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
86 changes: 86 additions & 0 deletions MCPForUnity/Editor/Tools/Animation/ClipCreate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>() ?? 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<string, AnimationCurve>();
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<object>();
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);
Expand Down
268 changes: 261 additions & 7 deletions MCPForUnity/Editor/Tools/Animation/ControllerBlendTrees.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<AnimationClip>(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<AnimationClip>(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<int>() ?? 0;

Expand Down Expand Up @@ -251,5 +267,243 @@ 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<AnimatorController>(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<int>() ?? 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;

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<BlendTreeType>(@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<bool>(); 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<ChildMotion>();
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<AnimationClip>(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<float>(), kpos[1].ToObject<float>());
if (kid["threshold"] != null) cm.threshold = kid["threshold"].ToObject<float>();
if (kid["timeScale"] != null) cm.timeScale = kid["timeScale"].ToObject<float>();
if (kid["cycleOffset"] != null) cm.cycleOffset = kid["cycleOffset"].ToObject<float>();
if (kid["mirror"] != null) cm.mirror = kid["mirror"].ToObject<bool>();
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;
if (edits == null) edits = new JArray();

int applied = 0;
foreach (var token in edits)
{
if (!(token is JObject edit)) continue;
int? idx = edit["index"]?.ToObject<int?>();
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<float>(), pos[1].ToObject<float>());
if (edit["threshold"] != null)
c.threshold = edit["threshold"].ToObject<float>();
if (edit["timeScale"] != null)
c.timeScale = edit["timeScale"].ToObject<float>();
if (edit["cycleOffset"] != null)
c.cycleOffset = edit["cycleOffset"].ToObject<float>();
if (edit["mirror"] != null)
c.mirror = edit["mirror"].ToObject<bool>();

children[idx.Value] = c;
applied++;
}

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<float>() ?? 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<float>(), cpos[1].ToObject<float>()));
}

child.name = ct["name"]?.ToString() ?? "Look Blend Tree";
child.hideFlags = HideFlags.HideInHierarchy;
if (ct["blendType"] != null && Enum.TryParse<BlendTreeType>(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<bool>();

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<AnimationClip>(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<float>() ?? 0f);
else if (kid["position"] is JArray kpos && kpos.Count >= 2)
child.AddChild(clip, new Vector2(kpos[0].ToObject<float>(), kpos[1].ToObject<float>()));
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 };
}
Comment on lines +444 to +495

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Unity BlendTree.CreateBlendTreeChild AddObjectToAsset persist sub-asset AnimatorController

💡 Result:

To persist a BlendTree and its children as sub-assets of an AnimatorController, you must explicitly use AssetDatabase.AddObjectToAsset [1][2]. When you create a BlendTree or a child BlendTree via script, it exists only in memory [1]. To ensure it is saved alongside the AnimatorController asset, follow these guidelines: 1. Use AnimatorController.CreateBlendTreeInController to initialize the primary BlendTree [3][4]. This method automatically adds the new BlendTree as a sub-asset to the AnimatorController [3]. 2. When manually creating child BlendTrees (e.g., using BlendTree.CreateBlendTreeChild), these are generally added as sub-assets by the internal implementation [5][6]. However, if you are manually creating and adding Motion objects or nested BlendTrees, you must call AssetDatabase.AddObjectToAsset(childObject, animatorController) to ensure they are serialized into the controller file [1][7][2]. 3. Always call AssetDatabase.SaveAssets after modifying the asset structure [1][8]. Failure to do so may result in the loss of in-memory changes [1]. Example pattern: using UnityEditor; using UnityEditor.Animations; using UnityEngine; // 1. Create or load your controller AnimatorController controller = AnimatorController.CreateAnimatorControllerAtPath("Assets/MyController.controller"); // 2. Create the main BlendTree within the controller controller.CreateBlendTreeInController("MyBlendTree", out BlendTree rootTree); // 3. Add a child BlendTree BlendTree childTree = rootTree.CreateBlendTreeChild(0.5f); // 4. If creating other sub-assets manually, add them to the controller // AssetDatabase.AddObjectToAsset(myNewObject, controller); // 5. Persist changes AssetDatabase.SaveAssets; Note that BlendTrees added as sub-assets are typically hidden from the Project window by default (using HideFlags) [6][2]. This is standard behavior for Unity animation sub-assets [2]. [5][3]

