Renaming the add-on from io_export_psk_psa to io_scene_psk_psa

This commit is contained in:
Colin Basnett
2022-01-22 23:54:15 -08:00
parent a6c193e059
commit 8be4b040f8
16 changed files with 6 additions and 6 deletions

View File

View File

@@ -0,0 +1,155 @@
from .data import *
from ..helpers import *
class PsaBuilderOptions(object):
def __init__(self):
self.actions = []
self.bone_filter_mode = 'ALL'
self.bone_group_indices = []
# https://git.cth451.me/cth451/blender-addons/blob/master/io_export_unreal_psk_psa.py
class PsaBuilder(object):
def __init__(self):
# TODO: add options in here (selected anims, eg.)
pass
def build(self, context, options: PsaBuilderOptions) -> Psa:
object = context.view_layer.objects.active
if object.type != 'ARMATURE':
raise RuntimeError('Selected object must be an Armature')
armature = object
if armature.animation_data is None:
raise RuntimeError('No animation data for armature')
psa = Psa()
bones = list(armature.data.bones)
# The order of the armature bones and the pose bones is not guaranteed to be the same.
# As as a result, we need to reconstruct the list of pose bones in the same order as the
# armature bones.
bone_names = [x.name for x in bones]
pose_bones = [(bone_names.index(bone.name), bone) for bone in armature.pose.bones]
del bone_names
pose_bones.sort(key=lambda x: x[0])
pose_bones = [x[1] for x in pose_bones]
bone_indices = list(range(len(bones)))
# If bone groups are specified, get only the bones that are in that specified bone groups and their ancestors.
if options.bone_filter_mode == 'BONE_GROUPS':
bone_indices = get_export_bone_indices_for_bone_groups(armature, options.bone_group_indices)
# Make the bone lists contain only the bones that are going to be exported.
bones = [bones[bone_index] for bone_index in bone_indices]
pose_bones = [pose_bones[bone_index] for bone_index in bone_indices]
if len(bones) == 0:
# No bones are going to be exported.
raise RuntimeError('No bones available for export')
# Ensure that the exported hierarchy has a single root bone.
root_bones = [x for x in bones if x.parent is None]
if len(root_bones) > 1:
root_bone_names = [x.name for x in bones]
raise RuntimeError('Exported bone hierarchy must have a single root bone.'
f'The bone hierarchy marked for export has {len(root_bones)} root bones: {root_bone_names}')
for pose_bone in bones:
psa_bone = Psa.Bone()
psa_bone.name = bytes(pose_bone.name, encoding='utf-8')
try:
parent_index = bones.index(pose_bone.parent)
psa_bone.parent_index = parent_index
psa.bones[parent_index].children_count += 1
except ValueError:
psa_bone.parent_index = -1
if pose_bone.parent is not None:
rotation = pose_bone.matrix.to_quaternion()
rotation.x = -rotation.x
rotation.y = -rotation.y
rotation.z = -rotation.z
quat_parent = pose_bone.parent.matrix.to_quaternion().inverted()
parent_head = quat_parent @ pose_bone.parent.head
parent_tail = quat_parent @ pose_bone.parent.tail
location = (parent_tail - parent_head) + pose_bone.head
else:
location = armature.matrix_local @ pose_bone.head
rot_matrix = pose_bone.matrix @ armature.matrix_local.to_3x3()
rotation = rot_matrix.to_quaternion()
psa_bone.location.x = location.x
psa_bone.location.y = location.y
psa_bone.location.z = location.z
psa_bone.rotation.x = rotation.x
psa_bone.rotation.y = rotation.y
psa_bone.rotation.z = rotation.z
psa_bone.rotation.w = rotation.w
psa.bones.append(psa_bone)
frame_start_index = 0
for action in options.actions:
if len(action.fcurves) == 0:
continue
armature.animation_data.action = action
context.view_layer.update()
frame_min, frame_max = [int(x) for x in action.frame_range]
sequence = Psa.Sequence()
sequence.name = bytes(action.name, encoding='utf-8')
sequence.frame_count = frame_max - frame_min + 1
sequence.frame_start_index = frame_start_index
sequence.fps = context.scene.render.fps
frame_count = frame_max - frame_min + 1
for frame in range(frame_count):
context.scene.frame_set(frame_min + frame)
for pose_bone in pose_bones:
key = Psa.Key()
pose_bone_matrix = pose_bone.matrix
if pose_bone.parent is not None:
pose_bone_parent_matrix = pose_bone.parent.matrix
pose_bone_matrix = pose_bone_parent_matrix.inverted() @ pose_bone_matrix
location = pose_bone_matrix.to_translation()
rotation = pose_bone_matrix.to_quaternion().normalized()
if pose_bone.parent is not None:
rotation.x = -rotation.x
rotation.y = -rotation.y
rotation.z = -rotation.z
key.location.x = location.x
key.location.y = location.y
key.location.z = location.z
key.rotation.x = rotation.x
key.rotation.y = rotation.y
key.rotation.z = rotation.z
key.rotation.w = rotation.w
key.time = 1.0 / sequence.fps
psa.keys.append(key)
frame_start_index += 1
sequence.bone_count = len(pose_bones)
sequence.track_time = frame_count
psa.sequences[action.name] = sequence
return psa

