From 4a9815edc26d0fd6b5be0769713feaf1ab8404b1 Mon Sep 17 00:00:00 2001 From: Colin Basnett <5035660+cmbasnett@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:52:08 -0800 Subject: [PATCH] Implement #142: Add support for `SCALEKEYS` --- Dockerfile | 3 +- io_scene_psk_psa/blender_manifest.toml | 4 +- io_scene_psk_psa/psa/import_/operators.py | 11 +++-- io_scene_psk_psa/psa/import_/properties.py | 1 + io_scene_psk_psa/psa/import_/properties.pyi | 1 + io_scene_psk_psa/psa/importer.py | 46 ++++++++++++++++-- .../wheels/psk_psa_py-0.0.1-py3-none-any.whl | Bin 13831 -> 0 bytes tests/psa_import_test.py | 26 ++++++++++ tests/requirements.txt | 2 +- 9 files changed, 81 insertions(+), 13 deletions(-) delete mode 100644 io_scene_psk_psa/wheels/psk_psa_py-0.0.1-py3-none-any.whl diff --git a/Dockerfile b/Dockerfile index 6017ba5..19fe885 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,9 +20,10 @@ RUN BLENDER_EXECUTABLE=$(blender-downloader $BLENDER_VERSION --extract --remove- RUN pip install pytest-cov # Source the environment variables and install Python dependencies +# TODO: would be nice to have these installed in the bash script below. RUN . /etc/environment && \ $BLENDER_PYTHON -m ensurepip && \ - $BLENDER_PYTHON -m pip install pytest pytest-cov psk-psa-py + $BLENDER_PYTHON -m pip install pytest pytest-cov psk-psa-py==0.0.4 # Persist BLENDER_EXECUTABLE as an environment variable RUN echo $(cat /blender_executable_path) > /tmp/blender_executable_path_env && \ diff --git a/io_scene_psk_psa/blender_manifest.toml b/io_scene_psk_psa/blender_manifest.toml index 75b1ff8..fd7ac90 100644 --- a/io_scene_psk_psa/blender_manifest.toml +++ b/io_scene_psk_psa/blender_manifest.toml @@ -1,6 +1,6 @@ schema_version = "1.0.0" id = "io_scene_psk_psa" -version = "9.0.2" +version = "9.1.0" name = "Unreal PSK/PSA (.psk/.psa)" tagline = "Import and export PSK and PSA files used in Unreal Engine" maintainer = "Colin Basnett " @@ -14,7 +14,7 @@ license = [ "SPDX:GPL-3.0-or-later", ] wheels = [ - './wheels/psk_psa_py-0.0.1-py3-none-any.whl' + './wheels/psk_psa_py-0.0.4-py3-none-any.whl' ] [build] diff --git a/io_scene_psk_psa/psa/import_/operators.py b/io_scene_psk_psa/psa/import_/operators.py index 84f3976..e8bc758 100644 --- a/io_scene_psk_psa/psa/import_/operators.py +++ b/io_scene_psk_psa/psa/import_/operators.py @@ -110,7 +110,7 @@ def load_psa_file(context, filepath: str): try: # Read the file and populate the action list. p = os.path.abspath(filepath) - psa_reader = PsaReader(p) + psa_reader = PsaReader.from_path(p) for sequence in psa_reader.sequences.values(): item = pg.sequence_list.add() item.action_name = sequence.name.decode('windows-1252') @@ -142,7 +142,7 @@ class PSA_OT_import_drag_and_drop(Operator, PsaImportMixin): for file in self.files: psa_path = str(os.path.join(self.directory, file.name)) - psa_reader = PsaReader(psa_path) + psa_reader = PsaReader.from_path(psa_path) sequence_names = list(psa_reader.sequences.keys()) options = psa_import_options_from_property_group(self, sequence_names) @@ -188,6 +188,7 @@ def psa_import_options_from_property_group(pg: PsaImportMixin, sequence_names: I options.should_overwrite = pg.should_overwrite options.should_write_metadata = pg.should_write_metadata options.should_write_keyframes = pg.should_write_keyframes + options.should_write_scale_keys = pg.should_write_scale_keys options.should_convert_to_samples = pg.should_convert_to_samples options.bone_mapping = BoneMapping( is_case_sensitive=pg.bone_mapping_is_case_sensitive, @@ -215,7 +216,7 @@ def _import_psa(context, except Exception as e: warnings.append(f'Failed to read PSA config file: {e}') - psa_reader = PsaReader(filepath) + psa_reader = PsaReader.from_path(filepath) result = import_psa(context, psa_reader, armature_object, options) result.warnings.extend(warnings) @@ -242,7 +243,7 @@ class PSA_OT_import_all(Operator, PsaImportMixin): def execute(self, context): sequence_names = [] - with PsaReader(self.filepath) as psa_reader: + with PsaReader.from_path(self.filepath) as psa_reader: sequence_names.extend(psa_reader.sequences.keys()) options = PsaImportOptions( @@ -376,6 +377,7 @@ class PSA_OT_import(Operator, ImportHelper, PsaImportMixin): col.use_property_decorate = False col.prop(self, 'should_write_keyframes') col.prop(self, 'should_write_metadata') + col.prop(self, 'should_write_scale_keys') if self.should_write_keyframes: col = col.column(heading='Keyframes') @@ -426,6 +428,7 @@ def draw_psa_import_options_no_panels(layout, pg: PsaImportMixin): col.use_property_decorate = False col.prop(pg, 'should_write_keyframes') col.prop(pg, 'should_write_metadata') + col.prop(pg, 'should_write_scale_keys') if pg.should_write_keyframes: col = col.column(heading='Keyframes') diff --git a/io_scene_psk_psa/psa/import_/properties.py b/io_scene_psk_psa/psa/import_/properties.py index 5a465dc..846abe8 100644 --- a/io_scene_psk_psa/psa/import_/properties.py +++ b/io_scene_psk_psa/psa/import_/properties.py @@ -66,6 +66,7 @@ class PsaImportMixin: should_write_metadata: BoolProperty(default=True, name='Metadata', options=set(), description='Additional data will be written to the custom properties of the ' 'Action (e.g., frame rate)') + should_write_scale_keys: BoolProperty(default=True, name='Scale Keys', options=set()) sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'}) sequence_filter_is_selected: BoolProperty(default=False, options=set(), name='Only Show Selected', description='Only show selected sequences') diff --git a/io_scene_psk_psa/psa/import_/properties.pyi b/io_scene_psk_psa/psa/import_/properties.pyi index 90f95d1..7dd6cd7 100644 --- a/io_scene_psk_psa/psa/import_/properties.pyi +++ b/io_scene_psk_psa/psa/import_/properties.pyi @@ -25,6 +25,7 @@ class PsaImportMixin: should_overwrite: bool should_write_keyframes: bool should_write_metadata: bool + should_write_scale_keys: bool sequence_filter_name: str sequence_filter_is_selected: bool sequence_use_filter_invert: bool diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index 584a119..b598d6a 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -40,6 +40,7 @@ class PsaImportOptions(object): should_use_fake_user: bool = False, should_write_keyframes: bool = True, should_write_metadata: bool = True, + should_write_scale_keys: bool = True, translation_scale: float = 1.0 ): self.action_name_prefix = action_name_prefix @@ -55,6 +56,7 @@ class PsaImportOptions(object): self.should_use_fake_user = should_use_fake_user self.should_write_keyframes = should_write_keyframes self.should_write_metadata = should_write_metadata + self.should_write_scale_keys = should_write_scale_keys self.translation_scale = translation_scale @@ -73,7 +75,7 @@ class ImportBone(object): def _calculate_fcurve_data(import_bone: ImportBone, key_data: Sequence[float]): # Convert world-space transforms to local-space transforms. key_rotation = Quaternion(key_data[0:4]) - key_location = Vector(key_data[4:]) + key_location = Vector(key_data[4:7]) q = import_bone.post_rotation.copy() q.rotate(import_bone.original_rotation) rotation = q @@ -85,7 +87,8 @@ def _calculate_fcurve_data(import_bone: ImportBone, key_data: Sequence[float]): rotation.rotate(q.conjugated()) location = key_location - import_bone.original_location location.rotate(import_bone.post_rotation.conjugated()) - return rotation.w, rotation.x, rotation.y, rotation.z, location.x, location.y, location.z + scale = Vector(key_data[7:10]) + return rotation.w, rotation.x, rotation.y, rotation.z, location.x, location.y, location.z, scale.x, scale.y, scale.z class PsaImportResult: @@ -169,6 +172,34 @@ def _resample_sequence_data_matrix(sequence_data_matrix: np.ndarray, frame_step: return resampled_sequence_data_matrix +def _read_sequence_data_matrix(psa_reader: PsaReader, sequence_name: str) -> np.ndarray: + """ + Reads and returns the data matrix for the given sequence. + The order of the data in the third axis is Qw, Qx, Qy, Qz, Lx, Ly, Lz, Sx, Sy, Sz + + @param sequence_name: The name of the sequence. + @return: An FxBx10 matrix where F is the number of frames, B is the number of bones. + """ + sequence = psa_reader.sequences[sequence_name] + keys = psa_reader.read_sequence_keys(sequence_name) + bone_count = len(psa_reader.bones) + matrix_size = sequence.frame_count, bone_count, 10 + matrix = np.ones(matrix_size) + keys_iter = iter(keys) + # Populate rotation and location data. + for frame_index in range(sequence.frame_count): + for bone_index in range(bone_count): + matrix[frame_index, bone_index, :7] = list(next(keys_iter).data) + # Populate scale data, if it exists. + scale_keys = psa_reader.read_sequence_scale_keys(sequence_name) + if len(scale_keys) > 0: + scale_keys_iter = iter(scale_keys) + for frame_index in range(sequence.frame_count): + for bone_index in range(bone_count): + matrix[frame_index, bone_index, 7:] = list(next(scale_keys_iter).data) + return matrix + + def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, options: PsaImportOptions) -> PsaImportResult: assert context.window_manager @@ -311,8 +342,10 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, pose_bone = import_bone.pose_bone rotation_data_path = pose_bone.path_from_id('rotation_quaternion') location_data_path = pose_bone.path_from_id('location') + scale_data_path = pose_bone.path_from_id('scale') add_rotation_fcurves = (bone_track_flags & REMOVE_TRACK_ROTATION) == 0 add_location_fcurves = (bone_track_flags & REMOVE_TRACK_LOCATION) == 0 + add_scale_fcurves = psa_reader.has_scale_keys and options.should_write_scale_keys import_bone.fcurves = [ channelbag.fcurves.ensure(rotation_data_path, index=0, group_name=pose_bone.name) if add_rotation_fcurves else None, # Qw channelbag.fcurves.ensure(rotation_data_path, index=1, group_name=pose_bone.name) if add_rotation_fcurves else None, # Qx @@ -321,14 +354,17 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, channelbag.fcurves.ensure(location_data_path, index=0, group_name=pose_bone.name) if add_location_fcurves else None, # Lx channelbag.fcurves.ensure(location_data_path, index=1, group_name=pose_bone.name) if add_location_fcurves else None, # Ly channelbag.fcurves.ensure(location_data_path, index=2, group_name=pose_bone.name) if add_location_fcurves else None, # Lz + channelbag.fcurves.ensure(scale_data_path, index=0, group_name=pose_bone.name) if add_scale_fcurves else None, # Sx + channelbag.fcurves.ensure(scale_data_path, index=1, group_name=pose_bone.name) if add_scale_fcurves else None, # Sy + channelbag.fcurves.ensure(scale_data_path, index=2, group_name=pose_bone.name) if add_scale_fcurves else None, # Sz ] # Read the sequence data matrix from the PSA. - sequence_data_matrix = psa_reader.read_sequence_data_matrix(sequence_name) + sequence_data_matrix = _read_sequence_data_matrix(psa_reader, sequence_name) if options.translation_scale != 1.0: # Scale the translation data. - sequence_data_matrix[:, :, 4:] *= options.translation_scale + sequence_data_matrix[:, :, 4:7] *= options.translation_scale # Convert the sequence's data from world-space to local-space. for bone_index, import_bone in enumerate(import_bones): @@ -366,7 +402,7 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, if options.should_convert_to_samples: # Bake the curve to samples. - for fcurve in action.fcurves: + for fcurve in channelbag.fcurves: fcurve.convert_to_samples(start=0, end=sequence.frame_count) # Write meta-data. diff --git a/io_scene_psk_psa/wheels/psk_psa_py-0.0.1-py3-none-any.whl b/io_scene_psk_psa/wheels/psk_psa_py-0.0.1-py3-none-any.whl deleted file mode 100644 index 95a54111b6bb9f20f27316f2904d8341344964b1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13831 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