Skip to content

Measure repulsion across the clear space between node boxes - #375

Merged
matt-edmondson merged 1 commit into
mainfrom
claude/practical-franklin-lt5o3e
Sep 9, 2026
Merged

Measure repulsion across the clear space between node boxes#375
matt-edmondson merged 1 commit into
mainfrom
claude/practical-franklin-lt5o3e

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #371.

The problem

CalculateRepulsionForces measured between body centres, which counts each body's own extent as part of the distance between them. A node pressed against the side of a wide one still had distant centres, so nothing pushed back; two small nodes with a screen of empty space between them read as crowded. The same RepulsionStrength therefore spaced a graph differently depending only on how big its nodes were drawn — which in a node editor is the difference between a literal and the class it belongs to.

The change

The distance is now the one between the two closest points on the pair's bounding boxes: their gap along each axis they are disjoint on, zero once they overlap on both, floored at MinRepulsionDistance so touching bodies push hard rather than infinitely hard. It is a new LayoutCore.ClearDistance helper feeding the existing inverse-square law.

The direction stays along the line between the centres. Taking it from the closest points as well makes every force between a pair sharing a row exactly horizontal, and every force between a pair sharing a column exactly vertical, which leaves repulsion unable to move a body diagonally out of another's way. Measured over ten starting arrangements of ForceLayoutTests.CounterGraph, that draws around half again as many links across bodies they are not an end of (5.6 against 3.1) and leaves the edges several degrees steeper. What the closest points establish is how much room a pair has, not which way they should go.

Recalibration

Because the distance no longer includes the bodies' own extents it is the smaller number, so the same spacing needs a smaller strength. RepulsionStrength goes from 1,200,000 to 600,000 in both LayoutSettings.Defaults and PhysicsSettings, and the demo's two physics presets are halved with it. A caller carrying a value tuned against the old measurement should expect to do the same — that is called out in the XML docs.

Measured over the same ten starts, 6000 steps each:

before (centres, 1.2M) after (clear space, 600K)
settled area 1,143,328 1,144,300
mean edge angle 26.3° 27.5°
links drawn over bodies 2.9 3.1
tightest clear gap 22.0 68.7
mean clear gap 443.3 436.9
worst overlap 0.0 0.0
settled within 100s 1/10 8/10
readable within 10s 10/10 10/10

Spacing and readability are held where they were; the tightest pair in the graph gains three times the room, and the simulation now actually converges.

Tests

New tests/ForceDirectedLayout.Tests/RepulsionTests.cs reads the force directly, one substep in with every other force switched off:

  • equal clear space is pushed equally hard whether the bodies are small, wide, or one of each — the property the issue asks for
  • at equal centre distance the bigger pair is now pushed far harder — the case the old law got wrong
  • axes a pair overlaps on drop out of the measurement, so a row offset inside the bodies' own height changes nothing
  • the push is still along the centre line, in proportion to the centres' offset
  • overlapping, touching, and exactly-one-floor-apart all sit at the floor; beyond it the law resumes
  • and the payoff end to end: two bodies settle with the same room between them at either size, their centres a body width apart where a centre measurement settled both pairs identically

One existing test changed

TwistedLinks_AreNotHeldApartOnTheAxisTheySwapAlong pinned the held pair's resting distance to exactly the overlap clearance (70). Repulsion between two bodies stacked in a column is now their vertical gap alone, so it too pushes along that axis and is the binding constraint — the pair rests at 122. The test now asserts the difference the untwist makes (the free pair closes to 95 vertically and takes 347 of separation on X against the held pair's 278), which is what its name claims, rather than a distance the overlap pass no longer sets by itself. The untwisting itself is unaffected: TwistedLinks_SwapTheirFarEndsIntoPinOrder and Untwisting_ReducesCrossingsWithoutLeavingBodiesOverlapping pass unchanged.

I considered giving repulsion the untangle-axis exemption SeparateOverlaps has, and measured it — it made layouts distinctly worse (links over bodies 7.7–14.3, and 5/10 starts settling) without fixing anything that was actually broken, so it is not in this PR.

Also updated

  • SeparateOverlaps and OverlapSeparationTests remarks, which claimed every force treats a body as a point — no longer true of repulsion, though the pass is still needed for the floor, the springs and coincident bodies
  • The node editor's physics-panel tooltips
  • The debug overlay's "repulsion zone", which drew a circle of MinRepulsionDistance around a node's centre; it is now the node's box grown by that distance, which is the region the floor actually covers, and it goes through EditorToScreen so it is correct under zoom
  • ForceDirectedLayout's README and DESCRIPTION, and the repo README

Verification

ForceDirectedLayout.Tests 49/49, ImGui.NodeEditor.Tests 71/71, NodeGraph.Tests 106/106, full solution builds clean in Release with no warnings.

Not verified here: ImGuiAppDemo.UITests cannot start in this container — examples/ImGuiAppDemo/icon.png is an unfetched Git LFS pointer and the harness fails decoding it in OnStart. It fails identically 26/26 on an unmodified tree, so it is environmental and pre-existing, but it does mean the physics panel and debug overlay changes are not covered by a run here.

🤖 Generated with Claude Code

https://claude.ai/code/session_018b2bn5CKSzpjRV4nxv3c3y


Generated by Claude Code

Repulsion was measured between body centres, which counts each body's own
extent as part of the distance between them. A node pressed against the side
of a wide one still had distant centres, so nothing pushed back; two small
nodes with a screen of space between them read as crowded. The same setting
spaced a graph differently depending only on how big its nodes were drawn,
which in a node editor is the difference between a literal and a class.

The distance is now the one between the two closest points on the pair's
bounding boxes: their gap along each axis they are disjoint on, zero once they
overlap on both, floored at MinRepulsionDistance so touching bodies push hard
rather than infinitely hard.

The direction stays along the line between the centres. Taking it from the
closest points as well makes every force between a pair sharing a row exactly
horizontal and every force between a pair sharing a column exactly vertical,
which leaves repulsion unable to move a body diagonally out of another's way -
measured over ten starting arrangements of the Counter graph, that draws around
half again as many links across bodies they are not an end of (5.6 against 3.1)
and leaves the edges several degrees steeper.

Since the distance no longer includes the bodies' own extents it is the smaller
number, so RepulsionStrength is recalibrated from 1,200,000 to 600,000, and the
demo's two physics presets are halved with it. Over the same ten starts that
leaves the settled graph the size it was (area 1,144,300 against 1,143,328) with
the same edge angle and the same number of links drawn over bodies, while the
tightest pair in it goes from 22 units of clear space to 69, and eight of the
ten starts settle where one did before.

TwistedLinks_AreNotHeldApartOnTheAxisTheySwapAlong pinned the held pair's
resting distance to exactly the overlap clearance. Repulsion is now the binding
constraint on that axis, so the test asserts the difference the untwist makes -
which is what its name claims - rather than a distance the overlap pass no
longer sets alone.

Fixes #371

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018b2bn5CKSzpjRV4nxv3c3y
@matt-edmondson
matt-edmondson merged commit a1c7b33 into main Sep 9, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/practical-franklin-lt5o3e branch September 9, 2026 01:29
@sonarqubecloud

sonarqubecloud Bot commented Sep 9, 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.

Repulsion should be based on the two closest points on the node bounding boxes

2 participants