mcpforunityserver 9.3.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,538 @@
1
+ """Texture CLI commands."""
2
+
3
+ import sys
4
+ import json
5
+ import click
6
+ from typing import Optional, Any
7
+
8
+ from cli.utils.config import get_config
9
+ from cli.utils.output import format_output, print_error, print_success
10
+ from cli.utils.connection import run_command, UnityConnectionError
11
+
12
+
13
+ def try_parse_json(value: str, context: str) -> Any:
14
+ """Try to parse JSON, with fallback for single quotes and Python bools."""
15
+ try:
16
+ return json.loads(value)
17
+ except json.JSONDecodeError:
18
+ # Try to fix common shell quoting issues (single quotes, Python bools)
19
+ try:
20
+ fixed = value.replace("'", '"').replace(
21
+ "True", "true").replace("False", "false")
22
+ return json.loads(fixed)
23
+ except json.JSONDecodeError as e:
24
+ print_error(f"Invalid JSON for {context}: {e}")
25
+ sys.exit(1)
26
+
27
+
28
+ _TEXTURE_TYPES = {
29
+ "default": "Default",
30
+ "normal_map": "NormalMap",
31
+ "editor_gui": "GUI",
32
+ "sprite": "Sprite",
33
+ "cursor": "Cursor",
34
+ "cookie": "Cookie",
35
+ "lightmap": "Lightmap",
36
+ "directional_lightmap": "DirectionalLightmap",
37
+ "shadow_mask": "Shadowmask",
38
+ "single_channel": "SingleChannel",
39
+ }
40
+
41
+ _TEXTURE_SHAPES = {"2d": "Texture2D", "cube": "TextureCube"}
42
+
43
+ _ALPHA_SOURCES = {
44
+ "none": "None",
45
+ "from_input": "FromInput",
46
+ "from_gray_scale": "FromGrayScale",
47
+ }
48
+
49
+ _WRAP_MODES = {
50
+ "repeat": "Repeat",
51
+ "clamp": "Clamp",
52
+ "mirror": "Mirror",
53
+ "mirror_once": "MirrorOnce",
54
+ }
55
+
56
+ _FILTER_MODES = {"point": "Point",
57
+ "bilinear": "Bilinear", "trilinear": "Trilinear"}
58
+
59
+ _COMPRESSIONS = {
60
+ "none": "Uncompressed",
61
+ "low_quality": "CompressedLQ",
62
+ "normal_quality": "Compressed",
63
+ "high_quality": "CompressedHQ",
64
+ }
65
+
66
+ _SPRITE_MODES = {"single": "Single",
67
+ "multiple": "Multiple", "polygon": "Polygon"}
68
+
69
+ _SPRITE_MESH_TYPES = {"full_rect": "FullRect", "tight": "Tight"}
70
+
71
+ _MIPMAP_FILTERS = {"box": "BoxFilter", "kaiser": "KaiserFilter"}
72
+
73
+ _MAX_TEXTURE_DIMENSION = 1024
74
+ _MAX_TEXTURE_PIXELS = 1024 * 1024
75
+
76
+
77
+ def _validate_texture_dimensions(width: int, height: int) -> list[str]:
78
+ if width <= 0 or height <= 0:
79
+ raise ValueError("width and height must be positive")
80
+ warnings: list[str] = []
81
+ if width > _MAX_TEXTURE_DIMENSION or height > _MAX_TEXTURE_DIMENSION:
82
+ warnings.append(
83
+ f"width and height should be <= {_MAX_TEXTURE_DIMENSION} (got {width}x{height})")
84
+ total_pixels = width * height
85
+ if total_pixels > _MAX_TEXTURE_PIXELS:
86
+ warnings.append(
87
+ f"width*height should be <= {_MAX_TEXTURE_PIXELS} (got {width}x{height})")
88
+ return warnings
89
+
90
+
91
+ def _is_normalized_color(values: list[Any]) -> bool:
92
+ if not values:
93
+ return False
94
+
95
+ try:
96
+ numeric_values = [float(v) for v in values]
97
+ except (TypeError, ValueError):
98
+ return False
99
+
100
+ all_small = all(0 <= v <= 1.0 for v in numeric_values)
101
+ if not all_small:
102
+ return False
103
+
104
+ has_fractional = any(0 < v < 1 for v in numeric_values)
105
+ all_binary = all(v in (0, 1, 0.0, 1.0) for v in numeric_values)
106
+
107
+ return has_fractional or all_binary
108
+
109
+
110
+ def _parse_hex_color(value: str) -> list[int]:
111
+ h = value.lstrip("#")
112
+ if len(h) == 6:
113
+ return [int(h[i:i + 2], 16) for i in (0, 2, 4)] + [255]
114
+ if len(h) == 8:
115
+ return [int(h[i:i + 2], 16) for i in (0, 2, 4, 6)]
116
+ raise ValueError(f"Invalid hex color: {value}")
117
+
118
+
119
+ def _normalize_color(value: Any, context: str) -> list[int]:
120
+ if value is None:
121
+ raise ValueError(f"{context} is required")
122
+
123
+ if isinstance(value, str):
124
+ if value.startswith("#"):
125
+ return _parse_hex_color(value)
126
+ value = try_parse_json(value, context)
127
+
128
+ if isinstance(value, (list, tuple)):
129
+ if len(value) == 3:
130
+ value = list(value) + [1.0 if _is_normalized_color(value) else 255]
131
+ if len(value) == 4:
132
+ try:
133
+ if _is_normalized_color(value):
134
+ return [int(round(float(c) * 255)) for c in value]
135
+ return [int(c) for c in value]
136
+ except (TypeError, ValueError):
137
+ raise ValueError(
138
+ f"{context} values must be numeric, got {value}")
139
+ raise ValueError(
140
+ f"{context} must have 3 or 4 components, got {len(value)}")
141
+
142
+ raise ValueError(f"{context} must be a list or hex string")
143
+
144
+
145
+ def _normalize_palette(value: Any, context: str) -> list[list[int]]:
146
+ if value is None:
147
+ return []
148
+ if isinstance(value, str):
149
+ value = try_parse_json(value, context)
150
+ if not isinstance(value, list):
151
+ raise ValueError(f"{context} must be a list of colors")
152
+ return [_normalize_color(color, f"{context} item") for color in value]
153
+
154
+
155
+ def _normalize_pixels(value: Any, width: int, height: int, context: str) -> list[list[int]] | str:
156
+ if value is None:
157
+ raise ValueError(f"{context} is required")
158
+ if isinstance(value, str):
159
+ if value.startswith("base64:"):
160
+ return value
161
+ trimmed = value.strip()
162
+ if trimmed.startswith("[") and trimmed.endswith("]"):
163
+ value = try_parse_json(trimmed, context)
164
+ else:
165
+ return f"base64:{value}"
166
+ if isinstance(value, list):
167
+ expected_count = width * height
168
+ if len(value) != expected_count:
169
+ raise ValueError(
170
+ f"{context} must have {expected_count} entries, got {len(value)}")
171
+ return [_normalize_color(pixel, f"{context} pixel") for pixel in value]
172
+ raise ValueError(f"{context} must be a list or base64 string")
173
+
174
+
175
+ def _normalize_set_pixels(value: Any) -> dict[str, Any]:
176
+ if value is None:
177
+ raise ValueError("set-pixels is required")
178
+ if isinstance(value, str):
179
+ value = try_parse_json(value, "set-pixels")
180
+ if not isinstance(value, dict):
181
+ raise ValueError("set-pixels must be a JSON object")
182
+
183
+ result: dict[str, Any] = dict(value)
184
+
185
+ if "pixels" in value:
186
+ width = value.get("width")
187
+ height = value.get("height")
188
+ if width is None or height is None:
189
+ raise ValueError(
190
+ "set-pixels requires width and height when pixels are provided")
191
+ width = int(width)
192
+ height = int(height)
193
+ if width <= 0 or height <= 0:
194
+ raise ValueError("set-pixels width and height must be positive")
195
+ result["width"] = width
196
+ result["height"] = height
197
+ result["pixels"] = _normalize_pixels(
198
+ value["pixels"], width, height, "set-pixels pixels")
199
+
200
+ if "color" in value:
201
+ result["color"] = _normalize_color(value["color"], "set-pixels color")
202
+
203
+ if "pixels" not in value and "color" not in value:
204
+ raise ValueError("set-pixels requires 'color' or 'pixels'")
205
+
206
+ if "x" in value:
207
+ result["x"] = int(value["x"])
208
+ if "y" in value:
209
+ result["y"] = int(value["y"])
210
+
211
+ if "width" in value and "pixels" not in value:
212
+ result["width"] = int(value["width"])
213
+ if "height" in value and "pixels" not in value:
214
+ result["height"] = int(value["height"])
215
+
216
+ return result
217
+
218
+
219
+ def _map_enum(value: Any, mapping: dict[str, str]) -> Any:
220
+ if isinstance(value, str):
221
+ key = value.lower()
222
+ return mapping.get(key, value)
223
+ return value
224
+
225
+
226
+ _TRUE_STRINGS = {"true", "1", "yes", "on"}
227
+ _FALSE_STRINGS = {"false", "0", "no", "off"}
228
+
229
+
230
+ def _coerce_bool(value: Any, name: str) -> bool:
231
+ if isinstance(value, bool):
232
+ return value
233
+ if isinstance(value, (int, float)) and value in (0, 1, 0.0, 1.0):
234
+ return bool(value)
235
+ if isinstance(value, str):
236
+ lowered = value.strip().lower()
237
+ if lowered in _TRUE_STRINGS:
238
+ return True
239
+ if lowered in _FALSE_STRINGS:
240
+ return False
241
+ raise ValueError(f"{name} must be a boolean")
242
+
243
+
244
+ def _normalize_import_settings(value: Any) -> dict[str, Any]:
245
+ if value is None:
246
+ return {}
247
+ if isinstance(value, str):
248
+ value = try_parse_json(value, "import_settings")
249
+ if not isinstance(value, dict):
250
+ raise ValueError("import_settings must be a JSON object")
251
+
252
+ result: dict[str, Any] = {}
253
+
254
+ if "texture_type" in value:
255
+ result["textureType"] = _map_enum(
256
+ value["texture_type"], _TEXTURE_TYPES)
257
+ if "texture_shape" in value:
258
+ result["textureShape"] = _map_enum(
259
+ value["texture_shape"], _TEXTURE_SHAPES)
260
+
261
+ for snake, camel in [
262
+ ("srgb", "sRGBTexture"),
263
+ ("alpha_is_transparency", "alphaIsTransparency"),
264
+ ("readable", "isReadable"),
265
+ ("generate_mipmaps", "mipmapEnabled"),
266
+ ("compression_crunched", "crunchedCompression"),
267
+ ]:
268
+ if snake in value:
269
+ result[camel] = _coerce_bool(value[snake], snake)
270
+
271
+ if "alpha_source" in value:
272
+ result["alphaSource"] = _map_enum(
273
+ value["alpha_source"], _ALPHA_SOURCES)
274
+
275
+ for snake, camel in [("wrap_mode", "wrapMode"), ("wrap_mode_u", "wrapModeU"), ("wrap_mode_v", "wrapModeV")]:
276
+ if snake in value:
277
+ result[camel] = _map_enum(value[snake], _WRAP_MODES)
278
+
279
+ if "filter_mode" in value:
280
+ result["filterMode"] = _map_enum(value["filter_mode"], _FILTER_MODES)
281
+ if "mipmap_filter" in value:
282
+ result["mipmapFilter"] = _map_enum(
283
+ value["mipmap_filter"], _MIPMAP_FILTERS)
284
+ if "compression" in value:
285
+ result["textureCompression"] = _map_enum(
286
+ value["compression"], _COMPRESSIONS)
287
+
288
+ if "aniso_level" in value:
289
+ result["anisoLevel"] = int(value["aniso_level"])
290
+ if "max_texture_size" in value:
291
+ result["maxTextureSize"] = int(value["max_texture_size"])
292
+ if "compression_quality" in value:
293
+ result["compressionQuality"] = int(value["compression_quality"])
294
+
295
+ if "sprite_mode" in value:
296
+ result["spriteImportMode"] = _map_enum(
297
+ value["sprite_mode"], _SPRITE_MODES)
298
+ if "sprite_pixels_per_unit" in value:
299
+ result["spritePixelsPerUnit"] = float(value["sprite_pixels_per_unit"])
300
+ if "sprite_pivot" in value:
301
+ result["spritePivot"] = value["sprite_pivot"]
302
+ if "sprite_mesh_type" in value:
303
+ result["spriteMeshType"] = _map_enum(
304
+ value["sprite_mesh_type"], _SPRITE_MESH_TYPES)
305
+ if "sprite_extrude" in value:
306
+ result["spriteExtrude"] = int(value["sprite_extrude"])
307
+
308
+ for key, val in value.items():
309
+ if key in result:
310
+ continue
311
+ if key in (
312
+ "textureType", "textureShape", "sRGBTexture", "alphaSource",
313
+ "alphaIsTransparency", "isReadable", "mipmapEnabled", "wrapMode",
314
+ "wrapModeU", "wrapModeV", "filterMode", "mipmapFilter", "anisoLevel",
315
+ "maxTextureSize", "textureCompression", "crunchedCompression",
316
+ "compressionQuality", "spriteImportMode", "spritePixelsPerUnit",
317
+ "spritePivot", "spriteMeshType", "spriteExtrude",
318
+ ):
319
+ result[key] = val
320
+
321
+ return result
322
+
323
+
324
+ @click.group()
325
+ def texture():
326
+ """Texture operations - create, modify, generate sprites."""
327
+ pass
328
+
329
+
330
+ @texture.command("create")
331
+ @click.argument("path")
332
+ @click.option("--width", default=64, help="Texture width (default: 64)")
333
+ @click.option("--height", default=64, help="Texture height (default: 64)")
334
+ @click.option("--image-path", help="Source image path (PNG/JPG) to import.")
335
+ @click.option("--color", help="Fill color (e.g., '#FF0000' or '[1,0,0,1]')")
336
+ @click.option("--pattern", type=click.Choice([
337
+ "checkerboard", "stripes", "stripes_h", "stripes_v", "stripes_diag",
338
+ "dots", "grid", "brick"
339
+ ]), help="Pattern type")
340
+ @click.option("--palette", help="Color palette for pattern (JSON array of colors)")
341
+ @click.option("--import-settings", help="TextureImporter settings (JSON)")
342
+ def create(path: str, width: int, height: int, image_path: Optional[str], color: Optional[str],
343
+ pattern: Optional[str], palette: Optional[str], import_settings: Optional[str]):
344
+ """Create a new procedural texture.
345
+
346
+ \b
347
+ Examples:
348
+ unity-mcp texture create Assets/Red.png --color '[255,0,0]'
349
+ unity-mcp texture create Assets/Check.png --pattern checkerboard
350
+ unity-mcp texture create Assets/UI.png --import-settings '{"texture_type": "sprite"}'
351
+ """
352
+ config = get_config()
353
+ if image_path:
354
+ if color or pattern or palette:
355
+ print_error(
356
+ "image-path cannot be combined with color, pattern, or palette.")
357
+ sys.exit(1)
358
+ else:
359
+ try:
360
+ warnings = _validate_texture_dimensions(width, height)
361
+ except ValueError as e:
362
+ print_error(str(e))
363
+ sys.exit(1)
364
+ for warning in warnings:
365
+ click.echo(f"⚠️ Warning: {warning}")
366
+
367
+ params: dict[str, Any] = {
368
+ "action": "create",
369
+ "path": path,
370
+ "width": width,
371
+ "height": height,
372
+ }
373
+
374
+ if color:
375
+ try:
376
+ params["fillColor"] = _normalize_color(color, "color")
377
+ except ValueError as e:
378
+ print_error(str(e))
379
+ sys.exit(1)
380
+ elif not pattern and not image_path:
381
+ # Default to white if no color or pattern specified
382
+ params["fillColor"] = [255, 255, 255, 255]
383
+
384
+ if pattern:
385
+ params["pattern"] = pattern
386
+
387
+ if palette:
388
+ try:
389
+ params["palette"] = _normalize_palette(palette, "palette")
390
+ except ValueError as e:
391
+ print_error(str(e))
392
+ sys.exit(1)
393
+
394
+ if import_settings:
395
+ try:
396
+ params["importSettings"] = _normalize_import_settings(
397
+ import_settings)
398
+ except ValueError as e:
399
+ print_error(str(e))
400
+ sys.exit(1)
401
+
402
+ if image_path:
403
+ params["imagePath"] = image_path
404
+
405
+ try:
406
+ result = run_command("manage_texture", params, config)
407
+ click.echo(format_output(result, config.format))
408
+ if result.get("success"):
409
+ print_success(f"Created texture: {path}")
410
+ except UnityConnectionError as e:
411
+ print_error(str(e))
412
+ sys.exit(1)
413
+
414
+
415
+ @texture.command("sprite")
416
+ @click.argument("path")
417
+ @click.option("--width", default=64, help="Texture width (default: 64)")
418
+ @click.option("--height", default=64, help="Texture height (default: 64)")
419
+ @click.option("--image-path", help="Source image path (PNG/JPG) to import.")
420
+ @click.option("--color", help="Fill color (e.g., '#FF0000' or '[1,0,0,1]')")
421
+ @click.option("--pattern", type=click.Choice([
422
+ "checkerboard", "stripes", "dots", "grid"
423
+ ]), help="Pattern type (defaults to checkerboard if no color specified)")
424
+ @click.option("--ppu", default=100.0, help="Pixels Per Unit")
425
+ @click.option("--pivot", help="Pivot as [x,y] (default: [0.5, 0.5])")
426
+ def sprite(path: str, width: int, height: int, image_path: Optional[str], color: Optional[str], pattern: Optional[str], ppu: float, pivot: Optional[str]):
427
+ """Quickly create a sprite texture.
428
+
429
+ \b
430
+ Examples:
431
+ unity-mcp texture sprite Assets/Sprites/Player.png
432
+ unity-mcp texture sprite Assets/Sprites/Coin.png --pattern dots
433
+ unity-mcp texture sprite Assets/Sprites/Solid.png --color '[0,255,0]'
434
+ """
435
+ config = get_config()
436
+ if image_path:
437
+ if color or pattern:
438
+ print_error("image-path cannot be combined with color or pattern.")
439
+ sys.exit(1)
440
+ else:
441
+ try:
442
+ warnings = _validate_texture_dimensions(width, height)
443
+ except ValueError as e:
444
+ print_error(str(e))
445
+ sys.exit(1)
446
+ for warning in warnings:
447
+ click.echo(f"⚠️ Warning: {warning}")
448
+
449
+ sprite_settings: dict[str, Any] = {"pixelsPerUnit": ppu}
450
+ if pivot:
451
+ sprite_settings["pivot"] = try_parse_json(pivot, "pivot")
452
+ else:
453
+ sprite_settings["pivot"] = [0.5, 0.5]
454
+
455
+ params: dict[str, Any] = {
456
+ "action": "create_sprite",
457
+ "path": path,
458
+ "width": width,
459
+ "height": height,
460
+ "spriteSettings": sprite_settings
461
+ }
462
+
463
+ if color:
464
+ try:
465
+ params["fillColor"] = _normalize_color(color, "color")
466
+ except ValueError as e:
467
+ print_error(str(e))
468
+ sys.exit(1)
469
+
470
+ # Only default pattern if no color is specified
471
+ if pattern:
472
+ params["pattern"] = pattern
473
+ elif not color and not image_path:
474
+ params["pattern"] = "checkerboard"
475
+
476
+ if image_path:
477
+ params["imagePath"] = image_path
478
+
479
+ try:
480
+ result = run_command("manage_texture", params, config)
481
+ click.echo(format_output(result, config.format))
482
+ if result.get("success"):
483
+ print_success(f"Created sprite: {path}")
484
+ except UnityConnectionError as e:
485
+ print_error(str(e))
486
+ sys.exit(1)
487
+
488
+
489
+ @texture.command("modify")
490
+ @click.argument("path")
491
+ @click.option("--set-pixels", required=True, help="Modification args as JSON")
492
+ def modify(path: str, set_pixels: str):
493
+ """Modify an existing texture.
494
+
495
+ \b
496
+ Examples:
497
+ unity-mcp texture modify Assets/Tex.png --set-pixels '{"x":0,"y":0,"width":10,"height":10,"color":[255,0,0]}'
498
+ 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]]}'
499
+ """
500
+ config = get_config()
501
+
502
+ params: dict[str, Any] = {
503
+ "action": "modify",
504
+ "path": path,
505
+ }
506
+
507
+ try:
508
+ params["setPixels"] = _normalize_set_pixels(set_pixels)
509
+ except ValueError as e:
510
+ print_error(str(e))
511
+ sys.exit(1)
512
+
513
+ try:
514
+ result = run_command("manage_texture", params, config)
515
+ click.echo(format_output(result, config.format))
516
+ if result.get("success"):
517
+ print_success(f"Modified texture: {path}")
518
+ except UnityConnectionError as e:
519
+ print_error(str(e))
520
+ sys.exit(1)
521
+
522
+
523
+ @texture.command("delete")
524
+ @click.argument("path")
525
+ def delete(path: str):
526
+ """Delete a texture.
527
+ """
528
+ config = get_config()
529
+
530
+ try:
531
+ result = run_command("manage_texture", {
532
+ "action": "delete", "path": path}, config)
533
+ click.echo(format_output(result, config.format))
534
+ if result.get("success"):
535
+ print_success(f"Deleted texture: {path}")
536
+ except UnityConnectionError as e:
537
+ print_error(str(e))
538
+ sys.exit(1)
cli/commands/tool.py ADDED
@@ -0,0 +1,61 @@
1
+ """Tool CLI commands for listing custom tools."""
2
+
3
+ import sys
4
+ import click
5
+
6
+ from cli.utils.config import get_config
7
+ from cli.utils.output import format_output, print_error
8
+ from cli.utils.connection import run_list_custom_tools, UnityConnectionError
9
+
10
+
11
+ def _list_custom_tools() -> None:
12
+ config = get_config()
13
+ try:
14
+ result = run_list_custom_tools(config)
15
+ if config.format != "text":
16
+ click.echo(format_output(result, config.format))
17
+ return
18
+
19
+ if not isinstance(result, dict) or not result.get("success", True):
20
+ click.echo(format_output(result, config.format))
21
+ return
22
+
23
+ tools = result.get("tools")
24
+ if tools is None:
25
+ data = result.get("data", {})
26
+ tools = data.get("tools") if isinstance(data, dict) else None
27
+ if not isinstance(tools, list):
28
+ click.echo(format_output(result, config.format))
29
+ return
30
+
31
+ click.echo(f"Custom tools ({len(tools)}):")
32
+ for i, tool in enumerate(tools):
33
+ name = tool.get("name") if isinstance(tool, dict) else str(tool)
34
+ click.echo(f" [{i}] {name}")
35
+ except UnityConnectionError as e:
36
+ print_error(str(e))
37
+ sys.exit(1)
38
+
39
+
40
+ @click.group("tool")
41
+ def tool():
42
+ """Tool management - list custom tools for the active Unity project."""
43
+ pass
44
+
45
+
46
+ @tool.command("list")
47
+ def list_tools():
48
+ """List custom tools registered for the active Unity project."""
49
+ _list_custom_tools()
50
+
51
+
52
+ @click.group("custom_tool")
53
+ def custom_tool():
54
+ """Alias for tool management (custom tools)."""
55
+ pass
56
+
57
+
58
+ @custom_tool.command("list")
59
+ def list_custom_tools():
60
+ """List custom tools registered for the active Unity project."""
61
+ _list_custom_tools()