OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / 853ed2d

routing: the hang was a linear-scan open list and a fixed grid
Double-clicking a node re-routed the board, and on a big one that never came back. Measured, not guessed: pear-git's 316 edges took 55,256ms at the fixed grid of 40. Two causes. A* kept its open list as an array and scanned it linearly for the cheapest cell, and keyed visited state by string concatenation, so both costs grew with the frontier. The open list is now a binary min-heap and state keys are numeric (cell * 5 + direction). The grid was the other half: cost is edges x cells and cells is area/grid^2, so a fixed grid means a big board is quadratically more work than a small one. chooseGrid() now derives it from the board's own area and edge count, clamped to [20, 45], and a cell-visit budget stops a hopeless board rather than freezing the tab. Both call sites stop passing grid: 40. And when routing does give up it now gives up WHOLE. Half-routed boards drew some wires orthogonally and the rest as curves, which reads as a rendering bug; past 15% failures it returns no routes at all and the caller falls back to curves for everything. Measured after: hyper-overlay 108 edges 801 -> 335ms, favela-peer 167 -> 732ms, favela-web 189 -> 1820ms, keet-cli 94ms, pear-git 316 over budget -> curves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
853ed2da363e7ddd3c858477f0808b2f918f5181
by plankatron <fleeky@gmail.com> · 2026-09-21T01:47:56.000Z

Changed files (3) +124 -20

