Skip to content

Add a link-flattening force to keep steep edges' curves visible - #360

Merged
matt-edmondson merged 2 commits into
mainfrom
claude/force-directed-vertical-links-4zjaqb
Sep 8, 2026
Merged

Add a link-flattening force to keep steep edges' curves visible#360
matt-edmondson merged 2 commits into
mainfrom
claude/force-directed-vertical-links-4zjaqb

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Problem

Near-vertical and backward edges had their rendered curves swallowed by their own endpoint bodies. ImNodes draws links in a channel beneath the node backgrounds, so a curve that doubles back over a node simply disappears.

Nothing in the simulation was angle-aware. DirectionalBias looked like it might help, but it only enforces a flat minimum horizontal gap of halfWidths + 20 and switches off past that threshold — it says nothing about slope, so a steep edge clears it easily and still vanishes.

The bound

ImNodes renders a link as a cubic bezier whose inner control points are offset horizontally by 0.25 * length from each pin. Writing gap for the clear horizontal span between the source's right edge and the target's left edge, the curve's x-coordinate is monotonic — it never doubles back over either body — exactly when:

gap >= 0.25 * length

Substituting length = sqrt(gap^2 + dy^2) and solving gives:

gap >= |dy| / sqrt(15)     (~0.2582 * |dy|, a cap of ~75.5 degrees off horizontal)

That ratio is LayoutCore.BezierClearanceRatio, derived rather than tuned, with the derivation in its doc comment.

Changes

  • LayoutCore.CalculateLinkFlatteningForces — new force pass, running between the plain link spring and the ordering bias. Splays an edge's endpoints apart along X until the bound is met, then switches off, so it shapes angles rather than stretching the graph. Pins are approximated by the facing edges of the two bodies at their centre heights.
  • LinkFlatteningStrength (default 0.5, 0 disables) and LinkFlatteningMargin (default 0.0, extra clearance on top of the derived bound) added to LayoutSettings and its PhysicsSettings mirror, plus both converters. LayoutSettings crosses the C ABI as a whole struct, so no per-field export changes were needed.
  • Demo — two sliders in the ImNodes demo's Links section, and values for the Gentle/Strong presets, which spell out every field.
  • README — the new settings in the tuning block, and the force in the opening summary.

This is orthogonal to DirectionalBias: that one holds a flat 20px ordering floor, this one caps slope. Both splay along X and they compose without fighting.

Behavior worth knowing

It is a soft constraint balanced against the link spring, so equilibrium settles just inside the bound rather than exactly on it. For a vertically stacked pair at default settings the residual violation is ~1.8%, i.e. a hair of backward travel — negligible in practice. Raise LinkFlatteningStrength, or add a LinkFlatteningMargin, to tighten it. The tests assert against 95% of the bound for this reason, and the doc comments say so.

Testing

Four new tests in ForceDirectedLayout.Tests covering the splay on a vertical stack, the no-op on an already-flat edge, the disable switch at zero strength, and the margin holding a flat edge apart. PhysicsSettings_RoundTrips_ThroughLayoutSettings extended to cover both new fields.

  • ForceDirectedLayout.Tests — 30/30 pass
  • ImGui.NodeEditor.Tests — 63/63 pass (the force is on by default, so this confirms it doesn't disturb existing expectations)
  • ImGui.NodeEditor, ForceDirectedLayout.Native and ImGuiAppDemo all build clean in Release

Note: this container's SDK is 10.0.111 (Roslyn 5.0) while ktsu.Sdk.Analyzers 2.28.0 requires Roslyn 5.9, so every build here fails with CS9057 on a clean checkout of main too. I verified locally with the analyzer stripped via an out-of-tree targets file; nothing in the repo was changed to work around it, and CI's newer SDK is unaffected.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D2KNUKr1xJPTEdDmeF2tQN


Generated by Claude Code

Near-vertical and backward edges had their rendered curves swallowed by
their own endpoint bodies. Links are drawn in a channel beneath the node
backgrounds, so a curve that doubles back over a node disappears.

ImNodes renders a link as a cubic bezier whose inner control points are
offset horizontally by 0.25 * length from each pin. Writing gap for the
clear horizontal span between the source's right edge and the target's
left edge, the curve's x-coordinate is monotonic - it never doubles back
over either body - exactly when gap >= 0.25 * length. Substituting
length = sqrt(gap^2 + dy^2) gives gap >= |dy| / sqrt(15), a cap of about
75.5 degrees off horizontal.

CalculateLinkFlatteningForces splays an edge's endpoints apart until that
bound is met, and switches off once it is, so it shapes angles rather
than stretching the graph. It is separate from DirectionalBias, which
enforces a flat 20px ordering floor and does nothing about slope; the two
are complementary and both splay along X.

Being a soft constraint balanced against the link spring, equilibrium
settles just inside the bound rather than exactly on it.

Adds LinkFlatteningStrength (0.5, 0 disables) and LinkFlatteningMargin
(0.0) to LayoutSettings and its PhysicsSettings mirror, sliders and
preset values to the ImNodes demo, and four tests covering the splay,
the no-op on already-flat edges, the disable switch and the margin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2KNUKr1xJPTEdDmeF2tQN
The gate failed at 78.4% coverage on new code against a required 80%, and
'Analyze & Release' failed only because of that gate. Examples are not in
sonar.coverage.exclusions, so the demo's new slider lines counted as new
code and none of them ran.

Measured with dotnet-coverage rather than guessed. Eight demo lines were
uncovered: the two sliders sit under a collapsing header that starts shut,
inside a panel BeginDisabled until physics is on, so neither the reads nor
the change branches ever executed.

CleanImNodes_LinkFlatteningSlidersRespond enables physics, expands Link
Springs, and drags each slider. The first attempt clicked at 0.75 of the
item rectangle and did not move either value: a slider's rect spans the
track and the label drawn to its right, so that point landed on the text.
Dragging across the left 10-45% stays on the track, which is what makes
the change branch run.

LinkFlattening_DanglingEdge_IsSkipped covers the bounds guard in the new
force. An edge naming an absent node resolves to index -1 at both ends,
and neither resolvable endpoint may be pushed.

Verified per-line against the cobertura report: 29/29 new library lines
and 8/8 new demo lines now covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2KNUKr1xJPTEdDmeF2tQN
@sonarqubecloud

sonarqubecloud Bot commented Sep 8, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants