Files
Avatar-Toolkit/functions/custom_tools/armature_merging.py
T
989onan 07adaa590b fix bad armature merging issues
also merge all bones isn't needed. we should do that by default

This also now uses dictionary matching to find bone types like hips, spine, and chest that should be merged.

Deletes bone shared and merges armatures, and parents bones back, causing a seamless merge.
2025-02-18 19:30:56 -05:00

472 lines
18 KiB
Python

import bpy
import numpy as np
from typing import List, Optional, Dict, Set, Tuple, Any
from bpy.types import Context, Object, Operator, ArmatureModifier, EditBone, VertexGroup, Mesh, ShapeKey
from ...core.dictionaries import bone_names
from ...core.logging_setup import logger
from ...core.translations import t
from ...core.common import (
get_all_meshes,
fix_zero_length_bones,
clear_unused_data_blocks,
join_mesh_objects,
remove_unused_shapekeys,
simplify_bonename
)
class AvatarToolkit_OT_MergeArmature(bpy.types.Operator):
"""Operator for merging two armatures together with their associated meshes"""
bl_idname: str = 'avatar_toolkit.merge_armatures'
bl_label: str = t('MergeArmature.label')
bl_description: str = t('MergeArmature.desc')
bl_options: Set[str] = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context: Context) -> bool:
return len(get_all_meshes(context)) > 1
def execute(self, context: Context) -> Set[str]:
try:
wm = context.window_manager
wm.progress_begin(0, 100)
# Get both armatures
base_armature_name: str = context.scene.avatar_toolkit.merge_armature_into
merge_armature_name: str = context.scene.avatar_toolkit.merge_armature
base_armature: Optional[Object] = bpy.data.objects.get(base_armature_name)
merge_armature: Optional[Object] = 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.not_found', 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
join_meshes: bool = context.scene.avatar_toolkit.join_meshes
# Merge armatures
merge_armatures(
base_armature_name,
merge_armature_name,
mesh_only=False,
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 delete_rigidbodies_and_joints(armature: Object) -> None:
"""Delete rigid bodies and joints associated with an armature"""
to_delete: List[Object] = []
parent: Object = 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 parent relationships and transformations of armatures"""
merge_parent: Optional[Object] = merge_armature.parent
base_parent: Optional[Object] = base_armature.parent
if merge_parent or base_parent:
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)
return True
def is_transform_clean(obj: Object) -> bool:
"""Check if object 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) -> None:
"""Initialize mesh vertex groups for merging process"""
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: VertexGroup = 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,
join_meshes: bool = False,
operator: Optional[Operator] = None
) -> None:
"""Main function to merge two armatures with their associated meshes and data"""
logger.info(f"Merging armatures: {merge_armature_name} into {base_armature_name}")
tolerance: float = 0.00008726647 # around 0.005 degrees
base_armature: Optional[Object] = bpy.data.objects.get(base_armature_name)
merge_armature: Optional[Object] = 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: Dict[str, Optional[str]] = {}
merge_armature_data: bpy.types.Armature = merge_armature.data
for bone in merge_armature_data.bones:
original_parents[bone.name] = bone.parent.name if bone.parent else None
#create reverse lookup
reverse_bone_lookup = {}
for preferred_name, name_list in bone_names.items():
for name in name_list:
reverse_bone_lookup[name] = preferred_name
# Get base bone names
base_bone_names: Set[str] = {bone.name for bone in base_armature.data.bones}
base_armature_standards: Dict[str,Optional[str]] = {}
for bone in base_bone_names:
if simplify_bonename(bone) in reverse_bone_lookup:
base_armature_standards[reverse_bone_lookup[simplify_bonename(bone)]] = bone
# 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/removing to target armature.
bone_names_source: list[str] = [bone.name for bone in merge_armature_data.edit_bones]
for bone in bone_names_source:
bone_name = bone
if bone_name not in base_bone_names: #not auto mergable to original
if simplify_bonename(bone_name) in reverse_bone_lookup: #if is a standard bone through standard translation.
if reverse_bone_lookup[simplify_bonename(bone_name)] in base_armature_standards: #if this bone equals for example, "hips", does a bone that should be "hips" exist on our target armature?
#if so, rename this bone to that one
merge_armature_data.edit_bones[bone_name].name = base_armature_standards[reverse_bone_lookup[simplify_bonename(bone_name)]]
bone_name = merge_armature_data.edit_bones[bone_name].name
#adjust original parents list to point to the new name.
for child_bone in merge_armature_data.edit_bones[bone_name]:
original_parents[child_bone.name] = bone_name
#then remove so it doesn't clash when merged.
merge_armature_data.edit_bones.remove(merge_armature_data.edit_bones[bone_name])
continue
#if it really doesn't have a counter part, just don't bother.
else:
merge_armature_data.edit_bones.remove(merge_armature_data.edit_bones[bone_name])
# 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()
base_armature_data: bpy.types.Armature = base_armature.data
# Restore parent relationships
bpy.ops.object.mode_set(mode='EDIT')
for bone in base_armature_data.edit_bones:
if bone.name in original_parents:
parent_name: Optional[str] = original_parents[bone.name]
if parent_name:
parent_bone: Optional[EditBone] = 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: List[Object] = [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: List[Object] = [obj for obj in bpy.data.objects if obj.type == 'MESH' and obj.parent == base_armature]
if meshes_to_join:
joined_mesh: Optional[Object] = 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='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
) -> None:
"""Adjust transforms of the merge armature"""
old_loc: List[float] = list(merge_armature.location)
old_scale: List[float] = 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
) -> List[str]:
"""Detect corresponding bones between base and merge armatures using smart detection and position tolerance"""
bones_to_merge: List[str] = []
# Cache base bone positions
base_bones_positions: Dict[str, np.ndarray] = {
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.ndarray = np.array(merge_bone.head)
found_match: bool = False
if 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]) -> None:
"""Process vertex groups in meshes"""
for mesh in meshes:
vg_names: Set[str] = {vg.name for vg in mesh.vertex_groups}
merge_vg_names: List[str] = [vg_name for vg_name in vg_names if vg_name.endswith('.merge')]
for vg_merge_name in merge_vg_names:
base_name: str = vg_merge_name[:-6]
vg_merge: Optional[VertexGroup] = mesh.vertex_groups.get(vg_merge_name)
vg_base: Optional[VertexGroup] = 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) -> None:
"""Mix vertex group weights"""
vg_from: Optional[VertexGroup] = mesh.vertex_groups.get(vg_from_name)
vg_to: Optional[VertexGroup] = mesh.vertex_groups.get(vg_to_name)
if not vg_from or not vg_to:
return
num_vertices: int = len(mesh.data.vertices)
weights_from: np.ndarray = np.zeros(num_vertices)
weights_to: np.ndarray = np.zeros(num_vertices)
idx_from: int = vg_from.index
idx_to: int = 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.ndarray = 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 remove_unused_vertex_groups(mesh: Object) -> None:
"""Remove vertex groups with no weights"""
for vg in mesh.vertex_groups:
has_weights: bool = 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) -> None:
"""Apply armature deformation to mesh"""
armature_mod: ArmatureModifier = 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) -> None:
"""Apply armature deformation to mesh with shape keys"""
old_active_index: int = mesh.active_shape_key_index
old_show_only: bool = mesh.show_only_shape_key
mesh.show_only_shape_key = True
shape_keys: List[ShapeKey] = mesh.data.shape_keys.key_blocks
vertex_groups: List[str] = []
mutes: List[bool] = []
for sk in shape_keys:
vertex_groups.append(sk.vertex_group)
sk.vertex_group = ''
mutes.append(sk.mute)
sk.mute = False
disabled_mods: List[Any] = []
for mod in mesh.modifiers:
if mod.show_viewport:
mod.show_viewport = False
disabled_mods.append(mod)
arm_mod: ArmatureModifier = mesh.modifiers.new('PoseToRest', 'ARMATURE')
arm_mod.object = armature
co_length: int = len(mesh.data.vertices) * 3
eval_cos: np.ndarray = 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 = 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