diff --git a/source/blender/animrig/ANIM_action.hh b/source/blender/animrig/ANIM_action.hh index 61c0a35ec6f..455d7f32739 100644 --- a/source/blender/animrig/ANIM_action.hh +++ b/source/blender/animrig/ANIM_action.hh @@ -892,6 +892,14 @@ FCurve *action_fcurve_ensure(Main *bmain, */ FCurve *action_fcurve_find(bAction *act, FCurveDescriptor fcurve_descriptor); +/** + * Remove the given FCurve from the action by searching for it in all channelbags. + * This assumes that an FCurve can only exist in an action once. + * + * \returns true if the given FCurve was removed. + */ +bool action_fcurve_remove(Action &action, FCurve &fcu); + /** * Find an appropriate user of the given Action + Slot for keyframing purposes. * diff --git a/source/blender/animrig/ANIM_action_iterators.hh b/source/blender/animrig/ANIM_action_iterators.hh new file mode 100644 index 00000000000..2a3424f4af3 --- /dev/null +++ b/source/blender/animrig/ANIM_action_iterators.hh @@ -0,0 +1,40 @@ +/* SPDX-FileCopyrightText: 2024 Blender Authors + * + * SPDX-License-Identifier: GPL-2.0-or-later */ + +/** \file + * \ingroup animrig + * + * \brief Functionality to iterate an Action in various ways. + */ + +#pragma once + +#include + +#include "BLI_vector.hh" +#include "DNA_action_types.h" + +struct FCurve; +namespace blender::animrig { +class Action; +class Layer; +class Strip; +class ChannelBag; +} // namespace blender::animrig + +namespace blender::animrig { + +using slot_handle_t = decltype(::ActionSlot::handle); + +/** + * Iterates over all FCurves of the given slot handle in the Action and executes the callback on + * it. Only works on layered Actions. + * + * \note Use lambdas to have access to specific data in the callback. + */ +void action_foreach_fcurve(Action &action, + slot_handle_t handle, + FunctionRef callback); + +} // namespace blender::animrig diff --git a/source/blender/animrig/ANIM_fcurve.hh b/source/blender/animrig/ANIM_fcurve.hh index 62976322adb..6afdfd24b09 100644 --- a/source/blender/animrig/ANIM_fcurve.hh +++ b/source/blender/animrig/ANIM_fcurve.hh @@ -71,6 +71,8 @@ void initialize_bezt(BezTriple *beztr, /** * Delete the keyframe at `time` on `fcurve` if a key exists there. * + * This does NOT delete the FCurve if it ends up empty. That is for the caller to do. + * * \note `time` is in fcurve time, not scene time. Any time remapping must be * done prior to calling this function. * diff --git a/source/blender/animrig/CMakeLists.txt b/source/blender/animrig/CMakeLists.txt index beeb22874ec..f81427616b4 100644 --- a/source/blender/animrig/CMakeLists.txt +++ b/source/blender/animrig/CMakeLists.txt @@ -24,6 +24,7 @@ set(SRC intern/action_legacy.cc intern/action_runtime.cc intern/action_selection.cc + intern/action_iterators.cc intern/anim_rna.cc intern/animdata.cc intern/bone_collections.cc @@ -37,6 +38,7 @@ set(SRC ANIM_action.hh ANIM_action_legacy.hh + ANIM_action_iterators.hh ANIM_animdata.hh ANIM_armature_iter.hh ANIM_bone_collections.hh @@ -76,6 +78,7 @@ if(WITH_GTESTS) ) set(TEST_SRC intern/action_test.cc + intern/action_iterators_test.cc intern/bone_collections_test.cc intern/evaluation_test.cc intern/keyframing_test.cc diff --git a/source/blender/animrig/intern/action.cc b/source/blender/animrig/intern/action.cc index c1c2ce4a72f..c7ebd6e34c9 100644 --- a/source/blender/animrig/intern/action.cc +++ b/source/blender/animrig/intern/action.cc @@ -1530,6 +1530,25 @@ FCurve *action_fcurve_ensure(Main *bmain, return fcu; } +bool action_fcurve_remove(Action &action, FCurve &fcu) +{ + for (Layer *layer : action.layers()) { + for (Strip *strip : layer->strips()) { + if (!(strip->type() == Strip::Type::Keyframe)) { + continue; + } + KeyframeStrip &key_strip = strip->template as(); + for (ChannelBag *bag : key_strip.channelbags()) { + const bool removed = bag->fcurve_remove(fcu); + if (removed) { + return true; + } + } + } + } + return false; +} + ID *action_slot_get_id_for_keying(Main &bmain, Action &action, const slot_handle_t slot_handle, diff --git a/source/blender/animrig/intern/action_iterators.cc b/source/blender/animrig/intern/action_iterators.cc new file mode 100644 index 00000000000..f78004bc152 --- /dev/null +++ b/source/blender/animrig/intern/action_iterators.cc @@ -0,0 +1,39 @@ +/* SPDX-FileCopyrightText: 2024 Blender Authors + * + * SPDX-License-Identifier: GPL-2.0-or-later */ + +/** \file + * \ingroup animrig + */ + +#include "ANIM_action.hh" +#include "ANIM_action_iterators.hh" +#include "BLI_assert.h" + +namespace blender::animrig { + +void action_foreach_fcurve(Action &action, + slot_handle_t handle, + FunctionRef callback) +{ + BLI_assert(action.is_action_layered()); + for (Layer *layer : action.layers()) { + for (Strip *strip : layer->strips()) { + if (!strip->is()) { + continue; + } + KeyframeStrip &key_strip = strip->as(); + for (ChannelBag *bag : key_strip.channelbags()) { + if (bag->slot_handle != handle) { + continue; + } + for (FCurve *fcu : bag->fcurves()) { + BLI_assert(fcu != nullptr); + callback(*fcu); + } + } + } + } +} + +} // namespace blender::animrig diff --git a/source/blender/animrig/intern/action_iterators_test.cc b/source/blender/animrig/intern/action_iterators_test.cc new file mode 100644 index 00000000000..9164ee7eaae --- /dev/null +++ b/source/blender/animrig/intern/action_iterators_test.cc @@ -0,0 +1,110 @@ +/* SPDX-FileCopyrightText: 2024 Blender Authors + * + * SPDX-License-Identifier: GPL-2.0-or-later */ +#include "ANIM_action.hh" +#include "ANIM_action_iterators.hh" + +#include "BKE_idtype.hh" +#include "BKE_lib_id.hh" +#include "BKE_main.hh" +#include "BKE_object.hh" + +#include "DNA_anim_types.h" +#include "DNA_object_types.h" + +#include "CLG_log.h" +#include "testing/testing.h" + +namespace blender::animrig::tests { +class ActionIteratorsTest : public testing::Test { + public: + Main *bmain; + Action *action; + + static void SetUpTestSuite() + { + /* BKE_id_free() hits a code path that uses CLOG, which crashes if not initialized properly. */ + CLG_init(); + + /* To make id_can_have_animdata() and friends work, the `id_types` array needs to be set up. */ + BKE_idtype_init(); + } + + static void TearDownTestSuite() + { + CLG_exit(); + } + + void SetUp() override + { + bmain = BKE_main_new(); + action = static_cast(BKE_id_new(bmain, ID_AC, "ACLayeredAction")); + } + + void TearDown() override + { + BKE_main_free(bmain); + } +}; + +TEST_F(ActionIteratorsTest, iterate_all_fcurves_of_slot) +{ + Slot &cube_slot = action->slot_add(); + Slot &monkey_slot = action->slot_add(); + EXPECT_TRUE(action->is_action_layered()); + + /* Try iterating an empty action. */ + blender::Vector no_fcurves; + action_foreach_fcurve( + *action, cube_slot.handle, [&](FCurve &fcurve) { no_fcurves.append(&fcurve); }); + + ASSERT_TRUE(no_fcurves.is_empty()); + + Layer &layer = action->layer_add("Layer One"); + Strip &strip = layer.strip_add(Strip::Type::Keyframe); + KeyframeStrip &key_strip = strip.as(); + const KeyframeSettings settings = get_keyframe_settings(false); + + /* Insert 3 FCurves for each slot. */ + for (int i = 0; i < 3; i++) { + SingleKeyingResult result_cube = key_strip.keyframe_insert( + bmain, cube_slot, {"location", i}, {1.0f, 0.0f}, settings); + ASSERT_EQ(SingleKeyingResult::SUCCESS, result_cube) + << "Expected keyframe insertion to be successful"; + + SingleKeyingResult result_monkey = key_strip.keyframe_insert( + bmain, monkey_slot, {"rotation", i}, {1.0f, 0.0f}, settings); + ASSERT_EQ(SingleKeyingResult::SUCCESS, result_monkey) + << "Expected keyframe insertion to be successful"; + } + + /* Get all FCurves. */ + blender::Vector cube_fcurves; + action_foreach_fcurve( + *action, cube_slot.handle, [&](FCurve &fcurve) { cube_fcurves.append(&fcurve); }); + + ASSERT_EQ(cube_fcurves.size(), 3); + for (FCurve *fcurve : cube_fcurves) { + ASSERT_STREQ(fcurve->rna_path, "location"); + } + + /* Get only FCurves with index 0 which should be 1. */ + blender::Vector monkey_fcurves; + action_foreach_fcurve(*action, monkey_slot.handle, [&](FCurve &fcurve) { + if (fcurve.array_index == 0) { + monkey_fcurves.append(&fcurve); + } + }); + + ASSERT_EQ(monkey_fcurves.size(), 1); + ASSERT_STREQ(monkey_fcurves[0]->rna_path, "rotation"); + + /* Slots handles are just numbers. Passing in a slot handle that doesn't exist should return + * nothing. */ + blender::Vector invalid_slot_fcurves; + action_foreach_fcurve(*action, monkey_slot.handle + cube_slot.handle, [&](FCurve &fcurve) { + invalid_slot_fcurves.append(&fcurve); + }); + ASSERT_TRUE(invalid_slot_fcurves.is_empty()); +} +} // namespace blender::animrig::tests diff --git a/source/blender/animrig/intern/animdata.cc b/source/blender/animrig/intern/animdata.cc index 5048092f138..f5d7f26f28e 100644 --- a/source/blender/animrig/intern/animdata.cc +++ b/source/blender/animrig/intern/animdata.cc @@ -127,7 +127,9 @@ void animdata_fcurve_delete(bAnimContext *ac, AnimData *adt, FCurve *fcu) animdata_remove_empty_action(adt); } else { - /* TODO: support deleting FCurves from layered Actions. */ + action_fcurve_remove(action, *fcu); + /* Return early to avoid the call to BKE_fcurve_free because the fcu has already been freed + * by action_fcurve_remove. */ return; } } diff --git a/source/blender/animrig/intern/fcurve.cc b/source/blender/animrig/intern/fcurve.cc index 3c4cd86783c..72da5a4beb8 100644 --- a/source/blender/animrig/intern/fcurve.cc +++ b/source/blender/animrig/intern/fcurve.cc @@ -88,6 +88,9 @@ FCurve *create_fcurve_for_channel(const FCurveDescriptor fcurve_descriptor) bool fcurve_delete_keyframe_at_time(FCurve *fcurve, const float time) { + if (BKE_fcurve_is_protected(fcurve)) { + return false; + } bool found; const int index = BKE_fcurve_bezt_binarysearch_index( diff --git a/source/blender/animrig/intern/keyframing.cc b/source/blender/animrig/intern/keyframing.cc index 08390d9ebda..2dcff26974f 100644 --- a/source/blender/animrig/intern/keyframing.cc +++ b/source/blender/animrig/intern/keyframing.cc @@ -12,6 +12,7 @@ #include #include "ANIM_action.hh" +#include "ANIM_action_iterators.hh" #include "ANIM_animdata.hh" #include "ANIM_fcurve.hh" #include "ANIM_keyframing.hh" @@ -670,13 +671,13 @@ int delete_keyframe(Main *bmain, ReportList *reports, ID *id, const RNAPath &rna } Action &action = act->wrap(); + Vector modified_fcurves; if (action.is_action_layered()) { /* Just being defensive in the face of the NLA shenanigans above. This * probably isn't necessary, but it doesn't hurt. */ BLI_assert(adt->action == act && action.slot_for_handle(adt->slot_handle) != nullptr); Span fcurves = fcurves_for_action_slot(action, adt->slot_handle); - int removed_key_count = 0; /* This loop's clause is copied from the pre-existing code for legacy * actions below, to ensure behavioral consistency between the two code * paths. In the future when legacy actions are removed, we can restructure @@ -686,38 +687,46 @@ int delete_keyframe(Main *bmain, ReportList *reports, ID *id, const RNAPath &rna if (fcurve == nullptr) { continue; } - removed_key_count += fcurve_delete_keyframe_at_time(fcurve, cfra); + if (fcurve_delete_keyframe_at_time(fcurve, cfra)) { + modified_fcurves.append(fcurve); + } } + } + else { + /* Will only loop once unless the array index was -1. */ + for (; array_index < array_index_max; array_index++) { + FCurve *fcu = action_fcurve_find(act, {rna_path.path, array_index}); - return removed_key_count; + if (fcu == nullptr) { + continue; + } + + if (BKE_fcurve_is_protected(fcu)) { + BKE_reportf(reports, + RPT_WARNING, + "Not deleting keyframe for locked F-Curve '%s' for %s '%s'", + fcu->rna_path, + BKE_idtype_idcode_to_name(GS(id->name)), + id->name + 2); + continue; + } + + if (fcurve_delete_keyframe_at_time(fcu, cfra)) { + modified_fcurves.append(fcu); + } + } } - /* Will only loop once unless the array index was -1. */ - int key_count = 0; - for (; array_index < array_index_max; array_index++) { - FCurve *fcu = action_fcurve_find(act, {rna_path.path, array_index}); - - if (fcu == nullptr) { - continue; + if (!modified_fcurves.is_empty()) { + for (FCurve *fcurve : modified_fcurves) { + if (BKE_fcurve_is_empty(fcurve)) { + animdata_fcurve_delete(nullptr, adt, fcurve); + } } - - if (BKE_fcurve_is_protected(fcu)) { - BKE_reportf(reports, - RPT_WARNING, - "Not deleting keyframe for locked F-Curve '%s' for %s '%s'", - fcu->rna_path, - BKE_idtype_idcode_to_name(GS(id->name)), - id->name + 2); - continue; - } - - key_count += delete_keyframe_fcurve_legacy(adt, fcu, cfra); - } - if (key_count) { deg_tag_after_keyframe_delete(bmain, id, adt); } - return key_count; + return modified_fcurves.size(); } /* ************************************************** */ @@ -751,43 +760,67 @@ int clear_keyframe(Main *bmain, ReportList *reports, ID *id, const RNAPath &rna_ } bAction *act = adt->action; - int array_index = rna_path.index.value_or(0); - int array_index_max = array_index + 1; - if (!rna_path.index.has_value()) { - array_index_max = RNA_property_array_length(&ptr, prop); - - /* For single properties, increase max_index so that the property itself gets included, - * but don't do this for standard arrays since that can cause corruption issues - * (extra unused curves). - */ - if (array_index_max == array_index) { - array_index_max++; - } - } - + Action &action = act->wrap(); int key_count = 0; - /* Will only loop once unless the array index was -1. */ - for (; array_index < array_index_max; array_index++) { - FCurve *fcu = action_fcurve_find(act, {rna_path.path, array_index}); - if (fcu == nullptr) { - continue; + if (action.is_action_layered()) { + if (adt->slot_handle) { + Vector fcurves; + action_foreach_fcurve(action, adt->slot_handle, [&](FCurve &fcurve) { + if (rna_path.index.has_value() && rna_path.index.value() != fcurve.array_index) { + return; + } + if (rna_path.path != fcurve.rna_path) { + return; + } + fcurves.append(&fcurve); + }); + + for (FCurve *fcu : fcurves) { + if (action_fcurve_remove(action, *fcu)) { + key_count++; + } + } } - - if (BKE_fcurve_is_protected(fcu)) { - BKE_reportf(reports, - RPT_WARNING, - "Not clearing all keyframes from locked F-Curve '%s' for %s '%s'", - fcu->rna_path, - BKE_idtype_idcode_to_name(GS(id->name)), - id->name + 2); - continue; - } - - animdata_fcurve_delete(nullptr, adt, fcu); - - key_count++; } + else { + int array_index = rna_path.index.value_or(0); + int array_index_max = array_index + 1; + if (!rna_path.index.has_value()) { + array_index_max = RNA_property_array_length(&ptr, prop); + + /* For single properties, increase max_index so that the property itself gets included, + * but don't do this for standard arrays since that can cause corruption issues + * (extra unused curves). + */ + if (array_index_max == array_index) { + array_index_max++; + } + } + /* Will only loop once unless the array index was -1. */ + for (; array_index < array_index_max; array_index++) { + FCurve *fcu = action_fcurve_find(act, {rna_path.path, array_index}); + + if (fcu == nullptr) { + continue; + } + + if (BKE_fcurve_is_protected(fcu)) { + BKE_reportf(reports, + RPT_WARNING, + "Not clearing all keyframes from locked F-Curve '%s' for %s '%s'", + fcu->rna_path, + BKE_idtype_idcode_to_name(GS(id->name)), + id->name + 2); + continue; + } + + animdata_fcurve_delete(nullptr, adt, fcu); + + key_count++; + } + } + if (key_count) { deg_tag_after_keyframe_delete(bmain, id, adt); } diff --git a/source/blender/editors/animation/keyframing.cc b/source/blender/editors/animation/keyframing.cc index 1109c2784f5..4aef81c3342 100644 --- a/source/blender/editors/animation/keyframing.cc +++ b/source/blender/editors/animation/keyframing.cc @@ -45,6 +45,7 @@ #include "ED_screen.hh" #include "ANIM_action.hh" +#include "ANIM_action_iterators.hh" #include "ANIM_animdata.hh" #include "ANIM_bone_collections.hh" #include "ANIM_driver.hh" @@ -708,50 +709,70 @@ void ANIM_OT_keyframe_delete_by_name(wmOperatorType *ot) * it is more useful for animators working in the 3D view. */ +/* While in pose mode, the selection of bones has to be considered. */ +static bool can_delete_fcurve(FCurve *fcu, Object *ob) +{ + bool can_delete = false; + /* in pose mode, only delete the F-Curve if it belongs to a selected bone */ + if (ob->mode & OB_MODE_POSE) { + if (fcu->rna_path) { + /* Get bone-name, and check if this bone is selected. */ + bPoseChannel *pchan = nullptr; + char bone_name[sizeof(pchan->name)]; + if (BLI_str_quoted_substr(fcu->rna_path, "pose.bones[", bone_name, sizeof(bone_name))) { + pchan = BKE_pose_channel_find_name(ob->pose, bone_name); + /* Delete if bone is selected. */ + if ((pchan) && (pchan->bone)) { + if (pchan->bone->flag & BONE_SELECTED) { + can_delete = true; + } + } + } + } + } + else { + /* object mode - all of Object's F-Curves are affected */ + /* TODO: this logic isn't solid. Only delete FCurves of the object, not of bones in this case. + */ + can_delete = true; + } + + return can_delete; +} + static int clear_anim_v3d_exec(bContext *C, wmOperator * /*op*/) { + using namespace blender::animrig; bool changed = false; CTX_DATA_BEGIN (C, Object *, ob, selected_objects) { /* just those in active action... */ if ((ob->adt) && (ob->adt->action)) { AnimData *adt = ob->adt; - bAction *act = adt->action; + bAction *dna_action = adt->action; FCurve *fcu, *fcn; - for (fcu = static_cast(act->curves.first); fcu; fcu = fcn) { - bool can_delete = false; - - fcn = fcu->next; - - /* in pose mode, only delete the F-Curve if it belongs to a selected bone */ - if (ob->mode & OB_MODE_POSE) { - if (fcu->rna_path) { - /* Get bone-name, and check if this bone is selected. */ - bPoseChannel *pchan = nullptr; - char bone_name[sizeof(pchan->name)]; - if (BLI_str_quoted_substr(fcu->rna_path, "pose.bones[", bone_name, sizeof(bone_name))) - { - pchan = BKE_pose_channel_find_name(ob->pose, bone_name); - /* Delete if bone is selected. */ - if ((pchan) && (pchan->bone)) { - if (pchan->bone->flag & BONE_SELECTED) { - can_delete = true; - } - } - } + Action &action = dna_action->wrap(); + if (action.is_action_layered()) { + blender::Vector fcurves_to_delete; + action_foreach_fcurve(action, adt->slot_handle, [&](FCurve &fcurve) { + if (can_delete_fcurve(&fcurve, ob)) { + fcurves_to_delete.append(&fcurve); } + }); + for (FCurve *fcurve : fcurves_to_delete) { + action_fcurve_remove(action, *fcurve); } - else { - /* object mode - all of Object's F-Curves are affected */ - can_delete = true; - } - - /* delete F-Curve completely */ - if (can_delete) { - blender::animrig::animdata_fcurve_delete(nullptr, adt, fcu); - DEG_id_tag_update(&ob->id, ID_RECALC_TRANSFORM); - changed = true; + } + else { + for (fcu = static_cast(dna_action->curves.first); fcu; fcu = fcn) { + fcn = fcu->next; + /* delete F-Curve completely */ + if (can_delete_fcurve(fcu, ob)) { + blender::animrig::animdata_fcurve_delete(nullptr, adt, fcu); + DEG_id_tag_update(&ob->id, ID_RECALC_TRANSFORM); + changed = true; + } } } @@ -820,6 +841,8 @@ static bool can_delete_key(FCurve *fcu, Object *ob, ReportList *reports) /* Special exception for bones, as this makes this operator more convenient to use * NOTE: This is only done in pose mode. * In object mode, we're dealing with the entire object. + * TODO: While this means bone animation is not deleted of all bones while in pose mode. Running + * the code on the armature object WILL delete keys of all bones. */ if (ob->mode & OB_MODE_POSE) { bPoseChannel *pchan = nullptr; @@ -880,11 +903,21 @@ static int delete_key_v3d_without_keying_set(bContext *C, wmOperator *op) Action &action = act->wrap(); if (action.is_action_layered()) { - for (FCurve *fcu : fcurves_for_action_slot(action, adt->slot_handle)) { - if (!can_delete_key(fcu, ob, op->reports)) { - continue; + blender::Vector modified_fcurves; + action_foreach_fcurve(action, adt->slot_handle, [&](FCurve &fcurve) { + if (!can_delete_key(&fcurve, ob, op->reports)) { + return; + } + if (blender::animrig::fcurve_delete_keyframe_at_time(&fcurve, cfra_unmap)) { + modified_fcurves.append(&fcurve); + } + }); + + success += modified_fcurves.size(); + for (FCurve *fcurve : modified_fcurves) { + if (BKE_fcurve_is_empty(fcurve)) { + action_fcurve_remove(action, *fcurve); } - success += blender::animrig::fcurve_delete_keyframe_at_time(fcu, cfra_unmap); } } else { @@ -897,7 +930,7 @@ static int delete_key_v3d_without_keying_set(bContext *C, wmOperator *op) /* Delete keyframes on current frame * WARNING: this can delete the next F-Curve, hence the "fcn" copying. */ - success += blender::animrig::delete_keyframe_fcurve_legacy(adt, fcu, cfra_unmap); + success += delete_keyframe_fcurve_legacy(adt, fcu, cfra_unmap); } } diff --git a/tests/python/bl_animation_keyframing.py b/tests/python/bl_animation_keyframing.py index f571f5e4183..dee531a0d0e 100644 --- a/tests/python/bl_animation_keyframing.py +++ b/tests/python/bl_animation_keyframing.py @@ -688,6 +688,42 @@ class NlaInsertTest(AbstractKeyframingTest, unittest.TestCase): self.assertAlmostEqual(fcurve_loc_x.keyframe_points[-1].co[1], 1.0, 8) +class KeyframeDeleteTest(AbstractKeyframingTest, unittest.TestCase): + + def test_delete_in_v3d_pose_mode(self): + armature = _create_armature() + bpy.context.scene.frame_set(1) + with bpy.context.temp_override(**_get_view3d_context()): + bpy.ops.anim.keyframe_insert_by_name(type="Location") + self.assertTrue(armature.animation_data is not None) + self.assertTrue(armature.animation_data.action is not None) + action = armature.animation_data.action + self.assertEqual(len(action.fcurves), 3) + + bpy.ops.object.mode_set(mode='POSE') + with bpy.context.temp_override(**_get_view3d_context()): + bpy.ops.anim.keyframe_insert_by_name(type="Location") + bpy.context.scene.frame_set(5) + bpy.ops.anim.keyframe_insert_by_name(type="Location") + # This should have added new FCurves for the pose bone. + self.assertEqual(len(action.fcurves), 6) + + bpy.ops.anim.keyframe_delete_v3d() + # No Fcurves should yet be deleted. + self.assertEqual(len(action.fcurves), 6) + self.assertEqual(len(action.fcurves[0].keyframe_points), 1) + bpy.context.scene.frame_set(1) + bpy.ops.anim.keyframe_delete_v3d() + # This should leave the object level keyframes of the armature + self.assertEqual(len(action.fcurves), 3) + + bpy.ops.object.mode_set(mode='OBJECT') + with bpy.context.temp_override(**_get_view3d_context()): + bpy.ops.anim.keyframe_delete_v3d() + # The last FCurves should be deleted from the object now. + self.assertEqual(len(action.fcurves), 0) + + def main(): global args import argparse