305 lines
13 KiB
TypeScript
305 lines
13 KiB
TypeScript
/**
|
|
* First-time wire auto-routing around component bounding boxes.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
routeAroundObstacles,
|
|
collectWireSegments,
|
|
ROUTE_MARGIN,
|
|
WIRE_SEPARATION,
|
|
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('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', () => {
|
|
// 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);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('routeAroundObstacles — wire avoidance (soft costs)', () => {
|
|
const seg = (x1: number, y1: number, x2: number, y2: number) => ({
|
|
a: { x: x1, y: y1 },
|
|
b: { x: x2, y: y2 },
|
|
});
|
|
|
|
/** Worst parallel-overlap px of the full route against the segments. */
|
|
const parallelOverlap = (
|
|
start: { x: number; y: number },
|
|
corners: { x: number; y: number }[],
|
|
end: { x: number; y: number },
|
|
segs: Array<{ a: { x: number; y: number }; b: { x: number; y: number } }>,
|
|
) => {
|
|
const pts = expandOrthogonalPoints([start, ...corners, end]);
|
|
let total = 0;
|
|
for (let i = 1; i < pts.length; i++) {
|
|
const a = pts[i - 1];
|
|
const b = pts[i];
|
|
const horizontal = a.y === b.y;
|
|
for (const s of segs) {
|
|
if ((s.a.y === s.b.y) !== horizontal) continue;
|
|
const gap = horizontal ? Math.abs(a.y - s.a.y) : Math.abs(a.x - s.a.x);
|
|
if (gap >= WIRE_SEPARATION) continue;
|
|
const lo = horizontal
|
|
? Math.max(Math.min(a.x, b.x), Math.min(s.a.x, s.b.x))
|
|
: Math.max(Math.min(a.y, b.y), Math.min(s.a.y, s.b.y));
|
|
const hi = horizontal
|
|
? Math.min(Math.max(a.x, b.x), Math.max(s.a.x, s.b.x))
|
|
: Math.min(Math.max(a.y, b.y), Math.max(s.a.y, s.b.y));
|
|
total += Math.max(0, hi - lo);
|
|
}
|
|
}
|
|
return total;
|
|
};
|
|
|
|
it('null when no obstacles and no wires', () => {
|
|
expect(routeAroundObstacles({ x: 0, y: 0 }, { x: 300, y: 0 }, [], [])).toBeNull();
|
|
});
|
|
|
|
it('dodges sideways instead of riding along a collinear wire', () => {
|
|
// Existing wire runs exactly along the direct path start->end, but
|
|
// does NOT terminate on either pin (a wire ending on our own pin is
|
|
// exempt — it must touch there).
|
|
const start = { x: 0, y: 100 };
|
|
const end = { x: 300, y: 100 };
|
|
const existing = [seg(-50, 100, 350, 100)];
|
|
const corners = routeAroundObstacles(start, end, [], existing);
|
|
expect(corners).not.toBeNull();
|
|
// The routed shape must NOT overlap the existing wire in parallel
|
|
// (endpoints touching at the shared start/end region are exempt zones,
|
|
// but this wire shares no endpoint: it is exactly under the route).
|
|
expect(parallelOverlap(start, corners!, end, existing)).toBe(0);
|
|
});
|
|
|
|
it('crossing a perpendicular wire stays allowed (no absurd detours)', () => {
|
|
// A vertical wire fully spanning the corridor: crossing is unavoidable.
|
|
const start = { x: 0, y: 100 };
|
|
const end = { x: 300, y: 100 };
|
|
const existing = [seg(150, -10000, 150, 10000)];
|
|
// Route may be null (direct considered fine) or a route that still
|
|
// crosses; either way it must not fail or detour to infinity.
|
|
const corners = routeAroundObstacles(start, end, [], existing);
|
|
if (corners) {
|
|
// All corners stay within a sane window around the route.
|
|
for (const c of corners!) {
|
|
expect(Math.abs(c.y - 100)).toBeLessThan(200);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('a wire sharing an endpoint with the route is exempt', () => {
|
|
// Another wire starts at the same pin — near the pin they MUST touch.
|
|
const start = { x: 0, y: 100 };
|
|
const end = { x: 300, y: 100 };
|
|
const sharing = [seg(0, 100, 0, 300)]; // starts exactly at `start`
|
|
expect(routeAroundObstacles(start, end, [], sharing)).toBeNull();
|
|
});
|
|
|
|
it('routes a second parallel wire as a side-by-side lane (bus look)', () => {
|
|
// First wire occupies y=100; the second, 4px away vertically at both
|
|
// pins, should shift to a clear lane rather than ride on the first.
|
|
const start = { x: 0, y: 104 };
|
|
const end = { x: 300, y: 104 };
|
|
const existing = [seg(-50, 100, 350, 100)];
|
|
const corners = routeAroundObstacles(start, end, [], existing);
|
|
expect(corners).not.toBeNull();
|
|
expect(parallelOverlap(start, corners!, end, existing)).toBe(0);
|
|
});
|
|
|
|
it('wires outside the window are ignored (grid stays small)', () => {
|
|
// 200 far-away wires must not blow the coordinate cap or the route.
|
|
const start = { x: 0, y: 0 };
|
|
const end = { x: 300, y: 0 };
|
|
const far = Array.from({ length: 200 }, (_, i) => seg(5000 + i * 10, 0, 5000 + i * 10, 500));
|
|
expect(routeAroundObstacles(start, end, [], far)).toBeNull();
|
|
});
|
|
|
|
it('degrades to null rather than failing when boxed in', () => {
|
|
const start = { x: 0, y: 0 };
|
|
const end = { x: 10, y: 0 };
|
|
const cage: ObstacleRect[] = [
|
|
{ x: -50, y: -50, w: 120, h: 20 },
|
|
{ x: -50, y: 30, w: 120, h: 20 },
|
|
];
|
|
// Whatever happens, it returns corners or null — never throws.
|
|
const r = routeAroundObstacles(start, end, cage, []);
|
|
expect(r === null || Array.isArray(r)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('collectWireSegments', () => {
|
|
it('skips bb wires and the wire being routed', () => {
|
|
const wires = [
|
|
{ id: 'w1', start: { x: 0, y: 0 }, end: { x: 100, y: 0 }, waypoints: [] },
|
|
{ id: 'seat', bb: true, start: { x: 5, y: 5 }, end: { x: 5, y: 5 }, waypoints: [] },
|
|
{ id: 'me', start: { x: 0, y: 50 }, end: { x: 100, y: 50 }, waypoints: [] },
|
|
];
|
|
const segs = collectWireSegments(wires as never, 'me');
|
|
expect(segs.length).toBeGreaterThan(0);
|
|
// Only w1's horizontal run at y=0 — nothing at y=50 or from the seat.
|
|
expect(segs.every((s) => s.a.y === 0 && s.b.y === 0)).toBe(true);
|
|
});
|
|
|
|
it('expands implicit elbows so costs see what the user sees', () => {
|
|
const wires = [
|
|
{ id: 'w1', start: { x: 0, y: 0 }, end: { x: 100, y: 80 }, waypoints: [] },
|
|
];
|
|
const segs = collectWireSegments(wires as never);
|
|
// Diagonal endpoints render as an L: one horizontal + one vertical.
|
|
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);
|
|
}
|
|
});
|
|
});
|