Added a number of new features:

* Added "Active Action" as the sequence type, allowing the user to
  select multiple armatures with the same data block and export an
  action for each one.
* Added a "Scale" option for PSA export when exporting animations
  for an armature that had been scaled on export.
* You are now able to export meshes without materials.
This commit is contained in:
Colin Basnett
2024-12-08 03:01:23 -08:00
parent 9b0df1c942
commit 491e042cec
6 changed files with 228 additions and 69 deletions

View File

@@ -13,7 +13,9 @@ class PsaBuildSequence:
self.frame_start: int = 0
self.frame_end: int = 0
def __init__(self):
def __init__(self, armature_object: Object, anim_data: AnimData):
self.armature_object = armature_object
self.anim_data = anim_data
self.name: str = ''
self.nla_state: PsaBuildSequence.NlaState = PsaBuildSequence.NlaState()
self.compression_ratio: float = 1.0
@@ -30,6 +32,7 @@ class PsaBuildOptions:
self.sequence_name_prefix: str = ''
self.sequence_name_suffix: str = ''
self.root_motion: bool = False
self.scale = 1.0
def _get_pose_bone_location_and_rotation(pose_bone: PoseBone, armature_object: Object, options: PsaBuildOptions):
@@ -48,6 +51,8 @@ def _get_pose_bone_location_and_rotation(pose_bone: PoseBone, armature_object: O
location = pose_bone_matrix.to_translation()
rotation = pose_bone_matrix.to_quaternion().normalized()
location *= options.scale
if pose_bone.parent is not None:
rotation.conjugate()
@@ -67,9 +72,6 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
# 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_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_object, options.bone_filter_mode, options.bone_collection_indices)
@@ -77,7 +79,6 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
# 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]
# No bones are going to be exported.
if len(bones) == 0:
@@ -140,8 +141,14 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
context.window_manager.progress_begin(0, len(options.sequences))
for export_sequence_index, export_sequence in enumerate(options.sequences):
# Look up the pose bones for the bones that are going to be exported.
pose_bones = [(bone_names.index(bone.name), bone) for bone in export_sequence.armature_object.pose.bones]
pose_bones.sort(key=lambda x: x[0])
pose_bones = [x[1] for x in pose_bones]
pose_bones = [pose_bones[bone_index] for bone_index in bone_indices]
# Link the action to the animation data and update view layer.
options.animation_data.action = export_sequence.nla_state.action
export_sequence.anim_data.action = export_sequence.nla_state.action
context.view_layer.update()
frame_start = export_sequence.nla_state.frame_start
@@ -181,7 +188,7 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
context.scene.frame_set(frame=int(frame), subframe=frame % 1.0)
for pose_bone in pose_bones:
location, rotation = _get_pose_bone_location_and_rotation(pose_bone, armature_object, options)
location, rotation = _get_pose_bone_location_and_rotation(pose_bone, export_sequence.armature_object, options)
key = Psa.Key()
key.location.x = location.x

View File

@@ -1,6 +1,6 @@
import re
from collections import Counter
from typing import List, Iterable, Dict, Tuple
from typing import List, Iterable, Dict, Tuple, Optional
import bpy
from bpy.props import StringProperty
@@ -229,6 +229,7 @@ class PSA_OT_export(Operator, ExportHelper):
flow.use_property_decorate = False
flow.prop(pg, 'sequence_source', text='Source')
if pg.sequence_source != 'ACTIVE_ACTION':
if pg.sequence_source in {'TIMELINE_MARKERS', 'NLA_TRACK_STRIPS'}:
# ANIMDATA SOURCE
flow.prop(pg, 'should_override_animation_data')
@@ -249,7 +250,7 @@ class PSA_OT_export(Operator, ExportHelper):
from .ui import PSA_UL_export_sequences
def get_sequences_propnames_from_source(sequence_source: str) -> Tuple[str, str]:
def get_sequences_propnames_from_source(sequence_source: str) -> Optional[Tuple[str, str]]:
match sequence_source:
case 'ACTIONS':
return 'action_list', 'action_list_index'
@@ -301,15 +302,16 @@ class PSA_OT_export(Operator, ExportHelper):
bones_panel.template_list('PSX_UL_bone_collection_list', '', pg, 'bone_collection_list', pg, 'bone_collection_list_index',
rows=rows)
# ADVANCED
advanced_header, advanced_panel = layout.panel('Advanced', default_closed=False)
advanced_header.label(text='Advanced')
# TRANSFORM
transform_header, transform_panel = layout.panel('Advanced', default_closed=False)
transform_header.label(text='Transform')
if advanced_panel:
flow = advanced_panel.grid_flow()
if transform_panel:
flow = transform_panel.grid_flow()
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(pg, 'root_motion', text='Root Motion')
flow.prop(pg, 'scale', text='Scale')
@classmethod
def _check_context(cls, context):
@@ -317,7 +319,16 @@ class PSA_OT_export(Operator, ExportHelper):
raise RuntimeError('An armature must be selected')
if context.view_layer.objects.active.type != 'ARMATURE':
raise RuntimeError('The selected object must be an armature')
raise RuntimeError('The active object must be an armature')
# If we have multiple armatures selected, make sure that they all use the same underlying armature data.
armature_objects = [obj for obj in context.selected_objects if obj.type == 'ARMATURE']
for obj in armature_objects:
if obj.data != context.view_layer.objects.active.data:
raise RuntimeError(f'All selected armatures must use the same armature data block.\n\n'
f'\The armature data block for "{obj.name}\" (\'{obj.data.name}\') does not match '
f'the active armature data block (\'{context.view_layer.objects.active.name}\')')
def invoke(self, context, _event):
try:
@@ -362,14 +373,16 @@ class PSA_OT_export(Operator, ExportHelper):
export_sequences: List[PsaBuildSequence] = []
selected_armature_objects = [obj for obj in context.selected_objects if obj.type == 'ARMATURE']
match pg.sequence_source:
case 'ACTIONS':
for action_item in filter(lambda x: x.is_selected, pg.action_list):
if len(action_item.action.fcurves) == 0:
continue
export_sequence = PsaBuildSequence()
export_sequence.nla_state.action = action_item.action
export_sequence = PsaBuildSequence(context.active_object, animation_data)
export_sequence.name = action_item.name
export_sequence.nla_state.action = action_item.action
export_sequence.nla_state.frame_start = action_item.frame_start
export_sequence.nla_state.frame_end = action_item.frame_end
export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [action_item.action])
@@ -378,9 +391,8 @@ class PSA_OT_export(Operator, ExportHelper):
export_sequences.append(export_sequence)
case 'TIMELINE_MARKERS':
for marker_item in filter(lambda x: x.is_selected, pg.marker_list):
export_sequence = PsaBuildSequence()
export_sequence = PsaBuildSequence(context.active_object, animation_data)
export_sequence.name = marker_item.name
export_sequence.nla_state.action = None
export_sequence.nla_state.frame_start = marker_item.frame_start
export_sequence.nla_state.frame_end = marker_item.frame_end
nla_strips_actions = set(
@@ -389,15 +401,28 @@ class PSA_OT_export(Operator, ExportHelper):
export_sequences.append(export_sequence)
case 'NLA_TRACK_STRIPS':
for nla_strip_item in filter(lambda x: x.is_selected, pg.nla_strip_list):
export_sequence = PsaBuildSequence()
export_sequence = PsaBuildSequence(context.active_object, animation_data)
export_sequence.name = nla_strip_item.name
export_sequence.nla_state.action = None
export_sequence.nla_state.frame_start = nla_strip_item.frame_start
export_sequence.nla_state.frame_end = nla_strip_item.frame_end
export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [nla_strip_item.action])
export_sequence.compression_ratio = nla_strip_item.action.psa_export.compression_ratio
export_sequence.key_quota = nla_strip_item.action.psa_export.key_quota
export_sequences.append(export_sequence)
case 'ACTIVE_ACTION':
for obj in selected_armature_objects:
if obj.animation_data is None or obj.animation_data.action is None:
continue
action = obj.animation_data.action
export_sequence = PsaBuildSequence(obj, obj.animation_data)
export_sequence.name = action.name
export_sequence.nla_state.action = action
export_sequence.nla_state.frame_start = int(action.frame_range[0])
export_sequence.nla_state.frame_end = int(action.frame_range[1])
export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [action])
export_sequence.compression_ratio = action.psa_export.compression_ratio
export_sequence.key_quota = action.psa_export.key_quota
export_sequences.append(export_sequence)
case _:
raise ValueError(f'Unhandled sequence source: {pg.sequence_source}')
@@ -409,6 +434,7 @@ class PSA_OT_export(Operator, ExportHelper):
options.sequence_name_prefix = pg.sequence_name_prefix
options.sequence_name_suffix = pg.sequence_name_suffix
options.root_motion = pg.root_motion
options.scale = pg.scale
try:
psa = build_psa(context, options)

