Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions ForceDirectedLayout/LayoutCore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,11 +245,13 @@
}

/// <summary>
/// Splay an edge's endpoints apart horizontally until the clear span between their facing edges is
/// wide enough for the rendered curve, per <see cref="BezierClearanceRatio"/>. 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 <see cref="BezierClearanceRatio"/> - 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.
/// </summary>
private void CalculateLinkFlatteningForces()
{
Expand All @@ -275,10 +277,25 @@
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)
Expand Down Expand Up @@ -412,7 +429,7 @@
/// and the pair comes to rest still overlapping, just less.
/// </para>
/// </remarks>
private void SeparateOverlaps()

Check warning on line 432 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 34 to the 15 allowed.

Check warning on line 432 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 34 to the 15 allowed.

Check warning on line 432 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 34 to the 15 allowed.

Check warning on line 432 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 34 to the 15 allowed.
{
double margin = Settings.OverlapMargin;
if (margin <= 0)
Expand Down
5 changes: 3 additions & 2 deletions ForceDirectedLayout/LayoutSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ public struct LayoutSettings
public double DirectionalBias;

/// <summary>
/// Strength of the horizontal splay that keeps an edge's rendered curve clear of its own endpoint
/// bodies. 0 disables it. See <see cref="LayoutCore.BezierClearanceRatio"/> 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 <see cref="LayoutCore.BezierClearanceRatio"/> for the clearance geometry.
/// </summary>
public double LinkFlatteningStrength;

Expand Down
5 changes: 3 additions & 2 deletions ForceDirectedLayout/PhysicsSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ public sealed record PhysicsSettings
public double DirectionalBias { get; init; } = 0.5;

/// <summary>
/// Strength of the horizontal splay that keeps an edge's rendered curve clear of its own endpoint
/// bodies. 0 disables it. See <see cref="LayoutCore.BezierClearanceRatio"/> 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 <see cref="LayoutCore.BezierClearanceRatio"/> for the clearance geometry.
/// </summary>
public double LinkFlatteningStrength { get; init; } = 0.5;

Expand Down
4 changes: 2 additions & 2 deletions ForceDirectedLayout/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,47 @@
bodies[1].Position.X + (bodies[1].Dimensions.X * 0.5));
}

[TestMethod]
public void LinkFlattening_PullsAForwardEdgeTowardsHorizontal()
{
ForceDirectedLayout<TestBody, TestEdge> layout = CreateLayout(new PhysicsSettings { Enabled = true });
List<TestBody> bodies = [Body(1, 0, 0, 160, 60), Body(2, 300, 400, 160, 60)];
List<TestEdge> 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}.");

Check warning on line 400 in tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsLessThan' instead of 'Assert.IsTrue'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_ImGuiApp&issues=AaCAP2RDE5zdVP3B_leU&open=AaCAP2RDE5zdVP3B_leU&pullRequest=363
}

[TestMethod]
public void LinkFlattening_ZeroStrength_LeavesAnEdgeAsSteepAsItStarted()
{
ForceDirectedLayout<TestBody, TestEdge> layout = CreateLayout(
new PhysicsSettings { Enabled = true, LinkFlatteningStrength = 0, GravityStrength = 0, RepulsionStrength = 0, DirectionalBias = 0 });
List<TestBody> bodies = [Body(1, 0, 0, 160, 60), Body(2, 300, 400, 160, 60)];
List<TestEdge> 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}.");

Check warning on line 421 in tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsGreaterThan' instead of 'Assert.IsTrue'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_ImGuiApp&issues=AaCAP2RDE5zdVP3B_leV&open=AaCAP2RDE5zdVP3B_leV&pullRequest=363
}

[TestMethod]
public void BackwardEdge_SwapsTheEndpointsIntoOrder()
{
Expand Down