diff --git a/docs/index.rst b/docs/index.rst index ccc3766f33..8d00770f89 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,6 +7,7 @@ Neuroglancer :caption: User Guide user-guide/navigation + user-guide/skeleton_editing .. toctree:: :hidden: @@ -42,4 +43,3 @@ Neuroglancer Neuroglancer is a WebGL-based viewer for volumetric data. It is capable of displaying arbitrary (non axis-aligned) cross-sectional views of volumetric data, as well as 3-D meshes and line-segment based models (skeletons). - diff --git a/docs/spatial_skeleton_refresh_nodes.md b/docs/spatial_skeleton_refresh_nodes.md new file mode 100644 index 0000000000..f144b23aed --- /dev/null +++ b/docs/spatial_skeleton_refresh_nodes.md @@ -0,0 +1,167 @@ +# Why `refreshNodes()` Is Called So Often + +This note documents every current `refreshNodes()` call site in `src/ui/spatial_skeleton_edit_tab.ts` and whether it is strictly required by the current implementation. + +## What `refreshNodes()` actually recomputes + +`refreshNodes()` is not just a repaint helper. It rebuilds the tab's backing data by: + +- Recomputing `activeSegmentIds` from `getVisibleSegments(...)`. +- Resolving the current spatial skeleton render layer and CATMAID client. +- Clearing the full-skeleton cache when `layer.spatialSkeletonNodeDataVersion` changes. +- Fetching full CATMAID skeletons for every active segment. +- Fetching CATMAID "true end" labels and applying them to the node list. +- Rebuilding `nodesBySegment`, `allNodes`, the summary text, and the selected node fallback. + +Because of that, a refresh is only fundamentally needed when one of those inputs changes. + +## Call sites + +### 1. `deleteNode(...)` success path + +Why it is called: + +- Deleting a node changes the local skeleton layer immediately. +- A delete can also split one skeleton into new segment ids, so the active visible segment set may change. +- The tab wants to re-fetch the authoritative CATMAID skeleton after the mutation instead of trusting only local chunk edits. + +Assessment: + +- Reasonable as an eager refresh. +- Probably redundant in practice, because the same code path also updates `visibleSegments` and bumps `layer.spatialSkeletonNodeDataVersion`, and both already have refresh listeners below. + +### 2. `layer.displayState.segmentSelectionState.changed` + +Why it is called today: + +- Most likely defensive: segment selection changes often happen while the user is interacting with the Seg tab. + +Assessment: + +- This does not look required by the current code. +- `refreshNodes()` does not read `segmentSelectionState`. +- If only the selected segment changes while the visible segment set stays the same, the node list inputs have not changed. + +### 3. `segmentationGroupState.visibleSegments.changed` + +Why it is called: + +- This is a direct dependency. +- `refreshNodes()` derives `activeSegmentIds` from the visible segment set, so adding or removing visible skeletons must rebuild the node list. + +Assessment: + +- Required. + +### 4. `segmentationGroupState.temporaryVisibleSegments.changed` + +Why it is called: + +- Merge/split previews can populate the temporary visible segment set. +- When the tab is currently using the temporary set, those preview edits change which skeletons should be shown. + +Assessment: + +- Required for preview mode correctness. + +### 5. `segmentationGroupState.useTemporaryVisibleSegments.changed` + +Why it is called: + +- `getVisibleSegments(...)` switches between the normal and temporary visible sets based on this flag. +- Flipping the flag changes the source of truth for `activeSegmentIds` even if neither set changed contents. + +Assessment: + +- Required. + +### 6. `layer.layersChanged` + +Why it is called: + +- `refreshNodes()` resolves the active spatial skeleton render layer on every run. +- If render layers are rebuilt, added, removed, or swapped, the tab may need a different skeleton layer or CATMAID client. + +Assessment: + +- Required. + +### 7. `layer.manager.chunkManager.layerChunkStatisticsUpdated` + +Why it is called today: + +- Probably to keep the tab in sync with spatial skeleton loading progress. + +Assessment: + +- `updateGateStatus()` is definitely needed here because action availability depends on chunk loading. +- The `refreshNodes()` part does not look required by the current implementation. +- `refreshNodes()` does not consume chunk statistics directly; it fetches full skeletons from CATMAID based on visible segments. +- This looks like defensive overlap or a holdover from an older "loaded nodes" model. + +### 8. `layer.displayState.spatialSkeletonGridLevel2d.changed` + +Why it is called today: + +- Likely meant to react when the user changes the skeleton grid resolution used by the render layers. + +Assessment: + +- Not a direct dependency of `refreshNodes()`. +- Grid level changes do affect edit eligibility and chunk loading, but those are already covered by `spatialSkeletonActionsAllowed`, `layersChanged`, and chunk-stat updates. +- For the current CATMAID-backed full-skeleton list, this refresh looks likely redundant. + +### 9. `layer.displayState.spatialSkeletonGridLevel3d.changed` + +Why it is called today: + +- Same rationale as the 2D grid-level watcher. + +Assessment: + +- Same conclusion as above: likely redundant for the current implementation. + +### 10. `layer.spatialSkeletonNodeDataVersion.changed` + +Why it is called: + +- This is the main explicit invalidation channel for real skeleton edits. +- When the version changes, `refreshNodes()` clears the full-skeleton cache and re-fetches from CATMAID. +- Other tools rely on this after add, move, merge, and split operations. + +Assessment: + +- Required. +- This is the cleanest "the node topology changed" signal in the file. + +### 11. Constructor tail: initial `refreshNodes()` + +Why it is called: + +- The tab needs an initial population pass after wiring observers. +- Without it, the list would stay empty until some later state change happens. + +Assessment: + +- Required. + +## Practical takeaway + +The clearly justified refresh triggers are: + +- `visibleSegments.changed` +- `temporaryVisibleSegments.changed` +- `useTemporaryVisibleSegments.changed` +- `layersChanged` +- `spatialSkeletonNodeDataVersion.changed` +- the initial constructor call + +The calls that currently look redundant or at least weakly justified are: + +- `segmentSelectionState.changed` +- the `refreshNodes()` inside `layerChunkStatisticsUpdated` +- `spatialSkeletonGridLevel2d.changed` +- `spatialSkeletonGridLevel3d.changed` +- the direct refresh after successful delete, because that path already triggers other refresh signals + +If we want to reduce refresh churn safely, those are the first places to verify with tests or manual UI checks. diff --git a/docs/user-guide/skeleton_editing.rst b/docs/user-guide/skeleton_editing.rst new file mode 100644 index 0000000000..dbaac90e3f --- /dev/null +++ b/docs/user-guide/skeleton_editing.rst @@ -0,0 +1,190 @@ +Skeleton Editing +================ + +Neuroglancer supports interactive editing of skeleton annotations, including +adding, moving, and deleting nodes, as well as merging and splitting skeletons. + +.. _skeleton-editing-sources: + +Supported Sources +----------------- + +Skeleton editing is currently only supported on CATMAID data sources. See the +CATMAID documentation to set up a CATMAID server. At minimum you will need: + +- A CATMAID project +- A linked project stack +- ``AnonymousUser`` permissions to read and edit the data on that project +- Skeletons initialised for that project + +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. + +After setting this up, enter ``catmaid:/`` as a data source in neuroglancer. + +.. _skeleton-editing-subsources: + +Layer Subsources +---------------- + +The data source exposes two skeleton subsources. The first is a spatially indexed +skeleton source, which is required for editing. The second is the regular skeleton +subsource from the pre-existing pipeline for rendering precomputed format skeletons. + +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. + +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. + +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. + +.. _skeleton-editing-tab: + +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. + +You can find a node by ID or by description, and filter nodes to show only: + +- Leaves +- Virtual ends +- True ends +- Nodes with descriptions + +You can also pick a subset of the visible skeletons to display information about in this menu. + +Skeleton Navigation +~~~~~~~~~~~~~~~~~~~ + +The skeleton tab provides buttons for navigating through the skeleton tree: + +- Go to the root +- Go to the start of the current branch +- Go to the end of the current branch +- Cycle through nodes at the current level +- Go to the parent or child of the current node (if there are multiple children, + one is chosen at random) +- Go to the nearest node that is a leaf but not marked as a true end + +You can also interact with nodes in the details viewer by right-clicking to move +to a node, or left-clicking to select it and move to it. + +.. _skeleton-node-types: + +Node Types +---------- + +Nodes use symbols to indicate their type: + +- **Root** — the root node of the skeleton +- **Regular node** — an interior node along a branch +- **Branch point** — a node with more than one child +- **Virtual end** — a leaf node that has not been marked as a true end +- **True end** — a leaf node manually marked by a reviewer as the end of a branch + +You can toggle a node between virtual end and true end by clicking its type icon +in the skeleton tab table. This only applies to visible segments. + +.. _skeleton-node-properties: + +Node Properties +--------------- + +To edit the detailed properties of a node, first make the segment visible, then +select the node by either: + +- Right-clicking on it in the viewer while holding :kbd:`Control` +- Left-clicking on it in the skeleton tab table + +Once a node is selected, you can: + +- Delete the node * +- Change the node type * +- Make the node the root of the skeleton * +- Change the radius +- Change the confidence level +- Add or edit a free-text description + +.. note:: + * These actions can also be performed from the skeleton tab table. + +.. _skeleton-editing-tools: + +Editing Tools +------------- + +To make structural edits to nodes, you must bind at least some of the editing +tools available in the skeleton tab. The available tools are **Edit**, **Merge**, +and **Split**. + +To bind a tool, click on it in the UI and hold down a key. To activate the tool, +press :kbd:`Shift` + the bound key. For example, if you bind :kbd:`E` to the Edit +tool, pressing :kbd:`Shift+E` activates it. + +An important concept throughout editing is the *selected node*. The selected node +is highlighted with a border in the viewer, highlighted in the skeleton tab table, +and its details are shown in the selection details panel. + +Edit Tool +~~~~~~~~~ + +With the Edit tool active: + +- **Move a node** — select the node, then hold :kbd:`Alt` and left-click and drag + it to the new location. This does not use picking to snap to nearby objects. +- **Add a child node** — select an existing node, then :kbd:`Control`-click where + you want to place the new node. The new node is added as a child of the selected + node. +- **Start a new skeleton** — :kbd:`Control`-click with no node selected to add a + root node with no parent. + +Merge Tool +~~~~~~~~~~ + +With the Merge tool active, select the "from" node first and then the "to" node. You must merge from a visible skeleton, but the "to" node may belong to a non-visible skeleton. +The surviving skeleton ID will be the ID of the skeleton containing the "from" +node. The only exception to this is if the CATMAID skeleton has annotations, and one of the skeletons is annotated as ``stable`` -- in this case, the surviving skeleton ID is from the one that was annotated as ``stable``. It is not currently possible to set these annotations within neuroglancer. + +Split Tool +~~~~~~~~~~ + +With the Split tool active, select the node at which to split. The selected node +is included in the newly created skeleton, not the surviving original skeleton. +The edge between the selected node and its parent is deleted, and the selected +node becomes the root of the new skeleton. A split always produces exactly two +skeletons, regardless of whether the selected node is a branch point or a leaf. +You can only split visible skeletons. + +.. _skeleton-editing-undo: + +Undo and Redo +------------- + +The skeleton tab provides **Undo** and **Redo** buttons. When any operation is +performed, its inverse is stored in the history. Note that the inverse of an +atomic operation is not necessarily atomic: for example, undoing a merge involves +a split followed by a reroot. Without the reroot step, the split skeleton could +end up with a different root than it had before the merge. + +Undo does not restore the skeleton ID to its pre-operation value, so a merge +followed by an undo will result in one of the skeletons having a new ID compared +to before the merge (specifically, the skeleton that did not "survive" the +original merge). diff --git a/package.json b/package.json index ed092da468..e46d8d5b66 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,12 @@ "neuroglancer/datasource/boss:disabled": "./src/util/false.ts", "default": "./src/datasource/boss/backend.ts" }, + "#datasource/catmaid/backend": { + "default": "./src/datasource/catmaid/backend.ts" + }, + "#datasource/catmaid/register_default": { + "default": "./src/datasource/catmaid/register_default.ts" + }, "#datasource/boss/async_computation": { "neuroglancer/datasource/boss:enabled": "./src/datasource/boss/async_computation.ts", "neuroglancer/datasource:none_by_default": "./src/util/false.ts", @@ -534,4 +540,4 @@ "default": "./src/util/false.ts" } } -} +} \ No newline at end of file diff --git a/python/neuroglancer/viewer_state.py b/python/neuroglancer/viewer_state.py index 27f3beedde..a1fc6ef006 100644 --- a/python/neuroglancer/viewer_state.py +++ b/python/neuroglancer/viewer_state.py @@ -1847,6 +1847,12 @@ class LayerSelectionState(JsonObjectWrapper): annotation_subsource = annotationSubsource = wrapped_property( "annotationSubsource", optional(str) ) + spatial_skeleton_node_id = spatialSkeletonNodeId = wrapped_property( + "spatialSkeletonNodeId", optional(int) + ) + spatial_skeleton_segment_id = spatialSkeletonSegmentId = wrapped_property( + "spatialSkeletonSegmentId", optional(int) + ) if typing.TYPE_CHECKING or _BUILDING_DOCS: diff --git a/python/tests/viewer_state_test.py b/python/tests/viewer_state_test.py index 5f2edb0b0e..4e2e74599b 100644 --- a/python/tests/viewer_state_test.py +++ b/python/tests/viewer_state_test.py @@ -116,3 +116,25 @@ def test_tool(): def test_annotation(): viewer_state.PointAnnotation(point=[1]) + + +def test_spatial_skeleton_node_selection_fields_roundtrip(): + selection = viewer_state.LayerSelectionState( + { + "spatialSkeletonNodeId": 17, + "spatialSkeletonSegmentId": 9, + } + ) + assert selection.spatial_skeleton_node_id == 17 + assert selection.spatial_skeleton_segment_id == 9 + assert selection.to_json() == { + "spatialSkeletonNodeId": 17, + "spatialSkeletonSegmentId": 9, + } + + +def test_spatial_skeleton_node_selection_without_segment_roundtrip(): + selection = viewer_state.LayerSelectionState(spatial_skeleton_node_id=5) + assert selection.to_json() == { + "spatialSkeletonNodeId": 5, + } diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts new file mode 100644 index 0000000000..c2c0f2fd68 --- /dev/null +++ b/src/datasource/catmaid/api.spec.ts @@ -0,0 +1,826 @@ +/** + * @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 { describe, expect, it, vi } from "vitest"; + +import { CatmaidClient } from "#src/datasource/catmaid/api.js"; + +type FetchMock = ReturnType; + +function getFetchCall(fetchMock: FetchMock, callIndex = 0) { + const call = fetchMock.mock.calls[callIndex]; + if (call === undefined) { + throw new Error(`Expected fetch call ${callIndex + 1} to exist.`); + } + return call; +} + +function getFetchPath(fetchMock: FetchMock, callIndex = 0) { + return getFetchCall(fetchMock, callIndex)[0]; +} + +function getFetchBody(fetchMock: FetchMock, callIndex = 0) { + const [, requestInit] = getFetchCall(fetchMock, callIndex); + if (requestInit === undefined || typeof requestInit !== "object") { + throw new Error( + `Expected fetch call ${callIndex + 1} to include request options.`, + ); + } + const body = (requestInit as { body?: unknown }).body; + if (!(body instanceof URLSearchParams)) { + throw new Error( + `Expected fetch call ${callIndex + 1} to include a URLSearchParams body.`, + ); + } + return body; +} + +describe("CatmaidClient skeleton editing methods", () => { + it("does not cache transient metadata discovery failures as null", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + (client as any).listStacks = vi + .fn() + .mockRejectedValueOnce(new Error("temporary stack lookup failure")) + .mockResolvedValueOnce([{ id: 7, title: "stack" }]); + (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 }, + }); + + 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 }], + }); + + expect((client as any).listStacks).toHaveBeenCalledTimes(2); + expect((client as any).getStackInfo).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); + + it("reads spatial skeleton chunk sizes 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({ + dimension: { x: 10, y: 20, z: 30 }, + 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], + ], + }, + }); + + 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: 120, y: 120, z: 120 }, + { x: 60, y: 60, z: 60 }, + { x: 30, y: 30, z: 30 }, + ], + }); + }); + + it("parses live compact-detail history rows and 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", + ], + [ + 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"], + ], + "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([ + { + nodeId: 22107946, + parentNodeId: undefined, + position: new Float32Array([23697030, 15055839, 16651262]), + segmentId: 2, + radius: 2000, + confidence: 100, + description: "afonso reviewed it", + isTrueEnd: false, + revisionToken: "2026-03-29T10:15:00Z", + }, + { + nodeId: 22107955, + parentNodeId: 22107954, + position: new Float32Array([23705874, 15093672, 16682375]), + segmentId: 2, + radius: 2000, + confidence: 100, + description: "test 123 4", + isTrueEnd: false, + revisionToken: "2026-03-29T10:16:00Z", + }, + { + nodeId: 22107959, + parentNodeId: 22107958, + position: new Float32Array([23704520, 15085237, 16708998]), + segmentId: 2, + radius: 2000, + confidence: 100, + description: undefined, + isTrueEnd: true, + revisionToken: "2026-03-29T10:17:00Z", + }, + ]); + }); + + it("ignores zero-width history rows when compact-detail includes ordering", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.fn().mockResolvedValue([ + [ + [ + 11422971, + 11422970, + 2, + 24313028.0, + 14983333.0, + 6761820.5, + 2000.0, + 5, + "2026-04-14 08:56:49.985049+00:00", + "2026-04-14 08:56:49.985049+00:00", + 2, + ], + [ + 11422972, + 11422971, + 2, + 24318870.0, + 14984255.0, + 6765134.0, + 2000.0, + 5, + "2026-04-14 08:56:49.985049+00:00", + "2026-04-14 08:56:49.985049+00:00", + 2, + ], + ], + [], + {}, + [], + [], + ]); + (client as any).fetch = fetchMock; + + await expect(client.getSkeleton(1140285)).resolves.toEqual([]); + }); + + it("merges skeletons using from/to treenode ids", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.fn().mockResolvedValue({ + result_skeleton_id: 17, + deleted_skeleton_id: 21, + stable_annotation_swap: false, + }); + (client as any).fetch = fetchMock; + + await expect( + client.mergeSkeletons(101, 202, { + nodes: [ + { nodeId: 101, revisionToken: "2026-03-29T11:50:00Z" }, + { nodeId: 202, revisionToken: "2026-03-29T11:51:00Z" }, + ], + }), + ).resolves.toEqual({ + resultSkeletonId: 17, + deletedSkeletonId: 21, + stableAnnotationSwap: false, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const requestBody = getFetchBody(fetchMock); + expect(getFetchPath(fetchMock)).toBe("skeleton/join"); + expect(requestBody.get("from_id")).toBe("101"); + expect(requestBody.get("to_id")).toBe("202"); + expect(requestBody.get("state")).toBe( + JSON.stringify([ + [101, "2026-03-29T11:50:00Z"], + [202, "2026-03-29T11:51:00Z"], + ]), + ); + }); + + it("parses browse node/list rows with revision tokens", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.fn().mockResolvedValue([ + [ + [101, null, 1, 2, 3, 5, 2000, 11, "2026-03-29T11:50:00Z", 2], + [102, 101, 4, 5, 6, 5, 2000, 17, "2026-03-29T11:51:00Z", 2], + ], + [], + {}, + false, + [], + [], + ]); + (client as any).fetch = fetchMock; + + await expect( + client.fetchNodes({ + min: { x: 0, y: 0, z: 0 }, + max: { x: 10, y: 10, z: 10 }, + }), + ).resolves.toEqual([ + { + nodeId: 101, + parentNodeId: undefined, + position: new Float32Array([1, 2, 3]), + segmentId: 11, + revisionToken: "2026-03-29T11:50:00Z", + }, + { + nodeId: 102, + parentNodeId: 101, + position: new Float32Array([4, 5, 6]), + segmentId: 17, + revisionToken: "2026-03-29T11:51:00Z", + }, + ]); + + expect(getFetchPath(fetchMock)).toMatch(/^node\/list\?/); + }); + + it("fetches skeleton root targets", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.fn().mockResolvedValue({ + root_id: 303, + x: 1, + y: 2, + z: 3, + }); + (client as any).fetch = fetchMock; + + await expect(client.getSkeletonRootNode(17)).resolves.toEqual({ + nodeId: 303, + x: 1, + y: 2, + z: 3, + }); + + expect(getFetchPath(fetchMock)).toBe("skeletons/17/root"); + }); + + it("rejects merge state when the provided node ids do not match the request", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.fn(); + (client as any).fetch = fetchMock; + + await expect( + client.mergeSkeletons(101, 202, { + nodes: [{ nodeId: 101, revisionToken: "2026-03-29T11:50:00Z" }], + }), + ).rejects.toThrow( + "CATMAID merge-skeleton node state does not match the requested node ids.", + ); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("returns ids and revisions from addNode and sends CATMAID parent state", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.fn().mockResolvedValue({ + treenode_id: 88, + skeleton_id: 13, + edition_time: "2026-03-29T12:00:00Z", + parent_edition_time: "2026-03-29T12:00:01Z", + }); + (client as any).fetch = fetchMock; + + await expect( + client.addNode(13, 1, 2, 3, 7, { + node: { + nodeId: 7, + revisionToken: "2026-03-29T11:59:00Z", + }, + }), + ).resolves.toEqual({ + treenodeId: 88, + skeletonId: 13, + revisionToken: "2026-03-29T12:00:00Z", + parentRevisionToken: "2026-03-29T12:00:01Z", + }); + + expect(getFetchBody(fetchMock).get("state")).toBe( + JSON.stringify({ parent: [7, "2026-03-29T11:59:00Z"] }), + ); + }); + + it("sends CATMAID root parent state when creating a root node", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.fn().mockResolvedValue({ + treenode_id: 88, + skeleton_id: 13, + edition_time: "2026-03-29T12:00:00Z", + }); + (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, + }); + + expect(getFetchBody(fetchMock).get("state")).toBe( + JSON.stringify({ parent: [-1, ""] }), + ); + }); + + it("inserts nodes using CATMAID local parent-and-child state", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.fn().mockResolvedValue({ + treenode_id: 89, + skeleton_id: 13, + edition_time: "2026-03-29T12:01:00Z", + parent_edition_time: "2026-03-29T12:01:01Z", + child_edition_times: [ + [11, "2026-03-29T12:01:02Z"], + [12, "2026-03-29T12:01:03Z"], + ], + }); + (client as any).fetch = fetchMock; + + await expect( + client.insertNode(13, 1, 2, 3, 7, [11, 12], { + node: { + nodeId: 7, + revisionToken: "2026-03-29T12:00:30Z", + }, + children: [ + { nodeId: 11, revisionToken: "2026-03-29T12:00:31Z" }, + { nodeId: 12, revisionToken: "2026-03-29T12:00:32Z" }, + ], + }), + ).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" }, + ], + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const requestBody = getFetchBody(fetchMock); + expect(getFetchPath(fetchMock)).toBe("treenode/insert"); + expect(requestBody.get("parent_id")).toBe("7"); + expect(requestBody.get("child_id")).toBe("11"); + expect(requestBody.get("takeover_child_ids[0]")).toBe("12"); + expect(requestBody.get("state")).toBe( + JSON.stringify({ + edition_time: "2026-03-29T12:00:30Z", + children: [ + [11, "2026-03-29T12:00:31Z"], + [12, "2026-03-29T12:00:32Z"], + ], + links: [], + }), + ); + }); + + it("reroots skeletons using treenode ids", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ newroot: 202, skeleton_id: 17 }) + .mockResolvedValueOnce([ + [201, 200, 1, 2, 3, 5, 2000, 13, 1711711711.25, 9], + [202, 201, 4, 5, 6, 5, 2000, 13, 1711711712.5, 9], + ]); + (client as any).fetch = fetchMock; + + await expect( + client.rerootSkeleton(202, { + node: { + nodeId: 202, + parentNodeId: 201, + revisionToken: "2026-03-29T12:05:00Z", + }, + parent: { + nodeId: 201, + revisionToken: "2026-03-29T12:04:00Z", + }, + children: [ + { nodeId: 203, revisionToken: "2026-03-29T12:06:00Z" }, + { nodeId: 204, revisionToken: "2026-03-29T12:07:00Z" }, + ], + nodes: [ + { nodeId: 202, revisionToken: "2026-03-29T12:05:00Z" }, + { nodeId: 201, revisionToken: "2026-03-29T12:04:00Z" }, + ], + }), + ).resolves.toEqual({ + nodeRevisionUpdates: [ + { nodeId: 201, revisionToken: "2024-03-29T11:28:31.250Z" }, + { nodeId: 202, revisionToken: "2024-03-29T11:28:32.500Z" }, + ], + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const requestBody = getFetchBody(fetchMock, 0); + expect(getFetchPath(fetchMock)).toBe("skeleton/reroot"); + expect(requestBody.get("treenode_id")).toBe("202"); + expect(requestBody.get("state")).toBe( + JSON.stringify({ + edition_time: "2026-03-29T12:05:00Z", + parent: [201, "2026-03-29T12:04:00Z"], + children: [ + [203, "2026-03-29T12:06:00Z"], + [204, "2026-03-29T12:07:00Z"], + ], + links: [], + }), + ); + expect(getFetchPath(fetchMock, 1)).toBe("treenodes/compact-detail"); + expect(getFetchBody(fetchMock, 1).toString()).toBe( + "treenode_ids%5B0%5D=201&treenode_ids%5B1%5D=202", + ); + }); + + it("rejects reroot state when the parent neighborhood is incomplete", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.fn(); + (client as any).fetch = fetchMock; + + await expect( + client.rerootSkeleton(202, { + node: { + nodeId: 202, + parentNodeId: 201, + revisionToken: "2026-03-29T12:05:00Z", + }, + children: [{ nodeId: 203, revisionToken: "2026-03-29T12:06:00Z" }], + }), + ).rejects.toThrow( + "CATMAID reroot-skeleton parent state does not match the cached skeleton neighborhood.", + ); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("splits skeletons using neighborhood state", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.fn().mockResolvedValue({ + existing_skeleton_id: 17, + new_skeleton_id: 21, + }); + (client as any).fetch = fetchMock; + + await expect( + client.splitSkeleton(202, { + node: { + nodeId: 202, + parentNodeId: 201, + revisionToken: "2026-03-29T12:05:00Z", + }, + parent: { + nodeId: 201, + revisionToken: "2026-03-29T12:04:00Z", + }, + children: [{ nodeId: 203, revisionToken: "2026-03-29T12:06:00Z" }], + }), + ).resolves.toEqual({ + existingSkeletonId: 17, + newSkeletonId: 21, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const requestBody = getFetchBody(fetchMock); + expect(getFetchPath(fetchMock)).toBe("skeleton/split"); + expect(requestBody.get("treenode_id")).toBe("202"); + expect(requestBody.get("state")).toBe( + JSON.stringify({ + edition_time: "2026-03-29T12:05:00Z", + parent: [201, "2026-03-29T12:04:00Z"], + children: [[203, "2026-03-29T12:06:00Z"]], + links: [], + }), + ); + }); + + it("rejects reroot when the follow-up revision refresh is incomplete", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ newroot: 202, skeleton_id: 17 }) + .mockResolvedValueOnce([ + [201, 200, 1, 2, 3, 5, 2000, 13, 1711711711.25, 9], + ]); + (client as any).fetch = fetchMock; + + await expect( + client.rerootSkeleton(202, { + node: { + nodeId: 202, + parentNodeId: 201, + revisionToken: "2026-03-29T12:05:00Z", + }, + parent: { + nodeId: 201, + revisionToken: "2026-03-29T12:04:00Z", + }, + nodes: [ + { nodeId: 202, revisionToken: "2026-03-29T12:05:00Z" }, + { nodeId: 201, revisionToken: "2026-03-29T12:04:00Z" }, + ], + }), + ).rejects.toThrow( + "CATMAID treenodes/compact-detail did not return revision metadata for node(s) 202.", + ); + }); + + it("moves nodes using node revision state and returns the updated revision", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.fn().mockResolvedValue({ + updated: 1, + old_treenodes: [[42, "2026-03-29T12:10:00Z", 1, 2, 3]], + old_connectors: [], + }); + (client as any).fetch = fetchMock; + + await expect( + client.moveNode(42, 10, 11, 12, { + node: { + nodeId: 42, + revisionToken: "2026-03-29T12:00:00Z", + }, + }), + ).resolves.toEqual({ + revisionToken: "2026-03-29T12:10:00Z", + }); + + expect(getFetchBody(fetchMock).get("state")).toBe( + JSON.stringify([[42, "2026-03-29T12:00:00Z"]]), + ); + }); + + it("deletes nodes using neighborhood state and returns child revisions", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.fn().mockResolvedValue({ + success: "Removed treenode successfully.", + children: [ + [12, "2026-03-29T12:20:00Z"], + [13, "2026-03-29T12:20:01Z"], + ], + }); + (client as any).fetch = fetchMock; + + await expect( + client.deleteNode(11, { + childNodeIds: [12, 13], + editContext: { + node: { + nodeId: 11, + parentNodeId: 7, + revisionToken: "2026-03-29T12:15:00Z", + }, + parent: { + nodeId: 7, + revisionToken: "2026-03-29T12:14:00Z", + }, + children: [ + { nodeId: 12, revisionToken: "2026-03-29T12:13:00Z" }, + { nodeId: 13, revisionToken: "2026-03-29T12:13:01Z" }, + ], + }, + }), + ).resolves.toEqual({ + nodeRevisionUpdates: [ + { nodeId: 12, revisionToken: "2026-03-29T12:20:00Z" }, + { nodeId: 13, revisionToken: "2026-03-29T12:20:01Z" }, + ], + }); + + expect(getFetchBody(fetchMock).get("state")).toBe( + JSON.stringify({ + edition_time: "2026-03-29T12:15:00Z", + parent: [7, "2026-03-29T12:14:00Z"], + children: [ + [12, "2026-03-29T12:13:00Z"], + [13, "2026-03-29T12:13:01Z"], + ], + links: [], + }), + ); + }); + + it("updates descriptions without CATMAID node state", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi + .fn() + .mockResolvedValue({ edition_time: "2026-03-29T13:00:00Z" }); + (client as any).fetch = fetchMock; + + await expect( + client.updateDescription(11, "updated description"), + ).resolves.toEqual({ + description: "updated description", + revisionToken: "2026-03-29T13:00:00Z", + }); + + const requestBody = getFetchBody(fetchMock); + expect(requestBody.get("state")).toBeNull(); + expect(requestBody.get("tags")).toBe("updated description"); + 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 + .fn() + .mockResolvedValueOnce({ edition_time: "2026-03-29T13:10:00Z" }) + .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.removeTrueEnd(11)).resolves.toEqual({ + revisionToken: "2026-03-29T13:11:00Z", + }); + + const addTagRequestBody = getFetchBody(fetchMock, 0); + const removeTagRequestBody = getFetchBody(fetchMock, 1); + expect(addTagRequestBody.get("state")).toBeNull(); + expect(removeTagRequestBody.get("state")).toBeNull(); + expect(addTagRequestBody.get("tags")).toBe("ends"); + expect(addTagRequestBody.get("delete_existing")).toBe("false"); + expect(removeTagRequestBody.get("tag")).toBe("ends"); + }); + + it("maps generic confidence percentages to CATMAID confidence levels", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.fn().mockResolvedValue({ + updated_partners: { "11": { edition_time: "2026-03-29T13:20:00Z" } }, + }); + (client as any).fetch = fetchMock; + + await expect( + client.updateConfidence(11, 75, { + node: { + nodeId: 11, + revisionToken: "2026-03-29T13:19:00Z", + }, + }), + ).resolves.toEqual({ + revisionToken: "2026-03-29T13:20:00Z", + }); + + expect(getFetchPath(fetchMock)).toBe("treenodes/11/confidence"); + expect(getFetchBody(fetchMock).get("new_confidence")).toBe("4"); + }); + + it("maps CATMAID state validation failures to a refresh-specific error", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + type: "StateMatchingError", + error: + "The provided state differs from the database state: {'edition_time': '2026-03-29T13:12:00Z'}", + }), + { + status: 400, + headers: { + "Content-Type": "application/json", + }, + }, + ), + ); + + await expect( + client.moveNode(11, 1, 2, 3, { + node: { + nodeId: 11, + revisionToken: "2026-03-29T13:11:00Z", + }, + }), + ).rejects.toThrow( + "CATMAID rejected the edit because the inspected skeleton is out of date. Refresh the skeleton and try again.", + ); + + fetchMock.mockRestore(); + }); + + it("preserves generic CATMAID 400 value errors", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + type: "ValueError", + error: "No valid state provided, missing edition time", + }), + { + status: 400, + headers: { + "Content-Type": "application/json", + }, + }, + ), + ); + + await expect( + client.moveNode(11, 1, 2, 3, { + node: { + nodeId: 11, + revisionToken: "2026-03-29T13:11:00Z", + }, + }), + ).rejects.toMatchObject({ + name: "HttpError", + status: 400, + }); + + fetchMock.mockRestore(); + }); +}); diff --git a/src/datasource/catmaid/api.ts b/src/datasource/catmaid/api.ts new file mode 100644 index 0000000000..be5b7ac08a --- /dev/null +++ b/src/datasource/catmaid/api.ts @@ -0,0 +1,1672 @@ +/** + * @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 { 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, + 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 { HttpError } from "#src/util/http_request.js"; + +interface CatmaidStackInfo { + dimension: { x: number; y: number; z: number }; + resolution: { x: number; y: number; z: number }; + translation: { x: number; y: number; z: number }; + metadata?: { + cache_provider?: string; + spatial_skeleton_chunk_sizes?: Array; + }; +} + +export interface CatmaidToken { + token?: string; +} + +export const credentialsKey = "CATMAID"; +const CATMAID_NO_MATCHING_NODE_PROVIDER_ERROR = + "Could not find matching node provider for request"; +const CATMAID_STATE_MATCHING_ERROR_TYPE = "StateMatchingError"; + +type CatmaidStatePayload = object; + +interface CatmaidDeleteNodeOptions { + childNodeIds?: readonly number[]; + editContext?: SpatiallyIndexedSkeletonEditContext; +} + +class CatmaidNotFoundError extends Error { + constructor(detail?: string) { + super(detail ?? "CATMAID resource not found."); + this.name = "CatmaidNotFoundError"; + } +} + +export class CatmaidStateValidationError extends Error { + constructor(detail?: string) { + super( + detail === undefined + ? "CATMAID rejected the edit because the inspected skeleton is out of date. Refresh the skeleton and try again." + : `CATMAID rejected the edit because the inspected skeleton is out of date. Refresh the skeleton and try again. ${detail}`, + ); + this.name = "CatmaidStateValidationError"; + } +} + +const CATMAID_TRUE_END_LABEL = "ends"; +const CATMAID_CLOSED_END_LABEL_PATTERNS = [ + /^uncertain continuation$/i, + /^not a branch$/i, + /^soma$/i, + /^(really|uncertain|anterior|posterior)?\s?ends?$/i, +]; + +function isCatmaidClosedEndLabel(label: string) { + const normalized = label.trim(); + return ( + normalized.length > 0 && + CATMAID_CLOSED_END_LABEL_PATTERNS.some((pattern) => + pattern.test(normalized), + ) + ); +} + +function includesNoMatchingNodeProviderError(value: unknown): boolean { + return ( + typeof value === "string" && + value.includes(CATMAID_NO_MATCHING_NODE_PROVIDER_ERROR) + ); +} + +function isNoMatchingNodeProviderErrorPayload(payload: unknown): boolean { + if (payload === null || typeof payload !== "object") return false; + const value = payload as { error?: unknown; detail?: unknown }; + return ( + includesNoMatchingNodeProviderError(value.error) || + includesNoMatchingNodeProviderError(value.detail) + ); +} + +function getCatmaidErrorMessage(payload: unknown): string | undefined { + if ( + payload === null || + typeof payload !== "object" || + Array.isArray(payload) + ) { + return undefined; + } + const value = payload as { error?: unknown }; + return typeof value.error === "string" ? value.error.trim() : undefined; +} + +function isCatmaidNotFoundPayload(payload: unknown): boolean { + if (payload === null || typeof payload !== "object" || Array.isArray(payload)) + return false; + const value = payload as { detail?: unknown }; + return ( + typeof value.detail === "string" && value.detail.includes("doesn't exist") + ); +} + +function isCatmaidStateMatchingErrorPayload(payload: unknown): boolean { + if ( + payload === null || + typeof payload !== "object" || + Array.isArray(payload) + ) { + return false; + } + const value = payload as { type?: unknown }; + return value.type === CATMAID_STATE_MATCHING_ERROR_TYPE; +} + +interface ParsedCatmaidNodeLabel { + label: string; + time?: number; +} + +function normalizeCatmaidDescription( + labels: readonly ParsedCatmaidNodeLabel[] | undefined, +): string | undefined { + if (labels === undefined || labels.length === 0) { + return undefined; + } + const descriptionLabels = labels.filter( + ({ label }) => + label.trim().length > 0 && + label.trim().toLowerCase() !== CATMAID_TRUE_END_LABEL && + !isCatmaidClosedEndLabel(label), + ); + const timedDescriptionLabels = descriptionLabels.filter( + ({ time }) => time !== undefined, + ); + const currentDescriptionLabels = + timedDescriptionLabels.length === 0 + ? descriptionLabels + : (() => { + const latestTime = Math.max( + ...timedDescriptionLabels.map(({ time }) => time!), + ); + return timedDescriptionLabels.filter( + ({ time }) => time === latestTime, + ); + })(); + return currentDescriptionLabels.length === 0 + ? undefined + : currentDescriptionLabels.map(({ label }) => label).join("\n"); +} + +function parseCatmaidLabelNodeReference(entry: unknown): + | { + nodeId: number; + time?: number; + } + | undefined { + const rawNodeId = Array.isArray(entry) ? entry[0] : entry; + const nodeId = Math.round(Number(rawNodeId)); + if (!Number.isSafeInteger(nodeId) || nodeId <= 0) return undefined; + const time = Array.isArray(entry) + ? getComparableCatmaidRevisionTime(entry[1]) + : undefined; + return { nodeId, time }; +} + +function addParsedCatmaidNodeLabel( + labelsByNodeId: Map, + nodeId: number, + label: ParsedCatmaidNodeLabel, +) { + const existingLabels = labelsByNodeId.get(nodeId); + if (existingLabels === undefined) { + labelsByNodeId.set(nodeId, [label]); + return; + } + if ( + !existingLabels.some( + (existingLabel) => + existingLabel.label === label.label && + existingLabel.time === label.time, + ) + ) { + existingLabels.push(label); + } +} + +function parseCatmaidNodeLabels( + rawLabels: unknown, +): Map { + const labelsByNodeId = new Map(); + if (rawLabels === null || typeof rawLabels !== "object") { + return labelsByNodeId; + } + for (const [key, value] of Object.entries( + rawLabels as Record, + )) { + if (!Array.isArray(value) || value.length === 0) continue; + const stringValues = value.filter( + (entry): entry is string => typeof entry === "string", + ); + if (stringValues.length === value.length) { + const nodeId = Number(key); + if (!Number.isFinite(nodeId)) continue; + const labels = stringValues + .map((label) => label.trim()) + .filter((label) => label.length > 0); + if (labels.length === 0) continue; + labelsByNodeId.set( + Math.round(nodeId), + labels.map((label) => ({ label })), + ); + continue; + } + const nodeReferences = value.map(parseCatmaidLabelNodeReference); + if (nodeReferences.some((nodeReference) => nodeReference === undefined)) + continue; + const label = key.trim(); + if (label.length === 0) continue; + for (const nodeReference of nodeReferences) { + if (nodeReference === undefined) continue; + addParsedCatmaidNodeLabel(labelsByNodeId, nodeReference.nodeId, { + label, + time: nodeReference.time, + }); + } + } + return labelsByNodeId; +} + +function getCatmaidNodeDescriptions( + labelsByNodeId: ReadonlyMap, +) { + const descriptionsByNodeId = new Map(); + for (const [nodeId, labels] of labelsByNodeId) { + const description = normalizeCatmaidDescription(labels); + if (description !== undefined) { + descriptionsByNodeId.set(nodeId, description); + } + } + return descriptionsByNodeId; +} + +function getCatmaidTrueEndNodes( + labelsByNodeId: ReadonlyMap, +) { + const trueEndByNodeId = new Map(); + for (const [nodeId, labels] of labelsByNodeId) { + const isTrueEnd = labels.some( + ({ label }) => label.trim().toLowerCase() === CATMAID_TRUE_END_LABEL, + ); + if (isTrueEnd) { + trueEndByNodeId.set(nodeId, true); + } + } + return trueEndByNodeId; +} + +async function tryReadJsonPayload( + response: Response, +): Promise { + try { + return await response.json(); + } catch { + return undefined; + } +} + +async function tryReadErrorPayload( + response: Response, +): Promise { + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + return tryReadJsonPayload(response); + } + try { + const text = await response.text(); + if (!text) return undefined; + try { + return JSON.parse(text); + } catch { + return { error: text }; + } + } catch { + return undefined; + } +} + +function mapCatmaidConfidenceToPercent(confidence: number | undefined) { + if (confidence === undefined) return undefined; + const normalized = Math.max( + 1, + Math.min( + SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES.length, + Math.round(confidence), + ), + ); + return SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES[normalized - 1]; +} + +function mapPercentConfidenceToCatmaid(confidence: number) { + const normalized = Math.max( + SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES[0], + Math.min( + SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES[ + SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES.length - 1 + ], + confidence, + ), + ); + let bestIndex = 0; + let bestDistance = Math.abs( + SPATIALLY_INDEXED_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]; + const distance = Math.abs(candidate - normalized); + if ( + distance < bestDistance || + (distance === bestDistance && + candidate > SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES[bestIndex]) + ) { + bestDistance = distance; + bestIndex = i; + } + } + return bestIndex + 1; +} + +function getCatmaidProjectSpaceBounds(info: CatmaidStackInfo): { + min: { x: number; y: number; z: number }; + max: { x: number; y: number; z: number }; +} { + const { dimension, resolution, translation } = info; + const offsetX = translation?.x ?? 0; + const offsetY = translation?.y ?? 0; + const offsetZ = translation?.z ?? 0; + + // 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, + }, + }; +} + +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); + + // 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)); + + return { left, top, z1, right, bottom, z2 }; +} + +function appendNodeUpdateRows( + body: URLSearchParams, + key: string, + rows: Array<[number, number, number, number]>, +) { + // CATMAID get_request_list parses nested lists from bracketed keys + // (e.g. t[0][0]=id, t[0][1]=x, ...), not from a JSON string. + for (let rowIndex = 0; rowIndex < rows.length; ++rowIndex) { + const row = rows[rowIndex]; + for (let colIndex = 0; colIndex < row.length; ++colIndex) { + body.append(`${key}[${rowIndex}][${colIndex}]`, row[colIndex].toString()); + } + } +} + +function appendScalarList( + body: URLSearchParams, + key: string, + values: readonly number[], +) { + for (let index = 0; index < values.length; ++index) { + body.append(`${key}[${index}]`, values[index].toString()); + } +} + +function appendCatmaidState( + body: URLSearchParams, + state?: CatmaidStatePayload, +) { + if (state === undefined) { + return; + } + body.append("state", JSON.stringify(state)); +} + +function normalizeCatmaidRevisionToken(value: unknown): string | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + const milliseconds = Math.abs(value) < 1e12 ? value * 1000 : value; + return new Date(milliseconds).toISOString(); + } + if (typeof value === "string") { + const normalizedValue = value.trim(); + if (normalizedValue.length > 0) { + return normalizedValue; + } + } + return undefined; +} + +const CATMAID_TIMESTAMP_WITH_SPACE_PATTERN = + /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)(Z|[+-]\d{2}:\d{2})$/; + +function getComparableCatmaidRevisionTime(value: unknown) { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value !== "string") { + return undefined; + } + const normalizedValue = value.trim(); + if (normalizedValue.length === 0) { + return undefined; + } + const parsedValue = Date.parse( + normalizedValue.replace(CATMAID_TIMESTAMP_WITH_SPACE_PATTERN, "$1T$2$3"), + ); + return Number.isFinite(parsedValue) ? parsedValue : undefined; +} + +function isCatmaidLiveHistoryRow(row: readonly unknown[]) { + const ordering = Number(row[10]); + if (Number.isFinite(ordering)) { + return Math.round(ordering) === 1; + } + if (row.length < 10) { + return true; + } + const lowerBound = getComparableCatmaidRevisionTime(row[8]); + const upperBound = getComparableCatmaidRevisionTime(row[9]); + if (lowerBound === undefined || upperBound === undefined) { + return true; + } + return upperBound <= lowerBound; +} + +function getCatmaidHistoryRevisionToken( + row: readonly unknown[], +): string | undefined { + return normalizeCatmaidRevisionToken(row[8]); +} + +function parseCatmaidSkeletonRootTarget( + response: any, +): SpatiallyIndexedSkeletonNavigationTarget { + if (!response || typeof response !== "object" || Array.isArray(response)) { + throw new Error( + "CATMAID skeleton root endpoint returned an unexpected response format.", + ); + } + + const { root_id, x, y, z } = response as Record; + const nodeId = Number(root_id); + const px = Number(x); + const py = Number(y); + const pz = Number(z); + + if ( + Number.isSafeInteger(nodeId) && + nodeId > 0 && + Number.isFinite(px) && + Number.isFinite(py) && + Number.isFinite(pz) + ) { + return { nodeId, x: px, y: py, z: pz }; + } + + throw new Error( + "CATMAID skeleton root endpoint returned an unexpected response format.", + ); +} + +function requireCatmaidRevisionToken( + revisionToken: string | undefined, + operation: string, + role: string, +) { + if (revisionToken === undefined) { + throw new Error( + `CATMAID ${operation} is missing the required ${role} revision state.`, + ); + } + return revisionToken; +} + +function buildCatmaidNodeState( + operation: string, + editContext?: SpatiallyIndexedSkeletonEditContext, + expectedNodeId?: number, +) { + const node = editContext?.node; + if (node === undefined) { + throw new Error(`CATMAID ${operation} requires inspected node state.`); + } + if (expectedNodeId !== undefined && node.nodeId !== expectedNodeId) { + throw new Error( + `CATMAID ${operation} node state does not match requested node id ${expectedNodeId}.`, + ); + } + return { + edition_time: requireCatmaidRevisionToken( + node.revisionToken, + operation, + "node", + ), + }; +} + +function buildCatmaidMultiNodeState( + operation: string, + editContext?: SpatiallyIndexedSkeletonEditContext, + expectedNodeIds?: readonly number[], +) { + const nodes = + editContext?.nodes ?? + (editContext?.node === undefined ? undefined : [editContext.node]); + if (nodes === undefined || nodes.length === 0) { + throw new Error(`CATMAID ${operation} requires inspected node state.`); + } + if ( + expectedNodeIds !== undefined && + (nodes.length !== expectedNodeIds.length || + nodes.some((node, index) => node.nodeId !== expectedNodeIds[index])) + ) { + throw new Error( + `CATMAID ${operation} node state does not match the requested node ids.`, + ); + } + return nodes.map((node): [number, string] => [ + node.nodeId, + requireCatmaidRevisionToken(node.revisionToken, operation, "node"), + ]); +} + +function buildCatmaidAddNodeState( + parentId: number | undefined, + editContext?: SpatiallyIndexedSkeletonEditContext, +) { + if (parentId === undefined) { + return { + parent: [-1, ""], + }; + } + const parentNode = editContext?.node; + if (parentNode === undefined) { + throw new Error( + "CATMAID add-node with a parent requires inspected parent state.", + ); + } + if (parentNode.nodeId !== parentId) { + throw new Error( + `CATMAID add-node parent state does not match requested parent id ${parentId}.`, + ); + } + return { + parent: [ + parentNode.nodeId, + requireCatmaidRevisionToken( + parentNode.revisionToken, + "add-node", + "parent", + ), + ], + }; +} + +function buildCatmaidNeighborhoodState( + operation: string, + editContext?: SpatiallyIndexedSkeletonEditContext, + options: { + expectedNodeId?: number; + expectedChildIds?: readonly number[]; + } = {}, +) { + const node = editContext?.node; + if (node === undefined) { + throw new Error(`CATMAID ${operation} requires inspected node state.`); + } + if ( + options.expectedNodeId !== undefined && + node.nodeId !== options.expectedNodeId + ) { + throw new Error( + `CATMAID ${operation} node state does not match requested node id ${options.expectedNodeId}.`, + ); + } + if ( + node.parentNodeId === undefined + ? editContext?.parent !== undefined + : editContext?.parent === undefined + ) { + throw new Error( + `CATMAID ${operation} parent state does not match the cached skeleton neighborhood.`, + ); + } + if ( + editContext?.parent !== undefined && + node.parentNodeId !== editContext.parent.nodeId + ) { + throw new Error( + `CATMAID ${operation} parent state does not match the cached skeleton neighborhood.`, + ); + } + const childStates = editContext?.children ?? []; + const expectedChildIds = options.expectedChildIds; + if ( + expectedChildIds !== undefined && + childStates.length !== expectedChildIds.length + ) { + throw new Error( + `CATMAID ${operation} requires revision state for all direct child nodes.`, + ); + } + if ( + expectedChildIds !== undefined && + childStates.some((child, index) => child.nodeId !== expectedChildIds[index]) + ) { + throw new Error( + `CATMAID ${operation} child state does not match the cached skeleton neighborhood.`, + ); + } + return { + edition_time: requireCatmaidRevisionToken( + node.revisionToken, + operation, + "node", + ), + ...(editContext?.parent === undefined + ? {} + : { + parent: [ + editContext.parent.nodeId, + requireCatmaidRevisionToken( + editContext.parent.revisionToken, + operation, + "parent", + ), + ], + }), + children: childStates.map((child): [number, string] => [ + child.nodeId, + requireCatmaidRevisionToken(child.revisionToken, operation, "child"), + ]), + links: [], + }; +} + +function buildCatmaidInsertNodeState( + parentId: number, + childNodeIds: readonly number[], + editContext?: SpatiallyIndexedSkeletonEditContext, +) { + const parentNode = editContext?.node; + if (parentNode === undefined) { + throw new Error("CATMAID insert-node requires inspected parent state."); + } + if (parentNode.nodeId !== parentId) { + throw new Error( + `CATMAID insert-node parent state does not match requested parent id ${parentId}.`, + ); + } + const childStates = editContext?.children ?? []; + if (childStates.length !== childNodeIds.length) { + throw new Error( + "CATMAID insert-node requires revision state for all reattached child nodes.", + ); + } + if ( + childStates.some((child, index) => child.nodeId !== childNodeIds[index]) + ) { + throw new Error( + "CATMAID insert-node child state does not match the requested child ids.", + ); + } + return { + edition_time: requireCatmaidRevisionToken( + parentNode.revisionToken, + "insert-node", + "parent", + ), + children: childStates.map((child): [number, string] => [ + child.nodeId, + requireCatmaidRevisionToken(child.revisionToken, "insert-node", "child"), + ]), + links: [], + }; +} + +function getCatmaidSingleNodeRevisionResult( + revisionToken: string | undefined, +): SpatiallyIndexedSkeletonNodeRevisionResult { + return revisionToken === undefined ? {} : { revisionToken }; +} + +function parseCatmaidNodeRevisionUpdates( + rows: unknown, +): SpatiallyIndexedSkeletonNodeRevisionUpdate[] { + if (!Array.isArray(rows)) { + throw new Error( + "CATMAID treenodes/compact-detail endpoint returned an unexpected response format.", + ); + } + const revisionUpdates: SpatiallyIndexedSkeletonNodeRevisionUpdate[] = []; + for (const row of rows) { + if (!Array.isArray(row) || row.length < 9) continue; + const nodeId = Number(row[0]); + const revisionToken = normalizeCatmaidRevisionToken(row[8]); + if (!Number.isFinite(nodeId) || revisionToken === undefined) continue; + revisionUpdates.push({ + nodeId: Math.round(nodeId), + revisionToken, + }); + } + return revisionUpdates; +} + +function parseCatmaidMoveRevisionToken( + response: any, + nodeId: number, +): string | undefined { + const updatedRows = Array.isArray(response?.old_treenodes) + ? response.old_treenodes + : []; + for (const row of updatedRows) { + if (!Array.isArray(row) || Number(row[0]) !== nodeId) continue; + return normalizeCatmaidRevisionToken(row[1]); + } + return normalizeCatmaidRevisionToken(response?.edition_time); +} + +function parseCatmaidUpdatedNodesRevisionToken( + response: any, + nodeId: number, +): string | undefined { + const updatedNodes = response?.updated_nodes; + if (updatedNodes !== null && typeof updatedNodes === "object") { + const directMatch = (updatedNodes as Record)[nodeId]; + const directRevision = normalizeCatmaidRevisionToken( + directMatch?.edition_time, + ); + if (directRevision !== undefined) { + return directRevision; + } + } + return normalizeCatmaidRevisionToken(response?.edition_time); +} + +function parseCatmaidConfidenceRevisionToken( + response: any, + nodeId: number, +): string | undefined { + const directRevision = parseCatmaidUpdatedNodesRevisionToken( + response, + nodeId, + ); + if (directRevision !== undefined) { + return directRevision; + } + const updatedPartners = response?.updated_partners; + if (updatedPartners === null || typeof updatedPartners !== "object") { + return undefined; + } + for (const value of Object.values(updatedPartners as Record)) { + const revisionToken = normalizeCatmaidRevisionToken(value?.edition_time); + if (revisionToken !== undefined) { + return revisionToken; + } + } + return undefined; +} + +function parseCatmaidChildRevisionUpdates( + value: unknown, +): readonly SpatiallyIndexedSkeletonNodeRevisionUpdate[] { + const revisionUpdates: SpatiallyIndexedSkeletonNodeRevisionUpdate[] = []; + const children = Array.isArray(value) ? value : []; + for (const child of children) { + if (!Array.isArray(child) || child.length < 2) continue; + const nodeId = Number(child[0]); + const revisionToken = normalizeCatmaidRevisionToken(child[1]); + if (!Number.isFinite(nodeId) || revisionToken === undefined) continue; + revisionUpdates.push({ + nodeId: Math.round(nodeId), + revisionToken, + }); + } + return revisionUpdates; +} + +function parseCatmaidDeleteRevisionUpdates( + response: any, +): readonly SpatiallyIndexedSkeletonNodeRevisionUpdate[] { + return parseCatmaidChildRevisionUpdates(response?.children); +} + +function fetchWithCatmaidCredentials( + credentialsProvider: CredentialsProvider, + input: string, + init: RequestInit, +): Promise { + return fetchOkWithCredentials( + credentialsProvider, + input, + init, + (credentials: CatmaidToken, init: RequestInit) => { + const newInit: RequestInit = { ...init }; + if (credentials.token) { + newInit.headers = { + ...newInit.headers, + Authorization: `Token ${credentials.token}`, + }; + } + return newInit; + }, + (error) => { + const { status } = error; + if (status === 403 || status === 401) { + // Authorization needed. Retry with refreshed token. + return "refresh"; + } + throw error; + }, + ); +} + +export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { + private metadataInfoPromise: Promise | undefined; + private readonly msgpackUnpackr = new Unpackr({ + mapsAsObjects: false, + int64AsType: "number", + }); + + constructor( + public baseUrl: string, + public projectId: number, + public credentialsProvider?: CredentialsProvider, + ) {} + + private async normalizeFetchError(error: unknown): Promise { + if (!(error instanceof HttpError) || error.response === undefined) { + return error; + } + const payload = await tryReadErrorPayload(error.response.clone()); + if (isCatmaidStateMatchingErrorPayload(payload)) { + return new CatmaidStateValidationError(getCatmaidErrorMessage(payload)); + } + if (error.status === 404 && isCatmaidNotFoundPayload(payload)) { + const detail = (payload as { detail: string }).detail; + return new CatmaidNotFoundError(detail); + } + return error; + } + + private async fetch( + endpoint: string, + options: RequestInit = {}, + expectMsgpack: boolean = false, + ): Promise { + // Ensure baseUrl doesn't have trailing slash and endpoint doesn't have leading slash + const baseUrl = this.baseUrl.replace(/\/$/, ""); + const url = `${baseUrl}/${this.projectId}/${endpoint}`; + const headers = new Headers(options.headers); + // CATMAID API often expects form-encoded data for POST + if (options.method === "POST" && options.body instanceof URLSearchParams) { + headers.append("Content-Type", "application/x-www-form-urlencoded"); + } + + let response: Response; + try { + if (this.credentialsProvider) { + response = await fetchWithCatmaidCredentials( + this.credentialsProvider, + url, + { ...options, headers }, + ); + } else { + response = await fetch(url, { ...options, headers }); + if (!response.ok) { + throw HttpError.fromResponse(response); + } + } + } catch (error) { + throw await this.normalizeFetchError(error); + } + + if (expectMsgpack) { + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + return response.json(); + } + const buffer = await response.arrayBuffer(); + try { + return this.msgpackUnpackr.unpack(new Uint8Array(buffer)); + } catch (error) { + // Some CATMAID deployments return a JSON error body with a msgpack request. + try { + return JSON.parse(new TextDecoder().decode(buffer)); + } catch { + throw error; + } + } + } + + return response.json(); + } + + private async isNoMatchingNodeProviderHttpError( + error: unknown, + ): Promise { + if (!(error instanceof HttpError) || error.response === undefined) { + return false; + } + const payload = await tryReadErrorPayload(error.response.clone()); + return isNoMatchingNodeProviderErrorPayload(payload); + } + + async listSkeletons(): Promise { + return this.fetch("skeletons/"); + } + + private async listStacks(): Promise<{ id: number }[]> { + return this.fetch("stacks"); + } + + private async getStackInfo(stackId: number): Promise { + return this.fetch(`stack/${stackId}/info`); + } + + private async loadMetadataInfo(): Promise { + const stacks = await this.listStacks(); + if (!stacks || stacks.length === 0) return null; + return this.getStackInfo(stacks[0].id); + } + + private getMetadataInfo(): Promise { + let promise = this.metadataInfoPromise; + if (promise === undefined) { + promise = this.loadMetadataInfo(); + this.metadataInfoPromise = promise; + promise.catch(() => { + if (this.metadataInfoPromise === promise) { + this.metadataInfoPromise = undefined; + } + }); + } + return promise; + } + + private async tryGetMetadataInfo(): Promise { + try { + return await this.getMetadataInfo(); + } catch (e) { + console.warn("Failed to fetch stack info:", e); + return null; + } + } + + 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], + }); + } + } + } + + // If no chunk sizes are specified, use the bounds-derived default. + if (gridSizes.length === 0) { + gridSizes.push(getDefaultSpatiallyIndexedSkeletonChunkSize(bounds)); + } + + return gridSizes; + } + + async getSpatialIndexMetadata(): Promise { + const info = await this.tryGetMetadataInfo(); + if (info === null) { + return null; + } + const bounds = getCatmaidProjectSpaceBounds(info); + return { + bounds, + resolution: info.resolution, + gridCellSizes: this.getGridCellSizesFromMetadataInfo(info, bounds), + }; + } + + async getCacheProvider(): Promise { + const info = await this.tryGetMetadataInfo(); + return info?.metadata?.cache_provider; + } + + async getSkeleton( + skeletonId: number, + options: { signal?: AbortSignal } = {}, + ): Promise { + let data: any; + try { + data = await this.fetch( + `skeletons/${skeletonId}/compact-detail?with_tags=true&with_history=true`, + { signal: options.signal }, + ); + } catch (error) { + if (error instanceof CatmaidNotFoundError) { + return []; + } else { + throw error; + } + } + const rawNodes = Array.isArray(data?.[0]) ? data[0] : []; + const labelsByNodeId = parseCatmaidNodeLabels(data?.[2]); + const descriptionByNodeId = getCatmaidNodeDescriptions(labelsByNodeId); + const trueEndByNodeId = getCatmaidTrueEndNodes(labelsByNodeId); + const liveNodes = new Map(); + for (const node of rawNodes) { + if ( + !Array.isArray(node) || + node.length < 8 || + !isCatmaidLiveHistoryRow(node) + ) { + continue; + } + const nodeId = Number(node[0]); + if (!Number.isFinite(nodeId) || liveNodes.has(Math.round(nodeId))) { + continue; + } + liveNodes.set(Math.round(nodeId), node); + } + return [...liveNodes.values()].map((n) => ({ + nodeId: n[0], + parentNodeId: n[1] ?? undefined, + position: new Float32Array([n[3], n[4], n[5]]), + segmentId: skeletonId, + radius: Number.isFinite(n[6]) ? n[6] : undefined, + confidence: Number.isFinite(n[7]) + ? mapCatmaidConfidenceToPercent(n[7]) + : undefined, + description: descriptionByNodeId.get(Number(n[0])), + isTrueEnd: trueEndByNodeId.has(Number(n[0])), + revisionToken: getCatmaidHistoryRevisionToken(n), + })); + } + + async fetchNodes( + boundingBox: { + min: { x: number; y: number; z: number }; + max: { x: number; y: number; z: number }; + }, + lod: number = 0, + options: { + cacheProvider?: string; + signal?: AbortSignal; + } = {}, + ): Promise { + const { cacheProvider, signal } = options; + const normalizedBoundingBox = normalizeBoundingBoxForNodeList(boundingBox); + const params = new URLSearchParams({ + left: normalizedBoundingBox.left.toString(), + top: normalizedBoundingBox.top.toString(), + z1: normalizedBoundingBox.z1.toString(), + right: normalizedBoundingBox.right.toString(), + bottom: normalizedBoundingBox.bottom.toString(), + z2: normalizedBoundingBox.z2.toString(), + lod_type: "percent", + lod: lod.toString(), + format: "msgpack", + }); + + // Add cache provider if available + if (cacheProvider) { + params.append("src", cacheProvider); + } + + let data: any; + try { + data = await this.fetch( + `node/list?${params.toString()}`, + { signal }, + true, + ); + } catch (error) { + if (await this.isNoMatchingNodeProviderHttpError(error)) { + return []; + } + throw error; + } + + if (isNoMatchingNodeProviderErrorPayload(data)) { + return []; + } + + if (!Array.isArray(data) || !Array.isArray(data[0])) { + throw new Error( + "CATMAID node/list endpoint returned an unexpected response format.", + ); + } + + // Check if limit was reached for the first LOD level + if (data[3]) { + console.warn( + "CATMAID node/list endpoint returned limit_reached=true. Some nodes may be missing.", + ); + } + + // Process first LOD level (data[0]) + const nodes: SpatiallyIndexedSkeletonNodeBase[] = data[0].map( + (n: any[]) => ({ + nodeId: n[0], + parentNodeId: n[1] ?? undefined, + position: new Float32Array([n[2], n[3], n[4]]), + segmentId: n[7], + revisionToken: normalizeCatmaidRevisionToken(n[8]), + }), + ); + + // Process additional LOD levels. + const extraNodes = data[5]; + if (Array.isArray(extraNodes)) { + for (const lodLevel of extraNodes) { + if (lodLevel[3]) { + console.warn( + "CATMAID node/list endpoint returned limit_reached=true for an extra LOD level. Some nodes may be missing.", + ); + } + const treenodes = lodLevel[0]; + if (Array.isArray(treenodes)) { + for (const n of treenodes) { + nodes.push({ + nodeId: n[0], + parentNodeId: n[1] ?? undefined, + position: new Float32Array([n[2], n[3], n[4]]), + segmentId: n[7], + revisionToken: normalizeCatmaidRevisionToken(n[8]), + }); + } + } + } + } + + return nodes; + } + + async moveNode( + nodeId: number, + x: number, + y: number, + z: number, + editContext?: SpatiallyIndexedSkeletonEditContext, + ): Promise { + const body = new URLSearchParams(); + appendNodeUpdateRows(body, "t", [[nodeId, x, y, z]]); + appendCatmaidState( + body, + buildCatmaidMultiNodeState("move-node", editContext, [nodeId]), + ); + + const response = await this.fetch(`node/update`, { + method: "POST", + body: body, + }); + return getCatmaidSingleNodeRevisionResult( + parseCatmaidMoveRevisionToken(response, nodeId), + ); + } + + async getSkeletonRootNode( + skeletonId: number, + ): Promise { + const response = await this.fetch(`skeletons/${skeletonId}/root`); + return parseCatmaidSkeletonRootTarget(response); + } + + async rerootSkeleton( + nodeId: number, + editContext?: SpatiallyIndexedSkeletonEditContext, + ): Promise { + const body = new URLSearchParams({ + treenode_id: nodeId.toString(), + }); + appendCatmaidState( + body, + buildCatmaidNeighborhoodState("reroot-skeleton", editContext, { + expectedNodeId: nodeId, + }), + ); + await this.fetch(`skeleton/reroot`, { + method: "POST", + body, + }); + const rerootedNodeIds = + editContext?.nodes + ?.map((value) => Number(value.nodeId)) + .filter((value) => Number.isFinite(value)) + .map((value) => Math.round(value)) ?? []; + return { + nodeRevisionUpdates: await this.fetchNodeRevisionUpdates(rerootedNodeIds), + }; + } + + private async fetchNodeRevisionUpdates( + nodeIds: readonly number[], + ): Promise { + const normalizedNodeIds = [ + ...new Set( + nodeIds + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value)) + .map((value) => Math.round(value)), + ), + ].sort((a, b) => a - b); + if (normalizedNodeIds.length === 0) { + return []; + } + const body = new URLSearchParams(); + appendScalarList(body, "treenode_ids", normalizedNodeIds); + const response = await this.fetch(`treenodes/compact-detail`, { + method: "POST", + body, + }); + const revisionUpdates = parseCatmaidNodeRevisionUpdates(response); + const returnedNodeIds = new Set( + revisionUpdates.map((update) => update.nodeId), + ); + const missingNodeIds = normalizedNodeIds.filter( + (nodeId) => !returnedNodeIds.has(nodeId), + ); + if (missingNodeIds.length > 0) { + throw new Error( + `CATMAID treenodes/compact-detail did not return revision metadata for node(s) ${missingNodeIds.join(", ")}.`, + ); + } + return revisionUpdates; + } + + async deleteNode( + nodeId: number, + options: CatmaidDeleteNodeOptions = {}, + ): Promise { + const { childNodeIds = [], editContext } = options; + const normalizedChildIds = [ + ...new Set( + childNodeIds + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value)) + .map((value) => Math.round(value)), + ), + ].sort((a, b) => a - b); + const body = new URLSearchParams({ + treenode_id: nodeId.toString(), + }); + appendCatmaidState( + body, + buildCatmaidNeighborhoodState("delete-node", editContext, { + expectedNodeId: nodeId, + expectedChildIds: normalizedChildIds, + }), + ); + const response = await this.fetch(`treenode/delete`, { + method: "POST", + body: body, + }); + if (response?.success === undefined) { + throw new Error("Delete endpoint returned an unexpected response."); + } + return { + nodeRevisionUpdates: parseCatmaidDeleteRevisionUpdates(response), + }; + } + + async addNode( + skeletonId: number, + x: number, + y: number, + z: number, + parentId?: number, + editContext?: SpatiallyIndexedSkeletonEditContext, + ): Promise { + const body = new URLSearchParams({ + x: x.toString(), + y: y.toString(), + z: z.toString(), + parent_id: (parentId ?? -1).toString(), + }); + if (Number.isSafeInteger(skeletonId) && skeletonId > 0) { + body.append("skeleton_id", skeletonId.toString()); + } + appendCatmaidState(body, buildCatmaidAddNodeState(parentId, editContext)); + + const res = await this.fetch(`treenode/create`, { + method: "POST", + body: body, + }); + const treenodeId = Number(res?.treenode_id); + const nextSkeletonId = Number(res?.skeleton_id); + if (!Number.isFinite(treenodeId)) { + throw new Error( + "CATMAID treenode/create did not return a valid treenode_id.", + ); + } + if (!Number.isFinite(nextSkeletonId)) { + throw new Error( + "CATMAID treenode/create did not return a valid skeleton_id.", + ); + } + return { + treenodeId, + skeletonId: nextSkeletonId, + revisionToken: normalizeCatmaidRevisionToken(res?.edition_time), + parentRevisionToken: normalizeCatmaidRevisionToken( + res?.parent_edition_time, + ), + }; + } + + async insertNode( + skeletonId: number, + x: number, + y: number, + z: number, + parentId: number, + childNodeIds: readonly number[], + editContext?: SpatiallyIndexedSkeletonEditContext, + ): Promise { + const normalizedChildIds = [ + ...new Set( + childNodeIds + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value)) + .map((value) => Math.round(value)), + ), + ].sort((a, b) => a - b); + if (normalizedChildIds.length === 0) { + throw new Error( + "CATMAID insert-node requires at least one child node to reattach.", + ); + } + const body = new URLSearchParams({ + x: x.toString(), + y: y.toString(), + z: z.toString(), + parent_id: parentId.toString(), + child_id: normalizedChildIds[0].toString(), + }); + if (Number.isSafeInteger(skeletonId) && skeletonId > 0) { + body.append("skeleton_id", skeletonId.toString()); + } + appendScalarList(body, "takeover_child_ids", normalizedChildIds.slice(1)); + appendCatmaidState( + body, + buildCatmaidInsertNodeState(parentId, normalizedChildIds, editContext), + ); + + const response = await this.fetch(`treenode/insert`, { + method: "POST", + body, + }); + const treenodeId = Number(response?.treenode_id); + const nextSkeletonId = Number(response?.skeleton_id); + if (!Number.isFinite(treenodeId)) { + throw new Error( + "CATMAID treenode/insert did not return a valid treenode_id.", + ); + } + if (!Number.isFinite(nextSkeletonId)) { + throw new Error( + "CATMAID treenode/insert did not return a valid skeleton_id.", + ); + } + return { + treenodeId: Math.round(treenodeId), + skeletonId: Math.round(nextSkeletonId), + revisionToken: normalizeCatmaidRevisionToken(response?.edition_time), + parentRevisionToken: normalizeCatmaidRevisionToken( + response?.parent_edition_time, + ), + nodeRevisionUpdates: parseCatmaidChildRevisionUpdates( + response?.child_edition_times, + ), + }; + } + + private async updateNodeLabel( + nodeId: number, + endpoint: "update" | "remove", + body: URLSearchParams, + ) { + return this.fetch(`label/treenode/${nodeId}/${endpoint}`, { + method: "POST", + body, + }); + } + + private normalizeNodeLabels(labels: readonly string[]) { + const normalizedLabels: string[] = []; + const seen = new Set(); + for (const label of labels) { + const trimmed = label.trim(); + if (trimmed.length === 0) continue; + if (trimmed.includes(",")) { + throw new Error( + "Node labels containing commas are not supported by the CATMAID label update endpoint.", + ); + } + const key = trimmed.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + normalizedLabels.push(trimmed); + } + return normalizedLabels; + } + + private buildDescriptionLabels(description: string) { + return this.normalizeNodeLabels( + description + .split(/\r?\n/) + .map((label) => label.trim()) + .filter((label) => label.length > 0 && !isCatmaidClosedEndLabel(label)), + ); + } + + private async replaceNodeLabels(nodeId: number, labels: readonly string[]) { + const normalizedLabels = this.normalizeNodeLabels(labels); + return this.updateNodeLabel( + nodeId, + "update", + new URLSearchParams({ + tags: normalizedLabels.join(","), + delete_existing: "true", + }), + ); + } + + private async addNodeLabel(nodeId: number, label: string) { + const normalizedLabel = label.trim(); + if (normalizedLabel.length === 0) { + throw new Error("Node label must not be empty."); + } + return this.updateNodeLabel( + nodeId, + "update", + new URLSearchParams({ + tags: normalizedLabel, + delete_existing: "false", + }), + ); + } + + private async removeNodeLabel(nodeId: number, label: string) { + const normalizedLabel = label.trim(); + if (normalizedLabel.length === 0) { + throw new Error("Node label must not be empty."); + } + return this.updateNodeLabel( + nodeId, + "remove", + new URLSearchParams({ tag: normalizedLabel }), + ); + } + + async updateDescription( + nodeId: number, + description: string, + ): Promise { + const normalizedLabels = this.buildDescriptionLabels(description); + const response = await this.replaceNodeLabels(nodeId, normalizedLabels); + return { + ...getCatmaidSingleNodeRevisionResult( + normalizeCatmaidRevisionToken(response?.edition_time), + ), + description: + normalizedLabels.length === 0 ? undefined : normalizedLabels.join("\n"), + }; + } + + async setTrueEnd( + nodeId: number, + ): Promise { + const response = await this.addNodeLabel(nodeId, CATMAID_TRUE_END_LABEL); + return getCatmaidSingleNodeRevisionResult( + normalizeCatmaidRevisionToken((response as any)?.edition_time), + ); + } + + async removeTrueEnd( + nodeId: number, + ): Promise { + const response = await this.removeNodeLabel(nodeId, CATMAID_TRUE_END_LABEL); + return getCatmaidSingleNodeRevisionResult( + normalizeCatmaidRevisionToken((response as any)?.edition_time), + ); + } + + async updateRadius( + nodeId: number, + radius: number, + editContext?: SpatiallyIndexedSkeletonEditContext, + ): Promise { + if (!Number.isFinite(radius)) { + throw new Error("Radius must be a finite number."); + } + const body = new URLSearchParams({ + radius: radius.toString(), + }); + appendCatmaidState( + body, + buildCatmaidNodeState("update-radius", editContext, nodeId), + ); + const response = await this.fetch(`treenode/${nodeId}/radius`, { + method: "POST", + body, + }); + return getCatmaidSingleNodeRevisionResult( + parseCatmaidUpdatedNodesRevisionToken(response, nodeId), + ); + } + + async updateConfidence( + nodeId: number, + confidence: number, + editContext?: SpatiallyIndexedSkeletonEditContext, + ): Promise { + if (!Number.isFinite(confidence) || confidence < 0 || confidence > 100) { + throw new Error("Confidence must be between 0 and 100."); + } + const body = new URLSearchParams({ + new_confidence: mapPercentConfidenceToCatmaid(confidence).toString(), + }); + appendCatmaidState( + body, + buildCatmaidNodeState("update-confidence", editContext, nodeId), + ); + const response = await this.fetch(`treenodes/${nodeId}/confidence`, { + method: "POST", + body, + }); + return getCatmaidSingleNodeRevisionResult( + parseCatmaidConfidenceRevisionToken(response, nodeId), + ); + } + + async mergeSkeletons( + fromNodeId: number, + toNodeId: number, + editContext?: SpatiallyIndexedSkeletonEditContext, + ): Promise { + const body = new URLSearchParams({ + from_id: fromNodeId.toString(), + to_id: toNodeId.toString(), + }); + appendCatmaidState( + body, + buildCatmaidMultiNodeState("merge-skeleton", editContext, [ + fromNodeId, + toNodeId, + ]), + ); + const response = await this.fetch(`skeleton/join`, { + method: "POST", + body, + }); + const resultSkeletonId = Number(response?.result_skeleton_id); + const deletedSkeletonId = Number(response?.deleted_skeleton_id); + return { + resultSkeletonId: Number.isFinite(resultSkeletonId) + ? Math.round(resultSkeletonId) + : undefined, + deletedSkeletonId: Number.isFinite(deletedSkeletonId) + ? Math.round(deletedSkeletonId) + : undefined, + stableAnnotationSwap: Boolean(response?.stable_annotation_swap), + }; + } + + async splitSkeleton( + nodeId: number, + editContext?: SpatiallyIndexedSkeletonEditContext, + ): Promise { + const body = new URLSearchParams({ + treenode_id: nodeId.toString(), + }); + appendCatmaidState( + body, + buildCatmaidNeighborhoodState("split-skeleton", editContext, { + expectedNodeId: nodeId, + }), + ); + const response = await this.fetch(`skeleton/split`, { + method: "POST", + body, + }); + const existingSkeletonId = Number(response?.existing_skeleton_id); + const newSkeletonId = Number(response?.new_skeleton_id); + return { + existingSkeletonId: Number.isFinite(existingSkeletonId) + ? Math.round(existingSkeletonId) + : undefined, + newSkeletonId: Number.isFinite(newSkeletonId) + ? Math.round(newSkeletonId) + : undefined, + }; + } +} diff --git a/src/datasource/catmaid/backend.ts b/src/datasource/catmaid/backend.ts new file mode 100644 index 0000000000..d4d42e6221 --- /dev/null +++ b/src/datasource/catmaid/backend.ts @@ -0,0 +1,138 @@ +/** + * @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 { 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 { + CatmaidSkeletonSourceParameters, + CatmaidCompleteSkeletonSourceParameters, +} from "#src/datasource/catmaid/base.js"; +import { packCatmaidSkeletonNodes } from "#src/datasource/catmaid/skeleton_packing.js"; +import type { + SpatiallyIndexedSkeletonChunk, + SkeletonChunk, +} from "#src/skeleton/backend.js"; +import { + SpatiallyIndexedSkeletonSourceBackend, + SkeletonSource, +} from "#src/skeleton/backend.js"; +import { vec3 } from "#src/util/geom.js"; +import { registerSharedObject } from "#src/worker_rpc.js"; + +@registerSharedObject() +export class CatmaidSpatiallyIndexedSkeletonSourceBackend extends WithParameters( + WithSharedCredentialsProviderCounterpart()( + SpatiallyIndexedSkeletonSourceBackend, + ), + CatmaidSkeletonSourceParameters, +) { + private clientInstance: CatmaidClient | undefined; + + get client(): CatmaidClient { + let client = this.clientInstance; + if (client === undefined) { + const { catmaidParameters } = this.parameters; + client = new CatmaidClient( + catmaidParameters.url, + catmaidParameters.projectId, + this.credentialsProvider, + ); + this.clientInstance = client; + } + return client; + } + + constructor(...args: any[]) { + super(args[0], args[1]); + } + + 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 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 cacheProvider = this.parameters.catmaidParameters.cacheProvider; + const nodes = await this.client.fetchNodes(bbox, lodValue, { + cacheProvider, + signal, + }); + const packed = packCatmaidSkeletonNodes(nodes); + + chunk.vertexPositions = packed.vertexPositions; + chunk.indices = packed.indices; + + // Pack only segment IDs into vertexAttributes (positions are in vertexPositions) + chunk.vertexAttributes = [packed.segmentIds]; + chunk.nodeIds = packed.nodeIds; + chunk.nodeRevisionTokens = packed.revisionTokens; + } +} + +@registerSharedObject() +export class CatmaidSkeletonSourceBackend extends WithParameters( + WithSharedCredentialsProviderCounterpart()(SkeletonSource), + CatmaidCompleteSkeletonSourceParameters, +) { + private clientInstance: CatmaidClient | undefined; + + get client(): CatmaidClient { + let client = this.clientInstance; + if (client === undefined) { + const { catmaidParameters } = this.parameters; + client = new CatmaidClient( + catmaidParameters.url, + catmaidParameters.projectId, + this.credentialsProvider, + ); + this.clientInstance = client; + } + return client; + } + + constructor(...args: any[]) { + super(args[0], args[1]); + } + + async download(chunk: SkeletonChunk, signal: AbortSignal) { + const skeletonId = Number(chunk.objectId); + const nodes = await this.client.getSkeleton(skeletonId, { signal }); + const packed = packCatmaidSkeletonNodes(nodes); + + chunk.vertexPositions = packed.vertexPositions; + chunk.indices = packed.indices; + chunk.vertexAttributes = [packed.segmentIds]; + } +} diff --git a/src/datasource/catmaid/base.ts b/src/datasource/catmaid/base.ts new file mode 100644 index 0000000000..e1a7406f27 --- /dev/null +++ b/src/datasource/catmaid/base.ts @@ -0,0 +1,34 @@ +/** + * @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 { SkeletonSourceParameters } from "#src/datasource/precomputed/base.js"; + +export class CatmaidDataSourceParameters { + url!: string; + projectId!: number; + cacheProvider?: string; +} + +export class CatmaidSkeletonSourceParameters extends SkeletonSourceParameters { + catmaidParameters!: CatmaidDataSourceParameters; + gridIndex?: number; + static RPC_ID = "catmaid/SkeletonSource"; +} + +export class CatmaidCompleteSkeletonSourceParameters extends SkeletonSourceParameters { + catmaidParameters!: CatmaidDataSourceParameters; + static RPC_ID = "catmaid/CompleteSkeletonSource"; +} diff --git a/src/datasource/catmaid/credentials_provider.ts b/src/datasource/catmaid/credentials_provider.ts new file mode 100644 index 0000000000..15ffeb96d3 --- /dev/null +++ b/src/datasource/catmaid/credentials_provider.ts @@ -0,0 +1,75 @@ +/** + * @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 { + CredentialsProvider, + makeCredentialsGetter, +} from "#src/credentials_provider/index.js"; +import { getCredentialsWithStatus } from "#src/credentials_provider/interactive_credentials_provider.js"; +import type { CatmaidToken } from "#src/datasource/catmaid/api.js"; +import { fetchOk } from "#src/util/http_request.js"; +import { ProgressSpan } from "#src/util/progress_listener.js"; + +async function getAnonymousToken( + serverUrl: string, + signal: AbortSignal, +): Promise { + // serverUrl passed here is the base URL. + + const tokenUrl = `${serverUrl}/accounts/anonymous-api-token`; + + const response = await fetchOk(tokenUrl, { + method: "GET", + signal: signal, + }); + + const json = await response.json(); + if ( + typeof json === "object" && + json !== null && + typeof json.token === "string" + ) { + return { token: json.token }; + } + throw new Error( + `Unexpected response from ${tokenUrl}: ${JSON.stringify(json)}`, + ); +} + +export class CatmaidCredentialsProvider extends CredentialsProvider { + constructor(public serverUrl: string) { + super(); + } + + get = makeCredentialsGetter(async (options) => { + using _span = new ProgressSpan(options.progressListener, { + message: `Requesting CATMAID access token from ${this.serverUrl}`, + }); + return await getCredentialsWithStatus( + { + description: `CATMAID server ${this.serverUrl}`, + supportsImmediate: true, + get: async (signal, immediate) => { + if (immediate) { + return await getAnonymousToken(this.serverUrl, signal); + } + return await getAnonymousToken(this.serverUrl, signal); + }, + }, + options.signal, + ); + }); +} diff --git a/src/datasource/catmaid/frontend.ts b/src/datasource/catmaid/frontend.ts new file mode 100644 index 0000000000..1ca882dcfc --- /dev/null +++ b/src/datasource/catmaid/frontend.ts @@ -0,0 +1,550 @@ +/** + * @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 { makeDataBoundsBoundingBoxAnnotationSet } from "#src/annotation/index.js"; +import type { ChunkManager } from "#src/chunk_manager/frontend.js"; +import { WithParameters } from "#src/chunk_manager/frontend.js"; +import { + makeCoordinateSpace, + makeIdentityTransform, +} from "#src/coordinate_transform.js"; +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 { + CatmaidSkeletonSourceParameters, + CatmaidCompleteSkeletonSourceParameters, + CatmaidDataSourceParameters, +} from "#src/datasource/catmaid/base.js"; +import type { + DataSource, + DataSourceProvider, + GetDataSourceOptions, +} from "#src/datasource/index.js"; +import { + SegmentPropertyMap, + normalizeInlineSegmentPropertyMap, +} from "#src/segmentation_display_state/property_map.js"; +import type { + EditableSpatiallyIndexedSkeletonSource, + SpatiallyIndexedSkeletonAddNodeResult, + SpatiallyIndexedSkeletonDeleteNodeResult, + SpatiallyIndexedSkeletonDescriptionUpdateResult, + SpatiallyIndexedSkeletonEditContext, + SpatiallyIndexedSkeletonInsertNodeResult, + SpatiallyIndexedSkeletonMergeResult, + SpatiallyIndexedSkeletonMetadata, + SpatiallyIndexedSkeletonNode, + SpatiallyIndexedSkeletonNodeRevisionResult, + SpatiallyIndexedSkeletonNodeBase, + SpatiallyIndexedSkeletonSplitResult, +} from "#src/skeleton/api.js"; +import { + SpatiallyIndexedSkeletonSource, + SkeletonSource, + MultiscaleSpatiallyIndexedSkeletonSource, +} from "#src/skeleton/frontend.js"; +import type { SliceViewSourceOptions } from "#src/sliceview/base.js"; +import { makeSliceViewChunkSpecification } from "#src/sliceview/base.js"; +import { ChunkLayout } from "#src/sliceview/chunk_layout.js"; +import type { SliceViewSingleResolutionSource } from "#src/sliceview/frontend.js"; +import { DataType } from "#src/util/data_type.js"; +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 +{ + private client_?: CatmaidClient; + + private get client() { + let client = this.client_; + if (client !== undefined) { + return client; + } + const catmaidParameters = this.parameters.catmaidParameters; + client = new CatmaidClient( + catmaidParameters.url, + catmaidParameters.projectId, + this.credentialsProvider, + ); + this.client_ = client; + return client; + } + + getSkeleton( + skeletonId: number, + options?: { signal?: AbortSignal }, + ): Promise { + return this.client.getSkeleton(skeletonId, options); + } + + listSkeletons(): Promise { + return this.client.listSkeletons(); + } + + getSpatialIndexMetadata(): Promise { + return this.client.getSpatialIndexMetadata(); + } + + fetchNodes( + boundingBox: { + min: { x: number; y: number; z: number }; + max: { x: number; y: number; z: number }; + }, + lod?: number, + options?: { + cacheProvider?: string; + signal?: AbortSignal; + }, + ): Promise { + return this.client.fetchNodes(boundingBox, lod, options); + } + + 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( + WithCredentialsProvider()(SkeletonSource), + CatmaidCompleteSkeletonSourceParameters, +) { + get vertexAttributes() { + return this.parameters.metadata.vertexAttributes; + } +} + +export class CatmaidMultiscaleSpatiallyIndexedSkeletonSource extends MultiscaleSpatiallyIndexedSkeletonSource { + get rank(): number { + return 3; + } + + private sortedGridCellSizes: Array<{ x: number; y: number; z: number }>; + + constructor( + chunkManager: Borrowed, + private baseUrl: string, + private projectId: number, + private credentialsProvider: CredentialsProvider, + private coordinateScaleFactorsInMeters: Float32Array, + private lowerBoundsInNanometers: Float32Array, + private upperBoundsInNanometers: Float32Array, + gridCellSizes: Array<{ x: number; y: number; z: number }>, + private cacheProvider?: string, + ) { + super(chunkManager); + this.sortedGridCellSizes = [...gridCellSizes].sort( + (a, b) => Math.min(b.x, b.y, b.z) - Math.min(a.x, a.y, a.z), + ); + } + + getSpatialSkeletonGridSizes(): Array<{ x: number; y: number; z: number }> { + return this.sortedGridCellSizes; + } + + getPerspectiveSources(): SliceViewSingleResolutionSource[] { + const sources = this.getSources({} as any); + return sources.length > 0 ? sources[0] : []; + } + + getSliceViewPanelSources(): SliceViewSingleResolutionSource[] { + return this.getPerspectiveSources(); + } + + getSources( + _options: SliceViewSourceOptions, + ): SliceViewSingleResolutionSource[][] { + void _options; + const sources: SliceViewSingleResolutionSource[] = + []; + + // Sorted by minimum dimension (Descending: Large/Coarse -> Small/Fine) + const sortedGridSizes = this.sortedGridCellSizes; + + for (const [gridIndex, gridCellSize] of sortedGridSizes.entries()) { + const chunkDataSize = Uint32Array.from([ + gridCellSize.x, + gridCellSize.y, + gridCellSize.z, + ]); + + const chunkLayoutTransform = mat4.create(); + mat4.fromScaling( + chunkLayoutTransform, + vec3.fromValues( + this.coordinateScaleFactorsInMeters[0], + this.coordinateScaleFactorsInMeters[1], + this.coordinateScaleFactorsInMeters[2], + ), + ); + + const chunkLayout = new ChunkLayout( + vec3.fromValues(chunkDataSize[0], chunkDataSize[1], chunkDataSize[2]), + chunkLayoutTransform, + 3, + ); + + const spec = { + ...makeSliceViewChunkSpecification({ + rank: 3, + chunkDataSize, + lowerVoxelBound: this.lowerBoundsInNanometers, + upperVoxelBound: this.upperBoundsInNanometers, + }), + chunkLayout, + }; + + const parameters = new CatmaidSkeletonSourceParameters(); + parameters.catmaidParameters = new CatmaidDataSourceParameters(); + parameters.catmaidParameters.url = this.baseUrl; + parameters.catmaidParameters.projectId = this.projectId; + parameters.catmaidParameters.cacheProvider = this.cacheProvider; + parameters.gridIndex = gridIndex; + parameters.metadata = { + transform: mat4.create(), + vertexAttributes: new Map([ + ["segment", { dataType: DataType.UINT32, numComponents: 1 }], + ]), + sharding: undefined, + }; + + const chunkSource = this.chunkManager.getChunkSource( + CatmaidSpatiallyIndexedSkeletonSource, + { parameters, spec, credentialsProvider: this.credentialsProvider }, + ); + + // CATMAID grid cell sizes are already expressed in project-space nanometers. + // Use identity here; additional relative scaling would double-apply grid size + // and can skew per-grid visible chunk counts and requests. + const chunkToMultiscaleTransform = mat4.create(); + sources.push({ + chunkSource, + chunkToMultiscaleTransform, + }); + } + + return [sources]; + } +} + +export class CatmaidDataSourceProvider implements DataSourceProvider { + get scheme() { + return "catmaid"; + } + + get description() { + return "CATMAID"; + } + + async get(options: GetDataSourceOptions): Promise { + const { providerUrl } = options; + + // Remove scheme if present to handle "catmaid://" + let cleanUrl = providerUrl; + if (cleanUrl.startsWith("catmaid://")) { + cleanUrl = cleanUrl.substring("catmaid://".length); + } + + const lastSlash = cleanUrl.lastIndexOf("/"); + if (lastSlash === -1) { + throw new Error( + "Invalid CATMAID URL. Expected format: catmaid:///", + ); + } + + const projectIdStr = cleanUrl.substring(lastSlash + 1); + const projectId = parseInt(projectIdStr); + if (isNaN(projectId)) { + throw new Error(`Invalid project ID: ${projectIdStr}`); + } + + let baseUrl = cleanUrl.substring(0, lastSlash); + if (!baseUrl.startsWith("http")) { + baseUrl = "https://" + baseUrl; + } + + const credentialsProvider = + options.registry.credentialsManager.getCredentialsProvider( + credentialsKey, + { serverUrl: baseUrl }, + ) as CredentialsProvider; + + const client = new CatmaidClient(baseUrl, projectId, credentialsProvider); + + // Fetch metadata-derived values through the generic source interface. + const [spatialIndexMetadata, cacheProvider, skeletonIds] = + await Promise.all([ + options.registry.chunkManager.memoize.getAsync( + { type: "catmaid:spatial-index-metadata", baseUrl, projectId }, + options, + () => client.getSpatialIndexMetadata(), + ), + options.registry.chunkManager.memoize.getAsync( + { type: "catmaid:cache-provider", baseUrl, projectId }, + options, + () => client.getCacheProvider(), + ), + options.registry.chunkManager.memoize.getAsync( + { type: "catmaid:skeletons", baseUrl, projectId }, + options, + () => client.listSkeletons(), + ), + ]); + + if (spatialIndexMetadata === null) { + throw new Error("Failed to fetch CATMAID spatial index metadata"); + } + + const { bounds: projectBounds, gridCellSizes } = spatialIndexMetadata; + + // The model-space coordinates we emit are in nanometers, converted to meters for Neuroglancer. + 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 modelSpace = makeCoordinateSpace({ + names: ["x", "y", "z"], + units: ["m", "m", "m"], + scales: coordinateScaleFactors, + boundingBoxes: [ + { + box: { + lowerBounds, + upperBounds, + }, + transform: Float64Array.from([1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]), + }, + ], + }); + + const rank = 3; + + const lowerCoordinateBound = new Float32Array(rank); + const upperCoordinateBound = new Float32Array(rank); + for (let i = 0; i < rank; ++i) { + lowerCoordinateBound[i] = lowerBounds[i]; + upperCoordinateBound[i] = upperBounds[i]; + } + + // Create multiscale skeleton source to get individual sources + const multiscaleSource = + new CatmaidMultiscaleSpatiallyIndexedSkeletonSource( + options.registry.chunkManager, + baseUrl, + projectId, + credentialsProvider, + new Float32Array(coordinateScaleFactors), + lowerCoordinateBound, + upperCoordinateBound, + gridCellSizes, + cacheProvider, + ); + // Create complete skeleton source (non-chunked) + const completeSkeletonParameters = + new CatmaidCompleteSkeletonSourceParameters(); + completeSkeletonParameters.catmaidParameters = + new CatmaidDataSourceParameters(); + completeSkeletonParameters.catmaidParameters.url = baseUrl; + completeSkeletonParameters.catmaidParameters.projectId = projectId; + completeSkeletonParameters.url = providerUrl; + completeSkeletonParameters.metadata = { + transform: mat4.create(), + vertexAttributes: new Map([ + ["segment", { dataType: DataType.UINT32, numComponents: 1 }], + ]), + sharding: undefined, + }; + + const completeSkeletonSource = options.registry.chunkManager.getChunkSource( + CatmaidSkeletonSource, + { parameters: completeSkeletonParameters, credentialsProvider }, + ); + + // Create SegmentPropertyMap + const ids = new BigUint64Array(skeletonIds.length); + for (let i = 0; i < skeletonIds.length; ++i) { + ids[i] = BigInt(skeletonIds[i]); + } + + const propertyMap = new SegmentPropertyMap({ + inlineProperties: normalizeInlineSegmentPropertyMap({ + ids, + properties: [], + }), + }); + + const subsources = [ + { + id: "skeletons-chunked", + default: true, + subsource: { mesh: multiscaleSource }, + }, + { + id: "skeletons", + default: false, + subsource: { mesh: completeSkeletonSource }, + }, + { + id: "properties", + default: true, + subsource: { segmentPropertyMap: propertyMap }, + }, + { + id: "bounds", + default: true, + subsource: { + staticAnnotations: makeDataBoundsBoundingBoxAnnotationSet( + modelSpace.bounds, + ), + }, + }, + ]; + + return { + modelTransform: makeIdentityTransform(modelSpace), + subsources, + }; + } +} diff --git a/src/datasource/catmaid/register_credentials_provider.ts b/src/datasource/catmaid/register_credentials_provider.ts new file mode 100644 index 0000000000..883df99028 --- /dev/null +++ b/src/datasource/catmaid/register_credentials_provider.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. + */ + +import { registerDefaultCredentialsProvider } from "#src/credentials_provider/default_manager.js"; +import { credentialsKey } from "#src/datasource/catmaid/api.js"; +import { CatmaidCredentialsProvider } from "#src/datasource/catmaid/credentials_provider.js"; + +registerDefaultCredentialsProvider( + credentialsKey, + (params: { serverUrl: string }) => + new CatmaidCredentialsProvider(params.serverUrl), +); diff --git a/src/datasource/catmaid/register_default.ts b/src/datasource/catmaid/register_default.ts new file mode 100644 index 0000000000..5529802bb5 --- /dev/null +++ b/src/datasource/catmaid/register_default.ts @@ -0,0 +1,20 @@ +/** + * @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 { CatmaidDataSourceProvider } from "#src/datasource/catmaid/frontend.js"; +import { registerProvider } from "#src/datasource/default_provider.js"; + +registerProvider(new CatmaidDataSourceProvider()); diff --git a/src/datasource/catmaid/skeleton_packing.spec.ts b/src/datasource/catmaid/skeleton_packing.spec.ts new file mode 100644 index 0000000000..bd5b15b491 --- /dev/null +++ b/src/datasource/catmaid/skeleton_packing.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { packCatmaidSkeletonNodes } from "#src/datasource/catmaid/skeleton_packing.js"; +import type { SpatiallyIndexedSkeletonNodeBase } from "#src/skeleton/api.js"; + +describe("datasource/catmaid/skeleton_packing", () => { + it("packs vertex, segment, index, and pick-node data", () => { + const nodes: SpatiallyIndexedSkeletonNodeBase[] = [ + { + nodeId: 1, + parentNodeId: undefined, + position: new Float32Array([1, 2, 3]), + segmentId: 10, + }, + { + nodeId: 2, + parentNodeId: 1, + position: new Float32Array([4, 5, 6]), + segmentId: 10, + }, + { + nodeId: 3, + parentNodeId: 99, + position: new Float32Array([7, 8, 9]), + segmentId: 11, + }, + ]; + + const packed = packCatmaidSkeletonNodes(nodes); + + expect(packed.vertexPositions).toEqual( + Float32Array.of(1, 2, 3, 4, 5, 6, 7, 8, 9), + ); + expect(packed.segmentIds).toEqual(Uint32Array.of(10, 10, 11)); + expect(packed.indices).toEqual(Uint32Array.of(1, 0)); + expect(packed.nodeIds).toEqual(Int32Array.of(1, 2, 3)); + }); + + it("preserves large segment ids exactly", () => { + const largeSegmentId = 16_777_217; + const nodes: SpatiallyIndexedSkeletonNodeBase[] = [ + { + nodeId: 1, + parentNodeId: undefined, + position: new Float32Array([1, 2, 3]), + segmentId: largeSegmentId, + }, + ]; + + const packed = packCatmaidSkeletonNodes(nodes); + + expect(packed.segmentIds).toEqual(Uint32Array.of(largeSegmentId)); + }); +}); diff --git a/src/datasource/catmaid/skeleton_packing.ts b/src/datasource/catmaid/skeleton_packing.ts new file mode 100644 index 0000000000..1f4bd74b54 --- /dev/null +++ b/src/datasource/catmaid/skeleton_packing.ts @@ -0,0 +1,65 @@ +/** + * @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 { SpatiallyIndexedSkeletonNodeBase } from "#src/skeleton/api.js"; + +interface PackedCatmaidSkeletonData { + vertexPositions: Float32Array; + segmentIds: Uint32Array; + indices: Uint32Array; + nodeIds: Int32Array; + revisionTokens: Array; +} + +export function packCatmaidSkeletonNodes( + nodes: readonly SpatiallyIndexedSkeletonNodeBase[], +): PackedCatmaidSkeletonData { + const numVertices = nodes.length; + const vertexPositions = new Float32Array(numVertices * 3); + const segmentIds = new Uint32Array(numVertices); + const nodeIds = new Int32Array(numVertices); + const revisionTokens = new Array(numVertices); + const indices: number[] = []; + const nodeMap = new Map(); + + for (let i = 0; i < numVertices; ++i) { + const node = nodes[i]; + nodeMap.set(node.nodeId, i); + nodeIds[i] = node.nodeId; + vertexPositions[i * 3] = node.position[0]; + vertexPositions[i * 3 + 1] = node.position[1]; + vertexPositions[i * 3 + 2] = node.position[2]; + segmentIds[i] = node.segmentId; + revisionTokens[i] = node.revisionToken; + } + + for (let i = 0; i < numVertices; ++i) { + const node = nodes[i]; + if (node.parentNodeId === undefined) continue; + const parentIndex = nodeMap.get(node.parentNodeId); + if (parentIndex !== undefined) { + indices.push(i, parentIndex); + } + } + + return { + vertexPositions, + segmentIds, + indices: new Uint32Array(indices), + nodeIds, + revisionTokens, + }; +} diff --git a/src/datasource/enabled_backend_modules.ts b/src/datasource/enabled_backend_modules.ts index 62f3d8564e..9ffd8a8b14 100644 --- a/src/datasource/enabled_backend_modules.ts +++ b/src/datasource/enabled_backend_modules.ts @@ -12,3 +12,4 @@ import "#datasource/python/backend"; import "#datasource/render/backend"; import "#datasource/vtk/backend"; import "#datasource/zarr/backend"; +import "#datasource/catmaid/backend"; diff --git a/src/datasource/enabled_frontend_modules.ts b/src/datasource/enabled_frontend_modules.ts index 264c63800b..b7b01c500e 100644 --- a/src/datasource/enabled_frontend_modules.ts +++ b/src/datasource/enabled_frontend_modules.ts @@ -16,3 +16,4 @@ import "#datasource/python/register_default"; import "#datasource/render/register_default"; import "#datasource/vtk/register_default"; import "#datasource/zarr/register_default"; +import "#datasource/catmaid/register_default"; diff --git a/src/datasource/graphene/frontend.ts b/src/datasource/graphene/frontend.ts index d3fc71fa18..fd18f6d8d3 100644 --- a/src/datasource/graphene/frontend.ts +++ b/src/datasource/graphene/frontend.ts @@ -1333,7 +1333,7 @@ class GraphConnection extends SegmentationGraphSourceConnection { return undefined; } - getMeshSource() { + getMeshSource(): MeshSource | undefined { const { layer } = this; for (const dataSource of layer.dataSources) { const { loadState } = dataSource; @@ -1351,7 +1351,9 @@ class GraphConnection extends SegmentationGraphSourceConnection { (subsource) => subsource.id === "mesh", )[0]; if (meshSubsource) { - return meshSubsource.subsource.mesh; + // `DataSubsource.mesh` is widened for spatial skeleton support, but Graphene + // segment updates still target mesh sources. + return meshSubsource.subsource.mesh as MeshSource | undefined; } } } diff --git a/src/datasource/index.ts b/src/datasource/index.ts index 38d2332bc5..c630322332 100644 --- a/src/datasource/index.ts +++ b/src/datasource/index.ts @@ -42,7 +42,11 @@ import type { MeshSource, MultiscaleMeshSource } from "#src/mesh/frontend.js"; import type { SegmentPropertyMap } from "#src/segmentation_display_state/property_map.js"; import type { SegmentationGraphSource } from "#src/segmentation_graph/source.js"; import type { SingleMeshSource } from "#src/single_mesh/frontend.js"; -import type { SkeletonSource } from "#src/skeleton/frontend.js"; +import type { + SkeletonSource, + SpatiallyIndexedSkeletonSource, + MultiscaleSpatiallyIndexedSkeletonSource, +} from "#src/skeleton/frontend.js"; import type { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; import type { WatchableValueInterface } from "#src/trackable_value.js"; import type { @@ -125,7 +129,12 @@ export interface ConvertLegacyUrlOptions extends ConvertLegacyUrlOptionsBase { export interface DataSubsource { volume?: MultiscaleVolumeChunkSource; - mesh?: MeshSource | MultiscaleMeshSource | SkeletonSource; + mesh?: + | MeshSource + | MultiscaleMeshSource + | SkeletonSource + | SpatiallyIndexedSkeletonSource + | MultiscaleSpatiallyIndexedSkeletonSource; annotation?: MultiscaleAnnotationSource; staticAnnotations?: AnnotationSource; local?: LocalDataSource; diff --git a/src/layer/index.ts b/src/layer/index.ts index fad0e1400e..e6e5a5d7a9 100644 --- a/src/layer/index.ts +++ b/src/layer/index.ts @@ -85,10 +85,12 @@ import { LayerToolBinder, SelectedLegacyTool } from "#src/ui/tool.js"; import { gatherUpdate } from "#src/util/array.js"; import type { Borrowed, Owned } from "#src/util/disposable.js"; import { invokeDisposers, RefCounted } from "#src/util/disposable.js"; +import { formatErrorMessage } from "#src/util/error.js"; import { emptyToUndefined, parseArray, parseFixedLengthArray, + parseUint64, verifyBoolean, verifyFiniteFloat, verifyInt, @@ -142,6 +144,7 @@ export interface UserLayerSelectionState { annotationSubsource: string | undefined; annotationSubsubsourceId: string | undefined; annotationPartIndex: number | undefined; + nodeId: string | undefined; value: any; } @@ -153,6 +156,21 @@ export class LayerActionContext { } } +function parsePositiveUint64String(value: unknown) { + if (typeof value !== "string") { + throw new Error( + `Expected string-encoded positive uint64 value, but received: ${JSON.stringify(value)}.`, + ); + } + const parsedValue = parseUint64(value); + if (parsedValue <= 0n) { + throw new Error( + `Expected positive uint64 value, but received: ${JSON.stringify(value)}.`, + ); + } + return parsedValue.toString(); +} + export interface UserLayerTab { id: string; label: string; @@ -191,7 +209,7 @@ export class UserLayer extends RefCounted { pick = new TrackableBoolean(true, true); - selectionState: UserLayerSelectionState; + selectionState!: UserLayerSelectionState; messages = new MessageList(); @@ -222,12 +240,14 @@ export class UserLayer extends RefCounted { state.annotationPartIndex = undefined; state.annotationInstanceIndex = undefined; state.annotationInstanceCount = undefined; + state.nodeId = undefined; state.value = undefined; } resetSelectionState(state: this["selectionState"]) { state.localPositionValid = false; state.annotationId = undefined; + state.nodeId = undefined; state.value = undefined; } @@ -276,6 +296,11 @@ export class UserLayer extends RefCounted { verifyString, ); } + state.nodeId = verifyOptionalObjectProperty( + json, + "nodeId", + parsePositiveUint64String, + ); state.value = json.value; } @@ -307,6 +332,9 @@ export class UserLayer extends RefCounted { json.annotationSource = state.annotationSourceIndex; json.annotationSubsource = state.annotationSubsource; } + if (state.nodeId !== undefined) { + json.nodeId = state.nodeId; + } if (state.value != null) { json.value = state.value; } @@ -353,6 +381,7 @@ export class UserLayer extends RefCounted { dest.annotationSourceIndex = source.annotationSourceIndex; dest.annotationSubsource = source.annotationSubsource; dest.annotationPartIndex = source.annotationPartIndex; + dest.nodeId = source.nodeId; dest.value = source.value; } @@ -1115,10 +1144,18 @@ export class LayerManager extends RefCounted { } } +export interface PickedSpatialSkeletonState { + nodeId?: number; + segmentId?: number; + position?: Float32Array; + revisionToken?: string; +} + export interface PickState { pickedRenderLayer: RenderLayer | null; pickedValue: bigint; pickedOffset: number; + pickedSpatialSkeleton: PickedSpatialSkeletonState | undefined; pickedAnnotationLayer: AnnotationLayerState | undefined; pickedAnnotationId: string | undefined; pickedAnnotationBuffer: ArrayBuffer | undefined; @@ -1140,6 +1177,7 @@ export class MouseSelectionState implements PickState { pickedRenderLayer: RenderLayer | null = null; pickedValue = 0n; pickedOffset = 0; + pickedSpatialSkeleton: PickedSpatialSkeletonState | undefined = undefined; pickedAnnotationLayer: AnnotationLayerState | undefined = undefined; pickedAnnotationId: string | undefined = undefined; pickedAnnotationBuffer: ArrayBuffer | undefined = undefined; @@ -1384,6 +1422,7 @@ export class TrackableDataSelectionState userLayer: Borrowed, capture: (state: T["selectionState"]) => boolean, pin: boolean | "toggle" | "force-unpin" = true, + options: { position?: ArrayLike } = {}, ) { if (pin === false && (!this.location.visible || this.pin.value)) return; const state = {} as UserLayerSelectionState; @@ -1400,7 +1439,10 @@ export class TrackableDataSelectionState this.value = { layers: [{ layer: userLayer, state }], coordinateSpace: this.coordinateSpace.value, - position: undefined, + position: + options.position === undefined + ? undefined + : new Float32Array(options.position), }; } } @@ -2216,10 +2258,7 @@ export class TopLevelLayerListSpecification extends LayerListSpecification { managedLayer.dispose(); const msg = new StatusMessage(); msg.setErrorMessage( - `Error creating layer ${JSON.stringify(name)}: ` + - (e instanceof Error) - ? e.message - : "" + e, + `Error creating layer ${JSON.stringify(name)}: ${formatErrorMessage(e)}`, ); } } @@ -2229,10 +2268,7 @@ export class TopLevelLayerListSpecification extends LayerListSpecification { } catch (e) { const msg = new StatusMessage(); msg.setErrorMessage( - `Error creating layer ${JSON.stringify(name)}: ` + - (e instanceof Error) - ? e.message - : "" + e, + `Error creating layer ${JSON.stringify(name)}: ${formatErrorMessage(e)}`, ); } } diff --git a/src/layer/segmentation/index.spec.ts b/src/layer/segmentation/index.spec.ts new file mode 100644 index 0000000000..9ade3ae1e0 --- /dev/null +++ b/src/layer/segmentation/index.spec.ts @@ -0,0 +1,561 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { RenderLayerTransform } from "#src/render_coordinate_transform.js"; +import { SpatialSkeletonActions } from "#src/skeleton/actions.js"; +import { WatchableValue } from "#src/trackable_value.js"; + +if (!("WebGL2RenderingContext" in globalThis)) { + Object.defineProperty(globalThis, "WebGL2RenderingContext", { + value: new Proxy(class WebGL2RenderingContext {} as any, { + get(target, property, receiver) { + if (Reflect.has(target, property)) { + return Reflect.get(target, property, receiver); + } + return 0; + }, + }), + configurable: true, + }); +} + +const { SegmentationUserLayer } = await import( + "#src/layer/segmentation/index.js" +); + +const { + PerspectiveViewSpatiallyIndexedSkeletonLayer, + SliceViewPanelSpatiallyIndexedSkeletonLayer, + SliceViewSpatiallyIndexedSkeletonLayer, + MultiscaleSliceViewSpatiallyIndexedSkeletonLayer, +} = await import("#src/skeleton/frontend.js"); + +const { SegmentSelectionState } = await import( + "#src/segmentation_display_state/frontend.js" +); + +function makeEditableSpatialSkeletonSource( + options: { + rerootSkeleton?: (() => Promise) | undefined; + } = {}, +) { + return { + 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, + }), + ...(options.rerootSkeleton === undefined + ? {} + : { rerootSkeleton: options.rerootSkeleton }), + }; +} + +function makeSpatialSkeletonLayerWithSource(source: unknown) { + return { + source, + }; +} + +describe("layer/segmentation spatial skeleton chunk stats", () => { + it("tracks combined chunk load state from the loading render layers only", () => { + const perspectiveLayer = Object.assign( + Object.create(PerspectiveViewSpatiallyIndexedSkeletonLayer.prototype), + { + layerChunkProgressInfo: { + numVisibleChunksNeeded: 5, + numVisibleChunksAvailable: 3, + }, + }, + ); + const sliceLayer = Object.assign( + Object.create(SliceViewSpatiallyIndexedSkeletonLayer.prototype), + { + layerChunkProgressInfo: { + numVisibleChunksNeeded: 4, + numVisibleChunksAvailable: 2, + }, + }, + ); + const multiscaleSliceLayer = Object.assign( + Object.create(MultiscaleSliceViewSpatiallyIndexedSkeletonLayer.prototype), + { + layerChunkProgressInfo: { + numVisibleChunksNeeded: 6, + numVisibleChunksAvailable: 5, + }, + }, + ); + const slicePanelLayer = Object.assign( + Object.create(SliceViewPanelSpatiallyIndexedSkeletonLayer.prototype), + { + layerChunkProgressInfo: { + numVisibleChunksNeeded: 100, + numVisibleChunksAvailable: 100, + }, + }, + ); + + const layer = Object.assign( + Object.create(SegmentationUserLayer.prototype), + { + renderLayers: [ + perspectiveLayer, + sliceLayer, + multiscaleSliceLayer, + slicePanelLayer, + ], + spatialSkeletonVisibleChunksNeeded: new WatchableValue(0), + spatialSkeletonVisibleChunksAvailable: new WatchableValue(0), + spatialSkeletonVisibleChunksLoaded: new WatchableValue(false), + displayState: { + spatialSkeletonGridChunkStats2d: new WatchableValue({ + presentCount: 0, + totalCount: 0, + }), + spatialSkeletonGridChunkStats3d: new WatchableValue({ + presentCount: 0, + totalCount: 0, + }), + }, + updateSpatialSkeletonSourceState: vi.fn(), + }, + ); + + layer.updateSpatialSkeletonChunkLoadState(); + + expect(layer.spatialSkeletonVisibleChunksNeeded.value).toBe(15); + expect(layer.spatialSkeletonVisibleChunksAvailable.value).toBe(10); + expect(layer.spatialSkeletonVisibleChunksLoaded.value).toBe(false); + }); +}); + +describe("layer/segmentation spatial skeleton action gating", () => { + it("does not require max lod for skeleton actions", () => { + const layer = Object.assign( + Object.create(SegmentationUserLayer.prototype), + { + getSpatiallyIndexedSkeletonLayer: () => + makeSpatialSkeletonLayerWithSource( + makeEditableSpatialSkeletonSource({ + rerootSkeleton: async () => {}, + }), + ), + spatialSkeletonVisibleChunksLoaded: new WatchableValue(true), + spatialSkeletonVisibleChunksNeeded: new WatchableValue(0), + spatialSkeletonVisibleChunksAvailable: new WatchableValue(0), + }, + ); + + expect( + layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.mergeSkeletons, + ), + ).toBeUndefined(); + expect( + layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.reroot, + { + requireVisibleChunks: false, + }, + ), + ).toBeUndefined(); + expect( + layer.getSpatialSkeletonActionsDisabledReason([ + SpatialSkeletonActions.addNodes, + SpatialSkeletonActions.moveNodes, + ]), + ).toBeUndefined(); + }); + + it("still reports visible chunk loading when requested", () => { + const layer = Object.assign( + Object.create(SegmentationUserLayer.prototype), + { + getSpatiallyIndexedSkeletonLayer: () => + makeSpatialSkeletonLayerWithSource( + makeEditableSpatialSkeletonSource(), + ), + spatialSkeletonVisibleChunksLoaded: new WatchableValue(false), + spatialSkeletonVisibleChunksNeeded: new WatchableValue(3), + spatialSkeletonVisibleChunksAvailable: new WatchableValue(1), + }, + ); + + expect( + layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.splitSkeletons, + { + requireVisibleChunks: true, + }, + ), + ).toBe("Wait for visible skeleton chunks to load (1/3)."); + }); + + it("reports missing reroot support explicitly", () => { + 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.reroot, + { + requireVisibleChunks: false, + }, + ), + ).toBe( + "The active spatial skeleton source does not support skeleton rerooting.", + ); + }); +}); + +describe("layer/segmentation spatial skeleton selection serialization", () => { + it("accepts bigint segment selections for runtime spatial skeleton state", () => { + const selectionState = new SegmentSelectionState(); + + selectionState.set(7n); + + expect(selectionState.value).toBe(7n); + expect(selectionState.baseValue).toBe(7n); + }); + + it("round-trips node id and segment value for spatial skeleton selections", () => { + const layer = Object.create(SegmentationUserLayer.prototype); + Object.defineProperty(layer, "localCoordinateSpace", { + value: { value: { rank: 0 } }, + configurable: true, + }); + const state: any = {}; + layer.initializeSelectionState(state); + + layer.selectionStateFromJson(state, { + nodeId: "23", + value: "7", + }); + + expect(state.nodeId).toBe("23"); + expect(state.value).toBe(7n); + expect(layer.selectionStateToJson(state, false)).toEqual({ + nodeId: "23", + value: "7", + }); + + const copiedState: any = {}; + layer.initializeSelectionState(copiedState); + layer.copySelectionState(copiedState, state); + expect(copiedState.nodeId).toBe("23"); + expect(copiedState.value).toBe(7n); + }); + + it("ignores legacy spatial skeleton selection keys", () => { + const layer = Object.create(SegmentationUserLayer.prototype); + Object.defineProperty(layer, "localCoordinateSpace", { + value: { value: { rank: 0 } }, + configurable: true, + }); + const state: any = {}; + layer.initializeSelectionState(state); + + layer.selectionStateFromJson(state, { + spatialSkeletonNodeId: "23", + spatialSkeletonSegmentId: "7", + }); + + expect(state.nodeId).toBeUndefined(); + expect(state.value).toBeUndefined(); + expect(layer.selectionStateToJson(state, false)).toEqual({}); + }); + + it("captures and clears spatial skeleton nodes using nodeId and segment value", () => { + const selectionState = { + pin: { value: false }, + coordinateSpace: { value: undefined }, + value: undefined as any, + }; + const layer = Object.create(SegmentationUserLayer.prototype); + Object.defineProperty(layer, "localCoordinateSpace", { + value: { value: { rank: 0 } }, + configurable: true, + }); + Object.defineProperty(layer, "manager", { + value: { + root: { + selectionState, + }, + }, + configurable: true, + }); + layer.captureSpatialSkeletonSelectionState((state: any) => { + state.nodeId = "31"; + state.value = 9n; + return true; + }, false); + + expect(selectionState.value.layers[0].state.nodeId).toBe("31"); + expect(selectionState.value.layers[0].state.value).toBe(9n); + + layer.captureSpatialSkeletonSelectionState((state: any) => { + state.nodeId = undefined; + state.value = undefined; + return true; + }, false); + + expect(selectionState.value.layers[0].state.nodeId).toBeUndefined(); + expect(selectionState.value.layers[0].state.value).toBeUndefined(); + }); + + it("captures spatial skeleton node ids from unpinned hover selection", () => { + const renderLayer = {}; + const layer = Object.create(SegmentationUserLayer.prototype); + Object.defineProperty(layer, "localCoordinateSpace", { + value: { value: { rank: 0 } }, + configurable: true, + }); + Object.defineProperty(layer, "localPosition", { + value: { value: new Float32Array(0) }, + configurable: true, + }); + Object.defineProperty(layer, "renderLayers", { + value: [renderLayer], + configurable: true, + }); + Object.defineProperty(layer, "getValueAt", { + value: vi.fn(() => 7n), + configurable: true, + }); + const state = {} as any; + layer.initializeSelectionState(state); + + layer.captureSelectionState(state, { + active: true, + position: new Float32Array(0), + pickedRenderLayer: renderLayer, + pickedSpatialSkeleton: { nodeId: 31, segmentId: 9 }, + } as any); + + expect(state.nodeId).toBe("31"); + expect(state.value).toBe(9n); + }); + + it("ignores spatial skeleton node ids from other render layers", () => { + const renderLayer = {}; + const otherRenderLayer = {}; + const layer = Object.create(SegmentationUserLayer.prototype); + Object.defineProperty(layer, "localCoordinateSpace", { + value: { value: { rank: 0 } }, + configurable: true, + }); + Object.defineProperty(layer, "localPosition", { + value: { value: new Float32Array(0) }, + configurable: true, + }); + Object.defineProperty(layer, "renderLayers", { + value: [renderLayer], + configurable: true, + }); + Object.defineProperty(layer, "getValueAt", { + value: vi.fn(() => 7n), + configurable: true, + }); + const state = {} as any; + layer.initializeSelectionState(state); + + layer.captureSelectionState(state, { + active: true, + position: new Float32Array(0), + pickedRenderLayer: otherRenderLayer, + pickedSpatialSkeleton: { nodeId: 31, segmentId: 9 }, + } as any); + + expect(state.nodeId).toBeUndefined(); + expect(state.value).toBe(7n); + }); + + it("renders only segment and node ids for non-inspected spatial index node selections", () => { + const state = { + nodeId: "22242672", + value: "2836850", + }; + const layer = Object.assign( + Object.create(SegmentationUserLayer.prototype), + { + displayState: undefined, + getSpatiallyIndexedSkeletonLayer: () => undefined, + selectSegment: vi.fn(), + selectedSpatialSkeletonNodeInfo: new WatchableValue(undefined), + spatialSkeletonNodeDataVersion: new WatchableValue(0), + spatialSkeletonState: { + getCachedNode: () => undefined, + }, + }, + ); + Object.defineProperty(layer, "manager", { + value: { + root: { + selectionState: { + value: { + layers: [{ layer, state }], + }, + }, + }, + }, + configurable: true, + }); + const parent = document.createElement("div"); + const context = { + redraw: vi.fn(), + registerDisposer: vi.fn((disposer: unknown) => disposer), + }; + + expect( + (layer as any).displaySpatialSkeletonSelection(state, parent, context), + ).toBe(true); + + expect(parent.textContent).toContain("2836850"); + expect(parent.textContent).toContain("22242672"); + expect(parent.textContent).not.toContain("Unknown"); + expect(parent.textContent).not.toContain("Unavailable"); + expect(parent.textContent).not.toContain("Radius"); + expect(parent.textContent).not.toContain("Confidence"); + }); +}); + +describe("layer/segmentation spatial skeleton node navigation helpers", () => { + it("maps model-space node positions through non-identity transforms before updating view state", () => { + const dispatchGlobalPositionChanged = vi.fn(); + const dispatchLocalPositionChanged = vi.fn(); + const transform: RenderLayerTransform = { + rank: 3, + unpaddedRank: 3, + localToRenderLayerDimensions: [1, -1, 2], + globalToRenderLayerDimensions: [2, 0, 1, -1], + channelToRenderLayerDimensions: [], + channelToModelDimensions: [], + channelSpaceShape: new Uint32Array(0), + modelToRenderLayerTransform: new Float32Array([ + 2, 0, 0, 0, 0, 0, 1, 0, 0, 3, 0, 0, 10, -5, 1, 1, + ]), + modelDimensionNames: ["x", "y", "z"], + layerDimensionNames: ["a", "b", "c"], + }; + const layer = Object.create(SegmentationUserLayer.prototype); + Object.assign(layer, { + getSpatiallyIndexedSkeletonLayer: () => ({ + displayState: { + transform: { + value: transform, + }, + }, + }), + }); + Object.defineProperty(layer, "manager", { + value: { + root: { + globalPosition: { + value: new Float32Array([100, 101, 102, 103]), + changed: { + dispatch: dispatchGlobalPositionChanged, + }, + }, + }, + }, + configurable: true, + }); + Object.defineProperty(layer, "localPosition", { + value: { + value: new Float32Array([200, 201, 202]), + changed: { + dispatch: dispatchLocalPositionChanged, + }, + }, + configurable: true, + }); + + layer.moveViewToSpatialSkeletonNodePosition([4, 5, 6]); + + expect(Array.from(layer.manager.root.globalPosition.value)).toEqual([ + 6, 18, 13, 103, + ]); + expect(Array.from(layer.localPosition.value)).toEqual([13, 201, 6]); + expect(dispatchLocalPositionChanged).toHaveBeenCalledTimes(1); + expect(dispatchGlobalPositionChanged).toHaveBeenCalledTimes(1); + }); + + it("selects and moves to the provided node, or clears selection when absent", () => { + const selectSpatialSkeletonNode = vi.fn(); + const moveViewToSpatialSkeletonNodePosition = vi.fn(); + const clearSpatialSkeletonNodeSelection = vi.fn(); + const layer = Object.assign( + Object.create(SegmentationUserLayer.prototype), + { + selectSpatialSkeletonNode, + moveViewToSpatialSkeletonNodePosition, + clearSpatialSkeletonNodeSelection, + }, + ); + Object.defineProperty(layer, "manager", { + value: { + root: { + selectionState: { + pin: { + value: true, + }, + }, + }, + }, + configurable: true, + }); + const node = { + nodeId: 31, + segmentId: 9, + position: new Float32Array([4, 5, 6]), + }; + + expect(layer.selectAndMoveToSpatialSkeletonNode(node)).toBe(true); + expect(selectSpatialSkeletonNode).toHaveBeenCalledWith(31, true, { + segmentId: 9, + position: new Float32Array([4, 5, 6]), + }); + expect(moveViewToSpatialSkeletonNodePosition).toHaveBeenCalledWith( + node.position, + ); + expect(clearSpatialSkeletonNodeSelection).not.toHaveBeenCalled(); + + expect(layer.selectAndMoveToSpatialSkeletonNode(undefined, false)).toBe( + false, + ); + expect(clearSpatialSkeletonNodeSelection).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 8fc24b8fd4..c7980e21a0 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -16,6 +16,11 @@ import "#src/layer/segmentation/style.css"; +import svg_circle from "ikonate/icons/circle.svg?raw"; +import svg_flag from "ikonate/icons/flag.svg?raw"; +import svg_minus from "ikonate/icons/minus.svg?raw"; +import svg_origin from "ikonate/icons/origin.svg?raw"; +import svg_share_android from "ikonate/icons/share-android.svg?raw"; import type { CoordinateTransformSpecification } from "#src/coordinate_transform.js"; import { emptyValidCoordinateSpace } from "#src/coordinate_transform.js"; import type { DataSourceSpecification } from "#src/datasource/index.js"; @@ -23,7 +28,12 @@ import { LocalDataSource, localEquivalencesUrl, } from "#src/datasource/local.js"; -import type { LayerActionContext, ManagedUserLayer } from "#src/layer/index.js"; +import type { + LayerActionContext, + ManagedUserLayer, + MouseSelectionState, + UserLayerSelectionState, +} from "#src/layer/index.js"; import { LinkedLayerGroup, registerLayerType, @@ -35,6 +45,20 @@ import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; import { layerDataSourceSpecificationFromJson } from "#src/layer/layer_data_source.js"; import * as json_keys from "#src/layer/segmentation/json_keys.js"; import { registerLayerControls } from "#src/layer/segmentation/layer_controls.js"; +import { + getNodeIdFromLayerSelectionState, + getSegmentIdFromLayerSelectionValue, + SpatialSkeletonHoverState, +} from "#src/layer/segmentation/selection.js"; +import { + executeSpatialSkeletonDeleteNode, + executeSpatialSkeletonNodeDescriptionUpdate, + executeSpatialSkeletonNodePropertiesUpdate, + executeSpatialSkeletonReroot, + executeSpatialSkeletonNodeTrueEndUpdate, +} from "#src/layer/segmentation/spatial_skeleton_commands.js"; +import { showSpatialSkeletonActionError } from "#src/layer/segmentation/spatial_skeleton_errors.js"; +import { appendSpatialSkeletonSerializationState } from "#src/layer/segmentation/spatial_skeleton_serialization.js"; import { MeshLayer, MeshSource, @@ -43,9 +67,16 @@ import { } from "#src/mesh/frontend.js"; import { RenderScaleHistogram, + numRenderScaleHistogramBins, + renderScaleHistogramBinSize, + renderScaleHistogramOrigin, trackableRenderScaleTarget, } from "#src/render_scale_statistics.js"; import { getCssColor, SegmentColorHash } from "#src/segment_color.js"; +import { + addSegmentToVisibleSets, + getVisibleSegments, +} from "#src/segmentation_display_state/base.js"; import type { SegmentationColorGroupState, SegmentationDisplayState, @@ -75,18 +106,54 @@ import type { import { SegmentationGraphSourceTab } from "#src/segmentation_graph/source.js"; import { SharedDisjointUint64Sets } from "#src/shared_disjoint_sets.js"; import { SharedWatchableValue } from "#src/shared_watchable_value.js"; +import { + DEFAULT_SPATIAL_SKELETON_EDIT_ACTIONS, + getSpatialSkeletonActionSupportLabel, + SpatialSkeletonActions, + type SpatialSkeletonAction, +} from "#src/skeleton/actions.js"; +import { + SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES, + type SpatiallyIndexedSkeletonNode, +} from "#src/skeleton/api.js"; +import { + buildSpatiallyIndexedSkeletonNeighborhoodEditContext, + findSpatiallyIndexedSkeletonNode, + getSpatiallyIndexedSkeletonDirectChildren, + getSpatiallyIndexedSkeletonNodeParent, +} from "#src/skeleton/edit_state.js"; import { PerspectiveViewSkeletonLayer, SkeletonLayer, SkeletonRenderingOptions, SliceViewPanelSkeletonLayer, + PerspectiveViewSpatiallyIndexedSkeletonLayer, + SliceViewPanelSpatiallyIndexedSkeletonLayer, + SliceViewSpatiallyIndexedSkeletonLayer, + SpatiallyIndexedSkeletonLayer, + SpatiallyIndexedSkeletonSource, + MultiscaleSpatiallyIndexedSkeletonSource, + MultiscaleSliceViewSpatiallyIndexedSkeletonLayer, } from "#src/skeleton/frontend.js"; +import { + classifySpatialSkeletonDisplayNodeType as getSpatialSkeletonDisplayNodeType, + getSpatialSkeletonNodeFilterLabel, + getSpatialSkeletonNodeIconFilterType, + SpatialSkeletonNodeFilterType, + type SpatialSkeletonDisplayNodeType, +} from "#src/skeleton/node_types.js"; +import { + getEditableSpatiallyIndexedSkeletonSource, + getSpatiallyIndexedSkeletonSource, + SpatialSkeletonState, +} from "#src/skeleton/spatial_skeleton_manager.js"; import { DataType, VolumeType } from "#src/sliceview/volume/base.js"; import { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; import { SegmentationRenderLayer } from "#src/sliceview/volume/segmentation_renderlayer.js"; import { StatusMessage } from "#src/status.js"; import { trackableAlphaValue } from "#src/trackable_alpha.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; +import { trackableFiniteFloat } from "#src/trackable_finite_float.js"; import type { TrackableValueInterface, WatchableValueInterface, @@ -106,6 +173,8 @@ import { SegmentDisplayTab } from "#src/ui/segment_list.js"; import { registerSegmentSelectTools } from "#src/ui/segment_select_tools.js"; import { registerSegmentSplitMergeTools } from "#src/ui/segment_split_merge_tools.js"; import { DisplayOptionsTab } from "#src/ui/segmentation_display_options_tab.js"; +import { SpatialSkeletonEditTab } from "#src/ui/spatial_skeleton_edit_tab.js"; +import { registerSpatialSkeletonEditModeTool } from "#src/ui/spatial_skeleton_edit_tool.js"; import { Uint64Map } from "#src/uint64_map.js"; import { Uint64OrderedSet } from "#src/uint64_ordered_set.js"; import { Uint64Set } from "#src/uint64_set.js"; @@ -124,17 +193,117 @@ import { parseArray, parseUint64, verifyFiniteNonNegativeFloat, + verifyNonnegativeInt, verifyObjectAsMap, verifyOptionalObjectProperty, verifyString, } from "#src/util/json.js"; +import * as matrix from "#src/util/matrix.js"; import { Signal } from "#src/util/signal.js"; +import { TrackableEnum } from "#src/util/trackable_enum.js"; import { makeWatchableShaderError } from "#src/webgl/dynamic_shader.js"; +import { makeDeleteButton } from "#src/widget/delete_button.js"; import type { DependentViewContext } from "#src/widget/dependent_view_widget.js"; +import { makeIcon } from "#src/widget/icon.js"; import { registerLayerShaderControlsTool } from "#src/widget/shader_controls.js"; const MAX_LAYER_BAR_UI_INDICATOR_COLORS = 6; +const SPATIAL_SKELETON_NODE_TYPE_ICONS: Record< + SpatialSkeletonDisplayNodeType, + string +> = { + root: svg_origin, + branchStart: svg_share_android, + regular: svg_minus, + virtualEnd: svg_circle, +}; + +function getSpatialSkeletonNodeTypeLabel( + nodeType: SpatialSkeletonDisplayNodeType, + nodeHasTrueEnd: boolean, +) { + if (nodeHasTrueEnd) return "True end"; + switch (nodeType) { + case "root": + return "Root"; + case "branchStart": + return "Branch point"; + case "virtualEnd": + return "Leaf"; + default: + return "Node"; + } +} + +function formatSpatialSkeletonPosition( + modelPosition: ArrayLike, + names?: readonly string[], +) { + const x = Math.round(Number(modelPosition[0])); + const y = Math.round(Number(modelPosition[1])); + const z = Math.round(Number(modelPosition[2])); + const n = names ?? ["x", "y", "z"]; + return { + copyText: `${x}, ${y}, ${z}`, + displayText: `${x} ${y} ${z}`, + fullText: `${n[0]}: ${x} ${n[1]}: ${y} ${n[2]}: ${z}`, + x, + y, + z, + }; +} + +function formatSpatialSkeletonEditableNumber( + value: number | undefined, + fallback = "0", +) { + return value === undefined ? fallback : `${value}`; +} + +function getSpatialSkeletonSegmentChipColors( + displayState: SegmentationDisplayState | undefined | null, + segmentId: number, +) { + const color = getBaseObjectColor( + displayState, + BigInt(segmentId), + new Float32Array(4), + ); + const r = Math.round(color[0] * 255); + const g = Math.round(color[1] * 255); + const b = Math.round(color[2] * 255); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return { + background: getCssColor(color), + foreground: luminance > 0.6 ? "#101010" : "#f5f5f5", + }; +} + +function bindSpatialSkeletonSegmentSelection( + element: HTMLElement, + selectSegment: (id: bigint, pin: true | "force-unpin") => void, + segmentId: number, +) { + const id = BigInt(segmentId); + const hasSegmentSelectionModifiers = (event: MouseEvent) => + event.ctrlKey && !event.altKey && !event.metaKey; + element.addEventListener("mousedown", (event: MouseEvent) => { + if (event.button !== 2 || !hasSegmentSelectionModifiers(event)) return; + selectSegment(id, event.shiftKey ? "force-unpin" : true); + event.preventDefault(); + event.stopPropagation(); + }); + element.addEventListener("contextmenu", (event: MouseEvent) => { + if (!hasSegmentSelectionModifiers(event)) return; + if (event.button !== 2) { + selectSegment(id, event.shiftKey ? "force-unpin" : true); + } + event.preventDefault(); + event.stopPropagation(); + }); +} + export class SegmentationUserLayerGroupState extends RefCounted implements SegmentationGroupState @@ -410,6 +579,118 @@ class LinkedSegmentationGroupState< } } +type SpatialSkeletonGridSize = { x: number; y: number; z: number }; +type SpatialSkeletonGridLevel = { size: SpatialSkeletonGridSize; lod: number }; + +function getSpatialSkeletonGridSpacing(size: SpatialSkeletonGridSize) { + return Math.min(size.x, size.y, size.z); +} + +function buildSpatialSkeletonGridLevels( + gridSizes: SpatialSkeletonGridSize[], +): SpatialSkeletonGridLevel[] { + if (gridSizes.length === 0) return []; + const lastIndex = gridSizes.length - 1; + return gridSizes.map((size, index) => ({ + size, + lod: lastIndex === 0 ? 0 : index / lastIndex, + })); +} + +function findClosestSpatialSkeletonGridLevel( + levels: SpatialSkeletonGridLevel[], + lod: number, +): number { + let bestIndex = 0; + let bestDistance = Number.POSITIVE_INFINITY; + for (let i = 0; i < levels.length; ++i) { + const distance = Math.abs(levels[i].lod - lod); + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = i; + } + } + return bestIndex; +} + +function findClosestSpatialSkeletonGridLevelBySpacing( + levels: SpatialSkeletonGridLevel[], + spacing: number, +): number { + let bestIndex = 0; + let bestDistance = Number.POSITIVE_INFINITY; + for (let i = 0; i < levels.length; ++i) { + const gridSpacing = getSpatialSkeletonGridSpacing(levels[i].size); + const distance = Math.abs(gridSpacing - spacing); + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = i; + } + } + return bestIndex; +} + +function getSpatialSkeletonGridHistogramConfig( + levels: SpatialSkeletonGridLevel[], +) { + if (levels.length === 0) { + return { + origin: renderScaleHistogramOrigin, + binSize: renderScaleHistogramBinSize, + }; + } + const logSpacings: number[] = []; + let minLogSpacing = Number.POSITIVE_INFINITY; + let maxLogSpacing = Number.NEGATIVE_INFINITY; + for (const level of levels) { + const spacing = Math.max(getSpatialSkeletonGridSpacing(level.size), 1e-6); + const logSpacing = Math.log2(spacing); + logSpacings.push(logSpacing); + minLogSpacing = Math.min(minLogSpacing, logSpacing); + maxLogSpacing = Math.max(maxLogSpacing, logSpacing); + } + if (!Number.isFinite(minLogSpacing) || !Number.isFinite(maxLogSpacing)) { + return { + origin: renderScaleHistogramOrigin, + binSize: renderScaleHistogramBinSize, + }; + } + logSpacings.sort((a, b) => a - b); + let minDelta = Number.POSITIVE_INFINITY; + for (let i = 1; i < logSpacings.length; ++i) { + const delta = logSpacings[i] - logSpacings[i - 1]; + if (delta > 0) minDelta = Math.min(minDelta, delta); + } + const span = maxLogSpacing - minLogSpacing; + const minBinSizeForCoverage = + span / Math.max(numRenderScaleHistogramBins - 4, 1); + const lowerBound = Math.max(minBinSizeForCoverage, 0.05); + let binSize = lowerBound; + if (Number.isFinite(minDelta)) { + const maxBinSizeForDistinctBars = minDelta * 0.9; + if (maxBinSizeForDistinctBars >= lowerBound) { + binSize = maxBinSizeForDistinctBars; + } + } + if (!Number.isFinite(binSize) || binSize <= 0) { + binSize = renderScaleHistogramBinSize; + } + + const range = numRenderScaleHistogramBins * binSize; + const desiredPadding = binSize * 2; + const minOrigin = maxLogSpacing + desiredPadding - range; + const maxOrigin = minLogSpacing - desiredPadding; + const centeredOrigin = (minLogSpacing + maxLogSpacing - range) / 2; + const clampedOrigin = Math.min( + Math.max(centeredOrigin, minOrigin), + maxOrigin, + ); + const roundedBinSize = Math.max(binSize, 1e-3); + const roundedOrigin = + Math.round(clampedOrigin / roundedBinSize) * roundedBinSize; + return { origin: roundedOrigin, binSize: roundedBinSize }; +} + class SegmentationUserLayerDisplayState implements SegmentationDisplayState { constructor(public layer: SegmentationUserLayer) { // Even though `SegmentationUserLayer` assigns this to its `displayState` property, redundantly @@ -421,8 +702,14 @@ class SegmentationUserLayerDisplayState implements SegmentationDisplayState { layer.manager.rootLayers, layer, (userLayer) => userLayer instanceof SegmentationUserLayer, - (userLayer: SegmentationUserLayer) => - userLayer.displayState.linkedSegmentationGroup, + (userLayer) => { + if (!(userLayer instanceof SegmentationUserLayer)) { + throw new Error( + "Expected a segmentation layer for the linked segmentation group.", + ); + } + return userLayer.displayState.linkedSegmentationGroup; + }, ), ); @@ -431,8 +718,14 @@ class SegmentationUserLayerDisplayState implements SegmentationDisplayState { layer.manager.rootLayers, layer, (userLayer) => userLayer instanceof SegmentationUserLayer, - (userLayer: SegmentationUserLayer) => - userLayer.displayState.linkedSegmentationColorGroup, + (userLayer) => { + if (!(userLayer instanceof SegmentationUserLayer)) { + throw new Error( + "Expected a segmentation layer for the linked segmentation color group.", + ); + } + return userLayer.displayState.linkedSegmentationColorGroup; + }, ), ); @@ -520,6 +813,83 @@ class SegmentationUserLayerDisplayState implements SegmentationDisplayState { (group) => group.segmentPropertyMap, ), ); + + this.spatialSkeletonGridResolutionTarget2d.changed.add(() => { + if (this.suppressSpatialSkeletonGridResolutionTarget2d) return; + this.spatialSkeletonGridResolutionTarget2dExplicit = true; + this.applySpatialSkeletonGridResolutionTarget("2d"); + }); + this.spatialSkeletonGridResolutionTarget3d.changed.add(() => { + if (this.suppressSpatialSkeletonGridResolutionTarget3d) return; + this.spatialSkeletonGridResolutionTarget3dExplicit = true; + this.applySpatialSkeletonGridResolutionTarget("3d"); + }); + this.spatialSkeletonGridResolutionRelative2d.changed.add(() => { + const nextRelative = this.spatialSkeletonGridResolutionRelative2d.value; + if (nextRelative !== this.lastSpatialSkeletonGridResolutionRelative2d) { + const pixelSize = Math.max( + this.spatialSkeletonGridPixelSize2d.value, + 1e-6, + ); + const currentTarget = this.spatialSkeletonGridResolutionTarget2d.value; + const adjustedTarget = nextRelative + ? currentTarget / pixelSize + : currentTarget * pixelSize; + this.suppressSpatialSkeletonGridResolutionTarget2d = true; + this.spatialSkeletonGridResolutionTarget2d.value = adjustedTarget; + this.suppressSpatialSkeletonGridResolutionTarget2d = false; + this.spatialSkeletonGridResolutionTarget2dExplicit = true; + this.lastSpatialSkeletonGridResolutionRelative2d = nextRelative; + } + this.applySpatialSkeletonGridResolutionTarget("2d"); + }); + this.spatialSkeletonGridResolutionRelative3d.changed.add(() => { + const nextRelative = this.spatialSkeletonGridResolutionRelative3d.value; + if (nextRelative !== this.lastSpatialSkeletonGridResolutionRelative3d) { + const pixelSize = Math.max( + this.spatialSkeletonGridPixelSize3d.value, + 1e-6, + ); + const currentTarget = this.spatialSkeletonGridResolutionTarget3d.value; + const adjustedTarget = nextRelative + ? currentTarget / pixelSize + : currentTarget * pixelSize; + this.suppressSpatialSkeletonGridResolutionTarget3d = true; + this.spatialSkeletonGridResolutionTarget3d.value = adjustedTarget; + this.suppressSpatialSkeletonGridResolutionTarget3d = false; + this.spatialSkeletonGridResolutionTarget3dExplicit = true; + this.lastSpatialSkeletonGridResolutionRelative3d = nextRelative; + } + this.applySpatialSkeletonGridResolutionTarget("3d"); + }); + this.spatialSkeletonGridPixelSize2d.changed.add(() => { + if (this.spatialSkeletonGridResolutionRelative2d.value) { + this.applySpatialSkeletonGridResolutionTarget("2d"); + } + }); + this.spatialSkeletonGridPixelSize3d.changed.add(() => { + if (this.spatialSkeletonGridResolutionRelative3d.value) { + this.applySpatialSkeletonGridResolutionTarget("3d"); + } + }); + this.spatialSkeletonGridLevel2d.changed.add(() => { + if (this.suppressSpatialSkeletonGridLevel2d) return; + if (this.spatialSkeletonGridLevels.value.length === 0) return; + this.setSpatialSkeletonGridLevel( + "2d", + this.spatialSkeletonGridLevel2d.value, + true, + ); + }); + this.spatialSkeletonGridLevel3d.changed.add(() => { + if (this.suppressSpatialSkeletonGridLevel3d) return; + if (this.spatialSkeletonGridLevels.value.length === 0) return; + this.setSpatialSkeletonGridLevel( + "3d", + this.spatialSkeletonGridLevel3d.value, + true, + ); + }); } segmentSelectionState = new SegmentSelectionState(); @@ -533,12 +903,67 @@ class SegmentationUserLayerDisplayState implements SegmentationDisplayState { 0, ); objectAlpha = trackableAlphaValue(1.0); + hiddenObjectAlpha = trackableAlphaValue(0.5); + skeletonLod = trackableFiniteFloat(0.0); + spatialSkeletonGridLevel2d = new TrackableValue( + 0, + verifyNonnegativeInt, + 0, + ); + spatialSkeletonGridLevel3d = new TrackableValue( + 0, + verifyNonnegativeInt, + 0, + ); + spatialSkeletonGridLevels = new WatchableValue( + [], + ); + spatialSkeletonGridResolutionTarget2d = new TrackableValue( + 1, + verifyFiniteNonNegativeFloat, + 1, + ); + spatialSkeletonGridResolutionTarget3d = new TrackableValue( + 1, + verifyFiniteNonNegativeFloat, + 1, + ); + spatialSkeletonGridResolutionRelative2d = new TrackableBoolean(false, false); + spatialSkeletonGridResolutionRelative3d = new TrackableBoolean(false, false); + spatialSkeletonGridPixelSize2d = new WatchableValue(1); + spatialSkeletonGridPixelSize3d = new WatchableValue(1); + spatialSkeletonGridChunkStats2d = new WatchableValue({ + presentCount: 0, + totalCount: 0, + }); + spatialSkeletonGridChunkStats3d = new WatchableValue({ + presentCount: 0, + totalCount: 0, + }); + spatialSkeletonGridRenderScaleHistogram2d = new RenderScaleHistogram(); + spatialSkeletonGridRenderScaleHistogram3d = new RenderScaleHistogram(); + spatialSkeletonLod2d = new WatchableValue(0); + spatialSkeletonNodeQuery = new TrackableValue("", verifyString); + spatialSkeletonNodeFilter = new TrackableEnum( + SpatialSkeletonNodeFilterType, + SpatialSkeletonNodeFilterType.NONE, + ); + private spatialSkeletonGridResolutionTarget2dExplicit = false; + private spatialSkeletonGridResolutionTarget3dExplicit = false; + private spatialSkeletonGridLevel2dExplicit = false; + private spatialSkeletonGridLevel3dExplicit = false; + private suppressSpatialSkeletonGridLevel2d = false; + private suppressSpatialSkeletonGridLevel3d = false; + private suppressSpatialSkeletonGridResolutionTarget2d = false; + private suppressSpatialSkeletonGridResolutionTarget3d = false; + private lastSpatialSkeletonGridResolutionRelative2d = false; + private lastSpatialSkeletonGridResolutionRelative3d = false; ignoreNullVisibleSet = new TrackableBoolean(true, true); skeletonRenderingOptions = new SkeletonRenderingOptions(); shaderError = makeWatchableShaderError(); renderScaleHistogram = new RenderScaleHistogram(); renderScaleTarget = trackableRenderScaleTarget(1); - selectSegment: (id: bigint, pin: boolean | "toggle") => void; + selectSegment: (id: bigint, pin: boolean | "toggle" | "force-unpin") => void; transparentPickEnabled: TrackableBoolean; baseSegmentColoring = new TrackableBoolean(false, false); baseSegmentHighlighting = new TrackableBoolean(false, false); @@ -551,6 +976,248 @@ class SegmentationUserLayerDisplayState implements SegmentationDisplayState { this.layer.moveToSegment(id); }; + setSpatialSkeletonGridSizes(gridSizes: SpatialSkeletonGridSize[]) { + const sortedSizes = [...gridSizes].sort( + (a, b) => Math.min(b.x, b.y, b.z) - Math.min(a.x, a.y, a.z), + ); + const levels = buildSpatialSkeletonGridLevels(sortedSizes); + const { origin: histogramOrigin, binSize: histogramBinSize } = + getSpatialSkeletonGridHistogramConfig(levels); + if ( + this.spatialSkeletonGridRenderScaleHistogram2d.logScaleOrigin !== + histogramOrigin || + this.spatialSkeletonGridRenderScaleHistogram2d.logScaleBinSize !== + histogramBinSize + ) { + this.spatialSkeletonGridRenderScaleHistogram2d.logScaleOrigin = + histogramOrigin; + this.spatialSkeletonGridRenderScaleHistogram2d.logScaleBinSize = + histogramBinSize; + this.spatialSkeletonGridRenderScaleHistogram2d.changed.dispatch(); + } + if ( + this.spatialSkeletonGridRenderScaleHistogram3d.logScaleOrigin !== + histogramOrigin || + this.spatialSkeletonGridRenderScaleHistogram3d.logScaleBinSize !== + histogramBinSize + ) { + this.spatialSkeletonGridRenderScaleHistogram3d.logScaleOrigin = + histogramOrigin; + this.spatialSkeletonGridRenderScaleHistogram3d.logScaleBinSize = + histogramBinSize; + this.spatialSkeletonGridRenderScaleHistogram3d.changed.dispatch(); + } + this.spatialSkeletonGridLevels.value = levels; + if (levels.length === 0) return; + const target3dIndex = this.spatialSkeletonGridResolutionTarget3dExplicit + ? findClosestSpatialSkeletonGridLevelBySpacing( + levels, + this.getSpatialSkeletonGridTargetSpacing("3d"), + ) + : this.spatialSkeletonGridLevel3dExplicit + ? this.spatialSkeletonGridLevel3d.value + : findClosestSpatialSkeletonGridLevel(levels, this.skeletonLod.value); + const resolved3dIndex = this.setSpatialSkeletonGridLevel( + "3d", + target3dIndex, + this.spatialSkeletonGridResolutionTarget3dExplicit || + this.spatialSkeletonGridLevel3dExplicit, + ); + const target2dIndex = this.spatialSkeletonGridResolutionTarget2dExplicit + ? findClosestSpatialSkeletonGridLevelBySpacing( + levels, + this.getSpatialSkeletonGridTargetSpacing("2d"), + ) + : this.spatialSkeletonGridLevel2dExplicit + ? this.spatialSkeletonGridLevel2d.value + : resolved3dIndex; + this.setSpatialSkeletonGridLevel( + "2d", + target2dIndex, + this.spatialSkeletonGridResolutionTarget2dExplicit || + this.spatialSkeletonGridLevel2dExplicit, + ); + if (!this.spatialSkeletonGridResolutionTarget3dExplicit) { + const spacing = getSpatialSkeletonGridSpacing( + levels[Math.min(resolved3dIndex, levels.length - 1)].size, + ); + const targetValue = this.spatialSkeletonGridResolutionRelative3d.value + ? spacing / Math.max(this.spatialSkeletonGridPixelSize3d.value, 1e-6) + : spacing; + this.suppressSpatialSkeletonGridResolutionTarget3d = true; + this.spatialSkeletonGridResolutionTarget3d.value = targetValue; + this.suppressSpatialSkeletonGridResolutionTarget3d = false; + } + if (!this.spatialSkeletonGridResolutionTarget2dExplicit) { + const resolved2dIndex = Math.min( + Math.max(target2dIndex, 0), + levels.length - 1, + ); + const spacing = getSpatialSkeletonGridSpacing( + levels[resolved2dIndex].size, + ); + const targetValue = this.spatialSkeletonGridResolutionRelative2d.value + ? spacing / Math.max(this.spatialSkeletonGridPixelSize2d.value, 1e-6) + : spacing; + this.suppressSpatialSkeletonGridResolutionTarget2d = true; + this.spatialSkeletonGridResolutionTarget2d.value = targetValue; + this.suppressSpatialSkeletonGridResolutionTarget2d = false; + } + } + + applySpatialSkeletonGridLevel2dFromSpec(value: any) { + if ( + value !== undefined && + !this.spatialSkeletonGridResolutionTarget2dExplicit + ) { + this.spatialSkeletonGridLevel2d.restoreState(value); + this.spatialSkeletonGridLevel2dExplicit = true; + if (this.spatialSkeletonGridLevels.value.length > 0) { + this.setSpatialSkeletonGridLevel( + "2d", + this.spatialSkeletonGridLevel2d.value, + true, + ); + if (!this.spatialSkeletonGridResolutionTarget2dExplicit) { + const spacing = getSpatialSkeletonGridSpacing( + this.spatialSkeletonGridLevels.value[ + Math.min( + this.spatialSkeletonGridLevel2d.value, + this.spatialSkeletonGridLevels.value.length - 1, + ) + ].size, + ); + const targetValue = this.spatialSkeletonGridResolutionRelative2d.value + ? spacing / + Math.max(this.spatialSkeletonGridPixelSize2d.value, 1e-6) + : spacing; + this.suppressSpatialSkeletonGridResolutionTarget2d = true; + this.spatialSkeletonGridResolutionTarget2d.value = targetValue; + this.suppressSpatialSkeletonGridResolutionTarget2d = false; + } + } + } + } + + applySpatialSkeletonGridLevel3dFromSpec(value: any) { + if ( + value !== undefined && + !this.spatialSkeletonGridResolutionTarget3dExplicit + ) { + this.spatialSkeletonGridLevel3d.restoreState(value); + this.spatialSkeletonGridLevel3dExplicit = true; + if (this.spatialSkeletonGridLevels.value.length > 0) { + this.setSpatialSkeletonGridLevel( + "3d", + this.spatialSkeletonGridLevel3d.value, + true, + ); + if (!this.spatialSkeletonGridResolutionTarget3dExplicit) { + const spacing = getSpatialSkeletonGridSpacing( + this.spatialSkeletonGridLevels.value[ + Math.min( + this.spatialSkeletonGridLevel3d.value, + this.spatialSkeletonGridLevels.value.length - 1, + ) + ].size, + ); + const targetValue = this.spatialSkeletonGridResolutionRelative3d.value + ? spacing / + Math.max(this.spatialSkeletonGridPixelSize3d.value, 1e-6) + : spacing; + this.suppressSpatialSkeletonGridResolutionTarget3d = true; + this.spatialSkeletonGridResolutionTarget3d.value = targetValue; + this.suppressSpatialSkeletonGridResolutionTarget3d = false; + } + } + } + } + + applySpatialSkeletonGridResolutionTarget2dFromSpec(value: any) { + if (value !== undefined) { + this.suppressSpatialSkeletonGridResolutionTarget2d = true; + this.spatialSkeletonGridResolutionTarget2d.restoreState(value); + this.suppressSpatialSkeletonGridResolutionTarget2d = false; + this.spatialSkeletonGridResolutionTarget2dExplicit = true; + if (this.spatialSkeletonGridLevels.value.length > 0) { + this.applySpatialSkeletonGridResolutionTarget("2d"); + } + } + } + + applySpatialSkeletonGridResolutionTarget3dFromSpec(value: any) { + if (value !== undefined) { + this.suppressSpatialSkeletonGridResolutionTarget3d = true; + this.spatialSkeletonGridResolutionTarget3d.restoreState(value); + this.suppressSpatialSkeletonGridResolutionTarget3d = false; + this.spatialSkeletonGridResolutionTarget3dExplicit = true; + if (this.spatialSkeletonGridLevels.value.length > 0) { + this.applySpatialSkeletonGridResolutionTarget("3d"); + } + } + } + + private getSpatialSkeletonGridTargetSpacing(kind: "2d" | "3d") { + const target = + kind === "2d" + ? this.spatialSkeletonGridResolutionTarget2d.value + : this.spatialSkeletonGridResolutionTarget3d.value; + const isRelative = + kind === "2d" + ? this.spatialSkeletonGridResolutionRelative2d.value + : this.spatialSkeletonGridResolutionRelative3d.value; + const pixelSize = + kind === "2d" + ? this.spatialSkeletonGridPixelSize2d.value + : this.spatialSkeletonGridPixelSize3d.value; + return isRelative ? target * pixelSize : target; + } + + private applySpatialSkeletonGridResolutionTarget(kind: "2d" | "3d") { + const levels = this.spatialSkeletonGridLevels.value; + if (levels.length === 0) return; + const targetSpacing = this.getSpatialSkeletonGridTargetSpacing(kind); + const index = findClosestSpatialSkeletonGridLevelBySpacing( + levels, + targetSpacing, + ); + const markExplicit = + kind === "2d" + ? this.spatialSkeletonGridResolutionTarget2dExplicit + : this.spatialSkeletonGridResolutionTarget3dExplicit; + this.setSpatialSkeletonGridLevel(kind, index, markExplicit); + } + + private setSpatialSkeletonGridLevel( + kind: "2d" | "3d", + index: number, + markExplicit: boolean, + ) { + const levels = this.spatialSkeletonGridLevels.value; + if (levels.length === 0) return 0; + const clampedIndex = Math.min(Math.max(index, 0), levels.length - 1); + if (kind === "2d") { + if (markExplicit) this.spatialSkeletonGridLevel2dExplicit = true; + this.suppressSpatialSkeletonGridLevel2d = true; + this.spatialSkeletonGridLevel2d.value = clampedIndex; + this.suppressSpatialSkeletonGridLevel2d = false; + const nextLod = levels[clampedIndex].lod; + if (this.spatialSkeletonLod2d.value !== nextLod) { + this.spatialSkeletonLod2d.value = nextLod; + } + return clampedIndex; + } + if (markExplicit) this.spatialSkeletonGridLevel3dExplicit = true; + this.suppressSpatialSkeletonGridLevel3d = true; + this.spatialSkeletonGridLevel3d.value = clampedIndex; + this.suppressSpatialSkeletonGridLevel3d = false; + const nextLod = levels[clampedIndex].lod; + if (this.skeletonLod.value !== nextLod) { + this.skeletonLod.value = nextLod; + } + return clampedIndex; + } + linkedSegmentationGroup: LinkedLayerGroup; linkedSegmentationColorGroup: LinkedLayerGroup; originalSegmentationGroupState: SegmentationUserLayerGroupState; @@ -579,11 +1246,48 @@ interface SegmentationActionContext extends LayerActionContext { segmentationToggleSegmentState?: boolean | undefined; } +interface SelectedSpatialSkeletonNodeInfo { + nodeId: number; + segmentId?: number; + position?: Float32Array; + revisionToken?: string; +} + +function normalizeOptionalPositiveSafeInteger(value: unknown) { + if (value === undefined) return undefined; + const normalized = Math.round(Number(value)); + return Number.isSafeInteger(normalized) && normalized > 0 + ? normalized + : undefined; +} + +function copyOptionalSpatialSkeletonPosition( + value: ArrayLike | undefined, +) { + if (value === undefined) return undefined; + return new Float32Array(Array.from(value, Number)); +} + const Base = UserLayerWithAnnotationsMixin(UserLayer); export class SegmentationUserLayer extends Base { sliceViewRenderScaleHistogram = new RenderScaleHistogram(); sliceViewRenderScaleTarget = trackableRenderScaleTarget(1); codeVisible = new TrackableBoolean(true); + readonly spatialSkeletonState = this.registerDisposer( + new SpatialSkeletonState(), + ); + readonly selectedSpatialSkeletonNodeId = new WatchableValue< + number | undefined + >(undefined); + readonly selectedSpatialSkeletonNodeInfo = new WatchableValue< + SelectedSpatialSkeletonNodeInfo | undefined + >(undefined); + readonly hoveredSpatialSkeletonNodeId = this.registerDisposer( + new SpatialSkeletonHoverState(), + ); + readonly spatialSkeletonVisibleChunksNeeded = new WatchableValue(0); + readonly spatialSkeletonVisibleChunksAvailable = new WatchableValue(0); + readonly spatialSkeletonVisibleChunksLoaded = new WatchableValue(false); graphConnection = new WatchableValue< SegmentationGraphSourceConnection | undefined @@ -606,6 +1310,216 @@ export class SegmentationUserLayer extends Base { ); }; + private captureSpatialSkeletonSelectionState( + capture: (state: this["selectionState"]) => boolean, + pin: boolean | "toggle" | "force-unpin", + options: { position?: ArrayLike } = {}, + ) { + const selectionState = this.manager.root.selectionState; + if (pin !== false || selectionState.pin.value) { + selectionState.captureSingleLayerState(this, capture, pin, options); + return; + } + const state = {} as UserLayerSelectionState; + this.initializeSelectionState(state); + if (!capture(state)) return; + selectionState.value = { + layers: [{ layer: this, state }], + coordinateSpace: selectionState.coordinateSpace.value, + position: + options.position === undefined + ? undefined + : new Float32Array(options.position), + }; + } + + private getGlobalSelectionPositionFromModelPosition( + modelPosition: ArrayLike | undefined, + ) { + if (modelPosition === undefined) return undefined; + const transform = + this.getSpatiallyIndexedSkeletonLayer()?.displayState.transform.value; + if (transform === undefined || transform.error !== undefined) + return undefined; + const rank = transform.rank; + const paddedModelPosition = new Float32Array(rank); + for (let i = 0; i < Math.min(modelPosition.length, rank); ++i) { + paddedModelPosition[i] = Number(modelPosition[i]); + } + const layerPosition = new Float32Array(rank); + matrix.transformPoint( + layerPosition, + transform.modelToRenderLayerTransform, + rank + 1, + paddedModelPosition, + rank, + ); + const result = this.manager.root.globalPosition.value.slice(); + gatherUpdate( + result, + layerPosition, + transform.globalToRenderLayerDimensions, + ); + return result; + } + + moveViewToSpatialSkeletonNodePosition(position: ArrayLike) { + const transform = + this.getSpatiallyIndexedSkeletonLayer()?.displayState.transform.value; + if (transform === undefined || transform.error !== undefined) return; + const rank = transform.rank; + const modelPosition = new Float32Array(rank); + for (let i = 0; i < Math.min(position.length, rank); ++i) { + modelPosition[i] = Number(position[i]); + } + const layerPosition = new Float32Array(rank); + matrix.transformPoint( + layerPosition, + transform.modelToRenderLayerTransform, + rank + 1, + modelPosition, + rank, + ); + this.setLayerPosition(transform, layerPosition); + } + + selectSpatialSkeletonNode = ( + nodeId: number, + pin: boolean | "toggle" = false, + options: { + segmentId?: number; + position?: ArrayLike; + revisionToken?: string; + } = {}, + ) => { + const normalizedNodeId = normalizeOptionalPositiveSafeInteger(nodeId); + if (normalizedNodeId === undefined) { + return; + } + const selectedNodeInfo = + this.getSpatiallyIndexedSkeletonLayer()?.getNode(normalizedNodeId); + const requestedSegmentId = + options.segmentId ?? selectedNodeInfo?.segmentId ?? undefined; + const segmentId = normalizeOptionalPositiveSafeInteger(requestedSegmentId); + const selectedNodePosition = options.position ?? selectedNodeInfo?.position; + const selectedGlobalPosition = + this.getGlobalSelectionPositionFromModelPosition(selectedNodePosition); + const revisionToken = + typeof options.revisionToken === "string" + ? options.revisionToken + : selectedNodeInfo?.revisionToken; + this.selectedSpatialSkeletonNodeInfo.value = { + nodeId: normalizedNodeId, + segmentId, + position: copyOptionalSpatialSkeletonPosition(selectedNodePosition), + revisionToken, + }; + this.captureSpatialSkeletonSelectionState( + (state) => { + state.nodeId = normalizedNodeId.toString(); + state.value = segmentId === undefined ? undefined : BigInt(segmentId); + return true; + }, + pin, + { position: selectedGlobalPosition }, + ); + }; + + selectAndMoveToSpatialSkeletonNode( + node: + | Pick + | undefined, + pin: boolean | "toggle" = this.manager.root.selectionState.pin.value, + ) { + if (node === undefined) { + this.clearSpatialSkeletonNodeSelection(pin); + return false; + } + this.selectSpatialSkeletonNode(node.nodeId, pin, { + segmentId: node.segmentId, + position: node.position, + }); + this.moveViewToSpatialSkeletonNodePosition(node.position); + return true; + } + + inspectSpatialSkeletonSegment = ( + segmentId: number, + options: { secondary?: boolean } = {}, + ) => { + void options; + const normalizedSegmentId = Math.round(Number(segmentId)); + if ( + !Number.isSafeInteger(normalizedSegmentId) || + normalizedSegmentId <= 0 + ) { + return false; + } + const visibleSegments = getVisibleSegments( + this.displayState.segmentationGroupState.value, + ); + if (visibleSegments.has(BigInt(normalizedSegmentId))) { + return false; + } + addSegmentToVisibleSets( + this.displayState.segmentationGroupState.value, + BigInt(normalizedSegmentId), + ); + return true; + }; + + setSpatialSkeletonMergeAnchor = (nodeId: number | undefined) => { + return this.spatialSkeletonState.setMergeAnchor(nodeId); + }; + + clearSpatialSkeletonMergeAnchor = () => { + return this.spatialSkeletonState.clearMergeAnchor(); + }; + + ensureSpatialSkeletonInspectionFromSelection = () => { + const selectedNodeId = this.selectedSpatialSkeletonNodeId.value; + const selectedNode = + selectedNodeId === undefined + ? undefined + : this.spatialSkeletonState.getCachedNode(selectedNodeId); + const visibleSegments = getVisibleSegments( + this.displayState.segmentationGroupState.value, + ); + if ( + selectedNode !== undefined && + visibleSegments.has(BigInt(selectedNode.segmentId)) + ) { + return selectedNode.segmentId; + } + const selectedSegmentValue = + this.displayState.segmentSelectionState.baseValue ?? undefined; + const selectedSegmentId = + selectedSegmentValue === undefined + ? undefined + : Number(selectedSegmentValue); + if ( + selectedSegmentId === undefined || + !Number.isSafeInteger(selectedSegmentId) || + selectedSegmentId <= 0 + ) { + return undefined; + } + return visibleSegments.has(BigInt(selectedSegmentId)) + ? selectedSegmentId + : undefined; + }; + + clearSpatialSkeletonNodeSelection = ( + pin: boolean | "toggle" | "force-unpin" = false, + ) => { + this.selectedSpatialSkeletonNodeInfo.value = undefined; + this.captureSpatialSkeletonSelectionState((state) => { + state.nodeId = undefined; + state.value = undefined; + return true; + }, pin); + }; + filterBySegmentLabel = (id: bigint) => { const augmented = augmentSegmentId(this.displayState, id); const { label } = augmented; @@ -621,6 +1535,11 @@ export class SegmentationUserLayer extends Base { }; displayState = new SegmentationUserLayerDisplayState(this); + readonly spatialSkeletonEditMode = this.spatialSkeletonState.editMode; + readonly spatialSkeletonMergeMode = this.spatialSkeletonState.mergeMode; + readonly spatialSkeletonSplitMode = this.spatialSkeletonState.splitMode; + readonly spatialSkeletonNodeDataVersion = + this.spatialSkeletonState.nodeDataVersion; anchorSegment = new TrackableValue(undefined, (x) => x === undefined ? undefined : parseUint64(x), @@ -649,6 +1568,39 @@ export class SegmentationUserLayer extends Base { this.manager.layerSelectedValues, this, ); + const syncSelectedSpatialSkeletonNodeIdFromGlobalSelection = () => { + const nextLayerSelectionState = + this.manager.root.selectionState.value?.layers.find( + (entry) => entry.layer === this, + )?.state; + const nextSelectedNodeId = getNodeIdFromLayerSelectionState( + nextLayerSelectionState, + ); + const nextSelectedSegmentId = getSegmentIdFromLayerSelectionValue( + nextLayerSelectionState, + ); + if (this.selectedSpatialSkeletonNodeId.value !== nextSelectedNodeId) { + this.selectedSpatialSkeletonNodeId.value = nextSelectedNodeId; + } + const selectedNodeInfo = this.selectedSpatialSkeletonNodeInfo.value; + if ( + selectedNodeInfo !== undefined && + (selectedNodeInfo.nodeId !== nextSelectedNodeId || + selectedNodeInfo.segmentId !== nextSelectedSegmentId) + ) { + this.selectedSpatialSkeletonNodeInfo.value = undefined; + } + }; + this.registerDisposer( + this.manager.root.selectionState.changed.add( + syncSelectedSpatialSkeletonNodeIdFromGlobalSelection, + ), + ); + syncSelectedSpatialSkeletonNodeIdFromGlobalSelection(); + this.hoveredSpatialSkeletonNodeId.bindTo( + this.manager.layerSelectedValues, + this, + ); this.displayState.selectedAlpha.changed.add( this.specificationChanged.dispatch, ); @@ -661,6 +1613,36 @@ export class SegmentationUserLayer extends Base { this.displayState.objectAlpha.changed.add( this.specificationChanged.dispatch, ); + this.displayState.hiddenObjectAlpha.changed.add( + this.specificationChanged.dispatch, + ); + this.displayState.skeletonLod.changed.add( + this.specificationChanged.dispatch, + ); + this.displayState.spatialSkeletonNodeQuery.changed.add( + this.specificationChanged.dispatch, + ); + this.displayState.spatialSkeletonNodeFilter.changed.add( + this.specificationChanged.dispatch, + ); + this.displayState.spatialSkeletonGridResolutionTarget2d.changed.add( + this.specificationChanged.dispatch, + ); + this.displayState.spatialSkeletonGridResolutionTarget3d.changed.add( + this.specificationChanged.dispatch, + ); + this.displayState.spatialSkeletonGridResolutionRelative2d.changed.add( + this.specificationChanged.dispatch, + ); + this.displayState.spatialSkeletonGridResolutionRelative3d.changed.add( + this.specificationChanged.dispatch, + ); + this.displayState.spatialSkeletonGridLevel2d.changed.add( + this.specificationChanged.dispatch, + ); + this.displayState.spatialSkeletonGridLevel3d.changed.add( + this.specificationChanged.dispatch, + ); this.displayState.hoverHighlight.changed.add( this.specificationChanged.dispatch, ); @@ -689,6 +1671,17 @@ export class SegmentationUserLayer extends Base { this.displayState.linkedSegmentationGroup.changed.add(() => this.updateDataSubsourceActivations(), ); + this.registerDisposer( + this.layersChanged.add(() => this.updateSpatialSkeletonChunkLoadState()), + ); + this.registerDisposer( + this.layersChanged.add(() => this.updateSpatialSkeletonSourceState()), + ); + this.registerDisposer( + this.manager.chunkManager.layerChunkStatisticsUpdated.add(() => + this.updateSpatialSkeletonChunkLoadState(), + ), + ); this.tabs.add("rendering", { label: "Render", order: -100, @@ -699,6 +1692,25 @@ export class SegmentationUserLayer extends Base { order: -50, getter: () => new SegmentDisplayTab(this), }); + const hideSpatialSkeletonEditTab = this.registerDisposer( + makeCachedLazyDerivedWatchableValue( + (layers) => + !layers.some( + (layer) => + (layer instanceof PerspectiveViewSpatiallyIndexedSkeletonLayer || + layer instanceof SliceViewPanelSpatiallyIndexedSkeletonLayer || + layer instanceof SliceViewSpatiallyIndexedSkeletonLayer) && + getSpatiallyIndexedSkeletonSource(layer.base) !== undefined, + ), + { changed: this.layersChanged, value: this.renderLayers }, + ), + ); + this.tabs.add("skeleton", { + label: "Skeleton", + order: -45, + getter: () => new SpatialSkeletonEditTab(this), + hidden: hideSpatialSkeletonEditTab, + }); const hideGraphTab = this.registerDisposer( makeCachedDerivedWatchableValue( (x) => x === undefined, @@ -712,6 +1724,8 @@ export class SegmentationUserLayer extends Base { hidden: hideGraphTab, }); this.tabs.default = "rendering"; + this.updateSpatialSkeletonChunkLoadState(); + this.updateSpatialSkeletonSourceState(); } get volumeOptions() { @@ -733,7 +1747,9 @@ export class SegmentationUserLayer extends Base { x instanceof MeshLayer || x instanceof MultiscaleMeshLayer || x instanceof PerspectiveViewSkeletonLayer || - x instanceof SliceViewPanelSkeletonLayer, + x instanceof SliceViewPanelSkeletonLayer || + x instanceof PerspectiveViewSpatiallyIndexedSkeletonLayer || + x instanceof SliceViewPanelSpatiallyIndexedSkeletonLayer, ), { changed: this.layersChanged, value: this.renderLayers }, ), @@ -741,26 +1757,289 @@ export class SegmentationUserLayer extends Base { readonly hasSkeletonsLayer = this.registerDisposer( makeCachedLazyDerivedWatchableValue( - (layers) => layers.some((x) => x instanceof PerspectiveViewSkeletonLayer), + (layers) => + layers.some( + (x) => + x instanceof PerspectiveViewSkeletonLayer || + x instanceof PerspectiveViewSpatiallyIndexedSkeletonLayer, + ), { changed: this.layersChanged, value: this.renderLayers }, ), ); - readonly getSkeletonLayer = () => { - for (const layer of this.renderLayers) { + readonly hasSpatiallyIndexedSkeletonsLayer = this.registerDisposer( + makeCachedLazyDerivedWatchableValue( + (layers) => + layers.some( + (x) => + x instanceof PerspectiveViewSpatiallyIndexedSkeletonLayer || + x instanceof SliceViewPanelSpatiallyIndexedSkeletonLayer, + ), + { changed: this.layersChanged, value: this.renderLayers }, + ), + ); + + readonly getSkeletonLayer = () => { + for (const layer of this.renderLayers) { if (layer instanceof PerspectiveViewSkeletonLayer) { return layer.base; } + if (layer instanceof PerspectiveViewSpatiallyIndexedSkeletonLayer) { + return layer.base; + } + } + return undefined; + }; + + readonly getSpatiallyIndexedSkeletonLayer = () => { + for (const layer of this.renderLayers) { + if (layer instanceof PerspectiveViewSpatiallyIndexedSkeletonLayer) { + return layer.base; + } + if (layer instanceof SliceViewPanelSpatiallyIndexedSkeletonLayer) { + return layer.base; + } + if (layer instanceof SliceViewSpatiallyIndexedSkeletonLayer) { + return layer.base; + } } return undefined; }; + getSpatialSkeletonChunkStats(kind: "2d" | "3d") { + let needed = 0; + let available = 0; + for (const layer of this.renderLayers) { + if ( + kind === "3d" && + layer instanceof PerspectiveViewSpatiallyIndexedSkeletonLayer + ) { + needed += layer.layerChunkProgressInfo.numVisibleChunksNeeded; + available += layer.layerChunkProgressInfo.numVisibleChunksAvailable; + continue; + } + if ( + kind === "2d" && + (layer instanceof SliceViewSpatiallyIndexedSkeletonLayer || + layer instanceof MultiscaleSliceViewSpatiallyIndexedSkeletonLayer) + ) { + needed += layer.layerChunkProgressInfo.numVisibleChunksNeeded; + available += layer.layerChunkProgressInfo.numVisibleChunksAvailable; + } + } + return { presentCount: available, totalCount: needed }; + } + + private setSpatialSkeletonChunkLoadState(needed: number, available: number) { + if (this.spatialSkeletonVisibleChunksNeeded.value !== needed) { + this.spatialSkeletonVisibleChunksNeeded.value = needed; + } + if (this.spatialSkeletonVisibleChunksAvailable.value !== available) { + this.spatialSkeletonVisibleChunksAvailable.value = available; + } + const loaded = needed > 0 && available >= needed; + if (this.spatialSkeletonVisibleChunksLoaded.value !== loaded) { + this.spatialSkeletonVisibleChunksLoaded.value = loaded; + } + } + + private updateSpatialSkeletonChunkLoadState() { + const stats2d = this.getSpatialSkeletonChunkStats("2d"); + const stats3d = this.getSpatialSkeletonChunkStats("3d"); + this.setSpatialSkeletonChunkLoadState( + stats2d.totalCount + stats3d.totalCount, + stats2d.presentCount + stats3d.presentCount, + ); + } + + private updateSpatialSkeletonSourceState() { + let hasSpatialSkeletonLayer = false; + for (const layer of this.renderLayers) { + if ( + layer instanceof PerspectiveViewSpatiallyIndexedSkeletonLayer || + layer instanceof SliceViewPanelSpatiallyIndexedSkeletonLayer || + layer instanceof SliceViewSpatiallyIndexedSkeletonLayer + ) { + hasSpatialSkeletonLayer = true; + break; + } + } + if (!hasSpatialSkeletonLayer) { + this.spatialSkeletonState.clearInspectedSkeletonCache(); + } + this.spatialSkeletonState.updateCommandHistorySource( + this.getSpatialSkeletonCommandHistorySource(), + ); + } + + private getSpatialSkeletonCommandHistorySource() { + for (const layer of this.renderLayers) { + if ( + layer instanceof PerspectiveViewSpatiallyIndexedSkeletonLayer || + layer instanceof SliceViewPanelSpatiallyIndexedSkeletonLayer || + layer instanceof SliceViewSpatiallyIndexedSkeletonLayer + ) { + return layer.base.source; + } + } + return undefined; + } + + private supportsSpatialSkeletonAction(action: SpatialSkeletonAction) { + const skeletonLayer = this.getSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) { + return false; + } + 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; + } + return true; + } + + private getMissingSpatialSkeletonSupportReason( + requiredActions: SpatialSkeletonAction | readonly SpatialSkeletonAction[], + ) { + const requirements = Array.isArray(requiredActions) + ? requiredActions + : [requiredActions]; + const missingRequirements = requirements.filter( + (action) => !this.supportsSpatialSkeletonAction(action), + ); + if (missingRequirements.length === 0) { + return undefined; + } + const names = missingRequirements.map(getSpatialSkeletonActionSupportLabel); + return `The active spatial skeleton source does not support ${names.join(", ")}.`; + } + + getSpatialSkeletonActionsDisabledReason( + requiredActions: + | SpatialSkeletonAction + | readonly SpatialSkeletonAction[] = DEFAULT_SPATIAL_SKELETON_EDIT_ACTIONS, + options: { + requireVisibleChunks?: boolean; + } = {}, + ) { + const { requireVisibleChunks = false } = options; + const missingSupportReason = + this.getMissingSpatialSkeletonSupportReason(requiredActions); + if (missingSupportReason !== undefined) { + return missingSupportReason; + } + if ( + requireVisibleChunks && + !this.spatialSkeletonVisibleChunksLoaded.value + ) { + const needed = this.spatialSkeletonVisibleChunksNeeded.value; + const available = this.spatialSkeletonVisibleChunksAvailable.value; + if (needed === 0) { + return "Waiting for visible skeleton chunks."; + } + return `Wait for visible skeleton chunks to load (${available}/${needed}).`; + } + return undefined; + } + + getCachedSpatialSkeletonSegmentNodesForEdit(segmentId: number) { + const segmentNodes = + this.spatialSkeletonState.getCachedSegmentNodes(segmentId); + if (segmentNodes === undefined) { + throw new Error( + `Segment ${segmentId} is not available in the inspected skeleton cache. Load the full skeleton before editing it.`, + ); + } + return segmentNodes; + } + + async getSpatialSkeletonDeleteOperationContext( + node: SpatiallyIndexedSkeletonNode, + ) { + const skeletonLayer = this.getSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) { + throw new Error( + "No active spatial skeleton layer found for delete action.", + ); + } + if ( + getEditableSpatiallyIndexedSkeletonSource(skeletonLayer) === undefined + ) { + throw new Error( + "Unable to resolve editable skeleton source for the active layer.", + ); + } + + const segmentNodes = this.getCachedSpatialSkeletonSegmentNodesForEdit( + node.segmentId, + ); + const currentNode = findSpatiallyIndexedSkeletonNode( + segmentNodes, + node.nodeId, + ); + if (currentNode === undefined) { + throw new Error( + `Node ${node.nodeId} is not available in the inspected skeleton cache.`, + ); + } + const childNodes = getSpatiallyIndexedSkeletonDirectChildren( + segmentNodes, + currentNode.nodeId, + ); + if (currentNode.parentNodeId === undefined && childNodes.length > 0) { + throw new Error( + "Deleting a root node with children is blocked. Reroot the skeleton manually before deleting it.", + ); + } + return { + node: currentNode, + parentNode: getSpatiallyIndexedSkeletonNodeParent( + segmentNodes, + currentNode, + ), + childNodes, + editContext: buildSpatiallyIndexedSkeletonNeighborhoodEditContext( + currentNode, + segmentNodes, + ), + }; + } + + getSpatialSkeletonNodeDisplayDescription(node: SpatiallyIndexedSkeletonNode) { + return node.description?.length ? node.description : undefined; + } + + async rerootSpatialSkeletonNode( + node: Pick< + SpatiallyIndexedSkeletonNode, + "nodeId" | "segmentId" | "parentNodeId" | "position" + >, + ) { + if (node.parentNodeId === undefined) { + throw new Error(`Node ${node.nodeId} is already root.`); + } + await executeSpatialSkeletonReroot(this, node); + } + + markSpatialSkeletonNodeDataChanged(options?: { + invalidateFullSkeletonCache?: boolean; + }) { + this.spatialSkeletonState.markNodeDataChanged(options); + } + activateDataSubsources(subsources: Iterable) { const updatedSegmentPropertyMaps: SegmentPropertyMap[] = []; const isGroupRoot = this.displayState.linkedSegmentationGroup.root.value === this; let updatedGraph: SegmentationGraphSource | undefined; let hasVolume = false; + let spatialSkeletonGridSizes: SpatialSkeletonGridSize[] | undefined; for (const loadedSubsource of subsources) { if (this.addStaticAnnotations(loadedSubsource)) continue; const { volume, mesh, segmentPropertyMap, segmentationGraph, local } = @@ -788,10 +2067,16 @@ export class SegmentationUserLayer extends Base { this.displayState.segmentationGroupState.value, ); } else if (mesh !== undefined) { + if (mesh instanceof MultiscaleSpatiallyIndexedSkeletonSource) { + // Collect grid metadata outside `activate`, since `activate` is a no-op + // when guard values are unchanged and may skip the callback. + spatialSkeletonGridSizes = mesh.getSpatialSkeletonGridSizes(); + } loadedSubsource.activate(() => { const displayState = { ...this.displayState, transform: loadedSubsource.getRenderLayerTransform(), + localPosition: this.localPosition, }; if (mesh instanceof MeshSource) { loadedSubsource.addRenderLayer( @@ -805,6 +2090,87 @@ export class SegmentationUserLayer extends Base { displayState, ), ); + } else if (mesh instanceof MultiscaleSpatiallyIndexedSkeletonSource) { + const base = new MultiscaleSliceViewSpatiallyIndexedSkeletonLayer( + this.manager.chunkManager, + mesh, + displayState, + ); + loadedSubsource.addRenderLayer(base); + + const perspectiveSources = mesh.getPerspectiveSources(); + const slicePanelSources = mesh.getSliceViewPanelSources(); + const sharedSpatialSkeletonSources = + perspectiveSources.length > 0 + ? perspectiveSources + : slicePanelSources; + if (sharedSpatialSkeletonSources.length > 0) { + // Share one mutable skeleton base across 2D/3D projections so + // local edit state stays consistent across panels. + const base = new SpatiallyIndexedSkeletonLayer( + this.manager.chunkManager, + sharedSpatialSkeletonSources, + displayState, + { + gridLevel: displayState.spatialSkeletonGridLevel3d, + lod: displayState.skeletonLod, + sources2d: slicePanelSources, + selectedNodeId: this.selectedSpatialSkeletonNodeId, + pendingNodePositionVersion: + this.spatialSkeletonState.pendingNodePositionVersion, + getPendingNodePosition: (nodeId) => + this.spatialSkeletonState.getPendingNodePosition(nodeId), + getCachedNode: (nodeId) => + this.spatialSkeletonState.getCachedNode(nodeId), + inspectionState: this.spatialSkeletonState, + }, + ); + if (perspectiveSources.length > 0) { + loadedSubsource.addRenderLayer( + new PerspectiveViewSpatiallyIndexedSkeletonLayer( + base.addRef(), + ), + ); + } + if (slicePanelSources.length > 0) { + loadedSubsource.addRenderLayer( + new SliceViewPanelSpatiallyIndexedSkeletonLayer( + /* transfer ownership */ base, + ), + ); + } else { + base.dispose(); + } + } + } else if (mesh instanceof SpatiallyIndexedSkeletonSource) { + const base = new SpatiallyIndexedSkeletonLayer( + this.manager.chunkManager, + mesh, + displayState, + { + gridLevel: displayState.spatialSkeletonGridLevel3d, + lod: displayState.skeletonLod, + selectedNodeId: this.selectedSpatialSkeletonNodeId, + pendingNodePositionVersion: + this.spatialSkeletonState.pendingNodePositionVersion, + getPendingNodePosition: (nodeId) => + this.spatialSkeletonState.getPendingNodePosition(nodeId), + getCachedNode: (nodeId) => + this.spatialSkeletonState.getCachedNode(nodeId), + inspectionState: this.spatialSkeletonState, + }, + ); + loadedSubsource.addRenderLayer( + new PerspectiveViewSpatiallyIndexedSkeletonLayer(base.addRef()), + ); + loadedSubsource.addRenderLayer( + new SliceViewSpatiallyIndexedSkeletonLayer(base.addRef()), + ); + loadedSubsource.addRenderLayer( + new SliceViewPanelSpatiallyIndexedSkeletonLayer( + /* transfer ownership */ base, + ), + ); } else { const base = new SkeletonLayer( this.manager.chunkManager, @@ -896,7 +2262,11 @@ export class SegmentationUserLayer extends Base { updatedSegmentPropertyMaps, ); this.displayState.originalSegmentationGroupState.graph.value = updatedGraph; + this.displayState.setSpatialSkeletonGridSizes( + spatialSkeletonGridSizes ?? [], + ); this.displayState.hasVolume.value = hasVolume; + this.updateSpatialSkeletonChunkLoadState(); } getLegacyDataSourceSpecifications( @@ -987,6 +2357,47 @@ export class SegmentationUserLayer extends Base { this.displayState.objectAlpha.restoreState( specification[json_keys.OBJECT_ALPHA_JSON_KEY], ); + this.displayState.hiddenObjectAlpha.restoreState( + specification[json_keys.HIDDEN_OPACITY_3D_JSON_KEY], + ); + this.displayState.skeletonLod.restoreState( + specification[json_keys.SKELETON_LOD_JSON_KEY], + ); + this.displayState.spatialSkeletonNodeQuery.restoreState( + specification[json_keys.SPATIAL_SKELETON_NODE_QUERY_JSON_KEY], + ); + verifyOptionalObjectProperty( + specification, + json_keys.SPATIAL_SKELETON_NODE_FILTER_JSON_KEY, + (value) => + this.displayState.spatialSkeletonNodeFilter.restoreState(value), + ); + this.displayState.spatialSkeletonGridResolutionRelative2d.restoreState( + specification[ + json_keys.SPATIAL_SKELETON_GRID_RESOLUTION_RELATIVE_2D_JSON_KEY + ], + ); + this.displayState.spatialSkeletonGridResolutionRelative3d.restoreState( + specification[ + json_keys.SPATIAL_SKELETON_GRID_RESOLUTION_RELATIVE_3D_JSON_KEY + ], + ); + this.displayState.applySpatialSkeletonGridResolutionTarget2dFromSpec( + specification[ + json_keys.SPATIAL_SKELETON_GRID_RESOLUTION_TARGET_2D_JSON_KEY + ], + ); + this.displayState.applySpatialSkeletonGridResolutionTarget3dFromSpec( + specification[ + json_keys.SPATIAL_SKELETON_GRID_RESOLUTION_TARGET_3D_JSON_KEY + ], + ); + this.displayState.applySpatialSkeletonGridLevel2dFromSpec( + specification[json_keys.SPATIAL_SKELETON_GRID_LEVEL_2D_JSON_KEY], + ); + this.displayState.applySpatialSkeletonGridLevel3dFromSpec( + specification[json_keys.SPATIAL_SKELETON_GRID_LEVEL_3D_JSON_KEY], + ); this.displayState.baseSegmentColoring.restoreState( specification[json_keys.BASE_SEGMENT_COLORING_JSON_KEY], ); @@ -1050,6 +2461,30 @@ export class SegmentationUserLayer extends Base { this.displayState.notSelectedAlpha.toJSON(); x[json_keys.SATURATION_JSON_KEY] = this.displayState.saturation.toJSON(); x[json_keys.OBJECT_ALPHA_JSON_KEY] = this.displayState.objectAlpha.toJSON(); + x[json_keys.SPATIAL_SKELETON_NODE_QUERY_JSON_KEY] = + this.displayState.spatialSkeletonNodeQuery.toJSON(); + x[json_keys.SPATIAL_SKELETON_NODE_FILTER_JSON_KEY] = + this.displayState.spatialSkeletonNodeFilter.toJSON(); + appendSpatialSkeletonSerializationState( + x, + { + hiddenObjectAlpha: this.displayState.hiddenObjectAlpha, + skeletonLod: this.displayState.skeletonLod, + spatialSkeletonGridResolutionTarget2d: + this.displayState.spatialSkeletonGridResolutionTarget2d, + spatialSkeletonGridResolutionTarget3d: + this.displayState.spatialSkeletonGridResolutionTarget3d, + spatialSkeletonGridResolutionRelative2d: + this.displayState.spatialSkeletonGridResolutionRelative2d, + spatialSkeletonGridResolutionRelative3d: + this.displayState.spatialSkeletonGridResolutionRelative3d, + spatialSkeletonGridLevel2d: + this.displayState.spatialSkeletonGridLevel2d, + spatialSkeletonGridLevel3d: + this.displayState.spatialSkeletonGridLevel3d, + }, + this.hasSpatiallyIndexedSkeletonsLayer.value, + ); x[json_keys.HOVER_HIGHLIGHT_JSON_KEY] = this.displayState.hoverHighlight.toJSON(); x[json_keys.BASE_SEGMENT_COLORING_JSON_KEY] = @@ -1142,14 +2577,41 @@ export class SegmentationUserLayer extends Base { } selectionStateFromJson(state: this["selectionState"], json: any) { super.selectionStateFromJson(state, json); - let { value } = state; - if (typeof value === "number") value = value.toString(); + let parsedValue = state.value; + if (typeof parsedValue === "number") parsedValue = parsedValue.toString(); try { - state.value = parseUint64(value); + state.value = parseUint64(parsedValue); } catch { state.value = undefined; } } + + captureSelectionState( + state: this["selectionState"], + mouseState: MouseSelectionState, + ) { + super.captureSelectionState(state, mouseState); + const pickedSpatialSkeleton = mouseState.pickedSpatialSkeleton; + if (pickedSpatialSkeleton === undefined) return; + const pickedRenderLayer = mouseState.pickedRenderLayer; + if ( + pickedRenderLayer !== null && + !this.renderLayers.includes(pickedRenderLayer) + ) { + return; + } + const nodeId = normalizeOptionalPositiveSafeInteger( + pickedSpatialSkeleton.nodeId, + ); + state.nodeId = nodeId === undefined ? undefined : nodeId.toString(); + const segmentId = normalizeOptionalPositiveSafeInteger( + pickedSpatialSkeleton.segmentId, + ); + if (segmentId !== undefined) { + state.value = BigInt(segmentId); + } + } + selectionStateToJson(state: this["selectionState"], forPython: boolean): any { const json = super.selectionStateToJson(state, forPython); const { value } = state; @@ -1262,12 +2724,688 @@ export class SegmentationUserLayer extends Base { return true; } + private displaySpatialSkeletonSelection( + state: this["selectionState"], + parent: HTMLElement, + context: DependentViewContext, + ) { + context.registerDisposer( + this.spatialSkeletonNodeDataVersion.changed.add(context.redraw), + ); + context.registerDisposer( + this.selectedSpatialSkeletonNodeInfo.changed.add(context.redraw), + ); + const nodeId = getNodeIdFromLayerSelectionState(state); + if (nodeId === undefined) { + return false; + } + + const selectedSegmentId = getSegmentIdFromLayerSelectionValue(state); + const skeletonLayer = this.getSpatiallyIndexedSkeletonLayer(); + const cachedNodeInfo = this.spatialSkeletonState.getCachedNode(nodeId); + const completeNodeInfo = skeletonLayer?.getNode(nodeId) ?? cachedNodeInfo; + const selectedNodeInfo = this.selectedSpatialSkeletonNodeInfo.value; + const previewNodeInfo = + selectedNodeInfo !== undefined && + selectedNodeInfo.nodeId === nodeId && + selectedNodeInfo.segmentId === selectedSegmentId + ? selectedNodeInfo + : undefined; + const nodeInfo = completeNodeInfo ?? previewNodeInfo; + const container = document.createElement("div"); + container.classList.add("neuroglancer-spatial-skeleton-selection"); + parent.appendChild(container); + + const appendValue = (label: string, value: string | HTMLElement) => { + const row = document.createElement("div"); + row.classList.add("neuroglancer-annotation-property"); + const nameElement = document.createElement("div"); + nameElement.classList.add("neuroglancer-annotation-property-label"); + nameElement.textContent = label; + const valueElement = document.createElement("div"); + valueElement.classList.add("neuroglancer-annotation-property-value"); + if (typeof value === "string") { + valueElement.textContent = value; + } else { + valueElement.appendChild(value); + } + row.appendChild(nameElement); + row.appendChild(valueElement); + container.appendChild(row); + }; + + const appendSegmentAndNodeIds = (segmentId: number, nodeId: number) => { + const segmentChipColors = getSpatialSkeletonSegmentChipColors( + this.displayState, + segmentId, + ); + const segmentIdChip = document.createElement("span"); + segmentIdChip.className = + "neuroglancer-spatial-skeleton-node-segment-chip"; + segmentIdChip.textContent = `${segmentId}`; + segmentIdChip.style.backgroundColor = segmentChipColors.background; + segmentIdChip.style.color = segmentChipColors.foreground; + segmentIdChip.title = + `Segment ${segmentId}\n` + + "Ctrl+right-click to pin selection\n" + + "Ctrl+shift+right-click to unpin"; + bindSpatialSkeletonSegmentSelection( + segmentIdChip, + this.selectSegment, + segmentId, + ); + appendValue("Segment ID", segmentIdChip); + appendValue("Node ID", `${nodeId}`); + }; + + if (completeNodeInfo === undefined) { + const segmentId = nodeInfo?.segmentId ?? selectedSegmentId; + if (segmentId !== undefined) { + appendSegmentAndNodeIds(segmentId, nodeId); + return true; + } + const valueElement = document.createElement("div"); + valueElement.classList.add( + "neuroglancer-selection-details-segment-description", + ); + valueElement.textContent = + "Selected node is not available in the current loaded or cached skeleton data."; + container.appendChild(valueElement); + return true; + } + + const segmentId = nodeInfo.segmentId; + const nodePosition = nodeInfo.position; + const segmentNodes = + this.spatialSkeletonState.getCachedSegmentNodes(segmentId); + const directChildNodeIds = + segmentNodes + ?.filter((candidate) => candidate.parentNodeId === nodeInfo.nodeId) + .map((candidate) => candidate.nodeId) ?? []; + const nodeHasTrueEnd = completeNodeInfo?.isTrueEnd ?? false; + const nodeType = + completeNodeInfo === undefined + ? undefined + : getSpatialSkeletonDisplayNodeType( + completeNodeInfo, + segmentNodes === undefined ? undefined : directChildNodeIds.length, + ); + const nodeTypeLabel = + nodeType === undefined + ? "Unknown" + : getSpatialSkeletonNodeTypeLabel(nodeType, nodeHasTrueEnd); + const iconFilterType = + nodeType === undefined + ? undefined + : getSpatialSkeletonNodeIconFilterType({ + nodeIsTrueEnd: nodeHasTrueEnd, + nodeType, + }); + const summaryRow = document.createElement("div"); + summaryRow.classList.add("neuroglancer-spatial-skeleton-selection-summary"); + container.appendChild(summaryRow); + + const skeletonSource = + skeletonLayer === undefined + ? undefined + : getEditableSpatiallyIndexedSkeletonSource(skeletonLayer); + const rerootDisabledReason = + skeletonSource?.rerootSkeleton === undefined + ? "Unable to resolve a reroot-capable skeleton source for the active layer." + : completeNodeInfo === undefined || segmentNodes === undefined + ? "Load the active skeleton in the Skeleton tab before rerooting from Selection." + : completeNodeInfo.parentNodeId === undefined + ? "Selected node is already root." + : this.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.reroot, + { + requireVisibleChunks: false, + }, + ); + const rerootButton = document.createElement("button"); + rerootButton.type = "button"; + rerootButton.className = "neuroglancer-spatial-skeleton-selection-action"; + rerootButton.disabled = rerootDisabledReason !== undefined; + rerootButton.title = rerootDisabledReason ?? "Set as root"; + rerootButton.appendChild( + makeIcon({ + svg: svg_origin, + title: rerootButton.title, + clickable: false, + }), + ); + let rerootPending = false; + rerootButton.addEventListener("click", () => { + if ( + rerootButton.disabled || + rerootPending || + completeNodeInfo === undefined || + completeNodeInfo.parentNodeId === undefined + ) { + return; + } + rerootPending = true; + rerootButton.disabled = true; + void (async () => { + try { + await this.rerootSpatialSkeletonNode(completeNodeInfo); + } catch (error) { + showSpatialSkeletonActionError("set node as root", error); + } finally { + rerootPending = false; + context.redraw(); + } + })(); + }); + const deleteDisabledReason = + skeletonSource === undefined + ? "Unable to resolve editable skeleton source for the active layer." + : completeNodeInfo === undefined || segmentNodes === undefined + ? "Load the active skeleton in the Skeleton tab before deleting from Selection." + : completeNodeInfo.parentNodeId === undefined && + directChildNodeIds.length > 0 + ? "Reroot the skeleton manually before deleting the current root node." + : this.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.deleteNodes, + ); + const deleteButton = document.createElement("button"); + deleteButton.type = "button"; + deleteButton.className = "neuroglancer-spatial-skeleton-selection-action"; + deleteButton.disabled = deleteDisabledReason !== undefined; + deleteButton.title = deleteDisabledReason ?? "Delete node"; + deleteButton.appendChild( + makeDeleteButton({ title: deleteButton.title, clickable: false }), + ); + let deletePending = false; + deleteButton.addEventListener("click", () => { + if ( + deleteButton.disabled || + skeletonSource === undefined || + completeNodeInfo === undefined || + deletePending + ) { + return; + } + deletePending = true; + void (async () => { + try { + await executeSpatialSkeletonDeleteNode(this, completeNodeInfo); + } catch (error) { + showSpatialSkeletonActionError("delete node", error); + } finally { + deletePending = false; + } + })(); + }); + summaryRow.appendChild(rerootButton); + summaryRow.appendChild(deleteButton); + + const icon = document.createElement("span"); + icon.className = "neuroglancer-spatial-skeleton-selection-summary-icon"; + const nodeTypeIconTitle = + iconFilterType !== undefined + ? getSpatialSkeletonNodeFilterLabel(iconFilterType) + : nodeTypeLabel; + icon.appendChild( + makeIcon({ + svg: + iconFilterType === SpatialSkeletonNodeFilterType.TRUE_END + ? svg_flag + : iconFilterType === SpatialSkeletonNodeFilterType.VIRTUAL_END + ? svg_circle + : nodeType === undefined + ? svg_circle + : SPATIAL_SKELETON_NODE_TYPE_ICONS[nodeType], + title: nodeTypeIconTitle, + clickable: false, + }), + ); + summaryRow.appendChild(icon); + + const skeletonDisplayTransform = + skeletonLayer?.displayState.transform.value; + let displayPosition: ArrayLike = nodePosition; + let displayNames: readonly string[] | undefined; + if ( + skeletonDisplayTransform !== undefined && + skeletonDisplayTransform.error === undefined + ) { + const rank = skeletonDisplayTransform.rank; + const modelPos = new Float32Array(rank); + for (let i = 0; i < Math.min(nodePosition.length, rank); i++) { + modelPos[i] = Number(nodePosition[i]); + } + const layerPos = new Float32Array(rank); + matrix.transformPoint( + layerPos, + skeletonDisplayTransform.modelToRenderLayerTransform, + rank + 1, + modelPos, + rank, + ); + displayPosition = layerPos; + displayNames = skeletonDisplayTransform.layerDimensionNames; + } + const position = formatSpatialSkeletonPosition( + displayPosition, + displayNames, + ); + const summaryCoordinates = document.createElement("span"); + summaryCoordinates.className = + "neuroglancer-spatial-skeleton-selection-summary-coordinates"; + summaryCoordinates.textContent = position.displayText; + summaryCoordinates.title = position.fullText; + summaryRow.appendChild(summaryCoordinates); + + appendSegmentAndNodeIds(segmentId, nodeInfo.nodeId); + const isLeaf = + segmentNodes !== undefined && directChildNodeIds.length === 0; + const leafTypeEditingDisabledReason = () => + skeletonSource === 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)) { + 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 leafTypeOptionElements: HTMLLabelElement[] = []; + const makeLeafTypeOption = (options: { + label: string; + svg: string; + trueEnd: boolean; + }) => { + const option = document.createElement("label"); + option.className = "neuroglancer-spatial-skeleton-leaf-type-option"; + const input = document.createElement("input"); + input.type = "radio"; + input.name = leafTypeRadioName; + input.value = options.trueEnd ? "trueEnd" : "virtualEnd"; + input.className = + "neuroglancer-spatial-skeleton-leaf-type-option-input"; + const icon = document.createElement("span"); + icon.className = "neuroglancer-spatial-skeleton-leaf-type-option-icon"; + icon.appendChild( + makeIcon({ + svg: options.svg, + title: options.label, + clickable: false, + }), + ); + const text = document.createElement("span"); + text.className = "neuroglancer-spatial-skeleton-leaf-type-option-text"; + text.textContent = options.label; + option.appendChild(input); + option.appendChild(icon); + option.appendChild(text); + leafTypeOptionElements.push(option); + leafTypeEditor.appendChild(option); + return input; + }; + const virtualEndInput = makeLeafTypeOption({ + label: "Virtual end", + svg: svg_circle, + trueEnd: false, + }); + const trueEndInput = makeLeafTypeOption({ + label: "True end", + svg: svg_flag, + trueEnd: true, + }); + const updateLeafTypeEditorState = () => { + const disabledReason = leafTypeEditingDisabledReason(); + const editable = disabledReason === undefined && !leafTypeSavePending; + virtualEndInput.checked = !committedTrueEnd; + trueEndInput.checked = committedTrueEnd; + for (const input of [virtualEndInput, trueEndInput]) { + input.disabled = !editable; + if (disabledReason !== undefined) { + input.title = disabledReason; + } else { + input.removeAttribute("title"); + } + } + for (const option of leafTypeOptionElements) { + option.classList.toggle( + "neuroglancer-spatial-skeleton-leaf-type-option-disabled", + !editable, + ); + if (disabledReason !== undefined) { + option.title = disabledReason; + } else { + option.removeAttribute("title"); + } + } + }; + const commitLeafType = (nextTrueEnd: boolean) => { + if (leafTypeSavePending) return; + const disabledReason = leafTypeEditingDisabledReason(); + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + updateLeafTypeEditorState(); + return; + } + if (committedTrueEnd === nextTrueEnd) { + updateLeafTypeEditorState(); + return; + } + const previousTrueEnd = committedTrueEnd; + committedTrueEnd = nextTrueEnd; + leafTypeSavePending = true; + updateLeafTypeEditorState(); + 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 executeSpatialSkeletonNodeTrueEndUpdate(this, { + node: currentNode, + nextIsTrueEnd: nextTrueEnd, + }); + committedTrueEnd = nextTrueEnd; + } catch (error) { + committedTrueEnd = previousTrueEnd; + const message = + error instanceof Error ? error.message : String(error); + StatusMessage.showTemporaryMessage( + `Failed to update leaf type: ${message}`, + ); + } finally { + leafTypeSavePending = false; + updateLeafTypeEditorState(); + } + })(); + }; + virtualEndInput.addEventListener("change", () => { + if (!virtualEndInput.checked) return; + commitLeafType(false); + }); + trueEndInput.addEventListener("change", () => { + if (!trueEndInput.checked) return; + commitLeafType(true); + }); + updateLeafTypeEditorState(); + appendValue("Node type", leafTypeEditor); + } else { + appendValue("Node type", nodeTypeLabel); + } + if (cachedNodeInfo === undefined || segmentNodes === undefined) { + appendValue( + "Radius", + formatSpatialSkeletonEditableNumber(nodeInfo.radius, "Unavailable"), + ); + appendValue( + "Confidence level", + formatSpatialSkeletonEditableNumber(nodeInfo.confidence, "Unavailable"), + ); + } else { + let committedRadius = nodeInfo.radius ?? 0; + let committedConfidence = + nodeInfo.confidence !== undefined && + Number.isFinite(nodeInfo.confidence) + ? Number(nodeInfo.confidence) + : 0; + const radiusInput = document.createElement("input"); + radiusInput.className = "neuroglancer-spatial-skeleton-properties-input"; + radiusInput.type = "number"; + radiusInput.step = "any"; + radiusInput.value = formatSpatialSkeletonEditableNumber(nodeInfo.radius); + appendValue("Radius", radiusInput); + const supportedConfidenceValues = Array.from( + new Set([ + ...SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES, + committedConfidence, + ]), + ).filter((value): value is number => Number.isFinite(value)); + const confidenceSelectValues = Array.from( + new Set([...supportedConfidenceValues, committedConfidence]), + ); + const confidenceControl = document.createElement("select"); + confidenceControl.className = + "neuroglancer-spatial-skeleton-properties-input"; + for (const value of confidenceSelectValues) { + const option = document.createElement("option"); + option.value = value.toString(); + option.textContent = formatSpatialSkeletonEditableNumber(value); + confidenceControl.appendChild(option); + } + 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"); + } + }; + const getConfidenceValidationError = (confidence: number) => { + if (!Number.isFinite(confidence)) { + return "Confidence must be a finite number."; + } + return confidenceSelectValues.includes(confidence) + ? undefined + : "Confidence must use one of the supported values."; + }; + const getParsedProperties = () => { + const radius = Number(radiusInput.value); + 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; + confidenceControl.disabled = + confidenceDisabledReason !== undefined || savePending; + setPropertyInputValidity( + radiusInput, + radiusValid, + "Radius must be a finite number.", + radiusDisabledReason, + ); + setPropertyInputValidity( + confidenceControl, + confidenceValid, + confidenceInvalidTitle ?? "Confidence is invalid.", + confidenceDisabledReason, + ); + }; + const resetPropertyInputs = () => { + radiusInput.value = + formatSpatialSkeletonEditableNumber(committedRadius); + confidenceControl.value = committedConfidence.toString(); + updatePropertyEditorState(); + }; + 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(); + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + resetPropertyInputs(); + return; + } + const { + radius, + confidence, + radiusValid, + confidenceValid, + confidenceInvalidTitle, + } = getParsedProperties(); + if (!radiusValid || !confidenceValid) { + StatusMessage.showTemporaryMessage( + confidenceInvalidTitle ?? "Enter a valid radius and confidence.", + ); + resetPropertyInputs(); + return; + } + const radiusChanged = radius !== committedRadius; + const confidenceChanged = confidence !== committedConfidence; + if (!radiusChanged && !confidenceChanged) { + resetPropertyInputs(); + return; + } + savePending = true; + updatePropertyEditorState(); + 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 }, + }); + committedRadius = radius; + committedConfidence = confidence; + resetPropertyInputs(); + } catch (error) { + showSpatialSkeletonActionError("update node properties", error); + resetPropertyInputs(); + } finally { + savePending = false; + updatePropertyEditorState(); + } + })(); + }; + radiusInput.addEventListener("input", updatePropertyEditorState); + radiusInput.addEventListener("keydown", handlePropertyInputKeyDown); + radiusInput.addEventListener("change", commitProperties); + confidenceControl.addEventListener("change", commitProperties); + updatePropertyEditorState(); + } + const descriptionText = + cachedNodeInfo?.description ?? completeNodeInfo?.description ?? ""; + const descriptionEditingDisabledReason = + skeletonSource === undefined + ? "Unable to resolve editable skeleton source for the active layer." + : cachedNodeInfo === undefined + ? "Load the active skeleton in the Skeleton tab before editing description." + : this.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.editNodeDescription, + ); + if (descriptionEditingDisabledReason === undefined) { + const descriptionElement = document.createElement("textarea"); + descriptionElement.classList.add( + "neuroglancer-spatial-skeleton-selection-description", + ); + descriptionElement.rows = 3; + descriptionElement.placeholder = "Description"; + descriptionElement.value = descriptionText; + descriptionElement.addEventListener("change", () => { + if (skeletonSource === undefined || cachedNodeInfo === undefined) { + return; + } + const nextDescription = descriptionElement.value; + if (descriptionText === nextDescription) { + descriptionElement.value = nextDescription; + return; + } + descriptionElement.disabled = true; + 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 executeSpatialSkeletonNodeDescriptionUpdate(this, { + node: currentNode, + nextDescription, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + descriptionElement.value = descriptionText; + StatusMessage.showTemporaryMessage( + `Failed to update description: ${message}`, + ); + } finally { + descriptionElement.disabled = false; + } + })(); + }); + container.appendChild(descriptionElement); + } else if (descriptionText.length > 0) { + const descriptionElement = document.createElement("div"); + descriptionElement.classList.add( + "neuroglancer-spatial-skeleton-selection-description", + ); + descriptionElement.textContent = descriptionText; + descriptionElement.title = descriptionEditingDisabledReason; + container.appendChild(descriptionElement); + } else if (completeNodeInfo === undefined) { + appendValue("Description", "Unavailable"); + } + return true; + } + displaySelectionState( state: this["selectionState"], parent: HTMLElement, context: DependentViewContext, ): boolean { let displayed = this.displaySegmentationSelection(state, parent, context); + if (this.displaySpatialSkeletonSelection(state, parent, context)) + displayed = true; if (super.displaySelectionState(state, parent, context)) displayed = true; return displayed; } @@ -1412,5 +3550,6 @@ registerLayerShaderControlsTool( json_keys.SKELETON_RENDERING_SHADER_CONTROL_TOOL_ID, ); +registerSpatialSkeletonEditModeTool(SegmentationUserLayer); registerSegmentSplitMergeTools(SegmentationUserLayer); registerSegmentSelectTools(SegmentationUserLayer); diff --git a/src/layer/segmentation/json_keys.ts b/src/layer/segmentation/json_keys.ts index 47d603dab4..4b1a534697 100644 --- a/src/layer/segmentation/json_keys.ts +++ b/src/layer/segmentation/json_keys.ts @@ -1,6 +1,20 @@ export const SELECTED_ALPHA_JSON_KEY = "selectedAlpha"; export const NOT_SELECTED_ALPHA_JSON_KEY = "notSelectedAlpha"; export const OBJECT_ALPHA_JSON_KEY = "objectAlpha"; +export const HIDDEN_OPACITY_3D_JSON_KEY = "hiddenObjectAlpha"; +export const SKELETON_LOD_JSON_KEY = "skeletonLod"; +export const SPATIAL_SKELETON_GRID_LEVEL_2D_JSON_KEY = + "spatialSkeletonGridLevel2d"; +export const SPATIAL_SKELETON_GRID_LEVEL_3D_JSON_KEY = + "spatialSkeletonGridLevel3d"; +export const SPATIAL_SKELETON_GRID_RESOLUTION_TARGET_2D_JSON_KEY = + "spatialSkeletonGridResolutionTarget2d"; +export const SPATIAL_SKELETON_GRID_RESOLUTION_TARGET_3D_JSON_KEY = + "spatialSkeletonGridResolutionTarget3d"; +export const SPATIAL_SKELETON_GRID_RESOLUTION_RELATIVE_2D_JSON_KEY = + "spatialSkeletonGridResolutionRelative2d"; +export const SPATIAL_SKELETON_GRID_RESOLUTION_RELATIVE_3D_JSON_KEY = + "spatialSkeletonGridResolutionRelative3d"; export const SATURATION_JSON_KEY = "saturation"; export const HOVER_HIGHLIGHT_JSON_KEY = "hoverHighlight"; export const HIDE_SEGMENT_ZERO_JSON_KEY = "hideSegmentZero"; @@ -18,6 +32,10 @@ export const SKELETON_RENDERING_JSON_KEY = "skeletonRendering"; export const SKELETON_SHADER_JSON_KEY = "skeletonShader"; export const SKELETON_CODE_VISIBLE_KEY = "codeVisible"; export const SEGMENT_QUERY_JSON_KEY = "segmentQuery"; +export const SPATIAL_SKELETON_NODE_QUERY_JSON_KEY = + "spatialSkeletonNodeQuery"; +export const SPATIAL_SKELETON_NODE_FILTER_JSON_KEY = + "spatialSkeletonNodeFilter"; export const MESH_SILHOUETTE_RENDERING_JSON_KEY = "meshSilhouetteRendering"; export const LINKED_SEGMENTATION_GROUP_JSON_KEY = "linkedSegmentationGroup"; export const LINKED_SEGMENTATION_COLOR_GROUP_JSON_KEY = diff --git a/src/layer/segmentation/layer_controls.ts b/src/layer/segmentation/layer_controls.ts index 8bcc268fa2..0fc723db83 100644 --- a/src/layer/segmentation/layer_controls.ts +++ b/src/layer/segmentation/layer_controls.ts @@ -1,11 +1,15 @@ import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; import * as json_keys from "#src/layer/segmentation/json_keys.js"; +import { makeCachedDerivedWatchableValue } from "#src/trackable_value.js"; import type { LayerControlDefinition } from "#src/widget/layer_control.js"; import { registerLayerControl } from "#src/widget/layer_control.js"; import { checkboxLayerControl } from "#src/widget/layer_control_checkbox.js"; import { enumLayerControl } from "#src/widget/layer_control_enum.js"; import { rangeLayerControl } from "#src/widget/layer_control_range.js"; -import { renderScaleLayerControl } from "#src/widget/render_scale_widget.js"; +import { + renderScaleLayerControl, + spatialSkeletonGridRenderScaleLayerControl, +} from "#src/widget/render_scale_widget.js"; import { colorSeedLayerControl, fixedColorLayerControl, @@ -67,6 +71,54 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ target: layer.displayState.renderScaleTarget, })), }, + { + label: "Resolution (skeleton grid 2D)", + toolJson: json_keys.SPATIAL_SKELETON_GRID_RESOLUTION_TARGET_2D_JSON_KEY, + isValid: (layer) => + makeCachedDerivedWatchableValue( + (levels, hasSpatialSkeletons) => + hasSpatialSkeletons && levels.length > 0, + [ + layer.displayState.spatialSkeletonGridLevels, + layer.hasSpatiallyIndexedSkeletonsLayer, + ], + ), + title: + "Select the grid size level for spatially indexed skeletons in 2D views", + ...spatialSkeletonGridRenderScaleLayerControl((layer) => ({ + histogram: layer.displayState.spatialSkeletonGridRenderScaleHistogram2d, + target: layer.displayState.spatialSkeletonGridResolutionTarget2d, + relative: layer.displayState.spatialSkeletonGridResolutionRelative2d, + pixelSize: layer.displayState.spatialSkeletonGridPixelSize2d, + chunkStats: layer.displayState.spatialSkeletonGridChunkStats2d, + relativeTooltip: + "Interpret the 2D skeleton grid resolution target as relative to zoom", + })), + }, + { + label: "Resolution (skeleton grid 3D)", + toolJson: json_keys.SPATIAL_SKELETON_GRID_RESOLUTION_TARGET_3D_JSON_KEY, + isValid: (layer) => + makeCachedDerivedWatchableValue( + (levels, hasSpatialSkeletons) => + hasSpatialSkeletons && levels.length > 0, + [ + layer.displayState.spatialSkeletonGridLevels, + layer.hasSpatiallyIndexedSkeletonsLayer, + ], + ), + title: + "Select the grid size level for spatially indexed skeletons in 3D views", + ...spatialSkeletonGridRenderScaleLayerControl((layer) => ({ + histogram: layer.displayState.spatialSkeletonGridRenderScaleHistogram3d, + target: layer.displayState.spatialSkeletonGridResolutionTarget3d, + relative: layer.displayState.spatialSkeletonGridResolutionRelative3d, + pixelSize: layer.displayState.spatialSkeletonGridPixelSize3d, + chunkStats: layer.displayState.spatialSkeletonGridChunkStats3d, + relativeTooltip: + "Interpret the 3D skeleton grid resolution target as relative to zoom", + })), + }, { label: "Opacity (3d)", toolJson: json_keys.OBJECT_ALPHA_JSON_KEY, @@ -76,6 +128,15 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ value: layer.displayState.objectAlpha, })), }, + { + label: "Hidden Opacity (3d)", + toolJson: json_keys.HIDDEN_OPACITY_3D_JSON_KEY, + isValid: (layer) => layer.hasSpatiallyIndexedSkeletonsLayer, + title: "Opacity of hidden (non-visible) skeleton nodes in 3D views", + ...rangeLayerControl((layer) => ({ + value: layer.displayState.hiddenObjectAlpha, + })), + }, { label: "Silhouette (3d)", toolJson: json_keys.MESH_SILHOUETTE_RENDERING_JSON_KEY, diff --git a/src/layer/segmentation/selection.spec.ts b/src/layer/segmentation/selection.spec.ts new file mode 100644 index 0000000000..34f7228aa9 --- /dev/null +++ b/src/layer/segmentation/selection.spec.ts @@ -0,0 +1,298 @@ +/** + * @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 { describe, expect, it } from "vitest"; + +import type { LayerSelectedValues } from "#src/layer/index.js"; +import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; +import { + getNodeIdFromLayerSelectionState, + getNodeIdFromViewerSelection, + getSegmentIdFromLayerSelectionValue, + getSpatialSkeletonMissingSelectionDisplayState, + getSpatialSkeletonSelectionRecoveryKey, + hasSpatialSkeletonNodeSelection, + SpatialSkeletonHoverState, + SpatialSkeletonSelectionRecoveryStatus, +} from "#src/layer/segmentation/selection.js"; + +describe("layer/segmentation/selection", () => { + it("recognizes field-based spatial skeleton node selections", () => { + expect( + hasSpatialSkeletonNodeSelection({ + nodeId: "17", + value: 9n, + }), + ).toBe(true); + expect( + hasSpatialSkeletonNodeSelection({ + nodeId: "18446744073709551615", + }), + ).toBe(true); + expect( + hasSpatialSkeletonNodeSelection({ + nodeId: 17, + }), + ).toBe(false); + expect( + hasSpatialSkeletonNodeSelection({ + nodeId: 0, + }), + ).toBe(false); + expect(hasSpatialSkeletonNodeSelection({})).toBe(false); + }); + + it("extracts node and segment ids from a layer selection state", () => { + expect( + getNodeIdFromLayerSelectionState({ + nodeId: "23", + value: 7n, + }), + ).toBe(23); + expect( + getSegmentIdFromLayerSelectionValue({ + nodeId: "23", + value: "7", + }), + ).toBe(7); + expect( + getNodeIdFromLayerSelectionState({ + nodeId: -1, + }), + ).toBeUndefined(); + expect( + getSegmentIdFromLayerSelectionValue({ + value: "9", + }), + ).toBe(9); + expect( + getSpatialSkeletonSelectionRecoveryKey({ + nodeId: "23", + value: 7n, + }), + ).toBe("23:7"); + expect( + getNodeIdFromLayerSelectionState({ + nodeId: "18446744073709551615", + }), + ).toBeUndefined(); + expect( + getSpatialSkeletonSelectionRecoveryKey({ + nodeId: 23, + }), + ).toBeUndefined(); + }); + + it("extracts the selected node id for the matching layer", () => { + const layerA = {}; + const layerB = {}; + expect( + getNodeIdFromViewerSelection( + { + layers: [ + { + layer: layerA, + state: {}, + }, + { + layer: layerB, + state: { + nodeId: "31", + value: 8n, + }, + }, + ], + }, + layerB, + ), + ).toBe(31); + expect( + getNodeIdFromViewerSelection( + { + layers: [ + { + layer: layerA, + state: { + nodeId: 4, + }, + }, + ], + }, + layerB, + ), + ).toBeUndefined(); + }); + + it("extracts the hovered node id only for matching render layers", () => { + // Create mock state, layers, and signal handlers + const renderLayerA = {}; + const renderLayerB = {}; + const layer = { renderLayers: [renderLayerA] }; + let mouseState: { + active: boolean; + pickedRenderLayer: unknown; + pickedSpatialSkeleton?: { nodeId?: unknown }; + } = { + active: false, + pickedRenderLayer: null, + pickedSpatialSkeleton: undefined, + }; + const handlers: Array<() => void> = []; + const layerSelectedValues = { + changed: { + add: (cb: () => void) => { + handlers.push(cb); + return () => true as boolean; + }, + }, + get mouseState() { + return mouseState; + }, + }; + const hoverState = new SpatialSkeletonHoverState(); + hoverState.bindTo( + layerSelectedValues as LayerSelectedValues, + layer as SegmentationUserLayer, + ); + const trigger = () => handlers.forEach((h) => h()); + + mouseState = { + active: true, + pickedRenderLayer: renderLayerA, + pickedSpatialSkeleton: { nodeId: 31 }, + }; + trigger(); + expect(hoverState.value).toBe(31); + + mouseState = { + active: true, + pickedRenderLayer: renderLayerB, + pickedSpatialSkeleton: { nodeId: 31 }, + }; + trigger(); + expect(hoverState.value).toBeUndefined(); + + mouseState = { + active: false, + pickedRenderLayer: renderLayerA, + pickedSpatialSkeleton: { nodeId: 31 }, + }; + trigger(); + expect(hoverState.value).toBeUndefined(); + + mouseState = { + active: true, + pickedRenderLayer: renderLayerA, + pickedSpatialSkeleton: { nodeId: -1 }, + }; + trigger(); + expect(hoverState.value).toBeUndefined(); + + hoverState.dispose(); + }); + + it("requests selection recovery only when a full-segment fetch can help", () => { + expect( + getSpatialSkeletonMissingSelectionDisplayState( + { + nodeId: "31", + value: 8n, + }, + { + hasInspectableSource: true, + hasCachedSegment: false, + recoveryStatus: undefined, + }, + ), + ).toEqual({ + recoveryKey: "31:8", + recoveryStatus: undefined, + shouldRequestRecovery: true, + loading: true, + }); + expect( + getSpatialSkeletonMissingSelectionDisplayState( + { + nodeId: "31", + value: 8n, + }, + { + hasInspectableSource: true, + hasCachedSegment: false, + recoveryStatus: SpatialSkeletonSelectionRecoveryStatus.PENDING, + }, + ), + ).toEqual({ + recoveryKey: "31:8", + recoveryStatus: SpatialSkeletonSelectionRecoveryStatus.PENDING, + shouldRequestRecovery: false, + loading: true, + }); + expect( + getSpatialSkeletonMissingSelectionDisplayState( + { + nodeId: "31", + value: 8n, + }, + { + hasInspectableSource: true, + hasCachedSegment: true, + recoveryStatus: undefined, + }, + ), + ).toEqual({ + recoveryKey: "31:8", + recoveryStatus: undefined, + shouldRequestRecovery: false, + loading: false, + }); + expect( + getSpatialSkeletonMissingSelectionDisplayState( + { + nodeId: "31", + value: 8n, + }, + { + hasInspectableSource: true, + hasCachedSegment: false, + recoveryStatus: SpatialSkeletonSelectionRecoveryStatus.FAILED, + }, + ), + ).toEqual({ + recoveryKey: "31:8", + recoveryStatus: SpatialSkeletonSelectionRecoveryStatus.FAILED, + shouldRequestRecovery: false, + loading: false, + }); + expect( + getSpatialSkeletonMissingSelectionDisplayState( + { + nodeId: 31, + }, + { + hasInspectableSource: true, + hasCachedSegment: false, + recoveryStatus: undefined, + }, + ), + ).toEqual({ + recoveryKey: undefined, + recoveryStatus: undefined, + shouldRequestRecovery: false, + loading: false, + }); + }); +}); diff --git a/src/layer/segmentation/selection.ts b/src/layer/segmentation/selection.ts new file mode 100644 index 0000000000..2d15b10751 --- /dev/null +++ b/src/layer/segmentation/selection.ts @@ -0,0 +1,224 @@ +/** + * @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 { LayerSelectedValues } from "#src/layer/index.js"; +import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; +import { RefCounted } from "#src/util/disposable.js"; +import { parseUint64 } from "#src/util/json.js"; +import { NullarySignal } from "#src/util/signal.js"; + +interface SpatialSkeletonViewerHoverMouseStateLike { + active: boolean; + pickedRenderLayer: TRenderLayer | null | undefined; + pickedSpatialSkeleton?: + | { + nodeId?: unknown; + } + | undefined; +} + +interface SpatialSkeletonViewerHoverLayerLike { + renderLayers: readonly TRenderLayer[]; +} + +export enum SpatialSkeletonSelectionRecoveryStatus { + PENDING = "pending", + FAILED = "failed", +} + +const MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); + +function parseSelectionStateStringId(value: unknown) { + if (typeof value !== "string") { + return undefined; + } + try { + const parsedValue = parseUint64(value); + return parsedValue > 0n ? parsedValue : undefined; + } catch { + return undefined; + } +} + +function normalizeSelectionStateStringId(value: unknown) { + const parsedValue = parseSelectionStateStringId(value); + if (parsedValue === undefined || parsedValue > MAX_SAFE_INTEGER_BIGINT) { + return undefined; + } + return Number(parsedValue); +} + +function getSelectionIdString(value: unknown) { + return parseSelectionStateStringId(value)?.toString(); +} + +function normalizeSelectionStateValueId(value: unknown) { + try { + const parsedValue = parseUint64(value); + if (parsedValue <= 0n || parsedValue > MAX_SAFE_INTEGER_BIGINT) { + return undefined; + } + return Number(parsedValue); + } catch { + return undefined; + } +} + +function getSelectionValueIdString(value: unknown) { + try { + const parsedValue = parseUint64(value); + return parsedValue > 0n && parsedValue <= MAX_SAFE_INTEGER_BIGINT + ? parsedValue.toString() + : undefined; + } catch { + return undefined; + } +} + +function normalizeSpatialSkeletonViewerHoverNodeId(value: unknown) { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 + ? value + : undefined; +} + +export function getNodeIdFromLayerSelectionState( + state: { nodeId?: unknown; value?: unknown } | undefined, +) { + return normalizeSelectionStateStringId(state?.nodeId); +} + +export function getSegmentIdFromLayerSelectionValue( + state: { nodeId?: unknown; value?: unknown } | undefined, +) { + return normalizeSelectionStateValueId(state?.value); +} + +export function getSpatialSkeletonSelectionRecoveryKey( + state: { nodeId?: unknown; value?: unknown } | undefined, +) { + const nodeId = getSelectionIdString(state?.nodeId); + const segmentId = getSelectionValueIdString(state?.value); + if (nodeId === undefined || segmentId === undefined) { + return undefined; + } + return `${nodeId}:${segmentId}`; +} + +export function getSpatialSkeletonMissingSelectionDisplayState( + state: { nodeId?: unknown; value?: unknown } | undefined, + options: { + hasInspectableSource: boolean; + hasCachedSegment: boolean; + recoveryStatus: SpatialSkeletonSelectionRecoveryStatus | undefined; + }, +) { + const recoveryKey = getSpatialSkeletonSelectionRecoveryKey(state); + if (recoveryKey === undefined) { + return { + recoveryKey, + recoveryStatus: undefined, + shouldRequestRecovery: false, + loading: false, + }; + } + const { hasInspectableSource, hasCachedSegment, recoveryStatus } = options; + if (recoveryStatus === SpatialSkeletonSelectionRecoveryStatus.PENDING) { + return { + recoveryKey, + recoveryStatus, + shouldRequestRecovery: false, + loading: true, + }; + } + const shouldRequestRecovery = + !hasCachedSegment && hasInspectableSource && recoveryStatus === undefined; + return { + recoveryKey, + recoveryStatus, + shouldRequestRecovery, + loading: shouldRequestRecovery, + }; +} + +export function hasSpatialSkeletonNodeSelection( + state: { nodeId?: unknown; value?: unknown } | undefined, +) { + return getSelectionIdString(state?.nodeId) !== undefined; +} + +export function getNodeIdFromViewerSelection( + selection: + | { + layers: readonly { + layer: TLayer; + state: { nodeId?: unknown; value?: unknown }; + }[]; + } + | undefined, + layer: TLayer, +) { + return getNodeIdFromLayerSelectionState( + selection?.layers.find((entry) => entry.layer === layer)?.state, + ); +} + +function getSpatialSkeletonNodeIdFromViewerHover( + mouseState: SpatialSkeletonViewerHoverMouseStateLike, + layer: SpatialSkeletonViewerHoverLayerLike, +) { + if (!mouseState.active) return undefined; + const pickedRenderLayer = mouseState.pickedRenderLayer; + if (pickedRenderLayer !== null) { + if ( + pickedRenderLayer === undefined || + !layer.renderLayers.includes(pickedRenderLayer) + ) { + return undefined; + } + } + // TODO (SKM): I think we can inline this function + return normalizeSpatialSkeletonViewerHoverNodeId( + mouseState.pickedSpatialSkeleton?.nodeId, + ); +} + +export class SpatialSkeletonHoverState extends RefCounted { + value: number | undefined = undefined; + readonly changed = new NullarySignal(); + + setValue(value: number | undefined) { + if (this.value !== value) { + this.value = value; + this.changed.dispatch(); + } + } + + bindTo( + layerSelectedValues: LayerSelectedValues, + layer: SegmentationUserLayer, + ) { + this.registerDisposer( + layerSelectedValues.changed.add(() => { + this.setValue( + getSpatialSkeletonNodeIdFromViewerHover( + layerSelectedValues.mouseState, + layer, + ), + ); + }), + ); + } +} diff --git a/src/layer/segmentation/spatial_skeleton_commands.spec.ts b/src/layer/segmentation/spatial_skeleton_commands.spec.ts new file mode 100644 index 0000000000..97e55c4811 --- /dev/null +++ b/src/layer/segmentation/spatial_skeleton_commands.spec.ts @@ -0,0 +1,1668 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + executeSpatialSkeletonAddNode, + executeSpatialSkeletonDeleteNode, + executeSpatialSkeletonMerge, + executeSpatialSkeletonMoveNode, + executeSpatialSkeletonSplit, + 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 { SpatialSkeletonCommandHistory } from "#src/skeleton/command_history.js"; +import { SpatialSkeletonState } from "#src/skeleton/spatial_skeleton_manager.js"; +import { StatusMessage } from "#src/status.js"; + +function cloneNode( + node: SpatiallyIndexedSkeletonNode, +): SpatiallyIndexedSkeletonNode { + return { + ...node, + position: new Float32Array(node.position), + description: node.description, + isTrueEnd: node.isTrueEnd, + }; +} + +function cloneNodes( + nodes: readonly SpatiallyIndexedSkeletonNode[] | undefined, +): SpatiallyIndexedSkeletonNode[] { + return (nodes ?? []).map((node) => cloneNode(node)); +} + +function setSegmentNodes( + cacheBySegment: Map, + cacheByNode: Map, + segmentId: number, + nodes: readonly SpatiallyIndexedSkeletonNode[], +) { + if (nodes.length === 0) { + cacheBySegment.delete(segmentId); + } else { + cacheBySegment.set(segmentId, cloneNodes(nodes)); + } + cacheByNode.clear(); + for (const segmentNodes of cacheBySegment.values()) { + for (const node of segmentNodes) { + cacheByNode.set(node.nodeId, node); + } + } +} + +function makeEditableSkeletonSource(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(), + updateRadius: vi.fn(), + updateConfidence: vi.fn(), + mergeSkeletons: vi.fn(), + splitSkeleton: vi.fn(), + ...overrides, + }; +} + +function suppressStatusMessages() { + const fakeStatusMessage = { + dispose() {}, + } as unknown as StatusMessage; + vi.spyOn(StatusMessage, "showTemporaryMessage").mockImplementation( + (_message: string, _closeAfter?: number) => fakeStatusMessage, + ); + vi.spyOn(StatusMessage, "showMessage").mockImplementation( + (_message: string) => fakeStatusMessage, + ); +} + +describe("spatial_skeleton_commands", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("commits move-node commands using model-space positions", async () => { + suppressStatusMessages(); + + const node: SpatiallyIndexedSkeletonNode = { + nodeId: 17, + segmentId: 23, + position: new Float32Array([1, 2, 3]), + isTrueEnd: false, + revisionToken: "before", + }; + const nextPositionInModelSpace = new Float32Array([7, 8, 9]); + const moveNode = vi.fn().mockResolvedValue({ + revisionToken: "after", + }); + const skeletonLayer = { + source: makeEditableSkeletonSource({ moveNode }), + getNode: vi.fn((nodeId: number) => + nodeId === node.nodeId ? node : undefined, + ), + retainOverlaySegment: vi.fn(), + invalidateSourceCaches: vi.fn(), + }; + const commandHistory = new SpatialSkeletonCommandHistory(); + const moveCachedNode = vi.fn(); + const setCachedNodeRevision = vi.fn(); + const markSpatialSkeletonNodeDataChanged = vi.fn(); + const layer = { + spatialSkeletonState: { + commandHistory, + getCachedNode: vi.fn((nodeId: number) => + nodeId === node.nodeId ? node : undefined, + ), + getCachedSegmentNodes: vi.fn((segmentId: number) => + segmentId === node.segmentId ? [node] : undefined, + ), + moveCachedNode, + setCachedNodeRevision, + }, + getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, + markSpatialSkeletonNodeDataChanged, + }; + + await executeSpatialSkeletonMoveNode(layer as any, { + node, + nextPositionInModelSpace, + }); + + expect(moveNode).toHaveBeenCalledWith(17, 7, 8, 9, { + node: { + nodeId: 17, + parentNodeId: undefined, + revisionToken: "before", + }, + }); + expect(skeletonLayer.retainOverlaySegment).toHaveBeenCalledWith(23); + expect(moveCachedNode).toHaveBeenCalledWith( + 17, + new Float32Array([7, 8, 9]), + ); + expect(setCachedNodeRevision).toHaveBeenCalledWith(17, "after"); + expect(markSpatialSkeletonNodeDataChanged).toHaveBeenCalledWith({ + invalidateFullSkeletonCache: false, + }); + expect(skeletonLayer.invalidateSourceCaches).not.toHaveBeenCalled(); + }); + + it("moves to the parent node when undoing an add-node command", async () => { + suppressStatusMessages(); + + const segmentId = 23; + const parentNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + revisionToken: "parent-before-add", + }; + const addNode = vi.fn().mockResolvedValue({ + treenodeId: 2, + skeletonId: segmentId, + revisionToken: "added-after-add", + parentRevisionToken: "parent-after-add", + }); + const deleteNode = vi.fn().mockResolvedValue({ + nodeRevisionUpdates: [ + { + nodeId: parentNode.nodeId, + revisionToken: "parent-after-undo", + }, + ], + }); + const skeletonSource = makeEditableSkeletonSource({ + addNode, + deleteNode, + }); + const spatialSkeletonState = new SpatialSkeletonState(); + spatialSkeletonState.upsertCachedNode(parentNode, { + allowUncachedSegment: true, + }); + const skeletonLayer = { + source: skeletonSource, + getNode: vi.fn((nodeId: number) => + spatialSkeletonState.getCachedNode(nodeId), + ), + retainOverlaySegment: vi.fn(), + invalidateSourceCaches: vi.fn(), + }; + const layer = { + displayState: { + segmentationGroupState: { + value: { + visibleSegments: new Set([BigInt(segmentId)]), + selectedSegments: new Set(), + segmentEquivalences: {}, + temporaryVisibleSegments: new Set(), + temporarySegmentEquivalences: {}, + useTemporaryVisibleSegments: { value: false }, + useTemporarySegmentEquivalences: { value: false }, + }, + }, + }, + manager: { + root: { + selectionState: { + pin: { + value: true, + }, + }, + }, + }, + spatialSkeletonState, + getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, + async getSpatialSkeletonDeleteOperationContext( + node: SpatiallyIndexedSkeletonNode, + ) { + const segmentNodes = + spatialSkeletonState.getCachedSegmentNodes(node.segmentId) ?? []; + const currentNode = findSpatiallyIndexedSkeletonNode( + segmentNodes, + node.nodeId, + ); + if (currentNode === undefined) { + throw new Error(`Unable to resolve cached node ${node.nodeId}.`); + } + const childNodes = getSpatiallyIndexedSkeletonDirectChildren( + segmentNodes, + currentNode.nodeId, + ); + return { + node: currentNode, + parentNode: getSpatiallyIndexedSkeletonNodeParent( + segmentNodes, + currentNode, + ), + childNodes, + editContext: buildSpatiallyIndexedSkeletonNeighborhoodEditContext( + currentNode, + segmentNodes, + ), + }; + }, + selectSegment: vi.fn(), + selectAndMoveToSpatialSkeletonNode: vi.fn(), + selectSpatialSkeletonNode: vi.fn(), + clearSpatialSkeletonNodeSelection: vi.fn(), + moveViewToSpatialSkeletonNodePosition: vi.fn(), + markSpatialSkeletonNodeDataChanged: vi.fn(), + }; + + await executeSpatialSkeletonAddNode(layer as any, { + skeletonId: segmentId, + parentNodeId: parentNode.nodeId, + positionInModelSpace: new Float32Array([7, 8, 9]), + }); + + layer.selectAndMoveToSpatialSkeletonNode.mockClear(); + layer.selectSpatialSkeletonNode.mockClear(); + layer.moveViewToSpatialSkeletonNodePosition.mockClear(); + + await undoSpatialSkeletonCommand(layer as any); + + 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: [], + }, + }); + expect(spatialSkeletonState.getCachedNode(2)).toBeUndefined(); + expect(layer.selectAndMoveToSpatialSkeletonNode).toHaveBeenCalledWith( + { + ...parentNode, + revisionToken: "parent-after-add", + }, + true, + ); + expect(layer.selectSpatialSkeletonNode).not.toHaveBeenCalled(); + expect(layer.moveViewToSpatialSkeletonNodePosition).not.toHaveBeenCalled(); + }); + + it("restores internal-node delete undo as an insertion in the local cache", async () => { + suppressStatusMessages(); + + const segmentId = 23; + const rootNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([1, 2, 3]), + isTrueEnd: false, + revisionToken: "root-before-delete", + }; + const deletedNode: SpatiallyIndexedSkeletonNode = { + nodeId: 2, + segmentId, + parentNodeId: rootNode.nodeId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + revisionToken: "deleted-before-delete", + }; + const firstChildNode: SpatiallyIndexedSkeletonNode = { + nodeId: 3, + segmentId, + parentNodeId: deletedNode.nodeId, + position: new Float32Array([7, 8, 9]), + isTrueEnd: false, + revisionToken: "first-child-before-delete", + }; + const secondChildNode: SpatiallyIndexedSkeletonNode = { + nodeId: 4, + segmentId, + parentNodeId: deletedNode.nodeId, + position: new Float32Array([10, 11, 12]), + isTrueEnd: false, + revisionToken: "second-child-before-delete", + }; + + const deleteNode = vi.fn().mockResolvedValue({ + nodeRevisionUpdates: [ + { + nodeId: rootNode.nodeId, + revisionToken: "root-after-delete", + }, + { + nodeId: firstChildNode.nodeId, + revisionToken: "first-child-after-delete", + }, + { + nodeId: secondChildNode.nodeId, + revisionToken: "second-child-after-delete", + }, + ], + }); + const insertNode = vi.fn().mockResolvedValue({ + treenodeId: 20, + skeletonId: segmentId, + revisionToken: "restored-after-undo", + parentRevisionToken: "root-after-undo", + nodeRevisionUpdates: [ + { + nodeId: firstChildNode.nodeId, + revisionToken: "first-child-after-undo", + }, + { + nodeId: secondChildNode.nodeId, + revisionToken: "second-child-after-undo", + }, + ], + }); + const skeletonSource = makeEditableSkeletonSource({ + deleteNode, + insertNode, + }); + const skeletonLayer = { + source: skeletonSource, + getNode: vi.fn(), + invalidateSourceCaches: vi.fn(), + retainOverlaySegment: vi.fn(), + }; + const spatialSkeletonState = new SpatialSkeletonState(); + spatialSkeletonState.upsertCachedNode(rootNode, { + allowUncachedSegment: true, + }); + spatialSkeletonState.upsertCachedNode(deletedNode); + spatialSkeletonState.upsertCachedNode(firstChildNode); + spatialSkeletonState.upsertCachedNode(secondChildNode); + skeletonLayer.getNode.mockImplementation((nodeId: number) => + spatialSkeletonState.getCachedNode(nodeId), + ); + + const layer = { + displayState: { + segmentationGroupState: { + value: { + visibleSegments: new Set([BigInt(segmentId)]), + selectedSegments: new Set(), + segmentEquivalences: {}, + temporaryVisibleSegments: new Set(), + temporarySegmentEquivalences: {}, + useTemporaryVisibleSegments: { value: false }, + useTemporarySegmentEquivalences: { value: false }, + }, + }, + }, + manager: { + root: { + selectionState: { + pin: { + value: true, + }, + }, + }, + }, + spatialSkeletonState, + getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, + getCachedSpatialSkeletonSegmentNodesForEdit: (requestedSegmentId: number) => + spatialSkeletonState.getCachedSegmentNodes(requestedSegmentId) ?? [], + async getSpatialSkeletonDeleteOperationContext( + node: SpatiallyIndexedSkeletonNode, + ) { + const segmentNodes = + spatialSkeletonState.getCachedSegmentNodes(node.segmentId) ?? []; + const currentNode = findSpatiallyIndexedSkeletonNode( + segmentNodes, + node.nodeId, + ); + if (currentNode === undefined) { + throw new Error(`Unable to resolve cached node ${node.nodeId}.`); + } + const childNodes = getSpatiallyIndexedSkeletonDirectChildren( + segmentNodes, + currentNode.nodeId, + ); + return { + node: currentNode, + parentNode: getSpatiallyIndexedSkeletonNodeParent( + segmentNodes, + currentNode, + ), + childNodes, + editContext: buildSpatiallyIndexedSkeletonNeighborhoodEditContext( + currentNode, + segmentNodes, + ), + }; + }, + selectAndMoveToSpatialSkeletonNode: vi.fn(), + selectSpatialSkeletonNode: vi.fn(), + clearSpatialSkeletonNodeSelection: vi.fn(), + markSpatialSkeletonNodeDataChanged: vi.fn(), + }; + + await executeSpatialSkeletonDeleteNode(layer as any, deletedNode); + + 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(insertNode).toHaveBeenCalledWith( + segmentId, + 4, + 5, + 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", + }, + ], + }, + ); + + const restoredNode = spatialSkeletonState.getCachedNode(20); + expect(restoredNode).toMatchObject({ + nodeId: 20, + parentNodeId: rootNode.nodeId, + segmentId, + }); + expect(spatialSkeletonState.getCachedNode(firstChildNode.nodeId)?.parentNodeId).toBe( + restoredNode?.nodeId, + ); + expect( + spatialSkeletonState.getCachedNode(secondChildNode.nodeId)?.parentNodeId, + ).toBe(restoredNode?.nodeId); + const restoredEditContext = buildSpatiallyIndexedSkeletonNeighborhoodEditContext( + restoredNode!, + spatialSkeletonState.getCachedSegmentNodes(segmentId)!, + ); + expect(restoredEditContext.children?.map((child) => child.nodeId)).toEqual([ + firstChildNode.nodeId, + secondChildNode.nodeId, + ]); + }); + + it("suppresses and clears the deleted segment when undoing a split", async () => { + suppressStatusMessages(); + + const originalSegmentId = 2973964; + const splitSegmentId = 2973946; + const formerParentNode: SpatiallyIndexedSkeletonNode = { + nodeId: 21893039, + segmentId: originalSegmentId, + position: new Float32Array([10, 20, 30]), + isTrueEnd: false, + revisionToken: "parent-before", + }; + const splitNodeBefore: SpatiallyIndexedSkeletonNode = { + nodeId: 21893038, + segmentId: originalSegmentId, + parentNodeId: formerParentNode.nodeId, + position: new Float32Array([11, 21, 31]), + isTrueEnd: false, + revisionToken: "split-before", + }; + const splitNodeAfter: SpatiallyIndexedSkeletonNode = { + ...splitNodeBefore, + segmentId: splitSegmentId, + parentNodeId: undefined, + revisionToken: "split-after", + }; + const splitNodeMergedBack: SpatiallyIndexedSkeletonNode = { + ...splitNodeBefore, + revisionToken: "split-merged-back", + }; + + const serverSegments = new Map(); + const cacheBySegment = new Map(); + const cacheByNode = new Map(); + + const syncCacheFromServer = (segmentId: number) => { + setSegmentNodes( + cacheBySegment, + cacheByNode, + segmentId, + serverSegments.get(segmentId) ?? [], + ); + return cacheBySegment.get(segmentId) ?? []; + }; + + serverSegments.set(originalSegmentId, [ + cloneNode(formerParentNode), + cloneNode(splitNodeBefore), + ]); + syncCacheFromServer(originalSegmentId); + + 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, + }; + }), + }); + + const deleteSegmentColor = vi.fn(); + const invalidateCachedSegments = vi.fn((segmentIds: Iterable) => { + for (const segmentId of segmentIds) { + setSegmentNodes(cacheBySegment, cacheByNode, segmentId, []); + } + }); + const getFullSegmentNodes = vi.fn( + async (_skeletonLayer: unknown, segmentId: number) => + syncCacheFromServer(segmentId), + ); + const skeletonLayer = { + source: skeletonSource, + getNode: vi.fn((nodeId: number) => cacheByNode.get(nodeId)), + invalidateSourceCaches: vi.fn(), + suppressBrowseSegment: vi.fn(), + }; + const layer = { + displayState: { + segmentationGroupState: { + value: { + visibleSegments: new Set([BigInt(originalSegmentId)]), + selectedSegments: new Set(), + segmentEquivalences: {}, + temporaryVisibleSegments: new Set(), + temporarySegmentEquivalences: {}, + useTemporaryVisibleSegments: { value: false }, + useTemporarySegmentEquivalences: { value: false }, + }, + }, + segmentStatedColors: { + value: { + delete: deleteSegmentColor, + }, + }, + }, + manager: { + root: { + selectionState: { + pin: { + value: true, + }, + }, + }, + }, + spatialSkeletonState: { + commandHistory: new SpatialSkeletonCommandHistory(), + getCachedNode: (nodeId: number) => cacheByNode.get(nodeId), + getCachedSegmentNodes: (segmentId: number) => + cacheBySegment.get(segmentId), + getFullSegmentNodes, + invalidateCachedSegments, + }, + getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, + getCachedSpatialSkeletonSegmentNodesForEdit: (segmentId: number) => + cacheBySegment.get(segmentId) ?? [], + selectSegment: vi.fn(), + selectSpatialSkeletonNode: vi.fn(), + markSpatialSkeletonNodeDataChanged: vi.fn(), + }; + + await executeSpatialSkeletonSplit(layer as any, { + nodeId: splitNodeBefore.nodeId, + segmentId: originalSegmentId, + }); + + skeletonLayer.suppressBrowseSegment.mockClear(); + deleteSegmentColor.mockClear(); + layer.selectSpatialSkeletonNode.mockClear(); + layer.markSpatialSkeletonNodeDataChanged.mockClear(); + skeletonLayer.invalidateSourceCaches.mockClear(); + invalidateCachedSegments.mockClear(); + getFullSegmentNodes.mockClear(); + + await undoSpatialSkeletonCommand(layer as any); + + expect(skeletonSource.mergeSkeletons).toHaveBeenCalledWith( + formerParentNode.nodeId, + splitNodeBefore.nodeId, + expect.any(Object), + ); + expect(deleteSegmentColor).toHaveBeenCalledWith(BigInt(splitSegmentId)); + expect(skeletonLayer.suppressBrowseSegment).toHaveBeenCalledWith( + splitSegmentId, + ); + expect(layer.selectSpatialSkeletonNode).toHaveBeenCalledWith( + splitNodeBefore.nodeId, + true, + { segmentId: originalSegmentId }, + ); + expect(invalidateCachedSegments).toHaveBeenCalledWith([ + originalSegmentId, + splitSegmentId, + ]); + expect(getFullSegmentNodes).toHaveBeenCalledTimes(2); + expect( + layer.displayState.segmentationGroupState.value.visibleSegments.has( + BigInt(originalSegmentId), + ), + ).toBe(true); + expect( + layer.displayState.segmentationGroupState.value.visibleSegments.has( + BigInt(splitSegmentId), + ), + ).toBe(false); + expect(cacheBySegment.get(splitSegmentId)).toBeUndefined(); + expect( + cacheBySegment.get(originalSegmentId)?.map((node) => node.nodeId), + ).toEqual([formerParentNode.nodeId, splitNodeBefore.nodeId]); + }); + + it("uses the original skeleton side as the join winner when undoing a split", async () => { + suppressStatusMessages(); + + const originalSegmentId = 2973964; + const splitSegmentId = 2973946; + const originalRootNode: SpatiallyIndexedSkeletonNode = { + nodeId: 21893001, + segmentId: originalSegmentId, + position: new Float32Array([1, 2, 3]), + isTrueEnd: false, + revisionToken: "root-before", + }; + const formerParentNode: SpatiallyIndexedSkeletonNode = { + nodeId: 21893039, + segmentId: originalSegmentId, + parentNodeId: originalRootNode.nodeId, + position: new Float32Array([10, 20, 30]), + isTrueEnd: false, + revisionToken: "parent-before", + }; + const splitNodeBefore: SpatiallyIndexedSkeletonNode = { + nodeId: 21893038, + segmentId: originalSegmentId, + parentNodeId: formerParentNode.nodeId, + position: new Float32Array([11, 21, 31]), + isTrueEnd: false, + revisionToken: "split-before", + }; + const splitNodeAfter: SpatiallyIndexedSkeletonNode = { + ...splitNodeBefore, + segmentId: splitSegmentId, + parentNodeId: undefined, + revisionToken: "split-after", + }; + const restoredNodes: SpatiallyIndexedSkeletonNode[] = [ + { + ...originalRootNode, + parentNodeId: undefined, + revisionToken: "root-rerooted", + }, + { + ...formerParentNode, + parentNodeId: originalRootNode.nodeId, + revisionToken: "parent-rerooted", + }, + { + ...splitNodeBefore, + segmentId: originalSegmentId, + parentNodeId: formerParentNode.nodeId, + revisionToken: "split-rerooted", + }, + ]; + + const serverSegments = new Map(); + const cacheBySegment = new Map(); + const cacheByNode = new Map(); + + const syncCacheFromServer = (segmentId: number) => { + setSegmentNodes( + cacheBySegment, + cacheByNode, + segmentId, + serverSegments.get(segmentId) ?? [], + ); + return cacheBySegment.get(segmentId) ?? []; + }; + + serverSegments.set(originalSegmentId, [ + cloneNode(originalRootNode), + cloneNode(formerParentNode), + cloneNode(splitNodeBefore), + ]); + syncCacheFromServer(originalSegmentId); + + 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, + }; + }), + }); + + const invalidateCachedSegments = vi.fn((segmentIds: Iterable) => { + for (const segmentId of segmentIds) { + setSegmentNodes(cacheBySegment, cacheByNode, segmentId, []); + } + }); + const getFullSegmentNodes = vi.fn( + async (_skeletonLayer: unknown, segmentId: number) => + syncCacheFromServer(segmentId), + ); + const skeletonLayer = { + source: skeletonSource, + getNode: vi.fn((nodeId: number) => cacheByNode.get(nodeId)), + invalidateSourceCaches: vi.fn(), + suppressBrowseSegment: vi.fn(), + }; + const layer = { + displayState: { + segmentationGroupState: { + value: { + visibleSegments: new Set([BigInt(originalSegmentId)]), + selectedSegments: new Set(), + segmentEquivalences: {}, + temporaryVisibleSegments: new Set(), + temporarySegmentEquivalences: {}, + useTemporaryVisibleSegments: { value: false }, + useTemporarySegmentEquivalences: { value: false }, + }, + }, + segmentStatedColors: { + value: { + delete: vi.fn(), + }, + }, + }, + manager: { + root: { + selectionState: { + pin: { + value: true, + }, + }, + }, + }, + spatialSkeletonState: { + commandHistory: new SpatialSkeletonCommandHistory(), + getCachedNode: (nodeId: number) => cacheByNode.get(nodeId), + getCachedSegmentNodes: (segmentId: number) => + cacheBySegment.get(segmentId), + getFullSegmentNodes, + invalidateCachedSegments, + }, + getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, + getCachedSpatialSkeletonSegmentNodesForEdit: (segmentId: number) => + cacheBySegment.get(segmentId) ?? [], + selectSegment: vi.fn(), + selectSpatialSkeletonNode: vi.fn(), + markSpatialSkeletonNodeDataChanged: vi.fn(), + }; + + await executeSpatialSkeletonSplit(layer as any, { + nodeId: splitNodeBefore.nodeId, + segmentId: originalSegmentId, + }); + + skeletonSource.rerootSkeleton.mockClear(); + getFullSegmentNodes.mockClear(); + invalidateCachedSegments.mockClear(); + + await undoSpatialSkeletonCommand(layer as any); + + expect(skeletonSource.mergeSkeletons).toHaveBeenCalledWith( + formerParentNode.nodeId, + splitNodeBefore.nodeId, + expect.any(Object), + ); + expect(skeletonSource.rerootSkeleton).not.toHaveBeenCalled(); + expect(invalidateCachedSegments).toHaveBeenCalledTimes(1); + expect(invalidateCachedSegments).toHaveBeenCalledWith([ + originalSegmentId, + splitSegmentId, + ]); + expect(getFullSegmentNodes).toHaveBeenCalledTimes(2); + expect(cacheBySegment.get(splitSegmentId)).toBeUndefined(); + expect( + cacheBySegment.get(originalSegmentId)?.map((node) => ({ + nodeId: node.nodeId, + parentNodeId: node.parentNodeId, + })), + ).toEqual([ + { + nodeId: originalRootNode.nodeId, + parentNodeId: undefined, + }, + { + nodeId: formerParentNode.nodeId, + parentNodeId: originalRootNode.nodeId, + }, + { + nodeId: splitNodeBefore.nodeId, + parentNodeId: formerParentNode.nodeId, + }, + ]); + }); + + it("preserves full merge undo behavior for a hidden second pick via the root endpoint", async () => { + suppressStatusMessages(); + + const visibleSegmentId = 11; + const hiddenSegmentId = 17; + const visibleRootNode: SpatiallyIndexedSkeletonNode = { + nodeId: 101, + segmentId: visibleSegmentId, + position: new Float32Array([1, 2, 3]), + isTrueEnd: false, + revisionToken: "visible-root-before", + }; + const visibleAnchorNode: SpatiallyIndexedSkeletonNode = { + nodeId: 102, + segmentId: visibleSegmentId, + parentNodeId: visibleRootNode.nodeId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + revisionToken: "visible-anchor-before", + }; + const hiddenRootNode: SpatiallyIndexedSkeletonNode = { + nodeId: 201, + segmentId: hiddenSegmentId, + position: new Float32Array([7, 8, 9]), + isTrueEnd: false, + revisionToken: "hidden-root-before", + }; + const hiddenAttachNodeBefore: SpatiallyIndexedSkeletonNode = { + nodeId: 202, + segmentId: hiddenSegmentId, + parentNodeId: hiddenRootNode.nodeId, + position: new Float32Array([10, 11, 12]), + isTrueEnd: false, + revisionToken: "hidden-attach-before", + }; + const mergedNodes: SpatiallyIndexedSkeletonNode[] = [ + cloneNode(visibleRootNode), + cloneNode(visibleAnchorNode), + { + ...cloneNode(hiddenAttachNodeBefore), + segmentId: visibleSegmentId, + parentNodeId: visibleAnchorNode.nodeId, + }, + { + ...cloneNode(hiddenRootNode), + segmentId: visibleSegmentId, + parentNodeId: hiddenAttachNodeBefore.nodeId, + }, + ]; + const splitOnlyRestoredNodes: SpatiallyIndexedSkeletonNode[] = [ + { + ...cloneNode(hiddenAttachNodeBefore), + parentNodeId: undefined, + revisionToken: "hidden-attach-split", + }, + { + ...cloneNode(hiddenRootNode), + parentNodeId: hiddenAttachNodeBefore.nodeId, + revisionToken: "hidden-root-split", + }, + ]; + const rerootedHiddenNodes: SpatiallyIndexedSkeletonNode[] = [ + { + ...cloneNode(hiddenRootNode), + parentNodeId: undefined, + revisionToken: "hidden-root-rerooted", + }, + { + ...cloneNode(hiddenAttachNodeBefore), + parentNodeId: hiddenRootNode.nodeId, + revisionToken: "hidden-attach-rerooted", + }, + ]; + + const serverSegments = new Map(); + const cacheBySegment = new Map(); + const cacheByNode = new Map(); + const hiddenSegmentVisibleDuringFetches: boolean[] = []; + + const syncCacheFromServer = (segmentId: number) => { + setSegmentNodes( + cacheBySegment, + cacheByNode, + segmentId, + serverSegments.get(segmentId) ?? [], + ); + return cacheBySegment.get(segmentId) ?? []; + }; + + serverSegments.set(visibleSegmentId, [ + cloneNode(visibleRootNode), + cloneNode(visibleAnchorNode), + ]); + serverSegments.set(hiddenSegmentId, [ + cloneNode(hiddenRootNode), + cloneNode(hiddenAttachNodeBefore), + ]); + syncCacheFromServer(visibleSegmentId); + + const skeletonSource = makeEditableSkeletonSource({ + getSkeletonRootNode: vi.fn(async () => ({ + nodeId: hiddenRootNode.nodeId, + x: hiddenRootNode.position[0], + y: hiddenRootNode.position[1], + z: hiddenRootNode.position[2], + })), + 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 {}; + }), + }); + + const invalidateCachedSegments = vi.fn((segmentIds: Iterable) => { + for (const segmentId of segmentIds) { + setSegmentNodes(cacheBySegment, cacheByNode, segmentId, []); + } + }); + const getFullSegmentNodes = vi.fn( + async (_skeletonLayer: unknown, segmentId: number) => { + if (segmentId === hiddenSegmentId) { + hiddenSegmentVisibleDuringFetches.push( + layer.displayState.segmentationGroupState.value.visibleSegments.has( + BigInt(hiddenSegmentId), + ), + ); + } + return syncCacheFromServer(segmentId); + }, + ); + const skeletonLayer = { + source: skeletonSource, + getNode: vi.fn((nodeId: number) => cacheByNode.get(nodeId)), + invalidateSourceCaches: vi.fn(), + suppressBrowseSegment: vi.fn(), + }; + const layer = { + displayState: { + segmentationGroupState: { + value: { + visibleSegments: new Set([BigInt(visibleSegmentId)]), + selectedSegments: new Set(), + segmentEquivalences: {}, + temporaryVisibleSegments: new Set(), + temporarySegmentEquivalences: {}, + useTemporaryVisibleSegments: { value: false }, + useTemporarySegmentEquivalences: { value: false }, + }, + }, + segmentStatedColors: { + value: { + delete: vi.fn(), + }, + }, + }, + manager: { + root: { + selectionState: { + pin: { + value: true, + }, + }, + }, + }, + spatialSkeletonState: { + commandHistory: new SpatialSkeletonCommandHistory(), + getCachedNode: (nodeId: number) => cacheByNode.get(nodeId), + getCachedSegmentNodes: (segmentId: number) => + cacheBySegment.get(segmentId), + getFullSegmentNodes, + invalidateCachedSegments, + }, + getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, + selectSegment: vi.fn(), + selectSpatialSkeletonNode: vi.fn(), + markSpatialSkeletonNodeDataChanged: vi.fn(), + clearSpatialSkeletonMergeAnchor: vi.fn(), + }; + + await executeSpatialSkeletonMerge( + layer as any, + { + nodeId: visibleAnchorNode.nodeId, + segmentId: visibleSegmentId, + }, + { + nodeId: hiddenAttachNodeBefore.nodeId, + segmentId: hiddenSegmentId, + revisionToken: hiddenAttachNodeBefore.revisionToken, + }, + ); + + expect(skeletonSource.getSkeletonRootNode).toHaveBeenCalledWith( + hiddenSegmentId, + ); + expect(skeletonSource.mergeSkeletons).toHaveBeenCalledWith( + visibleAnchorNode.nodeId, + hiddenAttachNodeBefore.nodeId, + expect.any(Object), + ); + expect(getFullSegmentNodes).toHaveBeenCalledTimes(2); + expect( + skeletonSource.mergeSkeletons.mock.invocationCallOrder[0], + ).toBeLessThan(getFullSegmentNodes.mock.invocationCallOrder[0]); + + skeletonSource.rerootSkeleton.mockClear(); + hiddenSegmentVisibleDuringFetches.length = 0; + + await undoSpatialSkeletonCommand(layer as any); + + expect(skeletonSource.splitSkeleton).toHaveBeenCalledWith( + hiddenAttachNodeBefore.nodeId, + expect.any(Object), + ); + expect(skeletonSource.rerootSkeleton).toHaveBeenCalledWith( + hiddenRootNode.nodeId, + expect.any(Object), + ); + expect(hiddenSegmentVisibleDuringFetches.length).toBeGreaterThan(0); + expect(hiddenSegmentVisibleDuringFetches.every(Boolean)).toBe(true); + expect( + cacheBySegment.get(hiddenSegmentId)?.map((node) => ({ + nodeId: node.nodeId, + parentNodeId: node.parentNodeId, + })), + ).toEqual([ + { + nodeId: hiddenRootNode.nodeId, + parentNodeId: undefined, + }, + { + nodeId: hiddenAttachNodeBefore.nodeId, + parentNodeId: hiddenRootNode.nodeId, + }, + ]); + }); + + it("reports reroot failure during merge undo as a split-only undo", async () => { + const fakeStatusMessage = { + dispose() {}, + } as unknown as StatusMessage; + const statusSpy = vi + .spyOn(StatusMessage, "showTemporaryMessage") + .mockImplementation( + (_message: string, _closeAfter?: number) => fakeStatusMessage, + ); + + const visibleSegmentId = 11; + const hiddenSegmentId = 17; + const visibleRootNode: SpatiallyIndexedSkeletonNode = { + nodeId: 101, + segmentId: visibleSegmentId, + position: new Float32Array([1, 2, 3]), + isTrueEnd: false, + revisionToken: "visible-root-before", + }; + const visibleAnchorNode: SpatiallyIndexedSkeletonNode = { + nodeId: 102, + segmentId: visibleSegmentId, + parentNodeId: visibleRootNode.nodeId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + revisionToken: "visible-anchor-before", + }; + const hiddenRootNode: SpatiallyIndexedSkeletonNode = { + nodeId: 201, + segmentId: hiddenSegmentId, + position: new Float32Array([7, 8, 9]), + isTrueEnd: false, + revisionToken: "hidden-root-before", + }; + const hiddenAttachNodeBefore: SpatiallyIndexedSkeletonNode = { + nodeId: 202, + segmentId: hiddenSegmentId, + parentNodeId: hiddenRootNode.nodeId, + position: new Float32Array([10, 11, 12]), + isTrueEnd: false, + revisionToken: "hidden-attach-before", + }; + const mergedNodes: SpatiallyIndexedSkeletonNode[] = [ + cloneNode(visibleRootNode), + cloneNode(visibleAnchorNode), + { + ...cloneNode(hiddenAttachNodeBefore), + segmentId: visibleSegmentId, + parentNodeId: visibleAnchorNode.nodeId, + }, + { + ...cloneNode(hiddenRootNode), + segmentId: visibleSegmentId, + parentNodeId: hiddenAttachNodeBefore.nodeId, + }, + ]; + const splitOnlyRestoredNodes: SpatiallyIndexedSkeletonNode[] = [ + { + ...cloneNode(hiddenAttachNodeBefore), + parentNodeId: undefined, + revisionToken: "hidden-attach-split", + }, + { + ...cloneNode(hiddenRootNode), + parentNodeId: hiddenAttachNodeBefore.nodeId, + revisionToken: "hidden-root-split", + }, + ]; + + const serverSegments = new Map(); + const cacheBySegment = new Map(); + const cacheByNode = new Map(); + + const syncCacheFromServer = (segmentId: number) => { + setSegmentNodes( + cacheBySegment, + cacheByNode, + segmentId, + serverSegments.get(segmentId) ?? [], + ); + return cacheBySegment.get(segmentId) ?? []; + }; + + serverSegments.set(visibleSegmentId, [ + cloneNode(visibleRootNode), + cloneNode(visibleAnchorNode), + ]); + serverSegments.set(hiddenSegmentId, [ + cloneNode(hiddenRootNode), + cloneNode(hiddenAttachNodeBefore), + ]); + syncCacheFromServer(visibleSegmentId); + + const skeletonSource = makeEditableSkeletonSource({ + getSkeletonRootNode: vi.fn(async () => ({ + nodeId: hiddenRootNode.nodeId, + x: hiddenRootNode.position[0], + y: hiddenRootNode.position[1], + z: hiddenRootNode.position[2], + })), + 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"); + }), + }); + + const getFullSegmentNodes = vi.fn( + async (_skeletonLayer: unknown, segmentId: number) => + syncCacheFromServer(segmentId), + ); + const layer = { + displayState: { + segmentationGroupState: { + value: { + visibleSegments: new Set([BigInt(visibleSegmentId)]), + selectedSegments: new Set(), + segmentEquivalences: {}, + temporaryVisibleSegments: new Set(), + temporarySegmentEquivalences: {}, + useTemporaryVisibleSegments: { value: false }, + useTemporarySegmentEquivalences: { value: false }, + }, + }, + segmentStatedColors: { + value: { + delete: vi.fn(), + }, + }, + }, + manager: { + root: { + selectionState: { + pin: { + value: true, + }, + }, + }, + }, + spatialSkeletonState: { + commandHistory: new SpatialSkeletonCommandHistory(), + getCachedNode: (nodeId: number) => cacheByNode.get(nodeId), + getCachedSegmentNodes: (segmentId: number) => + cacheBySegment.get(segmentId), + getFullSegmentNodes, + invalidateCachedSegments: vi.fn((segmentIds: Iterable) => { + for (const segmentId of segmentIds) { + setSegmentNodes(cacheBySegment, cacheByNode, segmentId, []); + } + }), + }, + getSpatiallyIndexedSkeletonLayer: () => ({ + source: skeletonSource, + getNode: vi.fn((nodeId: number) => cacheByNode.get(nodeId)), + invalidateSourceCaches: vi.fn(), + suppressBrowseSegment: vi.fn(), + }), + selectSegment: vi.fn(), + selectSpatialSkeletonNode: vi.fn(), + markSpatialSkeletonNodeDataChanged: vi.fn(), + clearSpatialSkeletonMergeAnchor: vi.fn(), + }; + + await executeSpatialSkeletonMerge( + layer as any, + { + nodeId: visibleAnchorNode.nodeId, + segmentId: visibleSegmentId, + }, + { + nodeId: hiddenAttachNodeBefore.nodeId, + segmentId: hiddenSegmentId, + revisionToken: hiddenAttachNodeBefore.revisionToken, + }, + ); + statusSpy.mockClear(); + + await expect(undoSpatialSkeletonCommand(layer as any)).resolves.toBe(true); + + expect(skeletonSource.splitSkeleton).toHaveBeenCalledWith( + hiddenAttachNodeBefore.nodeId, + expect.any(Object), + ); + expect(skeletonSource.rerootSkeleton).toHaveBeenCalledWith( + hiddenRootNode.nodeId, + expect.any(Object), + ); + expect( + cacheBySegment.get(hiddenSegmentId)?.map((node) => ({ + nodeId: node.nodeId, + parentNodeId: node.parentNodeId, + })), + ).toEqual([ + { + nodeId: hiddenAttachNodeBefore.nodeId, + parentNodeId: undefined, + }, + { + nodeId: hiddenRootNode.nodeId, + parentNodeId: hiddenAttachNodeBefore.nodeId, + }, + ]); + expect(statusSpy).toHaveBeenCalledWith( + expect.stringContaining("Only the split completed."), + ); + }); + + it("falls back to full resolution for an uncached second node without revision metadata", async () => { + suppressStatusMessages(); + + const firstSegmentId = 11; + const secondSegmentId = 17; + const firstRootNode: SpatiallyIndexedSkeletonNode = { + nodeId: 101, + segmentId: firstSegmentId, + position: new Float32Array([1, 2, 3]), + isTrueEnd: false, + revisionToken: "first-root-before", + }; + const firstAnchorNode: SpatiallyIndexedSkeletonNode = { + nodeId: 102, + segmentId: firstSegmentId, + parentNodeId: firstRootNode.nodeId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + revisionToken: "first-anchor-before", + }; + const secondRootNode: SpatiallyIndexedSkeletonNode = { + nodeId: 201, + segmentId: secondSegmentId, + position: new Float32Array([7, 8, 9]), + isTrueEnd: false, + revisionToken: "second-root-before", + }; + const secondAttachNode: SpatiallyIndexedSkeletonNode = { + nodeId: 202, + segmentId: secondSegmentId, + parentNodeId: secondRootNode.nodeId, + position: new Float32Array([10, 11, 12]), + isTrueEnd: false, + revisionToken: "second-attach-before", + }; + + const serverSegments = new Map(); + const cacheBySegment = new Map(); + const cacheByNode = new Map(); + + const syncCacheFromServer = (segmentId: number) => { + setSegmentNodes( + cacheBySegment, + cacheByNode, + segmentId, + serverSegments.get(segmentId) ?? [], + ); + return cacheBySegment.get(segmentId) ?? []; + }; + + serverSegments.set(firstSegmentId, [ + cloneNode(firstRootNode), + cloneNode(firstAnchorNode), + ]); + serverSegments.set(secondSegmentId, [ + cloneNode(secondRootNode), + cloneNode(secondAttachNode), + ]); + syncCacheFromServer(firstSegmentId); + + 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, + })), + }); + + const getFullSegmentNodes = vi.fn( + async (_skeletonLayer: unknown, segmentId: number) => + syncCacheFromServer(segmentId), + ); + const layer = { + displayState: { + segmentationGroupState: { + value: { + visibleSegments: new Set([BigInt(firstSegmentId)]), + selectedSegments: new Set(), + segmentEquivalences: {}, + temporaryVisibleSegments: new Set(), + temporarySegmentEquivalences: {}, + useTemporaryVisibleSegments: { value: false }, + useTemporarySegmentEquivalences: { value: false }, + }, + }, + segmentStatedColors: { + value: { + delete: vi.fn(), + }, + }, + }, + manager: { + root: { + selectionState: { + pin: { + value: true, + }, + }, + }, + }, + spatialSkeletonState: { + commandHistory: new SpatialSkeletonCommandHistory(), + getCachedNode: (nodeId: number) => cacheByNode.get(nodeId), + getCachedSegmentNodes: (segmentId: number) => + cacheBySegment.get(segmentId), + getFullSegmentNodes, + invalidateCachedSegments: vi.fn((segmentIds: Iterable) => { + for (const segmentId of segmentIds) { + setSegmentNodes(cacheBySegment, cacheByNode, segmentId, []); + } + }), + }, + getSpatiallyIndexedSkeletonLayer: () => ({ + source: skeletonSource, + getNode: vi.fn((nodeId: number) => cacheByNode.get(nodeId)), + invalidateSourceCaches: vi.fn(), + suppressBrowseSegment: vi.fn(), + }), + selectSegment: vi.fn(), + selectSpatialSkeletonNode: vi.fn(), + markSpatialSkeletonNodeDataChanged: vi.fn(), + clearSpatialSkeletonMergeAnchor: vi.fn(), + }; + + await executeSpatialSkeletonMerge( + layer as any, + { + nodeId: firstAnchorNode.nodeId, + segmentId: firstSegmentId, + }, + { + nodeId: secondAttachNode.nodeId, + segmentId: secondSegmentId, + }, + ); + + expect(skeletonSource.getSkeletonRootNode).not.toHaveBeenCalled(); + expect(getFullSegmentNodes).toHaveBeenCalledWith( + expect.anything(), + secondSegmentId, + ); + expect(skeletonSource.mergeSkeletons).toHaveBeenCalledWith( + firstAnchorNode.nodeId, + secondAttachNode.nodeId, + expect.any(Object), + ); + }); + + it("shows and clears a pending status while a merge is in flight", async () => { + const pendingStatus = { + dispose: vi.fn(), + } as unknown as StatusMessage; + const showMessage = vi + .spyOn(StatusMessage, "showMessage") + .mockReturnValue(pendingStatus); + vi.spyOn(StatusMessage, "showTemporaryMessage").mockImplementation( + () => ({ dispose() {} }) as unknown as StatusMessage, + ); + + let resolveMerge: + | ((value: { + resultSkeletonId: number; + deletedSkeletonId: number; + stableAnnotationSwap: boolean; + }) => void) + | undefined; + const mergeSkeletons = vi.fn( + () => + new Promise<{ + resultSkeletonId: number; + deletedSkeletonId: number; + stableAnnotationSwap: boolean; + }>((resolve) => { + resolveMerge = resolve; + }), + ); + const firstNode: SpatiallyIndexedSkeletonNode = { + nodeId: 101, + segmentId: 11, + position: new Float32Array([1, 2, 3]), + isTrueEnd: false, + revisionToken: "first-before", + }; + const secondNode: SpatiallyIndexedSkeletonNode = { + nodeId: 202, + segmentId: 17, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + revisionToken: "second-before", + }; + const skeletonLayer = { + source: makeEditableSkeletonSource({ mergeSkeletons }), + getNode: vi.fn((nodeId: number) => { + if (nodeId === firstNode.nodeId) return firstNode; + if (nodeId === secondNode.nodeId) return secondNode; + return undefined; + }), + suppressBrowseSegment: vi.fn(), + invalidateSourceCaches: vi.fn(), + }; + const layer = { + displayState: { + segmentationGroupState: { + value: { + visibleSegments: new Set([11n, 17n]), + selectedSegments: new Set(), + segmentEquivalences: {}, + temporaryVisibleSegments: new Set(), + temporarySegmentEquivalences: {}, + useTemporaryVisibleSegments: { value: false }, + useTemporarySegmentEquivalences: { value: false }, + }, + }, + segmentStatedColors: { + value: { + delete: vi.fn(), + }, + }, + }, + spatialSkeletonState: { + commandHistory: new SpatialSkeletonCommandHistory(), + getCachedNode: vi.fn((nodeId: number) => { + if (nodeId === firstNode.nodeId) return firstNode; + if (nodeId === secondNode.nodeId) return secondNode; + return undefined; + }), + getCachedSegmentNodes: vi.fn((segmentId: number) => { + if (segmentId === firstNode.segmentId) return [firstNode]; + if (segmentId === secondNode.segmentId) return [secondNode]; + return undefined; + }), + getFullSegmentNodes: vi.fn(async () => []), + invalidateCachedSegments: vi.fn(), + }, + getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, + selectSegment: vi.fn(), + selectSpatialSkeletonNode: vi.fn(), + markSpatialSkeletonNodeDataChanged: vi.fn(), + clearSpatialSkeletonMergeAnchor: vi.fn(), + manager: { + root: { + selectionState: { + pin: { + value: true, + }, + }, + }, + }, + }; + + const mergePromise = executeSpatialSkeletonMerge( + layer as any, + { nodeId: firstNode.nodeId, segmentId: firstNode.segmentId }, + { nodeId: secondNode.nodeId, segmentId: secondNode.segmentId }, + ); + + expect(showMessage).toHaveBeenCalledWith("Merging skeletons..."); + expect(pendingStatus.dispose).not.toHaveBeenCalled(); + await vi.waitFor(() => { + expect(mergeSkeletons).toHaveBeenCalledTimes(1); + }); + + resolveMerge?.({ + resultSkeletonId: firstNode.segmentId, + deletedSkeletonId: secondNode.segmentId, + stableAnnotationSwap: false, + }); + await mergePromise; + + expect(pendingStatus.dispose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/layer/segmentation/spatial_skeleton_commands.ts b/src/layer/segmentation/spatial_skeleton_commands.ts new file mode 100644 index 0000000000..0b46c23ef7 --- /dev/null +++ b/src/layer/segmentation/spatial_skeleton_commands.ts @@ -0,0 +1,1782 @@ +/** + * @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 { + 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"; +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, + }; +} + +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) { + throw new Error( + "Unable to resolve editable skeleton source for the active layer.", + ); + } + return { skeletonLayer, skeletonSource }; +} + +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; + 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}.`); + } + 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, + }; +} + +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( + 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: 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( + layer: SegmentationUserLayer, + options: { + skeletonId: number; + parentNodeId: number | undefined; + // Callers must convert viewer/global coordinates to skeleton model space. + positionInModelSpace: Float32Array; + }, +) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + const command = new AddNodeCommand( + layer, + commandMappings.getStableOrCurrentNodeId(options.parentNodeId), + commandMappings.getStableOrCurrentSegmentId(options.skeletonId) ?? + options.skeletonId, + new Float32Array(options.positionInModelSpace), + ); + return executeSpatialSkeletonCommandWithPendingMessage( + layer.spatialSkeletonState.commandHistory.execute(command), + "Creating node...", + ); +} + +export function executeSpatialSkeletonMoveNode( + layer: SegmentationUserLayer, + options: { + node: SpatiallyIndexedSkeletonNode; + // Callers must convert viewer/global coordinates to skeleton model space. + nextPositionInModelSpace: Float32Array; + }, +) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + const command = new MoveNodeCommand( + layer, + commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), + new Float32Array(options.node.position), + new Float32Array(options.nextPositionInModelSpace), + ); + return layer.spatialSkeletonState.commandHistory.execute(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 = new DeleteNodeCommand(layer, refreshedNode, childNodes); + return executeSpatialSkeletonCommandWithPendingMessage( + layer.spatialSkeletonState.commandHistory.execute(command), + "Deleting node...", + ); +} + +export function executeSpatialSkeletonNodeDescriptionUpdate( + layer: SegmentationUserLayer, + options: { + node: SpatiallyIndexedSkeletonNode; + nextDescription?: string; + }, +) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + const command = new NodeDescriptionCommand( + layer, + commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), + options.node.description, + options.nextDescription ?? options.node.description, + ); + return layer.spatialSkeletonState.commandHistory.execute(command); +} + +export function executeSpatialSkeletonNodeTrueEndUpdate( + layer: SegmentationUserLayer, + options: { + node: SpatiallyIndexedSkeletonNode; + nextIsTrueEnd: boolean; + }, +) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + const command = new NodeTrueEndCommand( + layer, + commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), + options.node.isTrueEnd, + options.nextIsTrueEnd, + ); + return layer.spatialSkeletonState.commandHistory.execute(command); +} + +export function executeSpatialSkeletonNodePropertiesUpdate( + layer: SegmentationUserLayer, + options: { + node: SpatiallyIndexedSkeletonNode; + next: { radius: number; confidence: number }; + }, +) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + const command = new NodePropertiesCommand( + layer, + commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), + { + radius: options.node.radius ?? 0, + confidence: options.node.confidence ?? 0, + }, + options.next, + ); + return layer.spatialSkeletonState.commandHistory.execute(command); +} + +export function executeSpatialSkeletonReroot( + 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; + const command = new RerootCommand( + layer, + commandMappings.getStableOrCurrentNodeId(node.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(node.segmentId), + commandMappings.getStableOrCurrentNodeId(rootNode.nodeId)!, + ); + return layer.spatialSkeletonState.commandHistory.execute(command); +} + +export function executeSpatialSkeletonSplit( + 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; + const command = new SplitCommand( + layer, + commandMappings.getStableOrCurrentNodeId(splitNode.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(splitNode.segmentId), + commandMappings.getStableOrCurrentNodeId(splitNode.parentNodeId), + ); + return executeSpatialSkeletonCommandWithPendingMessage( + layer.spatialSkeletonState.commandHistory.execute(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, +) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + const command = new MergeCommand( + layer, + commandMappings.getStableOrCurrentNodeId(firstNode.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(firstNode.segmentId), + commandMappings.getStableOrCurrentNodeId(secondNode.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(secondNode.segmentId), + secondNode.revisionToken, + ); + return executeSpatialSkeletonCommandWithPendingMessage( + layer.spatialSkeletonState.commandHistory.execute(command), + "Merging skeletons...", + ); +} + +export async function undoSpatialSkeletonCommand(layer: SegmentationUserLayer) { + const changed = await layer.spatialSkeletonState.commandHistory.undo(); + if (!changed) { + return false; + } + return true; +} + +export async function redoSpatialSkeletonCommand(layer: SegmentationUserLayer) { + const changed = await layer.spatialSkeletonState.commandHistory.redo(); + if (!changed) { + return false; + } + return true; +} diff --git a/src/layer/segmentation/spatial_skeleton_errors.ts b/src/layer/segmentation/spatial_skeleton_errors.ts new file mode 100644 index 0000000000..c5e43e9dc1 --- /dev/null +++ b/src/layer/segmentation/spatial_skeleton_errors.ts @@ -0,0 +1,37 @@ +/** + * @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 { CatmaidStateValidationError } from "#src/datasource/catmaid/api.js"; +import { StatusMessage } from "#src/status.js"; + +function formatError(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +export function isSpatialSkeletonOutdatedStateError(error: unknown) { + return error instanceof CatmaidStateValidationError; +} + +export function showSpatialSkeletonActionError(action: string, error: unknown) { + if (isSpatialSkeletonOutdatedStateError(error)) { + return StatusMessage.showErrorMessage( + `Failed to ${action} due to outdated state. Refresh the page to sync.`, + ); + } + return StatusMessage.showTemporaryMessage( + `Failed to ${action}: ${formatError(error)}`, + ); +} diff --git a/src/layer/segmentation/spatial_skeleton_serialization.spec.ts b/src/layer/segmentation/spatial_skeleton_serialization.spec.ts new file mode 100644 index 0000000000..9b91739d4c --- /dev/null +++ b/src/layer/segmentation/spatial_skeleton_serialization.spec.ts @@ -0,0 +1,136 @@ +/** + * @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 { describe, expect, it } from "vitest"; + +import * as json_keys from "#src/layer/segmentation/json_keys.js"; +import { appendSpatialSkeletonSerializationState } from "#src/layer/segmentation/spatial_skeleton_serialization.js"; +import { trackableAlphaValue } from "#src/trackable_alpha.js"; +import { TrackableBoolean } from "#src/trackable_boolean.js"; +import { trackableFiniteFloat } from "#src/trackable_finite_float.js"; +import { TrackableValue } from "#src/trackable_value.js"; +import { + verifyFiniteNonNegativeFloat, + verifyNonnegativeInt, +} from "#src/util/json.js"; + +function makeTrackables() { + return { + hiddenObjectAlpha: trackableAlphaValue(0.5), + skeletonLod: trackableFiniteFloat(0), + spatialSkeletonGridResolutionTarget2d: new TrackableValue( + 1, + verifyFiniteNonNegativeFloat, + 1, + ), + spatialSkeletonGridResolutionTarget3d: new TrackableValue( + 1, + verifyFiniteNonNegativeFloat, + 1, + ), + spatialSkeletonGridResolutionRelative2d: new TrackableBoolean(false, false), + spatialSkeletonGridResolutionRelative3d: new TrackableBoolean(false, false), + spatialSkeletonGridLevel2d: new TrackableValue( + 0, + verifyNonnegativeInt, + 0, + ), + spatialSkeletonGridLevel3d: new TrackableValue( + 0, + verifyNonnegativeInt, + 0, + ), + }; +} + +describe("appendSpatialSkeletonSerializationState", () => { + it("does not emit spatial skeleton keys when round-tripping a legacy spec", () => { + const legacySpec: Record = {}; + const trackables = makeTrackables(); + trackables.hiddenObjectAlpha.restoreState( + legacySpec[json_keys.HIDDEN_OPACITY_3D_JSON_KEY], + ); + trackables.skeletonLod.restoreState( + legacySpec[json_keys.SKELETON_LOD_JSON_KEY], + ); + trackables.spatialSkeletonGridResolutionTarget2d.restoreState( + legacySpec[json_keys.SPATIAL_SKELETON_GRID_RESOLUTION_TARGET_2D_JSON_KEY], + ); + trackables.spatialSkeletonGridResolutionTarget3d.restoreState( + legacySpec[json_keys.SPATIAL_SKELETON_GRID_RESOLUTION_TARGET_3D_JSON_KEY], + ); + trackables.spatialSkeletonGridResolutionRelative2d.restoreState( + legacySpec[ + json_keys.SPATIAL_SKELETON_GRID_RESOLUTION_RELATIVE_2D_JSON_KEY + ], + ); + trackables.spatialSkeletonGridResolutionRelative3d.restoreState( + legacySpec[ + json_keys.SPATIAL_SKELETON_GRID_RESOLUTION_RELATIVE_3D_JSON_KEY + ], + ); + trackables.spatialSkeletonGridLevel2d.restoreState( + legacySpec[json_keys.SPATIAL_SKELETON_GRID_LEVEL_2D_JSON_KEY], + ); + trackables.spatialSkeletonGridLevel3d.restoreState( + legacySpec[json_keys.SPATIAL_SKELETON_GRID_LEVEL_3D_JSON_KEY], + ); + + const serialized: Record = {}; + appendSpatialSkeletonSerializationState( + serialized, + trackables, + /* includeDefaults= */ false, + ); + expect(serialized).toEqual({}); + }); + + it("emits non-default values for non-spatial layers", () => { + const trackables = makeTrackables(); + trackables.skeletonLod.value = 0.35; + + const serialized: Record = {}; + appendSpatialSkeletonSerializationState( + serialized, + trackables, + /* includeDefaults= */ false, + ); + expect(serialized).toEqual({ + [json_keys.SKELETON_LOD_JSON_KEY]: 0.35, + }); + }); + + it("emits defaults for spatially indexed skeleton layers", () => { + const trackables = makeTrackables(); + + const serialized: Record = {}; + appendSpatialSkeletonSerializationState( + serialized, + trackables, + /* includeDefaults= */ true, + ); + expect(serialized).toEqual({ + [json_keys.HIDDEN_OPACITY_3D_JSON_KEY]: 0.5, + [json_keys.SKELETON_LOD_JSON_KEY]: 0, + [json_keys.SPATIAL_SKELETON_GRID_RESOLUTION_TARGET_2D_JSON_KEY]: 1, + [json_keys.SPATIAL_SKELETON_GRID_RESOLUTION_TARGET_3D_JSON_KEY]: 1, + [json_keys.SPATIAL_SKELETON_GRID_RESOLUTION_RELATIVE_2D_JSON_KEY]: false, + [json_keys.SPATIAL_SKELETON_GRID_RESOLUTION_RELATIVE_3D_JSON_KEY]: false, + [json_keys.SPATIAL_SKELETON_GRID_LEVEL_2D_JSON_KEY]: 0, + [json_keys.SPATIAL_SKELETON_GRID_LEVEL_3D_JSON_KEY]: 0, + }); + }); +}); diff --git a/src/layer/segmentation/spatial_skeleton_serialization.ts b/src/layer/segmentation/spatial_skeleton_serialization.ts new file mode 100644 index 0000000000..8a7e9658b3 --- /dev/null +++ b/src/layer/segmentation/spatial_skeleton_serialization.ts @@ -0,0 +1,110 @@ +/** + * @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 * as json_keys from "#src/layer/segmentation/json_keys.js"; + +export interface JsonSerializableTrackable { + toJSON(): any; + value: T; +} + +export interface SpatialSkeletonSerializationTrackables { + hiddenObjectAlpha: JsonSerializableTrackable; + skeletonLod: JsonSerializableTrackable; + spatialSkeletonGridResolutionTarget2d: JsonSerializableTrackable; + spatialSkeletonGridResolutionTarget3d: JsonSerializableTrackable; + spatialSkeletonGridResolutionRelative2d: JsonSerializableTrackable; + spatialSkeletonGridResolutionRelative3d: JsonSerializableTrackable; + spatialSkeletonGridLevel2d: JsonSerializableTrackable; + spatialSkeletonGridLevel3d: JsonSerializableTrackable; +} + +function getSerializedTrackableValue( + trackable: JsonSerializableTrackable, + includeDefaults: boolean, +) { + const value = trackable.toJSON(); + if (value !== undefined) return value; + if (!includeDefaults) return undefined; + return trackable.value; +} + +function setSerializedTrackable( + target: Record, + key: string, + trackable: JsonSerializableTrackable, + includeDefaults: boolean, +) { + const value = getSerializedTrackableValue(trackable, includeDefaults); + if (value !== undefined) { + target[key] = value; + } +} + +export function appendSpatialSkeletonSerializationState( + target: Record, + trackables: SpatialSkeletonSerializationTrackables, + includeDefaults: boolean, +) { + setSerializedTrackable( + target, + json_keys.HIDDEN_OPACITY_3D_JSON_KEY, + trackables.hiddenObjectAlpha, + includeDefaults, + ); + setSerializedTrackable( + target, + json_keys.SKELETON_LOD_JSON_KEY, + trackables.skeletonLod, + includeDefaults, + ); + setSerializedTrackable( + target, + json_keys.SPATIAL_SKELETON_GRID_RESOLUTION_TARGET_2D_JSON_KEY, + trackables.spatialSkeletonGridResolutionTarget2d, + includeDefaults, + ); + setSerializedTrackable( + target, + json_keys.SPATIAL_SKELETON_GRID_RESOLUTION_TARGET_3D_JSON_KEY, + trackables.spatialSkeletonGridResolutionTarget3d, + includeDefaults, + ); + setSerializedTrackable( + target, + json_keys.SPATIAL_SKELETON_GRID_RESOLUTION_RELATIVE_2D_JSON_KEY, + trackables.spatialSkeletonGridResolutionRelative2d, + includeDefaults, + ); + setSerializedTrackable( + target, + json_keys.SPATIAL_SKELETON_GRID_RESOLUTION_RELATIVE_3D_JSON_KEY, + trackables.spatialSkeletonGridResolutionRelative3d, + includeDefaults, + ); + setSerializedTrackable( + target, + json_keys.SPATIAL_SKELETON_GRID_LEVEL_2D_JSON_KEY, + trackables.spatialSkeletonGridLevel2d, + includeDefaults, + ); + setSerializedTrackable( + target, + json_keys.SPATIAL_SKELETON_GRID_LEVEL_3D_JSON_KEY, + trackables.spatialSkeletonGridLevel3d, + includeDefaults, + ); +} diff --git a/src/layer/segmentation/style.css b/src/layer/segmentation/style.css index 3d379ba8de..6f92b2f3a8 100644 --- a/src/layer/segmentation/style.css +++ b/src/layer/segmentation/style.css @@ -167,3 +167,677 @@ .neuroglancer-segment-list-entry-color-input::-webkit-color-swatch-wrapper { padding: 0; } + +.neuroglancer-spatial-skeleton-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.1em 0.2em; + width: 100%; +} + +.neuroglancer-spatial-skeleton-toolbar + .neuroglancer-tool-button + + .neuroglancer-tool-button { + margin-left: 0.35em; +} + +.neuroglancer-spatial-skeleton-toolbar-actions { + display: inline-flex; + align-items: center; + margin-left: auto; + gap: 0.1em; +} + +.neuroglancer-spatial-skeleton-nav-tools { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + flex: 0 0 auto; + justify-content: flex-start; + gap: 0.1em; +} + +.neuroglancer-spatial-skeleton-icon-button { + border: 0; + background: transparent; + color: inherit; + padding: 0; + margin: 0; + cursor: pointer; +} + +.neuroglancer-spatial-skeleton-icon-button .neuroglancer-icon { + min-width: 14px; + min-height: 14px; + padding-left: 0; + padding-right: 0; +} + +.neuroglancer-spatial-skeleton-icon-button:hover .neuroglancer-icon { + background-color: #db4437; +} + +.neuroglancer-spatial-skeleton-icon-button:disabled { + opacity: 0.35; + cursor: default; +} + +.neuroglancer-spatial-skeleton-icon-button:disabled .neuroglancer-icon { + background-color: transparent; +} + +.neuroglancer-spatial-skeleton-tab { + display: flex; + flex-direction: column; + gap: 0.5em; + overflow-y: auto; +} + +.neuroglancer-spatial-skeleton-tab-status { + display: flex; + flex-direction: column; + gap: 0.25em; + font-size: small; +} + +.neuroglancer-spatial-skeleton-section { + --neuroglancer-skeleton-actions-width: 44px; + --neuroglancer-skeleton-type-width: 21px; + border: 1px solid #2f2f2f; + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.neuroglancer-spatial-skeleton-section-toggle { + border: 1px solid #2f2f2f; + background: #101010; + color: inherit; + padding: 0; + cursor: pointer; + width: 1.5em; + height: 1.5em; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.neuroglancer-spatial-skeleton-section-toggle .neuroglancer-icon { + min-width: 14px; + min-height: 14px; +} + +.neuroglancer-spatial-skeleton-section-toggle:hover .neuroglancer-icon { + background-color: #3a3a3a; +} + +.neuroglancer-spatial-skeleton-filter { + box-sizing: border-box; + margin: 0 0.4em; + width: calc(100% - 0.8em); + background: #050505; + border: 1px solid #2f2f2f; + color: #dedede; + font-family: monospace; + padding: 0.32em 0.45em; +} + +.neuroglancer-spatial-skeleton-filter-row { + display: flex; + align-items: center; + gap: 0.45em; + margin: 0.35em 0.4em 0; + color: #c6c6c6; + font-size: small; +} + +.neuroglancer-spatial-skeleton-filter-label { + flex: 0 0 auto; +} + +.neuroglancer-spatial-skeleton-show-section { + display: flex; + flex-direction: column; + gap: 0.25em; + margin: 0.35em 0.4em 0; + color: #c6c6c6; + font-size: small; +} + +.neuroglancer-spatial-skeleton-show-list { + display: flex; + flex-direction: column; + gap: 0.2em; + max-height: 10.5em; + overflow-y: auto; +} + +.neuroglancer-spatial-skeleton-navigation-bar { + display: flex; + align-items: center; + padding: 0.35em 0.4em 0 0.4em; +} + +.neuroglancer-spatial-skeleton-show-item { + display: grid; + grid-template-columns: 1.35em minmax(0, 1fr); + align-items: center; + gap: 0.45em; + padding: 0.18em 0.32em 0.18em 0.15em; + border: 1px solid #2f2f2f; + background: #232323; + color: #e4e4e4; + font-family: monospace; +} + +.neuroglancer-spatial-skeleton-show-item:hover { + background: #2a2a2a; +} + +.neuroglancer-spatial-skeleton-show-checkbox { + width: 1.15em; + height: 1.15em; + margin: 0; + accent-color: #1473e6; +} + +.neuroglancer-spatial-skeleton-show-item-content { + display: grid; + grid-template-columns: min-content minmax(0, 1fr) min-content; + align-items: center; + gap: 0.45em; + min-width: 0; +} + +.neuroglancer-spatial-skeleton-show-item-name, +.neuroglancer-spatial-skeleton-segment-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.neuroglancer-spatial-skeleton-show-item-ratio, +.neuroglancer-spatial-skeleton-segment-ratio { + color: #adadad; + white-space: nowrap; +} + +.neuroglancer-spatial-skeleton-filter-select { + box-sizing: border-box; + margin: 0; + flex: 1 1 auto; + width: auto; + background: #050505; + border: 1px solid #2f2f2f; + color: #dedede; + font: inherit; + padding: 0.32em 0.45em; +} + +.neuroglancer-spatial-skeleton-filter-select:focus-visible { + outline: 1px solid #6ca4ff; + outline-offset: -1px; +} + +.neuroglancer-spatial-skeleton-summary-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5em; + padding: 0.25em 0.4em 0.25em 0.4em; +} + +.neuroglancer-spatial-skeleton-summary { + font-size: small; + color: #c6c6c6; + flex: 1 1 auto; + min-width: 0; +} + +.neuroglancer-spatial-skeleton-tree { + display: flex; + flex-direction: column; + min-height: 24em; + max-height: 60vh; + flex: 1 1 auto; + overflow-y: auto; + background: #050505; + border-top: 1px solid #1e1e1e; +} + +.neuroglancer-spatial-skeleton-list-header, +.neuroglancer-spatial-skeleton-tree-row { + display: grid; + grid-template-columns: + var(--neuroglancer-skeleton-actions-width) + var(--neuroglancer-skeleton-type-width) + minmax(58px, 104px) + minmax(0, 1fr); + align-items: center; + column-gap: 3px; + padding: 0 4px; +} + +.neuroglancer-spatial-skeleton-list-header { + position: sticky; + top: 0; + z-index: 1; + min-height: 1.7em; + background: #3a3a3a; + color: #f0f0f0; + font-family: monospace; + font-size: 11px; + text-transform: lowercase; +} + +.neuroglancer-spatial-skeleton-list-header-spacer { + display: block; +} + +.neuroglancer-spatial-skeleton-list-header-actions { + width: var(--neuroglancer-skeleton-actions-width); +} + +.neuroglancer-spatial-skeleton-list-header-type { + width: var(--neuroglancer-skeleton-type-width); +} + +.neuroglancer-spatial-skeleton-list-header-cell { + min-width: 0; +} + +.neuroglancer-spatial-skeleton-tree-entry { + display: flex; + flex-direction: column; +} + +.neuroglancer-spatial-skeleton-tree-row { + min-height: 1.6em; + color: #d2d2d2; + font-family: monospace; + font-size: small; + background: #050505; + transition: background-color 0.12s ease-in-out; +} + +.neuroglancer-spatial-skeleton-tree-row[role="button"] { + cursor: pointer; +} + +.neuroglancer-spatial-skeleton-tree-row[role="button"]:focus-visible { + outline: 1px solid #6ca4ff; + outline-offset: -1px; +} + +.neuroglancer-spatial-skeleton-tree-entry[data-selected="true"] + .neuroglancer-spatial-skeleton-tree-row { + background: #242d3c; +} + +.neuroglancer-spatial-skeleton-tree-entry[data-viewer-hovered="true"] + .neuroglancer-spatial-skeleton-tree-row:not([data-node-type="root"]) { + background: #18253a; +} + +.neuroglancer-spatial-skeleton-tree-entry[data-viewer-hovered="true"] + .neuroglancer-spatial-skeleton-tree-row[data-node-type="root"] { + background: #364257; +} + +.neuroglancer-spatial-skeleton-tree-row[data-node-type="root"] { + background: #2e2e2e; +} + +.neuroglancer-spatial-skeleton-tree-entry[data-selected="true"] + .neuroglancer-spatial-skeleton-tree-row[data-node-type="root"] { + background: #394558; +} + +.neuroglancer-spatial-skeleton-tree-entry[data-selected="true"][data-viewer-hovered="true"] + .neuroglancer-spatial-skeleton-tree-row { + background: #294067; +} + +.neuroglancer-spatial-skeleton-tree-entry[data-selected="true"][data-viewer-hovered="true"] + .neuroglancer-spatial-skeleton-tree-row[data-node-type="root"] { + background: #3f5471; +} + +.neuroglancer-spatial-skeleton-segment-row { + min-height: 1.75em; + background: #2e2e2e; + color: #f0f0f0; +} + +.neuroglancer-spatial-skeleton-segment-meta-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75em; + min-width: 0; +} + +.neuroglancer-spatial-skeleton-tree-entry:not( + [data-selected="true"] + )[data-list-hovered="true"] + .neuroglancer-spatial-skeleton-tree-row:not([data-node-type="root"]) { + background: #121212; +} + +.neuroglancer-spatial-skeleton-tree-entry:hover + .neuroglancer-spatial-skeleton-segment-row { + background: #2e2e2e; +} + +.neuroglancer-spatial-skeleton-node-id { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.neuroglancer-spatial-skeleton-node-coordinate-cell { + min-width: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.neuroglancer-spatial-skeleton-node-coordinates { + overflow: hidden; + color: #d7d7d7; +} + +.neuroglancer-spatial-skeleton-coordinates-flex { + display: flex; + flex-direction: row; +} + +.neuroglancer-spatial-skeleton-coord-dim { + flex: 1 1 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.neuroglancer-spatial-skeleton-node-description { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #a7a7a7; + font-size: 11px; +} + +.neuroglancer-spatial-skeleton-node-actions { + display: inline-flex; + align-items: center; + gap: 0.1em; + width: var(--neuroglancer-skeleton-actions-width); + visibility: hidden; + pointer-events: none; + justify-content: flex-start; +} + +.neuroglancer-spatial-skeleton-tree-entry[data-list-hovered="true"] + .neuroglancer-spatial-skeleton-node-actions, +.neuroglancer-spatial-skeleton-tree-entry:focus-within + .neuroglancer-spatial-skeleton-node-actions { + visibility: visible; + pointer-events: auto; +} + +.neuroglancer-spatial-skeleton-node-type, +.neuroglancer-spatial-skeleton-node-type-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--neuroglancer-skeleton-type-width); + min-height: 1.4em; + color: #cfcfcf; +} + +.neuroglancer-spatial-skeleton-node-type-toggle { + border: 0; + background: transparent; + padding: 0; + cursor: pointer; +} + +.neuroglancer-spatial-skeleton-node-type .neuroglancer-icon, +.neuroglancer-spatial-skeleton-node-type-toggle .neuroglancer-icon { + min-width: 14px; + min-height: 14px; +} + +.neuroglancer-spatial-skeleton-node-type-toggle:hover .neuroglancer-icon { + background-color: #3a3a3a; +} + +.neuroglancer-spatial-skeleton-node-type-toggle:disabled { + opacity: 0.4; + cursor: default; +} + +.neuroglancer-spatial-skeleton-node-type-toggle:disabled .neuroglancer-icon { + background-color: transparent; +} + +.neuroglancer-spatial-skeleton-node-action { + border: 1px solid #2f2f2f; + background: #0d0d0d; + color: inherit; + padding: 0; + cursor: pointer; + width: 1.5em; + display: inline-flex; + justify-content: center; + align-items: center; +} + +.neuroglancer-spatial-skeleton-node-action .neuroglancer-icon { + min-width: 14px; + min-height: 14px; +} + +.neuroglancer-spatial-skeleton-node-action:hover .neuroglancer-icon { + background-color: #db4437; +} + +.neuroglancer-spatial-skeleton-node-action:disabled { + opacity: 0.35; + cursor: default; +} + +.neuroglancer-spatial-skeleton-node-action:disabled .neuroglancer-icon { + background-color: transparent; +} + +.neuroglancer-spatial-skeleton-node-segment-chip { + display: inline-flex; + align-items: center; + min-height: 1.2em; + padding: 0 0.35em; + font-weight: 600; +} + +.neuroglancer-spatial-skeleton-properties-input { + width: 100%; + box-sizing: border-box; + border: 1px solid #717171; + background: #8b8b8b; + color: #111; + padding: 0.18rem 0.35rem; + text-align: left; + font: inherit; +} + +.neuroglancer-spatial-skeleton-properties-input-invalid { + border-color: #c94f4f; + background: #9f7b7b; +} + +.neuroglancer-spatial-skeleton-properties-input::placeholder { + color: #2a2a2a; +} + +.neuroglancer-spatial-skeleton-selection { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.neuroglancer-spatial-skeleton-selection-summary { + display: grid; + grid-template-columns: auto auto auto minmax(0, 1fr); + align-items: center; + gap: 0.45rem; + padding: 0.25rem 0.4rem; + background: #383838; + font-family: monospace; + font-size: medium; +} + +.neuroglancer-spatial-skeleton-selection-action { + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: 0.2rem; + background: transparent; + color: inherit; + padding: 0.1rem; + cursor: pointer; + transition: background-color 0.12s ease-in-out; +} + +.neuroglancer-spatial-skeleton-selection-action:disabled { + opacity: 0.45; + cursor: default; +} + +.neuroglancer-spatial-skeleton-selection-action:not(:disabled):hover { + background: rgba(255, 255, 255, 0.08); +} + +.neuroglancer-spatial-skeleton-selection-summary-icon { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.neuroglancer-spatial-skeleton-selection-summary-icon .neuroglancer-icon { + min-width: 14px; + min-height: 14px; +} + +.neuroglancer-spatial-skeleton-selection-summary-coordinates { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + user-select: text; +} + +.neuroglancer-spatial-skeleton-selection .neuroglancer-annotation-property { + display: grid; + grid-template-columns: 8rem minmax(0, 1fr); + align-items: center; + gap: 0.75rem; + padding: 0 0.1rem; +} + +.neuroglancer-spatial-skeleton-selection + .neuroglancer-annotation-property-label { + color: #b0b0b0; +} + +.neuroglancer-spatial-skeleton-selection + .neuroglancer-annotation-property-value { + color: #f0f0f0; + min-width: 0; + display: flex; + align-items: center; + justify-content: flex-end; +} + +.neuroglancer-spatial-skeleton-leaf-type { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.28rem; +} + +.neuroglancer-spatial-skeleton-leaf-type-option { + display: inline-flex; + align-items: center; + gap: 0.38rem; + color: inherit; + cursor: pointer; +} + +.neuroglancer-spatial-skeleton-leaf-type-option-disabled { + cursor: default; + opacity: 0.65; +} + +.neuroglancer-spatial-skeleton-leaf-type-option-input { + margin: 0; + accent-color: #4f8dff; +} + +.neuroglancer-spatial-skeleton-leaf-type-option-icon { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.neuroglancer-spatial-skeleton-leaf-type-option-icon .neuroglancer-icon { + min-width: 14px; + min-height: 14px; +} + +.neuroglancer-spatial-skeleton-leaf-type-option-text { + white-space: nowrap; +} + +.neuroglancer-spatial-skeleton-selection-description { + width: 100%; + box-sizing: border-box; + min-height: 5rem; + margin-top: 0.15rem; + padding: 0.55rem; + border: 1px solid #4a4a4a; + background: #3b3b3b; + color: #f0f0f0; + font: 10pt sans-serif; + white-space: pre-wrap; + word-break: break-word; + user-select: text; +} + +textarea.neuroglancer-spatial-skeleton-selection-description { + outline: 0; + resize: none; + overflow-y: auto; +} + +.neuroglancer-spatial-skeleton-actions { + display: flex; + flex-wrap: wrap; + gap: 0.25em; +} + +.neuroglancer-spatial-skeleton-actions > button { + flex: 1 1 auto; + min-width: 6em; +} + +.neuroglancer-spatial-skeleton-description { + min-height: 5em; + resize: vertical; +} diff --git a/src/object_picking.ts b/src/object_picking.ts index f1aca29704..aba5dfaec0 100644 --- a/src/object_picking.ts +++ b/src/object_picking.ts @@ -104,6 +104,7 @@ export class PickIDManager { values[valuesOffset + 1], values[valuesOffset + 2], )); + mouseState.pickedSpatialSkeleton = undefined; mouseState.pickedAnnotationId = undefined; mouseState.pickedAnnotationLayer = undefined; mouseState.pickedAnnotationBuffer = undefined; diff --git a/src/perspective_view/panel.ts b/src/perspective_view/panel.ts index 7b062b436c..c3262d648e 100644 --- a/src/perspective_view/panel.ts +++ b/src/perspective_view/panel.ts @@ -36,9 +36,11 @@ import type { RenderedDataViewerState, } from "#src/rendered_data_panel.js"; import { + getCenteredPickWindowCoordinate, getPickDiameter, getPickOffsetSequence, RenderedDataPanel, + resolveNearestPanelPickSample, } from "#src/rendered_data_panel.js"; import { DerivedProjectionParameters, @@ -717,55 +719,64 @@ export class PerspectivePanel extends RenderedDataPanel { mouseState.pickedRenderLayer = null; const pickDiameter = getPickDiameter(pickRadius); const pickOffsetSequence = getPickOffsetSequence(pickRadius); - const numOffsets = pickOffsetSequence.length; - for (let i = 0; i < numOffsets; ++i) { - const offset = pickOffsetSequence[i]; - const zValue = data[4 * offset]; - if (zValue === 0) continue; - const relativeX = offset % pickDiameter; - const relativeY = (offset - relativeX) / pickDiameter; - const glWindowZ = 1.0 - zValue; - tempVec3[0] = - (2.0 * (glWindowX + relativeX - pickRadius)) / - pickingData.viewportWidth - - 1.0; - tempVec3[1] = - (2.0 * (glWindowY + relativeY - pickRadius)) / - pickingData.viewportHeight - - 1.0; - tempVec3[2] = 2.0 * glWindowZ - 1.0; - vec3.transformMat4(tempVec3, tempVec3, pickingData.invTransform); - let { position: mousePosition, unsnappedPosition } = mouseState; - const { value: voxelCoordinates } = this.navigationState.position; - const rank = voxelCoordinates.length; - if (mousePosition.length !== rank) { - mousePosition = mouseState.position = new Float32Array(rank); - } - if (unsnappedPosition.length !== rank) { - unsnappedPosition = mouseState.unsnappedPosition = new Float32Array( - rank, - ); - } - mousePosition.set(voxelCoordinates); - mouseState.coordinateSpace = this.navigationState.coordinateSpace.value; - const displayDimensions = - this.navigationState.pose.displayDimensions.value; - const { displayDimensionIndices } = displayDimensions; - for ( - let i = 0, spatialRank = displayDimensionIndices.length; - i < spatialRank; - ++i - ) { - mousePosition[displayDimensionIndices[i]] = tempVec3[i]; - } - unsnappedPosition.set(mousePosition); - const pickValue = data[4 * pickDiameter * pickDiameter + 4 * offset]; - pickingData.pickIDs.setMouseState(mouseState, pickValue); - mouseState.displayDimensions = displayDimensions; - mouseState.setActive(true); + const resolvedPick = resolveNearestPanelPickSample( + data, + pickOffsetSequence, + pickRadius, + { + depthBaseOffset: 0, + pickBaseOffset: 4 * pickDiameter * pickDiameter, + }, + ); + if (resolvedPick === undefined || resolvedPick.depthValue === undefined) { + mouseState.setActive(false); return; } - mouseState.setActive(false); + const glWindowZ = 1.0 - resolvedPick.depthValue; + tempVec3[0] = + (2.0 * + getCenteredPickWindowCoordinate( + glWindowX, + resolvedPick.relativeX, + pickRadius, + )) / + pickingData.viewportWidth - + 1.0; + tempVec3[1] = + (2.0 * + getCenteredPickWindowCoordinate( + glWindowY, + resolvedPick.relativeY, + pickRadius, + )) / + pickingData.viewportHeight - + 1.0; + tempVec3[2] = 2.0 * glWindowZ - 1.0; + vec3.transformMat4(tempVec3, tempVec3, pickingData.invTransform); + let { position: mousePosition, unsnappedPosition } = mouseState; + const { value: voxelCoordinates } = this.navigationState.position; + const rank = voxelCoordinates.length; + if (mousePosition.length !== rank) { + mousePosition = mouseState.position = new Float32Array(rank); + } + if (unsnappedPosition.length !== rank) { + unsnappedPosition = mouseState.unsnappedPosition = new Float32Array(rank); + } + mousePosition.set(voxelCoordinates); + mouseState.coordinateSpace = this.navigationState.coordinateSpace.value; + const displayDimensions = this.navigationState.pose.displayDimensions.value; + const { displayDimensionIndices } = displayDimensions; + for ( + let i = 0, spatialRank = displayDimensionIndices.length; + i < spatialRank; + ++i + ) { + mousePosition[displayDimensionIndices[i]] = tempVec3[i]; + } + unsnappedPosition.set(mousePosition); + pickingData.pickIDs.setMouseState(mouseState, resolvedPick.pickValue); + mouseState.displayDimensions = displayDimensions; + mouseState.setActive(true); } translateDataPointByViewportPixels( diff --git a/src/render_scale_statistics.ts b/src/render_scale_statistics.ts index 703e2c655f..3206f1d01d 100644 --- a/src/render_scale_statistics.ts +++ b/src/render_scale_statistics.ts @@ -26,15 +26,17 @@ export const renderScaleHistogramOrigin = -4; export function getRenderScaleHistogramOffset( renderScale: number, origin: number = renderScaleHistogramOrigin, + binSize: number = renderScaleHistogramBinSize, ): number { - return (Math.log2(renderScale) - origin) / renderScaleHistogramBinSize; + return (Math.log2(renderScale) - origin) / binSize; } export function getRenderScaleFromHistogramOffset( offset: number, origin: number = renderScaleHistogramOrigin, + binSize: number = renderScaleHistogramBinSize, ): number { - return 2 ** (offset * renderScaleHistogramBinSize + origin); + return 2 ** (offset * binSize + origin); } export function trackableRenderScaleTarget( @@ -62,9 +64,14 @@ export class RenderScaleHistogram { visibility = new VisibilityPriorityAggregator(); changed = new NullarySignal(); logScaleOrigin: number; + logScaleBinSize: number; - constructor(origin: number = renderScaleHistogramOrigin) { + constructor( + origin: number = renderScaleHistogramOrigin, + binSize: number = renderScaleHistogramBinSize, + ) { this.logScaleOrigin = origin; + this.logScaleBinSize = binSize; } /** @@ -142,7 +149,11 @@ export class RenderScaleHistogram { Math.max( 0, Math.round( - getRenderScaleHistogramOffset(renderScale, this.logScaleOrigin), + getRenderScaleHistogramOffset( + renderScale, + this.logScaleOrigin, + this.logScaleBinSize, + ), ), ), numRenderScaleHistogramBins - 1, diff --git a/src/rendered_data_panel.ts b/src/rendered_data_panel.ts index 6f39a9c0f4..bab65e882f 100644 --- a/src/rendered_data_panel.ts +++ b/src/rendered_data_panel.ts @@ -21,12 +21,17 @@ import type { Annotation } from "#src/annotation/index.js"; import { getAnnotationTypeRenderHandler } from "#src/annotation/type_handler.js"; import type { DisplayContext } from "#src/display_context.js"; import { RenderedPanel } from "#src/display_context.js"; +import { hasSpatialSkeletonNodeSelection } from "#src/layer/segmentation/selection.js"; import type { NavigationState } from "#src/navigation_state.js"; import { PickIDManager } from "#src/object_picking.js"; import { displayToLayerCoordinates, layerToDisplayCoordinates, } from "#src/render_coordinate_transform.js"; +import { + clearOutOfBoundsPickData, + getPickDiameter, +} from "#src/rendered_data_panel_picking.js"; import { StatusMessage } from "#src/status.js"; import type { TrackableValue } from "#src/trackable_value.js"; import { AutomaticallyFocusedElement } from "#src/util/automatic_focus.js"; @@ -53,6 +58,40 @@ declare let NEUROGLANCER_SHOW_OBJECT_SELECTION_TOOLTIP: boolean | undefined; const tempVec3 = vec3.create(); +interface SpatialSkeletonSelectableLayer { + selectSpatialSkeletonNode: ( + nodeId: number, + pin: boolean | "toggle" | "force-unpin", + options?: { + segmentId?: number; + position?: ArrayLike; + revisionToken?: string; + }, + ) => void; + clearSpatialSkeletonNodeSelection: ( + pin: boolean | "toggle" | "force-unpin", + ) => void; +} + +function isSpatialSkeletonSelectableLayer( + layer: unknown, +): layer is SpatialSkeletonSelectableLayer { + return ( + typeof layer === "object" && + layer !== null && + "selectSpatialSkeletonNode" in layer && + typeof layer.selectSpatialSkeletonNode === "function" && + "clearSpatialSkeletonNodeSelection" in layer && + typeof layer.clearSpatialSkeletonNodeSelection === "function" + ); +} + +function isSpatialSkeletonNodeSelectionValue(state: unknown) { + return hasSpatialSkeletonNodeSelection( + state as { nodeId?: unknown } | undefined, + ); +} + export interface RenderedDataViewerState extends ViewerState { inputEventMap: EventActionMap; pickRadius: TrackableValue; @@ -77,88 +116,14 @@ export class PickRequest { const pickRequestInterval = 30; -export function getPickDiameter(pickRadius: number): number { - return 1 + pickRadius * 2; -} - -let _cachedPickRadius = -1; -let _cachedPickOffsetSequence: Uint32Array | undefined; - -/** - * Sequence of offsets into C order (pickDiamater, pickDiamater) array in order of increasing - * distance from center. - */ -export function getPickOffsetSequence(pickRadius: number) { - if (pickRadius === _cachedPickRadius) { - return _cachedPickOffsetSequence!; - } - _cachedPickRadius = pickRadius; - const pickDiameter = 1 + pickRadius * 2; - const maxDist2 = pickRadius ** 2; - const getDist2 = (x: number, y: number) => - (x - pickRadius) ** 2 + (y - pickRadius) ** 2; - - let offsets = new Uint32Array(pickDiameter * pickDiameter); - let count = 0; - for (let x = 0; x < pickDiameter; ++x) { - for (let y = 0; y < pickDiameter; ++y) { - if (getDist2(x, y) > maxDist2) continue; - offsets[count++] = y * pickDiameter + x; - } - } - offsets = offsets.subarray(0, count); - offsets.sort((a, b) => { - const x1 = a % pickDiameter; - const y1 = (a - x1) / pickDiameter; - const x2 = b % pickDiameter; - const y2 = (b - x2) / pickDiameter; - return getDist2(x1, y1) - getDist2(x2, y2); - }); - return (_cachedPickOffsetSequence = offsets); -} - -/** - * Sets array elements to 0 that would be outside the viewport. - * - * @param buffer Array view, which contains a C order (pickDiameter, pickDiameter) array. - * @param baseOffset Offset into `buffer` corresponding to (0, 0). - * @param stride Stride between consecutive elements of the array. - * @param glWindowX Center x position, must be integer. - * @param glWindowY Center y position, must be integer. - * @param viewportWidth Width of viewport in pixels. - * @param viewportHeight Width of viewport in pixels. - */ -export function clearOutOfBoundsPickData( - buffer: Float32Array, - baseOffset: number, - stride: number, - glWindowX: number, - glWindowY: number, - viewportWidth: number, - viewportHeight: number, - pickRadius: number, -) { - const pickDiameter = 1 + pickRadius * 2; - const startX = glWindowX - pickRadius; - const startY = glWindowY - pickRadius; - if ( - startX >= 0 && - startY >= 0 && - startX + pickDiameter <= viewportWidth && - startY + pickDiameter <= viewportHeight - ) { - return; - } - for (let relativeY = 0; relativeY < pickDiameter; ++relativeY) { - for (let relativeX = 0; relativeX < pickDiameter; ++relativeX) { - const x = startX + relativeX; - const y = startY + relativeY; - if (x < 0 || y < 0 || x >= viewportWidth || y >= viewportHeight) { - buffer[baseOffset + (y * pickDiameter + x) * stride] = 0; - } - } - } -} +export { + clearOutOfBoundsPickData, + getCenteredPickWindowCoordinate, + getPickDiameter, + getPickOffsetSequence, + resolveNearestPanelPickSample, + type ResolvedPanelPickSample, +} from "#src/rendered_data_panel_picking.js"; export abstract class RenderedDataPanel extends RenderedPanel { /** @@ -234,13 +199,15 @@ export abstract class RenderedDataPanel extends RenderedPanel { gl.bindBuffer(WebGL2RenderingContext.PIXEL_PACK_BUFFER, buffer); } const { renderViewport } = this; - const glWindowX = + const glWindowX = Math.floor( this.mouseX - - renderViewport.visibleLeftFraction * renderViewport.logicalWidth; - const glWindowY = + renderViewport.visibleLeftFraction * renderViewport.logicalWidth, + ); + const glWindowY = Math.floor( renderViewport.height - - (this.mouseY - - renderViewport.visibleTopFraction * renderViewport.logicalHeight); + (this.mouseY - + renderViewport.visibleTopFraction * renderViewport.logicalHeight), + ); this.issuePickRequest(glWindowX, glWindowY, pickRadius); pickRequest.sync = gl.fenceSync( WebGL2RenderingContext.SYNC_GPU_COMMANDS_COMPLETE, @@ -543,11 +510,78 @@ export abstract class RenderedDataPanel extends RenderedPanel { /*capture=*/ true, ); + const getPickedSpatialSkeletonLayerSelection = () => { + const { mouseState } = this.viewer; + if (!mouseState.updateUnconditionally()) { + return undefined; + } + const pickedSpatialSkeleton = mouseState.pickedSpatialSkeleton; + const pickedNodeId = pickedSpatialSkeleton?.nodeId; + if ( + typeof pickedNodeId !== "number" || + !Number.isSafeInteger(pickedNodeId) || + pickedNodeId <= 0 + ) { + return undefined; + } + const pickedLayer = mouseState.pickedRenderLayer?.userLayer; + if (!isSpatialSkeletonSelectableLayer(pickedLayer)) { + return undefined; + } + const pickedSegmentId = pickedSpatialSkeleton?.segmentId; + return { + layer: pickedLayer, + nodeId: pickedNodeId, + segmentId: + typeof pickedSegmentId === "number" && + Number.isSafeInteger(pickedSegmentId) && + pickedSegmentId > 0 + ? pickedSegmentId + : undefined, + position: pickedSpatialSkeleton?.position ?? mouseState.position, + revisionToken: pickedSpatialSkeleton?.revisionToken, + }; + }; + + const clearPinnedSpatialSkeletonSelection = () => { + const selectionValue = this.viewer.selectionDetailsState.value; + if (selectionValue === undefined) { + return false; + } + for (const { layer, state } of selectionValue.layers) { + if ( + !isSpatialSkeletonSelectableLayer(layer) || + !isSpatialSkeletonNodeSelectionValue(state) + ) { + continue; + } + layer.clearSpatialSkeletonNodeSelection("force-unpin"); + return true; + } + return false; + }; + registerActionListener(element, "select-position", () => { + const pickedSelection = getPickedSpatialSkeletonLayerSelection(); + if (pickedSelection !== undefined) { + pickedSelection.layer.selectSpatialSkeletonNode( + pickedSelection.nodeId, + true, + { + segmentId: pickedSelection.segmentId, + position: pickedSelection.position, + revisionToken: pickedSelection.revisionToken, + }, + ); + return; + } this.viewer.selectionDetailsState.select(); }); registerActionListener(element, "unpin-selected-position", () => { + if (clearPinnedSpatialSkeletonSelection()) { + return; + } this.viewer.selectionDetailsState.unpin(); }); @@ -882,7 +916,7 @@ export abstract class RenderedDataPanel extends RenderedPanel { ((clientX - bounds.left) / bounds.width) * element.offsetWidth - element.clientLeft; const mouseY = - ((clientY - bounds.top) / bounds.height) * element.offsetHeight + + ((clientY - bounds.top) / bounds.height) * element.offsetHeight - element.clientTop; const { mouseState } = this.viewer; mouseState.pageX = clientX + window.scrollX; diff --git a/src/rendered_data_panel_picking.spec.ts b/src/rendered_data_panel_picking.spec.ts new file mode 100644 index 0000000000..554fc265a5 --- /dev/null +++ b/src/rendered_data_panel_picking.spec.ts @@ -0,0 +1,84 @@ +/** + * @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 { describe, expect, it } from "vitest"; + +import { + clearOutOfBoundsPickData, + getPickDiameter, + getPickOffsetSequence, + resolveNearestPanelPickSample, +} from "#src/rendered_data_panel_picking.js"; + +describe("resolveNearestPanelPickSample", () => { + it("dereferences slice pick data using the sampled offset", () => { + const pickRadius = 5; + const pickOffsetSequence = getPickOffsetSequence(pickRadius); + const targetOffset = pickOffsetSequence[1]; + const data = new Float32Array(4 * getPickDiameter(pickRadius) ** 2); + data[4 * targetOffset] = 17; + + expect( + resolveNearestPanelPickSample(data, pickOffsetSequence, pickRadius), + ).toEqual({ + offset: targetOffset, + relativeX: targetOffset % getPickDiameter(pickRadius), + relativeY: + (targetOffset - (targetOffset % getPickDiameter(pickRadius))) / + getPickDiameter(pickRadius), + pickValue: 17, + depthValue: undefined, + }); + }); + + it("returns depth and pick payload from the same sampled pixel", () => { + const pickRadius = 2; + const pickDiameter = getPickDiameter(pickRadius); + const pickOffsetSequence = getPickOffsetSequence(pickRadius); + const targetOffset = pickOffsetSequence[3]; + const data = new Float32Array(2 * 4 * pickDiameter * pickDiameter); + data[4 * targetOffset] = 0.25; + data[4 * pickDiameter * pickDiameter + 4 * targetOffset] = 23; + + expect( + resolveNearestPanelPickSample(data, pickOffsetSequence, pickRadius, { + depthBaseOffset: 0, + pickBaseOffset: 4 * pickDiameter * pickDiameter, + }), + ).toEqual({ + offset: targetOffset, + relativeX: targetOffset % pickDiameter, + relativeY: (targetOffset - (targetOffset % pickDiameter)) / pickDiameter, + pickValue: 23, + depthValue: 0.25, + }); + }); +}); + +describe("clearOutOfBoundsPickData", () => { + it("zeros the relative pick-window indices that fall outside the viewport", () => { + const pickRadius = 1; + const pickDiameter = getPickDiameter(pickRadius); + const buffer = new Float32Array(4 * pickDiameter * pickDiameter).fill(1); + + clearOutOfBoundsPickData(buffer, 0, 4, 0, 0, 3, 3, pickRadius); + + expect(buffer[0]).toBe(0); + expect(buffer[4]).toBe(0); + expect(buffer[4 * pickDiameter]).toBe(0); + expect(buffer[4 * (pickDiameter + 1)]).toBe(1); + }); +}); diff --git a/src/rendered_data_panel_picking.ts b/src/rendered_data_panel_picking.ts new file mode 100644 index 0000000000..c54ff3f7ca --- /dev/null +++ b/src/rendered_data_panel_picking.ts @@ -0,0 +1,156 @@ +/** + * @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 interface ResolvedPanelPickSample { + offset: number; + relativeX: number; + relativeY: number; + pickValue: number; + depthValue?: number; +} + +export function getPickDiameter(pickRadius: number): number { + return 1 + pickRadius * 2; +} + +let _cachedPickRadius = -1; +let _cachedPickOffsetSequence: Uint32Array | undefined; + +/** + * Sequence of offsets into C order (pickDiamater, pickDiamater) array in order of increasing + * distance from center. + */ +export function getPickOffsetSequence(pickRadius: number) { + if (pickRadius === _cachedPickRadius) { + return _cachedPickOffsetSequence!; + } + _cachedPickRadius = pickRadius; + const pickDiameter = getPickDiameter(pickRadius); + const maxDist2 = pickRadius ** 2; + const getDist2 = (x: number, y: number) => + (x - pickRadius) ** 2 + (y - pickRadius) ** 2; + + let offsets = new Uint32Array(pickDiameter * pickDiameter); + let count = 0; + for (let x = 0; x < pickDiameter; ++x) { + for (let y = 0; y < pickDiameter; ++y) { + if (getDist2(x, y) > maxDist2) continue; + offsets[count++] = y * pickDiameter + x; + } + } + offsets = offsets.subarray(0, count); + offsets.sort((a, b) => { + const x1 = a % pickDiameter; + const y1 = (a - x1) / pickDiameter; + const x2 = b % pickDiameter; + const y2 = (b - x2) / pickDiameter; + return getDist2(x1, y1) - getDist2(x2, y2); + }); + return (_cachedPickOffsetSequence = offsets); +} + +/** + * Sets array elements to 0 that would be outside the viewport. + * + * @param buffer Array view, which contains a C order (pickDiameter, pickDiameter) array. + * @param baseOffset Offset into `buffer` corresponding to (0, 0). + * @param stride Stride between consecutive elements of the array. + * @param glWindowX Center x pixel index. + * @param glWindowY Center y pixel index. + * @param viewportWidth Width of viewport in pixels. + * @param viewportHeight Width of viewport in pixels. + */ +export function clearOutOfBoundsPickData( + buffer: Float32Array, + baseOffset: number, + stride: number, + glWindowX: number, + glWindowY: number, + viewportWidth: number, + viewportHeight: number, + pickRadius: number, +) { + const pickDiameter = getPickDiameter(pickRadius); + const startX = glWindowX - pickRadius; + const startY = glWindowY - pickRadius; + if ( + startX >= 0 && + startY >= 0 && + startX + pickDiameter <= viewportWidth && + startY + pickDiameter <= viewportHeight + ) { + return; + } + for (let relativeY = 0; relativeY < pickDiameter; ++relativeY) { + for (let relativeX = 0; relativeX < pickDiameter; ++relativeX) { + const x = startX + relativeX; + const y = startY + relativeY; + if (x < 0 || y < 0 || x >= viewportWidth || y >= viewportHeight) { + buffer[baseOffset + (relativeY * pickDiameter + relativeX) * stride] = + 0; + } + } + } +} + +export function getCenteredPickWindowCoordinate( + glWindowCoordinate: number, + relativeCoordinate: number, + pickRadius: number, +) { + return glWindowCoordinate + relativeCoordinate - pickRadius + 0.5; +} + +export function resolveNearestPanelPickSample( + data: ArrayLike, + pickOffsetSequence: ArrayLike, + pickRadius: number, + options: { + depthBaseOffset?: number; + pickBaseOffset?: number; + stride?: number; + } = {}, +): ResolvedPanelPickSample | undefined { + const { + depthBaseOffset, + pickBaseOffset = depthBaseOffset ?? 0, + stride = 4, + } = options; + const pickDiameter = getPickDiameter(pickRadius); + for (let i = 0; i < pickOffsetSequence.length; ++i) { + const offset = pickOffsetSequence[i]; + const depthValue = + depthBaseOffset === undefined + ? undefined + : (data[depthBaseOffset + stride * offset] ?? 0); + if (depthBaseOffset !== undefined && depthValue === 0) { + continue; + } + const pickValue = data[pickBaseOffset + stride * offset] ?? 0; + if (depthBaseOffset === undefined && pickValue === 0) { + continue; + } + const relativeX = offset % pickDiameter; + return { + offset, + relativeX, + relativeY: (offset - relativeX) / pickDiameter, + pickValue, + depthValue, + }; + } + return undefined; +} diff --git a/src/segmentation_display_state/base.spec.ts b/src/segmentation_display_state/base.spec.ts new file mode 100644 index 0000000000..a2c7a1e508 --- /dev/null +++ b/src/segmentation_display_state/base.spec.ts @@ -0,0 +1,93 @@ +/** + * @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 { describe, expect, it } from "vitest"; + +import type { VisibleSegmentsState } from "#src/segmentation_display_state/base.js"; +import { + addSegmentToVisibleSets, + removeSegmentFromVisibleSets, +} from "#src/segmentation_display_state/base.js"; + +class FakeUint64Set { + private values = new Set(); + + add(value: bigint) { + this.values.add(value); + } + + delete(value: bigint) { + this.values.delete(value); + } + + has(value: bigint) { + return this.values.has(value); + } +} + +function makeState(useTemporaryVisibleSegments: boolean) { + const visibleSegments = new FakeUint64Set(); + const temporaryVisibleSegments = new FakeUint64Set(); + const selectedSegments = new FakeUint64Set(); + const state = { + visibleSegments, + selectedSegments, + segmentEquivalences: {}, + temporaryVisibleSegments, + temporarySegmentEquivalences: {}, + useTemporaryVisibleSegments: { value: useTemporaryVisibleSegments }, + useTemporarySegmentEquivalences: { value: false }, + } as unknown as VisibleSegmentsState; + return { + state, + visibleSegments, + temporaryVisibleSegments, + selectedSegments, + }; +} + +describe("segmentation_display_state/base visible set helpers", () => { + it("adds only the persistent visible set when temporary visibility is disabled", () => { + const { state, visibleSegments, temporaryVisibleSegments } = + makeState(false); + + addSegmentToVisibleSets(state, 11n); + + expect(visibleSegments.has(11n)).toBe(true); + expect(temporaryVisibleSegments.has(11n)).toBe(false); + }); + + it("keeps persistent and temporary visibility in sync when temporary visibility is enabled", () => { + const { + state, + visibleSegments, + temporaryVisibleSegments, + selectedSegments, + } = makeState(true); + visibleSegments.add(11n); + temporaryVisibleSegments.add(11n); + selectedSegments.add(11n); + + addSegmentToVisibleSets(state, 12n); + removeSegmentFromVisibleSets(state, 11n, { deselect: true }); + + expect(visibleSegments.has(12n)).toBe(true); + expect(temporaryVisibleSegments.has(12n)).toBe(true); + expect(visibleSegments.has(11n)).toBe(false); + expect(temporaryVisibleSegments.has(11n)).toBe(false); + expect(selectedSegments.has(11n)).toBe(false); + }); +}); diff --git a/src/segmentation_display_state/base.ts b/src/segmentation_display_state/base.ts index ea37ea4f52..73a58fcfcb 100644 --- a/src/segmentation_display_state/base.ts +++ b/src/segmentation_display_state/base.ts @@ -88,6 +88,36 @@ export function getVisibleSegments(state: VisibleSegmentsState) { : state.visibleSegments; } +export function addSegmentToVisibleSets( + state: VisibleSegmentsState, + segmentId: bigint, + options: { + includeTemporary?: boolean; + } = {}, +) { + state.visibleSegments.add(segmentId); + if (options.includeTemporary ?? state.useTemporaryVisibleSegments.value) { + state.temporaryVisibleSegments.add(segmentId); + } +} + +export function removeSegmentFromVisibleSets( + state: VisibleSegmentsState, + segmentId: bigint, + options: { + includeTemporary?: boolean; + deselect?: boolean; + } = {}, +) { + state.visibleSegments.delete(segmentId); + if (options.includeTemporary ?? state.useTemporaryVisibleSegments.value) { + state.temporaryVisibleSegments.delete(segmentId); + } + if (options.deselect ?? false) { + state.selectedSegments.delete(segmentId); + } +} + export function getSegmentEquivalences(state: VisibleSegmentsState) { return state.useTemporarySegmentEquivalences.value ? state.temporarySegmentEquivalences diff --git a/src/segmentation_display_state/frontend.ts b/src/segmentation_display_state/frontend.ts index 8ffdebbad5..0bdfc4f642 100644 --- a/src/segmentation_display_state/frontend.ts +++ b/src/segmentation_display_state/frontend.ts @@ -929,6 +929,7 @@ export function makeSegmentWidget( export interface SegmentationDisplayStateWithAlpha extends SegmentationDisplayState { objectAlpha: TrackableAlphaValue; + hiddenObjectAlpha?: TrackableAlphaValue; } export interface SegmentationDisplayState3D diff --git a/src/skeleton/actions.ts b/src/skeleton/actions.ts new file mode 100644 index 0000000000..a21efe2e0a --- /dev/null +++ b/src/skeleton/actions.ts @@ -0,0 +1,67 @@ +/** + * @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 const SpatialSkeletonActions = { + inspect: "inspectSkeletons", + addNodes: "addNodes", + insertNodes: "insertNodes", + moveNodes: "moveNodes", + deleteNodes: "deleteNodes", + reroot: "rerootSkeletons", + editNodeDescription: "editNodeDescription", + editNodeTrueEnd: "editNodeTrueEnd", + editNodeProperties: "editNodeProperties", + mergeSkeletons: "mergeSkeletons", + splitSkeletons: "splitSkeletons", +} as const; + +export type SpatialSkeletonAction = + (typeof SpatialSkeletonActions)[keyof typeof SpatialSkeletonActions]; + +export const DEFAULT_SPATIAL_SKELETON_EDIT_ACTIONS = [ + SpatialSkeletonActions.addNodes, + SpatialSkeletonActions.moveNodes, + SpatialSkeletonActions.deleteNodes, +] as const satisfies readonly SpatialSkeletonAction[]; + +export function getSpatialSkeletonActionSupportLabel( + action: SpatialSkeletonAction, +) { + switch (action) { + case SpatialSkeletonActions.inspect: + return "full skeleton inspection"; + case SpatialSkeletonActions.addNodes: + return "node creation"; + case SpatialSkeletonActions.insertNodes: + return "internal node insertion"; + case SpatialSkeletonActions.moveNodes: + return "node movement"; + case SpatialSkeletonActions.deleteNodes: + return "node deletion"; + case SpatialSkeletonActions.reroot: + return "skeleton rerooting"; + case SpatialSkeletonActions.editNodeDescription: + return "node description editing"; + case SpatialSkeletonActions.editNodeTrueEnd: + return "node true-end editing"; + case SpatialSkeletonActions.editNodeProperties: + return "node property editing"; + case SpatialSkeletonActions.mergeSkeletons: + return "skeleton merging"; + case SpatialSkeletonActions.splitSkeletons: + return "skeleton splitting"; + } +} diff --git a/src/skeleton/api.ts b/src/skeleton/api.ts new file mode 100644 index 0000000000..515ff9ed27 --- /dev/null +++ b/src/skeleton/api.ts @@ -0,0 +1,218 @@ +/** + * @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 interface SpatiallyIndexedSkeletonNodeBase { + nodeId: number; + segmentId: number; + position: Float32Array; + parentNodeId?: number; + revisionToken?: string; +} + +export interface SpatiallyIndexedSkeletonNode + extends SpatiallyIndexedSkeletonNodeBase { + 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; +} + +export interface SpatiallyIndexedSkeletonEditContext { + node?: SpatiallyIndexedSkeletonEditNodeContext; + parent?: SpatiallyIndexedSkeletonEditParentContext; + children?: readonly SpatiallyIndexedSkeletonEditParentContext[]; + nodes?: readonly SpatiallyIndexedSkeletonEditParentContext[]; +} + +export interface SpatiallyIndexedSkeletonMergeResult + extends SpatiallyIndexedSkeletonEditResult { + resultSkeletonId: number | undefined; + deletedSkeletonId: number | undefined; + stableAnnotationSwap: boolean; +} + +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 { + listSkeletons(): Promise; + getSkeleton( + skeletonId: number, + options?: { signal?: AbortSignal }, + ): Promise; + getSpatialIndexMetadata(): Promise; + fetchNodes( + boundingBox: { + min: { x: number; y: number; z: number }; + max: { x: number; y: number; z: number }; + }, + lod?: number, + options?: { + cacheProvider?: string; + signal?: AbortSignal; + }, + ): Promise; +} + +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; +} diff --git a/src/skeleton/backend.spec.ts b/src/skeleton/backend.spec.ts new file mode 100644 index 0000000000..a7c64e0c96 --- /dev/null +++ b/src/skeleton/backend.spec.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, vi } from "vitest"; + +import { ChunkState } from "#src/chunk_manager/base.js"; +import { + cancelStaleSpatiallyIndexedSkeletonDownloads, + getSpatiallyIndexedSkeletonChunkPriority, + markSpatiallyIndexedSkeletonChunkRequested, + SpatiallyIndexedSkeletonChunkRequestOwner, +} from "#src/skeleton/backend.js"; + +describe("skeleton/backend chunk priority", () => { + it("uses the standard chunk-origin distance rule for 3d chunks", () => { + expect( + getSpatiallyIndexedSkeletonChunkPriority( + Float32Array.of(3, 4, 0), + Float32Array.of(2, 5, 1), + Float32Array.of(1, 0, 0), + ), + ).toBeCloseTo(-Math.sqrt(17)); + }); + + it("prioritizes chunks nearer the view center ahead of farther chunks", () => { + const localCenter = Float32Array.of(10, 20, 30); + const chunkSize = Float32Array.of(4, 4, 8); + const nearChunk = Float32Array.of(2, 5, 4); + const farChunk = Float32Array.of(5, 1, 0); + + expect( + getSpatiallyIndexedSkeletonChunkPriority( + localCenter, + chunkSize, + nearChunk, + ), + ).toBeGreaterThan( + getSpatiallyIndexedSkeletonChunkPriority( + localCenter, + chunkSize, + farChunk, + ), + ); + }); +}); + +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 85cbc710bd..a5deb22e6f 100644 --- a/src/skeleton/backend.ts +++ b/src/skeleton/backend.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { debounce } from "lodash-es"; +import type { ChunkManager } from "#src/chunk_manager/backend.js"; import { Chunk, ChunkRenderLayerBackend, @@ -22,26 +24,164 @@ import { } from "#src/chunk_manager/backend.js"; import { ChunkState } from "#src/chunk_manager/base.js"; import { decodeVertexPositionsAndIndices } from "#src/mesh/backend.js"; +import { + type DisplayDimensionRenderInfo, + validateDisplayDimensionRenderInfoProperty, +} from "#src/navigation_state.js"; +import type { + RenderLayerBackendAttachment, + RenderedViewBackend, +} from "#src/render_layer_backend.js"; +import { RenderLayerBackend } from "#src/render_layer_backend.js"; import { withSegmentationLayerBackendState } from "#src/segmentation_display_state/backend.js"; import { forEachVisibleSegment, getObjectKey, } from "#src/segmentation_display_state/base.js"; -import { SKELETON_LAYER_RPC_ID } from "#src/skeleton/base.js"; +import type { SharedWatchableValue } from "#src/shared_watchable_value.js"; +import { + SKELETON_LAYER_RPC_ID, + SPATIALLY_INDEXED_SKELETON_RENDER_LAYER_RPC_ID, + SPATIALLY_INDEXED_SKELETON_RENDER_LAYER_UPDATE_SOURCES_RPC_ID, + SPATIALLY_INDEXED_SKELETON_SLICEVIEW_RENDER_LAYER_RPC_ID, +} from "#src/skeleton/base.js"; +import { + freeSkeletonChunkSystemMemory, + getVertexAttributeBytes, + serializeSkeletonChunkData, + type SkeletonChunkData, +} from "#src/skeleton/skeleton_chunk_serialization.js"; +import { + getSpatiallyIndexedSkeletonGridIndex, + selectSpatiallyIndexedSkeletonEntriesByGrid, +} from "#src/skeleton/source_selection.js"; +import { + BASE_PRIORITY, + deserializeTransformedSources, + SCALE_PRIORITY_MULTIPLIER, + SliceViewChunk, + SliceViewChunkSourceBackend, + SliceViewRenderLayerBackend, +} from "#src/sliceview/backend.js"; +import { + forEachVisibleVolumetricChunk, + type SliceViewBase, + type SliceViewChunkSpecification, + type SliceViewProjectionParameters, + type TransformedSource, +} from "#src/sliceview/base.js"; import type { TypedNumberArray } from "#src/util/array.js"; import type { Endianness } from "#src/util/endian.js"; +import { vec3 } from "#src/util/geom.js"; import { getBasePriority, getPriorityTier, withSharedVisibility, } from "#src/visibility_priority/backend.js"; + import type { RPC } from "#src/worker_rpc.js"; -import { registerSharedObject } from "#src/worker_rpc.js"; +import { registerRPC, registerSharedObject } from "#src/worker_rpc.js"; +export interface SpatiallyIndexedSkeletonChunkSpecification + extends SliceViewChunkSpecification { + chunkLayout: any; +} const SKELETON_CHUNK_PRIORITY = 60; +const SPATIALLY_INDEXED_SKELETON_LOD_DEBOUNCE_MS = 300; +const tempCenter = vec3.create(); +const tempChunkSize = vec3.create(); +const tempCenterDataPosition = vec3.create(); + +export function getSpatiallyIndexedSkeletonChunkPriority( + localCenter: Float32Array, + chunkSize: Float32Array, + positionInChunks: Float32Array, +) { + let sum = 0; + for (let i = 0; i < 3; ++i) { + const delta = localCenter[i] - positionInChunks[i] * chunkSize[i]; + sum += delta * delta; + } + return -Math.sqrt(sum); +} + +export enum SpatiallyIndexedSkeletonChunkRequestOwner { + NONE = 0, + VIEW_2D = 1 << 0, + VIEW_3D = 1 << 1, +} + +export function markSpatiallyIndexedSkeletonChunkRequested( + chunk: SpatiallyIndexedSkeletonChunk, + currentGeneration: number, + owner: SpatiallyIndexedSkeletonChunkRequestOwner, +) { + if ( + owner === SpatiallyIndexedSkeletonChunkRequestOwner.NONE || + currentGeneration < 0 + ) { + return; + } + if (chunk.requestGeneration !== currentGeneration) { + chunk.requestGeneration = currentGeneration; + chunk.requestOwners = owner; + return; + } + chunk.requestOwners |= owner; +} + +export function cancelStaleSpatiallyIndexedSkeletonDownloads( + chunkManager: ChunkManager, + sources: Iterable, + currentGeneration: number, +) { + const queueManager = chunkManager.queueManager; + for (const source of sources) { + for (const chunk of source.chunks.values()) { + const typedChunk = chunk as SpatiallyIndexedSkeletonChunk; + if (typedChunk.state !== ChunkState.DOWNLOADING) continue; + if ( + typedChunk.requestGeneration === currentGeneration && + typedChunk.requestOwners !== + SpatiallyIndexedSkeletonChunkRequestOwner.NONE + ) { + continue; + } + const controller = typedChunk.downloadAbortController; + if (controller === undefined) continue; + typedChunk.downloadAbortController = undefined; + controller.abort( + new DOMException("stale spatial skeleton LOD download", "AbortError"), + ); + queueManager.updateChunkState(typedChunk, ChunkState.QUEUED); + } + } +} + +registerRPC( + SPATIALLY_INDEXED_SKELETON_RENDER_LAYER_UPDATE_SOURCES_RPC_ID, + function (x) { + const view = this.get(x.view) as RenderedViewBackend; + const layer = this.get( + x.layer, + ) as SpatiallyIndexedSkeletonRenderLayerBackend; + const attachment = layer.attachments.get( + view, + )! as RenderLayerBackendAttachment< + RenderedViewBackend, + SpatiallyIndexedSkeletonRenderLayerAttachmentState + >; + attachment.state!.transformedSources = deserializeTransformedSources< + SpatiallyIndexedSkeletonSourceBackend, + SpatiallyIndexedSkeletonRenderLayerBackend + >(this, x.sources, layer); + attachment.state!.displayDimensionRenderInfo = x.displayDimensionRenderInfo; + layer.chunkManager.scheduleUpdateChunkPriorities(); + }, +); // Chunk that contains the skeleton of a single object. -export class SkeletonChunk extends Chunk { +export class SkeletonChunk extends Chunk implements SkeletonChunkData { objectId: bigint = 0n; vertexPositions: Float32Array | null = null; vertexAttributes: TypedNumberArray[] | null = null; @@ -51,69 +191,20 @@ export class SkeletonChunk extends Chunk { super.initialize(key); this.objectId = objectId; } - freeSystemMemory() { - this.vertexPositions = this.indices = null; - } - private getVertexAttributeBytes() { - let total = this.vertexPositions!.byteLength; - const { vertexAttributes } = this; - if (vertexAttributes != null) { - vertexAttributes.forEach((a) => { - total += a.byteLength; - }); - } - return total; + freeSystemMemory() { + freeSkeletonChunkSystemMemory(this); } serialize(msg: any, transfers: any[]) { super.serialize(msg, transfers); - const vertexPositions = this.vertexPositions!; - const indices = this.indices!; - msg.numVertices = vertexPositions.length / 3; - msg.indices = indices; - transfers.push(indices.buffer); - - const { vertexAttributes } = this; - if (vertexAttributes != null && vertexAttributes.length > 0) { - const vertexData = new Uint8Array(this.getVertexAttributeBytes()); - vertexData.set( - new Uint8Array( - vertexPositions.buffer, - vertexPositions.byteOffset, - vertexPositions.byteLength, - ), - ); - const vertexAttributeOffsets = (msg.vertexAttributeOffsets = - new Uint32Array(vertexAttributes.length + 1)); - vertexAttributeOffsets[0] = 0; - let offset = vertexPositions.byteLength; - vertexAttributes.forEach((a, i) => { - vertexAttributeOffsets[i + 1] = offset; - vertexData.set( - new Uint8Array(a.buffer, a.byteOffset, a.byteLength), - offset, - ); - offset += a.byteLength; - }); - transfers.push(vertexData.buffer); - msg.vertexAttributes = vertexData; - } else { - msg.vertexAttributes = new Uint8Array( - vertexPositions.buffer, - vertexPositions.byteOffset, - vertexPositions.byteLength, - ); - msg.vertexAttributeOffsets = Uint32Array.of(0); - if (vertexPositions.buffer !== transfers[0]) { - transfers.push(vertexPositions.buffer); - } - } - this.vertexPositions = this.indices = this.vertexAttributes = null; + serializeSkeletonChunkData(this, msg, transfers); + freeSkeletonChunkSystemMemory(this); } + downloadSucceeded() { this.systemMemoryBytes = this.gpuMemoryBytes = - this.indices!.byteLength + this.getVertexAttributeBytes(); + this.indices!.byteLength + getVertexAttributeBytes(this); super.downloadSucceeded(); } } @@ -200,3 +291,449 @@ export function decodeSkeletonVertexPositionsAndIndices( chunk.vertexPositions = meshData.vertexPositions as Float32Array; chunk.indices = meshData.indices as Uint32Array; } + +export class SpatiallyIndexedSkeletonChunk + extends SliceViewChunk + implements SkeletonChunkData +{ + vertexPositions: Float32Array | null = null; + vertexAttributes: TypedNumberArray[] | null = null; + indices: Uint32Array | null = null; + lod: number = 0; + requestGeneration = -1; + requestOwners = SpatiallyIndexedSkeletonChunkRequestOwner.NONE; + nodeIds: Int32Array | undefined; + nodeRevisionTokens: Array | undefined; + + freeSystemMemory() { + freeSkeletonChunkSystemMemory(this); + } + + serialize(msg: any, transfers: any[]) { + super.serialize(msg, transfers); + serializeSkeletonChunkData(this, msg, transfers); + freeSkeletonChunkSystemMemory(this); + } + + downloadSucceeded() { + this.systemMemoryBytes = this.gpuMemoryBytes = + this.indices!.byteLength + getVertexAttributeBytes(this); + super.downloadSucceeded(); + } +} + +export class SpatiallyIndexedSkeletonSourceBackend extends SliceViewChunkSourceBackend< + SpatiallyIndexedSkeletonChunkSpecification, + SpatiallyIndexedSkeletonChunk +> { + chunkConstructor = SpatiallyIndexedSkeletonChunk; + currentLod: number = 0; + currentRequestGeneration = -1; + currentRequestOwner = SpatiallyIndexedSkeletonChunkRequestOwner.NONE; + + getChunk(chunkGridPosition: Float32Array) { + const lodValue = this.currentLod; + const key = `${chunkGridPosition.join()}:${lodValue}`; + let chunk = this.chunks.get(key); + if (chunk === undefined) { + chunk = this.getNewChunk_( + this.chunkConstructor, + ) as SpatiallyIndexedSkeletonChunk; + chunk.initializeVolumeChunk(key, chunkGridPosition); + chunk.lod = lodValue; + this.addChunk(chunk); + } + markSpatiallyIndexedSkeletonChunkRequested( + chunk, + this.currentRequestGeneration, + this.currentRequestOwner, + ); + return chunk; + } +} + +interface SpatiallyIndexedSkeletonRenderLayerAttachmentState { + displayDimensionRenderInfo: DisplayDimensionRenderInfo; + transformedSources: TransformedSource< + SpatiallyIndexedSkeletonRenderLayerBackend, + SpatiallyIndexedSkeletonSourceBackend + >[][]; +} + +@registerSharedObject(SPATIALLY_INDEXED_SKELETON_RENDER_LAYER_RPC_ID) +export class SpatiallyIndexedSkeletonRenderLayerBackend extends withChunkManager( + RenderLayerBackend, +) { + localPosition: SharedWatchableValue; + renderScaleTarget: SharedWatchableValue; + skeletonLod: SharedWatchableValue; + skeletonGridLevel: SharedWatchableValue; + private pendingLodCleanup = false; + + constructor(rpc: RPC, options: any) { + super(rpc, options); + this.renderScaleTarget = rpc.get(options.renderScaleTarget); + this.localPosition = rpc.get(options.localPosition); + this.skeletonLod = rpc.get(options.skeletonLod); + this.skeletonGridLevel = rpc.get(options.skeletonGridLevel); + const scheduleUpdateChunkPriorities = () => + this.chunkManager.scheduleUpdateChunkPriorities(); + this.registerDisposer( + this.localPosition.changed.add(scheduleUpdateChunkPriorities), + ); + this.registerDisposer( + this.renderScaleTarget.changed.add(scheduleUpdateChunkPriorities), + ); + this.registerDisposer( + this.skeletonGridLevel.changed.add(scheduleUpdateChunkPriorities), + ); + + // Debounce LOD changes to avoid making requests for every slider value + const debouncedLodUpdate = debounce(() => { + scheduleUpdateChunkPriorities(); + }, SPATIALLY_INDEXED_SKELETON_LOD_DEBOUNCE_MS); + this.registerDisposer(() => debouncedLodUpdate.cancel()); + + this.registerDisposer( + this.skeletonLod.changed.add(() => { + this.pendingLodCleanup = true; + debouncedLodUpdate(); + }), + ); + this.registerDisposer( + this.chunkManager.recomputeChunkPriorities.add(() => + this.recomputeChunkPriorities(), + ), + ); + this.registerDisposer( + this.chunkManager.recomputeChunkPrioritiesLate.add(() => { + if (!this.pendingLodCleanup) return; + const sources = new Set(); + for (const attachment of this.attachments.values()) { + const attachmentState = attachment.state as + | SpatiallyIndexedSkeletonRenderLayerAttachmentState + | undefined; + if (attachmentState === undefined) continue; + for (const scales of attachmentState.transformedSources) { + for (const tsource of scales) { + sources.add( + tsource.source as SpatiallyIndexedSkeletonSourceBackend, + ); + } + } + } + cancelStaleSpatiallyIndexedSkeletonDownloads( + this.chunkManager, + sources, + this.chunkManager.recomputeChunkPriorities.count, + ); + this.pendingLodCleanup = false; + }), + ); + } + + attach( + attachment: RenderLayerBackendAttachment< + RenderedViewBackend, + SpatiallyIndexedSkeletonRenderLayerAttachmentState + >, + ) { + const scheduleUpdateChunkPriorities = () => + this.chunkManager.scheduleUpdateChunkPriorities(); + const { view } = attachment; + attachment.registerDisposer(scheduleUpdateChunkPriorities); + attachment.registerDisposer( + view.projectionParameters.changed.add(scheduleUpdateChunkPriorities), + ); + attachment.registerDisposer( + view.visibility.changed.add(scheduleUpdateChunkPriorities), + ); + attachment.state = { + displayDimensionRenderInfo: + view.projectionParameters.value.displayDimensionRenderInfo, + transformedSources: [], + }; + } + + private recomputeChunkPriorities() { + this.chunkManager.registerLayer(this); + const currentGeneration = this.chunkManager.recomputeChunkPriorities.count; + for (const attachment of this.attachments.values()) { + const { view } = attachment; + const visibility = view.visibility.value; + if (visibility === Number.NEGATIVE_INFINITY) { + continue; + } + const attachmentState = + attachment.state! as SpatiallyIndexedSkeletonRenderLayerAttachmentState; + const { transformedSources } = attachmentState; + if ( + transformedSources.length === 0 || + !validateDisplayDimensionRenderInfoProperty( + attachmentState, + view.projectionParameters.value.displayDimensionRenderInfo, + ) + ) { + continue; + } + const priorityTier = getPriorityTier(visibility); + const basePriority = getBasePriority(visibility) + BASE_PRIORITY; + const projectionParameters = view.projectionParameters.value; + const { chunkManager } = this; + const localCenter = tempCenter; + const chunkSize = tempChunkSize; + const centerDataPosition = tempCenterDataPosition; + const { + globalPosition, + displayDimensionRenderInfo: { displayDimensionIndices }, + } = projectionParameters; + for (let displayDim = 0; displayDim < 3; ++displayDim) { + const globalDim = displayDimensionIndices[displayDim]; + centerDataPosition[displayDim] = + globalDim === -1 ? 0 : globalPosition[globalDim]; + } + const sliceProjectionParameters = + projectionParameters as SliceViewProjectionParameters; + const pixelSize = + "pixelSize" in sliceProjectionParameters + ? sliceProjectionParameters.pixelSize + : undefined; + let resolvedPixelSize = pixelSize; + if (resolvedPixelSize === undefined) { + const voxelPhysicalScales = + projectionParameters.displayDimensionRenderInfo?.voxelPhysicalScales; + if (voxelPhysicalScales) { + let computedPixelSize = 0; + const { invViewMatrix } = projectionParameters; + for (let i = 0; i < 3; ++i) { + const s = voxelPhysicalScales[i]; + const x = invViewMatrix[i]; + computedPixelSize += (s * x) ** 2; + } + resolvedPixelSize = Math.sqrt(computedPixelSize); + } + } + const renderScaleTarget = this.renderScaleTarget.value; + const skeletonGridLevel = this.skeletonGridLevel.value; + + const selectScales = ( + scales: TransformedSource< + SpatiallyIndexedSkeletonRenderLayerBackend, + SpatiallyIndexedSkeletonSourceBackend + >[], + ): Array<{ + tsource: TransformedSource< + SpatiallyIndexedSkeletonRenderLayerBackend, + SpatiallyIndexedSkeletonSourceBackend + >; + scaleIndex: number; + }> => { + if (scales.length === 0) { + return []; + } + if ( + scales.every( + (scale) => + getSpatiallyIndexedSkeletonGridIndex(scale) !== undefined, + ) + ) { + return selectSpatiallyIndexedSkeletonEntriesByGrid( + scales.map((tsource, scaleIndex) => ({ tsource, scaleIndex })), + skeletonGridLevel, + ({ tsource }) => getSpatiallyIndexedSkeletonGridIndex(tsource), + ); + } + if (resolvedPixelSize === undefined) { + return scales.map((tsource, scaleIndex) => ({ + tsource, + scaleIndex, + })); + } + const pixelSizeWithMargin = resolvedPixelSize * 1.1; + const smallestVoxelSize = scales[0].effectiveVoxelSize; + const canImproveOnVoxelSize = (voxelSize: Float32Array) => { + const targetSize = pixelSizeWithMargin * renderScaleTarget; + for (let i = 0; i < 3; ++i) { + const size = voxelSize[i]; + if (size > targetSize && size > 1.01 * smallestVoxelSize[i]) { + return true; + } + } + return false; + }; + const improvesOnPrevVoxelSize = ( + voxelSize: Float32Array, + prevVoxelSize: Float32Array, + ) => { + const targetSize = pixelSizeWithMargin * renderScaleTarget; + for (let i = 0; i < 3; ++i) { + const size = voxelSize[i]; + const prevSize = prevVoxelSize[i]; + if ( + Math.abs(targetSize - size) < Math.abs(targetSize - prevSize) && + size < 1.01 * prevSize + ) { + return true; + } + } + return false; + }; + + const selected: Array<{ + tsource: TransformedSource< + SpatiallyIndexedSkeletonRenderLayerBackend, + SpatiallyIndexedSkeletonSourceBackend + >; + scaleIndex: number; + }> = []; + let scaleIndex = scales.length - 1; + let prevVoxelSize: Float32Array | undefined; + while (true) { + const tsource = scales[scaleIndex]; + const selectionVoxelSize = tsource.effectiveVoxelSize; + if ( + prevVoxelSize !== undefined && + !improvesOnPrevVoxelSize(selectionVoxelSize, prevVoxelSize) + ) { + break; + } + selected.push({ tsource, scaleIndex }); + if (scaleIndex === 0) break; + if (!canImproveOnVoxelSize(selectionVoxelSize)) break; + prevVoxelSize = selectionVoxelSize; + --scaleIndex; + } + return selected; + }; + + const lodValue = this.skeletonLod.value; + for (const scales of transformedSources) { + const selectedScales = selectScales(scales); + for (const { tsource, scaleIndex } of selectedScales) { + const source = + tsource.source as SpatiallyIndexedSkeletonSourceBackend; + const { chunkLayout } = tsource; + chunkLayout.globalToLocalSpatial(localCenter, centerDataPosition); + const { size, finiteRank } = chunkLayout; + vec3.copy(chunkSize, size); + for (let i = finiteRank; i < 3; ++i) { + chunkSize[i] = 0; + localCenter[i] = 0; + } + const sourceBasePriority = + basePriority + SCALE_PRIORITY_MULTIPLIER * scaleIndex; + source.currentLod = lodValue; + source.currentRequestGeneration = currentGeneration; + source.currentRequestOwner = + SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_3D; + forEachVisibleVolumetricChunk( + projectionParameters, + this.localPosition.value, + tsource, + () => { + const chunk = source.getChunk(tsource.curPositionInChunks); + ++this.numVisibleChunksNeeded; + if (chunk.state === ChunkState.GPU_MEMORY) { + ++this.numVisibleChunksAvailable; + } + const priority = getSpatiallyIndexedSkeletonChunkPriority( + localCenter, + chunkSize, + tsource.curPositionInChunks, + ); + chunkManager.requestChunk( + chunk, + priorityTier, + sourceBasePriority + priority, + ); + }, + ); + } + } + } + } +} + +@registerSharedObject(SPATIALLY_INDEXED_SKELETON_SLICEVIEW_RENDER_LAYER_RPC_ID) +export class SpatiallyIndexedSkeletonSliceViewRenderLayerBackend extends SliceViewRenderLayerBackend { + skeletonGridLevel: SharedWatchableValue; + skeletonLod: SharedWatchableValue; + private chunkManager_: ChunkManager; + private pendingLodCleanup = false; + private trackedSources = new Set(); + + constructor(rpc: RPC, options: any) { + super(rpc, options); + this.skeletonGridLevel = rpc.get(options.skeletonGridLevel); + this.skeletonLod = rpc.get(options.skeletonLod); + const chunkManager = rpc.get(options.chunkManager); + this.chunkManager_ = chunkManager; + const scheduleUpdateChunkPriorities = () => + chunkManager.scheduleUpdateChunkPriorities(); + this.registerDisposer( + this.skeletonGridLevel.changed.add(scheduleUpdateChunkPriorities), + ); + // Debounce LOD changes to avoid making requests for every slider value. + const debouncedLodUpdate = debounce(() => { + scheduleUpdateChunkPriorities(); + }, SPATIALLY_INDEXED_SKELETON_LOD_DEBOUNCE_MS); + this.registerDisposer(() => debouncedLodUpdate.cancel()); + + this.registerDisposer( + this.skeletonLod.changed.add(() => { + this.pendingLodCleanup = true; + debouncedLodUpdate(); + }), + ); + this.registerDisposer( + chunkManager.recomputeChunkPrioritiesLate.add(() => { + if (!this.pendingLodCleanup) return; + cancelStaleSpatiallyIndexedSkeletonDownloads( + chunkManager, + this.trackedSources, + chunkManager.recomputeChunkPriorities.count, + ); + this.pendingLodCleanup = false; + }), + ); + } + + prepareChunkSourceForRequest(source: SpatiallyIndexedSkeletonSourceBackend) { + this.trackedSources.add(source); + source.currentLod = this.skeletonLod.value; + source.currentRequestGeneration = + this.chunkManager_.recomputeChunkPriorities.count; + source.currentRequestOwner = + SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_2D; + } + + filterVisibleSources( + sliceView: SliceViewBase, + sources: readonly TransformedSource[], + ): Iterable { + const lodValue = this.skeletonLod.value; + for (const tsource of sources) { + const source = tsource.source as SpatiallyIndexedSkeletonSourceBackend; + this.trackedSources.add(source); + source.currentLod = lodValue; + source.currentRequestGeneration = + this.chunkManager_.recomputeChunkPriorities.count; + source.currentRequestOwner = + SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_2D; + } + + if ( + sources.length > 0 && + sources.every( + (source) => getSpatiallyIndexedSkeletonGridIndex(source) !== undefined, + ) + ) { + return selectSpatiallyIndexedSkeletonEntriesByGrid( + sources, + this.skeletonGridLevel.value, + getSpatiallyIndexedSkeletonGridIndex, + ); + } + return super.filterVisibleSources(sliceView, sources); + } +} diff --git a/src/skeleton/base.ts b/src/skeleton/base.ts index 0809326658..a848400a10 100644 --- a/src/skeleton/base.ts +++ b/src/skeleton/base.ts @@ -18,6 +18,13 @@ import type { DataType } from "#src/util/data_type.js"; export const SKELETON_LAYER_RPC_ID = "skeleton/SkeletonLayer"; +export const SPATIALLY_INDEXED_SKELETON_RENDER_LAYER_RPC_ID = + "skeleton/SpatiallyIndexedSkeletonRenderLayer"; +export const SPATIALLY_INDEXED_SKELETON_RENDER_LAYER_UPDATE_SOURCES_RPC_ID = + "skeleton/SpatiallyIndexedSkeletonRenderLayer.updateSources"; +export const SPATIALLY_INDEXED_SKELETON_SLICEVIEW_RENDER_LAYER_RPC_ID = + "skeleton/SpatiallyIndexedSkeletonSliceViewRenderLayer"; + export interface VertexAttributeInfo { dataType: DataType; numComponents: number; diff --git a/src/skeleton/command_history.spec.ts b/src/skeleton/command_history.spec.ts new file mode 100644 index 0000000000..ef3d36aaaa --- /dev/null +++ b/src/skeleton/command_history.spec.ts @@ -0,0 +1,157 @@ +/** + * @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 { describe, expect, it } from "vitest"; + +import { + SpatialSkeletonCommandHistory, + type SpatialSkeletonCommand, +} from "#src/skeleton/command_history.js"; + +function deferred() { + let resolve: (() => void) | undefined; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { + promise, + resolve: () => resolve?.(), + }; +} + +describe("skeleton/command_history", () => { + it("serializes execution, updates labels, and truncates redo on a new edit", async () => { + const history = new SpatialSkeletonCommandHistory(); + const events: string[] = []; + const firstExecute = deferred(); + const firstCommand: SpatialSkeletonCommand = { + label: "First command", + execute: async () => { + events.push("first:start"); + await firstExecute.promise; + events.push("first:end"); + }, + undo: async () => { + events.push("first:undo"); + }, + }; + const secondCommand: SpatialSkeletonCommand = { + label: "Second command", + execute: async () => { + events.push("second:execute"); + }, + undo: async () => { + events.push("second:undo"); + }, + }; + const thirdCommand: SpatialSkeletonCommand = { + label: "Third command", + execute: async () => { + events.push("third:execute"); + }, + undo: async () => { + events.push("third:undo"); + }, + }; + + const firstPromise = history.execute(firstCommand); + const secondPromise = history.execute(secondCommand); + + expect(history.isBusy.value).toBe(true); + expect(events).toEqual(["first:start"]); + + firstExecute.resolve(); + await firstPromise; + await secondPromise; + + expect(events).toEqual(["first:start", "first:end", "second:execute"]); + expect(history.canUndo.value).toBe(true); + expect(history.undoLabel.value).toBe("Second command"); + + await history.undo(); + + expect(events).toEqual([ + "first:start", + "first:end", + "second:execute", + "second:undo", + ]); + expect(history.canRedo.value).toBe(true); + expect(history.redoLabel.value).toBe("Second command"); + + await history.execute(thirdCommand); + + expect(events).toEqual([ + "first:start", + "first:end", + "second:execute", + "second:undo", + "third:execute", + ]); + expect(history.canRedo.value).toBe(false); + expect(history.redoLabel.value).toBeUndefined(); + expect(history.undoLabel.value).toBe("Third command"); + }); + + it("keeps remapped node and segment ids across undo and redo", async () => { + const history = new SpatialSkeletonCommandHistory(); + let nextNodeId = 100; + let nextSegmentId = 200; + const command: SpatialSkeletonCommand = { + label: "Remap ids", + execute: async ({ mappings }) => { + mappings.remapNodeId(11, nextNodeId++); + mappings.remapSegmentId(21, nextSegmentId++); + }, + undo: async ({ mappings }) => { + mappings.remapNodeId(11, nextNodeId++); + mappings.remapSegmentId(21, nextSegmentId++); + }, + }; + + await history.execute(command); + expect(history.mappings.resolveNodeId(11)).toBe(100); + expect(history.mappings.resolveSegmentId(21)).toBe(200); + expect(history.mappings.getStableNodeId(100)).toBe(11); + expect(history.mappings.getStableSegmentId(200)).toBe(21); + + await history.undo(); + expect(history.mappings.resolveNodeId(11)).toBe(101); + expect(history.mappings.resolveSegmentId(21)).toBe(201); + + await history.redo(); + expect(history.mappings.resolveNodeId(11)).toBe(102); + expect(history.mappings.resolveSegmentId(21)).toBe(202); + }); + + it("restores mapping state if an operation fails", async () => { + const history = new SpatialSkeletonCommandHistory(); + history.mappings.remapNodeId(11, 99); + + const failingCommand: SpatialSkeletonCommand = { + label: "Failing command", + execute: async ({ mappings }) => { + mappings.remapNodeId(11, 100); + throw new Error("boom"); + }, + undo: async () => {}, + }; + + await expect(history.execute(failingCommand)).rejects.toThrow("boom"); + expect(history.mappings.resolveNodeId(11)).toBe(99); + expect(history.canUndo.value).toBe(false); + }); +}); diff --git a/src/skeleton/command_history.ts b/src/skeleton/command_history.ts new file mode 100644 index 0000000000..4ca7093210 --- /dev/null +++ b/src/skeleton/command_history.ts @@ -0,0 +1,326 @@ +/** + * @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 { WatchableValue } from "#src/trackable_value.js"; +import { RefCounted } from "#src/util/disposable.js"; + +export const SPATIAL_SKELETON_COMMAND_HISTORY_MAX_ENTRIES = 100; + +export interface SpatialSkeletonCommandContext { + readonly mappings: SpatialSkeletonCommandMappings; +} + +export interface SpatialSkeletonCommand { + readonly label: string; + execute(context: SpatialSkeletonCommandContext): Promise; + undo(context: SpatialSkeletonCommandContext): Promise; + redo?(context: SpatialSkeletonCommandContext): Promise; +} + +interface SpatialSkeletonCommandMappingSnapshot { + nodeIdMappings: Array<[number, number]>; + segmentIdMappings: Array<[number, number]>; +} + +function normalizeIdentifier(value: number | undefined) { + if (value === undefined) return undefined; + const normalizedValue = Math.round(Number(value)); + if (!Number.isSafeInteger(normalizedValue) || normalizedValue <= 0) { + return undefined; + } + return normalizedValue; +} + +function resolveIdentifierMapping( + mappings: Map, + value: number | undefined, +) { + let currentValue = normalizeIdentifier(value); + if (currentValue === undefined) { + return undefined; + } + const seen = new Set(); + while (true) { + const nextValue = mappings.get(currentValue); + if (nextValue === undefined || seen.has(currentValue)) { + return currentValue; + } + seen.add(currentValue); + currentValue = nextValue; + } +} + +function findStableIdentifier( + mappings: Map, + value: number | undefined, +) { + const currentValue = normalizeIdentifier(value); + if (currentValue === undefined) { + return undefined; + } + let stableValue = currentValue; + for (const candidate of mappings.keys()) { + if (resolveIdentifierMapping(mappings, candidate) !== currentValue) { + continue; + } + if (candidate < stableValue) { + stableValue = candidate; + } + } + return stableValue; +} + +export class SpatialSkeletonCommandMappings { + private nodeIdMappings = new Map(); + private segmentIdMappings = new Map(); + + clear() { + this.nodeIdMappings.clear(); + this.segmentIdMappings.clear(); + } + + cloneSnapshot(): SpatialSkeletonCommandMappingSnapshot { + return { + nodeIdMappings: [...this.nodeIdMappings.entries()], + segmentIdMappings: [...this.segmentIdMappings.entries()], + }; + } + + restoreSnapshot(snapshot: SpatialSkeletonCommandMappingSnapshot) { + this.nodeIdMappings = new Map(snapshot.nodeIdMappings); + this.segmentIdMappings = new Map(snapshot.segmentIdMappings); + } + + resolveNodeId(nodeId: number | undefined) { + return resolveIdentifierMapping(this.nodeIdMappings, nodeId); + } + + resolveSegmentId(segmentId: number | undefined) { + return resolveIdentifierMapping(this.segmentIdMappings, segmentId); + } + + getStableNodeId(nodeId: number | undefined) { + return findStableIdentifier(this.nodeIdMappings, nodeId); + } + + getStableSegmentId(segmentId: number | undefined) { + return findStableIdentifier(this.segmentIdMappings, segmentId); + } + + getStableOrCurrentNodeId(nodeId: number | undefined) { + return this.getStableNodeId(nodeId) ?? normalizeIdentifier(nodeId); + } + + getStableOrCurrentSegmentId(segmentId: number | undefined) { + return this.getStableSegmentId(segmentId) ?? normalizeIdentifier(segmentId); + } + + remapNodeId(originalNodeId: number | undefined, currentNodeId: number) { + const normalizedOriginalNodeId = normalizeIdentifier(originalNodeId); + const normalizedCurrentNodeId = normalizeIdentifier(currentNodeId); + if ( + normalizedOriginalNodeId === undefined || + normalizedCurrentNodeId === undefined + ) { + return false; + } + if (normalizedOriginalNodeId === normalizedCurrentNodeId) { + return this.nodeIdMappings.delete(normalizedOriginalNodeId); + } + if ( + this.nodeIdMappings.get(normalizedOriginalNodeId) === + normalizedCurrentNodeId + ) { + return false; + } + this.nodeIdMappings.set(normalizedOriginalNodeId, normalizedCurrentNodeId); + return true; + } + + remapSegmentId( + originalSegmentId: number | undefined, + currentSegmentId: number, + ) { + const normalizedOriginalSegmentId = normalizeIdentifier(originalSegmentId); + const normalizedCurrentSegmentId = normalizeIdentifier(currentSegmentId); + if ( + normalizedOriginalSegmentId === undefined || + normalizedCurrentSegmentId === undefined + ) { + return false; + } + if (normalizedOriginalSegmentId === normalizedCurrentSegmentId) { + return this.segmentIdMappings.delete(normalizedOriginalSegmentId); + } + if ( + this.segmentIdMappings.get(normalizedOriginalSegmentId) === + normalizedCurrentSegmentId + ) { + return false; + } + this.segmentIdMappings.set( + normalizedOriginalSegmentId, + normalizedCurrentSegmentId, + ); + return true; + } +} + +interface SpatialSkeletonCommandHistoryEntry { + readonly command: SpatialSkeletonCommand; +} + +export class SpatialSkeletonCommandHistory extends RefCounted { + readonly canUndo = new WatchableValue(false); + readonly canRedo = new WatchableValue(false); + readonly isBusy = new WatchableValue(false); + readonly undoLabel = new WatchableValue(undefined); + readonly redoLabel = new WatchableValue(undefined); + readonly mappings = new SpatialSkeletonCommandMappings(); + + private undoEntries: SpatialSkeletonCommandHistoryEntry[] = []; + private redoEntries: SpatialSkeletonCommandHistoryEntry[] = []; + private operationQueue = Promise.resolve(); + private pendingOperations = 0; + private source: unknown; + private readonly maxEntries = SPATIAL_SKELETON_COMMAND_HISTORY_MAX_ENTRIES; + + private updateState() { + const canUndo = this.undoEntries.length > 0; + const canRedo = this.redoEntries.length > 0; + const undoLabel = this.undoEntries.at(-1)?.command.label; + const redoLabel = this.redoEntries.at(-1)?.command.label; + if (this.canUndo.value !== canUndo) { + this.canUndo.value = canUndo; + } + if (this.canRedo.value !== canRedo) { + this.canRedo.value = canRedo; + } + if (this.undoLabel.value !== undoLabel) { + this.undoLabel.value = undoLabel; + } + if (this.redoLabel.value !== redoLabel) { + this.redoLabel.value = redoLabel; + } + } + + private async runOperation(operation: () => Promise) { + const previousOperation = this.operationQueue.catch(() => undefined); + const startsImmediately = this.pendingOperations === 0; + this.pendingOperations += 1; + if (!this.isBusy.value) { + this.isBusy.value = true; + } + const run = async () => { + try { + return await operation(); + } finally { + this.pendingOperations -= 1; + if (this.pendingOperations === 0 && this.isBusy.value) { + this.isBusy.value = false; + } + this.updateState(); + } + }; + const result = startsImmediately ? run() : previousOperation.then(run); + this.operationQueue = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private trimUndoEntries() { + if (this.undoEntries.length <= this.maxEntries) { + return; + } + this.undoEntries.splice(0, this.undoEntries.length - this.maxEntries); + } + + clear() { + this.undoEntries = []; + this.redoEntries = []; + this.mappings.clear(); + this.updateState(); + } + + setSource(source: unknown) { + if (this.source === source) { + return false; + } + this.source = source; + this.clear(); + return true; + } + + execute(command: SpatialSkeletonCommand) { + return this.runOperation(async () => { + const mappingSnapshot = this.mappings.cloneSnapshot(); + try { + await command.execute({ mappings: this.mappings }); + } catch (error) { + this.mappings.restoreSnapshot(mappingSnapshot); + throw error; + } + this.redoEntries = []; + this.undoEntries.push({ command }); + this.trimUndoEntries(); + }); + } + + undo() { + return this.runOperation(async () => { + const entry = this.undoEntries.at(-1); + if (entry === undefined) { + return false; + } + const mappingSnapshot = this.mappings.cloneSnapshot(); + try { + await entry.command.undo({ mappings: this.mappings }); + } catch (error) { + this.mappings.restoreSnapshot(mappingSnapshot); + throw error; + } + this.undoEntries.pop(); + this.redoEntries.push(entry); + return true; + }); + } + + redo() { + return this.runOperation(async () => { + const entry = this.redoEntries.at(-1); + if (entry === undefined) { + return false; + } + const mappingSnapshot = this.mappings.cloneSnapshot(); + try { + if (entry.command.redo !== undefined) { + await entry.command.redo({ mappings: this.mappings }); + } else { + await entry.command.execute({ mappings: this.mappings }); + } + } catch (error) { + this.mappings.restoreSnapshot(mappingSnapshot); + throw error; + } + this.redoEntries.pop(); + this.undoEntries.push(entry); + this.trimUndoEntries(); + return true; + }); + } +} diff --git a/src/skeleton/edit_mode_rendering.ts b/src/skeleton/edit_mode_rendering.ts new file mode 100644 index 0000000000..6e2f10ca7d --- /dev/null +++ b/src/skeleton/edit_mode_rendering.ts @@ -0,0 +1,27 @@ +import { SkeletonRenderMode } from "#src/skeleton/render_mode.js"; + +export interface SkeletonModeLayerLike { + displayState: { + skeletonRenderingOptions: { + params2d: { + mode: { + value: SkeletonRenderMode; + }; + }; + params3d: { + mode: { + value: SkeletonRenderMode; + }; + }; + }; + }; +} + +export function setSpatialSkeletonModesToLinesAndPoints( + layer: SkeletonModeLayerLike, +) { + layer.displayState.skeletonRenderingOptions.params2d.mode.value = + SkeletonRenderMode.LINES_AND_POINTS; + layer.displayState.skeletonRenderingOptions.params3d.mode.value = + SkeletonRenderMode.LINES_AND_POINTS; +} diff --git a/src/skeleton/edit_state.ts b/src/skeleton/edit_state.ts new file mode 100644 index 0000000000..653ae0632f --- /dev/null +++ b/src/skeleton/edit_state.ts @@ -0,0 +1,134 @@ +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; +} + +export function findSpatiallyIndexedSkeletonNode( + segmentNodes: readonly SpatiallyIndexedSkeletonNode[], + nodeId: number, +) { + return segmentNodes.find((node) => node.nodeId === nodeId); +} + +export function getSpatiallyIndexedSkeletonDirectChildren( + segmentNodes: readonly SpatiallyIndexedSkeletonNode[], + nodeId: number, +) { + return segmentNodes + .filter((node) => node.parentNodeId === nodeId) + .sort((a, b) => a.nodeId - b.nodeId); +} + +export function getSpatiallyIndexedSkeletonNodeParent( + segmentNodes: readonly SpatiallyIndexedSkeletonNode[], + node: SpatiallyIndexedSkeletonNode, +) { + if (node.parentNodeId === undefined) { + return undefined; + } + 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, +) { + const path = [node]; + const visited = new Set([node.nodeId]); + let currentNode = node; + while (true) { + const parentNode = getSpatiallyIndexedSkeletonNodeParent( + segmentNodes, + currentNode, + ); + if (parentNode === undefined || visited.has(parentNode.nodeId)) { + return path; + } + path.push(parentNode); + visited.add(parentNode.nodeId); + 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 new file mode 100644 index 0000000000..0f5e38770e --- /dev/null +++ b/src/skeleton/frontend.spec.ts @@ -0,0 +1,216 @@ +import { describe, expect, it, vi } from "vitest"; + +import { resolveSpatiallyIndexedSkeletonSegmentPick } from "#src/skeleton/picking.js"; +import { spatiallyIndexedSkeletonTextureAttributeSpecs } from "#src/skeleton/spatial_attribute_layout.js"; +import { WatchableValue } from "#src/trackable_value.js"; +import { Uint64Set } from "#src/uint64_set.js"; +import { DataType } from "#src/util/data_type.js"; +import { getObjectId } from "#src/util/object_id.js"; + +if (!("WebGL2RenderingContext" in globalThis)) { + Object.defineProperty(globalThis, "WebGL2RenderingContext", { + value: new Proxy(class WebGL2RenderingContext {} as any, { + get(target, property, receiver) { + if (Reflect.has(target, property)) { + return Reflect.get(target, property, receiver); + } + return 0; + }, + }), + configurable: true, + }); +} + +const { SpatiallyIndexedSkeletonLayer } = await import( + "#src/skeleton/frontend.js" +); + +describe("resolveSpatiallyIndexedSkeletonSegmentPick", () => { + it("returns the node segment id for direct node picks", () => { + const chunk = { + indices: new Uint32Array([0, 1, 1, 2]), + numVertices: 3, + }; + const segmentIds = new Uint32Array([11, 13, 17]); + + expect( + resolveSpatiallyIndexedSkeletonSegmentPick(chunk, segmentIds, 1, "node"), + ).toBe(13); + }); + + it("returns the first valid endpoint segment id for direct edge picks", () => { + const chunk = { + indices: new Uint32Array([0, 1, 1, 2]), + numVertices: 3, + }; + const segmentIds = new Uint32Array([0, 19, 23]); + + expect( + resolveSpatiallyIndexedSkeletonSegmentPick(chunk, segmentIds, 0, "edge"), + ).toBe(19); + expect( + resolveSpatiallyIndexedSkeletonSegmentPick(chunk, segmentIds, 1, "edge"), + ).toBe(19); + }); + + it("returns undefined for out-of-range direct picks", () => { + const chunk = { + indices: new Uint32Array([0, 1]), + numVertices: 2, + }; + const segmentIds = new Uint32Array([5, 7]); + + expect( + resolveSpatiallyIndexedSkeletonSegmentPick(chunk, segmentIds, 4, "node"), + ).toBeUndefined(); + expect( + resolveSpatiallyIndexedSkeletonSegmentPick(chunk, segmentIds, 2, "edge"), + ).toBeUndefined(); + }); +}); + +describe("SpatiallyIndexedSkeletonLayer browse node picks", () => { + it("resolves browse node picks with node id and revision token", () => { + const positions = new Float32Array([1, 2, 3, 4, 5, 6]); + const segmentIds = new Uint32Array([11, 17]); + const vertexBytes = new Uint8Array( + positions.byteLength + segmentIds.byteLength, + ); + vertexBytes.set(new Uint8Array(positions.buffer), 0); + vertexBytes.set(new Uint8Array(segmentIds.buffer), positions.byteLength); + const chunk = { + vertexAttributes: vertexBytes, + vertexAttributeOffsets: new Uint32Array([0, positions.byteLength]), + numVertices: 2, + indices: new Uint32Array([0, 1]), + nodeIds: new Int32Array([101, 202]), + nodeRevisionTokens: ["2026-03-29T11:50:00Z", "2026-03-29T11:51:00Z"], + }; + const layer = Object.create(SpatiallyIndexedSkeletonLayer.prototype); + + expect((layer as any).resolveNodePickFromChunk(chunk, 1)).toEqual({ + nodeId: 202, + segmentId: 17, + position: new Float32Array([4, 5, 6]), + revisionToken: "2026-03-29T11:51:00Z", + }); + }); +}); + +describe("spatiallyIndexedSkeletonTextureAttributeSpecs", () => { + it("keeps the browse path upload layout to position plus segment", () => { + expect(spatiallyIndexedSkeletonTextureAttributeSpecs).toEqual([ + { name: "position", dataType: DataType.FLOAT32, numComponents: 3 }, + { name: "segment", dataType: DataType.UINT32, numComponents: 1 }, + ]); + }); +}); + +describe("SpatiallyIndexedSkeletonLayer browse exclusions", () => { + it("includes suppressed browse segments even when no overlay segment is loaded", () => { + const layer = Object.assign( + Object.create(SpatiallyIndexedSkeletonLayer.prototype), + { + suppressedBrowseSegmentIds: new Set(), + browseExcludedSegments: new Uint64Set(), + browseExcludedSegmentsKey: undefined, + redrawNeeded: { dispatch: vi.fn() }, + getLoadedOverlaySegmentIds: () => [], + }, + ); + + expect(layer.suppressBrowseSegment(29)).toBe(true); + expect(layer.redrawNeeded.dispatch).toHaveBeenCalledTimes(1); + + const excludedSegments = (layer as any).getBrowsePassExcludedSegments(); + expect(excludedSegments).toBeInstanceOf(Uint64Set); + expect([...excludedSegments]).toEqual([29n]); + }); +}); + +describe("SpatiallyIndexedSkeletonLayer chunk stats", () => { + it("dedupes 2d chunk stats across rendered views for the selected grid source", () => { + const coarseSource = { parameters: { gridIndex: 0 } }; + const fineSource = { parameters: { gridIndex: 1 } }; + const coarseSourceId = getObjectId(coarseSource); + const fineSourceId = getObjectId(fineSource); + const layer = Object.assign( + Object.create(SpatiallyIndexedSkeletonLayer.prototype), + { + displayState: { + spatialSkeletonGridLevel2d: { value: 1 }, + spatialSkeletonGridLevel3d: { value: 0 }, + spatialSkeletonGridChunkStats2d: new WatchableValue({ + presentCount: 0, + totalCount: 0, + }), + spatialSkeletonGridChunkStats3d: new WatchableValue({ + presentCount: 0, + totalCount: 0, + }), + }, + sources: [], + sources2d: [ + { + chunkSource: coarseSource, + chunkToMultiscaleTransform: {}, + }, + { + chunkSource: fineSource, + chunkToMultiscaleTransform: {}, + }, + ], + visibleChunkKeysByRenderedView: new Map(), + }, + ); + + (layer as any).setVisibleChunkKeysForRenderedView( + "2d", + 11, + new Map([ + [ + coarseSourceId, + { + presentChunkKeys: new Set(["ignored"]), + totalChunkKeys: new Set(["ignored"]), + }, + ], + [ + fineSourceId, + { + presentChunkKeys: new Set(["a"]), + totalChunkKeys: new Set(["a", "b"]), + }, + ], + ]), + ); + expect(layer.displayState.spatialSkeletonGridChunkStats2d.value).toEqual({ + presentCount: 1, + totalCount: 2, + }); + + (layer as any).setVisibleChunkKeysForRenderedView( + "2d", + 22, + new Map([ + [ + fineSourceId, + { + presentChunkKeys: new Set(["c"]), + totalChunkKeys: new Set(["b", "c"]), + }, + ], + ]), + ); + expect(layer.displayState.spatialSkeletonGridChunkStats2d.value).toEqual({ + presentCount: 2, + totalCount: 3, + }); + + (layer as any).clearVisibleChunkKeysForRenderedView("2d", 11); + expect(layer.displayState.spatialSkeletonGridChunkStats2d.value).toEqual({ + presentCount: 1, + totalCount: 2, + }); + }); +}); diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index 4da5d2e63e..93f64dd694 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -16,37 +16,125 @@ import { ChunkState, LayerChunkProgressInfo } from "#src/chunk_manager/base.js"; import type { ChunkManager } from "#src/chunk_manager/frontend.js"; -import { Chunk, ChunkSource } from "#src/chunk_manager/frontend.js"; -import type { LayerView, VisibleLayerInfo } from "#src/layer/index.js"; +import { + Chunk, + ChunkRenderLayerFrontend, + ChunkSource, +} from "#src/chunk_manager/frontend.js"; +import { GPUHashTable, HashSetShaderManager } from "#src/gpu_hash/shader.js"; +import type { + LayerView, + MouseSelectionState, + PickState, + UserLayer, + VisibleLayerInfo, +} from "#src/layer/index.js"; import type { PerspectivePanel } from "#src/perspective_view/panel.js"; -import type { PerspectiveViewRenderContext } from "#src/perspective_view/render_layer.js"; +import type { + PerspectiveViewReadyRenderContext, + PerspectiveViewRenderContext, +} from "#src/perspective_view/render_layer.js"; import { PerspectiveViewRenderLayer } from "#src/perspective_view/render_layer.js"; +import type { + ChunkTransformParameters, + RenderLayerTransform, +} from "#src/render_coordinate_transform.js"; +import { getChunkTransformParameters } from "#src/render_coordinate_transform.js"; +import { RENDERED_VIEW_ADD_LAYER_RPC_ID } from "#src/render_layer_common.js"; +import type { RenderScaleHistogram } from "#src/render_scale_statistics.js"; import type { RenderLayer, ThreeDimensionalRenderLayerAttachmentState, } from "#src/renderlayer.js"; import { update3dRenderLayerAttachment } from "#src/renderlayer.js"; +import { + SegmentColorShaderManager, + SegmentStatedColorShaderManager, +} from "#src/segment_color.js"; import { forEachVisibleSegment, + getVisibleSegments, getObjectKey, } from "#src/segmentation_display_state/base.js"; -import type { SegmentationDisplayState3D } from "#src/segmentation_display_state/frontend.js"; +import type { + SegmentationDisplayState3D, + SegmentationDisplayState, +} from "#src/segmentation_display_state/frontend.js"; import { forEachVisibleSegmentToDraw, registerRedrawWhenSegmentationDisplayState3DChanged, 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 { VertexAttributeInfo } from "#src/skeleton/base.js"; -import { SKELETON_LAYER_RPC_ID } from "#src/skeleton/base.js"; +import { + SKELETON_LAYER_RPC_ID, + SPATIALLY_INDEXED_SKELETON_RENDER_LAYER_RPC_ID, + SPATIALLY_INDEXED_SKELETON_RENDER_LAYER_UPDATE_SOURCES_RPC_ID, + SPATIALLY_INDEXED_SKELETON_SLICEVIEW_RENDER_LAYER_RPC_ID, +} from "#src/skeleton/base.js"; +import { uploadVertexAttributesToGPU } from "#src/skeleton/gpu_upload_utils.js"; +import { buildSpatiallyIndexedSkeletonOverlayGeometry } from "#src/skeleton/overlay_geometry.js"; +import { + DEFAULT_MAX_RETAINED_OVERLAY_SEGMENTS, + mergeSpatiallyIndexedSkeletonOverlaySegmentIds, + retainSpatiallyIndexedSkeletonOverlaySegment, +} from "#src/skeleton/overlay_segment_retention.js"; +import { resolveSpatiallyIndexedSkeletonSegmentPick } from "#src/skeleton/picking.js"; +import { SkeletonRenderMode } from "#src/skeleton/render_mode.js"; +import { + getSpatiallyIndexedSkeletonGridIndex, + getSpatiallyIndexedSkeletonSourceView, + selectSpatiallyIndexedSkeletonEntriesByGrid, + selectSpatiallyIndexedSkeletonEntriesForView, + type SpatiallyIndexedSkeletonView, +} from "#src/skeleton/source_selection.js"; +import { spatiallyIndexedSkeletonTextureAttributeSpecs } from "#src/skeleton/spatial_attribute_layout.js"; +import { + forEachPlaneIntersectingVolumetricChunk, + getNormalizedChunkLayout, + forEachVisibleVolumetricChunk, + type SliceViewBase, + type SliceViewChunkSpecification, + type TransformedSource, +} from "#src/sliceview/base.js"; +import type { ChunkLayout } from "#src/sliceview/chunk_layout.js"; +import type { SliceViewSingleResolutionSource } from "#src/sliceview/frontend.js"; +import { + getVolumetricTransformedSources, + serializeAllTransformedSources, + SliceViewChunk, + SliceViewChunkSource, + MultiscaleSliceViewChunkSource, +} from "#src/sliceview/frontend.js"; import type { SliceViewPanel } from "#src/sliceview/panel.js"; -import type { SliceViewPanelRenderContext } from "#src/sliceview/renderlayer.js"; -import { SliceViewPanelRenderLayer } from "#src/sliceview/renderlayer.js"; -import { TrackableValue, WatchableValue } from "#src/trackable_value.js"; -import { DataType } from "#src/util/data_type.js"; +import type { + SliceViewPanelRenderContext, + SliceViewRenderContext, + SliceViewPanelReadyRenderContext, +} from "#src/sliceview/renderlayer.js"; +import { + SliceViewPanelRenderLayer, + SliceViewRenderLayer, +} from "#src/sliceview/renderlayer.js"; +import type { WatchableValueInterface } from "#src/trackable_value.js"; +import { + makeCachedLazyDerivedWatchableValue, + TrackableValue, + WatchableValue, + registerNested, +} from "#src/trackable_value.js"; +import { Uint64Set } from "#src/uint64_set.js"; +import { gatherUpdate } from "#src/util/array.js"; +import { DATA_TYPE_SIGNED, DataType } from "#src/util/data_type.js"; import { RefCounted } from "#src/util/disposable.js"; -import type { vec3 } from "#src/util/geom.js"; +import type { ValueOrError } from "#src/util/error.js"; +import { makeValueOrError, valueOrThrow } from "#src/util/error.js"; import { mat4 } from "#src/util/geom.js"; import { verifyFinitePositiveFloat } from "#src/util/json.js"; +import * as matrix from "#src/util/matrix.js"; +import { getObjectId } from "#src/util/object_id.js"; import { NullarySignal } from "#src/util/signal.js"; import type { Trackable } from "#src/util/trackable.js"; import { CompoundTrackable } from "#src/util/trackable.js"; @@ -75,6 +163,10 @@ import type { ShaderProgram, ShaderSamplerType, } from "#src/webgl/shader.js"; +import { + dataTypeShaderDefinition, + getShaderType, +} from "#src/webgl/shader_lib.js"; import type { ShaderControlsBuilderState } from "#src/webgl/shader_ui_controls.js"; import { addControlsToBuilder, @@ -87,18 +179,23 @@ import { computeTextureFormat, getSamplerPrefixForDataType, OneDimensionalTextureAccessHelper, - setOneDimensionalTextureData, TextureFormat, } from "#src/webgl/texture_access.js"; import { defineVertexId, VertexIdHelper } from "#src/webgl/vertex_id.js"; +import type { RPC } from "#src/worker_rpc.js"; const tempMat2 = mat4.create(); - const DEFAULT_FRAGMENT_MAIN = `void main() { emitDefault(); } `; +const SELECTED_NODE_OUTLINE_COLOR_RGB = "1.0, 0.95, 0.35"; +const SELECTED_NODE_OUTLINE_MIN_WIDTH_2D = "1.75"; +const SELECTED_NODE_OUTLINE_MAX_WIDTH_2D = "3.0"; +const SELECTED_NODE_OUTLINE_MIN_WIDTH_3D = "1.5"; +const SELECTED_NODE_OUTLINE_MAX_WIDTH_3D = "2.5"; + interface VertexAttributeRenderInfo extends VertexAttributeInfo { name: string; webglDataType: number; @@ -112,12 +209,89 @@ const vertexPositionTextureFormat = computeTextureFormat( DataType.FLOAT32, 3, ); +const segmentTextureFormat = computeTextureFormat( + new TextureFormat(), + DataType.UINT32, + 1, +); +const selectedNodeTextureFormat = computeTextureFormat( + new TextureFormat(), + DataType.FLOAT32, + 1, +); + +interface SkeletonLayerInterface { + vertexAttributes: VertexAttributeRenderInfo[]; + segmentColorAttributeIndex?: number; + dynamicSegmentAppearance?: boolean; + gl: GL; + fallbackShaderParameters: WatchableValue; + displayState: SkeletonLayerDisplayState; +} + +interface SkeletonChunkInterface { + vertexAttributeTextures: (WebGLTexture | null)[]; + indexBuffer: GLBuffer; + numIndices: number; + numVertices: number; + pickNodeIds?: Int32Array; + pickNodePositions?: Float32Array; + pickSegmentIds?: Uint32Array; + pickEdgeSegmentIds?: Uint32Array; +} + +interface SkeletonChunkData { + vertexAttributes: Uint8Array; + indices: Uint32Array; + numVertices: number; + vertexAttributeOffsets: Uint32Array; + nodeIds?: Int32Array; + nodeRevisionTokens?: Array; +} + +type SpatiallyIndexedSkeletonPickData = + | { + kind: "node"; + nodeIds: Int32Array; + nodePositions: Float32Array; + segmentIds: Uint32Array; + } + | { + kind: "edge"; + segmentIds: Uint32Array; + } + | { + kind: "segment-node"; + chunk: SpatiallyIndexedSkeletonChunk; + } + | { + kind: "segment-edge"; + chunk: SpatiallyIndexedSkeletonChunk; + }; class RenderHelper extends RefCounted { private textureAccessHelper = new OneDimensionalTextureAccessHelper( "vertexData", ); private vertexIdHelper; + private dynamicSegmentAppearance: boolean; + private segmentAttributeIndex: number | undefined; + private segmentColorAttributeIndex: number | undefined; + private selectedNodeAttributeIndex: number | undefined; + private visibleSegmentsShaderManager = new HashSetShaderManager( + "visibleSegments", + ); + private excludedSegmentsShaderManager = new HashSetShaderManager( + "excludedSegments", + ); + private segmentColorShaderManager = new SegmentColorShaderManager( + "segmentColorHash", + ); + private segmentStatedColorShaderManager = new SegmentStatedColorShaderManager( + "segmentStatedColor", + ); + private gpuSegmentStatedColorHashTable: GPUHashTable | undefined; + private emptySegmentSet = new Uint64Set(); get vertexAttributes(): VertexAttributeRenderInfo[] { return this.base.vertexAttributes; } @@ -129,6 +303,14 @@ class RenderHelper extends RefCounted { builder.addUniform("highp uint", "uPickID"); } + private getSegmentColorExpression() { + const index = this.segmentColorAttributeIndex; + if (index === undefined) { + return "uColor"; + } + return `vCustom${index}`; + } + edgeShaderGetter; nodeShaderGetter; @@ -136,18 +318,178 @@ class RenderHelper extends RefCounted { return this.base.gl; } + disposed() { + this.gpuSegmentStatedColorHashTable?.dispose(); + super.disposed(); + } + + private defineDynamicSegmentAppearance(builder: ShaderBuilder) { + this.visibleSegmentsShaderManager.defineShader(builder); + this.excludedSegmentsShaderManager.defineShader(builder); + this.segmentColorShaderManager.defineShader(builder); + this.segmentStatedColorShaderManager.defineShader(builder); + builder.addUniform("highp float", "uVisibleAlpha"); + builder.addUniform("highp float", "uHiddenAlpha"); + builder.addUniform("highp vec3", "uSegmentDefaultColor"); + builder.addUniform("highp uint", "uSkipVisibleSegments"); + builder.addUniform("highp uint", "uUseSegmentDefaultColor"); + builder.addUniform("highp uint", "uUseSegmentStatedColors"); + builder.addFragmentCode(` +uint64_t getSegmentAppearanceId(highp uint segmentValue) { + return uint64_t(uvec2(segmentValue, 0u)); +} +vec3 getSegmentLookupColor(uint64_t segmentId) { + vec4 statedColor; + if ( + uUseSegmentStatedColors != 0u && + ${this.segmentStatedColorShaderManager.getFunctionName}(segmentId, statedColor) + ) { + return statedColor.rgb; + } + if (uUseSegmentDefaultColor != 0u) { + return uSegmentDefaultColor; + } + return ${this.segmentColorShaderManager.prefix}(segmentId); +} +float getSegmentLookupAlpha(uint64_t segmentId) { + if (${this.excludedSegmentsShaderManager.hasFunctionName}(segmentId)) { + return 0.0; + } + bool isVisible = ${this.visibleSegmentsShaderManager.hasFunctionName}(segmentId); + if (uSkipVisibleSegments != 0u && isVisible) { + return 0.0; + } + return isVisible ? uVisibleAlpha : uHiddenAlpha; +} +vec4 getSegmentAppearance(highp uint segmentValue) { + uint64_t segmentId = getSegmentAppearanceId(segmentValue); + return vec4(getSegmentLookupColor(segmentId), getSegmentLookupAlpha(segmentId)); +} +`); + } + + enableDynamicSegmentAppearance( + gl: GL, + shader: ShaderProgram, + skipVisibleSegments: boolean, + excludedSegments?: Uint64Set, + ) { + if (!this.dynamicSegmentAppearance) return; + const segmentationGroupState = + this.base.displayState.segmentationGroupState.value; + const visibleSegments = segmentationGroupState.useTemporaryVisibleSegments + .value + ? segmentationGroupState.temporaryVisibleSegments + : segmentationGroupState.visibleSegments; + this.visibleSegmentsShaderManager.enable( + gl, + shader, + GPUHashTable.get(gl, visibleSegments.hashTable), + ); + this.excludedSegmentsShaderManager.enable( + gl, + shader, + GPUHashTable.get( + gl, + (excludedSegments ?? this.emptySegmentSet).hashTable, + ), + ); + gl.uniform1f( + shader.uniform("uVisibleAlpha"), + this.base.displayState.objectAlpha.value, + ); + gl.uniform1f( + shader.uniform("uHiddenAlpha"), + this.base.displayState.hiddenObjectAlpha?.value ?? 0, + ); + gl.uniform1ui( + shader.uniform("uSkipVisibleSegments"), + skipVisibleSegments ? 1 : 0, + ); + + const colorGroupState = + this.base.displayState.segmentationColorGroupState.value; + this.segmentColorShaderManager.enable( + gl, + shader, + colorGroupState.segmentColorHash.value, + ); + const segmentDefaultColor = colorGroupState.segmentDefaultColor.value; + if (segmentDefaultColor === undefined) { + gl.uniform1ui(shader.uniform("uUseSegmentDefaultColor"), 0); + } else { + gl.uniform1ui(shader.uniform("uUseSegmentDefaultColor"), 1); + gl.uniform3f( + shader.uniform("uSegmentDefaultColor"), + segmentDefaultColor[0], + segmentDefaultColor[1], + segmentDefaultColor[2], + ); + } + + const segmentStatedColors = colorGroupState.segmentStatedColors; + if (segmentStatedColors.size === 0) { + gl.uniform1ui(shader.uniform("uUseSegmentStatedColors"), 0); + this.segmentStatedColorShaderManager.disable(gl, shader); + return; + } + gl.uniform1ui(shader.uniform("uUseSegmentStatedColors"), 1); + let { gpuSegmentStatedColorHashTable } = this; + if ( + gpuSegmentStatedColorHashTable === undefined || + gpuSegmentStatedColorHashTable.hashTable !== segmentStatedColors.hashTable + ) { + gpuSegmentStatedColorHashTable?.dispose(); + this.gpuSegmentStatedColorHashTable = gpuSegmentStatedColorHashTable = + GPUHashTable.get(gl, segmentStatedColors.hashTable); + } + this.segmentStatedColorShaderManager.enable( + gl, + shader, + gpuSegmentStatedColorHashTable, + ); + } + + disableDynamicSegmentAppearance(gl: GL, shader: ShaderProgram) { + if (!this.dynamicSegmentAppearance) return; + this.visibleSegmentsShaderManager.disable(gl, shader); + this.excludedSegmentsShaderManager.disable(gl, shader); + this.segmentStatedColorShaderManager.disable(gl, shader); + } + constructor( - public base: SkeletonLayer, + public base: SkeletonLayerInterface, public targetIsSliceView: boolean, ) { super(); this.vertexIdHelper = this.registerDisposer(VertexIdHelper.get(this.gl)); + const { maxTextureImageUnits } = this.gl; + if (this.vertexAttributes.length > maxTextureImageUnits) { + console.warn( + `Skeleton has ${this.vertexAttributes.length} vertex attributes but device only supports ${maxTextureImageUnits} shader texture units`, + ); + } + const segmentAttrIndex = this.vertexAttributes.findIndex( + (x) => x.name === segmentAttribute.name, + ); + this.segmentAttributeIndex = + segmentAttrIndex >= 0 ? segmentAttrIndex : undefined; + this.dynamicSegmentAppearance = + base.dynamicSegmentAppearance === true && + this.segmentAttributeIndex !== undefined; + this.segmentColorAttributeIndex = base.segmentColorAttributeIndex; + const selectedNodeAttrIndex = this.vertexAttributes.findIndex( + (x) => x.name === selectedNodeAttribute.name, + ); + this.selectedNodeAttributeIndex = + selectedNodeAttrIndex >= 0 ? selectedNodeAttrIndex : undefined; this.edgeShaderGetter = parameterizedEmitterDependentShaderGetter( this, this.gl, { memoizeKey: { type: "skeleton/SkeletonShaderManager/edge", + dynamicSegmentAppearance: this.dynamicSegmentAppearance, vertexAttributes: this.vertexAttributes, }, fallbackParameters: this.base.fallbackShaderParameters, @@ -164,45 +506,130 @@ class RenderHelper extends RefCounted { } this.defineCommonShader(builder); this.defineAttributeAccess(builder); + if (this.dynamicSegmentAppearance) { + this.defineDynamicSegmentAppearance(builder); + } defineLineShader(builder); builder.addAttribute("highp uvec2", "aVertexIndex"); builder.addUniform("highp float", "uLineWidth"); + builder.addUniform("highp uint", "uPickInstanceStride"); + builder.addVarying("highp uint", "vPickID", "flat"); + if (this.dynamicSegmentAppearance) { + builder.addVarying("highp uint", "vSegmentValue", "flat"); + } let vertexMain = ` +highp uint pickOffset = uint(gl_InstanceID) * uPickInstanceStride; +vPickID = uPickID + pickOffset; highp vec3 vertexA = readAttribute0(aVertexIndex.x); highp vec3 vertexB = readAttribute0(aVertexIndex.y); emitLine(uProjection, vertexA, vertexB, uLineWidth); highp uint lineEndpointIndex = getLineEndpointIndex(); highp uint vertexIndex = aVertexIndex.x * (1u - lineEndpointIndex) + aVertexIndex.y * lineEndpointIndex; `; + if ( + this.dynamicSegmentAppearance && + this.segmentAttributeIndex !== undefined + ) { + vertexMain += `vSegmentValue = toRaw(readAttribute${this.segmentAttributeIndex}(aVertexIndex.x));\n`; + } - builder.addFragmentCode(` + const segmentColorExpression = this.getSegmentColorExpression(); + const segmentAlphaExpression = + this.segmentColorAttributeIndex === undefined + ? "uColor.a" + : `${segmentColorExpression}.a`; + if (this.dynamicSegmentAppearance) { + builder.addFragmentCode(` +vec4 segmentColor() { + return getSegmentAppearance(vSegmentValue); +} +void emitRGB(vec3 color) { + vec4 baseColor = segmentColor(); + highp float alpha = baseColor.a * getLineAlpha() * ${this.getCrossSectionFadeFactor()}; + if (alpha <= 0.0) discard; + emit(vec4(color * alpha, alpha), vPickID); +} +void emitDefault() { + vec4 baseColor = segmentColor(); + highp float alpha = baseColor.a * getLineAlpha() * ${this.getCrossSectionFadeFactor()}; + if (alpha <= 0.0) discard; + emit(vec4(baseColor.rgb * alpha, alpha), vPickID); +} +`); + } else if (this.segmentColorAttributeIndex === undefined) { + // Preserve legacy skeleton behavior where `uColor` is already + // premultiplied by `objectAlpha` in `getObjectColor`. + builder.addFragmentCode(` +vec4 segmentColor() { + return ${segmentColorExpression}; +} +void emitRGB(vec3 color) { + emit(vec4(color * uColor.a, uColor.a * getLineAlpha() * ${this.getCrossSectionFadeFactor()}), vPickID); +} +void emitDefault() { + emit(vec4(uColor.rgb, uColor.a * getLineAlpha() * ${this.getCrossSectionFadeFactor()}), vPickID); +} +`); + } else { + builder.addFragmentCode(` vec4 segmentColor() { - return uColor; + return ${segmentColorExpression}; } void emitRGB(vec3 color) { - emit(vec4(color * uColor.a, uColor.a * getLineAlpha() * ${this.getCrossSectionFadeFactor()}), uPickID); + highp float alpha = ${segmentAlphaExpression} * getLineAlpha() * ${this.getCrossSectionFadeFactor()}; + emit(vec4(color * alpha, alpha), vPickID); } void emitDefault() { - emit(vec4(uColor.rgb, uColor.a * getLineAlpha() * ${this.getCrossSectionFadeFactor()}), uPickID); + vec4 baseColor = segmentColor(); + highp float alpha = baseColor.a * getLineAlpha() * ${this.getCrossSectionFadeFactor()}; + emit(vec4(baseColor.rgb * alpha, alpha), vPickID); } `); + } builder.addFragmentCode(glsl_COLORMAPS); const { vertexAttributes } = this; const numAttributes = vertexAttributes.length; for (let i = 1; i < numAttributes; ++i) { const info = vertexAttributes[i]; - builder.addVarying(`highp ${info.glslDataType}`, `vCustom${i}`); - vertexMain += `vCustom${i} = readAttribute${i}(vertexIndex);\n`; - builder.addFragmentCode(`#define ${info.name} vCustom${i}\n`); + if ( + this.dynamicSegmentAppearance && + i === this.segmentAttributeIndex + ) { + builder.addFragmentCode(dataTypeShaderDefinition[info.dataType]); + builder.addFragmentCode( + `#define ${info.name} ${info.glslDataType}(vSegmentValue)\n`, + ); + builder.addFragmentCode( + `#define prop_${info.name}() ${info.glslDataType}(vSegmentValue)\n`, + ); + continue; + } + builder.addVarying( + `highp ${getVertexAttributeVaryingType(info)}`, + `vCustom${i}`, + getVertexAttributeInterpolationMode(info.dataType), + ); + vertexMain += `vCustom${i} = ${getVertexAttributeReadExpression(i, "vertexIndex", info)};\n`; + if (info.dataType !== DataType.FLOAT32) { + builder.addFragmentCode(dataTypeShaderDefinition[info.dataType]); + } + const fragmentExpression = getVertexAttributeFragmentExpression( + `vCustom${i}`, + info, + ); builder.addFragmentCode( - `#define prop_${info.name}() vCustom${i}\n`, + `#define ${info.name} ${fragmentExpression}\n`, + ); + builder.addFragmentCode( + `#define prop_${info.name}() ${fragmentExpression}\n`, ); } builder.setVertexMain(vertexMain); addControlsToBuilder(shaderBuilderState, builder); - builder.setFragmentMainFunction( - shaderCodeWithLineDirective(shaderBuilderState.parseResult.code), + const edgeFragmentCode = shaderCodeWithLineDirective( + shaderBuilderState.parseResult.code, ); + builder.setFragmentMainFunction(edgeFragmentCode); }, }, ); @@ -213,6 +640,7 @@ void emitDefault() { { memoizeKey: { type: "skeleton/SkeletonShaderManager/node", + dynamicSegmentAppearance: this.dynamicSegmentAppearance, vertexAttributes: this.vertexAttributes, }, fallbackParameters: this.base.fallbackShaderParameters, @@ -229,24 +657,100 @@ void emitDefault() { } this.defineCommonShader(builder); this.defineAttributeAccess(builder); + if (this.dynamicSegmentAppearance) { + this.defineDynamicSegmentAppearance(builder); + } defineCircleShader( builder, /*crossSectionFade=*/ this.targetIsSliceView, ); builder.addUniform("highp float", "uNodeDiameter"); + builder.addUniform("highp uint", "uPickInstanceStride"); + builder.addVarying("highp uint", "vPickID", "flat"); + if (this.dynamicSegmentAppearance) { + builder.addVarying("highp uint", "vSegmentValue", "flat"); + } + const selectedOutlineMinWidth = this.targetIsSliceView + ? SELECTED_NODE_OUTLINE_MIN_WIDTH_2D + : SELECTED_NODE_OUTLINE_MIN_WIDTH_3D; + const selectedOutlineMaxWidth = this.targetIsSliceView + ? SELECTED_NODE_OUTLINE_MAX_WIDTH_2D + : SELECTED_NODE_OUTLINE_MAX_WIDTH_3D; + const selectedNodeAttributeReadExpression = + this.selectedNodeAttributeIndex === undefined + ? "0.0" + : `readAttribute${this.selectedNodeAttributeIndex}(vertexIndex)`; + const selectedOutlineWidthExpression = + this.selectedNodeAttributeIndex === undefined + ? "0.0" + : `((${selectedNodeAttributeReadExpression} > 0.5) ? clamp(0.25 * uNodeDiameter, ${selectedOutlineMinWidth}, ${selectedOutlineMaxWidth}) : 0.0)`; let vertexMain = ` highp uint vertexIndex = uint(gl_InstanceID); +highp uint pickOffset = vertexIndex * uPickInstanceStride; +vPickID = uPickID + pickOffset; highp vec3 vertexPosition = readAttribute0(vertexIndex); -emitCircle(uProjection * vec4(vertexPosition, 1.0), uNodeDiameter, 0.0); +emitCircle( + uProjection * vec4(vertexPosition, 1.0), + uNodeDiameter, + ${selectedOutlineWidthExpression} +); `; + if ( + this.dynamicSegmentAppearance && + this.segmentAttributeIndex !== undefined + ) { + vertexMain += `vSegmentValue = toRaw(readAttribute${this.segmentAttributeIndex}(vertexIndex));\n`; + } - builder.addFragmentCode(` + const segmentColorExpression = this.getSegmentColorExpression(); + if ( + this.dynamicSegmentAppearance && + this.segmentAttributeIndex !== undefined + ) { + const segmentExpression = `vSegmentValue`; + const selectedNodeExpression = + this.selectedNodeAttributeIndex === undefined + ? undefined + : `vCustom${this.selectedNodeAttributeIndex}`; + const borderColorExpression = + selectedNodeExpression === undefined + ? "renderColor" + : `((${selectedNodeExpression} > 0.5) ? vec4(${SELECTED_NODE_OUTLINE_COLOR_RGB}, renderColor.a) : renderColor)`; + builder.addFragmentCode(` +vec4 segmentColor() { + return getSegmentAppearance(${segmentExpression}); +} +void emitRGBA(vec4 color) { + vec4 baseColor = segmentColor(); + highp float alpha = color.a * baseColor.a; + if (alpha <= 0.0) discard; + vec4 renderColor = vec4(color.rgb, alpha); + vec4 borderColor = ${borderColorExpression}; + vec4 circleColor = getCircleColor(renderColor, borderColor); + emit(vec4(circleColor.rgb * circleColor.a, circleColor.a), vPickID); +} +void emitRGB(vec3 color) { + emitRGBA(vec4(color, 1.0)); +} +void emitDefault() { + vec4 baseColor = segmentColor(); + highp float alpha = baseColor.a; + if (alpha <= 0.0) discard; + vec4 renderColor = vec4(baseColor.rgb, alpha); + vec4 borderColor = ${borderColorExpression}; + vec4 circleColor = getCircleColor(renderColor, borderColor); + emit(vec4(circleColor.rgb * circleColor.a, circleColor.a), vPickID); +} +`); + } else if (this.segmentColorAttributeIndex === undefined) { + // Preserve legacy skeleton behavior for non-spatial skeletons. + builder.addFragmentCode(` vec4 segmentColor() { - return uColor; + return ${segmentColorExpression}; } void emitRGBA(vec4 color) { vec4 borderColor = color; - emit(getCircleColor(color, borderColor), uPickID); + emit(getCircleColor(color, borderColor), vPickID); } void emitRGB(vec3 color) { emitRGBA(vec4(color, 1.0)); @@ -255,16 +759,69 @@ void emitDefault() { emitRGBA(uColor); } `); + } else { + const selectedNodeExpression = + this.selectedNodeAttributeIndex === undefined + ? undefined + : `vCustom${this.selectedNodeAttributeIndex}`; + const borderColorExpression = + selectedNodeExpression === undefined + ? "renderColor" + : `((${selectedNodeExpression} > 0.5) ? vec4(${SELECTED_NODE_OUTLINE_COLOR_RGB}, renderColor.a) : renderColor)`; + builder.addFragmentCode(` +vec4 segmentColor() { + return ${segmentColorExpression}; +} +void emitRGBA(vec4 color) { + vec4 renderColor = color; + vec4 borderColor = ${borderColorExpression}; + vec4 circleColor = getCircleColor(renderColor, borderColor); + emit(vec4(circleColor.rgb * circleColor.a, circleColor.a), vPickID); +} +void emitRGB(vec3 color) { + emitRGBA(vec4(color, 1.0)); +} +void emitDefault() { + emitRGBA(segmentColor()); +} +`); + } builder.addFragmentCode(glsl_COLORMAPS); const { vertexAttributes } = this; const numAttributes = vertexAttributes.length; for (let i = 1; i < numAttributes; ++i) { const info = vertexAttributes[i]; - builder.addVarying(`highp ${info.glslDataType}`, `vCustom${i}`); - vertexMain += `vCustom${i} = readAttribute${i}(vertexIndex);\n`; - builder.addFragmentCode(`#define ${info.name} vCustom${i}\n`); + if ( + this.dynamicSegmentAppearance && + i === this.segmentAttributeIndex + ) { + builder.addFragmentCode(dataTypeShaderDefinition[info.dataType]); + builder.addFragmentCode( + `#define ${info.name} ${info.glslDataType}(vSegmentValue)\n`, + ); + builder.addFragmentCode( + `#define prop_${info.name}() ${info.glslDataType}(vSegmentValue)\n`, + ); + continue; + } + builder.addVarying( + `highp ${getVertexAttributeVaryingType(info)}`, + `vCustom${i}`, + getVertexAttributeInterpolationMode(info.dataType), + ); + vertexMain += `vCustom${i} = ${getVertexAttributeReadExpression(i, "vertexIndex", info)};\n`; + if (info.dataType !== DataType.FLOAT32) { + builder.addFragmentCode(dataTypeShaderDefinition[info.dataType]); + } + const fragmentExpression = getVertexAttributeFragmentExpression( + `vCustom${i}`, + info, + ); + builder.addFragmentCode( + `#define ${info.name} ${fragmentExpression}\n`, + ); builder.addFragmentCode( - `#define prop_${info.name}() vCustom${i}\n`, + `#define prop_${info.name}() ${fragmentExpression}\n`, ); } builder.setVertexMain(vertexMain); @@ -324,24 +881,46 @@ void emitDefault() { this.vertexIdHelper.enable(); } - setColor(gl: GL, shader: ShaderProgram, color: vec3) { - gl.uniform4fv(shader.uniform("uColor"), color); + setColor(gl: GL, shader: ShaderProgram, color: Float32Array | number[]) { + const a = + (color as Float32Array).length >= 4 + ? (color as Float32Array)[3] + : this.base.displayState.objectAlpha.value; + gl.uniform4f( + shader.uniform("uColor"), + (color as Float32Array)[0], + (color as Float32Array)[1], + (color as Float32Array)[2], + a, + ); } setPickID(gl: GL, shader: ShaderProgram, pickID: number) { gl.uniform1ui(shader.uniform("uPickID"), pickID); } + setEdgePickInstanceStride(gl: GL, shader: ShaderProgram, stride: number) { + gl.uniform1ui(shader.uniform("uPickInstanceStride"), stride); + } + + setNodePickInstanceStride(gl: GL, shader: ShaderProgram, stride: number) { + gl.uniform1ui(shader.uniform("uPickInstanceStride"), stride); + } + drawSkeleton( gl: GL, edgeShader: ShaderProgram, nodeShader: ShaderProgram | null, - skeletonChunk: SkeletonChunk, + skeletonChunk: SkeletonChunkInterface, projectionParameters: { width: number; height: number }, ) { + // Bind vertex attribute textures to be used across edge and node shaders + // The edge shader and node shader share the same texture unit for each attribute + // so we only bind once. However, if this ever changes, we + // instead must bind for the edge shader, draw, then bind for node shader const { vertexAttributes } = this; - const numAttributes = vertexAttributes.length; const { vertexAttributeTextures } = skeletonChunk; + const numAttributes = vertexAttributes.length; for (let i = 0; i < numAttributes; ++i) { const textureUnit = WebGL2RenderingContext.TEXTURE0 + @@ -373,6 +952,7 @@ void emitDefault() { gl.disableVertexAttribArray(aVertexIndex); } + // Draw nodes if in line and node mode if (nodeShader !== null) { nodeShader.bind(); initializeCircleShader(nodeShader, projectionParameters, { @@ -382,24 +962,27 @@ void emitDefault() { } } - endLayer(gl: GL, shader: ShaderProgram) { + endLayer(gl: GL, ...shaders: Array) { const { vertexAttributes } = this; const numAttributes = vertexAttributes.length; - for (let i = 0; i < numAttributes; ++i) { - const curTextureUnit = - shader.textureUnit(vertexAttributeSamplerSymbols[i]) + - WebGL2RenderingContext.TEXTURE0; - gl.activeTexture(curTextureUnit); - gl.bindTexture(gl.TEXTURE_2D, null); + const clearedTextureUnits = new Set(); + for (const shader of shaders) { + if (shader === null) continue; + for (let i = 0; i < numAttributes; ++i) { + const curTextureUnit = + shader.textureUnit(vertexAttributeSamplerSymbols[i]) + + WebGL2RenderingContext.TEXTURE0; + if (clearedTextureUnits.has(curTextureUnit)) continue; + clearedTextureUnits.add(curTextureUnit); + gl.activeTexture(curTextureUnit); + gl.bindTexture(gl.TEXTURE_2D, null); + } } this.vertexIdHelper.disable(); } } -export enum SkeletonRenderMode { - LINES = 0, - LINES_AND_POINTS = 1, -} +export { SkeletonRenderMode } from "#src/skeleton/render_mode.js"; export class TrackableSkeletonRenderMode extends TrackableEnum { constructor( @@ -416,6 +999,43 @@ export class TrackableSkeletonLineWidth extends TrackableValue { } } +function getSkeletonNodeDiameter( + renderMode: SkeletonRenderMode, + lineWidth: number, +) { + if (renderMode === SkeletonRenderMode.LINES_AND_POINTS) { + return Math.max(5, lineWidth * 2); + } + return lineWidth; +} + +function setMouseStatePositionFromSpatialSkeletonNode( + mouseState: MouseSelectionState, + nodePosition: Float32Array, + transform: RenderLayerTransform, +) { + const rank = transform.rank; + const modelPosition = new Float32Array(rank); + for (let i = 0; i < Math.min(nodePosition.length, rank); ++i) { + const v = nodePosition[i]; + if (!Number.isFinite(v)) return; + modelPosition[i] = v; + } + const layerPosition = new Float32Array(rank); + matrix.transformPoint( + layerPosition, + transform.modelToRenderLayerTransform, + rank + 1, + modelPosition, + rank, + ); + gatherUpdate( + mouseState.position, + layerPosition, + transform.globalToRenderLayerDimensions, + ); +} + export interface ViewSpecificSkeletonRenderingOptions { mode: TrackableSkeletonRenderMode; lineWidth: TrackableSkeletonLineWidth; @@ -476,6 +1096,7 @@ export class SkeletonLayer extends RefCounted { redrawNeeded = new NullarySignal(); private sharedObject: SegmentationLayerSharedObject; vertexAttributes: VertexAttributeRenderInfo[]; + segmentColorAttributeIndex: number | undefined = undefined; fallbackShaderParameters = new WatchableValue( getFallbackBuilderState(parseShaderUiControls(DEFAULT_FRAGMENT_MAIN)), ); @@ -522,8 +1143,7 @@ export class SkeletonLayer extends RefCounted { dataType: info.dataType, numComponents: info.numComponents, webglDataType: getWebglDataType(info.dataType), - glslDataType: - info.numComponents > 1 ? `vec${info.numComponents}` : "float", + glslDataType: getShaderType(info.dataType, info.numComponents), }); } } @@ -543,7 +1163,7 @@ export class SkeletonLayer extends RefCounted { >, ) { const lineWidth = renderOptions.lineWidth.value; - const { gl, source, displayState } = this; + const { gl, displayState, source } = this; if (displayState.objectAlpha.value <= 0.0) { // Skip drawing. return; @@ -554,12 +1174,10 @@ export class SkeletonLayer extends RefCounted { attachment, ); if (modelMatrix === undefined) return; - let pointDiameter: number; - if (renderOptions.mode.value === SkeletonRenderMode.LINES_AND_POINTS) { - pointDiameter = Math.max(5, lineWidth * 2); - } else { - pointDiameter = lineWidth; - } + const pointDiameter = getSkeletonNodeDiameter( + renderOptions.mode.value, + lineWidth, + ); const edgeShaderResult = renderHelper.edgeShaderGetter( renderContext.emitter, @@ -580,6 +1198,7 @@ export class SkeletonLayer extends RefCounted { edgeShader.bind(); renderHelper.beginLayer(gl, edgeShader, renderContext, modelMatrix); + renderHelper.setEdgePickInstanceStride(gl, edgeShader, 0); setControlsInShader( gl, edgeShader, @@ -591,6 +1210,7 @@ export class SkeletonLayer extends RefCounted { nodeShader.bind(); renderHelper.beginLayer(gl, nodeShader, renderContext, modelMatrix); gl.uniform1f(nodeShader.uniform("uNodeDiameter"), pointDiameter); + renderHelper.setNodePickInstanceStride(gl, nodeShader, 0); setControlsInShader( gl, nodeShader, @@ -616,9 +1236,9 @@ export class SkeletonLayer extends RefCounted { } if (color !== undefined) { edgeShader.bind(); - renderHelper.setColor(gl, edgeShader, (color)); + renderHelper.setColor(gl, edgeShader, color as Float32Array); nodeShader.bind(); - renderHelper.setColor(gl, nodeShader, (color)); + renderHelper.setColor(gl, nodeShader, color as Float32Array); } if (pickIndex !== undefined) { edgeShader.bind(); @@ -635,7 +1255,7 @@ export class SkeletonLayer extends RefCounted { ); }, ); - renderHelper.endLayer(gl, edgeShader); + renderHelper.endLayer(gl, edgeShader, nodeShader); } isReady() { @@ -768,6 +1388,10 @@ function getWebglDataType(dataType: DataType) { switch (dataType) { case DataType.FLOAT32: return WebGL2RenderingContext.FLOAT; + case DataType.INT32: + return WebGL2RenderingContext.INT; + case DataType.UINT32: + return WebGL2RenderingContext.UNSIGNED_INT; default: throw new Error( `Data type not supported by WebGL: ${DataType[dataType]}`, @@ -775,6 +1399,59 @@ function getWebglDataType(dataType: DataType) { } } +function getVertexAttributeInterpolationMode(dataType: DataType) { + return dataType === DataType.FLOAT32 ? "" : "flat"; +} + +// Custom integer wrapper types like `uint32_t` are defined in fragment code, +// which is emitted after varying declarations. Keep varyings on raw GLSL +// scalar/vector types and wrap them back into helper structs in fragment code. +function getVertexAttributeVaryingType(info: VertexAttributeInfo) { + const { dataType, numComponents } = info; + if (dataType === DataType.FLOAT32) { + return getShaderType(dataType, numComponents); + } + if (dataType === DataType.UINT64) { + if (numComponents === 1) return "uvec2"; + if (numComponents === 2) return "uvec4"; + } + const vectorTypePrefix = DATA_TYPE_SIGNED[dataType] ? "ivec" : "uvec"; + if (numComponents === 1) { + return DATA_TYPE_SIGNED[dataType] ? "int" : "uint"; + } + if (numComponents >= 2 && numComponents <= 4) { + return `${vectorTypePrefix}${numComponents}`; + } + throw new Error( + `No varying type for ${DataType[dataType]}[${numComponents}].`, + ); +} + +function getVertexAttributeReadExpression( + attributeIndex: number, + indexExpression: string, + info: VertexAttributeInfo, +) { + const readExpression = `readAttribute${attributeIndex}(${indexExpression})`; + if (info.dataType === DataType.FLOAT32) { + return readExpression; + } + if (info.dataType === DataType.UINT64) { + return `${readExpression}.value`; + } + return `toRaw(${readExpression})`; +} + +function getVertexAttributeFragmentExpression( + varyingName: string, + info: VertexAttributeRenderInfo, +) { + if (info.dataType === DataType.FLOAT32) { + return varyingName; + } + return `${info.glslDataType}(${varyingName})`; +} + const vertexPositionAttribute: VertexAttributeRenderInfo = { dataType: DataType.FLOAT32, numComponents: 3, @@ -783,15 +1460,31 @@ const vertexPositionAttribute: VertexAttributeRenderInfo = { glslDataType: "vec3", }; -export class SkeletonChunk extends Chunk { +const segmentAttribute: VertexAttributeRenderInfo = { + dataType: DataType.UINT32, + numComponents: 1, + name: "segment", + webglDataType: WebGL2RenderingContext.UNSIGNED_INT, + glslDataType: getShaderType(DataType.UINT32, 1), +}; + +const selectedNodeAttribute: VertexAttributeRenderInfo = { + dataType: DataType.FLOAT32, + numComponents: 1, + name: "selectedNodeAttr", + webglDataType: WebGL2RenderingContext.FLOAT, + glslDataType: "float", +}; + +export class SkeletonChunk extends Chunk implements SkeletonChunkInterface { declare source: SkeletonSource; vertexAttributes: Uint8Array; indices: Uint32Array; - indexBuffer: GLBuffer; + indexBuffer!: GLBuffer; numIndices: number; numVertices: number; vertexAttributeOffsets: Uint32Array; - vertexAttributeTextures: (WebGLTexture | null)[]; + vertexAttributeTextures: (WebGLTexture | null)[] = []; constructor(source: SkeletonSource, x: any) { super(source); @@ -806,28 +1499,14 @@ export class SkeletonChunk extends Chunk { super.copyToGPU(gl); const { attributeTextureFormats } = this.source; const { vertexAttributes, vertexAttributeOffsets } = this; - const vertexAttributeTextures: (WebGLTexture | null)[] = - (this.vertexAttributeTextures = []); - for ( - let i = 0, numAttributes = vertexAttributeOffsets.length; - i < numAttributes; - ++i - ) { - const texture = gl.createTexture(); - gl.bindTexture(WebGL2RenderingContext.TEXTURE_2D, texture); - setOneDimensionalTextureData( - gl, - attributeTextureFormats[i], - vertexAttributes.subarray( - vertexAttributeOffsets[i], - i + 1 !== numAttributes - ? vertexAttributeOffsets[i + 1] - : vertexAttributes.length, - ), - ); - vertexAttributeTextures[i] = texture; - } - gl.bindTexture(WebGL2RenderingContext.TEXTURE_2D, null); + + this.vertexAttributeTextures = uploadVertexAttributesToGPU( + gl, + vertexAttributes, + vertexAttributeOffsets, + attributeTextureFormats, + ); + this.indexBuffer = GLBuffer.fromData( gl, this.indices, @@ -847,6 +1526,2572 @@ export class SkeletonChunk extends Chunk { } } +export class SpatiallyIndexedSkeletonChunk + extends SliceViewChunk + implements SkeletonChunkInterface +{ + declare source: SpatiallyIndexedSkeletonSource; + vertexAttributes: Uint8Array; + indices: Uint32Array; + indexBuffer!: GLBuffer; + numIndices: number; + numVertices: number; + vertexAttributeOffsets: Uint32Array; + vertexAttributeTextures: (WebGLTexture | null)[] = []; + nodeIds: Int32Array = new Int32Array(0); + nodeRevisionTokens: Array = []; + lod: number | undefined; + + constructor( + source: SpatiallyIndexedSkeletonSource, + chunkData: SkeletonChunkData, + ) { + super(source, chunkData); + this.vertexAttributes = chunkData.vertexAttributes; + const indices = (this.indices = chunkData.indices); + this.numVertices = chunkData.numVertices; + this.numIndices = indices.length; + this.vertexAttributeOffsets = chunkData.vertexAttributeOffsets; + this.lod = (chunkData as any).lod; + const nodeIdsData = (chunkData as any).nodeIds; + if (nodeIdsData instanceof Int32Array) { + this.nodeIds = nodeIdsData; + } else if (ArrayBuffer.isView(nodeIdsData)) { + this.nodeIds = new Int32Array( + nodeIdsData.buffer, + nodeIdsData.byteOffset, + nodeIdsData.byteLength / Int32Array.BYTES_PER_ELEMENT, + ); + } 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, + ) + : []; + } + + copyToGPU(gl: GL) { + const wasGpuResident = this.state === ChunkState.GPU_MEMORY; + super.copyToGPU(gl); + if (wasGpuResident) return; + const { attributeTextureFormats } = this.source; + this.vertexAttributeTextures = uploadVertexAttributesToGPU( + gl, + this.vertexAttributes, + this.vertexAttributeOffsets, + attributeTextureFormats, + ); + this.indexBuffer = GLBuffer.fromData( + gl, + this.indices, + WebGL2RenderingContext.ARRAY_BUFFER, + WebGL2RenderingContext.STATIC_DRAW, + ); + this.source.bumpLookupGeneration(); + } + + freeGPUMemory(gl: GL) { + const wasGpuResident = this.state === ChunkState.GPU_MEMORY; + super.freeGPUMemory(gl); + if (!wasGpuResident) return; + this.indexBuffer.dispose(); + const { vertexAttributeTextures } = this; + for (let i = 0, length = vertexAttributeTextures.length; i < length; ++i) { + gl.deleteTexture(vertexAttributeTextures[i]); + } + vertexAttributeTextures.length = 0; + this.source.bumpLookupGeneration(); + } +} + +export interface SpatiallyIndexedSkeletonChunkSpecification + extends SliceViewChunkSpecification { + chunkLayout: ChunkLayout; +} + +type SpatiallyIndexedSkeletonChunkListener = ( + key: string, + chunk: SpatiallyIndexedSkeletonChunk, +) => void; + +export class SpatiallyIndexedSkeletonSource extends SliceViewChunkSource< + SpatiallyIndexedSkeletonChunkSpecification, + SpatiallyIndexedSkeletonChunk +> { + vertexAttributes: VertexAttributeRenderInfo[]; + lookupGeneration = 0; + private attributeTextureFormats_?: TextureFormat[]; + private chunkListeners = new Set(); + + constructor(chunkManager: ChunkManager, options: any) { + super(chunkManager, options); + this.vertexAttributes = [vertexPositionAttribute, segmentAttribute]; + } + + get attributeTextureFormats() { + let attributeTextureFormats = this.attributeTextureFormats_; + if (attributeTextureFormats === undefined) { + attributeTextureFormats = this.attributeTextureFormats_ = + spatiallyIndexedSkeletonTextureAttributeSpecs.map( + ({ dataType, numComponents }) => + computeTextureFormat(new TextureFormat(), dataType, numComponents), + ); + } + return attributeTextureFormats; + } + + static encodeSpec(spec: SpatiallyIndexedSkeletonChunkSpecification) { + const base = SliceViewChunkSource.encodeSpec(spec); + return { ...base, chunkLayout: spec.chunkLayout.toObject() }; + } + + bumpLookupGeneration() { + ++this.lookupGeneration; + } + + addChunkListener(listener: SpatiallyIndexedSkeletonChunkListener) { + this.chunkListeners.add(listener); + return () => this.chunkListeners.delete(listener); + } + + addChunk(key: string, chunk: SpatiallyIndexedSkeletonChunk) { + super.addChunk(key, chunk); + for (const listener of this.chunkListeners) { + listener(key, chunk); + } + } + + getChunk(chunkData: SkeletonChunkData) { + return new SpatiallyIndexedSkeletonChunk(this, chunkData); + } +} + +export abstract class MultiscaleSpatiallyIndexedSkeletonSource extends MultiscaleSliceViewChunkSource { + getPerspectiveSources(): SliceViewSingleResolutionSource[] { + const sources = this.getSources({ view: "3d" } as any); + const flattened: SliceViewSingleResolutionSource[] = + []; + for (const scale of sources) { + if (scale.length > 0) { + flattened.push(scale[0]); + } + } + return flattened; + } + + getSliceViewPanelSources(): SliceViewSingleResolutionSource[] { + return this.getPerspectiveSources(); + } + + getSpatialSkeletonGridSizes(): + | { x: number; y: number; z: number }[] + | undefined { + return undefined; + } +} + +export class MultiscaleSliceViewSpatiallyIndexedSkeletonLayer extends SliceViewRenderLayer { + private renderOptions: ViewSpecificSkeletonRenderingOptions; + private visibleChunkKeysBySliceView = new Map< + number, + VisibleSpatialChunkKeysBySource + >(); + private trackedChunkStatsSliceViews = new Set(); + RPC_TYPE_ID = SPATIALLY_INDEXED_SKELETON_SLICEVIEW_RENDER_LAYER_RPC_ID; + constructor( + public chunkManager: ChunkManager, + public multiscaleSource: MultiscaleSpatiallyIndexedSkeletonSource, + public displayState: SegmentationDisplayState, + ) { + const renderScaleTarget = (displayState as any) + .renderScaleTarget as WatchableValueInterface; + const gridLevel2d = (displayState as any).spatialSkeletonGridLevel2d as + | WatchableValueInterface + | undefined; + super(chunkManager, multiscaleSource, { + transform: (displayState as any).transform, + localPosition: (displayState as any).localPosition, + renderScaleTarget, + visibleSourcesInvalidation: + gridLevel2d === undefined ? [] : [gridLevel2d], + }); + this.renderOptions = ( + displayState as any + ).skeletonRenderingOptions.params2d; + this.registerDisposer( + this.renderOptions.mode.changed.add(this.redrawNeeded.dispatch), + ); + this.registerDisposer( + this.renderOptions.lineWidth.changed.add(this.redrawNeeded.dispatch), + ); + const rpc = this.chunkManager.rpc!; + const lod2d = (displayState as any).spatialSkeletonLod2d; + if (gridLevel2d !== undefined && lod2d !== undefined) { + this.rpcTransfer = { + ...this.rpcTransfer, + chunkManager: this.chunkManager.rpcId, + skeletonGridLevel: this.registerDisposer( + SharedWatchableValue.makeFromExisting(rpc, gridLevel2d), + ).rpcId, + skeletonLod: this.registerDisposer( + SharedWatchableValue.makeFromExisting(rpc, lod2d), + ).rpcId, + }; + } + this.initializeCounterpart(); + } + + filterVisibleSources( + sliceView: SliceViewBase, + sources: readonly TransformedSource[], + ): Iterable { + const gridLevel = (this.displayState as any).spatialSkeletonGridLevel2d + ?.value as number | undefined; + if ( + gridLevel === undefined || + sources.length === 0 || + !sources.every( + (source) => getSpatiallyIndexedSkeletonGridIndex(source) !== undefined, + ) + ) { + return super.filterVisibleSources(sliceView, sources); + } + return selectSpatiallyIndexedSkeletonEntriesByGrid( + sources, + gridLevel, + getSpatiallyIndexedSkeletonGridIndex, + ); + } + + private updateChunkStatsWatchable() { + const { presentCount, totalCount } = mergeVisibleSpatialChunkKeyCounts( + this.visibleChunkKeysBySliceView.values(), + ); + updateSpatialSkeletonGridChunkStatsWatchable( + (this.displayState as any).spatialSkeletonGridChunkStats2d as + | WatchableValue + | undefined, + presentCount, + totalCount, + ); + } + + private setVisibleChunkKeysForSliceView( + sliceViewId: number, + visibleChunkKeysBySource: VisibleSpatialChunkKeysBySource, + ) { + this.visibleChunkKeysBySliceView.set(sliceViewId, visibleChunkKeysBySource); + this.updateChunkStatsWatchable(); + } + + private clearVisibleChunkKeysForSliceView(sliceViewId: number) { + if (!this.visibleChunkKeysBySliceView.delete(sliceViewId)) { + return; + } + this.updateChunkStatsWatchable(); + } + + private registerChunkStatsSliceView( + sliceView: RefCounted & { rpcId: number }, + ) { + const { rpcId } = sliceView; + if (this.trackedChunkStatsSliceViews.has(rpcId)) { + return; + } + this.trackedChunkStatsSliceViews.add(rpcId); + sliceView.registerDisposer(() => { + this.trackedChunkStatsSliceViews.delete(rpcId); + this.clearVisibleChunkKeysForSliceView(rpcId); + }); + } + + draw(renderContext: SliceViewRenderContext) { + const displayState = this.displayState as any; + const lodValue = displayState.spatialSkeletonLod2d?.value as + | number + | undefined; + const sliceView = renderContext.sliceView; + this.registerChunkStatsSliceView( + sliceView as RefCounted & { rpcId: number }, + ); + if (displayState.objectAlpha?.value <= 0.0 || lodValue === undefined) { + this.clearVisibleChunkKeysForSliceView(sliceView.rpcId); + return; + } + const visibleSources = + renderContext.sliceView.visibleLayers.get(this)?.visibleSources ?? []; + this.setVisibleChunkKeysForSliceView( + sliceView.rpcId, + collectPlaneIntersectingSpatialChunkKeysBySource( + visibleSources, + renderContext.projectionParameters, + this.localPosition.value, + lodValue, + ), + ); + } +} + +type SpatiallyIndexedSkeletonSourceEntry = + SliceViewSingleResolutionSource; + +interface SpatiallyIndexedSkeletonLayerOptions { + gridLevel?: WatchableValueInterface; + lod?: WatchableValueInterface; + sources2d?: SpatiallyIndexedSkeletonSourceEntry[]; + selectedNodeId?: WatchableValueInterface; + pendingNodePositionVersion?: WatchableValueInterface; + getPendingNodePosition?: (nodeId: number) => ArrayLike | undefined; + getCachedNode?: (nodeId: number) => SpatiallyIndexedSkeletonNode | undefined; + inspectionState?: SpatiallyIndexedSkeletonInspectionState; + maxRetainedOverlaySegments?: number; +} + +interface SpatiallyIndexedSkeletonInspectionState { + readonly nodeDataVersion: WatchableValueInterface; + readonly pendingNodePositionVersion: WatchableValueInterface; + getCachedSegmentNodes( + segmentId: number, + ): readonly SpatiallyIndexedSkeletonNode[] | undefined; + getFullSegmentNodes( + skeletonLayer: SpatiallyIndexedSkeletonLayer, + segmentId: number, + ): Promise; + evictInactiveSegmentNodes(activeSegmentIds: Iterable): void; +} + +interface SpatiallyIndexedSkeletonOverlayChunk extends SkeletonChunkInterface { + dispose(gl: GL): void; +} + +function getSpatialSkeletonGridSpacing( + transformedSource: TransformedSource, + levels: + | Array<{ size: { x: number; y: number; z: number }; lod: number }> + | undefined, + gridIndex: number, +) { + const levelSize = levels?.[gridIndex]?.size; + if (levelSize !== undefined) { + return Math.max(Math.min(levelSize.x, levelSize.y, levelSize.z), 1e-6); + } + const chunkSize = transformedSource.chunkLayout.size; + return Math.max(Math.min(chunkSize[0], chunkSize[1], chunkSize[2]), 1e-6); +} + +function updateSpatialSkeletonGridRenderScaleHistogram( + histogram: RenderScaleHistogram, + frameNumber: number, + transformedSources: readonly TransformedSource[][], + projectionParameters: any, + localPosition: Float32Array, + lod: number | undefined, + levels: + | Array<{ size: { x: number; y: number; z: number }; lod: number }> + | undefined, + relative: boolean, + pixelSize: number, +) { + histogram.begin(frameNumber); + if (lod === undefined || transformedSources.length === 0) { + return; + } + const lodSuffix = `:${lod}`; + const scales = transformedSources[0] ?? []; + if (scales.length === 0) { + return; + } + const safePixelSize = Math.max(pixelSize, 1e-6); + for (const tsource of scales) { + const gridIndex = (tsource.source as any).parameters?.gridIndex as + | number + | undefined; + if (gridIndex === undefined) { + continue; + } + const source = tsource.source as unknown as { + chunks: Map; + }; + let presentCount = 0; + let missingCount = 0; + forEachVisibleVolumetricChunk( + projectionParameters, + localPosition, + tsource, + (positionInChunks) => { + const key = `${positionInChunks.join()}${lodSuffix}`; + const chunk = source.chunks.get(key) as + | SpatiallyIndexedSkeletonChunk + | undefined; + if (chunk?.state === ChunkState.GPU_MEMORY) { + presentCount++; + } else { + missingCount++; + } + }, + ); + const spacing = getSpatialSkeletonGridSpacing(tsource, levels, gridIndex); + const renderScale = relative ? spacing / safePixelSize : spacing; + const total = presentCount + missingCount; + if (total > 0) { + histogram.add(spacing, renderScale, presentCount, missingCount); + } else { + // Keep all grid rows visible in the histogram even when currently empty. + histogram.add(spacing, renderScale, 0, 1, true); + } + } +} + +type VisibleSpatialChunksBySource = Map< + string, + readonly SpatiallyIndexedSkeletonChunk[] +>; + +interface VisibleSpatialChunkKeys { + presentChunkKeys: Set; + totalChunkKeys: Set; +} + +type VisibleSpatialChunkKeysBySource = Map; + +interface SpatialSkeletonGridChunkStats { + presentCount: number; + totalCount: number; +} + +function getOrCreateVisibleSpatialChunkKeys( + visibleChunkKeysBySource: VisibleSpatialChunkKeysBySource, + sourceId: string, +) { + let visibleChunkKeys = visibleChunkKeysBySource.get(sourceId); + if (visibleChunkKeys === undefined) { + visibleChunkKeys = { + presentChunkKeys: new Set(), + totalChunkKeys: new Set(), + }; + visibleChunkKeysBySource.set(sourceId, visibleChunkKeys); + } + return visibleChunkKeys; +} + +function updateSpatialSkeletonGridChunkStatsWatchable( + watchable: WatchableValue | undefined, + presentCount: number, + totalCount: number, +) { + if (watchable === undefined) return; + const prev = watchable.value; + if (prev.presentCount === presentCount && prev.totalCount === totalCount) { + return; + } + watchable.value = { presentCount, totalCount }; +} + +function mergeVisibleSpatialChunkKeyCounts( + visibleChunkKeysByView: Iterable, + selectedSourceIds?: Iterable, +) { + const presentChunkKeys = new Set(); + const totalChunkKeys = new Set(); + const selectedSourceIdSet = + selectedSourceIds === undefined ? undefined : new Set(selectedSourceIds); + for (const visibleChunkKeysBySource of visibleChunkKeysByView) { + for (const [sourceId, visibleChunkKeys] of visibleChunkKeysBySource) { + if ( + selectedSourceIdSet !== undefined && + !selectedSourceIdSet.has(sourceId) + ) { + continue; + } + for (const chunkKey of visibleChunkKeys.presentChunkKeys) { + presentChunkKeys.add(`${sourceId}:${chunkKey}`); + } + for (const chunkKey of visibleChunkKeys.totalChunkKeys) { + totalChunkKeys.add(`${sourceId}:${chunkKey}`); + } + } + } + return { + presentCount: presentChunkKeys.size, + totalCount: totalChunkKeys.size, + }; +} + +function collectPlaneIntersectingSpatialChunkKeysBySource( + transformedSources: readonly TransformedSource[], + projectionParameters: any, + localPosition: Float32Array, + lod: number | undefined, +) { + const visibleChunkKeysBySource: VisibleSpatialChunkKeysBySource = new Map(); + if (lod === undefined || transformedSources.length === 0) { + return visibleChunkKeysBySource; + } + const lodSuffix = `:${lod}`; + const seenChunkKeysBySource = new Map>(); + for (const tsource of transformedSources) { + const sourceId = getObjectId(tsource.source); + const visibleChunkKeys = getOrCreateVisibleSpatialChunkKeys( + visibleChunkKeysBySource, + sourceId, + ); + let seenChunkKeys = seenChunkKeysBySource.get(sourceId); + if (seenChunkKeys === undefined) { + seenChunkKeys = new Set(); + seenChunkKeysBySource.set(sourceId, seenChunkKeys); + } + const chunkLayout = getNormalizedChunkLayout( + projectionParameters, + tsource.chunkLayout, + ); + forEachPlaneIntersectingVolumetricChunk( + projectionParameters, + localPosition, + tsource, + chunkLayout, + (positionInChunks) => { + const chunkKey = `${positionInChunks.join()}${lodSuffix}`; + if (seenChunkKeys!.has(chunkKey)) { + return; + } + seenChunkKeys!.add(chunkKey); + visibleChunkKeys.totalChunkKeys.add(chunkKey); + const chunk = ( + tsource.source as SpatiallyIndexedSkeletonSource + ).chunks.get(chunkKey) as SpatiallyIndexedSkeletonChunk | undefined; + if (chunk?.state === ChunkState.GPU_MEMORY) { + visibleChunkKeys.presentChunkKeys.add(chunkKey); + } + }, + ); + } + return visibleChunkKeysBySource; +} + +export class SpatiallyIndexedSkeletonLayer + extends RefCounted + implements SkeletonLayerInterface +{ + layerChunkProgressInfo = new LayerChunkProgressInfo(); + redrawNeeded = new NullarySignal(); + dynamicSegmentAppearance = true; + vertexAttributes: VertexAttributeRenderInfo[]; + segmentColorAttributeIndex: number | undefined; + selectedNodeAttributeIndex: number | undefined; + readonly chunkGeometryRenderLayerInterface: SkeletonLayerInterface; + fallbackShaderParameters = new WatchableValue( + getFallbackBuilderState(parseShaderUiControls(DEFAULT_FRAGMENT_MAIN)), + ); + backend: ChunkRenderLayerFrontend; + localPosition: WatchableValueInterface; + readonly chunkTransform: WatchableValueInterface< + ValueOrError + >; + rpc: RPC | undefined; + + private overlayAttributeTextureFormats = [ + vertexPositionTextureFormat, + segmentTextureFormat, + selectedNodeTextureFormat, + ]; + private regularSkeletonLayerWatchable = new WatchableValue(false); + private regularSkeletonLayerUserLayer: UserLayer | undefined; + private removeRegularSkeletonLayerUserLayerListener: + | (() => boolean) + | undefined; + private visibleChunksByView = new Map< + SpatiallyIndexedSkeletonView, + VisibleSpatialChunksBySource + >(); + private visibleChunkKeysByRenderedView = new Map< + SpatiallyIndexedSkeletonView, + Map + >(); + gridLevel: WatchableValueInterface; + lod: WatchableValueInterface; + private selectedNodeId: + | WatchableValueInterface + | undefined; + private pendingNodePositionVersion: + | WatchableValueInterface + | undefined; + private getPendingNodePositionOverride: + | ((nodeId: number) => ArrayLike | undefined) + | undefined; + private getCachedNodeInfo: + | ((nodeId: number) => SpatiallyIndexedSkeletonNode | undefined) + | undefined; + private inspectionState: SpatiallyIndexedSkeletonInspectionState | undefined; + private overlayChunk: SpatiallyIndexedSkeletonOverlayChunk | undefined; + private overlayChunkKey: string | undefined; + private pendingOverlaySegmentLoads = new Set(); + private browseExcludedSegments = new Uint64Set(); + private browseExcludedSegmentsKey: string | undefined; + private suppressedBrowseSegmentIds = new Set(); + private retainedOverlaySegmentIds: number[] = []; + private maxRetainedOverlaySegments: number; + + private *iterateUniqueChunkSources() { + const seenSourceIds = new Set(); + for (const sourceEntry of [...this.sources, ...this.sources2d]) { + const sourceId = getObjectId(sourceEntry.chunkSource); + if (seenSourceIds.has(sourceId)) continue; + seenSourceIds.add(sourceId); + yield sourceEntry.chunkSource; + } + } + + private disposeOverlayChunk() { + this.overlayChunk?.dispose(this.gl); + this.overlayChunk = undefined; + this.overlayChunkKey = undefined; + } + + private requestOverlaySegmentLoad(segmentId: number) { + if ( + this.inspectionState === undefined || + this.pendingOverlaySegmentLoads.has(segmentId) + ) { + return; + } + this.pendingOverlaySegmentLoads.add(segmentId); + void this.inspectionState + .getFullSegmentNodes(this, segmentId) + .catch(() => {}) + .finally(() => { + this.pendingOverlaySegmentLoads.delete(segmentId); + this.disposeOverlayChunk(); + this.redrawNeeded.dispatch(); + }); + } + + private getOverlayChunkKey(segmentIds: readonly number[]) { + return [ + segmentIds.join(","), + `selected:${this.selectedNodeId?.value ?? ""}`, + `pending:${this.pendingNodePositionVersion?.value ?? ""}`, + `data:${this.inspectionState?.nodeDataVersion.value ?? ""}`, + ].join("|"); + } + + private getActiveEditableSegmentIds() { + const segments = getVisibleSegments( + this.displayState.segmentationGroupState.value, + ); + const segmentIds: number[] = []; + for (const segmentId of segments.keys()) { + const normalizedSegmentId = Number(segmentId); + if ( + !Number.isSafeInteger(normalizedSegmentId) || + normalizedSegmentId <= 0 + ) { + continue; + } + segmentIds.push(normalizedSegmentId); + } + segmentIds.sort((a, b) => a - b); + return segmentIds; + } + + getRetainedOverlaySegmentIds() { + return this.retainedOverlaySegmentIds; + } + + retainOverlaySegment(segmentId: number) { + const nextRetainedOverlaySegmentIds = + retainSpatiallyIndexedSkeletonOverlaySegment( + this.retainedOverlaySegmentIds, + segmentId, + { maxRetained: this.maxRetainedOverlaySegments }, + ); + if ( + nextRetainedOverlaySegmentIds.length === + this.retainedOverlaySegmentIds.length && + nextRetainedOverlaySegmentIds.every( + (candidateSegmentId, index) => + candidateSegmentId === this.retainedOverlaySegmentIds[index], + ) + ) { + return false; + } + this.retainedOverlaySegmentIds = nextRetainedOverlaySegmentIds; + this.redrawNeeded.dispatch(); + return true; + } + + suppressBrowseSegment(segmentId: number) { + const normalizedSegmentId = Math.round(Number(segmentId)); + if ( + !Number.isSafeInteger(normalizedSegmentId) || + normalizedSegmentId <= 0 || + this.suppressedBrowseSegmentIds.has(normalizedSegmentId) + ) { + return false; + } + this.suppressedBrowseSegmentIds.add(normalizedSegmentId); + this.redrawNeeded.dispatch(); + return true; + } + + private getOverlayRenderSegmentIds() { + return mergeSpatiallyIndexedSkeletonOverlaySegmentIds( + this.getActiveEditableSegmentIds(), + this.retainedOverlaySegmentIds, + ); + } + + private getLoadedOverlaySegmentIds( + segmentIds: readonly number[] = this.getOverlayRenderSegmentIds(), + ) { + if (this.inspectionState === undefined) { + return []; + } + return segmentIds.filter( + (segmentId) => + this.inspectionState?.getCachedSegmentNodes(segmentId) !== undefined, + ); + } + + private getNormalizedBrowsePassExcludedSegmentIds() { + const segmentIds = new Set(); + for (const segmentId of this.getLoadedOverlaySegmentIds()) { + const normalizedSegmentId = Math.round(Number(segmentId)); + if ( + !Number.isSafeInteger(normalizedSegmentId) || + normalizedSegmentId <= 0 + ) { + continue; + } + segmentIds.add(normalizedSegmentId); + } + for (const segmentId of this.suppressedBrowseSegmentIds) { + const normalizedSegmentId = Math.round(Number(segmentId)); + if ( + !Number.isSafeInteger(normalizedSegmentId) || + normalizedSegmentId <= 0 + ) { + continue; + } + segmentIds.add(normalizedSegmentId); + } + return [...segmentIds].sort((a, b) => a - b); + } + + private getBrowsePassExcludedSegments() { + const segmentIds = this.getNormalizedBrowsePassExcludedSegmentIds(); + if (segmentIds.length === 0) { + if (this.browseExcludedSegments.size !== 0) { + this.browseExcludedSegments.clear(); + } + this.browseExcludedSegmentsKey = undefined; + return undefined; + } + const excludedSegmentsKey = segmentIds.join(","); + if (this.browseExcludedSegmentsKey !== excludedSegmentsKey) { + this.browseExcludedSegments.clear(); + this.browseExcludedSegments.add( + segmentIds + .filter( + (segmentId) => Number.isSafeInteger(segmentId) && segmentId > 0, + ) + .map((segmentId) => BigInt(segmentId)), + ); + this.browseExcludedSegmentsKey = excludedSegmentsKey; + } + return this.browseExcludedSegments; + } + + private resolveSourceBackedOverlayChunk(): + | SpatiallyIndexedSkeletonOverlayChunk + | undefined { + if (this.inspectionState === undefined) { + this.disposeOverlayChunk(); + return undefined; + } + const overlaySegmentIds = this.getOverlayRenderSegmentIds(); + if (overlaySegmentIds.length === 0) { + this.disposeOverlayChunk(); + return undefined; + } + this.inspectionState.evictInactiveSegmentNodes(overlaySegmentIds); + const segmentNodeSets: SpatiallyIndexedSkeletonNode[][] = []; + const loadedSegmentIds: number[] = []; + for (const segmentId of overlaySegmentIds) { + const segmentNodes = + this.inspectionState.getCachedSegmentNodes(segmentId); + if (segmentNodes === undefined) { + this.requestOverlaySegmentLoad(segmentId); + continue; + } + loadedSegmentIds.push(segmentId); + segmentNodeSets.push(segmentNodes.map((node) => node)); + } + if (loadedSegmentIds.length === 0) { + this.disposeOverlayChunk(); + return undefined; + } + const overlayChunkKey = this.getOverlayChunkKey(loadedSegmentIds); + if ( + this.overlayChunk !== undefined && + this.overlayChunkKey === overlayChunkKey + ) { + return this.overlayChunk; + } + this.disposeOverlayChunk(); + const geometry = buildSpatiallyIndexedSkeletonOverlayGeometry( + segmentNodeSets, + { + selectedNodeId: this.selectedNodeId?.value, + getPendingNodePosition: this.getPendingNodePositionOverride, + }, + ); + const positionBytes = new Uint8Array(geometry.positions.buffer); + const segmentBytes = new Uint8Array(geometry.segmentIds.buffer); + const selectedBytes = new Uint8Array(geometry.selected.buffer); + const vertexBytes = new Uint8Array( + positionBytes.byteLength + + segmentBytes.byteLength + + selectedBytes.byteLength, + ); + vertexBytes.set(positionBytes, 0); + vertexBytes.set(segmentBytes, positionBytes.byteLength); + vertexBytes.set( + selectedBytes, + positionBytes.byteLength + segmentBytes.byteLength, + ); + const vertexOffsets = new Uint32Array([ + 0, + positionBytes.byteLength, + positionBytes.byteLength + segmentBytes.byteLength, + ]); + const vertexAttributeTextures = uploadVertexAttributesToGPU( + this.gl, + vertexBytes, + vertexOffsets, + this.overlayAttributeTextureFormats, + ); + const indexBuffer = GLBuffer.fromData( + this.gl, + geometry.indices, + WebGL2RenderingContext.ARRAY_BUFFER, + WebGL2RenderingContext.STATIC_DRAW, + ); + this.overlayChunk = { + vertexAttributeTextures, + indexBuffer, + numIndices: geometry.indices.length, + numVertices: geometry.numVertices, + pickNodeIds: geometry.nodeIds, + pickNodePositions: geometry.nodePositions, + pickSegmentIds: geometry.pickSegmentIds, + pickEdgeSegmentIds: geometry.pickEdgeSegmentIds, + dispose: (gl: GL) => { + for (const texture of vertexAttributeTextures) { + if (texture) gl.deleteTexture(texture); + } + indexBuffer.dispose(); + }, + }; + this.overlayChunkKey = overlayChunkKey; + return this.overlayChunk; + } + + private computeHasRegularSkeletonLayer(userLayer: UserLayer) { + for (const renderLayer of userLayer.renderLayers) { + if ( + renderLayer instanceof PerspectiveViewSkeletonLayer || + renderLayer instanceof SliceViewPanelSkeletonLayer + ) { + return true; + } + } + return false; + } + + private updateHasRegularSkeletonLayerWatchable( + userLayer: UserLayer | undefined, + ) { + if (this.regularSkeletonLayerUserLayer !== userLayer) { + this.removeRegularSkeletonLayerUserLayerListener?.(); + this.removeRegularSkeletonLayerUserLayerListener = undefined; + this.regularSkeletonLayerUserLayer = userLayer; + if (userLayer !== undefined) { + const update = () => { + const nextValue = this.computeHasRegularSkeletonLayer(userLayer); + if (this.regularSkeletonLayerWatchable.value !== nextValue) { + this.regularSkeletonLayerWatchable.value = nextValue; + this.redrawNeeded.dispatch(); + } + }; + update(); + this.removeRegularSkeletonLayerUserLayerListener = + userLayer.layersChanged.add(update); + } else if (this.regularSkeletonLayerWatchable.value) { + this.regularSkeletonLayerWatchable.value = false; + this.redrawNeeded.dispatch(); + } + } + return this.regularSkeletonLayerWatchable.value; + } + + private lodMatches( + chunk: SpatiallyIndexedSkeletonChunk, + targetLod: number | undefined, + ) { + if (targetLod === undefined || chunk.lod === undefined) { + return true; + } + return Math.abs(chunk.lod - targetLod) < 1e-6; + } + + get visibility() { + return this.displayState.objectAlpha; + } + + sources: SpatiallyIndexedSkeletonSourceEntry[]; + sources2d: SpatiallyIndexedSkeletonSourceEntry[]; + source: SpatiallyIndexedSkeletonSource; + + constructor( + public chunkManager: ChunkManager, + sources: + | SpatiallyIndexedSkeletonSourceEntry[] + | SpatiallyIndexedSkeletonSource, + public displayState: SkeletonLayerDisplayState & { + localPosition: WatchableValueInterface; + }, + options: SpatiallyIndexedSkeletonLayerOptions = {}, + ) { + super(); + this.registerDisposer(() => { + this.removeRegularSkeletonLayerUserLayerListener?.(); + this.removeRegularSkeletonLayerUserLayerListener = undefined; + this.regularSkeletonLayerUserLayer = undefined; + this.disposeOverlayChunk(); + }); + let sources3d: SpatiallyIndexedSkeletonSourceEntry[]; + let sources2d = options.sources2d ?? []; + if (Array.isArray(sources)) { + sources3d = sources; + } else { + sources3d = [ + { + chunkSource: sources, + chunkToMultiscaleTransform: mat4.create(), + }, + ]; + } + if (sources3d.length === 0 && sources2d.length > 0) { + sources3d = sources2d; + } + if (sources2d.length === 0) { + sources2d = sources3d; + } + if (sources3d.length === 0) { + throw new Error( + "SpatiallyIndexedSkeletonLayer requires at least one source.", + ); + } + this.sources = sources3d; + this.sources2d = sources2d; + this.source = sources3d[0].chunkSource; + this.localPosition = displayState.localPosition; + this.chunkTransform = this.registerDisposer( + makeCachedLazyDerivedWatchableValue( + (modelTransform) => + makeValueOrError(() => + getChunkTransformParameters(valueOrThrow(modelTransform)), + ), + this.displayState.transform, + ), + ); + this.gridLevel = + options.gridLevel ?? + (displayState as any).spatialSkeletonGridLevel3d ?? + new WatchableValue(0); + this.lod = + options.lod ?? (displayState as any).skeletonLod ?? new WatchableValue(0); + this.selectedNodeId = options.selectedNodeId; + this.pendingNodePositionVersion = options.pendingNodePositionVersion; + this.getPendingNodePositionOverride = options.getPendingNodePosition; + this.getCachedNodeInfo = options.getCachedNode; + this.inspectionState = options.inspectionState; + this.maxRetainedOverlaySegments = Math.max( + 1, + Math.round( + options.maxRetainedOverlaySegments ?? + DEFAULT_MAX_RETAINED_OVERLAY_SEGMENTS, + ), + ); + registerRedrawWhenSegmentationDisplayState3DChanged(displayState, this); + this.displayState.shaderError.value = undefined; + const { skeletonRenderingOptions: renderingOptions } = displayState; + this.registerDisposer( + renderingOptions.shader.changed.add(() => { + this.displayState.shaderError.value = undefined; + this.redrawNeeded.dispatch(); + }), + ); + + this.vertexAttributes = [ + ...this.source.vertexAttributes, + selectedNodeAttribute, + ]; + this.chunkGeometryRenderLayerInterface = { + vertexAttributes: this.source.vertexAttributes, + segmentColorAttributeIndex: this.segmentColorAttributeIndex, + dynamicSegmentAppearance: this.dynamicSegmentAppearance, + gl: this.gl, + fallbackShaderParameters: this.fallbackShaderParameters, + displayState: this.displayState, + }; + this.segmentColorAttributeIndex = undefined; + const selectedNodeIndex = this.vertexAttributes.findIndex( + (x) => x.name === selectedNodeAttribute.name, + ); + this.selectedNodeAttributeIndex = + selectedNodeIndex >= 0 ? selectedNodeIndex : undefined; + const requestRedraw = () => this.redrawNeeded.dispatch(); + const redrawWatchables = new Set(); + const registerNumericRedrawWatchable = ( + watchable: WatchableValueInterface | undefined, + ) => { + if (watchable === undefined) return; + const key = watchable as object; + if (redrawWatchables.has(key)) return; + redrawWatchables.add(key); + this.registerDisposer(watchable.changed.add(requestRedraw)); + }; + this.registerDisposer( + registerNested((context, segmentationGroup) => { + context.registerDisposer( + segmentationGroup.visibleSegments.changed.add(() => requestRedraw()), + ); + context.registerDisposer( + segmentationGroup.temporaryVisibleSegments.changed.add(() => + requestRedraw(), + ), + ); + context.registerDisposer( + segmentationGroup.useTemporaryVisibleSegments.changed.add(() => + requestRedraw(), + ), + ); + }, this.displayState.segmentationGroupState), + ); + this.registerDisposer( + registerNested((context, colorGroupState) => { + context.registerDisposer( + colorGroupState.segmentColorHash.changed.add(() => requestRedraw()), + ); + context.registerDisposer( + colorGroupState.segmentDefaultColor.changed.add(() => + requestRedraw(), + ), + ); + context.registerDisposer( + colorGroupState.segmentStatedColors.changed.add(() => + requestRedraw(), + ), + ); + }, this.displayState.segmentationColorGroupState), + ); + this.registerDisposer( + displayState.objectAlpha.changed.add(() => requestRedraw()), + ); + const selectedNodeWatchable = this.selectedNodeId; + if (selectedNodeWatchable?.changed) { + this.registerDisposer(selectedNodeWatchable.changed.add(requestRedraw)); + } + const pendingNodePositionVersion = options.pendingNodePositionVersion; + if (pendingNodePositionVersion?.changed) { + this.registerDisposer( + pendingNodePositionVersion.changed.add(() => { + this.redrawNeeded.dispatch(); + }), + ); + } + const inspectionState = this.inspectionState; + if (inspectionState !== undefined) { + this.registerDisposer( + inspectionState.nodeDataVersion.changed.add(() => { + this.redrawNeeded.dispatch(); + }), + ); + } + registerNumericRedrawWatchable(this.gridLevel); + registerNumericRedrawWatchable( + (displayState as any).spatialSkeletonGridLevel2d, + ); + registerNumericRedrawWatchable( + (displayState as any).spatialSkeletonGridLevel3d, + ); + registerNumericRedrawWatchable(this.lod); + registerNumericRedrawWatchable((displayState as any).spatialSkeletonLod2d); + registerNumericRedrawWatchable((displayState as any).skeletonLod); + if (displayState.hiddenObjectAlpha) { + this.registerDisposer( + displayState.hiddenObjectAlpha.changed.add(() => requestRedraw()), + ); + } + + // Create backend for perspective view chunk management + const sharedObject = this.registerDisposer( + new ChunkRenderLayerFrontend(this.layerChunkProgressInfo), + ); + const rpc = chunkManager.rpc!; + this.rpc = rpc; + sharedObject.RPC_TYPE_ID = SPATIALLY_INDEXED_SKELETON_RENDER_LAYER_RPC_ID; + + const renderScaleTargetWatchable = this.registerDisposer( + SharedWatchableValue.makeFromExisting( + rpc, + displayState.renderScaleTarget, + ), + ); + + const skeletonLodWatchable = this.registerDisposer( + SharedWatchableValue.makeFromExisting(rpc, this.lod), + ); + + const skeletonGridLevelWatchable = this.registerDisposer( + SharedWatchableValue.makeFromExisting(rpc, this.gridLevel), + ); + + sharedObject.initializeCounterpart(rpc, { + chunkManager: chunkManager.rpcId, + localPosition: this.registerDisposer( + SharedWatchableValue.makeFromExisting(rpc, this.localPosition), + ).rpcId, + renderScaleTarget: renderScaleTargetWatchable.rpcId, + skeletonLod: skeletonLodWatchable.rpcId, + skeletonGridLevel: skeletonGridLevelWatchable.rpcId, + }); + this.backend = sharedObject; + } + + get gl() { + return this.chunkManager.chunkQueueManager.gl; + } + + getSources(view: SpatiallyIndexedSkeletonView) { + return view === "2d" ? this.sources2d : this.sources; + } + + private selectSourcesForViewAndGrid( + view: SpatiallyIndexedSkeletonView, + gridLevel: number | undefined, + ) { + return selectSpatiallyIndexedSkeletonEntriesForView( + this.getSources(view), + view, + gridLevel, + getSpatiallyIndexedSkeletonSourceView, + getSpatiallyIndexedSkeletonGridIndex, + ); + } + + private getCachedNodeSnapshot(nodeId: number) { + const cachedNode = this.getCachedNodeInfo?.(nodeId); + if (cachedNode === undefined) { + return undefined; + } + const pendingPosition = + this.getPendingNodePositionOverride?.(cachedNode.nodeId) ?? + cachedNode.position; + return { + ...cachedNode, + position: new Float32Array([ + Number(pendingPosition[0]), + Number(pendingPosition[1]), + Number(pendingPosition[2]), + ]), + }; + } + + private getVisibleChunksForView(view: SpatiallyIndexedSkeletonView) { + return this.visibleChunksByView.get(view); + } + + private getGridLevelForView(view: SpatiallyIndexedSkeletonView) { + const displayState = this.displayState as any; + return ( + view === "2d" + ? displayState.spatialSkeletonGridLevel2d?.value + : displayState.spatialSkeletonGridLevel3d?.value + ) as number | undefined; + } + + private getGridChunkStatsWatchable(view: SpatiallyIndexedSkeletonView) { + return ( + view === "2d" + ? (this.displayState as any).spatialSkeletonGridChunkStats2d + : (this.displayState as any).spatialSkeletonGridChunkStats3d + ) as WatchableValue | undefined; + } + + private updateSelectedChunkStatsForView(view: SpatiallyIndexedSkeletonView) { + const visibleChunkKeysByRenderedView = + this.visibleChunkKeysByRenderedView.get(view); + const selectedSourceIds = new Set(); + for (const sourceEntry of this.selectSourcesForViewAndGrid( + view, + this.getGridLevelForView(view), + )) { + selectedSourceIds.add(getObjectId(sourceEntry.chunkSource)); + } + const { presentCount, totalCount } = mergeVisibleSpatialChunkKeyCounts( + visibleChunkKeysByRenderedView?.values() ?? [], + selectedSourceIds, + ); + updateSpatialSkeletonGridChunkStatsWatchable( + this.getGridChunkStatsWatchable(view), + presentCount, + totalCount, + ); + } + + setVisibleChunkKeysForRenderedView( + view: SpatiallyIndexedSkeletonView, + renderedViewId: number, + visibleChunkKeysBySource: VisibleSpatialChunkKeysBySource, + ) { + let visibleChunkKeysByRenderedView = + this.visibleChunkKeysByRenderedView.get(view); + if (visibleChunkKeysByRenderedView === undefined) { + visibleChunkKeysByRenderedView = new Map(); + this.visibleChunkKeysByRenderedView.set( + view, + visibleChunkKeysByRenderedView, + ); + } + visibleChunkKeysByRenderedView.set( + renderedViewId, + visibleChunkKeysBySource, + ); + this.updateSelectedChunkStatsForView(view); + } + + clearVisibleChunkKeysForRenderedView( + view: SpatiallyIndexedSkeletonView, + renderedViewId: number, + ) { + const visibleChunkKeysByRenderedView = + this.visibleChunkKeysByRenderedView.get(view); + if (visibleChunkKeysByRenderedView === undefined) { + return; + } + if (!visibleChunkKeysByRenderedView.delete(renderedViewId)) { + return; + } + if (visibleChunkKeysByRenderedView.size === 0) { + this.visibleChunkKeysByRenderedView.delete(view); + } + this.updateSelectedChunkStatsForView(view); + } + + private *iterateCandidateChunks( + selectedSources: readonly SpatiallyIndexedSkeletonSourceEntry[], + targetLod: number | undefined, + options: { + view?: SpatiallyIndexedSkeletonView; + } = {}, + ): Iterable { + const visibleChunksBySource = + options.view === undefined + ? undefined + : this.getVisibleChunksForView(options.view); + const useVisibleChunks = visibleChunksBySource !== undefined; + for (const sourceEntry of selectedSources) { + if (useVisibleChunks) { + const visibleChunks = visibleChunksBySource.get( + getObjectId(sourceEntry.chunkSource), + ); + if (visibleChunks === undefined) { + continue; + } + for (const chunk of visibleChunks) { + if (!this.lodMatches(chunk, targetLod)) continue; + if (chunk.state !== ChunkState.GPU_MEMORY) continue; + yield chunk; + } + continue; + } + for (const chunk of sourceEntry.chunkSource.chunks.values()) { + const typedChunk = chunk as SpatiallyIndexedSkeletonChunk; + if (!this.lodMatches(typedChunk, targetLod)) continue; + if (typedChunk.state !== ChunkState.GPU_MEMORY) continue; + yield typedChunk; + } + } + } + + invalidateSourceCaches() { + let invalidated = false; + for (const chunkSource of this.iterateUniqueChunkSources()) { + chunkSource.invalidateCache(); + invalidated = true; + } + if (!invalidated) { + return false; + } + this.redrawNeeded.dispatch(); + return true; + } + + private getChunkPositionAndSegmentArrays( + chunk: SpatiallyIndexedSkeletonChunk, + ) { + const offsets = chunk.vertexAttributeOffsets; + if (!offsets || offsets.length < 2) return undefined; + const positions = new Float32Array( + chunk.vertexAttributes.buffer, + chunk.vertexAttributes.byteOffset + offsets[0], + chunk.numVertices * 3, + ); + const segmentIds = new Uint32Array( + chunk.vertexAttributes.buffer, + chunk.vertexAttributes.byteOffset + offsets[1], + chunk.numVertices, + ); + return { positions, segmentIds }; + } + + resolveSegmentPickFromChunk( + chunk: SpatiallyIndexedSkeletonChunk, + pickedOffset: number, + kind: "node" | "edge", + ) { + const data = this.getChunkPositionAndSegmentArrays(chunk); + if (data === undefined) { + return undefined; + } + return resolveSpatiallyIndexedSkeletonSegmentPick( + chunk, + data.segmentIds, + pickedOffset, + kind, + ); + } + + resolveNodePickFromChunk( + chunk: SpatiallyIndexedSkeletonChunk, + pickedOffset: number, + ) { + const data = this.getChunkPositionAndSegmentArrays(chunk); + if ( + data === undefined || + pickedOffset < 0 || + pickedOffset >= chunk.numVertices || + pickedOffset >= chunk.nodeIds.length + ) { + return undefined; + } + const nodeId = chunk.nodeIds[pickedOffset]; + if (!Number.isSafeInteger(nodeId) || nodeId <= 0) { + return undefined; + } + const segmentId = resolveSpatiallyIndexedSkeletonSegmentPick( + chunk, + data.segmentIds, + pickedOffset, + "node", + ); + if (segmentId === undefined) { + return undefined; + } + const baseOffset = pickedOffset * 3; + return { + nodeId, + segmentId, + position: data.positions.subarray(baseOffset, baseOffset + 3), + revisionToken: chunk.nodeRevisionTokens[pickedOffset], + }; + } + + updateVisibleChunksForView( + view: SpatiallyIndexedSkeletonView, + transformedSources: readonly TransformedSource[][], + projectionParameters: any, + lod: number | undefined, + renderedViewId?: number, + ) { + if (lod === undefined) { + this.visibleChunksByView.delete(view); + if (renderedViewId !== undefined) { + this.clearVisibleChunkKeysForRenderedView(view, renderedViewId); + } + return; + } + const lodSuffix = `:${lod}`; + const chunksBySource: VisibleSpatialChunksBySource = new Map(); + const visibleChunkKeysBySource: VisibleSpatialChunkKeysBySource = new Map(); + const seenChunkKeysBySource = new Map>(); + for (const scales of transformedSources) { + for (const tsource of scales) { + const sourceId = getObjectId(tsource.source); + let visibleChunks = chunksBySource.get(sourceId); + if (visibleChunks === undefined) { + visibleChunks = []; + chunksBySource.set(sourceId, visibleChunks); + } + const visibleChunkKeys = getOrCreateVisibleSpatialChunkKeys( + visibleChunkKeysBySource, + sourceId, + ); + let seenChunkKeys = seenChunkKeysBySource.get(sourceId); + if (seenChunkKeys === undefined) { + seenChunkKeys = new Set(); + seenChunkKeysBySource.set(sourceId, seenChunkKeys); + } + forEachVisibleVolumetricChunk( + projectionParameters, + this.localPosition.value, + tsource, + (positionInChunks) => { + const chunkKey = `${positionInChunks.join()}${lodSuffix}`; + if (seenChunkKeys!.has(chunkKey)) { + return; + } + seenChunkKeys!.add(chunkKey); + visibleChunkKeys.totalChunkKeys.add(chunkKey); + const chunkSource = + tsource.source as SpatiallyIndexedSkeletonSource; + const chunk = chunkSource.chunks.get(chunkKey) as + | SpatiallyIndexedSkeletonChunk + | undefined; + if (chunk?.state !== ChunkState.GPU_MEMORY) { + return; + } + visibleChunkKeys.presentChunkKeys.add(chunkKey); + (visibleChunks as SpatiallyIndexedSkeletonChunk[]).push(chunk); + }, + ); + } + } + this.visibleChunksByView.set(view, chunksBySource); + if (renderedViewId !== undefined) { + this.setVisibleChunkKeysForRenderedView( + view, + renderedViewId, + visibleChunkKeysBySource, + ); + } + } + + private areVisibleChunksReady( + transformedSources: readonly TransformedSource[][], + projectionParameters: any, + lod: number | undefined, + ) { + if (this.displayState.objectAlpha.value <= 0.0) { + return true; + } + if (lod === undefined || transformedSources.length === 0) { + return false; + } + const lodSuffix = `:${lod}`; + const seenChunkKeysBySource = new Map>(); + let ready = true; + for (const scales of transformedSources) { + for (const tsource of scales) { + const sourceId = getObjectId(tsource.source); + let seenChunkKeys = seenChunkKeysBySource.get(sourceId); + if (seenChunkKeys === undefined) { + seenChunkKeys = new Set(); + seenChunkKeysBySource.set(sourceId, seenChunkKeys); + } + forEachVisibleVolumetricChunk( + projectionParameters, + this.localPosition.value, + tsource, + (positionInChunks) => { + if (!ready) { + return; + } + const chunkKey = `${positionInChunks.join()}${lodSuffix}`; + if (seenChunkKeys!.has(chunkKey)) { + return; + } + seenChunkKeys!.add(chunkKey); + const chunkSource = + tsource.source as SpatiallyIndexedSkeletonSource; + const chunk = chunkSource.chunks.get(chunkKey) as + | SpatiallyIndexedSkeletonChunk + | undefined; + if (chunk?.state !== ChunkState.GPU_MEMORY) { + ready = false; + } + }, + ); + if (!ready) { + return false; + } + } + } + return true; + } + + getNode( + nodeId: number, + options: { + lod?: number; + } = {}, + ): SpatiallyIndexedSkeletonNode | undefined { + void options; + if (!Number.isSafeInteger(nodeId) || nodeId <= 0) return undefined; + return this.getCachedNodeSnapshot(nodeId); + } + + getNodes( + options: { + segmentId?: bigint; + lod?: number; + } = {}, + ): SpatiallyIndexedSkeletonNode[] { + void options.lod; + const normalizedSegmentFilter = + options.segmentId === undefined + ? undefined + : Math.round(Number(options.segmentId)); + const useSegmentFilter = + normalizedSegmentFilter !== undefined && + Number.isFinite(normalizedSegmentFilter); + const segmentIds = + normalizedSegmentFilter === undefined + ? this.getActiveEditableSegmentIds() + : [normalizedSegmentFilter]; + const nodes = new Map(); + for (const segmentId of segmentIds) { + const segmentNodes = + this.inspectionState?.getCachedSegmentNodes(segmentId) ?? []; + for (const node of segmentNodes) { + if (nodes.has(node.nodeId)) continue; + const cachedNode = this.getCachedNodeSnapshot(node.nodeId); + if (cachedNode === undefined) continue; + if ( + useSegmentFilter && + normalizedSegmentFilter !== undefined && + cachedNode.segmentId !== normalizedSegmentFilter + ) { + continue; + } + nodes.set(cachedNode.nodeId, cachedNode); + } + } + return [...nodes.values()].sort((a, b) => a.nodeId - b.nodeId); + } + + private drawBrowsePass( + renderContext: SliceViewPanelRenderContext | PerspectiveViewRenderContext, + layer: RenderLayer, + renderHelper: RenderHelper, + modelMatrix: mat4, + lineWidth: number, + pointDiameter: number, + hasRegularSkeletonLayer: boolean, + selectedSources: readonly SpatiallyIndexedSkeletonSourceEntry[], + targetLod: number | undefined, + view: SpatiallyIndexedSkeletonView, + ) { + const { gl } = this; + const excludedSegments = this.getBrowsePassExcludedSegments(); + const edgeShaderResult = renderHelper.edgeShaderGetter( + renderContext.emitter, + ); + const nodeShaderResult = renderHelper.nodeShaderGetter( + renderContext.emitter, + ); + const { shader: edgeShader, parameters: edgeShaderParameters } = + edgeShaderResult; + const { shader: nodeShader, parameters: nodeShaderParameters } = + nodeShaderResult; + if (edgeShader === null || nodeShader === null) { + return; + } + const { shaderControlState } = this.displayState.skeletonRenderingOptions; + edgeShader.bind(); + renderHelper.beginLayer(gl, edgeShader, renderContext, modelMatrix); + renderHelper.setEdgePickInstanceStride(gl, edgeShader, 0); + setControlsInShader( + gl, + edgeShader, + shaderControlState, + edgeShaderParameters.parseResult.controls, + ); + gl.uniform1f(edgeShader.uniform("uLineWidth"), lineWidth); + nodeShader.bind(); + renderHelper.beginLayer(gl, nodeShader, renderContext, modelMatrix); + gl.uniform1f(nodeShader.uniform("uNodeDiameter"), pointDiameter); + renderHelper.setNodePickInstanceStride(gl, nodeShader, 0); + setControlsInShader( + gl, + nodeShader, + shaderControlState, + nodeShaderParameters.parseResult.controls, + ); + const baseColor = new Float32Array([1, 1, 1, 1]); + edgeShader.bind(); + renderHelper.setColor(gl, edgeShader, baseColor); + renderHelper.enableDynamicSegmentAppearance( + gl, + edgeShader, + hasRegularSkeletonLayer, + excludedSegments, + ); + nodeShader.bind(); + renderHelper.setColor(gl, nodeShader, baseColor); + renderHelper.enableDynamicSegmentAppearance( + gl, + nodeShader, + hasRegularSkeletonLayer, + excludedSegments, + ); + if (renderContext.emitPickID) { + edgeShader.bind(); + renderHelper.setPickID(gl, edgeShader, 0); + renderHelper.setEdgePickInstanceStride(gl, edgeShader, 0); + nodeShader.bind(); + renderHelper.setPickID(gl, nodeShader, 0); + renderHelper.setNodePickInstanceStride(gl, nodeShader, 0); + } + for (const chunk of this.iterateCandidateChunks( + selectedSources, + targetLod, + { + view, + }, + )) { + if (renderContext.emitPickID) { + let edgePickId = 0; + let edgePickStride = 0; + let nodePickId = 0; + let nodePickStride = 0; + if (chunk.numIndices > 0) { + edgePickId = renderContext.pickIDs.register( + layer, + chunk.numIndices / 2, + 0n, + { + kind: "segment-edge", + chunk, + } satisfies SpatiallyIndexedSkeletonPickData, + ); + edgePickStride = 1; + } + if (chunk.numVertices > 0) { + nodePickId = renderContext.pickIDs.register( + layer, + chunk.numVertices, + 0n, + { + kind: "segment-node", + chunk, + } satisfies SpatiallyIndexedSkeletonPickData, + ); + nodePickStride = 1; + } + edgeShader.bind(); + renderHelper.setPickID(gl, edgeShader, edgePickId); + renderHelper.setEdgePickInstanceStride(gl, edgeShader, edgePickStride); + nodeShader.bind(); + renderHelper.setPickID(gl, nodeShader, nodePickId); + renderHelper.setNodePickInstanceStride(gl, nodeShader, nodePickStride); + } + renderHelper.drawSkeleton( + gl, + edgeShader, + nodeShader, + chunk, + renderContext.projectionParameters, + ); + } + renderHelper.disableDynamicSegmentAppearance(gl, edgeShader); + renderHelper.disableDynamicSegmentAppearance(gl, nodeShader); + renderHelper.endLayer(gl, edgeShader, nodeShader); + } + + private drawInspectionOverlayPass( + renderContext: SliceViewPanelRenderContext | PerspectiveViewRenderContext, + layer: RenderLayer, + renderHelper: RenderHelper, + modelMatrix: mat4, + lineWidth: number, + pointDiameter: number, + hasRegularSkeletonLayer: boolean, + ) { + const overlayChunk = this.resolveSourceBackedOverlayChunk(); + if (overlayChunk === undefined) { + return; + } + const { gl } = this; + const edgeShaderResult = renderHelper.edgeShaderGetter( + renderContext.emitter, + ); + const nodeShaderResult = renderHelper.nodeShaderGetter( + renderContext.emitter, + ); + const { shader: edgeShader, parameters: edgeShaderParameters } = + edgeShaderResult; + const { shader: nodeShader, parameters: nodeShaderParameters } = + nodeShaderResult; + if (edgeShader === null || nodeShader === null) { + return; + } + const { shaderControlState } = this.displayState.skeletonRenderingOptions; + edgeShader.bind(); + renderHelper.beginLayer(gl, edgeShader, renderContext, modelMatrix); + renderHelper.setEdgePickInstanceStride(gl, edgeShader, 0); + setControlsInShader( + gl, + edgeShader, + shaderControlState, + edgeShaderParameters.parseResult.controls, + ); + gl.uniform1f(edgeShader.uniform("uLineWidth"), lineWidth); + nodeShader.bind(); + renderHelper.beginLayer(gl, nodeShader, renderContext, modelMatrix); + gl.uniform1f(nodeShader.uniform("uNodeDiameter"), pointDiameter); + renderHelper.setNodePickInstanceStride(gl, nodeShader, 0); + setControlsInShader( + gl, + nodeShader, + shaderControlState, + nodeShaderParameters.parseResult.controls, + ); + const baseColor = new Float32Array([1, 1, 1, 1]); + edgeShader.bind(); + renderHelper.setColor(gl, edgeShader, baseColor); + renderHelper.enableDynamicSegmentAppearance( + gl, + edgeShader, + hasRegularSkeletonLayer, + ); + nodeShader.bind(); + renderHelper.setColor(gl, nodeShader, baseColor); + renderHelper.enableDynamicSegmentAppearance( + gl, + nodeShader, + hasRegularSkeletonLayer, + ); + if (renderContext.emitPickID) { + const edgePickId = + overlayChunk.numIndices > 0 && + overlayChunk.pickEdgeSegmentIds !== undefined && + overlayChunk.pickEdgeSegmentIds.length > 0 + ? renderContext.pickIDs.register( + layer, + overlayChunk.pickEdgeSegmentIds.length, + 0n, + { + kind: "edge", + segmentIds: overlayChunk.pickEdgeSegmentIds, + } satisfies SpatiallyIndexedSkeletonPickData, + ) + : 0; + edgeShader.bind(); + renderHelper.setPickID(gl, edgeShader, edgePickId); + renderHelper.setEdgePickInstanceStride( + gl, + edgeShader, + edgePickId === 0 ? 0 : 1, + ); + nodeShader.bind(); + const nodePickId = + overlayChunk.numVertices > 0 && + overlayChunk.pickNodeIds !== undefined && + overlayChunk.pickNodePositions !== undefined && + overlayChunk.pickSegmentIds !== undefined + ? renderContext.pickIDs.register( + layer, + overlayChunk.numVertices, + 0n, + { + kind: "node", + nodeIds: overlayChunk.pickNodeIds, + nodePositions: overlayChunk.pickNodePositions, + segmentIds: overlayChunk.pickSegmentIds, + } satisfies SpatiallyIndexedSkeletonPickData, + ) + : 0; + renderHelper.setPickID(gl, nodeShader, nodePickId); + renderHelper.setNodePickInstanceStride( + gl, + nodeShader, + nodePickId === 0 ? 0 : 1, + ); + } + renderHelper.drawSkeleton( + gl, + edgeShader, + nodeShader, + overlayChunk, + renderContext.projectionParameters, + ); + renderHelper.disableDynamicSegmentAppearance(gl, edgeShader); + renderHelper.disableDynamicSegmentAppearance(gl, nodeShader); + renderHelper.endLayer(gl, edgeShader, nodeShader); + } + + draw( + renderContext: SliceViewPanelRenderContext | PerspectiveViewRenderContext, + layer: RenderLayer, + renderHelper: RenderHelper, + browseRenderHelper: RenderHelper, + renderOptions: ViewSpecificSkeletonRenderingOptions, + attachment: VisibleLayerInfo< + LayerView, + ThreeDimensionalRenderLayerAttachmentState + >, + drawOptions?: { + view?: SpatiallyIndexedSkeletonView; + gridLevel?: number; + lod?: number; + }, + ) { + const lineWidth = renderOptions.lineWidth.value; + const { displayState } = this; + if (displayState.objectAlpha.value <= 0.0) { + return; + } + const modelMatrix = update3dRenderLayerAttachment( + displayState.transform.value, + renderContext.projectionParameters.displayDimensionRenderInfo, + attachment, + ); + if (modelMatrix === undefined) return; + + const hasRegularSkeletonLayer = this.updateHasRegularSkeletonLayerWatchable( + layer.userLayer, + ); + const targetLod = drawOptions?.lod; + const view = drawOptions?.view ?? "3d"; + const pointDiameter = getSkeletonNodeDiameter( + renderOptions.mode.value, + lineWidth, + ); + + const selectedSources = this.selectSourcesForViewAndGrid( + view, + drawOptions?.gridLevel, + ); + this.drawBrowsePass( + renderContext, + layer, + browseRenderHelper, + modelMatrix, + lineWidth, + pointDiameter, + hasRegularSkeletonLayer, + selectedSources, + targetLod, + view, + ); + this.drawInspectionOverlayPass( + renderContext, + layer, + renderHelper, + modelMatrix, + lineWidth, + pointDiameter, + hasRegularSkeletonLayer, + ); + } + + isReady( + transformedSources?: readonly TransformedSource[][], + projectionParameters?: any, + lod?: number | undefined, + ) { + if ( + transformedSources === undefined || + projectionParameters === undefined + ) { + return this.displayState.objectAlpha.value <= 0.0; + } + return this.areVisibleChunksReady( + transformedSources, + projectionParameters, + lod, + ); + } +} + +export class PerspectiveViewSpatiallyIndexedSkeletonLayer extends PerspectiveViewRenderLayer { + private renderHelper: RenderHelper; + private browseRenderHelper: RenderHelper; + private renderOptions: ViewSpecificSkeletonRenderingOptions; + private transformedSources: TransformedSource[][] = []; + backend: ChunkRenderLayerFrontend; + + constructor(public base: SpatiallyIndexedSkeletonLayer) { + super(); + this.backend = base.backend; + this.renderHelper = this.registerDisposer(new RenderHelper(base, false)); + this.browseRenderHelper = this.registerDisposer( + new RenderHelper(base.chunkGeometryRenderLayerInterface, false), + ); + this.renderOptions = base.displayState.skeletonRenderingOptions.params3d; + + this.layerChunkProgressInfo = base.layerChunkProgressInfo; + this.registerDisposer(base); + this.registerDisposer(base.redrawNeeded.add(this.redrawNeeded.dispatch)); + const { renderOptions } = this; + this.registerDisposer( + renderOptions.mode.changed.add(this.redrawNeeded.dispatch), + ); + this.registerDisposer( + renderOptions.lineWidth.changed.add(this.redrawNeeded.dispatch), + ); + const histogram = (base.displayState as any) + .spatialSkeletonGridRenderScaleHistogram3d as + | RenderScaleHistogram + | undefined; + if (histogram !== undefined) { + this.registerDisposer(histogram.visibility.add(this.visibility)); + } + } + + attach( + attachment: VisibleLayerInfo< + PerspectivePanel, + ThreeDimensionalRenderLayerAttachmentState + >, + ) { + super.attach(attachment); + attachment.registerDisposer(() => + this.base.clearVisibleChunkKeysForRenderedView( + "3d", + attachment.view.rpcId, + ), + ); + + // Manually add layer to backend + const backend = this.backend; + if (backend && backend.rpc) { + backend.rpc.invoke(RENDERED_VIEW_ADD_LAYER_RPC_ID, { + layer: backend.rpcId, + view: attachment.view.rpcId, + }); + } + + // Capture references to avoid losing 'this' context in callback + const baseLayer = this.base; + const redrawNeeded = this.redrawNeeded; + + attachment.registerDisposer( + registerNested( + (context, transform, displayDimensionRenderInfo) => { + const transformedSources = getVolumetricTransformedSources( + displayDimensionRenderInfo, + transform, + () => [ + baseLayer.getSources("3d").map((sourceEntry) => ({ + chunkSource: sourceEntry.chunkSource, + chunkToMultiscaleTransform: + sourceEntry.chunkToMultiscaleTransform, + })), + ], + attachment.messages, + this, + ); + for (const scales of transformedSources) { + for (const tsource of scales) { + context.registerDisposer(tsource.source); + } + } + attachment.view.flushBackendProjectionParameters(); + this.transformedSources = transformedSources; + baseLayer.rpc!.invoke( + SPATIALLY_INDEXED_SKELETON_RENDER_LAYER_UPDATE_SOURCES_RPC_ID, + { + layer: baseLayer.backend.rpcId, + view: attachment.view.rpcId, + displayDimensionRenderInfo, + sources: serializeAllTransformedSources(transformedSources), + }, + ); + redrawNeeded.dispatch(); + return transformedSources; + }, + baseLayer.displayState.transform, + attachment.view.displayDimensionRenderInfo, + ), + ); + } + + get gl() { + return this.base.gl; + } + + get isTransparent() { + return this.base.displayState.objectAlpha.value < 1.0; + } + + getValueAt(position: Float32Array) { + position; + return undefined; + } + + transformPickedValue(pickState: PickState) { + const pickedSegmentId = pickState.pickedSpatialSkeleton?.segmentId; + if ( + typeof pickedSegmentId === "number" && + Number.isSafeInteger(pickedSegmentId) + ) { + return BigInt(pickedSegmentId); + } + return undefined; + } + + updateMouseState( + mouseState: MouseSelectionState, + _pickedValue: bigint, + pickedOffset: number, + data: any, + ) { + const pickData = data as SpatiallyIndexedSkeletonPickData | undefined; + if (pickData === undefined) return; + if (pickData.kind === "node") { + if ( + pickedOffset < 0 || + pickedOffset >= pickData.nodeIds.length || + pickedOffset >= pickData.segmentIds.length + ) { + return; + } + const segmentId = pickData.segmentIds[pickedOffset]; + if (!Number.isSafeInteger(segmentId) || segmentId <= 0) { + return; + } + mouseState.pickedSpatialSkeleton = { segmentId }; + if ( + !getVisibleSegments( + this.base.displayState.segmentationGroupState.value, + ).has(BigInt(segmentId)) + ) { + return; + } + const nodeId = pickData.nodeIds[pickedOffset]; + if (!Number.isSafeInteger(nodeId) || nodeId <= 0) return; + const nodePosition = pickData.nodePositions.subarray( + pickedOffset * 3, + pickedOffset * 3 + 3, + ); + mouseState.pickedSpatialSkeleton = { + nodeId, + segmentId, + position: new Float32Array(nodePosition), + }; + const transform = this.base.displayState.transform.value; + if (transform.error === undefined) { + setMouseStatePositionFromSpatialSkeletonNode( + mouseState, + nodePosition, + transform, + ); + } + return; + } + if (pickData.kind === "edge") { + if (pickedOffset < 0 || pickedOffset >= pickData.segmentIds.length) { + return; + } + const segmentId = pickData.segmentIds[pickedOffset]; + if (Number.isSafeInteger(segmentId) && segmentId > 0) { + mouseState.pickedSpatialSkeleton = { segmentId }; + } + return; + } + if (pickData.kind === "segment-node" || pickData.kind === "segment-edge") { + if (pickData.kind === "segment-node") { + const pickedNode = this.base.resolveNodePickFromChunk( + pickData.chunk, + pickedOffset, + ); + if (pickedNode !== undefined) { + mouseState.pickedSpatialSkeleton = { + nodeId: pickedNode.nodeId, + segmentId: pickedNode.segmentId, + position: new Float32Array(pickedNode.position), + revisionToken: pickedNode.revisionToken, + }; + } + return; + } + const segmentId = this.base.resolveSegmentPickFromChunk( + pickData.chunk, + pickedOffset, + "edge", + ); + if (segmentId !== undefined) { + mouseState.pickedSpatialSkeleton = { segmentId }; + } + } + } + + draw( + renderContext: PerspectiveViewRenderContext, + attachment: VisibleLayerInfo< + PerspectivePanel, + ThreeDimensionalRenderLayerAttachmentState + >, + ) { + const pixelSizeWatchable = (this.base.displayState as any) + .spatialSkeletonGridPixelSize3d as + | WatchableValueInterface + | undefined; + if (pixelSizeWatchable !== undefined) { + const voxelPhysicalScales = + renderContext.projectionParameters.displayDimensionRenderInfo + ?.voxelPhysicalScales; + if (voxelPhysicalScales !== undefined) { + const { invViewMatrix } = renderContext.projectionParameters; + let computedPixelSize = 0; + for (let i = 0; i < 3; ++i) { + const s = voxelPhysicalScales[i]; + const x = invViewMatrix[i]; + computedPixelSize += (s * x) ** 2; + } + const pixelSize = Math.sqrt(computedPixelSize); + if ( + Number.isFinite(pixelSize) && + pixelSizeWatchable.value !== pixelSize + ) { + pixelSizeWatchable.value = pixelSize; + } + } + } + if (!renderContext.emitColor && renderContext.alreadyEmittedPickID) { + return; + } + const displayState = this.base.displayState as any; + const lodValue = displayState.skeletonLod?.value as number | undefined; + this.base.updateVisibleChunksForView( + "3d", + this.transformedSources, + renderContext.projectionParameters, + lodValue, + attachment.view.rpcId, + ); + const levels = displayState.spatialSkeletonGridLevels?.value as + | Array<{ size: { x: number; y: number; z: number }; lod: number }> + | undefined; + const histogram = displayState.spatialSkeletonGridRenderScaleHistogram3d as + | RenderScaleHistogram + | undefined; + if (histogram !== undefined) { + const frameNumber = + this.base.chunkManager.chunkQueueManager.frameNumberCounter.frameNumber; + const relative = + displayState.spatialSkeletonGridResolutionRelative3d?.value === true; + const pixelSize = Math.max(pixelSizeWatchable?.value ?? 1, 1e-6); + updateSpatialSkeletonGridRenderScaleHistogram( + histogram, + frameNumber, + this.transformedSources, + renderContext.projectionParameters, + this.base.localPosition.value, + lodValue, + levels, + relative, + pixelSize, + ); + } + this.base.draw( + renderContext, + this, + this.renderHelper, + this.browseRenderHelper, + this.renderOptions, + attachment, + { + view: "3d", + gridLevel: displayState.spatialSkeletonGridLevel3d?.value as + | number + | undefined, + lod: lodValue, + }, + ); + } + + isReady( + renderContext: PerspectiveViewReadyRenderContext, + _attachment: VisibleLayerInfo< + PerspectivePanel, + ThreeDimensionalRenderLayerAttachmentState + >, + ) { + const displayState = this.base.displayState as any; + const lodValue = displayState.skeletonLod?.value as number | undefined; + return this.base.isReady( + this.transformedSources, + renderContext.projectionParameters, + lodValue, + ); + } +} + +export class SliceViewSpatiallyIndexedSkeletonLayer extends SliceViewRenderLayer { + private renderOptions: ViewSpecificSkeletonRenderingOptions; + private trackedChunkStatsSliceViews = new Set(); + constructor(public base: SpatiallyIndexedSkeletonLayer) { + super( + base.chunkManager, + { + getSources: () => { + return [ + [ + { + chunkSource: base.source, + chunkToMultiscaleTransform: mat4.create(), + }, + ], + ]; + }, + } as any, + { + transform: base.displayState.transform, + localPosition: (base.displayState as any).localPosition, + }, + ); + // @ts-expect-error RenderHelper requires panel-specific initialization here. + this.renderHelper = this.registerDisposer(new RenderHelper(base, true)); + this.renderOptions = base.displayState.skeletonRenderingOptions.params2d; + this.layerChunkProgressInfo = base.layerChunkProgressInfo; + this.registerDisposer(base); + const { renderOptions } = this; + this.registerDisposer( + renderOptions.mode.changed.add(this.redrawNeeded.dispatch), + ); + this.registerDisposer( + renderOptions.lineWidth.changed.add(this.redrawNeeded.dispatch), + ); + this.registerDisposer(base.redrawNeeded.add(this.redrawNeeded.dispatch)); + this.initializeCounterpart(); + } + get gl() { + return this.base.gl; + } + + getValueAt(position: Float32Array) { + position; + return undefined; + } + + draw(renderContext: SliceViewRenderContext) { + const displayState = this.base.displayState as any; + const lodValue = displayState.spatialSkeletonLod2d?.value as + | number + | undefined; + const sliceView = renderContext.sliceView; + const sliceViewId = sliceView.rpcId; + if (!this.trackedChunkStatsSliceViews.has(sliceViewId)) { + this.trackedChunkStatsSliceViews.add(sliceViewId); + sliceView.registerDisposer(() => { + this.trackedChunkStatsSliceViews.delete(sliceViewId); + this.base.clearVisibleChunkKeysForRenderedView("2d", sliceViewId); + }); + } + if (displayState.objectAlpha?.value <= 0.0 || lodValue === undefined) { + this.base.clearVisibleChunkKeysForRenderedView("2d", sliceViewId); + return; + } + const visibleSources = + renderContext.sliceView.visibleLayers.get(this)?.visibleSources ?? []; + this.base.setVisibleChunkKeysForRenderedView( + "2d", + sliceViewId, + collectPlaneIntersectingSpatialChunkKeysBySource( + visibleSources, + renderContext.projectionParameters, + this.base.localPosition.value, + lodValue, + ), + ); + } +} + +export class SliceViewPanelSpatiallyIndexedSkeletonLayer extends SliceViewPanelRenderLayer { + private renderHelper: RenderHelper; + private browseRenderHelper: RenderHelper; + private renderOptions: ViewSpecificSkeletonRenderingOptions; + private transformedSources: TransformedSource[][] = []; + constructor(public base: SpatiallyIndexedSkeletonLayer) { + super(); + this.renderHelper = this.registerDisposer(new RenderHelper(base, true)); + this.browseRenderHelper = this.registerDisposer( + new RenderHelper(base.chunkGeometryRenderLayerInterface, true), + ); + this.renderOptions = base.displayState.skeletonRenderingOptions.params2d; + this.layerChunkProgressInfo = base.layerChunkProgressInfo; + this.registerDisposer(base); + const { renderOptions } = this; + this.registerDisposer( + renderOptions.mode.changed.add(this.redrawNeeded.dispatch), + ); + this.registerDisposer( + renderOptions.lineWidth.changed.add(this.redrawNeeded.dispatch), + ); + const gridLevel2d = (base.displayState as any).spatialSkeletonGridLevel2d; + if (gridLevel2d?.changed) { + this.registerDisposer( + gridLevel2d.changed.add(this.redrawNeeded.dispatch), + ); + } + const lod2d = (base.displayState as any).spatialSkeletonLod2d; + if (lod2d?.changed) { + this.registerDisposer(lod2d.changed.add(this.redrawNeeded.dispatch)); + } + const histogram = (base.displayState as any) + .spatialSkeletonGridRenderScaleHistogram2d as + | RenderScaleHistogram + | undefined; + if (histogram !== undefined) { + this.registerDisposer(histogram.visibility.add(this.visibility)); + } + this.registerDisposer(base.redrawNeeded.add(this.redrawNeeded.dispatch)); + } + get gl() { + return this.base.gl; + } + + getValueAt(position: Float32Array) { + position; + return undefined; + } + + transformPickedValue(pickState: PickState) { + const pickedSegmentId = pickState.pickedSpatialSkeleton?.segmentId; + if ( + typeof pickedSegmentId === "number" && + Number.isSafeInteger(pickedSegmentId) + ) { + return BigInt(pickedSegmentId); + } + return undefined; + } + + updateMouseState( + mouseState: MouseSelectionState, + _pickedValue: bigint, + pickedOffset: number, + data: any, + ) { + const pickData = data as SpatiallyIndexedSkeletonPickData | undefined; + if (pickData === undefined) return; + if (pickData.kind === "node") { + if ( + pickedOffset < 0 || + pickedOffset >= pickData.nodeIds.length || + pickedOffset >= pickData.segmentIds.length + ) { + return; + } + const segmentId = pickData.segmentIds[pickedOffset]; + if (!Number.isSafeInteger(segmentId) || segmentId <= 0) { + return; + } + mouseState.pickedSpatialSkeleton = { segmentId }; + if ( + !getVisibleSegments( + this.base.displayState.segmentationGroupState.value, + ).has(BigInt(segmentId)) + ) { + return; + } + const nodeId = pickData.nodeIds[pickedOffset]; + if (!Number.isSafeInteger(nodeId) || nodeId <= 0) return; + const nodePosition = pickData.nodePositions.subarray( + pickedOffset * 3, + pickedOffset * 3 + 3, + ); + mouseState.pickedSpatialSkeleton = { + nodeId, + segmentId, + position: new Float32Array(nodePosition), + }; + const transform = this.base.displayState.transform.value; + if (transform.error === undefined) { + setMouseStatePositionFromSpatialSkeletonNode( + mouseState, + nodePosition, + transform, + ); + } + return; + } + if (pickData.kind === "edge") { + if (pickedOffset < 0 || pickedOffset >= pickData.segmentIds.length) { + return; + } + const segmentId = pickData.segmentIds[pickedOffset]; + if (Number.isSafeInteger(segmentId) && segmentId > 0) { + mouseState.pickedSpatialSkeleton = { segmentId }; + } + return; + } + if (pickData.kind === "segment-node" || pickData.kind === "segment-edge") { + if (pickData.kind === "segment-node") { + const pickedNode = this.base.resolveNodePickFromChunk( + pickData.chunk, + pickedOffset, + ); + if (pickedNode !== undefined) { + mouseState.pickedSpatialSkeleton = { + nodeId: pickedNode.nodeId, + segmentId: pickedNode.segmentId, + position: new Float32Array(pickedNode.position), + revisionToken: pickedNode.revisionToken, + }; + } + return; + } + const segmentId = this.base.resolveSegmentPickFromChunk( + pickData.chunk, + pickedOffset, + "edge", + ); + if (segmentId !== undefined) { + mouseState.pickedSpatialSkeleton = { segmentId }; + } + } + } + + attach( + attachment: VisibleLayerInfo< + SliceViewPanel, + ThreeDimensionalRenderLayerAttachmentState + >, + ) { + super.attach(attachment); + const baseLayer = this.base; + const redrawNeeded = this.redrawNeeded; + attachment.registerDisposer( + registerNested( + (context, transform, displayDimensionRenderInfo) => { + const transformedSources = getVolumetricTransformedSources( + displayDimensionRenderInfo, + transform, + () => [ + baseLayer.getSources("2d").map((sourceEntry) => ({ + chunkSource: sourceEntry.chunkSource, + chunkToMultiscaleTransform: + sourceEntry.chunkToMultiscaleTransform, + })), + ], + attachment.messages, + this, + ); + for (const scales of transformedSources) { + for (const tsource of scales) { + context.registerDisposer(tsource.source); + } + } + this.transformedSources = transformedSources; + redrawNeeded.dispatch(); + return transformedSources; + }, + baseLayer.displayState.transform, + attachment.view.displayDimensionRenderInfo, + ), + ); + } + + draw( + renderContext: SliceViewPanelRenderContext, + attachment: VisibleLayerInfo< + SliceViewPanel, + ThreeDimensionalRenderLayerAttachmentState + >, + ) { + const pixelSizeWatchable = (this.base.displayState as any) + .spatialSkeletonGridPixelSize2d as + | WatchableValueInterface + | undefined; + if (pixelSizeWatchable !== undefined) { + const pixelSize = + renderContext.sliceView.projectionParameters.value.pixelSize; + if ( + Number.isFinite(pixelSize) && + pixelSizeWatchable.value !== pixelSize + ) { + pixelSizeWatchable.value = pixelSize; + } + } + const displayState = this.base.displayState as any; + const lodValue = displayState.spatialSkeletonLod2d?.value as + | number + | undefined; + this.base.updateVisibleChunksForView( + "2d", + this.transformedSources, + renderContext.sliceView.projectionParameters.value, + lodValue, + ); + const levels = displayState.spatialSkeletonGridLevels?.value as + | Array<{ size: { x: number; y: number; z: number }; lod: number }> + | undefined; + const histogram = displayState.spatialSkeletonGridRenderScaleHistogram2d as + | RenderScaleHistogram + | undefined; + if (histogram !== undefined) { + const frameNumber = + this.base.chunkManager.chunkQueueManager.frameNumberCounter.frameNumber; + const relative = + displayState.spatialSkeletonGridResolutionRelative2d?.value === true; + const pixelSize = Math.max(pixelSizeWatchable?.value ?? 1, 1e-6); + updateSpatialSkeletonGridRenderScaleHistogram( + histogram, + frameNumber, + this.transformedSources, + renderContext.sliceView.projectionParameters.value, + this.base.localPosition.value, + lodValue, + levels, + relative, + pixelSize, + ); + } + this.base.draw( + renderContext, + this, + this.renderHelper, + this.browseRenderHelper, + this.renderOptions, + attachment, + { + view: "2d", + gridLevel: displayState.spatialSkeletonGridLevel2d?.value as + | number + | undefined, + lod: lodValue, + }, + ); + } + + isReady( + renderContext: SliceViewPanelReadyRenderContext, + _attachment: VisibleLayerInfo< + SliceViewPanel, + ThreeDimensionalRenderLayerAttachmentState + >, + ) { + const displayState = this.base.displayState as any; + const lodValue = displayState.spatialSkeletonLod2d?.value as + | number + | undefined; + return this.base.isReady( + this.transformedSources, + renderContext.projectionParameters, + lodValue, + ); + } +} + const emptyVertexAttributes = new Map(); function getAttributeTextureFormats( diff --git a/src/skeleton/gpu_upload_utils.ts b/src/skeleton/gpu_upload_utils.ts new file mode 100644 index 0000000000..ba9d0fbac2 --- /dev/null +++ b/src/skeleton/gpu_upload_utils.ts @@ -0,0 +1,107 @@ +/** + * @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 { TypedArray } from "#src/util/array.js"; +import type { GL } from "#src/webgl/context.js"; +import { + setOneDimensionalTextureData, + type TextureFormat, +} from "#src/webgl/texture_access.js"; + +function getOneDimensionalTextureRowCapacity(gl: GL, numElements: number) { + const minX = Math.ceil(numElements / gl.maxTextureSize); + return 1 << Math.ceil(Math.log2(Math.max(minX, 1))); +} + +/** + * Uploads vertex attribute data to GPU as 1D textures. + * + * This function takes contiguous packed vertex attribute data and creates separate + * GPU textures for each attribute type (e.g., positions, segment IDs, etc.). + * + * @param gl - WebGL rendering context + * @param vertexAttributes - Packed byte array containing all vertex attributes + * @param vertexAttributeOffsets - Byte offsets marking the start of each attribute in the packed array + * @param attributeTextureFormats - Texture format specifications for each attribute + * @returns Array of WebGL textures, one per attribute + */ +export function uploadVertexAttributesToGPU( + gl: GL, + vertexAttributes: Uint8Array, + vertexAttributeOffsets: Uint32Array, + attributeTextureFormats: TextureFormat[], +): (WebGLTexture | null)[] { + const vertexAttributeTextures: (WebGLTexture | null)[] = []; + const numAttributes = vertexAttributeOffsets.length; + + for (let i = 0; i < numAttributes; ++i) { + const texture = gl.createTexture(); + gl.bindTexture(WebGL2RenderingContext.TEXTURE_2D, texture); + setOneDimensionalTextureData( + gl, + attributeTextureFormats[i], + vertexAttributes.subarray( + vertexAttributeOffsets[i], + i + 1 !== numAttributes + ? vertexAttributeOffsets[i + 1] + : vertexAttributes.length, + ), + ); + vertexAttributeTextures[i] = texture; + } + gl.bindTexture(WebGL2RenderingContext.TEXTURE_2D, null); + + return vertexAttributeTextures; +} + +export function updateOneDimensionalTextureElement( + gl: GL, + texture: WebGLTexture, + format: TextureFormat, + numElements: number, + elementIndex: number, + data: TypedArray, +) { + if (elementIndex < 0 || elementIndex >= numElements) { + return; + } + const { arrayConstructor, texelsPerElement, textureFormat, texelType } = + format; + if (data.constructor !== arrayConstructor) { + data = new arrayConstructor( + data.buffer, + data.byteOffset, + data.byteLength / arrayConstructor.BYTES_PER_ELEMENT, + ); + } + const elementsPerRow = getOneDimensionalTextureRowCapacity(gl, numElements); + const x = (elementIndex % elementsPerRow) * texelsPerElement; + const y = Math.floor(elementIndex / elementsPerRow); + gl.bindTexture(WebGL2RenderingContext.TEXTURE_2D, texture); + gl.pixelStorei(WebGL2RenderingContext.UNPACK_ALIGNMENT, 1); + gl.texSubImage2D( + WebGL2RenderingContext.TEXTURE_2D, + 0, + x, + y, + texelsPerElement, + 1, + textureFormat, + texelType, + data, + ); + gl.bindTexture(WebGL2RenderingContext.TEXTURE_2D, null); +} diff --git a/src/skeleton/navigation.spec.ts b/src/skeleton/navigation.spec.ts new file mode 100644 index 0000000000..f4bc30a436 --- /dev/null +++ b/src/skeleton/navigation.spec.ts @@ -0,0 +1,197 @@ +/** + * @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 { describe, expect, it } from "vitest"; + +import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; +import { + buildSpatiallyIndexedSkeletonNavigationGraph, + getBranchEnd, + getBranchStart, + getChildNode, + getFlatListNodeIds, + getNextCollapsedLevelNode, + getOpenLeaves, + getParentNode, + getSkeletonRootNode, +} from "#src/skeleton/navigation.js"; + +function makeNode( + nodeId: number, + parentNodeId: number | undefined, + options: { + description?: string; + isTrueEnd?: boolean; + } = {}, +): SpatiallyIndexedSkeletonNode { + return { + nodeId, + segmentId: 42, + position: new Float32Array([nodeId, nodeId + 0.5, nodeId + 1]), + parentNodeId, + description: options.description, + isTrueEnd: options.isTrueEnd ?? false, + }; +} + +describe("skeleton/navigation", () => { + const graph = buildSpatiallyIndexedSkeletonNavigationGraph([ + makeNode(1, undefined), + makeNode(2, 1), + makeNode(3, 2), + makeNode(4, 3), + makeNode(5, 4, { description: "checkpoint" }), + makeNode(6, 5), + makeNode(7, 3), + makeNode(8, 3), + makeNode(9, 8), + makeNode(10, 9, { isTrueEnd: true }), + makeNode(11, 9), + ]); + + it("finds the skeleton root and branch starts", () => { + expect(getSkeletonRootNode(graph).nodeId).toBe(1); + expect(getBranchStart(graph, 6).nodeId).toBe(3); + expect(getBranchStart(graph, 3).nodeId).toBe(3); + expect(getBranchStart(graph, 2).nodeId).toBe(2); + expect(getBranchStart(graph, 1).nodeId).toBe(1); + }); + + it("prefers a downstream branch over a leaf for branch-end navigation", () => { + const preferenceGraph = buildSpatiallyIndexedSkeletonNavigationGraph([ + makeNode(1, undefined), + makeNode(2, 1), + makeNode(3, 1), + makeNode(4, 3), + makeNode(5, 4), + makeNode(6, 4), + ]); + + expect(getBranchEnd(preferenceGraph, 1).nodeId).toBe(4); + expect(getBranchEnd(preferenceGraph, 3).nodeId).toBe(4); + expect(getBranchEnd(preferenceGraph, 2).nodeId).toBe(2); + }); + + it("orders flat-list rows in leaf-first pre-order", () => { + const listGraph = buildSpatiallyIndexedSkeletonNavigationGraph([ + makeNode(1, undefined), + makeNode(2, 1), + makeNode(3, 1), + makeNode(4, 1), + makeNode(5, 2), + makeNode(6, 4), + makeNode(7, 4), + makeNode(8, 1, { isTrueEnd: true }), + makeNode(9, 8), + ]); + + expect(getFlatListNodeIds(listGraph)).toEqual([1, 3, 8, 9, 4, 6, 7, 2, 5]); + }); + + it("orders flat-list rows by collapsed branches in leaf-first pre-order", () => { + const listGraph = buildSpatiallyIndexedSkeletonNavigationGraph([ + makeNode(1, undefined), + makeNode(2, 1), + makeNode(3, 2), + makeNode(4, 3), + makeNode(5, 3), + makeNode(6, 2), + makeNode(7, 6), + ]); + + expect( + getFlatListNodeIds(listGraph, { + collapseRegularNodesForOrdering: true, + }), + ).toEqual([1, 2, 6, 7, 3, 4, 5]); + }); + + it("keeps a branch adjacent to its own leaf-first descendants", () => { + const listGraph = buildSpatiallyIndexedSkeletonNavigationGraph([ + makeNode(1, undefined), + makeNode(2, 1), + makeNode(3, 1), + makeNode(4, 2), + makeNode(5, 2), + makeNode(6, 3), + makeNode(7, 3), + ]); + + expect( + getFlatListNodeIds(listGraph, { + collapseRegularNodesForOrdering: true, + }), + ).toEqual([1, 2, 4, 5, 3, 6, 7]); + }); + + it("returns deterministic direct parent and child navigation targets", () => { + expect(getParentNode(graph, 6)?.nodeId).toBe(5); + expect(getParentNode(graph, 1)).toBeUndefined(); + expect(getChildNode(graph, 3)?.nodeId).toBe(7); + expect(getChildNode(graph, 11)).toBeUndefined(); + }); + + it("cycles through collapsed-level nodes and skips regular nodes", () => { + const collapsedGraph = buildSpatiallyIndexedSkeletonNavigationGraph([ + makeNode(1, undefined), + makeNode(2, 1), + makeNode(3, 2), + makeNode(4, 1), + makeNode(5, 1), + makeNode(6, 4), + makeNode(7, 4), + ]); + + expect(getNextCollapsedLevelNode(collapsedGraph, 1).nodeId).toBe(1); + expect(getNextCollapsedLevelNode(collapsedGraph, 2).nodeId).toBe(2); + expect(getNextCollapsedLevelNode(collapsedGraph, 5).nodeId).toBe(4); + expect(getNextCollapsedLevelNode(collapsedGraph, 4).nodeId).toBe(3); + expect(getNextCollapsedLevelNode(collapsedGraph, 3).nodeId).toBe(5); + }); + + it("cycles collapsed-level nodes using collapsed leaf-first ordering", () => { + const collapsedGraph = buildSpatiallyIndexedSkeletonNavigationGraph([ + makeNode(1, undefined), + makeNode(2, 1), + makeNode(3, 2), + makeNode(4, 3), + makeNode(5, 3), + makeNode(6, 2), + makeNode(7, 6), + ]); + + expect(getNextCollapsedLevelNode(collapsedGraph, 6).nodeId).toBe(6); + expect(getNextCollapsedLevelNode(collapsedGraph, 7).nodeId).toBe(3); + expect(getNextCollapsedLevelNode(collapsedGraph, 3).nodeId).toBe(7); + }); + + it("finds unfinished leaves from any selected node and filters closed ends", () => { + expect( + getOpenLeaves(graph, 3).map((leaf) => [leaf.nodeId, leaf.distance]), + ).toEqual([ + [7, 1], + [6, 3], + [11, 3], + ]); + expect( + getOpenLeaves(graph, 1).map((leaf) => [leaf.nodeId, leaf.distance]), + ).toEqual([ + [7, 3], + [6, 5], + [11, 5], + ]); + }); +}); diff --git a/src/skeleton/navigation.ts b/src/skeleton/navigation.ts new file mode 100644 index 0000000000..18f2ddbea9 --- /dev/null +++ b/src/skeleton/navigation.ts @@ -0,0 +1,649 @@ +/** + * @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 { + SpatiallyIndexedSkeletonNavigationTarget, + SpatiallyIndexedSkeletonNode, + SpatiallyIndexedSkeletonOpenLeaf, +} from "#src/skeleton/api.js"; + +export interface SpatiallyIndexedSkeletonNavigationGraph { + nodeById: Map; + childrenByParent: Map; + rootNodeIds: number[]; +} + +interface CollapsedChildPath { + path: readonly number[]; + representativeNodeId: number; +} + +interface CollapsedLevelContext { + levelByNodeId: Map; + nodeIdsByLevel: Map; +} + +interface NavigationGraphDerivedState { + sortPriorityByNodeId: Map; + orderedChildNodeIdsByNodeId: Map; + collapsedPathByNodeId: Map; + collapsedOrderedChildPathsByNodeId: Map< + number, + readonly CollapsedChildPath[] + >; + flatListNodeIds?: readonly number[]; + collapsedFlatListNodeIds?: readonly number[]; + collapsedLevelContext?: CollapsedLevelContext; +} + +const navigationGraphDerivedState = new WeakMap< + SpatiallyIndexedSkeletonNavigationGraph, + NavigationGraphDerivedState +>(); + +function buildNavigationGraphDerivedState( + graph: SpatiallyIndexedSkeletonNavigationGraph, +): NavigationGraphDerivedState { + const sortPriorityByNodeId = new Map(); + for (const [nodeId, node] of graph.nodeById) { + const childCount = graph.childrenByParent.get(nodeId)?.length ?? 0; + const parentNodeId = node.parentNodeId; + const parentInTree = + parentNodeId !== undefined && graph.nodeById.has(parentNodeId); + const sortPriority = node.isTrueEnd + ? 0 + : childCount === 0 + ? 0 + : !parentInTree + ? 3 + : childCount > 1 + ? 1 + : 2; + sortPriorityByNodeId.set(nodeId, sortPriority); + } + return { + sortPriorityByNodeId, + orderedChildNodeIdsByNodeId: new Map(), + collapsedPathByNodeId: new Map(), + collapsedOrderedChildPathsByNodeId: new Map(), + }; +} + +function getNavigationGraphDerivedState( + graph: SpatiallyIndexedSkeletonNavigationGraph, +) { + let state = navigationGraphDerivedState.get(graph); + if (state === undefined) { + state = buildNavigationGraphDerivedState(graph); + navigationGraphDerivedState.set(graph, state); + } + return state; +} + +export function buildSpatiallyIndexedSkeletonNavigationGraph( + nodes: readonly SpatiallyIndexedSkeletonNode[], +): SpatiallyIndexedSkeletonNavigationGraph { + const nodeById = new Map(); + for (const node of nodes) { + if (!nodeById.has(node.nodeId)) { + nodeById.set(node.nodeId, node); + } + } + + const childrenByParent = new Map(); + for (const node of nodeById.values()) { + const parentNodeId = node.parentNodeId; + if (parentNodeId === undefined || !nodeById.has(parentNodeId)) continue; + let children = childrenByParent.get(parentNodeId); + if (children === undefined) { + children = []; + childrenByParent.set(parentNodeId, children); + } + children.push(node.nodeId); + } + for (const children of childrenByParent.values()) { + children.sort((a, b) => a - b); + } + + const rootNodeIds: number[] = []; + for (const node of nodeById.values()) { + const parentNodeId = node.parentNodeId; + if (parentNodeId === undefined || !nodeById.has(parentNodeId)) { + rootNodeIds.push(node.nodeId); + } + } + rootNodeIds.sort((a, b) => a - b); + if (rootNodeIds.length === 0 && nodeById.size > 0) { + rootNodeIds.push([...nodeById.keys()].sort((a, b) => a - b)[0]); + } + + const graph = { + nodeById, + childrenByParent, + rootNodeIds, + }; + navigationGraphDerivedState.set( + graph, + buildNavigationGraphDerivedState(graph), + ); + return graph; +} + +function getFlatListNodeSortPriority( + graph: SpatiallyIndexedSkeletonNavigationGraph, + nodeId: number, +) { + const priority = + getNavigationGraphDerivedState(graph).sortPriorityByNodeId.get(nodeId); + if (priority === undefined) { + throw new Error(`Node ${nodeId} is not available in the loaded skeleton.`); + } + return priority; +} + +function compareFlatListNodeIds( + graph: SpatiallyIndexedSkeletonNavigationGraph, + a: number, + b: number, +) { + const priorityDelta = + getFlatListNodeSortPriority(graph, a) - + getFlatListNodeSortPriority(graph, b); + return priorityDelta !== 0 ? priorityDelta : a - b; +} + +export function getFlatListNodeIds( + graph: SpatiallyIndexedSkeletonNavigationGraph, + options: { collapseRegularNodesForOrdering?: boolean } = {}, +) { + if (options.collapseRegularNodesForOrdering ?? false) { + return getCollapsedOrderedFlatListNodeIds(graph); + } + const derivedState = getNavigationGraphDerivedState(graph); + if (derivedState.flatListNodeIds !== undefined) { + return derivedState.flatListNodeIds; + } + + const orderedNodeIds: number[] = []; + const visited = new Set(); + + const appendLeafFirstPreOrder = (startNodeIds: readonly number[]) => { + const stack = [...startNodeIds] + .sort((a, b) => compareFlatListNodeIds(graph, a, b)) + .reverse(); + while (stack.length > 0) { + const nodeId = stack.pop()!; + if (visited.has(nodeId)) continue; + visited.add(nodeId); + orderedNodeIds.push(nodeId); + const childNodeIds = [...getChildNodeIds(graph, nodeId)].sort((a, b) => + compareFlatListNodeIds(graph, a, b), + ); + for ( + let childIndex = childNodeIds.length - 1; + childIndex >= 0; + --childIndex + ) { + const childNodeId = childNodeIds[childIndex]; + if (!visited.has(childNodeId)) { + stack.push(childNodeId); + } + } + } + }; + + appendLeafFirstPreOrder(graph.rootNodeIds); + if (visited.size === graph.nodeById.size) { + derivedState.flatListNodeIds = orderedNodeIds; + return orderedNodeIds; + } + + const remainingNodeIds = [...graph.nodeById.keys()].sort((a, b) => + compareFlatListNodeIds(graph, a, b), + ); + for (const nodeId of remainingNodeIds) { + if (!visited.has(nodeId)) { + appendLeafFirstPreOrder([nodeId]); + } + } + + derivedState.flatListNodeIds = orderedNodeIds; + return orderedNodeIds; +} + +function getNodeOrThrow( + graph: SpatiallyIndexedSkeletonNavigationGraph, + nodeId: number, +) { + const node = graph.nodeById.get(nodeId); + if (node === undefined) { + throw new Error(`Node ${nodeId} is not available in the loaded skeleton.`); + } + return node; +} + +function getNodeTarget( + graph: SpatiallyIndexedSkeletonNavigationGraph, + nodeId: number, +): SpatiallyIndexedSkeletonNavigationTarget { + const node = getNodeOrThrow(graph, nodeId); + return { + nodeId: node.nodeId, + x: Number(node.position[0]), + y: Number(node.position[1]), + z: Number(node.position[2]), + }; +} + +function getChildNodeIds( + graph: SpatiallyIndexedSkeletonNavigationGraph, + nodeId: number, +) { + return graph.childrenByParent.get(nodeId) ?? []; +} + +function getParentNodeId( + graph: SpatiallyIndexedSkeletonNavigationGraph, + nodeId: number, +) { + const parentNodeId = getNodeOrThrow(graph, nodeId).parentNodeId; + if (parentNodeId === undefined || !graph.nodeById.has(parentNodeId)) { + return undefined; + } + return parentNodeId; +} + +function getFlatListOrderedChildNodeIds( + graph: SpatiallyIndexedSkeletonNavigationGraph, + nodeId: number, +) { + const childNodeIds = getChildNodeIds(graph, nodeId); + if (childNodeIds.length <= 1) { + return childNodeIds; + } + const derivedState = getNavigationGraphDerivedState(graph); + const cached = derivedState.orderedChildNodeIdsByNodeId.get(nodeId); + if (cached !== undefined) { + return cached; + } + const orderedChildNodeIds = [...childNodeIds].sort((a, b) => + compareFlatListNodeIds(graph, a, b), + ); + derivedState.orderedChildNodeIdsByNodeId.set(nodeId, orderedChildNodeIds); + return orderedChildNodeIds; +} + +function getCollapsedBranchPath( + graph: SpatiallyIndexedSkeletonNavigationGraph, + nodeId: number, +) { + const derivedState = getNavigationGraphDerivedState(graph); + const cached = derivedState.collapsedPathByNodeId.get(nodeId); + if (cached !== undefined) { + return cached; + } + const path = [nodeId]; + const visited = new Set(path); + let currentNodeId = nodeId; + while (isCollapsedRegularNode(graph, currentNodeId)) { + const nextNodeId = getChildNodeIds(graph, currentNodeId)[0]; + if (nextNodeId === undefined || visited.has(nextNodeId)) { + break; + } + path.push(nextNodeId); + visited.add(nextNodeId); + currentNodeId = nextNodeId; + } + derivedState.collapsedPathByNodeId.set(nodeId, path); + return path; +} + +function getCollapsedOrderedChildPaths( + graph: SpatiallyIndexedSkeletonNavigationGraph, + nodeId: number, +) { + const derivedState = getNavigationGraphDerivedState(graph); + const cached = derivedState.collapsedOrderedChildPathsByNodeId.get(nodeId); + if (cached !== undefined) { + return cached; + } + const childPaths = getChildNodeIds(graph, nodeId).map((childNodeId) => { + const path = getCollapsedBranchPath(graph, childNodeId); + return { + path, + representativeNodeId: path[path.length - 1], + }; + }); + childPaths.sort((a, b) => + compareFlatListNodeIds( + graph, + a.representativeNodeId, + b.representativeNodeId, + ), + ); + derivedState.collapsedOrderedChildPathsByNodeId.set(nodeId, childPaths); + return childPaths; +} + +function isCollapsedRegularNode( + graph: SpatiallyIndexedSkeletonNavigationGraph, + nodeId: number, +) { + const node = getNodeOrThrow(graph, nodeId); + return ( + getParentNodeId(graph, nodeId) !== undefined && + getChildNodeIds(graph, nodeId).length === 1 && + !node.isTrueEnd + ); +} + +function getCollapsedChildNodeIds( + graph: SpatiallyIndexedSkeletonNavigationGraph, + nodeId: number, +) { + return getCollapsedOrderedChildPaths(graph, nodeId).map( + ({ representativeNodeId }) => representativeNodeId, + ); +} + +function getCollapsedOrderedFlatListNodeIds( + graph: SpatiallyIndexedSkeletonNavigationGraph, +) { + const derivedState = getNavigationGraphDerivedState(graph); + if (derivedState.collapsedFlatListNodeIds !== undefined) { + return derivedState.collapsedFlatListNodeIds; + } + const orderedNodeIds: number[] = []; + const visited = new Set(); + + const appendLeafFirstPreOrder = ( + startPaths: readonly (readonly number[])[], + ) => { + const stack = [...startPaths].map((path) => [...path]).reverse(); + while (stack.length > 0) { + const path = stack.pop()!; + const representativeNodeId = path[path.length - 1]; + let appendedNode = false; + for (const nodeId of path) { + if (visited.has(nodeId)) continue; + visited.add(nodeId); + orderedNodeIds.push(nodeId); + appendedNode = true; + } + if (!appendedNode || !graph.nodeById.has(representativeNodeId)) { + continue; + } + const childPaths = getCollapsedOrderedChildPaths( + graph, + representativeNodeId, + ); + for ( + let childPathIndex = childPaths.length - 1; + childPathIndex >= 0; + --childPathIndex + ) { + const childPath = childPaths[childPathIndex]; + const firstNodeId = childPath.path[0]; + if (firstNodeId !== undefined && !visited.has(firstNodeId)) { + stack.push([...childPath.path]); + } + } + } + }; + + appendLeafFirstPreOrder(graph.rootNodeIds.map((nodeId) => [nodeId])); + if (visited.size === graph.nodeById.size) { + derivedState.collapsedFlatListNodeIds = orderedNodeIds; + return orderedNodeIds; + } + + const remainingNodeIds = [...graph.nodeById.keys()].sort((a, b) => + compareFlatListNodeIds(graph, a, b), + ); + for (const nodeId of remainingNodeIds) { + if (!visited.has(nodeId)) { + appendLeafFirstPreOrder([[...getCollapsedBranchPath(graph, nodeId)]]); + } + } + + derivedState.collapsedFlatListNodeIds = orderedNodeIds; + return orderedNodeIds; +} + +function getCollapsedLevelContext( + graph: SpatiallyIndexedSkeletonNavigationGraph, +) { + const derivedState = getNavigationGraphDerivedState(graph); + if (derivedState.collapsedLevelContext !== undefined) { + return derivedState.collapsedLevelContext; + } + const levelByNodeId = new Map(); + const nodeIdsByLevel = new Map(); + const queue = graph.rootNodeIds.map((nodeId) => ({ nodeId, level: 0 })); + const visited = new Set(); + + for (let queueIndex = 0; queueIndex < queue.length; ++queueIndex) { + const { nodeId, level } = queue[queueIndex]; + if (visited.has(nodeId)) continue; + visited.add(nodeId); + levelByNodeId.set(nodeId, level); + let nodeIds = nodeIdsByLevel.get(level); + if (nodeIds === undefined) { + nodeIds = []; + nodeIdsByLevel.set(level, nodeIds); + } + nodeIds.push(nodeId); + for (const childNodeId of getCollapsedChildNodeIds(graph, nodeId)) { + if (!visited.has(childNodeId)) { + queue.push({ nodeId: childNodeId, level: level + 1 }); + } + } + } + + const context = { levelByNodeId, nodeIdsByLevel }; + derivedState.collapsedLevelContext = context; + return context; +} + +function getBranchEndNodeIds( + graph: SpatiallyIndexedSkeletonNavigationGraph, + nodeId: number, +) { + getNodeOrThrow(graph, nodeId); + const childNodeIds = getFlatListOrderedChildNodeIds(graph, nodeId); + return childNodeIds.map((childNodeId) => { + let branchEndNodeId = childNodeId; + while (true) { + const branchChildNodeIds = getChildNodeIds(graph, branchEndNodeId); + if (branchChildNodeIds.length !== 1) { + break; + } + branchEndNodeId = branchChildNodeIds[0]; + } + return branchEndNodeId; + }); +} + +export function getSkeletonRootNode( + graph: SpatiallyIndexedSkeletonNavigationGraph, +) { + const rootNodeId = graph.rootNodeIds[0]; + if (rootNodeId === undefined) { + throw new Error("The loaded skeleton does not contain a root node."); + } + return getNodeTarget(graph, rootNodeId); +} + +export function getBranchStart( + graph: SpatiallyIndexedSkeletonNavigationGraph, + nodeId: number, +) { + getNodeOrThrow(graph, nodeId); + let currentNodeId = nodeId; + const visited = new Set([currentNodeId]); + while (true) { + const parentNodeId = getParentNodeId(graph, currentNodeId); + if (parentNodeId === undefined || visited.has(parentNodeId)) { + return getNodeTarget(graph, nodeId); + } + currentNodeId = parentNodeId; + visited.add(currentNodeId); + if (getChildNodeIds(graph, currentNodeId).length > 1) { + return getNodeTarget(graph, currentNodeId); + } + } +} + +export function getBranchEnd( + graph: SpatiallyIndexedSkeletonNavigationGraph, + nodeId: number, +) { + getNodeOrThrow(graph, nodeId); + const branchEndNodeIds = getBranchEndNodeIds(graph, nodeId); + if (branchEndNodeIds.length === 0) { + return getNodeTarget(graph, nodeId); + } + const preferredBranchEndNodeId = + branchEndNodeIds.find( + (branchEndNodeId) => getChildNodeIds(graph, branchEndNodeId).length > 1, + ) ?? branchEndNodeIds[0]; + return getNodeTarget(graph, preferredBranchEndNodeId); +} + +export function getParentNode( + graph: SpatiallyIndexedSkeletonNavigationGraph, + nodeId: number, +) { + const parentNodeId = getParentNodeId(graph, nodeId); + return parentNodeId === undefined + ? undefined + : getNodeTarget(graph, parentNodeId); +} + +export function getChildNode( + graph: SpatiallyIndexedSkeletonNavigationGraph, + nodeId: number, +) { + const childNodeId = getFlatListOrderedChildNodeIds(graph, nodeId)[0]; + return childNodeId === undefined + ? undefined + : getNodeTarget(graph, childNodeId); +} + +export function getRandomChildNode( + graph: SpatiallyIndexedSkeletonNavigationGraph, + nodeId: number, + options: { random?: () => number } = {}, +) { + const childNodeIds = getFlatListOrderedChildNodeIds(graph, nodeId); + if (childNodeIds.length === 0) { + return undefined; + } + const { random = Math.random } = options; + const randomValue = random(); + const childIndex = Number.isFinite(randomValue) + ? Math.min( + childNodeIds.length - 1, + Math.max(0, Math.floor(randomValue * childNodeIds.length)), + ) + : 0; + return getNodeTarget(graph, childNodeIds[childIndex]); +} + +export function getNextCollapsedLevelNode( + graph: SpatiallyIndexedSkeletonNavigationGraph, + nodeId: number, +) { + getNodeOrThrow(graph, nodeId); + if (getParentNodeId(graph, nodeId) === undefined) { + return getNodeTarget(graph, nodeId); + } + if (isCollapsedRegularNode(graph, nodeId)) { + return getNodeTarget(graph, nodeId); + } + + const { levelByNodeId, nodeIdsByLevel } = getCollapsedLevelContext(graph); + const level = levelByNodeId.get(nodeId); + if (level === undefined) { + return getNodeTarget(graph, nodeId); + } + const nodeIds = nodeIdsByLevel.get(level); + if (nodeIds === undefined || nodeIds.length <= 1) { + return getNodeTarget(graph, nodeId); + } + const currentIndex = nodeIds.indexOf(nodeId); + if (currentIndex === -1) { + return getNodeTarget(graph, nodeId); + } + return getNodeTarget(graph, nodeIds[(currentIndex + 1) % nodeIds.length]); +} + +export function getOpenLeaves( + graph: SpatiallyIndexedSkeletonNavigationGraph, + nodeId: number, +): SpatiallyIndexedSkeletonOpenLeaf[] { + getNodeOrThrow(graph, nodeId); + const distances = new Map([[nodeId, 0]]); + const queue = [nodeId]; + for (let queueIndex = 0; queueIndex < queue.length; ++queueIndex) { + const currentNodeId = queue[queueIndex]; + const nextDistance = (distances.get(currentNodeId) ?? 0) + 1; + const childNodeIds = getChildNodeIds(graph, currentNodeId); + const parentNodeId = getParentNodeId(graph, currentNodeId); + let parentAdded = false; + for (const childNodeId of childNodeIds) { + if ( + !parentAdded && + parentNodeId !== undefined && + parentNodeId < childNodeId + ) { + if (!distances.has(parentNodeId)) { + distances.set(parentNodeId, nextDistance); + queue.push(parentNodeId); + } + parentAdded = true; + } + const neighborNodeId = childNodeId; + if (distances.has(neighborNodeId)) continue; + distances.set(neighborNodeId, nextDistance); + queue.push(neighborNodeId); + } + if ( + !parentAdded && + parentNodeId !== undefined && + !distances.has(parentNodeId) + ) { + distances.set(parentNodeId, nextDistance); + queue.push(parentNodeId); + } + } + + const leaves: SpatiallyIndexedSkeletonOpenLeaf[] = []; + for (const candidateNodeId of queue) { + if (getChildNodeIds(graph, candidateNodeId).length !== 0) continue; + const candidateNode = getNodeOrThrow(graph, candidateNodeId); + if (candidateNode.isTrueEnd) continue; + leaves.push({ + ...getNodeTarget(graph, candidateNodeId), + distance: distances.get(candidateNodeId) ?? 0, + }); + } + + leaves.sort((a, b) => + a.distance === b.distance ? a.nodeId - b.nodeId : a.distance - b.distance, + ); + return leaves; +} diff --git a/src/skeleton/node_types.spec.ts b/src/skeleton/node_types.spec.ts new file mode 100644 index 0000000000..f0c33a7830 --- /dev/null +++ b/src/skeleton/node_types.spec.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; + +import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; +import { + classifySpatialSkeletonDisplayNodeType, + getSpatialSkeletonNodeFilterLabel, + getSpatialSkeletonNodeIconFilterType, + matchesSpatialSkeletonNodeFilter, + SpatialSkeletonNodeFilterType, +} from "#src/skeleton/node_types.js"; + +function makeNode( + overrides: Partial = {}, +): SpatiallyIndexedSkeletonNode { + return { + nodeId: 1, + segmentId: 1, + position: new Float32Array([0, 0, 0]), + isTrueEnd: false, + ...overrides, + }; +} + +describe("skeleton node types", () => { + it("classifies display node types for roots, branches, regular nodes, and virtual ends", () => { + expect( + classifySpatialSkeletonDisplayNodeType( + makeNode({ parentNodeId: undefined }), + 0, + ), + ).toBe("root"); + expect( + classifySpatialSkeletonDisplayNodeType(makeNode({ parentNodeId: 1 }), 2), + ).toBe("branchStart"); + expect( + classifySpatialSkeletonDisplayNodeType(makeNode({ parentNodeId: 1 }), 1), + ).toBe("regular"); + expect( + classifySpatialSkeletonDisplayNodeType(makeNode({ parentNodeId: 1 }), 0), + ).toBe("virtualEnd"); + expect( + classifySpatialSkeletonDisplayNodeType( + makeNode({ parentNodeId: 1 }), + 0, + false, + ), + ).toBe("root"); + }); + + it("matches the dropdown filter semantics", () => { + const rootLeaf = { + isLeaf: true, + nodeHasDescription: false, + nodeIsTrueEnd: false, + nodeType: "root" as const, + }; + const virtualEnd = { + isLeaf: true, + nodeHasDescription: false, + nodeIsTrueEnd: false, + nodeType: "virtualEnd" as const, + }; + const trueEnd = { + isLeaf: true, + nodeHasDescription: false, + nodeIsTrueEnd: true, + nodeType: "virtualEnd" as const, + }; + const describedNode = { + isLeaf: false, + nodeHasDescription: true, + nodeIsTrueEnd: false, + nodeType: "regular" as const, + }; + + expect( + matchesSpatialSkeletonNodeFilter( + SpatialSkeletonNodeFilterType.LEAF, + rootLeaf, + ), + ).toBe(true); + expect( + matchesSpatialSkeletonNodeFilter( + SpatialSkeletonNodeFilterType.VIRTUAL_END, + rootLeaf, + ), + ).toBe(true); + expect( + matchesSpatialSkeletonNodeFilter( + SpatialSkeletonNodeFilterType.VIRTUAL_END, + virtualEnd, + ), + ).toBe(true); + expect( + matchesSpatialSkeletonNodeFilter( + SpatialSkeletonNodeFilterType.VIRTUAL_END, + trueEnd, + ), + ).toBe(false); + expect( + matchesSpatialSkeletonNodeFilter( + SpatialSkeletonNodeFilterType.TRUE_END, + trueEnd, + ), + ).toBe(true); + expect( + matchesSpatialSkeletonNodeFilter( + SpatialSkeletonNodeFilterType.HAS_DESCRIPTION, + describedNode, + ), + ).toBe(true); + expect( + matchesSpatialSkeletonNodeFilter( + SpatialSkeletonNodeFilterType.HAS_DESCRIPTION, + rootLeaf, + ), + ).toBe(false); + }); + + it("reuses the terminal filter enum for row icon decisions", () => { + expect( + getSpatialSkeletonNodeIconFilterType({ + nodeIsTrueEnd: false, + nodeType: "virtualEnd", + }), + ).toBe(SpatialSkeletonNodeFilterType.VIRTUAL_END); + expect( + getSpatialSkeletonNodeIconFilterType({ + nodeIsTrueEnd: true, + nodeType: "regular", + }), + ).toBe(SpatialSkeletonNodeFilterType.TRUE_END); + expect( + getSpatialSkeletonNodeIconFilterType({ + nodeIsTrueEnd: false, + nodeType: "root", + }), + ).toBeUndefined(); + expect( + getSpatialSkeletonNodeFilterLabel(SpatialSkeletonNodeFilterType.TRUE_END), + ).toBe("True end"); + expect( + getSpatialSkeletonNodeFilterLabel( + SpatialSkeletonNodeFilterType.HAS_DESCRIPTION, + ), + ).toBe("Has description"); + }); +}); diff --git a/src/skeleton/node_types.ts b/src/skeleton/node_types.ts new file mode 100644 index 0000000000..21a5842fbb --- /dev/null +++ b/src/skeleton/node_types.ts @@ -0,0 +1,104 @@ +/** + * @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 } from "#src/skeleton/api.js"; + +export type SpatialSkeletonDisplayNodeType = + | "root" + | "branchStart" + | "regular" + | "virtualEnd"; + +export enum SpatialSkeletonNodeFilterType { + NONE, + LEAF, + VIRTUAL_END, + TRUE_END, + HAS_DESCRIPTION, +} + +export function classifySpatialSkeletonDisplayNodeType( + node: SpatiallyIndexedSkeletonNode, + childCount: number | undefined, + parentInTree = true, +): SpatialSkeletonDisplayNodeType { + if (node.parentNodeId === undefined || !parentInTree) { + return "root"; + } + if (childCount === undefined) { + return "regular"; + } + if (childCount > 1) { + return "branchStart"; + } + if (childCount === 0) { + return "virtualEnd"; + } + return "regular"; +} + +export function getSpatialSkeletonNodeFilterLabel( + filterType: SpatialSkeletonNodeFilterType, +) { + switch (filterType) { + case SpatialSkeletonNodeFilterType.NONE: + return "None"; + case SpatialSkeletonNodeFilterType.LEAF: + return "Leaf"; + case SpatialSkeletonNodeFilterType.VIRTUAL_END: + return "Virtual end"; + case SpatialSkeletonNodeFilterType.TRUE_END: + return "True end"; + case SpatialSkeletonNodeFilterType.HAS_DESCRIPTION: + return "Has description"; + } +} + +export function matchesSpatialSkeletonNodeFilter( + filterType: SpatialSkeletonNodeFilterType, + options: { + isLeaf: boolean; + nodeHasDescription: boolean; + nodeIsTrueEnd: boolean; + nodeType: SpatialSkeletonDisplayNodeType; + }, +) { + switch (filterType) { + case SpatialSkeletonNodeFilterType.NONE: + return true; + case SpatialSkeletonNodeFilterType.LEAF: + return options.isLeaf; + case SpatialSkeletonNodeFilterType.VIRTUAL_END: + return options.isLeaf && !options.nodeIsTrueEnd; + case SpatialSkeletonNodeFilterType.TRUE_END: + return options.nodeIsTrueEnd; + case SpatialSkeletonNodeFilterType.HAS_DESCRIPTION: + return options.nodeHasDescription; + } +} + +export function getSpatialSkeletonNodeIconFilterType(options: { + nodeIsTrueEnd: boolean; + nodeType: SpatialSkeletonDisplayNodeType; +}) { + if (options.nodeIsTrueEnd) { + return SpatialSkeletonNodeFilterType.TRUE_END; + } + if (options.nodeType === "virtualEnd") { + return SpatialSkeletonNodeFilterType.VIRTUAL_END; + } + return undefined; +} diff --git a/src/skeleton/overlay_geometry.spec.ts b/src/skeleton/overlay_geometry.spec.ts new file mode 100644 index 0000000000..513d17adad --- /dev/null +++ b/src/skeleton/overlay_geometry.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { buildSpatiallyIndexedSkeletonOverlayGeometry } from "#src/skeleton/overlay_geometry.js"; + +describe("buildSpatiallyIndexedSkeletonOverlayGeometry", () => { + it("packs inspected segment nodes into overlay geometry with deduped nodes", () => { + const geometry = buildSpatiallyIndexedSkeletonOverlayGeometry( + [ + [ + { + nodeId: 1, + segmentId: 11, + position: new Float32Array([1, 2, 3]), + }, + { + nodeId: 2, + segmentId: 11, + position: new Float32Array([4, 5, 6]), + parentNodeId: 1, + }, + ], + [ + { + nodeId: 2, + segmentId: 11, + position: new Float32Array([40, 50, 60]), + parentNodeId: 1, + }, + { + nodeId: 3, + segmentId: 13, + position: new Float32Array([7, 8, 9]), + }, + ], + ], + { + selectedNodeId: 2, + getPendingNodePosition: (nodeId) => + nodeId === 3 ? new Float32Array([70, 80, 90]) : undefined, + }, + ); + + expect(geometry.numVertices).toBe(3); + expect([...geometry.nodeIds]).toEqual([1, 2, 3]); + expect([...geometry.segmentIds]).toEqual([11, 11, 13]); + expect([...geometry.selected]).toEqual([0, 1, 0]); + expect([...geometry.positions]).toEqual([1, 2, 3, 4, 5, 6, 70, 80, 90]); + expect([...geometry.indices]).toEqual([1, 0]); + expect([...geometry.pickEdgeSegmentIds]).toEqual([11]); + }); +}); diff --git a/src/skeleton/overlay_geometry.ts b/src/skeleton/overlay_geometry.ts new file mode 100644 index 0000000000..f67a8f950c --- /dev/null +++ b/src/skeleton/overlay_geometry.ts @@ -0,0 +1,93 @@ +export interface SpatiallyIndexedSkeletonOverlayNodeLike { + nodeId: number; + segmentId: number; + position: ArrayLike; + parentNodeId?: number; +} + +export interface SpatiallyIndexedSkeletonOverlayGeometry { + positions: Float32Array; + segmentIds: Uint32Array; + selected: Float32Array; + nodeIds: Int32Array; + nodePositions: Float32Array; + pickSegmentIds: Uint32Array; + pickEdgeSegmentIds: Uint32Array; + indices: Uint32Array; + numVertices: number; +} + +export function buildSpatiallyIndexedSkeletonOverlayGeometry( + segmentNodeSets: readonly (readonly SpatiallyIndexedSkeletonOverlayNodeLike[])[], + options: { + selectedNodeId?: number; + getPendingNodePosition?: (nodeId: number) => ArrayLike | undefined; + } = {}, +): SpatiallyIndexedSkeletonOverlayGeometry { + const { selectedNodeId, getPendingNodePosition } = options; + const nodeIndex = new Map(); + const orderedNodes: SpatiallyIndexedSkeletonOverlayNodeLike[] = []; + + for (const segmentNodes of segmentNodeSets) { + for (const node of segmentNodes) { + if (nodeIndex.has(node.nodeId)) continue; + nodeIndex.set(node.nodeId, orderedNodes.length); + orderedNodes.push(node); + } + } + + const numVertices = orderedNodes.length; + const positions = new Float32Array(numVertices * 3); + const segmentIds = new Uint32Array(numVertices); + const selected = new Float32Array(numVertices); + const nodeIds = new Int32Array(numVertices); + const nodePositions = new Float32Array(numVertices * 3); + const pickSegmentIds = new Uint32Array(numVertices); + const indices: number[] = []; + const pickEdgeSegmentIds: number[] = []; + + orderedNodes.forEach((node, index) => { + const position = getPendingNodePosition?.(node.nodeId) ?? node.position; + const baseOffset = index * 3; + positions[baseOffset] = Number(position[0] ?? 0); + positions[baseOffset + 1] = Number(position[1] ?? 0); + positions[baseOffset + 2] = Number(position[2] ?? 0); + nodePositions[baseOffset] = positions[baseOffset]; + nodePositions[baseOffset + 1] = positions[baseOffset + 1]; + nodePositions[baseOffset + 2] = positions[baseOffset + 2]; + segmentIds[index] = Math.max(0, Math.round(Number(node.segmentId))); + pickSegmentIds[index] = segmentIds[index]; + nodeIds[index] = Math.round(Number(node.nodeId)); + selected[index] = + selectedNodeId !== undefined && node.nodeId === selectedNodeId ? 1 : 0; + }); + + orderedNodes.forEach((node) => { + const childIndex = nodeIndex.get(node.nodeId); + if (childIndex === undefined) return; + const parentNodeId = node.parentNodeId; + if ( + parentNodeId === undefined || + !Number.isSafeInteger(parentNodeId) || + parentNodeId <= 0 + ) { + return; + } + const parentIndex = nodeIndex.get(parentNodeId); + if (parentIndex === undefined) return; + indices.push(childIndex, parentIndex); + pickEdgeSegmentIds.push(segmentIds[childIndex] || segmentIds[parentIndex]); + }); + + return { + positions, + segmentIds, + selected, + nodeIds, + nodePositions, + pickSegmentIds, + pickEdgeSegmentIds: new Uint32Array(pickEdgeSegmentIds), + indices: new Uint32Array(indices), + numVertices, + }; +} diff --git a/src/skeleton/overlay_segment_retention.spec.ts b/src/skeleton/overlay_segment_retention.spec.ts new file mode 100644 index 0000000000..b59cefb92d --- /dev/null +++ b/src/skeleton/overlay_segment_retention.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_MAX_RETAINED_OVERLAY_SEGMENTS, + mergeSpatiallyIndexedSkeletonOverlaySegmentIds, + retainSpatiallyIndexedSkeletonOverlaySegment, +} from "#src/skeleton/overlay_segment_retention.js"; + +describe("mergeSpatiallyIndexedSkeletonOverlaySegmentIds", () => { + it("dedupes and sorts active and retained segment ids", () => { + expect( + mergeSpatiallyIndexedSkeletonOverlaySegmentIds([7, 3, 7], [9, 3, 5]), + ).toEqual([3, 5, 7, 9]); + }); + + it("ignores invalid segment ids", () => { + expect( + mergeSpatiallyIndexedSkeletonOverlaySegmentIds([1, 0, -2], [NaN, 4]), + ).toEqual([1, 4]); + }); +}); + +describe("retainSpatiallyIndexedSkeletonOverlaySegment", () => { + it("moves retained segments to the most recent position", () => { + expect(retainSpatiallyIndexedSkeletonOverlaySegment([2, 4, 6], 4)).toEqual([ + 2, 6, 4, + ]); + }); + + it("keeps only the most recent retained segments", () => { + const retained: number[] = []; + for ( + let segmentId = 1; + segmentId <= DEFAULT_MAX_RETAINED_OVERLAY_SEGMENTS + 2; + ++segmentId + ) { + retained.splice( + 0, + retained.length, + ...retainSpatiallyIndexedSkeletonOverlaySegment(retained, segmentId), + ); + } + const firstRetainedSegmentId = 3; + expect(retained).toEqual( + Array.from( + { length: DEFAULT_MAX_RETAINED_OVERLAY_SEGMENTS }, + (_, index) => firstRetainedSegmentId + index, + ), + ); + }); +}); diff --git a/src/skeleton/overlay_segment_retention.ts b/src/skeleton/overlay_segment_retention.ts new file mode 100644 index 0000000000..0837855b40 --- /dev/null +++ b/src/skeleton/overlay_segment_retention.ts @@ -0,0 +1,65 @@ +/** + * @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 const DEFAULT_MAX_RETAINED_OVERLAY_SEGMENTS = 4; + +function normalizeSegmentId(segmentId: number) { + const normalizedSegmentId = Math.round(Number(segmentId)); + if (!Number.isSafeInteger(normalizedSegmentId) || normalizedSegmentId <= 0) { + return undefined; + } + return normalizedSegmentId; +} + +export function mergeSpatiallyIndexedSkeletonOverlaySegmentIds( + activeSegmentIds: readonly number[], + retainedSegmentIds: readonly number[], +) { + const mergedSegmentIds = new Set(); + for (const segmentId of [...activeSegmentIds, ...retainedSegmentIds]) { + const normalizedSegmentId = normalizeSegmentId(segmentId); + if (normalizedSegmentId === undefined) continue; + mergedSegmentIds.add(normalizedSegmentId); + } + return [...mergedSegmentIds].sort((a, b) => a - b); +} + +export function retainSpatiallyIndexedSkeletonOverlaySegment( + retainedSegmentIds: readonly number[], + segmentId: number, + options: { + maxRetained?: number; + } = {}, +) { + const normalizedSegmentId = normalizeSegmentId(segmentId); + if (normalizedSegmentId === undefined) { + return [...retainedSegmentIds]; + } + const nextRetainedSegmentIds = retainedSegmentIds.filter( + (candidateSegmentId) => candidateSegmentId !== normalizedSegmentId, + ); + nextRetainedSegmentIds.push(normalizedSegmentId); + const maxRetained = Math.max( + 1, + Math.round(options.maxRetained ?? DEFAULT_MAX_RETAINED_OVERLAY_SEGMENTS), + ); + if (nextRetainedSegmentIds.length <= maxRetained) { + return nextRetainedSegmentIds; + } + return nextRetainedSegmentIds.slice( + nextRetainedSegmentIds.length - maxRetained, + ); +} diff --git a/src/skeleton/picking.ts b/src/skeleton/picking.ts new file mode 100644 index 0000000000..e83489e833 --- /dev/null +++ b/src/skeleton/picking.ts @@ -0,0 +1,33 @@ +export function resolveSpatiallyIndexedSkeletonSegmentPick( + chunk: { indices: Uint32Array; numVertices: number }, + segmentIds: Uint32Array, + pickedOffset: number, + kind: "node" | "edge", +) { + if (pickedOffset < 0) return undefined; + if (kind === "node") { + if ( + pickedOffset >= segmentIds.length || + pickedOffset >= chunk.numVertices + ) { + return undefined; + } + const segmentId = segmentIds[pickedOffset]; + return Number.isSafeInteger(segmentId) && segmentId > 0 + ? segmentId + : undefined; + } + const indexOffset = pickedOffset * 2; + if (indexOffset + 1 >= chunk.indices.length) { + return undefined; + } + const vertexA = chunk.indices[indexOffset]; + const vertexB = chunk.indices[indexOffset + 1]; + let segmentId = segmentIds[vertexA]; + if (!Number.isSafeInteger(segmentId) || segmentId <= 0) { + segmentId = segmentIds[vertexB]; + } + return Number.isSafeInteger(segmentId) && segmentId > 0 + ? segmentId + : undefined; +} diff --git a/src/skeleton/render_mode.ts b/src/skeleton/render_mode.ts new file mode 100644 index 0000000000..22576cf61b --- /dev/null +++ b/src/skeleton/render_mode.ts @@ -0,0 +1,4 @@ +export enum SkeletonRenderMode { + LINES = 0, + LINES_AND_POINTS = 1, +} diff --git a/src/skeleton/skeleton_chunk_serialization.ts b/src/skeleton/skeleton_chunk_serialization.ts new file mode 100644 index 0000000000..ca0222534d --- /dev/null +++ b/src/skeleton/skeleton_chunk_serialization.ts @@ -0,0 +1,112 @@ +/** + * @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 { TypedNumberArray } from "#src/util/array.js"; + +export interface SkeletonChunkData { + vertexPositions: Float32Array | null; + vertexAttributes: TypedNumberArray[] | null; + indices: Uint32Array | null; + lod?: number; + nodeIds?: Int32Array; + nodeRevisionTokens?: Array; +} + +/** + * Calculates the total byte size of vertex attributes including positions. + */ +export function getVertexAttributeBytes(data: SkeletonChunkData): number { + let total = data.vertexPositions!.byteLength; + const { vertexAttributes } = data; + if (vertexAttributes != null) { + vertexAttributes.forEach((a) => { + total += a.byteLength; + }); + } + return total; +} + +/** + * Serializes skeleton chunk data for transfer to frontend. + * Packs vertex positions and attributes into a single Uint8Array for efficient transfer. + */ +export function serializeSkeletonChunkData( + data: SkeletonChunkData, + msg: any, + transfers: any[], +): void { + if (data.lod !== undefined) { + msg.lod = data.lod; + } + const vertexPositions = data.vertexPositions!; + const indices = data.indices!; + msg.numVertices = vertexPositions.length / 3; + msg.indices = indices; + transfers.push(indices.buffer); + + const { vertexAttributes } = data; + if (vertexAttributes != null && vertexAttributes.length > 0) { + const vertexData = new Uint8Array(getVertexAttributeBytes(data)); + vertexData.set( + new Uint8Array( + vertexPositions.buffer, + vertexPositions.byteOffset, + vertexPositions.byteLength, + ), + ); + const vertexAttributeOffsets = (msg.vertexAttributeOffsets = + new Uint32Array(vertexAttributes.length + 1)); + vertexAttributeOffsets[0] = 0; + let offset = vertexPositions.byteLength; + vertexAttributes.forEach((a, i) => { + vertexAttributeOffsets[i + 1] = offset; + vertexData.set( + new Uint8Array(a.buffer, a.byteOffset, a.byteLength), + offset, + ); + offset += a.byteLength; + }); + transfers.push(vertexData.buffer); + msg.vertexAttributes = vertexData; + } else { + msg.vertexAttributes = new Uint8Array( + vertexPositions.buffer, + vertexPositions.byteOffset, + vertexPositions.byteLength, + ); + msg.vertexAttributeOffsets = Uint32Array.of(0); + if (vertexPositions.buffer !== transfers[0]) { + transfers.push(vertexPositions.buffer); + } + } + + if (data.nodeIds) { + msg.nodeIds = data.nodeIds; + transfers.push(data.nodeIds.buffer); + } + if (data.nodeRevisionTokens) { + msg.nodeRevisionTokens = data.nodeRevisionTokens; + } +} + +/** + * Clears skeleton chunk data from memory. + */ +export function freeSkeletonChunkSystemMemory(data: SkeletonChunkData): void { + data.vertexPositions = data.indices = data.vertexAttributes = null; + data.nodeIds = undefined; + data.nodeRevisionTokens = undefined; +} diff --git a/src/skeleton/source_selection.spec.ts b/src/skeleton/source_selection.spec.ts new file mode 100644 index 0000000000..a0b2dda3f2 --- /dev/null +++ b/src/skeleton/source_selection.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { selectSpatiallyIndexedSkeletonEntriesByGrid } from "#src/skeleton/source_selection.js"; + +describe("skeleton/source_selection", () => { + it("returns the exact grid match when available", () => { + const entries = [ + { id: "coarse", gridIndex: 0 }, + { id: "medium", gridIndex: 2 }, + { id: "fine", gridIndex: 4 }, + ]; + expect( + selectSpatiallyIndexedSkeletonEntriesByGrid( + entries, + 2, + (entry) => entry.gridIndex, + ), + ).toEqual([entries[1]]); + }); + + it("returns the nearest grid match and keeps the first entry on ties", () => { + const entries = [ + { id: "left", gridIndex: 0 }, + { id: "right", gridIndex: 4 }, + ]; + expect( + selectSpatiallyIndexedSkeletonEntriesByGrid( + entries, + 2, + (entry) => entry.gridIndex, + ), + ).toEqual([entries[0]]); + }); + + it("returns all entries if any entry is missing a grid index", () => { + const entries = [ + { id: "indexed", gridIndex: 0 }, + { id: "unindexed" }, + { id: "indexed-2", gridIndex: 2 }, + ]; + expect( + selectSpatiallyIndexedSkeletonEntriesByGrid( + entries, + 1, + (entry) => entry.gridIndex, + ), + ).toEqual(entries); + }); +}); diff --git a/src/skeleton/source_selection.ts b/src/skeleton/source_selection.ts new file mode 100644 index 0000000000..c9164ccc9c --- /dev/null +++ b/src/skeleton/source_selection.ts @@ -0,0 +1,145 @@ +/** + * @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 type SpatiallyIndexedSkeletonView = "2d" | "3d"; + +interface SpatiallyIndexedSkeletonParameterHolder { + parameters?: { + gridIndex?: unknown; + view?: unknown; + }; +} + +type SpatiallyIndexedSkeletonParameterAccess = + | SpatiallyIndexedSkeletonParameterHolder + | { + source?: unknown; + chunkSource?: unknown; + }; + +function isSpatiallyIndexedSkeletonParameterHolder( + value: unknown, +): value is SpatiallyIndexedSkeletonParameterHolder { + return typeof value === "object" && value !== null; +} + +function getSpatiallyIndexedSkeletonParameterHolder( + value: SpatiallyIndexedSkeletonParameterAccess, +) { + if (!isSpatiallyIndexedSkeletonParameterHolder(value)) { + return undefined; + } + if ("chunkSource" in value && value.chunkSource !== undefined) { + return isSpatiallyIndexedSkeletonParameterHolder(value.chunkSource) + ? value.chunkSource + : undefined; + } + if ("source" in value && value.source !== undefined) { + return isSpatiallyIndexedSkeletonParameterHolder(value.source) + ? value.source + : undefined; + } + return value; +} + +export function getSpatiallyIndexedSkeletonGridIndex( + value: SpatiallyIndexedSkeletonParameterAccess, +) { + const gridIndex = + getSpatiallyIndexedSkeletonParameterHolder(value)?.parameters?.gridIndex; + return typeof gridIndex === "number" ? gridIndex : undefined; +} + +export function getSpatiallyIndexedSkeletonSourceView( + value: SpatiallyIndexedSkeletonParameterAccess, +) { + const sourceView = + getSpatiallyIndexedSkeletonParameterHolder(value)?.parameters?.view; + return typeof sourceView === "string" ? sourceView : undefined; +} + +export function selectSpatiallyIndexedSkeletonEntriesByGrid( + entries: readonly T[], + gridLevel: number | undefined, + getGridIndex: (entry: T) => number | undefined, +) { + if (entries.length === 0 || gridLevel === undefined) { + return [...entries]; + } + let exactMatch: T | undefined; + let closestMatch: T | undefined; + let bestDistance = Number.POSITIVE_INFINITY; + for (const entry of entries) { + const gridIndex = getGridIndex(entry); + if (gridIndex === undefined) { + return [...entries]; + } + if (exactMatch === undefined && gridIndex === gridLevel) { + exactMatch = entry; + } + const distance = Math.abs(gridIndex - gridLevel); + if (distance < bestDistance) { + bestDistance = distance; + closestMatch = entry; + } + } + return [exactMatch ?? closestMatch!]; +} + +export function filterSpatiallyIndexedSkeletonEntriesByView( + entries: readonly T[], + view: SpatiallyIndexedSkeletonView, + getView: (entry: T) => string | undefined, +) { + return entries.filter((entry) => { + const sourceView = getView(entry); + return sourceView === undefined || sourceView === view; + }); +} + +export function selectSpatiallyIndexedSkeletonEntriesForView( + entries: readonly T[], + view: SpatiallyIndexedSkeletonView, + gridLevel: number | undefined, + getView: (entry: T) => string | undefined, + getGridIndex: (entry: T) => number | undefined, +) { + const viewFiltered = filterSpatiallyIndexedSkeletonEntriesByView( + entries, + view, + getView, + ); + return selectSpatiallyIndexedSkeletonEntriesByGrid( + viewFiltered, + gridLevel, + getGridIndex, + ); +} + +export function dedupeSpatiallyIndexedSkeletonEntries( + entries: Iterable, + getKey: (entry: T) => string, +) { + const deduped: T[] = []; + const seenKeys = new Set(); + for (const entry of entries) { + const key = getKey(entry); + if (seenKeys.has(key)) continue; + seenKeys.add(key); + deduped.push(entry); + } + return deduped; +} diff --git a/src/skeleton/spatial_attribute_layout.ts b/src/skeleton/spatial_attribute_layout.ts new file mode 100644 index 0000000000..6b8ee8b2fb --- /dev/null +++ b/src/skeleton/spatial_attribute_layout.ts @@ -0,0 +1,6 @@ +import { DataType } from "#src/util/data_type.js"; + +export const spatiallyIndexedSkeletonTextureAttributeSpecs = Object.freeze([ + { name: "position", dataType: DataType.FLOAT32, numComponents: 3 }, + { name: "segment", dataType: DataType.UINT32, numComponents: 1 }, +]); diff --git a/src/skeleton/spatial_chunk_sizing.spec.ts b/src/skeleton/spatial_chunk_sizing.spec.ts new file mode 100644 index 0000000000..5a2cf9d081 --- /dev/null +++ b/src/skeleton/spatial_chunk_sizing.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { getDefaultSpatiallyIndexedSkeletonChunkSize } from "#src/skeleton/spatial_chunk_sizing.js"; + +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 }, + }), + ).toEqual({ x: 15, y: 15, z: 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 }, + }), + ).toEqual({ x: 16, y: 16, z: 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 }, + }), + ).toEqual({ x: 1, y: 1, z: 1 }); + }); + + it("supports overriding the chunk budget", () => { + expect( + getDefaultSpatiallyIndexedSkeletonChunkSize( + { + min: { x: 0, y: 0, z: 0 }, + max: { x: 100, y: 100, z: 100 }, + }, + { maxChunks: 8 }, + ), + ).toEqual({ x: 50, y: 50, z: 50 }); + }); + + it("rejects NaN bounds", () => { + expect(() => + getDefaultSpatiallyIndexedSkeletonChunkSize({ + min: { x: Number.NaN, y: 0, z: 0 }, + max: { x: 10, y: 10, z: 10 }, + }), + ).toThrow(/bounds must be finite/i); + }); + + it("rejects infinite bounds", () => { + expect(() => + getDefaultSpatiallyIndexedSkeletonChunkSize({ + min: { x: 0, y: 0, z: 0 }, + max: { x: Number.POSITIVE_INFINITY, y: 10, z: 10 }, + }), + ).toThrow(/bounds must be finite/i); + }); + + it("rejects NaN minChunkSize", () => { + expect(() => + getDefaultSpatiallyIndexedSkeletonChunkSize( + { + min: { x: 0, y: 0, z: 0 }, + max: { x: 10, y: 10, z: 10 }, + }, + { minChunkSize: Number.NaN }, + ), + ).toThrow(/minChunkSize must be finite/i); + }); + + it("rejects infinite maxChunks", () => { + expect(() => + getDefaultSpatiallyIndexedSkeletonChunkSize( + { + min: { x: 0, y: 0, z: 0 }, + max: { x: 10, y: 10, z: 10 }, + }, + { maxChunks: Number.POSITIVE_INFINITY }, + ), + ).toThrow(/maxChunks must be finite/i); + }); +}); diff --git a/src/skeleton/spatial_chunk_sizing.ts b/src/skeleton/spatial_chunk_sizing.ts new file mode 100644 index 0000000000..584f15d8ac --- /dev/null +++ b/src/skeleton/spatial_chunk_sizing.ts @@ -0,0 +1,121 @@ +/** + * @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. + */ + +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 interface DefaultSpatiallyIndexedSkeletonChunkSizeOptions { + maxChunks?: number; + minChunkSize?: number; +} + +function validateFiniteOptions( + options: DefaultSpatiallyIndexedSkeletonChunkSizeOptions, +) { + if ( + options.minChunkSize !== undefined && + !Number.isFinite(options.minChunkSize) + ) { + throw new Error("Spatially indexed skeleton minChunkSize must be finite."); + } + if (options.maxChunks !== undefined && !Number.isFinite(options.maxChunks)) { + throw new Error("Spatially indexed skeleton maxChunks must be finite."); + } +} + +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) { + if (!Number.isFinite(value)) { + throw new Error( + `Spatially indexed skeleton bounds must be finite, but ${name} is ${value}.`, + ); + } + } +} + +function getChunkCoverageForChunkSize( + extents: readonly number[], + chunkSize: number, +) { + return extents.reduce((product, extent) => { + const axisChunks = extent <= 0 ? 1 : Math.ceil(extent / chunkSize); + return product * axisChunks; + }, 1); +} + +export function getDefaultSpatiallyIndexedSkeletonChunkSize( + bounds: SpatiallyIndexedSkeletonBounds, + options: DefaultSpatiallyIndexedSkeletonChunkSizeOptions = {}, +): SpatiallyIndexedSkeletonChunkSize { + validateFiniteOptions(options); + validateFiniteBounds(bounds); + const minChunkSize = Math.max( + DEFAULT_SPATIALLY_INDEXED_SKELETON_MIN_CHUNK_SIZE, + Math.ceil( + options.minChunkSize ?? DEFAULT_SPATIALLY_INDEXED_SKELETON_MIN_CHUNK_SIZE, + ), + ); + const maxChunks = Math.max( + 1, + Math.floor( + 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 maxExtent = Math.max(...extents); + + if (!(maxExtent > 0)) { + return { x: minChunkSize, y: minChunkSize, z: minChunkSize }; + } + + // Choose the smallest isotropic chunk size that keeps the full bounding box + // coverage within the requested chunk budget. + let low = minChunkSize; + let high = Math.max(minChunkSize, Math.ceil(maxExtent)); + while (low < high) { + const mid = Math.floor((low + high) / 2); + if (getChunkCoverageForChunkSize(extents, mid) <= maxChunks) { + high = mid; + } else { + low = mid + 1; + } + } + + return { x: low, y: low, z: low }; +} diff --git a/src/skeleton/spatial_skeleton_manager.spec.ts b/src/skeleton/spatial_skeleton_manager.spec.ts new file mode 100644 index 0000000000..21acec77ce --- /dev/null +++ b/src/skeleton/spatial_skeleton_manager.spec.ts @@ -0,0 +1,696 @@ +/** + * @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 { describe, expect, it, vi } from "vitest"; + +import { + buildSpatiallyIndexedSkeletonNavigationGraph, + getFlatListNodeIds, + getSkeletonRootNode, +} from "#src/skeleton/navigation.js"; +import { + isEditableSpatiallyIndexedSkeletonSource, + SpatialSkeletonState, +} from "#src/skeleton/spatial_skeleton_manager.js"; + +describe("skeleton/spatial_skeleton_manager", () => { + it("does not require reroot support for editable sources", () => { + const editableSource = { + 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( + isEditableSpatiallyIndexedSkeletonSource({ + ...editableSource, + rerootSkeleton: async () => {}, + }), + ).toBe(true); + }); + + it("clears the full skeleton cache before notifying node data listeners", () => { + const state = new SpatialSkeletonState(); + const cachedSegmentId = 11; + (state as any).fullSegmentNodeCache.set(cachedSegmentId, [ + { + nodeId: 1, + segmentId: cachedSegmentId, + position: new Float32Array([1, 2, 3]), + }, + ]); + + let cachePresentDuringNotification: boolean | undefined; + state.nodeDataVersion.changed.add(() => { + cachePresentDuringNotification = (state as any).fullSegmentNodeCache.has( + cachedSegmentId, + ); + }); + + state.markNodeDataChanged(); + + expect(cachePresentDuringNotification).toBe(false); + expect((state as any).fullSegmentNodeCache.has(cachedSegmentId)).toBe( + false, + ); + }); + + it("clears inspected cache state and pending node positions together", () => { + const state = new SpatialSkeletonState(); + (state as any).replaceCachedSegmentNodes(11, [ + { + nodeId: 5, + segmentId: 11, + position: new Float32Array([1, 2, 3]), + }, + ]); + state.setPendingNodePosition(5, [4, 5, 6]); + const nodeDataVersion = state.nodeDataVersion.value; + const pendingNodePositionVersion = state.pendingNodePositionVersion.value; + + expect(state.clearInspectedSkeletonCache()).toBe(true); + expect(state.getCachedSegmentNodes(11)).toBeUndefined(); + expect(state.getCachedNode(5)).toBeUndefined(); + expect(state.getPendingNodePosition(5)).toBeUndefined(); + expect(state.nodeDataVersion.value).toBe(nodeDataVersion + 1); + expect(state.pendingNodePositionVersion.value).toBe( + pendingNodePositionVersion + 1, + ); + }); + + it("can seed a brand-new cached segment from a local node mutation", () => { + const state = new SpatialSkeletonState(); + + const changed = state.upsertCachedNode( + { + nodeId: 5, + segmentId: 11, + position: new Float32Array([1, 2, 3]), + parentNodeId: undefined, + isTrueEnd: false, + }, + { allowUncachedSegment: true }, + ); + + expect(changed).toBe(true); + expect(state.getCachedSegmentNodes(11)).toEqual([ + { + nodeId: 5, + segmentId: 11, + position: new Float32Array([1, 2, 3]), + parentNodeId: undefined, + description: undefined, + isTrueEnd: false, + }, + ]); + expect(state.getCachedNode(5)).toEqual({ + nodeId: 5, + segmentId: 11, + position: new Float32Array([1, 2, 3]), + parentNodeId: undefined, + description: undefined, + isTrueEnd: false, + }); + }); + + it("updates cached node lookup when a node moves between cached segments", () => { + const state = new SpatialSkeletonState(); + (state as any).replaceCachedSegmentNodes(11, [ + { + nodeId: 5, + segmentId: 11, + position: new Float32Array([1, 2, 3]), + parentNodeId: undefined, + isTrueEnd: false, + }, + ]); + (state as any).replaceCachedSegmentNodes(13, [ + { + nodeId: 7, + segmentId: 13, + position: new Float32Array([4, 5, 6]), + parentNodeId: undefined, + isTrueEnd: false, + }, + ]); + + expect( + state.upsertCachedNode({ + nodeId: 5, + segmentId: 13, + position: new Float32Array([7, 8, 9]), + parentNodeId: undefined, + isTrueEnd: false, + }), + ).toBe(true); + + expect(state.getCachedSegmentNodes(11)).toBeUndefined(); + expect(state.getCachedSegmentNodes(13)?.map((node) => node.nodeId)).toEqual( + [5, 7], + ); + expect(state.getCachedNode(5)).toEqual({ + nodeId: 5, + segmentId: 13, + position: new Float32Array([7, 8, 9]), + parentNodeId: undefined, + description: undefined, + isTrueEnd: false, + }); + }); + + it("does not drop an existing cached node when upserting into an uncached segment without permission", () => { + const state = new SpatialSkeletonState(); + (state as any).replaceCachedSegmentNodes(11, [ + { + nodeId: 5, + segmentId: 11, + position: new Float32Array([1, 2, 3]), + parentNodeId: undefined, + isTrueEnd: false, + }, + ]); + + expect( + state.upsertCachedNode({ + nodeId: 5, + segmentId: 13, + position: new Float32Array([7, 8, 9]), + parentNodeId: undefined, + isTrueEnd: false, + }), + ).toBe(false); + + expect(state.getCachedSegmentNodes(11)).toEqual([ + { + nodeId: 5, + segmentId: 11, + position: new Float32Array([1, 2, 3]), + parentNodeId: undefined, + description: undefined, + isTrueEnd: false, + }, + ]); + expect(state.getCachedSegmentNodes(13)).toBeUndefined(); + expect(state.getCachedNode(5)).toEqual({ + nodeId: 5, + segmentId: 11, + position: new Float32Array([1, 2, 3]), + parentNodeId: undefined, + description: undefined, + isTrueEnd: false, + }); + }); + + it("does not cache a full segment fetch that was evicted while pending", async () => { + const state = new SpatialSkeletonState(); + let resolveFetch: + | (( + value: Array<{ + nodeId: number; + parentNodeId?: number; + position: Float32Array; + segmentId: number; + isTrueEnd: boolean; + }>, + ) => void) + | undefined; + const getSkeleton = vi.fn( + () => + new Promise< + Array<{ + nodeId: number; + parentNodeId?: number; + position: Float32Array; + segmentId: number; + isTrueEnd: boolean; + }> + >((resolve) => { + resolveFetch = resolve as typeof resolveFetch; + }), + ); + const skeletonLayer = { + source: { + listSkeletons: async () => [], + getSkeleton, + fetchNodes: async () => [], + getSpatialIndexMetadata: async () => null, + }, + } as any; + + const pending = state.getFullSegmentNodes(skeletonLayer, 11); + + state.evictInactiveSegmentNodes([]); + resolveFetch?.([ + { + nodeId: 5, + parentNodeId: undefined, + position: new Float32Array([1, 2, 3]), + segmentId: 11, + isTrueEnd: false, + }, + ]); + + await expect(pending).resolves.toEqual([ + { + nodeId: 5, + segmentId: 11, + position: new Float32Array([1, 2, 3]), + parentNodeId: undefined, + description: undefined, + isTrueEnd: false, + }, + ]); + expect(state.getCachedSegmentNodes(11)).toBeUndefined(); + expect(state.getCachedNode(5)).toBeUndefined(); + }); + + it("aborts pending full segment fetches when the cache generation is cleared", async () => { + const state = new SpatialSkeletonState(); + let receivedSignal: AbortSignal | undefined; + const getSkeleton = vi.fn( + (_segmentId: number, options?: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + receivedSignal = options?.signal; + options?.signal?.addEventListener( + "abort", + () => reject(options.signal?.reason), + { once: true }, + ); + }), + ); + + const pending = state.getFullSegmentNodes( + { + source: { + listSkeletons: async () => [], + getSkeleton, + fetchNodes: async () => [], + getSpatialIndexMetadata: async () => null, + }, + } as any, + 11, + ); + + expect(receivedSignal?.aborted).toBe(false); + expect(state.clearInspectedSkeletonCache()).toBe(true); + expect(receivedSignal?.aborted).toBe(true); + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + expect(state.getCachedSegmentNodes(11)).toBeUndefined(); + expect(state.getCachedNode(11)).toBeUndefined(); + }); + + it("aborts pending full segment fetches when a segment is invalidated", async () => { + const state = new SpatialSkeletonState(); + let receivedSignal: AbortSignal | undefined; + const getSkeleton = vi.fn( + (_segmentId: number, options?: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + receivedSignal = options?.signal; + options?.signal?.addEventListener( + "abort", + () => reject(options.signal?.reason), + { once: true }, + ); + }), + ); + + const pending = state.getFullSegmentNodes( + { + source: { + listSkeletons: async () => [], + getSkeleton, + fetchNodes: async () => [], + getSpatialIndexMetadata: async () => null, + }, + } as any, + 11, + ); + + expect(receivedSignal?.aborted).toBe(false); + expect(state.invalidateCachedSegments([11])).toBe(false); + expect(receivedSignal?.aborted).toBe(true); + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + expect(state.getCachedSegmentNodes(11)).toBeUndefined(); + expect(state.getCachedNode(11)).toBeUndefined(); + }); + + it("aborts pending full segment fetches when a segment is evicted", async () => { + const state = new SpatialSkeletonState(); + let receivedSignal: AbortSignal | undefined; + const getSkeleton = vi.fn( + (_segmentId: number, options?: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + receivedSignal = options?.signal; + options?.signal?.addEventListener( + "abort", + () => reject(options.signal?.reason), + { once: true }, + ); + }), + ); + + const pending = state.getFullSegmentNodes( + { + source: { + listSkeletons: async () => [], + getSkeleton, + fetchNodes: async () => [], + getSpatialIndexMetadata: async () => null, + }, + } as any, + 11, + ); + + expect(receivedSignal?.aborted).toBe(false); + expect(state.evictInactiveSegmentNodes([])).toBe(false); + expect(receivedSignal?.aborted).toBe(true); + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + expect(state.getCachedSegmentNodes(11)).toBeUndefined(); + expect(state.getCachedNode(11)).toBeUndefined(); + }); + + it("notifies node data listeners after caching a fetched full segment", async () => { + const state = new SpatialSkeletonState(); + const getSkeleton = vi.fn(async () => [ + { + nodeId: 5, + parentNodeId: undefined, + position: new Float32Array([1, 2, 3]), + segmentId: 11, + isTrueEnd: false, + }, + ]); + const skeletonLayer = { + source: { + listSkeletons: async () => [], + getSkeleton, + fetchNodes: async () => [], + getSpatialIndexMetadata: async () => null, + }, + } as any; + let notifications = 0; + state.nodeDataVersion.changed.add(() => { + notifications += 1; + }); + + await expect(state.getFullSegmentNodes(skeletonLayer, 11)).resolves.toEqual( + [ + { + nodeId: 5, + segmentId: 11, + position: new Float32Array([1, 2, 3]), + parentNodeId: undefined, + description: undefined, + isTrueEnd: false, + }, + ], + ); + + expect(notifications).toBe(1); + expect(state.getCachedNode(5)).toEqual({ + nodeId: 5, + segmentId: 11, + position: new Float32Array([1, 2, 3]), + parentNodeId: undefined, + description: undefined, + isTrueEnd: false, + }); + }); + + it("caches inspected revision metadata from full skeleton inspection", async () => { + const state = new SpatialSkeletonState(); + const getSkeleton = vi.fn(async () => [ + { + nodeId: 5, + parentNodeId: undefined, + position: new Float32Array([1, 2, 3]), + segmentId: 11, + isTrueEnd: false, + revisionToken: "2026-03-29T12:30:00Z", + }, + ]); + + await expect( + state.getFullSegmentNodes( + { + source: { + listSkeletons: async () => [], + getSkeleton, + fetchNodes: async () => [], + getSpatialIndexMetadata: async () => null, + }, + } as any, + 11, + ), + ).resolves.toEqual([ + { + nodeId: 5, + segmentId: 11, + position: new Float32Array([1, 2, 3]), + parentNodeId: undefined, + description: undefined, + isTrueEnd: false, + revisionToken: "2026-03-29T12:30:00Z", + }, + ]); + + expect(getSkeleton).toHaveBeenCalledTimes(1); + expect(state.getCachedNode(5)).toEqual({ + nodeId: 5, + segmentId: 11, + position: new Float32Array([1, 2, 3]), + parentNodeId: undefined, + description: undefined, + isTrueEnd: false, + revisionToken: "2026-03-29T12:30:00Z", + }); + }); + + it("stores merge anchor state only when the node id is valid", () => { + const state = new SpatialSkeletonState(); + + expect(state.setMergeAnchor(5)).toBe(true); + expect(state.mergeAnchorNodeId.value).toBe(5); + + expect(state.setMergeAnchor(0)).toBe(true); + expect(state.mergeAnchorNodeId.value).toBeUndefined(); + }); + + it("stores provided confidence when setting properties", () => { + const state = new SpatialSkeletonState(); + (state as any).replaceCachedSegmentNodes(11, [ + { + nodeId: 1, + segmentId: 11, + position: new Float32Array([1, 2, 3]), + parentNodeId: undefined, + radius: 4, + confidence: 50, + }, + ]); + + expect(state.setNodeProperties(1, { radius: 6, confidence: 63 })).toBe( + true, + ); + expect(state.getCachedNode(1)).toMatchObject({ + radius: 6, + confidence: 63, + }); + }); + + it("removes and reparents nodes within the affected cached segment only", () => { + const state = new SpatialSkeletonState(); + (state as any).replaceCachedSegmentNodes(11, [ + { + nodeId: 1, + segmentId: 11, + position: new Float32Array([1, 1, 1]), + parentNodeId: undefined, + isTrueEnd: false, + }, + { + nodeId: 2, + segmentId: 11, + position: new Float32Array([2, 2, 2]), + parentNodeId: 1, + isTrueEnd: false, + }, + { + nodeId: 3, + segmentId: 11, + position: new Float32Array([3, 3, 3]), + parentNodeId: 1, + isTrueEnd: false, + }, + ]); + (state as any).replaceCachedSegmentNodes(12, [ + { + nodeId: 4, + segmentId: 12, + position: new Float32Array([4, 4, 4]), + parentNodeId: undefined, + isTrueEnd: false, + }, + ]); + + expect( + state.removeCachedNode(1, { + parentNodeId: undefined, + childNodeIds: [2, 3], + }), + ).toBe(true); + + expect(state.getCachedSegmentNodes(11)).toEqual([ + { + nodeId: 2, + segmentId: 11, + position: new Float32Array([2, 2, 2]), + parentNodeId: undefined, + description: undefined, + isTrueEnd: false, + }, + { + nodeId: 3, + segmentId: 11, + position: new Float32Array([3, 3, 3]), + parentNodeId: undefined, + description: undefined, + isTrueEnd: false, + }, + ]); + expect(state.getCachedSegmentNodes(12)).toEqual([ + { + nodeId: 4, + segmentId: 12, + position: new Float32Array([4, 4, 4]), + parentNodeId: undefined, + description: undefined, + isTrueEnd: false, + }, + ]); + }); + + it("reroots cached segment topology, confidence, and derived ordering", () => { + const state = new SpatialSkeletonState(); + (state as any).replaceCachedSegmentNodes(11, [ + { + nodeId: 1, + segmentId: 11, + position: new Float32Array([1, 1, 1]), + parentNodeId: undefined, + confidence: 80, + }, + { + nodeId: 2, + segmentId: 11, + position: new Float32Array([2, 2, 2]), + parentNodeId: 1, + confidence: 20, + }, + { + nodeId: 3, + segmentId: 11, + position: new Float32Array([3, 3, 3]), + parentNodeId: 2, + confidence: 10, + }, + { + nodeId: 4, + segmentId: 11, + position: new Float32Array([4, 4, 4]), + parentNodeId: 2, + confidence: 40, + }, + { + nodeId: 5, + segmentId: 11, + position: new Float32Array([5, 5, 5]), + parentNodeId: 1, + confidence: 50, + }, + ]); + + expect(state.rerootCachedSegment(3)).toEqual([3, 2, 1]); + + const cachedNodes = state.getCachedSegmentNodes(11)!; + expect(cachedNodes.find((node) => node.nodeId === 3)).toMatchObject({ + parentNodeId: undefined, + confidence: 100, + }); + expect(cachedNodes.find((node) => node.nodeId === 2)).toMatchObject({ + parentNodeId: 3, + confidence: 10, + }); + expect(cachedNodes.find((node) => node.nodeId === 1)).toMatchObject({ + parentNodeId: 2, + confidence: 20, + }); + expect(cachedNodes.find((node) => node.nodeId === 4)).toMatchObject({ + parentNodeId: 2, + confidence: 40, + }); + expect(cachedNodes.find((node) => node.nodeId === 5)).toMatchObject({ + parentNodeId: 1, + confidence: 50, + }); + + const graph = buildSpatiallyIndexedSkeletonNavigationGraph(cachedNodes); + expect(getSkeletonRootNode(graph).nodeId).toBe(3); + expect(getFlatListNodeIds(graph)).toEqual([3, 2, 4, 1, 5]); + }); + + it("stores empty segments in the cache if nothing present for that segment in cache", () => { + const state = new SpatialSkeletonState(); + (state as any).replaceCachedSegmentNodes(1, []); + expect(state.getCachedSegmentNodes(1)?.length).toBe(0); + }); + + it("deletes segment from cache if the segment becomes empty", () => { + const state = new SpatialSkeletonState(); + const node = { + nodeId: 1, + segmentId: 1, + position: new Float32Array([1, 1, 1]), + }; + (state as any).replaceCachedSegmentNodes(1, [node]); + expect(state.getCachedSegmentNodes(1)).toStrictEqual([node]); + expect(state.getCachedNode(1)).toBe(node); + (state as any).replaceCachedSegmentNodes(1, []); + expect(state.getCachedSegmentNodes(1)).toBeUndefined(); + expect(state.getCachedNode(1)).toBeUndefined(); + }); +}); diff --git a/src/skeleton/spatial_skeleton_manager.ts b/src/skeleton/spatial_skeleton_manager.ts new file mode 100644 index 0000000000..e334f4fd16 --- /dev/null +++ b/src/skeleton/spatial_skeleton_manager.ts @@ -0,0 +1,827 @@ +/** + * @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 { + EditableSpatiallyIndexedSkeletonSource, + SpatiallyIndexedSkeletonNode, + SpatiallyIndexedSkeletonNodeRevisionUpdate, + SpatiallyIndexedSkeletonSource, +} from "#src/skeleton/api.js"; +import { SpatialSkeletonCommandHistory } from "#src/skeleton/command_history.js"; +import type { SpatiallyIndexedSkeletonLayer } from "#src/skeleton/frontend.js"; +import { WatchableValue } from "#src/trackable_value.js"; +import { RefCounted } from "#src/util/disposable.js"; + +interface SpatialSkeletonSourceAccess { + source: unknown; +} + +function hasFunction( + value: unknown, + property: T, +): value is Record unknown> { + return ( + typeof value === "object" && + value !== null && + typeof (value as Record)[property] === "function" + ); +} + +export function isSpatiallyIndexedSkeletonSource( + value: unknown, +): value is SpatiallyIndexedSkeletonSource { + return ( + hasFunction(value, "listSkeletons") && + hasFunction(value, "getSkeleton") && + hasFunction(value, "getSpatialIndexMetadata") && + hasFunction(value, "fetchNodes") + ); +} + +export function isEditableSpatiallyIndexedSkeletonSource( + value: unknown, +): 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") + ); +} + +export function getSpatiallyIndexedSkeletonSource( + value: SpatialSkeletonSourceAccess | undefined, +): SpatiallyIndexedSkeletonSource | undefined { + if (value === undefined) return undefined; + return isSpatiallyIndexedSkeletonSource(value.source) + ? value.source + : undefined; +} + +export function getEditableSpatiallyIndexedSkeletonSource( + value: SpatialSkeletonSourceAccess | undefined, +): EditableSpatiallyIndexedSkeletonSource | undefined { + if (value === undefined) return undefined; + return isEditableSpatiallyIndexedSkeletonSource(value.source) + ? value.source + : undefined; +} + +export function normalizeSpatiallyIndexedSkeletonNode( + node: SpatiallyIndexedSkeletonNode, + fallbackSegmentId: number, +): SpatiallyIndexedSkeletonNode | undefined { + const nodeId = Number(node.nodeId); + const segmentIdValue = Number(node.segmentId); + const x = Number(node.position[0]); + const y = Number(node.position[1]); + const z = Number(node.position[2]); + if ( + !Number.isFinite(nodeId) || + !Number.isFinite(segmentIdValue) || + !Number.isFinite(x) || + !Number.isFinite(y) || + !Number.isFinite(z) + ) { + return undefined; + } + const parentNodeId = + node.parentNodeId === undefined || + !Number.isFinite(Number(node.parentNodeId)) + ? undefined + : Math.round(Number(node.parentNodeId)); + return { + ...node, + nodeId: Math.round(nodeId), + segmentId: Math.round( + Number.isFinite(segmentIdValue) ? segmentIdValue : fallbackSegmentId, + ), + position: new Float32Array([x, y, z]), + parentNodeId, + description: + typeof node.description === "string" && node.description.length > 0 + ? node.description + : undefined, + isTrueEnd: node.isTrueEnd, + ...((node.radius !== undefined && Number.isFinite(Number(node.radius))) || + (node.confidence !== undefined && Number.isFinite(Number(node.confidence))) + ? { + ...(node.radius !== undefined && Number.isFinite(Number(node.radius)) + ? { radius: Number(node.radius) } + : {}), + ...(node.confidence !== undefined && + Number.isFinite(Number(node.confidence)) + ? { confidence: Number(node.confidence) } + : {}), + } + : {}), + ...(node.revisionToken === undefined + ? {} + : { revisionToken: node.revisionToken }), + }; +} + +function cloneSpatiallyIndexedSkeletonNode( + node: SpatiallyIndexedSkeletonNode, +): SpatiallyIndexedSkeletonNode { + return { + ...node, + position: new Float32Array(node.position), + }; +} + +export class SpatialSkeletonState extends RefCounted { + readonly commandHistory = this.registerDisposer( + new SpatialSkeletonCommandHistory(), + ); + readonly editMode = new WatchableValue(false); + readonly mergeMode = new WatchableValue(false); + readonly splitMode = new WatchableValue(false); + readonly mergeAnchorNodeId = new WatchableValue( + undefined, + ); + readonly nodeDataVersion = new WatchableValue(0); + readonly pendingNodePositionVersion = new WatchableValue(0); + + private pendingNodePositions = new Map(); + private fullSkeletonCacheGeneration = 0; + private fullSegmentNodeCache = new Map< + number, + SpatiallyIndexedSkeletonNode[] + >(); + private pendingFullSegmentNodeFetches = new Map< + number, + { + promise: Promise; + abortController: AbortController; + } + >(); + private cachedNodesById = new Map(); + + setNodeProperties( + nodeId: number, + properties: { radius: number; confidence: 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) + ) { + return false; + } + return this.updateCachedNode(normalizedNodeId, (node) => { + if (node.radius === radius && node.confidence === confidence) { + return node; + } + return { + ...node, + radius, + confidence, + }; + }); + } + + getPendingNodeIds() { + return this.pendingNodePositions.keys(); + } + + getPendingNodePosition(nodeId: number) { + return this.pendingNodePositions.get(nodeId); + } + + private normalizeNodeId(nodeId: number | undefined) { + if (nodeId === undefined) return undefined; + const normalizedNodeId = Math.round(Number(nodeId)); + if (!Number.isSafeInteger(normalizedNodeId) || normalizedNodeId <= 0) { + return undefined; + } + return normalizedNodeId; + } + + setMergeAnchor(nodeId: number | undefined) { + const normalizedNodeId = this.normalizeNodeId(nodeId); + if (this.mergeAnchorNodeId.value === normalizedNodeId) { + return false; + } + this.mergeAnchorNodeId.value = normalizedNodeId; + return true; + } + + clearMergeAnchor() { + return this.setMergeAnchor(undefined); + } + + setPendingNodePosition(nodeId: number, position: ArrayLike) { + const normalizedNodeId = this.normalizeNodeId(nodeId); + const x = Number(position[0]); + const y = Number(position[1]); + const z = Number(position[2]); + if ( + normalizedNodeId === undefined || + !Number.isFinite(x) || + !Number.isFinite(y) || + !Number.isFinite(z) + ) { + return false; + } + const existing = this.pendingNodePositions.get(normalizedNodeId); + if ( + existing !== undefined && + existing[0] === x && + existing[1] === y && + existing[2] === z + ) { + return false; + } + this.pendingNodePositions.set( + normalizedNodeId, + new Float32Array([x, y, z]), + ); + this.pendingNodePositionVersion.value = + this.pendingNodePositionVersion.value + 1; + return true; + } + + clearPendingNodePosition(nodeId: number) { + const normalizedNodeId = this.normalizeNodeId(nodeId); + if ( + normalizedNodeId === undefined || + !this.pendingNodePositions.delete(normalizedNodeId) + ) { + return false; + } + this.pendingNodePositionVersion.value = + this.pendingNodePositionVersion.value + 1; + return true; + } + + clearPendingNodePositions() { + if (this.pendingNodePositions.size === 0) { + return false; + } + this.pendingNodePositions.clear(); + this.pendingNodePositionVersion.value = + this.pendingNodePositionVersion.value + 1; + return true; + } + + updateCommandHistorySource(source: unknown) { + return this.commandHistory.setSource(source); + } + + clearInspectedSkeletonCache() { + const cacheChanged = + this.fullSegmentNodeCache.size !== 0 || + this.pendingFullSegmentNodeFetches.size !== 0 || + this.cachedNodesById.size !== 0; + const pendingChanged = this.clearPendingNodePositions(); + if (!cacheChanged) { + return pendingChanged; + } + this.clearFullSkeletonCache(); + this.nodeDataVersion.value = this.nodeDataVersion.value + 1; + return true; + } + + markNodeDataChanged(options: { invalidateFullSkeletonCache?: boolean } = {}) { + if (options.invalidateFullSkeletonCache ?? true) { + this.clearFullSkeletonCache(); + } + this.nodeDataVersion.value = this.nodeDataVersion.value + 1; + } + + getCachedSegmentNodes(segmentId: number) { + return this.fullSegmentNodeCache.get(segmentId); + } + + getCachedNode(nodeId: number) { + return this.cachedNodesById.get(nodeId); + } + + private replaceCachedSegmentNodes( + segmentId: number, + nextSegmentNodes: readonly SpatiallyIndexedSkeletonNode[], + ) { + const previousSegmentNodes = this.fullSegmentNodeCache.get(segmentId); + if (previousSegmentNodes !== undefined) { + for (const node of previousSegmentNodes) { + if (this.cachedNodesById.get(node.nodeId) === node) { + this.cachedNodesById.delete(node.nodeId); + } + } + } + if (nextSegmentNodes.length === 0) { + if (previousSegmentNodes === undefined) { + // No previous entry, assume this is an empty segment + this.fullSegmentNodeCache.set(segmentId, []); + } else { + // Previous entry exists, this is a segment being cleared + this.fullSegmentNodeCache.delete(segmentId); + } + return true; + } + const normalizedSegmentNodes = [...nextSegmentNodes]; + this.fullSegmentNodeCache.set(segmentId, normalizedSegmentNodes); + for (const node of normalizedSegmentNodes) { + this.cachedNodesById.set(node.nodeId, node); + } + return true; + } + + private deleteCachedSegment(segmentId: number) { + const previousSegmentNodes = this.fullSegmentNodeCache.get(segmentId); + if (previousSegmentNodes === undefined) return false; + for (const node of previousSegmentNodes) { + if (this.cachedNodesById.get(node.nodeId) === node) { + this.cachedNodesById.delete(node.nodeId); + } + } + + this.fullSegmentNodeCache.delete(segmentId); + return true; + } + + private abortPendingFullSegmentNodeFetch(segmentId: number, message: string) { + const pendingEntry = this.pendingFullSegmentNodeFetches.get(segmentId); + if (pendingEntry === undefined) { + return false; + } + pendingEntry.abortController.abort(new DOMException(message, "AbortError")); + this.pendingFullSegmentNodeFetches.delete(segmentId); + return true; + } + + setCachedNodeRevision(nodeId: number, revisionToken: string | undefined) { + if (revisionToken === undefined) { + return false; + } + return this.updateCachedNode(nodeId, (node) => { + if (node.revisionToken === revisionToken) { + return node; + } + return { + ...node, + revisionToken, + }; + }); + } + + setCachedNodeRevisions( + revisionUpdates: readonly SpatiallyIndexedSkeletonNodeRevisionUpdate[], + ) { + let changed = false; + for (const update of revisionUpdates) { + changed = + this.setCachedNodeRevision(update.nodeId, update.revisionToken) || + changed; + } + return changed; + } + + private getCachedSegmentIdForNode(nodeId: number) { + const normalizedNodeId = this.normalizeNodeId(nodeId); + if (normalizedNodeId === undefined) { + return undefined; + } + return this.cachedNodesById.get(normalizedNodeId)?.segmentId; + } + + private updateCachedNodeInSegment( + segmentId: number, + nodeId: number, + update: ( + node: SpatiallyIndexedSkeletonNode, + ) => SpatiallyIndexedSkeletonNode, + ) { + const segmentNodes = this.fullSegmentNodeCache.get(segmentId); + if (segmentNodes === undefined) { + return false; + } + let segmentChanged = false; + const nextSegmentNodes = segmentNodes.map((candidate) => { + if (candidate.nodeId !== nodeId) return candidate; + const updatedNode = update(candidate); + segmentChanged ||= updatedNode !== candidate; + return updatedNode; + }); + if (!segmentChanged) { + return false; + } + this.replaceCachedSegmentNodes(segmentId, nextSegmentNodes); + return true; + } + + private upsertCachedNodeInSegment( + segmentId: number, + node: SpatiallyIndexedSkeletonNode, + ) { + const segmentNodes = this.fullSegmentNodeCache.get(segmentId); + if (segmentNodes === undefined) { + return false; + } + const existingIndex = segmentNodes.findIndex( + (candidate) => candidate.nodeId === node.nodeId, + ); + if (existingIndex !== -1) { + const nextSegmentNodes = segmentNodes.slice(); + nextSegmentNodes[existingIndex] = node; + this.replaceCachedSegmentNodes(segmentId, nextSegmentNodes); + return true; + } + const insertIndex = segmentNodes.findIndex( + (candidate) => candidate.nodeId > node.nodeId, + ); + const nextSegmentNodes = segmentNodes.slice(); + nextSegmentNodes.splice( + insertIndex === -1 ? nextSegmentNodes.length : insertIndex, + 0, + node, + ); + this.replaceCachedSegmentNodes(segmentId, nextSegmentNodes); + return true; + } + + updateCachedNode( + nodeId: number, + update: ( + node: SpatiallyIndexedSkeletonNode, + ) => SpatiallyIndexedSkeletonNode, + ) { + const segmentId = this.getCachedSegmentIdForNode(nodeId); + if (segmentId === undefined) { + return false; + } + return this.updateCachedNodeInSegment(segmentId, nodeId, update); + } + + upsertCachedNode( + node: SpatiallyIndexedSkeletonNode, + options: { allowUncachedSegment?: boolean } = {}, + ) { + const normalizedNode = cloneSpatiallyIndexedSkeletonNode(node); + const targetSegmentCached = this.fullSegmentNodeCache.has( + normalizedNode.segmentId, + ); + const allowUncachedSegment = options.allowUncachedSegment ?? false; + const existingSegmentId = this.getCachedSegmentIdForNode( + normalizedNode.nodeId, + ); + if (!targetSegmentCached && !allowUncachedSegment) { + return false; + } + let changed = false; + if ( + existingSegmentId !== undefined && + existingSegmentId !== normalizedNode.segmentId + ) { + const existingSegmentNodes = + this.fullSegmentNodeCache.get(existingSegmentId); + if (existingSegmentNodes !== undefined) { + this.replaceCachedSegmentNodes( + existingSegmentId, + existingSegmentNodes.filter( + (candidate) => candidate.nodeId !== normalizedNode.nodeId, + ), + ); + changed = true; + } + } + if (!targetSegmentCached && allowUncachedSegment) { + this.abortPendingFullSegmentNodeFetch( + normalizedNode.segmentId, + "spatial skeleton full-segment inspection request replaced by local segment cache update", + ); + this.replaceCachedSegmentNodes(normalizedNode.segmentId, [ + normalizedNode, + ]); + return true; + } + return ( + this.upsertCachedNodeInSegment( + normalizedNode.segmentId, + normalizedNode, + ) || changed + ); + } + + moveCachedNode(nodeId: number, position: ArrayLike) { + const x = Number(position[0]); + const y = Number(position[1]); + const z = Number(position[2]); + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) { + return false; + } + return this.updateCachedNode(nodeId, (node) => { + if ( + node.position[0] === x && + node.position[1] === y && + node.position[2] === z + ) { + return node; + } + return { + ...node, + position: new Float32Array([x, y, z]), + }; + }); + } + + removeCachedNode( + nodeId: number, + options: { + parentNodeId?: number; + childNodeIds?: Iterable; + } = {}, + ) { + const normalizedNodeId = this.normalizeNodeId(nodeId); + if (normalizedNodeId === undefined) { + return false; + } + const childNodeIds = options.childNodeIds + ? new Set( + [...options.childNodeIds] + .map((value) => this.normalizeNodeId(Number(value))) + .filter((value): value is number => value !== undefined), + ) + : undefined; + let segmentId = this.getCachedSegmentIdForNode(normalizedNodeId); + if (segmentId === undefined && childNodeIds !== undefined) { + for (const childNodeId of childNodeIds) { + segmentId = this.getCachedSegmentIdForNode(childNodeId); + if (segmentId !== undefined) { + break; + } + } + } + if (segmentId === undefined) { + return false; + } + const segmentNodes = this.fullSegmentNodeCache.get(segmentId); + if (segmentNodes === undefined) { + return false; + } + let segmentChanged = false; + const nextSegmentNodes: SpatiallyIndexedSkeletonNode[] = []; + for (const candidate of segmentNodes) { + if (candidate.nodeId === normalizedNodeId) { + segmentChanged = true; + continue; + } + if (childNodeIds?.has(candidate.nodeId)) { + nextSegmentNodes.push({ + ...candidate, + parentNodeId: options.parentNodeId, + }); + segmentChanged = true; + continue; + } + nextSegmentNodes.push(candidate); + } + if (!segmentChanged) { + return false; + } + this.replaceCachedSegmentNodes(segmentId, nextSegmentNodes); + return true; + } + + setCachedNodeParent(nodeId: number, parentNodeId: number | undefined) { + return this.updateCachedNode(nodeId, (node) => { + if (node.parentNodeId === parentNodeId) { + return node; + } + return { + ...node, + parentNodeId, + }; + }); + } + + rerootCachedSegment(nodeId: number) { + const normalizedNodeId = this.normalizeNodeId(nodeId); + if (normalizedNodeId === undefined) { + return undefined; + } + const targetNode = this.cachedNodesById.get(normalizedNodeId); + if (targetNode === undefined) { + return undefined; + } + const segmentNodes = this.fullSegmentNodeCache.get(targetNode.segmentId); + if (segmentNodes === undefined) { + return undefined; + } + + const nodeById = new Map(); + for (const node of segmentNodes) { + nodeById.set(node.nodeId, node); + } + const startNode = nodeById.get(normalizedNodeId); + if (startNode === undefined) { + return undefined; + } + if (startNode.parentNodeId === undefined) { + return [startNode.nodeId]; + } + + const pathNodeIds: number[] = []; + const seen = new Set(); + let currentNode: SpatiallyIndexedSkeletonNode | undefined = startNode; + while (currentNode !== undefined) { + if (seen.has(currentNode.nodeId)) { + return undefined; + } + seen.add(currentNode.nodeId); + pathNodeIds.push(currentNode.nodeId); + const parentNodeId = currentNode.parentNodeId; + if (parentNodeId === undefined) { + break; + } + currentNode = nodeById.get(parentNodeId); + if (currentNode === undefined) { + return undefined; + } + } + + const nextParentByNodeId = new Map(); + const nextConfidenceByNodeId = new Map(); + nextParentByNodeId.set(startNode.nodeId, undefined); + nextConfidenceByNodeId.set(startNode.nodeId, 100); + + let downstreamConfidence = startNode.confidence; + for (let i = 1; i < pathNodeIds.length; ++i) { + const upstreamNodeId = pathNodeIds[i]; + const upstreamNode = nodeById.get(upstreamNodeId)!; + nextParentByNodeId.set(upstreamNodeId, pathNodeIds[i - 1]); + nextConfidenceByNodeId.set( + upstreamNodeId, + downstreamConfidence ?? upstreamNode.confidence, + ); + downstreamConfidence = upstreamNode.confidence; + } + + let changed = false; + const nextSegmentNodes = segmentNodes.map((candidate) => { + if (!nextParentByNodeId.has(candidate.nodeId)) { + return candidate; + } + const nextParentNodeId = nextParentByNodeId.get(candidate.nodeId); + const nextConfidence = nextConfidenceByNodeId.get(candidate.nodeId); + if ( + candidate.parentNodeId === nextParentNodeId && + candidate.confidence === nextConfidence + ) { + return candidate; + } + changed = true; + return { + ...candidate, + parentNodeId: nextParentNodeId, + confidence: nextConfidence, + }; + }); + if (!changed) { + return pathNodeIds; + } + this.replaceCachedSegmentNodes(targetNode.segmentId, nextSegmentNodes); + return pathNodeIds; + } + + invalidateCachedSegments(segmentIds: Iterable) { + let changed = false; + for (const segmentId of segmentIds) { + const normalizedSegmentId = Math.round(Number(segmentId)); + if ( + !Number.isSafeInteger(normalizedSegmentId) || + normalizedSegmentId <= 0 + ) { + continue; + } + changed = this.deleteCachedSegment(normalizedSegmentId) || changed; + this.abortPendingFullSegmentNodeFetch( + normalizedSegmentId, + "spatial skeleton full-segment inspection request invalidated for segment", + ); + } + return changed; + } + + evictInactiveSegmentNodes(activeSegmentIds: Iterable) { + const activeSegmentIdSet = new Set(activeSegmentIds); + let changed = false; + for (const segmentId of this.fullSegmentNodeCache.keys()) { + if (activeSegmentIdSet.has(segmentId)) continue; + changed = this.deleteCachedSegment(segmentId) || changed; + } + for (const segmentId of this.pendingFullSegmentNodeFetches.keys()) { + if (activeSegmentIdSet.has(segmentId)) continue; + this.abortPendingFullSegmentNodeFetch( + segmentId, + "spatial skeleton full-segment inspection request evicted for inactive segment", + ); + } + return changed; + } + + async getFullSegmentNodes( + skeletonLayer: SpatiallyIndexedSkeletonLayer, + segmentId: number, + ): Promise { + const cached = this.fullSegmentNodeCache.get(segmentId); + if (cached !== undefined) { + return cached; + } + const pendingEntry = this.pendingFullSegmentNodeFetches.get(segmentId); + if (pendingEntry !== undefined) { + return pendingEntry.promise; + } + const skeletonSource = getSpatiallyIndexedSkeletonSource(skeletonLayer); + if (skeletonSource === undefined) { + throw new Error( + "The active spatial skeleton source does not expose full skeleton inspection.", + ); + } + const fetchVersion = this.fullSkeletonCacheGeneration; + const abortController = new AbortController(); + const pendingFetch: { + promise?: Promise; + } = {}; + const fetchPromise = (async () => { + const fetchedNodes = await skeletonSource.getSkeleton(segmentId, { + signal: abortController.signal, + }); + const dedupedNodes = new Map(); + for (const fetchedNode of fetchedNodes) { + const mappedNode = normalizeSpatiallyIndexedSkeletonNode( + fetchedNode, + segmentId, + ); + if (mappedNode === undefined) continue; + if (!dedupedNodes.has(mappedNode.nodeId)) { + dedupedNodes.set(mappedNode.nodeId, mappedNode); + } + } + const normalizedNodes = [...dedupedNodes.values()].sort( + (a, b) => a.nodeId - b.nodeId, + ); + if ( + this.fullSkeletonCacheGeneration === fetchVersion && + pendingFetch.promise !== undefined && + this.pendingFullSegmentNodeFetches.get(segmentId)?.promise === + pendingFetch.promise + ) { + this.replaceCachedSegmentNodes(segmentId, normalizedNodes); + this.markNodeDataChanged({ invalidateFullSkeletonCache: false }); + } + return normalizedNodes; + })().finally(() => { + if ( + this.pendingFullSegmentNodeFetches.get(segmentId)?.promise === + pendingFetch.promise + ) { + this.pendingFullSegmentNodeFetches.delete(segmentId); + } + }); + pendingFetch.promise = fetchPromise; + this.pendingFullSegmentNodeFetches.set(segmentId, { + promise: fetchPromise, + abortController, + }); + return fetchPromise; + } + + private clearFullSkeletonCache() { + this.fullSkeletonCacheGeneration++; + for (const segmentId of this.pendingFullSegmentNodeFetches.keys()) { + this.abortPendingFullSegmentNodeFetch( + segmentId, + "stale spatial skeleton full-segment inspection request", + ); + } + this.fullSegmentNodeCache.clear(); + this.cachedNodesById.clear(); + } +} diff --git a/src/sliceview/backend.ts b/src/sliceview/backend.ts index 8bf70acc23..19b0aef1ec 100644 --- a/src/sliceview/backend.ts +++ b/src/sliceview/backend.ts @@ -78,6 +78,9 @@ class SliceViewCounterpartBase extends SliceViewBase< SliceViewChunkSourceBackend, SliceViewRenderLayerBackend > { + protected invalidateVisibleSourcesBound = () => + this.invalidateVisibleSources(); + constructor(rpc: RPC, options: any) { super(rpc.get(options.projectionParameters)); this.initializeSharedObject(rpc, options.id); @@ -158,6 +161,7 @@ export class SliceViewBackend extends SliceViewIntermediateBase { ++i ) { const tsource = visibleSources[i]; + layer.prepareChunkSourceForRequest(tsource.source); const prefetchOffsets = chunkManager.queueManager.enablePrefetch.value ? getPrefetchChunkOffsets(this.velocityEstimator, tsource) : []; @@ -244,7 +248,10 @@ export class SliceViewBackend extends SliceViewIntermediateBase { const layerInfo = visibleLayers.get(layer)!; visibleLayers.delete(layer); disposeTransformedSources(layerInfo.allSources); - layer.renderScaleTarget.changed.remove(this.invalidateVisibleSources); + layer.renderScaleTarget.changed.remove(this.invalidateVisibleSourcesBound); + for (const watchable of layer.visibleSourcesInvalidation) { + watchable.changed.remove(this.invalidateVisibleSourcesBound); + } layer.localPosition.changed.remove(this.handleLayerChanged); this.invalidateVisibleSources(); } @@ -268,6 +275,9 @@ export class SliceViewBackend extends SliceViewIntermediateBase { layer.renderScaleTarget.changed.add(() => this.invalidateVisibleSources(), ); + for (const watchable of layer.visibleSourcesInvalidation) { + watchable.changed.add(this.invalidateVisibleSourcesBound); + } layer.localPosition.changed.add(this.handleLayerChanged); } else { disposeTransformedSources(layerInfo.allSources); @@ -413,6 +423,7 @@ export class SliceViewRenderLayerBackend { declare rpcId: number; renderScaleTarget: SharedWatchableValue; + visibleSourcesInvalidation: SharedWatchableValue[]; localPosition: WatchableValueInterface; numVisibleChunksNeeded: number; @@ -424,6 +435,9 @@ export class SliceViewRenderLayerBackend constructor(rpc: RPC, options: any) { super(rpc, options); this.renderScaleTarget = rpc.get(options.renderScaleTarget); + this.visibleSourcesInvalidation = ( + options.visibleSourcesInvalidation ?? [] + ).map((id: number) => rpc.get(id)); this.localPosition = rpc.get(options.localPosition); this.numVisibleChunksNeeded = 0; this.numVisibleChunksAvailable = 0; @@ -432,6 +446,10 @@ export class SliceViewRenderLayerBackend this.chunkManagerGeneration = -1; } + prepareChunkSourceForRequest(_source: SliceViewChunkSourceBackend) { + // Override in subclasses to set per-request source state (e.g. LOD). + } + filterVisibleSources( sliceView: SliceViewBase, sources: readonly TransformedSource[], diff --git a/src/sliceview/frontend.ts b/src/sliceview/frontend.ts index f8a6c2853a..dab95a070d 100644 --- a/src/sliceview/frontend.ts +++ b/src/sliceview/frontend.ts @@ -396,6 +396,11 @@ export class SliceView extends Base { this.invalidateVisibleSources(), ), ); + for (const watchable of renderLayer.visibleSourcesInvalidation) { + disposers.push( + watchable.changed.add(() => this.invalidateVisibleSources()), + ); + } const { renderScaleHistogram } = renderLayer; if (renderScaleHistogram !== undefined) { disposers.push(renderScaleHistogram.visibility.add(this.visibility)); diff --git a/src/sliceview/panel.ts b/src/sliceview/panel.ts index fe616f185d..1d8178a884 100644 --- a/src/sliceview/panel.ts +++ b/src/sliceview/panel.ts @@ -24,9 +24,11 @@ import type { RenderedDataViewerState, } from "#src/rendered_data_panel.js"; import { + getCenteredPickWindowCoordinate, getPickDiameter, getPickOffsetSequence, RenderedDataPanel, + resolveNearestPanelPickSample, } from "#src/rendered_data_panel.js"; import type { SliceView } from "#src/sliceview/frontend.js"; import { SliceViewRenderHelper } from "#src/sliceview/frontend.js"; @@ -469,22 +471,28 @@ export class SliceViewPanel extends RenderedDataPanel { ) { const { mouseState } = this.viewer; mouseState.pickedRenderLayer = null; - const pickDiameter = getPickDiameter(pickRadius); const pickOffsetSequence = getPickOffsetSequence(pickRadius); const { viewportWidth, viewportHeight } = pickingData; - const numOffsets = pickOffsetSequence.length; const { value: voxelCoordinates } = this.navigationState.position; const rank = voxelCoordinates.length; const displayDimensions = this.navigationState.pose.displayDimensions.value; const { displayRank, displayDimensionIndices } = displayDimensions; const setPosition = ( - xOffset: number, - yOffset: number, + relativeX: number, + relativeY: number, position: Float32Array, ) => { - const x = glWindowX + xOffset; - const y = glWindowY + yOffset; + const x = getCenteredPickWindowCoordinate( + glWindowX, + relativeX, + pickRadius, + ); + const y = getCenteredPickWindowCoordinate( + glWindowY, + relativeY, + pickRadius, + ); tempVec3[0] = (2.0 * x) / viewportWidth - 1.0; tempVec3[1] = (2.0 * y) / viewportHeight - 1.0; tempVec3[2] = 0; @@ -502,7 +510,7 @@ export class SliceViewPanel extends RenderedDataPanel { mouseState.coordinateSpace = this.navigationState.coordinateSpace.value; mouseState.displayDimensions = displayDimensions; - setPosition(0, 0, unsnappedPosition); + setPosition(pickRadius, pickRadius, unsnappedPosition); const setStateFromRelative = ( relativeX: number, @@ -513,21 +521,21 @@ export class SliceViewPanel extends RenderedDataPanel { if (mousePosition.length !== rank) { mousePosition = mouseState.position = new Float32Array(rank); } - setPosition( - relativeX - pickRadius, - relativeY - pickRadius, - mousePosition, - ); + setPosition(relativeX, relativeY, mousePosition); this.pickIDs.setMouseState(mouseState, pickId); mouseState.setActive(true); }; - for (let i = 0; i < numOffsets; ++i) { - const offset = pickOffsetSequence[i]; - const pickId = data[4 * i]; - if (pickId === 0) continue; - const relativeX = offset % pickDiameter; - const relativeY = (offset - relativeX) / pickDiameter; - setStateFromRelative(relativeX, relativeY, pickId); + const resolvedPick = resolveNearestPanelPickSample( + data, + pickOffsetSequence, + pickRadius, + ); + if (resolvedPick !== undefined) { + setStateFromRelative( + resolvedPick.relativeX, + resolvedPick.relativeY, + resolvedPick.pickValue, + ); return; } setStateFromRelative(pickRadius, pickRadius, 0); diff --git a/src/sliceview/renderlayer.ts b/src/sliceview/renderlayer.ts index 61c4b2aa57..19f86b9de5 100644 --- a/src/sliceview/renderlayer.ts +++ b/src/sliceview/renderlayer.ts @@ -68,6 +68,7 @@ export interface SliceViewRenderLayerOptions { */ localPosition: WatchableValueInterface; dataHistogramSpecifications?: HistogramSpecifications; + visibleSourcesInvalidation?: WatchableValueInterface[]; rpcTransfer?: { [index: string]: number | string | null }; } @@ -96,6 +97,7 @@ export abstract class SliceViewRenderLayer< transform: WatchableValueInterface; renderScaleTarget: WatchableValueInterface; + visibleSourcesInvalidation: WatchableValueInterface[]; renderScaleHistogram?: RenderScaleHistogram; // This is only used by `ImageRenderLayer` currently, but is defined here because @@ -183,6 +185,7 @@ export abstract class SliceViewRenderLayer< const { renderScaleTarget = trackableRenderScaleTarget(1) } = options; this.renderScaleTarget = renderScaleTarget; + this.visibleSourcesInvalidation = options.visibleSourcesInvalidation ?? []; this.renderScaleHistogram = options.renderScaleHistogram; this.transform = options.transform; this.localPosition = options.localPosition; @@ -217,6 +220,12 @@ export abstract class SliceViewRenderLayer< renderScaleTarget: this.registerDisposer( SharedWatchableValue.makeFromExisting(rpc, this.renderScaleTarget), ).rpcId, + visibleSourcesInvalidation: this.visibleSourcesInvalidation.map( + (watchable) => + this.registerDisposer( + SharedWatchableValue.makeFromExisting(rpc, watchable), + ).rpcId, + ), ...this.rpcTransfer, }); this.rpcId = sharedObject.rpcId; diff --git a/src/status.ts b/src/status.ts index 7204ad6601..4c1bc4890c 100644 --- a/src/status.ts +++ b/src/status.ts @@ -208,6 +208,13 @@ export class StatusMessage { return msg; } + static showErrorMessage(message: string): StatusMessage { + const msg = new StatusMessage(); + msg.setErrorMessage(message); + msg.setVisible(true); + return msg; + } + static showTemporaryMessage( message: string, closeAfter = 2000, diff --git a/src/ui/layer_bar.ts b/src/ui/layer_bar.ts index b4339954e8..06f90db35f 100644 --- a/src/ui/layer_bar.ts +++ b/src/ui/layer_bar.ts @@ -42,6 +42,40 @@ import { makeDeleteButton } from "#src/widget/delete_button.js"; import { makeIcon } from "#src/widget/icon.js"; import { PositionWidget } from "#src/widget/position_widget.js"; +function formatLayerHoverValue(value: unknown) { + if (value === undefined || value === null) { + return ""; + } + if (typeof value !== "object") { + return `${value}`; + } + const entry = value as { + key?: unknown; + value?: unknown; + label?: unknown; + }; + if (typeof entry.key !== "bigint") { + return `${value}`; + } + const keyString = entry.key.toString(); + const mappedString = + typeof entry.value === "bigint" ? entry.value.toString() : undefined; + const baseText = + mappedString === undefined ? keyString : `${keyString}→${mappedString}`; + if (typeof entry.label !== "string") { + return baseText; + } + const label = entry.label.trim(); + if ( + label.length === 0 || + label === keyString || + (mappedString !== undefined && label === mappedString) + ) { + return baseText; + } + return `${baseText} ${label}`; +} + class LayerWidget extends RefCounted { element = document.createElement("div"); layerNumberElement = document.createElement("div"); @@ -435,7 +469,7 @@ export class LayerBar extends RefCounted { if (state !== undefined) { const { value } = state; if (value !== undefined) { - text = "" + value; + text = formatLayerHoverValue(value); } } } diff --git a/src/ui/spatial_skeleton_edit_tab.spec.ts b/src/ui/spatial_skeleton_edit_tab.spec.ts new file mode 100644 index 0000000000..222eac71a5 --- /dev/null +++ b/src/ui/spatial_skeleton_edit_tab.spec.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from "vitest"; + +import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; +import { buildSpatiallyIndexedSkeletonNavigationGraph } from "#src/skeleton/navigation.js"; +import { SpatialSkeletonNodeFilterType } from "#src/skeleton/node_types.js"; +import { buildSpatialSkeletonSegmentRenderState } from "#src/ui/spatial_skeleton_edit_tab_render_state.js"; + +function makeNode( + nodeId: number, + parentNodeId: number | undefined, + options: { + description?: string; + isTrueEnd?: boolean; + } = {}, +): SpatiallyIndexedSkeletonNode { + return { + nodeId, + segmentId: 20380, + parentNodeId, + position: new Float32Array([nodeId, nodeId + 1, nodeId + 2]), + description: options.description, + isTrueEnd: options.isTrueEnd ?? false, + }; +} + +async function getBuildSpatialSkeletonVirtualListItems() { + const webglContextStub = new Proxy( + {}, + { + get: () => 0, + }, + ); + ( + globalThis as { WebGL2RenderingContext?: unknown } + ).WebGL2RenderingContext ??= webglContextStub; + return (await import("#src/ui/spatial_skeleton_edit_tab.js")) + .buildSpatialSkeletonVirtualListItems; +} + +describe("spatial skeleton edit tab render state", () => { + it("shows only directly matching nodes for text filtering", () => { + const graph = buildSpatiallyIndexedSkeletonNavigationGraph([ + makeNode(1, undefined), + makeNode(2, 1), + makeNode(3, 2), + makeNode(4, 2), + ]); + + const state = buildSpatialSkeletonSegmentRenderState(20380, graph, { + filterText: "target", + nodeFilterType: SpatialSkeletonNodeFilterType.NONE, + getNodeDescription(node) { + return node.nodeId === 4 ? "target" : undefined; + }, + }); + + expect(state.matchedNodeCount).toBe(1); + expect(state.displayedNodeCount).toBe(1); + expect(state.branchCount).toBe(1); + expect(state.rows.map((row) => row.node.nodeId)).toEqual([4]); + }); + + it("does not match coordinates, segment ids, or true-end state in the search filter", () => { + const graph = buildSpatiallyIndexedSkeletonNavigationGraph([ + makeNode(101, undefined, { isTrueEnd: true }), + makeNode(102, 101), + ]); + + const byCoordinates = buildSpatialSkeletonSegmentRenderState(20380, graph, { + filterText: "101 102 103", + nodeFilterType: SpatialSkeletonNodeFilterType.NONE, + getNodeDescription() { + return undefined; + }, + }); + const bySegmentId = buildSpatialSkeletonSegmentRenderState(20380, graph, { + filterText: "20380", + nodeFilterType: SpatialSkeletonNodeFilterType.NONE, + getNodeDescription() { + return undefined; + }, + }); + const byTrueEndText = buildSpatialSkeletonSegmentRenderState(20380, graph, { + filterText: "true end", + nodeFilterType: SpatialSkeletonNodeFilterType.NONE, + getNodeDescription() { + return undefined; + }, + }); + + expect(byCoordinates.matchedNodeCount).toBe(0); + expect(byCoordinates.displayedNodeCount).toBe(0); + expect(bySegmentId.matchedNodeCount).toBe(0); + expect(bySegmentId.displayedNodeCount).toBe(0); + expect(byTrueEndText.matchedNodeCount).toBe(0); + expect(byTrueEndText.displayedNodeCount).toBe(0); + }); + + it("counts hidden regular nodes in the ratio while omitting them from collapsed rows", () => { + const graph = buildSpatiallyIndexedSkeletonNavigationGraph([ + makeNode(10, undefined), + makeNode(11, 10), + makeNode(12, 11), + ]); + + const state = buildSpatialSkeletonSegmentRenderState(20380, graph, { + filterText: "", + nodeFilterType: SpatialSkeletonNodeFilterType.NONE, + getNodeDescription() { + return undefined; + }, + }); + + expect(state.matchedNodeCount).toBe(3); + expect(state.displayedNodeCount).toBe(2); + expect(state.branchCount).toBe(1); + expect(state.rows.map((row) => row.node.nodeId)).toEqual([10, 12]); + }); + + it("treats node-type-only matches as disconnected visible branches", () => { + const graph = buildSpatiallyIndexedSkeletonNavigationGraph([ + makeNode(20, undefined), + makeNode(21, 20), + makeNode(22, 20), + ]); + + const state = buildSpatialSkeletonSegmentRenderState(20380, graph, { + filterText: "", + nodeFilterType: SpatialSkeletonNodeFilterType.VIRTUAL_END, + getNodeDescription() { + return undefined; + }, + }); + + expect(state.matchedNodeCount).toBe(2); + expect(state.displayedNodeCount).toBe(2); + expect(state.branchCount).toBe(2); + expect(state.rows.map((row) => row.node.nodeId)).toEqual([21, 22]); + }); + + it("filters to nodes with non-empty descriptions", () => { + const graph = buildSpatiallyIndexedSkeletonNavigationGraph([ + makeNode(30, undefined), + makeNode(31, 30), + makeNode(32, 30), + makeNode(33, 30), + ]); + + const state = buildSpatialSkeletonSegmentRenderState(20380, graph, { + filterText: "", + nodeFilterType: SpatialSkeletonNodeFilterType.HAS_DESCRIPTION, + getNodeDescription(node) { + switch (node.nodeId) { + case 31: + return "has description"; + case 32: + return ""; + case 33: + return " "; + default: + return undefined; + } + }, + }); + + expect(state.matchedNodeCount).toBe(1); + expect(state.displayedNodeCount).toBe(1); + expect(state.branchCount).toBe(1); + expect(state.rows.map((row) => row.node.nodeId)).toEqual([31]); + }); +}); + +describe("spatial skeleton edit tab virtual list items", () => { + it("flattens one selected segment and its displayed node rows", async () => { + const buildSpatialSkeletonVirtualListItems = + await getBuildSpatialSkeletonVirtualListItems(); + const graph = buildSpatiallyIndexedSkeletonNavigationGraph([ + makeNode(1, undefined), + makeNode(2, 1), + makeNode(3, 2), + ]); + const segmentState = { + ...buildSpatialSkeletonSegmentRenderState(20380, graph, { + filterText: "", + nodeFilterType: SpatialSkeletonNodeFilterType.NONE, + getNodeDescription() { + return undefined; + }, + }), + segmentLabel: "selected segment", + }; + + const flattened = buildSpatialSkeletonVirtualListItems( + segmentState, + "empty", + ); + + expect(flattened.items.map((item) => item.kind)).toEqual([ + "segment", + "node", + "node", + ]); + expect( + flattened.items + .filter((item) => item.kind === "node") + .map((item) => item.row.node.nodeId), + ).toEqual([1, 3]); + expect(flattened.listIndexByNodeId.get(1)).toBe(1); + expect(flattened.listIndexByNodeId.get(3)).toBe(2); + }); + + it("returns one empty row when no selected segment rows are available", async () => { + const buildSpatialSkeletonVirtualListItems = + await getBuildSpatialSkeletonVirtualListItems(); + + const flattened = buildSpatialSkeletonVirtualListItems( + undefined, + "Select a skeleton segment to inspect editable nodes.", + ); + + expect(flattened.items).toEqual([ + { + kind: "empty", + text: "Select a skeleton segment to inspect editable nodes.", + }, + ]); + expect(flattened.listIndexByNodeId.size).toBe(0); + }); + + it("keeps more than 10,000 displayed rows in the virtual source items", async () => { + const buildSpatialSkeletonVirtualListItems = + await getBuildSpatialSkeletonVirtualListItems(); + const leafCount = 10001; + const nodes = [makeNode(1, undefined)]; + for (let i = 0; i < leafCount; ++i) { + nodes.push(makeNode(i + 2, 1)); + } + const graph = buildSpatiallyIndexedSkeletonNavigationGraph(nodes); + const segmentState = { + ...buildSpatialSkeletonSegmentRenderState(20380, graph, { + filterText: "", + nodeFilterType: SpatialSkeletonNodeFilterType.NONE, + getNodeDescription() { + return undefined; + }, + }), + segmentLabel: undefined, + }; + + const flattened = buildSpatialSkeletonVirtualListItems( + segmentState, + "empty", + ); + + expect(segmentState.displayedNodeCount).toBeGreaterThan(10_000); + expect(flattened.items.length).toBe(segmentState.displayedNodeCount + 1); + expect(flattened.listIndexByNodeId.get(leafCount + 1)).toBe(leafCount + 1); + }); +}); diff --git a/src/ui/spatial_skeleton_edit_tab.ts b/src/ui/spatial_skeleton_edit_tab.ts new file mode 100644 index 0000000000..81a1aa9447 --- /dev/null +++ b/src/ui/spatial_skeleton_edit_tab.ts @@ -0,0 +1,1730 @@ +/** + * @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 svg_arrow_left from "ikonate/icons/arrow-left.svg?raw"; +import svg_arrow_right from "ikonate/icons/arrow-right.svg?raw"; +import svg_bin from "ikonate/icons/bin.svg?raw"; +import svg_chevron_right from "ikonate/icons/chevron-right.svg?raw"; +import svg_chevrons_left from "ikonate/icons/chevrons-left.svg?raw"; +import svg_chevrons_right from "ikonate/icons/chevrons-right.svg?raw"; +import svg_circle from "ikonate/icons/circle.svg?raw"; +import svg_flag from "ikonate/icons/flag.svg?raw"; +import svg_minus from "ikonate/icons/minus.svg?raw"; +import svg_origin from "ikonate/icons/origin.svg?raw"; +import svg_redo from "ikonate/icons/redo.svg?raw"; +import svg_retweet from "ikonate/icons/retweet.svg?raw"; +import svg_share_android from "ikonate/icons/share-android.svg?raw"; +import svg_undo from "ikonate/icons/undo.svg?raw"; +import { getSegmentIdFromLayerSelectionValue } from "#src/layer/segmentation/selection.js"; +import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; +import { + executeSpatialSkeletonDeleteNode, + executeSpatialSkeletonNodeTrueEndUpdate, + redoSpatialSkeletonCommand, + undoSpatialSkeletonCommand, +} from "#src/layer/segmentation/spatial_skeleton_commands.js"; +import { showSpatialSkeletonActionError } from "#src/layer/segmentation/spatial_skeleton_errors.js"; +import { + getSegmentEquivalences, + getVisibleSegments, +} from "#src/segmentation_display_state/base.js"; +import { getBaseObjectColor } from "#src/segmentation_display_state/frontend.js"; +import { + SpatialSkeletonActions, + type SpatialSkeletonAction, +} from "#src/skeleton/actions.js"; +import type { + SpatiallyIndexedSkeletonNavigationTarget, + SpatiallyIndexedSkeletonNode, + SpatiallyIndexedSkeletonOpenLeaf, +} from "#src/skeleton/api.js"; +import { + buildSpatiallyIndexedSkeletonNavigationGraph, + getBranchEnd as getBranchEndFromGraph, + getBranchStart as getBranchStartFromGraph, + getRandomChildNode as getRandomChildNodeFromGraph, + getNextCollapsedLevelNode as getNextCollapsedLevelNodeFromGraph, + getOpenLeaves as getOpenLeavesFromGraph, + getParentNode as getParentNodeFromGraph, + getSkeletonRootNode as getSkeletonRootNodeFromGraph, + type SpatiallyIndexedSkeletonNavigationGraph, +} from "#src/skeleton/navigation.js"; +import { + getSpatialSkeletonNodeFilterLabel, + getSpatialSkeletonNodeIconFilterType, + SpatialSkeletonNodeFilterType, + type SpatialSkeletonDisplayNodeType as SkeletonNodeType, +} from "#src/skeleton/node_types.js"; +import { StatusMessage } from "#src/status.js"; +import { observeWatchable, registerNested } from "#src/trackable_value.js"; +import { + buildSpatialSkeletonSegmentRenderState, + type SpatialSkeletonSegmentRenderRow, + type SpatialSkeletonSegmentRenderState, +} from "#src/ui/spatial_skeleton_edit_tab_render_state.js"; +import { + SPATIAL_SKELETON_EDIT_MODE_TOOL_ID, + SPATIAL_SKELETON_MERGE_MODE_TOOL_ID, + SPATIAL_SKELETON_SPLIT_MODE_TOOL_ID, +} from "#src/ui/spatial_skeleton_edit_tool.js"; +import { makeToolButton } from "#src/ui/tool.js"; +import type { ArraySpliceOp } from "#src/util/array.js"; +import * as matrix from "#src/util/matrix.js"; +import { formatScaleWithUnitAsString } from "#src/util/si_units.js"; +import { Signal } from "#src/util/signal.js"; +import { EnumSelectWidget } from "#src/widget/enum_widget.js"; +import { makeIcon } from "#src/widget/icon.js"; +import { Tab } from "#src/widget/tab_view.js"; +import type { VirtualListSource } from "#src/widget/virtual_list.js"; +import { VirtualList } from "#src/widget/virtual_list.js"; + +export type SegmentDisplayState = SpatialSkeletonSegmentRenderState & { + segmentLabel: string | undefined; +}; + +export type SpatialSkeletonListItem = + | { kind: "segment"; segmentState: SegmentDisplayState } + | { kind: "node"; row: SpatialSkeletonSegmentRenderRow } + | { kind: "empty"; text: string }; + +export function buildSpatialSkeletonVirtualListItems( + segmentState: SegmentDisplayState | undefined, + emptyText: string, +) { + const items: SpatialSkeletonListItem[] = []; + const listIndexByNodeId = new Map(); + if (segmentState !== undefined && segmentState.displayedNodeCount > 0) { + items.push({ kind: "segment", segmentState }); + for (const row of segmentState.rows) { + listIndexByNodeId.set(row.node.nodeId, items.length); + items.push({ kind: "node", row }); + } + } else { + items.push({ kind: "empty", text: emptyText }); + } + return { items, listIndexByNodeId }; +} + +interface SpatiallyIndexedSkeletonNavigationApi { + getSkeletonRootNode( + skeletonId: number, + ): Promise; + getBranchStart( + nodeId: number, + ): Promise; + getBranchEnd( + nodeId: number, + ): Promise; + getNextCollapsedLevelNode( + nodeId: number, + ): Promise; + getOpenLeaves( + skeletonId: number, + nodeId: number, + ): Promise; + getParentNode( + nodeId: number, + ): Promise; + getChildNode( + nodeId: number, + ): Promise; +} + +const NODE_TYPE_ICONS: Record = { + root: svg_origin, + branchStart: svg_share_android, + regular: svg_minus, + virtualEnd: svg_circle, +}; + +const NODE_TYPE_LABELS: Record = { + root: "root", + branchStart: "branch start", + regular: "regular", + virtualEnd: "virtual end", +}; + +export class SpatialSkeletonEditTab extends Tab { + constructor(public layer: SegmentationUserLayer) { + super(); + const { element } = this; + element.classList.add("neuroglancer-spatial-skeleton-tab"); + + const toolbox = document.createElement("div"); + toolbox.className = + "neuroglancer-segmentation-toolbox neuroglancer-spatial-skeleton-toolbar"; + toolbox.appendChild( + makeToolButton(this, layer.toolBinder, { + toolJson: SPATIAL_SKELETON_EDIT_MODE_TOOL_ID, + label: "Edit", + title: "Toggle skeleton node edit mode", + }), + ); + toolbox.appendChild( + makeToolButton(this, layer.toolBinder, { + toolJson: SPATIAL_SKELETON_MERGE_MODE_TOOL_ID, + label: "Merge", + title: "Toggle skeleton merge mode", + }), + ); + toolbox.appendChild( + makeToolButton(this, layer.toolBinder, { + toolJson: SPATIAL_SKELETON_SPLIT_MODE_TOOL_ID, + label: "Split", + title: "Toggle skeleton split mode", + }), + ); + const toolbarActions = document.createElement("div"); + toolbarActions.className = "neuroglancer-spatial-skeleton-toolbar-actions"; + + const makeIconButton = ( + parent: HTMLElement, + svg: string, + title: string, + onClick: () => void, + ) => { + const button = document.createElement("button"); + button.className = "neuroglancer-spatial-skeleton-icon-button"; + button.type = "button"; + button.title = title; + button.setAttribute("aria-label", title); + button.appendChild(makeIcon({ svg, title, clickable: false })); + button.addEventListener("click", () => onClick()); + parent.appendChild(button); + return button; + }; + const undoButton = makeIconButton(toolbarActions, svg_undo, "Undo", () => { + if (undoButton.disabled) return; + void (async () => { + try { + await undoSpatialSkeletonCommand(layer); + } catch (error) { + showSpatialSkeletonActionError("undo", error); + } + })(); + }); + const redoButton = makeIconButton(toolbarActions, svg_redo, "Redo", () => { + if (redoButton.disabled) return; + void (async () => { + try { + await redoSpatialSkeletonCommand(layer); + } catch (error) { + showSpatialSkeletonActionError("redo", error); + } + })(); + }); + toolbox.appendChild(toolbarActions); + + const navTools = document.createElement("div"); + navTools.className = "neuroglancer-spatial-skeleton-nav-tools"; + + const nodesSection = document.createElement("div"); + nodesSection.className = "neuroglancer-spatial-skeleton-section"; + const filterInput = document.createElement("input"); + filterInput.type = "text"; + filterInput.placeholder = "Enter node ID or description"; + filterInput.className = "neuroglancer-spatial-skeleton-filter"; + const nodeQuery = layer.displayState.spatialSkeletonNodeQuery; + const nodeFilterTypeModel = layer.displayState.spatialSkeletonNodeFilter; + filterInput.value = nodeQuery.value; + const nodeFilterTypeWidget = this.registerDisposer( + new EnumSelectWidget(nodeFilterTypeModel), + ); + nodeFilterTypeWidget.element.classList.add( + "neuroglancer-layer-control-control", + "neuroglancer-spatial-skeleton-filter-select", + ); + nodeFilterTypeWidget.element.title = "Filter loaded nodes by node type"; + nodeFilterTypeWidget.element.setAttribute( + "aria-label", + nodeFilterTypeWidget.element.title, + ); + for (const option of nodeFilterTypeWidget.element.options) { + option.textContent = getSpatialSkeletonNodeFilterLabel( + nodeFilterTypeModel.enumType[ + option.value.toUpperCase() + ] as SpatialSkeletonNodeFilterType, + ); + } + const nodeFilterTypeRow = document.createElement("label"); + nodeFilterTypeRow.className = "neuroglancer-spatial-skeleton-filter-row"; + const nodeFilterTypeLabel = document.createElement("span"); + nodeFilterTypeLabel.className = + "neuroglancer-spatial-skeleton-filter-label"; + nodeFilterTypeLabel.textContent = "Filter"; + nodeFilterTypeRow.appendChild(nodeFilterTypeLabel); + nodeFilterTypeRow.appendChild(nodeFilterTypeWidget.element); + const nodesNavigationBar = document.createElement("div"); + nodesNavigationBar.className = + "neuroglancer-spatial-skeleton-navigation-bar"; + const nodesSummaryBar = document.createElement("div"); + nodesSummaryBar.className = "neuroglancer-spatial-skeleton-summary-bar"; + const nodesSummary = document.createElement("div"); + nodesSummary.className = "neuroglancer-spatial-skeleton-summary"; + let virtualItems: SpatialSkeletonListItem[] = []; + let renderVirtualListItem = ( + _item: SpatialSkeletonListItem | undefined, + ): HTMLElement => document.createElement("div"); + const virtualListChanged = new Signal<(splices: ArraySpliceOp[]) => void>(); + const virtualListRenderChanged = new Signal(); + const virtualListSource: VirtualListSource = { + length: 0, + render: (index) => renderVirtualListItem(virtualItems[index]), + changed: virtualListChanged, + renderChanged: virtualListRenderChanged, + }; + const nodesList = this.registerDisposer( + new VirtualList({ source: virtualListSource }), + ); + nodesList.element.className = "neuroglancer-spatial-skeleton-tree"; + nodesSection.appendChild(filterInput); + nodesSection.appendChild(nodeFilterTypeRow); + nodesNavigationBar.appendChild(navTools); + nodesSection.appendChild(nodesNavigationBar); + nodesSummaryBar.appendChild(nodesSummary); + nodesSection.appendChild(nodesSummaryBar); + nodesSection.appendChild(nodesList.element); + element.appendChild(nodesSection); + + let allNodes: SpatiallyIndexedSkeletonNode[] = []; + let activeSegmentId: number | undefined; + let nodesBySegment = new Map(); + let inspectionAllowed = false; + let navigationAllowed = false; + let trueEndEditingAllowed = false; + let nodeDeletionAllowed = false; + let nodeRerootAllowed = false; + let pendingScrollToSelectedNode = false; + let loadedNodeSummarySuffix = ""; + let hoveredViewerNodeId: number | undefined; + let hoveredListNodeId: number | undefined; + const pendingDeleteNodes = new Set(); + const pendingRerootNodes = new Set(); + const pendingTrueEndNodes = new Set(); + const listIndexByNodeId = new Map(); + const skeletonState = layer.spatialSkeletonState; + const navigationGraphCache = new Map< + number, + { + nodes: readonly SpatiallyIndexedSkeletonNode[]; + graph: SpatiallyIndexedSkeletonNavigationGraph; + } + >(); + const segmentColorScratch = new Float32Array(4); + + const getSkeletonTransform = () => { + const transform = + layer.getSpatiallyIndexedSkeletonLayer()?.displayState.transform.value; + return transform !== undefined && transform.error === undefined + ? transform + : undefined; + }; + + const getCoordinateDimensionHeaders = (): string[] => { + const transform = getSkeletonTransform(); + if (transform === undefined) return ["x", "y", "z"]; + const globalCoordSpace = layer.manager.root.coordinateSpace.value; + const localCoordSpace = layer.localCoordinateSpace.value; + return transform.layerDimensionNames.map((name, renderDim) => { + for ( + let g = 0; + g < transform.globalToRenderLayerDimensions.length; + g++ + ) { + if (transform.globalToRenderLayerDimensions[g] === renderDim) { + return `${name} (${formatScaleWithUnitAsString(globalCoordSpace.scales[g], globalCoordSpace.units[g], { precision: 2 })})`; + } + } + for ( + let l = 0; + l < transform.localToRenderLayerDimensions.length; + l++ + ) { + if (transform.localToRenderLayerDimensions[l] === renderDim) { + return `${name} (${formatScaleWithUnitAsString(localCoordSpace.scales[l], localCoordSpace.units[l], { precision: 2 })})`; + } + } + return name; + }); + }; + + const formatNodeCoordinates = (position: ArrayLike): string[] => { + const transform = getSkeletonTransform(); + if (transform !== undefined) { + const rank = transform.rank; + const modelPos = new Float32Array(rank); + for (let i = 0; i < Math.min(position.length, rank); i++) { + modelPos[i] = Number(position[i]); + } + const layerPos = new Float32Array(rank); + matrix.transformPoint( + layerPos, + transform.modelToRenderLayerTransform, + rank + 1, + modelPos, + rank, + ); + return Array.from({ length: rank }, (_, i) => + String(Math.round(layerPos[i])), + ); + } + return [0, 1, 2].map((i) => String(Math.round(Number(position[i])))); + }; + + const getSelectedNode = () => { + const selectedId = layer.selectedSpatialSkeletonNodeId.value; + if (selectedId === undefined) return undefined; + return allNodes.find((node) => node.nodeId === selectedId); + }; + + const getFilterText = () => nodeQuery.value.trim().toLowerCase(); + + const ensureActionsAllowed = ( + requiredActions: SpatialSkeletonAction | readonly SpatialSkeletonAction[], + options: { + requireVisibleChunks?: boolean; + } = {}, + ) => { + const reason = layer.getSpatialSkeletonActionsDisabledReason( + requiredActions, + options, + ); + if (reason !== undefined) { + StatusMessage.showTemporaryMessage(reason); + return false; + } + return true; + }; + + const selectNode = ( + node: SpatiallyIndexedSkeletonNode | undefined, + options: { + moveView?: boolean; + pin?: boolean; + } = {}, + ) => { + if (node === undefined) return; + const moveView = options.moveView ?? false; + const pin = options.pin ?? false; + pendingScrollToSelectedNode = true; + layer.selectSpatialSkeletonNode(node.nodeId, pin, { + segmentId: node.segmentId, + position: node.position, + }); + if (moveView) { + moveViewToNodePosition(node.position); + } + applyRowInteractionState({ scrollSelectedIntoView: true }); + }; + + const moveViewToNodePosition = (position: ArrayLike) => { + layer.moveViewToSpatialSkeletonNodePosition(position); + }; + + const getNavigationNode = (nodeId: number) => { + return skeletonState.getCachedNode(nodeId); + }; + + const getSegmentNavigationNodes = (segmentId: number) => { + return ( + nodesBySegment.get(segmentId) ?? + skeletonState.getCachedSegmentNodes(segmentId) + ); + }; + + const getSegmentNavigationGraph = (segmentId: number) => { + const segmentNodes = getSegmentNavigationNodes(segmentId); + if (segmentNodes === undefined || segmentNodes.length === 0) { + throw new Error( + `Skeleton graph for segment ${segmentId} is not loaded yet.`, + ); + } + const cached = navigationGraphCache.get(segmentId); + if (cached !== undefined && cached.nodes === segmentNodes) { + return cached.graph; + } + const graph = buildSpatiallyIndexedSkeletonNavigationGraph(segmentNodes); + navigationGraphCache.set(segmentId, { + nodes: segmentNodes, + graph, + }); + return graph; + }; + + const getSegmentChipColors = (segmentId: number) => { + const color = getBaseObjectColor( + layer.displayState, + BigInt(segmentId), + segmentColorScratch, + ); + const r = Math.round(color[0] * 255); + const g = Math.round(color[1] * 255); + const b = Math.round(color[2] * 255); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return { + background: `rgb(${r}, ${g}, ${b})`, + foreground: luminance > 0.6 ? "#101010" : "#f5f5f5", + }; + }; + + const bindSegmentSelectionControls = ( + element: HTMLElement, + segmentId: number, + ) => { + const id = BigInt(segmentId); + const hasSegmentSelectionModifiers = (event: MouseEvent) => + event.ctrlKey && !event.altKey && !event.metaKey; + element.addEventListener("mousedown", (event: MouseEvent) => { + if (event.button !== 2 || !hasSegmentSelectionModifiers(event)) { + return; + } + layer.selectSegment(id, event.shiftKey ? "force-unpin" : true); + event.preventDefault(); + event.stopPropagation(); + }); + element.addEventListener("contextmenu", (event: MouseEvent) => { + if (!hasSegmentSelectionModifiers(event)) return; + if (event.button !== 2) { + layer.selectSegment(id, event.shiftKey ? "force-unpin" : true); + } + event.preventDefault(); + event.stopPropagation(); + }); + }; + + const getSegmentSelectionTitle = (segmentId: number) => + `segment ${segmentId}\n` + + "Ctrl+right-click to pin selection\n" + + "Ctrl+shift+right-click to unpin"; + + const getNodeDescriptionText = (node: SpatiallyIndexedSkeletonNode) => + layer.getSpatialSkeletonNodeDisplayDescription(node); + + const getHoveredNodeIdFromViewer = () => { + return layer.hoveredSpatialSkeletonNodeId.value; + }; + + const getSelectedSegmentId = () => { + const layerSelectionState = + layer.manager.root.selectionState.value?.layers.find( + (entry) => entry.layer === layer, + )?.state; + return getSegmentIdFromLayerSelectionValue(layerSelectionState); + }; + + const addVisibleSegmentIds = (segmentIds: Set) => { + const visibleSegments = getVisibleSegments( + layer.displayState.segmentationGroupState.value, + ); + for (const segmentId of visibleSegments.keys()) { + const normalizedSegmentId = Number(segmentId); + if ( + Number.isSafeInteger(normalizedSegmentId) && + normalizedSegmentId > 0 + ) { + segmentIds.add(normalizedSegmentId); + } + } + }; + + const scrollListItemIntoView = (index: number) => { + if (nodesList.getItemElement(index) !== undefined) { + nodesList.scrollItemIntoView(index); + return; + } + nodesList.state.anchorIndex = index; + nodesList.state.anchorClientOffset = 0; + virtualListRenderChanged.dispatch(); + }; + + const applyRowInteractionState = ( + options: { scrollSelectedIntoView?: boolean } = {}, + ) => { + const selectedNodeId = layer.selectedSpatialSkeletonNodeId.value; + nodesList.forEachRenderedItem((entry, index) => { + const item = virtualItems[index]; + if (item?.kind !== "node") return; + const { nodeId } = item.row.node; + const isSelected = nodeId === selectedNodeId; + const isHovered = nodeId === hoveredViewerNodeId; + const isListHovered = nodeId === hoveredListNodeId; + entry.dataset.selected = String(isSelected); + entry.dataset.viewerHovered = String(isHovered); + entry.dataset.listHovered = String(isListHovered); + }); + if (options.scrollSelectedIntoView) { + pendingScrollToSelectedNode = false; + const selectedIndex = + selectedNodeId === undefined + ? undefined + : listIndexByNodeId.get(selectedNodeId); + if (selectedIndex !== undefined) { + scrollListItemIntoView(selectedIndex); + } + } + }; + + const updateHoveredViewerNode = () => { + const nextHoveredNodeId = getHoveredNodeIdFromViewer(); + if (hoveredViewerNodeId === nextHoveredNodeId) return; + hoveredViewerNodeId = nextHoveredNodeId; + applyRowInteractionState(); + }; + + const updateHoveredListNode = (nextHoveredNodeId: number | undefined) => { + if (hoveredListNodeId === nextHoveredNodeId) return; + hoveredListNodeId = nextHoveredNodeId; + applyRowInteractionState(); + }; + + const skeletonNavigationApi: SpatiallyIndexedSkeletonNavigationApi = { + async getSkeletonRootNode(skeletonId: number) { + return getSkeletonRootNodeFromGraph( + getSegmentNavigationGraph(skeletonId), + ); + }, + async getBranchStart(nodeId: number) { + const node = getNavigationNode(nodeId); + if (node === undefined) { + throw new Error( + `Node ${nodeId} is not available in the loaded skeleton cache.`, + ); + } + return getBranchStartFromGraph( + getSegmentNavigationGraph(node.segmentId), + nodeId, + ); + }, + async getBranchEnd(nodeId: number) { + const node = getNavigationNode(nodeId); + if (node === undefined) { + throw new Error( + `Node ${nodeId} is not available in the loaded skeleton cache.`, + ); + } + return getBranchEndFromGraph( + getSegmentNavigationGraph(node.segmentId), + nodeId, + ); + }, + async getNextCollapsedLevelNode(nodeId: number) { + const node = getNavigationNode(nodeId); + if (node === undefined) { + throw new Error( + `Node ${nodeId} is not available in the loaded skeleton cache.`, + ); + } + return getNextCollapsedLevelNodeFromGraph( + getSegmentNavigationGraph(node.segmentId), + nodeId, + ); + }, + async getOpenLeaves(skeletonId: number, nodeId: number) { + return getOpenLeavesFromGraph( + getSegmentNavigationGraph(skeletonId), + nodeId, + ); + }, + async getParentNode(nodeId: number) { + const node = getNavigationNode(nodeId); + if (node === undefined) { + throw new Error( + `Node ${nodeId} is not available in the loaded skeleton cache.`, + ); + } + return getParentNodeFromGraph( + getSegmentNavigationGraph(node.segmentId), + nodeId, + ); + }, + async getChildNode(nodeId: number) { + const node = getNavigationNode(nodeId); + if (node === undefined) { + throw new Error( + `Node ${nodeId} is not available in the loaded skeleton cache.`, + ); + } + return getRandomChildNodeFromGraph( + getSegmentNavigationGraph(node.segmentId), + nodeId, + ); + }, + }; + + const navigateToNodeTarget = (target: { + nodeId: number; + x: number; + y: number; + z: number; + }) => { + const existingNode = allNodes.find( + (node) => node.nodeId === target.nodeId, + ); + if (existingNode !== undefined) { + selectNode(existingNode, { moveView: true, pin: true }); + return; + } + pendingScrollToSelectedNode = true; + const position = [target.x, target.y, target.z]; + layer.selectSpatialSkeletonNode(target.nodeId, true, { position }); + moveViewToNodePosition(position); + updateDisplay(); + }; + + const getSelectedNavigationContext = () => { + if ( + !ensureActionsAllowed(SpatialSkeletonActions.inspect, { + requireVisibleChunks: false, + }) + ) { + return undefined; + } + const selectedNode = getSelectedNode(); + if (selectedNode === undefined) { + StatusMessage.showTemporaryMessage("No skeleton node is selected."); + return undefined; + } + try { + getSegmentNavigationGraph(selectedNode.segmentId); + return { selectedNode, skeletonApi: skeletonNavigationApi }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + StatusMessage.showTemporaryMessage( + `Unable to resolve the local skeleton graph for navigation: ${message}`, + ); + return undefined; + } + }; + + const updateTrueEndLabel = ( + node: SpatiallyIndexedSkeletonNode, + present: boolean, + ) => { + if (!ensureActionsAllowed(SpatialSkeletonActions.editNodeTrueEnd)) return; + if (pendingTrueEndNodes.has(node.nodeId)) return; + pendingTrueEndNodes.add(node.nodeId); + updateDisplay(); + void (async () => { + try { + const currentNode = skeletonState.getCachedNode(node.nodeId); + if (currentNode === undefined) { + throw new Error( + `Node ${node.nodeId} is missing from the inspected skeleton cache.`, + ); + } + await executeSpatialSkeletonNodeTrueEndUpdate(layer, { + node: currentNode, + nextIsTrueEnd: present, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + StatusMessage.showTemporaryMessage( + `Failed to update true end state: ${message}`, + ); + } finally { + pendingTrueEndNodes.delete(node.nodeId); + updateDisplay(); + } + })(); + }; + + const goToClosestUnfinishedBranch = () => { + const context = getSelectedNavigationContext(); + if (context === undefined) return; + const { selectedNode, skeletonApi } = context; + void (async () => { + try { + const openLeaves = await skeletonApi.getOpenLeaves( + selectedNode.segmentId, + selectedNode.nodeId, + ); + if (openLeaves.length === 0) { + StatusMessage.showTemporaryMessage( + "No unfinished branch was found in the current skeleton.", + ); + return; + } + openLeaves.sort((a, b) => + a.distance === b.distance + ? a.nodeId - b.nodeId + : a.distance - b.distance, + ); + navigateToNodeTarget(openLeaves[0]); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + StatusMessage.showTemporaryMessage( + `Failed to locate unfinished branch: ${message}`, + ); + } + })(); + }; + + const deleteNode = (node: SpatiallyIndexedSkeletonNode) => { + if (!ensureActionsAllowed(SpatialSkeletonActions.deleteNodes)) return; + if (pendingDeleteNodes.has(node.nodeId)) { + return; + } + const segmentNodes = nodesBySegment.get(node.segmentId) ?? []; + const hasChildren = segmentNodes.some( + (candidate) => candidate.parentNodeId === node.nodeId, + ); + if (node.parentNodeId === undefined && hasChildren) { + StatusMessage.showTemporaryMessage( + "Reroot the skeleton manually before deleting the current root node.", + ); + return; + } + pendingDeleteNodes.add(node.nodeId); + updateDisplay(); + void (async () => { + try { + await executeSpatialSkeletonDeleteNode(layer, node); + refreshNodes(); + } catch (error) { + showSpatialSkeletonActionError("delete node", error); + updateDisplay(); + } finally { + pendingDeleteNodes.delete(node.nodeId); + updateDisplay(); + } + })(); + }; + + const rerootNode = (node: SpatiallyIndexedSkeletonNode) => { + if ( + !ensureActionsAllowed(SpatialSkeletonActions.reroot, { + requireVisibleChunks: false, + }) + ) { + return; + } + if (node.parentNodeId === undefined) { + StatusMessage.showTemporaryMessage("Selected node is already root."); + return; + } + if (pendingRerootNodes.has(node.nodeId)) { + return; + } + pendingRerootNodes.add(node.nodeId); + updateDisplay(); + void (async () => { + try { + await layer.rerootSpatialSkeletonNode(node); + } catch (error) { + showSpatialSkeletonActionError("set node as root", error); + } finally { + pendingRerootNodes.delete(node.nodeId); + updateDisplay(); + } + })(); + }; + + const goRootButton = makeIconButton( + navTools, + svg_origin, + "Go to root", + () => { + const context = getSelectedNavigationContext(); + if (context === undefined) return; + const { selectedNode, skeletonApi } = context; + void (async () => { + try { + navigateToNodeTarget( + await skeletonApi.getSkeletonRootNode(selectedNode.segmentId), + ); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + StatusMessage.showTemporaryMessage( + `Failed to locate skeleton root: ${message}`, + ); + } + })(); + }, + ); + const goBranchStartButton = makeIconButton( + navTools, + svg_chevrons_left, + "Go to start of the branch", + () => { + const context = getSelectedNavigationContext(); + if (context === undefined) return; + const { selectedNode, skeletonApi } = context; + void (async () => { + try { + navigateToNodeTarget( + await skeletonApi.getBranchStart(selectedNode.nodeId), + ); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + StatusMessage.showTemporaryMessage( + `Failed to locate branch start: ${message}`, + ); + } + })(); + }, + ); + const goTreeEndButton = makeIconButton( + navTools, + svg_chevrons_right, + "Go to end of the branch", + () => { + const context = getSelectedNavigationContext(); + if (context === undefined) return; + const { selectedNode, skeletonApi } = context; + void (async () => { + try { + navigateToNodeTarget( + await skeletonApi.getBranchEnd(selectedNode.nodeId), + ); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + StatusMessage.showTemporaryMessage( + `Failed to locate branch end: ${message}`, + ); + } + })(); + }, + ); + const cycleBranchesButton = makeIconButton( + navTools, + svg_retweet, + "Cycle through level nodes", + () => { + const context = getSelectedNavigationContext(); + if (context === undefined) return; + const { selectedNode, skeletonApi } = context; + void (async () => { + try { + navigateToNodeTarget( + await skeletonApi.getNextCollapsedLevelNode(selectedNode.nodeId), + ); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + StatusMessage.showTemporaryMessage( + `Failed to cycle through level nodes: ${message}`, + ); + } + })(); + }, + ); + const goParentButton = makeIconButton( + navTools, + svg_arrow_left, + "Go to parent", + () => { + const context = getSelectedNavigationContext(); + if (context === undefined) return; + const { selectedNode, skeletonApi } = context; + void (async () => { + try { + const target = await skeletonApi.getParentNode(selectedNode.nodeId); + if (target === undefined) { + StatusMessage.showTemporaryMessage( + "Selected node has no parent.", + ); + return; + } + navigateToNodeTarget(target); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + StatusMessage.showTemporaryMessage( + `Failed to locate parent node: ${message}`, + ); + } + })(); + }, + ); + const goChildButton = makeIconButton( + navTools, + svg_arrow_right, + "Go to child", + () => { + const context = getSelectedNavigationContext(); + if (context === undefined) return; + const { selectedNode, skeletonApi } = context; + void (async () => { + try { + const target = await skeletonApi.getChildNode(selectedNode.nodeId); + if (target === undefined) { + StatusMessage.showTemporaryMessage("Selected node has no child."); + return; + } + navigateToNodeTarget(target); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + StatusMessage.showTemporaryMessage( + `Failed to locate child node: ${message}`, + ); + } + })(); + }, + ); + const goUnfinishedBranchButton = makeIconButton( + navTools, + svg_chevron_right, + "Go to unfinished node", + () => { + goToClosestUnfinishedBranch(); + }, + ); + element.insertBefore(toolbox, nodesSection); + + const gatedControls = [ + goRootButton, + goBranchStartButton, + goTreeEndButton, + cycleBranchesButton, + goParentButton, + goChildButton, + goUnfinishedBranchButton, + ]; + + const makeRowActionButton = ( + svg: string, + title: string, + onClick: () => void, + disabled: boolean, + ) => { + const button = document.createElement("button"); + button.className = "neuroglancer-spatial-skeleton-node-action"; + button.type = "button"; + button.title = title; + button.disabled = disabled; + button.appendChild(makeIcon({ svg, title, clickable: false })); + button.addEventListener("click", (event: MouseEvent) => { + event.stopPropagation(); + onClick(); + }); + return button; + }; + + const getSegmentDisplayLabel = (segmentId: number) => { + const segmentationGroupState = + layer.displayState.segmentationGroupState.value; + const segmentPropertyMap = + segmentationGroupState.segmentPropertyMap.value; + if (segmentPropertyMap === undefined) { + return undefined; + } + const mappedSegmentId = getSegmentEquivalences( + segmentationGroupState, + ).get(BigInt(segmentId)); + return segmentPropertyMap.getSegmentLabel(mappedSegmentId); + }; + + const buildSegmentDisplayState = (): SegmentDisplayState | undefined => { + const segmentId = activeSegmentId; + if (segmentId === undefined) return undefined; + const segmentNodes = nodesBySegment.get(segmentId) ?? []; + const renderState = + segmentNodes.length === 0 + ? { + segmentId, + totalNodeCount: 0, + matchedNodeCount: 0, + displayedNodeCount: 0, + branchCount: 0, + rows: [], + } + : buildSpatialSkeletonSegmentRenderState( + segmentId, + getSegmentNavigationGraph(segmentId), + { + filterText: getFilterText(), + nodeFilterType: nodeFilterTypeModel.value, + getNodeDescription: getNodeDescriptionText, + }, + ); + return { + ...renderState, + segmentLabel: getSegmentDisplayLabel(segmentId), + }; + }; + + const makeListHeader = () => { + const listHeader = document.createElement("div"); + listHeader.className = "neuroglancer-spatial-skeleton-list-header"; + const headerActionsSpacer = document.createElement("span"); + headerActionsSpacer.className = + "neuroglancer-spatial-skeleton-list-header-spacer neuroglancer-spatial-skeleton-list-header-actions"; + const headerTypeSpacer = document.createElement("span"); + headerTypeSpacer.className = + "neuroglancer-spatial-skeleton-list-header-spacer neuroglancer-spatial-skeleton-list-header-type"; + const headerId = document.createElement("span"); + headerId.className = "neuroglancer-spatial-skeleton-list-header-cell"; + headerId.textContent = "id"; + const headerCoordinates = document.createElement("span"); + headerCoordinates.className = + "neuroglancer-spatial-skeleton-list-header-cell neuroglancer-spatial-skeleton-coordinates-flex"; + for (const dimLabel of getCoordinateDimensionHeaders()) { + const dimSpan = document.createElement("span"); + dimSpan.className = "neuroglancer-spatial-skeleton-coord-dim"; + dimSpan.textContent = dimLabel; + headerCoordinates.appendChild(dimSpan); + } + listHeader.appendChild(headerActionsSpacer); + listHeader.appendChild(headerTypeSpacer); + listHeader.appendChild(headerId); + listHeader.appendChild(headerCoordinates); + return listHeader; + }; + + const updateListHeader = (show: boolean) => { + nodesList.header.textContent = ""; + if (show) { + nodesList.header.appendChild(makeListHeader()); + } + }; + + const makeSegmentEntry = (segmentState: SegmentDisplayState) => { + const segmentEntry = document.createElement("div"); + segmentEntry.className = + "neuroglancer-spatial-skeleton-tree-entry neuroglancer-spatial-skeleton-segment-entry"; + const segmentRow = document.createElement("div"); + segmentRow.className = + "neuroglancer-spatial-skeleton-tree-row neuroglancer-spatial-skeleton-segment-row"; + const segmentActionsSpacer = document.createElement("span"); + segmentActionsSpacer.className = + "neuroglancer-spatial-skeleton-list-header-spacer neuroglancer-spatial-skeleton-list-header-actions"; + const segmentTypeSpacer = document.createElement("span"); + segmentTypeSpacer.className = + "neuroglancer-spatial-skeleton-list-header-spacer neuroglancer-spatial-skeleton-list-header-type"; + const segmentIdCell = document.createElement("span"); + segmentIdCell.className = "neuroglancer-spatial-skeleton-node-id"; + const segmentChip = document.createElement("span"); + segmentChip.className = "neuroglancer-spatial-skeleton-node-segment-chip"; + const segmentChipColors = getSegmentChipColors(segmentState.segmentId); + segmentChip.textContent = String(segmentState.segmentId); + segmentChip.style.backgroundColor = segmentChipColors.background; + segmentChip.style.color = segmentChipColors.foreground; + segmentChip.title = getSegmentSelectionTitle(segmentState.segmentId); + bindSegmentSelectionControls(segmentChip, segmentState.segmentId); + segmentIdCell.appendChild(segmentChip); + const segmentMeta = document.createElement("div"); + segmentMeta.className = + "neuroglancer-spatial-skeleton-node-coordinate-cell neuroglancer-spatial-skeleton-segment-meta"; + const segmentMetaLine = document.createElement("div"); + segmentMetaLine.className = + "neuroglancer-spatial-skeleton-segment-meta-line"; + const segmentName = document.createElement("span"); + segmentName.className = "neuroglancer-spatial-skeleton-segment-name"; + segmentName.textContent = segmentState.segmentLabel ?? ""; + const segmentRatio = document.createElement("span"); + segmentRatio.className = "neuroglancer-spatial-skeleton-segment-ratio"; + segmentRatio.textContent = `${segmentState.displayedNodeCount}/${segmentState.totalNodeCount}`; + segmentMetaLine.appendChild(segmentName); + segmentMetaLine.appendChild(segmentRatio); + segmentMeta.appendChild(segmentMetaLine); + segmentRow.appendChild(segmentActionsSpacer); + segmentRow.appendChild(segmentTypeSpacer); + segmentRow.appendChild(segmentIdCell); + segmentRow.appendChild(segmentMeta); + segmentEntry.appendChild(segmentRow); + return segmentEntry; + }; + + const makeNodeEntry = (rowInfo: SpatialSkeletonSegmentRenderRow) => { + const { node, type, isLeaf } = rowInfo; + const entry = document.createElement("div"); + entry.className = "neuroglancer-spatial-skeleton-tree-entry"; + entry.dataset.selected = String( + node.nodeId === layer.selectedSpatialSkeletonNodeId.value, + ); + entry.dataset.viewerHovered = String(node.nodeId === hoveredViewerNodeId); + entry.dataset.listHovered = String(node.nodeId === hoveredListNodeId); + entry.addEventListener("mouseenter", () => { + updateHoveredListNode(node.nodeId); + }); + entry.addEventListener("mouseleave", () => { + updateHoveredListNode(undefined); + }); + + const row = document.createElement("div"); + row.className = "neuroglancer-spatial-skeleton-tree-row"; + row.dataset.nodeType = type; + if (inspectionAllowed) { + row.tabIndex = 0; + row.setAttribute("role", "button"); + row.title = + "Click to move to node and pin selection. Right-click to move to node. Ctrl+right-click to pin selection without moving."; + row.addEventListener("click", (event: MouseEvent) => { + const target = event.target; + if ( + target instanceof HTMLElement && + target.closest( + ".neuroglancer-spatial-skeleton-node-actions, .neuroglancer-spatial-skeleton-node-type-toggle", + ) !== null + ) { + return; + } + if ( + !ensureActionsAllowed(SpatialSkeletonActions.inspect, { + requireVisibleChunks: false, + }) + ) { + return; + } + selectNode(node, { moveView: true, pin: true }); + }); + row.addEventListener("contextmenu", (event: MouseEvent) => { + const target = event.target; + if ( + target instanceof HTMLElement && + target.closest( + ".neuroglancer-spatial-skeleton-node-actions, .neuroglancer-spatial-skeleton-node-type-toggle", + ) !== null + ) { + return; + } + event.preventDefault(); + if ( + !ensureActionsAllowed(SpatialSkeletonActions.inspect, { + requireVisibleChunks: false, + }) + ) { + return; + } + if (event.ctrlKey || event.metaKey) { + selectNode(node, { moveView: false, pin: true }); + return; + } + moveViewToNodePosition(node.position); + }); + row.addEventListener("keydown", (event: KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + if ( + !ensureActionsAllowed(SpatialSkeletonActions.inspect, { + requireVisibleChunks: false, + }) + ) { + return; + } + selectNode(node, { moveView: true, pin: true }); + }); + } else { + row.setAttribute("aria-disabled", "true"); + } + + const nodeIsTrueEnd = node.isTrueEnd; + const iconFilterType = getSpatialSkeletonNodeIconFilterType({ + nodeIsTrueEnd, + nodeType: type, + }); + const typeIconSvg = + iconFilterType === SpatialSkeletonNodeFilterType.TRUE_END + ? svg_flag + : iconFilterType === SpatialSkeletonNodeFilterType.VIRTUAL_END + ? svg_circle + : NODE_TYPE_ICONS[type]; + const typeIconTitle = + iconFilterType !== undefined + ? getSpatialSkeletonNodeFilterLabel(iconFilterType).toLowerCase() + : NODE_TYPE_LABELS[type]; + const typeButtonPending = pendingTrueEndNodes.has(node.nodeId); + const typeButtonTitle = typeButtonPending + ? nodeIsTrueEnd + ? "removing true end" + : "setting true end" + : typeIconTitle; + const typeIcon = + isLeaf || nodeIsTrueEnd + ? document.createElement("button") + : document.createElement("span"); + typeIcon.className = + isLeaf || nodeIsTrueEnd + ? "neuroglancer-spatial-skeleton-node-type-toggle" + : "neuroglancer-spatial-skeleton-node-type"; + typeIcon.title = typeButtonTitle; + if (typeIcon instanceof HTMLButtonElement) { + typeIcon.type = "button"; + typeIcon.disabled = !trueEndEditingAllowed || typeButtonPending; + typeIcon.setAttribute("aria-pressed", String(nodeIsTrueEnd)); + typeIcon.addEventListener("click", (event: MouseEvent) => { + event.stopPropagation(); + updateTrueEndLabel(node, !nodeIsTrueEnd); + }); + } + typeIcon.appendChild( + makeIcon({ + svg: typeIconSvg, + title: typeButtonTitle, + clickable: false, + }), + ); + + const idCell = document.createElement("span"); + idCell.className = "neuroglancer-spatial-skeleton-node-id"; + idCell.textContent = String(node.nodeId); + + const coordinatesCell = document.createElement("div"); + coordinatesCell.className = + "neuroglancer-spatial-skeleton-node-coordinate-cell"; + const coordinatesLine = document.createElement("div"); + coordinatesLine.className = + "neuroglancer-spatial-skeleton-node-coordinates neuroglancer-spatial-skeleton-coordinates-flex"; + for (const val of formatNodeCoordinates(node.position)) { + const valSpan = document.createElement("span"); + valSpan.className = "neuroglancer-spatial-skeleton-coord-dim"; + valSpan.textContent = val; + coordinatesLine.appendChild(valSpan); + } + coordinatesCell.appendChild(coordinatesLine); + const description = getNodeDescriptionText(node); + if (description !== undefined) { + const descriptionLine = document.createElement("div"); + descriptionLine.className = + "neuroglancer-spatial-skeleton-node-description"; + descriptionLine.textContent = description; + coordinatesCell.appendChild(descriptionLine); + } + + const actions = document.createElement("div"); + actions.className = "neuroglancer-spatial-skeleton-node-actions"; + let rerootActionTitle = + node.parentNodeId === undefined ? "already root" : "set as root"; + if (pendingRerootNodes.has(node.nodeId)) { + rerootActionTitle = "setting root"; + } + actions.appendChild( + makeRowActionButton( + svg_origin, + rerootActionTitle, + () => rerootNode(node), + !nodeRerootAllowed || + pendingRerootNodes.has(node.nodeId) || + node.parentNodeId === undefined, + ), + ); + let deleteActionTitle = "delete node"; + if (pendingDeleteNodes.has(node.nodeId)) { + deleteActionTitle = "deleting node"; + } + actions.appendChild( + makeRowActionButton( + svg_bin, + deleteActionTitle, + () => deleteNode(node), + !nodeDeletionAllowed || pendingDeleteNodes.has(node.nodeId), + ), + ); + + row.appendChild(actions); + row.appendChild(typeIcon); + row.appendChild(idCell); + row.appendChild(coordinatesCell); + entry.appendChild(row); + return entry; + }; + + const makeEmptyEntry = (text: string) => { + const empty = document.createElement("div"); + empty.className = "neuroglancer-spatial-skeleton-summary"; + empty.textContent = text; + return empty; + }; + + renderVirtualListItem = (item: SpatialSkeletonListItem | undefined) => { + switch (item?.kind) { + case "segment": + return makeSegmentEntry(item.segmentState); + case "node": + return makeNodeEntry(item.row); + case "empty": + return makeEmptyEntry(item.text); + default: + return document.createElement("div"); + } + }; + + const getListItemKey = (item: SpatialSkeletonListItem) => { + switch (item.kind) { + case "segment": + return `segment:${item.segmentState.segmentId}`; + case "node": + return `node:${item.row.node.nodeId}`; + case "empty": + return `empty:${item.text}`; + } + }; + + const setVirtualItems = (nextItems: SpatialSkeletonListItem[]) => { + const oldItems = virtualItems; + const sameItems = + oldItems.length === nextItems.length && + oldItems.every( + (item, index) => + getListItemKey(item) === getListItemKey(nextItems[index]), + ); + virtualItems = nextItems; + virtualListSource.length = virtualItems.length; + if (sameItems) { + virtualListRenderChanged.dispatch(); + } else { + virtualListChanged.dispatch([ + { + retainCount: 0, + deleteCount: oldItems.length, + insertCount: nextItems.length, + }, + ]); + } + }; + + const getEmptyListText = ( + segmentState: SegmentDisplayState | undefined, + ) => { + if (activeSegmentId === undefined) { + return "Select a skeleton segment to inspect editable nodes."; + } + if ( + segmentState === undefined || + segmentState.totalNodeCount === 0 || + (getFilterText().length === 0 && + nodeFilterTypeModel.value === SpatialSkeletonNodeFilterType.NONE) + ) { + return "No loaded nodes."; + } + return "No matching nodes."; + }; + + const updateList = (segmentState: SegmentDisplayState | undefined) => { + const flattened = buildSpatialSkeletonVirtualListItems( + segmentState, + getEmptyListText(segmentState), + ); + listIndexByNodeId.clear(); + for (const [nodeId, index] of flattened.listIndexByNodeId) { + listIndexByNodeId.set(nodeId, index); + } + updateListHeader( + segmentState !== undefined && segmentState.displayedNodeCount > 0, + ); + setVirtualItems(flattened.items); + if (pendingScrollToSelectedNode) { + applyRowInteractionState({ scrollSelectedIntoView: true }); + } else { + applyRowInteractionState(); + } + }; + + const summarizeNodeState = ( + segmentState: SegmentDisplayState | undefined, + summarySuffix = "", + ) => { + const branchCount = segmentState?.branchCount ?? 0; + const nodeCount = segmentState?.displayedNodeCount ?? 0; + nodesSummary.textContent = `${branchCount} branch${branchCount === 1 ? "" : "es"}, ${nodeCount} node${ + nodeCount === 1 ? "" : "s" + }`; + if (summarySuffix.trim().length > 0) { + nodesSummary.title = summarySuffix.trim(); + } else { + nodesSummary.removeAttribute("title"); + } + }; + + const updateDisplay = (summarySuffix = loadedNodeSummarySuffix) => { + const segmentState = buildSegmentDisplayState(); + summarizeNodeState(segmentState, summarySuffix); + updateList(segmentState); + }; + + const applyNodesBySegment = ( + nextNodesBySegment: Map, + summarySuffix = "", + ) => { + loadedNodeSummarySuffix = summarySuffix; + navigationGraphCache.clear(); + nodesBySegment = nextNodesBySegment; + const allNodesById = new Map(); + for (const segmentNodes of nextNodesBySegment.values()) { + for (const node of segmentNodes) { + if (!allNodesById.has(node.nodeId)) { + allNodesById.set(node.nodeId, node); + } + } + } + allNodes = [...allNodesById.values()].sort((a, b) => + a.segmentId === b.segmentId + ? a.nodeId - b.nodeId + : a.segmentId - b.segmentId, + ); + updateDisplay(summarySuffix); + }; + + const refreshNodes = () => { + const skeletonLayer = layer.getSpatiallyIndexedSkeletonLayer(); + const selectedSegmentId = getSelectedSegmentId(); + const cachedSelectedSegmentNodes = + selectedSegmentId === undefined + ? undefined + : skeletonState.getCachedSegmentNodes(selectedSegmentId); + activeSegmentId = + cachedSelectedSegmentNodes === undefined + ? undefined + : selectedSegmentId; + loadedNodeSummarySuffix = ""; + if ( + skeletonLayer === undefined || + activeSegmentId === undefined || + cachedSelectedSegmentNodes === undefined + ) { + allNodes = []; + nodesBySegment = new Map(); + navigationGraphCache.clear(); + updateDisplay(); + return; + } + allNodes = []; + nodesBySegment = new Map(); + navigationGraphCache.clear(); + updateDisplay(); + + const segmentId = activeSegmentId; + const cachedSegmentIds = new Set([segmentId]); + addVisibleSegmentIds(cachedSegmentIds); + for (const retainedSegmentId of skeletonLayer.getRetainedOverlaySegmentIds()) { + cachedSegmentIds.add(retainedSegmentId); + } + skeletonState.evictInactiveSegmentNodes(cachedSegmentIds); + applyNodesBySegment( + new Map([ + [segmentId, cachedSelectedSegmentNodes], + ]), + " Using inspected full skeleton data.", + ); + }; + + const updateGateStatus = () => { + const nextInspectionAllowed = + layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.inspect, + { + requireVisibleChunks: false, + }, + ) === undefined; + const nextNavigationAllowed = nextInspectionAllowed; + const nextTrueEndEditingAllowed = + layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.editNodeTrueEnd, + ) === undefined; + const nextNodeDeletionAllowed = + layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.deleteNodes, + ) === undefined; + const nextNodeRerootAllowed = + layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.reroot, + { + requireVisibleChunks: false, + }, + ) === undefined; + const gateStateChanged = + inspectionAllowed !== nextInspectionAllowed || + navigationAllowed !== nextNavigationAllowed || + trueEndEditingAllowed !== nextTrueEndEditingAllowed || + nodeDeletionAllowed !== nextNodeDeletionAllowed || + nodeRerootAllowed !== nextNodeRerootAllowed; + + inspectionAllowed = nextInspectionAllowed; + navigationAllowed = nextNavigationAllowed; + trueEndEditingAllowed = nextTrueEndEditingAllowed; + nodeDeletionAllowed = nextNodeDeletionAllowed; + nodeRerootAllowed = nextNodeRerootAllowed; + + filterInput.disabled = !inspectionAllowed; + nodeFilterTypeWidget.element.disabled = !inspectionAllowed; + for (const control of gatedControls) { + control.disabled = !navigationAllowed; + } + if (gateStateChanged) { + updateDisplay(); + } + }; + + const updateHistoryButtons = () => { + const { commandHistory } = layer.spatialSkeletonState; + const undoLabel = commandHistory.undoLabel.value; + const redoLabel = commandHistory.redoLabel.value; + const busy = commandHistory.isBusy.value; + undoButton.disabled = busy || !commandHistory.canUndo.value; + redoButton.disabled = busy || !commandHistory.canRedo.value; + undoButton.title = busy + ? "Wait for the current skeleton edit to finish." + : undoLabel === undefined + ? "Nothing to undo." + : `Undo ${undoLabel}`; + redoButton.title = busy + ? "Wait for the current skeleton edit to finish." + : redoLabel === undefined + ? "Nothing to redo." + : `Redo ${redoLabel}`; + undoButton.setAttribute("aria-label", undoButton.title); + redoButton.setAttribute("aria-label", redoButton.title); + }; + + filterInput.addEventListener("input", () => { + nodeQuery.value = filterInput.value; + }); + this.registerDisposer( + nodeQuery.changed.add(() => { + if (filterInput.value !== nodeQuery.value) { + filterInput.value = nodeQuery.value; + } + updateDisplay(); + }), + ); + this.registerDisposer( + nodeFilterTypeModel.changed.add(() => { + updateDisplay(); + }), + ); + + this.registerDisposer( + observeWatchable(() => updateGateStatus(), layer.spatialSkeletonEditMode), + ); + this.registerDisposer( + observeWatchable( + () => updateGateStatus(), + layer.spatialSkeletonMergeMode, + ), + ); + this.registerDisposer( + observeWatchable( + () => updateGateStatus(), + layer.spatialSkeletonSplitMode, + ), + ); + this.registerDisposer( + layer.spatialSkeletonVisibleChunksAvailable.changed.add(() => { + updateGateStatus(); + }), + ); + this.registerDisposer( + layer.spatialSkeletonVisibleChunksNeeded.changed.add(() => { + updateGateStatus(); + }), + ); + this.registerDisposer( + layer.layersChanged.add(() => { + updateGateStatus(); + }), + ); + this.registerDisposer( + layer.spatialSkeletonState.commandHistory.canUndo.changed.add(() => { + updateHistoryButtons(); + }), + ); + this.registerDisposer( + layer.spatialSkeletonState.commandHistory.canRedo.changed.add(() => { + updateHistoryButtons(); + }), + ); + this.registerDisposer( + layer.spatialSkeletonState.commandHistory.isBusy.changed.add(() => { + updateHistoryButtons(); + }), + ); + this.registerDisposer( + layer.spatialSkeletonState.commandHistory.undoLabel.changed.add(() => { + updateHistoryButtons(); + }), + ); + this.registerDisposer( + layer.spatialSkeletonState.commandHistory.redoLabel.changed.add(() => { + updateHistoryButtons(); + }), + ); + this.registerDisposer( + layer.manager.root.selectionState.changed.add(() => { + const nextActiveSegmentId = getSelectedSegmentId(); + if (nextActiveSegmentId !== activeSegmentId) { + refreshNodes(); + } else { + updateDisplay(); + } + }), + ); + this.registerDisposer( + registerNested((context, colorGroupState) => { + context.registerDisposer( + colorGroupState.segmentColorHash.changed.add(() => { + updateDisplay(); + }), + ); + context.registerDisposer( + colorGroupState.segmentDefaultColor.changed.add(() => { + updateDisplay(); + }), + ); + context.registerDisposer( + colorGroupState.segmentStatedColors.changed.add(() => { + updateDisplay(); + }), + ); + }, layer.displayState.segmentationColorGroupState), + ); + this.registerDisposer( + layer.selectedSpatialSkeletonNodeId.changed.add(() => { + pendingScrollToSelectedNode = true; + applyRowInteractionState({ scrollSelectedIntoView: true }); + }), + ); + this.registerDisposer( + layer.hoveredSpatialSkeletonNodeId.changed.add(() => { + updateHoveredViewerNode(); + }), + ); + this.registerDisposer( + layer.layersChanged.add(() => { + refreshNodes(); + }), + ); + this.registerDisposer( + layer.manager.chunkManager.layerChunkStatisticsUpdated.add(() => { + updateGateStatus(); + }), + ); + this.registerDisposer( + layer.spatialSkeletonNodeDataVersion.changed.add(() => { + refreshNodes(); + }), + ); + this.registerDisposer( + layer.manager.root.coordinateSpace.changed.add(() => { + updateDisplay(); + }), + ); + this.registerDisposer( + layer.localCoordinateSpace.changed.add(() => { + updateDisplay(); + }), + ); + updateGateStatus(); + updateHistoryButtons(); + updateHoveredViewerNode(); + refreshNodes(); + } +} diff --git a/src/ui/spatial_skeleton_edit_tab_render_state.ts b/src/ui/spatial_skeleton_edit_tab_render_state.ts new file mode 100644 index 0000000000..35084fa74a --- /dev/null +++ b/src/ui/spatial_skeleton_edit_tab_render_state.ts @@ -0,0 +1,158 @@ +/** + * @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 } from "#src/skeleton/api.js"; +import { + getFlatListNodeIds, + type SpatiallyIndexedSkeletonNavigationGraph, +} from "#src/skeleton/navigation.js"; +import { + classifySpatialSkeletonDisplayNodeType as classifyNodeType, + matchesSpatialSkeletonNodeFilter, + SpatialSkeletonNodeFilterType, + type SpatialSkeletonDisplayNodeType as SkeletonNodeType, +} from "#src/skeleton/node_types.js"; + +function nodeMatchesFilter( + node: SpatiallyIndexedSkeletonNode, + filterText: string, + description: string | undefined, +) { + if (filterText.length === 0) return true; + if (String(node.nodeId).includes(filterText)) return true; + return description?.toLowerCase().includes(filterText) ?? false; +} + +function hasNonEmptyNodeDescription(description: string | undefined) { + return (description?.trim().length ?? 0) > 0; +} + +export interface SpatialSkeletonSegmentRenderRow { + node: SpatiallyIndexedSkeletonNode; + type: SkeletonNodeType; + isLeaf: boolean; +} + +export interface SpatialSkeletonSegmentRenderState { + segmentId: number; + totalNodeCount: number; + matchedNodeCount: number; + displayedNodeCount: number; + branchCount: number; + rows: readonly SpatialSkeletonSegmentRenderRow[]; +} + +export function buildSpatialSkeletonSegmentRenderState( + segmentId: number, + graph: SpatiallyIndexedSkeletonNavigationGraph, + options: { + filterText: string; + nodeFilterType: SpatialSkeletonNodeFilterType; + getNodeDescription: ( + node: SpatiallyIndexedSkeletonNode, + ) => string | undefined; + }, +): SpatialSkeletonSegmentRenderState { + const { nodeById, childrenByParent } = graph; + if (nodeById.size === 0) { + return { + segmentId, + totalNodeCount: 0, + matchedNodeCount: 0, + displayedNodeCount: 0, + branchCount: 0, + rows: [], + }; + } + + const visibleMemo = new Map(); + const isNodeVisible = (nodeId: number): boolean => { + const cached = visibleMemo.get(nodeId); + if (cached !== undefined) { + return cached; + } + const node = nodeById.get(nodeId); + if (node === undefined) { + visibleMemo.set(nodeId, false); + return false; + } + const children = childrenByParent.get(nodeId) ?? []; + const parentInTree = + node.parentNodeId !== undefined && nodeById.has(node.parentNodeId); + const nodeType = classifyNodeType(node, children.length, parentInTree); + const description = options.getNodeDescription(node); + const visible = + (options.nodeFilterType === SpatialSkeletonNodeFilterType.NONE || + matchesSpatialSkeletonNodeFilter(options.nodeFilterType, { + isLeaf: children.length === 0, + nodeHasDescription: hasNonEmptyNodeDescription(description), + nodeIsTrueEnd: node.isTrueEnd, + nodeType, + })) && + nodeMatchesFilter(node, options.filterText, description); + visibleMemo.set(nodeId, visible); + return visible; + }; + + const visibleNodeIds = getFlatListNodeIds(graph, { + collapseRegularNodesForOrdering: true, + }).filter((nodeId) => isNodeVisible(nodeId)); + const visibleNodeIdSet = new Set(visibleNodeIds); + + let branchCount = 0; + for (const nodeId of visibleNodeIds) { + const node = nodeById.get(nodeId); + if (node === undefined) continue; + const visibleParent = + node.parentNodeId !== undefined && + visibleNodeIdSet.has(node.parentNodeId); + if (!visibleParent) { + branchCount++; + } + let visibleChildCount = 0; + for (const childNodeId of childrenByParent.get(nodeId) ?? []) { + if (visibleNodeIdSet.has(childNodeId)) { + visibleChildCount++; + } + } + if (visibleChildCount > 1) { + branchCount += visibleChildCount - 1; + } + } + + const rows: SpatialSkeletonSegmentRenderRow[] = []; + for (const nodeId of visibleNodeIds) { + const node = nodeById.get(nodeId); + if (node === undefined) continue; + const children = childrenByParent.get(nodeId) ?? []; + const parentInTree = + node.parentNodeId !== undefined && nodeById.has(node.parentNodeId); + const type = classifyNodeType(node, children.length, parentInTree); + if (type === "regular" && !node.isTrueEnd) { + continue; + } + rows.push({ node, type, isLeaf: children.length === 0 }); + } + + return { + segmentId, + totalNodeCount: nodeById.size, + matchedNodeCount: visibleNodeIds.length, + displayedNodeCount: rows.length, + branchCount, + rows, + }; +} diff --git a/src/ui/spatial_skeleton_edit_tool.css b/src/ui/spatial_skeleton_edit_tool.css new file mode 100644 index 0000000000..537cd69ab6 --- /dev/null +++ b/src/ui/spatial_skeleton_edit_tool.css @@ -0,0 +1,47 @@ +/** + * @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. + */ + +.neuroglancer-spatial-skeleton-tool-status { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; +} + +.neuroglancer-spatial-skeleton-tool-status-message { + display: inline-flex; + align-items: center; +} + +.neuroglancer-spatial-skeleton-tool-status-point { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0.55rem; + color: #e6cb57; +} + +.neuroglancer-spatial-skeleton-tool-status-point-field { + display: inline-flex; + align-items: center; + gap: 0.2rem; + font-weight: 600; +} + +.neuroglancer-spatial-skeleton-tool-status-point-field-label, +.neuroglancer-spatial-skeleton-tool-status-point-field-value { + color: inherit; +} diff --git a/src/ui/spatial_skeleton_edit_tool.spec.ts b/src/ui/spatial_skeleton_edit_tool.spec.ts new file mode 100644 index 0000000000..1517190a11 --- /dev/null +++ b/src/ui/spatial_skeleton_edit_tool.spec.ts @@ -0,0 +1,507 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + executeSpatialSkeletonAddNode, + executeSpatialSkeletonMerge, +} from "#src/layer/segmentation/spatial_skeleton_commands.js"; +import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; +import { SpatialSkeletonCommandHistory } from "#src/skeleton/command_history.js"; +import { setSpatialSkeletonModesToLinesAndPoints } from "#src/skeleton/edit_mode_rendering.js"; +import { SkeletonRenderMode } from "#src/skeleton/render_mode.js"; +import { StatusMessage } from "#src/status.js"; + +if (!("WebGL2RenderingContext" in globalThis)) { + Object.defineProperty(globalThis, "WebGL2RenderingContext", { + value: new Proxy(class WebGL2RenderingContext {} as any, { + get(target, property, receiver) { + if (Reflect.has(target, property)) { + return Reflect.get(target, property, receiver); + } + return 0; + }, + }), + configurable: true, + }); +} + +const { SpatialSkeletonEditModeTool } = await import( + "#src/ui/spatial_skeleton_edit_tool.js" +); + +function makeVisibleSegmentsState(initialVisibleSegments: bigint[] = []) { + return { + visibleSegments: new Set(initialVisibleSegments), + selectedSegments: new Set(), + segmentEquivalences: {}, + temporaryVisibleSegments: new Set(), + temporarySegmentEquivalences: {}, + useTemporaryVisibleSegments: { value: false }, + useTemporarySegmentEquivalences: { value: false }, + }; +} + +function makeEditableSkeletonSource(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(), + updateRadius: vi.fn(), + updateConfidence: vi.fn(), + mergeSkeletons: vi.fn(), + splitSkeleton: vi.fn(), + ...overrides, + }; +} + +function suppressStatusMessages() { + const fakeStatusMessage = { + dispose() {}, + } as unknown as StatusMessage; + vi.spyOn(StatusMessage, "showTemporaryMessage").mockImplementation( + (_message: string, _closeAfter?: number) => fakeStatusMessage, + ); + vi.spyOn(StatusMessage, "showMessage").mockImplementation( + (_message: string) => fakeStatusMessage, + ); +} + +describe("spatial_skeleton_edit_tool", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("switches 2d and 3d skeleton rendering to lines and points", () => { + const layer = { + displayState: { + skeletonRenderingOptions: { + params2d: { mode: { value: SkeletonRenderMode.LINES } }, + params3d: { mode: { value: SkeletonRenderMode.LINES } }, + }, + }, + } as any; + + setSpatialSkeletonModesToLinesAndPoints(layer); + + expect( + layer.displayState.skeletonRenderingOptions.params3d.mode.value, + ).toBe(SkeletonRenderMode.LINES_AND_POINTS); + expect( + layer.displayState.skeletonRenderingOptions.params2d.mode.value, + ).toBe(SkeletonRenderMode.LINES_AND_POINTS); + }); + + it("keeps parented add-node commits overlay-first without refetching chunks", async () => { + suppressStatusMessages(); + const upsertCachedNode = vi.fn(); + const setCachedNodeRevision = vi.fn(); + const selectSegment = vi.fn(); + const selectSpatialSkeletonNode = vi.fn(); + const markSpatialSkeletonNodeDataChanged = vi.fn(); + const moveViewToSpatialSkeletonNodePosition = vi.fn(); + const getFullSegmentNodes = vi.fn(); + const parentNode: SpatiallyIndexedSkeletonNode = { + nodeId: 5, + segmentId: 11, + position: new Float32Array([8, 9, 10]), + isTrueEnd: false, + revisionToken: "parent-before", + }; + const addNode = vi.fn().mockResolvedValue({ + treenodeId: 17, + skeletonId: 11, + revisionToken: "node-after", + parentRevisionToken: "parent-after", + }); + const skeletonLayer = { + source: makeEditableSkeletonSource({ addNode }), + getNode: vi.fn((nodeId: number) => + nodeId === parentNode.nodeId ? parentNode : undefined, + ), + retainOverlaySegment: vi.fn(), + invalidateSourceCaches: vi.fn(), + }; + const commandHistory = new SpatialSkeletonCommandHistory(); + const visibleSegmentsState = makeVisibleSegmentsState(); + const layer = { + displayState: { + segmentationGroupState: { + value: visibleSegmentsState, + }, + }, + spatialSkeletonState: { + commandHistory, + getCachedNode: vi.fn((nodeId: number) => + nodeId === parentNode.nodeId ? parentNode : undefined, + ), + getCachedSegmentNodes: vi.fn((segmentId: number) => + segmentId === parentNode.segmentId ? [parentNode] : undefined, + ), + getFullSegmentNodes, + upsertCachedNode, + setCachedNodeRevision, + }, + getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, + selectSegment, + selectSpatialSkeletonNode, + markSpatialSkeletonNodeDataChanged, + moveViewToSpatialSkeletonNodePosition, + manager: { + root: { + selectionState: { + pin: { + value: true, + }, + }, + }, + }, + }; + const position = new Float32Array([1, 2, 3]); + + await executeSpatialSkeletonAddNode(layer as any, { + skeletonId: 11, + parentNodeId: 5, + positionInModelSpace: position, + }); + + expect(addNode).toHaveBeenCalledWith(11, 1, 2, 3, 5, { + node: { + nodeId: 5, + parentNodeId: undefined, + revisionToken: "parent-before", + }, + }); + expect(upsertCachedNode).toHaveBeenCalledWith( + { + nodeId: 17, + segmentId: 11, + position: new Float32Array([1, 2, 3]), + parentNodeId: 5, + isTrueEnd: false, + revisionToken: "node-after", + }, + { allowUncachedSegment: false }, + ); + expect(setCachedNodeRevision).toHaveBeenCalledWith(5, "parent-after"); + expect(visibleSegmentsState.visibleSegments.has(11n)).toBe(true); + expect(selectSegment).toHaveBeenCalledWith(11n, true); + expect(selectSpatialSkeletonNode).toHaveBeenCalledWith(17, true, { + segmentId: 11, + position: new Float32Array([1, 2, 3]), + }); + expect(moveViewToSpatialSkeletonNodePosition).toHaveBeenCalledWith( + new Float32Array([1, 2, 3]), + ); + expect(skeletonLayer.retainOverlaySegment).toHaveBeenCalledWith(11); + expect(markSpatialSkeletonNodeDataChanged).toHaveBeenCalledWith({ + invalidateFullSkeletonCache: false, + }); + expect(getFullSegmentNodes).not.toHaveBeenCalled(); + expect(skeletonLayer.invalidateSourceCaches).not.toHaveBeenCalled(); + }); + + it("seeds root add-node commits locally without overlay retention or refetching chunks", async () => { + suppressStatusMessages(); + const upsertCachedNode = vi.fn(); + const setCachedNodeRevision = 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", + }); + const skeletonLayer = { + source: makeEditableSkeletonSource({ addNode }), + getNode: vi.fn(), + retainOverlaySegment: vi.fn(), + invalidateSourceCaches: vi.fn(), + }; + const commandHistory = new SpatialSkeletonCommandHistory(); + const visibleSegmentsState = makeVisibleSegmentsState(); + const layer = { + displayState: { + segmentationGroupState: { + value: visibleSegmentsState, + }, + }, + spatialSkeletonState: { + commandHistory, + getCachedNode: vi.fn(), + getCachedSegmentNodes: vi.fn(), + getFullSegmentNodes, + upsertCachedNode, + setCachedNodeRevision, + }, + getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, + selectSegment, + selectSpatialSkeletonNode, + markSpatialSkeletonNodeDataChanged, + moveViewToSpatialSkeletonNodePosition, + manager: { + root: { + selectionState: { + pin: { + value: false, + }, + }, + }, + }, + }; + const position = new Float32Array([4, 5, 6]); + + await executeSpatialSkeletonAddNode(layer as any, { + skeletonId: 13, + parentNodeId: undefined, + positionInModelSpace: position, + }); + + expect(addNode).toHaveBeenCalledWith(13, 4, 5, 6, undefined, undefined); + expect(upsertCachedNode).toHaveBeenCalledWith( + { + nodeId: 29, + segmentId: 13, + position: new Float32Array([4, 5, 6]), + parentNodeId: undefined, + isTrueEnd: false, + revisionToken: "root-after", + }, + { allowUncachedSegment: true }, + ); + expect(setCachedNodeRevision).not.toHaveBeenCalled(); + expect(visibleSegmentsState.visibleSegments.has(13n)).toBe(true); + expect(selectSegment).toHaveBeenCalledWith(13n, true); + expect(selectSpatialSkeletonNode).toHaveBeenCalledWith(29, false, { + segmentId: 13, + position: new Float32Array([4, 5, 6]), + }); + expect(moveViewToSpatialSkeletonNodePosition).toHaveBeenCalledWith( + new Float32Array([4, 5, 6]), + ); + expect(skeletonLayer.retainOverlaySegment).not.toHaveBeenCalled(); + expect(markSpatialSkeletonNodeDataChanged).toHaveBeenCalledWith({ + invalidateFullSkeletonCache: false, + }); + expect(getFullSegmentNodes).not.toHaveBeenCalled(); + expect(skeletonLayer.invalidateSourceCaches).not.toHaveBeenCalled(); + }); + + it("blocks appending a child to a selected true-end node", () => { + const getAddNodeBlockedReason = ( + SpatialSkeletonEditModeTool.prototype as any + ).getAddNodeBlockedReason as ( + this: any, + skeletonLayer: any, + parentNodeId: number | undefined, + ) => string | undefined; + const getCachedNode = vi.fn((nodeId: number) => + nodeId === 17 + ? { + nodeId: 17, + segmentId: 11, + position: new Float32Array([1, 2, 3]), + isTrueEnd: true, + } + : undefined, + ); + const getNode = vi.fn(); + const tool = { + layer: { + spatialSkeletonState: { + getCachedNode, + }, + }, + getSelectedParentNodeForAdd: ( + SpatialSkeletonEditModeTool.prototype as any + ).getSelectedParentNodeForAdd, + }; + + expect(getAddNodeBlockedReason.call(tool, { getNode }, 17)).toBe( + "Node 17 is marked as a true end. Clear the true end state before appending a child node.", + ); + expect(getAddNodeBlockedReason.call(tool, { getNode }, 18)).toBe(undefined); + expect(getAddNodeBlockedReason.call(tool, { getNode }, undefined)).toBe( + undefined, + ); + expect(getNode).toHaveBeenCalledTimes(1); + expect(getNode).toHaveBeenCalledWith(18); + }); + + it("suppresses the deleted merge segment while keeping the surviving result selected", async () => { + suppressStatusMessages(); + const firstNode: SpatiallyIndexedSkeletonNode = { + nodeId: 101, + segmentId: 11, + position: new Float32Array([1, 2, 3]), + isTrueEnd: false, + revisionToken: "first-before", + }; + const secondNode: SpatiallyIndexedSkeletonNode = { + nodeId: 202, + segmentId: 17, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + revisionToken: "second-before", + }; + const mergeSkeletons = vi.fn().mockResolvedValue({ + resultSkeletonId: 17, + deletedSkeletonId: 11, + stableAnnotationSwap: true, + }); + const invalidateCachedSegments = vi.fn(); + const getFullSegmentNodes = vi.fn(async () => []); + const selectSegment = vi.fn(); + const selectSpatialSkeletonNode = vi.fn(); + const markSpatialSkeletonNodeDataChanged = vi.fn(); + const clearSpatialSkeletonMergeAnchor = vi.fn(); + const deleteSegmentColor = vi.fn(); + const skeletonLayer = { + source: makeEditableSkeletonSource({ mergeSkeletons }), + getNode: vi.fn((nodeId: number) => { + if (nodeId === firstNode.nodeId) return firstNode; + if (nodeId === secondNode.nodeId) return secondNode; + return undefined; + }), + suppressBrowseSegment: vi.fn(), + invalidateSourceCaches: vi.fn(), + }; + const commandHistory = new SpatialSkeletonCommandHistory(); + const visibleSegmentsState = makeVisibleSegmentsState([11n, 17n]); + const layer = { + displayState: { + segmentationGroupState: { + value: visibleSegmentsState, + }, + segmentStatedColors: { + value: { + delete: deleteSegmentColor, + }, + }, + }, + spatialSkeletonState: { + commandHistory, + getCachedNode: vi.fn((nodeId: number) => { + if (nodeId === firstNode.nodeId) return firstNode; + if (nodeId === secondNode.nodeId) return secondNode; + return undefined; + }), + getCachedSegmentNodes: vi.fn((segmentId: number) => { + if (segmentId === firstNode.segmentId) return [firstNode]; + if (segmentId === secondNode.segmentId) return [secondNode]; + return undefined; + }), + getFullSegmentNodes, + invalidateCachedSegments, + }, + getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, + selectSegment, + selectSpatialSkeletonNode, + markSpatialSkeletonNodeDataChanged, + clearSpatialSkeletonMergeAnchor, + manager: { + root: { + selectionState: { + pin: { + value: true, + }, + }, + }, + }, + }; + + await executeSpatialSkeletonMerge( + layer as any, + { nodeId: 101, segmentId: 11 }, + { nodeId: 202, segmentId: 17 }, + ); + + expect(mergeSkeletons).toHaveBeenCalledWith(101, 202, { + nodes: [ + { nodeId: 101, revisionToken: "first-before" }, + { nodeId: 202, revisionToken: "second-before" }, + ], + }); + expect(invalidateCachedSegments).toHaveBeenCalledWith([17, 11]); + expect(getFullSegmentNodes).toHaveBeenCalledTimes(2); + expect(selectSegment).toHaveBeenCalledWith(17n, false); + expect(selectSpatialSkeletonNode).toHaveBeenCalledWith(101, true, { + segmentId: 17, + }); + expect(deleteSegmentColor).toHaveBeenCalledWith(11n); + expect(skeletonLayer.suppressBrowseSegment).toHaveBeenCalledWith(11); + expect(markSpatialSkeletonNodeDataChanged).toHaveBeenCalledWith({ + invalidateFullSkeletonCache: false, + }); + expect(visibleSegmentsState.visibleSegments.has(17n)).toBe(true); + expect(visibleSegmentsState.visibleSegments.has(11n)).toBe(false); + expect(skeletonLayer.invalidateSourceCaches).toHaveBeenCalledTimes(1); + expect(clearSpatialSkeletonMergeAnchor).toHaveBeenCalledTimes(1); + }); + + it("clears the merge anchor when the clear-selection action runs in merge mode", () => { + suppressStatusMessages(); + const bindClearSelectionAction = ( + SpatialSkeletonEditModeTool.prototype as any + ).bindClearSelectionAction as (this: any, activation: any) => void; + const clearSpatialSkeletonNodeSelection = vi.fn(); + const clearSpatialSkeletonMergeAnchor = vi.fn(); + const unpin = vi.fn(); + let clearSelectionHandler: ((event: any) => void) | undefined; + const activation = { + bindAction: vi.fn((action: string, handler: (event: any) => void) => { + if (action === "spatial-skeleton-clear-node-selection") { + clearSelectionHandler = handler; + } + }), + }; + const tool = { + layer: { + selectedSpatialSkeletonNodeId: { value: undefined }, + spatialSkeletonState: { + mergeAnchorNodeId: { value: 101 }, + }, + clearSpatialSkeletonNodeSelection, + clearSpatialSkeletonMergeAnchor, + manager: { + root: { + selectionState: { + value: undefined, + unpin, + }, + }, + }, + }, + }; + + bindClearSelectionAction.call(tool, activation); + + expect(clearSelectionHandler).toBeDefined(); + clearSelectionHandler?.({ + stopPropagation: vi.fn(), + detail: { + button: 2, + ctrlKey: true, + shiftKey: true, + preventDefault: vi.fn(), + }, + }); + + expect(clearSpatialSkeletonNodeSelection).toHaveBeenCalledWith( + "force-unpin", + ); + expect(clearSpatialSkeletonMergeAnchor).toHaveBeenCalledTimes(1); + expect(unpin).not.toHaveBeenCalled(); + }); +}); diff --git a/src/ui/spatial_skeleton_edit_tool.ts b/src/ui/spatial_skeleton_edit_tool.ts new file mode 100644 index 0000000000..85717fd421 --- /dev/null +++ b/src/ui/spatial_skeleton_edit_tool.ts @@ -0,0 +1,1422 @@ +/** + * @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 "#src/ui/spatial_skeleton_edit_tool.css"; + +import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; +import { + getSegmentIdFromLayerSelectionValue, + hasSpatialSkeletonNodeSelection, +} from "#src/layer/segmentation/selection.js"; +import { + executeSpatialSkeletonAddNode, + executeSpatialSkeletonMerge, + executeSpatialSkeletonMoveNode, + executeSpatialSkeletonSplit, +} from "#src/layer/segmentation/spatial_skeleton_commands.js"; +import { showSpatialSkeletonActionError } from "#src/layer/segmentation/spatial_skeleton_errors.js"; +import { getChunkPositionFromCombinedGlobalLocalPositions } from "#src/render_coordinate_transform.js"; +import { RenderedDataPanel } from "#src/rendered_data_panel.js"; +import { + addSegmentToVisibleSets, + getVisibleSegments, + removeSegmentFromVisibleSets, +} 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 { SpatiallyIndexedSkeletonLayer } from "#src/skeleton/frontend.js"; +import { + PerspectiveViewSpatiallyIndexedSkeletonLayer, + SliceViewPanelSpatiallyIndexedSkeletonLayer, + SliceViewSpatiallyIndexedSkeletonLayer, +} from "#src/skeleton/frontend.js"; +import { StatusMessage } from "#src/status.js"; +import type { SpatialSkeletonToolPointInfo } from "#src/ui/spatial_skeleton_tool_messages.js"; +import { + SPATIAL_SKELETON_SPLIT_BANNER_MESSAGE, + getSpatialSkeletonEditBannerMessage, + getSpatialSkeletonMergeBannerMessage, + getSpatialSkeletonToolPointStatusFields, +} from "#src/ui/spatial_skeleton_tool_messages.js"; +import type { ToolActivation } from "#src/ui/tool.js"; +import { + LayerTool, + makeToolActivationStatusMessageWithHeader, + registerTool, +} from "#src/ui/tool.js"; +import { removeChildren } from "#src/util/dom.js"; +import type { ActionEvent } from "#src/util/event_action_map.js"; +import { EventActionMap } from "#src/util/event_action_map.js"; +import { vec3 } from "#src/util/geom.js"; +import { startRelativeMouseDrag } from "#src/util/mouse_drag.js"; + +export const SPATIAL_SKELETON_EDIT_MODE_TOOL_ID = "spatialSkeletonEditMode"; +export const SPATIAL_SKELETON_MERGE_MODE_TOOL_ID = "spatialSkeletonMergeMode"; +export const SPATIAL_SKELETON_SPLIT_MODE_TOOL_ID = "spatialSkeletonSplitMode"; + +const SPATIAL_SKELETON_EDIT_STATUS_INPUT_EVENT_MAP = EventActionMap.fromObject({ + // Only expose the primary edit actions in the auto-generated subtitle. + "at:control+mousedown0": "spatial-skeleton-add-node", + "at:alt+mousedown0": "spatial-skeleton-move-node", + "at:control+mousedown2": { + action: "spatial-skeleton-pin-node", + stopPropagation: true, + preventDefault: true, + }, +}); + +const SPATIAL_SKELETON_EDIT_AUX_INPUT_EVENT_MAP = EventActionMap.fromObject({ + "at:dblclick0": { + action: "spatial-skeleton-toggle-visible", + stopPropagation: true, + preventDefault: true, + }, + "at:shift+control+mousedown2": { + action: "spatial-skeleton-clear-node-selection", + stopPropagation: true, + preventDefault: true, + }, +}); + +const SPATIAL_SKELETON_PICK_INPUT_EVENT_MAP = EventActionMap.fromObject({ + "at:control+mousedown2": { + action: "spatial-skeleton-pick-node", + stopPropagation: true, + preventDefault: true, + }, +}); + +const SPATIAL_SKELETON_PICK_AUX_INPUT_EVENT_MAP = EventActionMap.fromObject({ + "at:dblclick0": { + action: "spatial-skeleton-toggle-visible", + stopPropagation: true, + preventDefault: true, + }, + "at:shift+control+mousedown2": { + action: "spatial-skeleton-clear-node-selection", + stopPropagation: true, + preventDefault: true, + }, +}); + +const DRAG_START_DISTANCE_PX = 4; + +function waitForNextAnimationFrame() { + return new Promise((resolve) => { + if (typeof requestAnimationFrame !== "function") { + window.setTimeout(resolve, 0); + return; + } + requestAnimationFrame(() => resolve()); + }); +} + +function renderSpatialSkeletonToolStatus( + body: HTMLElement, + options: { + message: string; + point?: SpatialSkeletonToolPointInfo; + }, +) { + removeChildren(body); + body.classList.add("neuroglancer-spatial-skeleton-tool-status"); + const message = document.createElement("span"); + message.className = "neuroglancer-spatial-skeleton-tool-status-message"; + message.textContent = options.message; + body.appendChild(message); + if (options.point === undefined) { + return; + } + const point = document.createElement("span"); + point.className = "neuroglancer-spatial-skeleton-tool-status-point"; + for (const field of getSpatialSkeletonToolPointStatusFields(options.point)) { + const fieldElement = document.createElement("span"); + fieldElement.className = + "neuroglancer-spatial-skeleton-tool-status-point-field"; + const label = document.createElement("span"); + label.className = + "neuroglancer-spatial-skeleton-tool-status-point-field-label"; + label.textContent = field.label; + fieldElement.appendChild(label); + const value = document.createElement("span"); + value.className = + "neuroglancer-spatial-skeleton-tool-status-point-field-value"; + value.textContent = field.value; + fieldElement.appendChild(value); + point.appendChild(fieldElement); + } + body.appendChild(point); +} + +abstract class SpatialSkeletonToolBase extends LayerTool { + constructor(layer: SegmentationUserLayer) { + super(layer, true); + } + + protected getActiveSpatiallyIndexedSkeletonLayer() { + const pickedLayer = this.mouseState.pickedRenderLayer; + if (pickedLayer instanceof PerspectiveViewSpatiallyIndexedSkeletonLayer) { + return pickedLayer.base; + } + if (pickedLayer instanceof SliceViewPanelSpatiallyIndexedSkeletonLayer) { + return pickedLayer.base; + } + if (pickedLayer instanceof SliceViewSpatiallyIndexedSkeletonLayer) { + return pickedLayer.base; + } + return this.layer.getSpatiallyIndexedSkeletonLayer(); + } + + protected getPickedSpatialSkeletonNode(): + | { + nodeId: number; + segmentId?: number; + position?: Float32Array; + revisionToken?: string; + } + | undefined { + if (!this.mouseState.updateUnconditionally() || !this.mouseState.active) { + return undefined; + } + const pickedSpatialSkeleton = this.mouseState.pickedSpatialSkeleton; + const nodeIdRaw = pickedSpatialSkeleton?.nodeId; + if ( + typeof nodeIdRaw !== "number" || + !Number.isSafeInteger(nodeIdRaw) || + nodeIdRaw <= 0 + ) { + return undefined; + } + const segmentIdRaw = pickedSpatialSkeleton?.segmentId; + const position = pickedSpatialSkeleton?.position; + const revisionToken = pickedSpatialSkeleton?.revisionToken; + return { + nodeId: nodeIdRaw, + segmentId: + typeof segmentIdRaw === "number" && Number.isSafeInteger(segmentIdRaw) + ? segmentIdRaw + : undefined, + position: + position instanceof Float32Array + ? new Float32Array(position) + : undefined, + revisionToken: + typeof revisionToken === "string" ? revisionToken : undefined, + }; + } + + protected getPickedSpatialSkeletonSegment() { + if (!this.mouseState.updateUnconditionally() || !this.mouseState.active) { + return undefined; + } + const segmentIdRaw = this.mouseState.pickedSpatialSkeleton?.segmentId; + if ( + typeof segmentIdRaw !== "number" || + !Number.isSafeInteger(segmentIdRaw) || + segmentIdRaw <= 0 + ) { + return undefined; + } + return segmentIdRaw; + } + + protected selectSegmentByNumber(value: number) { + if (!Number.isFinite(value)) return; + this.layer.selectSegment(BigInt(Math.round(value)), false); + } + + protected pinSegmentByNumber(value: number) { + if (!Number.isFinite(value)) return; + this.layer.selectSegment(BigInt(Math.round(value)), true); + } + + protected ensureSegmentVisibleByNumber(value: number) { + if (!Number.isFinite(value)) return; + addSegmentToVisibleSets( + this.layer.displayState.segmentationGroupState.value, + BigInt(Math.round(value)), + ); + } + + protected removeVisibleSegmentByNumber( + value: number, + options: { + deselect?: boolean; + } = {}, + ) { + if (!Number.isFinite(value)) return; + removeSegmentFromVisibleSets( + this.layer.displayState.segmentationGroupState.value, + BigInt(Math.round(value)), + options, + ); + } + + protected isSpatialSkeletonSegmentVisible(segmentId: number) { + return getVisibleSegments( + this.layer.displayState.segmentationGroupState.value, + ).has(BigInt(Math.round(segmentId))); + } + + protected describeVisibleSegmentRequirement(segmentId: number) { + return `Only visible skeletons are editable. Make skeleton ${segmentId} visible in Seg tab or by double-clicking it in the viewer.`; + } + + protected togglePickedSpatialSkeletonVisibility() { + const pickedSegmentId = this.getPickedSpatialSkeletonSegment(); + if (pickedSegmentId === undefined) { + return false; + } + const skeletonLayer = this.layer.getSpatiallyIndexedSkeletonLayer(); + const isVisible = this.isSpatialSkeletonSegmentVisible(pickedSegmentId); + if (isVisible) { + this.removeVisibleSegmentByNumber(pickedSegmentId, { deselect: true }); + const selectedNodeId = this.layer.selectedSpatialSkeletonNodeId.value; + const selectedNode = + selectedNodeId === undefined + ? undefined + : skeletonLayer?.getNode(selectedNodeId); + if (selectedNode?.segmentId === pickedSegmentId) { + this.layer.clearSpatialSkeletonNodeSelection(false); + } + const mergeAnchorNodeId = + this.layer.spatialSkeletonState.mergeAnchorNodeId.value; + const anchorSegmentId = + mergeAnchorNodeId === undefined + ? undefined + : (skeletonLayer?.getNode(mergeAnchorNodeId)?.segmentId ?? + this.layer.spatialSkeletonState.getCachedNode(mergeAnchorNodeId) + ?.segmentId); + if (anchorSegmentId === pickedSegmentId) { + this.layer.clearSpatialSkeletonMergeAnchor(); + } + const cachedSegmentIds = new Set( + [ + ...getVisibleSegments( + this.layer.displayState.segmentationGroupState.value, + ).keys(), + ] + .map((segmentId) => Number(segmentId)) + .filter( + (segmentId) => Number.isSafeInteger(segmentId) && segmentId > 0, + ), + ); + for (const retainedSegmentId of skeletonLayer?.getRetainedOverlaySegmentIds() ?? + []) { + cachedSegmentIds.add(retainedSegmentId); + } + this.layer.spatialSkeletonState.evictInactiveSegmentNodes( + cachedSegmentIds, + ); + StatusMessage.showTemporaryMessage( + `Removed skeleton ${pickedSegmentId} from visible/editable skeletons.`, + ); + return true; + } + this.ensureSegmentVisibleByNumber(pickedSegmentId); + this.selectSegmentByNumber(pickedSegmentId); + StatusMessage.showTemporaryMessage( + `Made skeleton ${pickedSegmentId} visible/editable.`, + ); + return true; + } + + protected bindVisibilityToggleAction(activation: ToolActivation) { + activation.bindAction( + "spatial-skeleton-toggle-visible", + (event: ActionEvent) => { + if (event.detail.button !== 0) return; + event.stopPropagation(); + event.detail.preventDefault(); + this.togglePickedSpatialSkeletonVisibility(); + }, + ); + } + + protected resolvePickedNodeForAction( + skeletonLayer: SpatiallyIndexedSkeletonLayer, + ) { + const pickedNode = this.resolvePickedNodeSelection(skeletonLayer); + if (pickedNode === undefined) { + return undefined; + } + if (pickedNode.segmentId !== undefined) { + this.selectSegmentByNumber(pickedNode.segmentId); + } + this.layer.selectSpatialSkeletonNode(pickedNode.nodeId, false, pickedNode); + return { + nodeId: pickedNode.nodeId, + segmentId: pickedNode.segmentId, + }; + } + + protected resolvePickedNodeSelection( + skeletonLayer: SpatiallyIndexedSkeletonLayer, + ) { + const nodeHit = this.getPickedSpatialSkeletonNode(); + if (nodeHit === undefined) { + return undefined; + } + const resolvedNodeInfo = skeletonLayer.getNode(nodeHit.nodeId); + return { + nodeId: nodeHit.nodeId, + segmentId: nodeHit.segmentId ?? resolvedNodeInfo?.segmentId, + position: nodeHit.position ?? resolvedNodeInfo?.position, + revisionToken: nodeHit.revisionToken ?? resolvedNodeInfo?.revisionToken, + }; + } + + protected resolvePickedNodeSelectionForMerge( + skeletonLayer: SpatiallyIndexedSkeletonLayer, + ): + | { + nodeId: number; + segmentId?: number; + position?: Float32Array; + revisionToken?: string; + visible: boolean; + } + | undefined { + const nodeHit = this.getPickedSpatialSkeletonNode(); + if (nodeHit === undefined) { + return undefined; + } + const resolvedNodeInfo = + skeletonLayer.getNode(nodeHit.nodeId) ?? + this.layer.spatialSkeletonState.getCachedNode(nodeHit.nodeId); + const segmentId = nodeHit.segmentId ?? resolvedNodeInfo?.segmentId; + return { + nodeId: nodeHit.nodeId, + segmentId, + position: nodeHit.position ?? resolvedNodeInfo?.position, + revisionToken: nodeHit.revisionToken ?? resolvedNodeInfo?.revisionToken, + visible: + segmentId !== undefined && + this.isSpatialSkeletonSegmentVisible(segmentId), + }; + } + + protected getSelectedSpatialSkeletonNodeSummary() { + const nodeId = this.layer.selectedSpatialSkeletonNodeId.value; + if (nodeId === undefined) { + return undefined; + } + const selectedNode = + this.getActiveSpatiallyIndexedSkeletonLayer()?.getNode(nodeId); + const layerSelectionState = + this.layer.manager.root.selectionState.value?.layers.find( + (entry) => entry.layer === this.layer, + )?.state; + return { + nodeId, + segmentId: + selectedNode?.segmentId ?? + getSegmentIdFromLayerSelectionValue(layerSelectionState), + }; + } + + protected bindPinnedSelectionAction( + activation: ToolActivation, + options: { + showNodeSelectionMessage?: boolean; + } = {}, + ) { + const { showNodeSelectionMessage = true } = options; + activation.bindAction( + "spatial-skeleton-pin-node", + (event: ActionEvent) => { + if ( + event.detail.button !== 2 || + !event.detail.ctrlKey || + event.detail.shiftKey + ) { + return; + } + event.stopPropagation(); + event.detail.preventDefault(); + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) { + return; + } + const pickedNode = this.resolvePickedNodeSelection(skeletonLayer); + if (pickedNode === undefined) { + const pickedSegmentId = this.getPickedSpatialSkeletonSegment(); + if (pickedSegmentId === undefined) { + return; + } + this.layer.clearSpatialSkeletonNodeSelection(false); + this.pinSegmentByNumber(pickedSegmentId); + return; + } + if (pickedNode.segmentId !== undefined) { + this.pinSegmentByNumber(pickedNode.segmentId); + } + this.layer.selectSpatialSkeletonNode( + pickedNode.nodeId, + true, + pickedNode, + ); + if (showNodeSelectionMessage) { + StatusMessage.showTemporaryMessage( + `Selected and pinned node ${pickedNode.nodeId}.`, + ); + } + }, + ); + } + + protected bindClearSelectionAction(activation: ToolActivation) { + activation.bindAction( + "spatial-skeleton-clear-node-selection", + (event: ActionEvent) => { + if ( + event.detail.button !== 2 || + !event.detail.ctrlKey || + !event.detail.shiftKey + ) { + return; + } + event.stopPropagation(); + event.detail.preventDefault(); + const pinnedSelection = this.layer.manager.root.selectionState.value; + const hasSpatialSkeletonSelection = + this.layer.selectedSpatialSkeletonNodeId.value !== undefined || + (pinnedSelection?.layers.some( + ({ layer, state }) => + layer === this.layer && hasSpatialSkeletonNodeSelection(state), + ) ?? + false); + const hasMergeAnchor = + this.layer.spatialSkeletonState.mergeAnchorNodeId.value !== undefined; + if (hasSpatialSkeletonSelection || hasMergeAnchor) { + this.layer.clearSpatialSkeletonNodeSelection("force-unpin"); + if (hasMergeAnchor) { + this.layer.clearSpatialSkeletonMergeAnchor(); + } + StatusMessage.showTemporaryMessage( + hasMergeAnchor + ? hasSpatialSkeletonSelection + ? "Spatial skeleton selection and merge anchor cleared." + : "Spatial skeleton merge anchor cleared." + : "Spatial skeleton node selection cleared.", + ); + return; + } + this.layer.manager.root.selectionState.unpin(); + }, + ); + } + + protected activateModeWatchable( + activation: ToolActivation, + modeWatchable: { value: boolean }, + ) { + setSpatialSkeletonModesToLinesAndPoints(this.layer); + modeWatchable.value = true; + activation.registerDisposer(() => { + modeWatchable.value = false; + }); + } + + protected registerAutoCancelOnDisabled( + activation: ToolActivation, + requiredActions: Parameters< + SegmentationUserLayer["getSpatialSkeletonActionsDisabledReason"] + >[0], + onReady?: () => void, + ) { + const handleStateChanged = () => { + const disabledReason = + this.layer.getSpatialSkeletonActionsDisabledReason(requiredActions); + if (disabledReason === undefined) { + onReady?.(); + return; + } + StatusMessage.showTemporaryMessage(disabledReason); + activation.cancel(); + }; + activation.registerDisposer( + this.layer.layersChanged.add(handleStateChanged), + ); + } +} + +export class SpatialSkeletonEditModeTool extends SpatialSkeletonToolBase { + toJSON() { + return SPATIAL_SKELETON_EDIT_MODE_TOOL_ID; + } + + get description() { + return "skeleton edit mode"; + } + + private curChunkRank = -1; + private tempChunkPosition = new Float32Array(0); + private readonly dragModelSpacePosition = vec3.create(); + private readonly dragGlobalAnchorPosition = vec3.create(); + private readonly dragGlobalPosition = vec3.create(); + + // TODO (skm): really we can't handle a rank change right now + // and heavily assume rank 3. This is likely mostly fine + // but need to test a little more how it works if embedded in + // higher dim spaces or alongside images with a t dim / channel dim + // can also possibly remove this and just set tempChunkPosition + // to be vec3 instead of Float32Array + // will verify and clean up + private handleRankChanged(rank: number) { + if (rank === this.curChunkRank) return; + this.curChunkRank = rank; + this.tempChunkPosition = new Float32Array(rank); + } + + private globalToSkeletonCoordinates( + globalPosition: Float32Array, + skeletonLayer: SpatiallyIndexedSkeletonLayer, + ): Float32Array | undefined { + const chunkTransform = skeletonLayer.chunkTransform.value; + if (chunkTransform.error !== undefined) return undefined; + this.handleRankChanged(chunkTransform.modelTransform.unpaddedRank); + if ( + !getChunkPositionFromCombinedGlobalLocalPositions( + this.tempChunkPosition, + globalPosition, + skeletonLayer.localPosition.value, + chunkTransform.layerRank, + chunkTransform.combinedGlobalLocalToChunkTransform, + ) + ) { + return undefined; + } + return this.tempChunkPosition; + } + + private getMousePositionInSkeletonCoordinates( + skeletonLayer: SpatiallyIndexedSkeletonLayer, + ): Float32Array | undefined { + if (!this.mouseState.updateUnconditionally() || !this.mouseState.active) { + return undefined; + } + return this.globalToSkeletonCoordinates( + this.mouseState.unsnappedPosition, + skeletonLayer, + ); + } + + private getSelectedParentNodeForAdd( + skeletonLayer: SpatiallyIndexedSkeletonLayer, + parentNodeId: number | undefined, + ) { + if (parentNodeId === undefined) { + return undefined; + } + return ( + this.layer.spatialSkeletonState.getCachedNode(parentNodeId) ?? + skeletonLayer.getNode(parentNodeId) + ); + } + + private getAddNodeBlockedReason( + skeletonLayer: SpatiallyIndexedSkeletonLayer, + parentNodeId: number | undefined, + ) { + if (parentNodeId === undefined) { + return undefined; + } + const selectedParentNode = this.getSelectedParentNodeForAdd( + skeletonLayer, + parentNodeId, + ); + if (selectedParentNode !== undefined && selectedParentNode.isTrueEnd) { + return `Node ${parentNodeId} is marked as a true end. Clear the true end state before appending a child node.`; + } + return undefined; + } + + private getRenderedDataPanelForEvent( + event: MouseEvent, + ): RenderedDataPanel | undefined { + const display = this.layer.manager.root.display; + const target = event.target; + if (target instanceof Node) { + for (const panel of display.panels) { + if (!(panel instanceof RenderedDataPanel)) continue; + if (panel.element.contains(target)) { + return panel; + } + } + } + const clientX = event.clientX; + const clientY = event.clientY; + for (const panel of display.panels) { + if (!(panel instanceof RenderedDataPanel)) continue; + const rect = panel.element.getBoundingClientRect(); + if ( + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom + ) { + return panel; + } + } + return undefined; + } + + activate(activation: ToolActivation) { + const { layer } = this; + const rawInputEventMapBinder = activation.inputEventMapBinder; + const { body, header } = + makeToolActivationStatusMessageWithHeader(activation); + header.textContent = "Spatial skeleton edit mode"; + let statusOverride: string | undefined; + const renderStatus = () => { + const selectedPoint = this.getSelectedSpatialSkeletonNodeSummary(); + renderSpatialSkeletonToolStatus(body, { + message: + statusOverride ?? getSpatialSkeletonEditBannerMessage(selectedPoint), + point: selectedPoint, + }); + }; + const setStatus = (nextStatus: string | undefined) => { + statusOverride = nextStatus; + renderStatus(); + }; + const setReadyStatus = () => { + setStatus(undefined); + }; + + const disableWithMessage = (message: string) => { + setStatus(message); + StatusMessage.showTemporaryMessage(message); + queueMicrotask(() => activation.cancel()); + }; + + const getEditSupportDisabledReason = () => + layer.getSpatialSkeletonActionsDisabledReason( + [SpatialSkeletonActions.addNodes, SpatialSkeletonActions.moveNodes], + { + requireVisibleChunks: false, + }, + ); + const getEditMutationDisabledReason = () => + layer.getSpatialSkeletonActionsDisabledReason([ + SpatialSkeletonActions.addNodes, + SpatialSkeletonActions.moveNodes, + ]); + const updateInteractionStatus = () => { + const reason = getEditMutationDisabledReason(); + if (reason === undefined) { + setReadyStatus(); + return undefined; + } + const message = `${reason} Node selection is still available.`; + setStatus(message); + return reason; + }; + + const disabledReason = getEditSupportDisabledReason(); + if (disabledReason !== undefined) { + disableWithMessage(disabledReason); + return; + } + if (this.getActiveSpatiallyIndexedSkeletonLayer() === undefined) { + disableWithMessage( + "No spatially indexed skeleton source is currently loaded.", + ); + return; + } + + this.activateModeWatchable(activation, layer.spatialSkeletonEditMode); + activation.bindInputEventMap(SPATIAL_SKELETON_EDIT_STATUS_INPUT_EVENT_MAP); + rawInputEventMapBinder( + SPATIAL_SKELETON_EDIT_AUX_INPUT_EVENT_MAP, + activation, + ); + this.bindPinnedSelectionAction(activation, { + showNodeSelectionMessage: false, + }); + this.bindClearSelectionAction(activation); + this.bindVisibilityToggleAction(activation); + updateInteractionStatus(); + activation.registerDisposer(() => { + layer.spatialSkeletonState.clearPendingNodePositions(); + layer.clearSpatialSkeletonNodeSelection(false); + }); + activation.registerDisposer( + layer.selectedSpatialSkeletonNodeId.changed.add(renderStatus), + ); + activation.registerDisposer( + layer.manager.root.selectionState.changed.add(renderStatus), + ); + activation.registerDisposer( + layer.layersChanged.add(() => { + const supportReason = getEditSupportDisabledReason(); + if (supportReason !== undefined) { + StatusMessage.showTemporaryMessage(supportReason); + activation.cancel(); + return; + } + const reason = updateInteractionStatus(); + if (reason !== undefined) { + StatusMessage.showTemporaryMessage(reason); + return; + } + setReadyStatus(); + }), + ); + + activation.bindAction( + "spatial-skeleton-add-node", + (event: ActionEvent) => { + if ( + event.detail.button !== 0 || + !event.detail.ctrlKey || + event.detail.shiftKey || + event.detail.altKey || + event.detail.metaKey + ) { + return; + } + event.stopPropagation(); + event.detail.preventDefault(); + const disabledReason = layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.addNodes, + ); + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + return; + } + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) { + StatusMessage.showTemporaryMessage( + "No spatially indexed skeleton source is currently loaded.", + ); + return; + } + const selectedParentNodeId = layer.selectedSpatialSkeletonNodeId.value; + const addNodeBlockedReason = this.getAddNodeBlockedReason( + skeletonLayer, + selectedParentNodeId, + ); + if (addNodeBlockedReason !== undefined) { + StatusMessage.showTemporaryMessage(addNodeBlockedReason); + return; + } + if (selectedParentNodeId === undefined) { + const pickedSegmentId = this.getPickedSpatialSkeletonSegment(); + if (pickedSegmentId !== undefined) { + this.selectSegmentByNumber(pickedSegmentId); + return; + } + } + const clickStartPosition = + this.getMousePositionInSkeletonCoordinates(skeletonLayer); + if (clickStartPosition === undefined) { + StatusMessage.showTemporaryMessage( + "Unable to resolve add-node position for this click.", + ); + return; + } + let dragDistanceSquared = 0; + startRelativeMouseDrag( + event.detail, + (_event, deltaX, deltaY) => { + dragDistanceSquared += deltaX * deltaX + deltaY * deltaY; + }, + (_finishEvent) => { + const thresholdSquared = + DRAG_START_DISTANCE_PX * DRAG_START_DISTANCE_PX; + // Block adding nodes if the mouse release position + // is too far from the click position + if (dragDistanceSquared > thresholdSquared) { + setReadyStatus(); + return; + } + const selectedParentNodeId = + layer.selectedSpatialSkeletonNodeId.value; + const addNodeBlockedReason = this.getAddNodeBlockedReason( + skeletonLayer, + selectedParentNodeId, + ); + if (addNodeBlockedReason !== undefined) { + setReadyStatus(); + StatusMessage.showTemporaryMessage(addNodeBlockedReason); + return; + } + const selectedParentNode = this.getSelectedParentNodeForAdd( + skeletonLayer, + selectedParentNodeId, + ); + const targetSkeletonId = + selectedParentNode === undefined + ? 0 + : selectedParentNode.segmentId; + const clickPositionInModelSpace = + this.getMousePositionInSkeletonCoordinates(skeletonLayer); + if (clickPositionInModelSpace === undefined) return; + void (async () => { + try { + await executeSpatialSkeletonAddNode(layer, { + skeletonId: targetSkeletonId, + parentNodeId: selectedParentNodeId, + positionInModelSpace: new Float32Array( + clickPositionInModelSpace, + ), + }); + } catch (error) { + showSpatialSkeletonActionError("create node", error); + return; + } + setReadyStatus(); + })(); + }, + ); + }, + ); + + activation.bindAction( + "spatial-skeleton-move-node", + (event: ActionEvent) => { + event.stopPropagation(); + event.detail.preventDefault(); + const disabledReason = layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.moveNodes, + ); + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + return; + } + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) { + StatusMessage.showTemporaryMessage( + "No spatially indexed skeleton source is currently loaded.", + ); + return; + } + const actionPanel = this.getRenderedDataPanelForEvent(event.detail); + const pickedNode = this.getPickedSpatialSkeletonNode(); + if (pickedNode === undefined) { + const pickedSegmentId = this.getPickedSpatialSkeletonSegment(); + if (pickedSegmentId !== undefined) { + this.selectSegmentByNumber(pickedSegmentId); + layer.clearSpatialSkeletonNodeSelection(false); + } + return; + } + const pickedPosition = this.mouseState.position; + const hasPickedPosition = + pickedPosition.length >= 3 && + Number.isFinite(pickedPosition[0]) && + Number.isFinite(pickedPosition[1]) && + Number.isFinite(pickedPosition[2]); + if (!hasPickedPosition) return; + const nodeInfo = skeletonLayer.getNode(pickedNode.nodeId); + if (nodeInfo === undefined) { + return; + } + const dragPanel = actionPanel; + if (dragPanel === undefined) { + StatusMessage.showTemporaryMessage( + "Unable to resolve active panel for node drag.", + ); + return; + } + let moved = false; + this.dragModelSpacePosition.set(nodeInfo.position); + vec3.set( + this.dragGlobalAnchorPosition, + Number(pickedPosition[0]), + Number(pickedPosition[1]), + Number(pickedPosition[2]), + ); + let totalDeltaX = 0; + let totalDeltaY = 0; + let dragDistanceSquared = 0; + let dragActive = false; + startRelativeMouseDrag( + event.detail, + (_event, deltaX, deltaY) => { + dragDistanceSquared += deltaX * deltaX + deltaY * deltaY; + const thresholdSquared = + DRAG_START_DISTANCE_PX * DRAG_START_DISTANCE_PX; + if (!dragActive && dragDistanceSquared >= thresholdSquared) { + dragActive = true; + setStatus("Dragging node"); + } + if (!dragActive) return; + totalDeltaX += deltaX; + totalDeltaY += deltaY; + dragPanel.translateDataPointByViewportPixels( + this.dragGlobalPosition, + this.dragGlobalAnchorPosition, + totalDeltaX, + totalDeltaY, + ); + if ( + !Number.isFinite(this.dragGlobalPosition[0]) || + !Number.isFinite(this.dragGlobalPosition[1]) || + !Number.isFinite(this.dragGlobalPosition[2]) + ) { + return; + } + const modelPosition = this.globalToSkeletonCoordinates( + this.dragGlobalPosition, + skeletonLayer, + ); + if (modelPosition === undefined) return; + const previewChanged = + layer.spatialSkeletonState.setPendingNodePosition( + pickedNode.nodeId, + modelPosition, + ); + if (!previewChanged) return; + moved = true; + this.dragModelSpacePosition.set(modelPosition); + }, + (_finishEvent) => { + if (!dragActive) { + setReadyStatus(); + return; + } + setReadyStatus(); + if (moved) { + void executeSpatialSkeletonMoveNode(layer, { + node: nodeInfo, + nextPositionInModelSpace: new Float32Array( + this.dragModelSpacePosition, + ), + }) + .then(() => { + layer.spatialSkeletonState.clearPendingNodePosition( + pickedNode.nodeId, + ); + }) + .catch((error) => { + layer.spatialSkeletonState.clearPendingNodePosition( + pickedNode.nodeId, + ); + showSpatialSkeletonActionError("move node", error); + }); + return; + } + layer.spatialSkeletonState.clearPendingNodePosition( + pickedNode.nodeId, + ); + }, + ); + }, + ); + } +} + +class SpatialSkeletonMergeModeTool extends SpatialSkeletonToolBase { + toJSON() { + return SPATIAL_SKELETON_MERGE_MODE_TOOL_ID; + } + + get description() { + return "skeleton merge mode"; + } + + activate(activation: ToolActivation) { + const rawInputEventMapBinder = activation.inputEventMapBinder; + const reason = this.layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.mergeSkeletons, + ); + if (reason !== undefined) { + StatusMessage.showTemporaryMessage(reason); + queueMicrotask(() => activation.cancel()); + return; + } + if (this.getActiveSpatiallyIndexedSkeletonLayer() === undefined) { + StatusMessage.showTemporaryMessage( + "No spatially indexed skeleton source is currently loaded.", + ); + queueMicrotask(() => activation.cancel()); + return; + } + + this.activateModeWatchable(activation, this.layer.spatialSkeletonMergeMode); + this.layer.clearSpatialSkeletonNodeSelection("force-unpin"); + this.layer.clearSpatialSkeletonMergeAnchor(); + activation.registerDisposer(() => { + this.layer.clearSpatialSkeletonMergeAnchor(); + }); + const { body, header } = + makeToolActivationStatusMessageWithHeader(activation); + header.textContent = "Spatial skeleton merge mode"; + let pending = false; + type MergeAnchorSelection = { + nodeId: number; + segmentId?: number; + position?: ArrayLike; + revisionToken?: string; + }; + let anchorSelection: MergeAnchorSelection | undefined; + let statusOverride: string | undefined; + const getAnchorNode = (): MergeAnchorSelection | undefined => { + const nodeId = this.layer.spatialSkeletonState.mergeAnchorNodeId.value; + if (nodeId === undefined || !Number.isSafeInteger(nodeId)) { + anchorSelection = undefined; + return undefined; + } + if (anchorSelection?.nodeId === nodeId) { + return anchorSelection; + } + const cachedNode = + this.getActiveSpatiallyIndexedSkeletonLayer()?.getNode(nodeId) ?? + this.layer.spatialSkeletonState.getCachedNode(nodeId); + const anchorNode = { + nodeId, + segmentId: cachedNode?.segmentId, + position: cachedNode?.position, + revisionToken: cachedNode?.revisionToken, + }; + anchorSelection = anchorNode; + return anchorNode; + }; + const renderStatus = () => { + const anchorNode = getAnchorNode(); + renderSpatialSkeletonToolStatus(body, { + message: + statusOverride ?? getSpatialSkeletonMergeBannerMessage(anchorNode), + point: anchorNode, + }); + }; + const setStatus = (nextStatus: string | undefined) => { + statusOverride = nextStatus; + renderStatus(); + }; + const setReadyStatus = () => { + setStatus(undefined); + }; + setReadyStatus(); + activation.bindInputEventMap(SPATIAL_SKELETON_PICK_INPUT_EVENT_MAP); + rawInputEventMapBinder( + SPATIAL_SKELETON_PICK_AUX_INPUT_EVENT_MAP, + activation, + ); + this.bindClearSelectionAction(activation); + this.bindVisibilityToggleAction(activation); + this.registerAutoCancelOnDisabled( + activation, + SpatialSkeletonActions.mergeSkeletons, + setReadyStatus, + ); + activation.registerDisposer( + this.layer.spatialSkeletonState.mergeAnchorNodeId.changed.add( + renderStatus, + ), + ); + activation.bindAction( + "spatial-skeleton-pick-node", + (event: ActionEvent) => { + if ( + event.detail.button !== 2 || + !event.detail.ctrlKey || + event.detail.shiftKey || + event.detail.altKey || + event.detail.metaKey + ) { + return; + } + if (pending) return; + const disabledReason = + this.layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.mergeSkeletons, + ); + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + return; + } + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) { + StatusMessage.showTemporaryMessage( + "No spatially indexed skeleton source is currently loaded.", + ); + return; + } + const pickedNode = + this.resolvePickedNodeSelectionForMerge(skeletonLayer); + const anchorNode = getAnchorNode(); + if (pickedNode === undefined) { + const pickedSegmentId = this.getPickedSpatialSkeletonSegment(); + if (pickedSegmentId !== undefined) { + this.pinSegmentByNumber(pickedSegmentId); + if ( + anchorNode === undefined || + pickedSegmentId === anchorNode.segmentId + ) { + this.layer.clearSpatialSkeletonNodeSelection(false); + } + renderStatus(); + } + return; + } + if (pickedNode === undefined || pickedNode.segmentId === undefined) { + return; + } + if ( + anchorNode === undefined || + anchorNode.nodeId === pickedNode.nodeId + ) { + if (!pickedNode.visible) { + StatusMessage.showTemporaryMessage( + "Pick the first merge anchor from a visible segment.", + ); + return; + } + this.pinSegmentByNumber(pickedNode.segmentId); + anchorSelection = { + nodeId: pickedNode.nodeId, + segmentId: pickedNode.segmentId, + position: pickedNode.position, + revisionToken: pickedNode.revisionToken, + }; + this.layer.setSpatialSkeletonMergeAnchor(pickedNode.nodeId); + this.layer.selectSpatialSkeletonNode( + pickedNode.nodeId, + true, + pickedNode, + ); + renderStatus(); + return; + } + if (anchorNode.segmentId === pickedNode.segmentId) { + if (!pickedNode.visible) { + StatusMessage.showTemporaryMessage( + "Pick the first merge anchor from a visible segment.", + ); + return; + } + this.pinSegmentByNumber(pickedNode.segmentId); + anchorSelection = { + nodeId: pickedNode.nodeId, + segmentId: pickedNode.segmentId, + position: pickedNode.position, + revisionToken: pickedNode.revisionToken, + }; + this.layer.setSpatialSkeletonMergeAnchor(pickedNode.nodeId); + this.layer.selectSpatialSkeletonNode( + pickedNode.nodeId, + true, + pickedNode, + ); + renderStatus(); + return; + } + const firstNode = anchorNode; + const secondNode = { + nodeId: pickedNode.nodeId, + segmentId: pickedNode.segmentId, + position: pickedNode.position, + revisionToken: pickedNode.revisionToken, + }; + if ( + firstNode.segmentId === undefined || + secondNode.segmentId === undefined + ) { + StatusMessage.showTemporaryMessage( + "Unable to resolve both merge segments.", + ); + return; + } + this.pinSegmentByNumber(pickedNode.segmentId); + this.layer.selectSpatialSkeletonNode( + pickedNode.nodeId, + true, + pickedNode, + ); + pending = true; + setStatus("Merging selected nodes."); + void (async () => { + try { + await waitForNextAnimationFrame(); + await executeSpatialSkeletonMerge( + this.layer, + { + nodeId: firstNode.nodeId, + segmentId: firstNode.segmentId!, + revisionToken: firstNode.revisionToken, + }, + { + nodeId: secondNode.nodeId, + segmentId: secondNode.segmentId!, + revisionToken: secondNode.revisionToken, + }, + ); + } catch (error) { + showSpatialSkeletonActionError("merge skeletons", error); + } finally { + pending = false; + setReadyStatus(); + } + })(); + }, + ); + } +} + +class SpatialSkeletonSplitModeTool extends SpatialSkeletonToolBase { + toJSON() { + return SPATIAL_SKELETON_SPLIT_MODE_TOOL_ID; + } + + get description() { + return "skeleton split mode"; + } + + activate(activation: ToolActivation) { + const rawInputEventMapBinder = activation.inputEventMapBinder; + const reason = this.layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.splitSkeletons, + ); + if (reason !== undefined) { + StatusMessage.showTemporaryMessage(reason); + queueMicrotask(() => activation.cancel()); + return; + } + if (this.getActiveSpatiallyIndexedSkeletonLayer() === undefined) { + StatusMessage.showTemporaryMessage( + "No spatially indexed skeleton source is currently loaded.", + ); + queueMicrotask(() => activation.cancel()); + return; + } + + this.activateModeWatchable(activation, this.layer.spatialSkeletonSplitMode); + this.layer.clearSpatialSkeletonNodeSelection("force-unpin"); + const { body, header } = + makeToolActivationStatusMessageWithHeader(activation); + header.textContent = "Spatial skeleton split mode"; + let pending = false; + let statusOverride: string | undefined; + let pendingPoint: SpatialSkeletonToolPointInfo | undefined; + const renderStatus = () => { + renderSpatialSkeletonToolStatus(body, { + message: statusOverride ?? SPATIAL_SKELETON_SPLIT_BANNER_MESSAGE, + point: pendingPoint, + }); + }; + const setStatus = ( + nextStatus: string | undefined, + nextPoint: SpatialSkeletonToolPointInfo | undefined = pendingPoint, + ) => { + statusOverride = nextStatus; + pendingPoint = nextPoint; + renderStatus(); + }; + const setReadyStatus = () => { + setStatus(undefined, undefined); + }; + setReadyStatus(); + activation.bindInputEventMap(SPATIAL_SKELETON_PICK_INPUT_EVENT_MAP); + rawInputEventMapBinder( + SPATIAL_SKELETON_PICK_AUX_INPUT_EVENT_MAP, + activation, + ); + this.bindClearSelectionAction(activation); + this.bindVisibilityToggleAction(activation); + this.registerAutoCancelOnDisabled( + activation, + SpatialSkeletonActions.splitSkeletons, + setReadyStatus, + ); + activation.bindAction( + "spatial-skeleton-pick-node", + (event: ActionEvent) => { + if ( + event.detail.button !== 2 || + !event.detail.ctrlKey || + event.detail.shiftKey || + event.detail.altKey || + event.detail.metaKey + ) { + return; + } + if (pending) return; + const disabledReason = + this.layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.splitSkeletons, + ); + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + return; + } + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) { + StatusMessage.showTemporaryMessage( + "No spatially indexed skeleton source is currently loaded.", + ); + return; + } + const pickedNode = this.resolvePickedNodeSelection(skeletonLayer); + if (pickedNode === undefined) { + const pickedSegmentId = this.getPickedSpatialSkeletonSegment(); + if (pickedSegmentId !== undefined) { + this.pinSegmentByNumber(pickedSegmentId); + this.layer.clearSpatialSkeletonNodeSelection(false); + renderStatus(); + } + return; + } + if (pickedNode === undefined || pickedNode.segmentId === undefined) { + return; + } + this.pinSegmentByNumber(pickedNode.segmentId); + this.layer.selectSpatialSkeletonNode( + pickedNode.nodeId, + true, + pickedNode, + ); + const point = { + nodeId: pickedNode.nodeId, + segmentId: pickedNode.segmentId, + position: pickedNode.position, + }; + pending = true; + setStatus("Splitting selected node.", point); + void (async () => { + try { + await executeSpatialSkeletonSplit(this.layer, { + nodeId: pickedNode.nodeId, + segmentId: pickedNode.segmentId!, + }); + } catch (error) { + showSpatialSkeletonActionError("split skeleton", error); + } finally { + pending = false; + setReadyStatus(); + } + })(); + }, + ); + } +} + +export function registerSpatialSkeletonEditModeTool( + contextType: typeof SegmentationUserLayer, +) { + registerTool(contextType, SPATIAL_SKELETON_EDIT_MODE_TOOL_ID, (layer) => { + return new SpatialSkeletonEditModeTool(layer); + }); + registerTool(contextType, SPATIAL_SKELETON_MERGE_MODE_TOOL_ID, (layer) => { + return new SpatialSkeletonMergeModeTool(layer); + }); + registerTool(contextType, SPATIAL_SKELETON_SPLIT_MODE_TOOL_ID, (layer) => { + return new SpatialSkeletonSplitModeTool(layer); + }); +} diff --git a/src/ui/spatial_skeleton_tool_messages.spec.ts b/src/ui/spatial_skeleton_tool_messages.spec.ts new file mode 100644 index 0000000000..1e6a1af7b8 --- /dev/null +++ b/src/ui/spatial_skeleton_tool_messages.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { + SPATIAL_SKELETON_EDIT_BANNER_MESSAGE, + SPATIAL_SKELETON_EDIT_SELECTED_BANNER_MESSAGE, + SPATIAL_SKELETON_MERGE_BANNER_MESSAGE, + SPATIAL_SKELETON_MERGE_SELECTED_BANNER_MESSAGE, + SPATIAL_SKELETON_SPLIT_BANNER_MESSAGE, + formatSpatialSkeletonToolPoint, + getSpatialSkeletonToolPointSummaryRow, + getSpatialSkeletonToolPointStatusFields, + getSpatialSkeletonEditBannerMessage, + getSpatialSkeletonMergeBannerMessage, +} from "#src/ui/spatial_skeleton_tool_messages.js"; + +describe("spatial_skeleton_tool_messages", () => { + it("formats tool points with node and segment ids", () => { + expect(formatSpatialSkeletonToolPoint({ nodeId: 17, segmentId: 9 })).toBe( + "Node 17, segment 9", + ); + expect(formatSpatialSkeletonToolPoint({ nodeId: 17 })).toBe("Node 17"); + expect( + getSpatialSkeletonToolPointStatusFields({ + nodeId: 17, + segmentId: 9, + }), + ).toEqual([ + { label: "Node ID:", value: "17" }, + { label: "Segment ID:", value: "9" }, + ]); + expect(getSpatialSkeletonToolPointStatusFields({ nodeId: 17 })).toEqual([ + { label: "Node ID:", value: "17" }, + ]); + expect( + getSpatialSkeletonToolPointSummaryRow({ + nodeId: 17, + segmentId: 9, + position: [100.2, 200.7, 300.1], + }), + ).toEqual({ + fields: [ + { label: "Segment ID:", value: "9" }, + { label: "Node ID:", value: "17" }, + { label: "x", value: "100", highlight: true }, + { label: "y", value: "201", highlight: true }, + { label: "z", value: "300", highlight: true }, + ], + }); + }); + + it("switches edit banner copy when a node is selected", () => { + expect(getSpatialSkeletonEditBannerMessage(undefined)).toBe( + SPATIAL_SKELETON_EDIT_BANNER_MESSAGE, + ); + expect( + getSpatialSkeletonEditBannerMessage({ nodeId: 8, segmentId: 12 }), + ).toBe(SPATIAL_SKELETON_EDIT_SELECTED_BANNER_MESSAGE); + }); + + it("switches merge banner copy after the first point is selected", () => { + expect(getSpatialSkeletonMergeBannerMessage(undefined)).toBe( + SPATIAL_SKELETON_MERGE_BANNER_MESSAGE, + ); + expect( + getSpatialSkeletonMergeBannerMessage({ nodeId: 8, segmentId: 12 }), + ).toBe(SPATIAL_SKELETON_MERGE_SELECTED_BANNER_MESSAGE); + }); + + it("keeps the split banner copy stable", () => { + expect(SPATIAL_SKELETON_SPLIT_BANNER_MESSAGE).toBe( + "Select 1 node to split", + ); + }); +}); diff --git a/src/ui/spatial_skeleton_tool_messages.ts b/src/ui/spatial_skeleton_tool_messages.ts new file mode 100644 index 0000000000..73d7cf3f9a --- /dev/null +++ b/src/ui/spatial_skeleton_tool_messages.ts @@ -0,0 +1,112 @@ +/** + * @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 interface SpatialSkeletonToolPointInfo { + nodeId: number; + segmentId?: number; + position?: ArrayLike; +} + +export interface SpatialSkeletonToolSummaryField { + label: string; + value: string; + highlight?: boolean; +} + +export interface SpatialSkeletonToolSummaryRow { + fields: SpatialSkeletonToolSummaryField[]; +} + +export interface SpatialSkeletonToolStatusField { + label: string; + value: string; +} + +export const SPATIAL_SKELETON_EDIT_BANNER_MESSAGE = + "Move nodes, select a node to append or click to start a new skeleton"; +export const SPATIAL_SKELETON_EDIT_SELECTED_BANNER_MESSAGE = + "Move node or append to selected node"; +export const SPATIAL_SKELETON_MERGE_BANNER_MESSAGE = "Select 2 nodes to merge"; +export const SPATIAL_SKELETON_MERGE_SELECTED_BANNER_MESSAGE = + "Select 2nd node from a different skeleton to merge with"; +export const SPATIAL_SKELETON_SPLIT_BANNER_MESSAGE = "Select 1 node to split"; + +export function formatSpatialSkeletonToolPoint( + point: SpatialSkeletonToolPointInfo, +) { + return point.segmentId === undefined + ? `Node ${point.nodeId}` + : `Node ${point.nodeId}, segment ${point.segmentId}`; +} + +export function getSpatialSkeletonToolPointSummaryRow( + point: SpatialSkeletonToolPointInfo, +): SpatialSkeletonToolSummaryRow { + const fields: SpatialSkeletonToolSummaryField[] = [ + { + label: "Segment ID:", + value: point.segmentId === undefined ? "-" : `${point.segmentId}`, + }, + { label: "Node ID:", value: `${point.nodeId}` }, + ]; + const { position } = point; + if (position !== undefined && position.length >= 3) { + fields.push({ + label: "x", + value: `${Math.round(Number(position[0]))}`, + highlight: true, + }); + fields.push({ + label: "y", + value: `${Math.round(Number(position[1]))}`, + highlight: true, + }); + fields.push({ + label: "z", + value: `${Math.round(Number(position[2]))}`, + highlight: true, + }); + } + return { fields }; +} + +export function getSpatialSkeletonToolPointStatusFields( + point: SpatialSkeletonToolPointInfo, +): SpatialSkeletonToolStatusField[] { + const fields: SpatialSkeletonToolStatusField[] = [ + { label: "Node ID:", value: `${point.nodeId}` }, + ]; + if (point.segmentId !== undefined) { + fields.push({ label: "Segment ID:", value: `${point.segmentId}` }); + } + return fields; +} + +export function getSpatialSkeletonEditBannerMessage( + selectedPoint: SpatialSkeletonToolPointInfo | undefined, +) { + return selectedPoint === undefined + ? SPATIAL_SKELETON_EDIT_BANNER_MESSAGE + : SPATIAL_SKELETON_EDIT_SELECTED_BANNER_MESSAGE; +} + +export function getSpatialSkeletonMergeBannerMessage( + selectedPoint: SpatialSkeletonToolPointInfo | undefined, +) { + return selectedPoint === undefined + ? SPATIAL_SKELETON_MERGE_BANNER_MESSAGE + : SPATIAL_SKELETON_MERGE_SELECTED_BANNER_MESSAGE; +} diff --git a/src/util/abort.ts b/src/util/abort.ts index da3b37908f..62313cfb77 100644 --- a/src/util/abort.ts +++ b/src/util/abort.ts @@ -34,6 +34,10 @@ export function scopedAbortCallback( }; } +export function isAbortError(error: unknown) { + return error instanceof Error && error.name === "AbortError"; +} + // Abort controller that aborts when *all* consumers have aborted. export class SharedAbortController { private consumers = new Map<(this: AbortSignal) => void, AbortSignal>(); diff --git a/src/widget/render_scale_widget.css b/src/widget/render_scale_widget.css index 6c1132d7c8..b0028b86b3 100644 --- a/src/widget/render_scale_widget.css +++ b/src/widget/render_scale_widget.css @@ -42,3 +42,58 @@ .neuroglancer-render-scale-widget-legend > div { height: 12px; } + +.neuroglancer-render-scale-widget-grid + .neuroglancer-render-scale-widget-legend { + width: 100%; + min-width: 0; + flex: 1 1 100%; + display: flex; + column-gap: 8px; + text-align: left; +} + +.neuroglancer-render-scale-widget-grid + .neuroglancer-render-scale-widget-legend + > div { + white-space: nowrap; + flex: 1 1 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.neuroglancer-render-scale-widget-grid { + align-items: center; + flex-wrap: wrap; + column-gap: 4px; +} + +.neuroglancer-render-scale-widget-grid > canvas { + order: 3; + width: 100%; + flex: 1 1 100%; +} + +.neuroglancer-render-scale-widget-grid + .neuroglancer-render-scale-widget-prompt { + order: 1; +} + +.neuroglancer-render-scale-widget-grid + .neuroglancer-render-scale-widget-legend { + order: 2; + font-size: 10px; +} + +.neuroglancer-render-scale-widget-relative { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 10px; + margin-right: 4px; +} + +.neuroglancer-render-scale-widget-relative-checkbox { + margin: 0; +} diff --git a/src/widget/render_scale_widget.ts b/src/widget/render_scale_widget.ts index 694236fb49..1efa63ecbd 100644 --- a/src/widget/render_scale_widget.ts +++ b/src/widget/render_scale_widget.ts @@ -26,7 +26,11 @@ import { renderScaleHistogramBinSize, renderScaleHistogramOrigin, } from "#src/render_scale_statistics.js"; -import type { TrackableValueInterface } from "#src/trackable_value.js"; +import { TrackableBooleanCheckbox } from "#src/trackable_boolean.js"; +import type { + TrackableValueInterface, + WatchableValueInterface, +} from "#src/trackable_value.js"; import { WatchableValue } from "#src/trackable_value.js"; import { serializeColor } from "#src/util/color.js"; import { hsvToRgb } from "#src/util/colorspace.js"; @@ -65,6 +69,19 @@ export interface RenderScaleWidgetOptions { target: TrackableValueInterface; } +export interface SpatialSkeletonGridRenderScaleWidgetOptions { + histogram: RenderScaleHistogram; + target: TrackableValueInterface; + relative: WatchableValueInterface; + pixelSize: WatchableValueInterface; + chunkStats?: WatchableValueInterface<{ + presentCount: number; + totalCount: number; + }>; + relativeLabel?: string; + relativeTooltip?: string; +} + export class RenderScaleWidget extends RefCounted { label = document.createElement("div"); element = document.createElement("div"); @@ -74,6 +91,7 @@ export class RenderScaleWidget extends RefCounted { legendSpatialScale = document.createElement("div"); legendChunks = document.createElement("div"); protected logScaleOrigin = renderScaleHistogramOrigin; + protected logScaleBinSize = renderScaleHistogramBinSize; protected unitOfTarget = "px"; private ctx = this.canvas.getContext("2d")!; hoverTarget = new WatchableValue<[number, number] | undefined>(undefined); @@ -94,8 +112,7 @@ export class RenderScaleWidget extends RefCounted { } this.hoverTarget.value = undefined; const logScaleMax = Math.round( - this.logScaleOrigin + - numRenderScaleHistogramBins * renderScaleHistogramBinSize, + this.logScaleOrigin + numRenderScaleHistogramBins * this.logScaleBinSize, ); const targetValue = clampToInterval( [2 ** this.logScaleOrigin, 2 ** (logScaleMax - 1)], @@ -145,7 +162,11 @@ export class RenderScaleWidget extends RefCounted { const getTargetValue = (event: MouseEvent) => { const position = (event.offsetX / canvas.width) * numRenderScaleHistogramBins; - return getRenderScaleFromHistogramOffset(position, this.logScaleOrigin); + return getRenderScaleFromHistogramOffset( + position, + this.logScaleOrigin, + this.logScaleBinSize, + ); }; this.registerEventListener(canvas, "pointermove", (event: MouseEvent) => { this.hoverTarget.value = [getTargetValue(event), event.offsetY]; @@ -192,6 +213,16 @@ export class RenderScaleWidget extends RefCounted { this.target.reset(); } + protected getLegendChunkCounts( + totalPresent: number, + totalNotPresent: number, + ) { + return { + presentCount: totalPresent, + totalCount: totalPresent + totalNotPresent, + }; + } + updateView() { const { ctx } = this; const { canvas } = this; @@ -256,7 +287,11 @@ export class RenderScaleWidget extends RefCounted { let hoverSpatialScale: number | undefined = undefined; if (hoverValue !== undefined) { const i = Math.floor( - getRenderScaleHistogramOffset(hoverValue[0], this.logScaleOrigin), + getRenderScaleHistogramOffset( + hoverValue[0], + this.logScaleOrigin, + this.logScaleBinSize, + ), ); if (i >= 0 && i < numRenderScaleHistogramBins) { let sum = 0; @@ -305,10 +340,10 @@ export class RenderScaleWidget extends RefCounted { } else { this.legendSpatialScale.textContent = ""; } + const { presentCount: legendPresentCount, totalCount: legendTotalCount } = + this.getLegendChunkCounts(totalPresent, totalNotPresent); - this.legendChunks.textContent = `${totalPresent}/${ - totalPresent + totalNotPresent - }`; + this.legendChunks.textContent = `${legendPresentCount}/${legendTotalCount}`; const spatialScaleColors = sortedSpatialScales.map((spatialScale) => { const saturation = spatialScale === hoverSpatialScale ? 0.5 : 1; @@ -357,7 +392,11 @@ export class RenderScaleWidget extends RefCounted { const value = targetValue; ctx.fillStyle = "#fff"; const startOffset = binToCanvasX( - getRenderScaleHistogramOffset(value, this.logScaleOrigin), + getRenderScaleHistogramOffset( + value, + this.logScaleOrigin, + this.logScaleBinSize, + ), ); const lineWidth = 1; ctx.fillRect(Math.floor(startOffset), 0, lineWidth, height); @@ -367,7 +406,11 @@ export class RenderScaleWidget extends RefCounted { const value = hoverValue[0]; ctx.fillStyle = "#888"; const startOffset = binToCanvasX( - getRenderScaleHistogramOffset(value, this.logScaleOrigin), + getRenderScaleHistogramOffset( + value, + this.logScaleOrigin, + this.logScaleBinSize, + ), ); const lineWidth = 1; ctx.fillRect(Math.floor(startOffset), 0, lineWidth, height); @@ -384,6 +427,103 @@ export class VolumeRenderingRenderScaleWidget extends RenderScaleWidget { } } +export class SpatialSkeletonGridRenderScaleWidget extends RenderScaleWidget { + protected unitOfTarget = "nm"; + private relative?: WatchableValueInterface; + private chunkStats?: WatchableValueInterface<{ + presentCount: number; + totalCount: number; + }>; + + private syncScaleConfig() { + this.logScaleOrigin = this.histogram.logScaleOrigin; + this.logScaleBinSize = this.histogram.logScaleBinSize; + } + + constructor( + histogram: RenderScaleHistogram, + target: TrackableValueInterface, + options: { + relative?: WatchableValueInterface; + pixelSize?: WatchableValueInterface; + chunkStats?: WatchableValueInterface<{ + presentCount: number; + totalCount: number; + }>; + relativeLabel?: string; + relativeTooltip?: string; + } = {}, + ) { + super(histogram, target); + this.element.classList.add("neuroglancer-render-scale-widget-grid"); + this.syncScaleConfig(); + this.relative = options.relative; + this.chunkStats = options.chunkStats; + if (options.chunkStats !== undefined) { + this.registerDisposer( + options.chunkStats.changed.add(() => this.updateView()), + ); + } + if (options.relative !== undefined) { + const relativeTooltip = + options.relativeTooltip ?? + "Interpret the skeleton grid resolution target as relative to zoom"; + this.label.classList.add("neuroglancer-render-scale-widget-relative"); + this.label.title = relativeTooltip; + const relativeCheckbox = this.registerDisposer( + new TrackableBooleanCheckbox(options.relative, { + enabledTitle: relativeTooltip, + disabledTitle: relativeTooltip, + }), + ); + relativeCheckbox.element.classList.add( + "neuroglancer-render-scale-widget-relative-checkbox", + ); + this.label.appendChild(relativeCheckbox.element); + const relativeLabel = document.createElement("span"); + relativeLabel.textContent = options.relativeLabel ?? "Rel"; + this.label.appendChild(relativeLabel); + this.registerDisposer( + options.relative.changed.add(() => this.updateView()), + ); + if (options.pixelSize !== undefined) { + this.registerDisposer( + options.pixelSize.changed.add(() => this.updateView()), + ); + } + this.registerEventListener(this.element, "click", (event: MouseEvent) => { + if (event.target === relativeCheckbox.element) { + return; + } + event.preventDefault(); + }); + } + this.legendRenderScale.title = "Target skeleton grid spacing"; + } + + adjustViaWheel(event: WheelEvent) { + this.syncScaleConfig(); + super.adjustViaWheel(event); + } + + protected getLegendChunkCounts( + totalPresent: number, + totalNotPresent: number, + ) { + const chunkStats = this.chunkStats?.value; + if (chunkStats !== undefined) { + return chunkStats; + } + return super.getLegendChunkCounts(totalPresent, totalNotPresent); + } + + updateView() { + this.syncScaleConfig(); + this.unitOfTarget = this.relative?.value === true ? "px" : "nm"; + super.updateView(); + } +} + const TOOL_INPUT_EVENT_MAP = EventActionMap.fromObject({ "at:shift+wheel": { action: "adjust-via-wheel" }, "at:shift+dblclick0": { action: "reset" }, @@ -428,3 +568,49 @@ export function renderScaleLayerControl< }, }; } + +export function spatialSkeletonGridRenderScaleLayerControl< + LayerType extends UserLayer, +>( + getter: (layer: LayerType) => SpatialSkeletonGridRenderScaleWidgetOptions, +): LayerControlFactory { + return { + makeControl: (layer, context) => { + const { + histogram, + target, + relative, + pixelSize, + chunkStats, + relativeLabel, + relativeTooltip, + } = getter(layer); + const control = context.registerDisposer( + new SpatialSkeletonGridRenderScaleWidget(histogram, target, { + relative, + pixelSize, + chunkStats, + relativeLabel, + relativeTooltip, + }), + ); + return { control, controlElement: control.element }; + }, + activateTool: (activation, control) => { + activation.bindInputEventMap(TOOL_INPUT_EVENT_MAP); + activation.bindAction( + "adjust-via-wheel", + (event: ActionEvent) => { + event.stopPropagation(); + event.preventDefault(); + control.adjustViaWheel(event.detail); + }, + ); + activation.bindAction("reset", (event: ActionEvent) => { + event.stopPropagation(); + event.preventDefault(); + control.reset(); + }); + }, + }; +} diff --git a/typings/css.d.ts b/typings/css.d.ts new file mode 100644 index 0000000000..cbe652dbe0 --- /dev/null +++ b/typings/css.d.ts @@ -0,0 +1 @@ +declare module "*.css"; diff --git a/typings/index.d.ts b/typings/index.d.ts index 792d3785ed..f4209dc93f 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -1,2 +1,3 @@ +/// /// ///