EasyWeight: Major update #317
@ -17,8 +17,7 @@ Easy Weights is an addon focused on quality of life improvements for weight pain
|
||||
## Installation
|
||||
Find installation instructions [here](https://studio.blender.org/pipeline/addons/overview).
|
||||
|
||||
## How to Use
|
||||
Read the paragraphs below to find out how to boost your weight painting workflow and comfort.
|
||||
## Features
|
||||
|
||||
### Entering Weight Paint Mode
|
||||
An operator called "Toggle Weight Paint" is added under the "Object" and "Weight" menus in the 3D View. You can right-click the operator in either of these locations to assign a shortcut. I use pie menu object modes on Tab, so my Ctrl+Tab shortcut is available for this.
|
||||
|
@ -12,7 +12,6 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from .utils import hotkeys
|
||||
from . import (
|
||||
rogue_weights,
|
||||
vertex_group_menu,
|
||||
@ -20,7 +19,6 @@ from . import (
|
||||
weight_paint_context_menu,
|
||||
toggle_weight_paint,
|
||||
force_apply_mirror,
|
||||
smart_weight_transfer,
|
||||
prefs,
|
||||
)
|
||||
import bpy
|
||||
@ -40,7 +38,6 @@ bl_info = {
|
||||
|
||||
|
||||
modules = [
|
||||
smart_weight_transfer,
|
||||
force_apply_mirror,
|
||||
toggle_weight_paint,
|
||||
weight_paint_context_menu,
|
||||
@ -82,26 +79,6 @@ def register_unregister_modules(modules, register: bool):
|
||||
def register():
|
||||
register_unregister_modules(modules, True)
|
||||
|
||||
prefs_class = bpy.types.AddonPreferences.bl_rna_get_subclass_py(
|
||||
'EASYWEIGHT_addon_preferences'
|
||||
)
|
||||
prefs_class.hotkeys.append(
|
||||
hotkeys.addon_hotkey_register(
|
||||
op_idname='object.custom_weight_paint_context_menu',
|
||||
keymap_name='Weight Paint',
|
||||
key_id='W',
|
||||
add_on_conflict=False,
|
||||
warn_on_conflict=True,
|
||||
error_on_conflict=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def unregister():
|
||||
prefs_class = bpy.types.AddonPreferences.bl_rna_get_subclass_py(
|
||||
'EASYWEIGHT_addon_preferences'
|
||||
)
|
||||
for py_kmi in prefs_class.hotkeys:
|
||||
py_kmi.unregister()
|
||||
|
||||
register_unregister_modules(modules, False)
|
||||
|
@ -1,8 +1,216 @@
|
||||
import bpy
|
||||
import bpy, json
|
||||
from bpy.props import BoolProperty
|
||||
|
||||
|
||||
class EASYWEIGHT_addon_preferences(bpy.types.AddonPreferences):
|
||||
hotkeys = []
|
||||
bl_idname = __package__
|
||||
easyweight_keymap_items = {}
|
||||
|
||||
show_hotkeys: BoolProperty(
|
||||
name="Show Hotkeys",
|
||||
default=False,
|
||||
description="Reveal the hotkey list. You may customize or disable these hotkeys",
|
||||
)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.use_property_split = True
|
||||
layout.use_property_decorate = False
|
||||
|
||||
main_col = layout.column(align=True)
|
||||
hotkey_col = self.draw_fake_dropdown(main_col, self, 'show_hotkeys', "Hotkeys")
|
||||
if self.show_hotkeys:
|
||||
type(self).draw_hotkey_list(hotkey_col, context)
|
||||
|
||||
# NOTE: This function is copied from CloudRig's prefs.py.
|
||||
def draw_fake_dropdown(self, layout, prop_owner, prop_name, dropdown_text):
|
||||
row = layout.row()
|
||||
split = row.split(factor=0.20)
|
||||
split.use_property_split = False
|
||||
prop_value = prop_owner.path_resolve(prop_name)
|
||||
icon = 'TRIA_DOWN' if prop_value else 'TRIA_RIGHT'
|
||||
split.prop(prop_owner, prop_name, icon=icon, emboss=False, text=dropdown_text)
|
||||
split.prop(prop_owner, prop_name, icon='BLANK1', emboss=False, text="")
|
||||
split = layout.split(factor=0.012)
|
||||
split.row()
|
||||
dropdown_row = split.row()
|
||||
dropdown_col = dropdown_row.column()
|
||||
row = dropdown_col.row()
|
||||
row.use_property_split = False
|
||||
|
||||
return dropdown_col
|
||||
|
||||
# NOTE: This function is copied from CloudRig's prefs.py.
|
||||
@classmethod
|
||||
def draw_hotkey_list(cls, layout, context):
|
||||
hotkey_class = cls
|
||||
user_kc = context.window_manager.keyconfigs.user
|
||||
|
||||
keymap_data = list(hotkey_class.easyweight_keymap_items.items())
|
||||
keymap_data = sorted(keymap_data, key=lambda tup: tup[1][2].name)
|
||||
|
||||
prev_kmi = None
|
||||
for kmi_hash, kmi_tup in keymap_data:
|
||||
addon_kc, addon_km, addon_kmi = kmi_tup
|
||||
|
||||
user_km = user_kc.keymaps.get(addon_km.name)
|
||||
if not user_km:
|
||||
# This really shouldn't happen.
|
||||
continue
|
||||
user_kmi = hotkey_class.find_kmi_in_km_by_hash(user_km, kmi_hash)
|
||||
|
||||
col = layout.column()
|
||||
col.context_pointer_set("keymap", user_km)
|
||||
if user_kmi and prev_kmi and prev_kmi.name != user_kmi.name:
|
||||
col.separator()
|
||||
user_row = col.row()
|
||||
|
||||
if False:
|
||||
# Debug code: Draw add-on and user KeyMapItems side-by-side.
|
||||
split = user_row.split(factor=0.5)
|
||||
addon_row = split.row()
|
||||
user_row = split.row()
|
||||
hotkey_class.draw_kmi(addon_km, addon_kmi, addon_row)
|
||||
if not user_kmi:
|
||||
# This should only happen for one frame during Reload Scripts.
|
||||
print(
|
||||
"EasyWeight: Can't find this hotkey to draw: ",
|
||||
addon_kmi.name,
|
||||
addon_kmi.to_string(),
|
||||
kmi_hash,
|
||||
)
|
||||
continue
|
||||
|
||||
hotkey_class.draw_kmi(user_km, user_kmi, user_row)
|
||||
prev_kmi = user_kmi
|
||||
|
||||
# NOTE: This function is copied from CloudRig's cloudrig.py.
|
||||
@staticmethod
|
||||
def draw_kmi(km, kmi, layout):
|
||||
"""A simplified version of draw_kmi from rna_keymap_ui.py."""
|
||||
|
||||
map_type = kmi.map_type
|
||||
|
||||
col = layout.column()
|
||||
|
||||
split = col.split(factor=0.7)
|
||||
|
||||
# header bar
|
||||
row = split.row(align=True)
|
||||
row.prop(kmi, "active", text="", emboss=False)
|
||||
row.label(text=f'{kmi.name} ({km.name})')
|
||||
|
||||
row = split.row(align=True)
|
||||
sub = row.row(align=True)
|
||||
sub.enabled = kmi.active
|
||||
sub.prop(kmi, "type", text="", full_event=True)
|
||||
|
||||
if kmi.is_user_modified:
|
||||
row.operator("preferences.keyitem_restore", text="", icon='BACK').item_id = kmi.id
|
||||
|
||||
# NOTE: This function is copied from CloudRig's cloudrig.py.
|
||||
@staticmethod
|
||||
def find_kmi_in_km_by_hash(keymap, kmi_hash):
|
||||
"""There's no solid way to match modified user keymap items to their
|
||||
add-on equivalent, which is necessary to draw them in the UI reliably.
|
||||
|
||||
To remedy this, we store a hash in the KeyMapItem's properties.
|
||||
|
||||
This function lets us find a KeyMapItem with a stored hash in a KeyMap.
|
||||
Eg., we can pass a User KeyMap and an Addon KeyMapItem's hash, to find the
|
||||
corresponding user keymap, even if it was modified.
|
||||
|
||||
The hash value is unfortunately exposed to the users, so we just hope they don't touch that.
|
||||
"""
|
||||
|
||||
for kmi in keymap.keymap_items:
|
||||
if not kmi.properties:
|
||||
continue
|
||||
if 'hash' not in kmi.properties:
|
||||
continue
|
||||
|
||||
if kmi.properties['hash'] == kmi_hash:
|
||||
return kmi
|
||||
|
||||
|
||||
# NOTE: This function is copied from CloudRig's cloudrig.py.
|
||||
def register_hotkey(
|
||||
bl_idname, hotkey_kwargs, *, key_cat='Window', space_type='EMPTY', op_kwargs={}
|
||||
):
|
||||
"""This function inserts a 'hash' into the created KeyMapItems' properties,
|
||||
so they can be compared to each other, and duplicates can be avoided."""
|
||||
|
||||
wm = bpy.context.window_manager
|
||||
prefs_class = bpy.types.AddonPreferences.bl_rna_get_subclass_py('EASYWEIGHT_addon_preferences')
|
||||
|
||||
addon_keyconfig = wm.keyconfigs.addon
|
||||
if not addon_keyconfig:
|
||||
# This happens when running Blender in background mode.
|
||||
return
|
||||
|
||||
# We limit the hash to a few digits, otherwise it errors when trying to store it.
|
||||
kmi_hash = (
|
||||
hash(json.dumps([bl_idname, hotkey_kwargs, key_cat, space_type, op_kwargs])) % 1000000
|
||||
)
|
||||
|
||||
# If it already exists, don't create it again.
|
||||
for (
|
||||
existing_kmi_hash,
|
||||
existing_kmi_tup,
|
||||
) in prefs_class.easyweight_keymap_items.items():
|
||||
existing_addon_kc, existing_addon_km, existing_kmi = existing_kmi_tup
|
||||
if kmi_hash == existing_kmi_hash:
|
||||
# The hash we just calculated matches one that is in storage.
|
||||
user_kc = wm.keyconfigs.user
|
||||
user_km = user_kc.keymaps.get(existing_addon_km.name)
|
||||
# NOTE: It's possible on Reload Scripts that some KeyMapItems remain in storage,
|
||||
# but are unregistered by Blender for no reason.
|
||||
# I noticed this particularly in the Weight Paint keymap.
|
||||
# So it's not enough to check if a KMI with a hash is in storage, we also need to check if a corresponding user KMI exists.
|
||||
user_kmi = prefs_class.find_kmi_in_km_by_hash(user_km, kmi_hash)
|
||||
if user_kmi:
|
||||
# print("Hotkey already exists, skipping: ", existing_kmi.name, existing_kmi.to_string(), kmi_hash)
|
||||
return
|
||||
|
||||
addon_keymaps = addon_keyconfig.keymaps
|
||||
addon_km = addon_keymaps.get(key_cat)
|
||||
if not addon_km:
|
||||
addon_km = addon_keymaps.new(name=key_cat, space_type=space_type)
|
||||
|
||||
addon_kmi = addon_km.keymap_items.new(bl_idname, **hotkey_kwargs)
|
||||
for key in op_kwargs:
|
||||
value = op_kwargs[key]
|
||||
setattr(addon_kmi.properties, key, value)
|
||||
|
||||
addon_kmi.properties['hash'] = kmi_hash
|
||||
|
||||
prefs_class.easyweight_keymap_items[kmi_hash] = (
|
||||
addon_keyconfig,
|
||||
addon_km,
|
||||
addon_kmi,
|
||||
)
|
||||
|
||||
|
||||
registry = [EASYWEIGHT_addon_preferences]
|
||||
|
||||
|
||||
def register():
|
||||
register_hotkey(
|
||||
bl_idname='object.custom_weight_paint_context_menu',
|
||||
hotkey_kwargs={
|
||||
'type': 'W',
|
||||
'value': 'PRESS',
|
||||
},
|
||||
key_cat='Weight Paint',
|
||||
)
|
||||
register_hotkey(
|
||||
bl_idname='object.weight_paint_toggle',
|
||||
hotkey_kwargs={
|
||||
'type': 'TAB',
|
||||
'value': 'PRESS',
|
||||
'ctrl': True
|
||||
},
|
||||
key_cat='3D View',
|
||||
)
|
||||
|
||||
|
||||
|
@ -1,282 +0,0 @@
|
||||
import bmesh
|
||||
from bpy.props import EnumProperty, BoolProperty, IntProperty, StringProperty
|
||||
import mathutils
|
||||
import bpy
|
||||
|
||||
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.
|
||||
|
||||
|
||||
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
|
||||
# {vert index : [('vgroup_name', vgroup_value), ...], ...}
|
||||
weight_dict = {}
|
||||
|
||||
if (vgroups == None):
|
||||
vgroups = obj.vertex_groups
|
||||
|
||||
for v in obj.data.vertices:
|
||||
# TODO: instead of looking through all vgroups we should be able to get only the groups that this vert is assigned to via v.groups[0].group which gives the group id which we can use to get the group via Object.vertex_groups[id]
|
||||
# With this maybe it's useless altogether to save the weights into a dict? idk.
|
||||
# Although the reason we are doing it this way is because we wanted some bones to be considered the same as others. (eg. toe bones should be considered a single combined bone)
|
||||
for vg in vgroups:
|
||||
w = 0
|
||||
try:
|
||||
w = vg.weight(v.index)
|
||||
except:
|
||||
pass
|
||||
|
||||
if (bone_combine_dict):
|
||||
# Adding the weights from any sub-vertexgroups defined in bone_combine_dict
|
||||
if (vg.name in bone_combine_dict.keys()):
|
||||
for sub_vg_name in bone_combine_dict[vg.name]:
|
||||
sub_vg = obj.vertex_groups.get(sub_vg_name)
|
||||
if (sub_vg == None):
|
||||
continue
|
||||
try:
|
||||
w = w + sub_vg.weight(v.index)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
if (w == 0):
|
||||
continue
|
||||
|
||||
# Masking transfer influence
|
||||
if (mask_vgroup):
|
||||
try:
|
||||
multiplier = mask_vgroup.weight(v.index)
|
||||
w = w * multiplier
|
||||
except:
|
||||
pass
|
||||
|
||||
# Create or append entry in the dict.
|
||||
if (v.index not in weight_dict):
|
||||
weight_dict[v.index] = [(vg.name, w)]
|
||||
else:
|
||||
weight_dict[v.index].append((vg.name, w))
|
||||
|
||||
return weight_dict
|
||||
|
||||
|
||||
def build_kdtree(obj):
|
||||
kd = mathutils.kdtree.KDTree(len(obj.data.vertices))
|
||||
for i, v in enumerate(obj.data.vertices):
|
||||
kd.insert(v.co, i)
|
||||
kd.balance()
|
||||
return kd
|
||||
|
||||
|
||||
def smart_transfer_weights(obj_from, obj_to, weights, expand=2):
|
||||
""" Smart Vertex Weight Transfer.
|
||||
The number of nearby verts which it searches for depends on how far the nearest vert is. (This is controlled by max_verts, max_dist and dist_multiplier)
|
||||
This means if a very close vert is found, it won't look for any more verts.
|
||||
If the nearest vert is quite far away(or dist_multiplier is set high), it will average the influences of a larger number few verts.
|
||||
The averaging of the influences is also weighted by their distance, so that a vertex which is twice as far away will contribute half as much influence to the final result.
|
||||
weights: a dictionary of vertex weights that needs to be built with build_weight_dict().
|
||||
expand: How many times the "selection" should be expanded around the nearest vert, to collect more verts whose weights will be averaged. 0 is like default weight transfer.
|
||||
"""
|
||||
# TODO: because we normalize at the end, it also means we are expecting the input weighs to be normalized. So we should call a normalize all automatically.
|
||||
|
||||
# Assuming obj_from is at least selected, but probably active. (Shouldn't matter thanks to multi-edit mode?)
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bm = bmesh.from_edit_mesh(obj_from.data)
|
||||
|
||||
kd = build_kdtree(obj_from)
|
||||
|
||||
for v in obj_to.data.vertices:
|
||||
# Finding the nearest vertex on source object
|
||||
nearest_co, nearest_idx, nearest_dist = kd.find(v.co)
|
||||
|
||||
# Find neighbouring verts to the nearest vert. Save their index to this list. Will later turn it into a list of (index, distance) tuples.
|
||||
source_vert_indices = [nearest_idx]
|
||||
|
||||
bm.verts.ensure_lookup_table()
|
||||
bmv = bm.verts[nearest_idx]
|
||||
|
||||
for i in range(0, expand):
|
||||
new_indices = []
|
||||
for v_idx in source_vert_indices:
|
||||
cur_bmv = bm.verts[v_idx]
|
||||
for e in cur_bmv.link_edges:
|
||||
v_other = e.other_vert(cur_bmv)
|
||||
if (v_other.index not in source_vert_indices):
|
||||
new_indices.append(v_other.index)
|
||||
source_vert_indices.extend(new_indices)
|
||||
|
||||
source_verts = []
|
||||
for vi in source_vert_indices:
|
||||
distance = (v.co - bm.verts[vi].co).length
|
||||
source_verts.append((vi, distance))
|
||||
|
||||
# Sort valid verts by distance (least to most distance)
|
||||
source_verts.sort(key=lambda tup: tup[1])
|
||||
|
||||
# Iterating through the source verts, from closest to furthest, and accumulating our target weight for each vertex group.
|
||||
vgroup_weights = {} # Dictionary of Vertex Group Name : Weight
|
||||
for i in range(0, len(source_verts)):
|
||||
vert = source_verts[i]
|
||||
# The closest vert's weights are multiplied by the farthest vert's distance, and vice versa. The 2nd closest will use the 2nd farthest, etc.
|
||||
# Note: The magnitude of the distance vectors doesn't matter because at the end they will be normalized anyways.
|
||||
pair_distance = source_verts[-i-1][1]
|
||||
if (vert[0] not in weights):
|
||||
continue
|
||||
for vg_name, vg_weight in weights[vert[0]]:
|
||||
new_weight = vg_weight * pair_distance
|
||||
if (vg_name not in vgroup_weights):
|
||||
vgroup_weights[vg_name] = new_weight
|
||||
else:
|
||||
vgroup_weights[vg_name] = vgroup_weights[vg_name] + new_weight
|
||||
|
||||
# The sum is used to normalize the weights. This is important because otherwise the values would depend on object scale, and in the case of very small or very large objects, stuff could get culled.
|
||||
weights_sum = sum(vgroup_weights.values())
|
||||
|
||||
# Assigning the final, normalized weights of this vertex to the vertex groups.
|
||||
for vg_avg in vgroup_weights.keys():
|
||||
target_vg = obj_to.vertex_groups.get(vg_avg)
|
||||
if (target_vg == None):
|
||||
target_vg = obj_to.vertex_groups.new(name=vg_avg)
|
||||
target_vg.add([v.index], vgroup_weights[vg_avg] /
|
||||
weights_sum, 'REPLACE')
|
||||
|
||||
# bpy.ops.object.mode_set(mode='WEIGHT_PAINT')
|
||||
|
||||
|
||||
w3_bone_dict_str = """{
|
||||
'Toe_Def.L' : ['Toe_Thumb1.L', 'Toe_Thumb2.L', 'Toe_Index1.L', 'Toe_Index2.L', 'Toe_Middle1.L', 'Toe_Middle2.L', 'Toe_Ring1.L', 'Toe_Ring2.L', 'Toe_Pinky1.L', 'Toe_Pinky2.L'],
|
||||
|
||||
'Toe_Def.R' : ['Toe_Thumb1.R', 'Toe_Thumb2.R', 'Toe_Index1.R', 'Toe_Index2.R', 'Toe_Middle1.R', 'Toe_Middle2.R', 'Toe_Ring1.R', 'Toe_Ring2.R', 'Toe_Pinky1.R', 'Toe_Pinky2.R'],
|
||||
|
||||
'Hand_Def.L' : ['l_thumb_roll', 'l_pinky0', 'l_index_knuckleRoll', 'l_middle_knuckleRoll', 'l_ring_knuckleRoll'],
|
||||
|
||||
'Hand_Def.R' : ['r_thumb_roll', 'r_pinky0', 'r_index_knuckleRoll', 'r_middle_knuckleRoll', 'r_ring_knuckleRoll'],
|
||||
}"""
|
||||
|
||||
|
||||
class EASYWEIGHT_OT_smart_weight_transfer(bpy.types.Operator):
|
||||
""" Transfer weights from active to selected objects based on weighted vert distances """
|
||||
bl_idname = "object.smart_weight_transfer"
|
||||
bl_label = "Smart Transfer Weights"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
opt_source_vgroups: EnumProperty(name="Source Groups",
|
||||
items=[("ALL", "All", "All"),
|
||||
("SELECTED", "Selected Bones",
|
||||
"Selected Bones"),
|
||||
("DEFORM", "Deform Bones",
|
||||
"Deform Bones"),
|
||||
],
|
||||
description="Which vertex groups to transfer from the source object",
|
||||
default="ALL")
|
||||
|
||||
opt_wipe_originals: BoolProperty(name="Wipe originals",
|
||||
default=True,
|
||||
description="Wipe original vertex groups before transferring. Recommended. Does not wipe vertex groups that aren't being transferred in the first place")
|
||||
|
||||
opt_expand: IntProperty(name="Expand",
|
||||
default=2,
|
||||
min=0,
|
||||
max=5,
|
||||
description="Expand source vertex pool - Higher values give smoother weights but calculations can take extremely long")
|
||||
|
||||
def get_vgroups(self, context):
|
||||
items = [('None', 'None', 'None')]
|
||||
for vg in context.object.vertex_groups:
|
||||
items.append((vg.name, vg.name, vg.name))
|
||||
return items
|
||||
|
||||
opt_mask_vgroup: EnumProperty(name="Operator Mask",
|
||||
items=get_vgroups,
|
||||
description="The operator's effect will be masked by this vertex group, unless 'None'")
|
||||
|
||||
opt_bone_combine_dict: StringProperty(name='Combine Dict',
|
||||
description="If you want some groups to be considered part of others(eg. to avoid transferring individual toe weights onto shoes), you can enter them here in the form of a valid Python dictionary, where the keys are the parent group name, and values are lists of child group names, eg: {'Toe_Main.L' : ['Toe1.L', 'Toe2.L'], 'Toe_Main.R' : ['Toe1.R', 'Toe2.R']}",
|
||||
default=w3_bone_dict_str
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return context.object # and (context.object.mode=='WEIGHT_PAINT')
|
||||
|
||||
def draw_smart_weight_transfer(self, context):
|
||||
operator = self.layout.operator(
|
||||
EASYWEIGHT_OT_smart_weight_transfer.bl_idname, text=EASYWEIGHT_OT_smart_weight_transfer.bl_label)
|
||||
|
||||
def execute(self, context):
|
||||
assert len(context.selected_objects) > 1, "At least two objects must be selected. Select the source object last, and enter weight paint mode."
|
||||
|
||||
bone_dict = ""
|
||||
if (self.opt_bone_combine_dict != ""):
|
||||
bone_dict = eval(self.opt_bone_combine_dict)
|
||||
|
||||
source_obj = context.object
|
||||
for o in context.selected_objects:
|
||||
if (o == source_obj or o.type != 'MESH'):
|
||||
continue
|
||||
# bpy.ops.object.mode_set(mode='OBJECT')
|
||||
# bpy.ops.object.select_all(action='DESELECT')
|
||||
|
||||
vgroups = []
|
||||
error = ""
|
||||
if (self.opt_source_vgroups == "ALL"):
|
||||
vgroups = source_obj.vertex_groups
|
||||
error = "the source has no vertex groups."
|
||||
elif (self.opt_source_vgroups == "SELECTED"):
|
||||
assert context.selected_pose_bones, "No selected pose bones to transfer from."
|
||||
vgroups = [source_obj.vertex_groups.get(
|
||||
b.name) for b in context.selected_pose_bones]
|
||||
error = "no bones were selected."
|
||||
elif (self.opt_source_vgroups == "DEFORM"):
|
||||
vgroups = [source_obj.vertex_groups.get(
|
||||
b.name) for b in context.pose_object.data.bones if b.use_deform]
|
||||
error = "there are no deform bones"
|
||||
|
||||
# Clean up
|
||||
vgroups = [vg for vg in vgroups if vg != None]
|
||||
assert len(
|
||||
vgroups) > 0, "No transferable Vertex Groups were found, " + error
|
||||
|
||||
# Delete the vertex groups from the destination mesh first...
|
||||
if (self.opt_wipe_originals):
|
||||
for vg in vgroups:
|
||||
if (vg.name in o.vertex_groups):
|
||||
o.vertex_groups.remove(o.vertex_groups.get(vg.name))
|
||||
|
||||
mask_vgroup = o.vertex_groups.get(self.opt_mask_vgroup)
|
||||
|
||||
weights = build_weight_dict(
|
||||
source_obj, vgroups, mask_vgroup, bone_dict)
|
||||
smart_transfer_weights(source_obj, o, weights, self.opt_expand)
|
||||
|
||||
bpy.context.view_layer.objects.active = o
|
||||
bpy.ops.object.mode_set(mode='WEIGHT_PAINT')
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
registry = [
|
||||
EASYWEIGHT_OT_smart_weight_transfer
|
||||
]
|
||||
|
||||
def register():
|
||||
bpy.types.VIEW3D_MT_paint_weight.append(
|
||||
EASYWEIGHT_OT_smart_weight_transfer.draw_smart_weight_transfer)
|
||||
|
||||
|
||||
def unregister():
|
||||
bpy.types.VIEW3D_MT_paint_weight.remove(
|
||||
EASYWEIGHT_OT_smart_weight_transfer.draw_smart_weight_transfer)
|
@ -1,218 +0,0 @@
|
||||
from typing import Dict, Tuple, List
|
||||
|
||||
import bpy
|
||||
from bpy.props import IntProperty
|
||||
from mathutils import Vector, kdtree
|
||||
|
||||
|
||||
class EASYWEIGHT_OT_transfer_vertex_groups(bpy.types.Operator):
|
||||
"""Transfer vertex groups from active to selected meshes"""
|
||||
|
||||
bl_idname = "object.transfer_vertex_groups"
|
||||
bl_label = "Transfer Vertex Groups"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
expand: IntProperty(
|
||||
name="Expand",
|
||||
default=2,
|
||||
min=0,
|
||||
max=5,
|
||||
description="Expand selection of source vertices from the nearest one. Higher values give smoother weights but pre-calculation takes longer",
|
||||
)
|
||||
|
||||
def draw_transfer_vertex_groups_op(self, context):
|
||||
self.layout.operator(
|
||||
EASYWEIGHT_OT_transfer_vertex_groups.bl_idname,
|
||||
text=EASYWEIGHT_OT_transfer_vertex_groups.bl_label,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not context.active_object or context.active_object.type != 'MESH':
|
||||
cls.poll_message_set("Active object must be a mesh.")
|
||||
return False
|
||||
selected_meshes = [ob for ob in context.selected_objects if ob.type == 'MESH']
|
||||
if len(selected_meshes) < 2:
|
||||
cls.poll_message_set("At least two meshes must be selected.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
source_obj = context.object
|
||||
vgroups = source_obj.vertex_groups
|
||||
kd_tree = build_kdtree(source_obj.data)
|
||||
|
||||
for target_obj in context.selected_objects:
|
||||
if target_obj == source_obj or target_obj.type != 'MESH':
|
||||
continue
|
||||
|
||||
# Remove groups from the target obj that we will be transferring.
|
||||
for src_vg in vgroups:
|
||||
tgt_vg = target_obj.vertex_groups.get(src_vg.name)
|
||||
if tgt_vg:
|
||||
target_obj.vertex_groups.remove(tgt_vg)
|
||||
|
||||
vert_influence_map = build_vert_influence_map(
|
||||
source_obj, target_obj, kd_tree, self.expand
|
||||
)
|
||||
transfer_vertex_groups(source_obj, target_obj, vert_influence_map, vgroups)
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
def precalc_and_transfer_single_group(source_obj, target_obj, vgroup_name, expand=2):
|
||||
"""Convenience function to transfer a single group. For transferring multiple groups,
|
||||
this is very inefficient and shouldn't be used.
|
||||
|
||||
Instead, you should:
|
||||
- build_kd_tree ONCE per source mesh.
|
||||
- build_vert_influence_map and transfer_vertex_groups ONCE per object pair.
|
||||
"""
|
||||
|
||||
# Remove group from the target obj if it already exists.
|
||||
tgt_vg = target_obj.vertex_groups.get(vgroup_name)
|
||||
if tgt_vg:
|
||||
target_obj.vertex_groups.remove(tgt_vg)
|
||||
|
||||
kd_tree = build_kdtree(source_obj.data)
|
||||
vert_influence_map = build_vert_influence_map(
|
||||
source_obj, target_obj, kd_tree, expand
|
||||
)
|
||||
transfer_vertex_groups(
|
||||
source_obj,
|
||||
target_obj,
|
||||
vert_influence_map,
|
||||
vgroups=[source_obj.vertex_groups[vgroup_name]],
|
||||
)
|
||||
|
||||
|
||||
def build_kdtree(mesh):
|
||||
kd = kdtree.KDTree(len(mesh.vertices))
|
||||
for i, v in enumerate(mesh.vertices):
|
||||
kd.insert(v.co, i)
|
||||
kd.balance()
|
||||
return kd
|
||||
|
||||
|
||||
def build_vert_influence_map(obj_from, obj_to, kd_tree, expand=2):
|
||||
verts_of_edge = {
|
||||
i: (e.vertices[0], e.vertices[1]) for i, e in enumerate(obj_from.data.edges)
|
||||
}
|
||||
|
||||
edges_of_vert: Dict[int, List[int]] = {}
|
||||
for edge_idx, edge in enumerate(obj_from.data.edges):
|
||||
for vert_idx in edge.vertices:
|
||||
if vert_idx not in edges_of_vert:
|
||||
edges_of_vert[vert_idx] = []
|
||||
edges_of_vert[vert_idx].append(edge_idx)
|
||||
|
||||
# A mapping from target vertex index to a list of source vertex indicies and
|
||||
# their influence.
|
||||
# This can be pre-calculated once per object pair, to minimize re-calculations
|
||||
# of subsequent transferring of individual vertex groups.
|
||||
vert_influence_map: List[int, List[Tuple[int, float]]] = {}
|
||||
for i, dest_vert in enumerate(obj_to.data.vertices):
|
||||
vert_influence_map[i] = get_source_vert_influences(
|
||||
dest_vert, obj_from, kd_tree, expand, edges_of_vert, verts_of_edge
|
||||
)
|
||||
|
||||
return vert_influence_map
|
||||
|
||||
|
||||
def get_source_vert_influences(
|
||||
target_vert, obj_from, kd_tree, expand=2, edges_of_vert={}, verts_of_edge={}
|
||||
) -> List[Tuple[int, float]]:
|
||||
_coord, idx, dist = get_nearest_vert(target_vert.co, kd_tree)
|
||||
source_vert_indices = [idx]
|
||||
|
||||
if dist == 0:
|
||||
# If the vertex position is a perfect match, just use that one vertex with max influence.
|
||||
return [(idx, 1)]
|
||||
|
||||
for i in range(0, expand):
|
||||
new_indices = []
|
||||
for vert_idx in source_vert_indices:
|
||||
for edge in edges_of_vert[vert_idx]:
|
||||
vert_other = other_vert_of_edge(edge, vert_idx, verts_of_edge)
|
||||
if vert_other not in source_vert_indices:
|
||||
new_indices.append(vert_other)
|
||||
source_vert_indices.extend(new_indices)
|
||||
|
||||
distances: List[Tuple[int, float]] = []
|
||||
distance_total = 0
|
||||
for src_vert_idx in source_vert_indices:
|
||||
distance = (target_vert.co - obj_from.data.vertices[src_vert_idx].co).length
|
||||
distance_total += distance
|
||||
distances.append((src_vert_idx, distance))
|
||||
|
||||
# Calculate influences such that the total of all influences adds up to 1.0,
|
||||
# and the influence is inversely correlated with the distance.
|
||||
parts = [1 / (dist / distance_total) for idx, dist in distances]
|
||||
parts_sum = sum(parts)
|
||||
|
||||
influences = [
|
||||
(idx, 1 if dist == 0 else part / parts_sum)
|
||||
for part, dist in zip(parts, distances)
|
||||
]
|
||||
|
||||
return influences
|
||||
|
||||
|
||||
def get_nearest_vert(
|
||||
coords: Vector, kd_tree: kdtree.KDTree
|
||||
) -> Tuple[Vector, int, float]:
|
||||
"""Return coordinate, index, and distance of nearest vert to coords in kd_tree."""
|
||||
return kd_tree.find(coords)
|
||||
|
||||
|
||||
def other_vert_of_edge(
|
||||
edge: int, vert: int, verts_of_edge: Dict[int, Tuple[int, int]]
|
||||
) -> int:
|
||||
verts = verts_of_edge[edge]
|
||||
assert vert in verts, f"Vert {vert} not part of edge {edge}."
|
||||
return verts[0] if vert == verts[1] else verts[1]
|
||||
|
||||
|
||||
def transfer_vertex_groups(obj_from, obj_to, vert_influence_map, src_vgroups):
|
||||
"""Transfer src_vgroups in obj_from to obj_to using a pre-calculated vert_influence_map."""
|
||||
|
||||
for src_vg in src_vgroups:
|
||||
target_vg = obj_to.vertex_groups.get(src_vg.name)
|
||||
if target_vg == None:
|
||||
target_vg = obj_to.vertex_groups.new(name=src_vg.name)
|
||||
|
||||
for i, dest_vert in enumerate(obj_to.data.vertices):
|
||||
source_verts = vert_influence_map[i]
|
||||
|
||||
# Vertex Group Name : Weight
|
||||
vgroup_weights = {}
|
||||
|
||||
for src_vert_idx, influence in source_verts:
|
||||
for group in obj_from.data.vertices[src_vert_idx].groups:
|
||||
group_idx = group.group
|
||||
vg = obj_from.vertex_groups[group_idx]
|
||||
if vg not in src_vgroups:
|
||||
continue
|
||||
if vg.name not in vgroup_weights:
|
||||
vgroup_weights[vg.name] = 0
|
||||
vgroup_weights[vg.name] += vg.weight(src_vert_idx) * influence
|
||||
|
||||
# Assign final weights of this vertex in the vertex groups.
|
||||
for vg_name in vgroup_weights.keys():
|
||||
target_vg = obj_to.vertex_groups.get(vg_name)
|
||||
target_vg.add([dest_vert.index], vgroup_weights[vg_name], 'REPLACE')
|
||||
|
||||
|
||||
registry = [EASYWEIGHT_OT_transfer_vertex_groups]
|
||||
|
||||
|
||||
def register():
|
||||
bpy.types.VIEW3D_MT_object.append(
|
||||
EASYWEIGHT_OT_transfer_vertex_groups.draw_transfer_vertex_groups_op
|
||||
)
|
||||
|
||||
|
||||
def unregister():
|
||||
bpy.types.VIEW3D_MT_object.remove(
|
||||
EASYWEIGHT_OT_transfer_vertex_groups.draw_transfer_vertex_groups_op
|
||||
)
|
@ -1,618 +0,0 @@
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
import bpy
|
||||
from bpy.types import KeyConfig, KeyMap, KeyMapItem, Operator
|
||||
|
||||
|
||||
def addon_hotkey_register(
|
||||
keymap_name='Window',
|
||||
op_idname='',
|
||||
key_id='A',
|
||||
event_type='PRESS',
|
||||
any=False,
|
||||
ctrl=False,
|
||||
alt=False,
|
||||
shift=False,
|
||||
oskey=False,
|
||||
key_modifier='NONE',
|
||||
direction='ANY',
|
||||
repeat=False,
|
||||
op_kwargs={},
|
||||
add_on_conflict=True,
|
||||
warn_on_conflict=True,
|
||||
error_on_conflict=False,
|
||||
):
|
||||
"""Top-level function for registering a hotkey as conveniently as possible.
|
||||
If you want to better manage the registered hotkey (for example, to be able
|
||||
to un-register it), it's advised to instantiate PyKeyMapItems yourself instead.
|
||||
|
||||
:param str keymap_name: Name of the KeyMap that this hotkey will be created in. Used to define what contexts the hotkey is available in
|
||||
:param str op_idname: bl_idname of the operator this hotkey should execute
|
||||
:param str key_id: Name of the key that must be interacted with to trigger this hotkey
|
||||
:param str event_type: Type of interaction to trigger this hotkey
|
||||
|
||||
:param bool any: If True, all modifier keys will be valid to trigger this hotkey
|
||||
:param bool ctrl: Whether the Ctrl key needs to be pressed in addition to the primary key
|
||||
:param bool alt: Whether the Alt key needs to be pressed in addition to the primary key
|
||||
:param bool shift: Whether the Shift key needs to be pressed in addition to the primary key
|
||||
:param bool oskey: Whether the OS key needs to be pressed in addition to the primary key
|
||||
:param str key_modifier: Another non-modifier key that should be used as a modifier key
|
||||
:param str direction: For interaction methods with a direction, this defines the direction
|
||||
:param bool repeat: Whether the hotkey should repeat its action as long as the keys remain held
|
||||
|
||||
:param op_kwargs: A dictionary of parameters that should be passed as operator parameters
|
||||
|
||||
:return: The PyKeyMapItem that manages this hotkey
|
||||
"""
|
||||
py_kmi = PyKeyMapItem(
|
||||
op_idname=op_idname,
|
||||
key_id=key_id,
|
||||
event_type=event_type,
|
||||
any=any,
|
||||
ctrl=ctrl,
|
||||
alt=alt,
|
||||
shift=shift,
|
||||
oskey=oskey,
|
||||
key_modifier=key_modifier,
|
||||
direction=direction,
|
||||
repeat=repeat,
|
||||
op_kwargs=op_kwargs,
|
||||
)
|
||||
|
||||
py_kmi.register(
|
||||
keymap_name=keymap_name,
|
||||
add_on_conflict=add_on_conflict,
|
||||
warn_on_conflict=warn_on_conflict,
|
||||
error_on_conflict=error_on_conflict,
|
||||
)
|
||||
return py_kmi
|
||||
|
||||
|
||||
class PyKeyMapItem:
|
||||
"""Class to help conveniently manage a single KeyMapItem, independently of
|
||||
any particular KeyMap or any other container or built-in bpy_type."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
op_idname='',
|
||||
key_id='A',
|
||||
event_type='PRESS',
|
||||
any=False,
|
||||
ctrl=False,
|
||||
alt=False,
|
||||
shift=False,
|
||||
oskey=False,
|
||||
key_modifier='NONE',
|
||||
direction='ANY',
|
||||
repeat=False,
|
||||
op_kwargs={},
|
||||
):
|
||||
self.op_idname = op_idname
|
||||
self.key_id = self.type = key_id
|
||||
self.check_key_id()
|
||||
self.event_type = self.value = event_type
|
||||
self.check_event_type()
|
||||
|
||||
self.any = any
|
||||
self.ctrl = ctrl
|
||||
self.alt = alt
|
||||
self.shift = shift
|
||||
self.oskey = oskey
|
||||
self.key_modifier = key_modifier
|
||||
self.direction = direction
|
||||
self.repeat = repeat
|
||||
|
||||
self.op_kwargs = op_kwargs
|
||||
|
||||
@staticmethod
|
||||
def new_from_keymap_item(kmi: KeyMapItem, context=None) -> "PyKeyMapItem":
|
||||
op_kwargs = {}
|
||||
if kmi.properties:
|
||||
op_kwargs = {key: value for key, value in kmi.properties.items()}
|
||||
return PyKeyMapItem(
|
||||
op_idname=kmi.idname,
|
||||
key_id=kmi.type,
|
||||
event_type=kmi.value,
|
||||
any=kmi.any,
|
||||
ctrl=kmi.ctrl,
|
||||
alt=kmi.alt,
|
||||
shift=kmi.shift,
|
||||
oskey=kmi.oskey,
|
||||
key_modifier=kmi.key_modifier,
|
||||
direction=kmi.direction,
|
||||
repeat=kmi.repeat,
|
||||
op_kwargs=op_kwargs,
|
||||
)
|
||||
|
||||
def check_key_id(self):
|
||||
"""Raise a KeyMapException if the keymap_name isn't a valid KeyMap name that
|
||||
actually exists in Blender's keymap system.
|
||||
"""
|
||||
return check_key_id(self.key_id)
|
||||
|
||||
def check_event_type(self):
|
||||
"""Raise a KeyMapException if the event_type isn't one that actually exists
|
||||
in Blender's keymap system."""
|
||||
return check_event_type(self.event_type)
|
||||
|
||||
@property
|
||||
def key_string(self) -> str:
|
||||
return get_kmi_key_string(self)
|
||||
|
||||
def register(
|
||||
self,
|
||||
context=None,
|
||||
keymap_name='Window',
|
||||
*,
|
||||
add_on_conflict=True,
|
||||
warn_on_conflict=True,
|
||||
error_on_conflict=False,
|
||||
) -> Optional[Tuple[KeyMap, KeyMapItem]]:
|
||||
"""Higher-level function for addon dev convenience.
|
||||
The caller doesn't have to worry about the KeyConfig or the KeyMap.
|
||||
The `addon` KeyConfig will be used.
|
||||
"""
|
||||
|
||||
if not context:
|
||||
context = bpy.context
|
||||
|
||||
wm = context.window_manager
|
||||
kconf_addon = wm.keyconfigs.addon
|
||||
if not kconf_addon:
|
||||
# This happens when running Blender in background mode.
|
||||
return
|
||||
|
||||
check_keymap_name(keymap_name)
|
||||
|
||||
# Find conflicts.
|
||||
user_km = get_keymap_of_config(wm.keyconfigs.user, keymap_name)
|
||||
if not user_km:
|
||||
conflicts = []
|
||||
else:
|
||||
conflicts = self.find_in_keymap_conflicts(user_km)
|
||||
|
||||
kmi = None
|
||||
keymap = None
|
||||
if not conflicts or add_on_conflict:
|
||||
# Add the keymap if there is no conflict, or if we are allowed
|
||||
# to add it in spite of a conflict.
|
||||
|
||||
# If this KeyMap already exists, new() will return the existing one,
|
||||
# which is confusing, but ideal.
|
||||
space_type, region_type = get_ui_types_of_keymap(keymap_name)
|
||||
keymap = kconf_addon.keymaps.new(
|
||||
name=keymap_name, space_type=space_type, region_type=region_type
|
||||
)
|
||||
|
||||
kmi = self.register_in_keymap(keymap)
|
||||
|
||||
# Warn or raise error about conflicts.
|
||||
if conflicts and (warn_on_conflict or error_on_conflict):
|
||||
conflict_info = "\n".join(
|
||||
["Conflict: " + kmi_to_str(kmi) for kmi in conflicts]
|
||||
)
|
||||
|
||||
if error_on_conflict:
|
||||
raise KeyMapException(
|
||||
"Failed to register KeyMapItem due to conflicting items:"
|
||||
+ conflict_info
|
||||
)
|
||||
if warn_on_conflict:
|
||||
print(
|
||||
"Warning: Conflicting KeyMapItems: "
|
||||
+ str(self)
|
||||
+ "\n"
|
||||
+ conflict_info
|
||||
)
|
||||
|
||||
return keymap, kmi
|
||||
|
||||
def register_in_keymap(self, keymap: KeyMap) -> Optional[KeyMapItem]:
|
||||
"""Lower-level function, for registering in a specific KeyMap."""
|
||||
|
||||
kmi = keymap.keymap_items.new(
|
||||
self.op_idname,
|
||||
type=self.key_id,
|
||||
value=self.event_type,
|
||||
any=self.any,
|
||||
ctrl=self.ctrl,
|
||||
alt=self.alt,
|
||||
shift=self.shift,
|
||||
oskey=self.oskey,
|
||||
key_modifier=self.key_modifier,
|
||||
direction=self.direction,
|
||||
repeat=self.repeat,
|
||||
)
|
||||
|
||||
for key in self.op_kwargs:
|
||||
value = self.op_kwargs[key]
|
||||
setattr(kmi.properties, key, value)
|
||||
|
||||
return kmi
|
||||
|
||||
def unregister(self, context=None) -> bool:
|
||||
"""Higher-level function for addon dev convenience.
|
||||
The caller doesn't have to worry about the KeyConfig or the KeyMap.
|
||||
The hotkey will be removed from all KeyMaps of both `addon` and 'user' KeyConfigs.
|
||||
"""
|
||||
|
||||
if not context:
|
||||
context = bpy.context
|
||||
|
||||
wm = context.window_manager
|
||||
kconfs = wm.keyconfigs
|
||||
|
||||
success = False
|
||||
for kconf in (kconfs.user, kconfs.addon):
|
||||
if not kconf:
|
||||
# This happens when running Blender in background mode.
|
||||
continue
|
||||
for km in self.find_containing_keymaps(kconf):
|
||||
self.unregister_from_keymap(km)
|
||||
success = True
|
||||
|
||||
return success
|
||||
|
||||
def unregister_from_keymap(self, keymap: KeyMap):
|
||||
"""Lower-level function, for unregistering from a specific KeyMap."""
|
||||
kmi = self.find_in_keymap_exact(keymap)
|
||||
if not kmi:
|
||||
return False
|
||||
keymap.keymap_items.remove(kmi)
|
||||
return True
|
||||
|
||||
def find_containing_keymaps(self, key_config: KeyConfig) -> List[KeyMap]:
|
||||
"""Return list of KeyMaps in a KeyConfig that contain a matching KeyMapItem."""
|
||||
matches: List[KeyMap] = []
|
||||
for km in key_config.keymaps:
|
||||
match = self.find_in_keymap_exact(km)
|
||||
if match:
|
||||
matches.append(km)
|
||||
return matches
|
||||
|
||||
def find_in_keymap_exact(self, keymap: KeyMap) -> Optional[KeyMapItem]:
|
||||
"""Find zero or one KeyMapItem in the given KeyMap that is an exact match
|
||||
with this in its operator, parameters, and key binding.
|
||||
More than one will result in an error.
|
||||
"""
|
||||
matches = self.find_in_keymap_exact_multi(keymap)
|
||||
if len(matches) > 1:
|
||||
# This should happen only if an addon dev or a user creates two keymaps
|
||||
# that are identical in everything except their ``repeat`` flag.
|
||||
raise KeyMapException(
|
||||
"More than one KeyMapItems match this PyKeyMapItem: \n"
|
||||
+ str(self)
|
||||
+ "\n".join([str(match) for match in matches])
|
||||
)
|
||||
if matches:
|
||||
return matches[0]
|
||||
|
||||
def find_in_keymap_exact_multi(self, keymap: KeyMap) -> List[KeyMapItem]:
|
||||
"""Return KeyMapItems in the given KeyMap that are an exact match with
|
||||
this PyKeyMapItem in its operator, parameters, and key binding.
|
||||
"""
|
||||
return [kmi for kmi in keymap.keymap_items if self.compare_to_kmi_exact(kmi)]
|
||||
|
||||
def compare_to_kmi_exact(self, kmi: KeyMapItem) -> bool:
|
||||
"""Return whether we have the same operator, params, and trigger
|
||||
as the passed KeyMapItem.
|
||||
"""
|
||||
return self.compare_to_kmi_by_operator(
|
||||
kmi, match_kwargs=True
|
||||
) and self.compare_to_kmi_by_trigger(kmi)
|
||||
|
||||
def find_in_keymap_by_operator(
|
||||
self, keymap: KeyMap, *, match_kwargs=True
|
||||
) -> List[KeyMapItem]:
|
||||
"""Return all KeyMapItems in the given KeyMap, which triggers the given
|
||||
operator with the given parameters.
|
||||
"""
|
||||
return [
|
||||
kmi
|
||||
for kmi in keymap.keymap_items
|
||||
if self.compare_to_kmi_by_operator(kmi, match_kwargs=match_kwargs)
|
||||
]
|
||||
|
||||
def compare_to_kmi_by_operator(self, kmi: KeyMapItem, *, match_kwargs=True) -> bool:
|
||||
"""Return whether we have the same operator
|
||||
(and optionally operator params) as the passed KMI.
|
||||
"""
|
||||
if kmi.idname != self.op_idname:
|
||||
return False
|
||||
|
||||
if not match_kwargs:
|
||||
return True
|
||||
|
||||
# Check for mismatching default-ness of operator parameters.
|
||||
if set(kmi.properties.keys()) != set(self.op_kwargs.keys()):
|
||||
# This happens when the parameter overrides specified in the KMI
|
||||
# aren't the same as what we're searching for.
|
||||
return False
|
||||
|
||||
# Check for mismatching values of operator parameters.
|
||||
for prop_name in kmi.properties.keys():
|
||||
# It's important to use getattr() instead of dictionary syntax here,
|
||||
# otherwise enum values will be integers instead of identifier strings.
|
||||
value = getattr(kmi.properties, prop_name)
|
||||
|
||||
if value != self.op_kwargs[prop_name]:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def find_in_keymap_conflicts(self, keymap: KeyMap) -> List[KeyMapItem]:
|
||||
"""Return any KeyMapItems in the given KeyMap which are bound to the
|
||||
same key combination.
|
||||
"""
|
||||
return [
|
||||
kmi for kmi in keymap.keymap_items if self.compare_to_kmi_by_trigger(kmi)
|
||||
]
|
||||
|
||||
def compare_to_kmi_by_trigger(self, kmi: KeyMapItem) -> bool:
|
||||
"""Return whether we have the same trigger settings as the passed KMI."""
|
||||
return (
|
||||
kmi.type == self.key_id
|
||||
and kmi.value == self.event_type
|
||||
and kmi.any == self.any
|
||||
and kmi.ctrl == self.ctrl
|
||||
and kmi.alt == self.alt
|
||||
and kmi.shift == self.shift
|
||||
and kmi.oskey == self.oskey
|
||||
and kmi.key_modifier == self.key_modifier
|
||||
and kmi.direction == self.direction
|
||||
)
|
||||
|
||||
def get_user_kmis(self, context=None) -> List[KeyMapItem]:
|
||||
"""Return all matching KeyMapItems in the user keyconfig."""
|
||||
if not context:
|
||||
context = bpy.context
|
||||
user_kconf = context.window_manager.keyconfigs.user
|
||||
matches = []
|
||||
for km in user_kconf.keymaps:
|
||||
for kmi in km.keymap_items:
|
||||
if self.compare_to_kmi_exact(kmi):
|
||||
matches.append(kmi)
|
||||
return matches
|
||||
|
||||
def update(self, **kwargs):
|
||||
"""Update all KeyMapItems with the passed keyword arguments."""
|
||||
for key, value in kwargs.items():
|
||||
for kmi in self.get_user_kmis():
|
||||
setattr(kmi, key, value)
|
||||
|
||||
setattr(self, key, value)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return an informative but compact string representation."""
|
||||
ret = f"PyKeyMapItem: < {self.key_string}"
|
||||
if self.op_idname:
|
||||
op = find_operator_class_by_bl_idname(self.op_idname)
|
||||
if not op:
|
||||
ret += " | " + self.op_idname + " (Unregistered)"
|
||||
else:
|
||||
op_ui_name = op.name if hasattr(op, 'name') else op.bl_label
|
||||
op_class_name = op.bl_rna.identifier
|
||||
ret += " | " + op_ui_name + f" | {self.op_idname} | {op_class_name}"
|
||||
if self.op_kwargs:
|
||||
ret += " | " + str(self.op_kwargs)
|
||||
else:
|
||||
ret += " | (No operator assigned.)"
|
||||
|
||||
return ret + " >"
|
||||
|
||||
def __repr__(self):
|
||||
"""Return a string representation that evaluates back to this object."""
|
||||
pretty_kwargs = str(self.op_kwargs).replace(", ", ",\n")
|
||||
return (
|
||||
"PyKeyMapItem(\n"
|
||||
f" op_idname='{self.op_idname}',\n"
|
||||
f" key_id='{self.key_id}',\n"
|
||||
f" event_type='{self.event_type}',\n"
|
||||
"\n"
|
||||
f" any={self.any},\n"
|
||||
f" ctrl={self.ctrl},\n"
|
||||
f" alt={self.alt},\n"
|
||||
f" shift={self.shift},\n"
|
||||
f" oskey={self.oskey},\n"
|
||||
f" key_modifier='{self.key_modifier}',\n"
|
||||
f" direction='{self.direction}',\n"
|
||||
f" repeat='{self.repeat}',\n"
|
||||
"\n"
|
||||
f" op_kwargs={pretty_kwargs}\n"
|
||||
")"
|
||||
)
|
||||
|
||||
|
||||
def kmi_to_str(kmi: KeyMapItem) -> str:
|
||||
"""Similar to PyKeyMapItem.__str__: Return a compact string representation of this KeyMapItem."""
|
||||
ret = f"KeyMapItem: < {get_kmi_key_string(kmi)}"
|
||||
if kmi.idname:
|
||||
op = find_operator_class_by_bl_idname(kmi.idname)
|
||||
if not op:
|
||||
ret += " | " + kmi.idname + " (Unregistered)"
|
||||
else:
|
||||
op_ui_name = op.name if hasattr(op, 'name') else op.bl_label
|
||||
op_class_name = op.bl_rna.identifier
|
||||
ret += " | " + op_ui_name + f" | {kmi.idname} | {op_class_name}"
|
||||
# if kmi.properties: # TODO: This currently causes a crash: https://projects.blender.org/blender/blender/issues/111702
|
||||
# ret += " | " + str({key:value for key, value in kmi.properties.items()})
|
||||
else:
|
||||
ret += " | (No operator assigned.)"
|
||||
|
||||
return ret + " >"
|
||||
|
||||
|
||||
def get_kmi_key_string(kmi) -> str:
|
||||
"""A user-friendly description string of the keys needed to activate this hotkey.
|
||||
Should be identical to what's displayed in Blender's Keymap preferences.
|
||||
"""
|
||||
key_data = get_enum_values(bpy.types.KeyMapItem, 'type')
|
||||
keys = []
|
||||
if kmi.shift:
|
||||
keys.append("Shift")
|
||||
if kmi.ctrl:
|
||||
keys.append("Ctrl")
|
||||
if kmi.alt:
|
||||
keys.append("Alt")
|
||||
if kmi.oskey:
|
||||
keys.append("OS")
|
||||
if kmi.key_modifier != 'NONE':
|
||||
keys.append(key_data[kmi.key_modifier][0])
|
||||
keys.append(key_data[kmi.type][0])
|
||||
final_string = " ".join(keys)
|
||||
if not final_string:
|
||||
return "Unassigned"
|
||||
return final_string
|
||||
|
||||
|
||||
def get_keymap_of_config(keyconfig: KeyConfig, keymap_name: str) -> Optional[KeyMap]:
|
||||
space_type, region_type = get_ui_types_of_keymap(keymap_name)
|
||||
keymap = keyconfig.keymaps.find(
|
||||
keymap_name, space_type=space_type, region_type=region_type
|
||||
)
|
||||
return keymap
|
||||
|
||||
|
||||
def ensure_keymap_in_config(keyconfig, keymap_name: str) -> KeyMap:
|
||||
space_type, region_type = get_ui_types_of_keymap(keymap_name)
|
||||
keymap = keyconfig.keymaps.new(
|
||||
keymap_name, space_type=space_type, region_type=region_type
|
||||
)
|
||||
return keymap
|
||||
|
||||
|
||||
def get_enum_values(bpy_type, enum_prop_name: str) -> Dict[str, Tuple[str, str]]:
|
||||
"""Given a registered EnumProperty's owner and name, return the enum's
|
||||
possible states as a dictionary, mapping the enum identifiers to a tuple
|
||||
of its name and description.
|
||||
|
||||
:param bpy_type: The RNA type that owns the Enum property
|
||||
:param str enum_prop_name: The name of the Enum property
|
||||
:return: A dictionary mapping the enum's identifiers to its name and description
|
||||
:rtype: dict{str: (str, str)}
|
||||
"""
|
||||
|
||||
# If it's a Python Operator.
|
||||
if isinstance(bpy_type, Operator):
|
||||
try:
|
||||
enum_items = bpy_type.__annotations__[enum_prop_name].keywords['items']
|
||||
return {e[0]: (e[1], e[2]) for e in enum_items}
|
||||
except:
|
||||
return
|
||||
|
||||
# If it's a built-in operator.
|
||||
enum_items = bpy_type.bl_rna.properties[enum_prop_name].enum_items
|
||||
return {e.identifier: (e.name, e.description) for e in enum_items}
|
||||
|
||||
|
||||
def get_all_keymap_names() -> List[str]:
|
||||
"""Returns a list of all keymap names in Blender.
|
||||
|
||||
:return: A list of all valid keymap names
|
||||
:rtype: list[str]
|
||||
"""
|
||||
return bpy.context.window_manager.keyconfigs.default.keymaps.keys()
|
||||
|
||||
|
||||
def get_ui_types_of_keymap(keymap_name: str) -> Tuple[str, str]:
|
||||
# The default KeyConfig contains all the possible valid KeyMap names,
|
||||
# with the correct space_type and region_type already assigned.
|
||||
kc_default = bpy.context.window_manager.keyconfigs.default
|
||||
# This is useful to acquire the correct parameters for new KeyMapItems,
|
||||
# since having the wrong params causes the KeyMapItem to fail silently.
|
||||
check_keymap_name(keymap_name)
|
||||
|
||||
km = kc_default.keymaps.get(keymap_name)
|
||||
assert km, f"Error: KeyMap not found: '{keymap_name}'"
|
||||
|
||||
return km.space_type, km.region_type
|
||||
|
||||
|
||||
def find_operator_class_by_bl_idname(bl_idname: str):
|
||||
"""
|
||||
Returns the class of the operator registered with the given bl_idname.
|
||||
|
||||
:param str bl_idname: Identifier of the operator to find
|
||||
:return: Class of the operator registered with the given bl_idname
|
||||
:rtype: bpy.types.Operator (for Python ops) or bpy_struct (for built-ins)
|
||||
"""
|
||||
|
||||
# Try Python operators first.
|
||||
for cl in Operator.__subclasses__():
|
||||
if not hasattr(cl, 'bl_idname'):
|
||||
# This can happen with mix-in classes.
|
||||
continue
|
||||
if cl.bl_idname == bl_idname:
|
||||
return cl
|
||||
|
||||
# Then built-ins.
|
||||
module_name, op_name = bl_idname.split(".")
|
||||
module = getattr(bpy.ops, module_name)
|
||||
if not module:
|
||||
return
|
||||
op = getattr(module, op_name)
|
||||
if not op:
|
||||
return
|
||||
return op.get_rna_type()
|
||||
|
||||
|
||||
class KeyMapException(Exception):
|
||||
"""Raised when a KeyMapItem cannot (un)register."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def check_keymap_name(keymap_name: str):
|
||||
"""Raise a KeyMapException if the keymap_name isn't a valid KeyMap name that
|
||||
actually exists in Blender's keymap system.
|
||||
"""
|
||||
all_km_names = get_all_keymap_names()
|
||||
is_valid = keymap_name in all_km_names
|
||||
if not is_valid:
|
||||
print("All valid keymap names:")
|
||||
print("\n".join(all_km_names))
|
||||
raise KeyMapException(
|
||||
f'"{keymap_name}" is not a valid keymap name. Must be one of the above.'
|
||||
)
|
||||
|
||||
|
||||
def check_key_id(key_id: str):
|
||||
"""Raise a KeyMapException if the key_id isn't one that actually exists
|
||||
in Blender's keymap system.
|
||||
"""
|
||||
all_valid_key_identifiers = get_enum_values(KeyMapItem, 'type')
|
||||
is_valid = key_id in all_valid_key_identifiers
|
||||
if not is_valid:
|
||||
print("All valid key identifiers and names:")
|
||||
print("\n".join(list(all_valid_key_identifiers.items())))
|
||||
raise KeyMapException(
|
||||
f'"{key_id}" is not a valid key identifier. Must be one of the above.'
|
||||
)
|
||||
|
||||
|
||||
def check_event_type(event_type: str):
|
||||
"""Raise a KeyMapException if the event_type isn't one that actually exists
|
||||
in Blender's keymap system.
|
||||
"""
|
||||
all_valid_event_types = get_enum_values(KeyMapItem, 'value')
|
||||
is_valid = event_type in all_valid_event_types
|
||||
if not is_valid:
|
||||
print("All valid event names:")
|
||||
print("\n".join(list(all_valid_event_types.keys())))
|
||||
raise KeyMapException(
|
||||
f'"{event_type}" is not a valid event type. Must be one of the above.'
|
||||
)
|
||||
return is_valid
|
||||
|
||||
|
||||
def find_broken_items_of_keymap(keymap: bpy.types.KeyMap):
|
||||
"""I encountered one case where kmi.properties.keys() resulted in an error.
|
||||
If that happens again, use this func to troubleshoot.
|
||||
"""
|
||||
broken = []
|
||||
for kmi in keymap.keymap_items:
|
||||
try:
|
||||
kmi.properties.keys()
|
||||
except:
|
||||
broken.append(kmi)
|
||||
|
||||
return broken
|
@ -1,129 +0,0 @@
|
||||
from typing import Tuple, List, Optional
|
||||
import re
|
||||
from bpy.utils import flip_name
|
||||
|
||||
separators = "-_."
|
||||
|
||||
|
||||
def get_name(thing) -> str:
|
||||
if hasattr(thing, 'name'):
|
||||
return thing.name
|
||||
else:
|
||||
return str(thing)
|
||||
|
||||
|
||||
def make_name(prefixes=[], base="", suffixes=[],
|
||||
prefix_separator="-", suffix_separator=".") -> str:
|
||||
"""Make a name from a list of prefixes, a base, and a list of suffixes."""
|
||||
name = ""
|
||||
for pre in prefixes:
|
||||
if pre == "":
|
||||
continue
|
||||
name += pre + prefix_separator
|
||||
name += base
|
||||
for suf in suffixes:
|
||||
if suf == "":
|
||||
continue
|
||||
name += suffix_separator + suf
|
||||
return name
|
||||
|
||||
|
||||
def slice_name(name, prefix_separator="-", suffix_separator="."):
|
||||
"""Break up a name into its prefix, base, suffix components."""
|
||||
prefixes = name.split(prefix_separator)[:-1]
|
||||
suffixes = name.split(suffix_separator)[1:]
|
||||
base = name.split(prefix_separator)[-1].split(suffix_separator)[0]
|
||||
return [prefixes, base, suffixes]
|
||||
|
||||
|
||||
def has_trailing_zeroes(thing):
|
||||
name = get_name(thing)
|
||||
regex = "\.[0-9][0-9][0-9]$"
|
||||
search = re.search(regex, name)
|
||||
return search != None
|
||||
|
||||
|
||||
def strip_trailing_numbers(name) -> Tuple[str, str]:
|
||||
if "." in name:
|
||||
# Check if there are only digits after the last period
|
||||
slices = name.split(".")
|
||||
after_last_period = slices[-1]
|
||||
before_last_period = ".".join(slices[:-1])
|
||||
|
||||
# If there are only digits after the last period, discard them
|
||||
if all([c in "0123456789" for c in after_last_period]):
|
||||
return before_last_period, "."+after_last_period
|
||||
|
||||
return name, ""
|
||||
|
||||
|
||||
def get_side_lists(with_separators=False) -> Tuple[List[str], List[str], List[str]]:
|
||||
left = ['left', 'Left', 'LEFT', 'l', 'L',]
|
||||
right_placehold = ['*rgt*', '*Rgt*', '*RGT*', '*r*', '*R*']
|
||||
right = ['right', 'Right', 'RIGHT', 'r', 'R']
|
||||
|
||||
# If the name is longer than 2 characters, only swap side identifiers if they
|
||||
# are next to a separator.
|
||||
if with_separators:
|
||||
for l in [left, right_placehold, right]:
|
||||
l_copy = l[:]
|
||||
for side in l_copy:
|
||||
if len(side) < 4:
|
||||
l.remove(side)
|
||||
for sep in separators:
|
||||
l.append(side+sep)
|
||||
l.append(sep+side)
|
||||
|
||||
return left, right_placehold, right
|
||||
|
||||
|
||||
def side_is_left(name) -> Optional[bool]:
|
||||
"""Identify whether a name belongs to the left or right side or neither."""
|
||||
|
||||
flipped_name = flip_name(name)
|
||||
if flipped_name == name:
|
||||
return None # Return None to indicate neither side.
|
||||
|
||||
stripped_name, number_suffix = strip_trailing_numbers(name)
|
||||
|
||||
def check_start_side(side_list, name):
|
||||
for side in side_list:
|
||||
if name.startswith(side):
|
||||
return True
|
||||
return False
|
||||
|
||||
def check_end_side(side_list, name):
|
||||
for side in side_list:
|
||||
if name.endswith(side):
|
||||
return True
|
||||
return False
|
||||
|
||||
left, right_placehold, right = get_side_lists(with_separators=True)
|
||||
|
||||
is_left_prefix = check_start_side(left, stripped_name)
|
||||
is_left_suffix = check_end_side(left, stripped_name)
|
||||
|
||||
is_right_prefix = check_start_side(right, stripped_name)
|
||||
is_right_suffix = check_end_side(right, stripped_name)
|
||||
|
||||
# Prioritize suffix for determining the name's side.
|
||||
if is_left_suffix or is_right_suffix:
|
||||
return is_left_suffix
|
||||
|
||||
# If no relevant suffix found, try prefix.
|
||||
if is_left_prefix or is_right_prefix:
|
||||
return is_left_prefix
|
||||
|
||||
# If no relevant suffix or prefix found, try anywhere.
|
||||
any_left = any([side in name for side in left])
|
||||
any_right = any([side in name for side in right])
|
||||
if not any_left and not any_right:
|
||||
# If neither side found, return None.
|
||||
return None
|
||||
if any_left and not any_right:
|
||||
return True
|
||||
if any_right and not any_left:
|
||||
return False
|
||||
|
||||
# If left and right were both found somewhere, I give up.
|
||||
return None
|
Loading…
Reference in New Issue
Block a user