Skip to content

Keep bodies off one another in the layout core - #351

Merged
matt-edmondson merged 1 commit into
mainfrom
claude/layout-overlap-separation
Sep 8, 2026
Merged

Keep bodies off one another in the layout core#351
matt-edmondson merged 1 commit into
mainfrom
claude/layout-overlap-separation

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Every force in the simulation treats a body as a point. Repulsion is measured between centers, the link spring pulls to a fixed rest length, and neither knows how wide a body is. Two bodies can therefore sit at a distance the forces are entirely happy with and still have their rectangles squarely on top of each other — which is what a consumer drawing them sees.

Tuning the forces cannot fix it: the comfortable distance depends on the pair's sizes and the forces do not have them. Two bodies at exactly the same point are worse still — repulsion needs a direction between centers, coincident centers have none, so CalculateRepulsionForces skips the pair and they stay where they are for ever.

So LayoutCore now resolves it positionally after integration, the same way ApplyDirectionalConstraints already does.

What it does

For each overlapping pair, SeparateOverlaps:

  • pushes along the axis they overlap least on — the shorter push, and the one that leaves the arrangement the forces worked out most nearly as it was;
  • shares the correction equally between the two, so the arrangement's centroid does not drift;
  • skips a body that is pinned or frozen, letting the movable one take the whole correction, matching how ApplyDirectionalConstraints treats them;
  • resolves the whole overlap, capped per substep.

The whole overlap rather than a fraction of it, because a fraction loses: between two linked bodies the spring pulls back harder each substep than a fraction of the overlap pushes, and the pair comes to rest still overlapping — just less. Capping the step instead keeps the correction decisive while still letting a deep overlap slide apart over a few frames rather than snapping.

Settings

Two new fields, on the POD LayoutSettings, mirrored on the managed PhysicsSettings, and carried through the C ABI header:

Setting Default Meaning
OverlapMargin 20.0 Clear space kept between body rectangles. 0 disables the pass, for a consumer that arranges its own bodies.
MaxOverlapCorrection 40.0 Per-substep cap on how far an overlapping pair is pushed apart.

20.0 matches the minimum horizontal gap CalculateDirectionalForces already assumes, so the library keeps one notion of clearance.

This is a behaviour change for existing consumers, deliberately: it is on by default, because overlapping bodies are a defect rather than a preference. Anything that wants the old behaviour sets OverlapMargin = 0.

LayoutSettings is a blittable POD crossing the C ABI, so the two doubles were appended to the end of the struct and ktsu_force_directed_layout.h updated to match. Native callers must rebuild against the new header — the struct has grown by 16 bytes.

Testing

tests/ForceDirectedLayout.Tests — 26 passing (20 before). The new OverlapSeparationTests cover linked bodies wider than the spring's rest length ending up clear, coincident bodies separating at all (the case no force tuning reaches), OverlapMargin = 0 leaving them overlapping, a pinned body taking none of the correction, two pinned bodies being left alone, and the per-substep cap holding. ImGuiNodeEditor.Tests passes, and the whole solution builds.

Where this came from

ktsu.Coder's graph editor had the same problem and fixed it in its own view layer (ktsu-dev/Coder#17). It belongs here instead, so every consumer of the layout gets it. Once this ships in a package, Coder's local AstGraph.SeparateOverlaps can go.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QwzCFb8zhd263sZeRbF7ba


Generated by Claude Code

Every force in the simulation treats a body as a point: repulsion is
measured between centers, the link spring pulls to a fixed rest length, and
neither knows how wide a body is. Two bodies can therefore sit at a
distance the forces are entirely happy with and still have their rectangles
squarely on top of each other, which is what a consumer drawing them sees.
Tuning the forces cannot fix it, because the comfortable distance depends
on the pair's sizes and the forces do not have them. Two bodies at exactly
the same point are worse still: repulsion needs a direction between centers
and coincident centers have none, so it skips them and they stay there.

LayoutCore now resolves that positionally after integration, the same way
ApplyDirectionalConstraints does. For each overlapping pair it pushes along
the axis they overlap least on — the shorter push, and the one that leaves
the arrangement the forces worked out most nearly as it was — sharing the
correction equally so the centroid does not drift, and skipping a body that
is pinned or frozen so the movable one takes the whole of it.

The whole overlap is resolved rather than a fraction of it, because a
fraction loses: between two linked bodies the spring pulls back harder each
substep than a fraction of the overlap pushes, and the pair comes to rest
still overlapping. The step is capped instead, so a deep overlap slides
apart over a few frames rather than snapping.

Two settings drive it, on both the POD LayoutSettings and the managed
PhysicsSettings, and carried through the C ABI header: OverlapMargin, the
clear space kept between rectangles, which at 0 disables the pass; and
MaxOverlapCorrection, the per-substep cap.

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