chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 76a782e424904c8686863ade93091b77
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,224 @@
|
||||
using System;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Animations;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Animation
|
||||
{
|
||||
internal static class AnimatorControl
|
||||
{
|
||||
public static object Play(JObject @params)
|
||||
{
|
||||
var go = ObjectResolver.ResolveGameObject(@params["target"], @params["searchMethod"]?.ToString());
|
||||
if (go == null)
|
||||
return new { success = false, message = "Target GameObject not found" };
|
||||
|
||||
var animator = go.GetComponent<Animator>();
|
||||
if (animator == null)
|
||||
return new { success = false, message = $"No Animator component on '{go.name}'" };
|
||||
|
||||
string stateName = @params["stateName"]?.ToString();
|
||||
if (string.IsNullOrEmpty(stateName))
|
||||
return new { success = false, message = "'stateName' is required" };
|
||||
|
||||
int layer = @params["layer"]?.ToObject<int>() ?? -1;
|
||||
|
||||
Undo.RecordObject(animator, "Play Animation State");
|
||||
animator.Play(stateName, layer);
|
||||
|
||||
return new { success = true, message = $"Playing state '{stateName}' on '{go.name}'" };
|
||||
}
|
||||
|
||||
public static object Crossfade(JObject @params)
|
||||
{
|
||||
var go = ObjectResolver.ResolveGameObject(@params["target"], @params["searchMethod"]?.ToString());
|
||||
if (go == null)
|
||||
return new { success = false, message = "Target GameObject not found" };
|
||||
|
||||
var animator = go.GetComponent<Animator>();
|
||||
if (animator == null)
|
||||
return new { success = false, message = $"No Animator component on '{go.name}'" };
|
||||
|
||||
string stateName = @params["stateName"]?.ToString();
|
||||
if (string.IsNullOrEmpty(stateName))
|
||||
return new { success = false, message = "'stateName' is required" };
|
||||
|
||||
float duration = @params["duration"]?.ToObject<float>() ?? 0.25f;
|
||||
int layer = @params["layer"]?.ToObject<int>() ?? -1;
|
||||
|
||||
Undo.RecordObject(animator, "Crossfade Animation State");
|
||||
animator.CrossFade(stateName, duration, layer);
|
||||
|
||||
return new { success = true, message = $"Crossfading to '{stateName}' over {duration}s on '{go.name}'" };
|
||||
}
|
||||
|
||||
public static object SetParameter(JObject @params)
|
||||
{
|
||||
var go = ObjectResolver.ResolveGameObject(@params["target"], @params["searchMethod"]?.ToString());
|
||||
if (go == null)
|
||||
return new { success = false, message = "Target GameObject not found" };
|
||||
|
||||
var animator = go.GetComponent<Animator>();
|
||||
if (animator == null)
|
||||
return new { success = false, message = $"No Animator component on '{go.name}'" };
|
||||
|
||||
string paramName = @params["parameterName"]?.ToString();
|
||||
if (string.IsNullOrEmpty(paramName))
|
||||
return new { success = false, message = "'parameterName' is required" };
|
||||
|
||||
string paramType = @params["parameterType"]?.ToString()?.ToLowerInvariant();
|
||||
|
||||
// Auto-detect type if not specified
|
||||
if (string.IsNullOrEmpty(paramType))
|
||||
{
|
||||
for (int i = 0; i < animator.parameterCount; i++)
|
||||
{
|
||||
var p = animator.GetParameter(i);
|
||||
if (p.name == paramName)
|
||||
{
|
||||
paramType = p.type.ToString().ToLowerInvariant();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(paramType))
|
||||
return new { success = false, message = $"Parameter '{paramName}' not found. Specify 'parameterType' explicitly or check the parameter name." };
|
||||
}
|
||||
|
||||
JToken valueToken = @params["value"];
|
||||
|
||||
// In Edit mode, runtime Animator.SetFloat/SetInteger/SetBool are no-ops because
|
||||
// the Animator graph isn't active. Instead, modify the controller asset's default
|
||||
// parameter values so changes actually persist.
|
||||
bool isPlaying = Application.isPlaying;
|
||||
|
||||
if (isPlaying)
|
||||
{
|
||||
Undo.RecordObject(animator, $"Set Animator Parameter {paramName}");
|
||||
|
||||
switch (paramType)
|
||||
{
|
||||
case "float":
|
||||
float fVal = valueToken?.ToObject<float>() ?? 0f;
|
||||
animator.SetFloat(paramName, fVal);
|
||||
return new { success = true, message = $"Set float '{paramName}' = {fVal}" };
|
||||
|
||||
case "int":
|
||||
case "integer":
|
||||
int iVal = valueToken?.ToObject<int>() ?? 0;
|
||||
animator.SetInteger(paramName, iVal);
|
||||
return new { success = true, message = $"Set int '{paramName}' = {iVal}" };
|
||||
|
||||
case "bool":
|
||||
case "boolean":
|
||||
bool bVal = valueToken?.ToObject<bool>() ?? false;
|
||||
animator.SetBool(paramName, bVal);
|
||||
return new { success = true, message = $"Set bool '{paramName}' = {bVal}" };
|
||||
|
||||
case "trigger":
|
||||
animator.SetTrigger(paramName);
|
||||
return new { success = true, message = $"Set trigger '{paramName}'" };
|
||||
|
||||
default:
|
||||
return new { success = false, message = $"Unknown parameter type: {paramType}. Valid: float, int, bool, trigger" };
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Edit mode: modify the AnimatorController asset's default parameter values
|
||||
var controller = animator.runtimeAnimatorController as AnimatorController;
|
||||
if (controller == null)
|
||||
return new { success = false, message = $"No AnimatorController assigned to Animator on '{go.name}'. Cannot set parameter defaults in Edit mode." };
|
||||
|
||||
var allParams = controller.parameters;
|
||||
int paramIndex = -1;
|
||||
for (int i = 0; i < allParams.Length; i++)
|
||||
{
|
||||
if (allParams[i].name == paramName)
|
||||
{
|
||||
paramIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (paramIndex < 0)
|
||||
return new { success = false, message = $"Parameter '{paramName}' not found on controller '{controller.name}'." };
|
||||
|
||||
Undo.RecordObject(controller, $"Set Parameter Default {paramName}");
|
||||
|
||||
switch (paramType)
|
||||
{
|
||||
case "float":
|
||||
float fVal = valueToken?.ToObject<float>() ?? 0f;
|
||||
allParams[paramIndex].defaultFloat = fVal;
|
||||
controller.parameters = allParams;
|
||||
EditorUtility.SetDirty(controller);
|
||||
AssetDatabase.SaveAssets();
|
||||
return new { success = true, message = $"Set float '{paramName}' = {fVal} (default value, Edit mode)" };
|
||||
|
||||
case "int":
|
||||
case "integer":
|
||||
int iVal = valueToken?.ToObject<int>() ?? 0;
|
||||
allParams[paramIndex].defaultInt = iVal;
|
||||
controller.parameters = allParams;
|
||||
EditorUtility.SetDirty(controller);
|
||||
AssetDatabase.SaveAssets();
|
||||
return new { success = true, message = $"Set int '{paramName}' = {iVal} (default value, Edit mode)" };
|
||||
|
||||
case "bool":
|
||||
case "boolean":
|
||||
bool bVal = valueToken?.ToObject<bool>() ?? false;
|
||||
allParams[paramIndex].defaultBool = bVal;
|
||||
controller.parameters = allParams;
|
||||
EditorUtility.SetDirty(controller);
|
||||
AssetDatabase.SaveAssets();
|
||||
return new { success = true, message = $"Set bool '{paramName}' = {bVal} (default value, Edit mode)" };
|
||||
|
||||
case "trigger":
|
||||
return new { success = true, message = $"Trigger '{paramName}' noted (triggers are runtime-only, no default to set)" };
|
||||
|
||||
default:
|
||||
return new { success = false, message = $"Unknown parameter type: {paramType}. Valid: float, int, bool, trigger" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static object SetSpeed(JObject @params)
|
||||
{
|
||||
var go = ObjectResolver.ResolveGameObject(@params["target"], @params["searchMethod"]?.ToString());
|
||||
if (go == null)
|
||||
return new { success = false, message = "Target GameObject not found" };
|
||||
|
||||
var animator = go.GetComponent<Animator>();
|
||||
if (animator == null)
|
||||
return new { success = false, message = $"No Animator component on '{go.name}'" };
|
||||
|
||||
float speed = @params["speed"]?.ToObject<float>() ?? 1f;
|
||||
|
||||
Undo.RecordObject(animator, "Set Animator Speed");
|
||||
animator.speed = speed;
|
||||
|
||||
return new { success = true, message = $"Set animator speed to {speed} on '{go.name}'" };
|
||||
}
|
||||
|
||||
public static object SetEnabled(JObject @params)
|
||||
{
|
||||
var go = ObjectResolver.ResolveGameObject(@params["target"], @params["searchMethod"]?.ToString());
|
||||
if (go == null)
|
||||
return new { success = false, message = "Target GameObject not found" };
|
||||
|
||||
var animator = go.GetComponent<Animator>();
|
||||
if (animator == null)
|
||||
return new { success = false, message = $"No Animator component on '{go.name}'" };
|
||||
|
||||
bool enabled = @params["enabled"]?.ToObject<bool>() ?? true;
|
||||
|
||||
Undo.RecordObject(animator, "Set Animator Enabled");
|
||||
animator.enabled = enabled;
|
||||
|
||||
return new { success = true, message = $"Animator {(enabled ? "enabled" : "disabled")} on '{go.name}'" };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ac285365908a4fb39f775b2af5edc60b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Animation
|
||||
{
|
||||
internal static class AnimatorRead
|
||||
{
|
||||
public static object GetInfo(JObject @params)
|
||||
{
|
||||
var go = ObjectResolver.ResolveGameObject(@params["target"], @params["searchMethod"]?.ToString());
|
||||
if (go == null)
|
||||
return new { success = false, message = "Target GameObject not found" };
|
||||
|
||||
var animator = go.GetComponent<Animator>();
|
||||
if (animator == null)
|
||||
return new { success = false, message = $"No Animator component on '{go.name}'" };
|
||||
|
||||
var parameters = new List<object>();
|
||||
for (int i = 0; i < animator.parameterCount; i++)
|
||||
{
|
||||
var p = animator.GetParameter(i);
|
||||
parameters.Add(new
|
||||
{
|
||||
name = p.name,
|
||||
type = p.type.ToString(),
|
||||
defaultFloat = p.defaultFloat,
|
||||
defaultInt = p.defaultInt,
|
||||
defaultBool = p.defaultBool
|
||||
});
|
||||
}
|
||||
|
||||
var layers = new List<object>();
|
||||
for (int i = 0; i < animator.layerCount; i++)
|
||||
{
|
||||
var stateInfo = animator.IsInTransition(i)
|
||||
? animator.GetNextAnimatorStateInfo(i)
|
||||
: animator.GetCurrentAnimatorStateInfo(i);
|
||||
|
||||
layers.Add(new
|
||||
{
|
||||
index = i,
|
||||
name = animator.GetLayerName(i),
|
||||
weight = animator.GetLayerWeight(i),
|
||||
currentStateHash = stateInfo.fullPathHash,
|
||||
currentStateNormalizedTime = stateInfo.normalizedTime,
|
||||
currentStateLength = stateInfo.length,
|
||||
isInTransition = animator.IsInTransition(i)
|
||||
});
|
||||
}
|
||||
|
||||
var clips = new List<object>();
|
||||
if (animator.runtimeAnimatorController != null)
|
||||
{
|
||||
foreach (var clip in animator.runtimeAnimatorController.animationClips)
|
||||
{
|
||||
clips.Add(new
|
||||
{
|
||||
name = clip.name,
|
||||
length = clip.length,
|
||||
frameRate = clip.frameRate,
|
||||
isLooping = clip.isLooping,
|
||||
wrapMode = clip.wrapMode.ToString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
data = new
|
||||
{
|
||||
gameObject = go.name,
|
||||
enabled = animator.enabled,
|
||||
speed = animator.speed,
|
||||
hasController = animator.runtimeAnimatorController != null,
|
||||
controllerName = animator.runtimeAnimatorController?.name,
|
||||
applyRootMotion = animator.applyRootMotion,
|
||||
updateMode = animator.updateMode.ToString(),
|
||||
cullingMode = animator.cullingMode.ToString(),
|
||||
parameterCount = animator.parameterCount,
|
||||
layerCount = animator.layerCount,
|
||||
parameters,
|
||||
layers,
|
||||
clips
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static object GetParameter(JObject @params)
|
||||
{
|
||||
var go = ObjectResolver.ResolveGameObject(@params["target"], @params["searchMethod"]?.ToString());
|
||||
if (go == null)
|
||||
return new { success = false, message = "Target GameObject not found" };
|
||||
|
||||
var animator = go.GetComponent<Animator>();
|
||||
if (animator == null)
|
||||
return new { success = false, message = $"No Animator component on '{go.name}'" };
|
||||
|
||||
string paramName = @params["parameterName"]?.ToString();
|
||||
if (string.IsNullOrEmpty(paramName))
|
||||
return new { success = false, message = "'parameterName' is required" };
|
||||
|
||||
AnimatorControllerParameter found = null;
|
||||
for (int i = 0; i < animator.parameterCount; i++)
|
||||
{
|
||||
var p = animator.GetParameter(i);
|
||||
if (p.name == paramName)
|
||||
{
|
||||
found = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (found == null)
|
||||
return new { success = false, message = $"Parameter '{paramName}' not found on Animator" };
|
||||
|
||||
object value;
|
||||
switch (found.type)
|
||||
{
|
||||
case AnimatorControllerParameterType.Float:
|
||||
value = animator.GetFloat(paramName);
|
||||
break;
|
||||
case AnimatorControllerParameterType.Int:
|
||||
value = animator.GetInteger(paramName);
|
||||
break;
|
||||
case AnimatorControllerParameterType.Bool:
|
||||
value = animator.GetBool(paramName);
|
||||
break;
|
||||
case AnimatorControllerParameterType.Trigger:
|
||||
value = animator.GetBool(paramName);
|
||||
break;
|
||||
default:
|
||||
value = null;
|
||||
break;
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
data = new
|
||||
{
|
||||
name = found.name,
|
||||
type = found.type.ToString(),
|
||||
value
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 66c604dff493453da1bc8cb6032ebf35
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,637 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Runtime.Helpers;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Animation
|
||||
{
|
||||
internal static class ClipCreate
|
||||
{
|
||||
public static object Create(JObject @params)
|
||||
{
|
||||
string clipPath = @params["clipPath"]?.ToString();
|
||||
if (string.IsNullOrEmpty(clipPath))
|
||||
return new { success = false, message = "'clipPath' is required (e.g. 'Assets/Animations/Walk.anim')" };
|
||||
|
||||
clipPath = AssetPathUtility.SanitizeAssetPath(clipPath);
|
||||
if (clipPath == null)
|
||||
return new { success = false, message = "Invalid asset path" };
|
||||
|
||||
if (!clipPath.EndsWith(".anim", StringComparison.OrdinalIgnoreCase))
|
||||
clipPath += ".anim";
|
||||
|
||||
// Ensure directory exists
|
||||
string dir = Path.GetDirectoryName(clipPath)?.Replace('\\', '/');
|
||||
if (!string.IsNullOrEmpty(dir) && !AssetDatabase.IsValidFolder(dir))
|
||||
{
|
||||
CreateFoldersRecursive(dir);
|
||||
}
|
||||
|
||||
// Check if already exists
|
||||
var existing = AssetDatabase.LoadAssetAtPath<AnimationClip>(clipPath);
|
||||
if (existing != null)
|
||||
return new { success = false, message = $"AnimationClip already exists at '{clipPath}'. Delete it first or use a different path." };
|
||||
|
||||
var clip = new AnimationClip();
|
||||
string name = @params["name"]?.ToString();
|
||||
clip.name = !string.IsNullOrEmpty(name)
|
||||
? name
|
||||
: Path.GetFileNameWithoutExtension(clipPath);
|
||||
|
||||
float length = @params["length"]?.ToObject<float>() ?? 1f;
|
||||
clip.frameRate = @params["frameRate"]?.ToObject<float>() ?? 60f;
|
||||
|
||||
bool loop = @params["loop"]?.ToObject<bool>() ?? false;
|
||||
var settings = AnimationUtility.GetAnimationClipSettings(clip);
|
||||
settings.loopTime = loop;
|
||||
settings.stopTime = length;
|
||||
AnimationUtility.SetAnimationClipSettings(clip, settings);
|
||||
|
||||
AssetDatabase.CreateAsset(clip, clipPath);
|
||||
|
||||
// Set m_WrapMode via SerializedObject — clip.wrapMode is a runtime property
|
||||
// that doesn't serialize to m_WrapMode, so we set it directly for the legacy system
|
||||
if (loop)
|
||||
{
|
||||
var so = new SerializedObject(clip);
|
||||
var wrapProp = so.FindProperty("m_WrapMode");
|
||||
if (wrapProp != null)
|
||||
{
|
||||
wrapProp.intValue = (int)WrapMode.Loop;
|
||||
so.ApplyModifiedProperties();
|
||||
}
|
||||
}
|
||||
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Created AnimationClip at '{clipPath}'",
|
||||
data = new
|
||||
{
|
||||
path = clipPath,
|
||||
name = clip.name,
|
||||
length,
|
||||
frameRate = clip.frameRate,
|
||||
isLooping = loop
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static object GetInfo(JObject @params)
|
||||
{
|
||||
string clipPath = @params["clipPath"]?.ToString();
|
||||
if (string.IsNullOrEmpty(clipPath))
|
||||
return new { success = false, message = "'clipPath' is required" };
|
||||
|
||||
clipPath = AssetPathUtility.SanitizeAssetPath(clipPath);
|
||||
if (clipPath == null)
|
||||
return new { success = false, message = "Invalid asset path" };
|
||||
|
||||
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(clipPath);
|
||||
if (clip == null)
|
||||
return new { success = false, message = $"AnimationClip not found at '{clipPath}'" };
|
||||
|
||||
var settings = AnimationUtility.GetAnimationClipSettings(clip);
|
||||
var bindings = AnimationUtility.GetCurveBindings(clip);
|
||||
|
||||
var curves = new List<object>();
|
||||
foreach (var binding in bindings)
|
||||
{
|
||||
var curve = AnimationUtility.GetEditorCurve(clip, binding);
|
||||
curves.Add(new
|
||||
{
|
||||
path = binding.path,
|
||||
propertyName = binding.propertyName,
|
||||
type = binding.type.Name,
|
||||
keyCount = curve?.length ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
var events = AnimationUtility.GetAnimationEvents(clip);
|
||||
var eventList = events.Select(e => new
|
||||
{
|
||||
time = e.time,
|
||||
functionName = e.functionName,
|
||||
stringParameter = e.stringParameter,
|
||||
floatParameter = e.floatParameter,
|
||||
intParameter = e.intParameter
|
||||
}).ToArray();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
data = new
|
||||
{
|
||||
path = clipPath,
|
||||
name = clip.name,
|
||||
length = clip.length,
|
||||
frameRate = clip.frameRate,
|
||||
isLooping = settings.loopTime,
|
||||
wrapMode = clip.wrapMode.ToString(),
|
||||
curveCount = bindings.Length,
|
||||
curves,
|
||||
eventCount = events.Length,
|
||||
events = eventList
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static object AddCurve(JObject @params)
|
||||
{
|
||||
return SetOrAddCurve(@params, append: true);
|
||||
}
|
||||
|
||||
public static object SetCurve(JObject @params)
|
||||
{
|
||||
return SetOrAddCurve(@params, append: false);
|
||||
}
|
||||
|
||||
private static object SetOrAddCurve(JObject @params, bool append)
|
||||
{
|
||||
string clipPath = @params["clipPath"]?.ToString();
|
||||
if (string.IsNullOrEmpty(clipPath))
|
||||
return new { success = false, message = "'clipPath' is required" };
|
||||
|
||||
clipPath = AssetPathUtility.SanitizeAssetPath(clipPath);
|
||||
if (clipPath == null)
|
||||
return new { success = false, message = "Invalid asset path" };
|
||||
|
||||
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(clipPath);
|
||||
if (clip == null)
|
||||
return new { success = false, message = $"AnimationClip not found at '{clipPath}'" };
|
||||
|
||||
string propertyPath = @params["propertyPath"]?.ToString();
|
||||
if (string.IsNullOrEmpty(propertyPath))
|
||||
return new { success = false, message = "'propertyPath' is required (e.g. 'localPosition.x')" };
|
||||
|
||||
string typeName = @params["type"]?.ToString() ?? "Transform";
|
||||
Type componentType = ResolveType(typeName);
|
||||
if (componentType == null)
|
||||
return new { success = false, message = $"Could not resolve type '{typeName}'" };
|
||||
|
||||
string relativePath = @params["relativePath"]?.ToString() ?? "";
|
||||
|
||||
JToken keysToken = @params["keys"];
|
||||
if (keysToken == null)
|
||||
return new { success = false, message = "'keys' is required" };
|
||||
|
||||
var keyframes = ParseKeyframes(keysToken);
|
||||
if (keyframes == null || keyframes.Length == 0)
|
||||
return new { success = false, message = "Failed to parse keyframes. Use [{\"time\":0,\"value\":0},...] or [[0,0],[1,1],...]" };
|
||||
|
||||
AnimationCurve curve;
|
||||
var binding = EditorCurveBinding.FloatCurve(relativePath, componentType, propertyPath);
|
||||
|
||||
if (append)
|
||||
{
|
||||
curve = AnimationUtility.GetEditorCurve(clip, binding) ?? new AnimationCurve();
|
||||
foreach (var kf in keyframes)
|
||||
{
|
||||
curve.AddKey(kf);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
curve = new AnimationCurve(keyframes);
|
||||
}
|
||||
|
||||
// Use AnimationUtility.SetEditorCurve instead of clip.SetCurve to avoid
|
||||
// marking the clip as legacy — legacy clips cannot be used in Mecanim BlendTrees.
|
||||
Undo.RecordObject(clip, append ? "Add Animation Curve" : "Set Animation Curve");
|
||||
AnimationUtility.SetEditorCurve(clip, binding, curve);
|
||||
EditorUtility.SetDirty(clip);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
string verb = append ? "Added" : "Set";
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"{verb} curve on '{propertyPath}' ({typeName}) with {keyframes.Length} keyframes",
|
||||
data = new
|
||||
{
|
||||
clipPath,
|
||||
propertyPath,
|
||||
type = typeName,
|
||||
keyframeCount = curve.length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static object SetVectorCurve(JObject @params)
|
||||
{
|
||||
string clipPath = @params["clipPath"]?.ToString();
|
||||
if (string.IsNullOrEmpty(clipPath))
|
||||
return new { success = false, message = "'clipPath' is required" };
|
||||
|
||||
clipPath = AssetPathUtility.SanitizeAssetPath(clipPath);
|
||||
if (clipPath == null)
|
||||
return new { success = false, message = "Invalid asset path" };
|
||||
|
||||
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(clipPath);
|
||||
if (clip == null)
|
||||
return new { success = false, message = $"AnimationClip not found at '{clipPath}'" };
|
||||
|
||||
// Accept both 'property' and 'propertyPath' for consistency with add_curve/set_curve
|
||||
string property = @params["property"]?.ToString() ?? @params["propertyPath"]?.ToString();
|
||||
if (string.IsNullOrEmpty(property))
|
||||
return new { success = false, message = "'property' (or 'propertyPath') is required (e.g. 'localPosition', 'localEulerAngles', 'localScale')" };
|
||||
|
||||
string typeName = @params["type"]?.ToString() ?? "Transform";
|
||||
Type componentType = ResolveType(typeName);
|
||||
if (componentType == null)
|
||||
return new { success = false, message = $"Could not resolve type '{typeName}'" };
|
||||
|
||||
string relativePath = @params["relativePath"]?.ToString() ?? "";
|
||||
|
||||
JToken keysToken = @params["keys"];
|
||||
if (keysToken == null || keysToken is not JArray keysArray || keysArray.Count == 0)
|
||||
return new { success = false, message = "'keys' is required. Use [{\"time\":0,\"value\":[0,1,0]},...]" };
|
||||
|
||||
// Map property group to axis suffixes
|
||||
string[] suffixes;
|
||||
switch (property.ToLowerInvariant())
|
||||
{
|
||||
case "localposition":
|
||||
property = "localPosition";
|
||||
suffixes = new[] { ".x", ".y", ".z" };
|
||||
break;
|
||||
case "localeulerangles":
|
||||
property = "localEulerAngles";
|
||||
suffixes = new[] { ".x", ".y", ".z" };
|
||||
break;
|
||||
case "localscale":
|
||||
property = "localScale";
|
||||
suffixes = new[] { ".x", ".y", ".z" };
|
||||
break;
|
||||
default:
|
||||
suffixes = new[] { ".x", ".y", ".z" };
|
||||
break;
|
||||
}
|
||||
|
||||
var xKeys = new List<Keyframe>();
|
||||
var yKeys = new List<Keyframe>();
|
||||
var zKeys = new List<Keyframe>();
|
||||
|
||||
foreach (var item in keysArray)
|
||||
{
|
||||
if (item is not JObject keyObj)
|
||||
return new { success = false, message = "Each key must be an object with 'time' and 'value' (Vector3 array)" };
|
||||
|
||||
float time = keyObj["time"]?.ToObject<float>() ?? 0f;
|
||||
JToken valueToken = keyObj["value"];
|
||||
if (valueToken is not JArray valArray || valArray.Count < 3)
|
||||
return new { success = false, message = $"Key at time {time}: 'value' must be a 3-element array [x, y, z]" };
|
||||
|
||||
float vx = valArray[0].ToObject<float>();
|
||||
float vy = valArray[1].ToObject<float>();
|
||||
float vz = valArray[2].ToObject<float>();
|
||||
|
||||
xKeys.Add(new Keyframe(time, vx));
|
||||
yKeys.Add(new Keyframe(time, vy));
|
||||
zKeys.Add(new Keyframe(time, vz));
|
||||
}
|
||||
|
||||
// Use AnimationUtility.SetEditorCurve instead of clip.SetCurve to avoid
|
||||
// marking the clip as legacy — legacy clips cannot be used in Mecanim BlendTrees.
|
||||
Undo.RecordObject(clip, "Set Vector Curve");
|
||||
var bindingX = EditorCurveBinding.FloatCurve(relativePath, componentType, property + suffixes[0]);
|
||||
var bindingY = EditorCurveBinding.FloatCurve(relativePath, componentType, property + suffixes[1]);
|
||||
var bindingZ = EditorCurveBinding.FloatCurve(relativePath, componentType, property + suffixes[2]);
|
||||
AnimationUtility.SetEditorCurve(clip, bindingX, new AnimationCurve(xKeys.ToArray()));
|
||||
AnimationUtility.SetEditorCurve(clip, bindingY, new AnimationCurve(yKeys.ToArray()));
|
||||
AnimationUtility.SetEditorCurve(clip, bindingZ, new AnimationCurve(zKeys.ToArray()));
|
||||
EditorUtility.SetDirty(clip);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Set 3 curves on '{property}' ({typeName}) with {keysArray.Count} vector keyframes",
|
||||
data = new
|
||||
{
|
||||
clipPath,
|
||||
property,
|
||||
type = typeName,
|
||||
curves = new[] { property + suffixes[0], property + suffixes[1], property + suffixes[2] },
|
||||
keyframeCount = keysArray.Count
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static object Assign(JObject @params)
|
||||
{
|
||||
var go = ObjectResolver.ResolveGameObject(@params["target"], @params["searchMethod"]?.ToString());
|
||||
if (go == null)
|
||||
return new { success = false, message = "Target GameObject not found" };
|
||||
|
||||
string clipPath = @params["clipPath"]?.ToString();
|
||||
if (string.IsNullOrEmpty(clipPath))
|
||||
return new { success = false, message = "'clipPath' is required" };
|
||||
|
||||
clipPath = AssetPathUtility.SanitizeAssetPath(clipPath);
|
||||
if (clipPath == null)
|
||||
return new { success = false, message = "Invalid asset path" };
|
||||
|
||||
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(clipPath);
|
||||
if (clip == null)
|
||||
return new { success = false, message = $"AnimationClip not found at '{clipPath}'" };
|
||||
|
||||
// Try legacy Animation component first
|
||||
var legacyAnim = go.GetComponent<UnityEngine.Animation>();
|
||||
if (legacyAnim != null)
|
||||
{
|
||||
var wasLegacy = clip.legacy;
|
||||
SetupLegacyClip(clip);
|
||||
Undo.RecordObject(legacyAnim, "Assign Animation Clip");
|
||||
legacyAnim.clip = clip;
|
||||
legacyAnim.AddClip(clip, clip.name);
|
||||
legacyAnim.playAutomatically = true;
|
||||
EditorUtility.SetDirty(legacyAnim);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
// Warn about AnimationEvents if present — they require a MonoBehaviour receiver
|
||||
var events = AnimationUtility.GetAnimationEvents(clip);
|
||||
string warning = "";
|
||||
if (events != null && events.Length > 0)
|
||||
{
|
||||
var eventNames = new System.Collections.Generic.List<string>();
|
||||
foreach (var e in events)
|
||||
eventNames.Add(e.functionName);
|
||||
warning = $" Warning: This clip has {events.Length} AnimationEvent(s) ({string.Join(", ", eventNames)}). " +
|
||||
$"'{go.name}' must have a MonoBehaviour with matching method(s) to receive them, " +
|
||||
"otherwise Unity will log 'AnimationEvent has no receiver' errors.";
|
||||
}
|
||||
|
||||
if (!wasLegacy) warning += " Warning: clip was converted to legacy and will not be usable in Mecanim/BlendTrees.";
|
||||
|
||||
return new { success = true, message = $"Assigned clip '{clip.name}' to Animation component on '{go.name}'.{warning}" };
|
||||
}
|
||||
|
||||
// Add Animation component if no Animator or Animation exists
|
||||
var animator = go.GetComponent<Animator>();
|
||||
if (animator == null)
|
||||
{
|
||||
var wasLegacy = clip.legacy;
|
||||
SetupLegacyClip(clip);
|
||||
Undo.RecordObject(go, "Add Animation Component");
|
||||
legacyAnim = Undo.AddComponent<UnityEngine.Animation>(go);
|
||||
legacyAnim.clip = clip;
|
||||
legacyAnim.AddClip(clip, clip.name);
|
||||
legacyAnim.playAutomatically = true;
|
||||
EditorUtility.SetDirty(go);
|
||||
AssetDatabase.SaveAssets();
|
||||
var legacyWarning = !wasLegacy ? " Warning: clip was converted to legacy and will not be usable in Mecanim/BlendTrees." : "";
|
||||
return new { success = true, message = $"Added Animation component and assigned clip '{clip.name}' to '{go.name}'.{legacyWarning}" };
|
||||
}
|
||||
|
||||
// Has Animator - we can't programmatically assign clips to Animator states easily,
|
||||
// so report what the user should do
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"GameObject '{go.name}' has an Animator component. The clip '{clip.name}' is available at '{clipPath}'. " +
|
||||
"Assign it to an Animator Controller state via the Animator window or create an AnimatorOverrideController."
|
||||
};
|
||||
}
|
||||
|
||||
private static void SetupLegacyClip(AnimationClip clip)
|
||||
{
|
||||
var so = new SerializedObject(clip);
|
||||
bool changed = false;
|
||||
|
||||
if (!clip.legacy)
|
||||
{
|
||||
var legacyProp = so.FindProperty("m_Legacy");
|
||||
if (legacyProp != null)
|
||||
{
|
||||
legacyProp.boolValue = true;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
var settings = AnimationUtility.GetAnimationClipSettings(clip);
|
||||
if (settings.loopTime)
|
||||
{
|
||||
var wrapProp = so.FindProperty("m_WrapMode");
|
||||
if (wrapProp != null && wrapProp.intValue != (int)WrapMode.Loop)
|
||||
{
|
||||
wrapProp.intValue = (int)WrapMode.Loop;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed)
|
||||
so.ApplyModifiedProperties();
|
||||
}
|
||||
|
||||
private static Keyframe[] ParseKeyframes(JToken keysToken)
|
||||
{
|
||||
if (keysToken is JArray array && array.Count > 0)
|
||||
{
|
||||
var keyframes = new List<Keyframe>();
|
||||
|
||||
foreach (var item in array)
|
||||
{
|
||||
if (item is JArray pair && pair.Count >= 2)
|
||||
{
|
||||
// Shorthand: [time, value]
|
||||
float time = pair[0].ToObject<float>();
|
||||
float value = pair[1].ToObject<float>();
|
||||
keyframes.Add(new Keyframe(time, value));
|
||||
}
|
||||
else if (item is JObject obj)
|
||||
{
|
||||
// Full form: {"time":0, "value":0, "inTangent":0, "outTangent":0}
|
||||
float time = obj["time"]?.ToObject<float>() ?? 0f;
|
||||
float value = obj["value"]?.ToObject<float>() ?? 0f;
|
||||
|
||||
var kf = new Keyframe(time, value);
|
||||
if (obj["inTangent"] != null)
|
||||
kf.inTangent = obj["inTangent"].ToObject<float>();
|
||||
if (obj["outTangent"] != null)
|
||||
kf.outTangent = obj["outTangent"].ToObject<float>();
|
||||
if (obj["inWeight"] != null)
|
||||
kf.inWeight = obj["inWeight"].ToObject<float>();
|
||||
if (obj["outWeight"] != null)
|
||||
kf.outWeight = obj["outWeight"].ToObject<float>();
|
||||
|
||||
keyframes.Add(kf);
|
||||
}
|
||||
}
|
||||
|
||||
return keyframes.ToArray();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Type ResolveType(string typeName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(typeName))
|
||||
return typeof(Transform);
|
||||
|
||||
// Try common Unity types
|
||||
Type type = Type.GetType($"UnityEngine.{typeName}, UnityEngine.CoreModule");
|
||||
if (type != null) return type;
|
||||
|
||||
type = Type.GetType($"UnityEngine.{typeName}, UnityEngine.AnimationModule");
|
||||
if (type != null) return type;
|
||||
|
||||
type = Type.GetType($"UnityEngine.{typeName}, UnityEngine");
|
||||
if (type != null) return type;
|
||||
|
||||
// Try fully qualified
|
||||
type = Type.GetType(typeName);
|
||||
if (type != null) return type;
|
||||
|
||||
// Fallback: search all loaded assemblies
|
||||
foreach (var assembly in UnityAssembliesCompat.GetLoadedAssemblies())
|
||||
{
|
||||
type = assembly.GetType(typeName);
|
||||
if (type != null) return type;
|
||||
|
||||
type = assembly.GetType($"UnityEngine.{typeName}");
|
||||
if (type != null) return type;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static object AddEvent(JObject @params)
|
||||
{
|
||||
string clipPath = @params["clipPath"]?.ToString();
|
||||
if (string.IsNullOrEmpty(clipPath))
|
||||
return new { success = false, message = "'clipPath' is required" };
|
||||
|
||||
clipPath = AssetPathUtility.SanitizeAssetPath(clipPath);
|
||||
if (clipPath == null)
|
||||
return new { success = false, message = "Invalid asset path" };
|
||||
|
||||
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(clipPath);
|
||||
if (clip == null)
|
||||
return new { success = false, message = $"AnimationClip not found at '{clipPath}'" };
|
||||
|
||||
float time = @params["time"]?.ToObject<float>() ?? 0f;
|
||||
string functionName = @params["functionName"]?.ToString();
|
||||
if (string.IsNullOrEmpty(functionName))
|
||||
return new { success = false, message = "'functionName' is required" };
|
||||
|
||||
var animEvent = new AnimationEvent
|
||||
{
|
||||
time = time,
|
||||
functionName = functionName,
|
||||
stringParameter = @params["stringParameter"]?.ToString() ?? "",
|
||||
floatParameter = @params["floatParameter"]?.ToObject<float>() ?? 0f,
|
||||
intParameter = @params["intParameter"]?.ToObject<int>() ?? 0
|
||||
};
|
||||
|
||||
var events = AnimationUtility.GetAnimationEvents(clip).ToList();
|
||||
events.Add(animEvent);
|
||||
AnimationUtility.SetAnimationEvents(clip, events.ToArray());
|
||||
|
||||
EditorUtility.SetDirty(clip);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Added event '{functionName}' at time {time} to '{clipPath}'",
|
||||
data = new
|
||||
{
|
||||
clipPath,
|
||||
time,
|
||||
functionName,
|
||||
stringParameter = animEvent.stringParameter,
|
||||
floatParameter = animEvent.floatParameter,
|
||||
intParameter = animEvent.intParameter
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static object RemoveEvent(JObject @params)
|
||||
{
|
||||
string clipPath = @params["clipPath"]?.ToString();
|
||||
if (string.IsNullOrEmpty(clipPath))
|
||||
return new { success = false, message = "'clipPath' is required" };
|
||||
|
||||
clipPath = AssetPathUtility.SanitizeAssetPath(clipPath);
|
||||
if (clipPath == null)
|
||||
return new { success = false, message = "Invalid asset path" };
|
||||
|
||||
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(clipPath);
|
||||
if (clip == null)
|
||||
return new { success = false, message = $"AnimationClip not found at '{clipPath}'" };
|
||||
|
||||
var events = AnimationUtility.GetAnimationEvents(clip).ToList();
|
||||
int originalCount = events.Count;
|
||||
|
||||
int? eventIndex = @params["eventIndex"]?.ToObject<int?>();
|
||||
if (eventIndex.HasValue)
|
||||
{
|
||||
if (eventIndex.Value < 0 || eventIndex.Value >= events.Count)
|
||||
return new { success = false, message = $"Event index {eventIndex.Value} out of range (0-{events.Count - 1})" };
|
||||
|
||||
events.RemoveAt(eventIndex.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
string functionName = @params["functionName"]?.ToString();
|
||||
if (string.IsNullOrEmpty(functionName))
|
||||
return new { success = false, message = "Either 'eventIndex' or 'functionName' is required" };
|
||||
|
||||
float? timeFilter = @params["time"]?.ToObject<float?>();
|
||||
events.RemoveAll(e =>
|
||||
{
|
||||
bool matchesFunction = e.functionName == functionName;
|
||||
bool matchesTime = !timeFilter.HasValue || Mathf.Approximately(e.time, timeFilter.Value);
|
||||
return matchesFunction && matchesTime;
|
||||
});
|
||||
}
|
||||
|
||||
int removedCount = originalCount - events.Count;
|
||||
if (removedCount == 0)
|
||||
return new { success = false, message = "No matching events found to remove" };
|
||||
|
||||
AnimationUtility.SetAnimationEvents(clip, events.ToArray());
|
||||
EditorUtility.SetDirty(clip);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Removed {removedCount} event(s) from '{clipPath}'",
|
||||
data = new
|
||||
{
|
||||
clipPath,
|
||||
removedCount,
|
||||
remainingCount = events.Count
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static void CreateFoldersRecursive(string folderPath)
|
||||
{
|
||||
if (AssetDatabase.IsValidFolder(folderPath))
|
||||
return;
|
||||
|
||||
string parent = Path.GetDirectoryName(folderPath)?.Replace('\\', '/');
|
||||
if (!string.IsNullOrEmpty(parent) && parent != "Assets" && !AssetDatabase.IsValidFolder(parent))
|
||||
{
|
||||
CreateFoldersRecursive(parent);
|
||||
}
|
||||
|
||||
string folderName = Path.GetFileName(folderPath);
|
||||
if (!string.IsNullOrEmpty(parent) && !string.IsNullOrEmpty(folderName))
|
||||
{
|
||||
AssetDatabase.CreateFolder(parent, folderName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d7513801696d4b16b906561009481548
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,401 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Animation
|
||||
{
|
||||
internal static class ClipPresets
|
||||
{
|
||||
private static readonly string[] ValidPresets = { "bounce", "rotate", "pulse", "fade", "shake", "hover", "spin", "sway", "bob", "wiggle", "blink", "slide_in", "elastic", "grow", "shrink" };
|
||||
|
||||
public static object CreatePreset(JObject @params)
|
||||
{
|
||||
string clipPath = @params["clipPath"]?.ToString();
|
||||
if (string.IsNullOrEmpty(clipPath))
|
||||
return new { success = false, message = "'clipPath' is required (e.g. 'Assets/Animations/Bounce.anim')" };
|
||||
|
||||
clipPath = AssetPathUtility.SanitizeAssetPath(clipPath);
|
||||
if (clipPath == null)
|
||||
return new { success = false, message = "Invalid asset path" };
|
||||
|
||||
if (!clipPath.EndsWith(".anim", StringComparison.OrdinalIgnoreCase))
|
||||
clipPath += ".anim";
|
||||
|
||||
string preset = @params["preset"]?.ToString()?.ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(preset))
|
||||
return new { success = false, message = $"'preset' is required. Valid: {string.Join(", ", ValidPresets)}" };
|
||||
|
||||
float duration = @params["duration"]?.ToObject<float>() ?? 1f;
|
||||
float amplitude = @params["amplitude"]?.ToObject<float>() ?? 1f;
|
||||
bool loop = @params["loop"]?.ToObject<bool>() ?? true;
|
||||
|
||||
// Resolve position offset from target GameObject or explicit offset parameter.
|
||||
// localPosition rather than absolute origin, preventing objects from jumping to (0,0,0).
|
||||
Vector3 offset = Vector3.zero;
|
||||
var targetToken = @params["target"];
|
||||
if (targetToken != null && targetToken.Type != JTokenType.Null)
|
||||
{
|
||||
string searchMethod = @params["searchMethod"]?.ToString();
|
||||
var go = ObjectResolver.ResolveGameObject(targetToken, searchMethod);
|
||||
if (go != null)
|
||||
offset = go.transform.localPosition;
|
||||
}
|
||||
var offsetToken = @params["offset"];
|
||||
if (offsetToken is JArray offsetArray && offsetArray.Count >= 3)
|
||||
{
|
||||
offset = new Vector3(
|
||||
offsetArray[0].ToObject<float>(),
|
||||
offsetArray[1].ToObject<float>(),
|
||||
offsetArray[2].ToObject<float>()
|
||||
);
|
||||
}
|
||||
|
||||
string dir = Path.GetDirectoryName(clipPath)?.Replace('\\', '/');
|
||||
if (!string.IsNullOrEmpty(dir) && !AssetDatabase.IsValidFolder(dir))
|
||||
CreateFoldersRecursive(dir);
|
||||
|
||||
var existing = AssetDatabase.LoadAssetAtPath<AnimationClip>(clipPath);
|
||||
if (existing != null)
|
||||
return new { success = false, message = $"AnimationClip already exists at '{clipPath}'. Delete it first or use a different path." };
|
||||
|
||||
var clip = new AnimationClip();
|
||||
clip.name = Path.GetFileNameWithoutExtension(clipPath);
|
||||
clip.frameRate = 60f;
|
||||
|
||||
var settings = AnimationUtility.GetAnimationClipSettings(clip);
|
||||
settings.loopTime = loop;
|
||||
settings.stopTime = duration;
|
||||
AnimationUtility.SetAnimationClipSettings(clip, settings);
|
||||
|
||||
switch (preset)
|
||||
{
|
||||
case "bounce":
|
||||
ApplyBounce(clip, duration, amplitude, offset);
|
||||
break;
|
||||
case "rotate":
|
||||
ApplyRotate(clip, duration, amplitude);
|
||||
break;
|
||||
case "pulse":
|
||||
ApplyPulse(clip, duration, amplitude);
|
||||
break;
|
||||
case "fade":
|
||||
ApplyFade(clip, duration);
|
||||
break;
|
||||
case "shake":
|
||||
ApplyShake(clip, duration, amplitude, offset);
|
||||
break;
|
||||
case "hover":
|
||||
ApplyHover(clip, duration, amplitude, offset);
|
||||
break;
|
||||
case "spin":
|
||||
ApplySpin(clip, duration, amplitude);
|
||||
break;
|
||||
case "sway":
|
||||
ApplySway(clip, duration, amplitude);
|
||||
break;
|
||||
case "bob":
|
||||
ApplyBob(clip, duration, amplitude, offset);
|
||||
break;
|
||||
case "wiggle":
|
||||
ApplyWiggle(clip, duration, amplitude);
|
||||
break;
|
||||
case "blink":
|
||||
ApplyBlink(clip, duration);
|
||||
break;
|
||||
case "slide_in":
|
||||
ApplySlideIn(clip, duration, amplitude, offset);
|
||||
break;
|
||||
case "elastic":
|
||||
ApplyElastic(clip, duration, amplitude);
|
||||
break;
|
||||
case "grow":
|
||||
ApplyGrow(clip, duration, amplitude);
|
||||
break;
|
||||
case "shrink":
|
||||
ApplyShrink(clip, duration, amplitude);
|
||||
break;
|
||||
default:
|
||||
return new { success = false, message = $"Unknown preset '{preset}'. Valid: {string.Join(", ", ValidPresets)}" };
|
||||
}
|
||||
|
||||
AssetDatabase.CreateAsset(clip, clipPath);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Created '{preset}' preset clip at '{clipPath}'" + (offset != Vector3.zero ? $" (offset: {offset})" : ""),
|
||||
data = new
|
||||
{
|
||||
path = clipPath,
|
||||
name = clip.name,
|
||||
preset,
|
||||
duration,
|
||||
amplitude,
|
||||
isLooping = loop,
|
||||
offset = new { x = offset.x, y = offset.y, z = offset.z },
|
||||
curveCount = AnimationUtility.GetCurveBindings(clip).Length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static void ApplyBounce(AnimationClip clip, float duration, float amplitude, Vector3 offset)
|
||||
{
|
||||
// localPosition.y sine wave oscillation, offset by target's current position
|
||||
float half = duration * 0.5f;
|
||||
var curve = new AnimationCurve(
|
||||
new Keyframe(0f, offset.y),
|
||||
new Keyframe(half * 0.5f, offset.y + amplitude),
|
||||
new Keyframe(half, offset.y),
|
||||
new Keyframe(half + half * 0.5f, offset.y + amplitude),
|
||||
new Keyframe(duration, offset.y)
|
||||
);
|
||||
SetTransformCurve(clip, "localPosition.y", curve);
|
||||
}
|
||||
|
||||
private static void ApplyRotate(AnimationClip clip, float duration, float amplitude)
|
||||
{
|
||||
// localEulerAngles.y full 360 rotation (amplitude acts as multiplier)
|
||||
var curve = new AnimationCurve(
|
||||
new Keyframe(0f, 0f),
|
||||
new Keyframe(duration, 360f * amplitude)
|
||||
);
|
||||
// Linear tangents for smooth rotation
|
||||
var keys = curve.keys;
|
||||
keys[0].outTangent = 360f * amplitude / duration;
|
||||
keys[1].inTangent = 360f * amplitude / duration;
|
||||
curve.keys = keys;
|
||||
SetTransformCurve(clip, "localEulerAngles.y", curve);
|
||||
}
|
||||
|
||||
private static void ApplyPulse(AnimationClip clip, float duration, float amplitude)
|
||||
{
|
||||
// localScale uniform scale up/down
|
||||
float peak = 1f + amplitude * 0.5f;
|
||||
float half = duration * 0.5f;
|
||||
var curve = new AnimationCurve(
|
||||
new Keyframe(0f, 1f),
|
||||
new Keyframe(half, peak),
|
||||
new Keyframe(duration, 1f)
|
||||
);
|
||||
SetTransformCurve(clip, "localScale.x", curve);
|
||||
SetTransformCurve(clip, "localScale.y", curve);
|
||||
SetTransformCurve(clip, "localScale.z", curve);
|
||||
}
|
||||
|
||||
private static void ApplyFade(AnimationClip clip, float duration)
|
||||
{
|
||||
// CanvasGroup alpha 1 -> 0
|
||||
var curve = new AnimationCurve(
|
||||
new Keyframe(0f, 1f),
|
||||
new Keyframe(duration, 0f)
|
||||
);
|
||||
var binding = EditorCurveBinding.FloatCurve("", typeof(CanvasGroup), "m_Alpha");
|
||||
AnimationUtility.SetEditorCurve(clip, binding, curve);
|
||||
}
|
||||
|
||||
private static void ApplyShake(AnimationClip clip, float duration, float amplitude, Vector3 offset)
|
||||
{
|
||||
// localPosition.x/z oscillation simulating shake, centered on target's current position
|
||||
int steps = 8;
|
||||
float stepTime = duration / steps;
|
||||
var xKeys = new Keyframe[steps + 1];
|
||||
var zKeys = new Keyframe[steps + 1];
|
||||
|
||||
for (int i = 0; i <= steps; i++)
|
||||
{
|
||||
float t = i * stepTime;
|
||||
float decay = 1f - (float)i / steps;
|
||||
// Alternating direction with decay
|
||||
float sign = (i % 2 == 0) ? 1f : -1f;
|
||||
xKeys[i] = new Keyframe(t, offset.x + sign * amplitude * decay);
|
||||
zKeys[i] = new Keyframe(t, offset.z - sign * amplitude * 0.5f * decay);
|
||||
}
|
||||
|
||||
// End at offset position
|
||||
xKeys[steps] = new Keyframe(duration, offset.x);
|
||||
zKeys[steps] = new Keyframe(duration, offset.z);
|
||||
|
||||
SetTransformCurve(clip, "localPosition.x", new AnimationCurve(xKeys));
|
||||
SetTransformCurve(clip, "localPosition.z", new AnimationCurve(zKeys));
|
||||
}
|
||||
|
||||
private static void ApplyHover(AnimationClip clip, float duration, float amplitude, Vector3 offset)
|
||||
{
|
||||
// localPosition.y gentle sine wave, offset by target's current position
|
||||
float q = duration * 0.25f;
|
||||
var curve = new AnimationCurve(
|
||||
new Keyframe(0f, offset.y),
|
||||
new Keyframe(q, offset.y + amplitude * 0.5f),
|
||||
new Keyframe(q * 2f, offset.y),
|
||||
new Keyframe(q * 3f, offset.y - amplitude * 0.5f),
|
||||
new Keyframe(duration, offset.y)
|
||||
);
|
||||
SetTransformCurve(clip, "localPosition.y", curve);
|
||||
}
|
||||
|
||||
private static void ApplySpin(AnimationClip clip, float duration, float amplitude)
|
||||
{
|
||||
// localEulerAngles.z continuous rotation
|
||||
var curve = new AnimationCurve(
|
||||
new Keyframe(0f, 0f),
|
||||
new Keyframe(duration, 360f * amplitude)
|
||||
);
|
||||
var keys = curve.keys;
|
||||
keys[0].outTangent = 360f * amplitude / duration;
|
||||
keys[1].inTangent = 360f * amplitude / duration;
|
||||
curve.keys = keys;
|
||||
SetTransformCurve(clip, "localEulerAngles.z", curve);
|
||||
}
|
||||
|
||||
private static void ApplySway(AnimationClip clip, float duration, float amplitude)
|
||||
{
|
||||
// localEulerAngles.z gentle side-to-side rotation (sine wave)
|
||||
float q = duration * 0.25f;
|
||||
var curve = new AnimationCurve(
|
||||
new Keyframe(0f, 0f),
|
||||
new Keyframe(q, amplitude),
|
||||
new Keyframe(q * 2f, 0f),
|
||||
new Keyframe(q * 3f, -amplitude),
|
||||
new Keyframe(duration, 0f)
|
||||
);
|
||||
SetTransformCurve(clip, "localEulerAngles.z", curve);
|
||||
}
|
||||
|
||||
private static void ApplyBob(AnimationClip clip, float duration, float amplitude, Vector3 offset)
|
||||
{
|
||||
// localPosition.z gentle forward/back movement, offset by target's current position
|
||||
float q = duration * 0.25f;
|
||||
var curve = new AnimationCurve(
|
||||
new Keyframe(0f, offset.z),
|
||||
new Keyframe(q, offset.z + amplitude * 0.5f),
|
||||
new Keyframe(q * 2f, offset.z),
|
||||
new Keyframe(q * 3f, offset.z - amplitude * 0.5f),
|
||||
new Keyframe(duration, offset.z)
|
||||
);
|
||||
SetTransformCurve(clip, "localPosition.z", curve);
|
||||
}
|
||||
|
||||
private static void ApplyWiggle(AnimationClip clip, float duration, float amplitude)
|
||||
{
|
||||
// localEulerAngles.z rapid oscillation (similar to shake but rotation)
|
||||
int steps = 8;
|
||||
float stepTime = duration / steps;
|
||||
var keys = new Keyframe[steps + 1];
|
||||
|
||||
for (int i = 0; i <= steps; i++)
|
||||
{
|
||||
float t = i * stepTime;
|
||||
float decay = 1f - (float)i / steps;
|
||||
float sign = (i % 2 == 0) ? 1f : -1f;
|
||||
keys[i] = new Keyframe(t, sign * amplitude * decay);
|
||||
}
|
||||
|
||||
keys[steps] = new Keyframe(duration, 0f);
|
||||
SetTransformCurve(clip, "localEulerAngles.z", new AnimationCurve(keys));
|
||||
}
|
||||
|
||||
private static void ApplyBlink(AnimationClip clip, float duration)
|
||||
{
|
||||
// localScale uniform scale to near-zero and back
|
||||
float mid = duration * 0.5f;
|
||||
var curve = new AnimationCurve(
|
||||
new Keyframe(0f, 1f),
|
||||
new Keyframe(mid, 0.05f),
|
||||
new Keyframe(duration, 1f)
|
||||
);
|
||||
SetTransformCurve(clip, "localScale.x", curve);
|
||||
SetTransformCurve(clip, "localScale.y", curve);
|
||||
SetTransformCurve(clip, "localScale.z", curve);
|
||||
}
|
||||
|
||||
private static void ApplySlideIn(AnimationClip clip, float duration, float amplitude, Vector3 offset)
|
||||
{
|
||||
// localPosition.x slide from offset-amplitude to offset (linear)
|
||||
var curve = new AnimationCurve(
|
||||
new Keyframe(0f, offset.x - amplitude),
|
||||
new Keyframe(duration, offset.x)
|
||||
);
|
||||
// Set linear tangents for smooth slide
|
||||
var keys = curve.keys;
|
||||
keys[0].outTangent = amplitude / duration;
|
||||
keys[1].inTangent = amplitude / duration;
|
||||
curve.keys = keys;
|
||||
SetTransformCurve(clip, "localPosition.x", curve);
|
||||
}
|
||||
|
||||
private static void ApplyElastic(AnimationClip clip, float duration, float amplitude)
|
||||
{
|
||||
// localScale uniform with overshoot effect
|
||||
float third = duration / 3f;
|
||||
float peak = 1f + amplitude * 1.2f;
|
||||
float settle = 1f + amplitude * 0.8f;
|
||||
var curve = new AnimationCurve(
|
||||
new Keyframe(0f, 1f),
|
||||
new Keyframe(third, peak),
|
||||
new Keyframe(third * 2f, settle),
|
||||
new Keyframe(duration, 1f)
|
||||
);
|
||||
SetTransformCurve(clip, "localScale.x", curve);
|
||||
SetTransformCurve(clip, "localScale.y", curve);
|
||||
SetTransformCurve(clip, "localScale.z", curve);
|
||||
}
|
||||
|
||||
private static void ApplyGrow(AnimationClip clip, float duration, float amplitude)
|
||||
{
|
||||
// localScale uniform from a reduced value up to 1.0
|
||||
float clamped = Mathf.Max(0f, amplitude);
|
||||
float start = Mathf.Clamp01(1f - clamped);
|
||||
var curve = new AnimationCurve(
|
||||
new Keyframe(0f, start),
|
||||
new Keyframe(duration, 1f)
|
||||
);
|
||||
SetTransformCurve(clip, "localScale.x", curve);
|
||||
SetTransformCurve(clip, "localScale.y", curve);
|
||||
SetTransformCurve(clip, "localScale.z", curve);
|
||||
}
|
||||
|
||||
private static void ApplyShrink(AnimationClip clip, float duration, float amplitude)
|
||||
{
|
||||
// localScale uniform from 1.0 down to a reduced value
|
||||
float clamped = Mathf.Max(0f, amplitude);
|
||||
float end = Mathf.Clamp01(1f - clamped);
|
||||
var curve = new AnimationCurve(
|
||||
new Keyframe(0f, 1f),
|
||||
new Keyframe(duration, end)
|
||||
);
|
||||
SetTransformCurve(clip, "localScale.x", curve);
|
||||
SetTransformCurve(clip, "localScale.y", curve);
|
||||
SetTransformCurve(clip, "localScale.z", curve);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets an animation curve on a Transform property using AnimationUtility.SetEditorCurve
|
||||
/// instead of clip.SetCurve to avoid marking the clip as legacy. Legacy clips cannot be
|
||||
/// used with Mecanim AnimatorControllers, and legacy Animation components take control of
|
||||
/// the entire Vector3 property (zeroing non-animated axes).
|
||||
/// </summary>
|
||||
private static void SetTransformCurve(AnimationClip clip, string propertyName, AnimationCurve curve)
|
||||
{
|
||||
var binding = EditorCurveBinding.FloatCurve("", typeof(Transform), propertyName);
|
||||
AnimationUtility.SetEditorCurve(clip, binding, curve);
|
||||
}
|
||||
|
||||
private static void CreateFoldersRecursive(string folderPath)
|
||||
{
|
||||
if (AssetDatabase.IsValidFolder(folderPath))
|
||||
return;
|
||||
|
||||
string parent = Path.GetDirectoryName(folderPath)?.Replace('\\', '/');
|
||||
if (!string.IsNullOrEmpty(parent) && parent != "Assets" && !AssetDatabase.IsValidFolder(parent))
|
||||
CreateFoldersRecursive(parent);
|
||||
|
||||
string folderName = Path.GetFileName(folderPath);
|
||||
if (!string.IsNullOrEmpty(parent) && !string.IsNullOrEmpty(folderName))
|
||||
AssetDatabase.CreateFolder(parent, folderName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b5e91d58c0a24e6db187f2a3c6840e29
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,255 @@
|
||||
using System;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Animations;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Animation
|
||||
{
|
||||
internal static class ControllerBlendTrees
|
||||
{
|
||||
public static object CreateBlendTree1D(JObject @params)
|
||||
{
|
||||
string controllerPath = @params["controllerPath"]?.ToString();
|
||||
if (string.IsNullOrEmpty(controllerPath))
|
||||
return new { success = false, message = "'controllerPath' is required" };
|
||||
|
||||
controllerPath = AssetPathUtility.SanitizeAssetPath(controllerPath);
|
||||
if (controllerPath == null)
|
||||
return new { success = false, message = "Invalid asset path" };
|
||||
|
||||
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(controllerPath);
|
||||
if (controller == null)
|
||||
return new { success = false, message = $"AnimatorController not found at '{controllerPath}'" };
|
||||
|
||||
string stateName = @params["stateName"]?.ToString();
|
||||
if (string.IsNullOrEmpty(stateName))
|
||||
return new { success = false, message = "'stateName' is required" };
|
||||
|
||||
string blendParameter = @params["blendParameter"]?.ToString();
|
||||
if (string.IsNullOrEmpty(blendParameter))
|
||||
return new { success = false, message = "'blendParameter' is required" };
|
||||
|
||||
int layerIndex = @params["layerIndex"]?.ToObject<int>() ?? 0;
|
||||
|
||||
var layers = controller.layers;
|
||||
if (layerIndex < 0 || layerIndex >= layers.Length)
|
||||
return new { success = false, message = $"Layer index {layerIndex} out of range (0-{layers.Length - 1})" };
|
||||
|
||||
var stateMachine = layers[layerIndex].stateMachine;
|
||||
|
||||
Undo.RecordObject(controller, "Create Blend Tree 1D");
|
||||
var state = stateMachine.AddState(stateName);
|
||||
var blendTree = new BlendTree
|
||||
{
|
||||
name = stateName,
|
||||
blendType = BlendTreeType.Simple1D,
|
||||
blendParameter = blendParameter,
|
||||
hideFlags = HideFlags.HideInHierarchy
|
||||
};
|
||||
|
||||
AssetDatabase.AddObjectToAsset(blendTree, controller);
|
||||
state.motion = blendTree;
|
||||
|
||||
EditorUtility.SetDirty(controller);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Created 1D blend tree state '{stateName}' in '{controllerPath}'",
|
||||
data = new
|
||||
{
|
||||
controllerPath,
|
||||
stateName,
|
||||
layerIndex,
|
||||
blendParameter,
|
||||
blendType = "Simple1D"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static object CreateBlendTree2D(JObject @params)
|
||||
{
|
||||
string controllerPath = @params["controllerPath"]?.ToString();
|
||||
if (string.IsNullOrEmpty(controllerPath))
|
||||
return new { success = false, message = "'controllerPath' is required" };
|
||||
|
||||
controllerPath = AssetPathUtility.SanitizeAssetPath(controllerPath);
|
||||
if (controllerPath == null)
|
||||
return new { success = false, message = "Invalid asset path" };
|
||||
|
||||
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(controllerPath);
|
||||
if (controller == null)
|
||||
return new { success = false, message = $"AnimatorController not found at '{controllerPath}'" };
|
||||
|
||||
string stateName = @params["stateName"]?.ToString();
|
||||
if (string.IsNullOrEmpty(stateName))
|
||||
return new { success = false, message = "'stateName' is required" };
|
||||
|
||||
string blendParameterX = @params["blendParameterX"]?.ToString();
|
||||
string blendParameterY = @params["blendParameterY"]?.ToString();
|
||||
if (string.IsNullOrEmpty(blendParameterX) || string.IsNullOrEmpty(blendParameterY))
|
||||
return new { success = false, message = "'blendParameterX' and 'blendParameterY' are required" };
|
||||
|
||||
int layerIndex = @params["layerIndex"]?.ToObject<int>() ?? 0;
|
||||
string blendTypeStr = @params["blendType"]?.ToString()?.ToLowerInvariant() ?? "simpledirectional2d";
|
||||
|
||||
BlendTreeType blendType = blendTypeStr switch
|
||||
{
|
||||
"freeformdirectional2d" => BlendTreeType.FreeformDirectional2D,
|
||||
"freeformcartesian2d" => BlendTreeType.FreeformCartesian2D,
|
||||
_ => BlendTreeType.SimpleDirectional2D
|
||||
};
|
||||
|
||||
var layers = controller.layers;
|
||||
if (layerIndex < 0 || layerIndex >= layers.Length)
|
||||
return new { success = false, message = $"Layer index {layerIndex} out of range (0-{layers.Length - 1})" };
|
||||
|
||||
var stateMachine = layers[layerIndex].stateMachine;
|
||||
|
||||
Undo.RecordObject(controller, "Create Blend Tree 2D");
|
||||
var state = stateMachine.AddState(stateName);
|
||||
var blendTree = new BlendTree
|
||||
{
|
||||
name = stateName,
|
||||
blendType = blendType,
|
||||
blendParameter = blendParameterX,
|
||||
blendParameterY = blendParameterY,
|
||||
hideFlags = HideFlags.HideInHierarchy
|
||||
};
|
||||
|
||||
AssetDatabase.AddObjectToAsset(blendTree, controller);
|
||||
state.motion = blendTree;
|
||||
|
||||
EditorUtility.SetDirty(controller);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Created 2D blend tree state '{stateName}' in '{controllerPath}'",
|
||||
data = new
|
||||
{
|
||||
controllerPath,
|
||||
stateName,
|
||||
layerIndex,
|
||||
blendParameterX,
|
||||
blendParameterY,
|
||||
blendType = blendType.ToString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static object AddBlendTreeChild(JObject @params)
|
||||
{
|
||||
string controllerPath = @params["controllerPath"]?.ToString();
|
||||
if (string.IsNullOrEmpty(controllerPath))
|
||||
return new { success = false, message = "'controllerPath' is required" };
|
||||
|
||||
controllerPath = AssetPathUtility.SanitizeAssetPath(controllerPath);
|
||||
if (controllerPath == null)
|
||||
return new { success = false, message = "Invalid asset path" };
|
||||
|
||||
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(controllerPath);
|
||||
if (controller == null)
|
||||
return new { success = false, message = $"AnimatorController not found at '{controllerPath}'" };
|
||||
|
||||
string stateName = @params["stateName"]?.ToString();
|
||||
if (string.IsNullOrEmpty(stateName))
|
||||
return new { success = false, message = "'stateName' is required" };
|
||||
|
||||
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}'" };
|
||||
|
||||
int layerIndex = @params["layerIndex"]?.ToObject<int>() ?? 0;
|
||||
|
||||
var layers = controller.layers;
|
||||
if (layerIndex < 0 || layerIndex >= layers.Length)
|
||||
return new { success = false, message = $"Layer index {layerIndex} out of range (0-{layers.Length - 1})" };
|
||||
|
||||
var stateMachine = layers[layerIndex].stateMachine;
|
||||
AnimatorState state = null;
|
||||
foreach (var s in stateMachine.states)
|
||||
{
|
||||
if (s.state.name == stateName)
|
||||
{
|
||||
state = s.state;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (state == null)
|
||||
return new { success = false, message = $"State '{stateName}' not found in layer {layerIndex}" };
|
||||
|
||||
if (!(state.motion is BlendTree blendTree))
|
||||
return new { success = false, message = $"State '{stateName}' does not have a BlendTree motion" };
|
||||
|
||||
Undo.RecordObject(blendTree, "Add Blend Tree Child");
|
||||
|
||||
if (blendTree.blendType == BlendTreeType.Simple1D)
|
||||
{
|
||||
float? threshold = @params["threshold"]?.ToObject<float?>();
|
||||
if (!threshold.HasValue)
|
||||
return new { success = false, message = "'threshold' is required for 1D blend trees" };
|
||||
|
||||
blendTree.AddChild(clip, threshold.Value);
|
||||
|
||||
EditorUtility.SetDirty(blendTree);
|
||||
EditorUtility.SetDirty(controller);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Added clip '{clip.name}' to blend tree '{stateName}' at threshold {threshold.Value}",
|
||||
data = new
|
||||
{
|
||||
controllerPath,
|
||||
stateName,
|
||||
clipPath,
|
||||
threshold = threshold.Value,
|
||||
childCount = blendTree.children.Length
|
||||
}
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
JToken positionToken = @params["position"];
|
||||
if (positionToken == null || !(positionToken is JArray posArray) || posArray.Count < 2)
|
||||
return new { success = false, message = "'position' is required for 2D blend trees as [x, y]" };
|
||||
|
||||
float posX = posArray[0].ToObject<float>();
|
||||
float posY = posArray[1].ToObject<float>();
|
||||
Vector2 position = new Vector2(posX, posY);
|
||||
|
||||
blendTree.AddChild(clip, position);
|
||||
|
||||
EditorUtility.SetDirty(blendTree);
|
||||
EditorUtility.SetDirty(controller);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Added clip '{clip.name}' to blend tree '{stateName}' at position ({posX}, {posY})",
|
||||
data = new
|
||||
{
|
||||
controllerPath,
|
||||
stateName,
|
||||
clipPath,
|
||||
position = new { x = posX, y = posY },
|
||||
childCount = blendTree.children.Length
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b8d4f0a2e53c5b7f9a6e1d4c8f0b2a5e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,458 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Animations;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Animation
|
||||
{
|
||||
internal static class ControllerCreate
|
||||
{
|
||||
public static object Create(JObject @params)
|
||||
{
|
||||
string controllerPath = @params["controllerPath"]?.ToString();
|
||||
if (string.IsNullOrEmpty(controllerPath))
|
||||
return new { success = false, message = "'controllerPath' is required (e.g. 'Assets/Animations/Player.controller')" };
|
||||
|
||||
controllerPath = AssetPathUtility.SanitizeAssetPath(controllerPath);
|
||||
if (controllerPath == null)
|
||||
return new { success = false, message = "Invalid asset path" };
|
||||
|
||||
if (!controllerPath.EndsWith(".controller", StringComparison.OrdinalIgnoreCase))
|
||||
controllerPath += ".controller";
|
||||
|
||||
string dir = Path.GetDirectoryName(controllerPath)?.Replace('\\', '/');
|
||||
if (!string.IsNullOrEmpty(dir) && !AssetDatabase.IsValidFolder(dir))
|
||||
CreateFoldersRecursive(dir);
|
||||
|
||||
var existing = AssetDatabase.LoadAssetAtPath<AnimatorController>(controllerPath);
|
||||
if (existing != null)
|
||||
return new { success = false, message = $"AnimatorController already exists at '{controllerPath}'. Delete it first or use a different path." };
|
||||
|
||||
var controller = AnimatorController.CreateAnimatorControllerAtPath(controllerPath);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Created AnimatorController at '{controllerPath}'",
|
||||
data = new
|
||||
{
|
||||
path = controllerPath,
|
||||
name = controller.name,
|
||||
layerCount = controller.layers.Length,
|
||||
parameterCount = controller.parameters.Length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static object AddState(JObject @params)
|
||||
{
|
||||
var controller = LoadController(@params);
|
||||
if (controller == null)
|
||||
return ControllerNotFoundError(@params);
|
||||
|
||||
string stateName = @params["stateName"]?.ToString();
|
||||
if (string.IsNullOrEmpty(stateName))
|
||||
return new { success = false, message = "'stateName' is required" };
|
||||
|
||||
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 (controller has {controller.layers.Length} layers)" };
|
||||
|
||||
var rootStateMachine = controller.layers[layerIndex].stateMachine;
|
||||
|
||||
// Check for duplicate state name
|
||||
foreach (var existingState in rootStateMachine.states)
|
||||
{
|
||||
if (existingState.state.name == stateName)
|
||||
return new { success = false, message = $"State '{stateName}' already exists in layer {layerIndex}" };
|
||||
}
|
||||
|
||||
var state = rootStateMachine.AddState(stateName);
|
||||
|
||||
// Optionally assign a clip
|
||||
string clipPath = @params["clipPath"]?.ToString();
|
||||
if (!string.IsNullOrEmpty(clipPath))
|
||||
{
|
||||
clipPath = AssetPathUtility.SanitizeAssetPath(clipPath);
|
||||
if (clipPath != null)
|
||||
{
|
||||
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(clipPath);
|
||||
if (clip != null)
|
||||
state.motion = clip;
|
||||
}
|
||||
}
|
||||
|
||||
float speed = @params["speed"]?.ToObject<float>() ?? 1f;
|
||||
state.speed = speed;
|
||||
|
||||
bool isDefault = @params["isDefault"]?.ToObject<bool>() ?? false;
|
||||
if (isDefault)
|
||||
rootStateMachine.defaultState = state;
|
||||
|
||||
EditorUtility.SetDirty(controller);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Added state '{stateName}' to layer {layerIndex}",
|
||||
data = new
|
||||
{
|
||||
stateName,
|
||||
layerIndex,
|
||||
hasMotion = state.motion != null,
|
||||
speed = state.speed,
|
||||
isDefault
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static object AddTransition(JObject @params)
|
||||
{
|
||||
var controller = LoadController(@params);
|
||||
if (controller == null)
|
||||
return ControllerNotFoundError(@params);
|
||||
|
||||
string fromStateName = @params["fromState"]?.ToString();
|
||||
string toStateName = @params["toState"]?.ToString();
|
||||
if (string.IsNullOrEmpty(fromStateName) || string.IsNullOrEmpty(toStateName))
|
||||
return new { success = false, message = "'fromState' and 'toState' are required" };
|
||||
|
||||
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;
|
||||
|
||||
// Check for AnyState as source
|
||||
bool isAnyState = string.Equals(fromStateName, "AnyState", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(fromStateName, "Any", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(fromStateName, "Any State", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
AnimatorState toState = null;
|
||||
foreach (var cs in rootStateMachine.states)
|
||||
{
|
||||
if (cs.state.name == toStateName) toState = cs.state;
|
||||
}
|
||||
|
||||
if (toState == null)
|
||||
return new { success = false, message = $"State '{toStateName}' not found in layer {layerIndex}" };
|
||||
|
||||
AnimatorStateTransition transition;
|
||||
if (isAnyState)
|
||||
{
|
||||
transition = rootStateMachine.AddAnyStateTransition(toState);
|
||||
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}" };
|
||||
|
||||
transition = fromState.AddTransition(toState);
|
||||
}
|
||||
|
||||
bool hasExitTime = @params["hasExitTime"]?.ToObject<bool>() ?? true;
|
||||
transition.hasExitTime = hasExitTime;
|
||||
|
||||
float duration = @params["duration"]?.ToObject<float>() ?? 0.25f;
|
||||
transition.duration = duration;
|
||||
|
||||
float exitTime = @params["exitTime"]?.ToObject<float>() ?? 0.75f;
|
||||
transition.exitTime = exitTime;
|
||||
|
||||
// Add conditions
|
||||
JToken conditionsToken = @params["conditions"];
|
||||
int conditionCount = 0;
|
||||
if (conditionsToken is JArray conditionsArray)
|
||||
{
|
||||
foreach (var condItem in conditionsArray)
|
||||
{
|
||||
if (condItem is not JObject condObj) continue;
|
||||
|
||||
string paramName = condObj["parameter"]?.ToString();
|
||||
if (string.IsNullOrEmpty(paramName)) continue;
|
||||
|
||||
string modeStr = condObj["mode"]?.ToString()?.ToLowerInvariant() ?? "greater";
|
||||
float threshold = condObj["threshold"]?.ToObject<float>() ?? 0f;
|
||||
|
||||
AnimatorConditionMode mode;
|
||||
switch (modeStr)
|
||||
{
|
||||
case "greater": mode = AnimatorConditionMode.Greater; break;
|
||||
case "less": mode = AnimatorConditionMode.Less; break;
|
||||
case "equals": mode = AnimatorConditionMode.Equals; break;
|
||||
case "notequal":
|
||||
case "not_equal": mode = AnimatorConditionMode.NotEqual; break;
|
||||
case "if":
|
||||
case "true": mode = AnimatorConditionMode.If; break;
|
||||
case "ifnot":
|
||||
case "if_not":
|
||||
case "false": mode = AnimatorConditionMode.IfNot; break;
|
||||
default: mode = AnimatorConditionMode.Greater; break;
|
||||
}
|
||||
|
||||
transition.AddCondition(mode, threshold, paramName);
|
||||
conditionCount++;
|
||||
}
|
||||
}
|
||||
|
||||
EditorUtility.SetDirty(controller);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Added transition from '{fromStateName}' to '{toStateName}' with {conditionCount} conditions",
|
||||
data = new
|
||||
{
|
||||
fromState = fromStateName,
|
||||
toState = toStateName,
|
||||
hasExitTime,
|
||||
duration,
|
||||
conditionCount
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static object AddParameter(JObject @params)
|
||||
{
|
||||
var controller = LoadController(@params);
|
||||
if (controller == null)
|
||||
return ControllerNotFoundError(@params);
|
||||
|
||||
string paramName = @params["parameterName"]?.ToString();
|
||||
if (string.IsNullOrEmpty(paramName))
|
||||
return new { success = false, message = "'parameterName' is required" };
|
||||
|
||||
string typeStr = @params["parameterType"]?.ToString()?.ToLowerInvariant() ?? "float";
|
||||
|
||||
AnimatorControllerParameterType paramType;
|
||||
switch (typeStr)
|
||||
{
|
||||
case "float": paramType = AnimatorControllerParameterType.Float; break;
|
||||
case "int":
|
||||
case "integer": paramType = AnimatorControllerParameterType.Int; break;
|
||||
case "bool":
|
||||
case "boolean": paramType = AnimatorControllerParameterType.Bool; break;
|
||||
case "trigger": paramType = AnimatorControllerParameterType.Trigger; break;
|
||||
default:
|
||||
return new { success = false, message = $"Unknown parameter type '{typeStr}'. Valid: float, int, bool, trigger" };
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
foreach (var existing in controller.parameters)
|
||||
{
|
||||
if (existing.name == paramName)
|
||||
return new { success = false, message = $"Parameter '{paramName}' already exists" };
|
||||
}
|
||||
|
||||
controller.AddParameter(paramName, paramType);
|
||||
|
||||
// Set default value if provided
|
||||
JToken defaultValue = @params["defaultValue"];
|
||||
if (defaultValue != null)
|
||||
{
|
||||
var allParams = controller.parameters;
|
||||
var addedParam = allParams[allParams.Length - 1];
|
||||
|
||||
switch (paramType)
|
||||
{
|
||||
case AnimatorControllerParameterType.Float:
|
||||
addedParam.defaultFloat = defaultValue.ToObject<float>();
|
||||
break;
|
||||
case AnimatorControllerParameterType.Int:
|
||||
addedParam.defaultInt = defaultValue.ToObject<int>();
|
||||
break;
|
||||
case AnimatorControllerParameterType.Bool:
|
||||
addedParam.defaultBool = defaultValue.ToObject<bool>();
|
||||
break;
|
||||
}
|
||||
|
||||
controller.parameters = allParams;
|
||||
}
|
||||
|
||||
EditorUtility.SetDirty(controller);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Added {typeStr} parameter '{paramName}'",
|
||||
data = new
|
||||
{
|
||||
parameterName = paramName,
|
||||
parameterType = typeStr,
|
||||
totalParameters = controller.parameters.Length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static object GetInfo(JObject @params)
|
||||
{
|
||||
var controller = LoadController(@params);
|
||||
if (controller == null)
|
||||
return ControllerNotFoundError(@params);
|
||||
|
||||
var layers = new List<object>();
|
||||
for (int i = 0; i < controller.layers.Length; i++)
|
||||
{
|
||||
var layer = controller.layers[i];
|
||||
var states = new List<object>();
|
||||
foreach (var cs in layer.stateMachine.states)
|
||||
{
|
||||
var transitions = new List<object>();
|
||||
foreach (var t in cs.state.transitions)
|
||||
{
|
||||
var conditions = new List<object>();
|
||||
foreach (var c in t.conditions)
|
||||
{
|
||||
conditions.Add(new
|
||||
{
|
||||
parameter = c.parameter,
|
||||
mode = c.mode.ToString(),
|
||||
threshold = c.threshold
|
||||
});
|
||||
}
|
||||
|
||||
transitions.Add(new
|
||||
{
|
||||
destinationState = t.destinationState?.name,
|
||||
hasExitTime = t.hasExitTime,
|
||||
exitTime = t.exitTime,
|
||||
duration = t.duration,
|
||||
conditionCount = t.conditions.Length,
|
||||
conditions
|
||||
});
|
||||
}
|
||||
|
||||
states.Add(new
|
||||
{
|
||||
name = cs.state.name,
|
||||
speed = cs.state.speed,
|
||||
hasMotion = cs.state.motion != null,
|
||||
motionName = cs.state.motion?.name,
|
||||
isDefault = layer.stateMachine.defaultState == cs.state,
|
||||
transitionCount = cs.state.transitions.Length,
|
||||
transitions
|
||||
});
|
||||
}
|
||||
|
||||
layers.Add(new
|
||||
{
|
||||
index = i,
|
||||
name = layer.name,
|
||||
stateCount = layer.stateMachine.states.Length,
|
||||
states
|
||||
});
|
||||
}
|
||||
|
||||
var parameters = new List<object>();
|
||||
foreach (var p in controller.parameters)
|
||||
{
|
||||
parameters.Add(new
|
||||
{
|
||||
name = p.name,
|
||||
type = p.type.ToString(),
|
||||
defaultFloat = p.defaultFloat,
|
||||
defaultInt = p.defaultInt,
|
||||
defaultBool = p.defaultBool
|
||||
});
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
data = new
|
||||
{
|
||||
path = AssetDatabase.GetAssetPath(controller),
|
||||
name = controller.name,
|
||||
layerCount = controller.layers.Length,
|
||||
parameterCount = controller.parameters.Length,
|
||||
layers,
|
||||
parameters
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static object AssignToGameObject(JObject @params)
|
||||
{
|
||||
var controller = LoadController(@params);
|
||||
if (controller == null)
|
||||
return ControllerNotFoundError(@params);
|
||||
|
||||
var go = ObjectResolver.ResolveGameObject(@params["target"], @params["searchMethod"]?.ToString());
|
||||
if (go == null)
|
||||
return new { success = false, message = "Target GameObject not found" };
|
||||
|
||||
var animator = go.GetComponent<Animator>();
|
||||
if (animator == null)
|
||||
{
|
||||
Undo.RecordObject(go, "Add Animator Component");
|
||||
animator = Undo.AddComponent<Animator>(go);
|
||||
}
|
||||
|
||||
Undo.RecordObject(animator, "Assign AnimatorController");
|
||||
animator.runtimeAnimatorController = controller;
|
||||
EditorUtility.SetDirty(go);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Assigned controller '{controller.name}' to '{go.name}'",
|
||||
data = new
|
||||
{
|
||||
gameObject = go.name,
|
||||
controllerName = controller.name,
|
||||
controllerPath = AssetDatabase.GetAssetPath(controller)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static AnimatorController LoadController(JObject @params)
|
||||
{
|
||||
string controllerPath = @params["controllerPath"]?.ToString();
|
||||
if (string.IsNullOrEmpty(controllerPath))
|
||||
return null;
|
||||
|
||||
controllerPath = AssetPathUtility.SanitizeAssetPath(controllerPath);
|
||||
if (controllerPath == null)
|
||||
return null;
|
||||
|
||||
return AssetDatabase.LoadAssetAtPath<AnimatorController>(controllerPath);
|
||||
}
|
||||
|
||||
private static object ControllerNotFoundError(JObject @params)
|
||||
{
|
||||
string path = @params["controllerPath"]?.ToString() ?? "(not specified)";
|
||||
return new { success = false, message = $"AnimatorController not found at '{path}'. Provide a valid 'controllerPath'." };
|
||||
}
|
||||
|
||||
private static void CreateFoldersRecursive(string folderPath)
|
||||
{
|
||||
if (AssetDatabase.IsValidFolder(folderPath))
|
||||
return;
|
||||
|
||||
string parent = Path.GetDirectoryName(folderPath)?.Replace('\\', '/');
|
||||
if (!string.IsNullOrEmpty(parent) && parent != "Assets" && !AssetDatabase.IsValidFolder(parent))
|
||||
CreateFoldersRecursive(parent);
|
||||
|
||||
string folderName = Path.GetFileName(folderPath);
|
||||
if (!string.IsNullOrEmpty(parent) && !string.IsNullOrEmpty(folderName))
|
||||
AssetDatabase.CreateFolder(parent, folderName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a3f82c47d9e14b8fa0c5e1b7d4923f16
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,202 @@
|
||||
using System;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Animations;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Animation
|
||||
{
|
||||
internal static class ControllerLayers
|
||||
{
|
||||
public static object AddLayer(JObject @params)
|
||||
{
|
||||
string controllerPath = @params["controllerPath"]?.ToString();
|
||||
if (string.IsNullOrEmpty(controllerPath))
|
||||
return new { success = false, message = "'controllerPath' is required" };
|
||||
|
||||
controllerPath = AssetPathUtility.SanitizeAssetPath(controllerPath);
|
||||
if (controllerPath == null)
|
||||
return new { success = false, message = "Invalid asset path" };
|
||||
|
||||
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(controllerPath);
|
||||
if (controller == null)
|
||||
return new { success = false, message = $"AnimatorController not found at '{controllerPath}'" };
|
||||
|
||||
string layerName = @params["layerName"]?.ToString();
|
||||
if (string.IsNullOrEmpty(layerName))
|
||||
return new { success = false, message = "'layerName' is required" };
|
||||
|
||||
float weight = @params["weight"]?.ToObject<float>() ?? 1f;
|
||||
string blendingModeStr = @params["blendingMode"]?.ToString()?.ToLowerInvariant() ?? "override";
|
||||
|
||||
AnimatorLayerBlendingMode blendingMode = blendingModeStr == "additive"
|
||||
? AnimatorLayerBlendingMode.Additive
|
||||
: AnimatorLayerBlendingMode.Override;
|
||||
|
||||
Undo.RecordObject(controller, "Add Layer");
|
||||
controller.AddLayer(layerName);
|
||||
|
||||
var layers = controller.layers;
|
||||
var newLayer = layers[layers.Length - 1];
|
||||
newLayer.defaultWeight = weight;
|
||||
newLayer.blendingMode = blendingMode;
|
||||
layers[layers.Length - 1] = newLayer;
|
||||
controller.layers = layers;
|
||||
|
||||
EditorUtility.SetDirty(controller);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Added layer '{layerName}' to '{controllerPath}'",
|
||||
data = new
|
||||
{
|
||||
controllerPath,
|
||||
layerName,
|
||||
layerIndex = layers.Length - 1,
|
||||
weight,
|
||||
blendingMode = blendingMode.ToString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static object RemoveLayer(JObject @params)
|
||||
{
|
||||
string controllerPath = @params["controllerPath"]?.ToString();
|
||||
if (string.IsNullOrEmpty(controllerPath))
|
||||
return new { success = false, message = "'controllerPath' is required" };
|
||||
|
||||
controllerPath = AssetPathUtility.SanitizeAssetPath(controllerPath);
|
||||
if (controllerPath == null)
|
||||
return new { success = false, message = "Invalid asset path" };
|
||||
|
||||
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(controllerPath);
|
||||
if (controller == null)
|
||||
return new { success = false, message = $"AnimatorController not found at '{controllerPath}'" };
|
||||
|
||||
int? layerIndex = @params["layerIndex"]?.ToObject<int?>();
|
||||
string layerName = @params["layerName"]?.ToString();
|
||||
|
||||
if (!layerIndex.HasValue && string.IsNullOrEmpty(layerName))
|
||||
return new { success = false, message = "Either 'layerIndex' or 'layerName' is required" };
|
||||
|
||||
var layers = controller.layers;
|
||||
if (layerIndex.HasValue)
|
||||
{
|
||||
if (layerIndex.Value < 0 || layerIndex.Value >= layers.Length)
|
||||
return new { success = false, message = $"Layer index {layerIndex.Value} out of range (0-{layers.Length - 1})" };
|
||||
|
||||
if (layerIndex.Value == 0)
|
||||
return new { success = false, message = "Cannot remove base layer (index 0)" };
|
||||
|
||||
layerName = layers[layerIndex.Value].name;
|
||||
}
|
||||
else
|
||||
{
|
||||
layerIndex = -1;
|
||||
for (int i = 0; i < layers.Length; i++)
|
||||
{
|
||||
if (layers[i].name == layerName)
|
||||
{
|
||||
layerIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (layerIndex.Value < 0)
|
||||
return new { success = false, message = $"Layer '{layerName}' not found" };
|
||||
|
||||
if (layerIndex.Value == 0)
|
||||
return new { success = false, message = $"Cannot remove base layer '{layerName}'" };
|
||||
}
|
||||
|
||||
Undo.RecordObject(controller, "Remove Layer");
|
||||
controller.RemoveLayer(layerIndex.Value);
|
||||
|
||||
EditorUtility.SetDirty(controller);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Removed layer '{layerName}' from '{controllerPath}'",
|
||||
data = new
|
||||
{
|
||||
controllerPath,
|
||||
layerName,
|
||||
layerIndex = layerIndex.Value
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static object SetLayerWeight(JObject @params)
|
||||
{
|
||||
string controllerPath = @params["controllerPath"]?.ToString();
|
||||
if (string.IsNullOrEmpty(controllerPath))
|
||||
return new { success = false, message = "'controllerPath' is required" };
|
||||
|
||||
controllerPath = AssetPathUtility.SanitizeAssetPath(controllerPath);
|
||||
if (controllerPath == null)
|
||||
return new { success = false, message = "Invalid asset path" };
|
||||
|
||||
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(controllerPath);
|
||||
if (controller == null)
|
||||
return new { success = false, message = $"AnimatorController not found at '{controllerPath}'" };
|
||||
|
||||
int? layerIndex = @params["layerIndex"]?.ToObject<int?>();
|
||||
string layerName = @params["layerName"]?.ToString();
|
||||
|
||||
if (!layerIndex.HasValue && string.IsNullOrEmpty(layerName))
|
||||
return new { success = false, message = "Either 'layerIndex' or 'layerName' is required" };
|
||||
|
||||
float weight = @params["weight"]?.ToObject<float>() ?? 1f;
|
||||
|
||||
var layers = controller.layers;
|
||||
if (layerIndex.HasValue)
|
||||
{
|
||||
if (layerIndex.Value < 0 || layerIndex.Value >= layers.Length)
|
||||
return new { success = false, message = $"Layer index {layerIndex.Value} out of range (0-{layers.Length - 1})" };
|
||||
|
||||
layerName = layers[layerIndex.Value].name;
|
||||
}
|
||||
else
|
||||
{
|
||||
layerIndex = -1;
|
||||
for (int i = 0; i < layers.Length; i++)
|
||||
{
|
||||
if (layers[i].name == layerName)
|
||||
{
|
||||
layerIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (layerIndex.Value < 0)
|
||||
return new { success = false, message = $"Layer '{layerName}' not found" };
|
||||
}
|
||||
|
||||
Undo.RecordObject(controller, "Set Layer Weight");
|
||||
var layer = layers[layerIndex.Value];
|
||||
layer.defaultWeight = weight;
|
||||
layers[layerIndex.Value] = layer;
|
||||
controller.layers = layers;
|
||||
|
||||
EditorUtility.SetDirty(controller);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Set layer '{layerName}' weight to {weight}",
|
||||
data = new
|
||||
{
|
||||
controllerPath,
|
||||
layerName,
|
||||
layerIndex = layerIndex.Value,
|
||||
weight
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a7c3e9f1d42b4a6e8f5d0c3b7e9a1f4d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,253 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Animation
|
||||
{
|
||||
[McpForUnityTool("manage_animation", AutoRegister = false, Group = "animation")]
|
||||
public static class ManageAnimation
|
||||
{
|
||||
private static readonly Dictionary<string, string> ParamAliases = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
{ "clip_path", "clipPath" },
|
||||
{ "controller_path", "controllerPath" },
|
||||
{ "state_name", "stateName" },
|
||||
{ "from_state", "fromState" },
|
||||
{ "to_state", "toState" },
|
||||
{ "parameter_name", "parameterName" },
|
||||
{ "parameter_type", "parameterType" },
|
||||
{ "property_path", "propertyPath" },
|
||||
{ "default_value", "defaultValue" },
|
||||
{ "has_exit_time", "hasExitTime" },
|
||||
{ "exit_time", "exitTime" },
|
||||
{ "layer_index", "layerIndex" },
|
||||
{ "is_default", "isDefault" },
|
||||
{ "relative_path", "relativePath" },
|
||||
{ "function_name", "functionName" },
|
||||
{ "string_parameter", "stringParameter" },
|
||||
{ "float_parameter", "floatParameter" },
|
||||
{ "int_parameter", "intParameter" },
|
||||
{ "event_index", "eventIndex" },
|
||||
{ "layer_name", "layerName" },
|
||||
{ "blending_mode", "blendingMode" },
|
||||
{ "blend_parameter", "blendParameter" },
|
||||
{ "blend_parameter_x", "blendParameterX" },
|
||||
{ "blend_parameter_y", "blendParameterY" },
|
||||
{ "blend_type", "blendType" },
|
||||
};
|
||||
|
||||
private static JObject NormalizeParams(JObject source)
|
||||
{
|
||||
if (source == null)
|
||||
{
|
||||
return new JObject();
|
||||
}
|
||||
|
||||
var normalized = new JObject();
|
||||
var properties = ExtractProperties(source);
|
||||
if (properties != null)
|
||||
{
|
||||
foreach (var prop in properties.Properties())
|
||||
{
|
||||
normalized[NormalizeKey(prop.Name, true)] = NormalizeToken(prop.Value);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var prop in source.Properties())
|
||||
{
|
||||
if (string.Equals(prop.Name, "properties", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
normalized[NormalizeKey(prop.Name, true)] = NormalizeToken(prop.Value);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static JObject ExtractProperties(JObject source)
|
||||
{
|
||||
if (source == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!source.TryGetValue("properties", StringComparison.OrdinalIgnoreCase, out var token))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (token == null || token.Type == JTokenType.Null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (token is JObject obj)
|
||||
{
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (token.Type == JTokenType.String)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JToken.Parse(token.ToString()) as JObject;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new JsonException(
|
||||
$"Failed to parse 'properties' JSON string. Raw value: {token}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeKey(string key, bool allowAliases)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
return key;
|
||||
}
|
||||
if (string.Equals(key, "action", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "action";
|
||||
}
|
||||
if (allowAliases && ParamAliases.TryGetValue(key, out var alias))
|
||||
{
|
||||
return alias;
|
||||
}
|
||||
if (key.IndexOf('_') >= 0)
|
||||
{
|
||||
return StringCaseUtility.ToCamelCase(key);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
private static JToken NormalizeToken(JToken token)
|
||||
{
|
||||
if (token == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (token is JObject obj)
|
||||
{
|
||||
var normalized = new JObject();
|
||||
foreach (var prop in obj.Properties())
|
||||
{
|
||||
normalized[NormalizeKey(prop.Name, false)] = NormalizeToken(prop.Value);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (token is JArray array)
|
||||
{
|
||||
var normalized = new JArray();
|
||||
foreach (var item in array)
|
||||
{
|
||||
normalized.Add(NormalizeToken(item));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
public static object HandleCommand(JObject @params)
|
||||
{
|
||||
JObject normalizedParams = NormalizeParams(@params);
|
||||
string action = normalizedParams["action"]?.ToString();
|
||||
if (string.IsNullOrEmpty(action))
|
||||
{
|
||||
return new { success = false, message = "Action is required" };
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string actionLower = action.ToLowerInvariant();
|
||||
|
||||
if (actionLower.StartsWith("animator_"))
|
||||
{
|
||||
return HandleAnimatorAction(normalizedParams, actionLower.Substring(9));
|
||||
}
|
||||
|
||||
if (actionLower.StartsWith("controller_"))
|
||||
{
|
||||
return HandleControllerAction(normalizedParams, actionLower.Substring(11));
|
||||
}
|
||||
|
||||
if (actionLower.StartsWith("clip_"))
|
||||
{
|
||||
return HandleClipAction(normalizedParams, actionLower.Substring(5));
|
||||
}
|
||||
|
||||
return new { success = false, message = $"Unknown action: {action}. Actions must be prefixed with: animator_, controller_, or clip_" };
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
McpLog.Error($"[ManageAnimation] Action '{action}' failed: {e}");
|
||||
return new ErrorResponse($"Internal error processing action '{action}': {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static object HandleAnimatorAction(JObject @params, string action)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case "get_info": return AnimatorRead.GetInfo(@params);
|
||||
case "get_parameter": return AnimatorRead.GetParameter(@params);
|
||||
case "play": return AnimatorControl.Play(@params);
|
||||
case "crossfade": return AnimatorControl.Crossfade(@params);
|
||||
case "set_parameter": return AnimatorControl.SetParameter(@params);
|
||||
case "set_speed": return AnimatorControl.SetSpeed(@params);
|
||||
case "set_enabled": return AnimatorControl.SetEnabled(@params);
|
||||
default:
|
||||
return new { success = false, message = $"Unknown animator action: {action}. Valid: get_info, get_parameter, play, crossfade, set_parameter, set_speed, set_enabled" };
|
||||
}
|
||||
}
|
||||
|
||||
private static object HandleControllerAction(JObject @params, string action)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case "create": return ControllerCreate.Create(@params);
|
||||
case "add_state": return ControllerCreate.AddState(@params);
|
||||
case "add_transition": return ControllerCreate.AddTransition(@params);
|
||||
case "add_parameter": return ControllerCreate.AddParameter(@params);
|
||||
case "get_info": return ControllerCreate.GetInfo(@params);
|
||||
case "assign": return ControllerCreate.AssignToGameObject(@params);
|
||||
case "add_layer": return ControllerLayers.AddLayer(@params);
|
||||
case "remove_layer": return ControllerLayers.RemoveLayer(@params);
|
||||
case "set_layer_weight": return ControllerLayers.SetLayerWeight(@params);
|
||||
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);
|
||||
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" };
|
||||
}
|
||||
}
|
||||
|
||||
private static object HandleClipAction(JObject @params, string action)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case "create": return ClipCreate.Create(@params);
|
||||
case "get_info": return ClipCreate.GetInfo(@params);
|
||||
case "add_curve": return ClipCreate.AddCurve(@params);
|
||||
case "set_curve": return ClipCreate.SetCurve(@params);
|
||||
case "set_vector_curve": return ClipCreate.SetVectorCurve(@params);
|
||||
case "create_preset": return ClipPresets.CreatePreset(@params);
|
||||
case "assign": return ClipCreate.Assign(@params);
|
||||
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" };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 13c8f20dbd31461796f64c421b0a1239
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9ba1214fff954a9498b166afcda0dc4b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,151 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Editor.Security;
|
||||
using MCPForUnity.Editor.Services.AssetGen;
|
||||
using MCPForUnity.Editor.Services.AssetGen.Providers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.AssetGen
|
||||
{
|
||||
/// <summary>
|
||||
/// 2D image generation via an aggregator (fal.ai / OpenRouter). Triggered here (never from the
|
||||
/// GUI); the C# side reads the provider key from the secure store and runs the job. Returns a
|
||||
/// job_id immediately; the client polls the `status` action.
|
||||
/// </summary>
|
||||
[McpForUnityTool("generate_image", AutoRegister = false, Group = "asset_gen", RequiresPolling = true, PollAction = "status", MaxPollSeconds = 300)]
|
||||
public static class GenerateImage
|
||||
{
|
||||
public static object HandleCommand(JObject @params)
|
||||
{
|
||||
if (@params == null) return new ErrorResponse("Parameters cannot be null.");
|
||||
var p = new ToolParams(@params);
|
||||
string action = (p.Get("action") ?? string.Empty).ToLowerInvariant();
|
||||
try
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case "generate": return Generate(p);
|
||||
case "remove_background":
|
||||
return new ErrorResponse("remove_background is not implemented in this version.");
|
||||
case "status": return Status(p);
|
||||
case "cancel": return Cancel(p);
|
||||
case "list_providers": return ListProviders();
|
||||
case "": return new ErrorResponse("'action' parameter is required.");
|
||||
default:
|
||||
return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, remove_background, status, cancel, list_providers.");
|
||||
}
|
||||
}
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
return new ErrorResponse(nse.Message);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return new ErrorResponse(SecretRedactor.Scrub(e.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static object Generate(ToolParams p)
|
||||
{
|
||||
string provider = (p.Get("provider", "fal") ?? "fal").ToLowerInvariant();
|
||||
AssetGenProviders.Image(provider); // throws NotSupportedException for unknown providers
|
||||
|
||||
if (!SecureKeyStore.Current.Has(provider))
|
||||
return new ErrorResponse(AssetGenProviders.MissingKeyMessage(provider));
|
||||
|
||||
var req = new ImageGenRequest
|
||||
{
|
||||
Provider = provider,
|
||||
Mode = (p.Get("mode", "text") ?? "text").ToLowerInvariant(),
|
||||
Prompt = p.Get("prompt"),
|
||||
ImagePath = p.Get("imagePath"),
|
||||
ImageUrl = p.Get("imageUrl"),
|
||||
Model = p.Get("model"),
|
||||
Transparent = p.GetBool("transparent", false),
|
||||
AsSprite = p.GetBool("asSprite", true),
|
||||
Width = p.GetInt("width", 0) ?? 0,
|
||||
Height = p.GetInt("height", 0) ?? 0,
|
||||
Name = p.Get("name"),
|
||||
OutputFolder = p.Get("outputFolder"),
|
||||
};
|
||||
if (!NormalizeOutputFolder(req.OutputFolder, out req.OutputFolder, out string outputErr))
|
||||
return new ErrorResponse(outputErr);
|
||||
|
||||
if (req.Mode == "text" && string.IsNullOrWhiteSpace(req.Prompt))
|
||||
return new ErrorResponse("'prompt' is required for text mode.");
|
||||
if (req.Mode == "image" && string.IsNullOrWhiteSpace(req.ImageUrl))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.ImagePath))
|
||||
return new ErrorResponse("image mode requires 'image_url' or 'image_path'.");
|
||||
if (!LocalImage.ResolveExisting(req.ImagePath, out string absImg, out string imgErr))
|
||||
return new ErrorResponse(imgErr);
|
||||
req.ImagePath = absImg;
|
||||
}
|
||||
|
||||
AssetGenJob job = AssetGenJobManager.StartImageGeneration(req);
|
||||
if (job.State == AssetGenJobState.Failed)
|
||||
return new ErrorResponse(job.Error ?? "Failed to start generation.");
|
||||
|
||||
return new PendingResponse(
|
||||
$"Image generation started with '{provider}'. Poll the status action with this job_id.",
|
||||
pollIntervalSeconds: 2.0,
|
||||
data: new { job_id = job.JobId, provider, status = "pending" });
|
||||
}
|
||||
|
||||
private static bool NormalizeOutputFolder(string outputFolder, out string normalized, out string error)
|
||||
{
|
||||
normalized = outputFolder;
|
||||
error = null;
|
||||
if (string.IsNullOrWhiteSpace(outputFolder)) return true;
|
||||
if (AssetGenPaths.TryGetAssetsFolder(outputFolder, out normalized)) return true;
|
||||
error = "'output_folder' must resolve under the project's Assets folder.";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static object Status(ToolParams p)
|
||||
{
|
||||
string jobId = p.Get("job_id");
|
||||
if (string.IsNullOrEmpty(jobId)) return new ErrorResponse("'job_id' is required for status.");
|
||||
AssetGenJob job = AssetGenJobManager.GetJob(jobId);
|
||||
if (job == null) return new ErrorResponse($"No job found with ID '{jobId}'.");
|
||||
|
||||
switch (job.State)
|
||||
{
|
||||
case AssetGenJobState.Done:
|
||||
return new SuccessResponse(
|
||||
$"Image generated: {job.AssetPath}",
|
||||
new { state = "done", asset_path = job.AssetPath, asset_guid = job.AssetGuid, progress = 1f });
|
||||
case AssetGenJobState.Failed:
|
||||
return new ErrorResponse(job.Error ?? "Generation failed.", new { state = "failed" });
|
||||
case AssetGenJobState.Canceled:
|
||||
return new SuccessResponse("Generation canceled.", new { state = "canceled" });
|
||||
default:
|
||||
return new PendingResponse(
|
||||
$"Image {job.State.ToString().ToLowerInvariant()} ({job.Progress:P0}).",
|
||||
pollIntervalSeconds: 2.0,
|
||||
data: new { job_id = job.JobId, state = job.State.ToString().ToLowerInvariant(), progress = job.Progress });
|
||||
}
|
||||
}
|
||||
|
||||
private static object Cancel(ToolParams p)
|
||||
{
|
||||
string jobId = p.Get("job_id");
|
||||
if (string.IsNullOrEmpty(jobId)) return new ErrorResponse("'job_id' is required for cancel.");
|
||||
return AssetGenJobManager.Cancel(jobId)
|
||||
? new SuccessResponse($"Cancel requested for job '{jobId}'.")
|
||||
: new ErrorResponse($"No cancelable job found with ID '{jobId}'.");
|
||||
}
|
||||
|
||||
private static object ListProviders()
|
||||
{
|
||||
var list = new List<object>();
|
||||
foreach (ProviderInfo info in AssetGenProviders.List())
|
||||
{
|
||||
if (info.Kind != "image") continue;
|
||||
list.Add(new { id = info.Id, kind = info.Kind, configured = info.Configured, capabilities = info.Capabilities });
|
||||
}
|
||||
return new SuccessResponse($"{list.Count} image provider(s).", new { providers = list });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a5e03ff5e4e040959fedaac555752094
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,147 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Editor.Security;
|
||||
using MCPForUnity.Editor.Services.AssetGen;
|
||||
using MCPForUnity.Editor.Services.AssetGen.Providers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.AssetGen
|
||||
{
|
||||
/// <summary>
|
||||
/// 3D model generation (Tripo/Meshy). Generation is triggered here (never from
|
||||
/// the GUI); the C# side reads the provider key from the secure store and runs the job.
|
||||
/// Long-running: returns a job_id immediately and the client polls the `status` action.
|
||||
/// </summary>
|
||||
[McpForUnityTool("generate_model", AutoRegister = false, Group = "asset_gen", RequiresPolling = true, PollAction = "status", MaxPollSeconds = 300)]
|
||||
public static class GenerateModel
|
||||
{
|
||||
public static object HandleCommand(JObject @params)
|
||||
{
|
||||
if (@params == null) return new ErrorResponse("Parameters cannot be null.");
|
||||
var p = new ToolParams(@params);
|
||||
string action = (p.Get("action") ?? string.Empty).ToLowerInvariant();
|
||||
try
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case "generate": return Generate(p);
|
||||
case "status": return Status(p);
|
||||
case "cancel": return Cancel(p);
|
||||
case "list_providers": return ListProviders();
|
||||
case "": return new ErrorResponse("'action' parameter is required.");
|
||||
default:
|
||||
return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, status, cancel, list_providers.");
|
||||
}
|
||||
}
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
return new ErrorResponse(nse.Message);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return new ErrorResponse(SecretRedactor.Scrub(e.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static object Generate(ToolParams p)
|
||||
{
|
||||
string provider = (p.Get("provider", "tripo") ?? "tripo").ToLowerInvariant();
|
||||
AssetGenProviders.Model(provider); // throws NotSupportedException for unimplemented providers
|
||||
|
||||
if (!SecureKeyStore.Current.Has(provider))
|
||||
return new ErrorResponse(AssetGenProviders.MissingKeyMessage(provider));
|
||||
|
||||
var req = new ModelGenRequest
|
||||
{
|
||||
Provider = provider,
|
||||
Mode = (p.Get("mode", "text") ?? "text").ToLowerInvariant(),
|
||||
Prompt = p.Get("prompt"),
|
||||
ImagePath = p.Get("imagePath"),
|
||||
ImageUrl = p.Get("imageUrl"),
|
||||
Format = (p.Get("format", "glb") ?? "glb").ToLowerInvariant(),
|
||||
TargetSize = p.GetFloat("targetSize", 1f) ?? 1f,
|
||||
Texture = p.GetBool("texture", true),
|
||||
Tier = p.Get("tier"),
|
||||
Name = p.Get("name"),
|
||||
OutputFolder = p.Get("outputFolder"),
|
||||
};
|
||||
if (!NormalizeOutputFolder(req.OutputFolder, out req.OutputFolder, out string outputErr))
|
||||
return new ErrorResponse(outputErr);
|
||||
|
||||
if (req.Mode == "text" && string.IsNullOrWhiteSpace(req.Prompt))
|
||||
return new ErrorResponse("'prompt' is required for text mode.");
|
||||
if (req.Mode == "image" && string.IsNullOrWhiteSpace(req.ImageUrl))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.ImagePath))
|
||||
return new ErrorResponse("image mode requires 'image_url' or 'image_path'.");
|
||||
if (provider == "tripo")
|
||||
return new ErrorResponse("Tripo image input requires a hosted 'image_url'; local 'image_path' is not supported for Tripo (use Meshy for local-image→3D).");
|
||||
if (!LocalImage.ResolveExisting(req.ImagePath, out string absImg, out string imgErr))
|
||||
return new ErrorResponse(imgErr);
|
||||
req.ImagePath = absImg;
|
||||
}
|
||||
|
||||
AssetGenJob job = AssetGenJobManager.StartModelGeneration(req);
|
||||
if (job.State == AssetGenJobState.Failed)
|
||||
return new ErrorResponse(job.Error ?? "Failed to start generation.");
|
||||
|
||||
return new PendingResponse(
|
||||
$"3D generation started with '{provider}'. Poll the status action with this job_id.",
|
||||
pollIntervalSeconds: 3.0,
|
||||
data: new { job_id = job.JobId, provider, status = "pending" });
|
||||
}
|
||||
|
||||
private static bool NormalizeOutputFolder(string outputFolder, out string normalized, out string error)
|
||||
{
|
||||
normalized = outputFolder;
|
||||
error = null;
|
||||
if (string.IsNullOrWhiteSpace(outputFolder)) return true;
|
||||
if (AssetGenPaths.TryGetAssetsFolder(outputFolder, out normalized)) return true;
|
||||
error = "'output_folder' must resolve under the project's Assets folder.";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static object Status(ToolParams p)
|
||||
{
|
||||
string jobId = p.Get("job_id");
|
||||
if (string.IsNullOrEmpty(jobId)) return new ErrorResponse("'job_id' is required for status.");
|
||||
AssetGenJob job = AssetGenJobManager.GetJob(jobId);
|
||||
if (job == null) return new ErrorResponse($"No job found with ID '{jobId}'.");
|
||||
|
||||
switch (job.State)
|
||||
{
|
||||
case AssetGenJobState.Done:
|
||||
return new SuccessResponse(
|
||||
$"Generation complete: {job.AssetPath}",
|
||||
new { state = "done", asset_path = job.AssetPath, asset_guid = job.AssetGuid, progress = 1f });
|
||||
case AssetGenJobState.Failed:
|
||||
return new ErrorResponse(job.Error ?? "Generation failed.", new { state = "failed" });
|
||||
case AssetGenJobState.Canceled:
|
||||
return new SuccessResponse("Generation canceled.", new { state = "canceled" });
|
||||
default:
|
||||
return new PendingResponse(
|
||||
$"Generation {job.State.ToString().ToLowerInvariant()} ({job.Progress:P0}).",
|
||||
pollIntervalSeconds: 3.0,
|
||||
data: new { job_id = job.JobId, state = job.State.ToString().ToLowerInvariant(), progress = job.Progress });
|
||||
}
|
||||
}
|
||||
|
||||
private static object Cancel(ToolParams p)
|
||||
{
|
||||
string jobId = p.Get("job_id");
|
||||
if (string.IsNullOrEmpty(jobId)) return new ErrorResponse("'job_id' is required for cancel.");
|
||||
return AssetGenJobManager.Cancel(jobId)
|
||||
? new SuccessResponse($"Cancel requested for job '{jobId}'.")
|
||||
: new ErrorResponse($"No cancelable job found with ID '{jobId}'.");
|
||||
}
|
||||
|
||||
private static object ListProviders()
|
||||
{
|
||||
var list = new List<object>();
|
||||
foreach (ProviderInfo info in AssetGenProviders.List())
|
||||
list.Add(new { id = info.Id, kind = info.Kind, configured = info.Configured, capabilities = info.Capabilities });
|
||||
return new SuccessResponse($"{list.Count} provider(s).", new { providers = list });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fa7787add8344fd99adfceff3b5dd57e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,171 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Editor.Security;
|
||||
using MCPForUnity.Editor.Services.AssetGen;
|
||||
using MCPForUnity.Editor.Services.AssetGen.Http;
|
||||
using MCPForUnity.Editor.Services.AssetGen.Providers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.AssetGen
|
||||
{
|
||||
/// <summary>
|
||||
/// 3D marketplace import (Sketchfab). Search/preview are read-only calls awaited directly:
|
||||
/// the handler is async so the underlying UnityWebRequest completes on the editor loop
|
||||
/// rather than blocking the main thread (a synchronous .GetResult() here deadlocks the
|
||||
/// editor — the request can only finish on a tick the blocked main thread can't run).
|
||||
/// Import downloads the model archive and unpacks it into the project as a long-running job
|
||||
/// (returns a job_id; the client polls the `status` action). The provider key is read from
|
||||
/// the secure store on the C# side and never transits the bridge.
|
||||
/// </summary>
|
||||
[McpForUnityTool("import_model", AutoRegister = false, Group = "asset_gen", RequiresPolling = true, PollAction = "status", MaxPollSeconds = 300)]
|
||||
public static class ImportModel
|
||||
{
|
||||
private const string Provider = "sketchfab";
|
||||
|
||||
// Test seam for the search/preview calls (import routes through the job manager's
|
||||
// own transport seam). Defaults to the production UnityWebRequest transport.
|
||||
internal static IHttpTransport TransportOverrideForTests;
|
||||
|
||||
public static async Task<object> HandleCommand(JObject @params)
|
||||
{
|
||||
if (@params == null) return new ErrorResponse("Parameters cannot be null.");
|
||||
var p = new ToolParams(@params);
|
||||
string action = (p.Get("action") ?? string.Empty).ToLowerInvariant();
|
||||
try
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case "search": return await Search(p);
|
||||
case "preview": return await Preview(p);
|
||||
case "import": return Import(p);
|
||||
case "status": return Status(p);
|
||||
case "cancel": return Cancel(p);
|
||||
case "list_providers": return ListProviders();
|
||||
case "": return new ErrorResponse("'action' parameter is required.");
|
||||
default:
|
||||
return new ErrorResponse($"Unknown action: '{action}'. Supported: search, preview, import, status, cancel, list_providers.");
|
||||
}
|
||||
}
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
return new ErrorResponse(nse.Message);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return new ErrorResponse(SecretRedactor.Scrub(e.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static IHttpTransport Transport() => TransportOverrideForTests ?? new UnityWebRequestTransport();
|
||||
|
||||
private static async Task<object> Search(ToolParams p)
|
||||
{
|
||||
string query = p.Get("query");
|
||||
if (string.IsNullOrWhiteSpace(query)) return new ErrorResponse("'query' is required for search.");
|
||||
if (!SecureKeyStore.Current.TryGet(Provider, out string key) || string.IsNullOrEmpty(key))
|
||||
return KeyError();
|
||||
|
||||
IMarketplaceProviderAdapter adapter = AssetGenProviders.Marketplace(Provider);
|
||||
string results = await adapter.SearchAsync(
|
||||
query, p.Get("categories"), p.GetBool("downloadable", true), p.GetInt("count"), p.Get("cursor"),
|
||||
key, Transport(), CancellationToken.None);
|
||||
return new SuccessResponse($"Search results for '{query}'.",
|
||||
new { provider = Provider, results = ParseOrRaw(results) });
|
||||
}
|
||||
|
||||
private static async Task<object> Preview(ToolParams p)
|
||||
{
|
||||
string uid = p.Get("uid");
|
||||
if (string.IsNullOrWhiteSpace(uid)) return new ErrorResponse("'uid' is required for preview.");
|
||||
if (!SecureKeyStore.Current.TryGet(Provider, out string key) || string.IsNullOrEmpty(key))
|
||||
return KeyError();
|
||||
|
||||
IMarketplaceProviderAdapter adapter = AssetGenProviders.Marketplace(Provider);
|
||||
string preview = await adapter.PreviewAsync(uid, key, Transport(), CancellationToken.None);
|
||||
return new SuccessResponse($"Preview for '{uid}'.",
|
||||
new { provider = Provider, uid, preview = ParseOrRaw(preview) });
|
||||
}
|
||||
|
||||
private static object Import(ToolParams p)
|
||||
{
|
||||
string uid = p.Get("uid");
|
||||
if (string.IsNullOrWhiteSpace(uid)) return new ErrorResponse("'uid' is required for import.");
|
||||
AssetGenProviders.Marketplace(Provider); // throws NotSupportedException for unimplemented providers
|
||||
|
||||
float targetSize = p.GetFloat("targetSize", 1f) ?? 1f;
|
||||
string name = p.Get("name");
|
||||
string outputFolder = p.Get("outputFolder");
|
||||
if (!string.IsNullOrWhiteSpace(outputFolder)
|
||||
&& !AssetGenPaths.TryGetAssetsFolder(outputFolder, out outputFolder))
|
||||
{
|
||||
return new ErrorResponse("'output_folder' must resolve under the project's Assets folder.");
|
||||
}
|
||||
|
||||
AssetGenJob job = AssetGenJobManager.StartMarketplaceImport(uid, targetSize, name, outputFolder);
|
||||
if (job.State == AssetGenJobState.Failed)
|
||||
return new ErrorResponse(job.Error ?? "Failed to start import.");
|
||||
|
||||
return new PendingResponse(
|
||||
$"Sketchfab import started for '{uid}'. Poll the status action with this job_id.",
|
||||
pollIntervalSeconds: 3.0,
|
||||
data: new { job_id = job.JobId, provider = Provider, status = "pending" });
|
||||
}
|
||||
|
||||
private static object Status(ToolParams p)
|
||||
{
|
||||
string jobId = p.Get("job_id");
|
||||
if (string.IsNullOrEmpty(jobId)) return new ErrorResponse("'job_id' is required for status.");
|
||||
AssetGenJob job = AssetGenJobManager.GetJob(jobId);
|
||||
if (job == null) return new ErrorResponse($"No job found with ID '{jobId}'.");
|
||||
|
||||
switch (job.State)
|
||||
{
|
||||
case AssetGenJobState.Done:
|
||||
return new SuccessResponse(
|
||||
$"Import complete: {job.AssetPath}",
|
||||
new { state = "done", asset_path = job.AssetPath, asset_guid = job.AssetGuid, progress = 1f });
|
||||
case AssetGenJobState.Failed:
|
||||
return new ErrorResponse(job.Error ?? "Import failed.", new { state = "failed" });
|
||||
case AssetGenJobState.Canceled:
|
||||
return new SuccessResponse("Import canceled.", new { state = "canceled" });
|
||||
default:
|
||||
return new PendingResponse(
|
||||
$"Import {job.State.ToString().ToLowerInvariant()} ({job.Progress:P0}).",
|
||||
pollIntervalSeconds: 3.0,
|
||||
data: new { job_id = job.JobId, state = job.State.ToString().ToLowerInvariant(), progress = job.Progress });
|
||||
}
|
||||
}
|
||||
|
||||
private static object Cancel(ToolParams p)
|
||||
{
|
||||
string jobId = p.Get("job_id");
|
||||
if (string.IsNullOrEmpty(jobId)) return new ErrorResponse("'job_id' is required for cancel.");
|
||||
return AssetGenJobManager.Cancel(jobId)
|
||||
? new SuccessResponse($"Cancel requested for job '{jobId}'.")
|
||||
: new ErrorResponse($"No cancelable job found with ID '{jobId}'.");
|
||||
}
|
||||
|
||||
private static object ListProviders()
|
||||
{
|
||||
var list = new List<object>();
|
||||
foreach (ProviderInfo info in AssetGenProviders.List())
|
||||
{
|
||||
if (info.Kind != "marketplace") continue;
|
||||
list.Add(new { id = info.Id, kind = info.Kind, configured = info.Configured, capabilities = info.Capabilities });
|
||||
}
|
||||
return new SuccessResponse($"{list.Count} provider(s).", new { providers = list });
|
||||
}
|
||||
|
||||
private static object KeyError()
|
||||
=> new ErrorResponse(AssetGenProviders.MissingKeyMessage(Provider));
|
||||
|
||||
private static object ParseOrRaw(string json)
|
||||
{
|
||||
if (string.IsNullOrEmpty(json)) return null;
|
||||
try { return JToken.Parse(json); } catch { return json; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 294fabfc32d2417ba2d6274ebcf3eb4b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Editor.Security;
|
||||
using MCPForUnity.Editor.Services.AssetGen;
|
||||
using MCPForUnity.Editor.Services.AssetGen.Import;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.AssetGen
|
||||
{
|
||||
/// <summary>
|
||||
/// Import a local 3D model file (already on disk — e.g. exported from Blender/Maya) into the
|
||||
/// Unity project. DCC-agnostic and key-free: the file is copied under Assets/ and run through
|
||||
/// the shared ModelImportPipeline (glTFast/FBX/OBJ/zip handling, scale-normalize, material
|
||||
/// settings). Placement into the scene is the caller's job (kept single-purpose).
|
||||
/// </summary>
|
||||
[McpForUnityTool("import_model_file", AutoRegister = false, Group = "asset_gen")]
|
||||
public static class ImportModelFile
|
||||
{
|
||||
private static readonly string[] SupportedExt = { ".fbx", ".obj", ".glb", ".gltf", ".zip" };
|
||||
|
||||
public static object HandleCommand(JObject @params)
|
||||
{
|
||||
if (@params == null) return new ErrorResponse("Parameters cannot be null.");
|
||||
var p = new ToolParams(@params);
|
||||
try
|
||||
{
|
||||
string source = p.Get("sourcePath");
|
||||
if (string.IsNullOrWhiteSpace(source))
|
||||
return new ErrorResponse("'source_path' is required.");
|
||||
|
||||
string srcAbs = ResolveSource(source);
|
||||
if (!File.Exists(srcAbs))
|
||||
return new ErrorResponse($"Source file not found: {source}");
|
||||
|
||||
string ext = Path.GetExtension(srcAbs).ToLowerInvariant();
|
||||
if (Array.IndexOf(SupportedExt, ext) < 0)
|
||||
return new ErrorResponse(
|
||||
$"Unsupported model extension '{ext}'. Supported: .fbx, .obj, .glb, .gltf, .zip.");
|
||||
|
||||
string baseName = p.Get("name");
|
||||
if (string.IsNullOrWhiteSpace(baseName))
|
||||
baseName = Path.GetFileNameWithoutExtension(srcAbs);
|
||||
|
||||
string destRel = StageUnderAssets(srcAbs, baseName, ext, p.Get("outputFolder"));
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
var job = new AssetGenJob
|
||||
{
|
||||
TargetSize = p.GetFloat("targetSize", 1f) ?? 1f,
|
||||
AnimationType = p.Get("animationType"),
|
||||
};
|
||||
AssetGenJob result = ModelImportPipeline.ImportInto(job, destRel);
|
||||
|
||||
if (result == null || result.State == AssetGenJobState.Failed)
|
||||
return new ErrorResponse(result?.Error ?? "Import failed.");
|
||||
|
||||
return new SuccessResponse(
|
||||
$"Imported model: {result.AssetPath}",
|
||||
new { asset_path = result.AssetPath, asset_guid = result.AssetGuid });
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return new ErrorResponse(SecretRedactor.Scrub(e.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveSource(string source)
|
||||
{
|
||||
string s = source.Replace('\\', '/');
|
||||
if (s == "Assets" || s.StartsWith("Assets/")) return AssetGenPaths.ToAbsolute(s);
|
||||
return s; // absolute path on disk
|
||||
}
|
||||
|
||||
private static string StageUnderAssets(string srcAbs, string baseName, string ext, string outputFolder)
|
||||
{
|
||||
string root = !string.IsNullOrWhiteSpace(outputFolder)
|
||||
? outputFolder
|
||||
: AssetGenPrefs.OutputRoot + "/Imported";
|
||||
if (!AssetGenPaths.TryGetAssetsFolder(root, out root))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(outputFolder))
|
||||
throw new ArgumentException("'output_folder' must resolve under the project's Assets folder.");
|
||||
root = AssetGenPrefs.DefaultOutputRoot + "/Imported";
|
||||
}
|
||||
|
||||
string absRoot = AssetGenPaths.ToAbsolute(root);
|
||||
Directory.CreateDirectory(absRoot);
|
||||
|
||||
string safe = SanitizeName(baseName);
|
||||
string fileName = safe + ext;
|
||||
string abs = Path.Combine(absRoot, fileName);
|
||||
int n = 1;
|
||||
while (File.Exists(abs)) { fileName = safe + "_" + n++ + ext; abs = Path.Combine(absRoot, fileName); }
|
||||
|
||||
File.Copy(srcAbs, abs);
|
||||
return (root.TrimEnd('/') + "/" + fileName).Replace('\\', '/');
|
||||
}
|
||||
|
||||
private static string SanitizeName(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return "model";
|
||||
foreach (char c in Path.GetInvalidFileNameChars()) raw = raw.Replace(c, '_');
|
||||
return raw.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e71d7281f8e84c45802ed38d1cceed81
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,235 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using MCPForUnity.Editor.Constants;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Editor.Services;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes multiple MCP commands within a single Unity-side handler. Commands are executed sequentially
|
||||
/// on the main thread to preserve determinism and Unity API safety.
|
||||
/// </summary>
|
||||
[McpForUnityTool("batch_execute", AutoRegister = false)]
|
||||
public static class BatchExecute
|
||||
{
|
||||
/// <summary>Default limit when no EditorPrefs override is set.</summary>
|
||||
internal const int DefaultMaxCommandsPerBatch = 25;
|
||||
|
||||
/// <summary>Hard ceiling to prevent extreme editor freezes regardless of user setting.</summary>
|
||||
internal const int AbsoluteMaxCommandsPerBatch = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the user-configured max commands per batch, clamped between 1 and <see cref="AbsoluteMaxCommandsPerBatch"/>.
|
||||
/// </summary>
|
||||
internal static int GetMaxCommandsPerBatch()
|
||||
{
|
||||
int configured = EditorPrefs.GetInt(EditorPrefKeys.BatchExecuteMaxCommands, DefaultMaxCommandsPerBatch);
|
||||
return Math.Clamp(configured, 1, AbsoluteMaxCommandsPerBatch);
|
||||
}
|
||||
|
||||
public static async Task<object> HandleCommand(JObject @params)
|
||||
{
|
||||
if (@params == null)
|
||||
{
|
||||
return new ErrorResponse("'commands' payload is required.");
|
||||
}
|
||||
|
||||
var commandsToken = @params["commands"] as JArray;
|
||||
if (commandsToken == null || commandsToken.Count == 0)
|
||||
{
|
||||
return new ErrorResponse("Provide at least one command entry in 'commands'.");
|
||||
}
|
||||
|
||||
int maxCommands = GetMaxCommandsPerBatch();
|
||||
if (commandsToken.Count > maxCommands)
|
||||
{
|
||||
return new ErrorResponse(
|
||||
$"A maximum of {maxCommands} commands are allowed per batch (configurable in MCP Tools window, hard max {AbsoluteMaxCommandsPerBatch}).");
|
||||
}
|
||||
|
||||
bool failFast = @params.Value<bool?>("failFast") ?? false;
|
||||
bool parallelRequested = @params.Value<bool?>("parallel") ?? false;
|
||||
int? maxParallel = @params.Value<int?>("maxParallelism");
|
||||
|
||||
if (parallelRequested)
|
||||
{
|
||||
McpLog.Warn("batch_execute parallel mode requested, but commands will run sequentially on the main thread for safety.");
|
||||
}
|
||||
|
||||
var commandResults = new List<object>(commandsToken.Count);
|
||||
int invocationSuccessCount = 0;
|
||||
int invocationFailureCount = 0;
|
||||
bool anyCommandFailed = false;
|
||||
|
||||
foreach (var token in commandsToken)
|
||||
{
|
||||
if (token is not JObject commandObj)
|
||||
{
|
||||
invocationFailureCount++;
|
||||
anyCommandFailed = true;
|
||||
commandResults.Add(new
|
||||
{
|
||||
tool = (string)null,
|
||||
callSucceeded = false,
|
||||
error = "Command entries must be JSON objects."
|
||||
});
|
||||
if (failFast)
|
||||
{
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
string toolName = commandObj["tool"]?.ToString();
|
||||
var rawParams = commandObj["params"] as JObject ?? new JObject();
|
||||
var commandParams = NormalizeParameterKeys(rawParams);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(toolName))
|
||||
{
|
||||
invocationFailureCount++;
|
||||
anyCommandFailed = true;
|
||||
commandResults.Add(new
|
||||
{
|
||||
tool = toolName,
|
||||
callSucceeded = false,
|
||||
error = "Each command must include a non-empty 'tool' field."
|
||||
});
|
||||
if (failFast)
|
||||
{
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Block disabled tools (mirrors TransportCommandDispatcher check)
|
||||
var toolMeta = MCPServiceLocator.ToolDiscovery.GetToolMetadata(toolName);
|
||||
if (toolMeta != null && !MCPServiceLocator.ToolDiscovery.IsToolEnabled(toolName))
|
||||
{
|
||||
invocationFailureCount++;
|
||||
anyCommandFailed = true;
|
||||
commandResults.Add(new
|
||||
{
|
||||
tool = toolName,
|
||||
callSucceeded = false,
|
||||
result = new ErrorResponse($"Tool '{toolName}' is disabled in the Unity Editor.")
|
||||
});
|
||||
if (failFast) break;
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await CommandRegistry.InvokeCommandAsync(toolName, commandParams).ConfigureAwait(true);
|
||||
bool callSucceeded = DetermineCallSucceeded(result);
|
||||
if (callSucceeded)
|
||||
{
|
||||
invocationSuccessCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
invocationFailureCount++;
|
||||
anyCommandFailed = true;
|
||||
}
|
||||
|
||||
commandResults.Add(new
|
||||
{
|
||||
tool = toolName,
|
||||
callSucceeded,
|
||||
result
|
||||
});
|
||||
|
||||
if (!callSucceeded && failFast)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
invocationFailureCount++;
|
||||
anyCommandFailed = true;
|
||||
commandResults.Add(new
|
||||
{
|
||||
tool = toolName,
|
||||
callSucceeded = false,
|
||||
error = ex.Message
|
||||
});
|
||||
|
||||
if (failFast)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool overallSuccess = !anyCommandFailed;
|
||||
var data = new
|
||||
{
|
||||
results = commandResults,
|
||||
callSuccessCount = invocationSuccessCount,
|
||||
callFailureCount = invocationFailureCount,
|
||||
parallelRequested,
|
||||
parallelApplied = false,
|
||||
maxParallelism = maxParallel
|
||||
};
|
||||
|
||||
return overallSuccess
|
||||
? new SuccessResponse("Batch execution completed.", data)
|
||||
: new ErrorResponse("One or more commands failed.", data);
|
||||
}
|
||||
|
||||
private static bool DetermineCallSucceeded(object result)
|
||||
{
|
||||
if (result == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (result is IMcpResponse response)
|
||||
{
|
||||
return response.Success;
|
||||
}
|
||||
|
||||
if (result is JObject obj)
|
||||
{
|
||||
var successToken = obj["success"];
|
||||
if (successToken != null && successToken.Type == JTokenType.Boolean)
|
||||
{
|
||||
return successToken.Value<bool>();
|
||||
}
|
||||
}
|
||||
|
||||
if (result is JToken token)
|
||||
{
|
||||
var successToken = token["success"];
|
||||
if (successToken != null && successToken.Type == JTokenType.Boolean)
|
||||
{
|
||||
return successToken.Value<bool>();
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static JObject NormalizeParameterKeys(JObject source)
|
||||
{
|
||||
if (source == null)
|
||||
{
|
||||
return new JObject();
|
||||
}
|
||||
|
||||
var normalized = new JObject();
|
||||
foreach (var property in source.Properties())
|
||||
{
|
||||
string normalizedName = ToCamelCase(property.Name);
|
||||
normalized[normalizedName] = property.Value;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string ToCamelCase(string key) => StringCaseUtility.ToCamelCase(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4e1e2d8f3a454a37b18d06a7a7b6c3fb
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6552334ce31748c6a1d4acd9f3b6ce06
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,189 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Build.Reporting;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Build
|
||||
{
|
||||
public enum BuildJobState
|
||||
{
|
||||
Pending,
|
||||
Building,
|
||||
Succeeded,
|
||||
Failed,
|
||||
Cancelled,
|
||||
Skipped
|
||||
}
|
||||
|
||||
public class BuildJob
|
||||
{
|
||||
public string JobId { get; }
|
||||
public BuildJobState State { get; set; } = BuildJobState.Pending;
|
||||
public BuildTarget Target { get; set; }
|
||||
public string OutputPath { get; set; }
|
||||
public DateTime StartedAt { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
public string ErrorMessage { get; set; }
|
||||
|
||||
// Extracted from BuildReport at completion to avoid retaining the heavy native object
|
||||
public double TotalSizeMb { get; set; }
|
||||
public int TotalErrors { get; set; }
|
||||
public int TotalWarnings { get; set; }
|
||||
|
||||
public BuildJob(string jobId, BuildTarget target, string outputPath)
|
||||
{
|
||||
JobId = jobId;
|
||||
Target = target;
|
||||
OutputPath = outputPath;
|
||||
}
|
||||
|
||||
public object ToStatusResponse()
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["job_id"] = JobId,
|
||||
["result"] = State.ToString().ToLowerInvariant(),
|
||||
["platform"] = Target.ToString(),
|
||||
["output_path"] = OutputPath
|
||||
};
|
||||
|
||||
if (StartedAt != default)
|
||||
data["started_at"] = StartedAt.ToString("O");
|
||||
|
||||
if (CompletedAt.HasValue)
|
||||
{
|
||||
data["duration_seconds"] = (CompletedAt.Value - StartedAt).TotalSeconds;
|
||||
data["completed_at"] = CompletedAt.Value.ToString("O");
|
||||
}
|
||||
|
||||
if (State == BuildJobState.Succeeded || State == BuildJobState.Failed)
|
||||
{
|
||||
data["total_size_mb"] = TotalSizeMb;
|
||||
data["errors"] = TotalErrors;
|
||||
data["warnings"] = TotalWarnings;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(ErrorMessage))
|
||||
data["error"] = ErrorMessage;
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
public class BatchJob
|
||||
{
|
||||
public string JobId { get; }
|
||||
public BuildJobState State { get; set; } = BuildJobState.Pending;
|
||||
public List<BuildJob> Children { get; } = new();
|
||||
public int CurrentIndex { get; set; } = -1;
|
||||
|
||||
public BatchJob(string jobId)
|
||||
{
|
||||
JobId = jobId;
|
||||
}
|
||||
|
||||
public object ToStatusResponse()
|
||||
{
|
||||
int completed = 0;
|
||||
string currentBuild = null;
|
||||
var builds = new List<object>();
|
||||
|
||||
foreach (var child in Children)
|
||||
{
|
||||
if (child.State == BuildJobState.Succeeded || child.State == BuildJobState.Failed
|
||||
|| child.State == BuildJobState.Skipped || child.State == BuildJobState.Cancelled)
|
||||
completed++;
|
||||
if (child.State == BuildJobState.Building)
|
||||
currentBuild = child.JobId;
|
||||
builds.Add(child.ToStatusResponse());
|
||||
}
|
||||
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
["job_id"] = JobId,
|
||||
["result"] = State.ToString().ToLowerInvariant(),
|
||||
["completed"] = completed,
|
||||
["total"] = Children.Count,
|
||||
["current_build"] = currentBuild,
|
||||
["builds"] = builds
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static store for all build jobs. Note: static fields are cleared on domain reload,
|
||||
/// but this is acceptable because BuildPipeline.BuildPlayer blocks the editor thread,
|
||||
/// preventing domain reload during a build. For batch builds with platform switches,
|
||||
/// the batch scheduling happens after each build completes via EditorApplication.update
|
||||
/// callbacks (ScheduleOnNextUpdate / WaitForCompletion), so state is maintained within
|
||||
/// a single domain lifecycle.
|
||||
/// </summary>
|
||||
public static class BuildJobStore
|
||||
{
|
||||
private static readonly Dictionary<string, BuildJob> _buildJobs = new();
|
||||
private static readonly Dictionary<string, BatchJob> _batchJobs = new();
|
||||
private static BuildJob _lastCompletedJob;
|
||||
|
||||
public static string CreateJobId() => $"build-{Guid.NewGuid():N}".Substring(0, 16);
|
||||
public static string CreateBatchId() => $"batch-{Guid.NewGuid():N}".Substring(0, 16);
|
||||
|
||||
public static void AddBuildJob(BuildJob job) => _buildJobs[job.JobId] = job;
|
||||
public static void AddBatchJob(BatchJob job) => _batchJobs[job.JobId] = job;
|
||||
|
||||
public static BuildJob GetBuildJob(string jobId)
|
||||
{
|
||||
_buildJobs.TryGetValue(jobId, out var job);
|
||||
return job;
|
||||
}
|
||||
|
||||
public static BatchJob GetBatchJob(string jobId)
|
||||
{
|
||||
_batchJobs.TryGetValue(jobId, out var job);
|
||||
return job;
|
||||
}
|
||||
|
||||
public static BuildJob LastCompletedJob => _lastCompletedJob;
|
||||
|
||||
public static void SetLastCompleted(BuildJob job)
|
||||
{
|
||||
_lastCompletedJob = job;
|
||||
PruneOldJobs();
|
||||
}
|
||||
|
||||
private const int MaxRetainedJobs = 50;
|
||||
|
||||
private static void PruneOldJobs()
|
||||
{
|
||||
if (_buildJobs.Count <= MaxRetainedJobs) return;
|
||||
|
||||
var toRemove = new List<string>();
|
||||
foreach (var kvp in _buildJobs)
|
||||
{
|
||||
if (kvp.Value.State != BuildJobState.Building && kvp.Value.State != BuildJobState.Pending
|
||||
&& kvp.Value != _lastCompletedJob)
|
||||
toRemove.Add(kvp.Key);
|
||||
}
|
||||
|
||||
foreach (var key in toRemove)
|
||||
{
|
||||
_buildJobs.Remove(key);
|
||||
if (_buildJobs.Count <= MaxRetainedJobs / 2) break;
|
||||
}
|
||||
|
||||
// Also prune batch jobs whose children are all terminal
|
||||
var batchesToRemove = new List<string>();
|
||||
foreach (var kvp in _batchJobs)
|
||||
{
|
||||
var batch = kvp.Value;
|
||||
if (batch.State == BuildJobState.Building || batch.State == BuildJobState.Pending)
|
||||
continue;
|
||||
// Remove child references that were already pruned from _buildJobs
|
||||
batch.Children.RemoveAll(c => !_buildJobs.ContainsKey(c.JobId));
|
||||
if (batch.Children.Count == 0)
|
||||
batchesToRemove.Add(kvp.Key);
|
||||
}
|
||||
foreach (var key in batchesToRemove)
|
||||
_batchJobs.Remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6c04470155554320bc9e1655674d6aa7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,234 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Build.Reporting;
|
||||
using UnityEditor.Build;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Build
|
||||
{
|
||||
public static class BuildRunner
|
||||
{
|
||||
public static object ScheduleBuild(BuildJob job, BuildPlayerOptions options)
|
||||
{
|
||||
job.State = BuildJobState.Pending;
|
||||
BuildJobStore.AddBuildJob(job);
|
||||
|
||||
ScheduleOnNextUpdate(() =>
|
||||
RunBuildCore(job, () => BuildPipeline.BuildPlayer(options)));
|
||||
|
||||
return new PendingResponse(
|
||||
$"Build scheduled for {job.Target}. Polling for completion...",
|
||||
pollIntervalSeconds: 5.0,
|
||||
data: new { job_id = job.JobId, platform = job.Target.ToString() }
|
||||
);
|
||||
}
|
||||
|
||||
#if UNITY_6000_0_OR_NEWER
|
||||
public static object ScheduleProfileBuild(BuildJob job, BuildPlayerWithProfileOptions options)
|
||||
{
|
||||
job.State = BuildJobState.Pending;
|
||||
BuildJobStore.AddBuildJob(job);
|
||||
|
||||
ScheduleOnNextUpdate(() =>
|
||||
RunBuildCore(job, () => BuildPipeline.BuildPlayer(options)));
|
||||
|
||||
return new PendingResponse(
|
||||
$"Profile build scheduled for {job.Target}. Polling for completion...",
|
||||
pollIntervalSeconds: 5.0,
|
||||
data: new { job_id = job.JobId, platform = job.Target.ToString() }
|
||||
);
|
||||
}
|
||||
#endif
|
||||
|
||||
public static BuildPlayerOptions CreateBuildOptions(
|
||||
BuildTarget target,
|
||||
string outputPath,
|
||||
string[] scenes,
|
||||
BuildOptions buildOptions,
|
||||
int subtarget)
|
||||
{
|
||||
var options = new BuildPlayerOptions
|
||||
{
|
||||
target = target,
|
||||
targetGroup = BuildTargetMapping.GetTargetGroup(target),
|
||||
locationPathName = outputPath,
|
||||
scenes = scenes ?? GetDefaultScenes(),
|
||||
options = buildOptions,
|
||||
};
|
||||
|
||||
// Subtarget is only meaningful for Standalone (Server vs Player).
|
||||
// On Android/iOS it maps to MobileTextureSubtarget (texture compression format).
|
||||
// Passing StandaloneBuildSubtarget.Player (0) for mobile platforms forces
|
||||
// a specific texture format — confirmed Unity bug IN-102413 where value 0
|
||||
// on Unity 6000+ triggers PVRTC, ignoring Player Settings.
|
||||
// Leave at platform default (0) so Unity respects Player Settings.
|
||||
if (target == BuildTarget.StandaloneWindows
|
||||
|| target == BuildTarget.StandaloneWindows64
|
||||
|| target == BuildTarget.StandaloneOSX
|
||||
|| target == BuildTarget.StandaloneLinux64)
|
||||
{
|
||||
options.subtarget = subtarget;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
public static BuildOptions ParseBuildOptions(string[] optionNames, bool development)
|
||||
{
|
||||
var opts = BuildOptions.None;
|
||||
if (development)
|
||||
opts |= BuildOptions.Development;
|
||||
|
||||
if (optionNames == null) return opts;
|
||||
|
||||
foreach (var name in optionNames)
|
||||
{
|
||||
switch (name.ToLowerInvariant())
|
||||
{
|
||||
case "clean_build": opts |= BuildOptions.CleanBuildCache; break;
|
||||
case "auto_run": opts |= BuildOptions.AutoRunPlayer; break;
|
||||
case "deep_profiling": opts |= BuildOptions.EnableDeepProfilingSupport; break;
|
||||
case "compress_lz4": opts |= BuildOptions.CompressWithLz4; break;
|
||||
case "strict_mode": opts |= BuildOptions.StrictMode; break;
|
||||
case "detailed_report": opts |= BuildOptions.DetailedBuildReport; break;
|
||||
case "allow_debugging": opts |= BuildOptions.AllowDebugging; break;
|
||||
case "connect_profiler": opts |= BuildOptions.ConnectWithProfiler; break;
|
||||
case "scripts_only": opts |= BuildOptions.BuildScriptsOnly; break;
|
||||
case "show_player": opts |= BuildOptions.ShowBuiltPlayer; break;
|
||||
case "include_tests": opts |= BuildOptions.IncludeTestAssemblies; break;
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
private static void RunBuildCore(BuildJob job, Func<BuildReport> buildFunc)
|
||||
{
|
||||
job.State = BuildJobState.Building;
|
||||
job.StartedAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
BuildReport report = buildFunc();
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
|
||||
// Extract summary data immediately, then let the BuildReport be GC'd
|
||||
var summary = report.summary;
|
||||
job.TotalSizeMb = Math.Round(summary.totalSize / (1024.0 * 1024.0), 2);
|
||||
job.TotalErrors = summary.totalErrors;
|
||||
job.TotalWarnings = summary.totalWarnings;
|
||||
|
||||
if (summary.result == BuildResult.Succeeded)
|
||||
{
|
||||
job.State = BuildJobState.Succeeded;
|
||||
}
|
||||
else
|
||||
{
|
||||
job.State = BuildJobState.Failed;
|
||||
#if UNITY_2023_1_OR_NEWER
|
||||
job.ErrorMessage = report.SummarizeErrors();
|
||||
#else
|
||||
job.ErrorMessage = $"Build failed with result: {summary.result}";
|
||||
#endif
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
job.State = BuildJobState.Failed;
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
job.ErrorMessage = ex.Message;
|
||||
}
|
||||
|
||||
BuildJobStore.SetLastCompleted(job);
|
||||
|
||||
// Emit console signal so the build result is visible in Unity's Console
|
||||
if (job.State == BuildJobState.Succeeded)
|
||||
{
|
||||
UnityEngine.Debug.Log(
|
||||
$"[MCP Build] Build succeeded: {job.Target} → {job.OutputPath} " +
|
||||
$"({job.TotalSizeMb} MB, {(job.CompletedAt.Value - job.StartedAt).TotalSeconds:F1}s)");
|
||||
}
|
||||
else
|
||||
{
|
||||
UnityEngine.Debug.LogError(
|
||||
$"[MCP Build] ✗ Build failed: {job.Target} — {job.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void ScheduleNextBatchBuild(BatchJob batch, Func<int, BuildJob> createChildBuild)
|
||||
{
|
||||
batch.CurrentIndex++;
|
||||
|
||||
if (batch.State == BuildJobState.Cancelled)
|
||||
{
|
||||
for (int i = batch.CurrentIndex; i < batch.Children.Count; i++)
|
||||
batch.Children[i].State = BuildJobState.Skipped;
|
||||
return;
|
||||
}
|
||||
|
||||
if (batch.CurrentIndex >= batch.Children.Count)
|
||||
{
|
||||
bool anyFailed = batch.Children.Any(c => c.State == BuildJobState.Failed);
|
||||
batch.State = anyFailed ? BuildJobState.Failed : BuildJobState.Succeeded;
|
||||
return;
|
||||
}
|
||||
|
||||
var child = batch.Children[batch.CurrentIndex];
|
||||
createChildBuild(batch.CurrentIndex);
|
||||
|
||||
// Safety timeout: if child never transitions out of Building/Pending after 2 hours,
|
||||
// unregister the delegate to prevent an orphaned update loop
|
||||
var watchStart = DateTime.UtcNow;
|
||||
var maxWait = TimeSpan.FromHours(2);
|
||||
|
||||
void WaitForCompletion()
|
||||
{
|
||||
if (DateTime.UtcNow - watchStart > maxWait)
|
||||
{
|
||||
EditorApplication.update -= WaitForCompletion;
|
||||
if (child.State == BuildJobState.Building || child.State == BuildJobState.Pending)
|
||||
{
|
||||
child.State = BuildJobState.Failed;
|
||||
child.ErrorMessage = "Build timed out after 2 hours.";
|
||||
}
|
||||
ScheduleNextBatchBuild(batch, createChildBuild);
|
||||
return;
|
||||
}
|
||||
|
||||
if (child.State == BuildJobState.Building || child.State == BuildJobState.Pending)
|
||||
return;
|
||||
|
||||
EditorApplication.update -= WaitForCompletion;
|
||||
ScheduleNextBatchBuild(batch, createChildBuild);
|
||||
}
|
||||
EditorApplication.update += WaitForCompletion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schedule an action to run on the next EditorApplication.update tick.
|
||||
/// Unlike delayCall, update callbacks are guaranteed to fire since the
|
||||
/// TransportCommandDispatcher processes commands through the same mechanism.
|
||||
/// </summary>
|
||||
private static void ScheduleOnNextUpdate(Action action)
|
||||
{
|
||||
bool executed = false;
|
||||
void RunOnce()
|
||||
{
|
||||
if (executed) return;
|
||||
executed = true;
|
||||
EditorApplication.update -= RunOnce;
|
||||
action();
|
||||
}
|
||||
EditorApplication.update += RunOnce;
|
||||
}
|
||||
|
||||
private static string[] GetDefaultScenes()
|
||||
{
|
||||
return EditorBuildSettings.scenes
|
||||
.Where(s => s.enabled)
|
||||
.Select(s => s.path)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9e1fff59ee9947f49ad112a9403b11df
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Build;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Build
|
||||
{
|
||||
public static class BuildSettingsHelper
|
||||
{
|
||||
public static object ReadProperty(string property, NamedBuildTarget namedTarget)
|
||||
{
|
||||
switch (property.ToLowerInvariant())
|
||||
{
|
||||
case "product_name":
|
||||
return new { property, value = PlayerSettings.productName };
|
||||
case "company_name":
|
||||
return new { property, value = PlayerSettings.companyName };
|
||||
case "version":
|
||||
return new { property, value = PlayerSettings.bundleVersion };
|
||||
case "bundle_id":
|
||||
return new { property, value = PlayerSettings.GetApplicationIdentifier(namedTarget) };
|
||||
case "scripting_backend":
|
||||
var backend = PlayerSettings.GetScriptingBackend(namedTarget);
|
||||
return new { property, value = backend == ScriptingImplementation.IL2CPP ? "il2cpp" : "mono" };
|
||||
case "defines":
|
||||
return new { property, value = PlayerSettings.GetScriptingDefineSymbols(namedTarget) };
|
||||
case "architecture":
|
||||
var arch = PlayerSettings.GetArchitecture(namedTarget);
|
||||
string archName = arch switch { 0 => "x86_64", 1 => "arm64", 2 => "universal", _ => "unknown" };
|
||||
return new { property, value = archName, raw = arch };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static string WriteProperty(string property, string value, NamedBuildTarget namedTarget)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (property.ToLowerInvariant())
|
||||
{
|
||||
case "product_name":
|
||||
PlayerSettings.productName = value;
|
||||
return null;
|
||||
case "company_name":
|
||||
PlayerSettings.companyName = value;
|
||||
return null;
|
||||
case "version":
|
||||
PlayerSettings.bundleVersion = value;
|
||||
return null;
|
||||
case "bundle_id":
|
||||
PlayerSettings.SetApplicationIdentifier(namedTarget, value);
|
||||
return null;
|
||||
case "scripting_backend":
|
||||
var backendValue = value.ToLowerInvariant();
|
||||
if (backendValue != "il2cpp" && backendValue != "mono")
|
||||
return $"Unknown scripting_backend '{value}'. Valid: mono, il2cpp";
|
||||
var impl = backendValue == "il2cpp"
|
||||
? ScriptingImplementation.IL2CPP
|
||||
: ScriptingImplementation.Mono2x;
|
||||
PlayerSettings.SetScriptingBackend(namedTarget, impl);
|
||||
return null;
|
||||
case "defines":
|
||||
PlayerSettings.SetScriptingDefineSymbols(namedTarget, value);
|
||||
return null;
|
||||
case "architecture":
|
||||
int arch = value.ToLowerInvariant() switch
|
||||
{
|
||||
"x86_64" or "none" or "default" => 0,
|
||||
"arm64" => 1,
|
||||
"universal" => 2,
|
||||
_ => -1
|
||||
};
|
||||
if (arch < 0)
|
||||
return $"Unknown architecture '{value}'. Valid: x86_64, arm64, universal";
|
||||
PlayerSettings.SetArchitecture(namedTarget, arch);
|
||||
return null;
|
||||
default:
|
||||
return $"Unknown property '{property}'. Valid: product_name, company_name, version, bundle_id, scripting_backend, defines, architecture";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Failed to set {property}: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
public static readonly IReadOnlyList<string> ValidProperties = new[]
|
||||
{
|
||||
"product_name", "company_name", "version", "bundle_id",
|
||||
"scripting_backend", "defines", "architecture"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 51f7a2852819440eada31cda61039b6a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,165 @@
|
||||
using System;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Build;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Build
|
||||
{
|
||||
public static class BuildTargetMapping
|
||||
{
|
||||
private const string VisionOSName = "VisionOS";
|
||||
|
||||
public static bool TryResolveBuildTarget(string name, out BuildTarget target)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
target = EditorUserBuildSettings.activeBuildTarget;
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (name.ToLowerInvariant())
|
||||
{
|
||||
case "windows64": target = BuildTarget.StandaloneWindows64; return true;
|
||||
case "windows": case "windows32": target = BuildTarget.StandaloneWindows; return true;
|
||||
case "osx": case "macos": target = BuildTarget.StandaloneOSX; return true;
|
||||
case "linux64": case "linux": target = BuildTarget.StandaloneLinux64; return true;
|
||||
case "android": target = BuildTarget.Android; return true;
|
||||
case "ios": target = BuildTarget.iOS; return true;
|
||||
case "webgl": target = BuildTarget.WebGL; return true;
|
||||
case "uwp": target = BuildTarget.WSAPlayer; return true;
|
||||
case "tvos": target = BuildTarget.tvOS; return true;
|
||||
default:
|
||||
if (TryParseDefinedBuildTarget(name, out target))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
target = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static BuildTargetGroup GetTargetGroup(BuildTarget target)
|
||||
{
|
||||
switch (target)
|
||||
{
|
||||
case BuildTarget.StandaloneWindows:
|
||||
case BuildTarget.StandaloneWindows64:
|
||||
case BuildTarget.StandaloneOSX:
|
||||
case BuildTarget.StandaloneLinux64:
|
||||
return BuildTargetGroup.Standalone;
|
||||
case BuildTarget.iOS: return BuildTargetGroup.iOS;
|
||||
case BuildTarget.Android: return BuildTargetGroup.Android;
|
||||
case BuildTarget.WebGL: return BuildTargetGroup.WebGL;
|
||||
case BuildTarget.WSAPlayer: return BuildTargetGroup.WSA;
|
||||
case BuildTarget.tvOS: return BuildTargetGroup.tvOS;
|
||||
default:
|
||||
if (IsVisionOSTarget(target)
|
||||
&& Enum.TryParse(VisionOSName, true, out BuildTargetGroup visionOSGroup))
|
||||
{
|
||||
return visionOSGroup;
|
||||
}
|
||||
|
||||
return BuildTargetGroup.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
public static NamedBuildTarget GetNamedBuildTarget(BuildTarget target)
|
||||
{
|
||||
return NamedBuildTarget.FromBuildTargetGroup(GetTargetGroup(target));
|
||||
}
|
||||
|
||||
public static string TryResolveNamedBuildTarget(string name, out NamedBuildTarget namedTarget)
|
||||
{
|
||||
if (!TryResolveBuildTarget(name, out var buildTarget))
|
||||
{
|
||||
namedTarget = default;
|
||||
return GetUnknownBuildTargetMessage(name);
|
||||
}
|
||||
|
||||
var targetGroup = GetTargetGroup(buildTarget);
|
||||
if (targetGroup == BuildTargetGroup.Unknown)
|
||||
{
|
||||
namedTarget = default;
|
||||
return IsVisionOSTarget(buildTarget)
|
||||
? "VisionOS build target is available, but its BuildTargetGroup is not exposed by this Unity editor installation."
|
||||
: $"Build target group could not be resolved for target '{buildTarget}'.";
|
||||
}
|
||||
|
||||
namedTarget = NamedBuildTarget.FromBuildTargetGroup(targetGroup);
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string GetUnknownBuildTargetMessage(string name)
|
||||
{
|
||||
if (string.Equals(name, "visionos", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "VisionOS build target is not available in this Unity editor installation. "
|
||||
+ "Install the visionOS build support module or use a Unity version/configuration that exposes BuildTarget.VisionOS.";
|
||||
}
|
||||
|
||||
return $"Unknown build target: '{name}'. Valid targets: {GetValidTargetsList()}.";
|
||||
}
|
||||
|
||||
private static string GetValidTargetsList()
|
||||
{
|
||||
string validTargets = "windows64, osx, linux64, android, ios, webgl, uwp, tvos";
|
||||
if (TryParseDefinedBuildTarget(VisionOSName, out _))
|
||||
{
|
||||
validTargets += ", visionos";
|
||||
}
|
||||
|
||||
return validTargets;
|
||||
}
|
||||
|
||||
private static bool IsVisionOSTarget(BuildTarget target)
|
||||
{
|
||||
return string.Equals(target.ToString(), VisionOSName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool TryParseDefinedBuildTarget(string name, out BuildTarget target)
|
||||
{
|
||||
target = default;
|
||||
if (int.TryParse(name, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Enum.TryParse(name, true, out target)
|
||||
&& Enum.IsDefined(typeof(BuildTarget), target);
|
||||
}
|
||||
|
||||
public static string GetDefaultOutputPath(BuildTarget target, string productName)
|
||||
{
|
||||
string basePath = $"Builds/{target}";
|
||||
switch (target)
|
||||
{
|
||||
case BuildTarget.StandaloneWindows:
|
||||
case BuildTarget.StandaloneWindows64:
|
||||
return $"{basePath}/{productName}.exe";
|
||||
case BuildTarget.StandaloneOSX:
|
||||
return $"{basePath}/{productName}.app";
|
||||
case BuildTarget.StandaloneLinux64:
|
||||
return $"{basePath}/{productName}.x86_64";
|
||||
case BuildTarget.Android:
|
||||
return EditorUserBuildSettings.buildAppBundle
|
||||
? $"{basePath}/{productName}.aab"
|
||||
: $"{basePath}/{productName}.apk";
|
||||
case BuildTarget.iOS:
|
||||
case BuildTarget.WebGL:
|
||||
return $"{basePath}/{productName}";
|
||||
default:
|
||||
return $"{basePath}/{productName}";
|
||||
}
|
||||
}
|
||||
|
||||
public static int ResolveSubtarget(string subtarget)
|
||||
{
|
||||
if (string.IsNullOrEmpty(subtarget))
|
||||
return (int)StandaloneBuildSubtarget.Player;
|
||||
string lower = subtarget.ToLowerInvariant();
|
||||
if (lower == "server")
|
||||
return (int)StandaloneBuildSubtarget.Server;
|
||||
return (int)StandaloneBuildSubtarget.Player;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c338ede07ac74e0ab9db99a1ac8c1a87
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 34337a86f21c4749be2a115f48fe6700
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,412 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using MCPForUnity.Runtime.Helpers;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Cameras
|
||||
{
|
||||
internal static class CameraConfigure
|
||||
{
|
||||
#region Tier 1 — Basic Camera
|
||||
|
||||
internal static object SetBasicCameraTarget(JObject @params)
|
||||
{
|
||||
var go = CameraHelpers.FindTargetGameObject(@params);
|
||||
if (go == null) return new ErrorResponse("Target Camera not found.");
|
||||
|
||||
var cam = go.GetComponent<UnityEngine.Camera>();
|
||||
if (cam == null) return new ErrorResponse($"No Camera component on '{go.name}'.");
|
||||
|
||||
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
|
||||
var lookAtToken = props["lookAt"] ?? props["look_at"] ?? props["follow"];
|
||||
if (lookAtToken == null)
|
||||
return new ErrorResponse("'follow' or 'lookAt' property is required.");
|
||||
|
||||
var target = CameraHelpers.ResolveGameObjectRef(lookAtToken);
|
||||
if (target == null)
|
||||
return new ErrorResponse($"Target '{lookAtToken}' not found.");
|
||||
|
||||
Undo.RecordObject(go.transform, "Set Camera Target");
|
||||
go.transform.LookAt(target.transform);
|
||||
CameraHelpers.MarkDirty(go);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Camera '{go.name}' now looking at '{target.name}'.",
|
||||
data = new { instanceID = go.GetInstanceIDCompat() }
|
||||
};
|
||||
}
|
||||
|
||||
internal static object SetBasicCameraLens(JObject @params)
|
||||
{
|
||||
var go = CameraHelpers.FindTargetGameObject(@params);
|
||||
if (go == null) return new ErrorResponse("Target Camera not found.");
|
||||
|
||||
var cam = go.GetComponent<UnityEngine.Camera>();
|
||||
if (cam == null) return new ErrorResponse($"No Camera component on '{go.name}'.");
|
||||
|
||||
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
|
||||
Undo.RecordObject(cam, "Set Camera Lens");
|
||||
|
||||
if (props["fieldOfView"] != null)
|
||||
cam.fieldOfView = ParamCoercion.CoerceFloat(props["fieldOfView"], cam.fieldOfView);
|
||||
if (props["nearClipPlane"] != null)
|
||||
cam.nearClipPlane = ParamCoercion.CoerceFloat(props["nearClipPlane"], cam.nearClipPlane);
|
||||
if (props["farClipPlane"] != null)
|
||||
cam.farClipPlane = ParamCoercion.CoerceFloat(props["farClipPlane"], cam.farClipPlane);
|
||||
if (props["orthographicSize"] != null)
|
||||
cam.orthographicSize = ParamCoercion.CoerceFloat(props["orthographicSize"], cam.orthographicSize);
|
||||
|
||||
CameraHelpers.MarkDirty(go);
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Lens properties set on Camera '{go.name}'.",
|
||||
data = new { instanceID = go.GetInstanceIDCompat() }
|
||||
};
|
||||
}
|
||||
|
||||
internal static object SetBasicCameraPriority(JObject @params)
|
||||
{
|
||||
var go = CameraHelpers.FindTargetGameObject(@params);
|
||||
if (go == null) return new ErrorResponse("Target Camera not found.");
|
||||
|
||||
var cam = go.GetComponent<UnityEngine.Camera>();
|
||||
if (cam == null) return new ErrorResponse($"No Camera component on '{go.name}'.");
|
||||
|
||||
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
|
||||
float depth = ParamCoercion.CoerceFloat(props["priority"], cam.depth);
|
||||
|
||||
Undo.RecordObject(cam, "Set Camera Depth");
|
||||
cam.depth = depth;
|
||||
CameraHelpers.MarkDirty(go);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Camera '{go.name}' depth set to {depth}.",
|
||||
data = new { instanceID = go.GetInstanceIDCompat(), depth }
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Tier 2 — Cinemachine
|
||||
|
||||
internal static object SetCinemachineTarget(JObject @params)
|
||||
{
|
||||
var cmCamera = CameraHelpers.FindCinemachineCamera(@params);
|
||||
if (cmCamera == null) return new ErrorResponse("Target CinemachineCamera not found.");
|
||||
|
||||
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
|
||||
|
||||
Undo.RecordObject(cmCamera, "Set Cinemachine Target");
|
||||
|
||||
if (props.ContainsKey("follow"))
|
||||
CameraHelpers.SetTransformTarget(cmCamera, "Follow", props["follow"]);
|
||||
if (props.ContainsKey("lookAt") || props.ContainsKey("look_at"))
|
||||
CameraHelpers.SetTransformTarget(cmCamera, "LookAt", props["lookAt"] ?? props["look_at"]);
|
||||
|
||||
CameraHelpers.MarkDirty(cmCamera.gameObject);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Targets set on CinemachineCamera '{cmCamera.gameObject.name}'.",
|
||||
data = new { instanceID = cmCamera.gameObject.GetInstanceIDCompat() }
|
||||
};
|
||||
}
|
||||
|
||||
internal static object SetCinemachineLens(JObject @params)
|
||||
{
|
||||
var cmCamera = CameraHelpers.FindCinemachineCamera(@params);
|
||||
if (cmCamera == null) return new ErrorResponse("Target CinemachineCamera not found.");
|
||||
|
||||
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
|
||||
Undo.RecordObject(cmCamera, "Set Cinemachine Lens");
|
||||
|
||||
// Lens is a struct field — use SerializedProperty for reliable setting
|
||||
using var so = new SerializedObject(cmCamera);
|
||||
var lensProp = so.FindProperty("Lens") ?? so.FindProperty("m_Lens");
|
||||
if (lensProp == null)
|
||||
return new ErrorResponse("Could not find Lens property on CinemachineCamera.");
|
||||
|
||||
SetFloatSubProp(lensProp, "FieldOfView", props["fieldOfView"]);
|
||||
SetFloatSubProp(lensProp, "NearClipPlane", props["nearClipPlane"]);
|
||||
SetFloatSubProp(lensProp, "FarClipPlane", props["farClipPlane"]);
|
||||
SetFloatSubProp(lensProp, "OrthographicSize", props["orthographicSize"]);
|
||||
SetFloatSubProp(lensProp, "Dutch", props["dutch"]);
|
||||
|
||||
so.ApplyModifiedProperties();
|
||||
CameraHelpers.MarkDirty(cmCamera.gameObject);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Lens properties set on CinemachineCamera '{cmCamera.gameObject.name}'.",
|
||||
data = new { instanceID = cmCamera.gameObject.GetInstanceIDCompat() }
|
||||
};
|
||||
}
|
||||
|
||||
internal static object SetCinemachinePriority(JObject @params)
|
||||
{
|
||||
var cmCamera = CameraHelpers.FindCinemachineCamera(@params);
|
||||
if (cmCamera == null) return new ErrorResponse("Target CinemachineCamera not found.");
|
||||
|
||||
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
|
||||
int priority = ParamCoercion.CoerceInt(props["priority"], 10);
|
||||
|
||||
// PrioritySettings is a struct with Enabled + m_Value — use SerializedProperty
|
||||
using var so = new SerializedObject(cmCamera);
|
||||
var priorityProp = so.FindProperty("Priority");
|
||||
if (priorityProp != null)
|
||||
{
|
||||
var enabledProp = priorityProp.FindPropertyRelative("Enabled");
|
||||
var valueProp = priorityProp.FindPropertyRelative("m_Value");
|
||||
if (enabledProp != null) enabledProp.boolValue = true;
|
||||
if (valueProp != null) valueProp.intValue = priority;
|
||||
so.ApplyModifiedProperties();
|
||||
}
|
||||
else
|
||||
{
|
||||
Undo.RecordObject(cmCamera, "Set Cinemachine Priority");
|
||||
CameraHelpers.SetReflectionProperty(cmCamera, "Priority", priority);
|
||||
}
|
||||
CameraHelpers.MarkDirty(cmCamera.gameObject);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Priority set to {priority} on CinemachineCamera '{cmCamera.gameObject.name}'.",
|
||||
data = new { instanceID = cmCamera.gameObject.GetInstanceIDCompat(), priority }
|
||||
};
|
||||
}
|
||||
|
||||
internal static object SetBody(JObject @params)
|
||||
{
|
||||
var cmCamera = CameraHelpers.FindCinemachineCamera(@params);
|
||||
if (cmCamera == null) return new ErrorResponse("Target CinemachineCamera not found.");
|
||||
|
||||
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
|
||||
var go = cmCamera.gameObject;
|
||||
|
||||
// Optionally swap body component
|
||||
string bodyTypeName = ParamCoercion.CoerceString(props["bodyType"] ?? props["body_type"], null);
|
||||
Component bodyComponent;
|
||||
|
||||
if (bodyTypeName != null)
|
||||
{
|
||||
bodyComponent = SwapPipelineComponent(go, "Body", bodyTypeName);
|
||||
if (bodyComponent == null)
|
||||
return new ErrorResponse($"Could not resolve body component type '{bodyTypeName}'.");
|
||||
}
|
||||
else
|
||||
{
|
||||
bodyComponent = CameraHelpers.GetPipelineComponent(cmCamera, "Body");
|
||||
if (bodyComponent == null)
|
||||
return new ErrorResponse("No Body component found on this CinemachineCamera. Provide 'bodyType' to add one.");
|
||||
}
|
||||
|
||||
// Set properties on body component
|
||||
SetComponentProperties(bodyComponent, props, new[] { "bodyType", "body_type" });
|
||||
CameraHelpers.MarkDirty(go);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Body configured on CinemachineCamera '{go.name}'.",
|
||||
data = new { instanceID = go.GetInstanceIDCompat(), body = bodyComponent.GetType().Name }
|
||||
};
|
||||
}
|
||||
|
||||
internal static object SetAim(JObject @params)
|
||||
{
|
||||
var cmCamera = CameraHelpers.FindCinemachineCamera(@params);
|
||||
if (cmCamera == null) return new ErrorResponse("Target CinemachineCamera not found.");
|
||||
|
||||
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
|
||||
var go = cmCamera.gameObject;
|
||||
|
||||
string aimTypeName = ParamCoercion.CoerceString(props["aimType"] ?? props["aim_type"], null);
|
||||
Component aimComponent;
|
||||
|
||||
if (aimTypeName != null)
|
||||
{
|
||||
aimComponent = SwapPipelineComponent(go, "Aim", aimTypeName);
|
||||
if (aimComponent == null)
|
||||
return new ErrorResponse($"Could not resolve aim component type '{aimTypeName}'.");
|
||||
}
|
||||
else
|
||||
{
|
||||
aimComponent = CameraHelpers.GetPipelineComponent(cmCamera, "Aim");
|
||||
if (aimComponent == null)
|
||||
return new ErrorResponse("No Aim component found. Provide 'aimType' to add one.");
|
||||
}
|
||||
|
||||
SetComponentProperties(aimComponent, props, new[] { "aimType", "aim_type" });
|
||||
CameraHelpers.MarkDirty(go);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Aim configured on CinemachineCamera '{go.name}'.",
|
||||
data = new { instanceID = go.GetInstanceIDCompat(), aim = aimComponent.GetType().Name }
|
||||
};
|
||||
}
|
||||
|
||||
internal static object SetNoise(JObject @params)
|
||||
{
|
||||
var cmCamera = CameraHelpers.FindCinemachineCamera(@params);
|
||||
if (cmCamera == null) return new ErrorResponse("Target CinemachineCamera not found.");
|
||||
|
||||
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
|
||||
var go = cmCamera.gameObject;
|
||||
|
||||
// Get or add noise component
|
||||
var noiseType = CameraHelpers.ResolveComponentType("CinemachineBasicMultiChannelPerlin");
|
||||
if (noiseType == null)
|
||||
return new ErrorResponse("CinemachineBasicMultiChannelPerlin type not found.");
|
||||
|
||||
var noiseComponent = go.GetComponent(noiseType);
|
||||
bool added = false;
|
||||
if (noiseComponent == null)
|
||||
{
|
||||
noiseComponent = Undo.AddComponent(go, noiseType);
|
||||
added = true;
|
||||
}
|
||||
|
||||
Undo.RecordObject(noiseComponent, "Set Cinemachine Noise");
|
||||
SetComponentProperties(noiseComponent, props, Array.Empty<string>());
|
||||
CameraHelpers.MarkDirty(go);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = added
|
||||
? $"Added noise to CinemachineCamera '{go.name}'."
|
||||
: $"Noise configured on CinemachineCamera '{go.name}'.",
|
||||
data = new { instanceID = go.GetInstanceIDCompat(), added }
|
||||
};
|
||||
}
|
||||
|
||||
internal static object AddExtension(JObject @params)
|
||||
{
|
||||
var cmCamera = CameraHelpers.FindCinemachineCamera(@params);
|
||||
if (cmCamera == null) return new ErrorResponse("Target CinemachineCamera not found.");
|
||||
|
||||
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
|
||||
string extTypeName = ParamCoercion.CoerceString(
|
||||
props["extensionType"] ?? props["extension_type"], null);
|
||||
if (string.IsNullOrEmpty(extTypeName))
|
||||
return new ErrorResponse("'extensionType' property is required.");
|
||||
|
||||
var extType = CameraHelpers.ResolveComponentType(extTypeName);
|
||||
if (extType == null)
|
||||
return new ErrorResponse($"Extension type '{extTypeName}' not found.");
|
||||
|
||||
var go = cmCamera.gameObject;
|
||||
var existing = go.GetComponent(extType);
|
||||
if (existing != null)
|
||||
return new { success = true, message = $"Extension '{extTypeName}' already exists on '{go.name}'." };
|
||||
|
||||
var ext = Undo.AddComponent(go, extType);
|
||||
SetComponentProperties(ext, props, new[] { "extensionType", "extension_type" });
|
||||
CameraHelpers.MarkDirty(go);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Extension '{extTypeName}' added to CinemachineCamera '{go.name}'.",
|
||||
data = new { instanceID = go.GetInstanceIDCompat(), extensionType = extTypeName }
|
||||
};
|
||||
}
|
||||
|
||||
internal static object RemoveExtension(JObject @params)
|
||||
{
|
||||
var cmCamera = CameraHelpers.FindCinemachineCamera(@params);
|
||||
if (cmCamera == null) return new ErrorResponse("Target CinemachineCamera not found.");
|
||||
|
||||
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
|
||||
string extTypeName = ParamCoercion.CoerceString(
|
||||
props["extensionType"] ?? props["extension_type"], null);
|
||||
if (string.IsNullOrEmpty(extTypeName))
|
||||
return new ErrorResponse("'extensionType' property is required.");
|
||||
|
||||
var extType = CameraHelpers.ResolveComponentType(extTypeName);
|
||||
if (extType == null)
|
||||
return new ErrorResponse($"Extension type '{extTypeName}' not found.");
|
||||
|
||||
var go = cmCamera.gameObject;
|
||||
var ext = go.GetComponent(extType);
|
||||
if (ext == null)
|
||||
return new ErrorResponse($"Extension '{extTypeName}' not found on '{go.name}'.");
|
||||
|
||||
Undo.DestroyObjectImmediate(ext);
|
||||
CameraHelpers.MarkDirty(go);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Extension '{extTypeName}' removed from CinemachineCamera '{go.name}'.",
|
||||
data = new { instanceID = go.GetInstanceIDCompat() }
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static void SetFloatSubProp(SerializedProperty parent, string subPropName, JToken value)
|
||||
{
|
||||
if (value == null || value.Type == JTokenType.Null) return;
|
||||
var sub = parent.FindPropertyRelative(subPropName)
|
||||
?? parent.FindPropertyRelative("m_" + subPropName);
|
||||
if (sub != null && sub.propertyType == SerializedPropertyType.Float)
|
||||
sub.floatValue = ParamCoercion.CoerceFloat(value, sub.floatValue);
|
||||
}
|
||||
|
||||
private static Component SwapPipelineComponent(GameObject go, string stage, string newTypeName)
|
||||
{
|
||||
var newType = CameraHelpers.ResolveComponentType(newTypeName);
|
||||
if (newType == null) return null;
|
||||
|
||||
// Remove existing component of same pipeline stage
|
||||
var cmCamera = go.GetComponent(CameraHelpers.CinemachineCameraType);
|
||||
if (cmCamera != null)
|
||||
{
|
||||
var existing = CameraHelpers.GetPipelineComponent(cmCamera, stage);
|
||||
if (existing != null && existing.GetType() != newType)
|
||||
Undo.DestroyObjectImmediate(existing);
|
||||
}
|
||||
|
||||
// Add new component if not already present
|
||||
var comp = go.GetComponent(newType);
|
||||
if (comp == null)
|
||||
comp = Undo.AddComponent(go, newType);
|
||||
|
||||
return comp;
|
||||
}
|
||||
|
||||
private static void SetComponentProperties(Component component, JObject props, string[] skipKeys)
|
||||
{
|
||||
if (component == null || props == null) return;
|
||||
|
||||
var skipSet = new System.Collections.Generic.HashSet<string>(
|
||||
skipKeys, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
Undo.RecordObject(component, $"Configure {component.GetType().Name}");
|
||||
|
||||
foreach (var kv in props)
|
||||
{
|
||||
if (skipSet.Contains(kv.Key)) continue;
|
||||
ComponentOps.SetProperty(component, kv.Key, kv.Value, out _);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7a26286aeede4949844309a8a952b2b0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,315 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using MCPForUnity.Runtime.Helpers;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Cameras
|
||||
{
|
||||
internal static class CameraControl
|
||||
{
|
||||
internal static object ListCameras(JObject @params)
|
||||
{
|
||||
var unityCameras = UnityFindObjectsCompat.FindAll<UnityEngine.Camera>();
|
||||
var cameraList = new List<object>();
|
||||
var unityCamList = new List<object>();
|
||||
|
||||
// Cinemachine cameras
|
||||
if (CameraHelpers.HasCinemachine)
|
||||
{
|
||||
var cmType = CameraHelpers.CinemachineCameraType;
|
||||
var allCm = UnityFindObjectsCompat.FindAll(cmType);
|
||||
foreach (Component cm in allCm)
|
||||
{
|
||||
var follow = CameraHelpers.GetReflectionProperty(cm, "Follow") as Transform;
|
||||
var lookAt = CameraHelpers.GetReflectionProperty(cm, "LookAt") as Transform;
|
||||
var isLive = CameraHelpers.GetReflectionProperty(cm, "IsLive");
|
||||
var priority = CameraHelpers.ReadCinemachinePriority(cm);
|
||||
|
||||
var body = CameraHelpers.GetPipelineComponent(cm, "Body");
|
||||
var aim = CameraHelpers.GetPipelineComponent(cm, "Aim");
|
||||
var noise = CameraHelpers.GetPipelineComponent(cm, "Noise");
|
||||
|
||||
// Collect extensions
|
||||
var extensions = new List<string>();
|
||||
var cmExtBaseType = cm.GetType().Assembly.GetType("Unity.Cinemachine.CinemachineExtension");
|
||||
if (cmExtBaseType != null)
|
||||
{
|
||||
foreach (var comp in cm.gameObject.GetComponents(cmExtBaseType))
|
||||
{
|
||||
if (comp != null)
|
||||
extensions.Add(comp.GetType().Name);
|
||||
}
|
||||
}
|
||||
|
||||
cameraList.Add(new
|
||||
{
|
||||
instanceID = cm.gameObject.GetInstanceIDCompat(),
|
||||
name = cm.gameObject.name,
|
||||
isLive = isLive is bool b && b,
|
||||
priority,
|
||||
follow = follow != null ? new { name = follow.gameObject.name, instanceID = follow.gameObject.GetInstanceIDCompat() } : null,
|
||||
lookAt = lookAt != null ? new { name = lookAt.gameObject.name, instanceID = lookAt.gameObject.GetInstanceIDCompat() } : null,
|
||||
body = body?.GetType().Name,
|
||||
aim = aim?.GetType().Name,
|
||||
noise = noise?.GetType().Name,
|
||||
extensions
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Unity cameras
|
||||
foreach (var cam in unityCameras)
|
||||
{
|
||||
bool hasBrain = CameraHelpers.HasCinemachine &&
|
||||
cam.gameObject.GetComponent(CameraHelpers.CinemachineBrainType) != null;
|
||||
unityCamList.Add(new
|
||||
{
|
||||
instanceID = cam.gameObject.GetInstanceIDCompat(),
|
||||
name = cam.gameObject.name,
|
||||
depth = cam.depth,
|
||||
fieldOfView = cam.fieldOfView,
|
||||
hasBrain
|
||||
});
|
||||
}
|
||||
|
||||
// Brain info
|
||||
object brainInfo = null;
|
||||
if (CameraHelpers.HasCinemachine)
|
||||
{
|
||||
var brain = CameraHelpers.FindBrain();
|
||||
if (brain != null)
|
||||
{
|
||||
var activeCam = CameraHelpers.GetReflectionProperty(brain, "ActiveVirtualCamera");
|
||||
var isBlending = CameraHelpers.GetReflectionProperty(brain, "IsBlending");
|
||||
|
||||
string activeName = null;
|
||||
int? activeID = null;
|
||||
if (activeCam != null)
|
||||
{
|
||||
var nameProp = activeCam.GetType().GetProperty("Name");
|
||||
activeName = nameProp?.GetValue(activeCam) as string;
|
||||
|
||||
if (activeCam is Component activeComp)
|
||||
activeID = activeComp.gameObject.GetInstanceIDCompat();
|
||||
}
|
||||
|
||||
brainInfo = new
|
||||
{
|
||||
exists = true,
|
||||
gameObject = brain.gameObject.name,
|
||||
instanceID = brain.gameObject.GetInstanceIDCompat(),
|
||||
activeCameraName = activeName,
|
||||
activeCameraID = activeID,
|
||||
isBlending = isBlending is bool bl && bl
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
data = new
|
||||
{
|
||||
brain = brainInfo,
|
||||
cinemachineCameras = cameraList,
|
||||
unityCameras = unityCamList,
|
||||
cinemachineInstalled = CameraHelpers.HasCinemachine
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
internal static object GetBrainStatus(JObject @params)
|
||||
{
|
||||
var brain = CameraHelpers.FindBrain();
|
||||
if (brain == null)
|
||||
return new ErrorResponse("No CinemachineBrain found in the scene.");
|
||||
|
||||
var activeCam = CameraHelpers.GetReflectionProperty(brain, "ActiveVirtualCamera");
|
||||
var isBlending = CameraHelpers.GetReflectionProperty(brain, "IsBlending");
|
||||
var activeBlend = CameraHelpers.GetReflectionProperty(brain, "ActiveBlend");
|
||||
|
||||
string activeName = null;
|
||||
int? activeID = null;
|
||||
if (activeCam != null)
|
||||
{
|
||||
var nameProp = activeCam.GetType().GetProperty("Name");
|
||||
activeName = nameProp?.GetValue(activeCam) as string;
|
||||
if (activeCam is Component comp)
|
||||
activeID = comp.gameObject.GetInstanceIDCompat();
|
||||
}
|
||||
|
||||
string blendDesc = null;
|
||||
if (activeBlend != null)
|
||||
{
|
||||
var descProp = activeBlend.GetType().GetProperty("Description");
|
||||
blendDesc = descProp?.GetValue(activeBlend) as string;
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
data = new
|
||||
{
|
||||
gameObject = brain.gameObject.name,
|
||||
instanceID = brain.gameObject.GetInstanceIDCompat(),
|
||||
activeCameraName = activeName,
|
||||
activeCameraID = activeID,
|
||||
isBlending = isBlending is bool b && b,
|
||||
blendDescription = blendDesc
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
internal static object SetBlend(JObject @params)
|
||||
{
|
||||
var brain = CameraHelpers.FindBrain();
|
||||
if (brain == null)
|
||||
return new ErrorResponse("No CinemachineBrain found. Use 'ensure_brain' first.");
|
||||
|
||||
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
|
||||
Undo.RecordObject(brain, "Set Camera Blend");
|
||||
|
||||
using var so = new SerializedObject(brain);
|
||||
var defaultBlendProp = so.FindProperty("DefaultBlend") ?? so.FindProperty("m_DefaultBlend");
|
||||
if (defaultBlendProp == null)
|
||||
return new ErrorResponse("Could not find DefaultBlend property on CinemachineBrain.");
|
||||
|
||||
string style = ParamCoercion.CoerceString(props["style"], null);
|
||||
if (style != null)
|
||||
{
|
||||
var styleProp = defaultBlendProp.FindPropertyRelative("Style")
|
||||
?? defaultBlendProp.FindPropertyRelative("m_Style");
|
||||
if (styleProp != null && styleProp.propertyType == SerializedPropertyType.Enum)
|
||||
{
|
||||
// Try to parse the style enum
|
||||
var enumNames = styleProp.enumNames;
|
||||
int idx = Array.FindIndex(enumNames, n => n.Equals(style, StringComparison.OrdinalIgnoreCase));
|
||||
if (idx >= 0)
|
||||
styleProp.enumValueIndex = idx;
|
||||
}
|
||||
}
|
||||
|
||||
float duration = ParamCoercion.CoerceFloat(props["duration"], -1f);
|
||||
if (duration >= 0)
|
||||
{
|
||||
var timeProp = defaultBlendProp.FindPropertyRelative("Time")
|
||||
?? defaultBlendProp.FindPropertyRelative("m_Time");
|
||||
if (timeProp != null)
|
||||
timeProp.floatValue = duration;
|
||||
}
|
||||
|
||||
so.ApplyModifiedProperties();
|
||||
CameraHelpers.MarkDirty(brain.gameObject);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = "Default blend configured on CinemachineBrain.",
|
||||
data = new { instanceID = brain.gameObject.GetInstanceIDCompat() }
|
||||
};
|
||||
}
|
||||
|
||||
private static int _overrideId = -1;
|
||||
|
||||
internal static object ForceCamera(JObject @params)
|
||||
{
|
||||
var brain = CameraHelpers.FindBrain();
|
||||
if (brain == null)
|
||||
return new ErrorResponse("No CinemachineBrain found. Use 'ensure_brain' first.");
|
||||
|
||||
var cmCamera = CameraHelpers.FindCinemachineCamera(@params);
|
||||
if (cmCamera == null)
|
||||
return new ErrorResponse("Target CinemachineCamera not found.");
|
||||
|
||||
// Use SetCameraOverride via reflection
|
||||
var brainType = brain.GetType();
|
||||
var method = brainType.GetMethod("SetCameraOverride",
|
||||
BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
if (method == null)
|
||||
{
|
||||
// Fallback: just set high priority
|
||||
CameraHelpers.SetReflectionProperty(cmCamera, "Priority", 999);
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Set high priority on '{cmCamera.gameObject.name}' (SetCameraOverride not available).",
|
||||
data = new { instanceID = cmCamera.gameObject.GetInstanceIDCompat(), method = "priority" }
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// CM3 signature: SetCameraOverride(int overrideId, int priority,
|
||||
// ICinemachineCamera camA, ICinemachineCamera camB, float weightB, float deltaTime)
|
||||
// -1 for overrideId creates a new override; same cam for A+B with weight=1 = instant switch
|
||||
_overrideId = (int)method.Invoke(brain, new object[]
|
||||
{
|
||||
_overrideId >= 0 ? _overrideId : -1,
|
||||
999, // high priority to win over all others
|
||||
cmCamera, // camA (at weight=0)
|
||||
cmCamera, // camB (at weight=1) — same camera = no blend
|
||||
1f, // weightB = fully on camB
|
||||
-1f // deltaTime = use default
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Fallback
|
||||
CameraHelpers.SetReflectionProperty(cmCamera, "Priority", 999);
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Forced via priority (override failed: {ex.Message}).",
|
||||
data = new { instanceID = cmCamera.gameObject.GetInstanceIDCompat(), method = "priority" }
|
||||
};
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Camera overridden to '{cmCamera.gameObject.name}'.",
|
||||
data = new
|
||||
{
|
||||
instanceID = cmCamera.gameObject.GetInstanceIDCompat(),
|
||||
overrideId = _overrideId,
|
||||
method = "override"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
internal static object ReleaseOverride(JObject @params)
|
||||
{
|
||||
var brain = CameraHelpers.FindBrain();
|
||||
if (brain == null)
|
||||
return new ErrorResponse("No CinemachineBrain found.");
|
||||
|
||||
if (_overrideId < 0)
|
||||
return new { success = true, message = "No active camera override to release." };
|
||||
|
||||
var method = brain.GetType().GetMethod("ReleaseCameraOverride",
|
||||
BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
if (method != null)
|
||||
{
|
||||
method.Invoke(brain, new object[] { _overrideId });
|
||||
int releasedId = _overrideId;
|
||||
_overrideId = -1;
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = "Camera override released.",
|
||||
data = new { releasedOverrideId = releasedId }
|
||||
};
|
||||
}
|
||||
|
||||
_overrideId = -1;
|
||||
return new { success = true, message = "Override state cleared." };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6644251762504798895ef138ff182d29
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,251 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using MCPForUnity.Runtime.Helpers;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Cameras
|
||||
{
|
||||
internal static class CameraCreate
|
||||
{
|
||||
private static readonly Dictionary<string, (string body, string aim)> Presets = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["follow"] = ("CinemachineFollow", "CinemachineRotationComposer"),
|
||||
["third_person"] = ("CinemachineThirdPersonFollow", "CinemachineRotationComposer"),
|
||||
["freelook"] = ("CinemachineOrbitalFollow", "CinemachineRotationComposer"),
|
||||
["dolly"] = ("CinemachineSplineDolly", "CinemachineRotationComposer"),
|
||||
["static"] = (null, "CinemachineHardLookAt"),
|
||||
["top_down"] = ("CinemachineFollow", null),
|
||||
["side_scroller"] = ("CinemachinePositionComposer", null),
|
||||
};
|
||||
|
||||
internal static object CreateBasicCamera(JObject @params)
|
||||
{
|
||||
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
|
||||
string name = ParamCoercion.CoerceString(props["name"], null) ?? "Camera";
|
||||
float fov = ParamCoercion.CoerceFloat(props["fieldOfView"], 60f);
|
||||
float near = ParamCoercion.CoerceFloat(props["nearClipPlane"], 0.3f);
|
||||
float far = ParamCoercion.CoerceFloat(props["farClipPlane"], 1000f);
|
||||
|
||||
var go = new GameObject(name);
|
||||
Undo.RegisterCreatedObjectUndo(go, $"Create Camera '{name}'");
|
||||
var cam = go.AddComponent<UnityEngine.Camera>();
|
||||
cam.fieldOfView = fov;
|
||||
cam.nearClipPlane = near;
|
||||
cam.farClipPlane = far;
|
||||
|
||||
// Position near follow target if provided
|
||||
string follow = ParamCoercion.CoerceString(props["follow"], null);
|
||||
if (follow != null)
|
||||
{
|
||||
var target = CameraHelpers.ResolveGameObjectRef(follow);
|
||||
if (target != null)
|
||||
go.transform.position = target.transform.position + new Vector3(0, 5, -10);
|
||||
}
|
||||
|
||||
// Look at target if provided
|
||||
string lookAt = ParamCoercion.CoerceString(props["lookAt"] ?? props["look_at"], null);
|
||||
if (lookAt != null)
|
||||
{
|
||||
var target = CameraHelpers.ResolveGameObjectRef(lookAt);
|
||||
if (target != null)
|
||||
go.transform.LookAt(target.transform);
|
||||
}
|
||||
|
||||
CameraHelpers.MarkDirty(go);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Created basic Camera '{name}' (Cinemachine not installed — using Unity Camera).",
|
||||
data = new
|
||||
{
|
||||
instanceID = go.GetInstanceIDCompat(),
|
||||
cinemachine = false,
|
||||
hint = "Install com.unity.cinemachine for presets, blending, and virtual camera features."
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
internal static object CreateCinemachineCamera(JObject @params)
|
||||
{
|
||||
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
|
||||
string name = ParamCoercion.CoerceString(props["name"], null) ?? "CM Camera";
|
||||
string preset = ParamCoercion.CoerceString(props["preset"], null) ?? "follow";
|
||||
int priority = ParamCoercion.CoerceInt(props["priority"], 10);
|
||||
|
||||
if (!Presets.TryGetValue(preset, out var presetDef))
|
||||
{
|
||||
return new ErrorResponse(
|
||||
$"Unknown preset '{preset}'. Valid presets: {string.Join(", ", Presets.Keys)}.");
|
||||
}
|
||||
|
||||
var go = new GameObject(name);
|
||||
Undo.RegisterCreatedObjectUndo(go, $"Create CinemachineCamera '{name}'");
|
||||
|
||||
// Add CinemachineCamera component
|
||||
var cmType = CameraHelpers.CinemachineCameraType;
|
||||
var cmCamera = go.AddComponent(cmType);
|
||||
|
||||
// PrioritySettings is a struct with Enabled + m_Value — use SerializedProperty
|
||||
using (var so = new SerializedObject(cmCamera))
|
||||
{
|
||||
var priorityProp = so.FindProperty("Priority");
|
||||
if (priorityProp != null)
|
||||
{
|
||||
var enabledProp = priorityProp.FindPropertyRelative("Enabled");
|
||||
var valueProp = priorityProp.FindPropertyRelative("m_Value");
|
||||
if (enabledProp != null) enabledProp.boolValue = true;
|
||||
if (valueProp != null) valueProp.intValue = priority;
|
||||
so.ApplyModifiedProperties();
|
||||
}
|
||||
else
|
||||
{
|
||||
CameraHelpers.SetReflectionProperty(cmCamera, "Priority", priority);
|
||||
}
|
||||
}
|
||||
|
||||
// Add Body component
|
||||
string bodyName = null;
|
||||
if (presetDef.body != null)
|
||||
{
|
||||
var bodyType = CameraHelpers.ResolveComponentType(presetDef.body);
|
||||
if (bodyType != null)
|
||||
{
|
||||
go.AddComponent(bodyType);
|
||||
bodyName = presetDef.body;
|
||||
}
|
||||
}
|
||||
|
||||
// Add Aim component
|
||||
string aimName = null;
|
||||
if (presetDef.aim != null)
|
||||
{
|
||||
var aimType = CameraHelpers.ResolveComponentType(presetDef.aim);
|
||||
if (aimType != null)
|
||||
{
|
||||
go.AddComponent(aimType);
|
||||
aimName = presetDef.aim;
|
||||
}
|
||||
}
|
||||
|
||||
// Set Follow target
|
||||
var followToken = props["follow"];
|
||||
if (followToken != null && followToken.Type != JTokenType.Null)
|
||||
CameraHelpers.SetTransformTarget(cmCamera, "Follow", followToken);
|
||||
|
||||
// Set LookAt target
|
||||
var lookAtToken = props["lookAt"] ?? props["look_at"];
|
||||
if (lookAtToken != null && lookAtToken.Type != JTokenType.Null)
|
||||
CameraHelpers.SetTransformTarget(cmCamera, "LookAt", lookAtToken);
|
||||
|
||||
CameraHelpers.MarkDirty(go);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Created CinemachineCamera '{name}' with preset '{preset}'.",
|
||||
data = new
|
||||
{
|
||||
instanceID = go.GetInstanceIDCompat(),
|
||||
cinemachine = true,
|
||||
preset,
|
||||
priority,
|
||||
body = bodyName,
|
||||
aim = aimName
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
internal static object EnsureBrain(JObject @params)
|
||||
{
|
||||
var props = CameraHelpers.ExtractProperties(@params) ?? new JObject();
|
||||
|
||||
// Check if Brain already exists
|
||||
var existingBrain = CameraHelpers.FindBrain();
|
||||
if (existingBrain != null)
|
||||
{
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"CinemachineBrain already exists on '{existingBrain.gameObject.name}'.",
|
||||
data = new
|
||||
{
|
||||
instanceID = existingBrain.gameObject.GetInstanceIDCompat(),
|
||||
alreadyExisted = true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Find target camera
|
||||
string cameraRef = ParamCoercion.CoerceString(props["camera"], null);
|
||||
UnityEngine.Camera cam;
|
||||
if (cameraRef != null)
|
||||
{
|
||||
var camGo = CameraHelpers.ResolveGameObjectRef(cameraRef);
|
||||
cam = camGo != null ? camGo.GetComponent<UnityEngine.Camera>() : null;
|
||||
}
|
||||
else
|
||||
{
|
||||
cam = CameraHelpers.FindMainCamera();
|
||||
}
|
||||
|
||||
if (cam == null)
|
||||
return new ErrorResponse("No Camera found to add CinemachineBrain to.");
|
||||
|
||||
var brainType = CameraHelpers.CinemachineBrainType;
|
||||
Undo.RecordObject(cam.gameObject, "Add CinemachineBrain");
|
||||
var brain = cam.gameObject.AddComponent(brainType);
|
||||
|
||||
// Configure default blend if provided
|
||||
string blendStyle = ParamCoercion.CoerceString(props["defaultBlendStyle"] ?? props["default_blend_style"], null);
|
||||
float blendDuration = ParamCoercion.CoerceFloat(props["defaultBlendDuration"] ?? props["default_blend_duration"], -1f);
|
||||
|
||||
if (blendStyle != null || blendDuration >= 0)
|
||||
{
|
||||
// Set via SerializedProperty for the DefaultBlend struct
|
||||
using var so = new SerializedObject(brain);
|
||||
var defaultBlendProp = so.FindProperty("DefaultBlend") ?? so.FindProperty("m_DefaultBlend");
|
||||
if (defaultBlendProp != null)
|
||||
{
|
||||
if (blendStyle != null)
|
||||
{
|
||||
var styleProp = defaultBlendProp.FindPropertyRelative("Style")
|
||||
?? defaultBlendProp.FindPropertyRelative("m_Style");
|
||||
if (styleProp != null)
|
||||
{
|
||||
int idx = Array.FindIndex(styleProp.enumNames,
|
||||
n => n.Equals(blendStyle, StringComparison.OrdinalIgnoreCase));
|
||||
if (idx >= 0)
|
||||
styleProp.enumValueIndex = idx;
|
||||
}
|
||||
}
|
||||
if (blendDuration >= 0)
|
||||
{
|
||||
var timeProp = defaultBlendProp.FindPropertyRelative("Time")
|
||||
?? defaultBlendProp.FindPropertyRelative("m_Time");
|
||||
if (timeProp != null)
|
||||
timeProp.floatValue = blendDuration;
|
||||
}
|
||||
so.ApplyModifiedProperties();
|
||||
}
|
||||
}
|
||||
|
||||
CameraHelpers.MarkDirty(cam.gameObject);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"CinemachineBrain added to '{cam.gameObject.name}'.",
|
||||
data = new
|
||||
{
|
||||
instanceID = cam.gameObject.GetInstanceIDCompat(),
|
||||
alreadyExisted = false
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a849a1ac03d245fe823e4c02b9e722d5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,270 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Runtime.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Cameras
|
||||
{
|
||||
internal static class CameraHelpers
|
||||
{
|
||||
private static bool? _hasCinemachine;
|
||||
private static Type _cmCameraType;
|
||||
private static Type _cmBrainType;
|
||||
|
||||
internal static bool HasCinemachine
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_hasCinemachine == null)
|
||||
DetectCinemachine();
|
||||
return _hasCinemachine.Value;
|
||||
}
|
||||
}
|
||||
|
||||
internal static Type CinemachineCameraType
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_hasCinemachine == null)
|
||||
DetectCinemachine();
|
||||
return _cmCameraType;
|
||||
}
|
||||
}
|
||||
|
||||
internal static Type CinemachineBrainType
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_hasCinemachine == null)
|
||||
DetectCinemachine();
|
||||
return _cmBrainType;
|
||||
}
|
||||
}
|
||||
|
||||
private static void DetectCinemachine()
|
||||
{
|
||||
_cmCameraType = UnityTypeResolver.ResolveComponent("CinemachineCamera");
|
||||
_cmBrainType = UnityTypeResolver.ResolveComponent("CinemachineBrain");
|
||||
_hasCinemachine = _cmCameraType != null && _cmBrainType != null;
|
||||
}
|
||||
|
||||
internal static string GetCinemachineVersion()
|
||||
{
|
||||
if (!HasCinemachine || _cmCameraType == null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var assembly = _cmCameraType.Assembly;
|
||||
var version = assembly.GetName().Version;
|
||||
return version?.ToString();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
internal static GameObject FindTargetGameObject(JObject @params)
|
||||
{
|
||||
var targetToken = @params["target"];
|
||||
if (targetToken == null)
|
||||
return null;
|
||||
|
||||
string searchMethod = ParamCoercion.CoerceString(
|
||||
@params["searchMethod"] ?? @params["search_method"], "by_name");
|
||||
|
||||
if (targetToken.Type == JTokenType.Integer)
|
||||
{
|
||||
int instanceId = targetToken.Value<int>();
|
||||
return GameObjectLookup.FindById(instanceId);
|
||||
}
|
||||
|
||||
string targetStr = targetToken.ToString();
|
||||
if (int.TryParse(targetStr, out int parsedId))
|
||||
{
|
||||
var byId = GameObjectLookup.FindById(parsedId);
|
||||
if (byId != null) return byId;
|
||||
}
|
||||
|
||||
return GameObjectLookup.FindByTarget(targetToken, searchMethod, true);
|
||||
}
|
||||
|
||||
internal static GameObject ResolveGameObjectRef(object reference)
|
||||
{
|
||||
if (reference == null) return null;
|
||||
|
||||
if (reference is JToken jt)
|
||||
{
|
||||
if (jt.Type == JTokenType.Integer)
|
||||
return GameObjectLookup.FindById(jt.Value<int>());
|
||||
if (jt.Type == JTokenType.String)
|
||||
{
|
||||
string str = jt.ToString();
|
||||
if (int.TryParse(str, out int id))
|
||||
{
|
||||
var byId = GameObjectLookup.FindById(id);
|
||||
if (byId != null) return byId;
|
||||
}
|
||||
return GameObjectLookup.FindByTarget(jt, "by_name", true);
|
||||
}
|
||||
}
|
||||
|
||||
if (reference is string s)
|
||||
{
|
||||
if (int.TryParse(s, out int id))
|
||||
{
|
||||
var byId = GameObjectLookup.FindById(id);
|
||||
if (byId != null) return byId;
|
||||
}
|
||||
var ids = GameObjectLookup.SearchGameObjects(
|
||||
GameObjectLookup.SearchMethod.ByName, s, includeInactive: true, maxResults: 1);
|
||||
return ids.Count > 0 ? GameObjectLookup.FindById(ids[0]) : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static Component FindCinemachineCamera(JObject @params)
|
||||
{
|
||||
if (!HasCinemachine) return null;
|
||||
var go = FindTargetGameObject(@params);
|
||||
return go != null ? go.GetComponent(CinemachineCameraType) : null;
|
||||
}
|
||||
|
||||
internal static Component FindBrain()
|
||||
{
|
||||
if (!HasCinemachine || _cmBrainType == null)
|
||||
return null;
|
||||
|
||||
return UnityFindObjectsCompat.FindAny(_cmBrainType) as Component;
|
||||
}
|
||||
|
||||
internal static UnityEngine.Camera FindMainCamera()
|
||||
{
|
||||
var main = UnityEngine.Camera.main;
|
||||
if (main != null) return main;
|
||||
|
||||
var allCams = UnityFindObjectsCompat.FindAll<UnityEngine.Camera>();
|
||||
return allCams.Length > 0 ? allCams[0] : null;
|
||||
}
|
||||
|
||||
internal static JObject ExtractProperties(JObject @params)
|
||||
{
|
||||
var props = @params["properties"] as JObject;
|
||||
if (props != null) return props;
|
||||
|
||||
var propsStr = ParamCoercion.CoerceString(@params["properties"], null);
|
||||
if (propsStr != null)
|
||||
{
|
||||
try { return JObject.Parse(propsStr); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static object GetReflectionProperty(Component component, string propertyName)
|
||||
{
|
||||
if (component == null) return null;
|
||||
var type = component.GetType();
|
||||
var prop = type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
|
||||
return prop?.GetValue(component);
|
||||
}
|
||||
|
||||
/// <summary>Read priority int from a CinemachineCamera component via SerializedObject.</summary>
|
||||
internal static int ReadCinemachinePriority(Component cmCamera)
|
||||
{
|
||||
if (cmCamera == null) return 0;
|
||||
using var so = new SerializedObject(cmCamera);
|
||||
var priorityProp = so.FindProperty("Priority");
|
||||
if (priorityProp == null) return 0;
|
||||
var enabledProp = priorityProp.FindPropertyRelative("Enabled");
|
||||
var valueProp = priorityProp.FindPropertyRelative("m_Value");
|
||||
if (enabledProp != null && !enabledProp.boolValue) return 0;
|
||||
return valueProp?.intValue ?? 0;
|
||||
}
|
||||
|
||||
internal static bool SetReflectionProperty(Component component, string propertyName, object value)
|
||||
{
|
||||
if (component == null) return false;
|
||||
var type = component.GetType();
|
||||
var prop = type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
|
||||
if (prop == null || !prop.CanWrite) return false;
|
||||
prop.SetValue(component, value);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static void SetTransformTarget(Component cmCamera, string propertyName, JToken targetRef)
|
||||
{
|
||||
if (cmCamera == null) return;
|
||||
|
||||
if (targetRef == null || targetRef.Type == JTokenType.Null)
|
||||
{
|
||||
SetReflectionProperty(cmCamera, propertyName, null);
|
||||
return;
|
||||
}
|
||||
|
||||
var go = ResolveGameObjectRef(targetRef);
|
||||
if (go != null)
|
||||
SetReflectionProperty(cmCamera, propertyName, go.transform);
|
||||
}
|
||||
|
||||
internal static Type ResolveComponentType(string typeName)
|
||||
{
|
||||
return UnityTypeResolver.ResolveComponent(typeName);
|
||||
}
|
||||
|
||||
internal static Component GetPipelineComponent(Component cmCamera, string stageName)
|
||||
{
|
||||
if (cmCamera == null) return null;
|
||||
var type = cmCamera.GetType();
|
||||
|
||||
// CinemachineCamera.GetCinemachineComponent(CinemachineCore.Stage stage)
|
||||
var stageEnumType = type.Assembly.GetType("Unity.Cinemachine.CinemachineCore+Stage")
|
||||
?? type.Assembly.GetType("Unity.Cinemachine.CinemachineCore")?.GetNestedType("Stage");
|
||||
|
||||
if (stageEnumType == null) return null;
|
||||
|
||||
object stageEnum;
|
||||
try { stageEnum = Enum.Parse(stageEnumType, stageName, true); }
|
||||
catch { return null; }
|
||||
|
||||
var method = type.GetMethod("GetCinemachineComponent",
|
||||
BindingFlags.Public | BindingFlags.Instance,
|
||||
null, new[] { stageEnumType }, null);
|
||||
|
||||
if (method == null) return null;
|
||||
return method.Invoke(cmCamera, new[] { stageEnum }) as Component;
|
||||
}
|
||||
|
||||
internal static string GetFallbackSuggestion(string action)
|
||||
{
|
||||
return action switch
|
||||
{
|
||||
"set_body" or "set_aim" => "Use 'set_lens' and 'set_target' for basic camera configuration.",
|
||||
"set_blend" => "Without Cinemachine, switch cameras by enabling/disabling Camera components.",
|
||||
"set_noise" => "Camera shake without Cinemachine requires a custom script.",
|
||||
"ensure_brain" => "CinemachineBrain requires the Cinemachine package. Basic Camera does not need a Brain.",
|
||||
"get_brain_status" => "No CinemachineBrain available. Cinemachine package not installed.",
|
||||
_ => "Install Cinemachine via Window > Package Manager."
|
||||
};
|
||||
}
|
||||
|
||||
internal static void MarkDirty(GameObject go)
|
||||
{
|
||||
if (go == null) return;
|
||||
EditorUtility.SetDirty(go);
|
||||
var prefabStage = UnityEditor.SceneManagement.PrefabStageUtility.GetCurrentPrefabStage();
|
||||
if (prefabStage != null)
|
||||
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(prefabStage.scene);
|
||||
else
|
||||
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(go.scene);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9664df00dcfa4a22b45ffa104bf29e46
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,134 @@
|
||||
using System;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Cameras
|
||||
{
|
||||
[McpForUnityTool("manage_camera", AutoRegister = false)]
|
||||
public static class ManageCamera
|
||||
{
|
||||
public static object HandleCommand(JObject @params)
|
||||
{
|
||||
if (@params == null)
|
||||
return new ErrorResponse("Parameters cannot be null.");
|
||||
|
||||
var p = new ToolParams(@params);
|
||||
string action = p.Get("action")?.ToLowerInvariant();
|
||||
|
||||
if (string.IsNullOrEmpty(action))
|
||||
return new ErrorResponse("'action' parameter is required.");
|
||||
|
||||
try
|
||||
{
|
||||
// Tier 1: Always-available actions (basic Camera fallback)
|
||||
switch (action)
|
||||
{
|
||||
case "ping":
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = CameraHelpers.HasCinemachine
|
||||
? "Cinemachine is available."
|
||||
: "Cinemachine not installed. Basic Camera operations available.",
|
||||
data = new
|
||||
{
|
||||
cinemachine = CameraHelpers.HasCinemachine,
|
||||
version = CameraHelpers.GetCinemachineVersion()
|
||||
}
|
||||
};
|
||||
|
||||
case "create_camera":
|
||||
return CameraHelpers.HasCinemachine
|
||||
? CameraCreate.CreateCinemachineCamera(@params)
|
||||
: CameraCreate.CreateBasicCamera(@params);
|
||||
|
||||
case "set_target":
|
||||
return CameraHelpers.HasCinemachine
|
||||
? CameraConfigure.SetCinemachineTarget(@params)
|
||||
: CameraConfigure.SetBasicCameraTarget(@params);
|
||||
|
||||
case "set_lens":
|
||||
return CameraHelpers.HasCinemachine
|
||||
? CameraConfigure.SetCinemachineLens(@params)
|
||||
: CameraConfigure.SetBasicCameraLens(@params);
|
||||
|
||||
case "set_priority":
|
||||
return CameraHelpers.HasCinemachine
|
||||
? CameraConfigure.SetCinemachinePriority(@params)
|
||||
: CameraConfigure.SetBasicCameraPriority(@params);
|
||||
|
||||
case "list_cameras":
|
||||
return CameraControl.ListCameras(@params);
|
||||
|
||||
case "screenshot":
|
||||
case "screenshot_multiview":
|
||||
{
|
||||
// Delegate to ManageScene's screenshot infrastructure
|
||||
var shotParams = new JObject(@params);
|
||||
shotParams["action"] = "screenshot";
|
||||
if (action == "screenshot_multiview")
|
||||
{
|
||||
shotParams["batch"] = "surround";
|
||||
shotParams["includeImage"] = true;
|
||||
}
|
||||
return ManageScene.HandleCommand(shotParams);
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2: Cinemachine-only actions
|
||||
if (!CameraHelpers.HasCinemachine)
|
||||
{
|
||||
return new ErrorResponse(
|
||||
$"Action '{action}' requires the Cinemachine package (com.unity.cinemachine). "
|
||||
+ CameraHelpers.GetFallbackSuggestion(action));
|
||||
}
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case "ensure_brain":
|
||||
return CameraCreate.EnsureBrain(@params);
|
||||
|
||||
case "get_brain_status":
|
||||
return CameraControl.GetBrainStatus(@params);
|
||||
|
||||
case "set_body":
|
||||
return CameraConfigure.SetBody(@params);
|
||||
|
||||
case "set_aim":
|
||||
return CameraConfigure.SetAim(@params);
|
||||
|
||||
case "set_noise":
|
||||
return CameraConfigure.SetNoise(@params);
|
||||
|
||||
case "add_extension":
|
||||
return CameraConfigure.AddExtension(@params);
|
||||
|
||||
case "remove_extension":
|
||||
return CameraConfigure.RemoveExtension(@params);
|
||||
|
||||
case "set_blend":
|
||||
return CameraControl.SetBlend(@params);
|
||||
|
||||
case "force_camera":
|
||||
return CameraControl.ForceCamera(@params);
|
||||
|
||||
case "release_override":
|
||||
return CameraControl.ReleaseOverride(@params);
|
||||
|
||||
default:
|
||||
return new ErrorResponse(
|
||||
$"Unknown action: '{action}'. Valid actions: ping, create_camera, set_target, "
|
||||
+ "set_lens, set_priority, list_cameras, screenshot, screenshot_multiview, "
|
||||
+ "ensure_brain, get_brain_status, "
|
||||
+ "set_body, set_aim, set_noise, add_extension, remove_extension, "
|
||||
+ "set_blend, force_camera, release_override.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Error($"[ManageCamera] Action '{action}' failed: {ex}");
|
||||
return new ErrorResponse($"Error in action '{action}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2f61f34d01e54d5ba8e47acab546ed98
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,495 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Editor.Resources;
|
||||
using MCPForUnity.Runtime.Helpers;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools
|
||||
{
|
||||
/// <summary>
|
||||
/// Holds information about a registered command handler.
|
||||
/// </summary>
|
||||
class HandlerInfo
|
||||
{
|
||||
public string CommandName { get; }
|
||||
public Func<JObject, object> SyncHandler { get; }
|
||||
public Func<JObject, Task<object>> AsyncHandler { get; }
|
||||
|
||||
public bool IsAsync => AsyncHandler != null;
|
||||
|
||||
public HandlerInfo(string commandName, Func<JObject, object> syncHandler, Func<JObject, Task<object>> asyncHandler)
|
||||
{
|
||||
CommandName = commandName;
|
||||
SyncHandler = syncHandler;
|
||||
AsyncHandler = asyncHandler;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registry for all MCP command handlers via reflection.
|
||||
/// Handles both MCP tools and resources.
|
||||
/// </summary>
|
||||
public static class CommandRegistry
|
||||
{
|
||||
private static readonly Dictionary<string, HandlerInfo> _handlers = new();
|
||||
private static bool _initialized = false;
|
||||
|
||||
/// <summary>
|
||||
/// Initialize and auto-discover all tools and resources marked with
|
||||
/// [McpForUnityTool] or [McpForUnityResource]
|
||||
/// </summary>
|
||||
public static void Initialize()
|
||||
{
|
||||
if (_initialized) return;
|
||||
|
||||
AutoDiscoverCommands();
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
private static string ToSnakeCase(string name) => StringCaseUtility.ToSnakeCase(name);
|
||||
|
||||
/// <summary>
|
||||
/// Auto-discover all types with [McpForUnityTool] or [McpForUnityResource] attributes
|
||||
/// </summary>
|
||||
private static void AutoDiscoverCommands()
|
||||
{
|
||||
// AssetImportWorker is a separate Editor subprocess. It doesn't host the MCP
|
||||
// transport so the registry is unused there, and Mono can hard-crash inside
|
||||
// GetCustomAttribute<T>() when scanning types whose owning assembly hasn't
|
||||
// finished domain-reload bookkeeping in the worker. Skip the scan there
|
||||
// entirely. See issue #1134.
|
||||
if (IsRunningInAssetImportWorker())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var allTypes = UnityAssembliesCompat.GetLoadedAssemblies()
|
||||
.Where(a => !a.IsDynamic)
|
||||
.SelectMany(a =>
|
||||
{
|
||||
try { return a.GetTypes(); }
|
||||
catch { return new Type[0]; }
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Discover tools
|
||||
var toolTypes = allTypes.Where(t => HasAttributeSafe<McpForUnityToolAttribute>(t));
|
||||
int toolCount = 0;
|
||||
foreach (var type in toolTypes)
|
||||
{
|
||||
if (RegisterCommandType(type, isResource: false))
|
||||
toolCount++;
|
||||
}
|
||||
|
||||
// Discover resources
|
||||
var resourceTypes = allTypes.Where(t => HasAttributeSafe<McpForUnityResourceAttribute>(t));
|
||||
int resourceCount = 0;
|
||||
foreach (var type in resourceTypes)
|
||||
{
|
||||
if (RegisterCommandType(type, isResource: true))
|
||||
resourceCount++;
|
||||
}
|
||||
|
||||
McpLog.Info($"Auto-discovered {toolCount} tools and {resourceCount} resources ({_handlers.Count} total handlers)", false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Error($"Failed to auto-discover MCP commands: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasAttributeSafe<T>(Type type) where T : Attribute
|
||||
{
|
||||
try
|
||||
{
|
||||
return type.GetCustomAttribute<T>() != null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Type metadata can be in a half-loaded state during domain reload; treat
|
||||
// those as "no attribute" rather than aborting the whole scan.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool? _cachedIsAssetImportWorker;
|
||||
|
||||
private static bool IsRunningInAssetImportWorker()
|
||||
{
|
||||
if (_cachedIsAssetImportWorker.HasValue)
|
||||
return _cachedIsAssetImportWorker.Value;
|
||||
|
||||
bool result = false;
|
||||
try
|
||||
{
|
||||
// AssetDatabase.IsAssetImportWorkerProcess() exists on Unity 2020.2+ but the
|
||||
// visibility has shifted between versions. Look it up reflectively so we
|
||||
// tolerate either signature without conditional compilation.
|
||||
var method = typeof(UnityEditor.AssetDatabase).GetMethod(
|
||||
"IsAssetImportWorkerProcess",
|
||||
BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
if (method != null && method.GetParameters().Length == 0)
|
||||
{
|
||||
result = method.Invoke(null, null) is bool b && b;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Reflection problems shouldn't break startup; fall through to the cmdline check.
|
||||
}
|
||||
|
||||
if (!result)
|
||||
{
|
||||
try
|
||||
{
|
||||
string cmd = Environment.CommandLine ?? string.Empty;
|
||||
if (cmd.IndexOf("-importWorker", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| cmd.IndexOf("AssetImportWorker", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
result = true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
_cachedIsAssetImportWorker = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a command type (tool or resource) with the registry.
|
||||
/// Returns true if successfully registered, false otherwise.
|
||||
/// </summary>
|
||||
private static bool RegisterCommandType(Type type, bool isResource)
|
||||
{
|
||||
string commandName;
|
||||
string typeLabel = isResource ? "resource" : "tool";
|
||||
|
||||
// Get command name from appropriate attribute
|
||||
if (isResource)
|
||||
{
|
||||
var resourceAttr = type.GetCustomAttribute<McpForUnityResourceAttribute>();
|
||||
commandName = resourceAttr.ResourceName;
|
||||
}
|
||||
else
|
||||
{
|
||||
var toolAttr = type.GetCustomAttribute<McpForUnityToolAttribute>();
|
||||
commandName = toolAttr.CommandName;
|
||||
}
|
||||
|
||||
// Auto-generate command name if not explicitly provided
|
||||
if (string.IsNullOrEmpty(commandName))
|
||||
{
|
||||
commandName = ToSnakeCase(type.Name);
|
||||
}
|
||||
|
||||
// Check for duplicate command names
|
||||
if (_handlers.ContainsKey(commandName))
|
||||
{
|
||||
McpLog.Warn(
|
||||
$"Duplicate command name '{commandName}' detected. " +
|
||||
$"{typeLabel} {type.Name} will override previously registered handler."
|
||||
);
|
||||
}
|
||||
|
||||
// Find HandleCommand method
|
||||
var method = type.GetMethod(
|
||||
"HandleCommand",
|
||||
BindingFlags.Public | BindingFlags.Static,
|
||||
null,
|
||||
new[] { typeof(JObject) },
|
||||
null
|
||||
);
|
||||
|
||||
if (method == null)
|
||||
{
|
||||
McpLog.Warn(
|
||||
$"MCP {typeLabel} {type.Name} is marked with [McpForUnity{(isResource ? "Resource" : "Tool")}] " +
|
||||
$"but has no public static HandleCommand(JObject) method"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
HandlerInfo handlerInfo;
|
||||
|
||||
if (typeof(Task).IsAssignableFrom(method.ReturnType))
|
||||
{
|
||||
var asyncHandler = CreateAsyncHandlerDelegate(method, commandName);
|
||||
handlerInfo = new HandlerInfo(commandName, null, asyncHandler);
|
||||
}
|
||||
else
|
||||
{
|
||||
var handler = (Func<JObject, object>)Delegate.CreateDelegate(
|
||||
typeof(Func<JObject, object>),
|
||||
method
|
||||
);
|
||||
handlerInfo = new HandlerInfo(commandName, handler, null);
|
||||
}
|
||||
|
||||
_handlers[commandName] = handlerInfo;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Error($"Failed to register {typeLabel} {type.Name}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a command handler by name
|
||||
/// </summary>
|
||||
private static HandlerInfo GetHandlerInfo(string commandName)
|
||||
{
|
||||
if (!_handlers.TryGetValue(commandName, out var handler))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Unknown or unsupported command type: {commandName}"
|
||||
);
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a synchronous command handler by name.
|
||||
/// Throws if the command is asynchronous.
|
||||
/// </summary>
|
||||
/// <param name="commandName"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="InvalidOperationException"></exception>
|
||||
public static Func<JObject, object> GetHandler(string commandName)
|
||||
{
|
||||
var handlerInfo = GetHandlerInfo(commandName);
|
||||
if (handlerInfo.IsAsync)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Command '{commandName}' is asynchronous and must be executed via ExecuteCommand"
|
||||
);
|
||||
}
|
||||
|
||||
return handlerInfo.SyncHandler;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute a command handler, supporting both synchronous and asynchronous (coroutine) handlers.
|
||||
/// If the handler returns an IEnumerator, it will be executed as a coroutine.
|
||||
/// </summary>
|
||||
/// <param name="commandName">The command name to execute</param>
|
||||
/// <param name="params">Command parameters</param>
|
||||
/// <param name="tcs">TaskCompletionSource to complete when async operation finishes</param>
|
||||
/// <returns>The result for synchronous commands, or null for async commands (TCS will be completed later)</returns>
|
||||
public static object ExecuteCommand(string commandName, JObject @params, TaskCompletionSource<string> tcs)
|
||||
{
|
||||
var handlerInfo = GetHandlerInfo(commandName);
|
||||
|
||||
if (handlerInfo.IsAsync)
|
||||
{
|
||||
ExecuteAsyncHandler(handlerInfo, @params, commandName, tcs);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (handlerInfo.SyncHandler == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Handler for '{commandName}' does not provide a synchronous implementation");
|
||||
}
|
||||
|
||||
return handlerInfo.SyncHandler(@params);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute a command handler and return its raw result, regardless of sync or async implementation.
|
||||
/// Used internally for features like batch execution where commands need to be composed.
|
||||
/// </summary>
|
||||
/// <param name="commandName">The registered command to execute.</param>
|
||||
/// <param name="params">Parameters to pass to the command (optional).</param>
|
||||
public static Task<object> InvokeCommandAsync(string commandName, JObject @params)
|
||||
{
|
||||
var handlerInfo = GetHandlerInfo(commandName);
|
||||
var payload = @params ?? new JObject();
|
||||
|
||||
if (handlerInfo.IsAsync)
|
||||
{
|
||||
if (handlerInfo.AsyncHandler == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Async handler for '{commandName}' is not configured correctly");
|
||||
}
|
||||
|
||||
return handlerInfo.AsyncHandler(payload);
|
||||
}
|
||||
|
||||
if (handlerInfo.SyncHandler == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Handler for '{commandName}' does not provide a synchronous implementation");
|
||||
}
|
||||
|
||||
object result = handlerInfo.SyncHandler(payload);
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a delegate for an async handler method that returns Task or Task<T>.
|
||||
/// The delegate will invoke the method and await its completion, returning the result.
|
||||
/// </summary>
|
||||
/// <param name="method"></param>
|
||||
/// <param name="commandName"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="InvalidOperationException"></exception>
|
||||
private static Func<JObject, Task<object>> CreateAsyncHandlerDelegate(MethodInfo method, string commandName)
|
||||
{
|
||||
return async (JObject parameters) =>
|
||||
{
|
||||
object rawResult;
|
||||
|
||||
try
|
||||
{
|
||||
rawResult = method.Invoke(null, new object[] { parameters });
|
||||
}
|
||||
catch (TargetInvocationException ex)
|
||||
{
|
||||
throw ex.InnerException ?? ex;
|
||||
}
|
||||
|
||||
if (rawResult == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (rawResult is not Task task)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Async handler '{commandName}' returned an object that is not a Task"
|
||||
);
|
||||
}
|
||||
|
||||
await task.ConfigureAwait(true);
|
||||
|
||||
var taskType = task.GetType();
|
||||
if (taskType.IsGenericType)
|
||||
{
|
||||
var resultProperty = taskType.GetProperty("Result");
|
||||
if (resultProperty != null)
|
||||
{
|
||||
return resultProperty.GetValue(task);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
private static void ExecuteAsyncHandler(
|
||||
HandlerInfo handlerInfo,
|
||||
JObject parameters,
|
||||
string commandName,
|
||||
TaskCompletionSource<string> tcs)
|
||||
{
|
||||
if (handlerInfo.AsyncHandler == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Async handler for '{commandName}' is not configured correctly");
|
||||
}
|
||||
|
||||
Task<object> handlerTask;
|
||||
|
||||
try
|
||||
{
|
||||
handlerTask = handlerInfo.AsyncHandler(parameters);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ReportAsyncFailure(commandName, tcs, ex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (handlerTask == null)
|
||||
{
|
||||
CompleteAsyncCommand(commandName, tcs, null);
|
||||
return;
|
||||
}
|
||||
|
||||
async void AwaitHandler()
|
||||
{
|
||||
try
|
||||
{
|
||||
var finalResult = await handlerTask.ConfigureAwait(true);
|
||||
CompleteAsyncCommand(commandName, tcs, finalResult);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ReportAsyncFailure(commandName, tcs, ex);
|
||||
}
|
||||
}
|
||||
|
||||
AwaitHandler();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complete the TaskCompletionSource for an async command with a success result.
|
||||
/// </summary>
|
||||
/// <param name="commandName"></param>
|
||||
/// <param name="tcs"></param>
|
||||
/// <param name="result"></param>
|
||||
private static void CompleteAsyncCommand(string commandName, TaskCompletionSource<string> tcs, object result)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = new { status = "success", result };
|
||||
string json = JsonConvert.SerializeObject(response);
|
||||
|
||||
if (!tcs.TrySetResult(json))
|
||||
{
|
||||
McpLog.Warn($"TCS for async command '{commandName}' was already completed");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Error($"Error completing async command '{commandName}': {ex.Message}\n{ex.StackTrace}");
|
||||
ReportAsyncFailure(commandName, tcs, ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Report an error that occurred during async command execution.
|
||||
/// Completes the TaskCompletionSource with an error response.
|
||||
/// </summary>
|
||||
/// <param name="commandName"></param>
|
||||
/// <param name="tcs"></param>
|
||||
/// <param name="ex"></param>
|
||||
private static void ReportAsyncFailure(string commandName, TaskCompletionSource<string> tcs, Exception ex)
|
||||
{
|
||||
McpLog.Error($"Error in async command '{commandName}': {ex.Message}\n{ex.StackTrace}");
|
||||
|
||||
var errorResponse = new
|
||||
{
|
||||
status = "error",
|
||||
error = ex.Message,
|
||||
command = commandName,
|
||||
stackTrace = ex.StackTrace
|
||||
};
|
||||
|
||||
string json;
|
||||
try
|
||||
{
|
||||
json = JsonConvert.SerializeObject(errorResponse);
|
||||
}
|
||||
catch (Exception serializationEx)
|
||||
{
|
||||
McpLog.Error($"Failed to serialize error response for '{commandName}': {serializationEx.Message}");
|
||||
json = "{\"status\":\"error\",\"error\":\"Failed to complete command\"}";
|
||||
}
|
||||
|
||||
if (!tcs.TrySetResult(json))
|
||||
{
|
||||
McpLog.Warn($"TCS for async command '{commandName}' was already completed when trying to report error");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5b61b5a84813b5749a5c64422694a0fa
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,709 @@
|
||||
using System;
|
||||
using System.CodeDom.Compiler;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Runtime.Helpers;
|
||||
using Microsoft.CSharp;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools
|
||||
{
|
||||
[McpForUnityTool("execute_code", AutoRegister = false, Group = "scripting_ext")]
|
||||
public static class ExecuteCode
|
||||
{
|
||||
private const int MaxCodeLength = 50000;
|
||||
private const int MaxHistoryEntries = 50;
|
||||
private const int MaxHistoryCodePreview = 500;
|
||||
internal const int WrapperLineOffset = 10;
|
||||
private const string WrapperClassName = "MCPDynamicCode";
|
||||
private const string WrapperMethodName = "Execute";
|
||||
|
||||
private const string ActionExecute = "execute";
|
||||
private const string ActionGetHistory = "get_history";
|
||||
private const string ActionClearHistory = "clear_history";
|
||||
private const string ActionReplay = "replay";
|
||||
|
||||
private static readonly List<HistoryEntry> _history = new List<HistoryEntry>();
|
||||
private static string[] _cachedAssemblyPaths;
|
||||
|
||||
[UnityEditor.InitializeOnLoadMethod]
|
||||
private static void OnDomainReload()
|
||||
{
|
||||
_cachedAssemblyPaths = null;
|
||||
RoslynCompiler.ResetCache();
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> _blockedPatterns = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"System.IO.File.Delete",
|
||||
"System.IO.Directory.Delete",
|
||||
"FileUtil.DeleteFileOrDirectory",
|
||||
"AssetDatabase.DeleteAsset",
|
||||
"AssetDatabase.MoveAssetToTrash",
|
||||
"EditorApplication.Exit",
|
||||
"Process.Start",
|
||||
"Process.Kill",
|
||||
"while(true)",
|
||||
"while (true)",
|
||||
"for(;;)",
|
||||
"for (;;)",
|
||||
};
|
||||
|
||||
public static object HandleCommand(JObject @params)
|
||||
{
|
||||
if (@params == null)
|
||||
return new ErrorResponse("Parameters cannot be null.");
|
||||
|
||||
var p = new ToolParams(@params);
|
||||
var actionResult = p.GetRequired("action");
|
||||
if (!actionResult.IsSuccess)
|
||||
return new ErrorResponse(actionResult.ErrorMessage);
|
||||
|
||||
string action = actionResult.Value.ToLowerInvariant();
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case ActionExecute:
|
||||
return HandleExecute(@params);
|
||||
case ActionGetHistory:
|
||||
return HandleGetHistory(@params);
|
||||
case ActionClearHistory:
|
||||
return HandleClearHistory();
|
||||
case ActionReplay:
|
||||
return HandleReplay(@params);
|
||||
default:
|
||||
return new ErrorResponse(
|
||||
$"Unknown action: '{action}'. Valid actions: {ActionExecute}, {ActionGetHistory}, {ActionClearHistory}, {ActionReplay}");
|
||||
}
|
||||
}
|
||||
|
||||
private static object HandleExecute(JObject @params)
|
||||
{
|
||||
string code = @params["code"]?.ToString();
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return new ErrorResponse("Required parameter 'code' is missing or empty.");
|
||||
|
||||
if (code.Length > MaxCodeLength)
|
||||
return new ErrorResponse($"Code exceeds maximum length of {MaxCodeLength} characters.");
|
||||
|
||||
bool safetyChecks = @params["safety_checks"]?.Value<bool>() ?? true;
|
||||
string compiler = @params["compiler"]?.ToString()?.ToLowerInvariant() ?? "auto";
|
||||
|
||||
if (safetyChecks)
|
||||
{
|
||||
var violation = CheckBlockedPatterns(code);
|
||||
if (violation != null)
|
||||
return new ErrorResponse($"Blocked pattern detected: {violation}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var startTime = DateTime.UtcNow;
|
||||
var result = CompileAndExecute(code, compiler);
|
||||
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||
|
||||
AddToHistory(code, result, elapsed, safetyChecks, compiler);
|
||||
return result;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
McpLog.Error($"[ExecuteCode] Execution failed: {e}");
|
||||
var errorResult = new ErrorResponse($"Execution failed: {e.Message}");
|
||||
AddToHistory(code, errorResult, 0, safetyChecks, compiler);
|
||||
return errorResult;
|
||||
}
|
||||
}
|
||||
|
||||
private static object HandleGetHistory(JObject @params)
|
||||
{
|
||||
int limit = @params["limit"]?.Value<int>() ?? 10;
|
||||
limit = Math.Clamp(limit, 1, MaxHistoryEntries);
|
||||
|
||||
if (_history.Count == 0)
|
||||
return new SuccessResponse("No execution history.", new { total = 0, entries = new object[0] });
|
||||
|
||||
var entries = _history.Skip(Math.Max(0, _history.Count - limit)).ToList();
|
||||
return new SuccessResponse($"Returning {entries.Count} of {_history.Count} history entries.", new
|
||||
{
|
||||
total = _history.Count,
|
||||
entries = entries.Select((e, i) => new
|
||||
{
|
||||
index = _history.Count - entries.Count + i,
|
||||
codePreview = e.code.Length > MaxHistoryCodePreview
|
||||
? e.code.Substring(0, MaxHistoryCodePreview) + "..."
|
||||
: e.code,
|
||||
e.success,
|
||||
e.resultPreview,
|
||||
e.elapsedMs,
|
||||
e.timestamp,
|
||||
e.safetyChecksEnabled,
|
||||
e.compiler,
|
||||
}).ToList(),
|
||||
});
|
||||
}
|
||||
|
||||
private static object HandleClearHistory()
|
||||
{
|
||||
int count = _history.Count;
|
||||
_history.Clear();
|
||||
return new SuccessResponse($"Cleared {count} history entries.");
|
||||
}
|
||||
|
||||
private static object HandleReplay(JObject @params)
|
||||
{
|
||||
if (_history.Count == 0)
|
||||
return new ErrorResponse("No execution history to replay.");
|
||||
|
||||
int? index = @params["index"]?.Value<int>();
|
||||
if (index == null || index < 0 || index >= _history.Count)
|
||||
return new ErrorResponse($"Invalid history index. Valid range: 0-{_history.Count - 1}");
|
||||
|
||||
var entry = _history[index.Value];
|
||||
var replayParams = JObject.FromObject(new
|
||||
{
|
||||
action = ActionExecute,
|
||||
code = entry.code,
|
||||
safety_checks = entry.safetyChecksEnabled,
|
||||
compiler = entry.compiler ?? "auto",
|
||||
});
|
||||
return HandleExecute(replayParams);
|
||||
}
|
||||
|
||||
// ──────────────────── Compilation ────────────────────
|
||||
|
||||
private static object CompileAndExecute(string code, string compiler)
|
||||
{
|
||||
string wrappedSource = WrapUserCode(code);
|
||||
string[] assemblyPaths = GetAssemblyPaths();
|
||||
|
||||
Assembly compiled;
|
||||
string usedCompiler;
|
||||
|
||||
switch (compiler)
|
||||
{
|
||||
case "roslyn":
|
||||
if (!RoslynCompiler.IsAvailable)
|
||||
return new ErrorResponse("Roslyn (Microsoft.CodeAnalysis) is not available. Install it via NuGet or use compiler='codedom'.");
|
||||
compiled = RoslynCompiler.Compile(wrappedSource, assemblyPaths, out var roslynErrors);
|
||||
if (compiled == null)
|
||||
return new ErrorResponse("Compilation failed", new { errors = OffsetErrors(roslynErrors), compiler = "roslyn" });
|
||||
usedCompiler = "roslyn";
|
||||
break;
|
||||
|
||||
case "codedom":
|
||||
compiled = CodeDomCompile(wrappedSource, assemblyPaths, out var codedomErrors);
|
||||
if (compiled == null)
|
||||
return new ErrorResponse("Compilation failed", new { errors = OffsetErrors(codedomErrors), compiler = "codedom" });
|
||||
usedCompiler = "codedom";
|
||||
break;
|
||||
|
||||
default: // "auto"
|
||||
if (RoslynCompiler.IsAvailable)
|
||||
{
|
||||
compiled = RoslynCompiler.Compile(wrappedSource, assemblyPaths, out var autoErrors);
|
||||
if (compiled == null)
|
||||
return new ErrorResponse("Compilation failed", new { errors = OffsetErrors(autoErrors), compiler = "roslyn" });
|
||||
usedCompiler = "roslyn";
|
||||
}
|
||||
else
|
||||
{
|
||||
compiled = CodeDomCompile(wrappedSource, assemblyPaths, out var autoFallbackErrors);
|
||||
if (compiled == null)
|
||||
return new ErrorResponse("Compilation failed", new { errors = OffsetErrors(autoFallbackErrors), compiler = "codedom" });
|
||||
usedCompiler = "codedom";
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return InvokeCompiled(compiled, usedCompiler);
|
||||
}
|
||||
|
||||
private static object InvokeCompiled(Assembly assembly, string compilerUsed)
|
||||
{
|
||||
var type = assembly.GetType(WrapperClassName);
|
||||
if (type == null)
|
||||
return new ErrorResponse("Internal error: failed to find compiled type.");
|
||||
|
||||
var method = type.GetMethod(WrapperMethodName, BindingFlags.Public | BindingFlags.Static);
|
||||
if (method == null)
|
||||
return new ErrorResponse("Internal error: failed to find Execute method.");
|
||||
|
||||
object result = null;
|
||||
Exception executionError = null;
|
||||
|
||||
try
|
||||
{
|
||||
result = method.Invoke(null, null);
|
||||
}
|
||||
catch (TargetInvocationException tie)
|
||||
{
|
||||
executionError = tie.InnerException ?? tie;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
executionError = e;
|
||||
}
|
||||
|
||||
if (executionError != null)
|
||||
return new ErrorResponse($"Runtime error: {executionError.Message}",
|
||||
new { exceptionType = executionError.GetType().Name, stackTrace = executionError.StackTrace, compiler = compilerUsed });
|
||||
|
||||
if (result != null)
|
||||
return new SuccessResponse("Code executed successfully.",
|
||||
new { result = SerializeResult(result), compiler = compilerUsed });
|
||||
|
||||
return new SuccessResponse("Code executed successfully.", new { compiler = compilerUsed });
|
||||
}
|
||||
|
||||
private static List<string> OffsetErrors(List<string> errors)
|
||||
{
|
||||
// Errors already have line numbers adjusted by the compiler-specific code
|
||||
return errors;
|
||||
}
|
||||
|
||||
// ──────────────────── CodeDom compiler ────────────────────
|
||||
|
||||
private static Assembly CodeDomCompile(string source, string[] assemblyPaths, out List<string> errors)
|
||||
{
|
||||
errors = new List<string>();
|
||||
|
||||
// CodeDom needs the netstandard-aware filtered paths
|
||||
var filtered = FilterAssemblyPathsForCodeDom(assemblyPaths);
|
||||
|
||||
// CSharpCodeProvider turns every ReferencedAssemblies entry into a literal /r:"..." flag
|
||||
// on the csc command line. Projects with ~100+ asmdefs blow past Windows' 32 KB
|
||||
// CreateProcess argument limit and fail with "The filename or extension is too long."
|
||||
// Route references through a response file (@responsefile is supported by both mcs and
|
||||
// Roslyn csc) so we pass exactly one short argument regardless of reference count.
|
||||
string responseFilePath = Path.Combine(Path.GetTempPath(), $"mcp-codedom-{Guid.NewGuid():N}.rsp");
|
||||
|
||||
try
|
||||
{
|
||||
using (var writer = new StreamWriter(responseFilePath, append: false, Encoding.UTF8))
|
||||
{
|
||||
foreach (var path in filtered)
|
||||
{
|
||||
writer.Write("/r:\"");
|
||||
writer.Write(path);
|
||||
writer.WriteLine("\"");
|
||||
}
|
||||
}
|
||||
|
||||
using (var provider = new CSharpCodeProvider())
|
||||
{
|
||||
var parameters = new CompilerParameters
|
||||
{
|
||||
GenerateInMemory = true,
|
||||
GenerateExecutable = false,
|
||||
TreatWarningsAsErrors = false,
|
||||
CompilerOptions = "@\"" + responseFilePath + "\"",
|
||||
};
|
||||
|
||||
var results = provider.CompileAssemblyFromSource(parameters, source);
|
||||
|
||||
if (results.Errors.HasErrors)
|
||||
{
|
||||
foreach (CompilerError error in results.Errors)
|
||||
{
|
||||
if (!error.IsWarning)
|
||||
{
|
||||
int userLine = Math.Max(1, error.Line - WrapperLineOffset);
|
||||
errors.Add($"Line {userLine}: {error.ErrorText}");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return results.CompiledAssembly;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (File.Exists(responseFilePath)) File.Delete(responseFilePath); }
|
||||
catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
// CSharpCodeProvider can't resolve type-forwarding, so when netstandard.dll is loaded
|
||||
// alongside mscorlib/System.Runtime/System.Collections, types like List<T> appear in
|
||||
// multiple assemblies causing "type defined multiple times" errors.
|
||||
private static readonly HashSet<string> _codedomDuplicateAssemblies = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"mscorlib",
|
||||
"System.Runtime",
|
||||
"System.Private.CoreLib",
|
||||
"System.Collections",
|
||||
};
|
||||
|
||||
private static string[] FilterAssemblyPathsForCodeDom(string[] allPaths)
|
||||
{
|
||||
bool hasNetstandard = allPaths.Any(p =>
|
||||
string.Equals(Path.GetFileNameWithoutExtension(p), "netstandard", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!hasNetstandard)
|
||||
return allPaths;
|
||||
|
||||
return allPaths.Where(p =>
|
||||
!_codedomDuplicateAssemblies.Contains(Path.GetFileNameWithoutExtension(p))).ToArray();
|
||||
}
|
||||
|
||||
// ──────────────────── Shared helpers ────────────────────
|
||||
|
||||
private static string WrapUserCode(string code)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("using System;");
|
||||
sb.AppendLine("using System.Collections.Generic;");
|
||||
sb.AppendLine("using System.Linq;");
|
||||
sb.AppendLine("using System.Reflection;");
|
||||
sb.AppendLine("using UnityEngine;");
|
||||
sb.AppendLine("using UnityEditor;");
|
||||
sb.AppendLine($"public static class {WrapperClassName}");
|
||||
sb.AppendLine("{");
|
||||
sb.AppendLine($" public static object {WrapperMethodName}()");
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine(code);
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine("}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string[] GetAssemblyPaths()
|
||||
{
|
||||
if (_cachedAssemblyPaths == null)
|
||||
_cachedAssemblyPaths = ResolveAssemblyPaths();
|
||||
return _cachedAssemblyPaths;
|
||||
}
|
||||
|
||||
private static string[] ResolveAssemblyPaths()
|
||||
{
|
||||
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var assembly in UnityAssembliesCompat.GetLoadedAssemblies())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (assembly.IsDynamic) continue;
|
||||
var location = assembly.Location;
|
||||
if (string.IsNullOrEmpty(location)) continue;
|
||||
if (!File.Exists(location)) continue;
|
||||
paths.Add(location);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
// Some assemblies don't support Location property
|
||||
}
|
||||
}
|
||||
|
||||
var result = new string[paths.Count];
|
||||
paths.CopyTo(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string CheckBlockedPatterns(string code)
|
||||
{
|
||||
foreach (var pattern in _blockedPatterns)
|
||||
{
|
||||
if (code.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
return $"Code contains blocked pattern: '{pattern}'. Disable safety checks with safety_checks=false if this is intentional.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void AddToHistory(string code, object result, double elapsedMs, bool safetyChecks, string compiler = "auto")
|
||||
{
|
||||
string preview;
|
||||
if (result is SuccessResponse sr)
|
||||
preview = sr.Data?.ToString() ?? sr.Message;
|
||||
else if (result is ErrorResponse er)
|
||||
preview = er.Error;
|
||||
else
|
||||
preview = result?.ToString() ?? "null";
|
||||
|
||||
if (preview != null && preview.Length > 200)
|
||||
preview = preview.Substring(0, 200) + "...";
|
||||
|
||||
_history.Add(new HistoryEntry
|
||||
{
|
||||
code = code,
|
||||
success = result is SuccessResponse,
|
||||
resultPreview = preview,
|
||||
elapsedMs = Math.Round(elapsedMs, 1),
|
||||
timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
safetyChecksEnabled = safetyChecks,
|
||||
compiler = compiler,
|
||||
});
|
||||
|
||||
while (_history.Count > MaxHistoryEntries)
|
||||
_history.RemoveAt(0);
|
||||
}
|
||||
|
||||
private static object SerializeResult(object result)
|
||||
{
|
||||
if (result == null) return null;
|
||||
|
||||
var type = result.GetType();
|
||||
if (type.IsPrimitive || result is string || result is decimal)
|
||||
return result;
|
||||
|
||||
try
|
||||
{
|
||||
return JToken.FromObject(result);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return result.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
private class HistoryEntry
|
||||
{
|
||||
public string code;
|
||||
public bool success;
|
||||
public string resultPreview;
|
||||
public double elapsedMs;
|
||||
public string timestamp;
|
||||
public bool safetyChecksEnabled;
|
||||
public string compiler;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Roslyn compiler backend accessed entirely via reflection.
|
||||
/// No compile-time dependency on Microsoft.CodeAnalysis — works only if the package is installed.
|
||||
/// </summary>
|
||||
internal static class RoslynCompiler
|
||||
{
|
||||
private static bool? _isAvailable;
|
||||
private static Type _syntaxTreeType;
|
||||
private static Type _compilationType;
|
||||
private static Type _compilationOptionsType;
|
||||
private static Type _parseOptionsType;
|
||||
private static Type _metadataReferenceType;
|
||||
private static Type _outputKindEnum;
|
||||
private static Type _languageVersionEnum;
|
||||
private static MethodInfo _parseText;
|
||||
private static MethodInfo _createCompilation;
|
||||
private static MethodInfo _createFromFile;
|
||||
private static MethodInfo _emit;
|
||||
private static object _parseOptions;
|
||||
private static object _compilationOptions;
|
||||
|
||||
public static bool IsAvailable
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_isAvailable == null)
|
||||
_isAvailable = Initialize();
|
||||
return _isAvailable.Value;
|
||||
}
|
||||
}
|
||||
|
||||
public static void ResetCache()
|
||||
{
|
||||
_isAvailable = null;
|
||||
}
|
||||
|
||||
private static bool Initialize()
|
||||
{
|
||||
try
|
||||
{
|
||||
_syntaxTreeType = Type.GetType("Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree, Microsoft.CodeAnalysis.CSharp");
|
||||
_compilationType = Type.GetType("Microsoft.CodeAnalysis.CSharp.CSharpCompilation, Microsoft.CodeAnalysis.CSharp");
|
||||
_compilationOptionsType = Type.GetType("Microsoft.CodeAnalysis.CSharp.CSharpCompilationOptions, Microsoft.CodeAnalysis.CSharp");
|
||||
_parseOptionsType = Type.GetType("Microsoft.CodeAnalysis.CSharp.CSharpParseOptions, Microsoft.CodeAnalysis.CSharp");
|
||||
_metadataReferenceType = Type.GetType("Microsoft.CodeAnalysis.MetadataReference, Microsoft.CodeAnalysis");
|
||||
_outputKindEnum = Type.GetType("Microsoft.CodeAnalysis.OutputKind, Microsoft.CodeAnalysis");
|
||||
_languageVersionEnum = Type.GetType("Microsoft.CodeAnalysis.CSharp.LanguageVersion, Microsoft.CodeAnalysis.CSharp");
|
||||
|
||||
if (_syntaxTreeType == null || _compilationType == null || _compilationOptionsType == null ||
|
||||
_parseOptionsType == null || _metadataReferenceType == null || _outputKindEnum == null ||
|
||||
_languageVersionEnum == null)
|
||||
return false;
|
||||
|
||||
// CSharpSyntaxTree.ParseText(string, CSharpParseOptions, string, Encoding, CancellationToken)
|
||||
var syntaxTreeBase = Type.GetType("Microsoft.CodeAnalysis.SyntaxTree, Microsoft.CodeAnalysis");
|
||||
_parseText = _syntaxTreeType.GetMethod("ParseText", new[] { typeof(string), _parseOptionsType, typeof(string), typeof(Encoding), typeof(System.Threading.CancellationToken) });
|
||||
if (_parseText == null)
|
||||
return false;
|
||||
|
||||
// CSharpCompilation.Create(string, IEnumerable<SyntaxTree>, IEnumerable<MetadataReference>, CSharpCompilationOptions)
|
||||
var metadataRefBase = _metadataReferenceType;
|
||||
var syntaxTreeEnumerable = typeof(IEnumerable<>).MakeGenericType(syntaxTreeBase);
|
||||
var metadataRefEnumerable = typeof(IEnumerable<>).MakeGenericType(metadataRefBase);
|
||||
_createCompilation = _compilationType.GetMethod("Create", new[] { typeof(string), syntaxTreeEnumerable, metadataRefEnumerable, _compilationOptionsType });
|
||||
if (_createCompilation == null)
|
||||
return false;
|
||||
|
||||
// MetadataReference.CreateFromFile(string, MetadataReferenceProperties, DocumentationProvider)
|
||||
_createFromFile = _metadataReferenceType.GetMethods(BindingFlags.Public | BindingFlags.Static)
|
||||
.FirstOrDefault(m => m.Name == "CreateFromFile");
|
||||
if (_createFromFile == null)
|
||||
return false;
|
||||
|
||||
// Emit has no single-param overload; the simplest is
|
||||
// Emit(Stream, Stream, Stream, Stream, IEnumerable<ResourceDescription>, EmitOptions, CancellationToken)
|
||||
var compilationBase = Type.GetType("Microsoft.CodeAnalysis.Compilation, Microsoft.CodeAnalysis");
|
||||
if (compilationBase == null) return false;
|
||||
_emit = compilationBase.GetMethods(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(m => m.Name == "Emit")
|
||||
.OrderBy(m => m.GetParameters().Length)
|
||||
.FirstOrDefault();
|
||||
if (_emit == null)
|
||||
return false;
|
||||
|
||||
// Build CSharpParseOptions — constructor has optional params, use reflection
|
||||
var latestValue = Enum.Parse(_languageVersionEnum, "Latest");
|
||||
var parseOptionsCtor = _parseOptionsType.GetConstructors(BindingFlags.Public | BindingFlags.Instance)[0];
|
||||
var parseCtorParams = parseOptionsCtor.GetParameters();
|
||||
var parseArgs = new object[parseCtorParams.Length];
|
||||
for (int i = 0; i < parseCtorParams.Length; i++)
|
||||
{
|
||||
if (parseCtorParams[i].Name == "languageVersion")
|
||||
parseArgs[i] = latestValue;
|
||||
else if (parseCtorParams[i].HasDefaultValue)
|
||||
parseArgs[i] = parseCtorParams[i].DefaultValue;
|
||||
else
|
||||
parseArgs[i] = null;
|
||||
}
|
||||
_parseOptions = parseOptionsCtor.Invoke(parseArgs);
|
||||
|
||||
// Build CSharpCompilationOptions — use the first constructor (has most defaults)
|
||||
var dllKind = Enum.Parse(_outputKindEnum, "DynamicallyLinkedLibrary");
|
||||
var compOptionsCtor = _compilationOptionsType.GetConstructors(BindingFlags.Public | BindingFlags.Instance)[0];
|
||||
var compCtorParams = compOptionsCtor.GetParameters();
|
||||
var compArgs = new object[compCtorParams.Length];
|
||||
for (int i = 0; i < compCtorParams.Length; i++)
|
||||
{
|
||||
if (compCtorParams[i].Name == "outputKind")
|
||||
compArgs[i] = dllKind;
|
||||
else if (compCtorParams[i].HasDefaultValue)
|
||||
compArgs[i] = compCtorParams[i].DefaultValue;
|
||||
else
|
||||
compArgs[i] = null;
|
||||
}
|
||||
_compilationOptions = compOptionsCtor.Invoke(compArgs);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
McpLog.Warn($"[ExecuteCode] Roslyn initialization failed: {e.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static Assembly Compile(string source, string[] assemblyPaths, out List<string> errors)
|
||||
{
|
||||
errors = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
// Parse source
|
||||
var syntaxTree = _parseText.Invoke(null, new object[] { source, _parseOptions, null, null, default(System.Threading.CancellationToken) });
|
||||
|
||||
// Build metadata references
|
||||
var metadataRefBase = _metadataReferenceType;
|
||||
var listType = typeof(List<>).MakeGenericType(metadataRefBase);
|
||||
var refs = (System.Collections.IList)Activator.CreateInstance(listType);
|
||||
|
||||
foreach (var path in assemblyPaths)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cfParams = _createFromFile.GetParameters();
|
||||
var cfArgs = new object[cfParams.Length];
|
||||
cfArgs[0] = path; // string path
|
||||
for (int i = 1; i < cfParams.Length; i++)
|
||||
cfArgs[i] = cfParams[i].HasDefaultValue ? cfParams[i].DefaultValue : null;
|
||||
var metaRef = _createFromFile.Invoke(null, cfArgs);
|
||||
refs.Add(metaRef);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip assemblies that can't be loaded as metadata
|
||||
}
|
||||
}
|
||||
|
||||
// Build syntax tree array
|
||||
var syntaxTreeBase = Type.GetType("Microsoft.CodeAnalysis.SyntaxTree, Microsoft.CodeAnalysis");
|
||||
var treeArray = Array.CreateInstance(syntaxTreeBase, 1);
|
||||
treeArray.SetValue(syntaxTree, 0);
|
||||
|
||||
// Create compilation
|
||||
var compilation = _createCompilation.Invoke(null, new object[] { "MCPDynamic", treeArray, refs, _compilationOptions });
|
||||
|
||||
// Emit to memory
|
||||
using (var ms = new MemoryStream())
|
||||
{
|
||||
// Build args for the Emit overload (fill non-stream params with defaults)
|
||||
var emitParams = _emit.GetParameters();
|
||||
var emitArgs = new object[emitParams.Length];
|
||||
emitArgs[0] = ms; // peStream
|
||||
for (int i = 1; i < emitParams.Length; i++)
|
||||
{
|
||||
if (emitParams[i].HasDefaultValue)
|
||||
emitArgs[i] = emitParams[i].DefaultValue;
|
||||
else
|
||||
emitArgs[i] = null;
|
||||
}
|
||||
var emitResult = _emit.Invoke(compilation, emitArgs);
|
||||
|
||||
// Check emitResult.Success
|
||||
var successProp = emitResult.GetType().GetProperty("Success");
|
||||
bool success = (bool)successProp.GetValue(emitResult);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
// Read emitResult.Diagnostics
|
||||
var diagProp = emitResult.GetType().GetProperty("Diagnostics");
|
||||
var diagnostics = (System.Collections.IEnumerable)diagProp.GetValue(emitResult);
|
||||
var severityError = Enum.Parse(Type.GetType("Microsoft.CodeAnalysis.DiagnosticSeverity, Microsoft.CodeAnalysis"), "Error");
|
||||
|
||||
foreach (var diag in diagnostics)
|
||||
{
|
||||
var sevProp = diag.GetType().GetProperty("Severity");
|
||||
var severity = sevProp.GetValue(diag);
|
||||
if (!severity.Equals(severityError)) continue;
|
||||
|
||||
var locProp = diag.GetType().GetProperty("Location");
|
||||
var loc = locProp.GetValue(diag);
|
||||
var spanProp = loc.GetType().GetMethod("GetLineSpan");
|
||||
var lineSpan = spanProp.Invoke(loc, null);
|
||||
var startProp = lineSpan.GetType().GetProperty("StartLinePosition");
|
||||
var startPos = startProp.GetValue(lineSpan);
|
||||
var lineProp = startPos.GetType().GetProperty("Line");
|
||||
int line = (int)lineProp.GetValue(startPos);
|
||||
|
||||
var msgProp = diag.GetType().GetMethod("GetMessage", new[] { typeof(System.Globalization.CultureInfo) });
|
||||
string msg = (string)msgProp.Invoke(diag, new object[] { null });
|
||||
|
||||
int userLine = Math.Max(1, line + 1 - ExecuteCode.WrapperLineOffset);
|
||||
errors.Add($"Line {userLine}: {msg}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
ms.Seek(0, SeekOrigin.Begin);
|
||||
return Assembly.Load(ms.ToArray());
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// Walk to the deepest cause: TargetInvocationException (and friends) wrap the real
|
||||
// failure inside .InnerException, and reporting only e.Message hides everything
|
||||
// useful (e.g. a missing transitive dep manifests as the generic "Exception has been
|
||||
// thrown by the target of an invocation.").
|
||||
Exception root = e;
|
||||
while (root.InnerException != null) root = root.InnerException;
|
||||
errors.Add($"Roslyn compilation error: {root.GetType().Name}: {root.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8ebfb8c53994b4750ad9c4f284e7126e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools
|
||||
{
|
||||
[McpForUnityTool("execute_menu_item", AutoRegister = false)]
|
||||
/// <summary>
|
||||
/// Tool to execute a Unity Editor menu item by its path.
|
||||
/// </summary>
|
||||
public static class ExecuteMenuItem
|
||||
{
|
||||
// Basic blacklist to prevent execution of disruptive menu items.
|
||||
private static readonly HashSet<string> _menuPathBlacklist = new HashSet<string>(
|
||||
StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"File/Quit",
|
||||
};
|
||||
|
||||
public static object HandleCommand(JObject @params)
|
||||
{
|
||||
McpLog.Info("[ExecuteMenuItem] Handling menu item command");
|
||||
string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString();
|
||||
if (string.IsNullOrWhiteSpace(menuPath))
|
||||
{
|
||||
return new ErrorResponse("Required parameter 'menu_path' or 'menuPath' is missing or empty.");
|
||||
}
|
||||
|
||||
if (_menuPathBlacklist.Contains(menuPath))
|
||||
{
|
||||
return new ErrorResponse($"Execution of menu item '{menuPath}' is blocked for safety reasons.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
bool executed = EditorApplication.ExecuteMenuItem(menuPath);
|
||||
if (!executed)
|
||||
{
|
||||
McpLog.Error($"[MenuItemExecutor] Failed to execute menu item '{menuPath}'. It might be invalid, disabled, or context-dependent.");
|
||||
return new ErrorResponse($"Failed to execute menu item '{menuPath}'. It might be invalid, disabled, or context-dependent.");
|
||||
}
|
||||
return new SuccessResponse($"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors.");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
McpLog.Error($"[MenuItemExecutor] Failed to setup execution for '{menuPath}': {e}");
|
||||
return new ErrorResponse($"Error setting up execution for menu item '{menuPath}': {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 269232350d16a464091aea9e9fcc9b55
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools
|
||||
{
|
||||
/// <summary>
|
||||
/// Tool for searching GameObjects in the scene.
|
||||
/// Returns only instance IDs with pagination support.
|
||||
///
|
||||
/// This is a focused search tool that returns lightweight results (IDs only).
|
||||
/// For detailed GameObject data, use the unity://scene/gameobject/{id} resource.
|
||||
/// </summary>
|
||||
[McpForUnityTool("find_gameobjects")]
|
||||
public static class FindGameObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles the find_gameobjects command.
|
||||
/// </summary>
|
||||
/// <param name="params">Command parameters</param>
|
||||
/// <returns>Paginated list of instance IDs</returns>
|
||||
public static object HandleCommand(JObject @params)
|
||||
{
|
||||
if (@params == null)
|
||||
{
|
||||
return new ErrorResponse("Parameters cannot be null.");
|
||||
}
|
||||
|
||||
var p = new ToolParams(@params);
|
||||
|
||||
// Parse search parameters
|
||||
string searchMethod = p.Get("searchMethod", "by_name");
|
||||
|
||||
// Try searchTerm, search_term, or target (for backwards compatibility)
|
||||
string searchTerm = p.Get("searchTerm");
|
||||
if (string.IsNullOrEmpty(searchTerm))
|
||||
{
|
||||
searchTerm = p.Get("target");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(searchTerm))
|
||||
{
|
||||
return new ErrorResponse("'searchTerm' or 'target' parameter is required.");
|
||||
}
|
||||
|
||||
// Pagination parameters using standard PaginationRequest
|
||||
var pagination = PaginationRequest.FromParams(@params, defaultPageSize: 50);
|
||||
pagination.PageSize = Mathf.Clamp(pagination.PageSize, 1, 500);
|
||||
|
||||
// Search options (supports multiple parameter name variants)
|
||||
bool includeInactive = p.GetBool("includeInactive", false) ||
|
||||
p.GetBool("searchInactive", false);
|
||||
|
||||
try
|
||||
{
|
||||
// Get all matching instance IDs
|
||||
var allIds = GameObjectLookup.SearchGameObjects(searchMethod, searchTerm, includeInactive, 0);
|
||||
|
||||
// Use standard pagination response
|
||||
var paginatedResult = PaginationResponse<int>.Create(allIds, pagination);
|
||||
|
||||
return new SuccessResponse("Found GameObjects", new
|
||||
{
|
||||
instanceIDs = paginatedResult.Items,
|
||||
pageSize = paginatedResult.PageSize,
|
||||
cursor = paginatedResult.Cursor,
|
||||
nextCursor = paginatedResult.NextCursor,
|
||||
totalCount = paginatedResult.TotalCount,
|
||||
hasMore = paginatedResult.HasMore
|
||||
});
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
McpLog.Error($"[FindGameObjects] Error searching GameObjects: {ex.Message}");
|
||||
return new ErrorResponse($"Error searching GameObjects: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4511082b395b14922b34e90f7a23027e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b61d0e8082ed14c1fb500648007bba7a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,142 @@
|
||||
#nullable disable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools
|
||||
{
|
||||
/// <summary>
|
||||
/// Component resolver that delegates to UnityTypeResolver.
|
||||
/// Kept for backwards compatibility.
|
||||
/// </summary>
|
||||
internal static class ComponentResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolve a Component/MonoBehaviour type by short or fully-qualified name.
|
||||
/// Delegates to UnityTypeResolver.TryResolve with Component constraint.
|
||||
/// </summary>
|
||||
public static bool TryResolve(string nameOrFullName, out Type type, out string error)
|
||||
{
|
||||
return UnityTypeResolver.TryResolve(nameOrFullName, out type, out error, typeof(Component));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all accessible property and field names from a component type.
|
||||
/// </summary>
|
||||
public static List<string> GetAllComponentProperties(Type componentType)
|
||||
{
|
||||
if (componentType == null) return new List<string>();
|
||||
|
||||
var properties = componentType.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.CanRead && p.CanWrite)
|
||||
.Select(p => p.Name);
|
||||
|
||||
var fields = componentType.GetFields(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(f => !f.IsInitOnly && !f.IsLiteral)
|
||||
.Select(f => f.Name);
|
||||
|
||||
// Also include SerializeField private fields (common in Unity)
|
||||
var serializeFields = componentType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
.Where(f => f.GetCustomAttribute<SerializeField>() != null)
|
||||
.Select(f => f.Name);
|
||||
|
||||
return properties.Concat(fields).Concat(serializeFields).Distinct().OrderBy(x => x).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Suggests the most likely property matches for a user's input using fuzzy matching.
|
||||
/// Uses Levenshtein distance, substring matching, and common naming pattern heuristics.
|
||||
/// </summary>
|
||||
public static List<string> GetFuzzyPropertySuggestions(string userInput, List<string> availableProperties)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userInput) || !availableProperties.Any())
|
||||
return new List<string>();
|
||||
|
||||
var cacheKey = $"{userInput.ToLowerInvariant()}:{string.Join(",", availableProperties)}";
|
||||
if (PropertySuggestionCache.TryGetValue(cacheKey, out var cached))
|
||||
return cached;
|
||||
|
||||
try
|
||||
{
|
||||
var suggestions = GetRuleBasedSuggestions(userInput, availableProperties);
|
||||
PropertySuggestionCache[cacheKey] = suggestions;
|
||||
return suggestions;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Warn($"[Property Matching] Error getting suggestions for '{userInput}': {ex.Message}");
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, List<string>> PropertySuggestionCache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Rule-based suggestions that mimic AI behavior for property matching.
|
||||
/// This provides immediate value while we could add real AI integration later.
|
||||
/// </summary>
|
||||
private static List<string> GetRuleBasedSuggestions(string userInput, List<string> availableProperties)
|
||||
{
|
||||
var suggestions = new List<string>();
|
||||
var cleanedInput = userInput.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", "");
|
||||
|
||||
foreach (var property in availableProperties)
|
||||
{
|
||||
var cleanedProperty = property.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", "");
|
||||
|
||||
if (cleanedProperty == cleanedInput)
|
||||
{
|
||||
suggestions.Add(property);
|
||||
continue;
|
||||
}
|
||||
|
||||
var inputWords = userInput.ToLowerInvariant().Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (inputWords.All(word => cleanedProperty.Contains(word.ToLowerInvariant())))
|
||||
{
|
||||
suggestions.Add(property);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (LevenshteinDistance(cleanedInput, cleanedProperty) <= Math.Max(2, cleanedInput.Length / 4))
|
||||
{
|
||||
suggestions.Add(property);
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions.OrderBy(s => LevenshteinDistance(cleanedInput, s.ToLowerInvariant().Replace(" ", "")))
|
||||
.Take(3)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates Levenshtein distance between two strings for similarity matching.
|
||||
/// </summary>
|
||||
private static int LevenshteinDistance(string s1, string s2)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s1)) return s2?.Length ?? 0;
|
||||
if (string.IsNullOrEmpty(s2)) return s1.Length;
|
||||
|
||||
var matrix = new int[s1.Length + 1, s2.Length + 1];
|
||||
|
||||
for (int i = 0; i <= s1.Length; i++) matrix[i, 0] = i;
|
||||
for (int j = 0; j <= s2.Length; j++) matrix[0, j] = j;
|
||||
|
||||
for (int i = 1; i <= s1.Length; i++)
|
||||
{
|
||||
for (int j = 1; j <= s2.Length; j++)
|
||||
{
|
||||
int cost = (s2[j - 1] == s1[i - 1]) ? 0 : 1;
|
||||
matrix[i, j] = Math.Min(Math.Min(
|
||||
matrix[i - 1, j] + 1,
|
||||
matrix[i, j - 1] + 1),
|
||||
matrix[i - 1, j - 1] + cost);
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[s1.Length, s2.Length];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f5e5a46bdebc040c68897fa4b5e689c7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,431 @@
|
||||
#nullable disable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Editor.Tools;
|
||||
using MCPForUnity.Runtime.Serialization;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.GameObjects
|
||||
{
|
||||
internal static class GameObjectComponentHelpers
|
||||
{
|
||||
internal static object AddComponentInternal(GameObject targetGo, string typeName, JObject properties)
|
||||
{
|
||||
Type componentType = FindType(typeName);
|
||||
if (componentType == null)
|
||||
{
|
||||
return new ErrorResponse($"Component type '{typeName}' not found or is not a valid Component.");
|
||||
}
|
||||
if (!typeof(Component).IsAssignableFrom(componentType))
|
||||
{
|
||||
return new ErrorResponse($"Type '{typeName}' is not a Component.");
|
||||
}
|
||||
|
||||
if (componentType == typeof(Transform))
|
||||
{
|
||||
return new ErrorResponse("Cannot add another Transform component.");
|
||||
}
|
||||
|
||||
bool isAdding2DPhysics = typeof(Rigidbody2D).IsAssignableFrom(componentType) || typeof(Collider2D).IsAssignableFrom(componentType);
|
||||
bool isAdding3DPhysics = typeof(Rigidbody).IsAssignableFrom(componentType) || typeof(Collider).IsAssignableFrom(componentType);
|
||||
|
||||
if (isAdding2DPhysics)
|
||||
{
|
||||
if (targetGo.GetComponent<Rigidbody>() != null || targetGo.GetComponent<Collider>() != null)
|
||||
{
|
||||
return new ErrorResponse($"Cannot add 2D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 3D Rigidbody or Collider.");
|
||||
}
|
||||
}
|
||||
else if (isAdding3DPhysics)
|
||||
{
|
||||
if (targetGo.GetComponent<Rigidbody2D>() != null || targetGo.GetComponent<Collider2D>() != null)
|
||||
{
|
||||
return new ErrorResponse($"Cannot add 3D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 2D Rigidbody or Collider.");
|
||||
}
|
||||
}
|
||||
|
||||
Component existingComponent = targetGo.GetComponent(componentType);
|
||||
if (existingComponent != null && !AllowsMultiple(componentType))
|
||||
{
|
||||
return new ErrorResponse(
|
||||
$"Component '{typeName}' already exists on '{targetGo.name}' and this type does not allow multiple instances."
|
||||
);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Component newComponent = Undo.AddComponent(targetGo, componentType);
|
||||
if (newComponent == null)
|
||||
{
|
||||
if (targetGo.GetComponent(componentType) != null && !AllowsMultiple(componentType))
|
||||
{
|
||||
return new ErrorResponse(
|
||||
$"Component '{typeName}' already exists on '{targetGo.name}' and this type does not allow multiple instances."
|
||||
);
|
||||
}
|
||||
|
||||
return new ErrorResponse(
|
||||
$"Failed to add component '{typeName}' to '{targetGo.name}'. Unity may restrict this component on the current target."
|
||||
);
|
||||
}
|
||||
|
||||
if (newComponent is Light light)
|
||||
{
|
||||
light.type = LightType.Directional;
|
||||
}
|
||||
|
||||
if (properties != null)
|
||||
{
|
||||
var setResult = SetComponentPropertiesInternal(targetGo, typeName, properties, newComponent);
|
||||
if (setResult != null)
|
||||
{
|
||||
Undo.DestroyObjectImmediate(newComponent);
|
||||
return setResult;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return new ErrorResponse($"Error adding component '{typeName}' to '{targetGo.name}': {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool AllowsMultiple(Type componentType)
|
||||
{
|
||||
if (componentType == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !Attribute.IsDefined(componentType, typeof(DisallowMultipleComponent), inherit: true);
|
||||
}
|
||||
|
||||
internal static object RemoveComponentInternal(GameObject targetGo, string typeName)
|
||||
{
|
||||
if (targetGo == null)
|
||||
{
|
||||
return new ErrorResponse("Target GameObject is null.");
|
||||
}
|
||||
|
||||
Type componentType = FindType(typeName);
|
||||
if (componentType == null)
|
||||
{
|
||||
return new ErrorResponse($"Component type '{typeName}' not found for removal.");
|
||||
}
|
||||
|
||||
if (componentType == typeof(Transform))
|
||||
{
|
||||
return new ErrorResponse("Cannot remove the Transform component.");
|
||||
}
|
||||
|
||||
Component componentToRemove = targetGo.GetComponent(componentType);
|
||||
if (componentToRemove == null)
|
||||
{
|
||||
return new ErrorResponse($"Component '{typeName}' not found on '{targetGo.name}' to remove.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Undo.DestroyObjectImmediate(componentToRemove);
|
||||
return null;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return new ErrorResponse($"Error removing component '{typeName}' from '{targetGo.name}': {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
internal static object SetComponentPropertiesInternal(GameObject targetGo, string componentTypeName, JObject properties, Component targetComponentInstance = null)
|
||||
{
|
||||
Component targetComponent = targetComponentInstance;
|
||||
if (targetComponent == null)
|
||||
{
|
||||
if (ComponentResolver.TryResolve(componentTypeName, out var compType, out var compError))
|
||||
{
|
||||
targetComponent = targetGo.GetComponent(compType);
|
||||
}
|
||||
else
|
||||
{
|
||||
targetComponent = targetGo.GetComponent(componentTypeName);
|
||||
}
|
||||
}
|
||||
if (targetComponent == null)
|
||||
{
|
||||
return new ErrorResponse($"Component '{componentTypeName}' not found on '{targetGo.name}' to set properties.");
|
||||
}
|
||||
|
||||
Undo.RecordObject(targetComponent, "Set Component Properties");
|
||||
|
||||
var failures = new List<string>();
|
||||
foreach (var prop in properties.Properties())
|
||||
{
|
||||
string propName = prop.Name;
|
||||
JToken propValue = prop.Value;
|
||||
|
||||
try
|
||||
{
|
||||
bool setResult;
|
||||
string setError;
|
||||
|
||||
// Nested paths (e.g. "transform.position") need local handling
|
||||
// since ComponentOps doesn't support dot/bracket notation.
|
||||
if (propName.Contains('.') || propName.Contains('['))
|
||||
{
|
||||
setResult = SetNestedProperty(targetComponent, propName, propValue, InputSerializer, out setError);
|
||||
}
|
||||
else
|
||||
{
|
||||
// ComponentOps handles reflection + SerializedProperty fallback
|
||||
setResult = ComponentOps.SetProperty(targetComponent, propName, propValue, out setError);
|
||||
}
|
||||
|
||||
if (!setResult)
|
||||
{
|
||||
string msg = setError;
|
||||
if (msg == null || msg.Contains("not found"))
|
||||
{
|
||||
var availableProperties = ComponentResolver.GetAllComponentProperties(targetComponent.GetType());
|
||||
var suggestions = ComponentResolver.GetFuzzyPropertySuggestions(propName, availableProperties);
|
||||
msg = suggestions.Any()
|
||||
? $"Property '{propName}' not found. Did you mean: {string.Join(", ", suggestions)}? Available: [{string.Join(", ", availableProperties)}]"
|
||||
: $"Property '{propName}' not found. Available: [{string.Join(", ", availableProperties)}]";
|
||||
}
|
||||
McpLog.Warn($"[ManageGameObject] {msg}");
|
||||
failures.Add(msg);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
McpLog.Error($"[ManageGameObject] Error setting property '{propName}' on '{componentTypeName}': {e.Message}");
|
||||
failures.Add($"Error setting '{propName}': {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
EditorUtility.SetDirty(targetComponent);
|
||||
return failures.Count == 0
|
||||
? null
|
||||
: new ErrorResponse($"One or more properties failed on '{componentTypeName}'.", new { errors = failures });
|
||||
}
|
||||
|
||||
private static JsonSerializer InputSerializer => UnityJsonSerializer.Instance;
|
||||
|
||||
private static bool SetNestedProperty(object target, string path, JToken value, JsonSerializer inputSerializer, out string error)
|
||||
{
|
||||
error = null;
|
||||
try
|
||||
{
|
||||
string[] pathParts = SplitPropertyPath(path);
|
||||
if (pathParts.Length == 0)
|
||||
{
|
||||
error = $"Invalid nested property path '{path}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
object currentObject = target;
|
||||
Type currentType = currentObject.GetType();
|
||||
BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase;
|
||||
|
||||
for (int i = 0; i < pathParts.Length - 1; i++)
|
||||
{
|
||||
string part = pathParts[i];
|
||||
bool isArray = false;
|
||||
int arrayIndex = -1;
|
||||
|
||||
if (part.Contains("["))
|
||||
{
|
||||
int startBracket = part.IndexOf('[');
|
||||
int endBracket = part.IndexOf(']');
|
||||
if (startBracket > 0 && endBracket > startBracket)
|
||||
{
|
||||
string indexStr = part.Substring(startBracket + 1, endBracket - startBracket - 1);
|
||||
if (int.TryParse(indexStr, out arrayIndex))
|
||||
{
|
||||
isArray = true;
|
||||
part = part.Substring(0, startBracket);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PropertyInfo propInfo = currentType.GetProperty(part, flags);
|
||||
FieldInfo fieldInfo = null;
|
||||
if (propInfo == null)
|
||||
{
|
||||
fieldInfo = currentType.GetField(part, flags);
|
||||
if (fieldInfo == null)
|
||||
{
|
||||
error = $"Could not find property or field '{part}' on type '{currentType.Name}' in path '{path}'.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
currentObject = propInfo != null ? propInfo.GetValue(currentObject) : fieldInfo.GetValue(currentObject);
|
||||
if (currentObject == null)
|
||||
{
|
||||
error = $"Property '{part}' is null in path '{path}', cannot access nested properties.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isArray)
|
||||
{
|
||||
if (currentObject is Material[])
|
||||
{
|
||||
var materials = currentObject as Material[];
|
||||
if (materials.Length == 0)
|
||||
{
|
||||
error = $"Material array is empty in path '{path}', cannot access index {arrayIndex}.";
|
||||
return false;
|
||||
}
|
||||
if (arrayIndex < 0 || arrayIndex >= materials.Length)
|
||||
{
|
||||
error = $"Material index {arrayIndex} out of range (0-{materials.Length - 1}) in path '{path}'.";
|
||||
return false;
|
||||
}
|
||||
currentObject = materials[arrayIndex];
|
||||
}
|
||||
else if (currentObject is System.Collections.IList)
|
||||
{
|
||||
var list = currentObject as System.Collections.IList;
|
||||
if (list.Count == 0)
|
||||
{
|
||||
error = $"List is empty in path '{path}', cannot access index {arrayIndex}.";
|
||||
return false;
|
||||
}
|
||||
if (arrayIndex < 0 || arrayIndex >= list.Count)
|
||||
{
|
||||
error = $"Index {arrayIndex} out of range (0-{list.Count - 1}) in path '{path}'.";
|
||||
return false;
|
||||
}
|
||||
currentObject = list[arrayIndex];
|
||||
}
|
||||
else
|
||||
{
|
||||
error = $"Property '{part}' is not an array or list in path '{path}', cannot access by index.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
currentType = currentObject.GetType();
|
||||
}
|
||||
|
||||
string finalPart = pathParts[pathParts.Length - 1];
|
||||
|
||||
if (currentObject is Material material && finalPart.StartsWith("_"))
|
||||
{
|
||||
if (!MaterialOps.TrySetShaderProperty(material, finalPart, value, inputSerializer))
|
||||
{
|
||||
error = $"Failed to set shader property '{finalPart}' on material '{material.name}' in path '{path}'.";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
PropertyInfo finalPropInfo = currentType.GetProperty(finalPart, flags);
|
||||
if (finalPropInfo != null && finalPropInfo.CanWrite)
|
||||
{
|
||||
object convertedValue = ConvertJTokenToType(value, finalPropInfo.PropertyType, inputSerializer);
|
||||
if (convertedValue != null || value.Type == JTokenType.Null)
|
||||
{
|
||||
finalPropInfo.SetValue(currentObject, convertedValue);
|
||||
return true;
|
||||
}
|
||||
error = $"Failed to convert value for '{finalPart}' to type '{finalPropInfo.PropertyType.Name}' in path '{path}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
FieldInfo finalFieldInfo = currentType.GetField(finalPart, flags);
|
||||
if (finalFieldInfo != null)
|
||||
{
|
||||
object convertedValue = ConvertJTokenToType(value, finalFieldInfo.FieldType, inputSerializer);
|
||||
if (convertedValue != null || value.Type == JTokenType.Null)
|
||||
{
|
||||
finalFieldInfo.SetValue(currentObject, convertedValue);
|
||||
return true;
|
||||
}
|
||||
error = $"Failed to convert value for '{finalPart}' to type '{finalFieldInfo.FieldType.Name}' in path '{path}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try non-public [SerializeField] fields (nested paths need this too)
|
||||
FieldInfo serializedField = ComponentOps.FindSerializedFieldInHierarchy(currentType, finalPart);
|
||||
if (serializedField != null)
|
||||
{
|
||||
object convertedValue = ConvertJTokenToType(value, serializedField.FieldType, inputSerializer);
|
||||
if (convertedValue != null || value.Type == JTokenType.Null)
|
||||
{
|
||||
serializedField.SetValue(currentObject, convertedValue);
|
||||
return true;
|
||||
}
|
||||
error = $"Failed to convert value for '{finalPart}' to type '{serializedField.FieldType.Name}' in path '{path}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
error = $"Property or field '{finalPart}' not found on type '{currentType.Name}' in path '{path}'.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Error setting nested property '{path}': {ex.Message}";
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string[] SplitPropertyPath(string path)
|
||||
{
|
||||
List<string> parts = new List<string>();
|
||||
int startIndex = 0;
|
||||
bool inBrackets = false;
|
||||
|
||||
for (int i = 0; i < path.Length; i++)
|
||||
{
|
||||
char c = path[i];
|
||||
|
||||
if (c == '[')
|
||||
{
|
||||
inBrackets = true;
|
||||
}
|
||||
else if (c == ']')
|
||||
{
|
||||
inBrackets = false;
|
||||
}
|
||||
else if (c == '.' && !inBrackets)
|
||||
{
|
||||
parts.Add(path.Substring(startIndex, i - startIndex));
|
||||
startIndex = i + 1;
|
||||
}
|
||||
}
|
||||
if (startIndex < path.Length)
|
||||
{
|
||||
parts.Add(path.Substring(startIndex));
|
||||
}
|
||||
return parts.ToArray();
|
||||
}
|
||||
|
||||
private static object ConvertJTokenToType(JToken token, Type targetType, JsonSerializer inputSerializer)
|
||||
{
|
||||
return PropertyConversion.ConvertToType(token, targetType);
|
||||
}
|
||||
|
||||
private static Type FindType(string typeName)
|
||||
{
|
||||
if (ComponentResolver.TryResolve(typeName, out Type resolvedType, out string error))
|
||||
{
|
||||
return resolvedType;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
McpLog.Warn($"[FindType] {error}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b580af06e2d3a4788960f3f779edac54
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,338 @@
|
||||
#nullable disable
|
||||
using System;
|
||||
using System.Linq;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEditorInternal;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.GameObjects
|
||||
{
|
||||
internal static class GameObjectCreate
|
||||
{
|
||||
internal static object Handle(JObject @params)
|
||||
{
|
||||
string name = @params["name"]?.ToString();
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
return new ErrorResponse("'name' parameter is required for 'create' action.");
|
||||
}
|
||||
|
||||
// Get prefab creation parameters
|
||||
bool saveAsPrefab = @params["saveAsPrefab"]?.ToObject<bool>() ?? false;
|
||||
string prefabPath = @params["prefabPath"]?.ToString();
|
||||
string tag = @params["tag"]?.ToString();
|
||||
string primitiveType = @params["primitiveType"]?.ToString();
|
||||
GameObject newGo = null;
|
||||
|
||||
// --- Try Instantiating Prefab First ---
|
||||
string originalPrefabPath = prefabPath;
|
||||
if (!saveAsPrefab && !string.IsNullOrEmpty(prefabPath))
|
||||
{
|
||||
string extension = System.IO.Path.GetExtension(prefabPath);
|
||||
|
||||
if (!prefabPath.Contains("/") && (string.IsNullOrEmpty(extension) || extension.Equals(".prefab", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
string prefabNameOnly = prefabPath;
|
||||
McpLog.Info($"[ManageGameObject.Create] Searching for prefab named: '{prefabNameOnly}'");
|
||||
string[] guids = AssetDatabase.FindAssets($"t:Prefab {prefabNameOnly}");
|
||||
if (guids.Length == 0)
|
||||
{
|
||||
return new ErrorResponse($"Prefab named '{prefabNameOnly}' not found anywhere in the project.");
|
||||
}
|
||||
else if (guids.Length > 1)
|
||||
{
|
||||
string foundPaths = string.Join(", ", guids.Select(g => AssetDatabase.GUIDToAssetPath(g)));
|
||||
return new ErrorResponse($"Multiple prefabs found matching name '{prefabNameOnly}': {foundPaths}. Please provide a more specific path.");
|
||||
}
|
||||
else
|
||||
{
|
||||
prefabPath = AssetDatabase.GUIDToAssetPath(guids[0]);
|
||||
McpLog.Info($"[ManageGameObject.Create] Found unique prefab at path: '{prefabPath}'");
|
||||
}
|
||||
}
|
||||
else if (prefabPath.Contains("/") && string.IsNullOrEmpty(extension))
|
||||
{
|
||||
McpLog.Warn($"[ManageGameObject.Create] Provided prefabPath '{prefabPath}' has no extension. Assuming it's a prefab and appending .prefab.");
|
||||
prefabPath += ".prefab";
|
||||
}
|
||||
else if (!prefabPath.Contains("/") && !string.IsNullOrEmpty(extension) && !extension.Equals(".prefab", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string fileName = prefabPath;
|
||||
string fileNameWithoutExtension = System.IO.Path.GetFileNameWithoutExtension(fileName);
|
||||
McpLog.Info($"[ManageGameObject.Create] Searching for asset file named: '{fileName}'");
|
||||
|
||||
string[] guids = AssetDatabase.FindAssets(fileNameWithoutExtension);
|
||||
var matches = guids
|
||||
.Select(g => AssetDatabase.GUIDToAssetPath(g))
|
||||
.Where(p => p.EndsWith("/" + fileName, StringComparison.OrdinalIgnoreCase) || p.Equals(fileName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
if (matches.Length == 0)
|
||||
{
|
||||
return new ErrorResponse($"Asset file '{fileName}' not found anywhere in the project.");
|
||||
}
|
||||
else if (matches.Length > 1)
|
||||
{
|
||||
string foundPaths = string.Join(", ", matches);
|
||||
return new ErrorResponse($"Multiple assets found matching file name '{fileName}': {foundPaths}. Please provide a more specific path.");
|
||||
}
|
||||
else
|
||||
{
|
||||
prefabPath = matches[0];
|
||||
McpLog.Info($"[ManageGameObject.Create] Found unique asset at path: '{prefabPath}'");
|
||||
}
|
||||
}
|
||||
|
||||
GameObject prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
|
||||
if (prefabAsset != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
newGo = PrefabUtility.InstantiatePrefab(prefabAsset) as GameObject;
|
||||
|
||||
if (newGo == null)
|
||||
{
|
||||
McpLog.Error($"[ManageGameObject.Create] Failed to instantiate prefab at '{prefabPath}', asset might be corrupted or not a GameObject.");
|
||||
return new ErrorResponse($"Failed to instantiate prefab at '{prefabPath}'.");
|
||||
}
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
newGo.name = name;
|
||||
}
|
||||
Undo.RegisterCreatedObjectUndo(newGo, $"Instantiate Prefab '{prefabAsset.name}' as '{newGo.name}'");
|
||||
McpLog.Info($"[ManageGameObject.Create] Instantiated prefab '{prefabAsset.name}' from path '{prefabPath}' as '{newGo.name}'.");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return new ErrorResponse($"Error instantiating prefab '{prefabPath}': {e.Message}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return new ErrorResponse($"Asset not found or not a GameObject at path: '{prefabPath}'.");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fallback: Create Primitive or Empty GameObject ---
|
||||
bool createdNewObject = false;
|
||||
if (newGo == null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(primitiveType))
|
||||
{
|
||||
try
|
||||
{
|
||||
PrimitiveType type = (PrimitiveType)Enum.Parse(typeof(PrimitiveType), primitiveType, true);
|
||||
newGo = GameObject.CreatePrimitive(type);
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
newGo.name = name;
|
||||
}
|
||||
else
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(newGo);
|
||||
return new ErrorResponse("'name' parameter is required when creating a primitive.");
|
||||
}
|
||||
createdNewObject = true;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return new ErrorResponse($"Invalid primitive type: '{primitiveType}'. Valid types: {string.Join(", ", Enum.GetNames(typeof(PrimitiveType)))}");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return new ErrorResponse($"Failed to create primitive '{primitiveType}': {e.Message}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
return new ErrorResponse("'name' parameter is required for 'create' action when not instantiating a prefab or creating a primitive.");
|
||||
}
|
||||
newGo = new GameObject(name);
|
||||
createdNewObject = true;
|
||||
}
|
||||
|
||||
if (createdNewObject)
|
||||
{
|
||||
Undo.RegisterCreatedObjectUndo(newGo, $"Create GameObject '{newGo.name}'");
|
||||
}
|
||||
}
|
||||
|
||||
if (newGo == null)
|
||||
{
|
||||
return new ErrorResponse("Failed to create or instantiate the GameObject.");
|
||||
}
|
||||
|
||||
Undo.RecordObject(newGo.transform, "Set GameObject Transform");
|
||||
Undo.RecordObject(newGo, "Set GameObject Properties");
|
||||
|
||||
// Set Parent
|
||||
JToken parentToken = @params["parent"];
|
||||
if (parentToken != null)
|
||||
{
|
||||
GameObject parentGo = ManageGameObjectCommon.FindObjectInternal(parentToken, "by_id_or_name_or_path");
|
||||
if (parentGo == null)
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(newGo);
|
||||
return new ErrorResponse($"Parent specified ('{parentToken}') but not found.");
|
||||
}
|
||||
newGo.transform.SetParent(parentGo.transform, true);
|
||||
}
|
||||
|
||||
// Set Transform
|
||||
Vector3? position = VectorParsing.ParseVector3(@params["position"]);
|
||||
Vector3? rotation = VectorParsing.ParseVector3(@params["rotation"]);
|
||||
Vector3? scale = VectorParsing.ParseVector3(@params["scale"]);
|
||||
|
||||
if (position.HasValue) newGo.transform.localPosition = position.Value;
|
||||
if (rotation.HasValue) newGo.transform.localEulerAngles = rotation.Value;
|
||||
if (scale.HasValue) newGo.transform.localScale = scale.Value;
|
||||
|
||||
// Set Tag
|
||||
if (!string.IsNullOrEmpty(tag))
|
||||
{
|
||||
if (tag != "Untagged" && !System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tag))
|
||||
{
|
||||
McpLog.Info($"[ManageGameObject.Create] Tag '{tag}' not found. Creating it.");
|
||||
try
|
||||
{
|
||||
InternalEditorUtility.AddTag(tag);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(newGo);
|
||||
return new ErrorResponse($"Failed to create tag '{tag}': {ex.Message}.");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
newGo.tag = tag;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(newGo);
|
||||
return new ErrorResponse($"Failed to set tag to '{tag}' during creation: {ex.Message}.");
|
||||
}
|
||||
}
|
||||
|
||||
// Set Layer
|
||||
string layerName = @params["layer"]?.ToString();
|
||||
if (!string.IsNullOrEmpty(layerName))
|
||||
{
|
||||
int layerId = LayerMask.NameToLayer(layerName);
|
||||
if (layerId != -1)
|
||||
{
|
||||
newGo.layer = layerId;
|
||||
}
|
||||
else
|
||||
{
|
||||
McpLog.Warn($"[ManageGameObject.Create] Layer '{layerName}' not found. Using default layer.");
|
||||
}
|
||||
}
|
||||
|
||||
// Add Components
|
||||
if (@params["componentsToAdd"] is JArray componentsToAddArray)
|
||||
{
|
||||
foreach (var compToken in componentsToAddArray)
|
||||
{
|
||||
string typeName = null;
|
||||
JObject properties = null;
|
||||
|
||||
if (compToken.Type == JTokenType.String)
|
||||
{
|
||||
typeName = compToken.ToString();
|
||||
}
|
||||
else if (compToken is JObject compObj)
|
||||
{
|
||||
typeName = compObj["typeName"]?.ToString();
|
||||
properties = compObj["properties"] as JObject;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(typeName))
|
||||
{
|
||||
var addResult = GameObjectComponentHelpers.AddComponentInternal(newGo, typeName, properties);
|
||||
if (addResult != null)
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(newGo);
|
||||
return addResult;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
McpLog.Warn($"[ManageGameObject] Invalid component format in componentsToAdd: {compToken}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save as Prefab ONLY if we *created* a new object AND saveAsPrefab is true
|
||||
GameObject finalInstance = newGo;
|
||||
if (createdNewObject && saveAsPrefab)
|
||||
{
|
||||
string finalPrefabPath = prefabPath;
|
||||
if (string.IsNullOrEmpty(finalPrefabPath))
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(newGo);
|
||||
return new ErrorResponse("'prefabPath' is required when 'saveAsPrefab' is true and creating a new object.");
|
||||
}
|
||||
if (!finalPrefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
McpLog.Info($"[ManageGameObject.Create] Appending .prefab extension to save path: '{finalPrefabPath}' -> '{finalPrefabPath}.prefab'");
|
||||
finalPrefabPath += ".prefab";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string directoryPath = System.IO.Path.GetDirectoryName(finalPrefabPath);
|
||||
if (!string.IsNullOrEmpty(directoryPath) && !System.IO.Directory.Exists(directoryPath))
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(directoryPath);
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
McpLog.Info($"[ManageGameObject.Create] Created directory for prefab: {directoryPath}");
|
||||
}
|
||||
|
||||
finalInstance = PrefabUtility.SaveAsPrefabAssetAndConnect(newGo, finalPrefabPath, InteractionMode.UserAction);
|
||||
|
||||
if (finalInstance == null)
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(newGo);
|
||||
return new ErrorResponse($"Failed to save GameObject '{name}' as prefab at '{finalPrefabPath}'. Check path and permissions.");
|
||||
}
|
||||
McpLog.Info($"[ManageGameObject.Create] GameObject '{name}' saved as prefab to '{finalPrefabPath}' and instance connected.");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(newGo);
|
||||
return new ErrorResponse($"Error saving prefab '{finalPrefabPath}': {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
Selection.activeGameObject = finalInstance;
|
||||
|
||||
string messagePrefabPath =
|
||||
finalInstance == null
|
||||
? originalPrefabPath
|
||||
: AssetDatabase.GetAssetPath(PrefabUtility.GetCorrespondingObjectFromSource(finalInstance) ?? (UnityEngine.Object)finalInstance);
|
||||
|
||||
string successMessage;
|
||||
if (!createdNewObject && !string.IsNullOrEmpty(messagePrefabPath))
|
||||
{
|
||||
successMessage = $"Prefab '{messagePrefabPath}' instantiated successfully as '{finalInstance.name}'.";
|
||||
}
|
||||
else if (createdNewObject && saveAsPrefab && !string.IsNullOrEmpty(messagePrefabPath))
|
||||
{
|
||||
successMessage = $"GameObject '{finalInstance.name}' created and saved as prefab to '{messagePrefabPath}'.";
|
||||
}
|
||||
else
|
||||
{
|
||||
successMessage = $"GameObject '{finalInstance.name}' created successfully in scene.";
|
||||
}
|
||||
|
||||
return new SuccessResponse(successMessage, Helpers.GameObjectSerializer.GetGameObjectData(finalInstance));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0931774a07e4b4626b4261dd8d0974c2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,49 @@
|
||||
#nullable disable
|
||||
using System.Collections.Generic;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using MCPForUnity.Runtime.Helpers;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.GameObjects
|
||||
{
|
||||
internal static class GameObjectDelete
|
||||
{
|
||||
internal static object Handle(JToken targetToken, string searchMethod)
|
||||
{
|
||||
List<GameObject> targets = ManageGameObjectCommon.FindObjectsInternal(targetToken, searchMethod, true);
|
||||
|
||||
if (targets.Count == 0)
|
||||
{
|
||||
return new ErrorResponse($"Target GameObject(s) ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
|
||||
}
|
||||
|
||||
List<object> deletedObjects = new List<object>();
|
||||
foreach (var targetGo in targets)
|
||||
{
|
||||
if (targetGo != null)
|
||||
{
|
||||
string goName = targetGo.name;
|
||||
int goId = targetGo.GetInstanceIDCompat();
|
||||
// Note: Undo.DestroyObjectImmediate doesn't work reliably in test context,
|
||||
// so we use Object.DestroyImmediate. This means delete isn't undoable.
|
||||
// TODO: Investigate Undo.DestroyObjectImmediate behavior in Unity 2022+
|
||||
Object.DestroyImmediate(targetGo);
|
||||
deletedObjects.Add(new { name = goName, instanceID = goId });
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedObjects.Count > 0)
|
||||
{
|
||||
string message =
|
||||
targets.Count == 1
|
||||
? $"GameObject '{((dynamic)deletedObjects[0]).name}' deleted successfully."
|
||||
: $"{deletedObjects.Count} GameObjects deleted successfully.";
|
||||
return new SuccessResponse(message, deletedObjects);
|
||||
}
|
||||
|
||||
return new ErrorResponse("Failed to delete target GameObject(s).");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 505a482aaf60b415abd794737a630b10
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,87 @@
|
||||
#nullable disable
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
using MCPForUnity.Runtime.Helpers;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.GameObjects
|
||||
{
|
||||
internal static class GameObjectDuplicate
|
||||
{
|
||||
internal static object Handle(JObject @params, JToken targetToken, string searchMethod)
|
||||
{
|
||||
GameObject sourceGo = ManageGameObjectCommon.FindObjectInternal(targetToken, searchMethod);
|
||||
if (sourceGo == null)
|
||||
{
|
||||
return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
|
||||
}
|
||||
|
||||
string newName = @params["new_name"]?.ToString();
|
||||
Vector3? position = VectorParsing.ParseVector3(@params["position"]);
|
||||
Vector3? offset = VectorParsing.ParseVector3(@params["offset"]);
|
||||
JToken parentToken = @params["parent"];
|
||||
|
||||
GameObject duplicatedGo = UnityEngine.Object.Instantiate(sourceGo);
|
||||
Undo.RegisterCreatedObjectUndo(duplicatedGo, $"Duplicate {sourceGo.name}");
|
||||
|
||||
if (!string.IsNullOrEmpty(newName))
|
||||
{
|
||||
duplicatedGo.name = newName;
|
||||
}
|
||||
else
|
||||
{
|
||||
duplicatedGo.name = sourceGo.name.Replace("(Clone)", "").Trim() + "_Copy";
|
||||
}
|
||||
|
||||
if (position.HasValue)
|
||||
{
|
||||
duplicatedGo.transform.position = position.Value;
|
||||
}
|
||||
else if (offset.HasValue)
|
||||
{
|
||||
duplicatedGo.transform.position = sourceGo.transform.position + offset.Value;
|
||||
}
|
||||
|
||||
if (parentToken != null)
|
||||
{
|
||||
if (parentToken.Type == JTokenType.Null || (parentToken.Type == JTokenType.String && string.IsNullOrEmpty(parentToken.ToString())))
|
||||
{
|
||||
duplicatedGo.transform.SetParent(null);
|
||||
}
|
||||
else
|
||||
{
|
||||
GameObject newParent = ManageGameObjectCommon.FindObjectInternal(parentToken, "by_id_or_name_or_path");
|
||||
if (newParent != null)
|
||||
{
|
||||
duplicatedGo.transform.SetParent(newParent.transform, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
McpLog.Warn($"[ManageGameObject.Duplicate] Parent '{parentToken}' not found. Object will remain at root level.");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
duplicatedGo.transform.SetParent(sourceGo.transform.parent, true);
|
||||
}
|
||||
|
||||
EditorUtility.SetDirty(duplicatedGo);
|
||||
EditorSceneManager.MarkSceneDirty(EditorSceneManager.GetActiveScene());
|
||||
|
||||
Selection.activeGameObject = duplicatedGo;
|
||||
|
||||
return new SuccessResponse(
|
||||
$"Duplicated '{sourceGo.name}' as '{duplicatedGo.name}'.",
|
||||
new
|
||||
{
|
||||
originalName = sourceGo.name,
|
||||
originalId = sourceGo.GetInstanceIDCompat(),
|
||||
duplicatedObject = Helpers.GameObjectSerializer.GetGameObjectData(duplicatedGo)
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 698728d56425a47af92a45377031a48b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,22 @@
|
||||
#nullable disable
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.GameObjects
|
||||
{
|
||||
internal static class GameObjectHandlers
|
||||
{
|
||||
internal static object Create(JObject @params) => GameObjectCreate.Handle(@params);
|
||||
|
||||
internal static object Modify(JObject @params, JToken targetToken, string searchMethod)
|
||||
=> GameObjectModify.Handle(@params, targetToken, searchMethod);
|
||||
|
||||
internal static object Delete(JToken targetToken, string searchMethod)
|
||||
=> GameObjectDelete.Handle(targetToken, searchMethod);
|
||||
|
||||
internal static object Duplicate(JObject @params, JToken targetToken, string searchMethod)
|
||||
=> GameObjectDuplicate.Handle(@params, targetToken, searchMethod);
|
||||
|
||||
internal static object MoveRelative(JObject @params, JToken targetToken, string searchMethod)
|
||||
=> GameObjectMoveRelative.Handle(@params, targetToken, searchMethod);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f3cf2313460d44a09b258d2ee04c5ef0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,64 @@
|
||||
#nullable disable
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using MCPForUnity.Runtime.Helpers;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.GameObjects
|
||||
{
|
||||
internal static class GameObjectLookAt
|
||||
{
|
||||
/// <summary>
|
||||
/// Rotates a GameObject to face a world position or another GameObject.
|
||||
/// Parameters:
|
||||
/// target - The GO to rotate (name/path/instanceID)
|
||||
/// look_at_target - World position [x,y,z] or GO reference (name/path/instanceID) to look at
|
||||
/// look_at_up - Optional up vector [x,y,z], defaults to Vector3.up
|
||||
/// </summary>
|
||||
internal static object Handle(JObject @params, JToken targetToken, string searchMethod)
|
||||
{
|
||||
GameObject targetGo = ManageGameObjectCommon.FindObjectInternal(targetToken, searchMethod);
|
||||
if (targetGo == null)
|
||||
{
|
||||
return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
|
||||
}
|
||||
|
||||
JToken lookAtToken = @params["look_at_target"] ?? @params["lookAtTarget"];
|
||||
if (lookAtToken == null)
|
||||
{
|
||||
return new ErrorResponse("'look_at_target' parameter is required for 'look_at' action. Provide a world position [x,y,z] or a GameObject name/path/ID.");
|
||||
}
|
||||
|
||||
// Try parsing as a position vector first
|
||||
Vector3? lookAtPos = VectorParsing.ParseVector3(lookAtToken);
|
||||
if (!lookAtPos.HasValue)
|
||||
{
|
||||
// Not a vector — treat as a GO reference, using the same search method as for the main target
|
||||
GameObject lookAtGo = ManageGameObjectCommon.FindObjectInternal(lookAtToken, searchMethod);
|
||||
if (lookAtGo == null)
|
||||
{
|
||||
return new ErrorResponse($"look_at_target '{lookAtToken}' could not be resolved as a position [x,y,z] or found as a GameObject.");
|
||||
}
|
||||
lookAtPos = lookAtGo.transform.position;
|
||||
}
|
||||
|
||||
Vector3 upVector = VectorParsing.ParseVector3OrDefault(@params["look_at_up"] ?? @params["lookAtUp"], Vector3.up);
|
||||
|
||||
Undo.RecordObject(targetGo.transform, $"LookAt {targetGo.name}");
|
||||
targetGo.transform.LookAt(lookAtPos.Value, upVector);
|
||||
|
||||
var euler = targetGo.transform.rotation.eulerAngles;
|
||||
return new SuccessResponse(
|
||||
$"'{targetGo.name}' now looking at ({lookAtPos.Value.x:F2}, {lookAtPos.Value.y:F2}, {lookAtPos.Value.z:F2}).",
|
||||
new
|
||||
{
|
||||
name = targetGo.name,
|
||||
instanceID = targetGo.GetInstanceIDCompat(),
|
||||
rotation = new[] { euler.x, euler.y, euler.z },
|
||||
lookAtPosition = new[] { lookAtPos.Value.x, lookAtPos.Value.y, lookAtPos.Value.z },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fede847680da4b11a1c9e01d98ffbf16
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,310 @@
|
||||
#nullable disable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEditorInternal;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.GameObjects
|
||||
{
|
||||
internal static class GameObjectModify
|
||||
{
|
||||
internal static object Handle(JObject @params, JToken targetToken, string searchMethod)
|
||||
{
|
||||
// When setActive=true is specified, we need to search for inactive objects
|
||||
// otherwise we can't find an inactive object to activate it
|
||||
JObject findParams = null;
|
||||
if (@params["setActive"]?.ToObject<bool?>() == true)
|
||||
{
|
||||
findParams = new JObject { ["searchInactive"] = true };
|
||||
}
|
||||
|
||||
GameObject targetGo = ManageGameObjectCommon.FindObjectInternal(targetToken, searchMethod, findParams);
|
||||
if (targetGo == null)
|
||||
{
|
||||
return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
|
||||
}
|
||||
|
||||
Undo.RecordObject(targetGo.transform, "Modify GameObject Transform");
|
||||
Undo.RecordObject(targetGo, "Modify GameObject Properties");
|
||||
|
||||
bool modified = false;
|
||||
|
||||
string name = @params["name"]?.ToString() ?? @params["new_name"]?.ToString() ?? @params["newName"]?.ToString();
|
||||
if (!string.IsNullOrEmpty(name) && targetGo.name != name)
|
||||
{
|
||||
// Check if we're renaming the root object of an open prefab stage
|
||||
var prefabStageForRename = PrefabStageUtility.GetCurrentPrefabStage();
|
||||
bool isRenamingPrefabRoot = prefabStageForRename != null &&
|
||||
prefabStageForRename.prefabContentsRoot == targetGo;
|
||||
|
||||
if (isRenamingPrefabRoot)
|
||||
{
|
||||
// Rename the prefab asset file to match the new name (avoids Unity dialog)
|
||||
string assetPath = prefabStageForRename.assetPath;
|
||||
string directory = System.IO.Path.GetDirectoryName(assetPath);
|
||||
string newAssetPath = AssetPathUtility.NormalizeSeparators(System.IO.Path.Combine(directory, name + ".prefab"));
|
||||
|
||||
// Only rename if the path actually changes
|
||||
if (newAssetPath != assetPath)
|
||||
{
|
||||
// Check for collision using GUID comparison
|
||||
string currentGuid = AssetDatabase.AssetPathToGUID(assetPath);
|
||||
string existingGuid = AssetDatabase.AssetPathToGUID(newAssetPath);
|
||||
|
||||
// Collision only if there's a different asset at the new path
|
||||
if (!string.IsNullOrEmpty(existingGuid) && existingGuid != currentGuid)
|
||||
{
|
||||
return new ErrorResponse($"Cannot rename prefab root to '{name}': a prefab already exists at '{newAssetPath}'.");
|
||||
}
|
||||
|
||||
// Rename the asset file
|
||||
string renameError = AssetDatabase.RenameAsset(assetPath, name);
|
||||
if (!string.IsNullOrEmpty(renameError))
|
||||
{
|
||||
return new ErrorResponse($"Failed to rename prefab asset: {renameError}");
|
||||
}
|
||||
|
||||
McpLog.Info($"[GameObjectModify] Renamed prefab asset from '{assetPath}' to '{newAssetPath}'");
|
||||
}
|
||||
}
|
||||
|
||||
targetGo.name = name;
|
||||
modified = true;
|
||||
}
|
||||
|
||||
JToken parentToken = @params["parent"];
|
||||
if (parentToken != null)
|
||||
{
|
||||
GameObject newParentGo = ManageGameObjectCommon.FindObjectInternal(parentToken, "by_id_or_name_or_path");
|
||||
if (
|
||||
newParentGo == null
|
||||
&& !(parentToken.Type == JTokenType.Null
|
||||
|| (parentToken.Type == JTokenType.String && string.IsNullOrEmpty(parentToken.ToString())))
|
||||
)
|
||||
{
|
||||
return new ErrorResponse($"New parent ('{parentToken}') not found.");
|
||||
}
|
||||
if (newParentGo != null && newParentGo.transform.IsChildOf(targetGo.transform))
|
||||
{
|
||||
return new ErrorResponse($"Cannot parent '{targetGo.name}' to '{newParentGo.name}', as it would create a hierarchy loop.");
|
||||
}
|
||||
if (targetGo.transform.parent != (newParentGo?.transform))
|
||||
{
|
||||
targetGo.transform.SetParent(newParentGo?.transform, true);
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
bool? setActive = @params["setActive"]?.ToObject<bool?>();
|
||||
if (setActive.HasValue && targetGo.activeSelf != setActive.Value)
|
||||
{
|
||||
targetGo.SetActive(setActive.Value);
|
||||
modified = true;
|
||||
}
|
||||
|
||||
string tag = @params["tag"]?.ToString();
|
||||
if (tag != null && targetGo.tag != tag)
|
||||
{
|
||||
string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag;
|
||||
|
||||
if (tagToSet != "Untagged" && !System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tagToSet))
|
||||
{
|
||||
McpLog.Info($"[ManageGameObject] Tag '{tagToSet}' not found. Creating it.");
|
||||
try
|
||||
{
|
||||
InternalEditorUtility.AddTag(tagToSet);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ErrorResponse($"Failed to create tag '{tagToSet}': {ex.Message}.");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
targetGo.tag = tagToSet;
|
||||
modified = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ErrorResponse($"Failed to set tag to '{tagToSet}': {ex.Message}.");
|
||||
}
|
||||
}
|
||||
|
||||
string layerName = @params["layer"]?.ToString();
|
||||
if (!string.IsNullOrEmpty(layerName))
|
||||
{
|
||||
int layerId = LayerMask.NameToLayer(layerName);
|
||||
if (layerId == -1)
|
||||
{
|
||||
return new ErrorResponse($"Invalid layer specified: '{layerName}'. Use a valid layer name.");
|
||||
}
|
||||
if (layerId != -1 && targetGo.layer != layerId)
|
||||
{
|
||||
targetGo.layer = layerId;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
bool? isStatic = @params["isStatic"]?.ToObject<bool?>();
|
||||
if (isStatic.HasValue)
|
||||
{
|
||||
var desiredFlags = isStatic.Value ? (StaticEditorFlags)~0 : 0;
|
||||
var currentFlags = GameObjectUtility.GetStaticEditorFlags(targetGo);
|
||||
|
||||
if (currentFlags != desiredFlags)
|
||||
{
|
||||
GameObjectUtility.SetStaticEditorFlags(targetGo, desiredFlags);
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
Vector3? position = VectorParsing.ParseVector3(@params["position"]);
|
||||
Vector3? rotation = VectorParsing.ParseVector3(@params["rotation"]);
|
||||
Vector3? scale = VectorParsing.ParseVector3(@params["scale"]);
|
||||
|
||||
if (position.HasValue && targetGo.transform.localPosition != position.Value)
|
||||
{
|
||||
targetGo.transform.localPosition = position.Value;
|
||||
modified = true;
|
||||
}
|
||||
if (rotation.HasValue && targetGo.transform.localEulerAngles != rotation.Value)
|
||||
{
|
||||
targetGo.transform.localEulerAngles = rotation.Value;
|
||||
modified = true;
|
||||
}
|
||||
if (scale.HasValue && targetGo.transform.localScale != scale.Value)
|
||||
{
|
||||
targetGo.transform.localScale = scale.Value;
|
||||
modified = true;
|
||||
}
|
||||
|
||||
if (@params["componentsToRemove"] is JArray componentsToRemoveArray)
|
||||
{
|
||||
foreach (var compToken in componentsToRemoveArray)
|
||||
{
|
||||
string typeName = compToken.ToString();
|
||||
if (!string.IsNullOrEmpty(typeName))
|
||||
{
|
||||
var removeResult = GameObjectComponentHelpers.RemoveComponentInternal(targetGo, typeName);
|
||||
if (removeResult != null)
|
||||
return removeResult;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (@params["componentsToAdd"] is JArray componentsToAddArrayModify)
|
||||
{
|
||||
foreach (var compToken in componentsToAddArrayModify)
|
||||
{
|
||||
string typeName = null;
|
||||
JObject properties = null;
|
||||
if (compToken.Type == JTokenType.String)
|
||||
typeName = compToken.ToString();
|
||||
else if (compToken is JObject compObj)
|
||||
{
|
||||
typeName = compObj["typeName"]?.ToString();
|
||||
properties = compObj["properties"] as JObject;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(typeName))
|
||||
{
|
||||
var addResult = GameObjectComponentHelpers.AddComponentInternal(targetGo, typeName, properties);
|
||||
if (addResult != null)
|
||||
return addResult;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var componentErrors = new List<object>();
|
||||
if (@params["componentProperties"] is JObject componentPropertiesObj)
|
||||
{
|
||||
foreach (var prop in componentPropertiesObj.Properties())
|
||||
{
|
||||
string compName = prop.Name;
|
||||
JObject propertiesToSet = prop.Value as JObject;
|
||||
if (propertiesToSet != null)
|
||||
{
|
||||
var setResult = GameObjectComponentHelpers.SetComponentPropertiesInternal(targetGo, compName, propertiesToSet);
|
||||
if (setResult != null)
|
||||
{
|
||||
componentErrors.Add(setResult);
|
||||
}
|
||||
else
|
||||
{
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (componentErrors.Count > 0)
|
||||
{
|
||||
var aggregatedErrors = new List<string>();
|
||||
foreach (var errorObj in componentErrors)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dataProp = errorObj?.GetType().GetProperty("data");
|
||||
var dataVal = dataProp?.GetValue(errorObj);
|
||||
if (dataVal != null)
|
||||
{
|
||||
var errorsProp = dataVal.GetType().GetProperty("errors");
|
||||
var errorsEnum = errorsProp?.GetValue(dataVal) as System.Collections.IEnumerable;
|
||||
if (errorsEnum != null)
|
||||
{
|
||||
foreach (var item in errorsEnum)
|
||||
{
|
||||
var s = item?.ToString();
|
||||
if (!string.IsNullOrEmpty(s)) aggregatedErrors.Add(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Warn($"[GameObjectModify] Error aggregating component errors: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return new ErrorResponse(
|
||||
$"One or more component property operations failed on '{targetGo.name}'.",
|
||||
new { componentErrors = componentErrors, errors = aggregatedErrors }
|
||||
);
|
||||
}
|
||||
|
||||
if (!modified)
|
||||
{
|
||||
return new SuccessResponse(
|
||||
$"No modifications applied to GameObject '{targetGo.name}'.",
|
||||
Helpers.GameObjectSerializer.GetGameObjectData(targetGo)
|
||||
);
|
||||
}
|
||||
|
||||
EditorUtility.SetDirty(targetGo);
|
||||
|
||||
// Mark the appropriate scene as dirty (handles both regular scenes and prefab stages)
|
||||
var prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
|
||||
if (prefabStage != null)
|
||||
{
|
||||
EditorSceneManager.MarkSceneDirty(prefabStage.scene);
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorSceneManager.MarkSceneDirty(EditorSceneManager.GetActiveScene());
|
||||
}
|
||||
|
||||
return new SuccessResponse(
|
||||
$"GameObject '{targetGo.name}' modified successfully.",
|
||||
Helpers.GameObjectSerializer.GetGameObjectData(targetGo)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ec5e33513bd094257a26ef6f75ea4574
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,119 @@
|
||||
#nullable disable
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.GameObjects
|
||||
{
|
||||
internal static class GameObjectMoveRelative
|
||||
{
|
||||
internal static object Handle(JObject @params, JToken targetToken, string searchMethod)
|
||||
{
|
||||
GameObject targetGo = ManageGameObjectCommon.FindObjectInternal(targetToken, searchMethod);
|
||||
if (targetGo == null)
|
||||
{
|
||||
return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
|
||||
}
|
||||
|
||||
JToken referenceToken = @params["reference_object"];
|
||||
if (referenceToken == null)
|
||||
{
|
||||
return new ErrorResponse("'reference_object' parameter is required for 'move_relative' action.");
|
||||
}
|
||||
|
||||
GameObject referenceGo = ManageGameObjectCommon.FindObjectInternal(referenceToken, "by_id_or_name_or_path");
|
||||
if (referenceGo == null)
|
||||
{
|
||||
return new ErrorResponse($"Reference object '{referenceToken}' not found.");
|
||||
}
|
||||
|
||||
string direction = @params["direction"]?.ToString()?.ToLower();
|
||||
float distance = @params["distance"]?.ToObject<float>() ?? 1f;
|
||||
Vector3? customOffset = VectorParsing.ParseVector3(@params["offset"]);
|
||||
bool useWorldSpace = @params["world_space"]?.ToObject<bool>() ?? true;
|
||||
|
||||
Undo.RecordObject(targetGo.transform, $"Move {targetGo.name} relative to {referenceGo.name}");
|
||||
|
||||
Vector3 newPosition;
|
||||
|
||||
if (customOffset.HasValue)
|
||||
{
|
||||
if (useWorldSpace)
|
||||
{
|
||||
newPosition = referenceGo.transform.position + customOffset.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
newPosition = referenceGo.transform.TransformPoint(customOffset.Value);
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(direction))
|
||||
{
|
||||
Vector3 directionVector = GetDirectionVector(direction, referenceGo.transform, useWorldSpace);
|
||||
newPosition = referenceGo.transform.position + directionVector * distance;
|
||||
}
|
||||
else
|
||||
{
|
||||
return new ErrorResponse("Either 'direction' or 'offset' parameter is required for 'move_relative' action.");
|
||||
}
|
||||
|
||||
targetGo.transform.position = newPosition;
|
||||
|
||||
EditorUtility.SetDirty(targetGo);
|
||||
EditorSceneManager.MarkSceneDirty(EditorSceneManager.GetActiveScene());
|
||||
|
||||
return new SuccessResponse(
|
||||
$"Moved '{targetGo.name}' relative to '{referenceGo.name}'.",
|
||||
new
|
||||
{
|
||||
movedObject = targetGo.name,
|
||||
referenceObject = referenceGo.name,
|
||||
newPosition = new[] { targetGo.transform.position.x, targetGo.transform.position.y, targetGo.transform.position.z },
|
||||
direction = direction,
|
||||
distance = distance,
|
||||
gameObject = Helpers.GameObjectSerializer.GetGameObjectData(targetGo)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private static Vector3 GetDirectionVector(string direction, Transform referenceTransform, bool useWorldSpace)
|
||||
{
|
||||
if (useWorldSpace)
|
||||
{
|
||||
switch (direction)
|
||||
{
|
||||
case "right": return Vector3.right;
|
||||
case "left": return Vector3.left;
|
||||
case "up": return Vector3.up;
|
||||
case "down": return Vector3.down;
|
||||
case "forward":
|
||||
case "front": return Vector3.forward;
|
||||
case "back":
|
||||
case "backward":
|
||||
case "behind": return Vector3.back;
|
||||
default:
|
||||
McpLog.Warn($"[ManageGameObject.MoveRelative] Unknown direction '{direction}', defaulting to forward.");
|
||||
return Vector3.forward;
|
||||
}
|
||||
}
|
||||
|
||||
switch (direction)
|
||||
{
|
||||
case "right": return referenceTransform.right;
|
||||
case "left": return -referenceTransform.right;
|
||||
case "up": return referenceTransform.up;
|
||||
case "down": return -referenceTransform.up;
|
||||
case "forward":
|
||||
case "front": return referenceTransform.forward;
|
||||
case "back":
|
||||
case "backward":
|
||||
case "behind": return -referenceTransform.forward;
|
||||
default:
|
||||
McpLog.Warn($"[ManageGameObject.MoveRelative] Unknown direction '{direction}', defaulting to forward.");
|
||||
return referenceTransform.forward;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8b19997a165de45c2af3ada79a6d3f08
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,117 @@
|
||||
#nullable disable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using MCPForUnity.Editor.Helpers; // For Response class
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.GameObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles GameObject manipulation within the current scene (CRUD, find, components).
|
||||
/// </summary>
|
||||
[McpForUnityTool("manage_gameobject", AutoRegister = false)]
|
||||
public static class ManageGameObject
|
||||
{
|
||||
// --- Main Handler ---
|
||||
|
||||
public static object HandleCommand(JObject @params)
|
||||
{
|
||||
if (@params == null)
|
||||
{
|
||||
return new ErrorResponse("Parameters cannot be null.");
|
||||
}
|
||||
|
||||
string action = @params["action"]?.ToString().ToLower();
|
||||
if (string.IsNullOrEmpty(action))
|
||||
{
|
||||
return new ErrorResponse("Action parameter is required.");
|
||||
}
|
||||
|
||||
// Parameters used by various actions
|
||||
JToken targetToken = @params["target"]; // Can be string (name/path) or int (instanceID)
|
||||
string name = @params["name"]?.ToString();
|
||||
|
||||
// --- Usability Improvement: Alias 'name' to 'target' for modification actions ---
|
||||
// If 'target' is missing but 'name' is provided, and we aren't creating a new object,
|
||||
// assume the user meant "find object by name".
|
||||
if (targetToken == null && !string.IsNullOrEmpty(name) && action != "create")
|
||||
{
|
||||
targetToken = name;
|
||||
// We don't update @params["target"] because we use targetToken locally mostly,
|
||||
// but some downstream methods might parse @params directly. Let's update @params too for safety.
|
||||
@params["target"] = name;
|
||||
}
|
||||
// -------------------------------------------------------------------------------
|
||||
|
||||
string searchMethod = @params["searchMethod"]?.ToString().ToLower();
|
||||
string tag = @params["tag"]?.ToString();
|
||||
string layer = @params["layer"]?.ToString();
|
||||
JToken parentToken = @params["parent"];
|
||||
|
||||
// Coerce string JSON to JObject for 'componentProperties' if provided as a JSON string
|
||||
var componentPropsToken = @params["componentProperties"];
|
||||
if (componentPropsToken != null && componentPropsToken.Type == JTokenType.String)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = JObject.Parse(componentPropsToken.ToString());
|
||||
@params["componentProperties"] = parsed;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
McpLog.Warn($"[ManageGameObject] Could not parse 'componentProperties' JSON string: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Prefab Asset Check ---
|
||||
// Prefab assets require different tools. Only 'create' (instantiation) is valid here.
|
||||
string targetPath =
|
||||
targetToken?.Type == JTokenType.String ? targetToken.ToString() : null;
|
||||
if (
|
||||
!string.IsNullOrEmpty(targetPath)
|
||||
&& targetPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)
|
||||
&& action != "create" // Allow prefab instantiation
|
||||
)
|
||||
{
|
||||
return new ErrorResponse(
|
||||
$"Target '{targetPath}' is a prefab asset. " +
|
||||
$"Use 'manage_asset' with action='modify' for prefab asset modifications, " +
|
||||
$"or 'manage_prefabs' with action='modify_contents' to edit the prefab headlessly, or 'manage_prefabs' with action='close_prefab_stage' to exit prefab editing mode."
|
||||
);
|
||||
}
|
||||
// --- End Prefab Asset Check ---
|
||||
|
||||
try
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
// --- Primary lifecycle actions (kept in manage_gameobject) ---
|
||||
case "create":
|
||||
return GameObjectCreate.Handle(@params);
|
||||
case "modify":
|
||||
return GameObjectModify.Handle(@params, targetToken, searchMethod);
|
||||
case "delete":
|
||||
return GameObjectDelete.Handle(targetToken, searchMethod);
|
||||
case "duplicate":
|
||||
return GameObjectDuplicate.Handle(@params, targetToken, searchMethod);
|
||||
case "move_relative":
|
||||
return GameObjectMoveRelative.Handle(@params, targetToken, searchMethod);
|
||||
case "look_at":
|
||||
return GameObjectLookAt.Handle(@params, targetToken, searchMethod);
|
||||
|
||||
default:
|
||||
return new ErrorResponse($"Unknown action: '{action}'.");
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
McpLog.Error($"[ManageGameObject] Action '{action}' failed: {e}");
|
||||
return new ErrorResponse($"Internal error processing action '{action}': {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7641d7388f0f6634b9d83d34de87b2ee
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,232 @@
|
||||
#nullable disable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Editor.Tools;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using MCPForUnity.Runtime.Helpers;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.GameObjects
|
||||
{
|
||||
internal static class ManageGameObjectCommon
|
||||
{
|
||||
internal static GameObject FindObjectInternal(JToken targetToken, string searchMethod, JObject findParams = null)
|
||||
{
|
||||
bool findAll = findParams?["findAll"]?.ToObject<bool>() ?? false;
|
||||
|
||||
if (
|
||||
targetToken?.Type == JTokenType.Integer
|
||||
|| (searchMethod == "by_id" && int.TryParse(targetToken?.ToString(), out _))
|
||||
)
|
||||
{
|
||||
findAll = false;
|
||||
}
|
||||
|
||||
List<GameObject> results = FindObjectsInternal(targetToken, searchMethod, findAll, findParams);
|
||||
return results.Count > 0 ? results[0] : null;
|
||||
}
|
||||
|
||||
internal static List<GameObject> FindObjectsInternal(
|
||||
JToken targetToken,
|
||||
string searchMethod,
|
||||
bool findAll,
|
||||
JObject findParams = null
|
||||
)
|
||||
{
|
||||
List<GameObject> results = new List<GameObject>();
|
||||
string searchTerm = findParams?["searchTerm"]?.ToString() ?? targetToken?.ToString();
|
||||
bool searchInChildren = findParams?["searchInChildren"]?.ToObject<bool>() ?? false;
|
||||
bool searchInactive = findParams?["searchInactive"]?.ToObject<bool>() ?? false;
|
||||
|
||||
if (string.IsNullOrEmpty(searchMethod))
|
||||
{
|
||||
if (targetToken?.Type == JTokenType.Integer)
|
||||
searchMethod = "by_id";
|
||||
else if (!string.IsNullOrEmpty(searchTerm) && searchTerm.Contains('/'))
|
||||
searchMethod = "by_path";
|
||||
else
|
||||
searchMethod = "by_name";
|
||||
}
|
||||
|
||||
GameObject rootSearchObject = null;
|
||||
if (searchInChildren && targetToken != null)
|
||||
{
|
||||
rootSearchObject = FindObjectInternal(targetToken, "by_id_or_name_or_path");
|
||||
if (rootSearchObject == null)
|
||||
{
|
||||
McpLog.Warn($"[ManageGameObject.Find] Root object '{targetToken}' for child search not found.");
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
switch (searchMethod)
|
||||
{
|
||||
case "by_id":
|
||||
if (int.TryParse(searchTerm, out int instanceId))
|
||||
{
|
||||
var allObjects = GetAllSceneObjects(searchInactive);
|
||||
GameObject obj = allObjects.FirstOrDefault(go => go.GetInstanceIDCompat() == instanceId);
|
||||
if (obj != null)
|
||||
results.Add(obj);
|
||||
}
|
||||
break;
|
||||
|
||||
case "by_name":
|
||||
var searchPoolName = rootSearchObject
|
||||
? rootSearchObject
|
||||
.GetComponentsInChildren<Transform>(searchInactive)
|
||||
.Select(t => t.gameObject)
|
||||
: GetAllSceneObjects(searchInactive);
|
||||
results.AddRange(searchPoolName.Where(go => go.name == searchTerm));
|
||||
break;
|
||||
|
||||
case "by_path":
|
||||
if (rootSearchObject != null)
|
||||
{
|
||||
Transform foundTransform = rootSearchObject.transform.Find(searchTerm);
|
||||
if (foundTransform != null)
|
||||
results.Add(foundTransform.gameObject);
|
||||
}
|
||||
else
|
||||
{
|
||||
var prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
|
||||
if (prefabStage != null || searchInactive)
|
||||
{
|
||||
// In Prefab Stage, GameObject.Find() doesn't work, need to search manually
|
||||
var allObjects = GetAllSceneObjects(searchInactive);
|
||||
foreach (var go in allObjects)
|
||||
{
|
||||
if (GameObjectLookup.MatchesPath(go, searchTerm))
|
||||
{
|
||||
results.Add(go);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var found = GameObject.Find(searchTerm);
|
||||
if (found != null)
|
||||
results.Add(found);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "by_tag":
|
||||
var searchPoolTag = rootSearchObject
|
||||
? rootSearchObject
|
||||
.GetComponentsInChildren<Transform>(searchInactive)
|
||||
.Select(t => t.gameObject)
|
||||
: GetAllSceneObjects(searchInactive);
|
||||
results.AddRange(searchPoolTag.Where(go => go.CompareTag(searchTerm)));
|
||||
break;
|
||||
|
||||
case "by_layer":
|
||||
var searchPoolLayer = rootSearchObject
|
||||
? rootSearchObject
|
||||
.GetComponentsInChildren<Transform>(searchInactive)
|
||||
.Select(t => t.gameObject)
|
||||
: GetAllSceneObjects(searchInactive);
|
||||
if (int.TryParse(searchTerm, out int layerIndex))
|
||||
{
|
||||
results.AddRange(searchPoolLayer.Where(go => go.layer == layerIndex));
|
||||
}
|
||||
else
|
||||
{
|
||||
int namedLayer = LayerMask.NameToLayer(searchTerm);
|
||||
if (namedLayer != -1)
|
||||
results.AddRange(searchPoolLayer.Where(go => go.layer == namedLayer));
|
||||
}
|
||||
break;
|
||||
|
||||
case "by_component":
|
||||
Type componentType = FindType(searchTerm);
|
||||
if (componentType != null)
|
||||
{
|
||||
IEnumerable<GameObject> searchPoolComp;
|
||||
if (rootSearchObject)
|
||||
{
|
||||
searchPoolComp = rootSearchObject
|
||||
.GetComponentsInChildren(componentType, searchInactive)
|
||||
.Select(c => (c as Component).gameObject);
|
||||
}
|
||||
else
|
||||
{
|
||||
searchPoolComp = UnityFindObjectsCompat.FindAll(componentType, searchInactive)
|
||||
.Cast<Component>()
|
||||
.Select(c => c.gameObject);
|
||||
}
|
||||
results.AddRange(searchPoolComp.Where(go => go != null));
|
||||
}
|
||||
else
|
||||
{
|
||||
McpLog.Warn($"[ManageGameObject.Find] Component type not found: {searchTerm}");
|
||||
}
|
||||
break;
|
||||
|
||||
case "by_id_or_name_or_path":
|
||||
if (int.TryParse(searchTerm, out int id))
|
||||
{
|
||||
var allObjectsId = GetAllSceneObjects(true);
|
||||
GameObject objById = allObjectsId.FirstOrDefault(go => go.GetInstanceIDCompat() == id);
|
||||
if (objById != null)
|
||||
{
|
||||
results.Add(objById);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Try path search - in Prefab Stage, GameObject.Find() doesn't work
|
||||
var allObjectsForPath = GetAllSceneObjects(true);
|
||||
GameObject objByPath = allObjectsForPath.FirstOrDefault(go =>
|
||||
{
|
||||
return GameObjectLookup.MatchesPath(go, searchTerm);
|
||||
});
|
||||
if (objByPath != null)
|
||||
{
|
||||
results.Add(objByPath);
|
||||
break;
|
||||
}
|
||||
|
||||
var allObjectsName = GetAllSceneObjects(true);
|
||||
results.AddRange(allObjectsName.Where(go => go.name == searchTerm));
|
||||
break;
|
||||
|
||||
default:
|
||||
McpLog.Warn($"[ManageGameObject.Find] Unknown search method: {searchMethod}");
|
||||
break;
|
||||
}
|
||||
|
||||
if (!findAll && results.Count > 1)
|
||||
{
|
||||
return new List<GameObject> { results[0] };
|
||||
}
|
||||
|
||||
return results.Distinct().ToList();
|
||||
}
|
||||
|
||||
private static IEnumerable<GameObject> GetAllSceneObjects(bool includeInactive)
|
||||
{
|
||||
// Delegate to GameObjectLookup to avoid code duplication and ensure consistent behavior
|
||||
return GameObjectLookup.GetAllSceneObjects(includeInactive);
|
||||
}
|
||||
|
||||
private static Type FindType(string typeName)
|
||||
{
|
||||
if (ComponentResolver.TryResolve(typeName, out Type resolvedType, out string error))
|
||||
{
|
||||
return resolvedType;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
McpLog.Warn($"[FindType] {error}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6bf0edf3cd2af46729294682cee3bee4
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,36 @@
|
||||
using System;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Editor.Services;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools
|
||||
{
|
||||
/// <summary>
|
||||
/// Poll a previously started async test job by job_id.
|
||||
/// </summary>
|
||||
[McpForUnityTool("get_test_job", AutoRegister = false, Group = "testing")]
|
||||
public static class GetTestJob
|
||||
{
|
||||
public static object HandleCommand(JObject @params)
|
||||
{
|
||||
string jobId = @params?["job_id"]?.ToString() ?? @params?["jobId"]?.ToString();
|
||||
if (string.IsNullOrWhiteSpace(jobId))
|
||||
{
|
||||
return new ErrorResponse("Missing required parameter 'job_id'.");
|
||||
}
|
||||
|
||||
var p = new ToolParams(@params);
|
||||
bool includeDetails = p.GetBool("includeDetails");
|
||||
bool includeFailedTests = p.GetBool("includeFailedTests");
|
||||
|
||||
var job = TestJobManager.GetJob(jobId);
|
||||
if (job == null)
|
||||
{
|
||||
return new ErrorResponse("Unknown job_id.");
|
||||
}
|
||||
|
||||
var payload = TestJobManager.ToSerializable(job, includeDetails, includeFailedTests);
|
||||
return new SuccessResponse("Test job status retrieved.", payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7f92c2b67a2c4b5c9d1a3c0e6f9b2d10
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fbd13c8334e847f58acef6bbae6d5c35
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,268 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Runtime.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Graphics
|
||||
{
|
||||
internal static class GraphicsHelpers
|
||||
{
|
||||
private static bool? _hasVolumeSystem;
|
||||
private static Type _volumeType;
|
||||
private static Type _volumeProfileType;
|
||||
private static Type _volumeComponentType;
|
||||
private static Type _volumeParameterType;
|
||||
|
||||
internal static bool HasVolumeSystem
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_hasVolumeSystem == null) DetectPackages();
|
||||
return _hasVolumeSystem.Value;
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool HasURP =>
|
||||
RenderPipelineUtility.GetActivePipeline() == RenderPipelineUtility.PipelineKind.Universal;
|
||||
|
||||
internal static bool HasHDRP =>
|
||||
RenderPipelineUtility.GetActivePipeline() == RenderPipelineUtility.PipelineKind.HighDefinition;
|
||||
|
||||
internal static Type VolumeType
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_hasVolumeSystem == null) DetectPackages();
|
||||
return _volumeType;
|
||||
}
|
||||
}
|
||||
|
||||
internal static Type VolumeProfileType
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_hasVolumeSystem == null) DetectPackages();
|
||||
return _volumeProfileType;
|
||||
}
|
||||
}
|
||||
|
||||
internal static Type VolumeComponentType
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_hasVolumeSystem == null) DetectPackages();
|
||||
return _volumeComponentType;
|
||||
}
|
||||
}
|
||||
|
||||
internal static Type VolumeParameterType
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_hasVolumeSystem == null) DetectPackages();
|
||||
return _volumeParameterType;
|
||||
}
|
||||
}
|
||||
|
||||
private static void DetectPackages()
|
||||
{
|
||||
_volumeType = Type.GetType("UnityEngine.Rendering.Volume, Unity.RenderPipelines.Core.Runtime");
|
||||
_volumeProfileType = Type.GetType("UnityEngine.Rendering.VolumeProfile, Unity.RenderPipelines.Core.Runtime");
|
||||
_volumeComponentType = Type.GetType("UnityEngine.Rendering.VolumeComponent, Unity.RenderPipelines.Core.Runtime");
|
||||
_volumeParameterType = Type.GetType("UnityEngine.Rendering.VolumeParameter, Unity.RenderPipelines.Core.Runtime");
|
||||
_hasVolumeSystem = _volumeType != null && _volumeProfileType != null;
|
||||
}
|
||||
|
||||
internal static Type ResolveVolumeComponentType(string effectName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(effectName) || VolumeComponentType == null)
|
||||
return null;
|
||||
|
||||
var derivedTypes = TypeCache.GetTypesDerivedFrom(VolumeComponentType);
|
||||
foreach (var t in derivedTypes)
|
||||
{
|
||||
if (t.IsAbstract) continue;
|
||||
if (string.Equals(t.Name, effectName, StringComparison.OrdinalIgnoreCase))
|
||||
return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static List<Type> GetAvailableEffectTypes()
|
||||
{
|
||||
if (VolumeComponentType == null)
|
||||
return new List<Type>();
|
||||
var derivedTypes = TypeCache.GetTypesDerivedFrom(VolumeComponentType);
|
||||
return derivedTypes
|
||||
.Where(t => !t.IsAbstract && !t.IsGenericType)
|
||||
.OrderBy(t => t.Name)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
internal static Component FindVolume(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
string target = p.Get("target");
|
||||
if (string.IsNullOrEmpty(target))
|
||||
{
|
||||
var allVolumes = UnityFindObjectsCompat.FindAll(VolumeType);
|
||||
return allVolumes.Length > 0 ? allVolumes[0] as Component : null;
|
||||
}
|
||||
|
||||
if (int.TryParse(target, out int instanceId))
|
||||
{
|
||||
var byId = GameObjectLookup.ResolveInstanceID(instanceId) as GameObject;
|
||||
if (byId != null) return byId.GetComponent(VolumeType);
|
||||
}
|
||||
|
||||
var go = GameObject.Find(target);
|
||||
if (go != null) return go.GetComponent(VolumeType);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static string GetPipelineName()
|
||||
{
|
||||
return RenderPipelineUtility.GetActivePipeline() switch
|
||||
{
|
||||
RenderPipelineUtility.PipelineKind.Universal => "Universal (URP)",
|
||||
RenderPipelineUtility.PipelineKind.HighDefinition => "High Definition (HDRP)",
|
||||
RenderPipelineUtility.PipelineKind.BuiltIn => "Built-in",
|
||||
RenderPipelineUtility.PipelineKind.Custom => "Custom",
|
||||
_ => "Unknown"
|
||||
};
|
||||
}
|
||||
|
||||
internal static object ReadSerializedValue(SerializedProperty prop)
|
||||
{
|
||||
return prop.propertyType switch
|
||||
{
|
||||
SerializedPropertyType.Boolean => prop.boolValue,
|
||||
SerializedPropertyType.Integer => prop.type == "long" ? prop.longValue : (object)prop.intValue,
|
||||
SerializedPropertyType.Float => prop.floatValue,
|
||||
SerializedPropertyType.String => prop.stringValue,
|
||||
SerializedPropertyType.Enum => prop.enumValueIndex < prop.enumNames.Length
|
||||
? prop.enumNames[prop.enumValueIndex]
|
||||
: (object)prop.enumValueIndex,
|
||||
SerializedPropertyType.ObjectReference => prop.objectReferenceValue != null
|
||||
? (object)new
|
||||
{
|
||||
name = prop.objectReferenceValue.name,
|
||||
path = AssetDatabase.GetAssetPath(prop.objectReferenceValue)
|
||||
}
|
||||
: null,
|
||||
SerializedPropertyType.Color => new[] { prop.colorValue.r, prop.colorValue.g, prop.colorValue.b, prop.colorValue.a },
|
||||
SerializedPropertyType.Vector2 => new[] { prop.vector2Value.x, prop.vector2Value.y },
|
||||
SerializedPropertyType.Vector3 => new[] { prop.vector3Value.x, prop.vector3Value.y, prop.vector3Value.z },
|
||||
SerializedPropertyType.LayerMask => prop.intValue,
|
||||
_ => prop.propertyType.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
internal static bool SetSerializedValue(SerializedProperty prop, JToken value)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (prop.propertyType)
|
||||
{
|
||||
case SerializedPropertyType.Boolean:
|
||||
prop.boolValue = ParamCoercion.CoerceBool(value, false);
|
||||
return true;
|
||||
case SerializedPropertyType.Integer:
|
||||
if (prop.type == "long")
|
||||
prop.longValue = ParamCoercion.CoerceLong(value, 0);
|
||||
else
|
||||
prop.intValue = ParamCoercion.CoerceInt(value, 0);
|
||||
return true;
|
||||
case SerializedPropertyType.Float:
|
||||
prop.floatValue = ParamCoercion.CoerceFloat(value, 0f);
|
||||
return true;
|
||||
case SerializedPropertyType.String:
|
||||
prop.stringValue = value.ToString();
|
||||
return true;
|
||||
case SerializedPropertyType.Enum:
|
||||
if (value.Type == JTokenType.String)
|
||||
{
|
||||
for (int i = 0; i < prop.enumNames.Length; i++)
|
||||
{
|
||||
if (string.Equals(prop.enumNames[i], value.ToString(), StringComparison.OrdinalIgnoreCase))
|
||||
{ prop.enumValueIndex = i; return true; }
|
||||
}
|
||||
}
|
||||
prop.enumValueIndex = ParamCoercion.CoerceInt(value, 0);
|
||||
return true;
|
||||
case SerializedPropertyType.ObjectReference:
|
||||
if (value.Type == JTokenType.String)
|
||||
{
|
||||
string path = value.ToString();
|
||||
var asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(path);
|
||||
if (asset != null) { prop.objectReferenceValue = asset; return true; }
|
||||
}
|
||||
else if (value.Type == JTokenType.Object)
|
||||
{
|
||||
string path = value["path"]?.ToString();
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
var asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(path);
|
||||
if (asset != null) { prop.objectReferenceValue = asset; return true; }
|
||||
}
|
||||
}
|
||||
else if (value.Type == JTokenType.Null)
|
||||
{
|
||||
prop.objectReferenceValue = null;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case SerializedPropertyType.Color:
|
||||
if (value is JArray colorArr && colorArr.Count >= 3)
|
||||
{
|
||||
prop.colorValue = new Color(
|
||||
(float)colorArr[0], (float)colorArr[1], (float)colorArr[2],
|
||||
colorArr.Count >= 4 ? (float)colorArr[3] : 1f);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case SerializedPropertyType.Vector2:
|
||||
if (value is JArray v2Arr && v2Arr.Count >= 2)
|
||||
{
|
||||
prop.vector2Value = new Vector2((float)v2Arr[0], (float)v2Arr[1]);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case SerializedPropertyType.Vector3:
|
||||
if (value is JArray v3Arr && v3Arr.Count >= 3)
|
||||
{
|
||||
prop.vector3Value = new Vector3((float)v3Arr[0], (float)v3Arr[1], (float)v3Arr[2]);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case SerializedPropertyType.LayerMask:
|
||||
prop.intValue = ParamCoercion.CoerceInt(value, 0);
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
internal static void MarkDirty(UnityEngine.Object obj)
|
||||
{
|
||||
if (obj == null) return;
|
||||
EditorUtility.SetDirty(obj);
|
||||
if (obj is Component comp)
|
||||
{
|
||||
var prefabStage = UnityEditor.SceneManagement.PrefabStageUtility.GetCurrentPrefabStage();
|
||||
if (prefabStage != null)
|
||||
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(prefabStage.scene);
|
||||
else
|
||||
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(comp.gameObject.scene);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 99e4b1a4aa03465ea2d55e2794537155
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,585 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
using MCPForUnity.Runtime.Helpers;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Graphics
|
||||
{
|
||||
internal static class LightBakingOps
|
||||
{
|
||||
// === bake_start ===
|
||||
// Params: async (bool, default true)
|
||||
internal static object StartBake(JObject @params)
|
||||
{
|
||||
if (Application.isPlaying)
|
||||
return new ErrorResponse("Light baking requires Edit mode.");
|
||||
|
||||
var p = new ToolParams(@params);
|
||||
bool async_ = p.GetBool("async", true);
|
||||
|
||||
if (async_)
|
||||
{
|
||||
Lightmapping.BakeAsync();
|
||||
return new PendingResponse(
|
||||
"Light bake started (async). Use bake_status to check progress.",
|
||||
pollIntervalSeconds: 2.0,
|
||||
data: new { mode = "async" }
|
||||
);
|
||||
}
|
||||
|
||||
Lightmapping.Bake();
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = "Light bake completed (synchronous).",
|
||||
data = new
|
||||
{
|
||||
mode = "sync",
|
||||
lightmapCount = LightmapSettings.lightmaps.Length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// === bake_cancel ===
|
||||
internal static object CancelBake(JObject @params)
|
||||
{
|
||||
Lightmapping.Cancel();
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = "Light bake cancelled."
|
||||
};
|
||||
}
|
||||
|
||||
// === bake_get_status ===
|
||||
internal static object GetStatus(JObject @params)
|
||||
{
|
||||
bool running = Lightmapping.isRunning;
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = running ? "Light bake in progress." : "No bake running.",
|
||||
data = new
|
||||
{
|
||||
isRunning = running,
|
||||
bakedGI = Lightmapping.bakedGI,
|
||||
realtimeGI = Lightmapping.realtimeGI,
|
||||
lightmapCount = LightmapSettings.lightmaps.Length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// === bake_clear ===
|
||||
internal static object ClearBake(JObject @params)
|
||||
{
|
||||
Lightmapping.Clear();
|
||||
Lightmapping.ClearLightingDataAsset();
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = "Cleared all baked lighting data and lighting data asset."
|
||||
};
|
||||
}
|
||||
|
||||
// === bake_reflection_probe ===
|
||||
// Params: target (name or instanceID of GameObject with ReflectionProbe)
|
||||
internal static object BakeReflectionProbe(JObject @params)
|
||||
{
|
||||
if (Application.isPlaying)
|
||||
return new ErrorResponse("Reflection probe baking requires Edit mode.");
|
||||
|
||||
var p = new ToolParams(@params);
|
||||
string target = p.Get("target");
|
||||
if (string.IsNullOrEmpty(target))
|
||||
return new ErrorResponse("'target' parameter is required (name or instanceID of a GameObject with ReflectionProbe).");
|
||||
|
||||
var go = FindGameObject(target);
|
||||
if (go == null)
|
||||
return new ErrorResponse($"GameObject '{target}' not found.");
|
||||
|
||||
var probe = go.GetComponent<ReflectionProbe>();
|
||||
if (probe == null)
|
||||
return new ErrorResponse($"GameObject '{go.name}' does not have a ReflectionProbe component.");
|
||||
|
||||
string dir = "Assets/Lightmaps";
|
||||
if (!AssetDatabase.IsValidFolder(dir))
|
||||
AssetDatabase.CreateFolder("Assets", "Lightmaps");
|
||||
|
||||
string outputPath = $"{dir}/{probe.name}_ReflectionProbe.exr";
|
||||
|
||||
bool result = Lightmapping.BakeReflectionProbe(probe, outputPath);
|
||||
if (!result)
|
||||
return new ErrorResponse($"Failed to bake reflection probe '{probe.name}'.");
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Baked reflection probe '{probe.name}' to '{outputPath}'.",
|
||||
data = new
|
||||
{
|
||||
probeName = probe.name,
|
||||
outputPath,
|
||||
instanceID = go.GetInstanceIDCompat()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// === bake_get_settings ===
|
||||
internal static object GetSettings(JObject @params)
|
||||
{
|
||||
var settings = EnsureLightingSettings();
|
||||
if (settings == null)
|
||||
return new ErrorResponse(
|
||||
"Failed to create LightingSettings. Open Window > Rendering > Lighting manually.");
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["name"] = settings.name,
|
||||
["path"] = AssetDatabase.GetAssetPath(settings),
|
||||
["bakedGI"] = settings.bakedGI,
|
||||
["realtimeGI"] = settings.realtimeGI,
|
||||
["lightmapper"] = settings.lightmapper.ToString(),
|
||||
["lightmapResolution"] = settings.lightmapResolution,
|
||||
["lightmapMaxSize"] = settings.lightmapMaxSize,
|
||||
["directSampleCount"] = settings.directSampleCount,
|
||||
["indirectSampleCount"] = settings.indirectSampleCount,
|
||||
["environmentSampleCount"] = settings.environmentSampleCount,
|
||||
["mixedBakeMode"] = settings.mixedBakeMode.ToString(),
|
||||
["lightmapCompression"] = settings.lightmapCompression.ToString(),
|
||||
["ao"] = settings.ao,
|
||||
["aoMaxDistance"] = settings.aoMaxDistance
|
||||
};
|
||||
|
||||
// bounceCount vs maxBounces — name varies by Unity version
|
||||
ReadBounceCount(settings, data);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Lighting settings: {settings.lightmapper}, resolution {settings.lightmapResolution}.",
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
// === bake_set_settings ===
|
||||
// Params: settings (dict of property name -> value)
|
||||
internal static object SetSettings(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
var settingsToken = p.GetRaw("settings") as JObject;
|
||||
if (settingsToken == null || !settingsToken.HasValues)
|
||||
return new ErrorResponse("'settings' parameter is required (dict of property name to value).");
|
||||
|
||||
var lightingSettings = EnsureLightingSettings();
|
||||
if (lightingSettings == null)
|
||||
return new ErrorResponse(
|
||||
"Failed to create LightingSettings. Open Window > Rendering > Lighting manually.");
|
||||
|
||||
Undo.RecordObject(lightingSettings, "Modify Lighting Settings");
|
||||
|
||||
var changed = new List<string>();
|
||||
var failed = new List<string>();
|
||||
|
||||
foreach (var prop in settingsToken.Properties())
|
||||
{
|
||||
string name = prop.Name;
|
||||
JToken value = prop.Value;
|
||||
|
||||
try
|
||||
{
|
||||
if (TrySetLightingSetting(lightingSettings, name, value))
|
||||
changed.Add(name);
|
||||
else
|
||||
failed.Add(name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Warn($"[LightBakingOps] Failed to set '{name}': {ex.Message}");
|
||||
failed.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (changed.Count == 0 && failed.Count > 0)
|
||||
return new ErrorResponse($"Failed to set any settings. Invalid properties: {string.Join(", ", failed)}");
|
||||
|
||||
EditorUtility.SetDirty(lightingSettings);
|
||||
|
||||
var msg = $"Updated {changed.Count} lighting setting(s)";
|
||||
if (failed.Count > 0)
|
||||
msg += $". Failed: {string.Join(", ", failed)}";
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = msg,
|
||||
data = new { changed, failed }
|
||||
};
|
||||
}
|
||||
|
||||
// === bake_create_light_probe_group ===
|
||||
// Params: name, position, grid_size, spacing
|
||||
internal static object CreateLightProbeGroup(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
string name = p.Get("name") ?? "Light Probes";
|
||||
float spacing = p.GetFloat("spacing") ?? 2.0f;
|
||||
|
||||
var posToken = p.GetRaw("position") as JArray;
|
||||
Vector3 position = posToken != null && posToken.Count >= 3
|
||||
? new Vector3(posToken[0].Value<float>(), posToken[1].Value<float>(), posToken[2].Value<float>())
|
||||
: Vector3.zero;
|
||||
|
||||
var gridToken = p.GetRaw("grid_size") as JArray;
|
||||
int gridX = gridToken != null && gridToken.Count >= 1 ? gridToken[0].Value<int>() : 3;
|
||||
int gridY = gridToken != null && gridToken.Count >= 2 ? gridToken[1].Value<int>() : 2;
|
||||
int gridZ = gridToken != null && gridToken.Count >= 3 ? gridToken[2].Value<int>() : 3;
|
||||
|
||||
var go = new GameObject(name);
|
||||
go.transform.position = position;
|
||||
Undo.RegisterCreatedObjectUndo(go, $"Create Light Probe Group '{name}'");
|
||||
|
||||
var probeGroup = go.AddComponent<LightProbeGroup>();
|
||||
|
||||
var positions = new List<Vector3>();
|
||||
float halfX = (gridX - 1) * spacing * 0.5f;
|
||||
float halfY = (gridY - 1) * spacing * 0.5f;
|
||||
float halfZ = (gridZ - 1) * spacing * 0.5f;
|
||||
|
||||
for (int x = 0; x < gridX; x++)
|
||||
{
|
||||
for (int y = 0; y < gridY; y++)
|
||||
{
|
||||
for (int z = 0; z < gridZ; z++)
|
||||
{
|
||||
positions.Add(new Vector3(
|
||||
x * spacing - halfX,
|
||||
y * spacing - halfY,
|
||||
z * spacing - halfZ
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
probeGroup.probePositions = positions.ToArray();
|
||||
GraphicsHelpers.MarkDirty(probeGroup);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Created Light Probe Group '{name}' with {positions.Count} probes ({gridX}x{gridY}x{gridZ} grid, spacing {spacing}).",
|
||||
data = new
|
||||
{
|
||||
instanceID = go.GetInstanceIDCompat(),
|
||||
probeCount = positions.Count,
|
||||
gridSize = new[] { gridX, gridY, gridZ },
|
||||
spacing,
|
||||
position = new[] { position.x, position.y, position.z }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// === bake_create_reflection_probe ===
|
||||
// Params: name, position, size, resolution, mode, hdr, box_projection
|
||||
internal static object CreateReflectionProbe(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
string name = p.Get("name") ?? "Reflection Probe";
|
||||
int resolution = p.GetInt("resolution") ?? 256;
|
||||
bool hdr = p.GetBool("hdr", true);
|
||||
bool boxProjection = p.GetBool("box_projection", false);
|
||||
string modeStr = p.Get("mode") ?? "Baked";
|
||||
|
||||
var posToken = p.GetRaw("position") as JArray;
|
||||
Vector3 position = posToken != null && posToken.Count >= 3
|
||||
? new Vector3(posToken[0].Value<float>(), posToken[1].Value<float>(), posToken[2].Value<float>())
|
||||
: Vector3.zero;
|
||||
|
||||
var sizeToken = p.GetRaw("size") as JArray;
|
||||
Vector3 size = sizeToken != null && sizeToken.Count >= 3
|
||||
? new Vector3(sizeToken[0].Value<float>(), sizeToken[1].Value<float>(), sizeToken[2].Value<float>())
|
||||
: new Vector3(10f, 10f, 10f);
|
||||
|
||||
if (!Enum.TryParse<ReflectionProbeMode>(modeStr, true, out var mode))
|
||||
return new ErrorResponse(
|
||||
$"Invalid mode '{modeStr}'. Valid values: Baked, Realtime, Custom.");
|
||||
|
||||
var go = new GameObject(name);
|
||||
go.transform.position = position;
|
||||
Undo.RegisterCreatedObjectUndo(go, $"Create Reflection Probe '{name}'");
|
||||
|
||||
var probe = go.AddComponent<ReflectionProbe>();
|
||||
probe.size = size;
|
||||
probe.resolution = resolution;
|
||||
probe.mode = mode;
|
||||
probe.hdr = hdr;
|
||||
probe.boxProjection = boxProjection;
|
||||
|
||||
GraphicsHelpers.MarkDirty(probe);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Created Reflection Probe '{name}' (mode: {mode}, resolution: {resolution}, HDR: {hdr}).",
|
||||
data = new
|
||||
{
|
||||
instanceID = go.GetInstanceIDCompat(),
|
||||
mode = mode.ToString(),
|
||||
resolution,
|
||||
hdr,
|
||||
boxProjection,
|
||||
size = new[] { size.x, size.y, size.z },
|
||||
position = new[] { position.x, position.y, position.z }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// === bake_set_probe_positions ===
|
||||
// Params: target (name/instanceID), positions (array of [x,y,z])
|
||||
internal static object SetProbePositions(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
string target = p.Get("target");
|
||||
if (string.IsNullOrEmpty(target))
|
||||
return new ErrorResponse("'target' parameter is required (name or instanceID of a GameObject with LightProbeGroup).");
|
||||
|
||||
var go = FindGameObject(target);
|
||||
if (go == null)
|
||||
return new ErrorResponse($"GameObject '{target}' not found.");
|
||||
|
||||
var probeGroup = go.GetComponent<LightProbeGroup>();
|
||||
if (probeGroup == null)
|
||||
return new ErrorResponse($"GameObject '{go.name}' does not have a LightProbeGroup component.");
|
||||
|
||||
var positionsToken = p.GetRaw("positions") as JArray;
|
||||
if (positionsToken == null || positionsToken.Count == 0)
|
||||
return new ErrorResponse("'positions' parameter is required (array of [x,y,z] arrays).");
|
||||
|
||||
Undo.RecordObject(probeGroup, "Set Light Probe Positions");
|
||||
|
||||
var positions = new Vector3[positionsToken.Count];
|
||||
for (int i = 0; i < positionsToken.Count; i++)
|
||||
{
|
||||
var arr = positionsToken[i] as JArray;
|
||||
if (arr == null || arr.Count < 3)
|
||||
return new ErrorResponse($"Position at index {i} must be an array of [x, y, z].");
|
||||
positions[i] = new Vector3(
|
||||
arr[0].Value<float>(),
|
||||
arr[1].Value<float>(),
|
||||
arr[2].Value<float>()
|
||||
);
|
||||
}
|
||||
|
||||
probeGroup.probePositions = positions;
|
||||
GraphicsHelpers.MarkDirty(probeGroup);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Set {positions.Length} probe positions on '{go.name}'.",
|
||||
data = new
|
||||
{
|
||||
instanceID = go.GetInstanceIDCompat(),
|
||||
probeCount = positions.Length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// --- Helper: Ensure a LightingSettings asset exists ---
|
||||
private static LightingSettings EnsureLightingSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var settings = Lightmapping.lightingSettings;
|
||||
if (settings != null) return settings;
|
||||
}
|
||||
catch { /* getter throws when no asset exists */ }
|
||||
|
||||
try
|
||||
{
|
||||
var settings = new LightingSettings { name = "LightingSettings" };
|
||||
Lightmapping.lightingSettings = settings;
|
||||
return Lightmapping.lightingSettings;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// --- Helper: Find a GameObject by name or instanceID ---
|
||||
private static GameObject FindGameObject(string target)
|
||||
{
|
||||
if (string.IsNullOrEmpty(target))
|
||||
return null;
|
||||
|
||||
if (int.TryParse(target, out int instanceId))
|
||||
{
|
||||
var byId = GameObjectLookup.ResolveInstanceID(instanceId) as GameObject;
|
||||
if (byId != null) return byId;
|
||||
}
|
||||
|
||||
return GameObject.Find(target);
|
||||
}
|
||||
|
||||
// --- Helper: Read bounceCount with version fallback ---
|
||||
private static void ReadBounceCount(LightingSettings settings, Dictionary<string, object> data)
|
||||
{
|
||||
var type = typeof(LightingSettings);
|
||||
|
||||
// Try bounceCount first (Unity 2022+)
|
||||
var prop = type.GetProperty("bounceCount", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (prop != null)
|
||||
{
|
||||
data["bounceCount"] = prop.GetValue(settings);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to maxBounces (older Unity versions)
|
||||
prop = type.GetProperty("maxBounces", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (prop != null)
|
||||
data["maxBounces"] = prop.GetValue(settings);
|
||||
}
|
||||
|
||||
// --- Helper: Set a single lighting setting by name ---
|
||||
private static bool TrySetLightingSetting(LightingSettings settings, string name, JToken value)
|
||||
{
|
||||
switch (name.ToLowerInvariant())
|
||||
{
|
||||
case "bakedgi":
|
||||
case "baked_gi":
|
||||
settings.bakedGI = ParamCoercion.CoerceBool(value, settings.bakedGI);
|
||||
return true;
|
||||
|
||||
case "realtimegi":
|
||||
case "realtime_gi":
|
||||
settings.realtimeGI = ParamCoercion.CoerceBool(value, settings.realtimeGI);
|
||||
return true;
|
||||
|
||||
case "lightmapper":
|
||||
if (TryParseEnum<LightingSettings.Lightmapper>(value, out var lm))
|
||||
{
|
||||
settings.lightmapper = lm;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
case "lightmapresolution":
|
||||
case "lightmap_resolution":
|
||||
settings.lightmapResolution = ParamCoercion.CoerceFloat(value, settings.lightmapResolution);
|
||||
return true;
|
||||
|
||||
case "lightmapmaxsize":
|
||||
case "lightmap_max_size":
|
||||
settings.lightmapMaxSize = ParamCoercion.CoerceInt(value, settings.lightmapMaxSize);
|
||||
return true;
|
||||
|
||||
case "directsamplecount":
|
||||
case "direct_sample_count":
|
||||
settings.directSampleCount = ParamCoercion.CoerceInt(value, settings.directSampleCount);
|
||||
return true;
|
||||
|
||||
case "indirectsamplecount":
|
||||
case "indirect_sample_count":
|
||||
settings.indirectSampleCount = ParamCoercion.CoerceInt(value, settings.indirectSampleCount);
|
||||
return true;
|
||||
|
||||
case "environmentsamplecount":
|
||||
case "environment_sample_count":
|
||||
settings.environmentSampleCount = ParamCoercion.CoerceInt(value, settings.environmentSampleCount);
|
||||
return true;
|
||||
|
||||
case "bouncecount":
|
||||
case "bounce_count":
|
||||
case "maxbounces":
|
||||
case "max_bounces":
|
||||
return TrySetBounceCount(settings, ParamCoercion.CoerceInt(value, 2));
|
||||
|
||||
case "mixedbakemode":
|
||||
case "mixed_bake_mode":
|
||||
if (TryParseEnum<MixedLightingMode>(value, out var mlm))
|
||||
{
|
||||
settings.mixedBakeMode = mlm;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
case "compresslightmaps":
|
||||
case "compress_lightmaps":
|
||||
case "lightmapcompression":
|
||||
case "lightmap_compression":
|
||||
var strVal = value?.ToString() ?? "";
|
||||
if (System.Enum.TryParse<LightmapCompression>(strVal, true, out var compression))
|
||||
settings.lightmapCompression = compression;
|
||||
else if (bool.TryParse(strVal, out var boolVal))
|
||||
settings.lightmapCompression = boolVal
|
||||
? LightmapCompression.NormalQuality : LightmapCompression.None;
|
||||
else if (int.TryParse(strVal, out var intVal))
|
||||
settings.lightmapCompression = (LightmapCompression)intVal;
|
||||
else
|
||||
return false;
|
||||
return true;
|
||||
|
||||
case "ao":
|
||||
settings.ao = ParamCoercion.CoerceBool(value, settings.ao);
|
||||
return true;
|
||||
|
||||
case "aomaxdistance":
|
||||
case "ao_max_distance":
|
||||
settings.aoMaxDistance = ParamCoercion.CoerceFloat(value, settings.aoMaxDistance);
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper: Set bounceCount with version fallback ---
|
||||
private static bool TrySetBounceCount(LightingSettings settings, int value)
|
||||
{
|
||||
var type = typeof(LightingSettings);
|
||||
|
||||
// Try bounceCount first (Unity 2022+)
|
||||
var prop = type.GetProperty("bounceCount", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (prop != null && prop.CanWrite)
|
||||
{
|
||||
prop.SetValue(settings, value);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback to maxBounces (older Unity versions)
|
||||
prop = type.GetProperty("maxBounces", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (prop != null && prop.CanWrite)
|
||||
{
|
||||
prop.SetValue(settings, value);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Helper: Parse enum from JToken (string name or int value) ---
|
||||
private static bool TryParseEnum<T>(JToken value, out T result) where T : struct, Enum
|
||||
{
|
||||
result = default;
|
||||
if (value == null || value.Type == JTokenType.Null) return false;
|
||||
|
||||
string str = value.ToString();
|
||||
|
||||
// Try parse by name
|
||||
if (Enum.TryParse(str, true, out result))
|
||||
return true;
|
||||
|
||||
// Try parse by int value
|
||||
if (int.TryParse(str, out int intVal))
|
||||
{
|
||||
result = (T)Enum.ToObject(typeof(T), intVal);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4bf5a93fc3954f3e880e60794fa968de
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,175 @@
|
||||
using System;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Graphics
|
||||
{
|
||||
[McpForUnityTool("manage_graphics", AutoRegister = false, Group = "core")]
|
||||
public static class ManageGraphics
|
||||
{
|
||||
public static object HandleCommand(JObject @params)
|
||||
{
|
||||
if (@params == null)
|
||||
return new ErrorResponse("Parameters cannot be null.");
|
||||
|
||||
var p = new ToolParams(@params);
|
||||
string action = p.Get("action")?.ToLowerInvariant();
|
||||
|
||||
if (string.IsNullOrEmpty(action))
|
||||
return new ErrorResponse("'action' parameter is required.");
|
||||
|
||||
try
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
// --- Health check ---
|
||||
case "ping":
|
||||
var pipeName = GraphicsHelpers.GetPipelineName();
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Graphics tool ready. Pipeline: {pipeName}",
|
||||
data = new
|
||||
{
|
||||
pipeline = RenderPipelineUtility.GetActivePipeline().ToString(),
|
||||
pipelineName = pipeName,
|
||||
hasVolumeSystem = GraphicsHelpers.HasVolumeSystem,
|
||||
hasURP = GraphicsHelpers.HasURP,
|
||||
hasHDRP = GraphicsHelpers.HasHDRP,
|
||||
availableEffects = GraphicsHelpers.HasVolumeSystem
|
||||
? GraphicsHelpers.GetAvailableEffectTypes().Count : 0
|
||||
}
|
||||
};
|
||||
|
||||
// --- Volume actions (require Volume system = URP or HDRP) ---
|
||||
case "volume_create":
|
||||
case "volume_add_effect":
|
||||
case "volume_set_effect":
|
||||
case "volume_remove_effect":
|
||||
case "volume_get_info":
|
||||
case "volume_set_properties":
|
||||
case "volume_list_effects":
|
||||
case "volume_create_profile":
|
||||
{
|
||||
if (!GraphicsHelpers.HasVolumeSystem)
|
||||
return new ErrorResponse(
|
||||
"Volume system not available. Requires URP or HDRP (com.unity.render-pipelines.core).");
|
||||
|
||||
return action switch
|
||||
{
|
||||
"volume_create" => VolumeOps.CreateVolume(@params),
|
||||
"volume_add_effect" => VolumeOps.AddEffect(@params),
|
||||
"volume_set_effect" => VolumeOps.SetEffect(@params),
|
||||
"volume_remove_effect" => VolumeOps.RemoveEffect(@params),
|
||||
"volume_get_info" => VolumeOps.GetInfo(@params),
|
||||
"volume_set_properties" => VolumeOps.SetProperties(@params),
|
||||
"volume_list_effects" => VolumeOps.ListEffects(@params),
|
||||
"volume_create_profile" => VolumeOps.CreateProfile(@params),
|
||||
_ => new ErrorResponse($"Unknown volume action: '{action}'")
|
||||
};
|
||||
}
|
||||
|
||||
// --- Bake actions (always available, Edit mode only) ---
|
||||
case "bake_start":
|
||||
return LightBakingOps.StartBake(@params);
|
||||
case "bake_cancel":
|
||||
return LightBakingOps.CancelBake(@params);
|
||||
case "bake_status":
|
||||
return LightBakingOps.GetStatus(@params);
|
||||
case "bake_clear":
|
||||
return LightBakingOps.ClearBake(@params);
|
||||
case "bake_reflection_probe":
|
||||
return LightBakingOps.BakeReflectionProbe(@params);
|
||||
case "bake_get_settings":
|
||||
return LightBakingOps.GetSettings(@params);
|
||||
case "bake_set_settings":
|
||||
return LightBakingOps.SetSettings(@params);
|
||||
case "bake_create_light_probe_group":
|
||||
return LightBakingOps.CreateLightProbeGroup(@params);
|
||||
case "bake_create_reflection_probe":
|
||||
return LightBakingOps.CreateReflectionProbe(@params);
|
||||
case "bake_set_probe_positions":
|
||||
return LightBakingOps.SetProbePositions(@params);
|
||||
|
||||
// --- Stats actions (always available) ---
|
||||
case "stats_get":
|
||||
return RenderingStatsOps.GetStats(@params);
|
||||
case "stats_list_counters":
|
||||
return RenderingStatsOps.ListCounters(@params);
|
||||
case "stats_set_scene_debug":
|
||||
return RenderingStatsOps.SetSceneDebugMode(@params);
|
||||
case "stats_get_memory":
|
||||
return RenderingStatsOps.GetMemory(@params);
|
||||
|
||||
// --- Pipeline actions (always available) ---
|
||||
case "pipeline_get_info":
|
||||
return RenderPipelineOps.GetInfo(@params);
|
||||
case "pipeline_set_quality":
|
||||
return RenderPipelineOps.SetQuality(@params);
|
||||
case "pipeline_get_settings":
|
||||
return RenderPipelineOps.GetSettings(@params);
|
||||
case "pipeline_set_settings":
|
||||
return RenderPipelineOps.SetSettings(@params);
|
||||
|
||||
// --- Renderer feature actions (URP only) ---
|
||||
case "feature_list":
|
||||
case "feature_add":
|
||||
case "feature_remove":
|
||||
case "feature_configure":
|
||||
case "feature_toggle":
|
||||
case "feature_reorder":
|
||||
{
|
||||
if (!GraphicsHelpers.HasURP)
|
||||
return new ErrorResponse("Renderer features require URP (Universal Render Pipeline).");
|
||||
|
||||
return action switch
|
||||
{
|
||||
"feature_list" => RendererFeatureOps.ListFeatures(@params),
|
||||
"feature_add" => RendererFeatureOps.AddFeature(@params),
|
||||
"feature_remove" => RendererFeatureOps.RemoveFeature(@params),
|
||||
"feature_configure" => RendererFeatureOps.ConfigureFeature(@params),
|
||||
"feature_toggle" => RendererFeatureOps.ToggleFeature(@params),
|
||||
"feature_reorder" => RendererFeatureOps.ReorderFeatures(@params),
|
||||
_ => new ErrorResponse($"Unknown feature action: '{action}'")
|
||||
};
|
||||
}
|
||||
|
||||
// --- Skybox / Environment actions (always available) ---
|
||||
case "skybox_get":
|
||||
return SkyboxOps.GetEnvironment(@params);
|
||||
case "skybox_set_material":
|
||||
return SkyboxOps.SetMaterial(@params);
|
||||
case "skybox_set_properties":
|
||||
return SkyboxOps.SetMaterialProperties(@params);
|
||||
case "skybox_set_ambient":
|
||||
return SkyboxOps.SetAmbient(@params);
|
||||
case "skybox_set_fog":
|
||||
return SkyboxOps.SetFog(@params);
|
||||
case "skybox_set_reflection":
|
||||
return SkyboxOps.SetReflection(@params);
|
||||
case "skybox_set_sun":
|
||||
return SkyboxOps.SetSun(@params);
|
||||
|
||||
default:
|
||||
return new ErrorResponse(
|
||||
$"Unknown action: '{action}'. Valid actions: ping, "
|
||||
+ "volume_create, volume_add_effect, volume_set_effect, volume_remove_effect, "
|
||||
+ "volume_get_info, volume_set_properties, volume_list_effects, volume_create_profile, "
|
||||
+ "bake_start, bake_cancel, bake_status, bake_clear, bake_reflection_probe, "
|
||||
+ "bake_get_settings, bake_set_settings, bake_create_light_probe_group, "
|
||||
+ "bake_create_reflection_probe, bake_set_probe_positions, "
|
||||
+ "stats_get, stats_list_counters, stats_set_scene_debug, stats_get_memory, "
|
||||
+ "pipeline_get_info, pipeline_set_quality, pipeline_get_settings, pipeline_set_settings, "
|
||||
+ "feature_list, feature_add, feature_remove, feature_configure, feature_toggle, feature_reorder, "
|
||||
+ "skybox_get, skybox_set_material, skybox_set_properties, skybox_set_ambient, "
|
||||
+ "skybox_set_fog, skybox_set_reflection, skybox_set_sun.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Error($"[ManageGraphics] Action '{action}' failed: {ex}");
|
||||
return new ErrorResponse($"Error in action '{action}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dafb0cedb22e465b8f7b19e68a636415
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,286 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Graphics
|
||||
{
|
||||
internal static class RenderPipelineOps
|
||||
{
|
||||
// === pipeline_get_info ===
|
||||
// Returns: active pipeline, quality level, renderer info, key settings
|
||||
internal static object GetInfo(JObject @params)
|
||||
{
|
||||
var pipeline = RenderPipelineUtility.GetActivePipeline();
|
||||
var pipelineAsset = GraphicsSettings.currentRenderPipeline;
|
||||
|
||||
// Quality level info
|
||||
int currentQuality = QualitySettings.GetQualityLevel();
|
||||
string[] qualityNames = QualitySettings.names;
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["pipeline"] = pipeline.ToString(),
|
||||
["pipelineName"] = GraphicsHelpers.GetPipelineName(),
|
||||
["qualityLevel"] = currentQuality,
|
||||
["qualityLevelName"] = currentQuality < qualityNames.Length ? qualityNames[currentQuality] : "Unknown",
|
||||
["qualityLevels"] = qualityNames,
|
||||
["colorSpace"] = QualitySettings.activeColorSpace.ToString(),
|
||||
["hasVolumeSystem"] = GraphicsHelpers.HasVolumeSystem,
|
||||
};
|
||||
|
||||
// If SRP, add pipeline asset info
|
||||
if (pipelineAsset != null)
|
||||
{
|
||||
data["pipelineAsset"] = pipelineAsset.name;
|
||||
data["pipelineAssetPath"] = AssetDatabase.GetAssetPath(pipelineAsset);
|
||||
data["pipelineAssetType"] = pipelineAsset.GetType().Name;
|
||||
|
||||
// Read common public properties via reflection
|
||||
var settings = new Dictionary<string, object>();
|
||||
TryReadProperty(pipelineAsset, "renderScale", settings);
|
||||
TryReadProperty(pipelineAsset, "supportsHDR", settings);
|
||||
TryReadProperty(pipelineAsset, "msaaSampleCount", settings);
|
||||
TryReadProperty(pipelineAsset, "shadowDistance", settings);
|
||||
TryReadProperty(pipelineAsset, "shadowCascadeCount", settings);
|
||||
TryReadProperty(pipelineAsset, "maxAdditionalLightsCount", settings);
|
||||
TryReadProperty(pipelineAsset, "supportsSoftShadows", settings);
|
||||
TryReadProperty(pipelineAsset, "colorGradingMode", settings);
|
||||
|
||||
if (settings.Count > 0)
|
||||
data["settings"] = settings;
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Pipeline: {GraphicsHelpers.GetPipelineName()}, Quality: {(currentQuality < qualityNames.Length ? qualityNames[currentQuality] : "?")}",
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
// === pipeline_set_quality ===
|
||||
// Params: level (int or string name)
|
||||
internal static object SetQuality(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
string levelName = p.Get("level");
|
||||
int? levelIndex = p.GetInt("level");
|
||||
|
||||
string[] names = QualitySettings.names;
|
||||
int targetIndex = -1;
|
||||
|
||||
if (levelIndex.HasValue)
|
||||
{
|
||||
targetIndex = levelIndex.Value;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(levelName))
|
||||
{
|
||||
// Try exact match first
|
||||
for (int i = 0; i < names.Length; i++)
|
||||
{
|
||||
if (string.Equals(names[i], levelName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
targetIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Try parse as int
|
||||
if (targetIndex < 0 && int.TryParse(levelName, out int parsed))
|
||||
targetIndex = parsed;
|
||||
}
|
||||
else
|
||||
{
|
||||
return new ErrorResponse($"'level' parameter required. Available: {string.Join(", ", names)}");
|
||||
}
|
||||
|
||||
if (targetIndex < 0 || targetIndex >= names.Length)
|
||||
return new ErrorResponse(
|
||||
$"Invalid quality level. Available: {string.Join(", ", names)} (0-{names.Length - 1})");
|
||||
|
||||
QualitySettings.SetQualityLevel(targetIndex, true);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Quality level set to '{names[targetIndex]}' (index {targetIndex}).",
|
||||
data = new
|
||||
{
|
||||
level = targetIndex,
|
||||
name = names[targetIndex],
|
||||
allLevels = names
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// === pipeline_get_settings ===
|
||||
// Detailed read of pipeline asset settings via public properties + SerializedObject fallback
|
||||
internal static object GetSettings(JObject @params)
|
||||
{
|
||||
var pipelineAsset = GraphicsSettings.currentRenderPipeline;
|
||||
if (pipelineAsset == null)
|
||||
return new ErrorResponse("No render pipeline asset found (Built-in pipeline has no asset).");
|
||||
|
||||
var settings = new Dictionary<string, object>();
|
||||
|
||||
// Public properties (URP)
|
||||
string[] publicProps = {
|
||||
"renderScale", "supportsHDR", "msaaSampleCount", "shadowDistance",
|
||||
"shadowCascadeCount", "mainLightShadowmapResolution",
|
||||
"additionalLightsShadowmapResolution", "maxAdditionalLightsCount",
|
||||
"supportsSoftShadows", "colorGradingMode", "colorGradingLutSize"
|
||||
};
|
||||
foreach (var propName in publicProps)
|
||||
TryReadProperty(pipelineAsset, propName, settings);
|
||||
|
||||
// SerializedObject for non-public settings
|
||||
var serializedSettings = new Dictionary<string, object>();
|
||||
string[] serializedPaths = {
|
||||
"m_DefaultRendererIndex", "m_MainLightRenderingMode",
|
||||
"m_AdditionalLightsRenderingMode", "m_SupportsOpaqueTexture",
|
||||
"m_SupportsDepthTexture"
|
||||
};
|
||||
|
||||
using (var so = new SerializedObject(pipelineAsset))
|
||||
{
|
||||
foreach (var path in serializedPaths)
|
||||
{
|
||||
var prop = so.FindProperty(path);
|
||||
if (prop != null)
|
||||
serializedSettings[path] = GraphicsHelpers.ReadSerializedValue(prop);
|
||||
}
|
||||
}
|
||||
|
||||
if (serializedSettings.Count > 0)
|
||||
settings["_serialized"] = serializedSettings;
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Pipeline settings for '{pipelineAsset.name}'.",
|
||||
data = new
|
||||
{
|
||||
assetName = pipelineAsset.name,
|
||||
assetPath = AssetDatabase.GetAssetPath(pipelineAsset),
|
||||
assetType = pipelineAsset.GetType().Name,
|
||||
settings
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// === pipeline_set_settings ===
|
||||
// Write pipeline asset settings via public properties + SerializedObject fallback
|
||||
internal static object SetSettings(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
var settingsToken = p.GetRaw("settings") as JObject;
|
||||
if (settingsToken == null)
|
||||
return new ErrorResponse("'settings' dict is required.");
|
||||
|
||||
var pipelineAsset = GraphicsSettings.currentRenderPipeline;
|
||||
if (pipelineAsset == null)
|
||||
return new ErrorResponse("No render pipeline asset found.");
|
||||
|
||||
var changed = new List<string>();
|
||||
var failed = new List<string>();
|
||||
|
||||
using (var so = new SerializedObject(pipelineAsset))
|
||||
{
|
||||
foreach (var prop in settingsToken.Properties())
|
||||
{
|
||||
string propName = prop.Name;
|
||||
JToken value = prop.Value;
|
||||
|
||||
// Try public property first
|
||||
var publicProp = pipelineAsset.GetType().GetProperty(propName,
|
||||
BindingFlags.Public | BindingFlags.Instance);
|
||||
if (publicProp != null && publicProp.CanWrite)
|
||||
{
|
||||
try
|
||||
{
|
||||
object converted = ConvertPropertyValue(value, publicProp.PropertyType);
|
||||
publicProp.SetValue(pipelineAsset, converted);
|
||||
changed.Add(propName);
|
||||
continue;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Warn($"[RenderPipelineOps] Failed to set '{propName}' via property: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Try SerializedObject fallback (for m_ prefixed properties)
|
||||
string serializedPath = propName.StartsWith("m_") ? propName : $"m_{char.ToUpper(propName[0])}{propName.Substring(1)}";
|
||||
var sProp = so.FindProperty(serializedPath);
|
||||
if (sProp == null && !propName.StartsWith("m_"))
|
||||
sProp = so.FindProperty(propName);
|
||||
|
||||
if (sProp != null)
|
||||
{
|
||||
if (GraphicsHelpers.SetSerializedValue(sProp, value))
|
||||
{
|
||||
changed.Add(propName);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
failed.Add(propName);
|
||||
}
|
||||
so.ApplyModifiedProperties();
|
||||
}
|
||||
|
||||
EditorUtility.SetDirty(pipelineAsset);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
var msg = $"Updated {changed.Count} pipeline setting(s)";
|
||||
if (failed.Count > 0)
|
||||
msg += $". Failed: {string.Join(", ", failed)}";
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = msg,
|
||||
data = new { changed, failed }
|
||||
};
|
||||
}
|
||||
|
||||
// --- Helper: Convert JToken to target property type ---
|
||||
private static object ConvertPropertyValue(JToken value, Type targetType)
|
||||
{
|
||||
if (targetType == typeof(bool)) return ParamCoercion.CoerceBool(value, false);
|
||||
if (targetType == typeof(int)) return ParamCoercion.CoerceInt(value, 0);
|
||||
if (targetType == typeof(float)) return ParamCoercion.CoerceFloat(value, 0f);
|
||||
if (targetType == typeof(string)) return value.ToString();
|
||||
if (targetType.IsEnum)
|
||||
{
|
||||
string str = value.ToString();
|
||||
if (Enum.TryParse(targetType, str, true, out object enumVal))
|
||||
return enumVal;
|
||||
if (int.TryParse(str, out int intVal))
|
||||
return Enum.ToObject(targetType, intVal);
|
||||
}
|
||||
return Convert.ChangeType(value.ToObject<object>(), targetType);
|
||||
}
|
||||
|
||||
// --- Helper: Try to read a property value via reflection ---
|
||||
private static void TryReadProperty(object obj, string propertyName, Dictionary<string, object> target)
|
||||
{
|
||||
if (obj == null) return;
|
||||
var prop = obj.GetType().GetProperty(propertyName,
|
||||
BindingFlags.Public | BindingFlags.Instance);
|
||||
if (prop != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var val = prop.GetValue(obj);
|
||||
target[propertyName] = val is Enum e ? e.ToString() : val;
|
||||
}
|
||||
catch { /* skip unreadable properties */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2e637fccb0c440e4b8cdb3bc6040c7c9
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,577 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
using MCPForUnity.Runtime.Helpers;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Graphics
|
||||
{
|
||||
internal static class RendererFeatureOps
|
||||
{
|
||||
// Cached URP types (resolved via reflection to avoid hard dependency)
|
||||
private static Type _scriptableRendererDataType;
|
||||
private static Type _scriptableRendererFeatureType;
|
||||
private static Type _universalRenderPipelineAssetType;
|
||||
private static bool _typesResolved;
|
||||
|
||||
private static void EnsureTypes()
|
||||
{
|
||||
if (_typesResolved) return;
|
||||
_typesResolved = true;
|
||||
|
||||
_scriptableRendererDataType = Type.GetType(
|
||||
"UnityEngine.Rendering.Universal.ScriptableRendererData, Unity.RenderPipelines.Universal.Runtime");
|
||||
_scriptableRendererFeatureType = Type.GetType(
|
||||
"UnityEngine.Rendering.Universal.ScriptableRendererFeature, Unity.RenderPipelines.Universal.Runtime");
|
||||
_universalRenderPipelineAssetType = Type.GetType(
|
||||
"UnityEngine.Rendering.Universal.UniversalRenderPipelineAsset, Unity.RenderPipelines.Universal.Runtime");
|
||||
}
|
||||
|
||||
// === feature_list ===
|
||||
internal static object ListFeatures(JObject @params)
|
||||
{
|
||||
var rendererData = GetRendererData(@params);
|
||||
if (rendererData == null)
|
||||
return new ErrorResponse("Could not find URP ScriptableRendererData. Ensure URP is active.");
|
||||
|
||||
var featuresProp = rendererData.GetType().GetProperty("rendererFeatures",
|
||||
BindingFlags.Public | BindingFlags.Instance);
|
||||
if (featuresProp == null)
|
||||
return new ErrorResponse("rendererFeatures property not found on renderer data.");
|
||||
|
||||
var featuresList = featuresProp.GetValue(rendererData) as System.Collections.IList;
|
||||
if (featuresList == null)
|
||||
return new { success = true, message = "No renderer features.", data = new { features = new object[0] } };
|
||||
|
||||
var features = new List<object>();
|
||||
for (int i = 0; i < featuresList.Count; i++)
|
||||
{
|
||||
var feature = featuresList[i] as ScriptableObject;
|
||||
if (feature == null) continue;
|
||||
|
||||
var isActiveProp = feature.GetType().GetProperty("isActive",
|
||||
BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
features.Add(new
|
||||
{
|
||||
index = i,
|
||||
name = feature.name,
|
||||
type = feature.GetType().Name,
|
||||
isActive = isActiveProp != null ? (bool)isActiveProp.GetValue(feature) : true,
|
||||
properties = GetFeatureProperties(feature)
|
||||
});
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Found {features.Count} renderer feature(s).",
|
||||
data = new
|
||||
{
|
||||
rendererDataName = (rendererData as ScriptableObject)?.name,
|
||||
features
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// === feature_add ===
|
||||
internal static object AddFeature(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
string typeName = p.Get("type");
|
||||
if (string.IsNullOrEmpty(typeName))
|
||||
return new ErrorResponse("'type' parameter required (e.g., 'FullScreenPassRendererFeature', 'RenderObjects').");
|
||||
|
||||
var rendererData = GetRendererData(@params);
|
||||
if (rendererData == null)
|
||||
return new ErrorResponse("Could not find URP ScriptableRendererData.");
|
||||
|
||||
EnsureTypes();
|
||||
if (_scriptableRendererFeatureType == null)
|
||||
return new ErrorResponse("ScriptableRendererFeature type not found. Is URP installed?");
|
||||
|
||||
// Resolve the feature type
|
||||
var featureType = ResolveFeatureType(typeName);
|
||||
if (featureType == null)
|
||||
{
|
||||
var available = GetAvailableFeatureTypes();
|
||||
return new ErrorResponse(
|
||||
$"Feature type '{typeName}' not found. Available: {string.Join(", ", available.Select(t => t.Name))}");
|
||||
}
|
||||
|
||||
// Create the feature instance
|
||||
var feature = ScriptableObject.CreateInstance(featureType);
|
||||
if (feature == null)
|
||||
return new ErrorResponse($"Failed to create instance of '{featureType.Name}'.");
|
||||
|
||||
string displayName = p.Get("name") ?? featureType.Name;
|
||||
feature.name = displayName;
|
||||
|
||||
// Add to the renderer data asset
|
||||
Undo.RecordObject(rendererData as UnityEngine.Object, "Add Renderer Feature");
|
||||
AssetDatabase.AddObjectToAsset(feature, rendererData as UnityEngine.Object);
|
||||
|
||||
// Add to the features list via SerializedObject
|
||||
using (var so = new SerializedObject(rendererData as UnityEngine.Object))
|
||||
{
|
||||
var rendererFeaturesProp = so.FindProperty("m_RendererFeatures");
|
||||
if (rendererFeaturesProp != null)
|
||||
{
|
||||
rendererFeaturesProp.arraySize++;
|
||||
var element = rendererFeaturesProp.GetArrayElementAtIndex(rendererFeaturesProp.arraySize - 1);
|
||||
element.objectReferenceValue = feature;
|
||||
so.ApplyModifiedProperties();
|
||||
}
|
||||
|
||||
// Also update the map (m_RendererFeatureMap) if it exists
|
||||
// Map stores persistent local file IDs, not transient instance IDs
|
||||
var mapProp = so.FindProperty("m_RendererFeatureMap");
|
||||
if (mapProp != null)
|
||||
{
|
||||
long localId = 0;
|
||||
AssetDatabase.TryGetGUIDAndLocalFileIdentifier(feature, out _, out localId);
|
||||
mapProp.arraySize++;
|
||||
var mapElement = mapProp.GetArrayElementAtIndex(mapProp.arraySize - 1);
|
||||
mapElement.longValue = localId;
|
||||
so.ApplyModifiedProperties();
|
||||
}
|
||||
}
|
||||
|
||||
// Configure initial properties if provided
|
||||
var propertiesToken = p.GetRaw("properties") as JObject;
|
||||
if (propertiesToken != null)
|
||||
ApplyFeatureProperties(feature, propertiesToken);
|
||||
|
||||
// Set material if provided (common for FullScreenPass)
|
||||
string materialPath = p.Get("material");
|
||||
if (!string.IsNullOrEmpty(materialPath))
|
||||
TrySetMaterial(feature, materialPath);
|
||||
|
||||
EditorUtility.SetDirty(rendererData as UnityEngine.Object);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Added renderer feature '{displayName}' ({featureType.Name}).",
|
||||
data = new
|
||||
{
|
||||
name = displayName,
|
||||
type = featureType.Name,
|
||||
instanceId = feature.GetInstanceIDCompat()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// === feature_remove ===
|
||||
internal static object RemoveFeature(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
int? index = p.GetInt("index");
|
||||
string name = p.Get("name");
|
||||
|
||||
var rendererData = GetRendererData(@params);
|
||||
if (rendererData == null)
|
||||
return new ErrorResponse("Could not find URP ScriptableRendererData.");
|
||||
|
||||
var featuresProp = rendererData.GetType().GetProperty("rendererFeatures",
|
||||
BindingFlags.Public | BindingFlags.Instance);
|
||||
if (featuresProp == null)
|
||||
return new ErrorResponse("rendererFeatures property not found.");
|
||||
|
||||
var featuresList = featuresProp.GetValue(rendererData) as System.Collections.IList;
|
||||
if (featuresList == null || featuresList.Count == 0)
|
||||
return new ErrorResponse("No renderer features to remove.");
|
||||
|
||||
int targetIndex = ResolveFeatureIndex(featuresList, index, name);
|
||||
if (targetIndex < 0)
|
||||
return new ErrorResponse($"Feature not found. Specify 'index' (0-{featuresList.Count - 1}) or 'name'.");
|
||||
|
||||
var feature = featuresList[targetIndex] as ScriptableObject;
|
||||
string featureName = feature?.name ?? "Unknown";
|
||||
|
||||
Undo.RecordObject(rendererData as UnityEngine.Object, "Remove Renderer Feature");
|
||||
|
||||
// Remove from the list via SerializedObject
|
||||
using (var so = new SerializedObject(rendererData as UnityEngine.Object))
|
||||
{
|
||||
var rendererFeaturesPropSo = so.FindProperty("m_RendererFeatures");
|
||||
if (rendererFeaturesPropSo != null)
|
||||
{
|
||||
rendererFeaturesPropSo.DeleteArrayElementAtIndex(targetIndex);
|
||||
// SerializedProperty.DeleteArrayElementAtIndex sets to null first for ObjectReference
|
||||
if (rendererFeaturesPropSo.arraySize > targetIndex)
|
||||
{
|
||||
var element = rendererFeaturesPropSo.GetArrayElementAtIndex(targetIndex);
|
||||
if (element.objectReferenceValue == null)
|
||||
rendererFeaturesPropSo.DeleteArrayElementAtIndex(targetIndex);
|
||||
}
|
||||
so.ApplyModifiedProperties();
|
||||
}
|
||||
|
||||
// Clean up the map
|
||||
var mapProp = so.FindProperty("m_RendererFeatureMap");
|
||||
if (mapProp != null && targetIndex < mapProp.arraySize)
|
||||
{
|
||||
mapProp.DeleteArrayElementAtIndex(targetIndex);
|
||||
so.ApplyModifiedProperties();
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the sub-asset
|
||||
if (feature != null)
|
||||
AssetDatabase.RemoveObjectFromAsset(feature);
|
||||
|
||||
EditorUtility.SetDirty(rendererData as UnityEngine.Object);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Removed renderer feature '{featureName}' at index {targetIndex}."
|
||||
};
|
||||
}
|
||||
|
||||
// === feature_configure ===
|
||||
internal static object ConfigureFeature(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
int? index = p.GetInt("index");
|
||||
string name = p.Get("name");
|
||||
var propertiesToken = (p.GetRaw("properties") ?? p.GetRaw("settings")) as JObject;
|
||||
|
||||
if (propertiesToken == null)
|
||||
return new ErrorResponse("'properties' (or 'settings') dict is required.");
|
||||
|
||||
var rendererData = GetRendererData(@params);
|
||||
if (rendererData == null)
|
||||
return new ErrorResponse("Could not find URP ScriptableRendererData.");
|
||||
|
||||
var featuresProp = rendererData.GetType().GetProperty("rendererFeatures",
|
||||
BindingFlags.Public | BindingFlags.Instance);
|
||||
var featuresList = featuresProp?.GetValue(rendererData) as System.Collections.IList;
|
||||
if (featuresList == null || featuresList.Count == 0)
|
||||
return new ErrorResponse("No renderer features to configure.");
|
||||
|
||||
int targetIndex = ResolveFeatureIndex(featuresList, index, name);
|
||||
if (targetIndex < 0)
|
||||
return new ErrorResponse($"Feature not found. Specify 'index' (0-{featuresList.Count - 1}) or 'name'.");
|
||||
|
||||
var feature = featuresList[targetIndex] as ScriptableObject;
|
||||
if (feature == null)
|
||||
return new ErrorResponse($"Feature at index {targetIndex} is null.");
|
||||
|
||||
Undo.RecordObject(feature, "Configure Renderer Feature");
|
||||
var result = ApplyFeatureProperties(feature, propertiesToken);
|
||||
EditorUtility.SetDirty(feature);
|
||||
EditorUtility.SetDirty(rendererData as UnityEngine.Object);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Configured '{feature.name}': {result.changed.Count} set, {result.failed.Count} failed.",
|
||||
data = new { result.changed, result.failed }
|
||||
};
|
||||
}
|
||||
|
||||
// === feature_toggle ===
|
||||
internal static object ToggleFeature(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
int? index = p.GetInt("index");
|
||||
string name = p.Get("name");
|
||||
bool? active = p.GetBool("active");
|
||||
|
||||
var rendererData = GetRendererData(@params);
|
||||
if (rendererData == null)
|
||||
return new ErrorResponse("Could not find URP ScriptableRendererData.");
|
||||
|
||||
var featuresProp = rendererData.GetType().GetProperty("rendererFeatures",
|
||||
BindingFlags.Public | BindingFlags.Instance);
|
||||
var featuresList = featuresProp?.GetValue(rendererData) as System.Collections.IList;
|
||||
if (featuresList == null || featuresList.Count == 0)
|
||||
return new ErrorResponse("No renderer features.");
|
||||
|
||||
int targetIndex = ResolveFeatureIndex(featuresList, index, name);
|
||||
if (targetIndex < 0)
|
||||
return new ErrorResponse($"Feature not found. Specify 'index' or 'name'.");
|
||||
|
||||
var feature = featuresList[targetIndex] as ScriptableObject;
|
||||
if (feature == null)
|
||||
return new ErrorResponse($"Feature at index {targetIndex} is null.");
|
||||
|
||||
// ScriptableRendererFeature.SetActive(bool) is public
|
||||
var setActiveMethod = feature.GetType().GetMethod("SetActive",
|
||||
BindingFlags.Public | BindingFlags.Instance);
|
||||
if (setActiveMethod == null)
|
||||
return new ErrorResponse("SetActive method not found on feature.");
|
||||
|
||||
bool newState = active ?? true;
|
||||
Undo.RecordObject(feature, "Toggle Renderer Feature");
|
||||
setActiveMethod.Invoke(feature, new object[] { newState });
|
||||
EditorUtility.SetDirty(feature);
|
||||
EditorUtility.SetDirty(rendererData as UnityEngine.Object);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Feature '{feature.name}' {(newState ? "enabled" : "disabled")}."
|
||||
};
|
||||
}
|
||||
|
||||
// === feature_reorder ===
|
||||
internal static object ReorderFeatures(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
var orderToken = p.GetRaw("order") as JArray;
|
||||
if (orderToken == null)
|
||||
return new ErrorResponse("'order' parameter required (array of indices, e.g. [2, 0, 1]).");
|
||||
|
||||
var rendererData = GetRendererData(@params);
|
||||
if (rendererData == null)
|
||||
return new ErrorResponse("Could not find URP ScriptableRendererData.");
|
||||
|
||||
var featuresProp = rendererData.GetType().GetProperty("rendererFeatures",
|
||||
BindingFlags.Public | BindingFlags.Instance);
|
||||
var featuresList = featuresProp?.GetValue(rendererData) as System.Collections.IList;
|
||||
if (featuresList == null || featuresList.Count == 0)
|
||||
return new ErrorResponse("No renderer features to reorder.");
|
||||
|
||||
var newOrder = orderToken.Select(t => (int)t).ToList();
|
||||
if (newOrder.Count != featuresList.Count)
|
||||
return new ErrorResponse(
|
||||
$"Order array length ({newOrder.Count}) must match feature count ({featuresList.Count}).");
|
||||
|
||||
// Validate all indices are present
|
||||
var sorted = newOrder.OrderBy(x => x).ToList();
|
||||
for (int i = 0; i < sorted.Count; i++)
|
||||
{
|
||||
if (sorted[i] != i)
|
||||
return new ErrorResponse("Order array must contain each index exactly once (0 to N-1).");
|
||||
}
|
||||
|
||||
Undo.RecordObject(rendererData as UnityEngine.Object, "Reorder Renderer Features");
|
||||
|
||||
using (var so = new SerializedObject(rendererData as UnityEngine.Object))
|
||||
{
|
||||
var rendererFeaturesPropSo = so.FindProperty("m_RendererFeatures");
|
||||
if (rendererFeaturesPropSo == null)
|
||||
return new ErrorResponse("m_RendererFeatures property not found.");
|
||||
|
||||
// Read current features
|
||||
var current = new UnityEngine.Object[featuresList.Count];
|
||||
for (int i = 0; i < featuresList.Count; i++)
|
||||
current[i] = rendererFeaturesPropSo.GetArrayElementAtIndex(i).objectReferenceValue;
|
||||
|
||||
// Apply new order
|
||||
for (int i = 0; i < newOrder.Count; i++)
|
||||
rendererFeaturesPropSo.GetArrayElementAtIndex(i).objectReferenceValue = current[newOrder[i]];
|
||||
|
||||
// Also reorder the feature map to keep it in sync
|
||||
var mapProp = so.FindProperty("m_RendererFeatureMap");
|
||||
if (mapProp != null && mapProp.arraySize == featuresList.Count)
|
||||
{
|
||||
var currentMap = new long[featuresList.Count];
|
||||
for (int i = 0; i < featuresList.Count; i++)
|
||||
currentMap[i] = mapProp.GetArrayElementAtIndex(i).longValue;
|
||||
for (int i = 0; i < newOrder.Count; i++)
|
||||
mapProp.GetArrayElementAtIndex(i).longValue = currentMap[newOrder[i]];
|
||||
}
|
||||
|
||||
so.ApplyModifiedProperties();
|
||||
}
|
||||
|
||||
EditorUtility.SetDirty(rendererData as UnityEngine.Object);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Reordered {featuresList.Count} renderer features."
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
private static object GetRendererData(JObject @params)
|
||||
{
|
||||
EnsureTypes();
|
||||
if (_universalRenderPipelineAssetType == null || _scriptableRendererDataType == null)
|
||||
return null;
|
||||
|
||||
var pipelineAsset = GraphicsSettings.currentRenderPipeline;
|
||||
if (pipelineAsset == null || !_universalRenderPipelineAssetType.IsInstanceOfType(pipelineAsset))
|
||||
return null;
|
||||
|
||||
var p = new ToolParams(@params);
|
||||
int rendererIndex = p.GetInt("renderer_index") ?? -1;
|
||||
|
||||
// Get renderer data from the URP asset
|
||||
// Try scriptableRendererData property or GetRenderer method
|
||||
if (rendererIndex >= 0)
|
||||
{
|
||||
// Use SerializedObject to get specific renderer
|
||||
using (var so = new SerializedObject(pipelineAsset))
|
||||
{
|
||||
var renderersProp = so.FindProperty("m_RendererDataList");
|
||||
if (renderersProp == null || rendererIndex >= renderersProp.arraySize)
|
||||
return null;
|
||||
|
||||
var element = renderersProp.GetArrayElementAtIndex(rendererIndex);
|
||||
return element.objectReferenceValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Default: get the active renderer (index from m_DefaultRendererIndex)
|
||||
using (var so = new SerializedObject(pipelineAsset))
|
||||
{
|
||||
var defaultIndex = so.FindProperty("m_DefaultRendererIndex");
|
||||
int idx = defaultIndex != null ? defaultIndex.intValue : 0;
|
||||
|
||||
var renderersProp = so.FindProperty("m_RendererDataList");
|
||||
if (renderersProp != null && idx < renderersProp.arraySize)
|
||||
{
|
||||
var element = renderersProp.GetArrayElementAtIndex(idx);
|
||||
return element.objectReferenceValue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Type ResolveFeatureType(string typeName)
|
||||
{
|
||||
EnsureTypes();
|
||||
if (_scriptableRendererFeatureType == null) return null;
|
||||
|
||||
var derivedTypes = TypeCache.GetTypesDerivedFrom(_scriptableRendererFeatureType);
|
||||
foreach (var t in derivedTypes)
|
||||
{
|
||||
if (t.IsAbstract) continue;
|
||||
if (string.Equals(t.Name, typeName, StringComparison.OrdinalIgnoreCase))
|
||||
return t;
|
||||
}
|
||||
|
||||
// Try partial match (e.g., "FullScreenPass" matches "FullScreenPassRendererFeature")
|
||||
foreach (var t in derivedTypes)
|
||||
{
|
||||
if (t.IsAbstract) continue;
|
||||
if (t.Name.StartsWith(typeName, StringComparison.OrdinalIgnoreCase))
|
||||
return t;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<Type> GetAvailableFeatureTypes()
|
||||
{
|
||||
EnsureTypes();
|
||||
if (_scriptableRendererFeatureType == null) return new List<Type>();
|
||||
|
||||
return TypeCache.GetTypesDerivedFrom(_scriptableRendererFeatureType)
|
||||
.Where(t => !t.IsAbstract && !t.IsGenericType)
|
||||
.OrderBy(t => t.Name)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static int ResolveFeatureIndex(System.Collections.IList featuresList, int? index, string name)
|
||||
{
|
||||
if (index.HasValue && index.Value >= 0 && index.Value < featuresList.Count)
|
||||
return index.Value;
|
||||
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
for (int i = 0; i < featuresList.Count; i++)
|
||||
{
|
||||
var feature = featuresList[i] as ScriptableObject;
|
||||
if (feature == null) continue;
|
||||
if (string.Equals(feature.name, name, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(feature.GetType().Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> GetFeatureProperties(ScriptableObject feature)
|
||||
{
|
||||
var props = new Dictionary<string, object>();
|
||||
using (var so = new SerializedObject(feature))
|
||||
{
|
||||
var iterator = so.GetIterator();
|
||||
if (iterator.NextVisible(true)) // Enter children
|
||||
{
|
||||
do
|
||||
{
|
||||
// Skip Unity internal properties
|
||||
if (iterator.name == "m_Script" || iterator.name == "m_ObjectHideFlags" || iterator.name == "m_Name")
|
||||
continue;
|
||||
|
||||
props[iterator.name] = GraphicsHelpers.ReadSerializedValue(iterator);
|
||||
} while (iterator.NextVisible(false));
|
||||
}
|
||||
}
|
||||
return props;
|
||||
}
|
||||
|
||||
private static (List<string> changed, List<string> failed) ApplyFeatureProperties(
|
||||
ScriptableObject feature, JObject propertiesToken)
|
||||
{
|
||||
var changed = new List<string>();
|
||||
var failed = new List<string>();
|
||||
|
||||
using (var so = new SerializedObject(feature))
|
||||
{
|
||||
foreach (var prop in propertiesToken.Properties())
|
||||
{
|
||||
var sProp = so.FindProperty(prop.Name);
|
||||
if (sProp != null)
|
||||
{
|
||||
if (GraphicsHelpers.SetSerializedValue(sProp, prop.Value))
|
||||
changed.Add(prop.Name);
|
||||
else
|
||||
failed.Add(prop.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Try nested: "settings.fieldName"
|
||||
string nested = $"settings.{prop.Name}";
|
||||
sProp = so.FindProperty(nested);
|
||||
if (sProp != null && GraphicsHelpers.SetSerializedValue(sProp, prop.Value))
|
||||
changed.Add(prop.Name);
|
||||
else
|
||||
failed.Add(prop.Name);
|
||||
}
|
||||
}
|
||||
so.ApplyModifiedProperties();
|
||||
}
|
||||
|
||||
return (changed, failed);
|
||||
}
|
||||
|
||||
private static void TrySetMaterial(ScriptableObject feature, string materialPath)
|
||||
{
|
||||
var mat = AssetDatabase.LoadAssetAtPath<Material>(materialPath);
|
||||
if (mat == null) return;
|
||||
|
||||
using (var so = new SerializedObject(feature))
|
||||
{
|
||||
// FullScreenPassRendererFeature uses "m_PassMaterial" or "passMaterial"
|
||||
var matProp = so.FindProperty("m_PassMaterial") ?? so.FindProperty("passMaterial");
|
||||
if (matProp != null && matProp.propertyType == SerializedPropertyType.ObjectReference)
|
||||
{
|
||||
matProp.objectReferenceValue = mat;
|
||||
so.ApplyModifiedProperties();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5a75368a2f6b478fbaa88a36c677b5af
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,171 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using Unity.Profiling;
|
||||
using Unity.Profiling.LowLevel.Unsafe;
|
||||
using UnityEngine.Profiling;
|
||||
using UProfiler = UnityEngine.Profiling.Profiler;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Graphics
|
||||
{
|
||||
internal static class RenderingStatsOps
|
||||
{
|
||||
private static readonly (string counterName, string jsonKey)[] COUNTER_MAP = new[]
|
||||
{
|
||||
("Draw Calls Count", "draw_calls"),
|
||||
("Batches Count", "batches"),
|
||||
("SetPass Calls Count", "set_pass_calls"),
|
||||
("Triangles Count", "triangles"),
|
||||
("Vertices Count", "vertices"),
|
||||
("Dynamic Batches Count", "dynamic_batches"),
|
||||
("Dynamic Batched Draw Calls Count", "dynamic_batched_draw_calls"),
|
||||
("Static Batches Count", "static_batches"),
|
||||
("Static Batched Draw Calls Count", "static_batched_draw_calls"),
|
||||
("Instanced Batches Count", "instanced_batches"),
|
||||
("Instanced Batched Draw Calls Count", "instanced_batched_draw_calls"),
|
||||
("Shadow Casters Count", "shadow_casters"),
|
||||
("Render Textures Count", "render_textures"),
|
||||
("Render Textures Bytes", "render_textures_bytes"),
|
||||
("Used Textures Count", "used_textures"),
|
||||
("Used Textures Bytes", "used_textures_bytes"),
|
||||
("Render Textures Changes Count", "render_target_changes"),
|
||||
("Visible Skinned Meshes Count", "visible_skinned_meshes"),
|
||||
};
|
||||
|
||||
// === stats_get ===
|
||||
internal static object GetStats(JObject @params)
|
||||
{
|
||||
var stats = new Dictionary<string, object>();
|
||||
|
||||
foreach (var (counterName, jsonKey) in COUNTER_MAP)
|
||||
{
|
||||
using var recorder = ProfilerRecorder.StartNew(ProfilerCategory.Render, counterName);
|
||||
stats[jsonKey] = recorder.Valid ? recorder.CurrentValue : 0;
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = "Rendering stats captured.",
|
||||
data = stats
|
||||
};
|
||||
}
|
||||
|
||||
// === stats_list_counters ===
|
||||
internal static object ListCounters(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
string categoryName = p.Get("category");
|
||||
|
||||
// Default to "Render" category to avoid massive payloads (all categories = 300K+ chars)
|
||||
ProfilerCategory category = ProfilerCategory.Render;
|
||||
if (!string.IsNullOrEmpty(categoryName))
|
||||
{
|
||||
category = TryResolveCategory(categoryName);
|
||||
}
|
||||
|
||||
var allHandles = new List<ProfilerRecorderHandle>();
|
||||
ProfilerRecorderHandle.GetAvailable(allHandles);
|
||||
var counters = allHandles
|
||||
.Select(h => ProfilerRecorderHandle.GetDescription(h))
|
||||
.Where(d => string.Equals(d.Category.Name, category.Name, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(d => new
|
||||
{
|
||||
name = d.Name,
|
||||
category = d.Category.Name,
|
||||
unit = d.UnitType.ToString()
|
||||
})
|
||||
.OrderBy(c => c.name).ToList();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Found {counters.Count} counters in category '{category.Name}'.",
|
||||
data = new { counters }
|
||||
};
|
||||
}
|
||||
|
||||
// === stats_set_scene_debug_mode ===
|
||||
internal static object SetSceneDebugMode(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
string modeName = p.Get("mode");
|
||||
if (string.IsNullOrEmpty(modeName))
|
||||
{
|
||||
var validModes = string.Join(", ", Enum.GetNames(typeof(DrawCameraMode)).Take(20));
|
||||
return new ErrorResponse(
|
||||
$"'mode' parameter required. Options: {validModes}");
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<DrawCameraMode>(modeName, true, out var drawMode))
|
||||
{
|
||||
var validModes = string.Join(", ", Enum.GetNames(typeof(DrawCameraMode)).Take(20));
|
||||
return new ErrorResponse($"Unknown mode '{modeName}'. Valid: {validModes}");
|
||||
}
|
||||
|
||||
var sceneView = SceneView.lastActiveSceneView;
|
||||
if (sceneView == null)
|
||||
return new ErrorResponse("No active Scene View found.");
|
||||
|
||||
sceneView.cameraMode = SceneView.GetBuiltinCameraMode(drawMode);
|
||||
|
||||
sceneView.Repaint();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Scene debug mode set to '{drawMode}'."
|
||||
};
|
||||
}
|
||||
|
||||
// === stats_get_memory ===
|
||||
internal static object GetMemory(JObject @params)
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["totalAllocatedMB"] = Math.Round(UProfiler.GetTotalAllocatedMemoryLong() / (1024.0 * 1024.0), 2),
|
||||
["totalReservedMB"] = Math.Round(UProfiler.GetTotalReservedMemoryLong() / (1024.0 * 1024.0), 2),
|
||||
["totalUnusedReservedMB"] = Math.Round(UProfiler.GetTotalUnusedReservedMemoryLong() / (1024.0 * 1024.0), 2),
|
||||
["monoUsedMB"] = Math.Round(UProfiler.GetMonoUsedSizeLong() / (1024.0 * 1024.0), 2),
|
||||
["monoHeapMB"] = Math.Round(UProfiler.GetMonoHeapSizeLong() / (1024.0 * 1024.0), 2),
|
||||
["graphicsDriverMB"] = Math.Round(UProfiler.GetAllocatedMemoryForGraphicsDriver() / (1024.0 * 1024.0), 2),
|
||||
};
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = "Memory stats captured.",
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
// --- Helper: Try to resolve a ProfilerCategory by name ---
|
||||
private static ProfilerCategory TryResolveCategory(string name)
|
||||
{
|
||||
// ProfilerCategory has static properties for well-known categories
|
||||
switch (name.ToLowerInvariant())
|
||||
{
|
||||
case "render": return ProfilerCategory.Render;
|
||||
case "scripts": return ProfilerCategory.Scripts;
|
||||
case "memory": return ProfilerCategory.Memory;
|
||||
case "physics": return ProfilerCategory.Physics;
|
||||
case "animation": return ProfilerCategory.Animation;
|
||||
case "audio": return ProfilerCategory.Audio;
|
||||
case "lighting": return ProfilerCategory.Lighting;
|
||||
case "network": return ProfilerCategory.Network;
|
||||
case "gui": return ProfilerCategory.Gui;
|
||||
case "ai": return ProfilerCategory.Ai;
|
||||
case "video": return ProfilerCategory.Video;
|
||||
case "loading": return ProfilerCategory.Loading;
|
||||
case "input": return ProfilerCategory.Input;
|
||||
case "vr": return ProfilerCategory.Vr;
|
||||
case "internal": return ProfilerCategory.Internal;
|
||||
default: return ProfilerCategory.Render;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c6a02f2bb19e450a9ddca18ee8d211bf
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,497 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
using MCPForUnity.Runtime.Helpers;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Graphics
|
||||
{
|
||||
internal static class SkyboxOps
|
||||
{
|
||||
static Texture CustomReflectionTexture
|
||||
{
|
||||
get =>
|
||||
#if UNITY_2022_1_OR_NEWER
|
||||
RenderSettings.customReflectionTexture;
|
||||
#else
|
||||
RenderSettings.customReflection;
|
||||
#endif
|
||||
set {
|
||||
#if UNITY_2022_1_OR_NEWER
|
||||
RenderSettings.customReflectionTexture = value;
|
||||
#else
|
||||
RenderSettings.customReflection = value;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------
|
||||
// skybox_get — read all environment settings
|
||||
// ---------------------------------------------------------------
|
||||
public static object GetEnvironment(JObject @params)
|
||||
{
|
||||
var skyMat = RenderSettings.skybox;
|
||||
var sun = RenderSettings.sun;
|
||||
|
||||
object matInfo = null;
|
||||
if (skyMat != null)
|
||||
{
|
||||
var props = new List<object>();
|
||||
int count = skyMat.shader.GetPropertyCount();
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
string propName = skyMat.shader.GetPropertyName(i);
|
||||
var propType = skyMat.shader.GetPropertyType(i);
|
||||
object val = ReadMaterialProperty(skyMat, propName, propType);
|
||||
props.Add(new { name = propName, type = propType.ToString(), value = val });
|
||||
}
|
||||
matInfo = new
|
||||
{
|
||||
name = skyMat.name,
|
||||
shader = skyMat.shader.name,
|
||||
path = AssetDatabase.GetAssetPath(skyMat),
|
||||
properties = props
|
||||
};
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = "Environment settings retrieved.",
|
||||
data = new
|
||||
{
|
||||
skybox = matInfo,
|
||||
ambient = new
|
||||
{
|
||||
mode = RenderSettings.ambientMode.ToString(),
|
||||
skyColor = ColorToArray(RenderSettings.ambientSkyColor),
|
||||
equatorColor = ColorToArray(RenderSettings.ambientEquatorColor),
|
||||
groundColor = ColorToArray(RenderSettings.ambientGroundColor),
|
||||
ambientLight = ColorToArray(RenderSettings.ambientLight),
|
||||
intensity = RenderSettings.ambientIntensity
|
||||
},
|
||||
fog = new
|
||||
{
|
||||
enabled = RenderSettings.fog,
|
||||
mode = RenderSettings.fogMode.ToString(),
|
||||
color = ColorToArray(RenderSettings.fogColor),
|
||||
density = RenderSettings.fogDensity,
|
||||
startDistance = RenderSettings.fogStartDistance,
|
||||
endDistance = RenderSettings.fogEndDistance
|
||||
},
|
||||
reflection = new
|
||||
{
|
||||
intensity = RenderSettings.reflectionIntensity,
|
||||
bounces = RenderSettings.reflectionBounces,
|
||||
mode = RenderSettings.defaultReflectionMode.ToString(),
|
||||
resolution = RenderSettings.defaultReflectionResolution,
|
||||
customCubemap = CustomReflectionTexture != null
|
||||
? AssetDatabase.GetAssetPath(CustomReflectionTexture)
|
||||
: null
|
||||
},
|
||||
sun = sun != null
|
||||
? (object)new { name = sun.gameObject.name, instanceID = sun.gameObject.GetInstanceIDCompat() }
|
||||
: null,
|
||||
subtractiveShadowColor = ColorToArray(RenderSettings.subtractiveShadowColor)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// skybox_set_material — assign a skybox material
|
||||
// ---------------------------------------------------------------
|
||||
public static object SetMaterial(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
string materialPath = p.Get("material") ?? p.Get("path") ?? p.Get("material_path");
|
||||
if (string.IsNullOrEmpty(materialPath))
|
||||
return new ErrorResponse("'material' (asset path) is required.");
|
||||
|
||||
var mat = AssetDatabase.LoadAssetAtPath<Material>(materialPath);
|
||||
if (mat == null)
|
||||
return new ErrorResponse($"Material not found at '{materialPath}'.");
|
||||
|
||||
RenderSettings.skybox = mat;
|
||||
MarkSceneDirty();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Skybox set to '{mat.name}' (shader: {mat.shader.name}).",
|
||||
data = new
|
||||
{
|
||||
material = mat.name,
|
||||
shader = mat.shader.name,
|
||||
path = materialPath
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// skybox_set_properties — modify properties on the current skybox material
|
||||
// ---------------------------------------------------------------
|
||||
public static object SetMaterialProperties(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
var skyMat = RenderSettings.skybox;
|
||||
if (skyMat == null)
|
||||
return new ErrorResponse("No skybox material is set.");
|
||||
|
||||
var propsRaw = p.GetRaw("properties") ?? p.GetRaw("parameters");
|
||||
if (propsRaw == null || propsRaw.Type != JTokenType.Object)
|
||||
return new ErrorResponse("'properties' dict is required.");
|
||||
|
||||
var set = new List<string>();
|
||||
var failed = new List<string>();
|
||||
|
||||
foreach (var kvp in (JObject)propsRaw)
|
||||
{
|
||||
string propName = kvp.Key;
|
||||
if (!skyMat.HasProperty(propName))
|
||||
{
|
||||
string altName = "_" + propName;
|
||||
if (skyMat.HasProperty(altName))
|
||||
propName = altName;
|
||||
else
|
||||
{
|
||||
failed.Add(kvp.Key);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (SetMaterialProperty(skyMat, propName, kvp.Value))
|
||||
set.Add(kvp.Key);
|
||||
else
|
||||
failed.Add(kvp.Key);
|
||||
}
|
||||
|
||||
EditorUtility.SetDirty(skyMat);
|
||||
AssetDatabase.SaveAssets();
|
||||
MarkSceneDirty();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Set {set.Count} property(ies) on skybox material '{skyMat.name}'.",
|
||||
data = new { material = skyMat.name, set, failed }
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// skybox_set_ambient — set ambient lighting mode and colors
|
||||
// ---------------------------------------------------------------
|
||||
public static object SetAmbient(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
|
||||
string modeStr = p.Get("ambient_mode") ?? p.Get("mode");
|
||||
if (!string.IsNullOrEmpty(modeStr))
|
||||
{
|
||||
if (Enum.TryParse<AmbientMode>(modeStr, true, out var mode))
|
||||
RenderSettings.ambientMode = mode;
|
||||
else
|
||||
return new ErrorResponse(
|
||||
$"Invalid ambient mode '{modeStr}'. Valid: Skybox, Trilight, Flat, Custom.");
|
||||
}
|
||||
|
||||
var skyColor = ParseColorToken(p.GetRaw("color") ?? p.GetRaw("sky_color"));
|
||||
if (skyColor.HasValue)
|
||||
RenderSettings.ambientSkyColor = skyColor.Value;
|
||||
|
||||
var equatorColor = ParseColorToken(p.GetRaw("equator_color"));
|
||||
if (equatorColor.HasValue)
|
||||
RenderSettings.ambientEquatorColor = equatorColor.Value;
|
||||
|
||||
var groundColor = ParseColorToken(p.GetRaw("ground_color"));
|
||||
if (groundColor.HasValue)
|
||||
RenderSettings.ambientGroundColor = groundColor.Value;
|
||||
|
||||
var intensity = p.GetFloat("intensity");
|
||||
if (intensity.HasValue)
|
||||
RenderSettings.ambientIntensity = intensity.Value;
|
||||
|
||||
MarkSceneDirty();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Ambient lighting updated (mode: {RenderSettings.ambientMode}).",
|
||||
data = new
|
||||
{
|
||||
mode = RenderSettings.ambientMode.ToString(),
|
||||
skyColor = ColorToArray(RenderSettings.ambientSkyColor),
|
||||
equatorColor = ColorToArray(RenderSettings.ambientEquatorColor),
|
||||
groundColor = ColorToArray(RenderSettings.ambientGroundColor),
|
||||
intensity = RenderSettings.ambientIntensity
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// skybox_set_fog — enable/configure fog
|
||||
// ---------------------------------------------------------------
|
||||
public static object SetFog(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
|
||||
var enabledToken = p.GetRaw("fog_enabled") ?? p.GetRaw("enabled");
|
||||
if (enabledToken != null && enabledToken.Type != JTokenType.Null)
|
||||
RenderSettings.fog = ParamCoercion.CoerceBool(enabledToken, RenderSettings.fog);
|
||||
|
||||
string modeStr = p.Get("fog_mode") ?? p.Get("mode");
|
||||
if (!string.IsNullOrEmpty(modeStr))
|
||||
{
|
||||
if (Enum.TryParse<FogMode>(modeStr, true, out var fogMode))
|
||||
RenderSettings.fogMode = fogMode;
|
||||
else
|
||||
return new ErrorResponse(
|
||||
$"Invalid fog mode '{modeStr}'. Valid: Linear, Exponential, ExponentialSquared.");
|
||||
}
|
||||
|
||||
var fogColor = ParseColorToken(p.GetRaw("fog_color") ?? p.GetRaw("color"));
|
||||
if (fogColor.HasValue)
|
||||
RenderSettings.fogColor = fogColor.Value;
|
||||
|
||||
var density = p.GetFloat("fog_density") ?? p.GetFloat("density");
|
||||
if (density.HasValue)
|
||||
RenderSettings.fogDensity = density.Value;
|
||||
|
||||
var start = p.GetFloat("fog_start") ?? p.GetFloat("start");
|
||||
if (start.HasValue)
|
||||
RenderSettings.fogStartDistance = start.Value;
|
||||
|
||||
var end = p.GetFloat("fog_end") ?? p.GetFloat("end");
|
||||
if (end.HasValue)
|
||||
RenderSettings.fogEndDistance = end.Value;
|
||||
|
||||
MarkSceneDirty();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Fog settings updated (enabled: {RenderSettings.fog}, mode: {RenderSettings.fogMode}).",
|
||||
data = new
|
||||
{
|
||||
enabled = RenderSettings.fog,
|
||||
mode = RenderSettings.fogMode.ToString(),
|
||||
color = ColorToArray(RenderSettings.fogColor),
|
||||
density = RenderSettings.fogDensity,
|
||||
startDistance = RenderSettings.fogStartDistance,
|
||||
endDistance = RenderSettings.fogEndDistance
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// skybox_set_reflection — configure environment reflections
|
||||
// ---------------------------------------------------------------
|
||||
public static object SetReflection(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
|
||||
var intensity = p.GetFloat("intensity");
|
||||
if (intensity.HasValue)
|
||||
RenderSettings.reflectionIntensity = intensity.Value;
|
||||
|
||||
var bounces = p.GetInt("bounces");
|
||||
if (bounces.HasValue)
|
||||
RenderSettings.reflectionBounces = bounces.Value;
|
||||
|
||||
string modeStr = p.Get("reflection_mode") ?? p.Get("mode");
|
||||
if (!string.IsNullOrEmpty(modeStr))
|
||||
{
|
||||
if (Enum.TryParse<DefaultReflectionMode>(modeStr, true, out var mode))
|
||||
RenderSettings.defaultReflectionMode = mode;
|
||||
else
|
||||
return new ErrorResponse(
|
||||
$"Invalid reflection mode '{modeStr}'. Valid: Skybox, Custom.");
|
||||
}
|
||||
|
||||
var resolution = p.GetInt("resolution");
|
||||
if (resolution.HasValue)
|
||||
RenderSettings.defaultReflectionResolution = resolution.Value;
|
||||
|
||||
string cubemapPath = p.Get("path") ?? p.Get("cubemap_path");
|
||||
if (!string.IsNullOrEmpty(cubemapPath))
|
||||
{
|
||||
var cubemap = AssetDatabase.LoadAssetAtPath<Texture>(cubemapPath);
|
||||
if (cubemap != null)
|
||||
CustomReflectionTexture = cubemap;
|
||||
else
|
||||
return new ErrorResponse($"Cubemap not found at '{cubemapPath}'.");
|
||||
}
|
||||
|
||||
MarkSceneDirty();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Reflection settings updated (intensity: {RenderSettings.reflectionIntensity}, bounces: {RenderSettings.reflectionBounces}).",
|
||||
data = new
|
||||
{
|
||||
intensity = RenderSettings.reflectionIntensity,
|
||||
bounces = RenderSettings.reflectionBounces,
|
||||
mode = RenderSettings.defaultReflectionMode.ToString(),
|
||||
resolution = RenderSettings.defaultReflectionResolution,
|
||||
customCubemap = CustomReflectionTexture != null
|
||||
? AssetDatabase.GetAssetPath(CustomReflectionTexture)
|
||||
: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// skybox_set_sun — set the sun source light
|
||||
// ---------------------------------------------------------------
|
||||
public static object SetSun(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
string target = p.Get("target") ?? p.Get("name");
|
||||
if (string.IsNullOrEmpty(target))
|
||||
return new ErrorResponse("'target' (light GameObject name or instance ID) is required.");
|
||||
|
||||
GameObject go = null;
|
||||
if (int.TryParse(target, out int instanceId))
|
||||
go = GameObjectLookup.ResolveInstanceID(instanceId) as GameObject;
|
||||
if (go == null)
|
||||
go = GameObject.Find(target);
|
||||
if (go == null)
|
||||
return new ErrorResponse($"GameObject '{target}' not found.");
|
||||
|
||||
var light = go.GetComponent<Light>();
|
||||
if (light == null)
|
||||
return new ErrorResponse($"'{go.name}' does not have a Light component.");
|
||||
|
||||
RenderSettings.sun = light;
|
||||
MarkSceneDirty();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Sun source set to '{go.name}'.",
|
||||
data = new
|
||||
{
|
||||
name = go.name,
|
||||
instanceID = go.GetInstanceIDCompat(),
|
||||
lightType = light.type.ToString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
private static float[] ColorToArray(Color c)
|
||||
{
|
||||
return new[] { c.r, c.g, c.b, c.a };
|
||||
}
|
||||
|
||||
private static Color ArrayToColor(float[] arr)
|
||||
{
|
||||
return new Color(
|
||||
arr[0], arr[1], arr[2],
|
||||
arr.Length >= 4 ? arr[3] : 1f);
|
||||
}
|
||||
|
||||
private static Color? ParseColorToken(JToken token)
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null) return null;
|
||||
if (token is JArray arr && arr.Count >= 3)
|
||||
{
|
||||
return new Color(
|
||||
(float)arr[0], (float)arr[1], (float)arr[2],
|
||||
arr.Count >= 4 ? (float)arr[3] : 1f);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static object ReadMaterialProperty(Material mat, string propName, ShaderPropertyType propType)
|
||||
{
|
||||
switch (propType)
|
||||
{
|
||||
case ShaderPropertyType.Color:
|
||||
return ColorToArray(mat.GetColor(propName));
|
||||
case ShaderPropertyType.Float:
|
||||
case ShaderPropertyType.Range:
|
||||
return mat.GetFloat(propName);
|
||||
case ShaderPropertyType.Int:
|
||||
return mat.GetInt(propName);
|
||||
case ShaderPropertyType.Vector:
|
||||
var v = mat.GetVector(propName);
|
||||
return new[] { v.x, v.y, v.z, v.w };
|
||||
case ShaderPropertyType.Texture:
|
||||
var tex = mat.GetTexture(propName);
|
||||
return tex != null ? AssetDatabase.GetAssetPath(tex) : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SetMaterialProperty(Material mat, string propName, JToken value)
|
||||
{
|
||||
int propIdx = mat.shader.FindPropertyIndex(propName);
|
||||
if (propIdx < 0) return false;
|
||||
|
||||
var propType = mat.shader.GetPropertyType(propIdx);
|
||||
try
|
||||
{
|
||||
switch (propType)
|
||||
{
|
||||
case ShaderPropertyType.Color:
|
||||
if (value is JArray colorArr && colorArr.Count >= 3)
|
||||
{
|
||||
mat.SetColor(propName, new Color(
|
||||
(float)colorArr[0], (float)colorArr[1], (float)colorArr[2],
|
||||
colorArr.Count >= 4 ? (float)colorArr[3] : 1f));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case ShaderPropertyType.Float:
|
||||
case ShaderPropertyType.Range:
|
||||
mat.SetFloat(propName, (float)value);
|
||||
return true;
|
||||
case ShaderPropertyType.Int:
|
||||
mat.SetInt(propName, (int)value);
|
||||
return true;
|
||||
case ShaderPropertyType.Vector:
|
||||
if (value is JArray vecArr && vecArr.Count >= 2)
|
||||
{
|
||||
mat.SetVector(propName, new Vector4(
|
||||
(float)vecArr[0], (float)vecArr[1],
|
||||
vecArr.Count >= 3 ? (float)vecArr[2] : 0f,
|
||||
vecArr.Count >= 4 ? (float)vecArr[3] : 0f));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case ShaderPropertyType.Texture:
|
||||
if (value.Type == JTokenType.String)
|
||||
{
|
||||
var tex = AssetDatabase.LoadAssetAtPath<Texture>(value.ToString());
|
||||
if (tex != null) { mat.SetTexture(propName, tex); return true; }
|
||||
}
|
||||
else if (value.Type == JTokenType.Null)
|
||||
{
|
||||
mat.SetTexture(propName, null);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void MarkSceneDirty()
|
||||
{
|
||||
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(
|
||||
UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 93b73ba8237dee84088417864958084a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,705 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using MCPForUnity.Runtime.Helpers;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools.Graphics
|
||||
{
|
||||
internal static class VolumeOps
|
||||
{
|
||||
// === volume_create ===
|
||||
// Params: name (string), is_global (bool, default true), weight (float, default 1),
|
||||
// priority (float, default 0), profile_path (string, optional - path to save VolumeProfile asset),
|
||||
// effects (array of {type, ...params}, optional - effects to add immediately)
|
||||
internal static object CreateVolume(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
string name = p.Get("name") ?? "Volume";
|
||||
bool isGlobal = p.GetBool("is_global", true);
|
||||
float weight = p.GetFloat("weight") ?? 1.0f;
|
||||
float priority = p.GetFloat("priority") ?? 0f;
|
||||
string profilePath = p.Get("profile_path");
|
||||
if (!string.IsNullOrEmpty(profilePath))
|
||||
{
|
||||
if (!profilePath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) &&
|
||||
!profilePath.StartsWith("Assets\\", StringComparison.OrdinalIgnoreCase))
|
||||
profilePath = "Assets/" + profilePath;
|
||||
if (!profilePath.EndsWith(".asset", StringComparison.OrdinalIgnoreCase))
|
||||
profilePath += ".asset";
|
||||
}
|
||||
|
||||
var go = new GameObject(name);
|
||||
Undo.RegisterCreatedObjectUndo(go, $"Create Volume '{name}'");
|
||||
|
||||
// Add Volume component via reflection
|
||||
var volumeComp = go.AddComponent(GraphicsHelpers.VolumeType);
|
||||
|
||||
// Set properties via reflection
|
||||
SetProperty(volumeComp, "isGlobal", isGlobal);
|
||||
SetProperty(volumeComp, "weight", weight);
|
||||
SetProperty(volumeComp, "priority", priority);
|
||||
|
||||
// Create or load VolumeProfile
|
||||
object profile;
|
||||
if (!string.IsNullOrEmpty(profilePath))
|
||||
{
|
||||
// Load existing or create new profile asset
|
||||
profile = AssetDatabase.LoadAssetAtPath(profilePath, GraphicsHelpers.VolumeProfileType);
|
||||
if (profile == null)
|
||||
{
|
||||
profile = ScriptableObject.CreateInstance(GraphicsHelpers.VolumeProfileType);
|
||||
// Ensure directory exists
|
||||
var dir = System.IO.Path.GetDirectoryName(profilePath);
|
||||
if (!string.IsNullOrEmpty(dir) && !System.IO.Directory.Exists(dir))
|
||||
System.IO.Directory.CreateDirectory(dir);
|
||||
AssetDatabase.CreateAsset((UnityEngine.Object)profile, profilePath);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create embedded profile (not saved as asset)
|
||||
profile = ScriptableObject.CreateInstance(GraphicsHelpers.VolumeProfileType);
|
||||
}
|
||||
|
||||
// Assign profile (sharedProfile is a public field, handled by SetProperty's field fallback)
|
||||
SetProperty(volumeComp, "sharedProfile", profile);
|
||||
|
||||
// Add initial effects if provided
|
||||
var effectsToken = p.GetRaw("effects") as JArray;
|
||||
var addedEffects = new List<string>();
|
||||
if (effectsToken != null)
|
||||
{
|
||||
foreach (var effectDef in effectsToken)
|
||||
{
|
||||
if (effectDef is JObject effectObj)
|
||||
{
|
||||
string effectType = ParamCoercion.CoerceString(effectObj["type"], null);
|
||||
if (string.IsNullOrEmpty(effectType)) continue;
|
||||
|
||||
var type = GraphicsHelpers.ResolveVolumeComponentType(effectType);
|
||||
if (type == null) continue;
|
||||
|
||||
// profile.Add(type, true)
|
||||
var addMethod = GraphicsHelpers.VolumeProfileType.GetMethod("Add",
|
||||
new[] { typeof(Type), typeof(bool) });
|
||||
if (addMethod != null)
|
||||
{
|
||||
var component = addMethod.Invoke(profile, new object[] { type, true });
|
||||
if (component != null)
|
||||
{
|
||||
// Set parameters — support both nested {"parameters": {...}} and flat fields
|
||||
var paramObj = effectObj["parameters"] as JObject;
|
||||
if (paramObj != null)
|
||||
{
|
||||
foreach (var pp in paramObj.Properties())
|
||||
SetVolumeParameter(component, pp.Name, pp.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var prop in effectObj.Properties())
|
||||
{
|
||||
if (prop.Name == "type") continue;
|
||||
SetVolumeParameter(component, prop.Name, prop.Value);
|
||||
}
|
||||
}
|
||||
addedEffects.Add(effectType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (profile is UnityEngine.Object profileObj)
|
||||
EditorUtility.SetDirty(profileObj);
|
||||
}
|
||||
|
||||
GraphicsHelpers.MarkDirty(volumeComp);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Created {(isGlobal ? "global" : "local")} Volume '{name}'" +
|
||||
(addedEffects.Count > 0 ? $" with effects: {string.Join(", ", addedEffects)}" : ""),
|
||||
data = new
|
||||
{
|
||||
instanceID = go.GetInstanceIDCompat(),
|
||||
isGlobal,
|
||||
weight,
|
||||
priority,
|
||||
profilePath = profilePath ?? "(embedded)",
|
||||
effects = addedEffects
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// === volume_add_effect ===
|
||||
// Params: target (string/int), effect (string - type name like "Bloom")
|
||||
internal static object AddEffect(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
string effectName = p.Get("effect");
|
||||
if (string.IsNullOrEmpty(effectName))
|
||||
return new ErrorResponse("'effect' parameter is required (e.g., 'Bloom', 'Vignette').");
|
||||
|
||||
var volume = GraphicsHelpers.FindVolume(@params);
|
||||
if (volume == null)
|
||||
return new ErrorResponse("Volume not found. Specify 'target' (name or instance ID).");
|
||||
|
||||
var effectType = GraphicsHelpers.ResolveVolumeComponentType(effectName);
|
||||
if (effectType == null)
|
||||
{
|
||||
var available = GraphicsHelpers.GetAvailableEffectTypes()
|
||||
.Select(t => t.Name).ToList();
|
||||
return new ErrorResponse(
|
||||
$"Effect type '{effectName}' not found. Available: {string.Join(", ", available.Take(20))}");
|
||||
}
|
||||
|
||||
var profile = GetProperty(volume, "sharedProfile");
|
||||
if (profile == null)
|
||||
return new ErrorResponse("Volume has no profile assigned.");
|
||||
|
||||
// Check if effect already exists
|
||||
var components = GetProperty(profile, "components") as System.Collections.IList;
|
||||
if (components != null)
|
||||
{
|
||||
foreach (var comp in components)
|
||||
{
|
||||
if (comp != null && comp.GetType() == effectType)
|
||||
return new ErrorResponse($"Effect '{effectName}' already exists on this Volume. Use volume_set_effect to modify it.");
|
||||
}
|
||||
}
|
||||
|
||||
// profile.Add(effectType, true) -- 'true' means override all params
|
||||
var addMethod = GraphicsHelpers.VolumeProfileType.GetMethod("Add",
|
||||
new[] { typeof(Type), typeof(bool) });
|
||||
if (addMethod == null)
|
||||
return new ErrorResponse("Could not find VolumeProfile.Add method.");
|
||||
|
||||
var component = addMethod.Invoke(profile, new object[] { effectType, true });
|
||||
if (component == null)
|
||||
return new ErrorResponse($"Failed to add effect '{effectName}'.");
|
||||
|
||||
if (profile is UnityEngine.Object profileObj)
|
||||
EditorUtility.SetDirty(profileObj);
|
||||
GraphicsHelpers.MarkDirty(volume);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Added '{effectName}' to Volume '{(volume as Component)?.gameObject.name}'.",
|
||||
data = new { effect = effectName, volumeInstanceID = (volume as Component)?.gameObject.GetInstanceIDCompat() }
|
||||
};
|
||||
}
|
||||
|
||||
// === volume_set_effect ===
|
||||
// Params: target (string/int), effect (string), parameters (dict of field->value)
|
||||
internal static object SetEffect(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
string effectName = p.Get("effect");
|
||||
if (string.IsNullOrEmpty(effectName))
|
||||
return new ErrorResponse("'effect' parameter is required.");
|
||||
|
||||
var volume = GraphicsHelpers.FindVolume(@params);
|
||||
if (volume == null)
|
||||
return new ErrorResponse("Volume not found. Specify 'target'.");
|
||||
|
||||
var profile = GetProperty(volume, "sharedProfile");
|
||||
if (profile == null)
|
||||
return new ErrorResponse("Volume has no profile assigned.");
|
||||
|
||||
var effectType = GraphicsHelpers.ResolveVolumeComponentType(effectName);
|
||||
if (effectType == null)
|
||||
return new ErrorResponse($"Effect type '{effectName}' not found.");
|
||||
|
||||
// Find the effect component in the profile
|
||||
var components = GetProperty(profile, "components") as System.Collections.IList;
|
||||
if (components == null)
|
||||
return new ErrorResponse("Could not read profile components.");
|
||||
|
||||
object targetComponent = null;
|
||||
foreach (var comp in components)
|
||||
{
|
||||
if (comp != null && comp.GetType() == effectType)
|
||||
{
|
||||
targetComponent = comp;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetComponent == null)
|
||||
return new ErrorResponse($"Effect '{effectName}' not found on this Volume. Use volume_add_effect first.");
|
||||
|
||||
// Set parameters
|
||||
var parameters = p.GetRaw("parameters") as JObject;
|
||||
if (parameters == null)
|
||||
return new ErrorResponse("'parameters' dict is required.");
|
||||
|
||||
var setParams = new List<string>();
|
||||
var failedParams = new List<string>();
|
||||
foreach (var prop in parameters.Properties())
|
||||
{
|
||||
if (SetVolumeParameter(targetComponent, prop.Name, prop.Value))
|
||||
setParams.Add(prop.Name);
|
||||
else
|
||||
failedParams.Add(prop.Name);
|
||||
}
|
||||
|
||||
if (profile is UnityEngine.Object profileObj)
|
||||
EditorUtility.SetDirty(profileObj);
|
||||
|
||||
var msg = $"Set {setParams.Count} parameter(s) on '{effectName}'";
|
||||
if (failedParams.Count > 0)
|
||||
msg += $". Failed: {string.Join(", ", failedParams)}";
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = msg,
|
||||
data = new { effect = effectName, set = setParams, failed = failedParams }
|
||||
};
|
||||
}
|
||||
|
||||
// === volume_remove_effect ===
|
||||
// Params: target, effect
|
||||
internal static object RemoveEffect(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
string effectName = p.Get("effect");
|
||||
if (string.IsNullOrEmpty(effectName))
|
||||
return new ErrorResponse("'effect' parameter is required.");
|
||||
|
||||
var volume = GraphicsHelpers.FindVolume(@params);
|
||||
if (volume == null)
|
||||
return new ErrorResponse("Volume not found.");
|
||||
|
||||
var effectType = GraphicsHelpers.ResolveVolumeComponentType(effectName);
|
||||
if (effectType == null)
|
||||
return new ErrorResponse($"Effect type '{effectName}' not found.");
|
||||
|
||||
var profile = GetProperty(volume, "sharedProfile");
|
||||
if (profile == null)
|
||||
return new ErrorResponse("Volume has no profile.");
|
||||
|
||||
// Check if effect exists before removing
|
||||
bool found = false;
|
||||
var components = GetProperty(profile, "components") as System.Collections.IList;
|
||||
if (components != null)
|
||||
{
|
||||
foreach (var comp in components)
|
||||
{
|
||||
if (comp != null && comp.GetType() == effectType)
|
||||
{
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found)
|
||||
return new ErrorResponse($"Effect '{effectName}' not found on this Volume.");
|
||||
|
||||
var removeMethod = GraphicsHelpers.VolumeProfileType.GetMethod("Remove",
|
||||
new[] { typeof(Type) });
|
||||
if (removeMethod == null)
|
||||
return new ErrorResponse("Could not find VolumeProfile.Remove method.");
|
||||
|
||||
removeMethod.Invoke(profile, new object[] { effectType });
|
||||
|
||||
if (profile is UnityEngine.Object profileObj)
|
||||
EditorUtility.SetDirty(profileObj);
|
||||
GraphicsHelpers.MarkDirty(volume);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Removed '{effectName}' from Volume.",
|
||||
data = new { effect = effectName }
|
||||
};
|
||||
}
|
||||
|
||||
// === volume_get_info ===
|
||||
// Params: target (optional -- if omitted, returns info for all volumes)
|
||||
internal static object GetInfo(JObject @params)
|
||||
{
|
||||
var volume = GraphicsHelpers.FindVolume(@params);
|
||||
if (volume == null)
|
||||
return new ErrorResponse("Volume not found.");
|
||||
|
||||
var info = BuildVolumeInfo(volume);
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Volume info for '{(volume as Component)?.gameObject.name}'.",
|
||||
data = info
|
||||
};
|
||||
}
|
||||
|
||||
// === volume_set_properties ===
|
||||
// Params: target, weight, priority, is_global, blend_distance
|
||||
// OR properties dict with those keys
|
||||
internal static object SetProperties(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
var volume = GraphicsHelpers.FindVolume(@params);
|
||||
if (volume == null)
|
||||
return new ErrorResponse("Volume not found.");
|
||||
|
||||
// Unpack "properties" dict into top-level params so callers can use either style
|
||||
var propsDict = p.GetRaw("properties") as JObject;
|
||||
if (propsDict != null)
|
||||
{
|
||||
foreach (var prop in propsDict.Properties())
|
||||
{
|
||||
if (@params[prop.Name] == null)
|
||||
@params[prop.Name] = prop.Value;
|
||||
}
|
||||
p = new ToolParams(@params);
|
||||
}
|
||||
|
||||
var changed = new List<string>();
|
||||
|
||||
var weight = p.GetFloat("weight");
|
||||
if (weight.HasValue) { SetProperty(volume, "weight", weight.Value); changed.Add("weight"); }
|
||||
|
||||
var priority = p.GetFloat("priority");
|
||||
if (priority.HasValue) { SetProperty(volume, "priority", priority.Value); changed.Add("priority"); }
|
||||
|
||||
if (p.Has("is_global")) { SetProperty(volume, "isGlobal", p.GetBool("is_global")); changed.Add("isGlobal"); }
|
||||
|
||||
var blendDist = p.GetFloat("blend_distance");
|
||||
if (blendDist.HasValue) { SetProperty(volume, "blendDistance", blendDist.Value); changed.Add("blendDistance"); }
|
||||
|
||||
if (changed.Count == 0)
|
||||
return new ErrorResponse("No properties specified. Use: weight, priority, is_global, blend_distance.");
|
||||
|
||||
GraphicsHelpers.MarkDirty(volume);
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Updated Volume properties: {string.Join(", ", changed)}",
|
||||
data = new { changed }
|
||||
};
|
||||
}
|
||||
|
||||
// === volume_list_effects ===
|
||||
// No params needed -- lists all available VolumeComponent types
|
||||
internal static object ListEffects(JObject @params)
|
||||
{
|
||||
var types = GraphicsHelpers.GetAvailableEffectTypes();
|
||||
var effectList = types.Select(t => new
|
||||
{
|
||||
name = t.Name,
|
||||
fullName = t.FullName,
|
||||
ns = t.Namespace
|
||||
}).ToList();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Found {effectList.Count} available volume effects.",
|
||||
data = new { pipeline = GraphicsHelpers.GetPipelineName(), effects = effectList }
|
||||
};
|
||||
}
|
||||
|
||||
// === volume_create_profile ===
|
||||
// Params: path (string -- asset path like "Assets/Settings/MyProfile.asset")
|
||||
internal static object CreateProfile(JObject @params)
|
||||
{
|
||||
var p = new ToolParams(@params);
|
||||
string path = p.Get("path");
|
||||
if (string.IsNullOrEmpty(path))
|
||||
return new ErrorResponse("'path' parameter is required (e.g., 'Settings/MyProfile' or 'Assets/Settings/MyProfile.asset').");
|
||||
|
||||
// Auto-prepend Assets/ if missing (paths are relative to Assets/ by convention)
|
||||
if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) &&
|
||||
!path.StartsWith("Assets\\", StringComparison.OrdinalIgnoreCase))
|
||||
path = "Assets/" + path;
|
||||
|
||||
if (!path.EndsWith(".asset", StringComparison.OrdinalIgnoreCase))
|
||||
path += ".asset";
|
||||
|
||||
// Ensure directory exists
|
||||
var dir = System.IO.Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(dir) && !AssetDatabase.IsValidFolder(dir))
|
||||
{
|
||||
// Create folders recursively
|
||||
var parts = dir.Replace("\\", "/").Split('/');
|
||||
string current = parts[0];
|
||||
for (int i = 1; i < parts.Length; i++)
|
||||
{
|
||||
string next = current + "/" + parts[i];
|
||||
if (!AssetDatabase.IsValidFolder(next))
|
||||
AssetDatabase.CreateFolder(current, parts[i]);
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
|
||||
var profile = ScriptableObject.CreateInstance(GraphicsHelpers.VolumeProfileType);
|
||||
AssetDatabase.CreateAsset(profile, path);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Created VolumeProfile at '{path}'.",
|
||||
data = new { path }
|
||||
};
|
||||
}
|
||||
|
||||
// === ListVolumes (used by VolumesResource) ===
|
||||
internal static object ListVolumes(JObject @params)
|
||||
{
|
||||
if (!GraphicsHelpers.HasVolumeSystem)
|
||||
return new { success = true, message = "Volume system not available.", data = new { volumes = new List<object>() } };
|
||||
|
||||
var allVolumes = UnityFindObjectsCompat.FindAll(GraphicsHelpers.VolumeType);
|
||||
var volumeList = new List<object>();
|
||||
|
||||
foreach (Component vol in allVolumes)
|
||||
{
|
||||
volumeList.Add(BuildVolumeInfo(vol));
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Found {volumeList.Count} volume(s).",
|
||||
data = new { pipeline = GraphicsHelpers.GetPipelineName(), volumes = volumeList }
|
||||
};
|
||||
}
|
||||
|
||||
// --- Helper: Build info object for a single Volume ---
|
||||
private static object BuildVolumeInfo(object volumeComponent)
|
||||
{
|
||||
var comp = volumeComponent as Component;
|
||||
if (comp == null) return null;
|
||||
|
||||
bool isGlobal = GetPropertyValue<bool>(volumeComponent, "isGlobal", true);
|
||||
float weight = GetPropertyValue<float>(volumeComponent, "weight", 1f);
|
||||
float priority = GetPropertyValue<float>(volumeComponent, "priority", 0f);
|
||||
float blendDistance = GetPropertyValue<float>(volumeComponent, "blendDistance", 0f);
|
||||
|
||||
var profile = GetProperty(volumeComponent, "sharedProfile");
|
||||
string profileName = profile is UnityEngine.Object profileObj2 ? profileObj2.name : null;
|
||||
string profilePath = profile is UnityEngine.Object po ? AssetDatabase.GetAssetPath(po) : null;
|
||||
|
||||
var effectsList = new List<object>();
|
||||
if (profile != null)
|
||||
{
|
||||
var components = GetProperty(profile, "components") as System.Collections.IList;
|
||||
if (components != null)
|
||||
{
|
||||
foreach (var effect in components)
|
||||
{
|
||||
if (effect == null) continue;
|
||||
var effectType = effect.GetType();
|
||||
bool active = GetPropertyValue<bool>(effect, "active", true);
|
||||
|
||||
// Collect overridden parameters
|
||||
var overriddenParams = new List<string>();
|
||||
foreach (var field in effectType.GetFields(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
var fieldValue = field.GetValue(effect);
|
||||
if (fieldValue == null) continue;
|
||||
var overrideProp = fieldValue.GetType().GetProperty("overrideState");
|
||||
if (overrideProp != null)
|
||||
{
|
||||
bool overridden = (bool)overrideProp.GetValue(fieldValue);
|
||||
if (overridden)
|
||||
overriddenParams.Add(field.Name);
|
||||
}
|
||||
}
|
||||
|
||||
effectsList.Add(new
|
||||
{
|
||||
type = effectType.Name,
|
||||
active,
|
||||
overridden_params = overriddenParams
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
name = comp.gameObject.name,
|
||||
instance_id = comp.gameObject.GetInstanceIDCompat(),
|
||||
is_global = isGlobal,
|
||||
weight,
|
||||
priority,
|
||||
blend_distance = blendDistance,
|
||||
profile = profileName,
|
||||
profile_path = profilePath ?? "",
|
||||
effects = effectsList
|
||||
};
|
||||
}
|
||||
|
||||
// --- Helper: Set a VolumeParameter field value via reflection ---
|
||||
// VolumeParameter<T> has: overrideState (bool), value (T)
|
||||
internal static bool SetVolumeParameter(object component, string fieldName, JToken value)
|
||||
{
|
||||
if (component == null || string.IsNullOrEmpty(fieldName)) return false;
|
||||
|
||||
var field = component.GetType().GetField(fieldName,
|
||||
BindingFlags.Public | BindingFlags.Instance);
|
||||
if (field == null)
|
||||
{
|
||||
// Try camelCase conversion from snake_case
|
||||
string camelCase = StringCaseUtility.ToCamelCase(fieldName);
|
||||
field = component.GetType().GetField(camelCase,
|
||||
BindingFlags.Public | BindingFlags.Instance);
|
||||
}
|
||||
if (field == null) return false;
|
||||
|
||||
var param = field.GetValue(component);
|
||||
if (param == null) return false;
|
||||
|
||||
// Set value with type conversion, then enable override on success
|
||||
var valueProp = param.GetType().GetProperty("value");
|
||||
if (valueProp == null) return false;
|
||||
|
||||
try
|
||||
{
|
||||
object converted = ConvertToParameterType(value, valueProp.PropertyType);
|
||||
valueProp.SetValue(param, converted);
|
||||
|
||||
var overrideProp = param.GetType().GetProperty("overrideState");
|
||||
if (overrideProp != null)
|
||||
overrideProp.SetValue(param, true);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Warn($"[VolumeOps] Failed to set '{fieldName}': {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper: Convert JToken to target parameter type ---
|
||||
private static object ConvertToParameterType(JToken value, Type targetType)
|
||||
{
|
||||
if (value == null || value.Type == JTokenType.Null) return null;
|
||||
|
||||
// Handle Color
|
||||
if (targetType == typeof(Color))
|
||||
{
|
||||
if (value is JArray arr && arr.Count >= 3)
|
||||
{
|
||||
float r = arr[0].Value<float>();
|
||||
float g = arr[1].Value<float>();
|
||||
float b = arr[2].Value<float>();
|
||||
float a = arr.Count >= 4 ? arr[3].Value<float>() : 1f;
|
||||
return new Color(r, g, b, a);
|
||||
}
|
||||
// Try hex string
|
||||
if (value.Type == JTokenType.String)
|
||||
{
|
||||
if (ColorUtility.TryParseHtmlString(value.ToString(), out Color c))
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Vector2
|
||||
if (targetType == typeof(Vector2))
|
||||
{
|
||||
if (value is JArray arr && arr.Count >= 2)
|
||||
return new Vector2(arr[0].Value<float>(), arr[1].Value<float>());
|
||||
}
|
||||
|
||||
// Handle Vector3
|
||||
if (targetType == typeof(Vector3))
|
||||
{
|
||||
if (value is JArray arr && arr.Count >= 3)
|
||||
return new Vector3(arr[0].Value<float>(), arr[1].Value<float>(), arr[2].Value<float>());
|
||||
}
|
||||
|
||||
// Handle Vector4
|
||||
if (targetType == typeof(Vector4))
|
||||
{
|
||||
if (value is JArray arr && arr.Count >= 4)
|
||||
return new Vector4(arr[0].Value<float>(), arr[1].Value<float>(),
|
||||
arr[2].Value<float>(), arr[3].Value<float>());
|
||||
}
|
||||
|
||||
// Handle enums
|
||||
if (targetType.IsEnum)
|
||||
{
|
||||
string str = value.ToString();
|
||||
if (Enum.TryParse(targetType, str, true, out object enumVal))
|
||||
return enumVal;
|
||||
// Try as int
|
||||
if (int.TryParse(str, out int intVal))
|
||||
return Enum.ToObject(targetType, intVal);
|
||||
}
|
||||
|
||||
// Handle bool
|
||||
if (targetType == typeof(bool))
|
||||
return ParamCoercion.CoerceBool(value, false);
|
||||
|
||||
// Handle float
|
||||
if (targetType == typeof(float))
|
||||
return ParamCoercion.CoerceFloat(value, 0f);
|
||||
|
||||
// Handle int
|
||||
if (targetType == typeof(int))
|
||||
return ParamCoercion.CoerceInt(value, 0);
|
||||
|
||||
// Handle Texture2D (by asset path)
|
||||
if (targetType == typeof(Texture2D) || targetType == typeof(Texture))
|
||||
{
|
||||
string path = value.ToString();
|
||||
return AssetDatabase.LoadAssetAtPath<Texture2D>(path);
|
||||
}
|
||||
|
||||
// Fallback: try Convert
|
||||
try
|
||||
{
|
||||
return Convert.ChangeType(value.ToObject<object>(), targetType);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return value.ToObject<object>();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Reflection helpers (with field fallback for Volume.sharedProfile etc.) ---
|
||||
private static object GetProperty(object obj, string name)
|
||||
{
|
||||
if (obj == null) return null;
|
||||
var prop = obj.GetType().GetProperty(name,
|
||||
BindingFlags.Public | BindingFlags.Instance);
|
||||
if (prop != null) return prop.GetValue(obj);
|
||||
// Fallback: try as a field (e.g., Volume.sharedProfile is a public field, not a property)
|
||||
var field = obj.GetType().GetField(name,
|
||||
BindingFlags.Public | BindingFlags.Instance);
|
||||
return field?.GetValue(obj);
|
||||
}
|
||||
|
||||
private static T GetPropertyValue<T>(object obj, string name, T defaultValue)
|
||||
{
|
||||
var val = GetProperty(obj, name);
|
||||
if (val is T typed) return typed;
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
private static void SetProperty(object obj, string name, object value)
|
||||
{
|
||||
if (obj == null) return;
|
||||
var prop = obj.GetType().GetProperty(name,
|
||||
BindingFlags.Public | BindingFlags.Instance);
|
||||
if (prop != null && prop.CanWrite)
|
||||
{
|
||||
prop.SetValue(obj, value);
|
||||
return;
|
||||
}
|
||||
// Fallback: try as a field (e.g., Volume.sharedProfile is a public field, not a property)
|
||||
var field = obj.GetType().GetField(name,
|
||||
BindingFlags.Public | BindingFlags.Instance);
|
||||
field?.SetValue(obj, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f9cc9cf9a8664f5bbefa277a02e020fc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,31 @@
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools
|
||||
{
|
||||
internal static class JsonUtil
|
||||
{
|
||||
/// <summary>
|
||||
/// If @params[paramName] is a JSON string, parse it to a JObject in-place.
|
||||
/// Logs a warning on parse failure and leaves the original value.
|
||||
/// </summary>
|
||||
internal static void CoerceJsonStringParameter(JObject @params, string paramName)
|
||||
{
|
||||
if (@params == null || string.IsNullOrEmpty(paramName)) return;
|
||||
var token = @params[paramName];
|
||||
if (token != null && token.Type == JTokenType.String)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = JObject.Parse(token.ToString());
|
||||
@params[paramName] = parsed;
|
||||
}
|
||||
catch (Newtonsoft.Json.JsonReaderException e)
|
||||
{
|
||||
McpLog.Warn($"[MCP] Could not parse '{paramName}' JSON string: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d4b3b6009d53e4b8f97fe7ab57888c65
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user