[FEATURE] Procedural Texture2D/Sprite Generation (#621)
* 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. * Texture Size Set Set texture size to 1024X1024 to avoid too large texture set * Add image input * Update to release direct error with large tex2d * Fix for AI advice * Update on action fetch linemain
parent
67dda7f9cc
commit
e00b2aed4f
|
|
@ -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<int>(), 0, 255);
|
||||
byte g = (byte)Mathf.Clamp(colorArray[1].ToObject<int>(), 0, 255);
|
||||
byte b = (byte)Mathf.Clamp(colorArray[2].ToObject<int>(), 0, 255);
|
||||
byte a = colorArray.Count > 3 ? (byte)Mathf.Clamp(colorArray[3].ToObject<int>(), 0, 255) : (byte)255;
|
||||
|
||||
return new Color32(r, g, b, a);
|
||||
}
|
||||
|
||||
public static List<Color32> ParsePalette(JArray paletteArray)
|
||||
{
|
||||
if (paletteArray == null)
|
||||
return null;
|
||||
|
||||
List<Color32> palette = new List<Color32>();
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 864ea682d797466a84b6b951f6c4e4ba
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -45,7 +45,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
|
||||
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.");
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
/// </summary>
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
{
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
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;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 8028b64102744ea5aad53a762d48079a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -0,0 +1,517 @@
|
|||
"""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"}
|
||||
|
||||
_MAX_TEXTURE_DIMENSION = 1024
|
||||
_MAX_TEXTURE_PIXELS = 1024 * 1024
|
||||
|
||||
|
||||
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:
|
||||
warnings.append(f"width*height should be <= {_MAX_TEXTURE_PIXELS} (got {width}x{height})")
|
||||
return warnings
|
||||
|
||||
|
||||
def _is_normalized_color(values: list[Any]) -> bool:
|
||||
if not values:
|
||||
return False
|
||||
|
||||
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_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_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:
|
||||
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")
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
_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 {}
|
||||
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] = _coerce_bool(value[snake], 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("--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",
|
||||
"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, image_path: Optional[str], 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()
|
||||
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:
|
||||
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",
|
||||
"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 and not image_path:
|
||||
# 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)
|
||||
|
||||
if image_path:
|
||||
params["imagePath"] = image_path
|
||||
|
||||
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("--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, image_path: Optional[str], 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()
|
||||
if image_path:
|
||||
if color or pattern:
|
||||
print_error("image-path cannot be combined with color or pattern.")
|
||||
sys.exit(1)
|
||||
else:
|
||||
try:
|
||||
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:
|
||||
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 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))
|
||||
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)
|
||||
|
|
@ -229,6 +229,7 @@ def register_commands():
|
|||
("cli.commands.shader", "shader"),
|
||||
("cli.commands.vfx", "vfx"),
|
||||
("cli.commands.batch", "batch"),
|
||||
("cli.commands.texture", "texture"),
|
||||
]
|
||||
|
||||
for module_name, command_name in optional_commands:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,668 @@
|
|||
"""
|
||||
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
|
||||
|
||||
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 numeric_values)
|
||||
if not all_small:
|
||||
return False
|
||||
|
||||
# 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 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 numeric_values)
|
||||
|
||||
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]:
|
||||
"""
|
||||
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,
|
||||
|
||||
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,
|
||||
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}
|
||||
|
||||
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 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}
|
||||
|
||||
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}
|
||||
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
|
||||
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"))
|
||||
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
|
||||
)
|
||||
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": pattern_size,
|
||||
"pixels": pixels_normalized,
|
||||
"imagePath": image_path,
|
||||
"gradientType": gradient_type,
|
||||
"gradientAngle": gradient_angle,
|
||||
"noiseScale": noise_scale,
|
||||
"octaves": 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)}
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
"""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."""
|
||||
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=0,
|
||||
height=64 # Non-positive dimension
|
||||
))
|
||||
|
||||
assert resp["success"] is False
|
||||
assert "positive" in resp["message"].lower()
|
||||
|
|
@ -1221,5 +1221,124 @@ class TestCodeSearchCommand:
|
|||
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"])
|
||||
|
|
|
|||
|
|
@ -912,7 +912,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "mcpforunityserver"
|
||||
version = "9.0.8"
|
||||
version = "9.2.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
|
|
|
|||
Loading…
Reference in New Issue