feat(wires): first-time auto-routing around components
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
This commit is contained in:
parent
abbbbad559
commit
7ed9c51bd3
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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<SimulatorState>((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<SimulatorState>((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,
|
||||
|
|
|
|||
|
|
@ -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<number>([start.x, end.x]);
|
||||
const ysSet = new Set<number>([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<number, number>();
|
||||
const prev = new Map<number, number>();
|
||||
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<string | undefined>,
|
||||
): 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;
|
||||
}
|
||||
Loading…
Reference in New Issue