Multi-armature mesh and animation export now working

This commit is contained in:
Colin Basnett
2025-03-30 12:08:44 -07:00
parent 91fe54f361
commit 7c695e6195
17 changed files with 498 additions and 323 deletions

View File

@@ -1,6 +1,6 @@
from bpy.types import Bone, Action, PoseBone from bpy.types import Action, PoseBone
from .data import * from .data import Psa
from ..shared.helpers import * from ..shared.helpers import *
@@ -23,10 +23,11 @@ class PsaBuildSequence:
class PsaBuildOptions: class PsaBuildOptions:
def __init__(self): def __init__(self):
self.armature_objects: List[Object] = []
self.animation_data: Optional[AnimData] = None self.animation_data: Optional[AnimData] = None
self.sequences: List[PsaBuildSequence] = [] self.sequences: List[PsaBuildSequence] = []
self.bone_filter_mode: str = 'ALL' self.bone_filter_mode: str = 'ALL'
self.bone_collection_indices: List[int] = [] self.bone_collection_indices: List[Tuple[str, int]] = []
self.sequence_name_prefix: str = '' self.sequence_name_prefix: str = ''
self.sequence_name_suffix: str = '' self.sequence_name_suffix: str = ''
self.scale = 1.0 self.scale = 1.0
@@ -34,24 +35,34 @@ class PsaBuildOptions:
self.export_space = 'WORLD' self.export_space = 'WORLD'
self.forward_axis = 'X' self.forward_axis = 'X'
self.up_axis = 'Z' self.up_axis = 'Z'
self.root_bone_name = 'ROOT'
def _get_pose_bone_location_and_rotation( def _get_pose_bone_location_and_rotation(
pose_bone: PoseBone, pose_bone: Optional[PoseBone],
armature_object: Object, armature_object: Optional[Object],
export_space: str, export_space: str,
scale: Vector, scale: Vector,
coordinate_system_transform: Matrix) -> Tuple[Vector, Quaternion]: coordinate_system_transform: Matrix,
if pose_bone.parent is not None: has_false_root_bone: bool,
) -> Tuple[Vector, Quaternion]:
# TODO: my kingdom for a Rust monad.
is_false_root_bone = pose_bone is None and armature_object is None
if is_false_root_bone:
pose_bone_matrix = coordinate_system_transform
elif pose_bone.parent is not None:
pose_bone_matrix = pose_bone.matrix pose_bone_matrix = pose_bone.matrix
pose_bone_parent_matrix = pose_bone.parent.matrix pose_bone_parent_matrix = pose_bone.parent.matrix
pose_bone_matrix = pose_bone_parent_matrix.inverted() @ pose_bone_matrix pose_bone_matrix = pose_bone_parent_matrix.inverted() @ pose_bone_matrix
else:
# Root bone
if has_false_root_bone:
pose_bone_matrix = armature_object.matrix_world @ pose_bone.matrix
else: else:
# Get the bone's pose matrix and transform it into the export space. # Get the bone's pose matrix and transform it into the export space.
# In the case of an 'ARMATURE' export space, this will be the inverse of armature object's world matrix. # In the case of an 'ARMATURE' export space, this will be the inverse of armature object's world matrix.
# Otherwise, it will be the identity matrix. # Otherwise, it will be the identity matrix.
# TODO: taking the pose bone matrix puts this in armature space. # TODO: taking the pose bone matrix puts this in armature space.
pose_bone_matrix = Matrix.Identity(4)
match export_space: match export_space:
case 'ARMATURE': case 'ARMATURE':
pose_bone_matrix = pose_bone.matrix pose_bone_matrix = pose_bone.matrix
@@ -59,6 +70,8 @@ def _get_pose_bone_location_and_rotation(
pose_bone_matrix = armature_object.matrix_world @ pose_bone.matrix pose_bone_matrix = armature_object.matrix_world @ pose_bone.matrix
case 'ROOT': case 'ROOT':
pose_bone_matrix = Matrix.Identity(4) pose_bone_matrix = Matrix.Identity(4)
case _:
assert False, f'Invalid export space: {export_space}'
# The root bone is the only bone that should be transformed by the coordinate system transform, since all # The root bone is the only bone that should be transformed by the coordinate system transform, since all
# other bones are relative to their parent bones. # other bones are relative to their parent bones.
@@ -67,57 +80,50 @@ def _get_pose_bone_location_and_rotation(
location = pose_bone_matrix.to_translation() location = pose_bone_matrix.to_translation()
rotation = pose_bone_matrix.to_quaternion().normalized() rotation = pose_bone_matrix.to_quaternion().normalized()
# TODO: this has gotten way more complicated than it needs to be.
# TODO: don't apply scale to the root bone of armatures if we have a false root:
if not has_false_root_bone or (pose_bone is None or pose_bone.parent is not None):
location *= scale location *= scale
if pose_bone.parent is not None: if has_false_root_bone:
is_child_bone = not is_false_root_bone
else:
is_child_bone = pose_bone.parent is not None
if is_child_bone is not None:
rotation.conjugate() rotation.conjugate()
return location, rotation return location, rotation
def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
active_object = context.view_layer.objects.active
psa = Psa() psa = Psa()
armature_object = active_object psx_bone_create_result = create_psx_bones(
evaluated_armature_object = armature_object.evaluated_get(context.evaluated_depsgraph_get()) armature_objects=options.armature_objects,
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]
# 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)
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.
bones = [bones[bone_index] for bone_index in bone_indices]
# No bones are going to be exported.
if len(bones) == 0:
raise RuntimeError('No bones available for export')
# The bone building code should be shared between the PSK and PSA exporters, since they both need to build a nearly identical bone list.
# TODO: The PSA bones are just here to validate the hierarchy. The pose information is not used by the engine.
# Build list of PSA bones.
psa.bones = convert_blender_bones_to_psx_bones(
bones=bones,
bone_class=Psa.Bone,
export_space=options.export_space, export_space=options.export_space,
armature_object_matrix_world=armature_object.matrix_world, # evaluated_armature_object.matrix_world, root_bone_name=options.root_bone_name,
scale=options.scale,
forward_axis=options.forward_axis, forward_axis=options.forward_axis,
up_axis=options.up_axis up_axis=options.up_axis,
scale=options.scale,
bone_filter_mode=options.bone_filter_mode,
bone_collection_indices=options.bone_collection_indices,
) )
# TODO: technically wrong, might not necessarily be true (i.e., if the armature object has no contributing bones).
has_false_root_bone = len(options.armature_objects) > 1
# Build list of PSA bones.
# Note that the PSA bones are just here to validate the hierarchy. The bind pose information is not used by the
# engine.
psa.bones = [psx_bone for psx_bone, _ in psx_bone_create_result.bones]
# No bones are going to be exported.
if len(psa.bones) == 0:
raise RuntimeError('No bones available for export')
# We invert the export space matrix so that we neutralize the transform of the armature object. # We invert the export space matrix so that we neutralize the transform of the armature object.
export_space_matrix_inverse = get_export_space_matrix(options.export_space, armature_object) # export_space_matrix_inverse = get_export_space_matrix(options.export_space, armature_object)
# Add prefixes and suffices to the names of the export sequences and strip whitespace. # Add prefixes and suffices to the names of the export sequences and strip whitespace.
for export_sequence in options.sequences: for export_sequence in options.sequences:
@@ -126,6 +132,7 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
# Save the current action and frame so that we can restore the state once we are done. # 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_frame_current = context.scene.frame_current
saved_action = options.animation_data.action saved_action = options.animation_data.action
# Now build the PSA sequences. # Now build the PSA sequences.
@@ -137,16 +144,6 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
coordinate_system_transform = get_coordinate_system_transform(options.forward_axis, options.up_axis) coordinate_system_transform = get_coordinate_system_transform(options.forward_axis, options.up_axis)
for export_sequence_index, export_sequence in enumerate(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 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.
export_sequence.anim_data.action = export_sequence.nla_state.action
context.view_layer.update()
frame_start = export_sequence.nla_state.frame_start frame_start = export_sequence.nla_state.frame_start
frame_end = export_sequence.nla_state.frame_end frame_end = export_sequence.nla_state.frame_end
@@ -160,26 +157,34 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
except ZeroDivisionError: except ZeroDivisionError:
frame_step = 0.0 frame_step = 0.0
sequence_duration = frame_count_raw / export_sequence.fps
# If this is a reverse sequence, we need to reverse the frame step. # If this is a reverse sequence, we need to reverse the frame step.
if frame_start > frame_end: if frame_start > frame_end:
frame_step = -frame_step frame_step = -frame_step
sequence_duration = frame_count_raw / export_sequence.fps
psa_sequence = Psa.Sequence() psa_sequence = Psa.Sequence()
try: try:
psa_sequence.name = bytes(export_sequence.name, encoding='windows-1252') psa_sequence.name = bytes(export_sequence.name, encoding='windows-1252')
except UnicodeEncodeError: except UnicodeEncodeError:
raise RuntimeError(f'Sequence name "{export_sequence.name}" contains characters that cannot be encoded in the Windows-1252 codepage') raise RuntimeError(
f'Sequence name "{export_sequence.name}" contains characters that cannot be encoded in the Windows-1252 codepage')
psa_sequence.frame_count = frame_count psa_sequence.frame_count = frame_count
psa_sequence.frame_start_index = frame_start_index psa_sequence.frame_start_index = frame_start_index
psa_sequence.fps = frame_count / sequence_duration psa_sequence.fps = frame_count / sequence_duration
psa_sequence.bone_count = len(pose_bones) psa_sequence.bone_count = len(psa.bones)
psa_sequence.track_time = frame_count psa_sequence.track_time = frame_count
psa_sequence.key_reduction = 1.0 psa_sequence.key_reduction = 1.0
frame = float(frame_start) frame = float(frame_start)
# Link the action to the animation data and update view layer.
for armature_object in options.armature_objects:
# TODO: change this to assign it to each armature object's animation data.
armature_object.animation_data.action = export_sequence.nla_state.action
context.view_layer.update()
def add_key(location: Vector, rotation: Quaternion): def add_key(location: Vector, rotation: Quaternion):
key = Psa.Key() key = Psa.Key()
key.location.x = location.x key.location.x = location.x
@@ -192,12 +197,40 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
key.time = 1.0 / psa_sequence.fps key.time = 1.0 / psa_sequence.fps
psa.keys.append(key) psa.keys.append(key)
# TODO: In _get_pose_bone_location_and_rotation, we need to factor in the evaluated armature object's scale. class PsaExportBone:
# Then also, if we're in ARMATURE export space, invert the pose bone matrix so that it's the identity matrix. def __init__(self, pose_bone: Optional[PoseBone], armature_object: Optional[Object], scale: Vector):
self.pose_bone = pose_bone
self.armature_object = armature_object
self.scale = scale
# TODO: extract the scale out of the evaluated_armature_object.matrix_world. armature_scales: Dict[Object, Vector] = {}
# Extract the scale from the world matrix of the evaluated armature object.
for armature_object in options.armature_objects:
evaluated_armature_object = armature_object.evaluated_get(context.evaluated_depsgraph_get())
_, _, scale = evaluated_armature_object.matrix_world.decompose() _, _, scale = evaluated_armature_object.matrix_world.decompose()
scale *= options.scale scale *= options.scale
armature_scales[armature_object] = scale
# Create a list of export pose bones, in the same order as the bones as they appear in the armature.
# The object contains the pose bone, the armature object, and a pre-calculated scaling value to apply to the
# locations.
export_bones: List[PsaExportBone] = []
for psx_bone, armature_object in psx_bone_create_result.bones:
print(psx_bone, armature_object)
# TODO: look up the pose bone from the name in the PSX bone.
if armature_object is None:
export_bones.append(PsaExportBone(None, None, Vector((1.0, 1.0, 1.0))))
continue
# TODO: we need to look up the pose bones using the name.
pose_bone = armature_object.pose.bones[psx_bone.name.decode('windows-1252')]
export_bones.append(PsaExportBone(pose_bone, armature_object, armature_scales[armature_object]))
for export_bone in export_bones:
print(export_bone.pose_bone, export_bone.armature_object, export_bone.scale)
match options.sampling_mode: match options.sampling_mode:
case 'INTERPOLATED': case 'INTERPOLATED':
@@ -221,13 +254,14 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
else: else:
last_frame_bone_poses.clear() last_frame_bone_poses.clear()
context.scene.frame_set(frame=last_frame) context.scene.frame_set(frame=last_frame)
for pose_bone in pose_bones: for export_bone in export_bones:
location, rotation = _get_pose_bone_location_and_rotation( location, rotation = _get_pose_bone_location_and_rotation(
pose_bone, export_bone.pose_bone,
armature_object, export_bone.armature_object,
options.export_space, options.export_space,
scale, export_bone.scale,
coordinate_system_transform=coordinate_system_transform coordinate_system_transform=coordinate_system_transform,
has_false_root_bone=has_false_root_bone,
) )
last_frame_bone_poses.append((location, rotation)) last_frame_bone_poses.append((location, rotation))
@@ -236,26 +270,27 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
# If this is not a subframe, just use the last frame's bone poses. # If this is not a subframe, just use the last frame's bone poses.
if frame % 1.0 == 0: if frame % 1.0 == 0:
for i in range(len(pose_bones)): for i in range(len(export_bones)):
add_key(*last_frame_bone_poses[i]) add_key(*last_frame_bone_poses[i])
else: else:
# Otherwise, this is a subframe, so we need to interpolate the pose between the next frame and the last frame. # Otherwise, this is a subframe, so we need to interpolate the pose between the next frame and the last frame.
if next_frame is None: if next_frame is None:
next_frame = last_frame + 1 next_frame = last_frame + 1
context.scene.frame_set(frame=next_frame) context.scene.frame_set(frame=next_frame)
for pose_bone in pose_bones: for export_bone in export_bones:
location, rotation = _get_pose_bone_location_and_rotation( location, rotation = _get_pose_bone_location_and_rotation(
pose_bone, pose_bone=export_bone.pose_bone,
armature_object, armature_object=export_bone.armature_object,
options.export_space, export_space=options.export_space,
scale, scale=export_bone.scale,
coordinate_system_transform=coordinate_system_transform coordinate_system_transform=coordinate_system_transform,
has_false_root_bone=has_false_root_bone,
) )
next_frame_bone_poses.append((location, rotation)) next_frame_bone_poses.append((location, rotation))
factor = frame % 1.0 factor = frame % 1.0
for i in range(len(pose_bones)): for i in range(len(export_bones)):
last_location, last_rotation = last_frame_bone_poses[i] last_location, last_rotation = last_frame_bone_poses[i]
next_location, next_rotation = next_frame_bone_poses[i] next_location, next_rotation = next_frame_bone_poses[i]
@@ -269,13 +304,14 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
for _ in range(frame_count): for _ in range(frame_count):
context.scene.frame_set(frame=int(frame), subframe=frame % 1.0) context.scene.frame_set(frame=int(frame), subframe=frame % 1.0)
for pose_bone in pose_bones: for export_bone in export_bones:
location, rotation = _get_pose_bone_location_and_rotation( location, rotation = _get_pose_bone_location_and_rotation(
pose_bone, pose_bone=export_bone.pose_bone,
armature_object, armature_object=export_bone.armature_object,
options.export_space, export_space=options.export_space,
scale, scale=export_bone.scale,
coordinate_system_transform=coordinate_system_transform coordinate_system_transform=coordinate_system_transform,
has_false_root_bone=has_false_root_bone,
) )
add_key(location, rotation) add_key(location, rotation)
@@ -288,7 +324,9 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
context.window_manager.progress_update(export_sequence_index) context.window_manager.progress_update(export_sequence_index)
# Restore the previous action & frame. # Restore the previous action & frame.
# TODO: store each armature object's previous action
options.animation_data.action = saved_action options.animation_data.action = saved_action
context.scene.frame_set(saved_frame_current) context.scene.frame_set(saved_frame_current)
context.window_manager.progress_end() context.window_manager.progress_end()

View File

@@ -11,16 +11,6 @@ Use the PsaReader::get_sequence_keys to get the keys for a sequence.
class Psa: class Psa:
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): class Sequence(Structure):
_fields_ = [ _fields_ = [
@@ -59,6 +49,6 @@ class Psa:
return repr((self.location, self.rotation, self.time)) return repr((self.location, self.rotation, self.time))
def __init__(self): def __init__(self):
self.bones: List[Psa.Bone] = [] self.bones: List[PsxBone] = []
self.sequences: typing.OrderedDict[str, Psa.Sequence] = OrderedDict() self.sequences: typing.OrderedDict[str, Psa.Sequence] = OrderedDict()
self.keys: List[Psa.Key] = [] self.keys: List[Psa.Key] = []

View File

@@ -3,9 +3,8 @@ from typing import List, Iterable, Dict, Tuple, Optional
import bpy import bpy
from bpy.props import StringProperty from bpy.props import StringProperty
from bpy.types import Context, Action, Object, AnimData, TimelineMarker from bpy.types import Context, Action, Object, AnimData, TimelineMarker, Operator
from bpy_extras.io_utils import ExportHelper from bpy_extras.io_utils import ExportHelper
from bpy_types import Operator
from .properties import PSA_PG_export, PSA_PG_export_action_list_item, filter_sequences, \ from .properties import PSA_PG_export, PSA_PG_export_action_list_item, filter_sequences, \
get_sequences_from_name_and_frame_range get_sequences_from_name_and_frame_range
@@ -63,7 +62,7 @@ def is_action_for_object(obj: Object, action: Action):
return any(obj in slot.users() for slot in action.slots) return any(obj in slot.users() for slot in action.slots)
def update_actions_and_timeline_markers(context: Context): def update_actions_and_timeline_markers(context: Context, armature_objects: Iterable[Object]):
pg = getattr(context.scene, 'psa_export') pg = getattr(context.scene, 'psa_export')
# Clear actions and markers. # Clear actions and markers.
@@ -72,6 +71,7 @@ def update_actions_and_timeline_markers(context: Context):
pg.active_action_list.clear() pg.active_action_list.clear()
# Get animation data. # Get animation data.
# TODO: Not sure how to handle this with multiple armatures.
animation_data_object = get_animation_data_object(context) animation_data_object = get_animation_data_object(context)
animation_data = animation_data_object.animation_data if animation_data_object else None animation_data = animation_data_object.animation_data if animation_data_object else None
@@ -80,7 +80,8 @@ def update_actions_and_timeline_markers(context: Context):
# Populate actions list. # Populate actions list.
for action in bpy.data.actions: for action in bpy.data.actions:
if not is_action_for_object(context.active_object, action): if not any(map(lambda armature_object: is_action_for_object(armature_object, action), armature_objects)):
# This action is not applicable to any of the selected armatures.
continue continue
for (name, frame_start, frame_end) in get_sequences_from_action(action): for (name, frame_start, frame_end) in get_sequences_from_action(action):
@@ -170,7 +171,7 @@ def get_animation_data_object(context: Context) -> Object:
active_object = context.view_layer.objects.active active_object = context.view_layer.objects.active
if active_object.type != 'ARMATURE': if active_object.type != 'ARMATURE':
raise RuntimeError('Selected object must be an Armature') raise RuntimeError('Active object must be an Armature')
if pg.sequence_source != 'ACTIONS' and pg.should_override_animation_data: if pg.sequence_source != 'ACTIONS' and pg.should_override_animation_data:
animation_data_object = pg.animation_data_override animation_data_object = pg.animation_data_override
@@ -275,7 +276,7 @@ class PSA_OT_export(Operator, ExportHelper):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.armature_object = None self.armature_objects: List[Object] = []
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
@@ -380,6 +381,14 @@ class PSA_OT_export(Operator, ExportHelper):
bones_panel.template_list('PSX_UL_bone_collection_list', '', pg, 'bone_collection_list', pg, 'bone_collection_list_index', bones_panel.template_list('PSX_UL_bone_collection_list', '', pg, 'bone_collection_list', pg, 'bone_collection_list_index',
rows=rows) rows=rows)
bones_advanced_header, bones_advanced_panel = layout.panel('Advanced', default_closed=False)
bones_advanced_header.label(text='Advanced')
if bones_advanced_panel:
flow = bones_advanced_panel.grid_flow()
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(pg, 'root_bone_name', text='Root Bone Name')
# TRANSFORM # TRANSFORM
transform_header, transform_panel = layout.panel('Advanced', default_closed=False) transform_header, transform_panel = layout.panel('Advanced', default_closed=False)
transform_header.label(text='Transform') transform_header.label(text='Transform')
@@ -401,18 +410,10 @@ class PSA_OT_export(Operator, ExportHelper):
if context.view_layer.objects.active.type != 'ARMATURE': if context.view_layer.objects.active.type != 'ARMATURE':
raise RuntimeError('The active 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}\')')
if context.scene.is_nla_tweakmode: if context.scene.is_nla_tweakmode:
raise RuntimeError('Cannot export PSA while in NLA tweak mode') raise RuntimeError('Cannot export PSA while in NLA tweak mode')
def invoke(self, context, _event): def invoke(self, context, _event):
try: try:
self._check_context(context) self._check_context(context)
@@ -421,16 +422,16 @@ class PSA_OT_export(Operator, ExportHelper):
pg: PSA_PG_export = getattr(context.scene, 'psa_export') pg: PSA_PG_export = getattr(context.scene, 'psa_export')
self.armature_object = context.view_layer.objects.active self.armature_objects = [x for x in context.view_layer.objects.selected if x.type == 'ARMATURE']
if self.armature_object.animation_data is None: for armature_object in self.armature_objects:
# This is required otherwise the action list will be empty if the armature has never had its animation # This is required otherwise the action list will be empty if the armature has never had its animation
# data created before (i.e. if no action was ever assigned to it). # data created before (i.e. if no action was ever assigned to it).
self.armature_object.animation_data_create() if armature_object.animation_data is None:
armature_object.animation_data_create()
update_actions_and_timeline_markers(context) update_actions_and_timeline_markers(context, self.armature_objects)
populate_bone_collection_list(self.armature_objects, pg.bone_collection_list)
populate_bone_collection_list(self.armature_object, pg.bone_collection_list)
context.window_manager.fileselect_add(self) context.window_manager.fileselect_add(self)
@@ -442,9 +443,11 @@ class PSA_OT_export(Operator, ExportHelper):
# Ensure that we actually have items that we are going to be exporting. # Ensure that we actually have items that we are going to be exporting.
if pg.sequence_source == 'ACTIONS' and len(pg.action_list) == 0: if pg.sequence_source == 'ACTIONS' and len(pg.action_list) == 0:
raise RuntimeError('No actions were selected for export') raise RuntimeError('No actions were selected for export')
elif pg.sequence_source == 'TIMELINE_MARKERS' and len(pg.marker_list) == 0:
if pg.sequence_source == 'TIMELINE_MARKERS' and len(pg.marker_list) == 0:
raise RuntimeError('No timeline markers were selected for export') raise RuntimeError('No timeline markers were selected for export')
elif pg.sequence_source == 'NLA_TRACK_STRIPS' and len(pg.nla_strip_list) == 0:
if pg.sequence_source == 'NLA_TRACK_STRIPS' and len(pg.nla_strip_list) == 0:
raise RuntimeError('No NLA track strips were selected for export') raise RuntimeError('No NLA track strips were selected for export')
# Populate the export sequence list. # Populate the export sequence list.
@@ -511,6 +514,7 @@ class PSA_OT_export(Operator, ExportHelper):
return {'CANCELLED'} return {'CANCELLED'}
options = PsaBuildOptions() options = PsaBuildOptions()
options.armature_objects = self.armature_objects
options.animation_data = animation_data options.animation_data = animation_data
options.sequences = export_sequences options.sequences = export_sequences
options.bone_filter_mode = pg.bone_filter_mode options.bone_filter_mode = pg.bone_filter_mode
@@ -522,6 +526,7 @@ class PSA_OT_export(Operator, ExportHelper):
options.export_space = pg.export_space options.export_space = pg.export_space
options.forward_axis = pg.forward_axis options.forward_axis = pg.forward_axis
options.up_axis = pg.up_axis options.up_axis = pg.up_axis
options.root_bone_name = pg.root_bone_name
try: try:
psa = build_psa(context, options) psa = build_psa(context, options)

View File

@@ -217,6 +217,11 @@ class PSA_PG_export(PropertyGroup, ForwardUpAxisMixin, ExportSpaceMixin):
), ),
default='INTERPOLATED' default='INTERPOLATED'
) )
root_bone_name: StringProperty(
name='Root Bone Name',
description='The name of the generated root bone when exporting multiple armatures',
default='ROOT',
)
def filter_sequences(pg: PSA_PG_export, sequences) -> List[int]: def filter_sequences(pg: PSA_PG_export, sequences) -> List[int]:

