diff --git a/ForceDirectedLayout/LayoutCore.cs b/ForceDirectedLayout/LayoutCore.cs
index cdfc154d..648b4bf3 100644
--- a/ForceDirectedLayout/LayoutCore.cs
+++ b/ForceDirectedLayout/LayoutCore.cs
@@ -19,6 +19,21 @@ public sealed class LayoutCore
/// Overlap depth below which leaves a pair alone.
private const double OverlapEpsilon = 0.01;
+ ///
+ /// 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.
+ ///
+ ///
+ /// ImNodes renders a link as a cubic bezier whose inner control points are offset horizontally by
+ /// 0.25 * length from each pin. Writing gap 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 gap >= 0.25 * length. Substituting
+ /// length = sqrt(gap^2 + dy^2) and solving gives gap >= |dy| / sqrt(15), so the ratio
+ /// below is 1 / sqrt(15), 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.
+ ///
+ public const double BezierClearanceRatio = 0.2581988897471611;
+
private BodyState[] bodies = [];
private int bodyCount;
@@ -133,6 +148,7 @@ public void Step(double deltaTime)
CalculateRepulsionForces();
CalculateLinkForces();
+ CalculateLinkFlatteningForces();
CalculateDirectionalForces();
CalculateGravityForces();
@@ -224,6 +240,54 @@ private void CalculateLinkForces()
}
}
+ ///
+ /// Splay an edge's endpoints apart horizontally until the clear span between their facing edges is
+ /// wide enough for the rendered curve, per . 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.
+ ///
+ 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;
diff --git a/ForceDirectedLayout/LayoutSettings.cs b/ForceDirectedLayout/LayoutSettings.cs
index d8e5a115..ae8fe890 100644
--- a/ForceDirectedLayout/LayoutSettings.cs
+++ b/ForceDirectedLayout/LayoutSettings.cs
@@ -29,6 +29,15 @@ public struct LayoutSettings
/// Strength of the horizontal source-left/target-right ordering bias along edges. 0 disables it.
public double DirectionalBias;
+ ///
+ /// Strength of the horizontal splay that keeps an edge's rendered curve clear of its own endpoint
+ /// bodies. 0 disables it. See for the geometry.
+ ///
+ public double LinkFlatteningStrength;
+
+ /// Extra horizontal clearance demanded on top of the derived bezier bound, in position units.
+ public double LinkFlatteningMargin;
+
/// Strength of the gravity force pulling each body toward the gravity target.
public double GravityStrength;
@@ -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,
diff --git a/ForceDirectedLayout/PhysicsSettings.cs b/ForceDirectedLayout/PhysicsSettings.cs
index 0c62df70..8da9600d 100644
--- a/ForceDirectedLayout/PhysicsSettings.cs
+++ b/ForceDirectedLayout/PhysicsSettings.cs
@@ -21,6 +21,15 @@ public sealed record PhysicsSettings
/// Strength of the horizontal source-left/target-right ordering bias. 0 disables it.
public double DirectionalBias { get; init; } = 0.5;
+ ///
+ /// Strength of the horizontal splay that keeps an edge's rendered curve clear of its own endpoint
+ /// bodies. 0 disables it. See for the geometry.
+ ///
+ public double LinkFlatteningStrength { get; init; } = 0.5;
+
+ /// Extra horizontal clearance demanded on top of the derived bezier bound, in position units.
+ public double LinkFlatteningMargin { get; init; }
+
/// Strength of the gravity force pulling each body toward the gravity target.
public double GravityStrength { get; init; } = 50.0;
@@ -61,6 +70,8 @@ public sealed record PhysicsSettings
RepulsionStrength = RepulsionStrength,
LinkSpringStrength = LinkSpringStrength,
DirectionalBias = DirectionalBias,
+ LinkFlatteningStrength = LinkFlatteningStrength,
+ LinkFlatteningMargin = LinkFlatteningMargin,
GravityStrength = GravityStrength,
OriginAnchorWeight = OriginAnchorWeight,
DampingFactor = DampingFactor,
@@ -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,
diff --git a/ForceDirectedLayout/README.md b/ForceDirectedLayout/README.md
index 52779efa..f5e1a2cd 100644
--- a/ForceDirectedLayout/README.md
+++ b/ForceDirectedLayout/README.md
@@ -3,7 +3,7 @@
[](https://nuget.org/packages/ktsu.ForceDirectedLayout)
[](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
@@ -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
diff --git a/examples/ImGuiAppDemo/Demos/CleanImNodesDemo.cs b/examples/ImGuiAppDemo/Demos/CleanImNodesDemo.cs
index 0dbbd7dd..d7353068 100644
--- a/examples/ImGuiAppDemo/Demos/CleanImNodesDemo.cs
+++ b/examples/ImGuiAppDemo/Demos/CleanImNodesDemo.cs
@@ -348,6 +348,20 @@ private void RenderPhysicsControls()
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
@@ -380,6 +394,7 @@ private void RenderPhysicsControls()
RepulsionStrength = 2_000_000.0,
LinkSpringStrength = 0.3,
DirectionalBias = 0.3,
+ LinkFlatteningStrength = 0.3,
GravityStrength = 20.0,
OriginAnchorWeight = 0.2,
DampingFactor = 0.95,
@@ -401,6 +416,7 @@ private void RenderPhysicsControls()
RepulsionStrength = 10_000_000.0,
LinkSpringStrength = 1.0,
DirectionalBias = 0.8,
+ LinkFlatteningStrength = 1.0,
GravityStrength = 100.0,
OriginAnchorWeight = 0.4,
DampingFactor = 0.85,
diff --git a/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs b/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs
index 1c539abd..78bdeed4 100644
--- a/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs
+++ b/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs
@@ -171,6 +171,8 @@ public void PhysicsSettings_RoundTrips_ThroughLayoutSettings()
RepulsionStrength = 42.0,
LinkSpringStrength = 0.25,
DirectionalBias = 0.1,
+ LinkFlatteningStrength = 0.7,
+ LinkFlatteningMargin = 12.0,
GravityStrength = 7.0,
OriginAnchorWeight = 0.3,
DampingFactor = 0.4,
@@ -312,6 +314,133 @@ public void GenericFacade_Step_WithEdge_PullsNodesCloser()
Assert.IsTrue(finalDistance < initialDistance, $"Spring should pull nodes closer; was {initialDistance}, now {finalDistance}.");
}
+ ///
+ /// Settings that isolate the flattening force: no repulsion, no gravity, no ordering bias, and a
+ /// spring only strong enough to hold the pair together.
+ ///
+ 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 layout = CreateLayout(FlatteningOnly());
+ List 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 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}.");
+ Assert.IsTrue(drop < 400.0, $"The pair started 400 apart vertically and should have flattened; drop was {drop}.");
+ }
+
+ [TestMethod]
+ public void LinkFlattening_DanglingEdge_IsSkipped()
+ {
+ ForceDirectedLayout layout = CreateLayout(FlatteningOnly());
+ List 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 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 layout = CreateLayout(FlatteningOnly() with { LinkSpringStrength = 0 });
+ List 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 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 layout = CreateLayout(
+ FlatteningOnly() with { LinkFlatteningStrength = 0, LinkSpringStrength = 0 });
+ List 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 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 layout = CreateLayout(
+ FlatteningOnly() with { LinkFlatteningMargin = 150.0, LinkSpringStrength = 0 });
+ List 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 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}.");
+ }
+
[TestMethod]
public void GenericFacade_PinnedNode_DoesNotMove()
{
diff --git a/tests/ImGuiAppDemo.UITests/AppDemoUITests.cs b/tests/ImGuiAppDemo.UITests/AppDemoUITests.cs
index 84da2b10..80e21344 100644
--- a/tests/ImGuiAppDemo.UITests/AppDemoUITests.cs
+++ b/tests/ImGuiAppDemo.UITests/AppDemoUITests.cs
@@ -344,6 +344,44 @@ public void CleanImNodes_PhysicsControlsRespond()
Assert.IsTrue(IsVisible("Strong Physics"), "The physics presets should survive being applied.");
}
+ ///
+ /// 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.
+ ///
+ [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.");
+ }
+
+ ///
+ /// 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.
+ ///
+ 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()
{