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
2 changes: 1 addition & 1 deletion ForceDirectedLayout/DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Settles a graph into a readable shape by simulation: bodies repel, edges pull like springs, gravity holds the whole together, and overlapping boxes are pushed apart. Pure physics 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 bring its own types, submit plain ids in bulk, or drive the flat core. Step it by a frame delta or solve to convergence, and read back energy and stability. The same core ships as a Native AOT shared library with a C ABI.
Settles a graph into a readable shape by simulation: bodies repel across the clear space between their bounding boxes, edges pull like springs, gravity holds the whole together, and overlapping boxes are pushed apart. Pure physics 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 bring its own types, submit plain ids in bulk, or drive the flat core. Step it by a frame delta or solve to convergence, and read back energy and stability. The same core ships as a Native AOT shared library with a C ABI.
66 changes: 58 additions & 8 deletions ForceDirectedLayout/LayoutCore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,38 @@
}
}

/// <summary>
/// Push every pair of bodies apart, inverse-square in the clear space between their rectangles.
/// </summary>
/// <remarks>
/// The distance is the one between the two closest points on the pair's bounding boxes, not the one
/// between their centres. A centre measurement measures the wrong thing: it counts each body's own
/// extent as part of the distance between them, so a wide node reads as far from a neighbour pressed
/// against its side, while two small ones read as crowded with a screen of empty space between them.
/// The same setting then spaces a graph differently depending only on how big its nodes happen to be,
/// and the nodes of a node editor are every size from a literal to a class. What a reader sees is the
/// clear space, and the clear space is what this works on.
/// <para>
/// That distance is zero along any axis the two overlap on, so for a pair sharing a row it is their
/// horizontal gap alone and for a pair sharing a column their vertical gap alone. It is what holds a
/// tall node's neighbour off by as much room beside its corner as beside its middle.
/// </para>
/// <para>
/// The direction stays along the line between the centres, because it is the room a pair has that the
/// closest points establish and not the way they should go. Taking the direction from them as well
/// makes every force between a pair sharing a row exactly horizontal and every force between a pair
/// sharing a column exactly vertical, which leaves repulsion unable to move a body diagonally out of
/// another's way: measured over ten starting arrangements of the graph in
/// <c>ForceLayoutTests.CounterGraph</c>, that draws around half again as many links across bodies
/// they are not an end of, and leaves the edges several degrees steeper.
/// </para>
/// <para>
/// Since the distance no longer includes the bodies' own extents it is much the smaller number, so
/// the same spacing needs a smaller <see cref="LayoutSettings.RepulsionStrength"/> than a centre
/// measurement did. The default was halved to match, and a caller carrying a value tuned against the
/// old measurement should expect to do the same.
/// </para>
/// </remarks>
private void CalculateRepulsionForces()
{
double minDist = Settings.MinRepulsionDistance;
Expand All @@ -226,8 +258,10 @@
continue;
}

// Inverse-square, clamped at MinRepulsionDistance to prevent explosions when bodies overlap.
double effectiveDist = Math.Max(dist, minDist);
// Inverse-square, clamped at MinRepulsionDistance to prevent explosions when bodies
// touch - which is where the clear distance reaches zero, rather than where the bodies
// are coincident.
double effectiveDist = Math.Max(ClearDistance(i, j, direction), minDist);
double magnitude = strength / (effectiveDist * effectiveDist);
Vec2D force = direction * (magnitude / dist);

Expand All @@ -237,6 +271,22 @@
}
}

/// <summary>
/// The distance between the two closest points on two bodies' bounding boxes: their gap along each
/// axis they are disjoint on, and zero once they touch or overlap on both.
/// </summary>
/// <param name="i">Index of the first body.</param>
/// <param name="j">Index of the second body.</param>
/// <param name="between">Offset between the two centres, which every caller has already computed.</param>
private double ClearDistance(int i, int j, Vec2D between)
{
Vec2D clearance = (bodies[i].Dimensions + bodies[j].Dimensions) * 0.5;
double gapX = Math.Max(Math.Abs(between.X) - clearance.X, 0.0);
double gapY = Math.Max(Math.Abs(between.Y) - clearance.Y, 0.0);

return Math.Sqrt((gapX * gapX) + (gapY * gapY));
}

/// <summary>
/// The two points an edge actually joins: its pin positions when the caller supplied them, and the
/// two body centres when it did not.
Expand Down Expand Up @@ -438,7 +488,7 @@
/// <param name="next">The chain to follow, indexed by edge.</param>
/// <param name="sharedAtTarget">True when the list is of edges arriving, false when leaving.</param>
/// <param name="strength">Force per unit of vertical swap still to be made.</param>
private void UntwistSharedEnds(int first, int[] next, bool sharedAtTarget, double strength)