View File

@@ -7,8 +7,8 @@ from bpy.types import FCurve, Object, Context
from mathutils import Vector, Quaternion from mathutils import Vector, Quaternion
from .config import PsaConfig, REMOVE_TRACK_LOCATION, REMOVE_TRACK_ROTATION from .config import PsaConfig, REMOVE_TRACK_LOCATION, REMOVE_TRACK_ROTATION
from .data import Psa
from .reader import PsaReader from .reader import PsaReader
from ..shared.data import PsxBone
class PsaImportOptions(object): class PsaImportOptions(object):
@@ -45,8 +45,8 @@ class PsaImportOptions(object):
class ImportBone(object): class ImportBone(object):
def __init__(self, psa_bone: Psa.Bone): def __init__(self, psa_bone: PsxBone):
self.psa_bone: Psa.Bone = psa_bone self.psa_bone: PsxBone = psa_bone
self.parent: Optional[ImportBone] = None self.parent: Optional[ImportBone] = None
self.armature_bone = None self.armature_bone = None
self.pose_bone = None self.pose_bone = None

View File

@@ -1,8 +1,9 @@
import ctypes from ctypes import sizeof
from typing import List
import numpy as np import numpy as np
from .data import * from .data import Psa, Section, PsxBone
def _try_fix_cue4parse_issue_103(sequences) -> bool: def _try_fix_cue4parse_issue_103(sequences) -> bool:
@@ -101,11 +102,11 @@ class PsaReader(object):
psa = Psa() psa = Psa()
while fp.read(1): while fp.read(1):
fp.seek(-1, 1) fp.seek(-1, 1)
section = Section.from_buffer_copy(fp.read(ctypes.sizeof(Section))) section = Section.from_buffer_copy(fp.read(sizeof(Section)))
if section.name == b'ANIMHEAD': if section.name == b'ANIMHEAD':
pass pass
elif section.name == b'BONENAMES': elif section.name == b'BONENAMES':
PsaReader._read_types(fp, Psa.Bone, section, psa.bones) PsaReader._read_types(fp, PsxBone, section, psa.bones)
elif section.name == b'ANIMINFO': elif section.name == b'ANIMINFO':
sequences = [] sequences = []
PsaReader._read_types(fp, Psa.Sequence, section, sequences) PsaReader._read_types(fp, Psa.Sequence, section, sequences)

