velxio/frontend/src/__tests__/wire-reroute.test.ts

196 lines
7.9 KiB
TypeScript
Raw Normal View History

feat(wires): avoid other wires + live routed preview + system-owned shapes Extends the existing component-avoiding A* (wireAutoRoute.ts) into the full auto-router the canvas was missing. Three pieces: Wire avoidance with soft costs ------------------------------ Component bodies stay hard-blocked, but wires get graded costs: running parallel on top of another wire (within an 8px corridor) is charged per px, a perpendicular crossing costs a small fixed amount, and bends keep their existing penalty. Crossings must stay possible — hard-blocking wires makes dense boards unroutable and everything would degrade to the default elbow. The compressed grid gains "corridor" coordinates 8px to each side of every wire segment, so the router actually has a lane to run BESIDE a wire; that is also what lays multi-wire runs out as a tidy side-by-side bus, since each new wire routes seeing the previous ones. Wires sharing an endpoint with the route are exempt (wires meeting on a pin must touch there), and only wires within 120px of the route's bbox participate, keeping the grid under the coordinate cap on dense canvases. autoRouted: the system owns the shape until the user takes it ------------------------------------------------------------- New Wire flag, set by pin-to-pin creation and by agent add_wire. Every shape-editing gesture (segment drag, waypoint drag, waypoint insert — five call sites) clears it: from that moment the wire is hand-authored and is NEVER re-shaped, exactly where the user put it. Wires from older projects have no flag and are treated as hand-authored. recalculateAllWirePositions re-routes flagged wires after endpoints move (component drag end, agent batches, mount settle — never per drag frame). This is also what routes agent wires at all: they are created before their elements mount and before pin coords are final, so creation-time routing is impossible; the settle-timer recalc routes them once geometry is real. Live routed preview ------------------- updateWireInProgress routes start->cursor (throttled to 40ms) and the preview renders that path, so the wire dodges components and wires AS THE MOUSE MOVES instead of snapping into shape on the final click. Hand-guided previews (user-placed waypoints) keep the classic path untouched. Verified in the live app: an agent-built breadboard circuit shows 0 wire overlap px and 0 body crossings across all wires, and a hand-started wire aimed collinear with an existing run previews 21px beside it, overlap 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:08:25 +07:00
// @vitest-environment jsdom
/**
* System-owned wire shapes (`autoRouted`): created by the router, re-routed
* by recalculateAllWirePositions when endpoints move, and demoted to
* hand-authored the moment the user edits them. Manual wires must never be
* re-shaped.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { useSimulatorStore } from '../store/useSimulatorStore';
import { expandOrthogonalPoints } from '../utils/wireUtils';
import { WIRE_SEPARATION } from '../utils/wireAutoRoute';
const PIN = [{ name: 'P', x: 0, y: 0, signals: [] }];
function mountComponent(id: string, w = 40, h = 12): void {
document.getElementById(id)?.remove();
document
.querySelector(`.dynamic-component-wrapper[data-component-id="${id}"]`)
?.remove();
const wrapper = document.createElement('div');
wrapper.className = 'dynamic-component-wrapper';
wrapper.setAttribute('data-component-id', id);
Object.defineProperty(wrapper, 'offsetWidth', { value: w, configurable: true });
Object.defineProperty(wrapper, 'offsetHeight', { value: h, configurable: true });
const el = document.createElement('div');
el.id = id;
(el as unknown as { pinInfo: unknown }).pinInfo = PIN;
wrapper.appendChild(el);
document.body.appendChild(wrapper);
}
function wireBetween(
id: string,
fromId: string,
toId: string,
extra: Record<string, unknown> = {},
) {
return {
id,
start: { componentId: fromId, pinName: 'P', x: 0, y: 0 },
end: { componentId: toId, pinName: 'P', x: 0, y: 0 },
waypoints: [],
color: '#000',
...extra,
};
}
/** Total parallel-overlap px between two rendered wires (corridor < separation). */
function overlapPx(w1: { start: never; end: never; waypoints: never }, w2: typeof w1): number {
const pts = (w: typeof w1) =>
expandOrthogonalPoints([
{ x: (w.start as { x: number }).x, y: (w.start as { y: number }).y },
...((w.waypoints as { x: number; y: number }[]) ?? []),
{ x: (w.end as { x: number }).x, y: (w.end as { y: number }).y },
]);
const p1 = pts(w1);
const p2 = pts(w2);
let total = 0;
for (let i = 1; i < p1.length; i++) {
for (let j = 1; j < p2.length; j++) {
const a1 = p1[i - 1]; const b1 = p1[i];
const a2 = p2[j - 1]; const b2 = p2[j];
const h1 = a1.y === b1.y; const h2 = a2.y === b2.y;
if (h1 !== h2) continue;
const gap = h1 ? Math.abs(a1.y - a2.y) : Math.abs(a1.x - a2.x);
if (gap >= WIRE_SEPARATION) continue;
const lo = h1
? Math.max(Math.min(a1.x, b1.x), Math.min(a2.x, b2.x))
: Math.max(Math.min(a1.y, b1.y), Math.min(a2.y, b2.y));
const hi = h1
? Math.min(Math.max(a1.x, b1.x), Math.max(a2.x, b2.x))
: Math.min(Math.max(a1.y, b1.y), Math.max(a2.y, b2.y));
total += Math.max(0, hi - lo);
}
}
return total;
}
describe('recalculateAllWirePositions — auto-route pass', () => {
beforeEach(() => {
document.body.innerHTML = '';
const s = useSimulatorStore.getState();
s.setComponents([
{ id: 'a1', metadataId: 'resistor', x: 0, y: 94, properties: {} },
{ id: 'a2', metadataId: 'resistor', x: 294, y: 94, properties: {} },
{ id: 'b1', metadataId: 'resistor', x: 0, y: 90, properties: {} },
{ id: 'b2', metadataId: 'resistor', x: 294, y: 90, properties: {} },
] as never);
for (const id of ['a1', 'a2', 'b1', 'b2']) mountComponent(id);
s.setWires([]);
});
it('separates two parallel autoRouted wires into side-by-side lanes', () => {
const s = useSimulatorStore.getState();
s.setWires([
wireBetween('w1', 'a1', 'a2', { autoRouted: true }),
wireBetween('w2', 'b1', 'b2', { autoRouted: true }),
] as never);
s.recalculateAllWirePositions();
const [w1, w2] = useSimulatorStore.getState().wires;
// Their natural straight lines sit 4px apart — inside the corridor.
// After the pass they must not ride each other.
expect(overlapPx(w1 as never, w2 as never)).toBe(0);
});
it('never touches a hand-authored wire', () => {
const s = useSimulatorStore.getState();
const manualWaypoints = [{ x: 150, y: 400 }]; // deliberate detour
s.setWires([
wireBetween('auto', 'a1', 'a2', { autoRouted: true }),
wireBetween('manual', 'b1', 'b2', { waypoints: manualWaypoints }),
] as never);
s.recalculateAllWirePositions();
const manual = useSimulatorStore.getState().wires.find((w) => w.id === 'manual')!;
expect(manual.waypoints).toEqual(manualWaypoints);
expect(manual.autoRouted).toBeUndefined();
});
it('routes an agent wire (empty waypoints) around a component in the way', () => {
const s = useSimulatorStore.getState();
// A tall component square in the middle of the a1->a2 line.
s.setComponents([
...useSimulatorStore.getState().components,
{ id: 'blocker', metadataId: 'chip', x: 120, y: 40, properties: {} },
] as never);
mountComponent('blocker', 60, 120); // spans y 40..160 — covers y=100
s.setWires([wireBetween('agentw', 'a1', 'a2', { autoRouted: true })] as never);
s.recalculateAllWirePositions();
const w = useSimulatorStore.getState().wires[0];
// Must have gained waypoints that detour around the blocker.
expect(w.waypoints.length).toBeGreaterThan(0);
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];
if (a.y === b.y) {
const inside = a.y > 40 - 8 && a.y < 160 + 8
&& Math.max(a.x, b.x) > 120 - 8 && Math.min(a.x, b.x) < 180 + 8;
expect(inside, `segment y=${a.y} crosses the blocker`).toBe(false);
}
}
});
it('bb seating wires are never routed', () => {
const s = useSimulatorStore.getState();
s.setWires([
wireBetween('seat', 'a1', 'a2', { bb: true, autoRouted: true }),
] as never);
s.recalculateAllWirePositions();
expect(useSimulatorStore.getState().wires[0].waypoints).toEqual([]);
});
});
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>
2026-07-21 02:35:58 +07:00
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);
}
});
});