Compare commits

..

8 Commits

Author SHA1 Message Date
Colin Basnett
d107a56007 Fixed duplicate code issue 2024-03-25 23:52:36 -07:00
Colin Basnett
a5bef57c8d Merge branch 'master' into blender-4.1
# Conflicts:
#	io_scene_psk_psa/psa/importer.py
2024-03-25 23:39:17 -07:00
Colin Basnett
44a55fc698 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.
2024-03-25 20:20:33 -07:00
Colin Basnett
09cc9e5d51 Added PSA resampling
Fixed PSA import resampling logic
2024-03-25 02:57:32 -07:00
Colin Basnett
d92f2d77d2 Incremented version to 6.2.1 2024-03-25 02:11:13 -07:00
Colin Basnett
9c8b9d922b Fix for issue where using case insensitive bone mapping would fail
Also made case-insensitive bone mapping the default for PSA import
2024-03-25 02:10:00 -07:00
Colin Basnett
20b072f87b Fix for root bone being incorrectly oriented if it wasn't at the identity rotation in the bind pose 2024-03-25 02:08:51 -07:00
Colin Basnett
bd667d4833 Update README.md 2024-03-14 19:13:48 -07:00
8 changed files with 33 additions and 120 deletions

View File

@@ -5,12 +5,13 @@
This Blender addon allows you to import and export meshes and animations to and from the [PSK and PSA file formats](https://wiki.beyondunreal.com/PSK_%26_PSA_file_formats) used in many versions of the Unreal Engine. This Blender addon allows you to import and export meshes and animations to and from the [PSK and PSA file formats](https://wiki.beyondunreal.com/PSK_%26_PSA_file_formats) used in many versions of the Unreal Engine.
## Compatibility # Compatibility
| Blender Version | Addon Version | Long Term Support | | Blender Version | Addon Version | Long Term Support |
|--------------------------------------------------------------|--------------------------------------------------------------------------------|-------------------| |------------------------------------------------------------|--------------------------------------------------------------------------------|-------------------|
| 4.0+ | [latest](https://github.com/DarklightGames/io_scene_psk_psa/releases/latest) | TBD | | [4.1](https://www.blender.org/download/releases/4-1/) | [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 | | [4.0](https://www.blender.org/download/releases/4-0/) | [6.2.1](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/6.2.1) | TBD |
| [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 | | [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. 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.

View File

@@ -3,8 +3,8 @@ from bpy.app.handlers import persistent
bl_info = { bl_info = {
'name': 'PSK/PSA Importer/Exporter', 'name': 'PSK/PSA Importer/Exporter',
'author': 'Colin Basnett, Yurii Ti', 'author': 'Colin Basnett, Yurii Ti',
'version': (6, 2, 0), 'version': (7, 0, 0),
'blender': (4, 0, 0), 'blender': (4, 1, 0),
'description': 'PSK/PSA Import/Export (.psk/.psa)', 'description': 'PSK/PSA Import/Export (.psk/.psa)',
'warning': '', 'warning': '',
'doc_url': 'https://github.com/DarklightGames/io_scene_psk_psa', 'doc_url': 'https://github.com/DarklightGames/io_scene_psk_psa',

View File

@@ -58,27 +58,7 @@ class Psa:
def __repr__(self) -> str: def __repr__(self) -> str:
return repr((self.location, self.rotation, self.time)) return repr((self.location, self.rotation, self.time))
class ScaleKey(Structure):
_fields_ = [
('scale', Vector3),
('time', c_float)
]
@property
def data(self):
yield self.scale.x
yield self.scale.y
yield self.scale.z
def __repr__(self) -> str:
return repr((self.scale, self.time))
def __init__(self): def __init__(self):
self.bones: List[Psa.Bone] = [] self.bones: List[Psa.Bone] = []
self.sequences: typing.OrderedDict[str, Psa.Sequence] = OrderedDict() self.sequences: typing.OrderedDict[str, Psa.Sequence] = OrderedDict()
self.keys: List[Psa.Key] = [] self.keys: List[Psa.Key] = []
self.scale_keys: List[Psa.ScaleKey] = []
@property
def has_scale_keys(self):
return len(self.scale_keys) > 0

View File

@@ -153,7 +153,6 @@ class PSA_OT_import(Operator, ImportHelper):
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_mode = pg.bone_mapping_mode options.bone_mapping_mode = pg.bone_mapping_mode
options.fps_source = pg.fps_source options.fps_source = pg.fps_source
@@ -234,7 +233,6 @@ class PSA_OT_import(Operator, ImportHelper):
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')

View File

@@ -47,8 +47,6 @@ class PSA_PG_import(PropertyGroup):
description='If an action with a matching name already exists, the existing action ' description='If an action with a matching name already exists, the existing action '
'will have it\'s data overwritten instead of a new action being created') 'will have it\'s data overwritten instead of a new action being created')
should_write_keyframes: BoolProperty(default=True, name='Keyframes', options=empty_set) should_write_keyframes: BoolProperty(default=True, name='Keyframes', options=empty_set)
should_write_scale_keys: BoolProperty(default=True, name='Scale Keys', options=empty_set, description=
'Import scale keys, if available')
should_write_metadata: BoolProperty(default=True, name='Metadata', options=empty_set, should_write_metadata: BoolProperty(default=True, name='Metadata', options=empty_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)')
@@ -73,7 +71,8 @@ class PSA_PG_import(PropertyGroup):
('EXACT', 'Exact', 'Bone names must match exactly.', 'EXACT', 0), ('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 ' ('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), '\'root\' can be mapped to the armature bone \'Root\')', 'CASE_INSENSITIVE', 1),
) ),
default='CASE_INSENSITIVE'
) )
fps_source: EnumProperty(name='FPS Source', items=( fps_source: EnumProperty(name='FPS Source', items=(
('SEQUENCE', 'Sequence', 'The sequence frame rate matches the original frame rate', 'ACTION', 0), ('SEQUENCE', 'Sequence', 'The sequence frame rate matches the original frame rate', 'ACTION', 0),

View File

@@ -1,5 +1,5 @@
import typing import typing
from typing import List, Optional, Iterable from typing import List, Optional
import bpy import bpy
import numpy as np import numpy as np
@@ -19,7 +19,6 @@ class PsaImportOptions(object):
self.should_overwrite = False self.should_overwrite = False
self.should_write_keyframes = True self.should_write_keyframes = True
self.should_write_metadata = True self.should_write_metadata = True
self.should_write_scale_keys = True
self.action_name_prefix = '' self.action_name_prefix = ''
self.should_convert_to_samples = False self.should_convert_to_samples = False
self.bone_mapping_mode = 'CASE_INSENSITIVE' self.bone_mapping_mode = 'CASE_INSENSITIVE'
@@ -39,10 +38,9 @@ class ImportBone(object):
self.original_rotation: Quaternion = Quaternion() self.original_rotation: Quaternion = Quaternion()
self.post_rotation: Quaternion = Quaternion() self.post_rotation: Quaternion = Quaternion()
self.fcurves: List[FCurve] = [] self.fcurves: List[FCurve] = []
self.scale_fcurves: List[FCurve] = []
def _calculate_fcurve_data(import_bone: ImportBone, key_data: Iterable[float]): def _calculate_fcurve_data(import_bone: ImportBone, key_data: typing.Iterable[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:])
@@ -145,7 +143,7 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
if armature_bone_index is not None: if armature_bone_index is not None:
# Ensure that no other PSA bone has been mapped to this armature bone yet. # 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: 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 armature_to_psa_bone_indices[armature_bone_index] = psa_bone_index
else: else:
# This armature bone has already been mapped to a PSA bone. # This armature bone has already been mapped to a PSA bone.
@@ -174,7 +172,7 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
# Create intermediate bone data for import operations. # Create intermediate bone data for import operations.
import_bones = [] 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): 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: if psa_bone_index not in psa_to_armature_bone_indices:
@@ -184,17 +182,22 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
import_bone = ImportBone(psa_bone) import_bone = ImportBone(psa_bone)
import_bone.armature_bone = armature_data.bones[psa_bone_name] import_bone.armature_bone = armature_data.bones[psa_bone_name]
import_bone.pose_bone = armature_object.pose.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) import_bones.append(import_bone)
bones_with_missing_parents = []
for import_bone in filter(lambda x: x is not None, import_bones): for import_bone in filter(lambda x: x is not None, import_bones):
armature_bone = import_bone.armature_bone armature_bone = import_bone.armature_bone
has_parent = armature_bone.parent is not None
if armature_bone.parent is not None and armature_bone.parent.name in psa_bone_names: if has_parent:
import_bone.parent = import_bones_dict[armature_bone.parent.name] 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?) # 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 = 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_location.rotate(armature_bone.parent.matrix_local.to_quaternion().conjugated())
import_bone.original_rotation = armature_bone.matrix_local.to_quaternion() import_bone.original_rotation = armature_bone.matrix_local.to_quaternion()
@@ -206,6 +209,12 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
import_bone.post_rotation = import_bone.original_rotation.conjugated() 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)) context.window_manager.progress_begin(0, len(sequences))
# Create and populate the data for new sequences. # Create and populate the data for new sequences.
@@ -258,14 +267,6 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
action.fcurves.new(location_data_path, index=1, action_group=pose_bone.name) if add_location_fcurves else None, # Ly action.fcurves.new(location_data_path, index=1, action_group=pose_bone.name) if add_location_fcurves else None, # Ly
action.fcurves.new(location_data_path, index=2, action_group=pose_bone.name) if add_location_fcurves else None, # Lz action.fcurves.new(location_data_path, index=2, action_group=pose_bone.name) if add_location_fcurves else None, # Lz
] ]
if options.should_write_scale_keys:
scale_data_path = pose_bone.path_from_id('scale')
import_bone.fcurves += [
action.fcurves.new(scale_data_path, index=0, action_group=pose_bone.name), # Sx
action.fcurves.new(scale_data_path, index=1, action_group=pose_bone.name), # Sy
action.fcurves.new(scale_data_path, index=2, action_group=pose_bone.name), # 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 = psa_reader.read_sequence_data_matrix(sequence_name)
@@ -303,22 +304,6 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
for fcurve_keyframe in fcurve.keyframe_points: for fcurve_keyframe in fcurve.keyframe_points:
fcurve_keyframe.interpolation = 'LINEAR' fcurve_keyframe.interpolation = 'LINEAR'
if options.should_write_scale_keys:
sequence_scale_data_matrix = psa_reader.read_sequence_scale_key_data_matrix(sequence_name)
# Write the scale keys out.
fcurve_data = numpy.zeros(2 * sequence.frame_count, dtype=float)
# 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.scale_fcurves):
fcurve_data[1::2] = sequence_scale_data_matrix[:, bone_index, fcurve_index]
fcurve.keyframe_points.add(sequence.frame_count)
fcurve.keyframe_points.foreach_set('co', fcurve_data)
for fcurve_keyframe in fcurve.keyframe_points:
fcurve_keyframe.interpolation = 'LINEAR'
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 action.fcurves:

View File

@@ -1,5 +1,4 @@
import ctypes import ctypes
from typing import Optional
import numpy as np import numpy as np
@@ -32,7 +31,6 @@ class PsaReader(object):
def __init__(self, path): def __init__(self, path):
self.keys_data_offset: int = 0 self.keys_data_offset: int = 0
self.scale_keys_data_offset: Optional[int] = None
self.fp = open(path, 'rb') self.fp = open(path, 'rb')
self.psa: Psa = self._read(self.fp) self.psa: Psa = self._read(self.fp)
@@ -66,9 +64,9 @@ class PsaReader(object):
Reads and returns the key data for a sequence. Reads and returns the key data for a sequence.
@param sequence_name: The name of the sequence. @param sequence_name: The name of the sequence.
@return: A list of keys for the sequence. @return: A list of Psa.Keys.
""" """
# Set the file reader to the beginning of the key data. # Set the file reader to the beginning of the keys data
sequence = self.psa.sequences[sequence_name] sequence = self.psa.sequences[sequence_name]
data_size = sizeof(Psa.Key) data_size = sizeof(Psa.Key)
bone_count = len(self.psa.bones) bone_count = len(self.psa.bones)
@@ -84,49 +82,6 @@ class PsaReader(object):
offset += data_size offset += data_size
return keys return keys
def read_sequence_scale_key_data_matrix(self, sequence_name: str) -> np.ndarray:
"""
Reads and returns the scale key data matrix for the given sequence.
@param sequence_name: The name of the sequence.
@return: An FxBx3 matrix where F is the number of frames, B is the number of bones.
"""
sequence = self.psa.sequences[sequence_name]
scale_keys = self.read_sequence_scale_keys(sequence_name)
bone_count = len(self.bones)
matrix_size = sequence.frame_count, bone_count, 3
matrix = np.ones(matrix_size)
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, :] = iter(next(keys_iter).scale)
return matrix
def read_sequence_scale_keys(self, sequence_name: str) -> List[Psa.ScaleKey]:
"""
Reads and returns the scale key data for a sequence.
Throws a RuntimeError exception if the sequence does not contain scale keys (use Psa.has_scale_keys to check).
@param sequence_name: The name of the sequence.
@return: A list of scale keys for the sequence.
"""
if not self.psa.has_scale_keys:
raise RuntimeError('The PSA file does not contain scale keys.')
# Set the file reader to the beginning of the key data.
sequence = self.psa.sequences[sequence_name]
data_size = sizeof(Psa.ScaleKey)
bone_count = len(self.psa.bones)
buffer_length = data_size * bone_count * sequence.frame_count
sequence_scale_keys_offset = self.keys_data_offset + (sequence.frame_start_index * bone_count * data_size)
self.fp.seek(sequence_scale_keys_offset, 0)
buffer = self.fp.read(buffer_length)
offset = 0
scale_keys = []
for _ in range(sequence.frame_count * bone_count):
scale_key = Psa.ScaleKey.from_buffer_copy(buffer, offset)
scale_keys.append(scale_key)
offset += data_size
return scale_keys
@staticmethod @staticmethod
def _read_types(fp, data_class, section: Section, data): def _read_types(fp, data_class, section: Section, data):
buffer_length = section.data_size * section.data_count buffer_length = section.data_size * section.data_count
@@ -156,10 +111,6 @@ class PsaReader(object):
# Skip keys on this pass. We will keep this file open and read from it as needed. # Skip keys on this pass. We will keep this file open and read from it as needed.
self.keys_data_offset = fp.tell() self.keys_data_offset = fp.tell()
fp.seek(section.data_size * section.data_count, 1) fp.seek(section.data_size * section.data_count, 1)
elif section.name == b'SCALEKEYS':
# Skip scale keys on this pass. We will keep this file open and read from it as needed.
self.scale_keys_data_offset = fp.tell()
fp.seek(section.data_size * section.data_count, 1)
else: else:
fp.seek(section.data_size * section.data_count, 1) fp.seek(section.data_size * section.data_count, 1)
print(f'Unrecognized section in PSA: "{section.name}"') print(f'Unrecognized section in PSA: "{section.name}"')

View File

@@ -231,7 +231,6 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult:
for vertex_normal in psk.vertex_normals: for vertex_normal in psk.vertex_normals:
normals.append(tuple(vertex_normal)) normals.append(tuple(vertex_normal))
mesh_data.normals_split_custom_set_from_vertices(normals) mesh_data.normals_split_custom_set_from_vertices(normals)
mesh_data.use_auto_smooth = True
else: else:
mesh_data.shade_smooth() mesh_data.shade_smooth()