View File

@@ -2,7 +2,7 @@ from ctypes import Structure, sizeof
from typing import Type from typing import Type
from .data import Psa from .data import Psa
from ..shared.data import Section from ..shared.data import Section, PsxBone
def write_section(fp, name: bytes, data_type: Type[Structure] = None, data: list = None): def write_section(fp, name: bytes, data_type: Type[Structure] = None, data: list = None):
@@ -20,6 +20,6 @@ def write_section(fp, name: bytes, data_type: Type[Structure] = None, data: list
def write_psa(psa: Psa, path: str): def write_psa(psa: Psa, path: str):
with open(path, 'wb') as fp: with open(path, 'wb') as fp:
write_section(fp, b'ANIMHEAD') write_section(fp, b'ANIMHEAD')
write_section(fp, b'BONENAMES', Psa.Bone, psa.bones) write_section(fp, b'BONENAMES', PsxBone, psa.bones)
write_section(fp, b'ANIMINFO', Psa.Sequence, list(psa.sequences.values())) write_section(fp, b'ANIMINFO', Psa.Sequence, list(psa.sequences.values()))
write_section(fp, b'ANIMKEYS', Psa.Key, psa.keys) write_section(fp, b'ANIMKEYS', Psa.Key, psa.keys)

View File

@@ -1,22 +1,23 @@
import typing import typing
from collections import Counter from typing import Dict, Generator, Set, Iterable, Optional, cast, Tuple
from typing import Dict, Generator, Set, Iterable, Optional, cast
import bmesh import bmesh
import bpy import bpy
import numpy as np import numpy as np
from bpy.types import Material, Collection, Context, Object, Armature, Bone from bpy.types import Collection, Context, Object, Armature
from mathutils import Matrix
from .data import * from .data import *
from .export.operators import get_materials_for_mesh_objects
from .properties import triangle_type_and_bit_flags_to_poly_flags from .properties import triangle_type_and_bit_flags_to_poly_flags
from ..shared.dfs import dfs_collection_objects, dfs_view_layer_objects, DfsObject from ..shared.dfs import dfs_collection_objects, dfs_view_layer_objects, DfsObject
from ..shared.helpers import get_coordinate_system_transform, convert_string_to_cp1252_bytes, \ from ..shared.helpers import convert_string_to_cp1252_bytes, \
get_export_bone_names, convert_blender_bones_to_psx_bones create_psx_bones, get_coordinate_system_transform
class PskInputObjects(object): class PskInputObjects(object):
def __init__(self): def __init__(self):
self.mesh_objects: List[DfsObject] = [] self.mesh_dfs_objects: List[DfsObject] = []
self.armature_objects: Set[Object] = set() self.armature_objects: Set[Object] = set()
@@ -25,7 +26,8 @@ class PskBuildOptions(object):
self.bone_filter_mode = 'ALL' self.bone_filter_mode = 'ALL'
self.bone_collection_indices: List[Tuple[str, int]] = [] self.bone_collection_indices: List[Tuple[str, int]] = []
self.object_eval_state = 'EVALUATED' self.object_eval_state = 'EVALUATED'
self.materials: List[Material] = [] self.material_order_mode = 'AUTOMATIC'
self.material_name_list: List[str] = []
self.scale = 1.0 self.scale = 1.0
self.export_space = 'WORLD' self.export_space = 'WORLD'
self.forward_axis = 'X' self.forward_axis = 'X'
@@ -63,14 +65,14 @@ def get_armatures_for_mesh_objects(mesh_objects: Iterable[Object]) -> Generator[
yield modifiers[0].object yield modifiers[0].object
def _get_psk_input_objects(mesh_objects: Iterable[DfsObject]) -> PskInputObjects: def _get_psk_input_objects(mesh_dfs_objects: Iterable[DfsObject]) -> PskInputObjects:
mesh_objects = list(mesh_objects) mesh_dfs_objects = list(mesh_dfs_objects)
if len(mesh_objects) == 0: if len(mesh_dfs_objects) == 0:
raise RuntimeError('At least one mesh must be selected') raise RuntimeError('At least one mesh must be selected')
input_objects = PskInputObjects() input_objects = PskInputObjects()
input_objects.mesh_objects = mesh_objects input_objects.mesh_dfs_objects = mesh_dfs_objects
input_objects.armature_objects |= set(get_armatures_for_mesh_objects(map(lambda x: x.obj, mesh_objects))) input_objects.armature_objects |= set(get_armatures_for_mesh_objects(map(lambda x: x.obj, mesh_dfs_objects)))
return input_objects return input_objects
@@ -133,98 +135,39 @@ def _get_material_name_indices(obj: Object, material_names: List[str]) -> Iterab
yield 0 yield 0
def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) -> PskBuildResult: def build_psk(context: Context, input_objects: PskInputObjects, options: PskBuildOptions) -> PskBuildResult:
armature_objects = list(input_objects.armature_objects) armature_objects = list(input_objects.armature_objects)
result = PskBuildResult() result = PskBuildResult()
psk = Psk() psk = Psk()
bones: List[Bone] = []
if options.export_space != 'WORLD' and len(armature_objects) > 1: psx_bone_create_result = create_psx_bones(
raise RuntimeError('When exporting multiple armatures, the Export Space must be World') armature_objects=armature_objects,
coordinate_system_matrix = get_coordinate_system_transform(options.forward_axis, options.up_axis)
coordinate_system_default_rotation = coordinate_system_matrix.to_quaternion()
scale_matrix = Matrix.Scale(options.scale, 4)
total_bone_count = sum(len(armature_object.data.bones) for armature_object in armature_objects)
# Store the index of the root bone for each armature object.
# We will need this later to correctly assign vertex weights.
armature_object_root_bone_indices = dict()
# Store the bone names to be exported for each armature object.
armature_object_bone_names: Dict[Object, List[str]] = dict()
for armature_object in armature_objects:
bone_collection_indices = [x[1] for x in options.bone_collection_indices if x[0] == armature_object.name]
bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, bone_collection_indices)
armature_object_bone_names[armature_object] = bone_names
if len(armature_objects) == 0 or total_bone_count == 0:
# If the mesh has no armature object or no bones, simply assign it a dummy bone at the root to satisfy the
# requirement that a PSK file must have at least one bone.
psk_bone = Psk.Bone()
psk_bone.name = convert_string_to_cp1252_bytes(options.root_bone_name)
psk_bone.flags = 0
psk_bone.children_count = 0
psk_bone.parent_index = 0
psk_bone.location = Vector3.zero()
psk_bone.rotation = Quaternion.from_bpy_quaternion(coordinate_system_default_rotation)
psk.bones.append(psk_bone)
armature_object_root_bone_indices[None] = 0
else:
# If we have multiple armature objects, create a root bone at the world origin.
if len(armature_objects) > 1:
psk_bone = Psk.Bone()
psk_bone.name = convert_string_to_cp1252_bytes(options.root_bone_name)
psk_bone.flags = 0
psk_bone.children_count = total_bone_count
psk_bone.parent_index = 0
psk_bone.location = Vector3.zero()
psk_bone.rotation = Quaternion.from_bpy_quaternion(coordinate_system_default_rotation)
psk.bones.append(psk_bone)
armature_object_root_bone_indices[None] = 0
root_bone = psk.bones[0] if len(psk.bones) > 0 else None
for armature_object in armature_objects:
bone_names = armature_object_bone_names[armature_object]
armature_data = typing.cast(Armature, armature_object.data)
bones = [armature_data.bones[bone_name] for bone_name in bone_names]
psk_bones = convert_blender_bones_to_psx_bones(
bones=bones,
bone_class=Psk.Bone,
export_space=options.export_space, export_space=options.export_space,
armature_object_matrix_world=armature_object.matrix_world,
scale=options.scale,
forward_axis=options.forward_axis, forward_axis=options.forward_axis,
up_axis=options.up_axis, up_axis=options.up_axis,
root_bone=root_bone, scale=options.scale,
root_bone_name=options.root_bone_name,
bone_filter_mode=options.bone_filter_mode,
bone_collection_indices=options.bone_collection_indices
) )
# If we are appending these bones to an existing list of bones, we need to adjust the parent indices. psk.bones = [psx_bone for psx_bone, _ in psx_bone_create_result.bones]
if len(psk.bones) > 0:
parent_index_offset = len(psk.bones)
for bone in psk_bones[1:]:
bone.parent_index += parent_index_offset
armature_object_root_bone_indices[armature_object] = len(psk.bones)
psk.bones.extend(psk_bones)
# Check if there are bone name conflicts between armatures.
bone_name_counts = Counter(x.name.decode('windows-1252').upper() for x in psk.bones)
for bone_name, count in bone_name_counts.items():
if count > 1:
raise RuntimeError(f'Found {count} bones with the name "{bone_name}". Bone names must be unique when compared case-insensitively.')
# Materials # Materials
for material in options.materials: match options.material_order_mode:
case 'AUTOMATIC':
mesh_objects = [dfs_object.obj for dfs_object in input_objects.mesh_dfs_objects]
materials = list(get_materials_for_mesh_objects(context.evaluated_depsgraph_get(), mesh_objects))
case 'MANUAL':
# The material name list may contain materials that are not on the mesh objects.
# Therefore, we can take the material_name_list as gospel and simply use it as a lookup table.
# If a look-up fails, replace it with an empty material.
materials = [bpy.data.materials.get(x.material_name, None) for x in options.material_name_list]
case _:
assert False, f'Invalid material order mode: {options.material_order_mode}'
for material in materials:
psk_material = Psk.Material() psk_material = Psk.Material()
psk_material.name = convert_string_to_cp1252_bytes(material.name) psk_material.name = convert_string_to_cp1252_bytes(material.name)
psk_material.texture_index = len(psk.materials) psk_material.texture_index = len(psk.materials)
@@ -243,9 +186,11 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
psk_material.name = convert_string_to_cp1252_bytes('None') psk_material.name = convert_string_to_cp1252_bytes('None')
psk.materials.append(psk_material) psk.materials.append(psk_material)
context.window_manager.progress_begin(0, len(input_objects.mesh_objects)) context.window_manager.progress_begin(0, len(input_objects.mesh_dfs_objects))
coordinate_system_matrix = get_coordinate_system_transform(options.forward_axis, options.up_axis)
mesh_export_space_matrix = _get_mesh_export_space_matrix(armature_objects, options.export_space) mesh_export_space_matrix = _get_mesh_export_space_matrix(armature_objects, options.export_space)
scale_matrix = Matrix.Scale(options.scale, 4)
vertex_transform_matrix = scale_matrix @ coordinate_system_matrix @ mesh_export_space_matrix vertex_transform_matrix = scale_matrix @ coordinate_system_matrix @ mesh_export_space_matrix
original_armature_object_pose_positions = {armature_object: armature_object.data.pose_position for armature_object in armature_objects} original_armature_object_pose_positions = {armature_object: armature_object.data.pose_position for armature_object in armature_objects}
@@ -255,9 +200,9 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
for armature_object in armature_objects: for armature_object in armature_objects:
armature_object.data.pose_position = 'REST' armature_object.data.pose_position = 'REST'
material_names = [m.name for m in options.materials] material_names = [m.name for m in materials]
for object_index, input_mesh_object in enumerate(input_objects.mesh_objects): for object_index, input_mesh_object in enumerate(input_objects.mesh_dfs_objects):
obj, instance_objects, matrix_world = input_mesh_object.obj, input_mesh_object.instance_objects, input_mesh_object.matrix_world obj, instance_objects, matrix_world = input_mesh_object.obj, input_mesh_object.instance_objects, input_mesh_object.matrix_world
armature_object = get_armature_for_mesh_object(obj) armature_object = get_armature_for_mesh_object(obj)
@@ -384,11 +329,11 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
# Weights # Weights
if armature_object is not None: if armature_object is not None:
armature_data = typing.cast(Armature, armature_object.data) armature_data = typing.cast(Armature, armature_object.data)
bone_index_offset = armature_object_root_bone_indices[armature_object] bone_index_offset = psx_bone_create_result.armature_object_root_bone_indices[armature_object]
# Because the vertex groups may contain entries for which there is no matching bone in the armature, # 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. # we must filter them out and not export any weights for these vertex groups.
bone_names = armature_object_bone_names[armature_object] bone_names = psx_bone_create_result.armature_object_bone_names[armature_object]
vertex_group_names = [x.name for x in mesh_object.vertex_groups] vertex_group_names = [x.name for x in mesh_object.vertex_groups]
vertex_group_bone_indices: Dict[int, int] = dict() vertex_group_bone_indices: Dict[int, int] = dict()
for vertex_group_index, vertex_group_name in enumerate(vertex_group_names): for vertex_group_index, vertex_group_name in enumerate(vertex_group_names):
@@ -433,7 +378,7 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
vertices_assigned_weights[vertex_index] = True vertices_assigned_weights[vertex_index] = True
# Assign vertices that have not been assigned weights to the root bone of the armature. # Assign vertices that have not been assigned weights to the root bone of the armature.
fallback_weight_bone_index = armature_object_root_bone_indices[armature_object] fallback_weight_bone_index = psx_bone_create_result.armature_object_root_bone_indices[armature_object]
for vertex_index, assigned in enumerate(vertices_assigned_weights): for vertex_index, assigned in enumerate(vertices_assigned_weights):
if not assigned: if not assigned:
w = Psk.Weight() w = Psk.Weight()

