diff --git a/ForceDirectedLayout.Native/ktsu_force_directed_layout.h b/ForceDirectedLayout.Native/ktsu_force_directed_layout.h
index 45a9792d..36528302 100644
--- a/ForceDirectedLayout.Native/ktsu_force_directed_layout.h
+++ b/ForceDirectedLayout.Native/ktsu_force_directed_layout.h
@@ -56,6 +56,8 @@ typedef struct {
double max_velocity;
double target_physics_hz;
double stability_threshold;
+ double overlap_margin; /* clear space kept between body rectangles; 0 disables the pass */
+ double max_overlap_correction; /* per-substep cap on how far an overlapping pair is pushed apart */
} LayoutSettings;
typedef struct {
diff --git a/ForceDirectedLayout/LayoutCore.cs b/ForceDirectedLayout/LayoutCore.cs
index c90faf82..cdfc154d 100644
--- a/ForceDirectedLayout/LayoutCore.cs
+++ b/ForceDirectedLayout/LayoutCore.cs
@@ -16,6 +16,9 @@ namespace ktsu.ForceDirectedLayout;
///
public sealed class LayoutCore
{
+ /// Overlap depth below which leaves a pair alone.
+ private const double OverlapEpsilon = 0.01;
+
private BodyState[] bodies = [];
private int bodyCount;
@@ -136,6 +139,7 @@ public void Step(double deltaTime)
IntegrateMotion(substepDt);
ApplyDirectionalConstraints();
+ SeparateOverlaps();
}
double energy = 0.0;
@@ -303,6 +307,90 @@ private void ApplyDirectionalConstraints()
}
}
+ ///
+ /// Push apart any pair of bodies whose rectangles are on top of one another.
+ ///
+ ///
+ /// Every force in this 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. That
+ /// cannot be fixed by tuning the forces, because the comfortable distance depends on the pair's
+ /// sizes and the forces do not have them.
+ ///
+ /// So it is resolved positionally, after integration, the same way
+ /// is: for each overlapping pair, 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 — and shared
+ /// equally between the two so the arrangement's centroid does not drift. The correction is capped
+ /// per substep, so a deep overlap slides apart over a few frames rather than snapping.
+ ///
+ ///
+ /// The whole overlap is resolved rather than a fraction of it. 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.
+ ///
+ ///
+ private void SeparateOverlaps()
+ {
+ double margin = Settings.OverlapMargin;
+ if (margin <= 0)
+ {
+ return;
+ }
+
+ double maxCorrection = Settings.MaxOverlapCorrection;
+
+ for (int i = 0; i < bodyCount; i++)
+ {
+ for (int j = i + 1; j < bodyCount; j++)
+ {
+ bool sourceMovable = bodies[i].IsPinned == 0 && bodies[i].IsFrozen == 0;
+ bool targetMovable = bodies[j].IsPinned == 0 && bodies[j].IsFrozen == 0;
+ if (!sourceMovable && !targetMovable)
+ {
+ continue;
+ }
+
+ Vec2D clearance = ((bodies[i].Dimensions + bodies[j].Dimensions) * 0.5) + new Vec2D(margin, margin);
+ Vec2D aCenter = bodies[i].Position + (bodies[i].Dimensions * 0.5);
+ Vec2D bCenter = bodies[j].Position + (bodies[j].Dimensions * 0.5);
+ Vec2D between = bCenter - aCenter;
+
+ double overlapX = clearance.X - Math.Abs(between.X);
+ double overlapY = clearance.Y - Math.Abs(between.Y);
+
+ // An overlap this shallow is not worth a write, and stopping short of exactly zero keeps
+ // rounding from nudging a resolved pair for ever.
+ if (overlapX <= OverlapEpsilon || overlapY <= OverlapEpsilon)
+ {
+ continue;
+ }
+
+ double correction = Math.Min(Math.Min(overlapX, overlapY), maxCorrection);
+
+ // A zero component has no side to be on, so the later body goes the positive way:
+ // arbitrary, but consistent, which is what stops a coincident pair from jittering.
+ Vec2D push = overlapX < overlapY
+ ? new Vec2D(correction * (between.X < 0 ? -1.0 : 1.0), 0)
+ : new Vec2D(0, correction * (between.Y < 0 ? -1.0 : 1.0));
+
+ if (sourceMovable && targetMovable)
+ {
+ bodies[i].Position -= push * 0.5;
+ bodies[j].Position += push * 0.5;
+ }
+ else if (sourceMovable)
+ {
+ bodies[i].Position -= push;
+ }
+ else
+ {
+ bodies[j].Position += push;
+ }
+ }
+ }
+ }
+
private void CalculateGravityForces()
{
if (bodyCount == 0)
diff --git a/ForceDirectedLayout/LayoutSettings.cs b/ForceDirectedLayout/LayoutSettings.cs
index b19a2ee0..d8e5a115 100644
--- a/ForceDirectedLayout/LayoutSettings.cs
+++ b/ForceDirectedLayout/LayoutSettings.cs
@@ -56,6 +56,12 @@ public struct LayoutSettings
/// System energy threshold below which the simulation reports IsStable.
public double StabilityThreshold;
+ /// Clear space kept between body rectangles by the overlap pass. 0 disables the pass.
+ public double OverlapMargin;
+
+ /// Per-substep cap on how far an overlapping pair is pushed apart.
+ public double MaxOverlapCorrection;
+
/// Sensible defaults matching the previous Force<float>/Length<float> values.
public static LayoutSettings Defaults => new()
{
@@ -72,5 +78,7 @@ public struct LayoutSettings
MaxVelocity = 50.0,
TargetPhysicsHz = 120.0,
StabilityThreshold = 1.0,
+ OverlapMargin = 20.0,
+ MaxOverlapCorrection = 40.0,
};
}
diff --git a/ForceDirectedLayout/PhysicsSettings.cs b/ForceDirectedLayout/PhysicsSettings.cs
index 5df8ae7e..0c62df70 100644
--- a/ForceDirectedLayout/PhysicsSettings.cs
+++ b/ForceDirectedLayout/PhysicsSettings.cs
@@ -48,6 +48,12 @@ public sealed record PhysicsSettings
/// System energy threshold below which the simulation reports IsStable.
public double StabilityThreshold { get; init; } = 1.0;
+ /// Clear space kept between body rectangles by the overlap pass. 0 disables the pass.
+ public double OverlapMargin { get; init; } = 20.0;
+
+ /// Per-substep cap on how far an overlapping pair is pushed apart.
+ public double MaxOverlapCorrection { get; init; } = 40.0;
+
/// Convert to the POD used by the AOT core.
public LayoutSettings ToLayoutSettings() => new()
{
@@ -64,6 +70,8 @@ public sealed record PhysicsSettings
MaxVelocity = MaxVelocity,
TargetPhysicsHz = TargetPhysicsHz,
StabilityThreshold = StabilityThreshold,
+ OverlapMargin = OverlapMargin,
+ MaxOverlapCorrection = MaxOverlapCorrection,
};
/// Construct a managed record from the POD .
@@ -82,5 +90,7 @@ public sealed record PhysicsSettings
MaxVelocity = s.MaxVelocity,
TargetPhysicsHz = s.TargetPhysicsHz,
StabilityThreshold = s.StabilityThreshold,
+ OverlapMargin = s.OverlapMargin,
+ MaxOverlapCorrection = s.MaxOverlapCorrection,
};
}
diff --git a/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs b/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs
index 251fd8ff..1c539abd 100644
--- a/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs
+++ b/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs
@@ -180,6 +180,8 @@ public void PhysicsSettings_RoundTrips_ThroughLayoutSettings()
MaxVelocity = 33.0,
TargetPhysicsHz = 144.0,
StabilityThreshold = 0.5,
+ OverlapMargin = 12.0,
+ MaxOverlapCorrection = 25.0,
};
LayoutSettings s = p.ToLayoutSettings();
diff --git a/tests/ForceDirectedLayout.Tests/OverlapSeparationTests.cs b/tests/ForceDirectedLayout.Tests/OverlapSeparationTests.cs
new file mode 100644
index 00000000..03019956
--- /dev/null
+++ b/tests/ForceDirectedLayout.Tests/OverlapSeparationTests.cs
@@ -0,0 +1,210 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.ForceDirectedLayout.Tests;
+
+using System;
+using ktsu.ForceDirectedLayout;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Tests the positional pass that keeps body rectangles off one another.
+///
+///
+/// Every force in the simulation treats a body as a point, so none of them can see that two bodies
+/// are drawn over each other: the comfortable distance depends on the pair's sizes and the forces do
+/// not have them. These cover the pass that does, including the case no amount of force tuning could
+/// ever fix — two bodies at exactly the same point, which repulsion skips for want of a direction.
+///
+[TestClass]
+public class OverlapSeparationTests
+{
+ private static LayoutSettings EnabledDefaults()
+ {
+ LayoutSettings s = LayoutSettings.Defaults;
+ s.Enabled = 1;
+ return s;
+ }
+
+ ///
+ /// Runs a second of simulation at sixty frames a second.
+ ///
+ /// The layout to step.
+ /// How many frames to run.
+ private static void Step(ForceLayout layout, int frames)
+ {
+ for (int frame = 0; frame < frames; frame++)
+ {
+ layout.Step(1.0 / 60.0);
+ }
+ }
+
+ ///
+ /// Asserts that no two of the given bodies are drawn over one another.
+ ///
+ /// Where each body ended up.
+ /// How big each body is, in the same order.
+ private static void AssertNoOverlaps(ReadOnlySpan positions, Vec2D[] dimensions)
+ {
+ for (int i = 0; i < positions.Length; i++)
+ {
+ for (int j = i + 1; j < positions.Length; j++)
+ {
+ bool apart =
+ positions[i].Position.X + dimensions[i].X <= positions[j].Position.X ||
+ positions[j].Position.X + dimensions[j].X <= positions[i].Position.X ||
+ positions[i].Position.Y + dimensions[i].Y <= positions[j].Position.Y ||
+ positions[j].Position.Y + dimensions[j].Y <= positions[i].Position.Y;
+
+ Assert.IsTrue(
+ apart,
+ $"Bodies {i} at {positions[i].Position} and {j} at {positions[j].Position} are drawn over one another.");
+ }
+ }
+ }
+
+ [TestMethod]
+ public void Step_LinkedWideBodies_EndUpClearOfEachOther()
+ {
+ // Wider than the spring's rest length, so the forces alone hold them overlapping: the spring
+ // wants their centers 225 apart and they need 420 to be clear.
+ Vec2D[] dimensions = [new Vec2D(400, 100), new Vec2D(400, 100)];
+ ForceLayout layout = new(EnabledDefaults());
+ layout.SetNodes(
+ [
+ new() { Id = 1, Position = new Vec2D(0, 0), Dimensions = dimensions[0] },
+ new() { Id = 2, Position = new Vec2D(225, 0), Dimensions = dimensions[1] },
+ ]);
+ layout.SetEdges(
+ [
+ new() { SourceBodyId = 1, TargetBodyId = 2 },
+ ]);
+
+ Step(layout, frames: 120);
+
+ Span positions = stackalloc NodePosition[2];
+ layout.GetPositions(positions);
+ AssertNoOverlaps(positions, dimensions);
+ }
+
+ [TestMethod]
+ public void Step_CoincidentBodies_Separate()
+ {
+ // Repulsion is computed from the direction between two centers, and coincident centers have
+ // none, so it skips the pair entirely. Only the overlap pass moves these.
+ Vec2D[] dimensions = [new Vec2D(120, 60), new Vec2D(120, 60)];
+ ForceLayout layout = new(EnabledDefaults());
+ layout.SetNodes(
+ [
+ new() { Id = 1, Position = new Vec2D(200, 200), Dimensions = dimensions[0] },
+ new() { Id = 2, Position = new Vec2D(200, 200), Dimensions = dimensions[1] },
+ ]);
+
+ Step(layout, frames: 60);
+
+ Span positions = stackalloc NodePosition[2];
+ layout.GetPositions(positions);
+ AssertNoOverlaps(positions, dimensions);
+
+ // Shared equally, so neither body carries the whole correction.
+ Assert.AreNotEqual(200.0, positions[0].Position.Y, 0.0001, "the first body should have moved too");
+ Assert.AreNotEqual(200.0, positions[1].Position.Y, 0.0001, "the second body should have moved too");
+ }
+
+ [TestMethod]
+ public void Step_WithOverlapMarginZero_LeavesTheBodiesOverlapping()
+ {
+ // Opting out has to be possible for a consumer that arranges its own bodies, and leaving them
+ // overlapping is what says the separation is this pass's doing rather than the forces'.
+ LayoutSettings settings = EnabledDefaults();
+ settings.OverlapMargin = 0.0;
+
+ Vec2D[] dimensions = [new Vec2D(400, 100), new Vec2D(400, 100)];
+ ForceLayout layout = new(settings);
+ layout.SetNodes(
+ [
+ new() { Id = 1, Position = new Vec2D(0, 0), Dimensions = dimensions[0] },
+ new() { Id = 2, Position = new Vec2D(225, 0), Dimensions = dimensions[1] },
+ ]);
+ layout.SetEdges(
+ [
+ new() { SourceBodyId = 1, TargetBodyId = 2 },
+ ]);
+
+ Step(layout, frames: 120);
+
+ Span positions = stackalloc NodePosition[2];
+ layout.GetPositions(positions);
+ double gap = Math.Abs(positions[1].Position.X - positions[0].Position.X);
+ Assert.IsTrue(gap < dimensions[0].X, $"Without the overlap pass the spring should still hold them overlapping; gap was {gap}.");
+ }
+
+ [TestMethod]
+ public void Step_PinnedBody_TakesNoneOfTheSeparation()
+ {
+ Vec2D[] dimensions = [new Vec2D(100, 100), new Vec2D(100, 100)];
+ ForceLayout layout = new(EnabledDefaults());
+ layout.SetNodes(
+ [
+ new() { Id = 1, Position = new Vec2D(0, 0), Dimensions = dimensions[0], IsPinned = 1 },
+ new() { Id = 2, Position = new Vec2D(20, 0), Dimensions = dimensions[1] },
+ ]);
+
+ Step(layout, frames: 60);
+
+ Span positions = stackalloc NodePosition[2];
+ layout.GetPositions(positions);
+
+ Assert.AreEqual(0.0, positions[0].Position.X, 0.0001, "a pinned body is not pushed out of an overlap");
+ Assert.AreEqual(0.0, positions[0].Position.Y, 0.0001, "a pinned body is not pushed out of an overlap");
+ AssertNoOverlaps(positions, dimensions);
+ }
+
+ [TestMethod]
+ public void Step_TwoPinnedBodies_AreLeftWhereTheyAre()
+ {
+ // Neither can move, so the pass has nothing to do rather than something to do wrongly.
+ Vec2D[] dimensions = [new Vec2D(100, 100), new Vec2D(100, 100)];
+ ForceLayout layout = new(EnabledDefaults());
+ layout.SetNodes(
+ [
+ new() { Id = 1, Position = new Vec2D(0, 0), Dimensions = dimensions[0], IsPinned = 1 },
+ new() { Id = 2, Position = new Vec2D(20, 0), Dimensions = dimensions[1], IsPinned = 1 },
+ ]);
+
+ Step(layout, frames: 60);
+
+ Span positions = stackalloc NodePosition[2];
+ layout.GetPositions(positions);
+ Assert.AreEqual(0.0, positions[0].Position.X, 0.0001);
+ Assert.AreEqual(20.0, positions[1].Position.X, 0.0001);
+ }
+
+ [TestMethod]
+ public void Step_SeparationIsCappedPerSubstep()
+ {
+ // A deep overlap slides apart over several frames rather than snapping, which is what makes the
+ // correction watchable rather than a jump.
+ LayoutSettings settings = EnabledDefaults();
+ settings.GravityStrength = 0.0;
+ settings.RepulsionStrength = 0.0;
+ settings.MaxOverlapCorrection = 10.0;
+
+ ForceLayout layout = new(settings);
+ layout.SetNodes(
+ [
+ new() { Id = 1, Position = new Vec2D(0, 0), Dimensions = new Vec2D(400, 400) },
+ new() { Id = 2, Position = new Vec2D(0, 0), Dimensions = new Vec2D(400, 400) },
+ ]);
+
+ // A frame exactly as long as the target substep is one substep, so the pair moves by at most
+ // one correction: five each way.
+ layout.Step(1.0 / settings.TargetPhysicsHz);
+ Assert.AreEqual(1, layout.LastStepInfo.SubstepCount, "the cap is per substep, so the test has to run exactly one");
+
+ Span positions = stackalloc NodePosition[2];
+ layout.GetPositions(positions);
+ double moved = Math.Abs(positions[1].Position.Y - positions[0].Position.Y);
+ Assert.IsTrue(moved <= 10.0 + 0.0001, $"a single substep moved the pair {moved} apart, past the cap");
+ Assert.IsTrue(moved > 0.0, "the pair should have started to come apart");
+ }
+}