diff --git a/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs b/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs index 328c18d78..f9f3bc702 100644 --- a/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs +++ b/MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs @@ -422,6 +422,138 @@ 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. + 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 {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 + } + }; + } + + 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, layer? }. + // Each entry's optional 'layer' (falling back to a top-level 'layerIndex') scopes + // the match to one layer, so a name reused across layers is no longer ambiguous; + // entries with no layer match that name on any layer. 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, layer? }, ...]" }; + + int? defaultLayer = @params["layerIndex"]?.ToObject(); + + // Key is "layer:name" when scoped to a layer, else "*:name" to match any layer. + 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; + 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, li, 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} key(s) unmatched.", + data = new + { + matched = matched.Count, + requested = want.Count, + unmatched + } + }; + } + + private static void ApplyPositions(AnimatorStateMachine sm, int layer, 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) + { + 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, layer, 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", 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 # =============================================================================