lib/floor/level.js +2 -1
……@@ -300,7 +300,8 @@ function render () {
300300 if (view.bundled) {
301301 for (const c of view.laid.containers) boxes.push({ id: c.id, x: c.x, y: c.y, w: c.w, h: c.h })
302302 }
303 const out = routeEdges(boxes, edges.map(e => ({ from: e.from, to: e.to })), { grid: 40 })
303 // No grid: routing picks one from the work the board needs.
304 const out = routeEdges(boxes, edges.map(e => ({ from: e.from, to: e.to })))
304305 for (const r of out.routes) {
305306 routedPaths.set(r.from + ' -> ' + r.to,
306307 'M ' + r.points.map(p => p.x.toFixed(1) + ' ' + p.y.toFixed(1)).join(' L '))
lib/floor/routing.js +119 -16
……@@ -18,7 +18,14 @@
1818 */
1919
2020const DEFAULTS = {
21 grid: 20, // routing resolution; also the minimum clearance around a node
21 // Routing resolution, and the minimum clearance around a node.
22 //
23 // Left undefined it is CHOSEN from the work the board actually needs: cost is
24 // edges x cells, and cells is area/grid^2, so a big board routes coarsely
25 // rather than not at all. Measured before this existed, one 226-node module
26 // took 55 SECONDS at a fixed grid of 40 — a frozen tab, not a slow render.
27 grid: undefined,
28 targetVisits: 700000, // the work to aim for when choosing a grid
2229 clearance: 10, // how far a route stays off a node's edge
2330 turnCost: 6, // a turn costs this many grid steps, so paths stay straight
2431
……@@ -29,7 +36,15 @@ const DEFAULTS = {
2936 // between two circuits that are not connected at all. Siblings are exempt:
3037 // wires from one source SHOULD share a trunk, that is what makes a junction.
3138 laneCost: 9,
32 maxCells: 400000 // refuse to search a board this big rather than hang the tab
39 maxCells: 400000, // refuse to search a board this big rather than hang the tab
40
41 // The work budget, in cell-visits across ALL edges.
42 //
43 // Cost is edges x search area, and it runs away: measured on a real scan,
44 // 108 edges over 3k cells took 0.8s but 316 edges over 22k cells took 55
45 // SECONDS, which is not a slow render, it is a frozen tab. Past this budget
46 // the caller is told to draw curves instead.
47 budget: 4000000
3348}
3449
3550/**
……@@ -41,6 +56,8 @@ export function routeEdges (nodes, links, opts = {}) {
4156 const O = { ...DEFAULTS, ...opts }
4257 const byId = new Map(nodes.map(n => [n.id, n]))
4358
59 if (O.grid === undefined) O.grid = chooseGrid(nodes, links, O)
60
4461 const valid = links.filter(l => byId.has(l.from) && byId.has(l.to))
4562 if (valid.length === 0) return { routes: [], failed: 0 }
4663
……@@ -63,6 +80,11 @@ export function routeEdges (nodes, links, opts = {}) {
6380 return { routes: [], failed: valid.length, tooLarge: true }
6481 }
6582
83 // Refuse the job rather than freeze: the caller falls back to curves.
84 if (valid.length * cols * rows > O.budget) {
85 return { routes: [], failed: valid.length, overBudget: true }
86 }
87
6688 const blocked = new Uint8Array(cols * rows)
6789 const owner = new Int32Array(cols * rows).fill(-1)
6890 const idx = (cx, cy) => cy * cols + cx
……@@ -105,6 +127,11 @@ export function routeEdges (nodes, links, opts = {}) {
105127 claimLanes(path, l.from)
106128 } else failed++
107129 }
130 // All or nothing: a board where many edges could not be routed looks worse
131 // half-orthogonal than it does as plain curves.
132 if (failed > routes.length * 0.15) {
133 return { routes: [], failed: valid.length, mixedRefused: true }
134 }
108135 return { routes, failed }
109136
110137 // Mark every cell a finished route runs through, so the next one prefers a
……@@ -190,9 +217,16 @@ export function routeEdges (nodes, links, opts = {}) {
190217 const startKey = start.cy * cols + start.cx
191218 const goalKey = goal.cy * cols + goal.cx
192219
193 const gScore = new Map([[startKey + ':0', 0]])
220 // State is (cell, direction), keyed as a NUMBER rather than a string: the
221 // string concatenation was allocating once per expansion, and the open list
222 // was scanned linearly for its minimum, which is O(n) per pop. Between them
223 // they were most of the 55 seconds a big level used to take.
224 const stateOf = (cellKey, dir) => cellKey * 5 + dir
225
226 const gScore = new Map([[stateOf(startKey, 0), 0]])
194227 const came = new Map()
195 const open = [{ key: startKey, dir: 0, f: heuristic(start), cx: start.cx, cy: start.cy }]
228 const open = new MinHeap()
229 open.push(heuristic(start), { key: startKey, dir: 0, cx: start.cx, cy: start.cy })
196230
197231 const DIRS = [
198232 { dx: 1, dy: 0, id: 1 }, { dx: -1, dy: 0, id: 2 },
……@@ -200,11 +234,9 @@ export function routeEdges (nodes, links, opts = {}) {
200234 ]
201235
202236 let guard = 0
203 while (open.length > 0) {
237 while (open.size > 0) {
204238 if (++guard > O.maxCells) return null
205 let bestAt = 0
206 for (let i = 1; i < open.length; i++) if (open[i].f < open[bestAt].f) bestAt = i
207 const current = open.splice(bestAt, 1)[0]
239 const current = open.pop()
208240
209241 if (current.key === goalKey) return reconstruct(current)
210242
……@@ -222,18 +254,15 @@ export function routeEdges (nodes, links, opts = {}) {
222254 const claimed = laneOwner.get(ny * cols + nx)
223255 const lane = claimed !== undefined && claimed !== source ? O.laneCost : 0
224256
225 const tentative = (gScore.get(current.key + ':' + current.dir) || 0) +
257 const tentative = (gScore.get(stateOf(current.key, current.dir)) || 0) +
226258 1 + turn + trunkBonus + lane
227259
228260 const nKey = ny * cols + nx
229 const stateKey = nKey + ':' + d.id
261 const stateKey = stateOf(nKey, d.id)
230262 if (gScore.has(stateKey) && gScore.get(stateKey) <= tentative) continue
231263 gScore.set(stateKey, tentative)
232264 came.set(stateKey, { key: current.key, dir: current.dir, cx: current.cx, cy: current.cy })
233 open.push({
234 key: nKey, dir: d.id, cx: nx, cy: ny,
235 f: tentative + heuristic({ cx: nx, cy: ny })
236 })
265 open.push(tentative + heuristic({ cx: nx, cy: ny }), { key: nKey, dir: d.id, cx: nx, cy: ny })
237266 }
238267 }
239268 return null
……@@ -246,9 +275,9 @@ export function routeEdges (nodes, links, opts = {}) {
246275 const out = [{ cx: end.cx, cy: end.cy }]
247276 let cursor = { key: end.key, dir: end.dir, cx: end.cx, cy: end.cy }
248277 let steps = 0
249 while (came.has(cursor.key + ':' + cursor.dir)) {
278 while (came.has(stateOf(cursor.key, cursor.dir))) {
250279 if (++steps > O.maxCells) break
251 cursor = came.get(cursor.key + ':' + cursor.dir)
280 cursor = came.get(stateOf(cursor.key, cursor.dir))
252281 out.push({ cx: cursor.cx, cy: cursor.cy })
253282 }
254283 return out.reverse()
……@@ -256,6 +285,80 @@ export function routeEdges (nodes, links, opts = {}) {
256285 }
257286}
258287
288/**
289 * Pick a routing resolution from the work the board needs.
290 *
291 * Cost is (edges x cells) and cells is area/grid^2, so grid ~ sqrt(edges x area
292 * / target). A small board gets a fine grid and tight clearance; a large one
293 * gets a coarse grid and still gets routed, which beats falling back to curves.
294 */
295function chooseGrid (nodes, links, O) {
296 if (nodes.length === 0 || links.length === 0) return 20
297 let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity
298 for (const n of nodes) {
299 minX = Math.min(minX, n.x); minY = Math.min(minY, n.y)
300 maxX = Math.max(maxX, n.x + n.w); maxY = Math.max(maxY, n.y + n.h)
301 }
302 const area = Math.max(1, (maxX - minX) * (maxY - minY))
303 // Capped at 45 on purpose. A coarser grid is faster but the clearance halo
304 // around each node is measured in CELLS, so past roughly the row gap it
305 // swallows the channels between stacked nodes and A* can find no path at all.
306 // Measured at grid ~120: favela-web routed 84 of 189 edges and the rest fell
307 // back to curves, which looks worse than either choice made consistently.
308 // Past what a routable grid can afford, the budget refuses the whole job.
309 const grid = Math.sqrt(links.length * area / Math.max(1, O.targetVisits))
310 return Math.max(20, Math.min(45, Math.round(grid / 5) * 5))
311}
312
313/** A binary min-heap: A* pops the cheapest state once per expansion. */
314class MinHeap {
315 constructor () {
316 this.keys = []
317 this.values = []
318 }
319
320 get size () { return this.keys.length }
321
322 push (priority, value) {
323 this.keys.push(priority)
324 this.values.push(value)
325 let i = this.keys.length - 1
326 while (i > 0) {
327 const parent = (i - 1) >> 1
328 if (this.keys[parent] <= this.keys[i]) break
329 this.swap(parent, i)
330 i = parent
331 }
332 }
333
334 pop () {
335 const top = this.values[0]
336 const lastKey = this.keys.pop()
337 const lastValue = this.values.pop()
338 if (this.keys.length > 0) {
339 this.keys[0] = lastKey
340 this.values[0] = lastValue
341 let i = 0
342 for (;;) {
343 const l = i * 2 + 1
344 const r = l + 1
345 let small = i
346 if (l < this.keys.length && this.keys[l] < this.keys[small]) small = l
347 if (r < this.keys.length && this.keys[r] < this.keys[small]) small = r
348 if (small === i) break
349 this.swap(small, i)
350 i = small
351 }
352 }
353 return top
354 }
355
356 swap (a, b) {
357 const k = this.keys[a]; this.keys[a] = this.keys[b]; this.keys[b] = k
358 const v = this.values[a]; this.values[a] = this.values[b]; this.values[b] = v
359 }
360}
361
259362/** Drop every point that does not change direction. */
260363function simplify (points) {
261364 if (points.length <= 2) return points
lib/render-nodes.js +3 -3
……@@ -670,9 +670,9 @@ function nvRender () {
670670 toPoint: nvSocketPos(rb, rl.toSide, rl.toSock)
671671 })
672672 }
673 // Grid 40: measured on a 56-module scan, 105 edges route in 0.4s at 40 and
674 // 3.4s at 20, both routing every edge.
675 var out = window.__ctRoute.routeEdges(boxes, routeLinks, { grid: 40 })
673 // No grid: the router picks one from the work this board needs. Fixed at 40,
674 // a 316-edge board took 55 SECONDS — which is what the hang was.
675 var out = window.__ctRoute.routeEdges(boxes, routeLinks)
676676 for (var oi = 0; oi < out.routes.length; oi++) {
677677 var rt = out.routes[oi]
678678 var dparts = []