Anim: Pose Slide operators for objects

This implements the pose slide operators for objects.
It extends the `AnimTransformable` API to support objects,
which makes most of the pose slide code work right away.

The auto keying code had to be adjusted because it used
the "Whole Character" keying set which doesn't work for
objects.

Discussed in an animation & rigging module meeting [1]

Part of #156579

[1] https://devtalk.blender.org/t/2026-05-28-animation-rigging-module-meeting/45331#p-162762-ongoing-work-5

Pull Request: https://projects.blender.org/blender/blender/pulls/158906
This commit is contained in:
Christoph Lendenfeld 2026-06-02 10:34:38 +02:00 • committed by Christoph Lendenfeld
parent 02cb21e4f0
commit d00c0d81e0
9 changed files with 206 additions and 60 deletions

View file

@ -2880,6 +2880,10 @@ class VIEW3D_MT_object_animation(Menu):
def draw(self, _context):
layout = self.layout
layout.menu("VIEW3D_MT_pose_slide")
layout.separator()
layout.operator("anim.keyframe_insert", text="Insert Keyframe")
layout.operator("anim.keyframe_insert_menu", text="Insert Keyframe with Keying Set...").always_prompt = True
layout.operator("anim.keyframe_delete_v3d", text="Delete Keyframes...")
@ -4225,10 +4229,6 @@ class VIEW3D_MT_pose(Menu):
layout.separator()
layout.menu("VIEW3D_MT_object_animation")
layout.separator()
layout.menu("VIEW3D_MT_pose_slide")
layout.menu("VIEW3D_MT_pose_propagate")
layout.separator()

View file

@ -248,6 +248,18 @@ AnimTransformable::AnimTransformable(Object &owner_id, bPoseChannel &pchan)
rna_path_from_id_ = animrig::get_pose_bone_rna_path(pchan);
}
AnimTransformable::AnimTransformable(Object &obj)
: type_(AnimTransformable::Type::OBJECT),
owner_id_(&obj.id),
data_(&obj),
location_({obj.loc, 3}),
rotation_mode_(reinterpret_cast<eRotationModes *>(&obj.rotmode)),
scale_({obj.scale, 3})
{
build_rotations_array(rotations_, obj.rot, obj.quat, obj.rotAxis, &obj.rotAngle);
rna_path_from_id_ = "";
}
template<> bPoseChannel *AnimTransformable::data<bPoseChannel *>() const
{
BLI_assert(type_ == Type::POSE_BONE);

View file

@ -212,8 +212,7 @@ void slide_subjects_reset(ListBaseT<SlideSubject> *slide_subjects);
/** Perform auto-key-framing after changes were made + confirmed. */
void slide_subjects_autokey(bContext *C,
Scene *scene,
const ListBaseT<SlideSubject> *slide_subjects,
float cframe);
const ListBaseT<SlideSubject> *slide_subjects);
/** \} */

View file

