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