feat: Add `manage_material` tool for dedicated material manipulation (#440)
* WIP: Material management tool implementation and tests - Add ManageMaterial tool for creating and modifying materials - Add MaterialOps helper for material property operations - Add comprehensive test suite for material management - Add string parameter parsing support for material properties - Update related tools (ManageGameObject, manage_asset, etc.) - Add test materials and scenes for material testing * refactor: unify material property logic into MaterialOps - Move and logic from to - Update to delegate to - Update to use enhanced for creation and property setting - Add texture path loading support to * Add parameter aliasing support: accept 'name' as alias for 'target' in manage_gameobject modify action * Refactor ManageMaterial and fix code review issues - Fix Python server tools (redundant imports, exception handling, string formatting) - Clean up documentation and error reports - Improve ManageMaterial.cs (overwrite checks, error handling) - Enhance MaterialOps.cs (robustness, logging, dead code removal) - Update tests (assertions, unused imports) - Fix manifest.json relative path - Remove temporary test artifacts and manual setup scripts * Remove test scene * remove extra mat * Remove unnecessary SceneTemplateSettings.json * Remove unnecessary SceneTemplateSettings.json * Fix MaterialOps issues * Fix: Case-insensitive material property lookup and missing HasProperty checks * Rabbit fixes * Improve material ops logging and test coverage * Fix: NormalizePath now handles backslashes correctly using AssetPathUtility * Fix: Address multiple nitpicks (test robustness, shader resolution, HasProperty checks) * Add manage_material tool documentation and fix MaterialOps texture property checks - Add comprehensive ManageMaterial tool documentation to MCPForUnity/README.md - Add manage_material to tools list in README.md and README-zh.md - Fix MaterialOps.cs to check HasProperty before SetTexture calls to prevent Unity warnings - Ensures consistency with other property setters in MaterialOps * Fix ManageMaterial shader reflection for Unity 6 and improve texture loggingmain
parent
7f8ca2a3bd
commit
fe4cae7241
|
|
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Applies a set of properties (JObject) to a material, handling aliases and structured formats.
|
||||||
|
/// </summary>
|
||||||
|
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<float>();
|
||||||
|
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<Texture>(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<string>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves common property aliases (e.g. "metallic" -> "_Metallic").
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Auto-detects the main color property name for a material's shader.
|
||||||
|
/// </summary>
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tries to set a shader property on a material based on a JToken value.
|
||||||
|
/// Handles Colors, Vectors, Floats, Ints, Booleans, and Textures.
|
||||||
|
/// </summary>
|
||||||
|
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<Vector4>(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<Vector2>(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<float>(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<bool>(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<Texture>(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<Texture>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper to parse color from JToken (array or object).
|
||||||
|
/// </summary>
|
||||||
|
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<Color>(serializer);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.LogWarning($"[MaterialOps] Failed to parse color from token: {ex.Message}");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a59e8545e32664dae9a696d449f82c3d
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -215,7 +215,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
|
|
||||||
if (propertiesForApply.HasValues)
|
if (propertiesForApply.HasValues)
|
||||||
{
|
{
|
||||||
ApplyMaterialProperties(mat, propertiesForApply);
|
MaterialOps.ApplyProperties(mat, propertiesForApply, ManageGameObject.InputSerializer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AssetDatabase.CreateAsset(mat, fullPath);
|
AssetDatabase.CreateAsset(mat, fullPath);
|
||||||
|
|
@ -443,7 +443,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
{
|
{
|
||||||
// Apply properties directly to the material. If this modifies, it sets modified=true.
|
// 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)
|
// 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
|
// Example: Modifying a ScriptableObject
|
||||||
else if (asset is ScriptableObject so)
|
else if (asset is ScriptableObject so)
|
||||||
|
|
@ -897,299 +897,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Applies properties from JObject to a Material.
|
|
||||||
/// </summary>
|
|
||||||
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<float>(),
|
|
||||||
colArr[1].ToObject<float>(),
|
|
||||||
colArr[2].ToObject<float>(),
|
|
||||||
colArr.Count > 3 ? colArr[3].ToObject<float>() : 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<float>(),
|
|
||||||
colorArr[1].ToObject<float>(),
|
|
||||||
colorArr[2].ToObject<float>(),
|
|
||||||
colorArr.Count > 3 ? colorArr[3].ToObject<float>() : 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<float>();
|
|
||||||
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<Texture>(
|
|
||||||
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<string>(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<float>(),
|
|
||||||
arr[1].ToObject<float>(),
|
|
||||||
arr[2].ToObject<float>(),
|
|
||||||
arr.Count > 3 ? arr[3].ToObject<float>() : 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<float>();
|
|
||||||
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<Texture>(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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Auto-detects the main color property name for a material's shader.
|
|
||||||
/// Tries common color property names in order: _BaseColor (URP), _Color (Standard), etc.
|
|
||||||
/// </summary>
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Applies properties from JObject to a PhysicsMaterial.
|
/// Applies properties from JObject to a PhysicsMaterial.
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
public static class ManageGameObject
|
public static class ManageGameObject
|
||||||
{
|
{
|
||||||
// Shared JsonSerializer to avoid per-call allocation overhead
|
// 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<JsonConverter>
|
Converters = new List<JsonConverter>
|
||||||
{
|
{
|
||||||
|
|
@ -54,10 +54,21 @@ namespace MCPForUnity.Editor.Tools
|
||||||
|
|
||||||
// Parameters used by various actions
|
// Parameters used by various actions
|
||||||
JToken targetToken = @params["target"]; // Can be string (name/path) or int (instanceID)
|
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();
|
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 tag = @params["tag"]?.ToString();
|
||||||
string layer = @params["layer"]?.ToString();
|
string layer = @params["layer"]?.ToString();
|
||||||
JToken parentToken = @params["parent"];
|
JToken parentToken = @params["parent"];
|
||||||
|
|
@ -2112,51 +2123,7 @@ namespace MCPForUnity.Editor.Tools
|
||||||
// Special handling for Material properties (shader properties)
|
// Special handling for Material properties (shader properties)
|
||||||
if (currentObject is Material material && finalPart.StartsWith("_"))
|
if (currentObject is Material material && finalPart.StartsWith("_"))
|
||||||
{
|
{
|
||||||
// Use the serializer to convert the JToken value first
|
return MaterialOps.TrySetShaderProperty(material, finalPart, value, inputSerializer);
|
||||||
if (value is JArray jArray)
|
|
||||||
{
|
|
||||||
// Try converting to known types that SetColor/SetVector accept
|
|
||||||
if (jArray.Count == 4)
|
|
||||||
{
|
|
||||||
try { Color color = value.ToObject<Color>(inputSerializer); material.SetColor(finalPart, color); return true; } catch { }
|
|
||||||
try { Vector4 vec = value.ToObject<Vector4>(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { }
|
|
||||||
}
|
|
||||||
else if (jArray.Count == 3)
|
|
||||||
{
|
|
||||||
try { Color color = value.ToObject<Color>(inputSerializer); material.SetColor(finalPart, color); return true; } catch { } // ToObject handles conversion to Color
|
|
||||||
}
|
|
||||||
else if (jArray.Count == 2)
|
|
||||||
{
|
|
||||||
try { Vector2 vec = value.ToObject<Vector2>(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (value.Type == JTokenType.Float || value.Type == JTokenType.Integer)
|
|
||||||
{
|
|
||||||
try { material.SetFloat(finalPart, value.ToObject<float>(inputSerializer)); return true; } catch { }
|
|
||||||
}
|
|
||||||
else if (value.Type == JTokenType.Boolean)
|
|
||||||
{
|
|
||||||
try { material.SetFloat(finalPart, value.ToObject<bool>(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<Texture>(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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For standard properties (not shader specific)
|
// For standard properties (not shader specific)
|
||||||
|
|
|
||||||
|
|
@ -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<int>() ?? 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<Renderer>();
|
||||||
|
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<int>() ?? 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<Renderer>();
|
||||||
|
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<object>();
|
||||||
|
|
||||||
|
#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 = $"<error: {ex.Message}>";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = $"<error: {ex.Message}>";
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Material>(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}" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e55741e2b00794a049a0ed5e63278a56
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -44,6 +44,7 @@ MCP for Unity 作为桥梁,允许 AI 助手(如 Claude、Cursor)通过本
|
||||||
* `manage_asset`: 执行资源操作(导入、创建、修改、删除等)。
|
* `manage_asset`: 执行资源操作(导入、创建、修改、删除等)。
|
||||||
* `manage_editor`: 控制和查询编辑器的状态和设置。
|
* `manage_editor`: 控制和查询编辑器的状态和设置。
|
||||||
* `manage_gameobject`: 管理游戏对象:创建、修改、删除、查找和组件操作。
|
* `manage_gameobject`: 管理游戏对象:创建、修改、删除、查找和组件操作。
|
||||||
|
* `manage_material`: 管理材质:创建、设置属性、分配给渲染器以及查询材质信息。
|
||||||
* `manage_prefabs`: 执行预制件操作(创建、修改、删除等)。
|
* `manage_prefabs`: 执行预制件操作(创建、修改、删除等)。
|
||||||
* `manage_scene`: 管理场景(加载、保存、创建、获取层次结构等)。
|
* `manage_scene`: 管理场景(加载、保存、创建、获取层次结构等)。
|
||||||
* `manage_script`: 传统脚本操作的兼容性路由器(创建、读取、删除)。建议使用 `apply_text_edits` 或 `script_apply_edits` 进行编辑。
|
* `manage_script`: 传统脚本操作的兼容性路由器(创建、读取、删除)。建议使用 `apply_text_edits` 或 `script_apply_edits` 进行编辑。
|
||||||
|
|
|
||||||
|
|
@ -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_asset`: Performs asset operations (import, create, modify, delete, etc.).
|
||||||
* `manage_editor`: Controls and queries the editor's state and settings.
|
* `manage_editor`: Controls and queries the editor's state and settings.
|
||||||
* `manage_gameobject`: Manages GameObjects: create, modify, delete, find, and component operations.
|
* `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_prefabs`: Performs prefab operations (create, modify, delete, etc.).
|
||||||
* `manage_scene`: Manages scenes (load, save, create, get hierarchy, 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.
|
* `manage_script`: Compatibility router for legacy script operations (create, read, delete). Prefer `apply_text_edits` or `script_apply_edits` for edits.
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from typing import Annotated, Any, Literal
|
||||||
from fastmcp import Context
|
from fastmcp import Context
|
||||||
from services.registry import mcp_for_unity_tool
|
from services.registry import mcp_for_unity_tool
|
||||||
from services.tools import get_unity_instance_from_context
|
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.unity_transport import send_with_unity_instance
|
||||||
from transport.legacy.unity_connection import async_send_command_with_retry
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
||||||
|
|
||||||
|
|
@ -63,6 +64,13 @@ async def manage_asset(
|
||||||
return raw, None
|
return raw, None
|
||||||
if isinstance(raw, str):
|
if isinstance(raw, str):
|
||||||
await ctx.info(f"manage_asset: received properties as string (first 100 chars): {raw[:100]}")
|
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)
|
parsed, source = _parse_properties_string(raw)
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
return None, source
|
return None, source
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import json
|
import json
|
||||||
from typing import Annotated, Any, Literal
|
import math
|
||||||
|
from typing import Annotated, Any, Literal, Union
|
||||||
|
|
||||||
from fastmcp import Context
|
from fastmcp import Context
|
||||||
from services.registry import mcp_for_unity_tool
|
from services.registry import mcp_for_unity_tool
|
||||||
from services.tools import get_unity_instance_from_context
|
from services.tools import get_unity_instance_from_context
|
||||||
from transport.unity_transport import send_with_unity_instance
|
from transport.unity_transport import send_with_unity_instance
|
||||||
from transport.legacy.unity_connection import async_send_command_with_retry
|
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(
|
@mcp_for_unity_tool(
|
||||||
|
|
@ -14,7 +15,7 @@ from services.tools.utils import coerce_bool
|
||||||
)
|
)
|
||||||
async def manage_gameobject(
|
async def manage_gameobject(
|
||||||
ctx: Context,
|
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,
|
target: Annotated[str,
|
||||||
"GameObject identifier by name or path for modify/delete/component actions"] | None = None,
|
"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"],
|
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,
|
"Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None,
|
||||||
parent: Annotated[str,
|
parent: Annotated[str,
|
||||||
"Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None,
|
"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,
|
"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,
|
"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,
|
"Scale - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None,
|
||||||
components_to_add: Annotated[list[str],
|
components_to_add: Annotated[list[str],
|
||||||
"List of component names to add"] | None = None,
|
"List of component names to add"] | None = None,
|
||||||
|
|
@ -46,7 +47,7 @@ async def manage_gameobject(
|
||||||
layer: Annotated[str, "Layer name"] | None = None,
|
layer: Annotated[str, "Layer name"] | None = None,
|
||||||
components_to_remove: Annotated[list[str],
|
components_to_remove: Annotated[list[str],
|
||||||
"List of component names to remove"] | None = None,
|
"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:
|
"""Dictionary of component names to their properties to set. For example:
|
||||||
`{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject
|
`{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject
|
||||||
`{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component
|
`{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component
|
||||||
|
|
@ -70,7 +71,7 @@ async def manage_gameobject(
|
||||||
# --- Parameters for 'duplicate' ---
|
# --- Parameters for 'duplicate' ---
|
||||||
new_name: Annotated[str,
|
new_name: Annotated[str,
|
||||||
"New name for the duplicated object (default: SourceName_Copy)"] | None = None,
|
"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,
|
"Offset from original/reference position - [x,y,z] or string '[x,y,z]'"] | None = None,
|
||||||
# --- Parameters for 'move_relative' ---
|
# --- Parameters for 'move_relative' ---
|
||||||
reference_object: Annotated[str,
|
reference_object: Annotated[str,
|
||||||
|
|
@ -86,11 +87,19 @@ async def manage_gameobject(
|
||||||
# Removed session_state import
|
# Removed session_state import
|
||||||
unity_instance = get_unity_instance_from_context(ctx)
|
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
|
# Coercers to tolerate stringified booleans and vectors
|
||||||
def _coerce_vec(value, default=None):
|
def _coerce_vec(value, default=None):
|
||||||
if value is None:
|
if value is None:
|
||||||
return default
|
return default
|
||||||
import math
|
|
||||||
|
# First try to parse if it's a string
|
||||||
|
val = parse_json_payload(value)
|
||||||
|
|
||||||
def _to_vec3(parts):
|
def _to_vec3(parts):
|
||||||
try:
|
try:
|
||||||
|
|
@ -98,10 +107,13 @@ async def manage_gameobject(
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return default
|
return default
|
||||||
return vec if all(math.isfinite(n) for n in vec) else 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(val, list) and len(val) == 3:
|
||||||
if isinstance(value, str):
|
return _to_vec3(val)
|
||||||
s = value.strip()
|
|
||||||
|
# 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"
|
# minimal tolerant parse for "[x,y,z]" or "x,y,z"
|
||||||
if s.startswith("[") and s.endswith("]"):
|
if s.startswith("[") and s.endswith("]"):
|
||||||
s = s[1:-1]
|
s = s[1:-1]
|
||||||
|
|
@ -125,16 +137,12 @@ async def manage_gameobject(
|
||||||
world_space = coerce_bool(world_space, default=True)
|
world_space = coerce_bool(world_space, default=True)
|
||||||
|
|
||||||
# Coerce 'component_properties' from JSON string to dict for client compatibility
|
# Coerce 'component_properties' from JSON string to dict for client compatibility
|
||||||
if isinstance(component_properties, str):
|
component_properties = parse_json_payload(component_properties)
|
||||||
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}"}
|
|
||||||
# Ensure final type is a dict (object) if provided
|
# Ensure final type is a dict (object) if provided
|
||||||
if component_properties is not None and not isinstance(component_properties, dict):
|
if component_properties is not None and not isinstance(component_properties, dict):
|
||||||
return {"success": False, "message": "component_properties must be a JSON object (dict)."}
|
return {"success": False, "message": "component_properties must be a JSON object (dict)."}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Map tag to search_term when search_method is by_tag for backward compatibility
|
# 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:
|
if action == "find" and search_method == "by_tag" and tag is not None and search_term is None:
|
||||||
|
|
@ -229,4 +237,4 @@ async def manage_gameobject(
|
||||||
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
|
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"success": False, "message": f"Python error managing GameObject: {str(e)}"}
|
return {"success": False, "message": f"Python error managing GameObject: {e!s}"}
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
@ -16,7 +16,7 @@ from transport.legacy.unity_connection import async_send_command_with_retry
|
||||||
async def read_console(
|
async def read_console(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
action: Annotated[Literal['get', 'clear'],
|
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',
|
types: Annotated[list[Literal['error', 'warning',
|
||||||
'log', 'all']], "Message types to get"] | None = None,
|
'log', 'all']], "Message types to get"] | None = None,
|
||||||
count: Annotated[int | str,
|
count: Annotated[int | str,
|
||||||
|
|
@ -99,8 +99,15 @@ async def read_console(
|
||||||
if isinstance(resp, dict) and resp.get("success") and not include_stacktrace:
|
if isinstance(resp, dict) and resp.get("success") and not include_stacktrace:
|
||||||
# Strip stacktrace fields from returned lines if present
|
# Strip stacktrace fields from returned lines if present
|
||||||
try:
|
try:
|
||||||
lines = resp.get("data", {}).get("lines", [])
|
data = resp.get("data")
|
||||||
for line in lines:
|
# 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:
|
if isinstance(line, dict) and "stacktrace" in line:
|
||||||
line.pop("stacktrace", None)
|
line.pop("stacktrace", None)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import re
|
import re
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any, Union
|
||||||
|
|
||||||
from fastmcp import Context
|
from fastmcp import Context
|
||||||
|
|
||||||
from services.registry import mcp_for_unity_tool
|
from services.registry import mcp_for_unity_tool
|
||||||
from services.tools import get_unity_instance_from_context
|
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.unity_transport import send_with_unity_instance
|
||||||
from transport.legacy.unity_connection import async_send_command_with_retry
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
||||||
|
|
||||||
|
|
@ -360,7 +361,7 @@ async def script_apply_edits(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
name: Annotated[str, "Name of the script to edit"],
|
name: Annotated[str, "Name of the script to edit"],
|
||||||
path: Annotated[str, "Path to the script to edit under Assets/ directory"],
|
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: Annotated[dict[str, Any],
|
||||||
"Options for the script edit"] | None = None,
|
"Options for the script edit"] | None = None,
|
||||||
script_type: Annotated[str,
|
script_type: Annotated[str,
|
||||||
|
|
@ -371,6 +372,12 @@ async def script_apply_edits(
|
||||||
unity_instance = get_unity_instance_from_context(ctx)
|
unity_instance = get_unity_instance_from_context(ctx)
|
||||||
await ctx.info(
|
await ctx.info(
|
||||||
f"Processing script_apply_edits: {name} (unity_instance={unity_instance or 'default'})")
|
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.
|
# Normalize locator first so downstream calls target the correct script file.
|
||||||
name, path = _normalize_script_locator(name, path)
|
name, path = _normalize_script_locator(name, path)
|
||||||
# Normalize unsupported or aliased ops to known structured/text paths
|
# Normalize unsupported or aliased ops to known structured/text paths
|
||||||
|
|
@ -895,10 +902,10 @@ async def script_apply_edits(
|
||||||
if isinstance(resp, dict) and resp.get("success"):
|
if isinstance(resp, dict) and resp.get("success"):
|
||||||
pass # Optional sentinel reload removed (deprecated)
|
pass # Optional sentinel reload removed (deprecated)
|
||||||
return _with_norm(
|
return _with_norm(
|
||||||
resp if isinstance(resp, dict) else {
|
resp if isinstance(resp, dict)
|
||||||
"success": False, "message": str(resp)},
|
else {"success": False, "message": str(resp)},
|
||||||
normalized_for_echo,
|
normalized_for_echo,
|
||||||
routing="text"
|
routing="text",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _with_norm({"success": False, "code": "conversion_failed", "message": f"Edit conversion failed: {e}"}, normalized_for_echo, routing="text")
|
return _with_norm({"success": False, "code": "conversion_failed", "message": f"Edit conversion failed: {e}"}, normalized_for_echo, routing="text")
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,12 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
_TRUTHY = {"true", "1", "yes", "on"}
|
_TRUTHY = {"true", "1", "yes", "on"}
|
||||||
_FALSY = {"false", "0", "no", "off"}
|
_FALSY = {"false", "0", "no", "off"}
|
||||||
|
|
||||||
|
|
||||||
def coerce_bool(value: Any, default: bool | None = None) -> bool | None:
|
def coerce_bool(value: Any, default: bool | None = None) -> bool | None:
|
||||||
"""Attempt to coerce a loosely-typed value to a boolean."""
|
"""Attempt to coerce a loosely-typed value to a boolean."""
|
||||||
if value is None:
|
if value is None:
|
||||||
|
|
@ -23,3 +22,39 @@ def coerce_bool(value: Any, default: bool | None = None) -> bool | None:
|
||||||
return False
|
return False
|
||||||
return default
|
return default
|
||||||
return bool(value)
|
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
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,10 @@ class TestManageAssetJsonParsing:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify JSON parsing was logged
|
# 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
|
# Verify the result
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
|
|
@ -117,12 +120,12 @@ class TestManageGameObjectJsonParsing:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_component_properties_json_string_parsing(self, monkeypatch):
|
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
|
from services.tools.manage_gameobject import manage_gameobject
|
||||||
|
|
||||||
ctx = DummyContext()
|
ctx = DummyContext()
|
||||||
|
|
||||||
async def fake_send(cmd, params, **kwargs):
|
async def fake_send(_cmd, params, **_kwargs):
|
||||||
return {"success": True, "message": "GameObject created successfully"}
|
return {"success": True, "message": "GameObject created successfully"}
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"services.tools.manage_gameobject.async_send_command_with_retry",
|
"services.tools.manage_gameobject.async_send_command_with_retry",
|
||||||
|
|
@ -137,8 +140,31 @@ class TestManageGameObjectJsonParsing:
|
||||||
component_properties='{"MeshRenderer": {"material": "Assets/Materials/BlueMaterial.mat"}}'
|
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
|
# Verify the result
|
||||||
assert result["success"] is True
|
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)
|
||||||
|
|
|
||||||
|
|
@ -641,7 +641,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "keyring"
|
name = "keyring"
|
||||||
version = "25.6.0"
|
version = "25.7.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "importlib-metadata", marker = "python_full_version < '3.12'" },
|
{ name = "importlib-metadata", marker = "python_full_version < '3.12'" },
|
||||||
|
|
@ -652,9 +652,9 @@ dependencies = [
|
||||||
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
|
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
|
||||||
{ name = "secretstorage", marker = "sys_platform == 'linux'" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
|
|
@ -694,7 +694,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcpforunityserver"
|
name = "mcpforunityserver"
|
||||||
version = "8.1.4"
|
version = "8.1.6"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
|
|
@ -1361,15 +1361,15 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "secretstorage"
|
name = "secretstorage"
|
||||||
version = "3.4.0"
|
version = "3.5.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cryptography" },
|
{ name = "cryptography" },
|
||||||
{ name = "jeepney" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d6cd845e48d9e4d558d50f7a50149682
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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<string>("status"), result.ToString());
|
||||||
|
var mat = AssetDatabase.LoadAssetAtPath<Material>(_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<string>("status"), result.ToString());
|
||||||
|
var mat = AssetDatabase.LoadAssetAtPath<Material>(_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<string>("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<string>("status"));
|
||||||
|
string msg = result.Value<string>("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<string>("status"));
|
||||||
|
var status = result.Value<string>("status");
|
||||||
|
Assert.IsTrue(status == "success" || status == "error", $"Status should be success or error, got {status}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: ca019b5c6c1ee4e13b77574f2ae53583
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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<string>("status"));
|
||||||
|
|
||||||
|
// We expect more detailed error message after fix
|
||||||
|
var message = result.Value<string>("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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c967207bf78c344178484efe6d87dea7
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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<string>("status"));
|
||||||
|
StringAssert.Contains("Could not find material", resultBadPath.Value<string>("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<string>("status"));
|
||||||
|
StringAssert.Contains("Invalid color format", resultBadColor.Value<string>("message"));
|
||||||
|
|
||||||
|
// 3. Bad slot index
|
||||||
|
// Assign material first
|
||||||
|
var renderer = _cube.GetComponent<Renderer>();
|
||||||
|
renderer.sharedMaterial = AssetDatabase.LoadAssetAtPath<Material>(_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<string>("status"));
|
||||||
|
StringAssert.Contains("out of bounds", resultBadSlot.Value<string>("message"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void StateIsolation_PropertyBlockDoesNotLeakToSharedMaterial()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var renderer = _cube.GetComponent<Renderer>();
|
||||||
|
var sharedMat = AssetDatabase.LoadAssetAtPath<Material>(_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<string>("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<string>("status"));
|
||||||
|
|
||||||
|
// Verify assignment
|
||||||
|
var renderer = _cube.GetComponent<Renderer>();
|
||||||
|
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<string>("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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 49ecdd3f43cf54deea7508f317efcb45
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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<Material>(_matPath);
|
||||||
|
if (mat.shader.name == "Standard")
|
||||||
|
{
|
||||||
|
paramsObj["property"] = "_Color";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = ToJObject(ManageMaterial.HandleCommand(paramsObj));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.AreEqual("success", result.Value<string>("status"), result.ToString());
|
||||||
|
|
||||||
|
mat = AssetDatabase.LoadAssetAtPath<Material>(_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<string>("status"), result.ToString());
|
||||||
|
|
||||||
|
var mat = AssetDatabase.LoadAssetAtPath<Material>(_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<string>("status"), result.ToString());
|
||||||
|
|
||||||
|
var renderer = go.GetComponent<Renderer>();
|
||||||
|
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<Material>(_matPath);
|
||||||
|
go.GetComponent<Renderer>().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<string>("status"), result.ToString());
|
||||||
|
|
||||||
|
var renderer = go.GetComponent<Renderer>();
|
||||||
|
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<string>("status"), result.ToString());
|
||||||
|
Assert.IsNotNull(result["properties"]);
|
||||||
|
Assert.IsInstanceOf<JArray>(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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9f96e01f904e044608d97842c3a3cb43
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -115,7 +115,7 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void AssignMaterial_ToSphere_UsingComponentPropertiesObject_Succeeds()
|
public void AssignMaterial_ToSphere_UsingManageMaterial_Succeeds()
|
||||||
{
|
{
|
||||||
// Ensure material exists first
|
// Ensure material exists first
|
||||||
CreateMaterial_WithObjectProperties_SucceedsAndSetsColor();
|
CreateMaterial_WithObjectProperties_SucceedsAndSetsColor();
|
||||||
|
|
@ -133,23 +133,18 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
_sphere = GameObject.Find("ToolTestSphere");
|
_sphere = GameObject.Find("ToolTestSphere");
|
||||||
Assert.IsNotNull(_sphere, "Sphere should be created.");
|
Assert.IsNotNull(_sphere, "Sphere should be created.");
|
||||||
|
|
||||||
// Assign material via object-typed componentProperties
|
// Assign material via ManageMaterial tool
|
||||||
var modifyParams = new JObject
|
var assignParams = new JObject
|
||||||
{
|
{
|
||||||
["action"] = "modify",
|
["action"] = "assign_material_to_renderer",
|
||||||
["target"] = "ToolTestSphere",
|
["target"] = "ToolTestSphere",
|
||||||
["searchMethod"] = "by_name",
|
["searchMethod"] = "by_name",
|
||||||
["componentProperties"] = new JObject
|
["materialPath"] = _matPath,
|
||||||
{
|
["slot"] = 0
|
||||||
["MeshRenderer"] = new JObject
|
|
||||||
{
|
|
||||||
["sharedMaterial"] = _matPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var modifyResult = ToJObject(ManageGameObject.HandleCommand(modifyParams));
|
var assignResult = ToJObject(ManageMaterial.HandleCommand(assignParams));
|
||||||
Assert.IsTrue(modifyResult.Value<bool>("success"), modifyResult.Value<string>("error"));
|
Assert.AreEqual("success", assignResult.Value<string>("status"), assignResult.ToString());
|
||||||
|
|
||||||
var renderer = _sphere.GetComponent<MeshRenderer>();
|
var renderer = _sphere.GetComponent<MeshRenderer>();
|
||||||
Assert.IsNotNull(renderer, "Sphere should have MeshRenderer.");
|
Assert.IsNotNull(renderer, "Sphere should have MeshRenderer.");
|
||||||
|
|
@ -161,7 +156,7 @@ namespace MCPForUnityTests.Editor.Tools
|
||||||
public void ReadRendererData_DoesNotInstantiateMaterial_AndIncludesSharedMaterial()
|
public void ReadRendererData_DoesNotInstantiateMaterial_AndIncludesSharedMaterial()
|
||||||
{
|
{
|
||||||
// Prepare object and assignment
|
// Prepare object and assignment
|
||||||
AssignMaterial_ToSphere_UsingComponentPropertiesObject_Succeeds();
|
AssignMaterial_ToSphere_UsingManageMaterial_Succeeds();
|
||||||
|
|
||||||
var renderer = _sphere.GetComponent<MeshRenderer>();
|
var renderer = _sphere.GetComponent<MeshRenderer>();
|
||||||
int beforeId = renderer.sharedMaterial != null ? renderer.sharedMaterial.GetInstanceID() : 0;
|
int beforeId = renderer.sharedMaterial != null ? renderer.sharedMaterial.GetInstanceID() : 0;
|
||||||
|
|
|
||||||
|
|
@ -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<bool>("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<bool>("success"), result.ToString());
|
||||||
|
|
||||||
|
// Verify clear effect
|
||||||
|
var getAfter = ToJObject(ReadConsole.HandleCommand(new JObject { ["action"] = "get", ["count"] = 10 }));
|
||||||
|
Assert.IsTrue(getAfter.Value<bool>("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<bool>("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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9ef057b0b14234c9abb66c953911792f
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
Loading…
Reference in New Issue