Add Easy_Weight to Addons #47

Merged
Nick Alberelli merged 48 commits from feature/easy_weights into main 2023-05-17 22:13:57 +02:00
4 changed files with 317 additions and 80 deletions
Showing only changes of commit f4e12b8de4 - Show all commits

View File

@ -27,17 +27,24 @@ _(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 new ["Clean Weights"](#clean-weights) option
- Commonly used Overlay and Armature display settings
- Commonly used or [new](#vertex-group-operators) operators
- 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" />
### Clean Weights
This is a new functionality found in the custom WP context menu. When enabled, **this will run the Clean Vertex Groups operator after every brush stroke** while you're in weight paint mode. This means 0-weights are automatically removed as they appear, which helps avoid small weight islands and rogue weights appearing as you work.
### 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="500" />
- 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:

BIN
docs/weight_islands.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@ -1,14 +1,32 @@
from typing import List, Tuple
import bpy, sys
from bpy.types import Operator, Mesh, VertexGroup, MeshVertex, Object
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 WeightPaintOperator
from .vertex_group_operators import get_deforming_armature, get_deforming_vgroups
"""
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.
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:
@ -28,13 +46,19 @@ def find_weight_island_vertices(mesh: Mesh, vert_idx: int, group_index: int, ver
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 > 0: # If this vert is in the group
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
@ -43,111 +67,314 @@ def find_any_vertex_in_group(mesh: Mesh, vgroup: VertexGroup, excluded_indicies=
return v
return None
def build_weight_islands_in_group(mesh: Mesh, vgroup: VertexGroup, vert_index_map: dict) -> List[List[int]]:
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_unselected_vertex_in_group = find_any_vertex_in_group(mesh, vgroup, excluded_indicies=flat_islands)
if not any_unselected_vertex_in_group:
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?
island = find_weight_island_vertices(mesh, any_unselected_vertex_in_group.index, vgroup.index, vert_index_map, island=[])
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 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 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
# TODO: This needs to be split off into a separate, even smarter system with more UI:
# It should be like the error checking buttons in the 3D Print addon by Campbell:
# You press the operator, and it detects and SAVES all the issues that were found:
# There is a sidebar panel for this (probably along with other EasyWeight UI)
# Pressing the button populates a UIList with vertex groups that were found with multiple islands.
# Selecting an entry in the UIList brings up ANOTHER UIList with each island and the number of vertices in them.
# A vertex group can be marked as correct, which will save the number of islands it had in the moment when it was considered correct, so on subsequent runs of the operator, it will be ignored as long as it has that number of islands still.
# Each island can be selected with a button (also selects the corresponding pose bone)
class FocusRogueDeformingWeights(WeightPaintOperator):
"""While in weight paint mode, find and focus a deforming vertex group which consists of several islands. The smallest weight island is selected, but no weights are removed. Keep running this until no more rogue weights are found"""
bl_idname = "object.focus_rogue_weights"
bl_label = "Focus Rogue Weights"
bl_options = {'REGISTER', 'UNDO'}
class VertIndex(PropertyGroup):
index: IntProperty()
@staticmethod
def save_skip_group(obj, vgroup):
"""Store this group's name in a custom property so it can be skipped on subsequent runs."""
if 'skip_groups' not in obj:
obj['skip_groups'] = []
l = obj['skip_groups']
if type(l) != list:
l = l.to_list()
l.append(vgroup.name)
obj['skip_groups'] = l
class WeightIsland(PropertyGroup):
vert_indicies: CollectionProperty(type=VertIndex) # TODO: Is this really needed?? Why can't a CollectionProperty(type=IntProperty) be fine??
@staticmethod
def find_rogue_deform_weights(obj: Object, vert_index_map: dict) -> Tuple[VertexGroup, List[List[int]]]:
"""Return the first vertex group we find that has multiple islands, as well as the islands."""
mesh = obj.data
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
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()
islands = build_weight_islands_in_group(mesh, vgroup, vert_index_map)
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'}
if len(islands) > 1:
return vgroup, islands
return None, None
vgroup: StringProperty(
name="Vertex Group",
default="",
description="Name of the vertex group whose intended island count will be set"
)
def execute(self, context):
rig = context.pose_object
obj = context.object
org_vg_idx = obj.vertex_groups.active_index
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
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."
if self.vgroup in obj.island_groups:
# 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, hidden from list.")
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')
mesh = obj.data
vert_index_map = build_vert_index_map(mesh)
if org_mode != 'EDIT':
bpy.ops.object.mode_set(mode=org_mode)
else:
bpy.ops.object.mode_set(mode='OBJECT')
vgroup, islands = self.find_rogue_deform_weights(obj, vert_index_map)
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
if vgroup:
# Select the smallest island.
select_vertices(mesh, min(islands, key=len))
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])
# Support the case where the user chooses not to fix the rogue weights: Perhaps they are intentional
self.save_skip_group(obj, vgroup)
self.report({'INFO'}, f'Found Vertex Group "{vgroup.name}" with {len(islands)} islands. Fix or ignore it, then keep running this operator to find the next group with rogue weights.')
if rig:
rig.select_set(True)
mesh.use_paint_mask_vertex = True
bpy.ops.object.mode_set(mode='WEIGHT_PAINT')
else:
if rig:
rig.select_set(True)
obj.vertex_groups.active_index = org_vg_idx
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 'skip_groups' in obj and len(obj['skip_groups']) > 0:
self.report({'INFO'}, f"No rogue weights found, but {len(obj['skip_groups'])} were skipped. Keep running the operator to cycle through groups with multiple weight islands to make sure they are desired.")
del obj['skip_groups']
else:
self.report({'INFO'}, "No rogue weights found!")
if self.enter_wp and org_mode != 'WEIGHT_PAINT':
bpy.ops.object.weight_paint_toggle()
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_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]
row = layout.row()
row.template_list(
'EASYWEIGHT_UL_weight_island_groups',
'',
obj,
'island_groups',
obj,
'active_islands_index',
)
class EASYWEIGHT_UL_weight_island_groups(UIList):
def filter_items(self, context, data, propname):
flt_flags = []
flt_neworder = []
list_items = getattr(data, propname)
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_name_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_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()
row.label(text=island_group.name)
row.label(text=str(num_islands), icon=icon)
op = row.operator(FocusSmallestIsland.bl_idname, text="", icon='VIEWZOOM').vgroup = island_group.name
row.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
classes = [
FocusRogueDeformingWeights
VertIndex,
WeightIsland,
IslandGroup,
CalculateWeightIslands,
FocusSmallestIsland,
MarkIslandsAsOkay,
EASYWEIGHT_PT_WeightIslands,
EASYWEIGHT_UL_weight_island_groups
]
def register():
@ -155,6 +382,9 @@ def register():
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:
@ -162,3 +392,6 @@ def unregister():
unregister_class(c)
except RuntimeError:
pass # TODO: Sometimes fails to unregister for literally no reason.
del Object.island_groups
del Object.active_islands_index

View File

@ -2,7 +2,6 @@ import bpy
from bpy.props import BoolProperty, EnumProperty
from bpy.app.handlers import persistent
from .vertex_group_operators import DeleteEmptyDeformGroups, DeleteUnusedVertexGroups
from .rogue_weights import FocusRogueDeformingWeights
class EASYWEIGHT_OT_wp_context_menu(bpy.types.Operator):
""" Custom Weight Paint context menu """
@ -62,8 +61,6 @@ class EASYWEIGHT_OT_wp_context_menu(bpy.types.Operator):
row.operator(DeleteEmptyDeformGroups.bl_idname, text="Wipe Empty", icon='GROUP_BONE')
row.operator(DeleteUnusedVertexGroups.bl_idname, text="Wipe Unused", icon='BRUSH_DATA')
layout.operator(FocusRogueDeformingWeights.bl_idname, icon='ZOOM_IN')
def draw_minimal(self, layout, context):
overlay = context.space_data.overlay
row = layout.row(heading="Symmetry: ")