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/dist/router.js ADDED
@@ -0,0 +1,432 @@
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
+ // Costs are in "cells". Tuning notes: TURN < the detour a straighter but
25
+ // longer route would take; CROSS small enough that crossing beats detouring
26
+ // around a whole row; OVERLAP practically prohibitive — any detour that
27
+ // exists at all must win, and a path that still pays it had NO alternative
28
+ // (a corridor-supply problem, reported as a violation).
29
+ const COST_STEP = 1;
30
+ const COST_CROSS = 0.4;
31
+ const COST_OVERLAP = 4000;
32
+ const GAP = 3; // endpoint stub clearance off the box edge
33
+ /** Box inflation in px: routed runs keep this clearance off every box. */
34
+ export const BOX_INFLATE = 3;
35
+ export function routeGrid(boxes, edges, width, height, opts = {}) {
36
+ const res = opts.res ?? 12;
37
+ const costOwn = opts.costOwn ?? 0.02;
38
+ const costTurn = opts.costTurn ?? 1;
39
+ const exitCost = opts.exitCost ?? 0;
40
+ const cols = Math.max(2, Math.ceil(width / res));
41
+ const rows = Math.max(2, Math.ceil(height / res));
42
+ const N = cols * rows;
43
+ const blocked = new Uint8Array(N);
44
+ const usageH = new Int32Array(N).fill(-1);
45
+ const usageV = new Int32Array(N).fill(-1);
46
+ // Path endpoints are EXCLUSIVE per bus: two buses sharing a start/goal cell
47
+ // (one entering it vertically, one horizontally) would stack their stubs and
48
+ // arrowheads on the same spot at the box edge.
49
+ const endpointOwner = new Int32Array(N).fill(-1);
50
+ // Consumer cost layers rasterized to a per-cell bias.
51
+ const bias = new Float64Array(N);
52
+ for (const rg of opts.costRegions ?? []) {
53
+ const x0 = Math.max(0, Math.floor(rg.l / res));
54
+ const x1 = Math.min(cols - 1, Math.floor(rg.r / res));
55
+ const y0 = Math.max(0, Math.floor(rg.t / res));
56
+ const y1 = Math.min(rows - 1, Math.floor(rg.b / res));
57
+ for (let y = y0; y <= y1; y++)
58
+ for (let x = x0; x <= x1; x++)
59
+ bias[y * cols + x] += rg.bias;
60
+ }
61
+ const centerOf = (i) => ({
62
+ x: ((i % cols) + 0.5) * res,
63
+ y: (Math.floor(i / cols) + 0.5) * res
64
+ });
65
+ // --- block box cells (inflated so runs keep visual clearance) ---
66
+ for (const b of boxes.values()) {
67
+ const x0 = Math.max(0, Math.floor((b.l - BOX_INFLATE) / res));
68
+ const x1 = Math.min(cols - 1, Math.floor((b.r + BOX_INFLATE) / res));
69
+ const y0 = Math.max(0, Math.floor((b.t - BOX_INFLATE) / res));
70
+ const y1 = Math.min(rows - 1, Math.floor((b.b + BOX_INFLATE) / res));
71
+ for (let y = y0; y <= y1; y++)
72
+ for (let x = x0; x <= x1; x++)
73
+ blocked[y * cols + x] = 1;
74
+ }
75
+ /** Ring of free cells just outside a box, CORNERS EXCLUDED: cells strictly
76
+ * over the box's horizontal span connect vertically, cells strictly beside
77
+ * its vertical span connect horizontally — so a path can never end with a
78
+ * sideways segment kissing a box corner. `includeBlocked` is the desperate
79
+ * mode: a fully sealed box still needs endpoints. */
80
+ function ring(b, includeBlocked = false, sides) {
81
+ const out = [];
82
+ const ok = (i) => includeBlocked || !blocked[i];
83
+ const allow = (s) => !sides || sides.includes(s);
84
+ // Small-box tolerance: a box narrower than two cells has no strict span —
85
+ // fall back to the single column/row nearest its center, so tiny nodes
86
+ // (consumer "port" pads) still get endpoints.
87
+ const xs = [];
88
+ for (let x = Math.max(0, Math.ceil(b.l / res)); x <= Math.floor(b.r / res) - 1; x++) {
89
+ if (x <= cols - 1)
90
+ xs.push(x);
91
+ }
92
+ if (!xs.length)
93
+ xs.push(Math.min(cols - 1, Math.max(0, Math.floor(b.cx / res))));
94
+ const yTop = Math.floor((b.t - BOX_INFLATE) / res) - 1;
95
+ const yBot = Math.floor((b.b + BOX_INFLATE) / res) + 1;
96
+ for (const x of xs) {
97
+ if (allow('top') && yTop >= 0 && ok(yTop * cols + x)) {
98
+ out.push({ cell: yTop * cols + x, dir: 1 });
99
+ }
100
+ if (allow('bottom') && yBot <= rows - 1 && ok(yBot * cols + x)) {
101
+ out.push({ cell: yBot * cols + x, dir: 1 });
102
+ }
103
+ }
104
+ const ys = [];
105
+ for (let y = Math.max(0, Math.ceil(b.t / res)); y <= Math.floor(b.b / res) - 1; y++) {
106
+ if (y <= rows - 1)
107
+ ys.push(y);
108
+ }
109
+ if (!ys.length)
110
+ ys.push(Math.min(rows - 1, Math.max(0, Math.floor(b.cy / res))));
111
+ const xL = Math.floor((b.l - BOX_INFLATE) / res) - 1;
112
+ const xR = Math.floor((b.r + BOX_INFLATE) / res) + 1;
113
+ for (const y of ys) {
114
+ if (allow('left') && xL >= 0 && ok(y * cols + xL))
115
+ out.push({ cell: y * cols + xL, dir: 0 });
116
+ if (allow('right') && xR <= cols - 1 && ok(y * cols + xR)) {
117
+ out.push({ cell: y * cols + xR, dir: 0 });
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+ const busMap = new Map();
123
+ for (const e of edges) {
124
+ if (!boxes.has(e.source) || !boxes.has(e.target))
125
+ continue;
126
+ const key = `${e.source}|${e.bus ?? ''}`;
127
+ let bus = busMap.get(key);
128
+ if (!bus) {
129
+ bus = { key, idx: busMap.size, items: [], own: new Set(), prev: new Map() };
130
+ busMap.set(key, bus);
131
+ }
132
+ bus.items.push(e);
133
+ }
134
+ // Bigger fans first: they claim their trunks before loners squeeze through.
135
+ const buses = [...busMap.values()].sort((a, b) => b.items.length - a.items.length);
136
+ // --- A* over (cell, dir) states ---
137
+ // Tiny binary heap on f; lazy decrease (re-push, skip stale pops).
138
+ function astar(bus, starts, goals,
139
+ /** desperate mode: blocked cells become traversable at a huge cost so a
140
+ * path ALWAYS exists — silently missing edges is worse than an ugly one */
141
+ desperate = false) {
142
+ const S = N * 2;
143
+ const g = new Float64Array(S).fill(Infinity);
144
+ const parent = new Int32Array(S).fill(-1);
145
+ const closed = new Uint8Array(S);
146
+ // goal heuristic anchor: mean of goal cells
147
+ let gx = 0;
148
+ let gy = 0;
149
+ for (const c of goals) {
150
+ gx += c % cols;
151
+ gy += Math.floor(c / cols);
152
+ }
153
+ gx /= goals.size || 1;
154
+ gy /= goals.size || 1;
155
+ const h = (cell) => Math.abs((cell % cols) - gx) + Math.abs(Math.floor(cell / cols) - gy);
156
+ const heap = [];
157
+ const push = (e) => {
158
+ heap.push(e);
159
+ let i = heap.length - 1;
160
+ while (i > 0) {
161
+ const p = (i - 1) >> 1;
162
+ if (heap[p].f <= heap[i].f)
163
+ break;
164
+ [heap[p], heap[i]] = [heap[i], heap[p]];
165
+ i = p;
166
+ }
167
+ };
168
+ const pop = () => {
169
+ const top = heap[0];
170
+ const last = heap.pop();
171
+ if (heap.length && last) {
172
+ heap[0] = last;
173
+ let i = 0;
174
+ for (;;) {
175
+ const l = 2 * i + 1;
176
+ const r = l + 1;
177
+ let m = i;
178
+ if (l < heap.length && heap[l].f < heap[m].f)
179
+ m = l;
180
+ if (r < heap.length && heap[r].f < heap[m].f)
181
+ m = r;
182
+ if (m === i)
183
+ break;
184
+ [heap[m], heap[i]] = [heap[i], heap[m]];
185
+ i = m;
186
+ }
187
+ }
188
+ return top;
189
+ };
190
+ for (const st of starts) {
191
+ const s = st.cell * 2 + st.dir;
192
+ if (g[s] > st.g0) {
193
+ g[s] = st.g0;
194
+ push({ f: st.g0 + h(st.cell), s });
195
+ }
196
+ }
197
+ // A step covers HALF of each of its two cells — price the worst of both,
198
+ // or an elbow's outgoing half would be free to overlap.
199
+ const halfCost = (cell, dir) => {
200
+ const same = dir === 0 ? usageH[cell] : usageV[cell];
201
+ if (same === bus.idx || bus.own.has(cell * 2 + dir))
202
+ return costOwn;
203
+ if (same !== -1)
204
+ return COST_OVERLAP;
205
+ return COST_STEP;
206
+ };
207
+ const stepCost = (from, next, dir) => {
208
+ // floored so negative cost-layer biases can't break A* (no negative steps)
209
+ let c = Math.max(0.05, Math.max(halfCost(from, dir), halfCost(next, dir)) + bias[next]);
210
+ const cross = dir === 0 ? usageV[next] : usageH[next];
211
+ if (cross !== -1 && cross !== bus.idx)
212
+ c += COST_CROSS;
213
+ return c;
214
+ };
215
+ let goalState = -1;
216
+ while (heap.length) {
217
+ const cur = pop();
218
+ const s = cur.s;
219
+ if (closed[s])
220
+ continue;
221
+ closed[s] = 1;
222
+ const cell = s >> 1;
223
+ const dir = (s & 1);
224
+ if (goals.has(cell)) {
225
+ goalState = s;
226
+ break;
227
+ }
228
+ const x = cell % cols;
229
+ const y = (cell - x) / cols;
230
+ const neigh = [];
231
+ if (x > 0)
232
+ neigh.push({ cell: cell - 1, dir: 0 });
233
+ if (x < cols - 1)
234
+ neigh.push({ cell: cell + 1, dir: 0 });
235
+ if (y > 0)
236
+ neigh.push({ cell: cell - cols, dir: 1 });
237
+ if (y < rows - 1)
238
+ neigh.push({ cell: cell + cols, dir: 1 });
239
+ for (const nb of neigh) {
240
+ if (blocked[nb.cell] && !desperate)
241
+ continue;
242
+ const ns = nb.cell * 2 + nb.dir;
243
+ if (closed[ns])
244
+ continue;
245
+ const cost = stepCost(cell, nb.cell, nb.dir) +
246
+ (nb.dir === dir ? 0 : costTurn) +
247
+ (blocked[nb.cell] ? 800 : 0);
248
+ const ng = g[s] + cost;
249
+ if (ng < g[ns]) {
250
+ g[ns] = ng;
251
+ parent[ns] = s;
252
+ push({ f: ng + h(nb.cell), s: ns });
253
+ }
254
+ }
255
+ }
256
+ if (goalState < 0)
257
+ return null;
258
+ const path = [];
259
+ for (let s = goalState; s >= 0; s = parent[s]) {
260
+ path.push({ cell: s >> 1, dir: (s & 1) });
261
+ }
262
+ path.reverse();
263
+ return path;
264
+ }
265
+ /** Contact point on a box edge for the path end at ring cell `i`. Stays
266
+ * aligned with the cell CENTER (corner-free rings guarantee it lies over
267
+ * the edge), so the stub is colinear with the routed run — never nudge the
268
+ * routed geometry instead: that would shift a run off its claimed lane. */
269
+ function contact(b, i) {
270
+ const c = centerOf(i);
271
+ if (c.y < b.t)
272
+ return { x: Math.min(Math.max(c.x, b.l + 2), b.r - 2), y: b.t - GAP };
273
+ if (c.y > b.b)
274
+ return { x: Math.min(Math.max(c.x, b.l + 2), b.r - 2), y: b.b + GAP };
275
+ if (c.x < b.l)
276
+ return { x: b.l - GAP, y: Math.min(Math.max(c.y, b.t + 2), b.b - 2) };
277
+ return { x: b.r + GAP, y: Math.min(Math.max(c.y, b.t + 2), b.b - 2) };
278
+ }
279
+ const conns = [];
280
+ let bottom = 0;
281
+ let violations = 0;
282
+ const badCells = new Set();
283
+ for (const bus of buses) {
284
+ const s = boxes.get(bus.items[0].source);
285
+ // Route nearest targets first so the trunk grows outward and later
286
+ // edges find own-bus cells to ride.
287
+ const items = [...bus.items].sort((a, b) => {
288
+ const ta = boxes.get(a.target);
289
+ const tb = boxes.get(b.target);
290
+ return (Math.abs(ta.cy - s.cy) + Math.abs(ta.cx - s.cx) -
291
+ (Math.abs(tb.cy - s.cy) + Math.abs(tb.cx - s.cx)));
292
+ });
293
+ for (const e of items) {
294
+ const t = boxes.get(e.target);
295
+ // Seeds: the source's ring (opening a NEW trunk costs exitCost) PLUS
296
+ // every cell the bus already routed at cost 0 — a later edge branches
297
+ // from the OPTIMAL point of the existing trunk (Steiner-style join).
298
+ // Endpoint cells owned by other buses are off limits (stacked stubs).
299
+ const sSides = opts.nodeSides?.[e.source];
300
+ const tSides = opts.nodeSides?.[e.target];
301
+ const starts = ring(s, false, sSides)
302
+ .filter((r) => endpointOwner[r.cell] === -1 || endpointOwner[r.cell] === bus.idx)
303
+ .map((r) => ({ ...r, g0: exitCost }));
304
+ for (const od of bus.own) {
305
+ starts.push({ cell: od >> 1, dir: (od & 1), g0: 0 });
306
+ }
307
+ const goals = new Set(ring(t, false, tSides)
308
+ .map((r) => r.cell)
309
+ .filter((c) => endpointOwner[c] === -1 || endpointOwner[c] === bus.idx));
310
+ let path = starts.length && goals.size ? astar(bus, starts, goals) : null;
311
+ if (!path) {
312
+ // No legal route (a dense node row can wall the grid off at coarse
313
+ // cell sizes, or port constraints seal a node in). NEVER drop the
314
+ // edge silently: retry with blocked cells traversable at a huge
315
+ // cost — and if even that fails, relax the port constraints too.
316
+ const dStarts = ring(s, true, sSides).map((r) => ({ ...r, g0: exitCost }));
317
+ for (const od of bus.own) {
318
+ dStarts.push({ cell: od >> 1, dir: (od & 1), g0: 0 });
319
+ }
320
+ const dGoals = new Set(ring(t, true, tSides).map((r) => r.cell));
321
+ path = dStarts.length && dGoals.size ? astar(bus, dStarts, dGoals, true) : null;
322
+ if (!path && (sSides || tSides)) {
323
+ const uStarts = ring(s, true).map((r) => ({ ...r, g0: exitCost }));
324
+ for (const od of bus.own) {
325
+ uStarts.push({ cell: od >> 1, dir: (od & 1), g0: 0 });
326
+ }
327
+ const uGoals = new Set(ring(t, true).map((r) => r.cell));
328
+ path = uStarts.length && uGoals.size ? astar(bus, uStarts, uGoals, true) : null;
329
+ }
330
+ if (!path)
331
+ continue;
332
+ violations++;
333
+ badCells.add(path[Math.floor(path.length / 2)].cell);
334
+ }
335
+ // Claim per STEP on BOTH cells (an elbow claims its outgoing direction
336
+ // too) + extend the bus tree (first writer keeps the link).
337
+ for (let i = 1; i < path.length; i++) {
338
+ const dir = path[i].dir;
339
+ for (const cell of [path[i - 1].cell, path[i].cell]) {
340
+ const cur = dir === 0 ? usageH[cell] : usageV[cell];
341
+ if (cur !== -1 && cur !== bus.idx && !bus.own.has(cell * 2 + dir)) {
342
+ violations++;
343
+ badCells.add(cell);
344
+ }
345
+ if (dir === 0) {
346
+ if (usageH[cell] === -1)
347
+ usageH[cell] = bus.idx;
348
+ }
349
+ else if (usageV[cell] === -1)
350
+ usageV[cell] = bus.idx;
351
+ bus.own.add(cell * 2 + dir);
352
+ }
353
+ if (!bus.prev.has(path[i].cell) && path[i - 1].cell !== path[i].cell) {
354
+ bus.prev.set(path[i].cell, path[i - 1].cell);
355
+ }
356
+ }
357
+ if (endpointOwner[path[0].cell] === -1)
358
+ endpointOwner[path[0].cell] = bus.idx;
359
+ const goalCell = path[path.length - 1].cell;
360
+ if (endpointOwner[goalCell] === -1)
361
+ endpointOwner[goalCell] = bus.idx;
362
+ // Full source→target chain: if the A* seed was a mid-trunk cell, walk
363
+ // the bus tree back to the source ring and prepend that trunk prefix.
364
+ const chain = [];
365
+ {
366
+ let c = path[0].cell;
367
+ const guard = new Set();
368
+ while (bus.prev.has(c) && !guard.has(c)) {
369
+ guard.add(c);
370
+ c = bus.prev.get(c);
371
+ chain.push(c);
372
+ }
373
+ chain.reverse();
374
+ }
375
+ const fullCells = [...chain, ...path.map((p) => p.cell)];
376
+ // polyline: source contact → cell centers (collinear-compressed) → target contact
377
+ const pts = [];
378
+ const first = contact(s, fullCells[0]);
379
+ const last = contact(t, fullCells[fullCells.length - 1]);
380
+ pts.push(first);
381
+ for (const c of fullCells)
382
+ pts.push(centerOf(c));
383
+ pts.push(last);
384
+ const simp = [];
385
+ for (const p of pts) {
386
+ const n = simp.length;
387
+ if (n >= 2) {
388
+ const a = simp[n - 2];
389
+ const b3 = simp[n - 1];
390
+ if ((a.x === b3.x && b3.x === p.x) || (a.y === b3.y && b3.y === p.y)) {
391
+ simp[n - 1] = p;
392
+ continue;
393
+ }
394
+ }
395
+ simp.push(p);
396
+ }
397
+ // force pure orthogonal corners (cell centers already are; stubs may
398
+ // introduce tiny diagonals — insert a corner point when needed)
399
+ const ortho = [simp[0]];
400
+ for (let i = 1; i < simp.length; i++) {
401
+ const a = ortho[ortho.length - 1];
402
+ const b4 = simp[i];
403
+ if (a.x !== b4.x && a.y !== b4.y)
404
+ ortho.push({ x: b4.x, y: a.y });
405
+ ortho.push(b4);
406
+ }
407
+ const d = ortho
408
+ .map((p, i) => `${i === 0 ? 'M' : 'L'} ${Math.round(p.x * 2) / 2} ${Math.round(p.y * 2) / 2}`)
409
+ .join(' ');
410
+ for (const p of ortho)
411
+ bottom = Math.max(bottom, p.y);
412
+ conns.push({ id: `e:${e.id}`, source: e.source, target: e.target, bus: bus.key, data: e.data, d });
413
+ }
414
+ }
415
+ // --- debug cells ---
416
+ const cells = [];
417
+ for (let i = 0; i < N; i++) {
418
+ const x = i % cols;
419
+ const y = (i - x) / cols;
420
+ if (badCells.has(i))
421
+ cells.push({ x, y, kind: 'bad' });
422
+ else if (blocked[i])
423
+ cells.push({ x, y, kind: 'chip' });
424
+ else if (usageH[i] !== -1 && usageV[i] !== -1)
425
+ cells.push({ x, y, kind: 'hv' });
426
+ else if (usageH[i] !== -1)
427
+ cells.push({ x, y, kind: 'h' });
428
+ else if (usageV[i] !== -1)
429
+ cells.push({ x, y, kind: 'v' });
430
+ }
431
+ return { conns, bottom, violations, debug: { res, cols, rows, cells } };
432
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "grid-router",
3
+ "version": "0.1.0",
4
+ "description": "Orthogonal edge routing on a grid: A* with per-direction cell occupancy, Steiner-style bus joins and crossing hops — plus an optional Svelte canvas where the consumer owns the chips.",
5
+ "license": "MIT",
6
+ "author": "Geptyro",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Geptyro/grid-router.git"
10
+ },
11
+ "homepage": "https://geptyro.github.io/grid-router/",
12
+ "keywords": [
13
+ "routing",
14
+ "orthogonal",
15
+ "edge-routing",
16
+ "diagram",
17
+ "astar",
18
+ "svelte"
19
+ ],
20
+ "type": "module",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.js"
25
+ },
26
+ "./svelte": {
27
+ "types": "./src/svelte/index.ts",
28
+ "svelte": "./src/svelte/index.ts",
29
+ "default": "./src/svelte/index.ts"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "src"
35
+ ],
36
+ "scripts": {
37
+ "build": "tsc",
38
+ "test": "vitest run",
39
+ "check": "tsc --noEmit",
40
+ "prepublishOnly": "npm test && npm run build"
41
+ },
42
+ "peerDependencies": {
43
+ "svelte": "^5.0.0"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "svelte": {
47
+ "optional": true
48
+ }
49
+ },
50
+ "devDependencies": {
51
+ "typescript": "^5.6.0",
52
+ "vitest": "^2.1.0"
53
+ }
54
+ }
@@ -0,0 +1,29 @@
1
+ import { BOX_INFLATE } from './router.js';
2
+
3
+ /**
4
+ * Corridor supply rules — the layout side of the routing contract.
5
+ *
6
+ * The router can only use free cells; the LAYOUT decides how many exist. These
7
+ * formulas are the empirically-validated minimums (violations = 0 across cell
8
+ * sizes 6–12 on real diagrams):
9
+ *
10
+ * - rowGap: a gap between node rows must hold several free horizontal lanes
11
+ * after box inflation eats its borders;
12
+ * - chipGap: a gap between nodes in a row must fit at least one ALIGNED free
13
+ * cell column (2·res + 2·inflate), or a dense row walls the grid off;
14
+ * - sidePad: canvas side padding keeps the outer columns routable so paths
15
+ * can go AROUND a row instead of only through it.
16
+ */
17
+ export interface CorridorGaps {
18
+ rowGap: number;
19
+ chipGap: number;
20
+ sidePad: number;
21
+ }
22
+
23
+ export function corridorGaps(res: number, inflate: number = BOX_INFLATE): CorridorGaps {
24
+ return {
25
+ rowGap: Math.max(34, 4.5 * res),
26
+ chipGap: Math.max(12, 2 * res + 2 * inflate),
27
+ sidePad: 1.5 * res
28
+ };
29
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ export {
2
+ routeGrid,
3
+ BOX_INFLATE,
4
+ type GridBox,
5
+ type GridEdge,
6
+ type GridConn,
7
+ type GridOpts,
8
+ type GridResult,
9
+ type GridDebugCell,
10
+ type BoxSide
11
+ } from './router.js';
12
+ export { corridorGaps, type CorridorGaps } from './corridors.js';
13
+ export {
14
+ withLayers,
15
+ portsLayer,
16
+ keepOutLayer,
17
+ corridorLayer,
18
+ type Rect,
19
+ type RouterLayer
20
+ } from './layers.js';
package/src/layers.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { BoxSide, GridOpts } from './router.js';
2
+
3
+ /**
4
+ * Layers: pre-built, composable producers of declarative routing facts.
5
+ *
6
+ * The router core stays agnostic — it only understands two substrates:
7
+ * per-cell cost bias (`costRegions`) and per-node port constraints
8
+ * (`nodeSides`). A layer is a small factory that expresses a CONSUMER concept
9
+ * (keep-out zone, preferred corridor, schematic pins…) in those substrates;
10
+ * `withLayers` merges any number of them into the opts.
11
+ *
12
+ * const opts = withLayers(
13
+ * { res: 12 },
14
+ * portsLayer({ r1: ['left', 'right'] }),
15
+ * maintenance && keepOutLayer([zoneRect])
16
+ * );
17
+ */
18
+
19
+ export interface Rect {
20
+ l: number;
21
+ t: number;
22
+ r: number;
23
+ b: number;
24
+ }
25
+
26
+ export interface RouterLayer {
27
+ costRegions?: NonNullable<GridOpts['costRegions']>;
28
+ nodeSides?: Record<string, BoxSide[]>;
29
+ }
30
+
31
+ /** Merge layers into base opts. Falsy entries are skipped, so layers can be
32
+ * toggled inline: `withLayers(base, zoneOn && keepOutLayer([zone]))`. */
33
+ export function withLayers(
34
+ base: GridOpts,
35
+ ...layers: (RouterLayer | false | null | undefined)[]
36
+ ): GridOpts {
37
+ const costRegions = [...(base.costRegions ?? [])];
38
+ const nodeSides: Record<string, BoxSide[]> = { ...(base.nodeSides ?? {}) };
39
+ for (const l of layers) {
40
+ if (!l) continue;
41
+ if (l.costRegions) costRegions.push(...l.costRegions);
42
+ if (l.nodeSides) Object.assign(nodeSides, l.nodeSides);
43
+ }
44
+ const out: GridOpts = { ...base };
45
+ if (costRegions.length) out.costRegions = costRegions;
46
+ if (Object.keys(nodeSides).length) out.nodeSides = nodeSides;
47
+ return out;
48
+ }
49
+
50
+ /** Port constraints: which box sides each node's edges may attach to
51
+ * (schematic pins, UML side-anchoring, …). */
52
+ export function portsLayer(sides: Record<string, BoxSide[]>): RouterLayer {
53
+ return { nodeSides: sides };
54
+ }
55
+
56
+ /** Keep-out zones: routes strongly avoid these rects (they still cross when
57
+ * there is no alternative — routing never fails). */
58
+ export function keepOutLayer(rects: Rect[], bias = 40): RouterLayer {
59
+ return { costRegions: rects.map((r) => ({ ...r, bias })) };
60
+ }
61
+
62
+ /** Preferred corridors: routes are gently attracted into these rects — the
63
+ * "lanes layer": express your layout's gaps as corridors and buses gravitate
64
+ * there. Negative bias is floored by the router, so any value is safe. */
65
+ export function corridorLayer(rects: Rect[], bias = -0.4): RouterLayer {
66
+ return { costRegions: rects.map((r) => ({ ...r, bias })) };
67
+ }