218 lines
7.0 KiB
Python
218 lines
7.0 KiB
Python
# 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]
|