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
20 changes: 20 additions & 0 deletions ForceDirectedLayout/BodyState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,24 @@ public struct EdgeRef

/// <summary>Reserved per-edge anisotropy weight. Ignored by V1; populated for future use (e.g. execution vs data pin biasing).</summary>
public Vec2D Anisotropy;

/// <summary>
/// Where on the source body this edge attaches, relative to that body's origin. Read only when
/// <see cref="HasPinOffsets"/> is non-zero.
/// </summary>
public Vec2D SourcePinOffset;

/// <summary>
/// Where on the target body this edge attaches, relative to that body's origin. Read only when
/// <see cref="HasPinOffsets"/> is non-zero.
/// </summary>
public Vec2D TargetPinOffset;

/// <summary>
/// Non-zero when the two pin offsets are meaningful. Zero offsets are a legitimate attachment
/// point - a body's top-left corner - so absence needs its own flag rather than a sentinel value.
/// When zero the edge is treated as attaching at both bodies' centres, which is how every force
/// behaved before pin offsets existed.
/// </summary>
public byte HasPinOffsets;
}
15 changes: 14 additions & 1 deletion ForceDirectedLayout/EdgeAccessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,20 @@ namespace ktsu.ForceDirectedLayout;
/// <typeparam name="TEdge">Caller-defined edge type.</typeparam>
/// <param name="GetSourceBodyId">Returns the id of the body the edge originates from.</param>
/// <param name="GetTargetBodyId">Returns the id of the body the edge terminates at.</param>
/// <param name="GetSourcePinOffset">
/// Returns where the edge attaches to its source body, relative to that body's origin. Supply this
/// together with <paramref name="GetTargetPinOffset"/> and the forces that shape a link's length and
/// angle measure between the points a renderer joins, instead of between body centres - which on a
/// body with several rows of pins differ by most of its height. Leave both null and the edge is
/// treated as attaching at the bodies' centres.
/// </param>
/// <param name="GetTargetPinOffset">
/// Returns where the edge attaches to its target body, relative to that body's origin. See
/// <paramref name="GetSourcePinOffset"/>.
/// </param>
public sealed record EdgeAccessor<TEdge>(
Func<TEdge, int> GetSourceBodyId,
Func<TEdge, int> GetTargetBodyId
Func<TEdge, int> GetTargetBodyId,
Func<TEdge, Vec2D>? GetSourcePinOffset = null,
Func<TEdge, Vec2D>? GetTargetPinOffset = null
);
7 changes: 7 additions & 0 deletions ForceDirectedLayout/ForceDirectedLayout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,18 @@ private void SnapshotEdges(IReadOnlyList<TEdge> edges)
targetIndex = -1;
}

// Both offsets or neither: one alone would leave a force measuring from a pin at one end and a
// centre at the other, which is worse than measuring centre to centre at both.
bool hasPins = edgeAccessor.GetSourcePinOffset is not null && edgeAccessor.GetTargetPinOffset is not null;

buf[i] = new EdgeRef
{
SourceIndex = sourceIndex,
TargetIndex = targetIndex,
Anisotropy = Vec2D.Zero,
SourcePinOffset = hasPins ? edgeAccessor.GetSourcePinOffset!(edge) : Vec2D.Zero,
TargetPinOffset = hasPins ? edgeAccessor.GetTargetPinOffset!(edge) : Vec2D.Zero,
HasPinOffsets = (byte)(hasPins ? 1 : 0),
};
}
}
Expand Down
15 changes: 15 additions & 0 deletions ForceDirectedLayout/ForceLayout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ public struct EdgeInit
/// (e.g. execution vs data pin biasing in node-editor consumers).
/// </summary>
public Vec2D Anisotropy;

/// <summary>Where on the source body this edge attaches, relative to that body's origin.</summary>
public Vec2D SourcePinOffset;

/// <summary>Where on the target body this edge attaches, relative to that body's origin.</summary>
public Vec2D TargetPinOffset;

/// <summary>
/// Non-zero when the two pin offsets are meaningful. Leave it zero and the edge attaches at both
/// bodies' centres, which is how every force behaved before pin offsets existed.
/// </summary>
public byte HasPinOffsets;
}

/// <summary>
Expand Down Expand Up @@ -175,6 +187,9 @@ public void SetEdges(ReadOnlySpan<EdgeInit> edges)
SourceIndex = sourceIndex,
TargetIndex = targetIndex,
Anisotropy = init.Anisotropy,
SourcePinOffset = init.SourcePinOffset,
TargetPinOffset = init.TargetPinOffset,
HasPinOffsets = init.HasPinOffsets,
};
}
}
Expand Down
71 changes: 59 additions & 12 deletions ForceDirectedLayout/LayoutCore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,31 @@
}
}

