diff --git a/docs/user-guide/skeleton_editing.rst b/docs/user-guide/skeleton_editing.rst index b08e1884f2..02317aca1c 100644 --- a/docs/user-guide/skeleton_editing.rst +++ b/docs/user-guide/skeleton_editing.rst @@ -175,6 +175,10 @@ 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**. +The skeleton tab also provides a **Find Path** inspection tool for spatially +indexed skeletons. Unlike the editing tools, **Find Path** is available for +read-only sources. + 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. @@ -183,6 +187,38 @@ An important concept throughout editing is the *selected node*. The selected nod is highlighted with a border in the viewer, highlighted in the skeleton tab table, and its details are shown in the selection details panel. +Find Path Tool +~~~~~~~~~~~~~~ + +Click **Find Path** in the skeleton tab, then left-click the source node followed +by the target node. You may also hold :kbd:`Shift` while selecting. Both +endpoints must be distinct, exact nodes in the same skeleton segment; points on +edges are not accepted. A third selection is ignored until an endpoint is +removed or the tool is cleared. The endpoint rows show each node's derived +topology type and coordinates; hover a row to see its node ID. + +The route is computed automatically after the target is selected and displayed +as a white annotation polyline. **Find Path** uses complete skeleton data already +cached in the client and does not initiate a download. A cached skeleton can be +used even if it is no longer visible. If the skeleton is not cached, make it +visible and wait for the normal visibility pipeline to load it; the route is +computed automatically when loading completes. Click **Clear** to remove the +endpoints and route. Deleting the route annotation has the same effect as +**Clear**. If a generic skeleton contains cycles, **Find Path** selects a +deterministic route with the fewest edges. + +In the 3-D view, the route is rendered as a non-pickable overlay without depth +occlusion so that the skeleton surface cannot hide it. It may therefore remain +visible where other geometry passes in front of it. + +While Find Path is active, use the middle mouse button to navigate. Control plus +left mouse provides the same trackpad-friendly navigation alternative as the +Edit tool. + +The spatial skeleton tool supports one active spatial skeleton datasource per +segmentation layer. Switching Find Path to another datasource while the layer +is loaded is not supported. Find Path state is saved with its datasource. + Edit Tool ~~~~~~~~~ diff --git a/src/annotation/annotation_layer_state.ts b/src/annotation/annotation_layer_state.ts index d2c0aa56a9..008931bd9f 100644 --- a/src/annotation/annotation_layer_state.ts +++ b/src/annotation/annotation_layer_state.ts @@ -160,6 +160,9 @@ export class AnnotationDisplayState extends RefCounted { ); ignoreNullSegmentFilter = new TrackableBoolean(true); disablePicking = new WatchableValue(false); + // Runtime-only rendering option for overlays that must remain visible even + // when their geometry lies inside an opaque surface. + disableDepthTest = new WatchableValue(false); displayUnfiltered = makeCachedLazyDerivedWatchableValue( (map, ignoreNullSegmentFilter) => { for (const state of map.values()) { diff --git a/src/annotation/renderlayer.ts b/src/annotation/renderlayer.ts index 2497562105..d3f26fb6ee 100644 --- a/src/annotation/renderlayer.ts +++ b/src/annotation/renderlayer.ts @@ -348,6 +348,9 @@ export class AnnotationLayer extends RefCounted { this.registerDisposer( displayState.shaderControls.changed.add(this.redrawNeeded.dispatch), ); + this.registerDisposer( + displayState.disableDepthTest.changed.add(this.redrawNeeded.dispatch), + ); this.registerDisposer( this.hoverState.changed.add(this.redrawNeeded.dispatch), ); diff --git a/src/datasource/catmaid/frontend.ts b/src/datasource/catmaid/frontend.ts index 1e6244a4e2..e4782323ce 100644 --- a/src/datasource/catmaid/frontend.ts +++ b/src/datasource/catmaid/frontend.ts @@ -57,6 +57,7 @@ import type { SpatiallyIndexedSkeletonNode, SpatiallyIndexedSkeletonNodeBase, } from "#src/skeleton/api.js"; +import { SkeletonDataSourceState } from "#src/skeleton/find_path.js"; import { SpatiallyIndexedSkeletonSource, SkeletonSource, @@ -321,6 +322,7 @@ export class CatmaidDataSourceProvider implements DataSourceProvider { async get(options: GetDataSourceOptions): Promise { const { providerUrl } = options; + const state = new SkeletonDataSourceState(options.state); // Remove scheme if present to handle "catmaid://" let cleanUrl = providerUrl; @@ -510,6 +512,7 @@ export class CatmaidDataSourceProvider implements DataSourceProvider { return { modelTransform: makeIdentityTransform(modelSpace), subsources, + state, }; } } diff --git a/src/layer/segmentation/index.spec.ts b/src/layer/segmentation/index.spec.ts index 8fcdf53211..4f4359d663 100644 --- a/src/layer/segmentation/index.spec.ts +++ b/src/layer/segmentation/index.spec.ts @@ -18,6 +18,7 @@ import { describe, expect, it, vi } from "vitest"; import type { RenderLayerTransform } from "#src/render_coordinate_transform.js"; import { SpatialSkeletonActions } from "#src/skeleton/command_protocol.js"; +import { SkeletonDataSourceState } from "#src/skeleton/find_path.js"; import { WatchableValue } from "#src/trackable_value.js"; if (!("WebGL2RenderingContext" in globalThis)) { @@ -41,6 +42,7 @@ const { SegmentationUserLayer } = await import( const { PerspectiveViewSpatiallyIndexedSkeletonLayer, SliceViewPanelSpatiallyIndexedSkeletonLayer, + SpatiallyIndexedSkeletonSource, } = await import("#src/skeleton/frontend.js"); const { SegmentSelectionState } = await import( @@ -748,3 +750,105 @@ describe("layer/segmentation spatial skeleton node navigation helpers", () => { expect(clearSpatialSkeletonNodeSelection).toHaveBeenCalledWith(false); }); }); + +describe("layer/segmentation spatial skeleton find-path state", () => { + const serializedFindPathState = { + source: { nodeId: "1", segmentId: "7", position: [1, 2, 3] }, + target: { nodeId: "3", segmentId: "7", position: [7, 8, 9] }, + result: [ + { nodeId: "1", position: [1, 2, 3] }, + { nodeId: "2", position: [4, 5, 6] }, + { nodeId: "3", position: [7, 8, 9] }, + ], + }; + + function makeContextTestLayer(dataSourceState: SkeletonDataSourceState) { + const context = { + skeletonLayer: { source: {} }, + state: dataSourceState.findPathState, + annotationController: {}, + }; + const layer = Object.assign( + Object.create(SegmentationUserLayer.prototype), + { + spatialSkeletonFindPathContext: context, + spatialSkeletonState: { + nodeDataVersion: new WatchableValue(0), + markNodeDataChanged: vi.fn(), + }, + }, + ); + return { layer, context }; + } + + it("selects a compatible spatial skeleton subsource", () => { + const spatialMesh = Object.create(SpatiallyIndexedSkeletonSource.prototype); + const makeLoadedSubsource = (state: unknown, mesh: unknown) => ({ + subsourceEntry: { subsource: { mesh } }, + loadedDataSource: { dataSource: { state } }, + }); + const unsupported = makeLoadedSubsource(undefined, spatialMesh); + const compatible = makeLoadedSubsource( + new SkeletonDataSourceState(), + spatialMesh, + ); + const regularSkeleton = makeLoadedSubsource( + new SkeletonDataSourceState(), + {}, + ); + const layer = Object.create(SegmentationUserLayer.prototype); + const select = (values: unknown[]) => + (layer as any).getSpatialSkeletonFindPathSubsource(values); + + expect(select([unsupported, compatible])).toBe(compatible); + expect(select([regularSkeleton])).toBeUndefined(); + }); + + it("exposes only the singular owning spatial skeleton context", () => { + const state = new SkeletonDataSourceState(); + const { layer, context } = makeContextTestLayer(state); + + expect(layer.getSpatialSkeletonFindPathContext()).toBe(context); + expect(layer.getSpatialSkeletonFindPathContext(context.skeletonLayer)).toBe( + context, + ); + expect(layer.getSpatialSkeletonFindPathContext({ source: {} })).toBe( + undefined, + ); + }); + + function makeLayerWithLoadedFindPathResult() { + const active = new SkeletonDataSourceState({ + findPath: serializedFindPathState, + }); + const inactive = new SkeletonDataSourceState({ + findPath: serializedFindPathState, + }); + const { layer } = makeContextTestLayer(active); + return { active, inactive, layer }; + } + + it("preserves restored results after a cache-only node-data notification", () => { + const { active, layer } = makeLayerWithLoadedFindPathResult(); + + layer.spatialSkeletonState.nodeDataVersion.value++; + + expect(active.findPathState.toJSON()).toEqual(serializedFindPathState); + }); + + it("invalidates only the active datasource result after a skeleton data change", () => { + const { active, inactive, layer } = makeLayerWithLoadedFindPathResult(); + + layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + + expect(active.findPathState.result).toBeUndefined(); + expect(active.findPathState.source?.nodeId).toBe(1n); + expect(active.findPathState.target?.nodeId).toBe(3n); + expect(inactive.findPathState.toJSON()).toEqual(serializedFindPathState); + expect(layer.spatialSkeletonState.markNodeDataChanged).toHaveBeenCalledWith( + { invalidateFullSkeletonCache: false }, + ); + }); +}); diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 64a7d270e1..1217e62120 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -132,6 +132,11 @@ import { showSpatialSkeletonActionError, undoSpatialSkeletonCommand, } from "#src/skeleton/commands.js"; +import { + SkeletonDataSourceState, + type SkeletonFindPathState, +} from "#src/skeleton/find_path.js"; +import { SpatialSkeletonFindPathAnnotationController } from "#src/skeleton/find_path_annotations.js"; import { PerspectiveViewSkeletonLayer, SkeletonLayer, @@ -801,6 +806,12 @@ interface SelectedSpatialSkeletonNodeInfo { sourceState?: SpatialSkeletonSourceState; } +export interface SpatialSkeletonFindPathContext { + readonly skeletonLayer: SpatiallyIndexedSkeletonLayer; + readonly state: SkeletonFindPathState; + readonly annotationController: SpatialSkeletonFindPathAnnotationController; +} + function normalizeOptionalPositiveSafeInteger(value: unknown) { if (value === undefined) return undefined; const normalized = Math.round(Number(value)); @@ -828,6 +839,9 @@ export class SegmentationUserLayer extends Base { readonly spatialSkeletonState = this.registerDisposer( new SpatialSkeletonState(), ); + private spatialSkeletonFindPathContext: + | SpatialSkeletonFindPathContext + | undefined; readonly selectedSpatialSkeletonNodeInfo = new WatchableValue< SelectedSpatialSkeletonNodeInfo | undefined >(undefined); @@ -1360,6 +1374,54 @@ export class SegmentationUserLayer extends Base { return undefined; }; + getSpatialSkeletonFindPathContext( + skeletonLayer?: SpatiallyIndexedSkeletonLayer, + ) { + const context = this.spatialSkeletonFindPathContext; + if ( + skeletonLayer !== undefined && + context?.skeletonLayer !== skeletonLayer + ) { + return undefined; + } + return context; + } + + private registerSpatialSkeletonFindPathContext( + skeletonLayer: SpatiallyIndexedSkeletonLayer, + loadedSubsource: LoadedDataSubsource, + ) { + const activated = loadedSubsource.activated; + if (activated === undefined) { + throw new Error( + "Cannot register find-path annotations for an inactive spatial skeleton source.", + ); + } + const dataSourceState = loadedSubsource.loadedDataSource.dataSource.state; + if (!(dataSourceState instanceof SkeletonDataSourceState)) { + return undefined; + } + const annotationController = activated.registerDisposer( + new SpatialSkeletonFindPathAnnotationController({ + layer: this, + loadedSubsource, + state: dataSourceState.findPathState, + }), + ); + const context: SpatialSkeletonFindPathContext = { + skeletonLayer, + state: dataSourceState.findPathState, + annotationController, + }; + this.spatialSkeletonFindPathContext = context; + activated.registerDisposer(() => { + if (this.spatialSkeletonFindPathContext === context) { + this.spatialSkeletonFindPathContext = undefined; + } + }); + return context; + } + getSpatialSkeletonChunkStats(kind: "2d" | "3d") { // 2D chunks are now handled by the same backend as 3D, so only report // under "3d" to avoid double-counting in updateSpatialSkeletonChunkLoadState. @@ -1603,6 +1665,7 @@ export class SegmentationUserLayer extends Base { markSpatialSkeletonNodeDataChanged(options?: { invalidateFullSkeletonCache?: boolean; }) { + this.spatialSkeletonFindPathContext?.state.invalidateResult(); this.spatialSkeletonState.markNodeDataChanged(options); } @@ -1631,13 +1694,30 @@ export class SegmentationUserLayer extends Base { return changed; } + private getSpatialSkeletonFindPathSubsource( + loadedSubsources: readonly LoadedDataSubsource[], + ) { + return loadedSubsources.find((loadedSubsource) => { + const { mesh } = loadedSubsource.subsourceEntry.subsource; + return ( + (mesh instanceof MultiscaleSpatiallyIndexedSkeletonSource || + mesh instanceof SpatiallyIndexedSkeletonSource) && + loadedSubsource.loadedDataSource.dataSource.state instanceof + SkeletonDataSourceState + ); + }); + } + activateDataSubsources(subsources: Iterable) { + const loadedSubsources = [...subsources]; + const findPathSubsource = + this.getSpatialSkeletonFindPathSubsource(loadedSubsources); const updatedSegmentPropertyMaps: SegmentPropertyMap[] = []; const isGroupRoot = this.displayState.linkedSegmentationGroup.root.value === this; let updatedGraph: SegmentationGraphSource | undefined; let hasVolume = false; - for (const loadedSubsource of subsources) { + for (const loadedSubsource of loadedSubsources) { if (this.addStaticAnnotations(loadedSubsource)) continue; const { volume, mesh, segmentPropertyMap, segmentationGraph, local } = loadedSubsource.subsourceEntry.subsource; @@ -1664,7 +1744,7 @@ export class SegmentationUserLayer extends Base { this.displayState.segmentationGroupState.value, ); } else if (mesh !== undefined) { - loadedSubsource.activate(() => { + const activateMeshSubsource = () => { const displayState = { ...this.displayState, transform: loadedSubsource.getRenderLayerTransform(), @@ -1717,6 +1797,12 @@ export class SegmentationUserLayer extends Base { inspectionState: this.spatialSkeletonState, }, ); + if (loadedSubsource === findPathSubsource) { + this.registerSpatialSkeletonFindPathContext( + base, + loadedSubsource, + ); + } if (perspectiveSources.length > 0) { loadedSubsource.addRenderLayer( new PerspectiveViewSpatiallyIndexedSkeletonLayer( @@ -1759,6 +1845,12 @@ export class SegmentationUserLayer extends Base { inspectionState: this.spatialSkeletonState, }, ); + if (loadedSubsource === findPathSubsource) { + this.registerSpatialSkeletonFindPathContext( + base, + loadedSubsource, + ); + } loadedSubsource.addRenderLayer( new PerspectiveViewSpatiallyIndexedSkeletonLayer(base.addRef()), ); @@ -1780,7 +1872,11 @@ export class SegmentationUserLayer extends Base { new SliceViewPanelSkeletonLayer(/* transfer ownership */ base), ); } - }, this.displayState.segmentationGroupState.value); + }; + loadedSubsource.activate( + activateMeshSubsource, + this.displayState.segmentationGroupState.value, + ); } else if (segmentPropertyMap !== undefined) { if (!isGroupRoot) { loadedSubsource.deactivate( diff --git a/src/perspective_view/panel.ts b/src/perspective_view/panel.ts index 6b3802dcc0..0c1353a9da 100644 --- a/src/perspective_view/panel.ts +++ b/src/perspective_view/panel.ts @@ -1080,14 +1080,23 @@ export class PerspectivePanel extends RenderedDataPanel { if (renderLayer.isAnnotation) { const annotationRenderLayer = renderLayer as PerspectiveViewAnnotationLayer; - if ( - annotationRenderLayer.base.state.displayState.disablePicking.value - ) { - disablePicking(); - annotationRenderLayer.draw(renderContext, attachment); - renderContext.bindFramebuffer(); - } else { - annotationRenderLayer.draw(renderContext, attachment); + const { displayState } = annotationRenderLayer.base.state; + const disableDepthTest = displayState.disableDepthTest.value; + if (disableDepthTest) { + gl.disable(WebGL2RenderingContext.DEPTH_TEST); + } + try { + if (displayState.disablePicking.value) { + disablePicking(); + annotationRenderLayer.draw(renderContext, attachment); + renderContext.bindFramebuffer(); + } else { + annotationRenderLayer.draw(renderContext, attachment); + } + } finally { + if (disableDepthTest) { + gl.enable(WebGL2RenderingContext.DEPTH_TEST); + } } } } diff --git a/src/skeleton/actions.ts b/src/skeleton/actions.ts index 94ad685874..e104ebad62 100644 --- a/src/skeleton/actions.ts +++ b/src/skeleton/actions.ts @@ -45,5 +45,9 @@ export const SKELETON_PIN_NODE = "skeleton-pin-node"; export const SKELETON_ENTER_DELETE_MODE = "skeleton-enter-delete-mode"; export const SKELETON_CLEAR_SELECTION = "skeleton-clear-node-selection"; +// --- Find Path tool actions --- +export const SKELETON_FIND_PATH_SELECT_ENDPOINT = + "skeleton-find-path-select-endpoint"; + // --- Display toggles --- export const SKELETON_TOGGLE_HIDDEN = "skeleton-toggle-hidden"; diff --git a/src/skeleton/find_path.spec.ts b/src/skeleton/find_path.spec.ts new file mode 100644 index 0000000000..bb88658bbe --- /dev/null +++ b/src/skeleton/find_path.spec.ts @@ -0,0 +1,203 @@ +/** + * @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 { + SkeletonDataSourceState, + type SkeletonFindPathEndpoint, + SkeletonFindPathState, +} from "#src/skeleton/find_path.js"; + +function endpoint( + nodeId: bigint | number, + segmentId: bigint | number = 100n, + position: ArrayLike = [ + Number(nodeId), + Number(nodeId) + 1, + Number(nodeId) + 2, + ], +): SkeletonFindPathEndpoint { + return { + nodeId: BigInt(nodeId), + segmentId: BigInt(segmentId), + position: new Float32Array(position), + }; +} + +describe("SkeletonFindPathState", () => { + it("round-trips uint64 endpoint identities in persisted state", () => { + const state = new SkeletonFindPathState(); + const largeSegmentId = 9_007_199_254_740_993n; + state.setEndpoints( + endpoint(1, largeSegmentId), + endpoint(4, largeSegmentId), + ); + state.setResult([ + { nodeId: 1n, position: new Float32Array([1, 2, 3]) }, + { nodeId: 3n, position: new Float32Array([3, 4, 5]) }, + { nodeId: 4n, position: new Float32Array([4, 5, 6]) }, + ]); + + const json = state.toJSON(); + expect(json).toEqual({ + source: { + nodeId: "1", + segmentId: largeSegmentId.toString(), + position: [1, 2, 3], + }, + target: { + nodeId: "4", + segmentId: largeSegmentId.toString(), + position: [4, 5, 6], + }, + result: [ + { nodeId: "1", position: [1, 2, 3] }, + { nodeId: "3", position: [3, 4, 5] }, + { nodeId: "4", position: [4, 5, 6] }, + ], + }); + + const restored = new SkeletonFindPathState(); + restored.restoreState(json); + expect(restored.toJSON()).toEqual(json); + expect(restored.source?.position).toBeInstanceOf(Float32Array); + expect(restored.result?.map((node) => node.nodeId)).toEqual([1n, 3n, 4n]); + }); + + it.each([ + null, + [], + { source: { nodeId: "01", segmentId: "1", position: [1, 2, 3] } }, + { source: { nodeId: "1", segmentId: "-1", position: [1, 2, 3] } }, + { + source: { + nodeId: "18446744073709551616", + segmentId: "1", + position: [1, 2, 3], + }, + }, + { source: { nodeId: "1", segmentId: "1", position: [1, 2] } }, + { source: { nodeId: "1", segmentId: "1", position: [1, NaN, 3] } }, + { result: {} }, + ])("rejects malformed serialized values %#", (json) => { + expect(() => new SkeletonFindPathState().restoreState(json)).toThrow(); + }); + + it("clears the result when either endpoint changes", () => { + const state = new SkeletonFindPathState(); + state.setEndpoints(endpoint(1), endpoint(2)); + state.setResult([endpoint(1), endpoint(2)]); + expect(state.result).toHaveLength(2); + + state.setTarget(endpoint(3)); + expect(state.result).toBeUndefined(); + }); + + it("invalidates only the result when topology changes", () => { + const state = new SkeletonFindPathState(); + state.setEndpoints(endpoint(1), endpoint(2)); + state.setResult([endpoint(1), endpoint(2)]); + + expect(state.invalidateResult()).toBe(true); + expect(state.source?.nodeId).toBe(1n); + expect(state.target?.nodeId).toBe(2n); + expect(state.result).toBeUndefined(); + }); + + it("clear and reset return the state to its default", () => { + const state = new SkeletonFindPathState(); + state.setEndpoints(endpoint(1), endpoint(2)); + state.setResult([endpoint(1), endpoint(2)]); + + expect(state.clear()).toBe(true); + expect(state.toJSON()).toBeUndefined(); + expect(state.clear()).toBe(false); + + state.setSource(endpoint(3)); + state.reset(); + expect(state.toJSON()).toBeUndefined(); + }); + + it("leaves endpoint relationship validation to the tool", () => { + const state = new SkeletonFindPathState(); + expect(() => + state.setEndpoints(endpoint(1, 10), endpoint(1, 11)), + ).not.toThrow(); + }); + + it("preserves the other endpoint when one endpoint is removed", () => { + const state = new SkeletonFindPathState(); + state.setEndpoints(endpoint(1), endpoint(2)); + state.setSource(undefined); + + const restored = new SkeletonFindPathState(); + restored.restoreState(state.toJSON()); + expect(restored.source).toBeUndefined(); + expect(restored.target?.nodeId).toBe(2n); + }); +}); + +describe("SkeletonDataSourceState", () => { + it("omits empty state before use and after Clear", () => { + const state = new SkeletonDataSourceState(); + + expect(state.toJSON()).toBeUndefined(); + + state.findPathState.setSource(endpoint(1)); + expect(state.toJSON()?.findPath?.source?.nodeId).toBe("1"); + + state.findPathState.clear(); + expect(state.toJSON()).toBeUndefined(); + }); + + it("round-trips Find Path under the datasource-owned findPath key", () => { + const state = new SkeletonDataSourceState(); + state.findPathState.setEndpoints(endpoint(1, 7), endpoint(3, 7)); + + expect(state.toJSON()).toEqual({ + findPath: { + source: { nodeId: "1", segmentId: "7", position: [1, 2, 3] }, + target: { nodeId: "3", segmentId: "7", position: [3, 4, 5] }, + }, + }); + + const restored = new SkeletonDataSourceState(state.toJSON()); + expect(restored.toJSON()).toEqual(state.toJSON()); + }); + + it("forwards nested changes", () => { + const state = new SkeletonDataSourceState(); + let changes = 0; + state.changed.add(() => ++changes); + state.findPathState.setEndpoints(endpoint(1), endpoint(2)); + state.findPathState.setResult([endpoint(1), endpoint(2)]); + + expect(changes).toBe(3); + expect(state.toJSON()?.findPath?.result).toHaveLength(2); + }); + + it("rejects malformed datasource state", () => { + expect( + () => + new SkeletonDataSourceState({ + findPath: { + source: { nodeId: "-1", segmentId: "1", position: [1, 2, 3] }, + }, + }), + ).toThrow(); + }); +}); diff --git a/src/skeleton/find_path.ts b/src/skeleton/find_path.ts new file mode 100644 index 0000000000..d0d7b499ff --- /dev/null +++ b/src/skeleton/find_path.ts @@ -0,0 +1,272 @@ +/** + * @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 { TrackableValue } from "#src/trackable_value.js"; +import { RefCounted } from "#src/util/disposable.js"; +import { + parseArray, + parseUint64, + verify3dVec, + verifyObject, + verifyObjectProperty, + verifyOptionalObjectProperty, +} from "#src/util/json.js"; +import { NullarySignal } from "#src/util/signal.js"; +import type { Trackable } from "#src/util/trackable.js"; + +/** + * Representation-neutral endpoint identity for skeleton path finding. + * + * Spatial skeletons use their node and segment IDs. A regular skeleton can + * use its object ID as `segmentId` and a stable vertex index as `nodeId`. + */ +export interface SkeletonFindPathEndpoint { + readonly nodeId: bigint; + readonly segmentId: bigint; + readonly position: Float32Array; +} + +export interface SkeletonFindPathResultNode { + readonly nodeId: bigint; + readonly position: Float32Array; +} + +export interface SkeletonFindPathEndpointJson { + nodeId: string; + segmentId: string; + position: number[]; +} + +export interface SkeletonFindPathResultNodeJson { + nodeId: string; + position: number[]; +} + +export interface SkeletonFindPathStateJson { + source?: SkeletonFindPathEndpointJson; + target?: SkeletonFindPathEndpointJson; + result?: SkeletonFindPathResultNodeJson[]; +} + +export interface SkeletonDataSourceStateJson { + findPath?: SkeletonFindPathStateJson; +} + +function restoreEndpoint(value: unknown): SkeletonFindPathEndpoint { + return { + nodeId: verifyObjectProperty(value, "nodeId", parseUint64), + segmentId: verifyObjectProperty(value, "segmentId", parseUint64), + position: verifyObjectProperty(value, "position", verify3dVec), + }; +} + +function restoreResultNode(value: unknown): SkeletonFindPathResultNode { + return { + nodeId: verifyObjectProperty(value, "nodeId", parseUint64), + position: verifyObjectProperty(value, "position", verify3dVec), + }; +} + +function endpointToJson( + endpoint: SkeletonFindPathEndpoint, +): SkeletonFindPathEndpointJson { + return { + nodeId: endpoint.nodeId.toString(), + segmentId: endpoint.segmentId.toString(), + position: Array.from(endpoint.position), + }; +} + +function resultNodeToJson( + node: SkeletonFindPathResultNode, +): SkeletonFindPathResultNodeJson { + return { + nodeId: node.nodeId.toString(), + position: Array.from(node.position), + }; +} + +/** + * Serializable state shared by skeleton find-path implementations. + * + * Source ownership belongs to the skeleton datasource containing this object, + * matching Graphene's datasource-owned state model. Selection constraints are + * enforced by the tool that populates this state. + */ +export class SkeletonFindPathState extends RefCounted implements Trackable { + readonly changed = new NullarySignal(); + + private readonly sourceValue = new TrackableValue< + SkeletonFindPathEndpoint | undefined + >(undefined, (value) => value); + private readonly targetValue = new TrackableValue< + SkeletonFindPathEndpoint | undefined + >(undefined, (value) => value); + private readonly resultValue = new TrackableValue< + readonly SkeletonFindPathResultNode[] | undefined + >(undefined, (value) => value); + + constructor() { + super(); + this.registerDisposer( + this.sourceValue.changed.add(() => { + this.resultValue.reset(); + this.changed.dispatch(); + }), + ); + this.registerDisposer( + this.targetValue.changed.add(() => { + this.resultValue.reset(); + this.changed.dispatch(); + }), + ); + this.registerDisposer(this.resultValue.changed.add(this.changed.dispatch)); + } + + get source() { + return this.sourceValue.value; + } + + get target() { + return this.targetValue.value; + } + + get result() { + return this.resultValue.value; + } + + setSource(value: SkeletonFindPathEndpoint | undefined): boolean { + if (this.source === value) return false; + this.sourceValue.value = value; + return true; + } + + setTarget(value: SkeletonFindPathEndpoint | undefined): boolean { + if (this.target === value) return false; + this.targetValue.value = value; + return true; + } + + setEndpoints( + source: SkeletonFindPathEndpoint | undefined, + target: SkeletonFindPathEndpoint | undefined, + ): boolean { + const changed = this.source !== source || this.target !== target; + this.sourceValue.value = source; + this.targetValue.value = target; + return changed; + } + + setResult(value: readonly SkeletonFindPathResultNode[] | undefined): boolean { + if (this.result === value) return false; + this.resultValue.value = value; + return true; + } + + /** Clears only the resolved result while preserving both endpoints. */ + invalidateResult(): boolean { + return this.setResult(undefined); + } + + /** Clears the endpoints and resolved result. */ + clear(): boolean { + const changed = + this.source !== undefined || + this.target !== undefined || + this.result !== undefined; + this.sourceValue.reset(); + this.targetValue.reset(); + this.resultValue.reset(); + return changed; + } + + reset(): void { + this.clear(); + } + + toJSON(): SkeletonFindPathStateJson | undefined { + const { source, target, result } = this; + if (source === undefined && target === undefined && result === undefined) { + return undefined; + } + return { + source: source === undefined ? undefined : endpointToJson(source), + target: target === undefined ? undefined : endpointToJson(target), + result: result === undefined ? undefined : result.map(resultNodeToJson), + }; + } + + restoreState(value: unknown): void { + if (value === undefined) { + this.reset(); + return; + } + const obj = verifyObject(value); + this.sourceValue.value = verifyOptionalObjectProperty( + obj, + "source", + restoreEndpoint, + ); + this.targetValue.value = verifyOptionalObjectProperty( + obj, + "target", + restoreEndpoint, + ); + this.resultValue.value = verifyOptionalObjectProperty( + obj, + "result", + (result) => parseArray(result, restoreResultNode), + ); + } +} + +/** + * Skeleton-tool state owned by a single datasource. + * + * Keeping this container representation-neutral mirrors Graphene's state + * model and lets future regular-skeleton datasources reuse Find Path without + * adding segmentation-layer state or source locators. + */ +export class SkeletonDataSourceState extends RefCounted implements Trackable { + readonly changed = new NullarySignal(); + readonly findPathState = this.registerDisposer(new SkeletonFindPathState()); + + constructor(value?: unknown) { + super(); + this.registerDisposer( + this.findPathState.changed.add(this.changed.dispatch), + ); + if (value !== undefined) { + this.restoreState(value); + } + } + + reset() { + this.findPathState.reset(); + } + + toJSON(): SkeletonDataSourceStateJson | undefined { + const findPath = this.findPathState.toJSON(); + return findPath === undefined ? undefined : { findPath }; + } + + restoreState(value: unknown) { + const obj = verifyObject(value); + verifyOptionalObjectProperty(obj, "findPath", (findPath) => { + this.findPathState.restoreState(findPath); + }); + } +} diff --git a/src/skeleton/find_path_annotations.spec.ts b/src/skeleton/find_path_annotations.spec.ts new file mode 100644 index 0000000000..07249c13fc --- /dev/null +++ b/src/skeleton/find_path_annotations.spec.ts @@ -0,0 +1,341 @@ +/** + * @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 { AnnotationType } from "#src/annotation/index.js"; +import { + makeCoordinateSpace, + makeIdentityTransform, + WatchableCoordinateSpaceTransform, +} from "#src/coordinate_transform.js"; +import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; +import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; +import { + type SkeletonFindPathEndpoint, + SkeletonFindPathState, +} from "#src/skeleton/find_path.js"; +import { + SpatialSkeletonFindPathAnnotationController, + SPATIAL_SKELETON_FIND_PATH_RESULT_DESCRIPTION, + SPATIAL_SKELETON_FIND_PATH_SOURCE_DESCRIPTION, + SPATIAL_SKELETON_FIND_PATH_TARGET_DESCRIPTION, +} from "#src/skeleton/find_path_annotations.js"; +import { WatchableValue } from "#src/trackable_value.js"; +import { NullarySignal } from "#src/util/signal.js"; + +vi.hoisted(() => { + const WebGL2RenderingContextStub = new Proxy(class {}, { + get(target, property, receiver) { + return Reflect.get(target, property, receiver) ?? 0; + }, + }); + Object.defineProperty(globalThis, "WebGL2RenderingContext", { + configurable: true, + value: WebGL2RenderingContextStub, + }); +}); + +function endpoint( + nodeId: number, + segmentId = 100, + position: readonly number[] = [nodeId, nodeId + 1, nodeId + 2], +): SkeletonFindPathEndpoint { + return { + nodeId: BigInt(nodeId), + segmentId: BigInt(segmentId), + position: new Float32Array(position), + }; +} + +function setResolvedPath(state: SkeletonFindPathState) { + state.setEndpoints(endpoint(1), endpoint(3)); + state.setResult([ + { nodeId: 1n, position: new Float32Array([1, 2, 3]) }, + { nodeId: 2n, position: new Float32Array([2, 3, 4]) }, + { nodeId: 3n, position: new Float32Array([3, 4, 5]) }, + ]); +} + +function makeFixture(state: SkeletonFindPathState) { + const coordinateSpace = makeCoordinateSpace({ + names: ["x", "y", "z"], + units: ["m", "m", "m"], + scales: Float64Array.of(1, 1, 1), + }); + const transform = new WatchableCoordinateSpaceTransform( + makeIdentityTransform(coordinateSpace), + ); + const visibleSegments = { + size: 0, + changed: new NullarySignal(), + }; + const displayState = { + segmentationGroupState: new WatchableValue({ visibleSegments }), + }; + let addedState: unknown; + let addedSubsource: unknown; + const layer = { + displayState, + localPosition: new WatchableValue(new Float32Array(3)), + addAnnotationLayerState(stateValue: unknown, subsourceValue: unknown) { + addedState = stateValue; + addedSubsource = subsourceValue; + }, + } as unknown as SegmentationUserLayer; + const loadedSubsource = { + loadedDataSource: { + transform, + layerDataSource: { name: "test data source" }, + }, + subsourceEntry: { id: "skeletons" }, + subsourceIndex: 4, + getRenderLayerTransform: () => + new WatchableValue({ error: new Error("Unused test transform") }), + } as unknown as LoadedDataSubsource; + const controller = new SpatialSkeletonFindPathAnnotationController({ + layer, + loadedSubsource, + state, + }); + return { + controller, + layer, + loadedSubsource, + transform, + get addedState() { + return addedState; + }, + get addedSubsource() { + return addedSubsource; + }, + }; +} + +describe("SpatialSkeletonFindPathAnnotationController", () => { + it("adds an independent non-pickable white overlay for the loaded subsource", () => { + const state = new SkeletonFindPathState(); + const fixture = makeFixture(state); + const { controller } = fixture; + + expect(fixture.addedState).toBe(controller.annotationState); + expect(fixture.addedSubsource).toBe(fixture.loadedSubsource); + expect(controller.annotationState.subsourceId).toBe("skeletons"); + expect(controller.annotationState.subsourceIndex).toBe(4); + expect(controller.annotationState.subsubsourceId).toBe( + "spatialSkeletonFindPath", + ); + expect(controller.annotationSource.relationships).toEqual([ + "associated segments", + ]); + expect( + Array.from(controller.annotationState.displayState.color.value), + ).toEqual([1, 1, 1]); + expect(controller.annotationState.displayState.disablePicking.value).toBe( + true, + ); + expect(controller.annotationState.displayState.disableDepthTest.value).toBe( + true, + ); + const relationship = + controller.annotationState.displayState.relationshipStates.get( + "associated segments", + ); + expect(relationship?.segmentationState.value).toBe( + fixture.layer.displayState, + ); + expect(relationship?.showMatches.value).toBe(false); + + controller.dispose(); + state.dispose(); + }); + + it("renders labeled endpoints and the ordered route for its source", () => { + const state = new SkeletonFindPathState(); + setResolvedPath(state); + const { controller } = makeFixture(state); + + const annotations = Array.from(controller.annotationSource); + expect(annotations).toHaveLength(3); + const source = annotations.find( + (annotation) => + annotation.description === + SPATIAL_SKELETON_FIND_PATH_SOURCE_DESCRIPTION, + ); + const target = annotations.find( + (annotation) => + annotation.description === + SPATIAL_SKELETON_FIND_PATH_TARGET_DESCRIPTION, + ); + const result = annotations.find( + (annotation) => + annotation.description === + SPATIAL_SKELETON_FIND_PATH_RESULT_DESCRIPTION, + ); + expect(source?.type).toBe(AnnotationType.POINT); + expect(target?.type).toBe(AnnotationType.POINT); + expect(result?.type).toBe(AnnotationType.POLYLINE); + if ( + source?.type !== AnnotationType.POINT || + target?.type !== AnnotationType.POINT || + result?.type !== AnnotationType.POLYLINE + ) { + throw new Error("Expected source, target, and result annotations."); + } + expect(Array.from(source.point)).toEqual([1, 2, 3]); + expect(Array.from(target.point)).toEqual([3, 4, 5]); + expect(result.points.map((point) => Array.from(point))).toEqual([ + [1, 2, 3], + [2, 3, 4], + [3, 4, 5], + ]); + for (const annotation of annotations) { + expect(annotation.id).not.toBe(""); + expect( + annotation.relatedSegments?.map((segments) => Array.from(segments)), + ).toEqual([[100n]]); + } + expect(state.toJSON()).not.toHaveProperty("annotationReference"); + + controller.dispose(); + state.dispose(); + }); + + it("keeps annotations and transforms isolated between datasource states", () => { + const firstState = new SkeletonFindPathState(); + const secondState = new SkeletonFindPathState(); + firstState.setSource(endpoint(1, 100, [1, 2, 3])); + secondState.setSource(endpoint(8, 200, [8, 9, 10])); + const first = makeFixture(firstState); + const second = makeFixture(secondState); + + const firstAnnotation = Array.from(first.controller.annotationSource)[0]; + const secondAnnotation = Array.from(second.controller.annotationSource)[0]; + expect(firstAnnotation.type).toBe(AnnotationType.POINT); + expect(secondAnnotation.type).toBe(AnnotationType.POINT); + if ( + firstAnnotation.type !== AnnotationType.POINT || + secondAnnotation.type !== AnnotationType.POINT + ) { + throw new Error("Expected isolated source point annotations."); + } + expect(Array.from(firstAnnotation.point)).toEqual([1, 2, 3]); + expect(Array.from(secondAnnotation.point)).toEqual([8, 9, 10]); + expect(first.controller.annotationSource.watchableTransform).toBe( + first.transform, + ); + expect(second.controller.annotationSource.watchableTransform).toBe( + second.transform, + ); + expect(first.controller.annotationSource.watchableTransform).not.toBe( + second.controller.annotationSource.watchableTransform, + ); + + first.controller.dispose(); + second.controller.dispose(); + firstState.dispose(); + secondState.dispose(); + }); + + it("replaces an endpoint annotation when its state changes", () => { + const state = new SkeletonFindPathState(); + setResolvedPath(state); + const { controller } = makeFixture(state); + const sourceBefore = Array.from(controller.annotationSource).find( + (annotation) => + annotation.description === + SPATIAL_SKELETON_FIND_PATH_SOURCE_DESCRIPTION, + ); + expect(sourceBefore).toBeDefined(); + + state.setSource(endpoint(11, 100, [11, 12, 13])); + const sourceAfter = Array.from(controller.annotationSource).find( + (annotation) => + annotation.description === + SPATIAL_SKELETON_FIND_PATH_SOURCE_DESCRIPTION, + ); + expect(sourceAfter?.id).not.toBe(sourceBefore?.id); + expect(sourceAfter?.type).toBe(AnnotationType.POINT); + if (sourceAfter?.type !== AnnotationType.POINT) { + throw new Error("Expected the replacement source point annotation."); + } + expect(Array.from(sourceAfter.point)).toEqual([11, 12, 13]); + expect(state.target?.nodeId).toBe(3n); + + controller.dispose(); + state.dispose(); + }); + + it("maps user deletions back to endpoint and result state", () => { + const state = new SkeletonFindPathState(); + setResolvedPath(state); + const { controller } = makeFixture(state); + + const sourceAnnotation = Array.from(controller.annotationSource).find( + (annotation) => + annotation.description === + SPATIAL_SKELETON_FIND_PATH_SOURCE_DESCRIPTION, + )!; + const sourceReference = controller.annotationSource.getReference( + sourceAnnotation.id, + ); + controller.annotationSource.delete(sourceReference); + sourceReference.dispose(); + expect(state.source).toBeUndefined(); + expect(state.target?.nodeId).toBe(3n); + expect(state.result).toBeUndefined(); + expect( + Array.from(controller.annotationSource).map( + (annotation) => annotation.description, + ), + ).toEqual([SPATIAL_SKELETON_FIND_PATH_TARGET_DESCRIPTION]); + + state.setSource(endpoint(1)); + state.setResult([ + { nodeId: 1n, position: new Float32Array([1, 2, 3]) }, + { nodeId: 3n, position: new Float32Array([3, 4, 5]) }, + ]); + const resultAnnotation = Array.from(controller.annotationSource).find( + (annotation) => + annotation.description === + SPATIAL_SKELETON_FIND_PATH_RESULT_DESCRIPTION, + )!; + const resultReference = controller.annotationSource.getReference( + resultAnnotation.id, + ); + controller.annotationSource.delete(resultReference); + resultReference.dispose(); + expect(state.source).toBeUndefined(); + expect(state.target).toBeUndefined(); + expect(state.result).toBeUndefined(); + expect(Array.from(controller.annotationSource)).toHaveLength(0); + + controller.dispose(); + state.dispose(); + }); + + it("does not clear persisted state on disposal", () => { + const state = new SkeletonFindPathState(); + setResolvedPath(state); + const persistedState = state.toJSON(); + const { controller } = makeFixture(state); + + controller.dispose(); + + expect(state.toJSON()).toEqual(persistedState); + state.dispose(); + }); +}); diff --git a/src/skeleton/find_path_annotations.ts b/src/skeleton/find_path_annotations.ts new file mode 100644 index 0000000000..ec302d6c6e --- /dev/null +++ b/src/skeleton/find_path_annotations.ts @@ -0,0 +1,228 @@ +/** + * @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 { + AnnotationDisplayState, + AnnotationLayerState, +} from "#src/annotation/annotation_layer_state.js"; +import { + type AnnotationReference, + AnnotationType, + LocalAnnotationSource, + type Point, + type PolyLine, +} from "#src/annotation/index.js"; +import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; +import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; +import { RenderLayerRole } from "#src/renderlayer.js"; +import { + type SkeletonFindPathEndpoint, + type SkeletonFindPathResultNode, + type SkeletonFindPathState, +} from "#src/skeleton/find_path.js"; +import { TrackableBoolean } from "#src/trackable_boolean.js"; +import { WatchableValue } from "#src/trackable_value.js"; +import { RefCounted } from "#src/util/disposable.js"; + +const ASSOCIATED_SEGMENTS_RELATIONSHIP = "associated segments"; +const SPATIAL_SKELETON_FIND_PATH_SUBSOURCE_ID = "spatialSkeletonFindPath"; + +export const SPATIAL_SKELETON_FIND_PATH_SOURCE_DESCRIPTION = "find path source"; +export const SPATIAL_SKELETON_FIND_PATH_TARGET_DESCRIPTION = "find path target"; +export const SPATIAL_SKELETON_FIND_PATH_RESULT_DESCRIPTION = "find path result"; + +export interface SpatialSkeletonFindPathAnnotationControllerOptions { + layer: SegmentationUserLayer; + loadedSubsource: LoadedDataSubsource; + state: SkeletonFindPathState; +} + +interface RenderedEndpoint { + endpoint: SkeletonFindPathEndpoint; + annotationReference: AnnotationReference; +} + +/** + * Projects persisted spatial-skeleton Find Path state into local annotations + * for one loaded subsource. Annotation references remain runtime-only. + */ +export class SpatialSkeletonFindPathAnnotationController extends RefCounted { + readonly annotationSource: LocalAnnotationSource; + readonly annotationState: AnnotationLayerState; + + private renderedSource: RenderedEndpoint | undefined; + private renderedTarget: RenderedEndpoint | undefined; + private resultReference: AnnotationReference | undefined; + private synchronizing = false; + + constructor( + private readonly options: SpatialSkeletonFindPathAnnotationControllerOptions, + ) { + super(); + + const { layer, loadedSubsource, state } = options; + const annotationSource = new LocalAnnotationSource( + loadedSubsource.loadedDataSource.transform, + new WatchableValue([]), + [ASSOCIATED_SEGMENTS_RELATIONSHIP], + ); + this.annotationSource = annotationSource; + + const displayState = new AnnotationDisplayState(); + displayState.color.value.set([1, 1, 1]); + // Skeleton tubes write their front-surface depth, while these co-located + // annotations follow the centerline behind that surface. Render Find Path + // as a non-pickable overlay so it stays visible without moving the exact + // route geometry or intercepting the node picks used by the tool. + displayState.disablePicking.value = true; + displayState.disableDepthTest.value = true; + displayState.relationshipStates.set(ASSOCIATED_SEGMENTS_RELATIONSHIP, { + segmentationState: new WatchableValue(layer.displayState), + showMatches: new TrackableBoolean(false), + }); + + const annotationState = new AnnotationLayerState({ + localPosition: layer.localPosition, + transform: loadedSubsource.getRenderLayerTransform(), + source: annotationSource, + displayState, + dataSource: loadedSubsource.loadedDataSource.layerDataSource, + subsourceIndex: loadedSubsource.subsourceIndex, + subsourceId: loadedSubsource.subsourceEntry.id, + subsubsourceId: SPATIAL_SKELETON_FIND_PATH_SUBSOURCE_ID, + role: RenderLayerRole.ANNOTATION, + }); + annotationState.registerDisposer(displayState); + this.annotationState = this.registerDisposer(annotationState); + layer.addAnnotationLayerState(annotationState, loadedSubsource); + + this.registerDisposer( + annotationSource.childDeleted.add((annotationId) => { + this.handleAnnotationDeleted(annotationId); + }), + ); + this.registerDisposer(state.changed.add(() => this.synchronize())); + this.synchronize(); + } + + private deleteAnnotation(reference: AnnotationReference) { + this.annotationSource.delete(reference); + reference.dispose(); + } + + private synchronizeEndpoint( + rendered: RenderedEndpoint | undefined, + endpoint: SkeletonFindPathEndpoint | undefined, + description: string, + ): RenderedEndpoint | undefined { + if (rendered?.endpoint === endpoint) return rendered; + if (rendered !== undefined) { + this.deleteAnnotation(rendered.annotationReference); + } + if (endpoint === undefined) return undefined; + + const annotation: Point = { + id: "", + point: endpoint.position, + type: AnnotationType.POINT, + properties: [], + relatedSegments: [BigUint64Array.of(endpoint.segmentId)], + description, + }; + return { + endpoint, + annotationReference: this.annotationSource.add(annotation), + }; + } + + private synchronizeResult( + result: readonly SkeletonFindPathResultNode[] | undefined, + segmentId: bigint | undefined, + ) { + if (this.resultReference !== undefined) { + this.deleteAnnotation(this.resultReference); + this.resultReference = undefined; + } + if (result === undefined || result.length < 2 || segmentId === undefined) { + return; + } + + const annotation: PolyLine = { + id: "", + type: AnnotationType.POLYLINE, + points: result.map((node) => node.position), + properties: [], + relatedSegments: [BigUint64Array.of(segmentId)], + description: SPATIAL_SKELETON_FIND_PATH_RESULT_DESCRIPTION, + }; + this.resultReference = this.annotationSource.add(annotation); + } + + private synchronize() { + if (this.synchronizing) return; + this.synchronizing = true; + try { + const { source, target, result } = this.options.state; + this.renderedSource = this.synchronizeEndpoint( + this.renderedSource, + source, + SPATIAL_SKELETON_FIND_PATH_SOURCE_DESCRIPTION, + ); + this.renderedTarget = this.synchronizeEndpoint( + this.renderedTarget, + target, + SPATIAL_SKELETON_FIND_PATH_TARGET_DESCRIPTION, + ); + this.synchronizeResult(result, source?.segmentId ?? target?.segmentId); + } finally { + this.synchronizing = false; + } + } + + private handleAnnotationDeleted(annotationId: string) { + if (this.synchronizing) return; + + const { state } = this.options; + if (this.renderedSource?.annotationReference.id === annotationId) { + this.renderedSource.annotationReference.dispose(); + this.renderedSource = undefined; + state.setSource(undefined); + return; + } + if (this.renderedTarget?.annotationReference.id === annotationId) { + this.renderedTarget.annotationReference.dispose(); + this.renderedTarget = undefined; + state.setTarget(undefined); + return; + } + if (this.resultReference?.id === annotationId) { + this.resultReference.dispose(); + this.resultReference = undefined; + state.clear(); + } + } + + disposed() { + this.synchronizing = true; + this.renderedSource?.annotationReference.dispose(); + this.renderedTarget?.annotationReference.dispose(); + this.resultReference?.dispose(); + this.renderedSource = undefined; + this.renderedTarget = undefined; + this.resultReference = undefined; + super.disposed(); + } +} diff --git a/src/skeleton/navigation_graph.spec.ts b/src/skeleton/navigation_graph.spec.ts index c38e30ab5e..585194595e 100644 --- a/src/skeleton/navigation_graph.spec.ts +++ b/src/skeleton/navigation_graph.spec.ts @@ -26,6 +26,7 @@ import { getNextCollapsedLevelNode, getOpenLeaves, getParentNode, + getPathBetweenNodes, getSkeletonRootNode, } from "#src/skeleton/navigation_graph.js"; @@ -189,6 +190,76 @@ describe("skeleton/navigation", () => { expect(getChildNode(graph, 11)).toBeUndefined(); }); + describe("getPathBetweenNodes", () => { + const getNodeIds = (path: ReturnType) => + path?.map(({ nodeId }) => nodeId); + + it("returns endpoint-inclusive paths across direct edges and branches", () => { + expect(getNodeIds(getPathBetweenNodes(graph, 5, 6))).toEqual([5, 6]); + expect(getNodeIds(getPathBetweenNodes(graph, 6, 7))).toEqual([ + 6, 5, 4, 3, 7, + ]); + }); + + it("finds the same route in reverse order", () => { + expect(getNodeIds(getPathBetweenNodes(graph, 7, 6))).toEqual([ + 7, 3, 4, 5, 6, + ]); + }); + + it("returns a singleton path only for an existing node", () => { + const path = getPathBetweenNodes(graph, 3, 3); + expect(getNodeIds(path)).toEqual([3]); + expect(path?.[0].position).toBe(graph.nodeById.get(3)?.position); + expect(getPathBetweenNodes(graph, 99, 99)).toBeUndefined(); + }); + + it("returns undefined for missing or disconnected nodes", () => { + const disconnectedGraph = buildSpatiallyIndexedSkeletonNavigationGraph([ + makeNode(1, undefined), + makeNode(2, 1), + makeNode(3, undefined), + makeNode(4, 3), + ]); + + expect(getPathBetweenNodes(disconnectedGraph, 1, 99)).toBeUndefined(); + expect(getPathBetweenNodes(disconnectedGraph, 99, 1)).toBeUndefined(); + expect(getPathBetweenNodes(disconnectedGraph, 1, 4)).toBeUndefined(); + }); + + it("is cycle-safe and chooses deterministic shortest paths", () => { + const cycleGraph = buildSpatiallyIndexedSkeletonNavigationGraph([ + makeNode(1, 4), + makeNode(2, 1), + makeNode(3, 2), + makeNode(4, 3), + ]); + + // Both 1-2-3 and 1-4-3 are shortest paths. Node 2 is visited first. + expect(getNodeIds(getPathBetweenNodes(cycleGraph, 1, 3))).toEqual([ + 1, 2, 3, + ]); + // The ascending-neighbor tie break applies independently in reverse. + expect(getNodeIds(getPathBetweenNodes(cycleGraph, 3, 1))).toEqual([ + 3, 2, 1, + ]); + }); + + it("handles long chains iteratively", () => { + const nodeCount = 10_000; + const chainGraph = buildSpatiallyIndexedSkeletonNavigationGraph( + Array.from({ length: nodeCount }, (_, index) => + makeNode(index + 1, index === 0 ? undefined : index), + ), + ); + + const path = getPathBetweenNodes(chainGraph, 1, nodeCount); + expect(path).toHaveLength(nodeCount); + expect(path?.[0].nodeId).toBe(1); + expect(path?.[nodeCount - 1].nodeId).toBe(nodeCount); + }); + }); + it("cycles through collapsed-level nodes and skips regular nodes", () => { const collapsedGraph = buildSpatiallyIndexedSkeletonNavigationGraph([ makeNode(1, undefined), diff --git a/src/skeleton/navigation_graph.ts b/src/skeleton/navigation_graph.ts index c707ad8597..9bac4ddd2e 100644 --- a/src/skeleton/navigation_graph.ts +++ b/src/skeleton/navigation_graph.ts @@ -562,6 +562,65 @@ export function getChildNode( : getNodeTarget(graph, childNodeId); } +/** + * Finds a shortest path between two nodes, treating parent/child links as + * undirected edges. + * + * Neighbors are visited in ascending node ID order so that the result is + * deterministic when multiple shortest paths exist. + */ +export function getPathBetweenNodes( + graph: SpatiallyIndexedSkeletonNavigationGraph, + sourceNodeId: number, + targetNodeId: number, +): SpatiallyIndexedSkeletonNavigationTarget[] | undefined { + if (!graph.nodeById.has(sourceNodeId) || !graph.nodeById.has(targetNodeId)) { + return undefined; + } + if (sourceNodeId === targetNodeId) { + return [getNodeTarget(graph, sourceNodeId)]; + } + + const predecessorByNodeId = new Map([ + [sourceNodeId, undefined], + ]); + const queue = [sourceNodeId]; + for (let queueIndex = 0; queueIndex < queue.length; ++queueIndex) { + const currentNodeId = queue[queueIndex]; + const neighborNodeIds = new Set( + getChildNodeIds(graph, currentNodeId), + ); + const parentNodeId = getParentNodeId(graph, currentNodeId); + if (parentNodeId !== undefined) { + neighborNodeIds.add(parentNodeId); + } + + for (const neighborNodeId of [...neighborNodeIds].sort((a, b) => a - b)) { + if ( + !graph.nodeById.has(neighborNodeId) || + predecessorByNodeId.has(neighborNodeId) + ) { + continue; + } + predecessorByNodeId.set(neighborNodeId, currentNodeId); + if (neighborNodeId === targetNodeId) { + const pathNodeIds = [targetNodeId]; + let pathNodeId = currentNodeId; + while (pathNodeId !== sourceNodeId) { + pathNodeIds.push(pathNodeId); + pathNodeId = predecessorByNodeId.get(pathNodeId)!; + } + pathNodeIds.push(sourceNodeId); + pathNodeIds.reverse(); + return pathNodeIds.map((nodeId) => getNodeTarget(graph, nodeId)); + } + queue.push(neighborNodeId); + } + } + + return undefined; +} + export function getRandomChildNode( graph: SpatiallyIndexedSkeletonNavigationGraph, nodeId: number, diff --git a/src/ui/default_input_event_bindings.ts b/src/ui/default_input_event_bindings.ts index 356d2e3469..40356952fd 100644 --- a/src/ui/default_input_event_bindings.ts +++ b/src/ui/default_input_event_bindings.ts @@ -22,6 +22,7 @@ import { SKELETON_ENTER_DELETE_MODE, SKELETON_ENTER_MERGE_MODE, SKELETON_ENTER_SPLIT_MODE, + SKELETON_FIND_PATH_SELECT_ENDPOINT, SKELETON_GO_BRANCH_END, SKELETON_GO_BRANCH_START, SKELETON_GO_CHILD, @@ -243,33 +244,54 @@ export function getDefaultSkeletonListBindings() { } let defaultSkeletonEditToolBindings: EventActionMap | undefined; -export function getDefaultSkeletonEditToolBindings() { - if (defaultSkeletonEditToolBindings === undefined) { - defaultSkeletonEditToolBindings = EventActionMap.fromObject({ +let defaultSkeletonToolNavigationBindings: EventActionMap | undefined; + +export function getDefaultSkeletonToolNavigationBindings() { + if (defaultSkeletonToolNavigationBindings === undefined) { + defaultSkeletonToolNavigationBindings = EventActionMap.fromObject({ "at:mousedown1": "rotate-via-mouse-drag", "at:control+mousedown1": "translate-via-mouse-drag", - // Trackpad-friendly aliases for the middle-mouse scheme above: on - // perspective panels these dispatch here; on slice panels they're - // intercepted directly in the capture-phase listener in - // skeleton_edit_tools.ts before they can bubble to this map (mirrors - // how mousedown1 is handled for slice panels). "at:control+mousedown0": "rotate-via-mouse-drag", "at:control+shift+mousedown0": "translate-via-mouse-drag", - "at:shift+mousedown0": SKELETON_ADD_NODE, - "at:keym": SKELETON_ENTER_MERGE_MODE, - "at:keys": SKELETON_ENTER_SPLIT_MODE, - "at:keyn": SKELETON_ENTER_CREATE, - "at:keyd": SKELETON_ENTER_DELETE_MODE, - "at:control+mousedown2": { - action: SKELETON_PIN_NODE, - stopPropagation: true, - preventDefault: true, - }, }); } + return defaultSkeletonToolNavigationBindings; +} + +export function getDefaultSkeletonEditToolBindings() { + if (defaultSkeletonEditToolBindings === undefined) { + defaultSkeletonEditToolBindings = EventActionMap.fromObject( + { + "at:shift+mousedown0": SKELETON_ADD_NODE, + "at:keym": SKELETON_ENTER_MERGE_MODE, + "at:keys": SKELETON_ENTER_SPLIT_MODE, + "at:keyn": SKELETON_ENTER_CREATE, + "at:keyd": SKELETON_ENTER_DELETE_MODE, + "at:control+mousedown2": { + action: SKELETON_PIN_NODE, + stopPropagation: true, + preventDefault: true, + }, + }, + { parents: [[getDefaultSkeletonToolNavigationBindings(), 0]] }, + ); + } return defaultSkeletonEditToolBindings; } +let defaultSkeletonFindPathToolBindings: EventActionMap | undefined; +export function getDefaultSkeletonFindPathToolBindings() { + if (defaultSkeletonFindPathToolBindings === undefined) { + defaultSkeletonFindPathToolBindings = EventActionMap.fromObject( + { + "at:shift?+mousedown0": SKELETON_FIND_PATH_SELECT_ENDPOINT, + }, + { parents: [[getDefaultSkeletonToolNavigationBindings(), 0]] }, + ); + } + return defaultSkeletonFindPathToolBindings; +} + let defaultSkeletonEditAuxBindings: EventActionMap | undefined; export function getDefaultSkeletonEditAuxBindings() { if (defaultSkeletonEditAuxBindings === undefined) { diff --git a/src/ui/skeleton_edit_tools.css b/src/ui/skeleton_edit_tools.css index ff817d2a63..f665bbdb5e 100644 --- a/src/ui/skeleton_edit_tools.css +++ b/src/ui/skeleton_edit_tools.css @@ -73,6 +73,33 @@ min-height: 1rem; } +.neuroglancer-skeleton-find-path-status { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.neuroglancer-skeleton-find-path-status > .neuroglancer-icon { + height: 100%; +} + +.neuroglancer-skeleton-find-path-message { + display: inline-flex; + align-items: center; +} + +.neuroglancer-skeleton-find-path-annotations { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.neuroglancer-skeleton-find-path-annotations + > .neuroglancer-annotation-list-entry { + background-color: black; +} + /* Per-mode cursor indicators — driven by data-skeleton-edit-mode on the panel element */ .neuroglancer-rendered-data-panel[data-skeleton-edit-mode="default"] { cursor: diff --git a/src/ui/skeleton_edit_tools.spec.ts b/src/ui/skeleton_edit_tools.spec.ts index 76bae63486..ba1d48e355 100644 --- a/src/ui/skeleton_edit_tools.spec.ts +++ b/src/ui/skeleton_edit_tools.spec.ts @@ -23,6 +23,7 @@ import { SKELETON_CLEAR_SELECTION, SKELETON_ENTER_MERGE_MODE, SKELETON_ENTER_SPLIT_MODE, + SKELETON_FIND_PATH_SELECT_ENDPOINT, } from "#src/skeleton/actions.js"; import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; import { SpatialSkeletonCommandHistory } from "#src/skeleton/command_history.js"; @@ -34,7 +35,11 @@ import { executeSpatialSkeletonAddNode, executeSpatialSkeletonMerge, } from "#src/skeleton/commands.js"; +import { SkeletonFindPathState } from "#src/skeleton/find_path.js"; +import { buildSpatiallyIndexedSkeletonNavigationGraph } from "#src/skeleton/navigation_graph.js"; import { StatusMessage } from "#src/status.js"; +import { WatchableValue } from "#src/trackable_value.js"; +import { getDefaultSkeletonFindPathToolBindings } from "#src/ui/default_input_event_bindings.js"; if (!("WebGL2RenderingContext" in globalThis)) { Object.defineProperty(globalThis, "WebGL2RenderingContext", { @@ -55,6 +60,10 @@ const { setSpatialSkeletonModesToLinesAndPoints, SkeletonRenderMode } = const { SpatialSkeletonEditTool } = await import( "#src/ui/skeleton_edit_tools.js" ); +const { + getSpatialSkeletonFindPathEndpointDescription, + SpatialSkeletonFindPathTool, +} = await import("#src/ui/skeleton_edit_tools.js"); function makeVisibleSegmentsState(initialVisibleSegments: bigint[] = []) { return { @@ -202,6 +211,159 @@ function makeToolActivation() { return { activation, actions, dispose }; } +function makeFindPathActionEvent() { + return { + stopPropagation: vi.fn(), + detail: { + preventDefault: vi.fn(), + }, + }; +} + +function makeFindPathNode( + nodeId: number, + segmentId = 11, + parentNodeId?: number, +): SpatiallyIndexedSkeletonNode { + return { + nodeId, + segmentId, + parentNodeId, + position: new Float32Array([nodeId, nodeId + 1, nodeId + 2]), + isTrueEnd: false, + }; +} + +function makeFindPathToolHarness( + options: { + cachedSegmentNodes?: readonly SpatiallyIndexedSkeletonNode[]; + disabledReason?: string; + hasSource?: boolean; + hasSecondSource?: boolean; + readonly?: boolean; + state?: SkeletonFindPathState; + visibleSegmentIds?: bigint[]; + } = {}, +) { + const state = options.state ?? new SkeletonFindPathState(); + const mouseState: any = { + pickedRenderLayer: undefined, + pickedSpatialSkeleton: undefined, + updateUnconditionally: vi.fn(() => true), + active: true, + }; + const cachedSegmentNodes = new Map< + number, + readonly SpatiallyIndexedSkeletonNode[] + >(); + if (options.cachedSegmentNodes !== undefined) { + cachedSegmentNodes.set(11, options.cachedSegmentNodes); + } + const getFullSegmentNodes = vi.fn(); + const nodeDataVersion = new WatchableValue(0); + const visibleSegmentsState = makeVisibleSegmentsState( + options.visibleSegmentIds ?? [11n, 12n], + ); + const skeletonLayer = + options.hasSource === false + ? undefined + : { + source: { readonly: options.readonly ?? true }, + getNode: vi.fn(), + }; + const secondSkeletonLayer = + options.hasSecondSource === true + ? { + source: { readonly: options.readonly ?? true }, + getNode: vi.fn(), + } + : undefined; + const context = + skeletonLayer === undefined + ? undefined + : { + skeletonLayer, + state, + annotationController: { + annotationState: { + source: [], + }, + }, + }; + let activeSkeletonLayer = skeletonLayer; + const getSpatialSkeletonActionsDisabledReason = vi.fn( + () => options.disabledReason, + ); + const layer = { + displayState: { + ...makeSkeletonRenderingOptions(), + segmentationGroupState: { value: visibleSegmentsState }, + }, + annotationDisplayState: { + hoverState: { value: undefined }, + }, + spatialSkeletonState: { + getFullSegmentNodes, + getCachedSegmentNodes: vi.fn((segmentId: number) => + cachedSegmentNodes.get(segmentId), + ), + nodeDataVersion, + }, + manager: { + root: { + layerSelectedValues: { mouseState }, + display: { panels: [] }, + }, + }, + getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, + getSpatialSkeletonFindPathContext: (candidate?: unknown) => + candidate === undefined || context?.skeletonLayer === candidate + ? context + : undefined, + getSpatialSkeletonActionsDisabledReason, + layersChanged: makeChangedSignal(), + }; + const { activation, actions, dispose } = makeToolActivation(); + const tool = Object.assign( + Object.create(SpatialSkeletonFindPathTool.prototype), + { + layer, + getActiveSpatiallyIndexedSkeletonLayer: () => activeSkeletonLayer, + }, + ); + + SpatialSkeletonFindPathTool.prototype.activate.call(tool, activation as any); + + const pickNode = ( + node: SpatiallyIndexedSkeletonNode, + candidateSkeletonLayer = skeletonLayer, + ) => { + activeSkeletonLayer = candidateSkeletonLayer; + mouseState.pickedSpatialSkeleton = node; + actions.get(SKELETON_FIND_PATH_SELECT_ENDPOINT)?.( + makeFindPathActionEvent(), + ); + }; + + return { + actions, + activation, + cachedSegmentNodes, + context, + dispose, + getFullSegmentNodes, + getSpatialSkeletonActionsDisabledReason, + layer, + mouseState, + nodeDataVersion, + pickNode, + skeletonLayer, + secondSkeletonLayer, + state, + visibleSegmentsState, + }; +} + function makeCommandFactory( action: SpatialSkeletonAction, execute = vi.fn(async () => {}), @@ -902,6 +1064,424 @@ describe("spatial_skeleton_edit_tool", () => { } }); + it("uses regular clicks for Find Path and preserves skeleton navigation chords", () => { + const bindings = getDefaultSkeletonFindPathToolBindings(); + + expect(bindings.get("at:mousedown0")?.action).toBe( + SKELETON_FIND_PATH_SELECT_ENDPOINT, + ); + expect(bindings.get("at:shift+mousedown0")?.action).toBe( + SKELETON_FIND_PATH_SELECT_ENDPOINT, + ); + expect(bindings.get("at:control+mousedown0")?.action).toBe( + "rotate-via-mouse-drag", + ); + expect(bindings.get("at:control+shift+mousedown0")?.action).toBe( + "translate-via-mouse-drag", + ); + expect(bindings.get("at:mousedown1")?.action).toBe("rotate-via-mouse-drag"); + }); + + it("describes Find Path endpoints using their derived topology type", () => { + const nodes = [ + makeFindPathNode(1), + makeFindPathNode(2, 11, 1), + makeFindPathNode(3, 11, 1), + makeFindPathNode(4, 11, 2), + makeFindPathNode(5, 11, 2), + ]; + nodes[3].isTrueEnd = true; + const graph = buildSpatiallyIndexedSkeletonNavigationGraph(nodes); + + expect( + getSpatialSkeletonFindPathEndpointDescription( + "Source", + { + nodeId: 1n, + segmentId: 11n, + position: new Float32Array(3), + }, + graph, + ), + ).toBe("Source · Root"); + expect( + getSpatialSkeletonFindPathEndpointDescription( + "Target", + { + nodeId: 2n, + segmentId: 11n, + position: new Float32Array(3), + }, + graph, + ), + ).toBe("Target · Branch point"); + expect( + getSpatialSkeletonFindPathEndpointDescription( + "Target", + { + nodeId: 3n, + segmentId: 11n, + position: new Float32Array(3), + }, + graph, + ), + ).toBe("Target · Leaf"); + expect( + getSpatialSkeletonFindPathEndpointDescription( + "Target", + { + nodeId: 4n, + segmentId: 11n, + position: new Float32Array(3), + }, + graph, + ), + ).toBe("Target · True end"); + }); + + it("collects two exact Find Path nodes and rejects invalid or extra picks", () => { + suppressStatusMessages(); + const harness = makeFindPathToolHarness(); + const source = makeFindPathNode(1); + const target = makeFindPathNode(2); + + try { + harness.pickNode(source); + expect(harness.state.source).toEqual({ + nodeId: 1n, + segmentId: 11n, + position: source.position, + }); + expect(harness.state.target).toBeUndefined(); + + harness.pickNode(makeFindPathNode(1)); + expect(harness.state.target).toBeUndefined(); + expect(StatusMessage.showTemporaryMessage).toHaveBeenLastCalledWith( + "Find Path endpoints must be distinct skeleton nodes.", + ); + + harness.pickNode(makeFindPathNode(2, 12)); + expect(harness.state.target).toBeUndefined(); + expect(StatusMessage.showTemporaryMessage).toHaveBeenLastCalledWith( + "Find Path endpoints must belong to the same skeleton segment.", + ); + + harness.pickNode(target); + expect(harness.state.target).toEqual({ + nodeId: 2n, + segmentId: 11n, + position: target.position, + }); + + harness.pickNode(makeFindPathNode(3)); + expect(harness.state.source?.nodeId).toBe(1n); + expect(harness.state.target?.nodeId).toBe(2n); + expect(StatusMessage.showTemporaryMessage).toHaveBeenLastCalledWith( + "Clear Find Path or delete an endpoint before selecting another node.", + ); + } finally { + harness.dispose(); + } + }); + + it("rejects edge-only picks and permits a new endpoint after Clear", () => { + suppressStatusMessages(); + const harness = makeFindPathToolHarness(); + + try { + harness.mouseState.pickedSpatialSkeleton = { segmentId: 11 }; + harness.actions.get(SKELETON_FIND_PATH_SELECT_ENDPOINT)?.( + makeFindPathActionEvent(), + ); + expect(harness.state.source).toBeUndefined(); + expect(StatusMessage.showTemporaryMessage).toHaveBeenLastCalledWith( + "Find Path endpoints must be exact skeleton nodes, not edges.", + ); + + harness.pickNode(makeFindPathNode(1)); + harness.state.clear(); + harness.pickNode(makeFindPathNode(2)); + expect(harness.state.source?.nodeId).toBe(2n); + } finally { + harness.dispose(); + } + }); + + it("rejects picks from a non-owning spatial skeleton source", () => { + suppressStatusMessages(); + const harness = makeFindPathToolHarness({ hasSecondSource: true }); + + try { + harness.pickNode(makeFindPathNode(1)); + harness.pickNode(makeFindPathNode(2), harness.secondSkeletonLayer); + + expect(harness.state.source?.nodeId).toBe(1n); + expect(harness.state.target).toBeUndefined(); + expect(StatusMessage.showTemporaryMessage).toHaveBeenLastCalledWith( + "Find Path is only available for the first active spatial skeleton datasource in this layer.", + ); + + harness.state.clear(); + harness.pickNode(makeFindPathNode(2), harness.secondSkeletonLayer); + expect(harness.state.toJSON()).toBeUndefined(); + expect(StatusMessage.showTemporaryMessage).toHaveBeenLastCalledWith( + "Find Path is only available for the first active spatial skeleton datasource in this layer.", + ); + } finally { + harness.dispose(); + } + }); + + it("automatically uses the cached skeleton and stores an endpoint-inclusive path", () => { + suppressStatusMessages(); + const nodes = [ + makeFindPathNode(1), + makeFindPathNode(2, 11, 1), + makeFindPathNode(3, 11, 2), + ]; + const harness = makeFindPathToolHarness({ cachedSegmentNodes: nodes }); + try { + harness.pickNode(nodes[2]); + harness.pickNode(nodes[0]); + + expect(harness.state.result?.map(({ nodeId }) => nodeId)).toEqual([ + 3n, + 2n, + 1n, + ]); + expect(harness.getFullSegmentNodes).not.toHaveBeenCalled(); + expect( + harness.layer.spatialSkeletonState.getCachedSegmentNodes, + ).toHaveBeenCalledWith(11); + expect(harness.state.result?.map(({ position }) => position)).toEqual([ + nodes[2].position, + nodes[1].position, + nodes[0].position, + ]); + expect(StatusMessage.showTemporaryMessage).toHaveBeenCalledWith( + "Path found!", + 5000, + ); + } finally { + harness.dispose(); + } + }); + + it("reports missing endpoints and disconnected cached skeletons distinctly", () => { + suppressStatusMessages(); + const cases = [ + { + nodes: [makeFindPathNode(3)], + expected: + "Failed to find path: Source node 1 is missing from the loaded skeleton.", + }, + { + nodes: [makeFindPathNode(1)], + expected: + "Failed to find path: Target node 3 is missing from the loaded skeleton.", + }, + { + nodes: [makeFindPathNode(1), makeFindPathNode(3)], + expected: "Failed to find path: No route exists between nodes 1 and 3.", + }, + ] as const; + + for (const { nodes, expected } of cases) { + const harness = makeFindPathToolHarness({ cachedSegmentNodes: nodes }); + try { + harness.pickNode(makeFindPathNode(1)); + harness.pickNode(makeFindPathNode(3)); + + expect(StatusMessage.showTemporaryMessage).toHaveBeenCalledWith( + expected, + ); + expect(harness.getFullSegmentNodes).not.toHaveBeenCalled(); + expect(harness.state.result).toBeUndefined(); + } finally { + harness.dispose(); + } + } + }); + + it("waits for cached node data without requesting it", () => { + suppressStatusMessages(); + const harness = makeFindPathToolHarness(); + + try { + harness.pickNode(makeFindPathNode(1)); + harness.pickNode(makeFindPathNode(3)); + + expect(harness.getFullSegmentNodes).not.toHaveBeenCalled(); + expect(harness.state.result).toBeUndefined(); + expect(StatusMessage.showTemporaryMessage).toHaveBeenCalledWith( + "Full data for skeleton 11 is not cached. Make it visible and wait for loading.", + ); + } finally { + harness.dispose(); + } + }); + + it("rejects persisted endpoint IDs outside the spatial number boundary", () => { + suppressStatusMessages(); + const state = new SkeletonFindPathState(); + const unsafeNodeId = BigInt(Number.MAX_SAFE_INTEGER) + 1n; + state.setEndpoints( + { + nodeId: unsafeNodeId, + segmentId: 11n, + position: new Float32Array([1, 2, 3]), + }, + { + nodeId: unsafeNodeId + 1n, + segmentId: 11n, + position: new Float32Array([4, 5, 6]), + }, + ); + const harness = makeFindPathToolHarness({ state }); + + try { + const status = Array.from( + document.querySelectorAll( + ".neuroglancer-skeleton-find-path-message", + ), + ).at(-1); + expect(status?.textContent).toBe( + "The selected endpoint IDs are not supported by this spatial skeleton source.", + ); + expect(harness.state.result).toBeUndefined(); + expect(harness.getFullSegmentNodes).not.toHaveBeenCalled(); + } finally { + harness.dispose(); + } + }); + + it("uses a cached skeleton even when it is not visible", () => { + suppressStatusMessages(); + const nodes = [ + makeFindPathNode(1), + makeFindPathNode(2, 11, 1), + makeFindPathNode(3, 11, 2), + ]; + const harness = makeFindPathToolHarness({ + cachedSegmentNodes: nodes, + visibleSegmentIds: [], + }); + + try { + harness.pickNode(nodes[0]); + harness.pickNode(nodes[2]); + + expect(harness.state.result?.map(({ nodeId }) => nodeId)).toEqual([ + 1n, + 2n, + 3n, + ]); + expect(harness.getFullSegmentNodes).not.toHaveBeenCalled(); + } finally { + harness.dispose(); + } + }); + + it("automatically retries when the full skeleton enters the cache", () => { + suppressStatusMessages(); + const nodes = [ + makeFindPathNode(1), + makeFindPathNode(2, 11, 1), + makeFindPathNode(3, 11, 2), + ]; + const harness = makeFindPathToolHarness(); + + try { + harness.pickNode(nodes[0]); + harness.pickNode(nodes[2]); + expect(harness.state.result).toBeUndefined(); + + harness.cachedSegmentNodes.set(11, nodes); + harness.nodeDataVersion.value++; + + expect(harness.state.result?.map(({ nodeId }) => nodeId)).toEqual([ + 1n, + 2n, + 3n, + ]); + expect(harness.getFullSegmentNodes).not.toHaveBeenCalled(); + } finally { + harness.dispose(); + } + }); + + it("Clear resets a result computed from cached nodes", () => { + suppressStatusMessages(); + const nodes = [ + makeFindPathNode(1), + makeFindPathNode(2, 11, 1), + makeFindPathNode(3, 11, 2), + ]; + const harness = makeFindPathToolHarness({ cachedSegmentNodes: nodes }); + + try { + harness.pickNode(nodes[0]); + harness.pickNode(nodes[2]); + expect(harness.state.result).toBeDefined(); + + const clearButton = Array.from( + document.querySelectorAll('[title="Clear Find Path"]'), + ).at(-1); + expect(clearButton).toBeDefined(); + clearButton?.click(); + + expect(harness.state.source).toBeUndefined(); + expect(harness.state.target).toBeUndefined(); + expect(harness.state.result).toBeUndefined(); + expect(harness.getFullSegmentNodes).not.toHaveBeenCalled(); + } finally { + harness.dispose(); + } + }); + + it("allows Find Path for a read-only source using inspect permission", () => { + suppressStatusMessages(); + const harness = makeFindPathToolHarness({ readonly: true }); + + try { + expect( + harness.getSpatialSkeletonActionsDisabledReason, + ).toHaveBeenCalledWith(SpatialSkeletonActions.inspect); + expect(harness.actions.has(SKELETON_FIND_PATH_SELECT_ENDPOINT)).toBe( + true, + ); + expect(harness.activation.cancel).not.toHaveBeenCalled(); + } finally { + harness.dispose(); + } + }); + + it("cancels Find Path when inspect is disabled or no source is loaded", async () => { + suppressStatusMessages(); + const disabledHarness = makeFindPathToolHarness({ + disabledReason: "Skeleton inspection is unavailable.", + }); + const noSourceHarness = makeFindPathToolHarness({ hasSource: false }); + + try { + await Promise.resolve(); + + expect(disabledHarness.activation.cancel).toHaveBeenCalledTimes(1); + expect(disabledHarness.actions.size).toBe(0); + expect(noSourceHarness.activation.cancel).toHaveBeenCalledTimes(1); + expect(noSourceHarness.actions.size).toBe(0); + expect(StatusMessage.showTemporaryMessage).toHaveBeenCalledWith( + "Skeleton inspection is unavailable.", + ); + expect(StatusMessage.showTemporaryMessage).toHaveBeenCalledWith( + "No spatially indexed skeleton source is currently loaded.", + ); + } finally { + disabledHarness.dispose(); + noSourceHarness.dispose(); + } + }); + it("errors when ctrl+click has no selected parent node", () => { suppressStatusMessages(); const skeletonLayer = { diff --git a/src/ui/skeleton_edit_tools.ts b/src/ui/skeleton_edit_tools.ts index d550203e2c..c8b13459c6 100644 --- a/src/ui/skeleton_edit_tools.ts +++ b/src/ui/skeleton_edit_tools.ts @@ -16,6 +16,7 @@ import "#src/ui/skeleton_edit_tools.css"; +import type { Annotation } from "#src/annotation/index.js"; import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; import { getSegmentIdFromLayerSelectionValue, @@ -32,6 +33,7 @@ import { SKELETON_ENTER_DELETE_MODE, SKELETON_ENTER_MERGE_MODE, SKELETON_ENTER_SPLIT_MODE, + SKELETON_FIND_PATH_SELECT_ENDPOINT, SKELETON_PIN_NODE, SKELETON_REROOT, SKELETON_TOGGLE_TRUE_END, @@ -50,19 +52,34 @@ import { executeSpatialSkeletonSplit, showSpatialSkeletonActionError, } from "#src/skeleton/commands.js"; +import type { SkeletonFindPathEndpoint } from "#src/skeleton/find_path.js"; import { - type SpatiallyIndexedSkeletonLayer, - setSpatialSkeletonModesToLinesAndPoints, -} from "#src/skeleton/frontend.js"; + SPATIAL_SKELETON_FIND_PATH_SOURCE_DESCRIPTION, + SPATIAL_SKELETON_FIND_PATH_TARGET_DESCRIPTION, +} from "#src/skeleton/find_path_annotations.js"; import { PerspectiveViewSpatiallyIndexedSkeletonLayer, + type SpatiallyIndexedSkeletonLayer, + setSpatialSkeletonModesToLinesAndPoints, SliceViewPanelSpatiallyIndexedSkeletonLayer, } from "#src/skeleton/frontend.js"; +import { + buildSpatiallyIndexedSkeletonNavigationGraph, + getPathBetweenNodes, + type SpatiallyIndexedSkeletonNavigationGraph, +} from "#src/skeleton/navigation_graph.js"; +import { + classifySpatialSkeletonDisplayNodeType, + SpatialSkeletonDisplayNodeType, +} from "#src/skeleton/node_types.js"; import { StatusMessage } from "#src/status.js"; +import { makeAnnotationListElement } from "#src/ui/annotations.js"; import { + getDefaultAnnotationListBindings, getDefaultSkeletonEditAuxBindings, getDefaultSkeletonEditNodeBindings, getDefaultSkeletonEditToolBindings, + getDefaultSkeletonFindPathToolBindings, } from "#src/ui/default_input_event_bindings.js"; import { getSpatialSkeletonCreateIdleStatusText, @@ -88,9 +105,12 @@ import { import { removeChildren } from "#src/util/dom.js"; import type { ActionEvent } from "#src/util/event_action_map.js"; import { vec3 } from "#src/util/geom.js"; +import { MouseEventBinder } from "#src/util/mouse_bindings.js"; import { startRelativeMouseDrag } from "#src/util/mouse_drag.js"; +import { makeIcon } from "#src/widget/icon.js"; export const SPATIAL_SKELETON_EDIT_MODE_TOOL_ID = "spatialSkeletonEditMode"; +export const SPATIAL_SKELETON_FIND_PATH_TOOL_ID = "spatialSkeletonFindPath"; // Internal mode enum for sustained editing states. // Move and Select are both handled in Default. @@ -138,6 +158,66 @@ function hasNavigationModifier(event: { ctrlKey: boolean; metaKey: boolean }) { return event.metaKey || event.ctrlKey; } +/** + * Preserves the skeleton tools' middle-mouse and navigation-modifier controls + * when a tool claims regular left click as its primary interaction. + */ +function bindSpatialSkeletonToolMouseControls< + T extends LayerTool, +>( + activation: ToolActivation, + layer: SegmentationUserLayer, + onPrimaryMousedown?: (event: MouseEvent, panel: RenderedDataPanel) => void, +) { + for (const panel of layer.manager.root.display.panels) { + if (!(panel instanceof RenderedDataPanel)) continue; + const captureMousedown = (event: MouseEvent) => { + // Perspective navigation is dispatched through the active tool's input + // map. Slice navigation is performed directly here. + const isNavigationGesture = + event.button === 1 || + (event.button === 0 && hasNavigationModifier(event)); + if (isNavigationGesture) { + if (panel instanceof PerspectivePanel) { + panel.element.dataset.skeletonPressMode = "rotate"; + const onMouseUp = () => { + delete panel.element.dataset.skeletonPressMode; + window.removeEventListener("mouseup", onMouseUp); + }; + window.addEventListener("mouseup", onMouseUp); + } else { + event.stopPropagation(); + event.preventDefault(); + panel.element.dataset.skeletonPressMode = "pan"; + startRelativeMouseDrag( + event, + (_dragEvent, deltaX, deltaY) => { + panel.context.flagContinuousCameraMotion(); + panel.translateByViewportPixels(deltaX, deltaY); + }, + () => { + delete panel.element.dataset.skeletonPressMode; + }, + ); + } + return; + } + + if (event.button === 0) { + onPrimaryMousedown?.(event, panel); + } + }; + panel.element.addEventListener("mousedown", captureMousedown, { + capture: true, + }); + activation.registerDisposer(() => { + panel.element.removeEventListener("mousedown", captureMousedown, { + capture: true, + }); + }); + } +} + function waitForNextAnimationFrame() { return new Promise((resolve) => { if (typeof requestAnimationFrame !== "function") { @@ -247,10 +327,12 @@ abstract class SpatialSkeletonToolBase extends LayerTool this.layer.selectSegment(BigInt(Math.round(value)), true); } - protected isSpatialSkeletonSegmentVisible(segmentId: number) { + protected isSpatialSkeletonSegmentVisible(segmentId: number | bigint) { return getVisibleSegments( this.layer.displayState.segmentationGroupState.value, - ).has(BigInt(Math.round(segmentId))); + ).has( + typeof segmentId === "bigint" ? segmentId : BigInt(Math.round(segmentId)), + ); } protected resolvePickedNodeSelection( @@ -395,6 +477,53 @@ abstract class SpatialSkeletonToolBase extends LayerTool modeWatchable.value = false; }); } + + protected registerAutoCancelOnDisabled( + activation: ToolActivation, + requiredActions: Parameters< + SegmentationUserLayer["getSpatialSkeletonActionsDisabledReason"] + >[0], + onReady?: () => void, + ) { + const handleStateChanged = () => { + const disabledReason = this.layer.getSpatialSkeletonActionsDisabledReason( + requiredActions, + { ignoreCommandBusy: true }, + ); + if (disabledReason === undefined) { + onReady?.(); + return; + } + StatusMessage.showTemporaryMessage(disabledReason); + activation.cancel(); + }; + activation.registerDisposer( + this.layer.layersChanged.add(handleStateChanged), + ); + } + + protected cancelActivationIfPreconditionsFail( + activation: ToolActivation, + requiredAction: Parameters< + SegmentationUserLayer["getSpatialSkeletonActionsDisabledReason"] + >[0], + ): boolean { + const reason = + this.layer.getSpatialSkeletonActionsDisabledReason(requiredAction); + if (reason !== undefined) { + StatusMessage.showTemporaryMessage(reason); + queueMicrotask(() => activation.cancel()); + return false; + } + if (this.getActiveSpatiallyIndexedSkeletonLayer() === undefined) { + StatusMessage.showTemporaryMessage( + "No spatially indexed skeleton source is currently loaded.", + ); + queueMicrotask(() => activation.cancel()); + return false; + } + return true; + } } export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { @@ -1533,122 +1662,38 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { window.removeEventListener("blur", onBlur); }); - // 10. Per-panel capture listeners — closures per panel; body delegates to class methods. - // Left click (mousedown0) is handled here rather than in the EventActionMap so that - // we can consume off-node clicks without accidentally shadowing EventActionMap actions - // at lower priority. All plain/shift left clicks are owned by the edit tool — they - // either select a node, add a node, or do nothing. Navigation (rotate/pan) belongs to - // middle mouse and to the navigation-modifier + left-click aliases below (for trackpad - // users without a reliable middle-click), handled via the EventActionMap + the - // slice-panel path below. - for (const panel of layer.manager.root.display.panels) { - if (!(panel instanceof RenderedDataPanel)) continue; - const captureMousedown = (event: MouseEvent) => { - // Middle mouse (plain): rotate in 3D (EventActionMap mousedown1 → rotate-via-mouse-drag), - // translate in 2D (intercepted here via startRelativeMouseDrag). - // Ctrl+middle: translate in 3D (EventActionMap control+mousedown1 → translate-via-mouse-drag), - // translate in 2D (intercepted here, same as plain middle). - if (event.button === 1) { - if (panel instanceof PerspectivePanel) { - panel.element.dataset.skeletonPressMode = "rotate"; - const onMouseUp = () => { - delete panel.element.dataset.skeletonPressMode; - window.removeEventListener("mouseup", onMouseUp); - }; - window.addEventListener("mouseup", onMouseUp); - } else { - event.stopPropagation(); - event.preventDefault(); - panel.element.dataset.skeletonPressMode = "pan"; - startRelativeMouseDrag( - event, - (_dragEvent, deltaX, deltaY) => { - panel.context.flagContinuousCameraMotion(); - panel.translateByViewportPixels(deltaX, deltaY); - }, - () => { - delete panel.element.dataset.skeletonPressMode; - }, - ); - } - return; - } - - // Trackpad-friendly aliases for the middle-mouse scheme above. - // Navigation modifier + left (plain): rotate in 3D (EventActionMap - // control+mousedown0 → rotate-via-mouse-drag), pan in 2D - // (intercepted here) — mirrors plain middle mouse. - // Navigation modifier + shift + left: translate in 3D - // (EventActionMap control+shift+mousedown0 → translate-via-mouse-drag), - // pan in 2D (intercepted here, same as above) — mirrors ctrl+middle - // mouse. Checked before the shift guard below so it takes priority - // over the shift+mousedown0 add-node chord; hasNavigationModifier is - // the discriminator (add-node never has the modifier held). - if (event.button === 0 && hasNavigationModifier(event)) { - if (panel instanceof PerspectivePanel) { - panel.element.dataset.skeletonPressMode = "rotate"; - const onMouseUp = () => { - delete panel.element.dataset.skeletonPressMode; - window.removeEventListener("mouseup", onMouseUp); - }; - window.addEventListener("mouseup", onMouseUp); - } else { - event.stopPropagation(); - event.preventDefault(); - panel.element.dataset.skeletonPressMode = "pan"; - startRelativeMouseDrag( - event, - (_dragEvent, deltaX, deltaY) => { - panel.context.flagContinuousCameraMotion(); - panel.translateByViewportPixels(deltaX, deltaY); - }, - () => { - delete panel.element.dataset.skeletonPressMode; - }, - ); - } - return; - } - - // shift+mousedown0 → EventActionMap (add-node); other buttons → normal dispatch. - // Both must pass through the capture listener unmodified. - if (event.button !== 0 || event.shiftKey) return; - if (this.currentMode === SkeletonEditMode.Merge) { - event.stopPropagation(); - event.preventDefault(); - this.handleMergeSecondPick(); - return; - } - if (this.currentMode === SkeletonEditMode.Split) { - event.stopPropagation(); - event.preventDefault(); - this.handleSplitPick(); - return; - } - if (this.currentMode === SkeletonEditMode.Create) { - event.stopPropagation(); - event.preventDefault(); - this.handleCreatePlace(); - return; - } - if (this.currentMode === SkeletonEditMode.Delete) { - event.stopPropagation(); - event.preventDefault(); - this.handleDeletePick(); - return; - } - // Default mode: only consume if hovering a node. - this.handleDefaultMousedown(event, panel); - }; - panel.element.addEventListener("mousedown", captureMousedown, { - capture: true, - }); - activation.registerDisposer(() => { - panel.element.removeEventListener("mousedown", captureMousedown, { - capture: true, - }); - }); - } + // 10. Share the navigation controls used by skeleton tools, while keeping + // the Edit tool's existing primary-click mode handling local. + bindSpatialSkeletonToolMouseControls(activation, layer, (event, panel) => { + // shift+mousedown0 is dispatched as the add-node action. + if (event.shiftKey) return; + if (this.currentMode === SkeletonEditMode.Merge) { + event.stopPropagation(); + event.preventDefault(); + this.handleMergeSecondPick(); + return; + } + if (this.currentMode === SkeletonEditMode.Split) { + event.stopPropagation(); + event.preventDefault(); + this.handleSplitPick(); + return; + } + if (this.currentMode === SkeletonEditMode.Create) { + event.stopPropagation(); + event.preventDefault(); + this.handleCreatePlace(); + return; + } + if (this.currentMode === SkeletonEditMode.Delete) { + event.stopPropagation(); + event.preventDefault(); + this.handleDeletePick(); + return; + } + // Default mode: only consume if hovering a node. + this.handleDefaultMousedown(event, panel); + }); // 11. Bind actions — thin one-liners delegating to class methods. activation.bindAction(SKELETON_ENTER_MERGE_MODE, () => @@ -1740,12 +1785,444 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { // Backward-compat alias — external code referencing SpatialSkeletonEditModeTool still works. export { SpatialSkeletonEditTool as SpatialSkeletonEditModeTool }; +function getSpatialSkeletonFindPathNodeTypeLabel( + graph: SpatiallyIndexedSkeletonNavigationGraph, + nodeId: number, +) { + const node = graph.nodeById.get(nodeId); + if (node === undefined) return undefined; + if (node.isTrueEnd ?? false) return "True end"; + const parentInTree = + node.parentNodeId !== undefined && graph.nodeById.has(node.parentNodeId); + const type = classifySpatialSkeletonDisplayNodeType( + node, + graph.childrenByParent.get(nodeId)?.length ?? 0, + parentInTree, + ); + switch (type) { + case SpatialSkeletonDisplayNodeType.ROOT: + return "Root"; + case SpatialSkeletonDisplayNodeType.BRANCH_START: + return "Branch point"; + case SpatialSkeletonDisplayNodeType.VIRTUAL_END: + return "Leaf"; + default: + return "Node"; + } +} + +export function getSpatialSkeletonFindPathEndpointDescription( + endpointName: "Source" | "Target", + endpoint: SkeletonFindPathEndpoint, + graph: SpatiallyIndexedSkeletonNavigationGraph | undefined, +) { + const nodeId = Number(endpoint.nodeId); + const nodeType = Number.isSafeInteger(nodeId) + ? graph === undefined + ? undefined + : getSpatialSkeletonFindPathNodeTypeLabel(graph, nodeId) + : undefined; + return nodeType === undefined + ? endpointName + : `${endpointName} · ${nodeType}`; +} + +export class SpatialSkeletonFindPathTool extends SpatialSkeletonToolBase { + toJSON() { + return SPATIAL_SKELETON_FIND_PATH_TOOL_ID; + } + + get description() { + return "Find path"; + } + + activate(activation: ToolActivation) { + if ( + !this.cancelActivationIfPreconditionsFail( + activation, + SpatialSkeletonActions.inspect, + ) + ) { + return; + } + + const { layer } = this; + const activeContext = layer.getSpatialSkeletonFindPathContext(); + setSpatialSkeletonModesToLinesAndPoints(layer); + + const { body, header } = + makeToolActivationStatusMessageWithHeader(activation); + header.textContent = "Find Path"; + body.classList.add("neuroglancer-skeleton-find-path-status"); + + const statusElement = document.createElement("span"); + statusElement.className = "neuroglancer-skeleton-find-path-message"; + let statusOverride: string | undefined; + + const getStateContext = () => { + if (activeContext === undefined) return undefined; + return layer.getSpatialSkeletonFindPathContext() === activeContext + ? activeContext + : undefined; + }; + + const getState = () => getStateContext()?.state; + body.appendChild( + makeIcon({ + text: "Clear", + title: "Clear Find Path", + onClick: () => { + statusOverride = undefined; + getState()?.clear(); + }, + }), + ); + body.appendChild(statusElement); + + const annotationElements = document.createElement("div"); + annotationElements.className = + "neuroglancer-skeleton-find-path-annotations"; + body.appendChild(annotationElements); + annotationElements.addEventListener("mouseleave", () => { + layer.annotationDisplayState.hoverState.value = undefined; + }); + activation.registerDisposer( + new MouseEventBinder( + annotationElements, + getDefaultAnnotationListBindings(), + ), + ); + + const updateAnnotationElements = () => { + removeChildren(annotationElements); + const state = getState(); + const annotationState = + getStateContext()?.annotationController.annotationState; + if (state === undefined || annotationState === undefined) return; + const annotationsByDescription = new Map(); + for (const annotation of annotationState.source) { + if ( + annotation.description === + SPATIAL_SKELETON_FIND_PATH_SOURCE_DESCRIPTION || + annotation.description === + SPATIAL_SKELETON_FIND_PATH_TARGET_DESCRIPTION + ) { + annotationsByDescription.set(annotation.description, annotation); + } + } + const maxColumnWidths = [0, 0, 0]; + const template = + "[symbol] 2ch [dim] var(--neuroglancer-column-0-width) [dim] var(--neuroglancer-column-1-width) [dim] var(--neuroglancer-column-2-width) [delete] min-content"; + for (const description of [ + SPATIAL_SKELETON_FIND_PATH_SOURCE_DESCRIPTION, + SPATIAL_SKELETON_FIND_PATH_TARGET_DESCRIPTION, + ]) { + const annotation = annotationsByDescription.get(description); + if (annotation === undefined) continue; + const sourceEndpoint = + description === SPATIAL_SKELETON_FIND_PATH_SOURCE_DESCRIPTION; + const endpoint = sourceEndpoint ? state.source : state.target; + const [element, elementColumnWidths] = makeAnnotationListElement( + layer, + annotation, + annotationState, + template, + [0, 1, 2], + [], + ); + if (endpoint !== undefined) { + const segmentId = Number(endpoint.segmentId); + const cachedSegmentNodes = Number.isSafeInteger(segmentId) + ? layer.spatialSkeletonState.getCachedSegmentNodes(segmentId) + : undefined; + const graph = + cachedSegmentNodes === undefined + ? undefined + : buildSpatiallyIndexedSkeletonNavigationGraph( + cachedSegmentNodes, + ); + const endpointDescription = + getSpatialSkeletonFindPathEndpointDescription( + sourceEndpoint ? "Source" : "Target", + endpoint, + graph, + ); + const descriptionElement = element.querySelector( + ".neuroglancer-annotation-description", + ); + if (descriptionElement !== null) { + descriptionElement.textContent = endpointDescription; + } + element.title = `${endpointDescription} · node ${endpoint.nodeId}`; + } + for (const [column, width] of elementColumnWidths.entries()) { + maxColumnWidths[column] = Math.max(maxColumnWidths[column], width); + } + annotationElements.appendChild(element); + } + for (const [column, width] of maxColumnWidths.entries()) { + annotationElements.style.setProperty( + `--neuroglancer-column-${column}-width`, + `${width + 2}ch`, + ); + } + }; + + function updateStatus() { + const state = getState(); + if (statusOverride !== undefined) { + statusElement.textContent = statusOverride; + } else if (state === undefined) { + statusElement.textContent = + "No spatial skeleton datasource supports Find Path."; + } else if (state.result !== undefined) { + statusElement.textContent = `Path found (${state.result.length} nodes).`; + } else if (state.source === undefined) { + statusElement.textContent = "Left-click the source node."; + } else if (state.target === undefined) { + statusElement.textContent = "Left-click the target node."; + } else { + statusElement.textContent = "Finding path…"; + } + updateAnnotationElements(); + } + + const showPathStatus = (message: string, announce: boolean) => { + statusOverride = message; + updateStatus(); + if (announce) { + StatusMessage.showTemporaryMessage(message); + } + }; + + const computePath = (announce: boolean) => { + const context = getStateContext(); + const state = context?.state; + if (context === undefined || state === undefined) { + showPathStatus( + "The spatial skeleton source selected for Find Path is no longer loaded.", + announce, + ); + return false; + } + const { source, target } = state; + if (source === undefined || target === undefined) { + updateStatus(); + return false; + } + if (state.result !== undefined) { + updateStatus(); + return true; + } + if (source.segmentId !== target.segmentId) { + showPathStatus( + "Find Path endpoints must belong to the same skeleton segment.", + announce, + ); + return false; + } + if (source.nodeId === target.nodeId) { + showPathStatus( + "Find Path endpoints must be distinct skeleton nodes.", + announce, + ); + return false; + } + + const segmentId = Number(source.segmentId); + const sourceNodeId = Number(source.nodeId); + const targetNodeId = Number(target.nodeId); + if ( + !Number.isSafeInteger(segmentId) || + !Number.isSafeInteger(sourceNodeId) || + !Number.isSafeInteger(targetNodeId) + ) { + showPathStatus( + "The selected endpoint IDs are not supported by this spatial skeleton source.", + announce, + ); + return false; + } + const cachedSegmentNodes = + layer.spatialSkeletonState.getCachedSegmentNodes(segmentId); + if (cachedSegmentNodes === undefined) { + showPathStatus( + `Full data for skeleton ${source.segmentId} is not cached. Make it visible and wait for loading.`, + announce, + ); + return false; + } + + statusOverride = undefined; + try { + const graph = + buildSpatiallyIndexedSkeletonNavigationGraph(cachedSegmentNodes); + if (!graph.nodeById.has(sourceNodeId)) { + throw new Error( + `Source node ${source.nodeId} is missing from the loaded skeleton.`, + ); + } + if (!graph.nodeById.has(targetNodeId)) { + throw new Error( + `Target node ${target.nodeId} is missing from the loaded skeleton.`, + ); + } + const path = getPathBetweenNodes(graph, sourceNodeId, targetNodeId); + if (path === undefined) { + throw new Error( + `No route exists between nodes ${source.nodeId} and ${target.nodeId}.`, + ); + } + state.setResult( + path.map(({ nodeId, position }) => ({ + nodeId: BigInt(nodeId), + position: new Float32Array(position), + })), + ); + if (announce) { + StatusMessage.showTemporaryMessage("Path found!", 5000); + } + return true; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + showPathStatus(`Failed to find path: ${detail}`, announce); + return false; + } + }; + + const state = getState(); + if (state !== undefined) { + activation.registerDisposer( + state.changed.add(() => { + statusOverride = undefined; + updateStatus(); + }), + ); + } + activation.registerDisposer( + layer.spatialSkeletonState.nodeDataVersion.changed.add(() => { + statusOverride = undefined; + const state = getState(); + if ( + state?.source !== undefined && + state.target !== undefined && + state.result === undefined + ) { + computePath(false); + } else { + updateStatus(); + } + }), + ); + this.registerAutoCancelOnDisabled( + activation, + SpatialSkeletonActions.inspect, + updateStatus, + ); + + bindSpatialSkeletonToolMouseControls(activation, layer); + activation.bindInputEventMap(getDefaultSkeletonFindPathToolBindings()); + activation.bindAction( + SKELETON_FIND_PATH_SELECT_ENDPOINT, + (event: ActionEvent) => { + event.stopPropagation(); + event.detail.preventDefault(); + const currentState = getState(); + if ( + currentState?.source !== undefined && + currentState.target !== undefined + ) { + StatusMessage.showTemporaryMessage( + "Clear Find Path or delete an endpoint before selecting another node.", + ); + return; + } + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + const context = getStateContext(); + if (skeletonLayer === undefined) { + StatusMessage.showTemporaryMessage( + "No spatially indexed skeleton source is currently loaded.", + ); + return; + } + if (context === undefined) { + StatusMessage.showTemporaryMessage( + "The spatial skeleton source selected for Find Path is no longer loaded.", + ); + return; + } + if (context.skeletonLayer !== skeletonLayer) { + StatusMessage.showTemporaryMessage( + "Find Path is only available for the first active spatial skeleton datasource in this layer.", + ); + return; + } + const pickedNode = this.resolvePickedNodeSelection(skeletonLayer); + if ( + pickedNode?.segmentId === undefined || + pickedNode.position === undefined + ) { + StatusMessage.showTemporaryMessage( + "Find Path endpoints must be exact skeleton nodes, not edges.", + ); + return; + } + const endpoint: SkeletonFindPathEndpoint = { + nodeId: BigInt(pickedNode.nodeId), + segmentId: BigInt(pickedNode.segmentId), + position: new Float32Array(pickedNode.position), + }; + const state = context.state; + const otherEndpoint = + state.source === undefined ? state.target : state.source; + if (otherEndpoint !== undefined) { + if (otherEndpoint.segmentId !== endpoint.segmentId) { + StatusMessage.showTemporaryMessage( + "Find Path endpoints must belong to the same skeleton segment.", + ); + return; + } + if (otherEndpoint.nodeId === endpoint.nodeId) { + StatusMessage.showTemporaryMessage( + "Find Path endpoints must be distinct skeleton nodes.", + ); + return; + } + } + statusOverride = undefined; + if (state.source === undefined) { + state.setSource(endpoint); + } else { + state.setTarget(endpoint); + } + if (state.source !== undefined && state.target !== undefined) { + computePath(true); + } + }, + ); + + if ( + state?.source !== undefined && + state.target !== undefined && + state.result === undefined + ) { + computePath(false); + } else { + updateStatus(); + } + } +} + function makeSpatialSkeletonToolLister(toolId: string) { return (layer: SegmentationUserLayer, onChange?: () => void) => { if (onChange !== undefined) { layer.layersChanged.addOnce(onChange); } - if (layer.getSpatiallyIndexedSkeletonLayer() === undefined) { + if ( + layer.getSpatiallyIndexedSkeletonLayer() === undefined || + (toolId === SPATIAL_SKELETON_FIND_PATH_TOOL_ID && + layer.getSpatialSkeletonFindPathContext() === undefined) + ) { return []; } return [{ type: toolId }]; @@ -1761,4 +2238,10 @@ export function registerSpatialSkeletonEditModeTool( (layer) => new SpatialSkeletonEditTool(layer), makeSpatialSkeletonToolLister(SPATIAL_SKELETON_EDIT_MODE_TOOL_ID), ); + registerTool( + contextType, + SPATIAL_SKELETON_FIND_PATH_TOOL_ID, + (layer) => new SpatialSkeletonFindPathTool(layer), + makeSpatialSkeletonToolLister(SPATIAL_SKELETON_FIND_PATH_TOOL_ID), + ); } diff --git a/src/ui/skeleton_tab.css b/src/ui/skeleton_tab.css index 405c572afc..6d09144d91 100644 --- a/src/ui/skeleton_tab.css +++ b/src/ui/skeleton_tab.css @@ -116,6 +116,7 @@ .neuroglancer-skeleton-filter-row .neuroglancer-tool-button { margin-left: auto; + white-space: nowrap; } .neuroglancer-skeleton-navigation-bar { diff --git a/src/ui/skeleton_tab.ts b/src/ui/skeleton_tab.ts index 59c11a2ba3..1575854087 100644 --- a/src/ui/skeleton_tab.ts +++ b/src/ui/skeleton_tab.ts @@ -86,7 +86,10 @@ import { getDefaultSkeletonListBindings, getDefaultSkeletonTabBindings, } from "#src/ui/default_input_event_bindings.js"; -import { SPATIAL_SKELETON_EDIT_MODE_TOOL_ID } from "#src/ui/skeleton_edit_tools.js"; +import { + SPATIAL_SKELETON_EDIT_MODE_TOOL_ID, + SPATIAL_SKELETON_FIND_PATH_TOOL_ID, +} from "#src/ui/skeleton_edit_tools.js"; import { buildSpatialSkeletonSegmentRenderState, type SpatialSkeletonSegmentRenderRow, @@ -329,6 +332,13 @@ export class SpatialSkeletonEditTab extends Tab { title: "Toggle skeleton edit mode", }), ); + nodeFilterTypeRow.appendChild( + makeToolButton(this, layer.toolBinder, { + toolJson: SPATIAL_SKELETON_FIND_PATH_TOOL_ID, + label: "Find Path", + title: "Highlight the route between two skeleton nodes", + }), + ); nodesSection.appendChild(filterInput); nodesSection.appendChild(nodeFilterTypeRow); nodesNavigationBar.appendChild(navTools);