Citations:


🏁 Script executed:

ast-grep outline MCPForUnity/Editor/Tools/Animation/ControllerBlendTrees.cs

Repository: CoplayDev/unity-mcp

Length of output: 230


🏁 Script executed:

ls -la MCPForUnity/Editor/Tools/Animation/ControllerBlendTrees.cs

Repository: CoplayDev/unity-mcp

Length of output: 266


🏁 Script executed:

wc -l MCPForUnity/Editor/Tools/Animation/ControllerBlendTrees.cs && cat -n MCPForUnity/Editor/Tools/Animation/ControllerBlendTrees.cs | sed -n '400,550p'

Repository: CoplayDev/unity-mcp

Length of output: 7081


🏁 Script executed:

cat -n MCPForUnity/Editor/Tools/Animation/ControllerBlendTrees.cs | sed -n '1,150p' | grep -A5 -B5 "CreateBlendTree"

Repository: CoplayDev/unity-mcp

Length of output: 1090


🏁 Script executed:

cat -n MCPForUnity/Editor/Tools/Animation/ControllerBlendTrees.cs | sed -n '13,130p'

Repository: CoplayDev/unity-mcp

Length of output: 6262


🏁 Script executed:

cat -n MCPForUnity/Editor/Tools/Animation/ControllerBlendTrees.cs | sed -n '200,350p'

Repository: CoplayDev/unity-mcp

Length of output: 7668


🏁 Script executed:

cat -n MCPForUnity/Editor/Tools/Animation/ControllerBlendTrees.cs | sed -n '349,395p'

Repository: CoplayDev/unity-mcp

Length of output: 3495


🏁 Script executed:

rg "CreateBlendTreeChild" MCPForUnity/ -A 5 -B 2

Repository: CoplayDev/unity-mcp

Length of output: 1812


🏁 Script executed:

cat -n MCPForUnity/Editor/Tools/Animation/ControllerBlendTrees.cs | sed -n '272,310p'

Repository: CoplayDev/unity-mcp

Length of output: 2437


🏁 Script executed:

cat -n MCPForUnity/Editor/Tools/Animation/ControllerBlendTrees.cs | sed -n '349,360p'

Repository: CoplayDev/unity-mcp

Length of output: 767


🏁 Script executed:

cat -n MCPForUnity/Editor/Tools/Animation/ControllerBlendTrees.cs | sed -n '349,510p' | head -80

Repository: CoplayDev/unity-mcp

Length of output: 5580


🏁 Script executed:

cat -n MCPForUnity/Editor/Tools/Animation/ControllerBlendTrees.cs | sed -n '349,507p'

Repository: CoplayDev/unity-mcp

Length of output: 10613


Add explicit AssetDatabase.AddObjectToAsset(child, controller) to persist the nested blend tree.

The nested BlendTree created via CreateBlendTreeChild lacks explicit sub-asset registration. Unlike CreateBlendTree1D/2D (which call AssetDatabase.AddObjectToAsset(blendTree, controller) at line 53 and 124), this code calls only SetDirty(child) without registering the child into the controller asset. The controller reference is also unavailable in EditBlendTree scope since ResolveBlendTree only returns the blendTree, not the controller.

Retrieve the controller (e.g., via AssetDatabase.LoadAssetAtPath using controllerPath from @params) and call AssetDatabase.AddObjectToAsset(child, controller) before or after SetDirty(child) to ensure the nested tree persists on reload.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@MCPForUnity/Editor/Tools/Animation/ControllerBlendTrees.cs` around lines 444
- 495, The nested BlendTree created via CreateBlendTreeChild is not being
registered as a sub-asset in the controller, causing it to not persist on
reload. Currently the code only calls SetDirty(child) but lacks the
AssetDatabase.AddObjectToAsset(child, controller) call that is used in other
blend tree creation paths. To fix this, retrieve the controller reference from
the `@params` (using controllerPath and AssetDatabase.LoadAssetAtPath if
necessary), and then call AssetDatabase.AddObjectToAsset(child, controller)
before or after the existing SetDirty(child) call to ensure the nested blend
tree is properly registered as a sub-asset of the controller.


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

return new
{
success = true,
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() }
};
}
}
}
Loading