feat(wires): fuse sub-pixel jogs + snap segment drags to the wire's own runs
Hand-aligning a dragged segment could leave two parallel runs a pixel or two apart, joined by a tiny perpendicular step, because alignment snapping only ever targeted OTHER wires' geometry. - Segment and bend-point drags now also snap (6 px threshold) against the dragged wire's own points — excluding the ones being dragged — so a run clicks into line with its neighbour and the exact simplification fuses them into one segment on commit. - fuseMicroJogs: parallel runs offset by under 2 px joined by a tiny step are aligned automatically (the run not anchored to a wire endpoint moves; shorter run yields when both are free). Applied at render time and in renderedToWaypoints/normalizeWireWaypoints, so already-saved crooked wires display straight without touching data.
This commit is contained in:
parent
152f9e4ce0
commit
abbbbad559
|
|
@ -7,6 +7,7 @@ import { describe, it, expect } from 'vitest';
|
|||
import {
|
||||
expandOrthogonalPoints,
|
||||
simplifyOrthogonalPath,
|
||||
fuseMicroJogs,
|
||||
roundedPathFromPoints,
|
||||
generateOrthogonalPath,
|
||||
generatePreviewPath,
|
||||
|
|
@ -180,6 +181,61 @@ describe('generatePreviewPath', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('fuseMicroJogs', () => {
|
||||
it('fuses two vertical runs offset by a sub-eps step (wire_test2 LCD wire)', () => {
|
||||
// Real saved data: runs at x=440.12 and x=441.39 joined by a 1.27 px
|
||||
// horizontal step — the "milimetrically misaligned" wire.
|
||||
const fused = fuseMicroJogs([
|
||||
{ x: 440.12, y: -38.7 },
|
||||
{ x: 440.12, y: 169.67 },
|
||||
{ x: 441.39, y: 169.67 },
|
||||
{ x: 441.39, y: 209.09 },
|
||||
{ x: 417.04, y: 209.09 },
|
||||
]);
|
||||
// The run anchored at the start pin wins; the free run moves onto it.
|
||||
expect(fused.every((p) => p.x !== 441.39)).toBe(true);
|
||||
// After exact simplification the jog is gone entirely.
|
||||
expect(simplifyOrthogonalPath(fused)).toEqual([
|
||||
{ x: 440.12, y: -38.7 },
|
||||
{ x: 440.12, y: 209.09 },
|
||||
{ x: 417.04, y: 209.09 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('moves the shorter run when neither side is anchored to an endpoint', () => {
|
||||
const fused = fuseMicroJogs([
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 100, y: 0 },
|
||||
{ x: 100, y: 200 }, // long vertical run at x=100
|
||||
{ x: 101.5, y: 200 }, // 1.5 px jog
|
||||
{ x: 101.5, y: 220 }, // short vertical run at x=101.5
|
||||
{ x: 200, y: 220 },
|
||||
]);
|
||||
expect(fused.every((p) => p.x !== 101.5)).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves a jog anchored to endpoints on both sides alone', () => {
|
||||
const pts = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 0, y: 50 },
|
||||
{ x: 1.5, y: 50 },
|
||||
{ x: 1.5, y: 100 },
|
||||
];
|
||||
expect(fuseMicroJogs(pts)).toEqual(pts);
|
||||
});
|
||||
|
||||
it('ignores steps larger than the tolerance', () => {
|
||||
const pts = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 0, y: 50 },
|
||||
{ x: 10, y: 50 },
|
||||
{ x: 10, y: 100 },
|
||||
{ x: 50, y: 100 },
|
||||
];
|
||||
expect(fuseMicroJogs(pts)).toEqual(pts);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeWireWaypoints', () => {
|
||||
it('returns no waypoints for a straight wire', () => {
|
||||
expect(normalizeWireWaypoints({ x: 0, y: 0 }, [], { x: 100, y: 0 })).toEqual([]);
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import {
|
|||
simplifyOrthogonalPath,
|
||||
insertWaypointAtSegment,
|
||||
collectAlignmentTargets,
|
||||
addOwnWireAlignmentTargets,
|
||||
snapToNearest,
|
||||
} from '../../utils/wireHitDetection';
|
||||
import { useIsCoarsePointer } from '../../utils/useTouchDevice';
|
||||
|
|
@ -1405,6 +1406,10 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
|||
sd.isDragging = true;
|
||||
const threshold = ALIGN_SNAP_PX / zoomRef.current;
|
||||
const targets = collectAlignmentTargets(wiresRef.current, sd.wireId);
|
||||
// Snap against the wire's own runs too, so a dragged segment can
|
||||
// line up with (and fuse into) its neighbours instead of ending
|
||||
// up millimetres off.
|
||||
addOwnWireAlignmentTargets(targets, sd.renderedPts, [sd.segIndex, sd.segIndex + 1]);
|
||||
const guides: AlignmentGuide[] = [];
|
||||
let newValue = sd.axis === 'horizontal' ? world.y : world.x;
|
||||
const snap = snapToNearest(
|
||||
|
|
@ -1432,6 +1437,17 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
|||
if (wire) {
|
||||
const threshold = ALIGN_SNAP_PX / zoomRef.current;
|
||||
const targets = collectAlignmentTargets(wiresRef.current, wd.wireId);
|
||||
// Own-wire targets (start, end, other bends) minus the dragged
|
||||
// bend itself, so it can fuse back onto its own wire's lines.
|
||||
addOwnWireAlignmentTargets(
|
||||
targets,
|
||||
[
|
||||
{ x: wire.start.x, y: wire.start.y },
|
||||
...wd.originalWaypoints,
|
||||
{ x: wire.end.x, y: wire.end.y },
|
||||
],
|
||||
[wd.waypointIndex + 1],
|
||||
);
|
||||
const guides: AlignmentGuide[] = [];
|
||||
let snappedX = world.x;
|
||||
let snappedY = world.y;
|
||||
|
|
@ -1488,6 +1504,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
|||
const world = toWorld(e.clientX, e.clientY);
|
||||
const threshold = ALIGN_SNAP_PX / zoomRef.current;
|
||||
const targets = collectAlignmentTargets(wiresRef.current, sd.wireId);
|
||||
addOwnWireAlignmentTargets(targets, sd.renderedPts, [sd.segIndex, sd.segIndex + 1]);
|
||||
let newValue = sd.axis === 'horizontal' ? world.y : world.x;
|
||||
const snap = snapToNearest(
|
||||
newValue,
|
||||
|
|
@ -1514,6 +1531,15 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
|||
if (wire) {
|
||||
const threshold = ALIGN_SNAP_PX / zoomRef.current;
|
||||
const targets = collectAlignmentTargets(wiresRef.current, wd.wireId);
|
||||
addOwnWireAlignmentTargets(
|
||||
targets,
|
||||
[
|
||||
{ x: wire.start.x, y: wire.start.y },
|
||||
...wd.originalWaypoints,
|
||||
{ x: wire.end.x, y: wire.end.y },
|
||||
],
|
||||
[wd.waypointIndex + 1],
|
||||
);
|
||||
let snappedX = world.x;
|
||||
let snappedY = world.y;
|
||||
const snapX = snapToNearest(world.x, targets.xs, threshold);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { Wire } from '../types/wire';
|
|||
import {
|
||||
expandOrthogonalPoints,
|
||||
simplifyOrthogonalPath,
|
||||
fuseMicroJogs,
|
||||
roundedPathFromPoints,
|
||||
} from './wireUtils';
|
||||
|
||||
|
|
@ -190,6 +191,26 @@ export function collectAlignmentTargets(
|
|||
return { xs, ys };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the dragged wire's OWN geometry as snap targets, so a dragged
|
||||
* segment or bend point can align — and, after simplification, fuse —
|
||||
* with the rest of its own wire. `excludeIndices` are the indices of the
|
||||
* points being dragged; including them would pin the drag at its current
|
||||
* position.
|
||||
*/
|
||||
export function addOwnWireAlignmentTargets(
|
||||
targets: { xs: Set<number>; ys: Set<number> },
|
||||
pts: { x: number; y: number }[],
|
||||
excludeIndices: Iterable<number>,
|
||||
): void {
|
||||
const skip = new Set(excludeIndices);
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
if (skip.has(i)) continue;
|
||||
targets.xs.add(pts[i].x);
|
||||
targets.ys.add(pts[i].y);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the nearest candidate from `targets` to `value` within `threshold`.
|
||||
* Returns the snapped value and the candidate that triggered it, or null
|
||||
|
|
@ -288,7 +309,7 @@ export function moveSegment(
|
|||
export function renderedToWaypoints(
|
||||
renderedPts: { x: number; y: number }[],
|
||||
): { x: number; y: number }[] {
|
||||
const simplified = simplifyOrthogonalPath(renderedPts);
|
||||
const simplified = simplifyOrthogonalPath(fuseMicroJogs(renderedPts));
|
||||
if (simplified.length <= 2) return [];
|
||||
return simplified.slice(1, -1).map((p) => ({ x: p.x, y: p.y }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,6 +121,80 @@ export function simplifyOrthogonalPath(pts: Point[]): Point[] {
|
|||
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
|
||||
|
|
@ -169,7 +243,9 @@ export function generateOrthogonalPath(
|
|||
): string {
|
||||
const points: Point[] = [start, ...(waypoints ?? []), end];
|
||||
if (points.length < 2) return '';
|
||||
return roundedPathFromPoints(simplifyOrthogonalPath(expandOrthogonalPoints(points)));
|
||||
return roundedPathFromPoints(
|
||||
simplifyOrthogonalPath(fuseMicroJogs(expandOrthogonalPoints(points))),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -213,6 +289,8 @@ export function generatePreviewPath(
|
|||
* commits) — not with stale/unresolved pins.
|
||||
*/
|
||||
export function normalizeWireWaypoints(start: Point, waypoints: Point[], end: Point): Point[] {
|
||||
const simplified = simplifyOrthogonalPath(expandOrthogonalPoints([start, ...waypoints, end]));
|
||||
const simplified = simplifyOrthogonalPath(
|
||||
fuseMicroJogs(expandOrthogonalPoints([start, ...waypoints, end])),
|
||||
);
|
||||
return simplified.slice(1, -1).map((p) => ({ x: p.x, y: p.y }));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue