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 *
@@ -23,10 +23,11 @@ class PsaBuildSequence:
class PsaBuildOptions:
def __init__(self):
self.armature_objects: List[Object] = []
self.animation_data: Optional[AnimData] = None
self.sequences: List[PsaBuildSequence] = []
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_suffix: str = ''
self.scale = 1.0
@@ -34,24 +35,34 @@ class PsaBuildOptions:
self.export_space = 'WORLD'
self.forward_axis = 'X'
self.up_axis = 'Z'
self.root_bone_name = 'ROOT'
def _get_pose_bone_location_and_rotation(
pose_bone: PoseBone,
armature_object: Object,
pose_bone: Optional[PoseBone],
armature_object: Optional[Object],
export_space: str,
scale: Vector,
coordinate_system_transform: Matrix) -> Tuple[Vector, Quaternion]:
if pose_bone.parent is not None:
coordinate_system_transform: Matrix,
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_parent_matrix = pose_bone.parent.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:
# 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.
# Otherwise, it will be the identity matrix.
# TODO: taking the pose bone matrix puts this in armature space.
pose_bone_matrix = Matrix.Identity(4)
match export_space:
case 'ARMATURE':
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
case 'ROOT':
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
# 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()
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
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()
return location, rotation
def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
active_object = context.view_layer.objects.active
psa = Psa()
armature_object = active_object
evaluated_armature_object = armature_object.evaluated_get(context.evaluated_depsgraph_get())
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,
psx_bone_create_result = create_psx_bones(
armature_objects=options.armature_objects,
export_space=options.export_space,
armature_object_matrix_world=armature_object.matrix_world, # evaluated_armature_object.matrix_world,
scale=options.scale,
root_bone_name=options.root_bone_name,
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.
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.
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.
saved_frame_current = context.scene.frame_current
saved_action = options.animation_data.action
# 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)
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_end = export_sequence.nla_state.frame_end
@@ -160,26 +157,34 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
except ZeroDivisionError:
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 frame_start > frame_end:
frame_step = -frame_step
sequence_duration = frame_count_raw / export_sequence.fps
psa_sequence = Psa.Sequence()
try:
psa_sequence.name = bytes(export_sequence.name, encoding='windows-1252')
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_start_index = frame_start_index
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.key_reduction = 1.0
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):
key = Psa.Key()
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
psa.keys.append(key)
# TODO: In _get_pose_bone_location_and_rotation, we need to factor in the evaluated armature object's scale.
# Then also, if we're in ARMATURE export space, invert the pose bone matrix so that it's the identity matrix.
class PsaExportBone:
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 *= 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:
case 'INTERPOLATED':
@@ -221,13 +254,14 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
else:
last_frame_bone_poses.clear()
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(
pose_bone,
armature_object,
export_bone.pose_bone,
export_bone.armature_object,
options.export_space,
scale,
coordinate_system_transform=coordinate_system_transform
export_bone.scale,
coordinate_system_transform=coordinate_system_transform,
has_false_root_bone=has_false_root_bone,
)
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 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])
else:
# 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:
next_frame = last_frame + 1
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(
pose_bone,
armature_object,
options.export_space,
scale,
coordinate_system_transform=coordinate_system_transform
pose_bone=export_bone.pose_bone,
armature_object=export_bone.armature_object,
export_space=options.export_space,
scale=export_bone.scale,
coordinate_system_transform=coordinate_system_transform,
has_false_root_bone=has_false_root_bone,
)
next_frame_bone_poses.append((location, rotation))
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]
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):
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(
pose_bone,
armature_object,
options.export_space,
scale,
coordinate_system_transform=coordinate_system_transform
pose_bone=export_bone.pose_bone,
armature_object=export_bone.armature_object,
export_space=options.export_space,
scale=export_bone.scale,
coordinate_system_transform=coordinate_system_transform,
has_false_root_bone=has_false_root_bone,
)
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)
# Restore the previous action & frame.
# TODO: store each armature object's previous action
options.animation_data.action = saved_action
context.scene.frame_set(saved_frame_current)
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 Bone(Structure):
_fields_ = [
('name', c_char * 64),
('flags', c_int32),
('children_count', c_int32),
('parent_index', c_int32),
('rotation', Quaternion),
('location', Vector3),
('padding', c_char * 16)
]
class Sequence(Structure):
_fields_ = [
@@ -59,6 +49,6 @@ class Psa:
return repr((self.location, self.rotation, self.time))
def __init__(self):
self.bones: List[Psa.Bone] = []
self.bones: List[PsxBone] = []
self.sequences: typing.OrderedDict[str, Psa.Sequence] = OrderedDict()
self.keys: List[Psa.Key] = []

View File

@@ -3,9 +3,8 @@ from typing import List, Iterable, Dict, Tuple, Optional
import bpy
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_types import Operator
from .properties import PSA_PG_export, PSA_PG_export_action_list_item, filter_sequences, \
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)
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')
# Clear actions and markers.
@@ -72,6 +71,7 @@ def update_actions_and_timeline_markers(context: Context):
pg.active_action_list.clear()
# Get animation data.
# TODO: Not sure how to handle this with multiple armatures.
animation_data_object = get_animation_data_object(context)
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.
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
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
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:
animation_data_object = pg.animation_data_override
@@ -275,7 +276,7 @@ class PSA_OT_export(Operator, ExportHelper):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.armature_object = None
self.armature_objects: List[Object] = []
@classmethod
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',
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_header, transform_panel = layout.panel('Advanced', default_closed=False)
transform_header.label(text='Transform')
@@ -401,18 +410,10 @@ class PSA_OT_export(Operator, ExportHelper):
if context.view_layer.objects.active.type != '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:
raise RuntimeError('Cannot export PSA while in NLA tweak mode')
def invoke(self, context, _event):
try:
self._check_context(context)
@@ -421,16 +422,16 @@ class PSA_OT_export(Operator, ExportHelper):
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
# 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)
populate_bone_collection_list(self.armature_object, pg.bone_collection_list)
update_actions_and_timeline_markers(context, self.armature_objects)
populate_bone_collection_list(self.armature_objects, pg.bone_collection_list)
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.
if pg.sequence_source == 'ACTIONS' and len(pg.action_list) == 0:
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')
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')
# Populate the export sequence list.
@@ -511,6 +514,7 @@ class PSA_OT_export(Operator, ExportHelper):
return {'CANCELLED'}
options = PsaBuildOptions()
options.armature_objects = self.armature_objects
options.animation_data = animation_data
options.sequences = export_sequences
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.forward_axis = pg.forward_axis
options.up_axis = pg.up_axis
options.root_bone_name = pg.root_bone_name
try:
psa = build_psa(context, options)

