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
64 changes: 64 additions & 0 deletions ForceDirectedLayout/LayoutCore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@
/// <summary>Overlap depth below which <see cref="SeparateOverlaps"/> leaves a pair alone.</summary>
private const double OverlapEpsilon = 0.01;

/// <summary>
/// Horizontal clearance an edge needs per unit of vertical drop, as a fraction of that drop, for its
/// rendered curve to stay inside the channel between its two endpoint bodies.
/// </summary>
/// <remarks>
/// ImNodes renders a link as a cubic bezier whose inner control points are offset horizontally by
/// <c>0.25 * length</c> from each pin. Writing <c>gap</c> for the clear horizontal span between the
/// source's right edge and the target's left edge, the curve's x-coordinate is monotonic - it never
/// doubles back over either body - exactly when <c>gap >= 0.25 * length</c>. Substituting
/// <c>length = sqrt(gap^2 + dy^2)</c> and solving gives <c>gap >= |dy| / sqrt(15)</c>, so the ratio
/// below is <c>1 / sqrt(15)</c>, a cap of about 75.5 degrees off horizontal. Links are drawn beneath
/// the node backgrounds, so a curve that doubles back is a curve that disappears.
/// </remarks>
public const double BezierClearanceRatio = 0.2581988897471611;

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

Expand Down Expand Up @@ -133,6 +148,7 @@

CalculateRepulsionForces();
CalculateLinkForces();
CalculateLinkFlatteningForces();
CalculateDirectionalForces();
CalculateGravityForces();

Expand Down Expand Up @@ -224,6 +240,54 @@
}
}

/// <summary>
/// Splay an edge's endpoints apart horizontally until the clear span between their facing edges is
/// wide enough for the rendered curve, per <see cref="BezierClearanceRatio"/>. Steep edges are the
/// ones that need it; once an edge is flat enough the force switches off, so this shapes angles
/// rather than stretching the graph. It is a soft constraint balanced against the link spring, so
/// equilibrium settles just inside the bound rather than exactly on it.
/// </summary>
private void CalculateLinkFlatteningForces()
{
double strength = Settings.LinkFlatteningStrength;
if (strength <= 0)
{
return;
}

double margin = Settings.LinkFlatteningMargin;

for (int e = 0; e < edgeCount; e++)
{
int s = edges[e].SourceIndex;
int t = edges[e].TargetIndex;
if ((uint)s >= (uint)bodyCount || (uint)t >= (uint)bodyCount)
{
continue;
}

// Approximate the pins by the facing edges of the two bodies at their centre heights.
double sourceRight = bodies[s].Position.X + bodies[s].Dimensions.X;
double targetLeft = bodies[t].Position.X;
double gap = targetLeft - sourceRight;

double sourceCenterY = bodies[s].Position.Y + (bodies[s].Dimensions.Y * 0.5);
double targetCenterY = bodies[t].Position.Y + (bodies[t].Dimensions.Y * 0.5);
double verticalDrop = Math.Abs(targetCenterY - sourceCenterY);

double required = (verticalDrop * BezierClearanceRatio) + margin;
double violation = required - gap;
if (violation <= 0)
{
continue;
}

double forceX = strength * violation;
bodies[s].Force += new Vec2D(-forceX, 0);
bodies[t].Force += new Vec2D(forceX, 0);
}
}

private void CalculateDirectionalForces()
{
double bias = Settings.DirectionalBias;
Expand Down Expand Up @@ -330,7 +394,7 @@
/// and the pair comes to rest still overlapping, just less.
/// </para>
/// </remarks>
private void SeparateOverlaps()

Check warning on line 397 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 397 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.
{
double margin = Settings.OverlapMargin;
if (margin <= 0)
Expand Down Expand Up @@ -371,8 +435,8 @@
// 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 438 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 438 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.
: new Vec2D(0, correction * (between.Y < 0 ? -1.0 : 1.0));

Check warning on line 439 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 439 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

if (sourceMovable && targetMovable)
{
Expand Down
11 changes: 11 additions & 0 deletions ForceDirectedLayout/LayoutSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ public struct LayoutSettings
/// <summary>Strength of the horizontal source-left/target-right ordering bias along edges. 0 disables it.</summary>
public double DirectionalBias;

/// <summary>
/// Strength of the horizontal splay that keeps an edge's rendered curve clear of its own endpoint
/// bodies. 0 disables it. See <see cref="LayoutCore.BezierClearanceRatio"/> for the geometry.
/// </summary>
public double LinkFlatteningStrength;

/// <summary>Extra horizontal clearance demanded on top of the derived bezier bound, in position units.</summary>
public double LinkFlatteningMargin;

/// <summary>Strength of the gravity force pulling each body toward the gravity target.</summary>
public double GravityStrength;

Expand Down Expand Up @@ -69,6 +78,8 @@ public struct LayoutSettings
RepulsionStrength = 1_200_000.0,
LinkSpringStrength = 0.5,
DirectionalBias = 0.5,
LinkFlatteningStrength = 0.5,
LinkFlatteningMargin = 0.0,
GravityStrength = 50.0,
OriginAnchorWeight = 1.0,
DampingFactor = 0.5,
Expand Down
13 changes: 13 additions & 0 deletions ForceDirectedLayout/PhysicsSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ public sealed record PhysicsSettings
/// <summary>Strength of the horizontal source-left/target-right ordering bias. 0 disables it.</summary>
public double DirectionalBias { get; init; } = 0.5;

/// <summary>
/// Strength of the horizontal splay that keeps an edge's rendered curve clear of its own endpoint
/// bodies. 0 disables it. See <see cref="LayoutCore.BezierClearanceRatio"/> for the geometry.
/// </summary>
public double LinkFlatteningStrength { get; init; } = 0.5;

/// <summary>Extra horizontal clearance demanded on top of the derived bezier bound, in position units.</summary>
public double LinkFlatteningMargin { get; init; }

/// <summary>Strength of the gravity force pulling each body toward the gravity target.</summary>
public double GravityStrength { get; init; } = 50.0;

Expand Down Expand Up @@ -61,6 +70,8 @@ public sealed record PhysicsSettings
RepulsionStrength = RepulsionStrength,
LinkSpringStrength = LinkSpringStrength,
DirectionalBias = DirectionalBias,
LinkFlatteningStrength = LinkFlatteningStrength,
LinkFlatteningMargin = LinkFlatteningMargin,
GravityStrength = GravityStrength,
OriginAnchorWeight = OriginAnchorWeight,
DampingFactor = DampingFactor,
Expand All @@ -81,6 +92,8 @@ public sealed record PhysicsSettings
RepulsionStrength = s.RepulsionStrength,
LinkSpringStrength = s.LinkSpringStrength,
DirectionalBias = s.DirectionalBias,
LinkFlatteningStrength = s.LinkFlatteningStrength,
LinkFlatteningMargin = s.LinkFlatteningMargin,
GravityStrength = s.GravityStrength,
OriginAnchorWeight = s.OriginAnchorWeight,
DampingFactor = s.DampingFactor,
Expand Down
4 changes: 3 additions & 1 deletion ForceDirectedLayout/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![NuGet](https://img.shields.io/nuget/v/ktsu.ForceDirectedLayout?logo=nuget)](https://nuget.org/packages/ktsu.ForceDirectedLayout)
[![License](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/ktsu-dev/ImGuiApp/blob/main/LICENSE.md)

ForceDirectedLayout settles a graph into a readable shape: bodies repel each other, edges pull like springs, gravity keeps the whole thing together, and overlaps are pushed apart. It is a pure simulation with no rendering, no UI dependency, and no runtime package dependencies — double precision throughout, AOT- and trim-clean, and exposed at three levels so a caller can pick how much ceremony they want. The same core is published as a native shared library for consumers outside .NET.
ForceDirectedLayout settles a graph into a readable shape: bodies repel each other, edges pull like springs, gravity keeps the whole thing together, steep edges are splayed apart so a renderer's curves stay clear of the bodies at their ends, and overlaps are pushed apart. It is a pure simulation with no rendering, no UI dependency, and no runtime package dependencies — double precision throughout, AOT- and trim-clean, and exposed at three levels so a caller can pick how much ceremony they want. The same core is published as a native shared library for consumers outside .NET.

## Features

Expand Down Expand Up @@ -108,6 +108,8 @@ PhysicsSettings settings = new()
LinkSpringStrength = 0.5, // Hooke's-law constant for edges
RestLinkLength = 225.0, // spring rest length
DirectionalBias = 0.5, // biases sources left and targets right
LinkFlatteningStrength = 0.5, // splays steep edges so their curves stay visible
LinkFlatteningMargin = 0.0, // extra clearance on top of the derived bound
GravityStrength = 50.0, // pull toward the gravity target
OriginAnchorWeight = 1.0, // 0 = centroid, 1 = world origin
DampingFactor = 0.5, // velocity retained per second
Expand Down
16 changes: 16 additions & 0 deletions examples/ImGuiAppDemo/Demos/CleanImNodesDemo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@
}
}

private void RenderPhysicsControls()

Check warning on line 292 in examples/ImGuiAppDemo/Demos/CleanImNodesDemo.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

Check warning on line 292 in examples/ImGuiAppDemo/Demos/CleanImNodesDemo.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 27 to the 15 allowed.
{
PhysicsSettings currentSettings = engine.PhysicsSettings;
bool settingsChanged = false;
Expand Down Expand Up @@ -348,6 +348,20 @@
currentSettings = currentSettings with { DirectionalBias = directionalBias };
settingsChanged = true;
}

float linkFlatteningStrength = (float)currentSettings.LinkFlatteningStrength;
if (DemoProbe.SliderFloat("Link Flattening", ref linkFlatteningStrength, 0.0f, 2.0f))
{
currentSettings = currentSettings with { LinkFlatteningStrength = linkFlatteningStrength };
settingsChanged = true;
}

float linkFlatteningMargin = (float)currentSettings.LinkFlatteningMargin;
if (DemoProbe.SliderFloat("Link Flattening Margin (px)", ref linkFlatteningMargin, 0.0f, 200.0f))
{
currentSettings = currentSettings with { LinkFlatteningMargin = linkFlatteningMargin };
settingsChanged = true;
}
}

// Gravity settings
Expand Down Expand Up @@ -380,6 +394,7 @@
RepulsionStrength = 2_000_000.0,
LinkSpringStrength = 0.3,
DirectionalBias = 0.3,
LinkFlatteningStrength = 0.3,
GravityStrength = 20.0,
OriginAnchorWeight = 0.2,
DampingFactor = 0.95,
Expand All @@ -401,6 +416,7 @@
RepulsionStrength = 10_000_000.0,
LinkSpringStrength = 1.0,
DirectionalBias = 0.8,
LinkFlatteningStrength = 1.0,
GravityStrength = 100.0,
OriginAnchorWeight = 0.4,
DampingFactor = 0.85,
Expand All @@ -424,7 +440,7 @@
}
}

