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/LICENSE +21 -0
- package/README.md +139 -0
- package/dist/corridors.d.ts +20 -0
- package/dist/corridors.js +8 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/layers.d.ts +39 -0
- package/dist/layers.js +36 -0
- package/dist/router.d.ts +109 -0
- package/dist/router.js +432 -0
- package/package.json +54 -0
- package/src/corridors.ts +29 -0
- package/src/index.ts +20 -0
- package/src/layers.ts +67 -0
- package/src/router.ts +519 -0
- package/src/svelte/GridDiagram.svelte +366 -0
- package/src/svelte/index.ts +21 -0
- package/src/svelte/types.ts +23 -0
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
|
+
}
|
package/src/corridors.ts
ADDED
|
@@ -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
|
+
}
|