From 7d0fb1db778bf1562e923dc7e9fc0e393831a409 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 05:20:43 +0000 Subject: [PATCH 1/2] [minor] Add a link-flattening force to keep steep edges' curves visible Near-vertical and backward edges had their rendered curves swallowed by their own endpoint bodies. Links are drawn in a channel beneath the node backgrounds, so a curve that doubles back over a node disappears. 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) gives gap >= |dy| / sqrt(15), a cap of about 75.5 degrees off horizontal. CalculateLinkFlatteningForces splays an edge's endpoints apart until that bound is met, and switches off once it is, so it shapes angles rather than stretching the graph. It is separate from DirectionalBias, which enforces a flat 20px ordering floor and does nothing about slope; the two are complementary and both splay along X. Being a soft constraint balanced against the link spring, equilibrium settles just inside the bound rather than exactly on it. Adds LinkFlatteningStrength (0.5, 0 disables) and LinkFlatteningMargin (0.0) to LayoutSettings and its PhysicsSettings mirror, sliders and preset values to the ImNodes demo, and four tests covering the splay, the no-op on already-flat edges, the disable switch and the margin. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D2KNUKr1xJPTEdDmeF2tQN --- ForceDirectedLayout/LayoutCore.cs | 64 +++++++++++ ForceDirectedLayout/LayoutSettings.cs | 11 ++ ForceDirectedLayout/PhysicsSettings.cs | 13 +++ ForceDirectedLayout/README.md | 4 +- .../ImGuiAppDemo/Demos/CleanImNodesDemo.cs | 16 +++ .../ForceLayoutTests.cs | 107 ++++++++++++++++++ 6 files changed, 214 insertions(+), 1 deletion(-) 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..8c0b444e 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,111 @@ 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_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() { From cf8cc7754e3ab477b36173abc520c4af0b644f3f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 05:47:50 +0000 Subject: [PATCH 2/2] Cover the new link-flattening lines to clear the Sonar quality gate The gate failed at 78.4% coverage on new code against a required 80%, and 'Analyze & Release' failed only because of that gate. Examples are not in sonar.coverage.exclusions, so the demo's new slider lines counted as new code and none of them ran. Measured with dotnet-coverage rather than guessed. Eight demo lines were uncovered: the two sliders sit under a collapsing header that starts shut, inside a panel BeginDisabled until physics is on, so neither the reads nor the change branches ever executed. CleanImNodes_LinkFlatteningSlidersRespond enables physics, expands Link Springs, and drags each slider. The first attempt clicked at 0.75 of the item rectangle and did not move either value: a slider's rect spans the track and the label drawn to its right, so that point landed on the text. Dragging across the left 10-45% stays on the track, which is what makes the change branch run. LinkFlattening_DanglingEdge_IsSkipped covers the bounds guard in the new force. An edge naming an absent node resolves to index -1 at both ends, and neither resolvable endpoint may be pushed. Verified per-line against the cobertura report: 29/29 new library lines and 8/8 new demo lines now covered. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D2KNUKr1xJPTEdDmeF2tQN --- .../ForceLayoutTests.cs | 22 +++++++++++ tests/ImGuiAppDemo.UITests/AppDemoUITests.cs | 38 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs b/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs index 8c0b444e..78bdeed4 100644 --- a/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs +++ b/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs @@ -357,6 +357,28 @@ public void LinkFlattening_VerticallyStackedPair_SplaysApartHorizontally() 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() { 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() {