Skip to content

ForceLayout.SetNodes keeps stale edges by index: removing a body silently drops valid edges or rewires them to the wrong bodies #475

Description

@matt-edmondson

What is wrong

ForceLayout.SetNodes (ForceDirectedLayout/ForceLayout.cs:138-170) rebuilds the body array and idToIndex. Previously submitted edges are kept, and the method tries to "Drop any edges whose endpoints no longer exist" (line 160), but the check at line 164 only looks at whether each edge's stored index is still in range:

if ((uint)edgeBuf[i].SourceIndex >= (uint)nodes.Length || (uint)edgeBuf[i].TargetIndex >= (uint)nodes.Length)
{
	edgeBuf[i].SourceIndex = -1;
	edgeBuf[i].TargetIndex = -1;
}

EdgeRef stores only indices, not the body ids given in EdgeInit. As soon as the new body set differs in order or membership, an index points at a different body:

  • A valid edge is dropped. Bodies [1,2,3] with edge 2->3 (indices 1,2); remove the unconnected body 1 with SetNodes([2,3]). Index 2 is now out of range, so the edge is invalidated though both endpoints still exist.
  • A phantom edge appears. Bodies [1,2,3] with edge 1->2 (indices 0,1); remove body 1 with SetNodes([2,3]). Indices 0 and 1 are still in range, so the edge now connects bodies 2 and 3, which were never linked, and its spring pulls them together.

Reordering (SetNodes([3,2,1])) also quietly rewires every edge. This affects the id-based ForceLayout surface and the C ABI (Layout_SetNodes / Layout_SetEdges), which the README presents as bulk submission of POD state keyed by id.

Failure scenario (verified)

Defaults with Enabled = 1.

Case 1: SetNodes([1,2,3]), SetEdges([2->3]), SetNodes([2,3]) → edge becomes src=-1 tgt=-1; the valid edge between 2 and 3 is gone.

Case 2: SetNodes([1,2,3]), SetEdges([1->2]), SetNodes([2,3]) → edge kept as src=0 tgt=1, i.e. ids 2 and 3.

After 600 steps at 1/60 s, distance between bodies 2 and 3 in case 2:

stale=False EdgeCount=0 distance(2,3)=227.7   (fresh layout, no edges)
stale=True  EdgeCount=1 distance(2,3)=134.2   (phantom edge pulling 2 and 3 together)

How it was verified

Scratch console program against the HEAD ForceDirectedLayout project; it reads the core's edge buffer by reflection to print SourceIndex/TargetIndex after each SetNodes, then steps the simulation to show the effect on positions.

Suggested fix / acceptance criteria

  • Keep the endpoint ids for each submitted edge (e.g. a parallel (sourceId, targetId) array filled by SetEdges); SetNodes re-resolves them through the new idToIndex and invalidates an edge only when an id is missing.
  • Alternatively, clear all edges in SetNodes and document that SetEdges must follow.
  • Tests: removing an unconnected body keeps an edge between the remaining bodies; removing an endpoint invalidates its edge instead of re-targeting it; reordering keeps each edge on the same pair of ids.

Activity

  1. matt-edmondson commented on Sep 27, 2026

    @matt-edmondson
    ContributorAuthor

    Triage

    • Category: Bug
    • Priority: Medium. Removing or reordering bodies silently drops edges or rewires them to the wrong bodies. It doesn't crash, but the layout is visibly wrong, and the C ABI (Layout_SetNodes/Layout_SetEdges) is affected too.
    • Area / suggested owner: ForceDirectedLayout (ForceLayout.SetNodes)
    • Duplicates / in progress: None found. No open PR.
    • Notes: Store the endpoint ids for each edge and re-resolve them through idToIndex in SetNodes. That fixes all three cases (drop, phantom, reorder). Clearing the edges instead would be a behaviour change and would need a README note.

    Generated by Claude Code

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

Metadata

Metadata

Labels

bugSomething isn't workingreadyFully specified; implement as written

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions