diff --git a/frontend/src/__tests__/wire-path.test.ts b/frontend/src/__tests__/wire-path.test.ts new file mode 100644 index 00000000..26a9a0cb --- /dev/null +++ b/frontend/src/__tests__/wire-path.test.ts @@ -0,0 +1,200 @@ +/** + * Wire path generation: orthogonal expansion, degenerate-geometry cleanup + * and rounded bends (Wokwi-style). + */ + +import { describe, it, expect } from 'vitest'; +import { + expandOrthogonalPoints, + simplifyOrthogonalPath, + roundedPathFromPoints, + generateOrthogonalPath, + generatePreviewPath, + previewElbow, + normalizeWireWaypoints, +} from '../utils/wireUtils'; + +describe('expandOrthogonalPoints', () => { + it('inserts a horizontal-first corner between non-aligned points', () => { + expect( + expandOrthogonalPoints([ + { x: 0, y: 0 }, + { x: 100, y: 50 }, + ]), + ).toEqual([ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 50 }, + ]); + }); + + it('keeps axis-aligned hops as-is', () => { + expect( + expandOrthogonalPoints([ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 50 }, + ]), + ).toEqual([ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 50 }, + ]); + }); +}); + +describe('simplifyOrthogonalPath', () => { + it('collapses a U-turn (wire doubling back over itself)', () => { + // Down 300, back up 230 along the same vertical — the wire_test red + // wire scenario. The middle point is the tip of the phantom stub. + const simplified = simplifyOrthogonalPath([ + { x: 74, y: 278 }, + { x: 74, y: 577 }, + { x: 74, y: 348 }, + { x: 384, y: 348 }, + ]); + expect(simplified).toEqual([ + { x: 74, y: 278 }, + { x: 74, y: 348 }, + { x: 384, y: 348 }, + ]); + }); + + it('collapses collinear runs and duplicate points', () => { + expect( + simplifyOrthogonalPath([ + { x: 0, y: 0 }, + { x: 50, y: 0 }, + { x: 50, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 80 }, + ]), + ).toEqual([ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 80 }, + ]); + }); + + it('never removes the first or last point', () => { + const pts = [ + { x: 0, y: 0 }, + { x: 0, y: 100 }, + { x: 0, y: 50 }, + ]; + const simplified = simplifyOrthogonalPath(pts); + expect(simplified[0]).toEqual({ x: 0, y: 0 }); + expect(simplified[simplified.length - 1]).toEqual({ x: 0, y: 50 }); + }); +}); + +describe('roundedPathFromPoints', () => { + it('emits a quadratic curve at each interior corner', () => { + const d = roundedPathFromPoints( + [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + ], + 7, + ); + expect(d).toBe('M 0 0 L 93 0 Q 100 0 100 7 L 100 100'); + }); + + it('clamps the radius to half the shorter adjacent segment', () => { + const d = roundedPathFromPoints( + [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 100 }, + ], + 7, + ); + // Incoming segment is 4 long → radius clamps to 2 + expect(d).toBe('M 0 0 L 2 0 Q 4 0 4 2 L 4 100'); + }); + + it('falls back to a hard corner when segments are too short to round', () => { + const d = roundedPathFromPoints( + [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 100 }, + ], + 7, + ); + expect(d).toBe('M 0 0 L 1 0 L 1 100'); + }); +}); + +describe('generateOrthogonalPath', () => { + it('cleans degenerate stored waypoints at render time', () => { + // The saved wire_test red wire: VIN → down past the target → back up → + // across. The rendered path must not contain the phantom y=577 tip. + const d = generateOrthogonalPath( + { x: 74, y: 278 }, + [ + { x: 74, y: 577 }, + { x: 74, y: 348 }, + ], + { x: 384, y: 321 }, + ); + expect(d).not.toContain('577'); + expect(d).toContain('Q'); + }); + + it('renders a straight wire with no corners', () => { + expect(generateOrthogonalPath({ x: 0, y: 10 }, [], { x: 200, y: 10 })).toBe( + 'M 0 10 L 200 10', + ); + }); +}); + +describe('previewElbow', () => { + it('goes horizontal-first when dx dominates', () => { + expect(previewElbow({ x: 0, y: 0 }, 100, 40)).toEqual({ x: 100, y: 0 }); + }); + + it('goes vertical-first when dy dominates', () => { + expect(previewElbow({ x: 0, y: 0 }, 40, 100)).toEqual({ x: 0, y: 100 }); + }); + + it('returns null for axis-aligned legs', () => { + expect(previewElbow({ x: 0, y: 0 }, 100, 0)).toBeNull(); + expect(previewElbow({ x: 0, y: 0 }, 0, 100)).toBeNull(); + }); +}); + +describe('generatePreviewPath', () => { + it('matches the committed shape for the vertical-first case', () => { + // Preview with dy > dx bends vertical-first; committing through + // finishWireCreation materialises the same elbow, so the two paths + // must be identical (WYSIWYG). + const preview = generatePreviewPath({ x: 0, y: 0 }, [], 40, 100); + const committed = generateOrthogonalPath( + { x: 0, y: 0 }, + normalizeWireWaypoints({ x: 0, y: 0 }, [{ x: 0, y: 100 }], { x: 40, y: 100 }), + { x: 40, y: 100 }, + ); + expect(preview).toBe(committed); + }); +}); + +describe('normalizeWireWaypoints', () => { + it('returns no waypoints for a straight wire', () => { + expect(normalizeWireWaypoints({ x: 0, y: 0 }, [], { x: 100, y: 0 })).toEqual([]); + }); + + it('materialises implicit corners and drops U-turn junk', () => { + expect( + normalizeWireWaypoints( + { x: 74, y: 278 }, + [ + { x: 74, y: 577 }, + { x: 74, y: 348 }, + ], + { x: 384, y: 348 }, + ), + ).toEqual([{ x: 74, y: 348 }]); + }); +}); diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 4b83fc6f..d760cfb0 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -28,7 +28,7 @@ import { isSpiceMapped } from '../../simulation/spice/componentToSpice'; import { PinOverlay } from './PinOverlay'; import { calculatePinPosition } from '../../utils/pinPositionCalculator'; import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping'; -import { autoWireColor, WIRE_KEY_COLORS } from '../../utils/wireUtils'; +import { autoWireColor, WIRE_KEY_COLORS, expandOrthogonalPoints } from '../../utils/wireUtils'; import { findWireNearPoint, findSegmentNearPoint, @@ -1451,20 +1451,11 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { ); setWaypointDragPreview({ wireId: wd.wireId, waypoints: newWaypoints }); // Reflect the moved bend point in the rendered path live - const stored = [ + const expanded = expandOrthogonalPoints([ { x: wire.start.x, y: wire.start.y }, ...newWaypoints, { x: wire.end.x, y: wire.end.y }, - ]; - const expanded: { x: number; y: number }[] = [stored[0]]; - for (let i = 1; i < stored.length; i++) { - const prev = stored[i - 1]; - const curr = stored[i]; - if (prev.x !== curr.x && prev.y !== curr.y) { - expanded.push({ x: curr.x, y: prev.y }); - } - expanded.push(curr); - } + ]); const overridePath = renderedPointsToPath(simplifyOrthogonalPath(expanded)); setSegmentDragPreview({ wireId: wd.wireId, overridePath }); } @@ -1533,20 +1524,11 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { i === wd.waypointIndex ? { x: snappedX, y: snappedY } : { ...wp }, ); // Run through expand → simplify so collinear waypoints get cleaned up - const stored = [ + const expanded = expandOrthogonalPoints([ { x: wire.start.x, y: wire.start.y }, ...newWaypoints, { x: wire.end.x, y: wire.end.y }, - ]; - const expanded: { x: number; y: number }[] = [stored[0]]; - for (let i = 1; i < stored.length; i++) { - const prev = stored[i - 1]; - const curr = stored[i]; - if (prev.x !== curr.x && prev.y !== curr.y) { - expanded.push({ x: curr.x, y: prev.y }); - } - expanded.push(curr); - } + ]); updateWire(wd.wireId, { waypoints: renderedToWaypoints(expanded) }); } } diff --git a/frontend/src/components/simulator/WireRenderer.tsx b/frontend/src/components/simulator/WireRenderer.tsx index 8ab12441..c65c5105 100644 --- a/frontend/src/components/simulator/WireRenderer.tsx +++ b/frontend/src/components/simulator/WireRenderer.tsx @@ -35,7 +35,7 @@ export const WireRenderer: React.FC = ({ const opacity = isSelected || isHovered ? 1 : 0.85; return ( - + {/* Dark outline for wire crossing effect */} @@ -69,11 +69,6 @@ export const WireRenderer: React.FC = ({ strokeWidth="1" /> - - {/* Waypoint dots */} - {waypoints.map((wp, i) => ( - - ))} ); }; diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index a3bddc00..0feb9af0 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -30,7 +30,12 @@ import { useEditorStore } from './useEditorStore'; import { useVfsStore } from './useVfsStore'; import { buildProjectSdImage, decodeSdFiles, bytesToB64 } from '../utils/sdCardFiles'; import { boardPinToNumber, isBoardComponent } from '../utils/boardPinMapping'; -import { autoWireColor, DEFAULT_WIRE_COLOR } from '../utils/wireUtils'; +import { + autoWireColor, + DEFAULT_WIRE_COLOR, + normalizeWireWaypoints, + previewElbow, +} from '../utils/wireUtils'; import { createSerialBatcher } from './serialBatcher'; import { bindBoard as icBindBoard, @@ -2547,11 +2552,24 @@ export const useSimulatorStore = create((set, get) => { // Finish wire: auto-detect color from pin name const finalColor = color === DEFAULT_WIRE_COLOR ? autoWireColor(endpoint.pinName) : color; + // 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 + // shape the instant the user clicks the destination pin. + const last = waypoints.length + ? waypoints[waypoints.length - 1] + : { x: startEndpoint.x, y: startEndpoint.y }; + const elbow = previewElbow(last, endpoint.x, endpoint.y); + const newWire: Wire = { id: `wire-${Date.now()}`, start: startEndpoint, end: endpoint, - waypoints, + waypoints: normalizeWireWaypoints( + { x: startEndpoint.x, y: startEndpoint.y }, + elbow ? [...waypoints, elbow] : waypoints, + { x: endpoint.x, y: endpoint.y }, + ), color: finalColor, }; set((state) => ({ wires: [...state.wires, newWire], wireInProgress: null })); diff --git a/frontend/src/utils/wireHitDetection.ts b/frontend/src/utils/wireHitDetection.ts index 59b37602..cb84e1f8 100644 --- a/frontend/src/utils/wireHitDetection.ts +++ b/frontend/src/utils/wireHitDetection.ts @@ -4,6 +4,15 @@ */ import type { Wire } from '../types/wire'; +import { + expandOrthogonalPoints, + simplifyOrthogonalPath, + roundedPathFromPoints, +} from './wireUtils'; + +// Re-exported for existing consumers (SimulatorCanvas) — the implementation +// moved to wireUtils so the renderer can share it without an import cycle. +export { simplifyOrthogonalPath }; export interface RenderedSegment { x1: number; @@ -20,25 +29,11 @@ export interface RenderedSegment { * Between each consecutive stored pair, a corner point is inserted if they are not axis-aligned. */ export function getRenderedPoints(wire: Wire): { x: number; y: number }[] { - const stored = [ + return expandOrthogonalPoints([ { x: wire.start.x, y: wire.start.y }, ...(wire.waypoints ?? []), { x: wire.end.x, y: wire.end.y }, - ]; - - if (stored.length < 2) return stored; - - const result: { x: number; y: number }[] = [stored[0]]; - for (let i = 1; i < stored.length; i++) { - const prev = stored[i - 1]; - const curr = stored[i]; - if (prev.x !== curr.x && prev.y !== curr.y) { - // L-shape: horizontal-first corner - result.push({ x: curr.x, y: prev.y }); - } - result.push(curr); - } - return result; + ]); } /** @@ -283,48 +278,6 @@ export function moveSegment( return pts; } -/** - * Simplify an orthogonal path 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 the visible overlapping bumps that - * accumulate after segment drags. - */ -export function simplifyOrthogonalPath( - pts: { x: number; y: number }[], -): { x: number; y: number }[] { - if (pts.length <= 2) return pts.map((p) => ({ ...p })); - - // Drop consecutive duplicates first - const dedup: { x: number; y: number }[] = []; - 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; -} - /** * Convert a list of rendered (expanded) points back to wire waypoints. * Waypoints are the interior corner/bend points (excludes start and end). @@ -341,15 +294,11 @@ export function renderedToWaypoints( } /** - * Build an SVG path string from an ordered list of rendered points (straight segments). + * Build an SVG path string from an ordered list of rendered points. + * Bends are rounded with the same radius as committed wires so segment + * and waypoint drag previews look identical to the final result. */ export function renderedPointsToPath(pts: { x: number; y: number }[]): string { if (pts.length < 2) return ''; - return ( - `M ${pts[0].x} ${pts[0].y}` + - pts - .slice(1) - .map((p) => ` L ${p.x} ${p.y}`) - .join('') - ); + return roundedPathFromPoints(pts); } diff --git a/frontend/src/utils/wireUtils.ts b/frontend/src/utils/wireUtils.ts index f3a184fb..b327d2b1 100644 --- a/frontend/src/utils/wireUtils.ts +++ b/frontend/src/utils/wireUtils.ts @@ -57,10 +57,110 @@ interface Point { } /** - * Generate an orthogonal SVG path through a sequence of points. - * Between each pair of consecutive points, an L-shape is drawn: - * - Go horizontal to the next point's X, then vertical to its Y. - * This matches Wokwi's routing style. + * 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; +} + +/** + * 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, @@ -69,31 +169,25 @@ export function generateOrthogonalPath( ): string { const points: Point[] = [start, ...(waypoints ?? []), end]; if (points.length < 2) return ''; - - let d = `M ${points[0].x} ${points[0].y}`; - - for (let i = 1; i < points.length; i++) { - const prev = points[i - 1]; - const curr = points[i]; - const dx = curr.x - prev.x; - const dy = curr.y - prev.y; - - if (dx === 0 || dy === 0) { - // Already axis-aligned: straight line - d += ` L ${curr.x} ${curr.y}`; - } else { - // L-shape: go horizontal first, then vertical - d += ` L ${curr.x} ${prev.y} L ${curr.x} ${curr.y}`; - } - } - - return d; + return roundedPathFromPoints(simplifyOrthogonalPath(expandOrthogonalPoints(points))); } /** - * Same as generateOrthogonalPath but for live preview: - * the last segment (to mouse cursor) adapts its elbow orientation - * based on whether the horizontal or vertical distance is larger. + * 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, @@ -101,42 +195,24 @@ export function generatePreviewPath( mouseX: number, mouseY: number, ): string { - const fixed: Point[] = [start, ...waypoints]; + const fixed = expandOrthogonalPoints([start, ...waypoints]); const last = fixed[fixed.length - 1]; - const mouse: Point = { x: mouseX, y: mouseY }; - - const dx = Math.abs(mouseX - last.x); - const dy = Math.abs(mouseY - last.y); - - // Choose elbow orientation based on distance: longer axis goes first - let elbowX: number; - let elbowY: number; - if (dx >= dy) { - // Horizontal-first - elbowX = mouseX; - elbowY = last.y; - } else { - // Vertical-first - elbowX = last.x; - elbowY = mouseY; - } - - // Build the fixed segments - let d = ''; - if (fixed.length >= 2) { - d = generateOrthogonalPath(fixed[0], fixed.slice(1), fixed[fixed.length - 1]); - } else { - d = `M ${last.x} ${last.y}`; - } - - // Append the live preview segment - if (dx === 0 && dy === 0) return d; - - if (dx === 0 || dy === 0) { - d += ` L ${mouse.x} ${mouse.y}`; - } else { - d += ` L ${elbowX} ${elbowY} L ${mouse.x} ${mouse.y}`; - } - - return d; + 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(expandOrthogonalPoints([start, ...waypoints, end])); + return simplified.slice(1, -1).map((p) => ({ x: p.x, y: p.y })); }