Skip to content

C ABI surface + AOT-friendly double-precision core for ForceDirectedLayout#177

Merged
matt-edmondson merged 1 commit into
mainfrom
claude/force-layout-native
May 27, 2026
Merged

C ABI surface + AOT-friendly double-precision core for ForceDirectedLayout#177
matt-edmondson merged 1 commit into
mainfrom
claude/force-layout-native

Conversation

@matt-edmondson
Copy link
Copy Markdown
Contributor

Summary

Note: this PR is stacked on top of #176 (the initial library extraction). The diff against main includes the #176 commits; review will be cleanest after #176 lands, at which point this PR's diff reduces to just the new work.

Follow-up to #176. Restructures ktsu.ForceDirectedLayout so a single source tree produces two artifacts:

  1. A managed assembly consumed via NuGet (existing generic facade kept, now idiomatic .NET on top of a shared core)
  2. A NativeAOT-compiled shared library (.dll / .dylib / .so) consumed via a C entry-point surface — Unreal plugins, C++ tooling, anything that can dlopen and call C functions

The C surface drove the design; the managed surface is the comfortable layer on top of the same core.

Layer cake

                ┌───────────────────────────────────────────────────┐
                │ NodeEditorEngine                                  │
                │   (System.Numerics.Vector2 float, for ImNodes)    │
                └──────────────────────┬────────────────────────────┘
                                       │ Vec2D↔Vector2 at boundary
                ┌──────────────────────▼────────────────────────────┐
                │ ForceDirectedLayout<TBody, TEdge>                 │
                │   generic facade, BodyAccessor / EdgeAccessor     │
                └──────────────────────┬────────────────────────────┘
                                       │ snapshot+commit
                ┌──────────────────────▼────────────────────────────┐  ┌───────────────────────┐
                │ ForceLayout                                       │◄─┤ NativeExports         │
                │   non-generic managed surface                     │  │   [UnmanagedCallers]  │
                │   ReadOnlySpan<NodePosition>, NodeInit/EdgeInit   │  │   try/catch→errcode   │
                └──────────────────────┬────────────────────────────┘  └───────────────────────┘
                                       │
                ┌──────────────────────▼────────────────────────────┐
                │ LayoutCore                                        │
                │   bare algorithm, BodyState[] / EdgeRef[]         │
                │   no allocations per step, no reflection          │
                │   double precision                                │
                └───────────────────────────────────────────────────┘

C API (V1 surface lock)

LayoutHandle Layout_Create(const LayoutSettings* config);
int32_t      Layout_Destroy(LayoutHandle handle);
int32_t      Layout_SetSettings(LayoutHandle, const LayoutSettings*);
int32_t      Layout_SetNodes(LayoutHandle, const NodeInit*, int32_t count);
int32_t      Layout_SetEdges(LayoutHandle, const EdgeInit*, int32_t count);
int32_t      Layout_SetPinned(LayoutHandle, int32_t nodeIndex, uint8_t pinned);
int32_t      Layout_Step(LayoutHandle, double dt);
int32_t      Layout_Solve(LayoutHandle, int32_t maxIterations, double tolerance);
int32_t      Layout_GetPositions(LayoutHandle, NodePosition* outBuf, int32_t bufLen);
int32_t      Layout_GetIndexOf(LayoutHandle, int32_t nodeId, int32_t* outIndex);
int32_t      Layout_GetNodeCount(LayoutHandle, int32_t* outCount);
int32_t      Layout_GetLastErrorMessage(uint8_t* outBuf, int32_t bufLen);
  • Opaque-handle style. Handles are GCHandle.Alloc(ForceLayout). Pair every Create with a Destroy.
  • All structs [StructLayout(Sequential)], fixed-size primitives only.
  • Every export wraps a try/catch that converts managed exceptions to error codes. No exception escapes into native code.
  • Reference header committed at ForceDirectedLayout.Native/ktsu_force_directed_layout.h alongside the AOT-emitted one.

What changed in the core

  • Vec2D: new double-precision blittable 2D vector. Replaces System.Numerics.Vector2 (which is float) throughout the layout core.
  • PhysicsSettings: managed record, plain doubles. The ktsu.Semantics.Quantities typed wrappers (Force<float> etc.) are gone — they generated AOT/trim concerns and the typed affordance wasn't worth the friction at the boundary.
  • LayoutSettings: POD mirror of PhysicsSettings, crosses the C boundary unchanged.
  • LayoutCore: the algorithm in its bare form. Operates on flat BodyState[] and EdgeRef[] buffers, no allocations per step, no reflection, no generics, no managed-only types.
  • ForceLayout: non-generic managed surface. Accepts NodeInit / EdgeInit POD bulk submission, returns ReadOnlySpan<NodePosition> for zero-copy reads.
  • ForceDirectedLayout<TBody, TEdge>: refactored to delegate to LayoutCore via snapshot+commit. Accessor signatures now use Vec2D.
  • NodeEditorEngine: bridges float Vector2Vec2D at the accessor boundary. Its public surface stays float-based.

