diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index dd0c8bd..95bd914 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -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,90 +35,95 @@ 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: - # 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) + # 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. + 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) + 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. - pose_bone_matrix = coordinate_system_transform @ 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 *= scale + # 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. - _, _, scale = evaluated_armature_object.matrix_world.decompose() - scale *= options.scale + 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() diff --git a/io_scene_psk_psa/psa/data.py b/io_scene_psk_psa/psa/data.py index b881ae6..5dd82cc 100644 --- a/io_scene_psk_psa/psa/data.py +++ b/io_scene_psk_psa/psa/data.py @@ -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] = [] diff --git a/io_scene_psk_psa/psa/export/operators.py b/io_scene_psk_psa/psa/export/operators.py index b621816..aa0b61a 100644 --- a/io_scene_psk_psa/psa/export/operators.py +++ b/io_scene_psk_psa/psa/export/operators.py @@ -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) diff --git a/io_scene_psk_psa/psa/export/properties.py b/io_scene_psk_psa/psa/export/properties.py index 340cda2..b454240 100644 --- a/io_scene_psk_psa/psa/export/properties.py +++ b/io_scene_psk_psa/psa/export/properties.py @@ -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]: diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index a4c7015..4855708 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -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 diff --git a/io_scene_psk_psa/psa/reader.py b/io_scene_psk_psa/psa/reader.py index 70cf212..c5ccf89 100644 --- a/io_scene_psk_psa/psa/reader.py +++ b/io_scene_psk_psa/psa/reader.py @@ -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) diff --git a/io_scene_psk_psa/psa/writer.py b/io_scene_psk_psa/psa/writer.py index a451108..05001cb 100644 --- a/io_scene_psk_psa/psa/writer.py +++ b/io_scene_psk_psa/psa/writer.py @@ -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) diff --git a/io_scene_psk_psa/psk/builder.py b/io_scene_psk_psa/psk/builder.py index 57aa3a4..4224d85 100644 --- a/io_scene_psk_psa/psk/builder.py +++ b/io_scene_psk_psa/psk/builder.py @@ -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') + psx_bone_create_result = create_psx_bones( + armature_objects=armature_objects, + export_space=options.export_space, + forward_axis=options.forward_axis, + up_axis=options.up_axis, + scale=options.scale, + root_bone_name=options.root_bone_name, + bone_filter_mode=options.bone_filter_mode, + bone_collection_indices=options.bone_collection_indices + ) - coordinate_system_matrix = get_coordinate_system_transform(options.forward_axis, options.up_axis) - coordinate_system_default_rotation = coordinate_system_matrix.to_quaternion() - - scale_matrix = Matrix.Scale(options.scale, 4) - - total_bone_count = sum(len(armature_object.data.bones) for armature_object in armature_objects) - - # Store the index of the root bone for each armature object. - # We will need this later to correctly assign vertex weights. - armature_object_root_bone_indices = dict() - - # Store the bone names to be exported for each armature object. - armature_object_bone_names: Dict[Object, List[str]] = dict() - for armature_object in armature_objects: - bone_collection_indices = [x[1] for x in options.bone_collection_indices if x[0] == armature_object.name] - bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, bone_collection_indices) - armature_object_bone_names[armature_object] = bone_names - - if len(armature_objects) == 0 or total_bone_count == 0: - # If the mesh has no armature object or no bones, simply assign it a dummy bone at the root to satisfy the - # requirement that a PSK file must have at least one bone. - psk_bone = Psk.Bone() - psk_bone.name = convert_string_to_cp1252_bytes(options.root_bone_name) - psk_bone.flags = 0 - psk_bone.children_count = 0 - psk_bone.parent_index = 0 - psk_bone.location = Vector3.zero() - psk_bone.rotation = Quaternion.from_bpy_quaternion(coordinate_system_default_rotation) - psk.bones.append(psk_bone) - - armature_object_root_bone_indices[None] = 0 - else: - # If we have multiple armature objects, create a root bone at the world origin. - if len(armature_objects) > 1: - psk_bone = Psk.Bone() - psk_bone.name = convert_string_to_cp1252_bytes(options.root_bone_name) - psk_bone.flags = 0 - psk_bone.children_count = total_bone_count - psk_bone.parent_index = 0 - psk_bone.location = Vector3.zero() - psk_bone.rotation = Quaternion.from_bpy_quaternion(coordinate_system_default_rotation) - psk.bones.append(psk_bone) - - armature_object_root_bone_indices[None] = 0 - - root_bone = psk.bones[0] if len(psk.bones) > 0 else None - - for armature_object in armature_objects: - bone_names = armature_object_bone_names[armature_object] - armature_data = typing.cast(Armature, armature_object.data) - bones = [armature_data.bones[bone_name] for bone_name in bone_names] - - psk_bones = convert_blender_bones_to_psx_bones( - bones=bones, - bone_class=Psk.Bone, - export_space=options.export_space, - 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.') + 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() diff --git a/io_scene_psk_psa/psk/data.py b/io_scene_psk_psa/psk/data.py index a060229..16e991c 100644 --- a/io_scene_psk_psa/psk/data.py +++ b/io_scene_psk_psa/psk/data.py @@ -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] = [] diff --git a/io_scene_psk_psa/psk/export/operators.py b/io_scene_psk_psa/psk/export/operators.py index 0725f37..f737c2d 100644 --- a/io_scene_psk_psa/psk/export/operators.py +++ b/io_scene_psk_psa/psk/export/operators.py @@ -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,15 +360,22 @@ 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') - 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_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') + 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() + col.operator(PSK_OT_material_list_name_add.bl_idname, text='', icon='ADD') # Transform transform_header, transform_panel = layout.panel('Transform', default_closed=False) @@ -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,17 +450,30 @@ 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: - 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) - col = row.column(align=True) - 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') + 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) + col = row.column(align=True) + 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) @@ -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, diff --git a/io_scene_psk_psa/psk/export/properties.py b/io_scene_psk_psa/psk/export/properties.py index 1ea55fe..588253d 100644 --- a/io_scene_psk_psa/psk/export/properties.py +++ b/io_scene_psk_psa/psk/export/properties.py @@ -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( diff --git a/io_scene_psk_psa/psk/export/ui.py b/io_scene_psk_psa/psk/export/ui.py index 2412d19..37645ad 100644 --- a/io_scene_psk_psa/psk/export/ui.py +++ b/io_scene_psk_psa/psk/export/ui.py @@ -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 = ( diff --git a/io_scene_psk_psa/psk/importer.py b/io_scene_psk_psa/psk/importer.py index 8b9bdbf..753d930 100644 --- a/io_scene_psk_psa/psk/importer.py +++ b/io_scene_psk_psa/psk/importer.py @@ -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() diff --git a/io_scene_psk_psa/psk/reader.py b/io_scene_psk_psa/psk/reader.py index 49864a6..7feb8fc 100644 --- a/io_scene_psk_psa/psk/reader.py +++ b/io_scene_psk_psa/psk/reader.py @@ -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': diff --git a/io_scene_psk_psa/psk/writer.py b/io_scene_psk_psa/psk/writer.py index 8bf7708..c49011e 100644 --- a/io_scene_psk_psa/psk/writer.py +++ b/io_scene_psk_psa/psk/writer.py @@ -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 diff --git a/io_scene_psk_psa/shared/data.py b/io_scene_psk_psa/shared/data.py index 7ec8d39..eabbae3 100644 --- a/io_scene_psk_psa/shared/data.py +++ b/io_scene_psk_psa/shared/data.py @@ -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) - )) diff --git a/io_scene_psk_psa/shared/helpers.py b/io_scene_psk_psa/shared/helpers.py index 2ba74bc..f6bd219 100644 --- a/io_scene_psk_psa/shared/helpers.py +++ b/io_scene_psk_psa/shared/helpers.py @@ -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) + ))