/// <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.
/// </summary>
/// <remarks>
/// A renderer draws a link between pins, not between centres, and on a node with several rows of
/// pins those differ by most of the node's height. Every force that reasons about a link's length
/// or its angle has to use the same two points the link is drawn between, or it is shaping
/// something the user cannot see.
/// </remarks>
private (Vec2D Source, Vec2D Target) EdgeEndpoints(int e)
{
int s = edges[e].SourceIndex;
int t = edges[e].TargetIndex;

if (edges[e].HasPinOffsets != 0)
{
return (bodies[s].Position + edges[e].SourcePinOffset,
bodies[t].Position + edges[e].TargetPinOffset);
}

return (bodies[s].Position + (bodies[s].Dimensions * 0.5),
bodies[t].Position + (bodies[t].Dimensions * 0.5));
}

private void CalculateLinkForces()
{
double restLength = Settings.RestLinkLength;
Expand All @@ -224,10 +249,9 @@
continue;
}

Vec2D sourceCenter = bodies[s].Position + (bodies[s].Dimensions * 0.5);
Vec2D targetCenter = bodies[t].Position + (bodies[t].Dimensions * 0.5);
(Vec2D sourcePin, Vec2D targetPin) = EdgeEndpoints(e);

Vec2D direction = targetCenter - sourceCenter;
Vec2D direction = targetPin - sourcePin;
double currentLength = direction.Length();
if (currentLength <= 0.1)
{
Expand All @@ -253,6 +277,30 @@
/// 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>
/// <summary>
/// The two points this edge's curve is drawn between, for the passes that shape its angle.
/// </summary>
/// <remarks>
/// With pin offsets supplied these are the pins themselves. Without them the fallback is the pair a
/// node editor implies - the source's right edge and the target's left edge, each at its body's
/// mid-height - rather than the body centres <see cref="EdgeEndpoints"/> falls back to. Centres
/// would put both points inside their bodies and overstate the horizontal room a curve has.
/// </remarks>
private (Vec2D Source, Vec2D Target) FlattenedEndpoints(int e)
{
int s = edges[e].SourceIndex;
int t = edges[e].TargetIndex;

if (edges[e].HasPinOffsets != 0)
{
return (bodies[s].Position + edges[e].SourcePinOffset,
bodies[t].Position + edges[e].TargetPinOffset);
}

return (new Vec2D(bodies[s].Position.X + bodies[s].Dimensions.X, bodies[s].Position.Y + (bodies[s].Dimensions.Y * 0.5)),
new Vec2D(bodies[t].Position.X, bodies[t].Position.Y + (bodies[t].Dimensions.Y * 0.5)));
}

private void CalculateLinkFlatteningForces()
{
double strength = Settings.LinkFlatteningStrength;
Expand All @@ -272,26 +320,25 @@
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;
// The angle and the clearance are properties of the drawn curve, so both are measured between
// the points the curve actually joins.
(Vec2D sourcePin, Vec2D targetPin) = FlattenedEndpoints(e);
double gap = targetPin.X - sourcePin.X;
double verticalDrop = Math.Abs(targetPin.Y - sourcePin.Y);

// Which way round the two bodies sit is a property of the bodies, not of where a link happens
// to attach, so the ordering test stays on their centres.
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);
double levelling = strength * (targetPin.Y - sourcePin.Y);
bodies[s].Force += new Vec2D(0, levelling);
bodies[t].Force += new Vec2D(0, -levelling);
}
Expand Down Expand Up @@ -429,7 +476,7 @@
/// and the pair comes to rest still overlapping, just less.
/// </para>
/// </remarks>
private void SeparateOverlaps()

Check warning on line 479 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 479 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 479 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 479 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
2 changes: 1 addition & 1 deletion 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, 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.
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. 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
50 changes: 49 additions & 1 deletion ImGui.NodeEditor/NodeEditorEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,60 @@ public NodeEditorEngine()

EdgeAccessor<Link> edgeAccessor = new(
GetSourceBodyId: l => pinIdToNodeId.TryGetValue(l.OutputPinId, out int id) ? id : -1,
GetTargetBodyId: l => pinIdToNodeId.TryGetValue(l.InputPinId, out int id) ? id : -1
GetTargetBodyId: l => pinIdToNodeId.TryGetValue(l.InputPinId, out int id) ? id : -1,
GetSourcePinOffset: l => ToVec2D(PinOffsetOrCentre(l.OutputPinId)),
GetTargetPinOffset: l => ToVec2D(PinOffsetOrCentre(l.InputPinId))
);

layout = new ForceDirectedLayout<Node, Link>(bodyAccessor, edgeAccessor);
}

/// <summary>Where each pin sits relative to its node's origin, as the renderer last measured it.</summary>
private readonly Dictionary<int, Vector2> pinIdToOffset = [];

/// <summary>
/// Records where a pin sits on its node, so the layout can measure a link between the points a
/// renderer joins rather than between node centres.
/// </summary>
/// <param name="pinId">The pin.</param>
/// <param name="offset">Its position relative to its node's origin, in engine space.</param>
public void UpdatePinOffset(int pinId, Vector2 offset) => pinIdToOffset[pinId] = offset;

