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,186 @@
|
|
|
1
|
+
"""Build the deterministic primary tree used by recursive fishbone layout."""
|
|
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 .commentary import COMMENTARY_TYPE
|
|
10
|
+
from .layout import MASTER_TYPES
|
|
11
|
+
from .layout_graph import (
|
|
12
|
+
graph_depths, has_incoming, id_key, immediate_comment_owners, is_master,
|
|
13
|
+
node_position, port_key, reject_cycles,
|
|
14
|
+
)
|
|
15
|
+
from .local_vars import REGISTER_LOCAL_VAR_TYPE
|
|
16
|
+
from .model import AseGraph, WireLine
|
|
17
|
+
from .wire_router import WIRE_NODE_TYPE, logical_wires
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Geometry(Protocol):
|
|
21
|
+
width: float
|
|
22
|
+
height: float
|
|
23
|
+
title_height: float
|
|
24
|
+
input_ports: dict[str, tuple[float, float]]
|
|
25
|
+
output_ports: dict[str, tuple[float, float]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class FishboneTopology:
|
|
30
|
+
ids: set[str]
|
|
31
|
+
types: dict[str, str]
|
|
32
|
+
collapsed_wires: list[WireLine]
|
|
33
|
+
satellites: set[str]
|
|
34
|
+
depths: dict[str, int]
|
|
35
|
+
group_of: dict[str, str]
|
|
36
|
+
current_y: dict[str, float]
|
|
37
|
+
primary_parent: dict[str, str]
|
|
38
|
+
primary_wire: dict[tuple[str, str], WireLine]
|
|
39
|
+
children: dict[str, tuple[str, ...]]
|
|
40
|
+
spine_child: dict[str, str]
|
|
41
|
+
roots: list[str]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_fishbone_topology(graph: AseGraph, geometry: Mapping[str, Geometry]) -> FishboneTopology:
|
|
45
|
+
candidates = [
|
|
46
|
+
node for node in graph.nodes
|
|
47
|
+
if node.type_name not in {COMMENTARY_TYPE, WIRE_NODE_TYPE}
|
|
48
|
+
]
|
|
49
|
+
collapsed = [item.wire for item in logical_wires(graph)]
|
|
50
|
+
connected = {node_id for wire in collapsed for node_id in (wire.out_node, wire.in_node)}
|
|
51
|
+
unavailable = {
|
|
52
|
+
node.node_id for node in candidates
|
|
53
|
+
if node.node_id not in geometry
|
|
54
|
+
or geometry[node.node_id].width <= 0
|
|
55
|
+
or geometry[node.node_id].height <= 0
|
|
56
|
+
or geometry[node.node_id].title_height <= 0
|
|
57
|
+
}
|
|
58
|
+
dormant = {
|
|
59
|
+
node.node_id for node in candidates
|
|
60
|
+
if node.node_id in unavailable and node.type_name in MASTER_TYPES
|
|
61
|
+
and node.node_id not in connected
|
|
62
|
+
}
|
|
63
|
+
missing = sorted(unavailable - dormant, key=id_key)
|
|
64
|
+
if missing:
|
|
65
|
+
raise ValueError(f"editor geometry missing active node(s): {', '.join(missing)}")
|
|
66
|
+
nodes = [node for node in candidates if node.node_id not in dormant]
|
|
67
|
+
ids = {node.node_id for node in nodes}
|
|
68
|
+
types = {node.node_id: node.type_name for node in nodes}
|
|
69
|
+
_require_logical_ports(collapsed, ids, geometry)
|
|
70
|
+
|
|
71
|
+
physical: dict[str, list[WireLine]] = defaultdict(list)
|
|
72
|
+
for wire in collapsed:
|
|
73
|
+
if wire.out_node in ids and wire.in_node in ids and wire.out_node != wire.in_node:
|
|
74
|
+
physical[wire.out_node].append(wire)
|
|
75
|
+
satellites = {
|
|
76
|
+
node_id for node_id in ids
|
|
77
|
+
if types[node_id] == REGISTER_LOCAL_VAR_TYPE and not physical.get(node_id)
|
|
78
|
+
and any(wire.in_node == node_id for wire in collapsed)
|
|
79
|
+
}
|
|
80
|
+
outgoing: dict[str, list[WireLine]] = defaultdict(list)
|
|
81
|
+
for source, wires in physical.items():
|
|
82
|
+
outgoing[source].extend(wire for wire in wires if wire.in_node not in satellites)
|
|
83
|
+
reject_cycles(ids, outgoing)
|
|
84
|
+
depths = graph_depths(ids, outgoing)
|
|
85
|
+
group_of = immediate_comment_owners(graph)
|
|
86
|
+
current_y = {node.node_id: node_position(node)[1] for node in nodes}
|
|
87
|
+
primary_parent, primary_wire = _choose_primary(
|
|
88
|
+
ids, outgoing, depths, group_of, current_y,
|
|
89
|
+
)
|
|
90
|
+
children_lists: dict[str, list[str]] = defaultdict(list)
|
|
91
|
+
for child, parent in primary_parent.items():
|
|
92
|
+
children_lists[parent].append(child)
|
|
93
|
+
for parent, members in children_lists.items():
|
|
94
|
+
members.sort(key=lambda child: (
|
|
95
|
+
port_key(primary_wire[(child, parent)].in_port), current_y[child], id_key(child),
|
|
96
|
+
))
|
|
97
|
+
children = {node_id: tuple(children_lists.get(node_id, [])) for node_id in ids}
|
|
98
|
+
upstream_depth, upstream_size = _measure_topology(ids, children)
|
|
99
|
+
spine_child = _choose_spines(
|
|
100
|
+
children, primary_wire, geometry, upstream_depth, upstream_size, current_y,
|
|
101
|
+
)
|
|
102
|
+
roots = [item for item in ids if item not in primary_parent and item not in satellites]
|
|
103
|
+
roots.sort(key=lambda item: (
|
|
104
|
+
0 if is_master(graph, item) else 1,
|
|
105
|
+
0 if outgoing.get(item) or has_incoming(graph, item) else 1,
|
|
106
|
+
id_key(item),
|
|
107
|
+
))
|
|
108
|
+
if not roots:
|
|
109
|
+
raise ValueError("meticulous layout found no root node")
|
|
110
|
+
return FishboneTopology(
|
|
111
|
+
ids, types, collapsed, satellites, depths, group_of, current_y,
|
|
112
|
+
primary_parent, primary_wire, children, spine_child, roots,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _choose_primary(ids, outgoing, depths, group_of, current_y):
|
|
117
|
+
parents, wires = {}, {}
|
|
118
|
+
for source in sorted(ids, key=id_key):
|
|
119
|
+
choices = outgoing.get(source, [])
|
|
120
|
+
if not choices:
|
|
121
|
+
continue
|
|
122
|
+
chosen = min(choices, key=lambda wire: (
|
|
123
|
+
-depths[wire.in_node],
|
|
124
|
+
0 if group_of.get(source) == group_of.get(wire.in_node) else 1,
|
|
125
|
+
port_key(wire.in_port), abs(current_y[source] - current_y[wire.in_node]),
|
|
126
|
+
id_key(wire.in_node),
|
|
127
|
+
))
|
|
128
|
+
parents[source] = chosen.in_node
|
|
129
|
+
wires[(source, chosen.in_node)] = chosen
|
|
130
|
+
return parents, wires
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _measure_topology(ids, children):
|
|
134
|
+
depths, sizes = {}, {}
|
|
135
|
+
|
|
136
|
+
def measure(node_id):
|
|
137
|
+
if node_id in depths:
|
|
138
|
+
return depths[node_id], sizes[node_id]
|
|
139
|
+
branch = children[node_id]
|
|
140
|
+
values = [measure(child) for child in branch]
|
|
141
|
+
depths[node_id] = 0 if not values else 1 + max(item[0] for item in values)
|
|
142
|
+
sizes[node_id] = 1 + sum(item[1] for item in values)
|
|
143
|
+
return depths[node_id], sizes[node_id]
|
|
144
|
+
|
|
145
|
+
for node_id in sorted(ids, key=id_key):
|
|
146
|
+
measure(node_id)
|
|
147
|
+
return depths, sizes
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _choose_spines(children, primary_wire, geometry, depths, sizes, current_y):
|
|
151
|
+
result = {}
|
|
152
|
+
for parent, branch in children.items():
|
|
153
|
+
if not branch:
|
|
154
|
+
continue
|
|
155
|
+
if len(branch) >= 3 and len(branch) % 2 == 1:
|
|
156
|
+
result[parent] = branch[len(branch) // 2]
|
|
157
|
+
continue
|
|
158
|
+
candidates = branch[len(branch) // 2 - 1: len(branch) // 2 + 1] if len(branch) >= 4 else branch
|
|
159
|
+
result[parent] = max(candidates, key=lambda child: _spine_score(
|
|
160
|
+
child, parent, primary_wire[(child, parent)], geometry, depths, sizes, current_y,
|
|
161
|
+
))
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _spine_score(child, parent, wire, geometry, depths, sizes, current_y):
|
|
166
|
+
label = str((getattr(geometry[parent], "input_port_labels", {}) or {}).get(wire.in_port, "")).lower()
|
|
167
|
+
control = ("strength", "amount", "softness", "saturation", "threshold", "scale")
|
|
168
|
+
data = ("input", "value", "color", "normal", "reflection", "true")
|
|
169
|
+
semantic = -1 if any(word in label for word in control) else int(any(word in label for word in data))
|
|
170
|
+
delta = abs(
|
|
171
|
+
current_y[child] + geometry[child].output_ports[wire.out_port][1]
|
|
172
|
+
- current_y[parent] - geometry[parent].input_ports[wire.in_port][1]
|
|
173
|
+
)
|
|
174
|
+
numeric_port = port_key(wire.in_port)
|
|
175
|
+
port_rank = -float(numeric_port[1]) if numeric_port[0] == 0 else 0.0
|
|
176
|
+
return depths[child], int(sizes[child] > 1), semantic, sizes[child], -delta, port_rank
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _require_logical_ports(wires, ids, geometry):
|
|
180
|
+
for wire in wires:
|
|
181
|
+
if wire.out_node not in ids or wire.in_node not in ids:
|
|
182
|
+
continue
|
|
183
|
+
if wire.out_port not in geometry[wire.out_node].output_ports:
|
|
184
|
+
raise ValueError(f"editor geometry missing output port {wire.out_node}:{wire.out_port}")
|
|
185
|
+
if wire.in_port not in geometry[wire.in_node].input_ports:
|
|
186
|
+
raise ValueError(f"editor geometry missing input port {wire.in_node}:{wire.in_port}")
|
asecli/core/graph_ops.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Graph mutation operations (TASK-0007)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .model import AseGraph, NodeLine, WireLine
|
|
6
|
+
from .local_vars import GET_LOCAL_VAR_TYPE, REGISTER_LOCAL_VAR_TYPE, parse_get_local_var
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def set_node_field(graph: AseGraph, node_id: str, field_index: int, value: str) -> NodeLine:
|
|
10
|
+
"""Set one serialized field of a node by absolute index (0 = 'Node' marker)."""
|
|
11
|
+
if field_index in {0, 1, 2}:
|
|
12
|
+
raise ValueError(
|
|
13
|
+
"fields 0-2 are structural (marker/type/id) and cannot be changed with set-field"
|
|
14
|
+
)
|
|
15
|
+
node = graph.node_by_id(node_id)
|
|
16
|
+
if node is None:
|
|
17
|
+
raise KeyError(f"node {node_id} not found")
|
|
18
|
+
if not 0 <= field_index < len(node.raw_fields):
|
|
19
|
+
raise IndexError(f"field index {field_index} out of range (node has {len(node.raw_fields)} fields)")
|
|
20
|
+
node.raw_fields[field_index] = value
|
|
21
|
+
graph.replace_node(node)
|
|
22
|
+
return node
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def remove_node(graph: AseGraph, node_id: str) -> int:
|
|
26
|
+
"""Remove a node and every wire touching it. Returns removed wire count."""
|
|
27
|
+
target = graph.node_by_id(node_id)
|
|
28
|
+
if target is None:
|
|
29
|
+
raise KeyError(f"node {node_id} not found")
|
|
30
|
+
if target.type_name == REGISTER_LOCAL_VAR_TYPE:
|
|
31
|
+
references = []
|
|
32
|
+
for node in graph.nodes:
|
|
33
|
+
if node.type_name != GET_LOCAL_VAR_TYPE:
|
|
34
|
+
continue
|
|
35
|
+
try:
|
|
36
|
+
if parse_get_local_var(node)["register_id"] == node_id:
|
|
37
|
+
references.append(node.node_id)
|
|
38
|
+
except ValueError:
|
|
39
|
+
continue
|
|
40
|
+
if references:
|
|
41
|
+
rendered = ", ".join(sorted(references, key=_node_id_sort_key))
|
|
42
|
+
raise ValueError(f"RegisterLocalVarNode {node_id} is referenced by Get nodes: {rendered}")
|
|
43
|
+
removed_wires = 0
|
|
44
|
+
for i in reversed(range(len(graph.instructions))):
|
|
45
|
+
kind, raw = graph.instructions[i]
|
|
46
|
+
if kind == "wire":
|
|
47
|
+
w = _parse_wire(raw)
|
|
48
|
+
if w.in_node == node_id or w.out_node == node_id:
|
|
49
|
+
graph.delete_instruction(i)
|
|
50
|
+
removed_wires += 1
|
|
51
|
+
for i, (kind, raw) in enumerate(graph.instructions):
|
|
52
|
+
if kind == "node" and _parse_node(raw).node_id == node_id:
|
|
53
|
+
graph.delete_instruction(i)
|
|
54
|
+
return removed_wires
|
|
55
|
+
raise KeyError(f"node {node_id} not found")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _node_id_sort_key(value: str) -> tuple[int, int | str]:
|
|
59
|
+
return (0, int(value)) if value.lstrip("-").isdigit() else (1, value)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def connect(
|
|
63
|
+
graph: AseGraph,
|
|
64
|
+
src_node: str,
|
|
65
|
+
src_port: str,
|
|
66
|
+
dst_node: str,
|
|
67
|
+
dst_port: str,
|
|
68
|
+
) -> WireLine:
|
|
69
|
+
"""Wire source output port to destination input port (creates data flow src -> dst)."""
|
|
70
|
+
if graph.node_by_id(src_node) is None:
|
|
71
|
+
raise KeyError(f"source node {src_node} not found")
|
|
72
|
+
if graph.node_by_id(dst_node) is None:
|
|
73
|
+
raise KeyError(f"destination node {dst_node} not found")
|
|
74
|
+
for w in graph.wires:
|
|
75
|
+
if (w.in_node, w.in_port, w.out_node, w.out_port) == (dst_node, dst_port, src_node, src_port):
|
|
76
|
+
return w # already connected
|
|
77
|
+
if (w.in_node, w.in_port) == (dst_node, dst_port):
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"destination input {dst_node}:{dst_port} is already connected from "
|
|
80
|
+
f"{w.out_node}:{w.out_port}"
|
|
81
|
+
)
|
|
82
|
+
wire = WireLine(in_node=dst_node, in_port=dst_port, out_node=src_node, out_port=src_port)
|
|
83
|
+
graph.add_wire(wire)
|
|
84
|
+
return wire
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def disconnect(graph: AseGraph, src_node: str, src_port: str, dst_node: str, dst_port: str) -> bool:
|
|
88
|
+
return graph.remove_wire(
|
|
89
|
+
out_node=src_node, out_port=src_port, in_node=dst_node, in_port=dst_port
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def next_free_node_id(graph: AseGraph) -> int:
|
|
94
|
+
used = {int(n.node_id) for n in graph.nodes if n.node_id.lstrip("-").isdigit()}
|
|
95
|
+
candidate = 1
|
|
96
|
+
while candidate in used:
|
|
97
|
+
candidate += 1
|
|
98
|
+
return candidate
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def node_from_schema(graph: AseGraph, schema: dict, node_id: int | None, pos: str, type_name: str) -> NodeLine:
|
|
102
|
+
"""Build a NodeLine from a runtime schema (schema['fields'] excludes the 6-field prefix)."""
|
|
103
|
+
if node_id is None:
|
|
104
|
+
node_id = next_free_node_id(graph)
|
|
105
|
+
fixed_fields = list(schema.get("fixed_fields", ["Inherit", "False"]))
|
|
106
|
+
if len(fixed_fields) != 2 or schema.get("fixed_prefix_len", 6) != 6:
|
|
107
|
+
raise ValueError(f"unsupported fixed node prefix for {type_name}")
|
|
108
|
+
fields = ["Node", type_name, str(node_id), pos, *fixed_fields]
|
|
109
|
+
fields.extend(schema["fields"])
|
|
110
|
+
return NodeLine(type_name=type_name, node_id=str(node_id), raw_fields=fields)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _parse_wire(raw: str) -> WireLine:
|
|
114
|
+
from .model import _parse_wire_line
|
|
115
|
+
|
|
116
|
+
return _parse_wire_line(raw)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _parse_node(raw: str) -> NodeLine:
|
|
120
|
+
from .model import parse_node_line
|
|
121
|
+
|
|
122
|
+
return parse_node_line(raw)
|
asecli/core/layout.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Node layout (FR-0008, ADR-0005): Sugiyama-lite layered arrangement.
|
|
2
|
+
|
|
3
|
+
Only mutates node position fields (raw_fields[3] = "x,y"). Everything else
|
|
4
|
+
is untouched, and the wire set is preserved by construction.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
|
|
11
|
+
from .commentary import COMMENTARY_TYPE, inspect_comment_groups
|
|
12
|
+
from .local_vars import local_var_edges
|
|
13
|
+
from .model import AseGraph, NodeLine
|
|
14
|
+
|
|
15
|
+
MASTER_TYPES = {
|
|
16
|
+
"AmplifyShaderEditor.TemplateMultiPassMasterNode",
|
|
17
|
+
"AmplifyShaderEditor.TemplateMasterNode",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def layout_positions(
|
|
22
|
+
graph: AseGraph,
|
|
23
|
+
gap_x: float = 280.0,
|
|
24
|
+
gap_y: float = 120.0,
|
|
25
|
+
origin_x: float = -640.0,
|
|
26
|
+
origin_y: float = 0.0,
|
|
27
|
+
) -> dict[str, tuple[float, float]]:
|
|
28
|
+
"""Compute tidy left-to-right positions. Deterministic for a given graph."""
|
|
29
|
+
nodes = graph.nodes
|
|
30
|
+
frozen = _commentary_composite_nodes(graph)
|
|
31
|
+
movable_nodes = [node for node in nodes if node.node_id not in frozen]
|
|
32
|
+
ids = [n.node_id for n in movable_nodes]
|
|
33
|
+
id_set = set(ids)
|
|
34
|
+
|
|
35
|
+
if frozen and movable_nodes:
|
|
36
|
+
max_right = max(_node_right_edge(node, graph) for node in nodes if node.node_id in frozen)
|
|
37
|
+
origin_x = max(origin_x, max_right + gap_x)
|
|
38
|
+
|
|
39
|
+
# edges: out node feeds in node
|
|
40
|
+
out_edges: dict[str, list[str]] = defaultdict(list)
|
|
41
|
+
in_degree: dict[str, int] = {i: 0 for i in ids}
|
|
42
|
+
edge_pairs = {
|
|
43
|
+
(w.out_node, w.in_node)
|
|
44
|
+
for w in graph.wires
|
|
45
|
+
if w.out_node in id_set and w.in_node in id_set and w.out_node != w.in_node
|
|
46
|
+
}
|
|
47
|
+
edge_pairs.update(
|
|
48
|
+
(source, target)
|
|
49
|
+
for source, target in local_var_edges(graph)
|
|
50
|
+
if source in id_set and target in id_set and source != target
|
|
51
|
+
)
|
|
52
|
+
for source, target in sorted(edge_pairs):
|
|
53
|
+
out_edges[source].append(target)
|
|
54
|
+
in_degree[target] += 1
|
|
55
|
+
|
|
56
|
+
masters = {n.node_id for n in movable_nodes if n.type_name in MASTER_TYPES}
|
|
57
|
+
non_master = [i for i in ids if i not in masters]
|
|
58
|
+
|
|
59
|
+
# Kahn topological order with cycle tolerance
|
|
60
|
+
layer: dict[str, int] = {}
|
|
61
|
+
queue = [i for i in non_master if in_degree[i] == 0]
|
|
62
|
+
for i in queue:
|
|
63
|
+
layer[i] = 0
|
|
64
|
+
order = list(queue)
|
|
65
|
+
while order:
|
|
66
|
+
cur = order.pop(0)
|
|
67
|
+
for nxt in out_edges.get(cur, []):
|
|
68
|
+
if nxt in masters:
|
|
69
|
+
continue
|
|
70
|
+
layer[nxt] = max(layer.get(nxt, 0), layer[cur] + 1)
|
|
71
|
+
in_degree[nxt] -= 1
|
|
72
|
+
if in_degree[nxt] == 0:
|
|
73
|
+
order.append(nxt)
|
|
74
|
+
# cycle survivors: place after their longest known predecessor
|
|
75
|
+
unassigned = [i for i in non_master if i not in layer]
|
|
76
|
+
changed = True
|
|
77
|
+
while changed and unassigned:
|
|
78
|
+
changed = False
|
|
79
|
+
for i in list(unassigned):
|
|
80
|
+
preds = [layer[p] for p, outs in out_edges.items() if i in outs and p in layer]
|
|
81
|
+
if preds:
|
|
82
|
+
layer[i] = max(preds) + 1
|
|
83
|
+
unassigned.remove(i)
|
|
84
|
+
changed = True
|
|
85
|
+
for i in unassigned: # fully isolated from everything known
|
|
86
|
+
layer[i] = 0
|
|
87
|
+
|
|
88
|
+
max_layer = max(layer.values(), default=-1)
|
|
89
|
+
for m in masters:
|
|
90
|
+
layer[m] = max_layer + 1 if non_master else 0
|
|
91
|
+
if masters and non_master:
|
|
92
|
+
max_layer += 1
|
|
93
|
+
|
|
94
|
+
# barycenter ordering within layers (two passes), deterministic
|
|
95
|
+
by_layer: dict[int, list[str]] = defaultdict(list)
|
|
96
|
+
for n in movable_nodes: # instruction order = stable initial order
|
|
97
|
+
by_layer[layer[n.node_id]].append(n.node_id)
|
|
98
|
+
preds_of: dict[str, list[str]] = defaultdict(list)
|
|
99
|
+
for src, outs in out_edges.items():
|
|
100
|
+
for dst in outs:
|
|
101
|
+
preds_of[dst].append(src)
|
|
102
|
+
|
|
103
|
+
y_of: dict[str, float] = {}
|
|
104
|
+
for l in sorted(by_layer):
|
|
105
|
+
for row, nid in enumerate(by_layer[l]):
|
|
106
|
+
y_of[nid] = float(row)
|
|
107
|
+
for _ in range(2):
|
|
108
|
+
for l in sorted(by_layer):
|
|
109
|
+
initial_row = {nid: i for i, nid in enumerate(by_layer[l])}
|
|
110
|
+
def key(nid: str) -> tuple[float, int]:
|
|
111
|
+
ps = [y_of[p] for p in preds_of.get(nid, []) if p in y_of]
|
|
112
|
+
return (sum(ps) / len(ps) if ps else 0.0, initial_row[nid])
|
|
113
|
+
by_layer[l].sort(key=key)
|
|
114
|
+
for row, nid in enumerate(by_layer[l]):
|
|
115
|
+
y_of[nid] = float(row)
|
|
116
|
+
|
|
117
|
+
positions: dict[str, tuple[float, float]] = {}
|
|
118
|
+
for l in sorted(by_layer):
|
|
119
|
+
members = by_layer[l]
|
|
120
|
+
rows = len(members)
|
|
121
|
+
for row, nid in enumerate(members):
|
|
122
|
+
x = origin_x + l * gap_x
|
|
123
|
+
y = origin_y + (row - (rows - 1) / 2.0) * gap_y
|
|
124
|
+
positions[nid] = (round(x, 1), round(y, 1))
|
|
125
|
+
for node in nodes:
|
|
126
|
+
if node.node_id in frozen:
|
|
127
|
+
positions[node.node_id] = _position(node)
|
|
128
|
+
return positions
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def apply_positions(graph: AseGraph, positions: dict[str, tuple[float, float]]) -> int:
|
|
132
|
+
"""Write positions back into node raw_fields. Returns count of nodes moved."""
|
|
133
|
+
moved = 0
|
|
134
|
+
for n in graph.nodes:
|
|
135
|
+
pos = positions.get(n.node_id)
|
|
136
|
+
if pos is None:
|
|
137
|
+
continue
|
|
138
|
+
if _position(n) == pos:
|
|
139
|
+
continue
|
|
140
|
+
new_xy = f"{pos[0]},{pos[1]}"
|
|
141
|
+
if n.raw_fields[3] != new_xy:
|
|
142
|
+
n.raw_fields[3] = new_xy
|
|
143
|
+
graph.replace_node(n)
|
|
144
|
+
moved += 1
|
|
145
|
+
return moved
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def tidy(graph: AseGraph, gap_x: float = 280.0, gap_y: float = 120.0) -> int:
|
|
149
|
+
return apply_positions(graph, layout_positions(graph, gap_x, gap_y))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _commentary_composite_nodes(graph: AseGraph) -> set[str]:
|
|
153
|
+
"""Keep curated Comment frames and every member as fixed composite units."""
|
|
154
|
+
frozen = set()
|
|
155
|
+
for group in inspect_comment_groups(graph):
|
|
156
|
+
frozen.add(group["node_id"])
|
|
157
|
+
frozen.update(group["members"])
|
|
158
|
+
return frozen
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _position(node: NodeLine) -> tuple[float, float]:
|
|
162
|
+
try:
|
|
163
|
+
x, y = node.raw_fields[3].split(",")
|
|
164
|
+
return float(x), float(y)
|
|
165
|
+
except (IndexError, ValueError) as exc:
|
|
166
|
+
raise ValueError(f"node {node.node_id} has invalid x,y position") from exc
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _node_right_edge(node: NodeLine, graph: AseGraph) -> float:
|
|
170
|
+
x, _ = _position(node)
|
|
171
|
+
if node.type_name == COMMENTARY_TYPE:
|
|
172
|
+
group = next(item for item in inspect_comment_groups(graph) if item["node_id"] == node.node_id)
|
|
173
|
+
return x + group["width"]
|
|
174
|
+
return x + 200.0
|