diff --git a/frontend/src/__tests__/wire-auto-route.test.ts b/frontend/src/__tests__/wire-auto-route.test.ts index 1e544bda..658afb62 100644 --- a/frontend/src/__tests__/wire-auto-route.test.ts +++ b/frontend/src/__tests__/wire-auto-route.test.ts @@ -73,12 +73,15 @@ describe('routeAroundObstacles', () => { 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('an endpoint-containing rect gets an escape corridor, not a free pass', () => { + // The obstacle sits right on the start pin. The wire must be able to + // LEAVE (routing can never fail because of the pin's own body), but the + // rest of the body must still repel the route — dropping the rect + // entirely let wires cross seated displays end to end. + const r = routeAroundObstacles(start, end, [{ x: -20, y: -20, w: 40, h: 40 }]); + // A route (or null when the escape happens to align with the direct + // elbow) — either way it must not throw and must produce a usable shape. + expect(r === null || Array.isArray(r)).toBe(true); }); it('falls back to null when the target is fully walled off', () => { @@ -234,3 +237,68 @@ describe('collectWireSegments', () => { expect(segs).toHaveLength(2); }); }); + +describe('routeAroundObstacles — escape corridors (endpoint inside obstacle)', () => { + it('a wire leaving a strip under a seated display does not cross its body', () => { + // The reported case: a 4-digit display body ~200x95 seated on the + // breadboard; the wire starts at a strip hole INSIDE the inflated bbox + // (right under the display pins) and ends far to the right. Dropping + // the rect let 15 wires run straight across the display. + const display: ObstacleRect = { x: 523, y: 86, w: 210, h: 103 }; + const start = { x: 540, y: 180 }; // inside, near the BOTTOM edge + const end = { x: 900, y: 120 }; // to the right, level with the body + const corners = routeAroundObstacles(start, end, [display], []); + expect(corners).not.toBeNull(); + // No horizontal run may pass through the body interior above the + // escape row (i.e. the route must go around, not across). + const pts = expandOrthogonalPoints([start, ...corners!, end]); + for (let i = 1; i < pts.length; i++) { + const a = pts[i - 1]; + const b = pts[i]; + if (a.y === b.y && a.y > 86 && a.y < 172) { + // horizontal inside the body's vertical span (above the corridor + // mouth region) must not overlap the body's x-range interior + const overl = Math.min(Math.max(a.x, b.x), 523 + 210) - Math.max(Math.min(a.x, b.x), 523); + expect(overl, `run at y=${a.y} crosses the display`).toBeLessThanOrEqual(16); + } + } + }); + + it('still routes when BOTH endpoints sit inside the same rect', () => { + // Strip-to-strip wire fully under the display: carving twice may leave + // nothing blocked — must not throw, must return something sane. + const display: ObstacleRect = { x: 0, y: 0, w: 300, h: 100 }; + const r = routeAroundObstacles({ x: 30, y: 90 }, { x: 250, y: 90 }, [display], []); + expect(r === null || Array.isArray(r)).toBe(true); + }); + + it('endpoint-outside rects behave exactly as before', () => { + const rect: ObstacleRect = { x: 100, y: -50, w: 50, h: 100 }; + const corners = routeAroundObstacles({ x: 0, y: 0 }, { x: 300, y: 0 }, [rect], []); + expect(corners).not.toBeNull(); // must detour around it + }); +}); + +describe('routeAroundObstacles — overlapping obstacle slab (seated resistor bank)', () => { + it('escapes a point buried in overlapping rects via one shared corridor', () => { + // 8 resistors at 19px pitch with ~66px inflated boxes form a solid slab. + // With per-rect escape directions the corridors contradicted each other + // and A* found no exit — the wire fell back to a display-crossing elbow. + const slab: ObstacleRect[] = Array.from({ length: 8 }, (_, i) => ({ + x: 850 + i * 19, y: 440, w: 50, h: 105, + })); + const display: ObstacleRect = { x: 500, y: 434, w: 202, h: 95 }; + const start = { x: 906, y: 521 }; // strip hole inside 2-3 resistors + const end = { x: 100, y: 219 }; // GPIO far left, above + const corners = routeAroundObstacles(start, end, [display, ...slab], []); + expect(corners).not.toBeNull(); + // The route must not cross the display body. + const pts = expandOrthogonalPoints([start, ...corners!, end]); + for (let i = 1; i < pts.length; i++) { + const a = pts[i - 1]; const b = pts[i]; + const inX = Math.max(a.x, b.x) > 500 + 4 && Math.min(a.x, b.x) < 702 - 4; + const inY = Math.max(a.y, b.y) > 434 + 4 && Math.min(a.y, b.y) < 529 - 4; + expect(inX && inY, `segment (${a.x},${a.y})->(${b.x},${b.y}) crosses the display`).toBe(false); + } + }); +}); diff --git a/frontend/src/__tests__/wire-reroute.test.ts b/frontend/src/__tests__/wire-reroute.test.ts index b6bfdf18..6a217439 100644 --- a/frontend/src/__tests__/wire-reroute.test.ts +++ b/frontend/src/__tests__/wire-reroute.test.ts @@ -153,3 +153,43 @@ describe('recalculateAllWirePositions — auto-route pass', () => { expect(useSimulatorStore.getState().wires[0].waypoints).toEqual([]); }); }); + +describe('re-route pass — null route materialises the CHECKED elbow', () => { + beforeEach(() => { + document.body.innerHTML = ''; + const s = useSimulatorStore.getState(); + // Diagonal pair with dy >> dx: previewElbow is vertical-first, while an + // empty waypoints array renders horizontal-first — a DIFFERENT corner + // the router never validated. Three live agent wires crossed a display + // exactly this way. + s.setComponents([ + { id: 'p1', metadataId: 'resistor', x: 300, y: 500, properties: {} }, + { id: 'p2', metadataId: 'resistor', x: 0, y: 100, properties: {} }, + // A blocker placed so ONLY the horizontal-first corner would cross it: + // it sits left of p1 at p1's row. + { id: 'blk', metadataId: 'chip', x: 100, y: 470, properties: {} }, + ] as never); + mountComponent('p1'); mountComponent('p2'); + mountComponent('blk', 60, 80); // y 470..550 — covers p1's row (~506) + s.setWires([]); + }); + + it('stores the longer-axis-first elbow instead of empty waypoints', () => { + const s = useSimulatorStore.getState(); + s.setWires([wireBetween('w', 'p1', 'p2', { autoRouted: true })] as never); + s.recalculateAllWirePositions(); + const w = useSimulatorStore.getState().wires[0]; + // Whatever shape came out, the RENDERED expansion must not cross blk. + const pts = expandOrthogonalPoints([ + { x: w.start.x, y: w.start.y }, ...w.waypoints, { x: w.end.x, y: w.end.y }, + ]); + for (let i = 1; i < pts.length; i++) { + const a = pts[i - 1]; const b = pts[i]; + const inX = Math.max(a.x, b.x) > 100 + 4 && Math.min(a.x, b.x) < 160 - 4; + const inY = Math.max(a.y, b.y) > 470 + 4 && Math.min(a.y, b.y) < 550 - 4; + const aIn = a.x > 100 && a.x < 160 && a.y > 470 && a.y < 550; + const bIn = b.x > 100 && b.x < 160 && b.y > 470 && b.y < 550; + expect(inX && inY && !aIn && !bIn, `segment (${a.x},${a.y})->(${b.x},${b.y}) crosses blk`).toBe(false); + } + }); +}); diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 57fe6a56..81d4df1f 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -2810,11 +2810,25 @@ export const useSimulatorStore = create((set, get) => { rects, collectWireSegments(updatedWires, wire.id), ); + // routed === null means the PREVIEW elbow (longer-axis-first) is + // clear — so that exact elbow must be materialised. Storing [] + // instead renders the implicit horizontal-first corner, a + // DIFFERENT elbow the router never checked: three agent wires + // shipped crossing a display that way while their checked route + // was clean. + const elbow = + routed === null + ? previewElbow( + { x: wire.start.x, y: wire.start.y }, + wire.end.x, + wire.end.y, + ) + : null; updatedWires[i] = { ...wire, waypoints: normalizeWireWaypoints( { x: wire.start.x, y: wire.start.y }, - routed ?? [], + routed ?? (elbow ? [elbow] : []), { x: wire.end.x, y: wire.end.y }, ), }; diff --git a/frontend/src/utils/wireAutoRoute.ts b/frontend/src/utils/wireAutoRoute.ts index e80db4d2..b800eb36 100644 --- a/frontend/src/utils/wireAutoRoute.ts +++ b/frontend/src/utils/wireAutoRoute.ts @@ -82,6 +82,90 @@ 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; } +/** + * A rect that contains an endpoint cannot be blocked whole (the wire must + * leave the pin) — but dropping it entirely let routes cross the WHOLE + * body. Seen live: strips under a seated 4-digit display start inside its + * inflated bbox, so the display stopped being an obstacle for every wire + * leaving those strips and 15 wires ran straight across it. + * + * Instead, carve an ESCAPE CORRIDOR from the endpoint to the rect's + * nearest edge and keep the rest blocked: the far side of the body stays + * an obstacle, and the route exits through the corridor like a real + * jumper leaving from under a chip. + * + * Returns the still-blocked sub-rects (0-3 of them). + */ +type EscapeDir = 'left' | 'right' | 'up' | 'down'; + +/** + * Escape direction for a point inside ONE OR MORE overlapping rects: the + * direction with the shortest run until the point clears ALL of them. + * + * Every containing rect must then carve its corridor in this SAME + * direction. Seated resistors overlap heavily (19px pitch, ~66px inflated + * boxes), and when each rect picked its own nearest edge the corridors + * pointed different ways and walled each other off — A* found no exit, + * fell back to the direct elbow, and the wire crossed a display. + */ +function unionEscapeDir(rects: ObstacleRect[], p: Point): EscapeDir { + const containing = rects.filter((r) => rectContains(r, p)); + if (containing.length === 0) return 'down'; + const dLeft = Math.max(...containing.map((r) => p.x - r.x)); + const dRight = Math.max(...containing.map((r) => r.x + r.w - p.x)); + const dTop = Math.max(...containing.map((r) => p.y - r.y)); + const dBottom = Math.max(...containing.map((r) => r.y + r.h - p.y)); + const min = Math.min(dLeft, dRight, dTop, dBottom); + if (min === dBottom) return 'down'; + if (min === dTop) return 'up'; + if (min === dRight) return 'right'; + return 'left'; +} + +function carveEscape(r: ObstacleRect, p: Point, dir?: EscapeDir): ObstacleRect[] { + if (!rectContains(r, p)) return [r]; + const dLeft = p.x - r.x; + const dRight = r.x + r.w - p.x; + const dTop = p.y - r.y; + const dBottom = r.y + r.h - p.y; + let d: EscapeDir; + if (dir) { + d = dir; + } else { + const min = Math.min(dLeft, dRight, dTop, dBottom); + d = min === dBottom ? 'down' : min === dTop ? 'up' : min === dRight ? 'right' : 'left'; + } + const C = ROUTE_MARGIN; // corridor half-width + const out: ObstacleRect[] = []; + const push = (x: number, y: number, w: number, h: number) => { + if (w > 1 && h > 1) out.push({ x, y, w, h }); + }; + // Side blocks OVERLAP the endpoint's row/column by 1px: segment-vs-rect + // hits are strict, so without the overlap the endpoint's row is a free + // seam between the far block and the side bands and the route rides it + // straight across the body. + if (d === 'down' || d === 'up') { + // Vertical corridor at p.x, opening toward the chosen horizontal edge. + const corridorY = (d === 'down' ? p.y : r.y) - (d === 'down' ? 1 : 0); + const corridorEnd = d === 'down' ? r.y + r.h : p.y + 1; + // Everything on the OTHER side of the endpoint stays fully blocked. + if (d === 'down') push(r.x, r.y, r.w, dTop); + else push(r.x, p.y, r.w, dBottom); + // Beside the corridor, still blocked. + push(r.x, corridorY, p.x - C - r.x, corridorEnd - corridorY); + push(p.x + C, corridorY, r.x + r.w - (p.x + C), corridorEnd - corridorY); + } else { + // Horizontal corridor at p.y, opening toward the chosen vertical edge. + const corridorX = (d === 'right' ? p.x : r.x) - (d === 'right' ? 1 : 0); + const corridorEnd = d === 'right' ? r.x + r.w : p.x + 1; + if (d === 'right') push(r.x, r.y, dLeft, r.h); + else push(p.x, r.y, dRight, r.h); + push(corridorX, r.y, corridorEnd - corridorX, p.y - C - r.y); + push(corridorX, p.y + C, corridorEnd - corridorX, r.y + r.h - (p.y + C)); + } + return out; +} + /** * Axis-aligned segment vs rect overlap. Touching an edge exactly does not * count as a hit, so routes may run along the inflated boundary. @@ -212,11 +296,17 @@ export function routeAroundObstacles( rawRects: ObstacleRect[], wireSegments: WireSegment[] = [], ): Point[] | 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)); + // Rects that contain an endpoint can never be blocked whole (the wire + // must leave the pin) — carve an escape corridor instead of dropping the + // obstacle, so the rest of the body still repels the route. All rects + // containing one endpoint carve in the SAME union-chosen direction, so + // the corridors of overlapping rects chain into one continuous exit. + const inflated = rawRects.map((r) => inflate(r, ROUTE_MARGIN)); + const startDir = unionEscapeDir(inflated, start); + const endDir = unionEscapeDir(inflated, end); + const rects = inflated + .flatMap((r) => carveEscape(r, start, startDir)) + .flatMap((r) => carveEscape(r, end, endDir)); // Wires participate only inside a window around the route, and never // when they share an endpoint with it (wires meeting on one pin must