From 8f7a25805ebd432cc8b579517e71b2048af21a2d Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Thu, 7 May 2026 16:12:50 +0100 Subject: [PATCH] feat: Replace number with bigInt --- src/datasource/catmaid/api.spec.ts | 226 +++++----- src/datasource/catmaid/api.ts | 423 ++++++++++-------- src/datasource/catmaid/backend.ts | 2 +- src/datasource/catmaid/frontend.ts | 9 +- .../catmaid/skeleton_packing.spec.ts | 26 +- src/datasource/catmaid/skeleton_packing.ts | 10 +- .../catmaid/spatial_skeleton_commands.ts | 375 ++++++++++------ .../catmaid/spatial_skeleton_edit_api.ts | 19 +- src/layer/index.ts | 4 +- src/layer/segmentation/index.ts | 82 ++-- src/layer/segmentation/selection.spec.ts | 18 +- src/layer/segmentation/selection.ts | 31 +- .../spatial_skeleton_commands.spec.ts | 294 ++++++------ src/skeleton/api.ts | 11 +- src/skeleton/backend.ts | 8 +- src/skeleton/command_history.spec.ts | 34 +- src/skeleton/command_history.ts | 51 ++- src/skeleton/edit_state.ts | 14 +- src/skeleton/frontend.spec.ts | 103 ++++- src/skeleton/frontend.ts | 208 ++++----- src/skeleton/navigation.spec.ts | 192 ++++---- src/skeleton/navigation.ts | 116 ++--- src/skeleton/overlay_geometry.spec.ts | 30 +- src/skeleton/overlay_geometry.ts | 50 ++- .../overlay_segment_retention.spec.ts | 28 +- src/skeleton/overlay_segment_retention.ts | 35 +- src/skeleton/picking.ts | 12 +- src/skeleton/skeleton_chunk_serialization.ts | 6 +- src/skeleton/spatial_attribute_layout.ts | 2 +- src/skeleton/spatial_skeleton_manager.spec.ts | 304 ++++++------- src/skeleton/spatial_skeleton_manager.ts | 165 ++++--- src/ui/spatial_skeleton_edit_tab.spec.ts | 92 ++-- src/ui/spatial_skeleton_edit_tab.ts | 125 +++--- .../spatial_skeleton_edit_tab_render_state.ts | 15 +- src/ui/spatial_skeleton_edit_tool.spec.ts | 110 ++--- src/ui/spatial_skeleton_edit_tool.ts | 103 ++--- src/ui/spatial_skeleton_tool_messages.spec.ts | 18 +- src/ui/spatial_skeleton_tool_messages.ts | 6 +- src/util/bigint.ts | 47 ++ 39 files changed, 1877 insertions(+), 1527 deletions(-) diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index 4c6dfce89d..1b12f43353 100644 --- a/src/datasource/catmaid/api.spec.ts +++ b/src/datasource/catmaid/api.spec.ts @@ -316,23 +316,23 @@ describe("CatmaidClient skeleton editing methods", () => { { "afonso reviewed it": [22107946], "test 123 4": [ - [22107955, "2026-03-29 10:16:00.000000+00:00"], - [22107955, "2026-03-29 10:15:30.000000+00:00"], + ["22107955", "2026-03-29 10:16:00.000000+00:00"], + ["22107955", "2026-03-29 10:15:30.000000+00:00"], ], - "stale description": [[22107955, "2026-03-29 10:15:45.000000+00:00"]], - ends: [[22107959, "2026-03-29 10:17:00.000000+00:00"]], + "stale description": [["22107955", "2026-03-29 10:15:45.000000+00:00"]], + ends: [["22107959", "2026-03-29 10:17:00.000000+00:00"]], }, [], [], ]); (client as any).fetch = fetchMock; - await expect(client.getSkeleton(2)).resolves.toEqual([ + await expect(client.getSkeleton(2n)).resolves.toEqual([ { - nodeId: 22107946, + nodeId: 22107946n, parentNodeId: undefined, position: new Float32Array([23697030, 15055839, 16651262]), - segmentId: 2, + segmentId: 2n, radius: 2000, confidence: 100, description: "afonso reviewed it", @@ -340,10 +340,10 @@ describe("CatmaidClient skeleton editing methods", () => { sourceState: testSourceState("2026-03-29T10:15:00Z"), }, { - nodeId: 22107955, - parentNodeId: 22107954, + nodeId: 22107955n, + parentNodeId: 22107954n, position: new Float32Array([23705874, 15093672, 16682375]), - segmentId: 2, + segmentId: 2n, radius: 2000, confidence: 100, description: "test 123 4", @@ -351,10 +351,10 @@ describe("CatmaidClient skeleton editing methods", () => { sourceState: testSourceState("2026-03-29T10:16:00Z"), }, { - nodeId: 22107959, - parentNodeId: 22107958, + nodeId: 22107959n, + parentNodeId: 22107958n, position: new Float32Array([23704520, 15085237, 16708998]), - segmentId: 2, + segmentId: 2n, radius: 2000, confidence: 100, description: undefined, @@ -392,7 +392,7 @@ describe("CatmaidClient skeleton editing methods", () => { ], [], { - ends: [[23218380, "2026-04-22 15:11:58.824455+00:00"]], + ends: [["23218380", "2026-04-22 15:11:58.824455+00:00"]], }, [], [], @@ -400,12 +400,12 @@ describe("CatmaidClient skeleton editing methods", () => { .mockResolvedValueOnce([[], [], {}, [], []]); (client as any).fetch = fetchMock; - await expect(client.getSkeleton(2974940)).resolves.toEqual([ + await expect(client.getSkeleton(2974940n)).resolves.toEqual([ { - nodeId: 23218380, + nodeId: 23218380n, parentNodeId: undefined, position: new Float32Array([24233266, 13917594, 15605623]), - segmentId: 2974940, + segmentId: 2974940n, radius: 0, confidence: 100, description: undefined, @@ -453,7 +453,7 @@ describe("CatmaidClient skeleton editing methods", () => { ]); (client as any).fetch = fetchMock; - await expect(client.getSkeleton(1140285)).resolves.toEqual([]); + await expect(client.getSkeleton(1140285n)).resolves.toEqual([]); }); it("merges skeletons using from/to treenode ids", async () => { @@ -466,15 +466,15 @@ describe("CatmaidClient skeleton editing methods", () => { (client as any).fetch = fetchMock; await expect( - client.mergeSkeletons(101, 202, { + client.mergeSkeletons(101n, 202n, { nodes: [ - { nodeId: 101, revisionToken: "2026-03-29T11:50:00Z" }, - { nodeId: 202, revisionToken: "2026-03-29T11:51:00Z" }, + { nodeId: 101n, revisionToken: "2026-03-29T11:50:00Z" }, + { nodeId: 202n, revisionToken: "2026-03-29T11:51:00Z" }, ], }), ).resolves.toEqual({ - resultSegmentId: 17, - deletedSegmentId: 21, + resultSegmentId: 17n, + deletedSegmentId: 21n, directionAdjusted: false, }); @@ -485,8 +485,8 @@ describe("CatmaidClient skeleton editing methods", () => { expect(requestBody.get("to_id")).toBe("202"); expect(requestBody.get("state")).toBe( JSON.stringify([ - [101, "2026-03-29T11:50:00Z"], - [202, "2026-03-29T11:51:00Z"], + ["101", "2026-03-29T11:50:00Z"], + ["202", "2026-03-29T11:51:00Z"], ]), ); }); @@ -513,17 +513,17 @@ describe("CatmaidClient skeleton editing methods", () => { }), ).resolves.toEqual([ { - nodeId: 101, + nodeId: 101n, parentNodeId: undefined, position: new Float32Array([1, 2, 3]), - segmentId: 11, + segmentId: 11n, sourceState: testSourceState("2026-03-29T11:50:00Z"), }, { - nodeId: 102, - parentNodeId: 101, + nodeId: 102n, + parentNodeId: 101n, position: new Float32Array([4, 5, 6]), - segmentId: 17, + segmentId: 17n, sourceState: testSourceState("2026-03-29T11:51:00Z"), }, ]); @@ -564,8 +564,8 @@ describe("CatmaidClient skeleton editing methods", () => { }); (client as any).fetch = fetchMock; - await expect(client.getSkeletonRootNode(17)).resolves.toEqual({ - nodeId: 303, + await expect(client.getSkeletonRootNode(17n)).resolves.toEqual({ + nodeId: 303n, position: [1, 2, 3], }); @@ -578,8 +578,8 @@ describe("CatmaidClient skeleton editing methods", () => { (client as any).fetch = fetchMock; await expect( - client.mergeSkeletons(101, 202, { - nodes: [{ nodeId: 101, revisionToken: "2026-03-29T11:50:00Z" }], + client.mergeSkeletons(101n, 202n, { + nodes: [{ nodeId: 101n, revisionToken: "2026-03-29T11:50:00Z" }], }), ).rejects.toThrow( "CATMAID merge-skeleton node state does not match the requested node ids.", @@ -599,21 +599,21 @@ describe("CatmaidClient skeleton editing methods", () => { (client as any).fetch = fetchMock; await expect( - client.addNode(13, 1, 2, 3, 7, { + client.addNode(13n, 1, 2, 3, 7n, { node: { - nodeId: 7, + nodeId: 7n, revisionToken: "2026-03-29T11:59:00Z", }, }), ).resolves.toEqual({ - nodeId: 88, - segmentId: 13, + nodeId: 88n, + segmentId: 13n, sourceState: testSourceState("2026-03-29T12:00:00Z"), parentSourceState: testSourceState("2026-03-29T12:00:01Z"), }); expect(getFetchBody(fetchMock).get("state")).toBe( - JSON.stringify({ parent: [7, "2026-03-29T11:59:00Z"] }), + JSON.stringify({ parent: ["7", "2026-03-29T11:59:00Z"] }), ); }); @@ -626,9 +626,9 @@ describe("CatmaidClient skeleton editing methods", () => { }); (client as any).fetch = fetchMock; - await expect(client.addNode(13, 1, 2, 3)).resolves.toEqual({ - nodeId: 88, - segmentId: 13, + await expect(client.addNode(13n, 1, 2, 3)).resolves.toEqual({ + nodeId: 88n, + segmentId: 13n, sourceState: testSourceState("2026-03-29T12:00:00Z"), parentSourceState: undefined, }); @@ -646,31 +646,31 @@ describe("CatmaidClient skeleton editing methods", () => { edition_time: "2026-03-29T12:01:00Z", parent_edition_time: "2026-03-29T12:01:01Z", child_edition_times: [ - [11, "2026-03-29T12:01:02Z"], - [12, "2026-03-29T12:01:03Z"], + ["11", "2026-03-29T12:01:02Z"], + ["12", "2026-03-29T12:01:03Z"], ], }); (client as any).fetch = fetchMock; await expect( - client.insertNode(13, 1, 2, 3, 7, [11, 12], { + client.insertNode(13n, 1, 2, 3, 7n, [11n, 12n], { node: { - nodeId: 7, + nodeId: 7n, revisionToken: "2026-03-29T12:00:30Z", }, children: [ - { nodeId: 11, revisionToken: "2026-03-29T12:00:31Z" }, - { nodeId: 12, revisionToken: "2026-03-29T12:00:32Z" }, + { nodeId: 11n, revisionToken: "2026-03-29T12:00:31Z" }, + { nodeId: 12n, revisionToken: "2026-03-29T12:00:32Z" }, ], }), ).resolves.toEqual({ - nodeId: 89, - segmentId: 13, + nodeId: 89n, + segmentId: 13n, sourceState: testSourceState("2026-03-29T12:01:00Z"), parentSourceState: testSourceState("2026-03-29T12:01:01Z"), nodeSourceStateUpdates: [ - { nodeId: 11, sourceState: testSourceState("2026-03-29T12:01:02Z") }, - { nodeId: 12, sourceState: testSourceState("2026-03-29T12:01:03Z") }, + { nodeId: 11n, sourceState: testSourceState("2026-03-29T12:01:02Z") }, + { nodeId: 12n, sourceState: testSourceState("2026-03-29T12:01:03Z") }, ], }); @@ -684,8 +684,8 @@ describe("CatmaidClient skeleton editing methods", () => { JSON.stringify({ edition_time: "2026-03-29T12:00:30Z", children: [ - [11, "2026-03-29T12:00:31Z"], - [12, "2026-03-29T12:00:32Z"], + ["11", "2026-03-29T12:00:31Z"], + ["12", "2026-03-29T12:00:32Z"], ], links: [], }), @@ -704,33 +704,33 @@ describe("CatmaidClient skeleton editing methods", () => { (client as any).fetch = fetchMock; await expect( - client.rerootSkeleton(202, { + client.rerootSkeleton(202n, { node: { - nodeId: 202, - parentNodeId: 201, + nodeId: 202n, + parentNodeId: 201n, revisionToken: "2026-03-29T12:05:00Z", }, parent: { - nodeId: 201, + nodeId: 201n, revisionToken: "2026-03-29T12:04:00Z", }, children: [ - { nodeId: 203, revisionToken: "2026-03-29T12:06:00Z" }, - { nodeId: 204, revisionToken: "2026-03-29T12:07:00Z" }, + { nodeId: 203n, revisionToken: "2026-03-29T12:06:00Z" }, + { nodeId: 204n, revisionToken: "2026-03-29T12:07:00Z" }, ], nodes: [ - { nodeId: 202, revisionToken: "2026-03-29T12:05:00Z" }, - { nodeId: 201, revisionToken: "2026-03-29T12:04:00Z" }, + { nodeId: 202n, revisionToken: "2026-03-29T12:05:00Z" }, + { nodeId: 201n, revisionToken: "2026-03-29T12:04:00Z" }, ], }), ).resolves.toEqual({ nodeSourceStateUpdates: [ { - nodeId: 201, + nodeId: 201n, sourceState: testSourceState("2024-03-29T11:28:31.250Z"), }, { - nodeId: 202, + nodeId: 202n, sourceState: testSourceState("2024-03-29T11:28:32.500Z"), }, ], @@ -743,10 +743,10 @@ describe("CatmaidClient skeleton editing methods", () => { expect(requestBody.get("state")).toBe( JSON.stringify({ edition_time: "2026-03-29T12:05:00Z", - parent: [201, "2026-03-29T12:04:00Z"], + parent: ["201", "2026-03-29T12:04:00Z"], children: [ - [203, "2026-03-29T12:06:00Z"], - [204, "2026-03-29T12:07:00Z"], + ["203", "2026-03-29T12:06:00Z"], + ["204", "2026-03-29T12:07:00Z"], ], links: [], }), @@ -763,13 +763,13 @@ describe("CatmaidClient skeleton editing methods", () => { (client as any).fetch = fetchMock; await expect( - client.rerootSkeleton(202, { + client.rerootSkeleton(202n, { node: { - nodeId: 202, - parentNodeId: 201, + nodeId: 202n, + parentNodeId: 201n, revisionToken: "2026-03-29T12:05:00Z", }, - children: [{ nodeId: 203, revisionToken: "2026-03-29T12:06:00Z" }], + children: [{ nodeId: 203n, revisionToken: "2026-03-29T12:06:00Z" }], }), ).rejects.toThrow( "CATMAID reroot-skeleton parent state does not match the cached skeleton neighborhood.", @@ -787,21 +787,21 @@ describe("CatmaidClient skeleton editing methods", () => { (client as any).fetch = fetchMock; await expect( - client.splitSkeleton(202, { + client.splitSkeleton(202n, { node: { - nodeId: 202, - parentNodeId: 201, + nodeId: 202n, + parentNodeId: 201n, revisionToken: "2026-03-29T12:05:00Z", }, parent: { - nodeId: 201, + nodeId: 201n, revisionToken: "2026-03-29T12:04:00Z", }, - children: [{ nodeId: 203, revisionToken: "2026-03-29T12:06:00Z" }], + children: [{ nodeId: 203n, revisionToken: "2026-03-29T12:06:00Z" }], }), ).resolves.toEqual({ - existingSegmentId: 17, - newSegmentId: 21, + existingSegmentId: 17n, + newSegmentId: 21n, }); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -811,8 +811,8 @@ describe("CatmaidClient skeleton editing methods", () => { expect(requestBody.get("state")).toBe( JSON.stringify({ edition_time: "2026-03-29T12:05:00Z", - parent: [201, "2026-03-29T12:04:00Z"], - children: [[203, "2026-03-29T12:06:00Z"]], + parent: ["201", "2026-03-29T12:04:00Z"], + children: [["203", "2026-03-29T12:06:00Z"]], links: [], }), ); @@ -829,19 +829,19 @@ describe("CatmaidClient skeleton editing methods", () => { (client as any).fetch = fetchMock; await expect( - client.rerootSkeleton(202, { + client.rerootSkeleton(202n, { node: { - nodeId: 202, - parentNodeId: 201, + nodeId: 202n, + parentNodeId: 201n, revisionToken: "2026-03-29T12:05:00Z", }, parent: { - nodeId: 201, + nodeId: 201n, revisionToken: "2026-03-29T12:04:00Z", }, nodes: [ - { nodeId: 202, revisionToken: "2026-03-29T12:05:00Z" }, - { nodeId: 201, revisionToken: "2026-03-29T12:04:00Z" }, + { nodeId: 202n, revisionToken: "2026-03-29T12:05:00Z" }, + { nodeId: 201n, revisionToken: "2026-03-29T12:04:00Z" }, ], }), ).rejects.toThrow( @@ -859,9 +859,9 @@ describe("CatmaidClient skeleton editing methods", () => { (client as any).fetch = fetchMock; await expect( - client.moveNode(42, 10, 11, 12, { + client.moveNode(42n, 10, 11, 12, { node: { - nodeId: 42, + nodeId: 42n, revisionToken: "2026-03-29T12:00:00Z", }, }), @@ -870,7 +870,7 @@ describe("CatmaidClient skeleton editing methods", () => { }); expect(getFetchBody(fetchMock).get("state")).toBe( - JSON.stringify([[42, "2026-03-29T12:00:00Z"]]), + JSON.stringify([["42", "2026-03-29T12:00:00Z"]]), ); }); @@ -879,45 +879,45 @@ describe("CatmaidClient skeleton editing methods", () => { const fetchMock = vi.fn().mockResolvedValue({ success: "Removed treenode successfully.", children: [ - [12, "2026-03-29T12:20:00Z"], - [13, "2026-03-29T12:20:01Z"], + ["12", "2026-03-29T12:20:00Z"], + ["13", "2026-03-29T12:20:01Z"], ], }); (client as any).fetch = fetchMock; await expect( - client.deleteNode(11, { - childNodeIds: [12, 13], + client.deleteNode(11n, { + childNodeIds: [12n, 13n], editContext: { node: { - nodeId: 11, - parentNodeId: 7, + nodeId: 11n, + parentNodeId: 7n, revisionToken: "2026-03-29T12:15:00Z", }, parent: { - nodeId: 7, + nodeId: 7n, revisionToken: "2026-03-29T12:14:00Z", }, children: [ - { nodeId: 12, revisionToken: "2026-03-29T12:13:00Z" }, - { nodeId: 13, revisionToken: "2026-03-29T12:13:01Z" }, + { nodeId: 12n, revisionToken: "2026-03-29T12:13:00Z" }, + { nodeId: 13n, revisionToken: "2026-03-29T12:13:01Z" }, ], }, }), ).resolves.toEqual({ nodeSourceStateUpdates: [ - { nodeId: 12, sourceState: testSourceState("2026-03-29T12:20:00Z") }, - { nodeId: 13, sourceState: testSourceState("2026-03-29T12:20:01Z") }, + { nodeId: 12n, sourceState: testSourceState("2026-03-29T12:20:00Z") }, + { nodeId: 13n, sourceState: testSourceState("2026-03-29T12:20:01Z") }, ], }); expect(getFetchBody(fetchMock).get("state")).toBe( JSON.stringify({ edition_time: "2026-03-29T12:15:00Z", - parent: [7, "2026-03-29T12:14:00Z"], + parent: ["7", "2026-03-29T12:14:00Z"], children: [ - [12, "2026-03-29T12:13:00Z"], - [13, "2026-03-29T12:13:01Z"], + ["12", "2026-03-29T12:13:00Z"], + ["13", "2026-03-29T12:13:01Z"], ], links: [], }), @@ -932,7 +932,7 @@ describe("CatmaidClient skeleton editing methods", () => { (client as any).fetch = fetchMock; await expect( - client.updateDescription(11, "updated description"), + client.updateDescription(11n, "updated description"), ).resolves.toEqual({ description: "updated description", sourceState: testSourceState("2026-03-29T13:00:00Z"), @@ -952,7 +952,7 @@ describe("CatmaidClient skeleton editing methods", () => { (client as any).fetch = fetchMock; await expect( - client.updateDescription(11, "updated description\nends", { + client.updateDescription(11n, "updated description\nends", { isTrueEnd: true, }), ).resolves.toEqual({ @@ -973,10 +973,10 @@ describe("CatmaidClient skeleton editing methods", () => { .mockResolvedValueOnce({ edition_time: "2026-03-29T13:11:00Z" }); (client as any).fetch = fetchMock; - await expect(client.toggleTrueEnd(11, true)).resolves.toEqual({ + await expect(client.toggleTrueEnd(11n, true)).resolves.toEqual({ sourceState: testSourceState("2026-03-29T13:10:00Z"), }); - await expect(client.toggleTrueEnd(11, false)).resolves.toEqual({ + await expect(client.toggleTrueEnd(11n, false)).resolves.toEqual({ sourceState: testSourceState("2026-03-29T13:11:00Z"), }); @@ -997,9 +997,9 @@ describe("CatmaidClient skeleton editing methods", () => { (client as any).fetch = fetchMock; await expect( - client.updateConfidence(11, 75, { + client.updateConfidence(11n, 75, { node: { - nodeId: 11, + nodeId: 11n, revisionToken: "2026-03-29T13:19:00Z", }, }), @@ -1030,9 +1030,9 @@ describe("CatmaidClient skeleton editing methods", () => { ); await expect( - client.moveNode(11, 1, 2, 3, { + client.moveNode(11n, 1, 2, 3, { node: { - nodeId: 11, + nodeId: 11n, revisionToken: "2026-03-29T13:11:00Z", }, }), @@ -1061,9 +1061,9 @@ describe("CatmaidClient skeleton editing methods", () => { ); await expect( - client.moveNode(11, 1, 2, 3, { + client.moveNode(11n, 1, 2, 3, { node: { - nodeId: 11, + nodeId: 11n, revisionToken: "2026-03-29T13:11:00Z", }, }), diff --git a/src/datasource/catmaid/api.ts b/src/datasource/catmaid/api.ts index b092d17ec2..1bee915bfc 100644 --- a/src/datasource/catmaid/api.ts +++ b/src/datasource/catmaid/api.ts @@ -18,6 +18,7 @@ import { Unpackr } from "msgpackr"; import { fetchOkWithCredentials } from "#src/credentials_provider/http_request.js"; import type { CredentialsProvider } from "#src/credentials_provider/index.js"; import type { + SpatialSkeletonId, SpatialSkeletonBounds, SpatialSkeletonSpatialIndexLevel, SpatialSkeletonSourceState, @@ -30,6 +31,7 @@ import { SpatialSkeletonEditConflictError } from "#src/skeleton/edit_errors.js"; import type { SpatiallyIndexedSkeletonNavigationTarget } from "#src/skeleton/navigation.js"; import { getDefaultSpatiallyIndexedSkeletonChunkSize } from "#src/skeleton/spatial_chunk_sizing.js"; import { HttpError } from "#src/util/http_request.js"; +import { compareUint64Ids, parsePositiveUint64Id } from "#src/util/bigint.js"; interface CatmaidStackInfo { dimension: { x: number; y: number; z: number }; @@ -59,13 +61,13 @@ type CatmaidStatePayload = object; export type CatmaidNodeSourceState = { readonly revisionToken: string }; export interface CatmaidEditNodeContext { - nodeId: number; - parentNodeId?: number; + nodeId: SpatialSkeletonId; + parentNodeId?: SpatialSkeletonId; revisionToken: string; } export interface CatmaidEditParentContext { - nodeId: number; + nodeId: SpatialSkeletonId; revisionToken: string; } @@ -77,7 +79,7 @@ export interface CatmaidEditContext { } export interface CatmaidSkeletonNodeSourceStateUpdate { - nodeId: number; + nodeId: SpatialSkeletonId; sourceState: SpatialSkeletonSourceState; } @@ -86,8 +88,8 @@ export interface CatmaidSkeletonEditResult { } export interface CatmaidAddNodeResult extends CatmaidSkeletonEditResult { - nodeId: number; - segmentId: number; + nodeId: SpatialSkeletonId; + segmentId: SpatialSkeletonId; sourceState?: SpatialSkeletonSourceState; parentSourceState?: SpatialSkeletonSourceState; } @@ -113,84 +115,84 @@ export type CatmaidDeleteNodeResult = CatmaidSkeletonEditResult; export type CatmaidRerootResult = CatmaidSkeletonEditResult; export interface CatmaidMergeResult extends CatmaidSkeletonEditResult { - resultSegmentId: number | undefined; - deletedSegmentId: number | undefined; + resultSegmentId: SpatialSkeletonId | undefined; + deletedSegmentId: SpatialSkeletonId | undefined; directionAdjusted: boolean; } export interface CatmaidSplitResult extends CatmaidSkeletonEditResult { - existingSegmentId: number | undefined; - newSegmentId: number | undefined; + existingSegmentId: SpatialSkeletonId | undefined; + newSegmentId: SpatialSkeletonId | undefined; } export interface CatmaidSpatialSkeletonEditApi { getSkeletonRootNode( - skeletonId: number, + skeletonId: SpatialSkeletonId, ): Promise; addNode( - skeletonId: number, + skeletonId: SpatialSkeletonId, x: number, y: number, z: number, - parentId?: number, + parentId?: SpatialSkeletonId, editContext?: CatmaidEditContext, ): Promise; deleteNode( - nodeId: number, + nodeId: SpatialSkeletonId, options: CatmaidDeleteNodeOptions, ): Promise; moveNode( - nodeId: number, + nodeId: SpatialSkeletonId, x: number, y: number, z: number, editContext?: CatmaidEditContext, ): Promise; splitSkeleton( - nodeId: number, + nodeId: SpatialSkeletonId, editContext?: CatmaidEditContext, ): Promise; mergeSkeletons( - fromNodeId: number, - toNodeId: number, + fromNodeId: SpatialSkeletonId, + toNodeId: SpatialSkeletonId, editContext?: CatmaidEditContext, ): Promise; toggleTrueEnd( - nodeId: number, + nodeId: SpatialSkeletonId, nextIsTrueEnd: boolean, ): Promise; insertNode( - skeletonId: number, + skeletonId: SpatialSkeletonId, x: number, y: number, z: number, - parentId: number, - childNodeIds: readonly number[], + parentId: SpatialSkeletonId, + childNodeIds: readonly SpatialSkeletonId[], editContext?: CatmaidEditContext, ): Promise; rerootSkeleton( - nodeId: number, + nodeId: SpatialSkeletonId, editContext?: CatmaidEditContext, ): Promise; updateDescription( - nodeId: number, + nodeId: SpatialSkeletonId, description: string, options?: CatmaidDescriptionUpdateOptions, ): Promise; updateRadius( - nodeId: number, + nodeId: SpatialSkeletonId, radius: number, editContext?: CatmaidEditContext, ): Promise; updateConfidence( - nodeId: number, + nodeId: SpatialSkeletonId, confidence: number, editContext?: CatmaidEditContext, ): Promise; } interface CatmaidDeleteNodeOptions { - childNodeIds?: readonly number[]; + childNodeIds?: readonly SpatialSkeletonId[]; editContext?: CatmaidEditContext; } @@ -347,13 +349,17 @@ function normalizeCatmaidDescription( function parseCatmaidLabelNodeReference(entry: unknown): | { - nodeId: number; + nodeId: SpatialSkeletonId; time?: number; } | undefined { const rawNodeId = Array.isArray(entry) ? entry[0] : entry; - const nodeId = Math.round(Number(rawNodeId)); - if (!Number.isSafeInteger(nodeId) || nodeId <= 0) return undefined; + let nodeId: SpatialSkeletonId; + try { + nodeId = parseCatmaidPositiveId(rawNodeId, "label node id"); + } catch { + return undefined; + } const time = Array.isArray(entry) ? getComparableCatmaidRevisionTime(entry[1]) : undefined; @@ -361,8 +367,8 @@ function parseCatmaidLabelNodeReference(entry: unknown): } function addParsedCatmaidNodeLabel( - labelsByNodeId: Map, - nodeId: number, + labelsByNodeId: Map, + nodeId: SpatialSkeletonId, label: ParsedCatmaidNodeLabel, ) { const existingLabels = labelsByNodeId.get(nodeId); @@ -383,8 +389,8 @@ function addParsedCatmaidNodeLabel( function parseCatmaidNodeLabels( rawLabels: unknown, -): Map { - const labelsByNodeId = new Map(); +): Map { + const labelsByNodeId = new Map(); if (rawLabels === null || typeof rawLabels !== "object") { return labelsByNodeId; } @@ -396,16 +402,17 @@ function parseCatmaidNodeLabels( (entry): entry is string => typeof entry === "string", ); if (stringValues.length === value.length) { - const nodeId = Number(key); - if (!Number.isFinite(nodeId)) continue; + let nodeId: SpatialSkeletonId; + try { + nodeId = parseCatmaidPositiveId(key, "label node id"); + } catch { + continue; + } const labels = stringValues .map((label) => label.trim()) .filter((label) => label.length > 0); if (labels.length === 0) continue; - labelsByNodeId.set( - Math.round(nodeId), - labels.map((label) => ({ label })), - ); + labelsByNodeId.set(nodeId, labels.map((label) => ({ label }))); continue; } const nodeReferences = value.map(parseCatmaidLabelNodeReference); @@ -425,9 +432,12 @@ function parseCatmaidNodeLabels( } function getCatmaidNodeDescriptions( - labelsByNodeId: ReadonlyMap, + labelsByNodeId: ReadonlyMap< + SpatialSkeletonId, + readonly ParsedCatmaidNodeLabel[] + >, ) { - const descriptionsByNodeId = new Map(); + const descriptionsByNodeId = new Map(); for (const [nodeId, labels] of labelsByNodeId) { const description = normalizeCatmaidDescription(labels); if (description !== undefined) { @@ -438,9 +448,12 @@ function getCatmaidNodeDescriptions( } function getCatmaidTrueEndNodes( - labelsByNodeId: ReadonlyMap, + labelsByNodeId: ReadonlyMap< + SpatialSkeletonId, + readonly ParsedCatmaidNodeLabel[] + >, ) { - const trueEndByNodeId = new Map(); + const trueEndByNodeId = new Map(); for (const [nodeId, labels] of labelsByNodeId) { const isTrueEnd = labels.some( ({ label }) => label.trim().toLowerCase() === CATMAID_TRUE_END_LABEL, @@ -612,6 +625,19 @@ function requireCatmaidNonNegativeInt(value: unknown, label: string): number { return numberValue; } +function parseCatmaidPositiveId(value: unknown, label: string) { + return parsePositiveUint64Id(value, `CATMAID ${label}`); +} + +function parseOptionalCatmaidPositiveId( + value: unknown, + label: string, +): SpatialSkeletonId | undefined { + return value === undefined || value === null + ? undefined + : parseCatmaidPositiveId(value, label); +} + function parseOptionalCatmaidBoolean( value: unknown, label: string, @@ -672,7 +698,7 @@ export function getCatmaidSpatialSkeletonGridCellBounds( function appendNodeUpdateRows( body: URLSearchParams, key: string, - rows: Array<[number, number, number, number]>, + rows: Array<[SpatialSkeletonId, number, number, number]>, ) { // CATMAID get_request_list parses nested lists from bracketed keys // (e.g. t[0][0]=id, t[0][1]=x, ...), not from a JSON string. @@ -687,7 +713,7 @@ function appendNodeUpdateRows( function appendScalarList( body: URLSearchParams, key: string, - values: readonly number[], + values: readonly (number | SpatialSkeletonId)[], ) { for (let index = 0; index < values.length; ++index) { body.append(`${key}[${index}]`, values[index].toString()); @@ -701,7 +727,12 @@ function appendCatmaidState( if (state === undefined) { return; } - body.append("state", JSON.stringify(state)); + body.append( + "state", + JSON.stringify(state, (_key, value) => + typeof value === "bigint" ? value.toString() : value, + ), + ); } function normalizeCatmaidRevisionToken(value: unknown): string | undefined { @@ -770,14 +801,19 @@ function parseCatmaidSkeletonRootTarget( } const { root_id, x, y, z } = response as Record; - const nodeId = Number(root_id); + let nodeId: SpatialSkeletonId; + try { + nodeId = parseCatmaidPositiveId(root_id, "root node id"); + } catch { + throw new Error( + "CATMAID skeleton root endpoint returned an unexpected response format.", + ); + } const px = Number(x); const py = Number(y); const pz = Number(z); if ( - Number.isSafeInteger(nodeId) && - nodeId > 0 && Number.isFinite(px) && Number.isFinite(py) && Number.isFinite(pz) @@ -806,7 +842,7 @@ function requireCatmaidRevisionToken( function buildCatmaidNodeState( operation: string, editContext?: CatmaidEditContext, - expectedNodeId?: number, + expectedNodeId?: SpatialSkeletonId, ) { const node = editContext?.node; if (node === undefined) { @@ -829,7 +865,7 @@ function buildCatmaidNodeState( function buildCatmaidMultiNodeState( operation: string, editContext?: CatmaidEditContext, - expectedNodeIds?: readonly number[], + expectedNodeIds?: readonly SpatialSkeletonId[], ) { const nodes = editContext?.nodes ?? @@ -846,14 +882,14 @@ function buildCatmaidMultiNodeState( `CATMAID ${operation} node state does not match the requested node ids.`, ); } - return nodes.map((node): [number, string] => [ + return nodes.map((node): [SpatialSkeletonId, string] => [ node.nodeId, requireCatmaidRevisionToken(node.revisionToken, operation, "node"), ]); } function buildCatmaidAddNodeState( - parentId: number | undefined, + parentId: SpatialSkeletonId | undefined, editContext?: CatmaidEditContext, ) { if (parentId === undefined) { @@ -888,8 +924,8 @@ function buildCatmaidNeighborhoodState( operation: string, editContext?: CatmaidEditContext, options: { - expectedNodeId?: number; - expectedChildIds?: readonly number[]; + expectedNodeId?: SpatialSkeletonId; + expectedChildIds?: readonly SpatialSkeletonId[]; } = {}, ) { const node = editContext?.node; @@ -957,7 +993,7 @@ function buildCatmaidNeighborhoodState( ), ], }), - children: childStates.map((child): [number, string] => [ + children: childStates.map((child): [SpatialSkeletonId, string] => [ child.nodeId, requireCatmaidRevisionToken(child.revisionToken, operation, "child"), ]), @@ -966,8 +1002,8 @@ function buildCatmaidNeighborhoodState( } function buildCatmaidInsertNodeState( - parentId: number, - childNodeIds: readonly number[], + parentId: SpatialSkeletonId, + childNodeIds: readonly SpatialSkeletonId[], editContext?: CatmaidEditContext, ) { const parentNode = editContext?.node; @@ -998,7 +1034,7 @@ function buildCatmaidInsertNodeState( "insert-node", "parent", ), - children: childStates.map((child): [number, string] => [ + children: childStates.map((child): [SpatialSkeletonId, string] => [ child.nodeId, requireCatmaidRevisionToken(child.revisionToken, "insert-node", "child"), ]), @@ -1024,11 +1060,16 @@ function parseCatmaidNodeRevisionUpdates( const revisionUpdates: CatmaidSkeletonNodeSourceStateUpdate[] = []; for (const row of rows) { if (!Array.isArray(row) || row.length < 9) continue; - const nodeId = Number(row[0]); + let nodeId: SpatialSkeletonId; + try { + nodeId = parseCatmaidPositiveId(row[0], "node id"); + } catch { + continue; + } const revisionToken = normalizeCatmaidRevisionToken(row[8]); - if (!Number.isFinite(nodeId) || revisionToken === undefined) continue; + if (revisionToken === undefined) continue; revisionUpdates.push({ - nodeId: Math.round(nodeId), + nodeId, sourceState: { revisionToken }, }); } @@ -1037,13 +1078,20 @@ function parseCatmaidNodeRevisionUpdates( function parseCatmaidMoveRevisionToken( response: any, - nodeId: number, + nodeId: SpatialSkeletonId, ): string | undefined { const updatedRows = Array.isArray(response?.old_treenodes) ? response.old_treenodes : []; for (const row of updatedRows) { - if (!Array.isArray(row) || Number(row[0]) !== nodeId) continue; + if (!Array.isArray(row)) continue; + let updatedNodeId: SpatialSkeletonId; + try { + updatedNodeId = parseCatmaidPositiveId(row[0], "node id"); + } catch { + continue; + } + if (updatedNodeId !== nodeId) continue; return normalizeCatmaidRevisionToken(row[1]); } return normalizeCatmaidRevisionToken(response?.edition_time); @@ -1051,11 +1099,13 @@ function parseCatmaidMoveRevisionToken( function parseCatmaidUpdatedNodesRevisionToken( response: any, - nodeId: number, + nodeId: SpatialSkeletonId, ): string | undefined { const updatedNodes = response?.updated_nodes; if (updatedNodes !== null && typeof updatedNodes === "object") { - const directMatch = (updatedNodes as Record)[nodeId]; + const directMatch = (updatedNodes as Record)[ + nodeId.toString() + ]; const directRevision = normalizeCatmaidRevisionToken( directMatch?.edition_time, ); @@ -1068,7 +1118,7 @@ function parseCatmaidUpdatedNodesRevisionToken( function parseCatmaidConfidenceRevisionToken( response: any, - nodeId: number, + nodeId: SpatialSkeletonId, ): string | undefined { const directRevision = parseCatmaidUpdatedNodesRevisionToken( response, @@ -1097,11 +1147,16 @@ function parseCatmaidChildRevisionUpdates( const children = Array.isArray(value) ? value : []; for (const child of children) { if (!Array.isArray(child) || child.length < 2) continue; - const nodeId = Number(child[0]); + let nodeId: SpatialSkeletonId; + try { + nodeId = parseCatmaidPositiveId(child[0], "child node id"); + } catch { + continue; + } const revisionToken = normalizeCatmaidRevisionToken(child[1]); - if (!Number.isFinite(nodeId) || revisionToken === undefined) continue; + if (revisionToken === undefined) continue; revisionUpdates.push({ - nodeId: Math.round(nodeId), + nodeId, sourceState: { revisionToken }, }); } @@ -1235,8 +1290,18 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { return isNoMatchingNodeProviderErrorPayload(payload); } - async listSkeletons(): Promise { - return this.fetch("skeletons/"); + async listSkeletons(): Promise { + const skeletons = await this.fetch("skeletons/"); + if (!Array.isArray(skeletons)) return []; + const ids: SpatialSkeletonId[] = []; + for (const value of skeletons) { + try { + ids.push(parseCatmaidPositiveId(value, "skeleton id")); + } catch { + continue; + } + } + return ids.sort(compareUint64Ids); } private async listStacks(): Promise<{ id: number }[]> { @@ -1365,7 +1430,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { } async getSkeleton( - skeletonId: number, + skeletonId: SpatialSkeletonId, options: { signal?: AbortSignal } = {}, ): Promise { const { signal } = options; @@ -1392,7 +1457,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { const labelsByNodeId = parseCatmaidNodeLabels(currentData?.[2]); const descriptionByNodeId = getCatmaidNodeDescriptions(labelsByNodeId); const trueEndByNodeId = getCatmaidTrueEndNodes(labelsByNodeId); - const liveNodes = new Map(); + const liveNodes = new Map(); for (const node of rawNodes) { if ( !Array.isArray(node) || @@ -1401,23 +1466,26 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { ) { continue; } - const nodeId = Number(node[0]); - if (!Number.isFinite(nodeId) || liveNodes.has(Math.round(nodeId))) { + let nodeId: SpatialSkeletonId; + try { + nodeId = parseCatmaidPositiveId(node[0], "node id"); + } catch { continue; } - liveNodes.set(Math.round(nodeId), node); + if (liveNodes.has(nodeId)) continue; + liveNodes.set(nodeId, node); } - return [...liveNodes.values()].map((n) => ({ - nodeId: n[0], - parentNodeId: n[1] ?? undefined, + return [...liveNodes.entries()].map(([nodeId, n]) => ({ + nodeId, + parentNodeId: parseOptionalCatmaidPositiveId(n[1], "parent node id"), position: new Float32Array([n[3], n[4], n[5]]), segmentId: skeletonId, radius: Number.isFinite(n[6]) ? n[6] : undefined, confidence: Number.isFinite(n[7]) ? mapCatmaidConfidenceToPercent(n[7]) : undefined, - description: descriptionByNodeId.get(Number(n[0])), - isTrueEnd: trueEndByNodeId.has(Number(n[0])), + description: descriptionByNodeId.get(nodeId), + isTrueEnd: trueEndByNodeId.has(nodeId), sourceState: makeCatmaidNodeSourceState( getCatmaidHistoryRevisionToken(n), ), @@ -1483,16 +1551,18 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { } // Process first LOD level (data[0]) + const parseNodeListRow = (n: any[]): SpatiallyIndexedSkeletonNodeBase => ({ + nodeId: parseCatmaidPositiveId(n[0], "node id"), + parentNodeId: parseOptionalCatmaidPositiveId(n[1], "parent node id"), + position: new Float32Array([n[2], n[3], n[4]]), + segmentId: parseCatmaidPositiveId(n[7], "segment id"), + sourceState: makeCatmaidNodeSourceState( + normalizeCatmaidRevisionToken(n[8]), + ), + }); + const nodes: SpatiallyIndexedSkeletonNodeBase[] = data[0].map( - (n: any[]) => ({ - nodeId: n[0], - parentNodeId: n[1] ?? undefined, - position: new Float32Array([n[2], n[3], n[4]]), - segmentId: n[7], - sourceState: makeCatmaidNodeSourceState( - normalizeCatmaidRevisionToken(n[8]), - ), - }), + parseNodeListRow, ); // Process additional LOD levels. @@ -1507,15 +1577,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { const treenodes = lodLevel[0]; if (Array.isArray(treenodes)) { for (const n of treenodes) { - nodes.push({ - nodeId: n[0], - parentNodeId: n[1] ?? undefined, - position: new Float32Array([n[2], n[3], n[4]]), - segmentId: n[7], - sourceState: makeCatmaidNodeSourceState( - normalizeCatmaidRevisionToken(n[8]), - ), - }); + nodes.push(parseNodeListRow(n)); } } } @@ -1525,7 +1587,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { } async moveNode( - nodeId: number, + nodeId: SpatialSkeletonId, x: number, y: number, z: number, @@ -1548,14 +1610,14 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { } async getSkeletonRootNode( - skeletonId: number, + skeletonId: SpatialSkeletonId, ): Promise { const response = await this.fetch(`skeletons/${skeletonId}/root`); return parseCatmaidSkeletonRootTarget(response); } async rerootSkeleton( - nodeId: number, + nodeId: SpatialSkeletonId, editContext?: CatmaidEditContext, ): Promise { const body = new URLSearchParams({ @@ -1571,11 +1633,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { method: "POST", body, }); - const rerootedNodeIds = - editContext?.nodes - ?.map((value) => Number(value.nodeId)) - .filter((value) => Number.isFinite(value)) - .map((value) => Math.round(value)) ?? []; + const rerootedNodeIds = editContext?.nodes?.map((value) => value.nodeId) ?? []; return { nodeSourceStateUpdates: await this.fetchNodeRevisionUpdates(rerootedNodeIds), @@ -1583,16 +1641,9 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { } private async fetchNodeRevisionUpdates( - nodeIds: readonly number[], + nodeIds: readonly SpatialSkeletonId[], ): Promise { - const normalizedNodeIds = [ - ...new Set( - nodeIds - .map((value) => Number(value)) - .filter((value) => Number.isFinite(value)) - .map((value) => Math.round(value)), - ), - ].sort((a, b) => a - b); + const normalizedNodeIds = [...new Set(nodeIds)].sort(compareUint64Ids); if (normalizedNodeIds.length === 0) { return []; } @@ -1618,18 +1669,13 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { } async deleteNode( - nodeId: number, + nodeId: SpatialSkeletonId, options: CatmaidDeleteNodeOptions = {}, ): Promise { const { childNodeIds = [], editContext } = options; - const normalizedChildIds = [ - ...new Set( - childNodeIds - .map((value) => Number(value)) - .filter((value) => Number.isFinite(value)) - .map((value) => Math.round(value)), - ), - ].sort((a, b) => a - b); + const normalizedChildIds = [...new Set(childNodeIds)].sort( + compareUint64Ids, + ); const body = new URLSearchParams({ treenode_id: nodeId.toString(), }); @@ -1653,20 +1699,20 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { } async addNode( - skeletonId: number, + skeletonId: SpatialSkeletonId, x: number, y: number, z: number, - parentId?: number, + parentId?: SpatialSkeletonId, editContext?: CatmaidEditContext, ): Promise { const body = new URLSearchParams({ x: x.toString(), y: y.toString(), z: z.toString(), - parent_id: (parentId ?? -1).toString(), + parent_id: parentId === undefined ? "-1" : parentId.toString(), }); - if (Number.isSafeInteger(skeletonId) && skeletonId > 0) { + if (skeletonId > 0n) { body.append("skeleton_id", skeletonId.toString()); } appendCatmaidState(body, buildCatmaidAddNodeState(parentId, editContext)); @@ -1675,21 +1721,25 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { method: "POST", body: body, }); - const treenodeId = Number(res?.treenode_id); - const nextSkeletonId = Number(res?.skeleton_id); - if (!Number.isFinite(treenodeId)) { + let treenodeId: SpatialSkeletonId; + let nextSkeletonId: SpatialSkeletonId; + try { + treenodeId = parseCatmaidPositiveId(res?.treenode_id, "treenode_id"); + } catch { throw new Error( "CATMAID treenode/create did not return a valid treenode_id.", ); } - if (!Number.isFinite(nextSkeletonId)) { + try { + nextSkeletonId = parseCatmaidPositiveId(res?.skeleton_id, "skeleton_id"); + } catch { throw new Error( "CATMAID treenode/create did not return a valid skeleton_id.", ); } return { - nodeId: Math.round(treenodeId), - segmentId: Math.round(nextSkeletonId), + nodeId: treenodeId, + segmentId: nextSkeletonId, sourceState: makeCatmaidNodeSourceState( normalizeCatmaidRevisionToken(res?.edition_time), ), @@ -1700,22 +1750,17 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { } async insertNode( - skeletonId: number, + skeletonId: SpatialSkeletonId, x: number, y: number, z: number, - parentId: number, - childNodeIds: readonly number[], + parentId: SpatialSkeletonId, + childNodeIds: readonly SpatialSkeletonId[], editContext?: CatmaidEditContext, ): Promise { - const normalizedChildIds = [ - ...new Set( - childNodeIds - .map((value) => Number(value)) - .filter((value) => Number.isFinite(value)) - .map((value) => Math.round(value)), - ), - ].sort((a, b) => a - b); + const normalizedChildIds = [...new Set(childNodeIds)].sort( + compareUint64Ids, + ); if (normalizedChildIds.length === 0) { throw new Error( "CATMAID insert-node requires at least one child node to reattach.", @@ -1728,7 +1773,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { parent_id: parentId.toString(), child_id: normalizedChildIds[0].toString(), }); - if (Number.isSafeInteger(skeletonId) && skeletonId > 0) { + if (skeletonId > 0n) { body.append("skeleton_id", skeletonId.toString()); } appendScalarList(body, "takeover_child_ids", normalizedChildIds.slice(1)); @@ -1741,21 +1786,28 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { method: "POST", body, }); - const treenodeId = Number(response?.treenode_id); - const nextSkeletonId = Number(response?.skeleton_id); - if (!Number.isFinite(treenodeId)) { + let treenodeId: SpatialSkeletonId; + let nextSkeletonId: SpatialSkeletonId; + try { + treenodeId = parseCatmaidPositiveId(response?.treenode_id, "treenode_id"); + } catch { throw new Error( "CATMAID treenode/insert did not return a valid treenode_id.", ); } - if (!Number.isFinite(nextSkeletonId)) { + try { + nextSkeletonId = parseCatmaidPositiveId( + response?.skeleton_id, + "skeleton_id", + ); + } catch { throw new Error( "CATMAID treenode/insert did not return a valid skeleton_id.", ); } return { - nodeId: Math.round(treenodeId), - segmentId: Math.round(nextSkeletonId), + nodeId: treenodeId, + segmentId: nextSkeletonId, sourceState: makeCatmaidNodeSourceState( normalizeCatmaidRevisionToken(response?.edition_time), ), @@ -1769,7 +1821,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { } private async updateNodeLabel( - nodeId: number, + nodeId: SpatialSkeletonId, endpoint: "update" | "remove", body: URLSearchParams, ) { @@ -1807,7 +1859,10 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { ); } - private async replaceNodeLabels(nodeId: number, labels: readonly string[]) { + private async replaceNodeLabels( + nodeId: SpatialSkeletonId, + labels: readonly string[], + ) { const normalizedLabels = this.normalizeNodeLabels(labels); return this.updateNodeLabel( nodeId, @@ -1819,7 +1874,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { ); } - private async addNodeLabel(nodeId: number, label: string) { + private async addNodeLabel(nodeId: SpatialSkeletonId, label: string) { const normalizedLabel = label.trim(); if (normalizedLabel.length === 0) { throw new Error("Node label must not be empty."); @@ -1834,7 +1889,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { ); } - private async removeNodeLabel(nodeId: number, label: string) { + private async removeNodeLabel(nodeId: SpatialSkeletonId, label: string) { const normalizedLabel = label.trim(); if (normalizedLabel.length === 0) { throw new Error("Node label must not be empty."); @@ -1847,7 +1902,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { } async updateDescription( - nodeId: number, + nodeId: SpatialSkeletonId, description: string, options: CatmaidDescriptionUpdateOptions = {}, ): Promise { @@ -1867,7 +1922,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { } private async addTrueEndLabel( - nodeId: number, + nodeId: SpatialSkeletonId, ): Promise { const response = await this.addNodeLabel(nodeId, CATMAID_TRUE_END_LABEL); return getCatmaidSingleNodeRevisionResult( @@ -1876,7 +1931,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { } private async removeTrueEndLabel( - nodeId: number, + nodeId: SpatialSkeletonId, ): Promise { const response = await this.removeNodeLabel(nodeId, CATMAID_TRUE_END_LABEL); return getCatmaidSingleNodeRevisionResult( @@ -1885,7 +1940,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { } toggleTrueEnd( - nodeId: number, + nodeId: SpatialSkeletonId, nextIsTrueEnd: boolean, ): Promise { return nextIsTrueEnd @@ -1894,7 +1949,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { } async updateRadius( - nodeId: number, + nodeId: SpatialSkeletonId, radius: number, editContext?: CatmaidEditContext, ): Promise { @@ -1918,7 +1973,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { } async updateConfidence( - nodeId: number, + nodeId: SpatialSkeletonId, confidence: number, editContext?: CatmaidEditContext, ): Promise { @@ -1942,8 +1997,8 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { } async mergeSkeletons( - fromNodeId: number, - toNodeId: number, + fromNodeId: SpatialSkeletonId, + toNodeId: SpatialSkeletonId, editContext?: CatmaidEditContext, ): Promise { const body = new URLSearchParams({ @@ -1961,21 +2016,21 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { method: "POST", body, }); - const resultSkeletonId = Number(response?.result_skeleton_id); - const deletedSkeletonId = Number(response?.deleted_skeleton_id); return { - resultSegmentId: Number.isFinite(resultSkeletonId) - ? Math.round(resultSkeletonId) - : undefined, - deletedSegmentId: Number.isFinite(deletedSkeletonId) - ? Math.round(deletedSkeletonId) - : undefined, + resultSegmentId: parseOptionalCatmaidPositiveId( + response?.result_skeleton_id, + "result skeleton id", + ), + deletedSegmentId: parseOptionalCatmaidPositiveId( + response?.deleted_skeleton_id, + "deleted skeleton id", + ), directionAdjusted: Boolean(response?.stable_annotation_swap), }; } async splitSkeleton( - nodeId: number, + nodeId: SpatialSkeletonId, editContext?: CatmaidEditContext, ): Promise { const body = new URLSearchParams({ @@ -1991,15 +2046,15 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { method: "POST", body, }); - const existingSkeletonId = Number(response?.existing_skeleton_id); - const newSkeletonId = Number(response?.new_skeleton_id); return { - existingSegmentId: Number.isFinite(existingSkeletonId) - ? Math.round(existingSkeletonId) - : undefined, - newSegmentId: Number.isFinite(newSkeletonId) - ? Math.round(newSkeletonId) - : undefined, + existingSegmentId: parseOptionalCatmaidPositiveId( + response?.existing_skeleton_id, + "existing skeleton id", + ), + newSegmentId: parseOptionalCatmaidPositiveId( + response?.new_skeleton_id, + "new skeleton id", + ), }; } } diff --git a/src/datasource/catmaid/backend.ts b/src/datasource/catmaid/backend.ts index 8a3d52172f..9d0fd0773b 100644 --- a/src/datasource/catmaid/backend.ts +++ b/src/datasource/catmaid/backend.ts @@ -114,7 +114,7 @@ export class CatmaidSkeletonSourceBackend extends WithParameters( } async download(chunk: SkeletonChunk, signal: AbortSignal) { - const skeletonId = Number(chunk.objectId); + const skeletonId = chunk.objectId; const nodes = await this.client.getSkeleton(skeletonId, { signal }); const packed = packCatmaidSkeletonNodes(nodes); diff --git a/src/datasource/catmaid/frontend.ts b/src/datasource/catmaid/frontend.ts index 7be9cd4f9d..ac0d4c74c2 100644 --- a/src/datasource/catmaid/frontend.ts +++ b/src/datasource/catmaid/frontend.ts @@ -46,6 +46,7 @@ import { normalizeInlineSegmentPropertyMap, } from "#src/segmentation_display_state/property_map.js"; import type { + SpatialSkeletonId, SpatialSkeletonConfidenceConfiguration, SpatialSkeletonGridCellIndex, SpatiallyIndexedSkeletonMetadata, @@ -154,13 +155,13 @@ export class CatmaidSpatiallyIndexedSkeletonSource extends WithParameters( } getSkeleton( - skeletonId: number, + skeletonId: SpatialSkeletonId, options?: { signal?: AbortSignal }, ): Promise { return this.client.getSkeleton(skeletonId, options); } - listSkeletons(): Promise { + listSkeletons(): Promise { return this.client.listSkeletons(); } @@ -188,7 +189,7 @@ export class CatmaidSpatiallyIndexedSkeletonSource extends WithParameters( ); } - getSkeletonRootNode(skeletonId: number) { + getSkeletonRootNode(skeletonId: SpatialSkeletonId) { return this.client.getSkeletonRootNode(skeletonId); } } @@ -469,7 +470,7 @@ export class CatmaidDataSourceProvider implements DataSourceProvider { // Create SegmentPropertyMap const ids = new BigUint64Array(skeletonIds.length); for (let i = 0; i < skeletonIds.length; ++i) { - ids[i] = BigInt(skeletonIds[i]); + ids[i] = skeletonIds[i]; } const propertyMap = new SegmentPropertyMap({ diff --git a/src/datasource/catmaid/skeleton_packing.spec.ts b/src/datasource/catmaid/skeleton_packing.spec.ts index eda5658f88..85a0688495 100644 --- a/src/datasource/catmaid/skeleton_packing.spec.ts +++ b/src/datasource/catmaid/skeleton_packing.spec.ts @@ -7,24 +7,24 @@ describe("datasource/catmaid/skeleton_packing", () => { it("packs vertex, segment, index, and pick-node data", () => { const nodes: SpatiallyIndexedSkeletonNodeBase[] = [ { - nodeId: 1, + nodeId: 1n, parentNodeId: undefined, position: new Float32Array([1, 2, 3]), - segmentId: 10, + segmentId: 10n, sourceState: { revisionToken: "node-1" }, }, { - nodeId: 2, - parentNodeId: 1, + nodeId: 2n, + parentNodeId: 1n, position: new Float32Array([4, 5, 6]), - segmentId: 10, + segmentId: 10n, sourceState: { revisionToken: "node-2" }, }, { - nodeId: 3, - parentNodeId: 99, + nodeId: 3n, + parentNodeId: 99n, position: new Float32Array([7, 8, 9]), - segmentId: 11, + segmentId: 11n, }, ]; @@ -33,9 +33,9 @@ describe("datasource/catmaid/skeleton_packing", () => { expect(packed.vertexPositions).toEqual( Float32Array.of(1, 2, 3, 4, 5, 6, 7, 8, 9), ); - expect(packed.segmentIds).toEqual(Uint32Array.of(10, 10, 11)); + expect(packed.segmentIds).toEqual(BigUint64Array.of(10n, 10n, 11n)); expect(packed.indices).toEqual(Uint32Array.of(1, 0)); - expect(packed.nodeIds).toEqual(Int32Array.of(1, 2, 3)); + expect(packed.nodeIds).toEqual(BigUint64Array.of(1n, 2n, 3n)); expect(packed.sourceStates).toEqual([ { revisionToken: "node-1" }, { revisionToken: "node-2" }, @@ -44,10 +44,10 @@ describe("datasource/catmaid/skeleton_packing", () => { }); it("preserves large segment ids exactly", () => { - const largeSegmentId = 16_777_217; + const largeSegmentId = 9_007_199_254_740_993n; const nodes: SpatiallyIndexedSkeletonNodeBase[] = [ { - nodeId: 1, + nodeId: 1n, parentNodeId: undefined, position: new Float32Array([1, 2, 3]), segmentId: largeSegmentId, @@ -56,6 +56,6 @@ describe("datasource/catmaid/skeleton_packing", () => { const packed = packCatmaidSkeletonNodes(nodes); - expect(packed.segmentIds).toEqual(Uint32Array.of(largeSegmentId)); + expect(packed.segmentIds).toEqual(BigUint64Array.of(largeSegmentId)); }); }); diff --git a/src/datasource/catmaid/skeleton_packing.ts b/src/datasource/catmaid/skeleton_packing.ts index 0403f818f9..6c182b1218 100644 --- a/src/datasource/catmaid/skeleton_packing.ts +++ b/src/datasource/catmaid/skeleton_packing.ts @@ -21,9 +21,9 @@ import type { interface PackedCatmaidSkeletonData { vertexPositions: Float32Array; - segmentIds: Uint32Array; + segmentIds: BigUint64Array; indices: Uint32Array; - nodeIds: Int32Array; + nodeIds: BigUint64Array; sourceStates: Array; } @@ -32,13 +32,13 @@ export function packCatmaidSkeletonNodes( ): PackedCatmaidSkeletonData { const numVertices = nodes.length; const vertexPositions = new Float32Array(numVertices * 3); - const segmentIds = new Uint32Array(numVertices); - const nodeIds = new Int32Array(numVertices); + const segmentIds = new BigUint64Array(numVertices); + const nodeIds = new BigUint64Array(numVertices); const sourceStates = new Array( numVertices, ); const indices: number[] = []; - const nodeMap = new Map(); + const nodeMap = new Map(); for (let i = 0; i < numVertices; ++i) { const node = nodes[i]; diff --git a/src/datasource/catmaid/spatial_skeleton_commands.ts b/src/datasource/catmaid/spatial_skeleton_commands.ts index 688c882a08..bb335739f9 100644 --- a/src/datasource/catmaid/spatial_skeleton_commands.ts +++ b/src/datasource/catmaid/spatial_skeleton_commands.ts @@ -54,6 +54,7 @@ import { type SpatialSkeletonAction, } from "#src/skeleton/actions.js"; import type { + SpatialSkeletonId, SpatiallyIndexedSkeletonNode, SpatialSkeletonSourceState, SpatialSkeletonVector, @@ -70,18 +71,19 @@ import { import type { SpatiallyIndexedSkeletonLayer } from "#src/skeleton/frontend.js"; import { getEditableSpatiallyIndexedSkeletonSource } from "#src/skeleton/spatial_skeleton_manager.js"; import { StatusMessage } from "#src/status.js"; +import { parsePositiveUint64Id } from "#src/util/bigint.js"; import { formatErrorMessage } from "#src/util/error.js"; interface CatmaidSpatialSkeletonAddNodeCommandOptions { - skeletonId: number; - parentNodeId: number | undefined; + skeletonId: SpatialSkeletonId; + parentNodeId: SpatialSkeletonId | undefined; positionInModelSpace: SpatialSkeletonVector; } interface CatmaidSpatialSkeletonInsertNodeCommandOptions { - skeletonId: number; - parentNodeId: number; - childNodeIds: readonly number[]; + skeletonId: SpatialSkeletonId; + parentNodeId: SpatialSkeletonId; + childNodeIds: readonly SpatialSkeletonId[]; positionInModelSpace: SpatialSkeletonVector; } @@ -111,8 +113,8 @@ interface CatmaidSpatialSkeletonNodeConfidenceCommandOptions { } interface CatmaidSpatialSkeletonMergeEndpoint { - nodeId: number; - segmentId: number; + nodeId: SpatialSkeletonId; + segmentId: SpatialSkeletonId; sourceState?: SpatialSkeletonSourceState; } @@ -169,6 +171,60 @@ function isOptionalFiniteNumber(value: number | undefined) { return value === undefined || isFiniteNumber(value); } +function isSpatialSkeletonIdValue(value: unknown) { + try { + parsePositiveUint64Id(value, "spatial skeleton id"); + return true; + } catch { + return false; + } +} + +function isOptionalSpatialSkeletonIdValue(value: unknown) { + return value === undefined || isSpatialSkeletonIdValue(value); +} + +function isCatmaidCommandTargetSkeletonIdValue(value: unknown) { + if (value === 0n || value === 0 || value === "0") { + return true; + } + return isSpatialSkeletonIdValue(value); +} + +function parseCatmaidCommandTargetSkeletonId(value: unknown) { + if (value === 0n || value === 0 || value === "0") { + return 0n; + } + return parsePositiveUint64Id(value, "CATMAID skeleton id"); +} + +function normalizeSpatialSkeletonIdValue(value: unknown, label: string) { + return parsePositiveUint64Id(value, label); +} + +function normalizeOptionalSpatialSkeletonIdValue( + value: unknown, + label: string, +) { + return value === undefined + ? undefined + : normalizeSpatialSkeletonIdValue(value, label); +} + +function normalizeSpatiallyIndexedSkeletonNodePayload( + value: SpatiallyIndexedSkeletonNode, +): SpatiallyIndexedSkeletonNode { + return { + ...value, + nodeId: normalizeSpatialSkeletonIdValue(value.nodeId, "node id"), + segmentId: normalizeSpatialSkeletonIdValue(value.segmentId, "segment id"), + parentNodeId: normalizeOptionalSpatialSkeletonIdValue( + value.parentNodeId, + "parent node id", + ), + }; +} + function isSpatialSkeletonVector( value: object | undefined, ): value is SpatialSkeletonVector { @@ -186,20 +242,20 @@ function isSpatiallyIndexedSkeletonNodePayload( ): value is SpatiallyIndexedSkeletonNode { if (value === undefined) return false; const candidate = value as { - nodeId?: number; - segmentId?: number; + nodeId?: unknown; + segmentId?: unknown; position?: object; - parentNodeId?: number; + parentNodeId?: unknown; radius?: number; confidence?: number; description?: string; isTrueEnd?: boolean; }; return ( - isFiniteNumber(candidate.nodeId) && - isFiniteNumber(candidate.segmentId) && + isSpatialSkeletonIdValue(candidate.nodeId) && + isSpatialSkeletonIdValue(candidate.segmentId) && isSpatialSkeletonVector(candidate.position) && - isOptionalFiniteNumber(candidate.parentNodeId) && + isOptionalSpatialSkeletonIdValue(candidate.parentNodeId) && isOptionalFiniteNumber(candidate.radius) && isOptionalFiniteNumber(candidate.confidence) && (candidate.description === undefined || @@ -214,11 +270,12 @@ function isCatmaidMergeEndpoint( ): value is CatmaidSpatialSkeletonMergeEndpoint { if (value === undefined) return false; const candidate = value as { - nodeId?: number; - segmentId?: number; + nodeId?: unknown; + segmentId?: unknown; }; return ( - isFiniteNumber(candidate.nodeId) && isFiniteNumber(candidate.segmentId) + isSpatialSkeletonIdValue(candidate.nodeId) && + isSpatialSkeletonIdValue(candidate.segmentId) ); } @@ -234,49 +291,72 @@ function requireCatmaidCommandPayload( } function requireCatmaidAddNodeCommandOptions(payload: object) { - return requireCatmaidCommandPayload( + const options = requireCatmaidCommandPayload( payload, "add-node", (candidate): candidate is CatmaidSpatialSkeletonAddNodeCommandOptions => { const options = candidate as { - skeletonId?: number; - parentNodeId?: number; + skeletonId?: unknown; + parentNodeId?: unknown; positionInModelSpace?: object; }; return ( - isFiniteNumber(options.skeletonId) && - isOptionalFiniteNumber(options.parentNodeId) && + isCatmaidCommandTargetSkeletonIdValue(options.skeletonId) && + isOptionalSpatialSkeletonIdValue(options.parentNodeId) && isSpatialSkeletonVector(options.positionInModelSpace) ); }, ); + return { + ...options, + skeletonId: parseCatmaidCommandTargetSkeletonId(options.skeletonId), + parentNodeId: normalizeOptionalSpatialSkeletonIdValue( + options.parentNodeId, + "parent node id", + ), + }; } function requireCatmaidInsertNodeCommandOptions(payload: object) { - return requireCatmaidCommandPayload( + const options = requireCatmaidCommandPayload( payload, "insert-node", ( candidate, ): candidate is CatmaidSpatialSkeletonInsertNodeCommandOptions => { const options = candidate as { - skeletonId?: number; - parentNodeId?: number; - childNodeIds?: readonly number[]; + skeletonId?: unknown; + parentNodeId?: unknown; + childNodeIds?: readonly unknown[]; positionInModelSpace?: object; }; return ( - isFiniteNumber(options.skeletonId) && - isFiniteNumber(options.parentNodeId) && - areFiniteNumbers(options.childNodeIds) && + isSpatialSkeletonIdValue(options.skeletonId) && + isSpatialSkeletonIdValue(options.parentNodeId) && + options.childNodeIds !== undefined && + options.childNodeIds.every(isSpatialSkeletonIdValue) && isSpatialSkeletonVector(options.positionInModelSpace) ); }, ); + return { + ...options, + skeletonId: normalizeSpatialSkeletonIdValue( + options.skeletonId, + "skeleton id", + ), + parentNodeId: normalizeSpatialSkeletonIdValue( + options.parentNodeId, + "parent node id", + ), + childNodeIds: options.childNodeIds.map((childNodeId) => + normalizeSpatialSkeletonIdValue(childNodeId, "child node id"), + ), + }; } function requireCatmaidMoveNodeCommandOptions(payload: object) { - return requireCatmaidCommandPayload( + const options = requireCatmaidCommandPayload( payload, "move-node", (candidate): candidate is CatmaidSpatialSkeletonMoveNodeCommandOptions => { @@ -290,18 +370,23 @@ function requireCatmaidMoveNodeCommandOptions(payload: object) { ); }, ); + return { + ...options, + node: normalizeSpatiallyIndexedSkeletonNodePayload(options.node), + }; } function requireCatmaidDeleteNodeCommandPayload(payload: object) { - return requireCatmaidCommandPayload( + const node = requireCatmaidCommandPayload( payload, "delete-node", isSpatiallyIndexedSkeletonNodePayload, ); + return normalizeSpatiallyIndexedSkeletonNodePayload(node); } function requireCatmaidNodeDescriptionCommandOptions(payload: object) { - return requireCatmaidCommandPayload( + const options = requireCatmaidCommandPayload( payload, "node-description", ( @@ -318,10 +403,14 @@ function requireCatmaidNodeDescriptionCommandOptions(payload: object) { ); }, ); + return { + ...options, + node: normalizeSpatiallyIndexedSkeletonNodePayload(options.node), + }; } function requireCatmaidNodeTrueEndCommandOptions(payload: object) { - return requireCatmaidCommandPayload( + const options = requireCatmaidCommandPayload( payload, "node-true-end", ( @@ -337,10 +426,14 @@ function requireCatmaidNodeTrueEndCommandOptions(payload: object) { ); }, ); + return { + ...options, + node: normalizeSpatiallyIndexedSkeletonNodePayload(options.node), + }; } function requireCatmaidNodeRadiusCommandOptions(payload: object) { - return requireCatmaidCommandPayload( + const options = requireCatmaidCommandPayload( payload, "node-radius", ( @@ -356,10 +449,14 @@ function requireCatmaidNodeRadiusCommandOptions(payload: object) { ); }, ); + return { + ...options, + node: normalizeSpatiallyIndexedSkeletonNodePayload(options.node), + }; } function requireCatmaidNodeConfidenceCommandOptions(payload: object) { - return requireCatmaidCommandPayload( + const options = requireCatmaidCommandPayload( payload, "node-confidence", ( @@ -375,10 +472,14 @@ function requireCatmaidNodeConfidenceCommandOptions(payload: object) { ); }, ); + return { + ...options, + node: normalizeSpatiallyIndexedSkeletonNodePayload(options.node), + }; } function requireCatmaidRerootCommandPayload(payload: object) { - return requireCatmaidCommandPayload( + const node = requireCatmaidCommandPayload( payload, "reroot", ( @@ -388,21 +489,29 @@ function requireCatmaidRerootCommandPayload(payload: object) { "nodeId" | "segmentId" | "parentNodeId" > => { const node = candidate as { - nodeId?: number; - segmentId?: number; - parentNodeId?: number; + nodeId?: unknown; + segmentId?: unknown; + parentNodeId?: unknown; }; return ( - isFiniteNumber(node.nodeId) && - isFiniteNumber(node.segmentId) && - isOptionalFiniteNumber(node.parentNodeId) + isSpatialSkeletonIdValue(node.nodeId) && + isSpatialSkeletonIdValue(node.segmentId) && + isOptionalSpatialSkeletonIdValue(node.parentNodeId) ); }, ); + return { + nodeId: normalizeSpatialSkeletonIdValue(node.nodeId, "node id"), + segmentId: normalizeSpatialSkeletonIdValue(node.segmentId, "segment id"), + parentNodeId: normalizeOptionalSpatialSkeletonIdValue( + node.parentNodeId, + "parent node id", + ), + }; } function requireCatmaidSplitCommandPayload(payload: object) { - return requireCatmaidCommandPayload( + const node = requireCatmaidCommandPayload( payload, "split", ( @@ -412,16 +521,23 @@ function requireCatmaidSplitCommandPayload(payload: object) { "nodeId" | "segmentId" > => { const node = candidate as { - nodeId?: number; - segmentId?: number; + nodeId?: unknown; + segmentId?: unknown; }; - return isFiniteNumber(node.nodeId) && isFiniteNumber(node.segmentId); + return ( + isSpatialSkeletonIdValue(node.nodeId) && + isSpatialSkeletonIdValue(node.segmentId) + ); }, ); + return { + nodeId: normalizeSpatialSkeletonIdValue(node.nodeId, "node id"), + segmentId: normalizeSpatialSkeletonIdValue(node.segmentId, "segment id"), + }; } function requireCatmaidMergeCommandPayload(payload: object) { - return requireCatmaidCommandPayload( + const options = requireCatmaidCommandPayload( payload, "merge", (candidate): candidate is CatmaidSpatialSkeletonMergeCommandPayload => { @@ -435,6 +551,30 @@ function requireCatmaidMergeCommandPayload(payload: object) { ); }, ); + return { + firstNode: { + ...options.firstNode, + nodeId: normalizeSpatialSkeletonIdValue( + options.firstNode.nodeId, + "first node id", + ), + segmentId: normalizeSpatialSkeletonIdValue( + options.firstNode.segmentId, + "first segment id", + ), + }, + secondNode: { + ...options.secondNode, + nodeId: normalizeSpatialSkeletonIdValue( + options.secondNode.nodeId, + "second node id", + ), + segmentId: normalizeSpatialSkeletonIdValue( + options.secondNode.segmentId, + "second segment id", + ), + }, + }; } function toCatmaidPositionInModelSpace( @@ -490,53 +630,41 @@ function getEditableSkeletonSourceForLayer(layer: SegmentationUserLayer): { function ensureVisibleSegment( layer: SegmentationUserLayer, - segmentId: number | undefined, + segmentId: SpatialSkeletonId | undefined, ) { - if ( - segmentId === undefined || - !Number.isSafeInteger(Math.round(Number(segmentId))) || - Math.round(Number(segmentId)) <= 0 - ) { + if (segmentId === undefined || segmentId <= 0n) { return; } addSegmentToVisibleSets( layer.displayState.segmentationGroupState.value, - BigInt(Math.round(Number(segmentId))), + segmentId, ); } function selectSegment( layer: SegmentationUserLayer, - segmentId: number | undefined, + segmentId: SpatialSkeletonId | undefined, pin: boolean, ) { - if ( - segmentId === undefined || - !Number.isSafeInteger(Math.round(Number(segmentId))) || - Math.round(Number(segmentId)) <= 0 - ) { + if (segmentId === undefined || segmentId <= 0n) { return; } - layer.selectSegment(BigInt(Math.round(Number(segmentId))), pin); + layer.selectSegment(segmentId, pin); } function removeVisibleSegment( layer: SegmentationUserLayer, - segmentId: number | undefined, + segmentId: SpatialSkeletonId | undefined, options: { deselect?: boolean; } = {}, ) { - if ( - segmentId === undefined || - !Number.isSafeInteger(Math.round(Number(segmentId))) || - Math.round(Number(segmentId)) <= 0 - ) { + if (segmentId === undefined || segmentId <= 0n) { return; } removeSegmentFromVisibleSets( layer.displayState.segmentationGroupState.value, - BigInt(Math.round(Number(segmentId))), + segmentId, options, ); } @@ -552,16 +680,16 @@ interface ResolvedSpatialSkeletonEditNode { } interface ResolvedSpatialSkeletonEditNodeContext { - currentNodeId: number; - segmentId: number; + currentNodeId: SpatialSkeletonId; + segmentId: SpatialSkeletonId; cachedNode: SpatiallyIndexedSkeletonNode | undefined; skeletonLayer: SpatiallyIndexedSkeletonLayer; } function getResolvedNodeContextForEdit( layer: SegmentationUserLayer, - stableNodeId: number, - stableSegmentId: number | undefined, + stableNodeId: SpatialSkeletonId, + stableSegmentId: SpatialSkeletonId | undefined, ): ResolvedSpatialSkeletonEditNodeContext { const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; const currentNodeId = commandMappings.resolveNodeId(stableNodeId); @@ -589,8 +717,8 @@ function getResolvedNodeContextForEdit( async function getResolvedNodeForEdit( layer: SegmentationUserLayer, - stableNodeId: number, - stableSegmentId: number | undefined, + stableNodeId: SpatialSkeletonId, + stableSegmentId: SpatialSkeletonId | undefined, ): Promise { const { currentNodeId, @@ -620,13 +748,11 @@ async function getResolvedNodeForEdit( async function refreshTopologySegments( layer: SegmentationUserLayer, - segmentIds: readonly number[], + segmentIds: readonly SpatialSkeletonId[], ) { const normalizedSegmentIds = [ - ...new Set( - segmentIds.filter((value) => Number.isSafeInteger(Math.round(value))), - ), - ].map((value) => Math.round(value)); + ...new Set(segmentIds.filter((value) => value > 0n)), + ]; if (normalizedSegmentIds.length === 0) { return; } @@ -647,7 +773,7 @@ function applyAddNodeToCache( layer: SegmentationUserLayer, skeletonLayer: SpatiallyIndexedSkeletonLayer, committedNode: CatmaidSpatialSkeletonAddNodeResult, - parentNodeId: number | undefined, + parentNodeId: SpatialSkeletonId | undefined, positionInModelSpace: Float32Array, options: { focusSelection: boolean; @@ -843,13 +969,13 @@ async function restoreNodeAttributes( class AddNodeCommand implements SpatialSkeletonCommand { readonly label = "Add node"; - private stableNodeId: number | undefined; - private stableSegmentId: number | undefined; + private stableNodeId: SpatialSkeletonId | undefined; + private stableSegmentId: SpatialSkeletonId | undefined; constructor( private layer: SegmentationUserLayer, - private stableParentNodeId: number | undefined, - private targetSkeletonId: number, + private stableParentNodeId: SpatialSkeletonId | undefined, + private targetSkeletonId: SpatialSkeletonId, private positionInModelSpace: Float32Array, private editOperations: CatmaidSpatialSkeletonEditOperations, ) {} @@ -969,14 +1095,14 @@ class AddNodeCommand implements SpatialSkeletonCommand { class InsertNodeCommand implements SpatialSkeletonCommand { readonly label = "Insert node"; - private stableNodeId: number | undefined; - private stableSegmentId: number | undefined; + private stableNodeId: SpatialSkeletonId | undefined; + private stableSegmentId: SpatialSkeletonId | undefined; constructor( private layer: SegmentationUserLayer, - private stableParentNodeId: number, - private stableChildNodeIds: readonly number[], - private targetSkeletonId: number, + private stableParentNodeId: SpatialSkeletonId, + private stableChildNodeIds: readonly SpatialSkeletonId[], + private targetSkeletonId: SpatialSkeletonId, private positionInModelSpace: Float32Array, private editOperations: CatmaidSpatialSkeletonEditOperations, ) {} @@ -1131,8 +1257,8 @@ class MoveNodeCommand implements SpatialSkeletonCommand { constructor( private layer: SegmentationUserLayer, - private stableNodeId: number, - private stableSegmentId: number | undefined, + private stableNodeId: SpatialSkeletonId, + private stableSegmentId: SpatialSkeletonId | undefined, private beforePositionInModelSpace: Float32Array, private afterPositionInModelSpace: Float32Array, private editOperations: CatmaidSpatialSkeletonEditOperations, @@ -1185,10 +1311,10 @@ class MoveNodeCommand implements SpatialSkeletonCommand { class DeleteNodeCommand implements SpatialSkeletonCommand { readonly label = "Delete node"; - private stableDeletedNodeId: number; - private stableSegmentId: number | undefined; - private stableParentNodeId: number | undefined; - private stableChildNodeIds: number[]; + private stableDeletedNodeId: SpatialSkeletonId; + private stableSegmentId: SpatialSkeletonId | undefined; + private stableParentNodeId: SpatialSkeletonId | undefined; + private stableChildNodeIds: SpatialSkeletonId[]; private deletedSnapshot: SpatiallyIndexedSkeletonNode; constructor( @@ -1269,7 +1395,7 @@ class DeleteNodeCommand implements SpatialSkeletonCommand { | CatmaidSpatialSkeletonInsertNodeResult; if (currentChildNodes.length === 0) { createResult = await this.editOperations.commitAddNode({ - segmentId: currentParentNode?.segmentId ?? 0, + segmentId: currentParentNode?.segmentId ?? 0n, position: this.deletedSnapshot.position, parentNode: currentParentNode, }); @@ -1376,8 +1502,8 @@ class NodeDescriptionCommand implements SpatialSkeletonCommand { constructor( private layer: SegmentationUserLayer, - private stableNodeId: number, - private stableSegmentId: number | undefined, + private stableNodeId: SpatialSkeletonId, + private stableSegmentId: SpatialSkeletonId | undefined, private beforeDescription: string | undefined, private afterDescription: string | undefined, private editOperations: CatmaidSpatialSkeletonEditOperations, @@ -1450,8 +1576,8 @@ class NodeTrueEndCommand implements SpatialSkeletonCommand { constructor( private layer: SegmentationUserLayer, - private stableNodeId: number, - private stableSegmentId: number | undefined, + private stableNodeId: SpatialSkeletonId, + private stableSegmentId: SpatialSkeletonId | undefined, private beforeIsTrueEnd: boolean, private afterIsTrueEnd: boolean, private editOperations: CatmaidSpatialSkeletonEditOperations, @@ -1514,8 +1640,8 @@ class NodeRadiusCommand implements SpatialSkeletonCommand { constructor( private layer: SegmentationUserLayer, - private stableNodeId: number, - private stableSegmentId: number | undefined, + private stableNodeId: SpatialSkeletonId, + private stableSegmentId: SpatialSkeletonId | undefined, private beforeRadius: number, private afterRadius: number, private editOperations: CatmaidSpatialSkeletonEditOperations, @@ -1567,8 +1693,8 @@ class NodeConfidenceCommand implements SpatialSkeletonCommand { constructor( private layer: SegmentationUserLayer, - private stableNodeId: number, - private stableSegmentId: number | undefined, + private stableNodeId: SpatialSkeletonId, + private stableSegmentId: SpatialSkeletonId | undefined, private beforeConfidence: number, private afterConfidence: number, private editOperations: CatmaidSpatialSkeletonEditOperations, @@ -1629,13 +1755,16 @@ class RerootCommand implements SpatialSkeletonCommand { constructor( private layer: SegmentationUserLayer, - private stableNodeId: number, - private stableSegmentId: number | undefined, - private stablePreviousRootNodeId: number, + private stableNodeId: SpatialSkeletonId, + private stableSegmentId: SpatialSkeletonId | undefined, + private stablePreviousRootNodeId: SpatialSkeletonId, private editOperations: CatmaidSpatialSkeletonEditOperations, ) {} - private async rerootAt(stableTargetNodeId: number, statusPrefix: string) { + private async rerootAt( + stableTargetNodeId: SpatialSkeletonId, + statusPrefix: string, + ) { const resolvedNode = await getResolvedNodeForEdit( this.layer, stableTargetNodeId, @@ -1690,13 +1819,13 @@ class RerootCommand implements SpatialSkeletonCommand { class SplitCommand implements SpatialSkeletonCommand { readonly label = "Split skeleton"; - private stableNewSegmentId: number | undefined; + private stableNewSegmentId: SpatialSkeletonId | undefined; constructor( private layer: SegmentationUserLayer, - private stableNodeId: number, - private stableSegmentId: number | undefined, - private stableFormerParentNodeId: number | undefined, + private stableNodeId: SpatialSkeletonId, + private stableSegmentId: SpatialSkeletonId | undefined, + private stableFormerParentNodeId: SpatialSkeletonId | undefined, private editOperations: CatmaidSpatialSkeletonEditOperations, ) {} @@ -1806,9 +1935,7 @@ class SplitCommand implements SpatialSkeletonCommand { ensureVisibleSegment(this.layer, resultSkeletonId); if (deletedSkeletonId !== resultSkeletonId) { removeVisibleSegment(this.layer, deletedSkeletonId, { deselect: true }); - this.layer.displayState.segmentStatedColors.value.delete( - BigInt(deletedSkeletonId), - ); + this.layer.displayState.segmentStatedColors.value.delete(deletedSkeletonId); splitNode.skeletonLayer.suppressBrowseSegment(deletedSkeletonId); } this.layer.selectSpatialSkeletonNode( @@ -1842,17 +1969,17 @@ class SplitCommand implements SpatialSkeletonCommand { class MergeCommand implements SpatialSkeletonCommand { readonly label = "Merge skeletons"; - private stableResultSegmentId: number | undefined; - private stableDeletedSegmentId: number | undefined; - private stableAttachedNodeId: number | undefined; - private stableAttachedRootNodeId: number | undefined; + private stableResultSegmentId: SpatialSkeletonId | undefined; + private stableDeletedSegmentId: SpatialSkeletonId | undefined; + private stableAttachedNodeId: SpatialSkeletonId | undefined; + private stableAttachedRootNodeId: SpatialSkeletonId | undefined; constructor( private layer: SegmentationUserLayer, - private stableFirstNodeId: number, - private stableFirstSegmentId: number | undefined, - private stableSecondNodeId: number, - private stableSecondSegmentId: number | undefined, + private stableFirstNodeId: SpatialSkeletonId, + private stableFirstSegmentId: SpatialSkeletonId | undefined, + private stableSecondNodeId: SpatialSkeletonId, + private stableSecondSegmentId: SpatialSkeletonId | undefined, private editOperations: CatmaidSpatialSkeletonEditOperations, ) {} @@ -1928,9 +2055,7 @@ class MergeCommand implements SpatialSkeletonCommand { segmentId: resultSkeletonId, }, ); - this.layer.displayState.segmentStatedColors.value.delete( - BigInt(deletedSkeletonId), - ); + this.layer.displayState.segmentStatedColors.value.delete(deletedSkeletonId); if (deletedSkeletonId !== resultSkeletonId) { firstNode.skeletonLayer.suppressBrowseSegment(deletedSkeletonId); } diff --git a/src/datasource/catmaid/spatial_skeleton_edit_api.ts b/src/datasource/catmaid/spatial_skeleton_edit_api.ts index b83b0e3eab..e9bc7e199a 100644 --- a/src/datasource/catmaid/spatial_skeleton_edit_api.ts +++ b/src/datasource/catmaid/spatial_skeleton_edit_api.ts @@ -15,6 +15,7 @@ */ import type { + SpatialSkeletonId, SpatiallyIndexedSkeletonNode, SpatialSkeletonSourceState, SpatialSkeletonVector, @@ -22,7 +23,7 @@ import type { // CATMAID owns these payloads; the generic skeleton API only promises named edit operations. export interface CatmaidSpatialSkeletonNodeSourceStateUpdate { - nodeId: number; + nodeId: SpatialSkeletonId; sourceState: SpatialSkeletonSourceState; } @@ -31,21 +32,21 @@ export interface CatmaidSpatialSkeletonEditResult { } export interface CatmaidSpatialSkeletonAddNodeRequest { - segmentId: number; + segmentId: SpatialSkeletonId; position: SpatialSkeletonVector; parentNode?: SpatiallyIndexedSkeletonNode; } export interface CatmaidSpatialSkeletonAddNodeResult extends CatmaidSpatialSkeletonEditResult { - nodeId: number; - segmentId: number; + nodeId: SpatialSkeletonId; + segmentId: SpatialSkeletonId; sourceState?: SpatialSkeletonSourceState; parentSourceState?: SpatialSkeletonSourceState; } export interface CatmaidSpatialSkeletonInsertNodeRequest { - segmentId: number; + segmentId: SpatialSkeletonId; position: SpatialSkeletonVector; parentNode: SpatiallyIndexedSkeletonNode; childNodes: readonly SpatiallyIndexedSkeletonNode[]; @@ -80,8 +81,8 @@ export interface CatmaidSpatialSkeletonSplitRequest { export interface CatmaidSpatialSkeletonSplitResult extends CatmaidSpatialSkeletonEditResult { - existingSegmentId: number | undefined; - newSegmentId: number | undefined; + existingSegmentId: SpatialSkeletonId | undefined; + newSegmentId: SpatialSkeletonId | undefined; } export interface CatmaidSpatialSkeletonMergeRequest { @@ -91,8 +92,8 @@ export interface CatmaidSpatialSkeletonMergeRequest { export interface CatmaidSpatialSkeletonMergeResult extends CatmaidSpatialSkeletonEditResult { - resultSegmentId: number | undefined; - deletedSegmentId: number | undefined; + resultSegmentId: SpatialSkeletonId | undefined; + deletedSegmentId: SpatialSkeletonId | undefined; directionAdjusted: boolean; } diff --git a/src/layer/index.ts b/src/layer/index.ts index 136f8b5a1e..09ccbb7b4b 100644 --- a/src/layer/index.ts +++ b/src/layer/index.ts @@ -1146,8 +1146,8 @@ export class LayerManager extends RefCounted { } export interface PickedSpatialSkeletonState { - nodeId?: number; - segmentId?: number; + nodeId?: bigint; + segmentId?: bigint; position?: Float32Array; sourceState?: SpatialSkeletonSourceState; } diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 124942708b..7f0675a643 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -106,6 +106,10 @@ import type { import { SegmentationGraphSourceTab } from "#src/segmentation_graph/source.js"; import { SharedDisjointUint64Sets } from "#src/shared_disjoint_sets.js"; import { SharedWatchableValue } from "#src/shared_watchable_value.js"; +import { + parsePositiveUint64Id, + stringifySpatialSkeletonId, +} from "#src/util/bigint.js"; import { DEFAULT_SPATIAL_SKELETON_EDIT_ACTIONS, getSpatialSkeletonActionSupportLabel, @@ -113,6 +117,7 @@ import { type SpatialSkeletonAction, } from "#src/skeleton/actions.js"; import type { + SpatialSkeletonId, SpatiallyIndexedSkeletonNode, SpatialSkeletonSourceState, } from "#src/skeleton/api.js"; @@ -261,11 +266,11 @@ function formatSpatialSkeletonEditableNumber( function getSpatialSkeletonSegmentChipColors( displayState: SegmentationDisplayState | undefined | null, - segmentId: number, + segmentId: SpatialSkeletonId, ) { const color = getBaseObjectColor( displayState, - BigInt(segmentId), + segmentId, new Float32Array(4), ); const r = Math.round(color[0] * 255); @@ -281,9 +286,9 @@ function getSpatialSkeletonSegmentChipColors( function bindSpatialSkeletonSegmentSelection( element: HTMLElement, selectSegment: (id: bigint, pin: true | "force-unpin") => void, - segmentId: number, + segmentId: SpatialSkeletonId, ) { - const id = BigInt(segmentId); + const id = segmentId; const hasSegmentSelectionModifiers = (event: MouseEvent) => event.ctrlKey && !event.altKey && !event.metaKey; element.addEventListener("mousedown", (event: MouseEvent) => { @@ -978,18 +983,19 @@ interface SegmentationActionContext extends LayerActionContext { } interface SelectedSpatialSkeletonNodeInfo { - nodeId: number; - segmentId?: number; + nodeId: SpatialSkeletonId; + segmentId?: SpatialSkeletonId; position?: Float32Array; sourceState?: SpatialSkeletonSourceState; } -function normalizeOptionalPositiveSafeInteger(value: unknown) { +function normalizeOptionalSpatialSkeletonId(value: unknown) { if (value === undefined) return undefined; - const normalized = Math.round(Number(value)); - return Number.isSafeInteger(normalized) && normalized > 0 - ? normalized - : undefined; + try { + return parsePositiveUint64Id(value, "spatial skeleton id"); + } catch { + return undefined; + } } function copyOptionalSpatialSkeletonPosition( @@ -1008,7 +1014,7 @@ export class SegmentationUserLayer extends Base { new SpatialSkeletonState(), ); readonly selectedSpatialSkeletonNodeId = new WatchableValue< - number | undefined + SpatialSkeletonId | undefined >(undefined); readonly selectedSpatialSkeletonNodeInfo = new WatchableValue< SelectedSpatialSkeletonNodeInfo | undefined @@ -1115,15 +1121,15 @@ export class SegmentationUserLayer extends Base { } selectSpatialSkeletonNode = ( - nodeId: number, + nodeId: SpatialSkeletonId, pin: boolean | "toggle" = false, options: { - segmentId?: number; + segmentId?: SpatialSkeletonId; position?: ArrayLike; sourceState?: SpatialSkeletonSourceState; } = {}, ) => { - const normalizedNodeId = normalizeOptionalPositiveSafeInteger(nodeId); + const normalizedNodeId = normalizeOptionalSpatialSkeletonId(nodeId); if (normalizedNodeId === undefined) { return; } @@ -1131,7 +1137,7 @@ export class SegmentationUserLayer extends Base { this.getSpatiallyIndexedSkeletonLayer()?.getNode(normalizedNodeId); const requestedSegmentId = options.segmentId ?? selectedNodeInfo?.segmentId ?? undefined; - const segmentId = normalizeOptionalPositiveSafeInteger(requestedSegmentId); + const segmentId = normalizeOptionalSpatialSkeletonId(requestedSegmentId); const selectedNodePosition = options.position ?? selectedNodeInfo?.position; const selectedGlobalPosition = this.getGlobalSelectionPositionFromModelPosition(selectedNodePosition); @@ -1144,8 +1150,8 @@ export class SegmentationUserLayer extends Base { }; this.captureSpatialSkeletonSelectionState( (state) => { - state.nodeId = normalizedNodeId.toString(); - state.value = segmentId === undefined ? undefined : BigInt(segmentId); + state.nodeId = stringifySpatialSkeletonId(normalizedNodeId); + state.value = segmentId; return true; }, pin, @@ -1172,31 +1178,28 @@ export class SegmentationUserLayer extends Base { } inspectSpatialSkeletonSegment = ( - segmentId: number, + segmentId: SpatialSkeletonId, options: { secondary?: boolean } = {}, ) => { void options; - const normalizedSegmentId = Math.round(Number(segmentId)); - if ( - !Number.isSafeInteger(normalizedSegmentId) || - normalizedSegmentId <= 0 - ) { + const normalizedSegmentId = normalizeOptionalSpatialSkeletonId(segmentId); + if (normalizedSegmentId === undefined) { return false; } const visibleSegments = getVisibleSegments( this.displayState.segmentationGroupState.value, ); - if (visibleSegments.has(BigInt(normalizedSegmentId))) { + if (visibleSegments.has(normalizedSegmentId)) { return false; } addSegmentToVisibleSets( this.displayState.segmentationGroupState.value, - BigInt(normalizedSegmentId), + normalizedSegmentId, ); return true; }; - setSpatialSkeletonMergeAnchor = (nodeId: number | undefined) => { + setSpatialSkeletonMergeAnchor = (nodeId: SpatialSkeletonId | undefined) => { return this.spatialSkeletonState.setMergeAnchor(nodeId); }; @@ -1215,7 +1218,7 @@ export class SegmentationUserLayer extends Base { ); if ( selectedNode !== undefined && - visibleSegments.has(BigInt(selectedNode.segmentId)) + visibleSegments.has(selectedNode.segmentId) ) { return selectedNode.segmentId; } @@ -1224,15 +1227,11 @@ export class SegmentationUserLayer extends Base { const selectedSegmentId = selectedSegmentValue === undefined ? undefined - : Number(selectedSegmentValue); - if ( - selectedSegmentId === undefined || - !Number.isSafeInteger(selectedSegmentId) || - selectedSegmentId <= 0 - ) { + : normalizeOptionalSpatialSkeletonId(selectedSegmentValue); + if (selectedSegmentId === undefined) { return undefined; } - return visibleSegments.has(BigInt(selectedSegmentId)) + return visibleSegments.has(selectedSegmentId) ? selectedSegmentId : undefined; }; @@ -1672,7 +1671,7 @@ export class SegmentationUserLayer extends Base { return undefined; } - getCachedSpatialSkeletonSegmentNodesForEdit(segmentId: number) { + getCachedSpatialSkeletonSegmentNodesForEdit(segmentId: SpatialSkeletonId) { const segmentNodes = this.spatialSkeletonState.getCachedSegmentNodes(segmentId); if (segmentNodes === undefined) { @@ -2277,15 +2276,15 @@ export class SegmentationUserLayer extends Base { ) { return; } - const nodeId = normalizeOptionalPositiveSafeInteger( + const nodeId = normalizeOptionalSpatialSkeletonId( pickedSpatialSkeleton.nodeId, ); state.nodeId = nodeId === undefined ? undefined : nodeId.toString(); - const segmentId = normalizeOptionalPositiveSafeInteger( + const segmentId = normalizeOptionalSpatialSkeletonId( pickedSpatialSkeleton.segmentId, ); if (segmentId !== undefined) { - state.value = BigInt(segmentId); + state.value = segmentId; } } @@ -2451,7 +2450,10 @@ export class SegmentationUserLayer extends Base { container.appendChild(row); }; - const appendSegmentAndNodeIds = (segmentId: number, nodeId: number) => { + const appendSegmentAndNodeIds = ( + segmentId: SpatialSkeletonId, + nodeId: SpatialSkeletonId, + ) => { const segmentChipColors = getSpatialSkeletonSegmentChipColors( this.displayState, segmentId, diff --git a/src/layer/segmentation/selection.spec.ts b/src/layer/segmentation/selection.spec.ts index 34f7228aa9..4670211706 100644 --- a/src/layer/segmentation/selection.spec.ts +++ b/src/layer/segmentation/selection.spec.ts @@ -61,13 +61,13 @@ describe("layer/segmentation/selection", () => { nodeId: "23", value: 7n, }), - ).toBe(23); + ).toBe(23n); expect( getSegmentIdFromLayerSelectionValue({ nodeId: "23", value: "7", }), - ).toBe(7); + ).toBe(7n); expect( getNodeIdFromLayerSelectionState({ nodeId: -1, @@ -77,7 +77,7 @@ describe("layer/segmentation/selection", () => { getSegmentIdFromLayerSelectionValue({ value: "9", }), - ).toBe(9); + ).toBe(9n); expect( getSpatialSkeletonSelectionRecoveryKey({ nodeId: "23", @@ -88,7 +88,7 @@ describe("layer/segmentation/selection", () => { getNodeIdFromLayerSelectionState({ nodeId: "18446744073709551615", }), - ).toBeUndefined(); + ).toBe(18446744073709551615n); expect( getSpatialSkeletonSelectionRecoveryKey({ nodeId: 23, @@ -118,7 +118,7 @@ describe("layer/segmentation/selection", () => { }, layerB, ), - ).toBe(31); + ).toBe(31n); expect( getNodeIdFromViewerSelection( { @@ -172,15 +172,15 @@ describe("layer/segmentation/selection", () => { mouseState = { active: true, pickedRenderLayer: renderLayerA, - pickedSpatialSkeleton: { nodeId: 31 }, + pickedSpatialSkeleton: { nodeId: 31n }, }; trigger(); - expect(hoverState.value).toBe(31); + expect(hoverState.value).toBe(31n); mouseState = { active: true, pickedRenderLayer: renderLayerB, - pickedSpatialSkeleton: { nodeId: 31 }, + pickedSpatialSkeleton: { nodeId: 31n }, }; trigger(); expect(hoverState.value).toBeUndefined(); @@ -188,7 +188,7 @@ describe("layer/segmentation/selection", () => { mouseState = { active: false, pickedRenderLayer: renderLayerA, - pickedSpatialSkeleton: { nodeId: 31 }, + pickedSpatialSkeleton: { nodeId: 31n }, }; trigger(); expect(hoverState.value).toBeUndefined(); diff --git a/src/layer/segmentation/selection.ts b/src/layer/segmentation/selection.ts index 2d15b10751..517535c3dc 100644 --- a/src/layer/segmentation/selection.ts +++ b/src/layer/segmentation/selection.ts @@ -16,6 +16,7 @@ import type { LayerSelectedValues } from "#src/layer/index.js"; import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; +import { parsePositiveUint64Id } from "#src/util/bigint.js"; import { RefCounted } from "#src/util/disposable.js"; import { parseUint64 } from "#src/util/json.js"; import { NullarySignal } from "#src/util/signal.js"; @@ -39,8 +40,6 @@ export enum SpatialSkeletonSelectionRecoveryStatus { FAILED = "failed", } -const MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); - function parseSelectionStateStringId(value: unknown) { if (typeof value !== "string") { return undefined; @@ -54,11 +53,7 @@ function parseSelectionStateStringId(value: unknown) { } function normalizeSelectionStateStringId(value: unknown) { - const parsedValue = parseSelectionStateStringId(value); - if (parsedValue === undefined || parsedValue > MAX_SAFE_INTEGER_BIGINT) { - return undefined; - } - return Number(parsedValue); + return parseSelectionStateStringId(value); } function getSelectionIdString(value: unknown) { @@ -67,11 +62,7 @@ function getSelectionIdString(value: unknown) { function normalizeSelectionStateValueId(value: unknown) { try { - const parsedValue = parseUint64(value); - if (parsedValue <= 0n || parsedValue > MAX_SAFE_INTEGER_BIGINT) { - return undefined; - } - return Number(parsedValue); + return parsePositiveUint64Id(value, "spatial skeleton segment id"); } catch { return undefined; } @@ -80,18 +71,18 @@ function normalizeSelectionStateValueId(value: unknown) { function getSelectionValueIdString(value: unknown) { try { const parsedValue = parseUint64(value); - return parsedValue > 0n && parsedValue <= MAX_SAFE_INTEGER_BIGINT - ? parsedValue.toString() - : undefined; + return parsedValue > 0n ? parsedValue.toString() : undefined; } catch { return undefined; } } function normalizeSpatialSkeletonViewerHoverNodeId(value: unknown) { - return typeof value === "number" && Number.isSafeInteger(value) && value > 0 - ? value - : undefined; + try { + return parsePositiveUint64Id(value, "spatial skeleton node id"); + } catch { + return undefined; + } } export function getNodeIdFromLayerSelectionState( @@ -196,10 +187,10 @@ function getSpatialSkeletonNodeIdFromViewerHover( } export class SpatialSkeletonHoverState extends RefCounted { - value: number | undefined = undefined; + value: bigint | undefined = undefined; readonly changed = new NullarySignal(); - setValue(value: number | undefined) { + setValue(value: bigint | undefined) { if (this.value !== value) { this.value = value; this.changed.dispatch(); diff --git a/src/layer/segmentation/spatial_skeleton_commands.spec.ts b/src/layer/segmentation/spatial_skeleton_commands.spec.ts index 021b9a0f6a..a1efc78864 100644 --- a/src/layer/segmentation/spatial_skeleton_commands.spec.ts +++ b/src/layer/segmentation/spatial_skeleton_commands.spec.ts @@ -44,9 +44,9 @@ function cloneNodes( } function setSegmentNodes( - cacheBySegment: Map, - cacheByNode: Map, - segmentId: number, + cacheBySegment: Map, + cacheByNode: Map, + segmentId: bigint, nodes: readonly SpatiallyIndexedSkeletonNode[], ) { if (nodes.length === 0) { @@ -180,8 +180,8 @@ describe("spatial_skeleton_commands", () => { }), }; const node: SpatiallyIndexedSkeletonNode = { - nodeId: 17, - segmentId: 23, + nodeId: 17n, + segmentId: 23n, position: new Float32Array([1, 2, 3]), }; const nextPositionInModelSpace = new Float32Array([7, 8, 9]); @@ -221,8 +221,8 @@ describe("spatial_skeleton_commands", () => { expect(() => executeSpatialSkeletonNodeDescriptionUpdate(layer as any, { node: { - nodeId: 17, - segmentId: 23, + nodeId: 17n, + segmentId: 23n, position: new Float32Array([1, 2, 3]), }, nextDescription: "next", @@ -247,8 +247,8 @@ describe("spatial_skeleton_commands", () => { }), }; const node: SpatiallyIndexedSkeletonNode = { - nodeId: 17, - segmentId: 23, + nodeId: 17n, + segmentId: 23n, position: new Float32Array([1, 2, 3]), }; @@ -285,8 +285,8 @@ describe("spatial_skeleton_commands", () => { }, }; const node: SpatiallyIndexedSkeletonNode = { - nodeId: 17, - segmentId: 23, + nodeId: 17n, + segmentId: 23n, position: new Float32Array([1, 2, 3]), }; @@ -318,8 +318,8 @@ describe("spatial_skeleton_commands", () => { suppressStatusMessages(); const node: SpatiallyIndexedSkeletonNode = { - nodeId: 17, - segmentId: 23, + nodeId: 17n, + segmentId: 23n, position: new Float32Array([1, 2, 3]), radius: 4, confidence: 50, @@ -334,24 +334,24 @@ describe("spatial_skeleton_commands", () => { }); const skeletonLayer = { source: makeEditableSkeletonSource({ updateRadius, updateConfidence }), - getNode: vi.fn((nodeId: number) => + getNode: vi.fn((nodeId: bigint) => nodeId === cachedNode.nodeId ? cachedNode : undefined, ), invalidateSourceCaches: vi.fn(), }; const commandHistory = new SpatialSkeletonCommandHistory(); - const setNodeRadius = vi.fn((nodeId: number, radius: number) => { + const setNodeRadius = vi.fn((nodeId: bigint, radius: number) => { if (nodeId === cachedNode.nodeId) { cachedNode = { ...cachedNode, radius }; } }); - const setNodeConfidence = vi.fn((nodeId: number, confidence: number) => { + const setNodeConfidence = vi.fn((nodeId: bigint, confidence: number) => { if (nodeId === cachedNode.nodeId) { cachedNode = { ...cachedNode, confidence }; } }); const setCachedNodeSourceState = vi.fn( - (nodeId: number, sourceState: unknown) => { + (nodeId: bigint, sourceState: unknown) => { if (nodeId === cachedNode.nodeId) { cachedNode = { ...cachedNode, sourceState: sourceState as any }; } @@ -361,10 +361,10 @@ describe("spatial_skeleton_commands", () => { const layer = { spatialSkeletonState: { commandHistory, - getCachedNode: vi.fn((nodeId: number) => + getCachedNode: vi.fn((nodeId: bigint) => nodeId === cachedNode.nodeId ? cachedNode : undefined, ), - getCachedSegmentNodes: vi.fn((segmentId: number) => + getCachedSegmentNodes: vi.fn((segmentId: bigint) => segmentId === cachedNode.segmentId ? [cachedNode] : undefined, ), setNodeRadius, @@ -385,27 +385,27 @@ describe("spatial_skeleton_commands", () => { }); expect(updateRadius).toHaveBeenCalledWith( - 17, + 17n, 6, expect.objectContaining({ - node: expect.objectContaining({ nodeId: 17 }), + node: expect.objectContaining({ nodeId: 17n }), }), ); expect(updateConfidence).toHaveBeenCalledWith( - 17, + 17n, 75, expect.objectContaining({ - node: expect.objectContaining({ nodeId: 17 }), + node: expect.objectContaining({ nodeId: 17n }), }), ); - expect(setNodeRadius).toHaveBeenCalledWith(17, 6); - expect(setNodeConfidence).toHaveBeenCalledWith(17, 75); + expect(setNodeRadius).toHaveBeenCalledWith(17n, 6); + expect(setNodeConfidence).toHaveBeenCalledWith(17n, 75); expect(setCachedNodeSourceState).toHaveBeenCalledWith( - 17, + 17n, testSourceState("after-radius"), ); expect(setCachedNodeSourceState).toHaveBeenCalledWith( - 17, + 17n, testSourceState("after-confidence"), ); expect(markSpatialSkeletonNodeDataChanged).toHaveBeenCalledTimes(2); @@ -415,8 +415,8 @@ describe("spatial_skeleton_commands", () => { suppressStatusMessages(); const node: SpatiallyIndexedSkeletonNode = { - nodeId: 17, - segmentId: 23, + nodeId: 17n, + segmentId: 23n, position: new Float32Array([1, 2, 3]), isTrueEnd: false, sourceState: testSourceState("before"), @@ -427,7 +427,7 @@ describe("spatial_skeleton_commands", () => { }); const skeletonLayer = { source: makeEditableSkeletonSource({ moveNode }), - getNode: vi.fn((nodeId: number) => + getNode: vi.fn((nodeId: bigint) => nodeId === node.nodeId ? node : undefined, ), retainOverlaySegment: vi.fn(), @@ -440,10 +440,10 @@ describe("spatial_skeleton_commands", () => { const layer = { spatialSkeletonState: { commandHistory, - getCachedNode: vi.fn((nodeId: number) => + getCachedNode: vi.fn((nodeId: bigint) => nodeId === node.nodeId ? node : undefined, ), - getCachedSegmentNodes: vi.fn((segmentId: number) => + getCachedSegmentNodes: vi.fn((segmentId: bigint) => segmentId === node.segmentId ? [node] : undefined, ), moveCachedNode, @@ -459,21 +459,21 @@ describe("spatial_skeleton_commands", () => { }); expect(moveNode).toHaveBeenCalledWith( - 17, + 17n, 7, 8, 9, expect.objectContaining({ - node: expect.objectContaining({ nodeId: 17 }), + node: expect.objectContaining({ nodeId: 17n }), }), ); - expect(skeletonLayer.retainOverlaySegment).toHaveBeenCalledWith(23); + expect(skeletonLayer.retainOverlaySegment).toHaveBeenCalledWith(23n); expect(moveCachedNode).toHaveBeenCalledWith( - 17, + 17n, new Float32Array([7, 8, 9]), ); expect(setCachedNodeSourceState).toHaveBeenCalledWith( - 17, + 17n, testSourceState("after"), ); expect(markSpatialSkeletonNodeDataChanged).toHaveBeenCalledWith({ @@ -486,8 +486,8 @@ describe("spatial_skeleton_commands", () => { suppressStatusMessages(); let cachedNode: SpatiallyIndexedSkeletonNode = { - nodeId: 17, - segmentId: 23, + nodeId: 17n, + segmentId: 23n, position: new Float32Array([1, 2, 3]), description: "before", isTrueEnd: true, @@ -500,7 +500,7 @@ describe("spatial_skeleton_commands", () => { const toggleTrueEnd = vi.fn(); const skeletonLayer = { source: makeEditableSkeletonSource({ updateDescription, toggleTrueEnd }), - getNode: vi.fn((nodeId: number) => + getNode: vi.fn((nodeId: bigint) => nodeId === cachedNode.nodeId ? cachedNode : undefined, ), invalidateSourceCaches: vi.fn(), @@ -508,7 +508,7 @@ describe("spatial_skeleton_commands", () => { const commandHistory = new SpatialSkeletonCommandHistory(); const updateCachedNode = vi.fn( ( - nodeId: number, + nodeId: bigint, updater: ( candidate: SpatiallyIndexedSkeletonNode, ) => SpatiallyIndexedSkeletonNode, @@ -519,7 +519,7 @@ describe("spatial_skeleton_commands", () => { }, ); const setCachedNodeSourceState = vi.fn( - (nodeId: number, sourceState: unknown) => { + (nodeId: bigint, sourceState: unknown) => { if (nodeId === cachedNode.nodeId) { cachedNode = { ...cachedNode, sourceState: sourceState as any }; } @@ -529,10 +529,10 @@ describe("spatial_skeleton_commands", () => { const layer = { spatialSkeletonState: { commandHistory, - getCachedNode: vi.fn((nodeId: number) => + getCachedNode: vi.fn((nodeId: bigint) => nodeId === cachedNode.nodeId ? cachedNode : undefined, ), - getCachedSegmentNodes: vi.fn((segmentId: number) => + getCachedSegmentNodes: vi.fn((segmentId: bigint) => segmentId === cachedNode.segmentId ? [cachedNode] : undefined, ), updateCachedNode, @@ -547,7 +547,7 @@ describe("spatial_skeleton_commands", () => { nextDescription: "after", }); - expect(updateDescription).toHaveBeenCalledWith(17, "after", { + expect(updateDescription).toHaveBeenCalledWith(17n, "after", { isTrueEnd: true, }); expect(toggleTrueEnd).not.toHaveBeenCalled(); @@ -564,16 +564,16 @@ describe("spatial_skeleton_commands", () => { it("moves to the parent node when undoing an add-node command", async () => { suppressStatusMessages(); - const segmentId = 23; + const segmentId = 23n; const parentNode: SpatiallyIndexedSkeletonNode = { - nodeId: 1, + nodeId: 1n, segmentId, position: new Float32Array([4, 5, 6]), isTrueEnd: false, sourceState: testSourceState("parent-before-add"), }; const addNode = vi.fn().mockResolvedValue({ - nodeId: 2, + nodeId: 2n, segmentId, sourceState: testSourceState("added-after-add"), parentSourceState: testSourceState("parent-after-add"), @@ -596,7 +596,7 @@ describe("spatial_skeleton_commands", () => { }); const skeletonLayer = { source: skeletonSource, - getNode: vi.fn((nodeId: number) => + getNode: vi.fn((nodeId: bigint) => spatialSkeletonState.getCachedNode(nodeId), ), retainOverlaySegment: vi.fn(), @@ -672,14 +672,14 @@ describe("spatial_skeleton_commands", () => { await undoSpatialSkeletonCommand(layer as any); - expect(deleteNode).toHaveBeenCalledWith(2, { + expect(deleteNode).toHaveBeenCalledWith(2n, { childNodeIds: [], editContext: expect.objectContaining({ - node: expect.objectContaining({ nodeId: 2 }), + node: expect.objectContaining({ nodeId: 2n }), parent: expect.objectContaining({ nodeId: parentNode.nodeId }), }), }); - expect(spatialSkeletonState.getCachedNode(2)).toBeUndefined(); + expect(spatialSkeletonState.getCachedNode(2n)).toBeUndefined(); expect(layer.selectAndMoveToSpatialSkeletonNode).toHaveBeenCalledWith( { ...parentNode, @@ -694,16 +694,16 @@ describe("spatial_skeleton_commands", () => { it("restores internal-node delete undo as an insertion in the local cache", async () => { suppressStatusMessages(); - const segmentId = 23; + const segmentId = 23n; const rootNode: SpatiallyIndexedSkeletonNode = { - nodeId: 1, + nodeId: 1n, segmentId, position: new Float32Array([1, 2, 3]), isTrueEnd: false, sourceState: testSourceState("root-before-delete"), }; const deletedNode: SpatiallyIndexedSkeletonNode = { - nodeId: 2, + nodeId: 2n, segmentId, parentNodeId: rootNode.nodeId, position: new Float32Array([4, 5, 6]), @@ -711,7 +711,7 @@ describe("spatial_skeleton_commands", () => { sourceState: testSourceState("deleted-before-delete"), }; const firstChildNode: SpatiallyIndexedSkeletonNode = { - nodeId: 3, + nodeId: 3n, segmentId, parentNodeId: deletedNode.nodeId, position: new Float32Array([7, 8, 9]), @@ -719,7 +719,7 @@ describe("spatial_skeleton_commands", () => { sourceState: testSourceState("first-child-before-delete"), }; const secondChildNode: SpatiallyIndexedSkeletonNode = { - nodeId: 4, + nodeId: 4n, segmentId, parentNodeId: deletedNode.nodeId, position: new Float32Array([10, 11, 12]), @@ -745,7 +745,7 @@ describe("spatial_skeleton_commands", () => { }); const addNode = vi.fn(); const insertNode = vi.fn().mockResolvedValue({ - nodeId: 20, + nodeId: 20n, segmentId, sourceState: testSourceState("restored-after-undo"), parentSourceState: testSourceState("root-after-undo"), @@ -778,7 +778,7 @@ describe("spatial_skeleton_commands", () => { spatialSkeletonState.upsertCachedNode(deletedNode); spatialSkeletonState.upsertCachedNode(firstChildNode); spatialSkeletonState.upsertCachedNode(secondChildNode); - skeletonLayer.getNode.mockImplementation((nodeId: number) => + skeletonLayer.getNode.mockImplementation((nodeId: bigint) => spatialSkeletonState.getCachedNode(nodeId), ); @@ -808,7 +808,7 @@ describe("spatial_skeleton_commands", () => { spatialSkeletonState, getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, getCachedSpatialSkeletonSegmentNodesForEdit: ( - requestedSegmentId: number, + requestedSegmentId: bigint, ) => spatialSkeletonState.getCachedSegmentNodes(requestedSegmentId) ?? [], async getSpatialSkeletonDeleteOperationContext( node: SpatiallyIndexedSkeletonNode, @@ -876,9 +876,9 @@ describe("spatial_skeleton_commands", () => { }), ); - const restoredNode = spatialSkeletonState.getCachedNode(20); + const restoredNode = spatialSkeletonState.getCachedNode(20n); expect(restoredNode).toMatchObject({ - nodeId: 20, + nodeId: 20n, parentNodeId: rootNode.nodeId, segmentId, }); @@ -901,17 +901,17 @@ describe("spatial_skeleton_commands", () => { it("suppresses and clears the deleted segment when undoing a split", async () => { suppressStatusMessages(); - const originalSegmentId = 2973964; - const splitSegmentId = 2973946; + const originalSegmentId = 2973964n; + const splitSegmentId = 2973946n; const formerParentNode: SpatiallyIndexedSkeletonNode = { - nodeId: 21893039, + nodeId: 21893039n, segmentId: originalSegmentId, position: new Float32Array([10, 20, 30]), isTrueEnd: false, sourceState: testSourceState("parent-before"), }; const splitNodeBefore: SpatiallyIndexedSkeletonNode = { - nodeId: 21893038, + nodeId: 21893038n, segmentId: originalSegmentId, parentNodeId: formerParentNode.nodeId, position: new Float32Array([11, 21, 31]), @@ -929,11 +929,11 @@ describe("spatial_skeleton_commands", () => { sourceState: testSourceState("split-merged-back"), }; - const serverSegments = new Map(); - const cacheBySegment = new Map(); - const cacheByNode = new Map(); + const serverSegments = new Map(); + const cacheBySegment = new Map(); + const cacheByNode = new Map(); - const syncCacheFromServer = (segmentId: number) => { + const syncCacheFromServer = (segmentId: bigint) => { setSegmentNodes( cacheBySegment, cacheByNode, @@ -975,18 +975,18 @@ describe("spatial_skeleton_commands", () => { }); const deleteSegmentColor = vi.fn(); - const invalidateCachedSegments = vi.fn((segmentIds: Iterable) => { + const invalidateCachedSegments = vi.fn((segmentIds: Iterable) => { for (const segmentId of segmentIds) { setSegmentNodes(cacheBySegment, cacheByNode, segmentId, []); } }); const getFullSegmentNodes = vi.fn( - async (_skeletonLayer: unknown, segmentId: number) => + async (_skeletonLayer: unknown, segmentId: bigint) => syncCacheFromServer(segmentId), ); const skeletonLayer = { source: skeletonSource, - getNode: vi.fn((nodeId: number) => cacheByNode.get(nodeId)), + getNode: vi.fn((nodeId: bigint) => cacheByNode.get(nodeId)), invalidateSourceCaches: vi.fn(), suppressBrowseSegment: vi.fn(), }; @@ -1020,14 +1020,14 @@ describe("spatial_skeleton_commands", () => { }, spatialSkeletonState: { commandHistory: new SpatialSkeletonCommandHistory(), - getCachedNode: (nodeId: number) => cacheByNode.get(nodeId), - getCachedSegmentNodes: (segmentId: number) => + getCachedNode: (nodeId: bigint) => cacheByNode.get(nodeId), + getCachedSegmentNodes: (segmentId: bigint) => cacheBySegment.get(segmentId), getFullSegmentNodes, invalidateCachedSegments, }, getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, - getCachedSpatialSkeletonSegmentNodesForEdit: (segmentId: number) => + getCachedSpatialSkeletonSegmentNodesForEdit: (segmentId: bigint) => cacheBySegment.get(segmentId) ?? [], selectSegment: vi.fn(), selectSpatialSkeletonNode: vi.fn(), @@ -1092,17 +1092,17 @@ describe("spatial_skeleton_commands", () => { it("uses the original skeleton side as the join winner when undoing a split", async () => { suppressStatusMessages(); - const originalSegmentId = 2973964; - const splitSegmentId = 2973946; + const originalSegmentId = 2973964n; + const splitSegmentId = 2973946n; const originalRootNode: SpatiallyIndexedSkeletonNode = { - nodeId: 21893001, + nodeId: 21893001n, segmentId: originalSegmentId, position: new Float32Array([1, 2, 3]), isTrueEnd: false, sourceState: testSourceState("root-before"), }; const formerParentNode: SpatiallyIndexedSkeletonNode = { - nodeId: 21893039, + nodeId: 21893039n, segmentId: originalSegmentId, parentNodeId: originalRootNode.nodeId, position: new Float32Array([10, 20, 30]), @@ -1110,7 +1110,7 @@ describe("spatial_skeleton_commands", () => { sourceState: testSourceState("parent-before"), }; const splitNodeBefore: SpatiallyIndexedSkeletonNode = { - nodeId: 21893038, + nodeId: 21893038n, segmentId: originalSegmentId, parentNodeId: formerParentNode.nodeId, position: new Float32Array([11, 21, 31]), @@ -1142,11 +1142,11 @@ describe("spatial_skeleton_commands", () => { }, ]; - const serverSegments = new Map(); - const cacheBySegment = new Map(); - const cacheByNode = new Map(); + const serverSegments = new Map(); + const cacheBySegment = new Map(); + const cacheByNode = new Map(); - const syncCacheFromServer = (segmentId: number) => { + const syncCacheFromServer = (segmentId: bigint) => { setSegmentNodes( cacheBySegment, cacheByNode, @@ -1190,18 +1190,18 @@ describe("spatial_skeleton_commands", () => { rerootSkeleton, }); - const invalidateCachedSegments = vi.fn((segmentIds: Iterable) => { + const invalidateCachedSegments = vi.fn((segmentIds: Iterable) => { for (const segmentId of segmentIds) { setSegmentNodes(cacheBySegment, cacheByNode, segmentId, []); } }); const getFullSegmentNodes = vi.fn( - async (_skeletonLayer: unknown, segmentId: number) => + async (_skeletonLayer: unknown, segmentId: bigint) => syncCacheFromServer(segmentId), ); const skeletonLayer = { source: skeletonSource, - getNode: vi.fn((nodeId: number) => cacheByNode.get(nodeId)), + getNode: vi.fn((nodeId: bigint) => cacheByNode.get(nodeId)), invalidateSourceCaches: vi.fn(), suppressBrowseSegment: vi.fn(), }; @@ -1235,14 +1235,14 @@ describe("spatial_skeleton_commands", () => { }, spatialSkeletonState: { commandHistory: new SpatialSkeletonCommandHistory(), - getCachedNode: (nodeId: number) => cacheByNode.get(nodeId), - getCachedSegmentNodes: (segmentId: number) => + getCachedNode: (nodeId: bigint) => cacheByNode.get(nodeId), + getCachedSegmentNodes: (segmentId: bigint) => cacheBySegment.get(segmentId), getFullSegmentNodes, invalidateCachedSegments, }, getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, - getCachedSpatialSkeletonSegmentNodesForEdit: (segmentId: number) => + getCachedSpatialSkeletonSegmentNodesForEdit: (segmentId: bigint) => cacheBySegment.get(segmentId) ?? [], selectSegment: vi.fn(), selectSpatialSkeletonNode: vi.fn(), @@ -1302,17 +1302,17 @@ describe("spatial_skeleton_commands", () => { it("preserves full merge undo behavior for a hidden second pick", async () => { suppressStatusMessages(); - const visibleSegmentId = 11; - const hiddenSegmentId = 17; + const visibleSegmentId = 11n; + const hiddenSegmentId = 17n; const visibleRootNode: SpatiallyIndexedSkeletonNode = { - nodeId: 101, + nodeId: 101n, segmentId: visibleSegmentId, position: new Float32Array([1, 2, 3]), isTrueEnd: false, sourceState: testSourceState("visible-root-before"), }; const visibleAnchorNode: SpatiallyIndexedSkeletonNode = { - nodeId: 102, + nodeId: 102n, segmentId: visibleSegmentId, parentNodeId: visibleRootNode.nodeId, position: new Float32Array([4, 5, 6]), @@ -1320,14 +1320,14 @@ describe("spatial_skeleton_commands", () => { sourceState: testSourceState("visible-anchor-before"), }; const hiddenRootNode: SpatiallyIndexedSkeletonNode = { - nodeId: 201, + nodeId: 201n, segmentId: hiddenSegmentId, position: new Float32Array([7, 8, 9]), isTrueEnd: false, sourceState: testSourceState("hidden-root-before"), }; const hiddenAttachNodeBefore: SpatiallyIndexedSkeletonNode = { - nodeId: 202, + nodeId: 202n, segmentId: hiddenSegmentId, parentNodeId: hiddenRootNode.nodeId, position: new Float32Array([10, 11, 12]), @@ -1373,12 +1373,12 @@ describe("spatial_skeleton_commands", () => { }, ]; - const serverSegments = new Map(); - const cacheBySegment = new Map(); - const cacheByNode = new Map(); + const serverSegments = new Map(); + const cacheBySegment = new Map(); + const cacheByNode = new Map(); const hiddenSegmentVisibleDuringFetches: boolean[] = []; - const syncCacheFromServer = (segmentId: number) => { + const syncCacheFromServer = (segmentId: bigint) => { setSegmentNodes( cacheBySegment, cacheByNode, @@ -1435,13 +1435,13 @@ describe("spatial_skeleton_commands", () => { rerootSkeleton, }); - const invalidateCachedSegments = vi.fn((segmentIds: Iterable) => { + const invalidateCachedSegments = vi.fn((segmentIds: Iterable) => { for (const segmentId of segmentIds) { setSegmentNodes(cacheBySegment, cacheByNode, segmentId, []); } }); const getFullSegmentNodes = vi.fn( - async (_skeletonLayer: unknown, segmentId: number) => { + async (_skeletonLayer: unknown, segmentId: bigint) => { if (segmentId === hiddenSegmentId) { hiddenSegmentVisibleDuringFetches.push( layer.displayState.segmentationGroupState.value.visibleSegments.has( @@ -1454,7 +1454,7 @@ describe("spatial_skeleton_commands", () => { ); const skeletonLayer = { source: skeletonSource, - getNode: vi.fn((nodeId: number) => cacheByNode.get(nodeId)), + getNode: vi.fn((nodeId: bigint) => cacheByNode.get(nodeId)), invalidateSourceCaches: vi.fn(), suppressBrowseSegment: vi.fn(), }; @@ -1488,8 +1488,8 @@ describe("spatial_skeleton_commands", () => { }, spatialSkeletonState: { commandHistory: new SpatialSkeletonCommandHistory(), - getCachedNode: (nodeId: number) => cacheByNode.get(nodeId), - getCachedSegmentNodes: (segmentId: number) => + getCachedNode: (nodeId: bigint) => cacheByNode.get(nodeId), + getCachedSegmentNodes: (segmentId: bigint) => cacheBySegment.get(segmentId), getFullSegmentNodes, invalidateCachedSegments, @@ -1578,17 +1578,17 @@ describe("spatial_skeleton_commands", () => { (_message: string, _closeAfter?: number) => fakeStatusMessage, ); - const visibleSegmentId = 11; - const hiddenSegmentId = 17; + const visibleSegmentId = 11n; + const hiddenSegmentId = 17n; const visibleRootNode: SpatiallyIndexedSkeletonNode = { - nodeId: 101, + nodeId: 101n, segmentId: visibleSegmentId, position: new Float32Array([1, 2, 3]), isTrueEnd: false, sourceState: testSourceState("visible-root-before"), }; const visibleAnchorNode: SpatiallyIndexedSkeletonNode = { - nodeId: 102, + nodeId: 102n, segmentId: visibleSegmentId, parentNodeId: visibleRootNode.nodeId, position: new Float32Array([4, 5, 6]), @@ -1596,14 +1596,14 @@ describe("spatial_skeleton_commands", () => { sourceState: testSourceState("visible-anchor-before"), }; const hiddenRootNode: SpatiallyIndexedSkeletonNode = { - nodeId: 201, + nodeId: 201n, segmentId: hiddenSegmentId, position: new Float32Array([7, 8, 9]), isTrueEnd: false, sourceState: testSourceState("hidden-root-before"), }; const hiddenAttachNodeBefore: SpatiallyIndexedSkeletonNode = { - nodeId: 202, + nodeId: 202n, segmentId: hiddenSegmentId, parentNodeId: hiddenRootNode.nodeId, position: new Float32Array([10, 11, 12]), @@ -1637,11 +1637,11 @@ describe("spatial_skeleton_commands", () => { }, ]; - const serverSegments = new Map(); - const cacheBySegment = new Map(); - const cacheByNode = new Map(); + const serverSegments = new Map(); + const cacheBySegment = new Map(); + const cacheByNode = new Map(); - const syncCacheFromServer = (segmentId: number) => { + const syncCacheFromServer = (segmentId: bigint) => { setSegmentNodes( cacheBySegment, cacheByNode, @@ -1698,7 +1698,7 @@ describe("spatial_skeleton_commands", () => { }); const getFullSegmentNodes = vi.fn( - async (_skeletonLayer: unknown, segmentId: number) => + async (_skeletonLayer: unknown, segmentId: bigint) => syncCacheFromServer(segmentId), ); const layer = { @@ -1731,11 +1731,11 @@ describe("spatial_skeleton_commands", () => { }, spatialSkeletonState: { commandHistory: new SpatialSkeletonCommandHistory(), - getCachedNode: (nodeId: number) => cacheByNode.get(nodeId), - getCachedSegmentNodes: (segmentId: number) => + getCachedNode: (nodeId: bigint) => cacheByNode.get(nodeId), + getCachedSegmentNodes: (segmentId: bigint) => cacheBySegment.get(segmentId), getFullSegmentNodes, - invalidateCachedSegments: vi.fn((segmentIds: Iterable) => { + invalidateCachedSegments: vi.fn((segmentIds: Iterable) => { for (const segmentId of segmentIds) { setSegmentNodes(cacheBySegment, cacheByNode, segmentId, []); } @@ -1743,7 +1743,7 @@ describe("spatial_skeleton_commands", () => { }, getSpatiallyIndexedSkeletonLayer: () => ({ source: skeletonSource, - getNode: vi.fn((nodeId: number) => cacheByNode.get(nodeId)), + getNode: vi.fn((nodeId: bigint) => cacheByNode.get(nodeId)), invalidateSourceCaches: vi.fn(), suppressBrowseSegment: vi.fn(), }), @@ -1806,17 +1806,17 @@ describe("spatial_skeleton_commands", () => { it("falls back to full resolution for an uncached second node without revision metadata", async () => { suppressStatusMessages(); - const firstSegmentId = 11; - const secondSegmentId = 17; + const firstSegmentId = 11n; + const secondSegmentId = 17n; const firstRootNode: SpatiallyIndexedSkeletonNode = { - nodeId: 101, + nodeId: 101n, segmentId: firstSegmentId, position: new Float32Array([1, 2, 3]), isTrueEnd: false, sourceState: testSourceState("first-root-before"), }; const firstAnchorNode: SpatiallyIndexedSkeletonNode = { - nodeId: 102, + nodeId: 102n, segmentId: firstSegmentId, parentNodeId: firstRootNode.nodeId, position: new Float32Array([4, 5, 6]), @@ -1824,14 +1824,14 @@ describe("spatial_skeleton_commands", () => { sourceState: testSourceState("first-anchor-before"), }; const secondRootNode: SpatiallyIndexedSkeletonNode = { - nodeId: 201, + nodeId: 201n, segmentId: secondSegmentId, position: new Float32Array([7, 8, 9]), isTrueEnd: false, sourceState: testSourceState("second-root-before"), }; const secondAttachNode: SpatiallyIndexedSkeletonNode = { - nodeId: 202, + nodeId: 202n, segmentId: secondSegmentId, parentNodeId: secondRootNode.nodeId, position: new Float32Array([10, 11, 12]), @@ -1839,11 +1839,11 @@ describe("spatial_skeleton_commands", () => { sourceState: testSourceState("second-attach-before"), }; - const serverSegments = new Map(); - const cacheBySegment = new Map(); - const cacheByNode = new Map(); + const serverSegments = new Map(); + const cacheBySegment = new Map(); + const cacheByNode = new Map(); - const syncCacheFromServer = (segmentId: number) => { + const syncCacheFromServer = (segmentId: bigint) => { setSegmentNodes( cacheBySegment, cacheByNode, @@ -1877,7 +1877,7 @@ describe("spatial_skeleton_commands", () => { }); const getFullSegmentNodes = vi.fn( - async (_skeletonLayer: unknown, segmentId: number) => + async (_skeletonLayer: unknown, segmentId: bigint) => syncCacheFromServer(segmentId), ); const layer = { @@ -1910,11 +1910,11 @@ describe("spatial_skeleton_commands", () => { }, spatialSkeletonState: { commandHistory: new SpatialSkeletonCommandHistory(), - getCachedNode: (nodeId: number) => cacheByNode.get(nodeId), - getCachedSegmentNodes: (segmentId: number) => + getCachedNode: (nodeId: bigint) => cacheByNode.get(nodeId), + getCachedSegmentNodes: (segmentId: bigint) => cacheBySegment.get(segmentId), getFullSegmentNodes, - invalidateCachedSegments: vi.fn((segmentIds: Iterable) => { + invalidateCachedSegments: vi.fn((segmentIds: Iterable) => { for (const segmentId of segmentIds) { setSegmentNodes(cacheBySegment, cacheByNode, segmentId, []); } @@ -1922,7 +1922,7 @@ describe("spatial_skeleton_commands", () => { }, getSpatiallyIndexedSkeletonLayer: () => ({ source: skeletonSource, - getNode: vi.fn((nodeId: number) => cacheByNode.get(nodeId)), + getNode: vi.fn((nodeId: bigint) => cacheByNode.get(nodeId)), invalidateSourceCaches: vi.fn(), suppressBrowseSegment: vi.fn(), }), @@ -1974,38 +1974,38 @@ describe("spatial_skeleton_commands", () => { let resolveMerge: | ((value: { - resultSegmentId: number; - deletedSegmentId: number; + resultSegmentId: bigint; + deletedSegmentId: bigint; directionAdjusted: boolean; }) => void) | undefined; const mergeSkeletons = vi.fn( () => new Promise<{ - resultSegmentId: number; - deletedSegmentId: number; + resultSegmentId: bigint; + deletedSegmentId: bigint; directionAdjusted: boolean; }>((resolve) => { resolveMerge = resolve; }), ); const firstNode: SpatiallyIndexedSkeletonNode = { - nodeId: 101, - segmentId: 11, + nodeId: 101n, + segmentId: 11n, position: new Float32Array([1, 2, 3]), isTrueEnd: false, sourceState: testSourceState("first-before"), }; const secondNode: SpatiallyIndexedSkeletonNode = { - nodeId: 202, - segmentId: 17, + nodeId: 202n, + segmentId: 17n, position: new Float32Array([4, 5, 6]), isTrueEnd: false, sourceState: testSourceState("second-before"), }; const skeletonLayer = { source: makeEditableSkeletonSource({ mergeSkeletons }), - getNode: vi.fn((nodeId: number) => { + getNode: vi.fn((nodeId: bigint) => { if (nodeId === firstNode.nodeId) return firstNode; if (nodeId === secondNode.nodeId) return secondNode; return undefined; @@ -2034,12 +2034,12 @@ describe("spatial_skeleton_commands", () => { }, spatialSkeletonState: { commandHistory: new SpatialSkeletonCommandHistory(), - getCachedNode: vi.fn((nodeId: number) => { + getCachedNode: vi.fn((nodeId: bigint) => { if (nodeId === firstNode.nodeId) return firstNode; if (nodeId === secondNode.nodeId) return secondNode; return undefined; }), - getCachedSegmentNodes: vi.fn((segmentId: number) => { + getCachedSegmentNodes: vi.fn((segmentId: bigint) => { if (segmentId === firstNode.segmentId) return [firstNode]; if (segmentId === secondNode.segmentId) return [secondNode]; return undefined; diff --git a/src/skeleton/api.ts b/src/skeleton/api.ts index fd4806df21..081e461466 100644 --- a/src/skeleton/api.ts +++ b/src/skeleton/api.ts @@ -29,6 +29,7 @@ import type { } from "#src/skeleton/edit_command_source.js"; export type SpatialSkeletonVector = ArrayLike; +export type SpatialSkeletonId = bigint; // Provider-specific node state that crosses the worker boundary must remain structured-cloneable. export type SpatialSkeletonSourceState = @@ -55,10 +56,10 @@ export interface SpatialSkeletonSpatialIndexLevel { } export interface SpatiallyIndexedSkeletonNodeBase { - nodeId: number; - segmentId: number; + nodeId: SpatialSkeletonId; + segmentId: SpatialSkeletonId; position: SpatialSkeletonVector; - parentNodeId?: number; + parentNodeId?: SpatialSkeletonId; sourceState?: SpatialSkeletonSourceState; } @@ -82,9 +83,9 @@ export interface SpatialSkeletonConfidenceConfiguration { export interface SpatiallyIndexedSkeletonSource { readonly readonly: boolean; - listSkeletons(): Promise; + listSkeletons(): Promise; getSkeleton( - skeletonId: number, + skeletonId: SpatialSkeletonId, options?: { signal?: AbortSignal }, ): Promise; getSpatialIndexMetadata(): Promise; diff --git a/src/skeleton/backend.ts b/src/skeleton/backend.ts index 919bcbd2b0..023bb33089 100644 --- a/src/skeleton/backend.ts +++ b/src/skeleton/backend.ts @@ -68,7 +68,7 @@ import { type SliceViewProjectionParameters, type TransformedSource, } from "#src/sliceview/base.js"; -import type { TypedNumberArray } from "#src/util/array.js"; +import type { TypedArray } from "#src/util/array.js"; import type { Endianness } from "#src/util/endian.js"; import { vec3 } from "#src/util/geom.js"; import { @@ -182,7 +182,7 @@ registerRPC( export class SkeletonChunk extends Chunk implements SkeletonChunkData { objectId: bigint = 0n; vertexPositions: Float32Array | null = null; - vertexAttributes: TypedNumberArray[] | null = null; + vertexAttributes: TypedArray[] | null = null; indices: Uint32Array | null = null; initializeSkeletonChunk(key: string, objectId: bigint) { @@ -295,12 +295,12 @@ export class SpatiallyIndexedSkeletonChunk implements SkeletonChunkData { vertexPositions: Float32Array | null = null; - vertexAttributes: TypedNumberArray[] | null = null; + vertexAttributes: TypedArray[] | null = null; indices: Uint32Array | null = null; lod: number = 0; requestGeneration = -1; requestOwners = SpatiallyIndexedSkeletonChunkRequestOwner.NONE; - nodeIds: Int32Array | undefined; + nodeIds: BigUint64Array | undefined; nodeSourceStates: Array | undefined; freeSystemMemory() { diff --git a/src/skeleton/command_history.spec.ts b/src/skeleton/command_history.spec.ts index ef3d36aaaa..904409f980 100644 --- a/src/skeleton/command_history.spec.ts +++ b/src/skeleton/command_history.spec.ts @@ -108,50 +108,50 @@ describe("skeleton/command_history", () => { it("keeps remapped node and segment ids across undo and redo", async () => { const history = new SpatialSkeletonCommandHistory(); - let nextNodeId = 100; - let nextSegmentId = 200; + let nextNodeId = 100n; + let nextSegmentId = 200n; const command: SpatialSkeletonCommand = { label: "Remap ids", execute: async ({ mappings }) => { - mappings.remapNodeId(11, nextNodeId++); - mappings.remapSegmentId(21, nextSegmentId++); + mappings.remapNodeId(11n, nextNodeId++); + mappings.remapSegmentId(21n, nextSegmentId++); }, undo: async ({ mappings }) => { - mappings.remapNodeId(11, nextNodeId++); - mappings.remapSegmentId(21, nextSegmentId++); + mappings.remapNodeId(11n, nextNodeId++); + mappings.remapSegmentId(21n, nextSegmentId++); }, }; await history.execute(command); - expect(history.mappings.resolveNodeId(11)).toBe(100); - expect(history.mappings.resolveSegmentId(21)).toBe(200); - expect(history.mappings.getStableNodeId(100)).toBe(11); - expect(history.mappings.getStableSegmentId(200)).toBe(21); + expect(history.mappings.resolveNodeId(11n)).toBe(100n); + expect(history.mappings.resolveSegmentId(21n)).toBe(200n); + expect(history.mappings.getStableNodeId(100n)).toBe(11n); + expect(history.mappings.getStableSegmentId(200n)).toBe(21n); await history.undo(); - expect(history.mappings.resolveNodeId(11)).toBe(101); - expect(history.mappings.resolveSegmentId(21)).toBe(201); + expect(history.mappings.resolveNodeId(11n)).toBe(101n); + expect(history.mappings.resolveSegmentId(21n)).toBe(201n); await history.redo(); - expect(history.mappings.resolveNodeId(11)).toBe(102); - expect(history.mappings.resolveSegmentId(21)).toBe(202); + expect(history.mappings.resolveNodeId(11n)).toBe(102n); + expect(history.mappings.resolveSegmentId(21n)).toBe(202n); }); it("restores mapping state if an operation fails", async () => { const history = new SpatialSkeletonCommandHistory(); - history.mappings.remapNodeId(11, 99); + history.mappings.remapNodeId(11n, 99n); const failingCommand: SpatialSkeletonCommand = { label: "Failing command", execute: async ({ mappings }) => { - mappings.remapNodeId(11, 100); + mappings.remapNodeId(11n, 100n); throw new Error("boom"); }, undo: async () => {}, }; await expect(history.execute(failingCommand)).rejects.toThrow("boom"); - expect(history.mappings.resolveNodeId(11)).toBe(99); + expect(history.mappings.resolveNodeId(11n)).toBe(99n); expect(history.canUndo.value).toBe(false); }); }); diff --git a/src/skeleton/command_history.ts b/src/skeleton/command_history.ts index 4ca7093210..49fceedb42 100644 --- a/src/skeleton/command_history.ts +++ b/src/skeleton/command_history.ts @@ -15,6 +15,8 @@ */ import { WatchableValue } from "#src/trackable_value.js"; +import type { SpatialSkeletonId } from "#src/skeleton/api.js"; +import { compareUint64Ids, parsePositiveUint64Id } from "#src/util/bigint.js"; import { RefCounted } from "#src/util/disposable.js"; export const SPATIAL_SKELETON_COMMAND_HISTORY_MAX_ENTRIES = 100; @@ -31,28 +33,28 @@ export interface SpatialSkeletonCommand { } interface SpatialSkeletonCommandMappingSnapshot { - nodeIdMappings: Array<[number, number]>; - segmentIdMappings: Array<[number, number]>; + nodeIdMappings: Array<[SpatialSkeletonId, SpatialSkeletonId]>; + segmentIdMappings: Array<[SpatialSkeletonId, SpatialSkeletonId]>; } -function normalizeIdentifier(value: number | undefined) { +function normalizeIdentifier(value: unknown) { if (value === undefined) return undefined; - const normalizedValue = Math.round(Number(value)); - if (!Number.isSafeInteger(normalizedValue) || normalizedValue <= 0) { + try { + return parsePositiveUint64Id(value, "spatial skeleton command id"); + } catch { return undefined; } - return normalizedValue; } function resolveIdentifierMapping( - mappings: Map, - value: number | undefined, + mappings: Map, + value: SpatialSkeletonId | undefined, ) { let currentValue = normalizeIdentifier(value); if (currentValue === undefined) { return undefined; } - const seen = new Set(); + const seen = new Set(); while (true) { const nextValue = mappings.get(currentValue); if (nextValue === undefined || seen.has(currentValue)) { @@ -64,8 +66,8 @@ function resolveIdentifierMapping( } function findStableIdentifier( - mappings: Map, - value: number | undefined, + mappings: Map, + value: SpatialSkeletonId | undefined, ) { const currentValue = normalizeIdentifier(value); if (currentValue === undefined) { @@ -76,7 +78,7 @@ function findStableIdentifier( if (resolveIdentifierMapping(mappings, candidate) !== currentValue) { continue; } - if (candidate < stableValue) { + if (compareUint64Ids(candidate, stableValue) < 0) { stableValue = candidate; } } @@ -84,8 +86,8 @@ function findStableIdentifier( } export class SpatialSkeletonCommandMappings { - private nodeIdMappings = new Map(); - private segmentIdMappings = new Map(); + private nodeIdMappings = new Map(); + private segmentIdMappings = new Map(); clear() { this.nodeIdMappings.clear(); @@ -104,31 +106,34 @@ export class SpatialSkeletonCommandMappings { this.segmentIdMappings = new Map(snapshot.segmentIdMappings); } - resolveNodeId(nodeId: number | undefined) { + resolveNodeId(nodeId: SpatialSkeletonId | undefined) { return resolveIdentifierMapping(this.nodeIdMappings, nodeId); } - resolveSegmentId(segmentId: number | undefined) { + resolveSegmentId(segmentId: SpatialSkeletonId | undefined) { return resolveIdentifierMapping(this.segmentIdMappings, segmentId); } - getStableNodeId(nodeId: number | undefined) { + getStableNodeId(nodeId: SpatialSkeletonId | undefined) { return findStableIdentifier(this.nodeIdMappings, nodeId); } - getStableSegmentId(segmentId: number | undefined) { + getStableSegmentId(segmentId: SpatialSkeletonId | undefined) { return findStableIdentifier(this.segmentIdMappings, segmentId); } - getStableOrCurrentNodeId(nodeId: number | undefined) { + getStableOrCurrentNodeId(nodeId: SpatialSkeletonId | undefined) { return this.getStableNodeId(nodeId) ?? normalizeIdentifier(nodeId); } - getStableOrCurrentSegmentId(segmentId: number | undefined) { + getStableOrCurrentSegmentId(segmentId: SpatialSkeletonId | undefined) { return this.getStableSegmentId(segmentId) ?? normalizeIdentifier(segmentId); } - remapNodeId(originalNodeId: number | undefined, currentNodeId: number) { + remapNodeId( + originalNodeId: SpatialSkeletonId | undefined, + currentNodeId: SpatialSkeletonId, + ) { const normalizedOriginalNodeId = normalizeIdentifier(originalNodeId); const normalizedCurrentNodeId = normalizeIdentifier(currentNodeId); if ( @@ -151,8 +156,8 @@ export class SpatialSkeletonCommandMappings { } remapSegmentId( - originalSegmentId: number | undefined, - currentSegmentId: number, + originalSegmentId: SpatialSkeletonId | undefined, + currentSegmentId: SpatialSkeletonId, ) { const normalizedOriginalSegmentId = normalizeIdentifier(originalSegmentId); const normalizedCurrentSegmentId = normalizeIdentifier(currentSegmentId); diff --git a/src/skeleton/edit_state.ts b/src/skeleton/edit_state.ts index ac50f433dc..8f38a2a8f6 100644 --- a/src/skeleton/edit_state.ts +++ b/src/skeleton/edit_state.ts @@ -1,19 +1,23 @@ -import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; +import type { + SpatialSkeletonId, + SpatiallyIndexedSkeletonNode, +} from "#src/skeleton/api.js"; +import { compareUint64Ids } from "#src/util/bigint.js"; export function findSpatiallyIndexedSkeletonNode( segmentNodes: readonly SpatiallyIndexedSkeletonNode[], - nodeId: number, + nodeId: SpatialSkeletonId, ) { return segmentNodes.find((node) => node.nodeId === nodeId); } export function getSpatiallyIndexedSkeletonDirectChildren( segmentNodes: readonly SpatiallyIndexedSkeletonNode[], - nodeId: number, + nodeId: SpatialSkeletonId, ) { return segmentNodes .filter((node) => node.parentNodeId === nodeId) - .sort((a, b) => a.nodeId - b.nodeId); + .sort((a, b) => compareUint64Ids(a.nodeId, b.nodeId)); } export function getSpatiallyIndexedSkeletonNodeParent( @@ -31,7 +35,7 @@ export function getSpatiallyIndexedSkeletonPathToRoot( node: SpatiallyIndexedSkeletonNode, ) { const path = [node]; - const visited = new Set([node.nodeId]); + const visited = new Set([node.nodeId]); let currentNode = node; while (true) { const parentNode = getSpatiallyIndexedSkeletonNodeParent( diff --git a/src/skeleton/frontend.spec.ts b/src/skeleton/frontend.spec.ts index 147a39db4e..4a27837dd6 100644 --- a/src/skeleton/frontend.spec.ts +++ b/src/skeleton/frontend.spec.ts @@ -29,11 +29,11 @@ describe("resolveSpatiallyIndexedSkeletonSegmentPick", () => { indices: new Uint32Array([0, 1, 1, 2]), numVertices: 3, }; - const segmentIds = new Uint32Array([11, 13, 17]); + const segmentIds = new BigUint64Array([11n, 13n, 17n]); expect( resolveSpatiallyIndexedSkeletonSegmentPick(chunk, segmentIds, 1, "node"), - ).toBe(13); + ).toBe(13n); }); it("returns the first valid endpoint segment id for direct edge picks", () => { @@ -41,14 +41,14 @@ describe("resolveSpatiallyIndexedSkeletonSegmentPick", () => { indices: new Uint32Array([0, 1, 1, 2]), numVertices: 3, }; - const segmentIds = new Uint32Array([0, 19, 23]); + const segmentIds = new BigUint64Array([0n, 19n, 23n]); expect( resolveSpatiallyIndexedSkeletonSegmentPick(chunk, segmentIds, 0, "edge"), - ).toBe(19); + ).toBe(19n); expect( resolveSpatiallyIndexedSkeletonSegmentPick(chunk, segmentIds, 1, "edge"), - ).toBe(19); + ).toBe(19n); }); it("returns undefined for out-of-range direct picks", () => { @@ -56,7 +56,7 @@ describe("resolveSpatiallyIndexedSkeletonSegmentPick", () => { indices: new Uint32Array([0, 1]), numVertices: 2, }; - const segmentIds = new Uint32Array([5, 7]); + const segmentIds = new BigUint64Array([5n, 7n]); expect( resolveSpatiallyIndexedSkeletonSegmentPick(chunk, segmentIds, 4, "node"), @@ -70,7 +70,7 @@ describe("resolveSpatiallyIndexedSkeletonSegmentPick", () => { describe("SpatiallyIndexedSkeletonLayer browse node picks", () => { it("resolves browse node picks with node id and source state", () => { const positions = new Float32Array([1, 2, 3, 4, 5, 6]); - const segmentIds = new Uint32Array([11, 17]); + const segmentIds = new BigUint64Array([11n, 17n]); const vertexBytes = new Uint8Array( positions.byteLength + segmentIds.byteLength, ); @@ -81,7 +81,7 @@ describe("SpatiallyIndexedSkeletonLayer browse node picks", () => { vertexAttributeOffsets: new Uint32Array([0, positions.byteLength]), numVertices: 2, indices: new Uint32Array([0, 1]), - nodeIds: new Int32Array([101, 202]), + nodeIds: new BigUint64Array([101n, 202n]), nodeSourceStates: [ { revisionToken: "2026-03-29T11:50:00Z" }, { revisionToken: "2026-03-29T11:51:00Z" }, @@ -90,19 +90,96 @@ describe("SpatiallyIndexedSkeletonLayer browse node picks", () => { const layer = Object.create(SpatiallyIndexedSkeletonLayer.prototype); expect((layer as any).resolveNodePickFromChunk(chunk, 1)).toEqual({ - nodeId: 202, - segmentId: 17, + nodeId: 202n, + segmentId: 17n, position: new Float32Array([4, 5, 6]), sourceState: { revisionToken: "2026-03-29T11:51:00Z" }, }); }); }); +describe("SpatiallyIndexedSkeletonLayer getNodes", () => { + it("matches safe bigint segment filters without rounding through an unsafe number", () => { + const layer = Object.assign( + Object.create(SpatiallyIndexedSkeletonLayer.prototype), + { + inspectionState: { + getCachedSegmentNodes: (segmentId: bigint) => + segmentId === 17n + ? [ + { + nodeId: 101n, + segmentId: 17n, + position: new Float32Array([1, 2, 3]), + }, + ] + : undefined, + }, + getCachedNodeInfo: (nodeId: bigint) => + nodeId === 101n + ? { + nodeId: 101n, + segmentId: 17n, + position: new Float32Array([1, 2, 3]), + } + : undefined, + getPendingNodePositionOverride: undefined, + }, + ); + + expect(layer.getNodes({ segmentId: 17n })).toEqual([ + { + nodeId: 101n, + segmentId: 17n, + position: new Float32Array([1, 2, 3]), + }, + ]); + }); + + it("matches large bigint segment filters exactly", () => { + const largeSegmentId = 9007199254740993n; + const layer = Object.assign( + Object.create(SpatiallyIndexedSkeletonLayer.prototype), + { + inspectionState: { + getCachedSegmentNodes: (segmentId: bigint) => + segmentId === largeSegmentId + ? [ + { + nodeId: largeSegmentId + 1n, + segmentId: largeSegmentId, + position: new Float32Array([1, 2, 3]), + }, + ] + : undefined, + }, + getCachedNodeInfo: (nodeId: bigint) => + nodeId === largeSegmentId + 1n + ? { + nodeId: largeSegmentId + 1n, + segmentId: largeSegmentId, + position: new Float32Array([1, 2, 3]), + } + : undefined, + getPendingNodePositionOverride: undefined, + }, + ); + + expect(layer.getNodes({ segmentId: largeSegmentId })).toEqual([ + { + nodeId: largeSegmentId + 1n, + segmentId: largeSegmentId, + position: new Float32Array([1, 2, 3]), + }, + ]); + }); +}); + describe("spatiallyIndexedSkeletonTextureAttributeSpecs", () => { it("keeps the browse path upload layout to position plus segment", () => { expect(spatiallyIndexedSkeletonTextureAttributeSpecs).toEqual([ { name: "position", dataType: DataType.FLOAT32, numComponents: 3 }, - { name: "segment", dataType: DataType.UINT32, numComponents: 1 }, + { name: "segment", dataType: DataType.UINT64, numComponents: 1 }, ]); }); }); @@ -112,7 +189,7 @@ describe("SpatiallyIndexedSkeletonLayer browse exclusions", () => { const layer = Object.assign( Object.create(SpatiallyIndexedSkeletonLayer.prototype), { - suppressedBrowseSegmentIds: new Set(), + suppressedBrowseSegmentIds: new Set(), browseExcludedSegments: new Uint64Set(), browseExcludedSegmentsKey: undefined, redrawNeeded: { dispatch: vi.fn() }, @@ -120,7 +197,7 @@ describe("SpatiallyIndexedSkeletonLayer browse exclusions", () => { }, ); - expect(layer.suppressBrowseSegment(29)).toBe(true); + expect(layer.suppressBrowseSegment(29n)).toBe(true); expect(layer.redrawNeeded.dispatch).toHaveBeenCalledTimes(1); const excludedSegments = (layer as any).getBrowsePassExcludedSegments(); diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index 2c9a672f56..2b3fbc9c36 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -64,6 +64,7 @@ import { } from "#src/segmentation_display_state/frontend.js"; import { SharedWatchableValue } from "#src/shared_watchable_value.js"; import type { + SpatialSkeletonId, SpatiallyIndexedSkeletonNode, SpatialSkeletonSourceState, } from "#src/skeleton/api.js"; @@ -118,6 +119,7 @@ import { } from "#src/trackable_value.js"; import { Uint64Set } from "#src/uint64_set.js"; import { gatherUpdate } from "#src/util/array.js"; +import { compareUint64Ids } from "#src/util/bigint.js"; import { hsvToRgb } from "#src/util/colorspace.js"; import { DATA_TYPE_SIGNED, DataType } from "#src/util/data_type.js"; import { RefCounted } from "#src/util/disposable.js"; @@ -214,7 +216,7 @@ const vertexPositionTextureFormat = computeTextureFormat( ); const segmentTextureFormat = computeTextureFormat( new TextureFormat(), - DataType.UINT32, + DataType.UINT64, 1, ); const selectedNodeTextureFormat = computeTextureFormat( @@ -237,10 +239,10 @@ interface SkeletonChunkInterface { indexBuffer: GLBuffer; numIndices: number; numVertices: number; - pickNodeIds?: Int32Array; + pickNodeIds?: BigUint64Array; pickNodePositions?: Float32Array; - pickSegmentIds?: Uint32Array; - pickEdgeSegmentIds?: Uint32Array; + pickSegmentIds?: BigUint64Array; + pickEdgeSegmentIds?: BigUint64Array; } interface SkeletonChunkData { @@ -249,20 +251,20 @@ interface SkeletonChunkData { numVertices: number; vertexAttributeOffsets: Uint32Array; lod?: number; - nodeIds?: Int32Array; + nodeIds?: BigUint64Array; nodeSourceStates?: Array; } type SpatiallyIndexedSkeletonPickData = | { kind: "node"; - nodeIds: Int32Array; + nodeIds: BigUint64Array; nodePositions: Float32Array; - segmentIds: Uint32Array; + segmentIds: BigUint64Array; } | { kind: "edge"; - segmentIds: Uint32Array; + segmentIds: BigUint64Array; } | { kind: "segment-node"; @@ -355,8 +357,8 @@ class RenderHelper extends RefCounted { builder.addUniform("highp uint", "uUseSegmentDefaultColor"); builder.addUniform("highp uint", "uUseSegmentStatedColors"); builder.addFragmentCode(` -uint64_t getSegmentAppearanceId(highp uint segmentValue) { - return uint64_t(uvec2(segmentValue, 0u)); +uint64_t getSegmentAppearanceId(highp uvec2 segmentValue) { + return uint64_t(segmentValue); } vec3 getSegmentLookupColor(uint64_t segmentId) { vec4 statedColor; @@ -378,7 +380,7 @@ float getSegmentLookupAlpha(uint64_t segmentId) { bool isVisible = ${this.visibleSegmentsShaderManager.hasFunctionName}(segmentId); ${alphaExpression} } -vec4 getSegmentAppearance(highp uint segmentValue) { +vec4 getSegmentAppearance(highp uvec2 segmentValue) { uint64_t segmentId = getSegmentAppearanceId(segmentValue); return vec4(getSegmentLookupColor(segmentId), getSegmentLookupAlpha(segmentId)); } @@ -530,7 +532,7 @@ vec4 getSegmentAppearance(highp uint segmentValue) { builder.addUniform("highp uint", "uPickInstanceStride"); builder.addVarying("highp uint", "vPickID", "flat"); if (this.dynamicSegmentAppearance) { - builder.addVarying("highp uint", "vSegmentValue", "flat"); + builder.addVarying("highp uvec2", "vSegmentValue", "flat"); } let vertexMain = ` highp uint pickOffset = uint(gl_InstanceID) * uPickInstanceStride; @@ -545,7 +547,7 @@ highp uint vertexIndex = aVertexIndex.x * (1u - lineEndpointIndex) + aVertexInde this.dynamicSegmentAppearance && this.segmentAttributeIndex !== undefined ) { - vertexMain += `vSegmentValue = toRaw(readAttribute${this.segmentAttributeIndex}(aVertexIndex.x));\n`; + vertexMain += `vSegmentValue = readAttribute${this.segmentAttributeIndex}(aVertexIndex.x).value;\n`; } const segmentColorExpression = this.getSegmentColorExpression(); @@ -683,7 +685,7 @@ void emitDefault() { builder.addUniform("highp uint", "uPickInstanceStride"); builder.addVarying("highp uint", "vPickID", "flat"); if (this.dynamicSegmentAppearance) { - builder.addVarying("highp uint", "vSegmentValue", "flat"); + builder.addVarying("highp uvec2", "vSegmentValue", "flat"); } const selectedOutlineMinWidth = this.targetIsSliceView ? SELECTED_NODE_OUTLINE_MIN_WIDTH_2D @@ -714,7 +716,7 @@ emitCircle( this.dynamicSegmentAppearance && this.segmentAttributeIndex !== undefined ) { - vertexMain += `vSegmentValue = toRaw(readAttribute${this.segmentAttributeIndex}(vertexIndex));\n`; + vertexMain += `vSegmentValue = readAttribute${this.segmentAttributeIndex}(vertexIndex).value;\n`; } const segmentColorExpression = this.getSegmentColorExpression(); @@ -1528,11 +1530,11 @@ const vertexPositionAttribute: VertexAttributeRenderInfo = { }; const segmentAttribute: VertexAttributeRenderInfo = { - dataType: DataType.UINT32, + dataType: DataType.UINT64, numComponents: 1, name: "segment", webglDataType: WebGL2RenderingContext.UNSIGNED_INT, - glslDataType: getShaderType(DataType.UINT32, 1), + glslDataType: getShaderType(DataType.UINT64, 1), }; const selectedNodeAttribute: VertexAttributeRenderInfo = { @@ -1605,7 +1607,7 @@ export class SpatiallyIndexedSkeletonChunk numVertices: number; vertexAttributeOffsets: Uint32Array; vertexAttributeTextures: (WebGLTexture | null)[] = []; - nodeIds: Int32Array = new Int32Array(0); + nodeIds: BigUint64Array = new BigUint64Array(0); nodeSourceStates: Array = []; lod: number | undefined; @@ -1621,16 +1623,16 @@ export class SpatiallyIndexedSkeletonChunk this.vertexAttributeOffsets = chunkData.vertexAttributeOffsets; this.lod = (chunkData as any).lod; const nodeIdsData = (chunkData as any).nodeIds; - if (nodeIdsData instanceof Int32Array) { + if (nodeIdsData instanceof BigUint64Array) { this.nodeIds = nodeIdsData; } else if (ArrayBuffer.isView(nodeIdsData)) { - this.nodeIds = new Int32Array( + this.nodeIds = new BigUint64Array( nodeIdsData.buffer, nodeIdsData.byteOffset, - nodeIdsData.byteLength / Int32Array.BYTES_PER_ELEMENT, + nodeIdsData.byteLength / BigUint64Array.BYTES_PER_ELEMENT, ); } else { - this.nodeIds = new Int32Array(0); + this.nodeIds = new BigUint64Array(0); } const nodeSourceStates = (chunkData as any).nodeSourceStates; this.nodeSourceStates = Array.isArray(nodeSourceStates) @@ -1767,10 +1769,14 @@ interface SpatiallyIndexedSkeletonLayerOptions { gridLevel2d?: WatchableValueInterface; lod2d?: WatchableValueInterface; sources2d?: SpatiallyIndexedSkeletonSourceEntry[]; - selectedNodeId?: WatchableValueInterface; + selectedNodeId?: WatchableValueInterface; pendingNodePositionVersion?: WatchableValueInterface; - getPendingNodePosition?: (nodeId: number) => ArrayLike | undefined; - getCachedNode?: (nodeId: number) => SpatiallyIndexedSkeletonNode | undefined; + getPendingNodePosition?: ( + nodeId: SpatialSkeletonId, + ) => ArrayLike | undefined; + getCachedNode?: ( + nodeId: SpatialSkeletonId, + ) => SpatiallyIndexedSkeletonNode | undefined; inspectionState?: SpatiallyIndexedSkeletonInspectionState; maxRetainedOverlaySegments?: number; } @@ -1779,13 +1785,13 @@ interface SpatiallyIndexedSkeletonInspectionState { readonly nodeDataVersion: WatchableValueInterface; readonly pendingNodePositionVersion: WatchableValueInterface; getCachedSegmentNodes( - segmentId: number, + segmentId: SpatialSkeletonId, ): readonly SpatiallyIndexedSkeletonNode[] | undefined; getFullSegmentNodes( skeletonLayer: SpatiallyIndexedSkeletonLayer, - segmentId: number, + segmentId: SpatialSkeletonId, ): Promise; - evictInactiveSegmentNodes(activeSegmentIds: Iterable): void; + evictInactiveSegmentNodes(activeSegmentIds: Iterable): void; } interface SpatiallyIndexedSkeletonOverlayChunk extends SkeletonChunkInterface { @@ -1927,25 +1933,25 @@ export class SpatiallyIndexedSkeletonLayer gridLevel2d: WatchableValueInterface; lod2d: WatchableValueInterface; private selectedNodeId: - | WatchableValueInterface + | WatchableValueInterface | undefined; private pendingNodePositionVersion: | WatchableValueInterface | undefined; private getPendingNodePositionOverride: - | ((nodeId: number) => ArrayLike | undefined) + | ((nodeId: SpatialSkeletonId) => ArrayLike | undefined) | undefined; private getCachedNodeInfo: - | ((nodeId: number) => SpatiallyIndexedSkeletonNode | undefined) + | ((nodeId: SpatialSkeletonId) => SpatiallyIndexedSkeletonNode | undefined) | undefined; private inspectionState: SpatiallyIndexedSkeletonInspectionState | undefined; private overlayChunk: SpatiallyIndexedSkeletonOverlayChunk | undefined; private overlayChunkKey: string | undefined; - private pendingOverlaySegmentLoads = new Set(); + private pendingOverlaySegmentLoads = new Set(); private browseExcludedSegments = new Uint64Set(); private browseExcludedSegmentsKey: string | undefined; - private suppressedBrowseSegmentIds = new Set(); - private retainedOverlaySegmentIds: number[] = []; + private suppressedBrowseSegmentIds = new Set(); + private retainedOverlaySegmentIds: SpatialSkeletonId[] = []; private maxRetainedOverlaySegments: number; private *iterateUniqueChunkSources() { @@ -1964,7 +1970,7 @@ export class SpatiallyIndexedSkeletonLayer this.overlayChunkKey = undefined; } - private requestOverlaySegmentLoad(segmentId: number) { + private requestOverlaySegmentLoad(segmentId: SpatialSkeletonId) { if ( this.inspectionState === undefined || this.pendingOverlaySegmentLoads.has(segmentId) @@ -1982,7 +1988,7 @@ export class SpatiallyIndexedSkeletonLayer }); } - private getOverlayChunkKey(segmentIds: readonly number[]) { + private getOverlayChunkKey(segmentIds: readonly SpatialSkeletonId[]) { return [ segmentIds.join(","), `selected:${this.selectedNodeId?.value ?? ""}`, @@ -1995,18 +2001,12 @@ export class SpatiallyIndexedSkeletonLayer const segments = getVisibleSegments( this.displayState.segmentationGroupState.value, ); - const segmentIds: number[] = []; + const segmentIds: SpatialSkeletonId[] = []; for (const segmentId of segments.keys()) { - const normalizedSegmentId = Number(segmentId); - if ( - !Number.isSafeInteger(normalizedSegmentId) || - normalizedSegmentId <= 0 - ) { - continue; - } - segmentIds.push(normalizedSegmentId); + if (segmentId <= 0n) continue; + segmentIds.push(segmentId); } - segmentIds.sort((a, b) => a - b); + segmentIds.sort(compareUint64Ids); return segmentIds; } @@ -2014,7 +2014,7 @@ export class SpatiallyIndexedSkeletonLayer return this.retainedOverlaySegmentIds; } - retainOverlaySegment(segmentId: number) { + retainOverlaySegment(segmentId: SpatialSkeletonId) { const nextRetainedOverlaySegmentIds = retainSpatiallyIndexedSkeletonOverlaySegment( this.retainedOverlaySegmentIds, @@ -2036,16 +2036,11 @@ export class SpatiallyIndexedSkeletonLayer return true; } - suppressBrowseSegment(segmentId: number) { - const normalizedSegmentId = Math.round(Number(segmentId)); - if ( - !Number.isSafeInteger(normalizedSegmentId) || - normalizedSegmentId <= 0 || - this.suppressedBrowseSegmentIds.has(normalizedSegmentId) - ) { + suppressBrowseSegment(segmentId: SpatialSkeletonId) { + if (segmentId <= 0n || this.suppressedBrowseSegmentIds.has(segmentId)) { return false; } - this.suppressedBrowseSegmentIds.add(normalizedSegmentId); + this.suppressedBrowseSegmentIds.add(segmentId); this.redrawNeeded.dispatch(); return true; } @@ -2058,7 +2053,7 @@ export class SpatiallyIndexedSkeletonLayer } private getLoadedOverlaySegmentIds( - segmentIds: readonly number[] = this.getOverlayRenderSegmentIds(), + segmentIds: readonly SpatialSkeletonId[] = this.getOverlayRenderSegmentIds(), ) { if (this.inspectionState === undefined) { return []; @@ -2070,28 +2065,16 @@ export class SpatiallyIndexedSkeletonLayer } private getNormalizedBrowsePassExcludedSegmentIds() { - const segmentIds = new Set(); + const segmentIds = new Set(); for (const segmentId of this.getLoadedOverlaySegmentIds()) { - const normalizedSegmentId = Math.round(Number(segmentId)); - if ( - !Number.isSafeInteger(normalizedSegmentId) || - normalizedSegmentId <= 0 - ) { - continue; - } - segmentIds.add(normalizedSegmentId); + if (segmentId <= 0n) continue; + segmentIds.add(segmentId); } for (const segmentId of this.suppressedBrowseSegmentIds) { - const normalizedSegmentId = Math.round(Number(segmentId)); - if ( - !Number.isSafeInteger(normalizedSegmentId) || - normalizedSegmentId <= 0 - ) { - continue; - } - segmentIds.add(normalizedSegmentId); + if (segmentId <= 0n) continue; + segmentIds.add(segmentId); } - return [...segmentIds].sort((a, b) => a - b); + return [...segmentIds].sort(compareUint64Ids); } private getBrowsePassExcludedSegments() { @@ -2106,13 +2089,7 @@ export class SpatiallyIndexedSkeletonLayer const excludedSegmentsKey = segmentIds.join(","); if (this.browseExcludedSegmentsKey !== excludedSegmentsKey) { this.browseExcludedSegments.clear(); - this.browseExcludedSegments.add( - segmentIds - .filter( - (segmentId) => Number.isSafeInteger(segmentId) && segmentId > 0, - ) - .map((segmentId) => BigInt(segmentId)), - ); + this.browseExcludedSegments.add(segmentIds); this.browseExcludedSegmentsKey = excludedSegmentsKey; } return this.browseExcludedSegments; @@ -2133,7 +2110,7 @@ export class SpatiallyIndexedSkeletonLayer this.inspectionState.evictInactiveSegmentNodes(overlaySegmentIds); // Pass 1: cheap scan to determine which segments are loaded and check cache. - const loadedSegmentIds: number[] = []; + const loadedSegmentIds: SpatialSkeletonId[] = []; for (const segmentId of overlaySegmentIds) { if (this.inspectionState.getCachedSegmentNodes(segmentId) !== undefined) { loadedSegmentIds.push(segmentId); @@ -2414,7 +2391,7 @@ export class SpatiallyIndexedSkeletonLayer ); } - private getCachedNodeSnapshot(nodeId: number) { + private getCachedNodeSnapshot(nodeId: SpatialSkeletonId) { const cachedNode = this.getCachedNodeInfo?.(nodeId); if (cachedNode === undefined) { return undefined; @@ -2455,10 +2432,12 @@ export class SpatiallyIndexedSkeletonLayer chunk.vertexAttributes.byteOffset + offsets[0], chunk.numVertices * 3, ); - const segmentIds = new Uint32Array( - chunk.vertexAttributes.buffer, - chunk.vertexAttributes.byteOffset + offsets[1], - chunk.numVertices, + const segmentIds = new BigUint64Array(chunk.numVertices); + new Uint8Array(segmentIds.buffer).set( + chunk.vertexAttributes.subarray( + offsets[1], + offsets[1] + chunk.numVertices * BigUint64Array.BYTES_PER_ELEMENT, + ), ); return { positions, segmentIds }; } @@ -2494,7 +2473,7 @@ export class SpatiallyIndexedSkeletonLayer return undefined; } const nodeId = chunk.nodeIds[pickedOffset]; - if (!Number.isSafeInteger(nodeId) || nodeId <= 0) { + if (nodeId <= 0n) { return undefined; } const segmentId = resolveSpatiallyIndexedSkeletonSegmentPick( @@ -2621,13 +2600,13 @@ export class SpatiallyIndexedSkeletonLayer } getNode( - nodeId: number, + nodeId: SpatialSkeletonId, options: { lod?: number; } = {}, ): SpatiallyIndexedSkeletonNode | undefined { void options.lod; - if (!Number.isSafeInteger(nodeId) || nodeId <= 0) return undefined; + if (nodeId <= 0n) return undefined; return this.getCachedNodeSnapshot(nodeId); } @@ -2638,18 +2617,18 @@ export class SpatiallyIndexedSkeletonLayer } = {}, ): SpatiallyIndexedSkeletonNode[] { void options.lod; - const normalizedSegmentFilter = - options.segmentId === undefined - ? undefined - : Math.round(Number(options.segmentId)); - const useSegmentFilter = + const normalizedSegmentFilter = options.segmentId; + if ( normalizedSegmentFilter !== undefined && - Number.isFinite(normalizedSegmentFilter); + normalizedSegmentFilter <= 0n + ) { + return []; + } const segmentIds = normalizedSegmentFilter === undefined ? this.getActiveEditableSegmentIds() : [normalizedSegmentFilter]; - const nodes = new Map(); + const nodes = new Map(); for (const segmentId of segmentIds) { const segmentNodes = this.inspectionState?.getCachedSegmentNodes(segmentId) ?? []; @@ -2658,7 +2637,6 @@ export class SpatiallyIndexedSkeletonLayer const cachedNode = this.getCachedNodeSnapshot(node.nodeId); if (cachedNode === undefined) continue; if ( - useSegmentFilter && normalizedSegmentFilter !== undefined && cachedNode.segmentId !== normalizedSegmentFilter ) { @@ -2667,7 +2645,9 @@ export class SpatiallyIndexedSkeletonLayer nodes.set(cachedNode.nodeId, cachedNode); } } - return [...nodes.values()].sort((a, b) => a.nodeId - b.nodeId); + return [...nodes.values()].sort((a, b) => + compareUint64Ids(a.nodeId, b.nodeId), + ); } private drawBrowsePass( @@ -3090,11 +3070,8 @@ export class PerspectiveViewSpatiallyIndexedSkeletonLayer extends PerspectiveVie transformPickedValue(pickState: PickState) { const pickedSegmentId = pickState.pickedSpatialSkeleton?.segmentId; - if ( - typeof pickedSegmentId === "number" && - Number.isSafeInteger(pickedSegmentId) - ) { - return BigInt(pickedSegmentId); + if (typeof pickedSegmentId === "bigint") { + return pickedSegmentId; } return undefined; } @@ -3116,19 +3093,19 @@ export class PerspectiveViewSpatiallyIndexedSkeletonLayer extends PerspectiveVie return; } const segmentId = pickData.segmentIds[pickedOffset]; - if (!Number.isSafeInteger(segmentId) || segmentId <= 0) { + if (segmentId <= 0n) { return; } mouseState.pickedSpatialSkeleton = { segmentId }; if ( !getVisibleSegments( this.base.displayState.segmentationGroupState.value, - ).has(BigInt(segmentId)) + ).has(segmentId) ) { return; } const nodeId = pickData.nodeIds[pickedOffset]; - if (!Number.isSafeInteger(nodeId) || nodeId <= 0) return; + if (nodeId <= 0n) return; const nodePosition = pickData.nodePositions.subarray( pickedOffset * 3, pickedOffset * 3 + 3, @@ -3153,7 +3130,7 @@ export class PerspectiveViewSpatiallyIndexedSkeletonLayer extends PerspectiveVie return; } const segmentId = pickData.segmentIds[pickedOffset]; - if (Number.isSafeInteger(segmentId) && segmentId > 0) { + if (segmentId > 0n) { mouseState.pickedSpatialSkeleton = { segmentId }; } return; @@ -3356,11 +3333,8 @@ export class SliceViewPanelSpatiallyIndexedSkeletonLayer extends SliceViewPanelR transformPickedValue(pickState: PickState) { const pickedSegmentId = pickState.pickedSpatialSkeleton?.segmentId; - if ( - typeof pickedSegmentId === "number" && - Number.isSafeInteger(pickedSegmentId) - ) { - return BigInt(pickedSegmentId); + if (typeof pickedSegmentId === "bigint") { + return pickedSegmentId; } return undefined; } @@ -3382,19 +3356,19 @@ export class SliceViewPanelSpatiallyIndexedSkeletonLayer extends SliceViewPanelR return; } const segmentId = pickData.segmentIds[pickedOffset]; - if (!Number.isSafeInteger(segmentId) || segmentId <= 0) { + if (segmentId <= 0n) { return; } mouseState.pickedSpatialSkeleton = { segmentId }; if ( !getVisibleSegments( this.base.displayState.segmentationGroupState.value, - ).has(BigInt(segmentId)) + ).has(segmentId) ) { return; } const nodeId = pickData.nodeIds[pickedOffset]; - if (!Number.isSafeInteger(nodeId) || nodeId <= 0) return; + if (nodeId <= 0n) return; const nodePosition = pickData.nodePositions.subarray( pickedOffset * 3, pickedOffset * 3 + 3, @@ -3419,7 +3393,7 @@ export class SliceViewPanelSpatiallyIndexedSkeletonLayer extends SliceViewPanelR return; } const segmentId = pickData.segmentIds[pickedOffset]; - if (Number.isSafeInteger(segmentId) && segmentId > 0) { + if (segmentId > 0n) { mouseState.pickedSpatialSkeleton = { segmentId }; } return; diff --git a/src/skeleton/navigation.spec.ts b/src/skeleton/navigation.spec.ts index f4bc30a436..999adea606 100644 --- a/src/skeleton/navigation.spec.ts +++ b/src/skeleton/navigation.spec.ts @@ -30,8 +30,8 @@ import { } from "#src/skeleton/navigation.js"; function makeNode( - nodeId: number, - parentNodeId: number | undefined, + nodeId: bigint, + parentNodeId: bigint | undefined, options: { description?: string; isTrueEnd?: boolean; @@ -39,8 +39,12 @@ function makeNode( ): SpatiallyIndexedSkeletonNode { return { nodeId, - segmentId: 42, - position: new Float32Array([nodeId, nodeId + 0.5, nodeId + 1]), + segmentId: 42n, + position: new Float32Array([ + Number(nodeId), + Number(nodeId) + 0.5, + Number(nodeId) + 1, + ]), parentNodeId, description: options.description, isTrueEnd: options.isTrueEnd ?? false, @@ -49,149 +53,159 @@ function makeNode( describe("skeleton/navigation", () => { const graph = buildSpatiallyIndexedSkeletonNavigationGraph([ - makeNode(1, undefined), - makeNode(2, 1), - makeNode(3, 2), - makeNode(4, 3), - makeNode(5, 4, { description: "checkpoint" }), - makeNode(6, 5), - makeNode(7, 3), - makeNode(8, 3), - makeNode(9, 8), - makeNode(10, 9, { isTrueEnd: true }), - makeNode(11, 9), + makeNode(1n, undefined), + makeNode(2n, 1n), + makeNode(3n, 2n), + makeNode(4n, 3n), + makeNode(5n, 4n, { description: "checkpoint" }), + makeNode(6n, 5n), + makeNode(7n, 3n), + makeNode(8n, 3n), + makeNode(9n, 8n), + makeNode(10n, 9n, { isTrueEnd: true }), + makeNode(11n, 9n), ]); 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, 1).nodeId).toBe(1); + expect(getSkeletonRootNode(graph).nodeId).toBe(1n); + expect(getBranchStart(graph, 6n).nodeId).toBe(3n); + expect(getBranchStart(graph, 3n).nodeId).toBe(3n); + expect(getBranchStart(graph, 2n).nodeId).toBe(2n); + expect(getBranchStart(graph, 1n).nodeId).toBe(1n); }); it("prefers a downstream branch over a leaf for branch-end navigation", () => { const preferenceGraph = buildSpatiallyIndexedSkeletonNavigationGraph([ - makeNode(1, undefined), - makeNode(2, 1), - makeNode(3, 1), - makeNode(4, 3), - makeNode(5, 4), - makeNode(6, 4), + makeNode(1n, undefined), + makeNode(2n, 1n), + makeNode(3n, 1n), + makeNode(4n, 3n), + makeNode(5n, 4n), + makeNode(6n, 4n), ]); - expect(getBranchEnd(preferenceGraph, 1).nodeId).toBe(4); - expect(getBranchEnd(preferenceGraph, 3).nodeId).toBe(4); - expect(getBranchEnd(preferenceGraph, 2).nodeId).toBe(2); + expect(getBranchEnd(preferenceGraph, 1n).nodeId).toBe(4n); + expect(getBranchEnd(preferenceGraph, 3n).nodeId).toBe(4n); + expect(getBranchEnd(preferenceGraph, 2n).nodeId).toBe(2n); }); it("orders flat-list rows in leaf-first pre-order", () => { const listGraph = buildSpatiallyIndexedSkeletonNavigationGraph([ - makeNode(1, undefined), - makeNode(2, 1), - makeNode(3, 1), - makeNode(4, 1), - makeNode(5, 2), - makeNode(6, 4), - makeNode(7, 4), - makeNode(8, 1, { isTrueEnd: true }), - makeNode(9, 8), + makeNode(1n, undefined), + makeNode(2n, 1n), + makeNode(3n, 1n), + makeNode(4n, 1n), + makeNode(5n, 2n), + makeNode(6n, 4n), + makeNode(7n, 4n), + makeNode(8n, 1n, { isTrueEnd: true }), + makeNode(9n, 8n), ]); - expect(getFlatListNodeIds(listGraph)).toEqual([1, 3, 8, 9, 4, 6, 7, 2, 5]); + expect(getFlatListNodeIds(listGraph)).toEqual([ + 1n, + 3n, + 8n, + 9n, + 4n, + 6n, + 7n, + 2n, + 5n, + ]); }); it("orders flat-list rows by collapsed branches in leaf-first pre-order", () => { const listGraph = buildSpatiallyIndexedSkeletonNavigationGraph([ - makeNode(1, undefined), - makeNode(2, 1), - makeNode(3, 2), - makeNode(4, 3), - makeNode(5, 3), - makeNode(6, 2), - makeNode(7, 6), + makeNode(1n, undefined), + makeNode(2n, 1n), + makeNode(3n, 2n), + makeNode(4n, 3n), + makeNode(5n, 3n), + makeNode(6n, 2n), + makeNode(7n, 6n), ]); expect( getFlatListNodeIds(listGraph, { collapseRegularNodesForOrdering: true, }), - ).toEqual([1, 2, 6, 7, 3, 4, 5]); + ).toEqual([1n, 2n, 6n, 7n, 3n, 4n, 5n]); }); it("keeps a branch adjacent to its own leaf-first descendants", () => { const listGraph = buildSpatiallyIndexedSkeletonNavigationGraph([ - makeNode(1, undefined), - makeNode(2, 1), - makeNode(3, 1), - makeNode(4, 2), - makeNode(5, 2), - makeNode(6, 3), - makeNode(7, 3), + makeNode(1n, undefined), + makeNode(2n, 1n), + makeNode(3n, 1n), + makeNode(4n, 2n), + makeNode(5n, 2n), + makeNode(6n, 3n), + makeNode(7n, 3n), ]); expect( getFlatListNodeIds(listGraph, { collapseRegularNodesForOrdering: true, }), - ).toEqual([1, 2, 4, 5, 3, 6, 7]); + ).toEqual([1n, 2n, 4n, 5n, 3n, 6n, 7n]); }); it("returns deterministic direct parent and child navigation targets", () => { - expect(getParentNode(graph, 6)?.nodeId).toBe(5); - expect(getParentNode(graph, 1)).toBeUndefined(); - expect(getChildNode(graph, 3)?.nodeId).toBe(7); - expect(getChildNode(graph, 11)).toBeUndefined(); + expect(getParentNode(graph, 6n)?.nodeId).toBe(5n); + expect(getParentNode(graph, 1n)).toBeUndefined(); + expect(getChildNode(graph, 3n)?.nodeId).toBe(7n); + expect(getChildNode(graph, 11n)).toBeUndefined(); }); it("cycles through collapsed-level nodes and skips regular nodes", () => { const collapsedGraph = buildSpatiallyIndexedSkeletonNavigationGraph([ - makeNode(1, undefined), - makeNode(2, 1), - makeNode(3, 2), - makeNode(4, 1), - makeNode(5, 1), - makeNode(6, 4), - makeNode(7, 4), + makeNode(1n, undefined), + makeNode(2n, 1n), + makeNode(3n, 2n), + makeNode(4n, 1n), + makeNode(5n, 1n), + makeNode(6n, 4n), + makeNode(7n, 4n), ]); - expect(getNextCollapsedLevelNode(collapsedGraph, 1).nodeId).toBe(1); - expect(getNextCollapsedLevelNode(collapsedGraph, 2).nodeId).toBe(2); - expect(getNextCollapsedLevelNode(collapsedGraph, 5).nodeId).toBe(4); - expect(getNextCollapsedLevelNode(collapsedGraph, 4).nodeId).toBe(3); - expect(getNextCollapsedLevelNode(collapsedGraph, 3).nodeId).toBe(5); + expect(getNextCollapsedLevelNode(collapsedGraph, 1n).nodeId).toBe(1n); + expect(getNextCollapsedLevelNode(collapsedGraph, 2n).nodeId).toBe(2n); + expect(getNextCollapsedLevelNode(collapsedGraph, 5n).nodeId).toBe(4n); + expect(getNextCollapsedLevelNode(collapsedGraph, 4n).nodeId).toBe(3n); + expect(getNextCollapsedLevelNode(collapsedGraph, 3n).nodeId).toBe(5n); }); it("cycles collapsed-level nodes using collapsed leaf-first ordering", () => { const collapsedGraph = buildSpatiallyIndexedSkeletonNavigationGraph([ - makeNode(1, undefined), - makeNode(2, 1), - makeNode(3, 2), - makeNode(4, 3), - makeNode(5, 3), - makeNode(6, 2), - makeNode(7, 6), + makeNode(1n, undefined), + makeNode(2n, 1n), + makeNode(3n, 2n), + makeNode(4n, 3n), + makeNode(5n, 3n), + makeNode(6n, 2n), + makeNode(7n, 6n), ]); - expect(getNextCollapsedLevelNode(collapsedGraph, 6).nodeId).toBe(6); - expect(getNextCollapsedLevelNode(collapsedGraph, 7).nodeId).toBe(3); - expect(getNextCollapsedLevelNode(collapsedGraph, 3).nodeId).toBe(7); + expect(getNextCollapsedLevelNode(collapsedGraph, 6n).nodeId).toBe(6n); + expect(getNextCollapsedLevelNode(collapsedGraph, 7n).nodeId).toBe(3n); + expect(getNextCollapsedLevelNode(collapsedGraph, 3n).nodeId).toBe(7n); }); it("finds unfinished leaves from any selected node and filters closed ends", () => { expect( - getOpenLeaves(graph, 3).map((leaf) => [leaf.nodeId, leaf.distance]), + getOpenLeaves(graph, 3n).map((leaf) => [leaf.nodeId, leaf.distance]), ).toEqual([ - [7, 1], - [6, 3], - [11, 3], + [7n, 1], + [6n, 3], + [11n, 3], ]); expect( - getOpenLeaves(graph, 1).map((leaf) => [leaf.nodeId, leaf.distance]), + getOpenLeaves(graph, 1n).map((leaf) => [leaf.nodeId, leaf.distance]), ).toEqual([ - [7, 3], - [6, 5], - [11, 5], + [7n, 3], + [6n, 5], + [11n, 5], ]); }); }); diff --git a/src/skeleton/navigation.ts b/src/skeleton/navigation.ts index 425806949e..6983d12b57 100644 --- a/src/skeleton/navigation.ts +++ b/src/skeleton/navigation.ts @@ -15,12 +15,14 @@ */ import type { + SpatialSkeletonId, SpatiallyIndexedSkeletonNode, SpatialSkeletonVector, } from "#src/skeleton/api.js"; +import { compareUint64Ids } from "#src/util/bigint.js"; export interface SpatiallyIndexedSkeletonNavigationTarget { - nodeId: number; + nodeId: SpatialSkeletonId; position: SpatialSkeletonVector; } @@ -31,31 +33,31 @@ export interface SpatiallyIndexedSkeletonOpenLeaf } export interface SpatiallyIndexedSkeletonNavigationGraph { - nodeById: Map; - childrenByParent: Map; - rootNodeIds: number[]; + nodeById: Map; + childrenByParent: Map; + rootNodeIds: SpatialSkeletonId[]; } interface CollapsedChildPath { - path: readonly number[]; - representativeNodeId: number; + path: readonly SpatialSkeletonId[]; + representativeNodeId: SpatialSkeletonId; } interface CollapsedLevelContext { - levelByNodeId: Map; - nodeIdsByLevel: Map; + levelByNodeId: Map; + nodeIdsByLevel: Map; } interface NavigationGraphDerivedState { - sortPriorityByNodeId: Map; - orderedChildNodeIdsByNodeId: Map; - collapsedPathByNodeId: Map; + sortPriorityByNodeId: Map; + orderedChildNodeIdsByNodeId: Map; + collapsedPathByNodeId: Map; collapsedOrderedChildPathsByNodeId: Map< - number, + SpatialSkeletonId, readonly CollapsedChildPath[] >; - flatListNodeIds?: readonly number[]; - collapsedFlatListNodeIds?: readonly number[]; + flatListNodeIds?: readonly SpatialSkeletonId[]; + collapsedFlatListNodeIds?: readonly SpatialSkeletonId[]; collapsedLevelContext?: CollapsedLevelContext; } @@ -67,7 +69,7 @@ const navigationGraphDerivedState = new WeakMap< function buildNavigationGraphDerivedState( graph: SpatiallyIndexedSkeletonNavigationGraph, ): NavigationGraphDerivedState { - const sortPriorityByNodeId = new Map(); + const sortPriorityByNodeId = new Map(); for (const [nodeId, node] of graph.nodeById) { const childCount = graph.childrenByParent.get(nodeId)?.length ?? 0; const parentNodeId = node.parentNodeId; @@ -107,14 +109,14 @@ function getNavigationGraphDerivedState( export function buildSpatiallyIndexedSkeletonNavigationGraph( nodes: readonly SpatiallyIndexedSkeletonNode[], ): SpatiallyIndexedSkeletonNavigationGraph { - const nodeById = new Map(); + const nodeById = new Map(); for (const node of nodes) { if (!nodeById.has(node.nodeId)) { nodeById.set(node.nodeId, node); } } - const childrenByParent = new Map(); + const childrenByParent = new Map(); for (const node of nodeById.values()) { const parentNodeId = node.parentNodeId; if (parentNodeId === undefined || !nodeById.has(parentNodeId)) continue; @@ -126,19 +128,19 @@ export function buildSpatiallyIndexedSkeletonNavigationGraph( children.push(node.nodeId); } for (const children of childrenByParent.values()) { - children.sort((a, b) => a - b); + children.sort(compareUint64Ids); } - const rootNodeIds: number[] = []; + const rootNodeIds: SpatialSkeletonId[] = []; for (const node of nodeById.values()) { const parentNodeId = node.parentNodeId; if (parentNodeId === undefined || !nodeById.has(parentNodeId)) { rootNodeIds.push(node.nodeId); } } - rootNodeIds.sort((a, b) => a - b); + rootNodeIds.sort(compareUint64Ids); if (rootNodeIds.length === 0 && nodeById.size > 0) { - rootNodeIds.push([...nodeById.keys()].sort((a, b) => a - b)[0]); + rootNodeIds.push([...nodeById.keys()].sort(compareUint64Ids)[0]); } const graph = { @@ -155,7 +157,7 @@ export function buildSpatiallyIndexedSkeletonNavigationGraph( function getFlatListNodeSortPriority( graph: SpatiallyIndexedSkeletonNavigationGraph, - nodeId: number, + nodeId: SpatialSkeletonId, ) { const priority = getNavigationGraphDerivedState(graph).sortPriorityByNodeId.get(nodeId); @@ -167,13 +169,13 @@ function getFlatListNodeSortPriority( function compareFlatListNodeIds( graph: SpatiallyIndexedSkeletonNavigationGraph, - a: number, - b: number, + a: SpatialSkeletonId, + b: SpatialSkeletonId, ) { const priorityDelta = getFlatListNodeSortPriority(graph, a) - getFlatListNodeSortPriority(graph, b); - return priorityDelta !== 0 ? priorityDelta : a - b; + return priorityDelta !== 0 ? priorityDelta : compareUint64Ids(a, b); } export function getFlatListNodeIds( @@ -188,10 +190,12 @@ export function getFlatListNodeIds( return derivedState.flatListNodeIds; } - const orderedNodeIds: number[] = []; - const visited = new Set(); + const orderedNodeIds: SpatialSkeletonId[] = []; + const visited = new Set(); - const appendLeafFirstPreOrder = (startNodeIds: readonly number[]) => { + const appendLeafFirstPreOrder = ( + startNodeIds: readonly SpatialSkeletonId[], + ) => { const stack = [...startNodeIds] .sort((a, b) => compareFlatListNodeIds(graph, a, b)) .reverse(); @@ -237,7 +241,7 @@ export function getFlatListNodeIds( function getNodeOrThrow( graph: SpatiallyIndexedSkeletonNavigationGraph, - nodeId: number, + nodeId: SpatialSkeletonId, ) { const node = graph.nodeById.get(nodeId); if (node === undefined) { @@ -248,7 +252,7 @@ function getNodeOrThrow( function getNodeTarget( graph: SpatiallyIndexedSkeletonNavigationGraph, - nodeId: number, + nodeId: SpatialSkeletonId, ): SpatiallyIndexedSkeletonNavigationTarget { const node = getNodeOrThrow(graph, nodeId); return { @@ -259,14 +263,14 @@ function getNodeTarget( function getChildNodeIds( graph: SpatiallyIndexedSkeletonNavigationGraph, - nodeId: number, + nodeId: SpatialSkeletonId, ) { return graph.childrenByParent.get(nodeId) ?? []; } function getParentNodeId( graph: SpatiallyIndexedSkeletonNavigationGraph, - nodeId: number, + nodeId: SpatialSkeletonId, ) { const parentNodeId = getNodeOrThrow(graph, nodeId).parentNodeId; if (parentNodeId === undefined || !graph.nodeById.has(parentNodeId)) { @@ -277,7 +281,7 @@ function getParentNodeId( function getFlatListOrderedChildNodeIds( graph: SpatiallyIndexedSkeletonNavigationGraph, - nodeId: number, + nodeId: SpatialSkeletonId, ) { const childNodeIds = getChildNodeIds(graph, nodeId); if (childNodeIds.length <= 1) { @@ -297,7 +301,7 @@ function getFlatListOrderedChildNodeIds( function getCollapsedBranchPath( graph: SpatiallyIndexedSkeletonNavigationGraph, - nodeId: number, + nodeId: SpatialSkeletonId, ) { const derivedState = getNavigationGraphDerivedState(graph); const cached = derivedState.collapsedPathByNodeId.get(nodeId); @@ -305,7 +309,7 @@ function getCollapsedBranchPath( return cached; } const path = [nodeId]; - const visited = new Set(path); + const visited = new Set(path); let currentNodeId = nodeId; while (isCollapsedRegularNode(graph, currentNodeId)) { const nextNodeId = getChildNodeIds(graph, currentNodeId)[0]; @@ -322,7 +326,7 @@ function getCollapsedBranchPath( function getCollapsedOrderedChildPaths( graph: SpatiallyIndexedSkeletonNavigationGraph, - nodeId: number, + nodeId: SpatialSkeletonId, ) { const derivedState = getNavigationGraphDerivedState(graph); const cached = derivedState.collapsedOrderedChildPathsByNodeId.get(nodeId); @@ -349,7 +353,7 @@ function getCollapsedOrderedChildPaths( function isCollapsedRegularNode( graph: SpatiallyIndexedSkeletonNavigationGraph, - nodeId: number, + nodeId: SpatialSkeletonId, ) { const node = getNodeOrThrow(graph, nodeId); return ( @@ -361,7 +365,7 @@ function isCollapsedRegularNode( function getCollapsedChildNodeIds( graph: SpatiallyIndexedSkeletonNavigationGraph, - nodeId: number, + nodeId: SpatialSkeletonId, ) { return getCollapsedOrderedChildPaths(graph, nodeId).map( ({ representativeNodeId }) => representativeNodeId, @@ -375,11 +379,11 @@ function getCollapsedOrderedFlatListNodeIds( if (derivedState.collapsedFlatListNodeIds !== undefined) { return derivedState.collapsedFlatListNodeIds; } - const orderedNodeIds: number[] = []; - const visited = new Set(); + const orderedNodeIds: SpatialSkeletonId[] = []; + const visited = new Set(); const appendLeafFirstPreOrder = ( - startPaths: readonly (readonly number[])[], + startPaths: readonly (readonly SpatialSkeletonId[])[], ) => { const stack = [...startPaths].map((path) => [...path]).reverse(); while (stack.length > 0) { @@ -439,10 +443,10 @@ function getCollapsedLevelContext( if (derivedState.collapsedLevelContext !== undefined) { return derivedState.collapsedLevelContext; } - const levelByNodeId = new Map(); - const nodeIdsByLevel = new Map(); + const levelByNodeId = new Map(); + const nodeIdsByLevel = new Map(); const queue = graph.rootNodeIds.map((nodeId) => ({ nodeId, level: 0 })); - const visited = new Set(); + const visited = new Set(); for (let queueIndex = 0; queueIndex < queue.length; ++queueIndex) { const { nodeId, level } = queue[queueIndex]; @@ -469,7 +473,7 @@ function getCollapsedLevelContext( function getBranchEndNodeIds( graph: SpatiallyIndexedSkeletonNavigationGraph, - nodeId: number, + nodeId: SpatialSkeletonId, ) { getNodeOrThrow(graph, nodeId); const childNodeIds = getFlatListOrderedChildNodeIds(graph, nodeId); @@ -498,11 +502,11 @@ export function getSkeletonRootNode( export function getBranchStart( graph: SpatiallyIndexedSkeletonNavigationGraph, - nodeId: number, + nodeId: SpatialSkeletonId, ) { getNodeOrThrow(graph, nodeId); let currentNodeId = nodeId; - const visited = new Set([currentNodeId]); + const visited = new Set([currentNodeId]); while (true) { const parentNodeId = getParentNodeId(graph, currentNodeId); if (parentNodeId === undefined || visited.has(parentNodeId)) { @@ -518,7 +522,7 @@ export function getBranchStart( export function getBranchEnd( graph: SpatiallyIndexedSkeletonNavigationGraph, - nodeId: number, + nodeId: SpatialSkeletonId, ) { getNodeOrThrow(graph, nodeId); const branchEndNodeIds = getBranchEndNodeIds(graph, nodeId); @@ -534,7 +538,7 @@ export function getBranchEnd( export function getParentNode( graph: SpatiallyIndexedSkeletonNavigationGraph, - nodeId: number, + nodeId: SpatialSkeletonId, ) { const parentNodeId = getParentNodeId(graph, nodeId); return parentNodeId === undefined @@ -544,7 +548,7 @@ export function getParentNode( export function getChildNode( graph: SpatiallyIndexedSkeletonNavigationGraph, - nodeId: number, + nodeId: SpatialSkeletonId, ) { const childNodeId = getFlatListOrderedChildNodeIds(graph, nodeId)[0]; return childNodeId === undefined @@ -554,7 +558,7 @@ export function getChildNode( export function getRandomChildNode( graph: SpatiallyIndexedSkeletonNavigationGraph, - nodeId: number, + nodeId: SpatialSkeletonId, options: { random?: () => number } = {}, ) { const childNodeIds = getFlatListOrderedChildNodeIds(graph, nodeId); @@ -574,7 +578,7 @@ export function getRandomChildNode( export function getNextCollapsedLevelNode( graph: SpatiallyIndexedSkeletonNavigationGraph, - nodeId: number, + nodeId: SpatialSkeletonId, ) { getNodeOrThrow(graph, nodeId); if (getParentNodeId(graph, nodeId) === undefined) { @@ -602,10 +606,10 @@ export function getNextCollapsedLevelNode( export function getOpenLeaves( graph: SpatiallyIndexedSkeletonNavigationGraph, - nodeId: number, + nodeId: SpatialSkeletonId, ): SpatiallyIndexedSkeletonOpenLeaf[] { getNodeOrThrow(graph, nodeId); - const distances = new Map([[nodeId, 0]]); + const distances = new Map([[nodeId, 0]]); const queue = [nodeId]; for (let queueIndex = 0; queueIndex < queue.length; ++queueIndex) { const currentNodeId = queue[queueIndex]; @@ -652,7 +656,9 @@ export function getOpenLeaves( } leaves.sort((a, b) => - a.distance === b.distance ? a.nodeId - b.nodeId : a.distance - b.distance, + a.distance === b.distance + ? compareUint64Ids(a.nodeId, b.nodeId) + : a.distance - b.distance, ); return leaves; } diff --git a/src/skeleton/overlay_geometry.spec.ts b/src/skeleton/overlay_geometry.spec.ts index 513d17adad..3fe4284e8f 100644 --- a/src/skeleton/overlay_geometry.spec.ts +++ b/src/skeleton/overlay_geometry.spec.ts @@ -8,44 +8,44 @@ describe("buildSpatiallyIndexedSkeletonOverlayGeometry", () => { [ [ { - nodeId: 1, - segmentId: 11, + nodeId: 1n, + segmentId: 11n, position: new Float32Array([1, 2, 3]), }, { - nodeId: 2, - segmentId: 11, + nodeId: 2n, + segmentId: 11n, position: new Float32Array([4, 5, 6]), - parentNodeId: 1, + parentNodeId: 1n, }, ], [ { - nodeId: 2, - segmentId: 11, + nodeId: 2n, + segmentId: 11n, position: new Float32Array([40, 50, 60]), - parentNodeId: 1, + parentNodeId: 1n, }, { - nodeId: 3, - segmentId: 13, + nodeId: 3n, + segmentId: 13n, position: new Float32Array([7, 8, 9]), }, ], ], { - selectedNodeId: 2, + selectedNodeId: 2n, getPendingNodePosition: (nodeId) => - nodeId === 3 ? new Float32Array([70, 80, 90]) : undefined, + nodeId === 3n ? new Float32Array([70, 80, 90]) : undefined, }, ); expect(geometry.numVertices).toBe(3); - expect([...geometry.nodeIds]).toEqual([1, 2, 3]); - expect([...geometry.segmentIds]).toEqual([11, 11, 13]); + expect([...geometry.nodeIds]).toEqual([1n, 2n, 3n]); + expect([...geometry.segmentIds]).toEqual([11n, 11n, 13n]); expect([...geometry.selected]).toEqual([0, 1, 0]); expect([...geometry.positions]).toEqual([1, 2, 3, 4, 5, 6, 70, 80, 90]); expect([...geometry.indices]).toEqual([1, 0]); - expect([...geometry.pickEdgeSegmentIds]).toEqual([11]); + expect([...geometry.pickEdgeSegmentIds]).toEqual([11n]); }); }); diff --git a/src/skeleton/overlay_geometry.ts b/src/skeleton/overlay_geometry.ts index f67a8f950c..b62cb9c305 100644 --- a/src/skeleton/overlay_geometry.ts +++ b/src/skeleton/overlay_geometry.ts @@ -1,18 +1,20 @@ +import type { SpatialSkeletonId } from "#src/skeleton/api.js"; + export interface SpatiallyIndexedSkeletonOverlayNodeLike { - nodeId: number; - segmentId: number; + nodeId: SpatialSkeletonId; + segmentId: SpatialSkeletonId; position: ArrayLike; - parentNodeId?: number; + parentNodeId?: SpatialSkeletonId; } export interface SpatiallyIndexedSkeletonOverlayGeometry { positions: Float32Array; - segmentIds: Uint32Array; + segmentIds: BigUint64Array; selected: Float32Array; - nodeIds: Int32Array; + nodeIds: BigUint64Array; nodePositions: Float32Array; - pickSegmentIds: Uint32Array; - pickEdgeSegmentIds: Uint32Array; + pickSegmentIds: BigUint64Array; + pickEdgeSegmentIds: BigUint64Array; indices: Uint32Array; numVertices: number; } @@ -20,12 +22,14 @@ export interface SpatiallyIndexedSkeletonOverlayGeometry { export function buildSpatiallyIndexedSkeletonOverlayGeometry( segmentNodeSets: readonly (readonly SpatiallyIndexedSkeletonOverlayNodeLike[])[], options: { - selectedNodeId?: number; - getPendingNodePosition?: (nodeId: number) => ArrayLike | undefined; + selectedNodeId?: SpatialSkeletonId; + getPendingNodePosition?: ( + nodeId: SpatialSkeletonId, + ) => ArrayLike | undefined; } = {}, ): SpatiallyIndexedSkeletonOverlayGeometry { const { selectedNodeId, getPendingNodePosition } = options; - const nodeIndex = new Map(); + const nodeIndex = new Map(); const orderedNodes: SpatiallyIndexedSkeletonOverlayNodeLike[] = []; for (const segmentNodes of segmentNodeSets) { @@ -38,13 +42,13 @@ export function buildSpatiallyIndexedSkeletonOverlayGeometry( const numVertices = orderedNodes.length; const positions = new Float32Array(numVertices * 3); - const segmentIds = new Uint32Array(numVertices); + const segmentIds = new BigUint64Array(numVertices); const selected = new Float32Array(numVertices); - const nodeIds = new Int32Array(numVertices); + const nodeIds = new BigUint64Array(numVertices); const nodePositions = new Float32Array(numVertices * 3); - const pickSegmentIds = new Uint32Array(numVertices); + const pickSegmentIds = new BigUint64Array(numVertices); const indices: number[] = []; - const pickEdgeSegmentIds: number[] = []; + const pickEdgeSegmentIds: bigint[] = []; orderedNodes.forEach((node, index) => { const position = getPendingNodePosition?.(node.nodeId) ?? node.position; @@ -55,9 +59,9 @@ export function buildSpatiallyIndexedSkeletonOverlayGeometry( nodePositions[baseOffset] = positions[baseOffset]; nodePositions[baseOffset + 1] = positions[baseOffset + 1]; nodePositions[baseOffset + 2] = positions[baseOffset + 2]; - segmentIds[index] = Math.max(0, Math.round(Number(node.segmentId))); + segmentIds[index] = node.segmentId; pickSegmentIds[index] = segmentIds[index]; - nodeIds[index] = Math.round(Number(node.nodeId)); + nodeIds[index] = node.nodeId; selected[index] = selectedNodeId !== undefined && node.nodeId === selectedNodeId ? 1 : 0; }); @@ -66,17 +70,17 @@ export function buildSpatiallyIndexedSkeletonOverlayGeometry( const childIndex = nodeIndex.get(node.nodeId); if (childIndex === undefined) return; const parentNodeId = node.parentNodeId; - if ( - parentNodeId === undefined || - !Number.isSafeInteger(parentNodeId) || - parentNodeId <= 0 - ) { + if (parentNodeId === undefined || parentNodeId <= 0n) { return; } const parentIndex = nodeIndex.get(parentNodeId); if (parentIndex === undefined) return; indices.push(childIndex, parentIndex); - pickEdgeSegmentIds.push(segmentIds[childIndex] || segmentIds[parentIndex]); + pickEdgeSegmentIds.push( + segmentIds[childIndex] !== 0n + ? segmentIds[childIndex] + : segmentIds[parentIndex], + ); }); return { @@ -86,7 +90,7 @@ export function buildSpatiallyIndexedSkeletonOverlayGeometry( nodeIds, nodePositions, pickSegmentIds, - pickEdgeSegmentIds: new Uint32Array(pickEdgeSegmentIds), + pickEdgeSegmentIds: new BigUint64Array(pickEdgeSegmentIds), indices: new Uint32Array(indices), numVertices, }; diff --git a/src/skeleton/overlay_segment_retention.spec.ts b/src/skeleton/overlay_segment_retention.spec.ts index b59cefb92d..fa74189f99 100644 --- a/src/skeleton/overlay_segment_retention.spec.ts +++ b/src/skeleton/overlay_segment_retention.spec.ts @@ -9,26 +9,29 @@ import { describe("mergeSpatiallyIndexedSkeletonOverlaySegmentIds", () => { it("dedupes and sorts active and retained segment ids", () => { expect( - mergeSpatiallyIndexedSkeletonOverlaySegmentIds([7, 3, 7], [9, 3, 5]), - ).toEqual([3, 5, 7, 9]); + mergeSpatiallyIndexedSkeletonOverlaySegmentIds( + [7n, 3n, 7n], + [9n, 3n, 5n], + ), + ).toEqual([3n, 5n, 7n, 9n]); }); it("ignores invalid segment ids", () => { expect( - mergeSpatiallyIndexedSkeletonOverlaySegmentIds([1, 0, -2], [NaN, 4]), - ).toEqual([1, 4]); + mergeSpatiallyIndexedSkeletonOverlaySegmentIds([1n, 0n], [4n]), + ).toEqual([1n, 4n]); }); }); describe("retainSpatiallyIndexedSkeletonOverlaySegment", () => { it("moves retained segments to the most recent position", () => { - expect(retainSpatiallyIndexedSkeletonOverlaySegment([2, 4, 6], 4)).toEqual([ - 2, 6, 4, - ]); + expect( + retainSpatiallyIndexedSkeletonOverlaySegment([2n, 4n, 6n], 4n), + ).toEqual([2n, 6n, 4n]); }); it("keeps only the most recent retained segments", () => { - const retained: number[] = []; + const retained: bigint[] = []; for ( let segmentId = 1; segmentId <= DEFAULT_MAX_RETAINED_OVERLAY_SEGMENTS + 2; @@ -37,14 +40,17 @@ describe("retainSpatiallyIndexedSkeletonOverlaySegment", () => { retained.splice( 0, retained.length, - ...retainSpatiallyIndexedSkeletonOverlaySegment(retained, segmentId), + ...retainSpatiallyIndexedSkeletonOverlaySegment( + retained, + BigInt(segmentId), + ), ); } - const firstRetainedSegmentId = 3; + const firstRetainedSegmentId = 3n; expect(retained).toEqual( Array.from( { length: DEFAULT_MAX_RETAINED_OVERLAY_SEGMENTS }, - (_, index) => firstRetainedSegmentId + index, + (_, index) => firstRetainedSegmentId + BigInt(index), ), ); }); diff --git a/src/skeleton/overlay_segment_retention.ts b/src/skeleton/overlay_segment_retention.ts index 0837855b40..af7ea06b3b 100644 --- a/src/skeleton/overlay_segment_retention.ts +++ b/src/skeleton/overlay_segment_retention.ts @@ -14,44 +14,37 @@ * limitations under the License. */ -export const DEFAULT_MAX_RETAINED_OVERLAY_SEGMENTS = 4; +import type { SpatialSkeletonId } from "#src/skeleton/api.js"; +import { compareUint64Ids } from "#src/util/bigint.js"; -function normalizeSegmentId(segmentId: number) { - const normalizedSegmentId = Math.round(Number(segmentId)); - if (!Number.isSafeInteger(normalizedSegmentId) || normalizedSegmentId <= 0) { - return undefined; - } - return normalizedSegmentId; -} +export const DEFAULT_MAX_RETAINED_OVERLAY_SEGMENTS = 4; export function mergeSpatiallyIndexedSkeletonOverlaySegmentIds( - activeSegmentIds: readonly number[], - retainedSegmentIds: readonly number[], + activeSegmentIds: readonly SpatialSkeletonId[], + retainedSegmentIds: readonly SpatialSkeletonId[], ) { - const mergedSegmentIds = new Set(); + const mergedSegmentIds = new Set(); for (const segmentId of [...activeSegmentIds, ...retainedSegmentIds]) { - const normalizedSegmentId = normalizeSegmentId(segmentId); - if (normalizedSegmentId === undefined) continue; - mergedSegmentIds.add(normalizedSegmentId); + if (segmentId <= 0n) continue; + mergedSegmentIds.add(segmentId); } - return [...mergedSegmentIds].sort((a, b) => a - b); + return [...mergedSegmentIds].sort(compareUint64Ids); } export function retainSpatiallyIndexedSkeletonOverlaySegment( - retainedSegmentIds: readonly number[], - segmentId: number, + retainedSegmentIds: readonly SpatialSkeletonId[], + segmentId: SpatialSkeletonId, options: { maxRetained?: number; } = {}, ) { - const normalizedSegmentId = normalizeSegmentId(segmentId); - if (normalizedSegmentId === undefined) { + if (segmentId <= 0n) { return [...retainedSegmentIds]; } const nextRetainedSegmentIds = retainedSegmentIds.filter( - (candidateSegmentId) => candidateSegmentId !== normalizedSegmentId, + (candidateSegmentId) => candidateSegmentId !== segmentId, ); - nextRetainedSegmentIds.push(normalizedSegmentId); + nextRetainedSegmentIds.push(segmentId); const maxRetained = Math.max( 1, Math.round(options.maxRetained ?? DEFAULT_MAX_RETAINED_OVERLAY_SEGMENTS), diff --git a/src/skeleton/picking.ts b/src/skeleton/picking.ts index e83489e833..ba9b88e9fd 100644 --- a/src/skeleton/picking.ts +++ b/src/skeleton/picking.ts @@ -1,6 +1,6 @@ export function resolveSpatiallyIndexedSkeletonSegmentPick( chunk: { indices: Uint32Array; numVertices: number }, - segmentIds: Uint32Array, + segmentIds: BigUint64Array, pickedOffset: number, kind: "node" | "edge", ) { @@ -13,9 +13,7 @@ export function resolveSpatiallyIndexedSkeletonSegmentPick( return undefined; } const segmentId = segmentIds[pickedOffset]; - return Number.isSafeInteger(segmentId) && segmentId > 0 - ? segmentId - : undefined; + return segmentId > 0n ? segmentId : undefined; } const indexOffset = pickedOffset * 2; if (indexOffset + 1 >= chunk.indices.length) { @@ -24,10 +22,8 @@ export function resolveSpatiallyIndexedSkeletonSegmentPick( const vertexA = chunk.indices[indexOffset]; const vertexB = chunk.indices[indexOffset + 1]; let segmentId = segmentIds[vertexA]; - if (!Number.isSafeInteger(segmentId) || segmentId <= 0) { + if (segmentId <= 0n) { segmentId = segmentIds[vertexB]; } - return Number.isSafeInteger(segmentId) && segmentId > 0 - ? segmentId - : undefined; + return segmentId > 0n ? segmentId : undefined; } diff --git a/src/skeleton/skeleton_chunk_serialization.ts b/src/skeleton/skeleton_chunk_serialization.ts index 2e9b762eb3..0d954ba648 100644 --- a/src/skeleton/skeleton_chunk_serialization.ts +++ b/src/skeleton/skeleton_chunk_serialization.ts @@ -15,14 +15,14 @@ */ import type { SpatialSkeletonSourceState } from "#src/skeleton/api.js"; -import type { TypedNumberArray } from "#src/util/array.js"; +import type { TypedArray } from "#src/util/array.js"; export interface SkeletonChunkData { vertexPositions: Float32Array | null; - vertexAttributes: TypedNumberArray[] | null; + vertexAttributes: TypedArray[] | null; indices: Uint32Array | null; lod?: number; - nodeIds?: Int32Array; + nodeIds?: BigUint64Array; nodeSourceStates?: Array; } diff --git a/src/skeleton/spatial_attribute_layout.ts b/src/skeleton/spatial_attribute_layout.ts index 6b8ee8b2fb..6eeaa4525d 100644 --- a/src/skeleton/spatial_attribute_layout.ts +++ b/src/skeleton/spatial_attribute_layout.ts @@ -2,5 +2,5 @@ import { DataType } from "#src/util/data_type.js"; export const spatiallyIndexedSkeletonTextureAttributeSpecs = Object.freeze([ { name: "position", dataType: DataType.FLOAT32, numComponents: 3 }, - { name: "segment", dataType: DataType.UINT32, numComponents: 1 }, + { name: "segment", dataType: DataType.UINT64, numComponents: 1 }, ]); diff --git a/src/skeleton/spatial_skeleton_manager.spec.ts b/src/skeleton/spatial_skeleton_manager.spec.ts index 3a69a06b1c..c72c54d84c 100644 --- a/src/skeleton/spatial_skeleton_manager.spec.ts +++ b/src/skeleton/spatial_skeleton_manager.spec.ts @@ -192,7 +192,7 @@ describe("skeleton/spatial_skeleton_manager", () => { const cachedSegmentId = 11; (state as any).fullSegmentNodeCache.set(cachedSegmentId, [ { - nodeId: 1, + nodeId: 1n, segmentId: cachedSegmentId, position: new Float32Array([1, 2, 3]), }, @@ -215,21 +215,21 @@ describe("skeleton/spatial_skeleton_manager", () => { it("clears inspected cache state and pending node positions together", () => { const state = new SpatialSkeletonState(); - (state as any).replaceCachedSegmentNodes(11, [ + (state as any).replaceCachedSegmentNodes(11n, [ { - nodeId: 5, - segmentId: 11, + nodeId: 5n, + segmentId: 11n, position: new Float32Array([1, 2, 3]), }, ]); - state.setPendingNodePosition(5, [4, 5, 6]); + state.setPendingNodePosition(5n, [4, 5, 6]); const nodeDataVersion = state.nodeDataVersion.value; const pendingNodePositionVersion = state.pendingNodePositionVersion.value; expect(state.clearInspectedSkeletonCache()).toBe(true); - expect(state.getCachedSegmentNodes(11)).toBeUndefined(); - expect(state.getCachedNode(5)).toBeUndefined(); - expect(state.getPendingNodePosition(5)).toBeUndefined(); + expect(state.getCachedSegmentNodes(11n)).toBeUndefined(); + expect(state.getCachedNode(5n)).toBeUndefined(); + expect(state.getPendingNodePosition(5n)).toBeUndefined(); expect(state.nodeDataVersion.value).toBe(nodeDataVersion + 1); expect(state.pendingNodePositionVersion.value).toBe( pendingNodePositionVersion + 1, @@ -241,8 +241,8 @@ describe("skeleton/spatial_skeleton_manager", () => { const changed = state.upsertCachedNode( { - nodeId: 5, - segmentId: 11, + nodeId: 5n, + segmentId: 11n, position: new Float32Array([1, 2, 3]), parentNodeId: undefined, isTrueEnd: false, @@ -251,19 +251,19 @@ describe("skeleton/spatial_skeleton_manager", () => { ); expect(changed).toBe(true); - expect(state.getCachedSegmentNodes(11)).toEqual([ + expect(state.getCachedSegmentNodes(11n)).toEqual([ { - nodeId: 5, - segmentId: 11, + nodeId: 5n, + segmentId: 11n, position: new Float32Array([1, 2, 3]), parentNodeId: undefined, description: undefined, isTrueEnd: false, }, ]); - expect(state.getCachedNode(5)).toEqual({ - nodeId: 5, - segmentId: 11, + expect(state.getCachedNode(5n)).toEqual({ + nodeId: 5n, + segmentId: 11n, position: new Float32Array([1, 2, 3]), parentNodeId: undefined, description: undefined, @@ -273,19 +273,19 @@ describe("skeleton/spatial_skeleton_manager", () => { it("updates cached node lookup when a node moves between cached segments", () => { const state = new SpatialSkeletonState(); - (state as any).replaceCachedSegmentNodes(11, [ + (state as any).replaceCachedSegmentNodes(11n, [ { - nodeId: 5, - segmentId: 11, + nodeId: 5n, + segmentId: 11n, position: new Float32Array([1, 2, 3]), parentNodeId: undefined, isTrueEnd: false, }, ]); - (state as any).replaceCachedSegmentNodes(13, [ + (state as any).replaceCachedSegmentNodes(13n, [ { - nodeId: 7, - segmentId: 13, + nodeId: 7n, + segmentId: 13n, position: new Float32Array([4, 5, 6]), parentNodeId: undefined, isTrueEnd: false, @@ -294,21 +294,21 @@ describe("skeleton/spatial_skeleton_manager", () => { expect( state.upsertCachedNode({ - nodeId: 5, - segmentId: 13, + nodeId: 5n, + segmentId: 13n, position: new Float32Array([7, 8, 9]), parentNodeId: undefined, isTrueEnd: false, }), ).toBe(true); - expect(state.getCachedSegmentNodes(11)).toBeUndefined(); - expect(state.getCachedSegmentNodes(13)?.map((node) => node.nodeId)).toEqual( - [5, 7], + expect(state.getCachedSegmentNodes(11n)).toBeUndefined(); + expect(state.getCachedSegmentNodes(13n)?.map((node) => node.nodeId)).toEqual( + [5n, 7n], ); - expect(state.getCachedNode(5)).toEqual({ - nodeId: 5, - segmentId: 13, + expect(state.getCachedNode(5n)).toEqual({ + nodeId: 5n, + segmentId: 13n, position: new Float32Array([7, 8, 9]), parentNodeId: undefined, description: undefined, @@ -318,10 +318,10 @@ describe("skeleton/spatial_skeleton_manager", () => { it("does not drop an existing cached node when upserting into an uncached segment without permission", () => { const state = new SpatialSkeletonState(); - (state as any).replaceCachedSegmentNodes(11, [ + (state as any).replaceCachedSegmentNodes(11n, [ { - nodeId: 5, - segmentId: 11, + nodeId: 5n, + segmentId: 11n, position: new Float32Array([1, 2, 3]), parentNodeId: undefined, isTrueEnd: false, @@ -330,28 +330,28 @@ describe("skeleton/spatial_skeleton_manager", () => { expect( state.upsertCachedNode({ - nodeId: 5, - segmentId: 13, + nodeId: 5n, + segmentId: 13n, position: new Float32Array([7, 8, 9]), parentNodeId: undefined, isTrueEnd: false, }), ).toBe(false); - expect(state.getCachedSegmentNodes(11)).toEqual([ + expect(state.getCachedSegmentNodes(11n)).toEqual([ { - nodeId: 5, - segmentId: 11, + nodeId: 5n, + segmentId: 11n, position: new Float32Array([1, 2, 3]), parentNodeId: undefined, description: undefined, isTrueEnd: false, }, ]); - expect(state.getCachedSegmentNodes(13)).toBeUndefined(); - expect(state.getCachedNode(5)).toEqual({ - nodeId: 5, - segmentId: 11, + expect(state.getCachedSegmentNodes(13n)).toBeUndefined(); + expect(state.getCachedNode(5n)).toEqual({ + nodeId: 5n, + segmentId: 11n, position: new Float32Array([1, 2, 3]), parentNodeId: undefined, description: undefined, @@ -364,10 +364,10 @@ describe("skeleton/spatial_skeleton_manager", () => { let resolveFetch: | (( value: Array<{ - nodeId: number; - parentNodeId?: number; + nodeId: bigint; + parentNodeId?: bigint; position: Float32Array; - segmentId: number; + segmentId: bigint; isTrueEnd: boolean; }>, ) => void) @@ -376,10 +376,10 @@ describe("skeleton/spatial_skeleton_manager", () => { () => new Promise< Array<{ - nodeId: number; - parentNodeId?: number; + nodeId: bigint; + parentNodeId?: bigint; position: Float32Array; - segmentId: number; + segmentId: bigint; isTrueEnd: boolean; }> >((resolve) => { @@ -396,38 +396,38 @@ describe("skeleton/spatial_skeleton_manager", () => { }, } as any; - const pending = state.getFullSegmentNodes(skeletonLayer, 11); + const pending = state.getFullSegmentNodes(skeletonLayer, 11n); state.evictInactiveSegmentNodes([]); resolveFetch?.([ { - nodeId: 5, + nodeId: 5n, parentNodeId: undefined, position: new Float32Array([1, 2, 3]), - segmentId: 11, + segmentId: 11n, isTrueEnd: false, }, ]); await expect(pending).resolves.toEqual([ { - nodeId: 5, - segmentId: 11, + nodeId: 5n, + segmentId: 11n, position: new Float32Array([1, 2, 3]), parentNodeId: undefined, description: undefined, isTrueEnd: false, }, ]); - expect(state.getCachedSegmentNodes(11)).toBeUndefined(); - expect(state.getCachedNode(5)).toBeUndefined(); + expect(state.getCachedSegmentNodes(11n)).toBeUndefined(); + expect(state.getCachedNode(5n)).toBeUndefined(); }); it("aborts pending full segment fetches when the cache generation is cleared", async () => { const state = new SpatialSkeletonState(); let receivedSignal: AbortSignal | undefined; const getSkeleton = vi.fn( - (_segmentId: number, options?: { signal?: AbortSignal }) => + (_segmentId: bigint, options?: { signal?: AbortSignal }) => new Promise((_resolve, reject) => { receivedSignal = options?.signal; options?.signal?.addEventListener( @@ -448,22 +448,22 @@ describe("skeleton/spatial_skeleton_manager", () => { getSpatialIndexMetadata: async () => null, }, } as any, - 11, + 11n, ); expect(receivedSignal?.aborted).toBe(false); expect(state.clearInspectedSkeletonCache()).toBe(true); expect(receivedSignal?.aborted).toBe(true); await expect(pending).rejects.toMatchObject({ name: "AbortError" }); - expect(state.getCachedSegmentNodes(11)).toBeUndefined(); - expect(state.getCachedNode(11)).toBeUndefined(); + expect(state.getCachedSegmentNodes(11n)).toBeUndefined(); + expect(state.getCachedNode(11n)).toBeUndefined(); }); it("aborts pending full segment fetches when a segment is invalidated", async () => { const state = new SpatialSkeletonState(); let receivedSignal: AbortSignal | undefined; const getSkeleton = vi.fn( - (_segmentId: number, options?: { signal?: AbortSignal }) => + (_segmentId: bigint, options?: { signal?: AbortSignal }) => new Promise((_resolve, reject) => { receivedSignal = options?.signal; options?.signal?.addEventListener( @@ -484,22 +484,22 @@ describe("skeleton/spatial_skeleton_manager", () => { getSpatialIndexMetadata: async () => null, }, } as any, - 11, + 11n, ); expect(receivedSignal?.aborted).toBe(false); expect(state.invalidateCachedSegments([11])).toBe(false); expect(receivedSignal?.aborted).toBe(true); await expect(pending).rejects.toMatchObject({ name: "AbortError" }); - expect(state.getCachedSegmentNodes(11)).toBeUndefined(); - expect(state.getCachedNode(11)).toBeUndefined(); + expect(state.getCachedSegmentNodes(11n)).toBeUndefined(); + expect(state.getCachedNode(11n)).toBeUndefined(); }); it("aborts pending full segment fetches when a segment is evicted", async () => { const state = new SpatialSkeletonState(); let receivedSignal: AbortSignal | undefined; const getSkeleton = vi.fn( - (_segmentId: number, options?: { signal?: AbortSignal }) => + (_segmentId: bigint, options?: { signal?: AbortSignal }) => new Promise((_resolve, reject) => { receivedSignal = options?.signal; options?.signal?.addEventListener( @@ -520,25 +520,25 @@ describe("skeleton/spatial_skeleton_manager", () => { getSpatialIndexMetadata: async () => null, }, } as any, - 11, + 11n, ); expect(receivedSignal?.aborted).toBe(false); expect(state.evictInactiveSegmentNodes([])).toBe(false); expect(receivedSignal?.aborted).toBe(true); await expect(pending).rejects.toMatchObject({ name: "AbortError" }); - expect(state.getCachedSegmentNodes(11)).toBeUndefined(); - expect(state.getCachedNode(11)).toBeUndefined(); + expect(state.getCachedSegmentNodes(11n)).toBeUndefined(); + expect(state.getCachedNode(11n)).toBeUndefined(); }); it("notifies node data listeners after caching a fetched full segment", async () => { const state = new SpatialSkeletonState(); const getSkeleton = vi.fn(async () => [ { - nodeId: 5, + nodeId: 5n, parentNodeId: undefined, position: new Float32Array([1, 2, 3]), - segmentId: 11, + segmentId: 11n, isTrueEnd: false, }, ]); @@ -556,11 +556,11 @@ describe("skeleton/spatial_skeleton_manager", () => { notifications += 1; }); - await expect(state.getFullSegmentNodes(skeletonLayer, 11)).resolves.toEqual( + await expect(state.getFullSegmentNodes(skeletonLayer, 11n)).resolves.toEqual( [ { - nodeId: 5, - segmentId: 11, + nodeId: 5n, + segmentId: 11n, position: new Float32Array([1, 2, 3]), parentNodeId: undefined, description: undefined, @@ -570,9 +570,9 @@ describe("skeleton/spatial_skeleton_manager", () => { ); expect(notifications).toBe(1); - expect(state.getCachedNode(5)).toEqual({ - nodeId: 5, - segmentId: 11, + expect(state.getCachedNode(5n)).toEqual({ + nodeId: 5n, + segmentId: 11n, position: new Float32Array([1, 2, 3]), parentNodeId: undefined, description: undefined, @@ -584,10 +584,10 @@ describe("skeleton/spatial_skeleton_manager", () => { const state = new SpatialSkeletonState(); const getSkeleton = vi.fn(async () => [ { - nodeId: 5, + nodeId: 5n, parentNodeId: undefined, position: new Float32Array([1, 2, 3]), - segmentId: 11, + segmentId: 11n, isTrueEnd: false, sourceState: { revisionToken: "2026-03-29T12:30:00Z" }, }, @@ -604,12 +604,12 @@ describe("skeleton/spatial_skeleton_manager", () => { getSpatialIndexMetadata: async () => null, }, } as any, - 11, + 11n, ), ).resolves.toEqual([ { - nodeId: 5, - segmentId: 11, + nodeId: 5n, + segmentId: 11n, position: new Float32Array([1, 2, 3]), parentNodeId: undefined, description: undefined, @@ -619,9 +619,9 @@ describe("skeleton/spatial_skeleton_manager", () => { ]); expect(getSkeleton).toHaveBeenCalledTimes(1); - expect(state.getCachedNode(5)).toEqual({ - nodeId: 5, - segmentId: 11, + expect(state.getCachedNode(5n)).toEqual({ + nodeId: 5n, + segmentId: 11n, position: new Float32Array([1, 2, 3]), parentNodeId: undefined, description: undefined, @@ -633,19 +633,19 @@ describe("skeleton/spatial_skeleton_manager", () => { it("stores merge anchor state only when the node id is valid", () => { const state = new SpatialSkeletonState(); - expect(state.setMergeAnchor(5)).toBe(true); - expect(state.mergeAnchorNodeId.value).toBe(5); + expect(state.setMergeAnchor(5n)).toBe(true); + expect(state.mergeAnchorNodeId.value).toBe(5n); - expect(state.setMergeAnchor(0)).toBe(true); + expect(state.setMergeAnchor(0n)).toBe(true); expect(state.mergeAnchorNodeId.value).toBeUndefined(); }); it("stores provided radius and confidence independently", () => { const state = new SpatialSkeletonState(); - (state as any).replaceCachedSegmentNodes(11, [ + (state as any).replaceCachedSegmentNodes(11n, [ { - nodeId: 1, - segmentId: 11, + nodeId: 1n, + segmentId: 11n, position: new Float32Array([1, 2, 3]), parentNodeId: undefined, radius: 4, @@ -653,9 +653,9 @@ describe("skeleton/spatial_skeleton_manager", () => { }, ]); - expect(state.setNodeRadius(1, 6)).toBe(true); - expect(state.setNodeConfidence(1, 63)).toBe(true); - expect(state.getCachedNode(1)).toMatchObject({ + expect(state.setNodeRadius(1n, 6)).toBe(true); + expect(state.setNodeConfidence(1n, 63)).toBe(true); + expect(state.getCachedNode(1n)).toMatchObject({ radius: 6, confidence: 63, }); @@ -663,33 +663,33 @@ describe("skeleton/spatial_skeleton_manager", () => { it("removes and reparents nodes within the affected cached segment only", () => { const state = new SpatialSkeletonState(); - (state as any).replaceCachedSegmentNodes(11, [ + (state as any).replaceCachedSegmentNodes(11n, [ { - nodeId: 1, - segmentId: 11, + nodeId: 1n, + segmentId: 11n, position: new Float32Array([1, 1, 1]), parentNodeId: undefined, isTrueEnd: false, }, { - nodeId: 2, - segmentId: 11, + nodeId: 2n, + segmentId: 11n, position: new Float32Array([2, 2, 2]), - parentNodeId: 1, + parentNodeId: 1n, isTrueEnd: false, }, { - nodeId: 3, - segmentId: 11, + nodeId: 3n, + segmentId: 11n, position: new Float32Array([3, 3, 3]), - parentNodeId: 1, + parentNodeId: 1n, isTrueEnd: false, }, ]); - (state as any).replaceCachedSegmentNodes(12, [ + (state as any).replaceCachedSegmentNodes(12n, [ { - nodeId: 4, - segmentId: 12, + nodeId: 4n, + segmentId: 12n, position: new Float32Array([4, 4, 4]), parentNodeId: undefined, isTrueEnd: false, @@ -697,34 +697,34 @@ describe("skeleton/spatial_skeleton_manager", () => { ]); expect( - state.removeCachedNode(1, { + state.removeCachedNode(1n, { parentNodeId: undefined, - childNodeIds: [2, 3], + childNodeIds: [2n, 3n], }), ).toBe(true); - expect(state.getCachedSegmentNodes(11)).toEqual([ + expect(state.getCachedSegmentNodes(11n)).toEqual([ { - nodeId: 2, - segmentId: 11, + nodeId: 2n, + segmentId: 11n, position: new Float32Array([2, 2, 2]), parentNodeId: undefined, description: undefined, isTrueEnd: false, }, { - nodeId: 3, - segmentId: 11, + nodeId: 3n, + segmentId: 11n, position: new Float32Array([3, 3, 3]), parentNodeId: undefined, description: undefined, isTrueEnd: false, }, ]); - expect(state.getCachedSegmentNodes(12)).toEqual([ + expect(state.getCachedSegmentNodes(12n)).toEqual([ { - nodeId: 4, - segmentId: 12, + nodeId: 4n, + segmentId: 12n, position: new Float32Array([4, 4, 4]), parentNodeId: undefined, description: undefined, @@ -735,91 +735,91 @@ describe("skeleton/spatial_skeleton_manager", () => { it("reroots cached segment topology, confidence, and derived ordering", () => { const state = new SpatialSkeletonState(); - (state as any).replaceCachedSegmentNodes(11, [ + (state as any).replaceCachedSegmentNodes(11n, [ { - nodeId: 1, - segmentId: 11, + nodeId: 1n, + segmentId: 11n, position: new Float32Array([1, 1, 1]), parentNodeId: undefined, confidence: 80, }, { - nodeId: 2, - segmentId: 11, + nodeId: 2n, + segmentId: 11n, position: new Float32Array([2, 2, 2]), - parentNodeId: 1, + parentNodeId: 1n, confidence: 20, }, { - nodeId: 3, - segmentId: 11, + nodeId: 3n, + segmentId: 11n, position: new Float32Array([3, 3, 3]), - parentNodeId: 2, + parentNodeId: 2n, confidence: 10, }, { - nodeId: 4, - segmentId: 11, + nodeId: 4n, + segmentId: 11n, position: new Float32Array([4, 4, 4]), - parentNodeId: 2, + parentNodeId: 2n, confidence: 40, }, { - nodeId: 5, - segmentId: 11, + nodeId: 5n, + segmentId: 11n, position: new Float32Array([5, 5, 5]), - parentNodeId: 1, + parentNodeId: 1n, confidence: 50, }, ]); - expect(state.rerootCachedSegment(3)).toEqual([3, 2, 1]); + expect(state.rerootCachedSegment(3n)).toEqual([3n, 2n, 1n]); - const cachedNodes = state.getCachedSegmentNodes(11)!; - expect(cachedNodes.find((node) => node.nodeId === 3)).toMatchObject({ + const cachedNodes = state.getCachedSegmentNodes(11n)!; + expect(cachedNodes.find((node) => node.nodeId === 3n)).toMatchObject({ parentNodeId: undefined, confidence: 100, }); - expect(cachedNodes.find((node) => node.nodeId === 2)).toMatchObject({ - parentNodeId: 3, + expect(cachedNodes.find((node) => node.nodeId === 2n)).toMatchObject({ + parentNodeId: 3n, confidence: 10, }); - expect(cachedNodes.find((node) => node.nodeId === 1)).toMatchObject({ - parentNodeId: 2, + expect(cachedNodes.find((node) => node.nodeId === 1n)).toMatchObject({ + parentNodeId: 2n, confidence: 20, }); - expect(cachedNodes.find((node) => node.nodeId === 4)).toMatchObject({ - parentNodeId: 2, + expect(cachedNodes.find((node) => node.nodeId === 4n)).toMatchObject({ + parentNodeId: 2n, confidence: 40, }); - expect(cachedNodes.find((node) => node.nodeId === 5)).toMatchObject({ - parentNodeId: 1, + expect(cachedNodes.find((node) => node.nodeId === 5n)).toMatchObject({ + parentNodeId: 1n, confidence: 50, }); const graph = buildSpatiallyIndexedSkeletonNavigationGraph(cachedNodes); - expect(getSkeletonRootNode(graph).nodeId).toBe(3); - expect(getFlatListNodeIds(graph)).toEqual([3, 2, 4, 1, 5]); + expect(getSkeletonRootNode(graph).nodeId).toBe(3n); + expect(getFlatListNodeIds(graph)).toEqual([3n, 2n, 4n, 1n, 5n]); }); it("stores empty segments in the cache if nothing present for that segment in cache", () => { const state = new SpatialSkeletonState(); - (state as any).replaceCachedSegmentNodes(1, []); - expect(state.getCachedSegmentNodes(1)?.length).toBe(0); + (state as any).replaceCachedSegmentNodes(1n, []); + expect(state.getCachedSegmentNodes(1n)?.length).toBe(0); }); it("deletes segment from cache if the segment becomes empty", () => { const state = new SpatialSkeletonState(); const node = { - nodeId: 1, - segmentId: 1, + nodeId: 1n, + segmentId: 1n, position: new Float32Array([1, 1, 1]), }; - (state as any).replaceCachedSegmentNodes(1, [node]); - expect(state.getCachedSegmentNodes(1)).toStrictEqual([node]); - expect(state.getCachedNode(1)).toBe(node); - (state as any).replaceCachedSegmentNodes(1, []); - expect(state.getCachedSegmentNodes(1)).toBeUndefined(); - expect(state.getCachedNode(1)).toBeUndefined(); + (state as any).replaceCachedSegmentNodes(1n, [node]); + expect(state.getCachedSegmentNodes(1n)).toStrictEqual([node]); + expect(state.getCachedNode(1n)).toBe(node); + (state as any).replaceCachedSegmentNodes(1n, []); + expect(state.getCachedSegmentNodes(1n)).toBeUndefined(); + expect(state.getCachedNode(1n)).toBeUndefined(); }); }); diff --git a/src/skeleton/spatial_skeleton_manager.ts b/src/skeleton/spatial_skeleton_manager.ts index c022458c99..3e3432caba 100644 --- a/src/skeleton/spatial_skeleton_manager.ts +++ b/src/skeleton/spatial_skeleton_manager.ts @@ -21,6 +21,7 @@ import { import type { EditableSpatiallyIndexedSkeletonSource, SpatialSkeletonConfidenceConfiguration, + SpatialSkeletonId, SpatiallyIndexedSkeletonNode, SpatialSkeletonSourceState, SpatiallyIndexedSkeletonSource, @@ -29,6 +30,7 @@ import { SpatialSkeletonCommandHistory } from "#src/skeleton/command_history.js" import { isSpatialSkeletonEditCommandFactory } from "#src/skeleton/edit_command_source.js"; import type { SpatiallyIndexedSkeletonLayer } from "#src/skeleton/frontend.js"; import { WatchableValue } from "#src/trackable_value.js"; +import { compareUint64Ids, parsePositiveUint64Id } from "#src/util/bigint.js"; import { RefCounted } from "#src/util/disposable.js"; interface SpatialSkeletonSourceAccess { @@ -206,33 +208,41 @@ export function getEditableSpatiallyIndexedSkeletonSource( export function normalizeSpatiallyIndexedSkeletonNode( node: SpatiallyIndexedSkeletonNode, - fallbackSegmentId: number, + fallbackSegmentId: SpatialSkeletonId, ): SpatiallyIndexedSkeletonNode | undefined { - const nodeId = Number(node.nodeId); - const segmentIdValue = Number(node.segmentId); + let nodeId: SpatialSkeletonId; + let segmentId: SpatialSkeletonId; + let parentNodeId: SpatialSkeletonId | undefined; + try { + nodeId = parsePositiveUint64Id(node.nodeId, "spatial skeleton node id"); + segmentId = parsePositiveUint64Id( + node.segmentId ?? fallbackSegmentId, + "spatial skeleton segment id", + ); + parentNodeId = + node.parentNodeId === undefined + ? undefined + : parsePositiveUint64Id( + node.parentNodeId, + "spatial skeleton parent node id", + ); + } catch { + return undefined; + } const x = Number(node.position[0]); const y = Number(node.position[1]); const z = Number(node.position[2]); if ( - !Number.isFinite(nodeId) || - !Number.isFinite(segmentIdValue) || !Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z) ) { return undefined; } - const parentNodeId = - node.parentNodeId === undefined || - !Number.isFinite(Number(node.parentNodeId)) - ? undefined - : Math.round(Number(node.parentNodeId)); return { ...node, - nodeId: Math.round(nodeId), - segmentId: Math.round( - Number.isFinite(segmentIdValue) ? segmentIdValue : fallbackSegmentId, - ), + nodeId, + segmentId, position: new Float32Array([x, y, z]), parentNodeId, description: @@ -274,28 +284,31 @@ export class SpatialSkeletonState extends RefCounted { readonly editMode = new WatchableValue(false); readonly mergeMode = new WatchableValue(false); readonly splitMode = new WatchableValue(false); - readonly mergeAnchorNodeId = new WatchableValue( + readonly mergeAnchorNodeId = new WatchableValue( undefined, ); readonly nodeDataVersion = new WatchableValue(0); readonly pendingNodePositionVersion = new WatchableValue(0); - private pendingNodePositions = new Map(); + private pendingNodePositions = new Map(); private fullSkeletonCacheGeneration = 0; private fullSegmentNodeCache = new Map< - number, + SpatialSkeletonId, SpatiallyIndexedSkeletonNode[] >(); private pendingFullSegmentNodeFetches = new Map< - number, + SpatialSkeletonId, { promise: Promise; abortController: AbortController; } >(); - private cachedNodesById = new Map(); + private cachedNodesById = new Map< + SpatialSkeletonId, + SpatiallyIndexedSkeletonNode + >(); - setNodeRadius(nodeId: number, radius: number) { + setNodeRadius(nodeId: SpatialSkeletonId, radius: number) { const normalizedNodeId = this.normalizeNodeId(nodeId); radius = Number(radius); if (normalizedNodeId === undefined || !Number.isFinite(radius)) { @@ -312,7 +325,7 @@ export class SpatialSkeletonState extends RefCounted { }); } - setNodeConfidence(nodeId: number, confidence: number) { + setNodeConfidence(nodeId: SpatialSkeletonId, confidence: number) { const normalizedNodeId = this.normalizeNodeId(nodeId); confidence = Number(confidence); if (normalizedNodeId === undefined || !Number.isFinite(confidence)) { @@ -333,20 +346,20 @@ export class SpatialSkeletonState extends RefCounted { return this.pendingNodePositions.keys(); } - getPendingNodePosition(nodeId: number) { + getPendingNodePosition(nodeId: SpatialSkeletonId) { return this.pendingNodePositions.get(nodeId); } - private normalizeNodeId(nodeId: number | undefined) { + private normalizeNodeId(nodeId: SpatialSkeletonId | undefined) { if (nodeId === undefined) return undefined; - const normalizedNodeId = Math.round(Number(nodeId)); - if (!Number.isSafeInteger(normalizedNodeId) || normalizedNodeId <= 0) { + try { + return parsePositiveUint64Id(nodeId, "spatial skeleton node id"); + } catch { return undefined; } - return normalizedNodeId; } - setMergeAnchor(nodeId: number | undefined) { + setMergeAnchor(nodeId: SpatialSkeletonId | undefined) { const normalizedNodeId = this.normalizeNodeId(nodeId); if (this.mergeAnchorNodeId.value === normalizedNodeId) { return false; @@ -359,7 +372,10 @@ export class SpatialSkeletonState extends RefCounted { return this.setMergeAnchor(undefined); } - setPendingNodePosition(nodeId: number, position: ArrayLike) { + setPendingNodePosition( + nodeId: SpatialSkeletonId, + position: ArrayLike, + ) { const normalizedNodeId = this.normalizeNodeId(nodeId); const x = Number(position[0]); const y = Number(position[1]); @@ -390,7 +406,7 @@ export class SpatialSkeletonState extends RefCounted { return true; } - clearPendingNodePosition(nodeId: number) { + clearPendingNodePosition(nodeId: SpatialSkeletonId) { const normalizedNodeId = this.normalizeNodeId(nodeId); if ( normalizedNodeId === undefined || @@ -438,16 +454,16 @@ export class SpatialSkeletonState extends RefCounted { this.nodeDataVersion.value = this.nodeDataVersion.value + 1; } - getCachedSegmentNodes(segmentId: number) { + getCachedSegmentNodes(segmentId: SpatialSkeletonId) { return this.fullSegmentNodeCache.get(segmentId); } - getCachedNode(nodeId: number) { + getCachedNode(nodeId: SpatialSkeletonId) { return this.cachedNodesById.get(nodeId); } private replaceCachedSegmentNodes( - segmentId: number, + segmentId: SpatialSkeletonId, nextSegmentNodes: readonly SpatiallyIndexedSkeletonNode[], ) { const previousSegmentNodes = this.fullSegmentNodeCache.get(segmentId); @@ -476,7 +492,7 @@ export class SpatialSkeletonState extends RefCounted { return true; } - private deleteCachedSegment(segmentId: number) { + private deleteCachedSegment(segmentId: SpatialSkeletonId) { const previousSegmentNodes = this.fullSegmentNodeCache.get(segmentId); if (previousSegmentNodes === undefined) return false; for (const node of previousSegmentNodes) { @@ -489,7 +505,10 @@ export class SpatialSkeletonState extends RefCounted { return true; } - private abortPendingFullSegmentNodeFetch(segmentId: number, message: string) { + private abortPendingFullSegmentNodeFetch( + segmentId: SpatialSkeletonId, + message: string, + ) { const pendingEntry = this.pendingFullSegmentNodeFetches.get(segmentId); if (pendingEntry === undefined) { return false; @@ -500,7 +519,7 @@ export class SpatialSkeletonState extends RefCounted { } setCachedNodeSourceState( - nodeId: number, + nodeId: SpatialSkeletonId, sourceState: SpatialSkeletonSourceState | undefined, ) { if (sourceState === undefined) { @@ -519,7 +538,7 @@ export class SpatialSkeletonState extends RefCounted { setCachedNodeSourceStates( sourceStateUpdates: readonly { - nodeId: number; + nodeId: SpatialSkeletonId; sourceState: SpatialSkeletonSourceState; }[], ) { @@ -532,7 +551,7 @@ export class SpatialSkeletonState extends RefCounted { return changed; } - private getCachedSegmentIdForNode(nodeId: number) { + private getCachedSegmentIdForNode(nodeId: SpatialSkeletonId) { const normalizedNodeId = this.normalizeNodeId(nodeId); if (normalizedNodeId === undefined) { return undefined; @@ -541,8 +560,8 @@ export class SpatialSkeletonState extends RefCounted { } private updateCachedNodeInSegment( - segmentId: number, - nodeId: number, + segmentId: SpatialSkeletonId, + nodeId: SpatialSkeletonId, update: ( node: SpatiallyIndexedSkeletonNode, ) => SpatiallyIndexedSkeletonNode, @@ -566,7 +585,7 @@ export class SpatialSkeletonState extends RefCounted { } private upsertCachedNodeInSegment( - segmentId: number, + segmentId: SpatialSkeletonId, node: SpatiallyIndexedSkeletonNode, ) { const segmentNodes = this.fullSegmentNodeCache.get(segmentId); @@ -596,7 +615,7 @@ export class SpatialSkeletonState extends RefCounted { } updateCachedNode( - nodeId: number, + nodeId: SpatialSkeletonId, update: ( node: SpatiallyIndexedSkeletonNode, ) => SpatiallyIndexedSkeletonNode, @@ -658,7 +677,7 @@ export class SpatialSkeletonState extends RefCounted { ); } - moveCachedNode(nodeId: number, position: ArrayLike) { + moveCachedNode(nodeId: SpatialSkeletonId, position: ArrayLike) { const x = Number(position[0]); const y = Number(position[1]); const z = Number(position[2]); @@ -681,10 +700,10 @@ export class SpatialSkeletonState extends RefCounted { } removeCachedNode( - nodeId: number, + nodeId: SpatialSkeletonId, options: { - parentNodeId?: number; - childNodeIds?: Iterable; + parentNodeId?: SpatialSkeletonId; + childNodeIds?: Iterable; } = {}, ) { const normalizedNodeId = this.normalizeNodeId(nodeId); @@ -694,8 +713,10 @@ export class SpatialSkeletonState extends RefCounted { const childNodeIds = options.childNodeIds ? new Set( [...options.childNodeIds] - .map((value) => this.normalizeNodeId(Number(value))) - .filter((value): value is number => value !== undefined), + .map((value) => this.normalizeNodeId(value)) + .filter( + (value): value is SpatialSkeletonId => value !== undefined, + ), ) : undefined; let segmentId = this.getCachedSegmentIdForNode(normalizedNodeId); @@ -738,7 +759,10 @@ export class SpatialSkeletonState extends RefCounted { return true; } - setCachedNodeParent(nodeId: number, parentNodeId: number | undefined) { + setCachedNodeParent( + nodeId: SpatialSkeletonId, + parentNodeId: SpatialSkeletonId | undefined, + ) { return this.updateCachedNode(nodeId, (node) => { if (node.parentNodeId === parentNodeId) { return node; @@ -750,7 +774,7 @@ export class SpatialSkeletonState extends RefCounted { }); } - rerootCachedSegment(nodeId: number) { + rerootCachedSegment(nodeId: SpatialSkeletonId) { const normalizedNodeId = this.normalizeNodeId(nodeId); if (normalizedNodeId === undefined) { return undefined; @@ -764,7 +788,7 @@ export class SpatialSkeletonState extends RefCounted { return undefined; } - const nodeById = new Map(); + const nodeById = new Map(); for (const node of segmentNodes) { nodeById.set(node.nodeId, node); } @@ -776,8 +800,8 @@ export class SpatialSkeletonState extends RefCounted { return [startNode.nodeId]; } - const pathNodeIds: number[] = []; - const seen = new Set(); + const pathNodeIds: SpatialSkeletonId[] = []; + const seen = new Set(); let currentNode: SpatiallyIndexedSkeletonNode | undefined = startNode; while (currentNode !== undefined) { if (seen.has(currentNode.nodeId)) { @@ -795,8 +819,14 @@ export class SpatialSkeletonState extends RefCounted { } } - const nextParentByNodeId = new Map(); - const nextConfidenceByNodeId = new Map(); + const nextParentByNodeId = new Map< + SpatialSkeletonId, + SpatialSkeletonId | undefined + >(); + const nextConfidenceByNodeId = new Map< + SpatialSkeletonId, + number | undefined + >(); nextParentByNodeId.set(startNode.nodeId, undefined); nextConfidenceByNodeId.set(startNode.nodeId, 100); @@ -839,14 +869,16 @@ export class SpatialSkeletonState extends RefCounted { return pathNodeIds; } - invalidateCachedSegments(segmentIds: Iterable) { + invalidateCachedSegments(segmentIds: Iterable) { let changed = false; for (const segmentId of segmentIds) { - const normalizedSegmentId = Math.round(Number(segmentId)); - if ( - !Number.isSafeInteger(normalizedSegmentId) || - normalizedSegmentId <= 0 - ) { + let normalizedSegmentId: SpatialSkeletonId; + try { + normalizedSegmentId = parsePositiveUint64Id( + segmentId, + "spatial skeleton segment id", + ); + } catch { continue; } changed = this.deleteCachedSegment(normalizedSegmentId) || changed; @@ -858,7 +890,7 @@ export class SpatialSkeletonState extends RefCounted { return changed; } - evictInactiveSegmentNodes(activeSegmentIds: Iterable) { + evictInactiveSegmentNodes(activeSegmentIds: Iterable) { const activeSegmentIdSet = new Set(activeSegmentIds); let changed = false; for (const segmentId of this.fullSegmentNodeCache.keys()) { @@ -877,7 +909,7 @@ export class SpatialSkeletonState extends RefCounted { async getFullSegmentNodes( skeletonLayer: SpatiallyIndexedSkeletonLayer, - segmentId: number, + segmentId: SpatialSkeletonId, ): Promise { const cached = this.fullSegmentNodeCache.get(segmentId); if (cached !== undefined) { @@ -902,7 +934,10 @@ export class SpatialSkeletonState extends RefCounted { const fetchedNodes = await skeletonSource.getSkeleton(segmentId, { signal: abortController.signal, }); - const dedupedNodes = new Map(); + const dedupedNodes = new Map< + SpatialSkeletonId, + SpatiallyIndexedSkeletonNode + >(); for (const fetchedNode of fetchedNodes) { const mappedNode = normalizeSpatiallyIndexedSkeletonNode( fetchedNode, @@ -913,8 +948,8 @@ export class SpatialSkeletonState extends RefCounted { dedupedNodes.set(mappedNode.nodeId, mappedNode); } } - const normalizedNodes = [...dedupedNodes.values()].sort( - (a, b) => a.nodeId - b.nodeId, + const normalizedNodes = [...dedupedNodes.values()].sort((a, b) => + compareUint64Ids(a.nodeId, b.nodeId), ); if ( this.fullSkeletonCacheGeneration === fetchVersion && diff --git a/src/ui/spatial_skeleton_edit_tab.spec.ts b/src/ui/spatial_skeleton_edit_tab.spec.ts index 222eac71a5..fb24c2783a 100644 --- a/src/ui/spatial_skeleton_edit_tab.spec.ts +++ b/src/ui/spatial_skeleton_edit_tab.spec.ts @@ -6,8 +6,8 @@ import { SpatialSkeletonNodeFilterType } from "#src/skeleton/node_types.js"; import { buildSpatialSkeletonSegmentRenderState } from "#src/ui/spatial_skeleton_edit_tab_render_state.js"; function makeNode( - nodeId: number, - parentNodeId: number | undefined, + nodeId: bigint, + parentNodeId: bigint | undefined, options: { description?: string; isTrueEnd?: boolean; @@ -15,9 +15,9 @@ function makeNode( ): SpatiallyIndexedSkeletonNode { return { nodeId, - segmentId: 20380, + segmentId: 20380n, parentNodeId, - position: new Float32Array([nodeId, nodeId + 1, nodeId + 2]), + position: new Float32Array([Number(nodeId), Number(nodeId) + 1, Number(nodeId) + 2]), description: options.description, isTrueEnd: options.isTrueEnd ?? false, }; @@ -40,47 +40,47 @@ async function getBuildSpatialSkeletonVirtualListItems() { describe("spatial skeleton edit tab render state", () => { it("shows only directly matching nodes for text filtering", () => { const graph = buildSpatiallyIndexedSkeletonNavigationGraph([ - makeNode(1, undefined), - makeNode(2, 1), - makeNode(3, 2), - makeNode(4, 2), + makeNode(1n, undefined), + makeNode(2n, 1n), + makeNode(3n, 2n), + makeNode(4n, 2n), ]); - const state = buildSpatialSkeletonSegmentRenderState(20380, graph, { + const state = buildSpatialSkeletonSegmentRenderState(20380n, graph, { filterText: "target", nodeFilterType: SpatialSkeletonNodeFilterType.NONE, getNodeDescription(node) { - return node.nodeId === 4 ? "target" : undefined; + return node.nodeId === 4n ? "target" : undefined; }, }); expect(state.matchedNodeCount).toBe(1); expect(state.displayedNodeCount).toBe(1); expect(state.branchCount).toBe(1); - expect(state.rows.map((row) => row.node.nodeId)).toEqual([4]); + expect(state.rows.map((row) => row.node.nodeId)).toEqual([4n]); }); it("does not match coordinates, segment ids, or true-end state in the search filter", () => { const graph = buildSpatiallyIndexedSkeletonNavigationGraph([ - makeNode(101, undefined, { isTrueEnd: true }), - makeNode(102, 101), + makeNode(101n, undefined, { isTrueEnd: true }), + makeNode(102n, 101n), ]); - const byCoordinates = buildSpatialSkeletonSegmentRenderState(20380, graph, { + const byCoordinates = buildSpatialSkeletonSegmentRenderState(20380n, graph, { filterText: "101 102 103", nodeFilterType: SpatialSkeletonNodeFilterType.NONE, getNodeDescription() { return undefined; }, }); - const bySegmentId = buildSpatialSkeletonSegmentRenderState(20380, graph, { + const bySegmentId = buildSpatialSkeletonSegmentRenderState(20380n, graph, { filterText: "20380", nodeFilterType: SpatialSkeletonNodeFilterType.NONE, getNodeDescription() { return undefined; }, }); - const byTrueEndText = buildSpatialSkeletonSegmentRenderState(20380, graph, { + const byTrueEndText = buildSpatialSkeletonSegmentRenderState(20380n, graph, { filterText: "true end", nodeFilterType: SpatialSkeletonNodeFilterType.NONE, getNodeDescription() { @@ -98,12 +98,12 @@ describe("spatial skeleton edit tab render state", () => { it("counts hidden regular nodes in the ratio while omitting them from collapsed rows", () => { const graph = buildSpatiallyIndexedSkeletonNavigationGraph([ - makeNode(10, undefined), - makeNode(11, 10), - makeNode(12, 11), + makeNode(10n, undefined), + makeNode(11n, 10n), + makeNode(12n, 11n), ]); - const state = buildSpatialSkeletonSegmentRenderState(20380, graph, { + const state = buildSpatialSkeletonSegmentRenderState(20380n, graph, { filterText: "", nodeFilterType: SpatialSkeletonNodeFilterType.NONE, getNodeDescription() { @@ -114,17 +114,17 @@ describe("spatial skeleton edit tab render state", () => { expect(state.matchedNodeCount).toBe(3); expect(state.displayedNodeCount).toBe(2); expect(state.branchCount).toBe(1); - expect(state.rows.map((row) => row.node.nodeId)).toEqual([10, 12]); + expect(state.rows.map((row) => row.node.nodeId)).toEqual([10n, 12n]); }); it("treats node-type-only matches as disconnected visible branches", () => { const graph = buildSpatiallyIndexedSkeletonNavigationGraph([ - makeNode(20, undefined), - makeNode(21, 20), - makeNode(22, 20), + makeNode(20n, undefined), + makeNode(21n, 20n), + makeNode(22n, 20n), ]); - const state = buildSpatialSkeletonSegmentRenderState(20380, graph, { + const state = buildSpatialSkeletonSegmentRenderState(20380n, graph, { filterText: "", nodeFilterType: SpatialSkeletonNodeFilterType.VIRTUAL_END, getNodeDescription() { @@ -135,27 +135,27 @@ describe("spatial skeleton edit tab render state", () => { expect(state.matchedNodeCount).toBe(2); expect(state.displayedNodeCount).toBe(2); expect(state.branchCount).toBe(2); - expect(state.rows.map((row) => row.node.nodeId)).toEqual([21, 22]); + expect(state.rows.map((row) => row.node.nodeId)).toEqual([21n, 22n]); }); it("filters to nodes with non-empty descriptions", () => { const graph = buildSpatiallyIndexedSkeletonNavigationGraph([ - makeNode(30, undefined), - makeNode(31, 30), - makeNode(32, 30), - makeNode(33, 30), + makeNode(30n, undefined), + makeNode(31n, 30n), + makeNode(32n, 30n), + makeNode(33n, 30n), ]); - const state = buildSpatialSkeletonSegmentRenderState(20380, graph, { + const state = buildSpatialSkeletonSegmentRenderState(20380n, graph, { filterText: "", nodeFilterType: SpatialSkeletonNodeFilterType.HAS_DESCRIPTION, getNodeDescription(node) { switch (node.nodeId) { - case 31: + case 31n: return "has description"; - case 32: + case 32n: return ""; - case 33: + case 33n: return " "; default: return undefined; @@ -166,7 +166,7 @@ describe("spatial skeleton edit tab render state", () => { expect(state.matchedNodeCount).toBe(1); expect(state.displayedNodeCount).toBe(1); expect(state.branchCount).toBe(1); - expect(state.rows.map((row) => row.node.nodeId)).toEqual([31]); + expect(state.rows.map((row) => row.node.nodeId)).toEqual([31n]); }); }); @@ -175,12 +175,12 @@ describe("spatial skeleton edit tab virtual list items", () => { const buildSpatialSkeletonVirtualListItems = await getBuildSpatialSkeletonVirtualListItems(); const graph = buildSpatiallyIndexedSkeletonNavigationGraph([ - makeNode(1, undefined), - makeNode(2, 1), - makeNode(3, 2), + makeNode(1n, undefined), + makeNode(2n, 1n), + makeNode(3n, 2n), ]); const segmentState = { - ...buildSpatialSkeletonSegmentRenderState(20380, graph, { + ...buildSpatialSkeletonSegmentRenderState(20380n, graph, { filterText: "", nodeFilterType: SpatialSkeletonNodeFilterType.NONE, getNodeDescription() { @@ -204,9 +204,9 @@ describe("spatial skeleton edit tab virtual list items", () => { flattened.items .filter((item) => item.kind === "node") .map((item) => item.row.node.nodeId), - ).toEqual([1, 3]); - expect(flattened.listIndexByNodeId.get(1)).toBe(1); - expect(flattened.listIndexByNodeId.get(3)).toBe(2); + ).toEqual([1n, 3n]); + expect(flattened.listIndexByNodeId.get(1n)).toBe(1); + expect(flattened.listIndexByNodeId.get(3n)).toBe(2); }); it("returns one empty row when no selected segment rows are available", async () => { @@ -231,13 +231,13 @@ describe("spatial skeleton edit tab virtual list items", () => { const buildSpatialSkeletonVirtualListItems = await getBuildSpatialSkeletonVirtualListItems(); const leafCount = 10001; - const nodes = [makeNode(1, undefined)]; + const nodes = [makeNode(1n, undefined)]; for (let i = 0; i < leafCount; ++i) { - nodes.push(makeNode(i + 2, 1)); + nodes.push(makeNode(BigInt(i + 2), 1n)); } const graph = buildSpatiallyIndexedSkeletonNavigationGraph(nodes); const segmentState = { - ...buildSpatialSkeletonSegmentRenderState(20380, graph, { + ...buildSpatialSkeletonSegmentRenderState(20380n, graph, { filterText: "", nodeFilterType: SpatialSkeletonNodeFilterType.NONE, getNodeDescription() { @@ -254,6 +254,6 @@ describe("spatial skeleton edit tab virtual list items", () => { expect(segmentState.displayedNodeCount).toBeGreaterThan(10_000); expect(flattened.items.length).toBe(segmentState.displayedNodeCount + 1); - expect(flattened.listIndexByNodeId.get(leafCount + 1)).toBe(leafCount + 1); + expect(flattened.listIndexByNodeId.get(BigInt(leafCount + 1))).toBe(leafCount + 1); }); }); diff --git a/src/ui/spatial_skeleton_edit_tab.ts b/src/ui/spatial_skeleton_edit_tab.ts index 2223b924e3..98300996be 100644 --- a/src/ui/spatial_skeleton_edit_tab.ts +++ b/src/ui/spatial_skeleton_edit_tab.ts @@ -46,7 +46,10 @@ import { SpatialSkeletonActions, type SpatialSkeletonAction, } from "#src/skeleton/actions.js"; -import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; +import type { + SpatialSkeletonId, + SpatiallyIndexedSkeletonNode, +} from "#src/skeleton/api.js"; import { buildSpatiallyIndexedSkeletonNavigationGraph, getBranchEnd as getBranchEndFromGraph, @@ -81,6 +84,7 @@ import { import { makeToolButton } from "#src/ui/tool.js"; import type { ArraySpliceOp } from "#src/util/array.js"; import * as matrix from "#src/util/matrix.js"; +import { compareUint64Ids } from "#src/util/bigint.js"; import { formatScaleWithUnitAsString } from "#src/util/si_units.js"; import { Signal } from "#src/util/signal.js"; import { EnumSelectWidget } from "#src/widget/enum_widget.js"; @@ -103,7 +107,7 @@ export function buildSpatialSkeletonVirtualListItems( emptyText: string, ) { const items: SpatialSkeletonListItem[] = []; - const listIndexByNodeId = new Map(); + const listIndexByNodeId = new Map(); if (segmentState !== undefined && segmentState.displayedNodeCount > 0) { items.push({ kind: "segment", segmentState }); for (const row of segmentState.rows) { @@ -118,26 +122,26 @@ export function buildSpatialSkeletonVirtualListItems( interface SpatiallyIndexedSkeletonNavigationApi { getSkeletonRootNode( - skeletonId: number, + skeletonId: SpatialSkeletonId, ): Promise; getBranchStart( - nodeId: number, + nodeId: SpatialSkeletonId, ): Promise; getBranchEnd( - nodeId: number, + nodeId: SpatialSkeletonId, ): Promise; getNextCollapsedLevelNode( - nodeId: number, + nodeId: SpatialSkeletonId, ): Promise; getOpenLeaves( - skeletonId: number, - nodeId: number, + skeletonId: SpatialSkeletonId, + nodeId: SpatialSkeletonId, ): Promise; getParentNode( - nodeId: number, + nodeId: SpatialSkeletonId, ): Promise; getChildNode( - nodeId: number, + nodeId: SpatialSkeletonId, ): Promise; } @@ -298,8 +302,11 @@ export class SpatialSkeletonEditTab extends Tab { element.appendChild(nodesSection); let allNodes: SpatiallyIndexedSkeletonNode[] = []; - let activeSegmentId: number | undefined; - let nodesBySegment = new Map(); + let activeSegmentId: SpatialSkeletonId | undefined; + let nodesBySegment = new Map< + SpatialSkeletonId, + SpatiallyIndexedSkeletonNode[] + >(); let inspectionAllowed = false; let navigationAllowed = false; let trueEndEditingAllowed = false; @@ -307,15 +314,15 @@ export class SpatialSkeletonEditTab extends Tab { let nodeRerootAllowed = false; let pendingScrollToSelectedNode = false; let loadedNodeSummarySuffix = ""; - let hoveredViewerNodeId: number | undefined; - let hoveredListNodeId: number | undefined; - const pendingDeleteNodes = new Set(); - const pendingRerootNodes = new Set(); - const pendingTrueEndNodes = new Set(); - const listIndexByNodeId = new Map(); + let hoveredViewerNodeId: SpatialSkeletonId | undefined; + let hoveredListNodeId: SpatialSkeletonId | undefined; + const pendingDeleteNodes = new Set(); + const pendingRerootNodes = new Set(); + const pendingTrueEndNodes = new Set(); + const listIndexByNodeId = new Map(); const skeletonState = layer.spatialSkeletonState; const navigationGraphCache = new Map< - number, + SpatialSkeletonId, { nodes: readonly SpatiallyIndexedSkeletonNode[]; graph: SpatiallyIndexedSkeletonNavigationGraph; @@ -432,18 +439,18 @@ export class SpatialSkeletonEditTab extends Tab { layer.moveViewToSpatialSkeletonNodePosition(position); }; - const getNavigationNode = (nodeId: number) => { + const getNavigationNode = (nodeId: SpatialSkeletonId) => { return skeletonState.getCachedNode(nodeId); }; - const getSegmentNavigationNodes = (segmentId: number) => { + const getSegmentNavigationNodes = (segmentId: SpatialSkeletonId) => { return ( nodesBySegment.get(segmentId) ?? skeletonState.getCachedSegmentNodes(segmentId) ); }; - const getSegmentNavigationGraph = (segmentId: number) => { + const getSegmentNavigationGraph = (segmentId: SpatialSkeletonId) => { const segmentNodes = getSegmentNavigationNodes(segmentId); if (segmentNodes === undefined || segmentNodes.length === 0) { throw new Error( @@ -462,10 +469,10 @@ export class SpatialSkeletonEditTab extends Tab { return graph; }; - const getSegmentChipColors = (segmentId: number) => { + const getSegmentChipColors = (segmentId: SpatialSkeletonId) => { const color = getBaseObjectColor( layer.displayState, - BigInt(segmentId), + segmentId, segmentColorScratch, ); const r = Math.round(color[0] * 255); @@ -480,30 +487,35 @@ export class SpatialSkeletonEditTab extends Tab { const bindSegmentSelectionControls = ( element: HTMLElement, - segmentId: number, + segmentId: SpatialSkeletonId, ) => { - const id = BigInt(segmentId); const hasSegmentSelectionModifiers = (event: MouseEvent) => event.ctrlKey && !event.altKey && !event.metaKey; element.addEventListener("mousedown", (event: MouseEvent) => { if (event.button !== 2 || !hasSegmentSelectionModifiers(event)) { return; } - layer.selectSegment(id, event.shiftKey ? "force-unpin" : true); + layer.selectSegment( + segmentId, + event.shiftKey ? "force-unpin" : true, + ); event.preventDefault(); event.stopPropagation(); }); element.addEventListener("contextmenu", (event: MouseEvent) => { if (!hasSegmentSelectionModifiers(event)) return; if (event.button !== 2) { - layer.selectSegment(id, event.shiftKey ? "force-unpin" : true); + layer.selectSegment( + segmentId, + event.shiftKey ? "force-unpin" : true, + ); } event.preventDefault(); event.stopPropagation(); }); }; - const getSegmentSelectionTitle = (segmentId: number) => + const getSegmentSelectionTitle = (segmentId: SpatialSkeletonId) => `segment ${segmentId}\n` + "Ctrl+right-click to pin selection\n" + "Ctrl+shift+right-click to unpin"; @@ -523,17 +535,13 @@ export class SpatialSkeletonEditTab extends Tab { return getSegmentIdFromLayerSelectionValue(layerSelectionState); }; - const addVisibleSegmentIds = (segmentIds: Set) => { + const addVisibleSegmentIds = (segmentIds: Set) => { const visibleSegments = getVisibleSegments( layer.displayState.segmentationGroupState.value, ); for (const segmentId of visibleSegments.keys()) { - const normalizedSegmentId = Number(segmentId); - if ( - Number.isSafeInteger(normalizedSegmentId) && - normalizedSegmentId > 0 - ) { - segmentIds.add(normalizedSegmentId); + if (segmentId > 0n) { + segmentIds.add(segmentId); } } }; @@ -582,19 +590,21 @@ export class SpatialSkeletonEditTab extends Tab { applyRowInteractionState(); }; - const updateHoveredListNode = (nextHoveredNodeId: number | undefined) => { + const updateHoveredListNode = ( + nextHoveredNodeId: SpatialSkeletonId | undefined, + ) => { if (hoveredListNodeId === nextHoveredNodeId) return; hoveredListNodeId = nextHoveredNodeId; applyRowInteractionState(); }; const skeletonNavigationApi: SpatiallyIndexedSkeletonNavigationApi = { - async getSkeletonRootNode(skeletonId: number) { + async getSkeletonRootNode(skeletonId: SpatialSkeletonId) { return getSkeletonRootNodeFromGraph( getSegmentNavigationGraph(skeletonId), ); }, - async getBranchStart(nodeId: number) { + async getBranchStart(nodeId: SpatialSkeletonId) { const node = getNavigationNode(nodeId); if (node === undefined) { throw new Error( @@ -606,7 +616,7 @@ export class SpatialSkeletonEditTab extends Tab { nodeId, ); }, - async getBranchEnd(nodeId: number) { + async getBranchEnd(nodeId: SpatialSkeletonId) { const node = getNavigationNode(nodeId); if (node === undefined) { throw new Error( @@ -618,7 +628,7 @@ export class SpatialSkeletonEditTab extends Tab { nodeId, ); }, - async getNextCollapsedLevelNode(nodeId: number) { + async getNextCollapsedLevelNode(nodeId: SpatialSkeletonId) { const node = getNavigationNode(nodeId); if (node === undefined) { throw new Error( @@ -630,13 +640,16 @@ export class SpatialSkeletonEditTab extends Tab { nodeId, ); }, - async getOpenLeaves(skeletonId: number, nodeId: number) { + async getOpenLeaves( + skeletonId: SpatialSkeletonId, + nodeId: SpatialSkeletonId, + ) { return getOpenLeavesFromGraph( getSegmentNavigationGraph(skeletonId), nodeId, ); }, - async getParentNode(nodeId: number) { + async getParentNode(nodeId: SpatialSkeletonId) { const node = getNavigationNode(nodeId); if (node === undefined) { throw new Error( @@ -648,7 +661,7 @@ export class SpatialSkeletonEditTab extends Tab { nodeId, ); }, - async getChildNode(nodeId: number) { + async getChildNode(nodeId: SpatialSkeletonId) { const node = getNavigationNode(nodeId); if (node === undefined) { throw new Error( @@ -755,7 +768,7 @@ export class SpatialSkeletonEditTab extends Tab { } openLeaves.sort((a, b) => a.distance === b.distance - ? a.nodeId - b.nodeId + ? compareUint64Ids(a.nodeId, b.nodeId) : a.distance - b.distance, ); navigateToNodeTarget(openLeaves[0]); @@ -1014,7 +1027,7 @@ export class SpatialSkeletonEditTab extends Tab { return button; }; - const getSegmentDisplayLabel = (segmentId: number) => { + const getSegmentDisplayLabel = (segmentId: SpatialSkeletonId) => { const segmentationGroupState = layer.displayState.segmentationGroupState.value; const segmentPropertyMap = @@ -1024,7 +1037,7 @@ export class SpatialSkeletonEditTab extends Tab { } const mappedSegmentId = getSegmentEquivalences( segmentationGroupState, - ).get(BigInt(segmentId)); + ).get(segmentId); return segmentPropertyMap.getSegmentLabel(mappedSegmentId); }; @@ -1447,13 +1460,19 @@ export class SpatialSkeletonEditTab extends Tab { }; const applyNodesBySegment = ( - nextNodesBySegment: Map, + nextNodesBySegment: Map< + SpatialSkeletonId, + SpatiallyIndexedSkeletonNode[] + >, summarySuffix = "", ) => { loadedNodeSummarySuffix = summarySuffix; navigationGraphCache.clear(); nodesBySegment = nextNodesBySegment; - const allNodesById = new Map(); + const allNodesById = new Map< + SpatialSkeletonId, + SpatiallyIndexedSkeletonNode + >(); for (const segmentNodes of nextNodesBySegment.values()) { for (const node of segmentNodes) { if (!allNodesById.has(node.nodeId)) { @@ -1463,8 +1482,8 @@ export class SpatialSkeletonEditTab extends Tab { } allNodes = [...allNodesById.values()].sort((a, b) => a.segmentId === b.segmentId - ? a.nodeId - b.nodeId - : a.segmentId - b.segmentId, + ? compareUint64Ids(a.nodeId, b.nodeId) + : compareUint64Ids(a.segmentId, b.segmentId), ); updateDisplay(summarySuffix); }; @@ -1498,14 +1517,14 @@ export class SpatialSkeletonEditTab extends Tab { updateDisplay(); const segmentId = activeSegmentId; - const cachedSegmentIds = new Set([segmentId]); + const cachedSegmentIds = new Set([segmentId]); addVisibleSegmentIds(cachedSegmentIds); for (const retainedSegmentId of skeletonLayer.getRetainedOverlaySegmentIds()) { cachedSegmentIds.add(retainedSegmentId); } skeletonState.evictInactiveSegmentNodes(cachedSegmentIds); applyNodesBySegment( - new Map([ + new Map([ [segmentId, cachedSelectedSegmentNodes], ]), " Using inspected full skeleton data.", diff --git a/src/ui/spatial_skeleton_edit_tab_render_state.ts b/src/ui/spatial_skeleton_edit_tab_render_state.ts index 273463ff6d..fe3690306f 100644 --- a/src/ui/spatial_skeleton_edit_tab_render_state.ts +++ b/src/ui/spatial_skeleton_edit_tab_render_state.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; +import type { + SpatialSkeletonId, + SpatiallyIndexedSkeletonNode, +} from "#src/skeleton/api.js"; import { getFlatListNodeIds, type SpatiallyIndexedSkeletonNavigationGraph, @@ -47,7 +50,7 @@ export interface SpatialSkeletonSegmentRenderRow { } export interface SpatialSkeletonSegmentRenderState { - segmentId: number; + segmentId: SpatialSkeletonId; totalNodeCount: number; matchedNodeCount: number; displayedNodeCount: number; @@ -56,7 +59,7 @@ export interface SpatialSkeletonSegmentRenderState { } export function buildSpatialSkeletonSegmentRenderState( - segmentId: number, + segmentId: SpatialSkeletonId, graph: SpatiallyIndexedSkeletonNavigationGraph, options: { filterText: string; @@ -78,8 +81,8 @@ export function buildSpatialSkeletonSegmentRenderState( }; } - const visibleMemo = new Map(); - const isNodeVisible = (nodeId: number): boolean => { + const visibleMemo = new Map(); + const isNodeVisible = (nodeId: SpatialSkeletonId): boolean => { const cached = visibleMemo.get(nodeId); if (cached !== undefined) { return cached; @@ -110,7 +113,7 @@ export function buildSpatialSkeletonSegmentRenderState( const visibleNodeIds = getFlatListNodeIds(graph, { collapseRegularNodesForOrdering: true, }).filter((nodeId) => isNodeVisible(nodeId)); - const visibleNodeIdSet = new Set(visibleNodeIds); + const visibleNodeIdSet = new Set(visibleNodeIds); let branchCount = 0; for (const nodeId of visibleNodeIds) { diff --git a/src/ui/spatial_skeleton_edit_tool.spec.ts b/src/ui/spatial_skeleton_edit_tool.spec.ts index 7423c48e2a..3280b3d183 100644 --- a/src/ui/spatial_skeleton_edit_tool.spec.ts +++ b/src/ui/spatial_skeleton_edit_tool.spec.ts @@ -160,21 +160,21 @@ describe("spatial_skeleton_edit_tool", () => { const moveViewToSpatialSkeletonNodePosition = vi.fn(); const getFullSegmentNodes = vi.fn(); const parentNode: SpatiallyIndexedSkeletonNode = { - nodeId: 5, - segmentId: 11, + nodeId: 5n, + segmentId: 11n, position: new Float32Array([8, 9, 10]), isTrueEnd: false, sourceState: testSourceState("parent-before"), }; const addNode = vi.fn().mockResolvedValue({ - nodeId: 17, - segmentId: 11, + nodeId: 17n, + segmentId: 11n, sourceState: testSourceState("node-after"), parentSourceState: testSourceState("parent-after"), }); const skeletonLayer = { source: makeEditableSkeletonSource({ addNode }), - getNode: vi.fn((nodeId: number) => + getNode: vi.fn((nodeId: bigint) => nodeId === parentNode.nodeId ? parentNode : undefined, ), retainOverlaySegment: vi.fn(), @@ -190,10 +190,10 @@ describe("spatial_skeleton_edit_tool", () => { }, spatialSkeletonState: { commandHistory, - getCachedNode: vi.fn((nodeId: number) => + getCachedNode: vi.fn((nodeId: bigint) => nodeId === parentNode.nodeId ? parentNode : undefined, ), - getCachedSegmentNodes: vi.fn((segmentId: number) => + getCachedSegmentNodes: vi.fn((segmentId: bigint) => segmentId === parentNode.segmentId ? [parentNode] : undefined, ), getFullSegmentNodes, @@ -218,46 +218,46 @@ describe("spatial_skeleton_edit_tool", () => { const position = new Float32Array([1, 2, 3]); await executeSpatialSkeletonAddNode(layer as any, { - skeletonId: 11, - parentNodeId: 5, + skeletonId: 11n, + parentNodeId: 5n, positionInModelSpace: position, }); expect(addNode).toHaveBeenCalledWith( - 11, + 11n, 1, 2, 3, - 5, + 5n, expect.objectContaining({ - node: expect.objectContaining({ nodeId: 5 }), + node: expect.objectContaining({ nodeId: 5n }), }), ); expect(upsertCachedNode).toHaveBeenCalledWith( { - nodeId: 17, - segmentId: 11, + nodeId: 17n, + segmentId: 11n, position: new Float32Array([1, 2, 3]), - parentNodeId: 5, + parentNodeId: 5n, isTrueEnd: false, sourceState: testSourceState("node-after"), }, { allowUncachedSegment: false }, ); expect(setCachedNodeSourceState).toHaveBeenCalledWith( - 5, + 5n, testSourceState("parent-after"), ); expect(visibleSegmentsState.visibleSegments.has(11n)).toBe(true); expect(selectSegment).toHaveBeenCalledWith(11n, true); - expect(selectSpatialSkeletonNode).toHaveBeenCalledWith(17, true, { - segmentId: 11, + expect(selectSpatialSkeletonNode).toHaveBeenCalledWith(17n, true, { + segmentId: 11n, position: new Float32Array([1, 2, 3]), }); expect(moveViewToSpatialSkeletonNodePosition).toHaveBeenCalledWith( new Float32Array([1, 2, 3]), ); - expect(skeletonLayer.retainOverlaySegment).toHaveBeenCalledWith(11); + expect(skeletonLayer.retainOverlaySegment).toHaveBeenCalledWith(11n); expect(markSpatialSkeletonNodeDataChanged).toHaveBeenCalledWith({ invalidateFullSkeletonCache: false, }); @@ -275,8 +275,8 @@ describe("spatial_skeleton_edit_tool", () => { const moveViewToSpatialSkeletonNodePosition = vi.fn(); const getFullSegmentNodes = vi.fn(); const addNode = vi.fn().mockResolvedValue({ - nodeId: 29, - segmentId: 13, + nodeId: 29n, + segmentId: 13n, sourceState: testSourceState("root-after"), }); const skeletonLayer = { @@ -319,16 +319,16 @@ describe("spatial_skeleton_edit_tool", () => { const position = new Float32Array([4, 5, 6]); await executeSpatialSkeletonAddNode(layer as any, { - skeletonId: 13, + skeletonId: 13n, parentNodeId: undefined, positionInModelSpace: position, }); - expect(addNode).toHaveBeenCalledWith(13, 4, 5, 6, undefined, undefined); + expect(addNode).toHaveBeenCalledWith(13n, 4, 5, 6, undefined, undefined); expect(upsertCachedNode).toHaveBeenCalledWith( { - nodeId: 29, - segmentId: 13, + nodeId: 29n, + segmentId: 13n, position: new Float32Array([4, 5, 6]), parentNodeId: undefined, isTrueEnd: false, @@ -339,8 +339,8 @@ describe("spatial_skeleton_edit_tool", () => { expect(setCachedNodeSourceState).not.toHaveBeenCalled(); expect(visibleSegmentsState.visibleSegments.has(13n)).toBe(true); expect(selectSegment).toHaveBeenCalledWith(13n, true); - expect(selectSpatialSkeletonNode).toHaveBeenCalledWith(29, false, { - segmentId: 13, + expect(selectSpatialSkeletonNode).toHaveBeenCalledWith(29n, false, { + segmentId: 13n, position: new Float32Array([4, 5, 6]), }); expect(moveViewToSpatialSkeletonNodePosition).toHaveBeenCalledWith( @@ -360,13 +360,13 @@ describe("spatial_skeleton_edit_tool", () => { ).getAddNodeBlockedReason as ( this: any, skeletonLayer: any, - parentNodeId: number | undefined, + parentNodeId: bigint | undefined, ) => string | undefined; - const getCachedNode = vi.fn((nodeId: number) => - nodeId === 17 + const getCachedNode = vi.fn((nodeId: bigint) => + nodeId === 17n ? { - nodeId: 17, - segmentId: 11, + nodeId: 17n, + segmentId: 11n, position: new Float32Array([1, 2, 3]), isTrueEnd: true, } @@ -384,36 +384,36 @@ describe("spatial_skeleton_edit_tool", () => { ).getSelectedParentNodeForAdd, }; - expect(getAddNodeBlockedReason.call(tool, { getNode }, 17)).toBe( + expect(getAddNodeBlockedReason.call(tool, { getNode }, 17n)).toBe( "Node 17 is marked as a true end. Clear the true end state before appending a child node.", ); - expect(getAddNodeBlockedReason.call(tool, { getNode }, 18)).toBe(undefined); + expect(getAddNodeBlockedReason.call(tool, { getNode }, 18n)).toBe(undefined); expect(getAddNodeBlockedReason.call(tool, { getNode }, undefined)).toBe( undefined, ); expect(getNode).toHaveBeenCalledTimes(1); - expect(getNode).toHaveBeenCalledWith(18); + expect(getNode).toHaveBeenCalledWith(18n); }); it("suppresses the deleted merge segment while keeping the surviving result selected", async () => { suppressStatusMessages(); const firstNode: SpatiallyIndexedSkeletonNode = { - nodeId: 101, - segmentId: 11, + nodeId: 101n, + segmentId: 11n, position: new Float32Array([1, 2, 3]), isTrueEnd: false, sourceState: testSourceState("first-before"), }; const secondNode: SpatiallyIndexedSkeletonNode = { - nodeId: 202, - segmentId: 17, + nodeId: 202n, + segmentId: 17n, position: new Float32Array([4, 5, 6]), isTrueEnd: false, sourceState: testSourceState("second-before"), }; const mergeSkeletons = vi.fn().mockResolvedValue({ - resultSegmentId: 17, - deletedSegmentId: 11, + resultSegmentId: 17n, + deletedSegmentId: 11n, directionAdjusted: true, }); const invalidateCachedSegments = vi.fn(); @@ -425,7 +425,7 @@ describe("spatial_skeleton_edit_tool", () => { const deleteSegmentColor = vi.fn(); const skeletonLayer = { source: makeEditableSkeletonSource({ mergeSkeletons }), - getNode: vi.fn((nodeId: number) => { + getNode: vi.fn((nodeId: bigint) => { if (nodeId === firstNode.nodeId) return firstNode; if (nodeId === secondNode.nodeId) return secondNode; return undefined; @@ -448,12 +448,12 @@ describe("spatial_skeleton_edit_tool", () => { }, spatialSkeletonState: { commandHistory, - getCachedNode: vi.fn((nodeId: number) => { + getCachedNode: vi.fn((nodeId: bigint) => { if (nodeId === firstNode.nodeId) return firstNode; if (nodeId === secondNode.nodeId) return secondNode; return undefined; }), - getCachedSegmentNodes: vi.fn((segmentId: number) => { + getCachedSegmentNodes: vi.fn((segmentId: bigint) => { if (segmentId === firstNode.segmentId) return [firstNode]; if (segmentId === secondNode.segmentId) return [secondNode]; return undefined; @@ -479,28 +479,28 @@ describe("spatial_skeleton_edit_tool", () => { await executeSpatialSkeletonMerge( layer as any, - { nodeId: 101, segmentId: 11 }, - { nodeId: 202, segmentId: 17 }, + { nodeId: 101n, segmentId: 11n }, + { nodeId: 202n, segmentId: 17n }, ); expect(mergeSkeletons).toHaveBeenCalledWith( - 101, - 202, + 101n, + 202n, expect.objectContaining({ nodes: expect.arrayContaining([ - expect.objectContaining({ nodeId: 101 }), - expect.objectContaining({ nodeId: 202 }), + expect.objectContaining({ nodeId: 101n }), + expect.objectContaining({ nodeId: 202n }), ]), }), ); - expect(invalidateCachedSegments).toHaveBeenCalledWith([17, 11]); + expect(invalidateCachedSegments).toHaveBeenCalledWith([17n, 11n]); expect(getFullSegmentNodes).toHaveBeenCalledTimes(2); expect(selectSegment).toHaveBeenCalledWith(17n, false); - expect(selectSpatialSkeletonNode).toHaveBeenCalledWith(101, true, { - segmentId: 17, + expect(selectSpatialSkeletonNode).toHaveBeenCalledWith(101n, true, { + segmentId: 17n, }); expect(deleteSegmentColor).toHaveBeenCalledWith(11n); - expect(skeletonLayer.suppressBrowseSegment).toHaveBeenCalledWith(11); + expect(skeletonLayer.suppressBrowseSegment).toHaveBeenCalledWith(11n); expect(markSpatialSkeletonNodeDataChanged).toHaveBeenCalledWith({ invalidateFullSkeletonCache: false, }); @@ -530,7 +530,7 @@ describe("spatial_skeleton_edit_tool", () => { layer: { selectedSpatialSkeletonNodeId: { value: undefined }, spatialSkeletonState: { - mergeAnchorNodeId: { value: 101 }, + mergeAnchorNodeId: { value: 101n }, }, clearSpatialSkeletonNodeSelection, clearSpatialSkeletonMergeAnchor, diff --git a/src/ui/spatial_skeleton_edit_tool.ts b/src/ui/spatial_skeleton_edit_tool.ts index 9205c47663..905a2382be 100644 --- a/src/ui/spatial_skeleton_edit_tool.ts +++ b/src/ui/spatial_skeleton_edit_tool.ts @@ -37,6 +37,7 @@ import { } from "#src/segmentation_display_state/base.js"; import { SpatialSkeletonActions } from "#src/skeleton/actions.js"; import type { + SpatialSkeletonId, SpatialSkeletonSourceState, SpatialSkeletonVector, } from "#src/skeleton/api.js"; @@ -182,8 +183,8 @@ abstract class SpatialSkeletonToolBase extends LayerTool protected getPickedSpatialSkeletonNode(): | { - nodeId: number; - segmentId?: number; + nodeId: SpatialSkeletonId; + segmentId?: SpatialSkeletonId; position?: Float32Array; sourceState?: SpatialSkeletonSourceState; } @@ -193,11 +194,7 @@ abstract class SpatialSkeletonToolBase extends LayerTool } const pickedSpatialSkeleton = this.mouseState.pickedSpatialSkeleton; const nodeIdRaw = pickedSpatialSkeleton?.nodeId; - if ( - typeof nodeIdRaw !== "number" || - !Number.isSafeInteger(nodeIdRaw) || - nodeIdRaw <= 0 - ) { + if (typeof nodeIdRaw !== "bigint" || nodeIdRaw <= 0n) { return undefined; } const segmentIdRaw = pickedSpatialSkeleton?.segmentId; @@ -206,7 +203,7 @@ abstract class SpatialSkeletonToolBase extends LayerTool return { nodeId: nodeIdRaw, segmentId: - typeof segmentIdRaw === "number" && Number.isSafeInteger(segmentIdRaw) + typeof segmentIdRaw === "bigint" && segmentIdRaw > 0n ? segmentIdRaw : undefined, position: @@ -222,55 +219,51 @@ abstract class SpatialSkeletonToolBase extends LayerTool return undefined; } const segmentIdRaw = this.mouseState.pickedSpatialSkeleton?.segmentId; - if ( - typeof segmentIdRaw !== "number" || - !Number.isSafeInteger(segmentIdRaw) || - segmentIdRaw <= 0 - ) { + if (typeof segmentIdRaw !== "bigint" || segmentIdRaw <= 0n) { return undefined; } return segmentIdRaw; } - protected selectSegmentByNumber(value: number) { - if (!Number.isFinite(value)) return; - this.layer.selectSegment(BigInt(Math.round(value)), false); + protected selectSegmentId(value: SpatialSkeletonId) { + if (value <= 0n) return; + this.layer.selectSegment(value, false); } - protected pinSegmentByNumber(value: number) { - if (!Number.isFinite(value)) return; - this.layer.selectSegment(BigInt(Math.round(value)), true); + protected pinSegmentId(value: SpatialSkeletonId) { + if (value <= 0n) return; + this.layer.selectSegment(value, true); } - protected ensureSegmentVisibleByNumber(value: number) { - if (!Number.isFinite(value)) return; + protected ensureSegmentVisible(value: SpatialSkeletonId) { + if (value <= 0n) return; addSegmentToVisibleSets( this.layer.displayState.segmentationGroupState.value, - BigInt(Math.round(value)), + value, ); } - protected removeVisibleSegmentByNumber( - value: number, + protected removeVisibleSegment( + value: SpatialSkeletonId, options: { deselect?: boolean; } = {}, ) { - if (!Number.isFinite(value)) return; + if (value <= 0n) return; removeSegmentFromVisibleSets( this.layer.displayState.segmentationGroupState.value, - BigInt(Math.round(value)), + value, options, ); } - protected isSpatialSkeletonSegmentVisible(segmentId: number) { + protected isSpatialSkeletonSegmentVisible(segmentId: SpatialSkeletonId) { return getVisibleSegments( this.layer.displayState.segmentationGroupState.value, - ).has(BigInt(Math.round(segmentId))); + ).has(segmentId); } - protected describeVisibleSegmentRequirement(segmentId: number) { + protected describeVisibleSegmentRequirement(segmentId: SpatialSkeletonId) { return `Only visible skeletons are editable. Make skeleton ${segmentId} visible in Seg tab or by double-clicking it in the viewer.`; } @@ -282,7 +275,7 @@ abstract class SpatialSkeletonToolBase extends LayerTool const skeletonLayer = this.layer.getSpatiallyIndexedSkeletonLayer(); const isVisible = this.isSpatialSkeletonSegmentVisible(pickedSegmentId); if (isVisible) { - this.removeVisibleSegmentByNumber(pickedSegmentId, { deselect: true }); + this.removeVisibleSegment(pickedSegmentId, { deselect: true }); const selectedNodeId = this.layer.selectedSpatialSkeletonNodeId.value; const selectedNode = selectedNodeId === undefined @@ -302,16 +295,12 @@ abstract class SpatialSkeletonToolBase extends LayerTool if (anchorSegmentId === pickedSegmentId) { this.layer.clearSpatialSkeletonMergeAnchor(); } - const cachedSegmentIds = new Set( + const cachedSegmentIds = new Set( [ ...getVisibleSegments( this.layer.displayState.segmentationGroupState.value, ).keys(), - ] - .map((segmentId) => Number(segmentId)) - .filter( - (segmentId) => Number.isSafeInteger(segmentId) && segmentId > 0, - ), + ].filter((segmentId) => segmentId > 0n), ); for (const retainedSegmentId of skeletonLayer?.getRetainedOverlaySegmentIds() ?? []) { @@ -325,8 +314,8 @@ abstract class SpatialSkeletonToolBase extends LayerTool ); return true; } - this.ensureSegmentVisibleByNumber(pickedSegmentId); - this.selectSegmentByNumber(pickedSegmentId); + this.ensureSegmentVisible(pickedSegmentId); + this.selectSegmentId(pickedSegmentId); StatusMessage.showTemporaryMessage( `Made skeleton ${pickedSegmentId} visible/editable.`, ); @@ -353,7 +342,7 @@ abstract class SpatialSkeletonToolBase extends LayerTool return undefined; } if (pickedNode.segmentId !== undefined) { - this.selectSegmentByNumber(pickedNode.segmentId); + this.selectSegmentId(pickedNode.segmentId); } this.layer.selectSpatialSkeletonNode(pickedNode.nodeId, false, pickedNode); return { @@ -382,8 +371,8 @@ abstract class SpatialSkeletonToolBase extends LayerTool skeletonLayer: SpatiallyIndexedSkeletonLayer, ): | { - nodeId: number; - segmentId?: number; + nodeId: SpatialSkeletonId; + segmentId?: SpatialSkeletonId; position?: SpatialSkeletonVector; sourceState?: SpatialSkeletonSourceState; visible: boolean; @@ -457,11 +446,11 @@ abstract class SpatialSkeletonToolBase extends LayerTool return; } this.layer.clearSpatialSkeletonNodeSelection(false); - this.pinSegmentByNumber(pickedSegmentId); + this.pinSegmentId(pickedSegmentId); return; } if (pickedNode.segmentId !== undefined) { - this.pinSegmentByNumber(pickedNode.segmentId); + this.pinSegmentId(pickedNode.segmentId); } this.layer.selectSpatialSkeletonNode( pickedNode.nodeId, @@ -616,7 +605,7 @@ export class SpatialSkeletonEditModeTool extends SpatialSkeletonToolBase { private getSelectedParentNodeForAdd( skeletonLayer: SpatiallyIndexedSkeletonLayer, - parentNodeId: number | undefined, + parentNodeId: SpatialSkeletonId | undefined, ) { if (parentNodeId === undefined) { return undefined; @@ -629,7 +618,7 @@ export class SpatialSkeletonEditModeTool extends SpatialSkeletonToolBase { private getAddNodeBlockedReason( skeletonLayer: SpatiallyIndexedSkeletonLayer, - parentNodeId: number | undefined, + parentNodeId: SpatialSkeletonId | undefined, ) { if (parentNodeId === undefined) { return undefined; @@ -817,7 +806,7 @@ export class SpatialSkeletonEditModeTool extends SpatialSkeletonToolBase { if (selectedParentNodeId === undefined) { const pickedSegmentId = this.getPickedSpatialSkeletonSegment(); if (pickedSegmentId !== undefined) { - this.selectSegmentByNumber(pickedSegmentId); + this.selectSegmentId(pickedSegmentId); return; } } @@ -861,7 +850,7 @@ export class SpatialSkeletonEditModeTool extends SpatialSkeletonToolBase { ); const targetSkeletonId = selectedParentNode === undefined - ? 0 + ? 0n : selectedParentNode.segmentId; const clickPositionInModelSpace = this.getMousePositionInSkeletonCoordinates(skeletonLayer); @@ -910,7 +899,7 @@ export class SpatialSkeletonEditModeTool extends SpatialSkeletonToolBase { if (pickedNode === undefined) { const pickedSegmentId = this.getPickedSpatialSkeletonSegment(); if (pickedSegmentId !== undefined) { - this.selectSegmentByNumber(pickedSegmentId); + this.selectSegmentId(pickedSegmentId); layer.clearSpatialSkeletonNodeSelection(false); } return; @@ -1059,8 +1048,8 @@ class SpatialSkeletonMergeModeTool extends SpatialSkeletonToolBase { header.textContent = "Spatial skeleton merge mode"; let pending = false; type MergeAnchorSelection = { - nodeId: number; - segmentId?: number; + nodeId: SpatialSkeletonId; + segmentId?: SpatialSkeletonId; position?: ArrayLike; sourceState?: SpatialSkeletonSourceState; }; @@ -1068,7 +1057,7 @@ class SpatialSkeletonMergeModeTool extends SpatialSkeletonToolBase { let statusOverride: string | undefined; const getAnchorNode = (): MergeAnchorSelection | undefined => { const nodeId = this.layer.spatialSkeletonState.mergeAnchorNodeId.value; - if (nodeId === undefined || !Number.isSafeInteger(nodeId)) { + if (nodeId === undefined) { anchorSelection = undefined; return undefined; } @@ -1154,7 +1143,7 @@ class SpatialSkeletonMergeModeTool extends SpatialSkeletonToolBase { if (pickedNode === undefined) { const pickedSegmentId = this.getPickedSpatialSkeletonSegment(); if (pickedSegmentId !== undefined) { - this.pinSegmentByNumber(pickedSegmentId); + this.pinSegmentId(pickedSegmentId); if ( anchorNode === undefined || pickedSegmentId === anchorNode.segmentId @@ -1178,7 +1167,7 @@ class SpatialSkeletonMergeModeTool extends SpatialSkeletonToolBase { ); return; } - this.pinSegmentByNumber(pickedNode.segmentId); + this.pinSegmentId(pickedNode.segmentId); anchorSelection = { nodeId: pickedNode.nodeId, segmentId: pickedNode.segmentId, @@ -1201,7 +1190,7 @@ class SpatialSkeletonMergeModeTool extends SpatialSkeletonToolBase { ); return; } - this.pinSegmentByNumber(pickedNode.segmentId); + this.pinSegmentId(pickedNode.segmentId); anchorSelection = { nodeId: pickedNode.nodeId, segmentId: pickedNode.segmentId, @@ -1233,7 +1222,7 @@ class SpatialSkeletonMergeModeTool extends SpatialSkeletonToolBase { ); return; } - this.pinSegmentByNumber(pickedNode.segmentId); + this.pinSegmentId(pickedNode.segmentId); this.layer.selectSpatialSkeletonNode( pickedNode.nodeId, true, @@ -1366,7 +1355,7 @@ class SpatialSkeletonSplitModeTool extends SpatialSkeletonToolBase { if (pickedNode === undefined) { const pickedSegmentId = this.getPickedSpatialSkeletonSegment(); if (pickedSegmentId !== undefined) { - this.pinSegmentByNumber(pickedSegmentId); + this.pinSegmentId(pickedSegmentId); this.layer.clearSpatialSkeletonNodeSelection(false); renderStatus(); } @@ -1375,7 +1364,7 @@ class SpatialSkeletonSplitModeTool extends SpatialSkeletonToolBase { if (pickedNode === undefined || pickedNode.segmentId === undefined) { return; } - this.pinSegmentByNumber(pickedNode.segmentId); + this.pinSegmentId(pickedNode.segmentId); this.layer.selectSpatialSkeletonNode( pickedNode.nodeId, true, diff --git a/src/ui/spatial_skeleton_tool_messages.spec.ts b/src/ui/spatial_skeleton_tool_messages.spec.ts index 1e6a1af7b8..9e223e5a1e 100644 --- a/src/ui/spatial_skeleton_tool_messages.spec.ts +++ b/src/ui/spatial_skeleton_tool_messages.spec.ts @@ -15,26 +15,26 @@ import { describe("spatial_skeleton_tool_messages", () => { it("formats tool points with node and segment ids", () => { - expect(formatSpatialSkeletonToolPoint({ nodeId: 17, segmentId: 9 })).toBe( + expect(formatSpatialSkeletonToolPoint({ nodeId: 17n, segmentId: 9n })).toBe( "Node 17, segment 9", ); - expect(formatSpatialSkeletonToolPoint({ nodeId: 17 })).toBe("Node 17"); + expect(formatSpatialSkeletonToolPoint({ nodeId: 17n })).toBe("Node 17"); expect( getSpatialSkeletonToolPointStatusFields({ - nodeId: 17, - segmentId: 9, + nodeId: 17n, + segmentId: 9n, }), ).toEqual([ { label: "Node ID:", value: "17" }, { label: "Segment ID:", value: "9" }, ]); - expect(getSpatialSkeletonToolPointStatusFields({ nodeId: 17 })).toEqual([ + expect(getSpatialSkeletonToolPointStatusFields({ nodeId: 17n })).toEqual([ { label: "Node ID:", value: "17" }, ]); expect( getSpatialSkeletonToolPointSummaryRow({ - nodeId: 17, - segmentId: 9, + nodeId: 17n, + segmentId: 9n, position: [100.2, 200.7, 300.1], }), ).toEqual({ @@ -53,7 +53,7 @@ describe("spatial_skeleton_tool_messages", () => { SPATIAL_SKELETON_EDIT_BANNER_MESSAGE, ); expect( - getSpatialSkeletonEditBannerMessage({ nodeId: 8, segmentId: 12 }), + getSpatialSkeletonEditBannerMessage({ nodeId: 8n, segmentId: 12n }), ).toBe(SPATIAL_SKELETON_EDIT_SELECTED_BANNER_MESSAGE); }); @@ -62,7 +62,7 @@ describe("spatial_skeleton_tool_messages", () => { SPATIAL_SKELETON_MERGE_BANNER_MESSAGE, ); expect( - getSpatialSkeletonMergeBannerMessage({ nodeId: 8, segmentId: 12 }), + getSpatialSkeletonMergeBannerMessage({ nodeId: 8n, segmentId: 12n }), ).toBe(SPATIAL_SKELETON_MERGE_SELECTED_BANNER_MESSAGE); }); diff --git a/src/ui/spatial_skeleton_tool_messages.ts b/src/ui/spatial_skeleton_tool_messages.ts index 73d7cf3f9a..b21ae40857 100644 --- a/src/ui/spatial_skeleton_tool_messages.ts +++ b/src/ui/spatial_skeleton_tool_messages.ts @@ -14,9 +14,11 @@ * limitations under the License. */ +import type { SpatialSkeletonId } from "#src/skeleton/api.js"; + export interface SpatialSkeletonToolPointInfo { - nodeId: number; - segmentId?: number; + nodeId: SpatialSkeletonId; + segmentId?: SpatialSkeletonId; position?: ArrayLike; } diff --git a/src/util/bigint.ts b/src/util/bigint.ts index 7fe63933ab..8f92d3df97 100644 --- a/src/util/bigint.ts +++ b/src/util/bigint.ts @@ -42,12 +42,59 @@ export function bigintMax(a: bigint, b: bigint): bigint { export const UINT64_MAX = 0xffffffffffffffffn; +function describeIntegerValue(value: unknown) { + return typeof value === "bigint" ? value.toString() : JSON.stringify(value); +} + export function clampToUint64(x: bigint): bigint { if (x < 0n) return 0n; if (x > UINT64_MAX) return UINT64_MAX; return x; } +export function parsePositiveUint64Id(value: unknown, label = "id"): bigint { + let id: bigint; + switch (typeof value) { + case "bigint": + id = value; + break; + case "number": + if (!Number.isSafeInteger(value)) { + throw new Error( + `Expected ${label} to be a safe integer number, but received: ${describeIntegerValue(value)}.`, + ); + } + id = BigInt(value); + break; + case "string": + if (!/^(?:0|[1-9][0-9]*)$/.test(value)) { + throw new Error( + `Expected ${label} to be a base-10 uint64 string, but received: ${JSON.stringify(value)}.`, + ); + } + id = BigInt(value); + break; + default: + throw new Error( + `Expected ${label} to be a uint64 id, but received: ${describeIntegerValue(value)}.`, + ); + } + if (id <= 0n || id > UINT64_MAX) { + throw new Error( + `Expected ${label} to be in range [1, ${UINT64_MAX}], but received: ${id}.`, + ); + } + return id; +} + +export function compareUint64Ids(a: bigint, b: bigint) { + return a < b ? -1 : a > b ? 1 : 0; +} + +export function stringifySpatialSkeletonId(id: bigint) { + return id.toString(); +} + export function bigintAbs(x: bigint): bigint { return x < 0n ? -x : x; }