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
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
+ )