asecli 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. asecli/__init__.py +3 -0
  2. asecli/bridge/__init__.py +40 -0
  3. asecli/bridge/_editor_port_contract.py +131 -0
  4. asecli/bridge/_editor_primitives.py +205 -0
  5. asecli/bridge/_editor_spec_grammar.py +245 -0
  6. asecli/bridge/_editor_spec_io.py +26 -0
  7. asecli/bridge/_editor_spec_validation.py +218 -0
  8. asecli/bridge/_gui_handoff_store.py +78 -0
  9. asecli/bridge/_gui_project.py +35 -0
  10. asecli/bridge/_gui_resource_store.py +159 -0
  11. asecli/bridge/_gui_resource_upgrade.py +122 -0
  12. asecli/bridge/editor_create.py +173 -0
  13. asecli/bridge/editor_spec.py +250 -0
  14. asecli/bridge/graph_geometry.py +249 -0
  15. asecli/bridge/graph_geometry_parser.py +57 -0
  16. asecli/bridge/graph_inspect.py +136 -0
  17. asecli/bridge/gui_handoff.py +100 -0
  18. asecli/bridge/gui_presentation.py +70 -0
  19. asecli/bridge/gui_provider_detection.py +197 -0
  20. asecli/bridge/gui_runtime_probe.py +25 -0
  21. asecli/bridge/gui_support.py +240 -0
  22. asecli/bridge/gui_support_resource.py +58 -0
  23. asecli/bridge/mcp_client.py +231 -0
  24. asecli/bridge/recompile.py +107 -0
  25. asecli/bridge/resource_text.py +12 -0
  26. asecli/bridge/resources/asecli_material_gui.authoring.part00.cs.txt +238 -0
  27. asecli/bridge/resources/asecli_material_gui.authoring.part01.cs.txt +235 -0
  28. asecli/bridge/resources/asecli_material_gui.authoring.store.cs.txt +125 -0
  29. asecli/bridge/resources/asecli_material_gui.condition.cs.txt +99 -0
  30. asecli/bridge/resources/asecli_material_gui.hydration.cs.txt +105 -0
  31. asecli/bridge/resources/asecli_material_gui.part00.cs.txt +300 -0
  32. asecli/bridge/resources/asecli_material_gui.part01.cs.txt +290 -0
  33. asecli/bridge/resources/asecli_material_gui.reconciliation.cs.txt +174 -0
  34. asecli/bridge/resources/asecli_material_gui.transaction.cs.txt +108 -0
  35. asecli/bridge/resources/editor_create.part00.cs.txt +176 -0
  36. asecli/bridge/resources/editor_create.part01.cs.txt +145 -0
  37. asecli/bridge/resources/editor_create.part02.cs.txt +139 -0
  38. asecli/bridge/resources/editor_create.part03.cs.txt +161 -0
  39. asecli/bridge/resources/editor_create.part04.cs.txt +114 -0
  40. asecli/bridge/resources/wire_route.transaction.cs.txt +140 -0
  41. asecli/bridge/wire_route.py +127 -0
  42. asecli/checks/__init__.py +10 -0
  43. asecli/checks/checksum.py +53 -0
  44. asecli/checks/local_vars.py +135 -0
  45. asecli/checks/usage.py +141 -0
  46. asecli/checks/validate.py +129 -0
  47. asecli/cli/__init__.py +1 -0
  48. asecli/cli/commands.py +237 -0
  49. asecli/cli/commentary_command.py +158 -0
  50. asecli/cli/create_command.py +250 -0
  51. asecli/cli/custom_gui_command.py +170 -0
  52. asecli/cli/gui_provider.py +30 -0
  53. asecli/cli/gui_support_command.py +35 -0
  54. asecli/cli/io.py +214 -0
  55. asecli/cli/layout_command.py +213 -0
  56. asecli/cli/main.py +245 -0
  57. asecli/cli/recompile_metadata.py +53 -0
  58. asecli/cli/skill_command.py +121 -0
  59. asecli/cli/usage_command.py +42 -0
  60. asecli/core/__init__.py +84 -0
  61. asecli/core/comment_bounds.py +166 -0
  62. asecli/core/comment_layout.py +167 -0
  63. asecli/core/comment_metrics.py +132 -0
  64. asecli/core/comment_purpose.py +140 -0
  65. asecli/core/commentary.py +225 -0
  66. asecli/core/compiled_metadata.py +135 -0
  67. asecli/core/compiled_properties.py +190 -0
  68. asecli/core/custom_gui.py +250 -0
  69. asecli/core/custom_gui_versions.py +35 -0
  70. asecli/core/fishbone_placement.py +159 -0
  71. asecli/core/fishbone_topology.py +186 -0
  72. asecli/core/graph_ops.py +122 -0
  73. asecli/core/layout.py +174 -0
  74. asecli/core/layout_audit.py +237 -0
  75. asecli/core/layout_audit_geometry.py +247 -0
  76. asecli/core/layout_audit_repeated.py +44 -0
  77. asecli/core/layout_graph.py +113 -0
  78. asecli/core/local_vars.py +54 -0
  79. asecli/core/material_gui_condition.py +68 -0
  80. asecli/core/material_gui_protocol.py +35 -0
  81. asecli/core/material_gui_spec.py +191 -0
  82. asecli/core/meticulous_layout.py +114 -0
  83. asecli/core/model.py +237 -0
  84. asecli/core/property_presentation.py +233 -0
  85. asecli/core/wire_geometry.py +83 -0
  86. asecli/core/wire_router.py +248 -0
  87. asecli/schema/__init__.py +49 -0
  88. asecli/schema/data/observed.json +16 -0
  89. asecli/schema/data/schemas.json +14106 -0
  90. asecli/skills/asecli/SKILL.md +275 -0
  91. asecli/skills/asecli/references/layout-standard.md +99 -0
  92. asecli/skills/asecli/references/master-output-settings-standard.md +51 -0
  93. asecli/skills/asecli/references/material-property-standard.md +108 -0
  94. asecli-0.6.2.dist-info/METADATA +594 -0
  95. asecli-0.6.2.dist-info/RECORD +98 -0
  96. asecli-0.6.2.dist-info/WHEEL +4 -0
  97. asecli-0.6.2.dist-info/entry_points.txt +2 -0
  98. asecli-0.6.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,190 @@
