diff --git a/io_scene_psk_psa/psa/import_/operators.py b/io_scene_psk_psa/psa/import_/operators.py index 6fca9b5..7010a9c 100644 --- a/io_scene_psk_psa/psa/import_/operators.py +++ b/io_scene_psk_psa/psa/import_/operators.py @@ -8,7 +8,7 @@ from bpy_extras.io_utils import ImportHelper from .properties import PsaImportMixin, get_visible_sequences from ..config import read_psa_config -from ..importer import PsaImportOptions, import_psa +from ..importer import BoneMapping, PsaImportOptions, import_psa from ..reader import PsaReader @@ -188,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_keyframes = pg.should_write_keyframes 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_custom = pg.fps_custom options.translation_scale = pg.translation_scale @@ -243,7 +246,10 @@ class PSA_OT_import_all(Operator, PsaImportMixin): options = PsaImportOptions( 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_source=self.fps_source, sequence_names=sequence_names, @@ -380,10 +386,14 @@ class PSA_OT_import(Operator, ImportHelper, PsaImportMixin): advanced_header.label(text='Advanced') if advanced_panel: - col = advanced_panel.column() - col.use_property_split = True - col.use_property_decorate = False - col.prop(self, 'bone_mapping_mode') + 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_decorate = False + col.prop(self, 'bone_mapping_is_case_sensitive') + col.prop(self, 'bone_mapping_should_ignore_trailing_whitespace') col = advanced_panel.column() col.use_property_split = True @@ -422,10 +432,15 @@ def draw_psa_import_options_no_panels(layout, pg: PsaImportMixin): col.use_property_decorate = False 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.use_property_split = True col.use_property_decorate = False - col.prop(pg, 'bone_mapping_mode') col.prop(pg, 'translation_scale') col = layout.column(heading='Options') diff --git a/io_scene_psk_psa/psa/import_/properties.py b/io_scene_psk_psa/psa/import_/properties.py index 171c5b7..294fc42 100644 --- a/io_scene_psk_psa/psa/import_/properties.py +++ b/io_scene_psk_psa/psa/import_/properties.py @@ -80,12 +80,13 @@ class PsaImportMixin: description='Convert keyframes to read-only samples. ' 'Recommended if you do not plan on editing the actions directly' ) - bone_mapping_mode: EnumProperty( - name='Bone Mapping', - options=set(), - description='The method by which bones from the incoming PSA file are mapped to the armature', - items=bone_mapping_items, - default='CASE_INSENSITIVE' + bone_mapping_is_case_sensitive: BoolProperty( + default=False, + name='Case Sensitive' + ) + bone_mapping_should_ignore_trailing_whitespace: BoolProperty( + default=True, + name='Ignore Trailing Whitespace' ) fps_source: EnumProperty(name='FPS Source', items=fps_source_items) fps_custom: FloatProperty( diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index dc09d9e..6eb45c8 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -2,6 +2,7 @@ from typing import Sequence, Iterable, List, Optional, cast as typing_cast import bpy import numpy as np +import re from bpy.types import Armature, Context, FCurve, Object, Bone, PoseBone from mathutils import Vector, Quaternion @@ -9,11 +10,22 @@ from .config import PsaConfig, REMOVE_TRACK_LOCATION, REMOVE_TRACK_ROTATION from .reader import PsaReader 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): def __init__(self, action_name_prefix: str = '', - bone_mapping_mode: str = 'CASE_INSENSITIVE', + bone_mapping: BoneMapping = BoneMapping(), fps_custom: float = 30.0, fps_source: str = 'SEQUENCE', psa_config: PsaConfig = PsaConfig(), @@ -28,7 +40,7 @@ class PsaImportOptions(object): translation_scale: float = 1.0 ): 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_source = fps_source self.psa_config = psa_config @@ -78,20 +90,30 @@ class PsaImportResult: 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 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. """ + # 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): - if bone_mapping_mode == 'CASE_INSENSITIVE': - 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 + if re.fullmatch(pattern, armature_bone_name): + return armature_bone_index + return None @@ -161,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): 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: # 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: