Compare commits
16 Commits
psa-collec
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
925fc01a01 | ||
|
|
3bb9e7d178 | ||
|
|
d5386cd870 | ||
|
|
65a8b62825 | ||
|
|
6c917faad1 | ||
|
|
ee05baf508 | ||
|
|
f613e50f3a | ||
|
|
be920d1c91 | ||
|
|
be45611657 | ||
|
|
9b0805279d | ||
|
|
2e217e2902 | ||
|
|
0cf6c8a36f | ||
|
|
c0ef2f7ce2 | ||
|
|
c38773002d | ||
|
|
d178de893f | ||
|
|
a34570fc1a |
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.
|
|
||||||
40
README.md
40
README.md
@@ -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
|
||||||
|
|||||||
@@ -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>"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
io_scene_psk_psa/wheels/psk_psa_py-0.0.4-py3-none-any.whl
Normal file
BIN
io_scene_psk_psa/wheels/psk_psa_py-0.0.4-py3-none-any.whl
Normal file
Binary file not shown.
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