Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ForceDirectedLayout.Native/ktsu_force_directed_layout.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
88 changes: 88 additions & 0 deletions ForceDirectedLayout/LayoutCore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
/// </remarks>
public sealed class LayoutCore
{
/// <summary>Overlap depth below which <see cref="SeparateOverlaps"/> leaves a pair alone.</summary>
private const double OverlapEpsilon = 0.01;

private BodyState[] bodies = [];
private int bodyCount;

Expand Down Expand Up @@ -136,6 +139,7 @@
IntegrateMotion(substepDt);

ApplyDirectionalConstraints();
SeparateOverlaps();
}

double energy = 0.0;
Expand Down Expand Up @@ -303,6 +307,90 @@
}
}

/// <summary>
/// Push apart any pair of bodies whose rectangles are on top of one another.
/// </summary>
/// <remarks>
/// 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.
/// <para>
/// So it is resolved positionally, after integration, the same way <see cref="ApplyDirectionalConstraints"/>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
private void SeparateOverlaps()

Check warning on line 333 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 31 to the 15 allowed.

Check warning on line 333 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 31 to the 15 allowed.

Check warning on line 333 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 31 to the 15 allowed.

Check warning on line 333 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 31 to the 15 allowed.

Check failure on line 333 in ForceDirectedLayout/LayoutCore.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 31 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_ImGuiApp&issues=AaB-rqWmVYvxjwHbXKH_&open=AaB-rqWmVYvxjwHbXKH_&pullRequest=351
{
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)

Check warning on line 374 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 374 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 374 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 374 in ForceDirectedLayout/LayoutCore.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_ImGuiApp&issues=AaB-rqWmVYvxjwHbXKIA&open=AaB-rqWmVYvxjwHbXKIA&pullRequest=351
: new Vec2D(0, correction * (between.Y < 0 ? -1.0 : 1.0));

Check warning on line 375 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 375 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 375 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 375 in ForceDirectedLayout/LayoutCore.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_ImGuiApp&issues=AaB-rqWmVYvxjwHbXKIB&open=AaB-rqWmVYvxjwHbXKIB&pullRequest=351

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)
Expand Down
8 changes: 8 additions & 0 deletions ForceDirectedLayout/LayoutSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ public struct LayoutSettings
/// <summary>System energy threshold below which the simulation reports IsStable.</summary>
public double StabilityThreshold;

/// <summary>Clear space kept between body rectangles by the overlap pass. 0 disables the pass.</summary>
public double OverlapMargin;

/// <summary>Per-substep cap on how far an overlapping pair is pushed apart.</summary>
public double MaxOverlapCorrection;

/// <summary>Sensible defaults matching the previous Force&lt;float&gt;/Length&lt;float&gt; values.</summary>
public static LayoutSettings Defaults => new()
{
Expand All @@ -72,5 +78,7 @@ public struct LayoutSettings
MaxVelocity = 50.0,
TargetPhysicsHz = 120.0,
StabilityThreshold = 1.0,
OverlapMargin = 20.0,
MaxOverlapCorrection = 40.0,
};
}
10 changes: 10 additions & 0 deletions ForceDirectedLayout/PhysicsSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ public sealed record PhysicsSettings
/// <summary>System energy threshold below which the simulation reports IsStable.</summary>
public double StabilityThreshold { get; init; } = 1.0;

/// <summary>Clear space kept between body rectangles by the overlap pass. 0 disables the pass.</summary>
public double OverlapMargin { get; init; } = 20.0;

/// <summary>Per-substep cap on how far an overlapping pair is pushed apart.</summary>
public double MaxOverlapCorrection { get; init; } = 40.0;

/// <summary>Convert to the POD <see cref="LayoutSettings"/> used by the AOT core.</summary>
public LayoutSettings ToLayoutSettings() => new()
{
Expand All @@ -64,6 +70,8 @@ public sealed record PhysicsSettings
MaxVelocity = MaxVelocity,
TargetPhysicsHz = TargetPhysicsHz,
StabilityThreshold = StabilityThreshold,
OverlapMargin = OverlapMargin,
MaxOverlapCorrection = MaxOverlapCorrection,
};

/// <summary>Construct a managed record from the POD <see cref="LayoutSettings"/>.</summary>
Expand All @@ -82,5 +90,7 @@ public sealed record PhysicsSettings
MaxVelocity = s.MaxVelocity,
TargetPhysicsHz = s.TargetPhysicsHz,
StabilityThreshold = s.StabilityThreshold,
OverlapMargin = s.OverlapMargin,
MaxOverlapCorrection = s.MaxOverlapCorrection,
};
}
2 changes: 2 additions & 0 deletions tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
210 changes: 210 additions & 0 deletions tests/ForceDirectedLayout.Tests/OverlapSeparationTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Tests the positional pass that keeps body rectangles off one another.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[TestClass]
public class OverlapSeparationTests
{
private static LayoutSettings EnabledDefaults()
{
LayoutSettings s = LayoutSettings.Defaults;
s.Enabled = 1;
return s;
}

/// <summary>
/// Runs a second of simulation at sixty frames a second.
/// </summary>
/// <param name="layout">The layout to step.</param>
/// <param name="frames">How many frames to run.</param>
private static void Step(ForceLayout layout, int frames)
{
for (int frame = 0; frame < frames; frame++)
{
layout.Step(1.0 / 60.0);
}
}

/// <summary>
/// Asserts that no two of the given bodies are drawn over one another.
/// </summary>
/// <param name="positions">Where each body ended up.</param>
/// <param name="dimensions">How big each body is, in the same order.</param>
private static void AssertNoOverlaps(ReadOnlySpan<NodePosition> 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<NodePosition> 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<NodePosition> 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<NodePosition> 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}.");

Check warning on line 138 in tests/ForceDirectedLayout.Tests/OverlapSeparationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsLessThan' instead of 'Assert.IsTrue'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_ImGuiApp&issues=AaB-rqRkVYvxjwHbXKH8&open=AaB-rqRkVYvxjwHbXKH8&pullRequest=351
}

[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<NodePosition> 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<NodePosition> 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<NodePosition> 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");

Check warning on line 207 in tests/ForceDirectedLayout.Tests/OverlapSeparationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsLessThanOrEqualTo' instead of 'Assert.IsTrue'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_ImGuiApp&issues=AaB-rqRkVYvxjwHbXKH9&open=AaB-rqRkVYvxjwHbXKH9&pullRequest=351
Assert.IsTrue(moved > 0.0, "the pair should have started to come apart");

Check warning on line 208 in tests/ForceDirectedLayout.Tests/OverlapSeparationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsGreaterThan' instead of 'Assert.IsTrue'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_ImGuiApp&issues=AaB-rqRkVYvxjwHbXKH-&open=AaB-rqRkVYvxjwHbXKH-&pullRequest=351
}
}