diff --git a/README.md b/README.md index 2e6de7c..62496c8 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ This Blender add-on allows you to import and export meshes and animations to and * Non-standard file section data is supported for import only (vertex normals, extra UV channels, vertex colors, shape keys). * Fine-grained PSA sequence importing for efficient workflow when working with large PSA files. * PSA sequence metadata (e.g., frame rate, sequence name) is preserved on import, allowing this data to be reused on export. -* Specific [bone groups](https://docs.blender.org/manual/en/latest/animation/armatures/properties/bone_groups.html) can be excluded from PSK/PSA export (useful for excluding non-contributing bones such as IK controllers). +* Specific bone collections can be excluded from PSK/PSA export (useful for excluding non-contributing bones such as IK controllers). * PSA sequences can be exported directly from actions or delineated using a scene's [timeline markers](https://docs.blender.org/manual/en/latest/animation/markers.html), allowing direct use of the [NLA](https://docs.blender.org/manual/en/latest/editors/nla/index.html) when creating sequences. * Manual re-ordering of material slots when exporting multiple mesh objects. diff --git a/io_scene_psk_psa/helpers.py b/io_scene_psk_psa/helpers.py index fe683e0..dfd0b76 100644 --- a/io_scene_psk_psa/helpers.py +++ b/io_scene_psk_psa/helpers.py @@ -1,7 +1,6 @@ import datetime import re import typing -from collections import Counter from typing import List, Iterable import addon_utils @@ -49,43 +48,46 @@ def get_nla_strips_in_timeframe(animation_data: AnimData, frame_min: float, fram return strips -def populate_bone_group_list(armature_object: Object, bone_group_list: bpy.props.CollectionProperty) -> None: +def populate_bone_collection_list(armature_object: Object, bone_collection_list: bpy.props.CollectionProperty) -> None: """ - Updates the bone group collection. + Updates the bone collections collection. - Bone group selections are preserved between updates unless none of the groups were previously selected; - otherwise, all groups are selected by default. + Bone collection selections are preserved between updates unless none of the groups were previously selected; + otherwise, all collections are selected by default. """ - has_selected_groups = any([g.is_selected for g in bone_group_list]) - unassigned_group_is_selected, selected_assigned_group_names = True, [] + has_selected_collections = any([g.is_selected for g in bone_collection_list]) + unassigned_collection_is_selected, selected_assigned_collection_names = True, [] - if has_selected_groups: + if has_selected_collections: # Preserve group selections before clearing the list. # We handle selections for the unassigned group separately to cover the edge case # where there might be an actual group with 'Unassigned' as its name. - unassigned_group_idx, unassigned_group_is_selected = next(iter([ - (i, g.is_selected) for i, g in enumerate(bone_group_list) if g.index == -1]), (-1, False)) + unassigned_collection_idx, unassigned_collection_is_selected = next(iter([ + (i, g.is_selected) for i, g in enumerate(bone_collection_list) if g.index == -1]), (-1, False)) - selected_assigned_group_names = [ - g.name for i, g in enumerate(bone_group_list) if i != unassigned_group_idx and g.is_selected] + selected_assigned_collection_names = [ + g.name for i, g in enumerate(bone_collection_list) if i != unassigned_collection_idx and g.is_selected] - bone_group_list.clear() + bone_collection_list.clear() - if armature_object and armature_object.pose: - bone_group_counts = Counter(map(lambda x: x.bone_group, armature_object.pose.bones)) + armature = armature_object.data - item = bone_group_list.add() - item.name = 'Unassigned' - item.index = -1 - item.count = 0 if None not in bone_group_counts else bone_group_counts[None] - item.is_selected = unassigned_group_is_selected + if armature is None: + return - 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.count = 0 if bone_group not in bone_group_counts else bone_group_counts[bone_group] - item.is_selected = bone_group.name in selected_assigned_group_names if has_selected_groups else True + item = bone_collection_list.add() + item.name = 'Unassigned' + item.index = -1 + # Count the number of bones without an assigned bone collection + item.count = sum(map(lambda bone: 1 if len(bone.collections) == 0 else 0, armature.bones)) + item.is_selected = unassigned_collection_is_selected + + for bone_collection_index, bone_collection in enumerate(armature.collections): + item = bone_collection_list.add() + item.name = bone_collection.name + item.index = bone_collection_index + item.count = len(bone_collection.bones) + item.is_selected = bone_collection.name in selected_assigned_collection_names if has_selected_collections else True def check_bone_names(bone_names: Iterable[str]): @@ -97,15 +99,15 @@ def check_bone_names(bone_names: Iterable[str]): f'You can bypass this by disabling "Enforce Bone Name Restrictions" in the export settings.') -def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_group_indices: List[int]) -> List[str]: +def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_collection_indices: List[int]) -> List[str]: """ - Returns a sorted list of bone indices that should be exported for the given bone filter mode and bone groups. + Returns a sorted list of bone indices that should be exported for the given bone filter mode and bone collections. - Note that the ancestors of bones within the bone groups will also be present in the returned list. + Note that the ancestors of bones within the bone collections 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. + :param bone_filter_mode: One of ['ALL', 'BONE_COLLECTIONS'] + :param bone_collection_indices: List of bone collection 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': @@ -113,16 +115,21 @@ def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_g armature_data = typing.cast(bpy.types.Armature, armature_object.data) bones = armature_data.bones - pose_bones = armature_object.pose.bones bone_names = [x.name for x in bones] # 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): + is_exporting_unassigned_bone_collections = -1 in bone_collection_indices + bone_collections = list(armature_data.collections) + + for bone_index, bone in enumerate(bones): + # Check if this bone is in any of the collections in the bone collection indices list. + this_bone_collection_indices = set(bone_collections.index(x) for x in bone.collections) + is_in_exported_bone_collections = len(set(bone_collection_indices).intersection(this_bone_collection_indices)) > 0 + 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): + (len(bone.collections) == 0 and is_exporting_unassigned_bone_collections) or \ + is_in_exported_bone_collections: 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 diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index 2a23ee9..97ef328 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -26,7 +26,7 @@ class PsaBuildOptions: self.animation_data: Optional[AnimData] = None self.sequences: List[PsaBuildSequence] = [] self.bone_filter_mode: str = 'ALL' - self.bone_group_indices: List[int] = [] + self.bone_collection_indices: List[int] = [] self.should_enforce_bone_name_restrictions: bool = False self.sequence_name_prefix: str = '' self.sequence_name_suffix: str = '' @@ -73,7 +73,7 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: pose_bones = [x[1] for x in pose_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_group_indices) + 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. diff --git a/io_scene_psk_psa/psa/export/operators.py b/io_scene_psk_psa/psa/export/operators.py index 13c28da..02ffe88 100644 --- a/io_scene_psk_psa/psa/export/operators.py +++ b/io_scene_psk_psa/psa/export/operators.py @@ -11,7 +11,7 @@ from bpy_types import Operator from ..builder import build_psa, PsaBuildSequence, PsaBuildOptions from ..export.properties import PSA_PG_export, PSA_PG_export_action_list_item, filter_sequences from ..writer import write_psa -from ...helpers import populate_bone_group_list, get_nla_strips_in_timeframe +from ...helpers import populate_bone_collection_list, get_nla_strips_in_timeframe def is_action_for_armature(armature: Armature, action: Action): @@ -126,9 +126,9 @@ def get_animation_data_object(context: Context) -> Object: 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: + if identifier == 'BONE_COLLECTIONS': + armature = context.active_object.data + if len(armature.collections) == 0: return False return True @@ -304,13 +304,13 @@ class PSA_OT_export(Operator, ExportHelper): row = layout.row(align=True) row.prop(pg, 'bone_filter_mode', text='Bones') - if pg.bone_filter_mode == 'BONE_GROUPS': + if pg.bone_filter_mode == 'BONE_COLLECTIONS': row = layout.row(align=True) row.label(text='Select') - row.operator(PSA_OT_export_bone_groups_select_all.bl_idname, text='All', icon='CHECKBOX_HLT') - row.operator(PSA_OT_export_bone_groups_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT') - rows = max(3, min(len(pg.bone_group_list), 10)) - layout.template_list('PSX_UL_bone_group_list', '', pg, 'bone_group_list', pg, 'bone_group_list_index', + row.operator(PSA_OT_export_bone_collections_select_all.bl_idname, text='All', icon='CHECKBOX_HLT') + row.operator(PSA_OT_export_bone_collections_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT') + rows = max(3, min(len(pg.bone_collection_list), 10)) + layout.template_list('PSX_UL_bone_collection_list', '', pg, 'bone_collection_list', pg, 'bone_collection_list_index', rows=rows) layout.prop(pg, 'should_enforce_bone_name_restrictions') @@ -345,8 +345,7 @@ class PSA_OT_export(Operator, ExportHelper): update_actions_and_timeline_markers(context, self.armature_object.data) - # Populate bone groups list. - populate_bone_group_list(self.armature_object, pg.bone_group_list) + populate_bone_collection_list(self.armature_object, pg.bone_collection_list) context.window_manager.fileselect_add(self) @@ -401,7 +400,7 @@ class PSA_OT_export(Operator, ExportHelper): options.animation_data = animation_data options.sequences = export_sequences options.bone_filter_mode = pg.bone_filter_mode - options.bone_group_indices = [x.index for x in pg.bone_group_list if x.is_selected] + options.bone_collection_indices = [x.index for x in pg.bone_collection_list if x.is_selected] options.should_ignore_bone_name_restrictions = pg.should_enforce_bone_name_restrictions options.sequence_name_prefix = pg.sequence_name_prefix options.sequence_name_suffix = pg.sequence_name_suffix @@ -479,42 +478,42 @@ class PSA_OT_export_actions_deselect_all(Operator): return {'FINISHED'} -class PSA_OT_export_bone_groups_select_all(Operator): - bl_idname = 'psa_export.bone_groups_select_all' +class PSA_OT_export_bone_collections_select_all(Operator): + bl_idname = 'psa_export.bone_collections_select_all' bl_label = 'Select All' - bl_description = 'Select all bone groups' + bl_description = 'Select all bone collections' bl_options = {'INTERNAL'} @classmethod def poll(cls, context): pg = getattr(context.scene, 'psa_export') - item_list = pg.bone_group_list + item_list = pg.bone_collection_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 = getattr(context.scene, 'psa_export') - for item in pg.bone_group_list: + for item in pg.bone_collection_list: item.is_selected = True return {'FINISHED'} -class PSA_OT_export_bone_groups_deselect_all(Operator): - bl_idname = 'psa_export.bone_groups_deselect_all' +class PSA_OT_export_bone_collections_deselect_all(Operator): + bl_idname = 'psa_export.bone_collections_deselect_all' bl_label = 'Deselect All' - bl_description = 'Deselect all bone groups' + bl_description = 'Deselect all bone collections' bl_options = {'INTERNAL'} @classmethod def poll(cls, context): pg = getattr(context.scene, 'psa_export') - item_list = pg.bone_group_list + item_list = pg.bone_collection_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 = getattr(context.scene, 'psa_export') - for action in pg.bone_group_list: + for action in pg.bone_collection_list: action.is_selected = False return {'FINISHED'} @@ -523,6 +522,6 @@ classes = ( PSA_OT_export, PSA_OT_export_actions_select_all, PSA_OT_export_actions_deselect_all, - PSA_OT_export_bone_groups_select_all, - PSA_OT_export_bone_groups_deselect_all, + PSA_OT_export_bone_collections_select_all, + PSA_OT_export_bone_collections_deselect_all, ) diff --git a/io_scene_psk_psa/psa/export/properties.py b/io_scene_psk_psa/psa/export/properties.py index 1f75f74..6534cbb 100644 --- a/io_scene_psk_psa/psa/export/properties.py +++ b/io_scene_psk_psa/psa/export/properties.py @@ -6,7 +6,7 @@ from bpy.props import BoolProperty, PointerProperty, EnumProperty, FloatProperty StringProperty from bpy.types import PropertyGroup, Object, Action -from ...types import PSX_PG_bone_group_list_item +from ...types import PSX_PG_bone_collection_list_item def psa_export_property_group_animation_data_override_poll(_context, obj): @@ -86,12 +86,12 @@ class PSA_PG_export(PropertyGroup): 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_COLLECTIONS', 'Bone Collections', 'Only bones belonging to the selected bone collections and their ' + 'ancestors will be exported.'), ) ) - bone_group_list: CollectionProperty(type=PSX_PG_bone_group_list_item) - bone_group_list_index: IntProperty(default=0, name='', description='') + bone_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item) + bone_collection_list_index: IntProperty(default=0, name='', description='') should_enforce_bone_name_restrictions: BoolProperty( default=False, name='Enforce Bone Name Restrictions', diff --git a/io_scene_psk_psa/psk/builder.py b/io_scene_psk_psa/psk/builder.py index 6d8677e..1f14779 100644 --- a/io_scene_psk_psa/psk/builder.py +++ b/io_scene_psk_psa/psk/builder.py @@ -15,7 +15,7 @@ class PskInputObjects(object): class PskBuildOptions(object): def __init__(self): self.bone_filter_mode = 'ALL' - self.bone_group_indices: List[int] = [] + self.bone_collection_indices: List[int] = [] self.use_raw_mesh_data = True self.material_names: List[str] = [] self.should_enforce_bone_name_restrictions = False @@ -78,7 +78,7 @@ def build_psk(context, options: PskBuildOptions) -> Psk: psk_bone.rotation = Quaternion.identity() psk.bones.append(psk_bone) else: - bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, options.bone_group_indices) + bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, options.bone_collection_indices) armature_data = typing.cast(Armature, armature_object.data) bones = [armature_data.bones[bone_name] for bone_name in bone_names] diff --git a/io_scene_psk_psa/psk/export/operators.py b/io_scene_psk_psa/psk/export/operators.py index cb48caa..9605b70 100644 --- a/io_scene_psk_psa/psk/export/operators.py +++ b/io_scene_psk_psa/psk/export/operators.py @@ -4,14 +4,14 @@ from bpy_extras.io_utils import ExportHelper from ..builder import build_psk, PskBuildOptions, get_psk_input_objects from ..writer import write_psk -from ...helpers import populate_bone_group_list +from ...helpers import populate_bone_collection_list def is_bone_filter_mode_item_available(context, identifier): input_objects = get_psk_input_objects(context) armature_object = input_objects.armature_object - if identifier == 'BONE_GROUPS': - if not armature_object or not armature_object.pose or not armature_object.pose.bone_groups: + if identifier == 'BONE_COLLECTIONS': + if armature_object is None or armature_object.data is None or len(armature_object.data.collections) == 0: return False # else if... you can set up other conditions if you add more options return True @@ -94,8 +94,7 @@ class PSK_OT_export(Operator, ExportHelper): pg = getattr(context.scene, 'psk_export') - # Populate bone groups list. - populate_bone_group_list(input_objects.armature_object, pg.bone_group_list) + populate_bone_collection_list(input_objects.armature_object, pg.bone_collection_list) try: populate_material_list(input_objects.mesh_objects, pg.material_list) @@ -136,10 +135,10 @@ class PSK_OT_export(Operator, ExportHelper): item_layout.prop_enum(pg, 'bone_filter_mode', item.identifier) item_layout.enabled = is_bone_filter_mode_item_available(context, identifier) - if pg.bone_filter_mode == 'BONE_GROUPS': + if pg.bone_filter_mode == 'BONE_COLLECTIONS': row = box.row() - rows = max(3, min(len(pg.bone_group_list), 10)) - row.template_list('PSX_UL_bone_group_list', '', pg, 'bone_group_list', pg, 'bone_group_list_index', rows=rows) + 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) box.prop(pg, 'should_enforce_bone_name_restrictions') @@ -147,7 +146,7 @@ class PSK_OT_export(Operator, ExportHelper): box = layout.box() box.label(text='Materials', icon='MATERIAL') row = box.row() - rows = max(3, min(len(pg.bone_group_list), 10)) + rows = max(3, min(len(pg.bone_collection_list), 10)) row.template_list('PSK_UL_materials', '', pg, 'material_list', pg, 'material_list_index', rows=rows) col = row.column(align=True) col.operator(PSK_OT_material_list_move_up.bl_idname, text='', icon='TRIA_UP') @@ -157,7 +156,7 @@ class PSK_OT_export(Operator, ExportHelper): pg = context.scene.psk_export options = PskBuildOptions() 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] + options.bone_collection_indices = [x.index for x in pg.bone_collection_list if x.is_selected] options.use_raw_mesh_data = pg.use_raw_mesh_data options.material_names = [m.material_name for m in pg.material_list] options.should_enforce_bone_name_restrictions = pg.should_enforce_bone_name_restrictions diff --git a/io_scene_psk_psa/psk/export/properties.py b/io_scene_psk_psa/psk/export/properties.py index ba5f1f6..8382d01 100644 --- a/io_scene_psk_psa/psk/export/properties.py +++ b/io_scene_psk_psa/psk/export/properties.py @@ -1,7 +1,7 @@ from bpy.props import EnumProperty, CollectionProperty, IntProperty, BoolProperty, StringProperty from bpy.types import PropertyGroup -from ...types import PSX_PG_bone_group_list_item +from ...types import PSX_PG_bone_collection_list_item class PSK_PG_material_list_item(PropertyGroup): @@ -16,12 +16,12 @@ class PSK_PG_export(PropertyGroup): 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_COLLECTIONS', 'Bone Collections', + 'Only bones belonging to the selected bone collections and their ancestors will be exported') ) ) - bone_group_list: CollectionProperty(type=PSX_PG_bone_group_list_item) - bone_group_list_index: IntProperty(default=0) + bone_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item) + bone_collection_list_index: IntProperty(default=0) use_raw_mesh_data: BoolProperty(default=False, name='Raw Mesh Data', description='No modifiers will be evaluated as part of the exported mesh') material_list: CollectionProperty(type=PSK_PG_material_list_item) material_list_index: IntProperty(default=0) diff --git a/io_scene_psk_psa/types.py b/io_scene_psk_psa/types.py index 26688b0..fa16a15 100644 --- a/io_scene_psk_psa/types.py +++ b/io_scene_psk_psa/types.py @@ -2,7 +2,7 @@ from bpy.props import StringProperty, IntProperty, BoolProperty, FloatProperty from bpy.types import PropertyGroup, UIList, UILayout, Context, AnyType, Panel -class PSX_UL_bone_group_list(UIList): +class PSX_UL_bone_collection_list(UIList): def draw_item(self, context: Context, layout: UILayout, data: AnyType, item: AnyType, icon: int, active_data: AnyType, active_property: str, index: int = 0, flt_flag: int = 0): @@ -11,7 +11,7 @@ class PSX_UL_bone_group_list(UIList): row.label(text=str(getattr(item, 'count')), icon='BONE_DATA') -class PSX_PG_bone_group_list_item(PropertyGroup): +class PSX_PG_bone_collection_list_item(PropertyGroup): name: StringProperty() index: IntProperty() count: IntProperty() @@ -44,7 +44,7 @@ class PSX_PT_action(Panel): classes = ( PSX_PG_action_export, - PSX_PG_bone_group_list_item, - PSX_UL_bone_group_list, + PSX_PG_bone_collection_list_item, + PSX_UL_bone_collection_list, PSX_PT_action )