From 11bf205fe2aaf16ffed1acfcd1cc4fa770eba674 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Tue, 13 Feb 2024 14:03:04 -0800 Subject: [PATCH] Added PSA resampling on import + some fixes for 4.1 --- io_scene_psk_psa/psa/export/operators.py | 19 +++--- io_scene_psk_psa/psa/import_/operators.py | 24 ++------ io_scene_psk_psa/psa/importer.py | 74 +++++++++++++++++++---- io_scene_psk_psa/psk/import_/operators.py | 27 +++++---- 4 files changed, 92 insertions(+), 52 deletions(-) diff --git a/io_scene_psk_psa/psa/export/operators.py b/io_scene_psk_psa/psa/export/operators.py index a44f02c..0aa46bb 100644 --- a/io_scene_psk_psa/psa/export/operators.py +++ b/io_scene_psk_psa/psa/export/operators.py @@ -91,15 +91,16 @@ def update_actions_and_timeline_markers(context: Context, armature: Armature): def get_sequence_fps(context: Context, fps_source: str, fps_custom: float, actions: Iterable[Action]) -> float: - if fps_source == 'SCENE': - return context.scene.render.fps - elif fps_source == 'CUSTOM': - return fps_custom - elif fps_source == 'ACTION_METADATA': - # Get the minimum value of action metadata FPS values. - return min([action.psa_export.fps for action in actions]) - else: - raise RuntimeError(f'Invalid FPS source "{fps_source}"') + match fps_source: + case 'SCENE': + return context.scene.render.fps + case 'CUSTOM': + return fps_custom + case 'ACTION_METADATA': + # Get the minimum value of action metadata FPS values. + return min([action.psa_export.fps for action in actions]) + case _: + raise RuntimeError(f'Invalid FPS source "{fps_source}"') def get_animation_data_object(context: Context) -> Object: diff --git a/io_scene_psk_psa/psa/import_/operators.py b/io_scene_psk_psa/psa/import_/operators.py index 7891a32..c588208 100644 --- a/io_scene_psk_psa/psa/import_/operators.py +++ b/io_scene_psk_psa/psa/import_/operators.py @@ -2,7 +2,7 @@ import os from pathlib import Path from bpy.props import StringProperty -from bpy.types import Operator, Event, Context +from bpy.types import Operator, Event, Context, FileHandler from bpy_extras.io_utils import ImportHelper from .properties import get_visible_sequences @@ -89,23 +89,6 @@ class PSA_OT_import_sequences_deselect_all(Operator): return {'FINISHED'} -class PSA_OT_import_select_file(Operator): - bl_idname = 'psa_import.select_file' - bl_label = 'Select' - bl_options = {'INTERNAL'} - bl_description = 'Select a PSA file from which to import animations' - filepath: StringProperty(subtype='FILE_PATH') - filter_glob: StringProperty(default='*.psa', options={'HIDDEN'}) - - def execute(self, context): - getattr(context.scene, 'psa_import').psa_file_path = self.filepath - return {'FINISHED'} - - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {'RUNNING_MODAL'} - - def load_psa_file(context, filepath: str): pg = context.scene.psa_import pg.sequence_list.clear() @@ -270,10 +253,13 @@ class PSA_OT_import(Operator, ImportHelper): col.prop(pg, 'action_name_prefix') +class PSA_FH_import(FileHandler): + + + classes = ( PSA_OT_import_sequences_select_all, PSA_OT_import_sequences_deselect_all, PSA_OT_import_sequences_from_text, PSA_OT_import, - PSA_OT_import_select_file, ) diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index d9438f6..d2f5a18 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -1,8 +1,9 @@ +import math 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 +81,50 @@ 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, target_frame_count: int): + ''' + 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, target_frame_count: int) -> typing.Iterable[float]: + time = 0.0 + time_step = source_frame_count / target_frame_count + while time <= target_frame_count - 1: + yield time + time += time_step + yield 1.0 + + source_frame_count, bone_count = sequence_data_matrix.shape[:1] + + if target_frame_count == source_frame_count: + # No resampling is necessary. + return sequence_data_matrix + + resampled_sequence_data_matrix = np.zeros((target_frame_count, bone_count, 7), dtype=float) + sample_times = get_sample_times(source_frame_count, target_frame_count) + + for frame_index, sample_time in enumerate(sample_times): + if sample_time % 1.0 == 0.0: + # Sample time has no fractional part, so just copy the frame. + resampled_sequence_data_matrix[frame_index, :, :] = sequence_data_matrix[int(sample_time), :, :] + else: + # Sample time has a fractional part, so interpolate between two frames. + for bone_index in range(bone_count): + frame_index = int(sample_time) + source_frame_1_data = sequence_data_matrix[frame_index, bone_index, :] + source_frame_2_data = sequence_data_matrix[frame_index + 1, 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[frame_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] @@ -186,12 +231,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(): @@ -225,19 +267,27 @@ 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. + # TODO: target frame count can be fractional. + target_frame_count = math.ceil(sequence.frame_count * (target_fps / sequence.fps)) + + # Write the keyframes out. + # Note that the f-curve data consists of alternating time and value data. + fcurve_data = np.zeros(2 * target_frame_count, dtype=float) + fcurve_data[0::2] = range(0, target_frame_count) + + # Resample the sequence data matrix to the target frame count. + # 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, 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' diff --git a/io_scene_psk_psa/psk/import_/operators.py b/io_scene_psk_psa/psk/import_/operators.py index 741775b..3cebfa4 100644 --- a/io_scene_psk_psa/psk/import_/operators.py +++ b/io_scene_psk_psa/psk/import_/operators.py @@ -11,15 +11,15 @@ from ..reader import read_psk empty_set = set() -class PSX_FH_psk(FileHandler): - bl_idname = 'PSX_FH_psk' - bl_label = 'Unreal PSK/PSKX' +class PSK_FH_import(FileHandler): + bl_idname = 'PSK_FH_import' + bl_label = 'File handler for Unreal PSK/PSKX import' bl_import_operator = 'import_scene.psk' - bl_file_extensions = '.psk;.pskx' + bl_file_extensions = '.psk' @classmethod def poll_drop(cls, context: Context): - return context.area.type == 'VIEW_3D' + return context.area and context.area.type == 'VIEW_3D' class PSK_OT_import(Operator, ImportHelper): @@ -143,10 +143,11 @@ class PSK_OT_import(Operator, ImportHelper): col.use_property_decorate = False col.prop(self, 'scale') - layout.prop(self, 'should_import_mesh') + mesh_header, mesh_panel = layout.panel('mesh_panel_id', default_closed=False) + mesh_header.prop(self, 'should_import_mesh') - if self.should_import_mesh: - row = layout.row() + if mesh_panel and self.should_import_mesh: + row = mesh_panel.row() col = row.column() col.use_property_split = True col.use_property_decorate = False @@ -158,9 +159,11 @@ class PSK_OT_import(Operator, ImportHelper): col.prop(self, 'vertex_color_space') col.prop(self, 'should_import_shape_keys', text='Shape Keys') - layout.prop(self, 'should_import_skeleton') - if self.should_import_skeleton: - row = layout.row() + skeleton_header, skeleton_panel = layout.panel('skeleton_panel_id', default_closed=False) + skeleton_header.prop(self, 'should_import_skeleton') + + if skeleton_panel and self.should_import_skeleton: + row = skeleton_panel.row() col = row.column() col.use_property_split = True col.use_property_decorate = False @@ -169,5 +172,5 @@ class PSK_OT_import(Operator, ImportHelper): classes = ( PSK_OT_import, - PSX_FH_psk, + PSK_FH_import, )