private (PhysicsSettings Settings, bool Changed) RenderDampingAndLimitsControls(PhysicsSettings currentSettings, bool settingsChanged, bool enabled)

Check warning on line 443 in examples/ImGuiAppDemo/Demos/CleanImNodesDemo.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

Check warning on line 443 in examples/ImGuiAppDemo/Demos/CleanImNodesDemo.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 21 to the 15 allowed.
{
if (DemoProbe.Header("Damping & Limits"))
{
Expand Down
129 changes: 129 additions & 0 deletions tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@
RepulsionStrength = 42.0,
LinkSpringStrength = 0.25,
DirectionalBias = 0.1,
LinkFlatteningStrength = 0.7,
LinkFlatteningMargin = 12.0,
GravityStrength = 7.0,
OriginAnchorWeight = 0.3,
DampingFactor = 0.4,
Expand Down Expand Up @@ -312,6 +314,133 @@
Assert.IsTrue(finalDistance < initialDistance, $"Spring should pull nodes closer; was {initialDistance}, now {finalDistance}.");
}

/// <summary>
/// Settings that isolate the flattening force: no repulsion, no gravity, no ordering bias, and a
/// spring only strong enough to hold the pair together.
/// </summary>
private static PhysicsSettings FlatteningOnly() => new()
{
Enabled = true,
RepulsionStrength = 0,
GravityStrength = 0,
DirectionalBias = 0,
LinkSpringStrength = 0.1,
OverlapMargin = 0,
DampingFactor = 0.1,
};

[TestMethod]
public void LinkFlattening_VerticallyStackedPair_SplaysApartHorizontally()
{
ForceDirectedLayout<TestBody, TestEdge> layout = CreateLayout(FlatteningOnly());
List<TestBody> bodies =
[
new TestBody(1, new Vec2D(0, 0), new Vec2D(100, 50), Vec2D.Zero, Vec2D.Zero, false),
new TestBody(2, new Vec2D(0, 400), new Vec2D(100, 50), Vec2D.Zero, Vec2D.Zero, false),
];
List<TestEdge> edges = [new TestEdge(1, 2)];

// Heavy damping makes this settle slowly, so give it enough simulated time to converge.
for (int i = 0; i < 3000; i++)
{
layout.Step(bodies, edges, 0.016);
}

double gap = bodies[1].Position.X - (bodies[0].Position.X + bodies[0].Dimensions.X);
// Both bodies are the same height, so the centre offsets cancel out of the drop.
double drop = Math.Abs(bodies[1].Position.Y - bodies[0].Position.Y);
double required = drop * LayoutCore.BezierClearanceRatio;

// The force is a soft constraint balancing the link spring, so equilibrium sits just inside the
// bound rather than exactly on it - the residual violation is what holds the spring off.
Assert.IsTrue(gap >= required * 0.95, $"Clear span {gap} should reach the bezier bound {required} for a drop of {drop}.");

Check warning on line 356 in tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsGreaterThanOrEqualTo' instead of 'Assert.IsTrue'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_ImGuiApp&issues=AaB_gx8bNolIRDFClAAs&open=AaB_gx8bNolIRDFClAAs&pullRequest=360
Assert.IsTrue(drop < 400.0, $"The pair started 400 apart vertically and should have flattened; drop was {drop}.");

Check warning on line 357 in tests/ForceDirectedLayout.Tests/ForceLayoutTests.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_gx8bNolIRDFClAAt&open=AaB_gx8bNolIRDFClAAt&pullRequest=360
}

[TestMethod]
public void LinkFlattening_DanglingEdge_IsSkipped()
{
ForceDirectedLayout<TestBody, TestEdge> layout = CreateLayout(FlatteningOnly());
List<TestBody> bodies =
[
new TestBody(1, new Vec2D(0, 0), new Vec2D(100, 50), Vec2D.Zero, Vec2D.Zero, false),
new TestBody(2, new Vec2D(0, 400), new Vec2D(100, 50), Vec2D.Zero, Vec2D.Zero, false),
];

// Node 999 does not exist, so the edge resolves to index -1 at both ends.
List<TestEdge> edges = [new TestEdge(1, 999), new TestEdge(999, 2)];

for (int i = 0; i < 20; i++)
{
layout.Step(bodies, edges, 0.016);
}

Assert.AreEqual(new Vec2D(0, 0), bodies[0].Position, "A dangling edge must not push its resolvable end.");
Assert.AreEqual(new Vec2D(0, 400), bodies[1].Position, "A dangling edge must not push its resolvable end.");
}

[TestMethod]
public void LinkFlattening_AlreadyFlatPair_IsLeftAlone()
{
ForceDirectedLayout<TestBody, TestEdge> layout = CreateLayout(FlatteningOnly() with { LinkSpringStrength = 0 });
List<TestBody> bodies =
[
new TestBody(1, new Vec2D(0, 0), new Vec2D(100, 50), Vec2D.Zero, Vec2D.Zero, false),
new TestBody(2, new Vec2D(400, 0), new Vec2D(100, 50), Vec2D.Zero, Vec2D.Zero, false),
];
List<TestEdge> edges = [new TestEdge(1, 2)];

for (int i = 0; i < 50; i++)
{
layout.Step(bodies, edges, 0.016);
}

Assert.AreEqual(new Vec2D(0, 0), bodies[0].Position, "A horizontal edge already clears the bound, so nothing should push.");
Assert.AreEqual(new Vec2D(400, 0), bodies[1].Position, "A horizontal edge already clears the bound, so nothing should push.");
}

[TestMethod]
public void LinkFlattening_ZeroStrength_DisablesTheForce()
{
ForceDirectedLayout<TestBody, TestEdge> layout = CreateLayout(
FlatteningOnly() with { LinkFlatteningStrength = 0, LinkSpringStrength = 0 });
List<TestBody> bodies =
[
new TestBody(1, new Vec2D(0, 0), new Vec2D(100, 50), Vec2D.Zero, Vec2D.Zero, false),
new TestBody(2, new Vec2D(0, 400), new Vec2D(100, 50), Vec2D.Zero, Vec2D.Zero, false),
];
List<TestEdge> edges = [new TestEdge(1, 2)];

for (int i = 0; i < 50; i++)
{
layout.Step(bodies, edges, 0.016);
}

Assert.AreEqual(0.0, bodies[0].Position.X, "With the force off, a stacked pair must not splay.");
Assert.AreEqual(0.0, bodies[1].Position.X, "With the force off, a stacked pair must not splay.");
}

[TestMethod]
public void LinkFlattening_Margin_AddsClearanceOnTopOfTheBound()
{
ForceDirectedLayout<TestBody, TestEdge> layout = CreateLayout(
FlatteningOnly() with { LinkFlatteningMargin = 150.0, LinkSpringStrength = 0 });
List<TestBody> bodies =
[
new TestBody(1, new Vec2D(0, 0), new Vec2D(100, 50), Vec2D.Zero, Vec2D.Zero, false),
new TestBody(2, new Vec2D(400, 0), new Vec2D(100, 50), Vec2D.Zero, Vec2D.Zero, false),
];
List<TestEdge> edges = [new TestEdge(1, 2)];

for (int i = 0; i < 200; i++)
{
layout.Step(bodies, edges, 0.016);
}

double gap = bodies[1].Position.X - (bodies[0].Position.X + bodies[0].Dimensions.X);
Assert.IsTrue(gap >= 150.0, $"A margin of 150 should hold even a flat edge that far apart; gap was {gap}.");

Check warning on line 441 in tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsGreaterThanOrEqualTo' instead of 'Assert.IsTrue'

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

[TestMethod]
public void GenericFacade_PinnedNode_DoesNotMove()
{
Expand Down
38 changes: 38 additions & 0 deletions tests/ImGuiAppDemo.UITests/AppDemoUITests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,44 @@ public void CleanImNodes_PhysicsControlsRespond()
Assert.IsTrue(IsVisible("Strong Physics"), "The physics presets should survive being applied.");
}

/// <summary>
/// The link-flattening sliders live under a collapsed header inside a panel that is disabled until
/// physics is on, so reaching them takes both a toggle and an expand.
/// </summary>
[TestMethod]
public void CleanImNodes_LinkFlatteningSlidersRespond()
{
OpenTab(CleanImNodesTab);

harness.Click("Enable Physics");
harness.Step(2);
harness.Click("Link Springs");
harness.Step(2);

foreach (string slider in new[] { "Link Flattening", "Link Flattening Margin (px)" })
{
Assert.IsTrue(IsVisible(slider), $"Expanding Link Springs should reveal '{slider}'.");
DragSliderTrack(slider);
harness.Step(2);
}

Assert.IsTrue(IsVisible("Link Flattening"), "The flattening sliders should survive being dragged.");
}

/// <summary>
/// Drags a slider along its track so its value actually moves, which is what drives the caller's
/// change branch. A slider's item rectangle spans the track *and* the label drawn to its right, so
/// the drag stays in the left portion to be sure it lands on the track rather than the text.
/// </summary>
private void DragSliderTrack(string name)
{
Rectangle rect = harness.Probe.Rect(name)
?? throw new AssertFailedException($"No item matching '{name}' has been marked.");

float y = rect.MinY + (rect.Height / 2f);
harness.Mouse.Drag(rect.MinX + (rect.Width * 0.1f), y, rect.MinX + (rect.Width * 0.45f), y);
}

[TestMethod]
public void Utilities_OffersTheBuiltInImGuiWindows()
{
Expand Down