From 95b9c2fcf669d62025cad9c374057e07cda5b2aa Mon Sep 17 00:00:00 2001 From: Thaina Date: Mon, 15 Jun 2026 17:36:46 +0700 Subject: [PATCH 1/7] get/set animation node state --- .../Tools/Animation/ControllerCreate.cs | 99 +++++++++++++++++++ .../Editor/Tools/Animation/ManageAnimation.cs | 4 +- Server/src/services/tools/manage_animation.py | 4 +- 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs b/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs index 328c18d78..0834535f3 100644 --- a/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs +++ b/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs @@ -422,6 +422,105 @@ public static object AssignToGameObject(JObject @params) }; } + // Reads node graph positions for every state in all layers (recurses into + // sub-state-machines). Returns [{ name, x, y, layer }] so a caller can analyze + // the current layout before sending back a revised one. + public static object GetStatePositions(JObject @params) + { + var controller = LoadController(@params); + if (controller == null) + return ControllerNotFoundError(@params); + + var nodes = new List(); + for (int li = 0; li < controller.layers.Length; li++) + CollectPositions(controller.layers[li].stateMachine, li, nodes); + + return new + { + success = true, + message = $"Read {nodes.Count} state position(s).", + data = new { count = nodes.Count, nodes } + }; + } + + private static void CollectPositions(AnimatorStateMachine sm, int layer, List outList) + { + var children = sm.states; + for (int i = 0; i < children.Length; i++) + outList.Add(new + { + name = children[i].state.name, + x = children[i].position.x, + y = children[i].position.y, + layer + }); + foreach (var sub in sm.stateMachines) + CollectPositions(sub.stateMachine, layer, outList); + } + + // Sets node graph positions from a 'positions' array of { name, x, y }. + // Matches states by name across all layers (recurses into sub-state-machines) + // and reassigns stateMachine.states so the edits persist on the asset. + public static object SetStatePositions(JObject @params) + { + var controller = LoadController(@params); + if (controller == null) + return ControllerNotFoundError(@params); + + if (!(@params["positions"] is JArray positions) || positions.Count == 0) + return new { success = false, message = "'positions' array is required: [{ name, x, y }, ...]" }; + + var want = new Dictionary(); + foreach (var token in positions) + { + string name = token["name"]?.ToString(); + if (string.IsNullOrEmpty(name)) + continue; + float x = token["x"]?.ToObject() ?? 0f; + float y = token["y"]?.ToObject() ?? 0f; + want[name] = new Vector2(x, y); + } + if (want.Count == 0) + return new { success = false, message = "No valid entries in 'positions' (each needs a 'name')." }; + + var matched = new HashSet(); + for (int li = 0; li < controller.layers.Length; li++) + ApplyPositions(controller.layers[li].stateMachine, want, matched); + + EditorUtility.SetDirty(controller); + AssetDatabase.SaveAssets(); + + var unmatched = want.Keys.Where(k => !matched.Contains(k)).ToList(); + return new + { + success = true, + message = $"Positioned {matched.Count} state(s); {unmatched.Count} name(s) unmatched.", + data = new + { + matched = matched.Count, + requested = want.Count, + unmatched + } + }; + } + + private static void ApplyPositions(AnimatorStateMachine sm, Dictionary want, HashSet matched) + { + var children = sm.states; + for (int i = 0; i < children.Length; i++) + { + if (want.TryGetValue(children[i].state.name, out var p)) + { + children[i].position = new Vector3(p.x, p.y, 0f); + matched.Add(children[i].state.name); + } + } + sm.states = children; // reassign so position edits persist + + foreach (var sub in sm.stateMachines) + ApplyPositions(sub.stateMachine, want, matched); + } + private static AnimatorController LoadController(JObject @params) { string controllerPath = @params["controllerPath"]?.ToString(); diff --git a/MCPForUnity/Editor/Tools/Animation/ManageAnimation.cs b/MCPForUnity/Editor/Tools/Animation/ManageAnimation.cs index 508994009..1d7d35ed0 100644 --- a/MCPForUnity/Editor/Tools/Animation/ManageAnimation.cs +++ b/MCPForUnity/Editor/Tools/Animation/ManageAnimation.cs @@ -217,6 +217,8 @@ private static object HandleControllerAction(JObject @params, string action) { case "create": return ControllerCreate.Create(@params); case "add_state": return ControllerCreate.AddState(@params); + case "set_state_positions": return ControllerCreate.SetStatePositions(@params); + case "get_state_positions": return ControllerCreate.GetStatePositions(@params); case "add_transition": return ControllerCreate.AddTransition(@params); case "add_parameter": return ControllerCreate.AddParameter(@params); case "get_info": return ControllerCreate.GetInfo(@params); @@ -228,7 +230,7 @@ private static object HandleControllerAction(JObject @params, string action) case "create_blend_tree_2d": return ControllerBlendTrees.CreateBlendTree2D(@params); case "add_blend_tree_child": return ControllerBlendTrees.AddBlendTreeChild(@params); default: - return new { success = false, message = $"Unknown controller action: {action}. Valid: create, add_state, add_transition, add_parameter, get_info, assign, add_layer, remove_layer, set_layer_weight, create_blend_tree_1d, create_blend_tree_2d, add_blend_tree_child" }; + return new { success = false, message = $"Unknown controller action: {action}. Valid: create, add_state, set_state_positions, get_state_positions, add_transition, add_parameter, get_info, assign, add_layer, remove_layer, set_layer_weight, create_blend_tree_1d, create_blend_tree_2d, add_blend_tree_child" }; } } diff --git a/Server/src/services/tools/manage_animation.py b/Server/src/services/tools/manage_animation.py index 7a66a247f..9eace2646 100644 --- a/Server/src/services/tools/manage_animation.py +++ b/Server/src/services/tools/manage_animation.py @@ -15,7 +15,9 @@ ] CONTROLLER_ACTIONS = [ - "controller_create", "controller_add_state", "controller_add_transition", + "controller_create", "controller_add_state", + "controller_set_state_positions", "controller_get_state_positions", + "controller_add_transition", "controller_add_parameter", "controller_get_info", "controller_assign", "controller_add_layer", "controller_remove_layer", "controller_set_layer_weight", "controller_create_blend_tree_1d", "controller_create_blend_tree_2d", "controller_add_blend_tree_child", From e4fbd3a4b028527e49fbf914fbaa18c446e72c8d Mon Sep 17 00:00:00 2001 From: Thaina Date: Mon, 15 Jun 2026 17:58:08 +0700 Subject: [PATCH 2/7] add pagination and undo --- .../Tools/Animation/ControllerCreate.cs | 67 ++++++++++++++----- Server/tests/test_manage_animation.py | 56 +++++++++++++++- 2 files changed, 105 insertions(+), 18 deletions(-) diff --git a/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs b/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs index 0834535f3..f9f3bc702 100644 --- a/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs +++ b/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs @@ -422,24 +422,44 @@ public static object AssignToGameObject(JObject @params) }; } - // Reads node graph positions for every state in all layers (recurses into - // sub-state-machines). Returns [{ name, x, y, layer }] so a caller can analyze - // the current layout before sending back a revised one. + // Reads node graph positions for every state (recurses into sub-state-machines). + // Returns [{ name, x, y, layer }] so a caller can analyze the current layout + // before sending back a revised one. Pass 'layerIndex' to scope to one layer; + // results are paged (page_size/cursor) since controllers can have many states. public static object GetStatePositions(JObject @params) { var controller = LoadController(@params); if (controller == null) return ControllerNotFoundError(@params); + int? layerFilter = @params["layerIndex"]?.ToObject(); + if (layerFilter.HasValue && (layerFilter < 0 || layerFilter >= controller.layers.Length)) + return new { success = false, message = $"Layer index {layerFilter} out of range (controller has {controller.layers.Length} layers)" }; + var nodes = new List(); for (int li = 0; li < controller.layers.Length; li++) + { + if (layerFilter.HasValue && li != layerFilter.Value) + continue; CollectPositions(controller.layers[li].stateMachine, li, nodes); + } + + var pagination = PaginationRequest.FromParams(@params, defaultPageSize: 50); + var paged = PaginationResponse.Create(nodes, pagination); return new { success = true, - message = $"Read {nodes.Count} state position(s).", - data = new { count = nodes.Count, nodes } + message = $"Read {paged.Items.Count} of {paged.TotalCount} state position(s).", + data = new + { + count = paged.TotalCount, + nodes = paged.Items, + pageSize = paged.PageSize, + cursor = paged.Cursor, + nextCursor = paged.NextCursor, + hasMore = paged.HasMore + } }; } @@ -458,9 +478,11 @@ private static void CollectPositions(AnimatorStateMachine sm, int layer, List(); + // Key is "layer:name" when scoped to a layer, else "*:name" to match any layer. var want = new Dictionary(); foreach (var token in positions) { @@ -478,14 +503,16 @@ public static object SetStatePositions(JObject @params) continue; float x = token["x"]?.ToObject() ?? 0f; float y = token["y"]?.ToObject() ?? 0f; - want[name] = new Vector2(x, y); + int? layer = token["layer"]?.ToObject() ?? defaultLayer; + want[$"{(layer.HasValue ? layer.Value.ToString() : "*")}:{name}"] = new Vector2(x, y); } if (want.Count == 0) return new { success = false, message = "No valid entries in 'positions' (each needs a 'name')." }; var matched = new HashSet(); + Undo.RecordObject(controller, "Set State Positions"); for (int li = 0; li < controller.layers.Length; li++) - ApplyPositions(controller.layers[li].stateMachine, want, matched); + ApplyPositions(controller.layers[li].stateMachine, li, want, matched); EditorUtility.SetDirty(controller); AssetDatabase.SaveAssets(); @@ -494,7 +521,7 @@ public static object SetStatePositions(JObject @params) return new { success = true, - message = $"Positioned {matched.Count} state(s); {unmatched.Count} name(s) unmatched.", + message = $"Positioned {matched.Count} state(s); {unmatched.Count} key(s) unmatched.", data = new { matched = matched.Count, @@ -504,21 +531,27 @@ public static object SetStatePositions(JObject @params) }; } - private static void ApplyPositions(AnimatorStateMachine sm, Dictionary want, HashSet matched) + private static void ApplyPositions(AnimatorStateMachine sm, int layer, Dictionary want, HashSet matched) { var children = sm.states; for (int i = 0; i < children.Length; i++) { - if (want.TryGetValue(children[i].state.name, out var p)) + string name = children[i].state.name; + // Prefer a layer-scoped entry; fall back to the any-layer entry. + string scopedKey = $"{layer}:{name}"; + string anyKey = $"*:{name}"; + string key = want.ContainsKey(scopedKey) ? scopedKey + : want.ContainsKey(anyKey) ? anyKey : null; + if (key != null) { - children[i].position = new Vector3(p.x, p.y, 0f); - matched.Add(children[i].state.name); + children[i].position = new Vector3(want[key].x, want[key].y, 0f); + matched.Add(key); } } sm.states = children; // reassign so position edits persist foreach (var sub in sm.stateMachines) - ApplyPositions(sub.stateMachine, want, matched); + ApplyPositions(sub.stateMachine, layer, want, matched); } private static AnimatorController LoadController(JObject @params) diff --git a/Server/tests/test_manage_animation.py b/Server/tests/test_manage_animation.py index 85c4f9651..455d58fb3 100644 --- a/Server/tests/test_manage_animation.py +++ b/Server/tests/test_manage_animation.py @@ -73,7 +73,9 @@ def test_expected_animator_actions_present(self): assert expected.issubset(set(ANIMATOR_ACTIONS)) def test_expected_controller_actions_present(self): - expected = {"controller_create", "controller_add_state", "controller_add_transition", + expected = {"controller_create", "controller_add_state", + "controller_set_state_positions", "controller_get_state_positions", + "controller_add_transition", "controller_add_parameter", "controller_get_info", "controller_assign", "controller_add_layer", "controller_remove_layer", "controller_set_layer_weight", "controller_create_blend_tree_1d", "controller_create_blend_tree_2d", "controller_add_blend_tree_child"} @@ -147,6 +149,58 @@ def test_no_prefix_action_suggests_valid_prefixes(self): assert "clip_" in result["message"] +class TestStatePositionActions: + """State-position actions dispatch through manage_animation with the right payload.""" + + def _dispatch(self, action, properties=None): + from services.tools import manage_animation as mod + + ctx = MagicMock() + ctx.get_state = AsyncMock(return_value=None) + with patch.object(mod, "get_unity_instance_from_context", AsyncMock(return_value=None)): + with patch.object(mod, "send_with_unity_instance", + AsyncMock(return_value={"success": True, "data": {}})) as mock_send: + asyncio.run(mod.manage_animation( + ctx, action=action, + controller_path="Assets/Anim/Player.controller", + properties=properties, + )) + # send_with_unity_instance(send_fn, unity_instance, command, params) + return mock_send.call_args[0][2], mock_send.call_args[0][3] + + def test_get_state_positions_dispatches(self): + command, params = self._dispatch("controller_get_state_positions") + assert command == "manage_animation" + assert params["action"] == "controller_get_state_positions" + assert params["controllerPath"] == "Assets/Anim/Player.controller" + + def test_get_state_positions_forwards_paging_and_layer(self): + _, params = self._dispatch( + "controller_get_state_positions", + properties={"layerIndex": 1, "page_size": 10, "cursor": 20}, + ) + assert params["properties"]["layerIndex"] == 1 + assert params["properties"]["page_size"] == 10 + assert params["properties"]["cursor"] == 20 + + def test_set_state_positions_forwards_positions(self): + positions = [{"name": "Idle", "x": 100, "y": 0}, {"name": "Walk", "x": 300, "y": 0}] + _, params = self._dispatch( + "controller_set_state_positions", properties={"positions": positions} + ) + assert params["action"] == "controller_set_state_positions" + assert params["properties"]["positions"] == positions + + def test_set_state_positions_forwards_layer_scoping(self): + positions = [{"name": "Idle", "x": 0, "y": 0, "layer": 1}] + _, params = self._dispatch( + "controller_set_state_positions", + properties={"positions": positions, "layerIndex": 0}, + ) + assert params["properties"]["positions"][0]["layer"] == 1 + assert params["properties"]["layerIndex"] == 0 + + # ============================================================================= # CLI Command Parameter Building # ============================================================================= From 85a06b295221d40c03927fff804a9c73338622f7 Mon Sep 17 00:00:00 2001 From: Thaina Date: Tue, 16 Jun 2026 13:54:56 +0700 Subject: [PATCH 3/7] use instanceID as main key Add long entityID for lossless convertion --- .../Tools/Animation/ControllerCreate.cs | 65 +++++++++---------- .../Runtime/Helpers/UnityObjectIdCompat.cs | 41 ++++++++++++ Server/tests/test_manage_animation.py | 15 ++--- 3 files changed, 77 insertions(+), 44 deletions(-) diff --git a/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs b/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs index f9f3bc702..e30f3998a 100644 --- a/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs +++ b/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs @@ -4,6 +4,7 @@ using System.Linq; using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Helpers; +using MCPForUnity.Runtime.Helpers; using UnityEditor; using UnityEditor.Animations; using UnityEngine; @@ -423,9 +424,11 @@ public static object AssignToGameObject(JObject @params) } // Reads node graph positions for every state (recurses into sub-state-machines). - // Returns [{ name, x, y, layer }] so a caller can analyze the current layout - // before sending back a revised one. Pass 'layerIndex' to scope to one layer; - // results are paged (page_size/cursor) since controllers can have many states. + // Returns [{ name, instanceId, x, y, layer }] so a caller can analyze the current + // layout before sending back a revised one. 'instanceId' round-trips into + // set_state_positions for an unambiguous match (duplicate names are fine). + // Pass 'layerIndex' to scope to one layer; results are paged (page_size/cursor) + // since controllers can have many states. public static object GetStatePositions(JObject @params) { var controller = LoadController(@params); @@ -470,6 +473,7 @@ private static void CollectPositions(AnimatorStateMachine sm, int layer, List(); - - // Key is "layer:name" when scoped to a layer, else "*:name" to match any layer. - var want = new Dictionary(); + var want = new Dictionary(); foreach (var token in positions) { - string name = token["name"]?.ToString(); - if (string.IsNullOrEmpty(name)) + if (!(token is JObject entry)) + continue; + ulong? instanceId = entry["instanceId"]?.ToObject(); + if (!instanceId.HasValue) continue; - float x = token["x"]?.ToObject() ?? 0f; - float y = token["y"]?.ToObject() ?? 0f; - int? layer = token["layer"]?.ToObject() ?? defaultLayer; - want[$"{(layer.HasValue ? layer.Value.ToString() : "*")}:{name}"] = new Vector2(x, y); + float x = entry["x"]?.ToObject() ?? 0f; + float y = entry["y"]?.ToObject() ?? 0f; + want[instanceId.Value] = new Vector2(x, y); } if (want.Count == 0) - return new { success = false, message = "No valid entries in 'positions' (each needs a 'name')." }; + return new { success = false, message = "No valid entries in 'positions' (each needs an 'instanceId')." }; - var matched = new HashSet(); + var matched = new HashSet(); Undo.RecordObject(controller, "Set State Positions"); for (int li = 0; li < controller.layers.Length; li++) - ApplyPositions(controller.layers[li].stateMachine, li, want, matched); + ApplyPositions(controller.layers[li].stateMachine, want, matched); EditorUtility.SetDirty(controller); AssetDatabase.SaveAssets(); @@ -521,7 +523,7 @@ public static object SetStatePositions(JObject @params) return new { success = true, - message = $"Positioned {matched.Count} state(s); {unmatched.Count} key(s) unmatched.", + message = $"Positioned {matched.Count} state(s); {unmatched.Count} id(s) unmatched.", data = new { matched = matched.Count, @@ -531,27 +533,22 @@ public static object SetStatePositions(JObject @params) }; } - private static void ApplyPositions(AnimatorStateMachine sm, int layer, Dictionary want, HashSet matched) + private static void ApplyPositions(AnimatorStateMachine sm, Dictionary want, HashSet matched) { var children = sm.states; for (int i = 0; i < children.Length; i++) { - string name = children[i].state.name; - // Prefer a layer-scoped entry; fall back to the any-layer entry. - string scopedKey = $"{layer}:{name}"; - string anyKey = $"*:{name}"; - string key = want.ContainsKey(scopedKey) ? scopedKey - : want.ContainsKey(anyKey) ? anyKey : null; - if (key != null) + ulong? id = children[i].state.GetInstanceIDLongCompat(); + if (id.HasValue && want.TryGetValue(id.Value, out var p)) { - children[i].position = new Vector3(want[key].x, want[key].y, 0f); - matched.Add(key); + children[i].position = new Vector3(p.x, p.y, 0f); + matched.Add(id.Value); } } sm.states = children; // reassign so position edits persist foreach (var sub in sm.stateMachines) - ApplyPositions(sub.stateMachine, layer, want, matched); + ApplyPositions(sub.stateMachine, want, matched); } private static AnimatorController LoadController(JObject @params) diff --git a/MCPForUnity/Runtime/Helpers/UnityObjectIdCompat.cs b/MCPForUnity/Runtime/Helpers/UnityObjectIdCompat.cs index a10a99167..b6b78a918 100644 --- a/MCPForUnity/Runtime/Helpers/UnityObjectIdCompat.cs +++ b/MCPForUnity/Runtime/Helpers/UnityObjectIdCompat.cs @@ -12,7 +12,9 @@ namespace MCPForUnity.Runtime.Helpers /// Version-gated wrappers for the InstanceID ↔ EntityId migration introduced in Unity 6.5 /// and tightened in 6.6. /// Forward (Object → int): + /// Forward (Object → ulong, lossless): /// Reverse (int → Object, Editor-only): + /// Reverse (ulong → Object, Editor-only): /// public static class UnityObjectIdCompat { @@ -36,6 +38,27 @@ public static int GetInstanceIDCompat(this Object obj) #endif } + /// + /// Like but returns the full handle without the + /// lossy int truncation: on 6.5+ the EntityId's underlying ulong, on older versions + /// the int instance ID widened to ulong. Returns null for a null object. Use when + /// the handle must round-trip exactly (e.g. matching the same object back across a + /// JSON request). + /// + public static ulong? GetInstanceIDLongCompat(this Object obj) + { + if (obj == null) + { + return null; + } + +#if UNITY_6000_5_OR_NEWER + return EntityId.ToULong(obj.GetEntityId()); +#else + return unchecked((ulong)obj.GetInstanceID()); +#endif + } + #if UNITY_EDITOR #if UNITY_6000_6_OR_NEWER private static MethodInfo _instanceIdToObject; @@ -68,6 +91,24 @@ public static Object InstanceIDToObjectCompat(int instanceId) return EditorUtility.EntityIdToObject(instanceId); #else return EditorUtility.InstanceIDToObject(instanceId); +#endif + } + + /// + /// Resolves a ulong handle (from ) back to a + /// UnityEngine.Object. Disambiguates by Unity version, not by inspecting the numeric + /// range — a wrapped-negative int and a genuine 64-bit EntityId can occupy the same + /// high band, so range checks cannot tell them apart. + /// 6.5+ : the handle is the EntityId's ulong — resolve via EntityId.FromULong. + /// Pre-6.5 : the handle is an int instance ID round-tripped through an unchecked + /// ulong cast (negatives are valid) — cast back and use the int resolver. + /// + public static Object InstanceIDToObjectLongCompat(ulong instanceId) + { +#if UNITY_6000_5_OR_NEWER + return EditorUtility.EntityIdToObject(EntityId.FromULong(instanceId)); +#else + return InstanceIDToObjectCompat(unchecked((int)instanceId)); #endif } #endif diff --git a/Server/tests/test_manage_animation.py b/Server/tests/test_manage_animation.py index 455d58fb3..a600490b8 100644 --- a/Server/tests/test_manage_animation.py +++ b/Server/tests/test_manage_animation.py @@ -184,21 +184,16 @@ def test_get_state_positions_forwards_paging_and_layer(self): assert params["properties"]["cursor"] == 20 def test_set_state_positions_forwards_positions(self): - positions = [{"name": "Idle", "x": 100, "y": 0}, {"name": "Walk", "x": 300, "y": 0}] + # instanceId round-trips from get_state_positions; can exceed 32-bit on Unity 6.5+ + # (EntityId), and JSON / Python int carry the full 64-bit value losslessly. + positions = [{"instanceId": 8412, "x": 100, "y": 0}, + {"instanceId": 18446744073709551000, "x": 300, "y": 0}] _, params = self._dispatch( "controller_set_state_positions", properties={"positions": positions} ) assert params["action"] == "controller_set_state_positions" assert params["properties"]["positions"] == positions - - def test_set_state_positions_forwards_layer_scoping(self): - positions = [{"name": "Idle", "x": 0, "y": 0, "layer": 1}] - _, params = self._dispatch( - "controller_set_state_positions", - properties={"positions": positions, "layerIndex": 0}, - ) - assert params["properties"]["positions"][0]["layer"] == 1 - assert params["properties"]["layerIndex"] == 0 + assert params["properties"]["positions"][1]["instanceId"] == 18446744073709551000 # ============================================================================= From 3787ea313e068f276d250f053bfabcca9cc1b383 Mon Sep 17 00:00:00 2001 From: Thaina Date: Wed, 17 Jun 2026 13:54:09 +0700 Subject: [PATCH 4/7] convert to general get/set_state_properties add remove_transition for editing --- .../Tools/Animation/ControllerCreate.cs | 196 ++++++++++++++---- .../Editor/Tools/Animation/ManageAnimation.cs | 7 +- Server/src/services/tools/manage_animation.py | 4 +- Server/tests/test_manage_animation.py | 47 +++-- 4 files changed, 192 insertions(+), 62 deletions(-) diff --git a/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs b/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs index e30f3998a..6e3029ed8 100644 --- a/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs +++ b/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs @@ -228,6 +228,73 @@ public static object AddTransition(JObject @params) }; } + // Removes transitions from 'fromState' (or AnyState) to 'toState' in a layer. + // If 'toState' is omitted, removes ALL outgoing transitions from 'fromState'. + // Use with add_transition to "edit" a transition: remove then re-add with new timing. + public static object RemoveTransition(JObject @params) + { + var controller = LoadController(@params); + if (controller == null) + return ControllerNotFoundError(@params); + + string fromStateName = @params["fromState"]?.ToString(); + if (string.IsNullOrEmpty(fromStateName)) + return new { success = false, message = "'fromState' is required" }; + string toStateName = @params["toState"]?.ToString(); // optional + + int layerIndex = @params["layerIndex"]?.ToObject() ?? 0; + if (layerIndex < 0 || layerIndex >= controller.layers.Length) + return new { success = false, message = $"Layer index {layerIndex} out of range" }; + + var rootStateMachine = controller.layers[layerIndex].stateMachine; + + bool isAnyState = string.Equals(fromStateName, "AnyState", StringComparison.OrdinalIgnoreCase) + || string.Equals(fromStateName, "Any", StringComparison.OrdinalIgnoreCase) + || string.Equals(fromStateName, "Any State", StringComparison.OrdinalIgnoreCase); + + int removed = 0; + + if (isAnyState) + { + foreach (var t in rootStateMachine.anyStateTransitions.ToArray()) + { + if (string.IsNullOrEmpty(toStateName) || (t.destinationState != null && t.destinationState.name == toStateName)) + { + rootStateMachine.RemoveAnyStateTransition(t); + removed++; + } + } + fromStateName = "AnyState"; + } + else + { + AnimatorState fromState = null; + foreach (var cs in rootStateMachine.states) + if (cs.state.name == fromStateName) fromState = cs.state; + if (fromState == null) + return new { success = false, message = $"State '{fromStateName}' not found in layer {layerIndex}" }; + + foreach (var t in fromState.transitions.ToArray()) + { + if (string.IsNullOrEmpty(toStateName) || (t.destinationState != null && t.destinationState.name == toStateName)) + { + fromState.RemoveTransition(t); + removed++; + } + } + } + + EditorUtility.SetDirty(controller); + AssetDatabase.SaveAssets(); + + return new + { + success = true, + message = $"Removed {removed} transition(s) from '{fromStateName}'" + (string.IsNullOrEmpty(toStateName) ? "" : $" to '{toStateName}'") + ".", + data = new { fromState = fromStateName, toState = toStateName, removed } + }; + } + public static object AddParameter(JObject @params) { var controller = LoadController(@params); @@ -423,13 +490,13 @@ public static object AssignToGameObject(JObject @params) }; } - // Reads node graph positions for every state (recurses into sub-state-machines). - // Returns [{ name, instanceId, x, y, layer }] so a caller can analyze the current - // layout before sending back a revised one. 'instanceId' round-trips into - // set_state_positions for an unambiguous match (duplicate names are fine). - // Pass 'layerIndex' to scope to one layer; results are paged (page_size/cursor) - // since controllers can have many states. - public static object GetStatePositions(JObject @params) + // Reads per-state properties for every state (recurses into sub-state-machines). + // Returns [{ name, instanceId, layer, x, y, speed, motionInstanceId, motionName, + // motionType }]. 'instanceId' round-trips into set_state_properties for an exact + // match (duplicate names are fine); 'motionInstanceId' lets a caller transfer a + // Motion (incl. FBX-embedded clips) to another state BY REFERENCE - no asset path. + // Pass 'layerIndex' to scope to one layer; results are paged (page_size/cursor). + public static object GetStateProperties(JObject @params) { var controller = LoadController(@params); if (controller == null) @@ -444,7 +511,7 @@ public static object GetStatePositions(JObject @params) { if (layerFilter.HasValue && li != layerFilter.Value) continue; - CollectPositions(controller.layers[li].stateMachine, li, nodes); + CollectProperties(controller.layers[li].stateMachine, li, nodes); } var pagination = PaginationRequest.FromParams(@params, defaultPageSize: 50); @@ -453,7 +520,7 @@ public static object GetStatePositions(JObject @params) return new { success = true, - message = $"Read {paged.Items.Count} of {paged.TotalCount} state position(s).", + message = $"Read {paged.Items.Count} of {paged.TotalCount} state(s).", data = new { count = paged.TotalCount, @@ -466,55 +533,63 @@ public static object GetStatePositions(JObject @params) }; } - private static void CollectPositions(AnimatorStateMachine sm, int layer, List outList) + private static void CollectProperties(AnimatorStateMachine sm, int layer, List outList) { var children = sm.states; for (int i = 0; i < children.Length; i++) + { + var st = children[i].state; + var motion = st.motion; outList.Add(new { - name = children[i].state.name, - instanceId = children[i].state.GetInstanceIDLongCompat(), + name = st.name, + instanceId = st.GetInstanceIDLongCompat(), + layer, x = children[i].position.x, y = children[i].position.y, - layer + speed = st.speed, + motionInstanceId = motion != null ? motion.GetInstanceIDLongCompat() : (ulong?)null, + motionName = motion != null ? motion.name : null, + motionType = motion != null ? motion.GetType().Name : null }); + } foreach (var sub in sm.stateMachines) - CollectPositions(sub.stateMachine, layer, outList); + CollectProperties(sub.stateMachine, layer, outList); } - // Sets node graph positions from a 'positions' array of { instanceId, x, y }. - // States are matched by 'instanceId' (from get_state_positions) for an exact, - // unambiguous hit even when names repeat across layers or sub-state-machines. - // Recurses into sub-state-machines and reassigns stateMachine.states so the - // edits persist on the asset. - public static object SetStatePositions(JObject @params) + // Sets per-state properties from a 'states' array of { instanceId, [x], [y], + // [speed], [motionInstanceId] }. States are matched by 'instanceId' for an exact, + // unambiguous hit. Each field is OPTIONAL - only provided fields are written, so the + // same call can move nodes, retime speed, and/or assign motion. 'motionInstanceId' + // is resolved to a Motion via UnityObjectIdCompat.InstanceIDToObjectLongCompat and + // assigned BY REFERENCE (works for FBX sub-asset clips - no asset-path lookup). Recurses into + // sub-state-machines and reassigns stateMachine.states so edits persist. + public static object SetStateProperties(JObject @params) { var controller = LoadController(@params); if (controller == null) return ControllerNotFoundError(@params); - if (!(@params["positions"] is JArray positions) || positions.Count == 0) - return new { success = false, message = "'positions' array is required: [{ instanceId, x, y }, ...]" }; + if (!(@params["states"] is JArray arr) || arr.Count == 0) + return new { success = false, message = "'states' array is required: [{ instanceId, x?, y?, speed?, motionInstanceId? }, ...]" }; - var want = new Dictionary(); - foreach (var token in positions) + var want = new Dictionary(); + foreach (var token in arr) { if (!(token is JObject entry)) continue; ulong? instanceId = entry["instanceId"]?.ToObject(); - if (!instanceId.HasValue) - continue; - float x = entry["x"]?.ToObject() ?? 0f; - float y = entry["y"]?.ToObject() ?? 0f; - want[instanceId.Value] = new Vector2(x, y); + if (instanceId.HasValue) + want[instanceId.Value] = entry; } if (want.Count == 0) - return new { success = false, message = "No valid entries in 'positions' (each needs an 'instanceId')." }; + return new { success = false, message = "No valid entries (each needs an 'instanceId')." }; var matched = new HashSet(); - Undo.RecordObject(controller, "Set State Positions"); + var motionFailures = new List(); + Undo.RecordObject(controller, "Set State Properties"); for (int li = 0; li < controller.layers.Length; li++) - ApplyPositions(controller.layers[li].stateMachine, want, matched); + ApplyProperties(controller.layers[li].stateMachine, want, matched, motionFailures); EditorUtility.SetDirty(controller); AssetDatabase.SaveAssets(); @@ -523,32 +598,73 @@ public static object SetStatePositions(JObject @params) return new { success = true, - message = $"Positioned {matched.Count} state(s); {unmatched.Count} id(s) unmatched.", + message = $"Updated {matched.Count} state(s); {unmatched.Count} id(s) unmatched; {motionFailures.Count} motion ref(s) failed.", data = new { matched = matched.Count, requested = want.Count, - unmatched + unmatched, + motionFailures } }; } - private static void ApplyPositions(AnimatorStateMachine sm, Dictionary want, HashSet matched) + private static void ApplyProperties(AnimatorStateMachine sm, Dictionary want, HashSet matched, List motionFailures) { var children = sm.states; for (int i = 0; i < children.Length; i++) { ulong? id = children[i].state.GetInstanceIDLongCompat(); - if (id.HasValue && want.TryGetValue(id.Value, out var p)) + if (!id.HasValue || !want.TryGetValue(id.Value, out var entry)) + continue; + + var st = children[i].state; + + // Position (x and/or y) - keep the unspecified axis unchanged. + if (entry["x"] != null || entry["y"] != null) { - children[i].position = new Vector3(p.x, p.y, 0f); - matched.Add(id.Value); + var pos = children[i].position; + float x = entry["x"]?.ToObject() ?? pos.x; + float y = entry["y"]?.ToObject() ?? pos.y; + children[i].position = new Vector3(x, y, 0f); } + + // Speed + if (entry["speed"] != null) + st.speed = entry["speed"].ToObject(); + + // Motion by reference (resolve instanceId -> Motion object). 0/null clears it. + if (entry["motionInstanceId"] != null) + { + var token = entry["motionInstanceId"]; + if (token.Type == JTokenType.Null) + { + st.motion = null; + } + else + { + ulong refId = token.ToObject(); + if (refId == 0UL) + { + st.motion = null; + } + else + { + var obj = UnityObjectIdCompat.InstanceIDToObjectLongCompat(refId) as Motion; + if (obj != null) + st.motion = obj; + else + motionFailures.Add(new { instanceId = id.Value, motionInstanceId = refId }); + } + } + } + + matched.Add(id.Value); } - sm.states = children; // reassign so position edits persist + sm.states = children; // reassign so edits persist foreach (var sub in sm.stateMachines) - ApplyPositions(sub.stateMachine, want, matched); + ApplyProperties(sub.stateMachine, want, matched, motionFailures); } private static AnimatorController LoadController(JObject @params) diff --git a/MCPForUnity/Editor/Tools/Animation/ManageAnimation.cs b/MCPForUnity/Editor/Tools/Animation/ManageAnimation.cs index 1d7d35ed0..404c0c0da 100644 --- a/MCPForUnity/Editor/Tools/Animation/ManageAnimation.cs +++ b/MCPForUnity/Editor/Tools/Animation/ManageAnimation.cs @@ -217,9 +217,10 @@ private static object HandleControllerAction(JObject @params, string action) { case "create": return ControllerCreate.Create(@params); case "add_state": return ControllerCreate.AddState(@params); - case "set_state_positions": return ControllerCreate.SetStatePositions(@params); - case "get_state_positions": return ControllerCreate.GetStatePositions(@params); + case "set_state_properties": return ControllerCreate.SetStateProperties(@params); + case "get_state_properties": return ControllerCreate.GetStateProperties(@params); case "add_transition": return ControllerCreate.AddTransition(@params); + case "remove_transition": return ControllerCreate.RemoveTransition(@params); case "add_parameter": return ControllerCreate.AddParameter(@params); case "get_info": return ControllerCreate.GetInfo(@params); case "assign": return ControllerCreate.AssignToGameObject(@params); @@ -230,7 +231,7 @@ private static object HandleControllerAction(JObject @params, string action) case "create_blend_tree_2d": return ControllerBlendTrees.CreateBlendTree2D(@params); case "add_blend_tree_child": return ControllerBlendTrees.AddBlendTreeChild(@params); default: - return new { success = false, message = $"Unknown controller action: {action}. Valid: create, add_state, set_state_positions, get_state_positions, add_transition, add_parameter, get_info, assign, add_layer, remove_layer, set_layer_weight, create_blend_tree_1d, create_blend_tree_2d, add_blend_tree_child" }; + return new { success = false, message = $"Unknown controller action: {action}. Valid: create, add_state, set_state_properties, get_state_properties, add_transition, remove_transition, add_parameter, get_info, assign, add_layer, remove_layer, set_layer_weight, create_blend_tree_1d, create_blend_tree_2d, add_blend_tree_child" }; } } diff --git a/Server/src/services/tools/manage_animation.py b/Server/src/services/tools/manage_animation.py index 9eace2646..eee9e28aa 100644 --- a/Server/src/services/tools/manage_animation.py +++ b/Server/src/services/tools/manage_animation.py @@ -16,8 +16,8 @@ CONTROLLER_ACTIONS = [ "controller_create", "controller_add_state", - "controller_set_state_positions", "controller_get_state_positions", - "controller_add_transition", + "controller_set_state_properties", "controller_get_state_properties", + "controller_add_transition", "controller_remove_transition", "controller_add_parameter", "controller_get_info", "controller_assign", "controller_add_layer", "controller_remove_layer", "controller_set_layer_weight", "controller_create_blend_tree_1d", "controller_create_blend_tree_2d", "controller_add_blend_tree_child", diff --git a/Server/tests/test_manage_animation.py b/Server/tests/test_manage_animation.py index a600490b8..80e818112 100644 --- a/Server/tests/test_manage_animation.py +++ b/Server/tests/test_manage_animation.py @@ -74,8 +74,8 @@ def test_expected_animator_actions_present(self): def test_expected_controller_actions_present(self): expected = {"controller_create", "controller_add_state", - "controller_set_state_positions", "controller_get_state_positions", - "controller_add_transition", + "controller_set_state_properties", "controller_get_state_properties", + "controller_add_transition", "controller_remove_transition", "controller_add_parameter", "controller_get_info", "controller_assign", "controller_add_layer", "controller_remove_layer", "controller_set_layer_weight", "controller_create_blend_tree_1d", "controller_create_blend_tree_2d", "controller_add_blend_tree_child"} @@ -149,8 +149,8 @@ def test_no_prefix_action_suggests_valid_prefixes(self): assert "clip_" in result["message"] -class TestStatePositionActions: - """State-position actions dispatch through manage_animation with the right payload.""" +class TestStatePropertyActions: + """State-property actions dispatch through manage_animation with the right payload.""" def _dispatch(self, action, properties=None): from services.tools import manage_animation as mod @@ -168,32 +168,45 @@ def _dispatch(self, action, properties=None): # send_with_unity_instance(send_fn, unity_instance, command, params) return mock_send.call_args[0][2], mock_send.call_args[0][3] - def test_get_state_positions_dispatches(self): - command, params = self._dispatch("controller_get_state_positions") + def test_get_state_properties_dispatches(self): + command, params = self._dispatch("controller_get_state_properties") assert command == "manage_animation" - assert params["action"] == "controller_get_state_positions" + assert params["action"] == "controller_get_state_properties" assert params["controllerPath"] == "Assets/Anim/Player.controller" - def test_get_state_positions_forwards_paging_and_layer(self): + def test_get_state_properties_forwards_paging_and_layer(self): _, params = self._dispatch( - "controller_get_state_positions", + "controller_get_state_properties", properties={"layerIndex": 1, "page_size": 10, "cursor": 20}, ) assert params["properties"]["layerIndex"] == 1 assert params["properties"]["page_size"] == 10 assert params["properties"]["cursor"] == 20 - def test_set_state_positions_forwards_positions(self): - # instanceId round-trips from get_state_positions; can exceed 32-bit on Unity 6.5+ + def test_set_state_properties_forwards_states(self): + # instanceId round-trips from get_state_properties; can exceed 32-bit on Unity 6.5+ # (EntityId), and JSON / Python int carry the full 64-bit value losslessly. - positions = [{"instanceId": 8412, "x": 100, "y": 0}, - {"instanceId": 18446744073709551000, "x": 300, "y": 0}] + # motionInstanceId transfers a Motion by reference (incl. FBX-embedded clips). + states = [{"instanceId": 8412, "x": 100, "y": 0, "speed": 1.5}, + {"instanceId": 18446744073709551000, "motionInstanceId": 9001}] _, params = self._dispatch( - "controller_set_state_positions", properties={"positions": positions} + "controller_set_state_properties", properties={"states": states} ) - assert params["action"] == "controller_set_state_positions" - assert params["properties"]["positions"] == positions - assert params["properties"]["positions"][1]["instanceId"] == 18446744073709551000 + assert params["action"] == "controller_set_state_properties" + assert params["properties"]["states"] == states + assert params["properties"]["states"][0]["instanceId"] == 8412 + assert params["properties"]["states"][1]["instanceId"] == 18446744073709551000 + + def test_remove_transition_dispatches(self): + # fromState required; toState optional (omit to remove all outgoing transitions). + _, params = self._dispatch( + "controller_remove_transition", + properties={"fromState": "Idle", "toState": "Walk", "layerIndex": 0}, + ) + assert params["action"] == "controller_remove_transition" + assert params["properties"]["fromState"] == "Idle" + assert params["properties"]["toState"] == "Walk" + assert params["properties"]["layerIndex"] == 0 # ============================================================================= From 6d316098d71edfd9a2cd1855d700275e5e3e441f Mon Sep 17 00:00:00 2001 From: Thaina Date: Wed, 17 Jun 2026 18:21:30 +0700 Subject: [PATCH 5/7] change ID to stringID to ensure lossless in every round trip --- .../Tools/Animation/ControllerCreate.cs | 57 +++++++++---------- .../Runtime/Helpers/UnityObjectIdCompat.cs | 51 ++++++++++------- Server/tests/test_manage_animation.py | 15 ++--- 3 files changed, 65 insertions(+), 58 deletions(-) diff --git a/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs b/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs index 6e3029ed8..126d4bb3a 100644 --- a/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs +++ b/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs @@ -543,12 +543,12 @@ private static void CollectProperties(AnimatorStateMachine sm, int layer, List(); + var want = new Dictionary(); foreach (var token in arr) { if (!(token is JObject entry)) continue; - ulong? instanceId = entry["instanceId"]?.ToObject(); - if (instanceId.HasValue) - want[instanceId.Value] = entry; + string instanceId = entry["instanceId"]?.ToString(); + if (!string.IsNullOrEmpty(instanceId)) + want[instanceId] = entry; } if (want.Count == 0) return new { success = false, message = "No valid entries (each needs an 'instanceId')." }; - var matched = new HashSet(); + var matched = new HashSet(); var motionFailures = new List(); Undo.RecordObject(controller, "Set State Properties"); for (int li = 0; li < controller.layers.Length; li++) @@ -609,13 +611,13 @@ public static object SetStateProperties(JObject @params) }; } - private static void ApplyProperties(AnimatorStateMachine sm, Dictionary want, HashSet matched, List motionFailures) + private static void ApplyProperties(AnimatorStateMachine sm, Dictionary want, HashSet matched, List motionFailures) { var children = sm.states; for (int i = 0; i < children.Length; i++) { - ulong? id = children[i].state.GetInstanceIDLongCompat(); - if (!id.HasValue || !want.TryGetValue(id.Value, out var entry)) + string id = children[i].state.GetInstanceIDString(); + if (string.IsNullOrEmpty(id) || !want.TryGetValue(id, out var entry)) continue; var st = children[i].state; @@ -633,33 +635,26 @@ private static void ApplyProperties(AnimatorStateMachine sm, Dictionary(); - // Motion by reference (resolve instanceId -> Motion object). 0/null clears it. + // Motion by reference (resolve string handle -> Motion object). empty/null/"0" clears it. if (entry["motionInstanceId"] != null) { var token = entry["motionInstanceId"]; - if (token.Type == JTokenType.Null) + string refId = token.Type == JTokenType.Null ? null : token.ToString(); + if (string.IsNullOrEmpty(refId) || refId == "0") { st.motion = null; } else { - ulong refId = token.ToObject(); - if (refId == 0UL) - { - st.motion = null; - } + var obj = UnityObjectIdCompat.InstanceIDFromString(refId) as Motion; + if (obj != null) + st.motion = obj; else - { - var obj = UnityObjectIdCompat.InstanceIDToObjectLongCompat(refId) as Motion; - if (obj != null) - st.motion = obj; - else - motionFailures.Add(new { instanceId = id.Value, motionInstanceId = refId }); - } + motionFailures.Add(new { instanceId = id, motionInstanceId = refId }); } } - matched.Add(id.Value); + matched.Add(id); } sm.states = children; // reassign so edits persist diff --git a/MCPForUnity/Runtime/Helpers/UnityObjectIdCompat.cs b/MCPForUnity/Runtime/Helpers/UnityObjectIdCompat.cs index b6b78a918..cda25a444 100644 --- a/MCPForUnity/Runtime/Helpers/UnityObjectIdCompat.cs +++ b/MCPForUnity/Runtime/Helpers/UnityObjectIdCompat.cs @@ -12,9 +12,9 @@ namespace MCPForUnity.Runtime.Helpers /// Version-gated wrappers for the InstanceID ↔ EntityId migration introduced in Unity 6.5 /// and tightened in 6.6. /// Forward (Object → int): - /// Forward (Object → ulong, lossless): + /// Forward (Object → string, lossless wire format): /// Reverse (int → Object, Editor-only): - /// Reverse (ulong → Object, Editor-only): + /// Reverse (string → Object, Editor-only): /// public static class UnityObjectIdCompat { @@ -39,13 +39,17 @@ public static int GetInstanceIDCompat(this Object obj) } /// - /// Like but returns the full handle without the - /// lossy int truncation: on 6.5+ the EntityId's underlying ulong, on older versions - /// the int instance ID widened to ulong. Returns null for a null object. Use when - /// the handle must round-trip exactly (e.g. matching the same object back across a - /// JSON request). + /// Returns the full object handle as a STRING for lossless JSON round-trips: on 6.5+ + /// the EntityId's underlying ulong, on older versions the raw signed int instance ID. + /// Returns null for a null object. + /// + /// A string (not a number) because JSON's number type is parsed as an IEEE-754 double + /// by some consumers (e.g. JavaScript JSON.parse), which silently collapses integers + /// above 2^53 — and instance handles can exceed that (a 64-bit EntityId, or a negative + /// int). The handle is an opaque identifier, never arithmetic, so a string is the + /// correct wire form. Resolve it back with . /// - public static ulong? GetInstanceIDLongCompat(this Object obj) + public static string GetInstanceIDString(this Object obj) { if (obj == null) { @@ -53,9 +57,9 @@ public static int GetInstanceIDCompat(this Object obj) } #if UNITY_6000_5_OR_NEWER - return EntityId.ToULong(obj.GetEntityId()); + return EntityId.ToULong(obj.GetEntityId()).ToString(); #else - return unchecked((ulong)obj.GetInstanceID()); + return obj.GetInstanceID().ToString(); #endif } @@ -95,20 +99,27 @@ public static Object InstanceIDToObjectCompat(int instanceId) } /// - /// Resolves a ulong handle (from ) back to a - /// UnityEngine.Object. Disambiguates by Unity version, not by inspecting the numeric - /// range — a wrapped-negative int and a genuine 64-bit EntityId can occupy the same - /// high band, so range checks cannot tell them apart. - /// 6.5+ : the handle is the EntityId's ulong — resolve via EntityId.FromULong. - /// Pre-6.5 : the handle is an int instance ID round-tripped through an unchecked - /// ulong cast (negatives are valid) — cast back and use the int resolver. + /// Resolves a string handle (from ) back to a + /// UnityEngine.Object. Disambiguates by Unity version, not by inspecting the value. + /// 6.5+ : the handle is the EntityId's ulong — parse ulong, resolve via EntityId.FromULong. + /// Pre-6.5 : the handle is a signed int instance ID — parse int, use the int resolver. + /// Returns null if the string is null/empty or does not parse. /// - public static Object InstanceIDToObjectLongCompat(ulong instanceId) + public static Object InstanceIDFromString(string instanceId) { + if (string.IsNullOrEmpty(instanceId)) + { + return null; + } + #if UNITY_6000_5_OR_NEWER - return EditorUtility.EntityIdToObject(EntityId.FromULong(instanceId)); + if (!ulong.TryParse(instanceId, out var entityId)) + return null; + return EditorUtility.EntityIdToObject(EntityId.FromULong(entityId)); #else - return InstanceIDToObjectCompat(unchecked((int)instanceId)); + if (!int.TryParse(instanceId, out var id)) + return null; + return InstanceIDToObjectCompat(id); #endif } #endif diff --git a/Server/tests/test_manage_animation.py b/Server/tests/test_manage_animation.py index 80e818112..5deaf0435 100644 --- a/Server/tests/test_manage_animation.py +++ b/Server/tests/test_manage_animation.py @@ -184,18 +184,19 @@ def test_get_state_properties_forwards_paging_and_layer(self): assert params["properties"]["cursor"] == 20 def test_set_state_properties_forwards_states(self): - # instanceId round-trips from get_state_properties; can exceed 32-bit on Unity 6.5+ - # (EntityId), and JSON / Python int carry the full 64-bit value losslessly. - # motionInstanceId transfers a Motion by reference (incl. FBX-embedded clips). - states = [{"instanceId": 8412, "x": 100, "y": 0, "speed": 1.5}, - {"instanceId": 18446744073709551000, "motionInstanceId": 9001}] + # instanceId/motionInstanceId are STRING handles from get_state_properties: opaque ids + # carried as strings so large 64-bit EntityId values (Unity 6.5+) survive JSON without + # the IEEE-754 double truncation a numeric form would suffer. motionInstanceId transfers + # a Motion by reference (incl. FBX-embedded clips). + states = [{"instanceId": "8412", "x": 100, "y": 0, "speed": 1.5}, + {"instanceId": "18446744073709551000", "motionInstanceId": "9001"}] _, params = self._dispatch( "controller_set_state_properties", properties={"states": states} ) assert params["action"] == "controller_set_state_properties" assert params["properties"]["states"] == states - assert params["properties"]["states"][0]["instanceId"] == 8412 - assert params["properties"]["states"][1]["instanceId"] == 18446744073709551000 + assert params["properties"]["states"][0]["instanceId"] == "8412" + assert params["properties"]["states"][1]["instanceId"] == "18446744073709551000" def test_remove_transition_dispatches(self): # fromState required; toState optional (omit to remove all outgoing transitions). From 60d5308a02fc38cb4009e66b79e6fb52ab29d609 Mon Sep 17 00:00:00 2001 From: Thaina Date: Fri, 19 Jun 2026 11:59:28 +0700 Subject: [PATCH 6/7] Add controller_get_blend_tree / controller_edit_blend_tree actions Read and edit existing BlendTree child motions (position, threshold, timeScale, cycleOffset, mirror) on an AnimatorController state. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Editor/Tools/Animation/ClipCreate.cs | 86 ++++++++++ .../Tools/Animation/ControllerBlendTrees.cs | 158 +++++++++++++++++- .../Editor/Tools/Animation/ManageAnimation.cs | 7 +- Server/src/services/tools/manage_animation.py | 3 +- 4 files changed, 244 insertions(+), 10 deletions(-) diff --git a/MCPForUnity/Editor/Tools/Animation/ClipCreate.cs b/MCPForUnity/Editor/Tools/Animation/ClipCreate.cs index b091dce37..fb1338bbd 100644 --- a/MCPForUnity/Editor/Tools/Animation/ClipCreate.cs +++ b/MCPForUnity/Editor/Tools/Animation/ClipCreate.cs @@ -143,6 +143,92 @@ public static object GetInfo(JObject @params) }; } + // Samples a clip's ROOT MOTION trajectory over time and returns the path plus + // summary metrics (net displacement, path length). Resolves the clip BY REFERENCE + // via clipInstanceId (string handle) so it works on FBX sub-asset clips that an + // asset-path load cannot reach. Root motion is read from the importer-baked + // 'RootT.x/y/z' (position) and 'RootQ.x/y/z/w' (rotation) editor curves; if those + // bindings are absent the clip has no baked root motion and we say so. + // Params: { clipInstanceId (string), samples? (int, default 30) }. + public static object GetRootMotion(JObject @params) + { + string clipId = @params["clipInstanceId"]?.ToString(); + if (string.IsNullOrEmpty(clipId)) + return new { success = false, message = "'clipInstanceId' is required (string handle from a state's motionInstanceId)" }; + + var clip = UnityObjectIdCompat.InstanceIDFromString(clipId) as AnimationClip; + if (clip == null) + return new { success = false, message = $"AnimationClip not resolved from clipInstanceId '{clipId}'" }; + + int samples = @params["samples"]?.ToObject() ?? 30; + if (samples < 2) samples = 2; + if (samples > 240) samples = 240; + + // Build name->curve map for the root-motion bindings only. + var rootCurves = new Dictionary(); + foreach (var binding in AnimationUtility.GetCurveBindings(clip)) + { + if (binding.propertyName.StartsWith("RootT.") || binding.propertyName.StartsWith("RootQ.")) + rootCurves[binding.propertyName] = AnimationUtility.GetEditorCurve(clip, binding); + } + + bool hasRootT = rootCurves.ContainsKey("RootT.x") || rootCurves.ContainsKey("RootT.y") || rootCurves.ContainsKey("RootT.z"); + if (!hasRootT) + { + return new + { + success = true, + data = new + { + name = clip.name, + clipInstanceId = clipId, + length = clip.length, + frameRate = clip.frameRate, + isLooping = AnimationUtility.GetAnimationClipSettings(clip).loopTime, + hasRootMotion = false, + message = "No baked RootT curves — clip carries no root translation (in-place / no root motion)." + } + }; + } + + float Eval(string key, float t) => rootCurves.TryGetValue(key, out var c) && c != null && c.length > 0 ? c.Evaluate(t) : 0f; + + var path = new List(); + float len = clip.length; + Vector3 prev = Vector3.zero; + Vector3 first = Vector3.zero, last = Vector3.zero; + float pathLength = 0f; + for (int i = 0; i < samples; i++) + { + float t = len * i / (samples - 1); + var p = new Vector3(Eval("RootT.x", t), Eval("RootT.y", t), Eval("RootT.z", t)); + if (i == 0) { first = p; } + else { pathLength += Vector3.Distance(prev, p); } + prev = p; + last = p; + path.Add(new { t, x = p.x, y = p.y, z = p.z }); + } + + Vector3 net = last - first; + return new + { + success = true, + data = new + { + name = clip.name, + clipInstanceId = clipId, + length = len, + frameRate = clip.frameRate, + isLooping = AnimationUtility.GetAnimationClipSettings(clip).loopTime, + hasRootMotion = true, + samples, + netDisplacement = new { x = net.x, y = net.y, z = net.z, magnitude = net.magnitude }, + pathLength, + path + } + }; + } + public static object AddCurve(JObject @params) { return SetOrAddCurve(@params, append: true); diff --git a/MCPForUnity/Editor/Tools/Animation/ControllerBlendTrees.cs b/MCPForUnity/Editor/Tools/Animation/ControllerBlendTrees.cs index af321094d..6218d7ff7 100644 --- a/MCPForUnity/Editor/Tools/Animation/ControllerBlendTrees.cs +++ b/MCPForUnity/Editor/Tools/Animation/ControllerBlendTrees.cs @@ -1,6 +1,7 @@ using System; using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Helpers; +using MCPForUnity.Runtime.Helpers; using UnityEditor; using UnityEditor.Animations; using UnityEngine; @@ -160,14 +161,29 @@ public static object AddBlendTreeChild(JObject @params) if (string.IsNullOrEmpty(stateName)) return new { success = false, message = "'stateName' is required" }; + // Resolve the clip either by asset path OR by instanceId string handle. The + // instanceId path is required for FBX sub-asset clips, which LoadAssetAtPath + // (main-asset only) cannot reach — same motion-by-reference trick as SetStateProperties. string clipPath = @params["clipPath"]?.ToString(); - if (string.IsNullOrEmpty(clipPath)) - return new { success = false, message = "'clipPath' is required" }; - - clipPath = AssetPathUtility.SanitizeAssetPath(clipPath); - var clip = AssetDatabase.LoadAssetAtPath(clipPath); - if (clip == null) - return new { success = false, message = $"AnimationClip not found at '{clipPath}'" }; + string clipInstanceId = @params["clipInstanceId"]?.ToString(); + AnimationClip clip = null; + if (!string.IsNullOrEmpty(clipInstanceId)) + { + clip = UnityObjectIdCompat.InstanceIDFromString(clipInstanceId) as AnimationClip; + if (clip == null) + return new { success = false, message = $"AnimationClip not resolved from clipInstanceId '{clipInstanceId}'" }; + } + else if (!string.IsNullOrEmpty(clipPath)) + { + clipPath = AssetPathUtility.SanitizeAssetPath(clipPath); + clip = AssetDatabase.LoadAssetAtPath(clipPath); + if (clip == null) + return new { success = false, message = $"AnimationClip not found at '{clipPath}'" }; + } + else + { + return new { success = false, message = "'clipInstanceId' or 'clipPath' is required" }; + } int layerIndex = @params["layerIndex"]?.ToObject() ?? 0; @@ -251,5 +267,133 @@ public static object AddBlendTreeChild(JObject @params) }; } } + + // Resolves a state's BlendTree motion within a controller layer. + private static BlendTree ResolveBlendTree(JObject @params, out object error) + { + error = null; + string controllerPath = @params["controllerPath"]?.ToString(); + if (string.IsNullOrEmpty(controllerPath)) + { error = new { success = false, message = "'controllerPath' is required" }; return null; } + + controllerPath = AssetPathUtility.SanitizeAssetPath(controllerPath); + if (controllerPath == null) + { error = new { success = false, message = "Invalid asset path" }; return null; } + + var controller = AssetDatabase.LoadAssetAtPath(controllerPath); + if (controller == null) + { error = new { success = false, message = $"AnimatorController not found at '{controllerPath}'" }; return null; } + + string stateName = @params["stateName"]?.ToString(); + if (string.IsNullOrEmpty(stateName)) + { error = new { success = false, message = "'stateName' is required" }; return null; } + + int layerIndex = @params["layerIndex"]?.ToObject() ?? 0; + var layers = controller.layers; + if (layerIndex < 0 || layerIndex >= layers.Length) + { error = new { success = false, message = $"Layer index {layerIndex} out of range (0-{layers.Length - 1})" }; return null; } + + AnimatorState state = null; + foreach (var s in layers[layerIndex].stateMachine.states) + if (s.state.name == stateName) { state = s.state; break; } + + if (state == null) + { error = new { success = false, message = $"State '{stateName}' not found in layer {layerIndex}" }; return null; } + + if (!(state.motion is BlendTree blendTree)) + { error = new { success = false, message = $"State '{stateName}' does not have a BlendTree motion" }; return null; } + + return blendTree; + } + + public static object GetBlendTree(JObject @params) + { + var blendTree = ResolveBlendTree(@params, out var error); + if (blendTree == null) return error; + + var children = blendTree.children; + var childData = new object[children.Length]; + for (int i = 0; i < children.Length; i++) + { + var c = children[i]; + childData[i] = new + { + index = i, + motionName = c.motion != null ? c.motion.name : null, + motionInstanceId = c.motion != null ? c.motion.GetInstanceIDString() : null, + threshold = c.threshold, + position = new { x = c.position.x, y = c.position.y }, + timeScale = c.timeScale, + cycleOffset = c.cycleOffset, + mirror = c.mirror + }; + } + + return new + { + success = true, + message = $"Read {children.Length} blend tree child(ren) for '{blendTree.name}'", + data = new + { + name = blendTree.name, + blendType = blendTree.blendType.ToString(), + blendParameter = blendTree.blendParameter, + blendParameterY = blendTree.blendParameterY, + useAutomaticThresholds = blendTree.useAutomaticThresholds, + childCount = children.Length, + children = childData + } + }; + } + + public static object EditBlendTree(JObject @params) + { + var blendTree = ResolveBlendTree(@params, out var error); + if (blendTree == null) return error; + + var edits = @params["children"] as JArray; + if (edits == null || edits.Count == 0) + return new { success = false, message = "'children' array is required: [{index, position:[x,y], threshold?, timeScale?, cycleOffset?, mirror?}]" }; + + // ChildMotion is a struct; must reassign the whole array. + var children = blendTree.children; + Undo.RecordObject(blendTree, "Edit Blend Tree"); + + int applied = 0; + foreach (var token in edits) + { + if (!(token is JObject edit)) continue; + int? idx = edit["index"]?.ToObject(); + if (!idx.HasValue || idx.Value < 0 || idx.Value >= children.Length) + return new { success = false, message = $"Child index {(idx.HasValue ? idx.Value.ToString() : "null")} out of range (0-{children.Length - 1})" }; + + var c = children[idx.Value]; + + if (edit["position"] is JArray pos && pos.Count >= 2) + c.position = new Vector2(pos[0].ToObject(), pos[1].ToObject()); + if (edit["threshold"] != null) + c.threshold = edit["threshold"].ToObject(); + if (edit["timeScale"] != null) + c.timeScale = edit["timeScale"].ToObject(); + if (edit["cycleOffset"] != null) + c.cycleOffset = edit["cycleOffset"].ToObject(); + if (edit["mirror"] != null) + c.mirror = edit["mirror"].ToObject(); + + children[idx.Value] = c; + applied++; + } + + blendTree.children = children; + EditorUtility.SetDirty(blendTree); + AssetDatabase.SaveAssets(); + + return new + { + success = true, + message = $"Applied {applied} edit(s) to blend tree '{blendTree.name}'", + data = new { name = blendTree.name, edited = applied, childCount = children.Length } + }; + } } } diff --git a/MCPForUnity/Editor/Tools/Animation/ManageAnimation.cs b/MCPForUnity/Editor/Tools/Animation/ManageAnimation.cs index 404c0c0da..16f9d37d6 100644 --- a/MCPForUnity/Editor/Tools/Animation/ManageAnimation.cs +++ b/MCPForUnity/Editor/Tools/Animation/ManageAnimation.cs @@ -230,8 +230,10 @@ private static object HandleControllerAction(JObject @params, string action) case "create_blend_tree_1d": return ControllerBlendTrees.CreateBlendTree1D(@params); case "create_blend_tree_2d": return ControllerBlendTrees.CreateBlendTree2D(@params); case "add_blend_tree_child": return ControllerBlendTrees.AddBlendTreeChild(@params); + case "get_blend_tree": return ControllerBlendTrees.GetBlendTree(@params); + case "edit_blend_tree": return ControllerBlendTrees.EditBlendTree(@params); default: - return new { success = false, message = $"Unknown controller action: {action}. Valid: create, add_state, set_state_properties, get_state_properties, add_transition, remove_transition, add_parameter, get_info, assign, add_layer, remove_layer, set_layer_weight, create_blend_tree_1d, create_blend_tree_2d, add_blend_tree_child" }; + return new { success = false, message = $"Unknown controller action: {action}. Valid: create, add_state, set_state_properties, get_state_properties, add_transition, remove_transition, add_parameter, get_info, assign, add_layer, remove_layer, set_layer_weight, create_blend_tree_1d, create_blend_tree_2d, add_blend_tree_child, get_blend_tree, edit_blend_tree" }; } } @@ -241,6 +243,7 @@ private static object HandleClipAction(JObject @params, string action) { case "create": return ClipCreate.Create(@params); case "get_info": return ClipCreate.GetInfo(@params); + case "get_root_motion": return ClipCreate.GetRootMotion(@params); case "add_curve": return ClipCreate.AddCurve(@params); case "set_curve": return ClipCreate.SetCurve(@params); case "set_vector_curve": return ClipCreate.SetVectorCurve(@params); @@ -249,7 +252,7 @@ private static object HandleClipAction(JObject @params, string action) case "add_event": return ClipCreate.AddEvent(@params); case "remove_event": return ClipCreate.RemoveEvent(@params); default: - return new { success = false, message = $"Unknown clip action: {action}. Valid: create, get_info, add_curve, set_curve, set_vector_curve, create_preset, assign, add_event, remove_event" }; + return new { success = false, message = $"Unknown clip action: {action}. Valid: create, get_info, get_root_motion, add_curve, set_curve, set_vector_curve, create_preset, assign, add_event, remove_event" }; } } } diff --git a/Server/src/services/tools/manage_animation.py b/Server/src/services/tools/manage_animation.py index eee9e28aa..0faf3079e 100644 --- a/Server/src/services/tools/manage_animation.py +++ b/Server/src/services/tools/manage_animation.py @@ -21,10 +21,11 @@ "controller_add_parameter", "controller_get_info", "controller_assign", "controller_add_layer", "controller_remove_layer", "controller_set_layer_weight", "controller_create_blend_tree_1d", "controller_create_blend_tree_2d", "controller_add_blend_tree_child", + "controller_get_blend_tree", "controller_edit_blend_tree", ] CLIP_ACTIONS = [ - "clip_create", "clip_get_info", + "clip_create", "clip_get_info", "clip_get_root_motion", "clip_add_curve", "clip_set_curve", "clip_set_vector_curve", "clip_create_preset", "clip_assign", "clip_add_event", "clip_remove_event", From ba09a7fb9ab47c94116de3bcbf3855370492f876 Mon Sep 17 00:00:00 2001 From: Thaina Date: Tue, 23 Jun 2026 20:46:55 +0700 Subject: [PATCH 7/7] add more edit parameter --- .../Tools/Animation/ControllerBlendTrees.cs | 120 +++++++++++++++++- .../Tools/Animation/ControllerCreate.cs | 32 +++++ Server/pyproject.toml | 2 +- 3 files changed, 148 insertions(+), 6 deletions(-) diff --git a/MCPForUnity/Editor/Tools/Animation/ControllerBlendTrees.cs b/MCPForUnity/Editor/Tools/Animation/ControllerBlendTrees.cs index 6218d7ff7..d928b1309 100644 --- a/MCPForUnity/Editor/Tools/Animation/ControllerBlendTrees.cs +++ b/MCPForUnity/Editor/Tools/Animation/ControllerBlendTrees.cs @@ -352,12 +352,61 @@ public static object EditBlendTree(JObject @params) if (blendTree == null) return error; var edits = @params["children"] as JArray; - if (edits == null || edits.Count == 0) - return new { success = false, message = "'children' array is required: [{index, position:[x,y], threshold?, timeScale?, cycleOffset?, mirror?}]" }; + + Undo.RecordObject(blendTree, "Edit Blend Tree"); + + // Tree-level properties (optional). Lets a blend tree be retyped/reparameterized + // in place — needed to copy a source tree's type+params onto an existing state. + int treeProps = 0; + if (@params["blendType"] != null && + Enum.TryParse(@params["blendType"].ToString(), true, out var bt)) + { blendTree.blendType = bt; treeProps++; } + if (@params["blendParameter"] != null) + { blendTree.blendParameter = @params["blendParameter"].ToString(); treeProps++; } + if (@params["blendParameterY"] != null) + { blendTree.blendParameterY = @params["blendParameterY"].ToString(); treeProps++; } + if (@params["useAutomaticThresholds"] != null) + { blendTree.useAutomaticThresholds = @params["useAutomaticThresholds"].ToObject(); treeProps++; } + + // Optional: REPLACE the entire clip-child list (clears then re-adds). Lets a tree be + // restructured in one call — needed when extracting children into a nested sub-tree, + // since there is no per-child remove. Each item: {clipInstanceId|clipPath, position?:[x,y], threshold?} + int replaced = -1; + if (@params["setChildren"] is JArray setKids) + { + var fresh = new System.Collections.Generic.List(); + foreach (var kt in setKids) + { + if (!(kt is JObject kid)) continue; + AnimationClip clip = null; + string cInst = kid["clipInstanceId"]?.ToString(); + string cPath = kid["clipPath"]?.ToString(); + if (!string.IsNullOrEmpty(cInst)) + clip = UnityObjectIdCompat.InstanceIDFromString(cInst) as AnimationClip; + else if (!string.IsNullOrEmpty(cPath)) + clip = AssetDatabase.LoadAssetAtPath(AssetPathUtility.SanitizeAssetPath(cPath)); + if (clip == null) + return new { success = false, message = $"setChildren clip not resolved (instanceId '{cInst}', path '{cPath}')" }; + + var cm = new ChildMotion { motion = clip, timeScale = 1f, directBlendParameter = blendTree.blendParameter }; + if (kid["position"] is JArray kpos && kpos.Count >= 2) + cm.position = new Vector2(kpos[0].ToObject(), kpos[1].ToObject()); + if (kid["threshold"] != null) cm.threshold = kid["threshold"].ToObject(); + if (kid["timeScale"] != null) cm.timeScale = kid["timeScale"].ToObject(); + if (kid["cycleOffset"] != null) cm.cycleOffset = kid["cycleOffset"].ToObject(); + if (kid["mirror"] != null) cm.mirror = kid["mirror"].ToObject(); + fresh.Add(cm); + } + blendTree.children = fresh.ToArray(); + replaced = fresh.Count; + } + + if ((edits == null || edits.Count == 0) && treeProps == 0 && replaced < 0 && @params["addChildTree"] == null) + return new { success = false, message = "Provide tree-level props, 'children' (index edits), 'setChildren' (replace all), and/or 'addChildTree' (nested)." }; // ChildMotion is a struct; must reassign the whole array. var children = blendTree.children; - Undo.RecordObject(blendTree, "Edit Blend Tree"); + if (edits == null) edits = new JArray(); int applied = 0; foreach (var token in edits) @@ -385,14 +434,75 @@ public static object EditBlendTree(JObject @params) } blendTree.children = children; + + // Optional: create a NESTED child blend tree inside this tree and populate it. + // This is the only way to reach a true 3rd blend dimension (Unity trees are max 2D); + // the nested tree blends on its own parameter, independent of the parent's axes. + // Shape: addChildTree: { name?, position:[x,y], threshold?, blendType, blendParameter, + // blendParameterY?, useAutomaticThresholds?, children:[{clipInstanceId|clipPath, threshold?, position?:[x,y]}] } + object nestedInfo = null; + if (@params["addChildTree"] is JObject ct) + { + BlendTree child; + if (blendTree.blendType == BlendTreeType.Simple1D) + { + float thr = ct["threshold"]?.ToObject() ?? 0f; + child = blendTree.CreateBlendTreeChild(thr); + } + else + { + if (!(ct["position"] is JArray cpos) || cpos.Count < 2) + return new { success = false, message = "addChildTree.position [x,y] is required when parent is a 2D tree" }; + child = blendTree.CreateBlendTreeChild(new Vector2(cpos[0].ToObject(), cpos[1].ToObject())); + } + + child.name = ct["name"]?.ToString() ?? "Look Blend Tree"; + child.hideFlags = HideFlags.HideInHierarchy; + if (ct["blendType"] != null && Enum.TryParse(ct["blendType"].ToString(), true, out var cbt)) + child.blendType = cbt; + if (ct["blendParameter"] != null) child.blendParameter = ct["blendParameter"].ToString(); + if (ct["blendParameterY"] != null) child.blendParameterY = ct["blendParameterY"].ToString(); + if (ct["useAutomaticThresholds"] != null) child.useAutomaticThresholds = ct["useAutomaticThresholds"].ToObject(); + + int nestedAdded = 0; + if (ct["children"] is JArray nestedKids) + { + foreach (var kt in nestedKids) + { + if (!(kt is JObject kid)) continue; + AnimationClip clip = null; + string cInst = kid["clipInstanceId"]?.ToString(); + string cPath = kid["clipPath"]?.ToString(); + if (!string.IsNullOrEmpty(cInst)) + clip = UnityObjectIdCompat.InstanceIDFromString(cInst) as AnimationClip; + else if (!string.IsNullOrEmpty(cPath)) + clip = AssetDatabase.LoadAssetAtPath(AssetPathUtility.SanitizeAssetPath(cPath)); + if (clip == null) + return new { success = false, message = $"addChildTree child clip not resolved (instanceId '{cInst}', path '{cPath}')" }; + + if (child.blendType == BlendTreeType.Simple1D) + child.AddChild(clip, kid["threshold"]?.ToObject() ?? 0f); + else if (kid["position"] is JArray kpos && kpos.Count >= 2) + child.AddChild(clip, new Vector2(kpos[0].ToObject(), kpos[1].ToObject())); + else + child.AddChild(clip); + nestedAdded++; + } + } + + EditorUtility.SetDirty(child); + nestedInfo = new { name = child.name, blendType = child.blendType.ToString(), blendParameter = child.blendParameter, childCount = child.children.Length, added = nestedAdded }; + } + EditorUtility.SetDirty(blendTree); AssetDatabase.SaveAssets(); return new { success = true, - message = $"Applied {applied} edit(s) to blend tree '{blendTree.name}'", - data = new { name = blendTree.name, edited = applied, childCount = children.Length } + nestedChildTree = nestedInfo, + message = $"Applied {treeProps} tree prop(s), {applied} child edit(s), replaced={replaced} on blend tree '{blendTree.name}'", + data = new { name = blendTree.name, treeProps, edited = applied, replaced, childCount = blendTree.children.Length, blendType = blendTree.blendType.ToString() } }; } } diff --git a/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs b/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs index 126d4bb3a..809da5268 100644 --- a/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs +++ b/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs @@ -174,6 +174,30 @@ public static object AddTransition(JObject @params) float exitTime = @params["exitTime"]?.ToObject() ?? 0.75f; transition.exitTime = exitTime; + // Optional fields: only override Unity's defaults when supplied, so a faithful + // remove+re-add round-trip (paired with get_info) loses nothing. Names match the + // keys emitted by GetInfo. + if (@params["offset"] != null) + transition.offset = @params["offset"].ToObject(); + if (@params["hasFixedDuration"] != null) + transition.hasFixedDuration = @params["hasFixedDuration"].ToObject(); + if (@params["canTransitionToSelf"] != null) + transition.canTransitionToSelf = @params["canTransitionToSelf"].ToObject(); + if (@params["orderedInterruption"] != null) + transition.orderedInterruption = @params["orderedInterruption"].ToObject(); + if (@params["interruptionSource"] != null) + { + if (Enum.TryParse(@params["interruptionSource"].ToString(), true, out var src)) + transition.interruptionSource = src; + } + if (@params["mute"] != null) + transition.mute = @params["mute"].ToObject(); + if (@params["solo"] != null) + transition.solo = @params["solo"].ToObject(); + string transitionName = @params["name"]?.ToString(); + if (!string.IsNullOrEmpty(transitionName)) + transition.name = transitionName; + // Add conditions JToken conditionsToken = @params["conditions"]; int conditionCount = 0; @@ -397,10 +421,18 @@ public static object GetInfo(JObject @params) transitions.Add(new { + name = t.name, destinationState = t.destinationState?.name, hasExitTime = t.hasExitTime, exitTime = t.exitTime, duration = t.duration, + offset = t.offset, + hasFixedDuration = t.hasFixedDuration, + canTransitionToSelf = t.canTransitionToSelf, + orderedInterruption = t.orderedInterruption, + interruptionSource = t.interruptionSource.ToString(), + mute = t.mute, + solo = t.solo, conditionCount = t.conditions.Length, conditions }); diff --git a/Server/pyproject.toml b/Server/pyproject.toml index 569ec6dd6..ceaccff1b 100644 --- a/Server/pyproject.toml +++ b/Server/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcpforunityserver" -version = "9.7.3" +version = "9.7.4" description = "MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)." readme = "README.md" license = "MIT"