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
asecli/core/model.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Core ASE graph data model (TASK-0003/TASK-0004)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
import hashlib
|
|
7
|
+
from collections import Counter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class NodeLine:
|
|
12
|
+
"""A raw ``Node;`` instruction line from the ASEBEGIN block."""
|
|
13
|
+
|
|
14
|
+
type_name: str
|
|
15
|
+
node_id: str
|
|
16
|
+
raw_fields: list[str]
|
|
17
|
+
|
|
18
|
+
def to_line(self) -> str:
|
|
19
|
+
return ";".join(self.raw_fields)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class WireLine:
|
|
24
|
+
"""A raw ``WireConnection;`` instruction.
|
|
25
|
+
|
|
26
|
+
ASE format: ``WireConnection;<in_node>;<in_port>;<out_node>;<out_port>``
|
|
27
|
+
(destination first, source second — verified against real files).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
out_node: str
|
|
31
|
+
out_port: str
|
|
32
|
+
in_node: str
|
|
33
|
+
in_port: str
|
|
34
|
+
|
|
35
|
+
def to_line(self) -> str:
|
|
36
|
+
return f"WireConnection;{self.in_node};{self.in_port};{self.out_node};{self.out_port}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class AseGraph:
|
|
41
|
+
"""Parsed representation of one ASEBEGIN..ASEEND block.
|
|
42
|
+
|
|
43
|
+
Preserves the raw instruction stream so that serialize(parse(x)) == x
|
|
44
|
+
byte-for-byte, and mutations produce minimal diffs. Unrecognized lines
|
|
45
|
+
(canvas params, comments, future ASE additions) are kept verbatim.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
version: str = ""
|
|
49
|
+
instructions: list[tuple[str, str]] = field(default_factory=list) # (kind, raw)
|
|
50
|
+
eol: str = "\n"
|
|
51
|
+
version_eol: str = "\n"
|
|
52
|
+
line_endings: list[str] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
def node_by_id(self, node_id: str) -> NodeLine | None:
|
|
55
|
+
for kind, raw in self.instructions:
|
|
56
|
+
if kind == "node":
|
|
57
|
+
n = parse_node_line(raw)
|
|
58
|
+
if n.node_id == node_id:
|
|
59
|
+
return n
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def nodes(self) -> list[NodeLine]:
|
|
64
|
+
return [parse_node_line(raw) for kind, raw in self.instructions if kind == "node"]
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def wires(self) -> list[WireLine]:
|
|
68
|
+
return [_parse_wire_line(raw) for kind, raw in self.instructions if kind == "wire"]
|
|
69
|
+
|
|
70
|
+
def replace_node(self, node: NodeLine) -> None:
|
|
71
|
+
for i, (kind, raw) in enumerate(self.instructions):
|
|
72
|
+
if kind == "node" and parse_node_line(raw).node_id == node.node_id:
|
|
73
|
+
self.instructions[i] = ("node", node.to_line())
|
|
74
|
+
return
|
|
75
|
+
raise KeyError(f"node id {node.node_id} not found")
|
|
76
|
+
|
|
77
|
+
def add_node(self, node: NodeLine) -> None:
|
|
78
|
+
if self.node_by_id(node.node_id) is not None:
|
|
79
|
+
raise ValueError(f"node id {node.node_id} already exists")
|
|
80
|
+
self._prepare_append()
|
|
81
|
+
self.instructions.append(("node", node.to_line()))
|
|
82
|
+
self.line_endings.append(self.eol)
|
|
83
|
+
|
|
84
|
+
def add_wire(self, wire: WireLine) -> None:
|
|
85
|
+
self._prepare_append()
|
|
86
|
+
self.instructions.append(("wire", wire.to_line()))
|
|
87
|
+
self.line_endings.append(self.eol)
|
|
88
|
+
|
|
89
|
+
def remove_wire(self, out_node: str, out_port: str, in_node: str, in_port: str) -> bool:
|
|
90
|
+
target = (out_node, out_port, in_node, in_port)
|
|
91
|
+
for i, (kind, raw) in enumerate(self.instructions):
|
|
92
|
+
if kind == "wire":
|
|
93
|
+
w = _parse_wire_line(raw)
|
|
94
|
+
if (w.out_node, w.out_port, w.in_node, w.in_port) == target:
|
|
95
|
+
self.delete_instruction(i)
|
|
96
|
+
return True
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
def serialize(self) -> str:
|
|
100
|
+
version = f"Version={self.version}" if not self.version.startswith("Version=") else self.version
|
|
101
|
+
parts = [version + self.version_eol]
|
|
102
|
+
for index, (_, raw) in enumerate(self.instructions):
|
|
103
|
+
ending = self.line_endings[index] if index < len(self.line_endings) else self.eol
|
|
104
|
+
parts.append(raw + ending)
|
|
105
|
+
return "".join(parts)
|
|
106
|
+
|
|
107
|
+
def delete_instruction(self, index: int) -> None:
|
|
108
|
+
del self.instructions[index]
|
|
109
|
+
if index < len(self.line_endings):
|
|
110
|
+
del self.line_endings[index]
|
|
111
|
+
|
|
112
|
+
def _prepare_append(self) -> None:
|
|
113
|
+
while len(self.line_endings) < len(self.instructions):
|
|
114
|
+
self.line_endings.append(self.eol)
|
|
115
|
+
if self.instructions:
|
|
116
|
+
if self.line_endings[len(self.instructions) - 1] == "":
|
|
117
|
+
self.line_endings[len(self.instructions) - 1] = self.eol
|
|
118
|
+
elif not self.instructions and self.version_eol == "":
|
|
119
|
+
self.version_eol = self.eol
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def parse_graph_text(body: str) -> AseGraph:
|
|
123
|
+
"""Parse the text between ``/*ASEBEGIN`` and ``ASEEND*/``.
|
|
124
|
+
|
|
125
|
+
The body excludes the markers themselves; first line is ``Version=..``.
|
|
126
|
+
Raises ``ValueError`` on structural violations.
|
|
127
|
+
"""
|
|
128
|
+
graph = AseGraph()
|
|
129
|
+
encoded_lines = body.splitlines(keepends=True)
|
|
130
|
+
if not encoded_lines:
|
|
131
|
+
raise ValueError("empty ASE graph body")
|
|
132
|
+
first, graph.version_eol = _split_line_ending(encoded_lines[0])
|
|
133
|
+
if not first.startswith("Version="):
|
|
134
|
+
raise ValueError(f"first graph line must be Version=..., got: {first!r}")
|
|
135
|
+
graph.version = first.removeprefix("Version=")
|
|
136
|
+
observed_endings = [graph.version_eol] if graph.version_eol else []
|
|
137
|
+
for encoded in encoded_lines[1:]:
|
|
138
|
+
line, ending = _split_line_ending(encoded)
|
|
139
|
+
if not line:
|
|
140
|
+
graph.instructions.append(("other", ""))
|
|
141
|
+
elif line.startswith("Node;"):
|
|
142
|
+
parse_node_line(line)
|
|
143
|
+
graph.instructions.append(("node", line))
|
|
144
|
+
elif line.startswith("WireConnection;"):
|
|
145
|
+
_parse_wire_line(line)
|
|
146
|
+
graph.instructions.append(("wire", line))
|
|
147
|
+
else:
|
|
148
|
+
graph.instructions.append(("other", line))
|
|
149
|
+
graph.line_endings.append(ending)
|
|
150
|
+
if ending:
|
|
151
|
+
observed_endings.append(ending)
|
|
152
|
+
if observed_endings:
|
|
153
|
+
counts = Counter(observed_endings)
|
|
154
|
+
graph.eol = max(counts, key=lambda item: (counts[item], item == graph.version_eol))
|
|
155
|
+
return graph
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _split_line_ending(line: str) -> tuple[str, str]:
|
|
159
|
+
if line.endswith("\r\n"):
|
|
160
|
+
return line[:-2], "\r\n"
|
|
161
|
+
if line.endswith("\n") or line.endswith("\r"):
|
|
162
|
+
return line[:-1], line[-1]
|
|
163
|
+
return line, ""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def parse_node_line(line: str) -> NodeLine:
|
|
167
|
+
"""Parse one serialized ``Node;...`` instruction."""
|
|
168
|
+
fields = line.split(";")
|
|
169
|
+
if len(fields) < 4:
|
|
170
|
+
raise ValueError(f"malformed Node line: {line!r}")
|
|
171
|
+
return NodeLine(type_name=fields[1], node_id=fields[2], raw_fields=fields)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# Compatibility alias for callers from the pre-0.2 private API period.
|
|
175
|
+
_parse_node_line = parse_node_line
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _parse_wire_line(line: str) -> WireLine:
|
|
179
|
+
fields = line.split(";")
|
|
180
|
+
if len(fields) != 5:
|
|
181
|
+
raise ValueError(f"malformed WireConnection line: {line!r}")
|
|
182
|
+
return WireLine(in_node=fields[1], in_port=fields[2], out_node=fields[3], out_port=fields[4])
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
BEGIN = "/*ASEBEGIN"
|
|
186
|
+
END = "ASEEND*/"
|
|
187
|
+
CHECKSUM_PREFIX = "//CHKSM="
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass
|
|
191
|
+
class AseFile:
|
|
192
|
+
"""A shader/function file containing exactly one ASE graph block."""
|
|
193
|
+
|
|
194
|
+
prefix: str
|
|
195
|
+
body: str # between BEGIN (exclusive) and END (exclusive)
|
|
196
|
+
suffix: str # from END marker through checksum line(s)
|
|
197
|
+
graph: AseGraph = field(default_factory=AseGraph)
|
|
198
|
+
source_digest: str | None = None
|
|
199
|
+
|
|
200
|
+
@classmethod
|
|
201
|
+
def from_text(cls, text: str) -> "AseFile":
|
|
202
|
+
begin = text.find(BEGIN)
|
|
203
|
+
if begin < 0:
|
|
204
|
+
raise ValueError("no /*ASEBEGIN marker found")
|
|
205
|
+
end = text.find(END, begin)
|
|
206
|
+
if end < 0:
|
|
207
|
+
raise ValueError("no ASEEND*/ marker found")
|
|
208
|
+
prefix = text[: begin + len(BEGIN)]
|
|
209
|
+
# body starts after the newline following BEGIN, ends before END marker
|
|
210
|
+
body_start = begin + len(BEGIN)
|
|
211
|
+
if text[body_start : body_start + 2] == "\r\n":
|
|
212
|
+
body_start += 2
|
|
213
|
+
prefix += "\r\n"
|
|
214
|
+
elif text[body_start] == "\n":
|
|
215
|
+
body_start += 1
|
|
216
|
+
prefix += "\n"
|
|
217
|
+
body = text[body_start:end]
|
|
218
|
+
suffix = text[end:]
|
|
219
|
+
graph = parse_graph_text(body)
|
|
220
|
+
return cls(prefix=prefix, body=body, suffix=suffix, graph=graph)
|
|
221
|
+
|
|
222
|
+
@classmethod
|
|
223
|
+
def from_path(cls, path: str | __import__("pathlib").Path) -> "AseFile":
|
|
224
|
+
from pathlib import Path as _P
|
|
225
|
+
|
|
226
|
+
data = _P(path).read_bytes()
|
|
227
|
+
result = cls.from_text(data.decode("utf-8"))
|
|
228
|
+
result.source_digest = hashlib.sha256(data).hexdigest()
|
|
229
|
+
return result
|
|
230
|
+
|
|
231
|
+
def serialize(self) -> str:
|
|
232
|
+
"""Rebuild full text; without mutations this is byte-identical to source."""
|
|
233
|
+
return self.prefix + self.graph.serialize() + self.suffix
|
|
234
|
+
|
|
235
|
+
def replace_graph(self, graph: AseGraph) -> None:
|
|
236
|
+
"""Replace only the ASE graph while preserving this file's outer shell."""
|
|
237
|
+
self.graph = graph
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Machine-checkable presentation contract for ASECLI-managed properties."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from .compiled_properties import inspect_compiled_properties
|
|
8
|
+
from .custom_gui_versions import require_property_metadata_tail_version
|
|
9
|
+
from .model import AseFile
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
PROPERTY_PRESENTATION_CONTRACT = {
|
|
13
|
+
"contract": "asecli.property-presentation.v2",
|
|
14
|
+
"scope": "exported_properties",
|
|
15
|
+
"display_name": {"language": "zh-Hans", "requires_han": True},
|
|
16
|
+
"tooltip": {
|
|
17
|
+
"attribute": "TooltipMzgui",
|
|
18
|
+
"language": "zh-Hans",
|
|
19
|
+
"requires_han": True,
|
|
20
|
+
"providers": ["MZGUI.MZGUI", "ASECLI.MaterialGUI.ASECLIMaterialGUI"],
|
|
21
|
+
"automatic_fields": ["property_name", "shader_default_value"],
|
|
22
|
+
"property_name_format": "english_identifier",
|
|
23
|
+
},
|
|
24
|
+
"help_box": {
|
|
25
|
+
"required": False,
|
|
26
|
+
"user_authored": True,
|
|
27
|
+
"attributes": ["HelpBoxMzgui", "ASECLIHelpBox"],
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_HAN = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]")
|
|
32
|
+
_ENGLISH_PROPERTY_NAME = re.compile(r"^_[A-Za-z][A-Za-z0-9_]*$")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def contains_han(text: object) -> bool:
|
|
36
|
+
return isinstance(text, str) and bool(_HAN.search(text))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def set_property_display_name(ase_file: AseFile, node_id: str, display_name: str) -> dict:
|
|
40
|
+
"""Set the graph and compiled ShaderLab display name as one semantic change."""
|
|
41
|
+
if not isinstance(display_name, str) or not display_name.strip() or len(display_name) > 128:
|
|
42
|
+
raise ValueError("property display_name must be a non-empty string of at most 128 characters")
|
|
43
|
+
if any(char in display_name for char in ('"', "'", ";", "\\", "\r", "\n")):
|
|
44
|
+
raise ValueError("property display_name contains a forbidden quote, separator, escape, or newline")
|
|
45
|
+
if not contains_han(display_name):
|
|
46
|
+
raise ValueError("property display_name must contain Chinese characters")
|
|
47
|
+
require_property_metadata_tail_version(ase_file.graph)
|
|
48
|
+
node = ase_file.graph.node_by_id(node_id)
|
|
49
|
+
if node is None:
|
|
50
|
+
raise KeyError(f"node {node_id} not found")
|
|
51
|
+
if len(node.raw_fields) <= 8 or node.raw_fields[6] != "Property":
|
|
52
|
+
raise ValueError(f"node {node_id} is not an exported PropertyNode with a display name")
|
|
53
|
+
|
|
54
|
+
property_name = node.raw_fields[7]
|
|
55
|
+
pattern = re.compile(
|
|
56
|
+
rf'(?m)^(?P<head>[ \t]*(?:\[[^\]\r\n]*\][ \t]*)*{re.escape(property_name)}[ \t]*\([ \t]*")'
|
|
57
|
+
r'(?P<label>[^"\r\n]*)(?P<tail>"[ \t]*,)'
|
|
58
|
+
)
|
|
59
|
+
matches = list(pattern.finditer(ase_file.prefix))
|
|
60
|
+
if len(matches) != 1:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"expected one compiled ShaderLab declaration for {property_name!r}, found {len(matches)}"
|
|
63
|
+
)
|
|
64
|
+
match = matches[0]
|
|
65
|
+
before_graph = node.raw_fields[8]
|
|
66
|
+
before_compiled = match.group("label")
|
|
67
|
+
node.raw_fields[8] = display_name
|
|
68
|
+
ase_file.graph.replace_node(node)
|
|
69
|
+
ase_file.prefix = (
|
|
70
|
+
ase_file.prefix[: match.start("label")]
|
|
71
|
+
+ display_name
|
|
72
|
+
+ ase_file.prefix[match.end("label") :]
|
|
73
|
+
)
|
|
74
|
+
return {
|
|
75
|
+
"kind": "property_display_name",
|
|
76
|
+
"node_id": node_id,
|
|
77
|
+
"property_name": property_name,
|
|
78
|
+
"before": {"graph": before_graph, "compiled": before_compiled},
|
|
79
|
+
"after": display_name,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def inspect_property_presentation(
|
|
84
|
+
editor: dict,
|
|
85
|
+
properties: list[dict],
|
|
86
|
+
compiled_source: str,
|
|
87
|
+
*,
|
|
88
|
+
supported_editors: frozenset[str],
|
|
89
|
+
) -> dict:
|
|
90
|
+
"""Inspect already-parsed Custom GUI state without importing graph modules."""
|
|
91
|
+
claimed = (
|
|
92
|
+
editor.get("graph") in supported_editors
|
|
93
|
+
or editor.get("compiled") in supported_editors
|
|
94
|
+
)
|
|
95
|
+
managed = (
|
|
96
|
+
editor.get("graph") in supported_editors
|
|
97
|
+
and editor.get("compiled") == editor.get("graph")
|
|
98
|
+
and editor.get("consistent") is True
|
|
99
|
+
)
|
|
100
|
+
violations: list[str] = []
|
|
101
|
+
if not managed:
|
|
102
|
+
violations.append("editor:mzgui_compatible_material_gui_required")
|
|
103
|
+
|
|
104
|
+
compiled_properties, compiled_properties_error = inspect_compiled_properties(compiled_source)
|
|
105
|
+
if compiled_properties_error is not None:
|
|
106
|
+
violations.append("inspection:compiled_properties_unavailable")
|
|
107
|
+
|
|
108
|
+
graph_by_name = _unique_properties(properties, "graph", violations)
|
|
109
|
+
compiled_by_name = _unique_properties(compiled_properties, "compiled", violations)
|
|
110
|
+
graph_names = set(graph_by_name)
|
|
111
|
+
compiled_names = set(compiled_by_name)
|
|
112
|
+
visible_compiled_names = {
|
|
113
|
+
name for name, prop in compiled_by_name.items() if not prop.get("hidden", False)
|
|
114
|
+
}
|
|
115
|
+
graph_only = sorted(graph_names - compiled_names) if compiled_properties_error is None else []
|
|
116
|
+
compiled_only = sorted(visible_compiled_names - graph_names) if compiled_properties_error is None else []
|
|
117
|
+
violations.extend(f"property:{name}:compiled_property_required" for name in graph_only)
|
|
118
|
+
violations.extend(f"compiled_property:{name}:graph_property_required" for name in compiled_only)
|
|
119
|
+
|
|
120
|
+
property_results = []
|
|
121
|
+
for prop in properties:
|
|
122
|
+
property_name = prop.get("property_name")
|
|
123
|
+
property_violations = []
|
|
124
|
+
if not isinstance(property_name, str) or not _ENGLISH_PROPERTY_NAME.fullmatch(property_name):
|
|
125
|
+
property_violations.append("property_name:english_identifier_required")
|
|
126
|
+
if not contains_han(prop.get("display_name")):
|
|
127
|
+
property_violations.append("display_name:chinese_required")
|
|
128
|
+
tooltip_values = [
|
|
129
|
+
item.get("text")
|
|
130
|
+
for item in prop.get("attributes", [])
|
|
131
|
+
if item.get("type") in {"TooltipMzgui", "ASECLITooltip"}
|
|
132
|
+
]
|
|
133
|
+
if len(tooltip_values) != 1 or not contains_han(tooltip_values[0]):
|
|
134
|
+
property_violations.append("tooltip:chinese_required")
|
|
135
|
+
compiled = compiled_by_name.get(property_name)
|
|
136
|
+
if compiled is not None:
|
|
137
|
+
compiled_display_name = compiled.get("display_name")
|
|
138
|
+
if not contains_han(compiled_display_name):
|
|
139
|
+
property_violations.append("compiled_display_name:chinese_required")
|
|
140
|
+
if compiled_display_name != prop.get("display_name"):
|
|
141
|
+
property_violations.append("display_name:graph_compiled_mismatch")
|
|
142
|
+
compiled_tooltip_values = [
|
|
143
|
+
item.get("text")
|
|
144
|
+
for item in compiled.get("attributes", [])
|
|
145
|
+
if item.get("type") in {"TooltipMzgui", "ASECLITooltip"}
|
|
146
|
+
]
|
|
147
|
+
if len(compiled_tooltip_values) != 1 or not contains_han(compiled_tooltip_values[0]):
|
|
148
|
+
property_violations.append("compiled_tooltip:chinese_required")
|
|
149
|
+
if tooltip_values != compiled_tooltip_values:
|
|
150
|
+
property_violations.append("tooltip:graph_compiled_mismatch")
|
|
151
|
+
label = property_name if isinstance(property_name, str) else str(prop.get("node_id"))
|
|
152
|
+
violations.extend(f"property:{label}:{item}" for item in property_violations)
|
|
153
|
+
property_results.append(
|
|
154
|
+
{
|
|
155
|
+
"node_id": prop.get("node_id"),
|
|
156
|
+
"property_name": property_name,
|
|
157
|
+
"display_name": prop.get("display_name"),
|
|
158
|
+
"compiled_display_name": compiled.get("display_name") if compiled else None,
|
|
159
|
+
"valid": not property_violations,
|
|
160
|
+
"violations": property_violations,
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
**PROPERTY_PRESENTATION_CONTRACT,
|
|
166
|
+
"claimed": claimed,
|
|
167
|
+
"managed": managed,
|
|
168
|
+
"inspection": "error" if compiled_properties_error is not None else "complete",
|
|
169
|
+
"inspection_error": compiled_properties_error,
|
|
170
|
+
"valid": managed and not violations,
|
|
171
|
+
"violations": violations,
|
|
172
|
+
"properties": property_results,
|
|
173
|
+
"graph_properties": sorted(graph_names),
|
|
174
|
+
"compiled_properties": sorted(compiled_names),
|
|
175
|
+
"reconciliation": {
|
|
176
|
+
"matched": sorted(graph_names & compiled_names),
|
|
177
|
+
"graph_only": graph_only,
|
|
178
|
+
"compiled_only": compiled_only,
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def require_managed_property_presentation(ase_file: AseFile) -> dict | None:
|
|
184
|
+
"""Fail closed only after a file claims an MZGUI-compatible material GUI."""
|
|
185
|
+
from .custom_gui import inspect_custom_gui
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
result = inspect_custom_gui(ase_file)["property_presentation"]
|
|
189
|
+
except ValueError as exc:
|
|
190
|
+
if not _has_asecli_presentation_marker(ase_file):
|
|
191
|
+
return None
|
|
192
|
+
raise ValueError(
|
|
193
|
+
f"MZGUI-compatible property presentation inspection failed: {exc}"
|
|
194
|
+
) from exc
|
|
195
|
+
if result["valid"]:
|
|
196
|
+
return result
|
|
197
|
+
if not result["claimed"] and not _has_asecli_presentation_marker(ase_file):
|
|
198
|
+
return result
|
|
199
|
+
raise ValueError(
|
|
200
|
+
"MZGUI-compatible property presentation contract failed: "
|
|
201
|
+
+ ", ".join(result["violations"])
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def require_property_presentation(ase_file: AseFile) -> dict:
|
|
206
|
+
"""Require the complete contract for a newly constructed ASE shader."""
|
|
207
|
+
from .custom_gui import inspect_custom_gui
|
|
208
|
+
|
|
209
|
+
result = inspect_custom_gui(ase_file)["property_presentation"]
|
|
210
|
+
if result["valid"]:
|
|
211
|
+
return result
|
|
212
|
+
raise ValueError(
|
|
213
|
+
"ASE property presentation contract failed: " + ", ".join(result["violations"])
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _unique_properties(properties: list[dict], source: str, violations: list[str]) -> dict[str, dict]:
|
|
218
|
+
result: dict[str, dict] = {}
|
|
219
|
+
for prop in properties:
|
|
220
|
+
name = prop.get("property_name")
|
|
221
|
+
if not isinstance(name, str):
|
|
222
|
+
violations.append(f"{source}_property:unknown:property_name_required")
|
|
223
|
+
continue
|
|
224
|
+
if name in result:
|
|
225
|
+
violations.append(f"{source}_property:{name}:duplicate")
|
|
226
|
+
continue
|
|
227
|
+
result[name] = prop
|
|
228
|
+
return result
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _has_asecli_presentation_marker(ase_file: AseFile) -> bool:
|
|
232
|
+
text = ase_file.serialize()
|
|
233
|
+
return any(editor in text for editor in PROPERTY_PRESENTATION_CONTRACT["tooltip"]["providers"]) or "Mzgui" in text or "[ASECLI" in text
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Approximate ASE Bezier paths for deterministic layout diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Mapping, Protocol
|
|
6
|
+
|
|
7
|
+
from .model import AseGraph, WireLine
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Geometry(Protocol):
|
|
11
|
+
width: float
|
|
12
|
+
height: float
|
|
13
|
+
input_ports: dict[str, tuple[float, float]]
|
|
14
|
+
output_ports: dict[str, tuple[float, float]]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def wire_paths(
|
|
18
|
+
graph: AseGraph,
|
|
19
|
+
geometry: Mapping[str, Geometry],
|
|
20
|
+
positions: Mapping[str, tuple[float, float]],
|
|
21
|
+
*,
|
|
22
|
+
samples: int = 24,
|
|
23
|
+
) -> list[tuple[WireLine, list[tuple[float, float]]]]:
|
|
24
|
+
result = []
|
|
25
|
+
for wire in graph.wires:
|
|
26
|
+
if wire.out_node not in positions or wire.in_node not in positions:
|
|
27
|
+
continue
|
|
28
|
+
source = _anchor(wire.out_node, wire.out_port, True, geometry, positions)
|
|
29
|
+
target = _anchor(wire.in_node, wire.in_port, False, geometry, positions)
|
|
30
|
+
span = max(48.0, abs(target[0] - source[0]) * 0.5)
|
|
31
|
+
control_a = (source[0] + span, source[1])
|
|
32
|
+
control_b = (target[0] - span, target[1])
|
|
33
|
+
path = [_bezier(source, control_a, control_b, target, step / samples) for step in range(samples + 1)]
|
|
34
|
+
result.append((wire, path))
|
|
35
|
+
return result
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def path_hits_rect(path: list[tuple[float, float]], rect: tuple[float, float, float, float]) -> bool:
|
|
39
|
+
return any(segment_hits_rect(left, right, rect) for left, right in zip(path, path[1:]))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def segment_hits_rect(a, b, rect) -> bool:
|
|
43
|
+
left, top, right, bottom = rect
|
|
44
|
+
if left <= a[0] <= right and top <= a[1] <= bottom:
|
|
45
|
+
return True
|
|
46
|
+
if left <= b[0] <= right and top <= b[1] <= bottom:
|
|
47
|
+
return True
|
|
48
|
+
corners = [(left, top), (right, top), (right, bottom), (left, bottom)]
|
|
49
|
+
return any(_segments_cross(a, b, corners[index], corners[(index + 1) % 4]) for index in range(4))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def paths_cross(left: list[tuple[float, float]], right: list[tuple[float, float]]) -> bool:
|
|
53
|
+
for a, b in zip(left, left[1:]):
|
|
54
|
+
for c, d in zip(right, right[1:]):
|
|
55
|
+
if _segments_cross(a, b, c, d):
|
|
56
|
+
return True
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _anchor(node_id, port_id, output, geometry, positions) -> tuple[float, float]:
|
|
61
|
+
offsets = geometry[node_id].output_ports if output else geometry[node_id].input_ports
|
|
62
|
+
dx, dy = offsets[port_id]
|
|
63
|
+
x, y = positions[node_id]
|
|
64
|
+
return x + dx, y + dy
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _bezier(p0, p1, p2, p3, t: float) -> tuple[float, float]:
|
|
68
|
+
one = 1.0 - t
|
|
69
|
+
return (
|
|
70
|
+
one ** 3 * p0[0] + 3 * one * one * t * p1[0] + 3 * one * t * t * p2[0] + t ** 3 * p3[0],
|
|
71
|
+
one ** 3 * p0[1] + 3 * one * one * t * p1[1] + 3 * one * t * t * p2[1] + t ** 3 * p3[1],
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _segments_cross(a, b, c, d) -> bool:
|
|
76
|
+
def orient(p, q, r):
|
|
77
|
+
return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])
|
|
78
|
+
ab_c, ab_d = orient(a, b, c), orient(a, b, d)
|
|
79
|
+
cd_a, cd_b = orient(c, d, a), orient(c, d, b)
|
|
80
|
+
epsilon = 1e-6
|
|
81
|
+
return ((ab_c > epsilon and ab_d < -epsilon) or (ab_c < -epsilon and ab_d > epsilon)) and (
|
|
82
|
+
(cd_a > epsilon and cd_b < -epsilon) or (cd_a < -epsilon and cd_b > epsilon)
|
|
83
|
+
)
|