From b1631b2868830fe8eb536f599f775f81844441d5 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Thu, 5 Dec 2024 14:44:41 +0000 Subject: [PATCH] Tools Panel Finished. --- core/common.py | 75 +++++++++++++ core/properties.py | 16 +++ functions/tools/additional_tools.py | 91 ++++++++++++++++ functions/tools/merge_tools.py | 161 ++++++++++++++++++++++++++++ resources/translations/en_US.json | 13 +++ ui/tools_panel.py | 36 ------- 6 files changed, 356 insertions(+), 36 deletions(-) create mode 100644 functions/tools/additional_tools.py create mode 100644 functions/tools/merge_tools.py diff --git a/core/common.py b/core/common.py index 69609e0..4b23aac 100644 --- a/core/common.py +++ b/core/common.py @@ -410,3 +410,78 @@ def restore_bone_transforms(bone: EditBone, transforms: Dict[str, Any]) -> None: bone.tail = transforms['tail'] bone.roll = transforms['roll'] bone.matrix = transforms['matrix'] + +def get_vertex_weights(mesh_obj: Object, group_name: str) -> Dict[int, float]: + """Get vertex weights for a specific vertex group""" + weights = {} + group_index = mesh_obj.vertex_groups[group_name].index + for vertex in mesh_obj.data.vertices: + for group in vertex.groups: + if group.group == group_index: + weights[vertex.index] = group.weight + return weights + +def transfer_vertex_weights(mesh_obj: Object, + source_name: str, + target_name: str, + threshold: float = 0.01) -> None: + """Transfer vertex weights from source to target group""" + if source_name not in mesh_obj.vertex_groups: + return + + source_group = mesh_obj.vertex_groups[source_name] + target_group = mesh_obj.vertex_groups.get(target_name) + + if not target_group: + target_group = mesh_obj.vertex_groups.new(name=target_name) + + # Get source weights + weights = get_vertex_weights(mesh_obj, source_name) + + # Transfer weights above threshold + for vertex_index, weight in weights.items(): + if weight > threshold: + target_group.add([vertex_index], weight, 'ADD') + + # Remove source group + mesh_obj.vertex_groups.remove(source_group) + +def remove_unused_shapekeys(mesh_obj: Object, tolerance: float = 0.001) -> int: + """Remove unused shape keys from a mesh object""" + if not mesh_obj.data.shape_keys: + return 0 + + key_blocks = mesh_obj.data.shape_keys.key_blocks + vertex_count = len(mesh_obj.data.vertices) + removed_count = 0 + + # Cache for relative key locations + cache = {} + locations = np.empty(3 * vertex_count, dtype=np.float32) + to_delete = [] + + for key in key_blocks: + if key == key.relative_key: + continue + + # Get current key locations + key.data.foreach_get("co", locations) + + # Get or calculate relative key locations + if key.relative_key.name not in cache: + rel_locations = np.empty(3 * vertex_count, dtype=np.float32) + key.relative_key.data.foreach_get("co", rel_locations) + cache[key.relative_key.name] = rel_locations + + # Compare locations + locations -= cache[key.relative_key.name] + if (np.abs(locations) < tolerance).all(): + if not any(c in key.name for c in "-=~"): # Skip category markers + to_delete.append(key.name) + + # Remove marked shape keys + for key_name in to_delete: + mesh_obj.shape_key_remove(key_blocks[key_name]) + removed_count += 1 + + return removed_count diff --git a/core/properties.py b/core/properties.py index dc90af1..66e2acf 100644 --- a/core/properties.py +++ b/core/properties.py @@ -100,6 +100,22 @@ class AvatarToolkitSceneProperties(PropertyGroup): max=0.9999999 ) + connect_bones_min_distance: FloatProperty( + name=t("Tools.connect_bones_min_distance"), + description=t("Tools.connect_bones_min_distance_desc"), + default=0.005, + min=0.001, + max=0.1 + ) + + merge_weights_threshold: FloatProperty( + name=t("Tools.merge_weights_threshold"), + description=t("Tools.merge_weights_threshold_desc"), + default=0.01, + min=0.0001, + max=1.0 + ) + def register() -> None: """Register the Avatar Toolkit property group""" logger.info("Registering Avatar Toolkit properties") diff --git a/functions/tools/additional_tools.py b/functions/tools/additional_tools.py new file mode 100644 index 0000000..2b3dd1c --- /dev/null +++ b/functions/tools/additional_tools.py @@ -0,0 +1,91 @@ +import bpy +import numpy as np +from bpy.types import Operator, Context +from typing import Set +from ...core.translations import t +from ...core.logging_setup import logger +from ...core.common import get_active_armature, get_all_meshes, validate_armature, remove_unused_shapekeys + +class AvatarToolkit_OT_ApplyTransforms(Operator): + """Apply all transformations to armature and associated meshes""" + bl_idname = "avatar_toolkit.apply_transforms" + bl_label = t("Tools.apply_transforms") + bl_description = t("Tools.apply_transforms_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 and context.mode == 'OBJECT' + + def execute(self, context: Context) -> Set[str]: + try: + armature = get_active_armature(context) + logger.info(f"Applying transforms to {armature.name} and associated meshes") + + # Select armature and meshes + bpy.ops.object.select_all(action='DESELECT') + armature.select_set(True) + context.view_layer.objects.active = armature + + meshes = get_all_meshes(context) + for mesh in meshes: + mesh.select_set(True) + + # Apply transforms + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + + self.report({'INFO'}, t("Tools.transforms_applied")) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Failed to apply transforms: {str(e)}") + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} + +class AvatarToolkit_OT_CleanShapekeys(Operator): + """Remove unused shape keys from meshes""" + bl_idname = "avatar_toolkit.clean_shapekeys" + bl_label = t("Tools.clean_shapekeys") + bl_description = t("Tools.clean_shapekeys_desc") + bl_options = {'REGISTER', 'UNDO'} + + tolerance: bpy.props.FloatProperty( + name=t("Tools.shapekey_tolerance"), + description=t("Tools.shapekey_tolerance_desc"), + default=0.001, + min=0.0001, + max=0.1 + ) + + @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 and context.mode == 'OBJECT' and len(get_all_meshes(context)) > 0 + + def execute(self, context: Context) -> Set[str]: + try: + logger.info("Starting shape key cleanup") + removed_count = 0 + + for mesh in get_all_meshes(context): + if not mesh.data.shape_keys or not mesh.data.shape_keys.use_relative: + continue + + removed = remove_unused_shapekeys(mesh, self.tolerance) + removed_count += removed + logger.debug(f"Removed {removed} shape keys from {mesh.name}") + + self.report({'INFO'}, t("Tools.shapekeys_removed", count=removed_count)) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Failed to clean shape keys: {str(e)}") + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} diff --git a/functions/tools/merge_tools.py b/functions/tools/merge_tools.py new file mode 100644 index 0000000..b8daada --- /dev/null +++ b/functions/tools/merge_tools.py @@ -0,0 +1,161 @@ +import bpy +import math +from typing import Set, List +from bpy.types import Operator, Context, Armature, EditBone +from ...core.translations import t +from ...core.logging_setup import logger +from ...core.common import get_active_armature, get_all_meshes, get_vertex_weights, transfer_vertex_weights, validate_armature + +class AvatarToolkit_OT_ConnectBones(Operator): + """Connect disconnected bones in chain""" + bl_idname = "avatar_toolkit.connect_bones" + bl_label = t("Tools.connect_bones") + bl_description = t("Tools.connect_bones_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]: + try: + armature = get_active_armature(context) + logger.info("Starting bone connection operation") + + bpy.ops.object.mode_set(mode='EDIT') + edit_bones = armature.data.edit_bones + bones_connected = 0 + min_distance = context.scene.avatar_toolkit.connect_bones_min_distance + + excluded_bones = {'LeftEye', 'RightEye', 'Head', 'Hips'} + + for bone in edit_bones: + if len(bone.children) == 1 and bone.name not in excluded_bones: + child = bone.children[0] + distance = math.dist(bone.tail, child.head) + + if distance > min_distance: + logger.debug(f"Connecting bone {bone.name} to {child.name}") + bone.tail = child.head + if bone.parent and len(bone.parent.children) == 1: + bone.use_connect = True + bones_connected += 1 + + bpy.ops.object.mode_set(mode='OBJECT') + self.report({'INFO'}, t("Tools.connect_bones_success", count=bones_connected)) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Failed to connect bones: {str(e)}") + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} + +class AvatarToolkit_OT_MergeToActive(Operator): + """Merge selected bones into active bone and transfer weights""" + bl_idname = "avatar_toolkit.merge_to_active" + bl_label = t("Tools.merge_to_active") + bl_description = t("Tools.merge_to_active_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + armature = get_active_armature(context) + if not armature: + return False + return context.mode == 'EDIT_ARMATURE' and context.active_bone + + def execute(self, context: Context) -> Set[str]: + try: + armature = get_active_armature(context) + active_bone = context.active_bone + selected_bones = [b for b in context.selected_editable_bones if b != active_bone] + + if not selected_bones: + self.report({'WARNING'}, t("Tools.no_bones_selected")) + return {'CANCELLED'} + + logger.info(f"Merging {len(selected_bones)} bones into {active_bone.name}") + + # Store weights before merging + meshes = get_all_meshes(context) + weight_data = {} + for bone in selected_bones: + for mesh in meshes: + if bone.name in mesh.vertex_groups: + weights = get_vertex_weights(mesh, bone.name) + weight_data.setdefault(mesh.name, {})[bone.name] = weights + + # Transfer weights to active bone + threshold = context.scene.avatar_toolkit.merge_weights_threshold + for mesh_name, bone_weights in weight_data.items(): + mesh = bpy.data.objects[mesh_name] + for bone_name, weights in bone_weights.items(): + transfer_vertex_weights(mesh, bone_name, active_bone.name, threshold) + + # Delete merged bones + for bone in selected_bones: + armature.data.edit_bones.remove(bone) + + self.report({'INFO'}, t("Tools.merge_to_active_success", count=len(selected_bones))) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Failed to merge bones: {str(e)}") + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} + +class AvatarToolkit_OT_MergeToParent(Operator): + """Merge selected bones into their respective parents and transfer weights""" + bl_idname = "avatar_toolkit.merge_to_parent" + bl_label = t("Tools.merge_to_parent") + bl_description = t("Tools.merge_to_parent_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + armature = get_active_armature(context) + if not armature: + return False + return context.mode == 'EDIT_ARMATURE' + + def execute(self, context: Context) -> Set[str]: + try: + armature = get_active_armature(context) + selected_bones = [b for b in context.selected_editable_bones if b.parent] + + if not selected_bones: + self.report({'WARNING'}, t("Tools.no_bones_with_parent")) + return {'CANCELLED'} + + logger.info(f"Merging {len(selected_bones)} bones to their parents") + + # Store weights before merging + meshes = get_all_meshes(context) + merged_count = 0 + threshold = context.scene.avatar_toolkit.merge_weights_threshold + + for bone in selected_bones: + parent = bone.parent + if not parent: + continue + + # Transfer weights to parent + for mesh in meshes: + if bone.name in mesh.vertex_groups: + transfer_vertex_weights(mesh, bone.name, parent.name, threshold) + + # Delete merged bone + armature.data.edit_bones.remove(bone) + merged_count += 1 + + self.report({'INFO'}, t("Tools.merge_to_parent_success", count=merged_count)) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Failed to merge bones: {str(e)}") + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index 87acdfb..46594a3 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -174,6 +174,19 @@ "Tools.analyzing_weights": "Analyzing vertex weights...", "Tools.removing_bones": "Removing unweighted bones...", "Tools.verifying_hierarchy": "Verifying bone hierarchy...", + "Tools.connect_bones_min_distance": "Minimum Distance", + "Tools.connect_bones_min_distance_desc": "Minimum distance between bones to attempt connection", + "Tools.connect_bones_success": "Connected {count} bones", + "Tools.merge_weights_threshold": "Weight Transfer Threshold", + "Tools.merge_weights_threshold_desc": "Minimum weight value to transfer when merging bones", + "Tools.no_bones_selected": "No bones selected to merge", + "Tools.no_bones_with_parent": "No selected bones with parents found", + "Tools.merge_to_active_success": "Successfully merged {count} bones to active bone", + "Tools.merge_to_parent_success": "Successfully merged {count} bones to their parents", + "Tools.transforms_applied": "Transforms applied successfully", + "Tools.shapekey_tolerance": "Shape Key Tolerance", + "Tools.shapekey_tolerance_desc": "Minimum difference to consider a shape key as used", + "Tools.shapekeys_removed": "Removed {count} unused shape keys", "Settings.label": "Settings", "Settings.language": "Language", diff --git a/ui/tools_panel.py b/ui/tools_panel.py index 170a6d6..a55d734 100644 --- a/ui/tools_panel.py +++ b/ui/tools_panel.py @@ -4,42 +4,6 @@ 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")