/// <summary>
/// Where a renderer last measured a pin, relative to its node's origin.
/// </summary>
/// <param name="pinId">The pin.</param>
/// <param name="offset">Its measured offset, when one has been recorded.</param>
/// <returns>True when the pin has been drawn and measured at least once.</returns>
public bool TryGetPinOffset(int pinId, out Vector2 offset) => pinIdToOffset.TryGetValue(pinId, out offset);

/// <summary>
/// Where a pin sits on its node, falling back to that node's centre until a renderer has measured
/// it.
/// </summary>
/// <remarks>
/// The centre is what every force used before pin offsets existed, so a pin nobody has drawn yet
/// behaves as it always did rather than snapping to a node's top-left corner.
/// </remarks>
private Vector2 PinOffsetOrCentre(int pinId)
{
if (pinIdToOffset.TryGetValue(pinId, out Vector2 offset))
{
return offset;
}

if (pinIdToNodeId.TryGetValue(pinId, out int nodeId))
{
Node? owner = nodes.Find(n => n.Id == nodeId);
if (owner is not null)
{
return owner.Dimensions * 0.5f;
}
}

return Vector2.Zero;
}

/// <inheritdoc/>
public IReadOnlyList<Node> Nodes => nodes.AsReadOnly();
/// <inheritdoc/>
Expand Down
47 changes: 45 additions & 2 deletions ImGui.NodeEditor/NodeEditorRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ public class NodeEditorRenderer
private const float FitMargin = 0.9f;

private readonly Dictionary<int, Vector2> lastKnownNodePositions = [];

/// <summary>Pin rows collected while drawing one node, reused across nodes and frames.</summary>
private readonly List<(int PinId, float MiddleY, bool IsInput)> pinRows = [];
private readonly Dictionary<int, Vector2> lastKnownNodeDimensions = [];
private readonly HashSet<int> currentlyDraggedNodes = [];

Expand Down Expand Up @@ -90,7 +93,7 @@ public void Render(NodeEditorEngine engine, Vector2 editorSize)
// Render all nodes
foreach (Node node in engine.Nodes)
{
RenderNode(node);
RenderNode(engine, node);
}

// Render all links
Expand Down Expand Up @@ -222,8 +225,9 @@ private readonly record struct ScaledStyle(
/// <summary>
/// Render a single node
/// </summary>
private void RenderNode(Node node)
private void RenderNode(NodeEditorEngine engine, Node node)
{
pinRows.Clear();
// Apply engine position to ImNodes BEFORE rendering the node
// This ensures physics-calculated positions are reflected immediately.
// Held in the space ImNodes works in, so a zoom change moves every node here and the
Expand Down Expand Up @@ -268,6 +272,7 @@ private void RenderNode(Node node)
ImNodes.BeginInputAttribute(pin.Id);
ImGui.Text(pin.EffectiveDisplayName);
ImNodes.EndInputAttribute();
RecordPinRow(pin.Id, isInput: true);
}

// Add some spacing between inputs and outputs
Expand Down Expand Up @@ -298,9 +303,47 @@ private void RenderNode(Node node)

ImGui.Text(pinText);
ImNodes.EndOutputAttribute();
RecordPinRow(pin.Id, isInput: false);
}

ImNodes.EndNode();

PublishPinOffsets(engine, node);
}

/// <summary>
/// Notes the vertical middle of the pin row just submitted, in screen space.
/// </summary>
/// <remarks>
/// ImNodes draws a pin's circle on the node's edge, level with the middle of its attribute's row,
/// so the row's rectangle is what says where the pin is. The node's own box is not final until
/// <c>EndNode</c>, which is why the rows are only turned into offsets afterwards.
/// </remarks>
private void RecordPinRow(int pinId, bool isInput) =>
pinRows.Add((pinId, (ImGui.GetItemRectMin().Y + ImGui.GetItemRectMax().Y) * 0.5f, isInput));

/// <summary>
/// Turns this node's recorded pin rows into offsets from its origin and hands them to the engine,
/// so the layout can measure a link between the points it is drawn between.
/// </summary>
private void PublishPinOffsets(NodeEditorEngine engine, Node node)
{
if (pinRows.Count == 0)
{
return;
}

Vector2 nodeScreenPos = ImNodes.GetNodeScreenSpacePos(node.Id);
Vector2 nodeDimensions = ImNodes.GetNodeDimensions(node.Id);

foreach ((int pinId, float middleY, bool isInput) in pinRows)
{
// Inputs sit on the left edge and outputs on the right. Everything here is in the zoomed
// space the view draws in, and the engine's lengths are not, so the offset is scaled back
// the same way node dimensions are.
float x = isInput ? 0f : nodeDimensions.X;
engine.UpdatePinOffset(pinId, new Vector2(x, middleY - nodeScreenPos.Y) / Zoom);
}
}

/// <summary>
Expand Down
Loading