grid-router 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.
package/src/router.ts ADDED
@@ -0,0 +1,519 @@
1
+ /**
2
+ * Orthogonal edge router on a grid.
3
+ *
4
+ * Rasterizes a canvas into square cells and routes every edge with A*:
5
+ *
6
+ * - node rects ("boxes", inflated) hard-block cells;
7
+ * - per-direction occupancy: a cell can host ONE horizontal and ONE vertical
8
+ * run — another bus reusing the same direction pays a near-prohibitive
9
+ * penalty (soft block, so routing never fails), while perpendicular
10
+ * crossings only pay a small toll → lanes separate themselves and crossings
11
+ * happen only where they're worth it;
12
+ * - same-bus discount + tree seeding: a bus's later edges branch from the
13
+ * OPTIMAL point of the already-routed trunk (Steiner-style joins), and
14
+ * `exitCost` prices opening a new trunk out of a source, so merges can win
15
+ * even over shorter direct paths;
16
+ * - turn penalty biases toward long straight runs;
17
+ * - endpoints are exclusive per bus (no stacked stubs/arrowheads);
18
+ * - an edge is NEVER dropped: when a node row walls the grid off, a desperate
19
+ * pass routes through obstacles at a huge cost and reports a violation.
20
+ *
21
+ * Pure geometry: the caller measures its DOM into {@link GridBox}es and
22
+ * renders the returned SVG path strings. Framework-free.
23
+ */
24
+
25
+ export interface GridBox {
26
+ t: number;
27
+ b: number;
28
+ l: number;
29
+ r: number;
30
+ cx: number;
31
+ cy: number;
32
+ }
33
+
34
+ export interface GridEdge {
35
+ id: string;
36
+ source: string;
37
+ target: string;
38
+ /** Bus discriminator: edges sharing (source, bus) merge into one trunk
39
+ * tree. Defaults to the source alone. */
40
+ bus?: string;
41
+ /** Opaque consumer payload, passed through untouched onto the conn. */
42
+ data?: unknown;
43
+ }
44
+
45
+ export interface GridConn {
46
+ id: string;
47
+ source: string;
48
+ target: string;
49
+ /** Full bus key (`source|bus`) — stable grouping handle for rendering
50
+ * (e.g. crossing-hop halos are painted per bus). */
51
+ bus: string;
52
+ data?: unknown;
53
+ /** Orthogonal SVG path: source contact → routed cells → target contact. */
54
+ d: string;
55
+ }
56
+
57
+ export interface GridDebugCell {
58
+ x: number;
59
+ y: number;
60
+ kind: 'chip' | 'h' | 'v' | 'hv' | 'bad';
61
+ }
62
+
63
+ export interface GridResult {
64
+ conns: GridConn[];
65
+ /** deepest y any path reaches (callers reserve layout height for it) */
66
+ bottom: number;
67
+ /** lane-sharing incidents (should be 0 — non-zero means the layout does
68
+ * not supply enough corridor space, see corridorGaps()) */
69
+ violations: number;
70
+ debug: { res: number; cols: number; rows: number; cells: GridDebugCell[] };
71
+ }
72
+
73
+ // Costs are in "cells". Tuning notes: TURN < the detour a straighter but
74
+ // longer route would take; CROSS small enough that crossing beats detouring
75
+ // around a whole row; OVERLAP practically prohibitive — any detour that
76
+ // exists at all must win, and a path that still pays it had NO alternative
77
+ // (a corridor-supply problem, reported as a violation).
78
+ const COST_STEP = 1;
79
+ const COST_CROSS = 0.4;
80
+ const COST_OVERLAP = 4000;
81
+ const GAP = 3; // endpoint stub clearance off the box edge
82
+
83
+ /** Box inflation in px: routed runs keep this clearance off every box. */
84
+ export const BOX_INFLATE = 3;
85
+
86
+ export interface GridOpts {
87
+ /** grid cell size in px (default 12) */
88
+ res?: number;
89
+ /** cost per cell when riding cells the bus already owns (default 0.02) */
90
+ costOwn?: number;
91
+ /** penalty per 90° turn (default 1) */
92
+ costTurn?: number;
93
+ /** penalty for opening a NEW trunk out of a source (default 0) — raise it
94
+ * to make a bus's edges merge even when direct paths would be shorter */
95
+ exitCost?: number;
96
+ /**
97
+ * Cost layers: arbitrary rects (px) adding a per-cell bias to every step
98
+ * inside them — positive repels routes, negative attracts them. This is
99
+ * the generic hook for consumer concepts the router deliberately does not
100
+ * know about (e.g. a "lanes" layer biasing corridors negative, keep-out
101
+ * zones, preferred highways). The effective step cost is floored so
102
+ * negative biases can't break A*.
103
+ */
104
+ costRegions?: { l: number; t: number; r: number; b: number; bias: number }[];
105
+ /**
106
+ * Port constraints: restrict which box sides edges may attach to, per node
107
+ * id (schematic semantics — a horizontal resistor only connects via its
108
+ * left/right leads, an IC via its pin sides). Unlisted nodes accept all
109
+ * four sides. The desperate fallback relaxes the constraint rather than
110
+ * dropping the edge.
111
+ */
112
+ nodeSides?: Record<string, BoxSide[]>;
113
+ }
114
+
115
+ export type BoxSide = 'top' | 'bottom' | 'left' | 'right';
116
+
117
+ export function routeGrid(
118
+ boxes: Map<string, GridBox>,
119
+ edges: GridEdge[],
120
+ width: number,
121
+ height: number,
122
+ opts: GridOpts = {}
123
+ ): GridResult {
124
+ const res = opts.res ?? 12;
125
+ const costOwn = opts.costOwn ?? 0.02;
126
+ const costTurn = opts.costTurn ?? 1;
127
+ const exitCost = opts.exitCost ?? 0;
128
+ const cols = Math.max(2, Math.ceil(width / res));
129
+ const rows = Math.max(2, Math.ceil(height / res));
130
+ const N = cols * rows;
131
+ const blocked = new Uint8Array(N);
132
+ const usageH = new Int32Array(N).fill(-1);
133
+ const usageV = new Int32Array(N).fill(-1);
134
+ // Path endpoints are EXCLUSIVE per bus: two buses sharing a start/goal cell
135
+ // (one entering it vertically, one horizontally) would stack their stubs and
136
+ // arrowheads on the same spot at the box edge.
137
+ const endpointOwner = new Int32Array(N).fill(-1);
138
+ // Consumer cost layers rasterized to a per-cell bias.
139
+ const bias = new Float64Array(N);
140
+ for (const rg of opts.costRegions ?? []) {
141
+ const x0 = Math.max(0, Math.floor(rg.l / res));
142
+ const x1 = Math.min(cols - 1, Math.floor(rg.r / res));
143
+ const y0 = Math.max(0, Math.floor(rg.t / res));
144
+ const y1 = Math.min(rows - 1, Math.floor(rg.b / res));
145
+ for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) bias[y * cols + x] += rg.bias;
146
+ }
147
+
148
+ const centerOf = (i: number) => ({
149
+ x: ((i % cols) + 0.5) * res,
150
+ y: (Math.floor(i / cols) + 0.5) * res
151
+ });
152
+
153
+ // --- block box cells (inflated so runs keep visual clearance) ---
154
+ for (const b of boxes.values()) {
155
+ const x0 = Math.max(0, Math.floor((b.l - BOX_INFLATE) / res));
156
+ const x1 = Math.min(cols - 1, Math.floor((b.r + BOX_INFLATE) / res));
157
+ const y0 = Math.max(0, Math.floor((b.t - BOX_INFLATE) / res));
158
+ const y1 = Math.min(rows - 1, Math.floor((b.b + BOX_INFLATE) / res));
159
+ for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) blocked[y * cols + x] = 1;
160
+ }
161
+
162
+ /** Ring of free cells just outside a box, CORNERS EXCLUDED: cells strictly
163
+ * over the box's horizontal span connect vertically, cells strictly beside
164
+ * its vertical span connect horizontally — so a path can never end with a
165
+ * sideways segment kissing a box corner. `includeBlocked` is the desperate
166
+ * mode: a fully sealed box still needs endpoints. */
167
+ function ring(
168
+ b: GridBox,
169
+ includeBlocked = false,
170
+ sides?: BoxSide[]
171
+ ): { cell: number; dir: 0 | 1 }[] {
172
+ const out: { cell: number; dir: 0 | 1 }[] = [];
173
+ const ok = (i: number) => includeBlocked || !blocked[i];
174
+ const allow = (s: BoxSide) => !sides || sides.includes(s);
175
+ // Small-box tolerance: a box narrower than two cells has no strict span —
176
+ // fall back to the single column/row nearest its center, so tiny nodes
177
+ // (consumer "port" pads) still get endpoints.
178
+ const xs: number[] = [];
179
+ for (let x = Math.max(0, Math.ceil(b.l / res)); x <= Math.floor(b.r / res) - 1; x++) {
180
+ if (x <= cols - 1) xs.push(x);
181
+ }
182
+ if (!xs.length) xs.push(Math.min(cols - 1, Math.max(0, Math.floor(b.cx / res))));
183
+ const yTop = Math.floor((b.t - BOX_INFLATE) / res) - 1;
184
+ const yBot = Math.floor((b.b + BOX_INFLATE) / res) + 1;
185
+ for (const x of xs) {
186
+ if (allow('top') && yTop >= 0 && ok(yTop * cols + x)) {
187
+ out.push({ cell: yTop * cols + x, dir: 1 });
188
+ }
189
+ if (allow('bottom') && yBot <= rows - 1 && ok(yBot * cols + x)) {
190
+ out.push({ cell: yBot * cols + x, dir: 1 });
191
+ }
192
+ }
193
+ const ys: number[] = [];
194
+ for (let y = Math.max(0, Math.ceil(b.t / res)); y <= Math.floor(b.b / res) - 1; y++) {
195
+ if (y <= rows - 1) ys.push(y);
196
+ }
197
+ if (!ys.length) ys.push(Math.min(rows - 1, Math.max(0, Math.floor(b.cy / res))));
198
+ const xL = Math.floor((b.l - BOX_INFLATE) / res) - 1;
199
+ const xR = Math.floor((b.r + BOX_INFLATE) / res) + 1;
200
+ for (const y of ys) {
201
+ if (allow('left') && xL >= 0 && ok(y * cols + xL)) out.push({ cell: y * cols + xL, dir: 0 });
202
+ if (allow('right') && xR <= cols - 1 && ok(y * cols + xR)) {
203
+ out.push({ cell: y * cols + xR, dir: 0 });
204
+ }
205
+ }
206
+ return out;
207
+ }
208
+
209
+ // --- group edges into buses ---
210
+ interface Bus {
211
+ key: string;
212
+ idx: number;
213
+ items: GridEdge[];
214
+ own: Set<number>; // cell*2+dir claimed by this bus
215
+ /** routed tree: cell → previous cell toward the source (trunk parent) */
216
+ prev: Map<number, number>;
217
+ }
218
+ const busMap = new Map<string, Bus>();
219
+ for (const e of edges) {
220
+ if (!boxes.has(e.source) || !boxes.has(e.target)) continue;
221
+ const key = `${e.source}|${e.bus ?? ''}`;
222
+ let bus = busMap.get(key);
223
+ if (!bus) {
224
+ bus = { key, idx: busMap.size, items: [], own: new Set(), prev: new Map() };
225
+ busMap.set(key, bus);
226
+ }
227
+ bus.items.push(e);
228
+ }
229
+ // Bigger fans first: they claim their trunks before loners squeeze through.
230
+ const buses = [...busMap.values()].sort((a, b) => b.items.length - a.items.length);
231
+
232
+ // --- A* over (cell, dir) states ---
233
+ // Tiny binary heap on f; lazy decrease (re-push, skip stale pops).
234
+ function astar(
235
+ bus: Bus,
236
+ starts: { cell: number; dir: 0 | 1; g0: number }[],
237
+ goals: Set<number>,
238
+ /** desperate mode: blocked cells become traversable at a huge cost so a
239
+ * path ALWAYS exists — silently missing edges is worse than an ugly one */
240
+ desperate = false
241
+ ): { cell: number; dir: 0 | 1 }[] | null {
242
+ const S = N * 2;
243
+ const g = new Float64Array(S).fill(Infinity);
244
+ const parent = new Int32Array(S).fill(-1);
245
+ const closed = new Uint8Array(S);
246
+ // goal heuristic anchor: mean of goal cells
247
+ let gx = 0;
248
+ let gy = 0;
249
+ for (const c of goals) {
250
+ gx += c % cols;
251
+ gy += Math.floor(c / cols);
252
+ }
253
+ gx /= goals.size || 1;
254
+ gy /= goals.size || 1;
255
+ const h = (cell: number) =>
256
+ Math.abs((cell % cols) - gx) + Math.abs(Math.floor(cell / cols) - gy);
257
+
258
+ type Entry = { f: number; s: number };
259
+ const heap: Entry[] = [];
260
+ const push = (e: Entry) => {
261
+ heap.push(e);
262
+ let i = heap.length - 1;
263
+ while (i > 0) {
264
+ const p = (i - 1) >> 1;
265
+ if (heap[p].f <= heap[i].f) break;
266
+ [heap[p], heap[i]] = [heap[i], heap[p]];
267
+ i = p;
268
+ }
269
+ };
270
+ const pop = (): Entry | undefined => {
271
+ const top = heap[0];
272
+ const last = heap.pop();
273
+ if (heap.length && last) {
274
+ heap[0] = last;
275
+ let i = 0;
276
+ for (;;) {
277
+ const l = 2 * i + 1;
278
+ const r = l + 1;
279
+ let m = i;
280
+ if (l < heap.length && heap[l].f < heap[m].f) m = l;
281
+ if (r < heap.length && heap[r].f < heap[m].f) m = r;
282
+ if (m === i) break;
283
+ [heap[m], heap[i]] = [heap[i], heap[m]];
284
+ i = m;
285
+ }
286
+ }
287
+ return top;
288
+ };
289
+
290
+ for (const st of starts) {
291
+ const s = st.cell * 2 + st.dir;
292
+ if (g[s] > st.g0) {
293
+ g[s] = st.g0;
294
+ push({ f: st.g0 + h(st.cell), s });
295
+ }
296
+ }
297
+
298
+ // A step covers HALF of each of its two cells — price the worst of both,
299
+ // or an elbow's outgoing half would be free to overlap.
300
+ const halfCost = (cell: number, dir: 0 | 1) => {
301
+ const same = dir === 0 ? usageH[cell] : usageV[cell];
302
+ if (same === bus.idx || bus.own.has(cell * 2 + dir)) return costOwn;
303
+ if (same !== -1) return COST_OVERLAP;
304
+ return COST_STEP;
305
+ };
306
+ const stepCost = (from: number, next: number, dir: 0 | 1) => {
307
+ // floored so negative cost-layer biases can't break A* (no negative steps)
308
+ let c = Math.max(0.05, Math.max(halfCost(from, dir), halfCost(next, dir)) + bias[next]);
309
+ const cross = dir === 0 ? usageV[next] : usageH[next];
310
+ if (cross !== -1 && cross !== bus.idx) c += COST_CROSS;
311
+ return c;
312
+ };
313
+
314
+ let goalState = -1;
315
+ while (heap.length) {
316
+ const cur = pop()!;
317
+ const s = cur.s;
318
+ if (closed[s]) continue;
319
+ closed[s] = 1;
320
+ const cell = s >> 1;
321
+ const dir = (s & 1) as 0 | 1;
322
+ if (goals.has(cell)) {
323
+ goalState = s;
324
+ break;
325
+ }
326
+ const x = cell % cols;
327
+ const y = (cell - x) / cols;
328
+ const neigh: { cell: number; dir: 0 | 1 }[] = [];
329
+ if (x > 0) neigh.push({ cell: cell - 1, dir: 0 });
330
+ if (x < cols - 1) neigh.push({ cell: cell + 1, dir: 0 });
331
+ if (y > 0) neigh.push({ cell: cell - cols, dir: 1 });
332
+ if (y < rows - 1) neigh.push({ cell: cell + cols, dir: 1 });
333
+ for (const nb of neigh) {
334
+ if (blocked[nb.cell] && !desperate) continue;
335
+ const ns = nb.cell * 2 + nb.dir;
336
+ if (closed[ns]) continue;
337
+ const cost =
338
+ stepCost(cell, nb.cell, nb.dir) +
339
+ (nb.dir === dir ? 0 : costTurn) +
340
+ (blocked[nb.cell] ? 800 : 0);
341
+ const ng = g[s] + cost;
342
+ if (ng < g[ns]) {
343
+ g[ns] = ng;
344
+ parent[ns] = s;
345
+ push({ f: ng + h(nb.cell), s: ns });
346
+ }
347
+ }
348
+ }
349
+ if (goalState < 0) return null;
350
+ const path: { cell: number; dir: 0 | 1 }[] = [];
351
+ for (let s = goalState; s >= 0; s = parent[s]) {
352
+ path.push({ cell: s >> 1, dir: (s & 1) as 0 | 1 });
353
+ }
354
+ path.reverse();
355
+ return path;
356
+ }
357
+
358
+ /** Contact point on a box edge for the path end at ring cell `i`. Stays
359
+ * aligned with the cell CENTER (corner-free rings guarantee it lies over
360
+ * the edge), so the stub is colinear with the routed run — never nudge the
361
+ * routed geometry instead: that would shift a run off its claimed lane. */
362
+ function contact(b: GridBox, i: number): { x: number; y: number } {
363
+ const c = centerOf(i);
364
+ if (c.y < b.t) return { x: Math.min(Math.max(c.x, b.l + 2), b.r - 2), y: b.t - GAP };
365
+ if (c.y > b.b) return { x: Math.min(Math.max(c.x, b.l + 2), b.r - 2), y: b.b + GAP };
366
+ if (c.x < b.l) return { x: b.l - GAP, y: Math.min(Math.max(c.y, b.t + 2), b.b - 2) };
367
+ return { x: b.r + GAP, y: Math.min(Math.max(c.y, b.t + 2), b.b - 2) };
368
+ }
369
+
370
+ const conns: GridConn[] = [];
371
+ let bottom = 0;
372
+ let violations = 0;
373
+ const badCells = new Set<number>();
374
+
375
+ for (const bus of buses) {
376
+ const s = boxes.get(bus.items[0].source)!;
377
+ // Route nearest targets first so the trunk grows outward and later
378
+ // edges find own-bus cells to ride.
379
+ const items = [...bus.items].sort((a, b) => {
380
+ const ta = boxes.get(a.target)!;
381
+ const tb = boxes.get(b.target)!;
382
+ return (
383
+ Math.abs(ta.cy - s.cy) + Math.abs(ta.cx - s.cx) -
384
+ (Math.abs(tb.cy - s.cy) + Math.abs(tb.cx - s.cx))
385
+ );
386
+ });
387
+ for (const e of items) {
388
+ const t = boxes.get(e.target)!;
389
+ // Seeds: the source's ring (opening a NEW trunk costs exitCost) PLUS
390
+ // every cell the bus already routed at cost 0 — a later edge branches
391
+ // from the OPTIMAL point of the existing trunk (Steiner-style join).
392
+ // Endpoint cells owned by other buses are off limits (stacked stubs).
393
+ const sSides = opts.nodeSides?.[e.source];
394
+ const tSides = opts.nodeSides?.[e.target];
395
+ const starts = ring(s, false, sSides)
396
+ .filter((r) => endpointOwner[r.cell] === -1 || endpointOwner[r.cell] === bus.idx)
397
+ .map((r) => ({ ...r, g0: exitCost }));
398
+ for (const od of bus.own) {
399
+ starts.push({ cell: od >> 1, dir: (od & 1) as 0 | 1, g0: 0 });
400
+ }
401
+ const goals = new Set(
402
+ ring(t, false, tSides)
403
+ .map((r) => r.cell)
404
+ .filter((c) => endpointOwner[c] === -1 || endpointOwner[c] === bus.idx)
405
+ );
406
+ let path = starts.length && goals.size ? astar(bus, starts, goals) : null;
407
+ if (!path) {
408
+ // No legal route (a dense node row can wall the grid off at coarse
409
+ // cell sizes, or port constraints seal a node in). NEVER drop the
410
+ // edge silently: retry with blocked cells traversable at a huge
411
+ // cost — and if even that fails, relax the port constraints too.
412
+ const dStarts = ring(s, true, sSides).map((r) => ({ ...r, g0: exitCost }));
413
+ for (const od of bus.own) {
414
+ dStarts.push({ cell: od >> 1, dir: (od & 1) as 0 | 1, g0: 0 });
415
+ }
416
+ const dGoals = new Set(ring(t, true, tSides).map((r) => r.cell));
417
+ path = dStarts.length && dGoals.size ? astar(bus, dStarts, dGoals, true) : null;
418
+ if (!path && (sSides || tSides)) {
419
+ const uStarts = ring(s, true).map((r) => ({ ...r, g0: exitCost }));
420
+ for (const od of bus.own) {
421
+ uStarts.push({ cell: od >> 1, dir: (od & 1) as 0 | 1, g0: 0 });
422
+ }
423
+ const uGoals = new Set(ring(t, true).map((r) => r.cell));
424
+ path = uStarts.length && uGoals.size ? astar(bus, uStarts, uGoals, true) : null;
425
+ }
426
+ if (!path) continue;
427
+ violations++;
428
+ badCells.add(path[Math.floor(path.length / 2)].cell);
429
+ }
430
+
431
+ // Claim per STEP on BOTH cells (an elbow claims its outgoing direction
432
+ // too) + extend the bus tree (first writer keeps the link).
433
+ for (let i = 1; i < path.length; i++) {
434
+ const dir = path[i].dir;
435
+ for (const cell of [path[i - 1].cell, path[i].cell]) {
436
+ const cur = dir === 0 ? usageH[cell] : usageV[cell];
437
+ if (cur !== -1 && cur !== bus.idx && !bus.own.has(cell * 2 + dir)) {
438
+ violations++;
439
+ badCells.add(cell);
440
+ }
441
+ if (dir === 0) {
442
+ if (usageH[cell] === -1) usageH[cell] = bus.idx;
443
+ } else if (usageV[cell] === -1) usageV[cell] = bus.idx;
444
+ bus.own.add(cell * 2 + dir);
445
+ }
446
+ if (!bus.prev.has(path[i].cell) && path[i - 1].cell !== path[i].cell) {
447
+ bus.prev.set(path[i].cell, path[i - 1].cell);
448
+ }
449
+ }
450
+ if (endpointOwner[path[0].cell] === -1) endpointOwner[path[0].cell] = bus.idx;
451
+ const goalCell = path[path.length - 1].cell;
452
+ if (endpointOwner[goalCell] === -1) endpointOwner[goalCell] = bus.idx;
453
+
454
+ // Full source→target chain: if the A* seed was a mid-trunk cell, walk
455
+ // the bus tree back to the source ring and prepend that trunk prefix.
456
+ const chain: number[] = [];
457
+ {
458
+ let c = path[0].cell;
459
+ const guard = new Set<number>();
460
+ while (bus.prev.has(c) && !guard.has(c)) {
461
+ guard.add(c);
462
+ c = bus.prev.get(c)!;
463
+ chain.push(c);
464
+ }
465
+ chain.reverse();
466
+ }
467
+ const fullCells = [...chain, ...path.map((p) => p.cell)];
468
+
469
+ // polyline: source contact → cell centers (collinear-compressed) → target contact
470
+ const pts: { x: number; y: number }[] = [];
471
+ const first = contact(s, fullCells[0]);
472
+ const last = contact(t, fullCells[fullCells.length - 1]);
473
+ pts.push(first);
474
+ for (const c of fullCells) pts.push(centerOf(c));
475
+ pts.push(last);
476
+ const simp: { x: number; y: number }[] = [];
477
+ for (const p of pts) {
478
+ const n = simp.length;
479
+ if (n >= 2) {
480
+ const a = simp[n - 2];
481
+ const b3 = simp[n - 1];
482
+ if ((a.x === b3.x && b3.x === p.x) || (a.y === b3.y && b3.y === p.y)) {
483
+ simp[n - 1] = p;
484
+ continue;
485
+ }
486
+ }
487
+ simp.push(p);
488
+ }
489
+ // force pure orthogonal corners (cell centers already are; stubs may
490
+ // introduce tiny diagonals — insert a corner point when needed)
491
+ const ortho: { x: number; y: number }[] = [simp[0]];
492
+ for (let i = 1; i < simp.length; i++) {
493
+ const a = ortho[ortho.length - 1];
494
+ const b4 = simp[i];
495
+ if (a.x !== b4.x && a.y !== b4.y) ortho.push({ x: b4.x, y: a.y });
496
+ ortho.push(b4);
497
+ }
498
+ const d = ortho
499
+ .map((p, i) => `${i === 0 ? 'M' : 'L'} ${Math.round(p.x * 2) / 2} ${Math.round(p.y * 2) / 2}`)
500
+ .join(' ');
501
+ for (const p of ortho) bottom = Math.max(bottom, p.y);
502
+ conns.push({ id: `e:${e.id}`, source: e.source, target: e.target, bus: bus.key, data: e.data, d });
503
+ }
504
+ }
505
+
506
+ // --- debug cells ---
507
+ const cells: GridDebugCell[] = [];
508
+ for (let i = 0; i < N; i++) {
509
+ const x = i % cols;
510
+ const y = (i - x) / cols;
511
+ if (badCells.has(i)) cells.push({ x, y, kind: 'bad' });
512
+ else if (blocked[i]) cells.push({ x, y, kind: 'chip' });
513
+ else if (usageH[i] !== -1 && usageV[i] !== -1) cells.push({ x, y, kind: 'hv' });
514
+ else if (usageH[i] !== -1) cells.push({ x, y, kind: 'h' });
515
+ else if (usageV[i] !== -1) cells.push({ x, y, kind: 'v' });
516
+ }
517
+
518
+ return { conns, bottom, violations, debug: { res, cols, rows, cells } };
519
+ }