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.