View File

@@ -217,6 +217,11 @@ class PSA_PG_export(PropertyGroup, ForwardUpAxisMixin, ExportSpaceMixin):
),
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]:

View File

@@ -7,8 +7,8 @@ from bpy.types import FCurve, Object, Context
from mathutils import Vector, Quaternion
from .config import PsaConfig, REMOVE_TRACK_LOCATION, REMOVE_TRACK_ROTATION
from .data import Psa
from .reader import PsaReader
from ..shared.data import PsxBone
class PsaImportOptions(object):
@@ -45,8 +45,8 @@ class PsaImportOptions(object):
class ImportBone(object):
def __init__(self, psa_bone: Psa.Bone):
self.psa_bone: Psa.Bone = psa_bone
def __init__(self, psa_bone: PsxBone):
self.psa_bone: PsxBone = psa_bone
self.parent: Optional[ImportBone] = None
self.armature_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
from .data import *
from .data import Psa, Section, PsxBone
def _try_fix_cue4parse_issue_103(sequences) -> bool:
@@ -101,11 +102,11 @@ class PsaReader(object):
psa = Psa()
while fp.read(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':
pass
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':
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 .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):
@@ -20,6 +20,6 @@ def write_section(fp, name: bytes, data_type: Type[Structure] = None, data: list
def write_psa(psa: Psa, path: str):
with open(path, 'wb') as fp:
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'ANIMKEYS', Psa.Key, psa.keys)

View File

