Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
925fc01a01 | ||
|
|
3bb9e7d178 | ||
|
|
d5386cd870 | ||
|
|
65a8b62825 | ||
|
|
6c917faad1 | ||
|
|
ee05baf508 | ||
|
|
f613e50f3a | ||
|
|
be920d1c91 | ||
|
|
be45611657 | ||
|
|
9b0805279d | ||
|
|
2e217e2902 | ||
|
|
0cf6c8a36f |
16
.github/workflows/main.yml
vendored
16
.github/workflows/main.yml
vendored
@@ -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: |
|
||||
|
||||
12
AGENTS.md
12
AGENTS.md
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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>"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from bpy.types import Material
|
||||
|
||||
from ...shared.types import BpyCollectionProperty, ExportSpaceMixin, TransformMixin, PsxBoneExportMixin, TransformSourceMixin
|
||||
|
||||
|
||||
|
||||
@@ -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
BIN
tests/data/psk_export_tests.blend
LFS
Normal file
Binary file not shown.
62
tests/psk_export_test.py
Normal file
62
tests/psk_export_test.py
Normal 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}."
|
||||
Reference in New Issue
Block a user