View File

@@ -1,6 +1,7 @@
from ctypes import Structure, c_uint32, c_float, c_int32, c_uint8, c_int8, c_int16, c_char, c_uint16
from typing import List from typing import List
from ..shared.data import * from ..shared.data import Vector3, Quaternion, Color, Vector2, PsxBone
class Psk(object): class Psk(object):
@@ -118,7 +119,7 @@ class Psk(object):
self.faces: List[Psk.Face] = [] self.faces: List[Psk.Face] = []
self.materials: List[Psk.Material] = [] self.materials: List[Psk.Material] = []
self.weights: List[Psk.Weight] = [] self.weights: List[Psk.Weight] = []
self.bones: List[Psk.Bone] = [] self.bones: List[PsxBone] = []
self.extra_uvs: List[Vector2] = [] self.extra_uvs: List[Vector2] = []
self.vertex_colors: List[Color] = [] self.vertex_colors: List[Color] = []
self.vertex_normals: List[Vector3] = [] self.vertex_normals: List[Vector3] = []

View File

@@ -2,7 +2,7 @@ from pathlib import Path
from typing import List, Optional, cast, Iterable from typing import List, Optional, cast, Iterable
import bpy import bpy
from bpy.props import StringProperty from bpy.props import StringProperty, BoolProperty
from bpy.types import Operator, Context, Object, Collection, SpaceProperties, Depsgraph, Material from bpy.types import Operator, Context, Object, Collection, SpaceProperties, Depsgraph, Material
from bpy_extras.io_utils import ExportHelper from bpy_extras.io_utils import ExportHelper
@@ -27,8 +27,14 @@ def get_materials_for_mesh_objects(depsgraph: Depsgraph, mesh_objects: Iterable[
yield material yield material
def populate_material_name_list(depsgraph: Depsgraph, mesh_objects, material_list): def populate_material_name_list(depsgraph: Depsgraph, mesh_objects: Iterable[Object], material_list):
materials = get_materials_for_mesh_objects(depsgraph, mesh_objects) materials = list(get_materials_for_mesh_objects(depsgraph, mesh_objects))
# Order the mesh object materials by the order any existing entries in the material list.
# This way, if the user has already set up the material list, we don't change the order.
material_names = [x.material_name for x in material_list]
materials = get_sorted_materials_by_names(materials, material_names)
material_list.clear() material_list.clear()
for index, material in enumerate(materials): for index, material in enumerate(materials):
m = material_list.add() m = material_list.add()
@@ -60,8 +66,8 @@ def get_collection_export_operator_from_context(context: Context) -> Optional[ob
return exporter.export_properties return exporter.export_properties
class PSK_OT_populate_bone_collection_list(Operator): class PSK_OT_bone_collection_list_populate(Operator):
bl_idname = 'psk.export_populate_bone_collection_list' bl_idname = 'psk.bone_collection_list_populate'
bl_label = 'Populate Bone Collection List' bl_label = 'Populate Bone Collection List'
bl_description = 'Populate the bone collection list from the armature that will be used in this collection export' bl_description = 'Populate the bone collection list from the armature that will be used in this collection export'
bl_options = {'INTERNAL'} bl_options = {'INTERNAL'}
@@ -83,6 +89,24 @@ class PSK_OT_populate_bone_collection_list(Operator):
return {'FINISHED'} return {'FINISHED'}
class PSK_OT_bone_collection_list_select_all(Operator):
bl_idname = 'psk.bone_collection_list_select_all'
bl_label = 'Select All'
bl_description = 'Select all bone collections'
bl_options = {'INTERNAL'}
is_selected: BoolProperty(default=True)
def execute(self, context):
export_operator = get_collection_export_operator_from_context(context)
if export_operator is None:
self.report({'ERROR_INVALID_CONTEXT'}, 'No valid export operator found in context')
return {'CANCELLED'}
for item in export_operator.bone_collection_list:
item.is_selected = self.is_selected
return {'FINISHED'}
class PSK_OT_populate_material_name_list(Operator): class PSK_OT_populate_material_name_list(Operator):
bl_idname = 'psk.export_populate_material_name_list' bl_idname = 'psk.export_populate_material_name_list'
bl_label = 'Populate Material Name List' bl_label = 'Populate Material Name List'
@@ -97,7 +121,7 @@ class PSK_OT_populate_material_name_list(Operator):
depsgraph = context.evaluated_depsgraph_get() depsgraph = context.evaluated_depsgraph_get()
input_objects = get_psk_input_objects_for_collection(context.collection) input_objects = get_psk_input_objects_for_collection(context.collection)
try: try:
populate_material_name_list(depsgraph, [x.obj for x in input_objects.mesh_objects], export_operator.material_name_list) populate_material_name_list(depsgraph, [x.obj for x in input_objects.mesh_dfs_objects], export_operator.material_name_list)
except RuntimeError as e: except RuntimeError as e:
self.report({'ERROR_INVALID_CONTEXT'}, str(e)) self.report({'ERROR_INVALID_CONTEXT'}, str(e))
return {'CANCELLED'} return {'CANCELLED'}
@@ -116,7 +140,7 @@ class PSK_OT_material_list_name_add(Operator):
bl_description = 'Add a material to the material name list (useful if you want to add a material slot that is not actually used in the mesh)' bl_description = 'Add a material to the material name list (useful if you want to add a material slot that is not actually used in the mesh)'
bl_options = {'INTERNAL'} bl_options = {'INTERNAL'}
name: StringProperty(search=material_list_names_search_cb) name: StringProperty(search=material_list_names_search_cb, name='Material Name', default='None')
def invoke(self, context, event): def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self) return context.window_manager.invoke_props_dialog(self)
@@ -232,7 +256,7 @@ def get_sorted_materials_by_names(materials: Iterable[Material], material_names:
return materials_in_collection + materials_not_in_collection return materials_in_collection + materials_not_in_collection
def get_psk_build_options_from_property_group(pg: 'PSK_PG_export') -> PskBuildOptions: def get_psk_build_options_from_property_group(pg: PskExportMixin) -> PskBuildOptions:
options = PskBuildOptions() options = PskBuildOptions()
options.object_eval_state = pg.object_eval_state options.object_eval_state = pg.object_eval_state
options.export_space = pg.export_space options.export_space = pg.export_space
@@ -242,15 +266,8 @@ def get_psk_build_options_from_property_group(pg: 'PSK_PG_export') -> PskBuildOp
options.forward_axis = pg.forward_axis options.forward_axis = pg.forward_axis
options.up_axis = pg.up_axis options.up_axis = pg.up_axis
options.root_bone_name = pg.root_bone_name options.root_bone_name = pg.root_bone_name
options.material_order_mode = pg.material_order_mode
# TODO: perhaps move this into the build function and replace the materials list with a material names list. options.material_name_list = pg.material_name_list
# materials = get_materials_for_mesh_objects(depsgraph, mesh_objects)
# The material name list may contain materials that are not on the mesh objects.
# Therefore, we can perhaps take the material_name_list as gospel and simply use it as a lookup table.
# If a look-up fails, replace it with an empty material.
options.materials = [bpy.data.materials.get(x.material_name, None) for x in pg.material_name_list]
return options return options
@@ -319,9 +336,16 @@ class PSK_OT_export_collection(Operator, ExportHelper, PskExportMixin):
draw_bone_filter_mode(bones_panel, self, True) draw_bone_filter_mode(bones_panel, self, True)
if self.bone_filter_mode == 'BONE_COLLECTIONS': if self.bone_filter_mode == 'BONE_COLLECTIONS':
bones_panel.operator(PSK_OT_populate_bone_collection_list.bl_idname, icon='FILE_REFRESH') row = bones_panel.row()
rows = max(3, min(len(self.bone_collection_list), 10)) 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) row.template_list('PSX_UL_bone_collection_list', '', self, 'bone_collection_list', self, 'bone_collection_list_index', rows=rows)
col = row.column(align=True)
col.operator(PSK_OT_bone_collection_list_populate.bl_idname, text='', icon='FILE_REFRESH')
col.separator()
op = col.operator(PSK_OT_bone_collection_list_select_all.bl_idname, text='', icon='CHECKBOX_HLT')
op.is_selected = True
op = col.operator(PSK_OT_bone_collection_list_select_all.bl_idname, text='', icon='CHECKBOX_DEHLT')
op.is_selected = False
advanced_bones_header, advanced_bones_panel = bones_panel.panel('Advanced', default_closed=True) advanced_bones_header, advanced_bones_panel = bones_panel.panel('Advanced', default_closed=True)
advanced_bones_header.label(text='Advanced') advanced_bones_header.label(text='Advanced')
@@ -336,11 +360,18 @@ class PSK_OT_export_collection(Operator, ExportHelper, PskExportMixin):
materials_header.label(text='Materials', icon='MATERIAL') materials_header.label(text='Materials', icon='MATERIAL')
if materials_panel: if materials_panel:
materials_panel.operator(PSK_OT_populate_material_name_list.bl_idname, icon='FILE_REFRESH') flow = materials_panel.grid_flow(row_major=True)
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(self, 'material_order_mode', text='Material Order')
if self.material_order_mode == 'MANUAL':
rows = max(3, min(len(self.material_name_list), 10)) rows = max(3, min(len(self.material_name_list), 10))
row = materials_panel.row() row = materials_panel.row()
row.template_list('PSK_UL_material_names', '', self, 'material_name_list', self, 'material_name_list_index', rows=rows) row.template_list('PSK_UL_material_names', '', self, 'material_name_list', self, 'material_name_list_index', rows=rows)
col = row.column(align=True) col = row.column(align=True)
col.operator(PSK_OT_populate_material_name_list.bl_idname, text='', icon='FILE_REFRESH')
col.separator()
col.operator(PSK_OT_material_list_name_move_up.bl_idname, text='', icon='TRIA_UP') col.operator(PSK_OT_material_list_name_move_up.bl_idname, text='', icon='TRIA_UP')
col.operator(PSK_OT_material_list_name_move_down.bl_idname, text='', icon='TRIA_DOWN') col.operator(PSK_OT_material_list_name_move_down.bl_idname, text='', icon='TRIA_DOWN')
col.separator() col.separator()
@@ -364,7 +395,7 @@ class PSK_OT_export(Operator, ExportHelper):
bl_idname = 'psk.export' bl_idname = 'psk.export'
bl_label = 'Export' bl_label = 'Export'
bl_options = {'INTERNAL', 'UNDO'} bl_options = {'INTERNAL', 'UNDO'}
bl_description = 'Export mesh and armature to PSK' bl_description = 'Export selected meshes to PSK'
filename_ext = '.psk' filename_ext = '.psk'
filter_glob: StringProperty(default='*.psk', options={'HIDDEN'}) filter_glob: StringProperty(default='*.psk', options={'HIDDEN'})
filepath: StringProperty( filepath: StringProperty(
@@ -387,7 +418,7 @@ class PSK_OT_export(Operator, ExportHelper):
depsgraph = context.evaluated_depsgraph_get() depsgraph = context.evaluated_depsgraph_get()
try: try:
populate_material_name_list(depsgraph, [x.obj for x in input_objects.mesh_objects], pg.material_name_list) populate_material_name_list(depsgraph, [x.obj for x in input_objects.mesh_dfs_objects], pg.material_name_list)
except RuntimeError as e: except RuntimeError as e:
self.report({'ERROR_INVALID_CONTEXT'}, str(e)) self.report({'ERROR_INVALID_CONTEXT'}, str(e))
return {'CANCELLED'} return {'CANCELLED'}
@@ -419,11 +450,24 @@ class PSK_OT_export(Operator, ExportHelper):
row = bones_panel.row() row = bones_panel.row()
rows = max(3, min(len(pg.bone_collection_list), 10)) rows = max(3, min(len(pg.bone_collection_list), 10))
row.template_list('PSX_UL_bone_collection_list', '', pg, 'bone_collection_list', pg, 'bone_collection_list_index', rows=rows) row.template_list('PSX_UL_bone_collection_list', '', pg, 'bone_collection_list', pg, 'bone_collection_list_index', rows=rows)
bones_advanced_header, bones_advanced_panel = bones_panel.panel('Advanced', default_closed=True)
bones_advanced_header.label(text='Advanced')
if bones_advanced_panel:
flow = bones_advanced_panel.grid_flow(row_major=True)
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(pg, 'root_bone_name')
# Materials # Materials
materials_header, materials_panel = layout.panel('Materials', default_closed=False) materials_header, materials_panel = layout.panel('Materials', default_closed=False)
materials_header.label(text='Materials', icon='MATERIAL') materials_header.label(text='Materials', icon='MATERIAL')
if materials_panel: if materials_panel:
flow = materials_panel.grid_flow(row_major=True)
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(pg, 'material_order_mode', text='Material Order')
if pg.material_order_mode == 'MANUAL':
row = materials_panel.row() row = materials_panel.row()
rows = max(3, min(len(pg.bone_collection_list), 10)) rows = max(3, min(len(pg.bone_collection_list), 10))
row.template_list('PSK_UL_material_names', '', pg, 'material_name_list', pg, 'material_name_list_index', rows=rows) row.template_list('PSK_UL_material_names', '', pg, 'material_name_list', pg, 'material_name_list_index', rows=rows)
@@ -470,7 +514,8 @@ classes = (
PSK_OT_material_list_move_down, PSK_OT_material_list_move_down,
PSK_OT_export, PSK_OT_export,
PSK_OT_export_collection, PSK_OT_export_collection,
PSK_OT_populate_bone_collection_list, PSK_OT_bone_collection_list_populate,
PSK_OT_bone_collection_list_select_all,
PSK_OT_populate_material_name_list, PSK_OT_populate_material_name_list,
PSK_OT_material_list_name_move_up, PSK_OT_material_list_name_move_up,
PSK_OT_material_list_name_move_down, PSK_OT_material_list_name_move_down,

View File

@@ -12,6 +12,11 @@ object_eval_state_items = (
('ORIGINAL', 'Original', 'Use data from original object with no modifiers applied'), ('ORIGINAL', 'Original', 'Use data from original object with no modifiers applied'),
) )
material_order_mode_items = (
('AUTOMATIC', 'Automatic', 'Automatically order the materials'),
('MANUAL', 'Manual', 'Manually arrange the materials'),
)
class PSK_PG_material_list_item(PropertyGroup): class PSK_PG_material_list_item(PropertyGroup):
material: PointerProperty(type=Material) material: PointerProperty(type=Material)
index: IntProperty() index: IntProperty()
@@ -47,6 +52,12 @@ class PskExportMixin(ExportSpaceMixin, ForwardUpAxisMixin):
) )
bone_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item) bone_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item)
bone_collection_list_index: IntProperty(default=0) bone_collection_list_index: IntProperty(default=0)
material_order_mode: EnumProperty(
name='Material Order',
description='The order in which to export the materials',
items=material_order_mode_items,
default='AUTOMATIC'
)
material_name_list: CollectionProperty(type=PSK_PG_material_name_list_item) material_name_list: CollectionProperty(type=PSK_PG_material_name_list_item)
material_name_list_index: IntProperty(default=0) material_name_list_index: IntProperty(default=0)
root_bone_name: StringProperty( root_bone_name: StringProperty(

View File

@@ -6,7 +6,8 @@ class PSK_UL_material_names(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
row = layout.row() row = layout.row()
material = bpy.data.materials.get(item.material_name, None) material = bpy.data.materials.get(item.material_name, None)
row.prop(item, 'material_name', text='', emboss=False, icon_value=layout.icon(material) if material else 0) icon_value = layout.icon(material) if material else 0
row.prop(item, 'material_name', text='', emboss=False, icon_value=icon_value, icon='BLANK1' if icon_value == 0 else 'NONE')
classes = ( classes = (

View File

@@ -8,6 +8,7 @@ from mathutils import Quaternion, Vector, Matrix
from .data import Psk from .data import Psk
from .properties import poly_flags_to_triangle_type_and_bit_flags from .properties import poly_flags_to_triangle_type_and_bit_flags
from ..shared.data import PsxBone
from ..shared.helpers import rgb_to_srgb, is_bdk_addon_loaded from ..shared.helpers import rgb_to_srgb, is_bdk_addon_loaded
@@ -31,9 +32,9 @@ class ImportBone:
''' '''
Intermediate bone type for the purpose of construction. Intermediate bone type for the purpose of construction.
''' '''
def __init__(self, index: int, psk_bone: Psk.Bone): def __init__(self, index: int, psk_bone: PsxBone):
self.index: int = index self.index: int = index
self.psk_bone: Psk.Bone = psk_bone self.psk_bone: PsxBone = psk_bone
self.parent: Optional[ImportBone] = None self.parent: Optional[ImportBone] = None
self.local_rotation: Quaternion = Quaternion() self.local_rotation: Quaternion = Quaternion()
self.local_translation: Vector = Vector() self.local_translation: Vector = Vector()

View File

@@ -53,7 +53,7 @@ def read_psk(path: str) -> Psk:
case b'MATT0000': case b'MATT0000':
_read_types(fp, Psk.Material, section, psk.materials) _read_types(fp, Psk.Material, section, psk.materials)
case b'REFSKELT': case b'REFSKELT':
_read_types(fp, Psk.Bone, section, psk.bones) _read_types(fp, PsxBone, section, psk.bones)
case b'RAWWEIGHTS': case b'RAWWEIGHTS':
_read_types(fp, Psk.Weight, section, psk.weights) _read_types(fp, Psk.Weight, section, psk.weights)
case b'FACE3200': case b'FACE3200':

View File

@@ -3,7 +3,7 @@ from ctypes import Structure, sizeof
from typing import Type from typing import Type
from .data import Psk from .data import Psk
from ..shared.data import Section, Vector3 from ..shared.data import Section, Vector3, PsxBone
MAX_WEDGE_COUNT = 65536 MAX_WEDGE_COUNT = 65536
MAX_POINT_COUNT = 4294967296 MAX_POINT_COUNT = 4294967296
@@ -55,7 +55,7 @@ def write_psk(psk: Psk, path: str):
_write_section(fp, b'VTXW0000', Psk.Wedge16, wedges) _write_section(fp, b'VTXW0000', Psk.Wedge16, wedges)
_write_section(fp, b'FACE0000', Psk.Face, psk.faces) _write_section(fp, b'FACE0000', Psk.Face, psk.faces)
_write_section(fp, b'MATT0000', Psk.Material, psk.materials) _write_section(fp, b'MATT0000', Psk.Material, psk.materials)
_write_section(fp, b'REFSKELT', Psk.Bone, psk.bones) _write_section(fp, b'REFSKELT', PsxBone, psk.bones)
_write_section(fp, b'RAWWEIGHTS', Psk.Weight, psk.weights) _write_section(fp, b'RAWWEIGHTS', Psk.Weight, psk.weights)
except PermissionError as e: except PermissionError as e:
raise RuntimeError(f'The current user "{os.getlogin()}" does not have permission to write to "{path}"') from e raise RuntimeError(f'The current user "{os.getlogin()}" does not have permission to write to "{path}"') from e

View File

@@ -2,7 +2,7 @@ from ctypes import *
from typing import Tuple from typing import Tuple
from bpy.props import EnumProperty from bpy.props import EnumProperty
from mathutils import Vector, Matrix, Quaternion as BpyQuaternion from mathutils import Quaternion as BpyQuaternion
class Color(Structure): class Color(Structure):
@@ -94,6 +94,19 @@ class Quaternion(Structure):
return quaternion return quaternion
class PsxBone(Structure):
_fields_ = [
('name', c_char * 64),
('flags', c_int32),
('children_count', c_int32),
('parent_index', c_int32),
('rotation', Quaternion),
('location', Vector3),
('length', c_float),
('size', Vector3)
]
class Section(Structure): class Section(Structure):
_fields_ = [ _fields_ = [
('name', c_char * 20), ('name', c_char * 20),
@@ -171,30 +184,3 @@ class ExportSpaceMixin:
items=export_space_items, items=export_space_items,
default='WORLD' default='WORLD'
) )
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)
))

View File

@@ -1,12 +1,14 @@
from typing import List, Iterable, cast, Optional from collections import Counter
from typing import List, Iterable, cast, Optional, Dict, Tuple
import bpy import bpy
from bpy.props import CollectionProperty from bpy.props import CollectionProperty
from bpy.types import AnimData, Object from bpy.types import AnimData, Object
from bpy.types import Armature from bpy.types import Armature
from mathutils import Matrix from mathutils import Matrix, Vector
from .data import get_coordinate_system_transform from .data import Vector3, Quaternion
from ..shared.data import PsxBone
def rgb_to_srgb(c: float): def rgb_to_srgb(c: float):
@@ -171,16 +173,15 @@ def convert_string_to_cp1252_bytes(string: str) -> bytes:
# TODO: Perhaps export space should just be a transform matrix, since the below is not actually used unless we're using WORLD space. # TODO: Perhaps export space should just be a transform matrix, since the below is not actually used unless we're using WORLD space.
def convert_blender_bones_to_psx_bones( def create_psx_bones_from_blender_bones(
bones: Iterable[bpy.types.Bone], bones: List[bpy.types.Bone],
bone_class: type,
export_space: str = 'WORLD', export_space: str = 'WORLD',
armature_object_matrix_world: Matrix = Matrix.Identity(4), armature_object_matrix_world: Matrix = Matrix.Identity(4),
scale = 1.0, scale = 1.0,
forward_axis: str = 'X', forward_axis: str = 'X',
up_axis: str = 'Z', up_axis: str = 'Z',
root_bone: Optional = None, root_bone: Optional = None,
) -> Iterable: ) -> List[PsxBone]:
scale_matrix = Matrix.Scale(scale, 4) scale_matrix = Matrix.Scale(scale, 4)
@@ -189,7 +190,7 @@ def convert_blender_bones_to_psx_bones(
psx_bones = [] psx_bones = []
for bone in bones: for bone in bones:
psx_bone = bone_class() psx_bone = PsxBone()
psx_bone.name = convert_string_to_cp1252_bytes(bone.name) psx_bone.name = convert_string_to_cp1252_bytes(bone.name)
try: try:
@@ -282,3 +283,148 @@ def get_export_space_matrix(export_space: str, armature_object: Optional[Object]
pass pass
case _: case _:
assert False, f'Invalid export space: {export_space}' assert False, f'Invalid export space: {export_space}'
class PsxBoneCreateResult:
def __init__(self,
bones: List[Tuple[PsxBone, Optional[Object]]], # List of tuples of (psx_bone, armature_object)
armature_object_root_bone_indices: Dict[Object, int],
armature_object_bone_names: Dict[Object, List[str]],
):
self.bones = bones
self.armature_object_root_bone_indices = armature_object_root_bone_indices
self.armature_object_bone_names = armature_object_bone_names
def create_psx_bones(
armature_objects: List[Object],
export_space: str = 'WORLD',
root_bone_name: str = 'ROOT',
forward_axis: str = 'X',
up_axis: str = 'Z',
scale: float = 1.0,
bone_filter_mode: str = 'ALL',
bone_collection_indices: Optional[List[Tuple[str, int]]] = None,
) -> PsxBoneCreateResult:
'''
Creates a list of PSX bones from the given armature objects and options.
This function will throw a RuntimeError if multiple armature objects are passed in and the export space is not WORLD.
It will also throw a RuntimeError if the bone names are not unique when compared case-insensitively.
'''
if bone_collection_indices is None:
bone_collection_indices = []
bones: List[Tuple[PsxBone, Optional[Object]]] = []
if export_space != 'WORLD' and len(armature_objects) > 1:
raise RuntimeError('When exporting multiple armatures, the Export Space must be World')
coordinate_system_matrix = get_coordinate_system_transform(forward_axis, up_axis)
coordinate_system_default_rotation = coordinate_system_matrix.to_quaternion()
total_bone_count = sum(len(armature_object.data.bones) for armature_object in armature_objects)
# Store the index of the root bone for each armature object.
# We will need this later to correctly assign vertex weights.
armature_object_root_bone_indices = dict()
# Store the bone names to be exported for each armature object.
armature_object_bone_names: Dict[Object, List[str]] = dict()
for armature_object in armature_objects:
armature_bone_collection_indices = [x[1] for x in bone_collection_indices if x[0] == armature_object.name]
bone_names = get_export_bone_names(armature_object, bone_filter_mode, armature_bone_collection_indices)
armature_object_bone_names[armature_object] = bone_names
if len(armature_objects) == 0 or total_bone_count == 0:
# If the mesh has no armature object or no bones, simply assign it a dummy bone at the root to satisfy the
# requirement that a PSK file must have at least one bone.
psx_bone = PsxBone()
psx_bone.name = convert_string_to_cp1252_bytes(root_bone_name)
psx_bone.flags = 0
psx_bone.children_count = 0
psx_bone.parent_index = 0
psx_bone.location = Vector3.zero()
psx_bone.rotation = Quaternion.from_bpy_quaternion(coordinate_system_default_rotation)
bones.append((psx_bone, None))
armature_object_root_bone_indices[None] = 0
else:
# If we have multiple armature objects, create a root bone at the world origin.
if len(armature_objects) > 1:
psx_bone = PsxBone()
psx_bone.name = convert_string_to_cp1252_bytes(root_bone_name)
psx_bone.flags = 0
psx_bone.children_count = total_bone_count
psx_bone.parent_index = 0
psx_bone.location = Vector3.zero()
psx_bone.rotation = Quaternion.from_bpy_quaternion(coordinate_system_default_rotation)
bones.append((psx_bone, None))
armature_object_root_bone_indices[None] = 0
root_bone = bones[0][0] if len(bones) > 0 else None
for armature_object in armature_objects:
bone_names = armature_object_bone_names[armature_object]
armature_data = cast(Armature, armature_object.data)
armature_bones = [armature_data.bones[bone_name] for bone_name in bone_names]
armature_psx_bones = create_psx_bones_from_blender_bones(
bones=armature_bones,
export_space=export_space,
armature_object_matrix_world=armature_object.matrix_world,
scale=scale,
forward_axis=forward_axis,
up_axis=up_axis,
root_bone=root_bone,
)
# If we are appending these bones to an existing list of bones, we need to adjust the parent indices.
if len(bones) > 0:
parent_index_offset = len(bones)
for bone in armature_psx_bones[1:]:
bone.parent_index += parent_index_offset
armature_object_root_bone_indices[armature_object] = len(bones)
bones.extend((psx_bone, armature_object) for psx_bone in armature_psx_bones)
# Check if there are bone name conflicts between armatures.
bone_name_counts = Counter(bone[0].name.decode('windows-1252').upper() for bone in bones)
for bone_name, count in bone_name_counts.items():
if count > 1:
raise RuntimeError(f'Found {count} bones with the name "{bone_name}". Bone names must be unique when compared case-insensitively.')
return PsxBoneCreateResult(
bones=bones,
armature_object_root_bone_indices=armature_object_root_bone_indices,
armature_object_bone_names=armature_object_bone_names,
)
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)
))