Compare commits
4 Commits
psa-import
...
scale_keys
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d3ef50907 | ||
|
|
bfdf1eb736 | ||
|
|
f2b5858635 | ||
|
|
563172ae23 |
11
README.md
11
README.md
@@ -5,13 +5,12 @@
|
||||
|
||||
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.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 |
|
||||
| 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 |
|
||||
| [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.
|
||||
|
||||
@@ -3,8 +3,8 @@ from bpy.app.handlers import persistent
|
||||
bl_info = {
|
||||
'name': 'PSK/PSA Importer/Exporter',
|
||||
'author': 'Colin Basnett, Yurii Ti',
|
||||
'version': (7, 0, 1),
|
||||
'blender': (4, 1, 0),
|
||||
'version': (6, 2, 0),
|
||||
'blender': (4, 0, 0),
|
||||
'description': 'PSK/PSA Import/Export (.psk/.psa)',
|
||||
'warning': '',
|
||||
'doc_url': 'https://github.com/DarklightGames/io_scene_psk_psa',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import re
|
||||
from configparser import ConfigParser
|
||||
from typing import Dict, List
|
||||
from typing import Dict
|
||||
|
||||
from .reader import PsaReader
|
||||
|
||||
@@ -50,7 +50,7 @@ def _get_bone_flags_from_value(value: str) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def read_psa_config(psa_sequence_names: List[str], file_path: str) -> PsaConfig:
|
||||
def read_psa_config(psa_reader: PsaReader, file_path: str) -> PsaConfig:
|
||||
psa_config = PsaConfig()
|
||||
|
||||
config = _load_config_file(file_path)
|
||||
@@ -62,6 +62,7 @@ def read_psa_config(psa_sequence_names: List[str], 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:
|
||||
|
||||
@@ -58,7 +58,27 @@ class Psa:
|
||||
def __repr__(self) -> str:
|
||||
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):
|
||||
self.bones: List[Psa.Bone] = []
|
||||
self.sequences: typing.OrderedDict[str, Psa.Sequence] = OrderedDict()
|
||||
self.keys: List[Psa.Key] = []
|
||||
self.scale_keys: List[Psa.ScaleKey] = []
|
||||
|
||||
@property
|
||||
def has_scale_keys(self):
|
||||
return len(self.scale_keys) > 0
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List
|
||||
|
||||
from bpy.props import StringProperty, CollectionProperty
|
||||
from bpy.types import Operator, Event, Context, FileHandler, OperatorFileListElement, Object
|
||||
from bpy.props import StringProperty
|
||||
from bpy.types import Operator, Event, Context, FileHandler
|
||||
from bpy_extras.io_utils import ImportHelper
|
||||
|
||||
from .properties import get_visible_sequences
|
||||
@@ -109,92 +108,11 @@ 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'
|
||||
@@ -220,13 +138,37 @@ 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'}
|
||||
|
||||
result = _import_psa(context, pg, self.filepath, sequence_names, context.view_layer.objects.active)
|
||||
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_write_scale_keys = pg.should_write_scale_keys
|
||||
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)
|
||||
|
||||
if len(result.warnings) > 0:
|
||||
message = f'Imported {len(sequence_names)} action(s) with {len(result.warnings)} warning(s)\n'
|
||||
@@ -248,118 +190,79 @@ 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')
|
||||
|
||||
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')
|
||||
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))
|
||||
|
||||
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')
|
||||
row = sequences_panel.row()
|
||||
col = row.column()
|
||||
|
||||
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')
|
||||
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 = layout.column()
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'bone_mapping_mode')
|
||||
col = col.row()
|
||||
col.template_list('PSA_UL_import_sequences', '', pg, 'sequence_list', pg, 'sequence_list_index', rows=rows)
|
||||
|
||||
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 = sequences_panel.column(heading='')
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'should_convert_to_samples')
|
||||
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')
|
||||
|
||||
advanced_header, advanced_panel = layout.panel('02_advanced_panel_id', default_closed=True)
|
||||
advanced_header.label(text='Advanced')
|
||||
data_header, data_panel = layout.panel('data_panel_id', default_closed=False)
|
||||
data_header.label(text='Data')
|
||||
|
||||
if advanced_panel:
|
||||
col = advanced_panel.column()
|
||||
col.use_property_split = True
|
||||
col.use_property_decorate = False
|
||||
col.prop(pg, 'bone_mapping_mode')
|
||||
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')
|
||||
col.prop(pg, 'should_write_scale_keys')
|
||||
|
||||
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')
|
||||
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')
|
||||
|
||||
|
||||
class PSA_FH_import(FileHandler):
|
||||
bl_idname = 'PSA_FH_import'
|
||||
bl_label = 'File handler for Unreal PSA import'
|
||||
bl_import_operator = 'psa_import.import_multiple'
|
||||
bl_import_operator = 'psa_import.import'
|
||||
bl_file_extensions = '.psa'
|
||||
|
||||
@classmethod
|
||||
@@ -372,6 +275,5 @@ classes = (
|
||||
PSA_OT_import_sequences_deselect_all,
|
||||
PSA_OT_import_sequences_from_text,
|
||||
PSA_OT_import,
|
||||
PSA_OT_import_multiple,
|
||||
PSA_FH_import,
|
||||
)
|
||||
|
||||
@@ -47,6 +47,8 @@ class PSA_PG_import(PropertyGroup):
|
||||
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')
|
||||
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,
|
||||
description='Additional data will be written to the custom properties of the '
|
||||
'Action (e.g., frame rate)')
|
||||
@@ -71,8 +73,7 @@ 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),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import typing
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Iterable
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
@@ -19,6 +19,7 @@ class PsaImportOptions(object):
|
||||
self.should_overwrite = False
|
||||
self.should_write_keyframes = True
|
||||
self.should_write_metadata = True
|
||||
self.should_write_scale_keys = True
|
||||
self.action_name_prefix = ''
|
||||
self.should_convert_to_samples = False
|
||||
self.bone_mapping_mode = 'CASE_INSENSITIVE'
|
||||
@@ -38,9 +39,10 @@ class ImportBone(object):
|
||||
self.original_rotation: Quaternion = Quaternion()
|
||||
self.post_rotation: Quaternion = Quaternion()
|
||||
self.fcurves: List[FCurve] = []
|
||||
self.scale_fcurves: List[FCurve] = []
|
||||
|
||||
|
||||
def _calculate_fcurve_data(import_bone: ImportBone, key_data: typing.Iterable[float]):
|
||||
def _calculate_fcurve_data(import_bone: ImportBone, key_data: Iterable[float]):
|
||||
# Convert world-space transforms to local-space transforms.
|
||||
key_rotation = Quaternion(key_data[0:4])
|
||||
key_location = Vector(key_data[4:])
|
||||
@@ -88,7 +90,6 @@ 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.
|
||||
@@ -144,7 +145,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_index
|
||||
psa_to_armature_bone_indices[psa_bone_index] = armature_bone_names.index(psa_bone_name)
|
||||
armature_to_psa_bone_indices[armature_bone_index] = psa_bone_index
|
||||
else:
|
||||
# This armature bone has already been mapped to a PSA bone.
|
||||
@@ -173,7 +174,7 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
|
||||
|
||||
# Create intermediate bone data for import operations.
|
||||
import_bones = []
|
||||
psa_bone_names_to_import_bones = dict()
|
||||
import_bones_dict = 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,22 +184,17 @@ 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]
|
||||
psa_bone_names_to_import_bones[psa_bone_name] = import_bone
|
||||
import_bones_dict[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
|
||||
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)
|
||||
|
||||
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]
|
||||
|
||||
# Calculate the original location & rotation of each bone (in world-space maybe?)
|
||||
if has_parent:
|
||||
if import_bone.parent is not None:
|
||||
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()
|
||||
@@ -210,12 +206,6 @@ 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.
|
||||
@@ -268,6 +258,14 @@ 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=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.
|
||||
sequence_data_matrix = psa_reader.read_sequence_data_matrix(sequence_name)
|
||||
@@ -305,6 +303,22 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
|
||||
for fcurve_keyframe in fcurve.keyframe_points:
|
||||
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:
|
||||
# Bake the curve to samples.
|
||||
for fcurve in action.fcurves:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import ctypes
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -31,6 +32,7 @@ class PsaReader(object):
|
||||
|
||||
def __init__(self, path):
|
||||
self.keys_data_offset: int = 0
|
||||
self.scale_keys_data_offset: Optional[int] = None
|
||||
self.fp = open(path, 'rb')
|
||||
self.psa: Psa = self._read(self.fp)
|
||||
|
||||
@@ -64,9 +66,9 @@ class PsaReader(object):
|
||||
Reads and returns the key data for a sequence.
|
||||
|
||||
@param sequence_name: The name of the sequence.
|
||||
@return: A list of Psa.Keys.
|
||||
@return: A list of keys for the sequence.
|
||||
"""
|
||||
# Set the file reader to the beginning of the keys data
|
||||
# Set the file reader to the beginning of the key data.
|
||||
sequence = self.psa.sequences[sequence_name]
|
||||
data_size = sizeof(Psa.Key)
|
||||
bone_count = len(self.psa.bones)
|
||||
@@ -82,6 +84,49 @@ class PsaReader(object):
|
||||
offset += data_size
|
||||
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
|
||||
def _read_types(fp, data_class, section: Section, data):
|
||||
buffer_length = section.data_size * section.data_count
|
||||
@@ -111,6 +156,10 @@ class PsaReader(object):
|
||||
# Skip keys on this pass. We will keep this file open and read from it as needed.
|
||||
self.keys_data_offset = fp.tell()
|
||||
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:
|
||||
fp.seek(section.data_size * section.data_count, 1)
|
||||
print(f'Unrecognized section in PSA: "{section.name}"')
|
||||
|
||||
@@ -33,8 +33,7 @@ class PSK_OT_import(Operator, ImportHelper):
|
||||
name='File Path',
|
||||
description='File path used for exporting the PSK file',
|
||||
maxlen=1024,
|
||||
subtype='FILE_PATH',
|
||||
options={'SKIP_SAVE'})
|
||||
default='')
|
||||
|
||||
should_import_vertex_colors: BoolProperty(
|
||||
default=True,
|
||||
|
||||
@@ -231,6 +231,7 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult:
|
||||
for vertex_normal in psk.vertex_normals:
|
||||
normals.append(tuple(vertex_normal))
|
||||
mesh_data.normals_split_custom_set_from_vertices(normals)
|
||||
mesh_data.use_auto_smooth = True
|
||||
else:
|
||||
mesh_data.shade_smooth()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user