Skip to content

Commit cc1f9fb

Browse files
fix(library): compare port seats exactly so both flanks of a fan nest alike
The reported "right side is off, like stmt -> }": a symmetric diagram was drawn lopsided. Its left flank nested its two detours on different corner rings while the right flank collapsed both onto one shared elbow. Root cause is a floating-point comparison on a decision path — the exact hazard this subsystem's "integer, exactly computed" rule exists to prevent, introduced by the ring selection added in the previous commit. A seat and its mirror image lie the same distance from their side's centre in exact arithmetic but not in binary floating point: for a seven-way band, 0.5 - 1/7 = 0.35714285714285715 6/7 - 0.5 = 0.3571428571428571 (smaller in the final bit) Ring choice compared each seat's distance against the largest on its side, so the right-hand member of every mirror pair failed the test by one ulp, lost the roomy ring, and fell back onto the tight corner its neighbour already used. Rounding the distance to whole permille before comparing makes reflection exact — far finer than any seat spacing, far coarser than the artefact. Both flanks now nest identically: bends land at (30,360)/(45,375) and mirror to (350,360)/(335,375). Covered by a regression test that fails on the previous arithmetic. Known remaining limitation, unchanged by this fix: when many edges share one short node side (five on a 70px side seats them 12px apart) a fan leaves the node too narrow to read as separate lines until the routes have travelled some distance. Widening it means letting a crowded side overflow onto its neighbours, which is a change to the shared port-assignment stage rather than to straight edges alone. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4AtGeJaSWiGooNonpVcxN
1 parent 70b9c05 commit cc1f9fb

2 files changed

Lines changed: 55 additions & 1 deletion

File tree

library/lib/utils/geometry/edgeGeometrySolver.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1353,8 +1353,15 @@ function computeAllEdgeGeometryPass(
13531353

13541354
// How far off its side's centre each seat sits, and the largest such offset on
13551355
// every occupied (node, side). Used to nest a fan — see `straightCornerRing`.
1356+
// QUANTISED, and that is the whole point. A seat and its mirror image are the same
1357+
// distance from their side's centre in exact arithmetic, but not in binary
1358+
// floating point: for a seven-way band, 0.5 - 1/7 and 6/7 - 0.5 differ in the last
1359+
// bit. Comparing raw distances therefore made one flank of a symmetric diagram
1360+
// take the roomy corner ring and the other the tight one — a visible asymmetry
1361+
// from a rounding artefact. Rounding to whole permille is far finer than any seat
1362+
// spacing yet coarse enough that reflection is exact.
13561363
const seatOffset = (anchor: FreeformEdgeAnchor): number =>
1357-
Math.abs(anchor.ratio - 0.5)
1364+
Math.round(Math.abs(anchor.ratio - 0.5) * 1000)
13581365
const outermostSeatOffset = new Map<string, number>()
13591366
for (const edge of coordinationEdges) {
13601367
for (const [end, nodeId] of [

library/tests/unit/straightHookRouting.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,53 @@ describe("straight-hook edge routing", () => {
342342
expect(shared).toEqual([])
343343
})
344344

345+
it("nests both flanks of a symmetric fan identically", () => {
346+
// Regression: the corner-ring choice compared each seat's distance from its
347+
// side's centre against the largest on that side. Mirror-paired seats are the
348+
// same distance out in exact arithmetic but NOT in binary floating point — for a
349+
// seven-way band, 0.5 - 1/7 and 6/7 - 0.5 differ in the final bit. One flank
350+
// therefore took the roomy corner ring and the other the tight one, and the
351+
// drawing was visibly lopsided even though every input was symmetric.
352+
const AXIS = 190
353+
const parent = makeNode("stmt", 155, 215, 70, 50)
354+
const columns = [
355+
makeNode("colL", 55, 385, 70, 50),
356+
makeNode("colR", 255, 385, 70, 50),
357+
]
358+
const kids = [
359+
makeNode("outerL", -35, 550, 50, 50),
360+
makeNode("innerL", 15, 550, 50, 50),
361+
makeNode("mid", 155, 310, 70, 50),
362+
makeNode("innerR", 315, 550, 50, 50),
363+
makeNode("outerR", 365, 550, 50, 50),
364+
]
365+
const edges: Edge[] = kids.map((k, i) => ({
366+
id: `e${i}`,
367+
source: "stmt",
368+
target: k.node.id,
369+
type: "SyntaxTreeLink",
370+
data: {},
371+
}))
372+
const { routeById } = computeAllEdgeGeometry(
373+
solverInput([parent, ...columns, ...kids], edges)
374+
)
375+
// e0/e4 are the outer pair, e1/e3 the inner pair.
376+
for (const [left, right] of [
377+
["e0", "e4"],
378+
["e1", "e3"],
379+
]) {
380+
const l = routeById[left]
381+
const r = routeById[right]
382+
expect(l.length).toBe(r.length)
383+
for (let i = 0; i < l.length; i++) {
384+
expect(Math.abs(2 * AXIS - l[i].x - r[i].x)).toBeLessThanOrEqual(1)
385+
expect(l[i].y).toBe(r[i].y)
386+
}
387+
}
388+
// And the two members of one flank still turn at different points.
389+
expect(routeById["e0"][1]).not.toEqual(routeById["e1"][1])
390+
})
391+
345392
it("routes a mirror-symmetric diagram symmetrically", () => {
346393
// Everything below is an exact reflection about x = AXIS, including the
347394
// obstacles, so every route must be the reflection of its partner. Asymmetry

0 commit comments

Comments
 (0)