1523 lines
66 KiB
C#
1523 lines
66 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text.RegularExpressions;
|
|
using MCPForUnity.Editor.Helpers;
|
|
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)]
|
|
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 (EditorApplication.isCompiling || 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)
|
|
{
|
|
var refObj = patchObj["ref"] as JObject;
|
|
var objRefValue = patchObj["value"];
|
|
UnityEngine.Object newRef = null;
|
|
string refGuid = refObj?["guid"]?.ToString();
|
|
string refPath = refObj?["path"]?.ToString();
|
|
string resolveMethod = "explicit";
|
|
|
|
if (refObj == null && objRefValue?.Type == JTokenType.Null)
|
|
{
|
|
// Explicit null - clear the reference
|
|
newRef = null;
|
|
resolveMethod = "cleared";
|
|
}
|
|
else if (!string.IsNullOrEmpty(refGuid) || !string.IsNullOrEmpty(refPath))
|
|
{
|
|
// Traditional ref object with guid or path
|
|
string resolvedPath = !string.IsNullOrEmpty(refGuid)
|
|
? AssetDatabase.GUIDToAssetPath(refGuid)
|
|
: AssetPathUtility.SanitizeAssetPath(refPath);
|
|
|
|
if (!string.IsNullOrEmpty(resolvedPath))
|
|
{
|
|
newRef = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(resolvedPath);
|
|
}
|
|
resolveMethod = !string.IsNullOrEmpty(refGuid) ? "ref.guid" : "ref.path";
|
|
}
|
|
else if (objRefValue?.Type == JTokenType.String)
|
|
{
|
|
// Phase 4: GUID shorthand - allow plain string value
|
|
string strVal = objRefValue.ToString();
|
|
|
|
// Check if it's a GUID (32 hex characters, no dashes)
|
|
if (Regex.IsMatch(strVal, @"^[0-9a-fA-F]{32}$"))
|
|
{
|
|
string guidPath = AssetDatabase.GUIDToAssetPath(strVal);
|
|
if (!string.IsNullOrEmpty(guidPath))
|
|
{
|
|
newRef = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(guidPath);
|
|
resolveMethod = "guid-shorthand";
|
|
}
|
|
}
|
|
// Check if it looks like an asset path
|
|
else if (strVal.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) ||
|
|
strVal.Contains("/"))
|
|
{
|
|
string sanitizedPath = AssetPathUtility.SanitizeAssetPath(strVal);
|
|
newRef = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(sanitizedPath);
|
|
resolveMethod = "path-shorthand";
|
|
}
|
|
}
|
|
|
|
if (prop.objectReferenceValue != newRef)
|
|
{
|
|
prop.objectReferenceValue = newRef;
|
|
changed = true;
|
|
}
|
|
|
|
string refMessage = newRef == null ? "Cleared reference." : $"Set reference ({resolveMethod}).";
|
|
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;
|
|
}
|
|
|
|
// Supported Types: Integer, Boolean, Float, String, Enum, Vector2, Vector3, Vector4, Color
|
|
// Using shared helpers from ParamCoercion and VectorParsing
|
|
switch (prop.propertyType)
|
|
{
|
|
case SerializedPropertyType.Integer:
|
|
// Use ParamCoercion for robust int parsing
|
|
int intVal = ParamCoercion.CoerceInt(valueToken, int.MinValue);
|
|
if (intVal == int.MinValue && valueToken?.Type != JTokenType.Integer)
|
|
{
|
|
// Double-check: if it's actually int.MinValue or failed to parse
|
|
if (valueToken == null || valueToken.Type == JTokenType.Null ||
|
|
(valueToken.Type == JTokenType.String && !int.TryParse(valueToken.ToString(), out _)))
|
|
{
|
|
message = "Expected integer value.";
|
|
return false;
|
|
}
|
|
}
|
|
prop.intValue = intVal;
|
|
message = "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);
|
|
}
|
|
}
|
|
}
|