339 lines
11 KiB
TypeScript
339 lines
11 KiB
TypeScript
/**
|
|
* Wire utilities: path generation, auto-coloring, keyboard color shortcuts.
|
|
* Implements Wokwi-style orthogonal wire routing.
|
|
*/
|
|
|
|
/** Keyboard shortcut → hex color (matches Wokwi's color palette) */
|
|
export const WIRE_KEY_COLORS: Record<string, string> = {
|
|
'0': '#000000', // Black
|
|
'1': '#8B4513', // Brown
|
|
'2': '#cc0000', // Red
|
|
'3': '#FF8C00', // Orange
|
|
'4': '#FFD700', // Gold
|
|
'5': '#22c55e', // Green
|
|
'6': '#0000cc', // Blue
|
|
'7': '#8B00FF', // Violet
|
|
'8': '#808080', // Gray
|
|
'9': '#FFFFFF', // White
|
|
c: '#00FFFF', // Cyan
|
|
l: '#32CD32', // Limegreen
|
|
m: '#FF00FF', // Magenta
|
|
p: '#800080', // Purple
|
|
y: '#FFFF00', // Yellow
|
|
};
|
|
|
|
/** Default wire color when no specific signal is detected */
|
|
export const DEFAULT_WIRE_COLOR = '#22c55e';
|
|
|
|
/**
|
|
* Jumper palette for breadboard wires — like a real jumper kit, neighbouring
|
|
* wires get visibly different colors instead of a wall of green. Red and
|
|
* black are deliberately absent: they're reserved for power-rail wires.
|
|
*/
|
|
export const WIRE_JUMPER_PALETTE = [
|
|
'#22c55e', // green
|
|
'#0000cc', // blue
|
|
'#FF8C00', // orange
|
|
'#8B00FF', // violet
|
|
'#FFD700', // gold
|
|
'#00FFFF', // cyan
|
|
'#FF00FF', // magenta
|
|
'#8B4513', // brown
|
|
'#808080', // gray
|
|
'#32CD32', // limegreen
|
|
] as const;
|
|
|
|
/**
|
|
* Power-rail hole → mandated wire color: positive rails (tp./bp.) are red,
|
|
* negative rails (tn./bn.) are black, like the stripes on a real breadboard.
|
|
* Returns null for anything that isn't a rail hole.
|
|
*/
|
|
export function railWireColor(pinName: string): string | null {
|
|
if (/^[tb]p\.\d+$/.test(pinName)) return '#cc0000';
|
|
if (/^[tb]n\.\d+$/.test(pinName)) return '#000000';
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Deterministic palette pick for a wire id — stable across reloads so a
|
|
* saved project keeps its colors, and different ids spread across the
|
|
* palette so adjacent jumpers rarely collide.
|
|
*/
|
|
export function jumperColorForId(wireId: string): string {
|
|
let h = 0;
|
|
for (let i = 0; i < wireId.length; i++) {
|
|
h = (h * 31 + wireId.charCodeAt(i)) >>> 0;
|
|
}
|
|
return WIRE_JUMPER_PALETTE[h % WIRE_JUMPER_PALETTE.length];
|
|
}
|
|
|
|
/**
|
|
* Automatically determine wire color from the starting pin name.
|
|
* GND → black, VCC/5V/3.3V/VBUS/VIN → red, everything else → green.
|
|
*/
|
|
export function autoWireColor(pinName: string): string {
|
|
const lower = pinName.toLowerCase();
|
|
if (lower.includes('gnd') || lower === 'ground' || lower === '-' || lower.startsWith('gnd')) {
|
|
return '#000000';
|
|
}
|
|
if (
|
|
lower.includes('vcc') ||
|
|
lower.includes('5v') ||
|
|
lower.includes('3.3v') ||
|
|
lower.includes('3v3') ||
|
|
lower.includes('vbus') ||
|
|
lower.includes('vin') ||
|
|
lower === 'power' ||
|
|
lower === '+' ||
|
|
lower.startsWith('vcc') ||
|
|
lower.startsWith('v+')
|
|
) {
|
|
return '#cc0000';
|
|
}
|
|
return DEFAULT_WIRE_COLOR;
|
|
}
|
|
|
|
interface Point {
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
/**
|
|
* Corner radius (world px) for rounded wire bends, Wokwi-style. Each bend
|
|
* clamps to half the length of its shorter adjacent segment so short
|
|
* segments never overshoot.
|
|
*/
|
|
export const WIRE_BEND_RADIUS = 7;
|
|
|
|
/**
|
|
* Expand a stored point chain into the rendered orthogonal polyline.
|
|
* Between consecutive points that are not axis-aligned an L-shape corner
|
|
* is inserted: horizontal to the next point's X, then vertical to its Y.
|
|
*/
|
|
export function expandOrthogonalPoints(points: Point[]): Point[] {
|
|
if (points.length < 2) return points.map((p) => ({ ...p }));
|
|
const out: Point[] = [{ ...points[0] }];
|
|
for (let i = 1; i < points.length; i++) {
|
|
const prev = points[i - 1];
|
|
const curr = points[i];
|
|
if (prev.x !== curr.x && prev.y !== curr.y) {
|
|
out.push({ x: curr.x, y: prev.y });
|
|
}
|
|
out.push({ ...curr });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Simplify an orthogonal polyline by removing duplicate points and
|
|
* collapsing collinear/U-turn triples.
|
|
*
|
|
* Three consecutive points sharing the same x (or same y) make the middle
|
|
* one redundant — whether the path goes straight through (collinear) or
|
|
* doubles back over itself (U-turn). Dropping the middle point handles
|
|
* both, which is what eliminates wires rendered on top of themselves.
|
|
*/
|
|
export function simplifyOrthogonalPath(pts: Point[]): Point[] {
|
|
if (pts.length <= 2) return pts.map((p) => ({ ...p }));
|
|
|
|
// Drop consecutive duplicates first
|
|
const dedup: Point[] = [];
|
|
for (const p of pts) {
|
|
const last = dedup[dedup.length - 1];
|
|
if (!last || last.x !== p.x || last.y !== p.y) dedup.push({ ...p });
|
|
}
|
|
|
|
// Iteratively collapse three-in-a-row on the same axis until stable
|
|
let result = dedup;
|
|
let changed = true;
|
|
while (changed && result.length > 2) {
|
|
changed = false;
|
|
for (let i = 1; i < result.length - 1; i++) {
|
|
const prev = result[i - 1];
|
|
const curr = result[i];
|
|
const next = result[i + 1];
|
|
if ((prev.x === curr.x && curr.x === next.x) || (prev.y === curr.y && curr.y === next.y)) {
|
|
result = [...result.slice(0, i), ...result.slice(i + 1)];
|
|
changed = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Sub-pixel jogs a hand-drag can leave behind: two parallel runs offset by
|
|
* less than this many world px, joined by a tiny perpendicular step, are
|
|
* fused onto the same line. Kept below the drag snap threshold so it only
|
|
* ever swallows accidental offsets, never deliberate routing.
|
|
*/
|
|
export const MICRO_JOG_EPS = 2;
|
|
|
|
/**
|
|
* Fuse micro jogs: when two parallel runs are joined by a perpendicular
|
|
* step shorter than `eps`, align one run onto the other so the wire reads
|
|
* as a single straight line. The run NOT anchored to a wire endpoint moves
|
|
* (the shorter one when both are free); a jog anchored to endpoints on
|
|
* both sides is structural and stays. Runs until stable.
|
|
*/
|
|
export function fuseMicroJogs(pts: Point[], eps: number = MICRO_JOG_EPS): Point[] {
|
|
const out = pts.map((p) => ({ ...p }));
|
|
if (out.length < 4) return out;
|
|
|
|
let changed = true;
|
|
while (changed) {
|
|
changed = false;
|
|
for (let i = 1; i + 2 < out.length; i++) {
|
|
const a = out[i - 1];
|
|
const p = out[i];
|
|
const q = out[i + 1];
|
|
const b = out[i + 2];
|
|
const beforeAnchored = i - 1 === 0;
|
|
const afterAnchored = i + 2 === out.length - 1;
|
|
|
|
// Horizontal micro jog joining two vertical runs
|
|
if (
|
|
p.y === q.y && p.x !== q.x && Math.abs(p.x - q.x) <= eps &&
|
|
a.x === p.x && a.y !== p.y && b.x === q.x && b.y !== q.y
|
|
) {
|
|
if (beforeAnchored && afterAnchored) continue;
|
|
const moveAfter = beforeAnchored
|
|
? true
|
|
: afterAnchored
|
|
? false
|
|
: Math.abs(b.y - q.y) <= Math.abs(p.y - a.y);
|
|
if (moveAfter) {
|
|
q.x = p.x;
|
|
b.x = p.x;
|
|
} else {
|
|
a.x = q.x;
|
|
p.x = q.x;
|
|
}
|
|
changed = true;
|
|
} else if (
|
|
// Vertical micro jog joining two horizontal runs
|
|
p.x === q.x && p.y !== q.y && Math.abs(p.y - q.y) <= eps &&
|
|
a.y === p.y && a.x !== p.x && b.y === q.y && b.x !== q.x
|
|
) {
|
|
if (beforeAnchored && afterAnchored) continue;
|
|
const moveAfter = beforeAnchored
|
|
? true
|
|
: afterAnchored
|
|
? false
|
|
: Math.abs(b.x - q.x) <= Math.abs(p.x - a.x);
|
|
if (moveAfter) {
|
|
q.y = p.y;
|
|
b.y = p.y;
|
|
} else {
|
|
a.y = q.y;
|
|
p.y = q.y;
|
|
}
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Build an SVG path through an orthogonal polyline with rounded bends.
|
|
* Every interior corner is shortened by the bend radius on both sides and
|
|
* bridged with a quadratic curve whose control point is the corner itself.
|
|
* Corners whose adjacent segments are too short to fit a visible arc fall
|
|
* back to a hard corner.
|
|
*/
|
|
export function roundedPathFromPoints(pts: Point[], radius: number = WIRE_BEND_RADIUS): string {
|
|
if (pts.length === 0) return '';
|
|
if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`;
|
|
|
|
let d = `M ${pts[0].x} ${pts[0].y}`;
|
|
for (let i = 1; i < pts.length - 1; i++) {
|
|
const prev = pts[i - 1];
|
|
const corner = pts[i];
|
|
const next = pts[i + 1];
|
|
const inLen = Math.hypot(corner.x - prev.x, corner.y - prev.y);
|
|
const outLen = Math.hypot(next.x - corner.x, next.y - corner.y);
|
|
const r = Math.min(radius, inLen / 2, outLen / 2);
|
|
if (r < 0.75 || inLen === 0 || outLen === 0) {
|
|
d += ` L ${corner.x} ${corner.y}`;
|
|
continue;
|
|
}
|
|
const inX = corner.x - ((corner.x - prev.x) / inLen) * r;
|
|
const inY = corner.y - ((corner.y - prev.y) / inLen) * r;
|
|
const outX = corner.x + ((next.x - corner.x) / outLen) * r;
|
|
const outY = corner.y + ((next.y - corner.y) / outLen) * r;
|
|
d += ` L ${inX} ${inY} Q ${corner.x} ${corner.y} ${outX} ${outY}`;
|
|
}
|
|
const last = pts[pts.length - 1];
|
|
d += ` L ${last.x} ${last.y}`;
|
|
return d;
|
|
}
|
|
|
|
/**
|
|
* Generate the SVG path for a wire: expand the stored points into the
|
|
* orthogonal polyline, drop degenerate geometry (duplicates, collinear
|
|
* runs, U-turns that render the wire on top of itself), then emit rounded
|
|
* bends. The cleanup runs at render time so wires saved with degenerate
|
|
* waypoints display correctly without touching the stored data.
|
|
*/
|
|
export function generateOrthogonalPath(
|
|
start: Point,
|
|
waypoints: Point[] | undefined,
|
|
end: Point,
|
|
): string {
|
|
const points: Point[] = [start, ...(waypoints ?? []), end];
|
|
if (points.length < 2) return '';
|
|
return roundedPathFromPoints(
|
|
simplifyOrthogonalPath(fuseMicroJogs(expandOrthogonalPoints(points))),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Elbow point for a leg between `from` and a free point (mouse cursor or
|
|
* the just-clicked destination pin): the longer axis goes first. Returns
|
|
* null when the leg is already axis-aligned (no elbow needed).
|
|
*/
|
|
export function previewElbow(from: Point, x: number, y: number): Point | null {
|
|
const dx = Math.abs(x - from.x);
|
|
const dy = Math.abs(y - from.y);
|
|
if (dx === 0 || dy === 0) return null;
|
|
return dx >= dy ? { x, y: from.y } : { x: from.x, y };
|
|
}
|
|
|
|
/**
|
|
* Live preview while drawing: fixed waypoints render like a committed wire;
|
|
* the last leg (to the mouse cursor) adapts its elbow orientation based on
|
|
* whether the horizontal or vertical distance is larger.
|
|
*/
|
|
export function generatePreviewPath(
|
|
start: Point,
|
|
waypoints: Point[],
|
|
mouseX: number,
|
|
mouseY: number,
|
|
): string {
|
|
const fixed = expandOrthogonalPoints([start, ...waypoints]);
|
|
const last = fixed[fixed.length - 1];
|
|
const elbow = previewElbow(last, mouseX, mouseY);
|
|
const pts = simplifyOrthogonalPath([
|
|
...fixed,
|
|
...(elbow ? [elbow] : []),
|
|
{ x: mouseX, y: mouseY },
|
|
]);
|
|
return roundedPathFromPoints(pts);
|
|
}
|
|
|
|
/**
|
|
* Canonical stored waypoints for a wire: every interior corner of the
|
|
* simplified orthogonal polyline, so the stored data matches exactly what
|
|
* is rendered. Call with the final endpoint positions (creation and drag
|
|
* commits) — not with stale/unresolved pins.
|
|
*/
|
|
export function normalizeWireWaypoints(start: Point, waypoints: Point[], end: Point): Point[] {
|
|
const simplified = simplifyOrthogonalPath(
|
|
fuseMicroJogs(expandOrthogonalPoints([start, ...waypoints, end])),
|
|
);
|
|
return simplified.slice(1, -1).map((p) => ({ x: p.x, y: p.y }));
|
|
}
|