From 37804bbec8ceaffee5bfa41909752086bafcfce8 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 3 Jul 2026 10:36:38 +0200 Subject: [PATCH 1/2] feat: walk past branch points this allows go to branch start to walk the full tree --- src/skeleton/navigation_graph.spec.ts | 34 +++++++++++++++++++++++++-- src/skeleton/navigation_graph.ts | 2 +- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/skeleton/navigation_graph.spec.ts b/src/skeleton/navigation_graph.spec.ts index 3f8e198fc5..70a3c0fbca 100644 --- a/src/skeleton/navigation_graph.spec.ts +++ b/src/skeleton/navigation_graph.spec.ts @@ -65,11 +65,41 @@ describe("skeleton/navigation", () => { it("finds the skeleton root and branch starts", () => { expect(getSkeletonRootNode(graph).nodeId).toBe(1); expect(getBranchStart(graph, 6).nodeId).toBe(3); - expect(getBranchStart(graph, 3).nodeId).toBe(3); - expect(getBranchStart(graph, 2).nodeId).toBe(2); + expect(getBranchStart(graph, 3).nodeId).toBe(1); + expect(getBranchStart(graph, 2).nodeId).toBe(1); expect(getBranchStart(graph, 1).nodeId).toBe(1); }); + it("walks a degenerate, unbranched skeleton to the root", () => { + const chainGraph = buildSpatiallyIndexedSkeletonNavigationGraph([ + makeNode(1, undefined), + makeNode(2, 1), + makeNode(3, 2), + makeNode(4, 3), + ]); + + expect(getBranchStart(chainGraph, 4).nodeId).toBe(1); + expect(getBranchStart(chainGraph, 3).nodeId).toBe(1); + expect(getBranchStart(chainGraph, 2).nodeId).toBe(1); + expect(getBranchStart(chainGraph, 1).nodeId).toBe(1); + }); + + it("walks past a branch point to the previous branch point or root", () => { + const nestedForkGraph = buildSpatiallyIndexedSkeletonNavigationGraph([ + makeNode(1, undefined), + makeNode(2, 1), + makeNode(3, 2), + makeNode(4, 2), + makeNode(5, 3), + makeNode(6, 3), + ]); + + expect(getBranchStart(nestedForkGraph, 6).nodeId).toBe(3); + expect(getBranchStart(nestedForkGraph, 3).nodeId).toBe(2); + expect(getBranchStart(nestedForkGraph, 2).nodeId).toBe(1); + expect(getBranchStart(nestedForkGraph, 1).nodeId).toBe(1); + }); + it("prefers a downstream branch over a leaf for branch-end navigation", () => { const preferenceGraph = buildSpatiallyIndexedSkeletonNavigationGraph([ makeNode(1, undefined), diff --git a/src/skeleton/navigation_graph.ts b/src/skeleton/navigation_graph.ts index 425806949e..d8cf63ae08 100644 --- a/src/skeleton/navigation_graph.ts +++ b/src/skeleton/navigation_graph.ts @@ -506,7 +506,7 @@ export function getBranchStart( while (true) { const parentNodeId = getParentNodeId(graph, currentNodeId); if (parentNodeId === undefined || visited.has(parentNodeId)) { - return getNodeTarget(graph, nodeId); + return getNodeTarget(graph, currentNodeId); } currentNodeId = parentNodeId; visited.add(currentNodeId); From f0ec5e841dbc708f91de7095191c5bb5a40c955a Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 3 Jul 2026 11:34:28 +0200 Subject: [PATCH 2/2] feat: break branch ties randomly --- src/skeleton/navigation_graph.spec.ts | 15 +++++++++++++++ src/skeleton/navigation_graph.ts | 20 +++++++++++++++----- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/skeleton/navigation_graph.spec.ts b/src/skeleton/navigation_graph.spec.ts index 70a3c0fbca..c38e30ab5e 100644 --- a/src/skeleton/navigation_graph.spec.ts +++ b/src/skeleton/navigation_graph.spec.ts @@ -115,6 +115,21 @@ describe("skeleton/navigation", () => { expect(getBranchEnd(preferenceGraph, 2).nodeId).toBe(2); }); + it("randomly resolves ties between equally preferred branch-end candidates", () => { + const tiedGraph = buildSpatiallyIndexedSkeletonNavigationGraph([ + makeNode(1, undefined), + makeNode(2, 1), + makeNode(3, 2), + makeNode(4, 2), + makeNode(5, 1), + makeNode(6, 5), + makeNode(7, 5), + ]); + + expect(getBranchEnd(tiedGraph, 1, { random: () => 0 }).nodeId).toBe(2); + expect(getBranchEnd(tiedGraph, 1, { random: () => 0.99 }).nodeId).toBe(5); + }); + it("orders flat-list rows in leaf-first pre-order", () => { const listGraph = buildSpatiallyIndexedSkeletonNavigationGraph([ makeNode(1, undefined), diff --git a/src/skeleton/navigation_graph.ts b/src/skeleton/navigation_graph.ts index d8cf63ae08..c707ad8597 100644 --- a/src/skeleton/navigation_graph.ts +++ b/src/skeleton/navigation_graph.ts @@ -519,17 +519,27 @@ export function getBranchStart( export function getBranchEnd( graph: SpatiallyIndexedSkeletonNavigationGraph, nodeId: number, + options: { random?: () => number } = {}, ) { getNodeOrThrow(graph, nodeId); const branchEndNodeIds = getBranchEndNodeIds(graph, nodeId); if (branchEndNodeIds.length === 0) { return getNodeTarget(graph, nodeId); } - const preferredBranchEndNodeId = - branchEndNodeIds.find( - (branchEndNodeId) => getChildNodeIds(graph, branchEndNodeId).length > 1, - ) ?? branchEndNodeIds[0]; - return getNodeTarget(graph, preferredBranchEndNodeId); + const forkNodeIds = branchEndNodeIds.filter( + (branchEndNodeId) => getChildNodeIds(graph, branchEndNodeId).length > 1, + ); + const preferredNodeIds = + forkNodeIds.length > 0 ? forkNodeIds : branchEndNodeIds; + const { random = Math.random } = options; + const randomValue = random(); + const index = Number.isFinite(randomValue) + ? Math.min( + preferredNodeIds.length - 1, + Math.max(0, Math.floor(randomValue * preferredNodeIds.length)), + ) + : 0; + return getNodeTarget(graph, preferredNodeIds[index]); } export function getParentNode(