344 lines
12 KiB
Python
344 lines
12 KiB
Python
# 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
|