diff --git a/docs/user-guide/skeleton_editing.rst b/docs/user-guide/skeleton_editing.rst index dbaac90e3..ca875df79 100644 --- a/docs/user-guide/skeleton_editing.rst +++ b/docs/user-guide/skeleton_editing.rst @@ -19,14 +19,37 @@ CATMAID documentation to set up a CATMAID server. At minimum you will need: The project stack dimensions and resolution are used to inform the bounding box of the data in neuroglancer as their product. Skeletons in CATMAID are in 1 nm -units. Optionally, you can also configure stack metadata, which is intended to be used alongside the CATMAID multiple-LOD cache -grid for faster fetches of spatially indexed skeletons. The stack metadata can specify the key ``spatial_skeleton_chunk_sizes`` to define the chunk sizes at each LOD level in neuroglancer in nm. These chunk sizes are not required to be the same as the CATMAID LOD cache grid chunk sizes, and chunks are not required to have an exact match in the cache. Neuroglancer will always request to CATMAID to provide nodes from a cache if present. In addition, neuroglancer will request chunks at: - -.. math:: - - \mathrm{lod}\!\left(\frac{k}{n - 1}\right) - -in CATMAID, where :math:`k` is the LOD index level in neuroglancer, and :math:`n` is the number of LOD levels. +units. + +The linked CATMAID stack must define spatial skeleton metadata. Neuroglancer +uses this metadata to build the spatially indexed skeleton source required for +editing. Add a ``spatial`` array to the stack metadata, with one entry for each +spatial index level: + +.. code-block:: json + + { + "spatial": [ + { + "chunk_size": [11168145, 11168145, 11168145], + "limit": 500 + }, + { + "chunk_size": [3939000, 3939000, 3939000], + "limit": 7000 + } + ], + "cache_provider": "cached_msgpack_grid", + "read_only": false + } + +``chunk_size`` is specified in CATMAID project-space nanometers. ``limit`` is +the maximum node count expected for that spatial level and is required. +``cache_provider`` is optional and, when present, is passed to CATMAID node-list +requests. ``read_only`` is optional and disables editing when set to ``true``. + +If ``spatial`` is absent or empty, Neuroglancer rejects the CATMAID datasource +because it cannot construct the spatially indexed skeleton source. After setting this up, enter ``catmaid:/`` as a data source in neuroglancer. @@ -43,12 +66,13 @@ In the **Render** tab you can adjust: - **Opacity (3d)** — controls the opacity of fully loaded, visible skeletons. - **Hidden Opacity (3d)** — controls the opacity of hidden skeletons, which represent - LOD-influenced spatial indicators of nodes in space. + spatially indexed indicators of nodes in space. When you make a skeleton visible, a full fetch is triggered and you are guaranteed to see all nodes and details of that skeleton. Otherwise you see whatever is -provided by the LOD spatial information. The selected LOD is controllable via the -**Resoltion (skeleton grid 2D)** and **Resolution (skeleton grid 3D)** resolution settings. +provided by the spatial index level selected for the current view. The selected +grid size is controlled via the **Resolution (skeleton grid 2D)** and +**Resolution (skeleton grid 3D)** settings. The **Seg** tab works as normal for a segmentation layer, allowing you to set the visibility of segments/skeletons by their ID or by label if one has been assigned. @@ -60,7 +84,9 @@ Skeleton Tab The **Skeleton** tab is used for editing and viewing information about skeletons. It is only available for CATMAID sources with an active spatially indexed skeleton -subsource, and only visible skeletons appear here. +subsource, and only visible skeletons appear here. If the CATMAID stack metadata +sets ``read_only`` to ``true``, inspection remains available but edit actions are +disabled. You can find a node by ID or by description, and filter nodes to show only: diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index c2c0f2fd6..529f4fa7e 100644 --- a/src/datasource/catmaid/api.spec.ts +++ b/src/datasource/catmaid/api.spec.ts @@ -16,10 +16,18 @@ import { describe, expect, it, vi } from "vitest"; -import { CatmaidClient } from "#src/datasource/catmaid/api.js"; +import { + CatmaidClient, + getCatmaidSpatialSkeletonGridCellBounds, + makeCatmaidNodeSourceState, +} from "#src/datasource/catmaid/api.js"; type FetchMock = ReturnType; +function testSourceState(revisionToken: string) { + return makeCatmaidNodeSourceState(revisionToken); +} + function getFetchCall(fetchMock: FetchMock, callIndex = 0) { const call = fetchMock.mock.calls[callIndex]; if (call === undefined) { @@ -60,16 +68,23 @@ describe("CatmaidClient skeleton editing methods", () => { dimension: { x: 10, y: 20, z: 30 }, resolution: { x: 2, y: 3, z: 4 }, translation: { x: 5, y: 6, z: 7 }, + metadata: { + spatial: [{ chunk_size: [15, 15, 15], limit: 1 }], + }, }); await expect(client.getSpatialIndexMetadata()).resolves.toBeNull(); await expect(client.getSpatialIndexMetadata()).resolves.toEqual({ - bounds: { - min: { x: 5, y: 6, z: 7 }, - max: { x: 25, y: 66, z: 127 }, - }, - resolution: { x: 2, y: 3, z: 4 }, - gridCellSizes: [{ x: 15, y: 15, z: 15 }], + lowerBounds: [5, 6, 7], + upperBounds: [25, 66, 127], + readOnly: true, + spatial: [ + { + chunkSize: [15, 15, 15], + gridShape: [2, 4, 8], + limit: 1, + }, + ], }); expect((client as any).listStacks).toHaveBeenCalledTimes(2); @@ -77,7 +92,40 @@ describe("CatmaidClient skeleton editing methods", () => { warnSpy.mockRestore(); }); - it("reads spatial skeleton chunk sizes from stack metadata", async () => { + it("honors explicit writable CATMAID spatial skeleton metadata", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + (client as any).listStacks = vi.fn().mockResolvedValue([{ id: 7 }]); + (client as any).getStackInfo = vi.fn().mockResolvedValue({ + dimension: { x: 10, y: 20, z: 30 }, + resolution: { x: 2, y: 3, z: 4 }, + translation: { x: 5, y: 6, z: 7 }, + metadata: { + read_only: false, + spatial: [{ chunk_size: [15, 15, 15], limit: 1 }], + }, + }); + + await expect(client.getSpatialIndexMetadata()).resolves.toMatchObject({ + readOnly: false, + }); + }); + + it("rejects CATMAID stack metadata without spatial skeleton levels", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + (client as any).listStacks = vi.fn().mockResolvedValue([{ id: 7 }]); + (client as any).getStackInfo = vi.fn().mockResolvedValue({ + dimension: { x: 10, y: 20, z: 30 }, + resolution: { x: 2, y: 3, z: 4 }, + translation: { x: 5, y: 6, z: 7 }, + metadata: {}, + }); + + await expect(client.getSpatialIndexMetadata()).rejects.toThrow( + /metadata\.spatial/i, + ); + }); + + it("reads spatial skeleton spatial index levels from stack metadata", async () => { const client = new CatmaidClient("https://example.invalid", 1); (client as any).listStacks = vi.fn().mockResolvedValue([{ id: 7 }]); (client as any).getStackInfo = vi.fn().mockResolvedValue({ @@ -85,94 +133,168 @@ describe("CatmaidClient skeleton editing methods", () => { resolution: { x: 2, y: 3, z: 4 }, translation: { x: 5, y: 6, z: 7 }, metadata: { - spatial_skeleton_chunk_sizes: [ - [120, 120, 120], - [60, 60, 60], - [30, 30, 30], + cache_provider: "cached_msgpack_grid", + read_only: true, + spatial: [ + { + chunk_size: [11168145, 11168145, 11168145], + limit: 500, + }, + { + chunk_size: [6632497, 6632497, 6632497], + limit: 500, + }, + { + chunk_size: [3939000, 3939000, 3939000], + limit: 7000, + }, + { + chunk_size: [2339000, 2339000, 2339000], + limit: 27500, + }, + { + chunk_size: [1500000, 1500000, 1500000], + limit: 70000, + }, ], }, }); await expect(client.getSpatialIndexMetadata()).resolves.toEqual({ - bounds: { - min: { x: 5, y: 6, z: 7 }, - max: { x: 25, y: 66, z: 127 }, - }, + lowerBounds: [5, 6, 7], + upperBounds: [25, 66, 127], + readOnly: true, + spatial: [ + { + chunkSize: [11168145, 11168145, 11168145], + gridShape: [1, 1, 1], + limit: 500, + }, + { + chunkSize: [6632497, 6632497, 6632497], + gridShape: [1, 1, 1], + limit: 500, + }, + { + chunkSize: [3939000, 3939000, 3939000], + gridShape: [1, 1, 1], + limit: 7000, + }, + { + chunkSize: [2339000, 2339000, 2339000], + gridShape: [1, 1, 1], + limit: 27500, + }, + { + chunkSize: [1500000, 1500000, 1500000], + gridShape: [1, 1, 1], + limit: 70000, + }, + ], + }); + await expect(client.getCacheProvider()).resolves.toBe( + "cached_msgpack_grid", + ); + }); + + it("accepts zero CATMAID spatial skeleton metadata limits", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + (client as any).listStacks = vi.fn().mockResolvedValue([{ id: 7 }]); + (client as any).getStackInfo = vi.fn().mockResolvedValue({ + dimension: { x: 10, y: 20, z: 30 }, resolution: { x: 2, y: 3, z: 4 }, - gridCellSizes: [ - { x: 120, y: 120, z: 120 }, - { x: 60, y: 60, z: 60 }, - { x: 30, y: 30, z: 30 }, + translation: { x: 5, y: 6, z: 7 }, + metadata: { + spatial: [{ chunk_size: [15, 15, 15], limit: 0 }], + }, + }); + + await expect(client.getSpatialIndexMetadata()).resolves.toMatchObject({ + spatial: [ + { + limit: 0, + }, ], }); }); - it("parses live compact-detail history rows and label maps", async () => { + it("parses live compact-detail history rows and current label maps", async () => { const client = new CatmaidClient("https://example.invalid", 1); - const fetchMock = vi.fn().mockResolvedValue([ - [ - [ - 22107946, - null, - 2, - 23697030.0, - 15055839.0, - 16651262.0, - 2000.0, - 5, - "2026-03-29T10:15:00Z", - "2026-03-29T10:15:00Z", - ], - [ - 22107946, - null, - 2, - 23697030.0, - 15055839.0, - 16651262.0, - 2000.0, - 5, - "2026-03-28T08:00:00Z", - "2026-03-29T10:15:00Z", - ], - [ - 22107955, - 22107954, - 2, - 23705874.0, - 15093672.0, - 16682375.0, - 2000.0, - 5, - "2026-03-29T10:16:00Z", - "2026-03-29T10:15:00Z", - ], + const fetchMock = vi + .fn() + .mockResolvedValueOnce([ [ - 22107959, - 22107958, - 2, - 23704520.0, - 15085237.0, - 16708998.0, - 2000.0, - 5, - "2026-03-29T10:17:00Z", - "2026-03-29T10:16:00Z", - ], - ], - [], - { - "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"], + [ + 22107946, + null, + 2, + 23697030.0, + 15055839.0, + 16651262.0, + 2000.0, + 5, + "2026-03-29T10:15:00Z", + "2026-03-29T10:15:00Z", + ], + [ + 22107946, + null, + 2, + 23697030.0, + 15055839.0, + 16651262.0, + 2000.0, + 5, + "2026-03-28T08:00:00Z", + "2026-03-29T10:15:00Z", + ], + [ + 22107955, + 22107954, + 2, + 23705874.0, + 15093672.0, + 16682375.0, + 2000.0, + 5, + "2026-03-29T10:16:00Z", + "2026-03-29T10:15:00Z", + ], + [ + 22107959, + 22107958, + 2, + 23704520.0, + 15085237.0, + 16708998.0, + 2000.0, + 5, + "2026-03-29T10:17:00Z", + "2026-03-29T10:16:00Z", + ], ], - "stale description": [[22107955, "2026-03-29 10:15:45.000000+00:00"]], - ends: [[22107959, "2026-03-29 10:17:00.000000+00:00"]], - }, - [], - [], - ]); + [], + {}, + [], + [], + ]) + .mockResolvedValueOnce([ + [], + [], + { + "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"], + ], + "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([ @@ -185,7 +307,7 @@ describe("CatmaidClient skeleton editing methods", () => { confidence: 100, description: "afonso reviewed it", isTrueEnd: false, - revisionToken: "2026-03-29T10:15:00Z", + sourceState: testSourceState("2026-03-29T10:15:00Z"), }, { nodeId: 22107955, @@ -196,7 +318,7 @@ describe("CatmaidClient skeleton editing methods", () => { confidence: 100, description: "test 123 4", isTrueEnd: false, - revisionToken: "2026-03-29T10:16:00Z", + sourceState: testSourceState("2026-03-29T10:16:00Z"), }, { nodeId: 22107959, @@ -207,7 +329,58 @@ describe("CatmaidClient skeleton editing methods", () => { confidence: 100, description: undefined, isTrueEnd: true, - revisionToken: "2026-03-29T10:17:00Z", + sourceState: testSourceState("2026-03-29T10:17:00Z"), + }, + ]); + expect(getFetchPath(fetchMock, 0)).toBe( + "skeletons/2/compact-detail?with_tags=true&with_history=true", + ); + expect(getFetchPath(fetchMock, 1)).toBe( + "skeletons/2/compact-detail?with_tags=true", + ); + }); + + it("ignores historical compact-detail labels that are not current", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi + .fn() + .mockResolvedValueOnce([ + [ + [ + 23218380, + null, + 1, + 24233266, + 13917594, + 15605623, + 0, + 5, + "2026-05-06 20:17:31.181383+00:00", + "2026-04-20 14:56:29.593124+00:00", + 1, + ], + ], + [], + { + ends: [[23218380, "2026-04-22 15:11:58.824455+00:00"]], + }, + [], + [], + ]) + .mockResolvedValueOnce([[], [], {}, [], []]); + (client as any).fetch = fetchMock; + + await expect(client.getSkeleton(2974940)).resolves.toEqual([ + { + nodeId: 23218380, + parentNodeId: undefined, + position: new Float32Array([24233266, 13917594, 15605623]), + segmentId: 2974940, + radius: 0, + confidence: 100, + description: undefined, + isTrueEnd: false, + sourceState: testSourceState("2026-05-06 20:17:31.181383+00:00"), }, ]); }); @@ -270,9 +443,9 @@ describe("CatmaidClient skeleton editing methods", () => { ], }), ).resolves.toEqual({ - resultSkeletonId: 17, - deletedSkeletonId: 21, - stableAnnotationSwap: false, + resultSegmentId: 17, + deletedSegmentId: 21, + directionAdjusted: false, }); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -304,9 +477,9 @@ describe("CatmaidClient skeleton editing methods", () => { (client as any).fetch = fetchMock; await expect( - client.fetchNodes({ - min: { x: 0, y: 0, z: 0 }, - max: { x: 10, y: 10, z: 10 }, + client.fetchNodesInBoundingBox({ + lowerBounds: [0, 0, 0], + upperBounds: [10, 10, 10], }), ).resolves.toEqual([ { @@ -314,20 +487,43 @@ describe("CatmaidClient skeleton editing methods", () => { parentNodeId: undefined, position: new Float32Array([1, 2, 3]), segmentId: 11, - revisionToken: "2026-03-29T11:50:00Z", + sourceState: testSourceState("2026-03-29T11:50:00Z"), }, { nodeId: 102, parentNodeId: 101, position: new Float32Array([4, 5, 6]), segmentId: 17, - revisionToken: "2026-03-29T11:51:00Z", + sourceState: testSourceState("2026-03-29T11:51:00Z"), }, ]); expect(getFetchPath(fetchMock)).toMatch(/^node\/list\?/); }); + it("converts spatial skeleton grid cell indices to CATMAID bounds", () => { + expect( + getCatmaidSpatialSkeletonGridCellBounds([2, 3, 4], [10, 20, 30]), + ).toEqual({ + lowerBounds: [20, 60, 120], + upperBounds: [30, 80, 150], + }); + }); + + it("rejects CATMAID node-list bounds with fewer than three coordinates", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.fn(); + (client as any).fetch = fetchMock; + + await expect( + client.fetchNodesInBoundingBox({ + lowerBounds: [0, 0], + upperBounds: [10, 10], + }), + ).rejects.toThrow(/requires at least 3 coordinates/i); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("fetches skeleton root targets", async () => { const client = new CatmaidClient("https://example.invalid", 1); const fetchMock = vi.fn().mockResolvedValue({ @@ -340,9 +536,7 @@ describe("CatmaidClient skeleton editing methods", () => { await expect(client.getSkeletonRootNode(17)).resolves.toEqual({ nodeId: 303, - x: 1, - y: 2, - z: 3, + position: [1, 2, 3], }); expect(getFetchPath(fetchMock)).toBe("skeletons/17/root"); @@ -364,7 +558,7 @@ describe("CatmaidClient skeleton editing methods", () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it("returns ids and revisions from addNode and sends CATMAID parent state", async () => { + it("returns ids and source state from addNode and sends CATMAID parent state", async () => { const client = new CatmaidClient("https://example.invalid", 1); const fetchMock = vi.fn().mockResolvedValue({ treenode_id: 88, @@ -382,10 +576,10 @@ describe("CatmaidClient skeleton editing methods", () => { }, }), ).resolves.toEqual({ - treenodeId: 88, - skeletonId: 13, - revisionToken: "2026-03-29T12:00:00Z", - parentRevisionToken: "2026-03-29T12:00:01Z", + nodeId: 88, + segmentId: 13, + sourceState: testSourceState("2026-03-29T12:00:00Z"), + parentSourceState: testSourceState("2026-03-29T12:00:01Z"), }); expect(getFetchBody(fetchMock).get("state")).toBe( @@ -403,10 +597,10 @@ describe("CatmaidClient skeleton editing methods", () => { (client as any).fetch = fetchMock; await expect(client.addNode(13, 1, 2, 3)).resolves.toEqual({ - treenodeId: 88, - skeletonId: 13, - revisionToken: "2026-03-29T12:00:00Z", - parentRevisionToken: undefined, + nodeId: 88, + segmentId: 13, + sourceState: testSourceState("2026-03-29T12:00:00Z"), + parentSourceState: undefined, }); expect(getFetchBody(fetchMock).get("state")).toBe( @@ -440,13 +634,13 @@ describe("CatmaidClient skeleton editing methods", () => { ], }), ).resolves.toEqual({ - treenodeId: 89, - skeletonId: 13, - revisionToken: "2026-03-29T12:01:00Z", - parentRevisionToken: "2026-03-29T12:01:01Z", - nodeRevisionUpdates: [ - { nodeId: 11, revisionToken: "2026-03-29T12:01:02Z" }, - { nodeId: 12, revisionToken: "2026-03-29T12:01:03Z" }, + nodeId: 89, + segmentId: 13, + 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") }, ], }); @@ -500,9 +694,15 @@ describe("CatmaidClient skeleton editing methods", () => { ], }), ).resolves.toEqual({ - nodeRevisionUpdates: [ - { nodeId: 201, revisionToken: "2024-03-29T11:28:31.250Z" }, - { nodeId: 202, revisionToken: "2024-03-29T11:28:32.500Z" }, + nodeSourceStateUpdates: [ + { + nodeId: 201, + sourceState: testSourceState("2024-03-29T11:28:31.250Z"), + }, + { + nodeId: 202, + sourceState: testSourceState("2024-03-29T11:28:32.500Z"), + }, ], }); @@ -570,8 +770,8 @@ describe("CatmaidClient skeleton editing methods", () => { children: [{ nodeId: 203, revisionToken: "2026-03-29T12:06:00Z" }], }), ).resolves.toEqual({ - existingSkeletonId: 17, - newSkeletonId: 21, + existingSegmentId: 17, + newSegmentId: 21, }); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -636,7 +836,7 @@ describe("CatmaidClient skeleton editing methods", () => { }, }), ).resolves.toEqual({ - revisionToken: "2026-03-29T12:10:00Z", + sourceState: testSourceState("2026-03-29T12:10:00Z"), }); expect(getFetchBody(fetchMock).get("state")).toBe( @@ -675,9 +875,9 @@ describe("CatmaidClient skeleton editing methods", () => { }, }), ).resolves.toEqual({ - nodeRevisionUpdates: [ - { nodeId: 12, revisionToken: "2026-03-29T12:20:00Z" }, - { nodeId: 13, revisionToken: "2026-03-29T12:20:01Z" }, + nodeSourceStateUpdates: [ + { nodeId: 12, sourceState: testSourceState("2026-03-29T12:20:00Z") }, + { nodeId: 13, sourceState: testSourceState("2026-03-29T12:20:01Z") }, ], }); @@ -705,7 +905,7 @@ describe("CatmaidClient skeleton editing methods", () => { client.updateDescription(11, "updated description"), ).resolves.toEqual({ description: "updated description", - revisionToken: "2026-03-29T13:00:00Z", + sourceState: testSourceState("2026-03-29T13:00:00Z"), }); const requestBody = getFetchBody(fetchMock); @@ -714,6 +914,27 @@ describe("CatmaidClient skeleton editing methods", () => { expect(requestBody.get("delete_existing")).toBe("true"); }); + it("preserves true-end labels while replacing description labels", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi + .fn() + .mockResolvedValue({ edition_time: "2026-03-29T13:05:00Z" }); + (client as any).fetch = fetchMock; + + await expect( + client.updateDescription(11, "updated description\nends", { + isTrueEnd: true, + }), + ).resolves.toEqual({ + description: "updated description", + sourceState: testSourceState("2026-03-29T13:05:00Z"), + }); + + const requestBody = getFetchBody(fetchMock); + expect(requestBody.get("tags")).toBe("updated description,ends"); + expect(requestBody.get("delete_existing")).toBe("true"); + }); + it("toggles true-end labels without CATMAID node state", async () => { const client = new CatmaidClient("https://example.invalid", 1); const fetchMock = vi @@ -722,11 +943,11 @@ describe("CatmaidClient skeleton editing methods", () => { .mockResolvedValueOnce({ edition_time: "2026-03-29T13:11:00Z" }); (client as any).fetch = fetchMock; - await expect(client.setTrueEnd(11)).resolves.toEqual({ - revisionToken: "2026-03-29T13:10:00Z", + await expect(client.toggleTrueEnd(11, true)).resolves.toEqual({ + sourceState: testSourceState("2026-03-29T13:10:00Z"), }); - await expect(client.removeTrueEnd(11)).resolves.toEqual({ - revisionToken: "2026-03-29T13:11:00Z", + await expect(client.toggleTrueEnd(11, false)).resolves.toEqual({ + sourceState: testSourceState("2026-03-29T13:11:00Z"), }); const addTagRequestBody = getFetchBody(fetchMock, 0); @@ -753,7 +974,7 @@ describe("CatmaidClient skeleton editing methods", () => { }, }), ).resolves.toEqual({ - revisionToken: "2026-03-29T13:20:00Z", + sourceState: testSourceState("2026-03-29T13:20:00Z"), }); expect(getFetchPath(fetchMock)).toBe("treenodes/11/confidence"); diff --git a/src/datasource/catmaid/api.ts b/src/datasource/catmaid/api.ts index be5b7ac08..c5317f64c 100644 --- a/src/datasource/catmaid/api.ts +++ b/src/datasource/catmaid/api.ts @@ -18,24 +18,16 @@ import { Unpackr } from "msgpackr"; import { fetchOkWithCredentials } from "#src/credentials_provider/http_request.js"; import type { CredentialsProvider } from "#src/credentials_provider/index.js"; import type { - EditableSpatiallyIndexedSkeletonSource, - SpatiallyIndexedSkeletonAddNodeResult, - SpatiallyIndexedSkeletonDeleteNodeResult, - SpatiallyIndexedSkeletonDescriptionUpdateResult, - SpatiallyIndexedSkeletonEditContext, - SpatiallyIndexedSkeletonInsertNodeResult, - SpatiallyIndexedSkeletonMergeResult, + SpatialSkeletonBounds, + SpatialSkeletonSpatialIndexLevel, + SpatialSkeletonSourceState, + SpatialSkeletonVector, SpatiallyIndexedSkeletonMetadata, - SpatiallyIndexedSkeletonNavigationTarget, SpatiallyIndexedSkeletonNode, - SpatiallyIndexedSkeletonNodeRevisionResult, - SpatiallyIndexedSkeletonNodeRevisionUpdate, SpatiallyIndexedSkeletonNodeBase, - SpatiallyIndexedSkeletonRerootResult, - SpatiallyIndexedSkeletonSplitResult, } from "#src/skeleton/api.js"; -import { SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES } from "#src/skeleton/api.js"; -import { getDefaultSpatiallyIndexedSkeletonChunkSize } from "#src/skeleton/spatial_chunk_sizing.js"; +import { SpatialSkeletonEditConflictError } from "#src/skeleton/edit_errors.js"; +import type { SpatiallyIndexedSkeletonNavigationTarget } from "#src/skeleton/navigation.js"; import { HttpError } from "#src/util/http_request.js"; interface CatmaidStackInfo { @@ -44,7 +36,11 @@ interface CatmaidStackInfo { translation: { x: number; y: number; z: number }; metadata?: { cache_provider?: string; - spatial_skeleton_chunk_sizes?: Array; + read_only?: boolean; + spatial?: Array<{ + chunk_size: SpatialSkeletonVector; + limit: number; + }>; }; } @@ -59,9 +55,142 @@ const CATMAID_STATE_MATCHING_ERROR_TYPE = "StateMatchingError"; type CatmaidStatePayload = object; +export type CatmaidNodeSourceState = { readonly revisionToken: string }; + +export interface CatmaidEditNodeContext { + nodeId: number; + parentNodeId?: number; + revisionToken: string; +} + +export interface CatmaidEditParentContext { + nodeId: number; + revisionToken: string; +} + +export interface CatmaidEditContext { + node?: CatmaidEditNodeContext; + parent?: CatmaidEditParentContext; + children?: readonly CatmaidEditParentContext[]; + nodes?: readonly CatmaidEditParentContext[]; +} + +export interface CatmaidSkeletonNodeSourceStateUpdate { + nodeId: number; + sourceState: SpatialSkeletonSourceState; +} + +export interface CatmaidSkeletonEditResult { + nodeSourceStateUpdates?: readonly CatmaidSkeletonNodeSourceStateUpdate[]; +} + +export interface CatmaidAddNodeResult extends CatmaidSkeletonEditResult { + nodeId: number; + segmentId: number; + sourceState?: SpatialSkeletonSourceState; + parentSourceState?: SpatialSkeletonSourceState; +} + +export type CatmaidInsertNodeResult = CatmaidAddNodeResult; + +export interface CatmaidNodeSourceStateResult + extends CatmaidSkeletonEditResult { + sourceState?: SpatialSkeletonSourceState; +} + +export interface CatmaidDescriptionUpdateResult + extends CatmaidNodeSourceStateResult { + description?: string; +} + +export interface CatmaidDescriptionUpdateOptions { + isTrueEnd?: boolean; +} + +export type CatmaidDeleteNodeResult = CatmaidSkeletonEditResult; + +export type CatmaidRerootResult = CatmaidSkeletonEditResult; + +export interface CatmaidMergeResult extends CatmaidSkeletonEditResult { + resultSegmentId: number | undefined; + deletedSegmentId: number | undefined; + directionAdjusted: boolean; +} + +export interface CatmaidSplitResult extends CatmaidSkeletonEditResult { + existingSegmentId: number | undefined; + newSegmentId: number | undefined; +} + +export interface CatmaidSpatialSkeletonEditApi { + getSkeletonRootNode( + skeletonId: number, + ): Promise; + addNode( + skeletonId: number, + x: number, + y: number, + z: number, + parentId?: number, + editContext?: CatmaidEditContext, + ): Promise; + deleteNode( + nodeId: number, + options: CatmaidDeleteNodeOptions, + ): Promise; + moveNode( + nodeId: number, + x: number, + y: number, + z: number, + editContext?: CatmaidEditContext, + ): Promise; + splitSkeleton( + nodeId: number, + editContext?: CatmaidEditContext, + ): Promise; + mergeSkeletons( + fromNodeId: number, + toNodeId: number, + editContext?: CatmaidEditContext, + ): Promise; + toggleTrueEnd( + nodeId: number, + nextIsTrueEnd: boolean, + ): Promise; + insertNode( + skeletonId: number, + x: number, + y: number, + z: number, + parentId: number, + childNodeIds: readonly number[], + editContext?: CatmaidEditContext, + ): Promise; + rerootSkeleton( + nodeId: number, + editContext?: CatmaidEditContext, + ): Promise; + updateDescription( + nodeId: number, + description: string, + options?: CatmaidDescriptionUpdateOptions, + ): Promise; + updateRadius( + nodeId: number, + radius: number, + editContext?: CatmaidEditContext, + ): Promise; + updateConfidence( + nodeId: number, + confidence: number, + editContext?: CatmaidEditContext, + ): Promise; +} + interface CatmaidDeleteNodeOptions { childNodeIds?: readonly number[]; - editContext?: SpatiallyIndexedSkeletonEditContext; + editContext?: CatmaidEditContext; } class CatmaidNotFoundError extends Error { @@ -71,7 +200,7 @@ class CatmaidNotFoundError extends Error { } } -export class CatmaidStateValidationError extends Error { +export class CatmaidStateValidationError extends SpatialSkeletonEditConflictError { constructor(detail?: string) { super( detail === undefined @@ -82,6 +211,36 @@ export class CatmaidStateValidationError extends Error { } } +export function makeCatmaidNodeSourceState( + revisionToken: string | undefined, +): CatmaidNodeSourceState | undefined { + return revisionToken === undefined ? undefined : { revisionToken }; +} + +export function getCatmaidRevisionToken( + sourceState: unknown, +): string | undefined { + if (typeof sourceState === "string") { + return sourceState.trim().length === 0 ? undefined : sourceState; + } + if ( + sourceState !== null && + typeof sourceState === "object" && + typeof (sourceState as { revisionToken?: unknown }).revisionToken === + "string" + ) { + const revisionToken = ( + sourceState as { revisionToken: string } + ).revisionToken.trim(); + return revisionToken.length === 0 ? undefined : revisionToken; + } + return undefined; +} + +export const CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES = [ + 0, 25, 50, 75, 100, +] as const; + const CATMAID_TRUE_END_LABEL = "ends"; const CATMAID_CLOSED_END_LABEL_PATTERNS = [ /^uncertain continuation$/i, @@ -327,38 +486,34 @@ function mapCatmaidConfidenceToPercent(confidence: number | undefined) { const normalized = Math.max( 1, Math.min( - SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES.length, + CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES.length, Math.round(confidence), ), ); - return SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES[normalized - 1]; + return CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES[normalized - 1]; } function mapPercentConfidenceToCatmaid(confidence: number) { const normalized = Math.max( - SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES[0], + CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES[0], Math.min( - SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES[ - SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES.length - 1 + CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES[ + CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES.length - 1 ], confidence, ), ); let bestIndex = 0; let bestDistance = Math.abs( - SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES[0] - normalized, + CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES[0] - normalized, ); - for ( - let i = 1; - i < SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES.length; - ++i - ) { - const candidate = SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES[i]; + for (let i = 1; i < CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES.length; ++i) { + const candidate = CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES[i]; const distance = Math.abs(candidate - normalized); if ( distance < bestDistance || (distance === bestDistance && - candidate > SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES[bestIndex]) + candidate > CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES[bestIndex]) ) { bestDistance = distance; bestIndex = i; @@ -367,10 +522,9 @@ function mapPercentConfidenceToCatmaid(confidence: number) { return bestIndex + 1; } -function getCatmaidProjectSpaceBounds(info: CatmaidStackInfo): { - min: { x: number; y: number; z: number }; - max: { x: number; y: number; z: number }; -} { +function getCatmaidProjectSpaceBounds( + info: CatmaidStackInfo, +): SpatialSkeletonBounds { const { dimension, resolution, translation } = info; const offsetX = translation?.x ?? 0; const offsetY = translation?.y ?? 0; @@ -378,32 +532,119 @@ function getCatmaidProjectSpaceBounds(info: CatmaidStackInfo): { // CATMAID treenode coordinates and grid cache cell sizes are in project-space nanometers. return { - min: { x: offsetX, y: offsetY, z: offsetZ }, - max: { - x: offsetX + dimension.x * resolution.x, - y: offsetY + dimension.y * resolution.y, - z: offsetZ + dimension.z * resolution.z, - }, + lowerBounds: [offsetX, offsetY, offsetZ], + upperBounds: [ + offsetX + dimension.x * resolution.x, + offsetY + dimension.y * resolution.y, + offsetZ + dimension.z * resolution.z, + ], }; } -function normalizeBoundingBoxForNodeList(boundingBox: { - min: { x: number; y: number; z: number }; - max: { x: number; y: number; z: number }; -}) { - const left = Math.floor(boundingBox.min.x); - const top = Math.floor(boundingBox.min.y); - const z1 = Math.floor(boundingBox.min.z); +function requireCatmaidRank3Vector( + vector: SpatialSkeletonVector, + label: string, +): readonly [number, number, number] { + if (vector.length < 3) { + throw new Error(`CATMAID ${label} requires at least 3 coordinates.`); + } + const values = [ + Number(vector[0]), + Number(vector[1]), + Number(vector[2]), + ] as const; + if (values.some((value) => !Number.isFinite(value))) { + throw new Error(`CATMAID ${label} coordinates must be finite.`); + } + return values; +} + +function requireCatmaidPositiveRank3Vector( + value: unknown, + label: string, +): readonly [number, number, number] { + if (!Array.isArray(value) || value.length !== 3) { + throw new Error(`CATMAID ${label} must be a rank-3 array.`); + } + const values = [ + Number(value[0]), + Number(value[1]), + Number(value[2]), + ] as const; + if (values.some((x) => !Number.isFinite(x) || x <= 0)) { + throw new Error( + `CATMAID ${label} coordinates must be finite and positive.`, + ); + } + return values; +} + + +function requireCatmaidNonNegativeInt(value: unknown, label: string): number { + const numberValue = Number(value); + if (!Number.isInteger(numberValue) || numberValue < 0) { + throw new Error(`CATMAID ${label} must be a non-negative integer.`); + } + return numberValue; +} + +function parseOptionalCatmaidBoolean( + value: unknown, + label: string, +): boolean | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== "boolean") { + throw new Error(`CATMAID ${label} must be a boolean.`); + } + return value; +} + +function normalizeBoundingBoxForNodeList(bounds: SpatialSkeletonBounds) { + const [minX, minY, minZ] = requireCatmaidRank3Vector( + bounds.lowerBounds, + "node-list lower bound", + ); + const [maxX, maxY, maxZ] = requireCatmaidRank3Vector( + bounds.upperBounds, + "node-list upper bound", + ); + const left = Math.floor(minX); + const top = Math.floor(minY); + const z1 = Math.floor(minZ); // CATMAID treats right/bottom as inclusive and z2 as exclusive for grid-cell index filtering. // Use ceil and ensure a positive extent on each axis. - const right = Math.max(left + 1, Math.ceil(boundingBox.max.x)); - const bottom = Math.max(top + 1, Math.ceil(boundingBox.max.y)); - const z2 = Math.max(z1 + 1, Math.ceil(boundingBox.max.z)); + const right = Math.max(left + 1, Math.ceil(maxX)); + const bottom = Math.max(top + 1, Math.ceil(maxY)); + const z2 = Math.max(z1 + 1, Math.ceil(maxZ)); return { left, top, z1, right, bottom, z2 }; } +export function getCatmaidSpatialSkeletonGridCellBounds( + cellIndex: SpatialSkeletonVector, + chunkSize: SpatialSkeletonVector, +): SpatialSkeletonBounds { + const [cellX, cellY, cellZ] = requireCatmaidRank3Vector( + cellIndex, + "spatial skeleton grid cell index", + ); + const [sizeX, sizeY, sizeZ] = requireCatmaidRank3Vector( + chunkSize, + "spatial skeleton grid cell size", + ); + return { + lowerBounds: [cellX * sizeX, cellY * sizeY, cellZ * sizeZ], + upperBounds: [ + (cellX + 1) * sizeX, + (cellY + 1) * sizeY, + (cellZ + 1) * sizeZ, + ], + }; +} + function appendNodeUpdateRows( body: URLSearchParams, key: string, @@ -517,7 +758,7 @@ function parseCatmaidSkeletonRootTarget( Number.isFinite(py) && Number.isFinite(pz) ) { - return { nodeId, x: px, y: py, z: pz }; + return { nodeId, position: [px, py, pz] }; } throw new Error( @@ -540,7 +781,7 @@ function requireCatmaidRevisionToken( function buildCatmaidNodeState( operation: string, - editContext?: SpatiallyIndexedSkeletonEditContext, + editContext?: CatmaidEditContext, expectedNodeId?: number, ) { const node = editContext?.node; @@ -563,7 +804,7 @@ function buildCatmaidNodeState( function buildCatmaidMultiNodeState( operation: string, - editContext?: SpatiallyIndexedSkeletonEditContext, + editContext?: CatmaidEditContext, expectedNodeIds?: readonly number[], ) { const nodes = @@ -589,7 +830,7 @@ function buildCatmaidMultiNodeState( function buildCatmaidAddNodeState( parentId: number | undefined, - editContext?: SpatiallyIndexedSkeletonEditContext, + editContext?: CatmaidEditContext, ) { if (parentId === undefined) { return { @@ -621,7 +862,7 @@ function buildCatmaidAddNodeState( function buildCatmaidNeighborhoodState( operation: string, - editContext?: SpatiallyIndexedSkeletonEditContext, + editContext?: CatmaidEditContext, options: { expectedNodeId?: number; expectedChildIds?: readonly number[]; @@ -703,7 +944,7 @@ function buildCatmaidNeighborhoodState( function buildCatmaidInsertNodeState( parentId: number, childNodeIds: readonly number[], - editContext?: SpatiallyIndexedSkeletonEditContext, + editContext?: CatmaidEditContext, ) { const parentNode = editContext?.node; if (parentNode === undefined) { @@ -743,19 +984,20 @@ function buildCatmaidInsertNodeState( function getCatmaidSingleNodeRevisionResult( revisionToken: string | undefined, -): SpatiallyIndexedSkeletonNodeRevisionResult { - return revisionToken === undefined ? {} : { revisionToken }; +): CatmaidNodeSourceStateResult { + const sourceState = makeCatmaidNodeSourceState(revisionToken); + return sourceState === undefined ? {} : { sourceState }; } function parseCatmaidNodeRevisionUpdates( rows: unknown, -): SpatiallyIndexedSkeletonNodeRevisionUpdate[] { +): CatmaidSkeletonNodeSourceStateUpdate[] { if (!Array.isArray(rows)) { throw new Error( "CATMAID treenodes/compact-detail endpoint returned an unexpected response format.", ); } - const revisionUpdates: SpatiallyIndexedSkeletonNodeRevisionUpdate[] = []; + const revisionUpdates: CatmaidSkeletonNodeSourceStateUpdate[] = []; for (const row of rows) { if (!Array.isArray(row) || row.length < 9) continue; const nodeId = Number(row[0]); @@ -763,7 +1005,7 @@ function parseCatmaidNodeRevisionUpdates( if (!Number.isFinite(nodeId) || revisionToken === undefined) continue; revisionUpdates.push({ nodeId: Math.round(nodeId), - revisionToken, + sourceState: { revisionToken }, }); } return revisionUpdates; @@ -826,8 +1068,8 @@ function parseCatmaidConfidenceRevisionToken( function parseCatmaidChildRevisionUpdates( value: unknown, -): readonly SpatiallyIndexedSkeletonNodeRevisionUpdate[] { - const revisionUpdates: SpatiallyIndexedSkeletonNodeRevisionUpdate[] = []; +): readonly CatmaidSkeletonNodeSourceStateUpdate[] { + const revisionUpdates: CatmaidSkeletonNodeSourceStateUpdate[] = []; const children = Array.isArray(value) ? value : []; for (const child of children) { if (!Array.isArray(child) || child.length < 2) continue; @@ -836,7 +1078,7 @@ function parseCatmaidChildRevisionUpdates( if (!Number.isFinite(nodeId) || revisionToken === undefined) continue; revisionUpdates.push({ nodeId: Math.round(nodeId), - revisionToken, + sourceState: { revisionToken }, }); } return revisionUpdates; @@ -844,7 +1086,7 @@ function parseCatmaidChildRevisionUpdates( function parseCatmaidDeleteRevisionUpdates( response: any, -): readonly SpatiallyIndexedSkeletonNodeRevisionUpdate[] { +): readonly CatmaidSkeletonNodeSourceStateUpdate[] { return parseCatmaidChildRevisionUpdates(response?.children); } @@ -878,7 +1120,7 @@ function fetchWithCatmaidCredentials( ); } -export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { +export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { private metadataInfoPromise: Promise | undefined; private readonly msgpackUnpackr = new Unpackr({ mapsAsObjects: false, @@ -1010,36 +1252,69 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { } } - private getGridCellSizesFromMetadataInfo( - info: CatmaidStackInfo, - bounds = getCatmaidProjectSpaceBounds(info), - ): Array<{ x: number; y: number; z: number }> { - const gridSizes: Array<{ x: number; y: number; z: number }> = []; - - // Try to get all allowed spatial skeleton chunk sizes from metadata. - if (info.metadata?.spatial_skeleton_chunk_sizes) { - for (const chunkSize of info.metadata.spatial_skeleton_chunk_sizes) { - if ( - chunkSize.length === 3 && - Number.isFinite(chunkSize[0]) && - Number.isFinite(chunkSize[1]) && - Number.isFinite(chunkSize[2]) - ) { - gridSizes.push({ - x: chunkSize[0], - y: chunkSize[1], - z: chunkSize[2], - }); - } - } + private getSpatialIndexLevelsFromSpatialMetadata( + metadata: CatmaidStackInfo["metadata"], + extents: readonly number[], + ): SpatialSkeletonSpatialIndexLevel[] { + const spatial = metadata?.spatial; + if (spatial === undefined) { + throw new Error( + "CATMAID stack metadata must define spatial skeleton metadata at metadata.spatial.", + ); } - - // If no chunk sizes are specified, use the bounds-derived default. - if (gridSizes.length === 0) { - gridSizes.push(getDefaultSpatiallyIndexedSkeletonChunkSize(bounds)); + if (!Array.isArray(spatial) || spatial.length === 0) { + throw new Error( + "CATMAID stack metadata.spatial must be a non-empty spatial skeleton metadata array.", + ); } + return spatial.map((level, index) => { + const chunkSize = requireCatmaidPositiveRank3Vector( + level?.chunk_size, + `spatial skeleton metadata spatial[${index}].chunk_size`, + ); + const limit = requireCatmaidNonNegativeInt( + level?.limit, + `spatial skeleton metadata spatial[${index}].limit`, + ); + return { + chunkSize, + gridShape: chunkSize.map((size, dim) => + Math.max(1, Math.ceil(extents[dim] / size)), + ), + limit, + }; + }); + } + + private getSpatialIndexLevelsFromMetadataInfo( + info: CatmaidStackInfo, + bounds = getCatmaidProjectSpaceBounds(info), + ): SpatialSkeletonSpatialIndexLevel[] { + const [lowerX, lowerY, lowerZ] = requireCatmaidRank3Vector( + bounds.lowerBounds, + "spatial metadata lower bound", + ); + const [upperX, upperY, upperZ] = requireCatmaidRank3Vector( + bounds.upperBounds, + "spatial metadata upper bound", + ); + const extents = [upperX - lowerX, upperY - lowerY, upperZ - lowerZ]; + return this.getSpatialIndexLevelsFromSpatialMetadata( + info.metadata, + extents, + ); + } - return gridSizes; + private getSpatialSkeletonReadOnlyFromMetadataInfo( + info: CatmaidStackInfo, + ): boolean { + const metadata = info.metadata; + return ( + parseOptionalCatmaidBoolean( + metadata?.read_only, + "spatial skeleton metadata read_only", + ) ?? true + ); } async getSpatialIndexMetadata(): Promise { @@ -1049,9 +1324,9 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { } const bounds = getCatmaidProjectSpaceBounds(info); return { - bounds, - resolution: info.resolution, - gridCellSizes: this.getGridCellSizesFromMetadataInfo(info, bounds), + ...bounds, + spatial: this.getSpatialIndexLevelsFromMetadataInfo(info, bounds), + readOnly: this.getSpatialSkeletonReadOnlyFromMetadataInfo(info), }; } @@ -1064,12 +1339,19 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { skeletonId: number, options: { signal?: AbortSignal } = {}, ): Promise { + const { signal } = options; let data: any; + let currentData: any; try { - data = await this.fetch( - `skeletons/${skeletonId}/compact-detail?with_tags=true&with_history=true`, - { signal: options.signal }, - ); + [data, currentData] = await Promise.all([ + this.fetch( + `skeletons/${skeletonId}/compact-detail?with_tags=true&with_history=true`, + { signal }, + ), + this.fetch(`skeletons/${skeletonId}/compact-detail?with_tags=true`, { + signal, + }), + ]); } catch (error) { if (error instanceof CatmaidNotFoundError) { return []; @@ -1078,7 +1360,7 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { } } const rawNodes = Array.isArray(data?.[0]) ? data[0] : []; - const labelsByNodeId = parseCatmaidNodeLabels(data?.[2]); + const labelsByNodeId = parseCatmaidNodeLabels(currentData?.[2]); const descriptionByNodeId = getCatmaidNodeDescriptions(labelsByNodeId); const trueEndByNodeId = getCatmaidTrueEndNodes(labelsByNodeId); const liveNodes = new Map(); @@ -1107,15 +1389,14 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { : undefined, description: descriptionByNodeId.get(Number(n[0])), isTrueEnd: trueEndByNodeId.has(Number(n[0])), - revisionToken: getCatmaidHistoryRevisionToken(n), + sourceState: makeCatmaidNodeSourceState( + getCatmaidHistoryRevisionToken(n), + ), })); } - async fetchNodes( - boundingBox: { - min: { x: number; y: number; z: number }; - max: { x: number; y: number; z: number }; - }, + async fetchNodesInBoundingBox( + bounds: SpatialSkeletonBounds, lod: number = 0, options: { cacheProvider?: string; @@ -1123,7 +1404,7 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { } = {}, ): Promise { const { cacheProvider, signal } = options; - const normalizedBoundingBox = normalizeBoundingBoxForNodeList(boundingBox); + const normalizedBoundingBox = normalizeBoundingBoxForNodeList(bounds); const params = new URLSearchParams({ left: normalizedBoundingBox.left.toString(), top: normalizedBoundingBox.top.toString(), @@ -1179,7 +1460,9 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { parentNodeId: n[1] ?? undefined, position: new Float32Array([n[2], n[3], n[4]]), segmentId: n[7], - revisionToken: normalizeCatmaidRevisionToken(n[8]), + sourceState: makeCatmaidNodeSourceState( + normalizeCatmaidRevisionToken(n[8]), + ), }), ); @@ -1200,7 +1483,9 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { parentNodeId: n[1] ?? undefined, position: new Float32Array([n[2], n[3], n[4]]), segmentId: n[7], - revisionToken: normalizeCatmaidRevisionToken(n[8]), + sourceState: makeCatmaidNodeSourceState( + normalizeCatmaidRevisionToken(n[8]), + ), }); } } @@ -1215,8 +1500,8 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { x: number, y: number, z: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise { + editContext?: CatmaidEditContext, + ): Promise { const body = new URLSearchParams(); appendNodeUpdateRows(body, "t", [[nodeId, x, y, z]]); appendCatmaidState( @@ -1242,8 +1527,8 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { async rerootSkeleton( nodeId: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise { + editContext?: CatmaidEditContext, + ): Promise { const body = new URLSearchParams({ treenode_id: nodeId.toString(), }); @@ -1263,13 +1548,14 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { .filter((value) => Number.isFinite(value)) .map((value) => Math.round(value)) ?? []; return { - nodeRevisionUpdates: await this.fetchNodeRevisionUpdates(rerootedNodeIds), + nodeSourceStateUpdates: + await this.fetchNodeRevisionUpdates(rerootedNodeIds), }; } private async fetchNodeRevisionUpdates( nodeIds: readonly number[], - ): Promise { + ): Promise { const normalizedNodeIds = [ ...new Set( nodeIds @@ -1305,7 +1591,7 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { async deleteNode( nodeId: number, options: CatmaidDeleteNodeOptions = {}, - ): Promise { + ): Promise { const { childNodeIds = [], editContext } = options; const normalizedChildIds = [ ...new Set( @@ -1333,7 +1619,7 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { throw new Error("Delete endpoint returned an unexpected response."); } return { - nodeRevisionUpdates: parseCatmaidDeleteRevisionUpdates(response), + nodeSourceStateUpdates: parseCatmaidDeleteRevisionUpdates(response), }; } @@ -1343,8 +1629,8 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { y: number, z: number, parentId?: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise { + editContext?: CatmaidEditContext, + ): Promise { const body = new URLSearchParams({ x: x.toString(), y: y.toString(), @@ -1373,11 +1659,13 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { ); } return { - treenodeId, - skeletonId: nextSkeletonId, - revisionToken: normalizeCatmaidRevisionToken(res?.edition_time), - parentRevisionToken: normalizeCatmaidRevisionToken( - res?.parent_edition_time, + nodeId: Math.round(treenodeId), + segmentId: Math.round(nextSkeletonId), + sourceState: makeCatmaidNodeSourceState( + normalizeCatmaidRevisionToken(res?.edition_time), + ), + parentSourceState: makeCatmaidNodeSourceState( + normalizeCatmaidRevisionToken(res?.parent_edition_time), ), }; } @@ -1389,8 +1677,8 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { z: number, parentId: number, childNodeIds: readonly number[], - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise { + editContext?: CatmaidEditContext, + ): Promise { const normalizedChildIds = [ ...new Set( childNodeIds @@ -1437,13 +1725,15 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { ); } return { - treenodeId: Math.round(treenodeId), - skeletonId: Math.round(nextSkeletonId), - revisionToken: normalizeCatmaidRevisionToken(response?.edition_time), - parentRevisionToken: normalizeCatmaidRevisionToken( - response?.parent_edition_time, + nodeId: Math.round(treenodeId), + segmentId: Math.round(nextSkeletonId), + sourceState: makeCatmaidNodeSourceState( + normalizeCatmaidRevisionToken(response?.edition_time), ), - nodeRevisionUpdates: parseCatmaidChildRevisionUpdates( + parentSourceState: makeCatmaidNodeSourceState( + normalizeCatmaidRevisionToken(response?.parent_edition_time), + ), + nodeSourceStateUpdates: parseCatmaidChildRevisionUpdates( response?.child_edition_times, ), }; @@ -1530,9 +1820,14 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { async updateDescription( nodeId: number, description: string, - ): Promise { + options: CatmaidDescriptionUpdateOptions = {}, + ): Promise { const normalizedLabels = this.buildDescriptionLabels(description); - const response = await this.replaceNodeLabels(nodeId, normalizedLabels); + const labels = + options.isTrueEnd === true + ? [...normalizedLabels, CATMAID_TRUE_END_LABEL] + : normalizedLabels; + const response = await this.replaceNodeLabels(nodeId, labels); return { ...getCatmaidSingleNodeRevisionResult( normalizeCatmaidRevisionToken(response?.edition_time), @@ -1542,29 +1837,38 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { }; } - async setTrueEnd( + private async addTrueEndLabel( nodeId: number, - ): Promise { + ): Promise { const response = await this.addNodeLabel(nodeId, CATMAID_TRUE_END_LABEL); return getCatmaidSingleNodeRevisionResult( normalizeCatmaidRevisionToken((response as any)?.edition_time), ); } - async removeTrueEnd( + private async removeTrueEndLabel( nodeId: number, - ): Promise { + ): Promise { const response = await this.removeNodeLabel(nodeId, CATMAID_TRUE_END_LABEL); return getCatmaidSingleNodeRevisionResult( normalizeCatmaidRevisionToken((response as any)?.edition_time), ); } + toggleTrueEnd( + nodeId: number, + nextIsTrueEnd: boolean, + ): Promise { + return nextIsTrueEnd + ? this.addTrueEndLabel(nodeId) + : this.removeTrueEndLabel(nodeId); + } + async updateRadius( nodeId: number, radius: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise { + editContext?: CatmaidEditContext, + ): Promise { if (!Number.isFinite(radius)) { throw new Error("Radius must be a finite number."); } @@ -1587,8 +1891,8 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { async updateConfidence( nodeId: number, confidence: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise { + editContext?: CatmaidEditContext, + ): Promise { if (!Number.isFinite(confidence) || confidence < 0 || confidence > 100) { throw new Error("Confidence must be between 0 and 100."); } @@ -1611,8 +1915,8 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { async mergeSkeletons( fromNodeId: number, toNodeId: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise { + editContext?: CatmaidEditContext, + ): Promise { const body = new URLSearchParams({ from_id: fromNodeId.toString(), to_id: toNodeId.toString(), @@ -1631,20 +1935,20 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { const resultSkeletonId = Number(response?.result_skeleton_id); const deletedSkeletonId = Number(response?.deleted_skeleton_id); return { - resultSkeletonId: Number.isFinite(resultSkeletonId) + resultSegmentId: Number.isFinite(resultSkeletonId) ? Math.round(resultSkeletonId) : undefined, - deletedSkeletonId: Number.isFinite(deletedSkeletonId) + deletedSegmentId: Number.isFinite(deletedSkeletonId) ? Math.round(deletedSkeletonId) : undefined, - stableAnnotationSwap: Boolean(response?.stable_annotation_swap), + directionAdjusted: Boolean(response?.stable_annotation_swap), }; } async splitSkeleton( nodeId: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise { + editContext?: CatmaidEditContext, + ): Promise { const body = new URLSearchParams({ treenode_id: nodeId.toString(), }); @@ -1661,10 +1965,10 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { const existingSkeletonId = Number(response?.existing_skeleton_id); const newSkeletonId = Number(response?.new_skeleton_id); return { - existingSkeletonId: Number.isFinite(existingSkeletonId) + existingSegmentId: Number.isFinite(existingSkeletonId) ? Math.round(existingSkeletonId) : undefined, - newSkeletonId: Number.isFinite(newSkeletonId) + newSegmentId: Number.isFinite(newSkeletonId) ? Math.round(newSkeletonId) : undefined, }; diff --git a/src/datasource/catmaid/backend.ts b/src/datasource/catmaid/backend.ts index d4d42e622..8a3d52172 100644 --- a/src/datasource/catmaid/backend.ts +++ b/src/datasource/catmaid/backend.ts @@ -17,7 +17,10 @@ import { WithParameters } from "#src/chunk_manager/backend.js"; import { WithSharedCredentialsProviderCounterpart } from "#src/credentials_provider/shared_counterpart.js"; import type { CatmaidToken } from "#src/datasource/catmaid/api.js"; -import { CatmaidClient } from "#src/datasource/catmaid/api.js"; +import { + CatmaidClient, + getCatmaidSpatialSkeletonGridCellBounds, +} from "#src/datasource/catmaid/api.js"; import { CatmaidSkeletonSourceParameters, CatmaidCompleteSkeletonSourceParameters, @@ -31,7 +34,6 @@ import { SpatiallyIndexedSkeletonSourceBackend, SkeletonSource, } from "#src/skeleton/backend.js"; -import { vec3 } from "#src/util/geom.js"; import { registerSharedObject } from "#src/worker_rpc.js"; @registerSharedObject() @@ -64,28 +66,13 @@ export class CatmaidSpatiallyIndexedSkeletonSourceBackend extends WithParameters async download(chunk: SpatiallyIndexedSkeletonChunk, signal: AbortSignal) { const { chunkGridPosition } = chunk; const { chunkDataSize } = this.spec; - - const localMin = vec3.multiply( - vec3.create(), - chunkGridPosition as unknown as vec3, - chunkDataSize as unknown as vec3, - ); - const localMax = vec3.add( - vec3.create(), - localMin, - chunkDataSize as unknown as vec3, + const bounds = getCatmaidSpatialSkeletonGridCellBounds( + chunkGridPosition, + chunkDataSize, ); - - const bbox = { - min: { x: localMin[0], y: localMin[1], z: localMin[2] }, - max: { x: localMax[0], y: localMax[1], z: localMax[2] }, - }; - - // Use LOD stored on the chunk to support per-view LODs on shared sources. - const lodValue = chunk.lod ?? this.currentLod; - // Get cache provider from parameters (passed from frontend) + const lodValue = this.parameters.catmaidLod ?? 0; const cacheProvider = this.parameters.catmaidParameters.cacheProvider; - const nodes = await this.client.fetchNodes(bbox, lodValue, { + const nodes = await this.client.fetchNodesInBoundingBox(bounds, lodValue, { cacheProvider, signal, }); @@ -97,7 +84,7 @@ export class CatmaidSpatiallyIndexedSkeletonSourceBackend extends WithParameters // Pack only segment IDs into vertexAttributes (positions are in vertexPositions) chunk.vertexAttributes = [packed.segmentIds]; chunk.nodeIds = packed.nodeIds; - chunk.nodeRevisionTokens = packed.revisionTokens; + chunk.nodeSourceStates = packed.sourceStates; } } diff --git a/src/datasource/catmaid/base.ts b/src/datasource/catmaid/base.ts index e1a7406f2..ad8da7511 100644 --- a/src/datasource/catmaid/base.ts +++ b/src/datasource/catmaid/base.ts @@ -20,11 +20,13 @@ export class CatmaidDataSourceParameters { url!: string; projectId!: number; cacheProvider?: string; + readOnly = true; } export class CatmaidSkeletonSourceParameters extends SkeletonSourceParameters { catmaidParameters!: CatmaidDataSourceParameters; gridIndex?: number; + catmaidLod?: number; static RPC_ID = "catmaid/SkeletonSource"; } diff --git a/src/datasource/catmaid/edit_state.ts b/src/datasource/catmaid/edit_state.ts new file mode 100644 index 000000000..6886f317e --- /dev/null +++ b/src/datasource/catmaid/edit_state.ts @@ -0,0 +1,100 @@ +import type { + CatmaidEditContext, + CatmaidEditNodeContext, + CatmaidEditParentContext, +} from "#src/datasource/catmaid/api.js"; +import { getCatmaidRevisionToken } from "#src/datasource/catmaid/api.js"; +import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; +import { + getSpatiallyIndexedSkeletonDirectChildren, + getSpatiallyIndexedSkeletonNodeParent, + getSpatiallyIndexedSkeletonPathToRoot, +} from "#src/skeleton/edit_state.js"; + +function requireRevisionToken( + node: SpatiallyIndexedSkeletonNode, + role: string, +): string { + const revisionToken = getCatmaidRevisionToken(node.sourceState); + if (revisionToken === undefined) { + throw new Error( + `Inspected CATMAID ${role} node ${node.nodeId} is missing revision metadata.`, + ); + } + return revisionToken; +} + +export function toCatmaidEditNodeContext( + node: SpatiallyIndexedSkeletonNode, +): CatmaidEditNodeContext { + return { + nodeId: node.nodeId, + parentNodeId: node.parentNodeId, + revisionToken: requireRevisionToken(node, "target"), + }; +} + +export function toCatmaidEditParentContext( + node: SpatiallyIndexedSkeletonNode, +): CatmaidEditParentContext { + return { + nodeId: node.nodeId, + revisionToken: requireRevisionToken(node, "related"), + }; +} + +export function buildCatmaidNodeEditContext( + node: SpatiallyIndexedSkeletonNode, +): CatmaidEditContext { + return { + node: toCatmaidEditNodeContext(node), + }; +} + +export function buildCatmaidNeighborhoodEditContext( + node: SpatiallyIndexedSkeletonNode, + segmentNodes: readonly SpatiallyIndexedSkeletonNode[], +): CatmaidEditContext { + const parentNode = getSpatiallyIndexedSkeletonNodeParent(segmentNodes, node); + const childNodes = getSpatiallyIndexedSkeletonDirectChildren( + segmentNodes, + node.nodeId, + ); + return { + node: toCatmaidEditNodeContext(node), + ...(parentNode === undefined + ? {} + : { parent: toCatmaidEditParentContext(parentNode) }), + children: childNodes.map(toCatmaidEditParentContext), + }; +} + +export function buildCatmaidRerootEditContext( + node: SpatiallyIndexedSkeletonNode, + segmentNodes: readonly SpatiallyIndexedSkeletonNode[], +): CatmaidEditContext { + return { + ...buildCatmaidNeighborhoodEditContext(node, segmentNodes), + nodes: getSpatiallyIndexedSkeletonPathToRoot(segmentNodes, node).map( + toCatmaidEditParentContext, + ), + }; +} + +export function buildCatmaidInsertEditContext( + parentNode: SpatiallyIndexedSkeletonNode, + childNodes: readonly SpatiallyIndexedSkeletonNode[], +): CatmaidEditContext { + return { + node: buildCatmaidNodeEditContext(parentNode).node, + children: childNodes.map(toCatmaidEditParentContext), + }; +} + +export function buildCatmaidMultiNodeEditContext( + ...nodes: SpatiallyIndexedSkeletonNode[] +): CatmaidEditContext { + return { + nodes: nodes.map(toCatmaidEditParentContext), + }; +} diff --git a/src/datasource/catmaid/frontend.ts b/src/datasource/catmaid/frontend.ts index 1ca882dcf..b024e25e9 100644 --- a/src/datasource/catmaid/frontend.ts +++ b/src/datasource/catmaid/frontend.ts @@ -24,12 +24,18 @@ import { import { WithCredentialsProvider } from "#src/credentials_provider/chunk_source_frontend.js"; import type { CredentialsProvider } from "#src/credentials_provider/index.js"; import type { CatmaidToken } from "#src/datasource/catmaid/api.js"; -import { CatmaidClient, credentialsKey } from "#src/datasource/catmaid/api.js"; +import { + CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES, + CatmaidClient, + credentialsKey, + getCatmaidSpatialSkeletonGridCellBounds, +} from "#src/datasource/catmaid/api.js"; import { CatmaidSkeletonSourceParameters, CatmaidCompleteSkeletonSourceParameters, CatmaidDataSourceParameters, } from "#src/datasource/catmaid/base.js"; +import { CatmaidSpatialSkeletonEditCommands } from "#src/datasource/catmaid/spatial_skeleton_commands.js"; import type { DataSource, DataSourceProvider, @@ -40,18 +46,11 @@ import { normalizeInlineSegmentPropertyMap, } from "#src/segmentation_display_state/property_map.js"; import type { - EditableSpatiallyIndexedSkeletonSource, - SpatiallyIndexedSkeletonAddNodeResult, - SpatiallyIndexedSkeletonDeleteNodeResult, - SpatiallyIndexedSkeletonDescriptionUpdateResult, - SpatiallyIndexedSkeletonEditContext, - SpatiallyIndexedSkeletonInsertNodeResult, - SpatiallyIndexedSkeletonMergeResult, + SpatialSkeletonConfidenceConfiguration, + SpatialSkeletonGridCellIndex, SpatiallyIndexedSkeletonMetadata, SpatiallyIndexedSkeletonNode, - SpatiallyIndexedSkeletonNodeRevisionResult, SpatiallyIndexedSkeletonNodeBase, - SpatiallyIndexedSkeletonSplitResult, } from "#src/skeleton/api.js"; import { SpatiallyIndexedSkeletonSource, @@ -67,15 +66,78 @@ import type { Borrowed } from "#src/util/disposable.js"; import { mat4, vec3 } from "#src/util/geom.js"; import "#src/datasource/catmaid/register_credentials_provider.js"; -export class CatmaidSpatiallyIndexedSkeletonSource - extends WithParameters( - WithCredentialsProvider()(SpatiallyIndexedSkeletonSource), - CatmaidSkeletonSourceParameters, - ) - implements EditableSpatiallyIndexedSkeletonSource -{ +const CATMAID_SPATIAL_SKELETON_CONFIDENCE_CONFIGURATION = { + values: CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES, +} satisfies SpatialSkeletonConfidenceConfiguration; + +export class CatmaidSpatiallyIndexedSkeletonSource extends WithParameters( + WithCredentialsProvider()(SpatiallyIndexedSkeletonSource), + CatmaidSkeletonSourceParameters, +) { + private readonly spatialSkeletonEditCommands = + new CatmaidSpatialSkeletonEditCommands({ + getClient: () => this.client, + }); private client_?: CatmaidClient; + get readOnly() { + return this.parameters.catmaidParameters.readOnly !== false; + } + + get spatialSkeletonConfidenceConfiguration() { + return this.readOnly + ? undefined + : CATMAID_SPATIAL_SKELETON_CONFIDENCE_CONFIGURATION; + } + + private get editableSpatialSkeletonEditCommands() { + return this.readOnly ? undefined : this.spatialSkeletonEditCommands; + } + + get addNodesCommand() { + return this.editableSpatialSkeletonEditCommands?.addNodesCommand; + } + + get insertNodesCommand() { + return this.editableSpatialSkeletonEditCommands?.insertNodesCommand; + } + + get moveNodesCommand() { + return this.editableSpatialSkeletonEditCommands?.moveNodesCommand; + } + + get deleteNodesCommand() { + return this.editableSpatialSkeletonEditCommands?.deleteNodesCommand; + } + + get rerootCommand() { + return this.editableSpatialSkeletonEditCommands?.rerootCommand; + } + + get editNodeDescriptionCommand() { + return this.editableSpatialSkeletonEditCommands?.editNodeDescriptionCommand; + } + + get editNodeTrueEndCommand() { + return this.editableSpatialSkeletonEditCommands?.editNodeTrueEndCommand; + } + + get editNodeRadiusCommand() { + return this.editableSpatialSkeletonEditCommands?.editNodeRadiusCommand; + } + + get editNodeConfidenceCommand() { + return this.editableSpatialSkeletonEditCommands?.editNodeConfidenceCommand; + } + + get mergeSkeletonsCommand() { + return this.editableSpatialSkeletonEditCommands?.mergeSkeletonsCommand; + } + + get splitSkeletonsCommand() { + return this.editableSpatialSkeletonEditCommands?.splitSkeletonsCommand; + } + private get client() { let client = this.client_; if (client !== undefined) { @@ -107,130 +169,28 @@ export class CatmaidSpatiallyIndexedSkeletonSource } fetchNodes( - boundingBox: { - min: { x: number; y: number; z: number }; - max: { x: number; y: number; z: number }; - }, - lod?: number, - options?: { - cacheProvider?: string; + cellIndex: SpatialSkeletonGridCellIndex, + options: { signal?: AbortSignal; - }, + } = {}, ): Promise { - return this.client.fetchNodes(boundingBox, lod, options); + const bounds = getCatmaidSpatialSkeletonGridCellBounds( + cellIndex.cell, + this.spec.chunkDataSize, + ); + return this.client.fetchNodesInBoundingBox( + bounds, + this.parameters.catmaidLod ?? 0, + { + cacheProvider: this.parameters.catmaidParameters.cacheProvider, + signal: options.signal, + }, + ); } getSkeletonRootNode(skeletonId: number) { return this.client.getSkeletonRootNode(skeletonId); } - - addNode( - skeletonId: number, - x: number, - y: number, - z: number, - parentId?: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise { - return this.client.addNode(skeletonId, x, y, z, parentId, editContext); - } - - insertNode( - skeletonId: number, - x: number, - y: number, - z: number, - parentId: number, - childNodeIds: readonly number[], - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise { - return this.client.insertNode( - skeletonId, - x, - y, - z, - parentId, - childNodeIds, - editContext, - ); - } - - moveNode( - nodeId: number, - x: number, - y: number, - z: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise { - return this.client.moveNode(nodeId, x, y, z, editContext); - } - - deleteNode( - nodeId: number, - options: { - childNodeIds?: readonly number[]; - editContext?: SpatiallyIndexedSkeletonEditContext; - }, - ): Promise { - return this.client.deleteNode(nodeId, options); - } - - rerootSkeleton( - nodeId: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ) { - return this.client.rerootSkeleton(nodeId, editContext); - } - - updateDescription( - nodeId: number, - description: string, - ): Promise { - return this.client.updateDescription(nodeId, description); - } - - setTrueEnd( - nodeId: number, - ): Promise { - return this.client.setTrueEnd(nodeId); - } - - removeTrueEnd( - nodeId: number, - ): Promise { - return this.client.removeTrueEnd(nodeId); - } - - updateRadius( - nodeId: number, - radius: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise { - return this.client.updateRadius(nodeId, radius, editContext); - } - - updateConfidence( - nodeId: number, - confidence: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise { - return this.client.updateConfidence(nodeId, confidence, editContext); - } - - mergeSkeletons( - fromNodeId: number, - toNodeId: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise { - return this.client.mergeSkeletons(fromNodeId, toNodeId, editContext); - } - - splitSkeleton( - nodeId: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise { - return this.client.splitSkeleton(nodeId, editContext); - } } export class CatmaidSkeletonSource extends WithParameters( @@ -259,6 +219,7 @@ export class CatmaidMultiscaleSpatiallyIndexedSkeletonSource extends MultiscaleS private upperBoundsInNanometers: Float32Array, gridCellSizes: Array<{ x: number; y: number; z: number }>, private cacheProvider?: string, + private readOnly = true, ) { super(chunkManager); this.sortedGridCellSizes = [...gridCellSizes].sort( @@ -289,6 +250,7 @@ export class CatmaidMultiscaleSpatiallyIndexedSkeletonSource extends MultiscaleS // Sorted by minimum dimension (Descending: Large/Coarse -> Small/Fine) const sortedGridSizes = this.sortedGridCellSizes; + const lastGridIndex = sortedGridSizes.length - 1; for (const [gridIndex, gridCellSize] of sortedGridSizes.entries()) { const chunkDataSize = Uint32Array.from([ gridCellSize.x, @@ -327,7 +289,10 @@ export class CatmaidMultiscaleSpatiallyIndexedSkeletonSource extends MultiscaleS parameters.catmaidParameters.url = this.baseUrl; parameters.catmaidParameters.projectId = this.projectId; parameters.catmaidParameters.cacheProvider = this.cacheProvider; + parameters.catmaidParameters.readOnly = this.readOnly; parameters.gridIndex = gridIndex; + parameters.catmaidLod = + lastGridIndex <= 0 ? 0 : gridIndex / lastGridIndex; parameters.metadata = { transform: mat4.create(), vertexAttributes: new Map([ @@ -423,26 +388,24 @@ export class CatmaidDataSourceProvider implements DataSourceProvider { throw new Error("Failed to fetch CATMAID spatial index metadata"); } - const { bounds: projectBounds, gridCellSizes } = spatialIndexMetadata; + const { + lowerBounds: projectLowerBounds, + upperBounds: projectUpperBounds, + spatial, + readOnly, + } = spatialIndexMetadata; + const gridCellSizes = spatial.map(({ chunkSize }) => ({ + x: Number(chunkSize[0]), + y: Number(chunkSize[1]), + z: Number(chunkSize[2]), + })); // The model-space coordinates we emit are in nanometers, converted to meters for Neuroglancer. - const coordinateScaleFactors = Float64Array.from([ - 1e-9, - 1e-9, - 1e-9, - ]); + const coordinateScaleFactors = Float64Array.from([1e-9, 1e-9, 1e-9]); // Bounds and chunk sizes are represented in project-space nanometers. - const lowerBounds = Float64Array.from([ - projectBounds.min.x, - projectBounds.min.y, - projectBounds.min.z, - ]); - const upperBounds = Float64Array.from([ - projectBounds.max.x, - projectBounds.max.y, - projectBounds.max.z, - ]); + const lowerBounds = Float64Array.from(projectLowerBounds); + const upperBounds = Float64Array.from(projectUpperBounds); const modelSpace = makeCoordinateSpace({ names: ["x", "y", "z"], @@ -480,6 +443,7 @@ export class CatmaidDataSourceProvider implements DataSourceProvider { upperCoordinateBound, gridCellSizes, cacheProvider, + readOnly, ); // Create complete skeleton source (non-chunked) const completeSkeletonParameters = diff --git a/src/datasource/catmaid/skeleton_packing.spec.ts b/src/datasource/catmaid/skeleton_packing.spec.ts index bd5b15b49..eda5658f8 100644 --- a/src/datasource/catmaid/skeleton_packing.spec.ts +++ b/src/datasource/catmaid/skeleton_packing.spec.ts @@ -11,12 +11,14 @@ describe("datasource/catmaid/skeleton_packing", () => { parentNodeId: undefined, position: new Float32Array([1, 2, 3]), segmentId: 10, + sourceState: { revisionToken: "node-1" }, }, { nodeId: 2, parentNodeId: 1, position: new Float32Array([4, 5, 6]), segmentId: 10, + sourceState: { revisionToken: "node-2" }, }, { nodeId: 3, @@ -34,6 +36,11 @@ describe("datasource/catmaid/skeleton_packing", () => { expect(packed.segmentIds).toEqual(Uint32Array.of(10, 10, 11)); expect(packed.indices).toEqual(Uint32Array.of(1, 0)); expect(packed.nodeIds).toEqual(Int32Array.of(1, 2, 3)); + expect(packed.sourceStates).toEqual([ + { revisionToken: "node-1" }, + { revisionToken: "node-2" }, + undefined, + ]); }); it("preserves large segment ids exactly", () => { diff --git a/src/datasource/catmaid/skeleton_packing.ts b/src/datasource/catmaid/skeleton_packing.ts index 1f4bd74b5..0403f818f 100644 --- a/src/datasource/catmaid/skeleton_packing.ts +++ b/src/datasource/catmaid/skeleton_packing.ts @@ -14,14 +14,17 @@ * limitations under the License. */ -import type { SpatiallyIndexedSkeletonNodeBase } from "#src/skeleton/api.js"; +import type { + SpatiallyIndexedSkeletonNodeBase, + SpatialSkeletonSourceState, +} from "#src/skeleton/api.js"; interface PackedCatmaidSkeletonData { vertexPositions: Float32Array; segmentIds: Uint32Array; indices: Uint32Array; nodeIds: Int32Array; - revisionTokens: Array; + sourceStates: Array; } export function packCatmaidSkeletonNodes( @@ -31,7 +34,9 @@ export function packCatmaidSkeletonNodes( const vertexPositions = new Float32Array(numVertices * 3); const segmentIds = new Uint32Array(numVertices); const nodeIds = new Int32Array(numVertices); - const revisionTokens = new Array(numVertices); + const sourceStates = new Array( + numVertices, + ); const indices: number[] = []; const nodeMap = new Map(); @@ -43,7 +48,7 @@ export function packCatmaidSkeletonNodes( vertexPositions[i * 3 + 1] = node.position[1]; vertexPositions[i * 3 + 2] = node.position[2]; segmentIds[i] = node.segmentId; - revisionTokens[i] = node.revisionToken; + sourceStates[i] = node.sourceState; } for (let i = 0; i < numVertices; ++i) { @@ -60,6 +65,6 @@ export function packCatmaidSkeletonNodes( segmentIds, indices: new Uint32Array(indices), nodeIds, - revisionTokens, + sourceStates, }; } diff --git a/src/datasource/catmaid/spatial_skeleton_commands.ts b/src/datasource/catmaid/spatial_skeleton_commands.ts new file mode 100644 index 000000000..797f5e858 --- /dev/null +++ b/src/datasource/catmaid/spatial_skeleton_commands.ts @@ -0,0 +1,2538 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; +import type { CatmaidClient } from "#src/datasource/catmaid/api.js"; +import { + buildCatmaidInsertEditContext, + buildCatmaidMultiNodeEditContext, + buildCatmaidNeighborhoodEditContext, + buildCatmaidNodeEditContext, + buildCatmaidRerootEditContext, +} from "#src/datasource/catmaid/edit_state.js"; +import { + addSegmentToVisibleSets, + removeSegmentFromVisibleSets, +} from "#src/segmentation_display_state/base.js"; +import type { + SpatiallyIndexedSkeletonNode, + SpatialSkeletonSourceState, + SpatialSkeletonVector, +} from "#src/skeleton/api.js"; +import type { + CatmaidSpatialSkeletonAddNodeRequest, + CatmaidSpatialSkeletonAddNodeResult, + CatmaidSpatialSkeletonConfidenceUpdateRequest, + CatmaidSpatialSkeletonDeleteNodeRequest, + CatmaidSpatialSkeletonDeleteNodeResult, + CatmaidSpatialSkeletonDescriptionUpdateRequest, + CatmaidSpatialSkeletonDescriptionUpdateResult, + CatmaidSpatialSkeletonInsertNodeRequest, + CatmaidSpatialSkeletonInsertNodeResult, + CatmaidSpatialSkeletonMergeRequest, + CatmaidSpatialSkeletonMergeResult, + CatmaidSpatialSkeletonMoveNodeRequest, + CatmaidSpatialSkeletonNodeSourceStateResult, + CatmaidSpatialSkeletonNodeSourceStateUpdate, + CatmaidSpatialSkeletonRadiusUpdateRequest, + CatmaidSpatialSkeletonRerootRequest, + CatmaidSpatialSkeletonRerootResult, + CatmaidSpatialSkeletonSplitRequest, + CatmaidSpatialSkeletonSplitResult, + CatmaidSpatialSkeletonTrueEndUpdateRequest, +} from "#src/datasource/catmaid/spatial_skeleton_edit_api.js"; +import { + SpatialSkeletonActions, + type SpatialSkeletonAction, +} from "#src/skeleton/actions.js"; +import type { + SpatialSkeletonCommand, + SpatialSkeletonCommandContext, +} from "#src/skeleton/command_history.js"; +import type { SpatialSkeletonEditCommandFactory } from "#src/skeleton/edit_command_source.js"; +import { + findSpatiallyIndexedSkeletonNode, + getSpatiallyIndexedSkeletonDirectChildren, +} from "#src/skeleton/edit_state.js"; +import type { SpatiallyIndexedSkeletonLayer } from "#src/skeleton/frontend.js"; +import { getEditableSpatiallyIndexedSkeletonSource } from "#src/skeleton/spatial_skeleton_manager.js"; +import { StatusMessage } from "#src/status.js"; +import { formatErrorMessage } from "#src/util/error.js"; + +interface CatmaidSpatialSkeletonAddNodeCommandOptions { + skeletonId: number; + parentNodeId: number | undefined; + positionInModelSpace: SpatialSkeletonVector; +} + +interface CatmaidSpatialSkeletonInsertNodeCommandOptions { + skeletonId: number; + parentNodeId: number; + childNodeIds: readonly number[]; + positionInModelSpace: SpatialSkeletonVector; +} + +interface CatmaidSpatialSkeletonMoveNodeCommandOptions { + node: SpatiallyIndexedSkeletonNode; + nextPositionInModelSpace: SpatialSkeletonVector; +} + +interface CatmaidSpatialSkeletonNodeDescriptionCommandOptions { + node: SpatiallyIndexedSkeletonNode; + nextDescription?: string; +} + +interface CatmaidSpatialSkeletonNodeTrueEndCommandOptions { + node: SpatiallyIndexedSkeletonNode; + nextIsTrueEnd: boolean; +} + +interface CatmaidSpatialSkeletonNodeRadiusCommandOptions { + node: SpatiallyIndexedSkeletonNode; + nextRadius: number; +} + +interface CatmaidSpatialSkeletonNodeConfidenceCommandOptions { + node: SpatiallyIndexedSkeletonNode; + nextConfidence: number; +} + +interface CatmaidSpatialSkeletonMergeEndpoint { + nodeId: number; + segmentId: number; + sourceState?: SpatialSkeletonSourceState; +} + +interface CatmaidSpatialSkeletonMergeCommandPayload { + firstNode: CatmaidSpatialSkeletonMergeEndpoint; + secondNode: CatmaidSpatialSkeletonMergeEndpoint; +} + +export interface CatmaidSpatialSkeletonEditCommandContext { + getClient(): CatmaidClient; +} + +interface CatmaidSpatialSkeletonEditOperations { + commitAddNode( + request: CatmaidSpatialSkeletonAddNodeRequest, + ): Promise; + commitInsertNode( + request: CatmaidSpatialSkeletonInsertNodeRequest, + ): Promise; + commitMoveNode( + request: CatmaidSpatialSkeletonMoveNodeRequest, + ): Promise; + commitDeleteNode( + request: CatmaidSpatialSkeletonDeleteNodeRequest, + ): Promise; + commitReroot( + request: CatmaidSpatialSkeletonRerootRequest, + ): Promise; + commitDescription( + request: CatmaidSpatialSkeletonDescriptionUpdateRequest, + ): Promise; + commitTrueEnd( + request: CatmaidSpatialSkeletonTrueEndUpdateRequest, + ): Promise; + commitRadius( + request: CatmaidSpatialSkeletonRadiusUpdateRequest, + ): Promise; + commitConfidence( + request: CatmaidSpatialSkeletonConfidenceUpdateRequest, + ): Promise; + commitMerge( + request: CatmaidSpatialSkeletonMergeRequest, + ): Promise; + commitSplit( + request: CatmaidSpatialSkeletonSplitRequest, + ): Promise; +} + +function isFiniteNumber(value: number | undefined) { + return typeof value === "number" && Number.isFinite(value); +} + +function isOptionalFiniteNumber(value: number | undefined) { + return value === undefined || isFiniteNumber(value); +} + +function isSpatialSkeletonVector( + value: object | undefined, +): value is SpatialSkeletonVector { + return ( + value !== undefined && isFiniteNumber((value as { length?: number }).length) + ); +} + +function areFiniteNumbers(values: readonly number[] | undefined) { + return values !== undefined && values.every((value) => isFiniteNumber(value)); +} + +function isSpatiallyIndexedSkeletonNodePayload( + value: object | undefined, +): value is SpatiallyIndexedSkeletonNode { + if (value === undefined) return false; + const candidate = value as { + nodeId?: number; + segmentId?: number; + position?: object; + parentNodeId?: number; + radius?: number; + confidence?: number; + description?: string; + isTrueEnd?: boolean; + }; + return ( + isFiniteNumber(candidate.nodeId) && + isFiniteNumber(candidate.segmentId) && + isSpatialSkeletonVector(candidate.position) && + isOptionalFiniteNumber(candidate.parentNodeId) && + isOptionalFiniteNumber(candidate.radius) && + isOptionalFiniteNumber(candidate.confidence) && + (candidate.description === undefined || + typeof candidate.description === "string") && + (candidate.isTrueEnd === undefined || + typeof candidate.isTrueEnd === "boolean") + ); +} + +function isCatmaidMergeEndpoint( + value: object | undefined, +): value is CatmaidSpatialSkeletonMergeEndpoint { + if (value === undefined) return false; + const candidate = value as { + nodeId?: number; + segmentId?: number; + }; + return ( + isFiniteNumber(candidate.nodeId) && isFiniteNumber(candidate.segmentId) + ); +} + +function requireCatmaidCommandPayload( + payload: object, + label: string, + isValid: (payload: object) => payload is T, +) { + if (!isValid(payload)) { + throw new Error(`CATMAID ${label} command received an invalid payload.`); + } + return payload; +} + +function requireCatmaidAddNodeCommandOptions(payload: object) { + return requireCatmaidCommandPayload( + payload, + "add-node", + (candidate): candidate is CatmaidSpatialSkeletonAddNodeCommandOptions => { + const options = candidate as { + skeletonId?: number; + parentNodeId?: number; + positionInModelSpace?: object; + }; + return ( + isFiniteNumber(options.skeletonId) && + isOptionalFiniteNumber(options.parentNodeId) && + isSpatialSkeletonVector(options.positionInModelSpace) + ); + }, + ); +} + +function requireCatmaidInsertNodeCommandOptions(payload: object) { + return requireCatmaidCommandPayload( + payload, + "insert-node", + ( + candidate, + ): candidate is CatmaidSpatialSkeletonInsertNodeCommandOptions => { + const options = candidate as { + skeletonId?: number; + parentNodeId?: number; + childNodeIds?: readonly number[]; + positionInModelSpace?: object; + }; + return ( + isFiniteNumber(options.skeletonId) && + isFiniteNumber(options.parentNodeId) && + areFiniteNumbers(options.childNodeIds) && + isSpatialSkeletonVector(options.positionInModelSpace) + ); + }, + ); +} + +function requireCatmaidMoveNodeCommandOptions(payload: object) { + return requireCatmaidCommandPayload( + payload, + "move-node", + (candidate): candidate is CatmaidSpatialSkeletonMoveNodeCommandOptions => { + const options = candidate as { + node?: object; + nextPositionInModelSpace?: object; + }; + return ( + isSpatiallyIndexedSkeletonNodePayload(options.node) && + isSpatialSkeletonVector(options.nextPositionInModelSpace) + ); + }, + ); +} + +function requireCatmaidDeleteNodeCommandPayload(payload: object) { + return requireCatmaidCommandPayload( + payload, + "delete-node", + isSpatiallyIndexedSkeletonNodePayload, + ); +} + +function requireCatmaidNodeDescriptionCommandOptions(payload: object) { + return requireCatmaidCommandPayload( + payload, + "node-description", + ( + candidate, + ): candidate is CatmaidSpatialSkeletonNodeDescriptionCommandOptions => { + const options = candidate as { + node?: object; + nextDescription?: string; + }; + return ( + isSpatiallyIndexedSkeletonNodePayload(options.node) && + (options.nextDescription === undefined || + typeof options.nextDescription === "string") + ); + }, + ); +} + +function requireCatmaidNodeTrueEndCommandOptions(payload: object) { + return requireCatmaidCommandPayload( + payload, + "node-true-end", + ( + candidate, + ): candidate is CatmaidSpatialSkeletonNodeTrueEndCommandOptions => { + const options = candidate as { + node?: object; + nextIsTrueEnd?: boolean; + }; + return ( + isSpatiallyIndexedSkeletonNodePayload(options.node) && + typeof options.nextIsTrueEnd === "boolean" + ); + }, + ); +} + +function requireCatmaidNodeRadiusCommandOptions(payload: object) { + return requireCatmaidCommandPayload( + payload, + "node-radius", + ( + candidate, + ): candidate is CatmaidSpatialSkeletonNodeRadiusCommandOptions => { + const options = candidate as { + node?: object; + nextRadius?: number; + }; + return ( + isSpatiallyIndexedSkeletonNodePayload(options.node) && + isFiniteNumber(options.nextRadius) + ); + }, + ); +} + +function requireCatmaidNodeConfidenceCommandOptions(payload: object) { + return requireCatmaidCommandPayload( + payload, + "node-confidence", + ( + candidate, + ): candidate is CatmaidSpatialSkeletonNodeConfidenceCommandOptions => { + const options = candidate as { + node?: object; + nextConfidence?: number; + }; + return ( + isSpatiallyIndexedSkeletonNodePayload(options.node) && + isFiniteNumber(options.nextConfidence) + ); + }, + ); +} + +function requireCatmaidRerootCommandPayload(payload: object) { + return requireCatmaidCommandPayload( + payload, + "reroot", + ( + candidate, + ): candidate is Pick< + SpatiallyIndexedSkeletonNode, + "nodeId" | "segmentId" | "parentNodeId" + > => { + const node = candidate as { + nodeId?: number; + segmentId?: number; + parentNodeId?: number; + }; + return ( + isFiniteNumber(node.nodeId) && + isFiniteNumber(node.segmentId) && + isOptionalFiniteNumber(node.parentNodeId) + ); + }, + ); +} + +function requireCatmaidSplitCommandPayload(payload: object) { + return requireCatmaidCommandPayload( + payload, + "split", + ( + candidate, + ): candidate is Pick< + SpatiallyIndexedSkeletonNode, + "nodeId" | "segmentId" + > => { + const node = candidate as { + nodeId?: number; + segmentId?: number; + }; + return isFiniteNumber(node.nodeId) && isFiniteNumber(node.segmentId); + }, + ); +} + +function requireCatmaidMergeCommandPayload(payload: object) { + return requireCatmaidCommandPayload( + payload, + "merge", + (candidate): candidate is CatmaidSpatialSkeletonMergeCommandPayload => { + const options = candidate as { + firstNode?: object; + secondNode?: object; + }; + return ( + isCatmaidMergeEndpoint(options.firstNode) && + isCatmaidMergeEndpoint(options.secondNode) + ); + }, + ); +} + +function toCatmaidPositionInModelSpace( + position: SpatialSkeletonVector, + label: string, +) { + if (position.length < 3) { + throw new Error(`CATMAID ${label} requires at least 3 coordinates.`); + } + const values = [ + Number(position[0]), + Number(position[1]), + Number(position[2]), + ]; + if (values.some((value) => !Number.isFinite(value))) { + throw new Error(`CATMAID ${label} coordinates must be finite.`); + } + return new Float32Array(values); +} + +function cloneNodeSnapshot( + node: SpatiallyIndexedSkeletonNode, +): SpatiallyIndexedSkeletonNode { + return { + nodeId: node.nodeId, + segmentId: node.segmentId, + position: toCatmaidPositionInModelSpace(node.position, "node position"), + parentNodeId: node.parentNodeId, + radius: node.radius, + confidence: node.confidence, + description: node.description, + isTrueEnd: node.isTrueEnd ?? false, + sourceState: node.sourceState, + }; +} + +function getEditableSkeletonSourceForLayer(layer: SegmentationUserLayer): { + skeletonLayer: SpatiallyIndexedSkeletonLayer; +} { + const skeletonLayer = layer.getSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) { + throw new Error( + "No spatially indexed skeleton source is currently loaded.", + ); + } + if (getEditableSpatiallyIndexedSkeletonSource(skeletonLayer) === undefined) { + throw new Error( + "Unable to resolve editable skeleton source for the active layer.", + ); + } + return { skeletonLayer }; +} + +function ensureVisibleSegment( + layer: SegmentationUserLayer, + segmentId: number | undefined, +) { + if ( + segmentId === undefined || + !Number.isSafeInteger(Math.round(Number(segmentId))) || + Math.round(Number(segmentId)) <= 0 + ) { + return; + } + addSegmentToVisibleSets( + layer.displayState.segmentationGroupState.value, + BigInt(Math.round(Number(segmentId))), + ); +} + +function selectSegment( + layer: SegmentationUserLayer, + segmentId: number | undefined, + pin: boolean, +) { + if ( + segmentId === undefined || + !Number.isSafeInteger(Math.round(Number(segmentId))) || + Math.round(Number(segmentId)) <= 0 + ) { + return; + } + layer.selectSegment(BigInt(Math.round(Number(segmentId))), pin); +} + +function removeVisibleSegment( + layer: SegmentationUserLayer, + segmentId: number | undefined, + options: { + deselect?: boolean; + } = {}, +) { + if ( + segmentId === undefined || + !Number.isSafeInteger(Math.round(Number(segmentId))) || + Math.round(Number(segmentId)) <= 0 + ) { + return; + } + removeSegmentFromVisibleSets( + layer.displayState.segmentationGroupState.value, + BigInt(Math.round(Number(segmentId))), + options, + ); +} + +function findRootNode(segmentNodes: readonly SpatiallyIndexedSkeletonNode[]) { + return segmentNodes.find((candidate) => candidate.parentNodeId === undefined); +} + +interface ResolvedSpatialSkeletonEditNode { + skeletonLayer: SpatiallyIndexedSkeletonLayer; + segmentNodes: readonly SpatiallyIndexedSkeletonNode[]; + node: SpatiallyIndexedSkeletonNode; +} + +interface ResolvedSpatialSkeletonEditNodeContext { + currentNodeId: number; + segmentId: number; + cachedNode: SpatiallyIndexedSkeletonNode | undefined; + skeletonLayer: SpatiallyIndexedSkeletonLayer; +} + +function getResolvedNodeContextForEdit( + layer: SegmentationUserLayer, + stableNodeId: number, + stableSegmentId: number | undefined, +): ResolvedSpatialSkeletonEditNodeContext { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + const currentNodeId = commandMappings.resolveNodeId(stableNodeId); + if (currentNodeId === undefined) { + throw new Error(`Unable to resolve current node ${stableNodeId}.`); + } + const { skeletonLayer } = getEditableSkeletonSourceForLayer(layer); + const cachedNode = + layer.spatialSkeletonState.getCachedNode(currentNodeId) ?? + skeletonLayer.getNode(currentNodeId); + const candidateSegmentId = + cachedNode?.segmentId ?? commandMappings.resolveSegmentId(stableSegmentId); + if (candidateSegmentId === undefined) { + throw new Error( + `Unable to resolve the current segment for node ${stableNodeId}.`, + ); + } + return { + currentNodeId, + segmentId: candidateSegmentId, + cachedNode, + skeletonLayer, + }; +} + +async function getResolvedNodeForEdit( + layer: SegmentationUserLayer, + stableNodeId: number, + stableSegmentId: number | undefined, +): Promise { + const { + currentNodeId, + segmentId: candidateSegmentId, + skeletonLayer, + } = getResolvedNodeContextForEdit(layer, stableNodeId, stableSegmentId); + let segmentNodes = + layer.spatialSkeletonState.getCachedSegmentNodes(candidateSegmentId); + if (segmentNodes === undefined) { + segmentNodes = await layer.spatialSkeletonState.getFullSegmentNodes( + skeletonLayer, + candidateSegmentId, + ); + } + const node = findSpatiallyIndexedSkeletonNode(segmentNodes, currentNodeId); + if (node === undefined) { + throw new Error( + `Node ${currentNodeId} is not available in the inspected skeleton cache.`, + ); + } + return { + skeletonLayer, + segmentNodes, + node, + }; +} + +async function refreshTopologySegments( + layer: SegmentationUserLayer, + segmentIds: readonly number[], +) { + const normalizedSegmentIds = [ + ...new Set( + segmentIds.filter((value) => Number.isSafeInteger(Math.round(value))), + ), + ].map((value) => Math.round(value)); + if (normalizedSegmentIds.length === 0) { + return; + } + const { skeletonLayer } = getEditableSkeletonSourceForLayer(layer); + layer.spatialSkeletonState.invalidateCachedSegments(normalizedSegmentIds); + layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + skeletonLayer.invalidateSourceCaches(); + await Promise.allSettled( + normalizedSegmentIds.map((segmentId) => + layer.spatialSkeletonState.getFullSegmentNodes(skeletonLayer, segmentId), + ), + ); +} + +function applyAddNodeToCache( + layer: SegmentationUserLayer, + skeletonLayer: SpatiallyIndexedSkeletonLayer, + committedNode: CatmaidSpatialSkeletonAddNodeResult, + parentNodeId: number | undefined, + positionInModelSpace: Float32Array, + options: { + focusSelection: boolean; + moveView: boolean; + pinSegment: boolean; + }, +) { + const newNode: SpatiallyIndexedSkeletonNode = { + nodeId: committedNode.nodeId, + segmentId: committedNode.segmentId, + position: new Float32Array(positionInModelSpace), + parentNodeId, + isTrueEnd: false, + ...(committedNode.sourceState === undefined + ? {} + : { sourceState: committedNode.sourceState }), + }; + layer.spatialSkeletonState.upsertCachedNode(newNode, { + allowUncachedSegment: parentNodeId === undefined, + }); + if ( + parentNodeId !== undefined && + committedNode.parentSourceState !== undefined + ) { + layer.spatialSkeletonState.setCachedNodeSourceState( + parentNodeId, + committedNode.parentSourceState, + ); + } + ensureVisibleSegment(layer, newNode.segmentId); + selectSegment(layer, newNode.segmentId, options.pinSegment); + if (options.focusSelection) { + layer.selectSpatialSkeletonNode( + newNode.nodeId, + layer.manager.root.selectionState.pin.value, + { + segmentId: newNode.segmentId, + position: newNode.position, + }, + ); + if (options.moveView) { + layer.moveViewToSpatialSkeletonNodePosition(newNode.position); + } + } + if (parentNodeId !== undefined) { + skeletonLayer.retainOverlaySegment(newNode.segmentId); + } + layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); +} + +function applyDeleteNodeToCache( + layer: SegmentationUserLayer, + deleteContext: { + node: SpatiallyIndexedSkeletonNode; + parentNode: SpatiallyIndexedSkeletonNode | undefined; + childNodes: readonly SpatiallyIndexedSkeletonNode[]; + }, + options: { + moveView: boolean; + }, + nodeSourceStateUpdates: readonly CatmaidSpatialSkeletonNodeSourceStateUpdate[] = [], +) { + const { node, parentNode, childNodes } = deleteContext; + const directChildIds = childNodes.map((child) => child.nodeId); + layer.spatialSkeletonState.removeCachedNode(node.nodeId, { + parentNodeId: node.parentNodeId, + childNodeIds: directChildIds, + }); + if (nodeSourceStateUpdates.length > 0) { + layer.spatialSkeletonState.setCachedNodeSourceStates( + nodeSourceStateUpdates, + ); + } + if (parentNode !== undefined) { + if (options.moveView) { + layer.selectAndMoveToSpatialSkeletonNode( + parentNode, + layer.manager.root.selectionState.pin.value, + ); + } else { + layer.selectSpatialSkeletonNode( + parentNode.nodeId, + layer.manager.root.selectionState.pin.value, + { + segmentId: parentNode.segmentId, + position: parentNode.position, + }, + ); + } + } else { + layer.clearSpatialSkeletonNodeSelection( + layer.manager.root.selectionState.pin.value, + ); + } + const remainingSegmentNodes = + layer.spatialSkeletonState.getCachedSegmentNodes(node.segmentId) ?? []; + if (remainingSegmentNodes.length === 0) { + removeVisibleSegment(layer, node.segmentId, { deselect: true }); + } + layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); +} + +async function applyNodeDescriptionAndTrueEnd( + editOperations: CatmaidSpatialSkeletonEditOperations, + node: SpatiallyIndexedSkeletonNode, + next: { + description?: string; + isTrueEnd?: boolean; + }, +) { + const nextDescription = next.description; + const nextTrueEnd = next.isTrueEnd ?? false; + let updatedNode: SpatiallyIndexedSkeletonNode = { + ...node, + description: nextDescription, + isTrueEnd: nextTrueEnd, + }; + const descriptionChanged = node.description !== nextDescription; + if (descriptionChanged) { + const descriptionResult = await editOperations.commitDescription({ + node, + description: nextDescription ?? "", + isTrueEnd: nextTrueEnd, + }); + updatedNode = { + ...updatedNode, + description: descriptionResult.description, + sourceState: descriptionResult.sourceState ?? updatedNode.sourceState, + }; + } + if (!descriptionChanged && node.isTrueEnd !== nextTrueEnd) { + const trueEndResult = await editOperations.commitTrueEnd({ + node, + isTrueEnd: nextTrueEnd, + }); + updatedNode = { + ...updatedNode, + sourceState: trueEndResult.sourceState ?? updatedNode.sourceState, + }; + } + return updatedNode; +} + +async function restoreNodeAttributes( + layer: SegmentationUserLayer, + editOperations: CatmaidSpatialSkeletonEditOperations, + createdNode: SpatiallyIndexedSkeletonNode, + snapshot: SpatiallyIndexedSkeletonNode, +) { + let nextNode = cloneNodeSnapshot(createdNode); + if (snapshot.radius !== undefined && snapshot.radius !== nextNode.radius) { + const radiusResult = await editOperations.commitRadius({ + node: nextNode, + radius: snapshot.radius, + }); + nextNode = { + ...nextNode, + radius: snapshot.radius, + sourceState: radiusResult.sourceState ?? nextNode.sourceState, + }; + } + if ( + snapshot.confidence !== undefined && + snapshot.confidence !== nextNode.confidence + ) { + const confidenceResult = await editOperations.commitConfidence({ + node: nextNode, + confidence: snapshot.confidence, + }); + nextNode = { + ...nextNode, + confidence: snapshot.confidence, + sourceState: confidenceResult.sourceState ?? nextNode.sourceState, + }; + } + if ( + nextNode.description !== snapshot.description || + nextNode.isTrueEnd !== snapshot.isTrueEnd + ) { + nextNode = await applyNodeDescriptionAndTrueEnd( + editOperations, + nextNode, + snapshot, + ); + } + layer.spatialSkeletonState.upsertCachedNode(nextNode); + return nextNode; +} + +class AddNodeCommand implements SpatialSkeletonCommand { + readonly label = "Add node"; + private stableNodeId: number | undefined; + private stableSegmentId: number | undefined; + + constructor( + private layer: SegmentationUserLayer, + private stableParentNodeId: number | undefined, + private targetSkeletonId: number, + private positionInModelSpace: Float32Array, + private editOperations: CatmaidSpatialSkeletonEditOperations, + ) {} + + private async addNode( + _context: SpatialSkeletonCommandContext, + options: { + moveView: boolean; + pinSegment: boolean; + statusPrefix: string; + }, + ) { + const { skeletonLayer } = getEditableSkeletonSourceForLayer(this.layer); + const currentParentNodeId = + this.stableParentNodeId === undefined + ? undefined + : this.layer.spatialSkeletonState.commandHistory.mappings.resolveNodeId( + this.stableParentNodeId, + ); + let parentNode: SpatiallyIndexedSkeletonNode | undefined; + let resolvedSkeletonId = this.targetSkeletonId; + if (currentParentNodeId !== undefined) { + parentNode = ( + await getResolvedNodeForEdit( + this.layer, + this.stableParentNodeId!, + this.layer.spatialSkeletonState.commandHistory.mappings.getStableOrCurrentSegmentId( + this.targetSkeletonId, + ), + ) + ).node; + resolvedSkeletonId = parentNode.segmentId; + } + const result = await this.editOperations.commitAddNode({ + segmentId: resolvedSkeletonId, + position: this.positionInModelSpace, + parentNode, + }); + if (this.stableNodeId === undefined) { + this.stableNodeId = result.nodeId; + } else { + this.layer.spatialSkeletonState.commandHistory.mappings.remapNodeId( + this.stableNodeId, + result.nodeId, + ); + } + if (this.stableSegmentId === undefined) { + this.stableSegmentId = result.segmentId; + } else { + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + this.stableSegmentId, + result.segmentId, + ); + } + applyAddNodeToCache( + this.layer, + skeletonLayer, + result, + currentParentNodeId, + this.positionInModelSpace, + { + focusSelection: true, + moveView: options.moveView, + pinSegment: options.pinSegment, + }, + ); + StatusMessage.showTemporaryMessage( + `${options.statusPrefix} node ${result.nodeId} on segment ${result.segmentId}.`, + ); + } + + async execute(context: SpatialSkeletonCommandContext) { + await this.addNode(context, { + moveView: true, + pinSegment: true, + statusPrefix: "Added", + }); + } + + async undo(_context: SpatialSkeletonCommandContext) { + if (this.stableNodeId === undefined) { + throw new Error("Add-node undo is missing the created node id."); + } + const resolvedNode = await getResolvedNodeForEdit( + this.layer, + this.stableNodeId, + this.stableSegmentId, + ); + const deleteContext = + await this.layer.getSpatialSkeletonDeleteOperationContext( + resolvedNode.node, + ); + const result = await this.editOperations.commitDeleteNode({ + node: deleteContext.node, + childNodes: [], + segmentNodes: resolvedNode.segmentNodes, + }); + applyDeleteNodeToCache( + this.layer, + deleteContext, + { moveView: true }, + result.nodeSourceStateUpdates, + ); + StatusMessage.showTemporaryMessage( + `Undid add node ${resolvedNode.node.nodeId}.`, + ); + } + + async redo(context: SpatialSkeletonCommandContext) { + await this.addNode(context, { + moveView: false, + pinSegment: false, + statusPrefix: "Redid add of", + }); + } +} + +class InsertNodeCommand implements SpatialSkeletonCommand { + readonly label = "Insert node"; + private stableNodeId: number | undefined; + private stableSegmentId: number | undefined; + + constructor( + private layer: SegmentationUserLayer, + private stableParentNodeId: number, + private stableChildNodeIds: readonly number[], + private targetSkeletonId: number, + private positionInModelSpace: Float32Array, + private editOperations: CatmaidSpatialSkeletonEditOperations, + ) {} + + private async insertNode(options: { + moveView: boolean; + pinSegment: boolean; + statusPrefix: string; + }) { + const { skeletonLayer } = getEditableSkeletonSourceForLayer(this.layer); + const parentNode = ( + await getResolvedNodeForEdit( + this.layer, + this.stableParentNodeId, + this.stableSegmentId ?? this.targetSkeletonId, + ) + ).node; + const childNodes = await Promise.all( + this.stableChildNodeIds.map((stableChildNodeId) => + getResolvedNodeForEdit( + this.layer, + stableChildNodeId, + parentNode.segmentId, + ).then((result) => result.node), + ), + ); + const result = await this.editOperations.commitInsertNode({ + segmentId: parentNode.segmentId, + position: this.positionInModelSpace, + parentNode, + childNodes, + }); + if (this.stableNodeId === undefined) { + this.stableNodeId = result.nodeId; + } else { + this.layer.spatialSkeletonState.commandHistory.mappings.remapNodeId( + this.stableNodeId, + result.nodeId, + ); + } + if (this.stableSegmentId === undefined) { + this.stableSegmentId = result.segmentId; + } else { + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + this.stableSegmentId, + result.segmentId, + ); + } + const newNode: SpatiallyIndexedSkeletonNode = { + nodeId: result.nodeId, + segmentId: result.segmentId, + position: new Float32Array(this.positionInModelSpace), + parentNodeId: parentNode.nodeId, + isTrueEnd: false, + ...(result.sourceState === undefined + ? {} + : { sourceState: result.sourceState }), + }; + this.layer.spatialSkeletonState.upsertCachedNode(newNode); + for (const childNode of childNodes) { + this.layer.spatialSkeletonState.setCachedNodeParent( + childNode.nodeId, + newNode.nodeId, + ); + } + if (result.parentSourceState !== undefined) { + this.layer.spatialSkeletonState.setCachedNodeSourceState( + parentNode.nodeId, + result.parentSourceState, + ); + } + if (result.nodeSourceStateUpdates?.length) { + this.layer.spatialSkeletonState.setCachedNodeSourceStates( + result.nodeSourceStateUpdates, + ); + } + ensureVisibleSegment(this.layer, newNode.segmentId); + selectSegment(this.layer, newNode.segmentId, options.pinSegment); + this.layer.selectSpatialSkeletonNode( + newNode.nodeId, + this.layer.manager.root.selectionState.pin.value, + { + segmentId: newNode.segmentId, + position: newNode.position, + }, + ); + if (options.moveView) { + this.layer.moveViewToSpatialSkeletonNodePosition(newNode.position); + } + skeletonLayer.retainOverlaySegment(newNode.segmentId); + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + StatusMessage.showTemporaryMessage( + `${options.statusPrefix} node ${result.nodeId} on segment ${result.segmentId}.`, + ); + } + + private async deleteInsertedNode(statusPrefix: string) { + if (this.stableNodeId === undefined) { + throw new Error("Insert-node undo is missing the created node id."); + } + const resolvedNode = await getResolvedNodeForEdit( + this.layer, + this.stableNodeId, + this.stableSegmentId, + ); + const deleteContext = + await this.layer.getSpatialSkeletonDeleteOperationContext( + resolvedNode.node, + ); + const result = await this.editOperations.commitDeleteNode({ + node: deleteContext.node, + childNodes: deleteContext.childNodes, + segmentNodes: resolvedNode.segmentNodes, + }); + applyDeleteNodeToCache( + this.layer, + deleteContext, + { moveView: false }, + result.nodeSourceStateUpdates, + ); + resolvedNode.skeletonLayer.invalidateSourceCaches(); + StatusMessage.showTemporaryMessage( + `${statusPrefix} inserted node ${resolvedNode.node.nodeId}.`, + ); + } + + execute() { + return this.insertNode({ + moveView: true, + pinSegment: true, + statusPrefix: "Inserted", + }); + } + + undo() { + return this.deleteInsertedNode("Undid insertion of"); + } + + redo() { + return this.insertNode({ + moveView: false, + pinSegment: false, + statusPrefix: "Redid insertion of", + }); + } +} + +class MoveNodeCommand implements SpatialSkeletonCommand { + readonly label = "Move node"; + + constructor( + private layer: SegmentationUserLayer, + private stableNodeId: number, + private stableSegmentId: number | undefined, + private beforePositionInModelSpace: Float32Array, + private afterPositionInModelSpace: Float32Array, + private editOperations: CatmaidSpatialSkeletonEditOperations, + ) {} + + private async moveTo( + positionInModelSpace: Float32Array, + statusPrefix: string, + ) { + const { node, skeletonLayer } = await getResolvedNodeForEdit( + this.layer, + this.stableNodeId, + this.stableSegmentId, + ); + const result = await this.editOperations.commitMoveNode({ + node, + position: positionInModelSpace, + }); + skeletonLayer.retainOverlaySegment(node.segmentId); + this.layer.spatialSkeletonState.moveCachedNode( + node.nodeId, + positionInModelSpace, + ); + if (result.sourceState !== undefined) { + this.layer.spatialSkeletonState.setCachedNodeSourceState( + node.nodeId, + result.sourceState, + ); + } + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + StatusMessage.showTemporaryMessage( + `${statusPrefix} node ${node.nodeId} to (${Math.round(positionInModelSpace[0])}, ${Math.round(positionInModelSpace[1])}, ${Math.round(positionInModelSpace[2])}).`, + ); + } + + execute() { + return this.moveTo(this.afterPositionInModelSpace, "Moved"); + } + + undo() { + return this.moveTo(this.beforePositionInModelSpace, "Undid move of"); + } + + redo() { + return this.moveTo(this.afterPositionInModelSpace, "Redid move of"); + } +} + +class DeleteNodeCommand implements SpatialSkeletonCommand { + readonly label = "Delete node"; + private stableDeletedNodeId: number; + private stableSegmentId: number | undefined; + private stableParentNodeId: number | undefined; + private stableChildNodeIds: number[]; + private deletedSnapshot: SpatiallyIndexedSkeletonNode; + + constructor( + private layer: SegmentationUserLayer, + node: SpatiallyIndexedSkeletonNode, + childNodes: readonly SpatiallyIndexedSkeletonNode[], + private editOperations: CatmaidSpatialSkeletonEditOperations, + ) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + this.stableDeletedNodeId = commandMappings.getStableOrCurrentNodeId( + node.nodeId, + )!; + this.stableSegmentId = commandMappings.getStableOrCurrentSegmentId( + node.segmentId, + ); + this.stableParentNodeId = commandMappings.getStableOrCurrentNodeId( + node.parentNodeId, + ); + this.stableChildNodeIds = childNodes.map( + (child) => commandMappings.getStableOrCurrentNodeId(child.nodeId)!, + ); + this.deletedSnapshot = cloneNodeSnapshot(node); + } + + private async deleteNode(options: { + moveView: boolean; + statusPrefix: string; + }) { + const resolvedNode = await getResolvedNodeForEdit( + this.layer, + this.stableDeletedNodeId, + this.stableSegmentId, + ); + const deleteContext = + await this.layer.getSpatialSkeletonDeleteOperationContext( + resolvedNode.node, + ); + const result = await this.editOperations.commitDeleteNode({ + node: deleteContext.node, + childNodes: deleteContext.childNodes, + segmentNodes: resolvedNode.segmentNodes, + }); + applyDeleteNodeToCache( + this.layer, + deleteContext, + { moveView: options.moveView }, + result.nodeSourceStateUpdates, + ); + resolvedNode.skeletonLayer.invalidateSourceCaches(); + StatusMessage.showTemporaryMessage( + `${options.statusPrefix} node ${resolvedNode.node.nodeId}.`, + ); + } + + private async restoreDeletedNode(statusPrefix: string) { + getEditableSkeletonSourceForLayer(this.layer); + const currentParentNode = + this.stableParentNodeId === undefined + ? undefined + : ( + await getResolvedNodeForEdit( + this.layer, + this.stableParentNodeId, + this.stableSegmentId, + ) + ).node; + const currentChildNodes = await Promise.all( + this.stableChildNodeIds.map((stableChildNodeId) => + getResolvedNodeForEdit( + this.layer, + stableChildNodeId, + this.stableSegmentId, + ).then((result) => result.node), + ), + ); + let createResult: + | CatmaidSpatialSkeletonAddNodeResult + | CatmaidSpatialSkeletonInsertNodeResult; + if (currentChildNodes.length === 0) { + createResult = await this.editOperations.commitAddNode({ + segmentId: currentParentNode?.segmentId ?? 0, + position: this.deletedSnapshot.position, + parentNode: currentParentNode, + }); + } else { + if (currentParentNode === undefined) { + throw new Error( + "Delete-node undo is missing the parent node needed for insertion.", + ); + } + createResult = await this.editOperations.commitInsertNode({ + segmentId: currentParentNode.segmentId, + position: this.deletedSnapshot.position, + parentNode: currentParentNode, + childNodes: currentChildNodes, + }); + } + this.layer.spatialSkeletonState.commandHistory.mappings.remapNodeId( + this.stableDeletedNodeId, + createResult.nodeId, + ); + if (this.stableSegmentId === undefined) { + this.stableSegmentId = createResult.segmentId; + } else { + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + this.stableSegmentId, + createResult.segmentId, + ); + } + const restoredNode: SpatiallyIndexedSkeletonNode = { + nodeId: createResult.nodeId, + segmentId: createResult.segmentId, + position: new Float32Array(this.deletedSnapshot.position), + parentNodeId: currentParentNode?.nodeId, + sourceState: createResult.sourceState, + radius: undefined, + confidence: undefined, + description: undefined, + isTrueEnd: false, + }; + this.layer.spatialSkeletonState.upsertCachedNode(restoredNode, { + allowUncachedSegment: currentParentNode === undefined, + }); + for (const childNode of currentChildNodes) { + this.layer.spatialSkeletonState.setCachedNodeParent( + childNode.nodeId, + restoredNode.nodeId, + ); + } + if (createResult.parentSourceState !== undefined && currentParentNode) { + this.layer.spatialSkeletonState.setCachedNodeSourceState( + currentParentNode.nodeId, + createResult.parentSourceState, + ); + } + if (createResult.nodeSourceStateUpdates?.length) { + this.layer.spatialSkeletonState.setCachedNodeSourceStates( + createResult.nodeSourceStateUpdates, + ); + } + const restoredNodeWithAttributes = await restoreNodeAttributes( + this.layer, + this.editOperations, + restoredNode, + this.deletedSnapshot, + ); + ensureVisibleSegment(this.layer, restoredNodeWithAttributes.segmentId); + this.layer.selectSpatialSkeletonNode( + restoredNodeWithAttributes.nodeId, + this.layer.manager.root.selectionState.pin.value, + { + segmentId: restoredNodeWithAttributes.segmentId, + position: restoredNodeWithAttributes.position, + }, + ); + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + StatusMessage.showTemporaryMessage( + `${statusPrefix} node ${restoredNodeWithAttributes.nodeId}.`, + ); + } + + execute() { + return this.deleteNode({ + moveView: true, + statusPrefix: "Deleted", + }); + } + + undo() { + return this.restoreDeletedNode("Restored"); + } + + redo() { + return this.deleteNode({ + moveView: false, + statusPrefix: "Redid deletion of", + }); + } +} + +class NodeDescriptionCommand implements SpatialSkeletonCommand { + readonly label = "Edit node description"; + + constructor( + private layer: SegmentationUserLayer, + private stableNodeId: number, + private stableSegmentId: number | undefined, + private beforeDescription: string | undefined, + private afterDescription: string | undefined, + private editOperations: CatmaidSpatialSkeletonEditOperations, + ) {} + + private async applyDescription( + nextDescription: string | undefined, + statusPrefix: string, + ) { + const { node } = await getResolvedNodeForEdit( + this.layer, + this.stableNodeId, + this.stableSegmentId, + ); + if (node.description === nextDescription) { + return; + } + const result = await this.editOperations.commitDescription({ + node, + description: nextDescription ?? "", + isTrueEnd: node.isTrueEnd === true, + }); + this.layer.spatialSkeletonState.updateCachedNode( + node.nodeId, + (candidate) => { + if (candidate.description === result.description) { + return candidate; + } + return { + ...candidate, + description: result.description, + }; + }, + ); + if (result.sourceState !== undefined) { + this.layer.spatialSkeletonState.setCachedNodeSourceState( + node.nodeId, + result.sourceState, + ); + } + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + StatusMessage.showTemporaryMessage( + `${statusPrefix} node ${node.nodeId} description.`, + ); + } + + execute() { + return this.applyDescription(this.afterDescription, "Updated"); + } + + undo() { + return this.applyDescription( + this.beforeDescription, + "Undid description update for", + ); + } + + redo() { + return this.applyDescription( + this.afterDescription, + "Redid description update for", + ); + } +} + +class NodeTrueEndCommand implements SpatialSkeletonCommand { + readonly label = "Edit node true end state"; + + constructor( + private layer: SegmentationUserLayer, + private stableNodeId: number, + private stableSegmentId: number | undefined, + private beforeIsTrueEnd: boolean, + private afterIsTrueEnd: boolean, + private editOperations: CatmaidSpatialSkeletonEditOperations, + ) {} + + private async applyTrueEnd(nextIsTrueEnd: boolean, statusPrefix: string) { + const { node } = await getResolvedNodeForEdit( + this.layer, + this.stableNodeId, + this.stableSegmentId, + ); + if (node.isTrueEnd === nextIsTrueEnd) { + return; + } + const result = await this.editOperations.commitTrueEnd({ + node, + isTrueEnd: nextIsTrueEnd, + }); + this.layer.spatialSkeletonState.updateCachedNode( + node.nodeId, + (candidate) => { + if (candidate.isTrueEnd === nextIsTrueEnd) { + return candidate; + } + return { + ...candidate, + isTrueEnd: nextIsTrueEnd, + }; + }, + ); + if (result.sourceState !== undefined) { + this.layer.spatialSkeletonState.setCachedNodeSourceState( + node.nodeId, + result.sourceState, + ); + } + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + StatusMessage.showTemporaryMessage( + `${statusPrefix} node ${node.nodeId} true end state.`, + ); + } + + execute() { + return this.applyTrueEnd(this.afterIsTrueEnd, "Updated"); + } + + undo() { + return this.applyTrueEnd(this.beforeIsTrueEnd, "Undid true end update for"); + } + + redo() { + return this.applyTrueEnd(this.afterIsTrueEnd, "Redid true end update for"); + } +} + +class NodeRadiusCommand implements SpatialSkeletonCommand { + readonly label = "Edit node radius"; + + constructor( + private layer: SegmentationUserLayer, + private stableNodeId: number, + private stableSegmentId: number | undefined, + private beforeRadius: number, + private afterRadius: number, + private editOperations: CatmaidSpatialSkeletonEditOperations, + ) {} + + private async applyRadius(nextRadius: number, statusPrefix: string) { + const { node } = await getResolvedNodeForEdit( + this.layer, + this.stableNodeId, + this.stableSegmentId, + ); + if (node.radius === nextRadius) { + return; + } + const radiusResult = await this.editOperations.commitRadius({ + node, + radius: nextRadius, + }); + this.layer.spatialSkeletonState.setNodeRadius(node.nodeId, nextRadius); + if (radiusResult.sourceState !== undefined) { + this.layer.spatialSkeletonState.setCachedNodeSourceState( + node.nodeId, + radiusResult.sourceState, + ); + } + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + StatusMessage.showTemporaryMessage( + `${statusPrefix} node ${node.nodeId} radius.`, + ); + } + + execute() { + return this.applyRadius(this.afterRadius, "Updated"); + } + + undo() { + return this.applyRadius(this.beforeRadius, "Undid radius update for"); + } + + redo() { + return this.applyRadius(this.afterRadius, "Redid radius update for"); + } +} + +class NodeConfidenceCommand implements SpatialSkeletonCommand { + readonly label = "Edit node confidence"; + + constructor( + private layer: SegmentationUserLayer, + private stableNodeId: number, + private stableSegmentId: number | undefined, + private beforeConfidence: number, + private afterConfidence: number, + private editOperations: CatmaidSpatialSkeletonEditOperations, + ) {} + + private async applyConfidence(nextConfidence: number, statusPrefix: string) { + const { node } = await getResolvedNodeForEdit( + this.layer, + this.stableNodeId, + this.stableSegmentId, + ); + if (node.confidence === nextConfidence) { + return; + } + const confidenceResult = await this.editOperations.commitConfidence({ + node, + confidence: nextConfidence, + }); + this.layer.spatialSkeletonState.setNodeConfidence( + node.nodeId, + nextConfidence, + ); + if (confidenceResult.sourceState !== undefined) { + this.layer.spatialSkeletonState.setCachedNodeSourceState( + node.nodeId, + confidenceResult.sourceState, + ); + } + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + StatusMessage.showTemporaryMessage( + `${statusPrefix} node ${node.nodeId} confidence.`, + ); + } + + execute() { + return this.applyConfidence(this.afterConfidence, "Updated"); + } + + undo() { + return this.applyConfidence( + this.beforeConfidence, + "Undid confidence update for", + ); + } + + redo() { + return this.applyConfidence( + this.afterConfidence, + "Redid confidence update for", + ); + } +} + +class RerootCommand implements SpatialSkeletonCommand { + readonly label = "Reroot skeleton"; + + constructor( + private layer: SegmentationUserLayer, + private stableNodeId: number, + private stableSegmentId: number | undefined, + private stablePreviousRootNodeId: number, + private editOperations: CatmaidSpatialSkeletonEditOperations, + ) {} + + private async rerootAt(stableTargetNodeId: number, statusPrefix: string) { + const resolvedNode = await getResolvedNodeForEdit( + this.layer, + stableTargetNodeId, + this.stableSegmentId, + ); + if (resolvedNode.node.parentNodeId === undefined) { + return; + } + const result = await this.editOperations.commitReroot({ + node: resolvedNode.node, + segmentNodes: resolvedNode.segmentNodes, + }); + this.layer.spatialSkeletonState.rerootCachedSegment( + resolvedNode.node.nodeId, + ); + if ( + result.nodeSourceStateUpdates !== undefined && + result.nodeSourceStateUpdates.length > 0 + ) { + this.layer.spatialSkeletonState.setCachedNodeSourceStates( + result.nodeSourceStateUpdates, + ); + } + this.layer.selectSpatialSkeletonNode( + resolvedNode.node.nodeId, + this.layer.manager.root.selectionState.pin.value, + { + segmentId: resolvedNode.node.segmentId, + position: resolvedNode.node.position, + }, + ); + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + StatusMessage.showTemporaryMessage( + `${statusPrefix} node ${resolvedNode.node.nodeId} as root.`, + ); + } + + execute() { + return this.rerootAt(this.stableNodeId, "Set"); + } + + undo() { + return this.rerootAt(this.stablePreviousRootNodeId, "Undid reroot for"); + } + + redo() { + return this.rerootAt(this.stableNodeId, "Redid reroot for"); + } +} + +class SplitCommand implements SpatialSkeletonCommand { + readonly label = "Split skeleton"; + private stableNewSegmentId: number | undefined; + + constructor( + private layer: SegmentationUserLayer, + private stableNodeId: number, + private stableSegmentId: number | undefined, + private stableFormerParentNodeId: number | undefined, + private editOperations: CatmaidSpatialSkeletonEditOperations, + ) {} + + private async split(statusPrefix: string) { + const resolvedNode = await getResolvedNodeForEdit( + this.layer, + this.stableNodeId, + this.stableSegmentId, + ); + let result: CatmaidSpatialSkeletonSplitResult; + try { + result = await this.editOperations.commitSplit({ + node: resolvedNode.node, + segmentNodes: resolvedNode.segmentNodes, + }); + } catch (error) { + await refreshTopologySegments(this.layer, [resolvedNode.node.segmentId]); + throw error; + } + const newSkeletonId = result.newSegmentId; + const existingSkeletonId = + result.existingSegmentId ?? resolvedNode.node.segmentId; + if (newSkeletonId === undefined) { + throw new Error( + "The active skeleton source did not return a new skeleton id for the split.", + ); + } + if (this.stableNewSegmentId === undefined) { + this.stableNewSegmentId = newSkeletonId; + } else { + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + this.stableNewSegmentId, + newSkeletonId, + ); + } + if (this.stableSegmentId !== undefined) { + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + this.stableSegmentId, + existingSkeletonId, + ); + } + ensureVisibleSegment(this.layer, existingSkeletonId); + ensureVisibleSegment(this.layer, newSkeletonId); + selectSegment(this.layer, newSkeletonId, true); + this.layer.selectSpatialSkeletonNode( + resolvedNode.node.nodeId, + this.layer.manager.root.selectionState.pin.value, + { + segmentId: newSkeletonId, + }, + ); + await refreshTopologySegments(this.layer, [ + existingSkeletonId, + newSkeletonId, + ]); + StatusMessage.showTemporaryMessage( + `${statusPrefix} skeleton ${existingSkeletonId}. New skeleton: ${newSkeletonId}.`, + ); + } + + private async mergeBack(statusPrefix: string) { + if (this.stableFormerParentNodeId === undefined) { + throw new Error("Split-node undo is missing the former parent node."); + } + const splitNode = await getResolvedNodeForEdit( + this.layer, + this.stableNodeId, + this.stableNewSegmentId ?? this.stableSegmentId, + ); + const formerParent = await getResolvedNodeForEdit( + this.layer, + this.stableFormerParentNodeId, + this.stableSegmentId, + ); + let result: CatmaidSpatialSkeletonMergeResult; + try { + result = await this.editOperations.commitMerge({ + fromNode: formerParent.node, + toNode: splitNode.node, + }); + } catch (error) { + await refreshTopologySegments(this.layer, [ + splitNode.node.segmentId, + formerParent.node.segmentId, + ]); + throw error; + } + const resultSkeletonId = + result.resultSegmentId ?? formerParent.node.segmentId; + const deletedSkeletonId = + result.deletedSegmentId ?? + (resultSkeletonId === splitNode.node.segmentId + ? formerParent.node.segmentId + : splitNode.node.segmentId); + if (this.stableSegmentId !== undefined) { + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + this.stableSegmentId, + resultSkeletonId, + ); + } + if (this.stableNewSegmentId !== undefined) { + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + this.stableNewSegmentId, + resultSkeletonId, + ); + } + ensureVisibleSegment(this.layer, resultSkeletonId); + if (deletedSkeletonId !== resultSkeletonId) { + removeVisibleSegment(this.layer, deletedSkeletonId, { deselect: true }); + this.layer.displayState.segmentStatedColors.value.delete( + BigInt(deletedSkeletonId), + ); + splitNode.skeletonLayer.suppressBrowseSegment(deletedSkeletonId); + } + this.layer.selectSpatialSkeletonNode( + splitNode.node.nodeId, + this.layer.manager.root.selectionState.pin.value, + { + segmentId: resultSkeletonId, + }, + ); + await refreshTopologySegments(this.layer, [ + resultSkeletonId, + deletedSkeletonId, + ]); + StatusMessage.showTemporaryMessage( + `${statusPrefix} split at node ${splitNode.node.nodeId}.`, + ); + } + + execute() { + return this.split("Split"); + } + + undo() { + return this.mergeBack("Undid"); + } + + redo() { + return this.split("Redid split of"); + } +} + +class MergeCommand implements SpatialSkeletonCommand { + readonly label = "Merge skeletons"; + private stableResultSegmentId: number | undefined; + private stableDeletedSegmentId: number | undefined; + private stableAttachedNodeId: number | undefined; + private stableAttachedRootNodeId: number | undefined; + + constructor( + private layer: SegmentationUserLayer, + private stableFirstNodeId: number, + private stableFirstSegmentId: number | undefined, + private stableSecondNodeId: number, + private stableSecondSegmentId: number | undefined, + private editOperations: CatmaidSpatialSkeletonEditOperations, + ) {} + + private async merge(statusPrefix: string) { + const firstNode = await getResolvedNodeForEdit( + this.layer, + this.stableFirstNodeId, + this.stableFirstSegmentId, + ); + const secondNode = await getResolvedNodeForEdit( + this.layer, + this.stableSecondNodeId, + this.stableSecondSegmentId, + ); + let result: CatmaidSpatialSkeletonMergeResult; + try { + result = await this.editOperations.commitMerge({ + fromNode: firstNode.node, + toNode: secondNode.node, + }); + } catch (error) { + await refreshTopologySegments(this.layer, [ + firstNode.node.segmentId, + secondNode.node.segmentId, + ]); + throw error; + } + const winningNode = + result.resultSegmentId === secondNode.node.segmentId + ? secondNode.node + : firstNode.node; + const losingNode = + winningNode.nodeId === firstNode.node.nodeId + ? secondNode.node + : firstNode.node; + const resultSkeletonId = result.resultSegmentId ?? winningNode.segmentId; + const deletedSkeletonId = result.deletedSegmentId ?? losingNode.segmentId; + const attachedRootNodeId = + losingNode.segmentId === firstNode.node.segmentId + ? findRootNode(firstNode.segmentNodes)?.nodeId + : findRootNode(secondNode.segmentNodes)?.nodeId; + this.stableAttachedNodeId = + this.stableAttachedNodeId ?? + this.layer.spatialSkeletonState.commandHistory.mappings.getStableOrCurrentNodeId( + losingNode.nodeId, + ); + this.stableAttachedRootNodeId = + this.stableAttachedRootNodeId ?? + this.layer.spatialSkeletonState.commandHistory.mappings.getStableOrCurrentNodeId( + attachedRootNodeId, + ); + this.stableResultSegmentId = + this.stableResultSegmentId ?? + this.layer.spatialSkeletonState.commandHistory.mappings.getStableOrCurrentSegmentId( + resultSkeletonId, + ); + this.stableDeletedSegmentId = + this.stableDeletedSegmentId ?? + this.layer.spatialSkeletonState.commandHistory.mappings.getStableOrCurrentSegmentId( + deletedSkeletonId, + ); + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + this.stableDeletedSegmentId, + resultSkeletonId, + ); + ensureVisibleSegment(this.layer, resultSkeletonId); + removeVisibleSegment(this.layer, deletedSkeletonId, { deselect: true }); + selectSegment(this.layer, resultSkeletonId, false); + this.layer.selectSpatialSkeletonNode( + losingNode.nodeId, + this.layer.manager.root.selectionState.pin.value, + { + segmentId: resultSkeletonId, + }, + ); + this.layer.displayState.segmentStatedColors.value.delete( + BigInt(deletedSkeletonId), + ); + if (deletedSkeletonId !== resultSkeletonId) { + firstNode.skeletonLayer.suppressBrowseSegment(deletedSkeletonId); + } + this.layer.clearSpatialSkeletonMergeAnchor(); + await refreshTopologySegments(this.layer, [ + resultSkeletonId, + deletedSkeletonId, + ]); + const swapSuffix = result.directionAdjusted + ? " Merge direction was adjusted by the active source." + : ""; + StatusMessage.showTemporaryMessage( + `${statusPrefix} skeleton ${deletedSkeletonId} into ${resultSkeletonId}.${swapSuffix}`, + ); + } + + private async undoMerge(statusPrefix: string) { + if (this.stableAttachedNodeId === undefined) { + throw new Error("Merge undo is missing the attached node id."); + } + if (this.stableDeletedSegmentId === undefined) { + throw new Error("Merge undo is missing the deleted skeleton id."); + } + const attachedNode = await getResolvedNodeForEdit( + this.layer, + this.stableAttachedNodeId, + this.stableResultSegmentId ?? this.stableFirstSegmentId, + ); + let splitResult: CatmaidSpatialSkeletonSplitResult; + try { + splitResult = await this.editOperations.commitSplit({ + node: attachedNode.node, + segmentNodes: attachedNode.segmentNodes, + }); + } catch (error) { + await refreshTopologySegments(this.layer, [attachedNode.node.segmentId]); + throw error; + } + const restoredSegmentId = + splitResult.newSegmentId ?? + (() => { + throw new Error( + "The active skeleton source did not return a new skeleton id for merge undo.", + ); + })(); + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + this.stableDeletedSegmentId, + restoredSegmentId, + ); + const survivingSegmentId = + splitResult.existingSegmentId ?? attachedNode.node.segmentId; + ensureVisibleSegment(this.layer, survivingSegmentId); + ensureVisibleSegment(this.layer, restoredSegmentId); + await refreshTopologySegments(this.layer, [ + survivingSegmentId, + restoredSegmentId, + ]); + let rerootWarning: string | undefined; + if ( + this.stableAttachedRootNodeId !== undefined && + this.stableAttachedRootNodeId !== this.stableAttachedNodeId + ) { + try { + const restoredRoot = await getResolvedNodeForEdit( + this.layer, + this.stableAttachedRootNodeId, + this.stableDeletedSegmentId, + ); + if (restoredRoot.node.parentNodeId !== undefined) { + await this.editOperations.commitReroot({ + node: restoredRoot.node, + segmentNodes: restoredRoot.segmentNodes, + }); + await refreshTopologySegments(this.layer, [ + survivingSegmentId, + restoredSegmentId, + ]); + } + } catch (error) { + await refreshTopologySegments(this.layer, [ + survivingSegmentId, + restoredSegmentId, + ]); + rerootWarning = + `Undo split the merged skeletons, but failed to reroot the restored skeleton. ` + + `Only the split completed. ${formatErrorMessage(error)}`; + } + } + this.layer.selectSpatialSkeletonNode( + attachedNode.node.nodeId, + this.layer.manager.root.selectionState.pin.value, + { + segmentId: restoredSegmentId, + }, + ); + StatusMessage.showTemporaryMessage( + rerootWarning ?? + `${statusPrefix} merge involving node ${attachedNode.node.nodeId}.`, + ); + } + + execute() { + return this.merge("Merged"); + } + + undo() { + return this.undoMerge("Undid"); + } + + redo() { + return this.merge("Redid merge of"); + } +} + +function makeCatmaidCommandFactory( + action: TAction, + createCommand: ( + layer: SegmentationUserLayer, + payload: object, + ) => SpatialSkeletonCommand, +): SpatialSkeletonEditCommandFactory { + return { action, createCommand }; +} + +function getCatmaidEditPosition( + position: SpatialSkeletonVector, + label: string, +): [number, number, number] { + const values = toCatmaidPositionInModelSpace(position, label); + return [values[0], values[1], values[2]]; +} + +export class CatmaidSpatialSkeletonEditCommands { + constructor( + private readonly editContext: CatmaidSpatialSkeletonEditCommandContext, + ) {} + + private readonly editOperations: CatmaidSpatialSkeletonEditOperations = { + commitAddNode: (request) => this.commitAddNode(request), + commitInsertNode: (request) => this.commitInsertNode(request), + commitMoveNode: (request) => this.commitMoveNode(request), + commitDeleteNode: (request) => this.commitDeleteNode(request), + commitReroot: (request) => this.commitReroot(request), + commitDescription: (request) => this.commitDescription(request), + commitTrueEnd: (request) => this.commitTrueEnd(request), + commitRadius: (request) => this.commitRadius(request), + commitConfidence: (request) => this.commitConfidence(request), + commitMerge: (request) => this.commitMerge(request), + commitSplit: (request) => this.commitSplit(request), + }; + + readonly addNodesCommand = makeCatmaidCommandFactory( + SpatialSkeletonActions.addNodes, + (layer, payload) => + this.createAddNodeCommand( + layer, + requireCatmaidAddNodeCommandOptions(payload), + ), + ); + + readonly insertNodesCommand = makeCatmaidCommandFactory( + SpatialSkeletonActions.insertNodes, + (layer, payload) => + this.createInsertNodeCommand( + layer, + requireCatmaidInsertNodeCommandOptions(payload), + ), + ); + + readonly moveNodesCommand = makeCatmaidCommandFactory( + SpatialSkeletonActions.moveNodes, + (layer, payload) => + this.createMoveNodeCommand( + layer, + requireCatmaidMoveNodeCommandOptions(payload), + ), + ); + + readonly deleteNodesCommand = makeCatmaidCommandFactory( + SpatialSkeletonActions.deleteNodes, + (layer, payload) => + this.createDeleteNodeCommand( + layer, + requireCatmaidDeleteNodeCommandPayload(payload), + ), + ); + + readonly rerootCommand = makeCatmaidCommandFactory( + SpatialSkeletonActions.reroot, + (layer, payload) => + this.createRerootCommand( + layer, + requireCatmaidRerootCommandPayload(payload), + ), + ); + + readonly editNodeDescriptionCommand = makeCatmaidCommandFactory( + SpatialSkeletonActions.editNodeDescription, + (layer, payload) => + this.createNodeDescriptionCommand( + layer, + requireCatmaidNodeDescriptionCommandOptions(payload), + ), + ); + + readonly editNodeTrueEndCommand = makeCatmaidCommandFactory( + SpatialSkeletonActions.editNodeTrueEnd, + (layer, payload) => + this.createNodeTrueEndCommand( + layer, + requireCatmaidNodeTrueEndCommandOptions(payload), + ), + ); + + readonly editNodeRadiusCommand = makeCatmaidCommandFactory( + SpatialSkeletonActions.editNodeRadius, + (layer, payload) => + this.createNodeRadiusCommand( + layer, + requireCatmaidNodeRadiusCommandOptions(payload), + ), + ); + + readonly editNodeConfidenceCommand = makeCatmaidCommandFactory( + SpatialSkeletonActions.editNodeConfidence, + (layer, payload) => + this.createNodeConfidenceCommand( + layer, + requireCatmaidNodeConfidenceCommandOptions(payload), + ), + ); + + readonly mergeSkeletonsCommand = makeCatmaidCommandFactory( + SpatialSkeletonActions.mergeSkeletons, + (layer, payload) => { + const options = requireCatmaidMergeCommandPayload(payload); + return this.createMergeCommand( + layer, + options.firstNode, + options.secondNode, + ); + }, + ); + + readonly splitSkeletonsCommand = makeCatmaidCommandFactory( + SpatialSkeletonActions.splitSkeletons, + (layer, payload) => + this.createSplitCommand( + layer, + requireCatmaidSplitCommandPayload(payload), + ), + ); + + private get client() { + return this.editContext.getClient(); + } + + private commitAddNode( + request: CatmaidSpatialSkeletonAddNodeRequest, + ): Promise { + const [x, y, z] = getCatmaidEditPosition( + request.position, + "add-node position", + ); + return this.client.addNode( + request.segmentId, + x, + y, + z, + request.parentNode?.nodeId, + request.parentNode === undefined + ? undefined + : buildCatmaidNodeEditContext(request.parentNode), + ); + } + + private commitInsertNode( + request: CatmaidSpatialSkeletonInsertNodeRequest, + ): Promise { + const [x, y, z] = getCatmaidEditPosition( + request.position, + "insert-node position", + ); + return this.client.insertNode( + request.segmentId, + x, + y, + z, + request.parentNode.nodeId, + request.childNodes.map((child) => child.nodeId), + buildCatmaidInsertEditContext(request.parentNode, request.childNodes), + ); + } + + private commitMoveNode( + request: CatmaidSpatialSkeletonMoveNodeRequest, + ): Promise { + const [x, y, z] = getCatmaidEditPosition( + request.position, + "move-node position", + ); + return this.client.moveNode( + request.node.nodeId, + x, + y, + z, + buildCatmaidNodeEditContext(request.node), + ); + } + + private commitDeleteNode( + request: CatmaidSpatialSkeletonDeleteNodeRequest, + ): Promise { + return this.client.deleteNode(request.node.nodeId, { + childNodeIds: request.childNodes.map((child) => child.nodeId), + editContext: buildCatmaidNeighborhoodEditContext( + request.node, + request.segmentNodes, + ), + }); + } + + private commitReroot( + request: CatmaidSpatialSkeletonRerootRequest, + ): Promise { + return this.client.rerootSkeleton( + request.node.nodeId, + buildCatmaidRerootEditContext(request.node, request.segmentNodes), + ); + } + + private commitDescription( + request: CatmaidSpatialSkeletonDescriptionUpdateRequest, + ): Promise { + return this.client.updateDescription( + request.node.nodeId, + request.description, + { + isTrueEnd: request.isTrueEnd ?? request.node.isTrueEnd === true, + }, + ); + } + + private commitTrueEnd( + request: CatmaidSpatialSkeletonTrueEndUpdateRequest, + ): Promise { + return this.client.toggleTrueEnd(request.node.nodeId, request.isTrueEnd); + } + + private commitRadius( + request: CatmaidSpatialSkeletonRadiusUpdateRequest, + ): Promise { + return this.client.updateRadius( + request.node.nodeId, + request.radius, + buildCatmaidNodeEditContext(request.node), + ); + } + + private commitConfidence( + request: CatmaidSpatialSkeletonConfidenceUpdateRequest, + ): Promise { + return this.client.updateConfidence( + request.node.nodeId, + request.confidence, + buildCatmaidNodeEditContext(request.node), + ); + } + + private commitMerge( + request: CatmaidSpatialSkeletonMergeRequest, + ): Promise { + return this.client.mergeSkeletons( + request.fromNode.nodeId, + request.toNode.nodeId, + buildCatmaidMultiNodeEditContext(request.fromNode, request.toNode), + ); + } + + private commitSplit( + request: CatmaidSpatialSkeletonSplitRequest, + ): Promise { + return this.client.splitSkeleton( + request.node.nodeId, + buildCatmaidNeighborhoodEditContext(request.node, request.segmentNodes), + ); + } + + private createAddNodeCommand( + layer: SegmentationUserLayer, + options: CatmaidSpatialSkeletonAddNodeCommandOptions, + ) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + return new AddNodeCommand( + layer, + commandMappings.getStableOrCurrentNodeId(options.parentNodeId), + commandMappings.getStableOrCurrentSegmentId(options.skeletonId) ?? + options.skeletonId, + toCatmaidPositionInModelSpace( + options.positionInModelSpace, + "add-node position", + ), + this.editOperations, + ); + } + + private createInsertNodeCommand( + layer: SegmentationUserLayer, + options: CatmaidSpatialSkeletonInsertNodeCommandOptions, + ) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + return new InsertNodeCommand( + layer, + commandMappings.getStableOrCurrentNodeId(options.parentNodeId)!, + options.childNodeIds.map( + (childNodeId) => commandMappings.getStableOrCurrentNodeId(childNodeId)!, + ), + commandMappings.getStableOrCurrentSegmentId(options.skeletonId) ?? + options.skeletonId, + toCatmaidPositionInModelSpace( + options.positionInModelSpace, + "insert-node position", + ), + this.editOperations, + ); + } + + private createMoveNodeCommand( + layer: SegmentationUserLayer, + options: CatmaidSpatialSkeletonMoveNodeCommandOptions, + ) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + return new MoveNodeCommand( + layer, + commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), + toCatmaidPositionInModelSpace( + options.node.position, + "move-node current position", + ), + toCatmaidPositionInModelSpace( + options.nextPositionInModelSpace, + "move-node target position", + ), + this.editOperations, + ); + } + + private createDeleteNodeCommand( + layer: SegmentationUserLayer, + node: SpatiallyIndexedSkeletonNode, + ) { + const segmentNodes = layer.getCachedSpatialSkeletonSegmentNodesForEdit( + node.segmentId, + ); + const refreshedNode = findSpatiallyIndexedSkeletonNode( + segmentNodes, + node.nodeId, + ); + if (refreshedNode === undefined) { + throw new Error( + `Node ${node.nodeId} is not available in the inspected skeleton cache.`, + ); + } + const childNodes = getSpatiallyIndexedSkeletonDirectChildren( + segmentNodes, + refreshedNode.nodeId, + ); + return new DeleteNodeCommand( + layer, + refreshedNode, + childNodes, + this.editOperations, + ); + } + + private createNodeDescriptionCommand( + layer: SegmentationUserLayer, + options: CatmaidSpatialSkeletonNodeDescriptionCommandOptions, + ) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + return new NodeDescriptionCommand( + layer, + commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), + options.node.description, + options.nextDescription ?? options.node.description, + this.editOperations, + ); + } + + private createNodeTrueEndCommand( + layer: SegmentationUserLayer, + options: CatmaidSpatialSkeletonNodeTrueEndCommandOptions, + ) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + return new NodeTrueEndCommand( + layer, + commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), + options.node.isTrueEnd ?? false, + options.nextIsTrueEnd, + this.editOperations, + ); + } + + private createNodeRadiusCommand( + layer: SegmentationUserLayer, + options: CatmaidSpatialSkeletonNodeRadiusCommandOptions, + ) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + return new NodeRadiusCommand( + layer, + commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), + options.node.radius ?? 0, + options.nextRadius, + this.editOperations, + ); + } + + private createNodeConfidenceCommand( + layer: SegmentationUserLayer, + options: CatmaidSpatialSkeletonNodeConfidenceCommandOptions, + ) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + return new NodeConfidenceCommand( + layer, + commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), + options.node.confidence ?? 0, + options.nextConfidence, + this.editOperations, + ); + } + + private createRerootCommand( + layer: SegmentationUserLayer, + node: Pick< + SpatiallyIndexedSkeletonNode, + "nodeId" | "segmentId" | "parentNodeId" + >, + ) { + const segmentNodes = layer.getCachedSpatialSkeletonSegmentNodesForEdit( + node.segmentId, + ); + const rootNode = + findRootNode(segmentNodes) ?? + (() => { + throw new Error( + `Unable to resolve the current root for segment ${node.segmentId}.`, + ); + })(); + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + return new RerootCommand( + layer, + commandMappings.getStableOrCurrentNodeId(node.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(node.segmentId), + commandMappings.getStableOrCurrentNodeId(rootNode.nodeId)!, + this.editOperations, + ); + } + + private createSplitCommand( + layer: SegmentationUserLayer, + node: Pick, + ) { + const segmentNodes = layer.getCachedSpatialSkeletonSegmentNodesForEdit( + node.segmentId, + ); + const splitNode = findSpatiallyIndexedSkeletonNode( + segmentNodes, + node.nodeId, + ); + if (splitNode === undefined) { + throw new Error( + `Node ${node.nodeId} is not available in the inspected skeleton cache.`, + ); + } + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + return new SplitCommand( + layer, + commandMappings.getStableOrCurrentNodeId(splitNode.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(splitNode.segmentId), + commandMappings.getStableOrCurrentNodeId(splitNode.parentNodeId), + this.editOperations, + ); + } + + private createMergeCommand( + layer: SegmentationUserLayer, + firstNode: CatmaidSpatialSkeletonMergeEndpoint, + secondNode: CatmaidSpatialSkeletonMergeEndpoint, + ) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + return new MergeCommand( + layer, + commandMappings.getStableOrCurrentNodeId(firstNode.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(firstNode.segmentId), + commandMappings.getStableOrCurrentNodeId(secondNode.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(secondNode.segmentId), + this.editOperations, + ); + } +} diff --git a/src/datasource/catmaid/spatial_skeleton_edit_api.ts b/src/datasource/catmaid/spatial_skeleton_edit_api.ts new file mode 100644 index 000000000..b83b0e3ea --- /dev/null +++ b/src/datasource/catmaid/spatial_skeleton_edit_api.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { + SpatiallyIndexedSkeletonNode, + SpatialSkeletonSourceState, + SpatialSkeletonVector, +} from "#src/skeleton/api.js"; + +// CATMAID owns these payloads; the generic skeleton API only promises named edit operations. +export interface CatmaidSpatialSkeletonNodeSourceStateUpdate { + nodeId: number; + sourceState: SpatialSkeletonSourceState; +} + +export interface CatmaidSpatialSkeletonEditResult { + nodeSourceStateUpdates?: readonly CatmaidSpatialSkeletonNodeSourceStateUpdate[]; +} + +export interface CatmaidSpatialSkeletonAddNodeRequest { + segmentId: number; + position: SpatialSkeletonVector; + parentNode?: SpatiallyIndexedSkeletonNode; +} + +export interface CatmaidSpatialSkeletonAddNodeResult + extends CatmaidSpatialSkeletonEditResult { + nodeId: number; + segmentId: number; + sourceState?: SpatialSkeletonSourceState; + parentSourceState?: SpatialSkeletonSourceState; +} + +export interface CatmaidSpatialSkeletonInsertNodeRequest { + segmentId: number; + position: SpatialSkeletonVector; + parentNode: SpatiallyIndexedSkeletonNode; + childNodes: readonly SpatiallyIndexedSkeletonNode[]; +} + +export type CatmaidSpatialSkeletonInsertNodeResult = + CatmaidSpatialSkeletonAddNodeResult; + +export interface CatmaidSpatialSkeletonMoveNodeRequest { + node: SpatiallyIndexedSkeletonNode; + position: SpatialSkeletonVector; +} + +export interface CatmaidSpatialSkeletonNodeSourceStateResult + extends CatmaidSpatialSkeletonEditResult { + sourceState?: SpatialSkeletonSourceState; +} + +export interface CatmaidSpatialSkeletonDeleteNodeRequest { + node: SpatiallyIndexedSkeletonNode; + childNodes: readonly SpatiallyIndexedSkeletonNode[]; + segmentNodes: readonly SpatiallyIndexedSkeletonNode[]; +} + +export type CatmaidSpatialSkeletonDeleteNodeResult = + CatmaidSpatialSkeletonEditResult; + +export interface CatmaidSpatialSkeletonSplitRequest { + node: SpatiallyIndexedSkeletonNode; + segmentNodes: readonly SpatiallyIndexedSkeletonNode[]; +} + +export interface CatmaidSpatialSkeletonSplitResult + extends CatmaidSpatialSkeletonEditResult { + existingSegmentId: number | undefined; + newSegmentId: number | undefined; +} + +export interface CatmaidSpatialSkeletonMergeRequest { + fromNode: SpatiallyIndexedSkeletonNode; + toNode: SpatiallyIndexedSkeletonNode; +} + +export interface CatmaidSpatialSkeletonMergeResult + extends CatmaidSpatialSkeletonEditResult { + resultSegmentId: number | undefined; + deletedSegmentId: number | undefined; + directionAdjusted: boolean; +} + +export interface CatmaidSpatialSkeletonRerootRequest { + node: SpatiallyIndexedSkeletonNode; + segmentNodes: readonly SpatiallyIndexedSkeletonNode[]; +} + +export type CatmaidSpatialSkeletonRerootResult = + CatmaidSpatialSkeletonEditResult; + +export interface CatmaidSpatialSkeletonDescriptionUpdateRequest { + node: SpatiallyIndexedSkeletonNode; + description: string; + isTrueEnd?: boolean; +} + +export interface CatmaidSpatialSkeletonDescriptionUpdateResult + extends CatmaidSpatialSkeletonNodeSourceStateResult { + description?: string; +} + +export interface CatmaidSpatialSkeletonTrueEndUpdateRequest { + node: SpatiallyIndexedSkeletonNode; + isTrueEnd: boolean; +} + +export interface CatmaidSpatialSkeletonRadiusUpdateRequest { + node: SpatiallyIndexedSkeletonNode; + radius: number; +} + +export interface CatmaidSpatialSkeletonConfidenceUpdateRequest { + node: SpatiallyIndexedSkeletonNode; + confidence: number; +} diff --git a/src/layer/index.ts b/src/layer/index.ts index e6e5a5d7a..136f8b5a1 100644 --- a/src/layer/index.ts +++ b/src/layer/index.ts @@ -63,6 +63,7 @@ import type { RenderLayerRole, VisibilityTrackedRenderLayer, } from "#src/renderlayer.js"; +import type { SpatialSkeletonSourceState } from "#src/skeleton/api.js"; import type { VolumeType } from "#src/sliceview/volume/base.js"; import { StatusMessage } from "#src/status.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; @@ -1148,7 +1149,7 @@ export interface PickedSpatialSkeletonState { nodeId?: number; segmentId?: number; position?: Float32Array; - revisionToken?: string; + sourceState?: SpatialSkeletonSourceState; } export interface PickState { diff --git a/src/layer/segmentation/index.spec.ts b/src/layer/segmentation/index.spec.ts index 55225e041..a6035860c 100644 --- a/src/layer/segmentation/index.spec.ts +++ b/src/layer/segmentation/index.spec.ts @@ -33,41 +33,56 @@ const { SegmentSelectionState } = await import( function makeEditableSpatialSkeletonSource( options: { - rerootSkeleton?: (() => Promise) | undefined; + confidenceConfiguration?: boolean; + rerootCommand?: boolean; } = {}, ) { + const createCommand = () => ({ + label: "test command", + execute: vi.fn(), + undo: vi.fn(), + redo: vi.fn(), + }); + const makeCommand = (action: string) => ({ + action, + createCommand, + }); return { + readOnly: false, + addNodesCommand: makeCommand(SpatialSkeletonActions.addNodes), + insertNodesCommand: makeCommand(SpatialSkeletonActions.insertNodes), + moveNodesCommand: makeCommand(SpatialSkeletonActions.moveNodes), + deleteNodesCommand: makeCommand(SpatialSkeletonActions.deleteNodes), + editNodeDescriptionCommand: makeCommand( + SpatialSkeletonActions.editNodeDescription, + ), + editNodeTrueEndCommand: makeCommand(SpatialSkeletonActions.editNodeTrueEnd), + editNodeRadiusCommand: makeCommand(SpatialSkeletonActions.editNodeRadius), + editNodeConfidenceCommand: makeCommand( + SpatialSkeletonActions.editNodeConfidence, + ), + mergeSkeletonsCommand: makeCommand(SpatialSkeletonActions.mergeSkeletons), + splitSkeletonsCommand: makeCommand(SpatialSkeletonActions.splitSkeletons), listSkeletons: async () => [], getSkeleton: async () => [], fetchNodes: async () => [], getSpatialIndexMetadata: async () => null, - addNode: async () => ({ treenodeId: 1, skeletonId: 1 }), - insertNode: async () => ({ treenodeId: 1, skeletonId: 1 }), - moveNode: async () => ({}), - deleteNode: async () => ({}), - updateDescription: async () => ({}), - setTrueEnd: async () => ({}), - removeTrueEnd: async () => ({}), - updateRadius: async () => ({}), - updateConfidence: async () => ({}), getSkeletonRootNode: async () => ({ nodeId: 1, - x: 0, - y: 0, - z: 0, - }), - mergeSkeletons: async () => ({ - resultSkeletonId: 1, - deletedSkeletonId: 2, - stableAnnotationSwap: false, - }), - splitSkeleton: async () => ({ - existingSkeletonId: 1, - newSkeletonId: 2, + position: [0, 0, 0], }), - ...(options.rerootSkeleton === undefined + ...(options.confidenceConfiguration !== true ? {} - : { rerootSkeleton: options.rerootSkeleton }), + : { + spatialSkeletonConfidenceConfiguration: { + values: [0, 50, 100], + }, + }), + ...(options.rerootCommand !== true + ? {} + : { + rerootCommand: makeCommand(SpatialSkeletonActions.reroot), + }), }; } @@ -120,14 +135,14 @@ describe("layer/segmentation spatial skeleton chunk stats", () => { }); describe("layer/segmentation spatial skeleton action gating", () => { - it("does not require max lod for skeleton actions", () => { + it("does not require a specific grid level for skeleton actions", () => { const layer = Object.assign( Object.create(SegmentationUserLayer.prototype), { getSpatiallyIndexedSkeletonLayer: () => makeSpatialSkeletonLayerWithSource( makeEditableSpatialSkeletonSource({ - rerootSkeleton: async () => {}, + rerootCommand: true, }), ), spatialSkeletonVisibleChunksLoaded: new WatchableValue(true), @@ -206,6 +221,69 @@ describe("layer/segmentation spatial skeleton action gating", () => { "The active spatial skeleton source does not support skeleton rerooting.", ); }); + + it("requires confidence configuration for confidence edit support", () => { + const layer = Object.assign( + Object.create(SegmentationUserLayer.prototype), + { + getSpatiallyIndexedSkeletonLayer: () => + makeSpatialSkeletonLayerWithSource(makeEditableSpatialSkeletonSource()), + spatialSkeletonVisibleChunksLoaded: new WatchableValue(true), + spatialSkeletonVisibleChunksNeeded: new WatchableValue(0), + spatialSkeletonVisibleChunksAvailable: new WatchableValue(0), + }, + ); + + expect( + layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.editNodeConfidence, + ), + ).toBe( + "The active spatial skeleton source does not support node confidence editing.", + ); + + layer.getSpatiallyIndexedSkeletonLayer = () => + makeSpatialSkeletonLayerWithSource( + makeEditableSpatialSkeletonSource({ + confidenceConfiguration: true, + }), + ); + + expect( + layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.editNodeConfidence, + ), + ).toBeUndefined(); + }); + + it("reports read-only spatial skeleton sources explicitly", () => { + const layer = Object.assign( + Object.create(SegmentationUserLayer.prototype), + { + getSpatiallyIndexedSkeletonLayer: () => + makeSpatialSkeletonLayerWithSource({ + ...makeEditableSpatialSkeletonSource({ + rerootCommand: true, + }), + readOnly: true, + }), + spatialSkeletonVisibleChunksLoaded: new WatchableValue(true), + spatialSkeletonVisibleChunksNeeded: new WatchableValue(0), + spatialSkeletonVisibleChunksAvailable: new WatchableValue(0), + }, + ); + + expect( + layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.addNodes, + ), + ).toBe("The active spatial skeleton source is read-only."); + expect( + layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.inspect, + ), + ).toBeUndefined(); + }); }); describe("layer/segmentation spatial skeleton selection serialization", () => { diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 10b413058..46d162e6a 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -52,8 +52,9 @@ import { } from "#src/layer/segmentation/selection.js"; import { executeSpatialSkeletonDeleteNode, + executeSpatialSkeletonNodeConfidenceUpdate, executeSpatialSkeletonNodeDescriptionUpdate, - executeSpatialSkeletonNodePropertiesUpdate, + executeSpatialSkeletonNodeRadiusUpdate, executeSpatialSkeletonReroot, executeSpatialSkeletonNodeTrueEndUpdate, } from "#src/layer/segmentation/spatial_skeleton_commands.js"; @@ -111,12 +112,11 @@ import { SpatialSkeletonActions, type SpatialSkeletonAction, } from "#src/skeleton/actions.js"; -import { - SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES, - type SpatiallyIndexedSkeletonNode, +import type { + SpatiallyIndexedSkeletonNode, + SpatialSkeletonSourceState, } from "#src/skeleton/api.js"; import { - buildSpatiallyIndexedSkeletonNeighborhoodEditContext, findSpatiallyIndexedSkeletonNode, getSpatiallyIndexedSkeletonDirectChildren, getSpatiallyIndexedSkeletonNodeParent, @@ -142,6 +142,7 @@ import { import { getEditableSpatiallyIndexedSkeletonSource, getSpatiallyIndexedSkeletonSource, + isSpatiallyIndexedSkeletonSourceReadOnly, SpatialSkeletonState, } from "#src/skeleton/spatial_skeleton_manager.js"; import { DataType, VolumeType } from "#src/sliceview/volume/base.js"; @@ -800,7 +801,10 @@ class SegmentationUserLayerDisplayState implements SegmentationDisplayState { if (levels.length > 0) { this.setSpatialSkeletonGridLevel( "2d", - findClosestSpatialSkeletonGridLevelBySpacing(levels, this.spatialSkeletonGridResolutionTarget2d.value), + findClosestSpatialSkeletonGridLevelBySpacing( + levels, + this.spatialSkeletonGridResolutionTarget2d.value, + ), ); } }); @@ -809,7 +813,10 @@ class SegmentationUserLayerDisplayState implements SegmentationDisplayState { if (levels.length > 0) { this.setSpatialSkeletonGridLevel( "3d", - findClosestSpatialSkeletonGridLevelBySpacing(levels, this.spatialSkeletonGridResolutionTarget3d.value), + findClosestSpatialSkeletonGridLevelBySpacing( + levels, + this.spatialSkeletonGridResolutionTarget3d.value, + ), ); } }); @@ -974,7 +981,7 @@ interface SelectedSpatialSkeletonNodeInfo { nodeId: number; segmentId?: number; position?: Float32Array; - revisionToken?: string; + sourceState?: SpatialSkeletonSourceState; } function normalizeOptionalPositiveSafeInteger(value: unknown) { @@ -1113,7 +1120,7 @@ export class SegmentationUserLayer extends Base { options: { segmentId?: number; position?: ArrayLike; - revisionToken?: string; + sourceState?: SpatialSkeletonSourceState; } = {}, ) => { const normalizedNodeId = normalizeOptionalPositiveSafeInteger(nodeId); @@ -1128,15 +1135,12 @@ export class SegmentationUserLayer extends Base { const selectedNodePosition = options.position ?? selectedNodeInfo?.position; const selectedGlobalPosition = this.getGlobalSelectionPositionFromModelPosition(selectedNodePosition); - const revisionToken = - typeof options.revisionToken === "string" - ? options.revisionToken - : selectedNodeInfo?.revisionToken; + const sourceState = options.sourceState ?? selectedNodeInfo?.sourceState; this.selectedSpatialSkeletonNodeInfo.value = { nodeId: normalizedNodeId, segmentId, position: copyOptionalSpatialSkeletonPosition(selectedNodePosition), - revisionToken, + sourceState, }; this.captureSpatialSkeletonSelectionState( (state) => { @@ -1587,23 +1591,49 @@ export class SegmentationUserLayer extends Base { if (action === SpatialSkeletonActions.inspect) { return getSpatiallyIndexedSkeletonSource(skeletonLayer) !== undefined; } - const editableSource = - getEditableSpatiallyIndexedSkeletonSource(skeletonLayer); - if (editableSource === undefined) { - return false; - } - if (action === SpatialSkeletonActions.reroot) { - return editableSource.rerootSkeleton !== undefined; + const source = getEditableSpatiallyIndexedSkeletonSource(skeletonLayer); + if (source === undefined) return false; + switch (action) { + case SpatialSkeletonActions.addNodes: + case SpatialSkeletonActions.deleteNodes: + case SpatialSkeletonActions.moveNodes: + case SpatialSkeletonActions.splitSkeletons: + case SpatialSkeletonActions.mergeSkeletons: + return true; + case SpatialSkeletonActions.insertNodes: + return source.insertNodesCommand !== undefined; + case SpatialSkeletonActions.reroot: + return source.rerootCommand !== undefined; + case SpatialSkeletonActions.editNodeDescription: + return source.editNodeDescriptionCommand !== undefined; + case SpatialSkeletonActions.editNodeTrueEnd: + return source.editNodeTrueEndCommand !== undefined; + case SpatialSkeletonActions.editNodeRadius: + return source.editNodeRadiusCommand !== undefined; + case SpatialSkeletonActions.editNodeConfidence: + return ( + source.editNodeConfidenceCommand !== undefined && + source.spatialSkeletonConfidenceConfiguration !== undefined + ); } - return true; } private getMissingSpatialSkeletonSupportReason( requiredActions: SpatialSkeletonAction | readonly SpatialSkeletonAction[], ) { + const skeletonLayer = this.getSpatiallyIndexedSkeletonLayer(); const requirements = Array.isArray(requiredActions) ? requiredActions : [requiredActions]; + if ( + skeletonLayer !== undefined && + requirements.some( + (action) => action !== SpatialSkeletonActions.inspect, + ) && + isSpatiallyIndexedSkeletonSourceReadOnly(skeletonLayer) + ) { + return "The active spatial skeleton source is read-only."; + } const missingRequirements = requirements.filter( (action) => !this.supportsSpatialSkeletonAction(action), ); @@ -1698,10 +1728,6 @@ export class SegmentationUserLayer extends Base { currentNode, ), childNodes, - editContext: buildSpatiallyIndexedSkeletonNeighborhoodEditContext( - currentNode, - segmentNodes, - ), }; } @@ -2057,8 +2083,12 @@ export class SegmentationUserLayer extends Base { (value) => this.displayState.spatialSkeletonNodeFilter.restoreState(value), ); - this.displayState.spatialSkeletonGridResolutionTarget2d.restoreState(specification[json_keys.SKELETON_CROSS_SECTION_RENDER_SCALE_JSON_KEY]); - this.displayState.spatialSkeletonGridResolutionTarget3d.restoreState(specification[json_keys.SKELETON_PERSPECTIVE_RENDER_SCALE_JSON_KEY]); + this.displayState.spatialSkeletonGridResolutionTarget2d.restoreState( + specification[json_keys.SKELETON_CROSS_SECTION_RENDER_SCALE_JSON_KEY], + ); + this.displayState.spatialSkeletonGridResolutionTarget3d.restoreState( + specification[json_keys.SKELETON_PERSPECTIVE_RENDER_SCALE_JSON_KEY], + ); this.displayState.baseSegmentColoring.restoreState( specification[json_keys.BASE_SEGMENT_COLORING_JSON_KEY], ); @@ -2461,22 +2491,20 @@ export class SegmentationUserLayer extends Base { return true; } - const segmentId = nodeInfo.segmentId; - const nodePosition = nodeInfo.position; + const fullNodeInfo = completeNodeInfo; + const segmentId = fullNodeInfo.segmentId; + const nodePosition = fullNodeInfo.position; const segmentNodes = this.spatialSkeletonState.getCachedSegmentNodes(segmentId); const directChildNodeIds = segmentNodes - ?.filter((candidate) => candidate.parentNodeId === nodeInfo.nodeId) + ?.filter((candidate) => candidate.parentNodeId === fullNodeInfo.nodeId) .map((candidate) => candidate.nodeId) ?? []; - const nodeHasTrueEnd = completeNodeInfo?.isTrueEnd ?? false; - const nodeType = - completeNodeInfo === undefined - ? undefined - : getSpatialSkeletonDisplayNodeType( - completeNodeInfo, - segmentNodes === undefined ? undefined : directChildNodeIds.length, - ); + const nodeHasTrueEnd = fullNodeInfo.isTrueEnd ?? false; + const nodeType = getSpatialSkeletonDisplayNodeType( + fullNodeInfo, + segmentNodes === undefined ? undefined : directChildNodeIds.length, + ); const nodeTypeLabel = nodeType === undefined ? "Unknown" @@ -2492,16 +2520,13 @@ export class SegmentationUserLayer extends Base { summaryRow.classList.add("neuroglancer-spatial-skeleton-selection-summary"); container.appendChild(summaryRow); - const skeletonSource = - skeletonLayer === undefined - ? undefined - : getEditableSpatiallyIndexedSkeletonSource(skeletonLayer); + const editSource = getEditableSpatiallyIndexedSkeletonSource(skeletonLayer); const rerootDisabledReason = - skeletonSource?.rerootSkeleton === undefined + editSource?.rerootCommand === undefined ? "Unable to resolve a reroot-capable skeleton source for the active layer." - : completeNodeInfo === undefined || segmentNodes === undefined + : segmentNodes === undefined ? "Load the active skeleton in the Skeleton tab before rerooting from Selection." - : completeNodeInfo.parentNodeId === undefined + : fullNodeInfo.parentNodeId === undefined ? "Selected node is already root." : this.getSpatialSkeletonActionsDisabledReason( SpatialSkeletonActions.reroot, @@ -2545,11 +2570,11 @@ export class SegmentationUserLayer extends Base { })(); }); const deleteDisabledReason = - skeletonSource === undefined + editSource === undefined ? "Unable to resolve editable skeleton source for the active layer." - : completeNodeInfo === undefined || segmentNodes === undefined + : segmentNodes === undefined ? "Load the active skeleton in the Skeleton tab before deleting from Selection." - : completeNodeInfo.parentNodeId === undefined && + : fullNodeInfo.parentNodeId === undefined && directChildNodeIds.length > 0 ? "Reroot the skeleton manually before deleting the current root node." : this.getSpatialSkeletonActionsDisabledReason( @@ -2567,7 +2592,7 @@ export class SegmentationUserLayer extends Base { deleteButton.addEventListener("click", () => { if ( deleteButton.disabled || - skeletonSource === undefined || + editSource === undefined || completeNodeInfo === undefined || deletePending ) { @@ -2644,23 +2669,23 @@ export class SegmentationUserLayer extends Base { summaryCoordinates.title = position.fullText; summaryRow.appendChild(summaryCoordinates); - appendSegmentAndNodeIds(segmentId, nodeInfo.nodeId); + appendSegmentAndNodeIds(segmentId, fullNodeInfo.nodeId); const isLeaf = segmentNodes !== undefined && directChildNodeIds.length === 0; const leafTypeEditingDisabledReason = () => - skeletonSource === undefined + editSource === undefined ? "Unable to resolve editable skeleton source for the active layer." : cachedNodeInfo === undefined || segmentNodes === undefined ? "Load the active skeleton in the Skeleton tab before changing leaf type." : this.getSpatialSkeletonActionsDisabledReason( SpatialSkeletonActions.editNodeTrueEnd, ); - if (completeNodeInfo !== undefined && (isLeaf || nodeHasTrueEnd)) { + if (isLeaf || nodeHasTrueEnd) { let committedTrueEnd = nodeHasTrueEnd; let leafTypeSavePending = false; const leafTypeEditor = document.createElement("div"); leafTypeEditor.className = "neuroglancer-spatial-skeleton-leaf-type"; - const leafTypeRadioName = `neuroglancer-spatial-skeleton-leaf-type-${segmentId}-${nodeInfo.nodeId}`; + const leafTypeRadioName = `neuroglancer-spatial-skeleton-leaf-type-${segmentId}-${fullNodeInfo.nodeId}`; const leafTypeOptionElements: HTMLLabelElement[] = []; const makeLeafTypeOption = (options: { label: string; @@ -2748,11 +2773,11 @@ export class SegmentationUserLayer extends Base { void (async () => { try { const currentNode = this.spatialSkeletonState.getCachedNode( - nodeInfo.nodeId, + fullNodeInfo.nodeId, ); if (currentNode === undefined) { throw new Error( - `Node ${nodeInfo.nodeId} is missing from the inspected skeleton cache.`, + `Node ${fullNodeInfo.nodeId} is missing from the inspected skeleton cache.`, ); } await executeSpatialSkeletonNodeTrueEndUpdate(this, { @@ -2786,33 +2811,164 @@ export class SegmentationUserLayer extends Base { } else { appendValue("Node type", nodeTypeLabel); } - if (cachedNodeInfo === undefined || segmentNodes === undefined) { - appendValue( - "Radius", - formatSpatialSkeletonEditableNumber(nodeInfo.radius, "Unavailable"), + const confidenceConfiguration = + editSource?.spatialSkeletonConfidenceConfiguration; + const setPropertyInputValidity = ( + input: HTMLInputElement | HTMLSelectElement, + valid: boolean, + invalidTitle: string, + disabledReason: string | undefined, + ) => { + input.classList.toggle( + "neuroglancer-spatial-skeleton-properties-input-invalid", + !valid, + ); + if (disabledReason !== undefined) { + input.title = disabledReason; + } else if (!valid) { + input.title = invalidTitle; + } else { + input.removeAttribute("title"); + } + }; + const handlePropertyInputKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter") return; + event.preventDefault(); + (event.currentTarget as HTMLElement | null)?.blur(); + }; + const getCachedNodeForPropertyEdit = () => { + const currentNode = this.spatialSkeletonState.getCachedNode( + fullNodeInfo.nodeId, ); + if (currentNode === undefined) { + throw new Error( + `Node ${fullNodeInfo.nodeId} is missing from the inspected skeleton cache.`, + ); + } + return currentNode; + }; + const radiusEditingDisabledReason = () => + editSource === undefined + ? "Unable to resolve editable skeleton source for the active layer." + : cachedNodeInfo === undefined + ? "Load the active skeleton in the Skeleton tab before editing radius." + : this.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.editNodeRadius, + ); + const confidenceEditingDisabledReason = () => + editSource === undefined + ? "Unable to resolve editable skeleton source for the active layer." + : cachedNodeInfo === undefined + ? "Load the active skeleton in the Skeleton tab before editing confidence." + : confidenceConfiguration === undefined + ? "The active skeleton source does not provide confidence value configuration." + : this.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.editNodeConfidence, + ); + + if (radiusEditingDisabledReason() !== undefined) { appendValue( - "Confidence level", - formatSpatialSkeletonEditableNumber(nodeInfo.confidence, "Unavailable"), + "Radius", + formatSpatialSkeletonEditableNumber(fullNodeInfo.radius, "Unavailable"), ); } else { - let committedRadius = nodeInfo.radius ?? 0; - let committedConfidence = - nodeInfo.confidence !== undefined && - Number.isFinite(nodeInfo.confidence) - ? Number(nodeInfo.confidence) - : 0; + let committedRadius = fullNodeInfo.radius ?? 0; const radiusInput = document.createElement("input"); radiusInput.className = "neuroglancer-spatial-skeleton-properties-input"; radiusInput.type = "number"; radiusInput.step = "any"; - radiusInput.value = formatSpatialSkeletonEditableNumber(nodeInfo.radius); + radiusInput.value = formatSpatialSkeletonEditableNumber( + fullNodeInfo.radius, + ); appendValue("Radius", radiusInput); + let radiusSavePending = false; + const getParsedRadius = () => { + const radius = Number(radiusInput.value); + return { + radius, + radiusValid: Number.isFinite(radius), + }; + }; + const updateRadiusEditorState = () => { + const disabledReason = radiusEditingDisabledReason(); + const { radiusValid } = getParsedRadius(); + radiusInput.disabled = + disabledReason !== undefined || radiusSavePending; + setPropertyInputValidity( + radiusInput, + radiusValid, + "Radius must be a finite number.", + disabledReason, + ); + }; + const resetRadiusInput = () => { + radiusInput.value = + formatSpatialSkeletonEditableNumber(committedRadius); + updateRadiusEditorState(); + }; + const commitRadius = () => { + if (radiusSavePending) return; + const disabledReason = radiusEditingDisabledReason(); + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + resetRadiusInput(); + return; + } + const { radius, radiusValid } = getParsedRadius(); + if (!radiusValid) { + StatusMessage.showTemporaryMessage("Radius must be a finite number."); + resetRadiusInput(); + return; + } + if (radius === committedRadius) { + resetRadiusInput(); + return; + } + radiusSavePending = true; + updateRadiusEditorState(); + void (async () => { + try { + await executeSpatialSkeletonNodeRadiusUpdate(this, { + node: getCachedNodeForPropertyEdit(), + nextRadius: radius, + }); + committedRadius = radius; + resetRadiusInput(); + } catch (error) { + showSpatialSkeletonActionError("update node radius", error); + resetRadiusInput(); + } finally { + radiusSavePending = false; + updateRadiusEditorState(); + } + })(); + }; + radiusInput.addEventListener("input", updateRadiusEditorState); + radiusInput.addEventListener("keydown", handlePropertyInputKeyDown); + radiusInput.addEventListener("change", commitRadius); + updateRadiusEditorState(); + } + + const confidenceConfigurationValues = confidenceConfiguration?.values; + if ( + confidenceEditingDisabledReason() !== undefined || + confidenceConfigurationValues === undefined + ) { + appendValue( + "Confidence level", + formatSpatialSkeletonEditableNumber( + fullNodeInfo.confidence, + "Unavailable", + ), + ); + } else { + let committedConfidence = + fullNodeInfo.confidence !== undefined && + Number.isFinite(fullNodeInfo.confidence) + ? Number(fullNodeInfo.confidence) + : 0; const supportedConfidenceValues = Array.from( - new Set([ - ...SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES, - committedConfidence, - ]), + new Set([...confidenceConfigurationValues, committedConfidence]), ).filter((value): value is number => Number.isFinite(value)); const confidenceSelectValues = Array.from( new Set([...supportedConfidenceValues, committedConfidence]), @@ -2828,38 +2984,7 @@ export class SegmentationUserLayer extends Base { } confidenceControl.value = committedConfidence.toString(); appendValue("Confidence level", confidenceControl); - let savePending = false; - const getPropertyEditingDisabledReason = () => - skeletonSource === undefined - ? "Unable to resolve editable skeleton source for the active layer." - : this.getSpatialSkeletonActionsDisabledReason( - SpatialSkeletonActions.editNodeProperties, - ); - const getConfidenceEditingDisabledReason = () => { - const disabledReason = getPropertyEditingDisabledReason(); - if (disabledReason !== undefined) { - return disabledReason; - } - return undefined; - }; - const setPropertyInputValidity = ( - input: HTMLInputElement | HTMLSelectElement, - valid: boolean, - invalidTitle: string, - disabledReason: string | undefined, - ) => { - input.classList.toggle( - "neuroglancer-spatial-skeleton-properties-input-invalid", - !valid, - ); - if (disabledReason !== undefined) { - input.title = disabledReason; - } else if (!valid) { - input.title = invalidTitle; - } else { - input.removeAttribute("title"); - } - }; + let confidenceSavePending = false; const getConfidenceValidationError = (confidence: number) => { if (!Number.isFinite(confidence)) { return "Confidence must be a finite number."; @@ -2868,34 +2993,21 @@ export class SegmentationUserLayer extends Base { ? undefined : "Confidence must use one of the supported values."; }; - const getParsedProperties = () => { - const radius = Number(radiusInput.value); + const getParsedConfidence = () => { const confidence = Number(confidenceControl.value); - const radiusValid = Number.isFinite(radius); const confidenceInvalidTitle = getConfidenceValidationError(confidence); return { - radius, confidence, - radiusValid, confidenceValid: confidenceInvalidTitle === undefined, confidenceInvalidTitle, }; }; - const updatePropertyEditorState = () => { - const radiusDisabledReason = getPropertyEditingDisabledReason(); - const confidenceDisabledReason = getConfidenceEditingDisabledReason(); - const { radiusValid, confidenceValid, confidenceInvalidTitle } = - getParsedProperties(); - radiusInput.disabled = - radiusDisabledReason !== undefined || savePending; + const updateConfidenceEditorState = () => { + const confidenceDisabledReason = confidenceEditingDisabledReason(); + const { confidenceValid, confidenceInvalidTitle } = + getParsedConfidence(); confidenceControl.disabled = - confidenceDisabledReason !== undefined || savePending; - setPropertyInputValidity( - radiusInput, - radiusValid, - "Radius must be a finite number.", - radiusDisabledReason, - ); + confidenceDisabledReason !== undefined || confidenceSavePending; setPropertyInputValidity( confidenceControl, confidenceValid, @@ -2903,83 +3015,58 @@ export class SegmentationUserLayer extends Base { confidenceDisabledReason, ); }; - const resetPropertyInputs = () => { - radiusInput.value = - formatSpatialSkeletonEditableNumber(committedRadius); + const resetConfidenceInput = () => { confidenceControl.value = committedConfidence.toString(); - updatePropertyEditorState(); + updateConfidenceEditorState(); }; - const handlePropertyInputKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Enter") return; - event.preventDefault(); - (event.currentTarget as HTMLElement | null)?.blur(); - }; - const commitProperties = () => { - if (savePending) return; - const disabledReason = getPropertyEditingDisabledReason(); + const commitConfidence = () => { + if (confidenceSavePending) return; + const disabledReason = confidenceEditingDisabledReason(); if (disabledReason !== undefined) { StatusMessage.showTemporaryMessage(disabledReason); - resetPropertyInputs(); + resetConfidenceInput(); return; } - const { - radius, - confidence, - radiusValid, - confidenceValid, - confidenceInvalidTitle, - } = getParsedProperties(); - if (!radiusValid || !confidenceValid) { + const { confidence, confidenceValid, confidenceInvalidTitle } = + getParsedConfidence(); + if (!confidenceValid) { StatusMessage.showTemporaryMessage( - confidenceInvalidTitle ?? "Enter a valid radius and confidence.", + confidenceInvalidTitle ?? "Confidence is invalid.", ); - resetPropertyInputs(); + resetConfidenceInput(); return; } - const radiusChanged = radius !== committedRadius; const confidenceChanged = confidence !== committedConfidence; - if (!radiusChanged && !confidenceChanged) { - resetPropertyInputs(); + if (!confidenceChanged) { + resetConfidenceInput(); return; } - savePending = true; - updatePropertyEditorState(); + confidenceSavePending = true; + updateConfidenceEditorState(); void (async () => { try { - const currentNode = this.spatialSkeletonState.getCachedNode( - nodeInfo.nodeId, - ); - if (currentNode === undefined) { - throw new Error( - `Node ${nodeInfo.nodeId} is missing from the inspected skeleton cache.`, - ); - } - await executeSpatialSkeletonNodePropertiesUpdate(this, { - node: currentNode, - next: { radius, confidence }, + await executeSpatialSkeletonNodeConfidenceUpdate(this, { + node: getCachedNodeForPropertyEdit(), + nextConfidence: confidence, }); - committedRadius = radius; committedConfidence = confidence; - resetPropertyInputs(); + resetConfidenceInput(); } catch (error) { - showSpatialSkeletonActionError("update node properties", error); - resetPropertyInputs(); + showSpatialSkeletonActionError("update node confidence", error); + resetConfidenceInput(); } finally { - savePending = false; - updatePropertyEditorState(); + confidenceSavePending = false; + updateConfidenceEditorState(); } })(); }; - radiusInput.addEventListener("input", updatePropertyEditorState); - radiusInput.addEventListener("keydown", handlePropertyInputKeyDown); - radiusInput.addEventListener("change", commitProperties); - confidenceControl.addEventListener("change", commitProperties); - updatePropertyEditorState(); + confidenceControl.addEventListener("change", commitConfidence); + updateConfidenceEditorState(); } const descriptionText = cachedNodeInfo?.description ?? completeNodeInfo?.description ?? ""; const descriptionEditingDisabledReason = - skeletonSource === undefined + editSource === undefined ? "Unable to resolve editable skeleton source for the active layer." : cachedNodeInfo === undefined ? "Load the active skeleton in the Skeleton tab before editing description." @@ -2995,7 +3082,7 @@ export class SegmentationUserLayer extends Base { descriptionElement.placeholder = "Description"; descriptionElement.value = descriptionText; descriptionElement.addEventListener("change", () => { - if (skeletonSource === undefined || cachedNodeInfo === undefined) { + if (editSource === undefined || cachedNodeInfo === undefined) { return; } const nextDescription = descriptionElement.value; @@ -3007,11 +3094,11 @@ export class SegmentationUserLayer extends Base { void (async () => { try { const currentNode = this.spatialSkeletonState.getCachedNode( - nodeInfo.nodeId, + fullNodeInfo.nodeId, ); if (currentNode === undefined) { throw new Error( - `Node ${nodeInfo.nodeId} is missing from the inspected skeleton cache.`, + `Node ${fullNodeInfo.nodeId} is missing from the inspected skeleton cache.`, ); } await executeSpatialSkeletonNodeDescriptionUpdate(this, { @@ -3069,7 +3156,7 @@ export class SegmentationUserLayer extends Base { const { rank, globalToRenderLayerDimensions } = transform; const { globalPosition } = this.manager.root; const globalLayerPosition = new Float32Array(rank); - const renderToGlobalLayerDimensions = []; + const renderToGlobalLayerDimensions: number[] = []; for (let i = 0; i < rank; i++) { renderToGlobalLayerDimensions[globalToRenderLayerDimensions[i]] = i; } diff --git a/src/layer/segmentation/layer_controls.ts b/src/layer/segmentation/layer_controls.ts index 228825364..29d500c72 100644 --- a/src/layer/segmentation/layer_controls.ts +++ b/src/layer/segmentation/layer_controls.ts @@ -85,10 +85,13 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ ), title: "Select the grid size level for spatially indexed skeletons in 2D views", - ...renderScaleLayerControl((layer) => ({ - histogram: layer.displayState.spatialSkeletonGridRenderScaleHistogram2d, - target: layer.displayState.spatialSkeletonGridResolutionTarget2d, - }), SpatialSkeletonGridRenderScaleWidget), + ...renderScaleLayerControl( + (layer) => ({ + histogram: layer.displayState.spatialSkeletonGridRenderScaleHistogram2d, + target: layer.displayState.spatialSkeletonGridResolutionTarget2d, + }), + SpatialSkeletonGridRenderScaleWidget, + ), }, { label: "Resolution (skeleton grid 3D)", @@ -104,10 +107,13 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ ), title: "Select the grid size level for spatially indexed skeletons in 3D views", - ...renderScaleLayerControl((layer) => ({ - histogram: layer.displayState.spatialSkeletonGridRenderScaleHistogram3d, - target: layer.displayState.spatialSkeletonGridResolutionTarget3d, - }), SpatialSkeletonGridRenderScaleWidget), + ...renderScaleLayerControl( + (layer) => ({ + histogram: layer.displayState.spatialSkeletonGridRenderScaleHistogram3d, + target: layer.displayState.spatialSkeletonGridResolutionTarget3d, + }), + SpatialSkeletonGridRenderScaleWidget, + ), }, { label: "Opacity (3d)", diff --git a/src/layer/segmentation/spatial_skeleton_commands.spec.ts b/src/layer/segmentation/spatial_skeleton_commands.spec.ts index 97e55c481..ef77a4428 100644 --- a/src/layer/segmentation/spatial_skeleton_commands.spec.ts +++ b/src/layer/segmentation/spatial_skeleton_commands.spec.ts @@ -1,20 +1,27 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { CatmaidSpatialSkeletonEditCommands } from "#src/datasource/catmaid/spatial_skeleton_commands.js"; +import { buildCatmaidNeighborhoodEditContext } from "#src/datasource/catmaid/edit_state.js"; +import { makeCatmaidNodeSourceState } from "#src/datasource/catmaid/api.js"; import { executeSpatialSkeletonAddNode, executeSpatialSkeletonDeleteNode, executeSpatialSkeletonMerge, executeSpatialSkeletonMoveNode, + executeSpatialSkeletonNodeConfidenceUpdate, + executeSpatialSkeletonNodeDescriptionUpdate, + executeSpatialSkeletonNodeRadiusUpdate, executeSpatialSkeletonSplit, + redoSpatialSkeletonCommand, undoSpatialSkeletonCommand, } from "#src/layer/segmentation/spatial_skeleton_commands.js"; import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; import { - buildSpatiallyIndexedSkeletonNeighborhoodEditContext, findSpatiallyIndexedSkeletonNode, getSpatiallyIndexedSkeletonDirectChildren, getSpatiallyIndexedSkeletonNodeParent, } from "#src/skeleton/edit_state.js"; +import { SpatialSkeletonActions } from "#src/skeleton/actions.js"; import { SpatialSkeletonCommandHistory } from "#src/skeleton/command_history.js"; import { SpatialSkeletonState } from "#src/skeleton/spatial_skeleton_manager.js"; import { StatusMessage } from "#src/status.js"; @@ -55,21 +62,29 @@ function setSegmentNodes( } } -function makeEditableSkeletonSource(overrides: Record = {}) { +const catmaidEditClientMethodNames = new Set([ + "addNode", + "insertNode", + "moveNode", + "deleteNode", + "rerootSkeleton", + "updateDescription", + "toggleTrueEnd", + "updateRadius", + "updateConfidence", + "mergeSkeletons", + "splitSkeleton", +]); + +function makeCatmaidClient(overrides: Record = {}) { return { - listSkeletons: vi.fn(), - getSkeleton: vi.fn(), - fetchNodes: vi.fn(), - getSpatialIndexMetadata: vi.fn(), - getSkeletonRootNode: vi.fn(), addNode: vi.fn(), insertNode: vi.fn(), moveNode: vi.fn(), deleteNode: vi.fn(), rerootSkeleton: vi.fn(), updateDescription: vi.fn(), - setTrueEnd: vi.fn(), - removeTrueEnd: vi.fn(), + toggleTrueEnd: vi.fn(), updateRadius: vi.fn(), updateConfidence: vi.fn(), mergeSkeletons: vi.fn(), @@ -78,6 +93,49 @@ function makeEditableSkeletonSource(overrides: Record = {}) { }; } +function makeCatmaidEditCommands(client = makeCatmaidClient()) { + return new CatmaidSpatialSkeletonEditCommands({ + getClient: () => client as any, + }); +} + +function makeEditableSkeletonSource(overrides: Record = {}) { + const clientOverrides: Record = {}; + const sourceOverrides: Record = {}; + for (const [key, value] of Object.entries(overrides)) { + if (catmaidEditClientMethodNames.has(key)) { + clientOverrides[key] = value; + } else { + sourceOverrides[key] = value; + } + } + const commands = makeCatmaidEditCommands(makeCatmaidClient(clientOverrides)); + return { + readOnly: false, + addNodesCommand: commands.addNodesCommand, + insertNodesCommand: commands.insertNodesCommand, + moveNodesCommand: commands.moveNodesCommand, + deleteNodesCommand: commands.deleteNodesCommand, + rerootCommand: commands.rerootCommand, + editNodeDescriptionCommand: commands.editNodeDescriptionCommand, + editNodeTrueEndCommand: commands.editNodeTrueEndCommand, + editNodeRadiusCommand: commands.editNodeRadiusCommand, + editNodeConfidenceCommand: commands.editNodeConfidenceCommand, + mergeSkeletonsCommand: commands.mergeSkeletonsCommand, + splitSkeletonsCommand: commands.splitSkeletonsCommand, + listSkeletons: vi.fn(), + getSkeleton: vi.fn(), + fetchNodes: vi.fn(), + getSpatialIndexMetadata: vi.fn(), + getSkeletonRootNode: vi.fn(), + ...sourceOverrides, + }; +} + +function testSourceState(revisionToken: string) { + return makeCatmaidNodeSourceState(revisionToken); +} + function suppressStatusMessages() { const fakeStatusMessage = { dispose() {}, @@ -95,6 +153,264 @@ describe("spatial_skeleton_commands", () => { vi.restoreAllMocks(); }); + it("executes opaque source-created commands through a valid edit source", async () => { + const execute = vi.fn(); + const undo = vi.fn(); + const redo = vi.fn(); + const command = { + label: "Backend-owned move", + execute, + undo, + redo, + }; + const createCommand = vi.fn(() => command); + const layer = { + spatialSkeletonState: { + commandHistory: new SpatialSkeletonCommandHistory(), + }, + getSpatiallyIndexedSkeletonLayer: () => ({ + source: { + ...makeEditableSkeletonSource({ + moveNodesCommand: { + action: SpatialSkeletonActions.moveNodes, + createCommand, + }, + }), + }, + }), + }; + const node: SpatiallyIndexedSkeletonNode = { + nodeId: 17, + segmentId: 23, + position: new Float32Array([1, 2, 3]), + }; + const nextPositionInModelSpace = new Float32Array([7, 8, 9]); + + await executeSpatialSkeletonMoveNode(layer as any, { + node, + nextPositionInModelSpace, + }); + await undoSpatialSkeletonCommand(layer as any); + await redoSpatialSkeletonCommand(layer as any); + + expect(createCommand).toHaveBeenCalledWith(layer, { + node, + nextPositionInModelSpace, + }); + expect(execute).toHaveBeenCalledTimes(1); + expect(undo).toHaveBeenCalledTimes(1); + expect(redo).toHaveBeenCalledTimes(1); + }); + + it("does not treat a source with an invalid command factory as editable", () => { + const layer = { + spatialSkeletonState: { + commandHistory: new SpatialSkeletonCommandHistory(), + }, + getSpatiallyIndexedSkeletonLayer: () => ({ + source: { + ...makeEditableSkeletonSource(), + readOnly: false, + editNodeDescriptionCommand: { + action: SpatialSkeletonActions.editNodeDescription, + }, + }, + }), + }; + + expect(() => + executeSpatialSkeletonNodeDescriptionUpdate(layer as any, { + node: { + nodeId: 17, + segmentId: 23, + position: new Float32Array([1, 2, 3]), + }, + nextDescription: "next", + }), + ).toThrow( + "Unable to resolve editable skeleton source for the active layer.", + ); + }); + + it("reports unsupported commands clearly", () => { + const layer = { + spatialSkeletonState: { + commandHistory: new SpatialSkeletonCommandHistory(), + }, + getSpatiallyIndexedSkeletonLayer: () => ({ + source: { + ...makeEditableSkeletonSource({ + editNodeDescriptionCommand: undefined, + }), + readOnly: false, + }, + }), + }; + const node: SpatiallyIndexedSkeletonNode = { + nodeId: 17, + segmentId: 23, + position: new Float32Array([1, 2, 3]), + }; + + expect(() => + executeSpatialSkeletonNodeDescriptionUpdate(layer as any, { + node, + nextDescription: "next", + }), + ).toThrow( + "The active skeleton source does not support node description editing.", + ); + }); + + it("exposes CATMAID command factories for supported edit actions", () => { + const commandSource = makeCatmaidEditCommands(); + + expect(commandSource.moveNodesCommand.action).toBe( + SpatialSkeletonActions.moveNodes, + ); + expect(commandSource.editNodeRadiusCommand.action).toBe( + SpatialSkeletonActions.editNodeRadius, + ); + expect(commandSource.editNodeConfidenceCommand.action).toBe( + SpatialSkeletonActions.editNodeConfidence, + ); + expect((commandSource as any).inspectCommand).toBeUndefined(); + }); + + it("creates CATMAID commands from valid opaque payloads", () => { + const commandSource = makeCatmaidEditCommands(); + const layer = { + spatialSkeletonState: { + commandHistory: new SpatialSkeletonCommandHistory(), + }, + }; + const node: SpatiallyIndexedSkeletonNode = { + nodeId: 17, + segmentId: 23, + position: new Float32Array([1, 2, 3]), + }; + + const command = commandSource.moveNodesCommand.createCommand(layer as any, { + node, + nextPositionInModelSpace: new Float32Array([7, 8, 9]), + }); + + expect(command?.label).toBe("Move node"); + }); + + it("reports invalid CATMAID command payloads clearly", () => { + const commandSource = makeCatmaidEditCommands(); + const layer = { + spatialSkeletonState: { + commandHistory: new SpatialSkeletonCommandHistory(), + }, + }; + + expect(() => + commandSource.moveNodesCommand.createCommand(layer as any, { + node: {}, + nextPositionInModelSpace: new Float32Array([7, 8, 9]), + }), + ).toThrow("CATMAID move-node command received an invalid payload."); + }); + + it("commits radius and confidence commands independently", async () => { + suppressStatusMessages(); + + const node: SpatiallyIndexedSkeletonNode = { + nodeId: 17, + segmentId: 23, + position: new Float32Array([1, 2, 3]), + radius: 4, + confidence: 50, + sourceState: testSourceState("before"), + }; + let cachedNode = node; + const updateRadius = vi.fn().mockResolvedValue({ + sourceState: testSourceState("after-radius"), + }); + const updateConfidence = vi.fn().mockResolvedValue({ + sourceState: testSourceState("after-confidence"), + }); + const skeletonLayer = { + source: makeEditableSkeletonSource({ updateRadius, updateConfidence }), + getNode: vi.fn((nodeId: number) => + nodeId === cachedNode.nodeId ? cachedNode : undefined, + ), + invalidateSourceCaches: vi.fn(), + }; + const commandHistory = new SpatialSkeletonCommandHistory(); + const setNodeRadius = vi.fn((nodeId: number, radius: number) => { + if (nodeId === cachedNode.nodeId) { + cachedNode = { ...cachedNode, radius }; + } + }); + const setNodeConfidence = vi.fn((nodeId: number, confidence: number) => { + if (nodeId === cachedNode.nodeId) { + cachedNode = { ...cachedNode, confidence }; + } + }); + const setCachedNodeSourceState = vi.fn( + (nodeId: number, sourceState: unknown) => { + if (nodeId === cachedNode.nodeId) { + cachedNode = { ...cachedNode, sourceState: sourceState as any }; + } + }, + ); + const markSpatialSkeletonNodeDataChanged = vi.fn(); + const layer = { + spatialSkeletonState: { + commandHistory, + getCachedNode: vi.fn((nodeId: number) => + nodeId === cachedNode.nodeId ? cachedNode : undefined, + ), + getCachedSegmentNodes: vi.fn((segmentId: number) => + segmentId === cachedNode.segmentId ? [cachedNode] : undefined, + ), + setNodeRadius, + setNodeConfidence, + setCachedNodeSourceState, + }, + getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, + markSpatialSkeletonNodeDataChanged, + }; + + await executeSpatialSkeletonNodeRadiusUpdate(layer as any, { + node: cachedNode, + nextRadius: 6, + }); + await executeSpatialSkeletonNodeConfidenceUpdate(layer as any, { + node: cachedNode, + nextConfidence: 75, + }); + + expect(updateRadius).toHaveBeenCalledWith( + 17, + 6, + expect.objectContaining({ + node: expect.objectContaining({ nodeId: 17 }), + }), + ); + expect(updateConfidence).toHaveBeenCalledWith( + 17, + 75, + expect.objectContaining({ + node: expect.objectContaining({ nodeId: 17 }), + }), + ); + expect(setNodeRadius).toHaveBeenCalledWith(17, 6); + expect(setNodeConfidence).toHaveBeenCalledWith(17, 75); + expect(setCachedNodeSourceState).toHaveBeenCalledWith( + 17, + testSourceState("after-radius"), + ); + expect(setCachedNodeSourceState).toHaveBeenCalledWith( + 17, + testSourceState("after-confidence"), + ); + expect(markSpatialSkeletonNodeDataChanged).toHaveBeenCalledTimes(2); + }); + it("commits move-node commands using model-space positions", async () => { suppressStatusMessages(); @@ -103,11 +419,11 @@ describe("spatial_skeleton_commands", () => { segmentId: 23, position: new Float32Array([1, 2, 3]), isTrueEnd: false, - revisionToken: "before", + sourceState: testSourceState("before"), }; const nextPositionInModelSpace = new Float32Array([7, 8, 9]); const moveNode = vi.fn().mockResolvedValue({ - revisionToken: "after", + sourceState: testSourceState("after"), }); const skeletonLayer = { source: makeEditableSkeletonSource({ moveNode }), @@ -119,7 +435,7 @@ describe("spatial_skeleton_commands", () => { }; const commandHistory = new SpatialSkeletonCommandHistory(); const moveCachedNode = vi.fn(); - const setCachedNodeRevision = vi.fn(); + const setCachedNodeSourceState = vi.fn(); const markSpatialSkeletonNodeDataChanged = vi.fn(); const layer = { spatialSkeletonState: { @@ -131,7 +447,7 @@ describe("spatial_skeleton_commands", () => { segmentId === node.segmentId ? [node] : undefined, ), moveCachedNode, - setCachedNodeRevision, + setCachedNodeSourceState, }, getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, markSpatialSkeletonNodeDataChanged, @@ -142,25 +458,109 @@ describe("spatial_skeleton_commands", () => { nextPositionInModelSpace, }); - expect(moveNode).toHaveBeenCalledWith(17, 7, 8, 9, { - node: { - nodeId: 17, - parentNodeId: undefined, - revisionToken: "before", - }, - }); + expect(moveNode).toHaveBeenCalledWith( + 17, + 7, + 8, + 9, + expect.objectContaining({ + node: expect.objectContaining({ nodeId: 17 }), + }), + ); expect(skeletonLayer.retainOverlaySegment).toHaveBeenCalledWith(23); expect(moveCachedNode).toHaveBeenCalledWith( 17, new Float32Array([7, 8, 9]), ); - expect(setCachedNodeRevision).toHaveBeenCalledWith(17, "after"); + expect(setCachedNodeSourceState).toHaveBeenCalledWith( + 17, + testSourceState("after"), + ); expect(markSpatialSkeletonNodeDataChanged).toHaveBeenCalledWith({ invalidateFullSkeletonCache: false, }); expect(skeletonLayer.invalidateSourceCaches).not.toHaveBeenCalled(); }); + it("preserves CATMAID true-end labels when editing node descriptions", async () => { + suppressStatusMessages(); + + let cachedNode: SpatiallyIndexedSkeletonNode = { + nodeId: 17, + segmentId: 23, + position: new Float32Array([1, 2, 3]), + description: "before", + isTrueEnd: true, + sourceState: testSourceState("before"), + }; + const updateDescription = vi.fn().mockResolvedValue({ + description: "after", + sourceState: testSourceState("after"), + }); + const toggleTrueEnd = vi.fn(); + const skeletonLayer = { + source: makeEditableSkeletonSource({ updateDescription, toggleTrueEnd }), + getNode: vi.fn((nodeId: number) => + nodeId === cachedNode.nodeId ? cachedNode : undefined, + ), + invalidateSourceCaches: vi.fn(), + }; + const commandHistory = new SpatialSkeletonCommandHistory(); + const updateCachedNode = vi.fn( + ( + nodeId: number, + updater: ( + candidate: SpatiallyIndexedSkeletonNode, + ) => SpatiallyIndexedSkeletonNode, + ) => { + if (nodeId === cachedNode.nodeId) { + cachedNode = updater(cachedNode); + } + }, + ); + const setCachedNodeSourceState = vi.fn( + (nodeId: number, sourceState: unknown) => { + if (nodeId === cachedNode.nodeId) { + cachedNode = { ...cachedNode, sourceState: sourceState as any }; + } + }, + ); + const markSpatialSkeletonNodeDataChanged = vi.fn(); + const layer = { + spatialSkeletonState: { + commandHistory, + getCachedNode: vi.fn((nodeId: number) => + nodeId === cachedNode.nodeId ? cachedNode : undefined, + ), + getCachedSegmentNodes: vi.fn((segmentId: number) => + segmentId === cachedNode.segmentId ? [cachedNode] : undefined, + ), + updateCachedNode, + setCachedNodeSourceState, + }, + getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, + markSpatialSkeletonNodeDataChanged, + }; + + await executeSpatialSkeletonNodeDescriptionUpdate(layer as any, { + node: cachedNode, + nextDescription: "after", + }); + + expect(updateDescription).toHaveBeenCalledWith(17, "after", { + isTrueEnd: true, + }); + expect(toggleTrueEnd).not.toHaveBeenCalled(); + expect(cachedNode).toMatchObject({ + description: "after", + isTrueEnd: true, + sourceState: testSourceState("after"), + }); + expect(markSpatialSkeletonNodeDataChanged).toHaveBeenCalledWith({ + invalidateFullSkeletonCache: false, + }); + }); + it("moves to the parent node when undoing an add-node command", async () => { suppressStatusMessages(); @@ -170,19 +570,19 @@ describe("spatial_skeleton_commands", () => { segmentId, position: new Float32Array([4, 5, 6]), isTrueEnd: false, - revisionToken: "parent-before-add", + sourceState: testSourceState("parent-before-add"), }; const addNode = vi.fn().mockResolvedValue({ - treenodeId: 2, - skeletonId: segmentId, - revisionToken: "added-after-add", - parentRevisionToken: "parent-after-add", + nodeId: 2, + segmentId, + sourceState: testSourceState("added-after-add"), + parentSourceState: testSourceState("parent-after-add"), }); const deleteNode = vi.fn().mockResolvedValue({ - nodeRevisionUpdates: [ + nodeSourceStateUpdates: [ { nodeId: parentNode.nodeId, - revisionToken: "parent-after-undo", + sourceState: testSourceState("parent-after-undo"), }, ], }); @@ -250,10 +650,6 @@ describe("spatial_skeleton_commands", () => { currentNode, ), childNodes, - editContext: buildSpatiallyIndexedSkeletonNeighborhoodEditContext( - currentNode, - segmentNodes, - ), }; }, selectSegment: vi.fn(), @@ -278,25 +674,16 @@ describe("spatial_skeleton_commands", () => { expect(deleteNode).toHaveBeenCalledWith(2, { childNodeIds: [], - editContext: { - node: { - nodeId: 2, - parentNodeId: parentNode.nodeId, - revisionToken: "added-after-add", - }, - parent: { - nodeId: parentNode.nodeId, - parentNodeId: undefined, - revisionToken: "parent-after-add", - }, - children: [], - }, + editContext: expect.objectContaining({ + node: expect.objectContaining({ nodeId: 2 }), + parent: expect.objectContaining({ nodeId: parentNode.nodeId }), + }), }); expect(spatialSkeletonState.getCachedNode(2)).toBeUndefined(); expect(layer.selectAndMoveToSpatialSkeletonNode).toHaveBeenCalledWith( { ...parentNode, - revisionToken: "parent-after-add", + sourceState: testSourceState("parent-after-add"), }, true, ); @@ -313,7 +700,7 @@ describe("spatial_skeleton_commands", () => { segmentId, position: new Float32Array([1, 2, 3]), isTrueEnd: false, - revisionToken: "root-before-delete", + sourceState: testSourceState("root-before-delete"), }; const deletedNode: SpatiallyIndexedSkeletonNode = { nodeId: 2, @@ -321,7 +708,7 @@ describe("spatial_skeleton_commands", () => { parentNodeId: rootNode.nodeId, position: new Float32Array([4, 5, 6]), isTrueEnd: false, - revisionToken: "deleted-before-delete", + sourceState: testSourceState("deleted-before-delete"), }; const firstChildNode: SpatiallyIndexedSkeletonNode = { nodeId: 3, @@ -329,7 +716,7 @@ describe("spatial_skeleton_commands", () => { parentNodeId: deletedNode.nodeId, position: new Float32Array([7, 8, 9]), isTrueEnd: false, - revisionToken: "first-child-before-delete", + sourceState: testSourceState("first-child-before-delete"), }; const secondChildNode: SpatiallyIndexedSkeletonNode = { nodeId: 4, @@ -337,42 +724,44 @@ describe("spatial_skeleton_commands", () => { parentNodeId: deletedNode.nodeId, position: new Float32Array([10, 11, 12]), isTrueEnd: false, - revisionToken: "second-child-before-delete", + sourceState: testSourceState("second-child-before-delete"), }; const deleteNode = vi.fn().mockResolvedValue({ - nodeRevisionUpdates: [ + nodeSourceStateUpdates: [ { nodeId: rootNode.nodeId, - revisionToken: "root-after-delete", + sourceState: testSourceState("root-after-delete"), }, { nodeId: firstChildNode.nodeId, - revisionToken: "first-child-after-delete", + sourceState: testSourceState("first-child-after-delete"), }, { nodeId: secondChildNode.nodeId, - revisionToken: "second-child-after-delete", + sourceState: testSourceState("second-child-after-delete"), }, ], }); + const addNode = vi.fn(); const insertNode = vi.fn().mockResolvedValue({ - treenodeId: 20, - skeletonId: segmentId, - revisionToken: "restored-after-undo", - parentRevisionToken: "root-after-undo", - nodeRevisionUpdates: [ + nodeId: 20, + segmentId, + sourceState: testSourceState("restored-after-undo"), + parentSourceState: testSourceState("root-after-undo"), + nodeSourceStateUpdates: [ { nodeId: firstChildNode.nodeId, - revisionToken: "first-child-after-undo", + sourceState: testSourceState("first-child-after-undo"), }, { nodeId: secondChildNode.nodeId, - revisionToken: "second-child-after-undo", + sourceState: testSourceState("second-child-after-undo"), }, ], }); const skeletonSource = makeEditableSkeletonSource({ + addNode, deleteNode, insertNode, }); @@ -418,8 +807,9 @@ describe("spatial_skeleton_commands", () => { }, spatialSkeletonState, getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, - getCachedSpatialSkeletonSegmentNodesForEdit: (requestedSegmentId: number) => - spatialSkeletonState.getCachedSegmentNodes(requestedSegmentId) ?? [], + getCachedSpatialSkeletonSegmentNodesForEdit: ( + requestedSegmentId: number, + ) => spatialSkeletonState.getCachedSegmentNodes(requestedSegmentId) ?? [], async getSpatialSkeletonDeleteOperationContext( node: SpatiallyIndexedSkeletonNode, ) { @@ -443,7 +833,7 @@ describe("spatial_skeleton_commands", () => { currentNode, ), childNodes, - editContext: buildSpatiallyIndexedSkeletonNeighborhoodEditContext( + editContext: buildCatmaidNeighborhoodEditContext( currentNode, segmentNodes, ), @@ -457,17 +847,19 @@ describe("spatial_skeleton_commands", () => { await executeSpatialSkeletonDeleteNode(layer as any, deletedNode); - expect(spatialSkeletonState.getCachedNode(deletedNode.nodeId)).toBeUndefined(); - expect(spatialSkeletonState.getCachedNode(firstChildNode.nodeId)?.parentNodeId).toBe( - rootNode.nodeId, - ); + expect( + spatialSkeletonState.getCachedNode(deletedNode.nodeId), + ).toBeUndefined(); + expect( + spatialSkeletonState.getCachedNode(firstChildNode.nodeId)?.parentNodeId, + ).toBe(rootNode.nodeId); expect( spatialSkeletonState.getCachedNode(secondChildNode.nodeId)?.parentNodeId, ).toBe(rootNode.nodeId); await undoSpatialSkeletonCommand(layer as any); - expect(skeletonSource.addNode).not.toHaveBeenCalled(); + expect(addNode).not.toHaveBeenCalled(); expect(insertNode).toHaveBeenCalledWith( segmentId, 4, @@ -475,23 +867,13 @@ describe("spatial_skeleton_commands", () => { 6, rootNode.nodeId, [firstChildNode.nodeId, secondChildNode.nodeId], - { - node: { - nodeId: rootNode.nodeId, - parentNodeId: undefined, - revisionToken: "root-after-delete", - }, - children: [ - { - nodeId: firstChildNode.nodeId, - revisionToken: "first-child-after-delete", - }, - { - nodeId: secondChildNode.nodeId, - revisionToken: "second-child-after-delete", - }, - ], - }, + expect.objectContaining({ + node: expect.objectContaining({ nodeId: rootNode.nodeId }), + children: expect.arrayContaining([ + expect.objectContaining({ nodeId: firstChildNode.nodeId }), + expect.objectContaining({ nodeId: secondChildNode.nodeId }), + ]), + }), ); const restoredNode = spatialSkeletonState.getCachedNode(20); @@ -500,13 +882,13 @@ describe("spatial_skeleton_commands", () => { parentNodeId: rootNode.nodeId, segmentId, }); - expect(spatialSkeletonState.getCachedNode(firstChildNode.nodeId)?.parentNodeId).toBe( - restoredNode?.nodeId, - ); + expect( + spatialSkeletonState.getCachedNode(firstChildNode.nodeId)?.parentNodeId, + ).toBe(restoredNode?.nodeId); expect( spatialSkeletonState.getCachedNode(secondChildNode.nodeId)?.parentNodeId, ).toBe(restoredNode?.nodeId); - const restoredEditContext = buildSpatiallyIndexedSkeletonNeighborhoodEditContext( + const restoredEditContext = buildCatmaidNeighborhoodEditContext( restoredNode!, spatialSkeletonState.getCachedSegmentNodes(segmentId)!, ); @@ -526,7 +908,7 @@ describe("spatial_skeleton_commands", () => { segmentId: originalSegmentId, position: new Float32Array([10, 20, 30]), isTrueEnd: false, - revisionToken: "parent-before", + sourceState: testSourceState("parent-before"), }; const splitNodeBefore: SpatiallyIndexedSkeletonNode = { nodeId: 21893038, @@ -534,17 +916,17 @@ describe("spatial_skeleton_commands", () => { parentNodeId: formerParentNode.nodeId, position: new Float32Array([11, 21, 31]), isTrueEnd: false, - revisionToken: "split-before", + sourceState: testSourceState("split-before"), }; const splitNodeAfter: SpatiallyIndexedSkeletonNode = { ...splitNodeBefore, segmentId: splitSegmentId, parentNodeId: undefined, - revisionToken: "split-after", + sourceState: testSourceState("split-after"), }; const splitNodeMergedBack: SpatiallyIndexedSkeletonNode = { ...splitNodeBefore, - revisionToken: "split-merged-back", + sourceState: testSourceState("split-merged-back"), }; const serverSegments = new Map(); @@ -567,27 +949,29 @@ describe("spatial_skeleton_commands", () => { ]); syncCacheFromServer(originalSegmentId); + const splitSkeleton = vi.fn(async () => { + serverSegments.set(originalSegmentId, [cloneNode(formerParentNode)]); + serverSegments.set(splitSegmentId, [cloneNode(splitNodeAfter)]); + return { + existingSegmentId: originalSegmentId, + newSegmentId: splitSegmentId, + }; + }); + const mergeSkeletons = vi.fn(async () => { + serverSegments.set(originalSegmentId, [ + cloneNode(formerParentNode), + cloneNode(splitNodeMergedBack), + ]); + serverSegments.delete(splitSegmentId); + return { + resultSegmentId: originalSegmentId, + deletedSegmentId: splitSegmentId, + directionAdjusted: false, + }; + }); const skeletonSource = makeEditableSkeletonSource({ - splitSkeleton: vi.fn(async () => { - serverSegments.set(originalSegmentId, [cloneNode(formerParentNode)]); - serverSegments.set(splitSegmentId, [cloneNode(splitNodeAfter)]); - return { - existingSkeletonId: originalSegmentId, - newSkeletonId: splitSegmentId, - }; - }), - mergeSkeletons: vi.fn(async () => { - serverSegments.set(originalSegmentId, [ - cloneNode(formerParentNode), - cloneNode(splitNodeMergedBack), - ]); - serverSegments.delete(splitSegmentId); - return { - resultSkeletonId: originalSegmentId, - deletedSkeletonId: splitSegmentId, - stableAnnotationSwap: false, - }; - }), + splitSkeleton, + mergeSkeletons, }); const deleteSegmentColor = vi.fn(); @@ -665,10 +1049,15 @@ describe("spatial_skeleton_commands", () => { await undoSpatialSkeletonCommand(layer as any); - expect(skeletonSource.mergeSkeletons).toHaveBeenCalledWith( + expect(mergeSkeletons).toHaveBeenCalledWith( formerParentNode.nodeId, splitNodeBefore.nodeId, - expect.any(Object), + expect.objectContaining({ + nodes: expect.arrayContaining([ + expect.objectContaining({ nodeId: formerParentNode.nodeId }), + expect.objectContaining({ nodeId: splitNodeBefore.nodeId }), + ]), + }), ); expect(deleteSegmentColor).toHaveBeenCalledWith(BigInt(splitSegmentId)); expect(skeletonLayer.suppressBrowseSegment).toHaveBeenCalledWith( @@ -710,7 +1099,7 @@ describe("spatial_skeleton_commands", () => { segmentId: originalSegmentId, position: new Float32Array([1, 2, 3]), isTrueEnd: false, - revisionToken: "root-before", + sourceState: testSourceState("root-before"), }; const formerParentNode: SpatiallyIndexedSkeletonNode = { nodeId: 21893039, @@ -718,7 +1107,7 @@ describe("spatial_skeleton_commands", () => { parentNodeId: originalRootNode.nodeId, position: new Float32Array([10, 20, 30]), isTrueEnd: false, - revisionToken: "parent-before", + sourceState: testSourceState("parent-before"), }; const splitNodeBefore: SpatiallyIndexedSkeletonNode = { nodeId: 21893038, @@ -726,30 +1115,30 @@ describe("spatial_skeleton_commands", () => { parentNodeId: formerParentNode.nodeId, position: new Float32Array([11, 21, 31]), isTrueEnd: false, - revisionToken: "split-before", + sourceState: testSourceState("split-before"), }; const splitNodeAfter: SpatiallyIndexedSkeletonNode = { ...splitNodeBefore, segmentId: splitSegmentId, parentNodeId: undefined, - revisionToken: "split-after", + sourceState: testSourceState("split-after"), }; const restoredNodes: SpatiallyIndexedSkeletonNode[] = [ { ...originalRootNode, parentNodeId: undefined, - revisionToken: "root-rerooted", + sourceState: testSourceState("root-rerooted"), }, { ...formerParentNode, parentNodeId: originalRootNode.nodeId, - revisionToken: "parent-rerooted", + sourceState: testSourceState("parent-rerooted"), }, { ...splitNodeBefore, segmentId: originalSegmentId, parentNodeId: formerParentNode.nodeId, - revisionToken: "split-rerooted", + sourceState: testSourceState("split-rerooted"), }, ]; @@ -774,27 +1163,31 @@ describe("spatial_skeleton_commands", () => { ]); syncCacheFromServer(originalSegmentId); + const splitSkeleton = vi.fn(async () => { + serverSegments.set(originalSegmentId, [ + cloneNode(originalRootNode), + cloneNode(formerParentNode), + ]); + serverSegments.set(splitSegmentId, [cloneNode(splitNodeAfter)]); + return { + existingSegmentId: originalSegmentId, + newSegmentId: splitSegmentId, + }; + }); + const mergeSkeletons = vi.fn(async () => { + serverSegments.set(originalSegmentId, restoredNodes.map(cloneNode)); + serverSegments.delete(splitSegmentId); + return { + resultSegmentId: originalSegmentId, + deletedSegmentId: splitSegmentId, + directionAdjusted: false, + }; + }); + const rerootSkeleton = vi.fn(); const skeletonSource = makeEditableSkeletonSource({ - splitSkeleton: vi.fn(async () => { - serverSegments.set(originalSegmentId, [ - cloneNode(originalRootNode), - cloneNode(formerParentNode), - ]); - serverSegments.set(splitSegmentId, [cloneNode(splitNodeAfter)]); - return { - existingSkeletonId: originalSegmentId, - newSkeletonId: splitSegmentId, - }; - }), - mergeSkeletons: vi.fn(async () => { - serverSegments.set(originalSegmentId, restoredNodes.map(cloneNode)); - serverSegments.delete(splitSegmentId); - return { - resultSkeletonId: originalSegmentId, - deletedSkeletonId: splitSegmentId, - stableAnnotationSwap: false, - }; - }), + splitSkeleton, + mergeSkeletons, + rerootSkeleton, }); const invalidateCachedSegments = vi.fn((segmentIds: Iterable) => { @@ -861,18 +1254,23 @@ describe("spatial_skeleton_commands", () => { segmentId: originalSegmentId, }); - skeletonSource.rerootSkeleton.mockClear(); + rerootSkeleton.mockClear(); getFullSegmentNodes.mockClear(); invalidateCachedSegments.mockClear(); await undoSpatialSkeletonCommand(layer as any); - expect(skeletonSource.mergeSkeletons).toHaveBeenCalledWith( + expect(mergeSkeletons).toHaveBeenCalledWith( formerParentNode.nodeId, splitNodeBefore.nodeId, - expect.any(Object), + expect.objectContaining({ + nodes: expect.arrayContaining([ + expect.objectContaining({ nodeId: formerParentNode.nodeId }), + expect.objectContaining({ nodeId: splitNodeBefore.nodeId }), + ]), + }), ); - expect(skeletonSource.rerootSkeleton).not.toHaveBeenCalled(); + expect(rerootSkeleton).not.toHaveBeenCalled(); expect(invalidateCachedSegments).toHaveBeenCalledTimes(1); expect(invalidateCachedSegments).toHaveBeenCalledWith([ originalSegmentId, @@ -901,7 +1299,7 @@ describe("spatial_skeleton_commands", () => { ]); }); - it("preserves full merge undo behavior for a hidden second pick via the root endpoint", async () => { + it("preserves full merge undo behavior for a hidden second pick", async () => { suppressStatusMessages(); const visibleSegmentId = 11; @@ -911,7 +1309,7 @@ describe("spatial_skeleton_commands", () => { segmentId: visibleSegmentId, position: new Float32Array([1, 2, 3]), isTrueEnd: false, - revisionToken: "visible-root-before", + sourceState: testSourceState("visible-root-before"), }; const visibleAnchorNode: SpatiallyIndexedSkeletonNode = { nodeId: 102, @@ -919,14 +1317,14 @@ describe("spatial_skeleton_commands", () => { parentNodeId: visibleRootNode.nodeId, position: new Float32Array([4, 5, 6]), isTrueEnd: false, - revisionToken: "visible-anchor-before", + sourceState: testSourceState("visible-anchor-before"), }; const hiddenRootNode: SpatiallyIndexedSkeletonNode = { nodeId: 201, segmentId: hiddenSegmentId, position: new Float32Array([7, 8, 9]), isTrueEnd: false, - revisionToken: "hidden-root-before", + sourceState: testSourceState("hidden-root-before"), }; const hiddenAttachNodeBefore: SpatiallyIndexedSkeletonNode = { nodeId: 202, @@ -934,7 +1332,7 @@ describe("spatial_skeleton_commands", () => { parentNodeId: hiddenRootNode.nodeId, position: new Float32Array([10, 11, 12]), isTrueEnd: false, - revisionToken: "hidden-attach-before", + sourceState: testSourceState("hidden-attach-before"), }; const mergedNodes: SpatiallyIndexedSkeletonNode[] = [ cloneNode(visibleRootNode), @@ -954,24 +1352,24 @@ describe("spatial_skeleton_commands", () => { { ...cloneNode(hiddenAttachNodeBefore), parentNodeId: undefined, - revisionToken: "hidden-attach-split", + sourceState: testSourceState("hidden-attach-split"), }, { ...cloneNode(hiddenRootNode), parentNodeId: hiddenAttachNodeBefore.nodeId, - revisionToken: "hidden-root-split", + sourceState: testSourceState("hidden-root-split"), }, ]; const rerootedHiddenNodes: SpatiallyIndexedSkeletonNode[] = [ { ...cloneNode(hiddenRootNode), parentNodeId: undefined, - revisionToken: "hidden-root-rerooted", + sourceState: testSourceState("hidden-root-rerooted"), }, { ...cloneNode(hiddenAttachNodeBefore), parentNodeId: hiddenRootNode.nodeId, - revisionToken: "hidden-attach-rerooted", + sourceState: testSourceState("hidden-attach-rerooted"), }, ]; @@ -1000,40 +1398,41 @@ describe("spatial_skeleton_commands", () => { ]); syncCacheFromServer(visibleSegmentId); + const mergeSkeletons = vi.fn(async () => { + serverSegments.set(visibleSegmentId, mergedNodes.map(cloneNode)); + serverSegments.delete(hiddenSegmentId); + return { + resultSegmentId: visibleSegmentId, + deletedSegmentId: hiddenSegmentId, + directionAdjusted: false, + }; + }); + const splitSkeleton = vi.fn(async () => { + serverSegments.set(visibleSegmentId, [ + cloneNode(visibleRootNode), + cloneNode(visibleAnchorNode), + ]); + serverSegments.set( + hiddenSegmentId, + splitOnlyRestoredNodes.map(cloneNode), + ); + return { + existingSegmentId: visibleSegmentId, + newSegmentId: hiddenSegmentId, + }; + }); + const rerootSkeleton = vi.fn(async () => { + serverSegments.set(hiddenSegmentId, rerootedHiddenNodes.map(cloneNode)); + return {}; + }); const skeletonSource = makeEditableSkeletonSource({ getSkeletonRootNode: vi.fn(async () => ({ nodeId: hiddenRootNode.nodeId, - x: hiddenRootNode.position[0], - y: hiddenRootNode.position[1], - z: hiddenRootNode.position[2], + position: hiddenRootNode.position, })), - mergeSkeletons: vi.fn(async () => { - serverSegments.set(visibleSegmentId, mergedNodes.map(cloneNode)); - serverSegments.delete(hiddenSegmentId); - return { - resultSkeletonId: visibleSegmentId, - deletedSkeletonId: hiddenSegmentId, - stableAnnotationSwap: false, - }; - }), - splitSkeleton: vi.fn(async () => { - serverSegments.set(visibleSegmentId, [ - cloneNode(visibleRootNode), - cloneNode(visibleAnchorNode), - ]); - serverSegments.set( - hiddenSegmentId, - splitOnlyRestoredNodes.map(cloneNode), - ); - return { - existingSkeletonId: visibleSegmentId, - newSkeletonId: hiddenSegmentId, - }; - }), - rerootSkeleton: vi.fn(async () => { - serverSegments.set(hiddenSegmentId, rerootedHiddenNodes.map(cloneNode)); - return {}; - }), + mergeSkeletons, + splitSkeleton, + rerootSkeleton, }); const invalidateCachedSegments = vi.fn((segmentIds: Iterable) => { @@ -1111,35 +1510,44 @@ describe("spatial_skeleton_commands", () => { { nodeId: hiddenAttachNodeBefore.nodeId, segmentId: hiddenSegmentId, - revisionToken: hiddenAttachNodeBefore.revisionToken, + sourceState: hiddenAttachNodeBefore.sourceState, }, ); - expect(skeletonSource.getSkeletonRootNode).toHaveBeenCalledWith( - hiddenSegmentId, - ); - expect(skeletonSource.mergeSkeletons).toHaveBeenCalledWith( + expect(skeletonSource.getSkeletonRootNode).not.toHaveBeenCalled(); + expect(mergeSkeletons).toHaveBeenCalledWith( visibleAnchorNode.nodeId, hiddenAttachNodeBefore.nodeId, - expect.any(Object), + expect.objectContaining({ + nodes: expect.arrayContaining([ + expect.objectContaining({ nodeId: visibleAnchorNode.nodeId }), + expect.objectContaining({ nodeId: hiddenAttachNodeBefore.nodeId }), + ]), + }), + ); + expect(getFullSegmentNodes).toHaveBeenCalledTimes(3); + expect(getFullSegmentNodes.mock.invocationCallOrder[0]).toBeLessThan( + mergeSkeletons.mock.invocationCallOrder[0], ); - expect(getFullSegmentNodes).toHaveBeenCalledTimes(2); - expect( - skeletonSource.mergeSkeletons.mock.invocationCallOrder[0], - ).toBeLessThan(getFullSegmentNodes.mock.invocationCallOrder[0]); - skeletonSource.rerootSkeleton.mockClear(); + rerootSkeleton.mockClear(); hiddenSegmentVisibleDuringFetches.length = 0; await undoSpatialSkeletonCommand(layer as any); - expect(skeletonSource.splitSkeleton).toHaveBeenCalledWith( + expect(splitSkeleton).toHaveBeenCalledWith( hiddenAttachNodeBefore.nodeId, - expect.any(Object), + expect.objectContaining({ + node: expect.objectContaining({ + nodeId: hiddenAttachNodeBefore.nodeId, + }), + }), ); - expect(skeletonSource.rerootSkeleton).toHaveBeenCalledWith( + expect(rerootSkeleton).toHaveBeenCalledWith( hiddenRootNode.nodeId, - expect.any(Object), + expect.objectContaining({ + node: expect.objectContaining({ nodeId: hiddenRootNode.nodeId }), + }), ); expect(hiddenSegmentVisibleDuringFetches.length).toBeGreaterThan(0); expect(hiddenSegmentVisibleDuringFetches.every(Boolean)).toBe(true); @@ -1177,7 +1585,7 @@ describe("spatial_skeleton_commands", () => { segmentId: visibleSegmentId, position: new Float32Array([1, 2, 3]), isTrueEnd: false, - revisionToken: "visible-root-before", + sourceState: testSourceState("visible-root-before"), }; const visibleAnchorNode: SpatiallyIndexedSkeletonNode = { nodeId: 102, @@ -1185,14 +1593,14 @@ describe("spatial_skeleton_commands", () => { parentNodeId: visibleRootNode.nodeId, position: new Float32Array([4, 5, 6]), isTrueEnd: false, - revisionToken: "visible-anchor-before", + sourceState: testSourceState("visible-anchor-before"), }; const hiddenRootNode: SpatiallyIndexedSkeletonNode = { nodeId: 201, segmentId: hiddenSegmentId, position: new Float32Array([7, 8, 9]), isTrueEnd: false, - revisionToken: "hidden-root-before", + sourceState: testSourceState("hidden-root-before"), }; const hiddenAttachNodeBefore: SpatiallyIndexedSkeletonNode = { nodeId: 202, @@ -1200,7 +1608,7 @@ describe("spatial_skeleton_commands", () => { parentNodeId: hiddenRootNode.nodeId, position: new Float32Array([10, 11, 12]), isTrueEnd: false, - revisionToken: "hidden-attach-before", + sourceState: testSourceState("hidden-attach-before"), }; const mergedNodes: SpatiallyIndexedSkeletonNode[] = [ cloneNode(visibleRootNode), @@ -1220,12 +1628,12 @@ describe("spatial_skeleton_commands", () => { { ...cloneNode(hiddenAttachNodeBefore), parentNodeId: undefined, - revisionToken: "hidden-attach-split", + sourceState: testSourceState("hidden-attach-split"), }, { ...cloneNode(hiddenRootNode), parentNodeId: hiddenAttachNodeBefore.nodeId, - revisionToken: "hidden-root-split", + sourceState: testSourceState("hidden-root-split"), }, ]; @@ -1253,39 +1661,40 @@ describe("spatial_skeleton_commands", () => { ]); syncCacheFromServer(visibleSegmentId); + const mergeSkeletons = vi.fn(async () => { + serverSegments.set(visibleSegmentId, mergedNodes.map(cloneNode)); + serverSegments.delete(hiddenSegmentId); + return { + resultSegmentId: visibleSegmentId, + deletedSegmentId: hiddenSegmentId, + directionAdjusted: false, + }; + }); + const splitSkeleton = vi.fn(async () => { + serverSegments.set(visibleSegmentId, [ + cloneNode(visibleRootNode), + cloneNode(visibleAnchorNode), + ]); + serverSegments.set( + hiddenSegmentId, + splitOnlyRestoredNodes.map(cloneNode), + ); + return { + existingSegmentId: visibleSegmentId, + newSegmentId: hiddenSegmentId, + }; + }); + const rerootSkeleton = vi.fn(async () => { + throw new Error("reroot failed"); + }); const skeletonSource = makeEditableSkeletonSource({ getSkeletonRootNode: vi.fn(async () => ({ nodeId: hiddenRootNode.nodeId, - x: hiddenRootNode.position[0], - y: hiddenRootNode.position[1], - z: hiddenRootNode.position[2], + position: hiddenRootNode.position, })), - mergeSkeletons: vi.fn(async () => { - serverSegments.set(visibleSegmentId, mergedNodes.map(cloneNode)); - serverSegments.delete(hiddenSegmentId); - return { - resultSkeletonId: visibleSegmentId, - deletedSkeletonId: hiddenSegmentId, - stableAnnotationSwap: false, - }; - }), - splitSkeleton: vi.fn(async () => { - serverSegments.set(visibleSegmentId, [ - cloneNode(visibleRootNode), - cloneNode(visibleAnchorNode), - ]); - serverSegments.set( - hiddenSegmentId, - splitOnlyRestoredNodes.map(cloneNode), - ); - return { - existingSkeletonId: visibleSegmentId, - newSkeletonId: hiddenSegmentId, - }; - }), - rerootSkeleton: vi.fn(async () => { - throw new Error("reroot failed"); - }), + mergeSkeletons, + splitSkeleton, + rerootSkeleton, }); const getFullSegmentNodes = vi.fn( @@ -1353,20 +1762,26 @@ describe("spatial_skeleton_commands", () => { { nodeId: hiddenAttachNodeBefore.nodeId, segmentId: hiddenSegmentId, - revisionToken: hiddenAttachNodeBefore.revisionToken, + sourceState: hiddenAttachNodeBefore.sourceState, }, ); statusSpy.mockClear(); await expect(undoSpatialSkeletonCommand(layer as any)).resolves.toBe(true); - expect(skeletonSource.splitSkeleton).toHaveBeenCalledWith( + expect(splitSkeleton).toHaveBeenCalledWith( hiddenAttachNodeBefore.nodeId, - expect.any(Object), + expect.objectContaining({ + node: expect.objectContaining({ + nodeId: hiddenAttachNodeBefore.nodeId, + }), + }), ); - expect(skeletonSource.rerootSkeleton).toHaveBeenCalledWith( + expect(rerootSkeleton).toHaveBeenCalledWith( hiddenRootNode.nodeId, - expect.any(Object), + expect.objectContaining({ + node: expect.objectContaining({ nodeId: hiddenRootNode.nodeId }), + }), ); expect( cacheBySegment.get(hiddenSegmentId)?.map((node) => ({ @@ -1398,7 +1813,7 @@ describe("spatial_skeleton_commands", () => { segmentId: firstSegmentId, position: new Float32Array([1, 2, 3]), isTrueEnd: false, - revisionToken: "first-root-before", + sourceState: testSourceState("first-root-before"), }; const firstAnchorNode: SpatiallyIndexedSkeletonNode = { nodeId: 102, @@ -1406,14 +1821,14 @@ describe("spatial_skeleton_commands", () => { parentNodeId: firstRootNode.nodeId, position: new Float32Array([4, 5, 6]), isTrueEnd: false, - revisionToken: "first-anchor-before", + sourceState: testSourceState("first-anchor-before"), }; const secondRootNode: SpatiallyIndexedSkeletonNode = { nodeId: 201, segmentId: secondSegmentId, position: new Float32Array([7, 8, 9]), isTrueEnd: false, - revisionToken: "second-root-before", + sourceState: testSourceState("second-root-before"), }; const secondAttachNode: SpatiallyIndexedSkeletonNode = { nodeId: 202, @@ -1421,7 +1836,7 @@ describe("spatial_skeleton_commands", () => { parentNodeId: secondRootNode.nodeId, position: new Float32Array([10, 11, 12]), isTrueEnd: false, - revisionToken: "second-attach-before", + sourceState: testSourceState("second-attach-before"), }; const serverSegments = new Map(); @@ -1448,18 +1863,17 @@ describe("spatial_skeleton_commands", () => { ]); syncCacheFromServer(firstSegmentId); + const mergeSkeletons = vi.fn(async () => ({ + resultSegmentId: firstSegmentId, + deletedSegmentId: secondSegmentId, + directionAdjusted: false, + })); const skeletonSource = makeEditableSkeletonSource({ getSkeletonRootNode: vi.fn(async () => ({ nodeId: secondRootNode.nodeId, - x: secondRootNode.position[0], - y: secondRootNode.position[1], - z: secondRootNode.position[2], - })), - mergeSkeletons: vi.fn(async () => ({ - resultSkeletonId: firstSegmentId, - deletedSkeletonId: secondSegmentId, - stableAnnotationSwap: false, + position: secondRootNode.position, })), + mergeSkeletons, }); const getFullSegmentNodes = vi.fn( @@ -1535,10 +1949,15 @@ describe("spatial_skeleton_commands", () => { expect.anything(), secondSegmentId, ); - expect(skeletonSource.mergeSkeletons).toHaveBeenCalledWith( + expect(mergeSkeletons).toHaveBeenCalledWith( firstAnchorNode.nodeId, secondAttachNode.nodeId, - expect.any(Object), + expect.objectContaining({ + nodes: expect.arrayContaining([ + expect.objectContaining({ nodeId: firstAnchorNode.nodeId }), + expect.objectContaining({ nodeId: secondAttachNode.nodeId }), + ]), + }), ); }); @@ -1555,17 +1974,17 @@ describe("spatial_skeleton_commands", () => { let resolveMerge: | ((value: { - resultSkeletonId: number; - deletedSkeletonId: number; - stableAnnotationSwap: boolean; + resultSegmentId: number; + deletedSegmentId: number; + directionAdjusted: boolean; }) => void) | undefined; const mergeSkeletons = vi.fn( () => new Promise<{ - resultSkeletonId: number; - deletedSkeletonId: number; - stableAnnotationSwap: boolean; + resultSegmentId: number; + deletedSegmentId: number; + directionAdjusted: boolean; }>((resolve) => { resolveMerge = resolve; }), @@ -1575,14 +1994,14 @@ describe("spatial_skeleton_commands", () => { segmentId: 11, position: new Float32Array([1, 2, 3]), isTrueEnd: false, - revisionToken: "first-before", + sourceState: testSourceState("first-before"), }; const secondNode: SpatiallyIndexedSkeletonNode = { nodeId: 202, segmentId: 17, position: new Float32Array([4, 5, 6]), isTrueEnd: false, - revisionToken: "second-before", + sourceState: testSourceState("second-before"), }; const skeletonLayer = { source: makeEditableSkeletonSource({ mergeSkeletons }), @@ -1657,9 +2076,9 @@ describe("spatial_skeleton_commands", () => { }); resolveMerge?.({ - resultSkeletonId: firstNode.segmentId, - deletedSkeletonId: secondNode.segmentId, - stableAnnotationSwap: false, + resultSegmentId: firstNode.segmentId, + deletedSegmentId: secondNode.segmentId, + directionAdjusted: false, }); await mergePromise; diff --git a/src/layer/segmentation/spatial_skeleton_commands.ts b/src/layer/segmentation/spatial_skeleton_commands.ts index 0b46c23ef..e8336e2e4 100644 --- a/src/layer/segmentation/spatial_skeleton_commands.ts +++ b/src/layer/segmentation/spatial_skeleton_commands.ts @@ -15,1752 +15,261 @@ */ import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; -import { - addSegmentToVisibleSets, - removeSegmentFromVisibleSets, -} from "#src/segmentation_display_state/base.js"; import type { EditableSpatiallyIndexedSkeletonSource, - SpatiallyIndexedSkeletonAddNodeResult, - SpatiallyIndexedSkeletonEditContext, - SpatiallyIndexedSkeletonInsertNodeResult, - SpatiallyIndexedSkeletonMergeResult, SpatiallyIndexedSkeletonNode, - SpatiallyIndexedSkeletonNodeRevisionUpdate, - SpatiallyIndexedSkeletonSplitResult, } from "#src/skeleton/api.js"; -import type { - SpatialSkeletonCommand, - SpatialSkeletonCommandContext, -} from "#src/skeleton/command_history.js"; import { - buildSpatiallyIndexedSkeletonMultiNodeEditContext, - buildSpatiallyIndexedSkeletonNeighborhoodEditContext, - buildSpatiallyIndexedSkeletonNodeEditContext, - buildSpatiallyIndexedSkeletonRerootEditContext, - findSpatiallyIndexedSkeletonNode, - getSpatiallyIndexedSkeletonDirectChildren, -} from "#src/skeleton/edit_state.js"; -import type { SpatiallyIndexedSkeletonLayer } from "#src/skeleton/frontend.js"; + SpatialSkeletonActions, + type SpatialSkeletonAction, +} from "#src/skeleton/actions.js"; +import type { + SpatialSkeletonCommandPayload, + SpatialSkeletonEditCommandFactory, +} from "#src/skeleton/edit_command_source.js"; +import type { SpatialSkeletonCommand } from "#src/skeleton/command_history.js"; import { getEditableSpatiallyIndexedSkeletonSource } from "#src/skeleton/spatial_skeleton_manager.js"; import { StatusMessage } from "#src/status.js"; -import { formatErrorMessage } from "#src/util/error.js"; -function cloneNodeSnapshot( - node: SpatiallyIndexedSkeletonNode, -): SpatiallyIndexedSkeletonNode { - return { - nodeId: node.nodeId, - segmentId: node.segmentId, - position: new Float32Array(node.position), - parentNodeId: node.parentNodeId, - radius: node.radius, - confidence: node.confidence, - description: node.description, - isTrueEnd: node.isTrueEnd, - revisionToken: node.revisionToken, - }; +interface SpatialSkeletonSourceAccess { + source: object; } -function getEditableSkeletonSourceForLayer(layer: SegmentationUserLayer): { - skeletonLayer: SpatiallyIndexedSkeletonLayer; - skeletonSource: EditableSpatiallyIndexedSkeletonSource; -} { - const skeletonLayer = layer.getSpatiallyIndexedSkeletonLayer(); - if (skeletonLayer === undefined) { - throw new Error( - "No spatially indexed skeleton source is currently loaded.", - ); - } - const skeletonSource = - getEditableSpatiallyIndexedSkeletonSource(skeletonLayer); - if (skeletonSource === undefined) { +function getEditSource( + layer: SegmentationUserLayer, +): EditableSpatiallyIndexedSkeletonSource { + const source = getEditableSpatiallyIndexedSkeletonSource( + layer.getSpatiallyIndexedSkeletonLayer(), + ); + if (source === undefined) { throw new Error( "Unable to resolve editable skeleton source for the active layer.", ); } - return { skeletonLayer, skeletonSource }; -} - -function ensureVisibleSegment( + return source; +} + +export function getSpatialSkeletonEditCommandFactory( + value: SpatialSkeletonSourceAccess | undefined, + action: SpatialSkeletonAction, +): SpatialSkeletonEditCommandFactory | undefined { + const source = getEditableSpatiallyIndexedSkeletonSource(value); + if (source === undefined) return undefined; + switch (action) { + case SpatialSkeletonActions.addNodes: + return source.addNodesCommand; + case SpatialSkeletonActions.insertNodes: + return source.insertNodesCommand; + case SpatialSkeletonActions.moveNodes: + return source.moveNodesCommand; + case SpatialSkeletonActions.deleteNodes: + return source.deleteNodesCommand; + case SpatialSkeletonActions.reroot: + return source.rerootCommand; + case SpatialSkeletonActions.editNodeDescription: + return source.editNodeDescriptionCommand; + case SpatialSkeletonActions.editNodeTrueEnd: + return source.editNodeTrueEndCommand; + case SpatialSkeletonActions.editNodeRadius: + return source.editNodeRadiusCommand; + case SpatialSkeletonActions.editNodeConfidence: + return source.editNodeConfidenceCommand; + case SpatialSkeletonActions.mergeSkeletons: + return source.mergeSkeletonsCommand; + case SpatialSkeletonActions.splitSkeletons: + return source.splitSkeletonsCommand; + case SpatialSkeletonActions.inspect: + return undefined; + } +} + +function executeCommand( layer: SegmentationUserLayer, - segmentId: number | undefined, + command: SpatialSkeletonCommand, ) { - if ( - segmentId === undefined || - !Number.isSafeInteger(Math.round(Number(segmentId))) || - Math.round(Number(segmentId)) <= 0 - ) { - return; - } - addSegmentToVisibleSets( - layer.displayState.segmentationGroupState.value, - BigInt(Math.round(Number(segmentId))), - ); + return layer.spatialSkeletonState.commandHistory.execute(command); } -function selectSegment( - layer: SegmentationUserLayer, - segmentId: number | undefined, - pin: boolean, +function executeCommandWithPendingMessage( + promise: Promise, + message: string, ) { - if ( - segmentId === undefined || - !Number.isSafeInteger(Math.round(Number(segmentId))) || - Math.round(Number(segmentId)) <= 0 - ) { - return; - } - layer.selectSegment(BigInt(Math.round(Number(segmentId))), pin); + const status = StatusMessage.showMessage(message); + return promise.finally(() => status.dispose()); } -function removeVisibleSegment( +function createSpatialSkeletonCommand( layer: SegmentationUserLayer, - segmentId: number | undefined, - options: { - deselect?: boolean; - } = {}, + action: SpatialSkeletonAction, + payload: SpatialSkeletonCommandPayload, + unsupportedMessage: string, ) { - if ( - segmentId === undefined || - !Number.isSafeInteger(Math.round(Number(segmentId))) || - Math.round(Number(segmentId)) <= 0 - ) { - return; - } - removeSegmentFromVisibleSets( - layer.displayState.segmentationGroupState.value, - BigInt(Math.round(Number(segmentId))), - options, + const source = getEditSource(layer); + const commandFactory = getSpatialSkeletonEditCommandFactory( + { source }, + action, ); -} - -function findRootNode(segmentNodes: readonly SpatiallyIndexedSkeletonNode[]) { - return segmentNodes.find((candidate) => candidate.parentNodeId === undefined); -} - -interface ResolvedSpatialSkeletonEditNode { - skeletonLayer: SpatiallyIndexedSkeletonLayer; - skeletonSource: EditableSpatiallyIndexedSkeletonSource; - segmentNodes: readonly SpatiallyIndexedSkeletonNode[]; - node: SpatiallyIndexedSkeletonNode; -} - -interface ResolvedSpatialSkeletonEditNodeContext { - currentNodeId: number; - segmentId: number; - cachedNode: SpatiallyIndexedSkeletonNode | undefined; - skeletonLayer: SpatiallyIndexedSkeletonLayer; - skeletonSource: EditableSpatiallyIndexedSkeletonSource; -} - -function getResolvedNodeContextForEdit( - layer: SegmentationUserLayer, - stableNodeId: number, - stableSegmentId: number | undefined, -): ResolvedSpatialSkeletonEditNodeContext { - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const currentNodeId = commandMappings.resolveNodeId(stableNodeId); - if (currentNodeId === undefined) { - throw new Error(`Unable to resolve current node ${stableNodeId}.`); + if (commandFactory === undefined) { + throw new Error(unsupportedMessage); } - const { skeletonLayer, skeletonSource } = - getEditableSkeletonSourceForLayer(layer); - const cachedNode = - layer.spatialSkeletonState.getCachedNode(currentNodeId) ?? - skeletonLayer.getNode(currentNodeId); - const candidateSegmentId = - cachedNode?.segmentId ?? commandMappings.resolveSegmentId(stableSegmentId); - if (candidateSegmentId === undefined) { - throw new Error( - `Unable to resolve the current segment for node ${stableNodeId}.`, - ); - } - return { - currentNodeId, - segmentId: candidateSegmentId, - cachedNode, - skeletonLayer, - skeletonSource, - }; + return commandFactory.createCommand(layer, payload); } -async function getResolvedNodeForEdit( - layer: SegmentationUserLayer, - stableNodeId: number, - stableSegmentId: number | undefined, -): Promise { - const { - currentNodeId, - segmentId: candidateSegmentId, - skeletonLayer, - skeletonSource, - } = getResolvedNodeContextForEdit(layer, stableNodeId, stableSegmentId); - let segmentNodes = - layer.spatialSkeletonState.getCachedSegmentNodes(candidateSegmentId); - if (segmentNodes === undefined) { - segmentNodes = await layer.spatialSkeletonState.getFullSegmentNodes( - skeletonLayer, - candidateSegmentId, - ); - } - const node = findSpatiallyIndexedSkeletonNode(segmentNodes, currentNodeId); - if (node === undefined) { - throw new Error( - `Node ${currentNodeId} is not available in the inspected skeleton cache.`, - ); - } - return { - skeletonLayer, - skeletonSource, - segmentNodes, - node, - }; -} - -function buildInsertEditContext( - parentNode: SpatiallyIndexedSkeletonNode, - childNodes: readonly SpatiallyIndexedSkeletonNode[], -): SpatiallyIndexedSkeletonEditContext { - return { - node: buildSpatiallyIndexedSkeletonNodeEditContext(parentNode).node, - children: childNodes.map((child) => ({ - nodeId: child.nodeId, - revisionToken: - child.revisionToken ?? - (() => { - throw new Error( - `Inspected child node ${child.nodeId} is missing revision metadata.`, - ); - })(), - })), - }; -} - -async function refreshTopologySegments( +export function executeSpatialSkeletonAddNode( layer: SegmentationUserLayer, - segmentIds: readonly number[], + options: SpatialSkeletonCommandPayload, ) { - const normalizedSegmentIds = [ - ...new Set( - segmentIds.filter((value) => Number.isSafeInteger(Math.round(value))), - ), - ].map((value) => Math.round(value)); - if (normalizedSegmentIds.length === 0) { - return; - } - const { skeletonLayer } = getEditableSkeletonSourceForLayer(layer); - layer.spatialSkeletonState.invalidateCachedSegments(normalizedSegmentIds); - layer.markSpatialSkeletonNodeDataChanged({ - invalidateFullSkeletonCache: false, - }); - skeletonLayer.invalidateSourceCaches(); - await Promise.allSettled( - normalizedSegmentIds.map((segmentId) => - layer.spatialSkeletonState.getFullSegmentNodes(skeletonLayer, segmentId), - ), + const command = createSpatialSkeletonCommand( + layer, + SpatialSkeletonActions.addNodes, + options, + "The active skeleton source does not support node creation.", + ); + return executeCommandWithPendingMessage( + executeCommand(layer, command), + "Creating node...", ); } -function applyAddNodeToCache( - layer: SegmentationUserLayer, - skeletonLayer: SpatiallyIndexedSkeletonLayer, - committedNode: SpatiallyIndexedSkeletonAddNodeResult, - parentNodeId: number | undefined, - positionInModelSpace: Float32Array, - options: { - focusSelection: boolean; - moveView: boolean; - pinSegment: boolean; - }, -) { - const newNode: SpatiallyIndexedSkeletonNode = { - nodeId: committedNode.treenodeId, - segmentId: committedNode.skeletonId, - position: new Float32Array(positionInModelSpace), - parentNodeId, - isTrueEnd: false, - ...(committedNode.revisionToken === undefined - ? {} - : { revisionToken: committedNode.revisionToken }), - }; - layer.spatialSkeletonState.upsertCachedNode(newNode, { - allowUncachedSegment: parentNodeId === undefined, - }); - if ( - parentNodeId !== undefined && - committedNode.parentRevisionToken !== undefined - ) { - layer.spatialSkeletonState.setCachedNodeRevision( - parentNodeId, - committedNode.parentRevisionToken, - ); - } - ensureVisibleSegment(layer, newNode.segmentId); - selectSegment(layer, newNode.segmentId, options.pinSegment); - if (options.focusSelection) { - layer.selectSpatialSkeletonNode( - newNode.nodeId, - layer.manager.root.selectionState.pin.value, - { - segmentId: newNode.segmentId, - position: newNode.position, - }, - ); - if (options.moveView) { - layer.moveViewToSpatialSkeletonNodePosition(newNode.position); - } - } - if (parentNodeId !== undefined) { - skeletonLayer.retainOverlaySegment(newNode.segmentId); - } - layer.markSpatialSkeletonNodeDataChanged({ - invalidateFullSkeletonCache: false, - }); -} - -function applyDeleteNodeToCache( - layer: SegmentationUserLayer, - deleteContext: { - node: SpatiallyIndexedSkeletonNode; - parentNode: SpatiallyIndexedSkeletonNode | undefined; - childNodes: readonly SpatiallyIndexedSkeletonNode[]; - editContext: SpatiallyIndexedSkeletonEditContext; - }, - options: { - moveView: boolean; - }, - nodeRevisionUpdates: readonly SpatiallyIndexedSkeletonNodeRevisionUpdate[] = [], -) { - const { node, parentNode, childNodes } = deleteContext; - const directChildIds = childNodes.map((child) => child.nodeId); - layer.spatialSkeletonState.removeCachedNode(node.nodeId, { - parentNodeId: node.parentNodeId, - childNodeIds: directChildIds, - }); - if (nodeRevisionUpdates.length > 0) { - layer.spatialSkeletonState.setCachedNodeRevisions(nodeRevisionUpdates); - } - if (parentNode !== undefined) { - if (options.moveView) { - layer.selectAndMoveToSpatialSkeletonNode( - parentNode, - layer.manager.root.selectionState.pin.value, - ); - } else { - layer.selectSpatialSkeletonNode( - parentNode.nodeId, - layer.manager.root.selectionState.pin.value, - { - segmentId: parentNode.segmentId, - position: parentNode.position, - }, - ); - } - } else { - layer.clearSpatialSkeletonNodeSelection( - layer.manager.root.selectionState.pin.value, - ); - } - const remainingSegmentNodes = - layer.spatialSkeletonState.getCachedSegmentNodes(node.segmentId) ?? []; - if (remainingSegmentNodes.length === 0) { - removeVisibleSegment(layer, node.segmentId, { deselect: true }); - } - layer.markSpatialSkeletonNodeDataChanged({ - invalidateFullSkeletonCache: false, - }); -} - -async function applyNodeDescriptionAndTrueEnd( - skeletonSource: EditableSpatiallyIndexedSkeletonSource, - node: SpatiallyIndexedSkeletonNode, - next: { - description?: string; - isTrueEnd: boolean; - }, -) { - const nextDescription = next.description; - const nextTrueEnd = next.isTrueEnd; - let updatedNode: SpatiallyIndexedSkeletonNode = { - ...node, - description: nextDescription, - isTrueEnd: nextTrueEnd, - }; - const descriptionChanged = node.description !== nextDescription; - if (descriptionChanged) { - const descriptionResult = await skeletonSource.updateDescription( - node.nodeId, - nextDescription ?? "", - ); - updatedNode = { - ...updatedNode, - description: descriptionResult.description, - revisionToken: - descriptionResult.revisionToken ?? updatedNode.revisionToken, - }; - } - if (node.isTrueEnd !== nextTrueEnd || (descriptionChanged && nextTrueEnd)) { - const trueEndResult = nextTrueEnd - ? await skeletonSource.setTrueEnd(node.nodeId) - : await skeletonSource.removeTrueEnd(node.nodeId); - updatedNode = { - ...updatedNode, - revisionToken: trueEndResult.revisionToken ?? updatedNode.revisionToken, - }; - } - return updatedNode; -} - -async function restoreNodeAttributes( - layer: SegmentationUserLayer, - skeletonSource: EditableSpatiallyIndexedSkeletonSource, - createdNode: SpatiallyIndexedSkeletonNode, - snapshot: SpatiallyIndexedSkeletonNode, -) { - let nextNode = cloneNodeSnapshot(createdNode); - if (snapshot.radius !== undefined && snapshot.radius !== nextNode.radius) { - const radiusResult = await skeletonSource.updateRadius( - createdNode.nodeId, - snapshot.radius, - buildSpatiallyIndexedSkeletonNodeEditContext(nextNode), - ); - nextNode = { - ...nextNode, - radius: snapshot.radius, - revisionToken: radiusResult.revisionToken ?? nextNode.revisionToken, - }; - } - if ( - snapshot.confidence !== undefined && - snapshot.confidence !== nextNode.confidence - ) { - if (nextNode.revisionToken === undefined) { - throw new Error( - `Node ${createdNode.nodeId} is missing revision metadata required to restore confidence.`, - ); - } - const confidenceResult = await skeletonSource.updateConfidence( - createdNode.nodeId, - snapshot.confidence, - buildSpatiallyIndexedSkeletonNodeEditContext(nextNode), - ); - nextNode = { - ...nextNode, - confidence: snapshot.confidence, - revisionToken: confidenceResult.revisionToken ?? nextNode.revisionToken, - }; - } - if ( - nextNode.description !== snapshot.description || - nextNode.isTrueEnd !== snapshot.isTrueEnd - ) { - nextNode = await applyNodeDescriptionAndTrueEnd( - skeletonSource, - nextNode, - snapshot, - ); - } - layer.spatialSkeletonState.upsertCachedNode(nextNode); - return nextNode; -} - -class AddNodeCommand implements SpatialSkeletonCommand { - readonly label = "Add node"; - private stableNodeId: number | undefined; - private stableSegmentId: number | undefined; - - constructor( - private layer: SegmentationUserLayer, - private stableParentNodeId: number | undefined, - private targetSkeletonId: number, - private positionInModelSpace: Float32Array, - ) {} - - private async addNode( - _context: SpatialSkeletonCommandContext, - options: { - moveView: boolean; - pinSegment: boolean; - statusPrefix: string; - }, - ) { - const { skeletonLayer, skeletonSource } = getEditableSkeletonSourceForLayer( - this.layer, - ); - const currentParentNodeId = - this.stableParentNodeId === undefined - ? undefined - : this.layer.spatialSkeletonState.commandHistory.mappings.resolveNodeId( - this.stableParentNodeId, - ); - let resolvedEditContext: SpatiallyIndexedSkeletonEditContext | undefined; - let resolvedSkeletonId = this.targetSkeletonId; - if (currentParentNodeId !== undefined) { - const parentNode = ( - await getResolvedNodeForEdit( - this.layer, - this.stableParentNodeId!, - this.layer.spatialSkeletonState.commandHistory.mappings.getStableOrCurrentSegmentId( - this.targetSkeletonId, - ), - ) - ).node; - resolvedSkeletonId = parentNode.segmentId; - resolvedEditContext = - buildSpatiallyIndexedSkeletonNodeEditContext(parentNode); - } - const result = await skeletonSource.addNode( - resolvedSkeletonId, - Number(this.positionInModelSpace[0]), - Number(this.positionInModelSpace[1]), - Number(this.positionInModelSpace[2]), - currentParentNodeId, - resolvedEditContext, - ); - if (this.stableNodeId === undefined) { - this.stableNodeId = result.treenodeId; - } else { - this.layer.spatialSkeletonState.commandHistory.mappings.remapNodeId( - this.stableNodeId, - result.treenodeId, - ); - } - if (this.stableSegmentId === undefined) { - this.stableSegmentId = result.skeletonId; - } else { - this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( - this.stableSegmentId, - result.skeletonId, - ); - } - applyAddNodeToCache( - this.layer, - skeletonLayer, - result, - currentParentNodeId, - this.positionInModelSpace, - { - focusSelection: true, - moveView: options.moveView, - pinSegment: options.pinSegment, - }, - ); - StatusMessage.showTemporaryMessage( - `${options.statusPrefix} node ${result.treenodeId} on segment ${result.skeletonId}.`, - ); - } - - async execute(context: SpatialSkeletonCommandContext) { - await this.addNode(context, { - moveView: true, - pinSegment: true, - statusPrefix: "Added", - }); - } - - async undo(_context: SpatialSkeletonCommandContext) { - if (this.stableNodeId === undefined) { - throw new Error("Add-node undo is missing the created node id."); - } - const resolvedNode = await getResolvedNodeForEdit( - this.layer, - this.stableNodeId, - this.stableSegmentId, - ); - const deleteContext = - await this.layer.getSpatialSkeletonDeleteOperationContext( - resolvedNode.node, - ); - const result = await resolvedNode.skeletonSource.deleteNode( - resolvedNode.node.nodeId, - { - childNodeIds: [], - editContext: deleteContext.editContext, - }, - ); - applyDeleteNodeToCache( - this.layer, - deleteContext, - { moveView: true }, - result.nodeRevisionUpdates, - ); - StatusMessage.showTemporaryMessage( - `Undid add node ${resolvedNode.node.nodeId}.`, - ); - } - - async redo(context: SpatialSkeletonCommandContext) { - await this.addNode(context, { - moveView: false, - pinSegment: false, - statusPrefix: "Redid add of", - }); - } -} - -class MoveNodeCommand implements SpatialSkeletonCommand { - readonly label = "Move node"; - - constructor( - private layer: SegmentationUserLayer, - private stableNodeId: number, - private stableSegmentId: number | undefined, - private beforePositionInModelSpace: Float32Array, - private afterPositionInModelSpace: Float32Array, - ) {} - - private async moveTo( - positionInModelSpace: Float32Array, - statusPrefix: string, - ) { - const { node, skeletonLayer, skeletonSource } = - await getResolvedNodeForEdit( - this.layer, - this.stableNodeId, - this.stableSegmentId, - ); - const result = await skeletonSource.moveNode( - node.nodeId, - Number(positionInModelSpace[0]), - Number(positionInModelSpace[1]), - Number(positionInModelSpace[2]), - buildSpatiallyIndexedSkeletonNodeEditContext(node), - ); - skeletonLayer.retainOverlaySegment(node.segmentId); - this.layer.spatialSkeletonState.moveCachedNode( - node.nodeId, - positionInModelSpace, - ); - if (result.revisionToken !== undefined) { - this.layer.spatialSkeletonState.setCachedNodeRevision( - node.nodeId, - result.revisionToken, - ); - } - this.layer.markSpatialSkeletonNodeDataChanged({ - invalidateFullSkeletonCache: false, - }); - StatusMessage.showTemporaryMessage( - `${statusPrefix} node ${node.nodeId} to (${Math.round(positionInModelSpace[0])}, ${Math.round(positionInModelSpace[1])}, ${Math.round(positionInModelSpace[2])}).`, - ); - } - - execute() { - return this.moveTo(this.afterPositionInModelSpace, "Moved"); - } - - undo() { - return this.moveTo(this.beforePositionInModelSpace, "Undid move of"); - } - - redo() { - return this.moveTo(this.afterPositionInModelSpace, "Redid move of"); - } -} - -class DeleteNodeCommand implements SpatialSkeletonCommand { - readonly label = "Delete node"; - private stableDeletedNodeId: number; - private stableSegmentId: number | undefined; - private stableParentNodeId: number | undefined; - private stableChildNodeIds: number[]; - private deletedSnapshot: SpatiallyIndexedSkeletonNode; - - constructor( - private layer: SegmentationUserLayer, - node: SpatiallyIndexedSkeletonNode, - childNodes: readonly SpatiallyIndexedSkeletonNode[], - ) { - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - this.stableDeletedNodeId = commandMappings.getStableOrCurrentNodeId( - node.nodeId, - )!; - this.stableSegmentId = commandMappings.getStableOrCurrentSegmentId( - node.segmentId, - ); - this.stableParentNodeId = commandMappings.getStableOrCurrentNodeId( - node.parentNodeId, - ); - this.stableChildNodeIds = childNodes.map( - (child) => commandMappings.getStableOrCurrentNodeId(child.nodeId)!, - ); - this.deletedSnapshot = cloneNodeSnapshot(node); - } - - private async deleteNode(options: { - moveView: boolean; - statusPrefix: string; - }) { - const resolvedNode = await getResolvedNodeForEdit( - this.layer, - this.stableDeletedNodeId, - this.stableSegmentId, - ); - const deleteContext = - await this.layer.getSpatialSkeletonDeleteOperationContext( - resolvedNode.node, - ); - const result = await resolvedNode.skeletonSource.deleteNode( - resolvedNode.node.nodeId, - { - childNodeIds: deleteContext.childNodes.map((child) => child.nodeId), - editContext: deleteContext.editContext, - }, - ); - applyDeleteNodeToCache( - this.layer, - deleteContext, - { moveView: options.moveView }, - result.nodeRevisionUpdates, - ); - resolvedNode.skeletonLayer.invalidateSourceCaches(); - StatusMessage.showTemporaryMessage( - `${options.statusPrefix} node ${resolvedNode.node.nodeId}.`, - ); - } - - private async restoreDeletedNode(statusPrefix: string) { - const { skeletonSource } = getEditableSkeletonSourceForLayer(this.layer); - const currentParentNode = - this.stableParentNodeId === undefined - ? undefined - : ( - await getResolvedNodeForEdit( - this.layer, - this.stableParentNodeId, - this.stableSegmentId, - ) - ).node; - const currentChildNodes = await Promise.all( - this.stableChildNodeIds.map((stableChildNodeId) => - getResolvedNodeForEdit( - this.layer, - stableChildNodeId, - this.stableSegmentId, - ).then((result) => result.node), - ), - ); - const createResult: - | SpatiallyIndexedSkeletonAddNodeResult - | SpatiallyIndexedSkeletonInsertNodeResult = - currentChildNodes.length === 0 - ? await skeletonSource.addNode( - currentParentNode?.segmentId ?? 0, - Number(this.deletedSnapshot.position[0]), - Number(this.deletedSnapshot.position[1]), - Number(this.deletedSnapshot.position[2]), - currentParentNode?.nodeId, - currentParentNode === undefined - ? undefined - : buildSpatiallyIndexedSkeletonNodeEditContext(currentParentNode), - ) - : await skeletonSource.insertNode( - currentParentNode?.segmentId ?? this.deletedSnapshot.segmentId, - Number(this.deletedSnapshot.position[0]), - Number(this.deletedSnapshot.position[1]), - Number(this.deletedSnapshot.position[2]), - currentParentNode?.nodeId ?? - (() => { - throw new Error( - "Delete-node undo is missing the parent node needed for insertion.", - ); - })(), - currentChildNodes.map((child) => child.nodeId), - buildInsertEditContext(currentParentNode!, currentChildNodes), - ); - this.layer.spatialSkeletonState.commandHistory.mappings.remapNodeId( - this.stableDeletedNodeId, - createResult.treenodeId, - ); - if (this.stableSegmentId === undefined) { - this.stableSegmentId = createResult.skeletonId; - } else { - this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( - this.stableSegmentId, - createResult.skeletonId, - ); - } - const restoredNode: SpatiallyIndexedSkeletonNode = { - nodeId: createResult.treenodeId, - segmentId: createResult.skeletonId, - position: new Float32Array(this.deletedSnapshot.position), - parentNodeId: currentParentNode?.nodeId, - revisionToken: createResult.revisionToken, - radius: undefined, - confidence: undefined, - description: undefined, - isTrueEnd: false, - }; - this.layer.spatialSkeletonState.upsertCachedNode(restoredNode, { - allowUncachedSegment: currentParentNode === undefined, - }); - for (const childNode of currentChildNodes) { - this.layer.spatialSkeletonState.setCachedNodeParent( - childNode.nodeId, - restoredNode.nodeId, - ); - } - if (createResult.parentRevisionToken !== undefined && currentParentNode) { - this.layer.spatialSkeletonState.setCachedNodeRevision( - currentParentNode.nodeId, - createResult.parentRevisionToken, - ); - } - if (createResult.nodeRevisionUpdates?.length) { - this.layer.spatialSkeletonState.setCachedNodeRevisions( - createResult.nodeRevisionUpdates, - ); - } - const restoredNodeWithAttributes = await restoreNodeAttributes( - this.layer, - skeletonSource, - restoredNode, - this.deletedSnapshot, - ); - ensureVisibleSegment(this.layer, restoredNodeWithAttributes.segmentId); - this.layer.selectSpatialSkeletonNode( - restoredNodeWithAttributes.nodeId, - this.layer.manager.root.selectionState.pin.value, - { - segmentId: restoredNodeWithAttributes.segmentId, - position: restoredNodeWithAttributes.position, - }, - ); - this.layer.markSpatialSkeletonNodeDataChanged({ - invalidateFullSkeletonCache: false, - }); - StatusMessage.showTemporaryMessage( - `${statusPrefix} node ${restoredNodeWithAttributes.nodeId}.`, - ); - } - - execute() { - return this.deleteNode({ - moveView: true, - statusPrefix: "Deleted", - }); - } - - undo() { - return this.restoreDeletedNode("Restored"); - } - - redo() { - return this.deleteNode({ - moveView: false, - statusPrefix: "Redid deletion of", - }); - } -} - -class NodeDescriptionCommand implements SpatialSkeletonCommand { - readonly label = "Edit node description"; - - constructor( - private layer: SegmentationUserLayer, - private stableNodeId: number, - private stableSegmentId: number | undefined, - private beforeDescription: string | undefined, - private afterDescription: string | undefined, - ) {} - - private async applyDescription( - nextDescription: string | undefined, - statusPrefix: string, - ) { - const { node, skeletonSource } = await getResolvedNodeForEdit( - this.layer, - this.stableNodeId, - this.stableSegmentId, - ); - if (node.description === nextDescription) { - return; - } - const result = await skeletonSource.updateDescription( - node.nodeId, - nextDescription ?? "", - ); - this.layer.spatialSkeletonState.updateCachedNode( - node.nodeId, - (candidate) => { - if (candidate.description === result.description) { - return candidate; - } - return { - ...candidate, - description: result.description, - }; - }, - ); - if (result.revisionToken !== undefined) { - this.layer.spatialSkeletonState.setCachedNodeRevision( - node.nodeId, - result.revisionToken, - ); - } - this.layer.markSpatialSkeletonNodeDataChanged({ - invalidateFullSkeletonCache: false, - }); - StatusMessage.showTemporaryMessage( - `${statusPrefix} node ${node.nodeId} description.`, - ); - } - - execute() { - return this.applyDescription(this.afterDescription, "Updated"); - } - - undo() { - return this.applyDescription( - this.beforeDescription, - "Undid description update for", - ); - } - - redo() { - return this.applyDescription( - this.afterDescription, - "Redid description update for", - ); - } -} - -class NodeTrueEndCommand implements SpatialSkeletonCommand { - readonly label = "Edit node true end state"; - - constructor( - private layer: SegmentationUserLayer, - private stableNodeId: number, - private stableSegmentId: number | undefined, - private beforeIsTrueEnd: boolean, - private afterIsTrueEnd: boolean, - ) {} - - private async applyTrueEnd(nextIsTrueEnd: boolean, statusPrefix: string) { - const { node, skeletonSource } = await getResolvedNodeForEdit( - this.layer, - this.stableNodeId, - this.stableSegmentId, - ); - if (node.isTrueEnd === nextIsTrueEnd) { - return; - } - const result = nextIsTrueEnd - ? await skeletonSource.setTrueEnd(node.nodeId) - : await skeletonSource.removeTrueEnd(node.nodeId); - this.layer.spatialSkeletonState.updateCachedNode( - node.nodeId, - (candidate) => { - if (candidate.isTrueEnd === nextIsTrueEnd) { - return candidate; - } - return { - ...candidate, - isTrueEnd: nextIsTrueEnd, - }; - }, - ); - if (result.revisionToken !== undefined) { - this.layer.spatialSkeletonState.setCachedNodeRevision( - node.nodeId, - result.revisionToken, - ); - } - this.layer.markSpatialSkeletonNodeDataChanged({ - invalidateFullSkeletonCache: false, - }); - StatusMessage.showTemporaryMessage( - `${statusPrefix} node ${node.nodeId} true end state.`, - ); - } - - execute() { - return this.applyTrueEnd(this.afterIsTrueEnd, "Updated"); - } - - undo() { - return this.applyTrueEnd(this.beforeIsTrueEnd, "Undid true end update for"); - } - - redo() { - return this.applyTrueEnd(this.afterIsTrueEnd, "Redid true end update for"); - } -} - -class NodePropertiesCommand implements SpatialSkeletonCommand { - readonly label = "Edit node properties"; - - constructor( - private layer: SegmentationUserLayer, - private stableNodeId: number, - private stableSegmentId: number | undefined, - private before: { radius: number; confidence: number }, - private after: { radius: number; confidence: number }, - ) {} - - private async applyProperties( - next: { radius: number; confidence: number }, - statusPrefix: string, - ) { - const { node, skeletonSource } = await getResolvedNodeForEdit( - this.layer, - this.stableNodeId, - this.stableSegmentId, - ); - let currentNode = cloneNodeSnapshot(node); - if (currentNode.radius !== next.radius) { - const radiusResult = await skeletonSource.updateRadius( - node.nodeId, - next.radius, - buildSpatiallyIndexedSkeletonNodeEditContext(currentNode), - ); - currentNode = { - ...currentNode, - radius: next.radius, - revisionToken: radiusResult.revisionToken ?? currentNode.revisionToken, - }; - } - if (currentNode.confidence !== next.confidence) { - if (currentNode.revisionToken === undefined) { - throw new Error( - `Node ${node.nodeId} is missing revision metadata required to update confidence.`, - ); - } - const confidenceResult = await skeletonSource.updateConfidence( - node.nodeId, - next.confidence, - buildSpatiallyIndexedSkeletonNodeEditContext(currentNode), - ); - currentNode = { - ...currentNode, - confidence: next.confidence, - revisionToken: - confidenceResult.revisionToken ?? currentNode.revisionToken, - }; - } - this.layer.spatialSkeletonState.setNodeProperties(node.nodeId, next); - if (currentNode.revisionToken !== undefined) { - this.layer.spatialSkeletonState.setCachedNodeRevision( - node.nodeId, - currentNode.revisionToken, - ); - } - this.layer.markSpatialSkeletonNodeDataChanged({ - invalidateFullSkeletonCache: false, - }); - StatusMessage.showTemporaryMessage( - `${statusPrefix} node ${node.nodeId} properties.`, - ); - } - - execute() { - return this.applyProperties(this.after, "Updated"); - } - - undo() { - return this.applyProperties(this.before, "Undid property update for"); - } - - redo() { - return this.applyProperties(this.after, "Redid property update for"); - } -} - -class RerootCommand implements SpatialSkeletonCommand { - readonly label = "Reroot skeleton"; - - constructor( - private layer: SegmentationUserLayer, - private stableNodeId: number, - private stableSegmentId: number | undefined, - private stablePreviousRootNodeId: number, - ) {} - - private async rerootAt(stableTargetNodeId: number, statusPrefix: string) { - const resolvedNode = await getResolvedNodeForEdit( - this.layer, - stableTargetNodeId, - this.stableSegmentId, - ); - if (resolvedNode.node.parentNodeId === undefined) { - return; - } - if (resolvedNode.skeletonSource.rerootSkeleton === undefined) { - throw new Error( - "Unable to resolve a reroot-capable skeleton source for the active layer.", - ); - } - const result = await resolvedNode.skeletonSource.rerootSkeleton( - resolvedNode.node.nodeId, - buildSpatiallyIndexedSkeletonRerootEditContext( - resolvedNode.node, - resolvedNode.segmentNodes, - ), - ); - this.layer.spatialSkeletonState.rerootCachedSegment( - resolvedNode.node.nodeId, - ); - if ( - result.nodeRevisionUpdates !== undefined && - result.nodeRevisionUpdates.length > 0 - ) { - this.layer.spatialSkeletonState.setCachedNodeRevisions( - result.nodeRevisionUpdates, - ); - } - this.layer.selectSpatialSkeletonNode( - resolvedNode.node.nodeId, - this.layer.manager.root.selectionState.pin.value, - { - segmentId: resolvedNode.node.segmentId, - position: resolvedNode.node.position, - }, - ); - this.layer.markSpatialSkeletonNodeDataChanged({ - invalidateFullSkeletonCache: false, - }); - StatusMessage.showTemporaryMessage( - `${statusPrefix} node ${resolvedNode.node.nodeId} as root.`, - ); - } - - execute() { - return this.rerootAt(this.stableNodeId, "Set"); - } - - undo() { - return this.rerootAt(this.stablePreviousRootNodeId, "Undid reroot for"); - } - - redo() { - return this.rerootAt(this.stableNodeId, "Redid reroot for"); - } -} - -class SplitCommand implements SpatialSkeletonCommand { - readonly label = "Split skeleton"; - private stableNewSegmentId: number | undefined; - - constructor( - private layer: SegmentationUserLayer, - private stableNodeId: number, - private stableSegmentId: number | undefined, - private stableFormerParentNodeId: number | undefined, - ) {} - - private async split(statusPrefix: string) { - const resolvedNode = await getResolvedNodeForEdit( - this.layer, - this.stableNodeId, - this.stableSegmentId, - ); - let result: SpatiallyIndexedSkeletonSplitResult; - try { - result = await resolvedNode.skeletonSource.splitSkeleton( - resolvedNode.node.nodeId, - buildSpatiallyIndexedSkeletonNeighborhoodEditContext( - resolvedNode.node, - resolvedNode.segmentNodes, - ), - ); - } catch (error) { - await refreshTopologySegments(this.layer, [resolvedNode.node.segmentId]); - throw error; - } - const newSkeletonId = result.newSkeletonId; - const existingSkeletonId = - result.existingSkeletonId ?? resolvedNode.node.segmentId; - if (newSkeletonId === undefined) { - throw new Error( - "The active skeleton source did not return a new skeleton id for the split.", - ); - } - if (this.stableNewSegmentId === undefined) { - this.stableNewSegmentId = newSkeletonId; - } else { - this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( - this.stableNewSegmentId, - newSkeletonId, - ); - } - if (this.stableSegmentId !== undefined) { - this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( - this.stableSegmentId, - existingSkeletonId, - ); - } - ensureVisibleSegment(this.layer, existingSkeletonId); - ensureVisibleSegment(this.layer, newSkeletonId); - selectSegment(this.layer, newSkeletonId, true); - this.layer.selectSpatialSkeletonNode( - resolvedNode.node.nodeId, - this.layer.manager.root.selectionState.pin.value, - { - segmentId: newSkeletonId, - }, - ); - await refreshTopologySegments(this.layer, [ - existingSkeletonId, - newSkeletonId, - ]); - StatusMessage.showTemporaryMessage( - `${statusPrefix} skeleton ${existingSkeletonId}. New skeleton: ${newSkeletonId}.`, - ); - } - - private async mergeBack(statusPrefix: string) { - if (this.stableFormerParentNodeId === undefined) { - throw new Error("Split-node undo is missing the former parent node."); - } - const splitNode = await getResolvedNodeForEdit( - this.layer, - this.stableNodeId, - this.stableNewSegmentId ?? this.stableSegmentId, - ); - const formerParent = await getResolvedNodeForEdit( - this.layer, - this.stableFormerParentNodeId, - this.stableSegmentId, - ); - let result: SpatiallyIndexedSkeletonMergeResult; - try { - result = await formerParent.skeletonSource.mergeSkeletons( - formerParent.node.nodeId, - splitNode.node.nodeId, - buildSpatiallyIndexedSkeletonMultiNodeEditContext( - formerParent.node, - splitNode.node, - ), - ); - } catch (error) { - await refreshTopologySegments(this.layer, [ - splitNode.node.segmentId, - formerParent.node.segmentId, - ]); - throw error; - } - const resultSkeletonId = - result.resultSkeletonId ?? formerParent.node.segmentId; - const deletedSkeletonId = - result.deletedSkeletonId ?? - (resultSkeletonId === splitNode.node.segmentId - ? formerParent.node.segmentId - : splitNode.node.segmentId); - if (this.stableSegmentId !== undefined) { - this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( - this.stableSegmentId, - resultSkeletonId, - ); - } - if (this.stableNewSegmentId !== undefined) { - this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( - this.stableNewSegmentId, - resultSkeletonId, - ); - } - ensureVisibleSegment(this.layer, resultSkeletonId); - if (deletedSkeletonId !== resultSkeletonId) { - removeVisibleSegment(this.layer, deletedSkeletonId, { deselect: true }); - this.layer.displayState.segmentStatedColors.value.delete( - BigInt(deletedSkeletonId), - ); - splitNode.skeletonLayer.suppressBrowseSegment(deletedSkeletonId); - } - this.layer.selectSpatialSkeletonNode( - splitNode.node.nodeId, - this.layer.manager.root.selectionState.pin.value, - { - segmentId: resultSkeletonId, - }, - ); - await refreshTopologySegments(this.layer, [ - resultSkeletonId, - deletedSkeletonId, - ]); - StatusMessage.showTemporaryMessage( - `${statusPrefix} split at node ${splitNode.node.nodeId}.`, - ); - } - - execute() { - return this.split("Split"); - } - - undo() { - return this.mergeBack("Undid"); - } - - redo() { - return this.split("Redid split of"); - } -} - -class MergeCommand implements SpatialSkeletonCommand { - readonly label = "Merge skeletons"; - private stableResultSegmentId: number | undefined; - private stableDeletedSegmentId: number | undefined; - private stableAttachedNodeId: number | undefined; - private stableAttachedRootNodeId: number | undefined; - - constructor( - private layer: SegmentationUserLayer, - private stableFirstNodeId: number, - private stableFirstSegmentId: number | undefined, - private stableSecondNodeId: number, - private stableSecondSegmentId: number | undefined, - private secondNodeRevisionToken: string | undefined, - ) {} - - private async merge(statusPrefix: string) { - const firstNode = await getResolvedNodeForEdit( - this.layer, - this.stableFirstNodeId, - this.stableFirstSegmentId, - ); - const secondNodeContext = getResolvedNodeContextForEdit( - this.layer, - this.stableSecondNodeId, - this.stableSecondSegmentId, - ); - let secondNode: ResolvedSpatialSkeletonEditNode; - let preservedSecondRootNodeId: number | undefined; - const secondSegmentCached = - this.layer.spatialSkeletonState.getCachedSegmentNodes( - secondNodeContext.segmentId, - ) !== undefined; - const secondRevisionToken = - this.secondNodeRevisionToken ?? - secondNodeContext.cachedNode?.revisionToken; - if (secondSegmentCached || secondRevisionToken === undefined) { - secondNode = await getResolvedNodeForEdit( - this.layer, - this.stableSecondNodeId, - this.stableSecondSegmentId, - ); - } else { - preservedSecondRootNodeId = ( - await secondNodeContext.skeletonSource.getSkeletonRootNode( - secondNodeContext.segmentId, - ) - ).nodeId; - secondNode = { - skeletonLayer: secondNodeContext.skeletonLayer, - skeletonSource: secondNodeContext.skeletonSource, - segmentNodes: [], - node: { - nodeId: secondNodeContext.currentNodeId, - segmentId: secondNodeContext.segmentId, - position: new Float32Array(3), - parentNodeId: secondNodeContext.cachedNode?.parentNodeId, - isTrueEnd: secondNodeContext.cachedNode?.isTrueEnd ?? false, - revisionToken: secondRevisionToken, - }, - }; - } - let result: SpatiallyIndexedSkeletonMergeResult; - try { - result = await firstNode.skeletonSource.mergeSkeletons( - firstNode.node.nodeId, - secondNode.node.nodeId, - buildSpatiallyIndexedSkeletonMultiNodeEditContext( - firstNode.node, - secondNode.node, - ), - ); - } catch (error) { - await refreshTopologySegments(this.layer, [ - firstNode.node.segmentId, - secondNode.node.segmentId, - ]); - throw error; - } - const winningNode = - result.resultSkeletonId === secondNode.node.segmentId - ? secondNode.node - : firstNode.node; - const losingNode = - winningNode.nodeId === firstNode.node.nodeId - ? secondNode.node - : firstNode.node; - const resultSkeletonId = result.resultSkeletonId ?? winningNode.segmentId; - const deletedSkeletonId = result.deletedSkeletonId ?? losingNode.segmentId; - const attachedRootNodeId = - losingNode.segmentId === firstNode.node.segmentId - ? findRootNode(firstNode.segmentNodes)?.nodeId - : (preservedSecondRootNodeId ?? - findRootNode(secondNode.segmentNodes)?.nodeId); - this.stableAttachedNodeId = - this.stableAttachedNodeId ?? - this.layer.spatialSkeletonState.commandHistory.mappings.getStableOrCurrentNodeId( - losingNode.nodeId, - ); - this.stableAttachedRootNodeId = - this.stableAttachedRootNodeId ?? - this.layer.spatialSkeletonState.commandHistory.mappings.getStableOrCurrentNodeId( - attachedRootNodeId, - ); - this.stableResultSegmentId = - this.stableResultSegmentId ?? - this.layer.spatialSkeletonState.commandHistory.mappings.getStableOrCurrentSegmentId( - resultSkeletonId, - ); - this.stableDeletedSegmentId = - this.stableDeletedSegmentId ?? - this.layer.spatialSkeletonState.commandHistory.mappings.getStableOrCurrentSegmentId( - deletedSkeletonId, - ); - this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( - this.stableDeletedSegmentId, - resultSkeletonId, - ); - ensureVisibleSegment(this.layer, resultSkeletonId); - removeVisibleSegment(this.layer, deletedSkeletonId, { deselect: true }); - selectSegment(this.layer, resultSkeletonId, false); - this.layer.selectSpatialSkeletonNode( - losingNode.nodeId, - this.layer.manager.root.selectionState.pin.value, - { - segmentId: resultSkeletonId, - }, - ); - this.layer.displayState.segmentStatedColors.value.delete( - BigInt(deletedSkeletonId), - ); - if (deletedSkeletonId !== resultSkeletonId) { - firstNode.skeletonLayer.suppressBrowseSegment(deletedSkeletonId); - } - this.layer.clearSpatialSkeletonMergeAnchor(); - await refreshTopologySegments(this.layer, [ - resultSkeletonId, - deletedSkeletonId, - ]); - const swapSuffix = result.stableAnnotationSwap - ? " Merge direction was adjusted by the active source." - : ""; - StatusMessage.showTemporaryMessage( - `${statusPrefix} skeleton ${deletedSkeletonId} into ${resultSkeletonId}.${swapSuffix}`, - ); - } - - private async undoMerge(statusPrefix: string) { - if (this.stableAttachedNodeId === undefined) { - throw new Error("Merge undo is missing the attached node id."); - } - if (this.stableDeletedSegmentId === undefined) { - throw new Error("Merge undo is missing the deleted skeleton id."); - } - const attachedNode = await getResolvedNodeForEdit( - this.layer, - this.stableAttachedNodeId, - this.stableResultSegmentId ?? this.stableFirstSegmentId, - ); - let splitResult: SpatiallyIndexedSkeletonSplitResult; - try { - splitResult = await attachedNode.skeletonSource.splitSkeleton( - attachedNode.node.nodeId, - buildSpatiallyIndexedSkeletonNeighborhoodEditContext( - attachedNode.node, - attachedNode.segmentNodes, - ), - ); - } catch (error) { - await refreshTopologySegments(this.layer, [attachedNode.node.segmentId]); - throw error; - } - const restoredSegmentId = - splitResult.newSkeletonId ?? - (() => { - throw new Error( - "The active skeleton source did not return a new skeleton id for merge undo.", - ); - })(); - this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( - this.stableDeletedSegmentId, - restoredSegmentId, - ); - const survivingSegmentId = - splitResult.existingSkeletonId ?? attachedNode.node.segmentId; - ensureVisibleSegment(this.layer, survivingSegmentId); - ensureVisibleSegment(this.layer, restoredSegmentId); - await refreshTopologySegments(this.layer, [ - survivingSegmentId, - restoredSegmentId, - ]); - let rerootWarning: string | undefined; - if ( - this.stableAttachedRootNodeId !== undefined && - this.stableAttachedRootNodeId !== this.stableAttachedNodeId - ) { - try { - const restoredRoot = await getResolvedNodeForEdit( - this.layer, - this.stableAttachedRootNodeId, - this.stableDeletedSegmentId, - ); - if (restoredRoot.node.parentNodeId !== undefined) { - if (restoredRoot.skeletonSource.rerootSkeleton === undefined) { - throw new Error( - "The active skeleton source does not support reroot.", - ); - } - await restoredRoot.skeletonSource.rerootSkeleton( - restoredRoot.node.nodeId, - buildSpatiallyIndexedSkeletonRerootEditContext( - restoredRoot.node, - restoredRoot.segmentNodes, - ), - ); - await refreshTopologySegments(this.layer, [ - survivingSegmentId, - restoredSegmentId, - ]); - } - } catch (error) { - await refreshTopologySegments(this.layer, [ - survivingSegmentId, - restoredSegmentId, - ]); - rerootWarning = - `Undo split the merged skeletons, but failed to reroot the restored skeleton. ` + - `Only the split completed. ${formatErrorMessage(error)}`; - } - } - this.layer.selectSpatialSkeletonNode( - attachedNode.node.nodeId, - this.layer.manager.root.selectionState.pin.value, - { - segmentId: restoredSegmentId, - }, - ); - StatusMessage.showTemporaryMessage( - rerootWarning ?? - `${statusPrefix} merge involving node ${attachedNode.node.nodeId}.`, - ); - } - - execute() { - return this.merge("Merged"); - } - - undo() { - return this.undoMerge("Undid"); - } - - redo() { - return this.merge("Redid merge of"); - } -} - -export function executeSpatialSkeletonAddNode( +export function executeSpatialSkeletonInsertNode( layer: SegmentationUserLayer, - options: { - skeletonId: number; - parentNodeId: number | undefined; - // Callers must convert viewer/global coordinates to skeleton model space. - positionInModelSpace: Float32Array; - }, + options: SpatialSkeletonCommandPayload, ) { - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const command = new AddNodeCommand( + const command = createSpatialSkeletonCommand( layer, - commandMappings.getStableOrCurrentNodeId(options.parentNodeId), - commandMappings.getStableOrCurrentSegmentId(options.skeletonId) ?? - options.skeletonId, - new Float32Array(options.positionInModelSpace), + SpatialSkeletonActions.insertNodes, + options, + "The active skeleton source does not support node insertion.", ); - return executeSpatialSkeletonCommandWithPendingMessage( - layer.spatialSkeletonState.commandHistory.execute(command), - "Creating node...", + return executeCommandWithPendingMessage( + executeCommand(layer, command), + "Inserting node...", ); } export function executeSpatialSkeletonMoveNode( layer: SegmentationUserLayer, - options: { - node: SpatiallyIndexedSkeletonNode; - // Callers must convert viewer/global coordinates to skeleton model space. - nextPositionInModelSpace: Float32Array; - }, + options: SpatialSkeletonCommandPayload, ) { - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const command = new MoveNodeCommand( + const command = createSpatialSkeletonCommand( layer, - commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, - commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), - new Float32Array(options.node.position), - new Float32Array(options.nextPositionInModelSpace), + SpatialSkeletonActions.moveNodes, + options, + "The active skeleton source does not support node movement.", ); - return layer.spatialSkeletonState.commandHistory.execute(command); + return executeCommand(layer, command); } export function executeSpatialSkeletonDeleteNode( layer: SegmentationUserLayer, node: SpatiallyIndexedSkeletonNode, ) { - const segmentNodes = layer.getCachedSpatialSkeletonSegmentNodesForEdit( - node.segmentId, - ); - const refreshedNode = findSpatiallyIndexedSkeletonNode( - segmentNodes, - node.nodeId, - ); - if (refreshedNode === undefined) { - throw new Error( - `Node ${node.nodeId} is not available in the inspected skeleton cache.`, - ); - } - const childNodes = getSpatiallyIndexedSkeletonDirectChildren( - segmentNodes, - refreshedNode.nodeId, + const command = createSpatialSkeletonCommand( + layer, + SpatialSkeletonActions.deleteNodes, + node, + "The active skeleton source does not support node deletion.", ); - const command = new DeleteNodeCommand(layer, refreshedNode, childNodes); - return executeSpatialSkeletonCommandWithPendingMessage( - layer.spatialSkeletonState.commandHistory.execute(command), + return executeCommandWithPendingMessage( + executeCommand(layer, command), "Deleting node...", ); } export function executeSpatialSkeletonNodeDescriptionUpdate( layer: SegmentationUserLayer, - options: { - node: SpatiallyIndexedSkeletonNode; - nextDescription?: string; - }, + options: SpatialSkeletonCommandPayload, ) { - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const command = new NodeDescriptionCommand( + const command = createSpatialSkeletonCommand( layer, - commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, - commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), - options.node.description, - options.nextDescription ?? options.node.description, + SpatialSkeletonActions.editNodeDescription, + options, + "The active skeleton source does not support node description editing.", ); - return layer.spatialSkeletonState.commandHistory.execute(command); + return executeCommand(layer, command); } export function executeSpatialSkeletonNodeTrueEndUpdate( layer: SegmentationUserLayer, - options: { - node: SpatiallyIndexedSkeletonNode; - nextIsTrueEnd: boolean; - }, + options: SpatialSkeletonCommandPayload, ) { - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const command = new NodeTrueEndCommand( + const command = createSpatialSkeletonCommand( layer, - commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, - commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), - options.node.isTrueEnd, - options.nextIsTrueEnd, + SpatialSkeletonActions.editNodeTrueEnd, + options, + "The active skeleton source does not support node true-end editing.", ); - return layer.spatialSkeletonState.commandHistory.execute(command); + return executeCommand(layer, command); } -export function executeSpatialSkeletonNodePropertiesUpdate( +export function executeSpatialSkeletonNodeRadiusUpdate( layer: SegmentationUserLayer, - options: { - node: SpatiallyIndexedSkeletonNode; - next: { radius: number; confidence: number }; - }, + options: SpatialSkeletonCommandPayload, ) { - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const command = new NodePropertiesCommand( + const command = createSpatialSkeletonCommand( layer, - commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, - commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), - { - radius: options.node.radius ?? 0, - confidence: options.node.confidence ?? 0, - }, - options.next, + SpatialSkeletonActions.editNodeRadius, + options, + "The active skeleton source does not support node radius editing.", ); - return layer.spatialSkeletonState.commandHistory.execute(command); + return executeCommand(layer, command); } -export function executeSpatialSkeletonReroot( +export function executeSpatialSkeletonNodeConfidenceUpdate( layer: SegmentationUserLayer, - node: Pick< - SpatiallyIndexedSkeletonNode, - "nodeId" | "segmentId" | "parentNodeId" - >, + options: SpatialSkeletonCommandPayload, ) { - const segmentNodes = layer.getCachedSpatialSkeletonSegmentNodesForEdit( - node.segmentId, + const command = createSpatialSkeletonCommand( + layer, + SpatialSkeletonActions.editNodeConfidence, + options, + "The active skeleton source does not support node confidence editing.", ); - const rootNode = - findRootNode(segmentNodes) ?? - (() => { - throw new Error( - `Unable to resolve the current root for segment ${node.segmentId}.`, - ); - })(); - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const command = new RerootCommand( + return executeCommand(layer, command); +} + +export function executeSpatialSkeletonReroot( + layer: SegmentationUserLayer, + node: SpatialSkeletonCommandPayload, +) { + const command = createSpatialSkeletonCommand( layer, - commandMappings.getStableOrCurrentNodeId(node.nodeId)!, - commandMappings.getStableOrCurrentSegmentId(node.segmentId), - commandMappings.getStableOrCurrentNodeId(rootNode.nodeId)!, + SpatialSkeletonActions.reroot, + node, + "The active skeleton source does not support skeleton rerooting.", ); - return layer.spatialSkeletonState.commandHistory.execute(command); + return executeCommand(layer, command); } export function executeSpatialSkeletonSplit( layer: SegmentationUserLayer, - node: Pick, + node: SpatialSkeletonCommandPayload, ) { - const segmentNodes = layer.getCachedSpatialSkeletonSegmentNodesForEdit( - node.segmentId, - ); - const splitNode = findSpatiallyIndexedSkeletonNode(segmentNodes, node.nodeId); - if (splitNode === undefined) { - throw new Error( - `Node ${node.nodeId} is not available in the inspected skeleton cache.`, - ); - } - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const command = new SplitCommand( + const command = createSpatialSkeletonCommand( layer, - commandMappings.getStableOrCurrentNodeId(splitNode.nodeId)!, - commandMappings.getStableOrCurrentSegmentId(splitNode.segmentId), - commandMappings.getStableOrCurrentNodeId(splitNode.parentNodeId), + SpatialSkeletonActions.splitSkeletons, + node, + "The active skeleton source does not support skeleton splitting.", ); - return executeSpatialSkeletonCommandWithPendingMessage( - layer.spatialSkeletonState.commandHistory.execute(command), + return executeCommandWithPendingMessage( + executeCommand(layer, command), "Splitting skeleton...", ); } -interface SpatialSkeletonMergeEndpoint { - nodeId: number; - segmentId: number; - revisionToken?: string; -} - -function executeSpatialSkeletonCommandWithPendingMessage( - promise: Promise, - message: string, -) { - const status = StatusMessage.showMessage(message); - return promise.finally(() => status.dispose()); -} - export function executeSpatialSkeletonMerge( layer: SegmentationUserLayer, - firstNode: SpatialSkeletonMergeEndpoint, - secondNode: SpatialSkeletonMergeEndpoint, + firstNode: SpatialSkeletonCommandPayload, + secondNode: SpatialSkeletonCommandPayload, ) { - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const command = new MergeCommand( + const command = createSpatialSkeletonCommand( layer, - commandMappings.getStableOrCurrentNodeId(firstNode.nodeId)!, - commandMappings.getStableOrCurrentSegmentId(firstNode.segmentId), - commandMappings.getStableOrCurrentNodeId(secondNode.nodeId)!, - commandMappings.getStableOrCurrentSegmentId(secondNode.segmentId), - secondNode.revisionToken, + SpatialSkeletonActions.mergeSkeletons, + { firstNode, secondNode }, + "The active skeleton source does not support skeleton merging.", ); - return executeSpatialSkeletonCommandWithPendingMessage( - layer.spatialSkeletonState.commandHistory.execute(command), + return executeCommandWithPendingMessage( + executeCommand(layer, command), "Merging skeletons...", ); } diff --git a/src/layer/segmentation/spatial_skeleton_errors.ts b/src/layer/segmentation/spatial_skeleton_errors.ts index c5e43e9dc..c1ec4c4da 100644 --- a/src/layer/segmentation/spatial_skeleton_errors.ts +++ b/src/layer/segmentation/spatial_skeleton_errors.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { CatmaidStateValidationError } from "#src/datasource/catmaid/api.js"; +import { SpatialSkeletonEditConflictError } from "#src/skeleton/edit_errors.js"; import { StatusMessage } from "#src/status.js"; function formatError(error: unknown) { @@ -22,7 +22,7 @@ function formatError(error: unknown) { } export function isSpatialSkeletonOutdatedStateError(error: unknown) { - return error instanceof CatmaidStateValidationError; + return error instanceof SpatialSkeletonEditConflictError; } export function showSpatialSkeletonActionError(action: string, error: unknown) { diff --git a/src/rendered_data_panel.ts b/src/rendered_data_panel.ts index bab65e882..33ff9b320 100644 --- a/src/rendered_data_panel.ts +++ b/src/rendered_data_panel.ts @@ -32,6 +32,7 @@ import { clearOutOfBoundsPickData, getPickDiameter, } from "#src/rendered_data_panel_picking.js"; +import type { SpatialSkeletonSourceState } from "#src/skeleton/api.js"; import { StatusMessage } from "#src/status.js"; import type { TrackableValue } from "#src/trackable_value.js"; import { AutomaticallyFocusedElement } from "#src/util/automatic_focus.js"; @@ -65,7 +66,7 @@ interface SpatialSkeletonSelectableLayer { options?: { segmentId?: number; position?: ArrayLike; - revisionToken?: string; + sourceState?: SpatialSkeletonSourceState; }, ) => void; clearSpatialSkeletonNodeSelection: ( @@ -539,7 +540,7 @@ export abstract class RenderedDataPanel extends RenderedPanel { ? pickedSegmentId : undefined, position: pickedSpatialSkeleton?.position ?? mouseState.position, - revisionToken: pickedSpatialSkeleton?.revisionToken, + sourceState: pickedSpatialSkeleton?.sourceState, }; }; @@ -570,7 +571,7 @@ export abstract class RenderedDataPanel extends RenderedPanel { { segmentId: pickedSelection.segmentId, position: pickedSelection.position, - revisionToken: pickedSelection.revisionToken, + sourceState: pickedSelection.sourceState, }, ); return; diff --git a/src/skeleton/actions.ts b/src/skeleton/actions.ts index a21efe2e0..e278567de 100644 --- a/src/skeleton/actions.ts +++ b/src/skeleton/actions.ts @@ -23,7 +23,8 @@ export const SpatialSkeletonActions = { reroot: "rerootSkeletons", editNodeDescription: "editNodeDescription", editNodeTrueEnd: "editNodeTrueEnd", - editNodeProperties: "editNodeProperties", + editNodeRadius: "editNodeRadius", + editNodeConfidence: "editNodeConfidence", mergeSkeletons: "mergeSkeletons", splitSkeletons: "splitSkeletons", } as const; @@ -57,8 +58,10 @@ export function getSpatialSkeletonActionSupportLabel( return "node description editing"; case SpatialSkeletonActions.editNodeTrueEnd: return "node true-end editing"; - case SpatialSkeletonActions.editNodeProperties: - return "node property editing"; + case SpatialSkeletonActions.editNodeRadius: + return "node radius editing"; + case SpatialSkeletonActions.editNodeConfidence: + return "node confidence editing"; case SpatialSkeletonActions.mergeSkeletons: return "skeleton merging"; case SpatialSkeletonActions.splitSkeletons: diff --git a/src/skeleton/api.ts b/src/skeleton/api.ts index 515ff9ed2..cc04440b5 100644 --- a/src/skeleton/api.ts +++ b/src/skeleton/api.ts @@ -14,12 +14,52 @@ * limitations under the License. */ +import type { + SpatialSkeletonAddNodesCommandFactory, + SpatialSkeletonDeleteNodesCommandFactory, + SpatialSkeletonEditNodeDescriptionCommandFactory, + SpatialSkeletonEditNodeConfidenceCommandFactory, + SpatialSkeletonEditNodeRadiusCommandFactory, + SpatialSkeletonEditNodeTrueEndCommandFactory, + SpatialSkeletonInsertNodesCommandFactory, + SpatialSkeletonMergeSkeletonsCommandFactory, + SpatialSkeletonMoveNodesCommandFactory, + SpatialSkeletonRerootCommandFactory, + SpatialSkeletonSplitSkeletonsCommandFactory, +} from "#src/skeleton/edit_command_source.js"; + +export type SpatialSkeletonVector = ArrayLike; + +// Provider-specific node state that crosses the worker boundary must remain structured-cloneable. +export type SpatialSkeletonSourceState = + | null + | boolean + | number + | string + | readonly SpatialSkeletonSourceState[] + | { readonly [key: string]: SpatialSkeletonSourceState }; + +export interface SpatialSkeletonBounds { + lowerBounds: SpatialSkeletonVector; + upperBounds: SpatialSkeletonVector; +} + +export interface SpatialSkeletonGridCellIndex { + cell: SpatialSkeletonVector; +} + +export interface SpatialSkeletonSpatialIndexLevel { + chunkSize: SpatialSkeletonVector; + gridShape: readonly number[]; + limit: number; +} + export interface SpatiallyIndexedSkeletonNodeBase { nodeId: number; segmentId: number; - position: Float32Array; + position: SpatialSkeletonVector; parentNodeId?: number; - revisionToken?: string; + sourceState?: SpatialSkeletonSourceState; } export interface SpatiallyIndexedSkeletonNode @@ -27,106 +67,21 @@ export interface SpatiallyIndexedSkeletonNode radius?: number; confidence?: number; description?: string; - isTrueEnd: boolean; -} - -export interface SpatiallyIndexedSkeletonOpenLeaf { - nodeId: number; - x: number; - y: number; - z: number; - distance: number; - creationTime?: string; -} - -export interface SpatiallyIndexedSkeletonNavigationTarget { - nodeId: number; - x: number; - y: number; - z: number; -} - -export interface SpatiallyIndexedSkeletonNodeRevisionUpdate { - nodeId: number; - revisionToken: string; -} - -export interface SpatiallyIndexedSkeletonEditResult { - nodeRevisionUpdates?: readonly SpatiallyIndexedSkeletonNodeRevisionUpdate[]; -} - -export interface SpatiallyIndexedSkeletonAddNodeResult - extends SpatiallyIndexedSkeletonEditResult { - treenodeId: number; - skeletonId: number; - revisionToken?: string; - parentRevisionToken?: string; -} - -export type SpatiallyIndexedSkeletonInsertNodeResult = - SpatiallyIndexedSkeletonAddNodeResult; - -export interface SpatiallyIndexedSkeletonNodeRevisionResult - extends SpatiallyIndexedSkeletonEditResult { - revisionToken?: string; -} - -export interface SpatiallyIndexedSkeletonDescriptionUpdateResult - extends SpatiallyIndexedSkeletonNodeRevisionResult { - description?: string; -} - -export type SpatiallyIndexedSkeletonDeleteNodeResult = - SpatiallyIndexedSkeletonEditResult; - -export type SpatiallyIndexedSkeletonRerootResult = - SpatiallyIndexedSkeletonEditResult; - -export interface SpatiallyIndexedSkeletonEditNodeContext { - nodeId: number; - parentNodeId?: number; - revisionToken: string; -} - -export interface SpatiallyIndexedSkeletonEditParentContext { - nodeId: number; - revisionToken: string; + isTrueEnd?: boolean; } -export interface SpatiallyIndexedSkeletonEditContext { - node?: SpatiallyIndexedSkeletonEditNodeContext; - parent?: SpatiallyIndexedSkeletonEditParentContext; - children?: readonly SpatiallyIndexedSkeletonEditParentContext[]; - nodes?: readonly SpatiallyIndexedSkeletonEditParentContext[]; +export interface SpatiallyIndexedSkeletonMetadata + extends SpatialSkeletonBounds { + spatial: readonly SpatialSkeletonSpatialIndexLevel[]; + readOnly: boolean; } -export interface SpatiallyIndexedSkeletonMergeResult - extends SpatiallyIndexedSkeletonEditResult { - resultSkeletonId: number | undefined; - deletedSkeletonId: number | undefined; - stableAnnotationSwap: boolean; +export interface SpatialSkeletonConfidenceConfiguration { + values: readonly number[]; } -export interface SpatiallyIndexedSkeletonSplitResult - extends SpatiallyIndexedSkeletonEditResult { - existingSkeletonId: number | undefined; - newSkeletonId: number | undefined; -} - -export interface SpatiallyIndexedSkeletonMetadata { - bounds: { - min: { x: number; y: number; z: number }; - max: { x: number; y: number; z: number }; - }; - resolution: { x: number; y: number; z: number }; - gridCellSizes: Array<{ x: number; y: number; z: number }>; -} - -export const SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES = [ - 0, 25, 50, 75, 100, -] as const; - export interface SpatiallyIndexedSkeletonSource { + readonly readOnly: boolean; listSkeletons(): Promise; getSkeleton( skeletonId: number, @@ -134,13 +89,8 @@ export interface SpatiallyIndexedSkeletonSource { ): Promise; getSpatialIndexMetadata(): Promise; fetchNodes( - boundingBox: { - min: { x: number; y: number; z: number }; - max: { x: number; y: number; z: number }; - }, - lod?: number, + cellIndex: SpatialSkeletonGridCellIndex, options?: { - cacheProvider?: string; signal?: AbortSignal; }, ): Promise; @@ -148,71 +98,17 @@ export interface SpatiallyIndexedSkeletonSource { export interface EditableSpatiallyIndexedSkeletonSource extends SpatiallyIndexedSkeletonSource { - getSkeletonRootNode( - skeletonId: number, - ): Promise; - addNode( - skeletonId: number, - x: number, - y: number, - z: number, - parentId?: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise; - insertNode( - skeletonId: number, - x: number, - y: number, - z: number, - parentId: number, - childNodeIds: readonly number[], - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise; - moveNode( - nodeId: number, - x: number, - y: number, - z: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise; - deleteNode( - nodeId: number, - options: { - childNodeIds?: readonly number[]; - editContext?: SpatiallyIndexedSkeletonEditContext; - }, - ): Promise; - rerootSkeleton?( - nodeId: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise; - updateDescription( - nodeId: number, - description: string, - ): Promise; - setTrueEnd( - nodeId: number, - ): Promise; - removeTrueEnd( - nodeId: number, - ): Promise; - updateRadius( - nodeId: number, - radius: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise; - updateConfidence( - nodeId: number, - confidence: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise; - mergeSkeletons( - fromNodeId: number, - toNodeId: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise; - splitSkeleton( - nodeId: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise; + readonly readOnly: false; + readonly addNodesCommand: SpatialSkeletonAddNodesCommandFactory; + readonly deleteNodesCommand: SpatialSkeletonDeleteNodesCommandFactory; + readonly moveNodesCommand: SpatialSkeletonMoveNodesCommandFactory; + readonly splitSkeletonsCommand: SpatialSkeletonSplitSkeletonsCommandFactory; + readonly mergeSkeletonsCommand: SpatialSkeletonMergeSkeletonsCommandFactory; + readonly insertNodesCommand?: SpatialSkeletonInsertNodesCommandFactory; + readonly rerootCommand?: SpatialSkeletonRerootCommandFactory; + readonly editNodeDescriptionCommand?: SpatialSkeletonEditNodeDescriptionCommandFactory; + readonly editNodeTrueEndCommand?: SpatialSkeletonEditNodeTrueEndCommandFactory; + readonly editNodeRadiusCommand?: SpatialSkeletonEditNodeRadiusCommandFactory; + readonly editNodeConfidenceCommand?: SpatialSkeletonEditNodeConfidenceCommandFactory; + readonly spatialSkeletonConfidenceConfiguration?: SpatialSkeletonConfidenceConfiguration; } diff --git a/src/skeleton/backend.spec.ts b/src/skeleton/backend.spec.ts index a7c64e0c9..f67d8e315 100644 --- a/src/skeleton/backend.spec.ts +++ b/src/skeleton/backend.spec.ts @@ -1,12 +1,6 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; -import { ChunkState } from "#src/chunk_manager/base.js"; -import { - cancelStaleSpatiallyIndexedSkeletonDownloads, - getSpatiallyIndexedSkeletonChunkPriority, - markSpatiallyIndexedSkeletonChunkRequested, - SpatiallyIndexedSkeletonChunkRequestOwner, -} from "#src/skeleton/backend.js"; +import { getSpatiallyIndexedSkeletonChunkPriority } from "#src/skeleton/backend.js"; describe("skeleton/backend chunk priority", () => { it("uses the standard chunk-origin distance rule for 3d chunks", () => { @@ -40,153 +34,3 @@ describe("skeleton/backend chunk priority", () => { ); }); }); - -describe("skeleton/backend stale LOD cancellation", () => { - function makeChunk(state = ChunkState.DOWNLOADING) { - return { - state, - requestGeneration: -1, - requestOwners: SpatiallyIndexedSkeletonChunkRequestOwner.NONE, - downloadAbortController: new AbortController(), - } as any; - } - - function makeSource(chunk: any) { - return { - chunks: new Map([["0,0,0:0", chunk]]), - } as any; - } - - function makeChunkManager() { - return { - queueManager: { - updateChunkState: vi.fn(), - }, - } as any; - } - - it("tracks both owners within the same recompute generation", () => { - const chunk = makeChunk(); - - markSpatiallyIndexedSkeletonChunkRequested( - chunk, - 5, - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_2D, - ); - markSpatiallyIndexedSkeletonChunkRequested( - chunk, - 5, - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_3D, - ); - - expect(chunk.requestGeneration).toBe(5); - expect(chunk.requestOwners).toBe( - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_2D | - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_3D, - ); - - markSpatiallyIndexedSkeletonChunkRequested( - chunk, - 6, - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_3D, - ); - - expect(chunk.requestGeneration).toBe(6); - expect(chunk.requestOwners).toBe( - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_3D, - ); - }); - - it("aborts stale downloading chunks that were not requested this recompute", () => { - const chunkManager = makeChunkManager(); - const chunk = makeChunk(); - const source = makeSource(chunk); - - markSpatiallyIndexedSkeletonChunkRequested( - chunk, - 4, - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_2D, - ); - - cancelStaleSpatiallyIndexedSkeletonDownloads(chunkManager, [source], 5); - - expect(chunk.downloadAbortController).toBeUndefined(); - expect(chunkManager.queueManager.updateChunkState).toHaveBeenCalledWith( - chunk, - ChunkState.QUEUED, - ); - }); - - it("keeps downloads requested by 3D in the current recompute", () => { - const chunkManager = makeChunkManager(); - const chunk = makeChunk(); - const source = makeSource(chunk); - - markSpatiallyIndexedSkeletonChunkRequested( - chunk, - 8, - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_3D, - ); - - cancelStaleSpatiallyIndexedSkeletonDownloads(chunkManager, [source], 8); - - expect(chunk.downloadAbortController?.signal.aborted).toBe(false); - expect(chunkManager.queueManager.updateChunkState).not.toHaveBeenCalled(); - }); - - it("keeps downloads requested by 2D in the current recompute", () => { - const chunkManager = makeChunkManager(); - const chunk = makeChunk(); - const source = makeSource(chunk); - - markSpatiallyIndexedSkeletonChunkRequested( - chunk, - 9, - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_2D, - ); - - cancelStaleSpatiallyIndexedSkeletonDownloads(chunkManager, [source], 9); - - expect(chunk.downloadAbortController?.signal.aborted).toBe(false); - expect(chunkManager.queueManager.updateChunkState).not.toHaveBeenCalled(); - }); - - it("keeps shared downloads when both owners still request the chunk", () => { - const chunkManager = makeChunkManager(); - const chunk = makeChunk(); - const source = makeSource(chunk); - - markSpatiallyIndexedSkeletonChunkRequested( - chunk, - 11, - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_2D, - ); - markSpatiallyIndexedSkeletonChunkRequested( - chunk, - 11, - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_3D, - ); - - cancelStaleSpatiallyIndexedSkeletonDownloads(chunkManager, [source], 11); - - expect(chunk.downloadAbortController?.signal.aborted).toBe(false); - expect(chunkManager.queueManager.updateChunkState).not.toHaveBeenCalled(); - }); - - it("does not touch queued chunks that never started downloading", () => { - const chunkManager = makeChunkManager(); - const chunk = makeChunk(ChunkState.QUEUED); - const source = makeSource(chunk); - - markSpatiallyIndexedSkeletonChunkRequested( - chunk, - 2, - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_2D, - ); - - cancelStaleSpatiallyIndexedSkeletonDownloads(chunkManager, [source], 3); - - expect(chunk.downloadAbortController?.signal.aborted).toBe(false); - expect(chunkManager.queueManager.updateChunkState).not.toHaveBeenCalled(); - }); -}); diff --git a/src/skeleton/backend.ts b/src/skeleton/backend.ts index def849509..919bcbd2b 100644 --- a/src/skeleton/backend.ts +++ b/src/skeleton/backend.ts @@ -39,6 +39,7 @@ import { getObjectKey, } from "#src/segmentation_display_state/base.js"; import type { SharedWatchableValue } from "#src/shared_watchable_value.js"; +import type { SpatialSkeletonSourceState } from "#src/skeleton/api.js"; import { SKELETON_LAYER_RPC_ID, SPATIALLY_INDEXED_SKELETON_RENDER_LAYER_RPC_ID, @@ -300,7 +301,7 @@ export class SpatiallyIndexedSkeletonChunk requestGeneration = -1; requestOwners = SpatiallyIndexedSkeletonChunkRequestOwner.NONE; nodeIds: Int32Array | undefined; - nodeRevisionTokens: Array | undefined; + nodeSourceStates: Array | undefined; freeSystemMemory() { freeSkeletonChunkSystemMemory(this); diff --git a/src/skeleton/edit_command_source.ts b/src/skeleton/edit_command_source.ts new file mode 100644 index 000000000..2f0803157 --- /dev/null +++ b/src/skeleton/edit_command_source.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; +import { + SpatialSkeletonActions, + type SpatialSkeletonAction, +} from "#src/skeleton/actions.js"; +import type { SpatialSkeletonCommand } from "#src/skeleton/command_history.js"; + +export type SpatialSkeletonCommandPayload = object; + +export interface SpatialSkeletonEditCommandFactory< + TAction extends SpatialSkeletonAction = SpatialSkeletonAction, +> { + readonly action: TAction; + createCommand( + layer: SegmentationUserLayer, + payload: SpatialSkeletonCommandPayload, + ): SpatialSkeletonCommand; +} + +type SpatialSkeletonEditCommandFactoryCandidate = { + action?: unknown; + createCommand?: ( + layer: SegmentationUserLayer, + payload: SpatialSkeletonCommandPayload, + ) => SpatialSkeletonCommand; +}; + +export function isSpatialSkeletonEditCommandFactory< + TAction extends SpatialSkeletonAction, +>( + value: unknown, + action: TAction, +): value is SpatialSkeletonEditCommandFactory { + return ( + typeof value === "object" && + value !== null && + (value as SpatialSkeletonEditCommandFactoryCandidate).action === action && + typeof (value as SpatialSkeletonEditCommandFactoryCandidate) + .createCommand === "function" + ); +} + +export type SpatialSkeletonAddNodesCommandFactory = + SpatialSkeletonEditCommandFactory; +export type SpatialSkeletonInsertNodesCommandFactory = + SpatialSkeletonEditCommandFactory; +export type SpatialSkeletonMoveNodesCommandFactory = + SpatialSkeletonEditCommandFactory; +export type SpatialSkeletonDeleteNodesCommandFactory = + SpatialSkeletonEditCommandFactory; +export type SpatialSkeletonRerootCommandFactory = + SpatialSkeletonEditCommandFactory; +export type SpatialSkeletonEditNodeDescriptionCommandFactory = + SpatialSkeletonEditCommandFactory< + typeof SpatialSkeletonActions.editNodeDescription + >; +export type SpatialSkeletonEditNodeTrueEndCommandFactory = + SpatialSkeletonEditCommandFactory< + typeof SpatialSkeletonActions.editNodeTrueEnd + >; +export type SpatialSkeletonEditNodeRadiusCommandFactory = + SpatialSkeletonEditCommandFactory< + typeof SpatialSkeletonActions.editNodeRadius + >; +export type SpatialSkeletonEditNodeConfidenceCommandFactory = + SpatialSkeletonEditCommandFactory< + typeof SpatialSkeletonActions.editNodeConfidence + >; +export type SpatialSkeletonMergeSkeletonsCommandFactory = + SpatialSkeletonEditCommandFactory< + typeof SpatialSkeletonActions.mergeSkeletons + >; +export type SpatialSkeletonSplitSkeletonsCommandFactory = + SpatialSkeletonEditCommandFactory< + typeof SpatialSkeletonActions.splitSkeletons + >; diff --git a/src/skeleton/edit_errors.ts b/src/skeleton/edit_errors.ts new file mode 100644 index 000000000..d33e11508 --- /dev/null +++ b/src/skeleton/edit_errors.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export class SpatialSkeletonEditConflictError extends Error { + constructor(detail?: string) { + super( + detail ?? + "The skeleton edit could not be applied because the source state is out of date.", + ); + this.name = "SpatialSkeletonEditConflictError"; + } +} diff --git a/src/skeleton/edit_state.ts b/src/skeleton/edit_state.ts index 653ae0632..ac50f433d 100644 --- a/src/skeleton/edit_state.ts +++ b/src/skeleton/edit_state.ts @@ -1,21 +1,4 @@ -import type { - SpatiallyIndexedSkeletonEditContext, - SpatiallyIndexedSkeletonEditNodeContext, - SpatiallyIndexedSkeletonEditParentContext, - SpatiallyIndexedSkeletonNode, -} from "#src/skeleton/api.js"; - -function requireRevisionToken( - node: SpatiallyIndexedSkeletonNode, - role: string, -): string { - if (node.revisionToken === undefined) { - throw new Error( - `Inspected spatial skeleton ${role} node ${node.nodeId} is missing revision metadata.`, - ); - } - return node.revisionToken; -} +import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; export function findSpatiallyIndexedSkeletonNode( segmentNodes: readonly SpatiallyIndexedSkeletonNode[], @@ -43,55 +26,6 @@ export function getSpatiallyIndexedSkeletonNodeParent( return findSpatiallyIndexedSkeletonNode(segmentNodes, node.parentNodeId); } -export function toSpatiallyIndexedSkeletonEditNodeContext( - node: SpatiallyIndexedSkeletonNode, -): SpatiallyIndexedSkeletonEditNodeContext { - return { - nodeId: node.nodeId, - parentNodeId: node.parentNodeId, - revisionToken: requireRevisionToken(node, "target"), - }; -} - -export function toSpatiallyIndexedSkeletonEditParentContext( - node: SpatiallyIndexedSkeletonNode, -): SpatiallyIndexedSkeletonEditParentContext { - return { - nodeId: node.nodeId, - revisionToken: requireRevisionToken(node, "related"), - }; -} - -export function buildSpatiallyIndexedSkeletonNodeEditContext( - node: SpatiallyIndexedSkeletonNode, -): SpatiallyIndexedSkeletonEditContext { - return { - node: toSpatiallyIndexedSkeletonEditNodeContext(node), - }; -} - -export function buildSpatiallyIndexedSkeletonNeighborhoodEditContext( - node: SpatiallyIndexedSkeletonNode, - segmentNodes: readonly SpatiallyIndexedSkeletonNode[], -): SpatiallyIndexedSkeletonEditContext { - // This intentionally derives parent/child state from the cached inspected - // segment on demand. If large inspected segments make edit preparation - // measurably slow, consider maintaining an adjacency index in - // SpatialSkeletonState instead of rescanning here. - const parentNode = getSpatiallyIndexedSkeletonNodeParent(segmentNodes, node); - const childNodes = getSpatiallyIndexedSkeletonDirectChildren( - segmentNodes, - node.nodeId, - ); - return { - node: toSpatiallyIndexedSkeletonEditNodeContext(node), - ...(parentNode === undefined - ? {} - : { parent: toSpatiallyIndexedSkeletonEditParentContext(parentNode) }), - children: childNodes.map(toSpatiallyIndexedSkeletonEditParentContext), - }; -} - export function getSpatiallyIndexedSkeletonPathToRoot( segmentNodes: readonly SpatiallyIndexedSkeletonNode[], node: SpatiallyIndexedSkeletonNode, @@ -112,23 +46,3 @@ export function getSpatiallyIndexedSkeletonPathToRoot( currentNode = parentNode; } } - -export function buildSpatiallyIndexedSkeletonRerootEditContext( - node: SpatiallyIndexedSkeletonNode, - segmentNodes: readonly SpatiallyIndexedSkeletonNode[], -): SpatiallyIndexedSkeletonEditContext { - return { - ...buildSpatiallyIndexedSkeletonNeighborhoodEditContext(node, segmentNodes), - nodes: getSpatiallyIndexedSkeletonPathToRoot(segmentNodes, node).map( - toSpatiallyIndexedSkeletonEditParentContext, - ), - }; -} - -export function buildSpatiallyIndexedSkeletonMultiNodeEditContext( - ...nodes: SpatiallyIndexedSkeletonNode[] -): SpatiallyIndexedSkeletonEditContext { - return { - nodes: nodes.map(toSpatiallyIndexedSkeletonEditParentContext), - }; -} diff --git a/src/skeleton/frontend.spec.ts b/src/skeleton/frontend.spec.ts index 599ec7c9d..147a39db4 100644 --- a/src/skeleton/frontend.spec.ts +++ b/src/skeleton/frontend.spec.ts @@ -68,7 +68,7 @@ describe("resolveSpatiallyIndexedSkeletonSegmentPick", () => { }); describe("SpatiallyIndexedSkeletonLayer browse node picks", () => { - it("resolves browse node picks with node id and revision token", () => { + 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 vertexBytes = new Uint8Array( @@ -82,7 +82,10 @@ describe("SpatiallyIndexedSkeletonLayer browse node picks", () => { numVertices: 2, indices: new Uint32Array([0, 1]), nodeIds: new Int32Array([101, 202]), - nodeRevisionTokens: ["2026-03-29T11:50:00Z", "2026-03-29T11:51:00Z"], + nodeSourceStates: [ + { revisionToken: "2026-03-29T11:50:00Z" }, + { revisionToken: "2026-03-29T11:51:00Z" }, + ], }; const layer = Object.create(SpatiallyIndexedSkeletonLayer.prototype); @@ -90,7 +93,7 @@ describe("SpatiallyIndexedSkeletonLayer browse node picks", () => { nodeId: 202, segmentId: 17, position: new Float32Array([4, 5, 6]), - revisionToken: "2026-03-29T11:51:00Z", + sourceState: { revisionToken: "2026-03-29T11:51:00Z" }, }); }); }); @@ -125,4 +128,3 @@ describe("SpatiallyIndexedSkeletonLayer browse exclusions", () => { expect([...excludedSegments]).toEqual([29n]); }); }); - diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index e4bae5b71..6d4a9565a 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -63,7 +63,10 @@ import { SegmentationLayerSharedObject, } from "#src/segmentation_display_state/frontend.js"; import { SharedWatchableValue } from "#src/shared_watchable_value.js"; -import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; +import type { + SpatiallyIndexedSkeletonNode, + SpatialSkeletonSourceState, +} from "#src/skeleton/api.js"; import type { VertexAttributeInfo } from "#src/skeleton/base.js"; import { SKELETON_LAYER_RPC_ID, @@ -233,8 +236,9 @@ interface SkeletonChunkData { indices: Uint32Array; numVertices: number; vertexAttributeOffsets: Uint32Array; + lod?: number; nodeIds?: Int32Array; - nodeRevisionTokens?: Array; + nodeSourceStates?: Array; } type SpatiallyIndexedSkeletonPickData = @@ -1527,7 +1531,7 @@ export class SpatiallyIndexedSkeletonChunk vertexAttributeOffsets: Uint32Array; vertexAttributeTextures: (WebGLTexture | null)[] = []; nodeIds: Int32Array = new Int32Array(0); - nodeRevisionTokens: Array = []; + nodeSourceStates: Array = []; lod: number | undefined; constructor( @@ -1553,11 +1557,9 @@ export class SpatiallyIndexedSkeletonChunk } else { this.nodeIds = new Int32Array(0); } - const nodeRevisionTokens = (chunkData as any).nodeRevisionTokens; - this.nodeRevisionTokens = Array.isArray(nodeRevisionTokens) - ? nodeRevisionTokens.map((value) => - typeof value === "string" ? value : undefined, - ) + const nodeSourceStates = (chunkData as any).nodeSourceStates; + this.nodeSourceStates = Array.isArray(nodeSourceStates) + ? nodeSourceStates : []; } @@ -2328,14 +2330,6 @@ export class SpatiallyIndexedSkeletonLayer }), ); } - // TODO (SKM): there should be maybe be a redraw on lod changing - // these were removed because there were too many of them - // a separate PR is working on a cleaner display state - // at that point we can likely add something back here - // for now it should be fine, because the chunks update - // as a result of the lod changing, which triggers a draw - // will check the pattern in slice view - // Create backend for perspective view chunk management const sharedObject = this.registerDisposer( new ChunkRenderLayerFrontend(this.layerChunkProgressInfo), @@ -2539,7 +2533,7 @@ export class SpatiallyIndexedSkeletonLayer nodeId, segmentId, position: data.positions.subarray(baseOffset, baseOffset + 3), - revisionToken: chunk.nodeRevisionTokens[pickedOffset], + sourceState: chunk.nodeSourceStates[pickedOffset], }; } @@ -2657,7 +2651,7 @@ export class SpatiallyIndexedSkeletonLayer lod?: number; } = {}, ): SpatiallyIndexedSkeletonNode | undefined { - void options; + void options.lod; if (!Number.isSafeInteger(nodeId) || nodeId <= 0) return undefined; return this.getCachedNodeSnapshot(nodeId); } @@ -3222,7 +3216,7 @@ export class PerspectiveViewSpatiallyIndexedSkeletonLayer extends PerspectiveVie nodeId: pickedNode.nodeId, segmentId: pickedNode.segmentId, position: new Float32Array(pickedNode.position), - revisionToken: pickedNode.revisionToken, + sourceState: pickedNode.sourceState, }; } return; @@ -3439,7 +3433,7 @@ export class SliceViewPanelSpatiallyIndexedSkeletonLayer extends SliceViewPanelR nodeId: pickedNode.nodeId, segmentId: pickedNode.segmentId, position: new Float32Array(pickedNode.position), - revisionToken: pickedNode.revisionToken, + sourceState: pickedNode.sourceState, }; } return; diff --git a/src/skeleton/navigation.ts b/src/skeleton/navigation.ts index 18f2ddbea..425806949 100644 --- a/src/skeleton/navigation.ts +++ b/src/skeleton/navigation.ts @@ -15,11 +15,21 @@ */ import type { - SpatiallyIndexedSkeletonNavigationTarget, SpatiallyIndexedSkeletonNode, - SpatiallyIndexedSkeletonOpenLeaf, + SpatialSkeletonVector, } from "#src/skeleton/api.js"; +export interface SpatiallyIndexedSkeletonNavigationTarget { + nodeId: number; + position: SpatialSkeletonVector; +} + +export interface SpatiallyIndexedSkeletonOpenLeaf + extends SpatiallyIndexedSkeletonNavigationTarget { + distance: number; + creationTime?: string; +} + export interface SpatiallyIndexedSkeletonNavigationGraph { nodeById: Map; childrenByParent: Map; @@ -63,15 +73,16 @@ function buildNavigationGraphDerivedState( const parentNodeId = node.parentNodeId; const parentInTree = parentNodeId !== undefined && graph.nodeById.has(parentNodeId); - const sortPriority = node.isTrueEnd - ? 0 - : childCount === 0 + const sortPriority = + (node.isTrueEnd ?? false) ? 0 - : !parentInTree - ? 3 - : childCount > 1 - ? 1 - : 2; + : childCount === 0 + ? 0 + : !parentInTree + ? 3 + : childCount > 1 + ? 1 + : 2; sortPriorityByNodeId.set(nodeId, sortPriority); } return { @@ -242,9 +253,7 @@ function getNodeTarget( const node = getNodeOrThrow(graph, nodeId); return { nodeId: node.nodeId, - x: Number(node.position[0]), - y: Number(node.position[1]), - z: Number(node.position[2]), + position: node.position, }; } diff --git a/src/skeleton/skeleton_chunk_serialization.ts b/src/skeleton/skeleton_chunk_serialization.ts index ca0222534..2e9b762eb 100644 --- a/src/skeleton/skeleton_chunk_serialization.ts +++ b/src/skeleton/skeleton_chunk_serialization.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import type { SpatialSkeletonSourceState } from "#src/skeleton/api.js"; import type { TypedNumberArray } from "#src/util/array.js"; export interface SkeletonChunkData { @@ -22,7 +23,7 @@ export interface SkeletonChunkData { indices: Uint32Array | null; lod?: number; nodeIds?: Int32Array; - nodeRevisionTokens?: Array; + nodeSourceStates?: Array; } /** @@ -97,8 +98,8 @@ export function serializeSkeletonChunkData( msg.nodeIds = data.nodeIds; transfers.push(data.nodeIds.buffer); } - if (data.nodeRevisionTokens) { - msg.nodeRevisionTokens = data.nodeRevisionTokens; + if (data.nodeSourceStates) { + msg.nodeSourceStates = data.nodeSourceStates; } } @@ -108,5 +109,5 @@ export function serializeSkeletonChunkData( export function freeSkeletonChunkSystemMemory(data: SkeletonChunkData): void { data.vertexPositions = data.indices = data.vertexAttributes = null; data.nodeIds = undefined; - data.nodeRevisionTokens = undefined; + data.nodeSourceStates = undefined; } diff --git a/src/skeleton/spatial_chunk_sizing.spec.ts b/src/skeleton/spatial_chunk_sizing.spec.ts index 5a2cf9d08..9398b7c98 100644 --- a/src/skeleton/spatial_chunk_sizing.spec.ts +++ b/src/skeleton/spatial_chunk_sizing.spec.ts @@ -6,47 +6,56 @@ describe("skeleton/spatial_chunk_sizing", () => { it("derives an isotropic chunk size that stays within the default chunk budget", () => { expect( getDefaultSpatiallyIndexedSkeletonChunkSize({ - min: { x: 5, y: 6, z: 7 }, - max: { x: 25, y: 66, z: 127 }, + lowerBounds: [5, 6, 7], + upperBounds: [25, 66, 127], }), - ).toEqual({ x: 15, y: 15, z: 15 }); + ).toEqual([15, 15, 15]); }); it("handles elongated bounds while keeping the chunk size isotropic", () => { expect( getDefaultSpatiallyIndexedSkeletonChunkSize({ - min: { x: 0, y: 0, z: 0 }, - max: { x: 1000, y: 10, z: 10 }, + lowerBounds: [0, 0, 0], + upperBounds: [1000, 10, 10], }), - ).toEqual({ x: 16, y: 16, z: 16 }); + ).toEqual([16, 16, 16]); }); it("returns the minimum chunk size for tiny bounds", () => { expect( getDefaultSpatiallyIndexedSkeletonChunkSize({ - min: { x: 0, y: 0, z: 0 }, - max: { x: 2, y: 2, z: 2 }, + lowerBounds: [0, 0, 0], + upperBounds: [2, 2, 2], }), - ).toEqual({ x: 1, y: 1, z: 1 }); + ).toEqual([1, 1, 1]); + }); + + it("returns a chunk-size array with the same rank as the bounds", () => { + expect( + getDefaultSpatiallyIndexedSkeletonChunkSize({ + lowerBounds: [0, 0, 0, 0], + upperBounds: [16, 32, 48, 2], + }), + ).toEqual([8, 8, 8, 8]); }); it("supports overriding the chunk budget", () => { expect( getDefaultSpatiallyIndexedSkeletonChunkSize( { - min: { x: 0, y: 0, z: 0 }, - max: { x: 100, y: 100, z: 100 }, + lowerBounds: [0, 0, 0], + upperBounds: [100, 100, 100], }, { maxChunks: 8 }, ), - ).toEqual({ x: 50, y: 50, z: 50 }); + ).toEqual([50, 50, 50]); }); it("rejects NaN bounds", () => { expect(() => getDefaultSpatiallyIndexedSkeletonChunkSize({ - min: { x: Number.NaN, y: 0, z: 0 }, - max: { x: 10, y: 10, z: 10 }, + lowerBounds: [Number.NaN, 0, 0], + upperBounds: [10, 10, 10], }), ).toThrow(/bounds must be finite/i); }); @@ -54,18 +63,27 @@ describe("skeleton/spatial_chunk_sizing", () => { it("rejects infinite bounds", () => { expect(() => getDefaultSpatiallyIndexedSkeletonChunkSize({ - min: { x: 0, y: 0, z: 0 }, - max: { x: Number.POSITIVE_INFINITY, y: 10, z: 10 }, + lowerBounds: [0, 0, 0], + upperBounds: [Number.POSITIVE_INFINITY, 10, 10], }), ).toThrow(/bounds must be finite/i); }); + it("rejects mismatched lower/upper bound ranks", () => { + expect(() => + getDefaultSpatiallyIndexedSkeletonChunkSize({ + lowerBounds: [0, 0], + upperBounds: [10, 10, 10], + }), + ).toThrow(/matching ranks/i); + }); + it("rejects NaN minChunkSize", () => { expect(() => getDefaultSpatiallyIndexedSkeletonChunkSize( { - min: { x: 0, y: 0, z: 0 }, - max: { x: 10, y: 10, z: 10 }, + lowerBounds: [0, 0, 0], + upperBounds: [10, 10, 10], }, { minChunkSize: Number.NaN }, ), @@ -76,8 +94,8 @@ describe("skeleton/spatial_chunk_sizing", () => { expect(() => getDefaultSpatiallyIndexedSkeletonChunkSize( { - min: { x: 0, y: 0, z: 0 }, - max: { x: 10, y: 10, z: 10 }, + lowerBounds: [0, 0, 0], + upperBounds: [10, 10, 10], }, { maxChunks: Number.POSITIVE_INFINITY }, ), diff --git a/src/skeleton/spatial_chunk_sizing.ts b/src/skeleton/spatial_chunk_sizing.ts index 584f15d8a..dd93f70a4 100644 --- a/src/skeleton/spatial_chunk_sizing.ts +++ b/src/skeleton/spatial_chunk_sizing.ts @@ -14,19 +14,15 @@ * limitations under the License. */ +import type { + SpatialSkeletonBounds, + SpatialSkeletonVector, +} from "#src/skeleton/api.js"; + const DEFAULT_SPATIALLY_INDEXED_SKELETON_MAX_CHUNKS = 64; const DEFAULT_SPATIALLY_INDEXED_SKELETON_MIN_CHUNK_SIZE = 1; -export interface SpatiallyIndexedSkeletonBounds { - min: { x: number; y: number; z: number }; - max: { x: number; y: number; z: number }; -} - -export interface SpatiallyIndexedSkeletonChunkSize { - x: number; - y: number; - z: number; -} +export type SpatiallyIndexedSkeletonChunkSize = number[]; export interface DefaultSpatiallyIndexedSkeletonChunkSizeOptions { maxChunks?: number; @@ -47,24 +43,30 @@ function validateFiniteOptions( } } -function validateFiniteBounds(bounds: SpatiallyIndexedSkeletonBounds) { - const values = [ - ["min.x", bounds.min.x], - ["min.y", bounds.min.y], - ["min.z", bounds.min.z], - ["max.x", bounds.max.x], - ["max.y", bounds.max.y], - ["max.z", bounds.max.z], - ] as const; - for (const [name, value] of values) { +function validateFiniteVector(vector: SpatialSkeletonVector, label: string) { + for (let i = 0; i < vector.length; ++i) { + const value = Number(vector[i]); if (!Number.isFinite(value)) { throw new Error( - `Spatially indexed skeleton bounds must be finite, but ${name} is ${value}.`, + `Spatially indexed skeleton bounds must be finite, but ${label}[${i}] is ${value}.`, ); } } } +function validateFiniteBounds(bounds: SpatialSkeletonBounds) { + if (bounds.lowerBounds.length !== bounds.upperBounds.length) { + throw new Error( + "Spatially indexed skeleton lower and upper bounds must have matching ranks.", + ); + } + if (bounds.lowerBounds.length === 0) { + throw new Error("Spatially indexed skeleton bounds must have rank > 0."); + } + validateFiniteVector(bounds.lowerBounds, "lowerBounds"); + validateFiniteVector(bounds.upperBounds, "upperBounds"); +} + function getChunkCoverageForChunkSize( extents: readonly number[], chunkSize: number, @@ -76,7 +78,7 @@ function getChunkCoverageForChunkSize( } export function getDefaultSpatiallyIndexedSkeletonChunkSize( - bounds: SpatiallyIndexedSkeletonBounds, + bounds: SpatialSkeletonBounds, options: DefaultSpatiallyIndexedSkeletonChunkSizeOptions = {}, ): SpatiallyIndexedSkeletonChunkSize { validateFiniteOptions(options); @@ -93,15 +95,13 @@ export function getDefaultSpatiallyIndexedSkeletonChunkSize( options.maxChunks ?? DEFAULT_SPATIALLY_INDEXED_SKELETON_MAX_CHUNKS, ), ); - const extents = [ - Math.max(0, bounds.max.x - bounds.min.x), - Math.max(0, bounds.max.y - bounds.min.y), - Math.max(0, bounds.max.z - bounds.min.z), - ] as const; + const extents = Array.from(bounds.lowerBounds, (lowerBound, index) => + Math.max(0, Number(bounds.upperBounds[index]) - Number(lowerBound)), + ); const maxExtent = Math.max(...extents); if (!(maxExtent > 0)) { - return { x: minChunkSize, y: minChunkSize, z: minChunkSize }; + return extents.map(() => minChunkSize); } // Choose the smallest isotropic chunk size that keeps the full bounding box @@ -117,5 +117,5 @@ export function getDefaultSpatiallyIndexedSkeletonChunkSize( } } - return { x: low, y: low, z: low }; + return extents.map(() => low); } diff --git a/src/skeleton/spatial_skeleton_manager.spec.ts b/src/skeleton/spatial_skeleton_manager.spec.ts index 21acec77c..b98a697df 100644 --- a/src/skeleton/spatial_skeleton_manager.spec.ts +++ b/src/skeleton/spatial_skeleton_manager.spec.ts @@ -21,46 +21,135 @@ import { getFlatListNodeIds, getSkeletonRootNode, } from "#src/skeleton/navigation.js"; +import { SpatialSkeletonActions } from "#src/skeleton/actions.js"; import { - isEditableSpatiallyIndexedSkeletonSource, + getEditableSpatiallyIndexedSkeletonSource, SpatialSkeletonState, } from "#src/skeleton/spatial_skeleton_manager.js"; +function makeCommandFactory(action: string) { + return { + action, + createCommand: vi.fn(), + }; +} + +function makeEditableSourceCommands() { + return { + addNodesCommand: makeCommandFactory(SpatialSkeletonActions.addNodes), + deleteNodesCommand: makeCommandFactory(SpatialSkeletonActions.deleteNodes), + moveNodesCommand: makeCommandFactory(SpatialSkeletonActions.moveNodes), + splitSkeletonsCommand: makeCommandFactory( + SpatialSkeletonActions.splitSkeletons, + ), + mergeSkeletonsCommand: makeCommandFactory( + SpatialSkeletonActions.mergeSkeletons, + ), + }; +} + describe("skeleton/spatial_skeleton_manager", () => { - it("does not require reroot support for editable sources", () => { - const editableSource = { + it("returns an editable source when mandatory edit actions are present", () => { + const source = { + ...makeEditableSourceCommands(), + readOnly: false, + listSkeletons: async () => [], + getSkeleton: async () => [], + fetchNodes: async () => [], + getSpatialIndexMetadata: async () => null, + }; + + expect(getEditableSpatiallyIndexedSkeletonSource({ source })).toBe(source); + }); + + it("does not treat a source missing mandatory edit actions as editable", () => { + const source = { + ...makeEditableSourceCommands(), + mergeSkeletonsCommand: undefined, + readOnly: false, + listSkeletons: async () => [], + getSkeleton: async () => [], + fetchNodes: async () => [], + getSpatialIndexMetadata: async () => null, + }; + + expect( + getEditableSpatiallyIndexedSkeletonSource({ source }), + ).toBeUndefined(); + }); + + it("does not treat a command factory for the wrong action as editable", () => { + const source = { + ...makeEditableSourceCommands(), + moveNodesCommand: makeCommandFactory(SpatialSkeletonActions.addNodes), + readOnly: false, + listSkeletons: async () => [], + getSkeleton: async () => [], + fetchNodes: async () => [], + getSpatialIndexMetadata: async () => null, + }; + + expect( + getEditableSpatiallyIndexedSkeletonSource({ source }), + ).toBeUndefined(); + }); + + it("does not require optional edit actions for editable source validation", () => { + const source = { + ...makeEditableSourceCommands(), + readOnly: false, + listSkeletons: async () => [], + getSkeleton: async () => [], + fetchNodes: async () => [], + getSpatialIndexMetadata: async () => null, + }; + + expect(getEditableSpatiallyIndexedSkeletonSource({ source })).toBe(source); + }); + + it("validates optional confidence configuration for editable sources", () => { + const source = { + ...makeEditableSourceCommands(), + editNodeConfidenceCommand: makeCommandFactory( + SpatialSkeletonActions.editNodeConfidence, + ), + spatialSkeletonConfidenceConfiguration: { + values: [0, 50, 100], + }, + readOnly: false, listSkeletons: async () => [], getSkeleton: async () => [], fetchNodes: async () => [], getSpatialIndexMetadata: async () => null, - getSkeletonRootNode: async () => ({ nodeId: 1, x: 1, y: 2, z: 3 }), - addNode: async () => ({ treenodeId: 1, skeletonId: 1 }), - insertNode: async () => ({ treenodeId: 1, skeletonId: 1 }), - moveNode: async () => {}, - deleteNode: async () => {}, - updateDescription: async () => {}, - setTrueEnd: async () => {}, - removeTrueEnd: async () => {}, - updateRadius: async () => {}, - updateConfidence: async () => {}, - mergeSkeletons: async () => ({ - resultSkeletonId: 1, - deletedSkeletonId: 2, - stableAnnotationSwap: false, - }), - splitSkeleton: async () => ({ - existingSkeletonId: 1, - newSkeletonId: 2, - }), }; - expect(isEditableSpatiallyIndexedSkeletonSource(editableSource)).toBe(true); + expect(getEditableSpatiallyIndexedSkeletonSource({ source })).toBe(source); + expect( - isEditableSpatiallyIndexedSkeletonSource({ - ...editableSource, - rerootSkeleton: async () => {}, + getEditableSpatiallyIndexedSkeletonSource({ + source: { + ...source, + spatialSkeletonConfidenceConfiguration: { + values: [0, Number.NaN, 100], + }, + }, }), - ).toBe(true); + ).toBeUndefined(); + }); + + it("does not treat a read-only source with edit commands as editable", () => { + const source = { + ...makeEditableSourceCommands(), + readOnly: true, + listSkeletons: async () => [], + getSkeleton: async () => [], + fetchNodes: async () => [], + getSpatialIndexMetadata: async () => null, + }; + + expect( + getEditableSpatiallyIndexedSkeletonSource({ source }), + ).toBeUndefined(); }); it("clears the full skeleton cache before notifying node data listeners", () => { @@ -264,6 +353,7 @@ describe("skeleton/spatial_skeleton_manager", () => { ); const skeletonLayer = { source: { + readOnly: false, listSkeletons: async () => [], getSkeleton, fetchNodes: async () => [], @@ -316,6 +406,7 @@ describe("skeleton/spatial_skeleton_manager", () => { const pending = state.getFullSegmentNodes( { source: { + readOnly: false, listSkeletons: async () => [], getSkeleton, fetchNodes: async () => [], @@ -351,6 +442,7 @@ describe("skeleton/spatial_skeleton_manager", () => { const pending = state.getFullSegmentNodes( { source: { + readOnly: false, listSkeletons: async () => [], getSkeleton, fetchNodes: async () => [], @@ -386,6 +478,7 @@ describe("skeleton/spatial_skeleton_manager", () => { const pending = state.getFullSegmentNodes( { source: { + readOnly: false, listSkeletons: async () => [], getSkeleton, fetchNodes: async () => [], @@ -416,6 +509,7 @@ describe("skeleton/spatial_skeleton_manager", () => { ]); const skeletonLayer = { source: { + readOnly: false, listSkeletons: async () => [], getSkeleton, fetchNodes: async () => [], @@ -451,7 +545,7 @@ describe("skeleton/spatial_skeleton_manager", () => { }); }); - it("caches inspected revision metadata from full skeleton inspection", async () => { + it("caches inspected source state from full skeleton inspection", async () => { const state = new SpatialSkeletonState(); const getSkeleton = vi.fn(async () => [ { @@ -460,7 +554,7 @@ describe("skeleton/spatial_skeleton_manager", () => { position: new Float32Array([1, 2, 3]), segmentId: 11, isTrueEnd: false, - revisionToken: "2026-03-29T12:30:00Z", + sourceState: { revisionToken: "2026-03-29T12:30:00Z" }, }, ]); @@ -468,6 +562,7 @@ describe("skeleton/spatial_skeleton_manager", () => { state.getFullSegmentNodes( { source: { + readOnly: false, listSkeletons: async () => [], getSkeleton, fetchNodes: async () => [], @@ -484,7 +579,7 @@ describe("skeleton/spatial_skeleton_manager", () => { parentNodeId: undefined, description: undefined, isTrueEnd: false, - revisionToken: "2026-03-29T12:30:00Z", + sourceState: { revisionToken: "2026-03-29T12:30:00Z" }, }, ]); @@ -496,7 +591,7 @@ describe("skeleton/spatial_skeleton_manager", () => { parentNodeId: undefined, description: undefined, isTrueEnd: false, - revisionToken: "2026-03-29T12:30:00Z", + sourceState: { revisionToken: "2026-03-29T12:30:00Z" }, }); }); @@ -510,7 +605,7 @@ describe("skeleton/spatial_skeleton_manager", () => { expect(state.mergeAnchorNodeId.value).toBeUndefined(); }); - it("stores provided confidence when setting properties", () => { + it("stores provided radius and confidence independently", () => { const state = new SpatialSkeletonState(); (state as any).replaceCachedSegmentNodes(11, [ { @@ -523,9 +618,8 @@ describe("skeleton/spatial_skeleton_manager", () => { }, ]); - expect(state.setNodeProperties(1, { radius: 6, confidence: 63 })).toBe( - true, - ); + expect(state.setNodeRadius(1, 6)).toBe(true); + expect(state.setNodeConfidence(1, 63)).toBe(true); expect(state.getCachedNode(1)).toMatchObject({ radius: 6, confidence: 63, diff --git a/src/skeleton/spatial_skeleton_manager.ts b/src/skeleton/spatial_skeleton_manager.ts index e334f4fd1..59b33a78d 100644 --- a/src/skeleton/spatial_skeleton_manager.ts +++ b/src/skeleton/spatial_skeleton_manager.ts @@ -16,11 +16,17 @@ import type { EditableSpatiallyIndexedSkeletonSource, + SpatialSkeletonConfidenceConfiguration, SpatiallyIndexedSkeletonNode, - SpatiallyIndexedSkeletonNodeRevisionUpdate, + SpatialSkeletonSourceState, SpatiallyIndexedSkeletonSource, } from "#src/skeleton/api.js"; +import { + SpatialSkeletonActions, + type SpatialSkeletonAction, +} from "#src/skeleton/actions.js"; 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 { RefCounted } from "#src/util/disposable.js"; @@ -40,10 +46,68 @@ function hasFunction( ); } +function getProperty(value: unknown, property: T): unknown { + return typeof value === "object" && value !== null + ? (value as Record)[property] + : undefined; +} + +function hasCommandFactory( + value: unknown, + property: T, + action: SpatialSkeletonAction, +) { + return isSpatialSkeletonEditCommandFactory( + getProperty(value, property), + action, + ); +} + +function hasOptionalCommandFactory( + value: unknown, + property: T, + action: SpatialSkeletonAction, +) { + const commandFactory = getProperty(value, property); + return ( + commandFactory === undefined || + isSpatialSkeletonEditCommandFactory(commandFactory, action) + ); +} + +function isFiniteNumberArray(value: unknown): value is readonly number[] { + return ( + Array.isArray(value) && + value.every((entry) => typeof entry === "number" && Number.isFinite(entry)) + ); +} + +function isSpatialSkeletonConfidenceConfiguration( + value: unknown, +): value is SpatialSkeletonConfidenceConfiguration { + return ( + typeof value === "object" && + value !== null && + isFiniteNumberArray(getProperty(value, "values")) + ); +} + +function hasOptionalConfidenceConfiguration(value: unknown) { + const configuration = getProperty( + value, + "spatialSkeletonConfidenceConfiguration", + ); + return ( + configuration === undefined || + isSpatialSkeletonConfidenceConfiguration(configuration) + ); +} + export function isSpatiallyIndexedSkeletonSource( value: unknown, ): value is SpatiallyIndexedSkeletonSource { return ( + typeof getProperty(value, "readOnly") === "boolean" && hasFunction(value, "listSkeletons") && hasFunction(value, "getSkeleton") && hasFunction(value, "getSpatialIndexMetadata") && @@ -56,18 +120,63 @@ export function isEditableSpatiallyIndexedSkeletonSource( ): value is EditableSpatiallyIndexedSkeletonSource { return ( isSpatiallyIndexedSkeletonSource(value) && - hasFunction(value, "addNode") && - hasFunction(value, "insertNode") && - hasFunction(value, "moveNode") && - hasFunction(value, "deleteNode") && - hasFunction(value, "updateDescription") && - hasFunction(value, "setTrueEnd") && - hasFunction(value, "removeTrueEnd") && - hasFunction(value, "updateRadius") && - hasFunction(value, "updateConfidence") && - hasFunction(value, "getSkeletonRootNode") && - hasFunction(value, "mergeSkeletons") && - hasFunction(value, "splitSkeleton") + !value.readOnly && + hasCommandFactory( + value, + "addNodesCommand", + SpatialSkeletonActions.addNodes, + ) && + hasCommandFactory( + value, + "deleteNodesCommand", + SpatialSkeletonActions.deleteNodes, + ) && + hasCommandFactory( + value, + "moveNodesCommand", + SpatialSkeletonActions.moveNodes, + ) && + hasCommandFactory( + value, + "splitSkeletonsCommand", + SpatialSkeletonActions.splitSkeletons, + ) && + hasCommandFactory( + value, + "mergeSkeletonsCommand", + SpatialSkeletonActions.mergeSkeletons, + ) && + hasOptionalCommandFactory( + value, + "insertNodesCommand", + SpatialSkeletonActions.insertNodes, + ) && + hasOptionalCommandFactory( + value, + "rerootCommand", + SpatialSkeletonActions.reroot, + ) && + hasOptionalCommandFactory( + value, + "editNodeDescriptionCommand", + SpatialSkeletonActions.editNodeDescription, + ) && + hasOptionalCommandFactory( + value, + "editNodeTrueEndCommand", + SpatialSkeletonActions.editNodeTrueEnd, + ) && + hasOptionalCommandFactory( + value, + "editNodeRadiusCommand", + SpatialSkeletonActions.editNodeRadius, + ) && + hasOptionalCommandFactory( + value, + "editNodeConfidenceCommand", + SpatialSkeletonActions.editNodeConfidence, + ) && + hasOptionalConfidenceConfiguration(value) ); } @@ -80,6 +189,12 @@ export function getSpatiallyIndexedSkeletonSource( : undefined; } +export function isSpatiallyIndexedSkeletonSourceReadOnly( + value: SpatialSkeletonSourceAccess | undefined, +): boolean { + return getSpatiallyIndexedSkeletonSource(value)?.readOnly === true; +} + export function getEditableSpatiallyIndexedSkeletonSource( value: SpatialSkeletonSourceAccess | undefined, ): EditableSpatiallyIndexedSkeletonSource | undefined { @@ -124,7 +239,7 @@ export function normalizeSpatiallyIndexedSkeletonNode( typeof node.description === "string" && node.description.length > 0 ? node.description : undefined, - isTrueEnd: node.isTrueEnd, + isTrueEnd: node.isTrueEnd ?? false, ...((node.radius !== undefined && Number.isFinite(Number(node.radius))) || (node.confidence !== undefined && Number.isFinite(Number(node.confidence))) ? { @@ -137,9 +252,9 @@ export function normalizeSpatiallyIndexedSkeletonNode( : {}), } : {}), - ...(node.revisionToken === undefined + ...(node.sourceState === undefined ? {} - : { revisionToken: node.revisionToken }), + : { sourceState: node.sourceState }), }; } @@ -180,27 +295,35 @@ export class SpatialSkeletonState extends RefCounted { >(); private cachedNodesById = new Map(); - setNodeProperties( - nodeId: number, - properties: { radius: number; confidence: number }, - ) { + setNodeRadius(nodeId: number, radius: number) { const normalizedNodeId = this.normalizeNodeId(nodeId); - const radius = Number(properties.radius); - const confidence = Number(properties.confidence); - if ( - normalizedNodeId === undefined || - !Number.isFinite(radius) || - !Number.isFinite(confidence) - ) { + radius = Number(radius); + if (normalizedNodeId === undefined || !Number.isFinite(radius)) { return false; } return this.updateCachedNode(normalizedNodeId, (node) => { - if (node.radius === radius && node.confidence === confidence) { + if (node.radius === radius) { return node; } return { ...node, radius, + }; + }); + } + + setNodeConfidence(nodeId: number, confidence: number) { + const normalizedNodeId = this.normalizeNodeId(nodeId); + confidence = Number(confidence); + if (normalizedNodeId === undefined || !Number.isFinite(confidence)) { + return false; + } + return this.updateCachedNode(normalizedNodeId, (node) => { + if (node.confidence === confidence) { + return node; + } + return { + ...node, confidence, }; }); @@ -376,28 +499,34 @@ export class SpatialSkeletonState extends RefCounted { return true; } - setCachedNodeRevision(nodeId: number, revisionToken: string | undefined) { - if (revisionToken === undefined) { + setCachedNodeSourceState( + nodeId: number, + sourceState: SpatialSkeletonSourceState | undefined, + ) { + if (sourceState === undefined) { return false; } return this.updateCachedNode(nodeId, (node) => { - if (node.revisionToken === revisionToken) { + if (node.sourceState === sourceState) { return node; } return { ...node, - revisionToken, + sourceState, }; }); } - setCachedNodeRevisions( - revisionUpdates: readonly SpatiallyIndexedSkeletonNodeRevisionUpdate[], + setCachedNodeSourceStates( + sourceStateUpdates: readonly { + nodeId: number; + sourceState: SpatialSkeletonSourceState; + }[], ) { let changed = false; - for (const update of revisionUpdates) { + for (const update of sourceStateUpdates) { changed = - this.setCachedNodeRevision(update.nodeId, update.revisionToken) || + this.setCachedNodeSourceState(update.nodeId, update.sourceState) || changed; } return changed; diff --git a/src/ui/spatial_skeleton_edit_tab.ts b/src/ui/spatial_skeleton_edit_tab.ts index 81a1aa944..ee7587412 100644 --- a/src/ui/spatial_skeleton_edit_tab.ts +++ b/src/ui/spatial_skeleton_edit_tab.ts @@ -47,9 +47,7 @@ import { type SpatialSkeletonAction, } from "#src/skeleton/actions.js"; import type { - SpatiallyIndexedSkeletonNavigationTarget, SpatiallyIndexedSkeletonNode, - SpatiallyIndexedSkeletonOpenLeaf, } from "#src/skeleton/api.js"; import { buildSpatiallyIndexedSkeletonNavigationGraph, @@ -60,6 +58,8 @@ import { getOpenLeaves as getOpenLeavesFromGraph, getParentNode as getParentNodeFromGraph, getSkeletonRootNode as getSkeletonRootNodeFromGraph, + type SpatiallyIndexedSkeletonNavigationTarget, + type SpatiallyIndexedSkeletonOpenLeaf, type SpatiallyIndexedSkeletonNavigationGraph, } from "#src/skeleton/navigation.js"; import { @@ -664,12 +664,9 @@ export class SpatialSkeletonEditTab extends Tab { }, }; - const navigateToNodeTarget = (target: { - nodeId: number; - x: number; - y: number; - z: number; - }) => { + const navigateToNodeTarget = ( + target: SpatiallyIndexedSkeletonNavigationTarget, + ) => { const existingNode = allNodes.find( (node) => node.nodeId === target.nodeId, ); @@ -678,7 +675,7 @@ export class SpatialSkeletonEditTab extends Tab { return; } pendingScrollToSelectedNode = true; - const position = [target.x, target.y, target.z]; + const position = target.position; layer.selectSpatialSkeletonNode(target.nodeId, true, { position }); moveViewToNodePosition(position); updateDisplay(); @@ -1227,7 +1224,7 @@ export class SpatialSkeletonEditTab extends Tab { row.setAttribute("aria-disabled", "true"); } - const nodeIsTrueEnd = node.isTrueEnd; + const nodeIsTrueEnd = node.isTrueEnd ?? false; const iconFilterType = getSpatialSkeletonNodeIconFilterType({ nodeIsTrueEnd, nodeType: type, diff --git a/src/ui/spatial_skeleton_edit_tab_render_state.ts b/src/ui/spatial_skeleton_edit_tab_render_state.ts index 35084fa74..273463ff6 100644 --- a/src/ui/spatial_skeleton_edit_tab_render_state.ts +++ b/src/ui/spatial_skeleton_edit_tab_render_state.ts @@ -99,7 +99,7 @@ export function buildSpatialSkeletonSegmentRenderState( matchesSpatialSkeletonNodeFilter(options.nodeFilterType, { isLeaf: children.length === 0, nodeHasDescription: hasNonEmptyNodeDescription(description), - nodeIsTrueEnd: node.isTrueEnd, + nodeIsTrueEnd: node.isTrueEnd ?? false, nodeType, })) && nodeMatchesFilter(node, options.filterText, description); @@ -141,7 +141,7 @@ export function buildSpatialSkeletonSegmentRenderState( const parentInTree = node.parentNodeId !== undefined && nodeById.has(node.parentNodeId); const type = classifyNodeType(node, children.length, parentInTree); - if (type === "regular" && !node.isTrueEnd) { + if (type === "regular" && !(node.isTrueEnd ?? false)) { continue; } rows.push({ node, type, isLeaf: children.length === 0 }); diff --git a/src/ui/spatial_skeleton_edit_tool.spec.ts b/src/ui/spatial_skeleton_edit_tool.spec.ts index 1517190a1..5cc53db60 100644 --- a/src/ui/spatial_skeleton_edit_tool.spec.ts +++ b/src/ui/spatial_skeleton_edit_tool.spec.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { makeCatmaidNodeSourceState } from "#src/datasource/catmaid/api.js"; +import { CatmaidSpatialSkeletonEditCommands } from "#src/datasource/catmaid/spatial_skeleton_commands.js"; import { executeSpatialSkeletonAddNode, executeSpatialSkeletonMerge, @@ -40,21 +42,29 @@ function makeVisibleSegmentsState(initialVisibleSegments: bigint[] = []) { }; } -function makeEditableSkeletonSource(overrides: Record = {}) { +const catmaidEditClientMethodNames = new Set([ + "addNode", + "insertNode", + "moveNode", + "deleteNode", + "rerootSkeleton", + "updateDescription", + "toggleTrueEnd", + "updateRadius", + "updateConfidence", + "mergeSkeletons", + "splitSkeleton", +]); + +function makeCatmaidClient(overrides: Record = {}) { return { - listSkeletons: vi.fn(), - getSkeleton: vi.fn(), - fetchNodes: vi.fn(), - getSpatialIndexMetadata: vi.fn(), - getSkeletonRootNode: vi.fn(), addNode: vi.fn(), insertNode: vi.fn(), moveNode: vi.fn(), deleteNode: vi.fn(), rerootSkeleton: vi.fn(), updateDescription: vi.fn(), - setTrueEnd: vi.fn(), - removeTrueEnd: vi.fn(), + toggleTrueEnd: vi.fn(), updateRadius: vi.fn(), updateConfidence: vi.fn(), mergeSkeletons: vi.fn(), @@ -63,6 +73,46 @@ function makeEditableSkeletonSource(overrides: Record = {}) { }; } +function makeEditableSkeletonSource(overrides: Record = {}) { + const clientOverrides: Record = {}; + const sourceOverrides: Record = {}; + for (const [key, value] of Object.entries(overrides)) { + if (catmaidEditClientMethodNames.has(key)) { + clientOverrides[key] = value; + } else { + sourceOverrides[key] = value; + } + } + const client = makeCatmaidClient(clientOverrides); + const commands = new CatmaidSpatialSkeletonEditCommands({ + getClient: () => client as any, + }); + return { + readOnly: false, + addNodesCommand: commands.addNodesCommand, + insertNodesCommand: commands.insertNodesCommand, + moveNodesCommand: commands.moveNodesCommand, + deleteNodesCommand: commands.deleteNodesCommand, + rerootCommand: commands.rerootCommand, + editNodeDescriptionCommand: commands.editNodeDescriptionCommand, + editNodeTrueEndCommand: commands.editNodeTrueEndCommand, + editNodeRadiusCommand: commands.editNodeRadiusCommand, + editNodeConfidenceCommand: commands.editNodeConfidenceCommand, + mergeSkeletonsCommand: commands.mergeSkeletonsCommand, + splitSkeletonsCommand: commands.splitSkeletonsCommand, + listSkeletons: vi.fn(), + getSkeleton: vi.fn(), + fetchNodes: vi.fn(), + getSpatialIndexMetadata: vi.fn(), + getSkeletonRootNode: vi.fn(), + ...sourceOverrides, + }; +} + +function testSourceState(revisionToken: string) { + return makeCatmaidNodeSourceState(revisionToken); +} + function suppressStatusMessages() { const fakeStatusMessage = { dispose() {}, @@ -103,7 +153,7 @@ describe("spatial_skeleton_edit_tool", () => { it("keeps parented add-node commits overlay-first without refetching chunks", async () => { suppressStatusMessages(); const upsertCachedNode = vi.fn(); - const setCachedNodeRevision = vi.fn(); + const setCachedNodeSourceState = vi.fn(); const selectSegment = vi.fn(); const selectSpatialSkeletonNode = vi.fn(); const markSpatialSkeletonNodeDataChanged = vi.fn(); @@ -114,13 +164,13 @@ describe("spatial_skeleton_edit_tool", () => { segmentId: 11, position: new Float32Array([8, 9, 10]), isTrueEnd: false, - revisionToken: "parent-before", + sourceState: testSourceState("parent-before"), }; const addNode = vi.fn().mockResolvedValue({ - treenodeId: 17, - skeletonId: 11, - revisionToken: "node-after", - parentRevisionToken: "parent-after", + nodeId: 17, + segmentId: 11, + sourceState: testSourceState("node-after"), + parentSourceState: testSourceState("parent-after"), }); const skeletonLayer = { source: makeEditableSkeletonSource({ addNode }), @@ -148,7 +198,7 @@ describe("spatial_skeleton_edit_tool", () => { ), getFullSegmentNodes, upsertCachedNode, - setCachedNodeRevision, + setCachedNodeSourceState, }, getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, selectSegment, @@ -173,13 +223,16 @@ describe("spatial_skeleton_edit_tool", () => { positionInModelSpace: position, }); - expect(addNode).toHaveBeenCalledWith(11, 1, 2, 3, 5, { - node: { - nodeId: 5, - parentNodeId: undefined, - revisionToken: "parent-before", - }, - }); + expect(addNode).toHaveBeenCalledWith( + 11, + 1, + 2, + 3, + 5, + expect.objectContaining({ + node: expect.objectContaining({ nodeId: 5 }), + }), + ); expect(upsertCachedNode).toHaveBeenCalledWith( { nodeId: 17, @@ -187,11 +240,14 @@ describe("spatial_skeleton_edit_tool", () => { position: new Float32Array([1, 2, 3]), parentNodeId: 5, isTrueEnd: false, - revisionToken: "node-after", + sourceState: testSourceState("node-after"), }, { allowUncachedSegment: false }, ); - expect(setCachedNodeRevision).toHaveBeenCalledWith(5, "parent-after"); + expect(setCachedNodeSourceState).toHaveBeenCalledWith( + 5, + testSourceState("parent-after"), + ); expect(visibleSegmentsState.visibleSegments.has(11n)).toBe(true); expect(selectSegment).toHaveBeenCalledWith(11n, true); expect(selectSpatialSkeletonNode).toHaveBeenCalledWith(17, true, { @@ -212,16 +268,16 @@ describe("spatial_skeleton_edit_tool", () => { it("seeds root add-node commits locally without overlay retention or refetching chunks", async () => { suppressStatusMessages(); const upsertCachedNode = vi.fn(); - const setCachedNodeRevision = vi.fn(); + const setCachedNodeSourceState = vi.fn(); const selectSegment = vi.fn(); const selectSpatialSkeletonNode = vi.fn(); const markSpatialSkeletonNodeDataChanged = vi.fn(); const moveViewToSpatialSkeletonNodePosition = vi.fn(); const getFullSegmentNodes = vi.fn(); const addNode = vi.fn().mockResolvedValue({ - treenodeId: 29, - skeletonId: 13, - revisionToken: "root-after", + nodeId: 29, + segmentId: 13, + sourceState: testSourceState("root-after"), }); const skeletonLayer = { source: makeEditableSkeletonSource({ addNode }), @@ -243,7 +299,7 @@ describe("spatial_skeleton_edit_tool", () => { getCachedSegmentNodes: vi.fn(), getFullSegmentNodes, upsertCachedNode, - setCachedNodeRevision, + setCachedNodeSourceState, }, getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, selectSegment, @@ -276,11 +332,11 @@ describe("spatial_skeleton_edit_tool", () => { position: new Float32Array([4, 5, 6]), parentNodeId: undefined, isTrueEnd: false, - revisionToken: "root-after", + sourceState: testSourceState("root-after"), }, { allowUncachedSegment: true }, ); - expect(setCachedNodeRevision).not.toHaveBeenCalled(); + expect(setCachedNodeSourceState).not.toHaveBeenCalled(); expect(visibleSegmentsState.visibleSegments.has(13n)).toBe(true); expect(selectSegment).toHaveBeenCalledWith(13n, true); expect(selectSpatialSkeletonNode).toHaveBeenCalledWith(29, false, { @@ -346,19 +402,19 @@ describe("spatial_skeleton_edit_tool", () => { segmentId: 11, position: new Float32Array([1, 2, 3]), isTrueEnd: false, - revisionToken: "first-before", + sourceState: testSourceState("first-before"), }; const secondNode: SpatiallyIndexedSkeletonNode = { nodeId: 202, segmentId: 17, position: new Float32Array([4, 5, 6]), isTrueEnd: false, - revisionToken: "second-before", + sourceState: testSourceState("second-before"), }; const mergeSkeletons = vi.fn().mockResolvedValue({ - resultSkeletonId: 17, - deletedSkeletonId: 11, - stableAnnotationSwap: true, + resultSegmentId: 17, + deletedSegmentId: 11, + directionAdjusted: true, }); const invalidateCachedSegments = vi.fn(); const getFullSegmentNodes = vi.fn(async () => []); @@ -427,12 +483,16 @@ describe("spatial_skeleton_edit_tool", () => { { nodeId: 202, segmentId: 17 }, ); - expect(mergeSkeletons).toHaveBeenCalledWith(101, 202, { - nodes: [ - { nodeId: 101, revisionToken: "first-before" }, - { nodeId: 202, revisionToken: "second-before" }, - ], - }); + expect(mergeSkeletons).toHaveBeenCalledWith( + 101, + 202, + expect.objectContaining({ + nodes: expect.arrayContaining([ + expect.objectContaining({ nodeId: 101 }), + expect.objectContaining({ nodeId: 202 }), + ]), + }), + ); expect(invalidateCachedSegments).toHaveBeenCalledWith([17, 11]); expect(getFullSegmentNodes).toHaveBeenCalledTimes(2); expect(selectSegment).toHaveBeenCalledWith(17n, false); diff --git a/src/ui/spatial_skeleton_edit_tool.ts b/src/ui/spatial_skeleton_edit_tool.ts index 00443d811..b54c01d4c 100644 --- a/src/ui/spatial_skeleton_edit_tool.ts +++ b/src/ui/spatial_skeleton_edit_tool.ts @@ -37,6 +37,10 @@ import { } from "#src/segmentation_display_state/base.js"; import { SpatialSkeletonActions } from "#src/skeleton/actions.js"; import { setSpatialSkeletonModesToLinesAndPoints } from "#src/skeleton/edit_mode_rendering.js"; +import type { + SpatialSkeletonSourceState, + SpatialSkeletonVector, +} from "#src/skeleton/api.js"; import type { SpatiallyIndexedSkeletonLayer } from "#src/skeleton/frontend.js"; import { PerspectiveViewSpatiallyIndexedSkeletonLayer, @@ -181,7 +185,7 @@ abstract class SpatialSkeletonToolBase extends LayerTool nodeId: number; segmentId?: number; position?: Float32Array; - revisionToken?: string; + sourceState?: SpatialSkeletonSourceState; } | undefined { if (!this.mouseState.updateUnconditionally() || !this.mouseState.active) { @@ -198,7 +202,7 @@ abstract class SpatialSkeletonToolBase extends LayerTool } const segmentIdRaw = pickedSpatialSkeleton?.segmentId; const position = pickedSpatialSkeleton?.position; - const revisionToken = pickedSpatialSkeleton?.revisionToken; + const sourceState = pickedSpatialSkeleton?.sourceState; return { nodeId: nodeIdRaw, segmentId: @@ -209,8 +213,7 @@ abstract class SpatialSkeletonToolBase extends LayerTool position instanceof Float32Array ? new Float32Array(position) : undefined, - revisionToken: - typeof revisionToken === "string" ? revisionToken : undefined, + sourceState, }; } @@ -371,7 +374,7 @@ abstract class SpatialSkeletonToolBase extends LayerTool nodeId: nodeHit.nodeId, segmentId: nodeHit.segmentId ?? resolvedNodeInfo?.segmentId, position: nodeHit.position ?? resolvedNodeInfo?.position, - revisionToken: nodeHit.revisionToken ?? resolvedNodeInfo?.revisionToken, + sourceState: nodeHit.sourceState ?? resolvedNodeInfo?.sourceState, }; } @@ -381,8 +384,8 @@ abstract class SpatialSkeletonToolBase extends LayerTool | { nodeId: number; segmentId?: number; - position?: Float32Array; - revisionToken?: string; + position?: SpatialSkeletonVector; + sourceState?: SpatialSkeletonSourceState; visible: boolean; } | undefined { @@ -398,7 +401,7 @@ abstract class SpatialSkeletonToolBase extends LayerTool nodeId: nodeHit.nodeId, segmentId, position: nodeHit.position ?? resolvedNodeInfo?.position, - revisionToken: nodeHit.revisionToken ?? resolvedNodeInfo?.revisionToken, + sourceState: nodeHit.sourceState ?? resolvedNodeInfo?.sourceState, visible: segmentId !== undefined && this.isSpatialSkeletonSegmentVisible(segmentId), @@ -1059,7 +1062,7 @@ class SpatialSkeletonMergeModeTool extends SpatialSkeletonToolBase { nodeId: number; segmentId?: number; position?: ArrayLike; - revisionToken?: string; + sourceState?: SpatialSkeletonSourceState; }; let anchorSelection: MergeAnchorSelection | undefined; let statusOverride: string | undefined; @@ -1079,7 +1082,7 @@ class SpatialSkeletonMergeModeTool extends SpatialSkeletonToolBase { nodeId, segmentId: cachedNode?.segmentId, position: cachedNode?.position, - revisionToken: cachedNode?.revisionToken, + sourceState: cachedNode?.sourceState, }; anchorSelection = anchorNode; return anchorNode; @@ -1180,7 +1183,7 @@ class SpatialSkeletonMergeModeTool extends SpatialSkeletonToolBase { nodeId: pickedNode.nodeId, segmentId: pickedNode.segmentId, position: pickedNode.position, - revisionToken: pickedNode.revisionToken, + sourceState: pickedNode.sourceState, }; this.layer.setSpatialSkeletonMergeAnchor(pickedNode.nodeId); this.layer.selectSpatialSkeletonNode( @@ -1203,7 +1206,7 @@ class SpatialSkeletonMergeModeTool extends SpatialSkeletonToolBase { nodeId: pickedNode.nodeId, segmentId: pickedNode.segmentId, position: pickedNode.position, - revisionToken: pickedNode.revisionToken, + sourceState: pickedNode.sourceState, }; this.layer.setSpatialSkeletonMergeAnchor(pickedNode.nodeId); this.layer.selectSpatialSkeletonNode( @@ -1219,7 +1222,7 @@ class SpatialSkeletonMergeModeTool extends SpatialSkeletonToolBase { nodeId: pickedNode.nodeId, segmentId: pickedNode.segmentId, position: pickedNode.position, - revisionToken: pickedNode.revisionToken, + sourceState: pickedNode.sourceState, }; if ( firstNode.segmentId === undefined || @@ -1246,12 +1249,12 @@ class SpatialSkeletonMergeModeTool extends SpatialSkeletonToolBase { { nodeId: firstNode.nodeId, segmentId: firstNode.segmentId!, - revisionToken: firstNode.revisionToken, + sourceState: firstNode.sourceState, }, { nodeId: secondNode.nodeId, segmentId: secondNode.segmentId!, - revisionToken: secondNode.revisionToken, + sourceState: secondNode.sourceState, }, ); } catch (error) {