Compare commits

...

11 Commits

Author SHA1 Message Date
Colin Basnett
7ceaa88f1d Incremented version to 7.0.1 2024-03-31 14:23:35 -07:00
Colin Basnett
37e246bf3e Fixed ordering of panels in the PSA import dialog 2024-03-31 14:22:16 -07:00
Colin Basnett
db93314fbc Initial commit for multiple PSA import 2024-03-31 12:47:48 -07:00
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
7 changed files with 217 additions and 103 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.
## Compatibility
# Compatibility
| 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 |
| Blender Version | Addon Version | Long Term Support |
|------------------------------------------------------------|--------------------------------------------------------------------------------|-------------------|
| [4.1](https://www.blender.org/download/releases/4-1/) | [latest](https://github.com/DarklightGames/io_scene_psk_psa/releases/latest) | TBD |
| [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 |
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 = {
'name': 'PSK/PSA Importer/Exporter',
'author': 'Colin Basnett, Yurii Ti',
'version': (6, 2, 0),
'blender': (4, 0, 0),
'version': (7, 0, 1),
'blender': (4, 1, 0),
'description': 'PSK/PSA Import/Export (.psk/.psa)',
'warning': '',
'doc_url': 'https://github.com/DarklightGames/io_scene_psk_psa',

View File

@@ -1,6 +1,6 @@
import re
from configparser import ConfigParser
from typing import Dict
from typing import Dict, List
from .reader import PsaReader
@@ -50,7 +50,7 @@ def _get_bone_flags_from_value(value: str) -> int:
return 0
def read_psa_config(psa_reader: PsaReader, file_path: str) -> PsaConfig:
def read_psa_config(psa_sequence_names: List[str], file_path: str) -> PsaConfig:
psa_config = PsaConfig()
config = _load_config_file(file_path)
@@ -62,7 +62,6 @@ def read_psa_config(psa_reader: PsaReader, file_path: str) -> PsaConfig:
# Map the sequence name onto the actual sequence name in the PSA file.
try:
psa_sequence_names = list(psa_reader.sequences.keys())
lowercase_sequence_names = [sequence_name.lower() for sequence_name in psa_sequence_names]
sequence_name = psa_sequence_names[lowercase_sequence_names.index(sequence_name.lower())]
except ValueError:

View File

@@ -1,8 +1,9 @@
import os
from pathlib import Path
from typing import Iterable, List
from bpy.props import StringProperty
from bpy.types import Operator, Event, Context, FileHandler
from bpy.props import StringProperty, CollectionProperty
from bpy.types import Operator, Event, Context, FileHandler, OperatorFileListElement, Object
from bpy_extras.io_utils import ImportHelper
from .properties import get_visible_sequences
@@ -108,11 +109,92 @@ def load_psa_file(context, filepath: str):
pg.psa_error = str(e)
def on_psa_file_path_updated(cls, context):
load_psa_file(context, cls.filepath)
class PSA_OT_import_multiple(Operator):
bl_idname = 'psa_import.import_multiple'
bl_label = 'Import PSA'
bl_description = 'Import multiple PSA files'
bl_options = {'INTERNAL', 'UNDO'}
directory: StringProperty(subtype='FILE_PATH', options={'SKIP_SAVE', 'HIDDEN'})
files: CollectionProperty(type=OperatorFileListElement, options={'SKIP_SAVE', 'HIDDEN'})
@classmethod
def poll(cls, context):
return True
def execute(self, context):
pg = getattr(context.scene, 'psa_import')
warnings = []
for file in self.files:
psa_path = os.path.join(self.directory, file.name)
psa_reader = PsaReader(psa_path)
sequence_names = psa_reader.sequences.keys()
result = _import_psa(context, pg, psa_path, sequence_names, context.view_layer.objects.active)
result.warnings.extend(warnings)
if len(result.warnings) > 0:
message = f'Imported {len(sequence_names)} action(s) with {len(result.warnings)} warning(s)\n'
self.report({'INFO'}, message)
for warning in result.warnings:
self.report({'WARNING'}, warning)
return {'FINISHED'}
def invoke(self, context: Context, event):
# Show the import operator properties in a pop-up dialog (do not use the file selector).
context.window_manager.invoke_props_dialog(self)
return {'RUNNING_MODAL'}
def draw(self, context):
layout = self.layout
pg = getattr(context.scene, 'psa_import')
draw_psa_import_options_no_panels(layout, pg)
def _import_psa(context,
pg,
filepath: str,
sequence_names: List[str],
armature_object: Object
):
options = PsaImportOptions()
options.sequence_names = sequence_names
options.should_use_fake_user = pg.should_use_fake_user
options.should_stash = pg.should_stash
options.action_name_prefix = pg.action_name_prefix if pg.should_use_action_name_prefix else ''
options.should_overwrite = pg.should_overwrite
options.should_write_metadata = pg.should_write_metadata
options.should_write_keyframes = pg.should_write_keyframes
options.should_convert_to_samples = pg.should_convert_to_samples
options.bone_mapping_mode = pg.bone_mapping_mode
options.fps_source = pg.fps_source
options.fps_custom = pg.fps_custom
warnings = []
if options.should_use_config_file:
# Read the PSA config file if it exists.
config_path = Path(filepath).with_suffix('.config')
if config_path.exists():
try:
options.psa_config = read_psa_config(sequence_names, str(config_path))
except Exception as e:
warnings.append(f'Failed to read PSA config file: {e}')
psa_reader = PsaReader(filepath)
result = import_psa(context, psa_reader, armature_object, options)
result.warnings.extend(warnings)
return result
class PSA_OT_import(Operator, ImportHelper):
bl_idname = 'psa_import.import'
bl_label = 'Import'
@@ -138,36 +220,13 @@ class PSA_OT_import(Operator, ImportHelper):
def execute(self, context):
pg = getattr(context.scene, 'psa_import')
psa_reader = PsaReader(self.filepath)
sequence_names = [x.action_name for x in pg.sequence_list if x.is_selected]
if len(sequence_names) == 0:
self.report({'ERROR_INVALID_CONTEXT'}, 'No sequences selected')
return {'CANCELLED'}
options = PsaImportOptions()
options.sequence_names = sequence_names
options.should_use_fake_user = pg.should_use_fake_user
options.should_stash = pg.should_stash
options.action_name_prefix = pg.action_name_prefix if pg.should_use_action_name_prefix else ''
options.should_overwrite = pg.should_overwrite
options.should_write_metadata = pg.should_write_metadata
options.should_write_keyframes = pg.should_write_keyframes
options.should_convert_to_samples = pg.should_convert_to_samples
options.bone_mapping_mode = pg.bone_mapping_mode
options.fps_source = pg.fps_source
options.fps_custom = pg.fps_custom
if options.should_use_config_file:
# Read the PSA config file if it exists.
config_path = Path(self.filepath).with_suffix('.config')
if config_path.exists():
try:
options.psa_config = read_psa_config(psa_reader, str(config_path))
except Exception as e:
self.report({'WARNING'}, f'Failed to read PSA config file: {e}')
result = import_psa(context, psa_reader, context.view_layer.objects.active, options)
result = _import_psa(context, pg, self.filepath, sequence_names, context.view_layer.objects.active)
if len(result.warnings) > 0:
message = f'Imported {len(sequence_names)} action(s) with {len(result.warnings)} warning(s)\n'
@@ -189,78 +248,118 @@ class PSA_OT_import(Operator, ImportHelper):
def draw(self, context: Context):
layout = self.layout
pg = getattr(context.scene, 'psa_import')
draw_psa_import_options(layout, pg)
sequences_header, sequences_panel = layout.panel('sequences_panel_id', default_closed=False)
sequences_header.label(text='Sequences')
if sequences_panel:
if pg.psa_error:
row = sequences_panel.row()
row.label(text='Select a PSA file', icon='ERROR')
else:
# Select buttons.
rows = max(3, min(len(pg.sequence_list), 10))
def draw_psa_import_options_no_panels(layout, pg):
col = layout.column(heading='Sequences')
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'fps_source')
if pg.fps_source == 'CUSTOM':
col.prop(pg, 'fps_custom')
col.prop(pg, 'should_overwrite')
col.prop(pg, 'should_use_action_name_prefix')
if pg.should_use_action_name_prefix:
col.prop(pg, 'action_name_prefix')
row = sequences_panel.row()
col = row.column()
col = layout.column(heading='Write')
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'should_write_keyframes')
col.prop(pg, 'should_write_metadata')
row2 = col.row(align=True)
row2.label(text='Select')
row2.operator(PSA_OT_import_sequences_from_text.bl_idname, text='', icon='TEXT')
row2.operator(PSA_OT_import_sequences_select_all.bl_idname, text='All', icon='CHECKBOX_HLT')
row2.operator(PSA_OT_import_sequences_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT')
if pg.should_write_keyframes:
col = col.column(heading='Keyframes')
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'should_convert_to_samples')
col = col.row()
col.template_list('PSA_UL_import_sequences', '', pg, 'sequence_list', pg, 'sequence_list_index', rows=rows)
col = layout.column()
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'bone_mapping_mode')
col = sequences_panel.column(heading='')
col = layout.column(heading='Options')
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'should_use_fake_user')
col.prop(pg, 'should_stash')
col.prop(pg, 'should_use_config_file')
def draw_psa_import_options(layout, pg):
sequences_header, sequences_panel = layout.panel('00_sequences_panel_id', default_closed=False)
sequences_header.label(text='Sequences')
if sequences_panel:
if pg.psa_error:
row = sequences_panel.row()
row.label(text='Select a PSA file', icon='ERROR')
else:
# Select buttons.
rows = max(3, min(len(pg.sequence_list), 10))
row = sequences_panel.row()
col = row.column()
row2 = col.row(align=True)
row2.label(text='Select')
row2.operator(PSA_OT_import_sequences_from_text.bl_idname, text='', icon='TEXT')
row2.operator(PSA_OT_import_sequences_select_all.bl_idname, text='All', icon='CHECKBOX_HLT')
row2.operator(PSA_OT_import_sequences_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT')
col = col.row()
col.template_list('PSA_UL_import_sequences', '', pg, 'sequence_list', pg, 'sequence_list_index', rows=rows)
col = sequences_panel.column(heading='')
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'fps_source')
if pg.fps_source == 'CUSTOM':
col.prop(pg, 'fps_custom')
col.prop(pg, 'should_overwrite')
col.prop(pg, 'should_use_action_name_prefix')
if pg.should_use_action_name_prefix:
col.prop(pg, 'action_name_prefix')
data_header, data_panel = layout.panel('01_data_panel_id', default_closed=False)
data_header.label(text='Data')
if data_panel:
col = data_panel.column(heading='Write')
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'should_write_keyframes')
col.prop(pg, 'should_write_metadata')
if pg.should_write_keyframes:
col = col.column(heading='Keyframes')
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'fps_source')
if pg.fps_source == 'CUSTOM':
col.prop(pg, 'fps_custom')
col.prop(pg, 'should_overwrite')
col.prop(pg, 'should_use_action_name_prefix')
if pg.should_use_action_name_prefix:
col.prop(pg, 'action_name_prefix')
col.prop(pg, 'should_convert_to_samples')
data_header, data_panel = layout.panel('data_panel_id', default_closed=False)
data_header.label(text='Data')
advanced_header, advanced_panel = layout.panel('02_advanced_panel_id', default_closed=True)
advanced_header.label(text='Advanced')
if data_panel:
col = data_panel.column(heading='Write')
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'should_write_keyframes')
col.prop(pg, 'should_write_metadata')
if advanced_panel:
col = advanced_panel.column()
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'bone_mapping_mode')
if pg.should_write_keyframes:
col = col.column(heading='Keyframes')
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'should_convert_to_samples')
advanced_header, advanced_panel = layout.panel('advanced_panel_id', default_closed=True)
advanced_header.label(text='Advanced')
if advanced_panel:
col = advanced_panel.column()
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'bone_mapping_mode')
col = advanced_panel.column(heading='Options')
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'should_use_fake_user')
col.prop(pg, 'should_stash')
col.prop(pg, 'should_use_config_file')
col = advanced_panel.column(heading='Options')
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'should_use_fake_user')
col.prop(pg, 'should_stash')
col.prop(pg, 'should_use_config_file')
class PSA_FH_import(FileHandler):
bl_idname = 'PSA_FH_import'
bl_label = 'File handler for Unreal PSA import'
bl_import_operator = 'psa_import.import'
bl_import_operator = 'psa_import.import_multiple'
bl_file_extensions = '.psa'
@classmethod
@@ -273,5 +372,6 @@ classes = (
PSA_OT_import_sequences_deselect_all,
PSA_OT_import_sequences_from_text,
PSA_OT_import,
PSA_OT_import_multiple,
PSA_FH_import,
)

View File

@@ -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),

View File

@@ -88,6 +88,7 @@ def _get_sample_frame_times(source_frame_count: int, frame_step: float) -> typin
time += frame_step
yield source_frame_count - 1
def _resample_sequence_data_matrix(sequence_data_matrix: np.ndarray, frame_step: float = 1.0) -> np.ndarray:
"""
Resamples the sequence data matrix to the target frame count.
@@ -143,7 +144,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.
@@ -172,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:
@@ -182,17 +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()
@@ -204,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.

View File

@@ -33,7 +33,8 @@ class PSK_OT_import(Operator, ImportHelper):
name='File Path',
description='File path used for exporting the PSK file',
maxlen=1024,
default='')
subtype='FILE_PATH',
options={'SKIP_SAVE'})
should_import_vertex_colors: BoolProperty(
default=True,