View File

@@ -0,0 +1,63 @@
import typing
from typing import List
from collections import OrderedDict
from ..data import *
"""
Note that keys are not stored within the Psa object.
Use the PsaReader::get_sequence_keys to get a the keys for a sequence.
"""
class Psa(object):
class Bone(Structure):
_fields_ = [
('name', c_char * 64),
('flags', c_int32),
('children_count', c_int32),
('parent_index', c_int32),
('rotation', Quaternion),
('location', Vector3),
('padding', c_char * 16)
]
class Sequence(Structure):
_fields_ = [
('name', c_char * 64),
('group', c_char * 64),
('bone_count', c_int32),
('root_include', c_int32),
('compression_style', c_int32),
('key_quotum', c_int32),
('key_reduction', c_float),
('track_time', c_float),
('fps', c_float),
('start_bone', c_int32),
('frame_start_index', c_int32),
('frame_count', c_int32)
]
class Key(Structure):
_fields_ = [
('location', Vector3),
('rotation', Quaternion),
('time', c_float)
]
@property
def data(self):
yield self.rotation.w
yield self.rotation.x
yield self.rotation.y
yield self.rotation.z
yield self.location.x
yield self.location.y
yield self.location.z
def __repr__(self) -> str:
return repr((self.location, self.rotation, self.time))
def __init__(self):
self.bones: List[Psa.Bone] = []
self.sequences: typing.OrderedDict[Psa.Sequence] = OrderedDict()
self.keys: List[Psa.Key] = []

View File

