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