diff --git a/ForceDirectedLayout/LayoutSettings.cs b/ForceDirectedLayout/LayoutSettings.cs
index 4576b25..13ce9cd 100644
--- a/ForceDirectedLayout/LayoutSettings.cs
+++ b/ForceDirectedLayout/LayoutSettings.cs
@@ -57,7 +57,11 @@ public struct LayoutSettings
/// Per-body force magnitude cap (applied before integration).
public double MaxForce;
- /// Per-body velocity magnitude cap (applied after integration).
+ ///
+ /// Per-body velocity magnitude cap (applied after integration). This is what bounds how long a
+ /// graph takes to settle: a body has to travel hundreds of units to reach its place, so too low a
+ /// cap leaves a graph still visibly unfolding many seconds after it opens.
+ ///
public double MaxVelocity;
/// Target substep frequency. Per-frame substep count is ceil(deltaTime * TargetPhysicsHz).
@@ -87,7 +91,7 @@ public struct LayoutSettings
MinRepulsionDistance = 50.0,
RestLinkLength = 225.0,
MaxForce = 5000.0,
- MaxVelocity = 50.0,
+ MaxVelocity = 250.0,
TargetPhysicsHz = 120.0,
StabilityThreshold = 1.0,
OverlapMargin = 20.0,
diff --git a/ForceDirectedLayout/PhysicsSettings.cs b/ForceDirectedLayout/PhysicsSettings.cs
index ade561a..d6e397b 100644
--- a/ForceDirectedLayout/PhysicsSettings.cs
+++ b/ForceDirectedLayout/PhysicsSettings.cs
@@ -49,8 +49,12 @@ public sealed record PhysicsSettings
/// Per-body force magnitude cap (applied before integration).
public double MaxForce { get; init; } = 5000.0;
- /// Per-body velocity magnitude cap (applied after integration).
- public double MaxVelocity { get; init; } = 50.0;
+ ///
+ /// Per-body velocity magnitude cap (applied after integration). This is what bounds how long a
+ /// graph takes to settle: a body has to travel hundreds of units to reach its place, so too low a
+ /// cap leaves a graph still visibly unfolding many seconds after it opens.
+ ///
+ public double MaxVelocity { get; init; } = 250.0;
/// Target substep frequency. Per-frame substep count is ceil(deltaTime * TargetPhysicsHz).
public double TargetPhysicsHz { get; init; } = 120.0;
diff --git a/ForceDirectedLayout/README.md b/ForceDirectedLayout/README.md
index 87f05cb..4a92e71 100644
--- a/ForceDirectedLayout/README.md
+++ b/ForceDirectedLayout/README.md
@@ -114,7 +114,7 @@ PhysicsSettings settings = new()
OriginAnchorWeight = 1.0, // 0 = centroid, 1 = world origin
DampingFactor = 0.5, // velocity retained per second
MaxForce = 5000.0,
- MaxVelocity = 50.0,
+ MaxVelocity = 250.0, // also bounds how fast a graph settles
TargetPhysicsHz = 120.0, // substep rate, independent of frame rate
StabilityThreshold = 1.0,
OverlapMargin = 20.0,
diff --git a/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs b/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs
index d95cd13..c7bfb4a 100644
--- a/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs
+++ b/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs
@@ -4,6 +4,7 @@ namespace ktsu.ForceDirectedLayout.Tests;
using System;
using System.Collections.Generic;
+using System.Linq;
using ktsu.ForceDirectedLayout;
using Microsoft.VisualStudio.TestTools.UnitTesting;
@@ -402,6 +403,154 @@ private static ForceDirectedLayout CreatePinnedLayout(Phys
Settings = settings,
};
+ /// A node in the benchmark graph, sized as the editor draws it.
+ private sealed record Box(int Id, double W, double H, int InputRows, int OutputRows);
+
+ /// An edge that knows which row it leaves and enters on.
+ private sealed record Wire(int From, int FromRow, int To, int ToRow);
+
+ ///
+ /// The Counter document as the editor lays it out: twenty nodes, twenty edges, five levels deep.
+ ///
+ private static (List Bodies, List Edges) CounterGraph(double spread)
+ {
+ // id, width, height, input rows, output rows
+ Box[] boxes =
+ [
+ new(1, 60, 50, 0, 1), new(2, 115, 62, 1, 1), // 0 -> var count
+ new(3, 60, 50, 0, 1), new(4, 115, 62, 1, 1), // 1 -> var step
+ new(5, 110, 50, 0, 1), // param amount
+ new(6, 75, 50, 0, 1), new(7, 75, 50, 0, 1), new(8, 75, 50, 0, 1), new(9, 75, 50, 0, 1),
+ new(10, 90, 75, 2, 1), // binary + (Add)
+ new(11, 90, 70, 2, 1), // assign
+ new(12, 105, 60, 1, 1), // return (Add)
+ new(13, 118, 180, 7, 1), // function Add
+ new(14, 75, 50, 0, 1), new(15, 75, 50, 0, 1),
+ new(16, 90, 75, 2, 1), // binary + (Next)
+ new(17, 115, 62, 1, 1), // var result
+ new(18, 105, 60, 1, 1), // return (Next)
+ new(19, 118, 160, 6, 1), // function Next
+ new(20, 117, 160, 6, 1), // class Counter
+ ];
+
+ Wire[] wires =
+ [
+ new(1, 0, 2, 0), new(2, 0, 20, 0),
+ new(3, 0, 4, 0), new(4, 0, 20, 1),
+ new(5, 0, 13, 0),
+ new(6, 0, 11, 0), new(7, 0, 10, 0), new(8, 0, 10, 1), new(10, 0, 11, 1),
+ new(11, 0, 13, 3), new(9, 0, 12, 0), new(12, 0, 13, 4), new(13, 0, 20, 2),
+ new(14, 0, 16, 0), new(15, 0, 16, 1), new(16, 0, 17, 0), new(17, 0, 19, 2),
+ new(18, 0, 19, 3), new(19, 0, 20, 3),
+ new(9, 0, 18, 0),
+ ];
+
+ Dictionary byId = boxes.ToDictionary(b => b.Id);
+ List bodies = [];
+ int seed = 0;
+ foreach (Box b in boxes)
+ {
+ // Scattered start, so the layout has to do the work rather than inherit an answer.
+ seed = ((seed * 1103515245) + 12345) & 0x7fffffff;
+ double x = seed % 1000 * spread;
+ seed = ((seed * 1103515245) + 12345) & 0x7fffffff;
+ double y = seed % 1000 * spread;
+ bodies.Add(Body(b.Id, x, y, b.W, b.H));
+ }
+
+ // A row's pin sits a header down plus its own row height, which is what ImNodes produces.
+ static double RowY(Box b, int row) => Math.Min(28.0 + (row * 21.0) + 10.0, b.H - 8.0);
+
+ List edges = [.. wires.Select(w => new PinnedEdge(
+ w.From, w.To,
+ new Vec2D(byId[w.From].W, RowY(byId[w.From], byId[w.From].InputRows + w.FromRow)),
+ new Vec2D(0, RowY(byId[w.To], w.ToRow))))];
+
+ return (bodies, edges);
+ }
+
+ /// Mean angle off horizontal across a graph's edges, and its bounding box.
+ private static (double Width, double Height, double MeanAngle) Shape(List bodies, List edges)
+ {
+ double width = bodies.Max(b => b.Position.X + b.Dimensions.X) - bodies.Min(b => b.Position.X);
+ double height = bodies.Max(b => b.Position.Y + b.Dimensions.Y) - bodies.Min(b => b.Position.Y);
+
+ Dictionary byId = bodies.ToDictionary(b => b.Id);
+ double total = 0;
+ foreach (PinnedEdge e in edges)
+ {
+ Vec2D from = byId[e.SourceId].Position + e.SourcePin;
+ Vec2D to = byId[e.TargetId].Position + e.TargetPin;
+ double angle = Math.Abs(Math.Atan2(to.Y - from.Y, to.X - from.X) * 180.0 / Math.PI);
+ total += angle > 90 ? 180 - angle : angle;
+ }
+
+ return (width, height, total / edges.Count);
+ }
+
+ ///
+ /// Tests that a graph the size of a small class reaches a readable shape within the first few
+ /// seconds, rather than only after a minute and a half of settling.
+ ///
+ ///
+ /// 600 steps is ten seconds in an editor running at sixty frames a second, which is about as long
+ /// as anyone watches a graph unfold. With MaxVelocity at 50 this same graph was still a tall narrow
+ /// column at that point - bodies simply could not travel the several hundred units to their places
+ /// fast enough - and only squared up around 6000 steps. Nothing measured settling speed, so the
+ /// layout looked broken while being perfectly correct.
+ ///
+ [TestMethod]
+ public void ASmallGraph_IsReadableWithinTenSeconds()
+ {
+ (List bodies, List edges) = CounterGraph(0.05);
+ ForceDirectedLayout layout = CreatePinnedLayout(new PhysicsSettings { Enabled = true });
+
+ for (int i = 0; i < 600; i++)
+ {
+ layout.Step(bodies, edges, 0.016);
+ }
+
+ (double width, double height, double meanAngle) = Shape(bodies, edges);
+
+ Assert.IsTrue(width > height,
+ $"A left-to-right graph should be wider than it is tall by now; it is {width:F0} x {height:F0}.");
+ Assert.IsTrue(meanAngle < 45.0,
+ $"Its edges should be nearer horizontal than vertical by now; mean angle is {meanAngle:F1} degrees.");
+ }
+
+ ///
+ /// Tests that repulsion is still what spreads a graph out, now that an overlap pass also keeps
+ /// bodies off one another.
+ ///
+ ///
+ /// The overlap pass only guarantees bodies do not sit on top of each other; it creates no room
+ /// beyond that. Without repulsion a settled graph collapses into a tall column whose edges run
+ /// close to vertical, which is the shape the levelling force then cannot recover from.
+ ///
+ [TestMethod]
+ public void Repulsion_IsWhatSpreadsAGraphOut()
+ {
+ (List withBodies, List withEdges) = CounterGraph(0.05);
+ ForceDirectedLayout with = CreatePinnedLayout(new PhysicsSettings { Enabled = true });
+
+ (List withoutBodies, List withoutEdges) = CounterGraph(0.05);
+ ForceDirectedLayout without = CreatePinnedLayout(new PhysicsSettings { Enabled = true, RepulsionStrength = 0 });
+
+ for (int i = 0; i < 6000; i++)
+ {
+ with.Step(withBodies, withEdges, 0.016);
+ without.Step(withoutBodies, withoutEdges, 0.016);
+ }
+
+ (double withWidth, double withHeight, double withAngle) = Shape(withBodies, withEdges);
+ (double withoutWidth, double withoutHeight, double withoutAngle) = Shape(withoutBodies, withoutEdges);
+
+ Assert.IsTrue(withWidth / withHeight > withoutWidth / withoutHeight,
+ $"Repulsion should leave the graph wider; with {withWidth / withHeight:F2}, without {withoutWidth / withoutHeight:F2}.");
+ Assert.IsTrue(withAngle < withoutAngle - 15.0,
+ $"Repulsion should leave the edges far flatter; with {withAngle:F1} deg, without {withoutAngle:F1} deg.");
+ }
+
[TestMethod]
public void PinOffsets_LevelThePinsRatherThanTheBodyCentres()
{
diff --git a/tests/ForceDirectedLayout.Tests/OverlapSeparationTests.cs b/tests/ForceDirectedLayout.Tests/OverlapSeparationTests.cs
index 0301995..8f09222 100644
--- a/tests/ForceDirectedLayout.Tests/OverlapSeparationTests.cs
+++ b/tests/ForceDirectedLayout.Tests/OverlapSeparationTests.cs
@@ -115,8 +115,15 @@ public void Step_WithOverlapMarginZero_LeavesTheBodiesOverlapping()
{
// Opting out has to be possible for a consumer that arranges its own bodies, and leaving them
// overlapping is what says the separation is this pass's doing rather than the forces'.
+ // Repulsion and the ordering bias both push a pair apart on their own - the ordering bias alone
+ // targets halfWidths + 20, which for these bodies is wider than they are - so they are turned
+ // off here. Otherwise this measures how far the pair has travelled rather than whether the pass
+ // is what separates them.
LayoutSettings settings = EnabledDefaults();
settings.OverlapMargin = 0.0;
+ settings.RepulsionStrength = 0.0;
+ settings.DirectionalBias = 0.0;
+ settings.LinkFlatteningStrength = 0.0;
Vec2D[] dimensions = [new Vec2D(400, 100), new Vec2D(400, 100)];
ForceLayout layout = new(settings);