Skip to content

Commit 5f79feb

Browse files
fix(library): collapse a loop only when its arms actually meet, not at proximity
A loop between perpendicular endpoints (diagram 84) collapsed to a side-jutting L-route the moment its arms came within ~10px of each other — so even a small drag "pushed the edge to the side" instead of just narrowing the loop. That 10px-proximity rule (hasArmCollapse) also auto-straightened any hand-drawn detour between facing endpoints. Removed the proximity collapse entirely. A loop now collapses only when its arms actually FOLD onto each other (the path reverses on itself) or squeeze so tight a stub no longer fits — a real "collapse this loop" gesture. A narrow-but-open loop, and a hand-drawn detour between facing endpoints, are kept exactly as drawn (per user request: "only collapse when there really is a spike", "stay as I drew it"). The fold-collapse and the adjacent-arm lane clamp still turn a full squeeze into the clean direct route. Updated the U-merge / near-overlap / facing-detour unit tests to the new "meet, don't graze" threshold. Full-drag collapse verified for diagrams 83 and 84; routing-quality gate, fresh-bend, interactions and reset e2e green; 1525 unit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 70aa0cf commit 5f79feb

2 files changed

Lines changed: 61 additions & 96 deletions

File tree

library/lib/utils/edgeUtils.ts

Lines changed: 9 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -2740,43 +2740,6 @@ const hasReducedTerminalStub = (
27402740
)
27412741
}
27422742

2743-
const hasArmCollapse = (points: IPoint[], proximityPx: number): boolean => {
2744-
if (points.length < 4) return false
2745-
2746-
for (let i = 0; i < points.length - 1; i++) {
2747-
const aStart = points[i]
2748-
const aEnd = points[i + 1]
2749-
const aIsH = aStart.y === aEnd.y
2750-
const aIsV = aStart.x === aEnd.x
2751-
if (!aIsH && !aIsV) continue
2752-
2753-
for (let j = i + 2; j < points.length - 1; j++) {
2754-
const bStart = points[j]
2755-
const bEnd = points[j + 1]
2756-
const bIsH = bStart.y === bEnd.y
2757-
const bIsV = bStart.x === bEnd.x
2758-
2759-
if (aIsH && bIsH && Math.abs(aStart.y - bStart.y) <= proximityPx) {
2760-
const aMinX = Math.min(aStart.x, aEnd.x)
2761-
const aMaxX = Math.max(aStart.x, aEnd.x)
2762-
const bMinX = Math.min(bStart.x, bEnd.x)
2763-
const bMaxX = Math.max(bStart.x, bEnd.x)
2764-
if (Math.max(aMinX, bMinX) < Math.min(aMaxX, bMaxX)) return true
2765-
}
2766-
2767-
if (aIsV && bIsV && Math.abs(aStart.x - bStart.x) <= proximityPx) {
2768-
const aMinY = Math.min(aStart.y, aEnd.y)
2769-
const aMaxY = Math.max(aStart.y, aEnd.y)
2770-
const bMinY = Math.min(bStart.y, bEnd.y)
2771-
const bMaxY = Math.max(bStart.y, bEnd.y)
2772-
if (Math.max(aMinY, bMinY) < Math.min(aMaxY, bMaxY)) return true
2773-
}
2774-
}
2775-
}
2776-
2777-
return false
2778-
}
2779-
27802743
const collapseTinyOrthogonalDoglegs = (
27812744
points: IPoint[],
27822745
proximityPx: number
@@ -3089,7 +3052,6 @@ export function normalizeOrthogonalEdgePoints(
30893052
sourcePosition,
30903053
targetPosition
30913054
) ||
3092-
hasArmCollapse(sanitized, EDGES.ORTHOGONAL_ARM_OVERLAP_PX) ||
30933055
hasStubCollision
30943056
) {
30953057
return fallback
@@ -3127,20 +3089,20 @@ export function resolveOrthogonalEdgeReleasePoints(
31273089
targetPosition
31283090
)
31293091

3130-
// When a release is invalid *because the user dragged the two parallel arms
3131-
// of a U together (or past each other)*, that is a deliberate "merge the U"
3132-
// gesture, not a bad drag — collapse the released geometry rather than
3133-
// snapping back to the pre-drag wide route. normalizeOrthogonalEdgePoints
3134-
// already routes overlapping input to the clean safe path and re-validates
3135-
// stubs, so any other invalidity still falls back safely.
3092+
// A release that FOLDS an arm flat onto its neighbour (a spike where the path reverses
3093+
// on itself) is a deliberate "collapse this loop" gesture, not a bad drag — keep the
3094+
// released geometry so normalizeOrthogonalEdgePoints routes it to the clean path rather
3095+
// than snapping back. Any other invalidity still falls back safely. A merely NARROW
3096+
// loop (arms close but not touching) is NOT a fold: it is kept exactly as drawn and
3097+
// only collapses once the arms actually meet.
31363098
const sanitized = sanitizeReleasedPoints(
31373099
releasedPoints,
31383100
sourcePoint,
31393101
targetPoint
31403102
)
3141-
const armOverlap = hasArmCollapse(sanitized, EDGES.ORTHOGONAL_ARM_OVERLAP_PX)
3103+
const folded = hasAxisFold(sanitized)
31423104
const pointsToNormalize =
3143-
invalid && !armOverlap ? lastValidPoints : releasedPoints
3105+
invalid && !folded ? lastValidPoints : releasedPoints
31443106

31453107
return normalizeOrthogonalEdgePoints(
31463108
pointsToNormalize,
@@ -3522,8 +3484,7 @@ export function preserveOrthogonalEdgePoints(
35223484
targetPoint,
35233485
sourcePosition,
35243486
targetPosition
3525-
) ||
3526-
hasArmCollapse(result, EDGES.ORTHOGONAL_ARM_OVERLAP_PX)
3487+
)
35273488
) {
35283489
return safePoints
35293490
}

