Add Easy_Weight
to Addons
#47
38
README.md
Normal file
38
README.md
Normal 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
47
__init__.py
Normal 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
75
change_brush.py
Normal 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)
|
BIN
docs/custom_wp_context_menu.png
Normal file
BIN
docs/custom_wp_context_menu.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 32 KiB |
BIN
docs/toggle_wp_shortcut.png
Normal file
BIN
docs/toggle_wp_shortcut.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
docs/wp_context_menu_shortcut.png
Normal file
BIN
docs/wp_context_menu_shortcut.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
234
force_apply_mirror.py
Normal file
234
force_apply_mirror.py
Normal 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
262
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 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
137
toggle_weight_paint.py
Normal 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
299
utils.py
Normal 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
|
129
weight_paint_context_menu.py
Normal file
129
weight_paint_context_menu.py
Normal 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)
|
Loading…
Reference in New Issue
Block a user