From bcd0d14943dd1f0b89c8b6462b60c022c71ba76b Mon Sep 17 00:00:00 2001 From: Christoph Lendenfeld Date: Thu, 26 Sep 2024 15:45:53 +0200 Subject: [PATCH] Versioning for layered actions This implements versioning code to go from legacy to layered action. The versioning is only triggered when the experimental flag for Multi-Slot actions is enabled. All the actions are converted in place, which should be fine because of backwards and forwards compatibility with layered actions. Pull Request: https://projects.blender.org/blender/blender/pulls/127842 --- .../blenloader/intern/versioning_400.cc | 128 ++++++++++++++++++ tests/data | 2 +- tests/python/CMakeLists.txt | 2 + tests/python/bl_animation_action.py | 92 +++++++++++++ 4 files changed, 223 insertions(+), 1 deletion(-) diff --git a/source/blender/blenloader/intern/versioning_400.cc b/source/blender/blenloader/intern/versioning_400.cc index 1d4f73c8f49..3afbbf4d51f 100644 --- a/source/blender/blenloader/intern/versioning_400.cc +++ b/source/blender/blenloader/intern/versioning_400.cc @@ -79,6 +79,8 @@ #include "SEQ_sequencer.hh" #include "SEQ_time.hh" +#include "ANIM_action.hh" +#include "ANIM_action_iterators.hh" #include "ANIM_armature_iter.hh" #include "ANIM_bone_collections.hh" @@ -105,6 +107,126 @@ static void version_composite_nodetree_null_id(bNodeTree *ntree, Scene *scene) } } +struct ActionUserInfo { + ID *id; + blender::animrig::slot_handle_t *slot_handle; + bAction **action_ptr_ptr; + char *slot_name; +}; + +static void convert_action_in_place(blender::animrig::Action &action) +{ + using namespace blender::animrig; + if (action.is_action_layered()) { + return; + } + Slot &slot = action.slot_add(); + slot.idtype = action.idroot; + action.idroot = 0; + Layer &layer = action.layer_add("Layer"); + blender::animrig::Strip &strip = layer.strip_add(action, + blender::animrig::Strip::Type::Keyframe); + ChannelBag &bag = strip.data(action).channelbag_for_slot_ensure(slot); + const int fcu_count = BLI_listbase_count(&action.curves); + const int group_count = BLI_listbase_count(&action.groups); + bag.fcurve_array = MEM_cnew_array(fcu_count, "Action versioning - fcurves"); + bag.fcurve_array_num = fcu_count; + bag.group_array = MEM_cnew_array(group_count, "Action versioning - groups"); + bag.group_array_num = group_count; + + int group_index = 0; + int fcurve_index = 0; + LISTBASE_FOREACH_INDEX (bActionGroup *, group, &action.groups, group_index) { + bag.group_array[group_index] = group; + + group->channel_bag = &bag; + group->fcurve_range_start = fcurve_index; + + LISTBASE_FOREACH (FCurve *, fcu, &group->channels) { + if (fcu->grp != group) { + break; + } + bag.fcurve_array[fcurve_index++] = fcu; + } + + group->fcurve_range_length = fcurve_index - group->fcurve_range_start; + } + + LISTBASE_FOREACH (FCurve *, fcu, &action.curves) { + /* Any fcurves with groups have already been added to the fcurve array. */ + if (fcu->grp) { + continue; + } + bag.fcurve_array[fcurve_index++] = fcu; + } + + BLI_assert(fcurve_index == fcu_count); + + action.curves = {nullptr, nullptr}; + action.groups = {nullptr, nullptr}; +} + +static void version_legacy_actions_to_layered(Main *bmain) +{ + using namespace blender::animrig; + blender::Map> action_users; + LISTBASE_FOREACH (bAction *, dna_action, &bmain->actions) { + Action &action = dna_action->wrap(); + if (action.is_action_layered()) { + continue; + } + action_users.add(dna_action, {}); + } + + ID *id; + FOREACH_MAIN_ID_BEGIN (bmain, id) { + auto callback = + [&](bAction *&action_ptr_ref, slot_handle_t &slot_handle_ref, char *slot_name) -> bool { + blender::Vector *action_user_vector = action_users.lookup_ptr( + action_ptr_ref); + /* Only actions that need to be converted are in this map. */ + if (!action_user_vector) { + return true; + } + ActionUserInfo user_info; + user_info.id = id; + user_info.action_ptr_ptr = &action_ptr_ref; + user_info.slot_handle = &slot_handle_ref; + user_info.slot_name = slot_name; + action_user_vector->append(user_info); + return true; + }; + + foreach_action_slot_use_with_references(*id, callback); + } + FOREACH_MAIN_ID_END; + + for (const auto &item : action_users.items()) { + Action &action = item.key->wrap(); + convert_action_in_place(action); + blender::Vector &user_infos = item.value; + if (user_infos.size() == 1) { + /* Rename the slot after its single user. If there are multiple users, the name is unchanged + * because there is no good way to determine a name. */ + action.slot_name_set(*bmain, *action.slot(0), user_infos[0].id->name); + } + for (ActionUserInfo &action_user : user_infos) { + BLI_assert_msg(*action_user.slot_handle == Slot::unassigned, + "Because the action was just converted from legacy, none of the users of " + "that action should have a slot set yet."); + + ActionSlotAssignmentResult result = generic_assign_action_slot_handle( + action.slot(0)->handle, + *action_user.id, + *action_user.action_ptr_ptr, + *action_user.slot_handle, + action_user.slot_name); + BLI_assert(result == ActionSlotAssignmentResult::OK); + UNUSED_VARS_NDEBUG(result); + } + } +} + /* Move bone-group color to the individual bones. */ static void version_bonegroup_migrate_color(Main *bmain) { @@ -1056,6 +1178,12 @@ void do_versions_after_linking_400(FileData *fd, Main *bmain) * * \note Keep this message at the bottom of the function. */ + + /* Keeping this block is without a `MAIN_VERSION_FILE_ATLEAST` until the experimental flag is + * removed. */ + if (USER_EXPERIMENTAL_TEST(&U, use_animation_baklava)) { + version_legacy_actions_to_layered(bmain); + } } static void version_mesh_legacy_to_struct_of_array_format(Mesh &mesh) diff --git a/tests/data b/tests/data index f3eaf4c561d..0ff59eafc76 160000 --- a/tests/data +++ b/tests/data @@ -1 +1 @@ -Subproject commit f3eaf4c561d027c27964a992bcddf3293ec786c5 +Subproject commit 0ff59eafc7658787b55db167b4157e7eed40bc6c diff --git a/tests/python/CMakeLists.txt b/tests/python/CMakeLists.txt index 6dd82ffcb92..2f0ee869bca 100644 --- a/tests/python/CMakeLists.txt +++ b/tests/python/CMakeLists.txt @@ -405,6 +405,8 @@ if(WITH_EXPERIMENTAL_FEATURES) add_blender_test( bl_animation_action --python ${CMAKE_CURRENT_LIST_DIR}/bl_animation_action.py + -- + --testdir "${TEST_SRC_DIR}/animation" ) endif() diff --git a/tests/python/bl_animation_action.py b/tests/python/bl_animation_action.py index 0dc099c6c48..96a35995c36 100644 --- a/tests/python/bl_animation_action.py +++ b/tests/python/bl_animation_action.py @@ -4,6 +4,7 @@ import unittest import sys +import pathlib import bpy @@ -432,6 +433,96 @@ class DataPathTest(unittest.TestCase): self.assertEqual("bpy.data.actions['TestAction'].layers[\"Layer\"].strips[0].channelbags[0]", repr(channelbag)) +class VersioningTest(unittest.TestCase): + def setUp(self): + enable_experimental_animation_baklava() + bpy.ops.wm.open_mainfile(filepath=str(args.testdir / "layered_action_versioning_42.blend"), load_ui=False) + + def tearDown(self) -> None: + disable_experimental_animation_baklava() + + def test_nla_conversion(self): + nla_object = bpy.data.objects["nla_object"] + nla_anim_data = nla_object.animation_data + self.assertTrue(nla_anim_data.action.is_action_layered) + self.assertNotEqual(nla_anim_data.action_slot_handle, 0) + + # The action that is not pushed into an NLA strip. + active_action = nla_anim_data.action + strip = active_action.layers[0].strips[0] + for fcurve_index, fcurve in enumerate(strip.channelbags[0].fcurves): + self.assertEqual(fcurve.data_path, "rotation_euler") + self.assertEqual(fcurve.group.name, "Object Transforms") + self.assertEqual(fcurve.array_index, fcurve_index) + + self.assertEqual(len(nla_anim_data.nla_tracks), 2) + self.assertTrue(nla_anim_data.nla_tracks[0].strips[0].action.is_action_layered) + self.assertNotEqual(nla_anim_data.nla_tracks[0].strips[0].action_slot_handle, 0) + + self.assertTrue(nla_anim_data.nla_tracks[1].strips[0].action.is_action_layered) + self.assertNotEqual(nla_anim_data.nla_tracks[1].strips[0].action_slot_handle, 0) + + def test_multi_use_action(self): + object_a = bpy.data.objects["multi_user_object_a"] + object_b = bpy.data.objects["multi_user_object_b"] + self.assertTrue(object_a.animation_data.action.is_action_layered) + self.assertNotEqual(object_a.animation_data.action_slot_handle, 0) + + self.assertTrue(object_b.animation_data.action.is_action_layered) + self.assertNotEqual(object_b.animation_data.action_slot_handle, 0) + + self.assertEqual(object_a.animation_data.action, object_b.animation_data.action) + self.assertEqual(object_a.animation_data.action_slot_handle, object_b.animation_data.action_slot_handle) + + action = object_a.animation_data.action + strip = action.layers[0].strips[0] + self.assertEqual(len(strip.channelbags[0].fcurves), 9) + self.assertEqual(len(strip.channelbags[0].groups), 1) + self.assertEqual(len(strip.channelbags[0].groups[0].channels), 9) + + # Multi user slots do not get named after their users. + self.assertEqual(action.slots[0].name, "OBSlot") + + def test_action_constraint(self): + constrained_object = bpy.data.objects["action_constraint_constrained"] + action_constraint = constrained_object.constraints[0] + self.assertTrue(action_constraint.action.is_action_layered) + self.assertNotEqual(action_constraint.action_slot_handle, 0) + + action_owner_object = bpy.data.objects["action_constraint_action_owner"] + action = action_owner_object.animation_data.action + self.assertTrue(action.is_action_layered) + self.assertEqual(action, action_constraint.action) + self.assertEqual(action_owner_object.animation_data.action_slot_handle, action_constraint.action_slot_handle) + strip = action.layers[0].strips[0] + self.assertEqual(len(strip.channelbags[0].fcurves), 1) + fcurve = strip.channelbags[0].fcurves[0] + self.assertEqual(fcurve.data_path, "location") + self.assertEqual(fcurve.array_index, 2) + self.assertEqual(fcurve.group.name, "Object Transforms") + + def test_armature_action_conversion(self): + armature_object = bpy.data.objects["armature_object"] + action = armature_object.animation_data.action + self.assertTrue(action.is_action_layered) + strip = action.layers[0].strips[0] + self.assertEqual(len(strip.channelbags[0].groups), 2) + self.assertEqual(strip.channelbags[0].groups[0].name, "Bone") + self.assertEqual(strip.channelbags[0].groups[1].name, "Bone.001") + self.assertEqual(len(strip.channelbags[0].fcurves), 20) + self.assertEqual(len(strip.channelbags[0].groups[0].channels), 10) + self.assertEqual(len(strip.channelbags[0].groups[1].channels), 10) + + # Slots with a single user are named after their user. + self.assertEqual(action.slots[0].name, "OBarmature_object") + + for fcurve in strip.channelbags[0].groups[0].channels: + self.assertEqual(fcurve.group.name, "Bone") + + for fcurve in strip.channelbags[0].groups[1].channels: + self.assertEqual(fcurve.group.name, "Bone.001") + + def main(): global args import argparse @@ -441,6 +532,7 @@ def main(): argv += sys.argv[sys.argv.index('--') + 1:] parser = argparse.ArgumentParser() + parser.add_argument('--testdir', required=True, type=pathlib.Path) args, remaining = parser.parse_known_args(argv) unittest.main(argv=remaining)