fix(router): escape corridors for endpoint-in-obstacle + checked-elbow parity

Three router bugs found by replaying a real agent session (reloj_3333) where
wires ran straight across a seated 4-digit display. Each fix is covered by a
regression test built from the failing geometry.

Endpoint inside an obstacle no longer drops the whole obstacle
--------------------------------------------------------------
Breadboard strips under a seated display start INSIDE its inflated bbox, so
the "rects containing an endpoint are dropped" rule deleted the display as
an obstacle for every wire leaving those strips — 15 wires crossed it end to
end. The rect is now carved instead: an escape corridor (ROUTE_MARGIN wide)
from the endpoint to the chosen edge, with the rest of the body still
blocking. Side blocks overlap the endpoint's row by 1px, or the strict
segment-hit test leaves the row as a free seam straight across the body.

Overlapping rects escape in ONE shared direction
------------------------------------------------
Seated resistors overlap heavily (19px pitch, ~66px inflated boxes). When
each containing 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 the display anyway. The escape
direction is now chosen once against the UNION of containing rects and
every carve uses it, so the corridors chain into a continuous exit.

Null route materialises the CHECKED elbow
------------------------------------------
routeAroundObstacles returns null when the PREVIEW elbow (longer-axis-first)
is clear — but the re-route pass stored empty waypoints, which the renderer
expands as the horizontal-first corner: a DIFFERENT elbow the router never
validated. Three wires shipped crossing a display whose checked route was
clean. The pass now materialises previewElbow explicitly, exactly like
finishWireCreation always did.

Verified E2E: the same agent prompt that produced 15 crossings now builds
the ESP32 clock with ZERO wire segments crossing the display body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-07-20 21:35:58 +02:00
parent 651161559a
commit 0635e15e7a
4 changed files with 224 additions and 12 deletions

View File

@ -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);
}
});
});

View File

@ -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);
}
});
});

View File

@ -2810,11 +2810,25 @@ export const useSimulatorStore = create<SimulatorState>((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 },
),
};

View File

@ -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