@@ -1,22 +1,23 @@
import typing
from collections import Counter
from typing import Dict, Generator, Set, Iterable, Optional, cast
from typing import Dict, Generator, Set, Iterable, Optional, cast, Tuple
import bmesh
import bpy
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 .export.operators import get_materials_for_mesh_objects
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.helpers import get_coordinate_system_transform, convert_string_to_cp1252_bytes, \
get_export_bone_names, convert_blender_bones_to_psx_bones
from ..shared.helpers import convert_string_to_cp1252_bytes, \
create_psx_bones, get_coordinate_system_transform
class PskInputObjects(object):
def __init__(self):
self.mesh_objects: List[DfsObject] = []
self.mesh_dfs_objects: List[DfsObject] = []
self.armature_objects: Set[Object] = set()
@@ -25,7 +26,8 @@ class PskBuildOptions(object):
self.bone_filter_mode = 'ALL'
self.bone_collection_indices: List[Tuple[str, int]] = []
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.export_space = 'WORLD'
self.forward_axis = 'X'
@@ -63,14 +65,14 @@ def get_armatures_for_mesh_objects(mesh_objects: Iterable[Object]) -> Generator[
yield modifiers[0].object
def _get_psk_input_objects(mesh_objects: Iterable[DfsObject]) -> PskInputObjects:
mesh_objects = list(mesh_objects)
if len(mesh_objects) == 0:
def _get_psk_input_objects(mesh_dfs_objects: Iterable[DfsObject]) -> PskInputObjects:
mesh_dfs_objects = list(mesh_dfs_objects)
if len(mesh_dfs_objects) == 0:
raise RuntimeError('At least one mesh must be selected')
input_objects = PskInputObjects()
input_objects.mesh_objects = mesh_objects
input_objects.armature_objects |= set(get_armatures_for_mesh_objects(map(lambda x: x.obj, 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_dfs_objects)))
return input_objects
@@ -133,98 +135,39 @@ def _get_material_name_indices(obj: Object, material_names: List[str]) -> Iterab
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)
result = PskBuildResult()
psk = Psk()
bones: List[Bone] = []
if options.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(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,
psx_bone_create_result = create_psx_bones(
armature_objects=armature_objects,
export_space=options.export_space,
armature_object_matrix_world=armature_object.matrix_world,
scale=options.scale,
forward_axis=options.forward_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.
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.')
psk.bones = [psx_bone for psx_bone, _ in psx_bone_create_result.bones]
# 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.name = convert_string_to_cp1252_bytes(material.name)
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.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)
scale_matrix = Matrix.Scale(options.scale, 4)
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}
@@ -255,9 +200,9 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
for armature_object in armature_objects:
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
armature_object = get_armature_for_mesh_object(obj)
@@ -384,11 +329,11 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions)
# Weights
if armature_object is not None:
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,
# 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_bone_indices: Dict[int, int] = dict()
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
# 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):
if not assigned:
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 ..shared.data import *
from ..shared.data import Vector3, Quaternion, Color, Vector2, PsxBone
class Psk(object):
@@ -118,7 +119,7 @@ class Psk(object):
self.faces: List[Psk.Face] = []
self.materials: List[Psk.Material] = []
self.weights: List[Psk.Weight] = []
self.bones: List[Psk.Bone] = []
self.bones: List[PsxBone] = []
self.extra_uvs: List[Vector2] = []
self.vertex_colors: List[Color] = []
self.vertex_normals: List[Vector3] = []

View File

