# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors # # SPDX-License-Identifier: GPL-3.0-or-later from typing import Dict, Optional import bpy from bpy.props import (FloatProperty, PointerProperty, StringProperty, CollectionProperty, EnumProperty, IntProperty) from uuid import uuid4 # Easy Constraints # This will be a panel in the Sidebar that lets animators easily constrain # something to another thing. # For example, by creating separate Copy Loc/Rot/Scale constraints, and # displaying them as one entity, with a checkbox for each behaviour, # which is actually just the checkbox of the constraints themselves. # We could maybe even use Child Of constraints, as long as the inverse matrix # is stored separately in the add-on, so that it doesn't get lost when removing # the constraint. con_icon = { 'COPY_LOCATION': 'CON_LOCLIKE', 'COPY_ROTATION': 'CON_ROTLIKE', 'COPY_SCALE': 'CON_SIZELIKE', 'COPY_TRANSFORMS': 'CON_TRANSLIKE', 'CHILD_OF': 'CON_CHILDOF', } def get_icon_value(icon_name: str) -> int: icon_items = bpy.types.UILayout.bl_rna.functions["prop"].parameters["icon"].enum_items.items() icon_dict = {tup[1].identifier : tup[1].value for tup in icon_items} return icon_dict[icon_name] def draw_with_icon_fix(layout, prop_owner, prop_name, icon: str, offset=1, invert_checkbox=False, **kwargs): """Drawing some booleans in the UI with a custom icon can be annoying because Blender might offset the icon based on the boolean state. You can use this function to counter that offset. To find the offset, you have to trial and error, it's either 1 or -1. (Or 0 but then you don't need this) """ bool_value = getattr(prop_owner, prop_name) offset = offset * int(bool_value) if invert_checkbox: offset = 1 - offset icon_value = get_icon_value(icon) + offset layout.prop(prop_owner, prop_name, icon_value=icon_value, invert_checkbox=invert_checkbox, **kwargs) class EasyConstraint(bpy.types.PropertyGroup): con_type: EnumProperty( name="Constraint Type", description="Type of constraint being managed", items=[ ('COPY_TRANSFORMS', 'Copy Transforms', 'Copy Transforms', 'CON_TRANSLIKE', 0), ('CHILD_OF', 'Child Of', 'Child Of', 'CON_CHILDOF', 1), ], override={'LIBRARY_OVERRIDABLE'} ) name: StringProperty( name="UUID", description="Unique identifier to match constraints to this EasyConstraint instance", override={'LIBRARY_OVERRIDABLE'} ) # influence: FloatProperty( # name = "Influence", # description = "Influence of the constraints", # default = 1.0, # min = 0.0, # max = 1.0, # override={'LIBRARY_OVERRIDABLE'} # ) owner_bone: StringProperty( name="Owner Bone", description="Bone that this EasyConstraint is on. For internal use only. This should never change after initialization", override={'LIBRARY_OVERRIDABLE'} ) @property def pose_bone(self) -> Optional[bpy.types.PoseBone]: """Return the associated PoseBone. Won't work if the bone was renamed; This cannot be supported. """ armature = self.id_data pb = armature.pose.bones.get(self.owner_bone) assert pb, "PoseBone not found for EasyConstraint: " + self.owner_bone return pb @property def influence(self): return self.pose_bone[f'EC_influence_{self.name}'] @influence.setter def influence(self, value): self.pose_bone[f'EC_influence_{self.name}'] = value def get_constraints(self) -> Dict[str, bpy.types.Constraint]: constraints = { 'COPY_LOCATION': None, 'COPY_ROTATION': None, 'COPY_SCALE': None } for c in self.pose_bone.constraints: if self.name in c.name: if c.type in constraints.keys(): constraints[c.type] = c return constraints def update_target(self, context): for con_type, con in self.get_constraints().items(): if not con: con = self.pose_bone.constraints.new(type=con_type) con.name += " " + self.name con.driver_remove('influence') d = con.driver_add('influence').driver d.expression = "var" var = d.variables.new() var.type = 'SINGLE_PROP' var.targets[0].id = context.object # var.targets[0].data_path = f'pose.bones["{self.owner_bone}"].easy_constraints["{self.name}"].influence' var.targets[0].data_path = f'pose.bones["{self.owner_bone}"]["EC_influence_{self.name}"]' con.target = self.target if hasattr(con, 'subtarget'): con.subtarget = self.subtarget target: PointerProperty( type=bpy.types.Object, name="Object", description="Object to copy the transforms of", update=update_target, override={'LIBRARY_OVERRIDABLE'} ) subtarget: StringProperty( name="Target Bone", description="Bone to copy the transforms of", update=update_target, override={'LIBRARY_OVERRIDABLE'} ) class POSE_OT_easyconstraint_add(bpy.types.Operator): """Stick this bone to another""" bl_idname = "pose.easy_constraint_add" bl_label = "Add Easy Constraint" bl_options = {'REGISTER', 'UNDO'} @classmethod def poll(cls, context): return context.mode == 'POSE' and context.active_pose_bone def execute(self, context): active_pb = context.active_pose_bone easycon = active_pb.easy_constraints.add() easycon.owner_bone = context.active_pose_bone.name easycon.name = str(uuid4())[:8] # Custom Property to work around T48975. (TODO: Remove when fixed) prop_name = f"EC_influence_{easycon.name}" context.active_pose_bone[prop_name] = 1.0 prop_data = context.active_pose_bone.id_properties_ui(prop_name) prop_data.update(min=0, max=1) context.active_pose_bone.property_overridable_library_set( f'["{prop_name}"]', True) easycon.type = 'COPY_TRANSFORMS' if len(context.selected_pose_bones) == 2: easycon.target = context.object for pb in context.selected_pose_bones: if pb == active_pb: continue easycon.subtarget = pb.name return {'FINISHED'} class POSE_OT_easyconstraint_remove(bpy.types.Operator): """Remove EasyConstraint set-up""" bl_idname = "pose.easy_constraint_remove" bl_label = "Remove Easy Constraint" bl_options = {'REGISTER', 'UNDO'} @classmethod def poll(cls, context): return context.mode == 'POSE' and context.active_pose_bone \ and len(context.active_pose_bone.easy_constraints) > 0 def execute(self, context): active_pb = context.active_pose_bone active_ec = active_pb.easy_constraints[active_pb.easy_constraints_active_index] # Remove constraints and constraint drivers for _con_type, con in active_ec.get_constraints().items(): if con: con.driver_remove('influence') active_ec.pose_bone.constraints.remove(con) # Remove influence keyframes from currently active Action if context.object.animation_data and context.object.animation_data.action: data_path = f'pose.bones["{active_ec.pose_bone.name}"]["EC_influence_{active_ec.name}"]' action = context.object.animation_data.action fc = action.fcurves.find(data_path) if not fc: print("Couldn't find fcurve ", data_path) else: action.fcurves.remove(fc) prop_name = f"EC_influence_{active_ec.name}" if prop_name in active_pb.keys(): del active_pb[prop_name] active_pb.easy_constraints.remove( active_pb.easy_constraints_active_index) active_pb.easy_constraints_active_index = max( 0, active_pb.easy_constraints_active_index-1) return {'FINISHED'} class POSE_OT_easyconstraint_kill_influence(bpy.types.Operator): """Set the influence to 0 while preserving the pose and inserting a keyframe. Only one bone must be selected""" bl_idname = "pose.easy_constraint_influence_zero" bl_label = "Zero Influence" bl_options = {'REGISTER', 'UNDO'} ec_name: StringProperty() @classmethod def poll(cls, context): # Since we're using the context-based bpy.ops.anim.keyframe_insert_menu(), # we want to only allow this operator when there are no other bones selected. return context.mode == 'POSE' and len(context.selected_pose_bones) == 1 def execute(self, context): active_pb = context.active_pose_bone ec = active_pb.easy_constraints[self.ec_name] matrix = active_pb.matrix.copy() ec.influence = 0.0 active_pb.matrix = matrix if context.scene.tool_settings.use_keyframe_insert_auto: keying_set = "LocRotScaleCProp" if context.scene.keying_sets.active: keying_set = context.scene.keying_sets.active.bl_idname bpy.ops.anim.keyframe_insert_menu(type=keying_set) return {'FINISHED'} class EC_UL_constraint_list(bpy.types.UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname): if self.layout_type != 'DEFAULT': return easy_constraint = item row = layout.row(align=True) icon = con_icon[easy_constraint.con_type] row.label(text="", icon=icon) if not easy_constraint.target: row.prop(easy_constraint, 'target', text=" Target") return else: row.prop_search(easy_constraint, 'subtarget', easy_constraint.target.data, 'bones', text="") for _con_type, con in easy_constraint.get_constraints().items(): if not con: continue icon = con_icon[con.type] draw_with_icon_fix(row, con, 'mute', icon=icon, offset=1, invert_checkbox=True, text="") row.prop(context.active_pose_bone, f'["EC_influence_{easy_constraint.name}"]', text="", slider=True) row.operator(POSE_OT_easyconstraint_kill_influence.bl_idname, text="", icon='CANCEL').ec_name = easy_constraint.name def draw_filter(self, context, layout): pass class EC_PT_constraints(bpy.types.Panel): bl_space_type = 'VIEW_3D' bl_region_type = 'UI' bl_category = 'Animation' bl_label = 'Easy Constraints' # bl_options = {'DEFAULT_CLOSED'} @classmethod def poll(cls, context): return context.mode == 'POSE' and context.active_pose_bone def draw(self, context): layout = self.layout pb = context.active_pose_bone row = layout.row() row.template_list( 'EC_UL_constraint_list', '', pb, 'easy_constraints', pb, 'easy_constraints_active_index', ) op_col = row.column() op_col.operator(POSE_OT_easyconstraint_add.bl_idname, text="", icon='ADD') op_col.operator(POSE_OT_easyconstraint_remove.bl_idname, text="", icon='REMOVE') registry = [ EasyConstraint, POSE_OT_easyconstraint_add, POSE_OT_easyconstraint_remove, POSE_OT_easyconstraint_kill_influence, EC_UL_constraint_list, EC_PT_constraints, ] def register(): bpy.types.PoseBone.easy_constraints = CollectionProperty( type=EasyConstraint, options={'LIBRARY_EDITABLE'}, override={'LIBRARY_OVERRIDABLE', 'USE_INSERTION'}, ) bpy.types.PoseBone.easy_constraints_active_index = IntProperty( name = "Easy Constraints", description = "Manage separate Copy Loc/Rot/Scale constraints in a compact UI", options={'LIBRARY_EDITABLE'}, override={'LIBRARY_OVERRIDABLE'}, ) def unregister(): del bpy.types.PoseBone.easy_constraints del bpy.types.PoseBone.easy_constraints_active_index