Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
245 changes: 245 additions & 0 deletions MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -227,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<int>() ?? 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);
Expand Down Expand Up @@ -422,6 +490,183 @@ public static object AssignToGameObject(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)
return ControllerNotFoundError(@params);

int? layerFilter = @params["layerIndex"]?.ToObject<int>();
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<object>();
for (int li = 0; li < controller.layers.Length; li++)
{
if (layerFilter.HasValue && li != layerFilter.Value)
continue;
CollectProperties(controller.layers[li].stateMachine, li, nodes);
}

var pagination = PaginationRequest.FromParams(@params, defaultPageSize: 50);
var paged = PaginationResponse<object>.Create(nodes, pagination);

return new
{
success = true,
message = $"Read {paged.Items.Count} of {paged.TotalCount} state(s).",
data = new
{
count = paged.TotalCount,
nodes = paged.Items,
pageSize = paged.PageSize,
cursor = paged.Cursor,
nextCursor = paged.NextCursor,
hasMore = paged.HasMore
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private static void CollectProperties(AnimatorStateMachine sm, int layer, List<object> 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 = st.name,
instanceId = st.GetInstanceIDLongCompat(),
layer,
x = children[i].position.x,
y = children[i].position.y,
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)
CollectProperties(sub.stateMachine, layer, outList);
}

// 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["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<ulong, JObject>();
foreach (var token in arr)
{
if (!(token is JObject entry))
continue;
ulong? instanceId = entry["instanceId"]?.ToObject<ulong>();
if (instanceId.HasValue)
want[instanceId.Value] = entry;
}
if (want.Count == 0)
return new { success = false, message = "No valid entries (each needs an 'instanceId')." };

var matched = new HashSet<ulong>();
var motionFailures = new List<object>();
Undo.RecordObject(controller, "Set State Properties");
for (int li = 0; li < controller.layers.Length; li++)
ApplyProperties(controller.layers[li].stateMachine, want, matched, motionFailures);

EditorUtility.SetDirty(controller);
AssetDatabase.SaveAssets();

var unmatched = want.Keys.Where(k => !matched.Contains(k)).ToList();
return new
{
success = true,
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,
motionFailures
}
};
}

private static void ApplyProperties(AnimatorStateMachine sm, Dictionary<ulong, JObject> want, HashSet<ulong> matched, List<object> 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))
continue;

var st = children[i].state;

// Position (x and/or y) - keep the unspecified axis unchanged.
if (entry["x"] != null || entry["y"] != null)
{
var pos = children[i].position;
float x = entry["x"]?.ToObject<float>() ?? pos.x;
float y = entry["y"]?.ToObject<float>() ?? pos.y;
children[i].position = new Vector3(x, y, 0f);
}

// Speed
if (entry["speed"] != null)
st.speed = entry["speed"].ToObject<float>();

// 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<ulong>();
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 edits persist

foreach (var sub in sm.stateMachines)
ApplyProperties(sub.stateMachine, want, matched, motionFailures);
}

private static AnimatorController LoadController(JObject @params)
{
string controllerPath = @params["controllerPath"]?.ToString();
Expand Down
5 changes: 4 additions & 1 deletion MCPForUnity/Editor/Tools/Animation/ManageAnimation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +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_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);
Expand All @@ -228,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, 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" };
}
}

Expand Down
41 changes: 41 additions & 0 deletions MCPForUnity/Runtime/Helpers/UnityObjectIdCompat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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): <see cref="GetInstanceIDCompat"/>
/// Forward (Object → ulong, lossless): <see cref="GetInstanceIDLongCompat"/>
/// Reverse (int → Object, Editor-only): <see cref="InstanceIDToObjectCompat"/>
/// Reverse (ulong → Object, Editor-only): <see cref="InstanceIDToObjectLongCompat"/>
/// </summary>
public static class UnityObjectIdCompat
{
Expand All @@ -36,6 +38,27 @@ public static int GetInstanceIDCompat(this Object obj)
#endif
}

/// <summary>
/// Like <see cref="GetInstanceIDCompat"/> 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).
/// </summary>
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;
Expand Down Expand Up @@ -68,6 +91,24 @@ public static Object InstanceIDToObjectCompat(int instanceId)
return EditorUtility.EntityIdToObject(instanceId);
#else
return EditorUtility.InstanceIDToObject(instanceId);
#endif
}

/// <summary>
/// Resolves a ulong handle (from <see cref="GetInstanceIDLongCompat"/>) 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.
/// </summary>
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
Expand Down
4 changes: 3 additions & 1 deletion Server/src/services/tools/manage_animation.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
]

CONTROLLER_ACTIONS = [
"controller_create", "controller_add_state", "controller_add_transition",
"controller_create", "controller_add_state",
"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",
Expand Down
Loading