Compare commits

..

15 Commits
4.0.1 ... 4.2.0

Author SHA1 Message Date
Colin Basnett
17e9e83826 Incremented version to 4.2.0 2022-11-25 12:44:50 -08:00
Colin Basnett
44afce3e64 Improved PSA import speed dramatically 2022-11-24 16:38:06 -08:00
Colin Basnett
449331cd00 Fixed a bug where certain material errors would not display correctly 2022-11-22 12:57:44 -08:00
Colin Basnett
8ada80e243 Typing and naming improvements 2022-11-22 12:57:06 -08:00
Colin Basnett
38ed183897 Fixed a bug where the root bone orientation would be exported incorrectly 2022-11-22 12:55:56 -08:00
Colin Basnett
9ae573422b Fixed a bug where actions would not be considered to be "for" an armature if its only F-Curve data was for custom properties on a bone 2022-11-16 12:34:09 -08:00
Colin Basnett
86473584b8 Fixing warnings and typing 2022-11-12 16:26:40 -08:00
Colin Basnett
aa8725c3d0 Added a "Bone Mapping" option.
This allows imported PSA actions to map to armature bones with names
that differ only by case.

In addition, import warnings are now written to the info log so they
have better visibility to the user.
2022-11-12 16:25:19 -08:00
Colin Basnett
980042fc7f Incremented version to 4.1.0 2022-11-01 11:34:31 -07:00
Colin Basnett
02082b9594 Added an option to bake imported PSA sequences to samples.
Also, the "Clean Keyframes" option has been removed from the PSA import
because it did not work correctly.

This will be replaced by a "Decimate F-Curves" option in the future.
2022-11-01 11:33:39 -07:00
Colin Basnett
4181a15d0e Improved comment and typing information 2022-11-01 11:32:50 -07:00
Colin Basnett
b6ef3dda44 Simplified method used to put armature in bind pose for PSK export 2022-11-01 11:32:25 -07:00
Colin Basnett
f7290e6808 The scene's frame and selected object's active action is now restored after PSA export 2022-11-01 11:30:13 -07:00
Colin Basnett
65d3104ea9 Added an option to ignore bone name restrictions 2022-11-01 11:28:55 -07:00
Colin Basnett
1a48128cb9 Updated bone name constraint message 2022-08-30 23:44:12 -07:00
11 changed files with 234 additions and 119 deletions

View File

