Compare commits

...

16 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
Colin Basnett
c0ef2f7ce2 Added missing psk_psa_py wheel 2026-02-17 01:02:29 -08:00
Dark Nation
c38773002d Update README.md 2026-02-17 08:56:34 +00:00
Colin Basnett
d178de893f Removed root_bone_name in more places 2026-02-16 19:31:06 -08:00
Colin Basnett
a34570fc1a Removed "Root Bone Name" property since it is no longer used 2026-02-16 19:29:52 -08:00
17 changed files with 154 additions and 132 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

@@ -13,37 +13,43 @@ For Blender 4.2 and higher, download the latest version from the [Blender Extens
For Blender 4.1 and lower, see [Legacy Compatibility](#legacy-compatibility). For Blender 4.1 and lower, see [Legacy Compatibility](#legacy-compatibility).
# Features # Features
* Full PSK/PSA import and export capabilities. * [Bone collections](https://docs.blender.org/manual/en/latest/animation/armatures/bones/bone_collections.html#bone-collections) can be excluded from PSK/PSA export (useful for excluding non-contributing bones such as IK controllers).
* Non-standard file section data (.pskx) is supported for import only (vertex normals, extra UV channels, vertex colors, shape keys). * [Collection Exporters](https://docs.blender.org/manual/en/latest/scene_layout/collections/collections.html#exporters) for reliable, repeatable export workflow.
* Non-standard model data (.pskx) is supported for import only (vertex normals, extra UV channels, vertex colors, shape keys).
* Manual re-ordering of material slots on export.
* Non-standard animation data is supported for import only (scale keys).
* Fine-grained PSA sequence importing for efficient workflow when working with large PSA files. * Fine-grained PSA sequence importing for efficient workflow when working with large PSA files.
* PSA sequence metadata (e.g., frame rate) is preserved on import, allowing this data to be reused on export. * PSA sequence metadata (e.g., frame rate) is preserved on import, allowing this data to be reused on export.
* [Bone collections](https://docs.blender.org/manual/en/latest/animation/armatures/bones/bone_collections.html#bone-collections) can be excluded from PSK/PSA export (useful for excluding non-contributing bones such as IK controllers).
* PSA sequences can be exported directly from actions or delineated using a scene's [timeline markers](https://docs.blender.org/manual/en/latest/animation/markers.html), pose markers, or NLA track strips, allowing direct use of the [NLA](https://docs.blender.org/manual/en/latest/editors/nla/index.html) when creating sequences. * PSA sequences can be exported directly from actions or delineated using a scene's [timeline markers](https://docs.blender.org/manual/en/latest/animation/markers.html), pose markers, or NLA track strips, allowing direct use of the [NLA](https://docs.blender.org/manual/en/latest/editors/nla/index.html) when creating sequences.
* Manual re-ordering of material slots. * Compress exported sequences via resampling ratios or frame quotas.
* Multiple armature objects can be exported to a single PSK or PSA file, allowing seamless use of [action slots](https://docs.blender.org/manual/en/latest/animation/actions.html#action-slots).
* Support for exporting instance collections.
# Usage # Usage
## Exporting a PSK
1. Select the mesh objects you wish to export.
2. Navigate to `File` > `Export` > `Unreal PSK (.psk)`.
3. Enter the file name and click `Export`.
## Importing a PSK/PSKX ## Import
### Importing a PSK/PSKX
1. Navigate to `File` > `Import` > `Unreal PSK (.psk/.pskx)`. 1. Navigate to `File` > `Import` > `Unreal PSK (.psk/.pskx)`.
2. Select the PSK file you want to import and click `Import`. 2. Select the PSK file you want to import and click `Import`.
## Exporting a PSA ### Importing a PSA
1. Select the armature objects you wish to export.
2. Navigate to `File` > `Export` > `Unreal PSA (.psa)`.
3. Enter the file name and click `Export`.
## Importing a PSA
1. Select an armature that you want import animations for. 1. Select an armature that you want import animations for.
2. Navigate to `File` > `Import` > `Unreal PSA (.psa)`. 2. Navigate to `File` > `Import` > `Unreal PSA (.psa)`.
3. Select the PSA file you want to import. 3. Select the PSA file you want to import.
4. Select the sequences that you want to import and click `Import`. 4. Select the sequences that you want to import and click `Import`.
## Export
It is highly recommended to use the provided [Collection Exporters](https://docs.blender.org/manual/en/latest/scene_layout/collections/collections.html#exporters) workflow, since it allows for highly reliable, repeatable exports of both PSK and PSA files. However, the traditional export workflow is available as well.
### Exporting a PSK
1. Select the mesh objects you wish to export.
2. Navigate to `File` > `Export` > `Unreal PSK (.psk)`.
3. Enter the file name and click `Export`.
### Exporting a PSA
1. Select the armature objects you wish to export.
2. Navigate to `File` > `Export` > `Unreal PSA (.psa)`.
3. Enter the file name and click `Export`.
> Note that in order to see the imported actions applied to your armature, you must use the [Dope Sheet](https://docs.blender.org/manual/en/latest/editors/dope_sheet/introduction.html) or [Nonlinear Animation](https://docs.blender.org/manual/en/latest/editors/nla/introduction.html) editors. > Note that in order to see the imported actions applied to your armature, you must use the [Dope Sheet](https://docs.blender.org/manual/en/latest/editors/dope_sheet/introduction.html) or [Nonlinear Animation](https://docs.blender.org/manual/en/latest/editors/nla/introduction.html) editors.
# FAQ # FAQ

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

@@ -39,7 +39,6 @@ class PsaBuildOptions:
self.export_space = 'WORLD' self.export_space = 'WORLD'
self.forward_axis = 'X' self.forward_axis = 'X'
self.up_axis = 'Z' self.up_axis = 'Z'
self.root_bone_name = 'ROOT'
self.sequence_source = 'ACTIONS' # One of ('ACTIONS', 'TIMELINE_MARKERS', 'NLA_STRIPS') self.sequence_source = 'ACTIONS' # One of ('ACTIONS', 'TIMELINE_MARKERS', 'NLA_STRIPS')
@property @property
@@ -162,7 +161,6 @@ def build_psa(context: Context, options: PsaBuildOptions) -> Psa:
psx_bone_create_result = create_psx_bones( psx_bone_create_result = create_psx_bones(
armature_objects=armature_objects_for_bones, armature_objects=armature_objects_for_bones,
export_space=options.export_space, export_space=options.export_space,
root_bone_name=options.root_bone_name,
forward_axis=options.forward_axis, forward_axis=options.forward_axis,
up_axis=options.up_axis, up_axis=options.up_axis,
scale=options.scale, scale=options.scale,

View File

@@ -346,14 +346,6 @@ class PSA_OT_export_collection(Operator, ExportHelper, PsaExportMixin):
op = col.operator(PSK_OT_bone_collection_list_select_all.bl_idname, text='', icon='CHECKBOX_DEHLT') op = col.operator(PSK_OT_bone_collection_list_select_all.bl_idname, text='', icon='CHECKBOX_DEHLT')
op.is_selected = False op.is_selected = False
advanced_bones_header, advanced_bones_panel = bones_panel.panel('Advanced', default_closed=True)
advanced_bones_header.label(text='Advanced')
if advanced_bones_panel:
flow = advanced_bones_panel.grid_flow(row_major=True)
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(self, 'root_bone_name')
# Transform # Transform
transform_header, transform_panel = layout.panel('Transform', default_closed=False) transform_header, transform_panel = layout.panel('Transform', default_closed=False)
transform_header.label(text='Transform', icon='DRIVER_TRANSFORM') transform_header.label(text='Transform', icon='DRIVER_TRANSFORM')
@@ -565,7 +557,6 @@ def create_psa_export_options(context: Context, armature_objects: Sequence[Objec
options.scale = pg.scale options.scale = pg.scale
options.forward_axis = pg.forward_axis options.forward_axis = pg.forward_axis
options.up_axis = pg.up_axis options.up_axis = pg.up_axis
options.root_bone_name = pg.root_bone_name
options.sequence_source = pg.sequence_source options.sequence_source = pg.sequence_source
return options return options
@@ -626,14 +617,6 @@ class PSA_OT_export(Operator, ExportHelper):
rows=rows rows=rows
) )
bones_advanced_header, bones_advanced_panel = bones_panel.panel('Bones Advanced', default_closed=True)
bones_advanced_header.label(text='Advanced')
if bones_advanced_panel:
flow = bones_advanced_panel.grid_flow()
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(pg, 'root_bone_name', text='Root Bone Name')
# TRANSFORM # TRANSFORM
transform_header, transform_panel = layout.panel('Advanced', default_closed=False) transform_header, transform_panel = layout.panel('Advanced', default_closed=False)
transform_header.label(text='Transform', icon='DRIVER_TRANSFORM') transform_header.label(text='Transform', icon='DRIVER_TRANSFORM')

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,
@@ -31,7 +32,6 @@ class PskBuildOptions(object):
self.export_space = 'WORLD' self.export_space = 'WORLD'
self.forward_axis = 'X' self.forward_axis = 'X'
self.up_axis = 'Z' self.up_axis = 'Z'
self.root_bone_name = 'ROOT'
class PskBuildResult(object): class PskBuildResult(object):
@@ -99,13 +99,20 @@ def build_psk(context: Context, input_objects: PskInputObjects, options: PskBuil
forward_axis=options.forward_axis, forward_axis=options.forward_axis,
up_axis=options.up_axis, up_axis=options.up_axis,
scale=options.scale, scale=options.scale,
root_bone_name=options.root_bone_name,
bone_filter_mode=options.bone_filter_mode, bone_filter_mode=options.bone_filter_mode,
bone_collection_indices=options.bone_collection_indices bone_collection_indices=options.bone_collection_indices
) )
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]
@@ -311,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

@@ -207,7 +207,6 @@ def get_psk_build_options_from_property_group(scene: Scene, pg: PskExportMixin)
options.export_space = pg.export_space options.export_space = pg.export_space
options.bone_filter_mode = pg.bone_filter_mode options.bone_filter_mode = pg.bone_filter_mode
options.bone_collection_indices = [PsxBoneCollection(x.armature_object_name, x.armature_data_name, x.index) for x in pg.bone_collection_list if x.is_selected] options.bone_collection_indices = [PsxBoneCollection(x.armature_object_name, x.armature_data_name, x.index) for x in pg.bone_collection_list if x.is_selected]
options.root_bone_name = pg.root_bone_name
options.material_order_mode = pg.material_order_mode options.material_order_mode = pg.material_order_mode
options.material_name_list = [x.material_name for x in pg.material_name_list] options.material_name_list = [x.material_name for x in pg.material_name_list]
@@ -309,14 +308,6 @@ class PSK_OT_export_collection(Operator, ExportHelper, PskExportMixin):
op = col.operator(PSK_OT_bone_collection_list_select_all.bl_idname, text='', icon='CHECKBOX_DEHLT') op = col.operator(PSK_OT_bone_collection_list_select_all.bl_idname, text='', icon='CHECKBOX_DEHLT')
op.is_selected = False op.is_selected = False
advanced_bones_header, advanced_bones_panel = bones_panel.panel('Advanced', default_closed=True)
advanced_bones_header.label(text='Advanced')
if advanced_bones_panel:
flow = advanced_bones_panel.grid_flow(row_major=True)
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(self, 'root_bone_name')
# Materials # Materials
materials_header, materials_panel = layout.panel('Materials', default_closed=False) materials_header, materials_panel = layout.panel('Materials', default_closed=False)
materials_header.label(text='Materials', icon='MATERIAL') materials_header.label(text='Materials', icon='MATERIAL')
@@ -429,13 +420,6 @@ class PSK_OT_export(Operator, ExportHelper):
row = bones_panel.row() row = bones_panel.row()
rows = max(3, min(len(pg.bone_collection_list), 10)) rows = max(3, min(len(pg.bone_collection_list), 10))
row.template_list('PSX_UL_bone_collection_list', '', pg, 'bone_collection_list', pg, 'bone_collection_list_index', rows=rows) row.template_list('PSX_UL_bone_collection_list', '', pg, 'bone_collection_list', pg, 'bone_collection_list_index', rows=rows)
bones_advanced_header, bones_advanced_panel = bones_panel.panel('Advanced', default_closed=True)
bones_advanced_header.label(text='Advanced')
if bones_advanced_panel:
flow = bones_advanced_panel.grid_flow(row_major=True)
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(pg, 'root_bone_name')
# Materials # Materials
materials_header, materials_panel = layout.panel('Materials', default_closed=False) materials_header, materials_panel = layout.panel('Materials', default_closed=False)

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

@@ -332,7 +332,6 @@ class ObjectTree:
def create_psx_bones( def create_psx_bones(
armature_objects: list[Object], armature_objects: list[Object],
export_space: str = 'WORLD', export_space: str = 'WORLD',
root_bone_name: str = 'ROOT',
forward_axis: str = 'X', forward_axis: str = 'X',
up_axis: str = 'Z', up_axis: str = 'Z',
scale: float = 1.0, scale: float = 1.0,
@@ -351,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.'
) )
@@ -453,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.
@@ -517,13 +527,10 @@ def create_psx_bones(
bone_name_counts = Counter(bone.psx_bone.name.decode('windows-1252').upper() for bone in bones) bone_name_counts = Counter(bone.psx_bone.name.decode('windows-1252').upper() for bone in bones)
for bone_name, count in bone_name_counts.items(): for bone_name, count in bone_name_counts.items():
if count > 1: if count > 1:
error_message = f'Found {count} bones with the name "{bone_name}". ' raise RuntimeError(
f'Bone names must be unique when compared case-insensitively.' f'Found {count} bones with the name "{bone_name}". '
f'Bone names must be unique when compared case-insensitively.'
if len(armature_objects) > 1 and bone_name == root_bone_name.upper(): )
error_message += f' This is the name of the automatically generated root bone. Consider changing this '
f''
raise RuntimeError(error_message)
# Apply the scale to the bone locations. # Apply the scale to the bone locations.
for bone in bones: for bone in bones:

View File

@@ -179,11 +179,6 @@ class PsxBoneExportMixin:
) )
bone_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item) bone_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item)
bone_collection_list_index: IntProperty(default=0, name='', description='') bone_collection_list_index: IntProperty(default=0, name='', description='')
root_bone_name: StringProperty(
name='Root Bone Name',
description='The name of the root bone when exporting a PSK with either no armature or multiple armatures',
default='ROOT',
)
class PSX_PG_scene_export(PropertyGroup, TransformMixin): class PSX_PG_scene_export(PropertyGroup, TransformMixin):

View File

@@ -54,7 +54,6 @@ class PsxBoneExportMixin:
bone_filter_mode: str bone_filter_mode: str
bone_collection_list: BpyCollectionProperty[PSX_PG_bone_collection_list_item] bone_collection_list: BpyCollectionProperty[PSX_PG_bone_collection_list_item]
bone_collection_list_index: int bone_collection_list_index: int
root_bone_name: str
class PSX_PG_scene_export(TransformSourceMixin): class PSX_PG_scene_export(TransformSourceMixin):

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