Start of Tools Panel

Several Improvements and etc. Still need to do the other half of the functions but getting there.
This commit is contained in:
Yusarina
2024-12-05 13:36:25 +00:00
parent 9cc5a41a98
commit 5ce3f9ff68
9 changed files with 634 additions and 2 deletions
+26 -1
View File
@@ -1,6 +1,6 @@
import bpy import bpy
import numpy as np 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 typing import Optional, Tuple, List, Set, Dict, Any, Generator, Callable
from ..core.logging_setup import logger from ..core.logging_setup import logger
from ..core.translations import t 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) 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)) 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 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']
+56
View File
@@ -110,3 +110,59 @@ dont_delete_these_main_bones = [
'RingFinger1_R', 'RingFinger2_R', 'RingFinger3_R', 'RingFinger1_R', 'RingFinger2_R', 'RingFinger3_R',
'LittleFinger1_R', 'LittleFinger2_R', 'LittleFinger3_R', 'LittleFinger1_R', 'LittleFinger2_R', 'LittleFinger3_R',
] ]
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"
}
+14
View File
@@ -86,6 +86,20 @@ class AvatarToolkitSceneProperties(PropertyGroup):
default=False 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: def register() -> None:
"""Register the Avatar Toolkit property group""" """Register the Avatar Toolkit property group"""
logger.info("Registering Avatar Toolkit properties") logger.info("Registering Avatar Toolkit properties")
View File
+226
View File
@@ -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 "<noik>" not in bone.name:
bone.name = bone.name.split('.')[0] + "<noik>"
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'}
+89
View File
@@ -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 "<noik>" tags
bone.name = re.compile(re.escape("<noik>"), 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 + "<noik>"
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'}
+68
View File
@@ -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'}
+50
View File
@@ -125,6 +125,56 @@
"Optimization.processing_shapekey": "Processing shape key: {name}", "Optimization.processing_shapekey": "Processing shape key: {name}",
"Optimization.remove_doubles_completed": "Remove doubles completed successfully", "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.label": "Settings",
"Settings.language": "Language", "Settings.language": "Language",
"Settings.language_desc": "Select interface language", "Settings.language_desc": "Select interface language",
+104
View File
@@ -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')