diff --git a/MCPForUnity/Editor/Helpers/MaterialOps.cs b/MCPForUnity/Editor/Helpers/MaterialOps.cs new file mode 100644 index 000000000..0f80fcbb5 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/MaterialOps.cs @@ -0,0 +1,397 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEngine; +using UnityEditor; +using MCPForUnity.Editor.Tools; + +namespace MCPForUnity.Editor.Helpers +{ + public static class MaterialOps + { + /// + /// Applies a set of properties (JObject) to a material, handling aliases and structured formats. + /// + public static bool ApplyProperties(Material mat, JObject properties, JsonSerializer serializer) + { + if (mat == null || properties == null) + return false; + bool modified = false; + + // Helper for case-insensitive lookup + JToken GetValue(string key) + { + return properties.Properties() + .FirstOrDefault(p => string.Equals(p.Name, key, StringComparison.OrdinalIgnoreCase))?.Value; + } + + // --- Structured / Legacy Format Handling --- + // Example: Set shader + var shaderToken = GetValue("shader"); + if (shaderToken?.Type == JTokenType.String) + { + string shaderRequest = shaderToken.ToString(); + // Set shader + Shader newShader = RenderPipelineUtility.ResolveShader(shaderRequest); + if (newShader != null && mat.shader != newShader) + { + mat.shader = newShader; + modified = true; + } + } + + // Example: Set color property (structured) + var colorToken = GetValue("color"); + if (colorToken is JObject colorProps) + { + string propName = colorProps["name"]?.ToString() ?? GetMainColorPropertyName(mat); + if (colorProps["value"] is JArray colArr && colArr.Count >= 3) + { + try + { + Color newColor = ParseColor(colArr, serializer); + if (mat.HasProperty(propName)) + { + if (mat.GetColor(propName) != newColor) + { + mat.SetColor(propName, newColor); + modified = true; + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"[MaterialOps] Failed to parse color for property '{propName}': {ex.Message}"); + } + } + } + else if (colorToken is JArray colorArr) // Structured shorthand + { + string propName = GetMainColorPropertyName(mat); + try + { + Color newColor = ParseColor(colorArr, serializer); + if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor) + { + mat.SetColor(propName, newColor); + modified = true; + } + } + catch (Exception ex) + { + Debug.LogWarning($"[MaterialOps] Failed to parse color array: {ex.Message}"); + } + } + + // Example: Set float property (structured) + var floatToken = GetValue("float"); + if (floatToken is JObject floatProps) + { + string propName = floatProps["name"]?.ToString(); + if (!string.IsNullOrEmpty(propName) && + (floatProps["value"]?.Type == JTokenType.Float || floatProps["value"]?.Type == JTokenType.Integer)) + { + try + { + float newVal = floatProps["value"].ToObject(); + if (mat.HasProperty(propName) && mat.GetFloat(propName) != newVal) + { + mat.SetFloat(propName, newVal); + modified = true; + } + } + catch (Exception ex) + { + Debug.LogWarning($"[MaterialOps] Failed to set float property '{propName}': {ex.Message}"); + } + } + } + + // Example: Set texture property (structured) + { + var texToken = GetValue("texture"); + if (texToken is JObject texProps) + { + string rawName = (texProps["name"] ?? texProps["Name"])?.ToString(); + string texPath = (texProps["path"] ?? texProps["Path"])?.ToString(); + if (!string.IsNullOrEmpty(texPath)) + { + var sanitizedPath = AssetPathUtility.SanitizeAssetPath(texPath); + var newTex = AssetDatabase.LoadAssetAtPath(sanitizedPath); + // Use ResolvePropertyName to handle aliases even for structured texture names + string candidateName = string.IsNullOrEmpty(rawName) ? "_BaseMap" : rawName; + string targetProp = ResolvePropertyName(mat, candidateName); + + if (!string.IsNullOrEmpty(targetProp) && mat.HasProperty(targetProp)) + { + if (mat.GetTexture(targetProp) != newTex) + { + mat.SetTexture(targetProp, newTex); + modified = true; + } + } + } + } + } + + // --- Direct Property Assignment (Flexible) --- + var reservedKeys = new HashSet(StringComparer.OrdinalIgnoreCase) { "shader", "color", "float", "texture" }; + + foreach (var prop in properties.Properties()) + { + if (reservedKeys.Contains(prop.Name)) continue; + string shaderProp = ResolvePropertyName(mat, prop.Name); + JToken v = prop.Value; + + if (TrySetShaderProperty(mat, shaderProp, v, serializer)) + { + modified = true; + } + } + + return modified; + } + + /// + /// Resolves common property aliases (e.g. "metallic" -> "_Metallic"). + /// + public static string ResolvePropertyName(Material mat, string name) + { + if (mat == null || string.IsNullOrEmpty(name)) return name; + string[] candidates; + var lower = name.ToLowerInvariant(); + switch (lower) + { + case "_color": candidates = new[] { "_Color", "_BaseColor" }; break; + case "_basecolor": candidates = new[] { "_BaseColor", "_Color" }; break; + case "_maintex": candidates = new[] { "_MainTex", "_BaseMap" }; break; + case "_basemap": candidates = new[] { "_BaseMap", "_MainTex" }; break; + case "_glossiness": candidates = new[] { "_Glossiness", "_Smoothness" }; break; + case "_smoothness": candidates = new[] { "_Smoothness", "_Glossiness" }; break; + // Friendly names → shader property names + case "metallic": candidates = new[] { "_Metallic" }; break; + case "smoothness": candidates = new[] { "_Smoothness", "_Glossiness" }; break; + case "albedo": candidates = new[] { "_BaseMap", "_MainTex" }; break; + default: candidates = new[] { name }; break; // keep original as-is + } + foreach (var candidate in candidates) + { + if (mat.HasProperty(candidate)) return candidate; + } + return name; + } + + /// + /// Auto-detects the main color property name for a material's shader. + /// + public static string GetMainColorPropertyName(Material mat) + { + if (mat == null || mat.shader == null) + return "_Color"; + + string[] commonColorProps = { "_BaseColor", "_Color", "_MainColor", "_Tint", "_TintColor" }; + foreach (var prop in commonColorProps) + { + if (mat.HasProperty(prop)) + return prop; + } + return "_Color"; + } + + /// + /// Tries to set a shader property on a material based on a JToken value. + /// Handles Colors, Vectors, Floats, Ints, Booleans, and Textures. + /// + public static bool TrySetShaderProperty(Material material, string propertyName, JToken value, JsonSerializer serializer) + { + if (material == null || string.IsNullOrEmpty(propertyName) || value == null) + return false; + + // Handle stringified JSON + if (value.Type == JTokenType.String) + { + string s = value.ToString(); + if (s.TrimStart().StartsWith("[") || s.TrimStart().StartsWith("{")) + { + try + { + JToken parsed = JToken.Parse(s); + return TrySetShaderProperty(material, propertyName, parsed, serializer); + } + catch { } + } + } + + // Use the serializer to convert the JToken value first + if (value is JArray jArray) + { + if (jArray.Count == 4) + { + if (material.HasProperty(propertyName)) + { + try { material.SetColor(propertyName, ParseColor(value, serializer)); return true; } + catch (Exception ex) + { + // Log at Debug level since we'll try other conversions + Debug.Log($"[MaterialOps] SetColor attempt for '{propertyName}' failed: {ex.Message}"); + } + + try { Vector4 vec = value.ToObject(serializer); material.SetVector(propertyName, vec); return true; } + catch (Exception ex) + { + Debug.Log($"[MaterialOps] SetVector (Vec4) attempt for '{propertyName}' failed: {ex.Message}"); + } + } + } + else if (jArray.Count == 3) + { + if (material.HasProperty(propertyName)) + { + try { material.SetColor(propertyName, ParseColor(value, serializer)); return true; } + catch (Exception ex) + { + Debug.Log($"[MaterialOps] SetColor (Vec3) attempt for '{propertyName}' failed: {ex.Message}"); + } + } + } + else if (jArray.Count == 2) + { + if (material.HasProperty(propertyName)) + { + try { Vector2 vec = value.ToObject(serializer); material.SetVector(propertyName, vec); return true; } + catch (Exception ex) + { + Debug.Log($"[MaterialOps] SetVector (Vec2) attempt for '{propertyName}' failed: {ex.Message}"); + } + } + } + } + else if (value.Type == JTokenType.Float || value.Type == JTokenType.Integer) + { + if (!material.HasProperty(propertyName)) + return false; + + try { material.SetFloat(propertyName, value.ToObject(serializer)); return true; } + catch (Exception ex) + { + Debug.Log($"[MaterialOps] SetFloat attempt for '{propertyName}' failed: {ex.Message}"); + } + } + else if (value.Type == JTokenType.Boolean) + { + if (!material.HasProperty(propertyName)) + return false; + + try { material.SetFloat(propertyName, value.ToObject(serializer) ? 1f : 0f); return true; } + catch (Exception ex) + { + Debug.Log($"[MaterialOps] SetFloat (bool) attempt for '{propertyName}' failed: {ex.Message}"); + } + } + else if (value.Type == JTokenType.String) + { + try + { + // Try loading as asset path first (most common case for strings in this context) + string path = value.ToString(); + if (!string.IsNullOrEmpty(path) && path.Contains("/")) // Heuristic: paths usually have slashes + { + // We need to handle texture assignment here. + // Since we don't have easy access to AssetDatabase here directly without using UnityEditor namespace (which is imported), + // we can try to load it. + var sanitizedPath = AssetPathUtility.SanitizeAssetPath(path); + Texture tex = AssetDatabase.LoadAssetAtPath(sanitizedPath); + if (tex != null && material.HasProperty(propertyName)) + { + material.SetTexture(propertyName, tex); + return true; + } + } + } + catch (Exception ex) + { + McpLog.Warn($"SetTexture (string path) for '{propertyName}' failed: {ex.Message}"); + } + } + + if (value.Type == JTokenType.Object) + { + try + { + Texture texture = value.ToObject(serializer); + if (texture != null && material.HasProperty(propertyName)) + { + material.SetTexture(propertyName, texture); + return true; + } + } + catch (Exception ex) + { + McpLog.Warn($"SetTexture (object) for '{propertyName}' failed: {ex.Message}"); + } + } + + Debug.LogWarning( + $"[MaterialOps] Unsupported or failed conversion for material property '{propertyName}' from value: {value.ToString(Formatting.None)}" + ); + return false; + } + + /// + /// Helper to parse color from JToken (array or object). + /// + public static Color ParseColor(JToken token, JsonSerializer serializer) + { + if (token.Type == JTokenType.String) + { + string s = token.ToString(); + if (s.TrimStart().StartsWith("[") || s.TrimStart().StartsWith("{")) + { + try + { + return ParseColor(JToken.Parse(s), serializer); + } + catch { } + } + } + + if (token is JArray jArray) + { + if (jArray.Count == 4) + { + return new Color( + (float)jArray[0], + (float)jArray[1], + (float)jArray[2], + (float)jArray[3] + ); + } + else if (jArray.Count == 3) + { + return new Color( + (float)jArray[0], + (float)jArray[1], + (float)jArray[2], + 1f + ); + } + else + { + throw new ArgumentException("Color array must have 3 or 4 elements."); + } + } + + try + { + return token.ToObject(serializer); + } + catch (Exception ex) + { + Debug.LogWarning($"[MaterialOps] Failed to parse color from token: {ex.Message}"); + throw; + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/MaterialOps.cs.meta b/MCPForUnity/Editor/Helpers/MaterialOps.cs.meta new file mode 100644 index 000000000..9296369a4 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/MaterialOps.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a59e8545e32664dae9a696d449f82c3d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageAsset.cs b/MCPForUnity/Editor/Tools/ManageAsset.cs index 7ca13ba1d..aee622ca9 100644 --- a/MCPForUnity/Editor/Tools/ManageAsset.cs +++ b/MCPForUnity/Editor/Tools/ManageAsset.cs @@ -213,7 +213,7 @@ private static object CreateAsset(JObject @params) if (propertiesForApply.HasValues) { - ApplyMaterialProperties(mat, propertiesForApply); + MaterialOps.ApplyProperties(mat, propertiesForApply, ManageGameObject.InputSerializer); } } AssetDatabase.CreateAsset(mat, fullPath); @@ -441,7 +441,7 @@ prop.Value is JObject componentProperties { // Apply properties directly to the material. If this modifies, it sets modified=true. // Use |= in case the asset was already marked modified by previous logic (though unlikely here) - modified |= ApplyMaterialProperties(material, properties); + modified |= MaterialOps.ApplyProperties(material, properties, ManageGameObject.InputSerializer); } // Example: Modifying a ScriptableObject else if (asset is ScriptableObject so) @@ -895,299 +895,7 @@ private static void EnsureDirectoryExists(string directoryPath) } } - /// - /// Applies properties from JObject to a Material. - /// - private static bool ApplyMaterialProperties(Material mat, JObject properties) - { - if (mat == null || properties == null) - return false; - bool modified = false; - - // Example: Set shader - if (properties["shader"]?.Type == JTokenType.String) - { - string shaderRequest = properties["shader"].ToString(); - Shader newShader = RenderPipelineUtility.ResolveShader(shaderRequest); - if (newShader != null && mat.shader != newShader) - { - mat.shader = newShader; - modified = true; - } - } - // Example: Set color property - if (properties["color"] is JObject colorProps) - { - string propName = colorProps["name"]?.ToString() ?? GetMainColorPropertyName(mat); // Auto-detect if not specified - if (colorProps["value"] is JArray colArr && colArr.Count >= 3) - { - try - { - Color newColor = new Color( - colArr[0].ToObject(), - colArr[1].ToObject(), - colArr[2].ToObject(), - colArr.Count > 3 ? colArr[3].ToObject() : 1.0f - ); - if (mat.HasProperty(propName)) - { - if (mat.GetColor(propName) != newColor) - { - mat.SetColor(propName, newColor); - modified = true; - } - } - else - { - Debug.LogWarning( - $"Material '{mat.name}' with shader '{mat.shader.name}' does not have color property '{propName}'. " + - $"Color not applied. Common color properties: _BaseColor (URP), _Color (Standard)" - ); - } - } - catch (Exception ex) - { - Debug.LogWarning( - $"Error parsing color property '{propName}': {ex.Message}" - ); - } - } - } - else if (properties["color"] is JArray colorArr) //Use color now with examples set in manage_asset.py - { - // Auto-detect the main color property for the shader - string propName = GetMainColorPropertyName(mat); - try - { - if (colorArr.Count >= 3) - { - Color newColor = new Color( - colorArr[0].ToObject(), - colorArr[1].ToObject(), - colorArr[2].ToObject(), - colorArr.Count > 3 ? colorArr[3].ToObject() : 1.0f - ); - if (mat.HasProperty(propName)) - { - if (mat.GetColor(propName) != newColor) - { - mat.SetColor(propName, newColor); - modified = true; - } - } - else - { - Debug.LogWarning( - $"Material '{mat.name}' with shader '{mat.shader.name}' does not have color property '{propName}'. " + - $"Color not applied. Common color properties: _BaseColor (URP), _Color (Standard)" - ); - } - } - } - catch (Exception ex) - { - Debug.LogWarning( - $"Error parsing color property '{propName}': {ex.Message}" - ); - } - } - // Example: Set float property - if (properties["float"] is JObject floatProps) - { - string propName = floatProps["name"]?.ToString(); - if ( - !string.IsNullOrEmpty(propName) && - (floatProps["value"]?.Type == JTokenType.Float || floatProps["value"]?.Type == JTokenType.Integer) - ) - { - try - { - float newVal = floatProps["value"].ToObject(); - if (mat.HasProperty(propName) && mat.GetFloat(propName) != newVal) - { - mat.SetFloat(propName, newVal); - modified = true; - } - } - catch (Exception ex) - { - Debug.LogWarning( - $"Error parsing float property '{propName}': {ex.Message}" - ); - } - } - } - // Example: Set texture property (case-insensitive key and subkeys) - { - JObject texProps = null; - var direct = properties.Property("texture"); - if (direct != null && direct.Value is JObject t0) texProps = t0; - if (texProps == null) - { - var ci = properties.Properties().FirstOrDefault( - p => string.Equals(p.Name, "texture", StringComparison.OrdinalIgnoreCase)); - if (ci != null && ci.Value is JObject t1) texProps = t1; - } - if (texProps != null) - { - string rawName = (texProps["name"] ?? texProps["Name"])?.ToString(); - string texPath = (texProps["path"] ?? texProps["Path"])?.ToString(); - if (!string.IsNullOrEmpty(texPath)) - { - var newTex = AssetDatabase.LoadAssetAtPath( - AssetPathUtility.SanitizeAssetPath(texPath)); - if (newTex == null) - { - Debug.LogWarning($"Texture not found at path: {texPath}"); - } - else - { - // Reuse alias resolver so friendly names like 'albedo' work here too - string candidateName = string.IsNullOrEmpty(rawName) ? "_BaseMap" : rawName; - string targetProp = ResolvePropertyName(candidateName); - if (!string.IsNullOrEmpty(targetProp) && mat.HasProperty(targetProp)) - { - if (mat.GetTexture(targetProp) != newTex) - { - mat.SetTexture(targetProp, newTex); - modified = true; - } - } - } - } - } - } - - // --- Flexible direct property assignment --- - // Allow payloads like: { "_Color": [r,g,b,a] }, { "_Glossiness": 0.5 }, { "_MainTex": "Assets/.." } - // while retaining backward compatibility with the structured keys above. - // This iterates all top-level keys except the reserved structured ones and applies them - // if they match known shader properties. - var reservedKeys = new HashSet(StringComparer.OrdinalIgnoreCase) { "shader", "color", "float", "texture" }; - - // Helper resolves common URP/Standard aliasing (e.g., _Color <-> _BaseColor, _MainTex <-> _BaseMap, _Glossiness <-> _Smoothness) - string ResolvePropertyName(string name) - { - if (string.IsNullOrEmpty(name)) return name; - string[] candidates; - var lower = name.ToLowerInvariant(); - switch (lower) - { - case "_color": candidates = new[] { "_Color", "_BaseColor" }; break; - case "_basecolor": candidates = new[] { "_BaseColor", "_Color" }; break; - case "_maintex": candidates = new[] { "_MainTex", "_BaseMap" }; break; - case "_basemap": candidates = new[] { "_BaseMap", "_MainTex" }; break; - case "_glossiness": candidates = new[] { "_Glossiness", "_Smoothness" }; break; - case "_smoothness": candidates = new[] { "_Smoothness", "_Glossiness" }; break; - // Friendly names → shader property names - case "metallic": candidates = new[] { "_Metallic" }; break; - case "smoothness": candidates = new[] { "_Smoothness", "_Glossiness" }; break; - case "albedo": candidates = new[] { "_BaseMap", "_MainTex" }; break; - default: candidates = new[] { name }; break; // keep original as-is - } - foreach (var candidate in candidates) - { - if (mat.HasProperty(candidate)) return candidate; - } - return name; // fall back to original - } - - foreach (var prop in properties.Properties()) - { - if (reservedKeys.Contains(prop.Name)) continue; - string shaderProp = ResolvePropertyName(prop.Name); - JToken v = prop.Value; - - // Color: numeric array [r,g,b,(a)] - if (v is JArray arr && arr.Count >= 3 && arr.All(t => t.Type == JTokenType.Float || t.Type == JTokenType.Integer)) - { - if (mat.HasProperty(shaderProp)) - { - try - { - var c = new Color( - arr[0].ToObject(), - arr[1].ToObject(), - arr[2].ToObject(), - arr.Count > 3 ? arr[3].ToObject() : 1f - ); - if (mat.GetColor(shaderProp) != c) - { - mat.SetColor(shaderProp, c); - modified = true; - } - } - catch (Exception ex) - { - Debug.LogWarning($"Error setting color '{shaderProp}': {ex.Message}"); - } - } - continue; - } - - // Float: single number - if (v.Type == JTokenType.Float || v.Type == JTokenType.Integer) - { - if (mat.HasProperty(shaderProp)) - { - try - { - float f = v.ToObject(); - if (!Mathf.Approximately(mat.GetFloat(shaderProp), f)) - { - mat.SetFloat(shaderProp, f); - modified = true; - } - } - catch (Exception ex) - { - Debug.LogWarning($"Error setting float '{shaderProp}': {ex.Message}"); - } - } - continue; - } - // Texture: string path - if (v.Type == JTokenType.String) - { - string texPath = v.ToString(); - if (!string.IsNullOrEmpty(texPath) && mat.HasProperty(shaderProp)) - { - var tex = AssetDatabase.LoadAssetAtPath(AssetPathUtility.SanitizeAssetPath(texPath)); - if (tex != null && mat.GetTexture(shaderProp) != tex) - { - mat.SetTexture(shaderProp, tex); - modified = true; - } - } - continue; - } - } - - // TODO: Add handlers for other property types (Vectors, Ints, Keywords, RenderQueue, etc.) - return modified; - } - - /// - /// Auto-detects the main color property name for a material's shader. - /// Tries common color property names in order: _BaseColor (URP), _Color (Standard), etc. - /// - private static string GetMainColorPropertyName(Material mat) - { - if (mat == null || mat.shader == null) - return "_Color"; - - // Try common color property names in order of likelihood - string[] commonColorProps = { "_BaseColor", "_Color", "_MainColor", "_Tint", "_TintColor" }; - foreach (var prop in commonColorProps) - { - if (mat.HasProperty(prop)) - return prop; - } - - // Fallback to _Color if none found - return "_Color"; - } /// /// Applies properties from JObject to a PhysicsMaterial. diff --git a/MCPForUnity/Editor/Tools/ManageGameObject.cs b/MCPForUnity/Editor/Tools/ManageGameObject.cs index 8bfdfc08a..77f3fde52 100644 --- a/MCPForUnity/Editor/Tools/ManageGameObject.cs +++ b/MCPForUnity/Editor/Tools/ManageGameObject.cs @@ -23,7 +23,7 @@ namespace MCPForUnity.Editor.Tools public static class ManageGameObject { // Shared JsonSerializer to avoid per-call allocation overhead - private static readonly JsonSerializer InputSerializer = JsonSerializer.Create(new JsonSerializerSettings + internal static readonly JsonSerializer InputSerializer = JsonSerializer.Create(new JsonSerializerSettings { Converters = new List { @@ -54,10 +54,21 @@ public static object HandleCommand(JObject @params) // Parameters used by various actions JToken targetToken = @params["target"]; // Can be string (name/path) or int (instanceID) - string searchMethod = @params["searchMethod"]?.ToString().ToLower(); - - // Get common parameters (consolidated) string name = @params["name"]?.ToString(); + + // --- Usability Improvement: Alias 'name' to 'target' for modification actions --- + // If 'target' is missing but 'name' is provided, and we aren't creating a new object, + // assume the user meant "find object by name". + if (targetToken == null && !string.IsNullOrEmpty(name) && action != "create") + { + targetToken = name; + // We don't update @params["target"] because we use targetToken locally mostly, + // but some downstream methods might parse @params directly. Let's update @params too for safety. + @params["target"] = name; + } + // ------------------------------------------------------------------------------- + + string searchMethod = @params["searchMethod"]?.ToString().ToLower(); string tag = @params["tag"]?.ToString(); string layer = @params["layer"]?.ToString(); JToken parentToken = @params["parent"]; @@ -2112,51 +2123,7 @@ private static bool SetNestedProperty(object target, string path, JToken value, // Special handling for Material properties (shader properties) if (currentObject is Material material && finalPart.StartsWith("_")) { - // Use the serializer to convert the JToken value first - if (value is JArray jArray) - { - // Try converting to known types that SetColor/SetVector accept - if (jArray.Count == 4) - { - try { Color color = value.ToObject(inputSerializer); material.SetColor(finalPart, color); return true; } catch { } - try { Vector4 vec = value.ToObject(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { } - } - else if (jArray.Count == 3) - { - try { Color color = value.ToObject(inputSerializer); material.SetColor(finalPart, color); return true; } catch { } // ToObject handles conversion to Color - } - else if (jArray.Count == 2) - { - try { Vector2 vec = value.ToObject(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { } - } - } - else if (value.Type == JTokenType.Float || value.Type == JTokenType.Integer) - { - try { material.SetFloat(finalPart, value.ToObject(inputSerializer)); return true; } catch { } - } - else if (value.Type == JTokenType.Boolean) - { - try { material.SetFloat(finalPart, value.ToObject(inputSerializer) ? 1f : 0f); return true; } catch { } - } - else if (value.Type == JTokenType.String) - { - // Try converting to Texture using the serializer/converter - try - { - Texture texture = value.ToObject(inputSerializer); - if (texture != null) - { - material.SetTexture(finalPart, texture); - return true; - } - } - catch { } - } - - Debug.LogWarning( - $"[SetNestedProperty] Unsupported or failed conversion for material property '{finalPart}' from value: {value.ToString(Formatting.None)}" - ); - return false; + return MaterialOps.TrySetShaderProperty(material, finalPart, value, inputSerializer); } // For standard properties (not shader specific) diff --git a/MCPForUnity/Editor/Tools/ManageMaterial.cs b/MCPForUnity/Editor/Tools/ManageMaterial.cs new file mode 100644 index 000000000..e8f701d10 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageMaterial.cs @@ -0,0 +1,518 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Helpers; +using UnityEngine; +using UnityEditor; + +namespace MCPForUnity.Editor.Tools +{ + [McpForUnityTool("manage_material", AutoRegister = false)] + public static class ManageMaterial + { + public static object HandleCommand(JObject @params) + { + string action = @params["action"]?.ToString(); + if (string.IsNullOrEmpty(action)) + { + return new { status = "error", message = "Action is required" }; + } + + try + { + switch (action) + { + case "ping": + return new { status = "success", tool = "manage_material" }; + + case "create": + return CreateMaterial(@params); + + case "set_material_shader_property": + return SetMaterialShaderProperty(@params); + + case "set_material_color": + return SetMaterialColor(@params); + + case "assign_material_to_renderer": + return AssignMaterialToRenderer(@params); + + case "set_renderer_color": + return SetRendererColor(@params); + + case "get_material_info": + return GetMaterialInfo(@params); + + default: + return new { status = "error", message = $"Unknown action: {action}" }; + } + } + catch (Exception ex) + { + return new { status = "error", message = ex.Message, stackTrace = ex.StackTrace }; + } + } + + private static string NormalizePath(string path) + { + if (string.IsNullOrEmpty(path)) return path; + + // Normalize separators and ensure Assets/ root + path = AssetPathUtility.SanitizeAssetPath(path); + + // Ensure .mat extension + if (!path.EndsWith(".mat", StringComparison.OrdinalIgnoreCase)) + { + path += ".mat"; + } + + return path; + } + + private static object SetMaterialShaderProperty(JObject @params) + { + string materialPath = NormalizePath(@params["materialPath"]?.ToString()); + string property = @params["property"]?.ToString(); + JToken value = @params["value"]; + + if (string.IsNullOrEmpty(materialPath) || string.IsNullOrEmpty(property) || value == null) + { + return new { status = "error", message = "materialPath, property, and value are required" }; + } + + // Find material + var findInstruction = new JObject { ["find"] = materialPath }; + Material mat = ManageGameObject.FindObjectByInstruction(findInstruction, typeof(Material)) as Material; + + if (mat == null) + { + return new { status = "error", message = $"Could not find material at path: {materialPath}" }; + } + + Undo.RecordObject(mat, "Set Material Property"); + + // Normalize alias/casing once for all code paths + property = MaterialOps.ResolvePropertyName(mat, property); + + // 1. Try handling Texture instruction explicitly (ManageMaterial special feature) + if (value.Type == JTokenType.Object) + { + // Check if it looks like an instruction + if (value is JObject obj && (obj.ContainsKey("find") || obj.ContainsKey("method"))) + { + Texture tex = ManageGameObject.FindObjectByInstruction(obj, typeof(Texture)) as Texture; + if (tex != null && mat.HasProperty(property)) + { + mat.SetTexture(property, tex); + EditorUtility.SetDirty(mat); + return new { status = "success", message = $"Set texture property {property} on {mat.name}" }; + } + } + } + + // 2. Fallback to standard logic via MaterialOps (handles Colors, Floats, Strings->Path) + bool success = MaterialOps.TrySetShaderProperty(mat, property, value, ManageGameObject.InputSerializer); + + if (success) + { + EditorUtility.SetDirty(mat); + return new { status = "success", message = $"Set property {property} on {mat.name}" }; + } + else + { + return new { status = "error", message = $"Failed to set property {property}. Value format might be unsupported or texture not found." }; + } + } + + private static object SetMaterialColor(JObject @params) + { + string materialPath = NormalizePath(@params["materialPath"]?.ToString()); + JToken colorToken = @params["color"]; + string property = @params["property"]?.ToString(); + + if (string.IsNullOrEmpty(materialPath) || colorToken == null) + { + return new { status = "error", message = "materialPath and color are required" }; + } + + var findInstruction = new JObject { ["find"] = materialPath }; + Material mat = ManageGameObject.FindObjectByInstruction(findInstruction, typeof(Material)) as Material; + + if (mat == null) + { + return new { status = "error", message = $"Could not find material at path: {materialPath}" }; + } + + Color color; + try + { + color = MaterialOps.ParseColor(colorToken, ManageGameObject.InputSerializer); + } + catch (Exception e) + { + return new { status = "error", message = $"Invalid color format: {e.Message}" }; + } + + Undo.RecordObject(mat, "Set Material Color"); + + bool foundProp = false; + if (!string.IsNullOrEmpty(property)) + { + if (mat.HasProperty(property)) + { + mat.SetColor(property, color); + foundProp = true; + } + } + else + { + // Fallback logic: _BaseColor (URP/HDRP) then _Color (Built-in) + if (mat.HasProperty("_BaseColor")) + { + mat.SetColor("_BaseColor", color); + foundProp = true; + property = "_BaseColor"; + } + else if (mat.HasProperty("_Color")) + { + mat.SetColor("_Color", color); + foundProp = true; + property = "_Color"; + } + } + + if (foundProp) + { + EditorUtility.SetDirty(mat); + return new { status = "success", message = $"Set color on {property}" }; + } + else + { + return new { status = "error", message = "Could not find suitable color property (_BaseColor or _Color) or specified property does not exist." }; + } + } + + private static object AssignMaterialToRenderer(JObject @params) + { + string target = @params["target"]?.ToString(); + string searchMethod = @params["searchMethod"]?.ToString(); + string materialPath = NormalizePath(@params["materialPath"]?.ToString()); + int slot = @params["slot"]?.ToObject() ?? 0; + + if (string.IsNullOrEmpty(target) || string.IsNullOrEmpty(materialPath)) + { + return new { status = "error", message = "target and materialPath are required" }; + } + + var goInstruction = new JObject { ["find"] = target }; + if (!string.IsNullOrEmpty(searchMethod)) goInstruction["method"] = searchMethod; + + GameObject go = ManageGameObject.FindObjectByInstruction(goInstruction, typeof(GameObject)) as GameObject; + if (go == null) + { + return new { status = "error", message = $"Could not find target GameObject: {target}" }; + } + + Renderer renderer = go.GetComponent(); + if (renderer == null) + { + return new { status = "error", message = $"GameObject {go.name} has no Renderer component" }; + } + + var matInstruction = new JObject { ["find"] = materialPath }; + Material mat = ManageGameObject.FindObjectByInstruction(matInstruction, typeof(Material)) as Material; + if (mat == null) + { + return new { status = "error", message = $"Could not find material: {materialPath}" }; + } + + Undo.RecordObject(renderer, "Assign Material"); + + Material[] sharedMats = renderer.sharedMaterials; + if (slot < 0 || slot >= sharedMats.Length) + { + return new { status = "error", message = $"Slot {slot} out of bounds (count: {sharedMats.Length})" }; + } + + sharedMats[slot] = mat; + renderer.sharedMaterials = sharedMats; + + EditorUtility.SetDirty(renderer); + return new { status = "success", message = $"Assigned material {mat.name} to {go.name} slot {slot}" }; + } + + private static object SetRendererColor(JObject @params) + { + string target = @params["target"]?.ToString(); + string searchMethod = @params["searchMethod"]?.ToString(); + JToken colorToken = @params["color"]; + int slot = @params["slot"]?.ToObject() ?? 0; + string mode = @params["mode"]?.ToString() ?? "property_block"; + + if (string.IsNullOrEmpty(target) || colorToken == null) + { + return new { status = "error", message = "target and color are required" }; + } + + Color color; + try + { + color = MaterialOps.ParseColor(colorToken, ManageGameObject.InputSerializer); + } + catch (Exception e) + { + return new { status = "error", message = $"Invalid color format: {e.Message}" }; + } + + var goInstruction = new JObject { ["find"] = target }; + if (!string.IsNullOrEmpty(searchMethod)) goInstruction["method"] = searchMethod; + + GameObject go = ManageGameObject.FindObjectByInstruction(goInstruction, typeof(GameObject)) as GameObject; + if (go == null) + { + return new { status = "error", message = $"Could not find target GameObject: {target}" }; + } + + Renderer renderer = go.GetComponent(); + if (renderer == null) + { + return new { status = "error", message = $"GameObject {go.name} has no Renderer component" }; + } + + if (mode == "property_block") + { + if (slot < 0 || slot >= renderer.sharedMaterials.Length) + { + return new { status = "error", message = $"Slot {slot} out of bounds (count: {renderer.sharedMaterials.Length})" }; + } + + MaterialPropertyBlock block = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(block, slot); + + if (renderer.sharedMaterials[slot] != null) + { + Material mat = renderer.sharedMaterials[slot]; + if (mat.HasProperty("_BaseColor")) block.SetColor("_BaseColor", color); + else if (mat.HasProperty("_Color")) block.SetColor("_Color", color); + else block.SetColor("_Color", color); + } + else + { + block.SetColor("_Color", color); + } + + renderer.SetPropertyBlock(block, slot); + EditorUtility.SetDirty(renderer); + return new { status = "success", message = $"Set renderer color (PropertyBlock) on slot {slot}" }; + } + else if (mode == "shared") + { + if (slot >= 0 && slot < renderer.sharedMaterials.Length) + { + Material mat = renderer.sharedMaterials[slot]; + if (mat == null) + { + return new { status = "error", message = $"No material in slot {slot}" }; + } + Undo.RecordObject(mat, "Set Material Color"); + if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color); + else mat.SetColor("_Color", color); + EditorUtility.SetDirty(mat); + return new { status = "success", message = "Set shared material color" }; + } + return new { status = "error", message = "Invalid slot" }; + } + else if (mode == "instance") + { + if (slot >= 0 && slot < renderer.materials.Length) + { + Material mat = renderer.materials[slot]; + if (mat == null) + { + return new { status = "error", message = $"No material in slot {slot}" }; + } + // Note: Undo cannot fully revert material instantiation + Undo.RecordObject(mat, "Set Instance Material Color"); + if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color); + else mat.SetColor("_Color", color); + return new { status = "success", message = "Set instance material color", warning = "Material instance created; Undo cannot fully revert instantiation." }; + } + return new { status = "error", message = "Invalid slot" }; + } + + return new { status = "error", message = $"Unknown mode: {mode}" }; + } + + private static object GetMaterialInfo(JObject @params) + { + string materialPath = NormalizePath(@params["materialPath"]?.ToString()); + if (string.IsNullOrEmpty(materialPath)) + { + return new { status = "error", message = "materialPath is required" }; + } + + var findInstruction = new JObject { ["find"] = materialPath }; + Material mat = ManageGameObject.FindObjectByInstruction(findInstruction, typeof(Material)) as Material; + + if (mat == null) + { + return new { status = "error", message = $"Could not find material at path: {materialPath}" }; + } + + Shader shader = mat.shader; + var properties = new List(); + +#if UNITY_6000_0_OR_NEWER + int propertyCount = shader.GetPropertyCount(); + for (int i = 0; i < propertyCount; i++) + { + string name = shader.GetPropertyName(i); + var type = shader.GetPropertyType(i); + string description = shader.GetPropertyDescription(i); + + object currentValue = null; + try + { + if (mat.HasProperty(name)) + { + switch (type) + { + case UnityEngine.Rendering.ShaderPropertyType.Color: + var c = mat.GetColor(name); + currentValue = new { r = c.r, g = c.g, b = c.b, a = c.a }; + break; + case UnityEngine.Rendering.ShaderPropertyType.Vector: + var v = mat.GetVector(name); + currentValue = new { x = v.x, y = v.y, z = v.z, w = v.w }; + break; + case UnityEngine.Rendering.ShaderPropertyType.Float: + case UnityEngine.Rendering.ShaderPropertyType.Range: + currentValue = mat.GetFloat(name); + break; + case UnityEngine.Rendering.ShaderPropertyType.Texture: + currentValue = mat.GetTexture(name)?.name ?? "null"; + break; + } + } + } + catch (Exception ex) + { + currentValue = $""; + } + + properties.Add(new + { + name = name, + type = type.ToString(), + description = description, + value = currentValue + }); + } +#else + int propertyCount = ShaderUtil.GetPropertyCount(shader); + for (int i = 0; i < propertyCount; i++) + { + string name = ShaderUtil.GetPropertyName(shader, i); + ShaderUtil.ShaderPropertyType type = ShaderUtil.GetPropertyType(shader, i); + string description = ShaderUtil.GetPropertyDescription(shader, i); + + object currentValue = null; + try { + if (mat.HasProperty(name)) + { + switch (type) { + case ShaderUtil.ShaderPropertyType.Color: + var c = mat.GetColor(name); + currentValue = new { r = c.r, g = c.g, b = c.b, a = c.a }; + break; + case ShaderUtil.ShaderPropertyType.Vector: + var v = mat.GetVector(name); + currentValue = new { x = v.x, y = v.y, z = v.z, w = v.w }; + break; + case ShaderUtil.ShaderPropertyType.Float: currentValue = mat.GetFloat(name); break; + case ShaderUtil.ShaderPropertyType.Range: currentValue = mat.GetFloat(name); break; + case ShaderUtil.ShaderPropertyType.TexEnv: currentValue = mat.GetTexture(name)?.name ?? "null"; break; + } + } + } catch (Exception ex) { + currentValue = $""; + } + + properties.Add(new { + name = name, + type = type.ToString(), + description = description, + value = currentValue + }); + } +#endif + + return new { + status = "success", + material = mat.name, + shader = shader.name, + properties = properties + }; + } + + private static object CreateMaterial(JObject @params) + { + string materialPath = NormalizePath(@params["materialPath"]?.ToString()); + string shaderName = @params["shader"]?.ToString() ?? "Standard"; + + JObject properties = null; + JToken propsToken = @params["properties"]; + if (propsToken != null) + { + if (propsToken.Type == JTokenType.String) + { + try { properties = JObject.Parse(propsToken.ToString()); } + catch (Exception ex) { return new { status = "error", message = $"Invalid JSON in properties: {ex.Message}" }; } + } + else if (propsToken is JObject obj) + { + properties = obj; + } + } + + if (string.IsNullOrEmpty(materialPath)) + { + return new { status = "error", message = "materialPath is required" }; + } + + // Path normalization handled by helper above, explicit check removed + // but we ensure it's valid for CreateAsset + if (!materialPath.StartsWith("Assets/")) + { + return new { status = "error", message = "Path must start with Assets/ (normalization failed)" }; + } + + Shader shader = RenderPipelineUtility.ResolveShader(shaderName); + if (shader == null) + { + return new { status = "error", message = $"Could not find shader: {shaderName}" }; + } + + Material material = new Material(shader); + + // Check for existing asset to avoid silent overwrite + if (AssetDatabase.LoadAssetAtPath(materialPath) != null) + { + return new { status = "error", message = $"Material already exists at {materialPath}" }; + } + + AssetDatabase.CreateAsset(material, materialPath); + + if (properties != null) + { + MaterialOps.ApplyProperties(material, properties, ManageGameObject.InputSerializer); + } + + EditorUtility.SetDirty(material); + AssetDatabase.SaveAssets(); + + return new { status = "success", message = $"Created material at {materialPath} with shader {shaderName}" }; + } + } +} diff --git a/MCPForUnity/Editor/Tools/ManageMaterial.cs.meta b/MCPForUnity/Editor/Tools/ManageMaterial.cs.meta new file mode 100644 index 000000000..a4ba8ed29 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageMaterial.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e55741e2b00794a049a0ed5e63278a56 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/README-zh.md b/README-zh.md index cea31313b..771750ab1 100644 --- a/README-zh.md +++ b/README-zh.md @@ -44,6 +44,7 @@ MCP for Unity 作为桥梁,允许 AI 助手(如 Claude、Cursor)通过本 * `manage_asset`: 执行资源操作(导入、创建、修改、删除等)。 * `manage_editor`: 控制和查询编辑器的状态和设置。 * `manage_gameobject`: 管理游戏对象:创建、修改、删除、查找和组件操作。 +* `manage_material`: 管理材质:创建、设置属性、分配给渲染器以及查询材质信息。 * `manage_prefabs`: 执行预制件操作(创建、修改、删除等)。 * `manage_scene`: 管理场景(加载、保存、创建、获取层次结构等)。 * `manage_script`: 传统脚本操作的兼容性路由器(创建、读取、删除)。建议使用 `apply_text_edits` 或 `script_apply_edits` 进行编辑。 diff --git a/README.md b/README.md index 975100194..beb5cfd62 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ MCP for Unity acts as a bridge, allowing AI assistants (like Claude, Cursor) to * `manage_asset`: Performs asset operations (import, create, modify, delete, etc.). * `manage_editor`: Controls and queries the editor's state and settings. * `manage_gameobject`: Manages GameObjects: create, modify, delete, find, and component operations. +* `manage_material`: Manages materials: create, set properties, colors, assign to renderers, and query material info. * `manage_prefabs`: Performs prefab operations (create, modify, delete, etc.). * `manage_scene`: Manages scenes (load, save, create, get hierarchy, etc.). * `manage_script`: Compatibility router for legacy script operations (create, read, delete). Prefer `apply_text_edits` or `script_apply_edits` for edits. diff --git a/Server/src/services/tools/manage_asset.py b/Server/src/services/tools/manage_asset.py index bf1717808..c9c4b5a42 100644 --- a/Server/src/services/tools/manage_asset.py +++ b/Server/src/services/tools/manage_asset.py @@ -9,6 +9,7 @@ from fastmcp import Context from services.registry import mcp_for_unity_tool from services.tools import get_unity_instance_from_context +from services.tools.utils import parse_json_payload from transport.unity_transport import send_with_unity_instance from transport.legacy.unity_connection import async_send_command_with_retry @@ -63,6 +64,13 @@ async def _normalize_properties(raw: dict[str, Any] | str | None) -> tuple[dict[ return raw, None if isinstance(raw, str): await ctx.info(f"manage_asset: received properties as string (first 100 chars): {raw[:100]}") + # Try our robust centralized parser first, then fallback to ast.literal_eval specific to manage_asset if needed + parsed = parse_json_payload(raw) + if isinstance(parsed, dict): + await ctx.info("manage_asset: coerced properties using centralized parser") + return parsed, None + + # Fallback to original logic for ast.literal_eval which parse_json_payload avoids for safety/simplicity parsed, source = _parse_properties_string(raw) if parsed is None: return None, source diff --git a/Server/src/services/tools/manage_gameobject.py b/Server/src/services/tools/manage_gameobject.py index 7774e17cb..d348a9403 100644 --- a/Server/src/services/tools/manage_gameobject.py +++ b/Server/src/services/tools/manage_gameobject.py @@ -1,12 +1,13 @@ import json -from typing import Annotated, Any, Literal +import math +from typing import Annotated, Any, Literal, Union from fastmcp import Context from services.registry import mcp_for_unity_tool from services.tools import get_unity_instance_from_context from transport.unity_transport import send_with_unity_instance from transport.legacy.unity_connection import async_send_command_with_retry -from services.tools.utils import coerce_bool +from services.tools.utils import coerce_bool, parse_json_payload @mcp_for_unity_tool( @@ -14,7 +15,7 @@ ) async def manage_gameobject( ctx: Context, - action: Annotated[Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components", "get_component", "duplicate", "move_relative"], "Perform CRUD operations on GameObjects and components."], + action: Annotated[Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components", "get_component", "duplicate", "move_relative"], "Perform CRUD operations on GameObjects and components."] | None = None, target: Annotated[str, "GameObject identifier by name or path for modify/delete/component actions"] | None = None, search_method: Annotated[Literal["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"], @@ -25,11 +26,11 @@ async def manage_gameobject( "Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None, parent: Annotated[str, "Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None, - position: Annotated[list[float] | str, + position: Annotated[Union[list[float], str], "Position - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None, - rotation: Annotated[list[float] | str, + rotation: Annotated[Union[list[float], str], "Rotation - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None, - scale: Annotated[list[float] | str, + scale: Annotated[Union[list[float], str], "Scale - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None, components_to_add: Annotated[list[str], "List of component names to add"] | None = None, @@ -46,7 +47,7 @@ async def manage_gameobject( layer: Annotated[str, "Layer name"] | None = None, components_to_remove: Annotated[list[str], "List of component names to remove"] | None = None, - component_properties: Annotated[dict[str, dict[str, Any]] | str, + component_properties: Annotated[Union[dict[str, dict[str, Any]], str], """Dictionary of component names to their properties to set. For example: `{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject `{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component @@ -70,7 +71,7 @@ async def manage_gameobject( # --- Parameters for 'duplicate' --- new_name: Annotated[str, "New name for the duplicated object (default: SourceName_Copy)"] | None = None, - offset: Annotated[list[float] | str, + offset: Annotated[Union[list[float], str], "Offset from original/reference position - [x,y,z] or string '[x,y,z]'"] | None = None, # --- Parameters for 'move_relative' --- reference_object: Annotated[str, @@ -86,22 +87,33 @@ async def manage_gameobject( # Removed session_state import unity_instance = get_unity_instance_from_context(ctx) + if action is None: + return { + "success": False, + "message": "Missing required parameter 'action'. Valid actions: create, modify, delete, find, add_component, remove_component, set_component_property, get_components, get_component, duplicate, move_relative" + } + # Coercers to tolerate stringified booleans and vectors def _coerce_vec(value, default=None): if value is None: return default - import math - + + # First try to parse if it's a string + val = parse_json_payload(value) + def _to_vec3(parts): try: vec = [float(parts[0]), float(parts[1]), float(parts[2])] except (ValueError, TypeError): return default return vec if all(math.isfinite(n) for n in vec) else default - if isinstance(value, list) and len(value) == 3: - return _to_vec3(value) - if isinstance(value, str): - s = value.strip() + + if isinstance(val, list) and len(val) == 3: + return _to_vec3(val) + + # Handle legacy comma-separated strings "1,2,3" that parse_json_payload doesn't handle (since they aren't JSON arrays) + if isinstance(val, str): + s = val.strip() # minimal tolerant parse for "[x,y,z]" or "x,y,z" if s.startswith("[") and s.endswith("]"): s = s[1:-1] @@ -125,16 +137,12 @@ def _to_vec3(parts): world_space = coerce_bool(world_space, default=True) # Coerce 'component_properties' from JSON string to dict for client compatibility - if isinstance(component_properties, str): - try: - component_properties = json.loads(component_properties) - await ctx.info( - "manage_gameobject: coerced component_properties from JSON string to dict") - except json.JSONDecodeError as e: - return {"success": False, "message": f"Invalid JSON in component_properties: {e}"} + component_properties = parse_json_payload(component_properties) + # Ensure final type is a dict (object) if provided if component_properties is not None and not isinstance(component_properties, dict): return {"success": False, "message": "component_properties must be a JSON object (dict)."} + try: # Map tag to search_term when search_method is by_tag for backward compatibility if action == "find" and search_method == "by_tag" and tag is not None and search_term is None: @@ -229,4 +237,4 @@ def _to_vec3(parts): return response if isinstance(response, dict) else {"success": False, "message": str(response)} except Exception as e: - return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} \ No newline at end of file + return {"success": False, "message": f"Python error managing GameObject: {e!s}"} diff --git a/Server/src/services/tools/manage_material.py b/Server/src/services/tools/manage_material.py new file mode 100644 index 000000000..b37467138 --- /dev/null +++ b/Server/src/services/tools/manage_material.py @@ -0,0 +1,95 @@ +""" +Defines the manage_material tool for interacting with Unity materials. +""" +import json +from typing import Annotated, Any, Literal, Union + +from fastmcp import Context +from services.registry import mcp_for_unity_tool +from services.tools import get_unity_instance_from_context +from services.tools.utils import parse_json_payload +from transport.unity_transport import send_with_unity_instance +from transport.legacy.unity_connection import async_send_command_with_retry + + +@mcp_for_unity_tool( + description="Manages Unity materials (set properties, colors, shaders, etc)." +) +async def manage_material( + ctx: Context, + action: Annotated[Literal[ + "ping", + "create", + "set_material_shader_property", + "set_material_color", + "assign_material_to_renderer", + "set_renderer_color", + "get_material_info" + ], "Action to perform."], + + # Common / Shared + material_path: Annotated[str, "Path to material asset (Assets/...)"] | None = None, + property: Annotated[str, "Shader property name (e.g., _BaseColor, _MainTex)"] | None = None, + + # create + shader: Annotated[str, "Shader name (default: Standard)"] | None = None, + properties: Annotated[Union[dict[str, Any], str], "Initial properties to set {name: value}."] | None = None, + + # set_material_shader_property + value: Annotated[Union[list, float, int, str, bool, None], "Value to set (color array, float, texture path/instruction)"] | None = None, + + # set_material_color / set_renderer_color + color: Annotated[Union[list[float], list[int], str], "Color as [r,g,b] or [r,g,b,a]."] | None = None, + + # assign_material_to_renderer / set_renderer_color + target: Annotated[str, "Target GameObject (name, path, or find instruction)"] | None = None, + search_method: Annotated[Literal["by_name", "by_path", "by_tag", "by_layer", "by_component"], "Search method for target"] | None = None, + slot: Annotated[int | str, "Material slot index"] | None = None, + mode: Annotated[Literal["shared", "instance", "property_block"], "Assignment/modification mode"] | None = None, + +) -> dict[str, Any]: + unity_instance = get_unity_instance_from_context(ctx) + + # Parse inputs that might be stringified JSON + color = parse_json_payload(color) + properties = parse_json_payload(properties) + value = parse_json_payload(value) + + # Coerce slot to int if it's a string + if slot is not None: + if isinstance(slot, str): + try: + slot = int(slot) + except ValueError: + return { + "success": False, + "message": f"Invalid slot value: '{slot}' must be a valid integer" + } + + # Prepare parameters for the C# handler + params_dict = { + "action": action.lower(), + "materialPath": material_path, + "shader": shader, + "properties": properties, + "property": property, + "value": value, + "color": color, + "target": target, + "searchMethod": search_method, + "slot": slot, + "mode": mode + } + + # Remove None values + params_dict = {k: v for k, v in params_dict.items() if v is not None} + + # Use centralized async retry helper with instance routing + result = await send_with_unity_instance( + async_send_command_with_retry, + unity_instance, + "manage_material", + params_dict, + ) + + return result if isinstance(result, dict) else {"success": False, "message": str(result)} diff --git a/Server/src/services/tools/read_console.py b/Server/src/services/tools/read_console.py index 3678e1b89..bb1de99c7 100644 --- a/Server/src/services/tools/read_console.py +++ b/Server/src/services/tools/read_console.py @@ -16,7 +16,7 @@ async def read_console( ctx: Context, action: Annotated[Literal['get', 'clear'], - "Get or clear the Unity Editor console."] | None = None, + "Get or clear the Unity Editor console. Defaults to 'get' if omitted."] | None = None, types: Annotated[list[Literal['error', 'warning', 'log', 'all']], "Message types to get"] | None = None, count: Annotated[int | str, @@ -99,10 +99,17 @@ def _coerce_int(value, default=None): if isinstance(resp, dict) and resp.get("success") and not include_stacktrace: # Strip stacktrace fields from returned lines if present try: - lines = resp.get("data", {}).get("lines", []) - for line in lines: - if isinstance(line, dict) and "stacktrace" in line: - line.pop("stacktrace", None) + data = resp.get("data") + # Handle standard format: {"data": {"lines": [...]}} + if isinstance(data, dict) and "lines" in data and isinstance(data["lines"], list): + for line in data["lines"]: + if isinstance(line, dict) and "stacktrace" in line: + line.pop("stacktrace", None) + # Handle legacy/direct list format if any + elif isinstance(data, list): + for line in data: + if isinstance(line, dict) and "stacktrace" in line: + line.pop("stacktrace", None) except Exception: pass return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} diff --git a/Server/src/services/tools/script_apply_edits.py b/Server/src/services/tools/script_apply_edits.py index b7d589d61..c6cab187f 100644 --- a/Server/src/services/tools/script_apply_edits.py +++ b/Server/src/services/tools/script_apply_edits.py @@ -1,12 +1,13 @@ import base64 import hashlib import re -from typing import Annotated, Any +from typing import Annotated, Any, Union from fastmcp import Context from services.registry import mcp_for_unity_tool from services.tools import get_unity_instance_from_context +from services.tools.utils import parse_json_payload from transport.unity_transport import send_with_unity_instance from transport.legacy.unity_connection import async_send_command_with_retry @@ -360,7 +361,7 @@ async def script_apply_edits( ctx: Context, name: Annotated[str, "Name of the script to edit"], path: Annotated[str, "Path to the script to edit under Assets/ directory"], - edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script"], + edits: Annotated[Union[list[dict[str, Any]], str], "List of edits to apply to the script (JSON list or stringified JSON)"], options: Annotated[dict[str, Any], "Options for the script edit"] | None = None, script_type: Annotated[str, @@ -371,6 +372,12 @@ async def script_apply_edits( unity_instance = get_unity_instance_from_context(ctx) await ctx.info( f"Processing script_apply_edits: {name} (unity_instance={unity_instance or 'default'})") + + # Parse edits if they came as a stringified JSON + edits = parse_json_payload(edits) + if not isinstance(edits, list): + return {"success": False, "message": f"Edits must be a list or JSON string of a list, got {type(edits)}"} + # Normalize locator first so downstream calls target the correct script file. name, path = _normalize_script_locator(name, path) # Normalize unsupported or aliased ops to known structured/text paths @@ -895,10 +902,10 @@ def _expand_dollars(rep: str, _m=m) -> str: if isinstance(resp, dict) and resp.get("success"): pass # Optional sentinel reload removed (deprecated) return _with_norm( - resp if isinstance(resp, dict) else { - "success": False, "message": str(resp)}, + resp if isinstance(resp, dict) + else {"success": False, "message": str(resp)}, normalized_for_echo, - routing="text" + routing="text", ) except Exception as e: return _with_norm({"success": False, "code": "conversion_failed", "message": f"Edit conversion failed: {e}"}, normalized_for_echo, routing="text") diff --git a/Server/src/services/tools/utils.py b/Server/src/services/tools/utils.py index 2e2b12bed..ad5d8970c 100644 --- a/Server/src/services/tools/utils.py +++ b/Server/src/services/tools/utils.py @@ -2,13 +2,12 @@ from __future__ import annotations +import json from typing import Any - _TRUTHY = {"true", "1", "yes", "on"} _FALSY = {"false", "0", "no", "off"} - def coerce_bool(value: Any, default: bool | None = None) -> bool | None: """Attempt to coerce a loosely-typed value to a boolean.""" if value is None: @@ -23,3 +22,39 @@ def coerce_bool(value: Any, default: bool | None = None) -> bool | None: return False return default return bool(value) + + +def parse_json_payload(value: Any) -> Any: + """ + Attempt to parse a value that might be a JSON string into its native object. + + This is a tolerant parser used to handle cases where MCP clients or LLMs + serialize complex objects (lists, dicts) into strings. It also handles + scalar values like numbers, booleans, and null. + + Args: + value: The input value (can be str, list, dict, etc.) + + Returns: + The parsed JSON object/list if the input was a valid JSON string, + or the original value if parsing failed or wasn't necessary. + """ + if not isinstance(value, str): + return value + + val_trimmed = value.strip() + + # Fast path: if it doesn't look like JSON structure, return as is + if not ( + (val_trimmed.startswith("{") and val_trimmed.endswith("}")) or + (val_trimmed.startswith("[") and val_trimmed.endswith("]")) or + val_trimmed in ("true", "false", "null") or + (val_trimmed.replace(".", "", 1).replace("-", "", 1).isdigit()) + ): + return value + + try: + return json.loads(value) + except (json.JSONDecodeError, ValueError): + # If parsing fails, assume it was meant to be a literal string + return value diff --git a/Server/tests/integration/test_manage_asset_json_parsing.py b/Server/tests/integration/test_manage_asset_json_parsing.py index a28ee4c46..db3a48956 100644 --- a/Server/tests/integration/test_manage_asset_json_parsing.py +++ b/Server/tests/integration/test_manage_asset_json_parsing.py @@ -33,7 +33,10 @@ async def fake_async(cmd, params, **kwargs): ) # Verify JSON parsing was logged - assert "manage_asset: coerced properties from JSON string to dict" in ctx.log_info + assert any( + "manage_asset: coerced properties using centralized parser" in msg + for msg in ctx.log_info + ) # Verify the result assert result["success"] is True @@ -117,12 +120,12 @@ class TestManageGameObjectJsonParsing: @pytest.mark.asyncio async def test_component_properties_json_string_parsing(self, monkeypatch): - """Test that JSON string component_properties are correctly parsed.""" + """Test that JSON string component_properties result in successful operation.""" from services.tools.manage_gameobject import manage_gameobject ctx = DummyContext() - async def fake_send(cmd, params, **kwargs): + async def fake_send(_cmd, params, **_kwargs): return {"success": True, "message": "GameObject created successfully"} monkeypatch.setattr( "services.tools.manage_gameobject.async_send_command_with_retry", @@ -137,8 +140,31 @@ async def fake_send(cmd, params, **kwargs): component_properties='{"MeshRenderer": {"material": "Assets/Materials/BlueMaterial.mat"}}' ) - # Verify JSON parsing was logged - assert "manage_gameobject: coerced component_properties from JSON string to dict" in ctx.log_info - # Verify the result assert result["success"] is True + + + @pytest.mark.asyncio + async def test_component_properties_parsing_verification(self, monkeypatch): + """Test that component_properties are actually parsed to dict before sending.""" + from services.tools.manage_gameobject import manage_gameobject + ctx = DummyContext() + + captured_params = {} + async def fake_send(_cmd, params, **_kwargs): + captured_params.update(params) + return {"success": True, "message": "GameObject created successfully"} + + monkeypatch.setattr( + "services.tools.manage_gameobject.async_send_command_with_retry", + fake_send, + ) + + await manage_gameobject( + ctx=ctx, + action="create", + name="TestObject", + component_properties='{"MeshRenderer": {"material": "Assets/Materials/BlueMaterial.mat"}}' + ) + + assert isinstance(captured_params.get("componentProperties"), dict) diff --git a/Server/uv.lock b/Server/uv.lock index 44a0ef88a..832b733b1 100644 --- a/Server/uv.lock +++ b/Server/uv.lock @@ -641,7 +641,7 @@ wheels = [ [[package]] name = "keyring" -version = "25.6.0" +version = "25.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, @@ -652,9 +652,9 @@ dependencies = [ { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, { name = "secretstorage", marker = "sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750, upload-time = "2024-12-25T15:26:45.782Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085, upload-time = "2024-12-25T15:26:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, ] [[package]] @@ -694,7 +694,7 @@ wheels = [ [[package]] name = "mcpforunityserver" -version = "8.1.4" +version = "8.1.6" source = { editable = "." } dependencies = [ { name = "fastapi" }, @@ -1361,15 +1361,15 @@ wheels = [ [[package]] name = "secretstorage" -version = "3.4.0" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "jeepney" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/31/9f/11ef35cf1027c1339552ea7bfe6aaa74a8516d8b5caf6e7d338daf54fd80/secretstorage-3.4.0.tar.gz", hash = "sha256:c46e216d6815aff8a8a18706a2fbfd8d53fcbb0dce99301881687a1b0289ef7c", size = 19748, upload-time = "2025-09-09T16:42:13.859Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/ff/2e2eed29e02c14a5cb6c57f09b2d5b40e65d6cc71f45b52e0be295ccbc2f/secretstorage-3.4.0-py3-none-any.whl", hash = "sha256:0e3b6265c2c63509fb7415717607e4b2c9ab767b7f344a57473b779ca13bd02e", size = 15272, upload-time = "2025-09-09T16:42:12.744Z" }, + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, ] [[package]] diff --git a/TestProjects/UnityMCPTests/Assets/Scripts/Editor.meta b/TestProjects/UnityMCPTests/Assets/Scripts/Editor.meta new file mode 100644 index 000000000..65220d07a --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Scripts/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d6cd845e48d9e4d558d50f7a50149682 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialPropertiesTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialPropertiesTests.cs new file mode 100644 index 000000000..1bedfd64c --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialPropertiesTests.cs @@ -0,0 +1,150 @@ +using System; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Tools; + +namespace MCPForUnityTests.Editor.Tools +{ + public class ManageMaterialPropertiesTests + { + private const string TempRoot = "Assets/Temp/ManageMaterialPropertiesTests"; + private string _matPath; + + [SetUp] + public void SetUp() + { + if (!AssetDatabase.IsValidFolder("Assets/Temp")) + { + AssetDatabase.CreateFolder("Assets", "Temp"); + } + if (!AssetDatabase.IsValidFolder(TempRoot)) + { + AssetDatabase.CreateFolder("Assets/Temp", "ManageMaterialPropertiesTests"); + } + _matPath = $"{TempRoot}/PropTest.mat"; + } + + [TearDown] + public void TearDown() + { + if (AssetDatabase.IsValidFolder(TempRoot)) + { + AssetDatabase.DeleteAsset(TempRoot); + } + } + + private static JObject ToJObject(object result) + { + return result as JObject ?? JObject.FromObject(result); + } + + [Test] + public void CreateMaterial_WithValidJsonStringArray_SetsProperty() + { + string jsonProps = "{\"_Color\": [1.0, 0.0, 0.0, 1.0]}"; + var paramsObj = new JObject + { + ["action"] = "create", + ["materialPath"] = _matPath, + ["shader"] = "Standard", + ["properties"] = jsonProps + }; + + var result = ToJObject(ManageMaterial.HandleCommand(paramsObj)); + + Assert.AreEqual("success", result.Value("status"), result.ToString()); + var mat = AssetDatabase.LoadAssetAtPath(_matPath); + Assert.AreEqual(Color.red, mat.color); + } + + [Test] + public void CreateMaterial_WithJObjectArray_SetsProperty() + { + var props = new JObject(); + props["_Color"] = new JArray(0.0f, 1.0f, 0.0f, 1.0f); + + var paramsObj = new JObject + { + ["action"] = "create", + ["materialPath"] = _matPath, + ["shader"] = "Standard", + ["properties"] = props + }; + + var result = ToJObject(ManageMaterial.HandleCommand(paramsObj)); + + Assert.AreEqual("success", result.Value("status"), result.ToString()); + var mat = AssetDatabase.LoadAssetAtPath(_matPath); + Assert.AreEqual(Color.green, mat.color); + } + + [Test] + public void CreateMaterial_WithEmptyProperties_Succeeds() + { + var paramsObj = new JObject + { + ["action"] = "create", + ["materialPath"] = _matPath, + ["shader"] = "Standard", + ["properties"] = new JObject() + }; + + var result = ToJObject(ManageMaterial.HandleCommand(paramsObj)); + + Assert.AreEqual("success", result.Value("status")); + } + + [Test] + public void CreateMaterial_WithInvalidJsonSyntax_ReturnsDetailedError() + { + // Missing closing brace + string invalidJson = "{\"_Color\": [1,0,0,1]"; + + var paramsObj = new JObject + { + ["action"] = "create", + ["materialPath"] = _matPath, + ["shader"] = "Standard", + ["properties"] = invalidJson + }; + + var result = ToJObject(ManageMaterial.HandleCommand(paramsObj)); + + Assert.AreEqual("error", result.Value("status")); + string msg = result.Value("message"); + + // Verify we get exception details + Assert.IsTrue(msg.Contains("Invalid JSON"), "Should mention Invalid JSON"); + // Verify the message contains more than just the prefix (has exception details) + Assert.IsTrue(msg.Length > "Invalid JSON".Length, + $"Message should contain exception details. Got: {msg}"); + } + + [Test] + public void CreateMaterial_WithNullProperty_HandlesGracefully() + { + var props = new JObject(); + props["_Color"] = null; + + var paramsObj = new JObject + { + ["action"] = "create", + ["materialPath"] = _matPath, + ["shader"] = "Standard", + ["properties"] = props + }; + + // Should probably succeed but warn or ignore, or fail gracefully + var result = ToJObject(ManageMaterial.HandleCommand(paramsObj)); + + // We accept either success (ignored) or specific error, but not crash + // Assert.AreNotEqual("internal_error", result.Value("status")); + var status = result.Value("status"); + Assert.IsTrue(status == "success" || status == "error", $"Status should be success or error, got {status}"); + } + } +} + + diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialPropertiesTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialPropertiesTests.cs.meta new file mode 100644 index 000000000..0f51a936e --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialPropertiesTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ca019b5c6c1ee4e13b77574f2ae53583 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialReproTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialReproTests.cs new file mode 100644 index 000000000..caaac78fa --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialReproTests.cs @@ -0,0 +1,73 @@ +using System; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Tools; + +namespace MCPForUnityTests.Editor.Tools +{ + public class ManageMaterialReproTests + { + private const string TempRoot = "Assets/Temp/ManageMaterialReproTests"; + private string _matPath; + + [SetUp] + public void SetUp() + { + if (!AssetDatabase.IsValidFolder("Assets/Temp")) + { + AssetDatabase.CreateFolder("Assets", "Temp"); + } + if (!AssetDatabase.IsValidFolder(TempRoot)) + { + AssetDatabase.CreateFolder("Assets/Temp", "ManageMaterialReproTests"); + } + + string guid = Guid.NewGuid().ToString("N"); + _matPath = $"{TempRoot}/ReproMat_{guid}.mat"; + } + + [TearDown] + public void TearDown() + { + if (AssetDatabase.IsValidFolder(TempRoot)) + { + AssetDatabase.DeleteAsset(TempRoot); + } + } + + private static JObject ToJObject(object result) + { + return result as JObject ?? JObject.FromObject(result); + } + + [Test] + public void CreateMaterial_WithInvalidJsonString_ReturnsGenericError() + { + // Arrange + // Malformed JSON string (missing closing brace) + string invalidJson = "{\"_Color\": [1,0,0,1]"; + + var paramsObj = new JObject + { + ["action"] = "create", + ["materialPath"] = _matPath, + ["shader"] = "Standard", + ["properties"] = invalidJson + }; + + // Act + var result = ToJObject(ManageMaterial.HandleCommand(paramsObj)); + + // Assert + Assert.AreEqual("error", result.Value("status")); + + // We expect more detailed error message after fix + var message = result.Value("message"); + Assert.IsTrue(message.StartsWith("Invalid JSON in properties"), "Message should start with prefix"); + Assert.AreNotEqual("Invalid JSON in properties", message, "Message should contain exception details"); + } + } +} + diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialReproTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialReproTests.cs.meta new file mode 100644 index 000000000..593dd5f22 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialReproTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c967207bf78c344178484efe6d87dea7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialStressTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialStressTests.cs new file mode 100644 index 000000000..f0b430bf8 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialStressTests.cs @@ -0,0 +1,194 @@ +using System; +using System.IO; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Tools; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnityTests.Editor.Tools +{ + public class ManageMaterialStressTests + { + private const string TempRoot = "Assets/Temp/ManageMaterialStressTests"; + private string _matPath; + private GameObject _cube; + + [SetUp] + public void SetUp() + { + if (!AssetDatabase.IsValidFolder("Assets/Temp")) + { + AssetDatabase.CreateFolder("Assets", "Temp"); + } + if (!AssetDatabase.IsValidFolder(TempRoot)) + { + AssetDatabase.CreateFolder("Assets/Temp", "ManageMaterialStressTests"); + } + + string guid = Guid.NewGuid().ToString("N"); + _matPath = $"{TempRoot}/StressMat_{guid}.mat"; + + var material = new Material(Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard")); + material.color = Color.white; + AssetDatabase.CreateAsset(material, _matPath); + AssetDatabase.SaveAssets(); + + _cube = GameObject.CreatePrimitive(PrimitiveType.Cube); + _cube.name = "StressCube"; + } + + [TearDown] + public void TearDown() + { + if (_cube != null) + { + UnityEngine.Object.DestroyImmediate(_cube); + } + + if (AssetDatabase.IsValidFolder(TempRoot)) + { + AssetDatabase.DeleteAsset(TempRoot); + } + + // Clean up parent Temp folder if it's empty + if (AssetDatabase.IsValidFolder("Assets/Temp")) + { + var remainingDirs = Directory.GetDirectories("Assets/Temp"); + var remainingFiles = Directory.GetFiles("Assets/Temp"); + if (remainingDirs.Length == 0 && remainingFiles.Length == 0) + { + AssetDatabase.DeleteAsset("Assets/Temp"); + } + } + } + + private static JObject ToJObject(object result) + { + return result as JObject ?? JObject.FromObject(result); + } + + [Test] + public void HandleInvalidInputs_ReturnsError_NotException() + { + // 1. Bad path + var paramsBadPath = new JObject + { + ["action"] = "set_material_color", + ["materialPath"] = "Assets/NonExistent/Ghost.mat", + ["color"] = new JArray(1f, 0f, 0f, 1f) + }; + var resultBadPath = ToJObject(ManageMaterial.HandleCommand(paramsBadPath)); + Assert.AreEqual("error", resultBadPath.Value("status")); + StringAssert.Contains("Could not find material", resultBadPath.Value("message")); + + // 2. Bad color array (too short) + var paramsBadColor = new JObject + { + ["action"] = "set_material_color", + ["materialPath"] = _matPath, + ["color"] = new JArray(1f) // Invalid + }; + var resultBadColor = ToJObject(ManageMaterial.HandleCommand(paramsBadColor)); + Assert.AreEqual("error", resultBadColor.Value("status")); + StringAssert.Contains("Invalid color format", resultBadColor.Value("message")); + + // 3. Bad slot index + // Assign material first + var renderer = _cube.GetComponent(); + renderer.sharedMaterial = AssetDatabase.LoadAssetAtPath(_matPath); + + var paramsBadSlot = new JObject + { + ["action"] = "assign_material_to_renderer", + ["target"] = "StressCube", + ["searchMethod"] = "by_name", + ["materialPath"] = _matPath, + ["slot"] = 99 + }; + var resultBadSlot = ToJObject(ManageMaterial.HandleCommand(paramsBadSlot)); + Assert.AreEqual("error", resultBadSlot.Value("status")); + StringAssert.Contains("out of bounds", resultBadSlot.Value("message")); + } + + [Test] + public void StateIsolation_PropertyBlockDoesNotLeakToSharedMaterial() + { + // Arrange + var renderer = _cube.GetComponent(); + var sharedMat = AssetDatabase.LoadAssetAtPath(_matPath); + renderer.sharedMaterial = sharedMat; + + // Initial color + var initialColor = Color.white; + if (sharedMat.HasProperty("_BaseColor")) sharedMat.SetColor("_BaseColor", initialColor); + else if (sharedMat.HasProperty("_Color")) sharedMat.SetColor("_Color", initialColor); + + // Act - Set Property Block Color + var blockColor = Color.red; + var paramsObj = new JObject + { + ["action"] = "set_renderer_color", + ["target"] = "StressCube", + ["searchMethod"] = "by_name", + ["color"] = new JArray(blockColor.r, blockColor.g, blockColor.b, blockColor.a), + ["mode"] = "property_block" + }; + + var result = ToJObject(ManageMaterial.HandleCommand(paramsObj)); + Assert.AreEqual("success", result.Value("status")); + + // Assert + // 1. Renderer has property block with Red + var block = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(block, 0); + var propName = sharedMat.HasProperty("_BaseColor") ? "_BaseColor" : "_Color"; + Assert.AreEqual(blockColor, block.GetColor(propName)); + + // 2. Shared material remains White + var sharedColor = sharedMat.GetColor(propName); + Assert.AreEqual(initialColor, sharedColor, "Shared material color should NOT change when using PropertyBlock"); + } + + [Test] + public void Integration_PureManageMaterial_AssignsMaterialAndModifies() + { + // This simulates a workflow where we create a GO, assign a mat, then tweak it. + + // 1. Create GO (already done in Setup, but let's verify) + Assert.IsNotNull(_cube); + + // 2. Assign Material using ManageMaterial + var assignParams = new JObject + { + ["action"] = "assign_material_to_renderer", + ["target"] = "StressCube", + ["searchMethod"] = "by_name", + ["materialPath"] = _matPath + }; + var assignResult = ToJObject(ManageMaterial.HandleCommand(assignParams)); + Assert.AreEqual("success", assignResult.Value("status")); + + // Verify assignment + var renderer = _cube.GetComponent(); + Assert.AreEqual(Path.GetFileNameWithoutExtension(_matPath), renderer.sharedMaterial.name); + + // 3. Modify Shared Material Color using ManageMaterial + var newColor = Color.blue; + var colorParams = new JObject + { + ["action"] = "set_material_color", + ["materialPath"] = _matPath, + ["color"] = new JArray(newColor.r, newColor.g, newColor.b, newColor.a) + }; + var colorResult = ToJObject(ManageMaterial.HandleCommand(colorParams)); + Assert.AreEqual("success", colorResult.Value("status")); + + // Verify color changed on renderer (because it's shared) + var propName = renderer.sharedMaterial.HasProperty("_BaseColor") ? "_BaseColor" : "_Color"; + Assert.AreEqual(newColor, renderer.sharedMaterial.GetColor(propName)); + } + } +} + diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialStressTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialStressTests.cs.meta new file mode 100644 index 000000000..ca6345347 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialStressTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 49ecdd3f43cf54deea7508f317efcb45 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialTests.cs new file mode 100644 index 000000000..1285e4284 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialTests.cs @@ -0,0 +1,234 @@ +using System; +using System.IO; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Tools; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnityTests.Editor.Tools +{ + public class ManageMaterialTests + { + private const string TempRoot = "Assets/Temp/ManageMaterialTests"; + private string _matPath; + + [SetUp] + public void SetUp() + { + if (!AssetDatabase.IsValidFolder("Assets/Temp")) + { + AssetDatabase.CreateFolder("Assets", "Temp"); + } + if (!AssetDatabase.IsValidFolder(TempRoot)) + { + AssetDatabase.CreateFolder("Assets/Temp", "ManageMaterialTests"); + } + + string guid = Guid.NewGuid().ToString("N"); + _matPath = $"{TempRoot}/TestMat_{guid}.mat"; + + // Create a basic material + var material = new Material(Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard")); + AssetDatabase.CreateAsset(material, _matPath); + AssetDatabase.SaveAssets(); + } + + [TearDown] + public void TearDown() + { + if (AssetDatabase.IsValidFolder(TempRoot)) + { + AssetDatabase.DeleteAsset(TempRoot); + } + + // Clean up parent Temp folder if it's empty + if (AssetDatabase.IsValidFolder("Assets/Temp")) + { + // Only delete if empty + var subFolders = AssetDatabase.GetSubFolders("Assets/Temp"); + if (subFolders.Length == 0) + { + AssetDatabase.DeleteAsset("Assets/Temp"); + } + } + } + + private static JObject ToJObject(object result) + { + return result as JObject ?? JObject.FromObject(result); + } + + [Test] + public void SetMaterialShaderProperty_SetsColor() + { + // Arrange + var color = new Color(1f, 1f, 0f, 1f); // Yellow + var paramsObj = new JObject + { + ["action"] = "set_material_shader_property", + ["materialPath"] = _matPath, + ["property"] = "_BaseColor", // URP + ["value"] = new JArray(color.r, color.g, color.b, color.a) + }; + + // Check if using Standard shader (fallback) + var mat = AssetDatabase.LoadAssetAtPath(_matPath); + if (mat.shader.name == "Standard") + { + paramsObj["property"] = "_Color"; + } + + // Act + var result = ToJObject(ManageMaterial.HandleCommand(paramsObj)); + + // Assert + Assert.AreEqual("success", result.Value("status"), result.ToString()); + + mat = AssetDatabase.LoadAssetAtPath(_matPath); // Reload + var prop = mat.shader.name == "Standard" ? "_Color" : "_BaseColor"; + + Assert.IsTrue(mat.HasProperty(prop), $"Material should have property {prop}"); + Assert.AreEqual(color, mat.GetColor(prop)); + } + + [Test] + public void SetMaterialColor_SetsColorWithFallback() + { + // Arrange + var color = new Color(0f, 1f, 0f, 1f); // Green + var paramsObj = new JObject + { + ["action"] = "set_material_color", + ["materialPath"] = _matPath, + ["color"] = new JArray(color.r, color.g, color.b, color.a) + }; + + // Act + var result = ToJObject(ManageMaterial.HandleCommand(paramsObj)); + + // Assert + Assert.AreEqual("success", result.Value("status"), result.ToString()); + + var mat = AssetDatabase.LoadAssetAtPath(_matPath); + var prop = mat.HasProperty("_BaseColor") ? "_BaseColor" : "_Color"; + + Assert.IsTrue(mat.HasProperty(prop), $"Material should have property {prop}"); + Assert.AreEqual(color, mat.GetColor(prop)); + } + + [Test] + public void AssignMaterialToRenderer_Works() + { + // Arrange + var go = GameObject.CreatePrimitive(PrimitiveType.Cube); + go.name = "AssignTestCube"; + + try + { + var paramsObj = new JObject + { + ["action"] = "assign_material_to_renderer", + ["target"] = "AssignTestCube", + ["searchMethod"] = "by_name", + ["materialPath"] = _matPath, + ["slot"] = 0 + }; + + // Act + var result = ToJObject(ManageMaterial.HandleCommand(paramsObj)); + + // Assert + Assert.AreEqual("success", result.Value("status"), result.ToString()); + + var renderer = go.GetComponent(); + Assert.IsNotNull(renderer.sharedMaterial); + // Compare names because objects might be different instances (loaded vs scene) + var matName = Path.GetFileNameWithoutExtension(_matPath); + Assert.AreEqual(matName, renderer.sharedMaterial.name); + } + finally + { + UnityEngine.Object.DestroyImmediate(go); + } + } + + [Test] + public void SetRendererColor_PropertyBlock_Works() + { + // Arrange + var go = GameObject.CreatePrimitive(PrimitiveType.Cube); + go.name = "BlockTestCube"; + + // Assign the material first so we have something valid + var mat = AssetDatabase.LoadAssetAtPath(_matPath); + go.GetComponent().sharedMaterial = mat; + + try + { + var color = new Color(1f, 0f, 0f, 1f); // Red + var paramsObj = new JObject + { + ["action"] = "set_renderer_color", + ["target"] = "BlockTestCube", + ["searchMethod"] = "by_name", + ["color"] = new JArray(color.r, color.g, color.b, color.a), + ["mode"] = "property_block" + }; + + // Act + var result = ToJObject(ManageMaterial.HandleCommand(paramsObj)); + + // Assert + Assert.AreEqual("success", result.Value("status"), result.ToString()); + + var renderer = go.GetComponent(); + var block = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(block, 0); + + var prop = mat.HasProperty("_BaseColor") ? "_BaseColor" : "_Color"; + Assert.AreEqual(color, block.GetColor(prop)); + + // Verify material asset didn't change (it was originally white/gray from setup?) + // We didn't check original color, but property block shouldn't affect shared material + // We can check that sharedMaterial color is NOT red if we set it to something else first + // But assuming test isolation, we can just verify the block is set. + } + finally + { + UnityEngine.Object.DestroyImmediate(go); + } + } + + [Test] + public void GetMaterialInfo_ReturnsProperties() + { + // Arrange + var paramsObj = new JObject + { + ["action"] = "get_material_info", + ["materialPath"] = _matPath + }; + + // Act + var result = ToJObject(ManageMaterial.HandleCommand(paramsObj)); + + // Assert + Assert.AreEqual("success", result.Value("status"), result.ToString()); + Assert.IsNotNull(result["properties"]); + Assert.IsInstanceOf(result["properties"]); + var props = result["properties"] as JArray; + Assert.IsTrue(props.Count > 0); + + // Check for standard properties + bool foundColor = false; + foreach(var p in props) + { + var name = p["name"]?.ToString(); + if (name == "_Color" || name == "_BaseColor") foundColor = true; + } + Assert.IsTrue(foundColor, "Should find color property"); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialTests.cs.meta new file mode 100644 index 000000000..3f81b502f --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9f96e01f904e044608d97842c3a3cb43 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialParameterToolTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialParameterToolTests.cs index 92fc5e672..5d56459d1 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialParameterToolTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialParameterToolTests.cs @@ -115,7 +115,7 @@ public void CreateMaterial_WithObjectProperties_SucceedsAndSetsColor() } [Test] - public void AssignMaterial_ToSphere_UsingComponentPropertiesObject_Succeeds() + public void AssignMaterial_ToSphere_UsingManageMaterial_Succeeds() { // Ensure material exists first CreateMaterial_WithObjectProperties_SucceedsAndSetsColor(); @@ -133,23 +133,18 @@ public void AssignMaterial_ToSphere_UsingComponentPropertiesObject_Succeeds() _sphere = GameObject.Find("ToolTestSphere"); Assert.IsNotNull(_sphere, "Sphere should be created."); - // Assign material via object-typed componentProperties - var modifyParams = new JObject + // Assign material via ManageMaterial tool + var assignParams = new JObject { - ["action"] = "modify", + ["action"] = "assign_material_to_renderer", ["target"] = "ToolTestSphere", ["searchMethod"] = "by_name", - ["componentProperties"] = new JObject - { - ["MeshRenderer"] = new JObject - { - ["sharedMaterial"] = _matPath - } - } + ["materialPath"] = _matPath, + ["slot"] = 0 }; - var modifyResult = ToJObject(ManageGameObject.HandleCommand(modifyParams)); - Assert.IsTrue(modifyResult.Value("success"), modifyResult.Value("error")); + var assignResult = ToJObject(ManageMaterial.HandleCommand(assignParams)); + Assert.AreEqual("success", assignResult.Value("status"), assignResult.ToString()); var renderer = _sphere.GetComponent(); Assert.IsNotNull(renderer, "Sphere should have MeshRenderer."); @@ -161,7 +156,7 @@ public void AssignMaterial_ToSphere_UsingComponentPropertiesObject_Succeeds() public void ReadRendererData_DoesNotInstantiateMaterial_AndIncludesSharedMaterial() { // Prepare object and assignment - AssignMaterial_ToSphere_UsingComponentPropertiesObject_Succeeds(); + AssignMaterial_ToSphere_UsingManageMaterial_Succeeds(); var renderer = _sphere.GetComponent(); int beforeId = renderer.sharedMaterial != null ? renderer.sharedMaterial.GetInstanceID() : 0; diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ReadConsoleTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ReadConsoleTests.cs new file mode 100644 index 000000000..1274ed1c1 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ReadConsoleTests.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Tools; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnityTests.Editor.Tools +{ + public class ReadConsoleTests + { + [Test] + public void HandleCommand_Clear_Works() + { + // Arrange + // Ensure there's something to clear + Debug.Log("Log to clear"); + + // Verify content exists before clear + var getBefore = ToJObject(ReadConsole.HandleCommand(new JObject { ["action"] = "get", ["count"] = 10 })); + Assert.IsTrue(getBefore.Value("success"), getBefore.ToString()); + var entriesBefore = getBefore["data"] as JArray; + + // Ideally we'd assert count > 0, but other tests/system logs might affect this. + // Just ensuring the call doesn't fail is a baseline, but let's try to be stricter if possible. + // Since we just logged, there should be at least one entry. + Assert.IsTrue(entriesBefore != null && entriesBefore.Count > 0, "Setup failed: console should have logs."); + + // Act + var result = ToJObject(ReadConsole.HandleCommand(new JObject { ["action"] = "clear" })); + + // Assert + Assert.IsTrue(result.Value("success"), result.ToString()); + + // Verify clear effect + var getAfter = ToJObject(ReadConsole.HandleCommand(new JObject { ["action"] = "get", ["count"] = 10 })); + Assert.IsTrue(getAfter.Value("success"), getAfter.ToString()); + var entriesAfter = getAfter["data"] as JArray; + Assert.IsTrue(entriesAfter == null || entriesAfter.Count == 0, "Console should be empty after clear."); + } + + [Test] + public void HandleCommand_Get_Works() + { + // Arrange + string uniqueMessage = $"Test Log Message {Guid.NewGuid()}"; + Debug.Log(uniqueMessage); + + var paramsObj = new JObject + { + ["action"] = "get", + ["count"] = 1000 // Fetch enough to likely catch our message + }; + + // Act + var result = ToJObject(ReadConsole.HandleCommand(paramsObj)); + + // Assert + Assert.IsTrue(result.Value("success"), result.ToString()); + var data = result["data"] as JArray; + Assert.IsNotNull(data, "Data array should not be null."); + Assert.IsTrue(data.Count > 0, "Should retrieve at least one log entry."); + + // Verify content + bool found = false; + foreach (var entry in data) + { + if (entry["message"]?.ToString().Contains(uniqueMessage) == true) + { + found = true; + break; + } + } + Assert.IsTrue(found, $"The unique log message '{uniqueMessage}' was not found in retrieved logs."); + } + + private static JObject ToJObject(object result) + { + if (result == null) + { + Assert.Fail("ReadConsole.HandleCommand returned null."); + return new JObject(); // Unreachable, but satisfies return type. + } + + return result as JObject ?? JObject.FromObject(result); + } + } +} + diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ReadConsoleTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ReadConsoleTests.cs.meta new file mode 100644 index 000000000..809030c71 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ReadConsoleTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9ef057b0b14234c9abb66c953911792f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: