# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors # # SPDX-License-Identifier: GPL-3.0-or-later from bpy.types import Operator, Object from bpy.props import EnumProperty from bpy.utils import flip_name from mathutils.kdtree import KDTree from ..utils import poll_deformed_mesh_with_vgroups class EASYWEIGHT_OT_symmetrize_groups(Operator): """Symmetrize weights of vertex groups on a near-symmetrical mesh.\May have poor results on assymetrical meshes""" bl_idname = "object.symmetrize_vertex_weights" bl_label = "Symmetrize Vertex Weights" bl_options = {'REGISTER', 'UNDO'} groups: EnumProperty( name="Groups", description="Subset of vertex groups that should be symmetrized", items=[ ('ACTIVE', 'Active', 'Active'), ('SELECTED', 'Selected Bones', 'Selected Bones'), ('ALL', 'All', 'All'), ], ) direction: EnumProperty( name="Direction", description="Whether to symmetrize from left to right or from right to left", items=[ ('AUTOMATIC', "Automatic", "Determine symmetrizing direction by the names of source vertex groups"), ('LEFT_TO_RIGHT', "Left to Right", "Left to Right"), ('RIGHT_TO_LEFT', "Right to Left", "Right to Left"), ], options={'SKIP_SAVE'}, ) @classmethod def poll(cls, context): return poll_deformed_mesh_with_vgroups(cls, context, must_deform=False) def invoke(self, context, _event): return context.window_manager.invoke_props_dialog(self, width=400) def draw(self, context): layout = self.layout layout.use_property_split = True layout.use_property_decorate = False layout.prop(self, 'groups') layout.prop(self, 'direction') def execute(self, context): obj = context.active_object vgroups = [obj.vertex_groups.active] if self.groups == 'SELECTED': vgroups = [] # Get vertex groups of selected bones. for pbone in context.selected_pose_bones: vgroup = obj.vertex_groups.get(pbone.name) if not vgroup: continue flipped_vg = flip_name(pbone.name) if flipped_vg in vgroups: self.report( {'ERROR'}, f'Both sides selected: "{vgroup.name}" & "{flipped_vg.name}". Only one side should be selected.', ) return {'CANCELLED'} vgroups.append(vgroup) elif self.groups == 'ALL': vgroups = obj.vertex_groups[:] symmetry_mapping = get_symmetry_mapping(obj=obj) if self.direction == 'AUTOMATIC': self.direction = 'LEFT_TO_RIGHT' righties = 0 lefties = 0 for vgroup in vgroups: name = vgroup.name.lower() if ( ".r" in name or "_r" in name or "r_" in name or "right" in name ): righties += 1 if ( ".l" in name or "_l" in name or "l_" in name or "left" in name ): lefties += 1 if righties > lefties: self.direction = 'RIGHT_TO_LEFT' for vgroup in vgroups: symmetrize_vertex_group( obj=obj, vg_name=vgroup.name, symmetry_mapping=symmetry_mapping, right_to_left=self.direction == 'RIGHT_TO_LEFT', ) msg_direction = self.direction.replace("_", " ").lower() self.report({'INFO'}, f"Symmetrized {len(vgroups)} groups {msg_direction}.") return {'FINISHED'} def get_symmetry_mapping(*, obj: Object, axis='X', symmetrize_pos_to_neg=False) -> dict[int, int]: """ Create a mapping of vertex indicies, such that the index on one side maps to the index on the opposite side of the mesh on a given axis. """ assert axis in 'XYZ', "Axis must be X, Y or Z!" vertices = obj.data.vertices size = len(vertices) kd = KDTree(size) for i, v in enumerate(vertices): kd.insert(v.co, i) kd.balance() coord_i = 'XYZ'.find(axis) # Figure out the function that will be used to determine whether a vertex # should be skipped or not. def zero_or_more(x): return x >= 0 def zero_or_less(x): return x <= 0 skip_func = zero_or_more if symmetrize_pos_to_neg else zero_or_less # For any vertex with an X coordinate > 0, try to find a vertex at # the coordinate with X flipped. vert_map = {} bad_counter = 0 for vert_idx, vert in enumerate(vertices): if abs(vert.co[coord_i]) < 0.0001: vert_map[vert_idx] = vert_idx continue # if skip_func(vert.co[coord_i]): # continue flipped_co = vert.co.copy() flipped_co[coord_i] *= -1 _opposite_co, opposite_idx, dist = kd.find(flipped_co) if dist > 0.1: # pretty big threshold, for testing. bad_counter += 1 continue if opposite_idx in vert_map.values(): # This vertex was already mapped, and another vertex just matched with it. # No way to tell which is correct. Input mesh should just be more symmetrical. bad_counter += 1 continue vert_map[vert_idx] = opposite_idx return vert_map def symmetrize_vertex_group( *, obj: Object, vg_name: str, symmetry_mapping: dict[int, int], right_to_left=False ): """ Symmetrize weights of a single group. The symmetry_mapping should first be calculated with get_symmetry_mapping(). """ vgroup = obj.vertex_groups.get(vg_name) if not vgroup: return opp_name = flip_name(vg_name) opp_vgroup = obj.vertex_groups.get(opp_name) if not opp_vgroup: opp_vgroup = obj.vertex_groups.new(name=opp_name) skip_func = None if vgroup != opp_vgroup: # Clear weights of the opposite group from all vertices. opp_vgroup.remove(range(len(obj.data.vertices))) else: # If the name isn't flippable, only remove weights of vertices # whose X coord >= 0. # Figure out the function that will be used to determine whether a vertex # should be skipped or not. def zero_or_more(x): return x >= 0 def zero_or_less(x): return x <= 0 skip_func = zero_or_more if right_to_left else zero_or_less # Write the new, mirrored weights for src_idx, dst_idx in symmetry_mapping.items(): vert = obj.data.vertices[src_idx] if skip_func != None and skip_func(vert.co.x): continue try: src_weight = vgroup.weight(src_idx) if src_weight == 0: continue except RuntimeError: continue opp_vgroup.add([dst_idx], src_weight, 'REPLACE') registry = [EASYWEIGHT_OT_symmetrize_groups]