@ -75,6 +75,29 @@
namespace blender {
static bool pose_slide_poll(bContext *C)
{
Object *obact = CTX_data_active_object(C);
if (!obact) {
return false;
}
const eContextObjectMode mode = CTX_data_mode_enum(C);
if (mode == CTX_MODE_OBJECT) {
return true;
}
if (!(obact->mode & OB_MODE_EDIT)) {
Object *obpose = BKE_object_pose_armature_get(obact);
if (obpose != nullptr) {
if ((obact == obpose) || (obact->mode & OB_MODE_ALL_WEIGHT_PAINT)) {
return true;
}
}
}
return false;
}
/* **************************************************** */
/* A) Push & Relax, Breakdowner */
@ -685,7 +708,7 @@ static void pose_slide_apply(bContext *C, tPoseSlideOp *pso)
static void pose_slide_autoKeyframe(bContext *C, tPoseSlideOp *pso)
{
/* Wrapper around the generic call. */
slide_subjects_autokey(C, pso->scene, &pso->slide_subjects, float(pso->current_frame));
slide_subjects_autokey(C, pso->scene, &pso->slide_subjects);
}
/**
@ -1247,7 +1270,7 @@ void POSE_OT_push(wmOperatorType *ot)
ot->invoke = pose_slide_push_invoke;
ot->modal = pose_slide_modal;
ot->cancel = pose_slide_cancel;
ot->poll = ED_operator_posemode;
ot->poll = pose_slide_poll;
/* flags */
ot->flag = OPTYPE_REGISTER | OPTYPE_UNDO | OPTYPE_BLOCKING | OPTYPE_GRAB_CURSOR_X;
@ -1304,7 +1327,7 @@ void POSE_OT_relax(wmOperatorType *ot)
ot->invoke = pose_slide_relax_invoke;
ot->modal = pose_slide_modal;
ot->cancel = pose_slide_cancel;
ot->poll = ED_operator_posemode;
ot->poll = pose_slide_poll;
/* flags */
ot->flag = OPTYPE_REGISTER | OPTYPE_UNDO | OPTYPE_BLOCKING | OPTYPE_GRAB_CURSOR_X;
@ -1366,7 +1389,7 @@ void POSE_OT_blend_with_rest(wmOperatorType *ot)
ot->invoke = pose_slide_blend_rest_invoke;
ot->modal = pose_slide_modal;
ot->cancel = pose_slide_cancel;
ot->poll = ED_operator_posemode;
ot->poll = pose_slide_poll;
/* flags */
ot->flag = OPTYPE_REGISTER | OPTYPE_UNDO | OPTYPE_BLOCKING | OPTYPE_GRAB_CURSOR_X;
@ -1425,7 +1448,7 @@ void POSE_OT_breakdown(wmOperatorType *ot)
ot->invoke = pose_slide_breakdown_invoke;
ot->modal = pose_slide_modal;
ot->cancel = pose_slide_cancel;
ot->poll = ED_operator_posemode;
ot->poll = pose_slide_poll;
/* flags */
ot->flag = OPTYPE_REGISTER | OPTYPE_UNDO | OPTYPE_BLOCKING | OPTYPE_GRAB_CURSOR_X;
@ -1477,7 +1500,7 @@ void POSE_OT_blend_to_neighbors(wmOperatorType *ot)
ot->invoke = pose_slide_blend_to_neighbors_invoke;
ot->modal = pose_slide_modal;
ot->cancel = pose_slide_cancel;
ot->poll = ED_operator_posemode;
ot->poll = pose_slide_poll;
/* Flags. */
ot->flag = OPTYPE_REGISTER | OPTYPE_UNDO | OPTYPE_BLOCKING | OPTYPE_GRAB_CURSOR_X;

View file

@ -39,6 +39,7 @@
#include "ED_anim_transformable.hh"
#include "ED_armature.hh"
#include "ED_keyframing.hh"
#include "ED_object.hh"
#include "ANIM_action.hh"
#include "ANIM_action_iterators.hh"
@ -174,6 +175,44 @@ static void store_property_snapshot(PointerRNA &ptr,
snapshots.append({prop, std::move(property_values)});
}
static void store_starting_transform(SlideSubject &slide_subject,
ed::AnimTransformable &transformable)
{
slide_subject.old_loc = transformable.get_property(
ed::AnimTransformable::PropertyType::LOCATION);
slide_subject.old_rot = transformable.get_rotation();
slide_subject.old_scale = transformable.get_property(ed::AnimTransformable::PropertyType::SCALE);
}
/**
* `id_properties` and `system_properties` can be a nullptr and are skipped if they are.
*/
static void store_id_properties(SlideSubject &slide_subject,
PointerRNA &ptr,
IDProperty *id_properties,
IDProperty *system_properties)
{
if (id_properties) {
for (const IDProperty &id_prop : id_properties->data.group) {
if (ELEM(id_prop.type, IDP_STRING, IDP_ID, IDP_IDPARRAY)) {
continue;
}
char name_escaped[MAX_IDPROP_NAME * 2];
BLI_str_escape(name_escaped, id_prop.name, sizeof(name_escaped));
std::string property_name_with_brackets = fmt::format("[\"{}\"]", name_escaped);
store_property_snapshot(ptr, property_name_with_brackets, slide_subject.properties);
}
}
if (system_properties) {
for (const IDProperty &id_prop : system_properties->data.group) {
if (ELEM(id_prop.type, IDP_STRING, IDP_ID, IDP_IDPARRAY)) {
continue;
}
store_property_snapshot(ptr, id_prop.name, slide_subject.system_properties);
}
}
}
/* helper for slide_subjects_get() -> get the relevant F-Curves per PoseChannel */
static void pchan_to_slide_subject(ListBaseT<SlideSubject> &slide_subjects,
Object &ob,
@ -199,11 +238,7 @@ static void pchan_to_slide_subject(ListBaseT<SlideSubject> &slide_subjects,
/* Set pchan's transform flags. */
slide_subject->transform_flag = transFlags;
slide_subject->old_loc = transformable->get_property(
ed::AnimTransformable::PropertyType::LOCATION);
slide_subject->old_rot = transformable->get_rotation();
slide_subject->old_scale = transformable->get_property(
ed::AnimTransformable::PropertyType::SCALE);
store_starting_transform(*slide_subject, *transformable);
slide_subject->ptr = bone_ptr;
@ -222,25 +257,7 @@ static void pchan_to_slide_subject(ListBaseT<SlideSubject> &slide_subjects,
/* Make copy of custom properties. */
if (transFlags & ACT_TRANS_PROP) {
if (pchan.prop) {
for (const IDProperty &id_prop : pchan.prop->data.group) {
if (ELEM(id_prop.type, IDP_STRING, IDP_ID, IDP_IDPARRAY)) {
continue;
}
char name_escaped[MAX_IDPROP_NAME * 2];
BLI_str_escape(name_escaped, id_prop.name, sizeof(name_escaped));
std::string property_name_with_brackets = fmt::format("[\"{}\"]", name_escaped);
store_property_snapshot(bone_ptr, property_name_with_brackets, slide_subject->properties);
}
}
if (pchan.system_properties) {
for (const IDProperty &id_prop : pchan.system_properties->data.group) {
if (ELEM(id_prop.type, IDP_STRING, IDP_ID, IDP_IDPARRAY)) {
continue;
}
store_property_snapshot(bone_ptr, id_prop.name, slide_subject->system_properties);
}
}
store_id_properties(*slide_subject, bone_ptr, pchan.prop, pchan.system_properties);
}
}
@ -307,6 +324,38 @@ static void get_pose_bones_for_slide(bContext *C, ListBaseT<SlideSubject> &slide
}
}
static void get_objects_for_slide(bContext *C, ListBaseT<SlideSubject> &slider_data)
{
CTX_DATA_BEGIN (C, Object *, ob, selected_objects) {
PointerRNA object_ptr = RNA_pointer_create_discrete(&ob->id, RNA_Object, ob);
Vector<FCurve *> curves;
const eAction_TransformFlags transFlags = get_item_transform_flags_and_fcurves(
ob->id, object_ptr, curves);
if (!transFlags) {
continue;
}
SlideSubject *slide_subject = MEM_new<SlideSubject>("TransformableFCurveLink");
BLI_addtail(&slider_data, slide_subject);
slide_subject->fcurves = curves;
ed::AnimTransformable *transformable = MEM_new<ed::AnimTransformable>("transformable_object",
*ob);
slide_subject->transformable = transformable;
slide_subject->transform_flag = transFlags;
store_starting_transform(*slide_subject, *transformable);
slide_subject->ptr = object_ptr;
if (transFlags & ACT_TRANS_PROP) {
store_id_properties(*slide_subject, object_ptr, ob->id.properties, ob->id.system_properties);
}
}
CTX_DATA_END;
}
void slide_subjects_get(bContext *C, ListBaseT<SlideSubject> *r_transformable_list)
{
BLI_assert(r_transformable_list != nullptr);
@ -315,6 +364,9 @@ void slide_subjects_get(bContext *C, ListBaseT<SlideSubject> *r_transformable_li
case CTX_MODE_POSE:
get_pose_bones_for_slide(C, *r_transformable_list);
break;
case CTX_MODE_OBJECT:
get_objects_for_slide(C, *r_transformable_list);
break;
default:
/* Not implemented. */
@ -347,6 +399,7 @@ void slide_subjects_free(ListBaseT<SlideSubject> *slide_subjects)
void slide_subjects_refresh(bContext *C, ID *id)
{
DEG_id_tag_update(id, ID_RECALC_GEOMETRY);
DEG_id_tag_update(id, ID_RECALC_TRANSFORM);
switch (GS(id->name)) {
case ID_OB:
WM_event_add_notifier(C, NC_OBJECT | ND_POSE, id_cast<Object *>(id));
@ -395,39 +448,85 @@ void slide_subjects_reset(ListBaseT<SlideSubject> *slide_subjects)
void slide_subjects_autokey(bContext *C,
Scene *scene,
const ListBaseT<SlideSubject> *slide_subjects,
const float cframe)
const ListBaseT<SlideSubject> *slide_subjects)
{
/* Insert keyframes as necessary if auto-key-framing.
* TODO: don't use a keyingset here. Just use the keyframing code directly. */
KeyingSet *ks = animrig::get_keyingset_for_autokeying(scene, ANIM_KS_WHOLE_CHARACTER_ID);
Vector<PointerRNA> sources;
bool anything_to_key = false;
for (SlideSubject &slide_subject : *slide_subjects) {
if (!animrig::autokeyframe_cfra_can_key(scene, slide_subject.ptr.owner_id)) {
continue;
}
anything_to_key = true;
break;
}
/* If there is nothing to key, return before deselecting any keys. */
if (!anything_to_key) {
return;
}
ANIM_deselect_keys_in_animation_editors(C);
/* Insert keyframes as necessary if auto-key-framing. */
for (SlideSubject &slide_subject : *slide_subjects) {
PointerRNA &ptr = slide_subject.ptr;
if (!animrig::autokeyframe_cfra_can_key(scene, slide_subject.ptr.owner_id)) {
continue;
}
animrig::relative_keyingset_add_source(sources, ptr.owner_id, ptr.type, ptr.data);
Vector<RNAPath> paths;
/* The transform flags tell us which properties have keys. Properties without keys cannot pose
* slide, so should not be auto keyed. */
if (slide_subject.transform_flag & ACT_TRANS_LOC) {
paths.append({"location"});
}
if (slide_subject.transform_flag & ACT_TRANS_ROT) {
paths.append(
{animrig::get_rotation_mode_path(slide_subject.transformable->get_rotation_mode())});
}
if (slide_subject.transform_flag & ACT_TRANS_SCALE) {
paths.append({"scale"});
}
/* No need to check the transform_flag here, because those vectors are only filled if the flag
* was set in the first place.*/
for (const PropertySnapshot &snapshot : slide_subject.additional_properties) {
paths.append({RNA_property_identifier(snapshot.property)});
}
for (const PropertySnapshot &snapshot : slide_subject.properties) {
char name_escaped[MAX_IDPROP_NAME * 2];
BLI_str_escape(
name_escaped, RNA_property_identifier(snapshot.property), sizeof(name_escaped));
paths.append({fmt::format("[\"{}\"]", name_escaped)});
}
for (const PropertySnapshot &snapshot : slide_subject.system_properties) {
paths.append({RNA_property_identifier(snapshot.property)});
}
switch (slide_subject.transformable->type()) {
case ed::AnimTransformable::Type::POSE_BONE:
animrig::autokeyframe_pose_channel(C,
scene,
id_cast<Object *>(ptr.owner_id),
static_cast<bPoseChannel *>(ptr.data),
paths,
false);
break;
case ed::AnimTransformable::Type::OBJECT:
animrig::autokeyframe_object(C, scene, id_cast<Object *>(ptr.owner_id), paths);
break;
}
}
/* insert keyframes for all relevant bones in one go */
animrig::apply_keyingset(C, &sources, ks, animrig::ModifyKeyMode::INSERT, cframe);
Vector<Object *> objects;
for (SlideSubject &slide_subject : *slide_subjects) {
ID *owner_id = slide_subject.transformable->owner_id();
if (GS(owner_id->name) != ID_OB) {
continue;
}
Object *ob = id_cast<Object *>(owner_id);
if (!ob->pose) {
continue;
}
if (ob->pose->avs.path_bakeflag & MOTIONPATH_BAKE_HAS_PATHS) {
/* TODO(sergey): Should ensure we can use more narrow update range here. */
ED_pose_recalculate_paths(C, scene, ob, ANIMVIZ_CALC_RANGE_FULL);
}
objects.append(id_cast<Object *>(owner_id));
}
/* This includes all motion paths for bones. Could be more fine grained in the future to avoid
* needless updates to data that was not changed. */
ed::object::motion_paths_recalc(C, scene, ANIMVIZ_CALC_RANGE_CHANGED, objects);
}
/* *********************************************** */

View file

@ -104,7 +104,8 @@ class AnimTransformable {
/* There has to be a constructor for every struct supported. */
/* Constructor for pose bones. */
AnimTransformable(Object &owner_id, bPoseChannel &pchan);
/* TODO (christoph): Add object support. */
/* Constructor for Objects. */
explicit AnimTransformable(Object &object);
Type type() const
{

View file

@ -383,6 +383,14 @@ void single_obdata_user_make(Main *bmain, Scene *scene, Object *ob);
*/
void motion_paths_clear(bContext *C, bool only_selected);
/**
* Recalculate the motion paths on the given objects. This includes bones when recalculating
* armature objects.
*/
void motion_paths_recalc(bContext *C,
Scene *scene,
const eAnimvizCalcRange range,
const Span<Object *> objects);
/**
* Recalculate motion paths on all selected objects. This includes bones when recalculating
* armature objects.

View file

@ -1281,10 +1281,10 @@ static bool has_pose_motion_paths(Object *ob)
return ob->pose && (ob->pose->avs.path_bakeflag & MOTIONPATH_BAKE_HAS_PATHS) != 0;
}
static void motion_paths_recalc(bContext *C,
Scene *scene,
const eAnimvizCalcRange range,
const Span<Object *> objects)
void motion_paths_recalc(bContext *C,
Scene *scene,
const eAnimvizCalcRange range,
const Span<Object *> objects)
{
BLI_assert(C != nullptr);
Main *bmain = CTX_data_main(C);

View file

@ -206,10 +206,12 @@ class BreakdownerTestPoseBone(AbstractPoseSlideTest):
def setUp(self) -> None:
super().setUp()
bpy.context.preferences.edit.use_keyframe_insert_available = False
bpy.context.preferences.edit.use_auto_keyframe_insert_needed = False
def tearDown(self) -> None:
super().tearDown()
bpy.context.preferences.edit.use_keyframe_insert_available = True
bpy.context.preferences.edit.use_auto_keyframe_insert_needed = True
def test_no_keys(self):
# The case of no keys will produce no interpolation.
@ -250,7 +252,9 @@ class BreakdownerTestPoseBone(AbstractPoseSlideTest):
self.assertAlmostEqual(self.pose_bone.location[i], 1, 3)
# The key count depends on the setting "Only insert available" in the user preferences.
self.assertEqual(len(channelbag.fcurves), 10)
# Also keys are only inserted into channels that already have keys.
self.assertEqual(len(channelbag.fcurves), 3)
# This depends on the setting "Only insert needed".
self.assertEqual(len(channelbag.fcurves[0].keyframe_points), 2)
def test_all_properties(self):