[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 line
main
Shutong Wu 2026-01-24 17:09:07 -05:00 committed by GitHub
parent 67dda7f9cc
commit e00b2aed4f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 2797 additions and 6 deletions

View File

@ -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}");
}
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 864ea682d797466a84b6b951f6c4e4ba
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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.");

View File

@ -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();

View File

@ -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");

View File

@ -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

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8028b64102744ea5aad53a762d48079a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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`

View File

@ -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)

View File

@ -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:

View File

@ -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)}

View File

@ -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()

View File

@ -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"])

View File

@ -912,7 +912,7 @@ wheels = [
[[package]]
name = "mcpforunityserver"
version = "9.0.8"
version = "9.2.0"
source = { editable = "." }
dependencies = [
{ name = "click" },