@@ -2,7 +2,7 @@ from pathlib import Path
from typing import List, Optional, cast, Iterable
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_extras.io_utils import ExportHelper
@@ -27,8 +27,14 @@ def get_materials_for_mesh_objects(depsgraph: Depsgraph, mesh_objects: Iterable[
yield material
def populate_material_name_list(depsgraph: Depsgraph, mesh_objects, material_list):
materials = get_materials_for_mesh_objects(depsgraph, mesh_objects)
def populate_material_name_list(depsgraph: Depsgraph, mesh_objects: Iterable[Object], material_list):
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()
for index, material in enumerate(materials):
m = material_list.add()
@@ -60,8 +66,8 @@ def get_collection_export_operator_from_context(context: Context) -> Optional[ob
return exporter.export_properties
class PSK_OT_populate_bone_collection_list(Operator):
bl_idname = 'psk.export_populate_bone_collection_list'
class PSK_OT_bone_collection_list_populate(Operator):
bl_idname = 'psk.bone_collection_list_populate'
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_options = {'INTERNAL'}
@@ -83,6 +89,24 @@ class PSK_OT_populate_bone_collection_list(Operator):
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):
bl_idname = 'psk.export_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()
input_objects = get_psk_input_objects_for_collection(context.collection)
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:
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
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_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):
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
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.object_eval_state = pg.object_eval_state
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.up_axis = pg.up_axis
options.root_bone_name = pg.root_bone_name
# TODO: perhaps move this into the build function and replace the materials list with a material names 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]
options.material_order_mode = pg.material_order_mode
options.material_name_list = pg.material_name_list
return options
@@ -319,9 +336,16 @@ class PSK_OT_export_collection(Operator, ExportHelper, PskExportMixin):
draw_bone_filter_mode(bones_panel, self, True)
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))
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.label(text='Advanced')
@@ -336,11 +360,18 @@ class PSK_OT_export_collection(Operator, ExportHelper, PskExportMixin):
materials_header.label(text='Materials', icon='MATERIAL')
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))
row = materials_panel.row()
row.template_list('PSK_UL_material_names', '', self, 'material_name_list', self, 'material_name_list_index', rows=rows)
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_down.bl_idname, text='', icon='TRIA_DOWN')
col.separator()
@@ -364,7 +395,7 @@ class PSK_OT_export(Operator, ExportHelper):
bl_idname = 'psk.export'
bl_label = 'Export'
bl_options = {'INTERNAL', 'UNDO'}
bl_description = 'Export mesh and armature to PSK'
bl_description = 'Export selected meshes to PSK'
filename_ext = '.psk'
filter_glob: StringProperty(default='*.psk', options={'HIDDEN'})
filepath: StringProperty(
@@ -387,7 +418,7 @@ class PSK_OT_export(Operator, ExportHelper):
depsgraph = context.evaluated_depsgraph_get()
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:
self.report({'ERROR_INVALID_CONTEXT'}, str(e))
return {'CANCELLED'}
@@ -419,11 +450,24 @@ class PSK_OT_export(Operator, ExportHelper):
row = bones_panel.row()
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)
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_header, materials_panel = layout.panel('Materials', default_closed=False)
materials_header.label(text='Materials', icon='MATERIAL')
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()
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)
@@ -470,7 +514,8 @@ classes = (
PSK_OT_material_list_move_down,
PSK_OT_export,
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_material_list_name_move_up,
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'),
)
material_order_mode_items = (
('AUTOMATIC', 'Automatic', 'Automatically order the materials'),
('MANUAL', 'Manual', 'Manually arrange the materials'),
)
class PSK_PG_material_list_item(PropertyGroup):
material: PointerProperty(type=Material)
index: IntProperty()
@@ -47,6 +52,12 @@ class PskExportMixin(ExportSpaceMixin, ForwardUpAxisMixin):
)
bone_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item)
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_index: IntProperty(default=0)
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):
row = layout.row()
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 = (

View File

@@ -8,6 +8,7 @@ from mathutils import Quaternion, Vector, Matrix
from .data import Psk
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
@@ -31,9 +32,9 @@ class ImportBone:
'''
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.psk_bone: Psk.Bone = psk_bone
self.psk_bone: PsxBone = psk_bone
self.parent: Optional[ImportBone] = None
self.local_rotation: Quaternion = Quaternion()
self.local_translation: Vector = Vector()

View File

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

View File

@@ -3,7 +3,7 @@ from ctypes import Structure, sizeof
from typing import Type
from .data import Psk
from ..shared.data import Section, Vector3
from ..shared.data import Section, Vector3, PsxBone
MAX_WEDGE_COUNT = 65536
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'FACE0000', Psk.Face, psk.faces)
_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)
except PermissionError as 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 bpy.props import EnumProperty
from mathutils import Vector, Matrix, Quaternion as BpyQuaternion
from mathutils import Quaternion as BpyQuaternion
class Color(Structure):
@@ -94,6 +94,19 @@ class Quaternion(Structure):
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):
_fields_ = [
('name', c_char * 20),
@@ -171,30 +184,3 @@ class ExportSpaceMixin:
items=export_space_items,
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
from bpy.props import CollectionProperty
from bpy.types import AnimData, Object
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):
@@ -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.
def convert_blender_bones_to_psx_bones(
bones: Iterable[bpy.types.Bone],
bone_class: type,
def create_psx_bones_from_blender_bones(
bones: List[bpy.types.Bone],
export_space: str = 'WORLD',
armature_object_matrix_world: Matrix = Matrix.Identity(4),
scale = 1.0,
forward_axis: str = 'X',
up_axis: str = 'Z',
root_bone: Optional = None,
) -> Iterable:
) -> List[PsxBone]:
scale_matrix = Matrix.Scale(scale, 4)
@@ -189,7 +190,7 @@ def convert_blender_bones_to_psx_bones(
psx_bones = []
for bone in bones:
psx_bone = bone_class()
psx_bone = PsxBone()
psx_bone.name = convert_string_to_cp1252_bytes(bone.name)
try:
@@ -282,3 +283,148 @@ def get_export_space_matrix(export_space: str, armature_object: Optional[Object]
pass
case _:
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)
))