@openpowershift/logic-diagram-language 0.1.0

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 (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/dist/examples.d.ts +2 -0
  4. package/dist/examples.js +469 -0
  5. package/dist/export-image.d.ts +10 -0
  6. package/dist/export-image.js +47 -0
  7. package/dist/index.d.ts +10 -0
  8. package/dist/index.html +13 -0
  9. package/dist/index.js +18 -0
  10. package/dist/parser/ast.d.ts +118 -0
  11. package/dist/parser/ast.js +79 -0
  12. package/dist/parser/index.d.ts +3 -0
  13. package/dist/parser/index.js +2 -0
  14. package/dist/parser/parser.d.ts +2 -0
  15. package/dist/parser/parser.js +635 -0
  16. package/dist/renderer/astar-router.d.ts +16 -0
  17. package/dist/renderer/astar-router.js +532 -0
  18. package/dist/renderer/gates.d.ts +19 -0
  19. package/dist/renderer/gates.js +92 -0
  20. package/dist/renderer/graph.d.ts +31 -0
  21. package/dist/renderer/graph.js +403 -0
  22. package/dist/renderer/layout.d.ts +76 -0
  23. package/dist/renderer/layout.js +3121 -0
  24. package/dist/renderer/math-renderer.d.ts +16 -0
  25. package/dist/renderer/math-renderer.js +119 -0
  26. package/dist/renderer/svg-renderer.d.ts +3 -0
  27. package/dist/renderer/svg-renderer.js +599 -0
  28. package/dist/renderer/wires.d.ts +5 -0
  29. package/dist/renderer/wires.js +12 -0
  30. package/dist/theme/themes.d.ts +58 -0
  31. package/dist/theme/themes.js +104 -0
  32. package/docs/api.adoc +196 -0
  33. package/docs/ldl-for-llms.md +163 -0
  34. package/docs/user-guide.adoc +318 -0
  35. package/package.json +78 -0
  36. package/spec/render.sh +22 -0
  37. package/spec/sections/attributes.adoc +80 -0
  38. package/spec/sections/connections.adoc +39 -0
  39. package/spec/sections/examples.adoc +212 -0
  40. package/spec/sections/expressions.adoc +182 -0
  41. package/spec/sections/file-extension.adoc +5 -0
  42. package/spec/sections/function-blocks.adoc +120 -0
  43. package/spec/sections/grammar.adoc +64 -0
  44. package/spec/sections/hyperlinks.adoc +18 -0
  45. package/spec/sections/introduction.adoc +16 -0
  46. package/spec/sections/layout-rules.adoc +491 -0
  47. package/spec/sections/lexical-conventions.adoc +68 -0
  48. package/spec/sections/objects.adoc +31 -0
  49. package/spec/sections/options.adoc +146 -0
  50. package/spec/sections/ports.adoc +77 -0
  51. package/spec/sections/rendering-contract.adoc +11 -0
  52. package/spec/sections/revision-history.adoc +9 -0
  53. package/spec/sections/styling.adoc +83 -0
  54. package/spec/sections/svg-symbol-specification.adoc +149 -0
  55. package/spec/sections/symbol-definitions.adoc +143 -0
  56. package/spec/sections/templates.adoc +31 -0
  57. package/spec/spec.adoc +49 -0
@@ -0,0 +1,3121 @@
1
+ import { DEFAULT_OPTIONS } from '../parser/ast.js';
2
+ import { hasMathContent } from './math-renderer.js';
3
+ import { routeWireAStar } from './astar-router.js';
4
+ import { orCurveTapX } from './gates.js';
5
+ import { buildGraph } from './graph.js';
6
+ const GATE_W = 60;
7
+ const INPUT_BAR_OFFSET = 12;
8
+ const GATE_W_MULTI = 75;
9
+ const AND_GATE_H_BASE = 45;
10
+ const PORT_SPACING = 15;
11
+ const NOT_TRIANGLE_W = 50;
12
+ const BUBBLE_R = 5;
13
+ const NOT_GATE_TOTAL_W = NOT_TRIANGLE_W + BUBBLE_R * 2 + 5;
14
+ const NOT_GATE_H = 40;
15
+ const INPUT_LABEL_W = 90;
16
+ const OUTPUT_LABEL_W = 90;
17
+ const INPUT_STUB = 10;
18
+ const OUTPUT_STUB = 10;
19
+ const PORT_SIZE = 5;
20
+ const COL_SPACING = 260;
21
+ const ROW_SPACING = 80;
22
+ const PAD_X = 170;
23
+ const PAD_Y = 50;
24
+ const MIN_PORT_GAP = 25;
25
+ const MIN_DOGLEG = 30;
26
+ const MIN_WIRE_SPACING = 10;
27
+ const MIN_CHANNEL_SPACING = 20;
28
+ const WIRE_PAD = MIN_WIRE_SPACING / 2;
29
+ const BUBBLE_STUB = 5;
30
+ const GRID = 5;
31
+ // Round a height UP to an even number of grid cells so the vertical centre (h/2) is exactly
32
+ // on the grid. AND/OR gate output ports and the OR arc tip both sit at h/2; without this the
33
+ // drawn arc tip drifts off-grid and no longer coincides with the port / junction dot.
34
+ const EVEN_CELL = 2 * GRID;
35
+ function evenGridHeight(v) { return Math.ceil(v / EVEN_CELL) * EVEN_CELL; }
36
+ let _id = 0;
37
+ function uid(prefix) { return `${prefix}_${++_id}`; }
38
+ function naturalCompare(a, b) {
39
+ const aParts = a.match(/\d+|\D+/g) ?? [a];
40
+ const bParts = b.match(/\d+|\D+/g) ?? [b];
41
+ for (let i = 0; i < Math.min(aParts.length, bParts.length); i++) {
42
+ const aIsNum = /^\d+$/.test(aParts[i]);
43
+ const bIsNum = /^\d+$/.test(bParts[i]);
44
+ if (aIsNum && bIsNum) {
45
+ const diff = parseInt(aParts[i]) - parseInt(bParts[i]);
46
+ if (diff !== 0)
47
+ return diff;
48
+ }
49
+ else {
50
+ const diff = aParts[i].localeCompare(bParts[i]);
51
+ if (diff !== 0)
52
+ return diff;
53
+ }
54
+ }
55
+ return aParts.length - bParts.length;
56
+ }
57
+ function rectsOverlap(ax, ay, aw, ah, bx, by, bw, bh, pad = 0) {
58
+ return !(ax + aw + pad < bx || bx + bw + pad < ax || ay + ah + pad < by || by + bh + pad < ay);
59
+ }
60
+ // The base (pre-expansion) height of a node, mirroring the height logic in the node-creation
61
+ // loop. Used by the coordinate assignment to space nodes within a column.
62
+ function baseNodeHeight(n) {
63
+ if (n.gateType === 'DUMMY')
64
+ return 0; // a thin long-edge lane; spacing comes from VGAP
65
+ if (n.kind === 'input' || n.kind === 'output') {
66
+ const math = (!!n.name && hasMathContent(n.name)) || (!!n.description && hasMathContent(n.description));
67
+ return n.description || math ? 30 : 20;
68
+ }
69
+ if (n.blockType === 'FB')
70
+ return fbDims(n).h + (n.description ? 18 : 0);
71
+ if (n.blockType)
72
+ return blockSize(n.blockType).h + (n.name ? 18 : 0) + (n.description ? 18 : 0);
73
+ if (n.gateType === 'NOT')
74
+ return NOT_GATE_H;
75
+ const numInputs = n.inputIds.length || 2;
76
+ const labelSpace = (n.name ? 18 : 0) + (n.description ? 18 : 0);
77
+ return gateBodyHeight(numInputs, gateGap(n), labelSpace);
78
+ }
79
+ // ── Gate vertical layout: single source of truth ──────────────────────────────────────────────
80
+ // A gate's body is sized and its ports laid out PURELY from its port count and a per-gate vertical
81
+ // port spacing `gap`: input port i sits at top + GATE_END_PAD + i*gap, so adjacent ports are `gap`
82
+ // apart with a fixed GATE_END_PAD above the first and below the last; the body height is the span
83
+ // plus both pads (rounded up to an even grid so the dead-centre output stays on-grid). With the
84
+ // default gap = PORT_SPACING this reproduces the historical (n+1)*PORT_SPACING layout exactly; a
85
+ // larger gap (for a gate fed by labelled inputs) widens the port spacing without other passes
86
+ // needing to know. Every pass that sizes or re-places a gate body derives geometry from these.
87
+ const GATE_END_PAD = PORT_SPACING;
88
+ function gateBodyHeight(numInputs, gap = PORT_SPACING, labelSpace = 0) {
89
+ return evenGridHeight(Math.max(AND_GATE_H_BASE, (numInputs - 1) * gap + 2 * GATE_END_PAD) + labelSpace);
90
+ }
91
+ function gateInputPortY(top, i, gap = PORT_SPACING) {
92
+ return top + GATE_END_PAD + i * gap;
93
+ }
94
+ // A gate's first-class vertical port spacing (`portGap`), defaulting to PORT_SPACING.
95
+ function gateGap(n) { return n.portGap ?? PORT_SPACING; }
96
+ // Body dimensions for a generic FB block: square-ish, sized to its port counts and labels.
97
+ function fbDims(n) {
98
+ const ni = n.inputIds.length;
99
+ const no = Math.max(1, n.usedPorts?.size ?? 1);
100
+ // Outputs sit at the output-stack gap (40) so the output nodes they feed line up straight;
101
+ // inputs need only MIN_PORT_GAP. Height fits whichever side has more ports.
102
+ const h = Math.max(50, Math.max((no - 1) * 40, (ni - 1) * MIN_PORT_GAP) + 50);
103
+ const textW = (s) => (s ? s.length * 6.5 : 0);
104
+ const inMax = Math.max(0, ...(n.inputLabels ?? []).map(textW));
105
+ const outMax = Math.max(0, ...[...(n.usedPorts ?? [])].map(p => textW(p === 'OUT' ? undefined : p)));
106
+ // Room for the left labels, the centred name and the right labels without overlap.
107
+ const w = Math.max(70, inMax + outMax + textW(n.name) + 30);
108
+ return { w: Math.ceil(w / GRID) * GRID, h: Math.ceil(h / GRID) * GRID };
109
+ }
110
+ // Body dimensions for each SEL function block.
111
+ function blockSize(blockType) {
112
+ switch (blockType) {
113
+ case 'TIMER': return { w: 85, h: 50 };
114
+ case 'SR': return { w: 60, h: 55 };
115
+ case 'COMPARE': return { w: 65, h: 50 };
116
+ case 'RISING':
117
+ case 'FALLING': return { w: 50, h: 40 };
118
+ default: return { w: 60, h: 45 };
119
+ }
120
+ }
121
+ // Priority-method vertical coordinate assignment (Sugiyama/Tagawa style). Each column keeps
122
+ // its fixed order (the barycentre ordering); we then alternate downward (align to sources)
123
+ // and upward (align to consumers) sweeps. In each sweep a node is moved toward the median of
124
+ // its neighbours on that side, but it may not displace a neighbour of higher-or-equal
125
+ // priority (priority = degree on the sweep side); lower-priority neighbours are pushed to keep
126
+ // the minimum gap. Returns each node's top-left Y (grid-snapped, normalised to PAD_Y).
127
+ function assignCoordinates(nodes, depthGroups, rowMap, maxDepth, rowSpacing, laneTight = false) {
128
+ // Feedback edges (input is an output node looped back) are excluded from placement: the
129
+ // back-edge would otherwise drag a gate toward the far-right output it feeds.
130
+ const isFeedback = (inputId) => nodes.get(inputId)?.kind === 'output';
131
+ const successors = new Map();
132
+ for (const n of nodes.values()) {
133
+ for (const id of n.inputIds) {
134
+ if (isFeedback(id))
135
+ continue;
136
+ const a = successors.get(id) ?? [];
137
+ a.push(n.id);
138
+ successors.set(id, a);
139
+ }
140
+ }
141
+ const columns = [];
142
+ for (let d = 0; d <= maxDepth; d++) {
143
+ columns[d] = (depthGroups.get(d) ?? []).slice()
144
+ .sort((a, b) => (rowMap.get(a.id) ?? 0) - (rowMap.get(b.id) ?? 0));
145
+ }
146
+ const H = new Map();
147
+ for (const n of nodes.values())
148
+ H.set(n.id, baseNodeHeight(n));
149
+ const VGAP = Math.max(MIN_PORT_GAP, Math.round(rowSpacing / GRID) * GRID);
150
+ // Minimum centre-to-centre gap between two adjacent inputs in the input column, based on what
151
+ // their actual rendered labels need plus a margin — keeps the input column tight (matching the
152
+ // gate's own height) rather than packing at rowSpacing, which left large white gaps for high
153
+ // fan-in (e.g. 20-input AND had a 1920px stack for a 320px gate). The MIN_PORT_GAP floor
154
+ // preserves the dot diameter; the description line bumps the gap just enough not to overlap
155
+ // an adjacent input's description. Gates keep the rowSpacing-based sep() so routing channels
156
+ // between gate columns stay generous.
157
+ const minInputGap = (a, b) => {
158
+ // The gate's port-expansion pass enforces MIN_PORT_GAP (25px) between successive ports.
159
+ // For an input to leave the gate-port Ys at their natural (PORT_SPACING=15) positions, the
160
+ // inputs must be at LEAST MIN_PORT_GAP apart — otherwise the expansion pass widens the gate
161
+ // body to match the input column, ballooning it. Add label-line space on top of MIN_PORT_GAP
162
+ // when name/description is present so e.g. a stack of labelled inputs has room for them.
163
+ // A 2×MIN_PORT_GAP floor leaves headroom for multi-consumer inputs (an input feeding two
164
+ // different multi-input gates spans more port-Ys than a single-consumer), keeping the gate
165
+ // expansion pass within budget and avoiding the dogleg-killer's all-or-nothing failure.
166
+ const lineA = (a.name ? 14 : 0) + (a.description ? 10 : 0);
167
+ const lineB = (b.name ? 14 : 0) + (b.description ? 10 : 0);
168
+ return Math.round((MIN_PORT_GAP + 10 + Math.max(lineA, lineB)) / GRID) * GRID;
169
+ };
170
+ // Two adjacent long-edge dummies are parallel WIRE LANES, not gates. Stacking them at the full
171
+ // gate-sized routing channel (VGAP) inflates a column and strands a rank-extreme lane far from its
172
+ // natural line (the "input floated to the top" void). Under `laneTight`, adjacent lanes pack at
173
+ // MIN_DOGLEG instead (a wire leaving one lane into a nearby port still turns with a legal dogleg).
174
+ // This is offered as a candidate layout (see layoutDiagram) and kept only where it measurably
175
+ // improves the geometry, so it can never regress a diagram that relies on the generous spacing. A
176
+ // dummy adjacent to a real GATE always keeps the full channel, so gates never shift toward a lane.
177
+ const LANE_GAP = MIN_DOGLEG;
178
+ const sep = (a, b) => {
179
+ if (a.kind === 'input' && b.kind === 'input') {
180
+ // If both inputs feed a common multi-input gate, space them at that gate's port gap so they
181
+ // land directly on adjacent ports — a straight fan-in with no gate growth. (Clamp to at least
182
+ // half their combined height so labels never overlap.)
183
+ const sa = new Set(successors.get(a.id) ?? []);
184
+ const sharedGap = (successors.get(b.id) ?? [])
185
+ .map(id => (sa.has(id) ? nodes.get(id)?.portGap : undefined))
186
+ .filter((g) => g !== undefined);
187
+ if (sharedGap.length)
188
+ return Math.max(Math.max(...sharedGap), (H.get(a.id) + H.get(b.id)) / 2);
189
+ return Math.min((H.get(a.id) + H.get(b.id)) / 2 + VGAP, minInputGap(a, b));
190
+ }
191
+ if (laneTight && (a.gateType === 'DUMMY' || b.gateType === 'DUMMY'))
192
+ return (H.get(a.id) + H.get(b.id)) / 2 + LANE_GAP; // lane↔lane / lane↔gate: pack to a dogleg
193
+ return (H.get(a.id) + H.get(b.id)) / 2 + VGAP; // gate↔gate (or loose): full channel
194
+ };
195
+ const centre = new Map();
196
+ for (let d = 0; d <= maxDepth; d++) {
197
+ const col = columns[d];
198
+ let y = 0;
199
+ for (let i = 0; i < col.length; i++) {
200
+ if (i > 0)
201
+ y += sep(col[i - 1], col[i]);
202
+ centre.set(col[i].id, y);
203
+ }
204
+ }
205
+ // Weighted isotonic regression (Pool Adjacent Violators) — the exact L2 optimum for
206
+ // min Σ wᵢ(xᵢ − tᵢ)² subject to x non-decreasing. Applied per column after removing the
207
+ // separation gaps (substitute zᵢ = centreᵢ − Σ_{k<i} sepₖ, turning "centre_{i+1} − centreᵢ ≥ sep"
208
+ // into "z monotone"), so a whole column is placed at its JOINT optimum in one shot — no greedy
209
+ // node-at-a-time blocking that leaves a column stuck in a locally-fixed arrangement.
210
+ const pava = (t, w) => {
211
+ const val = [], wt = [], cnt = [];
212
+ for (let i = 0; i < t.length; i++) {
213
+ let v = t[i], ww = w[i], c = 1;
214
+ while (val.length && val[val.length - 1] > v) {
215
+ const pv = val.pop(), pw = wt.pop(), pc = cnt.pop();
216
+ v = (v * ww + pv * pw) / (ww + pw);
217
+ ww += pw;
218
+ c += pc;
219
+ }
220
+ val.push(v);
221
+ wt.push(ww);
222
+ cnt.push(c);
223
+ }
224
+ const out = [];
225
+ for (let b = 0; b < val.length; b++)
226
+ for (let k = 0; k < cnt[b]; k++)
227
+ out.push(val[b]);
228
+ return out;
229
+ };
230
+ // Absolute centre-space Y of the port that `sourceId` feeds in consumer `c`. AND/OR assign ports
231
+ // to sources in ascending Y; a fixed-port block / NOT / output uses its centre.
232
+ const portYForSource = (c, sourceId, cCentre) => {
233
+ if ((c.gateType === 'AND' || c.gateType === 'OR') && c.inputIds.length >= 2) {
234
+ const gap = gateGap(c);
235
+ const rank = c.inputIds.filter(id => !isFeedback(id))
236
+ .map(id => ({ id, y: centre.get(id) ?? cCentre }))
237
+ .sort((a, b) => a.y - b.y)
238
+ .findIndex(r => r.id === sourceId);
239
+ if (rank >= 0)
240
+ return cCentre - H.get(c.id) / 2 + GATE_END_PAD + rank * gap;
241
+ }
242
+ return cCentre;
243
+ };
244
+ // A node's desired centre = mean over ALL its edges of the centre that draws that edge straight:
245
+ // a source edge wants port_i aligned to source_i; a consumer edge wants the node's output aligned
246
+ // to the consumer's port. Two sources feeding ADJACENT ports of one gate therefore pull to
247
+ // positions ≥ the port gap apart — fan-in spreads to the port spacing rather than crowding (which
248
+ // is what previously forced the cramped-port / sub-MIN-dogleg compromise).
249
+ const nodeTarget = (n) => {
250
+ let sum = 0, wsum = 0;
251
+ const h = H.get(n.id), gap = gateGap(n);
252
+ const srcYs = n.inputIds.filter(id => !isFeedback(id))
253
+ .map(id => ({ id, y: centre.get(id) }))
254
+ .filter((s) => s.y !== undefined);
255
+ if ((n.gateType === 'AND' || n.gateType === 'OR') && srcYs.length >= 2) {
256
+ srcYs.sort((a, b) => a.y - b.y).forEach((s, rank) => { sum += s.y - (GATE_END_PAD + rank * gap) + h / 2; wsum++; });
257
+ }
258
+ else {
259
+ for (const s of srcYs) {
260
+ sum += s.y;
261
+ wsum++;
262
+ }
263
+ }
264
+ for (const cid of successors.get(n.id) ?? []) {
265
+ const c = nodes.get(cid), cy = centre.get(cid);
266
+ if (c && cy !== undefined) {
267
+ sum += portYForSource(c, n.id, cy);
268
+ wsum++;
269
+ }
270
+ }
271
+ return wsum === 0 ? null : sum / wsum;
272
+ };
273
+ // Place a whole column at its joint optimum: target per node, then PAVA in gap-removed space.
274
+ // Node weight = its degree, so a well-connected node holds its target more firmly.
275
+ const solveColumn = (col) => {
276
+ const n = col.length;
277
+ if (n === 0)
278
+ return;
279
+ const G = [0];
280
+ for (let i = 1; i < n; i++)
281
+ G[i] = G[i - 1] + sep(col[i - 1], col[i]);
282
+ const t = [], w = [];
283
+ for (let i = 0; i < n; i++) {
284
+ t.push((nodeTarget(col[i]) ?? centre.get(col[i].id)) - G[i]);
285
+ w.push(Math.max(1, col[i].inputIds.filter(id => !isFeedback(id)).length + (successors.get(col[i].id) ?? []).length));
286
+ }
287
+ const z = pava(t, w);
288
+ for (let i = 0; i < n; i++)
289
+ centre.set(col[i].id, z[i] + G[i]);
290
+ };
291
+ // Iterate to convergence, alternating direction so both source and consumer influence propagate.
292
+ // Each column solve is the exact constrained optimum given its neighbours' current positions.
293
+ for (let it = 0; it < 12; it++) {
294
+ if (it % 2 === 0)
295
+ for (let d = 0; d <= maxDepth; d++)
296
+ solveColumn(columns[d]);
297
+ else
298
+ for (let d = maxDepth; d >= 0; d--)
299
+ solveColumn(columns[d]);
300
+ }
301
+ let minTop = Infinity;
302
+ for (const n of nodes.values())
303
+ minTop = Math.min(minTop, centre.get(n.id) - H.get(n.id) / 2);
304
+ const result = new Map();
305
+ for (const n of nodes.values()) {
306
+ const top = centre.get(n.id) - H.get(n.id) / 2 - minTop + PAD_Y;
307
+ result.set(n.id, Math.round(top / GRID) * GRID);
308
+ }
309
+ return result;
310
+ }
311
+ // Public entry: lay the diagram out with the input ORDERING that renders the fewest crossings. Each
312
+ // candidate strategy is a full, independent layout (layoutOnce rebuilds from scratch), and we keep
313
+ // whichever measures fewer crossings on the REAL geometry. 'heuristic' is always a candidate, so we
314
+ // can never render worse than before; 'crossmin' is tried only when 'heuristic' isn't already clean
315
+ // (the common case), keeping cost ~1x. Smarter candidates can be added later — each only ever helps.
316
+ export function layoutDiagram(diagram, portMeta = [], options) {
317
+ // Candidates span two independent dimensions: ordering ('heuristic' | 'crossmin') and long-edge
318
+ // lane packing (loose | tight — see assignCoordinates `laneTight`). Each is a full independent
319
+ // layout; we keep the best on MEASURED geometry, ranked lexicographically by: fewest sub-min
320
+ // doglegs, then fewest crossings, then fewest bends, then shortest. The loose-heuristic layout is
321
+ // always a candidate and is always dogleg-free, so the winner can never have more doglegs or
322
+ // crossings than today — tight only wins when it genuinely removes a crossing or collapses a void
323
+ // (shorter height) without cost. Ties keep the earlier (loose) candidate, so diagrams without long
324
+ // edges stay byte-identical.
325
+ const subMinDoglegs = (l) => {
326
+ let c = 0;
327
+ for (const w of l.wires) {
328
+ if (w.feedback)
329
+ continue;
330
+ for (let i = 0; i < w.points.length - 1; i++) {
331
+ const a = w.points[i], b = w.points[i + 1];
332
+ if (Math.abs(a.x - b.x) < 0.5) {
333
+ const len = Math.abs(a.y - b.y);
334
+ if (len >= 0.5 && len < MIN_DOGLEG - 0.01)
335
+ c++;
336
+ }
337
+ }
338
+ }
339
+ return c;
340
+ };
341
+ const cr = (l) => findWireCrossings(l.wires, l.junctions).length;
342
+ const bends = (l) => l.wires.reduce((s, w) => s + Math.max(0, w.points.length - 2), 0);
343
+ // Cross-net COINCIDENT parallel overlap: two different nets' same-orientation segments sharing an
344
+ // axis line (co-linear) and overlapping along it — i.e. drawn on top of each other. This is the
345
+ // wire-separation contract's worst failure (worse than a crossing): the routing passes refuse to
346
+ // trade it for a crossing, so when a placement/ordering leaves two nets genuinely contending for
347
+ // one channel an overlap can survive a single layout. Scoring it as the TOP-priority term makes
348
+ // the candidate selection reject any ordering/strategy that overlaps in favour of one that doesn't
349
+ // — so no single pass's all-or-nothing fallback can leak an overlap into the chosen layout when a
350
+ // clean candidate exists. Backed by the invariants.spec "no cross-net overlapping parallel" test.
351
+ const overlaps = (l) => {
352
+ const V = [], H = [];
353
+ for (const w of l.wires) {
354
+ if (w.feedback)
355
+ continue;
356
+ for (let i = 0; i < w.points.length - 1; i++) {
357
+ const a = w.points[i], b = w.points[i + 1];
358
+ if (Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) >= 0.5)
359
+ V.push({ p: a.x, a0: Math.min(a.y, b.y), a1: Math.max(a.y, b.y), from: w.fromId });
360
+ else if (Math.abs(a.y - b.y) < 0.5 && Math.abs(a.x - b.x) >= 0.5)
361
+ H.push({ p: a.y, a0: Math.min(a.x, b.x), a1: Math.max(a.x, b.x), from: w.fromId });
362
+ }
363
+ }
364
+ let c = 0;
365
+ for (const set of [V, H])
366
+ for (let i = 0; i < set.length; i++)
367
+ for (let j = i + 1; j < set.length; j++) {
368
+ const a = set[i], b = set[j];
369
+ if (a.from === b.from || Math.abs(a.p - b.p) >= 0.5)
370
+ continue; // different net, same axis line
371
+ if (Math.min(a.a1, b.a1) - Math.max(a.a0, b.a0) >= GRID)
372
+ c++; // co-linear overlap (matches the invariant)
373
+ }
374
+ return c;
375
+ };
376
+ // Cross-net gate/block BODY intrusion: a wire segment (not entering that node) running through or
377
+ // along a non-endpoint gate/block body. Uses the FULL body rect (no inset), so it also catches a
378
+ // wire grazing the body's edge — a straight pass-through drawn along a block's border reads as part
379
+ // of the block outline. A* can pick such a graze when it is the fewest-crossing route (the body
380
+ // graze is not itself a crossing), so scoring it lets the candidate selection prefer a layout that
381
+ // routes clear of the body when one exists. Ranked just below overlaps (both are "a wire drawn on
382
+ // top of something it must stay clear of").
383
+ const bodyIntrusions = (l) => {
384
+ const gates = l.nodes.filter(n => n.gateType !== 'INPUT' && n.gateType !== 'OUTPUT');
385
+ let c = 0;
386
+ for (const w of l.wires) {
387
+ if (w.feedback)
388
+ continue;
389
+ for (let i = 0; i < w.points.length - 1; i++) {
390
+ const a = w.points[i], b = w.points[i + 1];
391
+ const xm = Math.min(a.x, b.x), xM = Math.max(a.x, b.x), ym = Math.min(a.y, b.y), yM = Math.max(a.y, b.y);
392
+ for (const g of gates) {
393
+ if (g.id === w.fromId || g.id === w.toId)
394
+ continue; // legitimately enters this node
395
+ // Real overlap with the body rect, edges included (so an on-edge graze counts).
396
+ if (xM > g.absX + 0.5 && g.absX + g.width > xm + 0.5 && yM > g.absY - 0.5 && g.absY + g.height > ym - 0.5)
397
+ c++;
398
+ }
399
+ }
400
+ }
401
+ return c;
402
+ };
403
+ const score = (l) => [overlaps(l), bodyIntrusions(l), subMinDoglegs(l), cr(l), bends(l), l.height];
404
+ const better = (l, b) => {
405
+ const sl = score(l), sb = score(b);
406
+ for (let i = 0; i < sl.length; i++)
407
+ if (sl[i] !== sb[i])
408
+ return sl[i] < sb[i];
409
+ return false; // tie → keep the earlier candidate
410
+ };
411
+ let best = layoutOnce(diagram, portMeta, options, 'heuristic', false);
412
+ const consider = (l) => { if (better(l, best))
413
+ best = l; };
414
+ consider(layoutOnce(diagram, portMeta, options, 'heuristic', true)); // lane-tight variant (collapses voids)
415
+ // Reach for the crossmin candidates when the current best is not already clean — it still has
416
+ // crossings, or (rarer) carries a cross-net overlap or a gate-body intrusion a different ordering
417
+ // may avoid.
418
+ if (cr(best) > 0 || overlaps(best) > 0 || bodyIntrusions(best) > 0) {
419
+ consider(layoutOnce(diagram, portMeta, options, 'crossmin', false));
420
+ consider(layoutOnce(diagram, portMeta, options, 'crossmin', true));
421
+ }
422
+ symmetriseSmallGates(best); // cosmetic, validated post-pass
423
+ return best;
424
+ }
425
+ // Cosmetic symmetry for small gates: for a ≤3-input gate with exactly one dogleg fan-in above the
426
+ // middle and one below (each a clean H–V–H), align their turn channels to a single X so the two
427
+ // doglegs mirror — a symmetric funnel, which reads much cleaner. Runs on the FINAL chosen layout, so
428
+ // it cannot flip the candidate choice; and every move is fully validated — kept only if it neither
429
+ // adds a crossing nor brings the moved wire within MIN_WIRE_SPACING of another net (else reverted),
430
+ // so it can never introduce a new problem.
431
+ function symmetriseSmallGates(l) {
432
+ const crossings = () => findWireCrossings(l.wires, l.junctions).length;
433
+ const hvh = (w) => w.points.length === 4 && Math.abs(w.points[1].x - w.points[2].x) < 0.5
434
+ && Math.abs(w.points[0].y - w.points[1].y) < 0.5 && Math.abs(w.points[2].y - w.points[3].y) < 0.5;
435
+ const crowds = (self) => {
436
+ for (let s = 0; s < self.points.length - 1; s++) {
437
+ const a = self.points[s], b = self.points[s + 1];
438
+ const horiz = Math.abs(a.y - b.y) < 0.5;
439
+ const perp = horiz ? a.y : a.x, mn = horiz ? Math.min(a.x, b.x) : Math.min(a.y, b.y), mx = horiz ? Math.max(a.x, b.x) : Math.max(a.y, b.y);
440
+ for (const o of l.wires) {
441
+ if (o === self || o.fromId === self.fromId)
442
+ continue;
443
+ for (let k = 0; k < o.points.length - 1; k++) {
444
+ const c = o.points[k], d = o.points[k + 1];
445
+ const oh = Math.abs(c.y - d.y) < 0.5;
446
+ if (oh !== horiz || (!oh && Math.abs(c.x - d.x) >= 0.5) || (oh && Math.abs(c.x - d.x) < 0.5))
447
+ continue;
448
+ const dp = Math.abs((oh ? c.y : c.x) - perp);
449
+ if (dp >= MIN_WIRE_SPACING - 0.5)
450
+ continue; // clear (dp≈0 exact-overlap counts as crowd)
451
+ const omn = oh ? Math.min(c.x, d.x) : Math.min(c.y, d.y), omx = oh ? Math.max(c.x, d.x) : Math.max(c.y, d.y);
452
+ if (Math.min(mx, omx) - Math.max(mn, omn) > 0.5)
453
+ return true;
454
+ }
455
+ }
456
+ }
457
+ return false;
458
+ };
459
+ for (const g of l.nodes) {
460
+ if (g.gateType === 'INPUT' || g.gateType === 'OUTPUT' || g.inputs.length < 2 || g.inputs.length > 3)
461
+ continue;
462
+ const cy = g.absY + g.height / 2;
463
+ const fan = l.wires.filter(w => w.toId === g.id && !w.feedback && hvh(w));
464
+ const above = fan.filter(w => w.points[3].y < cy - 0.5), below = fan.filter(w => w.points[3].y > cy + 0.5);
465
+ if (above.length !== 1 || below.length !== 1)
466
+ continue;
467
+ const aw = above[0], bw = below[0], ax = aw.points[1].x, bx = bw.points[1].x;
468
+ if (Math.abs(ax - bx) < 0.5)
469
+ continue; // already symmetric
470
+ const targetX = Math.max(ax, bx); // align to the channel nearer the gate
471
+ const mover = ax === targetX ? bw : aw;
472
+ const p = mover.points, saved = p.slice();
473
+ if (targetX < p[0].x + 0.5)
474
+ continue; // would backtrack
475
+ if (l.junctions.some(j => (Math.abs(j.x - p[1].x) < 0.5 && Math.abs(j.y - p[1].y) < 0.5)
476
+ || (Math.abs(j.x - p[2].x) < 0.5 && Math.abs(j.y - p[2].y) < 0.5)))
477
+ continue; // turn carries a fan-out dot
478
+ const before = crossings();
479
+ mover.points = [p[0], { x: targetX, y: p[0].y }, { x: targetX, y: p[3].y }, p[3]];
480
+ if (crossings() > before || crowds(mover))
481
+ mover.points = saved; // revert unless strictly clean
482
+ }
483
+ }
484
+ // Candidate input ordering that MINIMISES crossings (Sugiyama step 2), built on its OWN local
485
+ // layered graph (real nodes + local dummy chains for long edges, so every edge joins adjacent layers
486
+ // and long-edge crossings are countable). Returns a per-layer rank for the REAL nodes only — the
487
+ // caller's existing dummy pass then reserves lanes as usual. Crossings are counted with PORT
488
+ // AWARENESS: an edge into a FIXED-PORT block (SR/timer/…) carries the block's declared port index, so
489
+ // a reversed source order counts as a crossing (this is what keeps SR S/R uncrossed); AND/OR ports
490
+ // follow source order (no internal cross). This is only ever a CANDIDATE — the caller keeps it only
491
+ // if it renders fewer crossings than the heuristic — so the combinatorial count need not be perfect.
492
+ function crossminOrder(nodes, maxDepth, opts) {
493
+ const isFeedback = (id) => nodes.get(id)?.kind === 'output';
494
+ const fixedPort = (id) => { const n = id ? nodes.get(id) : undefined; return !!n?.gateType && !['AND', 'OR', 'NOT', 'DUMMY', 'INPUT', 'OUTPUT'].includes(n.gateType); };
495
+ const layer = Array.from({ length: maxDepth + 1 }, () => []);
496
+ const depthOf = new Map();
497
+ const real = new Set();
498
+ for (const n of nodes.values()) {
499
+ layer[n.depth].push(n.id);
500
+ depthOf.set(n.id, n.depth);
501
+ real.add(n.id);
502
+ }
503
+ const edges = [];
504
+ let dc = 0;
505
+ for (const v of nodes.values()) {
506
+ v.inputIds.forEach((u, k) => {
507
+ if (isFeedback(u))
508
+ return;
509
+ const du = depthOf.get(u);
510
+ if (du === undefined)
511
+ return;
512
+ if (v.depth - du <= 1) {
513
+ edges.push({ u, v: v.id, k, vReal: v.id });
514
+ return;
515
+ }
516
+ let prev = u; // long edge → local dummy chain
517
+ for (let d = du + 1; d < v.depth; d++) {
518
+ const did = `cmD${dc++}`;
519
+ layer[d].push(did);
520
+ depthOf.set(did, d);
521
+ edges.push({ u: prev, v: did, k: 0, vReal: null });
522
+ prev = did;
523
+ }
524
+ edges.push({ u: prev, v: v.id, k, vReal: v.id });
525
+ });
526
+ }
527
+ const edgesFrom = Array.from({ length: maxDepth + 1 }, () => []);
528
+ const inAdj = new Map(), outAdj = new Map();
529
+ for (const e of edges) {
530
+ edgesFrom[depthOf.get(e.u)].push(e);
531
+ (inAdj.get(e.v) ?? inAdj.set(e.v, []).get(e.v)).push(e.u);
532
+ (outAdj.get(e.u) ?? outAdj.set(e.u, []).get(e.u)).push(e.v);
533
+ }
534
+ const pos = new Map();
535
+ const reindex = () => { for (const l of layer)
536
+ l.forEach((id, i) => pos.set(id, i)); };
537
+ const portOff = (e) => {
538
+ if (!e.vReal || !fixedPort(e.vReal))
539
+ return 0;
540
+ const c = nodes.get(e.vReal);
541
+ if (c.inputIds.length < 2)
542
+ return 0;
543
+ return (e.k - (c.inputIds.length - 1) / 2) / c.inputIds.length;
544
+ };
545
+ const countLayer = (d) => {
546
+ const es = edgesFrom[d];
547
+ let c = 0;
548
+ for (let i = 0; i < es.length; i++)
549
+ for (let j = i + 1; j < es.length; j++) {
550
+ const du = pos.get(es[i].u) - pos.get(es[j].u);
551
+ const dv = (pos.get(es[i].v) + portOff(es[i])) - (pos.get(es[j].v) + portOff(es[j]));
552
+ if (du * dv < -1e-9)
553
+ c++;
554
+ }
555
+ return c;
556
+ };
557
+ const countAll = () => { let c = 0; for (let d = 0; d < maxDepth; d++)
558
+ c += countLayer(d); return c; };
559
+ const median = (xs) => { xs.sort((a, b) => a - b); const m = xs.length >> 1; return xs.length % 2 ? xs[m] : (xs[m - 1] + xs[m]) / 2; };
560
+ const mean = (xs) => xs.reduce((s, x) => s + x, 0) / xs.length;
561
+ const sweep = (d, adj, useMean) => {
562
+ const key = new Map();
563
+ for (const id of layer[d]) {
564
+ const ps = (adj.get(id) ?? []).map(x => pos.get(x));
565
+ key.set(id, ps.length ? (useMean ? mean(ps) : median(ps)) : pos.get(id));
566
+ }
567
+ layer[d] = layer[d].map((id, i) => ({ id, i })).sort((a, b) => (key.get(a.id) - key.get(b.id)) || (a.i - b.i)).map(x => x.id);
568
+ reindex();
569
+ };
570
+ const transpose = () => {
571
+ for (let g = 0; g < 6; g++) {
572
+ let improved = false;
573
+ for (let d = 0; d <= maxDepth; d++)
574
+ for (let i = 0; i + 1 < layer[d].length; i++) {
575
+ const local = () => (d > 0 ? countLayer(d - 1) : 0) + (d < maxDepth ? countLayer(d) : 0);
576
+ const before = local();
577
+ [layer[d][i], layer[d][i + 1]] = [layer[d][i + 1], layer[d][i]];
578
+ reindex();
579
+ if (local() < before)
580
+ improved = true;
581
+ else {
582
+ [layer[d][i], layer[d][i + 1]] = [layer[d][i + 1], layer[d][i]];
583
+ reindex();
584
+ }
585
+ }
586
+ if (!improved)
587
+ break;
588
+ }
589
+ };
590
+ layer[0].sort((a, b) => naturalCompare(nodes.get(a)?.label ?? a, nodes.get(b)?.label ?? b));
591
+ reindex();
592
+ for (let d = 1; d <= maxDepth; d++)
593
+ sweep(d, inAdj, false);
594
+ let best = layer.map(l => l.slice()), bestCr = countAll();
595
+ const record = () => { const cr = countAll(); if (cr < bestCr) {
596
+ bestCr = cr;
597
+ best = layer.map(l => l.slice());
598
+ } };
599
+ const rounds = opts.inputOrder === 'DECLARATION' ? 0 : 24;
600
+ for (let r = 0; r < rounds && bestCr > 0; r++) {
601
+ const useMean = r % 2 === 1;
602
+ for (let d = 1; d <= maxDepth; d++)
603
+ sweep(d, inAdj, useMean);
604
+ transpose();
605
+ record();
606
+ for (let d = maxDepth - 1; d >= 0; d--)
607
+ sweep(d, outAdj, useMean);
608
+ transpose();
609
+ record();
610
+ }
611
+ // Fan-in contiguity: keep each gate's single-consumer inputs together (the combinatorial optimum
612
+ // can interleave independent chains — cheap combinatorially but crossing once drawn).
613
+ const soleC = (id) => { const cs = outAdj.get(id) ?? []; return cs.length === 1 ? cs[0] : id; };
614
+ const bpos = new Map();
615
+ best[0].forEach((id, i) => bpos.set(id, i));
616
+ const grp = new Map();
617
+ for (const id of best[0]) {
618
+ const k = soleC(id);
619
+ (grp.get(k) ?? grp.set(k, []).get(k)).push(id);
620
+ }
621
+ for (let d = 0; d <= maxDepth; d++)
622
+ layer[d] = best[d];
623
+ layer[0] = [...grp.values()].sort((a, b) => Math.min(...a.map(x => bpos.get(x))) - Math.min(...b.map(x => bpos.get(x))))
624
+ .map(g => g.sort((a, b) => bpos.get(a) - bpos.get(b))).flat();
625
+ reindex();
626
+ for (let d = 1; d <= maxDepth; d++)
627
+ sweep(d, inAdj, false);
628
+ transpose();
629
+ const rowMap = new Map();
630
+ for (let d = 0; d <= maxDepth; d++) {
631
+ let r = 0;
632
+ for (const id of layer[d])
633
+ if (real.has(id))
634
+ rowMap.set(id, r++);
635
+ }
636
+ return rowMap;
637
+ }
638
+ function layoutOnce(diagram, portMeta = [], options, strategy, laneTight = false) {
639
+ _id = 0;
640
+ const opts = options ?? DEFAULT_OPTIONS;
641
+ // Phase 1 — build the flattened logic graph (semantic model) from the parsed AST.
642
+ const { nodes, intermediateLabels } = buildGraph(diagram, portMeta, opts, uid);
643
+ // Per-gate first-class port spacing. A multi-input AND/OR fed by a labelled INPUT spaces its
644
+ // ports at a label-safe gap so those inputs can be placed directly on its ports (in
645
+ // assignCoordinates) without their labels colliding — the gate is then sized by port count, not
646
+ // grown to span far-apart sources. Gates fed only by other gates keep the tight PORT_SPACING.
647
+ const INPUT_PORT_GAP = 30;
648
+ for (const n of nodes.values()) {
649
+ if (n.kind !== 'gate' || !n.gateType || n.gateType === 'NOT' || n.inputIds.length < 2)
650
+ continue;
651
+ if (opts.gateInputStyle === 'BARS' && n.inputIds.length > 2)
652
+ continue; // BARS gates own their port layout
653
+ // Only widen ports for a labelled input that is ADJACENT (one column left), because only then
654
+ // is the input actually placed ON the port (a straight fan-in whose label needs the room). A
655
+ // labelled input feeding a deeper gate is columns away and doglegs in regardless, so widening
656
+ // there just bloats the gate — keep it at the tight PORT_SPACING.
657
+ const hasAdjacentInputSource = n.inputIds.some(id => {
658
+ const s = nodes.get(id);
659
+ return s?.kind === 'input' && s.depth === n.depth - 1;
660
+ });
661
+ if (hasAdjacentInputSource)
662
+ n.portGap = INPUT_PORT_GAP;
663
+ }
664
+ const rowMap = new Map();
665
+ const depthGroups = new Map();
666
+ for (const n of nodes.values()) {
667
+ if (!depthGroups.has(n.depth))
668
+ depthGroups.set(n.depth, []);
669
+ depthGroups.get(n.depth).push(n);
670
+ }
671
+ const inputGroup = depthGroups.get(0) ?? [];
672
+ inputGroup.sort((a, b) => naturalCompare(a.label ?? a.id, b.label ?? b.id));
673
+ for (let i = 0; i < inputGroup.length; i++) {
674
+ rowMap.set(inputGroup[i].id, i);
675
+ }
676
+ const maxDepth = Math.max(...Array.from(nodes.values()).map(n => n.depth), 0);
677
+ for (let depth = 1; depth <= maxDepth; depth++) {
678
+ const group = depthGroups.get(depth) ?? [];
679
+ for (const node of group) {
680
+ if (node.inputIds.length === 0) {
681
+ rowMap.set(node.id, 0);
682
+ continue;
683
+ }
684
+ const inputRows = node.inputIds
685
+ .map(id => rowMap.get(id))
686
+ .filter((r) => r !== undefined);
687
+ if (inputRows.length === 0) {
688
+ rowMap.set(node.id, 0);
689
+ continue;
690
+ }
691
+ const minR = Math.min(...inputRows);
692
+ const maxR = Math.max(...inputRows);
693
+ rowMap.set(node.id, (minR + maxR) / 2);
694
+ }
695
+ }
696
+ // Fixed-port blocks (SR, timers, comparators, edge-triggers, FB) bind their arguments to ports
697
+ // in a FIXED order — unlike AND/OR, which assign ports by ascending source Y and so never cross.
698
+ // Bias a source's barycentre row by the port index it feeds (top port → lower row) so the two
699
+ // sources land in port order and their wires don't cross entering the block. The bias (±<1 row)
700
+ // only breaks ties / near-ties between siblings; it never reorders across distinct gate rows.
701
+ const FIXED_PORT = (gt) => !!gt && !['AND', 'OR', 'NOT', 'DUMMY', 'INPUT', 'OUTPUT'].includes(gt);
702
+ const portBias = (consumer, inputId) => {
703
+ if (!FIXED_PORT(consumer.gateType) || consumer.inputIds.length < 2)
704
+ return 0;
705
+ const idx = consumer.inputIds.indexOf(inputId);
706
+ return idx < 0 ? 0 : (idx - (consumer.inputIds.length - 1) / 2) * 0.5;
707
+ };
708
+ // INPUT_ORDER = AUTO (default): reorder input rows by the Sugiyama barycentre method to
709
+ // minimise wire crossings. INPUT_ORDER = DECLARATION: keep inputs in their declared
710
+ // (natural-sorted) order and only propagate gate rows from that fixed input order.
711
+ const barycentreIterations = opts.inputOrder === 'AUTO' ? 3 : 0;
712
+ for (let iteration = 0; iteration < barycentreIterations; iteration++) {
713
+ const sortedInputGroup = [...inputGroup];
714
+ for (const node of sortedInputGroup) {
715
+ const downNodes = Array.from(nodes.values()).filter(n => n.inputIds.includes(node.id));
716
+ if (downNodes.length > 0) {
717
+ const bary = downNodes.reduce((s, n) => s + (rowMap.get(n.id) ?? 0) + portBias(n, node.id), 0) / downNodes.length;
718
+ rowMap.set(node.id, bary);
719
+ }
720
+ }
721
+ sortedInputGroup.sort((a, b) => (rowMap.get(a.id) ?? 0) - (rowMap.get(b.id) ?? 0));
722
+ for (let i = 0; i < sortedInputGroup.length; i++) {
723
+ rowMap.set(sortedInputGroup[i].id, i);
724
+ }
725
+ for (let depth = 1; depth <= maxDepth; depth++) {
726
+ const group = depthGroups.get(depth) ?? [];
727
+ for (const node of group) {
728
+ if (node.inputIds.length === 0) {
729
+ rowMap.set(node.id, 0);
730
+ continue;
731
+ }
732
+ const inputRows = node.inputIds
733
+ .map(id => rowMap.get(id))
734
+ .filter((r) => r !== undefined);
735
+ if (inputRows.length === 0) {
736
+ rowMap.set(node.id, 0);
737
+ continue;
738
+ }
739
+ const minR = Math.min(...inputRows);
740
+ const maxR = Math.max(...inputRows);
741
+ rowMap.set(node.id, (minR + maxR) / 2);
742
+ }
743
+ }
744
+ }
745
+ // 2-hop downstream median re-sort (INPUT_ORDER = AUTO only). After the barycentre pass each
746
+ // input's row is approximately at its IMMEDIATE consumer's row (1-hop), but a path through an
747
+ // intermediate gate like a NOT still crosses other horizontal corridors (e.g. HBLK -> not_7
748
+ // -> and_8 puts HBLK at not_7's row; if and_8 sits elsewhere the not_7->and_8 vertical
749
+ // crosses unrelated wires). Re-sorting the input column by the 2-hop median of consumer ranks
750
+ // (consumer's consumer) instead places HBLK at and_8's row, straightening the full path.
751
+ //
752
+ // IMPORTANT: this re-sorts the input column and re-assigns INTEGER ranks 0..n in the new
753
+ // order — `assignCoordinates` then space-packs them at uniform `sep()` spacing. So the input
754
+ // column's Y RANGE stays bounded to the original (height never balloons), unlike a direct
755
+ // placement at the raw 2-hop Y, which put START/EXT_ALARM at extreme Ys (4400+) because their
756
+ // 2-hop consumers were bottom outputs (the AUTO reordering placed them at the bottom). Only
757
+ // the SORT ORDER changes; uniform spacing holds; invariants stay satisfied.
758
+ if (opts.inputOrder === 'AUTO' && inputGroup.length > 4) {
759
+ // Successor map (node -> nodes it feeds into), excluding feedback edges (output sinks).
760
+ const isFeedback = (id) => nodes.get(id)?.kind === 'output';
761
+ const succ = new Map();
762
+ for (const n of nodes.values()) {
763
+ for (const id of n.inputIds) {
764
+ if (isFeedback(id))
765
+ continue;
766
+ const a = succ.get(id) ?? [];
767
+ a.push(n.id);
768
+ succ.set(id, a);
769
+ }
770
+ }
771
+ const med = (vals) => { const s = vals.slice().sort((a, b) => a - b); const m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
772
+ // Two-hop downstream median: use the rank of the consumer's consumer (NOT the consumer
773
+ // itself) as the sort key. For an input feeding through an intermediate single-input
774
+ // gate like a NOT, the 1-hop barycentre places the input at the NOT's row, but the
775
+ // NOT then feeds a gate elsewhere — the input's straightest target is the consumer's
776
+ // CONSUMER's row (e.g. HBLK -> not_7 -> and_8 places HBLK at and_8's row). For inputs
777
+ // feeding multi-input gates directly, 1-hop and 2-hop converge.
778
+ //
779
+ // Bounded: clamp each input's movement to within ±ceil(n/3) ranks of its barycentre
780
+ // position. Unbounded 2-hop occasionally throws an input clean across the column when
781
+ // its 2-hop consumer (an output or a far-flung gate) sits at an extreme — the new row
782
+ // then crosses un-related wires (RESET jumping from bottom to top in Shared Intermediates
783
+ // overlapped a COMPARE fan-out trunk). Clamping preserves the barycentre's overall
784
+ // structure while letting 2-hop nudge inputs toward straighter positions locally.
785
+ const twoHop = (id) => {
786
+ const cons = succ.get(id) ?? [];
787
+ if (cons.length === 0)
788
+ return rowMap.get(id) ?? 0;
789
+ const ranks = [];
790
+ for (const c of cons) {
791
+ const cc = succ.get(c);
792
+ if (cc && cc.length > 0)
793
+ ranks.push(...cc.map(x => rowMap.get(x) ?? 0));
794
+ else
795
+ ranks.push(rowMap.get(c) ?? 0);
796
+ }
797
+ return med(ranks);
798
+ };
799
+ const bary = new Map();
800
+ for (const n of inputGroup)
801
+ bary.set(n.id, rowMap.get(n.id) ?? 0);
802
+ const n = inputGroup.length;
803
+ const maxMove = Math.max(1, Math.ceil(n / 3));
804
+ const clampedTwoHop = (id) => {
805
+ const b = bary.get(id) ?? 0;
806
+ const t = twoHop(id);
807
+ return Math.max(b - maxMove, Math.min(b + maxMove, t));
808
+ };
809
+ // Stable tie-broken sort by the clamped 2-hop median; preserve the existing order on ties.
810
+ inputGroup.sort((a, b) => (clampedTwoHop(a.id) - clampedTwoHop(b.id)) || ((bary.get(a.id) ?? 0) - (bary.get(b.id) ?? 0)));
811
+ for (let i = 0; i < inputGroup.length; i++)
812
+ rowMap.set(inputGroup[i].id, i);
813
+ // Fan-in contiguity: keep each gate's single-consumer inputs contiguous, so a large fan-in
814
+ // aligns straight to its ports instead of being split — and doglegged around — by an input
815
+ // that feeds a DIFFERENT gate (the split is what tangles a big OR/AND and crowds its wires).
816
+ // Group single-consumer inputs by consumer; order the blocks by their members' average rank
817
+ // (keeps each block roughly where it was); within a block keep rank/Y order so it still matches
818
+ // the gate's ascending-Y port assignment. Multi-consumer inputs stay singletons (they serve
819
+ // several gates legitimately). Then reassign contiguous ranks.
820
+ const soleConsumer = new Map();
821
+ for (const inp of inputGroup) {
822
+ const cs = [...nodes.values()].filter(nd => nd.inputIds.includes(inp.id));
823
+ if (cs.length === 1)
824
+ soleConsumer.set(inp.id, cs[0].id);
825
+ }
826
+ const rankOf = (inp) => rowMap.get(inp.id) ?? 0;
827
+ const blocks = new Map();
828
+ for (const inp of inputGroup) {
829
+ const key = soleConsumer.get(inp.id) ?? inp.id;
830
+ (blocks.get(key) ?? blocks.set(key, []).get(key)).push(inp);
831
+ }
832
+ const ordered = [...blocks.values()].sort((a, b) => a.reduce((s, x) => s + rankOf(x), 0) / a.length - b.reduce((s, x) => s + rankOf(x), 0) / b.length);
833
+ for (const blk of ordered)
834
+ blk.sort((a, b) => rankOf(a) - rankOf(b));
835
+ ordered.flat().forEach((inp, i) => rowMap.set(inp.id, i));
836
+ // Multi-layer crossing minimisation: order the DERIVED layers (gates + outputs) by BOTH sides,
837
+ // not just their inputs. Alternate a down-sweep (rank = mean of input ranks) with an up-sweep
838
+ // (rank = mean of consumer ranks) over layers 1..maxDepth, iterating; the input layer stays
839
+ // fixed. The up-sweep is the half that was missing: a gate driving outputs far below it (a
840
+ // cascade OR feeding bottom outputs) is pulled toward its consumers, so its output wires don't
841
+ // dogleg back across another gate's fan-out. Continuous ranks are fine — only the order is used.
842
+ const meanRank = (ids) => {
843
+ const rs = ids.map(id => rowMap.get(id)).filter((r) => r !== undefined);
844
+ return rs.length ? rs.reduce((s, r) => s + r, 0) / rs.length : null;
845
+ };
846
+ const succOf = new Map();
847
+ for (const nd of nodes.values())
848
+ for (const id of nd.inputIds) {
849
+ if (nodes.get(id)?.kind === 'output')
850
+ continue; // feedback edge, not a forward consumer
851
+ (succOf.get(id) ?? succOf.set(id, []).get(id)).push(nd.id);
852
+ }
853
+ for (let it = 0; it < 6; it++) {
854
+ for (let depth = 1; depth <= maxDepth; depth++)
855
+ for (const node of depthGroups.get(depth) ?? []) {
856
+ // Side-balanced barycentre: the midpoint between the input barycentre and the consumer
857
+ // barycentre (each SIDE weighted equally, regardless of how many edges it has). This pulls
858
+ // a reconvergent gate — many inputs high, few outputs low — to the true middle so its
859
+ // output wires don't dogleg back across another gate's fan-out, while a gate whose inputs
860
+ // and consumers already align barely moves (so it doesn't dogleg its own fan-in). Outputs
861
+ // have no consumers and keep their driver order.
862
+ const inM = meanRank(node.inputIds), outM = meanRank(succOf.get(node.id) ?? []);
863
+ const t = inM !== null && outM !== null ? (inM + outM) / 2 : inM ?? outM;
864
+ if (t !== null)
865
+ rowMap.set(node.id, t);
866
+ }
867
+ }
868
+ }
869
+ // crossmin candidate: replace the heuristic ranks with the crossing-minimised ordering (real
870
+ // nodes only; the dummy pass below reserves lanes exactly as for the heuristic path).
871
+ if (strategy === 'crossmin') {
872
+ const cm = crossminOrder(nodes, maxDepth, opts);
873
+ for (const [id, r] of cm)
874
+ rowMap.set(id, r);
875
+ }
876
+ const layoutNodes = [];
877
+ const nodeMap = new Map();
878
+ // OPTION COMPACTNESS scales spacing per axis. COMPACT_V / COMPACT tighten vertical (row)
879
+ // spacing; COMPACT_H / COMPACT tighten horizontal (column) spacing; SPACIOUS loosens
880
+ // vertical. Tighter spacing still respects the minimum gaps enforced by the collision and
881
+ // protected-zone passes, so it never causes overlaps.
882
+ const cmp = opts.compactness;
883
+ const vScale = opts.compactnessFactors ? opts.compactnessFactors[0]
884
+ : cmp === 'COMPACT' || cmp === 'COMPACT_V' ? 0.7 : cmp === 'SPACIOUS' ? 1.35 : 1;
885
+ const hScale = opts.compactnessFactors ? opts.compactnessFactors[1]
886
+ : cmp === 'COMPACT' || cmp === 'COMPACT_H' ? 0.72 : 1;
887
+ const rowSpacing = Math.round(ROW_SPACING * vScale / GRID) * GRID;
888
+ const colSpacing = Math.round(COL_SPACING * hScale / GRID) * GRID;
889
+ // Obstacle-aware placement: decompose every edge spanning more than one depth column into a
890
+ // chain of thin DUMMY nodes (one per intermediate column), so the coordinate assignment reserves
891
+ // a vertical lane for that wire and never drops a real gate into its straight path. The
892
+ // ordering (rowMap) above is computed on REAL nodes only — dummies don't reorder gates; each
893
+ // dummy is slotted at the row interpolated between the edge's endpoints (i.e. on the wire's
894
+ // line). Dummies reserve space only: they are removed after placement and routing uses the
895
+ // original edges through the now-clear lane.
896
+ const dummyIds = new Set();
897
+ const restoreEdges = [];
898
+ for (const c of [...nodes.values()]) {
899
+ if (c.inputIds.length === 0)
900
+ continue;
901
+ let changed = false;
902
+ const rc = rowMap.get(c.id) ?? 0;
903
+ const newInputs = c.inputIds.map(sid => {
904
+ const s = nodes.get(sid);
905
+ if (!s || s.kind === 'output' || c.depth - s.depth <= 1)
906
+ return sid; // short edge / feedback
907
+ changed = true;
908
+ const rs = rowMap.get(sid) ?? 0;
909
+ let prev = sid;
910
+ for (let d = s.depth + 1; d < c.depth; d++) {
911
+ const did = uid('dummy');
912
+ const node = { id: did, kind: 'gate', gateType: 'DUMMY', depth: d, inputIds: [prev] };
913
+ nodes.set(did, node);
914
+ rowMap.set(did, rs + ((rc - rs) * (d - s.depth)) / (c.depth - s.depth)); // on the edge line
915
+ (depthGroups.get(d) ?? (depthGroups.set(d, []).get(d))).push(node);
916
+ dummyIds.add(did);
917
+ prev = did;
918
+ }
919
+ return prev;
920
+ });
921
+ if (changed) {
922
+ restoreEdges.push({ node: c, inputIds: c.inputIds, inputPorts: c.inputPorts });
923
+ c.inputIds = newInputs;
924
+ }
925
+ }
926
+ // ---- Priority-method coordinate assignment (spec: Coordinate Assignment) ----
927
+ // Each node's vertical centre is aligned to the median of its neighbours on BOTH sides
928
+ // (sources and consumers), keeping the per-column barycentre order. Replaces the old global
929
+ // row-rank mapping, which spread nodes apart and ignored the consumer side.
930
+ const assignedY = assignCoordinates(nodes, depthGroups, rowMap, maxDepth, rowSpacing, laneTight);
931
+ // Dummies have done their job (reserving lanes) — restore the original edges and drop them so
932
+ // geometry and routing see only real nodes, now placed clear of the long-edge lanes.
933
+ for (const { node, inputIds, inputPorts } of restoreEdges) {
934
+ node.inputIds = inputIds;
935
+ node.inputPorts = inputPorts;
936
+ }
937
+ for (const id of dummyIds)
938
+ nodes.delete(id);
939
+ for (const node of nodes.values()) {
940
+ const absY = assignedY.get(node.id) ?? PAD_Y;
941
+ const absX = PAD_X + node.depth * colSpacing;
942
+ let w, h;
943
+ if (node.kind === 'input') {
944
+ w = INPUT_LABEL_W;
945
+ h = node.description ? 30 : 20;
946
+ if (node.name && hasMathContent(node.name))
947
+ h = Math.max(h, 30);
948
+ if (node.description && hasMathContent(node.description))
949
+ h = Math.max(h, 30);
950
+ h = Math.ceil(h / 10) * 10;
951
+ const outX = absX + w + INPUT_STUB;
952
+ const outY = absY + h / 2;
953
+ const ln = {
954
+ id: node.id, gateType: 'INPUT', label: node.label,
955
+ name: node.name, description: node.description,
956
+ absX, absY, width: w + INPUT_STUB, height: h,
957
+ inputs: [], outputs: [{ name: 'out', absX: outX, absY: outY }],
958
+ depth: node.depth,
959
+ };
960
+ layoutNodes.push(ln);
961
+ nodeMap.set(node.id, ln);
962
+ }
963
+ else if (node.kind === 'output') {
964
+ w = OUTPUT_LABEL_W;
965
+ h = node.description ? 30 : 20;
966
+ if (node.name && hasMathContent(node.name))
967
+ h = Math.max(h, 30);
968
+ if (node.description && hasMathContent(node.description))
969
+ h = Math.max(h, 30);
970
+ h = Math.ceil(h / 10) * 10;
971
+ let inX = absX;
972
+ const inY = absY + h / 2;
973
+ let bubbledInput = false;
974
+ // Mark bubbled input (BUBBLES mode: NOT feeding into output)
975
+ if (node.invertedInputs && node.invertedInputs.has(0)) {
976
+ bubbledInput = true;
977
+ inX -= BUBBLE_R * 2;
978
+ }
979
+ const ln = {
980
+ id: node.id, gateType: 'OUTPUT', label: node.label,
981
+ name: node.name, description: node.description,
982
+ absX, absY, width: w + OUTPUT_STUB, height: h,
983
+ inputs: [{ name: 'in', absX: inX, absY: inY, bubbled: bubbledInput || undefined }], outputs: [],
984
+ depth: node.depth,
985
+ };
986
+ layoutNodes.push(ln);
987
+ nodeMap.set(node.id, ln);
988
+ }
989
+ else if (node.gateType === 'NOT') {
990
+ w = NOT_GATE_TOTAL_W;
991
+ h = NOT_GATE_H;
992
+ const ln = {
993
+ id: node.id, gateType: 'NOT', label: node.label,
994
+ absX, absY, width: w, height: h,
995
+ inputs: [{ name: 'in_0', absX: absX, absY: absY + h / 2 }],
996
+ outputs: [{ name: 'out', absX: absX + w, absY: absY + h / 2 }],
997
+ depth: node.depth,
998
+ };
999
+ layoutNodes.push(ln);
1000
+ nodeMap.set(node.id, ln);
1001
+ }
1002
+ else if (node.blockType) {
1003
+ const bt = node.blockType;
1004
+ ({ w, h } = bt === 'FB' ? fbDims(node) : blockSize(bt));
1005
+ const right = absX + w;
1006
+ let inputs;
1007
+ let outputs;
1008
+ if (bt === 'FB') {
1009
+ // Generic block: evenly-spaced input ports (labelled) on the left, one output port per
1010
+ // referenced .name on the right (default OUT). Ports are re-aligned to sources later.
1011
+ const place = (count, x) => Array.from({ length: count }, (_, i) => ({
1012
+ name: '', absX: x, absY: Math.round((absY + (h * (i + 1)) / (count + 1)) / GRID) * GRID,
1013
+ }));
1014
+ const used = [...(node.usedPorts ?? new Set())];
1015
+ if (used.length === 0)
1016
+ used.push('OUT');
1017
+ inputs = place(node.inputIds.length, absX);
1018
+ inputs.forEach((p, i) => { p.name = `in_${i}`; p.label = node.inputLabels?.[i]; });
1019
+ outputs = place(used.length, right);
1020
+ outputs.forEach((p, i) => { p.name = used[i]; p.label = used[i] === 'OUT' ? undefined : used[i]; });
1021
+ }
1022
+ else if (bt === 'SR') {
1023
+ inputs = [
1024
+ { name: 'S', absX, absY: absY + 15 },
1025
+ { name: 'R', absX, absY: absY + 40 },
1026
+ ];
1027
+ const used = node.usedPorts ?? new Set(['Q']);
1028
+ outputs = [];
1029
+ if (used.has('Q'))
1030
+ outputs.push({ name: 'Q', absX: right, absY: absY + 15 });
1031
+ if (used.has('NQ'))
1032
+ outputs.push({ name: 'NQ', absX: right, absY: absY + 40 });
1033
+ if (outputs.length === 0)
1034
+ outputs.push({ name: 'Q', absX: right, absY: absY + h / 2 });
1035
+ }
1036
+ else if (bt === 'COMPARE') {
1037
+ inputs = [
1038
+ { name: '+', absX, absY: absY + 15 },
1039
+ { name: '-', absX, absY: absY + 35 },
1040
+ ];
1041
+ outputs = [{ name: 'OUT', absX: right, absY: absY + h / 2 }];
1042
+ }
1043
+ else {
1044
+ // TIMER, RISING, FALLING — single input/output, centred.
1045
+ inputs = [{ name: 'in', absX, absY: absY + h / 2 }];
1046
+ outputs = [{ name: 'OUT', absX: right, absY: absY + h / 2 }];
1047
+ }
1048
+ const ln = {
1049
+ id: node.id, gateType: bt, label: node.label,
1050
+ name: node.name, description: node.description,
1051
+ absX, absY, width: w, height: h,
1052
+ inputs, outputs, depth: node.depth,
1053
+ blockType: bt, params: node.params,
1054
+ };
1055
+ layoutNodes.push(ln);
1056
+ nodeMap.set(node.id, ln);
1057
+ }
1058
+ else {
1059
+ const numInputs = node.inputIds.length || 2;
1060
+ const isMultiInput = numInputs > 2;
1061
+ const useBars = opts.gateInputStyle === 'BARS' && numInputs > 2;
1062
+ const gGap = gateGap(node);
1063
+ if (useBars) {
1064
+ h = AND_GATE_H_BASE;
1065
+ w = isMultiInput ? GATE_W_MULTI : GATE_W;
1066
+ }
1067
+ else {
1068
+ h = gateBodyHeight(numInputs, gGap);
1069
+ w = isMultiInput ? GATE_W_MULTI : GATE_W;
1070
+ }
1071
+ const inputs = [];
1072
+ if (useBars) {
1073
+ // First two inputs: normal ports on the gate body at 1/3 and 2/3 height.
1074
+ const portSpacing = Math.round(h / 3 / GRID) * GRID;
1075
+ for (let i = 0; i < Math.min(2, numInputs); i++) {
1076
+ const portY = absY + (i + 1) * portSpacing;
1077
+ inputs.push({ name: `in_${i}`, absX: absX, absY: portY });
1078
+ }
1079
+ // Bar-tapped inputs (3rd+): evenly distribute across the full gate body height.
1080
+ // Each tap's port sits at the bar X (absX - 12 per spec), so wires connect there
1081
+ // and the stub from bar to gate body is rendered as part of the gate symbol.
1082
+ const barX = absX - INPUT_BAR_OFFSET;
1083
+ const barCount = numInputs - 2;
1084
+ const barSpan = h - GRID * 2; // 1 grid inset top and bottom
1085
+ for (let i = 0; i < barCount; i++) {
1086
+ const portY = Math.round((absY + GRID + (barSpan * (i + 0.5)) / barCount) / GRID) * GRID;
1087
+ inputs.push({ name: `in_${i + 2}`, absX: barX, absY: portY });
1088
+ }
1089
+ }
1090
+ else {
1091
+ for (let i = 0; i < numInputs; i++) {
1092
+ const portY = gateInputPortY(absY, i, gGap);
1093
+ inputs.push({ name: `in_${i}`, absX: absX, absY: portY });
1094
+ }
1095
+ }
1096
+ // Inversion bubbles for multi-input gates are assigned in a later pass (see "Inversion bubble
1097
+ // port assignment"), once source-Y ordering — which decides source→port mapping — is final.
1098
+ // Apply per-port style overrides
1099
+ const styleMap = new Map();
1100
+ for (const m of portMeta) {
1101
+ if (m.property === 'Style')
1102
+ styleMap.set(m.identifier, m.value.toUpperCase());
1103
+ }
1104
+ const gateCenterY = Math.round((absY + h / 2) / GRID) * GRID;
1105
+ const outputs = [{ name: 'out', absX: absX + w, absY: gateCenterY }];
1106
+ // Mark bubbled output (BUBBLES mode) and shift output port right for bubble
1107
+ if (node.bubbledOutput) {
1108
+ outputs[0].bubbledOutput = true;
1109
+ outputs[0].absX += BUBBLE_R * 2;
1110
+ }
1111
+ const ln = {
1112
+ id: node.id, gateType: node.gateType ?? 'AND', label: node.label,
1113
+ name: node.name, description: node.description,
1114
+ absX, absY, width: w, height: h,
1115
+ inputs, outputs, depth: node.depth,
1116
+ barsMode: useBars ? true : undefined,
1117
+ };
1118
+ layoutNodes.push(ln);
1119
+ nodeMap.set(node.id, ln);
1120
+ }
1121
+ }
1122
+ // OPTION COLUMN_SPACING = ADAPTIVE: replace the fixed COL_SPACING pitch with a per-gap width sized
1123
+ // to each column's content. The gap between column d-1 and d must hold column d's fan-in dogleg
1124
+ // channels (nested at FANIN spacing), a gate-clearance turn at the gate, and a MIN_DOGLEG on the
1125
+ // source side — so gap = GATE_CLEARANCE + MIN_DOGLEG + (maxInDegree-1)*FANIN + slack. In-degree is
1126
+ // an upper bound on dogleg channels (straight inputs need none), so the estimate is conservative:
1127
+ // it only ever narrows relative to the uniform pitch and never cramps a fan-in. Shift is uniform
1128
+ // per column, so ports move with their node; runs before routing and label placement.
1129
+ if (opts.columnSpacing === 'ADAPTIVE') {
1130
+ const CLEAR = 20, FANIN = 15, SLACK = 30;
1131
+ const colWidth = [], colX = [PAD_X];
1132
+ for (let d = 0; d <= maxDepth; d++) {
1133
+ let w = 0;
1134
+ for (const n of layoutNodes)
1135
+ if (n.depth === d)
1136
+ w = Math.max(w, n.width);
1137
+ colWidth[d] = w;
1138
+ }
1139
+ for (let d = 1; d <= maxDepth; d++) {
1140
+ let indeg = 0;
1141
+ for (const n of layoutNodes)
1142
+ if (n.depth === d && n.gateType !== 'INPUT' && n.gateType !== 'OUTPUT')
1143
+ indeg = Math.max(indeg, n.inputs.length);
1144
+ const gap = Math.round((CLEAR + MIN_DOGLEG + Math.max(0, indeg - 1) * FANIN + SLACK) / GRID) * GRID;
1145
+ colX[d] = Math.min(colX[d - 1] + colWidth[d - 1] + gap, PAD_X + d * colSpacing); // never wider than uniform
1146
+ }
1147
+ for (const n of layoutNodes) {
1148
+ const dx = colX[n.depth] - n.absX;
1149
+ if (dx === 0)
1150
+ continue;
1151
+ n.absX += dx;
1152
+ for (const p of n.inputs)
1153
+ p.absX += dx;
1154
+ for (const p of n.outputs)
1155
+ p.absX += dx;
1156
+ }
1157
+ }
1158
+ for (const gateNode of layoutNodes) {
1159
+ if (gateNode.gateType === 'INPUT' || gateNode.gateType === 'OUTPUT')
1160
+ continue;
1161
+ const gateTop = gateNode.absY;
1162
+ const gateBottom = gateNode.absY + gateNode.height;
1163
+ const gateRight = gateNode.absX;
1164
+ for (const inputNode of layoutNodes) {
1165
+ if (inputNode.gateType !== 'INPUT')
1166
+ continue;
1167
+ if (inputNode.absX >= gateRight)
1168
+ continue;
1169
+ const inputBottom = inputNode.absY + inputNode.height;
1170
+ if (inputBottom > gateTop && inputNode.absY < gateBottom && inputNode.absY < gateTop + 5) {
1171
+ const shift = Math.round((inputBottom - gateTop + 5) / GRID) * GRID;
1172
+ gateNode.absY += shift;
1173
+ for (const port of gateNode.inputs)
1174
+ port.absY += shift;
1175
+ for (const port of gateNode.outputs)
1176
+ port.absY += shift;
1177
+ }
1178
+ }
1179
+ }
1180
+ // Position a gate's output port(s): a single output at the body centre; a multi-output block
1181
+ // (e.g. a generic FB) spreads its outputs evenly over the body. Used wherever the body is moved
1182
+ // or resized, so outputs stay placed (and downstream consumers, processed in depth order, align).
1183
+ const recenterOutputs = (g) => {
1184
+ const no = g.outputs.length;
1185
+ if (no <= 1) {
1186
+ if (g.outputs[0])
1187
+ g.outputs[0].absY = Math.round((g.absY + g.height / 2) / GRID) * GRID;
1188
+ return;
1189
+ }
1190
+ // FB outputs sit a fixed 40 apart (the output-stack gap) so the output nodes they drive
1191
+ // line up straight; other multi-output blocks just spread evenly.
1192
+ const gap = g.blockType === 'FB' ? 40 : Math.max(MIN_PORT_GAP, (g.height - 20) / no);
1193
+ const start = g.absY + g.height / 2 - ((no - 1) * gap) / 2;
1194
+ g.outputs.forEach((p, i) => { p.absY = Math.round((start + i * gap) / GRID) * GRID; });
1195
+ };
1196
+ // Align each single-input gate (e.g. NOT) so its input sits exactly on its source's output
1197
+ // Y — a straight wire. Processed in depth order so a chain of NOTs aligns left-to-right.
1198
+ // Run again AFTER the multi-input height/position passes below, because those move the
1199
+ // source gates and would otherwise leave a single-input gate stranded at a stale Y (which
1200
+ // makes its output collide with, and detour around, a neighbour in the next column).
1201
+ const singleInputGates = Array.from(nodes.values())
1202
+ .filter(n => n.kind === 'gate' && n.inputIds.length === 1)
1203
+ .sort((a, b) => a.depth - b.depth);
1204
+ const alignSingleInputGates = () => {
1205
+ for (const node of singleInputGates) {
1206
+ const gateNode = nodeMap.get(node.id);
1207
+ if (!gateNode || gateNode.inputs.length !== 1)
1208
+ continue;
1209
+ const sourceNode = nodeMap.get(node.inputIds[0]);
1210
+ if (!sourceNode || sourceNode.outputs.length === 0)
1211
+ continue;
1212
+ const sourceOutputY = Math.round(sourceNode.outputs[0].absY / GRID) * GRID;
1213
+ const offsetY = sourceOutputY - gateNode.inputs[0].absY;
1214
+ gateNode.absY = Math.round((gateNode.absY + offsetY) / GRID) * GRID;
1215
+ gateNode.inputs[0].absY = sourceOutputY;
1216
+ recenterOutputs(gateNode);
1217
+ }
1218
+ };
1219
+ alignSingleInputGates();
1220
+ for (const node of nodes.values()) {
1221
+ if (node.kind !== 'output')
1222
+ continue;
1223
+ const outputNode = nodeMap.get(node.id);
1224
+ if (!outputNode || node.inputIds.length === 0)
1225
+ continue;
1226
+ const sourceId = node.inputIds[0];
1227
+ const sourceNode = nodeMap.get(sourceId);
1228
+ if (!sourceNode || sourceNode.outputs.length === 0)
1229
+ continue;
1230
+ const sourceOutputY = Math.round(sourceNode.outputs[0].absY / GRID) * GRID;
1231
+ outputNode.inputs[0].absY = sourceOutputY;
1232
+ outputNode.absY = Math.round((sourceOutputY - outputNode.height / 2) / GRID) * GRID;
1233
+ }
1234
+ // ── Phase: gate placement. Every multi-input AND/OR gate is sized by PORT COUNT only (label-aware
1235
+ // gap) and slid to the position that minimises sub-MIN_DOGLEG jogs on its input wires — one
1236
+ // principled min-jog fit, whether the gate is fed by inputs or by other gates. Processed in depth
1237
+ // order so each gate sees its drivers already placed. The body is never grown to span far-apart
1238
+ // sources; such wires read as clean Z-routes. (NOT gates and blocks are aligned by their own
1239
+ // passes; outputs/inputs by their placement phases.)
1240
+ {
1241
+ const placeGate = (node) => {
1242
+ const gateNode = nodeMap.get(node.id);
1243
+ if (!gateNode || gateNode.inputs.length < 2)
1244
+ return;
1245
+ const gap = gateGap(node);
1246
+ const h = gateBodyHeight(node.inputIds.length, gap);
1247
+ const srcYs = node.inputIds
1248
+ .map(id => nodeMap.get(id)?.outputs[0]?.absY)
1249
+ .filter((y) => y !== undefined && Number.isFinite(y))
1250
+ .sort((a, b) => a - b);
1251
+ if (srcYs.length < 2)
1252
+ return;
1253
+ const cen = (srcYs[0] + srcYs[srcYs.length - 1]) / 2;
1254
+ let bestTop = gateNode.absY, bestSc = Infinity;
1255
+ for (let top = srcYs[0] - h; top <= srcYs[srcYs.length - 1] + GRID; top += GRID) {
1256
+ let sc = Math.abs(top + h / 2 - cen) * 0.001;
1257
+ for (let k = 0; k < srcYs.length; k++) {
1258
+ const d = Math.abs(srcYs[k] - gateInputPortY(top, k, gap));
1259
+ if (d >= 1 && d < MIN_DOGLEG)
1260
+ sc += 1000;
1261
+ sc += d * 0.1;
1262
+ }
1263
+ if (sc < bestSc) {
1264
+ bestSc = sc;
1265
+ bestTop = Math.round(top / GRID) * GRID;
1266
+ }
1267
+ }
1268
+ gateNode.absY = bestTop;
1269
+ gateNode.height = h;
1270
+ for (let k = 0; k < gateNode.inputs.length; k++) {
1271
+ gateNode.inputs[k].absY = Math.round(gateInputPortY(bestTop, k, gap) / GRID) * GRID;
1272
+ }
1273
+ recenterOutputs(gateNode);
1274
+ };
1275
+ for (const node of [...nodes.values()]
1276
+ .filter(n => n.kind === 'gate' && n.gateType && n.gateType !== 'NOT' && n.inputIds.length >= 2)
1277
+ .sort((a, b) => a.depth - b.depth))
1278
+ placeGate(node);
1279
+ }
1280
+ // (Output nodes are placed in the single "Phase: output placement" pass after all gate moves.)
1281
+ // Resolve gate-gate overlaps at the same depth column by pushing the lower
1282
+ // gate down so their bounding boxes no longer intersect.
1283
+ for (let pass = 0; pass < 5; pass++) {
1284
+ let anyOverlap = false;
1285
+ // Gates vs gates
1286
+ for (let i = 0; i < layoutNodes.length; i++) {
1287
+ const a = layoutNodes[i];
1288
+ if (a.gateType === 'INPUT' || a.gateType === 'OUTPUT')
1289
+ continue;
1290
+ for (let j = i + 1; j < layoutNodes.length; j++) {
1291
+ const b = layoutNodes[j];
1292
+ if (b.gateType === 'INPUT' || b.gateType === 'OUTPUT')
1293
+ continue;
1294
+ if (a.depth !== b.depth)
1295
+ continue;
1296
+ const xOverlap = Math.min(a.absX + a.width, b.absX + b.width) - Math.max(a.absX, b.absX);
1297
+ if (xOverlap <= 0)
1298
+ continue;
1299
+ const yOverlap = Math.min(a.absY + a.height, b.absY + b.height) - Math.max(a.absY, b.absY);
1300
+ if (yOverlap <= 0)
1301
+ continue;
1302
+ const shift = Math.round((yOverlap + MIN_PORT_GAP) / GRID) * GRID;
1303
+ if (a.absY < b.absY) {
1304
+ b.absY += shift;
1305
+ for (const port of b.inputs)
1306
+ port.absY += shift;
1307
+ for (const port of b.outputs)
1308
+ port.absY += shift;
1309
+ }
1310
+ else {
1311
+ a.absY += shift;
1312
+ for (const port of a.inputs)
1313
+ port.absY += shift;
1314
+ for (const port of a.outputs)
1315
+ port.absY += shift;
1316
+ }
1317
+ anyOverlap = true;
1318
+ }
1319
+ }
1320
+ // Gates vs outputs
1321
+ for (const node of layoutNodes) {
1322
+ if (node.gateType === 'INPUT')
1323
+ continue;
1324
+ if (node.gateType !== 'OUTPUT')
1325
+ continue;
1326
+ for (const gate of layoutNodes) {
1327
+ if (gate.gateType === 'INPUT' || gate.gateType === 'OUTPUT')
1328
+ continue;
1329
+ if (node.depth !== gate.depth)
1330
+ continue;
1331
+ const xOverlap = Math.min(node.absX + node.width, gate.absX + gate.width) - Math.max(node.absX, gate.absX);
1332
+ if (xOverlap <= 0)
1333
+ continue;
1334
+ const yOverlap = Math.min(node.absY + node.height, gate.absY + gate.height) - Math.max(node.absY, gate.absY);
1335
+ if (yOverlap <= 0)
1336
+ continue;
1337
+ const shift = Math.round((yOverlap + MIN_PORT_GAP) / GRID) * GRID;
1338
+ if (gate.absY < node.absY) {
1339
+ node.absY += shift;
1340
+ node.inputs[0].absY += shift;
1341
+ }
1342
+ else {
1343
+ gate.absY += shift;
1344
+ for (const port of gate.inputs)
1345
+ port.absY += shift;
1346
+ for (const port of gate.outputs)
1347
+ port.absY += shift;
1348
+ }
1349
+ anyOverlap = true;
1350
+ }
1351
+ }
1352
+ if (!anyOverlap)
1353
+ break;
1354
+ }
1355
+ // The collision pass above is the last thing that moves multi-input gates; re-align every
1356
+ // single-input gate (NOT) to its now-final source so it stays straight-through and out of
1357
+ // the next column's horizontal corridor (e.g. a NOT feeding an output mustn't sit in the
1358
+ // straight path of another gate's output wire).
1359
+ alignSingleInputGates();
1360
+ // (Output placement is done in a single pass after all gate moves — see "Phase: output
1361
+ // placement" below.)
1362
+ // Place feedback input ports. A feedback input has no left-hand source (it loops back from
1363
+ // an output), so the source-alignment passes above leave its port unset (non-finite). The
1364
+ // loop-back enters from whichever side it approaches: if the consumer sits ABOVE its
1365
+ // feedback driver the loop comes over the top (top port → fewer crossings), otherwise it
1366
+ // comes under (bottom port). Place the port on that side, expanding the gate body if needed.
1367
+ const feedbackPorts = new Set();
1368
+ for (const ln of layoutNodes) {
1369
+ const fb = ln.inputs.filter(p => !Number.isFinite(p.absY));
1370
+ for (const p of fb)
1371
+ feedbackPorts.add(p);
1372
+ if (fb.length === 0)
1373
+ continue;
1374
+ const real = ln.inputs.filter(p => Number.isFinite(p.absY));
1375
+ const fbOut = nodes.get(ln.id)?.inputIds.find(id => nodeMap.get(id)?.gateType === 'OUTPUT');
1376
+ const driverId = fbOut ? nodes.get(fbOut)?.inputIds[0] : undefined;
1377
+ const driver = driverId ? nodeMap.get(driverId) : undefined;
1378
+ const overTop = !!driver && (ln.absY + ln.height / 2) < (driver.absY + driver.height / 2);
1379
+ if (overTop) {
1380
+ let y = real.length ? Math.min(...real.map(p => p.absY)) : ln.absY + ln.height - PORT_SPACING / 2;
1381
+ for (const p of fb) {
1382
+ y = Math.round((y - PORT_SPACING) / GRID) * GRID;
1383
+ p.absY = y;
1384
+ p.absX = ln.absX;
1385
+ }
1386
+ const topEdge = y - PORT_SPACING;
1387
+ if (topEdge < ln.absY) {
1388
+ const grow = Math.ceil((ln.absY - topEdge) / GRID) * GRID;
1389
+ ln.absY -= grow;
1390
+ ln.height += grow;
1391
+ }
1392
+ }
1393
+ else {
1394
+ let y = real.length ? Math.max(...real.map(p => p.absY)) : ln.absY + PORT_SPACING / 2;
1395
+ for (const p of fb) {
1396
+ y = Math.round((y + PORT_SPACING) / GRID) * GRID;
1397
+ p.absY = y;
1398
+ p.absX = ln.absX;
1399
+ }
1400
+ const bottom = y + PORT_SPACING;
1401
+ if (bottom > ln.absY + ln.height)
1402
+ ln.height = Math.ceil((bottom - ln.absY) / GRID) * GRID;
1403
+ }
1404
+ if (ln.outputs[0])
1405
+ ln.outputs[0].absY = Math.round((ln.absY + ln.height / 2) / GRID) * GRID;
1406
+ }
1407
+ // Snap all node and port positions to the grid BEFORE routing. This guarantees that
1408
+ // an aligned source/dest pair has exactly equal Y, so the router takes the clean
1409
+ // straight-line fast-path instead of a 1px dogleg (which the router can otherwise
1410
+ // mis-handle). Done before the OR curve-tap pass so curve taps are not re-snapped.
1411
+ for (const n of layoutNodes) {
1412
+ n.absX = Math.round(n.absX / GRID) * GRID;
1413
+ n.absY = Math.round(n.absY / GRID) * GRID;
1414
+ for (const p of [...n.inputs, ...n.outputs]) {
1415
+ p.absX = Math.round(p.absX / GRID) * GRID;
1416
+ p.absY = Math.round(p.absY / GRID) * GRID;
1417
+ }
1418
+ }
1419
+ // ── Phase: dogleg cleanup. Gate placement MINIMISES sub-MIN_DOGLEG jogs, but a few can survive a
1420
+ // placement compromise — a multi-consumer input that cannot sit on every gate's port, or a body
1421
+ // nudged by the protected zone. This phase enforces the clean-wire rule: for any input port within
1422
+ // MIN_DOGLEG of (but not on) its source, shift the WHOLE gate to align one port without creating a
1423
+ // new small jog elsewhere; only if no such shift exists, nudge the single port (keeping its
1424
+ // PORT_SPACING gap to neighbours). Feedback ports have no left-hand source and are skipped.
1425
+ const isSmall = (d) => Math.abs(d) >= 0.5 && Math.abs(d) < MIN_DOGLEG;
1426
+ for (const node of nodes.values()) {
1427
+ if (node.inputIds.length === 0)
1428
+ continue;
1429
+ const ln = nodeMap.get(node.id);
1430
+ if (!ln || ln.inputs.length === 0)
1431
+ continue;
1432
+ const srcYs = node.inputIds
1433
+ .map(id => nodeMap.get(id)?.outputs[0]?.absY)
1434
+ .filter((y) => y !== undefined)
1435
+ .sort((a, b) => a - b);
1436
+ // Exclude feedback ports — they have no left-hand source, so pairing them to a real
1437
+ // source Y by sorted index mis-detects a phantom dogleg and shifts the whole gate.
1438
+ const ports = [...ln.inputs].filter(p => !feedbackPorts.has(p)).sort((a, b) => a.absY - b.absY);
1439
+ const n = Math.min(ports.length, srcYs.length);
1440
+ const diffs = () => ports.map((p, i) => (i < n ? p.absY - srcYs[i] : 0));
1441
+ if (!diffs().some(isSmall))
1442
+ continue;
1443
+ // Candidate whole-gate shifts: the offset that would align each currently-small port.
1444
+ const candidates = diffs().map((d, i) => (isSmall(d) ? -d : null)).filter((x) => x !== null);
1445
+ let applied = false;
1446
+ for (const delta of candidates) {
1447
+ if (!Number.isInteger(delta / GRID))
1448
+ continue;
1449
+ const after = ports.map((p, i) => (i < n ? p.absY + delta - srcYs[i] : 0));
1450
+ if (after.some(isSmall))
1451
+ continue; // would still leave a small jog somewhere
1452
+ ln.absY += delta;
1453
+ for (const p of ln.inputs)
1454
+ p.absY += delta;
1455
+ for (const p of ln.outputs)
1456
+ p.absY += delta;
1457
+ applied = true;
1458
+ break;
1459
+ }
1460
+ if (applied)
1461
+ continue;
1462
+ // Fallback: nudge an individual port onto its source, but never closer than
1463
+ // PORT_SPACING to a neighbour (so we don't trade a dogleg for a too-tight port gap).
1464
+ for (let i = 0; i < n; i++) {
1465
+ const port = ports[i];
1466
+ const want = srcYs[i];
1467
+ if (!isSmall(port.absY - want) || !Number.isInteger(want / GRID))
1468
+ continue;
1469
+ const prevY = i > 0 ? ports[i - 1].absY : -Infinity;
1470
+ const nextY = i < ports.length - 1 ? ports[i + 1].absY : Infinity;
1471
+ const insideBody = ln.gateType === 'OUTPUT' || (want > ln.absY && want < ln.absY + ln.height);
1472
+ if (want - prevY >= PORT_SPACING - 0.5 && nextY - want >= PORT_SPACING - 0.5 && insideBody) {
1473
+ port.absY = want;
1474
+ if (ln.gateType === 'OUTPUT')
1475
+ ln.absY = Math.round((want - ln.height / 2) / GRID) * GRID;
1476
+ }
1477
+ }
1478
+ }
1479
+ // Helper: the output Y of a driver's port `portName` (or its first output) — used by the
1480
+ // input/output placement phases to align a wire's far end to its driver.
1481
+ const blkSrcY = (srcId, portName) => {
1482
+ const s = nodeMap.get(srcId);
1483
+ if (!s)
1484
+ return undefined;
1485
+ return ((portName ? s.outputs.find(o => o.name === portName) : undefined) ?? s.outputs[0])?.absY;
1486
+ };
1487
+ // Protected zone: keep a minimum vertical gap between adjacent gate bodies in a column. The
1488
+ // alignment passes pull gates toward their sources, which can jam two gates that share an
1489
+ // input right up against each other (e.g. an AND and a NOT both reading the same signal).
1490
+ // Push the lower gate down to open the gap. The push is at least MIN_DOGLEG so the gate's
1491
+ // (now slightly offset) wires read as clean Z-routes rather than small jogs — and this runs
1492
+ // after the dogleg-killer so it is not pulled back. Done before routing so wires are fresh.
1493
+ for (const d of new Set(layoutNodes.filter(n => n.gateType !== 'INPUT' && n.gateType !== 'OUTPUT').map(n => n.depth))) {
1494
+ const col = layoutNodes
1495
+ .filter(n => n.depth === d && n.gateType !== 'INPUT' && n.gateType !== 'OUTPUT')
1496
+ .sort((a, b) => a.absY - b.absY);
1497
+ for (let i = 1; i < col.length; i++) {
1498
+ const gap = col[i].absY - (col[i - 1].absY + col[i - 1].height);
1499
+ if (gap < MIN_PORT_GAP - 0.5) {
1500
+ const dy = Math.round(Math.max(MIN_PORT_GAP - gap, MIN_DOGLEG) / GRID) * GRID;
1501
+ col[i].absY += dy;
1502
+ for (const p of col[i].inputs)
1503
+ p.absY += dy;
1504
+ for (const p of col[i].outputs)
1505
+ p.absY += dy;
1506
+ }
1507
+ }
1508
+ }
1509
+ // ── Phase: input placement. Snap each single-consumer INPUT onto the port row of the gate it
1510
+ // feeds, so its wire is straight (the gate is already port-count-sized and positioned). Process
1511
+ // each input column top-to-bottom, cascading to keep label-safe spacing; a multi-consumer input
1512
+ // can't sit on one gate's port so it keeps its swept position.
1513
+ {
1514
+ const consumerCount = new Map();
1515
+ const consumerOf = new Map();
1516
+ for (const nd of nodes.values())
1517
+ for (const id of nd.inputIds) {
1518
+ consumerCount.set(id, (consumerCount.get(id) ?? 0) + 1);
1519
+ if (!consumerOf.has(id))
1520
+ consumerOf.set(id, nd.id);
1521
+ }
1522
+ // The port Y that input `id` maps to on its consumer gate: gates connect sources to ports in
1523
+ // ascending source-Y order, so input rank r (by current source Y) -> the r-th port by Y.
1524
+ const targetPortY = (id) => {
1525
+ const consumerId = consumerOf.get(id);
1526
+ const cFlat = consumerId ? nodes.get(consumerId) : undefined;
1527
+ const cNode = consumerId ? nodeMap.get(consumerId) : undefined;
1528
+ // Only align to AND/OR gate ports (uniform gap). Blocks (COMPARE/TIMER/...) and BARS gates
1529
+ // have their own fixed/asymmetric port layouts, and NOT has a single input.
1530
+ if (!cFlat || !cNode || (cFlat.gateType !== 'AND' && cFlat.gateType !== 'OR') || cNode.barsMode || cNode.inputs.length < 2)
1531
+ return undefined;
1532
+ const ranked = cFlat.inputIds
1533
+ .map(sid => ({ sid, y: nodeMap.get(sid)?.outputs[0]?.absY }))
1534
+ .filter((e) => e.y !== undefined)
1535
+ .sort((a, b) => a.y - b.y);
1536
+ const rank = ranked.findIndex(e => e.sid === id);
1537
+ if (rank < 0)
1538
+ return undefined;
1539
+ const portsByY = [...cNode.inputs].sort((a, b) => a.absY - b.absY);
1540
+ return portsByY[rank]?.absY;
1541
+ };
1542
+ const LABEL_GAP = 30;
1543
+ const cols = new Map();
1544
+ for (const ln of layoutNodes) {
1545
+ if (ln.gateType !== 'INPUT')
1546
+ continue;
1547
+ (cols.get(ln.absX) ?? cols.set(ln.absX, []).get(ln.absX)).push(ln);
1548
+ }
1549
+ for (const col of cols.values()) {
1550
+ col.sort((a, b) => a.absY - b.absY);
1551
+ // Snap each single-consumer input onto its gate port — but only if the slot is clear of its
1552
+ // neighbours (so we never collide a snapped input with another input). Non-snappable inputs
1553
+ // keep their swept position.
1554
+ for (const ln of col) {
1555
+ if (consumerCount.get(ln.id) !== 1)
1556
+ continue;
1557
+ const want = targetPortY(ln.id);
1558
+ if (want === undefined)
1559
+ continue;
1560
+ const y = Math.round(want / GRID) * GRID;
1561
+ if (col.some(o => o !== ln && Math.abs(o.outputs[0].absY - y) < LABEL_GAP - 0.5))
1562
+ continue;
1563
+ const d = y - (ln.outputs[0]?.absY ?? ln.absY);
1564
+ ln.absY += d;
1565
+ for (const p of ln.outputs)
1566
+ p.absY += d;
1567
+ }
1568
+ }
1569
+ }
1570
+ // ── Phase: output placement (single pass, runs after every gate move so it sees final driver
1571
+ // positions). Order each output column (AUTO by source Y, else declaration), then place each
1572
+ // output at its driver's output Y — a straight wire where the column allows, otherwise pushed
1573
+ // down to a clean >= MIN_DOGLEG below the output above. Subsumes the earlier align / snap /
1574
+ // de-overlap output passes.
1575
+ {
1576
+ const declIndex = new Map();
1577
+ let di = 0;
1578
+ for (const node of nodes.values())
1579
+ if (node.kind === 'output')
1580
+ declIndex.set(node.id, di++);
1581
+ const sourceY = (o) => {
1582
+ const fn = nodes.get(o.id);
1583
+ const sy = fn ? blkSrcY(fn.inputIds[0], fn.inputPorts?.[0]) : undefined;
1584
+ return sy !== undefined ? Math.round(sy / GRID) * GRID : o.absY + o.height / 2;
1585
+ };
1586
+ const sourceDepth = (o) => {
1587
+ const srcId = nodes.get(o.id)?.inputIds[0];
1588
+ return srcId ? nodes.get(srcId)?.depth ?? 0 : 0;
1589
+ };
1590
+ const cols = new Map();
1591
+ for (const n of layoutNodes) {
1592
+ if (n.gateType !== 'OUTPUT')
1593
+ continue;
1594
+ (cols.get(n.absX) ?? cols.set(n.absX, []).get(n.absX)).push(n);
1595
+ }
1596
+ const minGap = 40; // centre-to-centre clearance between stacked output labels
1597
+ for (const outs of cols.values()) {
1598
+ // AUTO orders by source Y (deeper source wins a tie so its short wire stays straight); else
1599
+ // declaration order.
1600
+ outs.sort((a, b) => opts.outputOrder === 'AUTO'
1601
+ ? sourceY(a) - sourceY(b) || sourceDepth(b) - sourceDepth(a) || declIndex.get(a.id) - declIndex.get(b.id)
1602
+ : declIndex.get(a.id) - declIndex.get(b.id));
1603
+ let prevCenter = -Infinity;
1604
+ for (const o of outs) {
1605
+ if (!o.inputs[0])
1606
+ continue;
1607
+ const want = sourceY(o);
1608
+ let center = Math.max(want, prevCenter + minGap);
1609
+ if (center - want > 0 && center - want < MIN_DOGLEG)
1610
+ center = want + MIN_DOGLEG;
1611
+ center = Math.round(center / GRID) * GRID;
1612
+ o.absY = Math.round((center - o.height / 2) / GRID) * GRID;
1613
+ o.inputs[0].absY = center;
1614
+ prevCenter = center;
1615
+ }
1616
+ }
1617
+ }
1618
+ // ---- Inversion bubble port assignment (BUBBLES mode) ----
1619
+ // Input ports connect to their sources in ascending source-Y order (the wire router's rule),
1620
+ // so an inverted input's bubble must follow its SIGNAL to whatever port that source lands on —
1621
+ // not a fixed input index. Assigning by index put the bubble on the wrong port whenever the
1622
+ // gate's inputs were reordered (e.g. `... AND NOT HBLK` with INPUT_ORDER = AUTO).
1623
+ for (const node of nodes.values()) {
1624
+ if (!node.invertedInputs || node.invertedInputs.size === 0)
1625
+ continue;
1626
+ const gate = nodeMap.get(node.id);
1627
+ if (!gate || gate.gateType === 'OUTPUT' || gate.inputs.length === 0)
1628
+ continue;
1629
+ const order = node.inputIds.map((id, i) => ({ i, y: blkSrcY(id, node.inputPorts?.[i]) ?? Infinity }));
1630
+ if (gate.gateType === 'AND' || gate.gateType === 'OR')
1631
+ order.sort((a, b) => a.y - b.y);
1632
+ order.forEach((e, rank) => {
1633
+ const port = gate.inputs[Math.min(rank, gate.inputs.length - 1)];
1634
+ if (port && node.invertedInputs.has(e.i)) {
1635
+ port.bubbled = true;
1636
+ port.absX -= BUBBLE_R * 2;
1637
+ }
1638
+ });
1639
+ }
1640
+ // ---- Generic FB block: attach each input's label to the port its source lands on ----
1641
+ // (Inputs map to ports in ascending source-Y order, so the declared label list is permuted.)
1642
+ for (const node of nodes.values()) {
1643
+ if (node.blockType !== 'FB')
1644
+ continue;
1645
+ const gate = nodeMap.get(node.id);
1646
+ if (!gate)
1647
+ continue;
1648
+ const order = node.inputIds.map((id, i) => ({ i, y: blkSrcY(id, node.inputPorts?.[i]) ?? Infinity }));
1649
+ order.sort((a, b) => a.y - b.y);
1650
+ order.forEach((e, rank) => { if (gate.inputs[rank])
1651
+ gate.inputs[rank].label = node.inputLabels?.[e.i]; });
1652
+ // Pull each output port onto its single consuming output node so the wire runs straight
1653
+ // (the output nodes are already placed by the AUTO stack + output-snap above).
1654
+ for (const port of gate.outputs) {
1655
+ const consumers = layoutNodes.filter(o => o.gateType === 'OUTPUT' &&
1656
+ nodes.get(o.id)?.inputIds[0] === node.id &&
1657
+ (nodes.get(o.id)?.inputPorts?.[0] ?? 'OUT') === port.name && o.inputs[0]);
1658
+ if (consumers.length === 1)
1659
+ port.absY = consumers[0].inputs[0].absY;
1660
+ }
1661
+ // Scale the box to ENCOMPASS its ports (each already sitting on its connection), grid-aligned,
1662
+ // so it grows with the input/output counts without forcing any wire into a jog/dogleg.
1663
+ const ys = [...gate.inputs, ...gate.outputs].map(p => p.absY);
1664
+ if (ys.length) {
1665
+ // Pad clears the router's vertical gate buffer (GATE_BUFFER_MIN_Y) so the top/bottom ports'
1666
+ // wires don't get nudged off their Y on the way out of the box.
1667
+ const pad = 30;
1668
+ const top = Math.floor((Math.min(...ys) - pad) / GRID) * GRID;
1669
+ const bottom = Math.ceil((Math.max(...ys) + pad) / GRID) * GRID;
1670
+ gate.absY = top;
1671
+ gate.height = bottom - top;
1672
+ }
1673
+ }
1674
+ // Keep same-column block bodies from touching. The FB encompass pass above grows each block's box
1675
+ // symmetrically around its ports, which can consume the vertical gap `sep()` reserved between two
1676
+ // adjacent blocks (their grown boxes meet — 50P1/50Q1 stacked body-to-body). This runs BEFORE
1677
+ // routing, so pushing a block (and its ports) down to restore a clean gap simply lets its wires
1678
+ // route to the new position — no wire is disturbed. Only blocks that actually overlap move, and
1679
+ // only downward, so a column without the problem is untouched.
1680
+ {
1681
+ const MIN_BLOCK_GAP = 20;
1682
+ const byCol = new Map();
1683
+ for (const n of layoutNodes)
1684
+ if (n.blockType) {
1685
+ const a = byCol.get(n.absX);
1686
+ if (a)
1687
+ a.push(n);
1688
+ else
1689
+ byCol.set(n.absX, [n]);
1690
+ }
1691
+ for (const col of byCol.values()) {
1692
+ if (col.length < 2)
1693
+ continue;
1694
+ col.sort((a, b) => a.absY - b.absY);
1695
+ for (let i = 1; i < col.length; i++) {
1696
+ const prev = col[i - 1], cur = col[i];
1697
+ const need = prev.absY + prev.height + MIN_BLOCK_GAP;
1698
+ if (cur.absY < need) {
1699
+ const dy = Math.ceil((need - cur.absY) / GRID) * GRID;
1700
+ cur.absY += dy;
1701
+ for (const p of cur.inputs)
1702
+ p.absY += dy;
1703
+ for (const p of cur.outputs)
1704
+ p.absY += dy;
1705
+ }
1706
+ }
1707
+ }
1708
+ }
1709
+ // OR gate input ports tap the concave left curve. Done as a final pass so it uses
1710
+ // the gate's final height and each port's final (aligned) Y. The bbox, output port
1711
+ // and port Y positions stay on the grid; only the input-port X follows the curve.
1712
+ // Bubbled inputs shift left by BUBBLE_R*2 so the bubble's inner edge meets the curve.
1713
+ for (const gateNode of layoutNodes) {
1714
+ if (gateNode.gateType !== 'OR')
1715
+ continue;
1716
+ for (let i = 0; i < gateNode.inputs.length; i++) {
1717
+ const port = gateNode.inputs[i];
1718
+ if (gateNode.barsMode && i >= 2)
1719
+ continue; // bar-tap ports stay on the bar
1720
+ const localY = port.absY - gateNode.absY;
1721
+ const tapX = gateNode.absX + orCurveTapX(gateNode.height, localY);
1722
+ port.absX = port.bubbled ? tapX - BUBBLE_R * 2 : tapX;
1723
+ }
1724
+ }
1725
+ const wires = [];
1726
+ const junctions = [];
1727
+ const junctionSet = new Set();
1728
+ function addJunction(x, y) {
1729
+ const key = `${Math.round(x / GRID) * GRID},${Math.round(y / GRID) * GRID}`;
1730
+ if (!junctionSet.has(key)) {
1731
+ junctionSet.add(key);
1732
+ junctions.push({ x: Math.round(x / GRID) * GRID, y: Math.round(y / GRID) * GRID });
1733
+ }
1734
+ }
1735
+ const allObstacles = layoutNodes.map(n => ({ x: n.absX, y: n.absY, w: n.width, h: n.height, id: n.id }));
1736
+ // Place each intermediate net label just above its driver's output, and register it as a
1737
+ // routing obstacle so the fan-out wires route around (not through) the text.
1738
+ const labels = [];
1739
+ const textW = (s, size = 11) => (s ? s.length * size * 0.6 : 0);
1740
+ for (const il of intermediateLabels) {
1741
+ const driver = nodeMap.get(il.driverId);
1742
+ if (!driver)
1743
+ continue;
1744
+ const port = (il.port ? driver.outputs.find(o => o.name === il.port) : undefined) ?? driver.outputs[0];
1745
+ if (!port)
1746
+ continue;
1747
+ const w = Math.ceil((Math.max(textW(il.name, 11), textW(il.description, 9)) + 8) / GRID) * GRID;
1748
+ const lines = (il.name ? 1 : 0) + (il.description ? 1 : 0);
1749
+ const h = Math.ceil((lines * 13 + 6) / GRID) * GRID;
1750
+ const x = Math.round((port.absX + 6) / GRID) * GRID; // just right of the output stub
1751
+ const y = Math.round((port.absY - h - 6) / GRID) * GRID; // above the fan-out trunk
1752
+ labels.push({ x, y, width: w, height: h, anchorX: port.absX, anchorY: port.absY, driverId: il.driverId, name: il.name, description: il.description });
1753
+ allObstacles.push({ x, y, w, h, id: `label_${il.driverId}` });
1754
+ }
1755
+ const routedSegments = [];
1756
+ const canvasW = Math.max(...layoutNodes.map(n => n.absX + n.width), ...layoutNodes.map(n => n.outputs[0]?.absX ?? n.absX + n.width)) + 200;
1757
+ const canvasH = Math.max(...layoutNodes.map(n => n.absY + n.height)) + 200;
1758
+ // The output port a wire leaves from: a named port (SR .Q/.NQ) if the consumer selected one,
1759
+ // otherwise the source's first output.
1760
+ const sourcePort = (srcId, portName) => {
1761
+ const src = nodeMap.get(srcId);
1762
+ if (!src)
1763
+ return undefined;
1764
+ return (portName ? src.outputs.find(o => o.name === portName) : undefined) ?? src.outputs[0];
1765
+ };
1766
+ // Build fan-out groups: destinations per (source, output port). Keyed `id::PORT` so a block
1767
+ // with two outputs (SR Q and NQ) routes each from its own port.
1768
+ const fanOutGroups = new Map();
1769
+ const wireRoutingOrder = Array.from(nodes.values()).sort((a, b) => a.depth - b.depth);
1770
+ for (const node of wireRoutingOrder) {
1771
+ if (node.inputIds.length === 0)
1772
+ continue;
1773
+ const toLayoutNode = nodeMap.get(node.id);
1774
+ if (!toLayoutNode)
1775
+ continue;
1776
+ let entries = node.inputIds.map((id, i) => ({ id, port: node.inputPorts?.[i] }));
1777
+ if (node.kind === 'gate' && (node.gateType === 'AND' || node.gateType === 'OR' || node.blockType === 'FB')) {
1778
+ entries = entries
1779
+ .map(e => ({ ...e, y: sourcePort(e.id, e.port)?.absY ?? Infinity }))
1780
+ .sort((a, b) => a.y - b.y);
1781
+ }
1782
+ for (let i = 0; i < entries.length; i++) {
1783
+ const { id: fromId, port } = entries[i];
1784
+ const toPortIdx = Math.min(i, toLayoutNode.inputs.length - 1);
1785
+ const toPort = toLayoutNode.inputs[toPortIdx];
1786
+ if (!toPort)
1787
+ continue;
1788
+ const destIsGate = node.kind === 'gate';
1789
+ const groupKey = `${fromId}::${port ?? ''}`;
1790
+ if (!fanOutGroups.has(groupKey))
1791
+ fanOutGroups.set(groupKey, []);
1792
+ fanOutGroups.get(groupKey).push({ toId: node.id, toPort, toLayoutNode, destIsGate });
1793
+ }
1794
+ }
1795
+ // Route wires. Each destination is routed independently from the source output
1796
+ // port, which guarantees every consumer connects (including a source that feeds
1797
+ // both a gate and an output). Wires from the same source naturally overlap on a
1798
+ // shared horizontal "trunk" near the source (same-source crossings are cheap) and
1799
+ // diverge into separate channels; junction dots are added afterwards wherever
1800
+ // same-source wires form a T-intersection.
1801
+ for (const [groupKey, destinations] of fanOutGroups) {
1802
+ const sep = groupKey.lastIndexOf('::');
1803
+ const fromId = groupKey.slice(0, sep);
1804
+ const portName = groupKey.slice(sep + 2);
1805
+ const fromLayoutNode = nodeMap.get(fromId);
1806
+ const fromPort = sourcePort(fromId, portName || undefined);
1807
+ if (!fromLayoutNode || !fromPort)
1808
+ continue;
1809
+ const fx = fromPort.absX;
1810
+ const fy = fromPort.absY;
1811
+ // Route the destinations closest in Y to the source first, so the shared trunk
1812
+ // is established before farther branches need to find their channels.
1813
+ const ordered = [...destinations].sort((a, b) => Math.abs(a.toPort.absY - fy) - Math.abs(b.toPort.absY - fy));
1814
+ for (const dest of ordered) {
1815
+ const points = routeWireAStar(fx, fy, dest.toPort.absX, dest.toPort.absY, allObstacles, fromLayoutNode.absX, fromLayoutNode.absY, fromLayoutNode.width, fromLayoutNode.height, dest.toLayoutNode.absX, dest.toLayoutNode.absY, dest.toLayoutNode.width, dest.toLayoutNode.height, dest.destIsGate, routedSegments, canvasW, canvasH, fromId);
1816
+ routedSegments.push({ points, fromId });
1817
+ wires.push({ id: uid('wire'), points, fromId, toId: dest.toId });
1818
+ }
1819
+ }
1820
+ // Gate-clearance helpers, shared by the channel track-assignment pass and the fan-in
1821
+ // nesting below. A vertical/horizontal run must stay GATE_CLEARANCE clear of every gate body.
1822
+ const GATE_CLEARANCE = 20;
1823
+ function vGateClear(x, y0, y1, skipId) {
1824
+ const yMin = Math.min(y0, y1), yMax = Math.max(y0, y1);
1825
+ for (const o of allObstacles) {
1826
+ if (o.id === skipId)
1827
+ continue;
1828
+ if (x > o.x - GATE_CLEARANCE && x < o.x + o.w + GATE_CLEARANCE &&
1829
+ yMax > o.y - 1 && yMin < o.y + o.h + 1)
1830
+ return false;
1831
+ }
1832
+ return true;
1833
+ }
1834
+ function hGateClear(y, x0, x1, skipId) {
1835
+ const xMin = Math.min(x0, x1), xMax = Math.max(x0, x1);
1836
+ for (const o of allObstacles) {
1837
+ if (o.id === skipId)
1838
+ continue;
1839
+ if (y > o.y - 1 && y < o.y + o.h + 1 && xMax > o.x - 1 && xMin < o.x + o.w + 1)
1840
+ return false;
1841
+ }
1842
+ return true;
1843
+ }
1844
+ // ── Wire separation contract (single source of truth) ───────────────────────────────────────
1845
+ // A segment is "clear" only if it keeps >= MIN_WIRE_SPACING from every PARALLEL same-orientation
1846
+ // segment of a DIFFERENT net (both orientations); perpendicular crossings are fine (crossovers,
1847
+ // not crowding). EVERY wire-reshaping pass below (channel track assignment, fan-in nesting,
1848
+ // shared-trunk merge, output snap) validates its proposed geometry through this ONE contract
1849
+ // before committing, so no pass can silently override another's separation. (Gate-body clearance
1850
+ // is vGateClear/hGateClear.) Reads `wires` live, so it always reflects current geometry.
1851
+ const segCrowds = (x0, y0, x1, y1, skip) => {
1852
+ const horiz = Math.abs(y0 - y1) < 0.5;
1853
+ if (!horiz && Math.abs(x0 - x1) >= 0.5)
1854
+ return false; // only axis-aligned segments participate
1855
+ const perp = horiz ? y0 : x0;
1856
+ const aMin = horiz ? Math.min(x0, x1) : Math.min(y0, y1);
1857
+ const aMax = horiz ? Math.max(x0, x1) : Math.max(y0, y1);
1858
+ for (const w of wires) {
1859
+ if (skip(w))
1860
+ continue;
1861
+ for (let i = 0; i < w.points.length - 1; i++) {
1862
+ const a = w.points[i], b = w.points[i + 1];
1863
+ const oHoriz = Math.abs(a.y - b.y) < 0.5;
1864
+ if (oHoriz !== horiz || (!oHoriz && Math.abs(a.x - b.x) >= 0.5) || (oHoriz && Math.abs(a.x - b.x) < 0.5))
1865
+ continue;
1866
+ const oPerp = horiz ? a.y : a.x, dp = Math.abs(oPerp - perp);
1867
+ if (dp < 0.5 || dp >= MIN_WIRE_SPACING - 0.5)
1868
+ continue; // co-linear (overlap check elsewhere) or clear
1869
+ const oMin = horiz ? Math.min(a.x, b.x) : Math.min(a.y, b.y);
1870
+ const oMax = horiz ? Math.max(a.x, b.x) : Math.max(a.y, b.y);
1871
+ if (Math.min(aMax, oMax) - Math.max(aMin, oMin) > 0.5)
1872
+ return true; // parallel & overlapping & too close
1873
+ }
1874
+ }
1875
+ return false;
1876
+ };
1877
+ // A proposed wire (its point list) satisfies the contract iff none of its segments crowds another
1878
+ // net. `skip` excludes the wire being moved and any co-moved siblings (same source shares a trunk).
1879
+ const wireClear = (pts, skip) => {
1880
+ for (let i = 0; i < pts.length - 1; i++)
1881
+ if (segCrowds(pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y, skip))
1882
+ return false;
1883
+ return true;
1884
+ };
1885
+ // ── Channel track assignment ────────────────────────────────────────────────
1886
+ // Every wire that turns once (H–V–H) carries a single vertical segment living in the
1887
+ // inter-column channel between its two horizontal runs. Verticals that overlap in Y must
1888
+ // occupy distinct X "tracks" or they collide (the no-parallel-overlap invariant). A greedy
1889
+ // per-wire search can't resolve a *mutual* conflict — it never moves the other wire to make
1890
+ // room — so we assign tracks jointly: (1) collect the movable verticals, (2) group them into
1891
+ // channels by overlapping X-window, (3) interval-graph-colour each channel by Y so overlapping
1892
+ // cross-net verticals land on different tracks (same-source verticals may share a track — they
1893
+ // form a trunk), then (4) pick the track→X placement that minimises wire crossings, spreading
1894
+ // tracks across the channel's gate-clear window. All-or-nothing per channel and only for
1895
+ // channels that actually collide: a channel whose assignment can't be validated (gate in the
1896
+ // way, window too narrow) keeps its routed geometry, so we never trade a clean route for a
1897
+ // collision, and clean channels are left untouched.
1898
+ // A wire entering a gate input port must arrive with a horizontal run of at least
1899
+ // GATE_ENTRANCE, so the vertical turn sits clear of the gate body/curve and the wire visibly
1900
+ // enters horizontally rather than turning on the gate's edge (see spec: minimum gate entrance).
1901
+ const GATE_ENTRANCE = 20;
1902
+ // The track pass adjusts a wire's single vertical (a clean H–V–H turn). It deliberately does NOT
1903
+ // touch multi-bend wires: their extra bends are obstacle detours, and sliding the approach vertical
1904
+ // of such a wire would just drag its entrance horizontal along a sibling's wire (trading a gate-hug
1905
+ // for a horizontal overlap). Those cases are congestion to solve at placement, not here.
1906
+ const movables = [];
1907
+ for (const w of wires) {
1908
+ if (w.feedback)
1909
+ continue;
1910
+ const p = w.points;
1911
+ let vi = -1, vcount = 0;
1912
+ for (let i = 0; i < p.length - 1; i++) {
1913
+ if (Math.abs(p[i].x - p[i + 1].x) < 0.5 && Math.abs(p[i].y - p[i + 1].y) >= GRID) {
1914
+ vi = i;
1915
+ vcount++;
1916
+ }
1917
+ }
1918
+ if (vcount !== 1 || vi <= 0 || vi + 2 >= p.length)
1919
+ continue; // single clean H–V–H only
1920
+ if (Math.abs(p[vi - 1].y - p[vi].y) > 0.5)
1921
+ continue; // segment before V is horizontal
1922
+ if (Math.abs(p[vi + 1].y - p[vi + 2].y) > 0.5)
1923
+ continue; // segment after V is horizontal
1924
+ const aX = p[vi - 1].x, bX = p[vi + 2].x;
1925
+ if (bX <= aX + 0.5)
1926
+ continue; // left-to-right turns only
1927
+ const yA = p[vi].y, yB = p[vi + 1].y;
1928
+ const destNode = nodeMap.get(w.toId);
1929
+ const destIsGate = !!destNode && destNode.gateType !== 'INPUT' && destNode.gateType !== 'OUTPUT';
1930
+ movables.push({ w, vi, aX, bX, yA, yB, yTop: Math.min(yA, yB), yBot: Math.max(yA, yB), destIsGate });
1931
+ }
1932
+ // Fixed verticals: every vertical segment the track pass does NOT adjust (straight wires have
1933
+ // none; a multi-bend wire's non-tail verticals are pinned by the obstacles they detour around).
1934
+ // The pass treats these as occupied tracks so it keeps MIN_WIRE_SPACING clear of them — otherwise
1935
+ // a track can land 5px from a pinned vertical and read as a cluster. Only the specific adjustable
1936
+ // tail vertical of each movable is excluded (by wire id + segment index), not the whole wire.
1937
+ const movVi = new Map();
1938
+ for (const m of movables)
1939
+ movVi.set(m.w.id, m.vi);
1940
+ const fixedVerts = [];
1941
+ for (const w of wires) {
1942
+ for (let i = 0; i < w.points.length - 1; i++) {
1943
+ if (movVi.get(w.id) === i)
1944
+ continue; // the adjustable tail vertical
1945
+ const a = w.points[i], b = w.points[i + 1];
1946
+ if (Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) >= GRID)
1947
+ fixedVerts.push({ x: a.x, y0: Math.min(a.y, b.y), y1: Math.max(a.y, b.y), from: w.fromId });
1948
+ }
1949
+ }
1950
+ // Cross-net fixed verticals overlapping m's Y-span (excludes m's own source).
1951
+ const nearFixed = (m) => fixedVerts.filter(fv => fv.from !== m.w.fromId && Math.min(m.yBot, fv.y1) - Math.max(m.yTop, fv.y0) > 0);
1952
+ const yOverlap = (a, b) => Math.min(a.yBot, b.yBot) - Math.max(a.yTop, b.yTop) >= GRID;
1953
+ const placeValid = (m, X) => X >= m.aX - 0.5 && X <= m.bX + 0.5 &&
1954
+ vGateClear(X, m.yA, m.yB) && hGateClear(m.yA, m.aX, X, m.w.fromId) && hGateClear(m.yB, X, m.bX, m.w.toId);
1955
+ // Interior crossing between a horizontal run and another wire's vertical.
1956
+ const orthCross = (hx0, hx1, hy, vx, vy0, vy1) => vx > Math.min(hx0, hx1) + 0.5 && vx < Math.max(hx0, hx1) - 0.5 &&
1957
+ hy > Math.min(vy0, vy1) + 0.5 && hy < Math.max(vy0, vy1) - 0.5;
1958
+ const pairCross = (m1, x1, m2, x2) => {
1959
+ if (m1.w.fromId === m2.w.fromId)
1960
+ return 0; // same source: shared trunk, not a crossing
1961
+ let c = 0;
1962
+ if (orthCross(m1.aX, x1, m1.yA, x2, m2.yA, m2.yB))
1963
+ c++;
1964
+ if (orthCross(x1, m1.bX, m1.yB, x2, m2.yA, m2.yB))
1965
+ c++;
1966
+ if (orthCross(m2.aX, x2, m2.yA, x1, m1.yA, m1.yB))
1967
+ c++;
1968
+ if (orthCross(x2, m2.bX, m2.yB, x1, m1.yA, m1.yB))
1969
+ c++;
1970
+ return c;
1971
+ };
1972
+ const SPREAD = 3 * GRID; // preferred gap between cross-net tracks
1973
+ // Current X of each adjustable tail vertical (mutated as we place); every movable, whether or not
1974
+ // it is re-placed, acts as an obstacle at its current X. Working globally (no channel grouping)
1975
+ // means two verticals can never be blind to each other, so we can't reintroduce a collision.
1976
+ const curX = new Map(movables.map(m => [m, m.w.points[m.vi].x]));
1977
+ const crossNet = (m) => movables.filter(o => o !== m && o.w.fromId !== m.w.fromId && yOverlap(o, m));
1978
+ // The wire's two horizontal runs (run A: yA over [aX,X]; run B: yB over [X,bX]) at a candidate X,
1979
+ // checked against other nets' horizontals via the shared contract. The vertical stays under curX
1980
+ // coordination above; this adds the horizontal half so the track pass honours the full contract.
1981
+ const runsCrowd = (m, X) => {
1982
+ const skip = (o) => o.id === m.w.id || o.fromId === m.w.fromId;
1983
+ return segCrowds(m.aX, m.yA, X, m.yA, skip) || segCrowds(X, m.yB, m.bX, m.yB, skip);
1984
+ };
1985
+ // A movable violates if it hugs a gate (fails its own gate-clearance), has too short a gate
1986
+ // entrance, or runs within MIN_WIRE_SPACING of any cross-net vertical (fixed or another movable)
1987
+ // it overlaps in Y, or its horizontal runs crowd another net. Only violating movables are
1988
+ // re-placed; clean ones stay put (minimal churn).
1989
+ const crowded = (m, X) => nearFixed(m).some(fv => Math.abs(fv.x - X) < MIN_WIRE_SPACING - 0.5) ||
1990
+ crossNet(m).some(o => Math.abs(curX.get(o) - X) < MIN_WIRE_SPACING - 0.5) ||
1991
+ runsCrowd(m, X);
1992
+ const violates = (m) => {
1993
+ const X = curX.get(m);
1994
+ return !placeValid(m, X) || (m.destIsGate && m.bX - X < GATE_ENTRANCE - 0.5) || crowded(m, X);
1995
+ };
1996
+ // Candidate X's: grid positions in [aX, cap] that keep both runs gate-clear; a gate-bound vertical
1997
+ // is capped at bX-GATE_ENTRANCE so its horizontal entrance stays >= GATE_ENTRANCE.
1998
+ const candsFor = (m) => {
1999
+ const cands = [];
2000
+ const capX = m.destIsGate ? m.bX - GATE_ENTRANCE : m.bX;
2001
+ for (let x = Math.ceil(m.aX / GRID) * GRID; x <= Math.floor(capX / GRID) * GRID; x += GRID)
2002
+ if (placeValid(m, x))
2003
+ cands.push(x);
2004
+ return cands;
2005
+ };
2006
+ // Place violating movables most-constrained-first. Each avoids exact overlap with every fixed
2007
+ // vertical and every other movable's current X, and is scored to keep MIN_WIRE_SPACING clear,
2008
+ // minimise crossings, and stay near its routed X.
2009
+ const toPlace = movables.filter(violates).map(m => ({ m, cands: candsFor(m) }))
2010
+ .sort((a, b) => a.cands.length - b.cands.length);
2011
+ for (const { m, cands } of toPlace) {
2012
+ const origX = curX.get(m);
2013
+ const fixedForM = nearFixed(m), others = crossNet(m);
2014
+ let bestX = null, bestCost = Infinity;
2015
+ for (const x of cands) {
2016
+ let bad = false, cost = Math.abs(x - origX);
2017
+ for (const fv of fixedForM) {
2018
+ const d = Math.abs(fv.x - x);
2019
+ if (d < 0.5) {
2020
+ bad = true;
2021
+ break;
2022
+ } // exact overlap with a pinned vertical
2023
+ if (d < MIN_WIRE_SPACING - 0.5)
2024
+ cost += 2000;
2025
+ else if (d < SPREAD)
2026
+ cost += 50;
2027
+ }
2028
+ if (bad)
2029
+ continue;
2030
+ for (const o of others) {
2031
+ const d = Math.abs(curX.get(o) - x);
2032
+ if (d < 0.5) {
2033
+ bad = true;
2034
+ break;
2035
+ } // exact overlap with another track
2036
+ cost += pairCross(m, x, o, curX.get(o)) * 100000; // avoid new crossings above all
2037
+ if (d < MIN_WIRE_SPACING - 0.5)
2038
+ cost += 2000;
2039
+ else if (d < SPREAD)
2040
+ cost += 50;
2041
+ }
2042
+ if (bad)
2043
+ continue;
2044
+ if (runsCrowd(m, x))
2045
+ cost += 2000; // horizontal half of the contract
2046
+ if (cost < bestCost) {
2047
+ bestCost = cost;
2048
+ bestX = x;
2049
+ }
2050
+ }
2051
+ if (bestX !== null)
2052
+ curX.set(m, bestX); // no valid slot → keep routed X
2053
+ }
2054
+ for (const m of movables) {
2055
+ m.w.points[m.vi].x = curX.get(m);
2056
+ m.w.points[m.vi + 1].x = curX.get(m);
2057
+ }
2058
+ // Nested fan-in channels: when several wires dogleg into the same gate, give each its own
2059
+ // vertical channel just left of the gate, evenly spaced (FANIN_SPACING) and nested so they
2060
+ // neither cross nor crowd. Inputs arriving from above and from below are nested
2061
+ // independently, with the most extreme source turning closest to the gate — the arrangement
2062
+ // that avoids crossings and reads symmetrically. EVERY incoming dogleg wire is reshaped to
2063
+ // a clean H–V–H through its channel (regardless of the shape A* produced), so congested
2064
+ // multi-input gates get straight nested fan-in instead of A*'s small jogs. The all-or-
2065
+ // nothing validation falls back to the existing geometry if any channel hits a gate body or
2066
+ // a non-fan-in wire, so a genuinely obstacle-routed input is never forced through a gate.
2067
+ const FANIN_SPACING = 15;
2068
+ const lastPt = (w) => w.points[w.points.length - 1];
2069
+ const nestHit = (h1, h2, v1, v2) => Math.abs(h1.y - h2.y) < 1 && Math.abs(v1.x - v2.x) < 1 &&
2070
+ h1.y > Math.min(v1.y, v2.y) - 1 && h1.y < Math.max(v1.y, v2.y) + 1 &&
2071
+ v1.x > Math.min(h1.x, h2.x) - 1 && v1.x < Math.max(h1.x, h2.x) + 1;
2072
+ const nestCross = () => {
2073
+ let c = 0;
2074
+ for (let i = 0; i < wires.length; i++)
2075
+ for (let j = i + 1; j < wires.length; j++) {
2076
+ const a = wires[i], b = wires[j];
2077
+ if (a.fromId === b.fromId || a.feedback || b.feedback)
2078
+ continue;
2079
+ for (let s = 0; s < a.points.length - 1; s++)
2080
+ for (let t = 0; t < b.points.length - 1; t++)
2081
+ if (nestHit(a.points[s], a.points[s + 1], b.points[t], b.points[t + 1]) ||
2082
+ nestHit(b.points[t], b.points[t + 1], a.points[s], a.points[s + 1]))
2083
+ c++;
2084
+ }
2085
+ return c;
2086
+ };
2087
+ // Interior crossings of ONE wire against all other cross-net wires (same rule as nestCross). When a
2088
+ // reshape moves a single wire and nothing else, its own crossing count is the exact whole-diagram
2089
+ // delta — so this O(N) check replaces the O(N^2) nestCross() inside per-wire move searches.
2090
+ const wireCross = (self) => {
2091
+ if (self.feedback)
2092
+ return 0;
2093
+ let c = 0;
2094
+ for (const o of wires) {
2095
+ if (o === self || o.fromId === self.fromId || o.feedback)
2096
+ continue;
2097
+ for (let s = 0; s < self.points.length - 1; s++)
2098
+ for (let t = 0; t < o.points.length - 1; t++)
2099
+ if (nestHit(self.points[s], self.points[s + 1], o.points[t], o.points[t + 1]) ||
2100
+ nestHit(o.points[t], o.points[t + 1], self.points[s], self.points[s + 1]))
2101
+ c++;
2102
+ }
2103
+ return c;
2104
+ };
2105
+ for (const gate of layoutNodes) {
2106
+ if (gate.gateType === 'INPUT' || gate.gateType === 'OUTPUT')
2107
+ continue;
2108
+ const fanWires = wires.filter(w => w.toId === gate.id && Math.abs(w.points[0].y - lastPt(w).y) >= 1);
2109
+ if (fanWires.length < 2)
2110
+ continue;
2111
+ const above = fanWires.filter(w => w.points[0].y < lastPt(w).y);
2112
+ const below = fanWires.filter(w => w.points[0].y > lastPt(w).y);
2113
+ above.sort((a, b) => a.points[0].y - b.points[0].y); // topmost source first
2114
+ below.sort((a, b) => b.points[0].y - a.points[0].y); // bottommost source first
2115
+ // A reshaped channel is rejected if it would hit a gate body or overlap a wire from a
2116
+ // different source that is NOT part of this gate's nested fan-in (those are crossing-free
2117
+ // by construction). Rejected groups keep their original geometry.
2118
+ // Above and below groups are placed with an interleave offset (below shifts half a step
2119
+ // leftward) so their channels interleave and don't share the same X — without the offset,
2120
+ // above[0] and below[0] both map to gate.absX - GATE_CLEARANCE and overlap.
2121
+ const fanSet = new Set(fanWires);
2122
+ // Gate clearance + the shared wire-separation contract (both orientations). Co-reshaped
2123
+ // siblings and the same source are skipped; the mutually-distinct channel Xs and interleave
2124
+ // below keep the siblings themselves apart.
2125
+ const channelOk = (w, channelX, srcX, srcY, portX, portY) => {
2126
+ if (!vGateClear(channelX, srcY, portY))
2127
+ return false;
2128
+ if (!hGateClear(srcY, srcX, channelX, w.fromId))
2129
+ return false;
2130
+ if (!hGateClear(portY, channelX, portX, w.toId))
2131
+ return false;
2132
+ // Reject a channel that lands EXACTLY on another net's vertical (same X, overlapping Y).
2133
+ // wireClear/segCrowds deliberately ignore exact-collinear overlap ("overlap check elsewhere"),
2134
+ // so two gates sitting at the same X would otherwise nest their fan-in channels onto one line
2135
+ // (e.g. TRS and TRT both at x=875 -> both channels at 855). The leftward search then steps to a
2136
+ // distinct channel instead.
2137
+ const my0 = Math.min(srcY, portY), my1 = Math.max(srcY, portY);
2138
+ for (const o of wires) {
2139
+ if (o === w || o.fromId === w.fromId || fanSet.has(o))
2140
+ continue;
2141
+ for (let i = 0; i < o.points.length - 1; i++) {
2142
+ const a = o.points[i], b = o.points[i + 1];
2143
+ if (Math.abs(a.x - b.x) < 0.5 && Math.abs(a.x - channelX) < 0.5 &&
2144
+ Math.min(my1, Math.max(a.y, b.y)) - Math.max(my0, Math.min(a.y, b.y)) >= GRID)
2145
+ return false;
2146
+ }
2147
+ }
2148
+ const reshaped = [{ x: srcX, y: srcY }, { x: channelX, y: srcY }, { x: channelX, y: portY }, { x: portX, y: portY }];
2149
+ return wireClear(reshaped, o => o === w || o.fromId === w.fromId || fanSet.has(o));
2150
+ };
2151
+ // All-or-nothing per group. For EACH wire, take the gate-most channel that validates by
2152
+ // SEARCHING leftward from its ideal (nested) X down to its min: a wire whose source-side run at
2153
+ // the near-gate channel would cross a gate body instead turns *before* that obstacle (a slid
2154
+ // channel), rather than the whole group bailing to A*'s detour. Channels stay >= MIN_WIRE_SPACING
2155
+ // apart (mutually distinct, nested order preserved). If any wire finds no valid channel the group
2156
+ // keeps its routed geometry; and the applied reshape is reverted whole if it adds any crossing,
2157
+ // so a robust fan-in is never traded for a new crossover.
2158
+ const place = (group, offset) => {
2159
+ const plan = [];
2160
+ const used = [];
2161
+ const groupMinX = Math.max(0, ...group.map(w => Math.round((w.points[0].x + MIN_DOGLEG) / GRID) * GRID));
2162
+ const room = gate.absX - GATE_CLEARANCE - groupMinX;
2163
+ const step = group.length > 1
2164
+ ? Math.max(GRID, Math.min(FANIN_SPACING, Math.floor(room / (group.length - 1) / GRID) * GRID))
2165
+ : FANIN_SPACING;
2166
+ const gateMost = Math.round((gate.absX - GATE_CLEARANCE) / GRID) * GRID;
2167
+ for (let i = 0; i < group.length; i++) {
2168
+ const w = group[i];
2169
+ const srcX = w.points[0].x, srcY = w.points[0].y;
2170
+ const e = lastPt(w), portX = e.x, portY = e.y;
2171
+ const ideal = Math.round((gate.absX - GATE_CLEARANCE - i * step - offset) / GRID) * GRID;
2172
+ const minX = Math.round((srcX + MIN_DOGLEG) / GRID) * GRID;
2173
+ let cx = null;
2174
+ for (let x = Math.min(ideal, gateMost); x >= minX; x -= GRID) { // gate-most valid channel, sliding before obstacles
2175
+ if (used.some(u => Math.abs(u - x) < MIN_WIRE_SPACING - 0.5))
2176
+ continue;
2177
+ if (channelOk(w, x, srcX, srcY, portX, portY)) {
2178
+ cx = x;
2179
+ break;
2180
+ }
2181
+ }
2182
+ if (cx === null)
2183
+ return; // no valid channel → keep routed geometry
2184
+ used.push(cx);
2185
+ plan.push({ w, cx, srcX, srcY, portX, portY });
2186
+ }
2187
+ const saved = group.map(w => w.points.map(p => ({ x: p.x, y: p.y })));
2188
+ const before = nestCross();
2189
+ for (const { w, cx, srcX, srcY, portX, portY } of plan) {
2190
+ w.points = [{ x: srcX, y: srcY }, { x: cx, y: srcY }, { x: cx, y: portY }, { x: portX, y: portY }];
2191
+ }
2192
+ if (nestCross() > before)
2193
+ group.forEach((w, k) => { w.points = saved[k]; }); // never add a crossing
2194
+ };
2195
+ // Interleave above and below groups so their channels don't share the same X. Without
2196
+ // the half-step offset, above[0] and below[0] both map to gate.absX - GATE_CLEARANCE and
2197
+ // end up at the same channel — overlapping. Below shifts by step/2 leftward to interleave.
2198
+ const aboveStep = (() => {
2199
+ const aboveMinX = Math.max(0, ...above.map(w => Math.round((w.points[0].x + MIN_DOGLEG) / GRID) * GRID));
2200
+ const aboveRoom = gate.absX - GATE_CLEARANCE - aboveMinX;
2201
+ return above.length > 1 ? Math.max(GRID, Math.min(FANIN_SPACING, Math.floor(aboveRoom / (above.length - 1) / GRID) * GRID)) : FANIN_SPACING;
2202
+ })();
2203
+ place(above, 0);
2204
+ place(below, Math.round(aboveStep / 2 / GRID) * GRID);
2205
+ }
2206
+ // Obstacle-aware output placement. An output whose port Y lands inside the vertical shadow of a gate
2207
+ // its incoming wire must cross is forced either to detour around that gate (a down-and-up /
2208
+ // up-and-over that lands on unrelated wires' lines) OR to drive straight THROUGH the gate body (a
2209
+ // gate crossing — e.g. PSV02's LED/ICMS outputs cutting through the TRS gate under reconvergence).
2210
+ // Move such an output to the nearest Y clear of every gate shadow its wire crosses, and redraw the
2211
+ // wire as one clean H–V–H. It is applied only when a clear position + route exists that validates
2212
+ // through gate clearance, the separation contract, sibling-output spacing, a legal dogleg, and a
2213
+ // no-new-crossing guard — otherwise fully reverted. So it can only ever remove a detour or a gate
2214
+ // crossing and can never introduce a crossing, crowd, overlap, or sub-min dogleg. Running before the
2215
+ // entrance pass frees any straight input the detour was blocking (the entrance pass straightens it).
2216
+ {
2217
+ const outGates = layoutNodes.filter(n => n.gateType !== 'INPUT' && n.gateType !== 'OUTPUT');
2218
+ const bendsOf = (pts) => {
2219
+ let c = 0;
2220
+ for (let i = 1; i < pts.length - 1; i++)
2221
+ if ((Math.abs(pts[i - 1].y - pts[i].y) < 0.5) !== (Math.abs(pts[i].y - pts[i + 1].y) < 0.5))
2222
+ c++;
2223
+ return c;
2224
+ };
2225
+ // Does any of the wire's segments cut through a gate body that is not its own endpoint?
2226
+ const crossesGate = (w) => {
2227
+ for (let i = 0; i < w.points.length - 1; i++) {
2228
+ const a = w.points[i], b = w.points[i + 1];
2229
+ const xm = Math.min(a.x, b.x), xM = Math.max(a.x, b.x), ym = Math.min(a.y, b.y), yM = Math.max(a.y, b.y);
2230
+ for (const g of outGates) {
2231
+ if (g.id === w.fromId || g.id === w.toId)
2232
+ continue;
2233
+ if (xM > g.absX + 1 && g.absX + g.width - 1 > xm && yM > g.absY + 1 && g.absY + g.height - 1 > ym)
2234
+ return true;
2235
+ }
2236
+ }
2237
+ return false;
2238
+ };
2239
+ for (const O of layoutNodes) {
2240
+ if (O.gateType !== 'OUTPUT' || !O.inputs[0])
2241
+ continue;
2242
+ const w = wires.find(x => x.toId === O.id && !x.feedback);
2243
+ // Act on a detour (>2 bends) OR a wire that cuts through a gate body; a clean approach is left alone.
2244
+ if (!w || w.points.length < 4 || (bendsOf(w.points) <= 2 && !crossesGate(w)))
2245
+ continue;
2246
+ const sx = w.points[0].x, sy = w.points[0].y, ox = O.inputs[0].absX, oy = O.inputs[0].absY;
2247
+ const spanL = Math.min(sx, ox) + 1, spanR = Math.max(sx, ox) - 1;
2248
+ const crossing = outGates.filter(g => g.id !== w.fromId && g.absX + g.width > spanL && g.absX < spanR);
2249
+ const yClear = (ny) => crossing.every(g => ny <= g.absY - 1 || ny >= g.absY + g.height + 1);
2250
+ if (yClear(oy))
2251
+ continue; // output not actually in a shadow
2252
+ const sibClear = (ny) => layoutNodes.every(s => s === O || s.gateType !== 'OUTPUT' ||
2253
+ s.absX !== O.absX || Math.abs((s.absY + s.height / 2) - ny) >= MIN_PORT_GAP - 0.5);
2254
+ const before = nestCross();
2255
+ let done = false;
2256
+ for (let d = GRID; d <= 500 && !done; d += GRID) { // nearest clear Y first (above or below)
2257
+ for (const ny of [oy - d, oy + d]) {
2258
+ if (ny < 0 || !yClear(ny) || !sibClear(ny))
2259
+ continue;
2260
+ const jog = Math.abs(ny - sy);
2261
+ if (jog >= 0.5 && jog < MIN_DOGLEG)
2262
+ continue; // would be a sub-min dogleg
2263
+ for (let tapX = Math.round((sx + MIN_DOGLEG) / GRID) * GRID; tapX <= ox - GATE_ENTRANCE; tapX += GRID) {
2264
+ const route = [{ x: sx, y: sy }, { x: tapX, y: sy }, { x: tapX, y: ny }, { x: ox, y: ny }]
2265
+ .filter((p, i, a) => i === 0 || Math.abs(p.x - a[i - 1].x) >= 0.5 || Math.abs(p.y - a[i - 1].y) >= 0.5);
2266
+ const skip = (o) => o === w || o.fromId === w.fromId;
2267
+ if (!vGateClear(tapX, sy, ny) || !hGateClear(sy, sx, tapX, w.fromId) ||
2268
+ !hGateClear(ny, tapX, ox, w.toId) || !wireClear(route, skip))
2269
+ continue;
2270
+ const savedPts = w.points, savedOutY = O.absY, savedPortY = O.inputs[0].absY;
2271
+ w.points = route;
2272
+ O.inputs[0].absY = ny;
2273
+ O.absY = ny - O.height / 2;
2274
+ if (nestCross() > before) {
2275
+ w.points = savedPts;
2276
+ O.absY = savedOutY;
2277
+ O.inputs[0].absY = savedPortY;
2278
+ continue;
2279
+ }
2280
+ done = true;
2281
+ break;
2282
+ }
2283
+ if (done)
2284
+ break;
2285
+ }
2286
+ }
2287
+ }
2288
+ }
2289
+ // Un-wrap a single-consumer INPUT that wraps a block to reach its gate port (mirror of the output
2290
+ // placement above). A free input — exactly one consumer — feeds one port of a gate, but a sibling
2291
+ // block that also feeds that gate (an SR seal-in latch / sub-OR) sits in the horizontal span between
2292
+ // them, occupying the input's straight-line Y. The A* router is then forced up-and-over (or through)
2293
+ // that block, landing its wire across the block's own I/O wires (e.g. CTR3 → PSV03, wrapped over the
2294
+ // seal-in SR, crossing SR.Q→OR and OR→SR). Move the input's SOURCE Y to the near outer edge of that
2295
+ // shadow so a clean gate-clear H–V–H enters the fixed port from the correct side. Because only this
2296
+ // one wire moves, its own crossing count (wireCross, O(N)) is the exact whole-diagram delta; the move
2297
+ // is kept only if that STRICTLY drops and the route validates (gate clearance, separation contract,
2298
+ // sibling-input spacing, legal dogleg), reverted otherwise. So it can only ever remove a wrap
2299
+ // crossing and never introduces a crossing, crowd, overlap, or sub-min dogleg.
2300
+ {
2301
+ const fanout = new Map();
2302
+ for (const w of wires)
2303
+ if (!w.feedback)
2304
+ fanout.set(w.fromId, (fanout.get(w.fromId) ?? 0) + 1);
2305
+ for (const S of layoutNodes) {
2306
+ if (S.gateType !== 'INPUT' || !S.outputs[0])
2307
+ continue;
2308
+ if ((fanout.get(S.id) ?? 0) !== 1)
2309
+ continue; // single-consumer input only
2310
+ const w = wires.find(x => x.fromId === S.id && !x.feedback);
2311
+ if (!w || w.points.length < 4)
2312
+ continue; // a straight/one-bend input is already clean
2313
+ const before = wireCross(w);
2314
+ if (before === 0)
2315
+ continue; // only act on an input wire that crosses
2316
+ const dest = nodeMap.get(w.toId);
2317
+ if (!dest || dest.gateType === 'INPUT' || dest.gateType === 'OUTPUT')
2318
+ continue;
2319
+ const sx = w.points[0].x, port = lastPt(w), px = port.x, py = port.y;
2320
+ const sibClear = (sy) => layoutNodes.every(s => s === S || s.gateType !== 'INPUT' ||
2321
+ s.absX !== S.absX || Math.abs((s.absY + s.height / 2) - sy) >= MIN_PORT_GAP - 0.5);
2322
+ const savedPts = w.points, savedSY = S.absY, savedOutY = S.outputs[0].absY;
2323
+ // Search source Ys nearest the port outward; keep the FEWEST-crossing clean route (a partial
2324
+ // un-wrap that still clips a sibling wire, e.g. lands on the block's other input, is not the goal —
2325
+ // the fully-clear position just past that wire is). Only touch `w`, so wireCross is the exact delta.
2326
+ let best = null;
2327
+ let done = false;
2328
+ for (let d = GRID; d <= 500 && !done; d += GRID) { // source Y nearest the port first, above or below
2329
+ for (const sy of [py - d, py + d]) {
2330
+ if (sy < 0 || !sibClear(sy))
2331
+ continue;
2332
+ const jog = Math.abs(sy - py);
2333
+ if (jog >= 0.5 && jog < MIN_DOGLEG)
2334
+ continue; // would be a sub-min dogleg
2335
+ for (let tapX = Math.round((px - GATE_ENTRANCE) / GRID) * GRID; tapX > sx + MIN_DOGLEG; tapX -= GRID) {
2336
+ const route = [{ x: sx, y: sy }, { x: tapX, y: sy }, { x: tapX, y: py }, { x: px, y: py }]
2337
+ .filter((p, i, a) => i === 0 || Math.abs(p.x - a[i - 1].x) >= 0.5 || Math.abs(p.y - a[i - 1].y) >= 0.5);
2338
+ const skip = (o) => o === w || o.fromId === w.fromId;
2339
+ if (!vGateClear(tapX, sy, py) || !hGateClear(sy, sx, tapX, w.fromId) ||
2340
+ !hGateClear(py, tapX, px, w.toId) || !wireClear(route, skip))
2341
+ continue;
2342
+ w.points = route;
2343
+ const c = wireCross(w);
2344
+ w.points = savedPts;
2345
+ if (c < (best?.cross ?? before))
2346
+ best = { pts: route, sy, cross: c };
2347
+ if (c === 0) {
2348
+ done = true;
2349
+ break;
2350
+ } // fully clean — take it (nearest wins)
2351
+ }
2352
+ if (done)
2353
+ break;
2354
+ }
2355
+ }
2356
+ if (best && best.cross < before) {
2357
+ w.points = best.pts;
2358
+ S.outputs[0].absY = best.sy;
2359
+ S.absY = best.sy - S.height / 2;
2360
+ }
2361
+ }
2362
+ }
2363
+ // Straighten gratuitous sub-MIN_DOGLEG jogs: a small vertical step between two horizontal runs
2364
+ // is collapsed when the far run can slide onto the near run's Y with the span clear of gate
2365
+ // bodies (the obstacle-aware placement lanes can leave such a jog where the route had room to
2366
+ // run straight). Conservative: only when gate-clear, validated against the dogleg invariant.
2367
+ for (const w of wires) {
2368
+ if (w.feedback)
2369
+ continue;
2370
+ let changed = true;
2371
+ while (changed) {
2372
+ changed = false;
2373
+ const p = w.points;
2374
+ for (let k = 1; k + 3 < p.length; k++) { // need run B AND a segment after it (so p[k+2] is never the terminal port)
2375
+ if (Math.abs(p[k].x - p[k + 1].x) > 0.5)
2376
+ continue; // segment k vertical
2377
+ const len = Math.abs(p[k].y - p[k + 1].y);
2378
+ if (len < 0.5 || len >= MIN_DOGLEG)
2379
+ continue; // small jog only
2380
+ if (Math.abs(p[k - 1].y - p[k].y) > 0.5)
2381
+ continue; // run A horizontal
2382
+ if (Math.abs(p[k + 1].y - p[k + 2].y) > 0.5)
2383
+ continue; // run B horizontal
2384
+ const yA = p[k].y;
2385
+ const spanClear = hGateClear(yA, p[k + 1].x, p[k + 2].x, w.toId) && hGateClear(yA, p[k + 1].x, p[k + 2].x, w.fromId);
2386
+ const nextOk = vGateClear(p[k + 2].x, yA, p[k + 3].y, w.toId); // skip the destination gate: a port approach is meant to be near it
2387
+ if (!spanClear || !nextOk)
2388
+ continue;
2389
+ p[k + 1].y = yA;
2390
+ p[k + 2].y = yA; // slide run B onto run A's Y
2391
+ w.points = p.filter((pt, i) => i === 0 || Math.abs(pt.x - p[i - 1].x) >= 0.5 || Math.abs(pt.y - p[i - 1].y) >= 0.5);
2392
+ changed = true;
2393
+ break;
2394
+ }
2395
+ }
2396
+ }
2397
+ // Feedback (loop-back) wires. A source that is an output node feeds back into the logic
2398
+ // (e.g. a seal-in latch `Q = SET OR (Q AND NOT RESET)`). It cannot flow left-to-right, so it
2399
+ // is routed by the obstacle-aware A* router from the output's signal line back to the
2400
+ // consuming gate's input port — finding a local loop around the gates rather than a fixed
2401
+ // full-width lane (which looks bad when the consumer is far from the output).
2402
+ for (const [groupKey, dests] of fanOutGroups) {
2403
+ const fromId = groupKey.slice(0, groupKey.lastIndexOf('::'));
2404
+ const outNode = nodeMap.get(fromId);
2405
+ if (!outNode || outNode.gateType !== 'OUTPUT')
2406
+ continue; // feedback only
2407
+ // Share the output's driving signal: tap at the gate that drives the output (its output
2408
+ // port), so the feedback fans out from the same point as the output label rather than
2409
+ // retracing the driver→output wire. Same fromId => they share the trunk / a junction dot.
2410
+ const driverId = nodes.get(fromId)?.inputIds[0];
2411
+ const driver = driverId ? nodeMap.get(driverId) : undefined;
2412
+ const tapPort = driver?.outputs[0] ?? outNode.inputs[0];
2413
+ const tapFrom = driver ? driverId : fromId;
2414
+ if (!tapPort)
2415
+ continue;
2416
+ void driver;
2417
+ // Keep the loop-back a clear distance (3x the wire gap) off the driver output and the
2418
+ // consumer input. The A* runs to a waypoint on the SAME side as the consumer's feedback
2419
+ // port (under a bottom port, over a top port) so the loop approaches the port from the
2420
+ // correct side without crossing the consumer's other input wire. Zero-size endpoint boxes
2421
+ // keep the driver and consumer as regular obstacles (full clearance, never hugged).
2422
+ const clearance = Math.round((3 * MIN_WIRE_SPACING) / GRID) * GRID;
2423
+ for (const dest of dests) {
2424
+ const port = dest.toPort;
2425
+ const g = dest.toLayoutNode;
2426
+ const startX = tapPort.absX + clearance;
2427
+ const endX = port.absX - clearance;
2428
+ const bottomPort = port.absY > g.absY + g.height / 2;
2429
+ const laneY = bottomPort ? g.absY + g.height + clearance : g.absY - clearance;
2430
+ const mid = routeWireAStar(startX, tapPort.absY, endX, laneY, allObstacles, startX, tapPort.absY, 0, 0, endX, laneY, 0, 0, false, routedSegments, canvasW, canvasH, tapFrom);
2431
+ const pts = [
2432
+ { x: tapPort.absX, y: tapPort.absY }, ...mid,
2433
+ { x: endX, y: port.absY }, { x: port.absX, y: port.absY },
2434
+ ];
2435
+ const clean = [];
2436
+ for (const p of pts) {
2437
+ const last = clean[clean.length - 1];
2438
+ if (last && Math.abs(last.x - p.x) < 0.5 && Math.abs(last.y - p.y) < 0.5)
2439
+ continue;
2440
+ if (clean.length >= 2) {
2441
+ const a = clean[clean.length - 2], b = clean[clean.length - 1];
2442
+ const colinH = Math.abs(a.y - b.y) < 0.5 && Math.abs(b.y - p.y) < 0.5;
2443
+ const colinV = Math.abs(a.x - b.x) < 0.5 && Math.abs(b.x - p.x) < 0.5;
2444
+ if (colinH || colinV)
2445
+ clean.pop();
2446
+ }
2447
+ clean.push(p);
2448
+ }
2449
+ wires.push({ id: uid('wire'), points: clean, fromId: tapFrom, toId: dest.toId, feedback: true });
2450
+ routedSegments.push({ points: clean, fromId: tapFrom });
2451
+ addJunction(tapPort.absX, tapPort.absY);
2452
+ }
2453
+ }
2454
+ // Shared fan-out trunk: when one source feeds several destinations and two of its wires
2455
+ // turn vertically at nearly the same X, snap them to a single shared channel. Same-source
2456
+ // overlap is intentional (it reads as one trunk) and it collapses the near-duplicate
2457
+ // junction dots into one clean T-tap. Snapping toward the gate-most X only shortens the
2458
+ // peel-off horizontals, so it cannot introduce a backtrack.
2459
+ {
2460
+ const bySource = new Map();
2461
+ for (const w of wires) {
2462
+ if (w.points.length !== 4)
2463
+ continue;
2464
+ if (Math.abs(w.points[1].x - w.points[2].x) >= 1)
2465
+ continue; // middle segment vertical
2466
+ if (Math.abs(w.points[0].y - w.points[1].y) >= 1)
2467
+ continue; // exits horizontally
2468
+ const arr = bySource.get(w.fromId) ?? [];
2469
+ arr.push({ w, x: w.points[1].x });
2470
+ bySource.set(w.fromId, arr);
2471
+ }
2472
+ // The relocated branch (vertical at sharedX + its peel-off horizontal) must satisfy the shared
2473
+ // separation contract. Same-source wires are skipped — the whole point is that they merge into
2474
+ // one trunk. Crossovers with other nets are fine; only sub-MIN parallel crowding is rejected.
2475
+ // segCrowds/wireClear DELIBERATELY ignore exact-collinear overlap (deferring to the "overlap
2476
+ // check elsewhere" — the no-parallel-overlap invariant), so this pass must ALSO reject a move
2477
+ // that lands a segment exactly on a CROSS-NET parallel run (overlap >= GRID) — otherwise snapping
2478
+ // a branch to the gate-most X can drop it right on top of another net's channel. Its sibling
2479
+ // passes (fan-in nesting, the entrance pass) carry this same collinear guard; without it the
2480
+ // trunk merge can silently undo the track pass's overlap-avoiding placement.
2481
+ const collinearHitsOtherNet = (pts, selfFrom) => {
2482
+ for (let i = 0; i < pts.length - 1; i++) {
2483
+ const a = pts[i], b = pts[i + 1];
2484
+ const horiz = Math.abs(a.y - b.y) < 0.5;
2485
+ if (!horiz && Math.abs(a.x - b.x) >= 0.5)
2486
+ continue; // only axis-aligned segments
2487
+ const perp = horiz ? a.y : a.x;
2488
+ const s0 = horiz ? Math.min(a.x, b.x) : Math.min(a.y, b.y);
2489
+ const s1 = horiz ? Math.max(a.x, b.x) : Math.max(a.y, b.y);
2490
+ for (const w of wires) {
2491
+ if (w.fromId === selfFrom)
2492
+ continue; // same source shares the trunk (intentional)
2493
+ for (let j = 0; j < w.points.length - 1; j++) {
2494
+ const c = w.points[j], d = w.points[j + 1];
2495
+ const oHoriz = Math.abs(c.y - d.y) < 0.5;
2496
+ if (oHoriz !== horiz || (!oHoriz && Math.abs(c.x - d.x) >= 0.5) || (oHoriz && Math.abs(c.x - d.x) < 0.5))
2497
+ continue;
2498
+ if (Math.abs((oHoriz ? c.y : c.x) - perp) >= 0.5)
2499
+ continue; // not collinear
2500
+ const o0 = oHoriz ? Math.min(c.x, d.x) : Math.min(c.y, d.y);
2501
+ const o1 = oHoriz ? Math.max(c.x, d.x) : Math.max(c.y, d.y);
2502
+ if (Math.min(s1, o1) - Math.max(s0, o0) >= GRID)
2503
+ return true; // collinear overlap
2504
+ }
2505
+ }
2506
+ }
2507
+ return false;
2508
+ };
2509
+ const moveClear = (self, sharedX) => {
2510
+ const moved = [self.points[0], { x: sharedX, y: self.points[1].y }, { x: sharedX, y: self.points[2].y }, self.points[3]];
2511
+ return wireClear(moved, o => o.fromId === self.fromId) && !collinearHitsOtherNet(moved, self.fromId);
2512
+ };
2513
+ for (const group of bySource.values()) {
2514
+ if (group.length < 2)
2515
+ continue;
2516
+ group.sort((a, b) => a.x - b.x);
2517
+ let start = 0;
2518
+ for (let i = 1; i <= group.length; i++) {
2519
+ if (i === group.length || group[i].x - group[i - 1].x > FANIN_SPACING) {
2520
+ if (i - start >= 2) {
2521
+ const sharedX = group[i - 1].x; // gate-most X in the cluster
2522
+ if (group.slice(start, i).every(g => moveClear(g.w, sharedX))) {
2523
+ for (let k = start; k < i; k++) {
2524
+ group[k].w.points[1].x = sharedX;
2525
+ group[k].w.points[2].x = sharedX;
2526
+ }
2527
+ }
2528
+ }
2529
+ start = i;
2530
+ }
2531
+ }
2532
+ }
2533
+ }
2534
+ // ── Gate-entrance contract (single guarantee) ────────────────────────────────────────────────
2535
+ // Every wire entering a gate input port MUST arrive with a horizontal approach of at least
2536
+ // GATE_ENTRANCE, so its final turn sits clear of the gate body/curve and the wire visibly enters
2537
+ // horizontally instead of turning on the gate's edge. The reshaping passes above (channel tracks,
2538
+ // fan-in nesting) satisfy this only as a SIDE EFFECT and each has an escape hatch that can leave a
2539
+ // gate-hugging entrance behind — the track pass skips multi-bend wires by design, and nesting is
2540
+ // all-or-nothing (it reverts a whole group to raw A* geometry when one channel won't fit). So a
2541
+ // multi-bend A* route whose final vertical lands a few px off the gate was fixed by NEITHER pass.
2542
+ // This is the ONE place that GUARANTEES the contract for EVERY gate-input wire regardless of how it
2543
+ // was routed, backed by an invariant (invariants.spec: "gate-input wires keep the GATE_ENTRANCE
2544
+ // approach"). It only pulls a turn back (never toward the gate) and validates every move through
2545
+ // the shared separation contract (wireClear), gate clearance, and a no-new-crossing check, so it
2546
+ // can never introduce a crowd, a hug, or a crossing; a move that can't be made cleanly is left as
2547
+ // routed (genuine congestion, which the invariant then surfaces rather than hiding).
2548
+ {
2549
+ const hvHit = (h1, h2, v1, v2) => Math.abs(h1.y - h2.y) < 1 && Math.abs(v1.x - v2.x) < 1 &&
2550
+ h1.y > Math.min(v1.y, v2.y) - 1 && h1.y < Math.max(v1.y, v2.y) + 1 &&
2551
+ v1.x > Math.min(h1.x, h2.x) - 1 && v1.x < Math.max(h1.x, h2.x) + 1;
2552
+ const wireCrosses = (self) => {
2553
+ let c = 0;
2554
+ for (const o of wires) {
2555
+ if (o === self || o.fromId === self.fromId || o.feedback)
2556
+ continue;
2557
+ for (let i = 0; i < self.points.length - 1; i++)
2558
+ for (let j = 0; j < o.points.length - 1; j++) {
2559
+ const p1 = self.points[i], p2 = self.points[i + 1], q1 = o.points[j], q2 = o.points[j + 1];
2560
+ if (hvHit(p1, p2, q1, q2) || hvHit(q1, q2, p1, p2))
2561
+ c++;
2562
+ }
2563
+ }
2564
+ return c;
2565
+ };
2566
+ // Collinear-overlap check: segCrowds (the spacing contract) deliberately ignores segments at the
2567
+ // SAME perpendicular coordinate ("overlap check elsewhere" — the no-parallel-overlap invariant).
2568
+ // The entrance pass must honour that half too, or a straightened/pulled-back segment could land
2569
+ // exactly on another net's parallel run. Rejects a proposed geometry whose axis-aligned segment
2570
+ // overlaps a cross-net parallel segment at the same coordinate by >= GRID.
2571
+ const overlapsCollinear = (pts, skip) => {
2572
+ for (let i = 0; i < pts.length - 1; i++) {
2573
+ const a = pts[i], b = pts[i + 1];
2574
+ const horiz = Math.abs(a.y - b.y) < 0.5;
2575
+ if (!horiz && Math.abs(a.x - b.x) >= 0.5)
2576
+ continue;
2577
+ const perp = horiz ? a.y : a.x, mn = horiz ? Math.min(a.x, b.x) : Math.min(a.y, b.y), mx = horiz ? Math.max(a.x, b.x) : Math.max(a.y, b.y);
2578
+ for (const o of wires) {
2579
+ if (skip(o))
2580
+ continue;
2581
+ for (let k = 0; k < o.points.length - 1; k++) {
2582
+ const c = o.points[k], d = o.points[k + 1];
2583
+ const oh = Math.abs(c.y - d.y) < 0.5;
2584
+ if (oh !== horiz || (!oh && Math.abs(c.x - d.x) >= 0.5) || (oh && Math.abs(c.x - d.x) < 0.5))
2585
+ continue;
2586
+ if (Math.abs((oh ? c.y : c.x) - perp) >= 0.5)
2587
+ continue; // only exact-collinear runs
2588
+ const omn = oh ? Math.min(c.x, d.x) : Math.min(c.y, d.y), omx = oh ? Math.max(c.x, d.x) : Math.max(c.y, d.y);
2589
+ if (Math.min(mx, omx) - Math.max(mn, omn) >= GRID)
2590
+ return true;
2591
+ }
2592
+ }
2593
+ }
2594
+ return false;
2595
+ };
2596
+ for (const w of wires) {
2597
+ if (w.feedback)
2598
+ continue;
2599
+ const p = w.points, n = p.length;
2600
+ if (n < 3)
2601
+ continue;
2602
+ const dest = nodeMap.get(w.toId);
2603
+ if (!dest || dest.gateType === 'INPUT' || dest.gateType === 'OUTPUT')
2604
+ continue; // gate inputs only
2605
+ const port = p[n - 1];
2606
+ if (Math.abs(p[n - 2].y - port.y) >= 0.5)
2607
+ continue; // enters horizontally (invariant)
2608
+ if (Math.abs(port.x - p[n - 2].x) >= GATE_ENTRANCE - 0.5)
2609
+ continue; // already clear
2610
+ const before = wireCrosses(w);
2611
+ const skip = (o) => o.id === w.id || o.fromId === w.fromId;
2612
+ // (1) Straight-first: collinear source & port whose A* route wandered — one straight segment
2613
+ // removes the wander AND the hug at once, if the straight path is clean.
2614
+ if (Math.abs(p[0].y - port.y) < 0.5 && port.x - p[0].x >= GATE_ENTRANCE - 0.5) {
2615
+ const straight = [{ x: p[0].x, y: port.y }, { x: port.x, y: port.y }];
2616
+ if (hGateClear(port.y, p[0].x, port.x, w.fromId) && hGateClear(port.y, p[0].x, port.x, w.toId) &&
2617
+ wireClear(straight, skip) && !overlapsCollinear(straight, skip)) {
2618
+ const saved = w.points;
2619
+ w.points = straight;
2620
+ if (wireCrosses(w) <= before)
2621
+ continue; // accept
2622
+ w.points = saved; // else revert, try (2)
2623
+ }
2624
+ }
2625
+ // (2) Pull the tail vertical back to the gate-most clear track <= port.x - GATE_ENTRANCE. Needs
2626
+ // a movable tail vertical (>= 4 points, so it isn't the segment anchored at the source).
2627
+ if (n < 4)
2628
+ continue;
2629
+ const tv = n - 3; // tail vertical p[tv]->p[tv+1]
2630
+ if (Math.abs(p[tv].x - p[tv + 1].x) >= 0.5)
2631
+ continue; // must be vertical
2632
+ if (Math.abs(p[tv - 1].y - p[tv].y) >= 0.5)
2633
+ continue; // preceded by a horizontal run
2634
+ const preStartX = p[tv - 1].x, preY = p[tv].y, portY = port.y;
2635
+ const saved = p.map(pt => ({ x: pt.x, y: pt.y }));
2636
+ let placed = false;
2637
+ const hi = Math.floor((port.x - GATE_ENTRANCE) / GRID) * GRID;
2638
+ for (let x = hi; x > preStartX + 0.5; x -= GRID) { // leftward, keep pre-horizontal non-degenerate
2639
+ p[tv].x = x;
2640
+ p[tv + 1].x = x;
2641
+ const seg = [{ x: preStartX, y: preY }, { x, y: preY }, { x, y: portY }, { x: port.x, y: portY }];
2642
+ if (hGateClear(preY, preStartX, x, w.fromId) && vGateClear(x, preY, portY) &&
2643
+ hGateClear(portY, x, port.x, w.toId) && wireClear(seg, skip) &&
2644
+ !overlapsCollinear(seg, skip) && wireCrosses(w) <= before) {
2645
+ placed = true;
2646
+ break;
2647
+ }
2648
+ }
2649
+ if (!placed)
2650
+ for (let i = 0; i < n; i++) {
2651
+ p[i].x = saved[i].x;
2652
+ p[i].y = saved[i].y;
2653
+ } // revert
2654
+ }
2655
+ // Joint fallback: a wire can still hug a gate when a SIBLING fan-in channel occupies the only
2656
+ // clean track (e.g. three inputs converging where the gate-clear boundary, one input's channel,
2657
+ // and an unrelated trunk-end all coincide). Re-pack ALL of that gate's incoming tail verticals
2658
+ // onto distinct tracks stepping left from the gate-clear boundary, giving the gate-most track to
2659
+ // the LEAST-deflected (straightest) wire so no crossing is introduced. All-or-nothing and fully
2660
+ // validated (entrance + separation + gate clearance + no net new crossings): committed only if
2661
+ // every incoming wire then clears, else the whole gate reverts — so it can only resolve a hug.
2662
+ const countCross = () => {
2663
+ let c = 0;
2664
+ for (let i = 0; i < wires.length; i++)
2665
+ for (let j = i + 1; j < wires.length; j++) {
2666
+ const a = wires[i], b = wires[j];
2667
+ if (a.fromId === b.fromId || a.feedback || b.feedback)
2668
+ continue;
2669
+ for (let si = 0; si < a.points.length - 1; si++)
2670
+ for (let sj = 0; sj < b.points.length - 1; sj++)
2671
+ if (hvHit(a.points[si], a.points[si + 1], b.points[sj], b.points[sj + 1]) ||
2672
+ hvHit(b.points[sj], b.points[sj + 1], a.points[si], a.points[si + 1]))
2673
+ c++;
2674
+ }
2675
+ return c;
2676
+ };
2677
+ const violates = (w) => {
2678
+ const p = w.points, port = p[p.length - 1];
2679
+ return p.length >= 4 && Math.abs(p[p.length - 2].y - port.y) < 0.5 &&
2680
+ Math.abs(port.x - p[p.length - 2].x) < GATE_ENTRANCE - 0.5;
2681
+ };
2682
+ for (const gate of layoutNodes) {
2683
+ if (gate.gateType === 'INPUT' || gate.gateType === 'OUTPUT')
2684
+ continue;
2685
+ const incoming = wires.filter(w => !w.feedback && w.toId === gate.id && w.points.length >= 4);
2686
+ if (incoming.length < 2 || !incoming.some(violates))
2687
+ continue;
2688
+ // Each incoming wire must expose a movable tail vertical (clean H–V–H tail).
2689
+ const items = [];
2690
+ let shapesOk = true;
2691
+ for (const w of incoming) {
2692
+ const p = w.points, m = p.length, port = p[m - 1], tv = m - 3;
2693
+ if (Math.abs(p[m - 2].y - port.y) >= 0.5 || Math.abs(p[tv].x - p[tv + 1].x) >= 0.5 || Math.abs(p[tv - 1].y - p[tv].y) >= 0.5) {
2694
+ shapesOk = false;
2695
+ break;
2696
+ }
2697
+ items.push({ w, tv, preStartX: p[tv - 1].x, preY: p[tv].y, portY: port.y, portX: port.x, deflect: Math.abs(p[0].y - port.y) });
2698
+ }
2699
+ if (!shapesOk)
2700
+ continue;
2701
+ items.sort((a, b) => a.deflect - b.deflect); // straightest first → gate-most track
2702
+ const boundary = Math.floor((gate.absX - GATE_CLEARANCE) / GRID) * GRID;
2703
+ const crossBefore = countCross();
2704
+ const saved = items.map(it => it.w.points.map(pt => ({ x: pt.x, y: pt.y })));
2705
+ const used = [];
2706
+ let good = true;
2707
+ for (const it of items) {
2708
+ const skip = (o) => o.id === it.w.id || o.fromId === it.w.fromId;
2709
+ let placedX = null;
2710
+ const top = Math.min(boundary, Math.floor((it.portX - GATE_ENTRANCE) / GRID) * GRID);
2711
+ for (let x = top; x > it.preStartX + 0.5; x -= GRID) {
2712
+ if (used.some(u => Math.abs(u - x) < MIN_WIRE_SPACING - 0.5))
2713
+ continue;
2714
+ it.w.points[it.tv].x = x;
2715
+ it.w.points[it.tv + 1].x = x;
2716
+ const seg = [{ x: it.preStartX, y: it.preY }, { x, y: it.preY }, { x, y: it.portY }, { x: it.portX, y: it.portY }];
2717
+ if (hGateClear(it.preY, it.preStartX, x, it.w.fromId) && vGateClear(x, it.preY, it.portY) &&
2718
+ hGateClear(it.portY, x, it.portX, it.w.toId) && wireClear(seg, skip) && !overlapsCollinear(seg, skip)) {
2719
+ placedX = x;
2720
+ break;
2721
+ }
2722
+ }
2723
+ if (placedX === null) {
2724
+ good = false;
2725
+ break;
2726
+ }
2727
+ used.push(placedX);
2728
+ }
2729
+ if (good && countCross() > crossBefore)
2730
+ good = false; // never add a crossing
2731
+ if (!good)
2732
+ items.forEach((it, i) => { it.w.points = saved[i]; }); // revert the whole gate
2733
+ }
2734
+ }
2735
+ // Snap an output to its incoming wire's approach Y. A wire that had to clear a gate body
2736
+ // (vertical clearance) can arrive a few px off its output port, leaving a small terminal
2737
+ // jog. Since an output is a sink, just move it to where the wire arrives — eliminating the
2738
+ // jog — provided it stays clear of its sibling outputs in the same column.
2739
+ for (const o of layoutNodes) {
2740
+ if (o.gateType !== 'OUTPUT' || !o.inputs[0])
2741
+ continue;
2742
+ const w = wires.find(x => x.toId === o.id && !x.feedback);
2743
+ if (!w || w.points.length < 3)
2744
+ continue;
2745
+ const p = w.points;
2746
+ // Find a small vertical jog (< MIN_DOGLEG) in the last two segments — whether the wire
2747
+ // enters with the jog as its final segment, or jogs then runs a short horizontal into the
2748
+ // port. The "run" Y on the far side is where the wire actually travels; move the output
2749
+ // there (and collapse the jog), as long as it stays clear of its sibling outputs.
2750
+ let k = -1;
2751
+ for (let s = p.length - 2; s >= Math.max(0, p.length - 3); s--) {
2752
+ const a = p[s], b = p[s + 1];
2753
+ if (Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) >= 0.5 && Math.abs(a.y - b.y) < MIN_DOGLEG) {
2754
+ k = s;
2755
+ break;
2756
+ }
2757
+ }
2758
+ if (k < 0)
2759
+ continue;
2760
+ const newY = Math.round(p[k].y / GRID) * GRID; // the run Y (far side of the jog)
2761
+ const clash = layoutNodes.some(s => s !== o && s.gateType === 'OUTPUT' && s.absX === o.absX &&
2762
+ Math.abs((s.absY + s.height / 2) - newY) < MIN_PORT_GAP - 0.5);
2763
+ if (clash)
2764
+ continue;
2765
+ o.inputs[0].absY = newY;
2766
+ o.absY = Math.round((newY - o.height / 2) / GRID) * GRID;
2767
+ for (let m = k + 1; m < p.length; m++)
2768
+ p[m].y = newY; // collapse the jog onto the run
2769
+ w.points = p.filter((pt, idx) => idx === 0 ||
2770
+ Math.abs(pt.x - p[idx - 1].x) >= 0.5 || Math.abs(pt.y - p[idx - 1].y) >= 0.5);
2771
+ }
2772
+ // Final straightening for a block output port that feeds a single output node directly (no
2773
+ // gate between them, e.g. a generic FB): co-locate the port and the node and draw one straight
2774
+ // segment, so multi-output blocks never leave a residual jog after the earlier snap passes.
2775
+ for (const w of wires) {
2776
+ if (w.feedback || w.points.length < 2)
2777
+ continue;
2778
+ const src = nodeMap.get(w.fromId);
2779
+ const dst = nodeMap.get(w.toId);
2780
+ if (!src || src.blockType !== 'FB' || !dst || dst.gateType !== 'OUTPUT' || !dst.inputs[0])
2781
+ continue;
2782
+ const fn = nodes.get(dst.id);
2783
+ const sp = src.outputs.find(op => op.name === (fn?.inputPorts?.[0] ?? 'OUT'));
2784
+ if (!sp || Math.abs(sp.absX - w.points[0].x) > 0.5 || sp.absX >= dst.inputs[0].absX)
2785
+ continue;
2786
+ // Only safe to move the port if this is its sole wire (not shared with another consumer).
2787
+ if (wires.filter(o => o.fromId === w.fromId && Math.abs(o.points[0].y - sp.absY) < 0.5).length > 1)
2788
+ continue;
2789
+ const y = dst.inputs[0].absY;
2790
+ sp.absY = y;
2791
+ w.points = [{ x: sp.absX, y }, { x: dst.inputs[0].absX, y }];
2792
+ }
2793
+ // Junction dots mark where a NET actually branches — a point where its wires' segments leave in
2794
+ // three or more distinct directions (a T or a cross). A point where two same-source wires merely
2795
+ // bend together (only two directions, e.g. a shared trunk turning a corner) is NOT a branch and
2796
+ // gets no dot; a point where the trunk continues straight and one branch peels off (a T-tap) does.
2797
+ {
2798
+ const bySource = new Map();
2799
+ for (const w of wires) {
2800
+ const a = bySource.get(w.fromId);
2801
+ if (a)
2802
+ a.push(w);
2803
+ else
2804
+ bySource.set(w.fromId, [w]);
2805
+ }
2806
+ const dirFrom = (ax, ay, bx, by) => Math.abs(ax - bx) >= 0.5 ? (bx > ax ? 'R' : 'L') : (by > ay ? 'D' : 'U');
2807
+ for (const group of bySource.values()) {
2808
+ if (group.length < 2)
2809
+ continue; // a single wire never taps itself
2810
+ const pts = new Map(); // candidate points: every vertex in the net
2811
+ for (const w of group)
2812
+ for (const p of w.points)
2813
+ pts.set(`${Math.round(p.x)},${Math.round(p.y)}`, p);
2814
+ for (const { x: px, y: py } of pts.values()) {
2815
+ const set = new Set();
2816
+ for (const w of group) {
2817
+ const pp = w.points;
2818
+ for (let s = 0; s < pp.length - 1; s++) {
2819
+ const a = pp[s], b = pp[s + 1];
2820
+ const atA = Math.abs(a.x - px) < 1 && Math.abs(a.y - py) < 1;
2821
+ const atB = Math.abs(b.x - px) < 1 && Math.abs(b.y - py) < 1;
2822
+ if (atA)
2823
+ set.add(dirFrom(a.x, a.y, b.x, b.y)); // segment leaves pk toward b
2824
+ else if (atB)
2825
+ set.add(dirFrom(b.x, b.y, a.x, a.y)); // toward a
2826
+ else { // pk strictly interior → the run passes through both ways
2827
+ const horiz = Math.abs(a.y - b.y) < 0.5;
2828
+ const through = horiz
2829
+ ? Math.abs(py - a.y) < 1 && px > Math.min(a.x, b.x) + 0.5 && px < Math.max(a.x, b.x) - 0.5
2830
+ : Math.abs(px - a.x) < 1 && py > Math.min(a.y, b.y) + 0.5 && py < Math.max(a.y, b.y) - 0.5;
2831
+ if (through) {
2832
+ set.add(dirFrom(px, py, a.x, a.y));
2833
+ set.add(dirFrom(px, py, b.x, b.y));
2834
+ }
2835
+ }
2836
+ }
2837
+ }
2838
+ if (set.size >= 3)
2839
+ addJunction(px, py);
2840
+ }
2841
+ }
2842
+ }
2843
+ // Node and port positions were already grid-snapped before routing. Snap only the
2844
+ // interior wire vertices to the grid here, leaving each wire's first/last point exact
2845
+ // so endpoints stay glued to their ports (notably OR inputs that tap the curve off-grid).
2846
+ for (const w of wires) {
2847
+ for (let i = 1; i < w.points.length - 1; i++) {
2848
+ w.points[i].x = Math.round(w.points[i].x / GRID) * GRID;
2849
+ w.points[i].y = Math.round(w.points[i].y / GRID) * GRID;
2850
+ }
2851
+ }
2852
+ for (const j of junctions) {
2853
+ j.x = Math.round(j.x / GRID) * GRID;
2854
+ j.y = Math.round(j.y / GRID) * GRID;
2855
+ }
2856
+ // Merge near-duplicate junction dots (e.g. two fan-out branches that peel off within a
2857
+ // few px of each other) so a split reads as one clean dot rather than a smudge.
2858
+ const MERGE_DIST = 8;
2859
+ const mergedJunctions = [];
2860
+ for (const j of junctions) {
2861
+ if (!mergedJunctions.some(m => Math.abs(m.x - j.x) <= MERGE_DIST && Math.abs(m.y - j.y) <= MERGE_DIST)) {
2862
+ mergedJunctions.push(j);
2863
+ }
2864
+ }
2865
+ // Post-routing net-label relocation. A consumed-intermediate label is placed just above its
2866
+ // driver's output BEFORE routing (and registered as a routing obstacle). That default spot can
2867
+ // still end up on a wire: a fan-out branch that turns UP to a consumer above the driver pierces
2868
+ // it, or a wide name reaches into the downstream block whose port the branch must enter (so the
2869
+ // router cannot avoid it). Wires are final here, and a label is a pure annotation, so relocating
2870
+ // it never disturbs a wire. For each label whose box overlaps a wire or a gate/block body, search
2871
+ // a grid of candidate boxes around its anchor and move it to the one with the fewest wire
2872
+ // crossings, then fewest body overlaps, then least displacement. Keep the current spot if nothing
2873
+ // scores better, so an already-clean label never moves.
2874
+ {
2875
+ const boxHitsWire = (x, y, w, h) => {
2876
+ let c = 0;
2877
+ for (const wire of wires)
2878
+ for (let i = 0; i < wire.points.length - 1; i++) {
2879
+ const a = wire.points[i], b = wire.points[i + 1];
2880
+ if (Math.max(a.x, b.x) > x + 0.5 && Math.min(a.x, b.x) < x + w - 0.5 &&
2881
+ Math.max(a.y, b.y) > y + 0.5 && Math.min(a.y, b.y) < y + h - 0.5)
2882
+ c++;
2883
+ }
2884
+ return c;
2885
+ };
2886
+ const boxHitsBody = (x, y, w, h) => {
2887
+ let c = 0;
2888
+ for (const n of layoutNodes) {
2889
+ if (n.gateType === 'INPUT' || n.gateType === 'OUTPUT')
2890
+ continue;
2891
+ if (x + w > n.absX + 0.5 && n.absX + n.width > x + 0.5 &&
2892
+ y + h > n.absY + 0.5 && n.absY + n.height > y + 0.5)
2893
+ c++;
2894
+ }
2895
+ return c;
2896
+ };
2897
+ const boxHitsLabel = (x, y, w, h, self) => {
2898
+ let c = 0;
2899
+ for (const o of labels) {
2900
+ if (o === self)
2901
+ continue;
2902
+ if (x + w > o.x + 0.5 && o.x + o.width > x + 0.5 && y + h > o.y + 0.5 && o.y + o.height > y + 0.5)
2903
+ c++;
2904
+ }
2905
+ return c;
2906
+ };
2907
+ // Nearest gap (0 if overlapping) between a label box and an axis-aligned wire segment, treating
2908
+ // the segment as its degenerate bounding rect.
2909
+ const boxSegDist = (x, y, w, h, a, b) => {
2910
+ const dx = Math.max(0, Math.max(x - Math.max(a.x, b.x), Math.min(a.x, b.x) - (x + w)));
2911
+ const dy = Math.max(0, Math.max(y - Math.max(a.y, b.y), Math.min(a.y, b.y) - (y + h)));
2912
+ return Math.hypot(dx, dy);
2913
+ };
2914
+ for (const lb of labels) {
2915
+ const { width: w, height: h, anchorX: ax, anchorY: ay } = lb;
2916
+ // The label NAMES its driver's output net, so it should sit RIGHT NEXT TO that net's wire.
2917
+ // Primary objective: clear of wires (the bug) and bodies; among clear spots, MINIMISE the
2918
+ // distance to the driver's own fan-out wire so the label hugs the signal it names (rather than
2919
+ // floating off to distant whitespace). A tiny bias keeps it above / on the output side only to
2920
+ // break ties between equally-close spots.
2921
+ // The net's full geometry: EVERY wire driven by this node (all fan-out branches share the
2922
+ // driver's id), so placement optimises against the whole net, not one branch. Its fan-out
2923
+ // JUNCTION dots (branch points that lie on those wires) are added as point-segments too — a
2924
+ // junction is the net's identity node and a natural leader attach point.
2925
+ const netWires = wires.filter(wr => wr.fromId === lb.driverId);
2926
+ const netSegs = [];
2927
+ for (const wr of netWires)
2928
+ for (let i = 0; i < wr.points.length - 1; i++)
2929
+ netSegs.push([wr.points[i], wr.points[i + 1]]);
2930
+ const onNet = (jx, jy) => netSegs.some(([a, b]) => Math.min(a.x, b.x) - 0.5 <= jx && jx <= Math.max(a.x, b.x) + 0.5 &&
2931
+ Math.min(a.y, b.y) - 0.5 <= jy && jy <= Math.max(a.y, b.y) + 0.5);
2932
+ for (const j of mergedJunctions)
2933
+ if (onNet(j.x, j.y))
2934
+ netSegs.push([{ x: j.x, y: j.y }, { x: j.x, y: j.y }]);
2935
+ const distToNet = (x, y) => {
2936
+ let d = Infinity;
2937
+ for (const [a, b] of netSegs)
2938
+ d = Math.min(d, boxSegDist(x, y, w, h, a, b));
2939
+ return Number.isFinite(d) ? d : Math.hypot(x - ax, y - ay);
2940
+ };
2941
+ // With a leader line the label should sit a readable distance OFF the net (so the connector is
2942
+ // visible and the text isn't jammed against the wire); without one, it should hug the net.
2943
+ const idealGap = opts.wireLabelLeader ? 16 : 0;
2944
+ const cost = (x, y) => {
2945
+ const tieBias = (x + w < ax - 0.5 ? 6 : 0) + (y > ay + 0.5 ? 3 : 0); // ≪ 1px of distance; ties only
2946
+ return boxHitsWire(x, y, w, h) * 100000 + boxHitsBody(x, y, w, h) * 3000 + boxHitsLabel(x, y, w, h, lb) * 2000 +
2947
+ Math.abs(distToNet(x, y) - idealGap) + tieBias;
2948
+ };
2949
+ let best = { x: lb.x, y: lb.y, c: cost(lb.x, lb.y) };
2950
+ const defaultClean = boxHitsWire(lb.x, lb.y, w, h) === 0 && boxHitsBody(lb.x, lb.y, w, h) === 0;
2951
+ // Relocate when the default overlaps, OR always under WIRE_LABEL_LEADER (to seat every label at
2952
+ // the readable gap its connector needs).
2953
+ if (!defaultClean || opts.wireLabelLeader) {
2954
+ // Dense grid around the anchor. Left edge sweeps from just left of the output to well PAST it:
2955
+ // the net is a shared fan-out, so a spot to the RIGHT of the junction (hugging a branch) is
2956
+ // valid too. distToNet then selects the clear spot nearest the net wire.
2957
+ for (let x = Math.round((ax - w - 20) / GRID) * GRID; x <= ax + 180; x += GRID) {
2958
+ for (let y = Math.round((ay - 200) / GRID) * GRID; y <= ay + 200; y += GRID) {
2959
+ if (x < 0 || y < 0)
2960
+ continue;
2961
+ const c = cost(x, y);
2962
+ if (c < best.c)
2963
+ best = { x, y, c };
2964
+ }
2965
+ }
2966
+ lb.x = best.x;
2967
+ lb.y = best.y;
2968
+ }
2969
+ // Leader target: the point on the net (any branch OR a junction, via netSegs) nearest the
2970
+ // (final) label box, so an optional leader line (OPTION WIRE_LABEL_LEADER) connects the label to
2971
+ // the signal it names. Clamp the label's centre onto each net segment and keep the closest.
2972
+ const cx = lb.x + w / 2, cy = lb.y + h / 2;
2973
+ let bd = Infinity;
2974
+ for (const [a, b] of netSegs) {
2975
+ const px = Math.max(Math.min(a.x, b.x), Math.min(Math.max(a.x, b.x), cx));
2976
+ const py = Math.max(Math.min(a.y, b.y), Math.min(Math.max(a.y, b.y), cy));
2977
+ const d = Math.hypot(px - cx, py - cy);
2978
+ if (d < bd) {
2979
+ bd = d;
2980
+ lb.leaderX = px;
2981
+ lb.leaderY = py;
2982
+ }
2983
+ }
2984
+ }
2985
+ }
2986
+ // Re-normalise vertical position: the alignment/collision passes can drift content downward
2987
+ // from the assigned coordinates, leaving empty space at the top. Shift everything uniformly
2988
+ // (preserving every relative position and wire shape) so the topmost content sits at PAD_Y.
2989
+ {
2990
+ let minY = Infinity;
2991
+ for (const n of layoutNodes)
2992
+ minY = Math.min(minY, n.absY);
2993
+ for (const w of wires)
2994
+ for (const p of w.points)
2995
+ minY = Math.min(minY, p.y);
2996
+ for (const l of labels)
2997
+ minY = Math.min(minY, l.y); // a label may sit above the topmost node
2998
+ const dy = PAD_Y - minY;
2999
+ if (Number.isFinite(dy) && Math.abs(dy) >= GRID) {
3000
+ for (const n of layoutNodes) {
3001
+ n.absY += dy;
3002
+ for (const p of n.inputs)
3003
+ p.absY += dy;
3004
+ for (const p of n.outputs)
3005
+ p.absY += dy;
3006
+ }
3007
+ for (const w of wires)
3008
+ for (const p of w.points)
3009
+ p.y += dy;
3010
+ for (const j of mergedJunctions)
3011
+ j.y += dy;
3012
+ for (const l of labels) {
3013
+ l.y += dy;
3014
+ l.anchorY += dy;
3015
+ if (l.leaderY !== undefined)
3016
+ l.leaderY += dy;
3017
+ } // labels are anchored to gates — shift with them
3018
+ }
3019
+ }
3020
+ // Collapse blank vertical bands between disconnected logic sections. A fully-empty horizontal band
3021
+ // (no node body, label, or wire — including verticals passing through) means the content above and
3022
+ // below it are not connected by any wire, so pulling the lower section up cannot distort a wire or
3023
+ // cause an overlap. Each such band wider than SECTION_GAP is reduced to SECTION_GAP, removing the
3024
+ // wasted space while keeping sections visually separated. Uniform per-section shift, so relative
3025
+ // geometry (and therefore every crossing/dogleg) is preserved.
3026
+ {
3027
+ const SECTION_GAP = 50;
3028
+ const occ = new Set();
3029
+ const mark = (y0, y1) => { for (let y = Math.floor(Math.min(y0, y1) / GRID) * GRID; y <= Math.max(y0, y1) + 0.5; y += GRID)
3030
+ occ.add(y); };
3031
+ for (const n of layoutNodes)
3032
+ mark(n.absY, n.absY + n.height);
3033
+ for (const l of labels)
3034
+ mark(l.y, l.y + l.height);
3035
+ for (const w of wires)
3036
+ for (let i = 0; i < w.points.length - 1; i++)
3037
+ mark(w.points[i].y, w.points[i + 1].y);
3038
+ const ys = [...occ].sort((a, b) => a - b);
3039
+ const shifts = [];
3040
+ for (let k = 1; k < ys.length; k++) {
3041
+ const emptySpan = ys[k] - ys[k - 1] - GRID; // clear cells strictly between two occupied rows
3042
+ if (emptySpan > SECTION_GAP)
3043
+ shifts.push({ fromY: ys[k] - 0.5, amount: emptySpan - SECTION_GAP });
3044
+ }
3045
+ if (shifts.length) {
3046
+ const shiftFor = (y) => shifts.reduce((s, sh) => s + (y >= sh.fromY ? sh.amount : 0), 0);
3047
+ for (const n of layoutNodes) {
3048
+ const dy = shiftFor(n.absY);
3049
+ if (!dy)
3050
+ continue;
3051
+ n.absY -= dy;
3052
+ for (const p of n.inputs)
3053
+ p.absY -= dy;
3054
+ for (const p of n.outputs)
3055
+ p.absY -= dy;
3056
+ }
3057
+ for (const w of wires)
3058
+ for (const p of w.points)
3059
+ p.y -= shiftFor(p.y);
3060
+ for (const j of mergedJunctions)
3061
+ j.y -= shiftFor(j.y);
3062
+ for (const l of labels) {
3063
+ const dy = shiftFor(l.y);
3064
+ l.y -= dy;
3065
+ l.anchorY -= dy;
3066
+ if (l.leaderY !== undefined)
3067
+ l.leaderY -= dy;
3068
+ }
3069
+ }
3070
+ }
3071
+ const maxX = Math.max(...layoutNodes.map(n => n.absX + n.width), ...wires.flatMap(w => w.points.map(p => p.x)), ...labels.map(l => l.x + l.width));
3072
+ const maxY = Math.max(...layoutNodes.map(n => n.absY + n.height), ...wires.flatMap(w => w.points.map(p => p.y)), ...labels.map(l => l.y + l.height));
3073
+ return {
3074
+ nodes: layoutNodes,
3075
+ wires,
3076
+ junctions: mergedJunctions,
3077
+ labels,
3078
+ width: maxX,
3079
+ height: maxY,
3080
+ options: opts,
3081
+ };
3082
+ }
3083
+ export function findWireCrossings(wires, junctions) {
3084
+ const crossings = [];
3085
+ const junctionSet = new Set(junctions.map(j => `${Math.round(j.x)},${Math.round(j.y)}`));
3086
+ for (let i = 0; i < wires.length; i++) {
3087
+ for (let j = i + 1; j < wires.length; j++) {
3088
+ if (wires[i].fromId === wires[j].fromId)
3089
+ continue;
3090
+ if (wires[i].feedback || wires[j].feedback)
3091
+ continue; // loop-backs run in their own lane
3092
+ for (let si = 0; si < wires[i].points.length - 1; si++) {
3093
+ for (let sj = 0; sj < wires[j].points.length - 1; sj++) {
3094
+ const p1 = wires[i].points[si], p2 = wires[i].points[si + 1];
3095
+ const q1 = wires[j].points[sj], q2 = wires[j].points[sj + 1];
3096
+ // Test a horizontal segment against a vertical segment in EITHER orientation
3097
+ // (i-horiz×j-vert and i-vert×j-horiz), so crossings are caught regardless of
3098
+ // which wire happens to come first in the list.
3099
+ const cross = (h1, h2, v1, v2) => {
3100
+ if (Math.abs(h1.y - h2.y) >= 1 || Math.abs(v1.x - v2.x) >= 1)
3101
+ return null;
3102
+ const y = h1.y, x = v1.x;
3103
+ const yMin = Math.min(v1.y, v2.y), yMax = Math.max(v1.y, v2.y);
3104
+ const xMin = Math.min(h1.x, h2.x), xMax = Math.max(h1.x, h2.x);
3105
+ return (y >= yMin - 1 && y <= yMax + 1 && x >= xMin - 1 && x <= xMax + 1) ? { x, y } : null;
3106
+ };
3107
+ const hit = cross(p1, p2, q1, q2) ?? cross(q1, q2, p1, p2);
3108
+ if (hit && !junctionSet.has(`${Math.round(hit.x)},${Math.round(hit.y)}`)) {
3109
+ crossings.push({
3110
+ wire1From: wires[i].fromId, wire1To: wires[i].toId,
3111
+ wire2From: wires[j].fromId, wire2To: wires[j].toId,
3112
+ x: Math.round(hit.x), y: Math.round(hit.y),
3113
+ });
3114
+ }
3115
+ }
3116
+ }
3117
+ }
3118
+ }
3119
+ return crossings;
3120
+ }
3121
+ export { MIN_PORT_GAP, MIN_DOGLEG };