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:
ADDON_NAME: io_scene_psk_psa
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
lfs: true
persist-credentials: true
- 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
id: read_manifest
with:
@@ -38,8 +43,7 @@ jobs:
sudo apt-get install python3 -y
- name: Install Requirements
run: |
python3 -m pip install --upgrade pip
python3 -m pip install virtualenv
sudo apt-get install python3-virtualenv -y
python3 -m virtualenv venv
source venv/bin/activate
pip install pytest-blender
@@ -69,7 +73,7 @@ jobs:
source venv/bin/activate
pytest -svv tests --blender-addons-dirs .
- name: Archive addon
uses: actions/upload-artifact@v4
uses: https://github.com/christopherHX/gitea-upload-artifact@v4
with:
name: ${{ env.ADDON_NAME }}-${{ github.ref_name }}-${{ github.sha }}
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, helpers as shared_helpers
from .shared import dfs as shared_dfs, ui as shared_ui
from .shared import operators as shared_operators
from .shared import (
types as shared_types,
helpers as shared_helpers,
dfs as shared_dfs,
ui as shared_ui,
operators as shared_operators,
)
from .psk import (
builder as psk_builder,
importer as psk_importer,
properties as psk_properties,
ui as psk_ui,
)
from .psk import ui as psk_ui
from .psk.export import (
operators as psk_export_operators,
properties as psk_export_properties,
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 (
config as psa_config,
builder as psa_builder,
importer as psa_importer,
file_handlers as psa_file_handlers,
)
from .psa.export import (
properties as psa_export_properties,
ui as psa_export_ui,
operators as psa_export_operators,
)
from .psa.import_ import operators as psa_import_operators
from .psa.import_ import ui as psa_import_ui, properties as psa_import_properties
from .psa import file_handlers as psa_file_handlers
from .psa.import_ import (
operators as psa_import_operators,
ui as psa_import_ui,
properties as psa_import_properties,
)
_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_export.append(psa_export_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.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'}))
@@ -124,7 +128,6 @@ def unregister():
delattr(bpy.types.Scene, 'psa_export')
delattr(bpy.types.Scene, 'psk_export')
delattr(bpy.types.Action, 'psa_export')
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_export.remove(psa_export_menu_func)
@@ -135,16 +138,3 @@ def unregister():
if __name__ == '__main__':
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"
id = "io_scene_psk_psa"
version = "9.1.0"
version = "9.1.1"
name = "Unreal PSK/PSA (.psk/.psa)"
tagline = "Import and export PSK and PSA files used in Unreal Engine"
maintainer = "Colin Basnett <cmbasnett@gmail.com>"

View File

@@ -1,10 +1,10 @@
import bmesh
import bpy
import numpy as np
from bpy.types import Armature, Context, Object, Mesh, Material
from mathutils import Matrix
from typing import Iterable, Sequence, cast as typing_cast
from psk_psa_py.shared.data import Vector3
from bpy.types import Armature, Context, Object, Mesh
from mathutils import Matrix, Quaternion
from typing import Iterable, cast as typing_cast
from psk_psa_py.shared.data import PsxBone, Vector3
from psk_psa_py.psk.data import Psk
from .properties import triangle_type_and_bit_flags_to_poly_flags
from ..shared.helpers import (
@@ -12,6 +12,7 @@ from ..shared.helpers import (
ObjectTree,
PskInputObjects,
PsxBoneCollection,
convert_bpy_quaternion_to_psx_quaternion,
convert_string_to_cp1252_bytes,
create_psx_bones,
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]
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
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)
psk_face_start_index = len(psk.faces)
for f in mesh_data.loop_triangles:
face = Psk.Face()
face.material_index = material_indices[f.material_index]
face.wedge_indices[0] = loop_wedge_indices[f.loops[2]]
face.wedge_indices[1] = loop_wedge_indices[f.loops[1]]
face.wedge_indices[2] = loop_wedge_indices[f.loops[0]]
face.smoothing_groups = poly_groups[f.polygon_index]
face = Psk.Face(
wedge_indices=(loop_wedge_indices[f.loops[2]], loop_wedge_indices[f.loops[1]], loop_wedge_indices[f.loops[0]]),
material_index=material_indices[f.material_index],
smoothing_groups=poly_groups[f.polygon_index],
)
psk.faces.append(face)
if should_flip_normals:
# Invert the normals of the faces.
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
if armature_object is not None:

View File

@@ -3,10 +3,9 @@ from bpy.props import (
CollectionProperty,
EnumProperty,
IntProperty,
PointerProperty,
StringProperty,
)
from bpy.types import Material, PropertyGroup
from bpy.types import PropertyGroup
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

View File

@@ -350,9 +350,12 @@ def create_psx_bones(
armature_tree = ObjectTree(armature_objects)
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(
'Multiple root armature objects were found. '
'Only one root armature object is allowed. '
f'Multiple root armature objects were found: {root_bone_names}.\n'
'Only one root armature object is allowed.\n'
'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)
# 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.
# 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.

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}."