From d1bae944f1f73e35c1ea06c657f6762d86ba91f5 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Fri, 31 Jan 2025 00:39:24 -0800 Subject: [PATCH] Refactoring and fixing issues with PSK exports with non-default forward & up axes --- io_scene_psk_psa/psa/builder.py | 84 ++++++++--------- io_scene_psk_psa/psa/export/operators.py | 12 ++- io_scene_psk_psa/psa/export/properties.py | 4 +- io_scene_psk_psa/psk/builder.py | 109 ++++------------------ io_scene_psk_psa/psk/export/operators.py | 49 ++++++++-- io_scene_psk_psa/psk/export/properties.py | 56 +---------- io_scene_psk_psa/shared/data.py | 90 ++++++++++++++++++ io_scene_psk_psa/shared/helpers.py | 90 ++++++++++++++++++ 8 files changed, 289 insertions(+), 205 deletions(-) diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index b22cdab..01a2ea4 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -1,7 +1,6 @@ from typing import Optional from bpy.types import Bone, Action, PoseBone -from mathutils import Vector from .data import * from ..shared.helpers import * @@ -35,25 +34,32 @@ class PsaBuildOptions: self.root_motion: bool = False self.scale = 1.0 self.sampling_mode: str = 'INTERPOLATED' # One of ('INTERPOLATED', 'SUBFRAME') + self.export_space = 'WORLD' + self.forward_axis = 'X' + self.up_axis = 'Z' -def _get_pose_bone_location_and_rotation(pose_bone: PoseBone, armature_object: Object, options: PsaBuildOptions): +def _get_pose_bone_location_and_rotation(pose_bone: PoseBone, armature_object: Object, root_motion: bool, scale: float, coordinate_system_transform: Matrix) -> Tuple[Vector, Quaternion]: if 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: - if options.root_motion: + if root_motion: # Get the bone's pose matrix, taking the armature object's world matrix into account. pose_bone_matrix = armature_object.matrix_world @ pose_bone.matrix else: # Use the bind pose matrix for the root bone. pose_bone_matrix = pose_bone.matrix + # 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. + pose_bone_matrix = coordinate_system_transform @ pose_bone_matrix + location = pose_bone_matrix.to_translation() rotation = pose_bone_matrix.to_quaternion().normalized() - location *= options.scale + location *= scale if pose_bone.parent is not None: rotation.conjugate() @@ -86,46 +92,18 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: 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. + # Build list of PSA bones. - for bone in bones: - psa_bone = Psa.Bone() - - try: - psa_bone.name = bytes(bone.name, encoding='windows-1252') - except UnicodeEncodeError: - raise RuntimeError(f'Bone name "{bone.name}" contains characters that cannot be encoded in the Windows-1252 codepage') - - try: - parent_index = bones.index(bone.parent) - psa_bone.parent_index = parent_index - psa.bones[parent_index].children_count += 1 - except ValueError: - psa_bone.parent_index = 0 - - if bone.parent is not None: - rotation = bone.matrix.to_quaternion().conjugated() - inverse_parent_rotation = bone.parent.matrix.to_quaternion().inverted() - parent_head = inverse_parent_rotation @ bone.parent.head - parent_tail = inverse_parent_rotation @ bone.parent.tail - location = (parent_tail - parent_head) + bone.head - else: - armature_local_matrix = armature_object.matrix_local - location = armature_local_matrix @ bone.head - bone_rotation = bone.matrix.to_quaternion().conjugated() - local_rotation = armature_local_matrix.to_3x3().to_quaternion().conjugated() - rotation = bone_rotation @ local_rotation - rotation.conjugate() - - psa_bone.location.x = location.x - psa_bone.location.y = location.y - psa_bone.location.z = location.z - - psa_bone.rotation.x = rotation.x - psa_bone.rotation.y = rotation.y - psa_bone.rotation.z = rotation.z - psa_bone.rotation.w = rotation.w - - psa.bones.append(psa_bone) + psa.bones = convert_blender_bones_to_psx_bones( + bones=bones, + bone_class=Psa.Bone, + 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 + ) # Add prefixes and suffices to the names of the export sequences and strip whitespace. for export_sequence in options.sequences: @@ -142,6 +120,8 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: context.window_manager.progress_begin(0, len(options.sequences)) + 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 export_sequence.armature_object.pose.bones] @@ -221,9 +201,13 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: last_frame_bone_poses.clear() context.scene.frame_set(frame=last_frame) for pose_bone in pose_bones: - location, rotation = _get_pose_bone_location_and_rotation(pose_bone, - export_sequence.armature_object, - options) + location, rotation = _get_pose_bone_location_and_rotation( + pose_bone, + export_sequence.armature_object, + root_motion=options.root_motion, + scale=options.scale, + coordinate_system_transform=coordinate_system_transform + ) last_frame_bone_poses.append((location, rotation)) next_frame = None @@ -239,7 +223,13 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: next_frame = last_frame + 1 context.scene.frame_set(frame=next_frame) for pose_bone in pose_bones: - location, rotation = _get_pose_bone_location_and_rotation(pose_bone, export_sequence.armature_object, options) + location, rotation = _get_pose_bone_location_and_rotation( + pose_bone, + export_sequence.armature_object, + root_motion=options.root_motion, + scale=options.scale, + coordinate_system_transform=coordinate_system_transform + ) next_frame_bone_poses.append((location, rotation)) factor = frame % 1.0 diff --git a/io_scene_psk_psa/psa/export/operators.py b/io_scene_psk_psa/psa/export/operators.py index 65a37c4..99af986 100644 --- a/io_scene_psk_psa/psa/export/operators.py +++ b/io_scene_psk_psa/psa/export/operators.py @@ -383,11 +383,14 @@ class PSA_OT_export(Operator, ExportHelper): transform_header.label(text='Transform') if transform_panel: - flow = transform_panel.grid_flow() + flow = transform_panel.grid_flow(row_major=True) flow.use_property_split = True flow.use_property_decorate = False - flow.prop(pg, 'root_motion', text='Root Motion') - flow.prop(pg, 'scale', text='Scale') + flow.prop(pg, 'root_motion') + flow.prop(pg, 'export_space') + flow.prop(pg, 'scale') + flow.prop(pg, 'forward_axis') + flow.prop(pg, 'up_axis') @classmethod def _check_context(cls, context): @@ -514,6 +517,9 @@ class PSA_OT_export(Operator, ExportHelper): options.root_motion = pg.root_motion options.scale = pg.scale options.sampling_mode = pg.sampling_mode + options.export_space = pg.export_space + options.forward_axis = pg.forward_axis + options.up_axis = pg.up_axis try: psa = build_psa(context, options) diff --git a/io_scene_psk_psa/psa/export/properties.py b/io_scene_psk_psa/psa/export/properties.py index b87575b..e9a9823 100644 --- a/io_scene_psk_psa/psa/export/properties.py +++ b/io_scene_psk_psa/psa/export/properties.py @@ -7,7 +7,7 @@ from bpy.props import BoolProperty, PointerProperty, EnumProperty, FloatProperty StringProperty from bpy.types import PropertyGroup, Object, Action, AnimData, Context -from ...shared.data import bone_filter_mode_items +from ...shared.data import bone_filter_mode_items, ForwardUpAxisMixin, ExportSpaceMixin from ...shared.types import PSX_PG_bone_collection_list_item @@ -102,7 +102,7 @@ def animation_data_override_update_cb(self: 'PSA_PG_export', context: Context): self.nla_track = '' -class PSA_PG_export(PropertyGroup): +class PSA_PG_export(PropertyGroup, ForwardUpAxisMixin, ExportSpaceMixin): root_motion: BoolProperty( name='Root Motion', options=empty_set, diff --git a/io_scene_psk_psa/psk/builder.py b/io_scene_psk_psa/psk/builder.py index aa01c45..c485c3f 100644 --- a/io_scene_psk_psa/psk/builder.py +++ b/io_scene_psk_psa/psk/builder.py @@ -4,7 +4,6 @@ from typing import Optional import bmesh import numpy as np from bpy.types import Material, Collection, Context -from mathutils import Matrix, Vector from .data import * from .properties import triangle_type_and_bit_flags_to_poly_flags @@ -30,34 +29,6 @@ class PskBuildOptions(object): self.up_axis = 'Z' -def get_vector_from_axis_identifier(axis_identifier: str) -> Vector: - match axis_identifier: - case 'X': - return Vector((1.0, 0.0, 0.0)) - case 'Y': - return Vector((0.0, 1.0, 0.0)) - case 'Z': - return Vector((0.0, 0.0, 1.0)) - case '-X': - return Vector((-1.0, 0.0, 0.0)) - case '-Y': - return Vector((0.0, -1.0, 0.0)) - case '-Z': - return Vector((0.0, 0.0, -1.0)) - - -def get_coordinate_system_transform(forward_axis: str = 'X', up_axis: str = 'Z') -> Matrix: - forward = get_vector_from_axis_identifier(forward_axis) - up = get_vector_from_axis_identifier(up_axis) - left = up.cross(forward) - return Matrix(( - (forward.x, forward.y, forward.z, 0.0), - (left.x, left.y, left.z, 0.0), - (up.x, up.y, up.z, 0.0), - (0.0, 0.0, 0.0, 1.0) - )).inverted() - - def get_mesh_objects_for_collection(collection: Collection) -> Iterable[DfsObject]: return filter(lambda x: x.obj.type == 'MESH', dfs_collection_objects(collection)) @@ -143,7 +114,12 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) coordinate_system_default_rotation = coordinate_system_matrix.to_quaternion() export_space_matrix = get_export_space_matrix() # TODO: maybe neutralize the scale here? - scale_matrix = coordinate_system_matrix @ Matrix.Scale(options.scale, 4) + scale_matrix = Matrix.Scale(options.scale, 4) + + # We effectively need 3 transforms, I think: + # 1. The transform for the mesh vertices. + # 2. The transform for the bone locations. + # 3. The transform for the bone rotations. if armature_object is None or len(armature_object.data.bones) == 0: # If the mesh has no armature object or no bones, simply assign it a dummy bone at the root to satisfy the @@ -161,65 +137,14 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) armature_data = typing.cast(Armature, armature_object.data) bones = [armature_data.bones[bone_name] for bone_name in bone_names] - for bone in bones: - psk_bone = Psk.Bone() - try: - psk_bone.name = bytes(bone.name, encoding='windows-1252') - except UnicodeEncodeError: - raise RuntimeError( - f'Bone name "{bone.name}" contains characters that cannot be encoded in the Windows-1252 codepage') - psk_bone.flags = 0 - psk_bone.children_count = 0 - - try: - parent_index = bones.index(bone.parent) - psk_bone.parent_index = parent_index - psk.bones[parent_index].children_count += 1 - except ValueError: - psk_bone.parent_index = 0 - - if bone.parent is not None: - rotation = bone.matrix.to_quaternion().conjugated() - inverse_parent_rotation = bone.parent.matrix.to_quaternion().inverted() - parent_head = inverse_parent_rotation @ bone.parent.head - parent_tail = inverse_parent_rotation @ bone.parent.tail - location = (parent_tail - parent_head) + bone.head - else: - def get_armature_local_matrix(): - match options.export_space: - case 'WORLD': - return armature_object.matrix_world - case 'ARMATURE': - return Matrix.Identity(4) - case _: - raise ValueError(f'Invalid export space: {options.export_space}') - - armature_local_matrix = get_armature_local_matrix() - location = armature_local_matrix @ bone.head - bone_rotation = bone.matrix.to_quaternion().conjugated() - local_rotation = armature_local_matrix.to_3x3().to_quaternion().conjugated() - rotation = bone_rotation @ local_rotation - rotation.conjugate() - rotation = coordinate_system_default_rotation @ rotation - - location = scale_matrix @ location - - # If the armature object has been scaled, we need to scale the bone's location to match. - _, _, armature_object_scale = armature_object.matrix_world.decompose() - location.x *= armature_object_scale.x - location.y *= armature_object_scale.y - location.z *= armature_object_scale.z - - psk_bone.location.x = location.x - psk_bone.location.y = location.y - psk_bone.location.z = location.z - - psk_bone.rotation.w = rotation.w - psk_bone.rotation.x = rotation.x - psk_bone.rotation.y = rotation.y - psk_bone.rotation.z = rotation.z - - psk.bones.append(psk_bone) + psk.bones = convert_blender_bones_to_psx_bones( + bones, Psk.Bone, + options.export_space, + armature_object.matrix_world, + options.scale, + options.forward_axis, + options.up_axis + ) # MATERIALS for material in options.materials: @@ -248,6 +173,8 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) material_names = [m.name for m in options.materials] + vertex_transform_matrix = scale_matrix @ coordinate_system_matrix @ export_space_matrix + for object_index, input_mesh_object in enumerate(input_objects.mesh_objects): obj, instance_objects, matrix_world = input_mesh_object.obj, input_mesh_object.instance_objects, input_mesh_object.matrix_world @@ -329,12 +256,12 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) raise ValueError(f'Invalid object evaluation state: {options.object_eval_state}') vertex_offset = len(psk.points) - matrix_world = scale_matrix @ export_space_matrix @ mesh_object.matrix_world + point_transform_matrix = vertex_transform_matrix @ mesh_object.matrix_world # VERTICES for vertex in mesh_data.vertices: point = Vector3() - v = matrix_world @ vertex.co + v = point_transform_matrix @ vertex.co point.x = v.x point.y = v.y point.z = v.z diff --git a/io_scene_psk_psa/psk/export/operators.py b/io_scene_psk_psa/psk/export/operators.py index 83e9426..6c484c6 100644 --- a/io_scene_psk_psa/psk/export/operators.py +++ b/io_scene_psk_psa/psk/export/operators.py @@ -99,6 +99,35 @@ class PSK_OT_populate_material_name_list(Operator): return {'FINISHED'} + +def material_list_names_search_cb(self, context: Context, edit_text: str): + for material in bpy.data.materials: + yield material.name + + +class PSK_OT_material_list_name_add(Operator): + bl_idname = 'psk.export_material_name_list_item_add' + bl_label = 'Add Material' + 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) + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + 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'} + m = export_operator.material_name_list.add() + m.material_name = self.name + m.index = len(export_operator.material_name_list) - 1 + return {'FINISHED'} + + + class PSK_OT_material_list_move_up(Operator): bl_idname = 'psk.export_material_list_item_move_up' bl_label = 'Move Up' @@ -198,10 +227,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(mesh_objects: Iterable[Object], pg: 'PSK_PG_export', depsgraph: Optional[Depsgraph] = None) -> PskBuildOptions: - if depsgraph is None: - depsgraph = bpy.context.evaluated_depsgraph_get() - +def get_psk_build_options_from_property_group(pg: 'PSK_PG_export') -> PskBuildOptions: options = PskBuildOptions() options.object_eval_state = pg.object_eval_state options.export_space = pg.export_space @@ -212,8 +238,12 @@ def get_psk_build_options_from_property_group(mesh_objects: Iterable[Object], p options.up_axis = pg.up_axis # 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) - options.materials = get_sorted_materials_by_names(materials, [m.material_name for m in 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 @@ -241,7 +271,7 @@ class PSK_OT_export_collection(Operator, ExportHelper, PskExportMixin): self.report({'ERROR_INVALID_CONTEXT'}, str(e)) return {'CANCELLED'} - options = get_psk_build_options_from_property_group([x.obj for x in input_objects.mesh_objects], self) + options = get_psk_build_options_from_property_group(self) try: result = build_psk(context, input_objects, options) @@ -297,6 +327,8 @@ class PSK_OT_export_collection(Operator, ExportHelper, PskExportMixin): col = row.column(align=True) 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() + col.operator(PSK_OT_material_list_name_add.bl_idname, text='', icon='ADD') # TRANSFORM transform_header, transform_panel = layout.panel('Transform', default_closed=False) @@ -391,7 +423,7 @@ class PSK_OT_export(Operator, ExportHelper): pg = getattr(context.scene, 'psk_export') input_objects = get_psk_input_objects_for_context(context) - options = get_psk_build_options_from_property_group([x.obj for x in input_objects.mesh_objects], pg) + options = get_psk_build_options_from_property_group(pg) try: result = build_psk(context, input_objects, options) @@ -418,4 +450,5 @@ classes = ( PSK_OT_populate_material_name_list, PSK_OT_material_list_name_move_up, PSK_OT_material_list_name_move_down, + PSK_OT_material_list_name_add, ) diff --git a/io_scene_psk_psa/psk/export/properties.py b/io_scene_psk_psa/psk/export/properties.py index 71ba535..03f108d 100644 --- a/io_scene_psk_psa/psk/export/properties.py +++ b/io_scene_psk_psa/psk/export/properties.py @@ -2,7 +2,7 @@ from bpy.props import EnumProperty, CollectionProperty, IntProperty, PointerProp BoolProperty from bpy.types import PropertyGroup, Material -from ...shared.data import bone_filter_mode_items +from ...shared.data import bone_filter_mode_items, ExportSpaceMixin, ForwardUpAxisMixin from ...shared.types import PSX_PG_bone_collection_list_item empty_set = set() @@ -17,26 +17,6 @@ export_space_items = [ ('ARMATURE', 'Armature', 'Export in armature space'), ] - -axis_identifiers = ('X', 'Y', 'Z', '-X', '-Y', '-Z') -forward_items = ( - ('X', 'X Forward', ''), - ('Y', 'Y Forward', ''), - ('Z', 'Z Forward', ''), - ('-X', '-X Forward', ''), - ('-Y', '-Y Forward', ''), - ('-Z', '-Z Forward', ''), -) - -up_items = ( - ('X', 'X Up', ''), - ('Y', 'Y Up', ''), - ('Z', 'Z Up', ''), - ('-X', '-X Up', ''), - ('-Y', '-Y Up', ''), - ('-Z', '-Z Up', ''), -) - class PSK_PG_material_list_item(PropertyGroup): material: PointerProperty(type=Material) index: IntProperty() @@ -46,21 +26,7 @@ class PSK_PG_material_name_list_item(PropertyGroup): index: IntProperty() - - -def forward_axis_update(self, _context): - if self.forward_axis == self.up_axis: - # Automatically set the up axis to the next available axis - self.up_axis = next((axis for axis in axis_identifiers if axis != self.forward_axis), 'Z') - - -def up_axis_update(self, _context): - if self.up_axis == self.forward_axis: - # Automatically set the forward axis to the next available axis - self.forward_axis = next((axis for axis in axis_identifiers if axis != self.up_axis), 'X') - - -class PskExportMixin: +class PskExportMixin(ExportSpaceMixin, ForwardUpAxisMixin): object_eval_state: EnumProperty( items=object_eval_state_items, name='Object Evaluation State', @@ -78,12 +44,6 @@ class PskExportMixin: min=0.0001, soft_max=100.0 ) - export_space: EnumProperty( - name='Export Space', - description='Space to export the mesh in', - items=export_space_items, - default='WORLD' - ) bone_filter_mode: EnumProperty( name='Bone Filter', options=empty_set, @@ -92,18 +52,6 @@ class PskExportMixin: ) bone_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item) bone_collection_list_index: IntProperty(default=0) - forward_axis: EnumProperty( - name='Forward', - items=forward_items, - default='X', - update=forward_axis_update - ) - up_axis: EnumProperty( - name='Up', - items=up_items, - default='Z', - update=up_axis_update - ) material_name_list: CollectionProperty(type=PSK_PG_material_name_list_item) material_name_list_index: IntProperty(default=0) diff --git a/io_scene_psk_psa/shared/data.py b/io_scene_psk_psa/shared/data.py index 9f29f1c..f96cd77 100644 --- a/io_scene_psk_psa/shared/data.py +++ b/io_scene_psk_psa/shared/data.py @@ -1,6 +1,9 @@ from ctypes import * from typing import Tuple +from bpy.props import EnumProperty +from mathutils import Vector, Matrix + class Color(Structure): _fields_ = [ @@ -99,3 +102,90 @@ bone_filter_mode_items = ( ('ALL', 'All', 'All bones will be exported'), ('BONE_COLLECTIONS', 'Bone Collections', 'Only bones belonging to the selected bone collections and their ancestors will be exported') ) + +axis_identifiers = ('X', 'Y', 'Z', '-X', '-Y', '-Z') +forward_items = ( + ('X', 'X Forward', ''), + ('Y', 'Y Forward', ''), + ('Z', 'Z Forward', ''), + ('-X', '-X Forward', ''), + ('-Y', '-Y Forward', ''), + ('-Z', '-Z Forward', ''), +) + +up_items = ( + ('X', 'X Up', ''), + ('Y', 'Y Up', ''), + ('Z', 'Z Up', ''), + ('-X', '-X Up', ''), + ('-Y', '-Y Up', ''), + ('-Z', '-Z Up', ''), +) + + +def forward_axis_update(self, _context): + if self.forward_axis == self.up_axis: + # Automatically set the up axis to the next available axis + self.up_axis = next((axis for axis in axis_identifiers if axis != self.forward_axis), 'Z') + + +def up_axis_update(self, _context): + if self.up_axis == self.forward_axis: + # Automatically set the forward axis to the next available axis + self.forward_axis = next((axis for axis in axis_identifiers if axis != self.up_axis), 'X') + + +class ForwardUpAxisMixin: + forward_axis: EnumProperty( + name='Forward', + items=forward_items, + default='X', + update=forward_axis_update + ) + up_axis: EnumProperty( + name='Up', + items=up_items, + default='Z', + update=up_axis_update + ) + + +export_space_items = [ + ('WORLD', 'World', 'Export in world space'), + ('ARMATURE', 'Armature', 'Export in armature space'), +] + +class ExportSpaceMixin: + export_space: EnumProperty( + name='Export Space', + description='Space to export the mesh in', + 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) + )) diff --git a/io_scene_psk_psa/shared/helpers.py b/io_scene_psk_psa/shared/helpers.py index 9392def..520d75c 100644 --- a/io_scene_psk_psa/shared/helpers.py +++ b/io_scene_psk_psa/shared/helpers.py @@ -4,6 +4,9 @@ import bpy from bpy.props import CollectionProperty from bpy.types import AnimData, Object from bpy.types import Armature +from mathutils import Matrix + +from .data import get_coordinate_system_transform def rgb_to_srgb(c: float): @@ -208,3 +211,90 @@ class SemanticVersion(object): def __hash__(self): return hash((self.major, self.minor, self.patch)) + + +def convert_blender_bones_to_psx_bones( + bones: List[bpy.types.Bone], + bone_class: type, + export_space: str = 'WORLD', # perhaps export space should just be a transform matrix, since the below is not actually used unless we're using WORLD space. + armature_object_matrix_world: Matrix = Matrix.Identity(4), + scale = 1.0, + forward_axis: str = 'X', + up_axis: str = 'Z' +) -> Iterable[type]: + ''' + Function that converts a Blender bone list into a bone list that + @param bones: + @return: + ''' + scale_matrix = Matrix.Scale(scale, 4) + + coordinate_system_transform = get_coordinate_system_transform(forward_axis, up_axis) + coordinate_system_default_rotation = coordinate_system_transform.to_quaternion() + + psx_bones = [] + for bone in bones: + psx_bone = bone_class() + + try: + psx_bone.name = bytes(bone.name, encoding='windows-1252') + except UnicodeEncodeError: + raise RuntimeError( + f'Bone name "{bone.name}" contains characters that cannot be encoded in the Windows-1252 codepage') + + # TODO: flags & children_count should be initialized to zero anyways, so we can probably remove these lines? + psx_bone.flags = 0 + psx_bone.children_count = 0 + + try: + parent_index = bones.index(bone.parent) + psx_bone.parent_index = parent_index + psx_bones[parent_index].children_count += 1 + except ValueError: + psx_bone.parent_index = 0 + + if bone.parent is not None: + rotation = bone.matrix.to_quaternion().conjugated() + inverse_parent_rotation = bone.parent.matrix.to_quaternion().inverted() + parent_head = inverse_parent_rotation @ bone.parent.head + parent_tail = inverse_parent_rotation @ bone.parent.tail + location = (parent_tail - parent_head) + bone.head + else: + def get_armature_local_matrix(): + match export_space: + case 'WORLD': + return armature_object_matrix_world + case 'ARMATURE': + return Matrix.Identity(4) + case _: + raise ValueError(f'Invalid export space: {export_space}') + + armature_local_matrix = get_armature_local_matrix() + location = armature_local_matrix @ bone.head + location = coordinate_system_transform @ location + bone_rotation = bone.matrix.to_quaternion().conjugated() + local_rotation = armature_local_matrix.to_3x3().to_quaternion().conjugated() + rotation = bone_rotation @ local_rotation + rotation.conjugate() + rotation = coordinate_system_default_rotation @ rotation + + location = scale_matrix @ location + + # If the armature object has been scaled, we need to scale the bone's location to match. + _, _, armature_object_scale = armature_object_matrix_world.decompose() + location.x *= armature_object_scale.x + location.y *= armature_object_scale.y + location.z *= armature_object_scale.z + + psx_bone.location.x = location.x + psx_bone.location.y = location.y + psx_bone.location.z = location.z + + psx_bone.rotation.w = rotation.w + psx_bone.rotation.x = rotation.x + psx_bone.rotation.y = rotation.y + psx_bone.rotation.z = rotation.z + + psx_bones.append(psx_bone) + + return psx_bones