library/tests/unit/edgeUtils.test.ts

Lines changed: 52 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,28 +1081,27 @@ describe("preserveOrthogonalEdgePoints", () => {
10811081
expectOrthogonalSegments(result)
10821082
})
10831083

1084-
it("draws a straight line between facing endpoints on a shared lane, however close", () => {
1085-
// Collinear stubs cannot fold back on each other, so proximity is irrelevant
1086-
// here — detouring two nodes that a straight line already connects is noise.
1084+
it("keeps a hand-drawn detour between facing endpoints as drawn (no auto-straighten)", () => {
1085+
// A detour the user drew is theirs to keep: we no longer flatten a loop between
1086+
// facing endpoints just because a straight line would also connect them. It only
1087+
// collapses when its arms actually fold onto each other.
1088+
const detour = [
1089+
{ x: 0, y: 200 },
1090+
{ x: 30, y: 200 },
1091+
{ x: 30, y: 250 },
1092+
{ x: 20, y: 250 },
1093+
{ x: 20, y: 200 },
1094+
{ x: 50, y: 200 },
1095+
]
10871096
const result = normalizeOrthogonalEdgePoints(
1088-
[
1089-
{ x: 0, y: 200 },
1090-
{ x: 30, y: 200 },
1091-
{ x: 30, y: 250 },
1092-
{ x: 20, y: 250 },
1093-
{ x: 20, y: 200 },
1094-
{ x: 50, y: 200 },
1095-
],
1097+
detour,
10961098
{ x: 0, y: 200 },
10971099
{ x: 50, y: 200 },
10981100
Position.Right,
10991101
Position.Left
11001102
)
11011103

1102-
expect(result).toEqual([
1103-
{ x: 0, y: 200 },
1104-
{ x: 50, y: 200 },
1105-
])
1104+
expect(result).toEqual(detour)
11061105
expectOrthogonalSegments(result)
11071106
})
11081107

@@ -1320,26 +1319,26 @@ describe("preserveOrthogonalEdgePoints", () => {
13201319
expectOrthogonalSegments(result)
13211320
})
13221321

1323-
it("collapses near-overlapping parallel arms only in release normalization", () => {
1322+
it("keeps near-but-not-touching parallel arms as drawn (collapses only when they meet)", () => {
1323+
// Arms 10px apart are a narrow loop the user drew, not a spike — preserved. They
1324+
// only collapse once dragged together far enough that a stub can no longer fit.
1325+
const narrow = [
1326+
{ x: 0, y: 200 },
1327+
{ x: 360, y: 200 },
1328+
{ x: 360, y: 250 },
1329+
{ x: 370, y: 250 },
1330+
{ x: 370, y: 200 },
1331+
{ x: 400, y: 200 },
1332+
]
13241333
const result = normalizeOrthogonalEdgePoints(
1325-
[
1326-
{ x: 0, y: 200 },
1327-
{ x: 360, y: 200 },
1328-
{ x: 360, y: 250 },
1329-
{ x: 370, y: 250 },
1330-
{ x: 370, y: 200 },
1331-
{ x: 400, y: 200 },
1332-
],
1334+
narrow,
13331335
{ x: 0, y: 200 },
13341336
{ x: 400, y: 200 },
13351337
Position.Right,
13361338
Position.Left
13371339
)
13381340

1339-
expect(result).toEqual([
1340-
{ x: 0, y: 200 },
1341-
{ x: 400, y: 200 },
1342-
])
1341+
expect(result).toEqual(narrow)
13431342
expectOrthogonalSegments(result)
13441343
})
13451344

@@ -1477,10 +1476,11 @@ describe("preserveOrthogonalEdgePoints", () => {
14771476
expect(result).toEqual(tightened)
14781477
})
14791478

1480-
// Dragging a U's arm so the two parallel arms meet or cross is a deliberate
1481-
// "merge the U" gesture and must collapse the route — not snap back to the
1482-
// pre-drag wide U. Arms still clearly apart stay a (narrow) U.
1483-
describe("collapses a U when its arms are dragged together", () => {
1479+
// A U collapses only once its arms actually MEET (touch/cross), where the loop folds
1480+
// onto itself or a stub can no longer fit — a real "collapse this loop" gesture. A U
1481+
// whose arms are merely close stays exactly as drawn, so a partial drag never snaps the
1482+
// loop into a straight line.
1483+
describe("collapses a U only when its arms actually meet", () => {
14841484
const S = { x: 0, y: 200 }
14851485
const T = { x: 400, y: 200 }
14861486
const wideU = [
@@ -1500,9 +1500,10 @@ describe("preserveOrthogonalEdgePoints", () => {
15001500
{ x: 400, y: 200 },
15011501
]
15021502

1503-
// Arm gap 5px / 10px (boundary) / crossed: all collapse to a straight line.
1504-
for (const lx of [365, 360, 375]) {
1505-
it(`collapses when the dragged arm lands at x=${lx} (gap ${370 - lx}px)`, () => {
1503+
// Touching (0px), essentially touching (5px, a stub can no longer fit), and crossed
1504+
// all collapse to a straight line.
1505+
for (const lx of [365, 370, 375]) {
1506+
it(`collapses when the dragged arm reaches x=${lx} (gap ${370 - lx}px)`, () => {
15061507
const result = resolveOrthogonalEdgeReleasePoints(
15071508
releaseWithLeftArmAt(lx),
15081509
wideU,
@@ -1519,19 +1520,22 @@ describe("preserveOrthogonalEdgePoints", () => {
15191520
})
15201521
}
15211522

1522-
it("preserves a deliberate narrow U whose arms are still 15px apart", () => {
1523-
const released = releaseWithLeftArmAt(355)
1524-
const result = resolveOrthogonalEdgeReleasePoints(
1525-
released,
1526-
wideU,
1527-
S,
1528-
T,
1529-
Position.Right,
1530-
Position.Left
1531-
)
1532-
expect(result).toEqual(released)
1533-
expectOrthogonalSegments(result)
1534-
})
1523+
// Still clearly apart (10px, 15px): the narrow U is kept exactly as drawn.
1524+
for (const lx of [360, 355]) {
1525+
it(`preserves a narrow U whose arms are ${370 - lx}px apart`, () => {
1526+
const released = releaseWithLeftArmAt(lx)
1527+
const result = resolveOrthogonalEdgeReleasePoints(
1528+
released,
1529+
wideU,
1530+
S,
1531+
T,
1532+
Position.Right,
1533+
Position.Left
1534+
)
1535+
expect(result).toEqual(released)
1536+
expectOrthogonalSegments(result)
1537+
})
1538+
}
15351539
})
15361540

15371541
// A dragged arm must come to REST on the parallel arm two segments away (where the

0 commit comments

Comments
 (0)