EasyWeight: Major update #317

Merged
Demeter Dzadik merged 9 commits from easyweight-updates into main 2024-06-25 21:09:03 +02:00
8 changed files with 211 additions and 1274 deletions
Showing only changes of commit 739e7ee1d3 - Show all commits

View File

@ -17,8 +17,7 @@ Easy Weights is an addon focused on quality of life improvements for weight pain
## Installation ## Installation
Find installation instructions [here](https://studio.blender.org/pipeline/addons/overview). Find installation instructions [here](https://studio.blender.org/pipeline/addons/overview).
## How to Use ## Features
Read the paragraphs below to find out how to boost your weight painting workflow and comfort.
### Entering Weight Paint Mode ### 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. 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.

View File

@ -12,7 +12,6 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from .utils import hotkeys
from . import ( from . import (
rogue_weights, rogue_weights,
vertex_group_menu, vertex_group_menu,
@ -20,7 +19,6 @@ from . import (
weight_paint_context_menu, weight_paint_context_menu,
toggle_weight_paint, toggle_weight_paint,
force_apply_mirror, force_apply_mirror,
smart_weight_transfer,
prefs, prefs,
) )
import bpy import bpy
@ -40,7 +38,6 @@ bl_info = {
modules = [ modules = [
smart_weight_transfer,
force_apply_mirror, force_apply_mirror,
toggle_weight_paint, toggle_weight_paint,
weight_paint_context_menu, weight_paint_context_menu,
@ -82,26 +79,6 @@ def register_unregister_modules(modules, register: bool):
def register(): def register():
register_unregister_modules(modules, True) 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(): 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) register_unregister_modules(modules, False)

View File

@ -1,8 +1,216 @@
import bpy import bpy, json
from bpy.props import BoolProperty
class EASYWEIGHT_addon_preferences(bpy.types.AddonPreferences): 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] 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',
)

View File

@ -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)

View File

@ -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
)

View File

@ -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

View File

@ -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