diff --git a/io_export_psk_psa/__init__.py b/io_export_psk_psa/__init__.py index 10454f1..d29d0e4 100644 --- a/io_export_psk_psa/__init__.py +++ b/io_export_psk_psa/__init__.py @@ -13,6 +13,9 @@ bl_info = { if 'bpy' in locals(): import importlib + importlib.reload(psx_data) + importlib.reload(psx_helpers) + importlib.reload(psx_types) importlib.reload(psk_data) importlib.reload(psk_builder) importlib.reload(psk_exporter) @@ -25,6 +28,9 @@ if 'bpy' in locals(): importlib.reload(psa_importer) else: # if i remove this line, it can be enabled just fine + from . import data as psx_data + from . import helpers as psx_helpers + from . import types as psx_types from .psk import data as psk_data from .psk import builder as psk_builder from .psk import exporter as psk_exporter @@ -40,15 +46,19 @@ import bpy from bpy.props import PointerProperty -classes = [ - psk_exporter.PskExportOperator, +# TODO: have the individual files emit a __classes__ field or something we can update it locally instead of explicitly declaring it here. +classes = [] +classes.extend(psx_types.__classes__) +classes.extend(psk_exporter.__classes__) +classes.extend([ psk_importer.PskImportOperator, psa_importer.PsaImportOperator, psa_importer.PsaImportFileSelectOperator, psa_exporter.PSA_UL_ExportActionList, - psa_exporter.PSA_UL_ExportBoneGroupList, + # psa_exporter.PSA_UL_ExportBoneGroupList, psa_importer.PSA_UL_ImportActionList, psa_importer.PsaImportActionListItem, + psa_importer.PsaImportPsaBoneItem, psa_importer.PsaImportSelectAll, psa_importer.PsaImportDeselectAll, psa_importer.PSA_PT_ImportPanel, @@ -57,9 +67,8 @@ classes = [ psa_exporter.PsaExportSelectAll, psa_exporter.PsaExportDeselectAll, psa_exporter.PsaExportActionListItem, - psa_exporter.PsaExportBoneGroupListItem, psa_exporter.PsaExportPropertyGroup, -] +]) def psk_export_menu_func(self, context): @@ -87,6 +96,7 @@ def register(): bpy.types.TOPBAR_MT_file_import.append(psa_import_menu_func) bpy.types.Scene.psa_import = PointerProperty(type=psa_importer.PsaImportPropertyGroup) bpy.types.Scene.psa_export = PointerProperty(type=psa_exporter.PsaExportPropertyGroup) + bpy.types.Scene.psk_export = PointerProperty(type=psk_exporter.PskExportPropertyGroup) def unregister(): diff --git a/io_export_psk_psa/helpers.py b/io_export_psk_psa/helpers.py new file mode 100644 index 0000000..f8bcb4c --- /dev/null +++ b/io_export_psk_psa/helpers.py @@ -0,0 +1,60 @@ +from typing import List + + +def populate_bone_groups_list(armature_object, bone_group_list): + bone_group_list.clear() + + item = bone_group_list.add() + item.name = '(unassigned)' + item.index = -1 + item.is_selected = True + + for bone_group_index, bone_group in enumerate(armature_object.pose.bone_groups): + item = bone_group_list.add() + item.name = bone_group.name + item.index = bone_group_index + item.is_selected = True + + +def add_bone_groups_to_layout(layout): + pass + + +def get_export_bone_indices_for_bone_groups(armature_object, bone_group_indices: List[int]) -> List[int]: + """ + Returns a sorted list of bone indices that should be exported for the given 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_group_indices: List of bone group indices to be exported. + :return: A sorted list of bone indices that should be exported. + """ + if armature_object is None or armature_object.type != 'ARMATURE': + raise ValueError('An armature object must be supplied') + + bones = armature_object.data.bones + 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. + 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 \ + (pose_bone.bone_group is not None and pose_bone.bone_group_index in bone_group_indices): + bone_index_stack.append(bone_index) + + # 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() + while len(bone_index_stack) > 0: + 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) + + return list(sorted(list(bone_indices))) diff --git a/io_export_psk_psa/psa/builder.py b/io_export_psk_psa/psa/builder.py index 0ae9244..2f11438 100644 --- a/io_export_psk_psa/psa/builder.py +++ b/io_export_psk_psa/psa/builder.py @@ -1,10 +1,11 @@ from .data import * +from ..helpers import * class PsaBuilderOptions(object): def __init__(self): self.actions = [] - self.bone_filter_mode = 'NONE' + self.bone_filter_mode = 'ALL' self.bone_group_indices = [] @@ -34,39 +35,31 @@ 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': - # Get a list of the bone indices that are explicitly part of the bone groups we are including. - bone_index_stack = [] - for bone_index, pose_bone in enumerate(pose_bones): - if pose_bone.bone_group_index in options.bone_group_indices: - bone_index_stack.append(bone_index) + bone_indices = get_export_bone_indices_for_bone_groups(armature, options.bone_group_indices) - # For each bone that is explicitly being added, recursively walk up the hierarchy and ensure that all of - # those bone indices are also in the list. - bone_indices = set() - while len(bone_index_stack) > 0: - 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) - - del bone_names - - # Sort out list of bone indices to be exported. - bone_indices = sorted(list(bone_indices)) - - # The bone lists now contains only the bones that are going to be exported. + # 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] + 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 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}') + for pose_bone in bones: psa_bone = Psa.Bone() psa_bone.name = bytes(pose_bone.name, encoding='utf-8') diff --git a/io_export_psk_psa/psa/exporter.py b/io_export_psk_psa/psa/exporter.py index 587a586..643a6e6 100644 --- a/io_export_psk_psa/psa/exporter.py +++ b/io_export_psk_psa/psa/exporter.py @@ -5,6 +5,8 @@ from bpy_extras.io_utils import ExportHelper from typing import Type from .builder import PsaBuilder, PsaBuilderOptions from .data import * +from ..types import BoneGroupListItem +from ..helpers import * import re @@ -43,30 +45,29 @@ class PsaExportActionListItem(PropertyGroup): return self.action.name -class PsaExportBoneGroupListItem(PropertyGroup): - name: StringProperty() - index: IntProperty() - is_selected: BoolProperty(default=False) - - @property - def name(self): - return self.bone_group.name - - class PsaExportPropertyGroup(PropertyGroup): action_list: CollectionProperty(type=PsaExportActionListItem) action_list_index: IntProperty(default=0) bone_filter_mode: EnumProperty( name='Bone Filter', - items={ - ('NONE', 'None', 'All bones will be exported.'), - ('BONE_GROUPS', 'Bone Groups', 'Only bones belonging to the selected bone groups will be exported.'), - } + description='', + items=( + ('ALL', 'All', 'All bones will be exported.'), + ('BONE_GROUPS', 'Bone Groups', 'Only bones belonging to the selected bone groups and their ancestors will be exported.') + ) ) - bone_group_list: CollectionProperty(type=PsaExportBoneGroupListItem) + bone_group_list: CollectionProperty(type=BoneGroupListItem) bone_group_list_index: IntProperty(default=0) +def is_bone_filter_mode_item_available(context, identifier): + if identifier == "BONE_GROUPS": + obj = context.active_object + if not obj.pose or not obj.pose.bone_groups: + return False + return True + + class PsaExportOperator(Operator, ExportHelper): bl_idname = 'export.psa' bl_label = 'Export' @@ -84,29 +85,34 @@ class PsaExportOperator(Operator, ExportHelper): def draw(self, context): layout = self.layout - scene = context.scene + property_group = context.scene.psa_export + # ACTIONS box = layout.box() box.label(text='Actions', icon='ACTION') - row = box.row() - row.template_list('PSA_UL_ExportActionList', 'asd', scene.psa_export, 'action_list', scene.psa_export, 'action_list_index', rows=10) - - row = box.row() - row.operator('psa_export.actions_select_all', text='Select All') - row.operator('psa_export.actions_deselect_all', text='Deselect All') + row.template_list('PSA_UL_ExportActionList', 'asd', property_group, 'action_list', property_group, 'action_list_index', rows=10) + row = box.row(align=True) + row.label(text='Select') + row.operator('psa_export.actions_select_all', text='All') + row.operator('psa_export.actions_deselect_all', text='None') + # BONES box = layout.box() - box.label(text='Bone Filter', icon='FILTER') + box.label(text='Bones', icon='BONE_DATA') + bone_filter_mode_items = property_group.bl_rna.properties['bone_filter_mode'].enum_items_static + row = box.row(align=True) + for item in bone_filter_mode_items: + identifier = item.identifier + item_layout = row.row(align=True) + item_layout.prop_enum(property_group, 'bone_filter_mode', item.identifier) + item_layout.enabled = is_bone_filter_mode_item_available(context, identifier) - row = box.row() - row.alignment = 'EXPAND' - row.prop(scene.psa_export, 'bone_filter_mode', expand=True, text='Bone Filter') - - if scene.psa_export.bone_filter_mode == 'BONE_GROUPS': + if property_group.bone_filter_mode == 'BONE_GROUPS': + box = layout.box() row = box.row() - rows = max(3, min(len(scene.psa_export.bone_group_list), 10)) - row.template_list('PSA_UL_ExportBoneGroupList', 'asd', scene.psa_export, 'bone_group_list', scene.psa_export, 'bone_group_list_index', rows=rows) + rows = max(3, min(len(property_group.bone_group_list), 10)) + row.template_list('PSX_UL_BoneGroupList', '', property_group, 'bone_group_list', property_group, 'bone_group_list_index', rows=rows) def is_action_for_armature(self, action): if len(action.fcurves) == 0: @@ -122,6 +128,8 @@ class PsaExportOperator(Operator, ExportHelper): return False def invoke(self, context, event): + property_group = context.scene.psa_export + if context.view_layer.objects.active is None: self.report({'ERROR_INVALID_CONTEXT'}, 'An armature must be selected') return {'CANCELLED'} @@ -133,33 +141,29 @@ class PsaExportOperator(Operator, ExportHelper): self.armature = context.view_layer.objects.active # Populate actions list. - context.scene.psa_export.action_list.clear() + property_group.action_list.clear() for action in bpy.data.actions: - item = context.scene.psa_export.action_list.add() + item = property_group.action_list.add() item.action = action item.action_name = action.name if self.is_action_for_armature(action): item.is_selected = True - if len(context.scene.psa_export.action_list) == 0: + if len(property_group.action_list) == 0: # If there are no actions at all, we have nothing to export, so just cancel the operation. self.report({'ERROR_INVALID_CONTEXT'}, 'There are no actions to export.') return {'CANCELLED'} # Populate bone groups list. - context.scene.psa_export.bone_group_list.clear() - for bone_group_index, bone_group in enumerate(self.armature.pose.bone_groups): - item = context.scene.psa_export.bone_group_list.add() - item.name = bone_group.name - item.index = bone_group_index - item.is_selected = False + populate_bone_groups_list(self.armature, property_group.bone_group_list) context.window_manager.fileselect_add(self) return {'RUNNING_MODAL'} def execute(self, context): - actions = [x.action for x in context.scene.psa_export.action_list if x.is_selected] + property_group = context.scene.psa_export + actions = [x.action for x in property_group.action_list if x.is_selected] if len(actions) == 0: self.report({'ERROR_INVALID_CONTEXT'}, 'No actions were selected for export.') @@ -167,8 +171,8 @@ class PsaExportOperator(Operator, ExportHelper): options = PsaBuilderOptions() options.actions = actions - options.bone_filter_mode = context.scene.psa_export.bone_filter_mode - options.bone_group_indices = [x.index for x in context.scene.psa_export.bone_group_list if x.is_selected] + options.bone_filter_mode = property_group.bone_filter_mode + options.bone_group_indices = [x.index for x in property_group.bone_group_list if x.is_selected] builder = PsaBuilder() psa = builder.build(context, options) exporter = PsaExporter(psa) @@ -176,13 +180,6 @@ class PsaExportOperator(Operator, ExportHelper): return {'FINISHED'} -class PSA_UL_ExportBoneGroupList(UIList): - def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): - layout.alignment = 'LEFT' - layout.prop(item, 'is_selected', icon_only=True) - layout.label(text=item.name, icon='GROUP_BONE') - - class PSA_UL_ExportActionList(UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): layout.alignment = 'LEFT' @@ -210,12 +207,14 @@ class PsaExportSelectAll(bpy.types.Operator): @classmethod def poll(cls, context): - action_list = context.scene.psa_export.action_list + property_group = context.scene.psa_export + action_list = property_group.action_list has_unselected_actions = any(map(lambda action: not action.is_selected, action_list)) return len(action_list) > 0 and has_unselected_actions def execute(self, context): - for action in context.scene.psa_export.action_list: + property_group = context.scene.psa_export + for action in property_group.action_list: action.is_selected = True return {'FINISHED'} @@ -226,11 +225,13 @@ class PsaExportDeselectAll(bpy.types.Operator): @classmethod def poll(cls, context): - action_list = context.scene.psa_export.action_list + property_group = context.scene.psa_export + action_list = property_group.action_list has_selected_actions = any(map(lambda action: action.is_selected, action_list)) return len(action_list) > 0 and has_selected_actions def execute(self, context): - for action in context.scene.psa_export.action_list: + property_group = context.scene.psa_export + for action in property_group.action_list: action.is_selected = False return {'FINISHED'} diff --git a/io_export_psk_psa/psa/importer.py b/io_export_psk_psa/psa/importer.py index b471e02..996e96e 100644 --- a/io_export_psk_psa/psa/importer.py +++ b/io_export_psk_psa/psa/importer.py @@ -17,9 +17,9 @@ class PsaImporter(object): pass def import_psa(self, psa_reader: PsaReader, sequence_names: List[AnyStr], context): - properties = context.scene.psa_import + property_group = context.scene.psa_import sequences = map(lambda x: psa_reader.sequences[x], sequence_names) - armature_object = properties.armature_object + armature_object = property_group.armature_object armature_data = armature_object.data class ImportBone(object): @@ -190,6 +190,14 @@ class PsaImporter(object): print(f'total_time: {total_time}') +class PsaImportPsaBoneItem(PropertyGroup): + bone_name: StringProperty() + + @property + def name(self): + return self.bone_name + + class PsaImportActionListItem(PropertyGroup): action_name: StringProperty() frame_count: IntProperty() @@ -201,16 +209,22 @@ class PsaImportActionListItem(PropertyGroup): def on_psa_file_path_updated(property, context): - context.scene.psa_import.action_list.clear() + property_group = context.scene.psa_import + property_group.action_list.clear() + property_group.psa_bones.clear() try: # Read the file and populate the action list. - p = os.path.abspath(context.scene.psa_import.psa_file_path) + p = os.path.abspath(property_group.psa_file_path) psa_reader = PsaReader(p) for sequence in psa_reader.sequences.values(): - item = context.scene.psa_import.action_list.add() + item = property_group.action_list.add() item.action_name = sequence.name.decode('windows-1252') item.frame_count = sequence.frame_count item.is_selected = True + + for psa_bone in psa_reader.bones: + item = property_group.psa_bones.add() + item.bone_name = psa_bone.name except IOError as e: print('ERROR READING FILE') print(e) @@ -218,9 +232,19 @@ def on_psa_file_path_updated(property, context): pass +def on_armature_object_updated(property, context): + # TODO: ensure that there are matching bones between the two rigs. + property_group = context.scene.psa_import + armature_object = property_group.armature_object + if armature_object is not None: + armature_bone_names = set(map(lambda bone: bone.name, armature_object.data.bones)) + psa_bone_names = set(map(lambda psa_bone: psa_bone.name, property_group.psa_bones)) + + class PsaImportPropertyGroup(bpy.types.PropertyGroup): psa_file_path: StringProperty(default='', subtype='FILE_PATH', update=on_psa_file_path_updated) - armature_object: PointerProperty(name='Armature', type=bpy.types.Object) + psa_bones: CollectionProperty(type=PsaImportPsaBoneItem) + armature_object: PointerProperty(name='Object', type=bpy.types.Object, update=on_armature_object_updated) action_list: CollectionProperty(type=PsaImportActionListItem) action_list_index: IntProperty(name='', default=0) action_filter_name: StringProperty(default='') @@ -265,12 +289,14 @@ class PsaImportSelectAll(bpy.types.Operator): @classmethod def poll(cls, context): - action_list = context.scene.psa_import.action_list + property_group = context.scene.psa_import + action_list = property_group.action_list has_unselected_actions = any(map(lambda action: not action.is_selected, action_list)) return len(action_list) > 0 and has_unselected_actions def execute(self, context): - for action in context.scene.psa_import.action_list: + property_group = context.scene.psa_import + for action in property_group.action_list: action.is_selected = True return {'FINISHED'} @@ -281,35 +307,41 @@ class PsaImportDeselectAll(bpy.types.Operator): @classmethod def poll(cls, context): - action_list = context.scene.psa_import.action_list + property_group = context.scene.psa_import + action_list = property_group.action_list has_selected_actions = any(map(lambda action: action.is_selected, action_list)) return len(action_list) > 0 and has_selected_actions def execute(self, context): - for action in context.scene.psa_import.action_list: + property_group = context.scene.psa_import + for action in property_group.action_list: action.is_selected = False return {'FINISHED'} class PSA_PT_ImportPanel(Panel): - bl_space_type = 'VIEW_3D' + bl_space_type = 'NLA_EDITOR' bl_region_type = 'UI' bl_label = 'PSA Import' - bl_context = 'objectmode' + bl_context = 'object' bl_category = 'PSA Import' + @classmethod + def poll(cls, context): + return context.view_layer.objects.active is not None + def draw(self, context): layout = self.layout - scene = context.scene + property_group = context.scene.psa_import row = layout.row() - row.prop(scene.psa_import, 'psa_file_path', text='PSA File') + row.prop(property_group, 'psa_file_path', text='PSA File') row = layout.row() - row.prop_search(scene.psa_import, 'armature_object', bpy.data, 'objects') + row.prop_search(property_group, 'armature_object', bpy.data, 'objects') box = layout.box() - box.label(text=f'Actions ({len(scene.psa_import.action_list)})', icon='ACTION') + box.label(text=f'Actions ({len(property_group.action_list)})', icon='ACTION') row = box.row() - rows = max(3, min(len(scene.psa_import.action_list), 10)) - row.template_list('PSA_UL_ImportActionList', 'asd', scene.psa_import, 'action_list', scene.psa_import, 'action_list_index', rows=rows) + rows = max(3, min(len(property_group.action_list), 10)) + row.template_list('PSA_UL_ImportActionList', '', property_group, 'action_list', property_group, 'action_list_index', rows=rows) row = box.row(align=True) row.label(text='Select') row.operator('psa_import.actions_select_all', text='All') @@ -323,14 +355,16 @@ class PsaImportOperator(Operator): @classmethod def poll(cls, context): - action_list = context.scene.psa_import.action_list + property_group = context.scene.psa_import + action_list = property_group.action_list has_selected_actions = any(map(lambda action: action.is_selected, action_list)) - armature_object = context.scene.psa_import.armature_object + armature_object = property_group.armature_object return has_selected_actions and armature_object is not None def execute(self, context): - psa_reader = PsaReader(context.scene.psa_import.psa_file_path) - sequence_names = [x.action_name for x in context.scene.psa_import.action_list if x.is_selected] + property_group = context.scene.psa_import + psa_reader = PsaReader(property_group.psa_file_path) + sequence_names = [x.action_name for x in property_group.action_list if x.is_selected] PsaImporter().import_psa(psa_reader, sequence_names, context) return {'FINISHED'} @@ -351,6 +385,7 @@ class PsaImportFileSelectOperator(Operator, ImportHelper): return {'RUNNING_MODAL'} def execute(self, context): - context.scene.psa_import.psa_file_path = self.filepath + property_group = context.scene.psa_import + property_group.psa_file_path = self.filepath # Load the sequence names from the selected file return {'FINISHED'} diff --git a/io_export_psk_psa/psk/builder.py b/io_export_psk_psa/psk/builder.py index c5d92a0..f27bd12 100644 --- a/io_export_psk_psa/psk/builder.py +++ b/io_export_psk_psa/psk/builder.py @@ -2,8 +2,8 @@ import bpy import bmesh from collections import OrderedDict from .data import * +from ..helpers import * -# https://github.com/bwrsandman/blender-addons/blob/master/io_export_unreal_psk_psa.py class PskInputObjects(object): def __init__(self): @@ -11,6 +11,12 @@ class PskInputObjects(object): self.armature_object = None +class PskBuilderOptions(object): + def __init__(self): + self.bone_filter_mode = 'ALL' + self.bone_group_indices = [] + + class PskBuilder(object): def __init__(self): pass @@ -51,13 +57,16 @@ class PskBuilder(object): return input_objects - def build(self, context) -> Psk: + def build(self, context, options: PskBuilderOptions) -> Psk: input_objects = PskBuilder.get_input_objects(context) + armature_object = input_objects.armature_object + psk = Psk() + bones = [] materials = OrderedDict() - if input_objects.armature_object is None: + if armature_object is None: # Static mesh (no armature) psk_bone = Psk.Bone() psk_bone.name = bytes('static', encoding='utf-8') @@ -68,15 +77,30 @@ class PskBuilder(object): psk_bone.rotation = Quaternion(0, 0, 0, 1) psk.bones.append(psk_bone) else: - bones = list(input_objects.armature_object.data.bones) + bones = list(armature_object.data.bones) + + # If bone groups are specified, get only the bones that are in the specified bone groups and their ancestors. + if len(options.bone_group_indices) > 0: + 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 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}') + for bone in bones: psk_bone = Psk.Bone() psk_bone.name = bytes(bone.name, encoding='utf-8') psk_bone.flags = 0 - psk_bone.children_count = len(bone.children) + psk_bone.children_count = 0 try: - psk_bone.parent_index = bones.index(bone.parent) + parent_index = bones.index(bone.parent) + psk_bone.parent_index = parent_index + psk.bones[parent_index].children_count += 1 except ValueError: psk_bone.parent_index = 0 @@ -90,8 +114,8 @@ class PskBuilder(object): parent_tail = quat_parent @ bone.parent.tail location = (parent_tail - parent_head) + bone.head else: - location = input_objects.armature_object.matrix_local @ bone.head - rot_matrix = bone.matrix @ input_objects.armature_object.matrix_local.to_3x3() + location = armature_object.matrix_local @ bone.head + rot_matrix = bone.matrix @ armature_object.matrix_local.to_3x3() rotation = rot_matrix.to_quaternion() psk_bone.location.x = location.x @@ -177,14 +201,34 @@ class PskBuilder(object): psk.faces.append(face) # WEIGHTS - # TODO: bone ~> vg might not be 1:1, provide a nice error message if this is the case - if input_objects.armature_object is not None: - armature = input_objects.armature_object.data - bone_names = [x.name for x in armature.bones] + if armature_object is not None: + # 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] vertex_group_names = [x.name for x in object.vertex_groups] - bone_indices = [bone_names.index(name) for name in vertex_group_names] + vertex_group_bone_indices = 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) + 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. + # If there is, we can traverse the ancestors of that bone to find an alternate bone to use for + # weighting the vertices belonging to this vertex group. + if vertex_group_name in armature_object.data.bones: + bone = armature_object.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 + break + except ValueError: + bone = bone.parent for vertex_group_index, vertex_group in enumerate(object.vertex_groups): - bone_index = bone_indices[vertex_group_index] + if vertex_group_index not in vertex_group_bone_indices: + continue + bone_index = vertex_group_bone_indices[vertex_group_index] + # TODO: exclude vertex group if it doesn't match to a bone we are exporting for vertex_index in range(len(object.data.vertices)): try: weight = vertex_group.weight(vertex_index) diff --git a/io_export_psk_psa/psk/exporter.py b/io_export_psk_psa/psk/exporter.py index bc90446..4e9b930 100644 --- a/io_export_psk_psa/psk/exporter.py +++ b/io_export_psk_psa/psk/exporter.py @@ -1,9 +1,11 @@ from .data import * -from .builder import PskBuilder +from ..types import BoneGroupListItem +from ..helpers import populate_bone_group_list +from .builder import PskBuilder, PskBuilderOptions from typing import Type -from bpy.types import Operator +from bpy.types import Operator, PropertyGroup from bpy_extras.io_utils import ExportHelper -from bpy.props import StringProperty +from bpy.props import StringProperty, CollectionProperty, IntProperty, BoolProperty, EnumProperty MAX_WEDGE_COUNT = 65536 MAX_POINT_COUNT = 4294967296 @@ -58,6 +60,16 @@ class PskExporter(object): self.write_section(fp, b'RAWWEIGHTS', Psk.Weight, self.psk.weights) +def is_bone_filter_mode_item_available(context, identifier): + input_objects = PskBuilder.get_input_objects(context) + armature_object = input_objects.armature_object + if identifier == 'BONE_GROUPS': + if not armature_object.pose or not armature_object.pose.bone_groups: + return False + # else if... you can set up other conditions if you add more options + return True + + class PskExportOperator(Operator, ExportHelper): bl_idname = 'export.psk' bl_label = 'Export' @@ -73,17 +85,66 @@ class PskExportOperator(Operator, ExportHelper): def invoke(self, context, event): try: - PskBuilder.get_input_objects(context) + input_objects = PskBuilder.get_input_objects(context) except RuntimeError as e: self.report({'ERROR_INVALID_CONTEXT'}, str(e)) return {'CANCELLED'} + property_group = context.scene.psk_export + + # Populate bone groups list. + populate_bone_group_list(input_objects.armature_object, property_group.bone_group_list) + context.window_manager.fileselect_add(self) + return {'RUNNING_MODAL'} + def draw(self, context): + layout = self.layout + scene = context.scene + property_group = scene.psk_export + + # BONES + box = layout.box() + box.label(text='Bones', icon='BONE_DATA') + bone_filter_mode_items = property_group.bl_rna.properties['bone_filter_mode'].enum_items_static + row = box.row(align=True) + for item in bone_filter_mode_items: + identifier = item.identifier + item_layout = row.row(align=True) + item_layout.prop_enum(property_group, 'bone_filter_mode', item.identifier) + item_layout.enabled = is_bone_filter_mode_item_available(context, identifier) + + if property_group.bone_filter_mode == 'BONE_GROUPS': + row = box.row() + rows = max(3, min(len(property_group.bone_group_list), 10)) + row.template_list('PSX_UL_BoneGroupList', '', property_group, 'bone_group_list', property_group, 'bone_group_list_index', rows=rows) + def execute(self, context): + property_group = context.scene.psk_export builder = PskBuilder() - psk = builder.build(context) + options = PskBuilderOptions() + options.bone_group_indices = [x.index for x in property_group.bone_group_list if x.is_selected] + psk = builder.build(context, options) exporter = PskExporter(psk) exporter.export(self.filepath) return {'FINISHED'} + + +class PskExportPropertyGroup(PropertyGroup): + bone_filter_mode: EnumProperty( + name='Bone Filter', + description='', + items=( + ('ALL', 'All', 'All bones will be exported.'), + ('BONE_GROUPS', 'Bone Groups', 'Only bones belonging to the selected bone groups and their ancestors will be exported.') + ) + ) + bone_group_list: CollectionProperty(type=BoneGroupListItem) + bone_group_list_index: IntProperty(default=0) + + +__classes__ = [ + PskExportOperator, + PskExportPropertyGroup +] \ No newline at end of file diff --git a/io_export_psk_psa/types.py b/io_export_psk_psa/types.py new file mode 100644 index 0000000..47e6dcd --- /dev/null +++ b/io_export_psk_psa/types.py @@ -0,0 +1,25 @@ +from bpy.types import PropertyGroup, UIList +from bpy.props import StringProperty, IntProperty, BoolProperty + + +class PSX_UL_BoneGroupList(UIList): + def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): + layout.alignment = 'LEFT' + layout.prop(item, 'is_selected', icon_only=True) + layout.label(text=item.name, icon='GROUP_BONE' if item.index >= 0 else 'NONE') + + +class BoneGroupListItem(PropertyGroup): + name: StringProperty() + index: IntProperty() + is_selected: BoolProperty(default=False) + + @property + def name(self): + return self.name + + +__classes__ = [ + BoneGroupListItem, + PSX_UL_BoneGroupList +]