From 5e7c2535e225334074cf7f1ca1ab1af0f5e889a4 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Sat, 12 Feb 2022 01:45:19 -0800 Subject: [PATCH] * Unified how bones are filtered for export based on export settings (bone filter mode + bone groups) * Bone Group filtering now works properly for PSK export * Fixed a number of bugs that broke animation export --- io_scene_psk_psa/helpers.py | 57 ++++++++++++++++++++------ io_scene_psk_psa/psa/builder.py | 44 ++++++++------------- io_scene_psk_psa/psa/exporter.py | 68 ++++++++++++++++++++++++++------ io_scene_psk_psa/psk/builder.py | 16 +------- io_scene_psk_psa/psk/exporter.py | 1 + 5 files changed, 123 insertions(+), 63 deletions(-) diff --git a/io_scene_psk_psa/helpers.py b/io_scene_psk_psa/helpers.py index 6164a30..e2af18b 100644 --- a/io_scene_psk_psa/helpers.py +++ b/io_scene_psk_psa/helpers.py @@ -1,5 +1,5 @@ from bpy.types import NlaStrip -from typing import List +from typing import List, Tuple, Optional from collections import Counter @@ -59,13 +59,14 @@ def get_psa_sequence_name(action, should_use_original_sequence_name): return action.name -def get_export_bone_indices_for_bone_groups(armature_object, bone_group_indices: List[int]) -> List[int]: +def get_export_bone_names(armature_object, bone_filter_mode, bone_group_indices: List[int]) -> List[str]: """ - Returns a sorted list of bone indices that should be exported for the given bone groups. + Returns a sorted list of bone indices that should be exported for the given bone filter mode and bone groups. Note that the ancestors of bones within the bone groups will also be present in the returned list. :param armature_object: Blender object with type 'ARMATURE' + :param bone_filter_mode: One of ['ALL', 'BONE_GROUPS'] :param bone_group_indices: List of bone group indices to be exported. :return: A sorted list of bone indices that should be exported. """ @@ -76,24 +77,58 @@ def get_export_bone_indices_for_bone_groups(armature_object, bone_group_indices: pose_bones = armature_object.pose.bones bone_names = [x.name for x in bones] - # Get a list of the bone indices that are explicitly part of the bone groups we are including. + # Get a list of the bone indices that we are explicitly including. bone_index_stack = [] is_exporting_none_bone_groups = -1 in bone_group_indices for bone_index, pose_bone in enumerate(pose_bones): - if (pose_bone.bone_group is None and is_exporting_none_bone_groups) or \ + if bone_filter_mode == 'ALL' or \ + (pose_bone.bone_group is None and is_exporting_none_bone_groups) or \ (pose_bone.bone_group is not None and pose_bone.bone_group_index in bone_group_indices): - bone_index_stack.append(bone_index) + bone_index_stack.append((bone_index, None)) # For each bone that is explicitly being added, recursively walk up the hierarchy and ensure that all of # those ancestor bone indices are also in the list. - bone_indices = set() + bone_indices = dict() while len(bone_index_stack) > 0: - bone_index = bone_index_stack.pop() + bone_index, instigator_bone_index = bone_index_stack.pop() bone = bones[bone_index] if bone.parent is not None: parent_bone_index = bone_names.index(bone.parent.name) if parent_bone_index not in bone_indices: - bone_index_stack.append(parent_bone_index) - bone_indices.add(bone_index) + bone_index_stack.append((parent_bone_index, bone_index)) + bone_indices[bone_index] = instigator_bone_index - return list(sorted(list(bone_indices))) + # Sort the bone index list in-place. + bone_indices = [(x[0], x[1]) for x in bone_indices.items()] + bone_indices.sort(key=lambda x: x[0]) + + # Split out the bone indices and the instigator bone names into separate lists. + # We use the bone names for the return values because the bone name is a more universal way of referencing them. + # For example, users of this function may modify bone lists, which would invalidate the indices and require a + # index mapping scheme to resolve it. Using strings is more comfy and results in less code downstream. + instigator_bone_names = [bones[x[1]].name if x[1] is not None else None for x in bone_indices] + bone_names = [bones[x[0]].name for x in bone_indices] + + # Ensure that the hierarchy we are sending back has a single root bone. + 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: + # There is more than one root bone. + # Print out why each root bone was included by linking it to one of the explicitly included bones. + root_bone_names = [bone.name for bone in root_bones] + for root_bone_name in root_bone_names: + bone_name = root_bone_name + while True: + # Traverse the instigator chain until the end to find the true instigator bone. + # TODO: in future, it would be preferential to have a readout of *all* instigator bones. + instigator_bone_name = instigator_bone_names[bone_names.index(bone_name)] + if instigator_bone_name is None: + print(f'Root bone "{root_bone_name}" was included because {bone_name} was marked for export') + break + bone_name = instigator_bone_name + + raise RuntimeError('Exported bone hierarchy must have a single root bone.\n' + f'The bone hierarchy marked for export has {len(root_bones)} root bones: {root_bone_names}.\n' + f'Additional debugging information has been written to the console.') + + return bone_names diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index 74da670..20a5f6f 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -44,55 +44,45 @@ class PsaBuilder(object): # armature bones. bone_names = [x.name for x in bones] pose_bones = [(bone_names.index(bone.name), bone) for bone in armature.pose.bones] - del bone_names pose_bones.sort(key=lambda x: x[0]) pose_bones = [x[1] for x in pose_bones] - bone_indices = list(range(len(bones))) - - # If bone groups are specified, get only the bones that are in that specified bone groups and their ancestors. - if options.bone_filter_mode == 'BONE_GROUPS': - bone_indices = get_export_bone_indices_for_bone_groups(armature, options.bone_group_indices) + # Get a list of all the bone indices and instigator bones for the bone filter settings. + bone_names = get_export_bone_names(armature, options.bone_filter_mode, options.bone_group_indices) + bone_indices = [bone_names.index(x) for x in 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] pose_bones = [pose_bones[bone_index] for bone_index in bone_indices] + # No bones are going to be exported. if len(bones) == 0: - # No bones are going to be exported. raise RuntimeError('No bones available for export') - # Ensure that the exported hierarchy has a single root bone. - root_bones = [x for x in bones if x.parent is None] - if len(root_bones) > 1: - root_bone_names = [x.name for x in root_bones] - raise RuntimeError('Exported bone hierarchy must have a single root bone.' - f'The bone hierarchy marked for export has {len(root_bones)} root bones: {root_bone_names}') - # Build list of PSA bones. - for pose_bone in bones: + for bone in bones: psa_bone = Psa.Bone() - psa_bone.name = bytes(pose_bone.name, encoding='utf-8') + psa_bone.name = bytes(bone.name, encoding='utf-8') try: - parent_index = bones.index(pose_bone.parent) + parent_index = bones.index(bone.parent) psa_bone.parent_index = parent_index psa.bones[parent_index].children_count += 1 except ValueError: psa_bone.parent_index = -1 - if pose_bone.parent is not None: - rotation = pose_bone.matrix.to_quaternion() + if bone.parent is not None: + rotation = bone.matrix.to_quaternion() rotation.x = -rotation.x rotation.y = -rotation.y rotation.z = -rotation.z - quat_parent = pose_bone.parent.matrix.to_quaternion().inverted() - parent_head = quat_parent @ pose_bone.parent.head - parent_tail = quat_parent @ pose_bone.parent.tail - location = (parent_tail - parent_head) + pose_bone.head + quat_parent = bone.parent.matrix.to_quaternion().inverted() + parent_head = quat_parent @ bone.parent.head + parent_tail = quat_parent @ bone.parent.tail + location = (parent_tail - parent_head) + bone.head else: - location = armature.matrix_local @ pose_bone.head - rot_matrix = pose_bone.matrix @ armature.matrix_local.to_3x3() + location = armature.matrix_local @ bone.head + rot_matrix = bone.matrix @ armature.matrix_local.to_3x3() rotation = rot_matrix.to_quaternion() psa_bone.location.x = location.x @@ -195,8 +185,8 @@ class PsaBuilder(object): psa.keys.append(key) - export_sequence.bone_count = len(pose_bones) - export_sequence.track_time = frame_count + psa_sequence.bone_count = len(pose_bones) + psa_sequence.track_time = frame_count # Restore the mute state of the NLA strips we muted beforehand. for nla_strip, mute in nla_strip_mute_statuses.items(): diff --git a/io_scene_psk_psa/psa/exporter.py b/io_scene_psk_psa/psa/exporter.py index 770df29..e03bb4a 100644 --- a/io_scene_psk_psa/psa/exporter.py +++ b/io_scene_psk_psa/psa/exporter.py @@ -129,8 +129,8 @@ class PsaExportOperator(Operator, ExportHelper): layout.label(text='Actions', icon='ACTION') row = layout.row(align=True) row.label(text='Select') - row.operator(PsaExportSelectAll.bl_idname, text='All') - row.operator(PsaExportDeselectAll.bl_idname, text='None') + row.operator(PsaExportActionsSelectAll.bl_idname, text='All') + row.operator(PsaExportActionsDeselectAll.bl_idname, text='None') row = layout.row() rows = max(3, min(len(pg.action_list), 10)) row.template_list('PSA_UL_ExportActionList', '', pg, 'action_list', pg, 'action_list_index', rows=rows) @@ -172,6 +172,10 @@ class PsaExportOperator(Operator, ExportHelper): if pg.bone_filter_mode == 'BONE_GROUPS': rows = max(3, min(len(pg.bone_group_list), 10)) layout.template_list('PSX_UL_BoneGroupList', '', pg, 'bone_group_list', pg, 'bone_group_list_index', rows=rows) + row = layout.row(align=True) + row.label(text='Select') + row.operator(PsaExportBoneGroupsSelectAll.bl_idname, text='All') + row.operator(PsaExportBoneGroupsDeselectAll.bl_idname, text='None') def is_action_for_armature(self, action): if len(action.fcurves) == 0: @@ -297,7 +301,7 @@ class PSA_UL_ExportActionList(UIList): return flt_flags, flt_neworder -class PsaExportSelectAll(Operator): +class PsaExportActionsSelectAll(Operator): bl_idname = 'psa_export.actions_select_all' bl_label = 'Select All' bl_description = 'Select all actions' @@ -307,17 +311,17 @@ class PsaExportSelectAll(Operator): def poll(cls, context): pg = context.scene.psa_export item_list = pg.action_list - has_unselected_actions = any(map(lambda action: not action.is_selected, item_list)) - return len(item_list) > 0 and has_unselected_actions + has_unselected_items = any(map(lambda item: not item.is_selected, item_list)) + return len(item_list) > 0 and has_unselected_items def execute(self, context): pg = context.scene.psa_export - for action in pg.action_list: - action.is_selected = True + for item in pg.action_list: + item.is_selected = True return {'FINISHED'} -class PsaExportDeselectAll(Operator): +class PsaExportActionsDeselectAll(Operator): bl_idname = 'psa_export.actions_deselect_all' bl_label = 'Deselect All' bl_description = 'Deselect all actions' @@ -327,12 +331,52 @@ class PsaExportDeselectAll(Operator): def poll(cls, context): pg = context.scene.psa_export item_list = pg.action_list + has_selected_items = any(map(lambda item: item.is_selected, item_list)) + return len(item_list) > 0 and has_selected_items + + def execute(self, context): + pg = context.scene.psa_export + for item in pg.action_list: + item.is_selected = False + return {'FINISHED'} + + +class PsaExportBoneGroupsSelectAll(Operator): + bl_idname = 'psa_export.bone_groups_select_all' + bl_label = 'Select All' + bl_description = 'Select all bone groups' + bl_options = {'INTERNAL'} + + @classmethod + def poll(cls, context): + pg = context.scene.psa_export + item_list = pg.bone_group_list + has_unselected_items = any(map(lambda action: not action.is_selected, item_list)) + return len(item_list) > 0 and has_unselected_items + + def execute(self, context): + pg = context.scene.psa_export + for item in pg.bone_group_list: + item.is_selected = True + return {'FINISHED'} + + +class PsaExportBoneGroupsDeselectAll(Operator): + bl_idname = 'psa_export.bone_groups_deselect_all' + bl_label = 'Deselect All' + bl_description = 'Deselect all bone groups' + bl_options = {'INTERNAL'} + + @classmethod + def poll(cls, context): + pg = context.scene.psa_export + item_list = pg.bone_group_list has_selected_actions = any(map(lambda action: action.is_selected, item_list)) return len(item_list) > 0 and has_selected_actions def execute(self, context): pg = context.scene.psa_export - for action in pg.action_list: + for action in pg.bone_group_list: action.is_selected = False return {'FINISHED'} @@ -344,6 +388,8 @@ classes = ( PsaExportOperator, PSA_UL_ExportActionList, PSA_UL_ExportTimelineMarkerList, - PsaExportSelectAll, - PsaExportDeselectAll, + PsaExportActionsSelectAll, + PsaExportActionsDeselectAll, + PsaExportBoneGroupsSelectAll, + PsaExportBoneGroupsDeselectAll, ) diff --git a/io_scene_psk_psa/psk/builder.py b/io_scene_psk_psa/psk/builder.py index 5b8362c..de222b1 100644 --- a/io_scene_psk_psa/psk/builder.py +++ b/io_scene_psk_psa/psk/builder.py @@ -78,20 +78,8 @@ class PskBuilder(object): psk_bone.rotation = Quaternion(0, 0, 0, 1) psk.bones.append(psk_bone) else: - bones = list(armature_object.data.bones) - - # If we are filtering by bone groups, get only the bones that are in the specified bone groups and their - # ancestors. - if options.bone_filter_mode == 'BONE_GROUPS': - bone_indices = get_export_bone_indices_for_bone_groups(armature_object, options.bone_group_indices) - bones = [bones[bone_index] for bone_index in bone_indices] - - # Ensure that the exported hierarchy has a single root bone. - root_bones = [x for x in bones if x.parent is None] - if len(root_bones) > 1: - root_bone_names = [x.name for x in root_bones] - raise RuntimeError('Exported bone hierarchy must have a single root bone.' - f'The bone hierarchy marked for export has {len(root_bones)} root bones: {root_bone_names}') + bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, options.bone_group_indices) + bones = [armature_object.data.bones[bone_name] for bone_name in bone_names] for bone in bones: psk_bone = Psk.Bone() diff --git a/io_scene_psk_psa/psk/exporter.py b/io_scene_psk_psa/psk/exporter.py index 893559d..cc65e6b 100644 --- a/io_scene_psk_psa/psk/exporter.py +++ b/io_scene_psk_psa/psk/exporter.py @@ -125,6 +125,7 @@ class PskExportOperator(Operator, ExportHelper): pg = context.scene.psk_export builder = PskBuilder() options = PskBuilderOptions() + options.bone_filter_mode = pg.bone_filter_mode options.bone_group_indices = [x.index for x in pg.bone_group_list if x.is_selected] try: psk = builder.build(context, options)