Skip to content

Commit 95e4924

Browse files
fix(library): collapse a squeezed loop to the clean route, not a residual jog
Dragging a loop's arms together is meant to collapse it. Squeezing the free inner arm already produced the clean direct route, but squeezing the PINNED terminal arm left a 30px jog: its port is fixed, so the arm folds back on itself rather than merging, and no simplify pass removes the fold — it read as "it pushes to 30px and never collapses". normalizeOrthogonalEdgePoints now detects a fold (a point where the path reverses on itself along one axis) and hands the collapsed edge to the router for the clean direct path. Placing it here — the shared normalizer used by BOTH the bend release AND the post-drag geometry re-projection — is what makes it stick: doing it only at release let the re-projection (which also normalizes) re-introduce the jog a frame later. Together with the adjacent-arm lane clamp, dragging either arm now collapses the loop to a straight line the moment the arms meet (0px), with no push-apart and no leftover jog. Ordinary S-jogs that never reverse on themselves are untouched. 1523 unit + fresh-bend, interactions, zoom, reset and the routing-quality gate green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fc06111 commit 95e4924

2 files changed

Lines changed: 94 additions & 0 deletions

File tree

library/lib/utils/edgeUtils.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3004,6 +3004,27 @@ const getDegenerateRoute = (
30043004
targetPoint: IPoint
30053005
): IPoint[] => [{ ...sourcePoint }, { ...targetPoint }]
30063006

3007+
/**
3008+
* A point where the path REVERSES on itself along one axis — the two segments meeting
3009+
* there are collinear but point in opposite directions (a spike/fold). This is what a
3010+
* squeezed loop leaves once an arm is dragged flat onto its neighbour: dragging the
3011+
* PINNED terminal arm cannot merge into that neighbour cleanly (its port is fixed), so
3012+
* it folds and leaves a residual jog no simplify pass removes. A fold is never a shape
3013+
* worth keeping, so its presence marks a "collapse this loop" gesture.
3014+
*/
3015+
const hasAxisFold = (points: IPoint[]): boolean => {
3016+
for (let i = 1; i < points.length - 1; i++) {
3017+
const a = points[i - 1]
3018+
const b = points[i]
3019+
const c = points[i + 1]
3020+
const collinear =
3021+
(a.x === b.x && b.x === c.x) || (a.y === b.y && b.y === c.y)
3022+
const reverses = (b.x - a.x) * (c.x - b.x) + (b.y - a.y) * (c.y - b.y) < 0
3023+
if (collinear && reverses) return true
3024+
}
3025+
return false
3026+
}
3027+
30073028
export function normalizeOrthogonalEdgePoints(
30083029
points: IPoint[],
30093030
sourcePoint: IPoint,
@@ -3016,6 +3037,25 @@ export function normalizeOrthogonalEdgePoints(
30163037
return getDegenerateRoute(sourcePoint, targetPoint)
30173038
}
30183039

3040+
// A fold means an arm was squeezed flat onto its neighbour (the loop collapsed). A
3041+
// pinned terminal arm folds rather than merging cleanly, leaving a jog that survives
3042+
// simplify — so hand the collapsed edge back to the router for the clean direct route,
3043+
// exactly as squeezing the inner arm already produces. Done here (not only at release)
3044+
// so the geometry re-projection that runs after a drag cannot re-introduce the jog.
3045+
if (hasAxisFold(points)) {
3046+
return sanitizeReleasedPoints(
3047+
routeOrthogonalPath(
3048+
sourcePoint,
3049+
targetPoint,
3050+
sourcePosition,
3051+
targetPosition,
3052+
obstacles
3053+
),
3054+
sourcePoint,
3055+
targetPoint
3056+
)
3057+
}
3058+
30193059
const hasStubCollision = stubsWouldOverlap(
30203060
sourcePoint,
30213061
targetPoint,

library/tests/unit/edgeUtils.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1580,6 +1580,60 @@ describe("preserveOrthogonalEdgePoints", () => {
15801580
})
15811581
})
15821582

1583+
// Dragging a PINNED terminal arm flat onto its neighbour folds the path back on
1584+
// itself (a spike). No simplify pass removes the residual jog, so normalize hands the
1585+
// collapsed edge to the router for the clean direct route — the same result squeezing
1586+
// the free inner arm already produces. Done in normalize (not just at release) so the
1587+
// post-drag geometry re-projection cannot re-introduce the jog.
1588+
describe("collapses a folded (squeezed-flat) route to the clean direct path", () => {
1589+
const S = { x: 565, y: 335 }
1590+
const T = { x: 645, y: 410 }
1591+
1592+
it("routes a folded terminal squeeze straight instead of keeping the jog", () => {
1593+
// The terminal down-run folded onto the source arm at x=615: 615,335 → up to 140 →
1594+
// back down to 380 reverses on itself.
1595+
const folded = [
1596+
{ x: 565, y: 335 },
1597+
{ x: 615, y: 335 },
1598+
{ x: 615, y: 140 },
1599+
{ x: 615, y: 380 },
1600+
{ x: 645, y: 380 },
1601+
{ x: 645, y: 410 },
1602+
]
1603+
const result = normalizeOrthogonalEdgePoints(
1604+
folded,
1605+
S,
1606+
T,
1607+
Position.Right,
1608+
Position.Top
1609+
)
1610+
expect(result).toEqual([
1611+
{ x: 565, y: 335 },
1612+
{ x: 645, y: 335 },
1613+
{ x: 645, y: 410 },
1614+
])
1615+
expectOrthogonalSegments(result)
1616+
})
1617+
1618+
it("leaves an ordinary jog (no fold) untouched", () => {
1619+
// A genuine S-jog that never reverses on itself is preserved, not flattened.
1620+
const jog = [
1621+
{ x: 0, y: 0 },
1622+
{ x: 40, y: 0 },
1623+
{ x: 40, y: 60 },
1624+
{ x: 100, y: 60 },
1625+
]
1626+
const result = normalizeOrthogonalEdgePoints(
1627+
jog,
1628+
{ x: 0, y: 0 },
1629+
{ x: 100, y: 60 },
1630+
Position.Right,
1631+
Position.Left
1632+
)
1633+
expect(result).toEqual(jog)
1634+
})
1635+
})
1636+
15831637
it("detects mirrored horizontal and vertical stub collisions", () => {
15841638
expect(
15851639
stubsWouldOverlap(

0 commit comments

Comments
 (0)