@@ -0,0 +1,253 @@
import bpy
from bpy.types import Operator, PropertyGroup, Action, UIList, BoneGroup
from bpy.props import CollectionProperty, IntProperty, PointerProperty, StringProperty, BoolProperty, EnumProperty
from bpy_extras.io_utils import ExportHelper
from typing import Type
from .builder import PsaBuilder, PsaBuilderOptions
from .data import *
from ..types import BoneGroupListItem
from ..helpers import *
import re
class PsaExporter(object):
def __init__(self, psa: Psa):
self.psa: Psa = psa
# This method is shared by both PSA/K file formats, move this?
@staticmethod
def write_section(fp, name: bytes, data_type: Type[Structure] = None, data: list = None):
section = Section()
section.name = name
if data_type is not None and data is not None:
section.data_size = sizeof(data_type)
section.data_count = len(data)
fp.write(section)
if data is not None:
for datum in data:
fp.write(datum)
def export(self, path: str):
with open(path, 'wb') as fp:
self.write_section(fp, b'ANIMHEAD')
self.write_section(fp, b'BONENAMES', Psa.Bone, self.psa.bones)
self.write_section(fp, b'ANIMINFO', Psa.Sequence, list(self.psa.sequences.values()))
self.write_section(fp, b'ANIMKEYS', Psa.Key, self.psa.keys)
class PsaExportActionListItem(PropertyGroup):
action: PointerProperty(type=Action)
action_name: StringProperty()
is_selected: BoolProperty(default=False)
@property
def name(self):
return self.action.name
class PsaExportPropertyGroup(PropertyGroup):
action_list: CollectionProperty(type=PsaExportActionListItem)
action_list_index: IntProperty(default=0)
bone_filter_mode: EnumProperty(
name='Bone Filter',
description='',
items=(
('ALL', 'All', 'All bones will be exported.'),
('BONE_GROUPS', 'Bone Groups', 'Only bones belonging to the selected bone groups and their ancestors will be exported.')
)
)
bone_group_list: CollectionProperty(type=BoneGroupListItem)
bone_group_list_index: IntProperty(default=0)
def is_bone_filter_mode_item_available(context, identifier):
if identifier == "BONE_GROUPS":
obj = context.active_object
if not obj.pose or not obj.pose.bone_groups:
return False
return True
class PsaExportOperator(Operator, ExportHelper):
bl_idname = 'export.psa'
bl_label = 'Export'
__doc__ = 'Export actions to PSA'
filename_ext = '.psa'
filter_glob: StringProperty(default='*.psa', options={'HIDDEN'})
filepath: StringProperty(
name='File Path',
description='File path used for exporting the PSA file',
maxlen=1024,
default='')
def __init__(self):
self.armature = None
def draw(self, context):
layout = self.layout
property_group = context.scene.psa_export
# ACTIONS
box = layout.box()
box.label(text='Actions', icon='ACTION')
row = box.row()
row.template_list('PSA_UL_ExportActionList', 'asd', property_group, 'action_list', property_group, 'action_list_index', rows=10)
row = box.row(align=True)
row.label(text='Select')
row.operator('psa_export.actions_select_all', text='All')
row.operator('psa_export.actions_deselect_all', text='None')
# BONES
box = layout.box()
box.label(text='Bones', icon='BONE_DATA')
bone_filter_mode_items = property_group.bl_rna.properties['bone_filter_mode'].enum_items_static
row = box.row(align=True)
for item in bone_filter_mode_items:
identifier = item.identifier
item_layout = row.row(align=True)
item_layout.prop_enum(property_group, 'bone_filter_mode', item.identifier)
item_layout.enabled = is_bone_filter_mode_item_available(context, identifier)
if property_group.bone_filter_mode == 'BONE_GROUPS':
box = layout.box()
row = box.row()
rows = max(3, min(len(property_group.bone_group_list), 10))
row.template_list('PSX_UL_BoneGroupList', '', property_group, 'bone_group_list', property_group, 'bone_group_list_index', rows=rows)
def is_action_for_armature(self, action):
if len(action.fcurves) == 0:
return False
bone_names = set([x.name for x in self.armature.data.bones])
for fcurve in action.fcurves:
match = re.match(r'pose\.bones\["(.+)"\].\w+', fcurve.data_path)
if not match:
continue
bone_name = match.group(1)
if bone_name in bone_names:
return True
return False
def invoke(self, context, event):
property_group = context.scene.psa_export
if context.view_layer.objects.active is None:
self.report({'ERROR_INVALID_CONTEXT'}, 'An armature must be selected')
return {'CANCELLED'}
if context.view_layer.objects.active.type != 'ARMATURE':
self.report({'ERROR_INVALID_CONTEXT'}, 'The selected object must be an armature.')
return {'CANCELLED'}
self.armature = context.view_layer.objects.active
# Populate actions list.
property_group.action_list.clear()
for action in bpy.data.actions:
item = property_group.action_list.add()
item.action = action
item.action_name = action.name
if self.is_action_for_armature(action):
item.is_selected = True
if len(property_group.action_list) == 0:
# If there are no actions at all, we have nothing to export, so just cancel the operation.
self.report({'ERROR_INVALID_CONTEXT'}, 'There are no actions to export.')
return {'CANCELLED'}
# Populate bone groups list.
populate_bone_group_list(self.armature, property_group.bone_group_list)
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def execute(self, context):
property_group = context.scene.psa_export
actions = [x.action for x in property_group.action_list if x.is_selected]
if len(actions) == 0:
self.report({'ERROR_INVALID_CONTEXT'}, 'No actions were selected for export.')
return {'CANCELLED'}
options = PsaBuilderOptions()
options.actions = actions
options.bone_filter_mode = property_group.bone_filter_mode
options.bone_group_indices = [x.index for x in property_group.bone_group_list if x.is_selected]
builder = PsaBuilder()
try:
psa = builder.build(context, options)
except RuntimeError as e:
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
return {'CANCELLED'}
exporter = PsaExporter(psa)
exporter.export(self.filepath)
return {'FINISHED'}
class PSA_UL_ExportActionList(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
layout.alignment = 'LEFT'
layout.prop(item, 'is_selected', icon_only=True)
layout.label(text=item.action_name)
def filter_items(self, context, data, property):
actions = getattr(data, property)
flt_flags = []
flt_neworder = []
if self.filter_name:
flt_flags = bpy.types.UI_UL_list.filter_items_by_name(
self.filter_name,
self.bitflag_filter_item,
actions,
'action_name',
reverse=self.use_filter_invert
)
return flt_flags, flt_neworder
class PsaExportSelectAll(bpy.types.Operator):
bl_idname = 'psa_export.actions_select_all'
bl_label = 'Select All'
bl_description = 'Select all actions'
@classmethod
def poll(cls, context):
property_group = context.scene.psa_export
action_list = property_group.action_list
has_unselected_actions = any(map(lambda action: not action.is_selected, action_list))
return len(action_list) > 0 and has_unselected_actions
def execute(self, context):
property_group = context.scene.psa_export
for action in property_group.action_list:
action.is_selected = True
return {'FINISHED'}
class PsaExportDeselectAll(bpy.types.Operator):
bl_idname = 'psa_export.actions_deselect_all'
bl_label = 'Deselect All'
bl_description = 'Deselect all actions'
@classmethod
def poll(cls, context):
property_group = context.scene.psa_export
action_list = property_group.action_list
has_selected_actions = any(map(lambda action: action.is_selected, action_list))
return len(action_list) > 0 and has_selected_actions
def execute(self, context):
property_group = context.scene.psa_export
for action in property_group.action_list:
action.is_selected = False
return {'FINISHED'}
__classes__ = [
PsaExportActionListItem,
PsaExportPropertyGroup,
PsaExportOperator,
PSA_UL_ExportActionList,
PsaExportSelectAll,
PsaExportDeselectAll,
]

View File

@@ -0,0 +1,411 @@
import bpy
import os
import numpy as np
from mathutils import Vector, Quaternion, Matrix
from .data import Psa
from typing import List, AnyStr, Optional
from bpy.types import Operator, Action, UIList, PropertyGroup, Panel, Armature, FileSelectParams
from bpy_extras.io_utils import ExportHelper, ImportHelper
from bpy.props import StringProperty, BoolProperty, CollectionProperty, PointerProperty, IntProperty
from .reader import PsaReader
class PsaImporter(object):
def __init__(self):
pass
def import_psa(self, psa_reader: PsaReader, sequence_names: List[AnyStr], armature_object):
sequences = map(lambda x: psa_reader.sequences[x], sequence_names)
armature_data = armature_object.data
class ImportBone(object):
def __init__(self, psa_bone: Psa.Bone):
self.psa_bone: Psa.Bone = psa_bone
self.parent: Optional[ImportBone] = None
self.armature_bone = None
self.pose_bone = None
self.orig_loc: Vector = Vector()
self.orig_quat: Quaternion = Quaternion()
self.post_quat: Quaternion = Quaternion()
self.fcurves = []
def calculate_fcurve_data(import_bone: ImportBone, key_data: []):
# Convert world-space transforms to local-space transforms.
key_rotation = Quaternion(key_data[0:4])
key_location = Vector(key_data[4:])
q = import_bone.post_quat.copy()
q.rotate(import_bone.orig_quat)
quat = q
q = import_bone.post_quat.copy()
if import_bone.parent is None:
q.rotate(key_rotation.conjugated())
else:
q.rotate(key_rotation)
quat.rotate(q.conjugated())
loc = key_location - import_bone.orig_loc
loc.rotate(import_bone.post_quat.conjugated())
return quat.w, quat.x, quat.y, quat.z, loc.x, loc.y, loc.z
# Create an index mapping from bones in the PSA to bones in the target armature.
psa_to_armature_bone_indices = {}
armature_bone_names = [x.name for x in armature_data.bones]
psa_bone_names = []
for psa_bone_index, psa_bone in enumerate(psa_reader.bones):
psa_bone_name = psa_bone.name.decode('windows-1252')
psa_bone_names.append(psa_bone_name)
try:
psa_to_armature_bone_indices[psa_bone_index] = armature_bone_names.index(psa_bone_name)
except ValueError:
pass
# Report if there are missing bones in the target armature.
missing_bone_names = set(psa_bone_names).difference(set(armature_bone_names))
if len(missing_bone_names) > 0:
print(f'The armature object \'{armature_object.name}\' is missing the following bones that exist in the PSA:')
print(list(sorted(missing_bone_names)))
del armature_bone_names
# Create intermediate bone data for import operations.
import_bones = []
import_bones_dict = dict()
for psa_bone_index, psa_bone in enumerate(psa_reader.bones):
bone_name = psa_bone.name.decode('windows-1252')
if psa_bone_index not in psa_to_armature_bone_indices: # TODO: replace with bone_name in armature_data.bones
# PSA bone does not map to armature bone, skip it and leave an empty bone in its place.
import_bones.append(None)
continue
import_bone = ImportBone(psa_bone)
import_bone.armature_bone = armature_data.bones[bone_name]
import_bone.pose_bone = armature_object.pose.bones[bone_name]
import_bones_dict[bone_name] = import_bone
import_bones.append(import_bone)
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]
# Calculate the original location & rotation of each bone (in world-space maybe?)
if armature_bone.get('orig_quat') is not None:
# TODO: ideally we don't rely on bone auxiliary data like this, the non-aux data path is incorrect (animations are flipped 180 around Z)
import_bone.orig_quat = Quaternion(armature_bone['orig_quat'])
import_bone.orig_loc = Vector(armature_bone['orig_loc'])
import_bone.post_quat = Quaternion(armature_bone['post_quat'])
else:
if import_bone.parent is not None:
import_bone.orig_loc = armature_bone.matrix_local.translation - armature_bone.parent.matrix_local.translation
import_bone.orig_loc.rotate(armature_bone.parent.matrix_local.to_quaternion().conjugated())
import_bone.orig_quat = armature_bone.matrix_local.to_quaternion()
import_bone.orig_quat.rotate(armature_bone.parent.matrix_local.to_quaternion().conjugated())
import_bone.orig_quat.conjugate()
else:
import_bone.orig_loc = armature_bone.matrix_local.translation.copy()
import_bone.orig_quat = armature_bone.matrix_local.to_quaternion()
import_bone.post_quat = import_bone.orig_quat.conjugated()
# Create and populate the data for new sequences.
for sequence in sequences:
# Add the action.
action = bpy.data.actions.new(name=sequence.name.decode())
# 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():
import_bone = import_bones[psa_bone_index]
pose_bone = import_bone.pose_bone
rotation_data_path = pose_bone.path_from_id('rotation_quaternion')
location_data_path = pose_bone.path_from_id('location')
import_bone.fcurves = [
action.fcurves.new(rotation_data_path, index=0), # Qw
action.fcurves.new(rotation_data_path, index=1), # Qx
action.fcurves.new(rotation_data_path, index=2), # Qy
action.fcurves.new(rotation_data_path, index=3), # Qz
action.fcurves.new(location_data_path, index=0), # Lx
action.fcurves.new(location_data_path, index=1), # Ly
action.fcurves.new(location_data_path, index=2), # Lz
]
# Read the sequence keys from the PSA file.
sequence_name = sequence.name.decode('windows-1252')
# Read the sequence data matrix from the PSA.
sequence_data_matrix = psa_reader.read_sequence_data_matrix(sequence_name)
keyframe_write_matrix = np.ones(sequence_data_matrix.shape, dtype=np.int8)
# The first step is to determine the frames at which each bone will write out a keyframe.
threshold = 0.001
for bone_index, import_bone in enumerate(import_bones):
if import_bone is None:
continue
for fcurve_index, fcurve in enumerate(import_bone.fcurves):
# Get all the keyframe data for the bone's f-curve data from the sequence data matrix.
fcurve_frame_data = sequence_data_matrix[:, bone_index, fcurve_index]
last_written_datum = 0
for frame_index, datum in enumerate(fcurve_frame_data):
# If the f-curve data is not different enough to the last written frame, un-mark this data for writing.
if frame_index > 0 and abs(datum - last_written_datum) < threshold:
keyframe_write_matrix[frame_index, bone_index, fcurve_index] = 0
else:
last_written_datum = fcurve_frame_data[frame_index]
# Write the keyframes out!
for frame_index in range(sequence.frame_count):
for bone_index, import_bone in enumerate(import_bones):
if import_bone is None:
continue
bone_has_writeable_keyframes = any(keyframe_write_matrix[frame_index, bone_index])
if bone_has_writeable_keyframes:
# This bone has writeable keyframes for this frame.
key_data = sequence_data_matrix[frame_index, bone_index]
# Calculate the local-space key data for the bone.
fcurve_data = calculate_fcurve_data(import_bone, key_data)
for fcurve, should_write, datum in zip(import_bone.fcurves, keyframe_write_matrix[frame_index, bone_index], fcurve_data):
if should_write:
fcurve.keyframe_points.insert(frame_index, datum, options={'FAST'})
class PsaImportPsaBoneItem(PropertyGroup):
bone_name: StringProperty()
@property
def name(self):
return self.bone_name
class PsaImportActionListItem(PropertyGroup):
action_name: StringProperty()
frame_count: IntProperty()
is_selected: BoolProperty(default=False)
@property
def name(self):
return self.action_name
def on_psa_file_path_updated(property, context):
print('PATH UPDATED')
property_group = context.scene.psa_import
property_group.action_list.clear()
property_group.psa_bones.clear()
try:
# Read the file and populate the action list.
p = os.path.abspath(property_group.psa_file_path)
psa_reader = PsaReader(p)
for sequence in psa_reader.sequences.values():
item = property_group.action_list.add()
item.action_name = sequence.name.decode('windows-1252')
item.frame_count = sequence.frame_count
item.is_selected = True
for psa_bone in psa_reader.bones:
item = property_group.psa_bones.add()
item.bone_name = psa_bone.name
except IOError as e:
print('ERROR READING FILE')
print(e)
# TODO: set an error somewhere so the user knows the PSA could not be read.
pass
def on_armature_object_updated(property, context):
# TODO: ensure that there are matching bones between the two rigs.
property_group = context.scene.psa_import
armature_object = property_group.armature_object
if armature_object is not None:
armature_bone_names = set(map(lambda bone: bone.name, armature_object.data.bones))
psa_bone_names = set(map(lambda psa_bone: psa_bone.name, property_group.psa_bones))
class PsaImportPropertyGroup(bpy.types.PropertyGroup):
psa_file_path: StringProperty(default='', update=on_psa_file_path_updated, name='PSA File Path')
psa_bones: CollectionProperty(type=PsaImportPsaBoneItem)
# armature_object: PointerProperty(name='Object', type=bpy.types.Object, update=on_armature_object_updated)
action_list: CollectionProperty(type=PsaImportActionListItem)
action_list_index: IntProperty(name='', default=0)
action_filter_name: StringProperty(default='')
class PSA_UL_ImportActionList(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
row = layout.row(align=True)
split = row.split(align=True, factor=0.75)
action_col = split.row(align=True)
action_col.alignment = 'LEFT'
action_col.prop(item, 'is_selected', icon_only=True)
action_col.label(text=item.action_name)
def draw_filter(self, context, layout):
row = layout.row()
subrow = row.row(align=True)
subrow.prop(self, 'filter_name', text="")
subrow.prop(self, 'use_filter_invert', text="", icon='ARROW_LEFTRIGHT')
subrow = row.row(align=True)
subrow.prop(self, 'use_filter_sort_reverse', text='', icon='SORT_ASC')
def filter_items(self, context, data, property):
actions = getattr(data, property)
flt_flags = []
flt_neworder = []
if self.filter_name:
flt_flags = bpy.types.UI_UL_list.filter_items_by_name(
self.filter_name,
self.bitflag_filter_item,
actions,
'action_name',
reverse=self.use_filter_invert
)
return flt_flags, flt_neworder
class PsaImportSelectAll(bpy.types.Operator):
bl_idname = 'psa_import.actions_select_all'
bl_label = 'All'
bl_description = 'Select all actions'
@classmethod
def poll(cls, context):
property_group = context.scene.psa_import
action_list = property_group.action_list
has_unselected_actions = any(map(lambda action: not action.is_selected, action_list))
return len(action_list) > 0 and has_unselected_actions
def execute(self, context):
property_group = context.scene.psa_import
for action in property_group.action_list:
action.is_selected = True
return {'FINISHED'}
class PsaImportDeselectAll(bpy.types.Operator):
bl_idname = 'psa_import.actions_deselect_all'
bl_label = 'None'
bl_description = 'Deselect all actions'
@classmethod
def poll(cls, context):
property_group = context.scene.psa_import
action_list = property_group.action_list
has_selected_actions = any(map(lambda action: action.is_selected, action_list))
return len(action_list) > 0 and has_selected_actions
def execute(self, context):
property_group = context.scene.psa_import
for action in property_group.action_list:
action.is_selected = False
return {'FINISHED'}
class PSA_PT_ImportPanel(Panel):
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_label = 'PSA Import'
bl_context = 'data'
bl_category = 'PSA Import'
bl_options = {'DEFAULT_CLOSED'}
@classmethod
def poll(cls, context):
return context.object.type == 'ARMATURE'
def draw(self, context):
layout = self.layout
property_group = context.scene.psa_import
row = layout.row()
row.prop(property_group, 'psa_file_path', text='')
row.enabled = False
# row.enabled = property_group.psa_file_path is not ''
row = layout.row()
layout.separator()
row.operator('psa_import.select_file', text='Select PSA File', icon='FILEBROWSER')
if len(property_group.action_list) > 0:
box = layout.box()
box.label(text=f'Actions ({len(property_group.action_list)})', icon='ACTION')
row = box.row()
rows = max(3, min(len(property_group.action_list), 10))
row.template_list('PSA_UL_ImportActionList', '', property_group, 'action_list', property_group, 'action_list_index', rows=rows)
row = box.row(align=True)
row.label(text='Select')
row.operator('psa_import.actions_select_all', text='All')
row.operator('psa_import.actions_deselect_all', text='None')
layout.separator()
layout.operator('psa_import.import', text=f'Import')
class PsaImportSelectFile(Operator):
bl_idname = 'psa_import.select_file'
bl_label = 'Select'
bl_options = {'REGISTER', 'UNDO'}
bl_description = 'Select a PSA file from which to import animations'
filepath: bpy.props.StringProperty(subtype='FILE_PATH')
filter_glob: bpy.props.StringProperty(default="*.psa", options={'HIDDEN'})
def execute(self, context):
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"}
class PsaImportOperator(Operator):
bl_idname = 'psa_import.import'
bl_label = 'Import'
bl_description = 'Import the selected animations into the scene as actions'
@classmethod
def poll(cls, context):
property_group = context.scene.psa_import
active_object = context.view_layer.objects.active
action_list = property_group.action_list
has_selected_actions = any(map(lambda action: action.is_selected, action_list))
return has_selected_actions and active_object is not None and active_object.type == 'ARMATURE'
def execute(self, context):
property_group = context.scene.psa_import
psa_reader = PsaReader(property_group.psa_file_path)
sequence_names = [x.action_name for x in property_group.action_list if x.is_selected]
PsaImporter().import_psa(psa_reader, sequence_names, context.view_layer.objects.active)
self.report({'INFO'}, f'Imported {len(sequence_names)} action(s)')
return {'FINISHED'}
class PsaImportFileSelectOperator(Operator, ImportHelper):
bl_idname = 'psa_import.file_select'
bl_label = 'File Select'
filename_ext = '.psa'
filter_glob: StringProperty(default='*.psa', options={'HIDDEN'})
filepath: StringProperty(
name='File Path',
description='File path used for importing the PSA file',
maxlen=1024,
default='')
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def execute(self, context):
property_group = context.scene.psa_import
property_group.psa_file_path = self.filepath
# Load the sequence names from the selected file
return {'FINISHED'}
__classes__ = [
PsaImportPsaBoneItem,
PsaImportActionListItem,
PsaImportPropertyGroup,
PSA_UL_ImportActionList,
PsaImportSelectAll,
PsaImportDeselectAll,
PSA_PT_ImportPanel,
PsaImportOperator,
PsaImportFileSelectOperator,
PsaImportSelectFile,
]

View File

@@ -0,0 +1,91 @@
from .data import *
import ctypes
import numpy as np
class PsaReader(object):
"""
This class will read the sequences and bone information immediately upon instantiation and hold onto a file handle.
The key data is not read into memory upon instantiation due to it's potentially very large size.
To read the key data for a particular sequence, call `read_sequence_keys`.
"""
def __init__(self, path):
self.keys_data_offset: int = 0
self.fp = open(path, 'rb')
self.psa: Psa = self._read(self.fp)
@property
def bones(self):
return self.psa.bones
@property
def sequences(self):
return self.psa.sequences
@staticmethod
def _read_types(fp, data_class: ctypes.Structure, section: Section, data):
buffer_length = section.data_size * section.data_count
buffer = fp.read(buffer_length)
offset = 0
for _ in range(section.data_count):
data.append(data_class.from_buffer_copy(buffer, offset))
offset += section.data_size
def read_sequence_data_matrix(self, sequence_name: str):
sequence = self.psa.sequences[sequence_name]
keys = self.read_sequence_keys(sequence_name)
bone_count = len(self.bones)
matrix_size = sequence.frame_count, bone_count, 7
matrix = np.zeros(matrix_size)
keys_iter = iter(keys)
for frame_index in range(sequence.frame_count):
for bone_index in range(bone_count):
matrix[frame_index, bone_index, :] = list(next(keys_iter).data)
return matrix
def read_sequence_keys(self, sequence_name: str) -> List[Psa.Key]:
""" Reads and returns the key data for a sequence.
:param sequence_name: The name of the sequence.
:return: A list of Psa.Keys.
"""
# Set the file reader to the beginning of the keys data
sequence = self.psa.sequences[sequence_name]
data_size = sizeof(Psa.Key)
bone_count = len(self.psa.bones)
buffer_length = data_size * bone_count * sequence.frame_count
sequence_keys_offset = self.keys_data_offset + (sequence.frame_start_index * bone_count * data_size)
self.fp.seek(sequence_keys_offset, 0)
buffer = self.fp.read(buffer_length)
offset = 0
keys = []
for _ in range(sequence.frame_count * bone_count):
key = Psa.Key.from_buffer_copy(buffer, offset)
keys.append(key)
offset += data_size
return keys
def _read(self, fp) -> Psa:
psa = Psa()
while fp.read(1):
fp.seek(-1, 1)
section = Section.from_buffer_copy(fp.read(ctypes.sizeof(Section)))
if section.name == b'ANIMHEAD':
pass
elif section.name == b'BONENAMES':
PsaReader._read_types(fp, Psa.Bone, section, psa.bones)
elif section.name == b'ANIMINFO':
sequences = []
PsaReader._read_types(fp, Psa.Sequence, section, sequences)
for sequence in sequences:
psa.sequences[sequence.name.decode()] = sequence
elif section.name == b'ANIMKEYS':
# 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 in [b'SCALEKEYS']:
fp.seek(section.data_size * section.data_count, 1)
else:
raise RuntimeError(f'Unrecognized section "{section.name}"')
return psa
1