From 7ed9c51bd3c3fbc72858c9810e2e7fc0001c5853 Mon Sep 17 00:00:00 2001 From: David Montero Date: Sat, 18 Jul 2026 05:59:45 +0200 Subject: [PATCH] feat(wires): first-time auto-routing around components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a wire with a direct pin-to-pin click (no user waypoints) now routes around other components' bounding boxes instead of crossing them. Routing happens exactly once, at creation: the routed corners are stored as ordinary waypoints, so every later manual edit stays where the user puts it — never re-routed. Router (utils/wireAutoRoute.ts): - tries the preview elbow first (clear -> keep existing behavior and the WYSIWYG shape), then the opposite elbow, then A* over the compressed grid spanned by pin coordinates and obstacle edges inflated by an 8 px clearance, with a 40 px per-bend penalty so straighter routes win - obstacles are component boxes only (never boards — pins sit on both board edges and detouring around a board produces absurd routes), excluding the wire's own endpoint components, measured from the rendered DOM; rects containing an endpoint are dropped - any failure (walled-off target, oversized grid, no DOM) falls back to the previous direct-elbow behavior --- .../src/__tests__/wire-auto-route.test.ts | 101 +++++++ frontend/src/store/useSimulatorStore.ts | 20 +- frontend/src/utils/wireAutoRoute.ts | 262 ++++++++++++++++++ 3 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 frontend/src/__tests__/wire-auto-route.test.ts create mode 100644 frontend/src/utils/wireAutoRoute.ts diff --git a/frontend/src/__tests__/wire-auto-route.test.ts b/frontend/src/__tests__/wire-auto-route.test.ts new file mode 100644 index 00000000..21d1020c --- /dev/null +++ b/frontend/src/__tests__/wire-auto-route.test.ts @@ -0,0 +1,101 @@ +/** + * First-time wire auto-routing around component bounding boxes. + */ + +import { describe, it, expect } from 'vitest'; +import { + routeAroundObstacles, + ROUTE_MARGIN, + type ObstacleRect, +} from '../utils/wireAutoRoute'; +import { expandOrthogonalPoints } from '../utils/wireUtils'; + +/** True when no segment of [start, ...corners, end] crosses an inflated rect. */ +function routeAvoids( + start: { x: number; y: number }, + corners: { x: number; y: number }[], + end: { x: number; y: number }, + rects: ObstacleRect[], +): boolean { + const pts = expandOrthogonalPoints([start, ...corners, end]); + for (let i = 1; i < pts.length; i++) { + const a = pts[i - 1]; + const b = pts[i]; + for (const r of rects) { + const rx = r.x - ROUTE_MARGIN; + const ry = r.y - ROUTE_MARGIN; + const rw = r.w + 2 * ROUTE_MARGIN; + const rh = r.h + 2 * ROUTE_MARGIN; + if (a.y === b.y) { + if (a.y > ry && a.y < ry + rh && Math.max(a.x, b.x) > rx && Math.min(a.x, b.x) < rx + rw) + return false; + } else if (a.x > rx && a.x < rx + rw && Math.max(a.y, b.y) > ry && Math.min(a.y, b.y) < ry + rh) { + return false; + } + } + } + return true; +} + +describe('routeAroundObstacles', () => { + const start = { x: 0, y: 0 }; + const end = { x: 300, y: 200 }; + + it('returns null with no obstacles (default elbow keeps working)', () => { + expect(routeAroundObstacles(start, end, [])).toBeNull(); + }); + + it('returns null when the preview elbow is already clear', () => { + // Obstacle far away from both L-routes + expect( + routeAroundObstacles(start, end, [{ x: 1000, y: 1000, w: 50, h: 50 }]), + ).toBeNull(); + }); + + it('uses the other elbow orientation when only the preview one is blocked', () => { + // dy (200) > dx... no: dx=300 >= dy=200 → preview goes horizontal-first + // through (300, 0). Block that with a rect on the top edge. + const rects: ObstacleRect[] = [{ x: 120, y: -30, w: 60, h: 60 }]; + const corners = routeAroundObstacles(start, end, rects); + expect(corners).toEqual([{ x: 0, y: 200 }]); + expect(routeAvoids(start, corners!, end, rects)).toBe(true); + }); + + it('routes around an obstacle blocking both L orientations', () => { + // A tall block straddling the middle blocks horizontal-first and + // vertical-first alike; A* must detour around it. + const rects: ObstacleRect[] = [{ x: 100, y: -100, w: 60, h: 400 }]; + const corners = routeAroundObstacles(start, end, rects); + expect(corners).not.toBeNull(); + expect(corners!.length).toBeGreaterThan(0); + expect(routeAvoids(start, corners!, end, rects)).toBe(true); + }); + + it('ignores rects that contain an endpoint (wire must leave the pin)', () => { + // The obstacle sits right on the start pin — routing around it is + // impossible, so it must be dropped and the direct elbow kept. + expect( + routeAroundObstacles(start, end, [{ x: -20, y: -20, w: 40, h: 40 }]), + ).toBeNull(); + }); + + it('falls back to null when the target is fully walled off', () => { + // Four rects boxing the end point with no gap + const rects: ObstacleRect[] = [ + { x: 200, y: 100, w: 200, h: 20 }, + { x: 200, y: 280, w: 200, h: 20 }, + { x: 200, y: 100, w: 20, h: 200 }, + { x: 380, y: 100, w: 20, h: 200 }, + ]; + expect(routeAroundObstacles(start, { x: 300, y: 200 }, rects)).toBeNull(); + }); + + it('keeps the route orthogonal', () => { + const rects: ObstacleRect[] = [{ x: 100, y: -100, w: 60, h: 400 }]; + const corners = routeAroundObstacles(start, end, rects)!; + const pts = [start, ...corners, end]; + for (let i = 1; i < pts.length; i++) { + expect(pts[i - 1].x === pts[i].x || pts[i - 1].y === pts[i].y).toBe(true); + } + }); +}); diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 0feb9af0..ce23309e 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -36,6 +36,7 @@ import { normalizeWireWaypoints, previewElbow, } from '../utils/wireUtils'; +import { routeAroundObstacles, collectComponentObstacles } from '../utils/wireAutoRoute'; import { createSerialBatcher } from './serialBatcher'; import { bindBoard as icBindBoard, @@ -2552,6 +2553,23 @@ export const useSimulatorStore = create((set, get) => { // Finish wire: auto-detect color from pin name const finalColor = color === DEFAULT_WIRE_COLOR ? autoWireColor(endpoint.pinName) : color; + // First-time auto-routing: a direct pin-to-pin wire (no user-placed + // waypoints) gets routed around other components. This only ever + // happens here at creation — the routed corners become ordinary + // stored waypoints, so any later manual edit stays exactly where + // the user puts it, never re-routed. + let routed: { x: number; y: number }[] | null = null; + if (waypoints.length === 0) { + routed = routeAroundObstacles( + { x: startEndpoint.x, y: startEndpoint.y }, + { x: endpoint.x, y: endpoint.y }, + collectComponentObstacles(state.components, [ + startEndpoint.componentId, + endpoint.componentId, + ]), + ); + } + // Materialise the elbow of the final leg exactly as the live preview // drew it (longer axis first). Without this the committed wire falls // back to the implicit horizontal-first corner and visibly changes @@ -2567,7 +2585,7 @@ export const useSimulatorStore = create((set, get) => { end: endpoint, waypoints: normalizeWireWaypoints( { x: startEndpoint.x, y: startEndpoint.y }, - elbow ? [...waypoints, elbow] : waypoints, + routed ?? (elbow ? [...waypoints, elbow] : waypoints), { x: endpoint.x, y: endpoint.y }, ), color: finalColor, diff --git a/frontend/src/utils/wireAutoRoute.ts b/frontend/src/utils/wireAutoRoute.ts new file mode 100644 index 00000000..4d5dec57 --- /dev/null +++ b/frontend/src/utils/wireAutoRoute.ts @@ -0,0 +1,262 @@ +/** + * First-time wire auto-routing: find an orthogonal route from pin to pin + * that avoids crossing other components on the canvas. + * + * This runs ONLY when a wire is first created with no user-placed + * waypoints (a direct pin-to-pin click). Manual edits are never re-routed: + * the routed corners are stored as ordinary waypoints, so from that moment + * on the wire behaves exactly like a hand-drawn one and goes wherever the + * user drags it. + * + * Algorithm: A* over the compressed orthogonal grid spanned by the pin + * coordinates and the inflated obstacle edges, with a per-bend cost so + * straighter routes win. Canvases hold at most a few dozen components, so + * the grid stays tiny (2N+2 coordinates per axis). + */ + +import { previewElbow, simplifyOrthogonalPath } from './wireUtils'; + +export interface ObstacleRect { + x: number; + y: number; + w: number; + h: number; +} + +interface Point { + x: number; + y: number; +} + +/** Clearance kept between a routed wire and component bounding boxes. */ +export const ROUTE_MARGIN = 8; + +/** Extra path cost per 90-degree bend, in world px. */ +const BEND_PENALTY = 40; + +/** Hard cap on grid size, beyond which routing silently degrades to the + * direct elbow. Far above any realistic canvas. */ +const MAX_COORDS_PER_AXIS = 256; + +function inflate(r: ObstacleRect, m: number): ObstacleRect { + return { x: r.x - m, y: r.y - m, w: r.w + 2 * m, h: r.h + 2 * m }; +} + +function rectContains(r: ObstacleRect, p: Point): boolean { + return p.x > r.x && p.x < r.x + r.w && p.y > r.y && p.y < r.y + r.h; +} + +/** + * Axis-aligned segment vs rect overlap. Touching an edge exactly does not + * count as a hit, so routes may run along the inflated boundary. + */ +function segmentHitsRect(a: Point, b: Point, r: ObstacleRect): boolean { + if (a.y === b.y) { + // Horizontal + if (!(a.y > r.y && a.y < r.y + r.h)) return false; + return Math.max(a.x, b.x) > r.x && Math.min(a.x, b.x) < r.x + r.w; + } + if (a.x === b.x) { + // Vertical + if (!(a.x > r.x && a.x < r.x + r.w)) return false; + return Math.max(a.y, b.y) > r.y && Math.min(a.y, b.y) < r.y + r.h; + } + // Non-orthogonal segments never occur in routed paths + return false; +} + +function pathClear(pts: Point[], rects: ObstacleRect[]): boolean { + for (let i = 1; i < pts.length; i++) { + for (const r of rects) { + if (segmentHitsRect(pts[i - 1], pts[i], r)) return false; + } + } + return true; +} + +/** Minimal binary min-heap keyed on `f`. */ +class Heap { + private a: { f: number; s: number }[] = []; + get size() { + return this.a.length; + } + push(f: number, s: number) { + const a = this.a; + a.push({ f, s }); + let i = a.length - 1; + while (i > 0) { + const p = (i - 1) >> 1; + if (a[p].f <= a[i].f) break; + [a[p], a[i]] = [a[i], a[p]]; + i = p; + } + } + pop(): { f: number; s: number } { + const a = this.a; + const top = a[0]; + const last = a.pop()!; + if (a.length) { + a[0] = last; + let i = 0; + for (;;) { + const l = 2 * i + 1; + const r = l + 1; + let m = i; + if (l < a.length && a[l].f < a[m].f) m = l; + if (r < a.length && a[r].f < a[m].f) m = r; + if (m === i) break; + [a[m], a[i]] = [a[i], a[m]]; + i = m; + } + } + return top; + } +} + +/** + * Route from `start` to `end` avoiding `rawRects` (component bounding + * boxes in world coordinates, uninflated). + * + * Returns the interior corner points of the route (ready to store as + * waypoints), or null when the default direct elbow is already clear — + * or when no clean route exists — so the caller falls back to the + * existing preview-matching behavior. + */ +export function routeAroundObstacles( + start: Point, + end: Point, + rawRects: ObstacleRect[], +): Point[] | null { + if (rawRects.length === 0) return null; + + // Rects that contain an endpoint can never be avoided (the wire must + // leave the pin); drop them rather than making routing impossible. + const rects = rawRects + .map((r) => inflate(r, ROUTE_MARGIN)) + .filter((r) => !rectContains(r, start) && !rectContains(r, end)); + if (rects.length === 0) return null; + + // Preferred direct route: the same elbow the live preview drew. + const elbow = previewElbow(start, end.x, end.y); + const direct = elbow ? [start, elbow, end] : [start, end]; + if (pathClear(direct, rects)) return null; + + // The other elbow orientation costs nothing extra — try it before A*. + if (elbow) { + const alt = elbow.x === end.x ? { x: start.x, y: end.y } : { x: end.x, y: start.y }; + if (pathClear([start, alt, end], rects)) return [alt]; + } + + // ── A* over the compressed grid ───────────────────────────────────── + const xsSet = new Set([start.x, end.x]); + const ysSet = new Set([start.y, end.y]); + for (const r of rects) { + xsSet.add(r.x); + xsSet.add(r.x + r.w); + ysSet.add(r.y); + ysSet.add(r.y + r.h); + } + const xs = [...xsSet].sort((a, b) => a - b); + const ys = [...ysSet].sort((a, b) => a - b); + if (xs.length > MAX_COORDS_PER_AXIS || ys.length > MAX_COORDS_PER_AXIS) return null; + + const cols = xs.length; + const rows = ys.length; + const xi = new Map(xs.map((v, i) => [v, i])); + const yi = new Map(ys.map((v, i) => [v, i])); + + // State = (grid node, incoming direction). Directions: 0 none, 1 horizontal, 2 vertical. + const nodeId = (cx: number, cy: number, dir: number) => (cy * cols + cx) * 3 + dir; + const startCx = xi.get(start.x)!; + const startCy = yi.get(start.y)!; + const endCx = xi.get(end.x)!; + const endCy = yi.get(end.y)!; + + const dist = new Map(); + const prev = new Map(); + const h = (cx: number, cy: number) => + Math.abs(xs[cx] - end.x) + Math.abs(ys[cy] - end.y); + + const heap = new Heap(); + const s0 = nodeId(startCx, startCy, 0); + dist.set(s0, 0); + heap.push(h(startCx, startCy), s0); + + const stepClear = (a: Point, b: Point) => rects.every((r) => !segmentHitsRect(a, b, r)); + + let goal = -1; + while (heap.size) { + const { s } = heap.pop(); + const dir = s % 3; + const node = (s - dir) / 3; + const cx = node % cols; + const cy = (node - cx) / cols; + const d = dist.get(s)!; + if (cx === endCx && cy === endCy) { + goal = s; + break; + } + const neighbors: Array<[number, number, number]> = [ + [cx - 1, cy, 1], + [cx + 1, cy, 1], + [cx, cy - 1, 2], + [cx, cy + 1, 2], + ]; + for (const [nx, ny, ndir] of neighbors) { + if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue; + const a = { x: xs[cx], y: ys[cy] }; + const b = { x: xs[nx], y: ys[ny] }; + if (!stepClear(a, b)) continue; + const bend = dir !== 0 && dir !== ndir ? BEND_PENALTY : 0; + const nd = d + Math.abs(b.x - a.x) + Math.abs(b.y - a.y) + bend; + const ns = nodeId(nx, ny, ndir); + if (nd < (dist.get(ns) ?? Infinity)) { + dist.set(ns, nd); + prev.set(ns, s); + heap.push(nd + h(nx, ny), ns); + } + } + } + if (goal < 0) return null; + + // Reconstruct, simplify, return interior corners only. + const pts: Point[] = []; + for (let s: number | undefined = goal; s !== undefined; s = prev.get(s)) { + const dir = s % 3; + const node = (s - dir) / 3; + const cx = node % cols; + pts.push({ x: xs[cx], y: ys[(node - cx) / cols] }); + } + pts.reverse(); + const simplified = simplifyOrthogonalPath(pts); + return simplified.slice(1, -1); +} + +/** + * Bounding boxes of every component except the wire's own endpoints, + * measured from the rendered DOM (store coordinates + element size). + * Boards are deliberately NOT obstacles: pins live on both board edges + * and detouring around a board produces absurd routes. Returns [] in + * non-DOM environments (tests) and for unmounted components. + */ +export function collectComponentObstacles( + components: Array<{ id: string; x: number; y: number }>, + excludeIds: Array, +): ObstacleRect[] { + if (typeof document === 'undefined') return []; + const skip = new Set(excludeIds.filter(Boolean)); + const rects: ObstacleRect[] = []; + for (const c of components) { + if (skip.has(c.id)) continue; + const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(c.id) : c.id; + const el = document.querySelector( + `.dynamic-component-wrapper[data-component-id="${esc}"]`, + ) as HTMLElement | null; + if (!el) continue; + const w = el.offsetWidth; + const hh = el.offsetHeight; + if (!w || !hh) continue; + rects.push({ x: c.x, y: c.y, w, h: hh }); + } + return rects; +}