ktsu.ForceDirectedLayout
3.32.2
Prefix Reserved
dotnet add package ktsu.ForceDirectedLayout --version 3.32.2
NuGet\Install-Package ktsu.ForceDirectedLayout -Version 3.32.2
<PackageReference Include="ktsu.ForceDirectedLayout" Version="3.32.2" />
<PackageVersion Include="ktsu.ForceDirectedLayout" Version="3.32.2" />
<PackageReference Include="ktsu.ForceDirectedLayout" />
paket add ktsu.ForceDirectedLayout --version 3.32.2
#r "nuget: ktsu.ForceDirectedLayout, 3.32.2"
#:package ktsu.ForceDirectedLayout@3.32.2
#addin nuget:?package=ktsu.ForceDirectedLayout&version=3.32.2
#tool nuget:?package=ktsu.ForceDirectedLayout&version=3.32.2
ktsu.ForceDirectedLayout
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
- Three surfaces over one core: a generic facade for your own body and edge types, a non-generic id-based facade for bulk POD submission, and the flat
LayoutCoreunderneath - Renderer agnostic: nothing here knows what a node looks like;
ktsu.ImGui.NodeEditoris one consumer, an Unreal plugin driving the C ABI is another - 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
IsStableflag, 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
- 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.Nativepublishes a Native AOT shared library (ktsu_force_directed_layout) with aLayout_*entry point set and a generatedktsu_force_directed_layout.h
Installation
Package Manager Console
Install-Package ktsu.ForceDirectedLayout
.NET CLI
dotnet add package ktsu.ForceDirectedLayout
Package Reference
<PackageReference Include="ktsu.ForceDirectedLayout" Version="x.y.z" />
Usage Examples
Basic Example — your own types
ForceDirectedLayout<TBody, TEdge> reads and writes your types through two accessor records, so the simulation never owns your model. WithPhysicsState returns the body with new position, velocity and force, which suits immutable records as readily as mutable classes.
using ktsu.ForceDirectedLayout;
BodyAccessor<MyNode> bodies = new(
GetId: n => n.Id,
GetPosition: n => new Vec2D(n.X, n.Y),
GetDimensions: n => new Vec2D(n.Width, n.Height),
GetVelocity: n => n.Velocity,
GetForce: n => n.Force,
GetIsPinned: n => n.IsPinned,
WithPhysicsState: (n, position, velocity, force) => n with
{
X = position.X,
Y = position.Y,
Velocity = velocity,
Force = force,
});
EdgeAccessor<MyEdge> edges = new(
GetSourceBodyId: e => e.From,
GetTargetBodyId: e => e.To);
ForceDirectedLayout<MyNode, MyEdge> layout = new(bodies, edges)
{
Settings = new PhysicsSettings { Enabled = true },
};
// Once per frame
layout.SetFrozenBodies(draggedNodeIds);
layout.Step(nodes, links, deltaTime);
Id-based submission
ForceLayout takes plain structs and hands back positions as a span, which is the shape a bulk producer or an interop caller wants.
ForceLayout layout = new();
layout.SetNodes([
new NodeInit { Id = 1, Position = new Vec2D(0, 0), Dimensions = new Vec2D(120, 60) },
new NodeInit { Id = 2, Position = new Vec2D(300, 40), Dimensions = new Vec2D(120, 60) },
]);
layout.SetEdges([new EdgeInit { SourceBodyId = 1, TargetBodyId = 2 }]);
int iterations = layout.Solve(maxIterations: 500, tolerance: 0.5);
foreach (NodePosition node in layout.GetPositionsView())
{
Console.WriteLine($"{node.Id}: {node.Position.X}, {node.Position.Y}");
}
Tuning
Every force is a setting, and the defaults are tuned for node-editor-sized graphs.
PhysicsSettings settings = new()
{
Enabled = true,
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
LinkFlatteningStrength = 0.5, // pulls edges towards horizontal, and keeps curves visible
LinkFlatteningMargin = 0.0, // extra clearance on top of the derived bound
LinkUntwistStrength = 0.1, // swaps two links sharing a node into their pins' order
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
StabilityThreshold = 1.0,
OverlapMargin = 20.0,
MaxOverlapCorrection = 40.0,
};
These values were not guessed. tests/ForceDirectedLayout.Tests/Bench/ settles a corpus of graphs over a range of starting arrangements and reports what a layout measures — settled area, mean edge angle, links drawn across a body they are no end of, tightest clear gap, overlaps, crossed link pairs — because the simulation is chaotic and a single run says nothing. LayoutBench.Sweep walks one setting across a range and prints the rows as a table, LayoutBench.Compare puts named variants side by side, and LayoutSvg writes a settled graph out as SVG so it can be looked at rather than only read. Changing what a force measures changes the units its strength is in, so that is how a new default gets found.
From native code
ForceDirectedLayout.Native publishes a shared library with a C entry point set — Layout_Create, Layout_Destroy, Layout_SetSettings, Layout_SetNodes, Layout_SetEdges, Layout_Step, Layout_Solve, Layout_GetPositions, Layout_SetPinned, Layout_GetIndexOf, Layout_GetNodeCount and Layout_GetLastErrorMessage — and ships ktsu_force_directed_layout.h beside the binary. The settings and node/edge structs are laid out sequentially and cross the ABI unchanged.
dotnet publish ForceDirectedLayout.Native -c Release -r win-x64
API Reference
ForceDirectedLayout<TBody, TEdge>
| Name | Return Type | Description |
|---|---|---|
Settings |
PhysicsSettings |
The managed-facing settings; mutate freely between frames |
SetFrozenBodies(IReadOnlySet<int>) |
void |
Bodies excluded from integration, typically the ones being dragged |
InitializeWorldOriginToCentroid(IReadOnlyList<TBody>) |
void |
Anchors the world origin to the current centroid |
Step(IList<TBody>, IReadOnlyList<TEdge>, double) |
void |
Advances the simulation and writes state back through the accessor |
LastStepInfo |
(int SubstepCount, double SubstepDeltaTime) |
What the last step actually ran |
ForceLayout
| Name | Return Type | Description |
|---|---|---|
SetNodes(ReadOnlySpan<NodeInit>) / SetEdges(ReadOnlySpan<EdgeInit>) |
void |
Bulk submission of POD state |
Step(double) |
void |
Advances by a delta, substepping to TargetPhysicsHz |
Solve(int, double) |
int |
Runs to convergence, returning the iterations used |
GetPositions(Span<NodePosition>) |
int |
Copies positions into a caller buffer |
GetPositionsView() |
ReadOnlySpan<NodePosition> |
Zero-copy view of the current positions |
SetPinned(int, bool) / SetFrozen(int, bool) |
void |
Pins or freezes a body by index |
SetPosition(int, Vec2D) |
void |
Moves a body directly |
GetIndexOf(int) |
int |
Index of a body id, or -1 |
InitializeWorldOriginToCentroid() |
void |
Anchors the world origin to the current centroid |
LayoutCore
The flat working buffers under both facades: Settings, WorldOrigin, GravityCenter, TotalSystemEnergy, IsStable, LastStepInfo, Bodies, Edges, ResizeBodies, ResizeEdges, Step and Solve. Use it when you want to own the buffers yourself.
Supporting types
Vec2D (double-precision vector with the usual operators), BodyState, EdgeRef, NodeInit, EdgeInit, NodePosition, LayoutSettings (blittable POD, the C ABI form) and PhysicsSettings (the managed form, convertible both ways).
Acknowledgments
This package has no runtime dependencies; Polyfill is used at build time only, to backport newer APIs.
Contributing
Contributions are welcome! For feature requests, bug reports, or questions, please open an issue on the GitHub repository. If you would like to contribute code, please open a pull request with your changes.
License
ForceDirectedLayout is licensed under the MIT License. See LICENSE.md for more information.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
NuGet packages (3)
Showing the top 3 NuGet packages that depend on ktsu.ForceDirectedLayout:
| Package | Downloads |
|---|---|
|
ktsu.ImGuiNodeEditor
A comprehensive .NET library suite for building desktop applications with Dear ImGui. Provides application scaffolding with PID-controlled frame limiting, custom widgets (TabPanel, SearchBox, Knob, RadialProgressBar, DividerContainer, Grid), modal dialogs (file browser, input prompts, searchable lists), a theming system with 50+ built-in themes and scoped styling, and an attribute-based node graph editor with physics-based layout. Built on Hexa.NET.ImGui bindings and Silk.NET for cross-platform windowing. |
|
|
ktsu.Coder.Graph
A flexible and extensible .NET library for representing code as language-agnostic Abstract Syntax Trees, serializing them to human-readable YAML for round-trip storage and version control, and generating source in C#, Python, JavaScript and C++. Provides strongly-typed AST nodes for classes, functions, parameters, statements, expressions and typed literals, with a plugin-based architecture for adding target languages, full deep-cloning support and custom metadata on any node. Ships an ImGui node-graph editor over the same AST, with a force-directed layout, an inspector for every node's properties, undo/redo, and a desktop application built on it. |
|
|
ktsu.ImGui.NodeEditor
A visual node editor for Dear ImGui built on ImNodes, with the graph kept away from the drawing: the engine owns nodes, links and layout and knows nothing about ImGui, while the renderer draws what it holds and the input handler turns interactions into requests the engine can accept or refuse. Nodes can be declared as ordinary types decorated with ktsu.NodeGraph attributes and instantiated by reflection, with connections checked against the rules that metadata declares. Optional force-directed layout settles the graph, and the view zooms from quarter to double scale. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 3.32.2 | 0 | 9/10/2026 |
| 3.32.1 | 103 | 9/9/2026 |
| 3.32.0 | 103 | 9/9/2026 |
| 3.31.0 | 82 | 9/9/2026 |
| 3.30.0 | 76 | 9/9/2026 |
| 3.29.0 | 71 | 9/9/2026 |
| 3.28.0 | 81 | 9/9/2026 |
| 3.27.0 | 80 | 9/9/2026 |
| 3.26.1 | 90 | 9/8/2026 |
| 3.26.0 | 75 | 9/8/2026 |
| 3.25.0 | 88 | 9/8/2026 |
| 3.24.0 | 77 | 9/8/2026 |
| 3.23.0 | 84 | 9/8/2026 |
| 3.22.0 | 86 | 9/8/2026 |
| 3.21.0 | 120 | 9/8/2026 |
| 3.20.0 | 87 | 9/8/2026 |
| 3.19.0 | 110 | 9/8/2026 |
| 3.18.0 | 131 | 9/8/2026 |
| 3.17.0 | 58 | 9/8/2026 |
| 3.16.11 | 96 | 9/7/2026 |
## v3.32.2 (patch)
Changes since v3.32.1:
- Bump Polyfill from 11.2.0 to 11.3.0 ([@dependabot[bot]](https://github.com/dependabot[bot]))
- Bump the ktsu group with 6 updates ([@dependabot[bot]](https://github.com/dependabot[bot]))