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
11 changed files with 1221 additions and 0 deletions
Showing only changes of commit b7d8f64a67 - Show all commits

38
README.md Normal file
View File

@ -0,0 +1,38 @@
Easy Weight is an addon focused on quality of life improvements for weight painting in Blender.
### Brush Switching
The addon will force-register keybinds for this operator to the 1, 2, 3 keys in Weight Paint mode:
1: Change to Add Brush.
2: Change to Subtract Brush.
3: Change to Blur Brush.
The brushes must have their default name, ie. "Add", "Subtract", "Blur".
### Entering Weight Paint Mode
The Toggle Weight Paint Mode operator lets you switch into weight paint mode easier.
Select your object and run the operator.
- It will find the first armature modifier of your mesh, if there is one. It will ensure the armature is visible and in pose mode.
- It will set the shading settings to a pure white.
- If your object's display type was set to Wire, it will set it to Solid.
Run the operator again to restore everything to how it was before.
I recommend setting up a keybind for this, eg.:
<img src="docs/toggle_wp_shortcut.png" width="400" />
### 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)_
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" />
### Toggle Weight Cleaner
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.
### 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.

47
__init__.py Normal file
View File

@ -0,0 +1,47 @@
# 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": "3D View"
}
import bpy
from . import smart_weight_transfer
from . import force_apply_mirror
from . import toggle_weight_paint
from . import change_brush
from . import weight_paint_context_menu
# Each module is expected to have a register() and unregister() function.
modules = [
smart_weight_transfer,
force_apply_mirror,
toggle_weight_paint,
change_brush,
weight_paint_context_menu
]
def register():
for m in modules:
m.register()
def unregister():
for m in modules:
m.unregister()

75
change_brush.py Normal file
View File

@ -0,0 +1,75 @@
import bpy
from bpy.props import *
from bpy.app.handlers import persistent
class ChangeWPBrush(bpy.types.Operator):
"""Change the weight paint brush to a specific brush."""
bl_idname = "brush.set_specific"
bl_label = "Set WP Brush"
bl_options = {'REGISTER', 'UNDO'}
brush: EnumProperty(name="Brush",
items=[('Add', 'Add', 'Add'),
('Subtract', 'Subtract', 'Subtract'),
('Draw', 'Draw', 'Draw'),
('Average', 'Average', 'Average'),
('Blur', 'Blur', 'Blur'),
],
default="Add")
def execute(self, context):
brush_name = self.brush
brush = bpy.data.brushes.get(brush_name)
if not brush:
# Create the brush.
brush = bpy.data.brushes.new(brush_name, mode='WEIGHT_PAINT')
if brush_name == 'Add':
brush.blend = 'ADD'
if brush_name == 'Subtract':
brush.blend = 'SUB'
if brush_name == 'Blur':
brush.weight_tool = 'BLUR'
if brush_name == 'Average':
brush.weight_tool = 'AVERAGE'
# Configure brush.
value = 0.5 if brush.falloff_shape == 'SPHERE' else 1.0 # We use a darker color to indicate when falloff shape is set to Sphere.
if brush_name=='Add':
brush.cursor_color_add = [value, 0.0, 0.0, 1.0]
if brush_name=='Subtract':
brush.cursor_color_add = [0.0, 0.0, value, 1.0]
if brush_name=='Blur':
brush.cursor_color_add = [value, value, value, 1.0]
# Set the brush as the active one.
bpy.context.tool_settings.weight_paint.brush = brush
return { 'FINISHED' }
@persistent
def register_brush_switch_hotkeys(dummy):
# Without this, the hotkeys' properties get reset whenever the addon is disabled, which results in having to set the Add, Subtract, Blur brushes on the hotkeys manually every time.
# However, with this, the hotkey cannot be changed, since this will forcibly re-create the original anyways.
wp_hotkeys = bpy.data.window_managers[0].keyconfigs.active.keymaps['Weight Paint'].keymap_items
add_hotkey = wp_hotkeys.new('brush.set_specific',value='PRESS',type='ONE',ctrl=False,alt=False,shift=False,oskey=False)
add_hotkey.properties.brush = 'Add'
add_hotkey.type = add_hotkey.type
sub_hotkey = wp_hotkeys.new('brush.set_specific',value='PRESS',type='TWO',ctrl=False,alt=False,shift=False,oskey=False)
sub_hotkey.properties.brush = 'Subtract'
sub_hotkey.type = sub_hotkey.type
blur_hotkey = wp_hotkeys.new('brush.set_specific',value='PRESS',type='THREE',ctrl=False,alt=False,shift=False,oskey=False)
blur_hotkey.properties.brush = 'Blur'
blur_hotkey.type = blur_hotkey.type
def register():
from bpy.utils import register_class
register_class(ChangeWPBrush)
bpy.app.handlers.load_post.append(register_brush_switch_hotkeys)
def unregister():
from bpy.utils import unregister_class
unregister_class(ChangeWPBrush)
bpy.app.handlers.load_post.remove(register_brush_switch_hotkeys)

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
docs/toggle_wp_shortcut.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

234
force_apply_mirror.py Normal file
View File

@ -0,0 +1,234 @@
import bpy
from bpy.props import BoolProperty
from . import utils
# 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 = utils.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 = utils.flip_name(t.bone_target)
class ForceApplyMirror(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 and 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 = utils.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 = utils.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(ForceApplyMirror)
def unregister():
from bpy.utils import unregister_class
unregister_class(ForceApplyMirror)

262
smart_weight_transfer.py Normal file
View File

@ -0,0 +1,262 @@
bl_info = {
"name": "Distance Weighted Weight Transfer",
"description": "Smart Transfer Weights operator",
"author": "Mets 3D",
"version": (2, 0),
"blender": (2, 80, 0),
"location": "Search -> Smart Weight Transfer",
"category": "Object"
}
# This is probably fairly useless and will give roughly the same results as transferring weights with
# the Transfer Mesh Data operator set to Nearest Face Interpolated, and then running a Smooth Vertex Weights operator.
import bpy
import mathutils
from mathutils import Vector
import math
from bpy.props import *
import bmesh
def build_weight_dict(obj, vgroups=None, mask_vgroup=None, bone_combine_dict=None):
""" Builds and returns a dictionary that matches the vertex indicies of the object to a list of tuples containing the vertex group names that the vertex belongs to, and the weight of the vertex in that group.
vgroups: If passed, skip groups that aren't in vgroups.
bone_combine_dict: Can be specified if we want some bones to be merged into others, eg. passing in {'Toe_Main' : ['Toe1', 'Toe2', 'Toe3']} will combine the weights in the listed toe bones into Toe_Main. You would do this when transferring weights from a model of actual feet onto shoes.
"""
if(bone_combine_dict==""):
bone_combine_dict = None
weight_dict = {} # {vert index : [('vgroup_name', vgroup_value), ...], ...}
if(vgroups==None):
vgroups = obj.vertex_groups
for v in obj.data.vertices:
# TODO: instead of looking through all vgroups we should be able to get only the groups that this vert is assigned to via v.groups[0].group which gives the group id which we can use to get the group via Object.vertex_groups[id]
# With this maybe it's useless altogether to save the weights into a dict? idk.
# Although the reason we are doing it this way is because we wanted some bones to be considered the same as others. (eg. toe bones should be considered a single combined bone)
for vg in vgroups:
w = 0
try:
w = vg.weight(v.index)
except:
pass
if(bone_combine_dict):
# Adding the weights from any sub-vertexgroups defined in bone_combine_dict
if(vg.name in bone_combine_dict.keys()):
for sub_vg_name in bone_combine_dict[vg.name]:
sub_vg = obj.vertex_groups.get(sub_vg_name)
if(sub_vg==None): continue
try:
w = w + sub_vg.weight(v.index)
except RuntimeError:
pass
if(w==0): continue
# Masking transfer influence
if(mask_vgroup):
try:
multiplier = mask_vgroup.weight(v.index)
w = w * multiplier
except:
pass
# Create or append entry in the dict.
if(v.index not in weight_dict):
weight_dict[v.index] = [(vg.name, w)]
else:
weight_dict[v.index].append((vg.name, w))
return weight_dict
def build_kdtree(obj):
kd = mathutils.kdtree.KDTree(len(obj.data.vertices))
for i, v in enumerate(obj.data.vertices):
kd.insert(v.co, i)
kd.balance()
return kd
def smart_transfer_weights(obj_from, obj_to, weights, expand=2):
""" Smart Vertex Weight Transfer.
The number of nearby verts which it searches for depends on how far the nearest vert is. (This is controlled by max_verts, max_dist and dist_multiplier)
This means if a very close vert is found, it won't look for any more verts.
If the nearest vert is quite far away(or dist_multiplier is set high), it will average the influences of a larger number few verts.
The averaging of the influences is also weighted by their distance, so that a vertex which is twice as far away will contribute half as much influence to the final result.
weights: a dictionary of vertex weights that needs to be built with build_weight_dict().
expand: How many times the "selection" should be expanded around the nearest vert, to collect more verts whose weights will be averaged. 0 is like default weight transfer.
"""
# TODO: because we normalize at the end, it also means we are expecting the input weighs to be normalized. So we should call a normalize all automatically.
# Assuming obj_from is at least selected, but probably active. (Shouldn't matter thanks to multi-edit mode?)
bpy.ops.object.mode_set(mode='EDIT')
bm = bmesh.from_edit_mesh(obj_from.data)
kd = build_kdtree(obj_from)
for v in obj_to.data.vertices:
# Finding the nearest vertex on source object
nearest_co, nearest_idx, nearest_dist = kd.find(v.co)
# Find neighbouring verts to the nearest vert. Save their index to this list. Will later turn it into a list of (index, distance) tuples.
source_vert_indices = [nearest_idx]
bm.verts.ensure_lookup_table()
bmv = bm.verts[nearest_idx]
for i in range(0, expand):
new_indices = []
for v_idx in source_vert_indices:
cur_bmv = bm.verts[v_idx]
for e in cur_bmv.link_edges:
v_other = e.other_vert(cur_bmv)
if(v_other.index not in source_vert_indices):
new_indices.append(v_other.index)
source_vert_indices.extend(new_indices)
source_verts = []
for vi in source_vert_indices:
distance = (v.co - bm.verts[vi].co).length
source_verts.append((vi, distance))
# Sort valid verts by distance (least to most distance)
source_verts.sort(key=lambda tup: tup[1])
# Iterating through the source verts, from closest to furthest, and accumulating our target weight for each vertex group.
vgroup_weights = {} # Dictionary of Vertex Group Name : Weight
for i in range(0, len(source_verts)):
vert = source_verts[i]
# The closest vert's weights are multiplied by the farthest vert's distance, and vice versa. The 2nd closest will use the 2nd farthest, etc.
# Note: The magnitude of the distance vectors doesn't matter because at the end they will be normalized anyways.
pair_distance = source_verts[-i-1][1]
if(vert[0] not in weights): continue
for vg_name, vg_weight in weights[vert[0]]:
new_weight = vg_weight * pair_distance
if(vg_name not in vgroup_weights):
vgroup_weights[vg_name] = new_weight
else:
vgroup_weights[vg_name] = vgroup_weights[vg_name] + new_weight
# The sum is used to normalize the weights. This is important because otherwise the values would depend on object scale, and in the case of very small or very large objects, stuff could get culled.
weights_sum = sum(vgroup_weights.values())
# Assigning the final, normalized weights of this vertex to the vertex groups.
for vg_avg in vgroup_weights.keys():
target_vg = obj_to.vertex_groups.get(vg_avg)
if(target_vg == None):
target_vg = obj_to.vertex_groups.new(name=vg_avg)
target_vg.add([v.index], vgroup_weights[vg_avg]/weights_sum, 'REPLACE')
#bpy.ops.object.mode_set(mode='WEIGHT_PAINT')
w3_bone_dict_str = """{
'Toe_Def.L' : ['Toe_Thumb1.L', 'Toe_Thumb2.L', 'Toe_Index1.L', 'Toe_Index2.L', 'Toe_Middle1.L', 'Toe_Middle2.L', 'Toe_Ring1.L', 'Toe_Ring2.L', 'Toe_Pinky1.L', 'Toe_Pinky2.L'],
'Toe_Def.R' : ['Toe_Thumb1.R', 'Toe_Thumb2.R', 'Toe_Index1.R', 'Toe_Index2.R', 'Toe_Middle1.R', 'Toe_Middle2.R', 'Toe_Ring1.R', 'Toe_Ring2.R', 'Toe_Pinky1.R', 'Toe_Pinky2.R'],
'Hand_Def.L' : ['l_thumb_roll', 'l_pinky0', 'l_index_knuckleRoll', 'l_middle_knuckleRoll', 'l_ring_knuckleRoll'],
'Hand_Def.R' : ['r_thumb_roll', 'r_pinky0', 'r_index_knuckleRoll', 'r_middle_knuckleRoll', 'r_ring_knuckleRoll'],
}"""
class SmartWeightTransferOperator(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 is not None)# and (context.object.mode=='WEIGHT_PAINT')
def draw_smart_weight_transfer(self, context):
operator = self.layout.operator(SmartWeightTransferOperator.bl_idname, text=SmartWeightTransferOperator.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(SmartWeightTransferOperator)
bpy.types.VIEW3D_MT_paint_weight.append(SmartWeightTransferOperator.draw_smart_weight_transfer)
def unregister():
from bpy.utils import unregister_class
unregister_class(SmartWeightTransferOperator)
bpy.types.VIEW3D_MT_paint_weight.remove(SmartWeightTransferOperator.draw_smart_weight_transfer)

137
toggle_weight_paint.py Normal file
View File

@ -0,0 +1,137 @@
import bpy
# This operator is to make entering weight paint mode less of a pain in the ass.
# You just need to select a mesh, run the operator, and you should be ready to weight paint.
# It registers an operator called "Toggle Weight Paint Mode" that does the following:
# Set active object to weight paint mode
# Set shading mode to a white MatCap, Single Color shading
# Find first armature via the object's modifiers.
# Ensure it is visible by moving it to a temporary collection and changing its visibility settings.
# Enable "In Front" option
# Set it to pose mode.
# When running the operator again, it should restore all modes and shading settings, delete the temporary collection, and restore the armature's visibility settings.
# You need to set up your own keybind for this operator.
# NOTE: If you exit weight paint mode with this operator while in wireframe mode, the studio light shading setting won't be restored.
coll_name = "temp_weight_paint_armature"
class ToggleWeightPaint(bpy.types.Operator):
""" Toggle weight paint mode properly with a single operator. """
bl_idname = "object.weight_paint_toggle"
bl_label = "Toggle Weight Paint Mode"
bl_options = {'REGISTER', 'UNDO'}
# local_view: bpy.props.BoolProperty(name="Local View", description="Enter Local view with the mesh and armature")
@classmethod
def poll(cls, context):
return context.object and context.object.type=='MESH'
def execute(self, context):
obj = context.object
mode = obj.mode
enter_wp = not (mode == 'WEIGHT_PAINT')
# Finding armature.
armature = None
for m in obj.modifiers:
if(m.type=='ARMATURE'):
armature = m.object
if(enter_wp):
### Entering weight paint mode. ###
# If the mesh object's display mode was anything other than Solid or Textured, store it, as we'll have to change it. and then change it back.
if obj.display_type not in ['SOLID', 'TEXTURED']:
obj['wpt_display_type'] = obj.display_type
obj.display_type = 'SOLID'
# Store old shading settings in a dict custom property
if('wpt' not in context.screen):
context.screen['wpt'] = {}
wpt = context.screen['wpt'].to_dict()
# Set modes.
if(armature):
context.screen['wpt']['armature_enabled'] = armature.hide_viewport
context.screen['wpt']['armature_hide'] = armature.hide_get()
context.screen['wpt']['armature_in_front'] = armature.show_in_front
armature.hide_viewport = False
armature.hide_set(False)
armature.show_in_front = True
context.view_layer.objects.active = armature
coll = bpy.data.collections.get(coll_name)
if not coll:
coll = bpy.data.collections.new(coll_name)
if coll_name not in context.scene.collection.children:
context.scene.collection.children.link(coll)
if armature.name not in coll.objects:
coll.objects.link(armature)
if not armature.visible_get():
print("By some miracle, the armature is still hidden, cannot reveal it for weight paint mode.")
armature=None
else:
bpy.ops.object.mode_set(mode='POSE')
context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode='WEIGHT_PAINT')
if('last_switch_in' not in wpt or wpt['last_switch_in']==False): # Only save shading info if we exitted weight paint mode using this operator.
context.screen['wpt']['shading_type'] = context.space_data.shading.type
if context.space_data.shading.type=='SOLID':
context.screen['wpt']['light'] = context.space_data.shading.light
context.screen['wpt']['color_type'] = context.space_data.shading.color_type
context.screen['wpt']['studio_light'] = context.space_data.shading.studio_light
context.screen['wpt']['active_object'] = obj
context.screen['wpt']['last_switch_in'] = True # Store whether the last time the operator ran, were we switching into or out of weight paint mode.
context.screen['wpt']['mode'] = mode
# Set shading
if context.space_data.shading.type=='SOLID':
context.space_data.shading.light = 'MATCAP'
context.space_data.shading.color_type = 'SINGLE'
context.space_data.shading.studio_light = 'basic_1.exr'
else:
### Leaving weight paint mode. ###
# Restore object display type
if 'wpt_display_type' in obj:
obj.display_type = obj['wpt_display_type']
del obj['wpt_display_type']
if('wpt' in context.screen):
info = context.screen['wpt'].to_dict()
# Restore mode.
bpy.ops.object.mode_set(mode=info['mode'])
context.screen['wpt']['last_switch_in'] = False
# Restore shading options.
context.space_data.shading.type = info['shading_type']
if context.space_data.shading.type=='SOLID':
context.space_data.shading.light = info['light']
context.space_data.shading.color_type = info['color_type']
context.space_data.shading.studio_light = info['studio_light']
# If the armature was un-hidden, hide it again.
if(armature):
armature.hide_viewport = info['armature_enabled']
armature.hide_set(info['armature_hide'])
armature.show_in_front = info['armature_in_front']
coll = bpy.data.collections.get(coll_name)
bpy.data.collections.remove(coll)
else:
# If we didn't enter weight paint mode with this operator, just go into object mode when trying to leave WP mode with this operator.
bpy.ops.object.mode_set(mode='OBJECT')
return { 'FINISHED' }
def register():
from bpy.utils import register_class
register_class(ToggleWeightPaint)
def unregister():
from bpy.utils import unregister_class
unregister_class(ToggleWeightPaint)

299
utils.py Normal file
View File

@ -0,0 +1,299 @@
import bpy
# Collection of functions that are either used by other parts of the addon, or random code snippets that I wanted to include but aren't actually used.
class EnsureVisible:
""" Class to ensure an object is visible, then reset it to how it was before. """
is_visible = False
temp_coll = None
obj_hide = False
obj_hide_viewport = False
@classmethod
def ensure(cls, context, obj):
# Make temporary collection so we can ensure visibility.
if cls.is_visible:
print(f"Could not ensure visibility of object {obj.name}. Can only ensure the visibility of one object at a time. Must Run EnsureVisible.restore()!")
return
coll_name = "temp_coll"
temp_coll = bpy.data.collections.get(coll_name)
if not temp_coll:
temp_coll = bpy.data.collections.new(coll_name)
if coll_name not in context.scene.collection.children:
context.scene.collection.children.link(temp_coll)
if obj.name not in temp_coll.objects:
temp_coll.objects.link(obj)
cls.obj_hide = obj.hide_get()
obj.hide_set(False)
cls.obj_hide_viewport = obj.hide_viewport
obj.hide_viewport = False
cls.obj = obj
cls.temp_coll = temp_coll
@classmethod
def restore(cls, obj):
# Delete temp collection
if not cls.temp_coll:
return
cls.temp_coll = None
obj.hide_set(cls.obj_hide)
obj.hide_viewport = cls.obj_hide_viewport
cls.obj_hide = False
cls.obj_hide_viewport = False
cls.is_visible=False
def find_invalid_constraints(context, hidden_is_invalid=False):
# If hidden=True, disabled constraints are considered invalid.
o = context.object
present = True
if(present):
o.data.layers = [True] * 32
for b in o.pose.bones:
if len(b.constraints) == 0:
b.bone.hide = True
for c in b.constraints:
if not c.is_valid or (hidden_is_invalid and c.mute):
b.bone.hide = False
break
else:
b.bone.hide = True
def reset_stretch(armature):
for b in armature.pose.bones:
for c in b.constraints:
if(c.type=='STRETCH_TO'):
c.rest_length = 0
c.use_bulge_min=True
c.use_bulge_max=True
c.bulge_min=1
c.bulge_max=1
def assign_object_and_material_ids(start=1):
counter = start
for o in bpy.context.selected_objects:
if(o.type=='MESH'):
o.pass_index = counter
counter = counter + 1
counter = start
for m in bpy.data.materials:
m.pass_index = counter
counter = counter + 1
def connect_parent_bones():
# If the active object is an Armature
# For each bone
# If there is only one child
# Move the tail to the child's head
# Set Child's Connected to True
armature = bpy.context.object
if(armature.type != 'ARMATURE'): return
else:
bpy.ops.object.mode_set(mode="EDIT")
for b in armature.data.edit_bones:
if(len(b.children) == 1):
b.tail = b.children[0].head
#b.children[0].use_connect = True
def uniform_scale():
for o in bpy.context.selected_objects:
o.dimensions = [1, 1, 1]
o.scale = [min(o.scale), min(o.scale), min(o.scale)]
def find_or_create_bone(armature, bonename, select=True):
assert armature.mode=='EDIT', "Armature must be in edit mode"
bone = armature.data.edit_bones.get(bonename)
if(not bone):
bone = armature.data.edit_bones.new(bonename)
bone.select = select
return bone
def find_or_create_constraint(pb, ctype, name=None):
""" Create a constraint on a bone if it doesn't exist yet.
If a constraint with the given type already exists, just return that.
If a name was passed, also make sure the name matches before deeming it a match and returning it.
pb: Must be a pose bone.
"""
for c in pb.constraints:
if(c.type==ctype):
if(name):
if(c.name==name):
return c
else:
return c
c = pb.constraints.new(type=ctype)
if(name):
c.name = name
return c
def bone_search(armature, search=None, start=None, end=None, edit_bone=False, selected=True):
""" Convenience function to get iterables for our for loops. """ #TODO: Could use regex.
bone_list = []
if(edit_bone):
bone_list = armature.data.edit_bones
else:
bone_list = armature.pose.bones
filtered_list = []
if(search):
for b in bone_list:
if search in b.name:
if selected:
if edit_bone:
if b.select:
filtered_list.append(b)
else:
if b.bone.select:
filtered_list.append(b)
else:
filtered_list.append(b)
elif(start):
for b in filtered_list:
if not b.name.startswith(start):
filtered_list.remove(b)
elif(end):
for b in filtered_list:
if not b.name.endswith(end):
filtered_list.remove(b)
else:
assert False, "Nothing passed."
return filtered_list
def find_nearby_bones(armature, search_co, dist=0.0005, ebones=None):
""" Bruteforce search for bones that are within a given distance of the given coordinates. """
""" Active object must be an armature. """ # TODO: Let armature be passed, maybe optionally. Do some assert sanity checks.
""" ebones: Only search in these bones. """
assert armature.mode=='EDIT' # TODO: Could use data.bones instead so we don't have to be in edit mode?
ret = []
if not ebones:
ebones = armature.data.edit_bones
for eb in ebones:
if( (eb.head - search_co).length < dist):
ret.append(eb)
return ret
def get_bone_chain(bone, ret=[]):
""" Recursively build a list of the first children.
bone: Can be pose/data/edit bone, doesn't matter. """
ret.append(bone)
if(len(bone.children) > 0):
return get_bone_chain(bone.children[0], ret)
return ret
def flip_name(from_name, only=True, must_change=False):
# based on BLI_string_flip_side_name in https://developer.blender.org/diffusion/B/browse/master/source/blender/blenlib/intern/string_utils.c
# If only==True, only replace the first occurrence of a side identifier in the string, eg. "Left_Eyelid.L" would become "Right_Eyelid.L". With only==False, it would instead return "Right_Eyelid.R"
# if must_change==True, raise an error if the string couldn't be flipped.
l = len(from_name) # Number of characters from left to right, that we still care about. At first we care about all of them.
# Handling .### cases
if("." in from_name):
# Make sure there are only digits after the last period
after_last_period = from_name.split(".")[-1]
before_last_period = from_name.replace("."+after_last_period, "")
all_digits = True
for c in after_last_period:
if( c not in "0123456789" ):
all_digits = False
break
# If that is so, then we don't care about the characters after this last period.
if(all_digits):
l = len(before_last_period)
new_name = from_name[:l]
left = ['left', 'Left', 'LEFT', '.l', '.L', '_l', '_L', '-l', '-L', 'l.', 'L.', 'l_', 'L_', 'l-', 'L-']
right_placehold = ['*rgt*', '*Rgt*', '*RGT*', '*dotl*', '*dotL*', '*underscorel*', '*underscoreL*', '*dashl*', '*dashL', '*ldot*', '*Ldot', '*lunderscore*', '*Lunderscore*', '*ldash*','*Ldash*']
right = ['right', 'Right', 'RIGHT', '.r', '.R', '_r', '_R', '-r', '-R', 'r.', 'R.', 'r_', 'R_', 'r-', 'R-']
def flip_sides(list_from, list_to, new_name):
for side_idx, side in enumerate(list_from):
opp_side = list_to[side_idx]
if(only):
# Only look at prefix/suffix.
if(new_name.startswith(side)):
new_name = new_name[len(side):]+opp_side
break
elif(new_name.endswith(side)):
new_name = new_name[:-len(side)]+opp_side
break
else:
if("-" not in side and "_" not in side): # When it comes to searching the middle of a string, sides must Strictly a full word or separated with . otherwise we would catch stuff like "_leg" and turn it into "_reg".
# Replace all occurences and continue checking for keywords.
new_name = new_name.replace(side, opp_side)
continue
return new_name
new_name = flip_sides(left, right_placehold, new_name)
new_name = flip_sides(right, left, new_name)
new_name = flip_sides(right_placehold, right, new_name)
# Re-add trailing digits (.###)
new_name = new_name + from_name[l:]
if(must_change):
assert new_name != from_name, "Failed to flip string: " + from_name
return new_name
def copy_attributes(from_thing, to_thing, skip=[""], recursive=False):
"""Copy attributes from one thing to another.
from_thing: Object to copy values from. (Only if the attribute already exists in to_thing)
to_thing: Object to copy attributes into (No new attributes are created, only existing are changed).
skip: List of attribute names in from_thing that should not be attempted to be copied.
recursive: Copy iterable attributes recursively.
"""
#print("\nCOPYING FROM: " + str(from_thing))
#print(".... TO: " + str(to_thing))
bad_stuff = skip + ['active', 'bl_rna', 'error_location', 'error_rotation']
for prop in dir(from_thing):
if "__" in prop: continue
if(prop in bad_stuff): continue
if(hasattr(to_thing, prop)):
from_value = getattr(from_thing, prop)
# Iterables should be copied recursively, except str.
if recursive and type(from_value) not in [str]:
# NOTE: I think This will infinite loop if a CollectionProperty contains a reference to itself!
warn = False
try:
# Determine if the property is iterable. Otherwise this throws TypeError.
iter(from_value)
to_value = getattr(to_thing, prop)
# The thing we are copying to must therefore be an iterable as well. If this fails though, we should throw a warning.
warn = True
iter(to_value)
count = min(len(to_value), len(from_value))
for i in range(0, count):
copy_attributes(from_value[i], to_value[i], skip, recursive)
except TypeError: # Not iterable.
if warn:
print("WARNING: Could not copy attributes from iterable to non-iterable field: " + prop +
"\nFrom object: " + str(from_thing) +
"\nTo object: " + str(to_thing)
)
# Copy the attribute.
try:
setattr(to_thing, prop, from_value)
#print(prop + ": " + str(from_value))
except AttributeError: # Read-Only properties throw AttributeError. We ignore silently, which is not great.
continue

View File

@ -0,0 +1,129 @@
import bpy
from bpy.props import *
from bpy.app.handlers import persistent
class WPContextMenu(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_weight_cleaner(self, context):
context.scene['weight_cleaner'] = self.weight_cleaner
WeightCleaner.cleaner_active = context.scene['weight_cleaner']
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_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)
weight_cleaner: BoolProperty(name="Weight Cleaner", update=update_weight_cleaner)
front_faces: BoolProperty(name="Front Faces Only", update=update_front_faces)
falloff_shape: EnumProperty(name="Falloff Shape", 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(self, context):
layout = self.layout
layout.label(text="Brush Settings (Global)")
layout.prop(self, "front_faces", toggle=True)
layout.prop(self, "falloff_shape", expand=True)
layout.separator()
layout.label(text="Weight Paint settings")
tool_settings = context.tool_settings
row = layout.row()
row.prop(tool_settings, "use_auto_normalize", text="Auto Normalize", toggle=True)
row.prop(self, "weight_cleaner", toggle=True)
row = layout.row()
row.prop(tool_settings, "use_multipaint", text="Multi-Paint", toggle=True)
row.prop(context.weight_paint_object.data, "use_mirror_x", toggle=True)
layout.separator()
layout.label(text="Overlay")
row = layout.row()
row.use_property_split=True
row.prop(tool_settings, "vertex_group_user", text="Zero Weights", expand=True)
if hasattr(context.space_data, "overlay"):
overlay = context.space_data.overlay
layout.prop(overlay, "show_wpaint_contours", text="Weight Contours", toggle=True)
layout.prop(overlay, "show_paint_wire", text="Wireframe", 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 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 'weight_cleaner' not in context.scene:
context.scene['weight_cleaner'] = False
self.weight_cleaner = context.scene['weight_cleaner']
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(WPContextMenu)
bpy.app.handlers.load_post.append(start_cleaner)
def unregister():
from bpy.utils import unregister_class
unregister_class(WPContextMenu)
bpy.app.handlers.load_post.remove(start_cleaner)