(On Hold) Rework Properties UI Editor #159

Open
Demeter Dzadik wants to merge 10 commits from new-props-ux into master

When changing the target branch, be careful to rebase the branch in your fork to match. See documentation.
5 changed files with 1004 additions and 38 deletions

View File

@ -45,10 +45,10 @@ modules = [
# be registered before they themselves are. # be registered before they themselves are.
# - For Panels, they must be registered before their bl_parent_id is. # - 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. # - Hotkeys must come after `cloudrig`, since we're storing them on a panel.
rig_component_features,
rig_components, rig_components,
prefs, prefs,
generation, generation,
rig_component_features,
operators, operators,
properties, properties,
metarigs, metarigs,

View File

@ -14,6 +14,7 @@ from bpy.props import (
EnumProperty, EnumProperty,
PointerProperty, PointerProperty,
IntProperty, IntProperty,
CollectionProperty,
) )
from bpy.types import ( from bpy.types import (
bpy_struct, bpy_struct,
@ -582,8 +583,7 @@ class POSE_OT_cloudrig_toggle_ikfk_bake(SnapBakeOpMixin, CloudRigOperator):
self.report({'INFO'}, "Snapping complete.") self.report({'INFO'}, "Snapping complete.")
return {'FINISHED'} 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.""" """Overrides SnapBakeOpMixin to also select the IK pole before keying."""
print("PBONES:", pbones, select) print("PBONES:", pbones, select)
if select and self.target_value == 1 and self.ik_pole: 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: if self.target_value == 1 and self.ik_pole:
self.snap_pole_target() self.snap_pole_target()
def snap_pole_target(self) -> Matrix: def snap_pole_target(self) -> Matrix:
"""Snap the pole target based on the first IK bone. """Snap the pole target based on the first IK bone.
This needs to run after the IK wrist control had already been snapped. 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 # https://blenderartists.org/t/visual-transform-helper-functions-for-2-5/500965
def perpendicular_vector(v): def perpendicular_vector(v):
""" Returns a vector that is perpendicular to the one given. """Returns a vector that is perpendicular to the one given.
The returned vector is _not_ guaranteed to be normalized. The returned vector is _not_ guaranteed to be normalized.
""" """
# Create a vector that is not aligned with v. # Create a vector that is not aligned with v.
# It doesn't matter what vector. Just any vector # It doesn't matter what vector. Just any vector
# that's guaranteed to not be pointing in the same # that's guaranteed to not be pointing in the same
# direction. # direction.
if abs(v[0]) < abs(v[1]): if abs(v[0]) < abs(v[1]):
tv = Vector((1,0,0)) tv = Vector((1, 0, 0))
else: else:
tv = Vector((0,1,0)) tv = Vector((0, 1, 0))
# Use cross prouct to generate a vector perpendicular to # Use cross prouct to generate a vector perpendicular to
# both tv and (more importantly) v. # both tv and (more importantly) v.
return v.cross(tv) return v.cross(tv)
def rotation_difference(mat1, mat2): def rotation_difference(mat1, mat2):
""" Returns the shortest-path rotational difference between two """Returns the shortest-path rotational difference between two
matrices. matrices.
""" """
q1 = mat1.to_quaternion() q1 = mat1.to_quaternion()
q2 = mat2.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: if angle > pi:
angle = -angle + (2*pi) angle = -angle + (2 * pi)
return angle return angle
def get_pose_matrix_in_other_space(mat, pose_bone): def get_pose_matrix_in_other_space(mat, pose_bone):
""" Returns the transform matrix relative to pose_bone's current """Returns the transform matrix relative to pose_bone's current
transform space. In other words, presuming that mat is in transform space. In other words, presuming that mat is in
armature space, slapping the returned matrix onto pose_bone armature space, slapping the returned matrix onto pose_bone
should give it the armature-space transforms of mat. 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): def set_pose_translation(pose_bone, mat):
""" Sets the pose bone's translation to the same translation as the given matrix. """Sets the pose bone's translation to the same translation as the given matrix.
Matrix should be given in bone's local space. Matrix should be given in bone's local space.
""" """
pose_bone.location = mat.to_translation() pose_bone.location = mat.to_translation()
def match_pole_target(ik_first, ik_last, pole, match_bone): def match_pole_target(ik_first, ik_last, pole, match_bone):
""" Places an IK chain's pole target to match ik_first's """Places an IK chain's pole target to match ik_first's
transforms to match_bone. All bones should be given as pose bones. transforms to match_bone. All bones should be given as pose bones.
You need to be in pose mode on the relevant armature object. You need to be in pose mode on the relevant armature object.
ik_first: first bone in the IK chain ik_first: first bone in the IK chain
ik_last: last bone in the IK chain ik_last: last bone in the IK chain
pole: pole target bone for 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) 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 length: distance pole target should be placed from the chain center
""" """
a = ik_first.matrix.to_translation() a = ik_first.matrix.to_translation()
b = ik_last.matrix.to_translation() + ik_last.vector 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 pv = perpendicular_vector(ikv).normalized() * length
def set_pole(pvi): def set_pole(pvi):
""" Set pole target's position based on a vector """Set pole target's position based on a vector
from the arm center line. from the arm center line.
""" """
# Translate pvi into armature space # Translate pvi into armature space
ploc = a + (ikv/2) + pvi ploc = a + (ikv / 2) + pvi
# Set pole target to location # Set pole target to location
mat = get_pose_matrix_in_other_space(Matrix.Translation(ploc), pole) 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) match_pole_target(ik_first, self.ik_last, ik_pole, fk_first)
return ik_pole.matrix return ik_pole.matrix
def map_single_frame_to_bone_matrices( def map_single_frame_to_bone_matrices(
self, context, frame_number, bones_to_snap, snap_to_bones 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}") bone_column.label(text=f"{' '*10} {self.ik_pole}")
####################################### #######################################
######## Convenience Operators ######## ######## Convenience Operators ########
####################################### #######################################
@ -945,6 +943,10 @@ class CLOUDRIG_PT_settings(CLOUDRIG_PT_base):
if rig.cloudrig.ui_edit_mode: if rig.cloudrig.ui_edit_mode:
if hasattr(bpy.ops.pose, 'cloudrig_add_property_to_ui'): if hasattr(bpy.ops.pose, 'cloudrig_add_property_to_ui'):
layout.operator('pose.cloudrig_add_property_to_ui', icon='ADD') 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: if ui_data:
for panel_name, panel_data in ui_data.items(): 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 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 ########### ########### Rig Preferences ###########
####################################### #######################################
@ -1615,7 +2018,6 @@ class CloudRigBoneCollection(PropertyGroup):
if not component.active_bone_set: if not component.active_bone_set:
component.bone_sets_active_index = 0 component.bone_sets_active_index = 0
# Metarig: Update bone sets with this collection assigned to refer to the new name. # Metarig: Update bone sets with this collection assigned to refer to the new name.
if is_active_cloud_metarig(context): if is_active_cloud_metarig(context):
rig = context.pose_object or context.active_object rig = context.pose_object or context.active_object
@ -2652,7 +3054,9 @@ class POSE_OT_cloudrig_collection_clipboard_copy(CloudRigOperator):
counter += 1 counter += 1
json_obj[coll.name]['bone_names'] = [bone.name for bone in coll.bones] 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]['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: if counter == 0:
self.report({'ERROR'}, "No visible collections to copy.") self.report({'ERROR'}, "No visible collections to copy.")
@ -2971,10 +3375,12 @@ def register_hotkey(
####################################### #######################################
classes = ( classes = (
CloudRig_UIElement,
CloudRig_RigPreferences, CloudRig_RigPreferences,
CloudRigBoneCollection, CloudRigBoneCollection,
CLOUDRIG_UL_collections, CLOUDRIG_UL_collections,
CLOUDRIG_PT_settings, CLOUDRIG_PT_settings,
CLOUDRIG_PT_custom_ui,
CLOUDRIG_PT_hotkeys_panel, CLOUDRIG_PT_hotkeys_panel,
CLOUDRIG_PT_collections_sidebar, CLOUDRIG_PT_collections_sidebar,
CLOUDRIG_PT_collections_filter, CLOUDRIG_PT_collections_filter,
@ -3036,13 +3442,12 @@ def register():
for c in classes: for c in classes:
if not is_registered(c): 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) register_class(c)
bpy.types.Object.cloudrig_prefs = PointerProperty( bpy.types.Object.cloudrig_prefs = PointerProperty(
type=CloudRig_RigPreferences, override={'LIBRARY_OVERRIDABLE'} type=CloudRig_RigPreferences, override={'LIBRARY_OVERRIDABLE'}
) )
bpy.types.Object.cloudrig_ui = CollectionProperty(type=CloudRig_UIElement)
bpy.types.BoneCollection.cloudrig_info = PointerProperty( bpy.types.BoneCollection.cloudrig_info = PointerProperty(
type=CloudRigBoneCollection, override={'LIBRARY_OVERRIDABLE'} type=CloudRigBoneCollection, override={'LIBRARY_OVERRIDABLE'}

View File

@ -9,6 +9,7 @@ from . import (
object, object,
parenting, parenting,
properties_ui, properties_ui,
properties_ui_editor,
) )
@ -23,6 +24,7 @@ modules = [
parenting, parenting,
properties_ui, properties_ui,
component_params_ui, component_params_ui,
properties_ui_editor,
] ]
# Dictionary of modules that have a Params class, and want to register # Dictionary of modules that have a Params class, and want to register

View File

@ -13,7 +13,7 @@ from bpy.types import (
Modifier, Modifier,
) )
from typing import Any from typing import Any
from bpy.props import StringProperty, BoolProperty, CollectionProperty from bpy.props import StringProperty, BoolProperty, CollectionProperty, IntProperty
from collections import OrderedDict from collections import OrderedDict
from ..generation.cloudrig import ( from ..generation.cloudrig import (
unquote_custom_prop_name, unquote_custom_prop_name,
@ -1203,6 +1203,7 @@ def redraw_viewport():
class UIPathProperty(PropertyGroup): class UIPathProperty(PropertyGroup):
name: StringProperty() name: StringProperty()
index: IntProperty()
ui_path: StringProperty() ui_path: StringProperty()
current: StringProperty(description="Current value of this property. Used for pre-filling the Parent Values field") current: StringProperty(description="Current value of this property. Used for pre-filling the Parent Values field")

View File

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