From b2bfd450d3d773faad2df71b2854bfa8fbce903f Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Fri, 23 Jan 2026 13:07:51 -0500 Subject: [PATCH 1/6] Update for Texture2D/Sprite Generation Given the choice to generate Texture2D based on patterns and color, also introduce pipeline to turn Texture2D direct to Sprite. Update CLI command to include this too. --- MCPForUnity/Editor/Helpers/TextureOps.cs | 162 ++++ MCPForUnity/Editor/Helpers/TextureOps.cs.meta | 11 + MCPForUnity/Editor/Tools/ManageTexture.cs | 898 ++++++++++++++++++ .../Editor/Tools/ManageTexture.cs.meta | 11 + README.md | 2 +- Server/src/cli/commands/texture.py | 445 +++++++++ Server/src/cli/main.py | 1 + Server/src/services/tools/manage_texture.py | 619 ++++++++++++ .../tests/integration/test_manage_texture.py | 276 ++++++ Server/tests/test_cli.py | 119 +++ Server/uv.lock | 2 +- 11 files changed, 2544 insertions(+), 2 deletions(-) create mode 100644 MCPForUnity/Editor/Helpers/TextureOps.cs create mode 100644 MCPForUnity/Editor/Helpers/TextureOps.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ManageTexture.cs create mode 100644 MCPForUnity/Editor/Tools/ManageTexture.cs.meta create mode 100644 Server/src/cli/commands/texture.py create mode 100644 Server/src/services/tools/manage_texture.py create mode 100644 Server/tests/integration/test_manage_texture.py diff --git a/MCPForUnity/Editor/Helpers/TextureOps.cs b/MCPForUnity/Editor/Helpers/TextureOps.cs new file mode 100644 index 000000000..b062b47f2 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/TextureOps.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + public static class TextureOps + { + public static byte[] EncodeTexture(Texture2D texture, string assetPath) + { + if (texture == null) + return null; + + string extension = Path.GetExtension(assetPath); + if (string.IsNullOrEmpty(extension)) + { + McpLog.Warn($"[TextureOps] No file extension for '{assetPath}', defaulting to PNG."); + return texture.EncodeToPNG(); + } + + switch (extension.ToLowerInvariant()) + { + case ".png": + return texture.EncodeToPNG(); + case ".jpg": + case ".jpeg": + return texture.EncodeToJPG(); + default: + McpLog.Warn($"[TextureOps] Unsupported extension '{extension}' for '{assetPath}', defaulting to PNG."); + return texture.EncodeToPNG(); + } + } + + public static void FillTexture(Texture2D texture, Color32 color) + { + if (texture == null) + return; + + Color32[] pixels = new Color32[texture.width * texture.height]; + for (int i = 0; i < pixels.Length; i++) + { + pixels[i] = color; + } + texture.SetPixels32(pixels); + } + + public static Color32 ParseColor32(JArray colorArray) + { + if (colorArray == null || colorArray.Count < 3) + return new Color32(255, 255, 255, 255); + + byte r = (byte)Mathf.Clamp(colorArray[0].ToObject(), 0, 255); + byte g = (byte)Mathf.Clamp(colorArray[1].ToObject(), 0, 255); + byte b = (byte)Mathf.Clamp(colorArray[2].ToObject(), 0, 255); + byte a = colorArray.Count > 3 ? (byte)Mathf.Clamp(colorArray[3].ToObject(), 0, 255) : (byte)255; + + return new Color32(r, g, b, a); + } + + public static List ParsePalette(JArray paletteArray) + { + if (paletteArray == null) + return null; + + List palette = new List(); + foreach (var item in paletteArray) + { + if (item is JArray colorArray) + { + palette.Add(ParseColor32(colorArray)); + } + } + return palette.Count > 0 ? palette : null; + } + + public static void ApplyPixelData(Texture2D texture, JToken pixelsToken, int width, int height) + { + ApplyPixelDataToRegion(texture, pixelsToken, 0, 0, width, height); + } + + public static void ApplyPixelDataToRegion(Texture2D texture, JToken pixelsToken, int offsetX, int offsetY, int regionWidth, int regionHeight) + { + if (texture == null || pixelsToken == null) + return; + + int textureWidth = texture.width; + int textureHeight = texture.height; + + if (pixelsToken is JArray pixelArray) + { + int index = 0; + for (int y = 0; y < regionHeight && index < pixelArray.Count; y++) + { + for (int x = 0; x < regionWidth && index < pixelArray.Count; x++) + { + var pixelColor = pixelArray[index] as JArray; + if (pixelColor != null) + { + int px = offsetX + x; + int py = offsetY + y; + if (px >= 0 && px < textureWidth && py >= 0 && py < textureHeight) + { + texture.SetPixel(px, py, ParseColor32(pixelColor)); + } + } + index++; + } + } + + int expectedCount = regionWidth * regionHeight; + if (pixelArray.Count != expectedCount) + { + McpLog.Warn($"[TextureOps] Pixel array size mismatch: expected {expectedCount} entries, got {pixelArray.Count}"); + } + } + else if (pixelsToken.Type == JTokenType.String) + { + string pixelString = pixelsToken.ToString(); + string base64 = pixelString.StartsWith("base64:") ? pixelString.Substring(7) : pixelString; + if (!pixelString.StartsWith("base64:")) + { + McpLog.Warn("[TextureOps] Base64 pixel data missing 'base64:' prefix; attempting to decode."); + } + + byte[] rawData = Convert.FromBase64String(base64); + + // Assume RGBA32 format: 4 bytes per pixel + int expectedBytes = regionWidth * regionHeight * 4; + if (rawData.Length == expectedBytes) + { + int pixelIndex = 0; + for (int y = 0; y < regionHeight; y++) + { + for (int x = 0; x < regionWidth; x++) + { + int px = offsetX + x; + int py = offsetY + y; + if (px >= 0 && px < textureWidth && py >= 0 && py < textureHeight) + { + int byteIndex = pixelIndex * 4; + Color32 color = new Color32( + rawData[byteIndex], + rawData[byteIndex + 1], + rawData[byteIndex + 2], + rawData[byteIndex + 3] + ); + texture.SetPixel(px, py, color); + } + pixelIndex++; + } + } + } + else + { + McpLog.Warn($"[TextureOps] Base64 data size mismatch: expected {expectedBytes} bytes, got {rawData.Length}"); + } + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/TextureOps.cs.meta b/MCPForUnity/Editor/Helpers/TextureOps.cs.meta new file mode 100644 index 000000000..6dc72907f --- /dev/null +++ b/MCPForUnity/Editor/Helpers/TextureOps.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 864ea682d797466a84b6b951f6c4e4ba +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageTexture.cs b/MCPForUnity/Editor/Tools/ManageTexture.cs new file mode 100644 index 000000000..2d153bdc4 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageTexture.cs @@ -0,0 +1,898 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Handles procedural texture generation operations. + /// Supports patterns (checkerboard, stripes, dots, grid, brick), + /// gradients, noise, and direct pixel manipulation. + /// + [McpForUnityTool("manage_texture", AutoRegister = false)] + public static class ManageTexture + { + private static readonly List ValidActions = new List + { + "create", + "modify", + "delete", + "create_sprite", + "apply_pattern", + "apply_gradient", + "apply_noise" + }; + + public static object HandleCommand(JObject @params) + { + string action = @params["action"]?.ToString().ToLower(); + if (string.IsNullOrEmpty(action)) + { + return new ErrorResponse("Action parameter is required."); + } + + if (!ValidActions.Contains(action)) + { + string validActionsList = string.Join(", ", ValidActions); + return new ErrorResponse( + $"Unknown action: '{action}'. Valid actions are: {validActionsList}" + ); + } + + string path = @params["path"]?.ToString(); + + try + { + switch (action) + { + case "create": + case "create_sprite": + return CreateTexture(@params, action == "create_sprite"); + case "modify": + return ModifyTexture(@params); + case "delete": + return DeleteTexture(path); + case "apply_pattern": + return ApplyPattern(@params); + case "apply_gradient": + return ApplyGradient(@params); + case "apply_noise": + return ApplyNoise(@params); + default: + return new ErrorResponse($"Unknown action: '{action}'"); + } + } + catch (Exception e) + { + McpLog.Error($"[ManageTexture] Action '{action}' failed: {e}"); + return new ErrorResponse($"Internal error processing action '{action}': {e.Message}"); + } + } + + // --- Action Implementations --- + + private static object CreateTexture(JObject @params, bool asSprite) + { + string path = @params["path"]?.ToString(); + if (string.IsNullOrEmpty(path)) + return new ErrorResponse("'path' is required for create."); + + int width = @params["width"]?.ToObject() ?? 64; + int height = @params["height"]?.ToObject() ?? 64; + + // Validate dimensions + if (width <= 0 || height <= 0 || width > 4096 || height > 4096) + return new ErrorResponse($"Invalid dimensions: {width}x{height}. Must be 1-4096."); + + string fullPath = AssetPathUtility.SanitizeAssetPath(path); + EnsureDirectoryExists(fullPath); + + try + { + Texture2D texture = new Texture2D(width, height, TextureFormat.RGBA32, false); + + // Check for fill color + var fillColorToken = @params["fillColor"]; + if (fillColorToken != null && fillColorToken.Type == JTokenType.Array) + { + Color32 fillColor = TextureOps.ParseColor32(fillColorToken as JArray); + TextureOps.FillTexture(texture, fillColor); + } + + // Check for pattern + var patternToken = @params["pattern"]; + if (patternToken != null) + { + string pattern = patternToken.ToString(); + var palette = TextureOps.ParsePalette(@params["palette"] as JArray); + int patternSize = @params["patternSize"]?.ToObject() ?? 8; + ApplyPatternToTexture(texture, pattern, palette, patternSize); + } + + // Check for direct pixel data + var pixelsToken = @params["pixels"]; + if (pixelsToken != null) + { + TextureOps.ApplyPixelData(texture, pixelsToken, width, height); + } + + // If nothing specified, create transparent texture + if (fillColorToken == null && patternToken == null && pixelsToken == null) + { + TextureOps.FillTexture(texture, new Color32(0, 0, 0, 0)); + } + + texture.Apply(); + + // Save to disk + byte[] imageData = TextureOps.EncodeTexture(texture, fullPath); + if (imageData == null || imageData.Length == 0) + { + UnityEngine.Object.DestroyImmediate(texture); + return new ErrorResponse($"Failed to encode texture for '{fullPath}'"); + } + File.WriteAllBytes(GetAbsolutePath(fullPath), imageData); + + AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); + + // Configure texture importer settings if provided + JToken importSettingsToken = @params["importSettings"]; + JToken spriteSettingsToken = @params["spriteSettings"]; + + if (importSettingsToken != null) + { + ConfigureTextureImporter(fullPath, importSettingsToken); + } + else if (asSprite || spriteSettingsToken != null) + { + // Legacy sprite configuration + ConfigureAsSprite(fullPath, spriteSettingsToken); + } + + // Clean up memory + UnityEngine.Object.DestroyImmediate(texture); + + return new SuccessResponse( + $"Texture created at '{fullPath}' ({width}x{height})" + (asSprite ? " as sprite" : ""), + new { path = fullPath, width, height, asSprite = asSprite || spriteSettingsToken != null || (importSettingsToken?["textureType"]?.ToString() == "Sprite") } + ); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to create texture: {e.Message}"); + } + } + + private static object ModifyTexture(JObject @params) + { + string path = @params["path"]?.ToString(); + if (string.IsNullOrEmpty(path)) + return new ErrorResponse("'path' is required for modify."); + + string fullPath = AssetPathUtility.SanitizeAssetPath(path); + if (!AssetExists(fullPath)) + return new ErrorResponse($"Texture not found at path: {fullPath}"); + + try + { + Texture2D texture = AssetDatabase.LoadAssetAtPath(fullPath); + if (texture == null) + return new ErrorResponse($"Failed to load texture at path: {fullPath}"); + + // Make the texture readable + string absolutePath = GetAbsolutePath(fullPath); + byte[] fileData = File.ReadAllBytes(absolutePath); + Texture2D editableTexture = new Texture2D(texture.width, texture.height, TextureFormat.RGBA32, false); + editableTexture.LoadImage(fileData); + + // Apply modifications + var setPixelsToken = @params["setPixels"] as JObject; + if (setPixelsToken != null) + { + int x = setPixelsToken["x"]?.ToObject() ?? 0; + int y = setPixelsToken["y"]?.ToObject() ?? 0; + int w = setPixelsToken["width"]?.ToObject() ?? 1; + int h = setPixelsToken["height"]?.ToObject() ?? 1; + + if (w <= 0 || h <= 0) + { + UnityEngine.Object.DestroyImmediate(editableTexture); + return new ErrorResponse("setPixels width and height must be positive."); + } + + var pixelsToken = setPixelsToken["pixels"]; + var colorToken = setPixelsToken["color"]; + + if (pixelsToken != null) + { + TextureOps.ApplyPixelDataToRegion(editableTexture, pixelsToken, x, y, w, h); + } + else if (colorToken != null) + { + Color32 color = TextureOps.ParseColor32(colorToken as JArray); + int startX = Mathf.Max(0, x); + int startY = Mathf.Max(0, y); + int endX = Mathf.Min(x + w, editableTexture.width); + int endY = Mathf.Min(y + h, editableTexture.height); + + for (int py = startY; py < endY; py++) + { + for (int px = startX; px < endX; px++) + { + editableTexture.SetPixel(px, py, color); + } + } + } + else + { + UnityEngine.Object.DestroyImmediate(editableTexture); + return new ErrorResponse("setPixels requires 'color' or 'pixels'."); + } + } + + editableTexture.Apply(); + + // Save back to disk + byte[] imageData = TextureOps.EncodeTexture(editableTexture, fullPath); + if (imageData == null || imageData.Length == 0) + { + UnityEngine.Object.DestroyImmediate(editableTexture); + return new ErrorResponse($"Failed to encode texture for '{fullPath}'"); + } + File.WriteAllBytes(absolutePath, imageData); + + AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); + + UnityEngine.Object.DestroyImmediate(editableTexture); + + return new SuccessResponse($"Texture modified: {fullPath}"); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to modify texture: {e.Message}"); + } + } + + private static object DeleteTexture(string path) + { + if (string.IsNullOrEmpty(path)) + return new ErrorResponse("'path' is required for delete."); + + string fullPath = AssetPathUtility.SanitizeAssetPath(path); + if (!AssetExists(fullPath)) + return new ErrorResponse($"Texture not found at path: {fullPath}"); + + try + { + bool success = AssetDatabase.DeleteAsset(fullPath); + if (success) + return new SuccessResponse($"Texture deleted: {fullPath}"); + else + return new ErrorResponse($"Failed to delete texture: {fullPath}"); + } + catch (Exception e) + { + return new ErrorResponse($"Error deleting texture: {e.Message}"); + } + } + + private static object ApplyPattern(JObject @params) + { + // Reuse CreateTexture with pattern + return CreateTexture(@params, false); + } + + private static object ApplyGradient(JObject @params) + { + string path = @params["path"]?.ToString(); + if (string.IsNullOrEmpty(path)) + return new ErrorResponse("'path' is required for apply_gradient."); + + int width = @params["width"]?.ToObject() ?? 64; + int height = @params["height"]?.ToObject() ?? 64; + string gradientType = @params["gradientType"]?.ToString() ?? "linear"; + float angle = @params["gradientAngle"]?.ToObject() ?? 0f; + + var palette = TextureOps.ParsePalette(@params["palette"] as JArray); + if (palette == null || palette.Count < 2) + { + // Default gradient palette + palette = new List { new Color32(0, 0, 0, 255), new Color32(255, 255, 255, 255) }; + } + + string fullPath = AssetPathUtility.SanitizeAssetPath(path); + EnsureDirectoryExists(fullPath); + + try + { + Texture2D texture = new Texture2D(width, height, TextureFormat.RGBA32, false); + + if (gradientType == "radial") + { + ApplyRadialGradient(texture, palette); + } + else + { + ApplyLinearGradient(texture, palette, angle); + } + + texture.Apply(); + + byte[] imageData = TextureOps.EncodeTexture(texture, fullPath); + if (imageData == null || imageData.Length == 0) + { + UnityEngine.Object.DestroyImmediate(texture); + return new ErrorResponse($"Failed to encode texture for '{fullPath}'"); + } + File.WriteAllBytes(GetAbsolutePath(fullPath), imageData); + + AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); + + // Configure as sprite if requested + JToken spriteSettingsToken = @params["spriteSettings"]; + if (spriteSettingsToken != null) + { + ConfigureAsSprite(fullPath, spriteSettingsToken); + } + + UnityEngine.Object.DestroyImmediate(texture); + + return new SuccessResponse( + $"Gradient texture created at '{fullPath}' ({width}x{height})", + new { path = fullPath, width, height, gradientType } + ); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to create gradient texture: {e.Message}"); + } + } + + private static object ApplyNoise(JObject @params) + { + string path = @params["path"]?.ToString(); + if (string.IsNullOrEmpty(path)) + return new ErrorResponse("'path' is required for apply_noise."); + + int width = @params["width"]?.ToObject() ?? 64; + int height = @params["height"]?.ToObject() ?? 64; + float scale = @params["noiseScale"]?.ToObject() ?? 0.1f; + int octaves = @params["octaves"]?.ToObject() ?? 1; + + var palette = TextureOps.ParsePalette(@params["palette"] as JArray); + if (palette == null || palette.Count < 2) + { + palette = new List { new Color32(0, 0, 0, 255), new Color32(255, 255, 255, 255) }; + } + + string fullPath = AssetPathUtility.SanitizeAssetPath(path); + EnsureDirectoryExists(fullPath); + + try + { + Texture2D texture = new Texture2D(width, height, TextureFormat.RGBA32, false); + + ApplyPerlinNoise(texture, palette, scale, octaves); + + texture.Apply(); + + byte[] imageData = TextureOps.EncodeTexture(texture, fullPath); + if (imageData == null || imageData.Length == 0) + { + UnityEngine.Object.DestroyImmediate(texture); + return new ErrorResponse($"Failed to encode texture for '{fullPath}'"); + } + File.WriteAllBytes(GetAbsolutePath(fullPath), imageData); + + AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); + + // Configure as sprite if requested + JToken spriteSettingsToken = @params["spriteSettings"]; + if (spriteSettingsToken != null) + { + ConfigureAsSprite(fullPath, spriteSettingsToken); + } + + UnityEngine.Object.DestroyImmediate(texture); + + return new SuccessResponse( + $"Noise texture created at '{fullPath}' ({width}x{height})", + new { path = fullPath, width, height, noiseScale = scale, octaves } + ); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to create noise texture: {e.Message}"); + } + } + + // --- Pattern Helpers --- + + private static void ApplyPatternToTexture(Texture2D texture, string pattern, List palette, int patternSize) + { + if (palette == null || palette.Count == 0) + { + palette = new List { new Color32(255, 255, 255, 255), new Color32(0, 0, 0, 255) }; + } + + int width = texture.width; + int height = texture.height; + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + Color32 color = GetPatternColor(x, y, pattern, palette, patternSize, width, height); + texture.SetPixel(x, y, color); + } + } + } + + private static Color32 GetPatternColor(int x, int y, string pattern, List palette, int size, int width, int height) + { + int colorIndex = 0; + + switch (pattern.ToLower()) + { + case "checkerboard": + colorIndex = ((x / size) + (y / size)) % 2; + break; + + case "stripes": + case "stripes_v": + colorIndex = (x / size) % palette.Count; + break; + + case "stripes_h": + colorIndex = (y / size) % palette.Count; + break; + + case "stripes_diag": + colorIndex = ((x + y) / size) % palette.Count; + break; + + case "dots": + int cx = (x % (size * 2)) - size; + int cy = (y % (size * 2)) - size; + bool inDot = (cx * cx + cy * cy) < (size * size / 4); + colorIndex = inDot ? 1 : 0; + break; + + case "grid": + bool onGridLine = (x % size == 0) || (y % size == 0); + colorIndex = onGridLine ? 1 : 0; + break; + + case "brick": + int row = y / size; + int offset = (row % 2) * (size / 2); + bool onBorder = ((x + offset) % size == 0) || (y % size == 0); + colorIndex = onBorder ? 1 : 0; + break; + + default: + colorIndex = 0; + break; + } + + return palette[Mathf.Clamp(colorIndex, 0, palette.Count - 1)]; + } + + // --- Gradient Helpers --- + + private static void ApplyLinearGradient(Texture2D texture, List palette, float angle) + { + int width = texture.width; + int height = texture.height; + float radians = angle * Mathf.Deg2Rad; + Vector2 dir = new Vector2(Mathf.Cos(radians), Mathf.Sin(radians)); + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + float nx = x / (float)(width - 1); + float ny = y / (float)(height - 1); + float t = Vector2.Dot(new Vector2(nx, ny), dir); + t = Mathf.Clamp01((t + 1f) / 2f); + + Color32 color = LerpPalette(palette, t); + texture.SetPixel(x, y, color); + } + } + } + + private static void ApplyRadialGradient(Texture2D texture, List palette) + { + int width = texture.width; + int height = texture.height; + float cx = width / 2f; + float cy = height / 2f; + float maxDist = Mathf.Sqrt(cx * cx + cy * cy); + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + float dx = x - cx; + float dy = y - cy; + float dist = Mathf.Sqrt(dx * dx + dy * dy); + float t = Mathf.Clamp01(dist / maxDist); + + Color32 color = LerpPalette(palette, t); + texture.SetPixel(x, y, color); + } + } + } + + private static Color32 LerpPalette(List palette, float t) + { + if (palette.Count == 1) return palette[0]; + if (t <= 0) return palette[0]; + if (t >= 1) return palette[palette.Count - 1]; + + float scaledT = t * (palette.Count - 1); + int index = Mathf.FloorToInt(scaledT); + float localT = scaledT - index; + + if (index >= palette.Count - 1) + return palette[palette.Count - 1]; + + Color c1 = palette[index]; + Color c2 = palette[index + 1]; + return Color.Lerp(c1, c2, localT); + } + + // --- Noise Helpers --- + + private static void ApplyPerlinNoise(Texture2D texture, List palette, float scale, int octaves) + { + int width = texture.width; + int height = texture.height; + + // Random offset to ensure different patterns + float offsetX = UnityEngine.Random.Range(0f, 1000f); + float offsetY = UnityEngine.Random.Range(0f, 1000f); + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + float noiseValue = 0f; + float amplitude = 1f; + float frequency = 1f; + float maxValue = 0f; + + for (int o = 0; o < octaves; o++) + { + float sampleX = (x + offsetX) * scale * frequency; + float sampleY = (y + offsetY) * scale * frequency; + noiseValue += Mathf.PerlinNoise(sampleX, sampleY) * amplitude; + maxValue += amplitude; + amplitude *= 0.5f; + frequency *= 2f; + } + + float t = Mathf.Clamp01(noiseValue / maxValue); + Color32 color = LerpPalette(palette, t); + texture.SetPixel(x, y, color); + } + } + } + + private static void ConfigureAsSprite(string path, JToken spriteSettings) + { + TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter; + if (importer == null) + { + McpLog.Warn($"[ManageTexture] Could not get TextureImporter for {path}"); + return; + } + + importer.textureType = TextureImporterType.Sprite; + importer.spriteImportMode = SpriteImportMode.Single; + + if (spriteSettings != null && spriteSettings.Type == JTokenType.Object) + { + var settings = spriteSettings as JObject; + + // Pivot + var pivotToken = settings["pivot"]; + if (pivotToken is JArray pivotArray && pivotArray.Count >= 2) + { + importer.spritePivot = new Vector2( + pivotArray[0].ToObject(), + pivotArray[1].ToObject() + ); + } + + // Pixels per unit + var ppuToken = settings["pixelsPerUnit"]; + if (ppuToken != null) + { + importer.spritePixelsPerUnit = ppuToken.ToObject(); + } + } + + importer.SaveAndReimport(); + } + + private static void ConfigureTextureImporter(string path, JToken importSettings) + { + TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter; + if (importer == null) + { + McpLog.Warn($"[ManageTexture] Could not get TextureImporter for {path}"); + return; + } + + if (importSettings == null || importSettings.Type != JTokenType.Object) + { + return; + } + + var settings = importSettings as JObject; + + // Texture Type + var textureTypeToken = settings["textureType"]; + if (textureTypeToken != null) + { + string typeStr = textureTypeToken.ToString(); + if (TryParseEnum(typeStr, out var textureType)) + { + importer.textureType = textureType; + } + } + + // Texture Shape + var textureShapeToken = settings["textureShape"]; + if (textureShapeToken != null) + { + string shapeStr = textureShapeToken.ToString(); + if (TryParseEnum(shapeStr, out var textureShape)) + { + importer.textureShape = textureShape; + } + } + + // sRGB + var srgbToken = settings["sRGBTexture"]; + if (srgbToken != null) + { + importer.sRGBTexture = srgbToken.ToObject(); + } + + // Alpha Source + var alphaSourceToken = settings["alphaSource"]; + if (alphaSourceToken != null) + { + string alphaStr = alphaSourceToken.ToString(); + if (TryParseEnum(alphaStr, out var alphaSource)) + { + importer.alphaSource = alphaSource; + } + } + + // Alpha Is Transparency + var alphaTransToken = settings["alphaIsTransparency"]; + if (alphaTransToken != null) + { + importer.alphaIsTransparency = alphaTransToken.ToObject(); + } + + // Readable + var readableToken = settings["isReadable"]; + if (readableToken != null) + { + importer.isReadable = readableToken.ToObject(); + } + + // Mipmaps + var mipmapToken = settings["mipmapEnabled"]; + if (mipmapToken != null) + { + importer.mipmapEnabled = mipmapToken.ToObject(); + } + + // Mipmap Filter + var mipmapFilterToken = settings["mipmapFilter"]; + if (mipmapFilterToken != null) + { + string filterStr = mipmapFilterToken.ToString(); + if (TryParseEnum(filterStr, out var mipmapFilter)) + { + importer.mipmapFilter = mipmapFilter; + } + } + + // Wrap Mode + var wrapModeToken = settings["wrapMode"]; + if (wrapModeToken != null) + { + string wrapStr = wrapModeToken.ToString(); + if (TryParseEnum(wrapStr, out var wrapMode)) + { + importer.wrapMode = wrapMode; + } + } + + // Wrap Mode U + var wrapModeUToken = settings["wrapModeU"]; + if (wrapModeUToken != null) + { + string wrapStr = wrapModeUToken.ToString(); + if (TryParseEnum(wrapStr, out var wrapMode)) + { + importer.wrapModeU = wrapMode; + } + } + + // Wrap Mode V + var wrapModeVToken = settings["wrapModeV"]; + if (wrapModeVToken != null) + { + string wrapStr = wrapModeVToken.ToString(); + if (TryParseEnum(wrapStr, out var wrapMode)) + { + importer.wrapModeV = wrapMode; + } + } + + // Filter Mode + var filterModeToken = settings["filterMode"]; + if (filterModeToken != null) + { + string filterStr = filterModeToken.ToString(); + if (TryParseEnum(filterStr, out var filterMode)) + { + importer.filterMode = filterMode; + } + } + + // Aniso Level + var anisoToken = settings["anisoLevel"]; + if (anisoToken != null) + { + importer.anisoLevel = anisoToken.ToObject(); + } + + // Max Texture Size + var maxSizeToken = settings["maxTextureSize"]; + if (maxSizeToken != null) + { + importer.maxTextureSize = maxSizeToken.ToObject(); + } + + // Compression + var compressionToken = settings["textureCompression"]; + if (compressionToken != null) + { + string compStr = compressionToken.ToString(); + if (TryParseEnum(compStr, out var compression)) + { + importer.textureCompression = compression; + } + } + + // Crunched Compression + var crunchedToken = settings["crunchedCompression"]; + if (crunchedToken != null) + { + importer.crunchedCompression = crunchedToken.ToObject(); + } + + // Compression Quality + var qualityToken = settings["compressionQuality"]; + if (qualityToken != null) + { + importer.compressionQuality = qualityToken.ToObject(); + } + + // --- Sprite-specific settings --- + + // Sprite Import Mode + var spriteModeToken = settings["spriteImportMode"]; + if (spriteModeToken != null) + { + string modeStr = spriteModeToken.ToString(); + if (TryParseEnum(modeStr, out var spriteMode)) + { + importer.spriteImportMode = spriteMode; + } + } + + // Sprite Pixels Per Unit + var ppuToken = settings["spritePixelsPerUnit"]; + if (ppuToken != null) + { + importer.spritePixelsPerUnit = ppuToken.ToObject(); + } + + // Sprite Pivot + var pivotToken = settings["spritePivot"]; + if (pivotToken is JArray pivotArray && pivotArray.Count >= 2) + { + importer.spritePivot = new Vector2( + pivotArray[0].ToObject(), + pivotArray[1].ToObject() + ); + } + + // Apply sprite settings using TextureImporterSettings helper + TextureImporterSettings importerSettings = new TextureImporterSettings(); + importer.ReadTextureSettings(importerSettings); + + bool settingsChanged = false; + + // Sprite Mesh Type + var meshTypeToken = settings["spriteMeshType"]; + if (meshTypeToken != null) + { + string meshStr = meshTypeToken.ToString(); + if (TryParseEnum(meshStr, out var meshType)) + { + importerSettings.spriteMeshType = meshType; + settingsChanged = true; + } + } + + // Sprite Extrude + var extrudeToken = settings["spriteExtrude"]; + if (extrudeToken != null) + { + importerSettings.spriteExtrude = (uint)extrudeToken.ToObject(); + settingsChanged = true; + } + + if (settingsChanged) + { + importer.SetTextureSettings(importerSettings); + } + + importer.SaveAndReimport(); + } + + private static bool TryParseEnum(string value, out T result) where T : struct + { + // Try exact match first + if (Enum.TryParse(value, true, out result)) + { + return true; + } + + // Try without common prefixes/suffixes + string cleanValue = value.Replace("_", "").Replace("-", ""); + if (Enum.TryParse(cleanValue, true, out result)) + { + return true; + } + + result = default; + return false; + } + + private static bool AssetExists(string path) + { + return !string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(path)); + } + + private static void EnsureDirectoryExists(string assetPath) + { + string directory = Path.GetDirectoryName(assetPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(GetAbsolutePath(directory))) + { + Directory.CreateDirectory(GetAbsolutePath(directory)); + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + } + } + + private static string GetAbsolutePath(string assetPath) + { + return Path.Combine(Directory.GetCurrentDirectory(), assetPath); + } + } +} diff --git a/MCPForUnity/Editor/Tools/ManageTexture.cs.meta b/MCPForUnity/Editor/Tools/ManageTexture.cs.meta new file mode 100644 index 000000000..2dc1b884d --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageTexture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8028b64102744ea5aad53a762d48079a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/README.md b/README.md index 1c50114e5..8de255858 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ openupm add com.coplaydev.unity-mcp * **Extensible** — Works with various MCP Clients ### Available Tools -`manage_asset` • `manage_editor` • `manage_gameobject` • `manage_components` • `manage_material` • `manage_prefabs` • `manage_scene` • `manage_script` • `manage_scriptable_object` • `manage_shader` • `manage_vfx` • `batch_execute` • `find_gameobjects` • `find_in_file` • `read_console` • `refresh_unity` • `run_tests` • `get_test_job` • `execute_menu_item` • `apply_text_edits` • `script_apply_edits` • `validate_script` • `create_script` • `delete_script` • `get_sha` +`manage_asset` • `manage_editor` • `manage_gameobject` • `manage_components` • `manage_material` • `manage_prefabs` • `manage_scene` • `manage_script` • `manage_scriptable_object` • `manage_shader` • `manage_vfx` • `manage_texture` • `batch_execute` • `find_gameobjects` • `find_in_file` • `read_console` • `refresh_unity` • `run_tests` • `get_test_job` • `execute_menu_item` • `apply_text_edits` • `script_apply_edits` • `validate_script` • `create_script` • `delete_script` • `get_sha` ### Available Resources `custom_tools` • `unity_instances` • `menu_items` • `get_tests` • `gameobject` • `gameobject_components` • `editor_state` • `editor_selection` • `editor_prefab_stage` • `project_info` • `project_tags` • `project_layers` diff --git a/Server/src/cli/commands/texture.py b/Server/src/cli/commands/texture.py new file mode 100644 index 000000000..54a7f73c0 --- /dev/null +++ b/Server/src/cli/commands/texture.py @@ -0,0 +1,445 @@ +"""Texture CLI commands.""" + +import sys +import json +import click +from typing import Optional, Any + +from cli.utils.config import get_config +from cli.utils.output import format_output, print_error, print_success +from cli.utils.connection import run_command, UnityConnectionError + + +def try_parse_json(value: str, context: str) -> Any: + """Try to parse JSON, with fallback for single quotes and Python bools.""" + try: + return json.loads(value) + except json.JSONDecodeError: + # Try to fix common shell quoting issues (single quotes, Python bools) + try: + fixed = value.replace("'", '"').replace("True", "true").replace("False", "false") + return json.loads(fixed) + except json.JSONDecodeError as e: + print_error(f"Invalid JSON for {context}: {e}") + sys.exit(1) + + +_TEXTURE_TYPES = { + "default": "Default", + "normal_map": "NormalMap", + "editor_gui": "GUI", + "sprite": "Sprite", + "cursor": "Cursor", + "cookie": "Cookie", + "lightmap": "Lightmap", + "directional_lightmap": "DirectionalLightmap", + "shadow_mask": "Shadowmask", + "single_channel": "SingleChannel", +} + +_TEXTURE_SHAPES = {"2d": "Texture2D", "cube": "TextureCube"} + +_ALPHA_SOURCES = { + "none": "None", + "from_input": "FromInput", + "from_gray_scale": "FromGrayScale", +} + +_WRAP_MODES = { + "repeat": "Repeat", + "clamp": "Clamp", + "mirror": "Mirror", + "mirror_once": "MirrorOnce", +} + +_FILTER_MODES = {"point": "Point", "bilinear": "Bilinear", "trilinear": "Trilinear"} + +_COMPRESSIONS = { + "none": "Uncompressed", + "low_quality": "CompressedLQ", + "normal_quality": "Compressed", + "high_quality": "CompressedHQ", +} + +_SPRITE_MODES = {"single": "Single", "multiple": "Multiple", "polygon": "Polygon"} + +_SPRITE_MESH_TYPES = {"full_rect": "FullRect", "tight": "Tight"} + +_MIPMAP_FILTERS = {"box": "BoxFilter", "kaiser": "KaiserFilter"} + + +def _is_normalized_color(values: list[Any]) -> bool: + if not values: + return False + + all_small = all(0 <= v <= 1.0 for v in values) + if not all_small: + return False + + has_float = any(isinstance(v, float) for v in values) + has_fractional = any(0 < v < 1 for v in values) + all_binary = all(v in (0, 1, 0.0, 1.0) for v in values) + + return has_float or has_fractional or all_binary + + +def _parse_hex_color(value: str) -> list[int]: + h = value.lstrip("#") + if len(h) == 6: + return [int(h[i:i + 2], 16) for i in (0, 2, 4)] + [255] + if len(h) == 8: + return [int(h[i:i + 2], 16) for i in (0, 2, 4, 6)] + raise ValueError(f"Invalid hex color: {value}") + + +def _normalize_color(value: Any, context: str) -> list[int]: + if value is None: + raise ValueError(f"{context} is required") + + if isinstance(value, str): + if value.startswith("#"): + return _parse_hex_color(value) + value = try_parse_json(value, context) + + if isinstance(value, (list, tuple)): + if len(value) == 3: + value = list(value) + [1.0 if _is_normalized_color(value) else 255] + if len(value) == 4: + if _is_normalized_color(value): + return [int(round(float(c) * 255)) for c in value] + return [int(c) for c in value] + raise ValueError(f"{context} must have 3 or 4 components, got {len(value)}") + + raise ValueError(f"{context} must be a list or hex string") + + +def _normalize_palette(value: Any, context: str) -> list[list[int]]: + if value is None: + return [] + if isinstance(value, str): + value = try_parse_json(value, context) + if not isinstance(value, list): + raise ValueError(f"{context} must be a list of colors") + return [_normalize_color(color, f"{context} item") for color in value] + + +def _normalize_pixels(value: Any, width: int, height: int, context: str) -> list[list[int]] | str: + if value is None: + raise ValueError(f"{context} is required") + if isinstance(value, str): + if value.startswith("base64:"): + return value + trimmed = value.strip() + if trimmed.startswith("[") and trimmed.endswith("]"): + value = try_parse_json(trimmed, context) + else: + return f"base64:{value}" + if isinstance(value, list): + expected_count = width * height + if len(value) != expected_count: + raise ValueError(f"{context} must have {expected_count} entries, got {len(value)}") + return [_normalize_color(pixel, f"{context} pixel") for pixel in value] + raise ValueError(f"{context} must be a list or base64 string") + + +def _normalize_set_pixels(value: Any) -> dict[str, Any]: + if value is None: + raise ValueError("set-pixels is required") + if isinstance(value, str): + value = try_parse_json(value, "set-pixels") + if not isinstance(value, dict): + raise ValueError("set-pixels must be a JSON object") + + result: dict[str, Any] = dict(value) + + if "pixels" in value: + width = value.get("width") + height = value.get("height") + if width is None or height is None: + raise ValueError("set-pixels requires width and height when pixels are provided") + width = int(width) + height = int(height) + if width <= 0 or height <= 0: + raise ValueError("set-pixels width and height must be positive") + result["width"] = width + result["height"] = height + result["pixels"] = _normalize_pixels(value["pixels"], width, height, "set-pixels pixels") + + if "color" in value: + result["color"] = _normalize_color(value["color"], "set-pixels color") + + if "pixels" not in value and "color" not in value: + raise ValueError("set-pixels requires 'color' or 'pixels'") + + if "x" in value: + result["x"] = int(value["x"]) + if "y" in value: + result["y"] = int(value["y"]) + + if "width" in value and "pixels" not in value: + result["width"] = int(value["width"]) + if "height" in value and "pixels" not in value: + result["height"] = int(value["height"]) + + return result + + +def _map_enum(value: Any, mapping: dict[str, str]) -> Any: + if isinstance(value, str): + key = value.lower() + return mapping.get(key, value) + return value + + +def _normalize_import_settings(value: Any) -> dict[str, Any]: + if value is None: + return {} + if isinstance(value, str): + value = try_parse_json(value, "import_settings") + if not isinstance(value, dict): + raise ValueError("import_settings must be a JSON object") + + result: dict[str, Any] = {} + + if "texture_type" in value: + result["textureType"] = _map_enum(value["texture_type"], _TEXTURE_TYPES) + if "texture_shape" in value: + result["textureShape"] = _map_enum(value["texture_shape"], _TEXTURE_SHAPES) + + for snake, camel in [ + ("srgb", "sRGBTexture"), + ("alpha_is_transparency", "alphaIsTransparency"), + ("readable", "isReadable"), + ("generate_mipmaps", "mipmapEnabled"), + ("compression_crunched", "crunchedCompression"), + ]: + if snake in value: + result[camel] = bool(value[snake]) + + if "alpha_source" in value: + result["alphaSource"] = _map_enum(value["alpha_source"], _ALPHA_SOURCES) + + for snake, camel in [("wrap_mode", "wrapMode"), ("wrap_mode_u", "wrapModeU"), ("wrap_mode_v", "wrapModeV")]: + if snake in value: + result[camel] = _map_enum(value[snake], _WRAP_MODES) + + if "filter_mode" in value: + result["filterMode"] = _map_enum(value["filter_mode"], _FILTER_MODES) + if "mipmap_filter" in value: + result["mipmapFilter"] = _map_enum(value["mipmap_filter"], _MIPMAP_FILTERS) + if "compression" in value: + result["textureCompression"] = _map_enum(value["compression"], _COMPRESSIONS) + + if "aniso_level" in value: + result["anisoLevel"] = int(value["aniso_level"]) + if "max_texture_size" in value: + result["maxTextureSize"] = int(value["max_texture_size"]) + if "compression_quality" in value: + result["compressionQuality"] = int(value["compression_quality"]) + + if "sprite_mode" in value: + result["spriteImportMode"] = _map_enum(value["sprite_mode"], _SPRITE_MODES) + if "sprite_pixels_per_unit" in value: + result["spritePixelsPerUnit"] = float(value["sprite_pixels_per_unit"]) + if "sprite_pivot" in value: + result["spritePivot"] = value["sprite_pivot"] + if "sprite_mesh_type" in value: + result["spriteMeshType"] = _map_enum(value["sprite_mesh_type"], _SPRITE_MESH_TYPES) + if "sprite_extrude" in value: + result["spriteExtrude"] = int(value["sprite_extrude"]) + + for key, val in value.items(): + if key in result: + continue + if key in ( + "textureType", "textureShape", "sRGBTexture", "alphaSource", + "alphaIsTransparency", "isReadable", "mipmapEnabled", "wrapMode", + "wrapModeU", "wrapModeV", "filterMode", "mipmapFilter", "anisoLevel", + "maxTextureSize", "textureCompression", "crunchedCompression", + "compressionQuality", "spriteImportMode", "spritePixelsPerUnit", + "spritePivot", "spriteMeshType", "spriteExtrude", + ): + result[key] = val + + return result + +@click.group() +def texture(): + """Texture operations - create, modify, generate sprites.""" + pass + + +@texture.command("create") +@click.argument("path") +@click.option("--width", default=64, help="Texture width (default: 64)") +@click.option("--height", default=64, help="Texture height (default: 64)") +@click.option("--color", help="Fill color (e.g., '#FF0000' or '[1,0,0,1]')") +@click.option("--pattern", type=click.Choice([ + "checkerboard", "stripes", "stripes_h", "stripes_v", "stripes_diag", + "dots", "grid", "brick" +]), help="Pattern type") +@click.option("--palette", help="Color palette for pattern (JSON array of colors)") +@click.option("--import-settings", help="TextureImporter settings (JSON)") +def create(path: str, width: int, height: int, color: Optional[str], + pattern: Optional[str], palette: Optional[str], import_settings: Optional[str]): + """Create a new procedural texture. + + \b + Examples: + unity-mcp texture create Assets/Red.png --color '[255,0,0]' + unity-mcp texture create Assets/Check.png --pattern checkerboard + unity-mcp texture create Assets/UI.png --import-settings '{"texture_type": "sprite"}' + """ + config = get_config() + + params: dict[str, Any] = { + "action": "create", + "path": path, + "width": width, + "height": height, + } + + if color: + try: + params["fillColor"] = _normalize_color(color, "color") + except ValueError as e: + print_error(str(e)) + sys.exit(1) + elif not pattern: + # Default to white if no color or pattern specified + params["fillColor"] = [255, 255, 255, 255] + + if pattern: + params["pattern"] = pattern + + if palette: + try: + params["palette"] = _normalize_palette(palette, "palette") + except ValueError as e: + print_error(str(e)) + sys.exit(1) + + if import_settings: + try: + params["importSettings"] = _normalize_import_settings(import_settings) + except ValueError as e: + print_error(str(e)) + sys.exit(1) + + try: + result = run_command("manage_texture", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Created texture: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@texture.command("sprite") +@click.argument("path") +@click.option("--width", default=64, help="Texture width (default: 64)") +@click.option("--height", default=64, help="Texture height (default: 64)") +@click.option("--color", help="Fill color (e.g., '#FF0000' or '[1,0,0,1]')") +@click.option("--pattern", type=click.Choice([ + "checkerboard", "stripes", "dots", "grid" +]), help="Pattern type (defaults to checkerboard if no color specified)") +@click.option("--ppu", default=100.0, help="Pixels Per Unit") +@click.option("--pivot", help="Pivot as [x,y] (default: [0.5, 0.5])") +def sprite(path: str, width: int, height: int, color: Optional[str], pattern: Optional[str], ppu: float, pivot: Optional[str]): + """Quickly create a sprite texture. + + \b + Examples: + unity-mcp texture sprite Assets/Sprites/Player.png + unity-mcp texture sprite Assets/Sprites/Coin.png --pattern dots + unity-mcp texture sprite Assets/Sprites/Solid.png --color '[0,255,0]' + """ + config = get_config() + + sprite_settings: dict[str, Any] = {"pixelsPerUnit": ppu} + if pivot: + sprite_settings["pivot"] = try_parse_json(pivot, "pivot") + else: + sprite_settings["pivot"] = [0.5, 0.5] + + params: dict[str, Any] = { + "action": "create_sprite", + "path": path, + "width": width, + "height": height, + "spriteSettings": sprite_settings + } + + if color: + try: + params["fillColor"] = _normalize_color(color, "color") + except ValueError as e: + print_error(str(e)) + sys.exit(1) + + # Only default pattern if no color is specified + if pattern: + params["pattern"] = pattern + elif not color: + params["pattern"] = "checkerboard" + + try: + result = run_command("manage_texture", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Created sprite: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@texture.command("modify") +@click.argument("path") +@click.option("--set-pixels", required=True, help="Modification args as JSON") +def modify(path: str, set_pixels: str): + """Modify an existing texture. + + \b + Examples: + unity-mcp texture modify Assets/Tex.png --set-pixels '{"x":0,"y":0,"width":10,"height":10,"color":[255,0,0]}' + unity-mcp texture modify Assets/Tex.png --set-pixels '{"x":0,"y":0,"width":2,"height":2,"pixels":[[255,0,0,255],[0,255,0,255],[0,0,255,255],[255,255,0,255]]}' + """ + config = get_config() + + params: dict[str, Any] = { + "action": "modify", + "path": path, + } + + try: + params["setPixels"] = _normalize_set_pixels(set_pixels) + except ValueError as e: + print_error(str(e)) + sys.exit(1) + + try: + result = run_command("manage_texture", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Modified texture: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) + + +@texture.command("delete") +@click.argument("path") +def delete(path: str): + """Delete a texture. + """ + config = get_config() + + try: + result = run_command("manage_texture", {"action": "delete", "path": path}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success(f"Deleted texture: {path}") + except UnityConnectionError as e: + print_error(str(e)) + sys.exit(1) diff --git a/Server/src/cli/main.py b/Server/src/cli/main.py index 678d3abde..06e517e29 100644 --- a/Server/src/cli/main.py +++ b/Server/src/cli/main.py @@ -229,6 +229,7 @@ def register_optional_command(module_name: str, command_name: str) -> None: ("cli.commands.shader", "shader"), ("cli.commands.vfx", "vfx"), ("cli.commands.batch", "batch"), + ("cli.commands.texture", "texture"), ] for module_name, command_name in optional_commands: diff --git a/Server/src/services/tools/manage_texture.py b/Server/src/services/tools/manage_texture.py new file mode 100644 index 000000000..5b24181c9 --- /dev/null +++ b/Server/src/services/tools/manage_texture.py @@ -0,0 +1,619 @@ +""" +Defines the manage_texture tool for procedural texture generation in Unity. +""" +import base64 +import json +from typing import Annotated, Any, Literal + +from fastmcp import Context +from mcp.types import ToolAnnotations + +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, coerce_bool, coerce_int +from transport.unity_transport import send_with_unity_instance +from transport.legacy.unity_connection import async_send_command_with_retry +from services.tools.preflight import preflight + + +def _is_normalized_color(values: list) -> bool: + """ + Check if color values appear to be in normalized 0.0-1.0 range. + Returns True if all values are <= 1.0 and at least one is a float or between 0-1 exclusive. + """ + if not values: + return False + + # Check if all values are <= 1.0 + all_small = all(0 <= v <= 1.0 for v in values) + if not all_small: + return False + + # If any value is a float (not an integer), it's likely normalized + has_float = any(isinstance(v, float) for v in values) + + # If any non-zero value is less than 1, it's likely normalized (e.g., 0.5) + has_fractional = any(0 < v < 1 for v in values) + + # If all values are 0 or 1, and they're all integers, could be either format + # In this ambiguous case (like [1, 0, 0, 1]), assume normalized since that's + # what graphics programmers typically use + all_binary = all(v in (0, 1, 0.0, 1.0) for v in values) + + return has_float or has_fractional or all_binary + + +def _normalize_color(value: Any) -> tuple[list[int] | None, str | None]: + """ + Normalize color parameter to [r, g, b, a] format (0-255). + Auto-detects normalized float colors (0.0-1.0) and converts to 0-255. + Returns (parsed_color, error_message). + """ + if value is None: + return None, None + + # Already a list - validate + if isinstance(value, (list, tuple)): + if len(value) == 3: + value = list(value) + [1.0 if _is_normalized_color(value) else 255] + if len(value) == 4: + try: + # Check if values appear to be normalized (0.0-1.0 range) + if _is_normalized_color(value): + # Convert from 0.0-1.0 to 0-255 + return [int(round(float(c) * 255)) for c in value], None + else: + # Already in 0-255 range + return [int(c) for c in value], None + except (ValueError, TypeError): + return None, f"color values must be numeric, got {value}" + return None, f"color must have 3 or 4 components, got {len(value)}" + + # Try parsing as string + if isinstance(value, str): + if value in ("[object Object]", "undefined", "null", ""): + return None, f"color received invalid value: '{value}'. Expected [r, g, b] or [r, g, b, a]" + + # Handle Hex Colors + if value.startswith("#"): + h = value.lstrip("#") + try: + if len(h) == 6: + return [int(h[i:i+2], 16) for i in (0, 2, 4)] + [255], None + elif len(h) == 8: + return [int(h[i:i+2], 16) for i in (0, 2, 4, 6)], None + except ValueError: + return None, f"Invalid hex color: {value}" + + parsed = parse_json_payload(value) + if isinstance(parsed, (list, tuple)): + if len(parsed) == 3: + parsed = list(parsed) + [1.0 if _is_normalized_color(parsed) else 255] + if len(parsed) == 4: + try: + # Check if values appear to be normalized (0.0-1.0 range) + if _is_normalized_color(parsed): + # Convert from 0.0-1.0 to 0-255 + return [int(round(float(c) * 255)) for c in parsed], None + else: + # Already in 0-255 range + return [int(c) for c in parsed], None + except (ValueError, TypeError): + return None, f"color values must be numeric, got {parsed}" + return None, f"Failed to parse color string: {value}" + + return None, f"color must be a list or JSON string, got {type(value).__name__}" + + +def _normalize_palette(value: Any) -> tuple[list[list[int]] | None, str | None]: + """ + Normalize color palette to list of [r, g, b, a] colors (0-255). + Returns (parsed_palette, error_message). + """ + if value is None: + return None, None + + # Try parsing as string first + if isinstance(value, str): + if value in ("[object Object]", "undefined", "null", ""): + return None, f"palette received invalid value: '{value}'" + value = parse_json_payload(value) + + if not isinstance(value, list): + return None, f"palette must be a list of colors, got {type(value).__name__}" + + normalized = [] + for i, color in enumerate(value): + parsed, error = _normalize_color(color) + if error: + return None, f"palette[{i}]: {error}" + normalized.append(parsed) + + return normalized, None + + +def _normalize_pixels(value: Any, width: int, height: int) -> tuple[list[list[int]] | str | None, str | None]: + """ + Normalize pixel data to list of [r, g, b, a] colors or base64 string. + Returns (pixels, error_message). + """ + if value is None: + return None, None + + # Base64 string + if isinstance(value, str): + if value.startswith("base64:"): + return value, None # Pass through for Unity to decode + # Try parsing as JSON array + parsed = parse_json_payload(value) + if isinstance(parsed, list): + value = parsed + else: + # Assume it's raw base64 + return f"base64:{value}", None + + if isinstance(value, list): + expected_count = width * height + if len(value) != expected_count: + return None, f"pixels array must have {expected_count} entries for {width}x{height} texture, got {len(value)}" + + normalized = [] + for i, pixel in enumerate(value): + parsed, error = _normalize_color(pixel) + if error: + return None, f"pixels[{i}]: {error}" + normalized.append(parsed) + return normalized, None + + return None, f"pixels must be a list or base64 string, got {type(value).__name__}" + + +def _normalize_sprite_settings(value: Any) -> tuple[dict | None, str | None]: + """ + Normalize sprite settings. + Returns (settings, error_message). + """ + if value is None: + return None, None + + if isinstance(value, str): + value = parse_json_payload(value) + + if isinstance(value, dict): + result = {} + if "pivot" in value: + pivot = value["pivot"] + if isinstance(pivot, (list, tuple)) and len(pivot) == 2: + result["pivot"] = [float(pivot[0]), float(pivot[1])] + else: + return None, f"sprite pivot must be [x, y], got {pivot}" + if "pixels_per_unit" in value: + result["pixelsPerUnit"] = float(value["pixels_per_unit"]) + elif "pixelsPerUnit" in value: + result["pixelsPerUnit"] = float(value["pixelsPerUnit"]) + return result, None + + if isinstance(value, bool) and value: + # Just enable sprite mode with defaults + return {"pivot": [0.5, 0.5], "pixelsPerUnit": 100}, None + + return None, f"as_sprite must be a dict or boolean, got {type(value).__name__}" + + +# Valid values for import settings enums +_TEXTURE_TYPES = { + "default": "Default", + "normal_map": "NormalMap", + "editor_gui": "GUI", + "sprite": "Sprite", + "cursor": "Cursor", + "cookie": "Cookie", + "lightmap": "Lightmap", + "directional_lightmap": "DirectionalLightmap", + "shadow_mask": "Shadowmask", + "single_channel": "SingleChannel", +} + +_TEXTURE_SHAPES = {"2d": "Texture2D", "cube": "TextureCube"} + +_ALPHA_SOURCES = { + "none": "None", + "from_input": "FromInput", + "from_gray_scale": "FromGrayScale", +} + +_WRAP_MODES = { + "repeat": "Repeat", + "clamp": "Clamp", + "mirror": "Mirror", + "mirror_once": "MirrorOnce", +} + +_FILTER_MODES = {"point": "Point", "bilinear": "Bilinear", "trilinear": "Trilinear"} + +_COMPRESSIONS = { + "none": "Uncompressed", + "low_quality": "CompressedLQ", + "normal_quality": "Compressed", + "high_quality": "CompressedHQ", +} + +_SPRITE_MODES = {"single": "Single", "multiple": "Multiple", "polygon": "Polygon"} + +_SPRITE_MESH_TYPES = {"full_rect": "FullRect", "tight": "Tight"} + +_MIPMAP_FILTERS = {"box": "BoxFilter", "kaiser": "KaiserFilter"} + + +def _normalize_bool_setting(value: Any, name: str) -> tuple[bool | None, str | None]: + """ + Normalize boolean settings. + Returns (bool_value, error_message). + """ + if value is None: + return None, None + + if isinstance(value, bool): + return value, None + + if isinstance(value, (int, float)): + if value in (0, 1, 0.0, 1.0): + return bool(value), None + return None, f"{name} must be a boolean" + + if isinstance(value, str): + coerced = coerce_bool(value, default=None) + if coerced is None: + return None, f"{name} must be a boolean" + return coerced, None + + return None, f"{name} must be a boolean" + + +def _normalize_import_settings(value: Any) -> tuple[dict | None, str | None]: + """ + Normalize TextureImporter settings. + Converts snake_case keys to camelCase and validates enum values. + Returns (settings, error_message). + """ + if value is None: + return None, None + + if isinstance(value, str): + value = parse_json_payload(value) + + if not isinstance(value, dict): + return None, f"import_settings must be a dict, got {type(value).__name__}" + + result = {} + + # Texture type + if "texture_type" in value: + tt = value["texture_type"].lower() if isinstance(value["texture_type"], str) else value["texture_type"] + if tt not in _TEXTURE_TYPES: + return None, f"Invalid texture_type '{tt}'. Valid: {list(_TEXTURE_TYPES.keys())}" + result["textureType"] = _TEXTURE_TYPES[tt] + + # Texture shape + if "texture_shape" in value: + ts = value["texture_shape"].lower() if isinstance(value["texture_shape"], str) else value["texture_shape"] + if ts not in _TEXTURE_SHAPES: + return None, f"Invalid texture_shape '{ts}'. Valid: {list(_TEXTURE_SHAPES.keys())}" + result["textureShape"] = _TEXTURE_SHAPES[ts] + + # Boolean settings + for snake, camel in [ + ("srgb", "sRGBTexture"), + ("alpha_is_transparency", "alphaIsTransparency"), + ("readable", "isReadable"), + ("generate_mipmaps", "mipmapEnabled"), + ("compression_crunched", "crunchedCompression"), + ]: + if snake in value: + bool_value, bool_error = _normalize_bool_setting(value[snake], snake) + if bool_error: + return None, bool_error + if bool_value is not None: + result[camel] = bool_value + + # Alpha source + if "alpha_source" in value: + alpha = value["alpha_source"].lower() if isinstance(value["alpha_source"], str) else value["alpha_source"] + if alpha not in _ALPHA_SOURCES: + return None, f"Invalid alpha_source '{alpha}'. Valid: {list(_ALPHA_SOURCES.keys())}" + result["alphaSource"] = _ALPHA_SOURCES[alpha] + + # Wrap modes + for snake, camel in [("wrap_mode", "wrapMode"), ("wrap_mode_u", "wrapModeU"), ("wrap_mode_v", "wrapModeV")]: + if snake in value: + wm = value[snake].lower() if isinstance(value[snake], str) else value[snake] + if wm not in _WRAP_MODES: + return None, f"Invalid {snake} '{wm}'. Valid: {list(_WRAP_MODES.keys())}" + result[camel] = _WRAP_MODES[wm] + + # Filter mode + if "filter_mode" in value: + fm = value["filter_mode"].lower() if isinstance(value["filter_mode"], str) else value["filter_mode"] + if fm not in _FILTER_MODES: + return None, f"Invalid filter_mode '{fm}'. Valid: {list(_FILTER_MODES.keys())}" + result["filterMode"] = _FILTER_MODES[fm] + + # Mipmap filter + if "mipmap_filter" in value: + mf = value["mipmap_filter"].lower() if isinstance(value["mipmap_filter"], str) else value["mipmap_filter"] + if mf not in _MIPMAP_FILTERS: + return None, f"Invalid mipmap_filter '{mf}'. Valid: {list(_MIPMAP_FILTERS.keys())}" + result["mipmapFilter"] = _MIPMAP_FILTERS[mf] + + # Compression + if "compression" in value: + comp = value["compression"].lower() if isinstance(value["compression"], str) else value["compression"] + if comp not in _COMPRESSIONS: + return None, f"Invalid compression '{comp}'. Valid: {list(_COMPRESSIONS.keys())}" + result["textureCompression"] = _COMPRESSIONS[comp] + + # Integer settings + if "aniso_level" in value: + raw = value["aniso_level"] + level = coerce_int(raw) + if level is None: + if raw is not None: + return None, f"aniso_level must be an integer, got {raw}" + else: + if not 0 <= level <= 16: + return None, f"aniso_level must be 0-16, got {level}" + result["anisoLevel"] = level + + if "max_texture_size" in value: + raw = value["max_texture_size"] + size = coerce_int(raw) + if size is None: + if raw is not None: + return None, f"max_texture_size must be an integer, got {raw}" + else: + valid_sizes = [32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384] + if size not in valid_sizes: + return None, f"max_texture_size must be one of {valid_sizes}, got {size}" + result["maxTextureSize"] = size + + if "compression_quality" in value: + raw = value["compression_quality"] + quality = coerce_int(raw) + if quality is None: + if raw is not None: + return None, f"compression_quality must be an integer, got {raw}" + else: + if not 0 <= quality <= 100: + return None, f"compression_quality must be 0-100, got {quality}" + result["compressionQuality"] = quality + + # Sprite-specific settings + if "sprite_mode" in value: + sm = value["sprite_mode"].lower() if isinstance(value["sprite_mode"], str) else value["sprite_mode"] + if sm not in _SPRITE_MODES: + return None, f"Invalid sprite_mode '{sm}'. Valid: {list(_SPRITE_MODES.keys())}" + result["spriteImportMode"] = _SPRITE_MODES[sm] + + if "sprite_pixels_per_unit" in value: + raw = value["sprite_pixels_per_unit"] + try: + result["spritePixelsPerUnit"] = float(raw) + except (TypeError, ValueError): + return None, f"sprite_pixels_per_unit must be a number, got {raw}" + + if "sprite_pivot" in value: + pivot = value["sprite_pivot"] + if isinstance(pivot, (list, tuple)) and len(pivot) == 2: + result["spritePivot"] = [float(pivot[0]), float(pivot[1])] + else: + return None, f"sprite_pivot must be [x, y], got {pivot}" + + if "sprite_mesh_type" in value: + mt = value["sprite_mesh_type"].lower() if isinstance(value["sprite_mesh_type"], str) else value["sprite_mesh_type"] + if mt not in _SPRITE_MESH_TYPES: + return None, f"Invalid sprite_mesh_type '{mt}'. Valid: {list(_SPRITE_MESH_TYPES.keys())}" + result["spriteMeshType"] = _SPRITE_MESH_TYPES[mt] + + if "sprite_extrude" in value: + raw = value["sprite_extrude"] + extrude = coerce_int(raw) + if extrude is None: + if raw is not None: + return None, f"sprite_extrude must be an integer, got {raw}" + else: + if not 0 <= extrude <= 32: + return None, f"sprite_extrude must be 0-32, got {extrude}" + result["spriteExtrude"] = extrude + + return result, None + + +@mcp_for_unity_tool( + description=( + "Procedural texture generation for Unity. Creates textures with solid fills, " + "patterns (checkerboard, stripes, dots, grid, brick), gradients, and noise. " + "Supports full CRUD operations and one-call sprite creation.\n\n" + "Actions: create, modify, delete, create_sprite, apply_pattern, apply_gradient, apply_noise" + ), + annotations=ToolAnnotations( + title="Manage Texture", + destructiveHint=True, + ), +) +async def manage_texture( + ctx: Context, + action: Annotated[Literal[ + "create", + "modify", + "delete", + "create_sprite", + "apply_pattern", + "apply_gradient", + "apply_noise" + ], "Action to perform."], + + # Required for most actions + path: Annotated[str, + "Output texture path (e.g., 'Assets/Textures/MyTexture.png')"] | None = None, + + # Dimensions (defaults to 64x64) + width: Annotated[int, "Texture width in pixels (default: 64)"] | None = None, + height: Annotated[int, "Texture height in pixels (default: 64)"] | None = None, + + # Solid fill (accepts both 0-255 integers and 0.0-1.0 normalized floats) + fill_color: Annotated[list[int | float], + "Fill color as [r, g, b] or [r, g, b, a]. Accepts both 0-255 range (e.g., [255, 0, 0]) or 0.0-1.0 normalized range (e.g., [1.0, 0, 0])"] | None = None, + + # Pattern-based generation + pattern: Annotated[Literal[ + "checkerboard", "stripes", "stripes_h", "stripes_v", "stripes_diag", + "dots", "grid", "brick" + ], "Pattern type for apply_pattern action"] | None = None, + + palette: Annotated[list[list[int | float]], + "Color palette as [[r,g,b,a], ...]. Accepts both 0-255 range or 0.0-1.0 normalized range"] | None = None, + + pattern_size: Annotated[int, + "Pattern cell size in pixels (default: 8)"] | None = None, + + # Direct pixel data + pixels: Annotated[list[list[int]] | str, + "Pixel data as JSON array of [r,g,b,a] values or base64 string"] | None = None, + + # Gradient settings + gradient_type: Annotated[Literal["linear", "radial"], + "Gradient type (default: linear)"] | None = None, + gradient_angle: Annotated[float, + "Gradient angle in degrees for linear gradient (default: 0)"] | None = None, + + # Noise settings + noise_scale: Annotated[float, + "Noise scale/frequency (default: 0.1)"] | None = None, + octaves: Annotated[int, + "Number of noise octaves for detail (default: 1)"] | None = None, + + # Modify action + set_pixels: Annotated[dict, + "Region to modify: {x, y, width, height, color or pixels}"] | None = None, + + # Sprite creation (legacy, prefer import_settings) + as_sprite: Annotated[dict | bool, + "Configure as sprite: {pivot: [x,y], pixels_per_unit: 100} or true for defaults"] | None = None, + + # TextureImporter settings + import_settings: Annotated[dict, + "TextureImporter settings dict. Keys: texture_type (default/normal_map/sprite/etc), " + "texture_shape (2d/cube), srgb (bool), alpha_source (none/from_input/from_gray_scale), " + "alpha_is_transparency (bool), readable (bool), generate_mipmaps (bool), " + "wrap_mode/wrap_mode_u/wrap_mode_v (repeat/clamp/mirror/mirror_once), " + "filter_mode (point/bilinear/trilinear), aniso_level (0-16), max_texture_size (32-16384), " + "compression (none/low_quality/normal_quality/high_quality), compression_quality (0-100), " + "sprite_mode (single/multiple/polygon), sprite_pixels_per_unit, sprite_pivot, " + "sprite_mesh_type (full_rect/tight), sprite_extrude (0-32)"] | None = None, + +) -> dict[str, Any]: + unity_instance = get_unity_instance_from_context(ctx) + + # Preflight check + gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True) + if gate is not None: + return gate.model_dump() + + # --- Normalize parameters --- + fill_color, fill_error = _normalize_color(fill_color) + if fill_error: + return {"success": False, "message": fill_error} + + # Default to white for create action if nothing else specified + if action == "create" and fill_color is None and pattern is None and pixels is None: + fill_color = [255, 255, 255, 255] + + palette, palette_error = _normalize_palette(palette) + if palette_error: + return {"success": False, "message": palette_error} + + # Normalize dimensions + width = coerce_int(width) or 64 + height = coerce_int(height) or 64 + + # Normalize pixels if provided + pixels_normalized = None + if pixels is not None: + pixels_normalized, pixels_error = _normalize_pixels(pixels, width, height) + if pixels_error: + return {"success": False, "message": pixels_error} + + # Normalize sprite settings + sprite_settings, sprite_error = _normalize_sprite_settings(as_sprite) + if sprite_error: + return {"success": False, "message": sprite_error} + + # Normalize import settings + import_settings_normalized, import_error = _normalize_import_settings(import_settings) + if import_error: + return {"success": False, "message": import_error} + + # Normalize set_pixels for modify action + set_pixels_normalized = None + if set_pixels is not None: + if isinstance(set_pixels, str): + parsed = parse_json_payload(set_pixels) + if not isinstance(parsed, dict): + return {"success": False, "message": "set_pixels must be a JSON object"} + set_pixels = parsed + if not isinstance(set_pixels, dict): + return {"success": False, "message": "set_pixels must be a JSON object"} + + set_pixels_normalized = set_pixels.copy() + if "color" in set_pixels_normalized: + color, error = _normalize_color(set_pixels_normalized["color"]) + if error: + return {"success": False, "message": f"set_pixels.color: {error}"} + set_pixels_normalized["color"] = color + if "pixels" in set_pixels_normalized: + region_width = coerce_int(set_pixels_normalized.get("width")) or 1 + region_height = coerce_int(set_pixels_normalized.get("height")) or 1 + if region_width <= 0 or region_height <= 0: + return {"success": False, "message": "set_pixels width and height must be positive integers"} + pixels_normalized, pixels_error = _normalize_pixels( + set_pixels_normalized["pixels"], region_width, region_height + ) + if pixels_error: + return {"success": False, "message": f"set_pixels.pixels: {pixels_error}"} + set_pixels_normalized["pixels"] = pixels_normalized + + # --- Build params for Unity --- + params_dict = { + "action": action.lower(), + "path": path, + "width": width, + "height": height, + "fillColor": fill_color, + "pattern": pattern, + "palette": palette, + "patternSize": coerce_int(pattern_size), + "pixels": pixels_normalized, + "gradientType": gradient_type, + "gradientAngle": gradient_angle, + "noiseScale": noise_scale, + "octaves": coerce_int(octaves), + "setPixels": set_pixels_normalized, + "spriteSettings": sprite_settings, + "importSettings": import_settings_normalized, + } + + # Remove None values + params_dict = {k: v for k, v in params_dict.items() if v is not None} + + # Send to Unity + result = await send_with_unity_instance( + async_send_command_with_retry, + unity_instance, + "manage_texture", + params_dict, + ) + + if isinstance(result, dict): + result["_debug_params"] = params_dict + + return result if isinstance(result, dict) else {"success": False, "message": str(result)} diff --git a/Server/tests/integration/test_manage_texture.py b/Server/tests/integration/test_manage_texture.py new file mode 100644 index 000000000..4ed165e13 --- /dev/null +++ b/Server/tests/integration/test_manage_texture.py @@ -0,0 +1,276 @@ +"""Integration tests for manage_texture tool.""" + +import pytest +import asyncio +from .test_helpers import DummyContext +import services.tools.manage_texture as manage_texture_mod + +def run_async(coro): + """Simple wrapper to run a coroutine synchronously.""" + loop = asyncio.new_event_loop() + try: + asyncio.set_event_loop(loop) + return loop.run_until_complete(coro) + finally: + loop.close() + asyncio.set_event_loop(None) + +async def noop_preflight(*args, **kwargs): + return None + +class TestManageTextureIntegration: + """Integration tests for texture management tool logic.""" + + def test_create_texture_with_color_array(self, monkeypatch): + """Test creating a texture with RGB color array (0-255).""" + captured = {} + + async def fake_send(func, instance, cmd, params, **kwargs): + captured["cmd"] = cmd + captured["params"] = params + return {"success": True, "message": "Created texture"} + + monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send) + monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight) + + resp = run_async(manage_texture_mod.manage_texture( + ctx=DummyContext(), + action="create", + path="Assets/TestTextures/Red.png", + width=64, + height=64, + fill_color=[255, 0, 0, 255] + )) + + assert resp["success"] is True + assert captured["params"]["fillColor"] == [255, 0, 0, 255] + + def test_create_texture_with_normalized_color(self, monkeypatch): + """Test creating a texture with normalized color (0.0-1.0).""" + captured = {} + + async def fake_send(func, instance, cmd, params, **kwargs): + captured["params"] = params + return {"success": True, "message": "Created texture"} + + monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send) + monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight) + + resp = run_async(manage_texture_mod.manage_texture( + ctx=DummyContext(), + action="create", + path="Assets/TestTextures/Blue.png", + fill_color=[0.0, 0.0, 1.0, 1.0] + )) + + assert resp["success"] is True + # Should be normalized to 0-255 + assert captured["params"]["fillColor"] == [0, 0, 255, 255] + + def test_create_sprite_with_pattern(self, monkeypatch): + """Test creating a sprite with checkerboard pattern.""" + captured = {} + + async def fake_send(func, instance, cmd, params, **kwargs): + captured["params"] = params + return {"success": True, "message": "Created sprite", "data": {"asSprite": True}} + + monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send) + monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight) + + resp = run_async(manage_texture_mod.manage_texture( + ctx=DummyContext(), + action="create_sprite", + path="Assets/TestTextures/Checkerboard.png", + pattern="checkerboard", + as_sprite={ + "pixelsPerUnit": 100.0, + "pivot": [0.5, 0.5] + } + )) + + assert resp["success"] is True + assert captured["params"]["action"] == "create_sprite" + assert captured["params"]["pattern"] == "checkerboard" + assert captured["params"]["spriteSettings"]["pixelsPerUnit"] == 100.0 + + def test_create_texture_with_import_settings(self, monkeypatch): + """Test creating a texture with import settings (conversion of snake_case to camelCase).""" + captured = {} + + async def fake_send(func, instance, cmd, params, **kwargs): + captured["params"] = params + return {"success": True, "message": "Created texture"} + + monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send) + monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight) + + resp = run_async(manage_texture_mod.manage_texture( + ctx=DummyContext(), + action="create", + path="Assets/TestTextures/SpriteTexture.png", + import_settings={ + "texture_type": "sprite", + "sprite_pixels_per_unit": 100, + "filter_mode": "point", + "wrap_mode": "clamp" + } + )) + + assert resp["success"] is True + settings = captured["params"]["importSettings"] + assert settings["textureType"] == "Sprite" + assert settings["spritePixelsPerUnit"] == 100 + assert settings["filterMode"] == "Point" + assert settings["wrapMode"] == "Clamp" + + def test_texture_modify_params(self, monkeypatch): + """Test texture modify parameter conversion.""" + captured = {} + + async def fake_send(func, instance, cmd, params, **kwargs): + captured["params"] = params + return {"success": True, "message": "Modified texture"} + + monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send) + monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight) + + resp = run_async(manage_texture_mod.manage_texture( + ctx=DummyContext(), + action="modify", + path="Assets/Textures/Test.png", + set_pixels={ + "x": 0, + "y": 0, + "width": 10, + "height": 10, + "color": [255, 0, 0, 255] + } + )) + + assert resp["success"] is True + assert captured["params"]["setPixels"]["color"] == [255, 0, 0, 255] + + def test_texture_modify_pixels_array(self, monkeypatch): + """Test texture modify pixel array normalization.""" + captured = {} + + async def fake_send(func, instance, cmd, params, **kwargs): + captured["params"] = params + return {"success": True, "message": "Modified texture"} + + monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send) + monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight) + + resp = run_async(manage_texture_mod.manage_texture( + ctx=DummyContext(), + action="modify", + path="Assets/Textures/Test.png", + set_pixels={ + "x": 0, + "y": 0, + "width": 2, + "height": 2, + "pixels": [ + [1.0, 0.0, 0.0, 1.0], + [0.0, 1.0, 0.0, 1.0], + [0.0, 0.0, 1.0, 1.0], + [0.5, 0.5, 0.5, 1.0], + ] + } + )) + + assert resp["success"] is True + assert captured["params"]["setPixels"]["pixels"] == [ + [255, 0, 0, 255], + [0, 255, 0, 255], + [0, 0, 255, 255], + [128, 128, 128, 255], + ] + + def test_texture_modify_pixels_invalid_length(self, monkeypatch): + """Test error handling for invalid pixel array length.""" + async def fake_send(*args, **kwargs): + return {"success": True} + + monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send) + monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight) + + resp = run_async(manage_texture_mod.manage_texture( + ctx=DummyContext(), + action="modify", + path="Assets/Textures/Test.png", + set_pixels={ + "x": 0, + "y": 0, + "width": 2, + "height": 2, + "pixels": [ + [255, 0, 0, 255], + [0, 255, 0, 255], + [0, 0, 255, 255], + ] + } + )) + + assert resp["success"] is False + assert "pixels array must have 4 entries" in resp["message"] + + def test_texture_modify_invalid_set_pixels_type(self, monkeypatch): + """Test error handling for invalid set_pixels input type.""" + async def fake_send(*args, **kwargs): + return {"success": True} + + monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send) + monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight) + + resp = run_async(manage_texture_mod.manage_texture( + ctx=DummyContext(), + action="modify", + path="Assets/Textures/Test.png", + set_pixels=[] + )) + + assert resp["success"] is False + assert resp["message"] == "set_pixels must be a JSON object" + + def test_texture_delete_params(self, monkeypatch): + """Test texture delete parameter pass-through.""" + captured = {} + + async def fake_send(func, instance, cmd, params, **kwargs): + captured["params"] = params + return {"success": True, "message": "Deleted texture"} + + monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send) + monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight) + + resp = run_async(manage_texture_mod.manage_texture( + ctx=DummyContext(), + action="delete", + path="Assets/Textures/Old.png" + )) + + assert resp["success"] is True + assert captured["params"]["path"] == "Assets/Textures/Old.png" + + def test_invalid_dimensions(self, monkeypatch): + """Test error handling for invalid dimensions (verified by Unity).""" + async def fake_send(func, instance, cmd, params, **kwargs): + w = params.get("width", 0) + if w > 4096: + return {"success": False, "message": "Invalid dimensions: 5000x64. Must be 1-4096."} + return {"success": True} + + monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send) + monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight) + + resp = run_async(manage_texture_mod.manage_texture( + ctx=DummyContext(), + action="create", + path="Assets/Invalid.png", + width=5000 # Too large + )) + + assert resp["success"] is False + assert "dimensions" in resp["message"].lower() diff --git a/Server/tests/test_cli.py b/Server/tests/test_cli.py index 9e1bb47da..12cb8934c 100644 --- a/Server/tests/test_cli.py +++ b/Server/tests/test_cli.py @@ -1221,5 +1221,124 @@ def test_code_search_with_options(self, runner): assert "Line 1" in result.output + + +# ============================================================================= +# Texture Command Tests +# ============================================================================= + +class TestTextureCommands: + """Tests for Texture CLI commands.""" + + def test_texture_create_basic(self, runner, mock_unity_response): + """Test basic texture create command.""" + with patch("cli.commands.texture.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "texture", "create", "Assets/Textures/Red.png", + "--color", "[255,0,0,255]" + ]) + assert result.exit_code == 0 + + def test_texture_create_with_hex_color(self, runner, mock_unity_response): + """Test texture create with hex color.""" + with patch("cli.commands.texture.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "texture", "create", "Assets/Textures/Blue.png", + "--color", "#0000FF" + ]) + assert result.exit_code == 0 + + def test_texture_create_with_pattern(self, runner, mock_unity_response): + """Test texture create with pattern.""" + with patch("cli.commands.texture.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "texture", "create", "Assets/Textures/Checker.png", + "--pattern", "checkerboard", + "--width", "128", + "--height", "128" + ]) + assert result.exit_code == 0 + + def test_texture_create_with_import_settings(self, runner, mock_unity_response): + """Test texture create with import settings.""" + with patch("cli.commands.texture.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "texture", "create", "Assets/Textures/Sprite.png", + "--import-settings", '{"texture_type": "sprite", "filter_mode": "point"}' + ]) + assert result.exit_code == 0 + + def test_texture_sprite_basic(self, runner, mock_unity_response): + """Test sprite create command.""" + with patch("cli.commands.texture.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "texture", "sprite", "Assets/Sprites/Player.png" + ]) + assert result.exit_code == 0 + + def test_texture_sprite_with_color(self, runner, mock_unity_response): + """Test sprite create with solid color.""" + with patch("cli.commands.texture.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "texture", "sprite", "Assets/Sprites/Green.png", + "--color", "[0,255,0,255]" + ]) + assert result.exit_code == 0 + + def test_texture_sprite_with_pattern(self, runner, mock_unity_response): + """Test sprite create with pattern.""" + with patch("cli.commands.texture.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "texture", "sprite", "Assets/Sprites/Dots.png", + "--pattern", "dots", + "--ppu", "50" + ]) + assert result.exit_code == 0 + + def test_texture_sprite_with_custom_pivot(self, runner, mock_unity_response): + """Test sprite create with custom pivot.""" + with patch("cli.commands.texture.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "texture", "sprite", "Assets/Sprites/Custom.png", + "--pivot", "[0.25,0.75]" + ]) + assert result.exit_code == 0 + + def test_texture_modify(self, runner, mock_unity_response): + """Test texture modify command.""" + with patch("cli.commands.texture.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "texture", "modify", "Assets/Textures/Test.png", + "--set-pixels", '{"x":0,"y":0,"width":10,"height":10,"color":[255,0,0,255]}' + ]) + assert result.exit_code == 0 + + def test_texture_delete(self, runner, mock_unity_response): + """Test texture delete command.""" + with patch("cli.commands.texture.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "texture", "delete", "Assets/Textures/Old.png" + ]) + assert result.exit_code == 0 + + def test_texture_create_invalid_json(self, runner): + """Test texture create with invalid JSON.""" + result = runner.invoke(cli, [ + "texture", "create", "Assets/Test.png", + "--import-settings", "not valid json" + ]) + assert result.exit_code == 1 + assert "Invalid JSON" in result.output + + def test_texture_sprite_color_and_pattern_precedence(self, runner, mock_unity_response): + """Test that color takes precedence over default pattern in sprite command.""" + with patch("cli.commands.texture.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "texture", "sprite", "Assets/Sprites/Solid.png", + "--color", "[255,0,0,255]" + ]) + assert result.exit_code == 0 + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/Server/uv.lock b/Server/uv.lock index 48e2c5641..afc2b8e02 100644 --- a/Server/uv.lock +++ b/Server/uv.lock @@ -912,7 +912,7 @@ wheels = [ [[package]] name = "mcpforunityserver" -version = "9.0.8" +version = "9.2.0" source = { editable = "." } dependencies = [ { name = "click" }, From 4767f4b9f6eb9b887a05d70b3eb6eda4c973ad1f Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Fri, 23 Jan 2026 20:03:32 -0500 Subject: [PATCH 2/6] Texture Size Set Set texture size to 1024X1024 to avoid too large texture set --- MCPForUnity/Editor/Tools/ManageTexture.cs | 53 +++++++++++-- Server/src/cli/commands/texture.py | 63 ++++++++++++--- Server/src/services/tools/manage_texture.py | 77 ++++++++++++++++--- .../tests/integration/test_manage_texture.py | 7 +- 4 files changed, 171 insertions(+), 29 deletions(-) diff --git a/MCPForUnity/Editor/Tools/ManageTexture.cs b/MCPForUnity/Editor/Tools/ManageTexture.cs index 2d153bdc4..0c26f490a 100644 --- a/MCPForUnity/Editor/Tools/ManageTexture.cs +++ b/MCPForUnity/Editor/Tools/ManageTexture.cs @@ -16,6 +16,8 @@ namespace MCPForUnity.Editor.Tools [McpForUnityTool("manage_texture", AutoRegister = false)] public static class ManageTexture { + private const int MaxTexturePixels = 1024 * 1024; + private const int MaxNoiseWork = 4000000; private static readonly List ValidActions = new List { "create", @@ -27,6 +29,24 @@ public static class ManageTexture "apply_noise" }; + private static ErrorResponse ValidateDimensions(int width, int height) + { + if (width <= 0 || height <= 0) + return new ErrorResponse($"Invalid dimensions: {width}x{height}. Must be positive."); + long totalPixels = (long)width * height; + if (totalPixels > MaxTexturePixels) + return new ErrorResponse($"Invalid dimensions: {width}x{height}. Total pixels must be <= {MaxTexturePixels}."); + return null; + } + + private static ErrorResponse ValidateNoiseWork(int width, int height, int octaves) + { + long work = (long)width * height * octaves; + if (work > MaxNoiseWork) + return new ErrorResponse($"Invalid noise workload: {width}x{height}x{octaves} exceeds {MaxNoiseWork}."); + return null; + } + public static object HandleCommand(JObject @params) { string action = @params["action"]?.ToString().ToLower(); @@ -85,14 +105,24 @@ private static object CreateTexture(JObject @params, bool asSprite) int height = @params["height"]?.ToObject() ?? 64; // Validate dimensions - if (width <= 0 || height <= 0 || width > 4096 || height > 4096) - return new ErrorResponse($"Invalid dimensions: {width}x{height}. Must be 1-4096."); + var dimensionError = ValidateDimensions(width, height); + if (dimensionError != null) + return dimensionError; string fullPath = AssetPathUtility.SanitizeAssetPath(path); EnsureDirectoryExists(fullPath); try { + var patternToken = @params["pattern"]; + int patternSize = 8; + if (patternToken != null) + { + patternSize = @params["patternSize"]?.ToObject() ?? 8; + if (patternSize <= 0) + return new ErrorResponse("patternSize must be greater than 0."); + } + Texture2D texture = new Texture2D(width, height, TextureFormat.RGBA32, false); // Check for fill color @@ -104,12 +134,10 @@ private static object CreateTexture(JObject @params, bool asSprite) } // Check for pattern - var patternToken = @params["pattern"]; if (patternToken != null) { string pattern = patternToken.ToString(); var palette = TextureOps.ParsePalette(@params["palette"] as JArray); - int patternSize = @params["patternSize"]?.ToObject() ?? 8; ApplyPatternToTexture(texture, pattern, palette, patternSize); } @@ -294,6 +322,9 @@ private static object ApplyGradient(JObject @params) int width = @params["width"]?.ToObject() ?? 64; int height = @params["height"]?.ToObject() ?? 64; + var dimensionError = ValidateDimensions(width, height); + if (dimensionError != null) + return dimensionError; string gradientType = @params["gradientType"]?.ToString() ?? "linear"; float angle = @params["gradientAngle"]?.ToObject() ?? 0f; @@ -360,8 +391,16 @@ private static object ApplyNoise(JObject @params) int width = @params["width"]?.ToObject() ?? 64; int height = @params["height"]?.ToObject() ?? 64; + var dimensionError = ValidateDimensions(width, height); + if (dimensionError != null) + return dimensionError; float scale = @params["noiseScale"]?.ToObject() ?? 0.1f; int octaves = @params["octaves"]?.ToObject() ?? 1; + if (octaves <= 0) + return new ErrorResponse("octaves must be greater than 0."); + var noiseWorkError = ValidateNoiseWork(width, height, octaves); + if (noiseWorkError != null) + return noiseWorkError; var palette = TextureOps.ParsePalette(@params["palette"] as JArray); if (palette == null || palette.Count < 2) @@ -490,13 +529,15 @@ private static void ApplyLinearGradient(Texture2D texture, List palette int height = texture.height; float radians = angle * Mathf.Deg2Rad; Vector2 dir = new Vector2(Mathf.Cos(radians), Mathf.Sin(radians)); + float denomX = Mathf.Max(1, width - 1); + float denomY = Mathf.Max(1, height - 1); for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { - float nx = x / (float)(width - 1); - float ny = y / (float)(height - 1); + float nx = x / denomX; + float ny = y / denomY; float t = Vector2.Dot(new Vector2(nx, ny), dir); t = Mathf.Clamp01((t + 1f) / 2f); diff --git a/Server/src/cli/commands/texture.py b/Server/src/cli/commands/texture.py index 54a7f73c0..508b17be5 100644 --- a/Server/src/cli/commands/texture.py +++ b/Server/src/cli/commands/texture.py @@ -67,20 +67,34 @@ def try_parse_json(value: str, context: str) -> Any: _MIPMAP_FILTERS = {"box": "BoxFilter", "kaiser": "KaiserFilter"} +_MAX_TEXTURE_PIXELS = 1024 * 1024 + + +def _validate_texture_dimensions(width: int, height: int) -> None: + if width <= 0 or height <= 0: + raise ValueError("width and height must be positive") + total_pixels = width * height + if total_pixels > _MAX_TEXTURE_PIXELS: + raise ValueError(f"width*height must be <= {_MAX_TEXTURE_PIXELS} (got {width}x{height}).") + def _is_normalized_color(values: list[Any]) -> bool: if not values: return False - all_small = all(0 <= v <= 1.0 for v in values) + try: + numeric_values = [float(v) for v in values] + except (TypeError, ValueError): + return False + + all_small = all(0 <= v <= 1.0 for v in numeric_values) if not all_small: return False - has_float = any(isinstance(v, float) for v in values) - has_fractional = any(0 < v < 1 for v in values) - all_binary = all(v in (0, 1, 0.0, 1.0) for v in values) + has_fractional = any(0 < v < 1 for v in numeric_values) + all_binary = all(v in (0, 1, 0.0, 1.0) for v in numeric_values) - return has_float or has_fractional or all_binary + return has_fractional or all_binary def _parse_hex_color(value: str) -> list[int]: @@ -105,9 +119,12 @@ def _normalize_color(value: Any, context: str) -> list[int]: if len(value) == 3: value = list(value) + [1.0 if _is_normalized_color(value) else 255] if len(value) == 4: - if _is_normalized_color(value): - return [int(round(float(c) * 255)) for c in value] - return [int(c) for c in value] + try: + if _is_normalized_color(value): + return [int(round(float(c) * 255)) for c in value] + return [int(c) for c in value] + except (TypeError, ValueError): + raise ValueError(f"{context} values must be numeric, got {value}") raise ValueError(f"{context} must have 3 or 4 components, got {len(value)}") raise ValueError(f"{context} must be a list or hex string") @@ -191,6 +208,24 @@ def _map_enum(value: Any, mapping: dict[str, str]) -> Any: return value +_TRUE_STRINGS = {"true", "1", "yes", "on"} +_FALSE_STRINGS = {"false", "0", "no", "off"} + + +def _coerce_bool(value: Any, name: str) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)) and value in (0, 1, 0.0, 1.0): + return bool(value) + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in _TRUE_STRINGS: + return True + if lowered in _FALSE_STRINGS: + return False + raise ValueError(f"{name} must be a boolean") + + def _normalize_import_settings(value: Any) -> dict[str, Any]: if value is None: return {} @@ -214,7 +249,7 @@ def _normalize_import_settings(value: Any) -> dict[str, Any]: ("compression_crunched", "crunchedCompression"), ]: if snake in value: - result[camel] = bool(value[snake]) + result[camel] = _coerce_bool(value[snake], snake) if "alpha_source" in value: result["alphaSource"] = _map_enum(value["alpha_source"], _ALPHA_SOURCES) @@ -291,6 +326,11 @@ def create(path: str, width: int, height: int, color: Optional[str], unity-mcp texture create Assets/UI.png --import-settings '{"texture_type": "sprite"}' """ config = get_config() + try: + _validate_texture_dimensions(width, height) + except ValueError as e: + print_error(str(e)) + sys.exit(1) params: dict[str, Any] = { "action": "create", @@ -356,6 +396,11 @@ def sprite(path: str, width: int, height: int, color: Optional[str], pattern: Op unity-mcp texture sprite Assets/Sprites/Solid.png --color '[0,255,0]' """ config = get_config() + try: + _validate_texture_dimensions(width, height) + except ValueError as e: + print_error(str(e)) + sys.exit(1) sprite_settings: dict[str, Any] = {"pixelsPerUnit": ppu} if pivot: diff --git a/Server/src/services/tools/manage_texture.py b/Server/src/services/tools/manage_texture.py index 5b24181c9..af1554a77 100644 --- a/Server/src/services/tools/manage_texture.py +++ b/Server/src/services/tools/manage_texture.py @@ -16,6 +16,10 @@ from services.tools.preflight import preflight +_MAX_TEXTURE_PIXELS = 1024 * 1024 +_MAX_NOISE_WORK = 4000000 + + def _is_normalized_color(values: list) -> bool: """ Check if color values appear to be in normalized 0.0-1.0 range. @@ -24,23 +28,45 @@ def _is_normalized_color(values: list) -> bool: if not values: return False + try: + numeric_values = [float(v) for v in values] + except (TypeError, ValueError): + return False + # Check if all values are <= 1.0 - all_small = all(0 <= v <= 1.0 for v in values) + all_small = all(0 <= v <= 1.0 for v in numeric_values) if not all_small: return False - # If any value is a float (not an integer), it's likely normalized - has_float = any(isinstance(v, float) for v in values) - # If any non-zero value is less than 1, it's likely normalized (e.g., 0.5) - has_fractional = any(0 < v < 1 for v in values) + has_fractional = any(0 < v < 1 for v in numeric_values) # If all values are 0 or 1, and they're all integers, could be either format # In this ambiguous case (like [1, 0, 0, 1]), assume normalized since that's # what graphics programmers typically use - all_binary = all(v in (0, 1, 0.0, 1.0) for v in values) + all_binary = all(v in (0, 1, 0.0, 1.0) for v in numeric_values) - return has_float or has_fractional or all_binary + return has_fractional or all_binary + + +def _normalize_dimension(value: Any, name: str, default: int = 64) -> tuple[int | None, str | None]: + if value is None: + return default, None + coerced = coerce_int(value) + if coerced is None: + return None, f"{name} must be an integer" + if coerced <= 0: + return None, f"{name} must be positive" + return coerced, None + + +def _normalize_positive_int(value: Any, name: str) -> tuple[int | None, str | None]: + if value is None: + return None, None + coerced = coerce_int(value) + if coerced is None or coerced <= 0: + return None, f"{name} must be a positive integer" + return coerced, None def _normalize_color(value: Any) -> tuple[list[int] | None, str | None]: @@ -533,8 +559,37 @@ async def manage_texture( return {"success": False, "message": palette_error} # Normalize dimensions - width = coerce_int(width) or 64 - height = coerce_int(height) or 64 + width, width_error = _normalize_dimension(width, "width") + if width_error: + return {"success": False, "message": width_error} + height, height_error = _normalize_dimension(height, "height") + if height_error: + return {"success": False, "message": height_error} + total_pixels = width * height + if total_pixels > _MAX_TEXTURE_PIXELS: + return { + "success": False, + "message": f"width*height must be <= {_MAX_TEXTURE_PIXELS} (got {width}x{height}).", + } + if action.lower() == "apply_noise": + noise_octaves = octaves if octaves is not None else 1 + noise_work = width * height * noise_octaves + if noise_work > _MAX_NOISE_WORK: + return { + "success": False, + "message": ( + f"width*height*octaves must be <= {_MAX_NOISE_WORK} " + f"(got {width}x{height}x{noise_octaves})." + ), + } + + pattern_size, pattern_error = _normalize_positive_int(pattern_size, "pattern_size") + if pattern_error: + return {"success": False, "message": pattern_error} + + octaves, octaves_error = _normalize_positive_int(octaves, "octaves") + if octaves_error: + return {"success": False, "message": octaves_error} # Normalize pixels if provided pixels_normalized = None @@ -591,12 +646,12 @@ async def manage_texture( "fillColor": fill_color, "pattern": pattern, "palette": palette, - "patternSize": coerce_int(pattern_size), + "patternSize": pattern_size, "pixels": pixels_normalized, "gradientType": gradient_type, "gradientAngle": gradient_angle, "noiseScale": noise_scale, - "octaves": coerce_int(octaves), + "octaves": octaves, "setPixels": set_pixels_normalized, "spriteSettings": sprite_settings, "importSettings": import_settings_normalized, diff --git a/Server/tests/integration/test_manage_texture.py b/Server/tests/integration/test_manage_texture.py index 4ed165e13..5d4461be7 100644 --- a/Server/tests/integration/test_manage_texture.py +++ b/Server/tests/integration/test_manage_texture.py @@ -255,7 +255,7 @@ async def fake_send(func, instance, cmd, params, **kwargs): assert captured["params"]["path"] == "Assets/Textures/Old.png" def test_invalid_dimensions(self, monkeypatch): - """Test error handling for invalid dimensions (verified by Unity).""" + """Test error handling for invalid dimensions.""" async def fake_send(func, instance, cmd, params, **kwargs): w = params.get("width", 0) if w > 4096: @@ -269,8 +269,9 @@ async def fake_send(func, instance, cmd, params, **kwargs): ctx=DummyContext(), action="create", path="Assets/Invalid.png", - width=5000 # Too large + width=5000, + height=300 # Too many total pixels )) assert resp["success"] is False - assert "dimensions" in resp["message"].lower() + assert "width*height" in resp["message"].lower() From 999376687f4fca30633b88c2a7aa4c85d16ebdbf Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Fri, 23 Jan 2026 20:39:04 -0500 Subject: [PATCH 3/6] Add image input --- MCPForUnity/Editor/Tools/ManageTexture.cs | 103 ++++++++++++++------ Server/src/cli/commands/texture.py | 46 ++++++--- Server/src/services/tools/manage_texture.py | 76 +++++++++------ 3 files changed, 153 insertions(+), 72 deletions(-) diff --git a/MCPForUnity/Editor/Tools/ManageTexture.cs b/MCPForUnity/Editor/Tools/ManageTexture.cs index 0c26f490a..365b75fa4 100644 --- a/MCPForUnity/Editor/Tools/ManageTexture.cs +++ b/MCPForUnity/Editor/Tools/ManageTexture.cs @@ -101,57 +101,96 @@ private static object CreateTexture(JObject @params, bool asSprite) if (string.IsNullOrEmpty(path)) return new ErrorResponse("'path' is required for create."); + string imagePath = @params["imagePath"]?.ToString(); + bool hasImage = !string.IsNullOrEmpty(imagePath); + int width = @params["width"]?.ToObject() ?? 64; int height = @params["height"]?.ToObject() ?? 64; // Validate dimensions - var dimensionError = ValidateDimensions(width, height); - if (dimensionError != null) - return dimensionError; + if (!hasImage) + { + var dimensionError = ValidateDimensions(width, height); + if (dimensionError != null) + return dimensionError; + } string fullPath = AssetPathUtility.SanitizeAssetPath(path); EnsureDirectoryExists(fullPath); try { + var fillColorToken = @params["fillColor"]; var patternToken = @params["pattern"]; + var pixelsToken = @params["pixels"]; + + if (hasImage && (fillColorToken != null || patternToken != null || pixelsToken != null)) + { + return new ErrorResponse("imagePath cannot be combined with fillColor, pattern, or pixels."); + } + int patternSize = 8; - if (patternToken != null) + if (!hasImage && patternToken != null) { patternSize = @params["patternSize"]?.ToObject() ?? 8; if (patternSize <= 0) return new ErrorResponse("patternSize must be greater than 0."); } - Texture2D texture = new Texture2D(width, height, TextureFormat.RGBA32, false); - - // Check for fill color - var fillColorToken = @params["fillColor"]; - if (fillColorToken != null && fillColorToken.Type == JTokenType.Array) + Texture2D texture; + if (hasImage) { - Color32 fillColor = TextureOps.ParseColor32(fillColorToken as JArray); - TextureOps.FillTexture(texture, fillColor); - } + string resolvedImagePath = ResolveImagePath(imagePath); + if (!File.Exists(resolvedImagePath)) + return new ErrorResponse($"Image file not found at '{imagePath}'."); - // Check for pattern - if (patternToken != null) - { - string pattern = patternToken.ToString(); - var palette = TextureOps.ParsePalette(@params["palette"] as JArray); - ApplyPatternToTexture(texture, pattern, palette, patternSize); - } + byte[] imageBytes = File.ReadAllBytes(resolvedImagePath); + texture = new Texture2D(2, 2, TextureFormat.RGBA32, false); + if (!texture.LoadImage(imageBytes)) + { + UnityEngine.Object.DestroyImmediate(texture); + return new ErrorResponse($"Failed to load image from '{imagePath}'."); + } - // Check for direct pixel data - var pixelsToken = @params["pixels"]; - if (pixelsToken != null) - { - TextureOps.ApplyPixelData(texture, pixelsToken, width, height); + width = texture.width; + height = texture.height; + var imageDimensionError = ValidateDimensions(width, height); + if (imageDimensionError != null) + { + UnityEngine.Object.DestroyImmediate(texture); + return imageDimensionError; + } } - - // If nothing specified, create transparent texture - if (fillColorToken == null && patternToken == null && pixelsToken == null) + else { - TextureOps.FillTexture(texture, new Color32(0, 0, 0, 0)); + texture = new Texture2D(width, height, TextureFormat.RGBA32, false); + + // Check for fill color + if (fillColorToken != null && fillColorToken.Type == JTokenType.Array) + { + Color32 fillColor = TextureOps.ParseColor32(fillColorToken as JArray); + TextureOps.FillTexture(texture, fillColor); + } + + // Check for pattern + if (patternToken != null) + { + string pattern = patternToken.ToString(); + var palette = TextureOps.ParsePalette(@params["palette"] as JArray); + ApplyPatternToTexture(texture, pattern, palette, patternSize); + } + + // Check for direct pixel data + if (pixelsToken != null) + { + TextureOps.ApplyPixelData(texture, pixelsToken, width, height); + } + + // If nothing specified, create transparent texture + if (fillColorToken == null && patternToken == null && pixelsToken == null) + { + TextureOps.FillTexture(texture, new Color32(0, 0, 0, 0)); + } } texture.Apply(); @@ -935,5 +974,13 @@ private static string GetAbsolutePath(string assetPath) { return Path.Combine(Directory.GetCurrentDirectory(), assetPath); } + + private static string ResolveImagePath(string imagePath) + { + if (Path.IsPathRooted(imagePath)) + return imagePath; + + return Path.Combine(Directory.GetCurrentDirectory(), imagePath); + } } } diff --git a/Server/src/cli/commands/texture.py b/Server/src/cli/commands/texture.py index 508b17be5..b6ab08674 100644 --- a/Server/src/cli/commands/texture.py +++ b/Server/src/cli/commands/texture.py @@ -308,6 +308,7 @@ def texture(): @click.argument("path") @click.option("--width", default=64, help="Texture width (default: 64)") @click.option("--height", default=64, help="Texture height (default: 64)") +@click.option("--image-path", help="Source image path (PNG/JPG) to import.") @click.option("--color", help="Fill color (e.g., '#FF0000' or '[1,0,0,1]')") @click.option("--pattern", type=click.Choice([ "checkerboard", "stripes", "stripes_h", "stripes_v", "stripes_diag", @@ -315,7 +316,7 @@ def texture(): ]), help="Pattern type") @click.option("--palette", help="Color palette for pattern (JSON array of colors)") @click.option("--import-settings", help="TextureImporter settings (JSON)") -def create(path: str, width: int, height: int, color: Optional[str], +def create(path: str, width: int, height: int, image_path: Optional[str], color: Optional[str], pattern: Optional[str], palette: Optional[str], import_settings: Optional[str]): """Create a new procedural texture. @@ -326,11 +327,16 @@ def create(path: str, width: int, height: int, color: Optional[str], unity-mcp texture create Assets/UI.png --import-settings '{"texture_type": "sprite"}' """ config = get_config() - try: - _validate_texture_dimensions(width, height) - except ValueError as e: - print_error(str(e)) - sys.exit(1) + if image_path: + if color or pattern or palette: + print_error("image-path cannot be combined with color, pattern, or palette.") + sys.exit(1) + else: + try: + _validate_texture_dimensions(width, height) + except ValueError as e: + print_error(str(e)) + sys.exit(1) params: dict[str, Any] = { "action": "create", @@ -345,7 +351,7 @@ def create(path: str, width: int, height: int, color: Optional[str], except ValueError as e: print_error(str(e)) sys.exit(1) - elif not pattern: + elif not pattern and not image_path: # Default to white if no color or pattern specified params["fillColor"] = [255, 255, 255, 255] @@ -366,6 +372,9 @@ def create(path: str, width: int, height: int, color: Optional[str], print_error(str(e)) sys.exit(1) + if image_path: + params["imagePath"] = image_path + try: result = run_command("manage_texture", params, config) click.echo(format_output(result, config.format)) @@ -380,13 +389,14 @@ def create(path: str, width: int, height: int, color: Optional[str], @click.argument("path") @click.option("--width", default=64, help="Texture width (default: 64)") @click.option("--height", default=64, help="Texture height (default: 64)") +@click.option("--image-path", help="Source image path (PNG/JPG) to import.") @click.option("--color", help="Fill color (e.g., '#FF0000' or '[1,0,0,1]')") @click.option("--pattern", type=click.Choice([ "checkerboard", "stripes", "dots", "grid" ]), help="Pattern type (defaults to checkerboard if no color specified)") @click.option("--ppu", default=100.0, help="Pixels Per Unit") @click.option("--pivot", help="Pivot as [x,y] (default: [0.5, 0.5])") -def sprite(path: str, width: int, height: int, color: Optional[str], pattern: Optional[str], ppu: float, pivot: Optional[str]): +def sprite(path: str, width: int, height: int, image_path: Optional[str], color: Optional[str], pattern: Optional[str], ppu: float, pivot: Optional[str]): """Quickly create a sprite texture. \b @@ -396,11 +406,16 @@ def sprite(path: str, width: int, height: int, color: Optional[str], pattern: Op unity-mcp texture sprite Assets/Sprites/Solid.png --color '[0,255,0]' """ config = get_config() - try: - _validate_texture_dimensions(width, height) - except ValueError as e: - print_error(str(e)) - sys.exit(1) + if image_path: + if color or pattern: + print_error("image-path cannot be combined with color or pattern.") + sys.exit(1) + else: + try: + _validate_texture_dimensions(width, height) + except ValueError as e: + print_error(str(e)) + sys.exit(1) sprite_settings: dict[str, Any] = {"pixelsPerUnit": ppu} if pivot: @@ -426,9 +441,12 @@ def sprite(path: str, width: int, height: int, color: Optional[str], pattern: Op # Only default pattern if no color is specified if pattern: params["pattern"] = pattern - elif not color: + elif not color and not image_path: params["pattern"] = "checkerboard" + if image_path: + params["imagePath"] = image_path + try: result = run_command("manage_texture", params, config) click.echo(format_output(result, config.format)) diff --git a/Server/src/services/tools/manage_texture.py b/Server/src/services/tools/manage_texture.py index af1554a77..3c0dab12d 100644 --- a/Server/src/services/tools/manage_texture.py +++ b/Server/src/services/tools/manage_texture.py @@ -506,6 +506,9 @@ async def manage_texture( pixels: Annotated[list[list[int]] | str, "Pixel data as JSON array of [r,g,b,a] values or base64 string"] | None = None, + image_path: Annotated[str, + "Source image file path for create/create_sprite (PNG/JPG)."] | None = None, + # Gradient settings gradient_type: Annotated[Literal["linear", "radial"], "Gradient type (default: linear)"] | None = None, @@ -550,46 +553,58 @@ async def manage_texture( if fill_error: return {"success": False, "message": fill_error} + action_lower = action.lower() + + if image_path is not None and action_lower not in ("create", "create_sprite"): + return {"success": False, "message": "image_path is only supported for create/create_sprite."} + + if image_path is not None and (fill_color is not None or pattern is not None or pixels is not None): + return {"success": False, "message": "image_path cannot be combined with fill_color, pattern, or pixels."} + # Default to white for create action if nothing else specified - if action == "create" and fill_color is None and pattern is None and pixels is None: + if action == "create" and fill_color is None and pattern is None and pixels is None and image_path is None: fill_color = [255, 255, 255, 255] palette, palette_error = _normalize_palette(palette) if palette_error: return {"success": False, "message": palette_error} - # Normalize dimensions - width, width_error = _normalize_dimension(width, "width") - if width_error: - return {"success": False, "message": width_error} - height, height_error = _normalize_dimension(height, "height") - if height_error: - return {"success": False, "message": height_error} - total_pixels = width * height - if total_pixels > _MAX_TEXTURE_PIXELS: - return { - "success": False, - "message": f"width*height must be <= {_MAX_TEXTURE_PIXELS} (got {width}x{height}).", - } - if action.lower() == "apply_noise": - noise_octaves = octaves if octaves is not None else 1 - noise_work = width * height * noise_octaves - if noise_work > _MAX_NOISE_WORK: + if image_path is None: + # Normalize dimensions + width, width_error = _normalize_dimension(width, "width") + if width_error: + return {"success": False, "message": width_error} + height, height_error = _normalize_dimension(height, "height") + if height_error: + return {"success": False, "message": height_error} + total_pixels = width * height + if total_pixels > _MAX_TEXTURE_PIXELS: return { "success": False, - "message": ( - f"width*height*octaves must be <= {_MAX_NOISE_WORK} " - f"(got {width}x{height}x{noise_octaves})." - ), + "message": f"width*height must be <= {_MAX_TEXTURE_PIXELS} (got {width}x{height}).", } - - pattern_size, pattern_error = _normalize_positive_int(pattern_size, "pattern_size") - if pattern_error: - return {"success": False, "message": pattern_error} - - octaves, octaves_error = _normalize_positive_int(octaves, "octaves") - if octaves_error: - return {"success": False, "message": octaves_error} + if action_lower == "apply_noise": + noise_octaves = octaves if octaves is not None else 1 + noise_work = width * height * noise_octaves + if noise_work > _MAX_NOISE_WORK: + return { + "success": False, + "message": ( + f"width*height*octaves must be <= {_MAX_NOISE_WORK} " + f"(got {width}x{height}x{noise_octaves})." + ), + } + + pattern_size, pattern_error = _normalize_positive_int(pattern_size, "pattern_size") + if pattern_error: + return {"success": False, "message": pattern_error} + + octaves, octaves_error = _normalize_positive_int(octaves, "octaves") + if octaves_error: + return {"success": False, "message": octaves_error} + else: + width = None + height = None # Normalize pixels if provided pixels_normalized = None @@ -648,6 +663,7 @@ async def manage_texture( "palette": palette, "patternSize": pattern_size, "pixels": pixels_normalized, + "imagePath": image_path, "gradientType": gradient_type, "gradientAngle": gradient_angle, "noiseScale": noise_scale, From d672968e361fa3eadd771629b2e09a8dbc697e25 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sat, 24 Jan 2026 13:41:24 -0500 Subject: [PATCH 4/6] Update to release direct error with large tex2d --- MCPForUnity/Editor/Tools/ManageTexture.cs | 71 ++++++++++++++----- Server/src/cli/commands/texture.py | 17 +++-- Server/src/services/tools/manage_texture.py | 22 ------ .../tests/integration/test_manage_texture.py | 6 +- 4 files changed, 68 insertions(+), 48 deletions(-) diff --git a/MCPForUnity/Editor/Tools/ManageTexture.cs b/MCPForUnity/Editor/Tools/ManageTexture.cs index 365b75fa4..ba783edc8 100644 --- a/MCPForUnity/Editor/Tools/ManageTexture.cs +++ b/MCPForUnity/Editor/Tools/ManageTexture.cs @@ -16,6 +16,7 @@ namespace MCPForUnity.Editor.Tools [McpForUnityTool("manage_texture", AutoRegister = false)] public static class ManageTexture { + private const int MaxTextureDimension = 1024; private const int MaxTexturePixels = 1024 * 1024; private const int MaxNoiseWork = 4000000; private static readonly List ValidActions = new List @@ -29,23 +30,18 @@ public static class ManageTexture "apply_noise" }; - private static ErrorResponse ValidateDimensions(int width, int height) + private static ErrorResponse ValidateDimensions(int width, int height, List warnings) { if (width <= 0 || height <= 0) return new ErrorResponse($"Invalid dimensions: {width}x{height}. Must be positive."); + if (width > MaxTextureDimension || height > MaxTextureDimension) + warnings.Add($"Dimensions exceed recommended max {MaxTextureDimension} per side (got {width}x{height})."); long totalPixels = (long)width * height; if (totalPixels > MaxTexturePixels) - return new ErrorResponse($"Invalid dimensions: {width}x{height}. Total pixels must be <= {MaxTexturePixels}."); + warnings.Add($"Total pixels exceed recommended max {MaxTexturePixels} (got {width}x{height})."); return null; } - private static ErrorResponse ValidateNoiseWork(int width, int height, int octaves) - { - long work = (long)width * height * octaves; - if (work > MaxNoiseWork) - return new ErrorResponse($"Invalid noise workload: {width}x{height}x{octaves} exceeds {MaxNoiseWork}."); - return null; - } public static object HandleCommand(JObject @params) { @@ -106,11 +102,12 @@ private static object CreateTexture(JObject @params, bool asSprite) int width = @params["width"]?.ToObject() ?? 64; int height = @params["height"]?.ToObject() ?? 64; + List warnings = new List(); // Validate dimensions if (!hasImage) { - var dimensionError = ValidateDimensions(width, height); + var dimensionError = ValidateDimensions(width, height, warnings); if (dimensionError != null) return dimensionError; } @@ -154,7 +151,7 @@ private static object CreateTexture(JObject @params, bool asSprite) width = texture.width; height = texture.height; - var imageDimensionError = ValidateDimensions(width, height); + var imageDimensionError = ValidateDimensions(width, height, warnings); if (imageDimensionError != null) { UnityEngine.Object.DestroyImmediate(texture); @@ -222,10 +219,21 @@ private static object CreateTexture(JObject @params, bool asSprite) // Clean up memory UnityEngine.Object.DestroyImmediate(texture); + foreach (var warning in warnings) + { + McpLog.Warn($"[ManageTexture] {warning}"); + } return new SuccessResponse( $"Texture created at '{fullPath}' ({width}x{height})" + (asSprite ? " as sprite" : ""), - new { path = fullPath, width, height, asSprite = asSprite || spriteSettingsToken != null || (importSettingsToken?["textureType"]?.ToString() == "Sprite") } + new + { + path = fullPath, + width, + height, + asSprite = asSprite || spriteSettingsToken != null || (importSettingsToken?["textureType"]?.ToString() == "Sprite"), + warnings = warnings.Count > 0 ? warnings : null + } ); } catch (Exception e) @@ -361,7 +369,8 @@ private static object ApplyGradient(JObject @params) int width = @params["width"]?.ToObject() ?? 64; int height = @params["height"]?.ToObject() ?? 64; - var dimensionError = ValidateDimensions(width, height); + List warnings = new List(); + var dimensionError = ValidateDimensions(width, height, warnings); if (dimensionError != null) return dimensionError; string gradientType = @params["gradientType"]?.ToString() ?? "linear"; @@ -410,10 +419,21 @@ private static object ApplyGradient(JObject @params) } UnityEngine.Object.DestroyImmediate(texture); + foreach (var warning in warnings) + { + McpLog.Warn($"[ManageTexture] {warning}"); + } return new SuccessResponse( $"Gradient texture created at '{fullPath}' ({width}x{height})", - new { path = fullPath, width, height, gradientType } + new + { + path = fullPath, + width, + height, + gradientType, + warnings = warnings.Count > 0 ? warnings : null + } ); } catch (Exception e) @@ -430,16 +450,17 @@ private static object ApplyNoise(JObject @params) int width = @params["width"]?.ToObject() ?? 64; int height = @params["height"]?.ToObject() ?? 64; - var dimensionError = ValidateDimensions(width, height); + List warnings = new List(); + var dimensionError = ValidateDimensions(width, height, warnings); if (dimensionError != null) return dimensionError; float scale = @params["noiseScale"]?.ToObject() ?? 0.1f; int octaves = @params["octaves"]?.ToObject() ?? 1; if (octaves <= 0) return new ErrorResponse("octaves must be greater than 0."); - var noiseWorkError = ValidateNoiseWork(width, height, octaves); - if (noiseWorkError != null) - return noiseWorkError; + long noiseWork = (long)width * height * octaves; + if (noiseWork > MaxNoiseWork) + warnings.Add($"Noise workload exceeds recommended max {MaxNoiseWork} (got {width}x{height}x{octaves})."); var palette = TextureOps.ParsePalette(@params["palette"] as JArray); if (palette == null || palette.Count < 2) @@ -476,10 +497,22 @@ private static object ApplyNoise(JObject @params) } UnityEngine.Object.DestroyImmediate(texture); + foreach (var warning in warnings) + { + McpLog.Warn($"[ManageTexture] {warning}"); + } return new SuccessResponse( $"Noise texture created at '{fullPath}' ({width}x{height})", - new { path = fullPath, width, height, noiseScale = scale, octaves } + new + { + path = fullPath, + width, + height, + noiseScale = scale, + octaves, + warnings = warnings.Count > 0 ? warnings : null + } ); } catch (Exception e) diff --git a/Server/src/cli/commands/texture.py b/Server/src/cli/commands/texture.py index b6ab08674..07bdb5aad 100644 --- a/Server/src/cli/commands/texture.py +++ b/Server/src/cli/commands/texture.py @@ -67,15 +67,20 @@ def try_parse_json(value: str, context: str) -> Any: _MIPMAP_FILTERS = {"box": "BoxFilter", "kaiser": "KaiserFilter"} +_MAX_TEXTURE_DIMENSION = 1024 _MAX_TEXTURE_PIXELS = 1024 * 1024 -def _validate_texture_dimensions(width: int, height: int) -> None: +def _validate_texture_dimensions(width: int, height: int) -> list[str]: if width <= 0 or height <= 0: raise ValueError("width and height must be positive") + warnings: list[str] = [] + if width > _MAX_TEXTURE_DIMENSION or height > _MAX_TEXTURE_DIMENSION: + warnings.append(f"width and height should be <= {_MAX_TEXTURE_DIMENSION} (got {width}x{height})") total_pixels = width * height if total_pixels > _MAX_TEXTURE_PIXELS: - raise ValueError(f"width*height must be <= {_MAX_TEXTURE_PIXELS} (got {width}x{height}).") + warnings.append(f"width*height should be <= {_MAX_TEXTURE_PIXELS} (got {width}x{height})") + return warnings def _is_normalized_color(values: list[Any]) -> bool: @@ -333,10 +338,12 @@ def create(path: str, width: int, height: int, image_path: Optional[str], color: sys.exit(1) else: try: - _validate_texture_dimensions(width, height) + warnings = _validate_texture_dimensions(width, height) except ValueError as e: print_error(str(e)) sys.exit(1) + for warning in warnings: + click.echo(f"⚠️ Warning: {warning}") params: dict[str, Any] = { "action": "create", @@ -412,10 +419,12 @@ def sprite(path: str, width: int, height: int, image_path: Optional[str], color: sys.exit(1) else: try: - _validate_texture_dimensions(width, height) + warnings = _validate_texture_dimensions(width, height) except ValueError as e: print_error(str(e)) sys.exit(1) + for warning in warnings: + click.echo(f"⚠️ Warning: {warning}") sprite_settings: dict[str, Any] = {"pixelsPerUnit": ppu} if pivot: diff --git a/Server/src/services/tools/manage_texture.py b/Server/src/services/tools/manage_texture.py index 3c0dab12d..c652265b6 100644 --- a/Server/src/services/tools/manage_texture.py +++ b/Server/src/services/tools/manage_texture.py @@ -16,10 +16,6 @@ from services.tools.preflight import preflight -_MAX_TEXTURE_PIXELS = 1024 * 1024 -_MAX_NOISE_WORK = 4000000 - - def _is_normalized_color(values: list) -> bool: """ Check if color values appear to be in normalized 0.0-1.0 range. @@ -577,24 +573,6 @@ async def manage_texture( height, height_error = _normalize_dimension(height, "height") if height_error: return {"success": False, "message": height_error} - total_pixels = width * height - if total_pixels > _MAX_TEXTURE_PIXELS: - return { - "success": False, - "message": f"width*height must be <= {_MAX_TEXTURE_PIXELS} (got {width}x{height}).", - } - if action_lower == "apply_noise": - noise_octaves = octaves if octaves is not None else 1 - noise_work = width * height * noise_octaves - if noise_work > _MAX_NOISE_WORK: - return { - "success": False, - "message": ( - f"width*height*octaves must be <= {_MAX_NOISE_WORK} " - f"(got {width}x{height}x{noise_octaves})." - ), - } - pattern_size, pattern_error = _normalize_positive_int(pattern_size, "pattern_size") if pattern_error: return {"success": False, "message": pattern_error} diff --git a/Server/tests/integration/test_manage_texture.py b/Server/tests/integration/test_manage_texture.py index 5d4461be7..9921c0ed3 100644 --- a/Server/tests/integration/test_manage_texture.py +++ b/Server/tests/integration/test_manage_texture.py @@ -269,9 +269,9 @@ async def fake_send(func, instance, cmd, params, **kwargs): ctx=DummyContext(), action="create", path="Assets/Invalid.png", - width=5000, - height=300 # Too many total pixels + width=0, + height=64 # Non-positive dimension )) assert resp["success"] is False - assert "width*height" in resp["message"].lower() + assert "positive" in resp["message"].lower() From 0c1669938bd1fcfc410cc4d3a681e6488154d2ba Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sat, 24 Jan 2026 13:53:56 -0500 Subject: [PATCH 5/6] Fix for AI advice --- MCPForUnity/Editor/Tools/ManageTexture.cs | 20 +++++++++++++------- Server/src/services/tools/manage_texture.py | 6 +++--- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/MCPForUnity/Editor/Tools/ManageTexture.cs b/MCPForUnity/Editor/Tools/ManageTexture.cs index ba783edc8..b2cca069d 100644 --- a/MCPForUnity/Editor/Tools/ManageTexture.cs +++ b/MCPForUnity/Editor/Tools/ManageTexture.cs @@ -386,9 +386,10 @@ private static object ApplyGradient(JObject @params) string fullPath = AssetPathUtility.SanitizeAssetPath(path); EnsureDirectoryExists(fullPath); + Texture2D texture = null; try { - Texture2D texture = new Texture2D(width, height, TextureFormat.RGBA32, false); + texture = new Texture2D(width, height, TextureFormat.RGBA32, false); if (gradientType == "radial") { @@ -404,7 +405,6 @@ private static object ApplyGradient(JObject @params) byte[] imageData = TextureOps.EncodeTexture(texture, fullPath); if (imageData == null || imageData.Length == 0) { - UnityEngine.Object.DestroyImmediate(texture); return new ErrorResponse($"Failed to encode texture for '{fullPath}'"); } File.WriteAllBytes(GetAbsolutePath(fullPath), imageData); @@ -418,7 +418,6 @@ private static object ApplyGradient(JObject @params) ConfigureAsSprite(fullPath, spriteSettingsToken); } - UnityEngine.Object.DestroyImmediate(texture); foreach (var warning in warnings) { McpLog.Warn($"[ManageTexture] {warning}"); @@ -440,6 +439,11 @@ private static object ApplyGradient(JObject @params) { return new ErrorResponse($"Failed to create gradient texture: {e.Message}"); } + finally + { + if (texture != null) + UnityEngine.Object.DestroyImmediate(texture); + } } private static object ApplyNoise(JObject @params) @@ -471,10 +475,9 @@ private static object ApplyNoise(JObject @params) string fullPath = AssetPathUtility.SanitizeAssetPath(path); EnsureDirectoryExists(fullPath); + Texture2D texture = new Texture2D(width, height, TextureFormat.RGBA32, false); try { - Texture2D texture = new Texture2D(width, height, TextureFormat.RGBA32, false); - ApplyPerlinNoise(texture, palette, scale, octaves); texture.Apply(); @@ -482,7 +485,6 @@ private static object ApplyNoise(JObject @params) byte[] imageData = TextureOps.EncodeTexture(texture, fullPath); if (imageData == null || imageData.Length == 0) { - UnityEngine.Object.DestroyImmediate(texture); return new ErrorResponse($"Failed to encode texture for '{fullPath}'"); } File.WriteAllBytes(GetAbsolutePath(fullPath), imageData); @@ -496,7 +498,6 @@ private static object ApplyNoise(JObject @params) ConfigureAsSprite(fullPath, spriteSettingsToken); } - UnityEngine.Object.DestroyImmediate(texture); foreach (var warning in warnings) { McpLog.Warn($"[ManageTexture] {warning}"); @@ -519,6 +520,11 @@ private static object ApplyNoise(JObject @params) { return new ErrorResponse($"Failed to create noise texture: {e.Message}"); } + finally + { + if (texture != null) + UnityEngine.Object.DestroyImmediate(texture); + } } // --- Pattern Helpers --- diff --git a/Server/src/services/tools/manage_texture.py b/Server/src/services/tools/manage_texture.py index c652265b6..44306bcbc 100644 --- a/Server/src/services/tools/manage_texture.py +++ b/Server/src/services/tools/manage_texture.py @@ -619,9 +619,9 @@ async def manage_texture( return {"success": False, "message": f"set_pixels.color: {error}"} set_pixels_normalized["color"] = color if "pixels" in set_pixels_normalized: - region_width = coerce_int(set_pixels_normalized.get("width")) or 1 - region_height = coerce_int(set_pixels_normalized.get("height")) or 1 - if region_width <= 0 or region_height <= 0: + region_width = coerce_int(set_pixels_normalized.get("width")) + region_height = coerce_int(set_pixels_normalized.get("height")) + if region_width is None or region_height is None or region_width <= 0 or region_height <= 0: return {"success": False, "message": "set_pixels width and height must be positive integers"} pixels_normalized, pixels_error = _normalize_pixels( set_pixels_normalized["pixels"], region_width, region_height From c7b56ba2f3a6f8278bccd7fc79ca8d7fb0792797 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sat, 24 Jan 2026 16:20:06 -0500 Subject: [PATCH 6/6] Update on action fetch line --- MCPForUnity/Editor/Tools/ManageAsset.cs | 2 +- MCPForUnity/Editor/Tools/ManageEditor.cs | 2 +- MCPForUnity/Editor/Tools/ManageMaterial.cs | 2 +- MCPForUnity/Editor/Tools/ManageShader.cs | 2 +- MCPForUnity/Editor/Tools/ManageTexture.cs | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/MCPForUnity/Editor/Tools/ManageAsset.cs b/MCPForUnity/Editor/Tools/ManageAsset.cs index a285c9dcb..ed6e6c98e 100644 --- a/MCPForUnity/Editor/Tools/ManageAsset.cs +++ b/MCPForUnity/Editor/Tools/ManageAsset.cs @@ -45,7 +45,7 @@ public static class ManageAsset public static object HandleCommand(JObject @params) { - string action = @params["action"]?.ToString().ToLower(); + string action = @params["action"]?.ToString()?.ToLowerInvariant(); if (string.IsNullOrEmpty(action)) { return new ErrorResponse("Action parameter is required."); diff --git a/MCPForUnity/Editor/Tools/ManageEditor.cs b/MCPForUnity/Editor/Tools/ManageEditor.cs index 0c9411eb8..8ae89fbab 100644 --- a/MCPForUnity/Editor/Tools/ManageEditor.cs +++ b/MCPForUnity/Editor/Tools/ManageEditor.cs @@ -24,7 +24,7 @@ public static class ManageEditor /// public static object HandleCommand(JObject @params) { - string action = @params["action"]?.ToString().ToLower(); + string action = @params["action"]?.ToString()?.ToLowerInvariant(); // Parameters for specific actions string tagName = @params["tagName"]?.ToString(); string layerName = @params["layerName"]?.ToString(); diff --git a/MCPForUnity/Editor/Tools/ManageMaterial.cs b/MCPForUnity/Editor/Tools/ManageMaterial.cs index 3904017b4..e6d7cd22e 100644 --- a/MCPForUnity/Editor/Tools/ManageMaterial.cs +++ b/MCPForUnity/Editor/Tools/ManageMaterial.cs @@ -13,7 +13,7 @@ public static class ManageMaterial { public static object HandleCommand(JObject @params) { - string action = @params["action"]?.ToString(); + string action = @params["action"]?.ToString()?.ToLowerInvariant(); if (string.IsNullOrEmpty(action)) { return new ErrorResponse("Action is required"); diff --git a/MCPForUnity/Editor/Tools/ManageShader.cs b/MCPForUnity/Editor/Tools/ManageShader.cs index 9edd2d919..67299d102 100644 --- a/MCPForUnity/Editor/Tools/ManageShader.cs +++ b/MCPForUnity/Editor/Tools/ManageShader.cs @@ -21,7 +21,7 @@ public static class ManageShader public static object HandleCommand(JObject @params) { // Extract parameters - string action = @params["action"]?.ToString().ToLower(); + string action = @params["action"]?.ToString()?.ToLowerInvariant(); string name = @params["name"]?.ToString(); string path = @params["path"]?.ToString(); // Relative to Assets/ string contents = null; diff --git a/MCPForUnity/Editor/Tools/ManageTexture.cs b/MCPForUnity/Editor/Tools/ManageTexture.cs index b2cca069d..86e429045 100644 --- a/MCPForUnity/Editor/Tools/ManageTexture.cs +++ b/MCPForUnity/Editor/Tools/ManageTexture.cs @@ -45,7 +45,7 @@ private static ErrorResponse ValidateDimensions(int width, int height, List