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 @@ [![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, 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() {