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"); + } +}