feat(wires): wokwi-style rounded bends + degenerate path cleanup

Three wiring quality fixes:

- Rounded corners: every bend now renders as a quadratic curve
  (radius 7, clamped to half the shorter adjacent segment), with
  round line caps/joins. Segment/waypoint drag previews and the
  in-progress preview use the same path builder so the look is
  consistent everywhere.

- Degenerate geometry cleanup at render time: the expanded polyline
  is simplified (duplicates, collinear runs, U-turns) before the
  path is emitted, so wires saved with junk waypoints no longer
  render on top of themselves. Stored data is untouched until the
  user edits the wire.

- WYSIWYG commit: finishWireCreation materialises the final-leg
  elbow exactly as the live preview drew it (longer axis first) and
  normalises the stored waypoints. Previously the committed wire
  fell back to horizontal-first and visibly changed shape on click.

simplifyOrthogonalPath moved to wireUtils (re-exported from
wireHitDetection for existing imports); the duplicated inline
expansions in SimulatorCanvas now use the shared helper. Waypoint
dots on idle wires removed (visual noise); endpoint dots stay.
This commit is contained in:
David Montero 2026-07-18 05:02:39 +02:00
parent 2e5ac20eba
commit 152f9e4ce0
6 changed files with 380 additions and 160 deletions

View File

@ -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 }]);
});
});

View File

@ -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) });
}
}

View File

@ -35,7 +35,7 @@ export const WireRenderer: React.FC<WireRendererProps> = ({
const opacity = isSelected || isHovered ? 1 : 0.85;
return (
<g style={{ pointerEvents: 'none' }}>
<g style={{ pointerEvents: 'none' }} strokeLinecap="round" strokeLinejoin="round">
{/* Dark outline for wire crossing effect */}
<path d={path} stroke="#1a1a1a" strokeWidth={outlineW} fill="none" />
@ -69,11 +69,6 @@ export const WireRenderer: React.FC<WireRendererProps> = ({
strokeWidth="1"
/>
<circle cx={wire.end.x} cy={wire.end.y} r="3" fill={color} stroke="#1a1a1a" strokeWidth="1" />
{/* Waypoint dots */}
{waypoints.map((wp, i) => (
<circle key={i} cx={wp.x} cy={wp.y} r="2" fill={color} />
))}
</g>
);
};

View File

@ -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<SimulatorState>((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 }));

View File

@ -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);
}

View File

@ -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 }));
}