Skip to content
Draft
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
28 changes: 28 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,30 @@ 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 hold :kbd:`Control` and left-click
the source node followed by the target node. You may also hold :kbd:`Shift` while
selecting. The skeleton must already be visible, and 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.

Making a skeleton visible loads its complete node data through the normal
visibility pipeline. Click **Submit** or press :kbd:`Enter` to use the nodes
already cached in the client and highlight the route as a white annotation
polyline. **Find Path** does not initiate a download; if the visible skeleton is
still loading, wait and submit again. Click **Clear** to remove the endpoints and
route. If a generic skeleton contains cycles, **Find Path** selects a
deterministic route with the fewest edges.

When a segmentation layer has multiple spatial skeleton datasources, both
endpoints must be picked from the same datasource. A pick from another source
is rejected after the first endpoint; use **Clear** before switching sources.
Each datasource keeps its own saved Find Path state, while the segmentation
layer displays one active route at a time.

Edit Tool
~~~~~~~~~

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 @@ -319,6 +320,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 @@ -501,6 +503,7 @@ export class CatmaidDataSourceProvider implements DataSourceProvider {
return {
modelTransform: makeIdentityTransform(modelSpace),
subsources,
state,
};
}
}
197 changes: 197 additions & 0 deletions src/layer/segmentation/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ 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";
import { NullarySignal } from "#src/util/signal.js";

if (!("WebGL2RenderingContext" in globalThis)) {
Object.defineProperty(globalThis, "WebGL2RenderingContext", {
Expand Down Expand Up @@ -108,6 +110,73 @@ function makeSpatialSkeletonLayerWithSource(source: unknown) {
};
}

function makeTrackableStub<T>(initialValue: T) {
const value = new WatchableValue(initialValue);
return Object.assign(value, {
restoreState: (newValue: T | undefined) => {
if (newValue !== undefined) value.value = newValue;
},
toJSON: () => value.value,
});
}

function makeSegmentationUserLayerForFindPathTests() {
let nextRpcId = 0;
const rpc = {
newId: () => nextRpcId++,
set: vi.fn(),
delete: vi.fn(),
invoke: vi.fn(),
};
const globalToolBinder = {
bindings: new Map(),
localBinders: new Set(),
localBindersChanged: new NullarySignal(),
};
const mouseState = {
active: false,
changed: new NullarySignal(),
};
const layerSelectedValues = {
changed: new NullarySignal(),
mouseState,
get: () => undefined,
};
const selectionState = new WatchableValue<any>(undefined);
const layerManager = {
getLayerByName: () => undefined,
updateNonArchivedLayerIndices: vi.fn(),
};
const manager: any = {
rpc,
layerManager,
rootLayers: layerManager,
layerSelectedValues,
chunkManager: {
layerChunkStatisticsUpdated: new NullarySignal(),
memoize: {
getUncounted: (_key: unknown, getter: () => unknown) => getter(),
},
},
};
manager.root = {
toolBinder: globalToolBinder,
selectionState,
};
const managedLayer: any = {
name: "find-path-test",
layer: null,
manager,
localCoordinateSpaceCombiner: {},
localCoordinateSpace: makeTrackableStub({ rank: 0 }),
localPosition: makeTrackableStub(new Float32Array(0)),
localVelocity: makeTrackableStub(new Float32Array(0)),
};
const layer = new SegmentationUserLayer(managedLayer);
managedLayer.layer = layer;
return layer;
}

function makeSpatialSkeletonActionGateLayer(options: {
source: unknown;
visibleChunksLoaded?: boolean;
Expand Down Expand Up @@ -643,3 +712,131 @@ 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(states: SkeletonDataSourceState[]) {
const dataSources = states.map(() => ({}));
const contexts = states.map((dataSourceState, index) => ({
skeletonLayer: { source: {} },
dataSourceState,
loadedSubsource: {
subsourceIndex: 0,
loadedDataSource: { layerDataSource: dataSources[index] },
},
annotationController: {},
}));
const layer = Object.assign(
Object.create(SegmentationUserLayer.prototype),
{
dataSources,
spatialSkeletonFindPathContexts: new Map(
[...contexts]
.reverse()
.map((context) => [context.skeletonLayer, context]),
),
},
);
return { layer, contexts };
}

it("does not restore or serialize the removed layer-wide JSON key", () => {
const layer = makeSegmentationUserLayerForFindPathTests();
layer.restoreState({
spatialSkeletonFindPath: serializedFindPathState,
});

const layerJson = layer.toJSON();
expect(layerJson).not.toHaveProperty("spatialSkeletonFindPath");
});

it("keeps the lowest datasource-index state when multiple are non-empty", () => {
const first = new SkeletonDataSourceState({
findPath: serializedFindPathState,
});
const second = new SkeletonDataSourceState({
findPath: {
source: { nodeId: "4", segmentId: "8", position: [1, 1, 1] },
},
});
const { layer, contexts } = makeContextTestLayer([first, second]);

(layer as any).reconcileSpatialSkeletonFindPathStates();

expect(first.findPathState.toJSON()).toEqual(serializedFindPathState);
expect(second.findPathState.toJSON()).toBeUndefined();
expect(layer.getInitialSpatialSkeletonFindPathContext()).toBe(contexts[0]);
});

it("uses the picked context when all states are empty and claiming it clears others", () => {
const first = new SkeletonDataSourceState();
const second = new SkeletonDataSourceState();
const { layer, contexts } = makeContextTestLayer([first, second]);
const disabled = new SkeletonDataSourceState({
findPath: {
source: { nodeId: "9", segmentId: "9", position: [9, 9, 9] },
},
});
layer.dataSources.push({
loadState: { dataSource: { state: disabled } },
});

expect(
layer.getInitialSpatialSkeletonFindPathContext(contexts[1].skeletonLayer),
).toBe(contexts[1]);

first.findPathState.setSource({
nodeId: 1n,
segmentId: 7n,
position: new Float32Array([1, 2, 3]),
});
layer.claimSpatialSkeletonFindPathContext(contexts[1]);
expect(first.findPathState.toJSON()).toBeUndefined();
expect(disabled.findPathState.toJSON()).toBeUndefined();
expect(layer.claimSpatialSkeletonFindPathContext(contexts[1])).toBe(
second.findPathState,
);
});

it("invalidates results in every loaded datasource after a topology version", () => {
const layer = makeSegmentationUserLayerForFindPathTests();
const first = new SkeletonDataSourceState({
findPath: serializedFindPathState,
});
const second = new SkeletonDataSourceState({
findPath: serializedFindPathState,
});
const contexts = (layer as any).spatialSkeletonFindPathContexts as Map<
unknown,
unknown
>;
for (const dataSourceState of [first, second]) {
const skeletonLayer = {};
contexts.set(skeletonLayer, {
skeletonLayer,
dataSourceState,
loadedSubsource: {
subsourceIndex: 0,
loadedDataSource: { layerDataSource: {} },
},
});
}

layer.spatialSkeletonNodeDataVersion.value++;

for (const state of [first, second]) {
expect(state.findPathState.result).toBeUndefined();
expect(state.findPathState.source?.nodeId).toBe(1n);
expect(state.findPathState.target?.nodeId).toBe(3n);
}
});
});
Loading