OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / 5cf605d

Circuit routing: wires go around nodes, and share a trunk with junctions
Edges were beziers drawn straight from A to B, so they ran BEHIND whatever sat between them. At 105 edges on the system level and 108 inside one module, that is a wall of spaghetti hiding a layout that is otherwise correct. lib/floor/routing.js routes orthogonally with A* over a grid where node rectangles are blocked, and a TURN COST so paths stay straight instead of becoming staircases. A route leaves a node's right edge, runs in the free channels, and enters the target's left edge. Measured from the rendered DOM of a real 58-file module: link paths 108 orthogonal (no bezier) 108 of 108 segments 398 segments crossing a node body 0 junction dots 35 ## Junctions Edges leaving the same node share a trunk, and where a branch turns off it there is a dot — a bus with taps, the way a circuit diagram marks a connection rather than a crossing. A route with a clear straight shot does NOT detour to join the trunk: adding bends to a wire that does not need them is the opposite of the goal, and that is asserted. ## Two bugs found by the tests, both real A route could run straight back THROUGH its own source node, because I had made a route's own endpoints passable so it could leave and arrive. Ports now sit a full grid step clear and nothing is passable; the permissive pass survives only as a fallback for a node boxed in on every side, rather than dropping the edge. The stubs into node edges were drawn at the socket's y while the first routed cell was grid-snapped, making the first segment DIAGONAL — the one thing an orthogonal route must never be. Ports now take their y from the grid. ## Cost, measured before shipping 105 edges route in 3.4s at grid 20 and 0.4s at grid 40, both routing every edge. The view uses 40: a relayout that blocks the tab for three seconds on every expand is not worth the finer clearance. R toggles back to curves. ## And a trap I walked into routing.js was imported before it was SERVED, and the /floor catch-all happily answered the module request with HTML — blank page, syntax error. The catch-all now lists the floor's modules explicitly, so forgetting one fails loudly. Suite: 228 tests, 4814 asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5cf605d1b33891276f7c5f1889bb1467d38ff9c9
by plankatron <fleeky@gmail.com> · 2026-09-21T00:46:54.000Z

Changed files (5) +441 -7

