asecli 0.6.2__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.
Files changed (98) hide show
  1. asecli/__init__.py +3 -0
  2. asecli/bridge/__init__.py +40 -0
  3. asecli/bridge/_editor_port_contract.py +131 -0
  4. asecli/bridge/_editor_primitives.py +205 -0
  5. asecli/bridge/_editor_spec_grammar.py +245 -0
  6. asecli/bridge/_editor_spec_io.py +26 -0
  7. asecli/bridge/_editor_spec_validation.py +218 -0
  8. asecli/bridge/_gui_handoff_store.py +78 -0
  9. asecli/bridge/_gui_project.py +35 -0
  10. asecli/bridge/_gui_resource_store.py +159 -0
  11. asecli/bridge/_gui_resource_upgrade.py +122 -0
  12. asecli/bridge/editor_create.py +173 -0
  13. asecli/bridge/editor_spec.py +250 -0
  14. asecli/bridge/graph_geometry.py +249 -0
  15. asecli/bridge/graph_geometry_parser.py +57 -0
  16. asecli/bridge/graph_inspect.py +136 -0
  17. asecli/bridge/gui_handoff.py +100 -0
  18. asecli/bridge/gui_presentation.py +70 -0
  19. asecli/bridge/gui_provider_detection.py +197 -0
  20. asecli/bridge/gui_runtime_probe.py +25 -0
  21. asecli/bridge/gui_support.py +240 -0
  22. asecli/bridge/gui_support_resource.py +58 -0
  23. asecli/bridge/mcp_client.py +231 -0
  24. asecli/bridge/recompile.py +107 -0
  25. asecli/bridge/resource_text.py +12 -0
  26. asecli/bridge/resources/asecli_material_gui.authoring.part00.cs.txt +238 -0
  27. asecli/bridge/resources/asecli_material_gui.authoring.part01.cs.txt +235 -0
  28. asecli/bridge/resources/asecli_material_gui.authoring.store.cs.txt +125 -0
  29. asecli/bridge/resources/asecli_material_gui.condition.cs.txt +99 -0
  30. asecli/bridge/resources/asecli_material_gui.hydration.cs.txt +105 -0
  31. asecli/bridge/resources/asecli_material_gui.part00.cs.txt +300 -0
  32. asecli/bridge/resources/asecli_material_gui.part01.cs.txt +290 -0
  33. asecli/bridge/resources/asecli_material_gui.reconciliation.cs.txt +174 -0
  34. asecli/bridge/resources/asecli_material_gui.transaction.cs.txt +108 -0
  35. asecli/bridge/resources/editor_create.part00.cs.txt +176 -0
  36. asecli/bridge/resources/editor_create.part01.cs.txt +145 -0
  37. asecli/bridge/resources/editor_create.part02.cs.txt +139 -0
  38. asecli/bridge/resources/editor_create.part03.cs.txt +161 -0
  39. asecli/bridge/resources/editor_create.part04.cs.txt +114 -0
  40. asecli/bridge/resources/wire_route.transaction.cs.txt +140 -0
  41. asecli/bridge/wire_route.py +127 -0
  42. asecli/checks/__init__.py +10 -0
  43. asecli/checks/checksum.py +53 -0
  44. asecli/checks/local_vars.py +135 -0
  45. asecli/checks/usage.py +141 -0
  46. asecli/checks/validate.py +129 -0
  47. asecli/cli/__init__.py +1 -0
  48. asecli/cli/commands.py +237 -0
  49. asecli/cli/commentary_command.py +158 -0
  50. asecli/cli/create_command.py +250 -0
  51. asecli/cli/custom_gui_command.py +170 -0
  52. asecli/cli/gui_provider.py +30 -0
  53. asecli/cli/gui_support_command.py +35 -0
  54. asecli/cli/io.py +214 -0
  55. asecli/cli/layout_command.py +213 -0
  56. asecli/cli/main.py +245 -0
  57. asecli/cli/recompile_metadata.py +53 -0
  58. asecli/cli/skill_command.py +121 -0
  59. asecli/cli/usage_command.py +42 -0
  60. asecli/core/__init__.py +84 -0
  61. asecli/core/comment_bounds.py +166 -0
  62. asecli/core/comment_layout.py +167 -0
  63. asecli/core/comment_metrics.py +132 -0
  64. asecli/core/comment_purpose.py +140 -0
  65. asecli/core/commentary.py +225 -0
  66. asecli/core/compiled_metadata.py +135 -0
  67. asecli/core/compiled_properties.py +190 -0
  68. asecli/core/custom_gui.py +250 -0
  69. asecli/core/custom_gui_versions.py +35 -0
  70. asecli/core/fishbone_placement.py +159 -0
  71. asecli/core/fishbone_topology.py +186 -0
  72. asecli/core/graph_ops.py +122 -0
  73. asecli/core/layout.py +174 -0
  74. asecli/core/layout_audit.py +237 -0
  75. asecli/core/layout_audit_geometry.py +247 -0
  76. asecli/core/layout_audit_repeated.py +44 -0
  77. asecli/core/layout_graph.py +113 -0
  78. asecli/core/local_vars.py +54 -0
  79. asecli/core/material_gui_condition.py +68 -0
  80. asecli/core/material_gui_protocol.py +35 -0
  81. asecli/core/material_gui_spec.py +191 -0
  82. asecli/core/meticulous_layout.py +114 -0
  83. asecli/core/model.py +237 -0
  84. asecli/core/property_presentation.py +233 -0
  85. asecli/core/wire_geometry.py +83 -0
  86. asecli/core/wire_router.py +248 -0
  87. asecli/schema/__init__.py +49 -0
  88. asecli/schema/data/observed.json +16 -0
  89. asecli/schema/data/schemas.json +14106 -0
  90. asecli/skills/asecli/SKILL.md +275 -0
  91. asecli/skills/asecli/references/layout-standard.md +99 -0
  92. asecli/skills/asecli/references/master-output-settings-standard.md +51 -0
  93. asecli/skills/asecli/references/material-property-standard.md +108 -0
  94. asecli-0.6.2.dist-info/METADATA +594 -0
  95. asecli-0.6.2.dist-info/RECORD +98 -0
  96. asecli-0.6.2.dist-info/WHEEL +4 -0
  97. asecli-0.6.2.dist-info/entry_points.txt +2 -0
  98. asecli-0.6.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,173 @@
