Compare commits

...

12 Commits

Author SHA1 Message Date
Colin Basnett
925fc01a01 Using Gitea-compatible upload-artifact action 2026-03-31 02:50:55 -07:00
Colin Basnett
3bb9e7d178 Fix for error caused by changing system packages from python3 invocation 2026-03-31 02:39:00 -07:00
Colin Basnett
d5386cd870 Workaround for bug with checkout step 2026-03-31 02:26:54 -07:00
Colin Basnett
65a8b62825 Upgrade checkout action to v4 2026-03-31 02:19:13 -07:00
Colin Basnett
6c917faad1 Better error reporting when there are multiple root bones 2026-03-02 19:59:06 -08:00
Colin Basnett
ee05baf508 Add some simple PSK export tests 2026-03-02 01:17:21 -08:00
Colin Basnett
f613e50f3a Fixed regression where exporting meshes without an armature would fail 2026-03-02 01:17:07 -08:00
Colin Basnett
be920d1c91 Incremented version to 9.1.1 2026-02-26 20:53:53 -08:00
Colin Basnett
be45611657 Fix PSK export 2026-02-26 20:50:11 -08:00
Colin Basnett
9b0805279d Standardized the format of the imports in the root __init__ file 2026-02-20 13:35:20 -08:00
Colin Basnett
2e217e2902 Removed unused imports 2026-02-20 13:35:02 -08:00
Colin Basnett
0cf6c8a36f Removed AGENTS.md 2026-02-20 10:34:10 -08:00
10 changed files with 127 additions and 64 deletions

View File

@@ -16,11 +16,16 @@ jobs:
env: env:
ADDON_NAME: io_scene_psk_psa ADDON_NAME: io_scene_psk_psa
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
with: with:
lfs: true persist-credentials: true
- name: Checkout LFS objects - name: Checkout LFS objects
run: git lfs checkout run: |
git lfs install --local
AUTH=$(git config --local http.${{ github.server_url }}/.extraheader)
git config --local --unset http.${{ github.server_url }}/.extraheader
git config --local http.${{ github.server_url }}/${{ github.repository }}.git/info/lfs/objects/batch.extraheader "$AUTH"
git lfs pull
- uses: SebRollen/toml-action@v1.2.0 - uses: SebRollen/toml-action@v1.2.0
id: read_manifest id: read_manifest
with: with:
@@ -38,8 +43,7 @@ jobs:
sudo apt-get install python3 -y sudo apt-get install python3 -y
- name: Install Requirements - name: Install Requirements
run: | run: |
python3 -m pip install --upgrade pip sudo apt-get install python3-virtualenv -y
python3 -m pip install virtualenv
python3 -m virtualenv venv python3 -m virtualenv venv
source venv/bin/activate source venv/bin/activate
pip install pytest-blender pip install pytest-blender
@@ -69,7 +73,7 @@ jobs:
source venv/bin/activate source venv/bin/activate
pytest -svv tests --blender-addons-dirs . pytest -svv tests --blender-addons-dirs .
- name: Archive addon - name: Archive addon
uses: actions/upload-artifact@v4 uses: https://github.com/christopherHX/gitea-upload-artifact@v4
with: with:
name: ${{ env.ADDON_NAME }}-${{ github.ref_name }}-${{ github.sha }} name: ${{ env.ADDON_NAME }}-${{ github.ref_name }}-${{ github.sha }}
path: | path: |

View File

@@ -1,12 +0,0 @@
# AGENTS.md
This is an Blender addon for importing and exporting Unreal Engine PSK (skeletal mesh) and PSX (animation) files.
# PSK/PSA File Format Notes
* PSK and PSA bone hierarchies must have a single root bone. The root bone's `parent_index` is always `0`.
* All indices in PSK/PSX files are zero-based.
* All string fields in PSK/PSX files use Windows-1252 encoding and are null-terminated if they do not use the full length of the field.
* Bone transforms are in parent bone space, except for root bones, which are in world space.
# Naming Conventions
* The `PSX` prefix is used when refer to concepts that are shared between PSK and PSX files.

View File