lib/floor/level.html +3 -1
……@@ -45,6 +45,8 @@
4545 .link.bundled { stroke: #58a6ff; opacity: 0.45; }
4646 .link.hot { stroke: #ffa657; opacity: 0.95; }
4747 .link.cold { opacity: 0.04; }
48 .junction { fill: #4d5866; stroke: none; }
49 .link { stroke-linejoin: round; stroke-linecap: round; }
4850 /* A selected node's dependency closure: near edges hot, far ones cooling off,
4951 so depth is readable without counting hops. */
5052 .link.dep { opacity: 0.92; }
……@@ -89,7 +91,7 @@
8991 <span class="sw" style="background:#1f4a6b"></span>module body &nbsp;&nbsp;
9092 <span class="sw" style="background:#33405a"></span>zone depth rail
9193</div>
92<div id="hint">hover traces edges &middot; click a node for its dependency depth &middot; B bundles &middot; title opens &middot; Esc back</div>
94<div id="hint">hover traces edges &middot; click for dependency depth &middot; B bundles &middot; R routing &middot; title opens &middot; Esc back</div>
9395</div>
9496
9597<script type="module" src="/floor/level.js"></script>
lib/floor/level.js +38 -2
……@@ -16,6 +16,7 @@ import { layout } from '/floor/layout.js'
1616import {
1717 parseLocation, locationToUrl, otherSurface, splitFilePath, joinFilePath
1818} from '/floor/location.js'
19import { routeEdges, findJunctions } from '/floor/routing.js'
1920import {
2021 buildSystemLevel, buildModuleLevel, buildFileLevel, buildSymbolLevel,
2122 resolveCode, lineDepths, spanOnLine, marksOwnSpan, bundleLinks,
……@@ -62,6 +63,7 @@ const view = {
6263 links: [],
6364 containers: [],
6465 bundled: false, // one edge per container pair instead of per member
66 circuit: true, // orthogonal routes that go AROUND nodes, not behind them
6567 selected: null, // a card whose whole dependency closure is showing
6668 linkEls: [],
6769 cardEls: null,
……@@ -270,6 +272,30 @@ function render () {
270272 view.linkEls = []
271273 const edges = view.bundled ? bundleLinks(view.cards, view.links) : view.links
272274
275 // Orthogonal routing: wires leave a node's edge, run in the free channels and
276 // go AROUND whatever is in the way, instead of a bezier drawn straight from A
277 // to B and passing behind everything between them.
278 //
279 // The grid is 40, not the module's default 20: measured on this repo's own
280 // 56-module scan, 105 edges take 3.4s at 20 and 0.4s at 40, both routing
281 // every edge. A layout pass that blocks the tab for three seconds on every
282 // expand is not worth the finer clearance.
283 const routedPaths = new Map()
284 let junctions = []
285 if (view.circuit) {
286 const boxes = []
287 for (const n of view.laid.nodes) boxes.push({ id: n.id, x: n.x, y: n.y, w: n.w, h: n.h })
288 if (view.bundled) {
289 for (const c of view.laid.containers) boxes.push({ id: c.id, x: c.x, y: c.y, w: c.w, h: c.h })
290 }
291 const out = routeEdges(boxes, edges.map(e => ({ from: e.from, to: e.to })), { grid: 40 })
292 for (const r of out.routes) {
293 routedPaths.set(r.from + ' -> ' + r.to,
294 'M ' + r.points.map(p => p.x.toFixed(1) + ' ' + p.y.toFixed(1)).join(' L '))
295 }
296 junctions = findJunctions(out.routes)
297 }
298
273299 // Clicking a node shows its FULL dependency depth, not just one hop, with the
274300 // distance carried by colour. Measured on a 56-module scan: 10 modules have a
275301 // transitive dependency and the deepest reaches 14 modules three hops out —
……@@ -290,10 +316,11 @@ function render () {
290316 const x1 = a.x + a.w
291317 const x2 = b.x
292318 const dx = Math.max(30, Math.abs(x2 - x1) * 0.5)
319 const routed = routedPaths.get(l.from + ' -> ' + l.to)
293320 const path = el('path', {
294321 class: 'link' + (view.bundled ? ' bundled' : ''),
295 d: 'M ' + x1 + ' ' + y1 + ' C ' + (x1 + dx) + ' ' + y1 + ', ' + (x2 - dx) + ' ' + y2 +
296 ', ' + x2 + ' ' + y2
322 d: routed || ('M ' + x1 + ' ' + y1 + ' C ' + (x1 + dx) + ' ' + y1 + ', ' + (x2 - dx) + ' ' + y2 +
323 ', ' + x2 + ' ' + y2)
297324 })
298325 if (closure) {
299326 const depth = closure.edges.get(l.from + ' -> ' + l.to)
……@@ -318,6 +345,12 @@ function render () {
318345 }
319346 }
320347
348 // Junction dots where branches leave a shared trunk — a bus with taps, the
349 // way a circuit diagram marks a connection rather than a crossing.
350 for (const j of junctions) {
351 world.appendChild(el('circle', { class: 'junction', cx: j.x, cy: j.y, r: 3 }))
352 }
353
321354 view.cardEls = new Map()
322355 for (const card of view.cards) {
323356 const pos = placed.get(card.id)
……@@ -684,6 +717,9 @@ function wire () {
684717 } else if (e.key === 'c' || e.key === 'C') {
685718 view.expanded.clear()
686719 rebuild()
720 } else if (e.key === 'r' || e.key === 'R') {
721 view.circuit = !view.circuit
722 rebuild()
687723 } else if (e.key === 'b' || e.key === 'B') {
688724 view.bundled = !view.bundled
689725 syncUrl()
lib/floor/routing.js +257 -0
……@@ -0,0 +1,257 @@
1/**
2 * Orthogonal edge routing — wires that behave like circuitry.
3 *
4 * Edges used to be bezier curves drawn straight from A to B, so they ran BEHIND
5 * whatever sat between them. At 105 edges on the system level that is a wall of
6 * spaghetti that hides the layout underneath it.
7 *
8 * These routes are axis-aligned and obstacle-aware: they leave a node's right
9 * edge, run in the free channel between columns, and enter the target's left
10 * edge. A route never crosses a node it does not belong to, which is the
11 * invariant the tests check.
12 *
13 * Edges leaving the SAME node share a trunk, and where a branch turns off the
14 * trunk there is a JUNCTION — one wire with taps, rather than several wires
15 * drawn on top of each other.
16 *
17 * Pure: geometry in, geometry out. No DOM.
18 */
19
20const DEFAULTS = {
21 grid: 20, // routing resolution; also the minimum clearance around a node
22 clearance: 10, // how far a route stays off a node's edge
23 turnCost: 6, // a turn costs this many grid steps, so paths stay straight
24 maxCells: 400000 // refuse to search a board this big rather than hang the tab
25}
26
27/**
28 * @param {Array} nodes [{ id, x, y, w, h }]
29 * @param {Array} links [{ from, to }]
30 * @returns {object} { routes: [{ from, to, points: [{x,y}] }], failed }
31 */
32export function routeEdges (nodes, links, opts = {}) {
33 const O = { ...DEFAULTS, ...opts }
34 const byId = new Map(nodes.map(n => [n.id, n]))
35
36 const valid = links.filter(l => byId.has(l.from) && byId.has(l.to))
37 if (valid.length === 0) return { routes: [], failed: 0 }
38
39 // --- the board: a grid where a cell is blocked if a node covers it.
40 let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity
41 for (const n of nodes) {
42 minX = Math.min(minX, n.x)
43 minY = Math.min(minY, n.y)
44 maxX = Math.max(maxX, n.x + n.w)
45 maxY = Math.max(maxY, n.y + n.h)
46 }
47 // Margin so a route can always go around the outside.
48 const pad = O.grid * 6
49 minX -= pad; minY -= pad; maxX += pad; maxY += pad
50
51 const cols = Math.ceil((maxX - minX) / O.grid) + 1
52 const rows = Math.ceil((maxY - minY) / O.grid) + 1
53 if (cols * rows > O.maxCells) {
54 // Too big to search: say so rather than hang, and let the caller fall back.
55 return { routes: [], failed: valid.length, tooLarge: true }
56 }
57
58 const blocked = new Uint8Array(cols * rows)
59 const owner = new Int32Array(cols * rows).fill(-1)
60 const idx = (cx, cy) => cy * cols + cx
61 const toCellX = (x) => Math.round((x - minX) / O.grid)
62 const toCellY = (y) => Math.round((y - minY) / O.grid)
63
64 nodes.forEach((n, i) => {
65 const x0 = toCellX(n.x - O.clearance)
66 const x1 = toCellX(n.x + n.w + O.clearance)
67 const y0 = toCellY(n.y - O.clearance)
68 const y1 = toCellY(n.y + n.h + O.clearance)
69 for (let cy = Math.max(0, y0); cy <= Math.min(rows - 1, y1); cy++) {
70 for (let cx = Math.max(0, x0); cx <= Math.min(cols - 1, x1); cx++) {
71 blocked[idx(cx, cy)] = 1
72 owner[idx(cx, cy)] = i
73 }
74 }
75 })
76
77 // Edges from one source share a trunk: the channel just right of that node.
78 const trunkOf = new Map()
79 for (const l of valid) {
80 if (trunkOf.has(l.from)) continue
81 const n = byId.get(l.from)
82 trunkOf.set(l.from, n.x + n.w + O.grid * 2)
83 }
84
85 const routes = []
86 let failed = 0
87 for (const l of valid) {
88 const a = byId.get(l.from)
89 const b = byId.get(l.to)
90 const path = routeOne(a, b, trunkOf.get(l.from))
91 if (path) routes.push({ from: l.from, to: l.to, points: path })
92 else failed++
93 }
94 return { routes, failed }
95
96 // --- one route, as grid cells, then simplified back into world points.
97 function routeOne (a, b, trunkX) {
98 // Ports sit near the top of each node, where a header socket would be, and
99 // a full grid step CLEAR of it — far enough that the first routable cell is
100 // outside the node's blocked halo.
101 const startWorld = { x: a.x + a.w + O.grid * 2, y: a.y + Math.min(a.h / 2, 10) }
102 const endWorld = { x: b.x - O.grid * 2, y: b.y + Math.min(b.h / 2, 10) }
103
104 const start = { cx: clampX(toCellX(startWorld.x)), cy: clampY(toCellY(startWorld.y)) }
105 const goal = { cx: clampX(toCellX(endWorld.x)), cy: clampY(toCellY(endWorld.y)) }
106
107 const aIdx = nodes.indexOf(a)
108 const bIdx = nodes.indexOf(b)
109
110 // Nothing is passable, not even the route's own endpoints: letting a route
111 // cross its own source is how a backward edge ended up running straight
112 // back THROUGH the node it came from.
113 const strict = (cx, cy) => !blocked[idx(cx, cy)]
114
115 // Only if that fails — a node boxed in on every side — allow the route to
116 // pass through its own endpoints rather than dropping the edge.
117 const permissive = (cx, cy) => {
118 const i = idx(cx, cy)
119 return !blocked[i] || owner[i] === aIdx || owner[i] === bIdx
120 }
121
122 const trunkCell = trunkX === undefined ? null : clampX(toCellX(trunkX))
123 const cells = astar(start, goal, strict, trunkCell) || astar(start, goal, permissive, trunkCell)
124 if (!cells) return null
125
126 const points = cells.map(c => ({ x: minX + c.cx * O.grid, y: minY + c.cy * O.grid }))
127
128 // The stubs into the node edges take their y FROM THE GRID, not from the
129 // socket: a port at an unsnapped y makes the first segment diagonal, which
130 // is the one thing an orthogonal route must never be.
131 points.unshift({ x: a.x + a.w, y: points[0].y })
132 points.push({ x: b.x, y: points[points.length - 1].y })
133 return simplify(points)
134 }
135
136 function clampX (cx) { return Math.max(0, Math.min(cols - 1, cx)) }
137 function clampY (cy) { return Math.max(0, Math.min(rows - 1, cy)) }
138
139 /**
140 * A* with a TURN COST, which is what makes the result look like circuitry
141 * rather than a staircase: going straight is free, changing direction is not.
142 * A small bonus for running along the source's trunk keeps sibling edges
143 * together so they can share it.
144 */
145 function astar (start, goal, passable, trunkCx) {
146 const startKey = start.cy * cols + start.cx
147 const goalKey = goal.cy * cols + goal.cx
148
149 const gScore = new Map([[startKey + ':0', 0]])
150 const came = new Map()
151 const open = [{ key: startKey, dir: 0, f: heuristic(start), cx: start.cx, cy: start.cy }]
152
153 const DIRS = [
154 { dx: 1, dy: 0, id: 1 }, { dx: -1, dy: 0, id: 2 },
155 { dx: 0, dy: 1, id: 3 }, { dx: 0, dy: -1, id: 4 }
156 ]
157
158 let guard = 0
159 while (open.length > 0) {
160 if (++guard > O.maxCells) return null
161 let bestAt = 0
162 for (let i = 1; i < open.length; i++) if (open[i].f < open[bestAt].f) bestAt = i
163 const current = open.splice(bestAt, 1)[0]
164
165 if (current.key === goalKey) return reconstruct(current)
166
167 for (const d of DIRS) {
168 const nx = current.cx + d.dx
169 const ny = current.cy + d.dy
170 if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue
171 if (!passable(nx, ny)) continue
172
173 const turn = current.dir !== 0 && current.dir !== d.id ? O.turnCost : 0
174 const trunkBonus = trunkCx !== null && nx === trunkCx ? -0.4 : 0
175 const tentative = (gScore.get(current.key + ':' + current.dir) || 0) + 1 + turn + trunkBonus
176
177 const nKey = ny * cols + nx
178 const stateKey = nKey + ':' + d.id
179 if (gScore.has(stateKey) && gScore.get(stateKey) <= tentative) continue
180 gScore.set(stateKey, tentative)
181 came.set(stateKey, { key: current.key, dir: current.dir, cx: current.cx, cy: current.cy })
182 open.push({
183 key: nKey, dir: d.id, cx: nx, cy: ny,
184 f: tentative + heuristic({ cx: nx, cy: ny })
185 })
186 }
187 }
188 return null
189
190 function heuristic (c) {
191 return Math.abs(c.cx - goal.cx) + Math.abs(c.cy - goal.cy)
192 }
193
194 function reconstruct (end) {
195 const out = [{ cx: end.cx, cy: end.cy }]
196 let cursor = { key: end.key, dir: end.dir, cx: end.cx, cy: end.cy }
197 let steps = 0
198 while (came.has(cursor.key + ':' + cursor.dir)) {
199 if (++steps > O.maxCells) break
200 cursor = came.get(cursor.key + ':' + cursor.dir)
201 out.push({ cx: cursor.cx, cy: cursor.cy })
202 }
203 return out.reverse()
204 }
205 }
206}
207
208/** Drop every point that does not change direction. */
209function simplify (points) {
210 if (points.length <= 2) return points
211 const out = [points[0]]
212 for (let i = 1; i < points.length - 1; i++) {
213 const prev = out[out.length - 1]
214 const next = points[i + 1]
215 const straightX = Math.abs(prev.x - points[i].x) < 0.01 && Math.abs(points[i].x - next.x) < 0.01
216 const straightY = Math.abs(prev.y - points[i].y) < 0.01 && Math.abs(points[i].y - next.y) < 0.01
217 if (straightX || straightY) continue
218 out.push(points[i])
219 }
220 out.push(points[points.length - 1])
221 return out
222}
223
224/**
225 * Where routes from the same source part company.
226 *
227 * Three wires leaving one node and fanning out should read as a bus with taps,
228 * so the point a branch leaves the shared trunk gets a dot — the same thing a
229 * circuit diagram does, and for the same reason.
230 */
231export function findJunctions (routes) {
232 const bySource = new Map()
233 for (const r of routes) {
234 if (!bySource.has(r.from)) bySource.set(r.from, [])
235 bySource.get(r.from).push(r)
236 }
237
238 const junctions = []
239 for (const group of bySource.values()) {
240 if (group.length < 2) continue
241 const counts = new Map()
242 for (const r of group) {
243 // Every bend on this route is a candidate tap.
244 for (let i = 1; i < r.points.length - 1; i++) {
245 const key = Math.round(r.points[i].x) + ',' + Math.round(r.points[i].y)
246 counts.set(key, (counts.get(key) || 0) + 1)
247 }
248 }
249 for (const [key, degree] of counts) {
250 if (degree < 2) continue
251 const [x, y] = key.split(',').map(Number)
252 junctions.push({ x, y, degree })
253 }
254 }
255 junctions.sort((a, b) => a.x - b.x || a.y - b.y)
256 return junctions
257}
lib/server.js +10 -4
……@@ -278,10 +278,12 @@ export function startServer (tree, opts = {}) {
278278 // --- the floor: a level of the node ladder, drawn from the derivation.
279279 // /floor and every place under it serve the same shell; the client reads
280280 // location.pathname, exactly as the SPA does. One scheme, two surfaces.
281 if (url === '/floor' || url === '/floor/' ||
282 (url.startsWith('/floor/') && !url.startsWith('/floor/level.js') &&
283 !url.startsWith('/floor/cards.js') && !url.startsWith('/floor/layout.js') &&
284 !url.startsWith('/floor/location.js'))) {
281 // Every place under /floor serves the same shell, EXCEPT the modules it
282 // loads. Forgetting one here means a module request gets HTML back and the
283 // whole view fails to load with a blank page and a syntax error.
284 const FLOOR_MODULES = ['level.js', 'cards.js', 'layout.js', 'location.js', 'routing.js']
285 const isFloorModule = FLOOR_MODULES.some(m => url === '/floor/' + m)
286 if (url === '/floor' || url === '/floor/' || (url.startsWith('/floor/') && !isFloorModule)) {
285287 sendFile(req, res, 'floor/level.html', 'text/html; charset=utf-8')
286288 return
287289 }
……@@ -291,6 +293,10 @@ export function startServer (tree, opts = {}) {
291293 }
292294 // The view imports the SAME layout module the tests run against, rather than
293295 // a copy of it that can drift.
296 if (url === '/floor/routing.js') {
297 sendFile(req, res, 'floor/routing.js', 'application/javascript; charset=utf-8')
298 return
299 }
294300 if (url === '/floor/location.js') {
295301 sendFile(req, res, 'floor/location.js', 'application/javascript; charset=utf-8')
296302 return
test/routing.test.js +133 -0
……@@ -0,0 +1,133 @@
1import { test } from 'brittle'
2import { routeEdges, findJunctions } from '../lib/floor/routing.js'
3
4// Edges were drawn as bezier curves straight from A to B, so they ran BEHIND
5// whatever happened to be in between. At 105 edges that is a wall of spaghetti.
6// These routes are orthogonal and obstacle-aware: they look like circuitry
7// because they behave like it.
8
9function rect (id, x, y, w = 200, h = 60) {
10 return { id, x, y, w, h }
11}
12function pointInRect (p, r) {
13 return p.x > r.x && p.x < r.x + r.w && p.y > r.y && p.y < r.y + r.h
14}
15// Does a horizontal/vertical segment pass through a rectangle?
16function segmentHitsRect (a, b, r) {
17 const minX = Math.min(a.x, b.x)
18 const maxX = Math.max(a.x, b.x)
19 const minY = Math.min(a.y, b.y)
20 const maxY = Math.max(a.y, b.y)
21 return minX < r.x + r.w && maxX > r.x && minY < r.y + r.h && maxY > r.y
22}
23
24test('routing: a route is orthogonal — every segment is horizontal or vertical', (t) => {
25 const nodes = [rect('a', 0, 0), rect('b', 400, 200)]
26 const { routes } = routeEdges(nodes, [{ from: 'a', to: 'b' }])
27
28 t.is(routes.length, 1, 'one route')
29 const pts = routes[0].points
30 t.ok(pts.length >= 2, 'with at least two points')
31 for (let i = 1; i < pts.length; i++) {
32 const dx = Math.abs(pts[i].x - pts[i - 1].x)
33 const dy = Math.abs(pts[i].y - pts[i - 1].y)
34 t.ok(dx < 0.01 || dy < 0.01, 'segment ' + i + ' is axis-aligned')
35 }
36})
37
38test('routing: NO route passes through a node — the whole point', (t) => {
39 // c sits directly between a and b, which is exactly where a straight line
40 // would go.
41 const nodes = [rect('a', 0, 200), rect('c', 300, 180, 200, 100), rect('b', 700, 200)]
42 const { routes } = routeEdges(nodes, [{ from: 'a', to: 'b' }])
43
44 const pts = routes[0].points
45 t.ok(pts.length > 2, 'it had to bend: ' + pts.length + ' points')
46 for (let i = 1; i < pts.length; i++) {
47 t.absent(segmentHitsRect(pts[i - 1], pts[i], nodes[1]), 'segment ' + i + ' clears the obstacle')
48 }
49})
50
51test('routing: a clear shot stays straight, no gratuitous bends', (t) => {
52 const nodes = [rect('a', 0, 100), rect('b', 400, 100)]
53 const { routes } = routeEdges(nodes, [{ from: 'a', to: 'b' }])
54 const pts = routes[0].points
55
56 const ys = new Set(pts.map(p => Math.round(p.y)))
57 t.is(ys.size, 1, 'a clear horizontal run has one y: ' + [...ys].join(','))
58})
59
60test('routing: every route clears every node, on a crowded board', (t) => {
61 const nodes = []
62 for (let col = 0; col < 5; col++) {
63 for (let row = 0; row < 4; row++) {
64 nodes.push(rect('n' + col + row, col * 330, row * 120))
65 }
66 }
67 const links = [
68 { from: 'n00', to: 'n43' }, { from: 'n01', to: 'n40' },
69 { from: 'n03', to: 'n20' }, { from: 'n10', to: 'n33' },
70 { from: 'n21', to: 'n02' }
71 ]
72 const { routes } = routeEdges(nodes, links)
73 t.is(routes.length, links.length, 'every link routed')
74
75 let hits = 0
76 for (const route of routes) {
77 const endpoints = new Set([route.from, route.to])
78 for (let i = 1; i < route.points.length; i++) {
79 for (const n of nodes) {
80 if (endpoints.has(n.id)) continue
81 if (segmentHitsRect(route.points[i - 1], route.points[i], n)) hits++
82 }
83 }
84 }
85 t.is(hits, 0, 'no segment crosses a node it does not belong to')
86})
87
88test('routing: edges leaving one node share a trunk and get a JUNCTION', (t) => {
89 // One source feeding three targets should look like a bus with taps, not
90 // three separate wires drawn over each other.
91 const nodes = [rect('src', 0, 200), rect('t1', 400, 0), rect('t2', 400, 200), rect('t3', 400, 400)]
92 const links = [{ from: 'src', to: 't1' }, { from: 'src', to: 't2' }, { from: 'src', to: 't3' }]
93 const { routes } = routeEdges(nodes, links)
94 const junctions = findJunctions(routes)
95
96 t.ok(junctions.length > 0, 'there are junctions: ' + junctions.length)
97 for (const j of junctions) {
98 t.ok(j.degree >= 2, 'a junction joins at least two routes')
99 t.ok(Number.isFinite(j.x) && Number.isFinite(j.y), 'and has a position')
100 }
101
102 // Every route that BENDS leaves through the same trunk. One that has a clear
103 // straight shot does not detour to join it — adding bends to a wire that does
104 // not need them is the opposite of the goal.
105 const bending = routes.filter(r => r.points.length > 2)
106 t.ok(bending.length >= 2, 'at least two routes bend: ' + bending.length)
107 const trunkXs = new Set(bending.map(r => Math.round(r.points[1].x)))
108 t.is(trunkXs.size, 1, 'and they share one trunk: ' + [...trunkXs].join(','))
109})
110
111test('routing: a backward edge goes around rather than through', (t) => {
112 const nodes = [rect('a', 600, 100), rect('b', 0, 100)]
113 const { routes } = routeEdges(nodes, [{ from: 'a', to: 'b' }])
114 const pts = routes[0].points
115
116 t.ok(pts.length >= 4, 'it takes a path around: ' + pts.length + ' points')
117 for (let i = 1; i < pts.length; i++) {
118 for (const n of nodes) {
119 if (n.id === 'a' || n.id === 'b') continue
120 t.absent(segmentHitsRect(pts[i - 1], pts[i], n), 'clears everything')
121 }
122 }
123})
124
125test('routing: deterministic, and degenerate input does not throw', (t) => {
126 const nodes = [rect('a', 0, 0), rect('b', 300, 0)]
127 const links = [{ from: 'a', to: 'b' }]
128 t.alike(routeEdges(nodes, links), routeEdges(nodes, links), 'same in, same out')
129
130 t.alike(routeEdges([], []).routes, [], 'empty')
131 t.alike(routeEdges(nodes, [{ from: 'a', to: 'nope' }]).routes, [], 'a link to nothing is dropped')
132 t.alike(findJunctions([]), [], 'no routes, no junctions')
133})