Skip to content

Centre a settled graph on the origin, separately from gravity - #383

Merged
matt-edmondson merged 1 commit into
mainfrom
claude/gravity-centring
Sep 9, 2026
Merged

Centre a settled graph on the origin, separately from gravity#383
matt-edmondson merged 1 commit into
mainfrom
claude/gravity-centring

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

A settled document sits noticeably off to one side. Gravity turns out to be structurally unable to fix that, and the two obvious repairs both make something else worse — so placement is separated from cohesion instead.

Why gravity cannot do it

Gravity pulls every body the same amount whichever side of the target it sits and however far out. Summed over a graph that is a step function of position: it counts bodies rather than measuring them, so anywhere the counts balance it is exactly zero. That is a dead band, not an equilibrium.

The chain shows it unmistakably — settled, then shoved 600 units each way:

settled from +600 from −600
Chain +79.4 +79.5 −79.6

Same distance out, on whichever side it arrived from: both are edges of one dead band. And where the counts do balance is the median of body centres, which for a document with a dense cluster of literals one side and a few large functions the other is nowhere near the middle of what is drawn — on Counter, median −42 against a drawn centre of +161.

Why the obvious fix is worse

Making gravity proportional to distance kills the dead band and improves centring. It also pulls a distant body harder than a near one, so wide nodes get squeezed closer together than narrow ones and settled spacing depends on node size again — exactly what measuring repulsion across clear space rather than between centres was for. Measured: a 400-wide pair settled 160 apart against a 60-wide pair's 224, and SettledPairs_KeepTheSameClearSpace_WhateverTheirSize fails.

What this does instead

LayoutCore.RecentreOnOrigin slides the whole arrangement, positionally, after integration. Every body moves by the same vector, so no distance between any two changes; gravity is left doing the one thing it is good at.

approach score mean off-centre worst size-independent spacing
constant gravity (today) 1.194 62.7 172.0
proportional gravity 1.203 47.6 135.4
this 1.166 5.4 19.5

Score is unchanged (1.166 vs 1.194, inside the measurement's own 0.032 deviation). No defaults change: a re-sweep of all fifteen settings afterwards moved none of them, GravityStrength included, every candidate gain landing inside the 0.1 floor.

Three details that cost a round each to find:

  • Positional, not a force. As a force, MaxForce clamped it per body — a body already at the ceiling lost its share while its neighbour kept theirs, which reshaped the graph, the one thing this must not do. It failed its own rigidity test that way.
  • Stands down when any body is pinned or frozen, since whoever pinned it is saying where the graph goes.
  • Gated on OriginAnchorWeight, so a fixture isolating one force sets it to zero.

The rate is a constant rather than a setting because it decides only how quickly a graph arrives, never where. Picked from a sweep: 0.1 leaves a mean 18 units out, 0.5 leaves 5.4, 1.0 leaves 2.8, 2.0 begins to overshoot.

Testing

76/76 in ForceDirectedLayout.Tests, 98/98 in ImGui.NodeEditor.Tests.

New RecentringTests, verified to fail without the fix with the diagnostics you would want — Counter settled 161 off the origin horizontally, and it settled 80 from one side and -80 from the other, which names the dead band exactly. The third test asserts the whole simulation is translation-equivariant, which is the property that makes a uniform slide safe; it passes either way by design, guarding the invariant rather than the fix.

FlatteningOnly now also pins OriginAnchorWeight to zero. Left on, the slide moves both bodies of a stacked pair equally and splays neither — true, and not what those tests are asking.

🤖 Generated with Claude Code

https://claude.ai/code/session_018b2bn5CKSzpjRV4nxv3c3y


Generated by Claude Code

A settled document sat noticeably off to one side, and gravity turned out
to be unable to do anything about it. It pulls every body the same amount
whichever side of the target it sits and however far out, so summed over
a graph it is a step function of position: it counts bodies rather than
measuring them, and anywhere the counts balance it is exactly zero. That
is a dead band, not an equilibrium. A twelve-node chain settled 79 units
to one side and stayed there; pushed 600 the other way it came to rest 79
units to the other side, the same distance out, on whichever side it
happened to arrive from. And where the counts do balance is the median of
the body centres, which for a document with a dense cluster of literals
on one side and a few large functions on the other is 200 units from the
middle of what is actually drawn.

Making gravity proportional to distance fixes both and costs something
worse. A body further out is then pulled harder, so wide nodes are
squeezed closer together than narrow ones and settled spacing depends on
node size again - which is what measuring repulsion across clear space
rather than between centres was for. Measured, a 400-wide pair settled
160 apart against a 60-wide pair's 224.

So placement is separated from cohesion. RecentreOnOrigin slides the
whole arrangement, positionally, after integration: every body moves by
the same vector, so no distance between any two of them changes, and
gravity is left holding the graph together. It is positional rather than
a force because as a force MaxForce clamped it per body, and a body
already at the ceiling lost its share while its neighbour kept theirs -
which reshaped the graph, the one thing this must not do. It stands down
when any body is pinned, since whoever pinned it is saying where the
graph goes.

Mean offset from the origin over the corpus goes from 62.7 units to 5.4,
worst from 172 to 19.5, and the corpus score is unchanged at 1.166
against 1.194 - inside the measurement's own deviation. A re-sweep of all
fifteen settings afterwards moved none of them, GravityStrength included,
so no default changes here.

The rate was picked from a sweep rather than assumed: 0.1 leaves a graph
a mean 18 units out, 0.5 leaves it 5.4, 1.0 leaves it 2.8, and 2.0 begins
to overshoot. It is a constant rather than a setting because it decides
only how quickly a graph arrives, never where.

FlatteningOnly now also pins OriginAnchorWeight to zero. Left on, the
slide moves both bodies of a stacked pair equally and splays neither,
which is true and not what those tests are asking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018b2bn5CKSzpjRV4nxv3c3y
@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.

2 participants