1 # ##### BEGIN GPL LICENSE BLOCK #####
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; either version 2
6 # of the License, or (at your option) any later version.
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # GNU General Public License for more details.
13 # You should have received a copy of the GNU General Public License
14 # along with this program; if not, write to the Free Software Foundation,
15 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 # ##### END GPL LICENCE BLOCK #####
20 "name": "Bone Selection Sets",
21 "author": "Inês Almeida, Sybren A. Stüvel, Antony Riakiotakis, Dan Eicher",
23 "blender": (2, 80, 0),
24 "location": "Properties > Object Data (Armature) > Selection Sets",
25 "description": "List of Bone sets for easy selection while animating",
27 "doc_url": "{BLENDER_MANUAL_URL}/addons/animation/bone_selection_sets.html",
28 "category": "Animation",
32 from bpy
.types
import (
39 from bpy
.props
import (
48 # Data Structure ##############################################################
50 # Note: bones are stored by name, this means that if the bone is renamed,
51 # there can be problems. However, bone renaming is unlikely during animation.
52 class SelectionEntry(PropertyGroup
):
53 name
: StringProperty(name
="Bone Name", override
={'LIBRARY_OVERRIDABLE'})
56 class SelectionSet(PropertyGroup
):
57 name
: StringProperty(name
="Set Name", override
={'LIBRARY_OVERRIDABLE'})
58 bone_ids
: CollectionProperty(
60 override
={'LIBRARY_OVERRIDABLE', 'USE_INSERTION'}
62 is_selected
: BoolProperty(name
="Is Selected", override
={'LIBRARY_OVERRIDABLE'})
65 # UI Panel w/ UIList ##########################################################
67 class POSE_MT_selection_sets_context_menu(Menu
):
68 bl_label
= "Selection Sets Specials"
70 def draw(self
, context
):
73 layout
.operator("pose.selection_set_delete_all", icon
='X')
74 layout
.operator("pose.selection_set_remove_bones", icon
='X')
75 layout
.operator("pose.selection_set_copy", icon
='COPYDOWN')
76 layout
.operator("pose.selection_set_paste", icon
='PASTEDOWN')
79 class POSE_PT_selection_sets(Panel
):
80 bl_label
= "Selection Sets"
81 bl_space_type
= 'PROPERTIES'
82 bl_region_type
= 'WINDOW'
84 bl_options
= {'DEFAULT_CLOSED'}
87 def poll(cls
, context
):
88 return (context
.object and
89 context
.object.type == 'ARMATURE' and
92 def draw(self
, context
):
98 row
.enabled
= (context
.mode
== 'POSE')
101 rows
= 4 if len(arm
.selection_sets
) > 0 else 1
103 "POSE_UL_selection_set", "", # type and unique id
104 arm
, "selection_sets", # pointer to the CollectionProperty
105 arm
, "active_selection_set", # pointer to the active identifier
109 # add/remove/specials UI list Menu
110 col
= row
.column(align
=True)
111 col
.operator("pose.selection_set_add", icon
='ADD', text
="")
112 col
.operator("pose.selection_set_remove", icon
='REMOVE', text
="")
113 col
.menu("POSE_MT_selection_sets_context_menu", icon
='DOWNARROW_HLT', text
="")
115 # move up/down arrows
116 if len(arm
.selection_sets
) > 0:
118 col
.operator("pose.selection_set_move", icon
='TRIA_UP', text
="").direction
= 'UP'
119 col
.operator("pose.selection_set_move", icon
='TRIA_DOWN', text
="").direction
= 'DOWN'
124 sub
= row
.row(align
=True)
125 sub
.operator("pose.selection_set_assign", text
="Assign")
126 sub
.operator("pose.selection_set_unassign", text
="Remove")
128 sub
= row
.row(align
=True)
129 sub
.operator("pose.selection_set_select", text
="Select")
130 sub
.operator("pose.selection_set_deselect", text
="Deselect")
133 class POSE_UL_selection_set(UIList
):
134 def draw_item(self
, context
, layout
, data
, item
, icon
, active_data
, active_propname
, index
):
136 layout
.prop(item
, "name", text
="", icon
='GROUP_BONE', emboss
=False)
137 if self
.layout_type
in ('DEFAULT', 'COMPACT'):
138 layout
.prop(item
, "is_selected", text
="")
141 class POSE_MT_selection_set_create(Menu
):
142 bl_label
= "Choose Selection Set"
144 def draw(self
, context
):
146 layout
.operator("pose.selection_set_add_and_assign",
147 text
="New Selection Set")
150 class POSE_MT_selection_sets_select(Menu
):
151 bl_label
= 'Select Selection Set'
154 def poll(cls
, context
):
155 return POSE_OT_selection_set_select
.poll(context
)
157 def draw(self
, context
):
159 layout
.operator_context
= 'EXEC_DEFAULT'
160 for idx
, sel_set
in enumerate(context
.object.selection_sets
):
161 props
= layout
.operator(POSE_OT_selection_set_select
.bl_idname
, text
=sel_set
.name
)
162 props
.selection_set_index
= idx
165 # Operators ###################################################################
167 class PluginOperator(Operator
):
168 """Operator only available for objects of type armature in pose mode."""
170 def poll(cls
, context
):
171 return (context
.object and
172 context
.object.type == 'ARMATURE' and
173 context
.mode
== 'POSE')
176 class NeedSelSetPluginOperator(PluginOperator
):
177 """Operator only available if the armature has a selected selection set."""
179 def poll(cls
, context
):
180 if not super().poll(context
):
183 return 0 <= arm
.active_selection_set
< len(arm
.selection_sets
)
186 class POSE_OT_selection_set_delete_all(PluginOperator
):
187 bl_idname
= "pose.selection_set_delete_all"
188 bl_label
= "Delete All Sets"
189 bl_description
= "Deletes All Selection Sets"
190 bl_options
= {'UNDO', 'REGISTER'}
192 def execute(self
, context
):
194 arm
.selection_sets
.clear()
198 class POSE_OT_selection_set_remove_bones(PluginOperator
):
199 bl_idname
= "pose.selection_set_remove_bones"
200 bl_label
= "Remove Selected Bones from All Sets"
201 bl_description
= "Removes the Selected Bones from All Sets"
202 bl_options
= {'UNDO', 'REGISTER'}
204 def execute(self
, context
):
207 # iterate only the selected bones in current pose that are not hidden
208 for bone
in context
.selected_pose_bones
:
209 for selset
in arm
.selection_sets
:
210 if bone
.name
in selset
.bone_ids
:
211 idx
= selset
.bone_ids
.find(bone
.name
)
212 selset
.bone_ids
.remove(idx
)
217 class POSE_OT_selection_set_move(NeedSelSetPluginOperator
):
218 bl_idname
= "pose.selection_set_move"
219 bl_label
= "Move Selection Set in List"
220 bl_description
= "Move the active Selection Set up/down the list of sets"
221 bl_options
= {'UNDO', 'REGISTER'}
223 direction
: EnumProperty(
224 name
="Move Direction",
225 description
="Direction to move the active Selection Set: UP (default) or DOWN",
227 ('UP', "Up", "", -1),
228 ('DOWN', "Down", "", 1),
235 def poll(cls
, context
):
236 if not super().poll(context
):
239 return len(arm
.selection_sets
) > 1
241 def execute(self
, context
):
244 active_idx
= arm
.active_selection_set
245 new_idx
= active_idx
+ (-1 if self
.direction
== 'UP' else 1)
247 if new_idx
< 0 or new_idx
>= len(arm
.selection_sets
):
250 arm
.selection_sets
.move(active_idx
, new_idx
)
251 arm
.active_selection_set
= new_idx
256 class POSE_OT_selection_set_add(PluginOperator
):
257 bl_idname
= "pose.selection_set_add"
258 bl_label
= "Create Selection Set"
259 bl_description
= "Creates a new empty Selection Set"
260 bl_options
= {'UNDO', 'REGISTER'}
262 def execute(self
, context
):
264 sel_sets
= arm
.selection_sets
265 new_sel_set
= sel_sets
.add()
266 new_sel_set
.name
= uniqify("SelectionSet", sel_sets
.keys())
268 # select newly created set
269 arm
.active_selection_set
= len(sel_sets
) - 1
274 class POSE_OT_selection_set_remove(NeedSelSetPluginOperator
):
275 bl_idname
= "pose.selection_set_remove"
276 bl_label
= "Delete Selection Set"
277 bl_description
= "Delete a Selection Set"
278 bl_options
= {'UNDO', 'REGISTER'}
280 def execute(self
, context
):
283 arm
.selection_sets
.remove(arm
.active_selection_set
)
285 # change currently active selection set
286 numsets
= len(arm
.selection_sets
)
287 if (arm
.active_selection_set
> (numsets
- 1) and numsets
> 0):
288 arm
.active_selection_set
= len(arm
.selection_sets
) - 1
293 class POSE_OT_selection_set_assign(PluginOperator
):
294 bl_idname
= "pose.selection_set_assign"
295 bl_label
= "Add Bones to Selection Set"
296 bl_description
= "Add selected bones to Selection Set"
297 bl_options
= {'UNDO', 'REGISTER'}
299 def invoke(self
, context
, event
):
302 if not (arm
.active_selection_set
< len(arm
.selection_sets
)):
303 bpy
.ops
.wm
.call_menu("INVOKE_DEFAULT",
304 name
="POSE_MT_selection_set_create")
306 bpy
.ops
.pose
.selection_set_assign('EXEC_DEFAULT')
310 def execute(self
, context
):
312 act_sel_set
= arm
.selection_sets
[arm
.active_selection_set
]
314 # iterate only the selected bones in current pose that are not hidden
315 for bone
in context
.selected_pose_bones
:
316 if bone
.name
not in act_sel_set
.bone_ids
:
317 bone_id
= act_sel_set
.bone_ids
.add()
318 bone_id
.name
= bone
.name
323 class POSE_OT_selection_set_unassign(NeedSelSetPluginOperator
):
324 bl_idname
= "pose.selection_set_unassign"
325 bl_label
= "Remove Bones from Selection Set"
326 bl_description
= "Remove selected bones from Selection Set"
327 bl_options
= {'UNDO', 'REGISTER'}
329 def execute(self
, context
):
331 act_sel_set
= arm
.selection_sets
[arm
.active_selection_set
]
333 # iterate only the selected bones in current pose that are not hidden
334 for bone
in context
.selected_pose_bones
:
335 if bone
.name
in act_sel_set
.bone_ids
:
336 idx
= act_sel_set
.bone_ids
.find(bone
.name
)
337 act_sel_set
.bone_ids
.remove(idx
)
342 class POSE_OT_selection_set_select(NeedSelSetPluginOperator
):
343 bl_idname
= "pose.selection_set_select"
344 bl_label
= "Select Selection Set"
345 bl_description
= "Add Selection Set bones to current selection"
346 bl_options
= {'UNDO', 'REGISTER'}
348 selection_set_index
: IntProperty(
349 name
='Selection Set Index',
351 description
='Which Selection Set to select; -1 uses the active Selection Set',
355 def execute(self
, context
):
358 if self
.selection_set_index
== -1:
359 idx
= arm
.active_selection_set
361 idx
= self
.selection_set_index
362 sel_set
= arm
.selection_sets
[idx
]
364 for bone
in context
.visible_pose_bones
:
365 if bone
.name
in sel_set
.bone_ids
:
366 bone
.bone
.select
= True
371 class POSE_OT_selection_set_deselect(NeedSelSetPluginOperator
):
372 bl_idname
= "pose.selection_set_deselect"
373 bl_label
= "Deselect Selection Set"
374 bl_description
= "Remove Selection Set bones from current selection"
375 bl_options
= {'UNDO', 'REGISTER'}
377 def execute(self
, context
):
379 act_sel_set
= arm
.selection_sets
[arm
.active_selection_set
]
381 for bone
in context
.selected_pose_bones
:
382 if bone
.name
in act_sel_set
.bone_ids
:
383 bone
.bone
.select
= False
388 class POSE_OT_selection_set_add_and_assign(PluginOperator
):
389 bl_idname
= "pose.selection_set_add_and_assign"
390 bl_label
= "Create and Add Bones to Selection Set"
391 bl_description
= "Creates a new Selection Set with the currently selected bones"
392 bl_options
= {'UNDO', 'REGISTER'}
394 def execute(self
, context
):
395 bpy
.ops
.pose
.selection_set_add('EXEC_DEFAULT')
396 bpy
.ops
.pose
.selection_set_assign('EXEC_DEFAULT')
400 class POSE_OT_selection_set_copy(NeedSelSetPluginOperator
):
401 bl_idname
= "pose.selection_set_copy"
402 bl_label
= "Copy Selection Set(s)"
403 bl_description
= "Copies the selected Selection Set(s) to the clipboard"
404 bl_options
= {'UNDO', 'REGISTER'}
406 def execute(self
, context
):
407 context
.window_manager
.clipboard
= to_json(context
)
408 self
.report({'INFO'}, 'Copied Selection Set(s) to Clipboard')
412 class POSE_OT_selection_set_paste(PluginOperator
):
413 bl_idname
= "pose.selection_set_paste"
414 bl_label
= "Paste Selection Set(s)"
415 bl_description
= "Adds new Selection Set(s) from the Clipboard"
416 bl_options
= {'UNDO', 'REGISTER'}
418 def execute(self
, context
):
422 from_json(context
, context
.window_manager
.clipboard
)
423 except (json
.JSONDecodeError
, KeyError):
424 self
.report({'ERROR'}, 'The clipboard does not contain a Selection Set')
426 # Select the pasted Selection Set.
427 context
.object.active_selection_set
= len(context
.object.selection_sets
) - 1
432 # Helper Functions ############################################################
434 def menu_func_select_selection_set(self
, context
):
435 self
.layout
.menu('POSE_MT_selection_sets_select', text
="Bone Selection Set")
438 def to_json(context
) -> str:
439 """Convert the selected Selection Sets of the current rig to JSON.
441 Selected Sets are the active_selection_set determined by the UIList
442 plus any with the is_selected checkbox on."""
446 active_idx
= arm
.active_selection_set
449 for idx
, sel_set
in enumerate(context
.object.selection_sets
):
450 if idx
== active_idx
or sel_set
.is_selected
:
451 bones
= [bone_id
.name
for bone_id
in sel_set
.bone_ids
]
452 json_obj
[sel_set
.name
] = bones
454 return json
.dumps(json_obj
)
457 def from_json(context
, as_json
: str):
458 """Add the selection sets (one or more) from JSON to the current rig."""
461 json_obj
= json
.loads(as_json
)
462 arm_sel_sets
= context
.object.selection_sets
464 for name
, bones
in json_obj
.items():
465 new_sel_set
= arm_sel_sets
.add()
466 new_sel_set
.name
= uniqify(name
, arm_sel_sets
.keys())
467 for bone_name
in bones
:
468 bone_id
= new_sel_set
.bone_ids
.add()
469 bone_id
.name
= bone_name
472 def uniqify(name
: str, other_names
: list) -> str:
473 """Return a unique name with .xxx suffix if necessary.
477 >>> uniqify('hey', ['there'])
479 >>> uniqify('hey', ['hey.001', 'hey.005'])
481 >>> uniqify('hey', ['hey', 'hey.001', 'hey.005'])
483 >>> uniqify('hey', ['hey', 'hey.005', 'hey.001'])
485 >>> uniqify('hey', ['hey', 'hey.005', 'hey.001', 'hey.left'])
487 >>> uniqify('hey', ['hey', 'hey.001', 'hey.002'])
490 It also works with a dict_keys object:
491 >>> uniqify('hey', {'hey': 1, 'hey.005': 1, 'hey.001': 1}.keys())
495 if name
not in other_names
:
498 # Construct the list of numbers already in use.
499 offset
= len(name
) + 1
500 others
= (n
[offset
:] for n
in other_names
501 if n
.startswith(name
+ '.'))
502 numbers
= sorted(int(suffix
) for suffix
in others
505 # Find the first unused number.
511 return "{}.{:03d}".format(name
, min_index
)
514 # Registry ####################################################################
517 POSE_MT_selection_set_create
,
518 POSE_MT_selection_sets_context_menu
,
519 POSE_MT_selection_sets_select
,
520 POSE_PT_selection_sets
,
521 POSE_UL_selection_set
,
524 POSE_OT_selection_set_delete_all
,
525 POSE_OT_selection_set_remove_bones
,
526 POSE_OT_selection_set_move
,
527 POSE_OT_selection_set_add
,
528 POSE_OT_selection_set_remove
,
529 POSE_OT_selection_set_assign
,
530 POSE_OT_selection_set_unassign
,
531 POSE_OT_selection_set_select
,
532 POSE_OT_selection_set_deselect
,
533 POSE_OT_selection_set_add_and_assign
,
534 POSE_OT_selection_set_copy
,
535 POSE_OT_selection_set_paste
,
539 # Store keymaps here to access after registration.
545 bpy
.utils
.register_class(cls
)
548 bpy
.types
.Object
.selection_sets
= CollectionProperty(
550 name
="Selection Sets",
551 description
="List of groups of bones for easy selection",
552 override
={'LIBRARY_OVERRIDABLE', 'USE_INSERTION'}
554 bpy
.types
.Object
.active_selection_set
= IntProperty(
555 name
="Active Selection Set",
556 description
="Index of the currently active selection set",
558 override
={'LIBRARY_OVERRIDABLE'}
561 # Add shortcuts to the keymap.
562 wm
= bpy
.context
.window_manager
563 if wm
.keyconfigs
.addon
is not None:
564 # wm.keyconfigs.addon is None when Blender is running in the background.
565 km
= wm
.keyconfigs
.addon
.keymaps
.new(name
='Pose')
566 kmi
= km
.keymap_items
.new('wm.call_menu', 'W', 'PRESS', alt
=True, shift
=True)
567 kmi
.properties
.name
= 'POSE_MT_selection_sets_select'
568 addon_keymaps
.append((km
, kmi
))
570 # Add entries to menus.
571 bpy
.types
.VIEW3D_MT_select_pose
.append(menu_func_select_selection_set
)
576 bpy
.utils
.unregister_class(cls
)
579 del bpy
.types
.Object
.selection_sets
580 del bpy
.types
.Object
.active_selection_set
582 # Clear shortcuts from the keymap.
583 for km
, kmi
in addon_keymaps
:
584 km
.keymap_items
.remove(kmi
)
585 addon_keymaps
.clear()
587 # Clear entries from menus.
588 bpy
.types
.VIEW3D_MT_select_pose
.remove(menu_func_select_selection_set
)
592 if __name__
== "__main__":