Check warning on line 491 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

Check warning on line 491 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

Check warning on line 491 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

Check warning on line 491 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 24 to the 15 allowed.
{
for (int a = first; a >= 0; a = next[a])
{
Expand Down Expand Up @@ -619,12 +669,12 @@
/// Push apart any pair of bodies whose rectangles are on top of one another.
/// </summary>
/// <remarks>
/// Every force in this simulation treats a body as a point: repulsion is measured between centers,
/// the link spring pulls to a fixed rest length, and neither knows how wide a body is. Two bodies
/// can therefore sit at a distance the forces are entirely happy with and still have their
/// rectangles squarely on top of each other, which is what a consumer drawing them sees. That
/// cannot be fixed by tuning the forces, because the comfortable distance depends on the pair's
/// sizes and the forces do not have them.
/// Repulsion does know how big a body is - it is measured across the clear space between the two
/// rectangles - but it is a soft force with a ceiling on it: it stops getting stronger below
/// <see cref="LayoutSettings.MinRepulsionDistance"/>, so a link spring pulling to a fixed rest
/// length can hold a pair overlapping in spite of it, and two bodies at the same point have no
/// direction to be pushed along at all. Either way the rectangles end up squarely on top of one
/// another, which is what a consumer drawing them sees, and no amount of force tuning reaches it.
/// <para>
/// So it is resolved positionally, after integration, the same way <see cref="ApplyDirectionalConstraints"/>
/// is: for each overlapping pair, along the axis they overlap least on — the shorter push, and the
Expand All @@ -638,7 +688,7 @@
/// and the pair comes to rest still overlapping, just less.
/// </para>
/// </remarks>
private void SeparateOverlaps()

Check warning on line 691 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

Check warning on line 691 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

Check warning on line 691 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 47 to the 15 allowed.
{
double margin = Settings.OverlapMargin;
if (margin <= 0)
Expand Down
18 changes: 14 additions & 4 deletions ForceDirectedLayout/LayoutSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ public struct LayoutSettings
private byte pad2;
private int pad3;

/// <summary>Strength of pairwise inverse-square repulsion between bodies (newtons-equivalent).</summary>
/// <summary>
/// Strength of pairwise inverse-square repulsion between bodies, measured across the clear space
/// between their bounding boxes rather than between their centres (newtons-equivalent).
/// </summary>
public double RepulsionStrength;

/// <summary>Dimensionless Hooke's-law spring constant for edges.</summary>
Expand Down Expand Up @@ -54,7 +57,10 @@ public struct LayoutSettings
/// <summary>Per-second velocity retention. 0.5 means velocity halves every second.</summary>
public double DampingFactor;

/// <summary>Distance floor used to clamp the inverse-square repulsion denominator.</summary>
/// <summary>
/// Floor on the clear space used as the inverse-square repulsion denominator, so a pair that touches
/// pushes hard rather than infinitely hard.
/// </summary>
public double MinRepulsionDistance;

/// <summary>Spring rest length for edges.</summary>
Expand Down Expand Up @@ -82,11 +88,15 @@ public struct LayoutSettings
/// <summary>Per-substep cap on how far an overlapping pair is pushed apart.</summary>
public double MaxOverlapCorrection;

/// <summary>Sensible defaults matching the previous Force&lt;float&gt;/Length&lt;float&gt; values.</summary>
/// <summary>
/// Sensible defaults matching the previous Force&lt;float&gt;/Length&lt;float&gt; values, save for
/// <see cref="RepulsionStrength"/>, which was recalibrated when repulsion moved from measuring
/// between body centres to measuring the clear space between their bounding boxes.
/// </summary>
public static LayoutSettings Defaults => new()
{
Enabled = 0,
RepulsionStrength = 1_200_000.0,
RepulsionStrength = 600_000.0,
LinkSpringStrength = 0.5,
DirectionalBias = 0.5,
LinkFlatteningStrength = 0.5,
Expand Down
12 changes: 9 additions & 3 deletions ForceDirectedLayout/PhysicsSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ public sealed record PhysicsSettings
/// <summary>Whether simulation is active. When false, Step is a no-op.</summary>
public bool Enabled { get; init; }

/// <summary>Strength of pairwise inverse-square repulsion between bodies.</summary>
public double RepulsionStrength { get; init; } = 1_200_000.0;
/// <summary>
/// Strength of pairwise inverse-square repulsion between bodies, measured across the clear space
/// between their bounding boxes rather than between their centres.
/// </summary>
public double RepulsionStrength { get; init; } = 600_000.0;

/// <summary>Dimensionless Hooke's-law spring constant for edges.</summary>
public double LinkSpringStrength { get; init; } = 0.5;
Expand Down Expand Up @@ -46,7 +49,10 @@ public sealed record PhysicsSettings
/// <summary>Per-second velocity retention. 0.5 means velocity halves every second.</summary>
public double DampingFactor { get; init; } = 0.5;

/// <summary>Distance floor used to clamp the inverse-square repulsion denominator.</summary>
/// <summary>
/// Floor on the clear space used as the inverse-square repulsion denominator, so a pair that touches
/// pushes hard rather than infinitely hard.
/// </summary>
public double MinRepulsionDistance { get; init; } = 50.0;

/// <summary>Spring rest length for edges.</summary>
Expand Down
7 changes: 4 additions & 3 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 between the points they actually attach at, 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. Two edges meeting at one node put their far ends into the same vertical order as the pins they arrive at, so they stop crossing each other. Edges that run the wrong way reorder themselves. Both untangles are given the axis they travel on: the overlap pass separates them on the other one, rather than holding a pair apart on the very axis its swap has to cross, so nothing is left drawn overlapping once an untangle is done. 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 across the clear space between their bounding boxes, edges pull like springs between the points they actually attach at, 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. Two edges meeting at one node put their far ends into the same vertical order as the pins they arrive at, so they stop crossing each other. Edges that run the wrong way reorder themselves. Both untangles are given the axis they travel on: the overlap pass separates them on the other one, rather than holding a pair apart on the very axis its swap has to cross, so nothing is left drawn overlapping once an untangle is done. 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 All @@ -12,7 +12,7 @@ ForceDirectedLayout settles a graph into a readable shape: bodies repel each oth
- **Step or solve**: advance the simulation by a frame delta with automatic substepping, or run it to convergence with `Solve(maxIterations, tolerance)`
- **Stability reporting**: total system energy, an `IsStable` flag, and what the last step actually ran (substep count and substep delta)
- **Pinning and freezing**: a pinned body still pushes on others but does not move; a frozen body is one the user is currently dragging
- **Overlap resolution**: bodies have dimensions, so the layout separates boxes rather than points
- **Boxes, not points**: bodies have dimensions, and the layout uses them — repulsion is measured between the two closest points on a pair's bounding boxes, so the same setting leaves the same room between a pair of literals as between a pair of classes, and an overlap pass separates any boxes that still end up drawn over one another
- **AOT and trim clean**: `IsAotCompatible`, `IsTrimmable`, and analyzers enabled, with blittable POD settings and state structs
- **A C ABI**: `ForceDirectedLayout.Native` publishes a Native AOT shared library (`ktsu_force_directed_layout`) with a `Layout_*` entry point set and a generated `ktsu_force_directed_layout.h`

Expand Down Expand Up @@ -104,7 +104,7 @@ Every force is a setting, and the defaults are tuned for node-editor-sized graph
PhysicsSettings settings = new()
{
Enabled = true,
RepulsionStrength = 1_200_000.0, // pairwise inverse-square repulsion
RepulsionStrength = 600_000.0, // inverse-square in the clear space between bounding boxes
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
Expand All @@ -114,6 +114,7 @@ PhysicsSettings settings = new()
GravityStrength = 50.0, // pull toward the gravity target
OriginAnchorWeight = 1.0, // 0 = centroid, 1 = world origin
DampingFactor = 0.5, // velocity retained per second
MinRepulsionDistance = 50.0, // floor on that clear space, so touching bodies push hard, not infinitely hard
MaxForce = 5000.0,
MaxVelocity = 250.0, // also bounds how fast a graph settles
TargetPhysicsHz = 120.0, // substep rate, independent of frame rate
Expand Down
11 changes: 8 additions & 3 deletions ImGui.NodeEditor/NodeEditorRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -721,10 +721,15 @@ private void RenderPhysicsDebugInfo(ImDrawListPtr drawList, NodeEditorEngine eng
drawList.AddCircleFilled(velocityEnd, 3.0f, velocityColor);
}

// Render repulsion zone
float repulsionRadius = (float)engine.PhysicsSettings.MinRepulsionDistance;
// Render the repulsion floor: this node's own box grown by the minimum distance, which is
// where another body's nearest point has repulsion at its hardest. Repulsion is measured
// across the clear space between two boxes, so the floor is a box around this one and not
// a circle around its centre.
float repulsionFloor = (float)engine.PhysicsSettings.MinRepulsionDistance;
Vector2 floorMinScreen = EditorToScreen(node.Position - new Vector2(repulsionFloor, repulsionFloor));
Vector2 floorMaxScreen = EditorToScreen(node.Position + node.Dimensions + new Vector2(repulsionFloor, repulsionFloor));
uint repulsionZoneColor = ImGui.ColorConvertFloat4ToU32(new Vector4(1.0f, 0.5f, 0.0f, 0.2f)); // Orange, transparent
drawList.AddCircle(nodeCenterScreen, repulsionRadius, repulsionZoneColor, 32, 1.0f);
drawList.AddRect(floorMinScreen, floorMaxScreen, repulsionZoneColor, 0.0f, ImDrawFlags.None, 1.0f);
}

// Render gravity center (fixed point, in editor/position space)
Expand Down
4 changes: 2 additions & 2 deletions ImGui.NodeEditor/PhysicsSettingsPanel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,15 +102,15 @@
bool changed = false;
double repulsion = settings.RepulsionStrength;
if (Slider("Repulsion strength", ref repulsion, 0.0, 50_000_000.0, "%.0f",
"Pushes every pair of nodes apart, by the inverse square of their distance. This is what makes the room the other forces arrange things in; with none, a graph collapses onto itself."))
"Pushes every pair of nodes apart, by the inverse square of the clear space between their boxes — the gap you can see, not the distance between their centres, so a big node holds its neighbours off no harder than a small one does. This is what makes the room the other forces arrange things in; with none, a graph collapses onto itself."))
{
settings = settings with { RepulsionStrength = repulsion };
changed = true;
}

double minDistance = settings.MinRepulsionDistance;
if (Slider("Minimum distance", ref minDistance, 1.0, 200.0, "%.0f px",

Check warning on line 112 in ImGui.NodeEditor/PhysicsSettingsPanel.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Define a constant instead of using this literal '%.0f px' 5 times.

Check warning on line 112 in ImGui.NodeEditor/PhysicsSettingsPanel.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Define a constant instead of using this literal '%.0f px' 5 times.

Check warning on line 112 in ImGui.NodeEditor/PhysicsSettingsPanel.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Define a constant instead of using this literal '%.0f px' 5 times.
"The distance repulsion stops getting stronger at. Without a floor, two nodes that nearly touch would be flung apart."))
"The clear space repulsion stops getting stronger below. Without a floor, two nodes that touch would be flung apart."))
{
settings = settings with { MinRepulsionDistance = minDistance };
changed = true;
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ Draws what `ktsu.SyntaxHighlighting` classifies, inside Dear ImGui: a background

[![NuGet](https://img.shields.io/nuget/v/ktsu.ForceDirectedLayout?label=ktsu.ForceDirectedLayout&logo=nuget)](https://nuget.org/packages/ktsu.ForceDirectedLayout)

Renderer-agnostic force-directed layout: bodies repel, edges pull like springs, gravity holds the graph together, and overlapping boxes are pushed apart. Double precision, AOT- and trim-clean, with no runtime dependencies, and also published as a native shared library with a C ABI. `ktsu.ImGui.NodeEditor` uses it to lay out node graphs.
Renderer-agnostic force-directed layout: bodies repel across the clear space between their bounding boxes, edges pull like springs, gravity holds the graph together, and overlapping boxes are pushed apart. Double precision, AOT- and trim-clean, with no runtime dependencies, and also published as a native shared library with a C ABI. `ktsu.ImGui.NodeEditor` uses it to lay out node graphs.

### ImGui.Probes - Item Recording for Tests

Expand Down
4 changes: 2 additions & 2 deletions examples/ImGuiAppDemo/Demos/CleanImNodesDemo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ private void RenderPhysicsControls()
{
engine.UpdatePhysicsSettings(settings with
{
RepulsionStrength = 2_000_000.0,
RepulsionStrength = 1_000_000.0,
LinkSpringStrength = 0.3,
DirectionalBias = 0.3,
LinkFlatteningStrength = 0.3,
Expand All @@ -322,7 +322,7 @@ private void RenderPhysicsControls()
{
engine.UpdatePhysicsSettings(settings with
{
RepulsionStrength = 10_000_000.0,
RepulsionStrength = 5_000_000.0,
LinkSpringStrength = 1.0,
DirectionalBias = 0.8,
LinkFlatteningStrength = 1.0,
Expand Down
Loading