2025-12-01
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user