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:
|
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: |
|
||||||
|
|||||||
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,
|
||||||
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)
|
|
||||||
|
|||||||
@@ -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>"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
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