mcpforunityserver 9.2.0__py3-none-any.whl → 9.3.0b20260128055651__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,667 @@
1
+ """
2
+ Defines the manage_texture tool for procedural texture generation in Unity.
3
+ """
4
+ import base64
5
+ import json
6
+ from typing import Annotated, Any, Literal
7
+
8
+ from fastmcp import Context
9
+ from mcp.types import ToolAnnotations
10
+
11
+ from services.registry import mcp_for_unity_tool
12
+ from services.tools import get_unity_instance_from_context
13
+ from services.tools.utils import parse_json_payload, coerce_bool, coerce_int
14
+ from transport.unity_transport import send_with_unity_instance
15
+ from transport.legacy.unity_connection import async_send_command_with_retry
16
+ from services.tools.preflight import preflight
17
+
18
+
19
+ def _is_normalized_color(values: list) -> bool:
20
+ """
21
+ Check if color values appear to be in normalized 0.0-1.0 range.
22
+ Returns True if all values are <= 1.0 and at least one is a float or between 0-1 exclusive.
23
+ """
24
+ if not values:
25
+ return False
26
+
27
+ try:
28
+ numeric_values = [float(v) for v in values]
29
+ except (TypeError, ValueError):
30
+ return False
31
+
32
+ # Check if all values are <= 1.0
33
+ all_small = all(0 <= v <= 1.0 for v in numeric_values)
34
+ if not all_small:
35
+ return False
36
+
37
+ # If any non-zero value is less than 1, it's likely normalized (e.g., 0.5)
38
+ has_fractional = any(0 < v < 1 for v in numeric_values)
39
+
40
+ # If all values are 0 or 1, and they're all integers, could be either format
41
+ # In this ambiguous case (like [1, 0, 0, 1]), assume normalized since that's
42
+ # what graphics programmers typically use
43
+ all_binary = all(v in (0, 1, 0.0, 1.0) for v in numeric_values)
44
+
45
+ return has_fractional or all_binary
46
+
47
+
48
+ def _normalize_dimension(value: Any, name: str, default: int = 64) -> tuple[int | None, str | None]:
49
+ if value is None:
50
+ return default, None
51
+ coerced = coerce_int(value)
52
+ if coerced is None:
53
+ return None, f"{name} must be an integer"
54
+ if coerced <= 0:
55
+ return None, f"{name} must be positive"
56
+ return coerced, None
57
+
58
+
59
+ def _normalize_positive_int(value: Any, name: str) -> tuple[int | None, str | None]:
60
+ if value is None:
61
+ return None, None
62
+ coerced = coerce_int(value)
63
+ if coerced is None or coerced <= 0:
64
+ return None, f"{name} must be a positive integer"
65
+ return coerced, None
66
+
67
+
68
+ def _normalize_color(value: Any) -> tuple[list[int] | None, str | None]:
69
+ """
70
+ Normalize color parameter to [r, g, b, a] format (0-255).
71
+ Auto-detects normalized float colors (0.0-1.0) and converts to 0-255.
72
+ Returns (parsed_color, error_message).
73
+ """
74
+ if value is None:
75
+ return None, None
76
+
77
+ # Already a list - validate
78
+ if isinstance(value, (list, tuple)):
79
+ if len(value) == 3:
80
+ value = list(value) + [1.0 if _is_normalized_color(value) else 255]
81
+ if len(value) == 4:
82
+ try:
83
+ # Check if values appear to be normalized (0.0-1.0 range)
84
+ if _is_normalized_color(value):
85
+ # Convert from 0.0-1.0 to 0-255
86
+ return [int(round(float(c) * 255)) for c in value], None
87
+ else:
88
+ # Already in 0-255 range
89
+ return [int(c) for c in value], None
90
+ except (ValueError, TypeError):
91
+ return None, f"color values must be numeric, got {value}"
92
+ return None, f"color must have 3 or 4 components, got {len(value)}"
93
+
94
+ # Try parsing as string
95
+ if isinstance(value, str):
96
+ if value in ("[object Object]", "undefined", "null", ""):
97
+ return None, f"color received invalid value: '{value}'. Expected [r, g, b] or [r, g, b, a]"
98
+
99
+ # Handle Hex Colors
100
+ if value.startswith("#"):
101
+ h = value.lstrip("#")
102
+ try:
103
+ if len(h) == 6:
104
+ return [int(h[i:i+2], 16) for i in (0, 2, 4)] + [255], None
105
+ elif len(h) == 8:
106
+ return [int(h[i:i+2], 16) for i in (0, 2, 4, 6)], None
107
+ except ValueError:
108
+ return None, f"Invalid hex color: {value}"
109
+
110
+ parsed = parse_json_payload(value)
111
+ if isinstance(parsed, (list, tuple)):
112
+ if len(parsed) == 3:
113
+ parsed = list(parsed) + [1.0 if _is_normalized_color(parsed) else 255]
114
+ if len(parsed) == 4:
115
+ try:
116
+ # Check if values appear to be normalized (0.0-1.0 range)
117
+ if _is_normalized_color(parsed):
118
+ # Convert from 0.0-1.0 to 0-255
119
+ return [int(round(float(c) * 255)) for c in parsed], None
120
+ else:
121
+ # Already in 0-255 range
122
+ return [int(c) for c in parsed], None
123
+ except (ValueError, TypeError):
124
+ return None, f"color values must be numeric, got {parsed}"
125
+ return None, f"Failed to parse color string: {value}"
126
+
127
+ return None, f"color must be a list or JSON string, got {type(value).__name__}"
128
+
129
+
130
+ def _normalize_palette(value: Any) -> tuple[list[list[int]] | None, str | None]:
131
+ """
132
+ Normalize color palette to list of [r, g, b, a] colors (0-255).
133
+ Returns (parsed_palette, error_message).
134
+ """
135
+ if value is None:
136
+ return None, None
137
+
138
+ # Try parsing as string first
139
+ if isinstance(value, str):
140
+ if value in ("[object Object]", "undefined", "null", ""):
141
+ return None, f"palette received invalid value: '{value}'"
142
+ value = parse_json_payload(value)
143
+
144
+ if not isinstance(value, list):
145
+ return None, f"palette must be a list of colors, got {type(value).__name__}"
146
+
147
+ normalized = []
148
+ for i, color in enumerate(value):
149
+ parsed, error = _normalize_color(color)
150
+ if error:
151
+ return None, f"palette[{i}]: {error}"
152
+ normalized.append(parsed)
153
+
154
+ return normalized, None
155
+
156
+
157
+ def _normalize_pixels(value: Any, width: int, height: int) -> tuple[list[list[int]] | str | None, str | None]:
158
+ """
159
+ Normalize pixel data to list of [r, g, b, a] colors or base64 string.
160
+ Returns (pixels, error_message).
161
+ """
162
+ if value is None:
163
+ return None, None
164
+
165
+ # Base64 string
166
+ if isinstance(value, str):
167
+ if value.startswith("base64:"):
168
+ return value, None # Pass through for Unity to decode
169
+ # Try parsing as JSON array
170
+ parsed = parse_json_payload(value)
171
+ if isinstance(parsed, list):
172
+ value = parsed
173
+ else:
174
+ # Assume it's raw base64
175
+ return f"base64:{value}", None
176
+
177
+ if isinstance(value, list):
178
+ expected_count = width * height
179
+ if len(value) != expected_count:
180
+ return None, f"pixels array must have {expected_count} entries for {width}x{height} texture, got {len(value)}"
181
+
182
+ normalized = []
183
+ for i, pixel in enumerate(value):
184
+ parsed, error = _normalize_color(pixel)
185
+ if error:
186
+ return None, f"pixels[{i}]: {error}"
187
+ normalized.append(parsed)
188
+ return normalized, None
189
+
190
+ return None, f"pixels must be a list or base64 string, got {type(value).__name__}"
191
+
192
+
193
+ def _normalize_sprite_settings(value: Any) -> tuple[dict | None, str | None]:
194
+ """
195
+ Normalize sprite settings.
196
+ Returns (settings, error_message).
197
+ """
198
+ if value is None:
199
+ return None, None
200
+
201
+ if isinstance(value, str):
202
+ value = parse_json_payload(value)
203
+
204
+ if isinstance(value, dict):
205
+ result = {}
206
+ if "pivot" in value:
207
+ pivot = value["pivot"]
208
+ if isinstance(pivot, (list, tuple)) and len(pivot) == 2:
209
+ result["pivot"] = [float(pivot[0]), float(pivot[1])]
210
+ else:
211
+ return None, f"sprite pivot must be [x, y], got {pivot}"
212
+ if "pixels_per_unit" in value:
213
+ result["pixelsPerUnit"] = float(value["pixels_per_unit"])
214
+ elif "pixelsPerUnit" in value:
215
+ result["pixelsPerUnit"] = float(value["pixelsPerUnit"])
216
+ return result, None
217
+
218
+ if isinstance(value, bool) and value:
219
+ # Just enable sprite mode with defaults
220
+ return {"pivot": [0.5, 0.5], "pixelsPerUnit": 100}, None
221
+
222
+ return None, f"as_sprite must be a dict or boolean, got {type(value).__name__}"
223
+
224
+
225
+ # Valid values for import settings enums
226
+ _TEXTURE_TYPES = {
227
+ "default": "Default",
228
+ "normal_map": "NormalMap",
229
+ "editor_gui": "GUI",
230
+ "sprite": "Sprite",
231
+ "cursor": "Cursor",
232
+ "cookie": "Cookie",
233
+ "lightmap": "Lightmap",
234
+ "directional_lightmap": "DirectionalLightmap",
235
+ "shadow_mask": "Shadowmask",
236
+ "single_channel": "SingleChannel",
237
+ }
238
+
239
+ _TEXTURE_SHAPES = {"2d": "Texture2D", "cube": "TextureCube"}
240
+
241
+ _ALPHA_SOURCES = {
242
+ "none": "None",
243
+ "from_input": "FromInput",
244
+ "from_gray_scale": "FromGrayScale",
245
+ }
246
+
247
+ _WRAP_MODES = {
248
+ "repeat": "Repeat",
249
+ "clamp": "Clamp",
250
+ "mirror": "Mirror",
251
+ "mirror_once": "MirrorOnce",
252
+ }
253
+
254
+ _FILTER_MODES = {"point": "Point", "bilinear": "Bilinear", "trilinear": "Trilinear"}
255
+
256
+ _COMPRESSIONS = {
257
+ "none": "Uncompressed",
258
+ "low_quality": "CompressedLQ",
259
+ "normal_quality": "Compressed",
260
+ "high_quality": "CompressedHQ",
261
+ }
262
+
263
+ _SPRITE_MODES = {"single": "Single", "multiple": "Multiple", "polygon": "Polygon"}
264
+
265
+ _SPRITE_MESH_TYPES = {"full_rect": "FullRect", "tight": "Tight"}
266
+
267
+ _MIPMAP_FILTERS = {"box": "BoxFilter", "kaiser": "KaiserFilter"}
268
+
269
+
270
+ def _normalize_bool_setting(value: Any, name: str) -> tuple[bool | None, str | None]:
271
+ """
272
+ Normalize boolean settings.
273
+ Returns (bool_value, error_message).
274
+ """
275
+ if value is None:
276
+ return None, None
277
+
278
+ if isinstance(value, bool):
279
+ return value, None
280
+
281
+ if isinstance(value, (int, float)):
282
+ if value in (0, 1, 0.0, 1.0):
283
+ return bool(value), None
284
+ return None, f"{name} must be a boolean"
285
+
286
+ if isinstance(value, str):
287
+ coerced = coerce_bool(value, default=None)
288
+ if coerced is None:
289
+ return None, f"{name} must be a boolean"
290
+ return coerced, None
291
+
292
+ return None, f"{name} must be a boolean"
293
+
294
+
295
+ def _normalize_import_settings(value: Any) -> tuple[dict | None, str | None]:
296
+ """
297
+ Normalize TextureImporter settings.
298
+ Converts snake_case keys to camelCase and validates enum values.
299
+ Returns (settings, error_message).
300
+ """
301
+ if value is None:
302
+ return None, None
303
+
304
+ if isinstance(value, str):
305
+ value = parse_json_payload(value)
306
+
307
+ if not isinstance(value, dict):
308
+ return None, f"import_settings must be a dict, got {type(value).__name__}"
309
+
310
+ result = {}
311
+
312
+ # Texture type
313
+ if "texture_type" in value:
314
+ tt = value["texture_type"].lower() if isinstance(value["texture_type"], str) else value["texture_type"]
315
+ if tt not in _TEXTURE_TYPES:
316
+ return None, f"Invalid texture_type '{tt}'. Valid: {list(_TEXTURE_TYPES.keys())}"
317
+ result["textureType"] = _TEXTURE_TYPES[tt]
318
+
319
+ # Texture shape
320
+ if "texture_shape" in value:
321
+ ts = value["texture_shape"].lower() if isinstance(value["texture_shape"], str) else value["texture_shape"]
322
+ if ts not in _TEXTURE_SHAPES:
323
+ return None, f"Invalid texture_shape '{ts}'. Valid: {list(_TEXTURE_SHAPES.keys())}"
324
+ result["textureShape"] = _TEXTURE_SHAPES[ts]
325
+
326
+ # Boolean settings
327
+ for snake, camel in [
328
+ ("srgb", "sRGBTexture"),
329
+ ("alpha_is_transparency", "alphaIsTransparency"),
330
+ ("readable", "isReadable"),
331
+ ("generate_mipmaps", "mipmapEnabled"),
332
+ ("compression_crunched", "crunchedCompression"),
333
+ ]:
334
+ if snake in value:
335
+ bool_value, bool_error = _normalize_bool_setting(value[snake], snake)
336
+ if bool_error:
337
+ return None, bool_error
338
+ if bool_value is not None:
339
+ result[camel] = bool_value
340
+
341
+ # Alpha source
342
+ if "alpha_source" in value:
343
+ alpha = value["alpha_source"].lower() if isinstance(value["alpha_source"], str) else value["alpha_source"]
344
+ if alpha not in _ALPHA_SOURCES:
345
+ return None, f"Invalid alpha_source '{alpha}'. Valid: {list(_ALPHA_SOURCES.keys())}"
346
+ result["alphaSource"] = _ALPHA_SOURCES[alpha]
347
+
348
+ # Wrap modes
349
+ for snake, camel in [("wrap_mode", "wrapMode"), ("wrap_mode_u", "wrapModeU"), ("wrap_mode_v", "wrapModeV")]:
350
+ if snake in value:
351
+ wm = value[snake].lower() if isinstance(value[snake], str) else value[snake]
352
+ if wm not in _WRAP_MODES:
353
+ return None, f"Invalid {snake} '{wm}'. Valid: {list(_WRAP_MODES.keys())}"
354
+ result[camel] = _WRAP_MODES[wm]
355
+
356
+ # Filter mode
357
+ if "filter_mode" in value:
358
+ fm = value["filter_mode"].lower() if isinstance(value["filter_mode"], str) else value["filter_mode"]
359
+ if fm not in _FILTER_MODES:
360
+ return None, f"Invalid filter_mode '{fm}'. Valid: {list(_FILTER_MODES.keys())}"
361
+ result["filterMode"] = _FILTER_MODES[fm]
362
+
363
+ # Mipmap filter
364
+ if "mipmap_filter" in value:
365
+ mf = value["mipmap_filter"].lower() if isinstance(value["mipmap_filter"], str) else value["mipmap_filter"]
366
+ if mf not in _MIPMAP_FILTERS:
367
+ return None, f"Invalid mipmap_filter '{mf}'. Valid: {list(_MIPMAP_FILTERS.keys())}"
368
+ result["mipmapFilter"] = _MIPMAP_FILTERS[mf]
369
+
370
+ # Compression
371
+ if "compression" in value:
372
+ comp = value["compression"].lower() if isinstance(value["compression"], str) else value["compression"]
373
+ if comp not in _COMPRESSIONS:
374
+ return None, f"Invalid compression '{comp}'. Valid: {list(_COMPRESSIONS.keys())}"
375
+ result["textureCompression"] = _COMPRESSIONS[comp]
376
+
377
+ # Integer settings
378
+ if "aniso_level" in value:
379
+ raw = value["aniso_level"]
380
+ level = coerce_int(raw)
381
+ if level is None:
382
+ if raw is not None:
383
+ return None, f"aniso_level must be an integer, got {raw}"
384
+ else:
385
+ if not 0 <= level <= 16:
386
+ return None, f"aniso_level must be 0-16, got {level}"
387
+ result["anisoLevel"] = level
388
+
389
+ if "max_texture_size" in value:
390
+ raw = value["max_texture_size"]
391
+ size = coerce_int(raw)
392
+ if size is None:
393
+ if raw is not None:
394
+ return None, f"max_texture_size must be an integer, got {raw}"
395
+ else:
396
+ valid_sizes = [32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384]
397
+ if size not in valid_sizes:
398
+ return None, f"max_texture_size must be one of {valid_sizes}, got {size}"
399
+ result["maxTextureSize"] = size
400
+
401
+ if "compression_quality" in value:
402
+ raw = value["compression_quality"]
403
+ quality = coerce_int(raw)
404
+ if quality is None:
405
+ if raw is not None:
406
+ return None, f"compression_quality must be an integer, got {raw}"
407
+ else:
408
+ if not 0 <= quality <= 100:
409
+ return None, f"compression_quality must be 0-100, got {quality}"
410
+ result["compressionQuality"] = quality
411
+
412
+ # Sprite-specific settings
413
+ if "sprite_mode" in value:
414
+ sm = value["sprite_mode"].lower() if isinstance(value["sprite_mode"], str) else value["sprite_mode"]
415
+ if sm not in _SPRITE_MODES:
416
+ return None, f"Invalid sprite_mode '{sm}'. Valid: {list(_SPRITE_MODES.keys())}"
417
+ result["spriteImportMode"] = _SPRITE_MODES[sm]
418
+
419
+ if "sprite_pixels_per_unit" in value:
420
+ raw = value["sprite_pixels_per_unit"]
421
+ try:
422
+ result["spritePixelsPerUnit"] = float(raw)
423
+ except (TypeError, ValueError):
424
+ return None, f"sprite_pixels_per_unit must be a number, got {raw}"
425
+
426
+ if "sprite_pivot" in value:
427
+ pivot = value["sprite_pivot"]
428
+ if isinstance(pivot, (list, tuple)) and len(pivot) == 2:
429
+ result["spritePivot"] = [float(pivot[0]), float(pivot[1])]
430
+ else:
431
+ return None, f"sprite_pivot must be [x, y], got {pivot}"
432
+
433
+ if "sprite_mesh_type" in value:
434
+ mt = value["sprite_mesh_type"].lower() if isinstance(value["sprite_mesh_type"], str) else value["sprite_mesh_type"]
435
+ if mt not in _SPRITE_MESH_TYPES:
436
+ return None, f"Invalid sprite_mesh_type '{mt}'. Valid: {list(_SPRITE_MESH_TYPES.keys())}"
437
+ result["spriteMeshType"] = _SPRITE_MESH_TYPES[mt]
438
+
439
+ if "sprite_extrude" in value:
440
+ raw = value["sprite_extrude"]
441
+ extrude = coerce_int(raw)
442
+ if extrude is None:
443
+ if raw is not None:
444
+ return None, f"sprite_extrude must be an integer, got {raw}"
445
+ else:
446
+ if not 0 <= extrude <= 32:
447
+ return None, f"sprite_extrude must be 0-32, got {extrude}"
448
+ result["spriteExtrude"] = extrude
449
+
450
+ return result, None
451
+
452
+
453
+ @mcp_for_unity_tool(
454
+ description=(
455
+ "Procedural texture generation for Unity. Creates textures with solid fills, "
456
+ "patterns (checkerboard, stripes, dots, grid, brick), gradients, and noise. "
457
+ "Actions: create, modify, delete, create_sprite, apply_pattern, apply_gradient, apply_noise"
458
+ ),
459
+ annotations=ToolAnnotations(
460
+ title="Manage Texture",
461
+ destructiveHint=True,
462
+ ),
463
+ )
464
+ async def manage_texture(
465
+ ctx: Context,
466
+ action: Annotated[Literal[
467
+ "create",
468
+ "modify",
469
+ "delete",
470
+ "create_sprite",
471
+ "apply_pattern",
472
+ "apply_gradient",
473
+ "apply_noise"
474
+ ], "Action to perform."],
475
+
476
+ # Required for most actions
477
+ path: Annotated[str,
478
+ "Output texture path (e.g., 'Assets/Textures/MyTexture.png')"] | None = None,
479
+
480
+ # Dimensions (defaults to 64x64)
481
+ width: Annotated[int, "Texture width in pixels (default: 64)"] | None = None,
482
+ height: Annotated[int, "Texture height in pixels (default: 64)"] | None = None,
483
+
484
+ # Solid fill (accepts both 0-255 integers and 0.0-1.0 normalized floats)
485
+ fill_color: Annotated[list[int | float],
486
+ "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,
487
+
488
+ # Pattern-based generation
489
+ pattern: Annotated[Literal[
490
+ "checkerboard", "stripes", "stripes_h", "stripes_v", "stripes_diag",
491
+ "dots", "grid", "brick"
492
+ ], "Pattern type for apply_pattern action"] | None = None,
493
+
494
+ palette: Annotated[list[list[int | float]],
495
+ "Color palette as [[r,g,b,a], ...]. Accepts both 0-255 range or 0.0-1.0 normalized range"] | None = None,
496
+
497
+ pattern_size: Annotated[int,
498
+ "Pattern cell size in pixels (default: 8)"] | None = None,
499
+
500
+ # Direct pixel data
501
+ pixels: Annotated[list[list[int]] | str,
502
+ "Pixel data as JSON array of [r,g,b,a] values or base64 string"] | None = None,
503
+
504
+ image_path: Annotated[str,
505
+ "Source image file path for create/create_sprite (PNG/JPG)."] | None = None,
506
+
507
+ # Gradient settings
508
+ gradient_type: Annotated[Literal["linear", "radial"],
509
+ "Gradient type (default: linear)"] | None = None,
510
+ gradient_angle: Annotated[float,
511
+ "Gradient angle in degrees for linear gradient (default: 0)"] | None = None,
512
+
513
+ # Noise settings
514
+ noise_scale: Annotated[float,
515
+ "Noise scale/frequency (default: 0.1)"] | None = None,
516
+ octaves: Annotated[int,
517
+ "Number of noise octaves for detail (default: 1)"] | None = None,
518
+
519
+ # Modify action
520
+ set_pixels: Annotated[dict,
521
+ "Region to modify: {x, y, width, height, color or pixels}"] | None = None,
522
+
523
+ # Sprite creation (legacy, prefer import_settings)
524
+ as_sprite: Annotated[dict | bool,
525
+ "Configure as sprite: {pivot: [x,y], pixels_per_unit: 100} or true for defaults"] | None = None,
526
+
527
+ # TextureImporter settings
528
+ import_settings: Annotated[dict,
529
+ "TextureImporter settings dict. Keys: texture_type (default/normal_map/sprite/etc), "
530
+ "texture_shape (2d/cube), srgb (bool), alpha_source (none/from_input/from_gray_scale), "
531
+ "alpha_is_transparency (bool), readable (bool), generate_mipmaps (bool), "
532
+ "wrap_mode/wrap_mode_u/wrap_mode_v (repeat/clamp/mirror/mirror_once), "
533
+ "filter_mode (point/bilinear/trilinear), aniso_level (0-16), max_texture_size (32-16384), "
534
+ "compression (none/low_quality/normal_quality/high_quality), compression_quality (0-100), "
535
+ "sprite_mode (single/multiple/polygon), sprite_pixels_per_unit, sprite_pivot, "
536
+ "sprite_mesh_type (full_rect/tight), sprite_extrude (0-32)"] | None = None,
537
+
538
+ ) -> dict[str, Any]:
539
+ unity_instance = get_unity_instance_from_context(ctx)
540
+
541
+ # Preflight check
542
+ gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
543
+ if gate is not None:
544
+ return gate.model_dump()
545
+
546
+ # --- Normalize parameters ---
547
+ fill_color, fill_error = _normalize_color(fill_color)
548
+ if fill_error:
549
+ return {"success": False, "message": fill_error}
550
+
551
+ action_lower = action.lower()
552
+
553
+ if image_path is not None and action_lower not in ("create", "create_sprite"):
554
+ return {"success": False, "message": "image_path is only supported for create/create_sprite."}
555
+
556
+ if image_path is not None and (fill_color is not None or pattern is not None or pixels is not None):
557
+ return {"success": False, "message": "image_path cannot be combined with fill_color, pattern, or pixels."}
558
+
559
+ # Default to white for create action if nothing else specified
560
+ if action == "create" and fill_color is None and pattern is None and pixels is None and image_path is None:
561
+ fill_color = [255, 255, 255, 255]
562
+
563
+ palette, palette_error = _normalize_palette(palette)
564
+ if palette_error:
565
+ return {"success": False, "message": palette_error}
566
+
567
+ if image_path is None:
568
+ # Normalize dimensions
569
+ width, width_error = _normalize_dimension(width, "width")
570
+ if width_error:
571
+ return {"success": False, "message": width_error}
572
+ height, height_error = _normalize_dimension(height, "height")
573
+ if height_error:
574
+ return {"success": False, "message": height_error}
575
+ pattern_size, pattern_error = _normalize_positive_int(pattern_size, "pattern_size")
576
+ if pattern_error:
577
+ return {"success": False, "message": pattern_error}
578
+
579
+ octaves, octaves_error = _normalize_positive_int(octaves, "octaves")
580
+ if octaves_error:
581
+ return {"success": False, "message": octaves_error}
582
+ else:
583
+ width = None
584
+ height = None
585
+
586
+ # Normalize pixels if provided
587
+ pixels_normalized = None
588
+ if pixels is not None:
589
+ pixels_normalized, pixels_error = _normalize_pixels(pixels, width, height)
590
+ if pixels_error:
591
+ return {"success": False, "message": pixels_error}
592
+
593
+ # Normalize sprite settings
594
+ sprite_settings, sprite_error = _normalize_sprite_settings(as_sprite)
595
+ if sprite_error:
596
+ return {"success": False, "message": sprite_error}
597
+
598
+ # Normalize import settings
599
+ import_settings_normalized, import_error = _normalize_import_settings(import_settings)
600
+ if import_error:
601
+ return {"success": False, "message": import_error}
602
+
603
+ # Normalize set_pixels for modify action
604
+ set_pixels_normalized = None
605
+ if set_pixels is not None:
606
+ if isinstance(set_pixels, str):
607
+ parsed = parse_json_payload(set_pixels)
608
+ if not isinstance(parsed, dict):
609
+ return {"success": False, "message": "set_pixels must be a JSON object"}
610
+ set_pixels = parsed
611
+ if not isinstance(set_pixels, dict):
612
+ return {"success": False, "message": "set_pixels must be a JSON object"}
613
+
614
+ set_pixels_normalized = set_pixels.copy()
615
+ if "color" in set_pixels_normalized:
616
+ color, error = _normalize_color(set_pixels_normalized["color"])
617
+ if error:
618
+ return {"success": False, "message": f"set_pixels.color: {error}"}
619
+ set_pixels_normalized["color"] = color
620
+ if "pixels" in set_pixels_normalized:
621
+ region_width = coerce_int(set_pixels_normalized.get("width"))
622
+ region_height = coerce_int(set_pixels_normalized.get("height"))
623
+ if region_width is None or region_height is None or region_width <= 0 or region_height <= 0:
624
+ return {"success": False, "message": "set_pixels width and height must be positive integers"}
625
+ pixels_normalized, pixels_error = _normalize_pixels(
626
+ set_pixels_normalized["pixels"], region_width, region_height
627
+ )
628
+ if pixels_error:
629
+ return {"success": False, "message": f"set_pixels.pixels: {pixels_error}"}
630
+ set_pixels_normalized["pixels"] = pixels_normalized
631
+
632
+ # --- Build params for Unity ---
633
+ params_dict = {
634
+ "action": action.lower(),
635
+ "path": path,
636
+ "width": width,
637
+ "height": height,
638
+ "fillColor": fill_color,
639
+ "pattern": pattern,
640
+ "palette": palette,
641
+ "patternSize": pattern_size,
642
+ "pixels": pixels_normalized,
643
+ "imagePath": image_path,
644
+ "gradientType": gradient_type,
645
+ "gradientAngle": gradient_angle,
646
+ "noiseScale": noise_scale,
647
+ "octaves": octaves,
648
+ "setPixels": set_pixels_normalized,
649
+ "spriteSettings": sprite_settings,
650
+ "importSettings": import_settings_normalized,
651
+ }
652
+
653
+ # Remove None values
654
+ params_dict = {k: v for k, v in params_dict.items() if v is not None}
655
+
656
+ # Send to Unity
657
+ result = await send_with_unity_instance(
658
+ async_send_command_with_retry,
659
+ unity_instance,
660
+ "manage_texture",
661
+ params_dict,
662
+ )
663
+
664
+ if isinstance(result, dict):
665
+ result["_debug_params"] = params_dict
666
+
667
+ return result if isinstance(result, dict) else {"success": False, "message": str(result)}