From bd667d4833fb9d36141d5c05903bdd66eea61713 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Thu, 14 Mar 2024 19:13:48 -0700 Subject: [PATCH 1/6] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a60f3f0..c64b47b 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ This Blender addon allows you to import and export meshes and animations to and | Blender Version | Addon Version | Long Term Support | |--------------------------------------------------------------|--------------------------------------------------------------------------------|-------------------| | 4.0+ | [latest](https://github.com/DarklightGames/io_scene_psk_psa/releases/latest) | TBD | -| [3.4 - 3.6](https://www.blender.org/download/lts/3-6/) | [5.0.5](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/5.0.5) | ✅️ June 2025 | +| [3.4 - 3.6](https://www.blender.org/download/lts/3-6/) | [5.0.6](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/5.0.6) | ✅️ June 2025 | | [2.93 - 3.3](https://www.blender.org/download/releases/3-3/) | [4.3.0](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/4.3.0) | ✅️ September 2024 | Bug fixes will be issued for legacy addon versions that are under [Blender's LTS maintenance period](https://www.blender.org/download/lts/). Once the LTS period has ended, legacy addon versions will no longer be supported by the maintainers of this repository, although we will accept pull requests for bug fixes. From 20b072f87b1239c79708932aaa646c8fceef2ee4 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Thu, 14 Mar 2024 18:55:28 -0700 Subject: [PATCH 2/6] Fix for root bone being incorrectly oriented if it wasn't at the identity rotation in the bind pose --- io_scene_psk_psa/psa/importer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index d9438f6..b70432b 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -153,7 +153,8 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, import_bone.original_rotation.conjugate() else: import_bone.original_location = armature_bone.matrix_local.translation.copy() - import_bone.original_rotation = armature_bone.matrix_local.to_quaternion() + import_bone.original_rotation = armature_bone.matrix_local.to_quaternion().conjugated() + import_bone.post_rotation = import_bone.original_rotation.conjugated() context.window_manager.progress_begin(0, len(sequences)) From 9c8b9d922bab34d1dc9c2fe8e8479b3f44da5420 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Mon, 25 Mar 2024 02:10:00 -0700 Subject: [PATCH 3/6] Fix for issue where using case insensitive bone mapping would fail Also made case-insensitive bone mapping the default for PSA import --- io_scene_psk_psa/psa/import_/properties.py | 3 ++- io_scene_psk_psa/psa/importer.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/io_scene_psk_psa/psa/import_/properties.py b/io_scene_psk_psa/psa/import_/properties.py index 43dd375..d3acead 100644 --- a/io_scene_psk_psa/psa/import_/properties.py +++ b/io_scene_psk_psa/psa/import_/properties.py @@ -71,7 +71,8 @@ class PSA_PG_import(PropertyGroup): ('EXACT', 'Exact', 'Bone names must match exactly.', 'EXACT', 0), ('CASE_INSENSITIVE', 'Case Insensitive', 'Bones names must match, ignoring case (e.g., the bone PSA bone ' '\'root\' can be mapped to the armature bone \'Root\')', 'CASE_INSENSITIVE', 1), - ) + ), + default='CASE_INSENSITIVE' ) fps_source: EnumProperty(name='FPS Source', items=( ('SEQUENCE', 'Sequence', 'The sequence frame rate matches the original frame rate', 'ACTION', 0), diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index b70432b..630e3f6 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -98,7 +98,7 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, 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: - psa_to_armature_bone_indices[psa_bone_index] = armature_bone_names.index(psa_bone_name) + psa_to_armature_bone_indices[psa_bone_index] = armature_bone_index armature_to_psa_bone_indices[armature_bone_index] = psa_bone_index else: # This armature bone has already been mapped to a PSA bone. From d92f2d77d2cad9cfa6bb3af32eb6b14bf874fbf6 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Mon, 25 Mar 2024 02:11:13 -0700 Subject: [PATCH 4/6] Incremented version to 6.2.1 --- io_scene_psk_psa/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/io_scene_psk_psa/__init__.py b/io_scene_psk_psa/__init__.py index 51a7a51..0a16e05 100644 --- a/io_scene_psk_psa/__init__.py +++ b/io_scene_psk_psa/__init__.py @@ -3,7 +3,7 @@ from bpy.app.handlers import persistent bl_info = { 'name': 'PSK/PSA Importer/Exporter', 'author': 'Colin Basnett, Yurii Ti', - 'version': (6, 2, 0), + 'version': (6, 2, 1), 'blender': (4, 0, 0), 'description': 'PSK/PSA Import/Export (.psk/.psa)', 'warning': '', From 09cc9e5d5115b1834adfdd7d70031d768e3ae13f Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Tue, 13 Feb 2024 14:03:04 -0800 Subject: [PATCH 5/6] Added PSA resampling Fixed PSA import resampling logic --- io_scene_psk_psa/psa/importer.py | 73 ++++++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 12 deletions(-) diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index 630e3f6..3b1a185 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -2,7 +2,7 @@ import typing from typing import List, Optional import bpy -import numpy +import numpy as np from bpy.types import FCurve, Object, Context from mathutils import Vector, Quaternion @@ -80,6 +80,52 @@ def _get_armature_bone_index_for_psa_bone(psa_bone_name: str, armature_bone_name return None +def _resample_sequence_data_matrix(sequence_data_matrix: np.ndarray, time_step: float = 1.0) -> np.ndarray: + ''' + Resamples the sequence data matrix to the target frame count. + @param sequence_data_matrix: FxBx7 matrix where F is the number of frames, B is the number of bones, and X is the + number of data elements per bone. + @param target_frame_count: The number of frames to resample to. + @return: The resampled sequence data matrix, or sequence_data_matrix if no resampling is necessary. + ''' + def get_sample_times(source_frame_count: int, time_step: float) -> typing.Iterable[float]: + # TODO: for correctness, we should also emit the target frame time as well (because the last frame can be a + # fractional frame). + time = 0.0 + while time < source_frame_count - 1: + yield time + time += time_step + yield source_frame_count - 1 + + if time_step == 1.0: + # No resampling is necessary. + return sequence_data_matrix + + source_frame_count, bone_count = sequence_data_matrix.shape[:2] + sample_times = list(get_sample_times(source_frame_count, time_step)) + target_frame_count = len(sample_times) + resampled_sequence_data_matrix = np.zeros((target_frame_count, bone_count, 7), dtype=float) + + for sample_index, sample_time in enumerate(sample_times): + frame_index = int(sample_time) + if sample_time % 1.0 == 0.0: + # Sample time has no fractional part, so just copy the frame. + resampled_sequence_data_matrix[sample_index, :, :] = sequence_data_matrix[frame_index, :, :] + else: + # Sample time has a fractional part, so interpolate between two frames. + next_frame_index = frame_index + 1 + for bone_index in range(bone_count): + source_frame_1_data = sequence_data_matrix[frame_index, bone_index, :] + source_frame_2_data = sequence_data_matrix[next_frame_index, bone_index, :] + factor = sample_time - frame_index + q = Quaternion((source_frame_1_data[:4])).slerp(Quaternion((source_frame_2_data[:4])), factor) + q.normalize() + l = Vector(source_frame_1_data[4:]).lerp(Vector(source_frame_2_data[4:]), factor) + resampled_sequence_data_matrix[sample_index, bone_index, :] = q.w, q.x, q.y, q.z, l.x, l.y, l.z + + return resampled_sequence_data_matrix + + def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, options: PsaImportOptions) -> PsaImportResult: result = PsaImportResult() sequences = [psa_reader.sequences[x] for x in options.sequence_names] @@ -187,12 +233,9 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, case _: raise ValueError(f'Unknown FPS source: {options.fps_source}') - keyframe_time_dilation = target_fps / sequence.fps - if options.should_write_keyframes: - # Remove existing f-curves (replace with action.fcurves.clear() in Blender 3.2) - while len(action.fcurves) > 0: - action.fcurves.remove(action.fcurves[-1]) + # Remove existing f-curves. + action.fcurves.clear() # Create f-curves for the rotation and location of each bone. for psa_bone_index, armature_bone_index in psa_to_armature_bone_indices.items(): @@ -226,19 +269,25 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, # Calculate the local-space key data for the bone. sequence_data_matrix[frame_index, bone_index] = _calculate_fcurve_data(import_bone, key_data) - # Write the keyframes out. - fcurve_data = numpy.zeros(2 * sequence.frame_count, dtype=float) + # Resample the sequence data to the target FPS. + # If the target frame count is the same as the source frame count, this will be a no-op. + resampled_sequence_data_matrix = _resample_sequence_data_matrix(sequence_data_matrix, + time_step=sequence.fps / target_fps) + + # Write the keyframes out. + # Note that the f-curve data consists of alternating time and value data. + target_frame_count = resampled_sequence_data_matrix.shape[0] + fcurve_data = np.zeros(2 * target_frame_count, dtype=float) + fcurve_data[0::2] = range(0, target_frame_count) - # Populate the keyframe time data. - fcurve_data[0::2] = [x * keyframe_time_dilation for x in range(sequence.frame_count)] for bone_index, import_bone in enumerate(import_bones): if import_bone is None: continue for fcurve_index, fcurve in enumerate(import_bone.fcurves): if fcurve is None: continue - fcurve_data[1::2] = sequence_data_matrix[:, bone_index, fcurve_index] - fcurve.keyframe_points.add(sequence.frame_count) + fcurve_data[1::2] = resampled_sequence_data_matrix[:, bone_index, fcurve_index] + fcurve.keyframe_points.add(target_frame_count) fcurve.keyframe_points.foreach_set('co', fcurve_data) for fcurve_keyframe in fcurve.keyframe_points: fcurve_keyframe.interpolation = 'LINEAR' From 44a55fc6981149936d8a56ea1e9acd867d992f97 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Mon, 25 Mar 2024 20:20:33 -0700 Subject: [PATCH 6/6] Fix for #83 Bones whose parents are not present in the PSA will now simply use the actual armature parent bone instead of failing the look-up and treating the bone as a root bone. --- io_scene_psk_psa/psa/importer.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index 3b1a185..0c0decd 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -64,12 +64,12 @@ class PsaImportResult: def _get_armature_bone_index_for_psa_bone(psa_bone_name: str, armature_bone_names: List[str], bone_mapping_mode: str = 'EXACT') -> 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' or 'CASE_INSENSITIVE'. @return: The index of the armature bone that corresponds to the given PSA bone, or None if no such bone exists. - ''' + """ 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(): @@ -173,7 +173,7 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, # Create intermediate bone data for import operations. import_bones = [] - import_bones_dict = dict() + psa_bone_names_to_import_bones = dict() for (psa_bone_index, psa_bone), psa_bone_name in zip(enumerate(psa_reader.bones), psa_bone_names): if psa_bone_index not in psa_to_armature_bone_indices: @@ -183,15 +183,22 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, import_bone = ImportBone(psa_bone) import_bone.armature_bone = armature_data.bones[psa_bone_name] import_bone.pose_bone = armature_object.pose.bones[psa_bone_name] - import_bones_dict[psa_bone_name] = import_bone + psa_bone_names_to_import_bones[psa_bone_name] = import_bone import_bones.append(import_bone) + bones_with_missing_parents = [] + for import_bone in filter(lambda x: x is not None, import_bones): armature_bone = import_bone.armature_bone - if armature_bone.parent is not None and armature_bone.parent.name in psa_bone_names: - import_bone.parent = import_bones_dict[armature_bone.parent.name] + has_parent = armature_bone.parent is not None + if has_parent: + if armature_bone.parent.name in psa_bone_names: + import_bone.parent = psa_bone_names_to_import_bones[armature_bone.parent.name] + else: + # Add a warning if the parent bone is not in the PSA. + bones_with_missing_parents.append(armature_bone) # Calculate the original location & rotation of each bone (in world-space maybe?) - if import_bone.parent is not None: + if has_parent: import_bone.original_location = armature_bone.matrix_local.translation - armature_bone.parent.matrix_local.translation import_bone.original_location.rotate(armature_bone.parent.matrix_local.to_quaternion().conjugated()) import_bone.original_rotation = armature_bone.matrix_local.to_quaternion() @@ -203,6 +210,12 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, import_bone.post_rotation = import_bone.original_rotation.conjugated() + # Warn about bones with missing parents. + if len(bones_with_missing_parents) > 0: + count = len(bones_with_missing_parents) + message = f'{count} bone(s) have parents that are not present in the PSA:\n' + str([x.name for x in bones_with_missing_parents]) + result.warnings.append(message) + context.window_manager.progress_begin(0, len(sequences)) # Create and populate the data for new sequences.