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 {
|
import {
|
||||||
expandOrthogonalPoints,
|
expandOrthogonalPoints,
|
||||||
simplifyOrthogonalPath,
|
simplifyOrthogonalPath,
|
||||||
|
fuseMicroJogs,
|
||||||
roundedPathFromPoints,
|
roundedPathFromPoints,
|
||||||
generateOrthogonalPath,
|
generateOrthogonalPath,
|
||||||
generatePreviewPath,
|
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', () => {
|
describe('normalizeWireWaypoints', () => {
|
||||||
it('returns no waypoints for a straight wire', () => {
|
it('returns no waypoints for a straight wire', () => {
|
||||||
expect(normalizeWireWaypoints({ x: 0, y: 0 }, [], { x: 100, y: 0 })).toEqual([]);
|
expect(normalizeWireWaypoints({ x: 0, y: 0 }, [], { x: 100, y: 0 })).toEqual([]);
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import {
|
||||||
simplifyOrthogonalPath,
|
simplifyOrthogonalPath,
|
||||||
insertWaypointAtSegment,
|
insertWaypointAtSegment,
|
||||||
collectAlignmentTargets,
|
collectAlignmentTargets,
|
||||||
|
addOwnWireAlignmentTargets,
|
||||||
snapToNearest,
|
snapToNearest,
|
||||||
} from '../../utils/wireHitDetection';
|
} from '../../utils/wireHitDetection';
|
||||||
import { useIsCoarsePointer } from '../../utils/useTouchDevice';
|
import { useIsCoarsePointer } from '../../utils/useTouchDevice';
|
||||||
|
|
@ -1405,6 +1406,10 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
||||||
sd.isDragging = true;
|
sd.isDragging = true;
|
||||||
const threshold = ALIGN_SNAP_PX / zoomRef.current;
|
const threshold = ALIGN_SNAP_PX / zoomRef.current;
|
||||||
const targets = collectAlignmentTargets(wiresRef.current, sd.wireId);
|
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[] = [];
|
const guides: AlignmentGuide[] = [];
|
||||||
let newValue = sd.axis === 'horizontal' ? world.y : world.x;
|
let newValue = sd.axis === 'horizontal' ? world.y : world.x;
|
||||||
const snap = snapToNearest(
|
const snap = snapToNearest(
|
||||||
|
|
@ -1432,6 +1437,17 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
||||||
if (wire) {
|
if (wire) {
|
||||||
const threshold = ALIGN_SNAP_PX / zoomRef.current;
|
const threshold = ALIGN_SNAP_PX / zoomRef.current;
|
||||||
const targets = collectAlignmentTargets(wiresRef.current, wd.wireId);
|
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[] = [];
|
const guides: AlignmentGuide[] = [];
|
||||||
let snappedX = world.x;
|
let snappedX = world.x;
|
||||||
let snappedY = world.y;
|
let snappedY = world.y;
|
||||||
|
|
@ -1488,6 +1504,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
||||||
const world = toWorld(e.clientX, e.clientY);
|
const world = toWorld(e.clientX, e.clientY);
|
||||||
const threshold = ALIGN_SNAP_PX / zoomRef.current;
|
const threshold = ALIGN_SNAP_PX / zoomRef.current;
|
||||||
const targets = collectAlignmentTargets(wiresRef.current, sd.wireId);
|
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;
|
let newValue = sd.axis === 'horizontal' ? world.y : world.x;
|
||||||
const snap = snapToNearest(
|
const snap = snapToNearest(
|
||||||
newValue,
|
newValue,
|
||||||
|
|
@ -1514,6 +1531,15 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
||||||
if (wire) {
|
if (wire) {
|
||||||
const threshold = ALIGN_SNAP_PX / zoomRef.current;
|
const threshold = ALIGN_SNAP_PX / zoomRef.current;
|
||||||
const targets = collectAlignmentTargets(wiresRef.current, wd.wireId);
|
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 snappedX = world.x;
|
||||||
let snappedY = world.y;
|
let snappedY = world.y;
|
||||||
const snapX = snapToNearest(world.x, targets.xs, threshold);
|
const snapX = snapToNearest(world.x, targets.xs, threshold);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { Wire } from '../types/wire';
|
||||||
import {
|
import {
|
||||||
expandOrthogonalPoints,
|
expandOrthogonalPoints,
|
||||||
simplifyOrthogonalPath,
|
simplifyOrthogonalPath,
|
||||||
|
fuseMicroJogs,
|
||||||
roundedPathFromPoints,
|
roundedPathFromPoints,
|
||||||
} from './wireUtils';
|
} from './wireUtils';
|
||||||
|
|
||||||
|
|
@ -190,6 +191,26 @@ export function collectAlignmentTargets(
|
||||||
return { xs, ys };
|
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`.
|
* Find the nearest candidate from `targets` to `value` within `threshold`.
|
||||||
* Returns the snapped value and the candidate that triggered it, or null
|
* Returns the snapped value and the candidate that triggered it, or null
|
||||||
|
|
@ -288,7 +309,7 @@ export function moveSegment(
|
||||||
export function renderedToWaypoints(
|
export function renderedToWaypoints(
|
||||||
renderedPts: { x: number; y: number }[],
|
renderedPts: { x: number; y: number }[],
|
||||||
): { x: number; y: number }[] {
|
): { x: number; y: number }[] {
|
||||||
const simplified = simplifyOrthogonalPath(renderedPts);
|
const simplified = simplifyOrthogonalPath(fuseMicroJogs(renderedPts));
|
||||||
if (simplified.length <= 2) return [];
|
if (simplified.length <= 2) return [];
|
||||||
return simplified.slice(1, -1).map((p) => ({ x: p.x, y: p.y }));
|
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;
|
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.
|
* Build an SVG path through an orthogonal polyline with rounded bends.
|
||||||
* Every interior corner is shortened by the bend radius on both sides and
|
* Every interior corner is shortened by the bend radius on both sides and
|
||||||
|
|
@ -169,7 +243,9 @@ export function generateOrthogonalPath(
|
||||||
): string {
|
): string {
|
||||||
const points: Point[] = [start, ...(waypoints ?? []), end];
|
const points: Point[] = [start, ...(waypoints ?? []), end];
|
||||||
if (points.length < 2) return '';
|
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.
|
* commits) — not with stale/unresolved pins.
|
||||||
*/
|
*/
|
||||||
export function normalizeWireWaypoints(start: Point, waypoints: Point[], end: Point): Point[] {
|
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 }));
|
return simplified.slice(1, -1).map((p) => ({ x: p.x, y: p.y }));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue