From abbbbad559cd5bbae224abe6165d0f0238503d3a Mon Sep 17 00:00:00 2001 From: David Montero Date: Sat, 18 Jul 2026 05:39:37 +0200 Subject: [PATCH] feat(wires): fuse sub-pixel jogs + snap segment drags to the wire's own runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/__tests__/wire-path.test.ts | 56 +++++++++++++ .../components/simulator/SimulatorCanvas.tsx | 26 ++++++ frontend/src/utils/wireHitDetection.ts | 23 +++++- frontend/src/utils/wireUtils.ts | 82 ++++++++++++++++++- 4 files changed, 184 insertions(+), 3 deletions(-) diff --git a/frontend/src/__tests__/wire-path.test.ts b/frontend/src/__tests__/wire-path.test.ts index 26a9a0cb..99e440a6 100644 --- a/frontend/src/__tests__/wire-path.test.ts +++ b/frontend/src/__tests__/wire-path.test.ts @@ -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([]); diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index d760cfb0..8b39ad9c 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -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); diff --git a/frontend/src/utils/wireHitDetection.ts b/frontend/src/utils/wireHitDetection.ts index cb84e1f8..82978999 100644 --- a/frontend/src/utils/wireHitDetection.ts +++ b/frontend/src/utils/wireHitDetection.ts @@ -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; ys: Set }, + pts: { x: number; y: number }[], + excludeIndices: Iterable, +): 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 })); } diff --git a/frontend/src/utils/wireUtils.ts b/frontend/src/utils/wireUtils.ts index b327d2b1..b3b4db5b 100644 --- a/frontend/src/utils/wireUtils.ts +++ b/frontend/src/utils/wireUtils.ts @@ -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 })); }