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:
David Montero 2026-07-18 05:39:37 +02:00
parent 152f9e4ce0
commit abbbbad559
4 changed files with 184 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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