Add Easy_Weight
to Addons
#47
57
scripts-blender/addons/easy_weights/README.md
Normal file
57
scripts-blender/addons/easy_weights/README.md
Normal 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.
|
104
scripts-blender/addons/easy_weights/__init__.py
Normal file
104
scripts-blender/addons/easy_weights/__init__.py
Normal 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 |
BIN
scripts-blender/addons/easy_weights/docs/toggle_wp_shortcut.png
Normal file
BIN
scripts-blender/addons/easy_weights/docs/toggle_wp_shortcut.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
scripts-blender/addons/easy_weights/docs/vg_context_menu.png
Normal file
BIN
scripts-blender/addons/easy_weights/docs/vg_context_menu.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 43 KiB |
BIN
scripts-blender/addons/easy_weights/docs/weight_islands.png
Normal file
BIN
scripts-blender/addons/easy_weights/docs/weight_islands.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 374 KiB |
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
234
scripts-blender/addons/easy_weights/force_apply_mirror.py
Normal file
234
scripts-blender/addons/easy_weights/force_apply_mirror.py
Normal 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)
|
465
scripts-blender/addons/easy_weights/rogue_weights.py
Normal file
465
scripts-blender/addons/easy_weights/rogue_weights.py
Normal 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
|
262
scripts-blender/addons/easy_weights/smart_weight_transfer.py
Normal file
262
scripts-blender/addons/easy_weights/smart_weight_transfer.py
Normal 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)
|
156
scripts-blender/addons/easy_weights/toggle_weight_paint.py
Normal file
156
scripts-blender/addons/easy_weights/toggle_weight_paint.py
Normal 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)
|
21
scripts-blender/addons/easy_weights/util.py
Normal file
21
scripts-blender/addons/easy_weights/util.py
Normal 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)
|
169
scripts-blender/addons/easy_weights/utils/naming.py
Normal file
169
scripts-blender/addons/easy_weights/utils/naming.py
Normal 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
|
195
scripts-blender/addons/easy_weights/vertex_group_menu.py
Normal file
195
scripts-blender/addons/easy_weights/vertex_group_menu.py
Normal 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)
|
420
scripts-blender/addons/easy_weights/vertex_group_operators.py
Normal file
420
scripts-blender/addons/easy_weights/vertex_group_operators.py
Normal 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.
|
206
scripts-blender/addons/easy_weights/weight_paint_context_menu.py
Normal file
206
scripts-blender/addons/easy_weights/weight_paint_context_menu.py
Normal 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)
|
Loading…
Reference in New Issue
Block a user