Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/user-guide/skeleton_editing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
~~~~~~~~~

Expand Down
3 changes: 3 additions & 0 deletions src/annotation/annotation_layer_state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
3 changes: 3 additions & 0 deletions src/annotation/renderlayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
Expand Down
3 changes: 3 additions & 0 deletions src/datasource/catmaid/frontend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import type {
SpatiallyIndexedSkeletonNode,
SpatiallyIndexedSkeletonNodeBase,
} from "#src/skeleton/api.js";
import { SkeletonDataSourceState } from "#src/skeleton/find_path.js";
import {
SpatiallyIndexedSkeletonSource,
SkeletonSource,
Expand Down Expand Up @@ -321,6 +322,7 @@ export class CatmaidDataSourceProvider implements DataSourceProvider {

async get(options: GetDataSourceOptions): Promise<DataSource> {
const { providerUrl } = options;
const state = new SkeletonDataSourceState(options.state);

// Remove scheme if present to handle "catmaid://"
let cleanUrl = providerUrl;
Expand Down Expand Up @@ -510,6 +512,7 @@ export class CatmaidDataSourceProvider implements DataSourceProvider {
return {
modelTransform: makeIdentityTransform(modelSpace),
subsources,
state,
};
}
}
104 changes: 104 additions & 0 deletions src/layer/segmentation/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -41,6 +42,7 @@ const { SegmentationUserLayer } = await import(
const {
PerspectiveViewSpatiallyIndexedSkeletonLayer,
SliceViewPanelSpatiallyIndexedSkeletonLayer,
SpatiallyIndexedSkeletonSource,
} = await import("#src/skeleton/frontend.js");

const { SegmentSelectionState } = await import(
Expand Down Expand Up @@ -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 },
);
});
});
102 changes: 99 additions & 3 deletions src/layer/segmentation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1603,6 +1665,7 @@ export class SegmentationUserLayer extends Base {
markSpatialSkeletonNodeDataChanged(options?: {
invalidateFullSkeletonCache?: boolean;
}) {
this.spatialSkeletonFindPathContext?.state.invalidateResult();
this.spatialSkeletonState.markNodeDataChanged(options);
}

Expand Down Expand Up @@ -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<LoadedDataSubsource>) {
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;
Expand All @@ -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(),
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()),
);
Expand All @@ -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(
Expand Down
Loading