Build

  • ForceDirectedLayout.csproj: IsAotCompatible=true, IsTrimmable=true, EnableTrimAnalyzer=true, EnableAotAnalyzer=true, EnableSingleFileAnalyzer=true. Trim/AOT warnings fail the build so reflection drift is caught immediately.
  • ForceDirectedLayout.Native.csproj: thin AOT publish project. <PublishAot>true</PublishAot>, <NativeLib>Shared</NativeLib>, <TrimMode>full</TrimMode>, <InvariantGlobalization>true</InvariantGlobalization>. Per-platform binaries via dotnet publish -r <rid>.

Reserved Vec2D Anisotropy on EdgeInit for V2 (execution vs data-pin biasing in node-editor consumers) — adding it later would be an ABI break.

Reserved / explicitly not in V1

  • Per-edge anisotropy is plumbed through EdgeInit and EdgeRef.Anisotropy but the algorithm ignores it.
  • No callback exports yet. When they land they'll use delegate* unmanaged<...>, not Func<>/Action<>.
  • No native test harness (C/C++ link target). Worth adding to CI once a per-platform publish matrix exists.

Test plan

  • dotnet test --project tests/ForceDirectedLayout.Tests passes on the project's CI (covers repulsion, pinning, solve convergence, settings round-trip)
  • dotnet build ForceDirectedLayout.Native succeeds
  • dotnet publish ForceDirectedLayout.Native -c Release -r linux-x64 produces a .so (and the equivalent for win-x64 / osx-arm64 in CI)
  • Confirm the AOT-generated .h matches the hand-authored reference header in field order and primitive sizes
  • Confirm CleanImNodesDemo still settles into a left-to-right layout with all PhysicsSettings sliders and presets
  • Confirm pinning/dragging behaviour in the demo
  • Catch any trim/AOT analyzer warnings introduced by future changes — they fail the build by design

Generated by Claude Code

Restructures ktsu.ForceDirectedLayout for dual-artifact shipping:
  - Managed NuGet (existing generic facade, idiomatic .NET)
  - NativeAOT shared library (.dll/.dylib/.so) via a thin
    ForceDirectedLayout.Native publish project

Core changes:

  - New double-precision Vec2D struct replaces System.Numerics.Vector2
    in the layout core. Blittable POD layout so the same memory backs
    both managed and native callers.

  - LayoutSettings (POD, plain doubles) is the canonical settings shape;
    PhysicsSettings (managed record with bool/init properties) sits on
    top and converts at the boundary. The semantic-types dependency
    (Force<float>/Length<float>) is dropped - it generated AOT/trim
    concerns and the typed affordance is not worth the cost in the core.

  - LayoutCore: bare algorithm operating on flat BodyState[] and
    EdgeRef[] working buffers. No allocations per step. No reflection,
    no generics, no managed-only types. Exposes Bodies and Edges as
    Span<T>.

  - ForceLayout: non-generic managed surface over LayoutCore. Bulk
    NodeInit / EdgeInit submission, ReadOnlySpan<NodePosition> reads,
    SetPinned / SetFrozen / SetPosition. Same code path the C exports
    drive.

  - ForceDirectedLayout<TBody, TEdge>: refactored to delegate to
    LayoutCore via snapshot+commit. Accessor signatures now use Vec2D.

C ABI:

  - NativeExports: 11 [UnmanagedCallersOnly] entry points - Create,
    Destroy, SetSettings, SetNodes, SetEdges, Step, Solve, GetPositions,
    SetPinned, GetIndexOf, GetNodeCount, GetLastErrorMessage. Every body
    wraps a try/catch that converts managed exceptions to a small enum
    of return codes - no exception escapes the boundary.

  - Opaque handle = GCHandle wrapping a managed ForceLayout. Pair every
    Create with a Destroy.

  - Reserved Vec2D Anisotropy field on EdgeInit for future per-edge
    biasing (execution vs data pin weighting) - adding it later would
    be an ABI break.

  - Hand-authored reference header at
    ForceDirectedLayout.Native/ktsu_force_directed_layout.h alongside
    the AOT-generated one (emitted at publish time).

Build:

  - ForceDirectedLayout.csproj: IsAotCompatible, IsTrimmable,
    EnableTrimAnalyzer, EnableAotAnalyzer, EnableSingleFileAnalyzer all
    enabled so reflection drift fails the build.

  - ForceDirectedLayout.Native.csproj: PublishAot=true, NativeLib=Shared,
    InvariantGlobalization=true, TrimMode=full. Per-platform publish via
    `dotnet publish -r <rid>`.

Consumer impact:

  - ImGuiNodeEditor.NodeEditorEngine bridges System.Numerics.Vector2
    (float, for ImNodes) to Vec2D (double) at the accessor boundary.
    Public API of NodeEditorEngine remains float Vector2.

  - CleanImNodesDemo PhysicsSettings sliders cast (float)<->(double) at
    the ImGui boundary; the semantic-type formatting is gone.

  - Adds tests/ForceDirectedLayout.Tests with MSTest coverage of the
    new non-generic surface (repulsion, pinning, solve convergence,
    settings round-trip).
@sonarqubecloud
Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
1 Security Hotspot
0.0% Coverage on New Code (required ≥ 80%)
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@matt-edmondson matt-edmondson merged commit 357e166 into main May 27, 2026
4 of 5 checks passed
@matt-edmondson matt-edmondson deleted the claude/force-layout-native branch May 27, 2026 08:47
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