View File

@@ -123,7 +123,8 @@ class PSA_PG_export(PropertyGroup):
items=(
('ACTIONS', 'Actions', 'Sequences will be exported using actions', 'ACTION', 0),
('TIMELINE_MARKERS', 'Timeline Markers', 'Sequences are delineated by scene timeline markers', 'MARKER_HLT', 1),
('NLA_TRACK_STRIPS', 'NLA Track Strips', 'Sequences are delineated by the start & end times of strips on the selected NLA track', 'NLA', 2)
('NLA_TRACK_STRIPS', 'NLA Track Strips', 'Sequences are delineated by the start & end times of strips on the selected NLA track', 'NLA', 2),
('ACTIVE_ACTION', 'Active Action', 'The active action will be exported for each selected armature', 'ACTION', 3),
)
)
nla_track: StringProperty(
@@ -188,6 +189,13 @@ class PSA_PG_export(PropertyGroup):
name='Show Reversed',
description='Show reversed sequences'
)
scale: FloatProperty(
name='Scale',
default=1.0,
description='Scale factor to apply to the bone translations. Use this if you are exporting animations for a scaled PSK mesh',
min=0.0,
soft_max=100.0
)
def filter_sequences(pg: PSA_PG_export, sequences) -> List[int]:

View File

@@ -3,8 +3,8 @@ from typing import Optional
import bmesh
import numpy as np
from bpy.types import Material, Collection, Context, Mesh
from mathutils import Matrix
from bpy.types import Material, Collection, Context
from mathutils import Matrix, Vector
from .data import *
from .properties import triangle_type_and_bit_flags_to_poly_flags
@@ -26,6 +26,36 @@ class PskBuildOptions(object):
self.materials: List[Material] = []
self.scale = 1.0
self.export_space = 'WORLD'
self.forward_axis = 'X'
self.up_axis = 'Z'
def get_vector_from_axis_identifier(axis_identifier: str) -> Vector:
match axis_identifier:
case 'X':
return Vector((1.0, 0.0, 0.0))
case 'Y':
return Vector((0.0, 1.0, 0.0))
case 'Z':
return Vector((0.0, 0.0, 1.0))
case '-X':
return Vector((-1.0, 0.0, 0.0))
case '-Y':
return Vector((0.0, -1.0, 0.0))
case '-Z':
return Vector((0.0, 0.0, -1.0))
def get_coordinate_system_transform(forward_axis: str = 'X', up_axis: str = 'Z') -> Matrix:
forward = get_vector_from_axis_identifier(forward_axis)
up = get_vector_from_axis_identifier(up_axis)
left = up.cross(forward)
return Matrix((
(forward.x, forward.y, forward.z, 0.0),
(left.x, left.y, left.z, 0.0),
(up.x, up.y, up.z, 0.0),
(0.0, 0.0, 0.0, 1.0)
)).inverted()
def get_mesh_objects_for_collection(collection: Collection) -> Iterable[DfsObject]:
@@ -65,11 +95,6 @@ def _get_psk_input_objects(mesh_objects: Iterable[DfsObject]) -> PskInputObjects
if len(mesh_objects) == 0:
raise RuntimeError('At least one mesh must be selected')
for dfs_object in mesh_objects:
mesh_data = cast(Mesh, dfs_object.obj.data)
if len(mesh_data.materials) == 0:
raise RuntimeError(f'Mesh "{dfs_object.obj.name}" must have at least one material')
input_objects = PskInputObjects()
input_objects.mesh_objects = mesh_objects
input_objects.armature_object = get_armature_for_mesh_objects([x.obj for x in mesh_objects])
@@ -107,12 +132,18 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
case 'WORLD':
return Matrix.Identity(4)
case 'ARMATURE':
if armature_object is not None:
return armature_object.matrix_world.inverted()
else:
return Matrix.Identity(4)
case _:
raise ValueError(f'Invalid export space: {options.export_space}')
coordinate_system_matrix = get_coordinate_system_transform(options.forward_axis, options.up_axis)
coordinate_system_default_rotation = coordinate_system_matrix.to_quaternion()
export_space_matrix = get_export_space_matrix() # TODO: maybe neutralize the scale here?
scale_matrix = Matrix.Scale(options.scale, 4)
scale_matrix = coordinate_system_matrix @ Matrix.Scale(options.scale, 4)
if armature_object is None or len(armature_object.data.bones) == 0:
# If the mesh has no armature object or no bones, simply assign it a dummy bone at the root to satisfy the
@@ -123,7 +154,7 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
psk_bone.children_count = 0
psk_bone.parent_index = 0
psk_bone.location = Vector3.zero()
psk_bone.rotation = Quaternion.identity()
psk_bone.rotation = coordinate_system_default_rotation
psk.bones.append(psk_bone)
else:
bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, options.bone_collection_indices)
@@ -169,6 +200,7 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
local_rotation = armature_local_matrix.to_3x3().to_quaternion().conjugated()
rotation = bone_rotation @ local_rotation
rotation.conjugate()
rotation = coordinate_system_default_rotation @ rotation
location = scale_matrix @ location
@@ -178,8 +210,6 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
location.y *= armature_object_scale.y
location.z *= armature_object_scale.z
print(bone.name, location)
psk_bone.location.x = location.x
psk_bone.location.y = location.y
psk_bone.location.z = location.z
@@ -203,6 +233,17 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
material.psk.mesh_triangle_bit_flags)
psk.materials.append(psk_material)
# TODO: This wasn't left in a good state. We should detect if we need to add a "default" material.
# This can be done by checking if there is an empty material slot on any of the mesh objects, or if there are
# no material slots on any of the mesh objects.
# If so, it should be added to the end of the list of materials, and its index should mapped to a None value in the
# material indices list.
if len(psk.materials) == 0:
# Add a default material if no materials are present.
psk_material = Psk.Material()
psk_material.name = bytes('None', encoding='windows-1252')
psk.materials.append(psk_material)
context.window_manager.progress_begin(0, len(input_objects.mesh_objects))
material_names = [m.name for m in options.materials]
@@ -213,8 +254,26 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
should_flip_normals = False
def get_material_name_indices(obj: Object, material_names: List[str]) -> Iterable[int]:
'''
Returns the index of the material in the list of material names.
If the material is not found, the index 0 is returned.
'''
for material_slot in obj.material_slots:
if material_slot.material is None:
yield 0
else:
try:
yield material_names.index(material_slot.material.name)
except ValueError:
yield 0
# MATERIALS
material_indices = [material_names.index(material_slot.material.name) for material_slot in obj.material_slots]
material_indices = list(get_material_name_indices(obj, material_names))
if len(material_indices) == 0:
# Add a default material if no materials are present.
material_indices = [0]
# MESH DATA
match options.object_eval_state:
@@ -233,7 +292,13 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
depsgraph = context.evaluated_depsgraph_get()
bm = bmesh.new()
try:
bm.from_object(obj, depsgraph)
except ValueError:
raise RuntimeError(f'Object "{obj.name}" is not evaluated.\n'
'This is likely because the object is in a collection that has been excluded from the view layer.')
mesh_data = bpy.data.meshes.new('')
bm.to_mesh(mesh_data)
del bm

