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:
parent
2e5ac20eba
commit
152f9e4ce0
|
|
@ -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 }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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) });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue