diff --git a/core/common.py b/core/common.py index b5ee6ce..69609e0 100644 --- a/core/common.py +++ b/core/common.py @@ -1,6 +1,6 @@ import bpy import numpy as np -from bpy.types import Context, Object, Modifier +from bpy.types import Context, Object, Modifier, EditBone from typing import Optional, Tuple, List, Set, Dict, Any, Generator, Callable from ..core.logging_setup import logger from ..core.translations import t @@ -385,3 +385,28 @@ def clear_unused_data_blocks(self) -> int: bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) final_count: int = sum(len(getattr(bpy.data, attr)) for attr in dir(bpy.data) if isinstance(getattr(bpy.data, attr), bpy.types.bpy_prop_collection)) return initial_count - final_count + +def simplify_bonename(name: str) -> str: + """Simplify bone name by removing spaces, underscores, dots and converting to lowercase""" + return name.lower().translate(dict.fromkeys(map(ord, u" _."))) + +def duplicate_bone_chain(bones: List[EditBone]) -> List[EditBone]: + """Duplicate a chain of bones while preserving hierarchy""" + new_bones = [] + parent_map = {} + + for bone in bones: + new_bone = duplicate_bone(bone) + if bone.parent and bone.parent in parent_map: + new_bone.parent = parent_map[bone.parent] + parent_map[bone] = new_bone + new_bones.append(new_bone) + + return new_bones + +def restore_bone_transforms(bone: EditBone, transforms: Dict[str, Any]) -> None: + """Restore bone transforms from stored data""" + bone.head = transforms['head'] + bone.tail = transforms['tail'] + bone.roll = transforms['roll'] + bone.matrix = transforms['matrix'] diff --git a/core/dictionaries.py b/core/dictionaries.py index 3d5235d..26f0a0d 100644 --- a/core/dictionaries.py +++ b/core/dictionaries.py @@ -109,4 +109,60 @@ dont_delete_these_main_bones = [ 'MiddleFinger1_R', 'MiddleFinger2_R', 'MiddleFinger3_R', 'RingFinger1_R', 'RingFinger2_R', 'RingFinger3_R', 'LittleFinger1_R', 'LittleFinger2_R', 'LittleFinger3_R', -] \ No newline at end of file +] + +resonite_translations = { + 'hips': "Hips", + 'spine': "Spine", + 'chest': "Chest", + 'neck': "Neck", + 'head': "Head", + 'left_eye': "Eye.L", + 'right_eye': "Eye.R", + 'right_leg': "UpperLeg.R", + 'right_knee': "Calf.R", + 'right_ankle': "Foot.R", + 'right_toe': 'Toes.R', + 'right_shoulder': "Shoulder.R", + 'right_arm': "UpperArm.R", + 'right_elbow': "ForeArm.R", + 'right_wrist': "Hand.R", + 'left_leg': "UpperLeg.L", + 'left_knee': "Calf.L", + 'left_ankle': "Foot.L", + 'left_toe': "Toes.L", + 'left_shoulder': "Shoulder.L", + 'left_arm': "UpperArm.L", + 'left_elbow': "ForeArm.L", + 'left_wrist': "Hand.L", + 'pinkie_1_l': "pinkie1.L", + 'pinkie_2_l': "pinkie2.L", + 'pinkie_3_l': "pinkie3.L", + 'ring_1_l': "ring1.L", + 'ring_2_l': "ring2.L", + 'ring_3_l': "ring3.L", + 'middle_1_l': "middle1.L", + 'middle_2_l': "middle2.L", + 'middle_3_l': "middle3.L", + 'index_1_l': "index1.L", + 'index_2_l': "index2.L", + 'index_3_l': "index3.L", + 'thumb_1_l': "thumb1.L", + 'thumb_2_l': "thumb2.L", + 'thumb_3_l': "thumb3.L", + 'pinkie_1_r': "pinkie1.R", + 'pinkie_2_r': "pinkie2.R", + 'pinkie_3_r': "pinkie3.R", + 'ring_1_r': "ring1.R", + 'ring_2_r': "ring2.R", + 'ring_3_r': "ring3.R", + 'middle_1_r': "middle1.R", + 'middle_2_r': "middle2.R", + 'middle_3_r': "middle3.R", + 'index_1_r': "index1.R", + 'index_2_r': "index2.R", + 'index_3_r': "index3.R", + 'thumb_1_r': "thumb1.R", + 'thumb_2_r': "thumb2.R", + 'thumb_3_r': "thumb3.R" +} diff --git a/core/properties.py b/core/properties.py index edbc96d..dc90af1 100644 --- a/core/properties.py +++ b/core/properties.py @@ -86,6 +86,20 @@ class AvatarToolkitSceneProperties(PropertyGroup): default=False ) + merge_twist_bones: BoolProperty( + name=t("Tools.merge_twist_bones"), + description=t("Tools.merge_twist_bones_desc"), + default=True + ) + + clean_weights_threshold: FloatProperty( + name=t("Tools.clean_weights_threshold"), + description=t("Tools.clean_weights_threshold_desc"), + default=0.01, + min=0.0000001, + max=0.9999999 + ) + def register() -> None: """Register the Avatar Toolkit property group""" logger.info("Registering Avatar Toolkit properties") diff --git a/functions/tools/__init__.py b/functions/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/functions/tools/bone_tools.py b/functions/tools/bone_tools.py new file mode 100644 index 0000000..5cd7b4d --- /dev/null +++ b/functions/tools/bone_tools.py @@ -0,0 +1,226 @@ +import bpy +import re +from bpy.types import Operator, Context, EditBone, Object, Armature, Mesh +from typing import Optional, Dict, Any, List, Tuple +from ...core.translations import t +from ...core.common import ( + get_active_armature, + validate_armature, + get_all_meshes, + ProgressTracker, + validate_bone_hierarchy, + restore_bone_transforms +) + +def duplicate_bone(bone: EditBone) -> EditBone: + """Create a duplicate of the given bone""" + arm = bone.id_data + new_bone = arm.edit_bones.new(bone.name + "_copy") + new_bone.head = bone.head + new_bone.tail = bone.tail + new_bone.roll = bone.roll + new_bone.parent = bone.parent + return new_bone + +class AvatarToolKit_OT_CreateDigitigradeLegs(Operator): + """Operator to convert standard legs to digitigrade setup""" + bl_idname = "avatar_toolkit.create_digitigrade" + bl_label = t("Tools.create_digitigrade") + bl_description = t("Tools.create_digitigrade_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + """Check if operator can be executed""" + armature = get_active_armature(context) + if not armature: + return False + is_valid, _ = validate_armature(armature) + return (is_valid and + context.mode == 'EDIT_ARMATURE' and + context.selected_editable_bones is not None and + len(context.selected_editable_bones) == 2) + + def store_bone_chain_data(self, digi0: EditBone) -> Dict[str, Any]: + """Store initial bone chain data""" + chain_data = {} + current = digi0 + while current: + chain_data[current.name] = { + 'head': current.head.copy(), + 'tail': current.tail.copy(), + 'roll': current.roll, + 'matrix': current.matrix.copy(), + 'parent': current.parent.name if current.parent else None + } + if current.children: + current = current.children[0] + else: + break + return chain_data + + def process_leg_chain(self, digi0: EditBone) -> bool: + """Process a single leg bone chain""" + try: + # Get bone chain + digi1: EditBone = digi0.children[0] + digi2: EditBone = digi1.children[0] + digi3: EditBone = digi2.children[0] + digi4: Optional[EditBone] = digi3.children[0] if digi3.children else None + + # Clear roll for all bones + for bone in [digi0, digi1, digi2, digi3] + ([digi4] if digi4 else []): + bone.select = True + bpy.ops.armature.roll_clear() + bpy.ops.armature.select_all(action='DESELECT') + + # Create thigh bone + thigh = duplicate_bone(digi0) + base_name = digi0.name.split('.')[0] + thigh.name = base_name + + # Create and position calf bone + calf = duplicate_bone(digi1) + calf.name = digi1.name.split('.')[0] + calf.parent = thigh + + # Calculate new positions + midpoint = (digi1.tail + digi2.tail) * 0.5 + calf.head = thigh.tail + calf.tail = midpoint + + # Reparent foot to new calf + digi3.parent = calf + + # Mark original bones as non-IK + for bone in [digi0, digi1, digi2]: + if "" not in bone.name: + bone.name = bone.name.split('.')[0] + "" + + return True + + except Exception as e: + self.report({'ERROR'}, t("Tools.digitigrade_error", error=str(e))) + return False + + def execute(self, context: Context) -> set[str]: + """Execute the digitigrade conversion""" + bpy.ops.object.mode_set(mode='EDIT') + + with ProgressTracker(context, len(context.selected_editable_bones), t("Tools.digitigrade")) as progress: + for digi0 in context.selected_editable_bones: + progress.step(t("Tools.processing_leg", bone=digi0.name)) + if not self.process_leg_chain(digi0): + return {'CANCELLED'} + + self.report({'INFO'}, t("Tools.digitigrade_success")) + return {'FINISHED'} + +class AvatarToolKit_OT_DeleteBoneConstraints(Operator): + """Operator to remove all bone constraints from armature""" + bl_idname = "avatar_toolkit.clean_constraints" + bl_label = t("Tools.clean_constraints") + bl_description = t("Tools.clean_constraints_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + """Check if operator can be executed""" + armature = get_active_armature(context) + if not armature: + return False + is_valid, _ = validate_armature(armature) + return is_valid + + def execute(self, context: Context) -> set[str]: + """Execute the constraint removal operation""" + armature = get_active_armature(context) + bpy.ops.object.mode_set(mode='POSE') + + constraints_removed = 0 + for bone in armature.pose.bones: + while bone.constraints: + bone.constraints.remove(bone.constraints[0]) + constraints_removed += 1 + + bpy.ops.object.mode_set(mode='OBJECT') + self.report({'INFO'}, t("Tools.clean_constraints_success", count=constraints_removed)) + return {'FINISHED'} + +class AvatarToolKit_OT_RemoveZeroWeightBones(Operator): + """Operator to remove bones with no vertex weights""" + bl_idname = "avatar_toolkit.clean_weights" + bl_label = t("Tools.clean_weights") + bl_description = t("Tools.clean_weights_desc") + bl_options = {'REGISTER', 'UNDO'} + + def should_preserve_bone(self, bone_name: str, context: Context) -> bool: + """Check if bone should be preserved based on settings""" + if context.scene.avatar_toolkit.merge_twist_bones: + return "twist" in bone_name.lower() + return False + + def execute(self, context: Context) -> set[str]: + """Execute the zero weight bone removal operation""" + armature = get_active_armature(context) + if not armature: + return {'CANCELLED'} + + # Store initial transforms + bpy.ops.object.mode_set(mode='EDIT') + initial_transforms: Dict[str, Dict[str, Any]] = {} + for bone in armature.data.edit_bones: + initial_transforms[bone.name] = { + 'head': bone.head.copy(), + 'tail': bone.tail.copy(), + 'roll': bone.roll, + 'matrix': bone.matrix.copy(), + 'parent': bone.parent.name if bone.parent else None + } + + # Get weighted bones + weighted_bones: List[str] = [] + meshes = get_all_meshes(context) + + for mesh in meshes: + mesh_data: Mesh = mesh.data + for vertex in mesh_data.vertices: + for group in vertex.groups: + if group.weight > context.scene.avatar_toolkit.clean_weights_threshold: + weighted_bones.append(mesh.vertex_groups[group.group].name) + + # Process bone removal + bpy.ops.object.mode_set(mode='EDIT') + armature_data: Armature = armature.data + removed_count = 0 + + for bone in armature_data.edit_bones[:]: # Create a copy of the list + if (bone.name not in weighted_bones and + not self.should_preserve_bone(bone.name, context)): + + # Store children data + children = bone.children + children_data = {child.name: initial_transforms[child.name] for child in children} + + # Reparent children + for child in children: + child.use_connect = False + if bone.parent: + child.parent = bone.parent + + # Remove bone + armature_data.edit_bones.remove(bone) + removed_count += 1 + + # Restore children positions + for child_name, data in children_data.items(): + if child_name in armature_data.edit_bones: + child = armature_data.edit_bones[child_name] + child.head = data['head'] + child.tail = data['tail'] + child.roll = data['roll'] + child.matrix = data['matrix'] + + bpy.ops.object.mode_set(mode='OBJECT') + self.report({'INFO'}, t("Tools.clean_weights_success", count=removed_count)) + return {'FINISHED'} \ No newline at end of file diff --git a/functions/tools/convert_resonite.py b/functions/tools/convert_resonite.py new file mode 100644 index 0000000..8ab5d99 --- /dev/null +++ b/functions/tools/convert_resonite.py @@ -0,0 +1,89 @@ +import bpy +import re +from typing import Set, Dict, Optional +from bpy.types import Operator, Context +from ...core.translations import t +from ...core.logging_setup import logger +from ...core.common import get_active_armature, simplify_bonename, validate_armature, ProgressTracker +from ...core.dictionaries import bone_names, resonite_translations + +class AvatarToolkit_OT_ConvertResonite(Operator): + """Convert armature bone names to Resonite format with progress tracking and validation""" + bl_idname = "avatar_toolkit.convert_resonite" + bl_label = t("Tools.convert_resonite") + bl_description = t("Tools.convert_resonite_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + armature = get_active_armature(context) + if not armature: + return False + is_valid, _ = validate_armature(armature) + return is_valid + + def execute(self, context: Context) -> Set[str]: + armature = get_active_armature(context) + if not armature: + logger.warning("No armature selected for Resonite conversion") + self.report({'WARNING'}, t("Armature.validation.no_armature")) + return {'CANCELLED'} + + translate_bone_fails: int = 0 + untranslated_bones: Set[str] = set() + simplified_names: Dict[str, str] = {} + + # Create reverse lookup dictionary + reverse_bone_lookup = {} + for preferred_name, name_list in bone_names.items(): + for name in name_list: + reverse_bone_lookup[name] = preferred_name + + try: + context.view_layer.objects.active = armature + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.object.mode_set(mode='OBJECT') + + # Cache simplified bone names + for bone in armature.data.bones: + simplified_names[bone.name] = simplify_bonename(bone.name) + + total_bones = len(armature.data.bones) + with ProgressTracker(context, total_bones, t("Tools.convert_resonite.operation")) as progress: + for bone in armature.data.bones: + # Remove any existing "" tags + bone.name = re.compile(re.escape(""), re.IGNORECASE).sub("", bone.name) + simplified_name = simplified_names[bone.name] + + if simplified_name in reverse_bone_lookup and reverse_bone_lookup[simplified_name] in resonite_translations: + new_name = resonite_translations[reverse_bone_lookup[simplified_name]] + logger.debug(f"Translating bone: {bone.name} -> {new_name}") + bone.name = new_name + else: + untranslated_bones.add(bone.name) + bone.name = bone.name + "" + translate_bone_fails += 1 + logger.debug(f"Failed to translate bone: {bone.name}") + + progress.step(t("Tools.convert_resonite.processing", name=bone.name)) + + except Exception as e: + logger.error(f"Error during Resonite conversion: {str(e)}") + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} + + finally: + try: + bpy.ops.object.mode_set(mode='OBJECT') + except Exception as e: + logger.warning(f"Error returning to object mode: {str(e)}") + + if translate_bone_fails > 0: + logger.info(f"Conversion completed with {translate_bone_fails} untranslated bones") + logger.debug(f"Untranslated bones: {untranslated_bones}") + self.report({'INFO'}, t("Tools.bones_translated_with_fails", translate_bone_fails=translate_bone_fails)) + else: + logger.info("All bones translated successfully") + self.report({'INFO'}, t("Tools.bones_translated_success")) + + return {'FINISHED'} \ No newline at end of file diff --git a/functions/tools/mesh_separation.py b/functions/tools/mesh_separation.py new file mode 100644 index 0000000..6ffb68d --- /dev/null +++ b/functions/tools/mesh_separation.py @@ -0,0 +1,68 @@ +import bpy +from bpy.types import Operator, Context +from ...core.translations import t +from ...core.common import get_active_armature, validate_armature + +class AvatarToolKit_OT_SeparateByMaterials(Operator): + """Operator to separate mesh by materials""" + bl_idname = "avatar_toolkit.separate_materials" + bl_label = t("Tools.separate_materials") + bl_description = t("Tools.separate_materials_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + """Check if operator can be executed""" + armature = get_active_armature(context) + if not armature: + return False + is_valid, _ = validate_armature(armature) + return (context.active_object and + context.active_object.type == 'MESH' and + is_valid) + + def execute(self, context: Context) -> set[str]: + """Execute the separation operation""" + try: + obj = context.active_object + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_all(action='SELECT') + bpy.ops.mesh.separate(type='MATERIAL') + bpy.ops.object.mode_set(mode='OBJECT') + self.report({'INFO'}, t("Tools.separate_materials_success")) + return {'FINISHED'} + except Exception as e: + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} + +class AvatarToolKit_OT_SeparateByLooseParts(Operator): + """Operator to separate mesh by loose parts""" + bl_idname = "avatar_toolkit.separate_loose" + bl_label = t("Tools.separate_loose") + bl_description = t("Tools.separate_loose_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + """Check if operator can be executed""" + armature = get_active_armature(context) + if not armature: + return False + is_valid, _ = validate_armature(armature) + return (context.active_object and + context.active_object.type == 'MESH' and + is_valid) + + def execute(self, context: Context) -> set[str]: + """Execute the separation operation""" + try: + obj = context.active_object + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_all(action='SELECT') + bpy.ops.mesh.separate(type='LOOSE') + bpy.ops.object.mode_set(mode='OBJECT') + self.report({'INFO'}, t("Tools.separate_loose_success")) + return {'FINISHED'} + except Exception as e: + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index 8bdc720..87acdfb 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -125,6 +125,56 @@ "Optimization.processing_shapekey": "Processing shape key: {name}", "Optimization.remove_doubles_completed": "Remove doubles completed successfully", + "Tools.label": "Tools", + "Tools.general_title": "General Tools", + "Tools.convert_resonite": "Convert to Resonite", + "Tools.convert_resonite_desc": "Convert model for use in Resonite", + "Tools.convert_resonite.operation": "Converting to Resonite", + "Tools.separate_title": "Separation Tools", + "Tools.separate_materials": "By Materials", + "Tools.separate_materials_desc": "Separate mesh by materials", + "Tools.separate_loose": "Loose Parts", + "Tools.separate_loose_desc": "Separate mesh into loose parts", + "Tools.separate_materials_success": "Mesh separated by materials successfully", + "Tools.separate_loose_success": "Mesh separated into loose parts successfully", + "Tools.bone_title": "Bone Tools", + "Tools.create_digitigrade": "Create Digitigrade Legs", + "Tools.create_digitigrade_desc": "Convert legs to digitigrade setup", + "Tools.digitigrade": "Create Digitigrade Legs", + "Tools.digitigrade_desc": "Convert selected leg bones to digitigrade setup", + "Tools.digitigrade_error": "Failed to create digitigrade legs: {error}", + "Tools.digitigrade_success": "Successfully created digitigrade leg setup", + "Tools.processing_leg": "Processing leg bone: {bone}", + "Tools.merge_twist_bones": "Keep Twist Bones", + "Tools.merge_twist_bones_desc": "When checked, twist bones will be kept, even if there are zero-weight", + "Tools.clean_weights": "Remove Zero Weight Bones", + "Tools.clean_weights_desc": "Remove bones with no vertex weights", + "Tools.clean_constraints": "Delete Bone Constraints", + "Tools.clean_constraints_desc": "Remove all bone constraints from armature", + "Tools.clean_constraints_success": "Removed {count} bone constraints", + "Tools.processing_bone_constraints": "Removing constraints from bone: {bone}", + "Tools.clean_weights_success": "Removed {count} zero-weight bones", + "Tools.clean_weights_threshold": "Weight Threshold", + "Tools.clean_weights_threshold_desc": "Minimum weight value to consider a bone as weighted", + "Tools.merge_title": "Merge Tools", + "Tools.merge_to_active": "Merge to Active", + "Tools.merge_to_active_desc": "Merge selected bones to active bone", + "Tools.merge_to_parent": "Merge to Parent", + "Tools.merge_to_parent_desc": "Merge bones to their respective parents", + "Tools.connect_bones": "Connect Bones", + "Tools.connect_bones_desc": "Connect disconnected bones in chain", + "Tools.additional_title": "Additional Tools", + "Tools.apply_transforms": "Apply Transforms", + "Tools.apply_transforms_desc": "Apply all transformations to objects", + "Tools.clean_shapekeys": "Remove Unused Shapekeys", + "Tools.clean_shapekeys_desc": "Remove unused shape keys from meshes", + "Tools.bones_translated_success": "All bones translated successfully", + "Tools.bones_translated_with_fails": "Translation completed with {translate_bone_fails} untranslated bones", + "Tools.storing_transforms": "Storing bone transforms...", + "Tools.analyzing_weights": "Analyzing vertex weights...", + "Tools.removing_bones": "Removing unweighted bones...", + "Tools.verifying_hierarchy": "Verifying bone hierarchy...", + "Settings.label": "Settings", "Settings.language": "Language", "Settings.language_desc": "Select interface language", diff --git a/ui/tools_panel.py b/ui/tools_panel.py new file mode 100644 index 0000000..170a6d6 --- /dev/null +++ b/ui/tools_panel.py @@ -0,0 +1,104 @@ +import bpy +from typing import Set +from bpy.types import Panel, Context, UILayout, Operator +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from ..core.translations import t + +# Temporary Operator Classes for UI Preview +class AvatarToolkit_OT_MergeToActive(Operator): + bl_idname = "avatar_toolkit.merge_to_active" + bl_label = "Merge to Active" + + def execute(self, context: Context) -> Set[str]: + return {'FINISHED'} + +class AvatarToolkit_OT_MergeToParent(Operator): + bl_idname = "avatar_toolkit.merge_to_parent" + bl_label = "Merge to Parent" + + def execute(self, context: Context) -> Set[str]: + return {'FINISHED'} + +class AvatarToolkit_OT_ConnectBones(Operator): + bl_idname = "avatar_toolkit.connect_bones" + bl_label = "Connect Bones" + + def execute(self, context: Context) -> Set[str]: + return {'FINISHED'} + +class AvatarToolkit_OT_ApplyTransforms(Operator): + bl_idname = "avatar_toolkit.apply_transforms" + bl_label = "Apply Transforms" + + def execute(self, context: Context) -> Set[str]: + return {'FINISHED'} + +class AvatarToolkit_OT_CleanShapekeys(Operator): + bl_idname = "avatar_toolkit.clean_shapekeys" + bl_label = "Remove Unused Shapekeys" + + def execute(self, context: Context) -> Set[str]: + return {'FINISHED'} + +class AvatarToolKit_PT_ToolsPanel(Panel): + """Panel containing various tools for avatar customization and optimization""" + bl_label: str = t("Tools.label") + bl_idname: str = "OBJECT_PT_avatar_toolkit_tools" + bl_space_type: str = 'VIEW_3D' + bl_region_type: str = 'UI' + bl_category: str = CATEGORY_NAME + bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname + bl_order: int = 2 + + def draw(self, context: Context) -> None: + """Draw the tools panel interface""" + layout: UILayout = self.layout + + # General Tools + tools_box: UILayout = layout.box() + col: UILayout = tools_box.column(align=True) + col.label(text=t("Tools.general_title"), icon='TOOL_SETTINGS') + col.separator(factor=0.5) + col.operator("avatar_toolkit.convert_resonite", text=t("Tools.convert_resonite"), icon='EXPORT') + + # Separation Tools + sep_box: UILayout = layout.box() + col = sep_box.column(align=True) + col.label(text=t("Tools.separate_title"), icon='MOD_EXPLODE') + col.separator(factor=0.5) + row: UILayout = col.row(align=True) + row.operator("avatar_toolkit.separate_materials", text=t("Tools.separate_materials"), icon='MATERIAL') + row.operator("avatar_toolkit.separate_loose", text=t("Tools.separate_loose"), icon='MESH_DATA') + + # Bone Tools + bone_box: UILayout = layout.box() + col = bone_box.column(align=True) + col.label(text=t("Tools.bone_title"), icon='BONE_DATA') + col.separator(factor=0.5) + col.operator("avatar_toolkit.create_digitigrade", text=t("Tools.create_digitigrade"), icon='BONE_DATA') + + # Weight Tools + weight_box: UILayout = bone_box.box() + col = weight_box.column(align=True) + col.prop(context.scene.avatar_toolkit, "merge_twist_bones", text=t("Tools.merge_twist_bones")) + row = col.row(align=True) + row.operator("avatar_toolkit.clean_weights", text=t("Tools.clean_weights"), icon='GROUP_BONE') + row.operator("avatar_toolkit.clean_constraints", text=t("Tools.clean_constraints"), icon='CONSTRAINT_BONE') + + # Merge Tools + merge_box: UILayout = layout.box() + col = merge_box.column(align=True) + col.label(text=t("Tools.merge_title"), icon='AUTOMERGE_ON') + col.separator(factor=0.5) + row = col.row(align=True) + row.operator("avatar_toolkit.merge_to_active", text=t("Tools.merge_to_active"), icon='BONE_DATA') + row.operator("avatar_toolkit.merge_to_parent", text=t("Tools.merge_to_parent"), icon='BONE_DATA') + col.operator("avatar_toolkit.connect_bones", text=t("Tools.connect_bones"), icon='BONE_DATA') + + # Additional Tools + extra_box: UILayout = layout.box() + col = extra_box.column(align=True) + col.label(text=t("Tools.additional_title"), icon='TOOL_SETTINGS') + col.separator(factor=0.5) + col.operator("avatar_toolkit.apply_transforms", text=t("Tools.apply_transforms"), icon='OBJECT_DATA') + col.operator("avatar_toolkit.clean_shapekeys", text=t("Tools.clean_shapekeys"), icon='SHAPEKEY_DATA')