777e8a9c7a
EditorApplication.isCompiling conflates three states: actually compiling, compilation queued, and finished-but-reload-deferred. A project holding EditorApplication.LockReloadAssemblies sits in the third state for as long as the lock is held, with no compilation running, and isCompiling stays true the whole time. Eight call sites gated on that raw flag, so they refused work indefinitely: the stdio bridge would not start, unity_reflect and manage_scriptable_object returned "Unity is compiling", refresh_unity reported the wrong resulting state and never completed its wait, the stdio reload handler deferred its resume, and TestJobManager both mis-attributed its init timeout and reported a bogus "compiling" block reason. Route all eight through EditorStateCache.GetActualIsCompiling(), which falls back to the event-tracked CompilationPipeline flag, and drop the isPlaying gate that previously limited the workaround to play mode. Verified live: with LockReloadAssemblies held after RequestScriptCompilation, EditorApplication.isCompiling is true while the pipeline flag is false, so CompilationPipeline.compilationFinished does fire while the reload is held.
1503 lines
65 KiB
C#
1503 lines
65 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text.RegularExpressions;
|
|
using MCPForUnity.Editor.Helpers;
|
|
using MCPForUnity.Editor.Services;
|
|
using Newtonsoft.Json.Linq;
|
|
using UnityEditor;
|
|
using UnityEngine;
|
|
|
|
namespace MCPForUnity.Editor.Tools
|
|
{
|
|
/// <summary>
|
|
/// Single tool for ScriptableObject workflows:
|
|
/// - action=create: create a ScriptableObject asset (and optionally apply patches)
|
|
/// - action=modify: apply serialized property patches to an existing asset
|
|
///
|
|
/// Patching is performed via SerializedObject/SerializedProperty paths (Unity-native), not reflection.
|
|
/// </summary>
|
|
[McpForUnityTool("manage_scriptable_object", AutoRegister = false, Group = "scripting_ext")]
|
|
public static class ManageScriptableObject
|
|
{
|
|
private const string CodeCompilingOrReloading = "compiling_or_reloading";
|
|
private const string CodeInvalidParams = "invalid_params";
|
|
private const string CodeTypeNotFound = "type_not_found";
|
|
private const string CodeInvalidFolderPath = "invalid_folder_path";
|
|
private const string CodeTargetNotFound = "target_not_found";
|
|
private const string CodeAssetCreateFailed = "asset_create_failed";
|
|
|
|
private static readonly HashSet<string> ValidActions = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
// NOTE: Action strings are normalized by NormalizeAction() (lowercased, '_'/'-' removed),
|
|
// so we only need the canonical normalized forms here.
|
|
"create",
|
|
"createso",
|
|
"modify",
|
|
"modifyso",
|
|
};
|
|
|
|
public static object HandleCommand(JObject @params)
|
|
{
|
|
if (@params == null)
|
|
{
|
|
return new ErrorResponse(CodeInvalidParams);
|
|
}
|
|
|
|
if (EditorStateCache.GetActualIsCompiling() || EditorApplication.isUpdating)
|
|
{
|
|
// Unity is transient; treat as retryable on the client side.
|
|
return new ErrorResponse(CodeCompilingOrReloading, new { hint = "retry" });
|
|
}
|
|
|
|
// Allow JSON-string parameters for objects/arrays.
|
|
JsonUtil.CoerceJsonStringParameter(@params, "target");
|
|
CoerceJsonStringArrayParameter(@params, "patches");
|
|
|
|
string actionRaw = @params["action"]?.ToString();
|
|
if (string.IsNullOrWhiteSpace(actionRaw))
|
|
{
|
|
return new ErrorResponse(CodeInvalidParams, new { message = "'action' is required.", validActions = ValidActions.ToArray() });
|
|
}
|
|
|
|
string action = NormalizeAction(actionRaw);
|
|
if (!ValidActions.Contains(action))
|
|
{
|
|
return new ErrorResponse(CodeInvalidParams, new { message = $"Unknown action: '{actionRaw}'.", validActions = ValidActions.ToArray() });
|
|
}
|
|
|
|
if (IsCreateAction(action))
|
|
{
|
|
return HandleCreate(@params);
|
|
}
|
|
|
|
return HandleModify(@params);
|
|
}
|
|
|
|
private static object HandleCreate(JObject @params)
|
|
{
|
|
string typeName = @params["typeName"]?.ToString() ?? @params["type_name"]?.ToString();
|
|
string folderPath = @params["folderPath"]?.ToString() ?? @params["folder_path"]?.ToString();
|
|
string assetName = @params["assetName"]?.ToString() ?? @params["asset_name"]?.ToString();
|
|
bool overwrite = @params["overwrite"]?.ToObject<bool?>() ?? false;
|
|
|
|
if (string.IsNullOrWhiteSpace(typeName))
|
|
{
|
|
return new ErrorResponse(CodeInvalidParams, new { message = "'typeName' is required." });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(folderPath))
|
|
{
|
|
return new ErrorResponse(CodeInvalidParams, new { message = "'folderPath' is required." });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(assetName))
|
|
{
|
|
return new ErrorResponse(CodeInvalidParams, new { message = "'assetName' is required." });
|
|
}
|
|
|
|
if (assetName.Contains("/") || assetName.Contains("\\"))
|
|
{
|
|
return new ErrorResponse(CodeInvalidParams, new { message = "'assetName' must not contain path separators." });
|
|
}
|
|
|
|
if (!TryNormalizeFolderPath(folderPath, out var normalizedFolder, out var folderNormalizeError))
|
|
{
|
|
return new ErrorResponse(CodeInvalidFolderPath, new { message = folderNormalizeError, folderPath });
|
|
}
|
|
|
|
if (!EnsureFolderExists(normalizedFolder, out var folderError))
|
|
{
|
|
return new ErrorResponse(CodeInvalidFolderPath, new { message = folderError, folderPath = normalizedFolder });
|
|
}
|
|
|
|
var resolvedType = ResolveType(typeName);
|
|
if (resolvedType == null || !typeof(ScriptableObject).IsAssignableFrom(resolvedType))
|
|
{
|
|
return new ErrorResponse(CodeTypeNotFound, new { message = $"ScriptableObject type not found: '{typeName}'", typeName });
|
|
}
|
|
|
|
string fileName = assetName.EndsWith(".asset", StringComparison.OrdinalIgnoreCase)
|
|
? assetName
|
|
: assetName + ".asset";
|
|
string desiredPath = $"{normalizedFolder.TrimEnd('/')}/{fileName}";
|
|
string finalPath = overwrite ? desiredPath : AssetDatabase.GenerateUniqueAssetPath(desiredPath);
|
|
|
|
ScriptableObject instance;
|
|
try
|
|
{
|
|
instance = ScriptableObject.CreateInstance(resolvedType);
|
|
if (instance == null)
|
|
{
|
|
return new ErrorResponse(CodeAssetCreateFailed, new { message = "CreateInstance returned null.", typeName = resolvedType.FullName });
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new ErrorResponse(CodeAssetCreateFailed, new { message = ex.Message, typeName = resolvedType.FullName });
|
|
}
|
|
|
|
// GUID-preserving overwrite logic
|
|
bool isNewAsset = true;
|
|
try
|
|
{
|
|
if (overwrite)
|
|
{
|
|
var existingAsset = AssetDatabase.LoadAssetAtPath<ScriptableObject>(finalPath);
|
|
if (existingAsset != null && existingAsset.GetType() == resolvedType)
|
|
{
|
|
// Preserve GUID by overwriting existing asset data in-place
|
|
EditorUtility.CopySerialized(instance, existingAsset);
|
|
|
|
// Fix for "Main Object Name does not match filename" warning:
|
|
// CopySerialized overwrites the name with the (empty) name of the new instance.
|
|
// We must restore the correct name to match the filename.
|
|
existingAsset.name = Path.GetFileNameWithoutExtension(finalPath);
|
|
|
|
UnityEngine.Object.DestroyImmediate(instance); // Destroy temporary instance
|
|
instance = existingAsset; // Proceed with patching the existing asset
|
|
isNewAsset = false;
|
|
|
|
// Mark dirty to ensure changes are picked up
|
|
EditorUtility.SetDirty(instance);
|
|
}
|
|
else if (existingAsset != null)
|
|
{
|
|
// Type mismatch or not a ScriptableObject - must delete and recreate to change type, losing GUID
|
|
// (Or we could warn, but overwrite usually implies replacing)
|
|
AssetDatabase.DeleteAsset(finalPath);
|
|
}
|
|
}
|
|
|
|
if (isNewAsset)
|
|
{
|
|
// Ensure the new instance has the correct name before creating asset to avoid warnings
|
|
instance.name = Path.GetFileNameWithoutExtension(finalPath);
|
|
AssetDatabase.CreateAsset(instance, finalPath);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new ErrorResponse(CodeAssetCreateFailed, new { message = ex.Message, path = finalPath });
|
|
}
|
|
|
|
string guid = AssetDatabase.AssetPathToGUID(finalPath);
|
|
var patchesToken = @params["patches"];
|
|
object patchResults = null;
|
|
var warnings = new List<string>();
|
|
|
|
if (patchesToken is JArray patches && patches.Count > 0)
|
|
{
|
|
var patchApply = ApplyPatches(instance, patches);
|
|
patchResults = patchApply.results;
|
|
warnings.AddRange(patchApply.warnings);
|
|
}
|
|
|
|
EditorUtility.SetDirty(instance);
|
|
AssetDatabase.SaveAssets();
|
|
|
|
return new SuccessResponse(
|
|
"ScriptableObject created.",
|
|
new
|
|
{
|
|
guid,
|
|
path = finalPath,
|
|
typeNameResolved = resolvedType.FullName,
|
|
patchResults,
|
|
warnings = warnings.Count > 0 ? warnings : null
|
|
}
|
|
);
|
|
}
|
|
|
|
private static object HandleModify(JObject @params)
|
|
{
|
|
if (!TryResolveTarget(@params["target"], out var target, out var targetPath, out var targetGuid, out var err))
|
|
{
|
|
return err;
|
|
}
|
|
|
|
var patchesToken = @params["patches"];
|
|
if (patchesToken == null || patchesToken.Type == JTokenType.Null)
|
|
{
|
|
return new ErrorResponse(CodeInvalidParams, new { message = "'patches' is required.", targetPath, targetGuid });
|
|
}
|
|
|
|
if (patchesToken is not JArray patches)
|
|
{
|
|
return new ErrorResponse(CodeInvalidParams, new { message = "'patches' must be an array.", targetPath, targetGuid });
|
|
}
|
|
|
|
// Phase 5: Dry-run mode - validate patches without applying
|
|
bool dryRun = @params["dryRun"]?.ToObject<bool?>() ?? @params["dry_run"]?.ToObject<bool?>() ?? false;
|
|
|
|
if (dryRun)
|
|
{
|
|
var validationResults = ValidatePatches(target, patches);
|
|
return new SuccessResponse(
|
|
"Dry-run validation complete.",
|
|
new
|
|
{
|
|
targetGuid,
|
|
targetPath,
|
|
targetTypeName = target.GetType().FullName,
|
|
dryRun = true,
|
|
valid = validationResults.All(r => (bool)r.GetType().GetProperty("ok")?.GetValue(r)),
|
|
validationResults
|
|
}
|
|
);
|
|
}
|
|
|
|
var (results, warnings) = ApplyPatches(target, patches);
|
|
|
|
return new SuccessResponse(
|
|
"Serialized properties patched.",
|
|
new
|
|
{
|
|
targetGuid,
|
|
targetPath,
|
|
targetTypeName = target.GetType().FullName,
|
|
results,
|
|
warnings = warnings.Count > 0 ? warnings : null
|
|
}
|
|
);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates patches without applying them (for dry-run mode).
|
|
/// Checks that property paths exist and that value types are compatible.
|
|
/// </summary>
|
|
private static List<object> ValidatePatches(UnityEngine.Object target, JArray patches)
|
|
{
|
|
var results = new List<object>(patches.Count);
|
|
var so = new SerializedObject(target);
|
|
so.Update();
|
|
|
|
for (int i = 0; i < patches.Count; i++)
|
|
{
|
|
if (patches[i] is not JObject patchObj)
|
|
{
|
|
results.Add(new { index = i, propertyPath = "", op = "", ok = false, message = $"Patch at index {i} must be an object." });
|
|
continue;
|
|
}
|
|
|
|
string propertyPath = patchObj["propertyPath"]?.ToString()
|
|
?? patchObj["property_path"]?.ToString()
|
|
?? patchObj["path"]?.ToString();
|
|
string op = (patchObj["op"]?.ToString() ?? "set").Trim();
|
|
|
|
if (string.IsNullOrWhiteSpace(propertyPath))
|
|
{
|
|
results.Add(new { index = i, propertyPath = propertyPath ?? "", op, ok = false, message = "Missing required field: propertyPath" });
|
|
continue;
|
|
}
|
|
|
|
// Normalize the path
|
|
string normalizedPath = NormalizePropertyPath(propertyPath);
|
|
string normalizedOp = op.ToLowerInvariant();
|
|
|
|
// For array_resize, check if the array exists
|
|
if (normalizedOp == "array_resize")
|
|
{
|
|
var valueToken = patchObj["value"];
|
|
if (valueToken == null || valueToken.Type == JTokenType.Null)
|
|
{
|
|
results.Add(new { index = i, propertyPath = normalizedPath, op, ok = false, message = "array_resize requires integer 'value'." });
|
|
continue;
|
|
}
|
|
|
|
int size = ParamCoercion.CoerceInt(valueToken, -1);
|
|
if (size < 0)
|
|
{
|
|
results.Add(new { index = i, propertyPath = normalizedPath, op, ok = false, message = "array_resize requires non-negative integer 'value'." });
|
|
continue;
|
|
}
|
|
|
|
// Check if the array path exists
|
|
string arrayPath = normalizedPath;
|
|
if (arrayPath.EndsWith(".Array.size", StringComparison.Ordinal))
|
|
{
|
|
arrayPath = arrayPath.Substring(0, arrayPath.Length - ".Array.size".Length);
|
|
}
|
|
|
|
var arrayProp = so.FindProperty(arrayPath);
|
|
if (arrayProp == null)
|
|
{
|
|
results.Add(new { index = i, propertyPath = normalizedPath, op, ok = false, message = $"Array not found: {arrayPath}" });
|
|
continue;
|
|
}
|
|
|
|
if (!arrayProp.isArray)
|
|
{
|
|
results.Add(new { index = i, propertyPath = normalizedPath, op, ok = false, message = $"Property is not an array: {arrayPath}" });
|
|
continue;
|
|
}
|
|
|
|
results.Add(new { index = i, propertyPath = normalizedPath, op, ok = true, message = $"Will resize to {size}.", currentSize = arrayProp.arraySize });
|
|
continue;
|
|
}
|
|
|
|
// For set operations, check if the property exists (or can be auto-grown)
|
|
var prop = so.FindProperty(normalizedPath);
|
|
|
|
// Check if it's an auto-growable array element path
|
|
bool isAutoGrowable = false;
|
|
if (prop == null)
|
|
{
|
|
var match = Regex.Match(normalizedPath, @"^(.+?)\.Array\.data\[(\d+)\]");
|
|
if (match.Success)
|
|
{
|
|
string arrayPath = match.Groups[1].Value;
|
|
var arrayProp = so.FindProperty(arrayPath);
|
|
if (arrayProp != null && arrayProp.isArray)
|
|
{
|
|
isAutoGrowable = true;
|
|
// Get the element type info from existing elements or report as growable
|
|
int targetIndex = int.Parse(match.Groups[2].Value);
|
|
if (arrayProp.arraySize > 0)
|
|
{
|
|
var sampleElement = arrayProp.GetArrayElementAtIndex(0);
|
|
results.Add(new {
|
|
index = i,
|
|
propertyPath = normalizedPath,
|
|
op,
|
|
ok = true,
|
|
message = $"Will auto-grow array from {arrayProp.arraySize} to {targetIndex + 1}.",
|
|
elementType = sampleElement?.propertyType.ToString() ?? "unknown"
|
|
});
|
|
}
|
|
else
|
|
{
|
|
results.Add(new {
|
|
index = i,
|
|
propertyPath = normalizedPath,
|
|
op,
|
|
ok = true,
|
|
message = $"Will auto-grow empty array to size {targetIndex + 1}."
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (prop == null && !isAutoGrowable)
|
|
{
|
|
results.Add(new { index = i, propertyPath = normalizedPath, op, ok = false, message = $"Property not found: {normalizedPath}" });
|
|
continue;
|
|
}
|
|
|
|
if (prop != null)
|
|
{
|
|
// Property exists - validate value format for supported complex types
|
|
var valueToken = patchObj["value"];
|
|
string valueValidationMsg = null;
|
|
bool valueFormatOk = true;
|
|
|
|
// Enhanced dry-run: validate value format for AnimationCurve and Quaternion
|
|
// Uses shared validators from VectorParsing
|
|
if (valueToken != null && valueToken.Type != JTokenType.Null)
|
|
{
|
|
switch (prop.propertyType)
|
|
{
|
|
case SerializedPropertyType.AnimationCurve:
|
|
valueFormatOk = VectorParsing.ValidateAnimationCurveFormat(valueToken, out valueValidationMsg);
|
|
break;
|
|
case SerializedPropertyType.Quaternion:
|
|
valueFormatOk = VectorParsing.ValidateQuaternionFormat(valueToken, out valueValidationMsg);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (valueFormatOk)
|
|
{
|
|
results.Add(new {
|
|
index = i,
|
|
propertyPath = normalizedPath,
|
|
op,
|
|
ok = true,
|
|
message = valueValidationMsg ?? "Property found.",
|
|
propertyType = prop.propertyType.ToString(),
|
|
isArray = prop.isArray
|
|
});
|
|
}
|
|
else
|
|
{
|
|
results.Add(new {
|
|
index = i,
|
|
propertyPath = normalizedPath,
|
|
op,
|
|
ok = false,
|
|
message = valueValidationMsg,
|
|
propertyType = prop.propertyType.ToString(),
|
|
isArray = prop.isArray
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
private static (List<object> results, List<string> warnings) ApplyPatches(UnityEngine.Object target, JArray patches)
|
|
{
|
|
var warnings = new List<string>();
|
|
var results = new List<object>(patches.Count);
|
|
bool anyChanged = false;
|
|
|
|
var so = new SerializedObject(target);
|
|
so.Update();
|
|
|
|
for (int i = 0; i < patches.Count; i++)
|
|
{
|
|
if (patches[i] is not JObject patchObj)
|
|
{
|
|
results.Add(new { propertyPath = "", op = "", ok = false, message = $"Patch at index {i} must be an object." });
|
|
continue;
|
|
}
|
|
|
|
string propertyPath = patchObj["propertyPath"]?.ToString()
|
|
?? patchObj["property_path"]?.ToString()
|
|
?? patchObj["path"]?.ToString();
|
|
string op = (patchObj["op"]?.ToString() ?? "set").Trim();
|
|
if (string.IsNullOrWhiteSpace(propertyPath))
|
|
{
|
|
results.Add(new { propertyPath = propertyPath ?? "", op, ok = false, message = "Missing required field: propertyPath" });
|
|
continue;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(op))
|
|
{
|
|
op = "set";
|
|
}
|
|
|
|
var patchResult = ApplyPatch(so, propertyPath, op, patchObj, out bool changed);
|
|
anyChanged |= changed;
|
|
results.Add(patchResult);
|
|
|
|
// Array resize should be applied immediately so later paths resolve.
|
|
if (string.Equals(op, "array_resize", StringComparison.OrdinalIgnoreCase) && changed)
|
|
{
|
|
so.ApplyModifiedProperties();
|
|
so.Update();
|
|
}
|
|
}
|
|
|
|
if (anyChanged)
|
|
{
|
|
so.ApplyModifiedProperties();
|
|
EditorUtility.SetDirty(target);
|
|
AssetDatabase.SaveAssets();
|
|
}
|
|
|
|
return (results, warnings);
|
|
}
|
|
|
|
private static object ApplyPatch(SerializedObject so, string propertyPath, string op, JObject patchObj, out bool changed)
|
|
{
|
|
changed = false;
|
|
try
|
|
{
|
|
// Phase 1.1: Normalize friendly path syntax (e.g., myList[5] → myList.Array.data[5])
|
|
string normalizedPath = NormalizePropertyPath(propertyPath);
|
|
string normalizedOp = op.Trim().ToLowerInvariant();
|
|
|
|
switch (normalizedOp)
|
|
{
|
|
case "array_resize":
|
|
return ApplyArrayResize(so, normalizedPath, patchObj, out changed);
|
|
case "set":
|
|
default:
|
|
return ApplySet(so, normalizedPath, patchObj, out changed);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new { propertyPath, op, ok = false, message = ex.Message };
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Normalizes friendly property path syntax to Unity's internal format.
|
|
/// Converts bracket notation (e.g., myList[5]) to Unity's Array.data format (myList.Array.data[5]).
|
|
/// </summary>
|
|
private static string NormalizePropertyPath(string path)
|
|
{
|
|
if (string.IsNullOrEmpty(path))
|
|
return path;
|
|
|
|
// Pattern: word[number] where it's not already in .Array.data[number] format
|
|
// We need to handle cases like: myList[5], nested.list[0].field, etc.
|
|
// But NOT: myList.Array.data[5] (already in Unity format)
|
|
|
|
// Replace fieldName[index] with fieldName.Array.data[index]
|
|
// But only if it's not already in Array.data format
|
|
return Regex.Replace(path, @"(\w+)\[(\d+)\]", m =>
|
|
{
|
|
string fieldName = m.Groups[1].Value;
|
|
string index = m.Groups[2].Value;
|
|
|
|
// Check if this match is already part of .Array.data[index] pattern
|
|
// by checking if the text immediately before the field name is ".Array."
|
|
// and the field name is "data"
|
|
int matchStart = m.Index;
|
|
if (fieldName == "data" && matchStart >= 7) // Length of ".Array."
|
|
{
|
|
string preceding = path.Substring(matchStart - 7, 7);
|
|
if (preceding == ".Array.")
|
|
{
|
|
// Already in Unity format (e.g., myList.Array.data[0]), return as-is
|
|
return m.Value;
|
|
}
|
|
}
|
|
|
|
return $"{fieldName}.Array.data[{index}]";
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ensures an array has sufficient capacity for the given index.
|
|
/// Automatically resizes the array if the target index is beyond current bounds.
|
|
/// </summary>
|
|
/// <param name="so">The SerializedObject containing the array</param>
|
|
/// <param name="path">The normalized property path (must be in Array.data format)</param>
|
|
/// <param name="resized">True if the array was resized</param>
|
|
/// <returns>True if the path is valid for setting, false if it cannot be resolved</returns>
|
|
private static bool EnsureArrayCapacity(SerializedObject so, string path, out bool resized)
|
|
{
|
|
resized = false;
|
|
|
|
// Match pattern: something.Array.data[N]
|
|
var match = Regex.Match(path, @"^(.+?)\.Array\.data\[(\d+)\]");
|
|
if (!match.Success)
|
|
{
|
|
// Not an array element path, nothing to do
|
|
return true;
|
|
}
|
|
|
|
string arrayPath = match.Groups[1].Value;
|
|
if (!int.TryParse(match.Groups[2].Value, out int targetIndex))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var arrayProp = so.FindProperty(arrayPath);
|
|
if (arrayProp == null || !arrayProp.isArray)
|
|
{
|
|
// Array property not found or not an array
|
|
return false;
|
|
}
|
|
|
|
if (arrayProp.arraySize <= targetIndex)
|
|
{
|
|
// Need to grow the array
|
|
arrayProp.arraySize = targetIndex + 1;
|
|
so.ApplyModifiedProperties();
|
|
so.Update();
|
|
resized = true;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static object ApplyArrayResize(SerializedObject so, string propertyPath, JObject patchObj, out bool changed)
|
|
{
|
|
changed = false;
|
|
|
|
// Use ParamCoercion for robust int parsing
|
|
var valueToken = patchObj["value"];
|
|
if (valueToken == null || valueToken.Type == JTokenType.Null)
|
|
{
|
|
return new { propertyPath, op = "array_resize", ok = false, message = "array_resize requires integer 'value'." };
|
|
}
|
|
|
|
int newSize = ParamCoercion.CoerceInt(valueToken, -1);
|
|
if (newSize < 0)
|
|
{
|
|
return new { propertyPath, op = "array_resize", ok = false, message = "array_resize requires integer 'value'." };
|
|
}
|
|
|
|
newSize = Math.Max(0, newSize);
|
|
|
|
// Unity supports resizing either:
|
|
// - the array/list property itself (prop.isArray -> prop.arraySize)
|
|
// - the synthetic leaf property "<array>.Array.size" (prop.intValue)
|
|
//
|
|
// Different Unity versions/serialization edge cases can fail to resolve the synthetic leaf via FindProperty
|
|
// (or can return different property types), so we keep a "best-effort" fallback:
|
|
// - Prefer acting on the requested path if it resolves.
|
|
// - If the requested path doesn't resolve, try to resolve the *array property* and set arraySize directly.
|
|
SerializedProperty prop = so.FindProperty(propertyPath);
|
|
SerializedProperty arrayProp = null;
|
|
if (propertyPath.EndsWith(".Array.size", StringComparison.Ordinal))
|
|
{
|
|
// Caller explicitly targeted the synthetic leaf. Resolve the parent array property as a fallback
|
|
// (Unity sometimes fails to resolve the synthetic leaf in certain serialization contexts).
|
|
var arrayPath = propertyPath.Substring(0, propertyPath.Length - ".Array.size".Length);
|
|
arrayProp = so.FindProperty(arrayPath);
|
|
}
|
|
else
|
|
{
|
|
// Caller targeted either the array property itself (e.g., "items") or some other property.
|
|
// If it's already an array, we can resize it directly. Otherwise, we attempt to resolve
|
|
// a synthetic ".Array.size" leaf as a convenience, which some clients may pass.
|
|
arrayProp = prop != null && prop.isArray ? prop : so.FindProperty(propertyPath + ".Array.size");
|
|
}
|
|
|
|
if (prop == null)
|
|
{
|
|
// If we failed to find the direct property but we *can* find the array property, use that.
|
|
if (arrayProp != null && arrayProp.isArray)
|
|
{
|
|
if (arrayProp.arraySize != newSize)
|
|
{
|
|
arrayProp.arraySize = newSize;
|
|
changed = true;
|
|
}
|
|
return new
|
|
{
|
|
propertyPath,
|
|
op = "array_resize",
|
|
ok = true,
|
|
resolvedPropertyType = "Array",
|
|
message = $"Set array size to {newSize}."
|
|
};
|
|
}
|
|
|
|
return new { propertyPath, op = "array_resize", ok = false, message = $"Property not found: {propertyPath}" };
|
|
}
|
|
|
|
// Unity may represent ".Array.size" as either Integer or ArraySize depending on version.
|
|
if ((prop.propertyType == SerializedPropertyType.Integer || prop.propertyType == SerializedPropertyType.ArraySize)
|
|
&& propertyPath.EndsWith(".Array.size", StringComparison.Ordinal))
|
|
{
|
|
// We successfully resolved the synthetic leaf; write the size through its intValue.
|
|
if (prop.intValue != newSize)
|
|
{
|
|
prop.intValue = newSize;
|
|
changed = true;
|
|
}
|
|
return new { propertyPath, op = "array_resize", ok = true, resolvedPropertyType = prop.propertyType.ToString(), message = $"Set array size to {newSize}." };
|
|
}
|
|
|
|
if (prop.isArray)
|
|
{
|
|
// We resolved the array property itself; write through arraySize.
|
|
if (prop.arraySize != newSize)
|
|
{
|
|
prop.arraySize = newSize;
|
|
changed = true;
|
|
}
|
|
return new { propertyPath, op = "array_resize", ok = true, resolvedPropertyType = "Array", message = $"Set array size to {newSize}." };
|
|
}
|
|
|
|
return new { propertyPath, op = "array_resize", ok = false, resolvedPropertyType = prop.propertyType.ToString(), message = $"Property is not an array or array-size field: {propertyPath}" };
|
|
}
|
|
|
|
private static object ApplySet(SerializedObject so, string propertyPath, JObject patchObj, out bool changed)
|
|
{
|
|
changed = false;
|
|
|
|
// Phase 1.2: Auto-resize arrays if targeting an index beyond current bounds
|
|
if (!EnsureArrayCapacity(so, propertyPath, out bool arrayResized))
|
|
{
|
|
// Could not resolve the array path - try to find the property anyway for a better error message
|
|
var checkProp = so.FindProperty(propertyPath);
|
|
if (checkProp == null)
|
|
{
|
|
// Try to provide helpful context about what went wrong
|
|
var arrayMatch = Regex.Match(propertyPath, @"^(.+?)\.Array\.data\[(\d+)\]");
|
|
if (arrayMatch.Success)
|
|
{
|
|
string arrayPath = arrayMatch.Groups[1].Value;
|
|
var arrayProp = so.FindProperty(arrayPath);
|
|
if (arrayProp == null)
|
|
{
|
|
return new { propertyPath, op = "set", ok = false, message = $"Array property not found: {arrayPath}" };
|
|
}
|
|
if (!arrayProp.isArray)
|
|
{
|
|
return new { propertyPath, op = "set", ok = false, message = $"Property is not an array: {arrayPath}" };
|
|
}
|
|
}
|
|
return new { propertyPath, op = "set", ok = false, message = $"Property not found: {propertyPath}" };
|
|
}
|
|
}
|
|
|
|
var prop = so.FindProperty(propertyPath);
|
|
if (prop == null)
|
|
{
|
|
return new { propertyPath, op = "set", ok = false, message = $"Property not found: {propertyPath}" };
|
|
}
|
|
|
|
// Track if we resized - this counts as a change
|
|
if (arrayResized)
|
|
{
|
|
changed = true;
|
|
}
|
|
|
|
if (prop.propertyType == SerializedPropertyType.ObjectReference)
|
|
{
|
|
// Legacy "ref" key takes precedence for backward compatibility.
|
|
// Use TryGetValue to preserve non-JObject ref tokens (e.g. string GUID).
|
|
patchObj.TryGetValue("ref", out JToken refToken);
|
|
var objRefValue = patchObj["value"];
|
|
JToken resolveToken = refToken ?? objRefValue;
|
|
|
|
if (resolveToken == null)
|
|
{
|
|
return new { propertyPath, op = "set", ok = false, resolvedPropertyType = prop.propertyType.ToString(),
|
|
message = "ObjectReference patch requires a 'ref' or 'value' key." };
|
|
}
|
|
|
|
if (!ComponentOps.SetObjectReference(prop, resolveToken, out string refError))
|
|
{
|
|
return new { propertyPath, op = "set", ok = false, resolvedPropertyType = prop.propertyType.ToString(), message = refError };
|
|
}
|
|
|
|
changed = true;
|
|
string refMessage = prop.objectReferenceValue == null
|
|
? "Cleared reference."
|
|
: $"Set reference to '{prop.objectReferenceValue.name}'.";
|
|
return new { propertyPath, op = "set", ok = true, resolvedPropertyType = prop.propertyType.ToString(), message = refMessage };
|
|
}
|
|
|
|
var valueToken = patchObj["value"];
|
|
if (valueToken == null)
|
|
{
|
|
return new { propertyPath, op = "set", ok = false, resolvedPropertyType = prop.propertyType.ToString(), message = "Missing required field: value" };
|
|
}
|
|
|
|
bool ok = TrySetValue(prop, valueToken, out string message);
|
|
changed = ok;
|
|
return new { propertyPath, op = "set", ok, resolvedPropertyType = prop.propertyType.ToString(), message };
|
|
}
|
|
|
|
private static bool TrySetValue(SerializedProperty prop, JToken valueToken, out string message)
|
|
{
|
|
return TrySetValueRecursive(prop, valueToken, out message, 0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recursively sets values on SerializedProperties, supporting bulk array and object mapping.
|
|
/// </summary>
|
|
/// <param name="prop">The property to set</param>
|
|
/// <param name="valueToken">The JSON value</param>
|
|
/// <param name="message">Output message describing the result</param>
|
|
/// <param name="depth">Current recursion depth (for safety limits)</param>
|
|
private static bool TrySetValueRecursive(SerializedProperty prop, JToken valueToken, out string message, int depth)
|
|
{
|
|
message = null;
|
|
const int MaxRecursionDepth = 20;
|
|
|
|
if (depth > MaxRecursionDepth)
|
|
{
|
|
message = $"Maximum recursion depth ({MaxRecursionDepth}) exceeded. Check for circular references.";
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Phase 3.1: Handle bulk array mapping - JArray value for array/list properties
|
|
if (prop.isArray && prop.propertyType != SerializedPropertyType.String && valueToken is JArray jArray)
|
|
{
|
|
// Resize the array to match the JSON array
|
|
prop.arraySize = jArray.Count;
|
|
|
|
// Get the SerializedObject and apply so we can access elements
|
|
var so = prop.serializedObject;
|
|
so.ApplyModifiedProperties();
|
|
so.Update();
|
|
|
|
int successCount = 0;
|
|
var errors = new List<string>();
|
|
|
|
for (int i = 0; i < jArray.Count; i++)
|
|
{
|
|
var elementProp = prop.GetArrayElementAtIndex(i);
|
|
if (elementProp == null)
|
|
{
|
|
errors.Add($"Could not get element at index {i}");
|
|
continue;
|
|
}
|
|
|
|
if (TrySetValueRecursive(elementProp, jArray[i], out string elemMessage, depth + 1))
|
|
{
|
|
successCount++;
|
|
}
|
|
else
|
|
{
|
|
errors.Add($"[{i}]: {elemMessage}");
|
|
}
|
|
}
|
|
|
|
so.ApplyModifiedProperties();
|
|
|
|
if (errors.Count > 0)
|
|
{
|
|
message = $"Set {successCount}/{jArray.Count} elements. Errors: {string.Join("; ", errors)}";
|
|
return successCount > 0; // Partial success
|
|
}
|
|
|
|
message = $"Set array with {jArray.Count} elements.";
|
|
return true;
|
|
}
|
|
|
|
// Phase 3.2: Handle bulk object mapping - JObject value for Generic (struct/class) properties
|
|
if (prop.propertyType == SerializedPropertyType.Generic && !prop.isArray && valueToken is JObject jObj)
|
|
{
|
|
int successCount = 0;
|
|
var errors = new List<string>();
|
|
var so = prop.serializedObject;
|
|
|
|
foreach (var kvp in jObj)
|
|
{
|
|
string childPath = prop.propertyPath + "." + kvp.Key;
|
|
var childProp = so.FindProperty(childPath);
|
|
|
|
if (childProp == null)
|
|
{
|
|
errors.Add($"Property not found: {kvp.Key}");
|
|
continue;
|
|
}
|
|
|
|
if (TrySetValueRecursive(childProp, kvp.Value, out string childMessage, depth + 1))
|
|
{
|
|
successCount++;
|
|
}
|
|
else
|
|
{
|
|
errors.Add($"{kvp.Key}: {childMessage}");
|
|
}
|
|
}
|
|
|
|
so.ApplyModifiedProperties();
|
|
|
|
if (errors.Count > 0)
|
|
{
|
|
message = $"Set {successCount}/{jObj.Count} fields. Errors: {string.Join("; ", errors)}";
|
|
return successCount > 0; // Partial success
|
|
}
|
|
|
|
message = $"Set struct/class with {jObj.Count} fields.";
|
|
return true;
|
|
}
|
|
|
|
// ObjectReference - delegate to shared handler
|
|
if (prop.propertyType == SerializedPropertyType.ObjectReference)
|
|
{
|
|
if (!ComponentOps.SetObjectReference(prop, valueToken, out string refError))
|
|
{
|
|
message = refError;
|
|
return false;
|
|
}
|
|
message = prop.objectReferenceValue == null
|
|
? "Cleared reference."
|
|
: $"Set reference to '{prop.objectReferenceValue.name}'.";
|
|
return true;
|
|
}
|
|
|
|
// Supported Types: Integer, Boolean, Float, String, Enum, Vector2, Vector3, Vector4, Color
|
|
// Using shared helpers from ParamCoercion and VectorParsing
|
|
switch (prop.propertyType)
|
|
{
|
|
case SerializedPropertyType.Integer:
|
|
if (valueToken == null || valueToken.Type == JTokenType.Null)
|
|
{
|
|
message = "Expected integer value.";
|
|
return false;
|
|
}
|
|
if (valueToken.Type != JTokenType.Integer && valueToken.Type != JTokenType.Float
|
|
&& !long.TryParse(valueToken.ToString(), out _))
|
|
{
|
|
message = "Expected integer value.";
|
|
return false;
|
|
}
|
|
if (prop.type == "long")
|
|
prop.longValue = ParamCoercion.CoerceLong(valueToken, 0);
|
|
else
|
|
prop.intValue = ParamCoercion.CoerceInt(valueToken, 0);
|
|
message = prop.type == "long" ? "Set long." : "Set int.";
|
|
return true;
|
|
|
|
case SerializedPropertyType.Boolean:
|
|
// Use ParamCoercion for robust bool parsing (handles "true", "1", "yes", etc.)
|
|
if (valueToken == null || valueToken.Type == JTokenType.Null)
|
|
{
|
|
message = "Expected boolean value.";
|
|
return false;
|
|
}
|
|
bool boolVal = ParamCoercion.CoerceBool(valueToken, false);
|
|
// Verify it actually looked like a bool
|
|
if (valueToken.Type != JTokenType.Boolean)
|
|
{
|
|
string strVal = valueToken.ToString().Trim().ToLowerInvariant();
|
|
if (strVal != "true" && strVal != "false" && strVal != "1" && strVal != "0" &&
|
|
strVal != "yes" && strVal != "no" && strVal != "on" && strVal != "off")
|
|
{
|
|
message = "Expected boolean value.";
|
|
return false;
|
|
}
|
|
}
|
|
prop.boolValue = boolVal;
|
|
message = "Set bool.";
|
|
return true;
|
|
|
|
case SerializedPropertyType.Float:
|
|
// Use ParamCoercion for robust float parsing
|
|
float floatVal = ParamCoercion.CoerceFloat(valueToken, float.NaN);
|
|
if (float.IsNaN(floatVal))
|
|
{
|
|
message = "Expected float value.";
|
|
return false;
|
|
}
|
|
prop.floatValue = floatVal;
|
|
message = "Set float.";
|
|
return true;
|
|
|
|
case SerializedPropertyType.String:
|
|
prop.stringValue = valueToken.Type == JTokenType.Null ? null : valueToken.ToString();
|
|
message = "Set string.";
|
|
return true;
|
|
|
|
case SerializedPropertyType.Enum:
|
|
return TrySetEnum(prop, valueToken, out message);
|
|
|
|
case SerializedPropertyType.Vector2:
|
|
// Use VectorParsing for Vector2
|
|
var v2 = VectorParsing.ParseVector2(valueToken);
|
|
if (v2 == null)
|
|
{
|
|
message = "Expected Vector2 (array or object).";
|
|
return false;
|
|
}
|
|
prop.vector2Value = v2.Value;
|
|
message = "Set Vector2.";
|
|
return true;
|
|
|
|
case SerializedPropertyType.Vector3:
|
|
// Use VectorParsing for Vector3
|
|
var v3 = VectorParsing.ParseVector3(valueToken);
|
|
if (v3 == null)
|
|
{
|
|
message = "Expected Vector3 (array or object).";
|
|
return false;
|
|
}
|
|
prop.vector3Value = v3.Value;
|
|
message = "Set Vector3.";
|
|
return true;
|
|
|
|
case SerializedPropertyType.Vector4:
|
|
// Use VectorParsing for Vector4
|
|
var v4 = VectorParsing.ParseVector4(valueToken);
|
|
if (v4 == null)
|
|
{
|
|
message = "Expected Vector4 (array or object).";
|
|
return false;
|
|
}
|
|
prop.vector4Value = v4.Value;
|
|
message = "Set Vector4.";
|
|
return true;
|
|
|
|
case SerializedPropertyType.Color:
|
|
// Use VectorParsing for Color
|
|
var col = VectorParsing.ParseColor(valueToken);
|
|
if (col == null)
|
|
{
|
|
message = "Expected Color (array or object).";
|
|
return false;
|
|
}
|
|
prop.colorValue = col.Value;
|
|
message = "Set Color.";
|
|
return true;
|
|
|
|
case SerializedPropertyType.AnimationCurve:
|
|
return TrySetAnimationCurve(prop, valueToken, out message);
|
|
|
|
case SerializedPropertyType.Quaternion:
|
|
return TrySetQuaternion(prop, valueToken, out message);
|
|
|
|
case SerializedPropertyType.Generic:
|
|
// Generic properties (structs/classes) should be handled above with JObject mapping
|
|
// If we get here, the value wasn't a JObject
|
|
if (prop.isArray)
|
|
{
|
|
message = $"Expected array (JArray) for array property, got {valueToken?.Type.ToString() ?? "null"}.";
|
|
}
|
|
else
|
|
{
|
|
message = $"Expected object (JObject) for struct/class property, got {valueToken?.Type.ToString() ?? "null"}.";
|
|
}
|
|
return false;
|
|
|
|
default:
|
|
message = $"Unsupported SerializedPropertyType: {prop.propertyType}. " +
|
|
"This type cannot be set via MCP patches. Consider editing the .asset file directly " +
|
|
"or using Unity's Inspector. For complex types, check if there's a supported alternative format.";
|
|
return false;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
message = ex.Message;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool TrySetEnum(SerializedProperty prop, JToken valueToken, out string message)
|
|
{
|
|
message = null;
|
|
var names = prop.enumNames;
|
|
if (names == null || names.Length == 0) { message = "Enum has no names."; return false; }
|
|
|
|
if (valueToken.Type == JTokenType.Integer)
|
|
{
|
|
int idx = valueToken.Value<int>();
|
|
if (idx < 0 || idx >= names.Length) { message = $"Enum index out of range: {idx}"; return false; }
|
|
prop.enumValueIndex = idx; message = "Set enum."; return true;
|
|
}
|
|
|
|
string s = valueToken.ToString();
|
|
for (int i = 0; i < names.Length; i++)
|
|
{
|
|
if (string.Equals(names[i], s, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
prop.enumValueIndex = i; message = "Set enum."; return true;
|
|
}
|
|
}
|
|
message = $"Unknown enum name '{s}'.";
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets an AnimationCurve property from a JSON structure.
|
|
///
|
|
/// <para><b>Supported formats:</b></para>
|
|
/// <list type="bullet">
|
|
/// <item>Wrapped: <c>{ "keys": [ { "time": 0, "value": 1.0 }, ... ] }</c></item>
|
|
/// <item>Direct array: <c>[ { "time": 0, "value": 1.0 }, ... ]</c></item>
|
|
/// <item>Null/empty: Sets an empty AnimationCurve</item>
|
|
/// </list>
|
|
///
|
|
/// <para><b>Keyframe fields:</b></para>
|
|
/// <list type="bullet">
|
|
/// <item><c>time</c> (float): Keyframe time position. <b>Default: 0</b></item>
|
|
/// <item><c>value</c> (float): Keyframe value. <b>Default: 0</b></item>
|
|
/// <item><c>inSlope</c> or <c>inTangent</c> (float): Incoming tangent slope. <b>Default: 0</b></item>
|
|
/// <item><c>outSlope</c> or <c>outTangent</c> (float): Outgoing tangent slope. <b>Default: 0</b></item>
|
|
/// <item><c>weightedMode</c> (int): Weighted mode enum (0=None, 1=In, 2=Out, 3=Both). <b>Default: 0 (None)</b></item>
|
|
/// <item><c>inWeight</c> (float): Incoming tangent weight. <b>Default: 0</b></item>
|
|
/// <item><c>outWeight</c> (float): Outgoing tangent weight. <b>Default: 0</b></item>
|
|
/// </list>
|
|
///
|
|
/// <para><b>Note:</b> All keyframe fields are optional. Missing fields gracefully default to 0,
|
|
/// which produces linear interpolation when both tangents are 0.</para>
|
|
/// </summary>
|
|
/// <param name="prop">The SerializedProperty of type AnimationCurve to set</param>
|
|
/// <param name="valueToken">JSON token containing the curve data</param>
|
|
/// <param name="message">Output message describing the result</param>
|
|
/// <returns>True if successful, false if the format is invalid</returns>
|
|
private static bool TrySetAnimationCurve(SerializedProperty prop, JToken valueToken, out string message)
|
|
{
|
|
message = null;
|
|
|
|
if (valueToken == null || valueToken.Type == JTokenType.Null)
|
|
{
|
|
// Set to empty curve
|
|
prop.animationCurveValue = new AnimationCurve();
|
|
message = "Set AnimationCurve to empty.";
|
|
return true;
|
|
}
|
|
|
|
JArray keysArray = null;
|
|
|
|
// Accept either { "keys": [...] } or just [...]
|
|
if (valueToken is JObject curveObj)
|
|
{
|
|
keysArray = curveObj["keys"] as JArray;
|
|
if (keysArray == null)
|
|
{
|
|
message = "AnimationCurve object requires 'keys' array. Expected: { \"keys\": [ { \"time\": 0, \"value\": 0 }, ... ] }";
|
|
return false;
|
|
}
|
|
}
|
|
else if (valueToken is JArray directArray)
|
|
{
|
|
keysArray = directArray;
|
|
}
|
|
else
|
|
{
|
|
message = "AnimationCurve requires object with 'keys' or array of keyframes. " +
|
|
"Expected: { \"keys\": [ { \"time\": 0, \"value\": 0, \"inSlope\": 0, \"outSlope\": 0 }, ... ] }";
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
var curve = new AnimationCurve();
|
|
foreach (var keyToken in keysArray)
|
|
{
|
|
if (keyToken is not JObject keyObj)
|
|
{
|
|
message = "Each keyframe must be an object with 'time' and 'value'.";
|
|
return false;
|
|
}
|
|
|
|
float time = keyObj["time"]?.Value<float>() ?? 0f;
|
|
float value = keyObj["value"]?.Value<float>() ?? 0f;
|
|
float inSlope = keyObj["inSlope"]?.Value<float>() ?? keyObj["inTangent"]?.Value<float>() ?? 0f;
|
|
float outSlope = keyObj["outSlope"]?.Value<float>() ?? keyObj["outTangent"]?.Value<float>() ?? 0f;
|
|
|
|
var keyframe = new Keyframe(time, value, inSlope, outSlope);
|
|
|
|
// Optional: weighted tangent mode (Unity 2018.1+)
|
|
if (keyObj["weightedMode"] != null)
|
|
{
|
|
int weightedMode = keyObj["weightedMode"].Value<int>();
|
|
keyframe.weightedMode = (WeightedMode)weightedMode;
|
|
}
|
|
if (keyObj["inWeight"] != null)
|
|
{
|
|
keyframe.inWeight = keyObj["inWeight"].Value<float>();
|
|
}
|
|
if (keyObj["outWeight"] != null)
|
|
{
|
|
keyframe.outWeight = keyObj["outWeight"].Value<float>();
|
|
}
|
|
|
|
curve.AddKey(keyframe);
|
|
}
|
|
|
|
prop.animationCurveValue = curve;
|
|
message = $"Set AnimationCurve with {keysArray.Count} keyframes.";
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
message = $"Failed to parse AnimationCurve: {ex.Message}";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets a Quaternion property from JSON.
|
|
///
|
|
/// <para><b>Supported formats:</b></para>
|
|
/// <list type="bullet">
|
|
/// <item>Euler array: <c>[x, y, z]</c> - Euler angles in degrees</item>
|
|
/// <item>Raw quaternion array: <c>[x, y, z, w]</c> - Direct quaternion components</item>
|
|
/// <item>Object format: <c>{ "x": 0, "y": 0, "z": 0, "w": 1 }</c> - Direct components</item>
|
|
/// <item>Explicit euler: <c>{ "euler": [x, y, z] }</c> - Euler angles in degrees</item>
|
|
/// <item>Null/empty: Sets Quaternion.identity (no rotation)</item>
|
|
/// </list>
|
|
///
|
|
/// <para><b>Format detection:</b></para>
|
|
/// <list type="bullet">
|
|
/// <item>3-element array → Interpreted as Euler angles (degrees)</item>
|
|
/// <item>4-element array → Interpreted as raw quaternion [x, y, z, w]</item>
|
|
/// <item>Object with euler → Uses euler array for rotation</item>
|
|
/// <item>Object with x, y, z, w → Uses raw quaternion components</item>
|
|
/// </list>
|
|
/// </summary>
|
|
/// <param name="prop">The SerializedProperty of type Quaternion to set</param>
|
|
/// <param name="valueToken">JSON token containing the quaternion data</param>
|
|
/// <param name="message">Output message describing the result</param>
|
|
/// <returns>True if successful, false if the format is invalid</returns>
|
|
private static bool TrySetQuaternion(SerializedProperty prop, JToken valueToken, out string message)
|
|
{
|
|
message = null;
|
|
|
|
if (valueToken == null || valueToken.Type == JTokenType.Null)
|
|
{
|
|
prop.quaternionValue = Quaternion.identity;
|
|
message = "Set Quaternion to identity.";
|
|
return true;
|
|
}
|
|
|
|
try
|
|
{
|
|
if (valueToken is JArray arr)
|
|
{
|
|
if (arr.Count == 3)
|
|
{
|
|
// Euler angles [x, y, z]
|
|
var euler = new Vector3(
|
|
arr[0].Value<float>(),
|
|
arr[1].Value<float>(),
|
|
arr[2].Value<float>()
|
|
);
|
|
prop.quaternionValue = Quaternion.Euler(euler);
|
|
message = $"Set Quaternion from Euler({euler.x}, {euler.y}, {euler.z}).";
|
|
return true;
|
|
}
|
|
else if (arr.Count == 4)
|
|
{
|
|
// Raw quaternion [x, y, z, w]
|
|
prop.quaternionValue = new Quaternion(
|
|
arr[0].Value<float>(),
|
|
arr[1].Value<float>(),
|
|
arr[2].Value<float>(),
|
|
arr[3].Value<float>()
|
|
);
|
|
message = "Set Quaternion from [x, y, z, w].";
|
|
return true;
|
|
}
|
|
else
|
|
{
|
|
message = "Quaternion array must have 3 elements (Euler) or 4 elements (x, y, z, w).";
|
|
return false;
|
|
}
|
|
}
|
|
else if (valueToken is JObject obj)
|
|
{
|
|
// Check for explicit euler property
|
|
if (obj["euler"] is JArray eulerArr && eulerArr.Count == 3)
|
|
{
|
|
var euler = new Vector3(
|
|
eulerArr[0].Value<float>(),
|
|
eulerArr[1].Value<float>(),
|
|
eulerArr[2].Value<float>()
|
|
);
|
|
prop.quaternionValue = Quaternion.Euler(euler);
|
|
message = $"Set Quaternion from euler: ({euler.x}, {euler.y}, {euler.z}).";
|
|
return true;
|
|
}
|
|
|
|
// Object format { x, y, z, w }
|
|
if (obj["x"] != null && obj["y"] != null && obj["z"] != null && obj["w"] != null)
|
|
{
|
|
prop.quaternionValue = new Quaternion(
|
|
obj["x"].Value<float>(),
|
|
obj["y"].Value<float>(),
|
|
obj["z"].Value<float>(),
|
|
obj["w"].Value<float>()
|
|
);
|
|
message = "Set Quaternion from { x, y, z, w }.";
|
|
return true;
|
|
}
|
|
|
|
message = "Quaternion object must have { x, y, z, w } or { euler: [x, y, z] }.";
|
|
return false;
|
|
}
|
|
else
|
|
{
|
|
message = "Quaternion requires array [x,y,z] (Euler), [x,y,z,w] (raw), or object { x, y, z, w }.";
|
|
return false;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
message = $"Failed to parse Quaternion: {ex.Message}";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool TryResolveTarget(JToken targetToken, out UnityEngine.Object target, out string targetPath, out string targetGuid, out object error)
|
|
{
|
|
target = null;
|
|
targetPath = null;
|
|
targetGuid = null;
|
|
error = null;
|
|
|
|
if (targetToken is not JObject targetObj)
|
|
{
|
|
error = new ErrorResponse(CodeInvalidParams, new { message = "'target' must be an object with {guid|path}." });
|
|
return false;
|
|
}
|
|
|
|
string guid = targetObj["guid"]?.ToString();
|
|
string path = targetObj["path"]?.ToString();
|
|
|
|
if (string.IsNullOrWhiteSpace(guid) && string.IsNullOrWhiteSpace(path))
|
|
{
|
|
error = new ErrorResponse(CodeInvalidParams, new { message = "'target' must include 'guid' or 'path'." });
|
|
return false;
|
|
}
|
|
|
|
string resolvedPath = !string.IsNullOrWhiteSpace(guid)
|
|
? AssetDatabase.GUIDToAssetPath(guid)
|
|
: AssetPathUtility.SanitizeAssetPath(path);
|
|
|
|
if (string.IsNullOrWhiteSpace(resolvedPath))
|
|
{
|
|
error = new ErrorResponse(CodeTargetNotFound, new { message = "Could not resolve target path.", guid, path });
|
|
return false;
|
|
}
|
|
|
|
var obj = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(resolvedPath);
|
|
if (obj == null)
|
|
{
|
|
error = new ErrorResponse(CodeTargetNotFound, new { message = "Target asset not found.", targetPath = resolvedPath, targetGuid = guid });
|
|
return false;
|
|
}
|
|
|
|
target = obj;
|
|
targetPath = resolvedPath;
|
|
targetGuid = string.IsNullOrWhiteSpace(guid) ? AssetDatabase.AssetPathToGUID(resolvedPath) : guid;
|
|
return true;
|
|
}
|
|
|
|
private static void CoerceJsonStringArrayParameter(JObject @params, string paramName)
|
|
{
|
|
var token = @params?[paramName];
|
|
if (token != null && token.Type == JTokenType.String)
|
|
{
|
|
try
|
|
{
|
|
var parsed = JToken.Parse(token.ToString());
|
|
if (parsed is JArray arr)
|
|
{
|
|
@params[paramName] = arr;
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
McpLog.Warn($"[MCP] Could not parse '{paramName}' JSON string: {e.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static bool EnsureFolderExists(string folderPath, out string error)
|
|
{
|
|
error = null;
|
|
if (string.IsNullOrWhiteSpace(folderPath))
|
|
{
|
|
error = "Folder path is empty.";
|
|
return false;
|
|
}
|
|
|
|
// Expect normalized input here (Assets/... or Assets).
|
|
string sanitized = SanitizeSlashes(folderPath);
|
|
|
|
if (!sanitized.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)
|
|
&& !string.Equals(sanitized, "Assets", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
error = "Folder path must be under Assets/.";
|
|
return false;
|
|
}
|
|
|
|
if (string.Equals(sanitized, "Assets", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
sanitized = sanitized.TrimEnd('/');
|
|
if (AssetDatabase.IsValidFolder(sanitized))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Create recursively from Assets/
|
|
var parts = sanitized.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
|
|
if (parts.Length == 0 || !string.Equals(parts[0], "Assets", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
error = "Folder path must start with Assets/";
|
|
return false;
|
|
}
|
|
|
|
string current = "Assets";
|
|
for (int i = 1; i < parts.Length; i++)
|
|
{
|
|
string next = current + "/" + parts[i];
|
|
if (!AssetDatabase.IsValidFolder(next))
|
|
{
|
|
string guid = AssetDatabase.CreateFolder(current, parts[i]);
|
|
if (string.IsNullOrEmpty(guid))
|
|
{
|
|
error = $"Failed to create folder: {next}";
|
|
return false;
|
|
}
|
|
}
|
|
current = next;
|
|
}
|
|
|
|
return AssetDatabase.IsValidFolder(sanitized);
|
|
}
|
|
|
|
private static string SanitizeSlashes(string path)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
{
|
|
return path;
|
|
}
|
|
|
|
var s = AssetPathUtility.NormalizeSeparators(path);
|
|
while (s.IndexOf("//", StringComparison.Ordinal) >= 0)
|
|
{
|
|
s = s.Replace("//", "/", StringComparison.Ordinal);
|
|
}
|
|
return s;
|
|
}
|
|
|
|
private static bool TryNormalizeFolderPath(string folderPath, out string normalized, out string error)
|
|
{
|
|
normalized = null;
|
|
error = null;
|
|
|
|
if (string.IsNullOrWhiteSpace(folderPath))
|
|
{
|
|
error = "Folder path is empty.";
|
|
return false;
|
|
}
|
|
|
|
var s = SanitizeSlashes(folderPath.Trim());
|
|
|
|
// Reject obvious non-project/invalid roots. We only support Assets/ (and relative paths that will be rooted under Assets/).
|
|
if (s.StartsWith("/", StringComparison.Ordinal)
|
|
|| s.StartsWith("file:", StringComparison.OrdinalIgnoreCase)
|
|
|| Regex.IsMatch(s, @"^[a-zA-Z]:"))
|
|
{
|
|
error = "Folder path must be a project-relative path under Assets/.";
|
|
return false;
|
|
}
|
|
|
|
if (s.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase)
|
|
|| s.StartsWith("ProjectSettings/", StringComparison.OrdinalIgnoreCase)
|
|
|| s.StartsWith("Library/", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
error = "Folder path must be under Assets/.";
|
|
return false;
|
|
}
|
|
|
|
if (string.Equals(s, "Assets", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
normalized = "Assets";
|
|
return true;
|
|
}
|
|
|
|
if (s.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
normalized = s.TrimEnd('/');
|
|
return true;
|
|
}
|
|
|
|
// Allow relative paths like "Temp/MyFolder" and root them under Assets/.
|
|
normalized = ("Assets/" + s.TrimStart('/')).TrimEnd('/');
|
|
return true;
|
|
}
|
|
|
|
// NOTE: Local TryGet* helpers have been removed.
|
|
// Using shared helpers instead: ParamCoercion (for int/float/bool) and VectorParsing (for Vector2/3/4, Color)
|
|
|
|
private static string NormalizeAction(string raw)
|
|
{
|
|
var s = raw.Trim();
|
|
s = s.Replace("-", "").Replace("_", "");
|
|
return s.ToLowerInvariant();
|
|
}
|
|
|
|
private static bool IsCreateAction(string normalized)
|
|
{
|
|
return normalized == "create" || normalized == "createso";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves a type by name. Delegates to UnityTypeResolver.ResolveAny().
|
|
/// </summary>
|
|
private static Type ResolveType(string typeName)
|
|
{
|
|
return Helpers.UnityTypeResolver.ResolveAny(typeName);
|
|
}
|
|
}
|
|
}
|