Campbell Barton
e8da6131fd
Move copyright text to SPDX-FileCopyrightText or set to the Blender Foundation so "make check_licenses" now runs without warnings.
583 lines
18 KiB
Python
583 lines
18 KiB
Python
# SPDX-FileCopyrightText: 2016-2022 Blender Foundation
|
|
#
|
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
bl_info = {
|
|
"name": "Bone Selection Sets",
|
|
"author": "Inês Almeida, Sybren A. Stüvel, Antony Riakiotakis, Dan Eicher",
|
|
"version": (2, 1, 1),
|
|
"blender": (2, 80, 0),
|
|
"location": "Properties > Object Data (Armature) > Selection Sets",
|
|
"description": "List of Bone sets for easy selection while animating",
|
|
"warning": "",
|
|
"doc_url": "{BLENDER_MANUAL_URL}/addons/animation/bone_selection_sets.html",
|
|
"category": "Animation",
|
|
}
|
|
|
|
import bpy
|
|
from bpy.types import (
|
|
Operator,
|
|
Menu,
|
|
Panel,
|
|
UIList,
|
|
PropertyGroup,
|
|
)
|
|
from bpy.props import (
|
|
StringProperty,
|
|
IntProperty,
|
|
EnumProperty,
|
|
BoolProperty,
|
|
CollectionProperty,
|
|
)
|
|
|
|
|
|
# Data Structure ##############################################################
|
|
|
|
# Note: bones are stored by name, this means that if the bone is renamed,
|
|
# there can be problems. However, bone renaming is unlikely during animation.
|
|
class SelectionEntry(PropertyGroup):
|
|
name: StringProperty(name="Bone Name", override={'LIBRARY_OVERRIDABLE'})
|
|
|
|
|
|
class SelectionSet(PropertyGroup):
|
|
name: StringProperty(name="Set Name", override={'LIBRARY_OVERRIDABLE'})
|
|
bone_ids: CollectionProperty(
|
|
type=SelectionEntry,
|
|
override={'LIBRARY_OVERRIDABLE', 'USE_INSERTION'}
|
|
)
|
|
is_selected: BoolProperty(name="Is Selected", override={'LIBRARY_OVERRIDABLE'})
|
|
|
|
|
|
# UI Panel w/ UIList ##########################################################
|
|
|
|
class POSE_MT_selection_sets_context_menu(Menu):
|
|
bl_label = "Selection Sets Specials"
|
|
|
|
def draw(self, context):
|
|
layout = self.layout
|
|
|
|
layout.operator("pose.selection_set_delete_all", icon='X')
|
|
layout.operator("pose.selection_set_remove_bones", icon='X')
|
|
layout.operator("pose.selection_set_copy", icon='COPYDOWN')
|
|
layout.operator("pose.selection_set_paste", icon='PASTEDOWN')
|
|
|
|
|
|
class POSE_PT_selection_sets(Panel):
|
|
bl_label = "Selection Sets"
|
|
bl_space_type = 'PROPERTIES'
|
|
bl_region_type = 'WINDOW'
|
|
bl_context = "data"
|
|
bl_options = {'DEFAULT_CLOSED'}
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return (context.object and
|
|
context.object.type == 'ARMATURE' and
|
|
context.object.pose)
|
|
|
|
def draw(self, context):
|
|
layout = self.layout
|
|
|
|
arm = context.object
|
|
|
|
row = layout.row()
|
|
row.enabled = (context.mode == 'POSE')
|
|
|
|
# UI list
|
|
rows = 4 if len(arm.selection_sets) > 0 else 1
|
|
row.template_list(
|
|
"POSE_UL_selection_set", "", # type and unique id
|
|
arm, "selection_sets", # pointer to the CollectionProperty
|
|
arm, "active_selection_set", # pointer to the active identifier
|
|
rows=rows
|
|
)
|
|
|
|
# add/remove/specials UI list Menu
|
|
col = row.column(align=True)
|
|
col.operator("pose.selection_set_add", icon='ADD', text="")
|
|
col.operator("pose.selection_set_remove", icon='REMOVE', text="")
|
|
col.menu("POSE_MT_selection_sets_context_menu", icon='DOWNARROW_HLT', text="")
|
|
|
|
# move up/down arrows
|
|
if len(arm.selection_sets) > 0:
|
|
col.separator()
|
|
col.operator("pose.selection_set_move", icon='TRIA_UP', text="").direction = 'UP'
|
|
col.operator("pose.selection_set_move", icon='TRIA_DOWN', text="").direction = 'DOWN'
|
|
|
|
# buttons
|
|
row = layout.row()
|
|
|
|
sub = row.row(align=True)
|
|
sub.operator("pose.selection_set_assign", text="Assign")
|
|
sub.operator("pose.selection_set_unassign", text="Remove")
|
|
|
|
sub = row.row(align=True)
|
|
sub.operator("pose.selection_set_select", text="Select")
|
|
sub.operator("pose.selection_set_deselect", text="Deselect")
|
|
|
|
|
|
class POSE_UL_selection_set(UIList):
|
|
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
|
|
sel_set = item
|
|
layout.prop(item, "name", text="", icon='GROUP_BONE', emboss=False)
|
|
if self.layout_type in ('DEFAULT', 'COMPACT'):
|
|
layout.prop(item, "is_selected", text="")
|
|
|
|
|
|
class POSE_MT_selection_set_create(Menu):
|
|
bl_label = "Choose Selection Set"
|
|
|
|
def draw(self, context):
|
|
layout = self.layout
|
|
layout.operator("pose.selection_set_add_and_assign",
|
|
text="New Selection Set")
|
|
|
|
|
|
class POSE_MT_selection_sets_select(Menu):
|
|
bl_label = 'Select Selection Set'
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return POSE_OT_selection_set_select.poll(context)
|
|
|
|
def draw(self, context):
|
|
layout = self.layout
|
|
layout.operator_context = 'EXEC_DEFAULT'
|
|
for idx, sel_set in enumerate(context.object.selection_sets):
|
|
props = layout.operator(POSE_OT_selection_set_select.bl_idname, text=sel_set.name)
|
|
props.selection_set_index = idx
|
|
|
|
|
|
# Operators ###################################################################
|
|
|
|
class PluginOperator(Operator):
|
|
"""Operator only available for objects of type armature in pose mode."""
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return (context.object and
|
|
context.object.type == 'ARMATURE' and
|
|
context.mode == 'POSE')
|
|
|
|
|
|
class NeedSelSetPluginOperator(PluginOperator):
|
|
"""Operator only available if the armature has a selected selection set."""
|
|
@classmethod
|
|
def poll(cls, context):
|
|
if not super().poll(context):
|
|
return False
|
|
arm = context.object
|
|
return 0 <= arm.active_selection_set < len(arm.selection_sets)
|
|
|
|
|
|
class POSE_OT_selection_set_delete_all(PluginOperator):
|
|
bl_idname = "pose.selection_set_delete_all"
|
|
bl_label = "Delete All Sets"
|
|
bl_description = "Deletes All Selection Sets"
|
|
bl_options = {'UNDO', 'REGISTER'}
|
|
|
|
def execute(self, context):
|
|
arm = context.object
|
|
arm.selection_sets.clear()
|
|
return {'FINISHED'}
|
|
|
|
|
|
class POSE_OT_selection_set_remove_bones(PluginOperator):
|
|
bl_idname = "pose.selection_set_remove_bones"
|
|
bl_label = "Remove Selected Bones from All Sets"
|
|
bl_description = "Removes the Selected Bones from All Sets"
|
|
bl_options = {'UNDO', 'REGISTER'}
|
|
|
|
def execute(self, context):
|
|
arm = context.object
|
|
|
|
# iterate only the selected bones in current pose that are not hidden
|
|
for bone in context.selected_pose_bones:
|
|
for selset in arm.selection_sets:
|
|
if bone.name in selset.bone_ids:
|
|
idx = selset.bone_ids.find(bone.name)
|
|
selset.bone_ids.remove(idx)
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
class POSE_OT_selection_set_move(NeedSelSetPluginOperator):
|
|
bl_idname = "pose.selection_set_move"
|
|
bl_label = "Move Selection Set in List"
|
|
bl_description = "Move the active Selection Set up/down the list of sets"
|
|
bl_options = {'UNDO', 'REGISTER'}
|
|
|
|
direction: EnumProperty(
|
|
name="Move Direction",
|
|
description="Direction to move the active Selection Set: UP (default) or DOWN",
|
|
items=[
|
|
('UP', "Up", "", -1),
|
|
('DOWN', "Down", "", 1),
|
|
],
|
|
default='UP',
|
|
options={'HIDDEN'},
|
|
)
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
if not super().poll(context):
|
|
return False
|
|
arm = context.object
|
|
return len(arm.selection_sets) > 1
|
|
|
|
def execute(self, context):
|
|
arm = context.object
|
|
|
|
active_idx = arm.active_selection_set
|
|
new_idx = active_idx + (-1 if self.direction == 'UP' else 1)
|
|
|
|
if new_idx < 0 or new_idx >= len(arm.selection_sets):
|
|
return {'FINISHED'}
|
|
|
|
arm.selection_sets.move(active_idx, new_idx)
|
|
arm.active_selection_set = new_idx
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
class POSE_OT_selection_set_add(PluginOperator):
|
|
bl_idname = "pose.selection_set_add"
|
|
bl_label = "Create Selection Set"
|
|
bl_description = "Creates a new empty Selection Set"
|
|
bl_options = {'UNDO', 'REGISTER'}
|
|
|
|
def execute(self, context):
|
|
arm = context.object
|
|
sel_sets = arm.selection_sets
|
|
new_sel_set = sel_sets.add()
|
|
new_sel_set.name = uniqify("SelectionSet", sel_sets.keys())
|
|
|
|
# select newly created set
|
|
arm.active_selection_set = len(sel_sets) - 1
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
class POSE_OT_selection_set_remove(NeedSelSetPluginOperator):
|
|
bl_idname = "pose.selection_set_remove"
|
|
bl_label = "Delete Selection Set"
|
|
bl_description = "Delete a Selection Set"
|
|
bl_options = {'UNDO', 'REGISTER'}
|
|
|
|
def execute(self, context):
|
|
arm = context.object
|
|
|
|
arm.selection_sets.remove(arm.active_selection_set)
|
|
|
|
# change currently active selection set
|
|
numsets = len(arm.selection_sets)
|
|
if (arm.active_selection_set > (numsets - 1) and numsets > 0):
|
|
arm.active_selection_set = len(arm.selection_sets) - 1
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
class POSE_OT_selection_set_assign(PluginOperator):
|
|
bl_idname = "pose.selection_set_assign"
|
|
bl_label = "Add Bones to Selection Set"
|
|
bl_description = "Add selected bones to Selection Set"
|
|
bl_options = {'UNDO', 'REGISTER'}
|
|
|
|
def invoke(self, context, event):
|
|
arm = context.object
|
|
|
|
if not (arm.active_selection_set < len(arm.selection_sets)):
|
|
bpy.ops.wm.call_menu("INVOKE_DEFAULT",
|
|
name="POSE_MT_selection_set_create")
|
|
else:
|
|
bpy.ops.pose.selection_set_assign('EXEC_DEFAULT')
|
|
|
|
return {'FINISHED'}
|
|
|
|
def execute(self, context):
|
|
arm = context.object
|
|
act_sel_set = arm.selection_sets[arm.active_selection_set]
|
|
|
|
# iterate only the selected bones in current pose that are not hidden
|
|
for bone in context.selected_pose_bones:
|
|
if bone.name not in act_sel_set.bone_ids:
|
|
bone_id = act_sel_set.bone_ids.add()
|
|
bone_id.name = bone.name
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
class POSE_OT_selection_set_unassign(NeedSelSetPluginOperator):
|
|
bl_idname = "pose.selection_set_unassign"
|
|
bl_label = "Remove Bones from Selection Set"
|
|
bl_description = "Remove selected bones from Selection Set"
|
|
bl_options = {'UNDO', 'REGISTER'}
|
|
|
|
def execute(self, context):
|
|
arm = context.object
|
|
act_sel_set = arm.selection_sets[arm.active_selection_set]
|
|
|
|
# iterate only the selected bones in current pose that are not hidden
|
|
for bone in context.selected_pose_bones:
|
|
if bone.name in act_sel_set.bone_ids:
|
|
idx = act_sel_set.bone_ids.find(bone.name)
|
|
act_sel_set.bone_ids.remove(idx)
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
class POSE_OT_selection_set_select(NeedSelSetPluginOperator):
|
|
bl_idname = "pose.selection_set_select"
|
|
bl_label = "Select Selection Set"
|
|
bl_description = "Add Selection Set bones to current selection"
|
|
bl_options = {'UNDO', 'REGISTER'}
|
|
|
|
selection_set_index: IntProperty(
|
|
name='Selection Set Index',
|
|
default=-1,
|
|
description='Which Selection Set to select; -1 uses the active Selection Set',
|
|
options={'HIDDEN'},
|
|
)
|
|
|
|
def execute(self, context):
|
|
arm = context.object
|
|
|
|
if self.selection_set_index == -1:
|
|
idx = arm.active_selection_set
|
|
else:
|
|
idx = self.selection_set_index
|
|
sel_set = arm.selection_sets[idx]
|
|
|
|
for bone in context.visible_pose_bones:
|
|
if bone.name in sel_set.bone_ids:
|
|
bone.bone.select = True
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
class POSE_OT_selection_set_deselect(NeedSelSetPluginOperator):
|
|
bl_idname = "pose.selection_set_deselect"
|
|
bl_label = "Deselect Selection Set"
|
|
bl_description = "Remove Selection Set bones from current selection"
|
|
bl_options = {'UNDO', 'REGISTER'}
|
|
|
|
def execute(self, context):
|
|
arm = context.object
|
|
act_sel_set = arm.selection_sets[arm.active_selection_set]
|
|
|
|
for bone in context.selected_pose_bones:
|
|
if bone.name in act_sel_set.bone_ids:
|
|
bone.bone.select = False
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
class POSE_OT_selection_set_add_and_assign(PluginOperator):
|
|
bl_idname = "pose.selection_set_add_and_assign"
|
|
bl_label = "Create and Add Bones to Selection Set"
|
|
bl_description = "Creates a new Selection Set with the currently selected bones"
|
|
bl_options = {'UNDO', 'REGISTER'}
|
|
|
|
def execute(self, context):
|
|
bpy.ops.pose.selection_set_add('EXEC_DEFAULT')
|
|
bpy.ops.pose.selection_set_assign('EXEC_DEFAULT')
|
|
return {'FINISHED'}
|
|
|
|
|
|
class POSE_OT_selection_set_copy(NeedSelSetPluginOperator):
|
|
bl_idname = "pose.selection_set_copy"
|
|
bl_label = "Copy Selection Set(s)"
|
|
bl_description = "Copies the selected Selection Set(s) to the clipboard"
|
|
bl_options = {'UNDO', 'REGISTER'}
|
|
|
|
def execute(self, context):
|
|
context.window_manager.clipboard = to_json(context)
|
|
self.report({'INFO'}, 'Copied Selection Set(s) to Clipboard')
|
|
return {'FINISHED'}
|
|
|
|
|
|
class POSE_OT_selection_set_paste(PluginOperator):
|
|
bl_idname = "pose.selection_set_paste"
|
|
bl_label = "Paste Selection Set(s)"
|
|
bl_description = "Adds new Selection Set(s) from the Clipboard"
|
|
bl_options = {'UNDO', 'REGISTER'}
|
|
|
|
def execute(self, context):
|
|
import json
|
|
|
|
try:
|
|
from_json(context, context.window_manager.clipboard)
|
|
except (json.JSONDecodeError, KeyError):
|
|
self.report({'ERROR'}, 'The clipboard does not contain a Selection Set')
|
|
else:
|
|
# Select the pasted Selection Set.
|
|
context.object.active_selection_set = len(context.object.selection_sets) - 1
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
# Helper Functions ############################################################
|
|
|
|
def menu_func_select_selection_set(self, context):
|
|
self.layout.menu('POSE_MT_selection_sets_select', text="Bone Selection Set")
|
|
|
|
|
|
def to_json(context) -> str:
|
|
"""Convert the selected Selection Sets of the current rig to JSON.
|
|
|
|
Selected Sets are the active_selection_set determined by the UIList
|
|
plus any with the is_selected checkbox on."""
|
|
import json
|
|
|
|
arm = context.object
|
|
active_idx = arm.active_selection_set
|
|
|
|
json_obj = {}
|
|
for idx, sel_set in enumerate(context.object.selection_sets):
|
|
if idx == active_idx or sel_set.is_selected:
|
|
bones = [bone_id.name for bone_id in sel_set.bone_ids]
|
|
json_obj[sel_set.name] = bones
|
|
|
|
return json.dumps(json_obj)
|
|
|
|
|
|
def from_json(context, as_json: str):
|
|
"""Add the selection sets (one or more) from JSON to the current rig."""
|
|
import json
|
|
|
|
json_obj = json.loads(as_json)
|
|
arm_sel_sets = context.object.selection_sets
|
|
|
|
for name, bones in json_obj.items():
|
|
new_sel_set = arm_sel_sets.add()
|
|
new_sel_set.name = uniqify(name, arm_sel_sets.keys())
|
|
for bone_name in bones:
|
|
bone_id = new_sel_set.bone_ids.add()
|
|
bone_id.name = bone_name
|
|
|
|
|
|
def uniqify(name: str, other_names: list) -> str:
|
|
"""Return a unique name with .xxx suffix if necessary.
|
|
|
|
Example usage:
|
|
|
|
>>> uniqify('hey', ['there'])
|
|
'hey'
|
|
>>> uniqify('hey', ['hey.001', 'hey.005'])
|
|
'hey'
|
|
>>> uniqify('hey', ['hey', 'hey.001', 'hey.005'])
|
|
'hey.002'
|
|
>>> uniqify('hey', ['hey', 'hey.005', 'hey.001'])
|
|
'hey.002'
|
|
>>> uniqify('hey', ['hey', 'hey.005', 'hey.001', 'hey.left'])
|
|
'hey.002'
|
|
>>> uniqify('hey', ['hey', 'hey.001', 'hey.002'])
|
|
'hey.003'
|
|
|
|
It also works with a dict_keys object:
|
|
>>> uniqify('hey', {'hey': 1, 'hey.005': 1, 'hey.001': 1}.keys())
|
|
'hey.002'
|
|
"""
|
|
|
|
if name not in other_names:
|
|
return name
|
|
|
|
# Construct the list of numbers already in use.
|
|
offset = len(name) + 1
|
|
others = (n[offset:] for n in other_names
|
|
if n.startswith(name + '.'))
|
|
numbers = sorted(int(suffix) for suffix in others
|
|
if suffix.isdigit())
|
|
|
|
# Find the first unused number.
|
|
min_index = 1
|
|
for num in numbers:
|
|
if min_index < num:
|
|
break
|
|
min_index = num + 1
|
|
return "{}.{:03d}".format(name, min_index)
|
|
|
|
|
|
# Registry ####################################################################
|
|
|
|
classes = (
|
|
POSE_MT_selection_set_create,
|
|
POSE_MT_selection_sets_context_menu,
|
|
POSE_MT_selection_sets_select,
|
|
POSE_PT_selection_sets,
|
|
POSE_UL_selection_set,
|
|
SelectionEntry,
|
|
SelectionSet,
|
|
POSE_OT_selection_set_delete_all,
|
|
POSE_OT_selection_set_remove_bones,
|
|
POSE_OT_selection_set_move,
|
|
POSE_OT_selection_set_add,
|
|
POSE_OT_selection_set_remove,
|
|
POSE_OT_selection_set_assign,
|
|
POSE_OT_selection_set_unassign,
|
|
POSE_OT_selection_set_select,
|
|
POSE_OT_selection_set_deselect,
|
|
POSE_OT_selection_set_add_and_assign,
|
|
POSE_OT_selection_set_copy,
|
|
POSE_OT_selection_set_paste,
|
|
)
|
|
|
|
|
|
# Store keymaps here to access after registration.
|
|
addon_keymaps = []
|
|
|
|
|
|
def register():
|
|
for cls in classes:
|
|
bpy.utils.register_class(cls)
|
|
|
|
# Add properties.
|
|
bpy.types.Object.selection_sets = CollectionProperty(
|
|
type=SelectionSet,
|
|
name="Selection Sets",
|
|
description="List of groups of bones for easy selection",
|
|
override={'LIBRARY_OVERRIDABLE', 'USE_INSERTION'}
|
|
)
|
|
bpy.types.Object.active_selection_set = IntProperty(
|
|
name="Active Selection Set",
|
|
description="Index of the currently active selection set",
|
|
default=0,
|
|
override={'LIBRARY_OVERRIDABLE'}
|
|
)
|
|
|
|
# Add shortcuts to the keymap.
|
|
wm = bpy.context.window_manager
|
|
if wm.keyconfigs.addon is not None:
|
|
# wm.keyconfigs.addon is None when Blender is running in the background.
|
|
km = wm.keyconfigs.addon.keymaps.new(name='Pose')
|
|
kmi = km.keymap_items.new('wm.call_menu', 'W', 'PRESS', alt=True, shift=True)
|
|
kmi.properties.name = 'POSE_MT_selection_sets_select'
|
|
addon_keymaps.append((km, kmi))
|
|
|
|
# Add entries to menus.
|
|
bpy.types.VIEW3D_MT_select_pose.append(menu_func_select_selection_set)
|
|
|
|
|
|
def unregister():
|
|
for cls in classes:
|
|
bpy.utils.unregister_class(cls)
|
|
|
|
# Clear properties.
|
|
del bpy.types.Object.selection_sets
|
|
del bpy.types.Object.active_selection_set
|
|
|
|
# Clear shortcuts from the keymap.
|
|
for km, kmi in addon_keymaps:
|
|
km.keymap_items.remove(kmi)
|
|
addon_keymaps.clear()
|
|
|
|
# Clear entries from menus.
|
|
bpy.types.VIEW3D_MT_select_pose.remove(menu_func_select_selection_set)
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import doctest
|
|
|
|
doctest.testmod()
|
|
register()
|