diff --git a/Editor/API/AnimatorServices/ObjectPathRemapper.cs b/Editor/API/AnimatorServices/ObjectPathRemapper.cs
new file mode 100644
index 0000000..5546bdf
--- /dev/null
+++ b/Editor/API/AnimatorServices/ObjectPathRemapper.cs
@@ -0,0 +1,138 @@
+using System.Collections.Generic;
+using nadena.dev.ndmf.runtime;
+using UnityEngine;
+
+namespace nadena.dev.ndmf.animator
+{
+ ///
+ /// The ObjectPathRemapper is used to track GameObject movement in the hierarchy, and to update animation paths
+ /// accordingly.
+ /// While the ObjectPathRemapper is active, there are a few important rules around hierarchy and animation
+ /// maniuplation that must be observed.
+ /// 1. The ObjectPathRemapper takes a snapshot of the object paths that were present at time of activation;
+ /// as such, any new animations added while it is active must use those paths, not those of the current
+ /// hierarchy. To help with this, you can use the `GetVirtualPathForObject` method to get the path that
+ /// should be used for newly generated animations.
+ /// 2. Objects can be moved freely within the hierarchy; however, if you want to _remove_ an object, you must call
+ /// `ReplaceObject` on it first.
+ /// 3. Objects can be freely added; if you want to use those objects in animations, use `GetVirtualPathForObject`
+ /// to get the path that should be used. This will automatically register that object, if necessary. If you'd
+ /// like to use animation clips with pre-existing paths on, for example, a newly instantiated prefab hierarchy,
+ /// use `RecordObjectTree` to ensure that those objects have their current paths recorded first. This ensures
+ /// that if those objects are moved in later stages, the paths will be updated appropriately.
+ /// Note that it's possible that these paths may collide with paths that _previously_ existed, so it's still
+ /// recommended to use `GetVirtualPathForObject` to ensure that the path is unique.
+ ///
+ public class ObjectPathRemapper
+ {
+ private readonly Transform _root;
+ private readonly Dictionary> _objectToOriginalPaths = new();
+ private readonly Dictionary _pathToObject = new();
+
+ private bool _cacheValid;
+ private readonly Dictionary _originalToMappedPath = new();
+
+ internal ObjectPathRemapper(Transform root)
+ {
+ _root = root;
+ RecordObjectTree(root);
+ }
+
+ public void ApplyChanges(AnimationIndex index)
+ {
+ UpdateCache();
+
+ index.RewritePaths(_originalToMappedPath);
+ }
+
+ private void UpdateCache()
+ {
+ if (_cacheValid) return;
+
+ _originalToMappedPath.Clear();
+
+ foreach (var kvp in _objectToOriginalPaths)
+ {
+ foreach (var path in kvp.Value)
+ {
+ _originalToMappedPath[path] = GetVirtualPathForObject(kvp.Key);
+ }
+ }
+ }
+
+ public void RecordObjectTree(Transform subtree)
+ {
+ GetVirtualPathForObject(subtree);
+
+ foreach (Transform child in subtree)
+ {
+ RecordObjectTree(child);
+ }
+ }
+
+ public GameObject GetObjectForPath(string path)
+ {
+ var xform = _pathToObject.GetValueOrDefault(path);
+ return xform ? xform.gameObject : null;
+ }
+
+ public string GetVirtualPathForObject(GameObject obj)
+ {
+ return GetVirtualPathForObject(obj.transform);
+ }
+
+ public string GetVirtualPathForObject(Transform t)
+ {
+ if (_objectToOriginalPaths.TryGetValue(t, out var paths))
+ {
+ return paths[0];
+ }
+
+ var path = RuntimeUtil.RelativePath(_root, t);
+ if (path == null) return null;
+
+ if (_pathToObject.ContainsKey(path))
+ {
+ path += "###PENDING_" + t.GetInstanceID();
+ }
+
+ _objectToOriginalPaths[t] = new List { path };
+ _pathToObject[path] = t;
+ _cacheValid = false;
+
+ return path;
+ }
+
+ public void ReplaceObject(GameObject old, GameObject newObject)
+ {
+ ReplaceObject(old.transform, newObject.transform);
+ }
+
+ public void ReplaceObject(Transform old, Transform newObject)
+ {
+ if (!_objectToOriginalPaths.TryGetValue(old, out var paths)) return;
+
+ if (_objectToOriginalPaths.ContainsKey(newObject))
+ {
+ _objectToOriginalPaths[newObject].AddRange(paths);
+ }
+ else
+ {
+ _objectToOriginalPaths[newObject] = paths;
+ }
+
+ _objectToOriginalPaths.Remove(old);
+ foreach (var path in paths)
+ {
+ _pathToObject[path] = newObject;
+ }
+ }
+
+ public string MapPath(string originalPath)
+ {
+ UpdateCache();
+
+ return _originalToMappedPath.GetValueOrDefault(originalPath, originalPath);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Editor/API/AnimatorServices/ObjectPathRemapper.cs.meta b/Editor/API/AnimatorServices/ObjectPathRemapper.cs.meta
new file mode 100644
index 0000000..24ad97b
--- /dev/null
+++ b/Editor/API/AnimatorServices/ObjectPathRemapper.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 555cbff33c8d467ea13832f7b650b15a
+timeCreated: 1731273374
\ No newline at end of file
diff --git a/Runtime/RuntimeUtil.cs b/Runtime/RuntimeUtil.cs
index 86ffbc8..de3d24a 100644
--- a/Runtime/RuntimeUtil.cs
+++ b/Runtime/RuntimeUtil.cs
@@ -1,9 +1,9 @@
using System;
using System.Collections.Generic;
using JetBrains.Annotations;
+using UnityEditor;
using UnityEngine;
using UnityEngine.SceneManagement;
-
#if NDMF_VRCSDK3_AVATARS
using VRC.SDK3.Avatars.Components;
#endif
@@ -43,7 +43,7 @@ internal static T GetOrAddComponent(this Component obj) where T : Component
/// Returns whether the editor is in play mode.
///
#if UNITY_EDITOR
- public static bool IsPlaying => UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode;
+ public static bool IsPlaying => EditorApplication.isPlayingOrWillChangePlaymode;
#else
public static bool IsPlaying => true;
#endif
@@ -56,22 +56,34 @@ internal static T GetOrAddComponent(this Component obj) where T : Component
///
[CanBeNull]
public static string RelativePath(GameObject root, GameObject child)
+ {
+ return RelativePath(root.transform, child.transform);
+ }
+
+ ///
+ /// Returns the relative path from root to child, or null is child is not a descendant of root.
+ ///
+ ///
+ ///
+ ///
+ [CanBeNull]
+ public static string RelativePath(Transform root, Transform child)
{
if (root == child) return "";
- List pathSegments = new List();
+ var pathSegments = new List();
while (child != root && child != null)
{
- pathSegments.Add(child.name);
- child = child.transform.parent?.gameObject;
+ pathSegments.Add(child.gameObject.name);
+ child = child.parent;
}
if (child == null && root != null) return null;
pathSegments.Reverse();
- return String.Join("/", pathSegments);
+ return string.Join("/", pathSegments);
}
-
+
///
/// Returns the path of a game object relative to the avatar root, or null if the avatar root could not be
/// located.