From b194ac4fc4ba656ad20dda3b38c82ff64cf216c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 10:01:09 +0000 Subject: [PATCH] [minor] Measure links between their pins rather than between node centres Every force that shapes a link worked on body geometry: the spring pulled centre to centre, the levelling force closed the gap between centre heights, and the bezier clearance measured a drop between centres across the facing edges. A renderer draws a link between pins, and on a node with several rows of pins those differ by most of its height - so a pair whose centres are perfectly level can still show a steep link, and the clearance was computed from a drop the curve is not drawn with. EdgeRef and EdgeInit now carry a pin offset for each end, relative to that body's origin, with a HasPinOffsets flag: zero offsets are a legitimate attachment point, so absence needs a flag rather than a sentinel. The spring and both flattening mechanisms measure between those points. Fallbacks are per-force and chosen so nothing changes for a caller that supplies no pins. The spring falls back to centres, which is what it used. The flattening pass falls back to the pair a node editor implies - source's right edge, target's left edge, each at mid-height - which is what it already assumed; centres there would put both points inside their bodies and overstate the room a curve has. EdgeAccessor takes two optional offset getters, both or neither: one alone would measure from a pin at one end and a centre at the other, which is worse than centres at both. NodeEditorRenderer measures where ImNodes actually put each pin - the middle of its attribute row, on the node's left or right edge - and hands it to the engine, scaled back out of the zoomed view space the same way node dimensions are. It does this during its own render pass, so every consumer gets it without changing a call. A pin nobody has drawn yet falls back to its node's centre. Renderer_MeasuresWhereEachPinSitsOnItsNode drives real ImNodes through the headless harness and checks the measurement rather than trusting it: inputs on the left edge, outputs on the right, pins in the order drawn, three rows spanning more than a few pixels, all inside the node's height. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D2KNUKr1xJPTEdDmeF2tQN --- ForceDirectedLayout/BodyState.cs | 20 +++++ ForceDirectedLayout/EdgeAccessor.cs | 15 +++- ForceDirectedLayout/ForceDirectedLayout.cs | 7 ++ ForceDirectedLayout/ForceLayout.cs | 15 ++++ ForceDirectedLayout/LayoutCore.cs | 71 +++++++++++++--- ForceDirectedLayout/README.md | 2 +- ImGui.NodeEditor/NodeEditorEngine.cs | 50 ++++++++++- ImGui.NodeEditor/NodeEditorRenderer.cs | 47 ++++++++++- .../ForceLayoutTests.cs | 82 +++++++++++++++++++ .../NodeEditorInteractionTests.cs | 36 ++++++++ 10 files changed, 328 insertions(+), 17 deletions(-) diff --git a/ForceDirectedLayout/BodyState.cs b/ForceDirectedLayout/BodyState.cs index f7c475e5..384b71e6 100644 --- a/ForceDirectedLayout/BodyState.cs +++ b/ForceDirectedLayout/BodyState.cs @@ -50,4 +50,24 @@ public struct EdgeRef /// Reserved per-edge anisotropy weight. Ignored by V1; populated for future use (e.g. execution vs data pin biasing). public Vec2D Anisotropy; + + /// + /// Where on the source body this edge attaches, relative to that body's origin. Read only when + /// is non-zero. + /// + public Vec2D SourcePinOffset; + + /// + /// Where on the target body this edge attaches, relative to that body's origin. Read only when + /// is non-zero. + /// + public Vec2D TargetPinOffset; + + /// + /// 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. + /// + public byte HasPinOffsets; } diff --git a/ForceDirectedLayout/EdgeAccessor.cs b/ForceDirectedLayout/EdgeAccessor.cs index 2ac7698e..0f43cce0 100644 --- a/ForceDirectedLayout/EdgeAccessor.cs +++ b/ForceDirectedLayout/EdgeAccessor.cs @@ -12,7 +12,20 @@ namespace ktsu.ForceDirectedLayout; /// Caller-defined edge type. /// Returns the id of the body the edge originates from. /// Returns the id of the body the edge terminates at. +/// +/// Returns where the edge attaches to its source body, relative to that body's origin. Supply this +/// together with 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. +/// +/// +/// Returns where the edge attaches to its target body, relative to that body's origin. See +/// . +/// public sealed record EdgeAccessor( Func GetSourceBodyId, - Func GetTargetBodyId + Func GetTargetBodyId, + Func? GetSourcePinOffset = null, + Func? GetTargetPinOffset = null ); diff --git a/ForceDirectedLayout/ForceDirectedLayout.cs b/ForceDirectedLayout/ForceDirectedLayout.cs index ba5fae4a..35224f53 100644 --- a/ForceDirectedLayout/ForceDirectedLayout.cs +++ b/ForceDirectedLayout/ForceDirectedLayout.cs @@ -145,11 +145,18 @@ private void SnapshotEdges(IReadOnlyList 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), }; } } diff --git a/ForceDirectedLayout/ForceLayout.cs b/ForceDirectedLayout/ForceLayout.cs index 5aa64023..4647618e 100644 --- a/ForceDirectedLayout/ForceLayout.cs +++ b/ForceDirectedLayout/ForceLayout.cs @@ -46,6 +46,18 @@ public struct EdgeInit /// (e.g. execution vs data pin biasing in node-editor consumers). /// public Vec2D Anisotropy; + + /// Where on the source body this edge attaches, relative to that body's origin. + public Vec2D SourcePinOffset; + + /// Where on the target body this edge attaches, relative to that body's origin. + public Vec2D TargetPinOffset; + + /// + /// 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. + /// + public byte HasPinOffsets; } /// @@ -175,6 +187,9 @@ public void SetEdges(ReadOnlySpan edges) SourceIndex = sourceIndex, TargetIndex = targetIndex, Anisotropy = init.Anisotropy, + SourcePinOffset = init.SourcePinOffset, + TargetPinOffset = init.TargetPinOffset, + HasPinOffsets = init.HasPinOffsets, }; } } diff --git a/ForceDirectedLayout/LayoutCore.cs b/ForceDirectedLayout/LayoutCore.cs index d155c3f9..27b309d5 100644 --- a/ForceDirectedLayout/LayoutCore.cs +++ b/ForceDirectedLayout/LayoutCore.cs @@ -210,6 +210,31 @@ private void CalculateRepulsionForces() } } + /// + /// The two points an edge actually joins: its pin positions when the caller supplied them, and the + /// two body centres when it did not. + /// + /// + /// 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. + /// + 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; @@ -224,10 +249,9 @@ private void CalculateLinkForces() 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) { @@ -253,6 +277,30 @@ private void CalculateLinkForces() /// 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. /// + /// + /// The two points this edge's curve is drawn between, for the passes that shape its angle. + /// + /// + /// 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 falls back to. Centres + /// would put both points inside their bodies and overstate the horizontal room a curve has. + /// + 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; @@ -272,18 +320,17 @@ private void CalculateLinkFlatteningForces() 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. @@ -291,7 +338,7 @@ private void CalculateLinkFlatteningForces() // 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); } diff --git a/ForceDirectedLayout/README.md b/ForceDirectedLayout/README.md index c1b8f112..87f05cbf 100644 --- a/ForceDirectedLayout/README.md +++ b/ForceDirectedLayout/README.md @@ -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 diff --git a/ImGui.NodeEditor/NodeEditorEngine.cs b/ImGui.NodeEditor/NodeEditorEngine.cs index 28d8fadf..e7d2928e 100644 --- a/ImGui.NodeEditor/NodeEditorEngine.cs +++ b/ImGui.NodeEditor/NodeEditorEngine.cs @@ -47,12 +47,60 @@ public NodeEditorEngine() EdgeAccessor 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(bodyAccessor, edgeAccessor); } + /// Where each pin sits relative to its node's origin, as the renderer last measured it. + private readonly Dictionary pinIdToOffset = []; + + /// + /// 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. + /// + /// The pin. + /// Its position relative to its node's origin, in engine space. + public void UpdatePinOffset(int pinId, Vector2 offset) => pinIdToOffset[pinId] = offset; + + /// + /// Where a renderer last measured a pin, relative to its node's origin. + /// + /// The pin. + /// Its measured offset, when one has been recorded. + /// True when the pin has been drawn and measured at least once. + public bool TryGetPinOffset(int pinId, out Vector2 offset) => pinIdToOffset.TryGetValue(pinId, out offset); + + /// + /// Where a pin sits on its node, falling back to that node's centre until a renderer has measured + /// it. + /// + /// + /// 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. + /// + 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; + } + /// public IReadOnlyList Nodes => nodes.AsReadOnly(); /// diff --git a/ImGui.NodeEditor/NodeEditorRenderer.cs b/ImGui.NodeEditor/NodeEditorRenderer.cs index eff62d72..3561d234 100644 --- a/ImGui.NodeEditor/NodeEditorRenderer.cs +++ b/ImGui.NodeEditor/NodeEditorRenderer.cs @@ -25,6 +25,9 @@ public class NodeEditorRenderer private const float FitMargin = 0.9f; private readonly Dictionary lastKnownNodePositions = []; + + /// Pin rows collected while drawing one node, reused across nodes and frames. + private readonly List<(int PinId, float MiddleY, bool IsInput)> pinRows = []; private readonly Dictionary lastKnownNodeDimensions = []; private readonly HashSet currentlyDraggedNodes = []; @@ -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 @@ -222,8 +225,9 @@ private readonly record struct ScaledStyle( /// /// Render a single node /// - 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 @@ -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 @@ -298,9 +303,47 @@ private void RenderNode(Node node) ImGui.Text(pinText); ImNodes.EndOutputAttribute(); + RecordPinRow(pin.Id, isInput: false); } ImNodes.EndNode(); + + PublishPinOffsets(engine, node); + } + + /// + /// Notes the vertical middle of the pin row just submitted, in screen space. + /// + /// + /// 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 + /// EndNode, which is why the rows are only turned into offsets afterwards. + /// + private void RecordPinRow(int pinId, bool isInput) => + pinRows.Add((pinId, (ImGui.GetItemRectMin().Y + ImGui.GetItemRectMax().Y) * 0.5f, isInput)); + + /// + /// 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. + /// + 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); + } } /// diff --git a/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs b/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs index 84f0c0da..d95cd13f 100644 --- a/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs +++ b/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs @@ -380,6 +380,88 @@ private static (double SourceCenterX, double TargetCenterX) SettleBackwardEdge(L bodies[1].Position.X + (bodies[1].Dimensions.X * 0.5)); } + /// An edge that knows which pin it attaches to at each end. + private sealed record PinnedEdge(int SourceId, int TargetId, Vec2D SourcePin, Vec2D TargetPin); + + private static ForceDirectedLayout CreatePinnedLayout(PhysicsSettings settings) => + new( + new BodyAccessor( + GetId: b => b.Id, + GetPosition: b => b.Position, + GetDimensions: b => b.Dimensions, + GetVelocity: b => b.Velocity, + GetForce: b => b.Force, + GetIsPinned: b => b.IsPinned, + WithPhysicsState: (b, p, v, f) => b with { Position = p, Velocity = v, Force = f }), + new EdgeAccessor( + GetSourceBodyId: e => e.SourceId, + GetTargetBodyId: e => e.TargetId, + GetSourcePinOffset: e => e.SourcePin, + GetTargetPinOffset: e => e.TargetPin)) + { + Settings = settings, + }; + + [TestMethod] + public void PinOffsets_LevelThePinsRatherThanTheBodyCentres() + { + // A tall body whose pin sits near its top, feeding a short one whose pin is at its middle. Level + // the centres and the link is still steep; level the pins and the tall body has to ride up. + ForceDirectedLayout layout = CreatePinnedLayout(new PhysicsSettings { Enabled = true }); + List bodies = [Body(1, 0, 0, 160, 300), Body(2, 400, 0, 160, 60)]; + List edges = [new PinnedEdge(1, 2, new Vec2D(160, 30), new Vec2D(0, 30))]; + + for (int i = 0; i < 2000; i++) + { + layout.Step(bodies, edges, 0.016); + } + + double sourcePinY = bodies[0].Position.Y + 30; + double targetPinY = bodies[1].Position.Y + 30; + double sourceCentreY = bodies[0].Position.Y + 150; + double targetCentreY = bodies[1].Position.Y + 30; + double centreGap = Math.Abs(targetCentreY - sourceCentreY); + + // Soft against gravity, so the pins settle near level rather than exactly on it. What matters is + // which pair got levelled: the pins end up far closer together than the centres, where measuring + // from centres would have produced the reverse. + double pinGap = Math.Abs(targetPinY - sourcePinY); + + Assert.IsTrue(pinGap < 60.0, $"The pins should settle close to level; they are {pinGap} apart."); + Assert.IsTrue(centreGap > pinGap * 1.5, + $"The centres should stay further apart than the pins; centres {centreGap}, pins {pinGap}."); + } + + [TestMethod] + public void PinOffsets_MeasureTheSpringBetweenPins() + { + // No other force, so the spring alone settles the pair: pin-to-pin distance should reach the rest + // length, which the centre-to-centre distance then cannot also equal. + ForceDirectedLayout layout = CreatePinnedLayout(new PhysicsSettings + { + Enabled = true, + RepulsionStrength = 0, + GravityStrength = 0, + DirectionalBias = 0, + LinkFlatteningStrength = 0, + OverlapMargin = 0, + RestLinkLength = 200.0, + DampingFactor = 0.1, + }); + List bodies = [Body(1, 0, 0, 200, 80), Body(2, 800, 0, 200, 80)]; + List edges = [new PinnedEdge(1, 2, new Vec2D(200, 40), new Vec2D(0, 40))]; + + for (int i = 0; i < 4000; i++) + { + layout.Step(bodies, edges, 0.016); + } + + double sourcePinX = bodies[0].Position.X + 200; + double pinDistance = bodies[1].Position.X - sourcePinX; + Assert.IsTrue(Math.Abs(pinDistance - 200.0) < 15.0, + $"The spring should settle the pins at its rest length; they are {pinDistance} apart."); + } + [TestMethod] public void LinkFlattening_PullsAForwardEdgeTowardsHorizontal() { diff --git a/tests/ImGui.NodeEditor.Tests/NodeEditorInteractionTests.cs b/tests/ImGui.NodeEditor.Tests/NodeEditorInteractionTests.cs index f3b76cce..9e9deb9e 100644 --- a/tests/ImGui.NodeEditor.Tests/NodeEditorInteractionTests.cs +++ b/tests/ImGui.NodeEditor.Tests/NodeEditorInteractionTests.cs @@ -3,6 +3,7 @@ namespace ktsu.ImGui.NodeEditor.Tests; using System.Collections.Generic; +using System.Linq; using System.Numerics; using Hexa.NET.ImGui; @@ -96,6 +97,41 @@ public void ProcessInput_ReportsNothingWhenTheUserDidNothing() } [TestMethod] + public void Renderer_MeasuresWhereEachPinSitsOnItsNode() + { + Node source = engine.CreateNode(new Vector2(150, 150), "Source", [], ["Out"]); + Node target = engine.CreateNode(new Vector2(600, 150), "Target", ["First", "Second", "Third"], []); + engine.TryCreateLink(source.OutputPins[0].Id, target.InputPins[0].Id); + Start(); + + Node measured = engine.Nodes.Single(n => n.Id == target.Id); + + List inputYs = []; + foreach (Pin pin in measured.InputPins) + { + Assert.IsTrue(engine.TryGetPinOffset(pin.Id, out Vector2 offset), $"{pin.Id} was never measured."); + + // Inputs hang off the left edge, and every pin sits somewhere down the node's own height. + Assert.AreEqual(0f, offset.X, 0.01f, "an input pin should sit on the node's left edge"); + Assert.IsGreaterThan(0f, offset.Y, "a pin sits below the node's top edge"); + Assert.IsLessThan(measured.Dimensions.Y, offset.Y, "a pin sits above the node's bottom edge"); + inputYs.Add(offset.Y); + } + + // Drawn top to bottom, so measured top to bottom - which is the whole point of measuring rather + // than assuming every pin is at the node's middle. + for (int i = 1; i < inputYs.Count; i++) + { + Assert.IsGreaterThan(inputYs[i - 1], inputYs[i], "pins should be measured in the order they are drawn"); + } + + Assert.IsGreaterThan(20f, inputYs[^1] - inputYs[0], "three rows of pins should span more than a few pixels"); + + Node measuredSource = engine.Nodes.Single(n => n.Id == source.Id); + Assert.IsTrue(engine.TryGetPinOffset(measuredSource.OutputPins[0].Id, out Vector2 outputOffset)); + Assert.AreEqual(measuredSource.Dimensions.X, outputOffset.X, 0.01f, "an output pin should sit on the node's right edge"); + } + public void Renderer_MeasuresEveryNodeItDrew() { Node source = engine.CreateNode(new Vector2(200, 200), "Source", [], ["Value"]);