From 2af7a4739a4111bfdf99bdcb3f75d2f980572a47 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Mon, 16 Dec 2024 01:34:38 +0000 Subject: [PATCH] Armature Merging --- core/common.py | 34 +- core/properties.py | 64 +++ functions/custom_tools/armature_merging.py | 487 +++++++++++++++++++++ functions/custom_tools/mesh_attachment.py | 0 resources/translations/en_US.json | 45 ++ ui/custom_avatar_panel.py | 231 ++++++++++ 6 files changed, 852 insertions(+), 9 deletions(-) create mode 100644 functions/custom_tools/armature_merging.py create mode 100644 functions/custom_tools/mesh_attachment.py create mode 100644 ui/custom_avatar_panel.py diff --git a/core/common.py b/core/common.py index 253acc6..548c6f4 100644 --- a/core/common.py +++ b/core/common.py @@ -317,7 +317,7 @@ def validate_meshes(meshes: List[Object]) -> Tuple[bool, str]: return False, t("Optimization.non_mesh_objects") return True, "" -def join_mesh_objects(context: Context, meshes: List[Object], progress: Optional[ProgressTracker] = None) -> Tuple[bool, str]: +def join_mesh_objects(context: Context, meshes: List[Object], progress: Optional[ProgressTracker] = None) -> Optional[Object]: """Combines multiple mesh objects into a single mesh with proper cleanup and UV fixing""" try: bpy.ops.object.mode_set(mode='OBJECT') @@ -341,13 +341,16 @@ def join_mesh_objects(context: Context, meshes: List[Object], progress: Optional progress.step(t("Optimization.fixing_uvs")) fix_uv_coordinates(context) - return True, t("Optimization.meshes_joined") + # Return the joined mesh object + return context.active_object + + else: + # No objects were selected, return None + return None - return False, t("Optimization.no_mesh_selected") - except Exception as e: logger.error(f"Failed to join meshes: {str(e)}") - return False, str(e) + return None def fix_uv_coordinates(context: Context) -> None: """Normalizes and fixes UV coordinates for the active mesh object""" @@ -378,12 +381,14 @@ def fix_uv_coordinates(context: Context) -> None: for sel_obj in current_selected: sel_obj.select_set(True) context.view_layer.objects.active = current_active - -def clear_unused_data_blocks(self) -> int: +# This should be at the top level, not indented inside any class or function +def clear_unused_data_blocks() -> int: """Removes all unused data blocks from the current Blender file""" - initial_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)) + initial_count = sum(len(getattr(bpy.data, attr)) for attr in dir(bpy.data) + if isinstance(getattr(bpy.data, attr), bpy.types.bpy_prop_collection)) 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 = 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: @@ -575,4 +580,15 @@ def is_enum_non_empty(string): Returns True in all other cases.""" return _empty_enum_identifier != string +def fix_zero_length_bones(armature: Object) -> None: + """Fix zero length bones by setting a minimum length""" + if not armature: + return + + bpy.ops.object.mode_set(mode='EDIT') + for bone in armature.data.edit_bones: + if bone.length < 0.001: + bone.length = 0.001 + bpy.ops.object.mode_set(mode='OBJECT') + diff --git a/core/properties.py b/core/properties.py index 074d127..140c6e9 100644 --- a/core/properties.py +++ b/core/properties.py @@ -293,6 +293,70 @@ class AvatarToolkitSceneProperties(PropertyGroup): description=t("EyeTracking.lowerlid_right_desc") ) + merge_mode: EnumProperty( + name=t('CustomPanel.merge_mode'), + description=t('CustomPanel.merge_mode_desc'), + items=[ + ('ARMATURE', t('CustomPanel.mode.armature'), t('CustomPanel.mode.armature_desc')), + ('MESH', t('CustomPanel.mode.mesh'), t('CustomPanel.mode.mesh_desc')) + ], + default='ARMATURE' + ) + + merge_armature_into: StringProperty( + name=t('CustomPanel.merge_into'), + description=t('CustomPanel.merge_into_desc'), + default="" + ) + + merge_armature: StringProperty( + name=t('CustomPanel.merge_from'), + description=t('CustomPanel.merge_from_desc'), + default="" + ) + + attach_mesh: StringProperty( + name=t('CustomPanel.attach_mesh'), + description=t('CustomPanel.attach_mesh_desc'), + default="" + ) + + attach_bone: StringProperty( + name=t('CustomPanel.attach_bone'), + description=t('CustomPanel.attach_bone_desc'), + default="" + ) + + merge_all_bones: BoolProperty( + name=t('CustomPanel.merge_all_bones'), + description=t('CustomPanel.merge_all_bones_desc'), + default=True + ) + + apply_transforms: BoolProperty( + name=t('CustomPanel.apply_transforms'), + description=t('CustomPanel.apply_transforms_desc'), + default=True + ) + + join_meshes: BoolProperty( + name=t('CustomPanel.join_meshes'), + description=t('CustomPanel.join_meshes_desc'), + default=True + ) + + remove_zero_weights: BoolProperty( + name=t('CustomPanel.remove_zero_weights'), + description=t('CustomPanel.remove_zero_weights_desc'), + default=True + ) + + cleanup_shape_keys: BoolProperty( + name=t('CustomPanel.cleanup_shape_keys'), + description=t('CustomPanel.cleanup_shape_keys_desc'), + default=True + ) + def register() -> None: """Register the Avatar Toolkit property group""" logger.info("Registering Avatar Toolkit properties") diff --git a/functions/custom_tools/armature_merging.py b/functions/custom_tools/armature_merging.py new file mode 100644 index 0000000..2ff4e77 --- /dev/null +++ b/functions/custom_tools/armature_merging.py @@ -0,0 +1,487 @@ +import bpy +import numpy as np +from typing import List, Optional, Dict, Set +from mathutils import Vector +from bpy.types import Context, Object, Operator + +from ...core.logging_setup import logger +from ...core.translations import t +from ...core.common import ( + get_active_armature, + get_all_meshes, + fix_zero_length_bones, + clear_unused_data_blocks, + validate_armature, + join_mesh_objects, + fix_uv_coordinates, + remove_unused_shapekeys +) + +class AvatarToolkit_OT_MergeArmature(Operator): + bl_idname = 'avatar_toolkit.merge_armatures' + bl_label = t('MergeArmature.label') + bl_description = t('MergeArmature.desc') + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + return len(get_all_meshes(context)) > 1 + + def execute(self, context): + try: + wm = context.window_manager + wm.progress_begin(0, 100) + + # Get both armatures + base_armature_name = context.scene.merge_armature_into + merge_armature_name = context.scene.merge_armature + base_armature = bpy.data.objects.get(base_armature_name) + merge_armature = bpy.data.objects.get(merge_armature_name) + + if not base_armature or not merge_armature: + logger.error(f"Armature not found: {merge_armature_name}") + self.report({'ERROR'}, t('MergeArmature.error.notFound', name=merge_armature_name)) + return {'CANCELLED'} + + # Remove Rigid Bodies and Joints + delete_rigidbodies_and_joints(base_armature) + delete_rigidbodies_and_joints(merge_armature) + wm.progress_update(40) + + # Check parents and transformations + if not validate_parents_and_transforms(merge_armature, base_armature, context): + wm.progress_end() + return {'CANCELLED'} + wm.progress_update(80) + + # Get settings from scene properties + merge_all_bones = context.scene.avatar_toolkit.merge_all_bones + join_meshes = context.scene.avatar_toolkit.join_meshes + + # Merge armatures + merge_armatures( + base_armature_name, + merge_armature_name, + mesh_only=False, + merge_all_bones=context.scene.avatar_toolkit.merge_all_bones, + join_meshes=join_meshes, + operator=self + ) + wm.progress_update(90) + + wm.progress_update(100) + wm.progress_end() + + self.report({'INFO'}, t('MergeArmature.success')) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Error merging armatures: {str(e)}") + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} + +def calculate_bone_orientation(mesh, vertices): + """Calculate optimal bone orientation based on mesh geometry.""" + + if not vertices: + return Vector((0, 0, 0.1)), 0.0 + + coords = [mesh.data.vertices[v.index].co for v in vertices] + min_co = Vector(map(min, zip(*coords))) + max_co = Vector(map(max, zip(*coords))) + dimensions = max_co - min_co + + roll_angle = 0.0 + + return dimensions, roll_angle + +def delete_rigidbodies_and_joints(armature: Object): + """Delete rigid bodies and joints associated with the armature.""" + to_delete = [] + parent = armature + while parent.parent: + parent = parent.parent + + for child in parent.children: + if 'rigidbodies' in child.name.lower() or 'joints' in child.name.lower(): + to_delete.append(child) + for grandchild in child.children: + if 'rigidbodies' in grandchild.name.lower() or 'joints' in grandchild.name.lower(): + to_delete.append(grandchild) + + for obj in to_delete: + bpy.data.objects.remove(obj, do_unlink=True) + +def validate_parents_and_transforms(merge_armature: Object, base_armature: Object, context: Context) -> bool: + """Validate parents and transformations of armatures before merging.""" + merge_parent = merge_armature.parent + base_parent = base_armature.parent + + if merge_parent or base_parent: + if context.scene.merge_all_bones: + for armature, parent in [(merge_armature, merge_parent), (base_armature, base_parent)]: + if parent: + if not is_transform_clean(parent): + logger.error("Parent transforms are not clean") + return False + bpy.data.objects.remove(parent, do_unlink=True) + else: + logger.error("Parent relationships need fixing") + return False + return True + +def is_transform_clean(obj: Object) -> bool: + """Check if an object's transforms are at default values.""" + for i in range(3): + if obj.scale[i] != 1 or obj.location[i] != 0 or obj.rotation_euler[i] != 0: + return False + return True + +def prepare_mesh_vertex_groups(mesh: Object): + """Prepare mesh by assigning all vertices to a new vertex group.""" + if mesh.vertex_groups: + for vg in mesh.vertex_groups: + mesh.vertex_groups.remove(vg) + + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_all(action='SELECT') + vg = mesh.vertex_groups.new(name=mesh.name) + bpy.ops.object.vertex_group_assign() + bpy.ops.object.mode_set(mode='OBJECT') + +def merge_armatures( + base_armature_name: str, + merge_armature_name: str, + mesh_only: bool, + merge_all_bones: bool = False, + join_meshes: bool = False, + operator=None +): + """Main function to merge two armatures.""" + logger.info(f"Merging armatures: {merge_armature_name} into {base_armature_name}") + tolerance = 0.00008726647 # around 0.005 degrees + + base_armature = bpy.data.objects.get(base_armature_name) + merge_armature = bpy.data.objects.get(merge_armature_name) + + if not base_armature or not merge_armature: + logger.error(f"Armature not found: {merge_armature_name}") + if operator: + operator.report({'ERROR'}, t('MergeArmature.error.notFound', name=merge_armature_name)) + return + + # Check transforms early + if not validate_merge_armature_transforms(base_armature, merge_armature, None, tolerance): + if not bpy.context.scene.avatar_toolkit.apply_transforms: + logger.error("Transforms not aligned - user notification sent") + if operator: + operator.report({'ERROR'}, t('MergeArmature.error.transforms_not_aligned')) + return + + # Apply transforms if enabled + if bpy.context.scene.avatar_toolkit.apply_transforms: + for obj in [base_armature, merge_armature]: + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + obj.select_set(False) + + # Validate and fix armatures + fix_zero_length_bones(base_armature) + fix_zero_length_bones(merge_armature) + + # Store original parent relationships + original_parents = {} + for bone in merge_armature.data.bones: + original_parents[bone.name] = bone.parent.name if bone.parent else None + + # Get base bone names + base_bone_names = set(bone.name for bone in base_armature.data.bones) + + # Switch to edit mode on merge armature and rename bones + bpy.context.view_layer.objects.active = merge_armature + bpy.ops.object.mode_set(mode='EDIT') + + # Handle bone renaming based on merge_all_bones setting + for bone in merge_armature.data.edit_bones: + if not merge_all_bones: + # Only rename bones that don't exist in base armature + if bone.name not in base_bone_names: + bone.name += '.merge' + else: + # Rename all bones from merge armature + bone.name += '.merge' + + # Return to object mode + bpy.ops.object.mode_set(mode='OBJECT') + + # Select and join armatures + bpy.ops.object.select_all(action='DESELECT') + base_armature.select_set(True) + merge_armature.select_set(True) + bpy.context.view_layer.objects.active = base_armature + bpy.ops.object.join() + + # Restore parent relationships + bpy.ops.object.mode_set(mode='EDIT') + for bone in base_armature.data.edit_bones: + base_name = bone.name.replace('.merge', '') + if base_name in original_parents: + parent_name = original_parents[base_name] + if parent_name: + parent_bone = base_armature.data.edit_bones.get(parent_name) + if parent_bone: + bone.parent = parent_bone + + bpy.ops.object.mode_set(mode='OBJECT') + + # Update mesh parenting + for obj in bpy.data.objects: + if obj.type == 'MESH' and obj.parent == merge_armature: + obj.parent = base_armature + + # Process vertex groups if not mesh_only + if not mesh_only: + meshes = [obj for obj in bpy.data.objects if obj.type == 'MESH' and obj.parent == base_armature] + process_vertex_groups(meshes) + + # Remove zero weight vertex groups if enabled + if bpy.context.scene.avatar_toolkit.remove_zero_weights: + bpy.context.view_layer.objects.active = base_armature + for mesh in meshes: + bpy.context.view_layer.objects.active = mesh + bpy.ops.avatar_toolkit.clean_weights() + + # Join meshes if requested + if join_meshes: + meshes_to_join = [obj for obj in bpy.data.objects if obj.type == 'MESH' and obj.parent == base_armature] + if meshes_to_join: + joined_mesh = join_mesh_objects(bpy.context, meshes_to_join) + if joined_mesh: + logger.info(f"Joined meshes into {joined_mesh.name}") + + # Clean up shape keys if enabled + if bpy.context.scene.avatar_toolkit.cleanup_shape_keys: + for obj in bpy.data.objects: + if obj.type == 'MESH' and obj.parent == base_armature: + remove_unused_shapekeys(obj) + + # Remove any remaining .merge bones + bpy.context.view_layer.objects.active = base_armature + bpy.ops.object.mode_set(mode='EDIT') + edit_bones = base_armature.data.edit_bones + bones_to_remove = [bone for bone in edit_bones if bone.name.endswith('.merge')] + for bone in bones_to_remove: + edit_bones.remove(bone) + bpy.ops.object.mode_set(mode='OBJECT') + + # Final cleanup + clear_unused_data_blocks() + + +def validate_merge_armature_transforms( + base_armature: Object, + merge_armature: Object, + mesh_merge: Optional[Object], + tolerance: float +) -> bool: + """Validate transforms of both armatures and mesh.""" + for i in [0, 1, 2]: + if abs(base_armature.scale[i] - merge_armature.scale[i]) > tolerance: + return False + + if abs(merge_armature.rotation_euler[i]) > tolerance or \ + (mesh_merge and abs(mesh_merge.rotation_euler[i]) > tolerance): + return False + + return True + +def adjust_merge_armature_transforms( + merge_armature: Object, + mesh_merge: Object +): + """Adjust transforms of the merge armature.""" + old_loc = list(merge_armature.location) + old_scale = list(merge_armature.scale) + + for i in [0, 1, 2]: + merge_armature.location[i] = (mesh_merge.location[i] * old_scale[i]) + old_loc[i] + merge_armature.rotation_euler[i] = mesh_merge.rotation_euler[i] + merge_armature.scale[i] = mesh_merge.scale[i] * old_scale[i] + + for i in [0, 1, 2]: + mesh_merge.location[i] = 0 + mesh_merge.rotation_euler[i] = 0 + mesh_merge.scale[i] = 1 + + +def detect_bones_to_merge( + base_edit_bones: bpy.types.ArmatureEditBones, + merge_edit_bones: bpy.types.ArmatureEditBones, + tolerance: float, + merge_all_bones: bool +) -> List[str]: + """Detect corresponding bones between base and merge armatures using smart detection and position tolerance.""" + bones_to_merge = [] + + # Cache base bone positions + base_bones_positions = { + bone.name: np.array(bone.head) for bone in base_edit_bones + } + + # Smart bone detection + for merge_bone in merge_edit_bones: + merge_bone_position = np.array(merge_bone.head) + found_match = False + + if merge_all_bones and merge_bone.name in base_bones_positions: + # If merging same bones by name + bones_to_merge.append(merge_bone.name) + found_match = True + else: + # Find bones with close positions + for base_bone_name, base_bone_position in base_bones_positions.items(): + if np.linalg.norm(merge_bone_position - base_bone_position) <= tolerance: + bones_to_merge.append(base_bone_name) + found_match = True + break + + if not found_match: + # Handle unmatched bones if needed + pass + + return bones_to_merge + + +def process_vertex_groups(meshes: List[Object]): + """Process vertex groups in meshes.""" + for mesh in meshes: + vg_names = {vg.name for vg in mesh.vertex_groups} + merge_vg_names = [vg_name for vg_name in vg_names if vg_name.endswith('.merge')] + + for vg_merge_name in merge_vg_names: + base_name = vg_merge_name[:-6] + vg_merge = mesh.vertex_groups.get(vg_merge_name) + vg_base = mesh.vertex_groups.get(base_name) + + if vg_merge is None: + continue + + if vg_base: + mix_vertex_groups(mesh, vg_merge_name, base_name) + else: + vg_merge.name = base_name + +def mix_vertex_groups(mesh: Object, vg_from_name: str, vg_to_name: str): + """Mix vertex group weights.""" + vg_from = mesh.vertex_groups.get(vg_from_name) + vg_to = mesh.vertex_groups.get(vg_to_name) + + if not vg_from or not vg_to: + return + + num_vertices = len(mesh.data.vertices) + weights_from = np.zeros(num_vertices) + weights_to = np.zeros(num_vertices) + + idx_from = vg_from.index + idx_to = vg_to.index + + for v in mesh.data.vertices: + for g in v.groups: + if g.group == idx_from: + weights_from[v.index] = g.weight + elif g.group == idx_to: + weights_to[v.index] = g.weight + + weights_combined = np.clip(weights_from + weights_to, 0.0, 1.0) + vg_to.add(range(num_vertices), weights_combined.tolist(), 'REPLACE') + mesh.vertex_groups.remove(vg_from) + +def add_armature_modifier(mesh: Object, armature: Object): + """Add armature modifier to mesh.""" + for mod in mesh.modifiers: + if mod.type == 'ARMATURE': + mesh.modifiers.remove(mod) + + modifier = mesh.modifiers.new('Armature', 'ARMATURE') + modifier.object = armature + +def remove_unused_vertex_groups(mesh: Object): + """Remove vertex groups with no weights.""" + for vg in mesh.vertex_groups: + has_weights = False + for vert in mesh.data.vertices: + for group in vert.groups: + if group.group == vg.index and group.weight > 0.001: + has_weights = True + break + if has_weights: + break + if not has_weights: + mesh.vertex_groups.remove(vg) + +def apply_armature_to_mesh(armature: Object, mesh: Object): + """Apply armature deformation to mesh.""" + armature_mod = mesh.modifiers.new('PoseToRest', 'ARMATURE') + armature_mod.object = armature + + if bpy.app.version >= (3, 5): + mesh.modifiers.move(mesh.modifiers.find(armature_mod.name), 0) + else: + for _ in range(len(mesh.modifiers) - 1): + bpy.ops.object.modifier_move_up(modifier=armature_mod.name) + + with bpy.context.temp_override(object=mesh): + bpy.ops.object.modifier_apply(modifier=armature_mod.name) + +def apply_armature_to_mesh_with_shapekeys(armature: Object, mesh: Object, context: Context): + """Apply armature deformation to mesh with shape keys.""" + old_active_index = mesh.active_shape_key_index + old_show_only = mesh.show_only_shape_key + mesh.show_only_shape_key = True + + shape_keys = mesh.data.shape_keys.key_blocks + vertex_groups = [] + mutes = [] + + for sk in shape_keys: + vertex_groups.append(sk.vertex_group) + sk.vertex_group = '' + mutes.append(sk.mute) + sk.mute = False + + disabled_mods = [] + for mod in mesh.modifiers: + if mod.show_viewport: + mod.show_viewport = False + disabled_mods.append(mod) + + arm_mod = mesh.modifiers.new('PoseToRest', 'ARMATURE') + arm_mod.object = armature + + co_length = len(mesh.data.vertices) * 3 + eval_cos = np.empty(co_length, dtype=np.single) + + for i, shape_key in enumerate(shape_keys): + mesh.active_shape_key_index = i + + depsgraph = context.evaluated_depsgraph_get() + eval_mesh = mesh.evaluated_get(depsgraph) + eval_mesh.data.vertices.foreach_get('co', eval_cos) + + shape_key.data.foreach_set('co', eval_cos) + if i == 0: + mesh.data.vertices.foreach_set('co', eval_cos) + + for mod in disabled_mods: + mod.show_viewport = True + + mesh.modifiers.remove(arm_mod) + + for sk, vg, mute in zip(shape_keys, vertex_groups, mutes): + sk.vertex_group = vg + sk.mute = mute + + mesh.active_shape_key_index = old_active_index + mesh.show_only_shape_key = old_show_only diff --git a/functions/custom_tools/mesh_attachment.py b/functions/custom_tools/mesh_attachment.py new file mode 100644 index 0000000..e69de29 diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index ab0cf37..62823e7 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -315,6 +315,51 @@ "EyeTracking.type.sdk2": "SDK2 (Legacy)", "EyeTracking.type.sdk2_desc": "VRChat SDK2 eye tracking setup", + "CustomPanel.label": "Custom Avatar Tools", + "CustomPanel.merge_mode": "Merge Mode", + "CustomPanel.merge_mode_desc": "Select mode for merging operations", + "CustomPanel.mode.armature": "Armature", + "CustomPanel.mode.armature_desc": "Merge armatures together", + "CustomPanel.mode.mesh": "Mesh", + "CustomPanel.mode.mesh_desc": "Attach meshes to armature", + "CustomPanel.mergeArmatures": "Merge Armatures", + "CustomPanel.warn.twoArmatures": "Need at least two armatures to merge", + "CustomPanel.warn.noArmOrMesh1": "No armature or meshes found", + "CustomPanel.warn.noArmOrMesh2": "Please add required objects first", + "CustomPanel.merge_into": "Merge Into", + "CustomPanel.merge_into_desc": "Target armature to merge into", + "CustomPanel.merge_from": "Merge From", + "CustomPanel.merge_from_desc": "Source armature to merge", + "CustomPanel.toMerge": "To Merge", + "CustomPanel.attachMesh1": "Attach Mesh", + "CustomPanel.attachMesh2": "Select Mesh", + "CustomPanel.attach_mesh": "Mesh to Attach", + "CustomPanel.attach_mesh_desc": "Select mesh to attach", + "CustomPanel.attachToBone": "Attach to Bone", + "CustomPanel.attach_bone": "Target Bone", + "CustomPanel.attach_bone_desc": "Select bone to attach to", + "CustomPanel.merge_same_bones": "Merge Same Bones", + "CustomPanel.merge_same_bones_desc": "Merge bones with matching names", + "CustomPanel.apply_transforms": "Apply Transforms", + "CustomPanel.apply_transforms_desc": "Apply all transformations before merging", + "CustomPanel.join_meshes": "Join Meshes", + "CustomPanel.join_meshes_desc": "Join meshes after merging", + "CustomPanel.remove_zero_weights": "Remove Zero Weights", + "CustomPanel.remove_zero_weights_desc": "Remove vertex groups with no weights", + "CustomPanel.cleanup_shape_keys": "Clean Shape Keys", + "CustomPanel.cleanup_shape_keys_desc": "Remove unused shape keys", + "CustomPanel.merge_all_bones": "Merge Same Bones", + "CustomPanel.merge_all_bones_desc": "Merge bones with matching names", + "CustomPanel.mergeInto": "Merge Into", + "MergeArmature.label": "Merge Armatures", + "MergeArmature.desc": "Merge two armatures together", + "MergeArmature.error.notFound": "Armature '{name}' not found", + "MergeArmature.success": "Armatures merged successfully", + "MergeArmature.error.checkTransforms": "Please check parent transformations", + "MergeArmature.error.pleaseFix": "Please fix parent relationships", + "MergeArmature.error.transforms_not_aligned": "Transforms must be applied to merge this armature, either do this via the manual method or via apply transform checkmark", + + "Settings.label": "Settings", "Settings.language": "Language", "Settings.language_desc": "Select interface language", diff --git a/ui/custom_avatar_panel.py b/ui/custom_avatar_panel.py new file mode 100644 index 0000000..4332e1b --- /dev/null +++ b/ui/custom_avatar_panel.py @@ -0,0 +1,231 @@ +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 +from ..core.common import ( + get_active_armature, + get_all_meshes, + validate_armature, + get_armature_list +) + +class AvatarToolkit_OT_SearchMergeArmatureInto(Operator): + bl_idname = "avatar_toolkit.search_merge_armature_into" + bl_label = "" + bl_description = t('CustomPanel.search_merge_into_desc') + bl_property = "search_merge_armature_into_enum" + + # Define the enum property within the operator class + search_merge_armature_into_enum: bpy.props.EnumProperty( + name=t('CustomPanel.merge_into'), + description=t('CustomPanel.merge_into_desc'), + items=get_armature_list + ) + + def execute(self, context): + context.scene.avatar_toolkit.merge_armature_into = self.search_merge_armature_into_enum + return {'FINISHED'} + + def invoke(self, context, event): + context.window_manager.invoke_search_popup(self) + return {'FINISHED'} + +class AvatarToolkit_OT_SearchMergeArmature(Operator): + bl_idname = "avatar_toolkit.search_merge_armature" + bl_label = "" + bl_description = t('CustomPanel.search_merge_desc') + bl_property = "search_merge_armature_enum" + + search_merge_armature_enum: bpy.props.EnumProperty( + name=t('CustomPanel.merge_from'), + description=t('CustomPanel.merge_from_desc'), + items=get_armature_list + ) + + def execute(self, context): + context.scene.avatar_toolkit.merge_armature = self.search_merge_armature_enum + return {'FINISHED'} + + def invoke(self, context, event): + context.window_manager.invoke_search_popup(self) + return {'FINISHED'} + +class AvatarToolkit_OT_SearchAttachMesh(Operator): + bl_idname = "avatar_toolkit.search_attach_mesh" + bl_label = "" + bl_description = t('CustomPanel.search_mesh_desc') + bl_property = "search_attach_mesh_enum" + + search_attach_mesh_enum: bpy.props.EnumProperty( + name=t('CustomPanel.attach_mesh'), + description=t('CustomPanel.attach_mesh_desc'), + items=lambda self, context: [ + (obj.name, obj.name, "") + for obj in get_all_meshes(context) + ] + ) + + def execute(self, context): + context.scene.avatar_toolkit.attach_mesh = self.search_attach_mesh_enum + return {'FINISHED'} + + def invoke(self, context, event): + context.window_manager.invoke_search_popup(self) + return {'FINISHED'} + +class AvatarToolkit_OT_SearchAttachBone(Operator): + bl_idname = "avatar_toolkit.search_attach_bone" + bl_label = "" + bl_description = t('CustomPanel.search_bone_desc') + bl_property = "search_attach_bone_enum" + + search_attach_bone_enum: bpy.props.EnumProperty( + name=t('CustomPanel.attach_bone'), + description=t('CustomPanel.attach_bone_desc'), + items=lambda self, context: [ + (bone.name, bone.name, "") + for bone in get_active_armature(context).data.bones + ] if get_active_armature(context) else [] + ) + + def execute(self, context): + context.scene.avatar_toolkit.attach_bone = self.search_attach_bone_enum + return {'FINISHED'} + + def invoke(self, context, event): + context.window_manager.invoke_search_popup(self) + return {'FINISHED'} + +class AvatarToolKit_PT_CustomPanel(Panel): + """Panel containing tools for custom avatar creation and merging""" + bl_label = t('CustomPanel.label') + bl_idname = "VIEW3D_PT_avatar_toolkit_custom" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = CATEGORY_NAME + bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname + bl_order = 3 + bl_options = {'DEFAULT_CLOSED'} + + def draw(self, context: Context) -> None: + """Draw the custom avatar tools panel interface""" + layout: UILayout = self.layout + toolkit = context.scene.avatar_toolkit + + # Mode Selection Box + mode_box: UILayout = layout.box() + col: UILayout = mode_box.column(align=True) + col.label(text=t('CustomPanel.merge_mode'), icon='TOOL_SETTINGS') + col.separator(factor=0.5) + + # Create a row for the mode buttons with increased scale + row: UILayout = col.row(align=True) + row.scale_y = 1.5 + row.prop(toolkit, "merge_mode", expand=True) + + # Armature Merging Tools + if toolkit.merge_mode == 'ARMATURE': + self.draw_armature_tools(layout, context) + # Mesh Attachment Tools + else: + self.draw_mesh_tools(layout, context) + + def draw_armature_tools(self, layout: UILayout, context: Context) -> None: + """Draw the armature merging tools section""" + toolkit = context.scene.avatar_toolkit + + # Merge Settings Box + settings_box: UILayout = layout.box() + col: UILayout = settings_box.column(align=True) + col.label(text=t('CustomPanel.mergeArmatures'), icon='ARMATURE_DATA') + col.separator(factor=0.5) + + if len(get_armature_list(context)) <= 1: + col.label(text=t('CustomPanel.warn.twoArmatures'), icon='INFO') + return + + # Merge Options + options_box: UILayout = layout.box() + col: UILayout = options_box.column(align=True) + col.label(text=t('Tools.merge_title'), icon='SETTINGS') + col.separator(factor=0.5) + col.prop(toolkit, "merge_all_bones") + col.prop(toolkit, "apply_transforms") + col.prop(toolkit, "join_meshes") + col.prop(toolkit, "remove_zero_weights") + col.prop(toolkit, "cleanup_shape_keys") + + # Armature Selection Box + selection_box: UILayout = layout.box() + col: UILayout = selection_box.column(align=True) + col.label(text=t('QuickAccess.select_armature'), icon='BONE_DATA') + col.separator(factor=0.5) + + row: UILayout = col.row(align=True) + row.label(text=t('CustomPanel.mergeInto')) + row.operator("avatar_toolkit.search_merge_armature_into", + text=toolkit.merge_armature_into, + icon='ARMATURE_DATA') + + row: UILayout = col.row(align=True) + row.label(text=t('CustomPanel.toMerge')) + row.operator("avatar_toolkit.search_merge_armature", + text=toolkit.merge_armature, + icon='ARMATURE_DATA') + + # Merge Button + merge_col: UILayout = layout.column(align=True) + merge_col.scale_y = 1.2 + merge_col.operator("avatar_toolkit.merge_armatures", icon='ARMATURE_DATA') + + def draw_mesh_tools(self, layout: UILayout, context: Context) -> None: + """Draw the mesh attachment tools section""" + toolkit = context.scene.avatar_toolkit + + # Mesh Tools Box + tools_box: UILayout = layout.box() + col: UILayout = tools_box.column(align=True) + col.label(text=t('CustomPanel.attachMesh1'), icon='MESH_DATA') + col.separator(factor=0.5) + + if not get_active_armature(context) or not get_all_meshes(context): + col.label(text=t('CustomPanel.warn.noArmOrMesh1'), icon='INFO') + col.label(text=t('CustomPanel.warn.noArmOrMesh2')) + return + + # Mesh Options Box + options_box: UILayout = layout.box() + col: UILayout = options_box.column(align=True) + col.label(text=t('Tools.merge_title'), icon='SETTINGS') + col.separator(factor=0.5) + col.prop(toolkit, "join_meshes") + + # Selection Box + selection_box: UILayout = layout.box() + col: UILayout = selection_box.column(align=True) + col.label(text=t('Tools.merge_title'), icon='OBJECT_DATA') + col.separator(factor=0.5) + + row: UILayout = col.row(align=True) + row.label(text=t('CustomPanel.mergeInto')) + row.operator("avatar_toolkit.search_merge_armature_into", + text=toolkit.merge_armature_into, + icon='ARMATURE_DATA') + + row: UILayout = col.row(align=True) + row.label(text=t('CustomPanel.attachMesh2')) + row.operator("avatar_toolkit.search_attach_mesh", + text=toolkit.attach_mesh, + icon='MESH_DATA') + + row: UILayout = col.row(align=True) + row.label(text=t('CustomPanel.attachToBone')) + row.operator("avatar_toolkit.search_attach_bone", + text=toolkit.attach_bone, + icon='BONE_DATA') + + # Attach Button + attach_col: UILayout = layout.column(align=True) + attach_col.scale_y = 1.2 + attach_col.operator("avatar_toolkit.attach_mesh", icon='ARMATURE_DATA')