2025-12-01

This commit is contained in:
2026-03-17 14:58:51 -06:00
parent 183e865f8b
commit 4b82b57113
6846 changed files with 954887 additions and 162606 deletions
@@ -0,0 +1,19 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from . import (
delete_empty_deform_groups,
delete_unselected_deform_groups,
delete_unused_groups,
focus_deforming_bones,
symmetrize_weights,
)
modules = [
delete_empty_deform_groups,
delete_unselected_deform_groups,
delete_unused_groups,
focus_deforming_bones,
symmetrize_weights,
]
@@ -0,0 +1,79 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Operator, Object, VertexGroup
from bpy.utils import flip_name
from ..utils import delete_vgroups, poll_deformed_mesh_with_vgroups, get_deforming_vgroups
class EASYWEIGHT_OT_delete_empty_deform_groups(Operator):
"""Delete vertex groups which are associated to deforming bones but don't have any weights"""
bl_idname = "object.delete_empty_deform_vgroups"
bl_label = "Delete Empty Deform Groups"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
return poll_deformed_mesh_with_vgroups(cls, context)
def execute(self, context):
empty_groups = get_empty_deforming_vgroups(context.active_object)
num_groups = len(empty_groups)
print(f"Deleting empty deform groups:")
print(" " + "\n ".join([vg.name for vg in empty_groups]))
delete_vgroups(context.active_object, empty_groups)
self.report({'INFO'}, f"Deleted {num_groups} empty deform groups.")
return {'FINISHED'}
def get_non_deforming_vgroups(mesh_ob: Object) -> set:
"""Get the vertex groups of a mesh object that don't correspond to a deform bone in the given armature."""
deforming_vgroups = get_deforming_vgroups(mesh_ob)
return set(mesh_ob.vertex_groups) - set(deforming_vgroups)
def get_empty_deforming_vgroups(mesh_ob: Object) -> list[VertexGroup]:
deforming_vgroups = get_deforming_vgroups(mesh_ob)
empty_deforming_groups = [vg for vg in deforming_vgroups if not vgroup_has_weight(mesh_ob, vg)]
# If there's no Mirror modifier, we're done.
if not 'MIRROR' in (m.type for m in mesh_ob.modifiers):
return empty_deforming_groups
# Mirror Modifier: A group is not considered empty if it is the opposite
# of a non-empty group.
for vgroup in empty_deforming_groups[:]:
opp_vgroup = mesh_ob.vertex_groups.get(flip_name(vgroup.name))
if not opp_vgroup:
continue
if opp_vgroup not in empty_deforming_groups:
empty_deforming_groups.remove(vgroup)
return empty_deforming_groups
def get_vgroup_weight_on_vert(vgroup, vert_idx) -> float:
# Despite how terrible this is, as of 04/Jun/2021 it seems to be the
# only only way to ask Blender if a vertex is assigned to a vertex group.
try:
weight = vgroup.weight(vert_idx)
return weight
except RuntimeError:
return -1
def vgroup_has_weight(mesh_ob, vgroup) -> bool:
for i in range(0, len(mesh_ob.data.vertices)):
if get_vgroup_weight_on_vert(vgroup, i) > 0:
return True
return False
registry = [EASYWEIGHT_OT_delete_empty_deform_groups]
@@ -0,0 +1,38 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Operator
from ..utils import delete_vgroups, poll_weight_paint_mode, get_deforming_vgroups
class EASYWEIGHT_OT_delete_unselected_deform_groups(Operator):
"""Delete deforming vertex groups that do not correspond to any selected pose bone"""
bl_idname = "object.delete_unselected_deform_vgroups"
bl_label = "Delete Unselected Deform Groups"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
return poll_weight_paint_mode(cls, context, with_rig=True, with_groups=True)
def execute(self, context):
deforming_groups = get_deforming_vgroups(context.active_object)
selected_bone_names = [b.name for b in context.selected_pose_bones]
unselected_def_groups = [
vg for vg in deforming_groups if vg.name not in selected_bone_names
]
print(f"Deleting unselected deform groups:")
deleted_names = [vg.name for vg in unselected_def_groups]
print(" " + "\n ".join(deleted_names))
delete_vgroups(context.active_object, unselected_def_groups)
self.report({'INFO'}, f"Deleted {len(deleted_names)} unselected deform groups.")
return {'FINISHED'}
registry = [EASYWEIGHT_OT_delete_unselected_deform_groups]
@@ -0,0 +1,160 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Operator, Object, VertexGroup, Modifier
from ..utils import delete_vgroups, poll_deformed_mesh_with_vgroups, get_deforming_vgroups
class EASYWEIGHT_OT_delete_unused_vertex_groups(Operator):
"""Delete vertex groups which are not used by any modifiers, deforming bones, shape keys, or constraints"""
bl_idname = "object.delete_unused_vgroups"
bl_label = "Delete Unused Vertex Groups"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
return poll_deformed_mesh_with_vgroups(cls, context, must_deform=False)
def execute(self, context):
deleted_names = delete_unused_vgroups(context.active_object)
self.report({'INFO'}, f"Deleted {len(deleted_names)} unused non-deform groups.")
return {'FINISHED'}
def delete_unused_vgroups(mesh_ob: Object) -> list[str]:
"""Returns a list of vertex group names that got deleted."""
groups_to_delete = get_unused_vgroups(mesh_ob)
names = [vgroup.name for vgroup in groups_to_delete]
print(f"Deleting unused non-deform groups:")
print(" " + "\n ".join(names))
delete_vgroups(mesh_ob, groups_to_delete)
return names
def get_unused_vgroups(mesh_ob: Object) -> set[VertexGroup]:
return set(mesh_ob.vertex_groups) - set(get_used_vgroups(mesh_ob))
def get_used_vgroups(mesh_ob: Object) -> list[VertexGroup]:
"""Get a list of vertex groups used by the object.
Currently accounts for Modifiers, Armatures, GeoNodes, Physics,
Shape Keys, and Constraints of dependent objects.
"""
used_vgroups = []
# Inputs of Modifiers, including GeoNodes.
for modifier in mesh_ob.modifiers:
if modifier.type == 'NODES':
print(modifier.name)
used_vgroups.extend(get_vgroups_used_by_geonodes(mesh_ob, modifier))
else:
used_vgroups.extend(get_referenced_vgroups(mesh_ob, modifier))
if modifier.type == 'ARMATURE':
used_vgroups.extend(get_deforming_vgroups(mesh_ob, modifier.object))
# Masks of Physics settings.
if hasattr(modifier, 'settings'):
used_vgroups.extend(get_referenced_vgroups(mesh_ob, modifier.settings))
# Masks of Shape Keys.
used_vgroups.extend(get_vgroups_used_by_shape_keys(mesh_ob))
# Constraints of dependent objects.
used_vgroups.extend(get_vgroups_used_by_constraints_of_dependent_objects(mesh_ob))
return used_vgroups
def get_referenced_vgroups(mesh_ob: Object, py_ob: object) -> list[VertexGroup]:
"""Return a list of vertex groups directly referenced by any of the PyObject's members.
Note that this is NOT a recursive function, and it can't really become one.
Useful for determining if a vertex group is used by anything or not, but
you still have to be thorough, and call this function on any sub-member
of some object that might reference vertex groups."""
referenced_vgroups = []
for member in dir(py_ob):
value = getattr(py_ob, member)
if type(value) != str:
continue
vg = mesh_ob.vertex_groups.get(value)
if vg:
referenced_vgroups.append(vg)
return referenced_vgroups
def get_vgroups_used_by_shape_keys(mesh_ob) -> list[VertexGroup]:
mask_vgroups = []
if not mesh_ob.data.shape_keys:
return mask_vgroups
for key_block in mesh_ob.data.shape_keys.key_blocks:
vgroup = mesh_ob.vertex_groups.get(key_block.vertex_group)
if vgroup and vgroup.name not in mask_vgroups:
mask_vgroups.append(vgroup)
return mask_vgroups
def get_vgroups_used_by_constraints_of_dependent_objects(
mesh_ob: Object,
) -> list[VertexGroup]:
used_vgroups = []
dependent_objs = [id for id in bpy.data.user_map()[mesh_ob] if type(id) == Object]
for dependent_obj in dependent_objs:
constraint_lists = [dependent_obj.constraints]
if dependent_obj.type == 'ARMATURE':
constraint_lists += [pb.constraints for pb in dependent_obj.pose.bones]
for constraint_list in constraint_lists:
for constraint in constraint_list:
if (
hasattr(constraint, 'target')
and hasattr(constraint, 'subtarget')
and constraint.target == mesh_ob
and constraint.subtarget
):
vgroup = mesh_ob.vertex_groups.get(constraint.subtarget)
if vgroup:
used_vgroups.append(vgroup)
if constraint.space_object == mesh_ob and constraint.space_subtarget:
vgroup = mesh_ob.vertex_groups.get(constraint.space_subtarget)
if vgroup:
used_vgroups.append(vgroup)
return used_vgroups
def get_vgroups_used_by_geonodes(mesh_ob: Object, modifier: Modifier) -> list[VertexGroup]:
used_vgroups = []
for identifier in geomod_get_input_identifiers(modifier):
use_attrib = identifier + "_use_attribute"
if use_attrib in modifier and modifier[use_attrib]:
attrib_name = modifier[identifier + "_attribute_name"]
if attrib_name in mesh_ob.vertex_groups:
# NOTE: Could be a false positive if this is a non-vertexgroup attribute with a matching name.
used_vgroups.append(mesh_ob.vertex_groups[attrib_name])
return used_vgroups
def geomod_get_input_identifiers(modifier: Modifier) -> set[str]:
if hasattr(modifier.node_group, 'interface'):
# 4.0
return {
socket.identifier
for socket in modifier.node_group.interface.items_tree
if socket.item_type == 'SOCKET'
and socket.in_out == 'INPUT'
and socket.socket_type != 'NodeSocketGeometry'
}
else:
# 3.6
return {input.identifier for input in modifier.node_group.inputs[1:]}
registry = [EASYWEIGHT_OT_delete_unused_vertex_groups]
@@ -0,0 +1,39 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from bpy.types import Operator
from ..utils import poll_weight_paint_mode, reveal_bone, get_deforming_vgroups
class EASYWEIGHT_OT_focus_deform_bones(Operator):
"""While in Weight Paint Mode, reveal the layers of, unhide, and select the bones of all deforming vertex groups"""
bl_idname = "object.focus_deform_vgroups"
bl_label = "Focus Deforming Bones"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
return poll_weight_paint_mode(cls, context, with_rig=True, with_groups=True)
def execute(self, context):
# Deselect all bones
for pb in context.selected_pose_bones[:]:
pb.bone.select = False
# Reveal and select all deforming pose bones.
deform_groups = get_deforming_vgroups(context.active_object)
rig = context.pose_object
for vg in deform_groups:
pb = rig.pose.bones.get(vg.name)
if not pb:
continue
reveal_bone(pb.bone, select=True)
self.report({'INFO'}, "Un-hid and selected all deforming bones.")
return {'FINISHED'}
registry = [EASYWEIGHT_OT_focus_deform_bones]
@@ -0,0 +1,217 @@
# 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]