diff --git a/__init__.py b/__init__.py index 24644ff..0347dc1 100644 --- a/__init__.py +++ b/__init__.py @@ -45,10 +45,10 @@ modules = [ # be registered before they themselves are. # - For Panels, they must be registered before their bl_parent_id is. # - Hotkeys must come after `cloudrig`, since we're storing them on a panel. - rig_component_features, rig_components, prefs, generation, + rig_component_features, operators, properties, metarigs, diff --git a/generation/cloudrig.py b/generation/cloudrig.py index b9ace57..3b73970 100644 --- a/generation/cloudrig.py +++ b/generation/cloudrig.py @@ -14,6 +14,7 @@ from bpy.props import ( EnumProperty, PointerProperty, IntProperty, + CollectionProperty, ) from bpy.types import ( bpy_struct, @@ -582,8 +583,7 @@ class POSE_OT_cloudrig_toggle_ikfk_bake(SnapBakeOpMixin, CloudRigOperator): self.report({'INFO'}, "Snapping complete.") return {'FINISHED'} - - def set_bone_selection(self, rig, select=False, pbones: list[PoseBone]=None): + def set_bone_selection(self, rig, select=False, pbones: list[PoseBone] = None): """Overrides SnapBakeOpMixin to also select the IK pole before keying.""" print("PBONES:", pbones, select) if select and self.target_value == 1 and self.ik_pole: @@ -598,7 +598,6 @@ class POSE_OT_cloudrig_toggle_ikfk_bake(SnapBakeOpMixin, CloudRigOperator): if self.target_value == 1 and self.ik_pole: self.snap_pole_target() - def snap_pole_target(self) -> Matrix: """Snap the pole target based on the first IK bone. This needs to run after the IK wrist control had already been snapped. @@ -614,56 +613,58 @@ class POSE_OT_cloudrig_toggle_ikfk_bake(SnapBakeOpMixin, CloudRigOperator): # https://blenderartists.org/t/visual-transform-helper-functions-for-2-5/500965 def perpendicular_vector(v): - """ Returns a vector that is perpendicular to the one given. - The returned vector is _not_ guaranteed to be normalized. + """Returns a vector that is perpendicular to the one given. + The returned vector is _not_ guaranteed to be normalized. """ # Create a vector that is not aligned with v. # It doesn't matter what vector. Just any vector # that's guaranteed to not be pointing in the same # direction. if abs(v[0]) < abs(v[1]): - tv = Vector((1,0,0)) + tv = Vector((1, 0, 0)) else: - tv = Vector((0,1,0)) + tv = Vector((0, 1, 0)) # Use cross prouct to generate a vector perpendicular to # both tv and (more importantly) v. return v.cross(tv) def rotation_difference(mat1, mat2): - """ Returns the shortest-path rotational difference between two - matrices. + """Returns the shortest-path rotational difference between two + matrices. """ q1 = mat1.to_quaternion() q2 = mat2.to_quaternion() - angle = acos(min(1,max(-1,q1.dot(q2)))) * 2 + angle = acos(min(1, max(-1, q1.dot(q2)))) * 2 if angle > pi: - angle = -angle + (2*pi) + angle = -angle + (2 * pi) return angle def get_pose_matrix_in_other_space(mat, pose_bone): - """ Returns the transform matrix relative to pose_bone's current - transform space. In other words, presuming that mat is in - armature space, slapping the returned matrix onto pose_bone - should give it the armature-space transforms of mat. + """Returns the transform matrix relative to pose_bone's current + transform space. In other words, presuming that mat is in + armature space, slapping the returned matrix onto pose_bone + should give it the armature-space transforms of mat. """ - return pose_bone.id_data.convert_space(matrix=mat, pose_bone=pose_bone, from_space='POSE', to_space='LOCAL') + return pose_bone.id_data.convert_space( + matrix=mat, pose_bone=pose_bone, from_space='POSE', to_space='LOCAL' + ) def set_pose_translation(pose_bone, mat): - """ Sets the pose bone's translation to the same translation as the given matrix. - Matrix should be given in bone's local space. + """Sets the pose bone's translation to the same translation as the given matrix. + Matrix should be given in bone's local space. """ pose_bone.location = mat.to_translation() def match_pole_target(ik_first, ik_last, pole, match_bone): - """ Places an IK chain's pole target to match ik_first's - transforms to match_bone. All bones should be given as pose bones. - You need to be in pose mode on the relevant armature object. - ik_first: first bone in the IK chain - ik_last: last bone in the IK chain - pole: pole target bone for the IK chain - match_bone: bone to match ik_first to (probably first bone in a matching FK chain) - length: distance pole target should be placed from the chain center + """Places an IK chain's pole target to match ik_first's + transforms to match_bone. All bones should be given as pose bones. + You need to be in pose mode on the relevant armature object. + ik_first: first bone in the IK chain + ik_last: last bone in the IK chain + pole: pole target bone for the IK chain + match_bone: bone to match ik_first to (probably first bone in a matching FK chain) + length: distance pole target should be placed from the chain center """ a = ik_first.matrix.to_translation() b = ik_last.matrix.to_translation() + ik_last.vector @@ -678,11 +679,11 @@ class POSE_OT_cloudrig_toggle_ikfk_bake(SnapBakeOpMixin, CloudRigOperator): pv = perpendicular_vector(ikv).normalized() * length def set_pole(pvi): - """ Set pole target's position based on a vector - from the arm center line. + """Set pole target's position based on a vector + from the arm center line. """ # Translate pvi into armature space - ploc = a + (ikv/2) + pvi + ploc = a + (ikv / 2) + pvi # Set pole target to location mat = get_pose_matrix_in_other_space(Matrix.Translation(ploc), pole) @@ -714,8 +715,6 @@ class POSE_OT_cloudrig_toggle_ikfk_bake(SnapBakeOpMixin, CloudRigOperator): match_pole_target(ik_first, self.ik_last, ik_pole, fk_first) return ik_pole.matrix - - def map_single_frame_to_bone_matrices( self, context, frame_number, bones_to_snap, snap_to_bones ): @@ -744,7 +743,6 @@ class POSE_OT_cloudrig_toggle_ikfk_bake(SnapBakeOpMixin, CloudRigOperator): bone_column.label(text=f"{' '*10} {self.ik_pole}") - ####################################### ######## Convenience Operators ######## ####################################### @@ -945,6 +943,10 @@ class CLOUDRIG_PT_settings(CLOUDRIG_PT_base): if rig.cloudrig.ui_edit_mode: if hasattr(bpy.ops.pose, 'cloudrig_add_property_to_ui'): layout.operator('pose.cloudrig_add_property_to_ui', icon='ADD') + if hasattr(bpy.ops.object, 'cloudrig_ui_element_add'): + layout.operator('object.cloudrig_ui_element_add', icon='ADD') + else: + print("Why didn't the class register") if ui_data: for panel_name, panel_data in ui_data.items(): @@ -1426,6 +1428,407 @@ def unquote_custom_prop_name(prop_name: str) -> str: return prop_name +class CloudRig_UIElement(PropertyGroup): + @property + def rig(self): + return self.id_data + + element_type: EnumProperty( + name="Element Type", + description="How this UI element is drawn", + items=[ + ('PANEL', "Panel", "Collapsible panel. May contain Panels, Labels, Rows"), + ('LABEL', "Label", "Label. May contain Panels, Labels, Rows"), + ( + 'ROW', + "Row", + "Grouping for elements that allow multiple per row. Must be used for such elements, even if there is only one in the row. May contain Rows, Properties, and Operators", + ), + ( + 'PROPERTY', + "Property", + "A single Property. Must belong to a Row. May contain conditional Panels, Labels, Rows", + ), + ('OPERATOR', "Operator", "A single Operator. Must belong to a Row"), + ], + ) + + display_name: StringProperty( + name="Display Name", + description="Display name of this UI element", + default="", + ) + # NOTE: This needs to be updated when elements are removed. + parent_index: IntProperty( + # Supported Types: Panel, Label, Row. + # TODO: Deletion will need to treat this carefully! + name="Parent Index", + description="Index of the parent UI element", + default=-1, + ) + + @property + def parent(self): + if self.parent_index >= 0: + return self.rig.cloudrig_ui[self.parent_index] + + @parent.setter + def parent(self, value: 'CloudRig_UIElement'): + if not value: + self.parent_index = -1 + elif self == value: + # Trying to set self as parent, just ignore and move on. + return + elif value.element_type == 'OPERATOR': + # Operators are not allowed children. + return + elif self.element_type == 'ROW' == value.element_type: + # Rows cannot be parented to rows. + return + else: + self.parent_index = value.index + + @property + def index(self): + for i, elem in enumerate(self.rig.cloudrig_ui): + if elem == self: + return i + return -1 + + @property + def identifier(self): + id = self.display_name + parent = self.parent + while parent: + id = parent.display_name + " -> " + id + parent = parent.parent + return id + + parent_values: StringProperty( + # Supported Types: Panel, Label, Row, only when Element Type of parent element is Property. + name="Parent Values", + description="Condition for this UI element to be drawn, when its parent is a Property. This UI element will only be drawn if the parent property has one of these comma-separated values", + ) + + texts: StringProperty( + # Supported Types: Property, only Boolean and Integer. + name="Texts", + description="Comma-separated display texts for Integer and Boolean Properties", + ) + + bl_idname: StringProperty( + # Supported Types: Operator + name="Operator ID", + description="Operator bl_idname", + ) + op_kwargs: StringProperty( + # Supported Types: Operator + name="Operator Arguments", + description="Operator Keyword Arguments, as a json dict", + default="{}", + ) + def update_icons(self, context): + # Prevent illegal icon values when users clicks X button. + if self.icon == '': + self.icon = 'BLANK1' + if self.icon_false == '': + self.icon_false = 'BLANK1' + + icon: StringProperty( + # Supported Types: Label, Row, Property(bool), Operator + name="Icon", + description="Icon", + default='CHECKBOX_HLT', + update=update_icons, + ) + icon_false: StringProperty( + # Supported Types: Property(bool) + name="Icon False", + description="Icon to display when this boolean property is False", + default='CHECKBOX_DEHLT', + update=update_icons, + ) + + prop_owner_path: StringProperty( + # Supported Types: Property + name="Property Owner", + description="Data Path from the rig object to the direct owner of the property to be drawn", + ) + + @property + def prop_owner(self): + try: + return self.rig.path_resolve(self.prop_owner_path) + except ValueError: + # This can happen eg. if user adds a constraint influence to the UI, then deletes the constraint. + return + + def update_prop_name(self, context): + if self.is_custom_prop: + self.display_name = self.prop_name.replace("_", " ").title() + elif self.prop_name == 'is_visible' and self.prop_owner: + self.display_name = self.prop_owner.name + + prop_name: StringProperty( + # Supported Types: Property + name="Property Name", + description="Name of the property to be drawn", + update=update_prop_name, + ) + + @property + def bracketed_prop_name(self): + if self.is_custom_prop: + return f'["{self.prop_name}"]' + return self.prop_name + + @property + def prop_value(self): + if not hasattr(self.prop_owner, 'path_resolve'): + print("cloudrig.py: Cannot resolve path from: ", self.prop_owner) + return + try: + return self.prop_owner.path_resolve(self.bracketed_prop_name) + except ValueError: + # Property may have been removed. + return {'MISSING'} + + is_custom_prop: BoolProperty( + # Supported Types: Property # TODO: This should be set from the update of prop_name. + name="Is Custom Property", + description="Whether this is a custom or a built-in property. Set automatically", + ) + + @property + def custom_prop_settings(self): + if not self.is_custom_prop: + return + try: + return self.prop_owner.id_properties_ui(self.prop_name).as_dict() + except TypeError: + # This happens for Python properties. There's no point drawing them. + return + + @property + def children(self): + return [elem for elem in self.rig.cloudrig_ui if elem.parent == self] + + @property + def children_recursive(self): + ret = self.children + for child in self.children: + ret.extend(child.children_recursive) + return ret + + @property + def should_draw(self): + if not self.parent: + return True + if self.parent.element_type != 'PROPERTY': + return True + if not self.parent_values: + return True + + parent_value_str = str(self.parent.prop_value) + if parent_value_str in [v.strip() for v in self.parent_values.split(",")]: + return True + + return False + + def copy_from(self, other): + for prop_name in self.bl_rna.properties.keys(): + if prop_name == 'rna_type': + continue + setattr(self, prop_name, getattr(other, prop_name)) + + def draw_ui_recursive(self, context, layouts): + if not self.should_draw or not layouts: + return + + # We copy the layout stack so we can modify it without affecting the higher scope + layouts = layouts[:] + layout = layouts[-1] + + if self.element_type == 'PANEL': + # TODO: Figure out how to allow elements to be drawn in the header. + header, layout = layout.panel(idname=str(self.index) + self.display_name) + header.label(text=self.display_name) + self.draw_ui_edit_buttons(header) + + elif self.element_type == 'LABEL': + row = layout.row() + if self.display_name: + row.label(text=self.display_name) + self.draw_ui_edit_buttons(row) + + elif self.element_type == 'ROW': + row = layout.row(align=True) + layouts.append(row) + for child in self.children: + child.draw_ui_recursive(context, layouts) + if child != self.children[-1]: + row.separator() + layouts.pop() + # NOTE: We deliberately skip drawing edit buttons for a Row. + # A Row should be automatically deleted when it no longer has any elements. + return + + elif self.element_type == 'PROPERTY': + row = layout.row(align=True) + self.draw_property(context, row) + for child in self.children: + if child.element_type == 'OPERATOR': + child.draw_ui_recursive(context, layouts + [row]) + for child in self.children: + box = None + if child.should_draw and child.element_type == 'PROPERTY': + if not box: + if self.parent.element_type == 'ROW': + layouts.pop() + box = layouts[-1].box() + layouts.append(box) + child.draw_ui_recursive(context, layouts) + self.draw_ui_edit_buttons(row) + return + + elif self.element_type == 'OPERATOR': + if not self.parent or self.parent.element_type != 'ROW': + layout = layout.row(align=True) + layouts.append(layout) + self.draw_operator(context, layout) + self.draw_ui_edit_buttons(layout) + + if layout: + for child in self.children: + child.draw_ui_recursive(context, layouts) + + def draw_ui_edit_buttons(self, layout): + if not self.rig.cloudrig.ui_edit_mode: + return + + layout.operator( + 'object.cloudrig_ui_element_edit', text="", icon='GREASEPENCIL' + ).element_index = self.index + layout.operator( + 'object.cloudrig_ui_element_remove', text="", icon='X' + ).element_index = self.index + + def draw_property(self, context, layout): + prop_owner, prop_value = self.prop_owner, self.prop_value + if not prop_owner: + layout.alert = True + layout.label( + text=f"Missing property owner: '{self.prop_owner_path}' for property '{self.prop_name}'.", + icon='ERROR', + ) + return + if prop_value == {'MISSING'}: + layout.alert = True + layout.label( + text=f"Missing property '{self.prop_name}' of owner '{self.prop_owner_path}'.", + icon='ERROR', + ) + return + + display_name = self.display_name or self.prop_name + + bracketed_prop_name = self.bracketed_prop_name + value_type, is_array = rna_idprop_value_item_type(prop_value) + + if value_type is type(None) or issubclass(value_type, ID): + # Property is a Datablock Pointer. + layout.prop(self.prop_owner, bracketed_prop_name, text=display_name) + elif value_type in {int, float, bool}: + texts = [t.strip() for t in self.texts.split(",")] + if ( + texts + and not is_array + and len(texts) - 1 >= int(prop_value) >= 0 + ): + text = texts[int(prop_value)] + if text: + display_name += ": " + text + if value_type == bool: + icon = self.icon if prop_value else self.icon_false + layout.prop( + self.prop_owner, + bracketed_prop_name, + toggle=True, + text=display_name, + icon=icon, + ) + elif value_type in {int, float}: + if self.is_custom_prop: + # Property is a float/int/color + # For large ranges, a slider doesn't make sense. + prop_settings = self.custom_prop_settings + is_slider = ( + not is_array + and prop_settings['soft_max'] - prop_settings['soft_min'] < 100 + ) + layout.prop( + prop_owner, + bracketed_prop_name, + slider=is_slider, + text=display_name, + ) + else: + layout.prop(prop_owner, bracketed_prop_name, text=display_name) + elif value_type == str: + if ( + issubclass(type(prop_owner), bpy.types.Constraint) + and bracketed_prop_name == 'subtarget' + and prop_owner.target + and prop_owner.target.type == 'ARMATURE' + ): + # Special case for nice constraint sub-target selectors. + layout.prop_search( + prop_owner, bracketed_prop_name, prop_owner.target.pose, 'bones' + ) + else: + layout.prop(prop_owner, bracketed_prop_name) + else: + layout.prop(prop_owner, bracketed_prop_name, text=display_name) + + def draw_operator(self, context, layout): + op_icon = self.icon + if not self.icon or self.icon == 'NONE': + op_icon = 'BLANK1' + display_name = self.display_name + if self.parent and self.parent.element_type == 'PROPERTY': + display_name = "" + op_props = layout.operator(self.bl_idname, text=display_name, icon=op_icon) + feed_op_props(op_props, self.op_kwargs) + return op_props + + def reset(self): + rna = self.bl_rna + for prop_name, prop_data in rna.properties.items(): + if prop_name == 'rna_type': + continue + setattr(self, prop_name, prop_data.default) + + def __repr__(self): + return self.identifier + + +class CLOUDRIG_PT_custom_ui(CLOUDRIG_PT_base): + bl_idname = "CLOUDRIG_PT_custom_ui" + bl_label = "Rig UI" + + def draw(self, context): + layout = self.layout + layout.use_property_split = False + layout.use_property_decorate = False + col = layout.column(align=True) + + rig = context.active_object # TODO + + for elem in rig.cloudrig_ui: + if not elem.parent: + elem.draw_ui_recursive(context, [layout, col]) + + ####################################### ########### Rig Preferences ########### ####################################### @@ -1615,7 +2018,6 @@ class CloudRigBoneCollection(PropertyGroup): if not component.active_bone_set: component.bone_sets_active_index = 0 - # Metarig: Update bone sets with this collection assigned to refer to the new name. if is_active_cloud_metarig(context): rig = context.pose_object or context.active_object @@ -2652,7 +3054,9 @@ class POSE_OT_cloudrig_collection_clipboard_copy(CloudRigOperator): counter += 1 json_obj[coll.name]['bone_names'] = [bone.name for bone in coll.bones] json_obj[coll.name]['cloudrig_info'] = coll['cloudrig_info'].to_dict() - json_obj[coll.name]['parent_name'] = coll.parent.name if coll.parent else "" + json_obj[coll.name]['parent_name'] = ( + coll.parent.name if coll.parent else "" + ) if counter == 0: self.report({'ERROR'}, "No visible collections to copy.") @@ -2971,10 +3375,12 @@ def register_hotkey( ####################################### classes = ( + CloudRig_UIElement, CloudRig_RigPreferences, CloudRigBoneCollection, CLOUDRIG_UL_collections, CLOUDRIG_PT_settings, + CLOUDRIG_PT_custom_ui, CLOUDRIG_PT_hotkeys_panel, CLOUDRIG_PT_collections_sidebar, CLOUDRIG_PT_collections_filter, @@ -3036,13 +3442,12 @@ def register(): for c in classes: if not is_registered(c): - # This if statement is important to avoid re-registering UI panels, - # which would cause them to lose their sub-panels. (They would become top-level.) register_class(c) bpy.types.Object.cloudrig_prefs = PointerProperty( type=CloudRig_RigPreferences, override={'LIBRARY_OVERRIDABLE'} ) + bpy.types.Object.cloudrig_ui = CollectionProperty(type=CloudRig_UIElement) bpy.types.BoneCollection.cloudrig_info = PointerProperty( type=CloudRigBoneCollection, override={'LIBRARY_OVERRIDABLE'} diff --git a/rig_component_features/__init__.py b/rig_component_features/__init__.py index b51ac14..dcadc4e 100644 --- a/rig_component_features/__init__.py +++ b/rig_component_features/__init__.py @@ -9,6 +9,7 @@ from . import ( object, parenting, properties_ui, + properties_ui_editor, ) @@ -23,6 +24,7 @@ modules = [ parenting, properties_ui, component_params_ui, + properties_ui_editor, ] # Dictionary of modules that have a Params class, and want to register diff --git a/rig_component_features/properties_ui.py b/rig_component_features/properties_ui.py index 0ec603b..4d2e121 100644 --- a/rig_component_features/properties_ui.py +++ b/rig_component_features/properties_ui.py @@ -13,7 +13,7 @@ from bpy.types import ( Modifier, ) from typing import Any -from bpy.props import StringProperty, BoolProperty, CollectionProperty +from bpy.props import StringProperty, BoolProperty, CollectionProperty, IntProperty from collections import OrderedDict from ..generation.cloudrig import ( unquote_custom_prop_name, @@ -1203,6 +1203,7 @@ def redraw_viewport(): class UIPathProperty(PropertyGroup): name: StringProperty() + index: IntProperty() ui_path: StringProperty() current: StringProperty(description="Current value of this property. Used for pre-filling the Parent Values field") diff --git a/rig_component_features/properties_ui_editor.py b/rig_component_features/properties_ui_editor.py new file mode 100644 index 0000000..6e7a8b2 --- /dev/null +++ b/rig_component_features/properties_ui_editor.py @@ -0,0 +1,558 @@ +from ..generation.cloudrig import CloudRig_UIElement, find_cloudrig, feed_op_props +from .properties_ui import UIPathProperty +from bpy.types import Operator, UILayout, ID, PoseBone, BoneCollection +from bpy.props import ( + CollectionProperty, + StringProperty, + IntProperty, + BoolProperty, + EnumProperty, + PointerProperty, +) +from rna_prop_ui import rna_idprop_value_item_type +import bpy, json + + +def draw_ui_editing(context, layout, ui_element, operator): + draw_parent_picking(context, layout, ui_element, operator) + + if operator.element_type == 'PROPERTY': + draw_prop_editing(context, layout, ui_element, operator) + elif operator.element_type == 'OPERATOR': + draw_op_editing(context, layout, ui_element, operator) + else: + layout.prop(ui_element, 'display_name') + + # debug + # layout.prop(ui_element, 'prop_owner_path') + # layout.prop(ui_element, 'is_custom_prop') + + +def draw_parent_picking(context, layout, ui_element, operator): + parent_row = layout.row() + if operator.create_new_ui: + parent_row.prop(operator, 'new_panel_name') + layout.prop(operator, 'new_label_name') + layout.prop(operator, 'new_row_name') + else: + parent_row.prop_search( + operator, 'parent_element', context.scene, 'cloudrig_ui_parent_selector' + ) + rig = find_cloudrig(context) + if operator.parent_element and operator.parent_element in context.scene.cloudrig_ui_parent_selector: + parent_ui = rig.cloudrig_ui[context.scene.cloudrig_ui_parent_selector[operator.parent_element].index] + if parent_ui and parent_ui.element_type == 'PROPERTY': + value = parent_ui.prop_value + value_type, is_array = rna_idprop_value_item_type(value) + if value_type in {bool, int}: + layout.prop(ui_element, 'parent_values') + + if context.scene.cloudrig_ui_parent_selector: + parent_row.prop(operator, 'create_new_ui', text="", icon='ADD') + + +def draw_prop_editing(context, layout, ui_element, operator): + rig = find_cloudrig(context) + + owner_row = layout.row() + if operator.prop_owner_type == 'BONE': + owner_row.prop_search(operator, 'prop_bone', rig.pose, 'bones') + elif operator.prop_owner_type == 'COLLECTION': + owner_row.prop_search( + operator, + 'prop_coll', + rig.data, + 'collections_all', + icon='OUTLINER_COLLECTION', + ) + elif operator.prop_owner_type == 'DATA_PATH': + owner_row.prop(operator, 'prop_data_path', icon='RNA') + owner_row.prop(operator, 'prop_owner_type', expand=True, text="") + + if not ui_element.prop_owner: + return + if operator.prop_owner_type == 'COLLECTION' and not operator.prop_coll: + return + if operator.prop_owner_type == 'BONE' and not operator.prop_bone: + return + + if context.scene.cloudrig_ui_prop_selector: + layout.prop_search( + ui_element, 'prop_name', context.scene, 'cloudrig_ui_prop_selector' + ) + else: + layout.prop(ui_element, 'prop_name') + + if not ui_element.prop_name: + return + + layout.prop(ui_element, 'display_name') + + value_type, is_array = rna_idprop_value_item_type(ui_element.prop_value) + if not is_array: + if value_type in {bool, int}: + layout.prop(ui_element, 'texts') + if value_type == bool: + icons = UILayout.bl_rna.functions["prop"].parameters["icon"] + layout.prop_search( + ui_element, 'icon', icons, 'enum_items', icon=ui_element.icon + ) + layout.prop_search( + ui_element, + 'icon_false', + icons, + 'enum_items', + icon=ui_element.icon_false, + ) + + +def draw_op_editing(context, layout, ui_element, operator): + if operator.use_batch_add: + return + layout.prop(operator.temp_kmi, 'idname', text="Operator") + operator.op_kwargs_dict = {} + if not operator.temp_kmi.idname: + return + + box = None + op_rna = eval("bpy.ops." + operator.temp_kmi.idname).get_rna_type() + for key, value in op_rna.properties.items(): + if key == 'rna_type': + continue + if not box: + box = layout.box().column(align=True) + box.prop(operator.temp_kmi.properties, key) + operator.op_kwargs_dict[key] = str( + getattr(operator.temp_kmi.properties, key) + ) + icons = UILayout.bl_rna.functions["prop"].parameters["icon"] + layout.prop_search( + ui_element, 'icon', icons, 'enum_items', icon=ui_element.icon + ) + + layout.prop(ui_element, 'display_name') + + +def update_parent_selector(context): + context.scene.cloudrig_ui_parent_selector.clear() + + rig = find_cloudrig(context) + + for ui_element in rig.cloudrig_ui: + if ui_element.element_type in {'PANEL', 'LABEL', 'ROW', 'PROPERTY'}: + parent_option = context.scene.cloudrig_ui_parent_selector.add() + parent_option.name = ui_element.identifier + parent_option.index = ui_element.index + + +def wipe_parent_selector(context): + if 'cloudrig_ui_parent_selector' in context.scene: + del context.scene['cloudrig_ui_parent_selector'] + + +def get_new_ui_element(context): + rig = find_cloudrig(context) + return rig.cloudrig_ui_new_element + + +def update_property_selector(self, context): + context.scene.cloudrig_ui_prop_selector.clear() + + ui_element = get_new_ui_element(context) + + prop_owner = ui_element.prop_owner + + if not prop_owner: + return + + # Populate the property drop-down selector with available custom properties. + ui_element.is_custom_prop = True + for key in get_drawable_custom_properties(prop_owner): + name_entry = context.scene.cloudrig_ui_prop_selector.add() + name_entry.name = key + + if len(context.scene.cloudrig_ui_prop_selector) == 0: + # If that failed, populate it with built-in properties instead. + ui_element.is_custom_prop = False + for key in get_drawable_builtin_properties(prop_owner): + name_entry = context.scene.cloudrig_ui_prop_selector.add() + name_entry.name = key + + +class UIElementAddMixin: + def update_parent_element(self, context): + ui_element = get_new_ui_element(context) + if self.parent_element: + ui_element.parent_index = context.scene.cloudrig_ui_parent_selector[ + self.parent_element + ].index + else: + ui_element.parent_index = -1 + + parent_element: StringProperty( + name="Parent Element", + description="Optional. UI element that this new one should be a part of", + update=update_parent_element, + ) + + def update_prop_bone(self, context): + ui_element = get_new_ui_element(context) + if self.prop_bone: + ui_element.prop_owner_path = f'pose.bones["{self.prop_bone}"]' + update_property_selector(self, context) + if ui_element.prop_name not in ui_element.prop_owner: + ui_element.prop_name = "" + + prop_bone: StringProperty(name="Bone Name", update=update_prop_bone) + + def update_prop_coll(self, context): + ui_element = get_new_ui_element(context) + if self.prop_coll: + ui_element.prop_owner_path = f'data.collections_all["{self.prop_coll}"]' + update_property_selector(self, context) + ui_element.prop_name = 'is_visible' + ui_element.display_name = self.prop_coll + ui_element.icon = 'HIDE_OFF' + ui_element.icon_false = 'HIDE_ON' + + prop_coll: StringProperty(name="Bone Collection", update=update_prop_coll) + + def update_prop_data_path(self, context): + ui_element = get_new_ui_element(context) + ui_element.prop_owner_path = self.prop_data_path + update_property_selector(self, context) + + prop_data_path: StringProperty(name="Data Path", update=update_prop_data_path) + + def update_prop_owner_type(self, context): + ui_element = get_new_ui_element(context) + if self.prop_owner_type == 'COLLECTION': + self.prop_coll = self.prop_coll + if self.prop_owner_type == 'BONE': + self.prop_bone = self.prop_bone + if self.prop_owner_type == 'DATA_PATH': + self.prop_data_path = ui_element.prop_owner_path + + prop_owner_type: EnumProperty( + name="Property Owner Type", + description="How you would like to select the owner of the property which will be added to the UI", + items=[ + ('BONE', 'Bone', 'Select a bone from the rig', 'BONE_DATA', 0), + ( + 'COLLECTION', + 'Collection', + 'Select a bone collection from the rig', + 'OUTLINER_COLLECTION', + 1, + ), + ( + 'DATA_PATH', + 'Data Path', + 'Enter a Python Data Path to any property of the rig', + 'RNA', + 2, + ), + ], + update=update_prop_owner_type, + ) + + element_type: EnumProperty( + name="Element Type", + items=[ + ('PROPERTY', 'Property', "Property"), + ('OPERATOR', 'Operator', "Operator"), + ], + ) + create_new_ui: BoolProperty( + name="Create Containers", + description="Instead of placing this UI element in an existing panel, label, and row, create new ones", + ) + new_panel_name: StringProperty( + name="Panel Name", + description="Optional. Elements parented to this panel can be hidden by collapsing the panel", + ) + new_label_name: StringProperty( + name="Label Name", + description="Optional. Elements parented to this label will be displayed below it", + ) + new_row_name: StringProperty( + name="Row Name", + description="Optional. Elements parented to this row will be displayed side-by-side", + ) + + use_batch_add: BoolProperty( + name="Batch Add", + options={'SKIP_SAVE'}, + default=False, + description="Add all custom properties of the selected ID to the UI", + ) + + @classmethod + def poll(cls, context): + rig = find_cloudrig(context) + if not rig: + return False + return True + + def ensure_parent_elements(self, rig, ui_element): + """Of the specified Panel, Label, and Row names, + create any that don't already exist. + If no Row name was provided, create one based on the UI element name. + """ + parent = None + + root_panels = {elem.display_name: elem for elem in rig.cloudrig_ui if not elem.parent and elem.element_type == 'PANEL'} + + if self.create_new_ui: + if self.new_panel_name: + if self.new_panel_name not in root_panels: + parent = rig.cloudrig_ui.add() + parent.element_type = 'PANEL' + parent.display_name = self.new_panel_name + else: + parent = root_panels[self.new_panel_name] + + panel_labels = {elem.display_name : elem for elem in parent.children if elem.element_type == 'LABEL'} + if self.new_label_name: + if self.new_label_name not in panel_labels: + label = rig.cloudrig_ui.add() + label.parent = parent + label.element_type = 'LABEL' + label.display_name = self.new_label_name + parent = label + else: + parent = panel_labels[self.new_label_name] + + parent_rows = {elem.display_name : elem for elem in parent.children if elem.element_type == 'ROW'} + + if not self.new_row_name: + self.new_row_name = "Row: " + ui_element.display_name + if self.new_row_name not in parent_rows: + row = rig.cloudrig_ui.add() + row.parent = parent + row.element_type = 'ROW' + row.display_name = self.new_row_name or ui_element.prop_name + parent = row + else: + parent = parent_rows[self.new_row_name] + + return parent + + def init_temp_kmi(self, context): + self.temp_kmi = context.window_manager.keyconfigs.default.keymaps[ + 'Info' + ].keymap_items.new('', 'NUMPAD_5', 'PRESS') + if self.temp_element.bl_idname: + self.temp_kmi.idname = self.temp_element.bl_idname + if self.temp_element.op_kwargs: + op_props = self.temp_kmi.properties + feed_op_props(op_props, self.temp_element.op_kwargs) + +class CLOUDRIG_OT_ui_element_add(UIElementAddMixin, Operator): + """Add UI Element""" + + bl_idname = "object.cloudrig_ui_element_add" + bl_label = "Add UI Element" + bl_options = {'INTERNAL', 'REGISTER', 'UNDO'} + + def invoke(self, context, _event): + update_parent_selector(context) + if not context.scene.cloudrig_ui_parent_selector: + self.create_new_ui = True + + self.temp_element = get_new_ui_element(context) + self.temp_element.reset() + + self.init_temp_kmi(context) + + return context.window_manager.invoke_props_dialog(self, width=500) + + def draw(self, context): + layout = self.layout + layout.use_property_decorate = False + layout.use_property_split = True + + layout.prop(self, 'element_type', expand=True) + draw_ui_editing(context, layout, self.temp_element, self) + + def execute(self, context): + rig = find_cloudrig(context) + temp_ui_element = get_new_ui_element(context) + + parent = self.ensure_parent_elements(rig, temp_ui_element) + + new_ui_element = rig.cloudrig_ui.add() + new_ui_element.copy_from(temp_ui_element) + if parent: + new_ui_element.parent = parent + new_ui_element.element_type = self.element_type + + if self.element_type == 'OPERATOR': + new_ui_element.bl_idname = self.temp_kmi.idname + # NOTE: The op kwargs have been fed to the UIElement in draw_op_editing(). + + wipe_parent_selector(context) + del rig['cloudrig_ui_new_element'] + + return {'FINISHED'} + + +class CLOUDRIG_OT_ui_element_edit(UIElementAddMixin, Operator): + """Add a UI element""" + + bl_idname = "object.cloudrig_ui_element_edit" + bl_label = "Edit UI Element" + bl_options = {'INTERNAL', 'REGISTER', 'UNDO'} + + element_index: IntProperty() + element_type: StringProperty() + + def invoke(self, context, _event): + update_parent_selector(context) + rig = find_cloudrig(context) + + self.temp_element = get_new_ui_element(context) + self.temp_element.reset() + elem_to_edit = rig.cloudrig_ui[self.element_index] + self.temp_element.copy_from(elem_to_edit) + + if self.temp_element.element_type in {'PROPERTY', 'OPERATOR'}: + self.element_type = self.temp_element.element_type + else: + self.element_type = "" + + self.init_temp_kmi(context) + + prop_owner = self.temp_element.prop_owner + + if type(prop_owner) == PoseBone: + self.prop_owner_type = 'BONE' + self.prop_bone = prop_owner.name + elif type(prop_owner) == BoneCollection: + self.prop_owner_type = 'COLLECTION' + self.prop_coll = prop_owner.name + else: + self.prop_owner_type = 'DATA_PATH' + + if self.temp_element.parent_index > -1: + self.parent_element = rig.cloudrig_ui[self.temp_element.parent_index].identifier + else: + self.parent_element = "" + + self.prop_data_path = self.temp_element.prop_owner_path + + return context.window_manager.invoke_props_dialog(self, width=500) + + def draw(self, context): + layout = self.layout + layout.use_property_decorate = False + layout.use_property_split = True + + draw_ui_editing(context, layout, self.temp_element, self) + + def execute(self, context): + rig = find_cloudrig(context) + elem_to_edit = rig.cloudrig_ui[self.element_index] + + elem_to_edit.copy_from(self.temp_element) + if self.element_type == 'OPERATOR': + elem_to_edit.bl_idname = self.temp_kmi.idname + # NOTE: The op kwargs have been fed to the UIElement in draw_op_editing(). + return {'FINISHED'} + +class CLOUDRIG_OT_ui_element_remove(Operator): + """Remove this UI element.\n\nCtrl: Do not remove children""" + + bl_idname = "object.cloudrig_ui_element_remove" + bl_label = "Remove UI Element" + bl_options = {'INTERNAL', 'REGISTER', 'UNDO'} + + element_index: IntProperty() + recursive: BoolProperty(default=True) + + @classmethod + def poll(cls, context): + return find_cloudrig(context) + + def invoke(self, context, event): + self.recursive = not event.ctrl + return self.execute(context) + + def execute(self, context): + rig = find_cloudrig(context) + self.remove_element(rig, self.element_index) + return {'FINISHED'} + + def remove_element(self, rig, index): + element_to_remove = rig.cloudrig_ui[index] + fallback_parent = element_to_remove.parent + + indicies_to_remove = [] + + if self.recursive: + for child in element_to_remove.children_recursive: + indicies_to_remove.append(child.index) + else: + for child in element_to_remove.children: + child.parent = fallback_parent + indicies_to_remove.append(element_to_remove.index) + + for i in reversed(sorted(indicies_to_remove)): + rig.cloudrig_ui.remove(i) + for elem in rig.cloudrig_ui: + if elem.parent_index > i: + elem.parent_index -= 1 + +def supports_custom_props(prop_owner): + return isinstance(prop_owner, ID) or type(prop_owner) in {PoseBone, BoneCollection} + + +def has_custom_props(prop_owner) -> bool: + if not supports_custom_props(prop_owner): + return False + return bool(list(get_drawable_custom_properties(prop_owner))) + + +def get_drawable_custom_properties(prop_owner): + if not supports_custom_props(prop_owner): + return [] + for prop_name in prop_owner.keys(): + try: + prop_owner.id_properties_ui(prop_name).as_dict() + except TypeError: + # This happens for Python properties. There's not much point in drawing them. + continue + yield prop_name + + +def path_resolve_safe(owner, data_path): + try: + return owner.path_resolve(data_path) + except ValueError: + # This can happen eg. if user adds a constraint influence to the UI, then deletes the constraint. + return + + +def get_drawable_builtin_properties(prop_owner): + for prop_name, prop_data in prop_owner.bl_rna.properties.items(): + if prop_data.is_runtime: + continue + prop_value = getattr(prop_owner, prop_name) + value_type, is_array = rna_idprop_value_item_type(prop_value) + if value_type in {bool, int, float, str}: + yield prop_name + + +def register(): + bpy.types.Scene.cloudrig_ui_parent_selector = CollectionProperty( + type=UIPathProperty + ) + bpy.types.Scene.cloudrig_ui_prop_selector = CollectionProperty(type=UIPathProperty) + bpy.types.Object.cloudrig_ui_new_element = PointerProperty(type=CloudRig_UIElement) + + +registry = [ + CLOUDRIG_OT_ui_element_add, + CLOUDRIG_OT_ui_element_edit, + CLOUDRIG_OT_ui_element_remove, +]