diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index 275ec62..7723bc4 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -13,7 +13,9 @@ class PsaBuildSequence: self.frame_start: int = 0 self.frame_end: int = 0 - def __init__(self): + def __init__(self, armature_object: Object, anim_data: AnimData): + self.armature_object = armature_object + self.anim_data = anim_data self.name: str = '' self.nla_state: PsaBuildSequence.NlaState = PsaBuildSequence.NlaState() self.compression_ratio: float = 1.0 @@ -30,6 +32,7 @@ class PsaBuildOptions: self.sequence_name_prefix: str = '' self.sequence_name_suffix: str = '' self.root_motion: bool = False + self.scale = 1.0 def _get_pose_bone_location_and_rotation(pose_bone: PoseBone, armature_object: Object, options: PsaBuildOptions): @@ -48,6 +51,8 @@ def _get_pose_bone_location_and_rotation(pose_bone: PoseBone, armature_object: O location = pose_bone_matrix.to_translation() rotation = pose_bone_matrix.to_quaternion().normalized() + location *= options.scale + if pose_bone.parent is not None: rotation.conjugate() @@ -67,9 +72,6 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: # 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] - 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] # 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) @@ -77,12 +79,11 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: # 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: raise RuntimeError('No bones available for export') - + # Build list of PSA bones. for bone in bones: psa_bone = Psa.Bone() @@ -140,8 +141,14 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: context.window_manager.progress_begin(0, len(options.sequences)) for export_sequence_index, export_sequence in enumerate(options.sequences): + # Look up the pose bones for the bones that are going to be exported. + pose_bones = [(bone_names.index(bone.name), bone) for bone in export_sequence.armature_object.pose.bones] + pose_bones.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. - options.animation_data.action = export_sequence.nla_state.action + export_sequence.anim_data.action = export_sequence.nla_state.action context.view_layer.update() frame_start = export_sequence.nla_state.frame_start @@ -181,7 +188,7 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: context.scene.frame_set(frame=int(frame), subframe=frame % 1.0) for pose_bone in pose_bones: - location, rotation = _get_pose_bone_location_and_rotation(pose_bone, armature_object, options) + location, rotation = _get_pose_bone_location_and_rotation(pose_bone, export_sequence.armature_object, options) key = Psa.Key() key.location.x = location.x diff --git a/io_scene_psk_psa/psa/export/operators.py b/io_scene_psk_psa/psa/export/operators.py index 87316c6..ea24d58 100644 --- a/io_scene_psk_psa/psa/export/operators.py +++ b/io_scene_psk_psa/psa/export/operators.py @@ -1,6 +1,6 @@ import re from collections import Counter -from typing import List, Iterable, Dict, Tuple +from typing import List, Iterable, Dict, Tuple, Optional import bpy from bpy.props import StringProperty @@ -229,40 +229,41 @@ class PSA_OT_export(Operator, ExportHelper): flow.use_property_decorate = False flow.prop(pg, 'sequence_source', text='Source') - if pg.sequence_source in {'TIMELINE_MARKERS', 'NLA_TRACK_STRIPS'}: - # ANIMDATA SOURCE - flow.prop(pg, 'should_override_animation_data') - if pg.should_override_animation_data: - flow.prop(pg, 'animation_data_override', text=' ') + if pg.sequence_source != 'ACTIVE_ACTION': + if pg.sequence_source in {'TIMELINE_MARKERS', 'NLA_TRACK_STRIPS'}: + # ANIMDATA SOURCE + flow.prop(pg, 'should_override_animation_data') + if pg.should_override_animation_data: + flow.prop(pg, 'animation_data_override', text=' ') - if pg.sequence_source == 'NLA_TRACK_STRIPS': - flow = sequences_panel.grid_flow() - flow.use_property_split = True - flow.use_property_decorate = False - flow.prop(pg, 'nla_track') + if pg.sequence_source == 'NLA_TRACK_STRIPS': + flow = sequences_panel.grid_flow() + flow.use_property_split = True + flow.use_property_decorate = False + flow.prop(pg, 'nla_track') - # SELECT ALL/NONE - row = sequences_panel.row(align=True) - row.label(text='Select') - row.operator(PSA_OT_export_actions_select_all.bl_idname, text='All', icon='CHECKBOX_HLT') - row.operator(PSA_OT_export_actions_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT') + # SELECT ALL/NONE + row = sequences_panel.row(align=True) + row.label(text='Select') + row.operator(PSA_OT_export_actions_select_all.bl_idname, text='All', icon='CHECKBOX_HLT') + row.operator(PSA_OT_export_actions_deselect_all.bl_idname, text='None', icon='CHECKBOX_DEHLT') - from .ui import PSA_UL_export_sequences + from .ui import PSA_UL_export_sequences - def get_sequences_propnames_from_source(sequence_source: str) -> Tuple[str, str]: - match sequence_source: - case 'ACTIONS': - return 'action_list', 'action_list_index' - case 'TIMELINE_MARKERS': - return 'marker_list', 'marker_list_index' - case 'NLA_TRACK_STRIPS': - return 'nla_strip_list', 'nla_strip_list_index' - case _: - raise ValueError(f'Unhandled sequence source: {sequence_source}') + def get_sequences_propnames_from_source(sequence_source: str) -> Optional[Tuple[str, str]]: + match sequence_source: + case 'ACTIONS': + return 'action_list', 'action_list_index' + case 'TIMELINE_MARKERS': + return 'marker_list', 'marker_list_index' + case 'NLA_TRACK_STRIPS': + return 'nla_strip_list', 'nla_strip_list_index' + case _: + raise ValueError(f'Unhandled sequence source: {sequence_source}') - propname, active_propname = get_sequences_propnames_from_source(pg.sequence_source) - sequences_panel.template_list(PSA_UL_export_sequences.bl_idname, '', pg, propname, pg, active_propname, - rows=max(3, min(len(getattr(pg, propname)), 10))) + propname, active_propname = get_sequences_propnames_from_source(pg.sequence_source) + sequences_panel.template_list(PSA_UL_export_sequences.bl_idname, '', pg, propname, pg, active_propname, + rows=max(3, min(len(getattr(pg, propname)), 10))) flow = sequences_panel.grid_flow() flow.use_property_split = True @@ -301,15 +302,16 @@ 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) - # ADVANCED - advanced_header, advanced_panel = layout.panel('Advanced', default_closed=False) - advanced_header.label(text='Advanced') + # TRANSFORM + transform_header, transform_panel = layout.panel('Advanced', default_closed=False) + transform_header.label(text='Transform') - if advanced_panel: - flow = advanced_panel.grid_flow() + if transform_panel: + flow = transform_panel.grid_flow() flow.use_property_split = True flow.use_property_decorate = False flow.prop(pg, 'root_motion', text='Root Motion') + flow.prop(pg, 'scale', text='Scale') @classmethod def _check_context(cls, context): @@ -317,7 +319,16 @@ class PSA_OT_export(Operator, ExportHelper): raise RuntimeError('An armature must be selected') if context.view_layer.objects.active.type != 'ARMATURE': - raise RuntimeError('The selected object must be an 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}\')') def invoke(self, context, _event): try: @@ -362,14 +373,16 @@ class PSA_OT_export(Operator, ExportHelper): export_sequences: List[PsaBuildSequence] = [] + selected_armature_objects = [obj for obj in context.selected_objects if obj.type == 'ARMATURE'] + match pg.sequence_source: case 'ACTIONS': for action_item in filter(lambda x: x.is_selected, pg.action_list): if len(action_item.action.fcurves) == 0: continue - export_sequence = PsaBuildSequence() - export_sequence.nla_state.action = action_item.action + export_sequence = PsaBuildSequence(context.active_object, animation_data) export_sequence.name = action_item.name + export_sequence.nla_state.action = action_item.action export_sequence.nla_state.frame_start = action_item.frame_start export_sequence.nla_state.frame_end = action_item.frame_end export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [action_item.action]) @@ -378,9 +391,8 @@ class PSA_OT_export(Operator, ExportHelper): export_sequences.append(export_sequence) case 'TIMELINE_MARKERS': for marker_item in filter(lambda x: x.is_selected, pg.marker_list): - export_sequence = PsaBuildSequence() + export_sequence = PsaBuildSequence(context.active_object, animation_data) export_sequence.name = marker_item.name - export_sequence.nla_state.action = None export_sequence.nla_state.frame_start = marker_item.frame_start export_sequence.nla_state.frame_end = marker_item.frame_end nla_strips_actions = set( @@ -389,15 +401,28 @@ class PSA_OT_export(Operator, ExportHelper): export_sequences.append(export_sequence) case 'NLA_TRACK_STRIPS': for nla_strip_item in filter(lambda x: x.is_selected, pg.nla_strip_list): - export_sequence = PsaBuildSequence() + export_sequence = PsaBuildSequence(context.active_object, animation_data) export_sequence.name = nla_strip_item.name - export_sequence.nla_state.action = None export_sequence.nla_state.frame_start = nla_strip_item.frame_start export_sequence.nla_state.frame_end = nla_strip_item.frame_end export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [nla_strip_item.action]) export_sequence.compression_ratio = nla_strip_item.action.psa_export.compression_ratio export_sequence.key_quota = nla_strip_item.action.psa_export.key_quota export_sequences.append(export_sequence) + case 'ACTIVE_ACTION': + for obj in selected_armature_objects: + if obj.animation_data is None or obj.animation_data.action is None: + continue + action = obj.animation_data.action + export_sequence = PsaBuildSequence(obj, obj.animation_data) + export_sequence.name = action.name + export_sequence.nla_state.action = action + export_sequence.nla_state.frame_start = int(action.frame_range[0]) + export_sequence.nla_state.frame_end = int(action.frame_range[1]) + export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [action]) + export_sequence.compression_ratio = action.psa_export.compression_ratio + export_sequence.key_quota = action.psa_export.key_quota + export_sequences.append(export_sequence) case _: raise ValueError(f'Unhandled sequence source: {pg.sequence_source}') @@ -409,6 +434,7 @@ class PSA_OT_export(Operator, ExportHelper): options.sequence_name_prefix = pg.sequence_name_prefix options.sequence_name_suffix = pg.sequence_name_suffix options.root_motion = pg.root_motion + options.scale = pg.scale 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 df9a7a1..a073ce5 100644 --- a/io_scene_psk_psa/psa/export/properties.py +++ b/io_scene_psk_psa/psa/export/properties.py @@ -123,7 +123,8 @@ class PSA_PG_export(PropertyGroup): items=( ('ACTIONS', 'Actions', 'Sequences will be exported using actions', 'ACTION', 0), ('TIMELINE_MARKERS', 'Timeline Markers', 'Sequences are delineated by scene timeline markers', 'MARKER_HLT', 1), - ('NLA_TRACK_STRIPS', 'NLA Track Strips', 'Sequences are delineated by the start & end times of strips on the selected NLA track', 'NLA', 2) + ('NLA_TRACK_STRIPS', 'NLA Track Strips', 'Sequences are delineated by the start & end times of strips on the selected NLA track', 'NLA', 2), + ('ACTIVE_ACTION', 'Active Action', 'The active action will be exported for each selected armature', 'ACTION', 3), ) ) nla_track: StringProperty( @@ -188,6 +189,13 @@ class PSA_PG_export(PropertyGroup): name='Show Reversed', description='Show reversed sequences' ) + scale: FloatProperty( + name='Scale', + default=1.0, + description='Scale factor to apply to the bone translations. Use this if you are exporting animations for a scaled PSK mesh', + min=0.0, + soft_max=100.0 + ) def filter_sequences(pg: PSA_PG_export, sequences) -> List[int]: diff --git a/io_scene_psk_psa/psk/builder.py b/io_scene_psk_psa/psk/builder.py index bf9c953..aa01c45 100644 --- a/io_scene_psk_psa/psk/builder.py +++ b/io_scene_psk_psa/psk/builder.py @@ -3,8 +3,8 @@ from typing import Optional import bmesh import numpy as np -from bpy.types import Material, Collection, Context, Mesh -from mathutils import Matrix +from bpy.types import Material, Collection, Context +from mathutils import Matrix, Vector from .data import * from .properties import triangle_type_and_bit_flags_to_poly_flags @@ -26,6 +26,36 @@ class PskBuildOptions(object): self.materials: List[Material] = [] self.scale = 1.0 self.export_space = 'WORLD' + self.forward_axis = 'X' + self.up_axis = 'Z' + + +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) + )).inverted() def get_mesh_objects_for_collection(collection: Collection) -> Iterable[DfsObject]: @@ -65,11 +95,6 @@ def _get_psk_input_objects(mesh_objects: Iterable[DfsObject]) -> PskInputObjects if len(mesh_objects) == 0: raise RuntimeError('At least one mesh must be selected') - for dfs_object in mesh_objects: - mesh_data = cast(Mesh, dfs_object.obj.data) - if len(mesh_data.materials) == 0: - raise RuntimeError(f'Mesh "{dfs_object.obj.name}" must have at least one material') - input_objects = PskInputObjects() input_objects.mesh_objects = mesh_objects input_objects.armature_object = get_armature_for_mesh_objects([x.obj for x in mesh_objects]) @@ -107,12 +132,18 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) case 'WORLD': return Matrix.Identity(4) case 'ARMATURE': - return armature_object.matrix_world.inverted() + if armature_object is not None: + return armature_object.matrix_world.inverted() + else: + return Matrix.Identity(4) case _: raise ValueError(f'Invalid export space: {options.export_space}') + coordinate_system_matrix = get_coordinate_system_transform(options.forward_axis, options.up_axis) + coordinate_system_default_rotation = coordinate_system_matrix.to_quaternion() + export_space_matrix = get_export_space_matrix() # TODO: maybe neutralize the scale here? - scale_matrix = Matrix.Scale(options.scale, 4) + scale_matrix = coordinate_system_matrix @ Matrix.Scale(options.scale, 4) if armature_object is None or len(armature_object.data.bones) == 0: # If the mesh has no armature object or no bones, simply assign it a dummy bone at the root to satisfy the @@ -123,7 +154,7 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) psk_bone.children_count = 0 psk_bone.parent_index = 0 psk_bone.location = Vector3.zero() - psk_bone.rotation = Quaternion.identity() + psk_bone.rotation = coordinate_system_default_rotation psk.bones.append(psk_bone) else: bone_names = get_export_bone_names(armature_object, options.bone_filter_mode, options.bone_collection_indices) @@ -169,6 +200,7 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) local_rotation = armature_local_matrix.to_3x3().to_quaternion().conjugated() rotation = bone_rotation @ local_rotation rotation.conjugate() + rotation = coordinate_system_default_rotation @ rotation location = scale_matrix @ location @@ -178,8 +210,6 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) location.y *= armature_object_scale.y location.z *= armature_object_scale.z - print(bone.name, location) - psk_bone.location.x = location.x psk_bone.location.y = location.y psk_bone.location.z = location.z @@ -203,6 +233,17 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) material.psk.mesh_triangle_bit_flags) psk.materials.append(psk_material) + # TODO: This wasn't left in a good state. We should detect if we need to add a "default" material. + # This can be done by checking if there is an empty material slot on any of the mesh objects, or if there are + # no material slots on any of the mesh objects. + # If so, it should be added to the end of the list of materials, and its index should mapped to a None value in the + # material indices list. + if len(psk.materials) == 0: + # Add a default material if no materials are present. + psk_material = Psk.Material() + psk_material.name = bytes('None', encoding='windows-1252') + psk.materials.append(psk_material) + context.window_manager.progress_begin(0, len(input_objects.mesh_objects)) material_names = [m.name for m in options.materials] @@ -213,8 +254,26 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) should_flip_normals = False + def get_material_name_indices(obj: Object, material_names: List[str]) -> Iterable[int]: + ''' + Returns the index of the material in the list of material names. + If the material is not found, the index 0 is returned. + ''' + for material_slot in obj.material_slots: + if material_slot.material is None: + yield 0 + else: + try: + yield material_names.index(material_slot.material.name) + except ValueError: + yield 0 + # MATERIALS - material_indices = [material_names.index(material_slot.material.name) for material_slot in obj.material_slots] + material_indices = list(get_material_name_indices(obj, material_names)) + + if len(material_indices) == 0: + # Add a default material if no materials are present. + material_indices = [0] # MESH DATA match options.object_eval_state: @@ -233,7 +292,13 @@ def build_psk(context, input_objects: PskInputObjects, options: PskBuildOptions) depsgraph = context.evaluated_depsgraph_get() bm = bmesh.new() - bm.from_object(obj, depsgraph) + + try: + bm.from_object(obj, depsgraph) + except ValueError: + raise RuntimeError(f'Object "{obj.name}" is not evaluated.\n' + 'This is likely because the object is in a collection that has been excluded from the view layer.') + mesh_data = bpy.data.meshes.new('') bm.to_mesh(mesh_data) del bm diff --git a/io_scene_psk_psa/psk/export/operators.py b/io_scene_psk_psa/psk/export/operators.py index 5afb644..342ff7e 100644 --- a/io_scene_psk_psa/psk/export/operators.py +++ b/io_scene_psk_psa/psk/export/operators.py @@ -117,6 +117,36 @@ class PSK_OT_material_list_move_down(Operator): empty_set = set() +axis_identifiers = ('X', 'Y', 'Z', '-X', '-Y', '-Z') + +forward_items = ( + ('X', 'X Forward', ''), + ('Y', 'Y Forward', ''), + ('Z', 'Z Forward', ''), + ('-X', '-X Forward', ''), + ('-Y', '-Y Forward', ''), + ('-Z', '-Z Forward', ''), +) + +up_items = ( + ('X', 'X Up', ''), + ('Y', 'Y Up', ''), + ('Z', 'Z Up', ''), + ('-X', '-X Up', ''), + ('-Y', '-Y Up', ''), + ('-Z', '-Z Up', ''), +) + +def forward_axis_update(self, context): + if self.forward_axis == self.up_axis: + # Automatically set the up axis to the next available axis + self.up_axis = next((axis for axis in axis_identifiers if axis != self.forward_axis), 'Z') + + +def up_axis_update(self, context): + if self.up_axis == self.forward_axis: + # Automatically set the forward axis to the next available axis + self.forward_axis = next((axis for axis in axis_identifiers if axis != self.up_axis), 'X') class PSK_OT_export_collection(Operator, ExportHelper): bl_idname = 'export.psk_collection' @@ -163,7 +193,18 @@ class PSK_OT_export_collection(Operator, ExportHelper): ) bone_collection_list: CollectionProperty(type=PSX_PG_bone_collection_list_item) bone_collection_list_index: IntProperty(default=0) - + forward_axis: EnumProperty( + name='Forward', + items=forward_items, + default='X', + update=forward_axis_update + ) + up_axis: EnumProperty( + name='Up', + items=up_items, + default='Z', + update=up_axis_update + ) def execute(self, context): collection = bpy.data.collections.get(self.collection) @@ -181,6 +222,8 @@ class PSK_OT_export_collection(Operator, ExportHelper): options.export_space = self.export_space options.bone_filter_mode = self.bone_filter_mode options.bone_collection_indices = [x.index for x in self.bone_collection_list if x.is_selected] + options.forward_axis = self.forward_axis + options.up_axis = self.up_axis try: result = build_psk(context, input_objects, options) @@ -204,9 +247,6 @@ class PSK_OT_export_collection(Operator, ExportHelper): flow.use_property_split = True flow.use_property_decorate = False - flow.prop(self, 'scale') - flow.prop(self, 'export_space') - # MESH mesh_header, mesh_panel = layout.panel('Mesh', default_closed=False) mesh_header.label(text='Mesh', icon='MESH_DATA') @@ -227,6 +267,18 @@ class PSK_OT_export_collection(Operator, ExportHelper): 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) + # TRANSFORM + transform_header, transform_panel = layout.panel('Transform', default_closed=False) + transform_header.label(text='Transform') + if transform_panel: + flow = transform_panel.grid_flow(row_major=True) + flow.use_property_split = True + flow.use_property_decorate = False + flow.prop(self, 'export_space') + flow.prop(self, 'scale') + flow.prop(self, 'forward_axis') + flow.prop(self, 'up_axis') + class PSK_OT_export(Operator, ExportHelper): bl_idname = 'export.psk' diff --git a/io_scene_psk_psa/psk/export/properties.py b/io_scene_psk_psa/psk/export/properties.py index 2096015..c312188 100644 --- a/io_scene_psk_psa/psk/export/properties.py +++ b/io_scene_psk_psa/psk/export/properties.py @@ -1,6 +1,7 @@ from bpy.props import EnumProperty, CollectionProperty, IntProperty, PointerProperty, FloatProperty from bpy.types import PropertyGroup, Material +from ...shared.data import bone_filter_mode_items from ...shared.types import PSX_PG_bone_collection_list_item empty_set = set()