Implement #142: Add support for SCALEKEYS
This commit is contained in:
@@ -20,9 +20,10 @@ RUN BLENDER_EXECUTABLE=$(blender-downloader $BLENDER_VERSION --extract --remove-
|
|||||||
RUN pip install pytest-cov
|
RUN pip install pytest-cov
|
||||||
|
|
||||||
# Source the environment variables and install Python dependencies
|
# Source the environment variables and install Python dependencies
|
||||||
|
# TODO: would be nice to have these installed in the bash script below.
|
||||||
RUN . /etc/environment && \
|
RUN . /etc/environment && \
|
||||||
$BLENDER_PYTHON -m ensurepip && \
|
$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
|
# Persist BLENDER_EXECUTABLE as an environment variable
|
||||||
RUN echo $(cat /blender_executable_path) > /tmp/blender_executable_path_env && \
|
RUN echo $(cat /blender_executable_path) > /tmp/blender_executable_path_env && \
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
schema_version = "1.0.0"
|
schema_version = "1.0.0"
|
||||||
id = "io_scene_psk_psa"
|
id = "io_scene_psk_psa"
|
||||||
version = "9.0.2"
|
version = "9.1.0"
|
||||||
name = "Unreal PSK/PSA (.psk/.psa)"
|
name = "Unreal PSK/PSA (.psk/.psa)"
|
||||||
tagline = "Import and export PSK and PSA files used in Unreal Engine"
|
tagline = "Import and export PSK and PSA files used in Unreal Engine"
|
||||||
maintainer = "Colin Basnett <cmbasnett@gmail.com>"
|
maintainer = "Colin Basnett <cmbasnett@gmail.com>"
|
||||||
@@ -14,7 +14,7 @@ license = [
|
|||||||
"SPDX:GPL-3.0-or-later",
|
"SPDX:GPL-3.0-or-later",
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
'./wheels/psk_psa_py-0.0.1-py3-none-any.whl'
|
'./wheels/psk_psa_py-0.0.4-py3-none-any.whl'
|
||||||
]
|
]
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ def load_psa_file(context, filepath: str):
|
|||||||
try:
|
try:
|
||||||
# Read the file and populate the action list.
|
# Read the file and populate the action list.
|
||||||
p = os.path.abspath(filepath)
|
p = os.path.abspath(filepath)
|
||||||
psa_reader = PsaReader(p)
|
psa_reader = PsaReader.from_path(p)
|
||||||
for sequence in psa_reader.sequences.values():
|
for sequence in psa_reader.sequences.values():
|
||||||
item = pg.sequence_list.add()
|
item = pg.sequence_list.add()
|
||||||
item.action_name = sequence.name.decode('windows-1252')
|
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:
|
for file in self.files:
|
||||||
psa_path = str(os.path.join(self.directory, file.name))
|
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())
|
sequence_names = list(psa_reader.sequences.keys())
|
||||||
options = psa_import_options_from_property_group(self, sequence_names)
|
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_overwrite = pg.should_overwrite
|
||||||
options.should_write_metadata = pg.should_write_metadata
|
options.should_write_metadata = pg.should_write_metadata
|
||||||
options.should_write_keyframes = pg.should_write_keyframes
|
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.should_convert_to_samples = pg.should_convert_to_samples
|
||||||
options.bone_mapping = BoneMapping(
|
options.bone_mapping = BoneMapping(
|
||||||
is_case_sensitive=pg.bone_mapping_is_case_sensitive,
|
is_case_sensitive=pg.bone_mapping_is_case_sensitive,
|
||||||
@@ -215,7 +216,7 @@ def _import_psa(context,
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
warnings.append(f'Failed to read PSA config file: {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 = import_psa(context, psa_reader, armature_object, options)
|
||||||
result.warnings.extend(warnings)
|
result.warnings.extend(warnings)
|
||||||
@@ -242,7 +243,7 @@ class PSA_OT_import_all(Operator, PsaImportMixin):
|
|||||||
|
|
||||||
def execute(self, context):
|
def execute(self, context):
|
||||||
sequence_names = []
|
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())
|
sequence_names.extend(psa_reader.sequences.keys())
|
||||||
|
|
||||||
options = PsaImportOptions(
|
options = PsaImportOptions(
|
||||||
@@ -376,6 +377,7 @@ class PSA_OT_import(Operator, ImportHelper, PsaImportMixin):
|
|||||||
col.use_property_decorate = False
|
col.use_property_decorate = False
|
||||||
col.prop(self, 'should_write_keyframes')
|
col.prop(self, 'should_write_keyframes')
|
||||||
col.prop(self, 'should_write_metadata')
|
col.prop(self, 'should_write_metadata')
|
||||||
|
col.prop(self, 'should_write_scale_keys')
|
||||||
|
|
||||||
if self.should_write_keyframes:
|
if self.should_write_keyframes:
|
||||||
col = col.column(heading='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.use_property_decorate = False
|
||||||
col.prop(pg, 'should_write_keyframes')
|
col.prop(pg, 'should_write_keyframes')
|
||||||
col.prop(pg, 'should_write_metadata')
|
col.prop(pg, 'should_write_metadata')
|
||||||
|
col.prop(pg, 'should_write_scale_keys')
|
||||||
|
|
||||||
if pg.should_write_keyframes:
|
if pg.should_write_keyframes:
|
||||||
col = col.column(heading='Keyframes')
|
col = col.column(heading='Keyframes')
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ class PsaImportMixin:
|
|||||||
should_write_metadata: BoolProperty(default=True, name='Metadata', options=set(),
|
should_write_metadata: BoolProperty(default=True, name='Metadata', options=set(),
|
||||||
description='Additional data will be written to the custom properties of the '
|
description='Additional data will be written to the custom properties of the '
|
||||||
'Action (e.g., frame rate)')
|
'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_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'})
|
||||||
sequence_filter_is_selected: BoolProperty(default=False, options=set(), name='Only Show Selected',
|
sequence_filter_is_selected: BoolProperty(default=False, options=set(), name='Only Show Selected',
|
||||||
description='Only show selected sequences')
|
description='Only show selected sequences')
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class PsaImportMixin:
|
|||||||
should_overwrite: bool
|
should_overwrite: bool
|
||||||
should_write_keyframes: bool
|
should_write_keyframes: bool
|
||||||
should_write_metadata: bool
|
should_write_metadata: bool
|
||||||
|
should_write_scale_keys: bool
|
||||||
sequence_filter_name: str
|
sequence_filter_name: str
|
||||||
sequence_filter_is_selected: bool
|
sequence_filter_is_selected: bool
|
||||||
sequence_use_filter_invert: bool
|
sequence_use_filter_invert: bool
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class PsaImportOptions(object):
|
|||||||
should_use_fake_user: bool = False,
|
should_use_fake_user: bool = False,
|
||||||
should_write_keyframes: bool = True,
|
should_write_keyframes: bool = True,
|
||||||
should_write_metadata: bool = True,
|
should_write_metadata: bool = True,
|
||||||
|
should_write_scale_keys: bool = True,
|
||||||
translation_scale: float = 1.0
|
translation_scale: float = 1.0
|
||||||
):
|
):
|
||||||
self.action_name_prefix = action_name_prefix
|
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_use_fake_user = should_use_fake_user
|
||||||
self.should_write_keyframes = should_write_keyframes
|
self.should_write_keyframes = should_write_keyframes
|
||||||
self.should_write_metadata = should_write_metadata
|
self.should_write_metadata = should_write_metadata
|
||||||
|
self.should_write_scale_keys = should_write_scale_keys
|
||||||
self.translation_scale = translation_scale
|
self.translation_scale = translation_scale
|
||||||
|
|
||||||
|
|
||||||
@@ -73,7 +75,7 @@ class ImportBone(object):
|
|||||||
def _calculate_fcurve_data(import_bone: ImportBone, key_data: Sequence[float]):
|
def _calculate_fcurve_data(import_bone: ImportBone, key_data: Sequence[float]):
|
||||||
# Convert world-space transforms to local-space transforms.
|
# Convert world-space transforms to local-space transforms.
|
||||||
key_rotation = Quaternion(key_data[0:4])
|
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 = import_bone.post_rotation.copy()
|
||||||
q.rotate(import_bone.original_rotation)
|
q.rotate(import_bone.original_rotation)
|
||||||
rotation = q
|
rotation = q
|
||||||
@@ -85,7 +87,8 @@ def _calculate_fcurve_data(import_bone: ImportBone, key_data: Sequence[float]):
|
|||||||
rotation.rotate(q.conjugated())
|
rotation.rotate(q.conjugated())
|
||||||
location = key_location - import_bone.original_location
|
location = key_location - import_bone.original_location
|
||||||
location.rotate(import_bone.post_rotation.conjugated())
|
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:
|
class PsaImportResult:
|
||||||
@@ -169,6 +172,34 @@ def _resample_sequence_data_matrix(sequence_data_matrix: np.ndarray, frame_step:
|
|||||||
return resampled_sequence_data_matrix
|
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:
|
def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, options: PsaImportOptions) -> PsaImportResult:
|
||||||
|
|
||||||
assert context.window_manager
|
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
|
pose_bone = import_bone.pose_bone
|
||||||
rotation_data_path = pose_bone.path_from_id('rotation_quaternion')
|
rotation_data_path = pose_bone.path_from_id('rotation_quaternion')
|
||||||
location_data_path = pose_bone.path_from_id('location')
|
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_rotation_fcurves = (bone_track_flags & REMOVE_TRACK_ROTATION) == 0
|
||||||
add_location_fcurves = (bone_track_flags & REMOVE_TRACK_LOCATION) == 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 = [
|
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=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
|
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=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=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(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.
|
# 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:
|
if options.translation_scale != 1.0:
|
||||||
# Scale the translation data.
|
# 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.
|
# Convert the sequence's data from world-space to local-space.
|
||||||
for bone_index, import_bone in enumerate(import_bones):
|
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:
|
if options.should_convert_to_samples:
|
||||||
# Bake the curve 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)
|
fcurve.convert_to_samples(start=0, end=sequence.frame_count)
|
||||||
|
|
||||||
# Write meta-data.
|
# Write meta-data.
|
||||||
|
|||||||
Binary file not shown.
@@ -37,3 +37,29 @@ def test_psa_import_all():
|
|||||||
EXPECTED_ACTION_COUNT = 135
|
EXPECTED_ACTION_COUNT = 135
|
||||||
assert len(bpy.data.actions) == EXPECTED_ACTION_COUNT, \
|
assert len(bpy.data.actions) == EXPECTED_ACTION_COUNT, \
|
||||||
f"Expected {EXPECTED_ACTION_COUNT} actions, but found {len(bpy.data.actions)}."
|
f"Expected {EXPECTED_ACTION_COUNT} actions, but found {len(bpy.data.actions)}."
|
||||||
|
|
||||||
|
|
||||||
|
def test_psa_import_convert_to_samples():
|
||||||
|
assert bpy.ops.psk.import_file(
|
||||||
|
filepath=SHREK_PSK_FILEPATH,
|
||||||
|
components='ALL',
|
||||||
|
) == {'FINISHED'}, "PSK import failed."
|
||||||
|
|
||||||
|
armature_object = bpy.data.objects.get('Shrek', None)
|
||||||
|
assert armature_object is not None, "Armature object not found in the scene."
|
||||||
|
assert armature_object.type == 'ARMATURE', "Object is not of type ARMATURE."
|
||||||
|
|
||||||
|
# Select the armature object
|
||||||
|
bpy.context.view_layer.objects.active = armature_object
|
||||||
|
armature_object.select_set(True)
|
||||||
|
|
||||||
|
# Import the associated PSA file with import_all operator, and convert to samples.
|
||||||
|
assert bpy.ops.psa.import_all(
|
||||||
|
filepath=SHREK_PSA_FILEPATH,
|
||||||
|
should_convert_to_samples=True
|
||||||
|
) == {'FINISHED'}, "PSA import failed."
|
||||||
|
|
||||||
|
# TODO: More thorough tests on the imported data for the animations.
|
||||||
|
EXPECTED_ACTION_COUNT = 135
|
||||||
|
assert len(bpy.data.actions) == EXPECTED_ACTION_COUNT, \
|
||||||
|
f"Expected {EXPECTED_ACTION_COUNT} actions, but found {len(bpy.data.actions)}."
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
pytest
|
pytest
|
||||||
pytest-cov
|
pytest-cov
|
||||||
psk-psa-py
|
psk-psa-py == 0.0.4
|
||||||
|
|||||||
Reference in New Issue
Block a user