1
+ """Conservative ShaderLab ``Properties`` parser for presentation reconciliation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+
8
+ _PROPERTIES_TOKEN = re.compile(r"(?m)^[ \t]*Properties\b")
9
+ _ATTRIBUTE_TOKEN = re.compile(r"\[[^\]\r\n]*\]")
10
+ _ATTRIBUTES_ONLY = re.compile(r"^(?:\[[^\]\r\n]*\][ \t]*)+$")
11
+ _PROPERTY_DECLARATION = re.compile(
12
+ r"^(?P<attributes>(?:\[[^\]\r\n]*\][ \t]*)*)"
13
+ r"(?P<name>[A-Za-z_][A-Za-z0-9_]*)[ \t]*\([ \t]*"
14
+ r'"(?P<display_name>(?:\\.|[^"\\])*)"[ \t]*,'
15
+ )
16
+ _ATTRIBUTE_DETAIL = re.compile(
17
+ r"^\[(?P<type>[A-Za-z_][A-Za-z0-9_]*)(?:\((?P<args>.*)\))?\]$"
18
+ )
19
+
20
+
21
+ def parse_compiled_properties(source: str) -> list[dict]:
22
+ """Parse one ShaderLab Properties block or fail on an unknown active line.
23
+
24
+ ASECLI only promises layouts produced by its verified ASE profile. Failing
25
+ closed is intentional: an unparsed declaration must never become a vacuous
26
+ presentation pass.
27
+ """
28
+ body = _properties_body(source)
29
+ if body is None:
30
+ return []
31
+
32
+ properties: list[dict] = []
33
+ pending_attributes: list[str] = []
34
+ in_block_comment = False
35
+ for line_number, raw_line in enumerate(body.splitlines(), start=1):
36
+ line, in_block_comment = _without_comments(raw_line, in_block_comment)
37
+ stripped = line.strip()
38
+ if not stripped:
39
+ continue
40
+ if _ATTRIBUTES_ONLY.fullmatch(stripped):
41
+ pending_attributes.extend(_ATTRIBUTE_TOKEN.findall(stripped))
42
+ continue
43
+ match = _PROPERTY_DECLARATION.match(stripped)
44
+ if match is None:
45
+ raise ValueError(
46
+ f"unrecognized ShaderLab Properties declaration at block line {line_number}: {stripped!r}"
47
+ )
48
+ raw_attributes = [
49
+ *pending_attributes,
50
+ *_ATTRIBUTE_TOKEN.findall(match.group("attributes")),
51
+ ]
52
+ pending_attributes.clear()
53
+ attributes = [_parse_attribute(raw) for raw in raw_attributes]
54
+ properties.append(
55
+ {
56
+ "property_name": match.group("name"),
57
+ "display_name": match.group("display_name"),
58
+ "attributes": attributes,
59
+ "hidden": any(
60
+ item["type"].lower() == "hideininspector" for item in attributes
61
+ ),
62
+ "block_line": line_number,
63
+ }
64
+ )
65
+ if in_block_comment:
66
+ raise ValueError("unterminated block comment in ShaderLab Properties block")
67
+ if pending_attributes:
68
+ raise ValueError("ShaderLab property attributes are not followed by a declaration")
69
+ return properties
70
+
71
+
72
+ def inspect_compiled_properties(source: str) -> tuple[list[dict], str | None]:
73
+ try:
74
+ return parse_compiled_properties(source), None
75
+ except ValueError as exc:
76
+ return [], str(exc)
77
+
78
+
79
+ def _properties_body(source: str) -> str | None:
80
+ matches = list(_PROPERTIES_TOKEN.finditer(source))
81
+ if not matches:
82
+ return None
83
+ if len(matches) != 1:
84
+ raise ValueError(f"expected at most one ShaderLab Properties block, found {len(matches)}")
85
+ token = matches[0]
86
+ opening = re.match(r"[ \t\r\n]*\{", source[token.end() :])
87
+ if opening is None:
88
+ raise ValueError("ShaderLab Properties keyword is not followed by an opening brace")
89
+ open_index = token.end() + opening.end() - 1
90
+ depth = 1
91
+ in_string = False
92
+ in_line_comment = False
93
+ in_block_comment = False
94
+ escaped = False
95
+ index = open_index + 1
96
+ while index < len(source):
97
+ char = source[index]
98
+ next_char = source[index + 1] if index + 1 < len(source) else ""
99
+ if in_line_comment:
100
+ if char in "\r\n":
101
+ in_line_comment = False
102
+ index += 1
103
+ continue
104
+ if in_block_comment:
105
+ if char == "*" and next_char == "/":
106
+ in_block_comment = False
107
+ index += 2
108
+ else:
109
+ index += 1
110
+ continue
111
+ if in_string:
112
+ if escaped:
113
+ escaped = False
114
+ elif char == "\\":
115
+ escaped = True
116
+ elif char == '"':
117
+ in_string = False
118
+ index += 1
119
+ continue
120
+ if char == "/" and next_char == "/":
121
+ in_line_comment = True
122
+ index += 2
123
+ continue
124
+ if char == "/" and next_char == "*":
125
+ in_block_comment = True
126
+ index += 2
127
+ continue
128
+ if char == '"':
129
+ in_string = True
130
+ elif char == "{":
131
+ depth += 1
132
+ elif char == "}":
133
+ depth -= 1
134
+ if depth == 0:
135
+ return source[open_index + 1 : index]
136
+ index += 1
137
+ raise ValueError("unterminated ShaderLab Properties block")
138
+
139
+
140
+ def _without_comments(line: str, in_block_comment: bool) -> tuple[str, bool]:
141
+ result: list[str] = []
142
+ in_string = False
143
+ escaped = False
144
+ index = 0
145
+ while index < len(line):
146
+ char = line[index]
147
+ next_char = line[index + 1] if index + 1 < len(line) else ""
148
+ if in_block_comment:
149
+ if char == "*" and next_char == "/":
150
+ in_block_comment = False
151
+ index += 2
152
+ else:
153
+ index += 1
154
+ continue
155
+ if in_string:
156
+ result.append(char)
157
+ if escaped:
158
+ escaped = False
159
+ elif char == "\\":
160
+ escaped = True
161
+ elif char == '"':
162
+ in_string = False
163
+ index += 1
164
+ continue
165
+ if char == '"':
166
+ in_string = True
167
+ result.append(char)
168
+ index += 1
169
+ continue
170
+ if char == "/" and next_char == "/":
171
+ break
172
+ if char == "/" and next_char == "*":
173
+ in_block_comment = True
174
+ index += 2
175
+ continue
176
+ result.append(char)
177
+ index += 1
178
+ return "".join(result), in_block_comment
179
+
180
+
181
+ def _parse_attribute(raw: str) -> dict:
182
+ match = _ATTRIBUTE_DETAIL.fullmatch(raw)
183
+ if match is None:
184
+ raise ValueError(f"invalid ShaderLab property attribute: {raw!r}")
185
+ result = {"type": match.group("type"), "raw": raw, "args": match.group("args")}
186
+ if result["type"] in {"ASECLITooltip", "ASECLIHelpBox", "TooltipMzgui", "HelpBoxMzgui"} and result["args"] is not None:
187
+ units = re.findall(r"#([0-9A-Fa-f]{4})", result["args"])
188
+ encoded = b"".join(int(unit, 16).to_bytes(2, "little") for unit in units)
189
+ result["text"] = encoded.decode("utf-16-le", errors="replace")
190
+ return result
@@ -0,0 +1,250 @@
1
+ """CustomEditor and ASECLI property-metadata support."""
2
+ from __future__ import annotations
3
+ from dataclasses import dataclass
4
+ import re
5
+ from .model import AseFile, AseGraph, NodeLine
6
+ from .custom_gui_versions import CUSTOM_EDITOR_GRAPH_VERSIONS, PROPERTY_METADATA_TAIL_GRAPH_VERSIONS, TARGET_ASE_VERSION, TARGET_GRAPH_VERSION, require_custom_editor_version, require_property_metadata_tail_version
7
+ from .material_gui_protocol import LEGACY_PROPERTY_METADATA_ATTRIBUTE_TYPES as _ALTERNATE_ASECLI_ATTRIBUTE_TYPES, MANAGED_PROPERTY_METADATA_ATTRIBUTE_TYPES, PROPERTY_METADATA_ATTRIBUTE_TYPES, canonical_property_metadata_type, equivalent_property_metadata_types
8
+ from .material_gui_condition import parse_enable_if_arguments
9
+ from .property_presentation import inspect_property_presentation
10
+
11
+ ASECLI_GUI_EDITOR = "ASECLI.MaterialGUI.ASECLIMaterialGUI"
12
+ MZGUI_EDITOR = "MZGUI.MZGUI"
13
+ SUPPORTED_GUI_EDITORS = frozenset((MZGUI_EDITOR, ASECLI_GUI_EDITOR))
14
+ CUSTOM_EDITOR_SUGGESTIONS = ("ASEMaterialInspector", MZGUI_EDITOR, ASECLI_GUI_EDITOR, "Rendering.HighDefinition.LightingShaderGraphGUI", "Rendering.HighDefinition.HDUnlitGUI", "UnityEditor.Rendering.HighDefinition.HDLitGUI", "UnityEditor.ShaderGraph.PBRMasterGUI", "UnityEditor.Rendering.HighDefinition.DecalGUI", "UnityEditor.Rendering.HighDefinition.FabricGUI", "UnityEditor.Experimental.Rendering.HDPipeline.HDLitGUI", "Rendering.HighDefinition.DecalGUI", "Rendering.HighDefinition.LitShaderGraphGUI", "Rendering.HighDefinition.DecalShaderGraphGUI", "UnityEditor.ShaderGraphUnlitGUI", "UnityEditor.ShaderGraphLitGUI", "UnityEditor.Rendering.Universal.DecalShaderGraphGUI")
15
+ _MASTER_TYPES = {"AmplifyShaderEditor.TemplateMultiPassMasterNode", "AmplifyShaderEditor.TemplateMasterNode", "AmplifyShaderEditor.StandardSurfaceOutputNode", "AmplifyShaderEditor.LogNode"}
16
+ _EDITOR_CLASS_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$")
17
+ _ATTRIBUTE_RE = re.compile(r"^\[(?P<type>[A-Za-z_][A-Za-z0-9_]*)(?:\((?P<args>.*)\))?\]$")
18
+ _CUSTOM_EDITOR_LINE_RE = re.compile(r'(?m)^(?P<indent>[ \t]*)CustomEditor[ \t]+"(?P<name>[^"\r\n]*)"[ \t]*(?:\r?\n|$)')
19
+ _FALLBACK_LINE_RE = re.compile(r"(?im)^(?P<indent>[ \t]*)fallback\b")
20
+ _GROUP_INVALID = frozenset("\r\n\\><'\";:[]{}=+`~/?!@#$%^&*")
21
+ @dataclass(frozen=True)
22
+ class PropertyMetadataTail:
23
+ count_index: int; attributes: tuple[str, ...]
24
+ def validate_editor_class(name: str) -> str:
25
+ if len(name) > 255 or not _EDITOR_CLASS_RE.fullmatch(name):
26
+ raise ValueError(
27
+ "custom editor must be a namespace-qualified C# class name using only letters, digits, '_' and '.'"
28
+ )
29
+ return name
30
+ def main_master_node(graph: AseGraph) -> NodeLine:
31
+ require_custom_editor_version(graph)
32
+ candidates = [node for node in graph.nodes if node.type_name in _MASTER_TYPES
33
+ and len(node.raw_fields) > 9 and node.raw_fields[6].lower() == "true"]
34
+ if len(candidates) != 1:
35
+ raise ValueError(f"expected exactly one main MasterNode, found {len(candidates)}")
36
+ return candidates[0]
37
+ def graph_custom_editor(graph: AseGraph) -> str | None:
38
+ value = main_master_node(graph).raw_fields[9]
39
+ return value or None
40
+ def compiled_custom_editor(ase_file: AseFile) -> str | None:
41
+ matches = list(_CUSTOM_EDITOR_LINE_RE.finditer(ase_file.prefix))
42
+ if len(matches) > 1:
43
+ raise ValueError(f"expected at most one compiled CustomEditor directive, found {len(matches)}")
44
+ return matches[0].group("name") if matches else None
45
+ def set_custom_editor(ase_file: AseFile, editor: str | None) -> dict:
46
+ if editor is not None:
47
+ validate_editor_class(editor)
48
+ editor = MZGUI_EDITOR if editor == ASECLI_GUI_EDITOR else editor
49
+ node = main_master_node(ase_file.graph)
50
+ before_graph = node.raw_fields[9] or None
51
+ before_compiled = compiled_custom_editor(ase_file)
52
+ node.raw_fields[9] = editor or ""
53
+ ase_file.graph.replace_node(node)
54
+ ase_file.prefix = _replace_compiled_editor(ase_file.prefix, editor)
55
+ return {"kind": "custom_editor", "main_node_id": node.node_id,
56
+ "before": {"graph": before_graph, "compiled": before_compiled}, "after": editor}
57
+ def _replace_compiled_editor(prefix: str, editor: str | None) -> str:
58
+ matches = list(_CUSTOM_EDITOR_LINE_RE.finditer(prefix))
59
+ if len(matches) > 1:
60
+ raise ValueError(f"expected at most one compiled CustomEditor directive, found {len(matches)}")
61
+ if matches:
62
+ match = matches[0]
63
+ if editor is None:
64
+ return prefix[: match.start()] + prefix[match.end() :]
65
+ raw = match.group(0)
66
+ eol = "\r\n" if raw.endswith("\r\n") else ("\n" if raw.endswith("\n") else "")
67
+ line = f'{match.group("indent")}CustomEditor "{editor}"{eol}'
68
+ return prefix[: match.start()] + line + prefix[match.end() :]
69
+ if editor is None:
70
+ return prefix
71
+ eol = "\r\n" if "\r\n" in prefix else "\n"
72
+ fallback = _FALLBACK_LINE_RE.search(prefix)
73
+ if fallback:
74
+ line = f'{fallback.group("indent")}CustomEditor "{editor}"{eol}'
75
+ return prefix[: fallback.start()] + line + prefix[fallback.start() :]
76
+ closing_braces = list(re.finditer(r"(?m)^[ \t]*}[ \t]*(?:\r?\n|$)", prefix))
77
+ if not closing_braces:
78
+ raise ValueError("cannot insert compiled CustomEditor: ShaderLab closing brace not found")
79
+ insert_at = closing_braces[-1].start()
80
+ return prefix[:insert_at] + f'\tCustomEditor "{editor}"{eol}' + prefix[insert_at:]
81
+ def encode_custom_unicode(text: str) -> str:
82
+ raw = text.encode("utf-16-le", errors="surrogatepass")
83
+ return "".join(f"#{int.from_bytes(raw[i:i + 2], 'little'):04X}" for i in range(0, len(raw), 2))
84
+ def decode_custom_unicode(value: str) -> str:
85
+ units = re.findall(r"#([0-9A-Fa-f]{4})", value)
86
+ raw = b"".join(int(unit, 16).to_bytes(2, "little") for unit in units)
87
+ return raw.decode("utf-16-le", errors="replace")
88
+ def encode_foldout_title(text: str) -> str:
89
+ invalid = sorted({char for char in text if char in _GROUP_INVALID})
90
+ if invalid:
91
+ rendered = " ".join(repr(char) for char in invalid)
92
+ raise ValueError(f"group title contains characters rejected by ASE: {rendered}")
93
+ raw = text.encode("utf-16-le", errors="surrogatepass")
94
+ values = [int.from_bytes(raw[i:i + 2], "little") for i in range(0, len(raw), 2)]
95
+ return "".join(chr(value) if value <= 127 else f"#{value:04x}" for value in values)
96
+ def decode_foldout_title(value: str) -> str:
97
+ def replace(match: re.Match[str]) -> str:
98
+ return chr(int(match.group(1), 16))
99
+ decoded = re.sub(r"#([0-9A-Fa-f]{4})", replace, value)
100
+ return decoded.encode("utf-16-le", errors="surrogatepass").decode("utf-16-le", errors="replace")
101
+ def is_material_property_node(node: NodeLine) -> bool:
102
+ return len(node.raw_fields) > 7 and node.raw_fields[6] == "Property"
103
+ def parse_property_metadata_attribute(raw: str) -> dict:
104
+ match = _ATTRIBUTE_RE.fullmatch(raw)
105
+ if not match:
106
+ raise ValueError(f"invalid property metadata serialization: {raw!r}")
107
+ type_name = match.group("type")
108
+ args = match.group("args")
109
+ result = {"type": type_name, "raw": raw, "args": args}
110
+ if args is not None and type_name in {"ASECLITooltip", "ASECLIHelpBox", "TooltipMzgui", "HelpBoxMzgui"}:
111
+ result["text"] = decode_custom_unicode(args)
112
+ elif args is not None and type_name in {"ASECLIEnableIf", "EnableIfMzgui"}:
113
+ result["condition"] = parse_enable_if_arguments(args)
114
+ elif args is not None and type_name in {"ASECLIFoldout", "FoldoutMzgui"}:
115
+ result["text"] = decode_foldout_title(args)
116
+ return result
117
+ def read_property_metadata_tail(graph: AseGraph, node: NodeLine) -> PropertyMetadataTail:
118
+ require_property_metadata_tail_version(graph)
119
+ if not is_material_property_node(node):
120
+ raise ValueError(f"node {node.node_id} is not an exported PropertyNode")
121
+ fields = node.raw_fields
122
+ for index in range(len(fields) - 1, 5, -1):
123
+ value = fields[index]
124
+ if not value.isdigit():
125
+ continue
126
+ count = int(value)
127
+ if count != len(fields) - index - 1:
128
+ continue
129
+ attributes = fields[index + 1 :]
130
+ try:
131
+ for attribute in attributes:
132
+ parse_property_metadata_attribute(attribute)
133
+ except ValueError:
134
+ continue
135
+ return PropertyMetadataTail(index, tuple(attributes))
136
+ raise ValueError(f"node {node.node_id} has no valid property metadata serialization tail")
137
+ def _validated_raw_attribute(raw: str) -> tuple[str, str]:
138
+ if any(char in raw for char in (";", "\r", "\n", "\"", "'", "\\")):
139
+ raise ValueError("raw property metadata attribute contains a forbidden serialization character")
140
+ parsed = parse_property_metadata_attribute(raw)
141
+ type_name = str(parsed["type"])
142
+ canonical_type = canonical_property_metadata_type(type_name)
143
+ if canonical_type is None:
144
+ raise ValueError(
145
+ "unsupported MZGUI-compatible property metadata attribute type: " + type_name
146
+ )
147
+ return canonical_type, raw
148
+ def set_property_metadata_attribute(graph: AseGraph, node_id: str, raw: str) -> dict:
149
+ type_name, raw = _validated_raw_attribute(raw)
150
+ node = graph.node_by_id(node_id)
151
+ if node is None:
152
+ raise KeyError(f"node {node_id} not found")
153
+ tail = read_property_metadata_tail(graph, node)
154
+ attributes = list(tail.attributes)
155
+ matching = [
156
+ index
157
+ for index, item in enumerate(attributes)
158
+ if parse_property_metadata_attribute(item)["type"]
159
+ in equivalent_property_metadata_types(type_name)
160
+ ]
161
+ before = [attributes[i] for i in matching]
162
+ if matching:
163
+ first = matching[0]
164
+ attributes[first] = raw
165
+ for index in reversed(matching[1:]):
166
+ del attributes[index]
167
+ else:
168
+ attributes.append(raw)
169
+ _write_property_metadata_tail(graph, node, tail.count_index, attributes)
170
+ return {"kind": "property_metadata", "node_id": node_id, "type": type_name, "before": before, "after": raw}
171
+ def remove_property_metadata_attribute(graph: AseGraph, node_id: str, type_name: str) -> dict:
172
+ canonical_type = canonical_property_metadata_type(type_name)
173
+ if canonical_type is None:
174
+ raise ValueError(f"unsupported MZGUI-compatible property metadata attribute type: {type_name}")
175
+ node = graph.node_by_id(node_id)
176
+ if node is None:
177
+ raise KeyError(f"node {node_id} not found")
178
+ tail = read_property_metadata_tail(graph, node)
179
+ types = equivalent_property_metadata_types(canonical_type)
180
+ removed = [item for item in tail.attributes if parse_property_metadata_attribute(item)["type"] in types]
181
+ kept = [item for item in tail.attributes if parse_property_metadata_attribute(item)["type"] not in types]
182
+ _write_property_metadata_tail(graph, node, tail.count_index, kept)
183
+ return {"kind": "property_metadata", "node_id": node_id, "type": canonical_type, "removed": removed}
184
+ def _write_property_metadata_tail(graph: AseGraph, node: NodeLine, count_index: int, attributes: list[str]) -> None:
185
+ node.raw_fields = node.raw_fields[:count_index] + [str(len(attributes)), *attributes]
186
+ graph.replace_node(node)
187
+ def semantic_attribute(type_name: str, text: str) -> str:
188
+ if len(text) > 4096:
189
+ raise ValueError("custom GUI text must not exceed 4096 characters")
190
+ canonical_type = canonical_property_metadata_type(type_name)
191
+ if canonical_type is None:
192
+ raise ValueError(f"no text semantic encoder for {type_name}")
193
+ if canonical_type == "FoldoutMzgui":
194
+ encoded = encode_foldout_title(text)
195
+ elif canonical_type in {"TooltipMzgui", "HelpBoxMzgui"}:
196
+ encoded = encode_custom_unicode(text)
197
+ else:
198
+ raise ValueError(f"no text semantic encoder for {type_name}")
199
+ return f"[{type_name}({encoded})]"
200
+ def inspect_custom_gui(ase_file: AseFile) -> dict:
201
+ main = main_master_node(ase_file.graph)
202
+ graph_editor = main.raw_fields[9] or None
203
+ compiled_editor = compiled_custom_editor(ase_file)
204
+ properties = []
205
+ property_metadata_tail_supported = ase_file.graph.version in PROPERTY_METADATA_TAIL_GRAPH_VERSIONS
206
+ if property_metadata_tail_supported:
207
+ for node in ase_file.graph.nodes:
208
+ if not is_material_property_node(node):
209
+ continue
210
+ tail = read_property_metadata_tail(ase_file.graph, node)
211
+ properties.append(
212
+ {
213
+ "node_id": node.node_id,
214
+ "node_type": node.type_name,
215
+ "property_name": node.raw_fields[7] if len(node.raw_fields) > 7 else None, "order_index": int(node.raw_fields[9]),
216
+ "display_name": node.raw_fields[8] if len(node.raw_fields) > 8 else None,
217
+ "attributes": [parse_property_metadata_attribute(raw) for raw in tail.attributes],
218
+ }
219
+ )
220
+ editor = {"main_node_id": main.node_id, "graph": graph_editor,
221
+ "compiled": compiled_editor, "consistent": graph_editor == compiled_editor}
222
+ return {
223
+ "ase_profile": TARGET_ASE_VERSION,
224
+ "version_capabilities": {
225
+ "custom_editor": ase_file.graph.version in CUSTOM_EDITOR_GRAPH_VERSIONS,
226
+ "property_metadata_tail": property_metadata_tail_supported,
227
+ },
228
+ "profile_graph_version": TARGET_GRAPH_VERSION,
229
+ "graph_version": ase_file.graph.version,
230
+ "editor": editor,
231
+ "properties": properties,
232
+ "property_presentation": inspect_property_presentation(
233
+ editor, properties, ase_file.prefix, supported_editors=SUPPORTED_GUI_EDITORS
234
+ ),
235
+ "capabilities": {
236
+ "supported_editors": sorted(SUPPORTED_GUI_EDITORS),
237
+ "built_in_editor": MZGUI_EDITOR,
238
+ "legacy_fallback_editor": ASECLI_GUI_EDITOR,
239
+ "editor_suggestions": list(CUSTOM_EDITOR_SUGGESTIONS),
240
+ "property_attribute_types": list(PROPERTY_METADATA_ATTRIBUTE_TYPES),
241
+ "legacy_read_attribute_types": list(_ALTERNATE_ASECLI_ATTRIBUTE_TYPES),
242
+ "semantic_operations": {
243
+ "group": "FoldoutMzgui",
244
+ "tooltip": "TooltipMzgui",
245
+ "help_box": "HelpBoxMzgui",
246
+ "enabled_if": "EnableIfMzgui",
247
+ },
248
+ "shader_drawer_only": ["ShowIf"],
249
+ },
250
+ }
@@ -0,0 +1,35 @@
1
+ """Verified ASE graph-version capabilities for custom material GUI metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .model import AseGraph
6
+
7
+
8
+ TARGET_ASE_VERSION = "1.9.6.2"
9
+ TARGET_GRAPH_VERSION = "19602"
10
+ CUSTOM_EDITOR_GRAPH_VERSIONS = frozenset(("19109", "19602"))
11
+ PROPERTY_METADATA_TAIL_GRAPH_VERSIONS = frozenset(("19602",))
12
+
13
+
14
+ def _require_graph_version(graph: AseGraph, supported: frozenset[str], capability: str) -> None:
15
+ try:
16
+ int(graph.version)
17
+ except ValueError as exc:
18
+ raise ValueError(f"unsupported non-numeric ASE graph version: {graph.version!r}") from exc
19
+ if graph.version not in supported:
20
+ versions = ", ".join(sorted(supported))
21
+ raise ValueError(
22
+ f"unsupported ASE graph version {graph.version!r} for {capability}; verified versions: {versions}"
23
+ )
24
+
25
+
26
+ def require_custom_editor_version(graph: AseGraph) -> None:
27
+ _require_graph_version(graph, CUSTOM_EDITOR_GRAPH_VERSIONS, "serialized CustomEditor")
28
+
29
+
30
+ def require_property_metadata_tail_version(graph: AseGraph) -> None:
31
+ _require_graph_version(
32
+ graph,
33
+ PROPERTY_METADATA_TAIL_GRAPH_VERSIONS,
34
+ "ASECLI property metadata",
35
+ )
@@ -0,0 +1,159 @@
1
+ """Geometric placement pass for a prepared recursive fishbone topology."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from dataclasses import dataclass
7
+ from typing import Mapping, Protocol
8
+
9
+ from .layout_graph import id_key, node_position, node_rect, overlaps_with_gap, round_coord, sibling_gap
10
+ from .wire_router import WIRE_NODE_TYPE
11
+
12
+ HORIZONTAL_GAP = 96.0
13
+ SIBLING_GAP = 32.0
14
+ MODULE_GAP = 96.0
15
+
16
+
17
+ class Geometry(Protocol):
18
+ width: float
19
+ height: float
20
+ input_ports: dict[str, tuple[float, float]]
21
+ output_ports: dict[str, tuple[float, float]]
22
+
23
+
24
+ @dataclass
25
+ class _Template:
26
+ positions: dict[str, float]
27
+ bounds: tuple[float, float, float, float]
28
+
29
+
30
+ def place_fishbone(graph, geometry: Mapping[str, Geometry], topology):
31
+ roots = topology.roots
32
+ main = roots[0]
33
+ anchor_x, anchor_y = node_position(graph.node_by_id(main))
34
+ x_by_node = {root: anchor_x for root in roots}
35
+ stage_output_x = {}
36
+ ordered_parents = sorted(
37
+ topology.ids - topology.satellites,
38
+ key=lambda node_id: (topology.depths[node_id], id_key(node_id)),
39
+ )
40
+ for parent in ordered_parents:
41
+ if parent not in x_by_node:
42
+ continue
43
+ # Every node owns a local fishbone stage. Its direct sources share one
44
+ # output anchor line 96px before the consumer, while independent
45
+ # branches are not stretched to an unrelated global depth column.
46
+ launch_x = round_coord(x_by_node[parent] - HORIZONTAL_GAP)
47
+ for child in topology.children[parent]:
48
+ wire = topology.primary_wire[(child, parent)]
49
+ output_dx = geometry[child].output_ports[wire.out_port][0]
50
+ x_by_node[child] = launch_x - output_dx
51
+ stage_output_x[child] = launch_x
52
+
53
+ templates: dict[str, _Template] = {}
54
+
55
+ def build(node_id: str) -> _Template:
56
+ if node_id in templates:
57
+ return templates[node_id]
58
+ positions_y = {node_id: 0.0}
59
+ own = (
60
+ x_by_node[node_id], 0.0,
61
+ x_by_node[node_id] + geometry[node_id].width, geometry[node_id].height,
62
+ )
63
+ branch = topology.children[node_id]
64
+ if not branch:
65
+ templates[node_id] = _Template(positions_y, own)
66
+ return templates[node_id]
67
+ local = {child: build(child) for child in branch}
68
+ spine = topology.spine_child[node_id]
69
+ wire = topology.primary_wire[(spine, node_id)]
70
+ shifts = {
71
+ spine: geometry[node_id].input_ports[wire.in_port][1]
72
+ - geometry[spine].output_ports[wire.out_port][1]
73
+ }
74
+ spine_bounds = _shift(local[spine].bounds, shifts[spine])
75
+ spine_index = branch.index(spine)
76
+ upper_cursor = min(own[1], spine_bounds[1])
77
+ for index in range(spine_index - 1, -1, -1):
78
+ child, next_child = branch[index], branch[index + 1]
79
+ gap = sibling_gap(child, next_child, topology.group_of, SIBLING_GAP, MODULE_GAP)
80
+ shifts[child] = upper_cursor - gap - local[child].bounds[3]
81
+ upper_cursor = local[child].bounds[1] + shifts[child]
82
+ lower_cursor = max(own[3], spine_bounds[3])
83
+ for index in range(spine_index + 1, len(branch)):
84
+ child, previous = branch[index], branch[index - 1]
85
+ gap = sibling_gap(previous, child, topology.group_of, SIBLING_GAP, MODULE_GAP)
86
+ shifts[child] = lower_cursor + gap - local[child].bounds[1]
87
+ lower_cursor = local[child].bounds[3] + shifts[child]
88
+ bounds = [own]
89
+ for child in branch:
90
+ shift = shifts[child]
91
+ for member, y in local[child].positions.items():
92
+ positions_y[member] = y + shift
93
+ bounds.append(_shift(local[child].bounds, shift))
94
+ templates[node_id] = _Template(positions_y, _union(bounds))
95
+ return templates[node_id]
96
+
97
+ positions, ordered_roots = {}, [main]
98
+ main_template = build(main)
99
+ for node_id, relative_y in main_template.positions.items():
100
+ positions[node_id] = (round_coord(x_by_node[node_id]), round_coord(anchor_y + relative_y))
101
+ cursor = main_template.bounds[3] + anchor_y + MODULE_GAP
102
+ bundles: dict[tuple[str, str], list[str]] = defaultdict(list)
103
+ for root in roots[1:]:
104
+ owner = topology.group_of.get(root)
105
+ bundles[("comment", owner) if owner else ("node", root)].append(root)
106
+ ordered_bundles = sorted(
107
+ bundles.values(),
108
+ key=lambda bundle: (
109
+ min(topology.current_y[item] for item in bundle), min(id_key(item) for item in bundle),
110
+ ),
111
+ )
112
+ for bundle in ordered_bundles:
113
+ bundle.sort(key=lambda item: (topology.current_y[item], id_key(item)))
114
+ for index, root in enumerate(bundle):
115
+ template = build(root)
116
+ shift = cursor - template.bounds[1]
117
+ for node_id, relative_y in template.positions.items():
118
+ positions[node_id] = (round_coord(x_by_node[node_id]), round_coord(relative_y + shift))
119
+ ordered_roots.append(root)
120
+ cursor = template.bounds[3] + shift + (SIBLING_GAP if index + 1 < len(bundle) else MODULE_GAP)
121
+
122
+ _place_registers(topology, geometry, positions)
123
+ for node in graph.nodes:
124
+ if node.type_name == WIRE_NODE_TYPE:
125
+ positions[node.node_id] = node_position(node)
126
+ return positions, ordered_roots, stage_output_x
127
+
128
+
129
+ def _place_registers(topology, geometry, positions):
130
+ for register_id in sorted(topology.satellites, key=id_key):
131
+ incoming = sorted(
132
+ (wire for wire in topology.collapsed_wires if wire.in_node == register_id),
133
+ key=lambda wire: (id_key(wire.out_node), wire.in_port),
134
+ )
135
+ wire = incoming[0]
136
+ source = wire.out_node
137
+ source_rect = node_rect(source, positions, geometry)
138
+ x = source_rect[2] + HORIZONTAL_GAP
139
+ source_y = positions[source][1] + geometry[source].output_ports[wire.out_port][1]
140
+ y = source_y - geometry[register_id].input_ports[wire.in_port][1]
141
+ candidate = (x, y, x + geometry[register_id].width, y + geometry[register_id].height)
142
+ occupied = [node_rect(item, positions, geometry) for item in positions]
143
+ while any(overlaps_with_gap(candidate, rect, SIBLING_GAP) for rect in occupied):
144
+ y += geometry[register_id].height + SIBLING_GAP
145
+ candidate = (x, y, x + geometry[register_id].width, y + geometry[register_id].height)
146
+ positions[register_id] = round_coord(x), round_coord(y)
147
+ topology.depths[register_id] = max(0, topology.depths[source] - 1)
148
+
149
+
150
+ def _shift(bounds, dy):
151
+ return bounds[0], bounds[1] + dy, bounds[2], bounds[3] + dy
152
+
153
+
154
+ def _union(rects):
155
+ values = list(rects)
156
+ return (
157
+ min(rect[0] for rect in values), min(rect[1] for rect in values),
158
+ max(rect[2] for rect in values), max(rect[3] for rect in values),
159
+ )