Skip to content

Untwist links that cross where they meet at a node - #366

Merged
matt-edmondson merged 1 commit into
mainfrom
claude/link-untangling
Sep 8, 2026
Merged

Untwist links that cross where they meet at a node#366
matt-edmondson merged 1 commit into
mainfrom
claude/link-untangling

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

What a tangle is actually made of

Two links arriving at one node from two different bodies cross whenever the bodies sit in the opposite vertical order to the pins they arrive at — the upper body's link has to dive under the lower body's to reach the lower pin. In the screenshot that prompted this, assign and return both feed function Add, arriving at Body[0] and Body[1] from bodies stacked the other way round.

Nothing in the simulation could see it. Every force acts on one link or one pair of bodies at a time, and each of those two links is individually short, level and well spaced — they are only wrong about each other.

Measured over 150 random starting arrangements of a graph the size of the Counter document:

per settled layout
twisted pairs 5.76
crossings between links sharing a node 5.79
crossings between links sharing nothing 2.09

A one-to-one match between twists and shared-node crossings, and nearly three times the crossings between unrelated links. Sibling twist is the tangle.

The force

CalculateLinkUntwistForces puts the far ends of two links meeting at a node into their pins' order. Unlike a clearance force it does not decay as it succeeds: it holds full strength until the two ends draw level and switches off only once they have passed, which is what carries a swap through instead of stalling it half-done against the springs. Edges are bucketed by the body at each end, so the pass costs the sum of squared degrees rather than the square of the edge count.

Links with no pin offsets fall back to their bodies' mid-heights, giving both links at a shared node the same pin height and no order to preserve — so the pass correctly does nothing when there is no pin data to be wrong about.

Letting an untangle move

The overlap pass had to give it room. Separating a pair along the very axis it is trying to move on holds it exactly one clearance apart on the wrong side of itself — the standoff a backward edge already hit. The two untangles need opposite freedoms, so each now gets the axis it travels on and is separated on the other:

  • a reorder travels along X → pushed apart on Y (as before)
  • a swap travels along Y → pushed apart on X
  • a pair in the same column, where an X push would move them a whole body width to achieve nothing the swap needs → passes through
  • a body doing both at once → allowed to overlap until one is done

A settled graph is still left with no overlaps at all. Without the exemption a twisted pair comes to rest at exactly the clearance — 70px for the test's bodies, to the pixel; with it they settle 17px apart and take the separation on X instead.

Measured effect

Over the same 150 starts:

measure before after
crossings, links sharing a node 5.79 4.65
crossings, links sharing nothing 2.09 1.04
links drawn over an unrelated body 3.19 3.03
mean angle off horizontal 27.7° 24.8°
worst body overlap 0 0

Total crossings fall 28%. The one cost is hidden link length, 228 → 241px: the same number of links are hidden, slightly more of each.

Rejected on the way

Three approaches were built and measured before this one, and none shipped:

  • A force pushing a body off a link drawn across it did nothing a stronger setting did not undo — it decays to zero exactly where the body needs to end up, so it balances against the springs holding the body just short of clear. Uniformly at or below baseline across a 6×4 strength/margin sweep.
  • The same geometry as a positional correction over-constrains a dense graph and thrashes: mean angle 52°, crossings up 17×.
  • Exempting an untangling pair from repulsion too halves the crossings but doubles the hidden length and leaves 58px overlaps at rest.

One existing test changed meaning

Repulsion_IsWhatSpreadsAGraphOut asserted shape and edge angle, and both stopped measuring what they claimed. Without repulsion a graph used to collapse into a tall column of near-vertical links; it now collapses into a flat crushed ribbon whose links are flatter than the properly spread graph's. The test's claim still holds — the graph is a third of the area and six times as many links are drawn over a body — so it now asserts the room itself, and what the want of it does to the links.

Verification

  • ForceDirectedLayout.Tests 43/43, ImGui.NodeEditor.Tests 71/71, ImGuiAppDemo.UITests Clean ImNodes 3/3.
  • Every new line in LayoutCore is covered (69 new executable lines, 0 missed) — checked against a cobertura report, not assumed.
  • Solution builds clean in Release.

Not verified visually. This environment is headless and cannot run the app. Every number above comes from the simulation and the test suites.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D2KNUKr1xJPTEdDmeF2tQN


Generated by Claude Code

Two links arriving at one node from two different bodies cross whenever
the bodies sit in the opposite vertical order to the pins they arrive at:
the upper body's link has to dive under the lower body's to reach the
lower pin. Nothing in the simulation could see this. Every force acts on
one link or one pair of bodies at a time, and each of those two links is
individually short, level and well spaced - they are only wrong about
each other.

Measured over 150 random starting arrangements of a graph the size of a
small class, that is what a settled tangle is mostly made of: 5.76
twisted pairs against 5.79 crossings between links sharing a node, a
one-to-one match, and nearly three times the 2.09 crossings between links
sharing nothing.

An untwisting force puts the far ends into their pins' order. Unlike a
clearance force it does not decay as it succeeds: it holds full strength
until the two ends draw level and switches off only once they have
passed, which is what carries a swap through instead of stalling it
half-done against the springs.

The overlap pass had to give it room. Separating a pair along the very
axis it is trying to move on holds it exactly one clearance apart on the
wrong side of itself, which is the standoff a backward edge already hit.
The two untangles need opposite freedoms, so each now gets the axis it
travels on and is separated on the other: a reorder along X is pushed
apart on Y, a swap along Y is pushed apart on X, and a pair in the same
column - where an X push would move them a whole body width to achieve
nothing the swap needs - passes through. A settled graph is still left
with no overlaps at all.

Over the same 150 starts:

| measure                              | before | after |
|--------------------------------------|--------|-------|
| crossings, links sharing a node      |   5.79 |  4.65 |
| crossings, links sharing nothing     |   2.09 |  1.04 |
| links drawn over an unrelated body   |   3.19 |  3.03 |
| mean angle off horizontal            |  27.7d | 24.8d |
| worst body overlap                   |      0 |     0 |

Total crossings fall 28%. The one cost is hidden link length, 228 to 241
pixels: the same number of links are hidden, slightly more of each.

Two approaches were measured and rejected on the way. A force pushing a
body off a link it is drawn across did nothing a stronger one did not
undo, because it decays to zero exactly where the body needs to end up.
The same geometry as a positional correction over-constrains a dense
graph and thrashes. Exempting an untangling pair from repulsion as well
halves the crossings but doubles the hidden length and leaves 58-pixel
overlaps.

Repulsion_IsWhatSpreadsAGraphOut asserted shape and edge angle, and both
stopped meaning what they measured: without repulsion a graph used to
collapse into a tall column of near-vertical links, and now collapses
into a flat crushed ribbon whose links are flatter than the properly
spread graph's. Its claim still holds - the graph is a third of the area
and six times as many links are drawn over a body - so it now asserts the
room itself, and what the want of it does to the links.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2KNUKr1xJPTEdDmeF2tQN
@matt-edmondson
matt-edmondson merged commit a64d8ee into main Sep 8, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/link-untangling branch September 8, 2026 12:28
@sonarqubecloud

sonarqubecloud Bot commented Sep 8, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants