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,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}")
@@ -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