diff --git a/ForceDirectedLayout/LayoutCore.cs b/ForceDirectedLayout/LayoutCore.cs
index 6f0184d..d155c3f 100644
--- a/ForceDirectedLayout/LayoutCore.cs
+++ b/ForceDirectedLayout/LayoutCore.cs
@@ -245,11 +245,13 @@ 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.
+ /// Pull an edge towards horizontal, by two means. A levelling force closes the vertical offset
+ /// between its two ends continuously, which is what makes a link lie flat. On top of that, a
+ /// horizontal splay opens the clear span between their facing edges whenever it is too narrow for
+ /// the rendered curve, per - that one is a floor, switching off
+ /// once the curve is safe, so it guarantees a link stays visible without ever levelling it.
+ /// Both are soft, balanced against the link spring, so equilibrium settles near the target rather
+ /// than exactly on it, and neither can make every edge in a graph horizontal at once.
///
private void CalculateLinkFlatteningForces()
{
@@ -275,10 +277,25 @@ private void CalculateLinkFlatteningForces()
double targetLeft = bodies[t].Position.X;
double gap = targetLeft - sourceRight;
+ double sourceCenterX = bodies[s].Position.X + (bodies[s].Dimensions.X * 0.5);
+ double targetCenterX = bodies[t].Position.X + (bodies[t].Dimensions.X * 0.5);
+
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);
+ // Prefer horizontal: close the vertical offset between the two ends, always, in proportion to
+ // how far apart they sit. The clearance splay below only fires once a curve is at risk of
+ // hiding, which keeps a link legal without ever making it flat; this is what lays it flat.
+ // A backward edge is exempt - it is still being reordered, and pulling it level would fight
+ // the vertical slide that reorder needs.
+ if (targetCenterX > sourceCenterX)
+ {
+ double levelling = strength * (targetCenterY - sourceCenterY);
+ bodies[s].Force += new Vec2D(0, levelling);
+ bodies[t].Force += new Vec2D(0, -levelling);
+ }
+
double required = (verticalDrop * BezierClearanceRatio) + margin;
double violation = required - gap;
if (violation <= 0)
diff --git a/ForceDirectedLayout/LayoutSettings.cs b/ForceDirectedLayout/LayoutSettings.cs
index ae8fe89..4576b25 100644
--- a/ForceDirectedLayout/LayoutSettings.cs
+++ b/ForceDirectedLayout/LayoutSettings.cs
@@ -30,8 +30,9 @@ public struct LayoutSettings
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.
+ /// Strength of the preference for horizontal edges: it both levels an edge's two ends and, when the
+ /// rendered curve would otherwise hide, splays them apart horizontally. 0 disables both.
+ /// See for the clearance geometry.
///
public double LinkFlatteningStrength;
diff --git a/ForceDirectedLayout/PhysicsSettings.cs b/ForceDirectedLayout/PhysicsSettings.cs
index 8da9600..ade561a 100644
--- a/ForceDirectedLayout/PhysicsSettings.cs
+++ b/ForceDirectedLayout/PhysicsSettings.cs
@@ -22,8 +22,9 @@ public sealed record PhysicsSettings
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.
+ /// Strength of the preference for horizontal edges: it both levels an edge's two ends and, when the
+ /// rendered curve would otherwise hide, splays them apart horizontally. 0 disables both.
+ /// See for the clearance geometry.
///
public double LinkFlatteningStrength { get; init; } = 0.5;
diff --git a/ForceDirectedLayout/README.md b/ForceDirectedLayout/README.md
index d65219b..c1b8f11 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, steep edges are splayed apart so a renderer's curves stay clear of the bodies at their ends, and overlaps are pushed apart. Edges that run the wrong way reorder themselves: their endpoints slide around whatever stands between them rather than through it, so nothing is drawn overlapping on the way. 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, edges are pulled towards horizontal and steep ones splayed apart so a renderer's curves stay clear of the bodies at their ends, and overlaps are pushed apart. Edges that run the wrong way reorder themselves: their endpoints slide around whatever stands between them rather than through it, so nothing is drawn overlapping on the way. 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,7 +108,7 @@ PhysicsSettings settings = new()
LinkSpringStrength = 0.5, // Hooke's-law constant for edges
RestLinkLength = 225.0, // spring rest length
DirectionalBias = 0.5, // orders sources left of targets, reordering when needed
- LinkFlatteningStrength = 0.5, // splays steep edges so their curves stay visible
+ LinkFlatteningStrength = 0.5, // pulls edges towards horizontal, and keeps curves 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
diff --git a/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs b/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs
index ad27afb..84f0c0d 100644
--- a/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs
+++ b/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs
@@ -380,6 +380,47 @@ private static (double SourceCenterX, double TargetCenterX) SettleBackwardEdge(L
bodies[1].Position.X + (bodies[1].Dimensions.X * 0.5));
}
+ [TestMethod]
+ public void LinkFlattening_PullsAForwardEdgeTowardsHorizontal()
+ {
+ ForceDirectedLayout layout = CreateLayout(new PhysicsSettings { Enabled = true });
+ List bodies = [Body(1, 0, 0, 160, 60), Body(2, 300, 400, 160, 60)];
+ List edges = [new TestEdge(1, 2)];
+
+ double before = Math.Abs(bodies[1].Position.Y - bodies[0].Position.Y);
+
+ for (int i = 0; i < 2000; i++)
+ {
+ layout.Step(bodies, edges, 0.016);
+ }
+
+ double after = Math.Abs(bodies[1].Position.Y - bodies[0].Position.Y);
+
+ Assert.IsTrue(after < before * 0.25,
+ $"A forward edge should settle close to level; vertical offset went from {before} to {after}.");
+ }
+
+ [TestMethod]
+ public void LinkFlattening_ZeroStrength_LeavesAnEdgeAsSteepAsItStarted()
+ {
+ ForceDirectedLayout layout = CreateLayout(
+ new PhysicsSettings { Enabled = true, LinkFlatteningStrength = 0, GravityStrength = 0, RepulsionStrength = 0, DirectionalBias = 0 });
+ List bodies = [Body(1, 0, 0, 160, 60), Body(2, 300, 400, 160, 60)];
+ List edges = [new TestEdge(1, 2)];
+
+ for (int i = 0; i < 2000; i++)
+ {
+ layout.Step(bodies, edges, 0.016);
+ }
+
+ // Both bodies are the same size, so the centre offsets cancel.
+ double dx = bodies[1].Position.X - bodies[0].Position.X;
+ double dy = bodies[1].Position.Y - bodies[0].Position.Y;
+
+ Assert.IsTrue(Math.Abs(dy) > Math.Abs(dx),
+ $"With the preference off, the spring alone should leave this edge steeper than it is wide; dx {dx}, dy {dy}.");
+ }
+
[TestMethod]
public void BackwardEdge_SwapsTheEndpointsIntoOrder()
{