View File

@@ -117,6 +117,36 @@ class PSK_OT_material_list_move_down(Operator):
empty_set = set()
axis_identifiers = ('X', 'Y', 'Z', '-X', '-Y', '-Z')
forward_items = (
('X', 'X Forward', ''),
('Y', 'Y Forward', ''),
('Z', 'Z Forward', ''),
('-X', '-X Forward', ''),
('-Y', '-Y Forward', ''),
('-Z', '-Z Forward', ''),
)
up_items = (
('X', 'X Up', ''),
('Y', 'Y Up', ''),
('Z', 'Z Up', ''),
('-X', '-X Up', ''),
('-Y', '-Y Up', ''),
('-Z', '-Z Up', ''),
)
def forward_axis_update(self, context):
if self.forward_axis == self.up_axis:
# Automatically set the up axis to the next available axis
self.up_axis = next((axis for axis in axis_identifiers if axis != self.forward_axis), 'Z')
def up_axis_update(self, context):
if self.up_axis == self.forward_axis:
# Automatically set the forward axis to the next available axis
self.forward_axis = next((axis for axis in axis_identifiers if axis != self.up_axis), 'X')
class PSK_OT_export_collection(Operator, ExportHelper):
bl_idname = 'export.psk_collection'
@@ -163,7 +193,18 @@ class PSK_OT_export_collection(Operator, ExportHelper):
)
bone_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item)
bone_collection_list_index: IntProperty(default=0)
forward_axis: EnumProperty(
name='Forward',
items=forward_items,
default='X',
update=forward_axis_update
)
up_axis: EnumProperty(
name='Up',
items=up_items,
default='Z',
update=up_axis_update
)
def execute(self, context):
collection = bpy.data.collections.get(self.collection)
@@ -181,6 +222,8 @@ class PSK_OT_export_collection(Operator, ExportHelper):
options.export_space = self.export_space
options.bone_filter_mode = self.bone_filter_mode
options.bone_collection_indices = [x.index for x in self.bone_collection_list if x.is_selected]
options.forward_axis = self.forward_axis
options.up_axis = self.up_axis
try:
result = build_psk(context, input_objects, options)
@@ -204,9 +247,6 @@ class PSK_OT_export_collection(Operator, ExportHelper):
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(self, 'scale')
flow.prop(self, 'export_space')
# MESH
mesh_header, mesh_panel = layout.panel('Mesh', default_closed=False)
mesh_header.label(text='Mesh', icon='MESH_DATA')
@@ -227,6 +267,18 @@ class PSK_OT_export_collection(Operator, ExportHelper):
rows = max(3, min(len(self.bone_collection_list), 10))
bones_panel.template_list('PSX_UL_bone_collection_list', '', self, 'bone_collection_list', self, 'bone_collection_list_index', rows=rows)
# TRANSFORM
transform_header, transform_panel = layout.panel('Transform', default_closed=False)
transform_header.label(text='Transform')
if transform_panel:
flow = transform_panel.grid_flow(row_major=True)
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(self, 'export_space')
flow.prop(self, 'scale')
flow.prop(self, 'forward_axis')
flow.prop(self, 'up_axis')
class PSK_OT_export(Operator, ExportHelper):
bl_idname = 'export.psk'

View File

@@ -1,6 +1,7 @@
from bpy.props import EnumProperty, CollectionProperty, IntProperty, PointerProperty, FloatProperty
from bpy.types import PropertyGroup, Material
from ...shared.data import bone_filter_mode_items
from ...shared.types import PSX_PG_bone_collection_list_item
empty_set = set()