@@ -1,35 +1,40 @@
from bpy.app.handlers import persistent from .shared import (
types as shared_types,
from .shared import types as shared_types, helpers as shared_helpers helpers as shared_helpers,
from .shared import dfs as shared_dfs, ui as shared_ui dfs as shared_dfs,
from .shared import operators as shared_operators ui as shared_ui,
operators as shared_operators,
)
from .psk import ( from .psk import (
builder as psk_builder, builder as psk_builder,
importer as psk_importer, importer as psk_importer,
properties as psk_properties, properties as psk_properties,
ui as psk_ui,
) )
from .psk import ui as psk_ui
from .psk.export import ( from .psk.export import (
operators as psk_export_operators, operators as psk_export_operators,
properties as psk_export_properties, properties as psk_export_properties,
ui as psk_export_ui, ui as psk_export_ui,
) )
from .psk.import_ import operators as psk_import_operators from .psk.import_ import (
operators as psk_import_operators,
)
from .psa import ( from .psa import (
config as psa_config, config as psa_config,
builder as psa_builder, builder as psa_builder,
importer as psa_importer, importer as psa_importer,
file_handlers as psa_file_handlers,
) )
from .psa.export import ( from .psa.export import (
properties as psa_export_properties, properties as psa_export_properties,
ui as psa_export_ui, ui as psa_export_ui,
operators as psa_export_operators, operators as psa_export_operators,
) )
from .psa.import_ import operators as psa_import_operators from .psa.import_ import (
from .psa.import_ import ui as psa_import_ui, properties as psa_import_properties operators as psa_import_operators,
ui as psa_import_ui,
from .psa import file_handlers as psa_file_handlers properties as psa_import_properties,
)
_needs_reload = 'bpy' in locals() _needs_reload = 'bpy' in locals()
@@ -108,7 +113,6 @@ def register():
bpy.types.TOPBAR_MT_file_import.append(psk_import_menu_func) bpy.types.TOPBAR_MT_file_import.append(psk_import_menu_func)
bpy.types.TOPBAR_MT_file_export.append(psa_export_menu_func) bpy.types.TOPBAR_MT_file_export.append(psa_export_menu_func)
bpy.types.TOPBAR_MT_file_import.append(psa_import_menu_func) bpy.types.TOPBAR_MT_file_import.append(psa_import_menu_func)
setattr(bpy.types.Material, 'psk', PointerProperty(type=psk_properties.PSX_PG_material, options={'HIDDEN'})) setattr(bpy.types.Material, 'psk', PointerProperty(type=psk_properties.PSX_PG_material, options={'HIDDEN'}))
setattr(bpy.types.Scene, 'psx_export', PointerProperty(type=shared_types.PSX_PG_scene_export, options={'HIDDEN'})) setattr(bpy.types.Scene, 'psx_export', PointerProperty(type=shared_types.PSX_PG_scene_export, options={'HIDDEN'}))
setattr(bpy.types.Scene, 'psa_import', PointerProperty(type=psa_import_properties.PSA_PG_import, options={'HIDDEN'})) setattr(bpy.types.Scene, 'psa_import', PointerProperty(type=psa_import_properties.PSA_PG_import, options={'HIDDEN'}))
@@ -124,7 +128,6 @@ def unregister():
delattr(bpy.types.Scene, 'psa_export') delattr(bpy.types.Scene, 'psa_export')
delattr(bpy.types.Scene, 'psk_export') delattr(bpy.types.Scene, 'psk_export')
delattr(bpy.types.Action, 'psa_export') delattr(bpy.types.Action, 'psa_export')
bpy.types.TOPBAR_MT_file_export.remove(psk_export_menu_func) bpy.types.TOPBAR_MT_file_export.remove(psk_export_menu_func)
bpy.types.TOPBAR_MT_file_import.remove(psk_import_menu_func) bpy.types.TOPBAR_MT_file_import.remove(psk_import_menu_func)
bpy.types.TOPBAR_MT_file_export.remove(psa_export_menu_func) bpy.types.TOPBAR_MT_file_export.remove(psa_export_menu_func)
@@ -135,16 +138,3 @@ def unregister():
if __name__ == '__main__': if __name__ == '__main__':
register() register()
@persistent
def load_handler(dummy):
# Convert old `psa_sequence_fps` property to new `psa_export.fps` property.
# This is only needed for backwards compatibility with files that may have used older versions of the addon.
for action in bpy.data.actions:
if 'psa_sequence_fps' in action:
action.psa_export.fps = action['psa_sequence_fps']
del action['psa_sequence_fps']
bpy.app.handlers.load_post.append(load_handler)

View File

@@ -1,6 +1,6 @@
schema_version = "1.0.0" schema_version = "1.0.0"
id = "io_scene_psk_psa" id = "io_scene_psk_psa"
version = "9.1.0" version = "9.1.1"
name = "Unreal PSK/PSA (.psk/.psa)" name = "Unreal PSK/PSA (.psk/.psa)"
tagline = "Import and export PSK and PSA files used in Unreal Engine" tagline = "Import and export PSK and PSA files used in Unreal Engine"
maintainer = "Colin Basnett <cmbasnett@gmail.com>" maintainer = "Colin Basnett <cmbasnett@gmail.com>"

