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.
- asecli/__init__.py +3 -0
- asecli/bridge/__init__.py +40 -0
- asecli/bridge/_editor_port_contract.py +131 -0
- asecli/bridge/_editor_primitives.py +205 -0
- asecli/bridge/_editor_spec_grammar.py +245 -0
- asecli/bridge/_editor_spec_io.py +26 -0
- asecli/bridge/_editor_spec_validation.py +218 -0
- asecli/bridge/_gui_handoff_store.py +78 -0
- asecli/bridge/_gui_project.py +35 -0
- asecli/bridge/_gui_resource_store.py +159 -0
- asecli/bridge/_gui_resource_upgrade.py +122 -0
- asecli/bridge/editor_create.py +173 -0
- asecli/bridge/editor_spec.py +250 -0
- asecli/bridge/graph_geometry.py +249 -0
- asecli/bridge/graph_geometry_parser.py +57 -0
- asecli/bridge/graph_inspect.py +136 -0
- asecli/bridge/gui_handoff.py +100 -0
- asecli/bridge/gui_presentation.py +70 -0
- asecli/bridge/gui_provider_detection.py +197 -0
- asecli/bridge/gui_runtime_probe.py +25 -0
- asecli/bridge/gui_support.py +240 -0
- asecli/bridge/gui_support_resource.py +58 -0
- asecli/bridge/mcp_client.py +231 -0
- asecli/bridge/recompile.py +107 -0
- asecli/bridge/resource_text.py +12 -0
- asecli/bridge/resources/asecli_material_gui.authoring.part00.cs.txt +238 -0
- asecli/bridge/resources/asecli_material_gui.authoring.part01.cs.txt +235 -0
- asecli/bridge/resources/asecli_material_gui.authoring.store.cs.txt +125 -0
- asecli/bridge/resources/asecli_material_gui.condition.cs.txt +99 -0
- asecli/bridge/resources/asecli_material_gui.hydration.cs.txt +105 -0
- asecli/bridge/resources/asecli_material_gui.part00.cs.txt +300 -0
- asecli/bridge/resources/asecli_material_gui.part01.cs.txt +290 -0
- asecli/bridge/resources/asecli_material_gui.reconciliation.cs.txt +174 -0
- asecli/bridge/resources/asecli_material_gui.transaction.cs.txt +108 -0
- asecli/bridge/resources/editor_create.part00.cs.txt +176 -0
- asecli/bridge/resources/editor_create.part01.cs.txt +145 -0
- asecli/bridge/resources/editor_create.part02.cs.txt +139 -0
- asecli/bridge/resources/editor_create.part03.cs.txt +161 -0
- asecli/bridge/resources/editor_create.part04.cs.txt +114 -0
- asecli/bridge/resources/wire_route.transaction.cs.txt +140 -0
- asecli/bridge/wire_route.py +127 -0
- asecli/checks/__init__.py +10 -0
- asecli/checks/checksum.py +53 -0
- asecli/checks/local_vars.py +135 -0
- asecli/checks/usage.py +141 -0
- asecli/checks/validate.py +129 -0
- asecli/cli/__init__.py +1 -0
- asecli/cli/commands.py +237 -0
- asecli/cli/commentary_command.py +158 -0
- asecli/cli/create_command.py +250 -0
- asecli/cli/custom_gui_command.py +170 -0
- asecli/cli/gui_provider.py +30 -0
- asecli/cli/gui_support_command.py +35 -0
- asecli/cli/io.py +214 -0
- asecli/cli/layout_command.py +213 -0
- asecli/cli/main.py +245 -0
- asecli/cli/recompile_metadata.py +53 -0
- asecli/cli/skill_command.py +121 -0
- asecli/cli/usage_command.py +42 -0
- asecli/core/__init__.py +84 -0
- asecli/core/comment_bounds.py +166 -0
- asecli/core/comment_layout.py +167 -0
- asecli/core/comment_metrics.py +132 -0
- asecli/core/comment_purpose.py +140 -0
- asecli/core/commentary.py +225 -0
- asecli/core/compiled_metadata.py +135 -0
- asecli/core/compiled_properties.py +190 -0
- asecli/core/custom_gui.py +250 -0
- asecli/core/custom_gui_versions.py +35 -0
- asecli/core/fishbone_placement.py +159 -0
- asecli/core/fishbone_topology.py +186 -0
- asecli/core/graph_ops.py +122 -0
- asecli/core/layout.py +174 -0
- asecli/core/layout_audit.py +237 -0
- asecli/core/layout_audit_geometry.py +247 -0
- asecli/core/layout_audit_repeated.py +44 -0
- asecli/core/layout_graph.py +113 -0
- asecli/core/local_vars.py +54 -0
- asecli/core/material_gui_condition.py +68 -0
- asecli/core/material_gui_protocol.py +35 -0
- asecli/core/material_gui_spec.py +191 -0
- asecli/core/meticulous_layout.py +114 -0
- asecli/core/model.py +237 -0
- asecli/core/property_presentation.py +233 -0
- asecli/core/wire_geometry.py +83 -0
- asecli/core/wire_router.py +248 -0
- asecli/schema/__init__.py +49 -0
- asecli/schema/data/observed.json +16 -0
- asecli/schema/data/schemas.json +14106 -0
- asecli/skills/asecli/SKILL.md +275 -0
- asecli/skills/asecli/references/layout-standard.md +99 -0
- asecli/skills/asecli/references/master-output-settings-standard.md +51 -0
- asecli/skills/asecli/references/material-property-standard.md +108 -0
- asecli-0.6.2.dist-info/METADATA +594 -0
- asecli-0.6.2.dist-info/RECORD +98 -0
- asecli-0.6.2.dist-info/WHEEL +4 -0
- asecli-0.6.2.dist-info/entry_points.txt +2 -0
- 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)
|