1
+ """Transactionally create a new ASE shader through a fixed Editor executor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ from pathlib import Path
9
+ import secrets
10
+
11
+ from .editor_spec import EditorGraphSpec, SUPPORTED_ASE_VERSIONS
12
+ from .mcp_client import McpClient, McpError, redact, tool_text
13
+ from .resource_text import compose_resource_text
14
+
15
+
16
+ EDITOR_CREATE_RESOURCE_PARTS = (
17
+ "editor_create.part00.cs.txt",
18
+ "editor_create.part01.cs.txt",
19
+ "editor_create.part02.cs.txt",
20
+ "editor_create.part03.cs.txt",
21
+ "editor_create.part04.cs.txt",
22
+ )
23
+ EDITOR_CREATE_SNIPPET = compose_resource_text(
24
+ "asecli.bridge", "resources", EDITOR_CREATE_RESOURCE_PARTS
25
+ )
26
+ _RESULT_MARKER = "ASECLI_EDITOR_CREATE_V1:"
27
+
28
+
29
+ def create_shader_via_mcp(
30
+ shader_path: str | Path,
31
+ spec: EditorGraphSpec,
32
+ mcp_url: str = "http://127.0.0.1:8080/mcp",
33
+ instance_token: str | None = None,
34
+ allow_remote_mcp: bool = False,
35
+ ) -> dict:
36
+ """Create one absent shader and accept only a verified Save/Load manifest."""
37
+ target = Path(shader_path).resolve()
38
+ if target.suffix.lower() != ".shader":
39
+ raise ValueError("Editor create target must use the .shader extension")
40
+ if not target.parent.is_dir():
41
+ raise FileNotFoundError(f"parent directory not found: {target.parent}")
42
+ if target.exists() or target.is_symlink():
43
+ raise FileExistsError(f"Editor create target already exists: {target}")
44
+ project_root, asset_path = _project_asset_path(target)
45
+ transaction_nonce = secrets.token_hex(16)
46
+ temporary_asset_path = _temporary_asset_path(asset_path, transaction_nonce)
47
+ payload = spec.editor_payload(asset_path, temporary_asset_path)
48
+ encoded = base64.b64encode(
49
+ json.dumps(payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8")
50
+ ).decode("ascii")
51
+ code = EDITOR_CREATE_SNIPPET.replace("{payload_base64}", encoded)
52
+ client = McpClient(mcp_url, instance_token=instance_token, allow_remote=allow_remote_mcp)
53
+ client.connect()
54
+ result = client.call_tool(
55
+ "execute_code",
56
+ {
57
+ "action": "execute",
58
+ "code": code,
59
+ # The fixed transactional executor uses AssetDatabase.DeleteAsset
60
+ # only to roll back its nonce-scoped staging asset. MCP 3.4.7 blocks
61
+ # that API by pattern unless the per-call safety scan is disabled.
62
+ "safety_checks": False,
63
+ },
64
+ )
65
+ response = _parse_result(_execute_code_result_text(result, instance_token))
66
+ _validate_result(response, asset_path, spec.expected_manifest())
67
+ if not target.is_file():
68
+ raise McpError("Editor create reported success but target file is missing")
69
+ temporary_file = project_root / temporary_asset_path
70
+ if temporary_file.exists() or temporary_file.with_suffix(temporary_file.suffix + ".meta").exists():
71
+ raise McpError("Editor create left a temporary asset after commit")
72
+ meta_file = target.with_suffix(target.suffix + ".meta")
73
+ return {
74
+ "transport": "mcp",
75
+ "server": mcp_url,
76
+ "asset_path": asset_path,
77
+ "ase_version": response["ase_version"],
78
+ "template_guid": response["template_guid"],
79
+ "shader_name": response["shader_name"],
80
+ "saved": True,
81
+ # Compat: reloaded means the staging asset was LoadFromDisk'd, not the committed target.
82
+ "reloaded": True,
83
+ "staging_reloaded": True,
84
+ "target_graph_reloaded": False,
85
+ "committed": True,
86
+ "changed": True,
87
+ "manifest": response["manifest"],
88
+ "transaction_nonce": transaction_nonce,
89
+ "shader_sha256": _sha256(target),
90
+ "meta_sha256": _sha256(meta_file) if meta_file.is_file() else None,
91
+ }
92
+
93
+
94
+ def _project_asset_path(target: Path) -> tuple[Path, str]:
95
+ current = target.parent
96
+ while current != current.parent:
97
+ if (current / "Assets").is_dir() and (current / "ProjectSettings").is_dir():
98
+ try:
99
+ relative = target.relative_to(current)
100
+ except ValueError as exc: # pragma: no cover - resolve makes this defensive
101
+ raise ValueError("Editor create target is outside the Unity project") from exc
102
+ if not relative.parts or relative.parts[0] != "Assets":
103
+ raise ValueError("Editor create target must be inside the Unity project Assets directory")
104
+ return current, relative.as_posix()
105
+ current = current.parent
106
+ raise ValueError("cannot locate Unity project root; target must be inside Assets")
107
+
108
+
109
+ def _temporary_asset_path(asset_path: str, transaction_nonce: str) -> str:
110
+ target = Path(asset_path)
111
+ return (target.parent / f"ASECLI-Temp-{transaction_nonce}-{target.name}").as_posix()
112
+
113
+
114
+ def _sha256(path: Path) -> str:
115
+ digest = hashlib.sha256()
116
+ with path.open("rb") as handle:
117
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
118
+ digest.update(chunk)
119
+ return digest.hexdigest()
120
+
121
+
122
+ def _parse_result(text: str) -> dict:
123
+ marker_index = text.find(_RESULT_MARKER)
124
+ if marker_index < 0:
125
+ raise McpError("MCP tool result did not contain the Editor create protocol marker")
126
+ raw = text[marker_index + len(_RESULT_MARKER) :].lstrip()
127
+ try:
128
+ value, _ = json.JSONDecoder().raw_decode(raw)
129
+ except json.JSONDecodeError as exc:
130
+ raise McpError("MCP tool returned malformed Editor create JSON") from exc
131
+ if not isinstance(value, dict):
132
+ raise McpError("MCP Editor create result must be a JSON object")
133
+ return value
134
+
135
+
136
+ def _execute_code_result_text(result: dict, instance_token: str | None) -> str:
137
+ """Unwrap execute_code's JSON envelope while retaining legacy text support."""
138
+ text = tool_text(result, instance_token)
139
+ try:
140
+ envelope = json.loads(text)
141
+ except json.JSONDecodeError:
142
+ return text
143
+ if not isinstance(envelope, dict):
144
+ return text
145
+ if envelope.get("success") is False:
146
+ detail = envelope.get("message")
147
+ if not isinstance(detail, str) or not detail.strip():
148
+ detail = "no error details"
149
+ raise McpError(f"MCP execute_code reported failure: {redact(detail[:300], instance_token)}")
150
+ data = envelope.get("data")
151
+ if isinstance(data, dict) and isinstance(data.get("result"), str):
152
+ return data["result"]
153
+ return text
154
+
155
+
156
+ def _validate_result(response: dict, asset_path: str, expected_manifest: dict) -> None:
157
+ if response.get("protocol") != "ASECLI_EDITOR_CREATE_V1":
158
+ raise McpError("Editor create protocol version is missing or unsupported")
159
+ version = response.get("ase_version")
160
+ if version not in SUPPORTED_ASE_VERSIONS:
161
+ raise McpError(f"Editor create returned unsupported ASE version: {version}")
162
+ if response.get("asset_path") != asset_path:
163
+ raise McpError("Editor create returned a different asset path")
164
+ expected_template = expected_manifest["template"]
165
+ if response.get("template_guid") != expected_template["guid"]:
166
+ raise McpError("Editor create returned a different template guid")
167
+ if response.get("shader_name") != expected_template["shader_name"]:
168
+ raise McpError("Editor create returned a different shader name")
169
+ for field in ("saved", "reloaded", "committed"):
170
+ if response.get(field) is not True:
171
+ raise McpError(f"Editor create did not confirm {field}=True")
172
+ if response.get("manifest") != expected_manifest:
173
+ raise McpError("Editor create Save/Load manifest does not match the requested graph")
@@ -0,0 +1,250 @@
1
+ """Strict declarative contract for the narrow ASE Editor creation backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from ._editor_primitives import ExpansionSpec, expansion_classes, primitive_class
9
+
10
+ SUPPORTED_ASE_VERSIONS = frozenset({"1.9.6.2"})
11
+
12
+
13
+ class SpecError(ValueError):
14
+ """EditorGraphSpec is outside the explicitly supported v1 grammar."""
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class TemplateSpec:
19
+ guid: str
20
+ shader_name: str
21
+
22
+ def to_dict(self) -> dict:
23
+ return {"guid": self.guid, "shader_name": self.shader_name}
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class InputSpec:
28
+ name: str
29
+ type: str
30
+
31
+ def to_dict(self) -> dict:
32
+ return {"name": self.name, "type": self.type}
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class NodeSpec:
37
+ alias: str
38
+ kind: str
39
+ position: tuple[float, float]
40
+ type: str | None = None
41
+ property_name: str | None = None
42
+ inspector_name: str | None = None
43
+ tooltip: str | None = None
44
+ help: str | None = None
45
+ enabled_if: dict | None = None
46
+ parameter_type: str | None = None
47
+ name: str | None = None
48
+ code: str | None = None
49
+ output_type: str | None = None
50
+ inputs: tuple[InputSpec, ...] = ()
51
+ precision: str | None = None
52
+ default: Any | None = None
53
+ min: float | None = None
54
+ max: float | None = None
55
+ op: str | None = None
56
+ recipe: str | None = None
57
+ expansion: ExpansionSpec | None = None
58
+
59
+ @property
60
+ def ase_type(self) -> str:
61
+ if self.kind == "sampler":
62
+ return "SamplerNode"
63
+ if self.kind == "custom_expression":
64
+ return "CustomExpressionNode"
65
+ if self.kind == "primitive":
66
+ assert self.op is not None
67
+ cls = primitive_class(self.op)
68
+ assert cls is not None
69
+ return cls
70
+ if self.kind == "recipe":
71
+ # A recipe expands to multiple ASE nodes; the manifest records the
72
+ # authoritative expansion, not a single class name.
73
+ return "Recipe"
74
+ assert self.type is not None
75
+ return self.type
76
+
77
+ def to_dict(self) -> dict:
78
+ result: dict[str, Any] = {
79
+ "alias": self.alias,
80
+ "kind": self.kind,
81
+ "position": list(self.position),
82
+ }
83
+ if self.type is not None:
84
+ result["type"] = self.type
85
+ if self.precision is not None:
86
+ result["precision"] = self.precision
87
+ if self.default is not None:
88
+ result["default"] = self.default
89
+ if self.min is not None:
90
+ result["min"] = self.min
91
+ result["max"] = self.max
92
+ if self.property_name is not None:
93
+ result.update(
94
+ property_name=self.property_name,
95
+ inspector_name=self.inspector_name,
96
+ parameter_type=self.parameter_type,
97
+ )
98
+ if self.tooltip is not None:
99
+ result["tooltip"] = self.tooltip
100
+ if self.help is not None:
101
+ result["help"] = self.help
102
+ if self.enabled_if is not None:
103
+ result["enabled_if"] = dict(self.enabled_if)
104
+ if self.kind == "custom_expression":
105
+ result.update(
106
+ name=self.name,
107
+ code=self.code,
108
+ output_type=self.output_type,
109
+ inputs=[item.to_dict() for item in self.inputs],
110
+ )
111
+ if self.kind == "primitive":
112
+ result["op"] = self.op
113
+ if self.kind == "recipe":
114
+ result.update(
115
+ recipe=self.recipe,
116
+ code=self.code,
117
+ output_type=self.output_type,
118
+ inputs=[item.to_dict() for item in self.inputs],
119
+ expansion=self.expansion.to_dict(),
120
+ )
121
+ return result
122
+
123
+ def manifest_entry(self) -> dict:
124
+ result: dict[str, Any] = {"alias": self.alias, "kind": self.kind, "type": self.ase_type}
125
+ if self.property_name is not None:
126
+ result.update(
127
+ property_name=self.property_name,
128
+ inspector_name=self.inspector_name,
129
+ parameter_type=self.parameter_type,
130
+ )
131
+ if self.precision is not None:
132
+ result["precision"] = self.precision
133
+ if self.default is not None:
134
+ result["default"] = self.default
135
+ if self.min is not None:
136
+ result["min"] = self.min
137
+ result["max"] = self.max
138
+ if self.kind == "custom_expression":
139
+ result.update(
140
+ name=self.name,
141
+ code=self.code,
142
+ output_type=self.output_type,
143
+ inputs=[item.to_dict() for item in self.inputs],
144
+ )
145
+ if self.kind == "primitive":
146
+ result["op"] = self.op
147
+ if self.kind == "recipe":
148
+ if self.expansion_is_native():
149
+ classes = expansion_classes(self.expansion.primitives)
150
+ assert classes is not None
151
+ result.update(
152
+ recipe=self.recipe,
153
+ output_type=self.output_type,
154
+ expansion_nodes=[
155
+ {"id": primitive.id, "type": cls}
156
+ for primitive, cls in zip(self.expansion.primitives, classes)
157
+ ],
158
+ )
159
+ else:
160
+ # Fallback: the executor emits a single CustomExpressionNode
161
+ # using the authoritative HLSL code.
162
+ result["type"] = "CustomExpressionNode"
163
+ result.update(
164
+ name=self.recipe,
165
+ code=self.code,
166
+ output_type=self.output_type,
167
+ inputs=[item.to_dict() for item in self.inputs],
168
+ )
169
+ return result
170
+
171
+ def expansion_is_native(self) -> bool:
172
+ assert self.expansion is not None
173
+ if expansion_classes(self.expansion.primitives) is None:
174
+ return False
175
+ ids = {primitive.id for primitive in self.expansion.primitives}
176
+ return self.expansion.output in ids
177
+
178
+
179
+ @dataclass(frozen=True)
180
+ class EndpointSpec:
181
+ node: str
182
+ port: int
183
+
184
+ def to_dict(self) -> dict:
185
+ return {"node": self.node, "port": self.port}
186
+
187
+
188
+ @dataclass(frozen=True)
189
+ class ConnectionSpec:
190
+ source: EndpointSpec
191
+ destination: EndpointSpec
192
+
193
+ def to_dict(self) -> dict:
194
+ return {"from": self.source.to_dict(), "to": self.destination.to_dict()}
195
+
196
+
197
+ @dataclass(frozen=True)
198
+ class EditorGraphSpec:
199
+ version: int
200
+ template: TemplateSpec
201
+ nodes: tuple[NodeSpec, ...]
202
+ connections: tuple[ConnectionSpec, ...]
203
+ primitives_version: int | None = None
204
+
205
+ @classmethod
206
+ def from_dict(cls, value: Any) -> "EditorGraphSpec":
207
+ from ._editor_spec_validation import parse_editor_graph_spec
208
+
209
+ return parse_editor_graph_spec(value)
210
+
211
+ def to_dict(self) -> dict:
212
+ result: dict[str, Any] = {
213
+ "version": self.version,
214
+ "template": self.template.to_dict(),
215
+ "nodes": [node.to_dict() for node in self.nodes],
216
+ "connections": [item.to_dict() for item in self.connections],
217
+ }
218
+ if self.primitives_version is not None:
219
+ result["primitives_version"] = self.primitives_version
220
+ return result
221
+
222
+ def editor_payload(self, asset_path: str, temporary_asset_path: str) -> dict:
223
+ # The fixed ASE executor protocol remains v1. Presentation-only fields
224
+ # are finalized and verified by the CLI after ASE commits the graph.
225
+ payload = self.to_dict()
226
+ payload["version"] = 1
227
+ for node in payload["nodes"]:
228
+ node.pop("help", None)
229
+ node.pop("tooltip", None)
230
+ node.pop("enabled_if", None)
231
+ return {**payload, "asset_path": asset_path, "temporary_asset_path": temporary_asset_path}
232
+
233
+ def expected_manifest(self) -> dict:
234
+ return {
235
+ "template": self.template.to_dict(),
236
+ "nodes": [node.manifest_entry() for node in self.nodes],
237
+ "connections": [item.to_dict() for item in self.connections],
238
+ }
239
+
240
+
241
+ def load_editor_graph_spec(path: str | Path) -> EditorGraphSpec:
242
+ from ._editor_spec_io import load_editor_graph_spec as _load
243
+
244
+ return _load(path)
245
+
246
+
247
+ def route_create_backend(requested: str, spec: EditorGraphSpec | None) -> str:
248
+ from ._editor_spec_io import route_create_backend as _route
249
+
250
+ return _route(requested, spec)
@@ -0,0 +1,249 @@
1
+ """Editor-accurate node, title, and port geometry for meticulous layout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import json
7
+ from pathlib import Path
8
+
9
+ from .mcp_client import McpClient, McpError, tool_text
10
+ from .recompile import _detect_project_root
11
+ from .graph_geometry_parser import parse_geometry_payload
12
+
13
+
14
+ GEOMETRY_SNIPPET = r'''
15
+ string assetPath = {asset_path};
16
+ var shader = UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.Shader>(assetPath);
17
+ if (shader == null) throw new System.Exception("shader not found: " + assetPath);
18
+ var previousWindow = AmplifyShaderEditor.UIUtils.CurrentWindow;
19
+ var previousSelection = UnityEditor.Selection.activeObject;
20
+ AmplifyShaderEditor.AmplifyShaderEditorWindow win = null;
21
+ try
22
+ {{
23
+ win = UnityEditor.EditorWindow.CreateInstance<AmplifyShaderEditor.AmplifyShaderEditorWindow>();
24
+ AmplifyShaderEditor.UIUtils.CurrentWindow = win;
25
+ var flags = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public |
26
+ System.Reflection.BindingFlags.NonPublic;
27
+ System.Func<System.Type, string, System.Reflection.MemberInfo> findMember = (type, name) =>
28
+ {{
29
+ while (type != null)
30
+ {{
31
+ var property = type.GetProperty(name, flags);
32
+ if (property != null) return property;
33
+ var field = type.GetField(name, flags);
34
+ if (field != null) return field;
35
+ type = type.BaseType;
36
+ }}
37
+ return null;
38
+ }};
39
+ System.Func<System.Reflection.MemberInfo, object, object> readMember = (member, owner) =>
40
+ {{
41
+ if (member is System.Reflection.PropertyInfo)
42
+ return ((System.Reflection.PropertyInfo)member).GetValue(owner, null);
43
+ if (member is System.Reflection.FieldInfo)
44
+ return ((System.Reflection.FieldInfo)member).GetValue(owner);
45
+ return null;
46
+ }};
47
+ System.Func<object, string> displayText = value =>
48
+ {{
49
+ if (value == null) return "";
50
+ if (value is UnityEngine.GUIContent) return ((UnityEngine.GUIContent)value).text ?? "";
51
+ return value.ToString() ?? "";
52
+ }};
53
+ System.Func<string, string> encodeText = value => System.Convert.ToBase64String(
54
+ System.Text.Encoding.UTF8.GetBytes(value ?? ""));
55
+ var uiTextInfo = typeof(AmplifyShaderEditor.UIUtils).GetField(
56
+ "m_textInfo", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);
57
+ if (uiTextInfo != null && uiTextInfo.GetValue(null) == null)
58
+ uiTextInfo.SetValue(null, new System.Globalization.CultureInfo("en-US", false).TextInfo);
59
+ var delayedLoad = typeof(AmplifyShaderEditor.AmplifyShaderEditorWindow).GetField(
60
+ "m_delayedLoadObject", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
61
+ if (delayedLoad == null) throw new System.Exception("ASE delayed-load field not found");
62
+ delayedLoad.SetValue(win, shader);
63
+ win.Show();
64
+ var layoutEvent = new UnityEngine.Event(); layoutEvent.type = UnityEngine.EventType.Layout;
65
+ win.SendEvent(layoutEvent);
66
+ var repaintEvent = new UnityEngine.Event(); repaintEvent.type = UnityEngine.EventType.Repaint;
67
+ win.SendEvent(repaintEvent);
68
+ if (win.CurrentGraph == null || win.CurrentGraph.CurrentMasterNode == null)
69
+ throw new System.Exception("ASE graph did not load in GUI event");
70
+
71
+ // ASE only fills TruePosition size, HeaderPosition and port rectangles
72
+ // while a node participates in a layout pass. Large graphs leave
73
+ // off-screen nodes unmeasured, so run one non-drawing, all-visible layout
74
+ // pass in the temporary window before collecting geometry.
75
+ var probeDraw = win.CameraDrawInfo;
76
+ if (probeDraw == null)
77
+ throw new System.Exception("ASE camera draw-info is unavailable");
78
+ // ParentNode visibility compares against CameraArea width/height and does
79
+ // not use its x/y origin, so translate graph coordinates into the positive
80
+ // probe viewport instead of using a negative-origin rectangle.
81
+ probeDraw.CameraArea = new UnityEngine.Rect(0f, 0f, 20000000f, 20000000f);
82
+ probeDraw.TransformedCameraArea = probeDraw.CameraArea;
83
+ probeDraw.CameraOffset = new UnityEngine.Vector2(10000000f, 10000000f);
84
+ probeDraw.InvertedZoom = 1f;
85
+ probeDraw.CurrentEventType = UnityEngine.EventType.Repaint;
86
+ foreach (var probeNode in win.CurrentGraph.AllNodes)
87
+ {{
88
+ probeNode.OnNodeLogicUpdate(probeDraw);
89
+ probeNode.OnNodeLayout(probeDraw);
90
+ }}
91
+
92
+ var inv = System.Globalization.CultureInfo.InvariantCulture;
93
+ var sb = new System.Text.StringBuilder("ASECLI_GEOMETRY_V3\n");
94
+ foreach (var node in win.CurrentGraph.AllNodes)
95
+ {{
96
+ var rect = node.TruePosition;
97
+ // Template multi-pass graphs keep dormant master placeholders whose
98
+ // editor rect is intentionally empty. They are not part of the
99
+ // currently drawable graph and are omitted from the geometry payload.
100
+ if (rect.width <= 0 || rect.height <= 0) continue;
101
+ var global = node.GlobalPosition;
102
+ var scaleX = global.width / rect.width;
103
+ var scaleY = global.height / rect.height;
104
+ if (scaleX <= 0 || scaleY <= 0)
105
+ throw new System.Exception("ASE node coordinate transform unavailable for node " + node.UniqueId);
106
+ var nodeType = node.GetType();
107
+ var titleMember = findMember(nodeType, "TitleContent") ?? findMember(nodeType, "m_content") ??
108
+ findMember(nodeType, "Title") ?? findMember(nodeType, "m_title");
109
+ string nodeTitle = displayText(readMember(titleMember, node));
110
+ var headerMember = findMember(nodeType, "HeaderPosition") ?? findMember(nodeType, "m_headerPosition");
111
+ object headerValue = headerMember is System.Reflection.PropertyInfo
112
+ ? ((System.Reflection.PropertyInfo)headerMember).GetValue(node, null)
113
+ : headerMember is System.Reflection.FieldInfo
114
+ ? ((System.Reflection.FieldInfo)headerMember).GetValue(node) : null;
115
+ if (!(headerValue is UnityEngine.Rect))
116
+ throw new System.Exception("ASE node header geometry unavailable for node " + node.UniqueId);
117
+ var header = (UnityEngine.Rect)headerValue;
118
+ sb.Append("N|").Append(node.UniqueId).Append('|')
119
+ .Append(rect.x.ToString("R", inv)).Append('|').Append(rect.y.ToString("R", inv)).Append('|')
120
+ .Append(rect.width.ToString("R", inv)).Append('|').Append(rect.height.ToString("R", inv)).Append('|')
121
+ .Append((header.height / scaleY).ToString("R", inv)).Append('|')
122
+ .Append(encodeText(nodeTitle)).Append('\n');
123
+ foreach (var port in node.InputPorts)
124
+ {{
125
+ var portType = port.GetType();
126
+ var idMember = findMember(portType, "PortId") ?? findMember(portType, "UniqueId") ??
127
+ findMember(portType, "m_portId");
128
+ var posMember = findMember(portType, "Position") ?? findMember(portType, "PortPosition") ??
129
+ findMember(portType, "m_position");
130
+ var nameMember = findMember(portType, "Name") ?? findMember(portType, "m_name");
131
+ object idValue = idMember is System.Reflection.PropertyInfo
132
+ ? ((System.Reflection.PropertyInfo)idMember).GetValue(port, null)
133
+ : idMember is System.Reflection.FieldInfo ? ((System.Reflection.FieldInfo)idMember).GetValue(port) : null;
134
+ object posValue = posMember is System.Reflection.PropertyInfo
135
+ ? ((System.Reflection.PropertyInfo)posMember).GetValue(port, null)
136
+ : posMember is System.Reflection.FieldInfo ? ((System.Reflection.FieldInfo)posMember).GetValue(port) : null;
137
+ if (idValue == null || (!(posValue is UnityEngine.Rect) && !(posValue is UnityEngine.Vector2)))
138
+ throw new System.Exception("ASE input-port geometry unavailable for node " + node.UniqueId);
139
+ if (posValue is UnityEngine.Rect && (((UnityEngine.Rect)posValue).width <= 0 || ((UnityEngine.Rect)posValue).height <= 0))
140
+ continue;
141
+ if (posValue is UnityEngine.Vector2 && ((UnityEngine.Vector2)posValue) == UnityEngine.Vector2.zero)
142
+ continue;
143
+ var screenCenter = posValue is UnityEngine.Rect ? ((UnityEngine.Rect)posValue).center : (UnityEngine.Vector2)posValue;
144
+ string portName = displayText(readMember(nameMember, port));
145
+ var portCenter = new UnityEngine.Vector2(
146
+ rect.x + (screenCenter.x - global.x) / scaleX,
147
+ rect.y + (screenCenter.y - global.y) / scaleY);
148
+ sb.Append("I|").Append(node.UniqueId).Append('|').Append(idValue).Append('|')
149
+ .Append(portCenter.x.ToString("R", inv)).Append('|')
150
+ .Append(portCenter.y.ToString("R", inv)).Append('|')
151
+ .Append(encodeText(portName)).Append('\n');
152
+ }}
153
+ foreach (var port in node.OutputPorts)
154
+ {{
155
+ var portType = port.GetType();
156
+ var idMember = findMember(portType, "PortId") ?? findMember(portType, "UniqueId") ??
157
+ findMember(portType, "m_portId");
158
+ var posMember = findMember(portType, "Position") ?? findMember(portType, "PortPosition") ??
159
+ findMember(portType, "m_position");
160
+ var nameMember = findMember(portType, "Name") ?? findMember(portType, "m_name");
161
+ object idValue = idMember is System.Reflection.PropertyInfo
162
+ ? ((System.Reflection.PropertyInfo)idMember).GetValue(port, null)
163
+ : idMember is System.Reflection.FieldInfo ? ((System.Reflection.FieldInfo)idMember).GetValue(port) : null;
164
+ object posValue = posMember is System.Reflection.PropertyInfo
165
+ ? ((System.Reflection.PropertyInfo)posMember).GetValue(port, null)
166
+ : posMember is System.Reflection.FieldInfo ? ((System.Reflection.FieldInfo)posMember).GetValue(port) : null;
167
+ if (idValue == null || (!(posValue is UnityEngine.Rect) && !(posValue is UnityEngine.Vector2)))
168
+ throw new System.Exception("ASE output-port geometry unavailable for node " + node.UniqueId);
169
+ if (posValue is UnityEngine.Rect && (((UnityEngine.Rect)posValue).width <= 0 || ((UnityEngine.Rect)posValue).height <= 0))
170
+ continue;
171
+ if (posValue is UnityEngine.Vector2 && ((UnityEngine.Vector2)posValue) == UnityEngine.Vector2.zero)
172
+ continue;
173
+ var screenCenter = posValue is UnityEngine.Rect ? ((UnityEngine.Rect)posValue).center : (UnityEngine.Vector2)posValue;
174
+ string portName = displayText(readMember(nameMember, port));
175
+ var portCenter = new UnityEngine.Vector2(
176
+ rect.x + (screenCenter.x - global.x) / scaleX,
177
+ rect.y + (screenCenter.y - global.y) / scaleY);
178
+ sb.Append("O|").Append(node.UniqueId).Append('|').Append(idValue).Append('|')
179
+ .Append(portCenter.x.ToString("R", inv)).Append('|')
180
+ .Append(portCenter.y.ToString("R", inv)).Append('|')
181
+ .Append(encodeText(portName)).Append('\n');
182
+ }}
183
+ }}
184
+ return sb.ToString();
185
+ }}
186
+ finally
187
+ {{
188
+ UnityEditor.Selection.activeObject = previousSelection;
189
+ AmplifyShaderEditor.UIUtils.CurrentWindow = previousWindow;
190
+ if (win != null) {{ win.Close(); UnityEngine.Object.DestroyImmediate(win); }}
191
+ }}
192
+ '''
193
+
194
+
195
+ @dataclass(frozen=True)
196
+ class EditorNodeGeometry:
197
+ x: float
198
+ y: float
199
+ width: float
200
+ height: float
201
+ title_height: float
202
+ input_ports: dict[str, tuple[float, float]]
203
+ output_ports: dict[str, tuple[float, float]]
204
+ node_title: str = ""
205
+ input_port_labels: dict[str, str] | None = None
206
+ output_port_labels: dict[str, str] | None = None
207
+
208
+
209
+ def inspect_graph_geometry_via_mcp(
210
+ shader_path: str, *, mcp_url: str = "http://127.0.0.1:8080/mcp",
211
+ instance_token: str | None = None, unity_instance: str | None = None,
212
+ allow_remote_mcp: bool = False,
213
+ ) -> dict[str, EditorNodeGeometry]:
214
+ path = Path(shader_path).resolve()
215
+ if not path.exists():
216
+ raise FileNotFoundError(shader_path)
217
+ root = _detect_project_root(path)
218
+ asset_path = path.as_posix().removeprefix(root.as_posix() + "/")
219
+ client = McpClient(mcp_url, instance_token=instance_token, allow_remote=allow_remote_mcp)
220
+ client.connect()
221
+ args = {"action": "execute", "code": GEOMETRY_SNIPPET.format(asset_path=json.dumps(asset_path))}
222
+ if unity_instance is not None:
223
+ args["unity_instance"] = unity_instance
224
+ envelope_text = tool_text(client.call_tool("execute_code", args), instance_token)
225
+ try:
226
+ envelope = json.loads(envelope_text)
227
+ except json.JSONDecodeError as exc:
228
+ raise McpError("MCP graph-geometry result has an unexpected envelope") from exc
229
+ if not isinstance(envelope, dict):
230
+ raise McpError("MCP graph-geometry result has an unexpected envelope")
231
+ if envelope.get("success") is False:
232
+ data = envelope.get("data") if isinstance(envelope.get("data"), dict) else {}
233
+ detail = envelope.get("message") or data.get("reason") or "Unity execution failed"
234
+ available = data.get("available_instances")
235
+ if isinstance(available, list) and available:
236
+ detail = f"{detail}; available instances: {', '.join(str(item) for item in available)}"
237
+ raise McpError(str(detail), data=data)
238
+ try:
239
+ payload = envelope["data"]["result"]
240
+ except (KeyError, TypeError) as exc:
241
+ raise McpError("MCP graph-geometry result has an unexpected envelope") from exc
242
+ if not isinstance(payload, str) or not payload.startswith(("ASECLI_GEOMETRY_V2\n", "ASECLI_GEOMETRY_V3\n")):
243
+ raise McpError("MCP graph-geometry result has an unexpected payload")
244
+ return parse_geometry_payload(payload, EditorNodeGeometry)
245
+
246
+
247
+ def _parse_geometry_payload(payload: str) -> dict[str, EditorNodeGeometry]:
248
+ """Compatibility wrapper retained for focused parser tests/importers."""
249
+ return parse_geometry_payload(payload, EditorNodeGeometry)