View File

@@ -1,10 +1,10 @@
import bmesh import bmesh
import bpy import bpy
import numpy as np import numpy as np
from bpy.types import Armature, Context, Object, Mesh, Material from bpy.types import Armature, Context, Object, Mesh
from mathutils import Matrix from mathutils import Matrix, Quaternion
from typing import Iterable, Sequence, cast as typing_cast from typing import Iterable, cast as typing_cast
from psk_psa_py.shared.data import Vector3 from psk_psa_py.shared.data import PsxBone, Vector3
from psk_psa_py.psk.data import Psk from psk_psa_py.psk.data import Psk
from .properties import triangle_type_and_bit_flags_to_poly_flags from .properties import triangle_type_and_bit_flags_to_poly_flags
from ..shared.helpers import ( from ..shared.helpers import (
@@ -12,6 +12,7 @@ from ..shared.helpers import (
ObjectTree, ObjectTree,
PskInputObjects, PskInputObjects,
PsxBoneCollection, PsxBoneCollection,
convert_bpy_quaternion_to_psx_quaternion,
convert_string_to_cp1252_bytes, convert_string_to_cp1252_bytes,
create_psx_bones, create_psx_bones,
get_armature_for_mesh_object, get_armature_for_mesh_object,
@@ -104,6 +105,14 @@ def build_psk(context: Context, input_objects: PskInputObjects, options: PskBuil
psk.bones = [bone.psx_bone for bone in psx_bone_create_result.bones] psk.bones = [bone.psx_bone for bone in psx_bone_create_result.bones]
if len(psk.bones) == 0:
# Add a default root bone if there are no bones to export.
# This is necessary because Unreal Engine requires at least one bone in the PSK file.
psx_bone = PsxBone()
psx_bone.name = b'ROOT'
psx_bone.rotation = convert_bpy_quaternion_to_psx_quaternion(Quaternion())
psk.bones.append(psx_bone)
# Materials # Materials
mesh_objects = [dfs_object.obj for dfs_object in input_objects.mesh_dfs_objects] mesh_objects = [dfs_object.obj for dfs_object in input_objects.mesh_dfs_objects]
@@ -309,18 +318,17 @@ def build_psk(context: Context, input_objects: PskInputObjects, options: PskBuil
poly_groups, groups = mesh_data.calc_smooth_groups(use_bitflags=True) poly_groups, groups = mesh_data.calc_smooth_groups(use_bitflags=True)
psk_face_start_index = len(psk.faces) psk_face_start_index = len(psk.faces)
for f in mesh_data.loop_triangles: for f in mesh_data.loop_triangles:
face = Psk.Face() face = Psk.Face(
face.material_index = material_indices[f.material_index] wedge_indices=(loop_wedge_indices[f.loops[2]], loop_wedge_indices[f.loops[1]], loop_wedge_indices[f.loops[0]]),
face.wedge_indices[0] = loop_wedge_indices[f.loops[2]] material_index=material_indices[f.material_index],
face.wedge_indices[1] = loop_wedge_indices[f.loops[1]] smoothing_groups=poly_groups[f.polygon_index],
face.wedge_indices[2] = loop_wedge_indices[f.loops[0]] )
face.smoothing_groups = poly_groups[f.polygon_index]
psk.faces.append(face) psk.faces.append(face)
if should_flip_normals: if should_flip_normals:
# Invert the normals of the faces. # Invert the normals of the faces.
for face in psk.faces[psk_face_start_index:]: for face in psk.faces[psk_face_start_index:]:
face.wedge_indices[0], face.wedge_indices[2] = face.wedge_indices[2], face.wedge_indices[0] face.wedge_indices = (face.wedge_indices[2], face.wedge_indices[1], face.wedge_indices[0])
# Weights # Weights
if armature_object is not None: if armature_object is not None:

View File

@@ -3,10 +3,9 @@ from bpy.props import (
CollectionProperty, CollectionProperty,
EnumProperty, EnumProperty,
IntProperty, IntProperty,
PointerProperty,
StringProperty, StringProperty,
) )
from bpy.types import Material, PropertyGroup from bpy.types import PropertyGroup
from ...shared.types import ExportSpaceMixin, TransformMixin, PsxBoneExportMixin, TransformSourceMixin from ...shared.types import ExportSpaceMixin, TransformMixin, PsxBoneExportMixin, TransformSourceMixin

View File

@@ -1,5 +1,3 @@
from bpy.types import Material
from ...shared.types import BpyCollectionProperty, ExportSpaceMixin, TransformMixin, PsxBoneExportMixin, TransformSourceMixin from ...shared.types import BpyCollectionProperty, ExportSpaceMixin, TransformMixin, PsxBoneExportMixin, TransformSourceMixin

View File

@@ -350,9 +350,12 @@ def create_psx_bones(
armature_tree = ObjectTree(armature_objects) armature_tree = ObjectTree(armature_objects)
if len(armature_tree.root_nodes) >= 2: if len(armature_tree.root_nodes) >= 2:
root_bone_names = []
for root_node in armature_tree.root_nodes:
root_bone_names.append(root_node.object.name)
raise RuntimeError( raise RuntimeError(
'Multiple root armature objects were found. ' f'Multiple root armature objects were found: {root_bone_names}.\n'
'Only one root armature object is allowed. ' 'Only one root armature object is allowed.\n'
'To use multiple armature objects, parent them to one another in a hierarchy using Bone parenting.' 'To use multiple armature objects, parent them to one another in a hierarchy using Bone parenting.'
) )
@@ -452,6 +455,14 @@ def create_psx_bones(
bones.extend(PsxBoneResult(psx_bone, armature_object) for psx_bone in armature_psx_bones) bones.extend(PsxBoneResult(psx_bone, armature_object) for psx_bone in armature_psx_bones)
# Check that we have any bones to export at this point. If not, we can skip the rest of the processing.
if len(bones) == 0:
return PsxBoneCreateResult(
bones=[],
armature_object_root_bone_indices=armature_object_root_bone_indices,
armature_object_bone_names=armature_object_bone_names,
)
# Check if any of the armatures are parented to one another. # Check if any of the armatures are parented to one another.
# If so, adjust the hierarchy as though they are part of the same armature object. # If so, adjust the hierarchy as though they are part of the same armature object.
# This will let us re-use rig components without destructively joining them. # This will let us re-use rig components without destructively joining them.

BIN
tests/data/psk_export_tests.blend LFS Normal file

Binary file not shown.

62
tests/psk_export_test.py Normal file
View File

@@ -0,0 +1,62 @@
import bpy
import pytest
import tempfile
from psk_psa_py.psk.reader import read_psk_from_file
@pytest.fixture(autouse=True)
def run_before_and_after_tests(tmpdir):
# Setup: Run before the tests
bpy.ops.wm.read_homefile(filepath='tests/data/psk_export_tests.blend')
yield
# Teardown: Run after the tests
pass
def export_psk_and_read_back(collection_name: str):
collection = bpy.data.collections.get(collection_name, None)
assert collection is not None, f"Collection {collection_name} not found in the scene."
# Select the collection to make it the active collection.
view_layer = bpy.context.view_layer
assert view_layer is not None, "No active view layer found."
view_layer.active_layer_collection = view_layer.layer_collection.children[collection_name]
filepath = str(tempfile.gettempdir() + f'/{collection_name}.psk')
collection.exporters[0].filepath = filepath
assert bpy.ops.collection.exporter_export() == {'FINISHED'}, "PSK export failed."
# Now load the exported PSK file and return its contents.
psk = read_psk_from_file(filepath)
return psk
def test_psk_export_cube_no_bones():
psk = export_psk_and_read_back('cube_no_bones')
# There should be one bone when no armature is present, this is added automatically to serve as the root bone for the mesh.
assert len(psk.bones) == 1, f"Expected 1 bone, but found {len(psk.bones)}."
assert len(psk.points) == 8, f"Expected 8 points, but found {len(psk.points)}."
assert len(psk.faces) == 12, f"Expected 12 faces, but found {len(psk.faces)}."
assert len(psk.materials) == 1, f"Expected 1 material, but found {len(psk.materials)}."
def test_cube_edge_split():
# The cube has all the edges set to split with a modifier.
psk = export_psk_and_read_back('cube_edge_split')
assert len(psk.bones) == 1, f"Expected 1 bone, but found {len(psk.bones)}."
assert len(psk.points) == 24, f"Expected 24 points, but found {len(psk.points)}."
assert len(psk.faces) == 12, f"Expected 12 faces, but found {len(psk.faces)}."
assert len(psk.materials) == 1, f"Expected 1 material, but found {len(psk.materials)}."
def test_cube_with_simple_armature():
# The cube has all the edges set to split with a modifier.
psk = export_psk_and_read_back('cube_with_simple_armature')
assert len(psk.bones) == 1, f"Expected 1 bone, but found {len(psk.bones)}."
assert psk.bones[0].name == b'ROOT', f"Expected bone name 'ROOT', but found {psk.bones[0].name}."