Add Easy_Weight to Addons #47

Merged
Nick Alberelli merged 48 commits from feature/easy_weights into main 2023-05-17 22:13:57 +02:00
17 changed files with 2289 additions and 0 deletions

View File

@ -0,0 +1,57 @@
Easy Weight is an addon focused on quality of life improvements for weight painting in Blender.
### Entering Weight Paint Mode
The Toggle Weight Paint Mode operator lets you switch into weight paint mode easier.
Simply select your mesh object and run the operator. The armature will be un-hidden and put into pose mode if necessary.
Run the operator again to reset the armature object's visibility states to what they were before you entered weight paint mode.
I recommend setting up a keybind for this, eg.:
<img src="docs/toggle_wp_shortcut.png" width="400" />
If you don't want to use the hotkey editor, you can also just find the operator in the "Object" or "Weights" menus, and simply Right Click->Assign Shortcut.
### Weight Paint Context Menu
The default context menu (accessed via W key or Right Click) for weight paint mode is not very useful.
The Custom Weight Paint Context Menu operator is intended as a replacement for it.
<img src="docs/custom_wp_context_menu.png" width="250" />
_(The "OK" button is not needed, I just can't avoid it)_
This panel provides quick access to commonly needed tools, whether they are part of core Blender or the addon:
- Global toggles for the Accumulate, Front Faces Only and Falloff Shape brush options.
- Weight Paint mode settings including a new "Clean Weights" option to run the Clean Weights operator after every brush stroke.
- Commonly used Overlay and Armature display settings.
- Commonly used or [new](#vertex-group-operators) operators.
It lets you change the brush falloff type (Sphere/Projected) and the Front Faces Only option. These will affect ALL brushes.
Also, the color of your brushes will be darker when you're using Sphere falloff.
I recommend to overwrite the shortcut of the default weight paint context menu like so:
<img src="docs/wp_context_menu_shortcut.png" width="500" />
### Hunting Rogue Weights
The addon provides a super sleek workflow for hunting down rogue weights efficiently but safely, with just the right amount of automation. This functionality can be found in the Sidebar->EasyWeight->Weight Islands panel.
<img src="docs/weight_islands.png" width="800" />
- After pressing Calculate Weight Islands and waiting a few seconds, you will see a list of all vertex groups which consist of more than a single island.
- Clicking the magnifying glass icon will focus the smallest island in the group, so you can decide what to do with it.
- If the island is rogue weights, you can subtract them and go back to the previous step. If not, you can press the checkmark icon next to the magnifying glass, and the vertex group will be hidden from the list.
- Continue with this process until all entries are gone from the list.
- In the end, you can be 100% sure that you have no rogue weights anywhere on your mesh.
### Vertex Group Operators
The Vertex Groups context menu is re-organized with more icons and better labels, as well as some additional operators:
<img src="docs/vg_context_menu.png" width="500" />
- **Delete Empty Deform Groups**: Delete deforming groups that don't have any weights.
- **Delete Unused Non-Deform Groups**: Delete non-deforming groups that aren't used anywhere, even if they do have weights.
- **Delete Unselected Deform Groups**: Delete all deforming groups that don't correspond to a selected pose bone. Only in Weight Paint mode.
- **Ensure Mirror Groups**: If your object has a Mirror modifier, this will create any missing vertex groups.
- **Focus Deforming Bones**: Reveal and select all bones deforming this mesh. Only in Weight Paint mode.
If you have any more suggestions, feel free to open an Issue with a feature request.
- **Symmetrize Vertex Groups**: Symmetrizes vertex groups from left to right side, creating missing groups as needed.
### Force Apply Mirror Modifier
In Blender, you cannot apply a mirror modifier to meshes that have shape keys.
This operator tries to anyways, by duplicating your mesh, flipping it on the X axis and merging into the original. It will also flip vertex groups, shape keys, shape key masks, and even (attempt) shape key drivers, assuming everything is named with .L/.R suffixes.
### Previous Features
Over time as more things have been fixed on Blender's side, some features have been removed. To avoid confusion, these are listed here:
- As of [Blender 3.1](https://developer.blender.org/rBa215d7e230d3286abbed0108a46359ce57104bc1), holding the Ctrl and Shift buttons in weight painting will use the Subtract and Blur brushes respectively, removing the need for the shortcuts on the 1, 2, 3 keys this addon used to add to provide quick brush switching.
- As of [Blender 3.0](https://developer.blender.org/rBSc0f600cad1d2d107d189b15b12e2fcc6bba0985c), the weight paint overlay is no longer multiplied on top of the underlying colors, removing the need for this addon to change shading or object display settings when using the Toggle Weight Paint mode operator.

View File

@ -0,0 +1,104 @@
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
bl_info = {
"name": "Easy Weight",
"author": "Demeter Dzadik",
"version": (1,0),
"blender": (2, 90, 0),
"location": "Weight Paint > Weights > Easy Weight",
"description": "Operators to make weight painting easier.",
"category": "Rigging",
"doc_url": "https://gitlab.com/blender/easy_weight/-/blob/master/README.md",
"tracker_url": "https://gitlab.com/blender/easy_weight/-/issues/new",
}
import importlib
from bpy.utils import register_class, unregister_class
from typing import List
from . import smart_weight_transfer
from . import force_apply_mirror
from . import toggle_weight_paint
from . import weight_paint_context_menu
from . import vertex_group_operators
from . import vertex_group_menu
from . import rogue_weights
from . import util
# Each module is expected to have a register() and unregister() function.
modules = [
smart_weight_transfer,
force_apply_mirror,
toggle_weight_paint,
weight_paint_context_menu,
vertex_group_operators,
vertex_group_menu,
rogue_weights,
util
]
def register_unregister_modules(modules: List, register: bool):
"""Recursively register or unregister modules by looking for either
un/register() functions or lists named `registry` which should be a list of
registerable classes.
"""
register_func = register_class if register else unregister_class
for m in modules:
if register:
importlib.reload(m)
if hasattr(m, 'registry'):
for c in m.registry:
try:
register_func(c)
except Exception as e:
un = 'un' if not register else ''
print(f"Warning: CloudRig failed to {un}register class: {c.__name__}")
print(e)
if hasattr(m, 'modules'):
register_unregister_modules(m.modules, register)
if register and hasattr(m, 'register'):
m.register()
elif hasattr(m, 'unregister'):
m.unregister()
def register():
register_unregister_modules(modules, True)
print("Registering Easy Weight Hotkeys")
util.register_hotkey('paint.weight_paint'
,hotkey_kwargs = {'type': 'LEFTMOUSE', 'value': 'PRESS'}
,key_cat = 'Weight Paint'
,space_type = 'VIEW_3D'
,mode = 'NORMAL'
)
util.register_hotkey('paint.weight_paint'
,hotkey_kwargs = {'type': 'LEFTMOUSE', 'value': 'PRESS', 'ctrl' : True}
,key_cat = 'Weight Paint'
,space_type = 'VIEW_3D'
,mode = 'INVERT'
)
util.register_hotkey('paint.weight_paint'
,hotkey_kwargs = {'type': 'LEFTMOUSE', 'value': 'PRESS', 'shift' : True}
,key_cat = 'Weight Paint'
,space_type = 'VIEW_3D'
,mode = 'SMOOTH'
)
def unregister():
register_unregister_modules(modules, False)

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,234 @@
import bpy
from bpy.props import BoolProperty
from .utils.naming import flip_name
# TODO: Should find a way to select the X axis verts before doing Remove Doubles, or don't Remove Doubles at all. Also need to select the Basis shape before doing Remove Doubles.
# TODO: Implement our own Remove Doubles algo with kdtree, which would average the vertex weights of the merged verts rather than just picking the weights of one of them at random.
def flip_driver_targets(obj):
# We just need to flip the bone targets on every driver.
shape_keys = obj.data.shape_keys
if not shape_keys: return
if not hasattr(shape_keys.animation_data, "drivers"): return
drivers = shape_keys.animation_data.drivers
for sk in shape_keys.key_blocks:
for D in drivers: # Capital D signifies that this is a driver container (known as a driver) rather than a driver(also known as driver) - Yes, the naming convention for drivers in python API is BAD. D=driver, d=driver.driver.
print("Data path: " + D.data_path)
if(sk.name in D.data_path):
sk.vertex_group = flip_name(sk.vertex_group)
print("Shape key: " + sk.name)
for var in D.driver.variables:
print("var: " + var.name)
for t in var.targets:
if(not t.bone_target): continue
print("target: " + t.bone_target)
t.bone_target = flip_name(t.bone_target)
class EASYWEIGHT_OT_force_apply_mirror(bpy.types.Operator):
""" Force apply mirror modifier by duplicating the object, flipping it on the X axis, merging into the original """
bl_idname = "object.force_apply_mirror_modifier"
bl_label = "Force Apply Mirror Modifier"
bl_options = {'REGISTER', 'UNDO'}
remove_doubles: BoolProperty(name="Remove Doubles", default=False)
weighted_normals: BoolProperty(name="Weighted Normals", default=True)
split_shape_keys: BoolProperty(name="Split Shape Keys", default=True, description="If shape keys end in either .L or .R, duplicate them and flip their mask vertex group name")
@classmethod
def poll(cls, context):
ob = context.object
if not ob or ob.type!='MESH': return False
for m in ob.modifiers:
if m.type=='MIRROR':
return True
return False
def execute(self, context):
# Remove Mirror Modifier
# Copy mesh
# Scale it -1 on X
# Flip vgroup names
# Join into original mesh
# Remove doubles
# Recalc Normals
# Weight Normals
o = context.object
# Find Mirror modifier.
mirror=None
for m in o.modifiers:
if(m.type=='MIRROR'):
mirror = m
break
if(not mirror):
return {'CANCELLED'}
if mirror.use_axis[:] != (True, False, False):
self.report({'ERROR'}, "Only X axis mirroring is supported for now.")
return {'CANCELLED'}
# Remove mirror modifier.
o.modifiers.remove(mirror)
# Set mode and selection.
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.select_all(action='DESELECT')
o.select_set(True)
context.view_layer.objects.active = o
# Remove Doubles - This should print out removed 0, otherwise we're gonna remove some important verts.
if self.remove_doubles:
print("Checking for doubles pre-mirror. If it doesn't say Removed 0 vertices, you should undo.")
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.remove_doubles(use_unselected=True)
bpy.ops.object.mode_set(mode='OBJECT')
# Reset scale
org_scale = o.scale[:]
o.scale = (1, 1, 1)
# Duplicate and scale object.
bpy.ops.object.duplicate()
flipped_o = bpy.context.object
flipped_o.scale = (-1, 1, 1)
# Flip vertex group names.
done = [] # Don't flip names twice...
for vg in flipped_o.vertex_groups:
if vg in done: continue
old_name = vg.name
flipped_name = flip_name(vg.name)
if old_name == flipped_name: continue
opp_vg = flipped_o.vertex_groups.get(flipped_name)
if opp_vg:
vg.name = "temp"
opp_vg.name = old_name
vg.name = flipped_name
done.append(opp_vg)
vg.name = flipped_name
done.append(vg)
# Split/Flip shape keys.
if self.split_shape_keys:
done = [] # Don't flip names twice...
shape_keys = flipped_o.data.shape_keys
if shape_keys:
keys = shape_keys.key_blocks
for sk in keys:
if(sk in done): continue
old_name = sk.name
flipped_name = flip_name(sk.name)
if(old_name == flipped_name): continue
opp_sk = keys.get(flipped_name)
if(opp_sk):
sk.name = "temp"
opp_sk.name = old_name
done.append(opp_sk)
sk.name = flipped_name
done.append(sk)
flip_driver_targets(flipped_o)
# Joining objects does not seem to preserve drivers on any except the active object, at least for shape keys.
# To work around this, we duplicate the flipped mesh again, so we can copy the drivers over from that copy to the merged version...
bpy.ops.object.duplicate()
copy_of_flipped = bpy.context.object
copy_of_flipped.select_set(False)
flipped_o.select_set(True)
o.select_set(True)
context.view_layer.objects.active = o # We want to be sure the original is the active so the object name doesn't get a .001
bpy.ops.object.join()
combined_object = bpy.context.object
# Copy drivers from the duplicate... TODO We are partially flipping the drivers in flip_driver_targets() and we do the rest here... I hate the drivers python API...
if hasattr(copy_of_flipped.data.shape_keys, "animation_data") and \
hasattr(copy_of_flipped.data.shape_keys.animation_data, "drivers"):
for old_D in copy_of_flipped.data.shape_keys.animation_data.drivers:
for sk in combined_object.data.shape_keys.key_blocks:
if(sk.name in old_D.data_path):
# Create the driver...
new_D = combined_object.data.shape_keys.driver_add('key_blocks["' + sk.name + '"].value')
new_d = new_D.driver
old_d = old_D.driver
expression = old_d.expression
# The beginning of shape key names will indicate which axes should be flipped... What an awful solution! :)
flip_x = False
flip_y = False
flip_z = False
flip_flags = sk.name.split("_")[0]
if(flip_flags in ['XYZ', 'XZ', 'XY', 'YZ', 'Z']): # This code is just getting better :)
if('X') in flip_flags:
flip_x = True
if('Y') in flip_flags:
flip_y = True
if('Z') in flip_flags:
flip_z = True
for v in old_d.variables:
new_v = new_d.variables.new()
new_v.name = v.name
new_v.type = v.type
for i in range(len(v.targets)):
if(new_v.type == 'SINGLE_PROP'):
new_v.targets[i].id_type = v.targets[i].id_type
new_v.targets[i].id = v.targets[i].id
new_v.targets[i].bone_target = v.targets[i].bone_target
new_v.targets[i].data_path = v.targets[i].data_path
new_v.targets[i].transform_type = v.targets[i].transform_type
new_v.targets[i].transform_space = v.targets[i].transform_space
if( new_v.targets[0].bone_target and
"SCALE" not in v.targets[0].transform_type and
(v.targets[0].transform_type.endswith("_X") and flip_x) or
(v.targets[0].transform_type.endswith("_Y") and flip_y) or
(v.targets[0].transform_type.endswith("_Z") and flip_z)
):
# Flipping sign - this is awful, I know.
if("-"+new_v.name in expression):
expression = expression.replace("-"+new_v.name, "+"+new_v.name)
elif("+ "+new_v.name in expression):
expression = expression.replace("+ "+new_v.name, "- "+new_v.name)
else:
expression = expression.replace(new_v.name, "-"+new_v.name)
new_d.expression = expression
# Delete the copy
copy_of_flipped.select_set(True)
combined_object.select_set(False)
bpy.ops.object.delete(use_global=False)
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.normals_make_consistent(inside=False)
# Mesh cleanup
if(self.remove_doubles):
bpy.ops.mesh.remove_doubles()
bpy.ops.object.mode_set(mode='OBJECT')
if(self.weighted_normals and "calculate_weighted_normals" in dir(bpy.ops.object)):
bpy.ops.object.calculate_weighted_normals()
# Restore scale
context.object.scale = org_scale
return {'FINISHED'}
def register():
from bpy.utils import register_class
register_class(EASYWEIGHT_OT_force_apply_mirror)
def unregister():
from bpy.utils import unregister_class
unregister_class(EASYWEIGHT_OT_force_apply_mirror)

View File

@ -0,0 +1,465 @@
from typing import List
import bpy, sys
from bpy.props import IntProperty, CollectionProperty, PointerProperty, StringProperty, BoolProperty
from bpy.types import (PropertyGroup, Panel, UIList, Operator,
Mesh, VertexGroup, MeshVertex, Object)
import itertools
import bmesh
from .vertex_group_operators import get_deforming_armature, get_deforming_vgroups
from .utils.naming import flip_name
"""
This module creates a workflow for hunting down and cleaning up rogue weights in the most efficient way possible.
All functionality can be found in the Sidebar->EasyWeight->Weight Islands panel.
"""
# TODO:
# UIList: Filtering options, explanations as to what the numbers mean. Maybe a warning for Calculate Islands operator when the mesh has a lot of verts or vgroups.
# Take the ProgressTracker class from Dependency Graph add-on and use it to give user feedback on weight island calculation progress.
class VertIndex(PropertyGroup):
index: IntProperty()
class WeightIsland(PropertyGroup):
vert_indicies: CollectionProperty(type=VertIndex) # TODO: Is this really needed?? Why can't a CollectionProperty(type=IntProperty) be fine??
class IslandGroup(PropertyGroup):
name: StringProperty() # Name of the vertex group this set of island is associated with
islands: CollectionProperty(type=WeightIsland)
num_expected_islands: IntProperty(
name="Expected Islands",
default=1,
min=1,
description="Number of weight islands that have been marked as the expected amount by the user. If the real amount differs from this value, a warning appears"
)
index: IntProperty()
def update_vgroup_islands(mesh, vgroup, vert_index_map, island_groups, island_group=None) -> IslandGroup:
islands = get_islands_of_vgroup(mesh, vgroup, vert_index_map)
if not island_group:
island_group = island_groups.add()
island_group.index = len(island_groups)-1
island_group.name = vgroup.name
else:
island_group.islands.clear()
for island in islands:
island_storage = island_group.islands.add()
for v_idx in island:
v_idx_storage = island_storage.vert_indicies.add()
v_idx_storage.index = v_idx
return island_group
def build_vert_index_map(mesh) -> dict:
"""Build a dictionary of vertex indicies pointing to a list of other vertex indicies that the vertex is connected to by an edge."""
assert bpy.context.mode == 'EDIT_MESH'
bpy.ops.mesh.select_mode(type='VERT')
bpy.ops.mesh.reveal()
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.object.vertex_group_clean(group_select_mode='ALL', limit=0, keep_single=False)
bm = bmesh.from_edit_mesh(mesh)
v_dict = {}
for vert in bm.verts:
connected_verts = []
for be in vert.link_edges:
for connected_vert in be.verts:
if connected_vert.index == vert.index: continue
connected_verts.append(connected_vert.index)
v_dict[vert.index] = connected_verts
return v_dict
def find_weight_island_vertices(mesh: Mesh, vert_idx: int, group_index: int, vert_idx_map: dict, island=[]) -> List[int]:
"""Recursively find all vertices that are connected to a vertex by edges, and are also in the same vertex group."""
island.append(vert_idx)
for connected_vert_idx in vert_idx_map[vert_idx]: # For each edge connected to the vert
if connected_vert_idx in island: # Avoid infinite recursion!
continue
for g in mesh.vertices[connected_vert_idx].groups: # For each group this other vertex belongs to
if g.group == group_index and g.weight: # If this vert is in the group
find_weight_island_vertices(mesh, connected_vert_idx, group_index, vert_idx_map, island) # Continue recursion
return island
def find_any_vertex_in_group(mesh: Mesh, vgroup: VertexGroup, excluded_indicies=[]) -> MeshVertex:
"""Return the index of the first vertex we find which is part of the
vertex group and optinally, has a specified selection state."""
# TODO: This is probably our performance bottleneck atm.
# We should build an acceleration structure for this similar to build_vert_index_map,
# to map each vertex group to all of the verts within it, so we only need to iterate
# like this once.
for v in mesh.vertices:
if v.index in excluded_indicies:
continue
for g in v.groups:
if vgroup.index == g.group:
return v
return None
def get_islands_of_vgroup(mesh: Mesh, vgroup: VertexGroup, vert_index_map: dict) -> List[List[int]]:
"""Return a list of lists of vertex indicies: Weight islands within this vertex group."""
islands = []
while True:
flat_islands = set(itertools.chain.from_iterable(islands))
any_vert_in_group = find_any_vertex_in_group(mesh, vgroup, excluded_indicies=flat_islands)
if not any_vert_in_group:
break
sys.setrecursionlimit(len(mesh.vertices)) # TODO: I guess recursion is bad and we should avoid it here? (we would just do the expand in a while True, and break if the current list of verts is the same as at the end of the last loop, no recursion involved.)
island = find_weight_island_vertices(mesh, any_vert_in_group.index, vgroup.index, vert_index_map, island=[])
sys.setrecursionlimit(990)
islands.append(island)
return islands
def select_vertices(mesh: Mesh, vert_indicies: List[int]):
assert bpy.context.mode != 'EDIT_MESH', "Object must not be in edit mode, otherwise vertex selection doesn't work!"
for vi in vert_indicies:
mesh.vertices[vi].select = True
def update_active_islands_index(obj):
"""Make sure the active entry is visible, keep incrementing index until that is the case."""
new_active_index = obj.active_islands_index + 1
looped = False
while True:
if new_active_index >= len(obj.island_groups):
new_active_index = 0
if looped:
break
looped = True
island_group = obj.island_groups[new_active_index]
if len(island_group.islands) < 2 or \
len(island_group.islands) == island_group.num_expected_islands:
new_active_index += 1
continue
break
obj.active_islands_index = new_active_index
class MarkIslandsAsOkay(Operator):
"""Mark this number of vertex islands to be the intended amount. Vertex group will be hidden from the list until this number changes"""
bl_idname = "object.set_expected_island_count"
bl_label = "Set Intended Island Count"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
vgroup: StringProperty(
name="Vertex Group",
default="",
description="Name of the vertex group whose intended island count will be set"
)
def execute(self, context):
obj = context.object
mesh = obj.data
org_mode = obj.mode
assert self.vgroup in obj.island_groups, f"Island Group {self.vgroup} not found in object {obj.name}, aborting."
# Update existing island data first
island_group = obj.island_groups[self.vgroup]
vgroup = obj.vertex_groups[self.vgroup]
bpy.ops.object.mode_set(mode='EDIT')
vert_index_map = build_vert_index_map(mesh)
bpy.ops.object.mode_set(mode=org_mode)
org_num_islands = len(island_group.islands)
island_group = update_vgroup_islands(mesh, vgroup, vert_index_map, obj.island_groups, island_group)
new_num_islands = len(island_group.islands)
if new_num_islands != org_num_islands:
if new_num_islands == 1:
self.report({'INFO'}, f"Vertex group is now a single island, changing expected island count no longer necessary.")
return {'FINISHED'}
self.report({'INFO'}, f"Vertex group island count changed from {org_num_islands} to {new_num_islands}. Click again to mark this as the expected number.")
return {'FINISHED'}
island_group.num_expected_islands = new_num_islands
update_active_islands_index(obj)
return {'FINISHED'}
class FocusSmallestIsland(Operator):
"""Enter Weight Paint mode and focus on the smallest island"""
bl_idname = "object.focus_smallest_weight_island"
bl_label = "Focus Smallest Island"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
enter_wp: BoolProperty(
name="Enter Weight Paint",
default=True,
description="Enter Weight Paint Mode using the Toggle Weight Paint operator"
)
vgroup: StringProperty(
name="Vertex Group",
default="",
description="Name of the vertex group whose smallest island should be focused"
)
focus_view: BoolProperty(
name="Focus View",
default=True,
description="Whether to focus the 3D Viewport on the selected vertices"
)
def execute(self, context):
rig = context.pose_object
obj = context.object
mesh = obj.data
org_mode = obj.mode
assert self.vgroup in obj.vertex_groups, f"Vertex Group {self.vgroup} not found in object {obj.name}, aborting."
# Also update the opposite side vertex group
vgroup_names = [self.vgroup]
flipped = flip_name(self.vgroup)
if flipped != self.vgroup:
vgroup_names.append(flipped)
bpy.ops.object.mode_set(mode='EDIT')
vert_index_map = build_vert_index_map(mesh)
bpy.ops.object.mode_set(mode=org_mode)
hid_islands = False
for vg_name in vgroup_names:
if vg_name in obj.island_groups:
# Update existing island data first
island_group = obj.island_groups[vg_name]
vgroup = obj.vertex_groups[vg_name]
org_num_islands = len(island_group.islands)
island_group = update_vgroup_islands(mesh, vgroup, vert_index_map, obj.island_groups, island_group)
new_num_islands = len(island_group.islands)
if new_num_islands < 2:
hid_islands = True
self.report({'INFO'}, f"Vertex group {vg_name} no longer has multiple islands, hidden from list.")
if hid_islands:
update_active_islands_index(obj)
return {'FINISHED'}
# self.report({'INFO'}, f"Vertex group island count changed from {org_num_islands} to {new_num_islands}. Click again to focus smallest island.")
# return {'FINISHED'}
if org_mode != 'EDIT':
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_mode(type='VERT')
bpy.ops.mesh.reveal()
bpy.ops.mesh.select_all(action='DESELECT')
if org_mode != 'EDIT':
bpy.ops.object.mode_set(mode=org_mode)
else:
bpy.ops.object.mode_set(mode='OBJECT')
island_groups = obj.island_groups
island_group = island_groups[self.vgroup]
vgroup = obj.vertex_groups[self.vgroup]
obj.active_islands_index = island_group.index
obj.vertex_groups.active_index = vgroup.index
smallest_island = min(island_group.islands, key=lambda island: len(island.vert_indicies))
select_vertices(mesh, [vi.index for vi in smallest_island.vert_indicies])
if self.focus_view:
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.view3d.view_selected()
bpy.ops.object.mode_set(mode=org_mode)
if self.enter_wp and org_mode != 'WEIGHT_PAINT':
bpy.ops.object.weight_paint_toggle()
# Select the bone
if context.mode == 'PAINT_WEIGHT':
rig = context.pose_object
if rig:
for pb in rig.pose.bones:
pb.bone.select = False
if self.vgroup in rig.pose.bones:
rig.pose.bones[self.vgroup].bone.select = True
mesh.use_paint_mask_vertex = True
return {'FINISHED'}
class CalculateWeightIslands(Operator):
"""Detect number of weight islands for each deforming vertex group"""
bl_idname = "object.calculate_weight_islands"
bl_label = "Calculate Weight Islands"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
@staticmethod
def store_all_weight_islands(obj: Object, vert_index_map: dict):
"""Store the weight island information of every deforming vertex group."""
mesh = obj.data
island_groups = obj.island_groups
island_groups.clear() # TODO: This is bad, we need to hold onto num_expected_islands.
obj.active_islands_index = 0
for vgroup in get_deforming_vgroups(obj):
if 'skip_groups' in obj and vgroup.name in obj['skip_groups']:
continue
obj.vertex_groups.active_index = vgroup.index
update_vgroup_islands(mesh, vgroup, vert_index_map, island_groups)
@classmethod
def poll(cls, context):
if not context.object or context.object.type != 'MESH':
return False
return context.mode != 'EDIT_MESH'
def execute(self, context):
obj = context.object
rig = get_deforming_armature(obj)
org_mode = obj.mode
# TODO: Is it better to have this here instead of poll()?
assert rig, "Error: Object must be deformed by an armature, otherwise we can not tell which vertex groups are deforming."
org_vg_idx = obj.vertex_groups.active_index
org_mode = obj.mode
mesh = obj.data
bpy.ops.object.mode_set(mode='EDIT')
vert_index_map = build_vert_index_map(mesh)
bpy.ops.object.mode_set(mode='OBJECT')
self.store_all_weight_islands(obj, vert_index_map)
bpy.ops.object.mode_set(mode=org_mode)
return {'FINISHED'}
class EASYWEIGHT_UL_weight_island_groups(UIList):
@staticmethod
def draw_header(layout):
row = layout.row()
split1 = row.split(factor=0.5)
row1 = split1.row()
row1.label(text="Vertex Group")
row1.alignment = 'RIGHT'
row1.label(text="|")
row2 = split1.row()
row2.label(text="Islands")
def filter_items(self, context, data, propname):
flt_flags = []
flt_neworder = []
island_groups = getattr(data, propname)
helper_funcs = bpy.types.UI_UL_list
if self.filter_name:
flt_flags = helper_funcs.filter_items_by_name(self.filter_name, self.bitflag_filter_item, island_groups, "name",
reverse=self.use_filter_sort_reverse)
if not flt_flags:
flt_flags = [self.bitflag_filter_item] * len(island_groups)
if self.use_filter_invert:
for idx, flag in enumerate(flt_flags):
flt_flags[idx] = 0 if flag else self.bitflag_filter_item
for idx, island_group in enumerate(island_groups):
if len(island_group.islands) < 1:
# Filter island groups with only 1 or 0 islands in them
flt_flags[idx] = 0
elif len(island_group.islands) == island_group.num_expected_islands:
# Filter island groups with the expected number of islands in them
flt_flags[idx] = 0
return flt_flags, flt_neworder
def draw_filter(self, context, layout):
# Nothing much to say here, it's usual UI code...
main_row = layout.row()
row = main_row.row(align=True)
row.prop(self, 'filter_name', text="")
row.prop(self, 'use_filter_invert', toggle=True, text="", icon='ARROW_LEFTRIGHT')
row = main_row.row(align=True)
row.use_property_split=True
row.use_property_decorate=False
row.prop(self, 'use_filter_sort_alpha', toggle=True, text="")
row.prop(self, 'use_filter_sort_reverse', toggle=True, text="", icon='SORT_ASC')
def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
island_group = item
if self.layout_type in {'DEFAULT', 'COMPACT'}:
icon = 'ERROR'
num_islands = len(island_group.islands)
if num_islands == island_group.num_expected_islands:
icon = 'CHECKMARK'
row = layout.row()
split = row.split(factor=0.5)
row1 = split.row()
row1.label(text=island_group.name)
row1.alignment = 'RIGHT'
row1.label(text="|")
row2 = split.row()
row2.label(text=str(num_islands), icon=icon)
op = row2.operator(FocusSmallestIsland.bl_idname, text="", icon='VIEWZOOM').vgroup = island_group.name
row2.operator(MarkIslandsAsOkay.bl_idname, text="", icon='CHECKMARK').vgroup = island_group.name
# TODO: Operator to mark current number of islands as the expected amount
elif self.layout_type in {'GRID'}:
pass
class EASYWEIGHT_PT_WeightIslands(Panel):
"""Panel with utilities for detecting rogue weights."""
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = 'EasyWeight'
bl_label = "Weight Islands"
@classmethod
def poll(cls, context):
return context.object and context.object.type=='MESH'
def draw(self, context):
layout = self.layout
layout.operator(CalculateWeightIslands.bl_idname)
obj = context.object
island_groups = obj.island_groups
if len(island_groups)==0: return
active_weight_islands = obj.island_groups[obj.active_islands_index]
EASYWEIGHT_UL_weight_island_groups.draw_header(layout)
row = layout.row()
row.template_list(
'EASYWEIGHT_UL_weight_island_groups',
'',
obj,
'island_groups',
obj,
'active_islands_index',
)
classes = [
VertIndex,
WeightIsland,
IslandGroup,
CalculateWeightIslands,
FocusSmallestIsland,
MarkIslandsAsOkay,
EASYWEIGHT_PT_WeightIslands,
EASYWEIGHT_UL_weight_island_groups
]
def register():
from bpy.utils import register_class
for c in classes:
register_class(c)
Object.island_groups = CollectionProperty(type=IslandGroup)
Object.active_islands_index = IntProperty()
def unregister():
from bpy.utils import unregister_class
for c in classes:
try:
unregister_class(c)
except RuntimeError:
pass # TODO: Sometimes fails to unregister for literally no reason.
del Object.island_groups
del Object.active_islands_index

View File

@ -0,0 +1,262 @@
bl_info = {
"name": "Distance Weighted Weight Transfer",
"description": "Smart Transfer Weights operator",
"author": "Mets 3D",
"version": (2, 0),
"blender": (2, 80, 0),
"location": "Search -> Smart Weight Transfer",
"category": "Object"
}
# This is probably fairly useless and will give roughly the same results as transferring weights with
# the Transfer Mesh Data operator set to Nearest Face Interpolated, and then running a Smooth Vertex Weights operator.
import bpy
import mathutils
from mathutils import Vector
import math
from bpy.props import *
import bmesh
def build_weight_dict(obj, vgroups=None, mask_vgroup=None, bone_combine_dict=None):
""" Builds and returns a dictionary that matches the vertex indicies of the object to a list of tuples containing the vertex group names that the vertex belongs to, and the weight of the vertex in that group.
vgroups: If passed, skip groups that aren't in vgroups.
bone_combine_dict: Can be specified if we want some bones to be merged into others, eg. passing in {'Toe_Main' : ['Toe1', 'Toe2', 'Toe3']} will combine the weights in the listed toe bones into Toe_Main. You would do this when transferring weights from a model of actual feet onto shoes.
"""
if(bone_combine_dict==""):
bone_combine_dict = None
weight_dict = {} # {vert index : [('vgroup_name', vgroup_value), ...], ...}
if(vgroups==None):
vgroups = obj.vertex_groups
for v in obj.data.vertices:
# TODO: instead of looking through all vgroups we should be able to get only the groups that this vert is assigned to via v.groups[0].group which gives the group id which we can use to get the group via Object.vertex_groups[id]
# With this maybe it's useless altogether to save the weights into a dict? idk.
# Although the reason we are doing it this way is because we wanted some bones to be considered the same as others. (eg. toe bones should be considered a single combined bone)
for vg in vgroups:
w = 0
try:
w = vg.weight(v.index)
except:
pass
if(bone_combine_dict):
# Adding the weights from any sub-vertexgroups defined in bone_combine_dict
if(vg.name in bone_combine_dict.keys()):
for sub_vg_name in bone_combine_dict[vg.name]:
sub_vg = obj.vertex_groups.get(sub_vg_name)
if(sub_vg==None): continue
try:
w = w + sub_vg.weight(v.index)
except RuntimeError:
pass
if(w==0): continue
# Masking transfer influence
if(mask_vgroup):
try:
multiplier = mask_vgroup.weight(v.index)
w = w * multiplier
except:
pass
# Create or append entry in the dict.
if(v.index not in weight_dict):
weight_dict[v.index] = [(vg.name, w)]
else:
weight_dict[v.index].append((vg.name, w))
return weight_dict
def build_kdtree(obj):
kd = mathutils.kdtree.KDTree(len(obj.data.vertices))
for i, v in enumerate(obj.data.vertices):
kd.insert(v.co, i)
kd.balance()
return kd
def smart_transfer_weights(obj_from, obj_to, weights, expand=2):
""" Smart Vertex Weight Transfer.
The number of nearby verts which it searches for depends on how far the nearest vert is. (This is controlled by max_verts, max_dist and dist_multiplier)
This means if a very close vert is found, it won't look for any more verts.
If the nearest vert is quite far away(or dist_multiplier is set high), it will average the influences of a larger number few verts.
The averaging of the influences is also weighted by their distance, so that a vertex which is twice as far away will contribute half as much influence to the final result.
weights: a dictionary of vertex weights that needs to be built with build_weight_dict().
expand: How many times the "selection" should be expanded around the nearest vert, to collect more verts whose weights will be averaged. 0 is like default weight transfer.
"""
# TODO: because we normalize at the end, it also means we are expecting the input weighs to be normalized. So we should call a normalize all automatically.
# Assuming obj_from is at least selected, but probably active. (Shouldn't matter thanks to multi-edit mode?)
bpy.ops.object.mode_set(mode='EDIT')
bm = bmesh.from_edit_mesh(obj_from.data)
kd = build_kdtree(obj_from)
for v in obj_to.data.vertices:
# Finding the nearest vertex on source object
nearest_co, nearest_idx, nearest_dist = kd.find(v.co)
# Find neighbouring verts to the nearest vert. Save their index to this list. Will later turn it into a list of (index, distance) tuples.
source_vert_indices = [nearest_idx]
bm.verts.ensure_lookup_table()
bmv = bm.verts[nearest_idx]
for i in range(0, expand):
new_indices = []
for v_idx in source_vert_indices:
cur_bmv = bm.verts[v_idx]
for e in cur_bmv.link_edges:
v_other = e.other_vert(cur_bmv)
if(v_other.index not in source_vert_indices):
new_indices.append(v_other.index)
source_vert_indices.extend(new_indices)
source_verts = []
for vi in source_vert_indices:
distance = (v.co - bm.verts[vi].co).length
source_verts.append((vi, distance))
# Sort valid verts by distance (least to most distance)
source_verts.sort(key=lambda tup: tup[1])
# Iterating through the source verts, from closest to furthest, and accumulating our target weight for each vertex group.
vgroup_weights = {} # Dictionary of Vertex Group Name : Weight
for i in range(0, len(source_verts)):
vert = source_verts[i]
# The closest vert's weights are multiplied by the farthest vert's distance, and vice versa. The 2nd closest will use the 2nd farthest, etc.
# Note: The magnitude of the distance vectors doesn't matter because at the end they will be normalized anyways.
pair_distance = source_verts[-i-1][1]
if(vert[0] not in weights): continue
for vg_name, vg_weight in weights[vert[0]]:
new_weight = vg_weight * pair_distance
if(vg_name not in vgroup_weights):
vgroup_weights[vg_name] = new_weight
else:
vgroup_weights[vg_name] = vgroup_weights[vg_name] + new_weight
# The sum is used to normalize the weights. This is important because otherwise the values would depend on object scale, and in the case of very small or very large objects, stuff could get culled.
weights_sum = sum(vgroup_weights.values())
# Assigning the final, normalized weights of this vertex to the vertex groups.
for vg_avg in vgroup_weights.keys():
target_vg = obj_to.vertex_groups.get(vg_avg)
if(target_vg == None):
target_vg = obj_to.vertex_groups.new(name=vg_avg)
target_vg.add([v.index], vgroup_weights[vg_avg]/weights_sum, 'REPLACE')
#bpy.ops.object.mode_set(mode='WEIGHT_PAINT')
w3_bone_dict_str = """{
'Toe_Def.L' : ['Toe_Thumb1.L', 'Toe_Thumb2.L', 'Toe_Index1.L', 'Toe_Index2.L', 'Toe_Middle1.L', 'Toe_Middle2.L', 'Toe_Ring1.L', 'Toe_Ring2.L', 'Toe_Pinky1.L', 'Toe_Pinky2.L'],
'Toe_Def.R' : ['Toe_Thumb1.R', 'Toe_Thumb2.R', 'Toe_Index1.R', 'Toe_Index2.R', 'Toe_Middle1.R', 'Toe_Middle2.R', 'Toe_Ring1.R', 'Toe_Ring2.R', 'Toe_Pinky1.R', 'Toe_Pinky2.R'],
'Hand_Def.L' : ['l_thumb_roll', 'l_pinky0', 'l_index_knuckleRoll', 'l_middle_knuckleRoll', 'l_ring_knuckleRoll'],
'Hand_Def.R' : ['r_thumb_roll', 'r_pinky0', 'r_index_knuckleRoll', 'r_middle_knuckleRoll', 'r_ring_knuckleRoll'],
}"""
class EASYWEIGHT_OT_smart_weight_transfer(bpy.types.Operator):
""" Transfer weights from active to selected objects based on weighted vert distances """
bl_idname = "object.smart_weight_transfer"
bl_label = "Smart Transfer Weights"
bl_options = {'REGISTER', 'UNDO'}
opt_source_vgroups: EnumProperty(name="Source Groups",
items=[("ALL", "All", "All"),
("SELECTED", "Selected Bones", "Selected Bones"),
("DEFORM", "Deform Bones", "Deform Bones"),
],
description="Which vertex groups to transfer from the source object",
default="ALL")
opt_wipe_originals: BoolProperty(name="Wipe originals",
default=True,
description="Wipe original vertex groups before transferring. Recommended. Does not wipe vertex groups that aren't being transferred in the first place")
opt_expand: IntProperty(name="Expand",
default=2,
min=0,
max=5,
description="Expand source vertex pool - Higher values give smoother weights but calculations can take extremely long")
def get_vgroups(self, context):
items = [('None', 'None', 'None')]
for vg in context.object.vertex_groups:
items.append((vg.name, vg.name, vg.name))
return items
opt_mask_vgroup: EnumProperty(name="Operator Mask",
items=get_vgroups,
description="The operator's effect will be masked by this vertex group, unless 'None'")
opt_bone_combine_dict: StringProperty(name='Combine Dict',
description="If you want some groups to be considered part of others(eg. to avoid transferring individual toe weights onto shoes), you can enter them here in the form of a valid Python dictionary, where the keys are the parent group name, and values are lists of child group names, eg: {'Toe_Main.L' : ['Toe1.L', 'Toe2.L'], 'Toe_Main.R' : ['Toe1.R', 'Toe2.R']}",
default=w3_bone_dict_str
)
@classmethod
def poll(cls, context):
return context.object # and (context.object.mode=='WEIGHT_PAINT')
def draw_smart_weight_transfer(self, context):
operator = self.layout.operator(EASYWEIGHT_OT_smart_weight_transfer.bl_idname, text=EASYWEIGHT_OT_smart_weight_transfer.bl_label)
def execute(self, context):
assert len(context.selected_objects) > 1, "At least two objects must be selected. Select the source object last, and enter weight paint mode."
bone_dict = ""
if(self.opt_bone_combine_dict != ""):
bone_dict = eval(self.opt_bone_combine_dict)
source_obj = context.object
for o in context.selected_objects:
if(o==source_obj or o.type!='MESH'): continue
#bpy.ops.object.mode_set(mode='OBJECT')
#bpy.ops.object.select_all(action='DESELECT')
vgroups = []
error = ""
if(self.opt_source_vgroups == "ALL"):
vgroups = source_obj.vertex_groups
error = "the source has no vertex groups."
elif(self.opt_source_vgroups == "SELECTED"):
assert context.selected_pose_bones, "No selected pose bones to transfer from."
vgroups = [source_obj.vertex_groups.get(b.name) for b in context.selected_pose_bones]
error = "no bones were selected."
elif(self.opt_source_vgroups == "DEFORM"):
vgroups = [source_obj.vertex_groups.get(b.name) for b in context.pose_object.data.bones if b.use_deform]
error = "there are no deform bones"
# Clean up
vgroups = [vg for vg in vgroups if vg != None]
assert len(vgroups) > 0, "No transferable Vertex Groups were found, " + error
# Delete the vertex groups from the destination mesh first...
if(self.opt_wipe_originals):
for vg in vgroups:
if(vg.name in o.vertex_groups):
o.vertex_groups.remove(o.vertex_groups.get(vg.name))
mask_vgroup = o.vertex_groups.get(self.opt_mask_vgroup)
weights = build_weight_dict(source_obj, vgroups, mask_vgroup, bone_dict)
smart_transfer_weights(source_obj, o, weights, self.opt_expand)
bpy.context.view_layer.objects.active = o
bpy.ops.object.mode_set(mode='WEIGHT_PAINT')
return { 'FINISHED' }
def register():
from bpy.utils import register_class
register_class(EASYWEIGHT_OT_smart_weight_transfer)
bpy.types.VIEW3D_MT_paint_weight.append(EASYWEIGHT_OT_smart_weight_transfer.draw_smart_weight_transfer)
def unregister():
from bpy.utils import unregister_class
unregister_class(EASYWEIGHT_OT_smart_weight_transfer)
bpy.types.VIEW3D_MT_paint_weight.remove(EASYWEIGHT_OT_smart_weight_transfer.draw_smart_weight_transfer)

View File

@ -0,0 +1,156 @@
import bpy
from bpy.types import Object, Operator, VIEW3D_MT_paint_weight, VIEW3D_MT_object
# This operator is added to the Object menu.
# It does the following:
# Set active object to weight paint mode
# Find first armature via the object's modifiers.
# Ensure it is visible, select it and set it to pose mode.
# This allows you to start weight painting with a single button press from any state.
# When running the operator again, it should restore all armature visibility related settings to how it was before.
def get_armature_of_meshob(obj: Object):
"""Find and return the armature that deforms this mesh object."""
for m in obj.modifiers:
if m.type=='ARMATURE':
return m.object
def enter_wp(context) -> bool:
"""Enter weight paint mode, change the necessary settings, and save their
original states so they can be restored when leaving wp mode."""
obj = context.object
wm = context.window_manager
# Store old shading settings in a Custom Property dictionary in the Scene.
if 'wpt' not in wm:
wm['wpt'] = {}
wpt = wm['wpt']
wpt_as_dict = wpt.to_dict()
# If we are entering WP mode for the first time or if the last time
# the operator was exiting WP mode, then save current state.
if 'last_switch_in' not in wpt_as_dict or wpt_as_dict['last_switch_in']==False:
wpt['active_object'] = obj
# This flag indicates that the last time this operator ran, we were
# switching INTO wp mode.
wpt['last_switch_in'] = True
wpt['mode'] = obj.mode
# Enter WP mode.
bpy.ops.object.mode_set(mode='WEIGHT_PAINT')
### ENSURING ARMATURE VISIBILITY
armature = get_armature_of_meshob(obj)
if not armature:
return
# Save all object visibility related info so it can be restored later.
wpt['arm_enabled'] = armature.hide_viewport
wpt['arm_hide'] = armature.hide_get()
wpt['arm_in_front'] = armature.show_in_front
wpt['arm_coll_assigned'] = False
armature.hide_viewport = False
armature.hide_set(False)
armature.show_in_front = True
if context.space_data.local_view:
wpt['arm_local_view'] = armature.local_view_get(context.space_data)
armature.local_view_set(context.space_data, True)
# If the armature is still not visible, add it to the scene root collection.
if not armature.visible_get() and not armature.name in context.scene.collection.objects:
context.scene.collection.objects.link(armature)
wpt['arm_coll_assigned'] = True
if armature.visible_get():
context.view_layer.objects.active = armature
bpy.ops.object.mode_set(mode='POSE')
context.view_layer.objects.active = obj
return armature.visible_get()
def leave_wp(context):
"""Leave weight paint mode, then find, restore, and delete the data
that was stored about shading settings in enter_wp()."""
obj = context.object
wm = context.window_manager
if 'wpt' not in wm or 'mode' not in wm['wpt'].to_dict():
# There is no saved data to restore from, nothing else to do.
bpy.ops.object.mode_set(mode='OBJECT')
return {'FINISHED'}
wpt = wm['wpt']
wpt_as_dict = wpt.to_dict()
# Restore mode.
bpy.ops.object.mode_set(mode=wpt_as_dict['mode'])
# Reset the stored data
wm['wpt'] = {}
# Flag to save that the last time the operator ran we were EXITING wp mode.
wm['wpt']['last_switch_in'] = False
armature = get_armature_of_meshob(obj)
if not armature:
return
# If an armature was un-hidden, hide it again.
armature.hide_viewport = wpt_as_dict['arm_enabled']
armature.hide_set(wpt_as_dict['arm_hide'])
armature.show_in_front = wpt_as_dict['arm_in_front']
# Restore whether the armature is in local view or not.
if 'arm_local_view' in wpt_as_dict and context.space_data.local_view:
armature.local_view_set(context.space_data, wpt_as_dict['arm_local_view'])
# Remove armature from scene root collection if it was moved there.
if wpt_as_dict['arm_coll_assigned']:
context.scene.collection.objects.unlink(armature)
return
class EASYWEIGHT_OT_toggle_weight_paint(Operator):
"""Enter weight paint mode on a mesh object and pose mode on its armature"""
bl_idname = "object.weight_paint_toggle"
bl_label = "Toggle Weight Paint Mode"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
ob = context.object
return ob and ob.type=='MESH'
def draw(self, context):
self.layout.operator(EASYWEIGHT_OT_toggle_weight_paint.bl_idname)
def execute(self, context):
obj = context.object
if obj.mode != 'WEIGHT_PAINT':
armature_visible = enter_wp(context)
if armature_visible == False:
# This should never happen, but it also doesn't break anything.
self.report({'WARNING'}, "Could not make Armature visible.")
return {'FINISHED'}
else:
leave_wp(context)
return {'FINISHED'}
def register():
from bpy.utils import register_class
register_class(EASYWEIGHT_OT_toggle_weight_paint)
VIEW3D_MT_paint_weight.append(EASYWEIGHT_OT_toggle_weight_paint.draw)
VIEW3D_MT_object.append(EASYWEIGHT_OT_toggle_weight_paint.draw)
def unregister():
from bpy.utils import unregister_class
unregister_class(EASYWEIGHT_OT_toggle_weight_paint)
VIEW3D_MT_paint_weight.remove(EASYWEIGHT_OT_toggle_weight_paint.draw)
VIEW3D_MT_object.remove(EASYWEIGHT_OT_toggle_weight_paint.draw)

View File

@ -0,0 +1,21 @@
import bpy
def register_hotkey(bl_idname, hotkey_kwargs, *, key_cat='Window', space_type='EMPTY', **op_kwargs):
wm = bpy.context.window_manager
addon_keyconfig = wm.keyconfigs.addon
if not addon_keyconfig:
# This happens when running Blender in background mode.
return
keymaps = addon_keyconfig.keymaps
km = keymaps.get(key_cat)
if not km:
km = keymaps.new(name=key_cat, space_type=space_type)
if bl_idname not in km.keymap_items:
kmi = km.keymap_items.new(bl_idname, **hotkey_kwargs)
else:
kmi = km.keymap_items[bl_idname]
for key in op_kwargs:
value = op_kwargs[key]
setattr(kmi.properties, key, value)

View File

@ -0,0 +1,169 @@
from typing import Tuple, List, Optional
import re
separators = "-_."
def get_name(thing) -> str:
if hasattr(thing, 'name'):
return thing.name
else:
return str(thing)
def make_name(prefixes=[], base="", suffixes=[],
prefix_separator="-", suffix_separator=".") -> str:
"""Make a name from a list of prefixes, a base, and a list of suffixes."""
name = ""
for pre in prefixes:
if pre=="": continue
name += pre + prefix_separator
name += base
for suf in suffixes:
if suf=="": continue
name += suffix_separator + suf
return name
def slice_name(name, prefix_separator="-", suffix_separator="."):
"""Break up a name into its prefix, base, suffix components."""
prefixes = name.split(prefix_separator)[:-1]
suffixes = name.split(suffix_separator)[1:]
base = name.split(prefix_separator)[-1].split(suffix_separator)[0]
return [prefixes, base, suffixes]
def has_trailing_zeroes(thing):
name = get_name(thing)
regex = "\.[0-9][0-9][0-9]$"
search = re.search(regex, name)
return search != None
def strip_trailing_numbers(name) -> Tuple[str, str]:
if "." in name:
# Check if there are only digits after the last period
slices = name.split(".")
after_last_period = slices[-1]
before_last_period = ".".join(slices[:-1])
# If there are only digits after the last period, discard them
if all([c in "0123456789" for c in after_last_period]):
return before_last_period, "."+after_last_period
return name, ""
def get_side_lists(with_separators=False) -> Tuple[List[str], List[str], List[str]]:
left = ['left', 'Left', 'LEFT', 'l', 'L',]
right_placehold = ['*rgt*', '*Rgt*', '*RGT*', '*r*', '*R*']
right = ['right', 'Right', 'RIGHT', 'r', 'R']
# If the name is longer than 2 characters, only swap side identifiers if they
# are next to a separator.
if with_separators:
for l in [left, right_placehold, right]:
l_copy = l[:]
for side in l_copy:
if len(side)<4:
l.remove(side)
for sep in separators:
l.append(side+sep)
l.append(sep+side)
return left, right_placehold, right
def flip_name(from_name, ignore_base=True, must_change=False) -> str:
"""Turn a left-sided name into a right-sided one or vice versa.
Based on BLI_string_flip_side_name:
https://developer.blender.org/diffusion/B/browse/master/source/blender/blenlib/intern/string_utils.c
ignore_base: When True, ignore occurrences of side hints unless they're in
the beginning or end of the name string.
must_change: When True, raise an error if the name couldn't be flipped.
"""
# Handling .### cases
stripped_name, number_suffix = strip_trailing_numbers(from_name)
def flip_sides(list_from, list_to, name):
for side_idx, side in enumerate(list_from):
opp_side = list_to[side_idx]
if ignore_base:
# Only look at prefix/suffix.
if name.startswith(side):
name = name[len(side):]+opp_side
break
elif name.endswith(side):
name = name[:-len(side)]+opp_side
break
else:
# When it comes to searching the middle of a string,
# sides must strictly be a full word or separated with "."
# otherwise we would catch stuff like "_leg" and turn it into "_reg".
if not any([char not in side for char in "-_."]):
# Replace all occurences and continue checking for keywords.
name = name.replace(side, opp_side)
continue
return name
with_separators = len(stripped_name)>2
left, right_placehold, right = get_side_lists(with_separators)
flipped_name = flip_sides(left, right_placehold, stripped_name)
flipped_name = flip_sides(right, left, flipped_name)
flipped_name = flip_sides(right_placehold, right, flipped_name)
# Re-add trailing digits (.###)
new_name = flipped_name + number_suffix
if must_change:
assert new_name != from_name, "Failed to flip string: " + from_name
return new_name
def side_is_left(name) -> Optional[bool]:
"""Identify whether a name belongs to the left or right side or neither."""
flipped_name = flip_name(name)
if flipped_name==name: return None # Return None to indicate neither side.
stripped_name, number_suffix = strip_trailing_numbers(name)
def check_start_side(side_list, name):
for side in side_list:
if name.startswith(side):
return True
return False
def check_end_side(side_list, name):
for side in side_list:
if name.endswith(side):
return True
return False
left, right_placehold, right = get_side_lists(with_separators=True)
is_left_prefix = check_start_side(left, stripped_name)
is_left_suffix = check_end_side(left, stripped_name)
is_right_prefix = check_start_side(right, stripped_name)
is_right_suffix = check_end_side(right, stripped_name)
# Prioritize suffix for determining the name's side.
if is_left_suffix or is_right_suffix:
return is_left_suffix
# If no relevant suffix found, try prefix.
if is_left_prefix or is_right_prefix:
return is_left_prefix
# If no relevant suffix or prefix found, try anywhere.
any_left = any([side in name for side in left])
any_right = any([side in name for side in right])
if not any_left and not any_right:
# If neither side found, return None.
return None
if any_left and not any_right:
return True
if any_right and not any_left:
return False
# If left and right were both found somewhere, I give up.
return None

View File

@ -0,0 +1,195 @@
import bpy
from .vertex_group_operators import (
DeleteEmptyDeformGroups,
FocusDeformBones,
DeleteUnselectedDeformGroups,
DeleteUnusedVertexGroups,
CreateMirrorGroups
)
class MESH_MT_vertex_group_batch_delete(bpy.types.Menu):
bl_label = "Batch Delete"
def draw(self, context):
layout = self.layout
layout.operator(
"object.vertex_group_remove",
text="All Groups",
icon='TRASH'
).all = True
layout.operator(
"object.vertex_group_remove",
text="All Unlocked Groups",
icon='UNLOCKED'
).all_unlocked = True
layout.separator()
layout.operator(DeleteEmptyDeformGroups.bl_idname, text="Empty Deform Groups", icon='GROUP_BONE')
layout.operator(DeleteUnusedVertexGroups.bl_idname, text="Unused Non-Deform Groups", icon='BRUSH_DATA')
layout.operator(DeleteUnselectedDeformGroups.bl_idname, text="Unselected Deform Groups", icon='RESTRICT_SELECT_ON')
class MESH_MT_vertex_group_symmetry(bpy.types.Menu):
bl_label = "Symmetry"
def draw(self, context):
layout = self.layout
layout.operator(
"object.vertex_group_mirror",
text="Mirror Active Group (Proximity)",
icon='AUTOMERGE_OFF'
).use_topology = False
layout.operator(
"object.vertex_group_mirror",
text="Mirror Active Group (Topology)",
icon='AUTOMERGE_ON'
).use_topology = True
layout.separator()
layout.operator(
"object.symmetrize_vertex_weights",
text="Symmetrize Active Group",
icon='MOD_MIRROR'
).groups = 'ACTIVE'
layout.operator(
"object.symmetrize_vertex_weights",
text="Symmetrize Selected Bones' Groups",
icon='MOD_MIRROR'
).groups = 'BONES'
layout.operator(
"object.symmetrize_vertex_weights",
text="Symmetrize ALL Groups",
icon='MOD_MIRROR'
).groups = 'ALL'
class MESH_MT_vertex_group_sort(bpy.types.Menu):
bl_label = "Sort"
def draw(self, context):
layout = self.layout
layout.operator(
"object.vertex_group_sort",
icon='SORTALPHA',
text="By Name",
).sort_type = 'NAME'
layout.operator(
"object.vertex_group_sort",
icon='BONE_DATA',
text="By Bone Hierarchy",
).sort_type = 'BONE_HIERARCHY'
class MESH_MT_vertex_group_copy(bpy.types.Menu):
bl_label = "Copy"
def draw(self, context):
layout = self.layout
# TODO: This isn't grayed out when there's no active group.
# TODO: Maybe for things that use the active group, we should put the name of the group in the button text? Makes it harder to search tho perhaps. Not even sure if menu search supports dynamic menu text?
layout.operator("object.vertex_group_copy", icon='DUPLICATE', text="Duplicate Group")
layout.separator()
layout.operator("object.vertex_group_copy_to_linked", text="Synchronize Groups on All Instances", icon='LINKED')
layout.operator("object.vertex_group_copy_to_selected", text="Synchronize Groups on Selected", icon = 'RESTRICT_SELECT_OFF')
class MESH_MT_vertex_group_lock(bpy.types.Menu):
bl_label = "Batch Lock"
def draw(self, context):
layout = self.layout
props = layout.operator("object.vertex_group_lock", icon='LOCKED', text="Lock All")
props.action, props.mask = 'LOCK', 'ALL'
props = layout.operator("object.vertex_group_lock", icon='UNLOCKED', text="Unlock All")
props.action, props.mask = 'UNLOCK', 'ALL'
props = layout.operator("object.vertex_group_lock", icon='UV_SYNC_SELECT', text="Invert All Locks")
props.action, props.mask = 'INVERT', 'ALL'
class MESH_MT_vertex_group_weight(bpy.types.Menu):
bl_label = "Weights"
def draw(self, context):
layout = self.layout
layout.operator(
"object.vertex_group_remove_from",
icon='MESH_DATA',
text="Remove Selected Verts from All Groups",
).use_all_groups = True
layout.operator(
"object.vertex_group_clean",
icon='BRUSH_DATA',
text="Clean 0 Weights from All Groups"
).group_select_mode = 'ALL'
layout.separator()
layout.operator(
"object.vertex_group_remove_from",
icon='TRASH',
text="Remove All Verts from Selected Group"
).use_all_verts = True
layout.separator()
layout.operator(
'paint.weight_from_bones',
text="Assign Automatic from Bones",
icon='BONE_DATA'
).type='AUTOMATIC'
op = layout.operator(
'object.vertex_group_normalize_all',
text="Normalize Deform",
icon='IPO_SINE'
)
op.group_select_mode = 'BONE_DEFORM'
op.lock_active = False
def draw_misc(self, context):
layout = self.layout
layout.operator(FocusDeformBones.bl_idname, icon='ZOOM_IN')
# TODO: Add an operator called "Smart Cleanup" that creates missing mirror groups,
# Cleans 0 weights,
# Deletes unused deforming groups,
# and deletes unused non-deforming groups.
def draw_vertex_group_menu(self, context):
layout = self.layout
layout.row().menu(menu='MESH_MT_vertex_group_batch_delete', icon='TRASH')
layout.row().menu(menu='MESH_MT_vertex_group_symmetry', icon='ARROW_LEFTRIGHT')
layout.row().menu(menu='MESH_MT_vertex_group_sort', icon='SORTALPHA')
layout.row().menu(menu='MESH_MT_vertex_group_copy', icon='DUPLICATE')
layout.row().menu(menu='MESH_MT_vertex_group_lock', icon='LOCKED')
layout.row().menu(menu='MESH_MT_vertex_group_weight', icon='MOD_VERTEX_WEIGHT')
classes = [
MESH_MT_vertex_group_batch_delete,
MESH_MT_vertex_group_symmetry,
MESH_MT_vertex_group_sort,
MESH_MT_vertex_group_copy,
MESH_MT_vertex_group_lock,
MESH_MT_vertex_group_weight
]
def register():
from bpy.utils import register_class
for c in classes:
register_class(c)
bpy.types.MESH_MT_vertex_group_context_menu.old_draw = bpy.types.MESH_MT_vertex_group_context_menu.draw
bpy.types.MESH_MT_vertex_group_context_menu.remove(bpy.types.MESH_MT_vertex_group_context_menu.draw)
bpy.types.MESH_MT_vertex_group_context_menu.append(draw_vertex_group_menu)
bpy.types.MESH_MT_vertex_group_context_menu.append(draw_misc)
def unregister():
from bpy.utils import unregister_class
bpy.types.MESH_MT_vertex_group_context_menu.draw = bpy.types.MESH_MT_vertex_group_context_menu.old_draw
del bpy.types.MESH_MT_vertex_group_context_menu.old_draw
bpy.types.MESH_MT_vertex_group_context_menu.remove(draw_vertex_group_menu)
bpy.types.MESH_MT_vertex_group_context_menu.remove(draw_misc)
for c in classes:
unregister_class(c)

View File

@ -0,0 +1,420 @@
from typing import List, Tuple, Dict
import bpy
from bpy.types import Operator, VertexGroup, Object
from bpy.props import EnumProperty
from .utils.naming import flip_name
from mathutils.kdtree import KDTree
def get_deforming_armature(mesh_ob) -> Object:
for m in mesh_ob.modifiers:
if m.type=='ARMATURE':
return m.object
def delete_vgroups(mesh_ob, vgroups):
for vg in vgroups:
mesh_ob.vertex_groups.remove(vg)
def get_deforming_vgroups(mesh_ob) -> List[VertexGroup]:
arm_ob = get_deforming_armature(mesh_ob)
all_vgroups = mesh_ob.vertex_groups
deforming_vgroups = []
for b in arm_ob.data.bones:
if b.name in all_vgroups and b.use_deform:
deforming_vgroups.append(all_vgroups[b.name])
return deforming_vgroups
def get_empty_deforming_vgroups(mesh_ob) -> 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)]
# Always account for Mirror modifier:
if not 'MIRROR' in [m.type for m in mesh_ob.modifiers]:
return empty_deforming_groups
# A group is not considered empty if it is the opposite of a non-empty group.
for empty_vg in empty_deforming_groups[:]:
opposite_vg = mesh_ob.vertex_groups.get(flip_name(empty_vg.name))
if not opposite_vg:
continue
if opposite_vg not in empty_deforming_groups:
empty_deforming_groups.remove(empty_vg)
return empty_deforming_groups
def get_non_deforming_vgroups(mesh_ob) -> set:
all_vgroups = mesh_ob.vertex_groups
deforming_vgroups = get_deforming_vgroups(mesh_ob)
return set(all_vgroups) - set(deforming_vgroups)
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:
w = vgroup.weight(vert_idx)
return w
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
class DeleteEmptyDeformGroups(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):
obj = context.object
ob_is_mesh = obj and obj.type=='MESH'
if not ob_is_mesh: return False
ob_has_arm_mod = 'ARMATURE' in (m.type for m in obj.modifiers)
return obj.vertex_groups and ob_has_arm_mod
def execute(self, context):
empty_groups = get_empty_deforming_vgroups(context.object)
num_groups = len(empty_groups)
print(f"Deleting empty deform groups:")
print(" " + "\n ".join([vg.name for vg in empty_groups]))
self.report({'INFO'}, f"Deleted {num_groups} empty deform groups.")
delete_vgroups(context.object, empty_groups)
return {'FINISHED'}
class WeightPaintOperator(Operator):
@classmethod
def poll(cls, context):
obj = context.object
rig = context.pose_object
return context.mode == 'PAINT_WEIGHT' and obj and rig and obj.vertex_groups
class DeleteUnselectedDeformGroups(WeightPaintOperator):
"""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'}
def execute(self, context):
deforming_groups = get_deforming_vgroups(context.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.object, unselected_def_groups)
self.report({'INFO'}, f"Deleted {len(deleted_names)} unselected deform groups.")
return {'FINISHED'}
def reveal_bone(bone, select=True):
"""bone can be edit/pose/data bone.
This function should work regardless of selection or visibility states"""
if type(bone)==bpy.types.PoseBone:
bone = bone.bone
armature = bone.id_data
enabled_layers = [i for i in range(32) if armature.layers[i]]
# If none of this bone's layers are enabled, enable the first one.
bone_layers = [i for i in range(32) if bone.layers[i]]
if not any([i in enabled_layers for i in bone_layers]):
armature.layers[bone_layers[0]] = True
bone.hide = False
if select:
bone.select = True
class FocusDeformBones(WeightPaintOperator):
"""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'}
def execute(self, context):
deform_groups = get_deforming_vgroups(context.object)
rig = context.pose_object
# Deselect all bones
for pb in context.selected_pose_bones[:]:
pb.bone.select = False
# Reveal and select all deforming pose bones.
for vg in deform_groups:
pb = rig.pose.bones.get(vg.name)
if not pb: continue
reveal_bone(pb.bone)
return {'FINISHED'}
def get_referenced_vgroups(mesh_ob: Object, py_ob: object) -> List[VertexGroup]:
"""Return a list of vertex groups directly referenced by the object's attributes."""
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_shape_key_mask_vgroups(mesh_ob) -> List[VertexGroup]:
mask_vgroups = []
if not mesh_ob.data.shape_keys:
return mask_vgroups
for sk in mesh_ob.data.shape_keys.key_blocks:
vg = mesh_ob.vertex_groups.get(sk.vertex_group)
if vg and vg.name not in mask_vgroups:
mask_vgroups.append(vg)
return mask_vgroups
def delete_unused_vgroups(mesh_ob) -> List[str]:
non_deform_vgroups = get_non_deforming_vgroups(mesh_ob)
used_vgroups = []
# Modifiers
for m in mesh_ob.modifiers:
used_vgroups.extend(get_referenced_vgroups(mesh_ob, m))
# Physics settings
if hasattr(m, 'settings'):
used_vgroups.extend(get_referenced_vgroups(mesh_ob, m.settings))
# Shape Keys
used_vgroups.extend(get_shape_key_mask_vgroups(mesh_ob))
# Constraints: TODO. This is a pretty rare case, and will require checking through the entire blend file.
groups_to_delete = set(non_deform_vgroups) - set(used_vgroups)
names = [vg.name for vg in groups_to_delete]
print(f"Deleting unused non-deform groups:")
print(" " + "\n ".join(names))
delete_vgroups(mesh_ob, groups_to_delete)
return names
class DeleteUnusedVertexGroups(Operator):
"""Delete non-deforming vertex groups which are not used by any modifiers, shape keys or constraints"""
bl_idname = "object.delete_unused_vgroups"
bl_label = "Delete Unused Non-Deform Groups"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
obj = context.object
ob_is_mesh = obj and obj.type=='MESH'
if not ob_is_mesh: return False
ob_has_groups = len(obj.vertex_groups) > 0
return ob_has_groups
def execute(self, context):
deleted_names = delete_unused_vgroups(context.object)
self.report({'INFO'}, f"Deleted {len(deleted_names)} unused non-deform groups.")
return {'FINISHED'}
# TODO: This is now unused, remove it.
class CreateMirrorGroups(Operator):
"""Create missing Left/Right vertex groups to ensure correct behaviour of Mirror modifier"""
bl_idname = "object.ensure_mirror_vgroups"
bl_label = "Ensure Mirror Groups"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
obj = context.object
ob_is_mesh = obj and obj.type=='MESH'
if not ob_is_mesh: return False
ob_has_arm_mod = 'ARMATURE' in (m.type for m in obj.modifiers)
ob_has_mirror_mod = 'MIRROR' in (m.type for m in obj.modifiers)
return obj.vertex_groups and ob_has_arm_mod and ob_has_mirror_mod
def execute(self, context):
obj = context.object
deforming_groups = get_deforming_vgroups(obj)
new_counter = 0
print("Creating missing Mirror groups:")
for vg in deforming_groups:
flipped_name = flip_name(vg.name)
if flipped_name == vg.name:
continue
if flipped_name in obj.vertex_groups:
continue
obj.vertex_groups.new(name=flipped_name)
print(" "+flipped_name)
new_counter += 1
self.report({'INFO'}, f"Created {new_counter} missing groups")
return {'FINISHED'}
def get_symmetry_mapping(*
,obj: Object
,axis = 'X' # Only X axis is supported for now, since bpy.utils.flip_name() only supports X symmetry, as well as the "Mirror Vertex Group" checkbox in weight paint modeonly supports X symmetry.
,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.
zero_or_more = lambda x: x >= 0
zero_or_less = lambda x: 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().
"""
vg = obj.vertex_groups.get(vg_name)
if not vg:
return
opp_name = flip_name(vg_name)
opp_vg = obj.vertex_groups.get(opp_name)
if not opp_vg:
opp_vg = obj.vertex_groups.new(name=opp_name)
skip_func = None
if vg != opp_vg:
# Clear weights of the opposite group from all vertices.
opp_vg.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.
zero_or_more = lambda x: x >= 0
zero_or_less = lambda x: 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 = vg.weight(src_idx)
if src_weight == 0:
continue
except RuntimeError:
continue
opp_vg.add([dst_idx], src_weight, 'REPLACE')
class SymmetrizeVertexGroups(Operator):
"""Symmetrize weights of vertex groups"""
bl_idname = "object.symmetrize_vertex_weights"
bl_label = "Symmetrize Vertex Weights"
bl_options = {'REGISTER', 'UNDO'}
groups: EnumProperty(
name = "Subset"
,description = "Subset of vertex groups that should be symmetrized"
,items=[
('ACTIVE', 'Active', 'Active')
,('BONES', 'Selected Bones', 'Selected Bones')
,('ALL', 'All', 'All')
]
)
@classmethod
def poll(cls, context):
obj = context.object
if not (obj and obj.type=='MESH'):
return False
return obj.vertex_groups
def execute(self, context):
obj = context.object
symmetry_mapping = get_symmetry_mapping(obj=obj)
vgs = [obj.vertex_groups.active]
if self.groups == 'SELECTED':
# Get vertex groups of selected bones.
vgs = [obj.vertex_groups.get(pb.name) for pb in context.selected_pose_bones]
elif self.groups == 'ALL':
vgs = obj.vertex_groups
for vg in vgs:
symmetrize_vertex_group(
obj=obj,
vg_name=vg.name,
symmetry_mapping=symmetry_mapping
)
return {'FINISHED'}
classes = [
DeleteEmptyDeformGroups,
FocusDeformBones,
DeleteUnselectedDeformGroups,
DeleteUnusedVertexGroups,
CreateMirrorGroups,
SymmetrizeVertexGroups,
]
def register():
from bpy.utils import register_class
for c in classes:
register_class(c)
def unregister():
from bpy.utils import unregister_class
for c in classes:
try:
unregister_class(c)
except RuntimeError:
pass # TODO: Sometimes fails to unregister for literally no reason.

View File

@ -0,0 +1,206 @@
import bpy
from bpy.props import BoolProperty, EnumProperty
from bpy.app.handlers import persistent
from .vertex_group_operators import DeleteEmptyDeformGroups, DeleteUnusedVertexGroups
class EASYWEIGHT_OT_wp_context_menu(bpy.types.Operator):
""" Custom Weight Paint context menu """
bl_idname = "object.custom_weight_paint_context_menu"
bl_label = "Custom Weight Paint Context Menu"
bl_options = {'REGISTER'}
def update_clean_weights(self, context):
context.scene['clean_weights'] = self.clean_weights
WeightCleaner.cleaner_active = context.scene['clean_weights']
def update_front_faces(self, context):
for b in bpy.data.brushes:
if not b.use_paint_weight: continue
b.use_frontface = self.front_faces
def update_accumulate(self, context):
for b in bpy.data.brushes:
if not b.use_paint_weight: continue
b.use_accumulate = self.accumulate
def update_falloff_shape(self, context):
for b in bpy.data.brushes:
if not b.use_paint_weight: continue
b.falloff_shape = self.falloff_shape
for i, val in enumerate(b.cursor_color_add):
if val > 0:
b.cursor_color_add[i] = (0.5 if self.falloff_shape=='SPHERE' else 2.0)
clean_weights: BoolProperty(name="Clean Weights", description="Run the Clean Vertex Groups operator after every weight brush stroke", update=update_clean_weights)
front_faces: BoolProperty(name="Front Faces Only", description="Toggle the Front Faces Only setting for all weight brushes", update=update_front_faces)
accumulate: BoolProperty(name="Accumulate", description="Toggle the Accumulate setting for all weight brushes", update=update_accumulate)
falloff_shape: EnumProperty(name="Falloff Type", description="Select the Falloff Shape setting for all weight brushes", update=update_falloff_shape,
items=[
('SPHERE', 'Sphere', "The brush influence falls off along a sphere whose center is the mesh under the cursor's pointer"),
('PROJECTED', 'Projected', "The brush influence falls off in a tube around the cursor. This is useful for painting backfaces, as long as Front Faces Only is off.")
]
)
@classmethod
def poll(cls, context):
return context.mode=='PAINT_WEIGHT'
def draw_operators(self, layout, context):
layout.label(text="Operators")
op = layout.operator(
'object.vertex_group_normalize_all',
text="Normalize Deform",
icon='IPO_SINE'
)
op.group_select_mode = 'BONE_DEFORM'
op.lock_active = False
row = layout.row()
row.operator("object.vertex_group_clean", icon='BRUSH_DATA', text="Clean 0").group_select_mode = 'ALL'
row.operator(DeleteEmptyDeformGroups.bl_idname, text="Wipe Empty", icon='GROUP_BONE')
row.operator(DeleteUnusedVertexGroups.bl_idname, text="Wipe Unused", icon='BRUSH_DATA')
def draw_minimal(self, layout, context):
overlay = context.space_data.overlay
row = layout.row(heading="Symmetry: ")
# Compatibility for versions between rB5502517c3c12086c111a and rBfa9b05149c2ca3915a4fb26.
if hasattr(context.weight_paint_object.data, "use_mirror_vertex_group_x"):
row.prop(context.weight_paint_object.data, "use_mirror_vertex_group_x", text="X-Mirror", toggle=True)
else:
row.prop(context.weight_paint_object.data, "use_mirror_x", text="X-Mirror", toggle=True)
if hasattr(context.weight_paint_object.data, 'use_mirror_vertex_groups'):
row.prop(context.weight_paint_object.data, 'use_mirror_vertex_groups', text="Flip Groups", toggle=True)
row = layout.row(heading="Mesh Display: ")
row.prop(overlay, "show_wpaint_contours", text="Weight Contours", toggle=True)
row.prop(overlay, "show_paint_wire", text="Wireframe", toggle=True)
row = layout.row(heading="Bone Display: ")
row.prop(overlay, "show_bones", text="Bones", toggle=True)
if context.pose_object:
row.prop(context.pose_object, "show_in_front", toggle=True)
self.draw_operators(layout, context)
def draw_overlay_settings(self, layout, context):
overlay = context.space_data.overlay
tool_settings = context.tool_settings
layout.label(text="Overlay")
row = layout.row()
row.use_property_split=True
row.prop(tool_settings, "vertex_group_user", text="Zero Weights Display", expand=True)
if hasattr(context.space_data, "overlay"):
row = layout.row()
row.prop(overlay, "show_wpaint_contours", text="Weight Contours", toggle=True)
row.prop(overlay, "show_paint_wire", text="Wireframe", toggle=True)
row.prop(overlay, "show_bones", text="Bones", toggle=True)
if context.pose_object:
layout.label(text="Armature Display")
layout.prop(context.pose_object.data, "display_type", expand=True)
layout.prop(context.pose_object, "show_in_front", toggle=True)
def draw_weight_paint_settings(self, layout, context):
tool_settings = context.tool_settings
layout.label(text="Weight Paint settings")
row = layout.row()
row.prop(tool_settings, "use_auto_normalize", text="Auto Normalize", toggle=True)
row.prop(self, "clean_weights", toggle=True)
row.prop(tool_settings, "use_multipaint", text="Multi-Paint", toggle=True)
row = layout.row()
# Compatibility for versions between rB5502517c3c12086c111a and rBfa9b05149c2ca3915a4fb26.
if hasattr(context.weight_paint_object.data, "use_mirror_vertex_group_x"):
row.prop(context.weight_paint_object.data, "use_mirror_vertex_group_x", text="X-Mirror", toggle=True)
else:
row.prop(context.weight_paint_object.data, "use_mirror_x", text="X-Mirror", toggle=True)
if hasattr(context.weight_paint_object.data, 'use_mirror_vertex_groups'):
row.prop(context.weight_paint_object.data, 'use_mirror_vertex_groups', text="Flip Groups", toggle=True)
def draw_brush_settings(self, layout, context):
row = layout.row()
row.label(text="Brush Settings (Global)")
icon = 'HIDE_ON' if context.scene.easyweight_minimal else 'HIDE_OFF'
row.prop(context.scene, "easyweight_minimal", icon=icon, toggle=False, text="", emboss=False)
layout.prop(self, "accumulate", toggle=True)
layout.prop(self, "front_faces", toggle=True)
row = layout.row(heading="Falloff Shape: ")
row.prop(self, "falloff_shape", expand=True)
layout.separator()
def draw(self, context):
layout = self.layout
self.draw_brush_settings(layout, context)
layout.separator()
if context.scene.easyweight_minimal:
self.draw_minimal(layout, context)
return
self.draw_weight_paint_settings(layout, context)
layout.separator()
self.draw_overlay_settings(layout, context)
layout.separator()
self.draw_operators(layout, context)
def invoke(self, context, event):
active_brush = context.tool_settings.weight_paint.brush
self.front_faces = active_brush.use_frontface
self.falloff_shape = active_brush.falloff_shape
if 'clean_weights' not in context.scene:
context.scene['clean_weights'] = False
self.clean_weights = context.scene['clean_weights']
wm = context.window_manager
return wm.invoke_props_dialog(self)
def execute(self, context):
context.scene.tool_settings.vertex_group_user = 'ACTIVE'
return {'FINISHED'}
class WeightCleaner:
"""Run bpy.ops.object.vertex_group_clean on every depsgraph update while in weight paint mode (ie. every brush stroke)."""
# Most of the code is simply responsible for avoiding infinite looping depsgraph updates.
cleaner_active = False # Flag set by the user via the custom WP context menu.
can_clean = True # Flag set in post_depsgraph_update, to indicate to pre_depsgraph_update that the depsgraph update has indeed completed.
cleaning_in_progress = False # Flag set by pre_depsgraph_update to indicate to post_depsgraph_update that the cleanup operator is still running (in a different thread).
@classmethod
def clean_weights(cls, scene, depsgraph):
if bpy.context.mode!='PAINT_WEIGHT': return
if not bpy.context or not hasattr(bpy.context, 'object') or not bpy.context.object: return
if not cls.cleaner_active: return
if cls.can_clean:
cls.can_clean = False
cls.cleaning_in_progress = True
bpy.ops.object.vertex_group_clean(group_select_mode='ALL', limit=0.001) # This will trigger a depsgraph update, and therefore clean_weights, again.
cls.cleaning_in_progress = False
@classmethod
def reset_flag(cls, scene, depsgraph):
if bpy.context.mode!='PAINT_WEIGHT': return
if not bpy.context or not hasattr(bpy.context, 'object') or not bpy.context.object: return
if cls.cleaning_in_progress: return
if not cls.cleaner_active: return
cls.can_clean = True
@persistent
def start_cleaner(scene, depsgraph):
bpy.app.handlers.depsgraph_update_pre.append(WeightCleaner.clean_weights)
bpy.app.handlers.depsgraph_update_post.append(WeightCleaner.reset_flag)
def register():
from bpy.utils import register_class
register_class(EASYWEIGHT_OT_wp_context_menu)
bpy.types.Scene.easyweight_minimal = BoolProperty(name="Minimal", description="Hide options that are less frequently used", default=False)
start_cleaner(None, None)
bpy.app.handlers.load_post.append(start_cleaner)
def unregister():
from bpy.utils import unregister_class
del bpy.types.Scene.easyweight_minimal
unregister_class(EASYWEIGHT_OT_wp_context_menu)
bpy.app.handlers.load_post.remove(start_cleaner)