Compare commits

...

8 Commits
4.0.0 ... 4.1.0

Author SHA1 Message Date
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
Colin Basnett
88c22c9e80 Bone names can now contain hyphens 2022-08-11 15:57:02 -07:00
9 changed files with 80 additions and 61 deletions

View File

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

View File

@@ -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-Z0-9_\- ]+$')
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':
@@ -112,7 +113,8 @@ def build_psa(context, options: PsaBuildOptions) -> Psa:
psa = Psa()
armature = active_object
bones = list(armature.data.bones)
armature_data = typing.cast(Armature, armature)
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
@@ -135,6 +137,7 @@ def build_psa(context, options: PsaBuildOptions) -> Psa:
raise RuntimeError('No bones available for export')
# Check that all bone names are valid.
if not options.should_ignore_bone_name_restrictions:
check_bone_names(map(lambda bone: bone.name, bones))
# Build list of PSA bones.
@@ -208,6 +211,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 +224,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
@@ -280,4 +288,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
@@ -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

@@ -4,7 +4,6 @@ import re
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
from bpy_extras.io_utils import ImportHelper
@@ -16,7 +15,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 +22,7 @@ class PsaImportOptions(object):
self.should_write_keyframes = True
self.should_write_metadata = True
self.action_name_prefix = ''
self.should_convert_to_samples = False
class ImportBone(object):
@@ -154,7 +153,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,40 +164,19 @@ 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!
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:
for fcurve, datum in zip(import_bone.fcurves, key_data):
fcurve.keyframe_points.insert(frame_index, datum, options={'FAST'})
if options.should_convert_to_samples:
for fcurve in action.fcurves:
fcurve.convert_to_samples(start=0, end=sequence.frame_count)
# Write
if options.should_write_metadata:
action['psa_sequence_name'] = sequence_name
@@ -266,9 +243,6 @@ 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',
options=empty_set)
@@ -289,6 +263,11 @@ 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=True,
name='Convert to Samples',
description='Convert keyframes to read-only samples. Recommended if you do not plan on editing the actions directly'
)
def filter_sequences(pg: PsaImportPropertyGroup, sequences) -> List[int]:
@@ -457,11 +436,14 @@ class PSA_PT_ImportPanel_Advanced(Panel):
layout = self.layout
pg = getattr(context.scene, 'psa_import')
col = layout.column(heading="Options")
col = layout.column(heading='Keyframes')
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'should_clean_keys')
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_use_fake_user')
col.prop(pg, 'should_stash')
col.prop(pg, 'should_use_action_name_prefix')
@@ -585,13 +567,13 @@ 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
import_psa(psa_reader, context.view_layer.objects.active, options)

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,8 @@
from collections import OrderedDict
from typing import Dict, List
import bmesh
import bpy
from .data import *
from ..helpers import *
import bmesh
import bpy
class PskInputObjects(object):
@@ -19,6 +17,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:
@@ -81,6 +80,7 @@ def build_psk(context, options: PskBuildOptions) -> Psk:
bones = [armature_object.data.bones[bone_name] for bone_name in bone_names]
# Check that all bone names are valid.
if not options.should_ignore_bone_name_restrictions:
check_bone_names(map(lambda x: x.name, bones))
for bone in bones:
@@ -133,16 +133,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 +158,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)

View File

@@ -208,6 +208,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 +219,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 +247,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 = (