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.
This commit is contained in:
Colin Basnett
2025-10-06 17:48:51 -07:00
parent 240b79d374
commit dd1ea683bb
3 changed files with 63 additions and 25 deletions

View File

@@ -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')

View File

@@ -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(

View File

@@ -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: