mcpforunityserver 9.3.0b20260128055651__py3-none-any.whl → 9.3.0b20260129121506__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.
- cli/commands/animation.py +6 -9
- cli/commands/asset.py +50 -80
- cli/commands/audio.py +14 -22
- cli/commands/batch.py +20 -33
- cli/commands/code.py +63 -70
- cli/commands/component.py +33 -55
- cli/commands/editor.py +122 -188
- cli/commands/gameobject.py +60 -83
- cli/commands/instance.py +28 -36
- cli/commands/lighting.py +54 -59
- cli/commands/material.py +39 -68
- cli/commands/prefab.py +63 -81
- cli/commands/scene.py +30 -54
- cli/commands/script.py +32 -50
- cli/commands/shader.py +43 -55
- cli/commands/texture.py +53 -51
- cli/commands/tool.py +24 -27
- cli/commands/ui.py +125 -130
- cli/commands/vfx.py +84 -138
- cli/utils/confirmation.py +37 -0
- cli/utils/connection.py +32 -2
- cli/utils/constants.py +23 -0
- cli/utils/parsers.py +112 -0
- core/config.py +0 -4
- core/telemetry.py +20 -2
- {mcpforunityserver-9.3.0b20260128055651.dist-info → mcpforunityserver-9.3.0b20260129121506.dist-info}/METADATA +21 -1
- mcpforunityserver-9.3.0b20260129121506.dist-info/RECORD +103 -0
- services/resources/active_tool.py +1 -1
- services/resources/custom_tools.py +1 -1
- services/resources/editor_state.py +1 -1
- services/resources/gameobject.py +4 -4
- services/resources/layers.py +1 -1
- services/resources/menu_items.py +1 -1
- services/resources/prefab.py +3 -3
- services/resources/prefab_stage.py +1 -1
- services/resources/project_info.py +1 -1
- services/resources/selection.py +1 -1
- services/resources/tags.py +1 -1
- services/resources/tests.py +40 -8
- services/resources/unity_instances.py +1 -1
- services/resources/windows.py +1 -1
- services/tools/__init__.py +3 -1
- services/tools/find_gameobjects.py +32 -11
- services/tools/manage_gameobject.py +11 -66
- services/tools/manage_material.py +4 -37
- services/tools/manage_prefabs.py +51 -7
- services/tools/manage_script.py +1 -1
- services/tools/manage_texture.py +10 -96
- services/tools/run_tests.py +67 -4
- services/tools/utils.py +217 -0
- transport/models.py +1 -0
- transport/plugin_hub.py +2 -1
- transport/plugin_registry.py +3 -0
- transport/unity_transport.py +0 -51
- utils/focus_nudge.py +291 -23
- mcpforunityserver-9.3.0b20260128055651.dist-info/RECORD +0 -101
- utils/reload_sentinel.py +0 -9
- {mcpforunityserver-9.3.0b20260128055651.dist-info → mcpforunityserver-9.3.0b20260129121506.dist-info}/WHEEL +0 -0
- {mcpforunityserver-9.3.0b20260128055651.dist-info → mcpforunityserver-9.3.0b20260129121506.dist-info}/entry_points.txt +0 -0
- {mcpforunityserver-9.3.0b20260128055651.dist-info → mcpforunityserver-9.3.0b20260129121506.dist-info}/licenses/LICENSE +0 -0
- {mcpforunityserver-9.3.0b20260128055651.dist-info → mcpforunityserver-9.3.0b20260129121506.dist-info}/top_level.txt +0 -0
|
@@ -9,44 +9,11 @@ from mcp.types import ToolAnnotations
|
|
|
9
9
|
|
|
10
10
|
from services.registry import mcp_for_unity_tool
|
|
11
11
|
from services.tools import get_unity_instance_from_context
|
|
12
|
-
from services.tools.utils import parse_json_payload, coerce_int, normalize_properties
|
|
12
|
+
from services.tools.utils import parse_json_payload, coerce_int, normalize_properties, normalize_color
|
|
13
13
|
from transport.unity_transport import send_with_unity_instance
|
|
14
14
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
def _normalize_color(value: Any) -> tuple[list[float] | None, str | None]:
|
|
18
|
-
"""
|
|
19
|
-
Normalize color parameter to [r, g, b] or [r, g, b, a] format.
|
|
20
|
-
Returns (parsed_color, error_message).
|
|
21
|
-
"""
|
|
22
|
-
if value is None:
|
|
23
|
-
return None, None
|
|
24
|
-
|
|
25
|
-
# Already a list - validate
|
|
26
|
-
if isinstance(value, (list, tuple)):
|
|
27
|
-
if len(value) in (3, 4):
|
|
28
|
-
try:
|
|
29
|
-
return [float(c) for c in value], None
|
|
30
|
-
except (ValueError, TypeError):
|
|
31
|
-
return None, f"color values must be numbers, got {value}"
|
|
32
|
-
return None, f"color must have 3 or 4 components, got {len(value)}"
|
|
33
|
-
|
|
34
|
-
# Try parsing as string
|
|
35
|
-
if isinstance(value, str):
|
|
36
|
-
if value in ("[object Object]", "undefined", "null", ""):
|
|
37
|
-
return None, f"color received invalid value: '{value}'. Expected [r, g, b] or [r, g, b, a]"
|
|
38
|
-
|
|
39
|
-
parsed = parse_json_payload(value)
|
|
40
|
-
if isinstance(parsed, (list, tuple)) and len(parsed) in (3, 4):
|
|
41
|
-
try:
|
|
42
|
-
return [float(c) for c in parsed], None
|
|
43
|
-
except (ValueError, TypeError):
|
|
44
|
-
return None, f"color values must be numbers, got {parsed}"
|
|
45
|
-
return None, f"Failed to parse color string: {value}"
|
|
46
|
-
|
|
47
|
-
return None, f"color must be a list or JSON string, got {type(value).__name__}"
|
|
48
|
-
|
|
49
|
-
|
|
50
17
|
@mcp_for_unity_tool(
|
|
51
18
|
description="Manages Unity materials (set properties, colors, shaders, etc). Read-only actions: ping, get_material_info. Modifying actions: create, set_material_shader_property, set_material_color, assign_material_to_renderer, set_renderer_color.",
|
|
52
19
|
annotations=ToolAnnotations(
|
|
@@ -82,8 +49,8 @@ async def manage_material(
|
|
|
82
49
|
"Value to set (color array, float, texture path/instruction)"] | None = None,
|
|
83
50
|
|
|
84
51
|
# set_material_color / set_renderer_color
|
|
85
|
-
color: Annotated[list[float] | str,
|
|
86
|
-
"Color as [r, g, b] or [r, g, b, a] array
|
|
52
|
+
color: Annotated[list[float] | dict[str, float] | str,
|
|
53
|
+
"Color as [r, g, b] or [r, g, b, a] array, {r, g, b, a} object, or JSON string."] | None = None,
|
|
87
54
|
|
|
88
55
|
# assign_material_to_renderer / set_renderer_color
|
|
89
56
|
target: Annotated[str,
|
|
@@ -98,7 +65,7 @@ async def manage_material(
|
|
|
98
65
|
unity_instance = get_unity_instance_from_context(ctx)
|
|
99
66
|
|
|
100
67
|
# --- Normalize color with validation ---
|
|
101
|
-
color, color_error =
|
|
68
|
+
color, color_error = normalize_color(color, output_range="float")
|
|
102
69
|
if color_error:
|
|
103
70
|
return {"success": False, "message": color_error}
|
|
104
71
|
|
services/tools/manage_prefabs.py
CHANGED
|
@@ -5,7 +5,7 @@ from mcp.types import ToolAnnotations
|
|
|
5
5
|
|
|
6
6
|
from services.registry import mcp_for_unity_tool
|
|
7
7
|
from services.tools import get_unity_instance_from_context
|
|
8
|
-
from services.tools.utils import coerce_bool
|
|
8
|
+
from services.tools.utils import coerce_bool, normalize_vector3
|
|
9
9
|
from transport.unity_transport import send_with_unity_instance
|
|
10
10
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
11
11
|
from services.tools.preflight import preflight
|
|
@@ -25,6 +25,10 @@ REQUIRED_PARAMS = {
|
|
|
25
25
|
"Manages Unity Prefab assets via headless operations (no UI, no prefab stages). "
|
|
26
26
|
"Actions: get_info, get_hierarchy, create_from_gameobject, modify_contents. "
|
|
27
27
|
"Use modify_contents for headless prefab editing - ideal for automated workflows. "
|
|
28
|
+
"Use create_child parameter with modify_contents to add child GameObjects to a prefab "
|
|
29
|
+
"(single object or array for batch creation in one save). "
|
|
30
|
+
"Example: create_child=[{\"name\": \"Child1\", \"primitive_type\": \"Sphere\", \"position\": [1,0,0]}, "
|
|
31
|
+
"{\"name\": \"Child2\", \"primitive_type\": \"Cube\", \"parent\": \"Child1\"}]. "
|
|
28
32
|
"Use manage_asset action=search filterType=Prefab to list prefabs."
|
|
29
33
|
),
|
|
30
34
|
annotations=ToolAnnotations(
|
|
@@ -49,9 +53,9 @@ async def manage_prefabs(
|
|
|
49
53
|
search_inactive: Annotated[bool, "Include inactive GameObjects in search."] | None = None,
|
|
50
54
|
unlink_if_instance: Annotated[bool, "Unlink from existing prefab before creating new one."] | None = None,
|
|
51
55
|
# modify_contents parameters
|
|
52
|
-
position: Annotated[list[float], "New local position [x, y, z] for modify_contents."] | None = None,
|
|
53
|
-
rotation: Annotated[list[float], "New local rotation (euler angles) [x, y, z] for modify_contents."] | None = None,
|
|
54
|
-
scale: Annotated[list[float], "New local scale [x, y, z] for modify_contents."] | None = None,
|
|
56
|
+
position: Annotated[list[float] | dict[str, float] | str, "New local position [x, y, z] or {x, y, z} for modify_contents."] | None = None,
|
|
57
|
+
rotation: Annotated[list[float] | dict[str, float] | str, "New local rotation (euler angles) [x, y, z] or {x, y, z} for modify_contents."] | None = None,
|
|
58
|
+
scale: Annotated[list[float] | dict[str, float] | str, "New local scale [x, y, z] or {x, y, z} for modify_contents."] | None = None,
|
|
55
59
|
name: Annotated[str, "New name for the target object in modify_contents."] | None = None,
|
|
56
60
|
tag: Annotated[str, "New tag for the target object in modify_contents."] | None = None,
|
|
57
61
|
layer: Annotated[str, "New layer name for the target object in modify_contents."] | None = None,
|
|
@@ -59,6 +63,7 @@ async def manage_prefabs(
|
|
|
59
63
|
parent: Annotated[str, "New parent object name/path within prefab for modify_contents."] | None = None,
|
|
60
64
|
components_to_add: Annotated[list[str], "Component types to add in modify_contents."] | None = None,
|
|
61
65
|
components_to_remove: Annotated[list[str], "Component types to remove in modify_contents."] | None = None,
|
|
66
|
+
create_child: Annotated[dict[str, Any] | list[dict[str, Any]], "Create child GameObject(s) in the prefab. Single object or array of objects, each with: name (required), parent (optional, defaults to target), primitive_type (optional: Cube, Sphere, Capsule, Cylinder, Plane, Quad), position, rotation, scale, components_to_add, tag, layer, set_active."] | None = None,
|
|
62
67
|
) -> dict[str, Any]:
|
|
63
68
|
# Back-compat: map 'name' → 'target' for create_from_gameobject (Unity accepts both)
|
|
64
69
|
if action == "create_from_gameobject" and target is None and name is not None:
|
|
@@ -114,11 +119,20 @@ async def manage_prefabs(
|
|
|
114
119
|
|
|
115
120
|
# modify_contents parameters
|
|
116
121
|
if position is not None:
|
|
117
|
-
|
|
122
|
+
position_value, position_error = normalize_vector3(position, "position")
|
|
123
|
+
if position_error:
|
|
124
|
+
return {"success": False, "message": position_error}
|
|
125
|
+
params["position"] = position_value
|
|
118
126
|
if rotation is not None:
|
|
119
|
-
|
|
127
|
+
rotation_value, rotation_error = normalize_vector3(rotation, "rotation")
|
|
128
|
+
if rotation_error:
|
|
129
|
+
return {"success": False, "message": rotation_error}
|
|
130
|
+
params["rotation"] = rotation_value
|
|
120
131
|
if scale is not None:
|
|
121
|
-
|
|
132
|
+
scale_value, scale_error = normalize_vector3(scale, "scale")
|
|
133
|
+
if scale_error:
|
|
134
|
+
return {"success": False, "message": scale_error}
|
|
135
|
+
params["scale"] = scale_value
|
|
122
136
|
if name is not None:
|
|
123
137
|
params["name"] = name
|
|
124
138
|
if tag is not None:
|
|
@@ -134,6 +148,36 @@ async def manage_prefabs(
|
|
|
134
148
|
params["componentsToAdd"] = components_to_add
|
|
135
149
|
if components_to_remove is not None:
|
|
136
150
|
params["componentsToRemove"] = components_to_remove
|
|
151
|
+
if create_child is not None:
|
|
152
|
+
# Normalize vector fields within create_child (handles single object or array)
|
|
153
|
+
def normalize_child_params(child: Any, index: int | None = None) -> tuple[dict | None, str | None]:
|
|
154
|
+
prefix = f"create_child[{index}]" if index is not None else "create_child"
|
|
155
|
+
if not isinstance(child, dict):
|
|
156
|
+
return None, f"{prefix} must be a dict with child properties (name, primitive_type, position, etc.), got {type(child).__name__}"
|
|
157
|
+
child_params = dict(child)
|
|
158
|
+
for vec_field in ("position", "rotation", "scale"):
|
|
159
|
+
if vec_field in child_params and child_params[vec_field] is not None:
|
|
160
|
+
vec_val, vec_err = normalize_vector3(child_params[vec_field], f"{prefix}.{vec_field}")
|
|
161
|
+
if vec_err:
|
|
162
|
+
return None, vec_err
|
|
163
|
+
child_params[vec_field] = vec_val
|
|
164
|
+
return child_params, None
|
|
165
|
+
|
|
166
|
+
if isinstance(create_child, list):
|
|
167
|
+
# Array of children
|
|
168
|
+
normalized_children = []
|
|
169
|
+
for i, child in enumerate(create_child):
|
|
170
|
+
child_params, err = normalize_child_params(child, i)
|
|
171
|
+
if err:
|
|
172
|
+
return {"success": False, "message": err}
|
|
173
|
+
normalized_children.append(child_params)
|
|
174
|
+
params["createChild"] = normalized_children
|
|
175
|
+
else:
|
|
176
|
+
# Single child object
|
|
177
|
+
child_params, err = normalize_child_params(create_child)
|
|
178
|
+
if err:
|
|
179
|
+
return {"success": False, "message": err}
|
|
180
|
+
params["createChild"] = child_params
|
|
137
181
|
|
|
138
182
|
# Send command to Unity
|
|
139
183
|
response = await send_with_unity_instance(
|
services/tools/manage_script.py
CHANGED
|
@@ -613,7 +613,7 @@ async def manage_script_capabilities(ctx: Context) -> dict[str, Any]:
|
|
|
613
613
|
|
|
614
614
|
|
|
615
615
|
@mcp_for_unity_tool(
|
|
616
|
-
description="Get SHA256 and basic metadata for a Unity C# script without returning file contents",
|
|
616
|
+
description="Get SHA256 and basic metadata for a Unity C# script without returning file contents. Requires uri (script path under Assets/ or mcpforunity://path/Assets/... or file://...).",
|
|
617
617
|
annotations=ToolAnnotations(
|
|
618
618
|
title="Get SHA",
|
|
619
619
|
readOnlyHint=True,
|
services/tools/manage_texture.py
CHANGED
|
@@ -10,41 +10,12 @@ from mcp.types import ToolAnnotations
|
|
|
10
10
|
|
|
11
11
|
from services.registry import mcp_for_unity_tool
|
|
12
12
|
from services.tools import get_unity_instance_from_context
|
|
13
|
-
from services.tools.utils import parse_json_payload, coerce_bool, coerce_int
|
|
13
|
+
from services.tools.utils import parse_json_payload, coerce_bool, coerce_int, normalize_color
|
|
14
14
|
from transport.unity_transport import send_with_unity_instance
|
|
15
15
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
16
16
|
from services.tools.preflight import preflight
|
|
17
17
|
|
|
18
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
19
|
def _normalize_dimension(value: Any, name: str, default: int = 64) -> tuple[int | None, str | None]:
|
|
49
20
|
if value is None:
|
|
50
21
|
return default, None
|
|
@@ -65,66 +36,9 @@ def _normalize_positive_int(value: Any, name: str) -> tuple[int | None, str | No
|
|
|
65
36
|
return coerced, None
|
|
66
37
|
|
|
67
38
|
|
|
68
|
-
def
|
|
69
|
-
"""
|
|
70
|
-
|
|
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__}"
|
|
39
|
+
def _normalize_color_int(value: Any) -> tuple[list[int] | None, str | None]:
|
|
40
|
+
"""Thin wrapper for normalize_color with int output for texture operations."""
|
|
41
|
+
return normalize_color(value, output_range="int")
|
|
128
42
|
|
|
129
43
|
|
|
130
44
|
def _normalize_palette(value: Any) -> tuple[list[list[int]] | None, str | None]:
|
|
@@ -146,7 +60,7 @@ def _normalize_palette(value: Any) -> tuple[list[list[int]] | None, str | None]:
|
|
|
146
60
|
|
|
147
61
|
normalized = []
|
|
148
62
|
for i, color in enumerate(value):
|
|
149
|
-
parsed, error =
|
|
63
|
+
parsed, error = _normalize_color_int(color)
|
|
150
64
|
if error:
|
|
151
65
|
return None, f"palette[{i}]: {error}"
|
|
152
66
|
normalized.append(parsed)
|
|
@@ -181,7 +95,7 @@ def _normalize_pixels(value: Any, width: int, height: int) -> tuple[list[list[in
|
|
|
181
95
|
|
|
182
96
|
normalized = []
|
|
183
97
|
for i, pixel in enumerate(value):
|
|
184
|
-
parsed, error =
|
|
98
|
+
parsed, error = _normalize_color_int(pixel)
|
|
185
99
|
if error:
|
|
186
100
|
return None, f"pixels[{i}]: {error}"
|
|
187
101
|
normalized.append(parsed)
|
|
@@ -482,8 +396,8 @@ async def manage_texture(
|
|
|
482
396
|
height: Annotated[int, "Texture height in pixels (default: 64)"] | None = None,
|
|
483
397
|
|
|
484
398
|
# 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,
|
|
399
|
+
fill_color: Annotated[list[int | float] | dict[str, int | float] | str,
|
|
400
|
+
"Fill color as [r, g, b] or [r, g, b, a] array, {r, g, b, a} object, or hex string. 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
401
|
|
|
488
402
|
# Pattern-based generation
|
|
489
403
|
pattern: Annotated[Literal[
|
|
@@ -544,7 +458,7 @@ async def manage_texture(
|
|
|
544
458
|
return gate.model_dump()
|
|
545
459
|
|
|
546
460
|
# --- Normalize parameters ---
|
|
547
|
-
fill_color, fill_error =
|
|
461
|
+
fill_color, fill_error = _normalize_color_int(fill_color)
|
|
548
462
|
if fill_error:
|
|
549
463
|
return {"success": False, "message": fill_error}
|
|
550
464
|
|
|
@@ -613,7 +527,7 @@ async def manage_texture(
|
|
|
613
527
|
|
|
614
528
|
set_pixels_normalized = set_pixels.copy()
|
|
615
529
|
if "color" in set_pixels_normalized:
|
|
616
|
-
color, error =
|
|
530
|
+
color, error = _normalize_color_int(set_pixels_normalized["color"])
|
|
617
531
|
if error:
|
|
618
532
|
return {"success": False, "message": f"set_pixels.color: {error}"}
|
|
619
533
|
set_pixels_normalized["color"] = color
|
services/tools/run_tests.py
CHANGED
|
@@ -16,11 +16,58 @@ from services.tools import get_unity_instance_from_context
|
|
|
16
16
|
from services.tools.preflight import preflight
|
|
17
17
|
import transport.unity_transport as unity_transport
|
|
18
18
|
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
19
|
-
from
|
|
19
|
+
from transport.plugin_hub import PluginHub
|
|
20
|
+
from utils.focus_nudge import nudge_unity_focus, should_nudge, reset_nudge_backoff
|
|
20
21
|
|
|
21
22
|
logger = logging.getLogger(__name__)
|
|
22
23
|
|
|
23
24
|
|
|
25
|
+
async def _get_unity_project_path(unity_instance: str | None) -> str | None:
|
|
26
|
+
"""Get the project root path for a Unity instance (for focus nudging).
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
unity_instance: Unity instance hash or "Name@hash" format or None
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Project root path (e.g., "/Users/name/project"), or falls back to project_name if path unavailable
|
|
33
|
+
"""
|
|
34
|
+
if not unity_instance:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
registry = PluginHub._registry
|
|
39
|
+
if not registry:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
# Parse Name@hash format if present (middleware stores instances as "Name@hash")
|
|
43
|
+
target_hash = unity_instance
|
|
44
|
+
if "@" in target_hash:
|
|
45
|
+
_, _, target_hash = target_hash.rpartition("@")
|
|
46
|
+
if not target_hash:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
# Get session by hash
|
|
50
|
+
session_id = await registry.get_session_id_by_hash(target_hash)
|
|
51
|
+
if not session_id:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
session = await registry.get_session(session_id)
|
|
55
|
+
if not session:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
except Exception as e:
|
|
59
|
+
# Re-raise cancellation errors so task cancellation propagates
|
|
60
|
+
if isinstance(e, asyncio.CancelledError):
|
|
61
|
+
raise
|
|
62
|
+
logger.debug(f"Could not get Unity project path: {e}")
|
|
63
|
+
return None
|
|
64
|
+
else:
|
|
65
|
+
# Return full path if available, otherwise fall back to project name
|
|
66
|
+
if session.project_path:
|
|
67
|
+
return session.project_path
|
|
68
|
+
return session.project_name if session.project_name else None
|
|
69
|
+
|
|
70
|
+
|
|
24
71
|
class RunTestsSummary(BaseModel):
|
|
25
72
|
total: int
|
|
26
73
|
passed: int
|
|
@@ -200,6 +247,10 @@ async def get_test_job(
|
|
|
200
247
|
if wait_timeout and wait_timeout > 0:
|
|
201
248
|
deadline = asyncio.get_event_loop().time() + wait_timeout
|
|
202
249
|
poll_interval = 2.0 # Poll Unity every 2 seconds
|
|
250
|
+
prev_last_update_unix_ms = None
|
|
251
|
+
|
|
252
|
+
# Get project path once for focus nudging (multi-instance support)
|
|
253
|
+
project_path = await _get_unity_project_path(unity_instance)
|
|
203
254
|
|
|
204
255
|
while True:
|
|
205
256
|
response = await _fetch_status()
|
|
@@ -216,12 +267,20 @@ async def get_test_job(
|
|
|
216
267
|
if status in ("succeeded", "failed", "cancelled"):
|
|
217
268
|
return GetTestJobResponse(**response)
|
|
218
269
|
|
|
270
|
+
# Detect progress and reset exponential backoff
|
|
271
|
+
last_update_unix_ms = data.get("last_update_unix_ms")
|
|
272
|
+
if prev_last_update_unix_ms is not None and last_update_unix_ms != prev_last_update_unix_ms:
|
|
273
|
+
# Progress detected - reset exponential backoff for next potential stall
|
|
274
|
+
reset_nudge_backoff()
|
|
275
|
+
logger.debug(f"Test job {job_id} made progress - reset nudge backoff")
|
|
276
|
+
prev_last_update_unix_ms = last_update_unix_ms
|
|
277
|
+
|
|
219
278
|
# Check if Unity needs a focus nudge to make progress
|
|
220
279
|
# This handles OS-level throttling (e.g., macOS App Nap) that can
|
|
221
280
|
# stall PlayMode tests when Unity is in the background.
|
|
281
|
+
# Uses exponential backoff: 1s, 2s, 4s, 8s, 10s max between nudges.
|
|
222
282
|
progress = data.get("progress", {})
|
|
223
283
|
editor_is_focused = progress.get("editor_is_focused", True)
|
|
224
|
-
last_update_unix_ms = data.get("last_update_unix_ms")
|
|
225
284
|
current_time_ms = int(time.time() * 1000)
|
|
226
285
|
|
|
227
286
|
if should_nudge(
|
|
@@ -229,10 +288,14 @@ async def get_test_job(
|
|
|
229
288
|
editor_is_focused=editor_is_focused,
|
|
230
289
|
last_update_unix_ms=last_update_unix_ms,
|
|
231
290
|
current_time_ms=current_time_ms,
|
|
232
|
-
|
|
291
|
+
# Use default stall_threshold_ms (3s)
|
|
233
292
|
):
|
|
234
293
|
logger.info(f"Test job {job_id} appears stalled (unfocused Unity), attempting nudge...")
|
|
235
|
-
|
|
294
|
+
# Lazily resolve project path if not yet available (registry may have become ready)
|
|
295
|
+
if project_path is None:
|
|
296
|
+
project_path = await _get_unity_project_path(unity_instance)
|
|
297
|
+
# Pass project path for multi-instance support
|
|
298
|
+
nudged = await nudge_unity_focus(unity_project_path=project_path)
|
|
236
299
|
if nudged:
|
|
237
300
|
logger.info(f"Test job {job_id} nudge completed")
|
|
238
301
|
|
services/tools/utils.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import math
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
8
9
|
_TRUTHY = {"true", "1", "yes", "on"}
|
|
@@ -129,3 +130,219 @@ def normalize_properties(value: Any) -> tuple[dict[str, Any] | None, str | None]
|
|
|
129
130
|
return None, f"properties must be a JSON object (dict), got string that parsed to {type(parsed).__name__}"
|
|
130
131
|
|
|
131
132
|
return None, f"properties must be a dict or JSON string, got {type(value).__name__}"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def normalize_vector3(value: Any, param_name: str = "vector") -> tuple[list[float] | None, str | None]:
|
|
136
|
+
"""
|
|
137
|
+
Normalize a vector parameter to [x, y, z] format.
|
|
138
|
+
|
|
139
|
+
Handles various input formats from MCP clients/LLMs:
|
|
140
|
+
- None -> (None, None)
|
|
141
|
+
- list/tuple [x, y, z] -> ([x, y, z], None)
|
|
142
|
+
- dict {x, y, z} -> ([x, y, z], None)
|
|
143
|
+
- JSON string "[x, y, z]" or "{x, y, z}" -> parsed and normalized
|
|
144
|
+
- comma-separated string "x, y, z" -> ([x, y, z], None)
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Tuple of (parsed_vector, error_message). If error_message is set, parsed_vector is None.
|
|
148
|
+
"""
|
|
149
|
+
if value is None:
|
|
150
|
+
return None, None
|
|
151
|
+
|
|
152
|
+
# Handle dict with x/y/z keys (e.g., {"x": 0, "y": 1, "z": 2})
|
|
153
|
+
if isinstance(value, dict):
|
|
154
|
+
if all(k in value for k in ("x", "y", "z")):
|
|
155
|
+
try:
|
|
156
|
+
vec = [float(value["x"]), float(value["y"]), float(value["z"])]
|
|
157
|
+
if all(math.isfinite(n) for n in vec):
|
|
158
|
+
return vec, None
|
|
159
|
+
return None, f"{param_name} values must be finite numbers, got {value}"
|
|
160
|
+
except (ValueError, TypeError, KeyError):
|
|
161
|
+
return None, f"{param_name} dict values must be numbers, got {value}"
|
|
162
|
+
return None, f"{param_name} dict must have 'x', 'y', 'z' keys, got {list(value.keys())}"
|
|
163
|
+
|
|
164
|
+
# If already a list/tuple with 3 elements, convert to floats
|
|
165
|
+
if isinstance(value, (list, tuple)) and len(value) == 3:
|
|
166
|
+
try:
|
|
167
|
+
vec = [float(value[0]), float(value[1]), float(value[2])]
|
|
168
|
+
if all(math.isfinite(n) for n in vec):
|
|
169
|
+
return vec, None
|
|
170
|
+
return None, f"{param_name} values must be finite numbers, got {value}"
|
|
171
|
+
except (ValueError, TypeError):
|
|
172
|
+
return None, f"{param_name} values must be numbers, got {value}"
|
|
173
|
+
|
|
174
|
+
# Try parsing as string
|
|
175
|
+
if isinstance(value, str):
|
|
176
|
+
# Check for obviously invalid values
|
|
177
|
+
if value in ("[object Object]", "undefined", "null", ""):
|
|
178
|
+
return None, f"{param_name} received invalid value: '{value}'. Expected [x, y, z] array or {{x, y, z}} object"
|
|
179
|
+
|
|
180
|
+
parsed = parse_json_payload(value)
|
|
181
|
+
|
|
182
|
+
# Handle parsed dict
|
|
183
|
+
if isinstance(parsed, dict):
|
|
184
|
+
return normalize_vector3(parsed, param_name)
|
|
185
|
+
|
|
186
|
+
# Handle parsed list
|
|
187
|
+
if isinstance(parsed, list) and len(parsed) == 3:
|
|
188
|
+
try:
|
|
189
|
+
vec = [float(parsed[0]), float(parsed[1]), float(parsed[2])]
|
|
190
|
+
if all(math.isfinite(n) for n in vec):
|
|
191
|
+
return vec, None
|
|
192
|
+
return None, f"{param_name} values must be finite numbers, got {parsed}"
|
|
193
|
+
except (ValueError, TypeError):
|
|
194
|
+
return None, f"{param_name} values must be numbers, got {parsed}"
|
|
195
|
+
|
|
196
|
+
# Handle comma-separated strings "1,2,3", "[1,2,3]", or "(1,2,3)"
|
|
197
|
+
s = value.strip()
|
|
198
|
+
if (s.startswith("[") and s.endswith("]")) or (s.startswith("(") and s.endswith(")")):
|
|
199
|
+
s = s[1:-1]
|
|
200
|
+
parts = [p.strip() for p in (s.split(",") if "," in s else s.split())]
|
|
201
|
+
if len(parts) == 3:
|
|
202
|
+
try:
|
|
203
|
+
vec = [float(parts[0]), float(parts[1]), float(parts[2])]
|
|
204
|
+
if all(math.isfinite(n) for n in vec):
|
|
205
|
+
return vec, None
|
|
206
|
+
return None, f"{param_name} values must be finite numbers, got {value}"
|
|
207
|
+
except (ValueError, TypeError):
|
|
208
|
+
return None, f"{param_name} values must be numbers, got {value}"
|
|
209
|
+
|
|
210
|
+
return None, f"{param_name} must be a [x, y, z] array or {{x, y, z}} object, got: {value}"
|
|
211
|
+
|
|
212
|
+
return None, f"{param_name} must be a list, dict, or string, got {type(value).__name__}"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def normalize_color(value: Any, output_range: str = "float") -> tuple[list[float] | None, str | None]:
|
|
216
|
+
"""
|
|
217
|
+
Normalize a color parameter to [r, g, b, a] format.
|
|
218
|
+
|
|
219
|
+
Handles various input formats from MCP clients/LLMs:
|
|
220
|
+
- None -> (None, None)
|
|
221
|
+
- list/tuple [r, g, b] or [r, g, b, a] -> normalized with optional alpha
|
|
222
|
+
- dict {r, g, b} or {r, g, b, a} -> converted to list
|
|
223
|
+
- hex string "#RGB", "#RRGGBB", "#RRGGBBAA" -> parsed to [r, g, b, a]
|
|
224
|
+
- JSON string -> parsed and normalized
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
value: The color value to normalize
|
|
228
|
+
output_range: "float" for 0.0-1.0 range, "int" for 0-255 range
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Tuple of (parsed_color, error_message). If error_message is set, parsed_color is None.
|
|
232
|
+
"""
|
|
233
|
+
if value is None:
|
|
234
|
+
return None, None
|
|
235
|
+
|
|
236
|
+
def _to_output_range(components: list[float], from_hex: bool = False) -> list:
|
|
237
|
+
"""Convert color components to the requested output range."""
|
|
238
|
+
if output_range == "int":
|
|
239
|
+
if from_hex:
|
|
240
|
+
# Already 0-255 from hex parsing
|
|
241
|
+
return [int(c) for c in components]
|
|
242
|
+
# Check if input is normalized (0-1) or already 0-255
|
|
243
|
+
if all(0 <= c <= 1 for c in components):
|
|
244
|
+
return [int(round(c * 255)) for c in components]
|
|
245
|
+
return [int(c) for c in components]
|
|
246
|
+
else: # float
|
|
247
|
+
if from_hex:
|
|
248
|
+
# Convert 0-255 to 0-1
|
|
249
|
+
return [c / 255.0 for c in components]
|
|
250
|
+
if any(c > 1 for c in components):
|
|
251
|
+
return [c / 255.0 for c in components]
|
|
252
|
+
return [float(c) for c in components]
|
|
253
|
+
|
|
254
|
+
# Handle dict with r/g/b keys
|
|
255
|
+
if isinstance(value, dict):
|
|
256
|
+
if all(k in value for k in ("r", "g", "b")):
|
|
257
|
+
try:
|
|
258
|
+
color = [float(value["r"]), float(value["g"]), float(value["b"])]
|
|
259
|
+
if "a" in value:
|
|
260
|
+
color.append(float(value["a"]))
|
|
261
|
+
else:
|
|
262
|
+
if output_range == "int" and all(0 <= c <= 1 for c in color):
|
|
263
|
+
color.append(1.0)
|
|
264
|
+
else:
|
|
265
|
+
color.append(1.0 if output_range == "float" else 255)
|
|
266
|
+
return _to_output_range(color), None
|
|
267
|
+
except (ValueError, TypeError, KeyError):
|
|
268
|
+
return None, f"color dict values must be numbers, got {value}"
|
|
269
|
+
return None, f"color dict must have 'r', 'g', 'b' keys, got {list(value.keys())}"
|
|
270
|
+
|
|
271
|
+
# Already a list/tuple - validate
|
|
272
|
+
if isinstance(value, (list, tuple)):
|
|
273
|
+
if len(value) in (3, 4):
|
|
274
|
+
try:
|
|
275
|
+
color = [float(c) for c in value]
|
|
276
|
+
if len(color) == 3:
|
|
277
|
+
if output_range == "int" and all(0 <= c <= 1 for c in color):
|
|
278
|
+
color.append(1.0)
|
|
279
|
+
else:
|
|
280
|
+
color.append(1.0 if output_range == "float" else 255)
|
|
281
|
+
return _to_output_range(color), None
|
|
282
|
+
except (ValueError, TypeError):
|
|
283
|
+
return None, f"color values must be numbers, got {value}"
|
|
284
|
+
return None, f"color must have 3 or 4 components, got {len(value)}"
|
|
285
|
+
|
|
286
|
+
# Try parsing as string
|
|
287
|
+
if isinstance(value, str):
|
|
288
|
+
if value in ("[object Object]", "undefined", "null", ""):
|
|
289
|
+
return None, f"color received invalid value: '{value}'. Expected [r, g, b, a] or {{r, g, b, a}}"
|
|
290
|
+
|
|
291
|
+
# Handle hex colors
|
|
292
|
+
if value.startswith("#"):
|
|
293
|
+
h = value.lstrip("#")
|
|
294
|
+
try:
|
|
295
|
+
if len(h) == 3:
|
|
296
|
+
# Short form #RGB -> expand to #RRGGBB
|
|
297
|
+
components = [int(c + c, 16) for c in h] + [255]
|
|
298
|
+
return _to_output_range(components, from_hex=True), None
|
|
299
|
+
elif len(h) == 6:
|
|
300
|
+
components = [int(h[i:i+2], 16) for i in (0, 2, 4)] + [255]
|
|
301
|
+
return _to_output_range(components, from_hex=True), None
|
|
302
|
+
elif len(h) == 8:
|
|
303
|
+
components = [int(h[i:i+2], 16) for i in (0, 2, 4, 6)]
|
|
304
|
+
return _to_output_range(components, from_hex=True), None
|
|
305
|
+
except ValueError:
|
|
306
|
+
return None, f"Invalid hex color: {value}"
|
|
307
|
+
return None, f"Invalid hex color length: {value}"
|
|
308
|
+
|
|
309
|
+
# Try parsing as JSON
|
|
310
|
+
parsed = parse_json_payload(value)
|
|
311
|
+
|
|
312
|
+
# Handle parsed dict
|
|
313
|
+
if isinstance(parsed, dict):
|
|
314
|
+
return normalize_color(parsed, output_range)
|
|
315
|
+
|
|
316
|
+
# Handle parsed list
|
|
317
|
+
if isinstance(parsed, (list, tuple)) and len(parsed) in (3, 4):
|
|
318
|
+
try:
|
|
319
|
+
color = [float(c) for c in parsed]
|
|
320
|
+
if len(color) == 3:
|
|
321
|
+
if output_range == "int" and all(0 <= c <= 1 for c in color):
|
|
322
|
+
color.append(1.0)
|
|
323
|
+
else:
|
|
324
|
+
color.append(1.0 if output_range == "float" else 255)
|
|
325
|
+
return _to_output_range(color), None
|
|
326
|
+
except (ValueError, TypeError):
|
|
327
|
+
return None, f"color values must be numbers, got {parsed}"
|
|
328
|
+
|
|
329
|
+
# Handle tuple-style strings "(r, g, b)" or "(r, g, b, a)"
|
|
330
|
+
s = value.strip()
|
|
331
|
+
if (s.startswith("[") and s.endswith("]")) or (s.startswith("(") and s.endswith(")")):
|
|
332
|
+
s = s[1:-1]
|
|
333
|
+
parts = [p.strip() for p in s.split(",")]
|
|
334
|
+
if len(parts) in (3, 4):
|
|
335
|
+
try:
|
|
336
|
+
color = [float(p) for p in parts]
|
|
337
|
+
if len(color) == 3:
|
|
338
|
+
if output_range == "int" and all(0 <= c <= 1 for c in color):
|
|
339
|
+
color.append(1.0)
|
|
340
|
+
else:
|
|
341
|
+
color.append(1.0 if output_range == "float" else 255)
|
|
342
|
+
return _to_output_range(color), None
|
|
343
|
+
except (ValueError, TypeError):
|
|
344
|
+
pass # Fall through to error message
|
|
345
|
+
|
|
346
|
+
return None, f"Failed to parse color string: {value}"
|
|
347
|
+
|
|
348
|
+
return None, f"color must be a list, dict, hex string, or JSON string, got {type(value).__name__}"
|