Compare commits

..

10 Commits
8.2.1 ... 8.2.4

Author SHA1 Message Date
Colin Basnett
37f7cc4d9f Increment version to 8.2.4 2025-11-08 18:28:21 -08:00
Colin Basnett
93083f09f8 Fix #135: Extra UV maps have incorrect data
This was caused by a regression caused by 29831d7f09.

The test for importing extra UVs has been updated to check that the data is different between the different UV layers.
2025-11-08 18:27:40 -08:00
Colin Basnett
75660f9dc1 Incremented version to 8.2.3 2025-10-31 12:43:09 -07:00
Colin Basnett
5421ac5151 Removed debugging code 2025-10-31 12:42:14 -07:00
Colin Basnett
9dcbb74058 Fix for missing transform source and broken scale controls on PSK export dialog 2025-10-31 12:41:14 -07:00
Colin Basnett
8ed985263c Fix for bad class inclusion 2025-10-06 17:57:55 -07:00
Colin Basnett
d91408ecab Incremented version to 8.2.2 2025-10-06 17:52:59 -07:00
Colin Basnett
dd1ea683bb Added bone mapping option to ignore trailing whitespace
Some very old PSKs and PSAs have trailing spaces in the bone names instead of padding the buffer with null bytes.

Trailing whitespace will now be ignored by default to maximize compatibility.
2025-10-06 17:48:51 -07:00
Colin Basnett
240b79d374 Fixed a number of PEP warnings
Most of these are just assert statements to silence the warnings for accessing optionals
2025-09-20 12:34:35 -07:00
Colin Basnett
33e7862288 Converted __init__ module initialisation to new pattern
Thanks to Dr. Sybren for the BCON25 presentation that tipped me off to the existence of `register_classes_factory`.
2025-09-18 23:29:14 -07:00
22 changed files with 284 additions and 122 deletions

View File

@@ -1,6 +1,41 @@
from bpy.app.handlers import persistent from bpy.app.handlers import persistent
if 'bpy' in locals(): from .shared import data as shared_data, types as shared_types, helpers as shared_helpers
from .shared import dfs as shared_dfs, ui as shared_ui
from .psk import (
builder as psk_builder,
data as psk_data,
importer as psk_importer,
properties as psk_properties,
writer as psk_writer,
)
from .psk import reader as psk_reader, 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 .psa import (
config as psa_config,
data as psa_data,
writer as psa_writer,
reader as psa_reader,
builder as psa_builder,
importer as psa_importer,
)
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
_needs_reload = 'bpy' in locals()
if _needs_reload:
import importlib import importlib
importlib.reload(shared_data) importlib.reload(shared_data)
@@ -33,58 +68,10 @@ if 'bpy' in locals():
importlib.reload(psa_import_properties) importlib.reload(psa_import_properties)
importlib.reload(psa_import_operators) importlib.reload(psa_import_operators)
importlib.reload(psa_import_ui) importlib.reload(psa_import_ui)
else:
from .shared import data as shared_data, types as shared_types, helpers as shared_helpers
from .shared import dfs as shared_dfs, ui as shared_ui
from .psk import (
builder as psk_builder,
data as psk_data,
importer as psk_importer,
properties as psk_properties,
writer as psk_writer,
)
from .psk import reader as psk_reader, 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 .psa import (
config as psa_config,
data as psa_data,
writer as psa_writer,
reader as psa_reader,
builder as psa_builder,
importer as psa_importer,
)
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
import bpy import bpy
from bpy.props import PointerProperty from bpy.props import PointerProperty
classes = shared_types.classes + \
shared_ui.classes + \
psk_properties.classes + \
psk_ui.classes + \
psk_import_operators.classes + \
psk_export_properties.classes + \
psk_export_operators.classes + \
psk_export_ui.classes + \
psa_export_properties.classes + \
psa_export_operators.classes + \
psa_export_ui.classes + \
psa_import_properties.classes + \
psa_import_operators.classes + \
psa_import_ui.classes
def psk_export_menu_func(self, context): def psk_export_menu_func(self, context):
self.layout.operator(psk_export_operators.PSK_OT_export.bl_idname, text='Unreal PSK (.psk)') self.layout.operator(psk_export_operators.PSK_OT_export.bl_idname, text='Unreal PSK (.psk)')
@@ -102,9 +89,26 @@ def psa_import_menu_func(self, context):
self.layout.operator(psa_import_operators.PSA_OT_import.bl_idname, text='Unreal PSA (.psa)') self.layout.operator(psa_import_operators.PSA_OT_import.bl_idname, text='Unreal PSA (.psa)')
_modules = (
shared_types,
shared_ui,
psk_properties,
psk_ui,
psk_import_operators,
psk_export_properties,
psk_export_operators,
psk_export_ui,
psa_export_properties,
psa_export_operators,
psa_export_ui,
psa_import_properties,
psa_import_operators,
psa_import_ui
)
def register(): def register():
for cls in classes: for module in _modules:
bpy.utils.register_class(cls) module.register()
bpy.types.TOPBAR_MT_file_export.append(psk_export_menu_func) bpy.types.TOPBAR_MT_file_export.append(psk_export_menu_func)
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)
@@ -128,8 +132,8 @@ def unregister():
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)
bpy.types.TOPBAR_MT_file_import.remove(psa_import_menu_func) bpy.types.TOPBAR_MT_file_import.remove(psa_import_menu_func)
for cls in reversed(classes): for module in reversed(_modules):
bpy.utils.unregister_class(cls) module.unregister()
if __name__ == '__main__': if __name__ == '__main__':

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 = "8.2.1" version = "8.2.4"
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

@@ -103,6 +103,10 @@ def _get_pose_bone_location_and_rotation(
def build_psa(context: Context, options: PsaBuildOptions) -> Psa: def build_psa(context: Context, options: PsaBuildOptions) -> Psa:
assert context.scene
assert context.window_manager
psa = Psa() psa = Psa()
armature_objects_for_bones = options.armature_objects armature_objects_for_bones = options.armature_objects
@@ -224,6 +228,7 @@ def build_psa(context: Context, options: PsaBuildOptions) -> Psa:
export_bones.append(PsaExportBone(None, None, Vector((1.0, 1.0, 1.0)))) export_bones.append(PsaExportBone(None, None, Vector((1.0, 1.0, 1.0))))
continue continue
assert armature_object.pose
pose_bone = armature_object.pose.bones[psx_bone.name.decode('windows-1252')] pose_bone = armature_object.pose.bones[psx_bone.name.decode('windows-1252')]
export_bones.append(PsaExportBone(pose_bone, armature_object, armature_scales[armature_object])) export_bones.append(PsaExportBone(pose_bone, armature_object, armature_scales[armature_object]))
@@ -321,6 +326,7 @@ def build_psa(context: Context, options: PsaBuildOptions) -> Psa:
# Restore the previous actions & frame. # Restore the previous actions & frame.
for armature_object, action in saved_armature_object_actions.items(): for armature_object, action in saved_armature_object_actions.items():
assert armature_object.animation_data
armature_object.animation_data.action = action armature_object.animation_data.action = action
context.scene.frame_set(saved_frame_current) context.scene.frame_set(saved_frame_current)

View File

@@ -462,6 +462,9 @@ class PSA_OT_export(Operator, ExportHelper):
if animation_data is None: if animation_data is None:
raise RuntimeError(f'No animation data for object \'{animation_data_object.name}\'') raise RuntimeError(f'No animation data for object \'{animation_data_object.name}\'')
if context.active_object is None:
raise RuntimeError('No active object')
export_sequences: List[PsaBuildSequence] = [] export_sequences: List[PsaBuildSequence] = []
match pg.sequence_source: match pg.sequence_source:
@@ -658,10 +661,14 @@ class PSA_OT_export_bone_collections_deselect_all(Operator):
return {'FINISHED'} return {'FINISHED'}
classes = ( _classes = (
PSA_OT_export, PSA_OT_export,
PSA_OT_export_actions_select_all, PSA_OT_export_actions_select_all,
PSA_OT_export_actions_deselect_all, PSA_OT_export_actions_deselect_all,
PSA_OT_export_bone_collections_select_all, PSA_OT_export_bone_collections_select_all,
PSA_OT_export_bone_collections_deselect_all, PSA_OT_export_bone_collections_deselect_all,
) )
from bpy.utils import register_classes_factory
register, unregister = register_classes_factory(_classes)

View File

@@ -132,6 +132,7 @@ sampling_mode_items = (
def sequence_source_update_cb(self: 'PSA_PG_export', context: Context) -> None: def sequence_source_update_cb(self: 'PSA_PG_export', context: Context) -> None:
armature_objects = [] armature_objects = []
assert context.view_layer
for dfs_object in dfs_view_layer_objects(context.view_layer): for dfs_object in dfs_view_layer_objects(context.view_layer):
if dfs_object.obj.type == 'ARMATURE' and dfs_object.is_selected: if dfs_object.obj.type == 'ARMATURE' and dfs_object.is_selected:
armature_objects.append(dfs_object.obj) armature_objects.append(dfs_object.obj)
@@ -262,10 +263,14 @@ def filter_sequences(pg: PSA_PG_export, sequences) -> List[int]:
return flt_flags return flt_flags
classes = ( _classes = (
PSA_PG_export_action_list_item, PSA_PG_export_action_list_item,
PSA_PG_export_timeline_markers, PSA_PG_export_timeline_markers,
PSA_PG_export_nla_strip_list_item, PSA_PG_export_nla_strip_list_item,
PSA_PG_export_active_action_list_item, PSA_PG_export_active_action_list_item,
PSA_PG_export, PSA_PG_export,
) )
from bpy.utils import register_classes_factory
register, unregister = register_classes_factory(_classes)

View File

@@ -52,6 +52,10 @@ class PSA_UL_export_sequences(UIList):
return flt_flags, flt_neworder return flt_flags, flt_neworder
classes = ( _classes = (
PSA_UL_export_sequences, PSA_UL_export_sequences,
) )
from bpy.utils import register_classes_factory
register, unregister = register_classes_factory(_classes)

View File

@@ -8,11 +8,12 @@ from bpy_extras.io_utils import ImportHelper
from .properties import PsaImportMixin, get_visible_sequences from .properties import PsaImportMixin, get_visible_sequences
from ..config import read_psa_config from ..config import read_psa_config
from ..importer import PsaImportOptions, import_psa from ..importer import BoneMapping, PsaImportOptions, import_psa
from ..reader import PsaReader from ..reader import PsaReader
def psa_import_poll(cls, context: Context): def psa_import_poll(cls, context: Context):
assert context.view_layer and context.view_layer.objects.active
active_object = context.view_layer.objects.active active_object = context.view_layer.objects.active
if active_object is None or active_object.type != 'ARMATURE': if active_object is None or active_object.type != 'ARMATURE':
cls.poll_message_set('The active object must be an armature') cls.poll_message_set('The active object must be an armature')
@@ -32,10 +33,12 @@ class PSA_OT_import_sequences_select_from_text(Operator):
return len(pg.sequence_list) > 0 return len(pg.sequence_list) > 0
def invoke(self, context, event): def invoke(self, context, event):
assert context.window_manager
return context.window_manager.invoke_props_dialog(self, width=256) return context.window_manager.invoke_props_dialog(self, width=256)
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
assert layout
pg = getattr(context.scene, 'psa_import') pg = getattr(context.scene, 'psa_import')
layout.label(icon='INFO', text='Each sequence name should be on a new line.') layout.label(icon='INFO', text='Each sequence name should be on a new line.')
layout.prop(pg, 'select_text', text='') layout.prop(pg, 'select_text', text='')
@@ -134,6 +137,8 @@ class PSA_OT_import_drag_and_drop(Operator, PsaImportMixin):
warnings = [] warnings = []
sequences_count = 0 sequences_count = 0
assert context.view_layer and context.view_layer.objects.active
for file in self.files: for file in self.files:
psa_path = str(os.path.join(self.directory, file.name)) psa_path = str(os.path.join(self.directory, file.name))
psa_reader = PsaReader(psa_path) psa_reader = PsaReader(psa_path)
@@ -157,12 +162,14 @@ class PSA_OT_import_drag_and_drop(Operator, PsaImportMixin):
def invoke(self, context: Context, event): def invoke(self, context: Context, event):
# Make sure the selected object is an obj. # Make sure the selected object is an obj.
assert context.view_layer and context.view_layer.objects.active
active_object = context.view_layer.objects.active active_object = context.view_layer.objects.active
if active_object is None or active_object.type != 'ARMATURE': if active_object is None or active_object.type != 'ARMATURE':
self.report({'ERROR_INVALID_CONTEXT'}, 'The active object must be an armature') self.report({'ERROR_INVALID_CONTEXT'}, 'The active object must be an armature')
return {'CANCELLED'} return {'CANCELLED'}
# Show the import operator properties in a pop-up dialog (do not use the file selector). # Show the import operator properties in a pop-up dialog (do not use the file selector).
assert context.window_manager
context.window_manager.invoke_props_dialog(self) context.window_manager.invoke_props_dialog(self)
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}
@@ -181,7 +188,10 @@ def psa_import_options_from_property_group(pg: PsaImportMixin, sequence_names: I
options.should_write_metadata = pg.should_write_metadata options.should_write_metadata = pg.should_write_metadata
options.should_write_keyframes = pg.should_write_keyframes options.should_write_keyframes = pg.should_write_keyframes
options.should_convert_to_samples = pg.should_convert_to_samples options.should_convert_to_samples = pg.should_convert_to_samples
options.bone_mapping_mode = pg.bone_mapping_mode options.bone_mapping = BoneMapping(
is_case_sensitive=pg.bone_mapping_is_case_sensitive,
should_ignore_trailing_whitespace=pg.bone_mapping_should_ignore_trailing_whitespace
)
options.fps_source = pg.fps_source options.fps_source = pg.fps_source
options.fps_custom = pg.fps_custom options.fps_custom = pg.fps_custom
options.translation_scale = pg.translation_scale options.translation_scale = pg.translation_scale
@@ -236,7 +246,10 @@ class PSA_OT_import_all(Operator, PsaImportMixin):
options = PsaImportOptions( options = PsaImportOptions(
action_name_prefix=self.action_name_prefix, action_name_prefix=self.action_name_prefix,
bone_mapping_mode=self.bone_mapping_mode, bone_mapping=BoneMapping(
is_case_sensitive=self.bone_mapping_is_case_sensitive,
should_ignore_trailing_whitespace=self.bone_mapping_should_ignore_trailing_whitespace
),
fps_custom=self.fps_custom, fps_custom=self.fps_custom,
fps_source=self.fps_source, fps_source=self.fps_source,
sequence_names=sequence_names, sequence_names=sequence_names,
@@ -250,6 +263,8 @@ class PSA_OT_import_all(Operator, PsaImportMixin):
translation_scale=self.translation_scale translation_scale=self.translation_scale
) )
assert context.view_layer
assert context.view_layer.objects.active
result = _import_psa(context, options, self.filepath, context.view_layer.objects.active) result = _import_psa(context, options, self.filepath, context.view_layer.objects.active)
if len(result.warnings) > 0: if len(result.warnings) > 0:
@@ -308,12 +323,13 @@ class PSA_OT_import(Operator, ImportHelper, PsaImportMixin):
def invoke(self, context: Context, event: Event): def invoke(self, context: Context, event: Event):
# Attempt to load the PSA file for the pre-selected file. # Attempt to load the PSA file for the pre-selected file.
load_psa_file(context, self.filepath) load_psa_file(context, self.filepath)
assert context.window_manager
context.window_manager.fileselect_add(self) context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}
def draw(self, context: Context): def draw(self, context: Context):
layout = self.layout layout = self.layout
assert layout
pg = getattr(context.scene, 'psa_import') pg = getattr(context.scene, 'psa_import')
sequences_header, sequences_panel = layout.panel('sequences_panel_id', default_closed=False) sequences_header, sequences_panel = layout.panel('sequences_panel_id', default_closed=False)
@@ -370,10 +386,14 @@ class PSA_OT_import(Operator, ImportHelper, PsaImportMixin):
advanced_header.label(text='Advanced') advanced_header.label(text='Advanced')
if advanced_panel: if advanced_panel:
col = advanced_panel.column() bone_mapping_header, bone_mapping_panel = layout.panel('bone_mapping_id', default_closed=False)
bone_mapping_header.label(text='Bone Mapping')
if bone_mapping_panel:
col = bone_mapping_panel.column()
col.use_property_split = True col.use_property_split = True
col.use_property_decorate = False col.use_property_decorate = False
col.prop(self, 'bone_mapping_mode') col.prop(self, 'bone_mapping_is_case_sensitive')
col.prop(self, 'bone_mapping_should_ignore_trailing_whitespace')
col = advanced_panel.column() col = advanced_panel.column()
col.use_property_split = True col.use_property_split = True
@@ -412,10 +432,15 @@ def draw_psa_import_options_no_panels(layout, pg: PsaImportMixin):
col.use_property_decorate = False col.use_property_decorate = False
col.prop(pg, 'should_convert_to_samples') col.prop(pg, 'should_convert_to_samples')
col = layout.column(heading='Bone Mapping')
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'bone_mapping_is_case_sensitive')
col.prop(pg, 'bone_mapping_should_ignore_trailing_whitespace')
col = layout.column() col = layout.column()
col.use_property_split = True col.use_property_split = True
col.use_property_decorate = False col.use_property_decorate = False
col.prop(pg, 'bone_mapping_mode')
col.prop(pg, 'translation_scale') col.prop(pg, 'translation_scale')
col = layout.column(heading='Options') col = layout.column(heading='Options')
@@ -434,11 +459,11 @@ class PSA_FH_import(FileHandler): # TODO: rename and add handling for PSA expor
bl_file_extensions = '.psa' bl_file_extensions = '.psa'
@classmethod @classmethod
def poll_drop(cls, context: Context): def poll_drop(cls, context: Context) -> bool:
return context.area and context.area.type == 'VIEW_3D' return context.area is not None and context.area.type == 'VIEW_3D'
classes = ( _classes = (
PSA_OT_import_sequences_select_all, PSA_OT_import_sequences_select_all,
PSA_OT_import_sequences_deselect_all, PSA_OT_import_sequences_deselect_all,
PSA_OT_import_sequences_select_from_text, PSA_OT_import_sequences_select_from_text,
@@ -447,3 +472,6 @@ classes = (
PSA_OT_import_drag_and_drop, PSA_OT_import_drag_and_drop,
PSA_FH_import, PSA_FH_import,
) )
from bpy.utils import register_classes_factory
register, unregister = register_classes_factory(_classes)

View File

@@ -80,12 +80,13 @@ class PsaImportMixin:
description='Convert keyframes to read-only samples. ' description='Convert keyframes to read-only samples. '
'Recommended if you do not plan on editing the actions directly' 'Recommended if you do not plan on editing the actions directly'
) )
bone_mapping_mode: EnumProperty( bone_mapping_is_case_sensitive: BoolProperty(
name='Bone Mapping', default=False,
options=set(), name='Case Sensitive'
description='The method by which bones from the incoming PSA file are mapped to the armature', )
items=bone_mapping_items, bone_mapping_should_ignore_trailing_whitespace: BoolProperty(
default='CASE_INSENSITIVE' default=True,
name='Ignore Trailing Whitespace'
) )
fps_source: EnumProperty(name='FPS Source', items=fps_source_items) fps_source: EnumProperty(name='FPS Source', items=fps_source_items)
fps_custom: FloatProperty( fps_custom: FloatProperty(
@@ -175,9 +176,12 @@ def get_visible_sequences(pg: PSA_PG_import, sequences) -> List[PSA_PG_import_ac
return visible_sequences return visible_sequences
classes = ( _classes = (
PSA_PG_import_action_list_item, PSA_PG_import_action_list_item,
PSA_PG_bone, PSA_PG_bone,
PSA_PG_data, PSA_PG_data,
PSA_PG_import, PSA_PG_import,
) )
from bpy.utils import register_classes_factory
register, unregister = register_classes_factory(_classes)

View File

@@ -42,8 +42,11 @@ class PSA_UL_import_actions(PSA_UL_sequences_mixin):
pass pass
classes = ( _classes = (
PSA_UL_sequences, PSA_UL_sequences,
PSA_UL_import_sequences, PSA_UL_import_sequences,
PSA_UL_import_actions, PSA_UL_import_actions,
) )
from bpy.utils import register_classes_factory
register, unregister = register_classes_factory(_classes)

View File

@@ -2,6 +2,7 @@ from typing import Sequence, Iterable, List, Optional, cast as typing_cast
import bpy import bpy
import numpy as np import numpy as np
import re
from bpy.types import Armature, Context, FCurve, Object, Bone, PoseBone from bpy.types import Armature, Context, FCurve, Object, Bone, PoseBone
from mathutils import Vector, Quaternion from mathutils import Vector, Quaternion
@@ -9,11 +10,22 @@ from .config import PsaConfig, REMOVE_TRACK_LOCATION, REMOVE_TRACK_ROTATION
from .reader import PsaReader from .reader import PsaReader
from ..shared.data import PsxBone from ..shared.data import PsxBone
class BoneMapping:
def __init__(self,
is_case_sensitive: bool = False,
should_ignore_trailing_whitespace: bool = True
):
self.is_case_sensitive = is_case_sensitive
# Ancient PSK and PSA exporters would, for some reason, pad the bone names with spaces
# instead of just writing null bytes, probably because the programmers were lazy.
# By default, we will ignore trailing whitespace when doing comparisons.
self.should_ignore_trailing_whitespace = should_ignore_trailing_whitespace
class PsaImportOptions(object): class PsaImportOptions(object):
def __init__(self, def __init__(self,
action_name_prefix: str = '', action_name_prefix: str = '',
bone_mapping_mode: str = 'CASE_INSENSITIVE', bone_mapping: BoneMapping = BoneMapping(),
fps_custom: float = 30.0, fps_custom: float = 30.0,
fps_source: str = 'SEQUENCE', fps_source: str = 'SEQUENCE',
psa_config: PsaConfig = PsaConfig(), psa_config: PsaConfig = PsaConfig(),
@@ -28,7 +40,7 @@ class PsaImportOptions(object):
translation_scale: float = 1.0 translation_scale: float = 1.0
): ):
self.action_name_prefix = action_name_prefix self.action_name_prefix = action_name_prefix
self.bone_mapping_mode = bone_mapping_mode self.bone_mapping = bone_mapping
self.fps_custom = fps_custom self.fps_custom = fps_custom
self.fps_source = fps_source self.fps_source = fps_source
self.psa_config = psa_config self.psa_config = psa_config
@@ -78,20 +90,30 @@ class PsaImportResult:
self.warnings: List[str] = [] self.warnings: List[str] = []
def _get_armature_bone_index_for_psa_bone(psa_bone_name: str, armature_bone_names: List[str], bone_mapping_mode: str = 'EXACT') -> Optional[int]: def _get_armature_bone_index_for_psa_bone(psa_bone_name: str, armature_bone_names: List[str], bone_mapping: BoneMapping) -> Optional[int]:
""" """
@param psa_bone_name: The name of the PSA bone. @param psa_bone_name: The name of the PSA bone.
@param armature_bone_names: The names of the bones in the armature. @param armature_bone_names: The names of the bones in the armature.
@param bone_mapping_mode: One of `['EXACT', 'CASE_INSENSITIVE']`. @param bone_mapping: Bone mapping information.
@return: The index of the armature bone that corresponds to the given PSA bone, or None if no such bone exists. @return: The index of the armature bone that corresponds to the given PSA bone, or None if no such bone exists.
""" """
# Use regular expressions for bone name matching.
pattern = psa_bone_name
flags = 0
if bone_mapping.should_ignore_trailing_whitespace:
psa_bone_name = psa_bone_name.rstrip()
pattern += r'\s*'
if not bone_mapping.is_case_sensitive:
flags = re.IGNORECASE
pattern = re.compile(pattern, flags)
for armature_bone_index, armature_bone_name in enumerate(armature_bone_names): for armature_bone_index, armature_bone_name in enumerate(armature_bone_names):
if bone_mapping_mode == 'CASE_INSENSITIVE': if re.fullmatch(pattern, armature_bone_name):
if armature_bone_name.lower() == psa_bone_name.lower():
return armature_bone_index
else:
if armature_bone_name == psa_bone_name:
return armature_bone_index return armature_bone_index
return None return None
@@ -145,6 +167,9 @@ def _resample_sequence_data_matrix(sequence_data_matrix: np.ndarray, frame_step:
def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, options: PsaImportOptions) -> PsaImportResult: def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, options: PsaImportOptions) -> PsaImportResult:
assert context.window_manager
result = PsaImportResult() result = PsaImportResult()
sequences = [psa_reader.sequences[x] for x in options.sequence_names] sequences = [psa_reader.sequences[x] for x in options.sequence_names]
armature_data = typing_cast(Armature, armature_object.data) armature_data = typing_cast(Armature, armature_object.data)
@@ -158,7 +183,7 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
for psa_bone_index, psa_bone in enumerate(psa_reader.bones): for psa_bone_index, psa_bone in enumerate(psa_reader.bones):
psa_bone_name: str = psa_bone.name.decode('windows-1252') psa_bone_name: str = psa_bone.name.decode('windows-1252')
armature_bone_index = _get_armature_bone_index_for_psa_bone(psa_bone_name, armature_bone_names, options.bone_mapping_mode) armature_bone_index = _get_armature_bone_index_for_psa_bone(psa_bone_name, armature_bone_names, options.bone_mapping)
if armature_bone_index is not None: if armature_bone_index is not None:
# Ensure that no other PSA bone has been mapped to this armature bone yet. # Ensure that no other PSA bone has been mapped to this armature bone yet.
if armature_bone_index not in armature_to_psa_bone_indices: if armature_bone_index not in armature_to_psa_bone_indices:
@@ -259,6 +284,7 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
case 'CUSTOM': case 'CUSTOM':
target_fps = options.fps_custom target_fps = options.fps_custom
case 'SCENE': case 'SCENE':
assert context.scene
target_fps = context.scene.render.fps target_fps = context.scene.render.fps
case 'SEQUENCE': case 'SEQUENCE':
target_fps = sequence.fps target_fps = sequence.fps

View File

@@ -1,7 +1,7 @@
import bmesh import bmesh
import bpy import bpy
import numpy as np import numpy as np
from bpy.types import Armature, Collection, Context, Depsgraph, Object, ArmatureModifier from bpy.types import Armature, Collection, Context, Depsgraph, Object, ArmatureModifier, Mesh
from mathutils import Matrix from mathutils import Matrix
from typing import Dict, Iterable, List, Optional, Set, cast as typing_cast from typing import Dict, Iterable, List, Optional, Set, cast as typing_cast
from .data import Psk from .data import Psk
@@ -94,9 +94,9 @@ def get_psk_input_objects_for_collection(collection: Collection) -> PskInputObje
class PskBuildResult(object): class PskBuildResult(object):
def __init__(self): def __init__(self, psk: Psk, warnings: list[str]):
self.psk = None self.psk: Psk = psk
self.warnings: List[str] = [] self.warnings: List[str] = warnings
def _get_mesh_export_space_matrix(armature_object: Optional[Object], export_space: str) -> Matrix: def _get_mesh_export_space_matrix(armature_object: Optional[Object], export_space: str) -> Matrix:
@@ -137,9 +137,12 @@ def _get_material_name_indices(obj: Object, material_names: List[str]) -> Iterab
def build_psk(context: Context, input_objects: PskInputObjects, options: PskBuildOptions) -> PskBuildResult: def build_psk(context: Context, input_objects: PskInputObjects, options: PskBuildOptions) -> PskBuildResult:
assert context.window_manager
armature_objects = list(input_objects.armature_objects) armature_objects = list(input_objects.armature_objects)
result = PskBuildResult() warnings: list[str] = []
psk = Psk() psk = Psk()
psx_bone_create_result = create_psx_bones( psx_bone_create_result = create_psx_bones(
@@ -208,7 +211,8 @@ def build_psk(context: Context, input_objects: PskInputObjects, options: PskBuil
# Temporarily force the armature into the rest position. # Temporarily force the armature into the rest position.
# We will undo this later. # We will undo this later.
for armature_object in armature_objects: for armature_object in armature_objects:
armature_object.data.pose_position = 'REST' armature_data = typing_cast(Armature, armature_object.data)
armature_data.pose_position = 'REST'
material_names = [m.name if m is not None else 'None' for m in materials] material_names = [m.name if m is not None else 'None' for m in materials]
@@ -232,7 +236,7 @@ def build_psk(context: Context, input_objects: PskInputObjects, options: PskBuil
match options.object_eval_state: match options.object_eval_state:
case 'ORIGINAL': case 'ORIGINAL':
mesh_object = obj mesh_object = obj
mesh_data = obj.data mesh_data = typing_cast(Mesh, obj.data)
case 'EVALUATED': case 'EVALUATED':
# Create a copy of the mesh object after non-armature modifiers are applied. # Create a copy of the mesh object after non-armature modifiers are applied.
depsgraph = context.evaluated_depsgraph_get() depsgraph = context.evaluated_depsgraph_get()
@@ -299,7 +303,7 @@ def build_psk(context: Context, input_objects: PskInputObjects, options: PskBuil
mesh_data.calc_loop_triangles() mesh_data.calc_loop_triangles()
if mesh_data.uv_layers.active is None: if mesh_data.uv_layers.active is None:
result.warnings.append(f'"{mesh_object.name}" has no active UV Map') warnings.append(f'"{mesh_object.name}" has no active UV Map')
# Build a list of non-unique wedges. # Build a list of non-unique wedges.
wedges = [] wedges = []
@@ -423,13 +427,12 @@ def build_psk(context: Context, input_objects: PskInputObjects, options: PskBuil
# Restore the original pose position of the armature objects. # Restore the original pose position of the armature objects.
for armature_object, pose_position in original_armature_object_pose_positions.items(): for armature_object, pose_position in original_armature_object_pose_positions.items():
armature_object.data.pose_position = pose_position armature_data = typing_cast(Armature, armature_object.data)
armature_data.pose_position = pose_position
# https://github.com/DarklightGames/io_scene_psk_psa/issues/129. # https://github.com/DarklightGames/io_scene_psk_psa/issues/129.
psk.sort_and_normalize_weights() psk.sort_and_normalize_weights()
context.window_manager.progress_end() context.window_manager.progress_end()
result.psk = psk return PskBuildResult(psk, warnings)
return result

View File

@@ -3,7 +3,7 @@ from typing import Iterable, List, Optional, cast as typing_cast
import bpy import bpy
from bpy.props import BoolProperty, StringProperty from bpy.props import BoolProperty, StringProperty
from bpy.types import Collection, Context, Depsgraph, Material, Object, Operator, SpaceProperties, Scene from bpy.types import Context, Depsgraph, Material, Object, Operator, Scene
from bpy_extras.io_utils import ExportHelper from bpy_extras.io_utils import ExportHelper
from .properties import PskExportMixin from .properties import PskExportMixin
@@ -91,6 +91,7 @@ class PSK_OT_populate_material_name_list(Operator):
self.report({'ERROR_INVALID_CONTEXT'}, 'No valid export operator found in context') self.report({'ERROR_INVALID_CONTEXT'}, 'No valid export operator found in context')
return {'CANCELLED'} return {'CANCELLED'}
depsgraph = context.evaluated_depsgraph_get() depsgraph = context.evaluated_depsgraph_get()
assert context.collection
input_objects = get_psk_input_objects_for_collection(context.collection) input_objects = get_psk_input_objects_for_collection(context.collection)
try: try:
populate_material_name_list(depsgraph, [x.obj for x in input_objects.mesh_dfs_objects], export_operator.material_name_list) populate_material_name_list(depsgraph, [x.obj for x in input_objects.mesh_dfs_objects], export_operator.material_name_list)
@@ -115,6 +116,7 @@ class PSK_OT_material_list_name_add(Operator):
name: StringProperty(search=material_list_names_search_cb, name='Material Name', default='None') name: StringProperty(search=material_list_names_search_cb, name='Material Name', default='None')
def invoke(self, context, event): def invoke(self, context, event):
assert context.window_manager
return context.window_manager.invoke_props_dialog(self) return context.window_manager.invoke_props_dialog(self)
def execute(self, context): def execute(self, context):
@@ -239,7 +241,7 @@ def get_psk_build_options_from_property_group(scene: Scene, pg: PskExportMixin)
match pg.transform_source: match pg.transform_source:
case 'SCENE': case 'SCENE':
transform_source = getattr(scene, 'psx_export') transform_source = getattr(scene, 'psx_export')
case 'SELF': case 'CUSTOM':
transform_source = pg transform_source = pg
case _: case _:
assert False, f'Invalid transform source: {pg.transform_source}' assert False, f'Invalid transform source: {pg.transform_source}'
@@ -266,7 +268,10 @@ class PSK_OT_export_collection(Operator, ExportHelper, PskExportMixin):
collection: StringProperty(options={'HIDDEN'}) collection: StringProperty(options={'HIDDEN'})
def execute(self, context): def execute(self, context):
collection = bpy.data.collections.get(self.collection) collection = bpy.data.collections.get(self.collection, None)
if collection is not None:
return {'CANCELLED'}
try: try:
input_objects = get_psk_input_objects_for_collection(collection) input_objects = get_psk_input_objects_for_collection(collection)
@@ -295,6 +300,8 @@ class PSK_OT_export_collection(Operator, ExportHelper, PskExportMixin):
def draw(self, context: Context): def draw(self, context: Context):
layout = self.layout layout = self.layout
assert layout is not None
flow = layout.grid_flow(row_major=True) flow = layout.grid_flow(row_major=True)
flow.use_property_split = True flow.use_property_split = True
flow.use_property_decorate = False flow.use_property_decorate = False
@@ -376,6 +383,8 @@ class PSK_OT_export_collection(Operator, ExportHelper, PskExportMixin):
flow.enabled = False flow.enabled = False
case 'CUSTOM': case 'CUSTOM':
transform_source = self transform_source = self
case _:
assert False, f'Invalid transform source: {self.transform_source}'
flow.prop(transform_source, 'scale') flow.prop(transform_source, 'scale')
flow.prop(transform_source, 'forward_axis') flow.prop(transform_source, 'forward_axis')
@@ -414,6 +423,7 @@ class PSK_OT_export(Operator, ExportHelper):
self.report({'ERROR_INVALID_CONTEXT'}, str(e)) self.report({'ERROR_INVALID_CONTEXT'}, str(e))
return {'CANCELLED'} return {'CANCELLED'}
assert context.window_manager
context.window_manager.fileselect_add(self) context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}
@@ -421,6 +431,8 @@ class PSK_OT_export(Operator, ExportHelper):
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
assert layout
pg = getattr(context.scene, 'psk_export') pg = getattr(context.scene, 'psk_export')
# Mesh # Mesh
@@ -474,9 +486,24 @@ class PSK_OT_export(Operator, ExportHelper):
flow.use_property_split = True flow.use_property_split = True
flow.use_property_decorate = False flow.use_property_decorate = False
flow.prop(pg, 'export_space') flow.prop(pg, 'export_space')
flow.prop(pg, 'scale') flow.prop(pg, 'transform_source')
flow.prop(pg, 'forward_axis')
flow.prop(pg, 'up_axis') flow = transform_panel.grid_flow(row_major=True)
flow.use_property_split = True
flow.use_property_decorate = False
match pg.transform_source:
case 'SCENE':
transform_source = getattr(context.scene, 'psx_export')
flow.enabled = False
case 'CUSTOM':
transform_source = pg
case _:
assert False, f'Invalid transform source: {pg.transform_source}'
flow.prop(transform_source, 'scale')
flow.prop(transform_source, 'forward_axis')
flow.prop(transform_source, 'up_axis')
# Extended Format # Extended Format
extended_format_header, extended_format_panel = layout.panel('Extended Format', default_closed=False) extended_format_header, extended_format_panel = layout.panel('Extended Format', default_closed=False)
@@ -490,6 +517,8 @@ class PSK_OT_export(Operator, ExportHelper):
def execute(self, context): def execute(self, context):
pg = getattr(context.scene, 'psk_export') pg = getattr(context.scene, 'psk_export')
assert context.scene
input_objects = get_psk_input_objects_for_context(context) input_objects = get_psk_input_objects_for_context(context)
options = get_psk_build_options_from_property_group(context.scene, pg) options = get_psk_build_options_from_property_group(context.scene, pg)
@@ -509,7 +538,7 @@ class PSK_OT_export(Operator, ExportHelper):
return {'FINISHED'} return {'FINISHED'}
classes = ( _classes = (
PSK_OT_material_list_move_up, PSK_OT_material_list_move_up,
PSK_OT_material_list_move_down, PSK_OT_material_list_move_down,
PSK_OT_export, PSK_OT_export,
@@ -521,3 +550,6 @@ classes = (
PSK_OT_material_list_name_move_down, PSK_OT_material_list_name_move_down,
PSK_OT_material_list_name_add, PSK_OT_material_list_name_add,
) )
from bpy.utils import register_classes_factory
register, unregister = register_classes_factory(_classes)

View File

@@ -52,7 +52,7 @@ class PskExportMixin(ExportSpaceMixin, TransformMixin, PsxBoneExportMixin):
material_name_list: CollectionProperty(type=PSK_PG_material_name_list_item) material_name_list: CollectionProperty(type=PSK_PG_material_name_list_item)
material_name_list_index: IntProperty(default=0) material_name_list_index: IntProperty(default=0)
should_export_vertex_normals: BoolProperty( should_export_vertex_normals: BoolProperty(
'Export Vertex Normals', name='Export Vertex Normals',
default=False, default=False,
description='Export VTXNORMS section.' description='Export VTXNORMS section.'
) )
@@ -67,8 +67,12 @@ class PSK_PG_export(PropertyGroup, PskExportMixin):
pass pass
classes = ( _classes = (
PSK_PG_material_list_item, PSK_PG_material_list_item,
PSK_PG_material_name_list_item, PSK_PG_material_name_list_item,
PSK_PG_export, PSK_PG_export,
) )
from bpy.utils import register_classes_factory
register, unregister = register_classes_factory(_classes)

View File

@@ -10,6 +10,9 @@ class PSK_UL_material_names(UIList):
row.prop(item, 'material_name', text='', emboss=False, icon_value=icon_value, icon='BLANK1' if icon_value == 0 else 'NONE') row.prop(item, 'material_name', text='', emboss=False, icon_value=icon_value, icon='BLANK1' if icon_value == 0 else 'NONE')
classes = ( _classes = (
PSK_UL_material_names, PSK_UL_material_names,
) )
from bpy.utils import register_classes_factory
register, unregister = register_classes_factory(_classes)

View File

@@ -9,6 +9,7 @@ from ..importer import PskImportOptions, import_psk
from ..properties import PskImportMixin from ..properties import PskImportMixin
from ..reader import read_psk from ..reader import read_psk
def get_psk_import_options_from_properties(property_group: PskImportMixin): def get_psk_import_options_from_properties(property_group: PskImportMixin):
options = PskImportOptions() options = PskImportOptions()
options.should_import_mesh = property_group.should_import_mesh options.should_import_mesh = property_group.should_import_mesh
@@ -109,6 +110,7 @@ class PSK_OT_import(Operator, ImportHelper, PskImportMixin):
return {'FINISHED'} return {'FINISHED'}
def draw(self, context): def draw(self, context):
assert self.layout
psk_import_draw(self.layout, self) psk_import_draw(self.layout, self)
@@ -122,13 +124,15 @@ class PSK_OT_import_drag_and_drop(Operator, PskImportMixin):
files: CollectionProperty(type=OperatorFileListElement, options={'SKIP_SAVE', 'HIDDEN'}) files: CollectionProperty(type=OperatorFileListElement, options={'SKIP_SAVE', 'HIDDEN'})
@classmethod @classmethod
def poll(cls, context): def poll(cls, context) -> bool:
return context.area and context.area.type == 'VIEW_3D' return context.area is not None and context.area.type == 'VIEW_3D'
def draw(self, context): def draw(self, context):
assert self.layout
psk_import_draw(self.layout, self) psk_import_draw(self.layout, self)
def invoke(self, context, event): def invoke(self, context, event):
assert context.window_manager
context.window_manager.invoke_props_dialog(self) context.window_manager.invoke_props_dialog(self)
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}
@@ -167,12 +171,15 @@ class PSK_FH_import(FileHandler):
bl_file_extensions = '.psk;.pskx' bl_file_extensions = '.psk;.pskx'
@classmethod @classmethod
def poll_drop(cls, context: Context): def poll_drop(cls, context: Context) -> bool:
return context.area and context.area.type == 'VIEW_3D' return context.area is not None and context.area.type == 'VIEW_3D'
classes = ( _classes = (
PSK_OT_import, PSK_OT_import,
PSK_OT_import_drag_and_drop, PSK_OT_import_drag_and_drop,
PSK_FH_import, PSK_FH_import,
) )
from bpy.utils import register_classes_factory
register, unregister = register_classes_factory(_classes)

View File

@@ -62,6 +62,9 @@ def import_psk(psk: Psk, context: Context, name: str, options: PskImportOptions)
armature_object = None armature_object = None
mesh_object = None mesh_object = None
assert context.scene
assert bpy.context.view_layer
if options.should_import_armature: if options.should_import_armature:
# Armature # Armature
armature_data = bpy.data.armatures.new(name) armature_data = bpy.data.armatures.new(name)
@@ -207,8 +210,9 @@ def import_psk(psk: Psk, context: Context, name: str, options: PskImportOptions)
for face_index, face in enumerate(psk.faces): for face_index, face in enumerate(psk.faces):
if face_index in invalid_face_indices: if face_index in invalid_face_indices:
continue continue
for wedge in map(lambda i: psk.wedges[i], reversed(face.wedge_indices)): for wedge_index in reversed(face.wedge_indices):
uv_layer_data[uv_layer_data_index] = wedge.u, 1.0 - wedge.v u, v = psk.extra_uvs[wedge_index_offset + wedge_index]
uv_layer_data[uv_layer_data_index] = u, 1.0 - v
uv_layer_data_index += 1 uv_layer_data_index += 1
wedge_index_offset += len(psk.wedges) wedge_index_offset += len(psk.wedges)
uv_layer = mesh_data.uv_layers.new(name=f'EXTRAUV{extra_uv_index}') uv_layer = mesh_data.uv_layers.new(name=f'EXTRAUV{extra_uv_index}')

View File

@@ -145,6 +145,10 @@ class PskImportMixin:
) )
classes = ( _classes = (
PSX_PG_material, PSX_PG_material,
) )
from bpy.utils import register_classes_factory
register, unregister = register_classes_factory(_classes)

View File

@@ -67,7 +67,7 @@ def read_psk(path: str) -> Psk:
case b'MRPHDATA': case b'MRPHDATA':
_read_types(fp, Psk.MorphData, section, psk.morph_data) _read_types(fp, Psk.MorphData, section, psk.morph_data)
case _: case _:
if section.name.startswith(b'EXTRAUVS'): if section.name.startswith(b'EXTRAUV'):
_read_types(fp, Vector2, section, psk.extra_uvs) _read_types(fp, Vector2, section, psk.extra_uvs)
else: else:
# Section is not handled, skip it. # Section is not handled, skip it.

View File

@@ -15,6 +15,7 @@ class PSK_PT_material(Panel):
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
assert layout is not None
layout.use_property_split = True layout.use_property_split = True
layout.use_property_decorate = False layout.use_property_decorate = False
material = context.material material = context.material
@@ -23,6 +24,10 @@ class PSK_PT_material(Panel):
col.prop(material.psk, 'mesh_triangle_bit_flags', expand=True, text='Flags') col.prop(material.psk, 'mesh_triangle_bit_flags', expand=True, text='Flags')
classes = ( _classes = (
PSK_PT_material, PSK_PT_material,
) )
from bpy.utils import register_classes_factory
register, unregister = register_classes_factory(_classes)

View File

@@ -154,10 +154,13 @@ class PSX_PG_scene_export(PropertyGroup, TransformMixin):
pass pass
classes = ( _classes = (
PSX_PG_scene_export, PSX_PG_scene_export,
PSX_PG_action_export, PSX_PG_action_export,
PSX_PG_bone_collection_list_item, PSX_PG_bone_collection_list_item,
PSX_UL_bone_collection_list, PSX_UL_bone_collection_list,
PSX_PT_action, PSX_PT_action,
) )
from bpy.utils import register_classes_factory
register, unregister = register_classes_factory(_classes)

View File

@@ -50,6 +50,10 @@ class PSX_PT_scene(Panel):
flow.prop(psx_export, 'up_axis') flow.prop(psx_export, 'up_axis')
classes = ( _classes = (
PSX_PT_scene, PSX_PT_scene,
) )
from bpy.utils import register_classes_factory
register, unregister = register_classes_factory(_classes)

View File

@@ -220,6 +220,12 @@ def test_psk_import_extra_uvs():
assert mesh_data.uv_layers[0].name == 'UVMap', "First UV layer should be named 'UVMap'" assert mesh_data.uv_layers[0].name == 'UVMap', "First UV layer should be named 'UVMap'"
assert mesh_data.uv_layers[1].name == 'EXTRAUV0', "Second UV layer should be named 'EXTRAUV0'" assert mesh_data.uv_layers[1].name == 'EXTRAUV0', "Second UV layer should be named 'EXTRAUV0'"
# Verify that the data is actually different
assert mesh_data.uv_layers[0].uv[0].vector.x == 0.92480468750
assert mesh_data.uv_layers[0].uv[0].vector.y == 0.90533447265625
assert mesh_data.uv_layers[1].uv[0].vector.x == 3.0517578125e-05
assert mesh_data.uv_layers[1].uv[0].vector.y == 0.999969482421875
def test_psk_import_materials(): def test_psk_import_materials():
assert bpy.ops.psk.import_file( assert bpy.ops.psk.import_file(