blender/tests/python/bl_animation_rename.py
Christoph Lendenfeld 4c1b8d11ac Anim: Improved AnimData rename API
Through a convoluted logic the previous AnimData renaming code
basically did:
* For any given ID
* check all FCurves it uses directly for the rename string
(includes owned drivers)
* check all other AnimData in Main if the given ID is being used as
a driver target

When doing that in a loop for many IDs, things can slow down
considerably. The solution is to build a map of ID to the drivers using
it as a target beforehand.
That way, we only have to do loop main once to build the map and
can then reuse it to subsequent calls to the rename function
(which only looks at the given ID)

### Implementation

**Removed `BKE_animdata_fix_paths_rename`**:
Since this function doesn't check for driver targets by looping main,
it missed to rename them leaving invalid drivers behind. When renaming
anything that can be animated it is never correct to ignore driver
targets which is why I removed it. Replaced with calls to
`BKE_animdata_fix_paths`.

**Removed `BKE_animdata_fix_paths_rename_all_ex` and `BKE_animdata_fix_paths_rename_all`**:
This has been replaced with a combination of
`BKE_animdata_build_driver_target_map` and `BKE_animdata_fix_paths`.
By splitting this up we can get the performance
gains when doing many renames at once.

**Added `RNA_path_name_to_infix` and `RNA_path_number_to_infix`**:
The new functions no longer assume anything about the rename string
they are given. Instead the caller has to format the strings accordingly
which is done with this function.
Doing so removes a boolean argument from the API.

In order to get the un-escaped name (which is needed for drivers
targeting pose bones by name) I added `infix_to_name`. This function
is not public because it makes a few assumptions
about the structure of the given string.

**`driver_target_path_fix` no longer verifies the path**
RNA paths for drivers can contain a trailing number like `[0]` to
indicate the array index. For regular rna paths this is usually a
separate integer which is why such paths won't resolve. Verifying the
path would mean that driver targets are not correctly updated, as
confirmed by the unit test that was disabled.
This is now fixed and the unit test was enabled.

### Fixed Issues

Fixes #155262
Fixes #159712
Fixes unreported: Drivers targeting VSE strips break if strip is renamed

Depending on the file, this is up to 27x times faster.

Part of #160849

Pull Request: https://projects.blender.org/blender/blender/pulls/160988
2026-07-21 08:07:41 +02:00

165 lines
7.4 KiB
Python

# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import unittest
import bpy
import pathlib
import sys
_BONE_NAME_A = "bone_a"
_BONE_NAME_B = "bone_b"
def _set_up_driver(driver: bpy.types.Driver, target_id: bpy.types.ID, target_path: str) -> None:
driver_variable_name = "var"
driver.type = 'AVERAGE'
driver.expression = driver_variable_name
driver_var = driver.variables.new()
driver_var.name = driver_variable_name
driver_var.type = 'SINGLE_PROP'
driver_target = driver_var.targets[0]
driver_target.id_type = target_id.id_type
driver_target.id = target_id
driver_target.data_path = target_path
class PoseBoneRenameTest(unittest.TestCase):
"""Ensure that drivers and animation are moved to the correct RNA path once a pose bone is renamed."""
def setUp(self) -> None:
super().setUp()
bpy.ops.wm.read_homefile(use_factory_startup=True)
armature = bpy.data.armatures.new("test_armature")
self.armature_obj = bpy.data.objects.new("test_armature_object", armature)
bpy.context.scene.collection.objects.link(self.armature_obj)
bpy.context.view_layer.objects.active = self.armature_obj
self.armature_obj.select_set(True)
bpy.ops.object.mode_set(mode='EDIT')
edit_bone = armature.edit_bones.new(_BONE_NAME_A)
edit_bone.head = (1, 0, 0)
edit_bone.tail = (0, 0, 0)
edit_bone = armature.edit_bones.new(_BONE_NAME_B)
edit_bone.head = (1, 1, 0)
edit_bone.tail = (0, 1, 0)
bpy.ops.object.mode_set(mode='OBJECT')
# This is useful to check if the rename does not affect animation on identically named
# bones in a different object.
self.armature_obj_dupli = self.armature_obj.copy()
self.armature_obj_dupli.data = armature.copy()
bpy.context.scene.collection.objects.link(self.armature_obj_dupli)
def test_rename_bone_driver(self) -> None:
fcu = self.armature_obj.data.bones[_BONE_NAME_A].driver_add("bbone_segments", -1)
self.assertEqual(fcu.data_path, f'bones["{_BONE_NAME_A}"].bbone_segments')
_set_up_driver(fcu.driver, self.armature_obj, f'pose.bones["{_BONE_NAME_B}"].location[0]')
# Setting up the same driver on the duplicate armature.
fcu_dupli = self.armature_obj_dupli.data.bones[_BONE_NAME_A].driver_add("bbone_segments", -1)
self.assertEqual(fcu_dupli.data_path, fcu.data_path)
_set_up_driver(fcu_dupli.driver, self.armature_obj_dupli, f'pose.bones["{_BONE_NAME_B}"].location[0]')
bone_a_new_name = "bone_a_2"
self.armature_obj.pose.bones[_BONE_NAME_A].name = bone_a_new_name
# `bbone_segments` is a property of the bone thus armature.
self.assertEqual(len(self.armature_obj.data.animation_data.drivers), 1, "Shouldn't remove the driver")
self.assertEqual(fcu.data_path, f'bones["{bone_a_new_name}"].bbone_segments')
self.assertEqual(
fcu_dupli.data_path,
f'bones["{_BONE_NAME_A}"].bbone_segments',
"Should not modify duplicate armature")
bone_b_new_name = "bone_b_2"
self.armature_obj.pose.bones[_BONE_NAME_B].name = bone_b_new_name
# Testing if driver targets are still correct after a rename.
driver_target = fcu.driver.variables[0].targets[0]
self.assertEqual(driver_target.data_path, f"pose.bones[\"{bone_b_new_name}\"].location[0]")
def test_rename_pose_bone_driver(self) -> None:
fcu = self.armature_obj.pose.bones[_BONE_NAME_A].driver_add("hide", -1)
self.assertEqual(fcu.data_path, f'pose.bones["{_BONE_NAME_A}"].hide')
_set_up_driver(fcu.driver, self.armature_obj, f'pose.bones["{_BONE_NAME_B}"].location[0]')
# Setting up the same driver on the duplicate armature.
fcu_dupli = self.armature_obj_dupli.pose.bones[_BONE_NAME_A].driver_add("hide", -1)
self.assertEqual(fcu_dupli.data_path, fcu.data_path)
_set_up_driver(fcu_dupli.driver, self.armature_obj_dupli, f'pose.bones["{_BONE_NAME_B}"].location[0]')
bone_a_new_name = "bone_a_2"
self.armature_obj.pose.bones[_BONE_NAME_A].name = bone_a_new_name
# `hide` is a property on the pose bone thus object.
self.assertEqual(len(self.armature_obj.animation_data.drivers), 1, "Shouldn't remove the driver")
self.assertEqual(fcu.data_path, f'pose.bones["{bone_a_new_name}"].hide')
self.assertEqual(
fcu_dupli.data_path,
f'pose.bones["{_BONE_NAME_A}"].hide',
"Should not modify the duplicate armature")
bone_b_new_name = "bone_b_2"
self.armature_obj.pose.bones[_BONE_NAME_B].name = bone_b_new_name
# Testing if driver targets are still correct after a rename.
driver_target = fcu.driver.variables[0].targets[0]
self.assertEqual(driver_target.data_path, f"pose.bones[\"{bone_b_new_name}\"].location[0]")
def test_rename_bone_animation(self) -> None:
# Not particularly useful to animate this property, but it can be done, so better test it.
self.armature_obj.data.bones[_BONE_NAME_A].keyframe_insert("bbone_segments")
self.assertNotEqual(self.armature_obj.data.animation_data.action, None)
action = self.armature_obj.data.animation_data.action
fcurves = action.layers[0].strips[0].channelbags[0].fcurves
self.assertEqual(fcurves[0].data_path, f'bones[\"{_BONE_NAME_A}"].bbone_segments')
self.armature_obj_dupli.data.bones[_BONE_NAME_A].keyframe_insert("bbone_segments")
self.assertNotEqual(self.armature_obj_dupli.data.animation_data.action, None)
bone_a_new_name = "bone_a_2"
# Renaming the pose bone also renames the bone itself, which should rename the fcurve data path too.
self.armature_obj.pose.bones[_BONE_NAME_A].name = bone_a_new_name
self.assertEqual(fcurves[0].data_path, f'bones[\"{bone_a_new_name}"].bbone_segments')
dupli_action = self.armature_obj_dupli.data.animation_data.action
dupli_fcurves = dupli_action.layers[0].strips[0].channelbags[0].fcurves
self.assertEqual(dupli_fcurves[0].data_path, f'bones[\"{_BONE_NAME_A}"].bbone_segments')
def test_rename_pose_bone_animation(self) -> None:
self.armature_obj.pose.bones[_BONE_NAME_A].keyframe_insert("location")
self.assertNotEqual(self.armature_obj.animation_data.action, None)
action = self.armature_obj.animation_data.action
fcurves = action.layers[0].strips[0].channelbags[0].fcurves
for fcurve in fcurves:
self.assertEqual(fcurve.data_path, f'pose.bones[\"{_BONE_NAME_A}"].location')
self.armature_obj_dupli.pose.bones[_BONE_NAME_A].keyframe_insert("location")
self.assertNotEqual(self.armature_obj_dupli.animation_data.action, None)
bone_a_new_name = "bone_a_2"
self.armature_obj.pose.bones[_BONE_NAME_A].name = bone_a_new_name
for fcurve in fcurves:
self.assertEqual(fcurve.data_path, f'pose.bones[\"{bone_a_new_name}"].location')
dupli_action = self.armature_obj_dupli.animation_data.action
for fcurve in dupli_action.layers[0].strips[0].channelbags[0].fcurves:
self.assertEqual(fcurve.data_path, f'pose.bones[\"{_BONE_NAME_A}"].location')
if __name__ == "__main__":
import sys
sys.argv = [__file__] + (sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [])
unittest.main()