diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index 01a2ea4..dd0c8bd 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -1,5 +1,3 @@ -from typing import Optional - from bpy.types import Bone, Action, PoseBone from .data import * @@ -31,7 +29,6 @@ class PsaBuildOptions: self.bone_collection_indices: List[int] = [] self.sequence_name_prefix: str = '' self.sequence_name_suffix: str = '' - self.root_motion: bool = False self.scale = 1.0 self.sampling_mode: str = 'INTERPOLATED' # One of ('INTERPOLATED', 'SUBFRAME') self.export_space = 'WORLD' @@ -39,18 +36,29 @@ class PsaBuildOptions: self.up_axis = 'Z' -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]: +def _get_pose_bone_location_and_rotation( + pose_bone: PoseBone, + armature_object: Object, + export_space: str, + scale: Vector, + 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 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 + # 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 + case 'WORLD': + pose_bone_matrix = armature_object.matrix_world @ pose_bone.matrix + case 'ROOT': + pose_bone_matrix = Matrix.Identity(4) # 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. @@ -73,6 +81,8 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: 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)) @@ -94,17 +104,21 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: # 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, - armature_object_matrix_world=armature_object.matrix_world, + armature_object_matrix_world=armature_object.matrix_world, # evaluated_armature_object.matrix_world, scale=options.scale, forward_axis=options.forward_axis, up_axis=options.up_axis ) + # 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) + # Add prefixes and suffices to the names of the export sequences and strip whitespace. for export_sequence in options.sequences: export_sequence.name = f'{options.sequence_name_prefix}{export_sequence.name}{options.sequence_name_suffix}' @@ -124,7 +138,7 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: for export_sequence_index, export_sequence in enumerate(options.sequences): # Look up the pose bones for the bones that are going to be exported. - pose_bones = [(bone_names.index(bone.name), bone) for bone in export_sequence.armature_object.pose.bones] + pose_bones = [(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] @@ -178,6 +192,13 @@ 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. + + # TODO: extract the scale out of the evaluated_armature_object.matrix_world. + _, _, scale = evaluated_armature_object.matrix_world.decompose() + scale *= options.scale + match options.sampling_mode: case 'INTERPOLATED': # Used as a store for the last frame's pose bone locations and rotations. @@ -203,9 +224,9 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: for pose_bone in pose_bones: location, rotation = _get_pose_bone_location_and_rotation( pose_bone, - export_sequence.armature_object, - root_motion=options.root_motion, - scale=options.scale, + armature_object, + options.export_space, + scale, coordinate_system_transform=coordinate_system_transform ) last_frame_bone_poses.append((location, rotation)) @@ -225,9 +246,9 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: for pose_bone in pose_bones: location, rotation = _get_pose_bone_location_and_rotation( pose_bone, - export_sequence.armature_object, - root_motion=options.root_motion, - scale=options.scale, + armature_object, + options.export_space, + scale, coordinate_system_transform=coordinate_system_transform ) next_frame_bone_poses.append((location, rotation)) @@ -249,7 +270,13 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: context.scene.frame_set(frame=int(frame), subframe=frame % 1.0) for pose_bone in pose_bones: - location, rotation = _get_pose_bone_location_and_rotation(pose_bone, export_sequence.armature_object, options) + location, rotation = _get_pose_bone_location_and_rotation( + pose_bone, + armature_object, + options.export_space, + scale, + coordinate_system_transform=coordinate_system_transform + ) add_key(location, rotation) frame += frame_step diff --git a/io_scene_psk_psa/psa/export/operators.py b/io_scene_psk_psa/psa/export/operators.py index 19abf08..b621816 100644 --- a/io_scene_psk_psa/psa/export/operators.py +++ b/io_scene_psk_psa/psa/export/operators.py @@ -27,7 +27,7 @@ def get_sequences_propnames_from_source(sequence_source: str) -> Optional[Tuple[ case 'ACTIVE_ACTION': return 'active_action_list', 'active_action_list_index' case _: - raise ValueError(f'Unhandled sequence source: {sequence_source}') + assert False, f'Invalid sequence source: {sequence_source}' def is_action_for_object(obj: Object, action: Action): @@ -84,7 +84,6 @@ def update_actions_and_timeline_markers(context: Context): continue for (name, frame_start, frame_end) in get_sequences_from_action(action): - print(name) item = pg.action_list.add() item.action = action item.name = name @@ -151,18 +150,18 @@ def get_sequence_fps(context: Context, fps_source: str, fps_custom: float, actio # Get the minimum value of action metadata FPS values. return min([action.psa_export.fps for action in actions]) case _: - raise RuntimeError(f'Invalid FPS source "{fps_source}"') + assert False, f'Invalid FPS source: {fps_source}' def get_sequence_compression_ratio(compression_ratio_source: str, compression_ratio_custom: float, actions: Iterable[Action]) -> float: match compression_ratio_source: case 'ACTION_METADATA': # Get the minimum value of action metadata compression ratio values. - return min([action.psa_export.compression_ratio for action in actions]) + return min(map(lambda action: action.psa_export.compression_ratio, actions)) case 'CUSTOM': return compression_ratio_custom case _: - raise RuntimeError(f'Invalid compression ratio source "{compression_ratio_source}"') + assert False, f'Invalid compression ratio source: {compression_ratio_source}' def get_animation_data_object(context: Context) -> Object: @@ -389,7 +388,6 @@ class PSA_OT_export(Operator, ExportHelper): flow = transform_panel.grid_flow(row_major=True) flow.use_property_split = True flow.use_property_decorate = False - flow.prop(pg, 'root_motion') flow.prop(pg, 'export_space') flow.prop(pg, 'scale') flow.prop(pg, 'forward_axis') @@ -506,7 +504,7 @@ class PSA_OT_export(Operator, ExportHelper): export_sequence.key_quota = action.psa_export.key_quota export_sequences.append(export_sequence) case _: - raise ValueError(f'Unhandled sequence source: {pg.sequence_source}') + assert False, f'Invalid sequence source: {pg.sequence_source}' if len(export_sequences) == 0: self.report({'ERROR'}, 'No sequences were selected for export') @@ -516,10 +514,9 @@ class PSA_OT_export(Operator, ExportHelper): options.animation_data = animation_data options.sequences = export_sequences options.bone_filter_mode = pg.bone_filter_mode - options.bone_collection_indices = [x.index for x in pg.bone_collection_list if x.is_selected] + options.bone_collection_indices = [(x.armature_object_name, x.index) for x in pg.bone_collection_list if x.is_selected] options.sequence_name_prefix = pg.sequence_name_prefix options.sequence_name_suffix = pg.sequence_name_suffix - options.root_motion = pg.root_motion options.scale = pg.scale options.sampling_mode = pg.sampling_mode options.export_space = pg.export_space diff --git a/io_scene_psk_psa/psa/export/properties.py b/io_scene_psk_psa/psa/export/properties.py index e9a9823..340cda2 100644 --- a/io_scene_psk_psa/psa/export/properties.py +++ b/io_scene_psk_psa/psa/export/properties.py @@ -103,14 +103,6 @@ def animation_data_override_update_cb(self: 'PSA_PG_export', context: Context): class PSA_PG_export(PropertyGroup, ForwardUpAxisMixin, ExportSpaceMixin): - root_motion: BoolProperty( - name='Root Motion', - options=empty_set, - default=False, - description='When enabled, the root bone will be transformed as it appears in the scene.\n\n' - 'You might want to disable this if you are exporting an animation for an armature that is ' - 'attached to another object, such as a weapon or a shield', - ) should_override_animation_data: BoolProperty( name='Override Animation Data', options=empty_set, diff --git a/io_scene_psk_psa/psa/export/ui.py b/io_scene_psk_psa/psa/export/ui.py index 8e5d1f2..ab7544c 100644 --- a/io_scene_psk_psa/psa/export/ui.py +++ b/io_scene_psk_psa/psa/export/ui.py @@ -23,14 +23,14 @@ class PSA_UL_export_sequences(UIList): row = layout.row(align=True) row.alignment = 'RIGHT' - if item.frame_end < item.frame_start: - row.label(text='', icon='FRAME_PREV') - if is_pose_marker: - row.label(text=item.action.name, icon='PMARKER') + + row.label(text=str(abs(item.frame_end - item.frame_start) + 1), icon='FRAME_PREV' if item.frame_end < item.frame_start else 'KEYFRAME') if hasattr(item, 'armature_object') and item.armature_object is not None: row.label(text=item.armature_object.name, icon='ARMATURE_DATA') + # row.label(text=item.action.name, icon='PMARKER' if is_pose_marker else 'ACTION_DATA') + def draw_filter(self, context, layout): pg = getattr(context.scene, 'psa_export') row = layout.row() diff --git a/io_scene_psk_psa/psa/import_/properties.py b/io_scene_psk_psa/psa/import_/properties.py index dd3e81b..455553f 100644 --- a/io_scene_psk_psa/psa/import_/properties.py +++ b/io_scene_psk_psa/psa/import_/properties.py @@ -93,7 +93,7 @@ class PsaImportMixin: soft_max=60.0, step=100, ) - compression_ratio_source: EnumProperty(name='Compression Ratio Source', items=compression_ratio_source_items) + compression_ratio_source: EnumProperty(name='Compression Ratio Source', items=compression_ratio_source_items, default='ACTION') compression_ratio_custom: FloatProperty( default=1.0, name='Custom Compression Ratio', diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index fd3779c..a4c7015 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -260,7 +260,7 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, case 'SEQUENCE': target_fps = sequence.fps case _: - raise ValueError(f'Unknown FPS source: {options.fps_source}') + assert False, f'Invalid FPS source: {options.fps_source}' if options.should_write_keyframes: # Remove existing f-curves. diff --git a/io_scene_psk_psa/psk/builder.py b/io_scene_psk_psa/psk/builder.py index e7347e4..57aa3a4 100644 --- a/io_scene_psk_psa/psk/builder.py +++ b/io_scene_psk_psa/psk/builder.py @@ -1,32 +1,36 @@ import typing -from typing import Optional +from collections import Counter +from typing import Dict, Generator, Set, Iterable, Optional, cast import bmesh +import bpy import numpy as np -from bpy.types import Material, Collection, Context +from bpy.types import Material, Collection, Context, Object, Armature, Bone from .data import * 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 * +from ..shared.helpers import get_coordinate_system_transform, convert_string_to_cp1252_bytes, \ + get_export_bone_names, convert_blender_bones_to_psx_bones class PskInputObjects(object): def __init__(self): self.mesh_objects: List[DfsObject] = [] - self.armature_object: Optional[Object] = None + self.armature_objects: Set[Object] = set() class PskBuildOptions(object): def __init__(self): self.bone_filter_mode = 'ALL' - self.bone_collection_indices: List[int] = [] + self.bone_collection_indices: List[Tuple[str, int]] = [] self.object_eval_state = 'EVALUATED' self.materials: List[Material] = [] self.scale = 1.0 self.export_space = 'WORLD' self.forward_axis = 'X' self.up_axis = 'Z' + self.root_bone_name = 'ROOT' def get_mesh_objects_for_collection(collection: Collection) -> Iterable[DfsObject]: @@ -39,26 +43,24 @@ def get_mesh_objects_for_context(context: Context) -> Iterable[DfsObject]: yield dfs_object -def get_armature_for_mesh_objects(mesh_objects: Iterable[Object]) -> Optional[Object]: +def get_armature_for_mesh_object(mesh_object: Object) -> Optional[Object]: + for modifier in mesh_object.modifiers: + if modifier.type == 'ARMATURE': + return modifier.object + return None + + +def get_armatures_for_mesh_objects(mesh_objects: Iterable[Object]) -> Generator[Object, None, None]: # Ensure that there are either no armature modifiers (static mesh) or that there is exactly one armature modifier # object shared between all meshes. - armature_modifier_objects = set() + armature_objects = set() for mesh_object in mesh_objects: modifiers = [x for x in mesh_object.modifiers if x.type == 'ARMATURE'] if len(modifiers) == 0: continue - elif len(modifiers) > 1: - raise RuntimeError(f'Mesh "{mesh_object.name}" must have only one armature modifier') - armature_modifier_objects.add(modifiers[0].object) - - if len(armature_modifier_objects) > 1: - armature_modifier_names = [x.name for x in armature_modifier_objects] - raise RuntimeError( - f'All meshes must have the same armature modifier, encountered {len(armature_modifier_names)} ({", ".join(armature_modifier_names)})') - elif len(armature_modifier_objects) == 1: - return list(armature_modifier_objects)[0] - else: - return None + if modifiers[0].object in armature_objects: + continue + yield modifiers[0].object def _get_psk_input_objects(mesh_objects: Iterable[DfsObject]) -> PskInputObjects: @@ -68,7 +70,7 @@ def _get_psk_input_objects(mesh_objects: Iterable[DfsObject]) -> PskInputObjects input_objects = PskInputObjects() input_objects.mesh_objects = mesh_objects - input_objects.armature_object = get_armature_for_mesh_objects([x.obj for x in mesh_objects]) + input_objects.armature_objects |= set(get_armatures_for_mesh_objects(map(lambda x: x.obj, mesh_objects))) return input_objects @@ -91,68 +93,140 @@ class PskBuildResult(object): self.warnings: List[str] = [] +def _get_mesh_export_space_matrix(armature_objects: Iterable[Object], export_space: str) -> Matrix: + if not armature_objects: + return Matrix.Identity(4) + + def get_object_space_space_matrix(obj: Object) -> Matrix: + translation, rotation, _ = obj.matrix_world.decompose() + # We neutralize the scale here because the scale is already applied to the mesh objects implicitly. + return Matrix.Translation(translation) @ rotation.to_matrix().to_4x4() + + + match export_space: + case 'WORLD': + return Matrix.Identity(4) + case 'ARMATURE': + return get_object_space_space_matrix(armature_objects[0]).inverted() + case 'ROOT': + # TODO: multiply this by the root bone's local matrix + armature_object = armature_objects[0] + armature_data = cast(armature_object.data, Armature) + armature_space_matrix = get_object_space_space_matrix(armature_object) @ armature_data.bones[0].matrix_local + return armature_space_matrix.inverted() + case _: + assert False, f'Invalid export space: {export_space}' + + +def _get_material_name_indices(obj: Object, material_names: List[str]) -> Iterable[int]: + ''' + Returns the index of the material in the list of material names. + If the material is not found, the index 0 is returned. + ''' + for material_slot in obj.material_slots: + if material_slot.material is None: + yield 0 + else: + try: + yield material_names.index(material_slot.material.name) + except ValueError: + yield 0 + + def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) -> PskBuildResult: - armature_object: bpy.types.Object = input_objects.armature_object + armature_objects = list(input_objects.armature_objects) result = PskBuildResult() psk = Psk() - bones = [] + bones: List[Bone] = [] - def get_export_space_matrix(): - match options.export_space: - case 'WORLD': - return Matrix.Identity(4) - case 'ARMATURE': - if armature_object is not None: - return armature_object.matrix_world.inverted() - else: - return Matrix.Identity(4) - case _: - raise ValueError(f'Invalid export space: {options.export_space}') + 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() - export_space_matrix = get_export_space_matrix() # TODO: maybe neutralize the scale here? 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. + total_bone_count = sum(len(armature_object.data.bones) for armature_object in armature_objects) - if armature_object is None or len(armature_object.data.bones) == 0: + # 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 = bytes('root', encoding='windows-1252') + 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: - bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, options.bone_collection_indices) - armature_data = typing.cast(Armature, armature_object.data) - bones = [armature_data.bones[bone_name] for bone_name in bone_names] + # 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) - 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 - ) + armature_object_root_bone_indices[None] = 0 - # MATERIALS + 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, + 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, + ) + + # 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.') + + # Materials for material in options.materials: psk_material = Psk.Material() - try: - psk_material.name = bytes(material.name, encoding='windows-1252') - except UnicodeEncodeError: - raise RuntimeError(f'Material name "{material.name}" contains characters that cannot be encoded in the Windows-1252 codepage') + psk_material.name = convert_string_to_cp1252_bytes(material.name) psk_material.texture_index = len(psk.materials) psk_material.poly_flags = triangle_type_and_bit_flags_to_poly_flags(material.psk.mesh_triangle_type, material.psk.mesh_triangle_bit_flags) @@ -166,99 +240,90 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) if len(psk.materials) == 0: # Add a default material if no materials are present. psk_material = Psk.Material() - psk_material.name = bytes('None', encoding='windows-1252') + psk_material.name = convert_string_to_cp1252_bytes('None') psk.materials.append(psk_material) context.window_manager.progress_begin(0, len(input_objects.mesh_objects)) + mesh_export_space_matrix = _get_mesh_export_space_matrix(armature_objects, options.export_space) + 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} + + # Temporarily force the armature into the rest position. + # We will undo this later. + for armature_object in armature_objects: + armature_object.data.pose_position = 'REST' + 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 + armature_object = get_armature_for_mesh_object(obj) + should_flip_normals = False - def get_material_name_indices(obj: Object, material_names: List[str]) -> Iterable[int]: - ''' - Returns the index of the material in the list of material names. - If the material is not found, the index 0 is returned. - ''' - for material_slot in obj.material_slots: - if material_slot.material is None: - yield 0 - else: - try: - yield material_names.index(material_slot.material.name) - except ValueError: - yield 0 - - # MATERIALS - material_indices = list(get_material_name_indices(obj, material_names)) + # Material indices + material_indices = list(_get_material_name_indices(obj, material_names)) if len(material_indices) == 0: # Add a default material if no materials are present. material_indices = [0] - # MESH DATA + # Store the reference to the evaluated object and data so that we can clean them up later. + evaluated_mesh_object = None + evaluated_mesh_data = None + + # Mesh data match options.object_eval_state: case 'ORIGINAL': mesh_object = obj mesh_data = obj.data case 'EVALUATED': # Create a copy of the mesh object after non-armature modifiers are applied. - - # Temporarily force the armature into the rest position. - # We will undo this later. - old_pose_position = None - if armature_object is not None: - old_pose_position = armature_object.data.pose_position - armature_object.data.pose_position = 'REST' - depsgraph = context.evaluated_depsgraph_get() bm = bmesh.new() try: bm.from_object(obj, depsgraph) - except ValueError: + except ValueError as e: + del bm raise RuntimeError(f'Object "{obj.name}" is not evaluated.\n' - 'This is likely because the object is in a collection that has been excluded from the view layer.') + 'This is likely because the object is in a collection that has been excluded from the view layer.') from e - mesh_data = bpy.data.meshes.new('') + evaluated_mesh_data = bpy.data.meshes.new('') + mesh_data = evaluated_mesh_data bm.to_mesh(mesh_data) del bm - mesh_object = bpy.data.objects.new('', mesh_data) + evaluated_mesh_object = bpy.data.objects.new('', mesh_data) + mesh_object = evaluated_mesh_object mesh_object.matrix_world = matrix_world # Extract the scale from the matrix. _, _, scale = matrix_world.decompose() - # Negative scaling in Blender results in inverted normals after the scale is applied. However, if the scale - # is not applied, the normals will appear unaffected in the viewport. The evaluated mesh data used in the - # export will have the scale applied, but this behavior is not obvious to the user. + # Negative scaling in Blender results in inverted normals after the scale is applied. However, if the + # scale is not applied, the normals will appear unaffected in the viewport. The evaluated mesh data used + # in the export will have the scale applied, but this behavior is not obvious to the user. # - # In order to have the exporter be as WYSIWYG as possible, we need to check for negative scaling and invert - # the normals if necessary. If two axes have negative scaling and the third has positive scaling, the - # normals will be correct. We can detect this by checking if the number of negative scaling axes is odd. If - # it is, we need to invert the normals of the mesh by swapping the order of the vertices in each face. + # In order to have the exporter be as WYSIWYG as possible, we need to check for negative scaling and + # invert the normals if necessary. If two axes have negative scaling and the third has positive scaling, + # the normals will be correct. We can detect this by checking if the number of negative scaling axes is + # odd. If it is, we need to invert the normals of the mesh by swapping the order of the vertices in each + # face. should_flip_normals = sum(1 for x in scale if x < 0) % 2 == 1 # Copy the vertex groups for vertex_group in obj.vertex_groups: mesh_object.vertex_groups.new(name=vertex_group.name) - - # Restore the previous pose position on the armature. - if old_pose_position is not None: - armature_object.data.pose_position = old_pose_position case _: - raise ValueError(f'Invalid object evaluation state: {options.object_eval_state}') + assert False, f'Invalid object evaluation state: {options.object_eval_state}' vertex_offset = len(psk.points) point_transform_matrix = vertex_transform_matrix @ mesh_object.matrix_world - # VERTICES + # Vertices for vertex in mesh_data.vertices: point = Vector3() v = point_transform_matrix @ vertex.co @@ -269,7 +334,7 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) uv_layer = mesh_data.uv_layers.active.data - # WEDGES + # Wedges mesh_data.calc_loop_triangles() # Build a list of non-unique wedges. @@ -286,7 +351,7 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) for loop_index in triangle.loops: wedges[loop_index].material_index = material_indices[triangle.material_index] - # Populate the list of wedges with unique wedges & build a look-up table of loop indices to wedge indices + # Populate the list of wedges with unique wedges & build a look-up table of loop indices to wedge indices. wedge_indices = dict() loop_wedge_indices = np.full(len(mesh_data.loops), -1) for loop_index, wedge in enumerate(wedges): @@ -299,7 +364,7 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) psk.wedges.append(wedge) loop_wedge_indices[loop_index] = wedge_index - # FACES + # Faces poly_groups, groups = mesh_data.calc_smooth_groups(use_bitflags=True) psk_face_start_index = len(psk.faces) for f in mesh_data.loop_triangles: @@ -316,17 +381,19 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) for face in psk.faces[psk_face_start_index:]: face.wedge_indices[0], face.wedge_indices[2] = face.wedge_indices[2], face.wedge_indices[0] - # WEIGHTS + # 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] # 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 = [x.name for x in bones] + + bone_names = armature_object_bone_names[armature_object] vertex_group_names = [x.name for x in mesh_object.vertex_groups] - vertex_group_bone_indices = dict() + vertex_group_bone_indices: Dict[int, int] = dict() for vertex_group_index, vertex_group_name in enumerate(vertex_group_names): try: - vertex_group_bone_indices[vertex_group_index] = bone_names.index(vertex_group_name) + vertex_group_bone_indices[vertex_group_index] = bone_names.index(vertex_group_name) + bone_index_offset except ValueError: # The vertex group does not have a matching bone in the list of bones to be exported. # Check to see if there is an associated bone for this vertex group that exists in the armature. @@ -336,8 +403,7 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) bone = armature_data.bones[vertex_group_name] while bone is not None: try: - bone_index = bone_names.index(bone.name) - vertex_group_bone_indices[vertex_group_index] = bone_index + vertex_group_bone_indices[vertex_group_index] = bone_names.index(bone.name) + bone_index_offset break except ValueError: bone = bone.parent @@ -366,22 +432,30 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) psk.weights.append(w) vertices_assigned_weights[vertex_index] = True - # Assign vertices that have not been assigned weights to the root bone. + # 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] for vertex_index, assigned in enumerate(vertices_assigned_weights): if not assigned: w = Psk.Weight() - w.bone_index = 0 + w.bone_index = fallback_weight_bone_index w.point_index = vertex_offset + vertex_index w.weight = 1.0 psk.weights.append(w) - if options.object_eval_state == 'EVALUATED': + if evaluated_mesh_object is not None: bpy.data.objects.remove(mesh_object) + del mesh_object + + if evaluated_mesh_data is not None: bpy.data.meshes.remove(mesh_data) del mesh_data context.window_manager.progress_update(object_index) + # Restore the original pose position of the armature objects. + for armature_object, pose_position in original_armature_object_pose_positions.items(): + armature_object.data.pose_position = pose_position + context.window_manager.progress_end() result.psk = psk diff --git a/io_scene_psk_psa/psk/export/operators.py b/io_scene_psk_psa/psk/export/operators.py index aa31f63..0725f37 100644 --- a/io_scene_psk_psa/psk/export/operators.py +++ b/io_scene_psk_psa/psk/export/operators.py @@ -1,3 +1,4 @@ +from pathlib import Path from typing import List, Optional, cast, Iterable import bpy @@ -75,10 +76,10 @@ class PSK_OT_populate_bone_collection_list(Operator): except RuntimeError as e: self.report({'ERROR_INVALID_CONTEXT'}, str(e)) return {'CANCELLED'} - if input_objects.armature_object is None: - self.report({'ERROR_INVALID_CONTEXT'}, 'No armature found in collection') + if not input_objects.armature_objects: + self.report({'ERROR_INVALID_CONTEXT'}, 'No armature modifiers found on mesh objects') return {'CANCELLED'} - populate_bone_collection_list(input_objects.armature_object, export_operator.bone_collection_list) + populate_bone_collection_list(input_objects.armature_objects, export_operator.bone_collection_list) return {'FINISHED'} @@ -236,10 +237,11 @@ def get_psk_build_options_from_property_group(pg: 'PSK_PG_export') -> PskBuildOp options.object_eval_state = pg.object_eval_state options.export_space = pg.export_space options.bone_filter_mode = pg.bone_filter_mode - options.bone_collection_indices = [x.index for x in pg.bone_collection_list if x.is_selected] + options.bone_collection_indices = [(x.armature_object_name, x.index) for x in pg.bone_collection_list if x.is_selected] options.scale = pg.scale 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) @@ -276,12 +278,13 @@ class PSK_OT_export_collection(Operator, ExportHelper, PskExportMixin): return {'CANCELLED'} options = get_psk_build_options_from_property_group(self) + filepath = str(Path(self.filepath).resolve()) try: result = build_psk(context, input_objects, options) for warning in result.warnings: self.report({'WARNING'}, warning) - write_psk(result.psk, self.filepath) + write_psk(result.psk, filepath) if len(result.warnings) > 0: self.report({'WARNING'}, f'PSK export successful with {len(result.warnings)} warnings') else: @@ -299,7 +302,7 @@ class PSK_OT_export_collection(Operator, ExportHelper, PskExportMixin): flow.use_property_split = True flow.use_property_decorate = False - # MESH + # Mesh mesh_header, mesh_panel = layout.panel('Mesh', default_closed=False) mesh_header.label(text='Mesh', icon='MESH_DATA') if mesh_panel: @@ -309,17 +312,26 @@ class PSK_OT_export_collection(Operator, ExportHelper, PskExportMixin): flow.prop(self, 'object_eval_state', text='Data') flow.prop(self, 'should_exclude_hidden_meshes') - # BONES + # Bones bones_header, bones_panel = layout.panel('Bones', default_closed=False) bones_header.label(text='Bones', icon='BONE_DATA') if bones_panel: 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') 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) - # MATERIALS + advanced_bones_header, advanced_bones_panel = bones_panel.panel('Advanced', default_closed=True) + advanced_bones_header.label(text='Advanced') + if advanced_bones_panel: + flow = advanced_bones_panel.grid_flow(row_major=True) + flow.use_property_split = True + flow.use_property_decorate = False + flow.prop(self, 'root_bone_name') + + # Materials materials_header, materials_panel = layout.panel('Materials', default_closed=False) materials_header.label(text='Materials', icon='MATERIAL') @@ -334,7 +346,7 @@ class PSK_OT_export_collection(Operator, ExportHelper, PskExportMixin): col.separator() col.operator(PSK_OT_material_list_name_add.bl_idname, text='', icon='ADD') - # TRANSFORM + # Transform transform_header, transform_panel = layout.panel('Transform', default_closed=False) transform_header.label(text='Transform') if transform_panel: @@ -368,13 +380,9 @@ class PSK_OT_export(Operator, ExportHelper): self.report({'ERROR_INVALID_CONTEXT'}, str(e)) return {'CANCELLED'} - if len(input_objects.mesh_objects) == 0: - self.report({'ERROR_INVALID_CONTEXT'}, 'No mesh objects selected') - return {'CANCELLED'} - pg = getattr(context.scene, 'psk_export') - populate_bone_collection_list(input_objects.armature_object, pg.bone_collection_list) + populate_bone_collection_list(input_objects.armature_objects, pg.bone_collection_list) depsgraph = context.evaluated_depsgraph_get() @@ -393,7 +401,7 @@ class PSK_OT_export(Operator, ExportHelper): pg = getattr(context.scene, 'psk_export') - # MESH + # Mesh mesh_header, mesh_panel = layout.panel('Mesh', default_closed=False) mesh_header.label(text='Mesh', icon='MESH_DATA') if mesh_panel: @@ -402,7 +410,7 @@ class PSK_OT_export(Operator, ExportHelper): flow.use_property_decorate = False flow.prop(pg, 'object_eval_state', text='Data') - # BONES + # Bones bones_header, bones_panel = layout.panel('Bones', default_closed=False) bones_header.label(text='Bones', icon='BONE_DATA') if bones_panel: @@ -412,7 +420,7 @@ class PSK_OT_export(Operator, ExportHelper): 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) - # MATERIALS + # Materials materials_header, materials_panel = layout.panel('Materials', default_closed=False) materials_header.label(text='Materials', icon='MATERIAL') if materials_panel: @@ -423,6 +431,18 @@ class PSK_OT_export(Operator, ExportHelper): col.operator(PSK_OT_material_list_move_up.bl_idname, text='', icon='TRIA_UP') col.operator(PSK_OT_material_list_move_down.bl_idname, text='', icon='TRIA_DOWN') + # Transform + transform_header, transform_panel = layout.panel('Transform', default_closed=False) + transform_header.label(text='Transform') + if transform_panel: + flow = transform_panel.grid_flow(row_major=True) + flow.use_property_split = True + flow.use_property_decorate = False + flow.prop(pg, 'export_space') + flow.prop(pg, 'scale') + flow.prop(pg, 'forward_axis') + flow.prop(pg, 'up_axis') + def execute(self, context): pg = getattr(context.scene, 'psk_export') diff --git a/io_scene_psk_psa/psk/export/properties.py b/io_scene_psk_psa/psk/export/properties.py index 03f108d..1ea55fe 100644 --- a/io_scene_psk_psa/psk/export/properties.py +++ b/io_scene_psk_psa/psk/export/properties.py @@ -12,11 +12,6 @@ object_eval_state_items = ( ('ORIGINAL', 'Original', 'Use data from original object with no modifiers applied'), ) -export_space_items = [ - ('WORLD', 'World', 'Export in world space'), - ('ARMATURE', 'Armature', 'Export in armature space'), -] - class PSK_PG_material_list_item(PropertyGroup): material: PointerProperty(type=Material) index: IntProperty() @@ -54,6 +49,11 @@ class PskExportMixin(ExportSpaceMixin, ForwardUpAxisMixin): bone_collection_list_index: IntProperty(default=0) material_name_list: CollectionProperty(type=PSK_PG_material_name_list_item) material_name_list_index: IntProperty(default=0) + root_bone_name: StringProperty( + name='Root Bone Name', + description='The name of the generated root bone when exporting multiple armatures', + default='ROOT', + ) class PSK_PG_export(PropertyGroup, PskExportMixin): diff --git a/io_scene_psk_psa/psk/writer.py b/io_scene_psk_psa/psk/writer.py index 315e0d1..8bf7708 100644 --- a/io_scene_psk_psa/psk/writer.py +++ b/io_scene_psk_psa/psk/writer.py @@ -32,27 +32,30 @@ def write_psk(psk: Psk, path: str): raise RuntimeError(f'Number of materials ({len(psk.materials)}) exceeds limit of {MAX_MATERIAL_COUNT}') if len(psk.bones) > MAX_BONE_COUNT: raise RuntimeError(f'Number of bones ({len(psk.bones)}) exceeds limit of {MAX_BONE_COUNT}') - elif len(psk.bones) == 0: + if len(psk.bones) == 0: raise RuntimeError(f'At least one bone must be marked for export') # Make the directory for the file if it doesn't exist. os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, 'wb') as fp: - _write_section(fp, b'ACTRHEAD') - _write_section(fp, b'PNTS0000', Vector3, psk.points) + try: + with open(path, 'wb') as fp: + _write_section(fp, b'ACTRHEAD') + _write_section(fp, b'PNTS0000', Vector3, psk.points) - wedges = [] - for index, w in enumerate(psk.wedges): - wedge = Psk.Wedge16() - wedge.material_index = w.material_index - wedge.u = w.u - wedge.v = w.v - wedge.point_index = w.point_index - wedges.append(wedge) + wedges = [] + for index, w in enumerate(psk.wedges): + wedge = Psk.Wedge16() + wedge.material_index = w.material_index + wedge.u = w.u + wedge.v = w.v + wedge.point_index = w.point_index + wedges.append(wedge) - _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'RAWWEIGHTS', Psk.Weight, psk.weights) + _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'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 diff --git a/io_scene_psk_psa/shared/data.py b/io_scene_psk_psa/shared/data.py index ab0ccc2..7ec8d39 100644 --- a/io_scene_psk_psa/shared/data.py +++ b/io_scene_psk_psa/shared/data.py @@ -161,13 +161,13 @@ class ForwardUpAxisMixin: export_space_items = [ ('WORLD', 'World', 'Export in world space'), - ('ARMATURE', 'Armature', 'Export in armature space'), + ('ARMATURE', 'Armature', 'Export the local space of the armature object'), + ('ROOT', 'Root', 'Export in the space of the root bone') ] class ExportSpaceMixin: export_space: EnumProperty( name='Export Space', - description='Space to export the mesh in', items=export_space_items, default='WORLD' ) diff --git a/io_scene_psk_psa/shared/helpers.py b/io_scene_psk_psa/shared/helpers.py index 7d20d40..2ba74bc 100644 --- a/io_scene_psk_psa/shared/helpers.py +++ b/io_scene_psk_psa/shared/helpers.py @@ -29,7 +29,7 @@ def get_nla_strips_in_frame_range(animation_data: AnimData, frame_min: float, fr yield strip -def populate_bone_collection_list(armature_object: Object, bone_collection_list: CollectionProperty) -> None: +def populate_bone_collection_list(armature_objects: Iterable[Object], bone_collection_list: CollectionProperty) -> None: """ Updates the bone collections collection. @@ -39,7 +39,7 @@ def populate_bone_collection_list(armature_object: Object, bone_collection_list: has_selected_collections = any([g.is_selected for g in bone_collection_list]) unassigned_collection_is_selected, selected_assigned_collection_names = True, [] - if armature_object is None: + if not armature_objects: return if has_selected_collections: @@ -54,24 +54,27 @@ def populate_bone_collection_list(armature_object: Object, bone_collection_list: bone_collection_list.clear() - armature = cast(Armature, armature_object.data) + for armature_object in armature_objects: + armature = cast(Armature, armature_object.data) - if armature is None: - return + if armature is None: + return - item = bone_collection_list.add() - item.name = 'Unassigned' - item.index = -1 - # Count the number of bones without an assigned bone collection - item.count = sum(map(lambda bone: 1 if len(bone.collections) == 0 else 0, armature.bones)) - item.is_selected = unassigned_collection_is_selected - - for bone_collection_index, bone_collection in enumerate(armature.collections_all): item = bone_collection_list.add() - item.name = bone_collection.name - item.index = bone_collection_index - item.count = len(bone_collection.bones) - item.is_selected = bone_collection.name in selected_assigned_collection_names if has_selected_collections else True + item.armature_object_name = armature_object.name + item.name = 'Unassigned' # TODO: localize + item.index = -1 + # Count the number of bones without an assigned bone collection + item.count = sum(map(lambda bone: 1 if len(bone.collections) == 0 else 0, armature.bones)) + item.is_selected = unassigned_collection_is_selected + + for bone_collection_index, bone_collection in enumerate(armature.collections_all): + item = bone_collection_list.add() + item.armature_object_name = armature_object.name + item.name = bone_collection.name + item.index = bone_collection_index + item.count = len(bone_collection.bones) + item.is_selected = bone_collection.name in selected_assigned_collection_names if has_selected_collections else True def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_collection_indices: Iterable[int]) -> List[str]: @@ -131,6 +134,7 @@ def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_c bone_names = [bones[x[0]].name for x in bone_indices] # Ensure that the hierarchy we are sending back has a single root bone. + # TODO: This is only relevant if we are exporting a single armature; how should we reorganize this call? bone_indices = [x[0] for x in bone_indices] root_bones = [bones[bone_index] for bone_index in bone_indices if bones[bone_index].parent is None] if len(root_bones) > 1: @@ -159,20 +163,25 @@ def is_bdk_addon_loaded() -> bool: return 'bdk' in dir(bpy.ops) +def convert_string_to_cp1252_bytes(string: str) -> bytes: + try: + return bytes(string, encoding='windows-1252') + except UnicodeEncodeError as e: + raise RuntimeError(f'The string "{string}" contains characters that cannot be encoded in the Windows-1252 codepage') from e + + +# 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: List[bpy.types.Bone], + bones: Iterable[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. + export_space: str = 'WORLD', 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: - ''' + up_axis: str = 'Z', + root_bone: Optional = None, +) -> Iterable: + scale_matrix = Matrix.Scale(scale, 4) coordinate_system_transform = get_coordinate_system_transform(forward_axis, up_axis) @@ -181,16 +190,7 @@ def convert_blender_bones_to_psx_bones( 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 + psx_bone.name = convert_string_to_cp1252_bytes(bone.name) try: parent_index = bones.index(bone.parent) @@ -205,6 +205,22 @@ def convert_blender_bones_to_psx_bones( parent_head = inverse_parent_rotation @ bone.parent.head parent_tail = inverse_parent_rotation @ bone.parent.tail location = (parent_tail - parent_head) + bone.head + elif bone.parent is None and root_bone is not None: + # This is a special case for the root bone when export + # Because the root bone and child bones are in different spaces, we need to treat the root bone of this + # armature as though it were a child bone. + bone_rotation = bone.matrix.to_quaternion().conjugated() + local_rotation = armature_object_matrix_world.to_3x3().to_quaternion().conjugated() + rotation = bone_rotation @ local_rotation + translation, _, scale = armature_object_matrix_world.decompose() + # Invert the scale of the armature object matrix. + inverse_scale_matrix = Matrix.Identity(4) + inverse_scale_matrix[0][0] = 1.0 / scale.x + inverse_scale_matrix[1][1] = 1.0 / scale.y + inverse_scale_matrix[2][2] = 1.0 / scale.z + + translation = translation @ inverse_scale_matrix + location = translation + bone.head else: def get_armature_local_matrix(): match export_space: @@ -212,8 +228,10 @@ def convert_blender_bones_to_psx_bones( return armature_object_matrix_world case 'ARMATURE': return Matrix.Identity(4) + case 'ROOT': + return bone.matrix.inverted() case _: - raise ValueError(f'Invalid export space: {export_space}') + assert False, f'Invalid export space: {export_space}' armature_local_matrix = get_armature_local_matrix() location = armature_local_matrix @ bone.head @@ -244,3 +262,23 @@ def convert_blender_bones_to_psx_bones( psx_bones.append(psx_bone) return psx_bones + + +# TODO: we need two different ones for the PSK and PSA. +# TODO: Figure out in what "space" the root bone is in for PSA animations. +# Maybe make a set of space-switching functions to make this easier to follow and figure out. +def get_export_space_matrix(export_space: str, armature_object: Optional[Object] = None) -> Matrix: + match export_space: + case 'WORLD': + return Matrix.Identity(4) + case 'ARMATURE': + # We do not care about the scale when dealing with export spaces, only the translation and rotation. + if armature_object is not None: + translation, rotation, _ = armature_object.matrix_world.decompose() + return (rotation.to_matrix().to_4x4() @ Matrix.Translation(translation)).inverted() + else: + return Matrix.Identity(4) + case 'ROOT': + pass + case _: + assert False, f'Invalid export space: {export_space}' diff --git a/io_scene_psk_psa/shared/types.py b/io_scene_psk_psa/shared/types.py index 4f13902..77a5fa9 100644 --- a/io_scene_psk_psa/shared/types.py +++ b/io_scene_psk_psa/shared/types.py @@ -1,3 +1,4 @@ +import bpy from bpy.props import StringProperty, IntProperty, BoolProperty, FloatProperty from bpy.types import PropertyGroup, UIList, UILayout, Context, AnyType, Panel @@ -7,11 +8,19 @@ class PSX_UL_bone_collection_list(UIList): def draw_item(self, context: Context, layout: UILayout, data: AnyType, item: AnyType, icon: int, active_data: AnyType, active_property: str, index: int = 0, flt_flag: int = 0): row = layout.row() + row.prop(item, 'is_selected', text=getattr(item, 'name')) row.label(text=str(getattr(item, 'count')), icon='BONE_DATA') + armature_object = bpy.data.objects.get(item.armature_object_name, None) + if armature_object is None: + row.label(icon='ERROR') + else: + row.label(text=armature_object.name, icon='ARMATURE_DATA') + class PSX_PG_bone_collection_list_item(PropertyGroup): + armature_object_name: StringProperty() name: StringProperty() index: IntProperty() count: IntProperty()