@@ -1,7 +1,7 @@
bl_info = {
"name": "PSK/PSA Importer/Exporter",
"author": "Colin Basnett, Yurii Ti",
"version": (4, 0, 1),
"version": (4, 2, 0),
"blender": (2, 80, 0),
# "location": "File > Export > PSK Export (.psk)",
"description": "PSK/PSA Import/Export (.psk/.psa)",

View File

@@ -47,7 +47,7 @@ def get_nla_strips_in_timeframe(animation_data, frame_min, frame_max) -> List[Nl
return strips
def populate_bone_group_list(armature_object: Object, bone_group_list: bpy.types.Collection) -> None:
def populate_bone_group_list(armature_object: Object, bone_group_list: bpy.props.CollectionProperty) -> None:
"""
Updates the bone group collection.
@@ -94,11 +94,11 @@ def get_psa_sequence_name(action, should_use_original_sequence_name):
def check_bone_names(bone_names: Iterable[str]):
pattern = re.compile(r'^[a-zA-Z0-9_\- ]+$')
pattern = re.compile(r'^[a-zA-Z\d_\- ]+$')
invalid_bone_names = [x for x in bone_names if pattern.match(x) is None]
if len(invalid_bone_names) > 0:
raise RuntimeError(f'The following bone names are invalid: {invalid_bone_names}.\n'
f'Bone names must only contain letters, numbers, spaces, and underscores.')
f'Bone names must only contain letters, numbers, spaces, hyphens and underscores.')
def get_export_bone_names(armature_object, bone_filter_mode, bone_group_indices: List[int]) -> List[str]:

View File

@@ -1,6 +1,6 @@
from typing import Dict
from bpy.types import Action
from bpy.types import Action, Armature, Bone
from .data import *
from ..helpers import *
@@ -19,6 +19,7 @@ class PsaBuildOptions(object):
self.bone_group_indices = []
self.should_use_original_sequence_names = False
self.should_trim_timeline_marker_sequences = True
self.should_ignore_bone_name_restrictions = False
self.sequence_name_prefix = ''
self.sequence_name_suffix = ''
self.root_motion = False
@@ -87,7 +88,7 @@ def get_timeline_marker_sequence_frame_ranges(animation_data, context, options:
return sequence_frame_ranges
def build_psa(context, options: PsaBuildOptions) -> Psa:
def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
active_object = context.view_layer.objects.active
if active_object.type != 'ARMATURE':
@@ -111,19 +112,20 @@ def build_psa(context, options: PsaBuildOptions) -> Psa:
psa = Psa()
armature = active_object
bones = list(armature.data.bones)
armature_object = active_object
armature_data = typing.cast(Armature, armature_object.data)
bones: List[Bone] = list(iter(armature_data.bones))
# The order of the armature bones and the pose bones is not guaranteed to be the same.
# 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]
pose_bones = [(bone_names.index(bone.name), bone) for bone in armature_object.pose.bones]
pose_bones.sort(key=lambda x: x[0])
pose_bones = [x[1] for x in pose_bones]
# Get a list of all the bone indices and instigator bones for the bone filter settings.
export_bone_names = get_export_bone_names(armature, options.bone_filter_mode, options.bone_group_indices)
export_bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, options.bone_group_indices)
bone_indices = [bone_names.index(x) for x in export_bone_names]
# Make the bone lists contain only the bones that are going to be exported.
@@ -135,7 +137,8 @@ def build_psa(context, options: PsaBuildOptions) -> Psa:
raise RuntimeError('No bones available for export')
# Check that all bone names are valid.
check_bone_names(map(lambda bone: bone.name, bones))
if not options.should_ignore_bone_name_restrictions:
check_bone_names(map(lambda bone: bone.name, bones))
# Build list of PSA bones.
for bone in bones:
@@ -156,9 +159,12 @@ def build_psa(context, options: PsaBuildOptions) -> Psa:
parent_tail = inverse_parent_rotation @ bone.parent.tail
location = (parent_tail - parent_head) + bone.head
else:
location = armature.matrix_local @ bone.head
rot_matrix = bone.matrix @ armature.matrix_local.to_3x3()
rotation = rot_matrix.to_quaternion()
armature_local_matrix = armature_object.matrix_local
location = armature_local_matrix @ bone.head
bone_rotation = bone.matrix.to_quaternion().conjugated()
local_rotation = armature_local_matrix.to_3x3().to_quaternion().conjugated()
rotation = bone_rotation @ local_rotation
rotation.conjugate()
psa_bone.location.x = location.x
psa_bone.location.y = location.y
@@ -208,6 +214,7 @@ def build_psa(context, options: PsaBuildOptions) -> Psa:
export_sequence.nla_state.action = None
export_sequence.nla_state.frame_min = frame_min
export_sequence.nla_state.frame_max = frame_max
nla_strips_actions = set(
map(lambda x: x.action, get_nla_strips_in_timeframe(animation_data, frame_min, frame_max)))
export_sequence.fps = get_sequence_fps(context, options, nla_strips_actions)
@@ -220,6 +227,10 @@ def build_psa(context, options: PsaBuildOptions) -> Psa:
export_sequence.name = f'{options.sequence_name_prefix}{export_sequence.name}{options.sequence_name_suffix}'
export_sequence.name = export_sequence.name.strip()
# Save the current action and frame so that we can restore the state once we are done.
saved_frame_current = context.scene.frame_current
saved_action = animation_data.action
# Now build the PSA sequences.
# We actually alter the timeline frame and simply record the resultant pose bone matrices.
frame_start_index = 0
@@ -252,7 +263,7 @@ def build_psa(context, options: PsaBuildOptions) -> Psa:
else:
if options.root_motion:
# Export root motion
pose_bone_matrix = armature.matrix_world @ pose_bone.matrix
pose_bone_matrix = armature_object.matrix_world @ pose_bone.matrix
else:
pose_bone_matrix = pose_bone.matrix
@@ -280,4 +291,8 @@ def build_psa(context, options: PsaBuildOptions) -> Psa:
psa.sequences[export_sequence.name] = psa_sequence
# Restore the previous action & frame.
animation_data.action = saved_action
context.scene.frame_set(saved_frame_current)
return psa

View File

@@ -6,7 +6,7 @@ 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.
Use the PsaReader::get_sequence_keys to get the keys for a sequence.
"""
@@ -60,5 +60,5 @@ class Psa(object):
def __init__(self):
self.bones: List[Psa.Bone] = []
self.sequences: typing.OrderedDict[Psa.Sequence] = OrderedDict()
self.sequences: typing.OrderedDict[str, Psa.Sequence] = OrderedDict()
self.keys: List[Psa.Key] = []

View File

@@ -136,6 +136,12 @@ class PsaExportPropertyGroup(PropertyGroup):
description='Frames without NLA track information at the boundaries of timeline markers will be excluded from '
'the exported sequences '
)
should_ignore_bone_name_restrictions: BoolProperty(
default=False,
name='Ignore Bone Name Restrictions',
description='Bone names restrictions will be ignored. Note that bone names without properly formatted names '
'cannot be referenced in scripts.'
)
sequence_name_prefix: StringProperty(name='Prefix', options=empty_set)
sequence_name_suffix: StringProperty(name='Suffix', options=empty_set)
sequence_filter_name: StringProperty(
@@ -262,6 +268,8 @@ class PsaExportOperator(Operator, ExportHelper):
layout.template_list('PSX_UL_BoneGroupList', '', pg, 'bone_group_list', pg, 'bone_group_list_index',
rows=rows)
layout.prop(pg, 'should_ignore_bone_name_restrictions')
layout.separator()
# ROOT MOTION
@@ -272,7 +280,7 @@ class PsaExportOperator(Operator, ExportHelper):
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)
match = re.match(r'pose\.bones\[\"([^\"]+)\"](\[\"([^\"]+)\"])?', fcurve.data_path)
if not match:
continue
bone_name = match.group(1)
@@ -346,6 +354,7 @@ class PsaExportOperator(Operator, ExportHelper):
options.bone_group_indices = [x.index for x in pg.bone_group_list if x.is_selected]
options.should_use_original_sequence_names = pg.should_use_original_sequence_names
options.should_trim_timeline_marker_sequences = pg.should_trim_timeline_marker_sequences
options.should_ignore_bone_name_restrictions = pg.should_ignore_bone_name_restrictions
options.sequence_name_prefix = pg.sequence_name_prefix
options.sequence_name_suffix = pg.sequence_name_suffix
options.root_motion = pg.root_motion

View File

@@ -1,12 +1,14 @@
import fnmatch
import os
import re
import typing
from collections import Counter
from typing import List, Optional
import bpy
import numpy as np
from bpy.props import StringProperty, BoolProperty, CollectionProperty, PointerProperty, IntProperty
from bpy.types import Operator, UIList, PropertyGroup, Panel
import numpy
from bpy.props import StringProperty, BoolProperty, CollectionProperty, PointerProperty, IntProperty, EnumProperty
from bpy.types import Operator, UIList, PropertyGroup, Panel, FCurve
from bpy_extras.io_utils import ImportHelper
from mathutils import Vector, Quaternion
@@ -16,7 +18,6 @@ from .reader import PsaReader
class PsaImportOptions(object):
def __init__(self):
self.should_clean_keys = True
self.should_use_fake_user = False
self.should_stash = False
self.sequence_names = []
@@ -24,6 +25,8 @@ class PsaImportOptions(object):
self.should_write_keyframes = True
self.should_write_metadata = True
self.action_name_prefix = ''
self.should_convert_to_samples = False
self.bone_mapping_mode = 'CASE_INSENSITIVE'
class ImportBone(object):
@@ -35,10 +38,10 @@ class ImportBone(object):
self.orig_loc: Vector = Vector()
self.orig_quat: Quaternion = Quaternion()
self.post_quat: Quaternion = Quaternion()
self.fcurves = []
self.fcurves: List[FCurve] = []
def calculate_fcurve_data(import_bone: ImportBone, key_data: []):
def calculate_fcurve_data(import_bone: ImportBone, key_data: typing.Iterable[float]):
# Convert world-space transforms to local-space transforms.
key_rotation = Quaternion(key_data[0:4])
key_location = Vector(key_data[4:])
@@ -56,44 +59,73 @@ def calculate_fcurve_data(import_bone: ImportBone, key_data: []):
return quat.w, quat.x, quat.y, quat.z, loc.x, loc.y, loc.z
def import_psa(psa_reader: PsaReader, armature_object, options: PsaImportOptions):
class PsaImportResult:
def __init__(self):
self.warnings: List[str] = []
def import_psa(psa_reader: PsaReader, armature_object: bpy.types.Object, options: PsaImportOptions) -> PsaImportResult:
result = PsaImportResult()
sequences = map(lambda x: psa_reader.sequences[x], options.sequence_names)
armature_data = armature_object.data
armature_data = typing.cast(bpy.types.Armature, armature_object.data)
# 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)
psa_bone_name: str = psa_bone.name.decode('windows-1252')
try:
psa_to_armature_bone_indices[psa_bone_index] = armature_bone_names.index(psa_bone_name)
except ValueError:
pass
# PSA bone could not be mapped directly to an armature bone by name.
# Attempt to create a bone mapping by ignoring the case of the names.
if options.bone_mapping_mode == 'CASE_INSENSITIVE':
for armature_bone_index, armature_bone_name in enumerate(armature_bone_names):
if armature_bone_name.upper() == psa_bone_name.upper():
psa_to_armature_bone_indices[psa_bone_index] = armature_bone_index
psa_bone_name = armature_bone_name
break
psa_bone_names.append(psa_bone_name)
# Remove ambiguous bone mappings (where multiple PSA bones correspond to the same armature bone).
armature_bone_index_counts = Counter(psa_to_armature_bone_indices.values())
for armature_bone_index, count in armature_bone_index_counts.items():
if count > 1:
psa_bone_indices = []
for psa_bone_index, mapped_bone_index in psa_to_armature_bone_indices:
if mapped_bone_index == armature_bone_index:
psa_bone_indices.append(psa_bone_index)
ambiguous_psa_bone_names = list(sorted([psa_bone_names[x] for x in psa_bone_indices]))
result.warnings.append(
f'Ambiguous mapping for bone {armature_bone_names[armature_bone_index]}!\n'
f'The following PSA bones all map to the same armature bone: {ambiguous_psa_bone_names}\n'
f'These bones will be ignored.'
)
# 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)))
result.warnings.append(
f'The armature \'{armature_object.name}\' is missing {len(missing_bone_names)} bones that exist in '
'the PSA:\n' +
str(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
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:
# 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_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
import_bones.append(import_bone)
for import_bone in filter(lambda x: x is not None, import_bones):
@@ -154,7 +186,6 @@ def import_psa(psa_reader: PsaReader, armature_object, options: PsaImportOptions
# 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)
# Convert the sequence's data from world-space to local-space.
for bone_index, import_bone in enumerate(import_bones):
@@ -166,41 +197,23 @@ def import_psa(psa_reader: PsaReader, armature_object, options: PsaImportOptions
# Calculate the local-space key data for the bone.
sequence_data_matrix[frame_index, bone_index] = calculate_fcurve_data(import_bone, key_data)
# Clean the keyframe data. This is accomplished by writing zeroes to the write matrix when there is an
# insufficiently large change in the data from the last written frame.
if options.should_clean_keys:
threshold = 0.001
for bone_index, import_bone in enumerate(import_bones):
if import_bone is None:
continue
for fcurve_index in range(len(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 = datum
# Write the keyframes out.
fcurve_data = numpy.zeros(2 * sequence.frame_count, dtype=float)
fcurve_data[0::2] = 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.fcurves):
fcurve_data[1::2] = sequence_data_matrix[:, bone_index, fcurve_index]
fcurve.keyframe_points.add(sequence.frame_count)
fcurve.keyframe_points.foreach_set('co', fcurve_data)
# 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]
for fcurve, should_write, datum in zip(import_bone.fcurves,
keyframe_write_matrix[frame_index, bone_index],
key_data):
if should_write:
fcurve.keyframe_points.insert(frame_index, datum, options={'FAST'})
if options.should_convert_to_samples:
# Bake the curve to samples.
for fcurve in action.fcurves:
fcurve.convert_to_samples(start=0, end=sequence.frame_count)
# Write
# Write meta-data.
if options.should_write_metadata:
action['psa_sequence_name'] = sequence_name
action['psa_sequence_fps'] = sequence.fps
@@ -219,6 +232,8 @@ def import_psa(psa_reader: PsaReader, armature_object, options: PsaImportOptions
nla_track.mute = True
nla_track.strips.new(name=action.name, start=0, action=action)
return result
empty_set = set()
@@ -247,7 +262,7 @@ def load_psa_file(context):
pg.psa_error = str(e)
def on_psa_file_path_updated(property, context):
def on_psa_file_path_updated(property_, context):
load_psa_file(context)
@@ -266,11 +281,9 @@ class PsaImportPropertyGroup(PropertyGroup):
psa: PointerProperty(type=PsaDataPropertyGroup)
sequence_list: CollectionProperty(type=PsaImportActionListItem)
sequence_list_index: IntProperty(name='', default=0)
should_clean_keys: BoolProperty(default=True, name='Clean Keyframes',
description='Exclude unnecessary keyframes from being written to the actions',
options=empty_set)
should_use_fake_user: BoolProperty(default=True, name='Fake User',
description='Assign each imported action a fake user so that the data block is saved even it has no users',
description='Assign each imported action a fake user so that the data block is '
'saved even it has no users',
options=empty_set)
should_stash: BoolProperty(default=False, name='Stash',
description='Stash each imported action as a strip on a new non-contributing NLA track',
@@ -278,10 +291,12 @@ class PsaImportPropertyGroup(PropertyGroup):
should_use_action_name_prefix: BoolProperty(default=False, name='Prefix Action Name', options=empty_set)
action_name_prefix: StringProperty(default='', name='Prefix', options=empty_set)
should_overwrite: BoolProperty(default=False, name='Reuse Existing Actions', options=empty_set,
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')
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_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)')
description='Additional data will be written to the custom properties of the '
'Action (e.g., frame rate)')
sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'})
sequence_filter_is_selected: BoolProperty(default=False, options=empty_set, name='Only Show Selected',
description='Only show selected sequences')
@@ -289,6 +304,22 @@ class PsaImportPropertyGroup(PropertyGroup):
sequence_use_filter_regex: BoolProperty(default=False, name='Regular Expression',
description='Filter using regular expressions', options=empty_set)
select_text: PointerProperty(type=bpy.types.Text)
should_convert_to_samples: BoolProperty(
default=False,
name='Convert to Samples',
description='Convert keyframes to read-only samples. '
'Recommended if you do not plan on editing the actions directly'
)
bone_mapping_mode: EnumProperty(
name='Bone Mapping',
options=empty_set,
description='The method by which bones from the incoming PSA file are mapped to the armature',
items=(
('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),
)
)
def filter_sequences(pg: PsaImportPropertyGroup, sequences) -> List[int]:
@@ -352,9 +383,9 @@ class PSA_UL_SequenceList(UIList):
sub_row.prop(pg, 'sequence_use_filter_regex', text="", icon='SORTBYEXT')
sub_row.prop(pg, 'sequence_filter_is_selected', text="", icon='CHECKBOX_HLT')
def filter_items(self, context, data, property):
def filter_items(self, context, data, property_):
pg = getattr(context.scene, 'psa_import')
sequences = getattr(data, property)
sequences = getattr(data, property_)
flt_flags = filter_sequences(pg, sequences)
flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(sequences, 'action_name')
return flt_flags, flt_neworder
@@ -457,11 +488,21 @@ class PSA_PT_ImportPanel_Advanced(Panel):
layout = self.layout
pg = getattr(context.scene, 'psa_import')
col = layout.column(heading="Options")
col = layout.column()
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'bone_mapping_mode')
if pg.should_write_keyframes:
col = layout.column(heading='Keyframes')
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'should_convert_to_samples')
col.separator()
col = layout.column(heading='Options')
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'should_clean_keys')
col.separator()
col.prop(pg, 'should_use_fake_user')
col.prop(pg, 'should_stash')
col.prop(pg, 'should_use_action_name_prefix')
@@ -585,17 +626,23 @@ class PsaImportOperator(Operator):
options = PsaImportOptions()
options.sequence_names = sequence_names
options.should_clean_keys = pg.should_clean_keys
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
import_psa(psa_reader, context.view_layer.objects.active, options)
result = import_psa(psa_reader, context.view_layer.objects.active, options)
self.report({'INFO'}, f'Imported {len(sequence_names)} action(s)')
if len(result.warnings) > 0:
message = f'Imported {len(sequence_names)} action(s) with {len(result.warnings)} warning(s)\n'
message += '\n'.join(result.warnings)
self.report({'WARNING'}, message)
else:
self.report({'INFO'}, f'Imported {len(sequence_names)} action(s)')
return {'FINISHED'}

View File

@@ -25,7 +25,12 @@ class PsaReader(object):
def sequences(self):
return self.psa.sequences
def read_sequence_data_matrix(self, sequence_name: str):
def read_sequence_data_matrix(self, sequence_name: str) -> np.ndarray:
"""
Reads and returns the data matrix for the given sequence.
@param sequence_name: The name of the sequence.
@return: An FxBx7 matrix where F is the number of frames, B is the number of bones.
"""
sequence = self.psa.sequences[sequence_name]
keys = self.read_sequence_keys(sequence_name)
bone_count = len(self.bones)
@@ -41,8 +46,8 @@ 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.
@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]

View File

@@ -1,10 +1,11 @@
from collections import OrderedDict
from typing import Dict, List
import typing
import bmesh
import bpy
from bpy.types import Armature
from .data import *
from ..helpers import *
import bmesh
import bpy
class PskInputObjects(object):
@@ -19,6 +20,7 @@ class PskBuildOptions(object):
self.bone_group_indices: List[int] = []
self.use_raw_mesh_data = True
self.material_names: List[str] = []
self.should_ignore_bone_name_restrictions = False
def get_psk_input_objects(context) -> PskInputObjects:
@@ -60,7 +62,7 @@ def get_psk_input_objects(context) -> PskInputObjects:
def build_psk(context, options: PskBuildOptions) -> Psk:
input_objects = get_psk_input_objects(context)
armature_object = input_objects.armature_object
armature_object: bpy.types.Object = input_objects.armature_object
psk = Psk()
bones = []
@@ -78,10 +80,12 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
psk.bones.append(psk_bone)
else:
bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, options.bone_group_indices)
bones = [armature_object.data.bones[bone_name] for bone_name in bone_names]
armature_data = typing.cast(Armature, armature_object.data)
bones = [armature_data.bones[bone_name] for bone_name in bone_names]
# Check that all bone names are valid.
check_bone_names(map(lambda x: x.name, bones))
if not options.should_ignore_bone_name_restrictions:
check_bone_names(map(lambda x: x.name, bones))
for bone in bones:
psk_bone = Psk.Bone()
@@ -98,15 +102,17 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
if bone.parent is not None:
rotation = bone.matrix.to_quaternion().conjugated()
quat_parent = bone.parent.matrix.to_quaternion().inverted()
parent_head = quat_parent @ bone.parent.head
parent_tail = quat_parent @ bone.parent.tail
inverse_parent_rotation = bone.parent.matrix.to_quaternion().inverted()
parent_head = inverse_parent_rotation @ bone.parent.head
parent_tail = inverse_parent_rotation @ bone.parent.tail
location = (parent_tail - parent_head) + bone.head
else:
local_matrix = armature_object.matrix_local
location = local_matrix @ bone.head
rot_matrix = bone.matrix @ local_matrix.to_3x3()
rotation = rot_matrix.to_quaternion()
armature_local_matrix = armature_object.matrix_local
location = armature_local_matrix @ bone.head
bone_rotation = bone.matrix.to_quaternion().conjugated()
local_rotation = armature_local_matrix.to_3x3().to_quaternion().conjugated()
rotation = bone_rotation @ local_rotation
rotation.conjugate()
psk_bone.location.x = location.x
psk_bone.location.y = location.y
@@ -133,16 +139,17 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
# MATERIALS
material_indices = [material_names.index(material.name) for material in input_mesh_object.data.materials]
# MESH DATA
if options.use_raw_mesh_data:
mesh_object = input_mesh_object
mesh_data = input_mesh_object.data
else:
# Create a copy of the mesh object after non-armature modifiers are applied.
# Temporarily deactivate any armature modifiers on the input mesh object.
active_armature_modifiers = [x for x in filter(lambda x: x.type == 'ARMATURE' and x.is_active, input_mesh_object.modifiers)]
for modifier in active_armature_modifiers:
modifier.show_viewport = False
# Temporarily force the armature into the rest position.
# We will undo this later.
old_pose_position = armature_object.data.pose_position
armature_object.data.pose_position = 'REST'
depsgraph = context.evaluated_depsgraph_get()
bm = bmesh.new()
@@ -157,9 +164,8 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
for vertex_group in input_mesh_object.vertex_groups:
mesh_object.vertex_groups.new(name=vertex_group.name)
# Reactivate previously active armature modifiers
for modifier in active_armature_modifiers:
modifier.show_viewport = True
# Restore the previous pose position on the armature.
armature_object.data.pose_position = old_pose_position
vertex_offset = len(psk.points)
@@ -217,6 +223,7 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
# WEIGHTS
if armature_object is not None:
armature_data = typing.cast(Armature, armature_object.data)
# Because the vertex groups may contain entries for which there is no matching bone in the armature,
# we must filter them out and not export any weights for these vertex groups.
bone_names = [x.name for x in bones]
@@ -230,8 +237,8 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
# Check to see if there is an associated bone for this vertex group that exists in the armature.
# If there is, we can traverse the ancestors of that bone to find an alternate bone to use for
# weighting the vertices belonging to this vertex group.
if vertex_group_name in armature_object.data.bones:
bone = armature_object.data.bones[vertex_group_name]
if vertex_group_name in armature_data.bones:
bone = armature_data.bones[vertex_group_name]
while bone is not None:
try:
bone_index = bone_names.index(bone.name)

View File

@@ -161,7 +161,12 @@ class PskExportOperator(Operator, ExportHelper):
# Populate bone groups list.
populate_bone_group_list(input_objects.armature_object, pg.bone_group_list)
populate_material_list(input_objects.mesh_objects, pg.material_list)
try:
populate_material_list(input_objects.mesh_objects, pg.material_list)
except RuntimeError as e:
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
return {'CANCELLED'}
context.window_manager.fileselect_add(self)
@@ -208,6 +213,10 @@ class PskExportOperator(Operator, ExportHelper):
col.operator(PskMaterialListItemMoveUp.bl_idname, text='', icon='TRIA_UP')
col.operator(PskMaterialListItemMoveDown.bl_idname, text='', icon='TRIA_DOWN')
layout.separator()
layout.prop(pg, 'should_ignore_bone_name_restrictions')
def execute(self, context):
pg = context.scene.psk_export
options = PskBuildOptions()
@@ -215,6 +224,7 @@ class PskExportOperator(Operator, ExportHelper):
options.bone_group_indices = [x.index for x in pg.bone_group_list if x.is_selected]
options.use_raw_mesh_data = pg.use_raw_mesh_data
options.material_names = [m.material_name for m in pg.material_list]
options.should_ignore_bone_name_restrictions = pg.should_ignore_bone_name_restrictions
try:
psk = build_psk(context, options)
@@ -242,6 +252,12 @@ class PskExportPropertyGroup(PropertyGroup):
use_raw_mesh_data: BoolProperty(default=False, name='Raw Mesh Data', description='No modifiers will be evaluated as part of the exported mesh')
material_list: CollectionProperty(type=MaterialListItem)
material_list_index: IntProperty(default=0)
should_ignore_bone_name_restrictions: BoolProperty(
default=False,
name='Ignore Bone Name Restrictions',
description='Bone names restrictions will be ignored. Note that bone names without properly formatted names '
'cannot be referenced in scripts.'
)
classes = (

View File

@@ -46,7 +46,13 @@ class ImportBone(object):
self.post_quat: Quaternion = Quaternion()
def import_psk(psk: Psk, context, options: PskImportOptions):
class PskImportResult:
def __init__(self):
self.warnings: List[str] = []
def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult:
result = PskImportResult()
armature_object = None
if options.should_import_skeleton:
@@ -142,7 +148,7 @@ def import_psk(psk: Psk, context, options: PskImportOptions):
degenerate_face_indices.add(face_index)
if len(degenerate_face_indices) > 0:
print(f'WARNING: Discarded {len(degenerate_face_indices)} degenerate face(s).')
result.warnings.append(f'Discarded {len(degenerate_face_indices)} degenerate face(s).')
bm.to_mesh(mesh_data)
@@ -200,7 +206,8 @@ def import_psk(psk: Psk, context, options: PskImportOptions):
vertex_color_data.data[loop_index].color = 1.0, 1.0, 1.0, 1.0
if len(ambiguous_vertex_color_point_indices) > 0:
print(f'WARNING: {len(ambiguous_vertex_color_point_indices)} vertex(es) with ambiguous vertex colors.')
result.warnings.append(
f'{len(ambiguous_vertex_color_point_indices)} vertex(es) with ambiguous vertex colors.')
# VERTEX NORMALS
if psk.has_vertex_normals and options.should_import_vertex_normals:
@@ -236,6 +243,8 @@ def import_psk(psk: Psk, context, options: PskImportOptions):
except:
pass
return result
empty_set = set()
@@ -320,7 +329,14 @@ class PskImportOperator(Operator, ImportHelper):
options.should_import_skeleton = pg.should_import_skeleton
options.bone_length = pg.bone_length
import_psk(psk, context, options)
result = import_psk(psk, context, options)
if len(result.warnings):
message = f'PSK imported with {len(result.warnings)} warning(s)\n'
message += '\n'.join(result.warnings)
self.report({'WARNING'}, message)
else:
self.report({'INFO'}, f'PSK imported')
return {'FINISHED'}

View File

@@ -12,7 +12,7 @@ def _read_types(fp, data_class, section: Section, data):
offset += section.data_size
def read_psk(path) -> Psk:
def read_psk(path: str) -> Psk:
psk = Psk()
with open(path, 'rb') as fp:
while fp.read(1):