From 116bf3129cab610977c69b79b405085bdaee8c27 Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Thu, 30 Apr 2026 12:23:22 +0100 Subject: [PATCH 01/11] refactor: WIP - Tight boundaries between generic spatial skeleton and specific implementation --- src/datasource/catmaid/api.spec.ts | 89 +- src/datasource/catmaid/api.ts | 198 +- src/datasource/catmaid/backend.ts | 2 +- src/datasource/catmaid/edit_state.ts | 90 + src/datasource/catmaid/frontend.ts | 42 +- .../catmaid/skeleton_packing.spec.ts | 7 + src/datasource/catmaid/skeleton_packing.ts | 8 +- .../catmaid/spatial_skeleton_commands.ts | 1980 +++++++++++++++++ src/layer/index.ts | 2 +- src/layer/segmentation/index.spec.ts | 19 +- src/layer/segmentation/index.ts | 146 +- .../spatial_skeleton_commands.spec.ts | 259 ++- .../segmentation/spatial_skeleton_commands.ts | 1731 +------------- .../segmentation/spatial_skeleton_errors.ts | 4 +- src/rendered_data_panel.ts | 6 +- src/skeleton/api.ts | 185 +- src/skeleton/backend.ts | 2 +- src/skeleton/edit_state.ts | 88 +- src/skeleton/frontend.spec.ts | 9 +- src/skeleton/frontend.ts | 18 +- src/skeleton/navigation.ts | 17 +- src/skeleton/skeleton_chunk_serialization.ts | 8 +- src/skeleton/spatial_skeleton_manager.spec.ts | 25 +- src/skeleton/spatial_skeleton_manager.ts | 47 +- src/ui/spatial_skeleton_edit_tab.ts | 6 +- .../spatial_skeleton_edit_tab_render_state.ts | 4 +- src/ui/spatial_skeleton_edit_tool.spec.ts | 52 +- src/ui/spatial_skeleton_edit_tool.ts | 27 +- 28 files changed, 2855 insertions(+), 2216 deletions(-) create mode 100644 src/datasource/catmaid/edit_state.ts create mode 100644 src/datasource/catmaid/spatial_skeleton_commands.ts diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index c2c0f2fd6..e142804fc 100644 --- a/src/datasource/catmaid/api.spec.ts +++ b/src/datasource/catmaid/api.spec.ts @@ -16,10 +16,17 @@ import { describe, expect, it, vi } from "vitest"; -import { CatmaidClient } from "#src/datasource/catmaid/api.js"; +import { + CatmaidClient, + makeCatmaidNodeSourceState, +} from "#src/datasource/catmaid/api.js"; type FetchMock = ReturnType; +function testSourceState(revisionToken: string) { + return makeCatmaidNodeSourceState(revisionToken); +} + function getFetchCall(fetchMock: FetchMock, callIndex = 0) { const call = fetchMock.mock.calls[callIndex]; if (call === undefined) { @@ -185,7 +192,7 @@ describe("CatmaidClient skeleton editing methods", () => { confidence: 100, description: "afonso reviewed it", isTrueEnd: false, - revisionToken: "2026-03-29T10:15:00Z", + sourceState: testSourceState("2026-03-29T10:15:00Z"), }, { nodeId: 22107955, @@ -196,7 +203,7 @@ describe("CatmaidClient skeleton editing methods", () => { confidence: 100, description: "test 123 4", isTrueEnd: false, - revisionToken: "2026-03-29T10:16:00Z", + sourceState: testSourceState("2026-03-29T10:16:00Z"), }, { nodeId: 22107959, @@ -207,7 +214,7 @@ describe("CatmaidClient skeleton editing methods", () => { confidence: 100, description: undefined, isTrueEnd: true, - revisionToken: "2026-03-29T10:17:00Z", + sourceState: testSourceState("2026-03-29T10:17:00Z"), }, ]); }); @@ -270,9 +277,9 @@ describe("CatmaidClient skeleton editing methods", () => { ], }), ).resolves.toEqual({ - resultSkeletonId: 17, - deletedSkeletonId: 21, - stableAnnotationSwap: false, + resultSegmentId: 17, + deletedSegmentId: 21, + directionAdjusted: false, }); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -314,14 +321,14 @@ describe("CatmaidClient skeleton editing methods", () => { parentNodeId: undefined, position: new Float32Array([1, 2, 3]), segmentId: 11, - revisionToken: "2026-03-29T11:50:00Z", + sourceState: testSourceState("2026-03-29T11:50:00Z"), }, { nodeId: 102, parentNodeId: 101, position: new Float32Array([4, 5, 6]), segmentId: 17, - revisionToken: "2026-03-29T11:51:00Z", + sourceState: testSourceState("2026-03-29T11:51:00Z"), }, ]); @@ -364,7 +371,7 @@ describe("CatmaidClient skeleton editing methods", () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it("returns ids and revisions from addNode and sends CATMAID parent state", async () => { + it("returns ids and source state from addNode and sends CATMAID parent state", async () => { const client = new CatmaidClient("https://example.invalid", 1); const fetchMock = vi.fn().mockResolvedValue({ treenode_id: 88, @@ -382,10 +389,10 @@ describe("CatmaidClient skeleton editing methods", () => { }, }), ).resolves.toEqual({ - treenodeId: 88, - skeletonId: 13, - revisionToken: "2026-03-29T12:00:00Z", - parentRevisionToken: "2026-03-29T12:00:01Z", + nodeId: 88, + segmentId: 13, + sourceState: testSourceState("2026-03-29T12:00:00Z"), + parentSourceState: testSourceState("2026-03-29T12:00:01Z"), }); expect(getFetchBody(fetchMock).get("state")).toBe( @@ -403,10 +410,10 @@ describe("CatmaidClient skeleton editing methods", () => { (client as any).fetch = fetchMock; await expect(client.addNode(13, 1, 2, 3)).resolves.toEqual({ - treenodeId: 88, - skeletonId: 13, - revisionToken: "2026-03-29T12:00:00Z", - parentRevisionToken: undefined, + nodeId: 88, + segmentId: 13, + sourceState: testSourceState("2026-03-29T12:00:00Z"), + parentSourceState: undefined, }); expect(getFetchBody(fetchMock).get("state")).toBe( @@ -440,13 +447,13 @@ describe("CatmaidClient skeleton editing methods", () => { ], }), ).resolves.toEqual({ - treenodeId: 89, - skeletonId: 13, - revisionToken: "2026-03-29T12:01:00Z", - parentRevisionToken: "2026-03-29T12:01:01Z", - nodeRevisionUpdates: [ - { nodeId: 11, revisionToken: "2026-03-29T12:01:02Z" }, - { nodeId: 12, revisionToken: "2026-03-29T12:01:03Z" }, + nodeId: 89, + segmentId: 13, + sourceState: testSourceState("2026-03-29T12:01:00Z"), + parentSourceState: testSourceState("2026-03-29T12:01:01Z"), + nodeSourceStateUpdates: [ + { nodeId: 11, sourceState: testSourceState("2026-03-29T12:01:02Z") }, + { nodeId: 12, sourceState: testSourceState("2026-03-29T12:01:03Z") }, ], }); @@ -500,9 +507,15 @@ describe("CatmaidClient skeleton editing methods", () => { ], }), ).resolves.toEqual({ - nodeRevisionUpdates: [ - { nodeId: 201, revisionToken: "2024-03-29T11:28:31.250Z" }, - { nodeId: 202, revisionToken: "2024-03-29T11:28:32.500Z" }, + nodeSourceStateUpdates: [ + { + nodeId: 201, + sourceState: testSourceState("2024-03-29T11:28:31.250Z"), + }, + { + nodeId: 202, + sourceState: testSourceState("2024-03-29T11:28:32.500Z"), + }, ], }); @@ -570,8 +583,8 @@ describe("CatmaidClient skeleton editing methods", () => { children: [{ nodeId: 203, revisionToken: "2026-03-29T12:06:00Z" }], }), ).resolves.toEqual({ - existingSkeletonId: 17, - newSkeletonId: 21, + existingSegmentId: 17, + newSegmentId: 21, }); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -636,7 +649,7 @@ describe("CatmaidClient skeleton editing methods", () => { }, }), ).resolves.toEqual({ - revisionToken: "2026-03-29T12:10:00Z", + sourceState: testSourceState("2026-03-29T12:10:00Z"), }); expect(getFetchBody(fetchMock).get("state")).toBe( @@ -675,9 +688,9 @@ describe("CatmaidClient skeleton editing methods", () => { }, }), ).resolves.toEqual({ - nodeRevisionUpdates: [ - { nodeId: 12, revisionToken: "2026-03-29T12:20:00Z" }, - { nodeId: 13, revisionToken: "2026-03-29T12:20:01Z" }, + nodeSourceStateUpdates: [ + { nodeId: 12, sourceState: testSourceState("2026-03-29T12:20:00Z") }, + { nodeId: 13, sourceState: testSourceState("2026-03-29T12:20:01Z") }, ], }); @@ -705,7 +718,7 @@ describe("CatmaidClient skeleton editing methods", () => { client.updateDescription(11, "updated description"), ).resolves.toEqual({ description: "updated description", - revisionToken: "2026-03-29T13:00:00Z", + sourceState: testSourceState("2026-03-29T13:00:00Z"), }); const requestBody = getFetchBody(fetchMock); @@ -723,10 +736,10 @@ describe("CatmaidClient skeleton editing methods", () => { (client as any).fetch = fetchMock; await expect(client.setTrueEnd(11)).resolves.toEqual({ - revisionToken: "2026-03-29T13:10:00Z", + sourceState: testSourceState("2026-03-29T13:10:00Z"), }); await expect(client.removeTrueEnd(11)).resolves.toEqual({ - revisionToken: "2026-03-29T13:11:00Z", + sourceState: testSourceState("2026-03-29T13:11:00Z"), }); const addTagRequestBody = getFetchBody(fetchMock, 0); @@ -753,7 +766,7 @@ describe("CatmaidClient skeleton editing methods", () => { }, }), ).resolves.toEqual({ - revisionToken: "2026-03-29T13:20:00Z", + sourceState: testSourceState("2026-03-29T13:20:00Z"), }); expect(getFetchPath(fetchMock)).toBe("treenodes/11/confidence"); diff --git a/src/datasource/catmaid/api.ts b/src/datasource/catmaid/api.ts index be5b7ac08..baa00a4c7 100644 --- a/src/datasource/catmaid/api.ts +++ b/src/datasource/catmaid/api.ts @@ -17,24 +17,22 @@ import { Unpackr } from "msgpackr"; import { fetchOkWithCredentials } from "#src/credentials_provider/http_request.js"; import type { CredentialsProvider } from "#src/credentials_provider/index.js"; +import { SpatialSkeletonEditConflictError } from "#src/skeleton/api.js"; import type { - EditableSpatiallyIndexedSkeletonSource, SpatiallyIndexedSkeletonAddNodeResult, SpatiallyIndexedSkeletonDeleteNodeResult, SpatiallyIndexedSkeletonDescriptionUpdateResult, - SpatiallyIndexedSkeletonEditContext, SpatiallyIndexedSkeletonInsertNodeResult, SpatiallyIndexedSkeletonMergeResult, SpatiallyIndexedSkeletonMetadata, SpatiallyIndexedSkeletonNavigationTarget, SpatiallyIndexedSkeletonNode, - SpatiallyIndexedSkeletonNodeRevisionResult, - SpatiallyIndexedSkeletonNodeRevisionUpdate, + SpatiallyIndexedSkeletonNodeSourceStateResult, + SpatiallyIndexedSkeletonNodeSourceStateUpdate, SpatiallyIndexedSkeletonNodeBase, SpatiallyIndexedSkeletonRerootResult, SpatiallyIndexedSkeletonSplitResult, } from "#src/skeleton/api.js"; -import { SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES } from "#src/skeleton/api.js"; import { getDefaultSpatiallyIndexedSkeletonChunkSize } from "#src/skeleton/spatial_chunk_sizing.js"; import { HttpError } from "#src/util/http_request.js"; @@ -59,9 +57,31 @@ const CATMAID_STATE_MATCHING_ERROR_TYPE = "StateMatchingError"; type CatmaidStatePayload = object; +export interface CatmaidNodeSourceState { + revisionToken: string; +} + +export interface CatmaidEditNodeContext { + nodeId: number; + parentNodeId?: number; + revisionToken: string; +} + +export interface CatmaidEditParentContext { + nodeId: number; + revisionToken: string; +} + +export interface CatmaidEditContext { + node?: CatmaidEditNodeContext; + parent?: CatmaidEditParentContext; + children?: readonly CatmaidEditParentContext[]; + nodes?: readonly CatmaidEditParentContext[]; +} + interface CatmaidDeleteNodeOptions { childNodeIds?: readonly number[]; - editContext?: SpatiallyIndexedSkeletonEditContext; + editContext?: CatmaidEditContext; } class CatmaidNotFoundError extends Error { @@ -71,7 +91,7 @@ class CatmaidNotFoundError extends Error { } } -export class CatmaidStateValidationError extends Error { +export class CatmaidStateValidationError extends SpatialSkeletonEditConflictError { constructor(detail?: string) { super( detail === undefined @@ -82,6 +102,36 @@ export class CatmaidStateValidationError extends Error { } } +export function makeCatmaidNodeSourceState( + revisionToken: string | undefined, +): CatmaidNodeSourceState | undefined { + return revisionToken === undefined ? undefined : { revisionToken }; +} + +export function getCatmaidRevisionToken( + sourceState: unknown, +): string | undefined { + if (typeof sourceState === "string") { + return sourceState.trim().length === 0 ? undefined : sourceState; + } + if ( + sourceState !== null && + typeof sourceState === "object" && + typeof (sourceState as { revisionToken?: unknown }).revisionToken === + "string" + ) { + const revisionToken = ( + sourceState as { revisionToken: string } + ).revisionToken.trim(); + return revisionToken.length === 0 ? undefined : revisionToken; + } + return undefined; +} + +export const CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES = [ + 0, 25, 50, 75, 100, +] as const; + const CATMAID_TRUE_END_LABEL = "ends"; const CATMAID_CLOSED_END_LABEL_PATTERNS = [ /^uncertain continuation$/i, @@ -327,38 +377,34 @@ function mapCatmaidConfidenceToPercent(confidence: number | undefined) { const normalized = Math.max( 1, Math.min( - SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES.length, + CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES.length, Math.round(confidence), ), ); - return SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES[normalized - 1]; + return CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES[normalized - 1]; } function mapPercentConfidenceToCatmaid(confidence: number) { const normalized = Math.max( - SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES[0], + CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES[0], Math.min( - SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES[ - SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES.length - 1 + CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES[ + CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES.length - 1 ], confidence, ), ); let bestIndex = 0; let bestDistance = Math.abs( - SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES[0] - normalized, + CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES[0] - normalized, ); - for ( - let i = 1; - i < SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES.length; - ++i - ) { - const candidate = SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES[i]; + for (let i = 1; i < CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES.length; ++i) { + const candidate = CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES[i]; const distance = Math.abs(candidate - normalized); if ( distance < bestDistance || (distance === bestDistance && - candidate > SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES[bestIndex]) + candidate > CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES[bestIndex]) ) { bestDistance = distance; bestIndex = i; @@ -540,7 +586,7 @@ function requireCatmaidRevisionToken( function buildCatmaidNodeState( operation: string, - editContext?: SpatiallyIndexedSkeletonEditContext, + editContext?: CatmaidEditContext, expectedNodeId?: number, ) { const node = editContext?.node; @@ -563,7 +609,7 @@ function buildCatmaidNodeState( function buildCatmaidMultiNodeState( operation: string, - editContext?: SpatiallyIndexedSkeletonEditContext, + editContext?: CatmaidEditContext, expectedNodeIds?: readonly number[], ) { const nodes = @@ -589,7 +635,7 @@ function buildCatmaidMultiNodeState( function buildCatmaidAddNodeState( parentId: number | undefined, - editContext?: SpatiallyIndexedSkeletonEditContext, + editContext?: CatmaidEditContext, ) { if (parentId === undefined) { return { @@ -621,7 +667,7 @@ function buildCatmaidAddNodeState( function buildCatmaidNeighborhoodState( operation: string, - editContext?: SpatiallyIndexedSkeletonEditContext, + editContext?: CatmaidEditContext, options: { expectedNodeId?: number; expectedChildIds?: readonly number[]; @@ -703,7 +749,7 @@ function buildCatmaidNeighborhoodState( function buildCatmaidInsertNodeState( parentId: number, childNodeIds: readonly number[], - editContext?: SpatiallyIndexedSkeletonEditContext, + editContext?: CatmaidEditContext, ) { const parentNode = editContext?.node; if (parentNode === undefined) { @@ -743,19 +789,20 @@ function buildCatmaidInsertNodeState( function getCatmaidSingleNodeRevisionResult( revisionToken: string | undefined, -): SpatiallyIndexedSkeletonNodeRevisionResult { - return revisionToken === undefined ? {} : { revisionToken }; +): SpatiallyIndexedSkeletonNodeSourceStateResult { + const sourceState = makeCatmaidNodeSourceState(revisionToken); + return sourceState === undefined ? {} : { sourceState }; } function parseCatmaidNodeRevisionUpdates( rows: unknown, -): SpatiallyIndexedSkeletonNodeRevisionUpdate[] { +): SpatiallyIndexedSkeletonNodeSourceStateUpdate[] { if (!Array.isArray(rows)) { throw new Error( "CATMAID treenodes/compact-detail endpoint returned an unexpected response format.", ); } - const revisionUpdates: SpatiallyIndexedSkeletonNodeRevisionUpdate[] = []; + const revisionUpdates: SpatiallyIndexedSkeletonNodeSourceStateUpdate[] = []; for (const row of rows) { if (!Array.isArray(row) || row.length < 9) continue; const nodeId = Number(row[0]); @@ -763,7 +810,7 @@ function parseCatmaidNodeRevisionUpdates( if (!Number.isFinite(nodeId) || revisionToken === undefined) continue; revisionUpdates.push({ nodeId: Math.round(nodeId), - revisionToken, + sourceState: { revisionToken }, }); } return revisionUpdates; @@ -826,8 +873,8 @@ function parseCatmaidConfidenceRevisionToken( function parseCatmaidChildRevisionUpdates( value: unknown, -): readonly SpatiallyIndexedSkeletonNodeRevisionUpdate[] { - const revisionUpdates: SpatiallyIndexedSkeletonNodeRevisionUpdate[] = []; +): readonly SpatiallyIndexedSkeletonNodeSourceStateUpdate[] { + const revisionUpdates: SpatiallyIndexedSkeletonNodeSourceStateUpdate[] = []; const children = Array.isArray(value) ? value : []; for (const child of children) { if (!Array.isArray(child) || child.length < 2) continue; @@ -836,7 +883,7 @@ function parseCatmaidChildRevisionUpdates( if (!Number.isFinite(nodeId) || revisionToken === undefined) continue; revisionUpdates.push({ nodeId: Math.round(nodeId), - revisionToken, + sourceState: { revisionToken }, }); } return revisionUpdates; @@ -844,7 +891,7 @@ function parseCatmaidChildRevisionUpdates( function parseCatmaidDeleteRevisionUpdates( response: any, -): readonly SpatiallyIndexedSkeletonNodeRevisionUpdate[] { +): readonly SpatiallyIndexedSkeletonNodeSourceStateUpdate[] { return parseCatmaidChildRevisionUpdates(response?.children); } @@ -878,7 +925,7 @@ function fetchWithCatmaidCredentials( ); } -export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { +export class CatmaidClient { private metadataInfoPromise: Promise | undefined; private readonly msgpackUnpackr = new Unpackr({ mapsAsObjects: false, @@ -1107,7 +1154,9 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { : undefined, description: descriptionByNodeId.get(Number(n[0])), isTrueEnd: trueEndByNodeId.has(Number(n[0])), - revisionToken: getCatmaidHistoryRevisionToken(n), + sourceState: makeCatmaidNodeSourceState( + getCatmaidHistoryRevisionToken(n), + ), })); } @@ -1179,7 +1228,9 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { parentNodeId: n[1] ?? undefined, position: new Float32Array([n[2], n[3], n[4]]), segmentId: n[7], - revisionToken: normalizeCatmaidRevisionToken(n[8]), + sourceState: makeCatmaidNodeSourceState( + normalizeCatmaidRevisionToken(n[8]), + ), }), ); @@ -1200,7 +1251,9 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { parentNodeId: n[1] ?? undefined, position: new Float32Array([n[2], n[3], n[4]]), segmentId: n[7], - revisionToken: normalizeCatmaidRevisionToken(n[8]), + sourceState: makeCatmaidNodeSourceState( + normalizeCatmaidRevisionToken(n[8]), + ), }); } } @@ -1215,8 +1268,8 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { x: number, y: number, z: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise { + editContext?: CatmaidEditContext, + ): Promise { const body = new URLSearchParams(); appendNodeUpdateRows(body, "t", [[nodeId, x, y, z]]); appendCatmaidState( @@ -1242,7 +1295,7 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { async rerootSkeleton( nodeId: number, - editContext?: SpatiallyIndexedSkeletonEditContext, + editContext?: CatmaidEditContext, ): Promise { const body = new URLSearchParams({ treenode_id: nodeId.toString(), @@ -1263,13 +1316,14 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { .filter((value) => Number.isFinite(value)) .map((value) => Math.round(value)) ?? []; return { - nodeRevisionUpdates: await this.fetchNodeRevisionUpdates(rerootedNodeIds), + nodeSourceStateUpdates: + await this.fetchNodeRevisionUpdates(rerootedNodeIds), }; } private async fetchNodeRevisionUpdates( nodeIds: readonly number[], - ): Promise { + ): Promise { const normalizedNodeIds = [ ...new Set( nodeIds @@ -1333,7 +1387,7 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { throw new Error("Delete endpoint returned an unexpected response."); } return { - nodeRevisionUpdates: parseCatmaidDeleteRevisionUpdates(response), + nodeSourceStateUpdates: parseCatmaidDeleteRevisionUpdates(response), }; } @@ -1343,7 +1397,7 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { y: number, z: number, parentId?: number, - editContext?: SpatiallyIndexedSkeletonEditContext, + editContext?: CatmaidEditContext, ): Promise { const body = new URLSearchParams({ x: x.toString(), @@ -1373,11 +1427,13 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { ); } return { - treenodeId, - skeletonId: nextSkeletonId, - revisionToken: normalizeCatmaidRevisionToken(res?.edition_time), - parentRevisionToken: normalizeCatmaidRevisionToken( - res?.parent_edition_time, + nodeId: Math.round(treenodeId), + segmentId: Math.round(nextSkeletonId), + sourceState: makeCatmaidNodeSourceState( + normalizeCatmaidRevisionToken(res?.edition_time), + ), + parentSourceState: makeCatmaidNodeSourceState( + normalizeCatmaidRevisionToken(res?.parent_edition_time), ), }; } @@ -1389,7 +1445,7 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { z: number, parentId: number, childNodeIds: readonly number[], - editContext?: SpatiallyIndexedSkeletonEditContext, + editContext?: CatmaidEditContext, ): Promise { const normalizedChildIds = [ ...new Set( @@ -1437,13 +1493,15 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { ); } return { - treenodeId: Math.round(treenodeId), - skeletonId: Math.round(nextSkeletonId), - revisionToken: normalizeCatmaidRevisionToken(response?.edition_time), - parentRevisionToken: normalizeCatmaidRevisionToken( - response?.parent_edition_time, + nodeId: Math.round(treenodeId), + segmentId: Math.round(nextSkeletonId), + sourceState: makeCatmaidNodeSourceState( + normalizeCatmaidRevisionToken(response?.edition_time), + ), + parentSourceState: makeCatmaidNodeSourceState( + normalizeCatmaidRevisionToken(response?.parent_edition_time), ), - nodeRevisionUpdates: parseCatmaidChildRevisionUpdates( + nodeSourceStateUpdates: parseCatmaidChildRevisionUpdates( response?.child_edition_times, ), }; @@ -1544,7 +1602,7 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { async setTrueEnd( nodeId: number, - ): Promise { + ): Promise { const response = await this.addNodeLabel(nodeId, CATMAID_TRUE_END_LABEL); return getCatmaidSingleNodeRevisionResult( normalizeCatmaidRevisionToken((response as any)?.edition_time), @@ -1553,7 +1611,7 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { async removeTrueEnd( nodeId: number, - ): Promise { + ): Promise { const response = await this.removeNodeLabel(nodeId, CATMAID_TRUE_END_LABEL); return getCatmaidSingleNodeRevisionResult( normalizeCatmaidRevisionToken((response as any)?.edition_time), @@ -1563,8 +1621,8 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { async updateRadius( nodeId: number, radius: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise { + editContext?: CatmaidEditContext, + ): Promise { if (!Number.isFinite(radius)) { throw new Error("Radius must be a finite number."); } @@ -1587,8 +1645,8 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { async updateConfidence( nodeId: number, confidence: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise { + editContext?: CatmaidEditContext, + ): Promise { if (!Number.isFinite(confidence) || confidence < 0 || confidence > 100) { throw new Error("Confidence must be between 0 and 100."); } @@ -1611,7 +1669,7 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { async mergeSkeletons( fromNodeId: number, toNodeId: number, - editContext?: SpatiallyIndexedSkeletonEditContext, + editContext?: CatmaidEditContext, ): Promise { const body = new URLSearchParams({ from_id: fromNodeId.toString(), @@ -1631,19 +1689,19 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { const resultSkeletonId = Number(response?.result_skeleton_id); const deletedSkeletonId = Number(response?.deleted_skeleton_id); return { - resultSkeletonId: Number.isFinite(resultSkeletonId) + resultSegmentId: Number.isFinite(resultSkeletonId) ? Math.round(resultSkeletonId) : undefined, - deletedSkeletonId: Number.isFinite(deletedSkeletonId) + deletedSegmentId: Number.isFinite(deletedSkeletonId) ? Math.round(deletedSkeletonId) : undefined, - stableAnnotationSwap: Boolean(response?.stable_annotation_swap), + directionAdjusted: Boolean(response?.stable_annotation_swap), }; } async splitSkeleton( nodeId: number, - editContext?: SpatiallyIndexedSkeletonEditContext, + editContext?: CatmaidEditContext, ): Promise { const body = new URLSearchParams({ treenode_id: nodeId.toString(), @@ -1661,10 +1719,10 @@ export class CatmaidClient implements EditableSpatiallyIndexedSkeletonSource { const existingSkeletonId = Number(response?.existing_skeleton_id); const newSkeletonId = Number(response?.new_skeleton_id); return { - existingSkeletonId: Number.isFinite(existingSkeletonId) + existingSegmentId: Number.isFinite(existingSkeletonId) ? Math.round(existingSkeletonId) : undefined, - newSkeletonId: Number.isFinite(newSkeletonId) + newSegmentId: Number.isFinite(newSkeletonId) ? Math.round(newSkeletonId) : undefined, }; diff --git a/src/datasource/catmaid/backend.ts b/src/datasource/catmaid/backend.ts index d4d42e622..f36990e78 100644 --- a/src/datasource/catmaid/backend.ts +++ b/src/datasource/catmaid/backend.ts @@ -97,7 +97,7 @@ export class CatmaidSpatiallyIndexedSkeletonSourceBackend extends WithParameters // Pack only segment IDs into vertexAttributes (positions are in vertexPositions) chunk.vertexAttributes = [packed.segmentIds]; chunk.nodeIds = packed.nodeIds; - chunk.nodeRevisionTokens = packed.revisionTokens; + chunk.nodeSourceStates = packed.sourceStates; } } diff --git a/src/datasource/catmaid/edit_state.ts b/src/datasource/catmaid/edit_state.ts new file mode 100644 index 000000000..580cd67dd --- /dev/null +++ b/src/datasource/catmaid/edit_state.ts @@ -0,0 +1,90 @@ +import type { + CatmaidEditContext, + CatmaidEditNodeContext, + CatmaidEditParentContext, +} from "#src/datasource/catmaid/api.js"; +import { getCatmaidRevisionToken } from "#src/datasource/catmaid/api.js"; +import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; +import { + getSpatiallyIndexedSkeletonDirectChildren, + getSpatiallyIndexedSkeletonNodeParent, + getSpatiallyIndexedSkeletonPathToRoot, +} from "#src/skeleton/edit_state.js"; + +function requireRevisionToken( + node: SpatiallyIndexedSkeletonNode, + role: string, +): string { + const revisionToken = getCatmaidRevisionToken(node.sourceState); + if (revisionToken === undefined) { + throw new Error( + `Inspected CATMAID ${role} node ${node.nodeId} is missing revision metadata.`, + ); + } + return revisionToken; +} + +export function toCatmaidEditNodeContext( + node: SpatiallyIndexedSkeletonNode, +): CatmaidEditNodeContext { + return { + nodeId: node.nodeId, + parentNodeId: node.parentNodeId, + revisionToken: requireRevisionToken(node, "target"), + }; +} + +export function toCatmaidEditParentContext( + node: SpatiallyIndexedSkeletonNode, +): CatmaidEditParentContext { + return { + nodeId: node.nodeId, + revisionToken: requireRevisionToken(node, "related"), + }; +} + +export function buildCatmaidNodeEditContext( + node: SpatiallyIndexedSkeletonNode, +): CatmaidEditContext { + return { + node: toCatmaidEditNodeContext(node), + }; +} + +export function buildCatmaidNeighborhoodEditContext( + node: SpatiallyIndexedSkeletonNode, + segmentNodes: readonly SpatiallyIndexedSkeletonNode[], +): CatmaidEditContext { + const parentNode = getSpatiallyIndexedSkeletonNodeParent(segmentNodes, node); + const childNodes = getSpatiallyIndexedSkeletonDirectChildren( + segmentNodes, + node.nodeId, + ); + return { + node: toCatmaidEditNodeContext(node), + ...(parentNode === undefined + ? {} + : { parent: toCatmaidEditParentContext(parentNode) }), + children: childNodes.map(toCatmaidEditParentContext), + }; +} + +export function buildCatmaidRerootEditContext( + node: SpatiallyIndexedSkeletonNode, + segmentNodes: readonly SpatiallyIndexedSkeletonNode[], +): CatmaidEditContext { + return { + ...buildCatmaidNeighborhoodEditContext(node, segmentNodes), + nodes: getSpatiallyIndexedSkeletonPathToRoot(segmentNodes, node).map( + toCatmaidEditParentContext, + ), + }; +} + +export function buildCatmaidMultiNodeEditContext( + ...nodes: SpatiallyIndexedSkeletonNode[] +): CatmaidEditContext { + return { + nodes: nodes.map(toCatmaidEditParentContext), + }; +} diff --git a/src/datasource/catmaid/frontend.ts b/src/datasource/catmaid/frontend.ts index 689125023..7b1e4682d 100644 --- a/src/datasource/catmaid/frontend.ts +++ b/src/datasource/catmaid/frontend.ts @@ -23,13 +23,17 @@ import { } from "#src/coordinate_transform.js"; import { WithCredentialsProvider } from "#src/credentials_provider/chunk_source_frontend.js"; import type { CredentialsProvider } from "#src/credentials_provider/index.js"; -import type { CatmaidToken } from "#src/datasource/catmaid/api.js"; +import type { + CatmaidEditContext, + CatmaidToken, +} from "#src/datasource/catmaid/api.js"; import { CatmaidClient, credentialsKey } from "#src/datasource/catmaid/api.js"; import { CatmaidSkeletonSourceParameters, CatmaidCompleteSkeletonSourceParameters, CatmaidDataSourceParameters, } from "#src/datasource/catmaid/base.js"; +import { CatmaidSpatialSkeletonEditController } from "#src/datasource/catmaid/spatial_skeleton_commands.js"; import type { DataSource, DataSourceProvider, @@ -44,12 +48,11 @@ import type { SpatiallyIndexedSkeletonAddNodeResult, SpatiallyIndexedSkeletonDeleteNodeResult, SpatiallyIndexedSkeletonDescriptionUpdateResult, - SpatiallyIndexedSkeletonEditContext, SpatiallyIndexedSkeletonInsertNodeResult, SpatiallyIndexedSkeletonMergeResult, SpatiallyIndexedSkeletonMetadata, SpatiallyIndexedSkeletonNode, - SpatiallyIndexedSkeletonNodeRevisionResult, + SpatiallyIndexedSkeletonNodeSourceStateResult, SpatiallyIndexedSkeletonNodeBase, SpatiallyIndexedSkeletonSplitResult, } from "#src/skeleton/api.js"; @@ -76,6 +79,8 @@ export class CatmaidSpatiallyIndexedSkeletonSource ) implements EditableSpatiallyIndexedSkeletonSource { + readonly spatialSkeletonEditController = + new CatmaidSpatialSkeletonEditController(); private client_?: CatmaidClient; private get client() { @@ -132,7 +137,7 @@ export class CatmaidSpatiallyIndexedSkeletonSource y: number, z: number, parentId?: number, - editContext?: SpatiallyIndexedSkeletonEditContext, + editContext?: CatmaidEditContext, ): Promise { return this.client.addNode(skeletonId, x, y, z, parentId, editContext); } @@ -144,7 +149,7 @@ export class CatmaidSpatiallyIndexedSkeletonSource z: number, parentId: number, childNodeIds: readonly number[], - editContext?: SpatiallyIndexedSkeletonEditContext, + editContext?: CatmaidEditContext, ): Promise { return this.client.insertNode( skeletonId, @@ -162,8 +167,8 @@ export class CatmaidSpatiallyIndexedSkeletonSource x: number, y: number, z: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise { + editContext?: CatmaidEditContext, + ): Promise { return this.client.moveNode(nodeId, x, y, z, editContext); } @@ -171,16 +176,13 @@ export class CatmaidSpatiallyIndexedSkeletonSource nodeId: number, options: { childNodeIds?: readonly number[]; - editContext?: SpatiallyIndexedSkeletonEditContext; + editContext?: CatmaidEditContext; }, ): Promise { return this.client.deleteNode(nodeId, options); } - rerootSkeleton( - nodeId: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ) { + rerootSkeleton(nodeId: number, editContext?: CatmaidEditContext) { return this.client.rerootSkeleton(nodeId, editContext); } @@ -193,43 +195,43 @@ export class CatmaidSpatiallyIndexedSkeletonSource setTrueEnd( nodeId: number, - ): Promise { + ): Promise { return this.client.setTrueEnd(nodeId); } removeTrueEnd( nodeId: number, - ): Promise { + ): Promise { return this.client.removeTrueEnd(nodeId); } updateRadius( nodeId: number, radius: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise { + editContext?: CatmaidEditContext, + ): Promise { return this.client.updateRadius(nodeId, radius, editContext); } updateConfidence( nodeId: number, confidence: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise { + editContext?: CatmaidEditContext, + ): Promise { return this.client.updateConfidence(nodeId, confidence, editContext); } mergeSkeletons( fromNodeId: number, toNodeId: number, - editContext?: SpatiallyIndexedSkeletonEditContext, + editContext?: CatmaidEditContext, ): Promise { return this.client.mergeSkeletons(fromNodeId, toNodeId, editContext); } splitSkeleton( nodeId: number, - editContext?: SpatiallyIndexedSkeletonEditContext, + editContext?: CatmaidEditContext, ): Promise { return this.client.splitSkeleton(nodeId, editContext); } diff --git a/src/datasource/catmaid/skeleton_packing.spec.ts b/src/datasource/catmaid/skeleton_packing.spec.ts index bd5b15b49..eda5658f8 100644 --- a/src/datasource/catmaid/skeleton_packing.spec.ts +++ b/src/datasource/catmaid/skeleton_packing.spec.ts @@ -11,12 +11,14 @@ describe("datasource/catmaid/skeleton_packing", () => { parentNodeId: undefined, position: new Float32Array([1, 2, 3]), segmentId: 10, + sourceState: { revisionToken: "node-1" }, }, { nodeId: 2, parentNodeId: 1, position: new Float32Array([4, 5, 6]), segmentId: 10, + sourceState: { revisionToken: "node-2" }, }, { nodeId: 3, @@ -34,6 +36,11 @@ describe("datasource/catmaid/skeleton_packing", () => { expect(packed.segmentIds).toEqual(Uint32Array.of(10, 10, 11)); expect(packed.indices).toEqual(Uint32Array.of(1, 0)); expect(packed.nodeIds).toEqual(Int32Array.of(1, 2, 3)); + expect(packed.sourceStates).toEqual([ + { revisionToken: "node-1" }, + { revisionToken: "node-2" }, + undefined, + ]); }); it("preserves large segment ids exactly", () => { diff --git a/src/datasource/catmaid/skeleton_packing.ts b/src/datasource/catmaid/skeleton_packing.ts index 1f4bd74b5..5c54168fd 100644 --- a/src/datasource/catmaid/skeleton_packing.ts +++ b/src/datasource/catmaid/skeleton_packing.ts @@ -21,7 +21,7 @@ interface PackedCatmaidSkeletonData { segmentIds: Uint32Array; indices: Uint32Array; nodeIds: Int32Array; - revisionTokens: Array; + sourceStates: unknown[]; } export function packCatmaidSkeletonNodes( @@ -31,7 +31,7 @@ export function packCatmaidSkeletonNodes( const vertexPositions = new Float32Array(numVertices * 3); const segmentIds = new Uint32Array(numVertices); const nodeIds = new Int32Array(numVertices); - const revisionTokens = new Array(numVertices); + const sourceStates = new Array(numVertices); const indices: number[] = []; const nodeMap = new Map(); @@ -43,7 +43,7 @@ export function packCatmaidSkeletonNodes( vertexPositions[i * 3 + 1] = node.position[1]; vertexPositions[i * 3 + 2] = node.position[2]; segmentIds[i] = node.segmentId; - revisionTokens[i] = node.revisionToken; + sourceStates[i] = node.sourceState; } for (let i = 0; i < numVertices; ++i) { @@ -60,6 +60,6 @@ export function packCatmaidSkeletonNodes( segmentIds, indices: new Uint32Array(indices), nodeIds, - revisionTokens, + sourceStates, }; } diff --git a/src/datasource/catmaid/spatial_skeleton_commands.ts b/src/datasource/catmaid/spatial_skeleton_commands.ts new file mode 100644 index 000000000..62e5817e7 --- /dev/null +++ b/src/datasource/catmaid/spatial_skeleton_commands.ts @@ -0,0 +1,1980 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; +import type { CatmaidEditContext } from "#src/datasource/catmaid/api.js"; +import { getCatmaidRevisionToken } from "#src/datasource/catmaid/api.js"; +import { + buildCatmaidMultiNodeEditContext, + buildCatmaidNeighborhoodEditContext, + buildCatmaidNodeEditContext, + buildCatmaidRerootEditContext, +} from "#src/datasource/catmaid/edit_state.js"; +import { + addSegmentToVisibleSets, + removeSegmentFromVisibleSets, +} from "#src/segmentation_display_state/base.js"; +import type { + EditableSpatiallyIndexedSkeletonSource, + SpatiallyIndexedSkeletonAddNodeResult, + SpatiallyIndexedSkeletonInsertNodeResult, + SpatiallyIndexedSkeletonMergeResult, + SpatiallyIndexedSkeletonNode, + SpatiallyIndexedSkeletonNodeSourceStateUpdate, + SpatiallyIndexedSkeletonSplitResult, + SpatialSkeletonAddNodeCommandOptions, + SpatialSkeletonEditController, + SpatialSkeletonMergeEndpoint, + SpatialSkeletonMoveNodeCommandOptions, + SpatialSkeletonNodeDescriptionCommandOptions, + SpatialSkeletonNodePropertiesCommandOptions, + SpatialSkeletonNodeTrueEndCommandOptions, +} from "#src/skeleton/api.js"; +import { SpatialSkeletonActions } from "#src/skeleton/actions.js"; +import type { + SpatialSkeletonCommand, + SpatialSkeletonCommandContext, +} from "#src/skeleton/command_history.js"; +import { + findSpatiallyIndexedSkeletonNode, + getSpatiallyIndexedSkeletonDirectChildren, +} from "#src/skeleton/edit_state.js"; +import type { SpatiallyIndexedSkeletonLayer } from "#src/skeleton/frontend.js"; +import { getEditableSpatiallyIndexedSkeletonSource } from "#src/skeleton/spatial_skeleton_manager.js"; +import { StatusMessage } from "#src/status.js"; +import { formatErrorMessage } from "#src/util/error.js"; + +function cloneNodeSnapshot( + node: SpatiallyIndexedSkeletonNode, +): SpatiallyIndexedSkeletonNode { + return { + nodeId: node.nodeId, + segmentId: node.segmentId, + position: new Float32Array(node.position), + parentNodeId: node.parentNodeId, + radius: node.radius, + confidence: node.confidence, + description: node.description, + isTrueEnd: node.isTrueEnd ?? false, + sourceState: node.sourceState, + }; +} + +function getEditableSkeletonSourceForLayer(layer: SegmentationUserLayer): { + skeletonLayer: SpatiallyIndexedSkeletonLayer; + skeletonSource: EditableSpatiallyIndexedSkeletonSource; +} { + const skeletonLayer = layer.getSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) { + throw new Error( + "No spatially indexed skeleton source is currently loaded.", + ); + } + const skeletonSource = + getEditableSpatiallyIndexedSkeletonSource(skeletonLayer); + if (skeletonSource === undefined) { + throw new Error( + "Unable to resolve editable skeleton source for the active layer.", + ); + } + return { skeletonLayer, skeletonSource }; +} + +function ensureVisibleSegment( + layer: SegmentationUserLayer, + segmentId: number | undefined, +) { + if ( + segmentId === undefined || + !Number.isSafeInteger(Math.round(Number(segmentId))) || + Math.round(Number(segmentId)) <= 0 + ) { + return; + } + addSegmentToVisibleSets( + layer.displayState.segmentationGroupState.value, + BigInt(Math.round(Number(segmentId))), + ); +} + +function selectSegment( + layer: SegmentationUserLayer, + segmentId: number | undefined, + pin: boolean, +) { + if ( + segmentId === undefined || + !Number.isSafeInteger(Math.round(Number(segmentId))) || + Math.round(Number(segmentId)) <= 0 + ) { + return; + } + layer.selectSegment(BigInt(Math.round(Number(segmentId))), pin); +} + +function removeVisibleSegment( + layer: SegmentationUserLayer, + segmentId: number | undefined, + options: { + deselect?: boolean; + } = {}, +) { + if ( + segmentId === undefined || + !Number.isSafeInteger(Math.round(Number(segmentId))) || + Math.round(Number(segmentId)) <= 0 + ) { + return; + } + removeSegmentFromVisibleSets( + layer.displayState.segmentationGroupState.value, + BigInt(Math.round(Number(segmentId))), + options, + ); +} + +function findRootNode(segmentNodes: readonly SpatiallyIndexedSkeletonNode[]) { + return segmentNodes.find((candidate) => candidate.parentNodeId === undefined); +} + +interface ResolvedSpatialSkeletonEditNode { + skeletonLayer: SpatiallyIndexedSkeletonLayer; + skeletonSource: EditableSpatiallyIndexedSkeletonSource; + segmentNodes: readonly SpatiallyIndexedSkeletonNode[]; + node: SpatiallyIndexedSkeletonNode; +} + +interface ResolvedSpatialSkeletonEditNodeContext { + currentNodeId: number; + segmentId: number; + cachedNode: SpatiallyIndexedSkeletonNode | undefined; + skeletonLayer: SpatiallyIndexedSkeletonLayer; + skeletonSource: EditableSpatiallyIndexedSkeletonSource; +} + +function getResolvedNodeContextForEdit( + layer: SegmentationUserLayer, + stableNodeId: number, + stableSegmentId: number | undefined, +): ResolvedSpatialSkeletonEditNodeContext { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + const currentNodeId = commandMappings.resolveNodeId(stableNodeId); + if (currentNodeId === undefined) { + throw new Error(`Unable to resolve current node ${stableNodeId}.`); + } + const { skeletonLayer, skeletonSource } = + getEditableSkeletonSourceForLayer(layer); + const cachedNode = + layer.spatialSkeletonState.getCachedNode(currentNodeId) ?? + skeletonLayer.getNode(currentNodeId); + const candidateSegmentId = + cachedNode?.segmentId ?? commandMappings.resolveSegmentId(stableSegmentId); + if (candidateSegmentId === undefined) { + throw new Error( + `Unable to resolve the current segment for node ${stableNodeId}.`, + ); + } + return { + currentNodeId, + segmentId: candidateSegmentId, + cachedNode, + skeletonLayer, + skeletonSource, + }; +} + +async function getResolvedNodeForEdit( + layer: SegmentationUserLayer, + stableNodeId: number, + stableSegmentId: number | undefined, +): Promise { + const { + currentNodeId, + segmentId: candidateSegmentId, + skeletonLayer, + skeletonSource, + } = getResolvedNodeContextForEdit(layer, stableNodeId, stableSegmentId); + let segmentNodes = + layer.spatialSkeletonState.getCachedSegmentNodes(candidateSegmentId); + if (segmentNodes === undefined) { + segmentNodes = await layer.spatialSkeletonState.getFullSegmentNodes( + skeletonLayer, + candidateSegmentId, + ); + } + const node = findSpatiallyIndexedSkeletonNode(segmentNodes, currentNodeId); + if (node === undefined) { + throw new Error( + `Node ${currentNodeId} is not available in the inspected skeleton cache.`, + ); + } + return { + skeletonLayer, + skeletonSource, + segmentNodes, + node, + }; +} + +function buildInsertEditContext( + parentNode: SpatiallyIndexedSkeletonNode, + childNodes: readonly SpatiallyIndexedSkeletonNode[], +): CatmaidEditContext { + return { + node: buildCatmaidNodeEditContext(parentNode).node, + children: childNodes.map((child) => { + const revisionToken = getCatmaidRevisionToken(child.sourceState); + if (revisionToken === undefined) { + throw new Error( + `Inspected CATMAID child node ${child.nodeId} is missing revision metadata.`, + ); + } + return { nodeId: child.nodeId, revisionToken }; + }), + }; +} + +async function refreshTopologySegments( + layer: SegmentationUserLayer, + segmentIds: readonly number[], +) { + const normalizedSegmentIds = [ + ...new Set( + segmentIds.filter((value) => Number.isSafeInteger(Math.round(value))), + ), + ].map((value) => Math.round(value)); + if (normalizedSegmentIds.length === 0) { + return; + } + const { skeletonLayer } = getEditableSkeletonSourceForLayer(layer); + layer.spatialSkeletonState.invalidateCachedSegments(normalizedSegmentIds); + layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + skeletonLayer.invalidateSourceCaches(); + await Promise.allSettled( + normalizedSegmentIds.map((segmentId) => + layer.spatialSkeletonState.getFullSegmentNodes(skeletonLayer, segmentId), + ), + ); +} + +function applyAddNodeToCache( + layer: SegmentationUserLayer, + skeletonLayer: SpatiallyIndexedSkeletonLayer, + committedNode: SpatiallyIndexedSkeletonAddNodeResult, + parentNodeId: number | undefined, + positionInModelSpace: Float32Array, + options: { + focusSelection: boolean; + moveView: boolean; + pinSegment: boolean; + }, +) { + const newNode: SpatiallyIndexedSkeletonNode = { + nodeId: committedNode.nodeId, + segmentId: committedNode.segmentId, + position: new Float32Array(positionInModelSpace), + parentNodeId, + isTrueEnd: false, + ...(committedNode.sourceState === undefined + ? {} + : { sourceState: committedNode.sourceState }), + }; + layer.spatialSkeletonState.upsertCachedNode(newNode, { + allowUncachedSegment: parentNodeId === undefined, + }); + if ( + parentNodeId !== undefined && + committedNode.parentSourceState !== undefined + ) { + layer.spatialSkeletonState.setCachedNodeSourceState( + parentNodeId, + committedNode.parentSourceState, + ); + } + ensureVisibleSegment(layer, newNode.segmentId); + selectSegment(layer, newNode.segmentId, options.pinSegment); + if (options.focusSelection) { + layer.selectSpatialSkeletonNode( + newNode.nodeId, + layer.manager.root.selectionState.pin.value, + { + segmentId: newNode.segmentId, + position: newNode.position, + }, + ); + if (options.moveView) { + layer.moveViewToSpatialSkeletonNodePosition(newNode.position); + } + } + if (parentNodeId !== undefined) { + skeletonLayer.retainOverlaySegment(newNode.segmentId); + } + layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); +} + +function applyDeleteNodeToCache( + layer: SegmentationUserLayer, + deleteContext: { + node: SpatiallyIndexedSkeletonNode; + parentNode: SpatiallyIndexedSkeletonNode | undefined; + childNodes: readonly SpatiallyIndexedSkeletonNode[]; + }, + options: { + moveView: boolean; + }, + nodeSourceStateUpdates: readonly SpatiallyIndexedSkeletonNodeSourceStateUpdate[] = [], +) { + const { node, parentNode, childNodes } = deleteContext; + const directChildIds = childNodes.map((child) => child.nodeId); + layer.spatialSkeletonState.removeCachedNode(node.nodeId, { + parentNodeId: node.parentNodeId, + childNodeIds: directChildIds, + }); + if (nodeSourceStateUpdates.length > 0) { + layer.spatialSkeletonState.setCachedNodeSourceStates( + nodeSourceStateUpdates, + ); + } + if (parentNode !== undefined) { + if (options.moveView) { + layer.selectAndMoveToSpatialSkeletonNode( + parentNode, + layer.manager.root.selectionState.pin.value, + ); + } else { + layer.selectSpatialSkeletonNode( + parentNode.nodeId, + layer.manager.root.selectionState.pin.value, + { + segmentId: parentNode.segmentId, + position: parentNode.position, + }, + ); + } + } else { + layer.clearSpatialSkeletonNodeSelection( + layer.manager.root.selectionState.pin.value, + ); + } + const remainingSegmentNodes = + layer.spatialSkeletonState.getCachedSegmentNodes(node.segmentId) ?? []; + if (remainingSegmentNodes.length === 0) { + removeVisibleSegment(layer, node.segmentId, { deselect: true }); + } + layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); +} + +async function applyNodeDescriptionAndTrueEnd( + skeletonSource: EditableSpatiallyIndexedSkeletonSource, + node: SpatiallyIndexedSkeletonNode, + next: { + description?: string; + isTrueEnd?: boolean; + }, +) { + const nextDescription = next.description; + const nextTrueEnd = next.isTrueEnd ?? false; + let updatedNode: SpatiallyIndexedSkeletonNode = { + ...node, + description: nextDescription, + isTrueEnd: nextTrueEnd, + }; + const descriptionChanged = node.description !== nextDescription; + if (descriptionChanged) { + const descriptionResult = await skeletonSource.updateDescription( + node.nodeId, + nextDescription ?? "", + ); + updatedNode = { + ...updatedNode, + description: descriptionResult.description, + sourceState: descriptionResult.sourceState ?? updatedNode.sourceState, + }; + } + if (node.isTrueEnd !== nextTrueEnd || (descriptionChanged && nextTrueEnd)) { + const trueEndResult = nextTrueEnd + ? await skeletonSource.setTrueEnd(node.nodeId) + : await skeletonSource.removeTrueEnd(node.nodeId); + updatedNode = { + ...updatedNode, + sourceState: trueEndResult.sourceState ?? updatedNode.sourceState, + }; + } + return updatedNode; +} + +async function restoreNodeAttributes( + layer: SegmentationUserLayer, + skeletonSource: EditableSpatiallyIndexedSkeletonSource, + createdNode: SpatiallyIndexedSkeletonNode, + snapshot: SpatiallyIndexedSkeletonNode, +) { + let nextNode = cloneNodeSnapshot(createdNode); + if (snapshot.radius !== undefined && snapshot.radius !== nextNode.radius) { + const radiusResult = await skeletonSource.updateRadius( + createdNode.nodeId, + snapshot.radius, + buildCatmaidNodeEditContext(nextNode), + ); + nextNode = { + ...nextNode, + radius: snapshot.radius, + sourceState: radiusResult.sourceState ?? nextNode.sourceState, + }; + } + if ( + snapshot.confidence !== undefined && + snapshot.confidence !== nextNode.confidence + ) { + if (getCatmaidRevisionToken(nextNode.sourceState) === undefined) { + throw new Error( + `Node ${createdNode.nodeId} is missing revision metadata required to restore confidence.`, + ); + } + const confidenceResult = await skeletonSource.updateConfidence( + createdNode.nodeId, + snapshot.confidence, + buildCatmaidNodeEditContext(nextNode), + ); + nextNode = { + ...nextNode, + confidence: snapshot.confidence, + sourceState: confidenceResult.sourceState ?? nextNode.sourceState, + }; + } + if ( + nextNode.description !== snapshot.description || + nextNode.isTrueEnd !== snapshot.isTrueEnd + ) { + nextNode = await applyNodeDescriptionAndTrueEnd( + skeletonSource, + nextNode, + snapshot, + ); + } + layer.spatialSkeletonState.upsertCachedNode(nextNode); + return nextNode; +} + +class AddNodeCommand implements SpatialSkeletonCommand { + readonly label = "Add node"; + private stableNodeId: number | undefined; + private stableSegmentId: number | undefined; + + constructor( + private layer: SegmentationUserLayer, + private stableParentNodeId: number | undefined, + private targetSkeletonId: number, + private positionInModelSpace: Float32Array, + ) {} + + private async addNode( + _context: SpatialSkeletonCommandContext, + options: { + moveView: boolean; + pinSegment: boolean; + statusPrefix: string; + }, + ) { + const { skeletonLayer, skeletonSource } = getEditableSkeletonSourceForLayer( + this.layer, + ); + const currentParentNodeId = + this.stableParentNodeId === undefined + ? undefined + : this.layer.spatialSkeletonState.commandHistory.mappings.resolveNodeId( + this.stableParentNodeId, + ); + let resolvedEditContext: CatmaidEditContext | undefined; + let resolvedSkeletonId = this.targetSkeletonId; + if (currentParentNodeId !== undefined) { + const parentNode = ( + await getResolvedNodeForEdit( + this.layer, + this.stableParentNodeId!, + this.layer.spatialSkeletonState.commandHistory.mappings.getStableOrCurrentSegmentId( + this.targetSkeletonId, + ), + ) + ).node; + resolvedSkeletonId = parentNode.segmentId; + resolvedEditContext = buildCatmaidNodeEditContext(parentNode); + } + const result = await skeletonSource.addNode( + resolvedSkeletonId, + Number(this.positionInModelSpace[0]), + Number(this.positionInModelSpace[1]), + Number(this.positionInModelSpace[2]), + currentParentNodeId, + resolvedEditContext, + ); + if (this.stableNodeId === undefined) { + this.stableNodeId = result.nodeId; + } else { + this.layer.spatialSkeletonState.commandHistory.mappings.remapNodeId( + this.stableNodeId, + result.nodeId, + ); + } + if (this.stableSegmentId === undefined) { + this.stableSegmentId = result.segmentId; + } else { + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + this.stableSegmentId, + result.segmentId, + ); + } + applyAddNodeToCache( + this.layer, + skeletonLayer, + result, + currentParentNodeId, + this.positionInModelSpace, + { + focusSelection: true, + moveView: options.moveView, + pinSegment: options.pinSegment, + }, + ); + StatusMessage.showTemporaryMessage( + `${options.statusPrefix} node ${result.nodeId} on segment ${result.segmentId}.`, + ); + } + + async execute(context: SpatialSkeletonCommandContext) { + await this.addNode(context, { + moveView: true, + pinSegment: true, + statusPrefix: "Added", + }); + } + + async undo(_context: SpatialSkeletonCommandContext) { + if (this.stableNodeId === undefined) { + throw new Error("Add-node undo is missing the created node id."); + } + const resolvedNode = await getResolvedNodeForEdit( + this.layer, + this.stableNodeId, + this.stableSegmentId, + ); + const deleteContext = + await this.layer.getSpatialSkeletonDeleteOperationContext( + resolvedNode.node, + ); + const result = await resolvedNode.skeletonSource.deleteNode( + resolvedNode.node.nodeId, + { + childNodeIds: [], + editContext: buildCatmaidNeighborhoodEditContext( + deleteContext.node, + resolvedNode.segmentNodes, + ), + }, + ); + applyDeleteNodeToCache( + this.layer, + deleteContext, + { moveView: false }, + result.nodeSourceStateUpdates, + ); + StatusMessage.showTemporaryMessage( + `Undid add node ${resolvedNode.node.nodeId}.`, + ); + } + + async redo(context: SpatialSkeletonCommandContext) { + await this.addNode(context, { + moveView: false, + pinSegment: false, + statusPrefix: "Redid add of", + }); + } +} + +class MoveNodeCommand implements SpatialSkeletonCommand { + readonly label = "Move node"; + + constructor( + private layer: SegmentationUserLayer, + private stableNodeId: number, + private stableSegmentId: number | undefined, + private beforePositionInModelSpace: Float32Array, + private afterPositionInModelSpace: Float32Array, + ) {} + + private async moveTo( + positionInModelSpace: Float32Array, + statusPrefix: string, + ) { + const { node, skeletonLayer, skeletonSource } = + await getResolvedNodeForEdit( + this.layer, + this.stableNodeId, + this.stableSegmentId, + ); + const result = await skeletonSource.moveNode( + node.nodeId, + Number(positionInModelSpace[0]), + Number(positionInModelSpace[1]), + Number(positionInModelSpace[2]), + buildCatmaidNodeEditContext(node), + ); + skeletonLayer.retainOverlaySegment(node.segmentId); + this.layer.spatialSkeletonState.moveCachedNode( + node.nodeId, + positionInModelSpace, + ); + if (result.sourceState !== undefined) { + this.layer.spatialSkeletonState.setCachedNodeSourceState( + node.nodeId, + result.sourceState, + ); + } + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + StatusMessage.showTemporaryMessage( + `${statusPrefix} node ${node.nodeId} to (${Math.round(positionInModelSpace[0])}, ${Math.round(positionInModelSpace[1])}, ${Math.round(positionInModelSpace[2])}).`, + ); + } + + execute() { + return this.moveTo(this.afterPositionInModelSpace, "Moved"); + } + + undo() { + return this.moveTo(this.beforePositionInModelSpace, "Undid move of"); + } + + redo() { + return this.moveTo(this.afterPositionInModelSpace, "Redid move of"); + } +} + +class DeleteNodeCommand implements SpatialSkeletonCommand { + readonly label = "Delete node"; + private stableDeletedNodeId: number; + private stableSegmentId: number | undefined; + private stableParentNodeId: number | undefined; + private stableChildNodeIds: number[]; + private deletedSnapshot: SpatiallyIndexedSkeletonNode; + + constructor( + private layer: SegmentationUserLayer, + node: SpatiallyIndexedSkeletonNode, + childNodes: readonly SpatiallyIndexedSkeletonNode[], + ) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + this.stableDeletedNodeId = commandMappings.getStableOrCurrentNodeId( + node.nodeId, + )!; + this.stableSegmentId = commandMappings.getStableOrCurrentSegmentId( + node.segmentId, + ); + this.stableParentNodeId = commandMappings.getStableOrCurrentNodeId( + node.parentNodeId, + ); + this.stableChildNodeIds = childNodes.map( + (child) => commandMappings.getStableOrCurrentNodeId(child.nodeId)!, + ); + this.deletedSnapshot = cloneNodeSnapshot(node); + } + + private async deleteNode(options: { + moveView: boolean; + statusPrefix: string; + }) { + const resolvedNode = await getResolvedNodeForEdit( + this.layer, + this.stableDeletedNodeId, + this.stableSegmentId, + ); + const deleteContext = + await this.layer.getSpatialSkeletonDeleteOperationContext( + resolvedNode.node, + ); + const result = await resolvedNode.skeletonSource.deleteNode( + resolvedNode.node.nodeId, + { + childNodeIds: deleteContext.childNodes.map((child) => child.nodeId), + editContext: buildCatmaidNeighborhoodEditContext( + deleteContext.node, + resolvedNode.segmentNodes, + ), + }, + ); + applyDeleteNodeToCache( + this.layer, + deleteContext, + { moveView: options.moveView }, + result.nodeSourceStateUpdates, + ); + resolvedNode.skeletonLayer.invalidateSourceCaches(); + StatusMessage.showTemporaryMessage( + `${options.statusPrefix} node ${resolvedNode.node.nodeId}.`, + ); + } + + private async restoreDeletedNode(statusPrefix: string) { + const { skeletonSource } = getEditableSkeletonSourceForLayer(this.layer); + const currentParentNode = + this.stableParentNodeId === undefined + ? undefined + : ( + await getResolvedNodeForEdit( + this.layer, + this.stableParentNodeId, + this.stableSegmentId, + ) + ).node; + const currentChildNodes = await Promise.all( + this.stableChildNodeIds.map((stableChildNodeId) => + getResolvedNodeForEdit( + this.layer, + stableChildNodeId, + this.stableSegmentId, + ).then((result) => result.node), + ), + ); + const createResult: + | SpatiallyIndexedSkeletonAddNodeResult + | SpatiallyIndexedSkeletonInsertNodeResult = + currentChildNodes.length === 0 + ? await skeletonSource.addNode( + currentParentNode?.segmentId ?? 0, + Number(this.deletedSnapshot.position[0]), + Number(this.deletedSnapshot.position[1]), + Number(this.deletedSnapshot.position[2]), + currentParentNode?.nodeId, + currentParentNode === undefined + ? undefined + : buildCatmaidNodeEditContext(currentParentNode), + ) + : await skeletonSource.insertNode( + currentParentNode?.segmentId ?? this.deletedSnapshot.segmentId, + Number(this.deletedSnapshot.position[0]), + Number(this.deletedSnapshot.position[1]), + Number(this.deletedSnapshot.position[2]), + currentParentNode?.nodeId ?? + (() => { + throw new Error( + "Delete-node undo is missing the parent node needed for insertion.", + ); + })(), + currentChildNodes.map((child) => child.nodeId), + buildInsertEditContext(currentParentNode!, currentChildNodes), + ); + this.layer.spatialSkeletonState.commandHistory.mappings.remapNodeId( + this.stableDeletedNodeId, + createResult.nodeId, + ); + if (this.stableSegmentId === undefined) { + this.stableSegmentId = createResult.segmentId; + } else { + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + this.stableSegmentId, + createResult.segmentId, + ); + } + const restoredNode: SpatiallyIndexedSkeletonNode = { + nodeId: createResult.nodeId, + segmentId: createResult.segmentId, + position: new Float32Array(this.deletedSnapshot.position), + parentNodeId: currentParentNode?.nodeId, + sourceState: createResult.sourceState, + radius: undefined, + confidence: undefined, + description: undefined, + isTrueEnd: false, + }; + this.layer.spatialSkeletonState.upsertCachedNode(restoredNode, { + allowUncachedSegment: currentParentNode === undefined, + }); + for (const childNode of currentChildNodes) { + this.layer.spatialSkeletonState.setCachedNodeParent( + childNode.nodeId, + restoredNode.nodeId, + ); + } + if (createResult.parentSourceState !== undefined && currentParentNode) { + this.layer.spatialSkeletonState.setCachedNodeSourceState( + currentParentNode.nodeId, + createResult.parentSourceState, + ); + } + if (createResult.nodeSourceStateUpdates?.length) { + this.layer.spatialSkeletonState.setCachedNodeSourceStates( + createResult.nodeSourceStateUpdates, + ); + } + const restoredNodeWithAttributes = await restoreNodeAttributes( + this.layer, + skeletonSource, + restoredNode, + this.deletedSnapshot, + ); + ensureVisibleSegment(this.layer, restoredNodeWithAttributes.segmentId); + this.layer.selectSpatialSkeletonNode( + restoredNodeWithAttributes.nodeId, + this.layer.manager.root.selectionState.pin.value, + { + segmentId: restoredNodeWithAttributes.segmentId, + position: restoredNodeWithAttributes.position, + }, + ); + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + StatusMessage.showTemporaryMessage( + `${statusPrefix} node ${restoredNodeWithAttributes.nodeId}.`, + ); + } + + execute() { + return this.deleteNode({ + moveView: true, + statusPrefix: "Deleted", + }); + } + + undo() { + return this.restoreDeletedNode("Restored"); + } + + redo() { + return this.deleteNode({ + moveView: false, + statusPrefix: "Redid deletion of", + }); + } +} + +class NodeDescriptionCommand implements SpatialSkeletonCommand { + readonly label = "Edit node description"; + + constructor( + private layer: SegmentationUserLayer, + private stableNodeId: number, + private stableSegmentId: number | undefined, + private beforeDescription: string | undefined, + private afterDescription: string | undefined, + ) {} + + private async applyDescription( + nextDescription: string | undefined, + statusPrefix: string, + ) { + const { node, skeletonSource } = await getResolvedNodeForEdit( + this.layer, + this.stableNodeId, + this.stableSegmentId, + ); + if (node.description === nextDescription) { + return; + } + const result = await skeletonSource.updateDescription( + node.nodeId, + nextDescription ?? "", + ); + this.layer.spatialSkeletonState.updateCachedNode( + node.nodeId, + (candidate) => { + if (candidate.description === result.description) { + return candidate; + } + return { + ...candidate, + description: result.description, + }; + }, + ); + if (result.sourceState !== undefined) { + this.layer.spatialSkeletonState.setCachedNodeSourceState( + node.nodeId, + result.sourceState, + ); + } + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + StatusMessage.showTemporaryMessage( + `${statusPrefix} node ${node.nodeId} description.`, + ); + } + + execute() { + return this.applyDescription(this.afterDescription, "Updated"); + } + + undo() { + return this.applyDescription( + this.beforeDescription, + "Undid description update for", + ); + } + + redo() { + return this.applyDescription( + this.afterDescription, + "Redid description update for", + ); + } +} + +class NodeTrueEndCommand implements SpatialSkeletonCommand { + readonly label = "Edit node true end state"; + + constructor( + private layer: SegmentationUserLayer, + private stableNodeId: number, + private stableSegmentId: number | undefined, + private beforeIsTrueEnd: boolean, + private afterIsTrueEnd: boolean, + ) {} + + private async applyTrueEnd(nextIsTrueEnd: boolean, statusPrefix: string) { + const { node, skeletonSource } = await getResolvedNodeForEdit( + this.layer, + this.stableNodeId, + this.stableSegmentId, + ); + if (node.isTrueEnd === nextIsTrueEnd) { + return; + } + const result = nextIsTrueEnd + ? await skeletonSource.setTrueEnd(node.nodeId) + : await skeletonSource.removeTrueEnd(node.nodeId); + this.layer.spatialSkeletonState.updateCachedNode( + node.nodeId, + (candidate) => { + if (candidate.isTrueEnd === nextIsTrueEnd) { + return candidate; + } + return { + ...candidate, + isTrueEnd: nextIsTrueEnd, + }; + }, + ); + if (result.sourceState !== undefined) { + this.layer.spatialSkeletonState.setCachedNodeSourceState( + node.nodeId, + result.sourceState, + ); + } + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + StatusMessage.showTemporaryMessage( + `${statusPrefix} node ${node.nodeId} true end state.`, + ); + } + + execute() { + return this.applyTrueEnd(this.afterIsTrueEnd, "Updated"); + } + + undo() { + return this.applyTrueEnd(this.beforeIsTrueEnd, "Undid true end update for"); + } + + redo() { + return this.applyTrueEnd(this.afterIsTrueEnd, "Redid true end update for"); + } +} + +class NodePropertiesCommand implements SpatialSkeletonCommand { + readonly label = "Edit node properties"; + + constructor( + private layer: SegmentationUserLayer, + private stableNodeId: number, + private stableSegmentId: number | undefined, + private before: { radius: number; confidence: number }, + private after: { radius: number; confidence: number }, + ) {} + + private async applyProperties( + next: { radius: number; confidence: number }, + statusPrefix: string, + ) { + const { node, skeletonSource } = await getResolvedNodeForEdit( + this.layer, + this.stableNodeId, + this.stableSegmentId, + ); + let currentNode = cloneNodeSnapshot(node); + if (currentNode.radius !== next.radius) { + const radiusResult = await skeletonSource.updateRadius( + node.nodeId, + next.radius, + buildCatmaidNodeEditContext(currentNode), + ); + currentNode = { + ...currentNode, + radius: next.radius, + sourceState: radiusResult.sourceState ?? currentNode.sourceState, + }; + } + if (currentNode.confidence !== next.confidence) { + if (getCatmaidRevisionToken(currentNode.sourceState) === undefined) { + throw new Error( + `Node ${node.nodeId} is missing revision metadata required to update confidence.`, + ); + } + const confidenceResult = await skeletonSource.updateConfidence( + node.nodeId, + next.confidence, + buildCatmaidNodeEditContext(currentNode), + ); + currentNode = { + ...currentNode, + confidence: next.confidence, + sourceState: confidenceResult.sourceState ?? currentNode.sourceState, + }; + } + this.layer.spatialSkeletonState.setNodeProperties(node.nodeId, next); + if (currentNode.sourceState !== undefined) { + this.layer.spatialSkeletonState.setCachedNodeSourceState( + node.nodeId, + currentNode.sourceState, + ); + } + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + StatusMessage.showTemporaryMessage( + `${statusPrefix} node ${node.nodeId} properties.`, + ); + } + + execute() { + return this.applyProperties(this.after, "Updated"); + } + + undo() { + return this.applyProperties(this.before, "Undid property update for"); + } + + redo() { + return this.applyProperties(this.after, "Redid property update for"); + } +} + +class RerootCommand implements SpatialSkeletonCommand { + readonly label = "Reroot skeleton"; + + constructor( + private layer: SegmentationUserLayer, + private stableNodeId: number, + private stableSegmentId: number | undefined, + private stablePreviousRootNodeId: number, + ) {} + + private async rerootAt(stableTargetNodeId: number, statusPrefix: string) { + const resolvedNode = await getResolvedNodeForEdit( + this.layer, + stableTargetNodeId, + this.stableSegmentId, + ); + if (resolvedNode.node.parentNodeId === undefined) { + return; + } + if (resolvedNode.skeletonSource.rerootSkeleton === undefined) { + throw new Error( + "Unable to resolve a reroot-capable skeleton source for the active layer.", + ); + } + const result = await resolvedNode.skeletonSource.rerootSkeleton( + resolvedNode.node.nodeId, + buildCatmaidRerootEditContext( + resolvedNode.node, + resolvedNode.segmentNodes, + ), + ); + this.layer.spatialSkeletonState.rerootCachedSegment( + resolvedNode.node.nodeId, + ); + if ( + result.nodeSourceStateUpdates !== undefined && + result.nodeSourceStateUpdates.length > 0 + ) { + this.layer.spatialSkeletonState.setCachedNodeSourceStates( + result.nodeSourceStateUpdates, + ); + } + this.layer.selectSpatialSkeletonNode( + resolvedNode.node.nodeId, + this.layer.manager.root.selectionState.pin.value, + { + segmentId: resolvedNode.node.segmentId, + position: resolvedNode.node.position, + }, + ); + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + StatusMessage.showTemporaryMessage( + `${statusPrefix} node ${resolvedNode.node.nodeId} as root.`, + ); + } + + execute() { + return this.rerootAt(this.stableNodeId, "Set"); + } + + undo() { + return this.rerootAt(this.stablePreviousRootNodeId, "Undid reroot for"); + } + + redo() { + return this.rerootAt(this.stableNodeId, "Redid reroot for"); + } +} + +class SplitCommand implements SpatialSkeletonCommand { + readonly label = "Split skeleton"; + private stableNewSegmentId: number | undefined; + + constructor( + private layer: SegmentationUserLayer, + private stableNodeId: number, + private stableSegmentId: number | undefined, + private stableFormerParentNodeId: number | undefined, + ) {} + + private async split(statusPrefix: string) { + const resolvedNode = await getResolvedNodeForEdit( + this.layer, + this.stableNodeId, + this.stableSegmentId, + ); + let result: SpatiallyIndexedSkeletonSplitResult; + try { + result = await resolvedNode.skeletonSource.splitSkeleton( + resolvedNode.node.nodeId, + buildCatmaidNeighborhoodEditContext( + resolvedNode.node, + resolvedNode.segmentNodes, + ), + ); + } catch (error) { + await refreshTopologySegments(this.layer, [resolvedNode.node.segmentId]); + throw error; + } + const newSkeletonId = result.newSegmentId; + const existingSkeletonId = + result.existingSegmentId ?? resolvedNode.node.segmentId; + if (newSkeletonId === undefined) { + throw new Error( + "The active skeleton source did not return a new skeleton id for the split.", + ); + } + if (this.stableNewSegmentId === undefined) { + this.stableNewSegmentId = newSkeletonId; + } else { + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + this.stableNewSegmentId, + newSkeletonId, + ); + } + if (this.stableSegmentId !== undefined) { + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + this.stableSegmentId, + existingSkeletonId, + ); + } + ensureVisibleSegment(this.layer, existingSkeletonId); + ensureVisibleSegment(this.layer, newSkeletonId); + selectSegment(this.layer, newSkeletonId, true); + this.layer.selectSpatialSkeletonNode( + resolvedNode.node.nodeId, + this.layer.manager.root.selectionState.pin.value, + { + segmentId: newSkeletonId, + }, + ); + await refreshTopologySegments(this.layer, [ + existingSkeletonId, + newSkeletonId, + ]); + StatusMessage.showTemporaryMessage( + `${statusPrefix} skeleton ${existingSkeletonId}. New skeleton: ${newSkeletonId}.`, + ); + } + + private async mergeBack(statusPrefix: string) { + if (this.stableFormerParentNodeId === undefined) { + throw new Error("Split-node undo is missing the former parent node."); + } + const splitNode = await getResolvedNodeForEdit( + this.layer, + this.stableNodeId, + this.stableNewSegmentId ?? this.stableSegmentId, + ); + const formerParent = await getResolvedNodeForEdit( + this.layer, + this.stableFormerParentNodeId, + this.stableSegmentId, + ); + let result: SpatiallyIndexedSkeletonMergeResult; + try { + result = await formerParent.skeletonSource.mergeSkeletons( + formerParent.node.nodeId, + splitNode.node.nodeId, + buildCatmaidMultiNodeEditContext(formerParent.node, splitNode.node), + ); + } catch (error) { + await refreshTopologySegments(this.layer, [ + splitNode.node.segmentId, + formerParent.node.segmentId, + ]); + throw error; + } + const resultSkeletonId = + result.resultSegmentId ?? formerParent.node.segmentId; + const deletedSkeletonId = + result.deletedSegmentId ?? + (resultSkeletonId === splitNode.node.segmentId + ? formerParent.node.segmentId + : splitNode.node.segmentId); + if (this.stableSegmentId !== undefined) { + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + this.stableSegmentId, + resultSkeletonId, + ); + } + if (this.stableNewSegmentId !== undefined) { + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + this.stableNewSegmentId, + resultSkeletonId, + ); + } + ensureVisibleSegment(this.layer, resultSkeletonId); + if (deletedSkeletonId !== resultSkeletonId) { + removeVisibleSegment(this.layer, deletedSkeletonId, { deselect: true }); + this.layer.displayState.segmentStatedColors.value.delete( + BigInt(deletedSkeletonId), + ); + splitNode.skeletonLayer.suppressBrowseSegment(deletedSkeletonId); + } + this.layer.selectSpatialSkeletonNode( + splitNode.node.nodeId, + this.layer.manager.root.selectionState.pin.value, + { + segmentId: resultSkeletonId, + }, + ); + await refreshTopologySegments(this.layer, [ + resultSkeletonId, + deletedSkeletonId, + ]); + StatusMessage.showTemporaryMessage( + `${statusPrefix} split at node ${splitNode.node.nodeId}.`, + ); + } + + execute() { + return this.split("Split"); + } + + undo() { + return this.mergeBack("Undid"); + } + + redo() { + return this.split("Redid split of"); + } +} + +class MergeCommand implements SpatialSkeletonCommand { + readonly label = "Merge skeletons"; + private stableResultSegmentId: number | undefined; + private stableDeletedSegmentId: number | undefined; + private stableAttachedNodeId: number | undefined; + private stableAttachedRootNodeId: number | undefined; + + constructor( + private layer: SegmentationUserLayer, + private stableFirstNodeId: number, + private stableFirstSegmentId: number | undefined, + private stableSecondNodeId: number, + private stableSecondSegmentId: number | undefined, + private secondNodeSourceState: unknown, + ) {} + + private async merge(statusPrefix: string) { + const firstNode = await getResolvedNodeForEdit( + this.layer, + this.stableFirstNodeId, + this.stableFirstSegmentId, + ); + const secondNodeContext = getResolvedNodeContextForEdit( + this.layer, + this.stableSecondNodeId, + this.stableSecondSegmentId, + ); + let secondNode: ResolvedSpatialSkeletonEditNode; + let preservedSecondRootNodeId: number | undefined; + const secondSegmentCached = + this.layer.spatialSkeletonState.getCachedSegmentNodes( + secondNodeContext.segmentId, + ) !== undefined; + const secondSourceState = + this.secondNodeSourceState ?? secondNodeContext.cachedNode?.sourceState; + if ( + secondSegmentCached || + getCatmaidRevisionToken(secondSourceState) === undefined + ) { + secondNode = await getResolvedNodeForEdit( + this.layer, + this.stableSecondNodeId, + this.stableSecondSegmentId, + ); + } else { + preservedSecondRootNodeId = ( + await secondNodeContext.skeletonSource.getSkeletonRootNode( + secondNodeContext.segmentId, + ) + ).nodeId; + secondNode = { + skeletonLayer: secondNodeContext.skeletonLayer, + skeletonSource: secondNodeContext.skeletonSource, + segmentNodes: [], + node: { + nodeId: secondNodeContext.currentNodeId, + segmentId: secondNodeContext.segmentId, + position: new Float32Array(3), + parentNodeId: secondNodeContext.cachedNode?.parentNodeId, + isTrueEnd: secondNodeContext.cachedNode?.isTrueEnd ?? false, + sourceState: secondSourceState, + }, + }; + } + let result: SpatiallyIndexedSkeletonMergeResult; + try { + result = await firstNode.skeletonSource.mergeSkeletons( + firstNode.node.nodeId, + secondNode.node.nodeId, + buildCatmaidMultiNodeEditContext(firstNode.node, secondNode.node), + ); + } catch (error) { + await refreshTopologySegments(this.layer, [ + firstNode.node.segmentId, + secondNode.node.segmentId, + ]); + throw error; + } + const winningNode = + result.resultSegmentId === secondNode.node.segmentId + ? secondNode.node + : firstNode.node; + const losingNode = + winningNode.nodeId === firstNode.node.nodeId + ? secondNode.node + : firstNode.node; + const resultSkeletonId = result.resultSegmentId ?? winningNode.segmentId; + const deletedSkeletonId = result.deletedSegmentId ?? losingNode.segmentId; + const attachedRootNodeId = + losingNode.segmentId === firstNode.node.segmentId + ? findRootNode(firstNode.segmentNodes)?.nodeId + : (preservedSecondRootNodeId ?? + findRootNode(secondNode.segmentNodes)?.nodeId); + this.stableAttachedNodeId = + this.stableAttachedNodeId ?? + this.layer.spatialSkeletonState.commandHistory.mappings.getStableOrCurrentNodeId( + losingNode.nodeId, + ); + this.stableAttachedRootNodeId = + this.stableAttachedRootNodeId ?? + this.layer.spatialSkeletonState.commandHistory.mappings.getStableOrCurrentNodeId( + attachedRootNodeId, + ); + this.stableResultSegmentId = + this.stableResultSegmentId ?? + this.layer.spatialSkeletonState.commandHistory.mappings.getStableOrCurrentSegmentId( + resultSkeletonId, + ); + this.stableDeletedSegmentId = + this.stableDeletedSegmentId ?? + this.layer.spatialSkeletonState.commandHistory.mappings.getStableOrCurrentSegmentId( + deletedSkeletonId, + ); + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + this.stableDeletedSegmentId, + resultSkeletonId, + ); + ensureVisibleSegment(this.layer, resultSkeletonId); + removeVisibleSegment(this.layer, deletedSkeletonId, { deselect: true }); + selectSegment(this.layer, resultSkeletonId, false); + this.layer.selectSpatialSkeletonNode( + losingNode.nodeId, + this.layer.manager.root.selectionState.pin.value, + { + segmentId: resultSkeletonId, + }, + ); + this.layer.displayState.segmentStatedColors.value.delete( + BigInt(deletedSkeletonId), + ); + if (deletedSkeletonId !== resultSkeletonId) { + firstNode.skeletonLayer.suppressBrowseSegment(deletedSkeletonId); + } + this.layer.clearSpatialSkeletonMergeAnchor(); + await refreshTopologySegments(this.layer, [ + resultSkeletonId, + deletedSkeletonId, + ]); + const swapSuffix = result.directionAdjusted + ? " Merge direction was adjusted by the active source." + : ""; + StatusMessage.showTemporaryMessage( + `${statusPrefix} skeleton ${deletedSkeletonId} into ${resultSkeletonId}.${swapSuffix}`, + ); + } + + private async undoMerge(statusPrefix: string) { + if (this.stableAttachedNodeId === undefined) { + throw new Error("Merge undo is missing the attached node id."); + } + if (this.stableDeletedSegmentId === undefined) { + throw new Error("Merge undo is missing the deleted skeleton id."); + } + const attachedNode = await getResolvedNodeForEdit( + this.layer, + this.stableAttachedNodeId, + this.stableResultSegmentId ?? this.stableFirstSegmentId, + ); + let splitResult: SpatiallyIndexedSkeletonSplitResult; + try { + splitResult = await attachedNode.skeletonSource.splitSkeleton( + attachedNode.node.nodeId, + buildCatmaidNeighborhoodEditContext( + attachedNode.node, + attachedNode.segmentNodes, + ), + ); + } catch (error) { + await refreshTopologySegments(this.layer, [attachedNode.node.segmentId]); + throw error; + } + const restoredSegmentId = + splitResult.newSegmentId ?? + (() => { + throw new Error( + "The active skeleton source did not return a new skeleton id for merge undo.", + ); + })(); + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + this.stableDeletedSegmentId, + restoredSegmentId, + ); + const survivingSegmentId = + splitResult.existingSegmentId ?? attachedNode.node.segmentId; + ensureVisibleSegment(this.layer, survivingSegmentId); + ensureVisibleSegment(this.layer, restoredSegmentId); + await refreshTopologySegments(this.layer, [ + survivingSegmentId, + restoredSegmentId, + ]); + let rerootWarning: string | undefined; + if ( + this.stableAttachedRootNodeId !== undefined && + this.stableAttachedRootNodeId !== this.stableAttachedNodeId + ) { + try { + const restoredRoot = await getResolvedNodeForEdit( + this.layer, + this.stableAttachedRootNodeId, + this.stableDeletedSegmentId, + ); + if (restoredRoot.node.parentNodeId !== undefined) { + if (restoredRoot.skeletonSource.rerootSkeleton === undefined) { + throw new Error( + "The active skeleton source does not support reroot.", + ); + } + await restoredRoot.skeletonSource.rerootSkeleton( + restoredRoot.node.nodeId, + buildCatmaidRerootEditContext( + restoredRoot.node, + restoredRoot.segmentNodes, + ), + ); + await refreshTopologySegments(this.layer, [ + survivingSegmentId, + restoredSegmentId, + ]); + } + } catch (error) { + await refreshTopologySegments(this.layer, [ + survivingSegmentId, + restoredSegmentId, + ]); + rerootWarning = + `Undo split the merged skeletons, but failed to reroot the restored skeleton. ` + + `Only the split completed. ${formatErrorMessage(error)}`; + } + } + this.layer.selectSpatialSkeletonNode( + attachedNode.node.nodeId, + this.layer.manager.root.selectionState.pin.value, + { + segmentId: restoredSegmentId, + }, + ); + StatusMessage.showTemporaryMessage( + rerootWarning ?? + `${statusPrefix} merge involving node ${attachedNode.node.nodeId}.`, + ); + } + + execute() { + return this.merge("Merged"); + } + + undo() { + return this.undoMerge("Undid"); + } + + redo() { + return this.merge("Redid merge of"); + } +} + +export class CatmaidSpatialSkeletonEditController + implements SpatialSkeletonEditController +{ + readonly capabilities = { + nodeFeatures: { + description: true, + trueEnd: true, + radius: true, + confidenceValues: [0, 25, 50, 75, 100], + }, + }; + + supports(action: string) { + switch (action) { + case SpatialSkeletonActions.addNodes: + case SpatialSkeletonActions.insertNodes: + case SpatialSkeletonActions.moveNodes: + case SpatialSkeletonActions.deleteNodes: + case SpatialSkeletonActions.reroot: + case SpatialSkeletonActions.editNodeDescription: + case SpatialSkeletonActions.editNodeTrueEnd: + case SpatialSkeletonActions.editNodeProperties: + case SpatialSkeletonActions.mergeSkeletons: + case SpatialSkeletonActions.splitSkeletons: + return true; + default: + return false; + } + } + + createAddNodeCommand( + layer: SegmentationUserLayer, + options: SpatialSkeletonAddNodeCommandOptions, + ) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + return new AddNodeCommand( + layer, + commandMappings.getStableOrCurrentNodeId(options.parentNodeId), + commandMappings.getStableOrCurrentSegmentId(options.skeletonId) ?? + options.skeletonId, + new Float32Array(options.positionInModelSpace), + ); + } + + createMoveNodeCommand( + layer: SegmentationUserLayer, + options: SpatialSkeletonMoveNodeCommandOptions, + ) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + return new MoveNodeCommand( + layer, + commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), + new Float32Array(options.node.position), + new Float32Array(options.nextPositionInModelSpace), + ); + } + + createDeleteNodeCommand( + layer: SegmentationUserLayer, + node: SpatiallyIndexedSkeletonNode, + ) { + const segmentNodes = layer.getCachedSpatialSkeletonSegmentNodesForEdit( + node.segmentId, + ); + const refreshedNode = findSpatiallyIndexedSkeletonNode( + segmentNodes, + node.nodeId, + ); + if (refreshedNode === undefined) { + throw new Error( + `Node ${node.nodeId} is not available in the inspected skeleton cache.`, + ); + } + const childNodes = getSpatiallyIndexedSkeletonDirectChildren( + segmentNodes, + refreshedNode.nodeId, + ); + return new DeleteNodeCommand(layer, refreshedNode, childNodes); + } + + createNodeDescriptionCommand( + layer: SegmentationUserLayer, + options: SpatialSkeletonNodeDescriptionCommandOptions, + ) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + return new NodeDescriptionCommand( + layer, + commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), + options.node.description, + options.nextDescription ?? options.node.description, + ); + } + + createNodeTrueEndCommand( + layer: SegmentationUserLayer, + options: SpatialSkeletonNodeTrueEndCommandOptions, + ) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + return new NodeTrueEndCommand( + layer, + commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), + options.node.isTrueEnd ?? false, + options.nextIsTrueEnd, + ); + } + + createNodePropertiesCommand( + layer: SegmentationUserLayer, + options: SpatialSkeletonNodePropertiesCommandOptions, + ) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + return new NodePropertiesCommand( + layer, + commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), + { + radius: options.node.radius ?? 0, + confidence: options.node.confidence ?? 0, + }, + options.next, + ); + } + + createRerootCommand( + layer: SegmentationUserLayer, + node: Pick< + SpatiallyIndexedSkeletonNode, + "nodeId" | "segmentId" | "parentNodeId" + >, + ) { + const segmentNodes = layer.getCachedSpatialSkeletonSegmentNodesForEdit( + node.segmentId, + ); + const rootNode = + findRootNode(segmentNodes) ?? + (() => { + throw new Error( + `Unable to resolve the current root for segment ${node.segmentId}.`, + ); + })(); + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + return new RerootCommand( + layer, + commandMappings.getStableOrCurrentNodeId(node.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(node.segmentId), + commandMappings.getStableOrCurrentNodeId(rootNode.nodeId)!, + ); + } + + createSplitCommand( + layer: SegmentationUserLayer, + node: Pick, + ) { + const segmentNodes = layer.getCachedSpatialSkeletonSegmentNodesForEdit( + node.segmentId, + ); + const splitNode = findSpatiallyIndexedSkeletonNode( + segmentNodes, + node.nodeId, + ); + if (splitNode === undefined) { + throw new Error( + `Node ${node.nodeId} is not available in the inspected skeleton cache.`, + ); + } + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + return new SplitCommand( + layer, + commandMappings.getStableOrCurrentNodeId(splitNode.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(splitNode.segmentId), + commandMappings.getStableOrCurrentNodeId(splitNode.parentNodeId), + ); + } + + createMergeCommand( + layer: SegmentationUserLayer, + firstNode: SpatialSkeletonMergeEndpoint, + secondNode: SpatialSkeletonMergeEndpoint, + ) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + return new MergeCommand( + layer, + commandMappings.getStableOrCurrentNodeId(firstNode.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(firstNode.segmentId), + commandMappings.getStableOrCurrentNodeId(secondNode.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(secondNode.segmentId), + secondNode.sourceState, + ); + } +} + +export function executeSpatialSkeletonAddNode( + layer: SegmentationUserLayer, + options: { + skeletonId: number; + parentNodeId: number | undefined; + // Callers must convert viewer/global coordinates to skeleton model space. + positionInModelSpace: Float32Array; + }, +) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + const command = new AddNodeCommand( + layer, + commandMappings.getStableOrCurrentNodeId(options.parentNodeId), + commandMappings.getStableOrCurrentSegmentId(options.skeletonId) ?? + options.skeletonId, + new Float32Array(options.positionInModelSpace), + ); + return executeSpatialSkeletonCommandWithPendingMessage( + layer.spatialSkeletonState.commandHistory.execute(command), + "Creating node...", + ); +} + +export function executeSpatialSkeletonMoveNode( + layer: SegmentationUserLayer, + options: { + node: SpatiallyIndexedSkeletonNode; + // Callers must convert viewer/global coordinates to skeleton model space. + nextPositionInModelSpace: Float32Array; + }, +) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + const command = new MoveNodeCommand( + layer, + commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), + new Float32Array(options.node.position), + new Float32Array(options.nextPositionInModelSpace), + ); + return layer.spatialSkeletonState.commandHistory.execute(command); +} + +export function executeSpatialSkeletonDeleteNode( + layer: SegmentationUserLayer, + node: SpatiallyIndexedSkeletonNode, +) { + const segmentNodes = layer.getCachedSpatialSkeletonSegmentNodesForEdit( + node.segmentId, + ); + const refreshedNode = findSpatiallyIndexedSkeletonNode( + segmentNodes, + node.nodeId, + ); + if (refreshedNode === undefined) { + throw new Error( + `Node ${node.nodeId} is not available in the inspected skeleton cache.`, + ); + } + const childNodes = getSpatiallyIndexedSkeletonDirectChildren( + segmentNodes, + refreshedNode.nodeId, + ); + const command = new DeleteNodeCommand(layer, refreshedNode, childNodes); + return executeSpatialSkeletonCommandWithPendingMessage( + layer.spatialSkeletonState.commandHistory.execute(command), + "Deleting node...", + ); +} + +export function executeSpatialSkeletonNodeDescriptionUpdate( + layer: SegmentationUserLayer, + options: { + node: SpatiallyIndexedSkeletonNode; + nextDescription?: string; + }, +) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + const command = new NodeDescriptionCommand( + layer, + commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), + options.node.description, + options.nextDescription ?? options.node.description, + ); + return layer.spatialSkeletonState.commandHistory.execute(command); +} + +export function executeSpatialSkeletonNodeTrueEndUpdate( + layer: SegmentationUserLayer, + options: { + node: SpatiallyIndexedSkeletonNode; + nextIsTrueEnd: boolean; + }, +) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + const command = new NodeTrueEndCommand( + layer, + commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), + options.node.isTrueEnd ?? false, + options.nextIsTrueEnd, + ); + return layer.spatialSkeletonState.commandHistory.execute(command); +} + +export function executeSpatialSkeletonNodePropertiesUpdate( + layer: SegmentationUserLayer, + options: { + node: SpatiallyIndexedSkeletonNode; + next: { radius: number; confidence: number }; + }, +) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + const command = new NodePropertiesCommand( + layer, + commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), + { + radius: options.node.radius ?? 0, + confidence: options.node.confidence ?? 0, + }, + options.next, + ); + return layer.spatialSkeletonState.commandHistory.execute(command); +} + +export function executeSpatialSkeletonReroot( + layer: SegmentationUserLayer, + node: Pick< + SpatiallyIndexedSkeletonNode, + "nodeId" | "segmentId" | "parentNodeId" + >, +) { + const segmentNodes = layer.getCachedSpatialSkeletonSegmentNodesForEdit( + node.segmentId, + ); + const rootNode = + findRootNode(segmentNodes) ?? + (() => { + throw new Error( + `Unable to resolve the current root for segment ${node.segmentId}.`, + ); + })(); + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + const command = new RerootCommand( + layer, + commandMappings.getStableOrCurrentNodeId(node.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(node.segmentId), + commandMappings.getStableOrCurrentNodeId(rootNode.nodeId)!, + ); + return layer.spatialSkeletonState.commandHistory.execute(command); +} + +export function executeSpatialSkeletonSplit( + layer: SegmentationUserLayer, + node: Pick, +) { + const segmentNodes = layer.getCachedSpatialSkeletonSegmentNodesForEdit( + node.segmentId, + ); + const splitNode = findSpatiallyIndexedSkeletonNode(segmentNodes, node.nodeId); + if (splitNode === undefined) { + throw new Error( + `Node ${node.nodeId} is not available in the inspected skeleton cache.`, + ); + } + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + const command = new SplitCommand( + layer, + commandMappings.getStableOrCurrentNodeId(splitNode.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(splitNode.segmentId), + commandMappings.getStableOrCurrentNodeId(splitNode.parentNodeId), + ); + return executeSpatialSkeletonCommandWithPendingMessage( + layer.spatialSkeletonState.commandHistory.execute(command), + "Splitting skeleton...", + ); +} + +function executeSpatialSkeletonCommandWithPendingMessage( + promise: Promise, + message: string, +) { + const status = StatusMessage.showMessage(message); + return promise.finally(() => status.dispose()); +} + +export function executeSpatialSkeletonMerge( + layer: SegmentationUserLayer, + firstNode: SpatialSkeletonMergeEndpoint, + secondNode: SpatialSkeletonMergeEndpoint, +) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + const command = new MergeCommand( + layer, + commandMappings.getStableOrCurrentNodeId(firstNode.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(firstNode.segmentId), + commandMappings.getStableOrCurrentNodeId(secondNode.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(secondNode.segmentId), + secondNode.sourceState, + ); + return executeSpatialSkeletonCommandWithPendingMessage( + layer.spatialSkeletonState.commandHistory.execute(command), + "Merging skeletons...", + ); +} + +export async function undoSpatialSkeletonCommand(layer: SegmentationUserLayer) { + const changed = await layer.spatialSkeletonState.commandHistory.undo(); + if (!changed) { + return false; + } + return true; +} + +export async function redoSpatialSkeletonCommand(layer: SegmentationUserLayer) { + const changed = await layer.spatialSkeletonState.commandHistory.redo(); + if (!changed) { + return false; + } + return true; +} diff --git a/src/layer/index.ts b/src/layer/index.ts index e6e5a5d7a..1ac9d0ade 100644 --- a/src/layer/index.ts +++ b/src/layer/index.ts @@ -1148,7 +1148,7 @@ export interface PickedSpatialSkeletonState { nodeId?: number; segmentId?: number; position?: Float32Array; - revisionToken?: string; + sourceState?: unknown; } export interface PickState { diff --git a/src/layer/segmentation/index.spec.ts b/src/layer/segmentation/index.spec.ts index 2149c1e14..ee6fb08dd 100644 --- a/src/layer/segmentation/index.spec.ts +++ b/src/layer/segmentation/index.spec.ts @@ -39,12 +39,17 @@ function makeEditableSpatialSkeletonSource( } = {}, ) { return { + spatialSkeletonEditController: { + supports: (action: string) => + action !== SpatialSkeletonActions.reroot || + options.rerootSkeleton !== undefined, + }, listSkeletons: async () => [], getSkeleton: async () => [], fetchNodes: async () => [], getSpatialIndexMetadata: async () => null, - addNode: async () => ({ treenodeId: 1, skeletonId: 1 }), - insertNode: async () => ({ treenodeId: 1, skeletonId: 1 }), + addNode: async () => ({ nodeId: 1, segmentId: 1 }), + insertNode: async () => ({ nodeId: 1, segmentId: 1 }), moveNode: async () => ({}), deleteNode: async () => ({}), updateDescription: async () => ({}), @@ -59,13 +64,13 @@ function makeEditableSpatialSkeletonSource( z: 0, }), mergeSkeletons: async () => ({ - resultSkeletonId: 1, - deletedSkeletonId: 2, - stableAnnotationSwap: false, + resultSegmentId: 1, + deletedSegmentId: 2, + directionAdjusted: false, }), splitSkeleton: async () => ({ - existingSkeletonId: 1, - newSkeletonId: 2, + existingSegmentId: 1, + newSegmentId: 2, }), ...(options.rerootSkeleton === undefined ? {} diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 5b8ab1c87..09b25f926 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -111,12 +111,8 @@ import { SpatialSkeletonActions, type SpatialSkeletonAction, } from "#src/skeleton/actions.js"; +import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; import { - SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES, - type SpatiallyIndexedSkeletonNode, -} from "#src/skeleton/api.js"; -import { - buildSpatiallyIndexedSkeletonNeighborhoodEditContext, findSpatiallyIndexedSkeletonNode, getSpatiallyIndexedSkeletonDirectChildren, getSpatiallyIndexedSkeletonNodeParent, @@ -142,7 +138,7 @@ import { type SpatialSkeletonDisplayNodeType, } from "#src/skeleton/node_types.js"; import { - getEditableSpatiallyIndexedSkeletonSource, + getSpatialSkeletonEditController, getSpatiallyIndexedSkeletonSource, SpatialSkeletonState, } from "#src/skeleton/spatial_skeleton_manager.js"; @@ -1249,7 +1245,7 @@ interface SelectedSpatialSkeletonNodeInfo { nodeId: number; segmentId?: number; position?: Float32Array; - revisionToken?: string; + sourceState?: unknown; } function normalizeOptionalPositiveSafeInteger(value: unknown) { @@ -1388,7 +1384,7 @@ export class SegmentationUserLayer extends Base { options: { segmentId?: number; position?: ArrayLike; - revisionToken?: string; + sourceState?: unknown; } = {}, ) => { const normalizedNodeId = normalizeOptionalPositiveSafeInteger(nodeId); @@ -1403,15 +1399,12 @@ export class SegmentationUserLayer extends Base { const selectedNodePosition = options.position ?? selectedNodeInfo?.position; const selectedGlobalPosition = this.getGlobalSelectionPositionFromModelPosition(selectedNodePosition); - const revisionToken = - typeof options.revisionToken === "string" - ? options.revisionToken - : selectedNodeInfo?.revisionToken; + const sourceState = options.sourceState ?? selectedNodeInfo?.sourceState; this.selectedSpatialSkeletonNodeInfo.value = { nodeId: normalizedNodeId, segmentId, position: copyOptionalSpatialSkeletonPosition(selectedNodePosition), - revisionToken, + sourceState, }; this.captureSpatialSkeletonSelectionState( (state) => { @@ -1892,15 +1885,9 @@ export class SegmentationUserLayer extends Base { if (action === SpatialSkeletonActions.inspect) { return getSpatiallyIndexedSkeletonSource(skeletonLayer) !== undefined; } - const editableSource = - getEditableSpatiallyIndexedSkeletonSource(skeletonLayer); - if (editableSource === undefined) { - return false; - } - if (action === SpatialSkeletonActions.reroot) { - return editableSource.rerootSkeleton !== undefined; - } - return true; + return ( + getSpatialSkeletonEditController(skeletonLayer)?.supports(action) ?? false + ); } private getMissingSpatialSkeletonSupportReason( @@ -1967,9 +1954,7 @@ export class SegmentationUserLayer extends Base { "No active spatial skeleton layer found for delete action.", ); } - if ( - getEditableSpatiallyIndexedSkeletonSource(skeletonLayer) === undefined - ) { + if (getSpatialSkeletonEditController(skeletonLayer) === undefined) { throw new Error( "Unable to resolve editable skeleton source for the active layer.", ); @@ -2003,10 +1988,6 @@ export class SegmentationUserLayer extends Base { currentNode, ), childNodes, - editContext: buildSpatiallyIndexedSkeletonNeighborhoodEditContext( - currentNode, - segmentNodes, - ), }; } @@ -2786,22 +2767,20 @@ export class SegmentationUserLayer extends Base { return true; } - const segmentId = nodeInfo.segmentId; - const nodePosition = nodeInfo.position; + const fullNodeInfo = completeNodeInfo; + const segmentId = fullNodeInfo.segmentId; + const nodePosition = fullNodeInfo.position; const segmentNodes = this.spatialSkeletonState.getCachedSegmentNodes(segmentId); const directChildNodeIds = segmentNodes - ?.filter((candidate) => candidate.parentNodeId === nodeInfo.nodeId) + ?.filter((candidate) => candidate.parentNodeId === fullNodeInfo.nodeId) .map((candidate) => candidate.nodeId) ?? []; - const nodeHasTrueEnd = completeNodeInfo?.isTrueEnd ?? false; - const nodeType = - completeNodeInfo === undefined - ? undefined - : getSpatialSkeletonDisplayNodeType( - completeNodeInfo, - segmentNodes === undefined ? undefined : directChildNodeIds.length, - ); + const nodeHasTrueEnd = fullNodeInfo.isTrueEnd ?? false; + const nodeType = getSpatialSkeletonDisplayNodeType( + fullNodeInfo, + segmentNodes === undefined ? undefined : directChildNodeIds.length, + ); const nodeTypeLabel = nodeType === undefined ? "Unknown" @@ -2817,16 +2796,14 @@ export class SegmentationUserLayer extends Base { summaryRow.classList.add("neuroglancer-spatial-skeleton-selection-summary"); container.appendChild(summaryRow); - const skeletonSource = - skeletonLayer === undefined - ? undefined - : getEditableSpatiallyIndexedSkeletonSource(skeletonLayer); + const skeletonEditController = + getSpatialSkeletonEditController(skeletonLayer); const rerootDisabledReason = - skeletonSource?.rerootSkeleton === undefined + skeletonEditController === undefined ? "Unable to resolve a reroot-capable skeleton source for the active layer." - : completeNodeInfo === undefined || segmentNodes === undefined + : segmentNodes === undefined ? "Load the active skeleton in the Skeleton tab before rerooting from Selection." - : completeNodeInfo.parentNodeId === undefined + : fullNodeInfo.parentNodeId === undefined ? "Selected node is already root." : this.getSpatialSkeletonActionsDisabledReason( SpatialSkeletonActions.reroot, @@ -2870,11 +2847,11 @@ export class SegmentationUserLayer extends Base { })(); }); const deleteDisabledReason = - skeletonSource === undefined + skeletonEditController === undefined ? "Unable to resolve editable skeleton source for the active layer." - : completeNodeInfo === undefined || segmentNodes === undefined + : segmentNodes === undefined ? "Load the active skeleton in the Skeleton tab before deleting from Selection." - : completeNodeInfo.parentNodeId === undefined && + : fullNodeInfo.parentNodeId === undefined && directChildNodeIds.length > 0 ? "Reroot the skeleton manually before deleting the current root node." : this.getSpatialSkeletonActionsDisabledReason( @@ -2892,7 +2869,7 @@ export class SegmentationUserLayer extends Base { deleteButton.addEventListener("click", () => { if ( deleteButton.disabled || - skeletonSource === undefined || + skeletonEditController === undefined || completeNodeInfo === undefined || deletePending ) { @@ -2969,23 +2946,23 @@ export class SegmentationUserLayer extends Base { summaryCoordinates.title = position.fullText; summaryRow.appendChild(summaryCoordinates); - appendSegmentAndNodeIds(segmentId, nodeInfo.nodeId); + appendSegmentAndNodeIds(segmentId, fullNodeInfo.nodeId); const isLeaf = segmentNodes !== undefined && directChildNodeIds.length === 0; const leafTypeEditingDisabledReason = () => - skeletonSource === undefined + skeletonEditController === undefined ? "Unable to resolve editable skeleton source for the active layer." : cachedNodeInfo === undefined || segmentNodes === undefined ? "Load the active skeleton in the Skeleton tab before changing leaf type." : this.getSpatialSkeletonActionsDisabledReason( SpatialSkeletonActions.editNodeTrueEnd, ); - if (completeNodeInfo !== undefined && (isLeaf || nodeHasTrueEnd)) { + if (isLeaf || nodeHasTrueEnd) { let committedTrueEnd = nodeHasTrueEnd; let leafTypeSavePending = false; const leafTypeEditor = document.createElement("div"); leafTypeEditor.className = "neuroglancer-spatial-skeleton-leaf-type"; - const leafTypeRadioName = `neuroglancer-spatial-skeleton-leaf-type-${segmentId}-${nodeInfo.nodeId}`; + const leafTypeRadioName = `neuroglancer-spatial-skeleton-leaf-type-${segmentId}-${fullNodeInfo.nodeId}`; const leafTypeOptionElements: HTMLLabelElement[] = []; const makeLeafTypeOption = (options: { label: string; @@ -3073,11 +3050,11 @@ export class SegmentationUserLayer extends Base { void (async () => { try { const currentNode = this.spatialSkeletonState.getCachedNode( - nodeInfo.nodeId, + fullNodeInfo.nodeId, ); if (currentNode === undefined) { throw new Error( - `Node ${nodeInfo.nodeId} is missing from the inspected skeleton cache.`, + `Node ${fullNodeInfo.nodeId} is missing from the inspected skeleton cache.`, ); } await executeSpatialSkeletonNodeTrueEndUpdate(this, { @@ -3111,33 +3088,47 @@ export class SegmentationUserLayer extends Base { } else { appendValue("Node type", nodeTypeLabel); } - if (cachedNodeInfo === undefined || segmentNodes === undefined) { + const nodeFeatureCapabilities = getSpatialSkeletonEditController( + this.getSpatiallyIndexedSkeletonLayer(), + )?.capabilities?.nodeFeatures; + const confidenceCapabilityValues = + nodeFeatureCapabilities?.confidenceValues; + const nodePropertiesEditable = + (nodeFeatureCapabilities?.radius ?? false) && + confidenceCapabilityValues !== undefined; + if ( + cachedNodeInfo === undefined || + segmentNodes === undefined || + !nodePropertiesEditable + ) { appendValue( "Radius", - formatSpatialSkeletonEditableNumber(nodeInfo.radius, "Unavailable"), + formatSpatialSkeletonEditableNumber(fullNodeInfo.radius, "Unavailable"), ); appendValue( "Confidence level", - formatSpatialSkeletonEditableNumber(nodeInfo.confidence, "Unavailable"), + formatSpatialSkeletonEditableNumber( + fullNodeInfo.confidence, + "Unavailable", + ), ); } else { - let committedRadius = nodeInfo.radius ?? 0; + let committedRadius = fullNodeInfo.radius ?? 0; let committedConfidence = - nodeInfo.confidence !== undefined && - Number.isFinite(nodeInfo.confidence) - ? Number(nodeInfo.confidence) + fullNodeInfo.confidence !== undefined && + Number.isFinite(fullNodeInfo.confidence) + ? Number(fullNodeInfo.confidence) : 0; const radiusInput = document.createElement("input"); radiusInput.className = "neuroglancer-spatial-skeleton-properties-input"; radiusInput.type = "number"; radiusInput.step = "any"; - radiusInput.value = formatSpatialSkeletonEditableNumber(nodeInfo.radius); + radiusInput.value = formatSpatialSkeletonEditableNumber( + fullNodeInfo.radius, + ); appendValue("Radius", radiusInput); const supportedConfidenceValues = Array.from( - new Set([ - ...SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES, - committedConfidence, - ]), + new Set([...confidenceCapabilityValues!, committedConfidence]), ).filter((value): value is number => Number.isFinite(value)); const confidenceSelectValues = Array.from( new Set([...supportedConfidenceValues, committedConfidence]), @@ -3155,7 +3146,7 @@ export class SegmentationUserLayer extends Base { appendValue("Confidence level", confidenceControl); let savePending = false; const getPropertyEditingDisabledReason = () => - skeletonSource === undefined + skeletonEditController === undefined ? "Unable to resolve editable skeleton source for the active layer." : this.getSpatialSkeletonActionsDisabledReason( SpatialSkeletonActions.editNodeProperties, @@ -3272,11 +3263,11 @@ export class SegmentationUserLayer extends Base { void (async () => { try { const currentNode = this.spatialSkeletonState.getCachedNode( - nodeInfo.nodeId, + fullNodeInfo.nodeId, ); if (currentNode === undefined) { throw new Error( - `Node ${nodeInfo.nodeId} is missing from the inspected skeleton cache.`, + `Node ${fullNodeInfo.nodeId} is missing from the inspected skeleton cache.`, ); } await executeSpatialSkeletonNodePropertiesUpdate(this, { @@ -3304,7 +3295,7 @@ export class SegmentationUserLayer extends Base { const descriptionText = cachedNodeInfo?.description ?? completeNodeInfo?.description ?? ""; const descriptionEditingDisabledReason = - skeletonSource === undefined + skeletonEditController === undefined ? "Unable to resolve editable skeleton source for the active layer." : cachedNodeInfo === undefined ? "Load the active skeleton in the Skeleton tab before editing description." @@ -3320,7 +3311,10 @@ export class SegmentationUserLayer extends Base { descriptionElement.placeholder = "Description"; descriptionElement.value = descriptionText; descriptionElement.addEventListener("change", () => { - if (skeletonSource === undefined || cachedNodeInfo === undefined) { + if ( + skeletonEditController === undefined || + cachedNodeInfo === undefined + ) { return; } const nextDescription = descriptionElement.value; @@ -3332,11 +3326,11 @@ export class SegmentationUserLayer extends Base { void (async () => { try { const currentNode = this.spatialSkeletonState.getCachedNode( - nodeInfo.nodeId, + fullNodeInfo.nodeId, ); if (currentNode === undefined) { throw new Error( - `Node ${nodeInfo.nodeId} is missing from the inspected skeleton cache.`, + `Node ${fullNodeInfo.nodeId} is missing from the inspected skeleton cache.`, ); } await executeSpatialSkeletonNodeDescriptionUpdate(this, { diff --git a/src/layer/segmentation/spatial_skeleton_commands.spec.ts b/src/layer/segmentation/spatial_skeleton_commands.spec.ts index 05143ae3e..3e003deac 100644 --- a/src/layer/segmentation/spatial_skeleton_commands.spec.ts +++ b/src/layer/segmentation/spatial_skeleton_commands.spec.ts @@ -1,15 +1,18 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { CatmaidSpatialSkeletonEditController } from "#src/datasource/catmaid/spatial_skeleton_commands.js"; +import { buildCatmaidNeighborhoodEditContext } from "#src/datasource/catmaid/edit_state.js"; +import { makeCatmaidNodeSourceState } from "#src/datasource/catmaid/api.js"; import { executeSpatialSkeletonDeleteNode, executeSpatialSkeletonMerge, executeSpatialSkeletonMoveNode, executeSpatialSkeletonSplit, + redoSpatialSkeletonCommand, undoSpatialSkeletonCommand, } from "#src/layer/segmentation/spatial_skeleton_commands.js"; import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; import { - buildSpatiallyIndexedSkeletonNeighborhoodEditContext, findSpatiallyIndexedSkeletonNode, getSpatiallyIndexedSkeletonDirectChildren, getSpatiallyIndexedSkeletonNodeParent, @@ -56,6 +59,7 @@ function setSegmentNodes( function makeEditableSkeletonSource(overrides: Record = {}) { return { + spatialSkeletonEditController: new CatmaidSpatialSkeletonEditController(), listSkeletons: vi.fn(), getSkeleton: vi.fn(), fetchNodes: vi.fn(), @@ -77,6 +81,10 @@ function makeEditableSkeletonSource(overrides: Record = {}) { }; } +function testSourceState(revisionToken: string) { + return makeCatmaidNodeSourceState(revisionToken); +} + function suppressStatusMessages() { const fakeStatusMessage = { dispose() {}, @@ -94,6 +102,57 @@ describe("spatial_skeleton_commands", () => { vi.restoreAllMocks(); }); + it("executes opaque source-created commands without generic edit methods", async () => { + const execute = vi.fn(); + const undo = vi.fn(); + const redo = vi.fn(); + const command = { + label: "Backend-owned move", + execute, + undo, + redo, + }; + const createMoveNodeCommand = vi.fn(() => command); + const layer = { + spatialSkeletonState: { + commandHistory: new SpatialSkeletonCommandHistory(), + }, + getSpatiallyIndexedSkeletonLayer: () => ({ + source: { + spatialSkeletonEditController: { + supports: () => true, + createMoveNodeCommand, + }, + listSkeletons: vi.fn(), + getSkeleton: vi.fn(), + fetchNodes: vi.fn(), + getSpatialIndexMetadata: vi.fn(), + }, + }), + }; + const node: SpatiallyIndexedSkeletonNode = { + nodeId: 17, + segmentId: 23, + position: new Float32Array([1, 2, 3]), + }; + const nextPositionInModelSpace = new Float32Array([7, 8, 9]); + + await executeSpatialSkeletonMoveNode(layer as any, { + node, + nextPositionInModelSpace, + }); + await undoSpatialSkeletonCommand(layer as any); + await redoSpatialSkeletonCommand(layer as any); + + expect(createMoveNodeCommand).toHaveBeenCalledWith(layer, { + node, + nextPositionInModelSpace, + }); + expect(execute).toHaveBeenCalledTimes(1); + expect(undo).toHaveBeenCalledTimes(1); + expect(redo).toHaveBeenCalledTimes(1); + }); + it("commits move-node commands using model-space positions", async () => { suppressStatusMessages(); @@ -102,11 +161,11 @@ describe("spatial_skeleton_commands", () => { segmentId: 23, position: new Float32Array([1, 2, 3]), isTrueEnd: false, - revisionToken: "before", + sourceState: testSourceState("before"), }; const nextPositionInModelSpace = new Float32Array([7, 8, 9]); const moveNode = vi.fn().mockResolvedValue({ - revisionToken: "after", + sourceState: testSourceState("after"), }); const skeletonLayer = { source: makeEditableSkeletonSource({ moveNode }), @@ -118,7 +177,7 @@ describe("spatial_skeleton_commands", () => { }; const commandHistory = new SpatialSkeletonCommandHistory(); const moveCachedNode = vi.fn(); - const setCachedNodeRevision = vi.fn(); + const setCachedNodeSourceState = vi.fn(); const markSpatialSkeletonNodeDataChanged = vi.fn(); const layer = { spatialSkeletonState: { @@ -130,7 +189,7 @@ describe("spatial_skeleton_commands", () => { segmentId === node.segmentId ? [node] : undefined, ), moveCachedNode, - setCachedNodeRevision, + setCachedNodeSourceState, }, getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, markSpatialSkeletonNodeDataChanged, @@ -153,7 +212,10 @@ describe("spatial_skeleton_commands", () => { 17, new Float32Array([7, 8, 9]), ); - expect(setCachedNodeRevision).toHaveBeenCalledWith(17, "after"); + expect(setCachedNodeSourceState).toHaveBeenCalledWith( + 17, + testSourceState("after"), + ); expect(markSpatialSkeletonNodeDataChanged).toHaveBeenCalledWith({ invalidateFullSkeletonCache: false, }); @@ -169,7 +231,7 @@ describe("spatial_skeleton_commands", () => { segmentId, position: new Float32Array([1, 2, 3]), isTrueEnd: false, - revisionToken: "root-before-delete", + sourceState: testSourceState("root-before-delete"), }; const deletedNode: SpatiallyIndexedSkeletonNode = { nodeId: 2, @@ -177,7 +239,7 @@ describe("spatial_skeleton_commands", () => { parentNodeId: rootNode.nodeId, position: new Float32Array([4, 5, 6]), isTrueEnd: false, - revisionToken: "deleted-before-delete", + sourceState: testSourceState("deleted-before-delete"), }; const firstChildNode: SpatiallyIndexedSkeletonNode = { nodeId: 3, @@ -185,7 +247,7 @@ describe("spatial_skeleton_commands", () => { parentNodeId: deletedNode.nodeId, position: new Float32Array([7, 8, 9]), isTrueEnd: false, - revisionToken: "first-child-before-delete", + sourceState: testSourceState("first-child-before-delete"), }; const secondChildNode: SpatiallyIndexedSkeletonNode = { nodeId: 4, @@ -193,38 +255,38 @@ describe("spatial_skeleton_commands", () => { parentNodeId: deletedNode.nodeId, position: new Float32Array([10, 11, 12]), isTrueEnd: false, - revisionToken: "second-child-before-delete", + sourceState: testSourceState("second-child-before-delete"), }; const deleteNode = vi.fn().mockResolvedValue({ - nodeRevisionUpdates: [ + nodeSourceStateUpdates: [ { nodeId: rootNode.nodeId, - revisionToken: "root-after-delete", + sourceState: testSourceState("root-after-delete"), }, { nodeId: firstChildNode.nodeId, - revisionToken: "first-child-after-delete", + sourceState: testSourceState("first-child-after-delete"), }, { nodeId: secondChildNode.nodeId, - revisionToken: "second-child-after-delete", + sourceState: testSourceState("second-child-after-delete"), }, ], }); const insertNode = vi.fn().mockResolvedValue({ - treenodeId: 20, - skeletonId: segmentId, - revisionToken: "restored-after-undo", - parentRevisionToken: "root-after-undo", - nodeRevisionUpdates: [ + nodeId: 20, + segmentId, + sourceState: testSourceState("restored-after-undo"), + parentSourceState: testSourceState("root-after-undo"), + nodeSourceStateUpdates: [ { nodeId: firstChildNode.nodeId, - revisionToken: "first-child-after-undo", + sourceState: testSourceState("first-child-after-undo"), }, { nodeId: secondChildNode.nodeId, - revisionToken: "second-child-after-undo", + sourceState: testSourceState("second-child-after-undo"), }, ], }); @@ -274,8 +336,9 @@ describe("spatial_skeleton_commands", () => { }, spatialSkeletonState, getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, - getCachedSpatialSkeletonSegmentNodesForEdit: (requestedSegmentId: number) => - spatialSkeletonState.getCachedSegmentNodes(requestedSegmentId) ?? [], + getCachedSpatialSkeletonSegmentNodesForEdit: ( + requestedSegmentId: number, + ) => spatialSkeletonState.getCachedSegmentNodes(requestedSegmentId) ?? [], async getSpatialSkeletonDeleteOperationContext( node: SpatiallyIndexedSkeletonNode, ) { @@ -299,7 +362,7 @@ describe("spatial_skeleton_commands", () => { currentNode, ), childNodes, - editContext: buildSpatiallyIndexedSkeletonNeighborhoodEditContext( + editContext: buildCatmaidNeighborhoodEditContext( currentNode, segmentNodes, ), @@ -313,10 +376,12 @@ describe("spatial_skeleton_commands", () => { await executeSpatialSkeletonDeleteNode(layer as any, deletedNode); - expect(spatialSkeletonState.getCachedNode(deletedNode.nodeId)).toBeUndefined(); - expect(spatialSkeletonState.getCachedNode(firstChildNode.nodeId)?.parentNodeId).toBe( - rootNode.nodeId, - ); + expect( + spatialSkeletonState.getCachedNode(deletedNode.nodeId), + ).toBeUndefined(); + expect( + spatialSkeletonState.getCachedNode(firstChildNode.nodeId)?.parentNodeId, + ).toBe(rootNode.nodeId); expect( spatialSkeletonState.getCachedNode(secondChildNode.nodeId)?.parentNodeId, ).toBe(rootNode.nodeId); @@ -356,13 +421,13 @@ describe("spatial_skeleton_commands", () => { parentNodeId: rootNode.nodeId, segmentId, }); - expect(spatialSkeletonState.getCachedNode(firstChildNode.nodeId)?.parentNodeId).toBe( - restoredNode?.nodeId, - ); + expect( + spatialSkeletonState.getCachedNode(firstChildNode.nodeId)?.parentNodeId, + ).toBe(restoredNode?.nodeId); expect( spatialSkeletonState.getCachedNode(secondChildNode.nodeId)?.parentNodeId, ).toBe(restoredNode?.nodeId); - const restoredEditContext = buildSpatiallyIndexedSkeletonNeighborhoodEditContext( + const restoredEditContext = buildCatmaidNeighborhoodEditContext( restoredNode!, spatialSkeletonState.getCachedSegmentNodes(segmentId)!, ); @@ -382,7 +447,7 @@ describe("spatial_skeleton_commands", () => { segmentId: originalSegmentId, position: new Float32Array([10, 20, 30]), isTrueEnd: false, - revisionToken: "parent-before", + sourceState: testSourceState("parent-before"), }; const splitNodeBefore: SpatiallyIndexedSkeletonNode = { nodeId: 21893038, @@ -390,17 +455,17 @@ describe("spatial_skeleton_commands", () => { parentNodeId: formerParentNode.nodeId, position: new Float32Array([11, 21, 31]), isTrueEnd: false, - revisionToken: "split-before", + sourceState: testSourceState("split-before"), }; const splitNodeAfter: SpatiallyIndexedSkeletonNode = { ...splitNodeBefore, segmentId: splitSegmentId, parentNodeId: undefined, - revisionToken: "split-after", + sourceState: testSourceState("split-after"), }; const splitNodeMergedBack: SpatiallyIndexedSkeletonNode = { ...splitNodeBefore, - revisionToken: "split-merged-back", + sourceState: testSourceState("split-merged-back"), }; const serverSegments = new Map(); @@ -428,8 +493,8 @@ describe("spatial_skeleton_commands", () => { serverSegments.set(originalSegmentId, [cloneNode(formerParentNode)]); serverSegments.set(splitSegmentId, [cloneNode(splitNodeAfter)]); return { - existingSkeletonId: originalSegmentId, - newSkeletonId: splitSegmentId, + existingSegmentId: originalSegmentId, + newSegmentId: splitSegmentId, }; }), mergeSkeletons: vi.fn(async () => { @@ -439,9 +504,9 @@ describe("spatial_skeleton_commands", () => { ]); serverSegments.delete(splitSegmentId); return { - resultSkeletonId: originalSegmentId, - deletedSkeletonId: splitSegmentId, - stableAnnotationSwap: false, + resultSegmentId: originalSegmentId, + deletedSegmentId: splitSegmentId, + directionAdjusted: false, }; }), }); @@ -566,7 +631,7 @@ describe("spatial_skeleton_commands", () => { segmentId: originalSegmentId, position: new Float32Array([1, 2, 3]), isTrueEnd: false, - revisionToken: "root-before", + sourceState: testSourceState("root-before"), }; const formerParentNode: SpatiallyIndexedSkeletonNode = { nodeId: 21893039, @@ -574,7 +639,7 @@ describe("spatial_skeleton_commands", () => { parentNodeId: originalRootNode.nodeId, position: new Float32Array([10, 20, 30]), isTrueEnd: false, - revisionToken: "parent-before", + sourceState: testSourceState("parent-before"), }; const splitNodeBefore: SpatiallyIndexedSkeletonNode = { nodeId: 21893038, @@ -582,30 +647,30 @@ describe("spatial_skeleton_commands", () => { parentNodeId: formerParentNode.nodeId, position: new Float32Array([11, 21, 31]), isTrueEnd: false, - revisionToken: "split-before", + sourceState: testSourceState("split-before"), }; const splitNodeAfter: SpatiallyIndexedSkeletonNode = { ...splitNodeBefore, segmentId: splitSegmentId, parentNodeId: undefined, - revisionToken: "split-after", + sourceState: testSourceState("split-after"), }; const restoredNodes: SpatiallyIndexedSkeletonNode[] = [ { ...originalRootNode, parentNodeId: undefined, - revisionToken: "root-rerooted", + sourceState: testSourceState("root-rerooted"), }, { ...formerParentNode, parentNodeId: originalRootNode.nodeId, - revisionToken: "parent-rerooted", + sourceState: testSourceState("parent-rerooted"), }, { ...splitNodeBefore, segmentId: originalSegmentId, parentNodeId: formerParentNode.nodeId, - revisionToken: "split-rerooted", + sourceState: testSourceState("split-rerooted"), }, ]; @@ -638,17 +703,17 @@ describe("spatial_skeleton_commands", () => { ]); serverSegments.set(splitSegmentId, [cloneNode(splitNodeAfter)]); return { - existingSkeletonId: originalSegmentId, - newSkeletonId: splitSegmentId, + existingSegmentId: originalSegmentId, + newSegmentId: splitSegmentId, }; }), mergeSkeletons: vi.fn(async () => { serverSegments.set(originalSegmentId, restoredNodes.map(cloneNode)); serverSegments.delete(splitSegmentId); return { - resultSkeletonId: originalSegmentId, - deletedSkeletonId: splitSegmentId, - stableAnnotationSwap: false, + resultSegmentId: originalSegmentId, + deletedSegmentId: splitSegmentId, + directionAdjusted: false, }; }), }); @@ -767,7 +832,7 @@ describe("spatial_skeleton_commands", () => { segmentId: visibleSegmentId, position: new Float32Array([1, 2, 3]), isTrueEnd: false, - revisionToken: "visible-root-before", + sourceState: testSourceState("visible-root-before"), }; const visibleAnchorNode: SpatiallyIndexedSkeletonNode = { nodeId: 102, @@ -775,14 +840,14 @@ describe("spatial_skeleton_commands", () => { parentNodeId: visibleRootNode.nodeId, position: new Float32Array([4, 5, 6]), isTrueEnd: false, - revisionToken: "visible-anchor-before", + sourceState: testSourceState("visible-anchor-before"), }; const hiddenRootNode: SpatiallyIndexedSkeletonNode = { nodeId: 201, segmentId: hiddenSegmentId, position: new Float32Array([7, 8, 9]), isTrueEnd: false, - revisionToken: "hidden-root-before", + sourceState: testSourceState("hidden-root-before"), }; const hiddenAttachNodeBefore: SpatiallyIndexedSkeletonNode = { nodeId: 202, @@ -790,7 +855,7 @@ describe("spatial_skeleton_commands", () => { parentNodeId: hiddenRootNode.nodeId, position: new Float32Array([10, 11, 12]), isTrueEnd: false, - revisionToken: "hidden-attach-before", + sourceState: testSourceState("hidden-attach-before"), }; const mergedNodes: SpatiallyIndexedSkeletonNode[] = [ cloneNode(visibleRootNode), @@ -810,24 +875,24 @@ describe("spatial_skeleton_commands", () => { { ...cloneNode(hiddenAttachNodeBefore), parentNodeId: undefined, - revisionToken: "hidden-attach-split", + sourceState: testSourceState("hidden-attach-split"), }, { ...cloneNode(hiddenRootNode), parentNodeId: hiddenAttachNodeBefore.nodeId, - revisionToken: "hidden-root-split", + sourceState: testSourceState("hidden-root-split"), }, ]; const rerootedHiddenNodes: SpatiallyIndexedSkeletonNode[] = [ { ...cloneNode(hiddenRootNode), parentNodeId: undefined, - revisionToken: "hidden-root-rerooted", + sourceState: testSourceState("hidden-root-rerooted"), }, { ...cloneNode(hiddenAttachNodeBefore), parentNodeId: hiddenRootNode.nodeId, - revisionToken: "hidden-attach-rerooted", + sourceState: testSourceState("hidden-attach-rerooted"), }, ]; @@ -867,9 +932,9 @@ describe("spatial_skeleton_commands", () => { serverSegments.set(visibleSegmentId, mergedNodes.map(cloneNode)); serverSegments.delete(hiddenSegmentId); return { - resultSkeletonId: visibleSegmentId, - deletedSkeletonId: hiddenSegmentId, - stableAnnotationSwap: false, + resultSegmentId: visibleSegmentId, + deletedSegmentId: hiddenSegmentId, + directionAdjusted: false, }; }), splitSkeleton: vi.fn(async () => { @@ -882,8 +947,8 @@ describe("spatial_skeleton_commands", () => { splitOnlyRestoredNodes.map(cloneNode), ); return { - existingSkeletonId: visibleSegmentId, - newSkeletonId: hiddenSegmentId, + existingSegmentId: visibleSegmentId, + newSegmentId: hiddenSegmentId, }; }), rerootSkeleton: vi.fn(async () => { @@ -967,7 +1032,7 @@ describe("spatial_skeleton_commands", () => { { nodeId: hiddenAttachNodeBefore.nodeId, segmentId: hiddenSegmentId, - revisionToken: hiddenAttachNodeBefore.revisionToken, + sourceState: hiddenAttachNodeBefore.sourceState, }, ); @@ -1033,7 +1098,7 @@ describe("spatial_skeleton_commands", () => { segmentId: visibleSegmentId, position: new Float32Array([1, 2, 3]), isTrueEnd: false, - revisionToken: "visible-root-before", + sourceState: testSourceState("visible-root-before"), }; const visibleAnchorNode: SpatiallyIndexedSkeletonNode = { nodeId: 102, @@ -1041,14 +1106,14 @@ describe("spatial_skeleton_commands", () => { parentNodeId: visibleRootNode.nodeId, position: new Float32Array([4, 5, 6]), isTrueEnd: false, - revisionToken: "visible-anchor-before", + sourceState: testSourceState("visible-anchor-before"), }; const hiddenRootNode: SpatiallyIndexedSkeletonNode = { nodeId: 201, segmentId: hiddenSegmentId, position: new Float32Array([7, 8, 9]), isTrueEnd: false, - revisionToken: "hidden-root-before", + sourceState: testSourceState("hidden-root-before"), }; const hiddenAttachNodeBefore: SpatiallyIndexedSkeletonNode = { nodeId: 202, @@ -1056,7 +1121,7 @@ describe("spatial_skeleton_commands", () => { parentNodeId: hiddenRootNode.nodeId, position: new Float32Array([10, 11, 12]), isTrueEnd: false, - revisionToken: "hidden-attach-before", + sourceState: testSourceState("hidden-attach-before"), }; const mergedNodes: SpatiallyIndexedSkeletonNode[] = [ cloneNode(visibleRootNode), @@ -1076,12 +1141,12 @@ describe("spatial_skeleton_commands", () => { { ...cloneNode(hiddenAttachNodeBefore), parentNodeId: undefined, - revisionToken: "hidden-attach-split", + sourceState: testSourceState("hidden-attach-split"), }, { ...cloneNode(hiddenRootNode), parentNodeId: hiddenAttachNodeBefore.nodeId, - revisionToken: "hidden-root-split", + sourceState: testSourceState("hidden-root-split"), }, ]; @@ -1120,9 +1185,9 @@ describe("spatial_skeleton_commands", () => { serverSegments.set(visibleSegmentId, mergedNodes.map(cloneNode)); serverSegments.delete(hiddenSegmentId); return { - resultSkeletonId: visibleSegmentId, - deletedSkeletonId: hiddenSegmentId, - stableAnnotationSwap: false, + resultSegmentId: visibleSegmentId, + deletedSegmentId: hiddenSegmentId, + directionAdjusted: false, }; }), splitSkeleton: vi.fn(async () => { @@ -1135,8 +1200,8 @@ describe("spatial_skeleton_commands", () => { splitOnlyRestoredNodes.map(cloneNode), ); return { - existingSkeletonId: visibleSegmentId, - newSkeletonId: hiddenSegmentId, + existingSegmentId: visibleSegmentId, + newSegmentId: hiddenSegmentId, }; }), rerootSkeleton: vi.fn(async () => { @@ -1209,7 +1274,7 @@ describe("spatial_skeleton_commands", () => { { nodeId: hiddenAttachNodeBefore.nodeId, segmentId: hiddenSegmentId, - revisionToken: hiddenAttachNodeBefore.revisionToken, + sourceState: hiddenAttachNodeBefore.sourceState, }, ); statusSpy.mockClear(); @@ -1254,7 +1319,7 @@ describe("spatial_skeleton_commands", () => { segmentId: firstSegmentId, position: new Float32Array([1, 2, 3]), isTrueEnd: false, - revisionToken: "first-root-before", + sourceState: testSourceState("first-root-before"), }; const firstAnchorNode: SpatiallyIndexedSkeletonNode = { nodeId: 102, @@ -1262,14 +1327,14 @@ describe("spatial_skeleton_commands", () => { parentNodeId: firstRootNode.nodeId, position: new Float32Array([4, 5, 6]), isTrueEnd: false, - revisionToken: "first-anchor-before", + sourceState: testSourceState("first-anchor-before"), }; const secondRootNode: SpatiallyIndexedSkeletonNode = { nodeId: 201, segmentId: secondSegmentId, position: new Float32Array([7, 8, 9]), isTrueEnd: false, - revisionToken: "second-root-before", + sourceState: testSourceState("second-root-before"), }; const secondAttachNode: SpatiallyIndexedSkeletonNode = { nodeId: 202, @@ -1277,7 +1342,7 @@ describe("spatial_skeleton_commands", () => { parentNodeId: secondRootNode.nodeId, position: new Float32Array([10, 11, 12]), isTrueEnd: false, - revisionToken: "second-attach-before", + sourceState: testSourceState("second-attach-before"), }; const serverSegments = new Map(); @@ -1312,9 +1377,9 @@ describe("spatial_skeleton_commands", () => { z: secondRootNode.position[2], })), mergeSkeletons: vi.fn(async () => ({ - resultSkeletonId: firstSegmentId, - deletedSkeletonId: secondSegmentId, - stableAnnotationSwap: false, + resultSegmentId: firstSegmentId, + deletedSegmentId: secondSegmentId, + directionAdjusted: false, })), }); @@ -1411,17 +1476,17 @@ describe("spatial_skeleton_commands", () => { let resolveMerge: | ((value: { - resultSkeletonId: number; - deletedSkeletonId: number; - stableAnnotationSwap: boolean; + resultSegmentId: number; + deletedSegmentId: number; + directionAdjusted: boolean; }) => void) | undefined; const mergeSkeletons = vi.fn( () => new Promise<{ - resultSkeletonId: number; - deletedSkeletonId: number; - stableAnnotationSwap: boolean; + resultSegmentId: number; + deletedSegmentId: number; + directionAdjusted: boolean; }>((resolve) => { resolveMerge = resolve; }), @@ -1431,14 +1496,14 @@ describe("spatial_skeleton_commands", () => { segmentId: 11, position: new Float32Array([1, 2, 3]), isTrueEnd: false, - revisionToken: "first-before", + sourceState: testSourceState("first-before"), }; const secondNode: SpatiallyIndexedSkeletonNode = { nodeId: 202, segmentId: 17, position: new Float32Array([4, 5, 6]), isTrueEnd: false, - revisionToken: "second-before", + sourceState: testSourceState("second-before"), }; const skeletonLayer = { source: makeEditableSkeletonSource({ mergeSkeletons }), @@ -1513,9 +1578,9 @@ describe("spatial_skeleton_commands", () => { }); resolveMerge?.({ - resultSkeletonId: firstNode.segmentId, - deletedSkeletonId: secondNode.segmentId, - stableAnnotationSwap: false, + resultSegmentId: firstNode.segmentId, + deletedSegmentId: secondNode.segmentId, + directionAdjusted: false, }); await mergePromise; diff --git a/src/layer/segmentation/spatial_skeleton_commands.ts b/src/layer/segmentation/spatial_skeleton_commands.ts index 5505247e9..8f9149e1f 100644 --- a/src/layer/segmentation/spatial_skeleton_commands.ts +++ b/src/layer/segmentation/spatial_skeleton_commands.ts @@ -15,1667 +15,128 @@ */ import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; -import { - addSegmentToVisibleSets, - removeSegmentFromVisibleSets, -} from "#src/segmentation_display_state/base.js"; import type { - EditableSpatiallyIndexedSkeletonSource, - SpatiallyIndexedSkeletonAddNodeResult, - SpatiallyIndexedSkeletonEditContext, - SpatiallyIndexedSkeletonInsertNodeResult, - SpatiallyIndexedSkeletonMergeResult, SpatiallyIndexedSkeletonNode, - SpatiallyIndexedSkeletonNodeRevisionUpdate, - SpatiallyIndexedSkeletonSplitResult, + SpatialSkeletonAddNodeCommandOptions, + SpatialSkeletonEditController, + SpatialSkeletonMergeEndpoint, + SpatialSkeletonMoveNodeCommandOptions, + SpatialSkeletonNodeDescriptionCommandOptions, + SpatialSkeletonNodePropertiesCommandOptions, + SpatialSkeletonNodeTrueEndCommandOptions, } from "#src/skeleton/api.js"; -import type { - SpatialSkeletonCommand, - SpatialSkeletonCommandContext, -} from "#src/skeleton/command_history.js"; -import { - buildSpatiallyIndexedSkeletonMultiNodeEditContext, - buildSpatiallyIndexedSkeletonNeighborhoodEditContext, - buildSpatiallyIndexedSkeletonNodeEditContext, - buildSpatiallyIndexedSkeletonRerootEditContext, - findSpatiallyIndexedSkeletonNode, - getSpatiallyIndexedSkeletonDirectChildren, -} from "#src/skeleton/edit_state.js"; -import type { SpatiallyIndexedSkeletonLayer } from "#src/skeleton/frontend.js"; -import { getEditableSpatiallyIndexedSkeletonSource } from "#src/skeleton/spatial_skeleton_manager.js"; +import type { SpatialSkeletonCommand } from "#src/skeleton/command_history.js"; +import { getSpatialSkeletonEditController } from "#src/skeleton/spatial_skeleton_manager.js"; import { StatusMessage } from "#src/status.js"; -import { formatErrorMessage } from "#src/util/error.js"; -function cloneNodeSnapshot( - node: SpatiallyIndexedSkeletonNode, -): SpatiallyIndexedSkeletonNode { - return { - nodeId: node.nodeId, - segmentId: node.segmentId, - position: new Float32Array(node.position), - parentNodeId: node.parentNodeId, - radius: node.radius, - confidence: node.confidence, - description: node.description, - isTrueEnd: node.isTrueEnd, - revisionToken: node.revisionToken, - }; -} - -function getEditableSkeletonSourceForLayer(layer: SegmentationUserLayer): { - skeletonLayer: SpatiallyIndexedSkeletonLayer; - skeletonSource: EditableSpatiallyIndexedSkeletonSource; -} { +function getController( + layer: SegmentationUserLayer, +): SpatialSkeletonEditController { const skeletonLayer = layer.getSpatiallyIndexedSkeletonLayer(); - if (skeletonLayer === undefined) { - throw new Error( - "No spatially indexed skeleton source is currently loaded.", - ); - } - const skeletonSource = - getEditableSpatiallyIndexedSkeletonSource(skeletonLayer); - if (skeletonSource === undefined) { + const controller = getSpatialSkeletonEditController(skeletonLayer); + if (controller === undefined) { throw new Error( "Unable to resolve editable skeleton source for the active layer.", ); } - return { skeletonLayer, skeletonSource }; -} - -function ensureVisibleSegment( - layer: SegmentationUserLayer, - segmentId: number | undefined, -) { - if ( - segmentId === undefined || - !Number.isSafeInteger(Math.round(Number(segmentId))) || - Math.round(Number(segmentId)) <= 0 - ) { - return; - } - addSegmentToVisibleSets( - layer.displayState.segmentationGroupState.value, - BigInt(Math.round(Number(segmentId))), - ); -} - -function selectSegment( - layer: SegmentationUserLayer, - segmentId: number | undefined, - pin: boolean, -) { - if ( - segmentId === undefined || - !Number.isSafeInteger(Math.round(Number(segmentId))) || - Math.round(Number(segmentId)) <= 0 - ) { - return; - } - layer.selectSegment(BigInt(Math.round(Number(segmentId))), pin); -} - -function removeVisibleSegment( - layer: SegmentationUserLayer, - segmentId: number | undefined, - options: { - deselect?: boolean; - } = {}, -) { - if ( - segmentId === undefined || - !Number.isSafeInteger(Math.round(Number(segmentId))) || - Math.round(Number(segmentId)) <= 0 - ) { - return; - } - removeSegmentFromVisibleSets( - layer.displayState.segmentationGroupState.value, - BigInt(Math.round(Number(segmentId))), - options, - ); -} - -function findRootNode(segmentNodes: readonly SpatiallyIndexedSkeletonNode[]) { - return segmentNodes.find((candidate) => candidate.parentNodeId === undefined); -} - -interface ResolvedSpatialSkeletonEditNode { - skeletonLayer: SpatiallyIndexedSkeletonLayer; - skeletonSource: EditableSpatiallyIndexedSkeletonSource; - segmentNodes: readonly SpatiallyIndexedSkeletonNode[]; - node: SpatiallyIndexedSkeletonNode; -} - -interface ResolvedSpatialSkeletonEditNodeContext { - currentNodeId: number; - segmentId: number; - cachedNode: SpatiallyIndexedSkeletonNode | undefined; - skeletonLayer: SpatiallyIndexedSkeletonLayer; - skeletonSource: EditableSpatiallyIndexedSkeletonSource; -} - -function getResolvedNodeContextForEdit( - layer: SegmentationUserLayer, - stableNodeId: number, - stableSegmentId: number | undefined, -): ResolvedSpatialSkeletonEditNodeContext { - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const currentNodeId = commandMappings.resolveNodeId(stableNodeId); - if (currentNodeId === undefined) { - throw new Error(`Unable to resolve current node ${stableNodeId}.`); - } - const { skeletonLayer, skeletonSource } = - getEditableSkeletonSourceForLayer(layer); - const cachedNode = - layer.spatialSkeletonState.getCachedNode(currentNodeId) ?? - skeletonLayer.getNode(currentNodeId); - const candidateSegmentId = - cachedNode?.segmentId ?? commandMappings.resolveSegmentId(stableSegmentId); - if (candidateSegmentId === undefined) { - throw new Error( - `Unable to resolve the current segment for node ${stableNodeId}.`, - ); - } - return { - currentNodeId, - segmentId: candidateSegmentId, - cachedNode, - skeletonLayer, - skeletonSource, - }; -} - -async function getResolvedNodeForEdit( - layer: SegmentationUserLayer, - stableNodeId: number, - stableSegmentId: number | undefined, -): Promise { - const { - currentNodeId, - segmentId: candidateSegmentId, - skeletonLayer, - skeletonSource, - } = getResolvedNodeContextForEdit(layer, stableNodeId, stableSegmentId); - let segmentNodes = - layer.spatialSkeletonState.getCachedSegmentNodes(candidateSegmentId); - if (segmentNodes === undefined) { - segmentNodes = await layer.spatialSkeletonState.getFullSegmentNodes( - skeletonLayer, - candidateSegmentId, - ); - } - const node = findSpatiallyIndexedSkeletonNode(segmentNodes, currentNodeId); - if (node === undefined) { - throw new Error( - `Node ${currentNodeId} is not available in the inspected skeleton cache.`, - ); - } - return { - skeletonLayer, - skeletonSource, - segmentNodes, - node, - }; -} - -function buildInsertEditContext( - parentNode: SpatiallyIndexedSkeletonNode, - childNodes: readonly SpatiallyIndexedSkeletonNode[], -): SpatiallyIndexedSkeletonEditContext { - return { - node: buildSpatiallyIndexedSkeletonNodeEditContext(parentNode).node, - children: childNodes.map((child) => ({ - nodeId: child.nodeId, - revisionToken: - child.revisionToken ?? - (() => { - throw new Error( - `Inspected child node ${child.nodeId} is missing revision metadata.`, - ); - })(), - })), - }; -} - -async function refreshTopologySegments( - layer: SegmentationUserLayer, - segmentIds: readonly number[], -) { - const normalizedSegmentIds = [ - ...new Set( - segmentIds.filter((value) => Number.isSafeInteger(Math.round(value))), - ), - ].map((value) => Math.round(value)); - if (normalizedSegmentIds.length === 0) { - return; - } - const { skeletonLayer } = getEditableSkeletonSourceForLayer(layer); - layer.spatialSkeletonState.invalidateCachedSegments(normalizedSegmentIds); - layer.markSpatialSkeletonNodeDataChanged({ - invalidateFullSkeletonCache: false, - }); - skeletonLayer.invalidateSourceCaches(); - await Promise.allSettled( - normalizedSegmentIds.map((segmentId) => - layer.spatialSkeletonState.getFullSegmentNodes(skeletonLayer, segmentId), - ), - ); + return controller; } -function applyAddNodeToCache( - layer: SegmentationUserLayer, - skeletonLayer: SpatiallyIndexedSkeletonLayer, - committedNode: SpatiallyIndexedSkeletonAddNodeResult, - parentNodeId: number | undefined, - positionInModelSpace: Float32Array, - options: { - focusSelection: boolean; - moveView: boolean; - pinSegment: boolean; - }, +function requireCommand( + command: SpatialSkeletonCommand | undefined, + message: string, ) { - const newNode: SpatiallyIndexedSkeletonNode = { - nodeId: committedNode.treenodeId, - segmentId: committedNode.skeletonId, - position: new Float32Array(positionInModelSpace), - parentNodeId, - isTrueEnd: false, - ...(committedNode.revisionToken === undefined - ? {} - : { revisionToken: committedNode.revisionToken }), - }; - layer.spatialSkeletonState.upsertCachedNode(newNode, { - allowUncachedSegment: parentNodeId === undefined, - }); - if ( - parentNodeId !== undefined && - committedNode.parentRevisionToken !== undefined - ) { - layer.spatialSkeletonState.setCachedNodeRevision( - parentNodeId, - committedNode.parentRevisionToken, - ); - } - ensureVisibleSegment(layer, newNode.segmentId); - selectSegment(layer, newNode.segmentId, options.pinSegment); - if (options.focusSelection) { - layer.selectSpatialSkeletonNode( - newNode.nodeId, - layer.manager.root.selectionState.pin.value, - { - segmentId: newNode.segmentId, - position: newNode.position, - }, - ); - if (options.moveView) { - layer.moveViewToSpatialSkeletonNodePosition(newNode.position); - } - } - if (parentNodeId !== undefined) { - skeletonLayer.retainOverlaySegment(newNode.segmentId); + if (command === undefined) { + throw new Error(message); } - layer.markSpatialSkeletonNodeDataChanged({ - invalidateFullSkeletonCache: false, - }); + return command; } -function applyDeleteNodeToCache( +function executeCommand( layer: SegmentationUserLayer, - deleteContext: { - node: SpatiallyIndexedSkeletonNode; - parentNode: SpatiallyIndexedSkeletonNode | undefined; - childNodes: readonly SpatiallyIndexedSkeletonNode[]; - editContext: SpatiallyIndexedSkeletonEditContext; - }, - options: { - moveView: boolean; - }, - nodeRevisionUpdates: readonly SpatiallyIndexedSkeletonNodeRevisionUpdate[] = [], -) { - const { node, parentNode, childNodes } = deleteContext; - const directChildIds = childNodes.map((child) => child.nodeId); - layer.spatialSkeletonState.removeCachedNode(node.nodeId, { - parentNodeId: node.parentNodeId, - childNodeIds: directChildIds, - }); - if (nodeRevisionUpdates.length > 0) { - layer.spatialSkeletonState.setCachedNodeRevisions(nodeRevisionUpdates); - } - if (parentNode !== undefined) { - if (options.moveView) { - layer.selectAndMoveToSpatialSkeletonNode( - parentNode, - layer.manager.root.selectionState.pin.value, - ); - } else { - layer.selectSpatialSkeletonNode( - parentNode.nodeId, - layer.manager.root.selectionState.pin.value, - { - segmentId: parentNode.segmentId, - position: parentNode.position, - }, - ); - } - } else { - layer.clearSpatialSkeletonNodeSelection( - layer.manager.root.selectionState.pin.value, - ); - } - const remainingSegmentNodes = - layer.spatialSkeletonState.getCachedSegmentNodes(node.segmentId) ?? []; - if (remainingSegmentNodes.length === 0) { - removeVisibleSegment(layer, node.segmentId, { deselect: true }); - } - layer.markSpatialSkeletonNodeDataChanged({ - invalidateFullSkeletonCache: false, - }); -} - -async function applyNodeDescriptionAndTrueEnd( - skeletonSource: EditableSpatiallyIndexedSkeletonSource, - node: SpatiallyIndexedSkeletonNode, - next: { - description?: string; - isTrueEnd: boolean; - }, + command: SpatialSkeletonCommand, ) { - const nextDescription = next.description; - const nextTrueEnd = next.isTrueEnd; - let updatedNode: SpatiallyIndexedSkeletonNode = { - ...node, - description: nextDescription, - isTrueEnd: nextTrueEnd, - }; - const descriptionChanged = node.description !== nextDescription; - if (descriptionChanged) { - const descriptionResult = await skeletonSource.updateDescription( - node.nodeId, - nextDescription ?? "", - ); - updatedNode = { - ...updatedNode, - description: descriptionResult.description, - revisionToken: - descriptionResult.revisionToken ?? updatedNode.revisionToken, - }; - } - if (node.isTrueEnd !== nextTrueEnd || (descriptionChanged && nextTrueEnd)) { - const trueEndResult = nextTrueEnd - ? await skeletonSource.setTrueEnd(node.nodeId) - : await skeletonSource.removeTrueEnd(node.nodeId); - updatedNode = { - ...updatedNode, - revisionToken: trueEndResult.revisionToken ?? updatedNode.revisionToken, - }; - } - return updatedNode; + return layer.spatialSkeletonState.commandHistory.execute(command); } -async function restoreNodeAttributes( - layer: SegmentationUserLayer, - skeletonSource: EditableSpatiallyIndexedSkeletonSource, - createdNode: SpatiallyIndexedSkeletonNode, - snapshot: SpatiallyIndexedSkeletonNode, +function executeCommandWithPendingMessage( + promise: Promise, + message: string, ) { - let nextNode = cloneNodeSnapshot(createdNode); - if (snapshot.radius !== undefined && snapshot.radius !== nextNode.radius) { - const radiusResult = await skeletonSource.updateRadius( - createdNode.nodeId, - snapshot.radius, - buildSpatiallyIndexedSkeletonNodeEditContext(nextNode), - ); - nextNode = { - ...nextNode, - radius: snapshot.radius, - revisionToken: radiusResult.revisionToken ?? nextNode.revisionToken, - }; - } - if ( - snapshot.confidence !== undefined && - snapshot.confidence !== nextNode.confidence - ) { - if (nextNode.revisionToken === undefined) { - throw new Error( - `Node ${createdNode.nodeId} is missing revision metadata required to restore confidence.`, - ); - } - const confidenceResult = await skeletonSource.updateConfidence( - createdNode.nodeId, - snapshot.confidence, - buildSpatiallyIndexedSkeletonNodeEditContext(nextNode), - ); - nextNode = { - ...nextNode, - confidence: snapshot.confidence, - revisionToken: confidenceResult.revisionToken ?? nextNode.revisionToken, - }; - } - if ( - nextNode.description !== snapshot.description || - nextNode.isTrueEnd !== snapshot.isTrueEnd - ) { - nextNode = await applyNodeDescriptionAndTrueEnd( - skeletonSource, - nextNode, - snapshot, - ); - } - layer.spatialSkeletonState.upsertCachedNode(nextNode); - return nextNode; -} - -class AddNodeCommand implements SpatialSkeletonCommand { - readonly label = "Add node"; - private stableNodeId: number | undefined; - private stableSegmentId: number | undefined; - - constructor( - private layer: SegmentationUserLayer, - private stableParentNodeId: number | undefined, - private targetSkeletonId: number, - private positionInModelSpace: Float32Array, - ) {} - - private async addNode( - _context: SpatialSkeletonCommandContext, - options: { - moveView: boolean; - pinSegment: boolean; - statusPrefix: string; - }, - ) { - const { skeletonLayer, skeletonSource } = getEditableSkeletonSourceForLayer( - this.layer, - ); - const currentParentNodeId = - this.stableParentNodeId === undefined - ? undefined - : this.layer.spatialSkeletonState.commandHistory.mappings.resolveNodeId( - this.stableParentNodeId, - ); - let resolvedEditContext: SpatiallyIndexedSkeletonEditContext | undefined; - let resolvedSkeletonId = this.targetSkeletonId; - if (currentParentNodeId !== undefined) { - const parentNode = ( - await getResolvedNodeForEdit( - this.layer, - this.stableParentNodeId!, - this.layer.spatialSkeletonState.commandHistory.mappings.getStableOrCurrentSegmentId( - this.targetSkeletonId, - ), - ) - ).node; - resolvedSkeletonId = parentNode.segmentId; - resolvedEditContext = - buildSpatiallyIndexedSkeletonNodeEditContext(parentNode); - } - const result = await skeletonSource.addNode( - resolvedSkeletonId, - Number(this.positionInModelSpace[0]), - Number(this.positionInModelSpace[1]), - Number(this.positionInModelSpace[2]), - currentParentNodeId, - resolvedEditContext, - ); - if (this.stableNodeId === undefined) { - this.stableNodeId = result.treenodeId; - } else { - this.layer.spatialSkeletonState.commandHistory.mappings.remapNodeId( - this.stableNodeId, - result.treenodeId, - ); - } - if (this.stableSegmentId === undefined) { - this.stableSegmentId = result.skeletonId; - } else { - this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( - this.stableSegmentId, - result.skeletonId, - ); - } - applyAddNodeToCache( - this.layer, - skeletonLayer, - result, - currentParentNodeId, - this.positionInModelSpace, - { - focusSelection: true, - moveView: options.moveView, - pinSegment: options.pinSegment, - }, - ); - StatusMessage.showTemporaryMessage( - `${options.statusPrefix} node ${result.treenodeId} on segment ${result.skeletonId}.`, - ); - } - - async execute(context: SpatialSkeletonCommandContext) { - await this.addNode(context, { - moveView: true, - pinSegment: true, - statusPrefix: "Added", - }); - } - - async undo(_context: SpatialSkeletonCommandContext) { - if (this.stableNodeId === undefined) { - throw new Error("Add-node undo is missing the created node id."); - } - const resolvedNode = await getResolvedNodeForEdit( - this.layer, - this.stableNodeId, - this.stableSegmentId, - ); - const deleteContext = - await this.layer.getSpatialSkeletonDeleteOperationContext( - resolvedNode.node, - ); - const result = await resolvedNode.skeletonSource.deleteNode( - resolvedNode.node.nodeId, - { - childNodeIds: [], - editContext: deleteContext.editContext, - }, - ); - applyDeleteNodeToCache( - this.layer, - deleteContext, - { moveView: false }, - result.nodeRevisionUpdates, - ); - StatusMessage.showTemporaryMessage( - `Undid add node ${resolvedNode.node.nodeId}.`, - ); - } - - async redo(context: SpatialSkeletonCommandContext) { - await this.addNode(context, { - moveView: false, - pinSegment: false, - statusPrefix: "Redid add of", - }); - } -} - -class MoveNodeCommand implements SpatialSkeletonCommand { - readonly label = "Move node"; - - constructor( - private layer: SegmentationUserLayer, - private stableNodeId: number, - private stableSegmentId: number | undefined, - private beforePositionInModelSpace: Float32Array, - private afterPositionInModelSpace: Float32Array, - ) {} - - private async moveTo( - positionInModelSpace: Float32Array, - statusPrefix: string, - ) { - const { node, skeletonLayer, skeletonSource } = - await getResolvedNodeForEdit( - this.layer, - this.stableNodeId, - this.stableSegmentId, - ); - const result = await skeletonSource.moveNode( - node.nodeId, - Number(positionInModelSpace[0]), - Number(positionInModelSpace[1]), - Number(positionInModelSpace[2]), - buildSpatiallyIndexedSkeletonNodeEditContext(node), - ); - skeletonLayer.retainOverlaySegment(node.segmentId); - this.layer.spatialSkeletonState.moveCachedNode( - node.nodeId, - positionInModelSpace, - ); - if (result.revisionToken !== undefined) { - this.layer.spatialSkeletonState.setCachedNodeRevision( - node.nodeId, - result.revisionToken, - ); - } - this.layer.markSpatialSkeletonNodeDataChanged({ - invalidateFullSkeletonCache: false, - }); - StatusMessage.showTemporaryMessage( - `${statusPrefix} node ${node.nodeId} to (${Math.round(positionInModelSpace[0])}, ${Math.round(positionInModelSpace[1])}, ${Math.round(positionInModelSpace[2])}).`, - ); - } - - execute() { - return this.moveTo(this.afterPositionInModelSpace, "Moved"); - } - - undo() { - return this.moveTo(this.beforePositionInModelSpace, "Undid move of"); - } - - redo() { - return this.moveTo(this.afterPositionInModelSpace, "Redid move of"); - } -} - -class DeleteNodeCommand implements SpatialSkeletonCommand { - readonly label = "Delete node"; - private stableDeletedNodeId: number; - private stableSegmentId: number | undefined; - private stableParentNodeId: number | undefined; - private stableChildNodeIds: number[]; - private deletedSnapshot: SpatiallyIndexedSkeletonNode; - - constructor( - private layer: SegmentationUserLayer, - node: SpatiallyIndexedSkeletonNode, - childNodes: readonly SpatiallyIndexedSkeletonNode[], - ) { - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - this.stableDeletedNodeId = commandMappings.getStableOrCurrentNodeId( - node.nodeId, - )!; - this.stableSegmentId = commandMappings.getStableOrCurrentSegmentId( - node.segmentId, - ); - this.stableParentNodeId = commandMappings.getStableOrCurrentNodeId( - node.parentNodeId, - ); - this.stableChildNodeIds = childNodes.map( - (child) => commandMappings.getStableOrCurrentNodeId(child.nodeId)!, - ); - this.deletedSnapshot = cloneNodeSnapshot(node); - } - - private async deleteNode(options: { - moveView: boolean; - statusPrefix: string; - }) { - const resolvedNode = await getResolvedNodeForEdit( - this.layer, - this.stableDeletedNodeId, - this.stableSegmentId, - ); - const deleteContext = - await this.layer.getSpatialSkeletonDeleteOperationContext( - resolvedNode.node, - ); - const result = await resolvedNode.skeletonSource.deleteNode( - resolvedNode.node.nodeId, - { - childNodeIds: deleteContext.childNodes.map((child) => child.nodeId), - editContext: deleteContext.editContext, - }, - ); - applyDeleteNodeToCache( - this.layer, - deleteContext, - { moveView: options.moveView }, - result.nodeRevisionUpdates, - ); - resolvedNode.skeletonLayer.invalidateSourceCaches(); - StatusMessage.showTemporaryMessage( - `${options.statusPrefix} node ${resolvedNode.node.nodeId}.`, - ); - } - - private async restoreDeletedNode(statusPrefix: string) { - const { skeletonSource } = getEditableSkeletonSourceForLayer(this.layer); - const currentParentNode = - this.stableParentNodeId === undefined - ? undefined - : ( - await getResolvedNodeForEdit( - this.layer, - this.stableParentNodeId, - this.stableSegmentId, - ) - ).node; - const currentChildNodes = await Promise.all( - this.stableChildNodeIds.map((stableChildNodeId) => - getResolvedNodeForEdit( - this.layer, - stableChildNodeId, - this.stableSegmentId, - ).then((result) => result.node), - ), - ); - const createResult: - | SpatiallyIndexedSkeletonAddNodeResult - | SpatiallyIndexedSkeletonInsertNodeResult = - currentChildNodes.length === 0 - ? await skeletonSource.addNode( - currentParentNode?.segmentId ?? 0, - Number(this.deletedSnapshot.position[0]), - Number(this.deletedSnapshot.position[1]), - Number(this.deletedSnapshot.position[2]), - currentParentNode?.nodeId, - currentParentNode === undefined - ? undefined - : buildSpatiallyIndexedSkeletonNodeEditContext(currentParentNode), - ) - : await skeletonSource.insertNode( - currentParentNode?.segmentId ?? this.deletedSnapshot.segmentId, - Number(this.deletedSnapshot.position[0]), - Number(this.deletedSnapshot.position[1]), - Number(this.deletedSnapshot.position[2]), - currentParentNode?.nodeId ?? - (() => { - throw new Error( - "Delete-node undo is missing the parent node needed for insertion.", - ); - })(), - currentChildNodes.map((child) => child.nodeId), - buildInsertEditContext(currentParentNode!, currentChildNodes), - ); - this.layer.spatialSkeletonState.commandHistory.mappings.remapNodeId( - this.stableDeletedNodeId, - createResult.treenodeId, - ); - if (this.stableSegmentId === undefined) { - this.stableSegmentId = createResult.skeletonId; - } else { - this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( - this.stableSegmentId, - createResult.skeletonId, - ); - } - const restoredNode: SpatiallyIndexedSkeletonNode = { - nodeId: createResult.treenodeId, - segmentId: createResult.skeletonId, - position: new Float32Array(this.deletedSnapshot.position), - parentNodeId: currentParentNode?.nodeId, - revisionToken: createResult.revisionToken, - radius: undefined, - confidence: undefined, - description: undefined, - isTrueEnd: false, - }; - this.layer.spatialSkeletonState.upsertCachedNode(restoredNode, { - allowUncachedSegment: currentParentNode === undefined, - }); - for (const childNode of currentChildNodes) { - this.layer.spatialSkeletonState.setCachedNodeParent( - childNode.nodeId, - restoredNode.nodeId, - ); - } - if (createResult.parentRevisionToken !== undefined && currentParentNode) { - this.layer.spatialSkeletonState.setCachedNodeRevision( - currentParentNode.nodeId, - createResult.parentRevisionToken, - ); - } - if (createResult.nodeRevisionUpdates?.length) { - this.layer.spatialSkeletonState.setCachedNodeRevisions( - createResult.nodeRevisionUpdates, - ); - } - const restoredNodeWithAttributes = await restoreNodeAttributes( - this.layer, - skeletonSource, - restoredNode, - this.deletedSnapshot, - ); - ensureVisibleSegment(this.layer, restoredNodeWithAttributes.segmentId); - this.layer.selectSpatialSkeletonNode( - restoredNodeWithAttributes.nodeId, - this.layer.manager.root.selectionState.pin.value, - { - segmentId: restoredNodeWithAttributes.segmentId, - position: restoredNodeWithAttributes.position, - }, - ); - this.layer.markSpatialSkeletonNodeDataChanged({ - invalidateFullSkeletonCache: false, - }); - StatusMessage.showTemporaryMessage( - `${statusPrefix} node ${restoredNodeWithAttributes.nodeId}.`, - ); - } - - execute() { - return this.deleteNode({ - moveView: true, - statusPrefix: "Deleted", - }); - } - - undo() { - return this.restoreDeletedNode("Restored"); - } - - redo() { - return this.deleteNode({ - moveView: false, - statusPrefix: "Redid deletion of", - }); - } -} - -class NodeDescriptionCommand implements SpatialSkeletonCommand { - readonly label = "Edit node description"; - - constructor( - private layer: SegmentationUserLayer, - private stableNodeId: number, - private stableSegmentId: number | undefined, - private beforeDescription: string | undefined, - private afterDescription: string | undefined, - ) {} - - private async applyDescription( - nextDescription: string | undefined, - statusPrefix: string, - ) { - const { node, skeletonSource } = await getResolvedNodeForEdit( - this.layer, - this.stableNodeId, - this.stableSegmentId, - ); - if (node.description === nextDescription) { - return; - } - const result = await skeletonSource.updateDescription( - node.nodeId, - nextDescription ?? "", - ); - this.layer.spatialSkeletonState.updateCachedNode( - node.nodeId, - (candidate) => { - if (candidate.description === result.description) { - return candidate; - } - return { - ...candidate, - description: result.description, - }; - }, - ); - if (result.revisionToken !== undefined) { - this.layer.spatialSkeletonState.setCachedNodeRevision( - node.nodeId, - result.revisionToken, - ); - } - this.layer.markSpatialSkeletonNodeDataChanged({ - invalidateFullSkeletonCache: false, - }); - StatusMessage.showTemporaryMessage( - `${statusPrefix} node ${node.nodeId} description.`, - ); - } - - execute() { - return this.applyDescription(this.afterDescription, "Updated"); - } - - undo() { - return this.applyDescription( - this.beforeDescription, - "Undid description update for", - ); - } - - redo() { - return this.applyDescription( - this.afterDescription, - "Redid description update for", - ); - } -} - -class NodeTrueEndCommand implements SpatialSkeletonCommand { - readonly label = "Edit node true end state"; - - constructor( - private layer: SegmentationUserLayer, - private stableNodeId: number, - private stableSegmentId: number | undefined, - private beforeIsTrueEnd: boolean, - private afterIsTrueEnd: boolean, - ) {} - - private async applyTrueEnd(nextIsTrueEnd: boolean, statusPrefix: string) { - const { node, skeletonSource } = await getResolvedNodeForEdit( - this.layer, - this.stableNodeId, - this.stableSegmentId, - ); - if (node.isTrueEnd === nextIsTrueEnd) { - return; - } - const result = nextIsTrueEnd - ? await skeletonSource.setTrueEnd(node.nodeId) - : await skeletonSource.removeTrueEnd(node.nodeId); - this.layer.spatialSkeletonState.updateCachedNode( - node.nodeId, - (candidate) => { - if (candidate.isTrueEnd === nextIsTrueEnd) { - return candidate; - } - return { - ...candidate, - isTrueEnd: nextIsTrueEnd, - }; - }, - ); - if (result.revisionToken !== undefined) { - this.layer.spatialSkeletonState.setCachedNodeRevision( - node.nodeId, - result.revisionToken, - ); - } - this.layer.markSpatialSkeletonNodeDataChanged({ - invalidateFullSkeletonCache: false, - }); - StatusMessage.showTemporaryMessage( - `${statusPrefix} node ${node.nodeId} true end state.`, - ); - } - - execute() { - return this.applyTrueEnd(this.afterIsTrueEnd, "Updated"); - } - - undo() { - return this.applyTrueEnd(this.beforeIsTrueEnd, "Undid true end update for"); - } - - redo() { - return this.applyTrueEnd(this.afterIsTrueEnd, "Redid true end update for"); - } -} - -class NodePropertiesCommand implements SpatialSkeletonCommand { - readonly label = "Edit node properties"; - - constructor( - private layer: SegmentationUserLayer, - private stableNodeId: number, - private stableSegmentId: number | undefined, - private before: { radius: number; confidence: number }, - private after: { radius: number; confidence: number }, - ) {} - - private async applyProperties( - next: { radius: number; confidence: number }, - statusPrefix: string, - ) { - const { node, skeletonSource } = await getResolvedNodeForEdit( - this.layer, - this.stableNodeId, - this.stableSegmentId, - ); - let currentNode = cloneNodeSnapshot(node); - if (currentNode.radius !== next.radius) { - const radiusResult = await skeletonSource.updateRadius( - node.nodeId, - next.radius, - buildSpatiallyIndexedSkeletonNodeEditContext(currentNode), - ); - currentNode = { - ...currentNode, - radius: next.radius, - revisionToken: radiusResult.revisionToken ?? currentNode.revisionToken, - }; - } - if (currentNode.confidence !== next.confidence) { - if (currentNode.revisionToken === undefined) { - throw new Error( - `Node ${node.nodeId} is missing revision metadata required to update confidence.`, - ); - } - const confidenceResult = await skeletonSource.updateConfidence( - node.nodeId, - next.confidence, - buildSpatiallyIndexedSkeletonNodeEditContext(currentNode), - ); - currentNode = { - ...currentNode, - confidence: next.confidence, - revisionToken: - confidenceResult.revisionToken ?? currentNode.revisionToken, - }; - } - this.layer.spatialSkeletonState.setNodeProperties(node.nodeId, next); - if (currentNode.revisionToken !== undefined) { - this.layer.spatialSkeletonState.setCachedNodeRevision( - node.nodeId, - currentNode.revisionToken, - ); - } - this.layer.markSpatialSkeletonNodeDataChanged({ - invalidateFullSkeletonCache: false, - }); - StatusMessage.showTemporaryMessage( - `${statusPrefix} node ${node.nodeId} properties.`, - ); - } - - execute() { - return this.applyProperties(this.after, "Updated"); - } - - undo() { - return this.applyProperties(this.before, "Undid property update for"); - } - - redo() { - return this.applyProperties(this.after, "Redid property update for"); - } -} - -class RerootCommand implements SpatialSkeletonCommand { - readonly label = "Reroot skeleton"; - - constructor( - private layer: SegmentationUserLayer, - private stableNodeId: number, - private stableSegmentId: number | undefined, - private stablePreviousRootNodeId: number, - ) {} - - private async rerootAt(stableTargetNodeId: number, statusPrefix: string) { - const resolvedNode = await getResolvedNodeForEdit( - this.layer, - stableTargetNodeId, - this.stableSegmentId, - ); - if (resolvedNode.node.parentNodeId === undefined) { - return; - } - if (resolvedNode.skeletonSource.rerootSkeleton === undefined) { - throw new Error( - "Unable to resolve a reroot-capable skeleton source for the active layer.", - ); - } - const result = await resolvedNode.skeletonSource.rerootSkeleton( - resolvedNode.node.nodeId, - buildSpatiallyIndexedSkeletonRerootEditContext( - resolvedNode.node, - resolvedNode.segmentNodes, - ), - ); - this.layer.spatialSkeletonState.rerootCachedSegment( - resolvedNode.node.nodeId, - ); - if ( - result.nodeRevisionUpdates !== undefined && - result.nodeRevisionUpdates.length > 0 - ) { - this.layer.spatialSkeletonState.setCachedNodeRevisions( - result.nodeRevisionUpdates, - ); - } - this.layer.selectSpatialSkeletonNode( - resolvedNode.node.nodeId, - this.layer.manager.root.selectionState.pin.value, - { - segmentId: resolvedNode.node.segmentId, - position: resolvedNode.node.position, - }, - ); - this.layer.markSpatialSkeletonNodeDataChanged({ - invalidateFullSkeletonCache: false, - }); - StatusMessage.showTemporaryMessage( - `${statusPrefix} node ${resolvedNode.node.nodeId} as root.`, - ); - } - - execute() { - return this.rerootAt(this.stableNodeId, "Set"); - } - - undo() { - return this.rerootAt(this.stablePreviousRootNodeId, "Undid reroot for"); - } - - redo() { - return this.rerootAt(this.stableNodeId, "Redid reroot for"); - } -} - -class SplitCommand implements SpatialSkeletonCommand { - readonly label = "Split skeleton"; - private stableNewSegmentId: number | undefined; - - constructor( - private layer: SegmentationUserLayer, - private stableNodeId: number, - private stableSegmentId: number | undefined, - private stableFormerParentNodeId: number | undefined, - ) {} - - private async split(statusPrefix: string) { - const resolvedNode = await getResolvedNodeForEdit( - this.layer, - this.stableNodeId, - this.stableSegmentId, - ); - let result: SpatiallyIndexedSkeletonSplitResult; - try { - result = await resolvedNode.skeletonSource.splitSkeleton( - resolvedNode.node.nodeId, - buildSpatiallyIndexedSkeletonNeighborhoodEditContext( - resolvedNode.node, - resolvedNode.segmentNodes, - ), - ); - } catch (error) { - await refreshTopologySegments(this.layer, [resolvedNode.node.segmentId]); - throw error; - } - const newSkeletonId = result.newSkeletonId; - const existingSkeletonId = - result.existingSkeletonId ?? resolvedNode.node.segmentId; - if (newSkeletonId === undefined) { - throw new Error( - "The active skeleton source did not return a new skeleton id for the split.", - ); - } - if (this.stableNewSegmentId === undefined) { - this.stableNewSegmentId = newSkeletonId; - } else { - this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( - this.stableNewSegmentId, - newSkeletonId, - ); - } - if (this.stableSegmentId !== undefined) { - this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( - this.stableSegmentId, - existingSkeletonId, - ); - } - ensureVisibleSegment(this.layer, existingSkeletonId); - ensureVisibleSegment(this.layer, newSkeletonId); - selectSegment(this.layer, newSkeletonId, true); - this.layer.selectSpatialSkeletonNode( - resolvedNode.node.nodeId, - this.layer.manager.root.selectionState.pin.value, - { - segmentId: newSkeletonId, - }, - ); - await refreshTopologySegments(this.layer, [ - existingSkeletonId, - newSkeletonId, - ]); - StatusMessage.showTemporaryMessage( - `${statusPrefix} skeleton ${existingSkeletonId}. New skeleton: ${newSkeletonId}.`, - ); - } - - private async mergeBack(statusPrefix: string) { - if (this.stableFormerParentNodeId === undefined) { - throw new Error("Split-node undo is missing the former parent node."); - } - const splitNode = await getResolvedNodeForEdit( - this.layer, - this.stableNodeId, - this.stableNewSegmentId ?? this.stableSegmentId, - ); - const formerParent = await getResolvedNodeForEdit( - this.layer, - this.stableFormerParentNodeId, - this.stableSegmentId, - ); - let result: SpatiallyIndexedSkeletonMergeResult; - try { - result = await formerParent.skeletonSource.mergeSkeletons( - formerParent.node.nodeId, - splitNode.node.nodeId, - buildSpatiallyIndexedSkeletonMultiNodeEditContext( - formerParent.node, - splitNode.node, - ), - ); - } catch (error) { - await refreshTopologySegments(this.layer, [ - splitNode.node.segmentId, - formerParent.node.segmentId, - ]); - throw error; - } - const resultSkeletonId = - result.resultSkeletonId ?? formerParent.node.segmentId; - const deletedSkeletonId = - result.deletedSkeletonId ?? - (resultSkeletonId === splitNode.node.segmentId - ? formerParent.node.segmentId - : splitNode.node.segmentId); - if (this.stableSegmentId !== undefined) { - this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( - this.stableSegmentId, - resultSkeletonId, - ); - } - if (this.stableNewSegmentId !== undefined) { - this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( - this.stableNewSegmentId, - resultSkeletonId, - ); - } - ensureVisibleSegment(this.layer, resultSkeletonId); - if (deletedSkeletonId !== resultSkeletonId) { - removeVisibleSegment(this.layer, deletedSkeletonId, { deselect: true }); - this.layer.displayState.segmentStatedColors.value.delete( - BigInt(deletedSkeletonId), - ); - splitNode.skeletonLayer.suppressBrowseSegment(deletedSkeletonId); - } - this.layer.selectSpatialSkeletonNode( - splitNode.node.nodeId, - this.layer.manager.root.selectionState.pin.value, - { - segmentId: resultSkeletonId, - }, - ); - await refreshTopologySegments(this.layer, [ - resultSkeletonId, - deletedSkeletonId, - ]); - StatusMessage.showTemporaryMessage( - `${statusPrefix} split at node ${splitNode.node.nodeId}.`, - ); - } - - execute() { - return this.split("Split"); - } - - undo() { - return this.mergeBack("Undid"); - } - - redo() { - return this.split("Redid split of"); - } -} - -class MergeCommand implements SpatialSkeletonCommand { - readonly label = "Merge skeletons"; - private stableResultSegmentId: number | undefined; - private stableDeletedSegmentId: number | undefined; - private stableAttachedNodeId: number | undefined; - private stableAttachedRootNodeId: number | undefined; - - constructor( - private layer: SegmentationUserLayer, - private stableFirstNodeId: number, - private stableFirstSegmentId: number | undefined, - private stableSecondNodeId: number, - private stableSecondSegmentId: number | undefined, - private secondNodeRevisionToken: string | undefined, - ) {} - - private async merge(statusPrefix: string) { - const firstNode = await getResolvedNodeForEdit( - this.layer, - this.stableFirstNodeId, - this.stableFirstSegmentId, - ); - const secondNodeContext = getResolvedNodeContextForEdit( - this.layer, - this.stableSecondNodeId, - this.stableSecondSegmentId, - ); - let secondNode: ResolvedSpatialSkeletonEditNode; - let preservedSecondRootNodeId: number | undefined; - const secondSegmentCached = - this.layer.spatialSkeletonState.getCachedSegmentNodes( - secondNodeContext.segmentId, - ) !== undefined; - const secondRevisionToken = - this.secondNodeRevisionToken ?? - secondNodeContext.cachedNode?.revisionToken; - if (secondSegmentCached || secondRevisionToken === undefined) { - secondNode = await getResolvedNodeForEdit( - this.layer, - this.stableSecondNodeId, - this.stableSecondSegmentId, - ); - } else { - preservedSecondRootNodeId = ( - await secondNodeContext.skeletonSource.getSkeletonRootNode( - secondNodeContext.segmentId, - ) - ).nodeId; - secondNode = { - skeletonLayer: secondNodeContext.skeletonLayer, - skeletonSource: secondNodeContext.skeletonSource, - segmentNodes: [], - node: { - nodeId: secondNodeContext.currentNodeId, - segmentId: secondNodeContext.segmentId, - position: new Float32Array(3), - parentNodeId: secondNodeContext.cachedNode?.parentNodeId, - isTrueEnd: secondNodeContext.cachedNode?.isTrueEnd ?? false, - revisionToken: secondRevisionToken, - }, - }; - } - let result: SpatiallyIndexedSkeletonMergeResult; - try { - result = await firstNode.skeletonSource.mergeSkeletons( - firstNode.node.nodeId, - secondNode.node.nodeId, - buildSpatiallyIndexedSkeletonMultiNodeEditContext( - firstNode.node, - secondNode.node, - ), - ); - } catch (error) { - await refreshTopologySegments(this.layer, [ - firstNode.node.segmentId, - secondNode.node.segmentId, - ]); - throw error; - } - const winningNode = - result.resultSkeletonId === secondNode.node.segmentId - ? secondNode.node - : firstNode.node; - const losingNode = - winningNode.nodeId === firstNode.node.nodeId - ? secondNode.node - : firstNode.node; - const resultSkeletonId = result.resultSkeletonId ?? winningNode.segmentId; - const deletedSkeletonId = result.deletedSkeletonId ?? losingNode.segmentId; - const attachedRootNodeId = - losingNode.segmentId === firstNode.node.segmentId - ? findRootNode(firstNode.segmentNodes)?.nodeId - : (preservedSecondRootNodeId ?? - findRootNode(secondNode.segmentNodes)?.nodeId); - this.stableAttachedNodeId = - this.stableAttachedNodeId ?? - this.layer.spatialSkeletonState.commandHistory.mappings.getStableOrCurrentNodeId( - losingNode.nodeId, - ); - this.stableAttachedRootNodeId = - this.stableAttachedRootNodeId ?? - this.layer.spatialSkeletonState.commandHistory.mappings.getStableOrCurrentNodeId( - attachedRootNodeId, - ); - this.stableResultSegmentId = - this.stableResultSegmentId ?? - this.layer.spatialSkeletonState.commandHistory.mappings.getStableOrCurrentSegmentId( - resultSkeletonId, - ); - this.stableDeletedSegmentId = - this.stableDeletedSegmentId ?? - this.layer.spatialSkeletonState.commandHistory.mappings.getStableOrCurrentSegmentId( - deletedSkeletonId, - ); - this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( - this.stableDeletedSegmentId, - resultSkeletonId, - ); - ensureVisibleSegment(this.layer, resultSkeletonId); - removeVisibleSegment(this.layer, deletedSkeletonId, { deselect: true }); - selectSegment(this.layer, resultSkeletonId, false); - this.layer.selectSpatialSkeletonNode( - losingNode.nodeId, - this.layer.manager.root.selectionState.pin.value, - { - segmentId: resultSkeletonId, - }, - ); - this.layer.displayState.segmentStatedColors.value.delete( - BigInt(deletedSkeletonId), - ); - if (deletedSkeletonId !== resultSkeletonId) { - firstNode.skeletonLayer.suppressBrowseSegment(deletedSkeletonId); - } - this.layer.clearSpatialSkeletonMergeAnchor(); - await refreshTopologySegments(this.layer, [ - resultSkeletonId, - deletedSkeletonId, - ]); - const swapSuffix = result.stableAnnotationSwap - ? " Merge direction was adjusted by the active source." - : ""; - StatusMessage.showTemporaryMessage( - `${statusPrefix} skeleton ${deletedSkeletonId} into ${resultSkeletonId}.${swapSuffix}`, - ); - } - - private async undoMerge(statusPrefix: string) { - if (this.stableAttachedNodeId === undefined) { - throw new Error("Merge undo is missing the attached node id."); - } - if (this.stableDeletedSegmentId === undefined) { - throw new Error("Merge undo is missing the deleted skeleton id."); - } - const attachedNode = await getResolvedNodeForEdit( - this.layer, - this.stableAttachedNodeId, - this.stableResultSegmentId ?? this.stableFirstSegmentId, - ); - let splitResult: SpatiallyIndexedSkeletonSplitResult; - try { - splitResult = await attachedNode.skeletonSource.splitSkeleton( - attachedNode.node.nodeId, - buildSpatiallyIndexedSkeletonNeighborhoodEditContext( - attachedNode.node, - attachedNode.segmentNodes, - ), - ); - } catch (error) { - await refreshTopologySegments(this.layer, [attachedNode.node.segmentId]); - throw error; - } - const restoredSegmentId = - splitResult.newSkeletonId ?? - (() => { - throw new Error( - "The active skeleton source did not return a new skeleton id for merge undo.", - ); - })(); - this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( - this.stableDeletedSegmentId, - restoredSegmentId, - ); - const survivingSegmentId = - splitResult.existingSkeletonId ?? attachedNode.node.segmentId; - ensureVisibleSegment(this.layer, survivingSegmentId); - ensureVisibleSegment(this.layer, restoredSegmentId); - await refreshTopologySegments(this.layer, [ - survivingSegmentId, - restoredSegmentId, - ]); - let rerootWarning: string | undefined; - if ( - this.stableAttachedRootNodeId !== undefined && - this.stableAttachedRootNodeId !== this.stableAttachedNodeId - ) { - try { - const restoredRoot = await getResolvedNodeForEdit( - this.layer, - this.stableAttachedRootNodeId, - this.stableDeletedSegmentId, - ); - if (restoredRoot.node.parentNodeId !== undefined) { - if (restoredRoot.skeletonSource.rerootSkeleton === undefined) { - throw new Error( - "The active skeleton source does not support reroot.", - ); - } - await restoredRoot.skeletonSource.rerootSkeleton( - restoredRoot.node.nodeId, - buildSpatiallyIndexedSkeletonRerootEditContext( - restoredRoot.node, - restoredRoot.segmentNodes, - ), - ); - await refreshTopologySegments(this.layer, [ - survivingSegmentId, - restoredSegmentId, - ]); - } - } catch (error) { - await refreshTopologySegments(this.layer, [ - survivingSegmentId, - restoredSegmentId, - ]); - rerootWarning = - `Undo split the merged skeletons, but failed to reroot the restored skeleton. ` + - `Only the split completed. ${formatErrorMessage(error)}`; - } - } - this.layer.selectSpatialSkeletonNode( - attachedNode.node.nodeId, - this.layer.manager.root.selectionState.pin.value, - { - segmentId: restoredSegmentId, - }, - ); - StatusMessage.showTemporaryMessage( - rerootWarning ?? - `${statusPrefix} merge involving node ${attachedNode.node.nodeId}.`, - ); - } - - execute() { - return this.merge("Merged"); - } - - undo() { - return this.undoMerge("Undid"); - } - - redo() { - return this.merge("Redid merge of"); - } + const status = StatusMessage.showMessage(message); + return promise.finally(() => status.dispose()); } export function executeSpatialSkeletonAddNode( layer: SegmentationUserLayer, - options: { - skeletonId: number; - parentNodeId: number | undefined; - // Callers must convert viewer/global coordinates to skeleton model space. - positionInModelSpace: Float32Array; - }, + options: SpatialSkeletonAddNodeCommandOptions, ) { - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const command = new AddNodeCommand( - layer, - commandMappings.getStableOrCurrentNodeId(options.parentNodeId), - commandMappings.getStableOrCurrentSegmentId(options.skeletonId) ?? - options.skeletonId, - new Float32Array(options.positionInModelSpace), + const command = requireCommand( + getController(layer).createAddNodeCommand?.(layer, options), + "The active skeleton source does not support node creation.", ); - return executeSpatialSkeletonCommandWithPendingMessage( - layer.spatialSkeletonState.commandHistory.execute(command), + return executeCommandWithPendingMessage( + executeCommand(layer, command), "Creating node...", ); } export function executeSpatialSkeletonMoveNode( layer: SegmentationUserLayer, - options: { - node: SpatiallyIndexedSkeletonNode; - // Callers must convert viewer/global coordinates to skeleton model space. - nextPositionInModelSpace: Float32Array; - }, + options: SpatialSkeletonMoveNodeCommandOptions, ) { - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const command = new MoveNodeCommand( - layer, - commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, - commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), - new Float32Array(options.node.position), - new Float32Array(options.nextPositionInModelSpace), + const command = requireCommand( + getController(layer).createMoveNodeCommand?.(layer, options), + "The active skeleton source does not support node movement.", ); - return layer.spatialSkeletonState.commandHistory.execute(command); + return executeCommand(layer, command); } export function executeSpatialSkeletonDeleteNode( layer: SegmentationUserLayer, node: SpatiallyIndexedSkeletonNode, ) { - const segmentNodes = layer.getCachedSpatialSkeletonSegmentNodesForEdit( - node.segmentId, - ); - const refreshedNode = findSpatiallyIndexedSkeletonNode( - segmentNodes, - node.nodeId, - ); - if (refreshedNode === undefined) { - throw new Error( - `Node ${node.nodeId} is not available in the inspected skeleton cache.`, - ); - } - const childNodes = getSpatiallyIndexedSkeletonDirectChildren( - segmentNodes, - refreshedNode.nodeId, + const command = requireCommand( + getController(layer).createDeleteNodeCommand?.(layer, node), + "The active skeleton source does not support node deletion.", ); - const command = new DeleteNodeCommand(layer, refreshedNode, childNodes); - return executeSpatialSkeletonCommandWithPendingMessage( - layer.spatialSkeletonState.commandHistory.execute(command), + return executeCommandWithPendingMessage( + executeCommand(layer, command), "Deleting node...", ); } export function executeSpatialSkeletonNodeDescriptionUpdate( layer: SegmentationUserLayer, - options: { - node: SpatiallyIndexedSkeletonNode; - nextDescription?: string; - }, + options: SpatialSkeletonNodeDescriptionCommandOptions, ) { - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const command = new NodeDescriptionCommand( - layer, - commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, - commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), - options.node.description, - options.nextDescription ?? options.node.description, + const command = requireCommand( + getController(layer).createNodeDescriptionCommand?.(layer, options), + "The active skeleton source does not support node description editing.", ); - return layer.spatialSkeletonState.commandHistory.execute(command); + return executeCommand(layer, command); } export function executeSpatialSkeletonNodeTrueEndUpdate( layer: SegmentationUserLayer, - options: { - node: SpatiallyIndexedSkeletonNode; - nextIsTrueEnd: boolean; - }, + options: SpatialSkeletonNodeTrueEndCommandOptions, ) { - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const command = new NodeTrueEndCommand( - layer, - commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, - commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), - options.node.isTrueEnd, - options.nextIsTrueEnd, + const command = requireCommand( + getController(layer).createNodeTrueEndCommand?.(layer, options), + "The active skeleton source does not support node true-end editing.", ); - return layer.spatialSkeletonState.commandHistory.execute(command); + return executeCommand(layer, command); } export function executeSpatialSkeletonNodePropertiesUpdate( layer: SegmentationUserLayer, - options: { - node: SpatiallyIndexedSkeletonNode; - next: { radius: number; confidence: number }; - }, + options: SpatialSkeletonNodePropertiesCommandOptions, ) { - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const command = new NodePropertiesCommand( - layer, - commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, - commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), - { - radius: options.node.radius ?? 0, - confidence: options.node.confidence ?? 0, - }, - options.next, + const command = requireCommand( + getController(layer).createNodePropertiesCommand?.(layer, options), + "The active skeleton source does not support node property editing.", ); - return layer.spatialSkeletonState.commandHistory.execute(command); + return executeCommand(layer, command); } export function executeSpatialSkeletonReroot( @@ -1685,82 +146,38 @@ export function executeSpatialSkeletonReroot( "nodeId" | "segmentId" | "parentNodeId" >, ) { - const segmentNodes = layer.getCachedSpatialSkeletonSegmentNodesForEdit( - node.segmentId, - ); - const rootNode = - findRootNode(segmentNodes) ?? - (() => { - throw new Error( - `Unable to resolve the current root for segment ${node.segmentId}.`, - ); - })(); - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const command = new RerootCommand( - layer, - commandMappings.getStableOrCurrentNodeId(node.nodeId)!, - commandMappings.getStableOrCurrentSegmentId(node.segmentId), - commandMappings.getStableOrCurrentNodeId(rootNode.nodeId)!, + const command = requireCommand( + getController(layer).createRerootCommand?.(layer, node), + "The active skeleton source does not support skeleton rerooting.", ); - return layer.spatialSkeletonState.commandHistory.execute(command); + return executeCommand(layer, command); } export function executeSpatialSkeletonSplit( layer: SegmentationUserLayer, node: Pick, ) { - const segmentNodes = layer.getCachedSpatialSkeletonSegmentNodesForEdit( - node.segmentId, - ); - const splitNode = findSpatiallyIndexedSkeletonNode(segmentNodes, node.nodeId); - if (splitNode === undefined) { - throw new Error( - `Node ${node.nodeId} is not available in the inspected skeleton cache.`, - ); - } - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const command = new SplitCommand( - layer, - commandMappings.getStableOrCurrentNodeId(splitNode.nodeId)!, - commandMappings.getStableOrCurrentSegmentId(splitNode.segmentId), - commandMappings.getStableOrCurrentNodeId(splitNode.parentNodeId), + const command = requireCommand( + getController(layer).createSplitCommand?.(layer, node), + "The active skeleton source does not support skeleton splitting.", ); - return executeSpatialSkeletonCommandWithPendingMessage( - layer.spatialSkeletonState.commandHistory.execute(command), + return executeCommandWithPendingMessage( + executeCommand(layer, command), "Splitting skeleton...", ); } -interface SpatialSkeletonMergeEndpoint { - nodeId: number; - segmentId: number; - revisionToken?: string; -} - -function executeSpatialSkeletonCommandWithPendingMessage( - promise: Promise, - message: string, -) { - const status = StatusMessage.showMessage(message); - return promise.finally(() => status.dispose()); -} - export function executeSpatialSkeletonMerge( layer: SegmentationUserLayer, firstNode: SpatialSkeletonMergeEndpoint, secondNode: SpatialSkeletonMergeEndpoint, ) { - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const command = new MergeCommand( - layer, - commandMappings.getStableOrCurrentNodeId(firstNode.nodeId)!, - commandMappings.getStableOrCurrentSegmentId(firstNode.segmentId), - commandMappings.getStableOrCurrentNodeId(secondNode.nodeId)!, - commandMappings.getStableOrCurrentSegmentId(secondNode.segmentId), - secondNode.revisionToken, + const command = requireCommand( + getController(layer).createMergeCommand?.(layer, firstNode, secondNode), + "The active skeleton source does not support skeleton merging.", ); - return executeSpatialSkeletonCommandWithPendingMessage( - layer.spatialSkeletonState.commandHistory.execute(command), + return executeCommandWithPendingMessage( + executeCommand(layer, command), "Merging skeletons...", ); } diff --git a/src/layer/segmentation/spatial_skeleton_errors.ts b/src/layer/segmentation/spatial_skeleton_errors.ts index c5e43e9dc..38d19736a 100644 --- a/src/layer/segmentation/spatial_skeleton_errors.ts +++ b/src/layer/segmentation/spatial_skeleton_errors.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { CatmaidStateValidationError } from "#src/datasource/catmaid/api.js"; +import { SpatialSkeletonEditConflictError } from "#src/skeleton/api.js"; import { StatusMessage } from "#src/status.js"; function formatError(error: unknown) { @@ -22,7 +22,7 @@ function formatError(error: unknown) { } export function isSpatialSkeletonOutdatedStateError(error: unknown) { - return error instanceof CatmaidStateValidationError; + return error instanceof SpatialSkeletonEditConflictError; } export function showSpatialSkeletonActionError(action: string, error: unknown) { diff --git a/src/rendered_data_panel.ts b/src/rendered_data_panel.ts index bab65e882..dfed10192 100644 --- a/src/rendered_data_panel.ts +++ b/src/rendered_data_panel.ts @@ -65,7 +65,7 @@ interface SpatialSkeletonSelectableLayer { options?: { segmentId?: number; position?: ArrayLike; - revisionToken?: string; + sourceState?: unknown; }, ) => void; clearSpatialSkeletonNodeSelection: ( @@ -539,7 +539,7 @@ export abstract class RenderedDataPanel extends RenderedPanel { ? pickedSegmentId : undefined, position: pickedSpatialSkeleton?.position ?? mouseState.position, - revisionToken: pickedSpatialSkeleton?.revisionToken, + sourceState: pickedSpatialSkeleton?.sourceState, }; }; @@ -570,7 +570,7 @@ export abstract class RenderedDataPanel extends RenderedPanel { { segmentId: pickedSelection.segmentId, position: pickedSelection.position, - revisionToken: pickedSelection.revisionToken, + sourceState: pickedSelection.sourceState, }, ); return; diff --git a/src/skeleton/api.ts b/src/skeleton/api.ts index 515ff9ed2..87c4c9f1d 100644 --- a/src/skeleton/api.ts +++ b/src/skeleton/api.ts @@ -14,12 +14,25 @@ * limitations under the License. */ +import type { SpatialSkeletonAction } from "#src/skeleton/actions.js"; +import type { SpatialSkeletonCommand } from "#src/skeleton/command_history.js"; + +export class SpatialSkeletonEditConflictError extends Error { + constructor(detail?: string) { + super( + detail ?? + "The skeleton edit could not be applied because the source state is out of date.", + ); + this.name = "SpatialSkeletonEditConflictError"; + } +} + export interface SpatiallyIndexedSkeletonNodeBase { nodeId: number; segmentId: number; position: Float32Array; parentNodeId?: number; - revisionToken?: string; + sourceState?: unknown; } export interface SpatiallyIndexedSkeletonNode @@ -27,7 +40,7 @@ export interface SpatiallyIndexedSkeletonNode radius?: number; confidence?: number; description?: string; - isTrueEnd: boolean; + isTrueEnd?: boolean; } export interface SpatiallyIndexedSkeletonOpenLeaf { @@ -46,33 +59,33 @@ export interface SpatiallyIndexedSkeletonNavigationTarget { z: number; } -export interface SpatiallyIndexedSkeletonNodeRevisionUpdate { +export interface SpatiallyIndexedSkeletonNodeSourceStateUpdate { nodeId: number; - revisionToken: string; + sourceState: unknown; } export interface SpatiallyIndexedSkeletonEditResult { - nodeRevisionUpdates?: readonly SpatiallyIndexedSkeletonNodeRevisionUpdate[]; + nodeSourceStateUpdates?: readonly SpatiallyIndexedSkeletonNodeSourceStateUpdate[]; } export interface SpatiallyIndexedSkeletonAddNodeResult extends SpatiallyIndexedSkeletonEditResult { - treenodeId: number; - skeletonId: number; - revisionToken?: string; - parentRevisionToken?: string; + nodeId: number; + segmentId: number; + sourceState?: unknown; + parentSourceState?: unknown; } export type SpatiallyIndexedSkeletonInsertNodeResult = SpatiallyIndexedSkeletonAddNodeResult; -export interface SpatiallyIndexedSkeletonNodeRevisionResult +export interface SpatiallyIndexedSkeletonNodeSourceStateResult extends SpatiallyIndexedSkeletonEditResult { - revisionToken?: string; + sourceState?: unknown; } export interface SpatiallyIndexedSkeletonDescriptionUpdateResult - extends SpatiallyIndexedSkeletonNodeRevisionResult { + extends SpatiallyIndexedSkeletonNodeSourceStateResult { description?: string; } @@ -82,35 +95,17 @@ export type SpatiallyIndexedSkeletonDeleteNodeResult = export type SpatiallyIndexedSkeletonRerootResult = SpatiallyIndexedSkeletonEditResult; -export interface SpatiallyIndexedSkeletonEditNodeContext { - nodeId: number; - parentNodeId?: number; - revisionToken: string; -} - -export interface SpatiallyIndexedSkeletonEditParentContext { - nodeId: number; - revisionToken: string; -} - -export interface SpatiallyIndexedSkeletonEditContext { - node?: SpatiallyIndexedSkeletonEditNodeContext; - parent?: SpatiallyIndexedSkeletonEditParentContext; - children?: readonly SpatiallyIndexedSkeletonEditParentContext[]; - nodes?: readonly SpatiallyIndexedSkeletonEditParentContext[]; -} - export interface SpatiallyIndexedSkeletonMergeResult extends SpatiallyIndexedSkeletonEditResult { - resultSkeletonId: number | undefined; - deletedSkeletonId: number | undefined; - stableAnnotationSwap: boolean; + resultSegmentId: number | undefined; + deletedSegmentId: number | undefined; + directionAdjusted: boolean; } export interface SpatiallyIndexedSkeletonSplitResult extends SpatiallyIndexedSkeletonEditResult { - existingSkeletonId: number | undefined; - newSkeletonId: number | undefined; + existingSegmentId: number | undefined; + newSegmentId: number | undefined; } export interface SpatiallyIndexedSkeletonMetadata { @@ -122,11 +117,96 @@ export interface SpatiallyIndexedSkeletonMetadata { gridCellSizes: Array<{ x: number; y: number; z: number }>; } -export const SPATIALLY_INDEXED_SKELETON_CONFIDENCE_VALUES = [ - 0, 25, 50, 75, 100, -] as const; +export interface SpatialSkeletonNodeFeatureCapabilities { + description?: boolean; + trueEnd?: boolean; + radius?: boolean; + confidenceValues?: readonly number[]; +} + +export interface SpatialSkeletonEditCapabilities { + nodeFeatures?: SpatialSkeletonNodeFeatureCapabilities; +} + +export interface SpatialSkeletonAddNodeCommandOptions { + skeletonId: number; + parentNodeId: number | undefined; + positionInModelSpace: Float32Array; +} + +export interface SpatialSkeletonMoveNodeCommandOptions { + node: SpatiallyIndexedSkeletonNode; + nextPositionInModelSpace: Float32Array; +} + +export interface SpatialSkeletonNodeDescriptionCommandOptions { + node: SpatiallyIndexedSkeletonNode; + nextDescription?: string; +} + +export interface SpatialSkeletonNodeTrueEndCommandOptions { + node: SpatiallyIndexedSkeletonNode; + nextIsTrueEnd: boolean; +} + +export interface SpatialSkeletonNodePropertiesCommandOptions { + node: SpatiallyIndexedSkeletonNode; + next: { radius: number; confidence: number }; +} + +export interface SpatialSkeletonMergeEndpoint { + nodeId: number; + segmentId: number; + sourceState?: unknown; +} + +export interface SpatialSkeletonEditController { + readonly capabilities?: SpatialSkeletonEditCapabilities; + supports(action: SpatialSkeletonAction): boolean; + createAddNodeCommand?( + layer: any, + options: SpatialSkeletonAddNodeCommandOptions, + ): SpatialSkeletonCommand; + createMoveNodeCommand?( + layer: any, + options: SpatialSkeletonMoveNodeCommandOptions, + ): SpatialSkeletonCommand; + createDeleteNodeCommand?( + layer: any, + node: SpatiallyIndexedSkeletonNode, + ): SpatialSkeletonCommand; + createNodeDescriptionCommand?( + layer: any, + options: SpatialSkeletonNodeDescriptionCommandOptions, + ): SpatialSkeletonCommand; + createNodeTrueEndCommand?( + layer: any, + options: SpatialSkeletonNodeTrueEndCommandOptions, + ): SpatialSkeletonCommand; + createNodePropertiesCommand?( + layer: any, + options: SpatialSkeletonNodePropertiesCommandOptions, + ): SpatialSkeletonCommand; + createRerootCommand?( + layer: any, + node: Pick< + SpatiallyIndexedSkeletonNode, + "nodeId" | "segmentId" | "parentNodeId" + >, + ): SpatialSkeletonCommand; + createSplitCommand?( + layer: any, + node: Pick, + ): SpatialSkeletonCommand; + createMergeCommand?( + layer: any, + firstNode: SpatialSkeletonMergeEndpoint, + secondNode: SpatialSkeletonMergeEndpoint, + ): SpatialSkeletonCommand; +} export interface SpatiallyIndexedSkeletonSource { + readonly spatialSkeletonEditController?: SpatialSkeletonEditController; listSkeletons(): Promise; getSkeleton( skeletonId: number, @@ -140,7 +220,6 @@ export interface SpatiallyIndexedSkeletonSource { }, lod?: number, options?: { - cacheProvider?: string; signal?: AbortSignal; }, ): Promise; @@ -157,7 +236,7 @@ export interface EditableSpatiallyIndexedSkeletonSource y: number, z: number, parentId?: number, - editContext?: SpatiallyIndexedSkeletonEditContext, + editContext?: unknown, ): Promise; insertNode( skeletonId: number, @@ -166,25 +245,25 @@ export interface EditableSpatiallyIndexedSkeletonSource z: number, parentId: number, childNodeIds: readonly number[], - editContext?: SpatiallyIndexedSkeletonEditContext, + editContext?: unknown, ): Promise; moveNode( nodeId: number, x: number, y: number, z: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise; + editContext?: unknown, + ): Promise; deleteNode( nodeId: number, options: { childNodeIds?: readonly number[]; - editContext?: SpatiallyIndexedSkeletonEditContext; + editContext?: unknown; }, ): Promise; rerootSkeleton?( nodeId: number, - editContext?: SpatiallyIndexedSkeletonEditContext, + editContext?: unknown, ): Promise; updateDescription( nodeId: number, @@ -192,27 +271,27 @@ export interface EditableSpatiallyIndexedSkeletonSource ): Promise; setTrueEnd( nodeId: number, - ): Promise; + ): Promise; removeTrueEnd( nodeId: number, - ): Promise; + ): Promise; updateRadius( nodeId: number, radius: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise; + editContext?: unknown, + ): Promise; updateConfidence( nodeId: number, confidence: number, - editContext?: SpatiallyIndexedSkeletonEditContext, - ): Promise; + editContext?: unknown, + ): Promise; mergeSkeletons( fromNodeId: number, toNodeId: number, - editContext?: SpatiallyIndexedSkeletonEditContext, + editContext?: unknown, ): Promise; splitSkeleton( nodeId: number, - editContext?: SpatiallyIndexedSkeletonEditContext, + editContext?: unknown, ): Promise; } diff --git a/src/skeleton/backend.ts b/src/skeleton/backend.ts index a5deb22e6..56242212d 100644 --- a/src/skeleton/backend.ts +++ b/src/skeleton/backend.ts @@ -303,7 +303,7 @@ export class SpatiallyIndexedSkeletonChunk requestGeneration = -1; requestOwners = SpatiallyIndexedSkeletonChunkRequestOwner.NONE; nodeIds: Int32Array | undefined; - nodeRevisionTokens: Array | undefined; + nodeSourceStates: unknown[] | undefined; freeSystemMemory() { freeSkeletonChunkSystemMemory(this); diff --git a/src/skeleton/edit_state.ts b/src/skeleton/edit_state.ts index 653ae0632..ac50f433d 100644 --- a/src/skeleton/edit_state.ts +++ b/src/skeleton/edit_state.ts @@ -1,21 +1,4 @@ -import type { - SpatiallyIndexedSkeletonEditContext, - SpatiallyIndexedSkeletonEditNodeContext, - SpatiallyIndexedSkeletonEditParentContext, - SpatiallyIndexedSkeletonNode, -} from "#src/skeleton/api.js"; - -function requireRevisionToken( - node: SpatiallyIndexedSkeletonNode, - role: string, -): string { - if (node.revisionToken === undefined) { - throw new Error( - `Inspected spatial skeleton ${role} node ${node.nodeId} is missing revision metadata.`, - ); - } - return node.revisionToken; -} +import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; export function findSpatiallyIndexedSkeletonNode( segmentNodes: readonly SpatiallyIndexedSkeletonNode[], @@ -43,55 +26,6 @@ export function getSpatiallyIndexedSkeletonNodeParent( return findSpatiallyIndexedSkeletonNode(segmentNodes, node.parentNodeId); } -export function toSpatiallyIndexedSkeletonEditNodeContext( - node: SpatiallyIndexedSkeletonNode, -): SpatiallyIndexedSkeletonEditNodeContext { - return { - nodeId: node.nodeId, - parentNodeId: node.parentNodeId, - revisionToken: requireRevisionToken(node, "target"), - }; -} - -export function toSpatiallyIndexedSkeletonEditParentContext( - node: SpatiallyIndexedSkeletonNode, -): SpatiallyIndexedSkeletonEditParentContext { - return { - nodeId: node.nodeId, - revisionToken: requireRevisionToken(node, "related"), - }; -} - -export function buildSpatiallyIndexedSkeletonNodeEditContext( - node: SpatiallyIndexedSkeletonNode, -): SpatiallyIndexedSkeletonEditContext { - return { - node: toSpatiallyIndexedSkeletonEditNodeContext(node), - }; -} - -export function buildSpatiallyIndexedSkeletonNeighborhoodEditContext( - node: SpatiallyIndexedSkeletonNode, - segmentNodes: readonly SpatiallyIndexedSkeletonNode[], -): SpatiallyIndexedSkeletonEditContext { - // This intentionally derives parent/child state from the cached inspected - // segment on demand. If large inspected segments make edit preparation - // measurably slow, consider maintaining an adjacency index in - // SpatialSkeletonState instead of rescanning here. - const parentNode = getSpatiallyIndexedSkeletonNodeParent(segmentNodes, node); - const childNodes = getSpatiallyIndexedSkeletonDirectChildren( - segmentNodes, - node.nodeId, - ); - return { - node: toSpatiallyIndexedSkeletonEditNodeContext(node), - ...(parentNode === undefined - ? {} - : { parent: toSpatiallyIndexedSkeletonEditParentContext(parentNode) }), - children: childNodes.map(toSpatiallyIndexedSkeletonEditParentContext), - }; -} - export function getSpatiallyIndexedSkeletonPathToRoot( segmentNodes: readonly SpatiallyIndexedSkeletonNode[], node: SpatiallyIndexedSkeletonNode, @@ -112,23 +46,3 @@ export function getSpatiallyIndexedSkeletonPathToRoot( currentNode = parentNode; } } - -export function buildSpatiallyIndexedSkeletonRerootEditContext( - node: SpatiallyIndexedSkeletonNode, - segmentNodes: readonly SpatiallyIndexedSkeletonNode[], -): SpatiallyIndexedSkeletonEditContext { - return { - ...buildSpatiallyIndexedSkeletonNeighborhoodEditContext(node, segmentNodes), - nodes: getSpatiallyIndexedSkeletonPathToRoot(segmentNodes, node).map( - toSpatiallyIndexedSkeletonEditParentContext, - ), - }; -} - -export function buildSpatiallyIndexedSkeletonMultiNodeEditContext( - ...nodes: SpatiallyIndexedSkeletonNode[] -): SpatiallyIndexedSkeletonEditContext { - return { - nodes: nodes.map(toSpatiallyIndexedSkeletonEditParentContext), - }; -} diff --git a/src/skeleton/frontend.spec.ts b/src/skeleton/frontend.spec.ts index 0f5e38770..2687d7dee 100644 --- a/src/skeleton/frontend.spec.ts +++ b/src/skeleton/frontend.spec.ts @@ -70,7 +70,7 @@ describe("resolveSpatiallyIndexedSkeletonSegmentPick", () => { }); describe("SpatiallyIndexedSkeletonLayer browse node picks", () => { - it("resolves browse node picks with node id and revision token", () => { + it("resolves browse node picks with node id and source state", () => { const positions = new Float32Array([1, 2, 3, 4, 5, 6]); const segmentIds = new Uint32Array([11, 17]); const vertexBytes = new Uint8Array( @@ -84,7 +84,10 @@ describe("SpatiallyIndexedSkeletonLayer browse node picks", () => { numVertices: 2, indices: new Uint32Array([0, 1]), nodeIds: new Int32Array([101, 202]), - nodeRevisionTokens: ["2026-03-29T11:50:00Z", "2026-03-29T11:51:00Z"], + nodeSourceStates: [ + { revisionToken: "2026-03-29T11:50:00Z" }, + { revisionToken: "2026-03-29T11:51:00Z" }, + ], }; const layer = Object.create(SpatiallyIndexedSkeletonLayer.prototype); @@ -92,7 +95,7 @@ describe("SpatiallyIndexedSkeletonLayer browse node picks", () => { nodeId: 202, segmentId: 17, position: new Float32Array([4, 5, 6]), - revisionToken: "2026-03-29T11:51:00Z", + sourceState: { revisionToken: "2026-03-29T11:51:00Z" }, }); }); }); diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index 93f64dd69..4b9f67dec 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -246,7 +246,7 @@ interface SkeletonChunkData { numVertices: number; vertexAttributeOffsets: Uint32Array; nodeIds?: Int32Array; - nodeRevisionTokens?: Array; + nodeSourceStates?: unknown[]; } type SpatiallyIndexedSkeletonPickData = @@ -1539,7 +1539,7 @@ export class SpatiallyIndexedSkeletonChunk vertexAttributeOffsets: Uint32Array; vertexAttributeTextures: (WebGLTexture | null)[] = []; nodeIds: Int32Array = new Int32Array(0); - nodeRevisionTokens: Array = []; + nodeSourceStates: unknown[] = []; lod: number | undefined; constructor( @@ -1565,11 +1565,9 @@ export class SpatiallyIndexedSkeletonChunk } else { this.nodeIds = new Int32Array(0); } - const nodeRevisionTokens = (chunkData as any).nodeRevisionTokens; - this.nodeRevisionTokens = Array.isArray(nodeRevisionTokens) - ? nodeRevisionTokens.map((value) => - typeof value === "string" ? value : undefined, - ) + const nodeSourceStates = (chunkData as any).nodeSourceStates; + this.nodeSourceStates = Array.isArray(nodeSourceStates) + ? nodeSourceStates : []; } @@ -2908,7 +2906,7 @@ export class SpatiallyIndexedSkeletonLayer nodeId, segmentId, position: data.positions.subarray(baseOffset, baseOffset + 3), - revisionToken: chunk.nodeRevisionTokens[pickedOffset], + sourceState: chunk.nodeSourceStates[pickedOffset], }; } @@ -3617,7 +3615,7 @@ export class PerspectiveViewSpatiallyIndexedSkeletonLayer extends PerspectiveVie nodeId: pickedNode.nodeId, segmentId: pickedNode.segmentId, position: new Float32Array(pickedNode.position), - revisionToken: pickedNode.revisionToken, + sourceState: pickedNode.sourceState, }; } return; @@ -3945,7 +3943,7 @@ export class SliceViewPanelSpatiallyIndexedSkeletonLayer extends SliceViewPanelR nodeId: pickedNode.nodeId, segmentId: pickedNode.segmentId, position: new Float32Array(pickedNode.position), - revisionToken: pickedNode.revisionToken, + sourceState: pickedNode.sourceState, }; } return; diff --git a/src/skeleton/navigation.ts b/src/skeleton/navigation.ts index 18f2ddbea..bd8ba5105 100644 --- a/src/skeleton/navigation.ts +++ b/src/skeleton/navigation.ts @@ -63,15 +63,16 @@ function buildNavigationGraphDerivedState( const parentNodeId = node.parentNodeId; const parentInTree = parentNodeId !== undefined && graph.nodeById.has(parentNodeId); - const sortPriority = node.isTrueEnd - ? 0 - : childCount === 0 + const sortPriority = + (node.isTrueEnd ?? false) ? 0 - : !parentInTree - ? 3 - : childCount > 1 - ? 1 - : 2; + : childCount === 0 + ? 0 + : !parentInTree + ? 3 + : childCount > 1 + ? 1 + : 2; sortPriorityByNodeId.set(nodeId, sortPriority); } return { diff --git a/src/skeleton/skeleton_chunk_serialization.ts b/src/skeleton/skeleton_chunk_serialization.ts index ca0222534..ac29bf393 100644 --- a/src/skeleton/skeleton_chunk_serialization.ts +++ b/src/skeleton/skeleton_chunk_serialization.ts @@ -22,7 +22,7 @@ export interface SkeletonChunkData { indices: Uint32Array | null; lod?: number; nodeIds?: Int32Array; - nodeRevisionTokens?: Array; + nodeSourceStates?: unknown[]; } /** @@ -97,8 +97,8 @@ export function serializeSkeletonChunkData( msg.nodeIds = data.nodeIds; transfers.push(data.nodeIds.buffer); } - if (data.nodeRevisionTokens) { - msg.nodeRevisionTokens = data.nodeRevisionTokens; + if (data.nodeSourceStates) { + msg.nodeSourceStates = data.nodeSourceStates; } } @@ -108,5 +108,5 @@ export function serializeSkeletonChunkData( export function freeSkeletonChunkSystemMemory(data: SkeletonChunkData): void { data.vertexPositions = data.indices = data.vertexAttributes = null; data.nodeIds = undefined; - data.nodeRevisionTokens = undefined; + data.nodeSourceStates = undefined; } diff --git a/src/skeleton/spatial_skeleton_manager.spec.ts b/src/skeleton/spatial_skeleton_manager.spec.ts index 21acec77c..150a0dc83 100644 --- a/src/skeleton/spatial_skeleton_manager.spec.ts +++ b/src/skeleton/spatial_skeleton_manager.spec.ts @@ -29,13 +29,16 @@ import { describe("skeleton/spatial_skeleton_manager", () => { it("does not require reroot support for editable sources", () => { const editableSource = { + spatialSkeletonEditController: { + supports: () => true, + }, listSkeletons: async () => [], getSkeleton: async () => [], fetchNodes: async () => [], getSpatialIndexMetadata: async () => null, getSkeletonRootNode: async () => ({ nodeId: 1, x: 1, y: 2, z: 3 }), - addNode: async () => ({ treenodeId: 1, skeletonId: 1 }), - insertNode: async () => ({ treenodeId: 1, skeletonId: 1 }), + addNode: async () => ({ nodeId: 1, segmentId: 1 }), + insertNode: async () => ({ nodeId: 1, segmentId: 1 }), moveNode: async () => {}, deleteNode: async () => {}, updateDescription: async () => {}, @@ -44,13 +47,13 @@ describe("skeleton/spatial_skeleton_manager", () => { updateRadius: async () => {}, updateConfidence: async () => {}, mergeSkeletons: async () => ({ - resultSkeletonId: 1, - deletedSkeletonId: 2, - stableAnnotationSwap: false, + resultSegmentId: 1, + deletedSegmentId: 2, + directionAdjusted: false, }), splitSkeleton: async () => ({ - existingSkeletonId: 1, - newSkeletonId: 2, + existingSegmentId: 1, + newSegmentId: 2, }), }; @@ -451,7 +454,7 @@ describe("skeleton/spatial_skeleton_manager", () => { }); }); - it("caches inspected revision metadata from full skeleton inspection", async () => { + it("caches inspected source state from full skeleton inspection", async () => { const state = new SpatialSkeletonState(); const getSkeleton = vi.fn(async () => [ { @@ -460,7 +463,7 @@ describe("skeleton/spatial_skeleton_manager", () => { position: new Float32Array([1, 2, 3]), segmentId: 11, isTrueEnd: false, - revisionToken: "2026-03-29T12:30:00Z", + sourceState: { revisionToken: "2026-03-29T12:30:00Z" }, }, ]); @@ -484,7 +487,7 @@ describe("skeleton/spatial_skeleton_manager", () => { parentNodeId: undefined, description: undefined, isTrueEnd: false, - revisionToken: "2026-03-29T12:30:00Z", + sourceState: { revisionToken: "2026-03-29T12:30:00Z" }, }, ]); @@ -496,7 +499,7 @@ describe("skeleton/spatial_skeleton_manager", () => { parentNodeId: undefined, description: undefined, isTrueEnd: false, - revisionToken: "2026-03-29T12:30:00Z", + sourceState: { revisionToken: "2026-03-29T12:30:00Z" }, }); }); diff --git a/src/skeleton/spatial_skeleton_manager.ts b/src/skeleton/spatial_skeleton_manager.ts index e334f4fd1..3805a0334 100644 --- a/src/skeleton/spatial_skeleton_manager.ts +++ b/src/skeleton/spatial_skeleton_manager.ts @@ -17,7 +17,8 @@ import type { EditableSpatiallyIndexedSkeletonSource, SpatiallyIndexedSkeletonNode, - SpatiallyIndexedSkeletonNodeRevisionUpdate, + SpatiallyIndexedSkeletonNodeSourceStateUpdate, + SpatialSkeletonEditController, SpatiallyIndexedSkeletonSource, } from "#src/skeleton/api.js"; import { SpatialSkeletonCommandHistory } from "#src/skeleton/command_history.js"; @@ -56,18 +57,9 @@ export function isEditableSpatiallyIndexedSkeletonSource( ): value is EditableSpatiallyIndexedSkeletonSource { return ( isSpatiallyIndexedSkeletonSource(value) && - hasFunction(value, "addNode") && - hasFunction(value, "insertNode") && - hasFunction(value, "moveNode") && - hasFunction(value, "deleteNode") && - hasFunction(value, "updateDescription") && - hasFunction(value, "setTrueEnd") && - hasFunction(value, "removeTrueEnd") && - hasFunction(value, "updateRadius") && - hasFunction(value, "updateConfidence") && - hasFunction(value, "getSkeletonRootNode") && - hasFunction(value, "mergeSkeletons") && - hasFunction(value, "splitSkeleton") + typeof value.spatialSkeletonEditController === "object" && + value.spatialSkeletonEditController !== null && + hasFunction(value.spatialSkeletonEditController, "supports") ); } @@ -89,6 +81,13 @@ export function getEditableSpatiallyIndexedSkeletonSource( : undefined; } +export function getSpatialSkeletonEditController( + value: SpatialSkeletonSourceAccess | undefined, +): SpatialSkeletonEditController | undefined { + const source = getSpatiallyIndexedSkeletonSource(value); + return source?.spatialSkeletonEditController; +} + export function normalizeSpatiallyIndexedSkeletonNode( node: SpatiallyIndexedSkeletonNode, fallbackSegmentId: number, @@ -124,7 +123,7 @@ export function normalizeSpatiallyIndexedSkeletonNode( typeof node.description === "string" && node.description.length > 0 ? node.description : undefined, - isTrueEnd: node.isTrueEnd, + isTrueEnd: node.isTrueEnd ?? false, ...((node.radius !== undefined && Number.isFinite(Number(node.radius))) || (node.confidence !== undefined && Number.isFinite(Number(node.confidence))) ? { @@ -137,9 +136,9 @@ export function normalizeSpatiallyIndexedSkeletonNode( : {}), } : {}), - ...(node.revisionToken === undefined + ...(node.sourceState === undefined ? {} - : { revisionToken: node.revisionToken }), + : { sourceState: node.sourceState }), }; } @@ -376,28 +375,28 @@ export class SpatialSkeletonState extends RefCounted { return true; } - setCachedNodeRevision(nodeId: number, revisionToken: string | undefined) { - if (revisionToken === undefined) { + setCachedNodeSourceState(nodeId: number, sourceState: unknown) { + if (sourceState === undefined) { return false; } return this.updateCachedNode(nodeId, (node) => { - if (node.revisionToken === revisionToken) { + if (node.sourceState === sourceState) { return node; } return { ...node, - revisionToken, + sourceState, }; }); } - setCachedNodeRevisions( - revisionUpdates: readonly SpatiallyIndexedSkeletonNodeRevisionUpdate[], + setCachedNodeSourceStates( + sourceStateUpdates: readonly SpatiallyIndexedSkeletonNodeSourceStateUpdate[], ) { let changed = false; - for (const update of revisionUpdates) { + for (const update of sourceStateUpdates) { changed = - this.setCachedNodeRevision(update.nodeId, update.revisionToken) || + this.setCachedNodeSourceState(update.nodeId, update.sourceState) || changed; } return changed; diff --git a/src/ui/spatial_skeleton_edit_tab.ts b/src/ui/spatial_skeleton_edit_tab.ts index dbdfa8f14..a8874ec3a 100644 --- a/src/ui/spatial_skeleton_edit_tab.ts +++ b/src/ui/spatial_skeleton_edit_tab.ts @@ -1193,7 +1193,7 @@ export class SpatialSkeletonEditTab extends Tab { row.setAttribute("aria-disabled", "true"); } - const nodeIsTrueEnd = node.isTrueEnd; + const nodeIsTrueEnd = node.isTrueEnd ?? false; const iconFilterType = getSpatialSkeletonNodeIconFilterType({ nodeIsTrueEnd, nodeType: type, @@ -1458,7 +1458,9 @@ export class SpatialSkeletonEditTab extends Tab { ? undefined : skeletonState.getCachedSegmentNodes(selectedSegmentId); activeSegmentId = - cachedSelectedSegmentNodes === undefined ? undefined : selectedSegmentId; + cachedSelectedSegmentNodes === undefined + ? undefined + : selectedSegmentId; loadedNodeSummarySuffix = ""; if ( skeletonLayer === undefined || diff --git a/src/ui/spatial_skeleton_edit_tab_render_state.ts b/src/ui/spatial_skeleton_edit_tab_render_state.ts index 35084fa74..273463ff6 100644 --- a/src/ui/spatial_skeleton_edit_tab_render_state.ts +++ b/src/ui/spatial_skeleton_edit_tab_render_state.ts @@ -99,7 +99,7 @@ export function buildSpatialSkeletonSegmentRenderState( matchesSpatialSkeletonNodeFilter(options.nodeFilterType, { isLeaf: children.length === 0, nodeHasDescription: hasNonEmptyNodeDescription(description), - nodeIsTrueEnd: node.isTrueEnd, + nodeIsTrueEnd: node.isTrueEnd ?? false, nodeType, })) && nodeMatchesFilter(node, options.filterText, description); @@ -141,7 +141,7 @@ export function buildSpatialSkeletonSegmentRenderState( const parentInTree = node.parentNodeId !== undefined && nodeById.has(node.parentNodeId); const type = classifyNodeType(node, children.length, parentInTree); - if (type === "regular" && !node.isTrueEnd) { + if (type === "regular" && !(node.isTrueEnd ?? false)) { continue; } rows.push({ node, type, isLeaf: children.length === 0 }); diff --git a/src/ui/spatial_skeleton_edit_tool.spec.ts b/src/ui/spatial_skeleton_edit_tool.spec.ts index 1517190a1..17d7cb1b0 100644 --- a/src/ui/spatial_skeleton_edit_tool.spec.ts +++ b/src/ui/spatial_skeleton_edit_tool.spec.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { makeCatmaidNodeSourceState } from "#src/datasource/catmaid/api.js"; +import { CatmaidSpatialSkeletonEditController } from "#src/datasource/catmaid/spatial_skeleton_commands.js"; import { executeSpatialSkeletonAddNode, executeSpatialSkeletonMerge, @@ -42,6 +44,7 @@ function makeVisibleSegmentsState(initialVisibleSegments: bigint[] = []) { function makeEditableSkeletonSource(overrides: Record = {}) { return { + spatialSkeletonEditController: new CatmaidSpatialSkeletonEditController(), listSkeletons: vi.fn(), getSkeleton: vi.fn(), fetchNodes: vi.fn(), @@ -63,6 +66,10 @@ function makeEditableSkeletonSource(overrides: Record = {}) { }; } +function testSourceState(revisionToken: string) { + return makeCatmaidNodeSourceState(revisionToken); +} + function suppressStatusMessages() { const fakeStatusMessage = { dispose() {}, @@ -103,7 +110,7 @@ describe("spatial_skeleton_edit_tool", () => { it("keeps parented add-node commits overlay-first without refetching chunks", async () => { suppressStatusMessages(); const upsertCachedNode = vi.fn(); - const setCachedNodeRevision = vi.fn(); + const setCachedNodeSourceState = vi.fn(); const selectSegment = vi.fn(); const selectSpatialSkeletonNode = vi.fn(); const markSpatialSkeletonNodeDataChanged = vi.fn(); @@ -114,13 +121,13 @@ describe("spatial_skeleton_edit_tool", () => { segmentId: 11, position: new Float32Array([8, 9, 10]), isTrueEnd: false, - revisionToken: "parent-before", + sourceState: testSourceState("parent-before"), }; const addNode = vi.fn().mockResolvedValue({ - treenodeId: 17, - skeletonId: 11, - revisionToken: "node-after", - parentRevisionToken: "parent-after", + nodeId: 17, + segmentId: 11, + sourceState: testSourceState("node-after"), + parentSourceState: testSourceState("parent-after"), }); const skeletonLayer = { source: makeEditableSkeletonSource({ addNode }), @@ -148,7 +155,7 @@ describe("spatial_skeleton_edit_tool", () => { ), getFullSegmentNodes, upsertCachedNode, - setCachedNodeRevision, + setCachedNodeSourceState, }, getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, selectSegment, @@ -187,11 +194,14 @@ describe("spatial_skeleton_edit_tool", () => { position: new Float32Array([1, 2, 3]), parentNodeId: 5, isTrueEnd: false, - revisionToken: "node-after", + sourceState: testSourceState("node-after"), }, { allowUncachedSegment: false }, ); - expect(setCachedNodeRevision).toHaveBeenCalledWith(5, "parent-after"); + expect(setCachedNodeSourceState).toHaveBeenCalledWith( + 5, + testSourceState("parent-after"), + ); expect(visibleSegmentsState.visibleSegments.has(11n)).toBe(true); expect(selectSegment).toHaveBeenCalledWith(11n, true); expect(selectSpatialSkeletonNode).toHaveBeenCalledWith(17, true, { @@ -212,16 +222,16 @@ describe("spatial_skeleton_edit_tool", () => { it("seeds root add-node commits locally without overlay retention or refetching chunks", async () => { suppressStatusMessages(); const upsertCachedNode = vi.fn(); - const setCachedNodeRevision = vi.fn(); + const setCachedNodeSourceState = vi.fn(); const selectSegment = vi.fn(); const selectSpatialSkeletonNode = vi.fn(); const markSpatialSkeletonNodeDataChanged = vi.fn(); const moveViewToSpatialSkeletonNodePosition = vi.fn(); const getFullSegmentNodes = vi.fn(); const addNode = vi.fn().mockResolvedValue({ - treenodeId: 29, - skeletonId: 13, - revisionToken: "root-after", + nodeId: 29, + segmentId: 13, + sourceState: testSourceState("root-after"), }); const skeletonLayer = { source: makeEditableSkeletonSource({ addNode }), @@ -243,7 +253,7 @@ describe("spatial_skeleton_edit_tool", () => { getCachedSegmentNodes: vi.fn(), getFullSegmentNodes, upsertCachedNode, - setCachedNodeRevision, + setCachedNodeSourceState, }, getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, selectSegment, @@ -276,11 +286,11 @@ describe("spatial_skeleton_edit_tool", () => { position: new Float32Array([4, 5, 6]), parentNodeId: undefined, isTrueEnd: false, - revisionToken: "root-after", + sourceState: testSourceState("root-after"), }, { allowUncachedSegment: true }, ); - expect(setCachedNodeRevision).not.toHaveBeenCalled(); + expect(setCachedNodeSourceState).not.toHaveBeenCalled(); expect(visibleSegmentsState.visibleSegments.has(13n)).toBe(true); expect(selectSegment).toHaveBeenCalledWith(13n, true); expect(selectSpatialSkeletonNode).toHaveBeenCalledWith(29, false, { @@ -346,19 +356,19 @@ describe("spatial_skeleton_edit_tool", () => { segmentId: 11, position: new Float32Array([1, 2, 3]), isTrueEnd: false, - revisionToken: "first-before", + sourceState: testSourceState("first-before"), }; const secondNode: SpatiallyIndexedSkeletonNode = { nodeId: 202, segmentId: 17, position: new Float32Array([4, 5, 6]), isTrueEnd: false, - revisionToken: "second-before", + sourceState: testSourceState("second-before"), }; const mergeSkeletons = vi.fn().mockResolvedValue({ - resultSkeletonId: 17, - deletedSkeletonId: 11, - stableAnnotationSwap: true, + resultSegmentId: 17, + deletedSegmentId: 11, + directionAdjusted: true, }); const invalidateCachedSegments = vi.fn(); const getFullSegmentNodes = vi.fn(async () => []); diff --git a/src/ui/spatial_skeleton_edit_tool.ts b/src/ui/spatial_skeleton_edit_tool.ts index 85717fd42..6a24f4595 100644 --- a/src/ui/spatial_skeleton_edit_tool.ts +++ b/src/ui/spatial_skeleton_edit_tool.ts @@ -185,7 +185,7 @@ abstract class SpatialSkeletonToolBase extends LayerTool nodeId: number; segmentId?: number; position?: Float32Array; - revisionToken?: string; + sourceState?: unknown; } | undefined { if (!this.mouseState.updateUnconditionally() || !this.mouseState.active) { @@ -202,7 +202,7 @@ abstract class SpatialSkeletonToolBase extends LayerTool } const segmentIdRaw = pickedSpatialSkeleton?.segmentId; const position = pickedSpatialSkeleton?.position; - const revisionToken = pickedSpatialSkeleton?.revisionToken; + const sourceState = pickedSpatialSkeleton?.sourceState; return { nodeId: nodeIdRaw, segmentId: @@ -213,8 +213,7 @@ abstract class SpatialSkeletonToolBase extends LayerTool position instanceof Float32Array ? new Float32Array(position) : undefined, - revisionToken: - typeof revisionToken === "string" ? revisionToken : undefined, + sourceState, }; } @@ -375,7 +374,7 @@ abstract class SpatialSkeletonToolBase extends LayerTool nodeId: nodeHit.nodeId, segmentId: nodeHit.segmentId ?? resolvedNodeInfo?.segmentId, position: nodeHit.position ?? resolvedNodeInfo?.position, - revisionToken: nodeHit.revisionToken ?? resolvedNodeInfo?.revisionToken, + sourceState: nodeHit.sourceState ?? resolvedNodeInfo?.sourceState, }; } @@ -386,7 +385,7 @@ abstract class SpatialSkeletonToolBase extends LayerTool nodeId: number; segmentId?: number; position?: Float32Array; - revisionToken?: string; + sourceState?: unknown; visible: boolean; } | undefined { @@ -402,7 +401,7 @@ abstract class SpatialSkeletonToolBase extends LayerTool nodeId: nodeHit.nodeId, segmentId, position: nodeHit.position ?? resolvedNodeInfo?.position, - revisionToken: nodeHit.revisionToken ?? resolvedNodeInfo?.revisionToken, + sourceState: nodeHit.sourceState ?? resolvedNodeInfo?.sourceState, visible: segmentId !== undefined && this.isSpatialSkeletonSegmentVisible(segmentId), @@ -1063,7 +1062,7 @@ class SpatialSkeletonMergeModeTool extends SpatialSkeletonToolBase { nodeId: number; segmentId?: number; position?: ArrayLike; - revisionToken?: string; + sourceState?: unknown; }; let anchorSelection: MergeAnchorSelection | undefined; let statusOverride: string | undefined; @@ -1083,7 +1082,7 @@ class SpatialSkeletonMergeModeTool extends SpatialSkeletonToolBase { nodeId, segmentId: cachedNode?.segmentId, position: cachedNode?.position, - revisionToken: cachedNode?.revisionToken, + sourceState: cachedNode?.sourceState, }; anchorSelection = anchorNode; return anchorNode; @@ -1184,7 +1183,7 @@ class SpatialSkeletonMergeModeTool extends SpatialSkeletonToolBase { nodeId: pickedNode.nodeId, segmentId: pickedNode.segmentId, position: pickedNode.position, - revisionToken: pickedNode.revisionToken, + sourceState: pickedNode.sourceState, }; this.layer.setSpatialSkeletonMergeAnchor(pickedNode.nodeId); this.layer.selectSpatialSkeletonNode( @@ -1207,7 +1206,7 @@ class SpatialSkeletonMergeModeTool extends SpatialSkeletonToolBase { nodeId: pickedNode.nodeId, segmentId: pickedNode.segmentId, position: pickedNode.position, - revisionToken: pickedNode.revisionToken, + sourceState: pickedNode.sourceState, }; this.layer.setSpatialSkeletonMergeAnchor(pickedNode.nodeId); this.layer.selectSpatialSkeletonNode( @@ -1223,7 +1222,7 @@ class SpatialSkeletonMergeModeTool extends SpatialSkeletonToolBase { nodeId: pickedNode.nodeId, segmentId: pickedNode.segmentId, position: pickedNode.position, - revisionToken: pickedNode.revisionToken, + sourceState: pickedNode.sourceState, }; if ( firstNode.segmentId === undefined || @@ -1250,12 +1249,12 @@ class SpatialSkeletonMergeModeTool extends SpatialSkeletonToolBase { { nodeId: firstNode.nodeId, segmentId: firstNode.segmentId!, - revisionToken: firstNode.revisionToken, + sourceState: firstNode.sourceState, }, { nodeId: secondNode.nodeId, segmentId: secondNode.segmentId!, - revisionToken: secondNode.revisionToken, + sourceState: secondNode.sourceState, }, ); } catch (error) { From 91153191417286753bf3a9fe292abff36284e960 Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Thu, 30 Apr 2026 16:25:54 +0100 Subject: [PATCH 02/11] refactor: WIP - Simplify skeleton api --- src/datasource/catmaid/api.spec.ts | 60 +- src/datasource/catmaid/api.ts | 282 ++++++--- src/datasource/catmaid/backend.ts | 8 +- src/datasource/catmaid/frontend.ts | 75 ++- .../catmaid/spatial_skeleton_commands.ts | 535 +++++++++--------- src/layer/segmentation/index.spec.ts | 19 +- .../spatial_skeleton_commands.spec.ts | 67 ++- .../segmentation/spatial_skeleton_commands.ts | 61 +- .../segmentation/spatial_skeleton_errors.ts | 2 +- src/skeleton/api.ts | 263 +-------- src/skeleton/edit_controller.ts | 121 ++++ src/skeleton/edit_errors.ts | 25 + src/skeleton/navigation.ts | 18 +- src/skeleton/spatial_chunk_sizing.spec.ts | 58 +- src/skeleton/spatial_chunk_sizing.ts | 58 +- src/skeleton/spatial_skeleton_manager.spec.ts | 83 ++- src/skeleton/spatial_skeleton_manager.ts | 38 +- src/ui/spatial_skeleton_edit_tab.ts | 15 +- 18 files changed, 1000 insertions(+), 788 deletions(-) create mode 100644 src/skeleton/edit_controller.ts create mode 100644 src/skeleton/edit_errors.ts diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index e142804fc..cfc1af7b6 100644 --- a/src/datasource/catmaid/api.spec.ts +++ b/src/datasource/catmaid/api.spec.ts @@ -71,12 +71,14 @@ describe("CatmaidClient skeleton editing methods", () => { await expect(client.getSpatialIndexMetadata()).resolves.toBeNull(); await expect(client.getSpatialIndexMetadata()).resolves.toEqual({ - bounds: { - min: { x: 5, y: 6, z: 7 }, - max: { x: 25, y: 66, z: 127 }, - }, - resolution: { x: 2, y: 3, z: 4 }, - gridCellSizes: [{ x: 15, y: 15, z: 15 }], + lowerBounds: [5, 6, 7], + upperBounds: [25, 66, 127], + spatial: [ + { + chunkSize: [15, 15, 15], + gridShape: [2, 4, 8], + }, + ], }); expect((client as any).listStacks).toHaveBeenCalledTimes(2); @@ -101,15 +103,21 @@ describe("CatmaidClient skeleton editing methods", () => { }); await expect(client.getSpatialIndexMetadata()).resolves.toEqual({ - bounds: { - min: { x: 5, y: 6, z: 7 }, - max: { x: 25, y: 66, z: 127 }, - }, - resolution: { x: 2, y: 3, z: 4 }, - gridCellSizes: [ - { x: 120, y: 120, z: 120 }, - { x: 60, y: 60, z: 60 }, - { x: 30, y: 30, z: 30 }, + lowerBounds: [5, 6, 7], + upperBounds: [25, 66, 127], + spatial: [ + { + chunkSize: [120, 120, 120], + gridShape: [1, 1, 1], + }, + { + chunkSize: [60, 60, 60], + gridShape: [1, 1, 2], + }, + { + chunkSize: [30, 30, 30], + gridShape: [1, 2, 4], + }, ], }); }); @@ -312,8 +320,8 @@ describe("CatmaidClient skeleton editing methods", () => { await expect( client.fetchNodes({ - min: { x: 0, y: 0, z: 0 }, - max: { x: 10, y: 10, z: 10 }, + lowerBounds: [0, 0, 0], + upperBounds: [10, 10, 10], }), ).resolves.toEqual([ { @@ -335,6 +343,20 @@ describe("CatmaidClient skeleton editing methods", () => { expect(getFetchPath(fetchMock)).toMatch(/^node\/list\?/); }); + it("rejects CATMAID node-list bounds with fewer than three coordinates", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.fn(); + (client as any).fetch = fetchMock; + + await expect( + client.fetchNodes({ + lowerBounds: [0, 0], + upperBounds: [10, 10], + }), + ).rejects.toThrow(/requires at least 3 coordinates/i); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("fetches skeleton root targets", async () => { const client = new CatmaidClient("https://example.invalid", 1); const fetchMock = vi.fn().mockResolvedValue({ @@ -347,9 +369,7 @@ describe("CatmaidClient skeleton editing methods", () => { await expect(client.getSkeletonRootNode(17)).resolves.toEqual({ nodeId: 303, - x: 1, - y: 2, - z: 3, + position: [1, 2, 3], }); expect(getFetchPath(fetchMock)).toBe("skeletons/17/root"); diff --git a/src/datasource/catmaid/api.ts b/src/datasource/catmaid/api.ts index baa00a4c7..a22f2576c 100644 --- a/src/datasource/catmaid/api.ts +++ b/src/datasource/catmaid/api.ts @@ -17,22 +17,15 @@ import { Unpackr } from "msgpackr"; import { fetchOkWithCredentials } from "#src/credentials_provider/http_request.js"; import type { CredentialsProvider } from "#src/credentials_provider/index.js"; -import { SpatialSkeletonEditConflictError } from "#src/skeleton/api.js"; import type { - SpatiallyIndexedSkeletonAddNodeResult, - SpatiallyIndexedSkeletonDeleteNodeResult, - SpatiallyIndexedSkeletonDescriptionUpdateResult, - SpatiallyIndexedSkeletonInsertNodeResult, - SpatiallyIndexedSkeletonMergeResult, + SpatialSkeletonBounds, + SpatialSkeletonVector, SpatiallyIndexedSkeletonMetadata, - SpatiallyIndexedSkeletonNavigationTarget, SpatiallyIndexedSkeletonNode, - SpatiallyIndexedSkeletonNodeSourceStateResult, - SpatiallyIndexedSkeletonNodeSourceStateUpdate, SpatiallyIndexedSkeletonNodeBase, - SpatiallyIndexedSkeletonRerootResult, - SpatiallyIndexedSkeletonSplitResult, } from "#src/skeleton/api.js"; +import { SpatialSkeletonEditConflictError } from "#src/skeleton/edit_errors.js"; +import type { SpatiallyIndexedSkeletonNavigationTarget } from "#src/skeleton/navigation.js"; import { getDefaultSpatiallyIndexedSkeletonChunkSize } from "#src/skeleton/spatial_chunk_sizing.js"; import { HttpError } from "#src/util/http_request.js"; @@ -79,6 +72,115 @@ export interface CatmaidEditContext { nodes?: readonly CatmaidEditParentContext[]; } +export interface CatmaidSkeletonNodeSourceStateUpdate { + nodeId: number; + sourceState: unknown; +} + +export interface CatmaidSkeletonEditResult { + nodeSourceStateUpdates?: readonly CatmaidSkeletonNodeSourceStateUpdate[]; +} + +export interface CatmaidAddNodeResult extends CatmaidSkeletonEditResult { + nodeId: number; + segmentId: number; + sourceState?: unknown; + parentSourceState?: unknown; +} + +export type CatmaidInsertNodeResult = CatmaidAddNodeResult; + +export interface CatmaidNodeSourceStateResult + extends CatmaidSkeletonEditResult { + sourceState?: unknown; +} + +export interface CatmaidDescriptionUpdateResult + extends CatmaidNodeSourceStateResult { + description?: string; +} + +export type CatmaidDeleteNodeResult = CatmaidSkeletonEditResult; + +export type CatmaidRerootResult = CatmaidSkeletonEditResult; + +export interface CatmaidMergeResult extends CatmaidSkeletonEditResult { + resultSegmentId: number | undefined; + deletedSegmentId: number | undefined; + directionAdjusted: boolean; +} + +export interface CatmaidSplitResult extends CatmaidSkeletonEditResult { + existingSegmentId: number | undefined; + newSegmentId: number | undefined; +} + +export interface CatmaidSpatialSkeletonEditApi { + getSkeletonRootNode( + skeletonId: number, + ): Promise; + addNode( + skeletonId: number, + x: number, + y: number, + z: number, + parentId?: number, + editContext?: CatmaidEditContext, + ): Promise; + insertNode( + skeletonId: number, + x: number, + y: number, + z: number, + parentId: number, + childNodeIds: readonly number[], + editContext?: CatmaidEditContext, + ): Promise; + moveNode( + nodeId: number, + x: number, + y: number, + z: number, + editContext?: CatmaidEditContext, + ): Promise; + deleteNode( + nodeId: number, + options: { + childNodeIds?: readonly number[]; + editContext?: CatmaidEditContext; + }, + ): Promise; + rerootSkeleton?( + nodeId: number, + editContext?: CatmaidEditContext, + ): Promise; + updateDescription( + nodeId: number, + description: string, + ): Promise; + setTrueEnd(nodeId: number): Promise; + removeTrueEnd(nodeId: number): Promise; + updateRadius( + nodeId: number, + radius: number, + editContext?: CatmaidEditContext, + ): Promise; + updateConfidence( + nodeId: number, + confidence: number, + editContext?: CatmaidEditContext, + ): Promise; + mergeSkeletons( + fromNodeId: number, + toNodeId: number, + editContext?: CatmaidEditContext, + ): Promise; + splitSkeleton( + nodeId: number, + editContext?: CatmaidEditContext, + ): Promise; +} + interface CatmaidDeleteNodeOptions { childNodeIds?: readonly number[]; editContext?: CatmaidEditContext; @@ -413,10 +515,9 @@ function mapPercentConfidenceToCatmaid(confidence: number) { return bestIndex + 1; } -function getCatmaidProjectSpaceBounds(info: CatmaidStackInfo): { - min: { x: number; y: number; z: number }; - max: { x: number; y: number; z: number }; -} { +function getCatmaidProjectSpaceBounds( + info: CatmaidStackInfo, +): SpatialSkeletonBounds { const { dimension, resolution, translation } = info; const offsetX = translation?.x ?? 0; const offsetY = translation?.y ?? 0; @@ -424,28 +525,51 @@ function getCatmaidProjectSpaceBounds(info: CatmaidStackInfo): { // CATMAID treenode coordinates and grid cache cell sizes are in project-space nanometers. return { - min: { x: offsetX, y: offsetY, z: offsetZ }, - max: { - x: offsetX + dimension.x * resolution.x, - y: offsetY + dimension.y * resolution.y, - z: offsetZ + dimension.z * resolution.z, - }, + lowerBounds: [offsetX, offsetY, offsetZ], + upperBounds: [ + offsetX + dimension.x * resolution.x, + offsetY + dimension.y * resolution.y, + offsetZ + dimension.z * resolution.z, + ], }; } -function normalizeBoundingBoxForNodeList(boundingBox: { - min: { x: number; y: number; z: number }; - max: { x: number; y: number; z: number }; -}) { - const left = Math.floor(boundingBox.min.x); - const top = Math.floor(boundingBox.min.y); - const z1 = Math.floor(boundingBox.min.z); +function requireCatmaidRank3Vector( + vector: SpatialSkeletonVector, + label: string, +): readonly [number, number, number] { + if (vector.length < 3) { + throw new Error(`CATMAID ${label} requires at least 3 coordinates.`); + } + const values = [ + Number(vector[0]), + Number(vector[1]), + Number(vector[2]), + ] as const; + if (values.some((value) => !Number.isFinite(value))) { + throw new Error(`CATMAID ${label} coordinates must be finite.`); + } + return values; +} + +function normalizeBoundingBoxForNodeList(bounds: SpatialSkeletonBounds) { + const [minX, minY, minZ] = requireCatmaidRank3Vector( + bounds.lowerBounds, + "node-list lower bound", + ); + const [maxX, maxY, maxZ] = requireCatmaidRank3Vector( + bounds.upperBounds, + "node-list upper bound", + ); + const left = Math.floor(minX); + const top = Math.floor(minY); + const z1 = Math.floor(minZ); // CATMAID treats right/bottom as inclusive and z2 as exclusive for grid-cell index filtering. // Use ceil and ensure a positive extent on each axis. - const right = Math.max(left + 1, Math.ceil(boundingBox.max.x)); - const bottom = Math.max(top + 1, Math.ceil(boundingBox.max.y)); - const z2 = Math.max(z1 + 1, Math.ceil(boundingBox.max.z)); + const right = Math.max(left + 1, Math.ceil(maxX)); + const bottom = Math.max(top + 1, Math.ceil(maxY)); + const z2 = Math.max(z1 + 1, Math.ceil(maxZ)); return { left, top, z1, right, bottom, z2 }; } @@ -563,7 +687,7 @@ function parseCatmaidSkeletonRootTarget( Number.isFinite(py) && Number.isFinite(pz) ) { - return { nodeId, x: px, y: py, z: pz }; + return { nodeId, position: [px, py, pz] }; } throw new Error( @@ -789,20 +913,20 @@ function buildCatmaidInsertNodeState( function getCatmaidSingleNodeRevisionResult( revisionToken: string | undefined, -): SpatiallyIndexedSkeletonNodeSourceStateResult { +): CatmaidNodeSourceStateResult { const sourceState = makeCatmaidNodeSourceState(revisionToken); return sourceState === undefined ? {} : { sourceState }; } function parseCatmaidNodeRevisionUpdates( rows: unknown, -): SpatiallyIndexedSkeletonNodeSourceStateUpdate[] { +): CatmaidSkeletonNodeSourceStateUpdate[] { if (!Array.isArray(rows)) { throw new Error( "CATMAID treenodes/compact-detail endpoint returned an unexpected response format.", ); } - const revisionUpdates: SpatiallyIndexedSkeletonNodeSourceStateUpdate[] = []; + const revisionUpdates: CatmaidSkeletonNodeSourceStateUpdate[] = []; for (const row of rows) { if (!Array.isArray(row) || row.length < 9) continue; const nodeId = Number(row[0]); @@ -873,8 +997,8 @@ function parseCatmaidConfidenceRevisionToken( function parseCatmaidChildRevisionUpdates( value: unknown, -): readonly SpatiallyIndexedSkeletonNodeSourceStateUpdate[] { - const revisionUpdates: SpatiallyIndexedSkeletonNodeSourceStateUpdate[] = []; +): readonly CatmaidSkeletonNodeSourceStateUpdate[] { + const revisionUpdates: CatmaidSkeletonNodeSourceStateUpdate[] = []; const children = Array.isArray(value) ? value : []; for (const child of children) { if (!Array.isArray(child) || child.length < 2) continue; @@ -891,7 +1015,7 @@ function parseCatmaidChildRevisionUpdates( function parseCatmaidDeleteRevisionUpdates( response: any, -): readonly SpatiallyIndexedSkeletonNodeSourceStateUpdate[] { +): readonly CatmaidSkeletonNodeSourceStateUpdate[] { return parseCatmaidChildRevisionUpdates(response?.children); } @@ -925,7 +1049,7 @@ function fetchWithCatmaidCredentials( ); } -export class CatmaidClient { +export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { private metadataInfoPromise: Promise | undefined; private readonly msgpackUnpackr = new Unpackr({ mapsAsObjects: false, @@ -1060,8 +1184,8 @@ export class CatmaidClient { private getGridCellSizesFromMetadataInfo( info: CatmaidStackInfo, bounds = getCatmaidProjectSpaceBounds(info), - ): Array<{ x: number; y: number; z: number }> { - const gridSizes: Array<{ x: number; y: number; z: number }> = []; + ): number[][] { + const gridSizes: number[][] = []; // Try to get all allowed spatial skeleton chunk sizes from metadata. if (info.metadata?.spatial_skeleton_chunk_sizes) { @@ -1070,13 +1194,12 @@ export class CatmaidClient { chunkSize.length === 3 && Number.isFinite(chunkSize[0]) && Number.isFinite(chunkSize[1]) && - Number.isFinite(chunkSize[2]) + Number.isFinite(chunkSize[2]) && + chunkSize[0] > 0 && + chunkSize[1] > 0 && + chunkSize[2] > 0 ) { - gridSizes.push({ - x: chunkSize[0], - y: chunkSize[1], - z: chunkSize[2], - }); + gridSizes.push([chunkSize[0], chunkSize[1], chunkSize[2]]); } } } @@ -1089,6 +1212,29 @@ export class CatmaidClient { return gridSizes; } + private getSpatialIndexLevelsFromMetadataInfo( + info: CatmaidStackInfo, + bounds = getCatmaidProjectSpaceBounds(info), + ) { + const [lowerX, lowerY, lowerZ] = requireCatmaidRank3Vector( + bounds.lowerBounds, + "spatial metadata lower bound", + ); + const [upperX, upperY, upperZ] = requireCatmaidRank3Vector( + bounds.upperBounds, + "spatial metadata upper bound", + ); + const extents = [upperX - lowerX, upperY - lowerY, upperZ - lowerZ]; + return this.getGridCellSizesFromMetadataInfo(info, bounds).map( + (chunkSize) => ({ + chunkSize, + gridShape: chunkSize.map((size, index) => + Math.max(1, Math.ceil(extents[index] / size)), + ), + }), + ); + } + async getSpatialIndexMetadata(): Promise { const info = await this.tryGetMetadataInfo(); if (info === null) { @@ -1096,9 +1242,8 @@ export class CatmaidClient { } const bounds = getCatmaidProjectSpaceBounds(info); return { - bounds, - resolution: info.resolution, - gridCellSizes: this.getGridCellSizesFromMetadataInfo(info, bounds), + ...bounds, + spatial: this.getSpatialIndexLevelsFromMetadataInfo(info, bounds), }; } @@ -1161,10 +1306,7 @@ export class CatmaidClient { } async fetchNodes( - boundingBox: { - min: { x: number; y: number; z: number }; - max: { x: number; y: number; z: number }; - }, + bounds: SpatialSkeletonBounds, lod: number = 0, options: { cacheProvider?: string; @@ -1172,7 +1314,7 @@ export class CatmaidClient { } = {}, ): Promise { const { cacheProvider, signal } = options; - const normalizedBoundingBox = normalizeBoundingBoxForNodeList(boundingBox); + const normalizedBoundingBox = normalizeBoundingBoxForNodeList(bounds); const params = new URLSearchParams({ left: normalizedBoundingBox.left.toString(), top: normalizedBoundingBox.top.toString(), @@ -1269,7 +1411,7 @@ export class CatmaidClient { y: number, z: number, editContext?: CatmaidEditContext, - ): Promise { + ): Promise { const body = new URLSearchParams(); appendNodeUpdateRows(body, "t", [[nodeId, x, y, z]]); appendCatmaidState( @@ -1296,7 +1438,7 @@ export class CatmaidClient { async rerootSkeleton( nodeId: number, editContext?: CatmaidEditContext, - ): Promise { + ): Promise { const body = new URLSearchParams({ treenode_id: nodeId.toString(), }); @@ -1323,7 +1465,7 @@ export class CatmaidClient { private async fetchNodeRevisionUpdates( nodeIds: readonly number[], - ): Promise { + ): Promise { const normalizedNodeIds = [ ...new Set( nodeIds @@ -1359,7 +1501,7 @@ export class CatmaidClient { async deleteNode( nodeId: number, options: CatmaidDeleteNodeOptions = {}, - ): Promise { + ): Promise { const { childNodeIds = [], editContext } = options; const normalizedChildIds = [ ...new Set( @@ -1398,7 +1540,7 @@ export class CatmaidClient { z: number, parentId?: number, editContext?: CatmaidEditContext, - ): Promise { + ): Promise { const body = new URLSearchParams({ x: x.toString(), y: y.toString(), @@ -1446,7 +1588,7 @@ export class CatmaidClient { parentId: number, childNodeIds: readonly number[], editContext?: CatmaidEditContext, - ): Promise { + ): Promise { const normalizedChildIds = [ ...new Set( childNodeIds @@ -1588,7 +1730,7 @@ export class CatmaidClient { async updateDescription( nodeId: number, description: string, - ): Promise { + ): Promise { const normalizedLabels = this.buildDescriptionLabels(description); const response = await this.replaceNodeLabels(nodeId, normalizedLabels); return { @@ -1600,18 +1742,14 @@ export class CatmaidClient { }; } - async setTrueEnd( - nodeId: number, - ): Promise { + async setTrueEnd(nodeId: number): Promise { const response = await this.addNodeLabel(nodeId, CATMAID_TRUE_END_LABEL); return getCatmaidSingleNodeRevisionResult( normalizeCatmaidRevisionToken((response as any)?.edition_time), ); } - async removeTrueEnd( - nodeId: number, - ): Promise { + async removeTrueEnd(nodeId: number): Promise { const response = await this.removeNodeLabel(nodeId, CATMAID_TRUE_END_LABEL); return getCatmaidSingleNodeRevisionResult( normalizeCatmaidRevisionToken((response as any)?.edition_time), @@ -1622,7 +1760,7 @@ export class CatmaidClient { nodeId: number, radius: number, editContext?: CatmaidEditContext, - ): Promise { + ): Promise { if (!Number.isFinite(radius)) { throw new Error("Radius must be a finite number."); } @@ -1646,7 +1784,7 @@ export class CatmaidClient { nodeId: number, confidence: number, editContext?: CatmaidEditContext, - ): Promise { + ): Promise { if (!Number.isFinite(confidence) || confidence < 0 || confidence > 100) { throw new Error("Confidence must be between 0 and 100."); } @@ -1670,7 +1808,7 @@ export class CatmaidClient { fromNodeId: number, toNodeId: number, editContext?: CatmaidEditContext, - ): Promise { + ): Promise { const body = new URLSearchParams({ from_id: fromNodeId.toString(), to_id: toNodeId.toString(), @@ -1702,7 +1840,7 @@ export class CatmaidClient { async splitSkeleton( nodeId: number, editContext?: CatmaidEditContext, - ): Promise { + ): Promise { const body = new URLSearchParams({ treenode_id: nodeId.toString(), }); diff --git a/src/datasource/catmaid/backend.ts b/src/datasource/catmaid/backend.ts index f36990e78..4374721f7 100644 --- a/src/datasource/catmaid/backend.ts +++ b/src/datasource/catmaid/backend.ts @@ -76,16 +76,16 @@ export class CatmaidSpatiallyIndexedSkeletonSourceBackend extends WithParameters chunkDataSize as unknown as vec3, ); - const bbox = { - min: { x: localMin[0], y: localMin[1], z: localMin[2] }, - max: { x: localMax[0], y: localMax[1], z: localMax[2] }, + const bounds = { + lowerBounds: localMin, + upperBounds: localMax, }; // Use LOD stored on the chunk to support per-view LODs on shared sources. const lodValue = chunk.lod ?? this.currentLod; // Get cache provider from parameters (passed from frontend) const cacheProvider = this.parameters.catmaidParameters.cacheProvider; - const nodes = await this.client.fetchNodes(bbox, lodValue, { + const nodes = await this.client.fetchNodes(bounds, lodValue, { cacheProvider, signal, }); diff --git a/src/datasource/catmaid/frontend.ts b/src/datasource/catmaid/frontend.ts index 7b1e4682d..5fb375047 100644 --- a/src/datasource/catmaid/frontend.ts +++ b/src/datasource/catmaid/frontend.ts @@ -24,7 +24,15 @@ import { import { WithCredentialsProvider } from "#src/credentials_provider/chunk_source_frontend.js"; import type { CredentialsProvider } from "#src/credentials_provider/index.js"; import type { + CatmaidAddNodeResult, + CatmaidDeleteNodeResult, + CatmaidDescriptionUpdateResult, CatmaidEditContext, + CatmaidInsertNodeResult, + CatmaidMergeResult, + CatmaidNodeSourceStateResult, + CatmaidSplitResult, + CatmaidSpatialSkeletonEditApi, CatmaidToken, } from "#src/datasource/catmaid/api.js"; import { CatmaidClient, credentialsKey } from "#src/datasource/catmaid/api.js"; @@ -44,17 +52,10 @@ import { normalizeInlineSegmentPropertyMap, } from "#src/segmentation_display_state/property_map.js"; import type { - EditableSpatiallyIndexedSkeletonSource, - SpatiallyIndexedSkeletonAddNodeResult, - SpatiallyIndexedSkeletonDeleteNodeResult, - SpatiallyIndexedSkeletonDescriptionUpdateResult, - SpatiallyIndexedSkeletonInsertNodeResult, - SpatiallyIndexedSkeletonMergeResult, + SpatialSkeletonBounds, SpatiallyIndexedSkeletonMetadata, SpatiallyIndexedSkeletonNode, - SpatiallyIndexedSkeletonNodeSourceStateResult, SpatiallyIndexedSkeletonNodeBase, - SpatiallyIndexedSkeletonSplitResult, } from "#src/skeleton/api.js"; import { SpatiallyIndexedSkeletonSource, @@ -77,7 +78,7 @@ export class CatmaidSpatiallyIndexedSkeletonSource WithCredentialsProvider()(SpatiallyIndexedSkeletonSource), CatmaidSkeletonSourceParameters, ) - implements EditableSpatiallyIndexedSkeletonSource + implements CatmaidSpatialSkeletonEditApi { readonly spatialSkeletonEditController = new CatmaidSpatialSkeletonEditController(); @@ -114,17 +115,14 @@ export class CatmaidSpatiallyIndexedSkeletonSource } fetchNodes( - boundingBox: { - min: { x: number; y: number; z: number }; - max: { x: number; y: number; z: number }; - }, + bounds: SpatialSkeletonBounds, lod?: number, options?: { cacheProvider?: string; signal?: AbortSignal; }, ): Promise { - return this.client.fetchNodes(boundingBox, lod, options); + return this.client.fetchNodes(bounds, lod, options); } getSkeletonRootNode(skeletonId: number) { @@ -138,7 +136,7 @@ export class CatmaidSpatiallyIndexedSkeletonSource z: number, parentId?: number, editContext?: CatmaidEditContext, - ): Promise { + ): Promise { return this.client.addNode(skeletonId, x, y, z, parentId, editContext); } @@ -150,7 +148,7 @@ export class CatmaidSpatiallyIndexedSkeletonSource parentId: number, childNodeIds: readonly number[], editContext?: CatmaidEditContext, - ): Promise { + ): Promise { return this.client.insertNode( skeletonId, x, @@ -168,7 +166,7 @@ export class CatmaidSpatiallyIndexedSkeletonSource y: number, z: number, editContext?: CatmaidEditContext, - ): Promise { + ): Promise { return this.client.moveNode(nodeId, x, y, z, editContext); } @@ -178,7 +176,7 @@ export class CatmaidSpatiallyIndexedSkeletonSource childNodeIds?: readonly number[]; editContext?: CatmaidEditContext; }, - ): Promise { + ): Promise { return this.client.deleteNode(nodeId, options); } @@ -189,19 +187,15 @@ export class CatmaidSpatiallyIndexedSkeletonSource updateDescription( nodeId: number, description: string, - ): Promise { + ): Promise { return this.client.updateDescription(nodeId, description); } - setTrueEnd( - nodeId: number, - ): Promise { + setTrueEnd(nodeId: number): Promise { return this.client.setTrueEnd(nodeId); } - removeTrueEnd( - nodeId: number, - ): Promise { + removeTrueEnd(nodeId: number): Promise { return this.client.removeTrueEnd(nodeId); } @@ -209,7 +203,7 @@ export class CatmaidSpatiallyIndexedSkeletonSource nodeId: number, radius: number, editContext?: CatmaidEditContext, - ): Promise { + ): Promise { return this.client.updateRadius(nodeId, radius, editContext); } @@ -217,7 +211,7 @@ export class CatmaidSpatiallyIndexedSkeletonSource nodeId: number, confidence: number, editContext?: CatmaidEditContext, - ): Promise { + ): Promise { return this.client.updateConfidence(nodeId, confidence, editContext); } @@ -225,14 +219,14 @@ export class CatmaidSpatiallyIndexedSkeletonSource fromNodeId: number, toNodeId: number, editContext?: CatmaidEditContext, - ): Promise { + ): Promise { return this.client.mergeSkeletons(fromNodeId, toNodeId, editContext); } splitSkeleton( nodeId: number, editContext?: CatmaidEditContext, - ): Promise { + ): Promise { return this.client.splitSkeleton(nodeId, editContext); } } @@ -427,7 +421,16 @@ export class CatmaidDataSourceProvider implements DataSourceProvider { throw new Error("Failed to fetch CATMAID spatial index metadata"); } - const { bounds: projectBounds, gridCellSizes } = spatialIndexMetadata; + const { + lowerBounds: projectLowerBounds, + upperBounds: projectUpperBounds, + spatial, + } = spatialIndexMetadata; + const gridCellSizes = spatial.map(({ chunkSize }) => ({ + x: Number(chunkSize[0]), + y: Number(chunkSize[1]), + z: Number(chunkSize[2]), + })); // The model-space coordinates we emit are in nanometers, converted to meters for Neuroglancer. const coordinateScaleFactors = Float64Array.from([ @@ -437,16 +440,8 @@ export class CatmaidDataSourceProvider implements DataSourceProvider { ]); // Bounds and chunk sizes are represented in project-space nanometers. - const lowerBounds = Float64Array.from([ - projectBounds.min.x, - projectBounds.min.y, - projectBounds.min.z, - ]); - const upperBounds = Float64Array.from([ - projectBounds.max.x, - projectBounds.max.y, - projectBounds.max.z, - ]); + const lowerBounds = Float64Array.from(projectLowerBounds); + const upperBounds = Float64Array.from(projectUpperBounds); const modelSpace = makeCoordinateSpace({ names: ["x", "y", "z"], diff --git a/src/datasource/catmaid/spatial_skeleton_commands.ts b/src/datasource/catmaid/spatial_skeleton_commands.ts index 62e5817e7..37baa835e 100644 --- a/src/datasource/catmaid/spatial_skeleton_commands.ts +++ b/src/datasource/catmaid/spatial_skeleton_commands.ts @@ -15,7 +15,15 @@ */ import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; -import type { CatmaidEditContext } from "#src/datasource/catmaid/api.js"; +import type { + CatmaidAddNodeResult, + CatmaidEditContext, + CatmaidInsertNodeResult, + CatmaidMergeResult, + CatmaidSkeletonNodeSourceStateUpdate, + CatmaidSplitResult, + CatmaidSpatialSkeletonEditApi, +} from "#src/datasource/catmaid/api.js"; import { getCatmaidRevisionToken } from "#src/datasource/catmaid/api.js"; import { buildCatmaidMultiNodeEditContext, @@ -28,21 +36,19 @@ import { removeSegmentFromVisibleSets, } from "#src/segmentation_display_state/base.js"; import type { - EditableSpatiallyIndexedSkeletonSource, - SpatiallyIndexedSkeletonAddNodeResult, - SpatiallyIndexedSkeletonInsertNodeResult, - SpatiallyIndexedSkeletonMergeResult, SpatiallyIndexedSkeletonNode, - SpatiallyIndexedSkeletonNodeSourceStateUpdate, - SpatiallyIndexedSkeletonSplitResult, + SpatialSkeletonVector, +} from "#src/skeleton/api.js"; +import type { SpatialSkeletonAddNodeCommandOptions, SpatialSkeletonEditController, + SpatialSkeletonInsertNodeCommandOptions, SpatialSkeletonMergeEndpoint, SpatialSkeletonMoveNodeCommandOptions, SpatialSkeletonNodeDescriptionCommandOptions, SpatialSkeletonNodePropertiesCommandOptions, SpatialSkeletonNodeTrueEndCommandOptions, -} from "#src/skeleton/api.js"; +} from "#src/skeleton/edit_controller.js"; import { SpatialSkeletonActions } from "#src/skeleton/actions.js"; import type { SpatialSkeletonCommand, @@ -53,17 +59,64 @@ import { getSpatiallyIndexedSkeletonDirectChildren, } from "#src/skeleton/edit_state.js"; import type { SpatiallyIndexedSkeletonLayer } from "#src/skeleton/frontend.js"; -import { getEditableSpatiallyIndexedSkeletonSource } from "#src/skeleton/spatial_skeleton_manager.js"; import { StatusMessage } from "#src/status.js"; import { formatErrorMessage } from "#src/util/error.js"; +function hasFunction( + value: unknown, + property: T, +): value is Record unknown> { + return ( + typeof value === "object" && + value !== null && + typeof (value as Record)[property] === "function" + ); +} + +function isCatmaidSpatialSkeletonEditApi( + value: unknown, +): value is CatmaidSpatialSkeletonEditApi { + return ( + hasFunction(value, "getSkeletonRootNode") && + hasFunction(value, "addNode") && + hasFunction(value, "insertNode") && + hasFunction(value, "moveNode") && + hasFunction(value, "deleteNode") && + hasFunction(value, "updateDescription") && + hasFunction(value, "setTrueEnd") && + hasFunction(value, "removeTrueEnd") && + hasFunction(value, "updateRadius") && + hasFunction(value, "updateConfidence") && + hasFunction(value, "mergeSkeletons") && + hasFunction(value, "splitSkeleton") + ); +} + +function toCatmaidPositionInModelSpace( + position: SpatialSkeletonVector, + label: string, +) { + if (position.length < 3) { + throw new Error(`CATMAID ${label} requires at least 3 coordinates.`); + } + const values = [ + Number(position[0]), + Number(position[1]), + Number(position[2]), + ]; + if (values.some((value) => !Number.isFinite(value))) { + throw new Error(`CATMAID ${label} coordinates must be finite.`); + } + return new Float32Array(values); +} + function cloneNodeSnapshot( node: SpatiallyIndexedSkeletonNode, ): SpatiallyIndexedSkeletonNode { return { nodeId: node.nodeId, segmentId: node.segmentId, - position: new Float32Array(node.position), + position: toCatmaidPositionInModelSpace(node.position, "node position"), parentNodeId: node.parentNodeId, radius: node.radius, confidence: node.confidence, @@ -75,7 +128,7 @@ function cloneNodeSnapshot( function getEditableSkeletonSourceForLayer(layer: SegmentationUserLayer): { skeletonLayer: SpatiallyIndexedSkeletonLayer; - skeletonSource: EditableSpatiallyIndexedSkeletonSource; + skeletonSource: CatmaidSpatialSkeletonEditApi; } { const skeletonLayer = layer.getSpatiallyIndexedSkeletonLayer(); if (skeletonLayer === undefined) { @@ -83,11 +136,14 @@ function getEditableSkeletonSourceForLayer(layer: SegmentationUserLayer): { "No spatially indexed skeleton source is currently loaded.", ); } - const skeletonSource = - getEditableSpatiallyIndexedSkeletonSource(skeletonLayer); + const skeletonSource = isCatmaidSpatialSkeletonEditApi( + skeletonLayer.source, + ) + ? skeletonLayer.source + : undefined; if (skeletonSource === undefined) { throw new Error( - "Unable to resolve editable skeleton source for the active layer.", + "Unable to resolve CATMAID editable skeleton source for the active layer.", ); } return { skeletonLayer, skeletonSource }; @@ -152,7 +208,7 @@ function findRootNode(segmentNodes: readonly SpatiallyIndexedSkeletonNode[]) { interface ResolvedSpatialSkeletonEditNode { skeletonLayer: SpatiallyIndexedSkeletonLayer; - skeletonSource: EditableSpatiallyIndexedSkeletonSource; + skeletonSource: CatmaidSpatialSkeletonEditApi; segmentNodes: readonly SpatiallyIndexedSkeletonNode[]; node: SpatiallyIndexedSkeletonNode; } @@ -162,7 +218,7 @@ interface ResolvedSpatialSkeletonEditNodeContext { segmentId: number; cachedNode: SpatiallyIndexedSkeletonNode | undefined; skeletonLayer: SpatiallyIndexedSkeletonLayer; - skeletonSource: EditableSpatiallyIndexedSkeletonSource; + skeletonSource: CatmaidSpatialSkeletonEditApi; } function getResolvedNodeContextForEdit( @@ -275,7 +331,7 @@ async function refreshTopologySegments( function applyAddNodeToCache( layer: SegmentationUserLayer, skeletonLayer: SpatiallyIndexedSkeletonLayer, - committedNode: SpatiallyIndexedSkeletonAddNodeResult, + committedNode: CatmaidAddNodeResult, parentNodeId: number | undefined, positionInModelSpace: Float32Array, options: { @@ -339,7 +395,7 @@ function applyDeleteNodeToCache( options: { moveView: boolean; }, - nodeSourceStateUpdates: readonly SpatiallyIndexedSkeletonNodeSourceStateUpdate[] = [], + nodeSourceStateUpdates: readonly CatmaidSkeletonNodeSourceStateUpdate[] = [], ) { const { node, parentNode, childNodes } = deleteContext; const directChildIds = childNodes.map((child) => child.nodeId); @@ -384,7 +440,7 @@ function applyDeleteNodeToCache( } async function applyNodeDescriptionAndTrueEnd( - skeletonSource: EditableSpatiallyIndexedSkeletonSource, + skeletonSource: CatmaidSpatialSkeletonEditApi, node: SpatiallyIndexedSkeletonNode, next: { description?: string; @@ -424,7 +480,7 @@ async function applyNodeDescriptionAndTrueEnd( async function restoreNodeAttributes( layer: SegmentationUserLayer, - skeletonSource: EditableSpatiallyIndexedSkeletonSource, + skeletonSource: CatmaidSpatialSkeletonEditApi, createdNode: SpatiallyIndexedSkeletonNode, snapshot: SpatiallyIndexedSkeletonNode, ) { @@ -611,6 +667,174 @@ class AddNodeCommand implements SpatialSkeletonCommand { } } +class InsertNodeCommand implements SpatialSkeletonCommand { + readonly label = "Insert node"; + private stableNodeId: number | undefined; + private stableSegmentId: number | undefined; + + constructor( + private layer: SegmentationUserLayer, + private stableParentNodeId: number, + private stableChildNodeIds: readonly number[], + private targetSkeletonId: number, + private positionInModelSpace: Float32Array, + ) {} + + private async insertNode(options: { + moveView: boolean; + pinSegment: boolean; + statusPrefix: string; + }) { + const { skeletonLayer, skeletonSource } = getEditableSkeletonSourceForLayer( + this.layer, + ); + const parentNode = ( + await getResolvedNodeForEdit( + this.layer, + this.stableParentNodeId, + this.stableSegmentId ?? this.targetSkeletonId, + ) + ).node; + const childNodes = await Promise.all( + this.stableChildNodeIds.map((stableChildNodeId) => + getResolvedNodeForEdit( + this.layer, + stableChildNodeId, + parentNode.segmentId, + ).then((result) => result.node), + ), + ); + const result = await skeletonSource.insertNode( + parentNode.segmentId, + Number(this.positionInModelSpace[0]), + Number(this.positionInModelSpace[1]), + Number(this.positionInModelSpace[2]), + parentNode.nodeId, + childNodes.map((child) => child.nodeId), + buildInsertEditContext(parentNode, childNodes), + ); + if (this.stableNodeId === undefined) { + this.stableNodeId = result.nodeId; + } else { + this.layer.spatialSkeletonState.commandHistory.mappings.remapNodeId( + this.stableNodeId, + result.nodeId, + ); + } + if (this.stableSegmentId === undefined) { + this.stableSegmentId = result.segmentId; + } else { + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + this.stableSegmentId, + result.segmentId, + ); + } + const newNode: SpatiallyIndexedSkeletonNode = { + nodeId: result.nodeId, + segmentId: result.segmentId, + position: new Float32Array(this.positionInModelSpace), + parentNodeId: parentNode.nodeId, + isTrueEnd: false, + ...(result.sourceState === undefined + ? {} + : { sourceState: result.sourceState }), + }; + this.layer.spatialSkeletonState.upsertCachedNode(newNode); + for (const childNode of childNodes) { + this.layer.spatialSkeletonState.setCachedNodeParent( + childNode.nodeId, + newNode.nodeId, + ); + } + if (result.parentSourceState !== undefined) { + this.layer.spatialSkeletonState.setCachedNodeSourceState( + parentNode.nodeId, + result.parentSourceState, + ); + } + if (result.nodeSourceStateUpdates?.length) { + this.layer.spatialSkeletonState.setCachedNodeSourceStates( + result.nodeSourceStateUpdates, + ); + } + ensureVisibleSegment(this.layer, newNode.segmentId); + selectSegment(this.layer, newNode.segmentId, options.pinSegment); + this.layer.selectSpatialSkeletonNode( + newNode.nodeId, + this.layer.manager.root.selectionState.pin.value, + { + segmentId: newNode.segmentId, + position: newNode.position, + }, + ); + if (options.moveView) { + this.layer.moveViewToSpatialSkeletonNodePosition(newNode.position); + } + skeletonLayer.retainOverlaySegment(newNode.segmentId); + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + StatusMessage.showTemporaryMessage( + `${options.statusPrefix} node ${result.nodeId} on segment ${result.segmentId}.`, + ); + } + + private async deleteInsertedNode(statusPrefix: string) { + if (this.stableNodeId === undefined) { + throw new Error("Insert-node undo is missing the created node id."); + } + const resolvedNode = await getResolvedNodeForEdit( + this.layer, + this.stableNodeId, + this.stableSegmentId, + ); + const deleteContext = + await this.layer.getSpatialSkeletonDeleteOperationContext( + resolvedNode.node, + ); + const result = await resolvedNode.skeletonSource.deleteNode( + resolvedNode.node.nodeId, + { + childNodeIds: deleteContext.childNodes.map((child) => child.nodeId), + editContext: buildCatmaidNeighborhoodEditContext( + deleteContext.node, + resolvedNode.segmentNodes, + ), + }, + ); + applyDeleteNodeToCache( + this.layer, + deleteContext, + { moveView: false }, + result.nodeSourceStateUpdates, + ); + resolvedNode.skeletonLayer.invalidateSourceCaches(); + StatusMessage.showTemporaryMessage( + `${statusPrefix} inserted node ${resolvedNode.node.nodeId}.`, + ); + } + + execute() { + return this.insertNode({ + moveView: true, + pinSegment: true, + statusPrefix: "Inserted", + }); + } + + undo() { + return this.deleteInsertedNode("Undid insertion of"); + } + + redo() { + return this.insertNode({ + moveView: false, + pinSegment: false, + statusPrefix: "Redid insertion of", + }); + } +} + class MoveNodeCommand implements SpatialSkeletonCommand { readonly label = "Move node"; @@ -756,9 +980,7 @@ class DeleteNodeCommand implements SpatialSkeletonCommand { ).then((result) => result.node), ), ); - const createResult: - | SpatiallyIndexedSkeletonAddNodeResult - | SpatiallyIndexedSkeletonInsertNodeResult = + const createResult: CatmaidAddNodeResult | CatmaidInsertNodeResult = currentChildNodes.length === 0 ? await skeletonSource.addNode( currentParentNode?.segmentId ?? 0, @@ -1169,7 +1391,7 @@ class SplitCommand implements SpatialSkeletonCommand { this.stableNodeId, this.stableSegmentId, ); - let result: SpatiallyIndexedSkeletonSplitResult; + let result: CatmaidSplitResult; try { result = await resolvedNode.skeletonSource.splitSkeleton( resolvedNode.node.nodeId, @@ -1237,7 +1459,7 @@ class SplitCommand implements SpatialSkeletonCommand { this.stableFormerParentNodeId, this.stableSegmentId, ); - let result: SpatiallyIndexedSkeletonMergeResult; + let result: CatmaidMergeResult; try { result = await formerParent.skeletonSource.mergeSkeletons( formerParent.node.nodeId, @@ -1371,7 +1593,7 @@ class MergeCommand implements SpatialSkeletonCommand { }, }; } - let result: SpatiallyIndexedSkeletonMergeResult; + let result: CatmaidMergeResult; try { result = await firstNode.skeletonSource.mergeSkeletons( firstNode.node.nodeId, @@ -1465,7 +1687,7 @@ class MergeCommand implements SpatialSkeletonCommand { this.stableAttachedNodeId, this.stableResultSegmentId ?? this.stableFirstSegmentId, ); - let splitResult: SpatiallyIndexedSkeletonSplitResult; + let splitResult: CatmaidSplitResult; try { splitResult = await attachedNode.skeletonSource.splitSkeleton( attachedNode.node.nodeId, @@ -1602,7 +1824,31 @@ export class CatmaidSpatialSkeletonEditController commandMappings.getStableOrCurrentNodeId(options.parentNodeId), commandMappings.getStableOrCurrentSegmentId(options.skeletonId) ?? options.skeletonId, - new Float32Array(options.positionInModelSpace), + toCatmaidPositionInModelSpace( + options.positionInModelSpace, + "add-node position", + ), + ); + } + + createInsertNodeCommand( + layer: SegmentationUserLayer, + options: SpatialSkeletonInsertNodeCommandOptions, + ) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + return new InsertNodeCommand( + layer, + commandMappings.getStableOrCurrentNodeId(options.parentNodeId)!, + options.childNodeIds.map( + (childNodeId) => + commandMappings.getStableOrCurrentNodeId(childNodeId)!, + ), + commandMappings.getStableOrCurrentSegmentId(options.skeletonId) ?? + options.skeletonId, + toCatmaidPositionInModelSpace( + options.positionInModelSpace, + "insert-node position", + ), ); } @@ -1615,8 +1861,14 @@ export class CatmaidSpatialSkeletonEditController layer, commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), - new Float32Array(options.node.position), - new Float32Array(options.nextPositionInModelSpace), + toCatmaidPositionInModelSpace( + options.node.position, + "move-node current position", + ), + toCatmaidPositionInModelSpace( + options.nextPositionInModelSpace, + "move-node target position", + ), ); } @@ -1755,226 +2007,3 @@ export class CatmaidSpatialSkeletonEditController ); } } - -export function executeSpatialSkeletonAddNode( - layer: SegmentationUserLayer, - options: { - skeletonId: number; - parentNodeId: number | undefined; - // Callers must convert viewer/global coordinates to skeleton model space. - positionInModelSpace: Float32Array; - }, -) { - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const command = new AddNodeCommand( - layer, - commandMappings.getStableOrCurrentNodeId(options.parentNodeId), - commandMappings.getStableOrCurrentSegmentId(options.skeletonId) ?? - options.skeletonId, - new Float32Array(options.positionInModelSpace), - ); - return executeSpatialSkeletonCommandWithPendingMessage( - layer.spatialSkeletonState.commandHistory.execute(command), - "Creating node...", - ); -} - -export function executeSpatialSkeletonMoveNode( - layer: SegmentationUserLayer, - options: { - node: SpatiallyIndexedSkeletonNode; - // Callers must convert viewer/global coordinates to skeleton model space. - nextPositionInModelSpace: Float32Array; - }, -) { - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const command = new MoveNodeCommand( - layer, - commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, - commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), - new Float32Array(options.node.position), - new Float32Array(options.nextPositionInModelSpace), - ); - return layer.spatialSkeletonState.commandHistory.execute(command); -} - -export function executeSpatialSkeletonDeleteNode( - layer: SegmentationUserLayer, - node: SpatiallyIndexedSkeletonNode, -) { - const segmentNodes = layer.getCachedSpatialSkeletonSegmentNodesForEdit( - node.segmentId, - ); - const refreshedNode = findSpatiallyIndexedSkeletonNode( - segmentNodes, - node.nodeId, - ); - if (refreshedNode === undefined) { - throw new Error( - `Node ${node.nodeId} is not available in the inspected skeleton cache.`, - ); - } - const childNodes = getSpatiallyIndexedSkeletonDirectChildren( - segmentNodes, - refreshedNode.nodeId, - ); - const command = new DeleteNodeCommand(layer, refreshedNode, childNodes); - return executeSpatialSkeletonCommandWithPendingMessage( - layer.spatialSkeletonState.commandHistory.execute(command), - "Deleting node...", - ); -} - -export function executeSpatialSkeletonNodeDescriptionUpdate( - layer: SegmentationUserLayer, - options: { - node: SpatiallyIndexedSkeletonNode; - nextDescription?: string; - }, -) { - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const command = new NodeDescriptionCommand( - layer, - commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, - commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), - options.node.description, - options.nextDescription ?? options.node.description, - ); - return layer.spatialSkeletonState.commandHistory.execute(command); -} - -export function executeSpatialSkeletonNodeTrueEndUpdate( - layer: SegmentationUserLayer, - options: { - node: SpatiallyIndexedSkeletonNode; - nextIsTrueEnd: boolean; - }, -) { - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const command = new NodeTrueEndCommand( - layer, - commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, - commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), - options.node.isTrueEnd ?? false, - options.nextIsTrueEnd, - ); - return layer.spatialSkeletonState.commandHistory.execute(command); -} - -export function executeSpatialSkeletonNodePropertiesUpdate( - layer: SegmentationUserLayer, - options: { - node: SpatiallyIndexedSkeletonNode; - next: { radius: number; confidence: number }; - }, -) { - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const command = new NodePropertiesCommand( - layer, - commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, - commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), - { - radius: options.node.radius ?? 0, - confidence: options.node.confidence ?? 0, - }, - options.next, - ); - return layer.spatialSkeletonState.commandHistory.execute(command); -} - -export function executeSpatialSkeletonReroot( - layer: SegmentationUserLayer, - node: Pick< - SpatiallyIndexedSkeletonNode, - "nodeId" | "segmentId" | "parentNodeId" - >, -) { - const segmentNodes = layer.getCachedSpatialSkeletonSegmentNodesForEdit( - node.segmentId, - ); - const rootNode = - findRootNode(segmentNodes) ?? - (() => { - throw new Error( - `Unable to resolve the current root for segment ${node.segmentId}.`, - ); - })(); - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const command = new RerootCommand( - layer, - commandMappings.getStableOrCurrentNodeId(node.nodeId)!, - commandMappings.getStableOrCurrentSegmentId(node.segmentId), - commandMappings.getStableOrCurrentNodeId(rootNode.nodeId)!, - ); - return layer.spatialSkeletonState.commandHistory.execute(command); -} - -export function executeSpatialSkeletonSplit( - layer: SegmentationUserLayer, - node: Pick, -) { - const segmentNodes = layer.getCachedSpatialSkeletonSegmentNodesForEdit( - node.segmentId, - ); - const splitNode = findSpatiallyIndexedSkeletonNode(segmentNodes, node.nodeId); - if (splitNode === undefined) { - throw new Error( - `Node ${node.nodeId} is not available in the inspected skeleton cache.`, - ); - } - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const command = new SplitCommand( - layer, - commandMappings.getStableOrCurrentNodeId(splitNode.nodeId)!, - commandMappings.getStableOrCurrentSegmentId(splitNode.segmentId), - commandMappings.getStableOrCurrentNodeId(splitNode.parentNodeId), - ); - return executeSpatialSkeletonCommandWithPendingMessage( - layer.spatialSkeletonState.commandHistory.execute(command), - "Splitting skeleton...", - ); -} - -function executeSpatialSkeletonCommandWithPendingMessage( - promise: Promise, - message: string, -) { - const status = StatusMessage.showMessage(message); - return promise.finally(() => status.dispose()); -} - -export function executeSpatialSkeletonMerge( - layer: SegmentationUserLayer, - firstNode: SpatialSkeletonMergeEndpoint, - secondNode: SpatialSkeletonMergeEndpoint, -) { - const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - const command = new MergeCommand( - layer, - commandMappings.getStableOrCurrentNodeId(firstNode.nodeId)!, - commandMappings.getStableOrCurrentSegmentId(firstNode.segmentId), - commandMappings.getStableOrCurrentNodeId(secondNode.nodeId)!, - commandMappings.getStableOrCurrentSegmentId(secondNode.segmentId), - secondNode.sourceState, - ); - return executeSpatialSkeletonCommandWithPendingMessage( - layer.spatialSkeletonState.commandHistory.execute(command), - "Merging skeletons...", - ); -} - -export async function undoSpatialSkeletonCommand(layer: SegmentationUserLayer) { - const changed = await layer.spatialSkeletonState.commandHistory.undo(); - if (!changed) { - return false; - } - return true; -} - -export async function redoSpatialSkeletonCommand(layer: SegmentationUserLayer) { - const changed = await layer.spatialSkeletonState.commandHistory.redo(); - if (!changed) { - return false; - } - return true; -} diff --git a/src/layer/segmentation/index.spec.ts b/src/layer/segmentation/index.spec.ts index ee6fb08dd..465511c26 100644 --- a/src/layer/segmentation/index.spec.ts +++ b/src/layer/segmentation/index.spec.ts @@ -38,11 +38,26 @@ function makeEditableSpatialSkeletonSource( rerootSkeleton?: (() => Promise) | undefined; } = {}, ) { + const createCommand = () => ({ + label: "test command", + execute: vi.fn(), + undo: vi.fn(), + redo: vi.fn(), + }); return { spatialSkeletonEditController: { supports: (action: string) => action !== SpatialSkeletonActions.reroot || options.rerootSkeleton !== undefined, + createAddNodeCommand: createCommand, + createInsertNodeCommand: createCommand, + createMoveNodeCommand: createCommand, + createDeleteNodeCommand: createCommand, + createSplitCommand: createCommand, + createMergeCommand: createCommand, + ...(options.rerootSkeleton === undefined + ? {} + : { createRerootCommand: createCommand }), }, listSkeletons: async () => [], getSkeleton: async () => [], @@ -59,9 +74,7 @@ function makeEditableSpatialSkeletonSource( updateConfidence: async () => ({}), getSkeletonRootNode: async () => ({ nodeId: 1, - x: 0, - y: 0, - z: 0, + position: [0, 0, 0], }), mergeSkeletons: async () => ({ resultSegmentId: 1, diff --git a/src/layer/segmentation/spatial_skeleton_commands.spec.ts b/src/layer/segmentation/spatial_skeleton_commands.spec.ts index 3e003deac..2ca80d391 100644 --- a/src/layer/segmentation/spatial_skeleton_commands.spec.ts +++ b/src/layer/segmentation/spatial_skeleton_commands.spec.ts @@ -7,6 +7,7 @@ import { executeSpatialSkeletonDeleteNode, executeSpatialSkeletonMerge, executeSpatialSkeletonMoveNode, + executeSpatialSkeletonNodeDescriptionUpdate, executeSpatialSkeletonSplit, redoSpatialSkeletonCommand, undoSpatialSkeletonCommand, @@ -102,7 +103,7 @@ describe("spatial_skeleton_commands", () => { vi.restoreAllMocks(); }); - it("executes opaque source-created commands without generic edit methods", async () => { + it("executes opaque source-created commands through a valid edit controller", async () => { const execute = vi.fn(); const undo = vi.fn(); const redo = vi.fn(); @@ -113,6 +114,7 @@ describe("spatial_skeleton_commands", () => { redo, }; const createMoveNodeCommand = vi.fn(() => command); + const createCommand = vi.fn(() => command); const layer = { spatialSkeletonState: { commandHistory: new SpatialSkeletonCommandHistory(), @@ -121,7 +123,12 @@ describe("spatial_skeleton_commands", () => { source: { spatialSkeletonEditController: { supports: () => true, + createAddNodeCommand: createCommand, + createInsertNodeCommand: createCommand, createMoveNodeCommand, + createDeleteNodeCommand: createCommand, + createSplitCommand: createCommand, + createMergeCommand: createCommand, }, listSkeletons: vi.fn(), getSkeleton: vi.fn(), @@ -153,6 +160,52 @@ describe("spatial_skeleton_commands", () => { expect(redo).toHaveBeenCalledTimes(1); }); + it("reports unsupported optional command factories clearly", () => { + const command = { + label: "required command", + execute: vi.fn(), + undo: vi.fn(), + redo: vi.fn(), + }; + const createCommand = vi.fn(() => command); + const layer = { + spatialSkeletonState: { + commandHistory: new SpatialSkeletonCommandHistory(), + }, + getSpatiallyIndexedSkeletonLayer: () => ({ + source: { + spatialSkeletonEditController: { + supports: () => true, + createAddNodeCommand: createCommand, + createInsertNodeCommand: createCommand, + createMoveNodeCommand: createCommand, + createDeleteNodeCommand: createCommand, + createSplitCommand: createCommand, + createMergeCommand: createCommand, + }, + listSkeletons: vi.fn(), + getSkeleton: vi.fn(), + fetchNodes: vi.fn(), + getSpatialIndexMetadata: vi.fn(), + }, + }), + }; + const node: SpatiallyIndexedSkeletonNode = { + nodeId: 17, + segmentId: 23, + position: new Float32Array([1, 2, 3]), + }; + + expect(() => + executeSpatialSkeletonNodeDescriptionUpdate(layer as any, { + node, + nextDescription: "next", + }), + ).toThrow( + "The active skeleton source does not support node description editing.", + ); + }); + it("commits move-node commands using model-space positions", async () => { suppressStatusMessages(); @@ -924,9 +977,7 @@ describe("spatial_skeleton_commands", () => { const skeletonSource = makeEditableSkeletonSource({ getSkeletonRootNode: vi.fn(async () => ({ nodeId: hiddenRootNode.nodeId, - x: hiddenRootNode.position[0], - y: hiddenRootNode.position[1], - z: hiddenRootNode.position[2], + position: hiddenRootNode.position, })), mergeSkeletons: vi.fn(async () => { serverSegments.set(visibleSegmentId, mergedNodes.map(cloneNode)); @@ -1177,9 +1228,7 @@ describe("spatial_skeleton_commands", () => { const skeletonSource = makeEditableSkeletonSource({ getSkeletonRootNode: vi.fn(async () => ({ nodeId: hiddenRootNode.nodeId, - x: hiddenRootNode.position[0], - y: hiddenRootNode.position[1], - z: hiddenRootNode.position[2], + position: hiddenRootNode.position, })), mergeSkeletons: vi.fn(async () => { serverSegments.set(visibleSegmentId, mergedNodes.map(cloneNode)); @@ -1372,9 +1421,7 @@ describe("spatial_skeleton_commands", () => { const skeletonSource = makeEditableSkeletonSource({ getSkeletonRootNode: vi.fn(async () => ({ nodeId: secondRootNode.nodeId, - x: secondRootNode.position[0], - y: secondRootNode.position[1], - z: secondRootNode.position[2], + position: secondRootNode.position, })), mergeSkeletons: vi.fn(async () => ({ resultSegmentId: firstSegmentId, diff --git a/src/layer/segmentation/spatial_skeleton_commands.ts b/src/layer/segmentation/spatial_skeleton_commands.ts index 8f9149e1f..6447b5cd5 100644 --- a/src/layer/segmentation/spatial_skeleton_commands.ts +++ b/src/layer/segmentation/spatial_skeleton_commands.ts @@ -15,16 +15,17 @@ */ import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; +import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; import type { - SpatiallyIndexedSkeletonNode, SpatialSkeletonAddNodeCommandOptions, SpatialSkeletonEditController, + SpatialSkeletonInsertNodeCommandOptions, SpatialSkeletonMergeEndpoint, SpatialSkeletonMoveNodeCommandOptions, SpatialSkeletonNodeDescriptionCommandOptions, SpatialSkeletonNodePropertiesCommandOptions, SpatialSkeletonNodeTrueEndCommandOptions, -} from "#src/skeleton/api.js"; +} from "#src/skeleton/edit_controller.js"; import type { SpatialSkeletonCommand } from "#src/skeleton/command_history.js"; import { getSpatialSkeletonEditController } from "#src/skeleton/spatial_skeleton_manager.js"; import { StatusMessage } from "#src/status.js"; @@ -71,37 +72,47 @@ export function executeSpatialSkeletonAddNode( layer: SegmentationUserLayer, options: SpatialSkeletonAddNodeCommandOptions, ) { - const command = requireCommand( - getController(layer).createAddNodeCommand?.(layer, options), - "The active skeleton source does not support node creation.", - ); return executeCommandWithPendingMessage( - executeCommand(layer, command), + executeCommand( + layer, + getController(layer).createAddNodeCommand(layer, options), + ), "Creating node...", ); } +export function executeSpatialSkeletonInsertNode( + layer: SegmentationUserLayer, + options: SpatialSkeletonInsertNodeCommandOptions, +) { + return executeCommandWithPendingMessage( + executeCommand( + layer, + getController(layer).createInsertNodeCommand(layer, options), + ), + "Inserting node...", + ); +} + export function executeSpatialSkeletonMoveNode( layer: SegmentationUserLayer, options: SpatialSkeletonMoveNodeCommandOptions, ) { - const command = requireCommand( - getController(layer).createMoveNodeCommand?.(layer, options), - "The active skeleton source does not support node movement.", + return executeCommand( + layer, + getController(layer).createMoveNodeCommand(layer, options), ); - return executeCommand(layer, command); } export function executeSpatialSkeletonDeleteNode( layer: SegmentationUserLayer, node: SpatiallyIndexedSkeletonNode, ) { - const command = requireCommand( - getController(layer).createDeleteNodeCommand?.(layer, node), - "The active skeleton source does not support node deletion.", - ); return executeCommandWithPendingMessage( - executeCommand(layer, command), + executeCommand( + layer, + getController(layer).createDeleteNodeCommand(layer, node), + ), "Deleting node...", ); } @@ -157,12 +168,11 @@ export function executeSpatialSkeletonSplit( layer: SegmentationUserLayer, node: Pick, ) { - const command = requireCommand( - getController(layer).createSplitCommand?.(layer, node), - "The active skeleton source does not support skeleton splitting.", - ); return executeCommandWithPendingMessage( - executeCommand(layer, command), + executeCommand( + layer, + getController(layer).createSplitCommand(layer, node), + ), "Splitting skeleton...", ); } @@ -172,12 +182,11 @@ export function executeSpatialSkeletonMerge( firstNode: SpatialSkeletonMergeEndpoint, secondNode: SpatialSkeletonMergeEndpoint, ) { - const command = requireCommand( - getController(layer).createMergeCommand?.(layer, firstNode, secondNode), - "The active skeleton source does not support skeleton merging.", - ); return executeCommandWithPendingMessage( - executeCommand(layer, command), + executeCommand( + layer, + getController(layer).createMergeCommand(layer, firstNode, secondNode), + ), "Merging skeletons...", ); } diff --git a/src/layer/segmentation/spatial_skeleton_errors.ts b/src/layer/segmentation/spatial_skeleton_errors.ts index 38d19736a..c1ec4c4da 100644 --- a/src/layer/segmentation/spatial_skeleton_errors.ts +++ b/src/layer/segmentation/spatial_skeleton_errors.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { SpatialSkeletonEditConflictError } from "#src/skeleton/api.js"; +import { SpatialSkeletonEditConflictError } from "#src/skeleton/edit_errors.js"; import { StatusMessage } from "#src/status.js"; function formatError(error: unknown) { diff --git a/src/skeleton/api.ts b/src/skeleton/api.ts index 87c4c9f1d..178a94a69 100644 --- a/src/skeleton/api.ts +++ b/src/skeleton/api.ts @@ -14,23 +14,23 @@ * limitations under the License. */ -import type { SpatialSkeletonAction } from "#src/skeleton/actions.js"; -import type { SpatialSkeletonCommand } from "#src/skeleton/command_history.js"; +export type SpatialSkeletonVector = ArrayLike; -export class SpatialSkeletonEditConflictError extends Error { - constructor(detail?: string) { - super( - detail ?? - "The skeleton edit could not be applied because the source state is out of date.", - ); - this.name = "SpatialSkeletonEditConflictError"; - } +export interface SpatialSkeletonBounds { + lowerBounds: SpatialSkeletonVector; + upperBounds: SpatialSkeletonVector; +} + +export interface SpatialSkeletonSpatialIndexLevel { + chunkSize: SpatialSkeletonVector; + gridShape: readonly number[]; + limit?: number; } export interface SpatiallyIndexedSkeletonNodeBase { nodeId: number; segmentId: number; - position: Float32Array; + position: SpatialSkeletonVector; parentNodeId?: number; sourceState?: unknown; } @@ -43,170 +43,13 @@ export interface SpatiallyIndexedSkeletonNode isTrueEnd?: boolean; } -export interface SpatiallyIndexedSkeletonOpenLeaf { - nodeId: number; - x: number; - y: number; - z: number; - distance: number; - creationTime?: string; -} - -export interface SpatiallyIndexedSkeletonNavigationTarget { - nodeId: number; - x: number; - y: number; - z: number; -} - -export interface SpatiallyIndexedSkeletonNodeSourceStateUpdate { - nodeId: number; - sourceState: unknown; -} - -export interface SpatiallyIndexedSkeletonEditResult { - nodeSourceStateUpdates?: readonly SpatiallyIndexedSkeletonNodeSourceStateUpdate[]; -} - -export interface SpatiallyIndexedSkeletonAddNodeResult - extends SpatiallyIndexedSkeletonEditResult { - nodeId: number; - segmentId: number; - sourceState?: unknown; - parentSourceState?: unknown; -} - -export type SpatiallyIndexedSkeletonInsertNodeResult = - SpatiallyIndexedSkeletonAddNodeResult; - -export interface SpatiallyIndexedSkeletonNodeSourceStateResult - extends SpatiallyIndexedSkeletonEditResult { - sourceState?: unknown; -} - -export interface SpatiallyIndexedSkeletonDescriptionUpdateResult - extends SpatiallyIndexedSkeletonNodeSourceStateResult { - description?: string; -} - -export type SpatiallyIndexedSkeletonDeleteNodeResult = - SpatiallyIndexedSkeletonEditResult; - -export type SpatiallyIndexedSkeletonRerootResult = - SpatiallyIndexedSkeletonEditResult; - -export interface SpatiallyIndexedSkeletonMergeResult - extends SpatiallyIndexedSkeletonEditResult { - resultSegmentId: number | undefined; - deletedSegmentId: number | undefined; - directionAdjusted: boolean; -} - -export interface SpatiallyIndexedSkeletonSplitResult - extends SpatiallyIndexedSkeletonEditResult { - existingSegmentId: number | undefined; - newSegmentId: number | undefined; -} - -export interface SpatiallyIndexedSkeletonMetadata { - bounds: { - min: { x: number; y: number; z: number }; - max: { x: number; y: number; z: number }; - }; - resolution: { x: number; y: number; z: number }; - gridCellSizes: Array<{ x: number; y: number; z: number }>; -} - -export interface SpatialSkeletonNodeFeatureCapabilities { - description?: boolean; - trueEnd?: boolean; - radius?: boolean; - confidenceValues?: readonly number[]; -} - -export interface SpatialSkeletonEditCapabilities { - nodeFeatures?: SpatialSkeletonNodeFeatureCapabilities; -} - -export interface SpatialSkeletonAddNodeCommandOptions { - skeletonId: number; - parentNodeId: number | undefined; - positionInModelSpace: Float32Array; -} - -export interface SpatialSkeletonMoveNodeCommandOptions { - node: SpatiallyIndexedSkeletonNode; - nextPositionInModelSpace: Float32Array; -} - -export interface SpatialSkeletonNodeDescriptionCommandOptions { - node: SpatiallyIndexedSkeletonNode; - nextDescription?: string; -} - -export interface SpatialSkeletonNodeTrueEndCommandOptions { - node: SpatiallyIndexedSkeletonNode; - nextIsTrueEnd: boolean; -} - -export interface SpatialSkeletonNodePropertiesCommandOptions { - node: SpatiallyIndexedSkeletonNode; - next: { radius: number; confidence: number }; -} - -export interface SpatialSkeletonMergeEndpoint { - nodeId: number; - segmentId: number; - sourceState?: unknown; -} - -export interface SpatialSkeletonEditController { - readonly capabilities?: SpatialSkeletonEditCapabilities; - supports(action: SpatialSkeletonAction): boolean; - createAddNodeCommand?( - layer: any, - options: SpatialSkeletonAddNodeCommandOptions, - ): SpatialSkeletonCommand; - createMoveNodeCommand?( - layer: any, - options: SpatialSkeletonMoveNodeCommandOptions, - ): SpatialSkeletonCommand; - createDeleteNodeCommand?( - layer: any, - node: SpatiallyIndexedSkeletonNode, - ): SpatialSkeletonCommand; - createNodeDescriptionCommand?( - layer: any, - options: SpatialSkeletonNodeDescriptionCommandOptions, - ): SpatialSkeletonCommand; - createNodeTrueEndCommand?( - layer: any, - options: SpatialSkeletonNodeTrueEndCommandOptions, - ): SpatialSkeletonCommand; - createNodePropertiesCommand?( - layer: any, - options: SpatialSkeletonNodePropertiesCommandOptions, - ): SpatialSkeletonCommand; - createRerootCommand?( - layer: any, - node: Pick< - SpatiallyIndexedSkeletonNode, - "nodeId" | "segmentId" | "parentNodeId" - >, - ): SpatialSkeletonCommand; - createSplitCommand?( - layer: any, - node: Pick, - ): SpatialSkeletonCommand; - createMergeCommand?( - layer: any, - firstNode: SpatialSkeletonMergeEndpoint, - secondNode: SpatialSkeletonMergeEndpoint, - ): SpatialSkeletonCommand; +export interface SpatiallyIndexedSkeletonMetadata + extends SpatialSkeletonBounds { + spatial: readonly SpatialSkeletonSpatialIndexLevel[]; } export interface SpatiallyIndexedSkeletonSource { - readonly spatialSkeletonEditController?: SpatialSkeletonEditController; + readonly spatialSkeletonEditController?: unknown; listSkeletons(): Promise; getSkeleton( skeletonId: number, @@ -214,84 +57,10 @@ export interface SpatiallyIndexedSkeletonSource { ): Promise; getSpatialIndexMetadata(): Promise; fetchNodes( - boundingBox: { - min: { x: number; y: number; z: number }; - max: { x: number; y: number; z: number }; - }, + bounds: SpatialSkeletonBounds, lod?: number, options?: { signal?: AbortSignal; }, ): Promise; } - -export interface EditableSpatiallyIndexedSkeletonSource - extends SpatiallyIndexedSkeletonSource { - getSkeletonRootNode( - skeletonId: number, - ): Promise; - addNode( - skeletonId: number, - x: number, - y: number, - z: number, - parentId?: number, - editContext?: unknown, - ): Promise; - insertNode( - skeletonId: number, - x: number, - y: number, - z: number, - parentId: number, - childNodeIds: readonly number[], - editContext?: unknown, - ): Promise; - moveNode( - nodeId: number, - x: number, - y: number, - z: number, - editContext?: unknown, - ): Promise; - deleteNode( - nodeId: number, - options: { - childNodeIds?: readonly number[]; - editContext?: unknown; - }, - ): Promise; - rerootSkeleton?( - nodeId: number, - editContext?: unknown, - ): Promise; - updateDescription( - nodeId: number, - description: string, - ): Promise; - setTrueEnd( - nodeId: number, - ): Promise; - removeTrueEnd( - nodeId: number, - ): Promise; - updateRadius( - nodeId: number, - radius: number, - editContext?: unknown, - ): Promise; - updateConfidence( - nodeId: number, - confidence: number, - editContext?: unknown, - ): Promise; - mergeSkeletons( - fromNodeId: number, - toNodeId: number, - editContext?: unknown, - ): Promise; - splitSkeleton( - nodeId: number, - editContext?: unknown, - ): Promise; -} diff --git a/src/skeleton/edit_controller.ts b/src/skeleton/edit_controller.ts new file mode 100644 index 000000000..76252dba0 --- /dev/null +++ b/src/skeleton/edit_controller.ts @@ -0,0 +1,121 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { SpatialSkeletonAction } from "#src/skeleton/actions.js"; +import type { + SpatiallyIndexedSkeletonNode, + SpatialSkeletonVector, +} from "#src/skeleton/api.js"; +import type { SpatialSkeletonCommand } from "#src/skeleton/command_history.js"; + +export interface SpatialSkeletonNodeFeatureCapabilities { + description?: boolean; + trueEnd?: boolean; + radius?: boolean; + confidenceValues?: readonly number[]; +} + +export interface SpatialSkeletonEditCapabilities { + nodeFeatures?: SpatialSkeletonNodeFeatureCapabilities; +} + +export interface SpatialSkeletonAddNodeCommandOptions { + skeletonId: number; + parentNodeId: number | undefined; + positionInModelSpace: SpatialSkeletonVector; +} + +export interface SpatialSkeletonInsertNodeCommandOptions { + skeletonId: number; + parentNodeId: number; + childNodeIds: readonly number[]; + positionInModelSpace: SpatialSkeletonVector; +} + +export interface SpatialSkeletonMoveNodeCommandOptions { + node: SpatiallyIndexedSkeletonNode; + nextPositionInModelSpace: SpatialSkeletonVector; +} + +export interface SpatialSkeletonNodeDescriptionCommandOptions { + node: SpatiallyIndexedSkeletonNode; + nextDescription?: string; +} + +export interface SpatialSkeletonNodeTrueEndCommandOptions { + node: SpatiallyIndexedSkeletonNode; + nextIsTrueEnd: boolean; +} + +export interface SpatialSkeletonNodePropertiesCommandOptions { + node: SpatiallyIndexedSkeletonNode; + next: { radius: number; confidence: number }; +} + +export interface SpatialSkeletonMergeEndpoint { + nodeId: number; + segmentId: number; + sourceState?: unknown; +} + +export interface SpatialSkeletonEditController { + readonly capabilities?: SpatialSkeletonEditCapabilities; + supports(action: SpatialSkeletonAction): boolean; + createAddNodeCommand( + layer: any, + options: SpatialSkeletonAddNodeCommandOptions, + ): SpatialSkeletonCommand; + createInsertNodeCommand( + layer: any, + options: SpatialSkeletonInsertNodeCommandOptions, + ): SpatialSkeletonCommand; + createMoveNodeCommand( + layer: any, + options: SpatialSkeletonMoveNodeCommandOptions, + ): SpatialSkeletonCommand; + createDeleteNodeCommand( + layer: any, + node: SpatiallyIndexedSkeletonNode, + ): SpatialSkeletonCommand; + createNodeDescriptionCommand?( + layer: any, + options: SpatialSkeletonNodeDescriptionCommandOptions, + ): SpatialSkeletonCommand; + createNodeTrueEndCommand?( + layer: any, + options: SpatialSkeletonNodeTrueEndCommandOptions, + ): SpatialSkeletonCommand; + createNodePropertiesCommand?( + layer: any, + options: SpatialSkeletonNodePropertiesCommandOptions, + ): SpatialSkeletonCommand; + createRerootCommand?( + layer: any, + node: Pick< + SpatiallyIndexedSkeletonNode, + "nodeId" | "segmentId" | "parentNodeId" + >, + ): SpatialSkeletonCommand; + createSplitCommand( + layer: any, + node: Pick, + ): SpatialSkeletonCommand; + createMergeCommand( + layer: any, + firstNode: SpatialSkeletonMergeEndpoint, + secondNode: SpatialSkeletonMergeEndpoint, + ): SpatialSkeletonCommand; +} diff --git a/src/skeleton/edit_errors.ts b/src/skeleton/edit_errors.ts new file mode 100644 index 000000000..d33e11508 --- /dev/null +++ b/src/skeleton/edit_errors.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export class SpatialSkeletonEditConflictError extends Error { + constructor(detail?: string) { + super( + detail ?? + "The skeleton edit could not be applied because the source state is out of date.", + ); + this.name = "SpatialSkeletonEditConflictError"; + } +} diff --git a/src/skeleton/navigation.ts b/src/skeleton/navigation.ts index bd8ba5105..425806949 100644 --- a/src/skeleton/navigation.ts +++ b/src/skeleton/navigation.ts @@ -15,11 +15,21 @@ */ import type { - SpatiallyIndexedSkeletonNavigationTarget, SpatiallyIndexedSkeletonNode, - SpatiallyIndexedSkeletonOpenLeaf, + SpatialSkeletonVector, } from "#src/skeleton/api.js"; +export interface SpatiallyIndexedSkeletonNavigationTarget { + nodeId: number; + position: SpatialSkeletonVector; +} + +export interface SpatiallyIndexedSkeletonOpenLeaf + extends SpatiallyIndexedSkeletonNavigationTarget { + distance: number; + creationTime?: string; +} + export interface SpatiallyIndexedSkeletonNavigationGraph { nodeById: Map; childrenByParent: Map; @@ -243,9 +253,7 @@ function getNodeTarget( const node = getNodeOrThrow(graph, nodeId); return { nodeId: node.nodeId, - x: Number(node.position[0]), - y: Number(node.position[1]), - z: Number(node.position[2]), + position: node.position, }; } diff --git a/src/skeleton/spatial_chunk_sizing.spec.ts b/src/skeleton/spatial_chunk_sizing.spec.ts index 5a2cf9d08..9398b7c98 100644 --- a/src/skeleton/spatial_chunk_sizing.spec.ts +++ b/src/skeleton/spatial_chunk_sizing.spec.ts @@ -6,47 +6,56 @@ describe("skeleton/spatial_chunk_sizing", () => { it("derives an isotropic chunk size that stays within the default chunk budget", () => { expect( getDefaultSpatiallyIndexedSkeletonChunkSize({ - min: { x: 5, y: 6, z: 7 }, - max: { x: 25, y: 66, z: 127 }, + lowerBounds: [5, 6, 7], + upperBounds: [25, 66, 127], }), - ).toEqual({ x: 15, y: 15, z: 15 }); + ).toEqual([15, 15, 15]); }); it("handles elongated bounds while keeping the chunk size isotropic", () => { expect( getDefaultSpatiallyIndexedSkeletonChunkSize({ - min: { x: 0, y: 0, z: 0 }, - max: { x: 1000, y: 10, z: 10 }, + lowerBounds: [0, 0, 0], + upperBounds: [1000, 10, 10], }), - ).toEqual({ x: 16, y: 16, z: 16 }); + ).toEqual([16, 16, 16]); }); it("returns the minimum chunk size for tiny bounds", () => { expect( getDefaultSpatiallyIndexedSkeletonChunkSize({ - min: { x: 0, y: 0, z: 0 }, - max: { x: 2, y: 2, z: 2 }, + lowerBounds: [0, 0, 0], + upperBounds: [2, 2, 2], }), - ).toEqual({ x: 1, y: 1, z: 1 }); + ).toEqual([1, 1, 1]); + }); + + it("returns a chunk-size array with the same rank as the bounds", () => { + expect( + getDefaultSpatiallyIndexedSkeletonChunkSize({ + lowerBounds: [0, 0, 0, 0], + upperBounds: [16, 32, 48, 2], + }), + ).toEqual([8, 8, 8, 8]); }); it("supports overriding the chunk budget", () => { expect( getDefaultSpatiallyIndexedSkeletonChunkSize( { - min: { x: 0, y: 0, z: 0 }, - max: { x: 100, y: 100, z: 100 }, + lowerBounds: [0, 0, 0], + upperBounds: [100, 100, 100], }, { maxChunks: 8 }, ), - ).toEqual({ x: 50, y: 50, z: 50 }); + ).toEqual([50, 50, 50]); }); it("rejects NaN bounds", () => { expect(() => getDefaultSpatiallyIndexedSkeletonChunkSize({ - min: { x: Number.NaN, y: 0, z: 0 }, - max: { x: 10, y: 10, z: 10 }, + lowerBounds: [Number.NaN, 0, 0], + upperBounds: [10, 10, 10], }), ).toThrow(/bounds must be finite/i); }); @@ -54,18 +63,27 @@ describe("skeleton/spatial_chunk_sizing", () => { it("rejects infinite bounds", () => { expect(() => getDefaultSpatiallyIndexedSkeletonChunkSize({ - min: { x: 0, y: 0, z: 0 }, - max: { x: Number.POSITIVE_INFINITY, y: 10, z: 10 }, + lowerBounds: [0, 0, 0], + upperBounds: [Number.POSITIVE_INFINITY, 10, 10], }), ).toThrow(/bounds must be finite/i); }); + it("rejects mismatched lower/upper bound ranks", () => { + expect(() => + getDefaultSpatiallyIndexedSkeletonChunkSize({ + lowerBounds: [0, 0], + upperBounds: [10, 10, 10], + }), + ).toThrow(/matching ranks/i); + }); + it("rejects NaN minChunkSize", () => { expect(() => getDefaultSpatiallyIndexedSkeletonChunkSize( { - min: { x: 0, y: 0, z: 0 }, - max: { x: 10, y: 10, z: 10 }, + lowerBounds: [0, 0, 0], + upperBounds: [10, 10, 10], }, { minChunkSize: Number.NaN }, ), @@ -76,8 +94,8 @@ describe("skeleton/spatial_chunk_sizing", () => { expect(() => getDefaultSpatiallyIndexedSkeletonChunkSize( { - min: { x: 0, y: 0, z: 0 }, - max: { x: 10, y: 10, z: 10 }, + lowerBounds: [0, 0, 0], + upperBounds: [10, 10, 10], }, { maxChunks: Number.POSITIVE_INFINITY }, ), diff --git a/src/skeleton/spatial_chunk_sizing.ts b/src/skeleton/spatial_chunk_sizing.ts index 584f15d8a..dd93f70a4 100644 --- a/src/skeleton/spatial_chunk_sizing.ts +++ b/src/skeleton/spatial_chunk_sizing.ts @@ -14,19 +14,15 @@ * limitations under the License. */ +import type { + SpatialSkeletonBounds, + SpatialSkeletonVector, +} from "#src/skeleton/api.js"; + const DEFAULT_SPATIALLY_INDEXED_SKELETON_MAX_CHUNKS = 64; const DEFAULT_SPATIALLY_INDEXED_SKELETON_MIN_CHUNK_SIZE = 1; -export interface SpatiallyIndexedSkeletonBounds { - min: { x: number; y: number; z: number }; - max: { x: number; y: number; z: number }; -} - -export interface SpatiallyIndexedSkeletonChunkSize { - x: number; - y: number; - z: number; -} +export type SpatiallyIndexedSkeletonChunkSize = number[]; export interface DefaultSpatiallyIndexedSkeletonChunkSizeOptions { maxChunks?: number; @@ -47,24 +43,30 @@ function validateFiniteOptions( } } -function validateFiniteBounds(bounds: SpatiallyIndexedSkeletonBounds) { - const values = [ - ["min.x", bounds.min.x], - ["min.y", bounds.min.y], - ["min.z", bounds.min.z], - ["max.x", bounds.max.x], - ["max.y", bounds.max.y], - ["max.z", bounds.max.z], - ] as const; - for (const [name, value] of values) { +function validateFiniteVector(vector: SpatialSkeletonVector, label: string) { + for (let i = 0; i < vector.length; ++i) { + const value = Number(vector[i]); if (!Number.isFinite(value)) { throw new Error( - `Spatially indexed skeleton bounds must be finite, but ${name} is ${value}.`, + `Spatially indexed skeleton bounds must be finite, but ${label}[${i}] is ${value}.`, ); } } } +function validateFiniteBounds(bounds: SpatialSkeletonBounds) { + if (bounds.lowerBounds.length !== bounds.upperBounds.length) { + throw new Error( + "Spatially indexed skeleton lower and upper bounds must have matching ranks.", + ); + } + if (bounds.lowerBounds.length === 0) { + throw new Error("Spatially indexed skeleton bounds must have rank > 0."); + } + validateFiniteVector(bounds.lowerBounds, "lowerBounds"); + validateFiniteVector(bounds.upperBounds, "upperBounds"); +} + function getChunkCoverageForChunkSize( extents: readonly number[], chunkSize: number, @@ -76,7 +78,7 @@ function getChunkCoverageForChunkSize( } export function getDefaultSpatiallyIndexedSkeletonChunkSize( - bounds: SpatiallyIndexedSkeletonBounds, + bounds: SpatialSkeletonBounds, options: DefaultSpatiallyIndexedSkeletonChunkSizeOptions = {}, ): SpatiallyIndexedSkeletonChunkSize { validateFiniteOptions(options); @@ -93,15 +95,13 @@ export function getDefaultSpatiallyIndexedSkeletonChunkSize( options.maxChunks ?? DEFAULT_SPATIALLY_INDEXED_SKELETON_MAX_CHUNKS, ), ); - const extents = [ - Math.max(0, bounds.max.x - bounds.min.x), - Math.max(0, bounds.max.y - bounds.min.y), - Math.max(0, bounds.max.z - bounds.min.z), - ] as const; + const extents = Array.from(bounds.lowerBounds, (lowerBound, index) => + Math.max(0, Number(bounds.upperBounds[index]) - Number(lowerBound)), + ); const maxExtent = Math.max(...extents); if (!(maxExtent > 0)) { - return { x: minChunkSize, y: minChunkSize, z: minChunkSize }; + return extents.map(() => minChunkSize); } // Choose the smallest isotropic chunk size that keeps the full bounding box @@ -117,5 +117,5 @@ export function getDefaultSpatiallyIndexedSkeletonChunkSize( } } - return { x: low, y: low, z: low }; + return extents.map(() => low); } diff --git a/src/skeleton/spatial_skeleton_manager.spec.ts b/src/skeleton/spatial_skeleton_manager.spec.ts index 150a0dc83..467b030b6 100644 --- a/src/skeleton/spatial_skeleton_manager.spec.ts +++ b/src/skeleton/spatial_skeleton_manager.spec.ts @@ -22,48 +22,73 @@ import { getSkeletonRootNode, } from "#src/skeleton/navigation.js"; import { - isEditableSpatiallyIndexedSkeletonSource, + getSpatialSkeletonEditController, SpatialSkeletonState, } from "#src/skeleton/spatial_skeleton_manager.js"; +function makeRequiredEditControllerFactories() { + const createCommand = () => ({ + label: "test command", + execute: vi.fn(), + undo: vi.fn(), + redo: vi.fn(), + }); + return { + createAddNodeCommand: createCommand, + createInsertNodeCommand: createCommand, + createMoveNodeCommand: createCommand, + createDeleteNodeCommand: createCommand, + createSplitCommand: createCommand, + createMergeCommand: createCommand, + }; +} + describe("skeleton/spatial_skeleton_manager", () => { - it("does not require reroot support for editable sources", () => { - const editableSource = { + it("returns a source edit controller when all required factories are present", () => { + const controller = { + supports: () => true, + ...makeRequiredEditControllerFactories(), + }; + const source = { + spatialSkeletonEditController: controller, + listSkeletons: async () => [], + getSkeleton: async () => [], + fetchNodes: async () => [], + getSpatialIndexMetadata: async () => null, + }; + + expect(getSpatialSkeletonEditController({ source })).toBe(controller); + }); + + it("does not treat a partial controller as editable", () => { + const source = { spatialSkeletonEditController: { supports: () => true, + createMoveNodeCommand: vi.fn(), }, listSkeletons: async () => [], getSkeleton: async () => [], fetchNodes: async () => [], getSpatialIndexMetadata: async () => null, - getSkeletonRootNode: async () => ({ nodeId: 1, x: 1, y: 2, z: 3 }), - addNode: async () => ({ nodeId: 1, segmentId: 1 }), - insertNode: async () => ({ nodeId: 1, segmentId: 1 }), - moveNode: async () => {}, - deleteNode: async () => {}, - updateDescription: async () => {}, - setTrueEnd: async () => {}, - removeTrueEnd: async () => {}, - updateRadius: async () => {}, - updateConfidence: async () => {}, - mergeSkeletons: async () => ({ - resultSegmentId: 1, - deletedSegmentId: 2, - directionAdjusted: false, - }), - splitSkeleton: async () => ({ - existingSegmentId: 1, - newSegmentId: 2, - }), }; - expect(isEditableSpatiallyIndexedSkeletonSource(editableSource)).toBe(true); - expect( - isEditableSpatiallyIndexedSkeletonSource({ - ...editableSource, - rerootSkeleton: async () => {}, - }), - ).toBe(true); + expect(getSpatialSkeletonEditController({ source })).toBeUndefined(); + }); + + it("does not require optional edit factories for controller validation", () => { + const controller = { + supports: () => false, + ...makeRequiredEditControllerFactories(), + }; + const source = { + spatialSkeletonEditController: controller, + listSkeletons: async () => [], + getSkeleton: async () => [], + fetchNodes: async () => [], + getSpatialIndexMetadata: async () => null, + }; + + expect(getSpatialSkeletonEditController({ source })).toBe(controller); }); it("clears the full skeleton cache before notifying node data listeners", () => { diff --git a/src/skeleton/spatial_skeleton_manager.ts b/src/skeleton/spatial_skeleton_manager.ts index 3805a0334..d4998afec 100644 --- a/src/skeleton/spatial_skeleton_manager.ts +++ b/src/skeleton/spatial_skeleton_manager.ts @@ -15,13 +15,11 @@ */ import type { - EditableSpatiallyIndexedSkeletonSource, SpatiallyIndexedSkeletonNode, - SpatiallyIndexedSkeletonNodeSourceStateUpdate, - SpatialSkeletonEditController, SpatiallyIndexedSkeletonSource, } from "#src/skeleton/api.js"; import { SpatialSkeletonCommandHistory } from "#src/skeleton/command_history.js"; +import type { SpatialSkeletonEditController } from "#src/skeleton/edit_controller.js"; import type { SpatiallyIndexedSkeletonLayer } from "#src/skeleton/frontend.js"; import { WatchableValue } from "#src/trackable_value.js"; import { RefCounted } from "#src/util/disposable.js"; @@ -52,14 +50,19 @@ export function isSpatiallyIndexedSkeletonSource( ); } -export function isEditableSpatiallyIndexedSkeletonSource( +function isSpatialSkeletonEditController( value: unknown, -): value is EditableSpatiallyIndexedSkeletonSource { +): value is SpatialSkeletonEditController { return ( - isSpatiallyIndexedSkeletonSource(value) && - typeof value.spatialSkeletonEditController === "object" && - value.spatialSkeletonEditController !== null && - hasFunction(value.spatialSkeletonEditController, "supports") + typeof value === "object" && + value !== null && + hasFunction(value, "supports") && + hasFunction(value, "createAddNodeCommand") && + hasFunction(value, "createInsertNodeCommand") && + hasFunction(value, "createMoveNodeCommand") && + hasFunction(value, "createDeleteNodeCommand") && + hasFunction(value, "createSplitCommand") && + hasFunction(value, "createMergeCommand") ); } @@ -72,20 +75,12 @@ export function getSpatiallyIndexedSkeletonSource( : undefined; } -export function getEditableSpatiallyIndexedSkeletonSource( - value: SpatialSkeletonSourceAccess | undefined, -): EditableSpatiallyIndexedSkeletonSource | undefined { - if (value === undefined) return undefined; - return isEditableSpatiallyIndexedSkeletonSource(value.source) - ? value.source - : undefined; -} - export function getSpatialSkeletonEditController( value: SpatialSkeletonSourceAccess | undefined, ): SpatialSkeletonEditController | undefined { const source = getSpatiallyIndexedSkeletonSource(value); - return source?.spatialSkeletonEditController; + const controller = source?.spatialSkeletonEditController; + return isSpatialSkeletonEditController(controller) ? controller : undefined; } export function normalizeSpatiallyIndexedSkeletonNode( @@ -391,7 +386,10 @@ export class SpatialSkeletonState extends RefCounted { } setCachedNodeSourceStates( - sourceStateUpdates: readonly SpatiallyIndexedSkeletonNodeSourceStateUpdate[], + sourceStateUpdates: readonly { + nodeId: number; + sourceState: unknown; + }[], ) { let changed = false; for (const update of sourceStateUpdates) { diff --git a/src/ui/spatial_skeleton_edit_tab.ts b/src/ui/spatial_skeleton_edit_tab.ts index a8874ec3a..a2ff26148 100644 --- a/src/ui/spatial_skeleton_edit_tab.ts +++ b/src/ui/spatial_skeleton_edit_tab.ts @@ -44,9 +44,7 @@ import { type SpatialSkeletonAction, } from "#src/skeleton/actions.js"; import type { - SpatiallyIndexedSkeletonNavigationTarget, SpatiallyIndexedSkeletonNode, - SpatiallyIndexedSkeletonOpenLeaf, } from "#src/skeleton/api.js"; import { buildSpatiallyIndexedSkeletonNavigationGraph, @@ -57,6 +55,8 @@ import { getOpenLeaves as getOpenLeavesFromGraph, getParentNode as getParentNodeFromGraph, getSkeletonRootNode as getSkeletonRootNodeFromGraph, + type SpatiallyIndexedSkeletonNavigationTarget, + type SpatiallyIndexedSkeletonOpenLeaf, type SpatiallyIndexedSkeletonNavigationGraph, } from "#src/skeleton/navigation.js"; import { @@ -637,12 +637,9 @@ export class SpatialSkeletonEditTab extends Tab { }, }; - const navigateToNodeTarget = (target: { - nodeId: number; - x: number; - y: number; - z: number; - }) => { + const navigateToNodeTarget = ( + target: SpatiallyIndexedSkeletonNavigationTarget, + ) => { const existingNode = allNodes.find( (node) => node.nodeId === target.nodeId, ); @@ -651,7 +648,7 @@ export class SpatialSkeletonEditTab extends Tab { return; } pendingScrollToSelectedNode = true; - const position = [target.x, target.y, target.z]; + const position = target.position; layer.selectSpatialSkeletonNode(target.nodeId, true, { position }); moveViewToNodePosition(position); updateDisplay(); From 128872e18551f4ac0c379a78fe9500f298ad7e4f Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Mon, 4 May 2026 17:26:09 +0100 Subject: [PATCH 03/11] refactor: WIP - Tight boundaries between generic spatial skeleton and specific implementation --- src/datasource/catmaid/api.spec.ts | 4 +- src/datasource/catmaid/api.ts | 81 +-- src/datasource/catmaid/frontend.ts | 42 +- src/datasource/catmaid/skeleton_packing.ts | 11 +- .../catmaid/spatial_skeleton_commands.ts | 491 +++++++++++++++--- src/layer/index.ts | 3 +- src/layer/segmentation/index.spec.ts | 23 +- src/layer/segmentation/index.ts | 42 +- .../spatial_skeleton_commands.spec.ts | 155 +++++- .../segmentation/spatial_skeleton_commands.ts | 200 ++++--- src/rendered_data_panel.ts | 3 +- src/skeleton/api.ts | 44 +- src/skeleton/backend.ts | 3 +- src/skeleton/edit_controller.ts | 121 ----- src/skeleton/frontend.ts | 9 +- src/skeleton/skeleton_chunk_serialization.ts | 3 +- src/skeleton/spatial_skeleton_manager.spec.ts | 54 +- src/skeleton/spatial_skeleton_manager.ts | 41 +- src/ui/spatial_skeleton_edit_tool.spec.ts | 8 +- src/ui/spatial_skeleton_edit_tool.ts | 12 +- 20 files changed, 909 insertions(+), 441 deletions(-) delete mode 100644 src/skeleton/edit_controller.ts diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index cfc1af7b6..cbc142fff 100644 --- a/src/datasource/catmaid/api.spec.ts +++ b/src/datasource/catmaid/api.spec.ts @@ -755,10 +755,10 @@ describe("CatmaidClient skeleton editing methods", () => { .mockResolvedValueOnce({ edition_time: "2026-03-29T13:11:00Z" }); (client as any).fetch = fetchMock; - await expect(client.setTrueEnd(11)).resolves.toEqual({ + await expect(client.toggleTrueEnd(11, true)).resolves.toEqual({ sourceState: testSourceState("2026-03-29T13:10:00Z"), }); - await expect(client.removeTrueEnd(11)).resolves.toEqual({ + await expect(client.toggleTrueEnd(11, false)).resolves.toEqual({ sourceState: testSourceState("2026-03-29T13:11:00Z"), }); diff --git a/src/datasource/catmaid/api.ts b/src/datasource/catmaid/api.ts index a22f2576c..fb2f7f0d7 100644 --- a/src/datasource/catmaid/api.ts +++ b/src/datasource/catmaid/api.ts @@ -19,6 +19,7 @@ import { fetchOkWithCredentials } from "#src/credentials_provider/http_request.j import type { CredentialsProvider } from "#src/credentials_provider/index.js"; import type { SpatialSkeletonBounds, + SpatialSkeletonSourceState, SpatialSkeletonVector, SpatiallyIndexedSkeletonMetadata, SpatiallyIndexedSkeletonNode, @@ -50,9 +51,7 @@ const CATMAID_STATE_MATCHING_ERROR_TYPE = "StateMatchingError"; type CatmaidStatePayload = object; -export interface CatmaidNodeSourceState { - revisionToken: string; -} +export type CatmaidNodeSourceState = { readonly revisionToken: string }; export interface CatmaidEditNodeContext { nodeId: number; @@ -74,7 +73,7 @@ export interface CatmaidEditContext { export interface CatmaidSkeletonNodeSourceStateUpdate { nodeId: number; - sourceState: unknown; + sourceState: SpatialSkeletonSourceState; } export interface CatmaidSkeletonEditResult { @@ -84,15 +83,15 @@ export interface CatmaidSkeletonEditResult { export interface CatmaidAddNodeResult extends CatmaidSkeletonEditResult { nodeId: number; segmentId: number; - sourceState?: unknown; - parentSourceState?: unknown; + sourceState?: SpatialSkeletonSourceState; + parentSourceState?: SpatialSkeletonSourceState; } export type CatmaidInsertNodeResult = CatmaidAddNodeResult; export interface CatmaidNodeSourceStateResult extends CatmaidSkeletonEditResult { - sourceState?: unknown; + sourceState?: SpatialSkeletonSourceState; } export interface CatmaidDescriptionUpdateResult @@ -127,30 +126,40 @@ export interface CatmaidSpatialSkeletonEditApi { parentId?: number, editContext?: CatmaidEditContext, ): Promise; - insertNode( - skeletonId: number, + deleteNode( + nodeId: number, + options: CatmaidDeleteNodeOptions, + ): Promise; + moveNode( + nodeId: number, x: number, y: number, z: number, - parentId: number, - childNodeIds: readonly number[], editContext?: CatmaidEditContext, - ): Promise; - moveNode( + ): Promise; + splitSkeleton( nodeId: number, + editContext?: CatmaidEditContext, + ): Promise; + mergeSkeletons( + fromNodeId: number, + toNodeId: number, + editContext?: CatmaidEditContext, + ): Promise; + toggleTrueEnd( + nodeId: number, + nextIsTrueEnd: boolean, + ): Promise; + insertNode( + skeletonId: number, x: number, y: number, z: number, + parentId: number, + childNodeIds: readonly number[], editContext?: CatmaidEditContext, - ): Promise; - deleteNode( - nodeId: number, - options: { - childNodeIds?: readonly number[]; - editContext?: CatmaidEditContext; - }, - ): Promise; - rerootSkeleton?( + ): Promise; + rerootSkeleton( nodeId: number, editContext?: CatmaidEditContext, ): Promise; @@ -158,8 +167,6 @@ export interface CatmaidSpatialSkeletonEditApi { nodeId: number, description: string, ): Promise; - setTrueEnd(nodeId: number): Promise; - removeTrueEnd(nodeId: number): Promise; updateRadius( nodeId: number, radius: number, @@ -170,15 +177,6 @@ export interface CatmaidSpatialSkeletonEditApi { confidence: number, editContext?: CatmaidEditContext, ): Promise; - mergeSkeletons( - fromNodeId: number, - toNodeId: number, - editContext?: CatmaidEditContext, - ): Promise; - splitSkeleton( - nodeId: number, - editContext?: CatmaidEditContext, - ): Promise; } interface CatmaidDeleteNodeOptions { @@ -1742,20 +1740,33 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { }; } - async setTrueEnd(nodeId: number): Promise { + private async addTrueEndLabel( + nodeId: number, + ): Promise { const response = await this.addNodeLabel(nodeId, CATMAID_TRUE_END_LABEL); return getCatmaidSingleNodeRevisionResult( normalizeCatmaidRevisionToken((response as any)?.edition_time), ); } - async removeTrueEnd(nodeId: number): Promise { + private async removeTrueEndLabel( + nodeId: number, + ): Promise { const response = await this.removeNodeLabel(nodeId, CATMAID_TRUE_END_LABEL); return getCatmaidSingleNodeRevisionResult( normalizeCatmaidRevisionToken((response as any)?.edition_time), ); } + toggleTrueEnd( + nodeId: number, + nextIsTrueEnd: boolean, + ): Promise { + return nextIsTrueEnd + ? this.addTrueEndLabel(nodeId) + : this.removeTrueEndLabel(nodeId); + } + async updateRadius( nodeId: number, radius: number, diff --git a/src/datasource/catmaid/frontend.ts b/src/datasource/catmaid/frontend.ts index 19582b09b..998616bbf 100644 --- a/src/datasource/catmaid/frontend.ts +++ b/src/datasource/catmaid/frontend.ts @@ -35,13 +35,17 @@ import type { CatmaidSpatialSkeletonEditApi, CatmaidToken, } from "#src/datasource/catmaid/api.js"; -import { CatmaidClient, credentialsKey } from "#src/datasource/catmaid/api.js"; +import { + CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES, + CatmaidClient, + credentialsKey, +} from "#src/datasource/catmaid/api.js"; import { CatmaidSkeletonSourceParameters, CatmaidCompleteSkeletonSourceParameters, CatmaidDataSourceParameters, } from "#src/datasource/catmaid/base.js"; -import { CatmaidSpatialSkeletonEditController } from "#src/datasource/catmaid/spatial_skeleton_commands.js"; +import { CatmaidSpatialSkeletonEditCommandSource } from "#src/datasource/catmaid/spatial_skeleton_commands.js"; import type { DataSource, DataSourceProvider, @@ -52,6 +56,7 @@ import { normalizeInlineSegmentPropertyMap, } from "#src/segmentation_display_state/property_map.js"; import type { + EditableSpatiallyIndexedSkeletonSource, SpatialSkeletonBounds, SpatiallyIndexedSkeletonMetadata, SpatiallyIndexedSkeletonNode, @@ -76,10 +81,20 @@ export class CatmaidSpatiallyIndexedSkeletonSource WithCredentialsProvider()(SpatiallyIndexedSkeletonSource), CatmaidSkeletonSourceParameters, ) - implements CatmaidSpatialSkeletonEditApi + implements + CatmaidSpatialSkeletonEditApi, + EditableSpatiallyIndexedSkeletonSource { - readonly spatialSkeletonEditController = - new CatmaidSpatialSkeletonEditController(); + readonly spatialSkeletonEditCapabilities = { + nodeFeatures: { + description: true, + trueEnd: true, + radius: true, + confidenceValues: CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES, + }, + }; + readonly spatialSkeletonEditCommandSource = + new CatmaidSpatialSkeletonEditCommandSource(); private client_?: CatmaidClient; private get client() { @@ -189,12 +204,11 @@ export class CatmaidSpatiallyIndexedSkeletonSource return this.client.updateDescription(nodeId, description); } - setTrueEnd(nodeId: number): Promise { - return this.client.setTrueEnd(nodeId); - } - - removeTrueEnd(nodeId: number): Promise { - return this.client.removeTrueEnd(nodeId); + toggleTrueEnd( + nodeId: number, + nextIsTrueEnd: boolean, + ): Promise { + return this.client.toggleTrueEnd(nodeId, nextIsTrueEnd); } updateRadius( @@ -431,11 +445,7 @@ export class CatmaidDataSourceProvider implements DataSourceProvider { })); // The model-space coordinates we emit are in nanometers, converted to meters for Neuroglancer. - const coordinateScaleFactors = Float64Array.from([ - 1e-9, - 1e-9, - 1e-9, - ]); + const coordinateScaleFactors = Float64Array.from([1e-9, 1e-9, 1e-9]); // Bounds and chunk sizes are represented in project-space nanometers. const lowerBounds = Float64Array.from(projectLowerBounds); diff --git a/src/datasource/catmaid/skeleton_packing.ts b/src/datasource/catmaid/skeleton_packing.ts index 5c54168fd..0403f818f 100644 --- a/src/datasource/catmaid/skeleton_packing.ts +++ b/src/datasource/catmaid/skeleton_packing.ts @@ -14,14 +14,17 @@ * limitations under the License. */ -import type { SpatiallyIndexedSkeletonNodeBase } from "#src/skeleton/api.js"; +import type { + SpatiallyIndexedSkeletonNodeBase, + SpatialSkeletonSourceState, +} from "#src/skeleton/api.js"; interface PackedCatmaidSkeletonData { vertexPositions: Float32Array; segmentIds: Uint32Array; indices: Uint32Array; nodeIds: Int32Array; - sourceStates: unknown[]; + sourceStates: Array; } export function packCatmaidSkeletonNodes( @@ -31,7 +34,9 @@ export function packCatmaidSkeletonNodes( const vertexPositions = new Float32Array(numVertices * 3); const segmentIds = new Uint32Array(numVertices); const nodeIds = new Int32Array(numVertices); - const sourceStates = new Array(numVertices); + const sourceStates = new Array( + numVertices, + ); const indices: number[] = []; const nodeMap = new Map(); diff --git a/src/datasource/catmaid/spatial_skeleton_commands.ts b/src/datasource/catmaid/spatial_skeleton_commands.ts index 5c73d662c..a73ae3185 100644 --- a/src/datasource/catmaid/spatial_skeleton_commands.ts +++ b/src/datasource/catmaid/spatial_skeleton_commands.ts @@ -37,19 +37,14 @@ import { } from "#src/segmentation_display_state/base.js"; import type { SpatiallyIndexedSkeletonNode, + SpatialSkeletonSourceState, SpatialSkeletonVector, } from "#src/skeleton/api.js"; -import type { - SpatialSkeletonAddNodeCommandOptions, - SpatialSkeletonEditController, - SpatialSkeletonInsertNodeCommandOptions, - SpatialSkeletonMergeEndpoint, - SpatialSkeletonMoveNodeCommandOptions, - SpatialSkeletonNodeDescriptionCommandOptions, - SpatialSkeletonNodePropertiesCommandOptions, - SpatialSkeletonNodeTrueEndCommandOptions, -} from "#src/skeleton/edit_controller.js"; -import { SpatialSkeletonActions } from "#src/skeleton/actions.js"; +import type { SpatialSkeletonEditCommandSource } from "#src/layer/segmentation/spatial_skeleton_commands.js"; +import { + SpatialSkeletonActions, + type SpatialSkeletonAction, +} from "#src/skeleton/actions.js"; import type { SpatialSkeletonCommand, SpatialSkeletonCommandContext, @@ -62,19 +57,328 @@ import type { SpatiallyIndexedSkeletonLayer } from "#src/skeleton/frontend.js"; import { StatusMessage } from "#src/status.js"; import { formatErrorMessage } from "#src/util/error.js"; +interface CatmaidSpatialSkeletonAddNodeCommandOptions { + skeletonId: number; + parentNodeId: number | undefined; + positionInModelSpace: SpatialSkeletonVector; +} + +interface CatmaidSpatialSkeletonInsertNodeCommandOptions { + skeletonId: number; + parentNodeId: number; + childNodeIds: readonly number[]; + positionInModelSpace: SpatialSkeletonVector; +} + +interface CatmaidSpatialSkeletonMoveNodeCommandOptions { + node: SpatiallyIndexedSkeletonNode; + nextPositionInModelSpace: SpatialSkeletonVector; +} + +interface CatmaidSpatialSkeletonNodeDescriptionCommandOptions { + node: SpatiallyIndexedSkeletonNode; + nextDescription?: string; +} + +interface CatmaidSpatialSkeletonNodeTrueEndCommandOptions { + node: SpatiallyIndexedSkeletonNode; + nextIsTrueEnd: boolean; +} + +interface CatmaidSpatialSkeletonNodePropertiesCommandOptions { + node: SpatiallyIndexedSkeletonNode; + next: { radius: number; confidence: number }; +} + +interface CatmaidSpatialSkeletonMergeEndpoint { + nodeId: number; + segmentId: number; + sourceState?: SpatialSkeletonSourceState; +} + +interface CatmaidSpatialSkeletonMergeCommandPayload { + firstNode: CatmaidSpatialSkeletonMergeEndpoint; + secondNode: CatmaidSpatialSkeletonMergeEndpoint; +} + +type CatmaidSpatialSkeletonCommandHandler = ( + layer: SegmentationUserLayer, + payload: object, +) => SpatialSkeletonCommand; + function hasFunction( - value: unknown, + value: object | undefined, property: T, -): value is Record unknown> { +): value is Record object> { + return ( + value !== undefined && + typeof (value as Partial object>>)[ + property + ] === "function" + ); +} + +function isFiniteNumber(value: number | undefined) { + return typeof value === "number" && Number.isFinite(value); +} + +function isOptionalFiniteNumber(value: number | undefined) { + return value === undefined || isFiniteNumber(value); +} + +function isSpatialSkeletonVector( + value: object | undefined, +): value is SpatialSkeletonVector { + return ( + value !== undefined && + isFiniteNumber((value as { length?: number }).length) + ); +} + +function areFiniteNumbers(values: readonly number[] | undefined) { + return values !== undefined && values.every((value) => isFiniteNumber(value)); +} + +function isSpatiallyIndexedSkeletonNodePayload( + value: object | undefined, +): value is SpatiallyIndexedSkeletonNode { + if (value === undefined) return false; + const candidate = value as { + nodeId?: number; + segmentId?: number; + position?: object; + parentNodeId?: number; + radius?: number; + confidence?: number; + description?: string; + isTrueEnd?: boolean; + }; return ( - typeof value === "object" && - value !== null && - typeof (value as Record)[property] === "function" + isFiniteNumber(candidate.nodeId) && + isFiniteNumber(candidate.segmentId) && + isSpatialSkeletonVector(candidate.position) && + isOptionalFiniteNumber(candidate.parentNodeId) && + isOptionalFiniteNumber(candidate.radius) && + isOptionalFiniteNumber(candidate.confidence) && + (candidate.description === undefined || + typeof candidate.description === "string") && + (candidate.isTrueEnd === undefined || + typeof candidate.isTrueEnd === "boolean") + ); +} + +function isCatmaidMergeEndpoint( + value: object | undefined, +): value is CatmaidSpatialSkeletonMergeEndpoint { + if (value === undefined) return false; + const candidate = value as { + nodeId?: number; + segmentId?: number; + }; + return isFiniteNumber(candidate.nodeId) && isFiniteNumber(candidate.segmentId); +} + +function requireCatmaidCommandPayload( + payload: object, + label: string, + isValid: (payload: object) => payload is T, +) { + if (!isValid(payload)) { + throw new Error(`CATMAID ${label} command received an invalid payload.`); + } + return payload; +} + +function requireCatmaidAddNodeCommandOptions(payload: object) { + return requireCatmaidCommandPayload( + payload, + "add-node", + (candidate): candidate is CatmaidSpatialSkeletonAddNodeCommandOptions => { + const options = candidate as { + skeletonId?: number; + parentNodeId?: number; + positionInModelSpace?: object; + }; + return ( + isFiniteNumber(options.skeletonId) && + isOptionalFiniteNumber(options.parentNodeId) && + isSpatialSkeletonVector(options.positionInModelSpace) + ); + }, + ); +} + +function requireCatmaidInsertNodeCommandOptions(payload: object) { + return requireCatmaidCommandPayload( + payload, + "insert-node", + (candidate): candidate is CatmaidSpatialSkeletonInsertNodeCommandOptions => { + const options = candidate as { + skeletonId?: number; + parentNodeId?: number; + childNodeIds?: readonly number[]; + positionInModelSpace?: object; + }; + return ( + isFiniteNumber(options.skeletonId) && + isFiniteNumber(options.parentNodeId) && + areFiniteNumbers(options.childNodeIds) && + isSpatialSkeletonVector(options.positionInModelSpace) + ); + }, + ); +} + +function requireCatmaidMoveNodeCommandOptions(payload: object) { + return requireCatmaidCommandPayload( + payload, + "move-node", + (candidate): candidate is CatmaidSpatialSkeletonMoveNodeCommandOptions => { + const options = candidate as { + node?: object; + nextPositionInModelSpace?: object; + }; + return ( + isSpatiallyIndexedSkeletonNodePayload(options.node) && + isSpatialSkeletonVector(options.nextPositionInModelSpace) + ); + }, + ); +} + +function requireCatmaidDeleteNodeCommandPayload(payload: object) { + return requireCatmaidCommandPayload( + payload, + "delete-node", + isSpatiallyIndexedSkeletonNodePayload, + ); +} + +function requireCatmaidNodeDescriptionCommandOptions(payload: object) { + return requireCatmaidCommandPayload( + payload, + "node-description", + ( + candidate, + ): candidate is CatmaidSpatialSkeletonNodeDescriptionCommandOptions => { + const options = candidate as { + node?: object; + nextDescription?: string; + }; + return ( + isSpatiallyIndexedSkeletonNodePayload(options.node) && + (options.nextDescription === undefined || + typeof options.nextDescription === "string") + ); + }, + ); +} + +function requireCatmaidNodeTrueEndCommandOptions(payload: object) { + return requireCatmaidCommandPayload( + payload, + "node-true-end", + ( + candidate, + ): candidate is CatmaidSpatialSkeletonNodeTrueEndCommandOptions => { + const options = candidate as { + node?: object; + nextIsTrueEnd?: boolean; + }; + return ( + isSpatiallyIndexedSkeletonNodePayload(options.node) && + typeof options.nextIsTrueEnd === "boolean" + ); + }, + ); +} + +function requireCatmaidNodePropertiesCommandOptions(payload: object) { + return requireCatmaidCommandPayload( + payload, + "node-properties", + ( + candidate, + ): candidate is CatmaidSpatialSkeletonNodePropertiesCommandOptions => { + const options = candidate as { + node?: object; + next?: object; + }; + const next = options.next as + | { radius?: number; confidence?: number } + | undefined; + return ( + isSpatiallyIndexedSkeletonNodePayload(options.node) && + next !== undefined && + isFiniteNumber(next.radius) && + isFiniteNumber(next.confidence) + ); + }, + ); +} + +function requireCatmaidRerootCommandPayload(payload: object) { + return requireCatmaidCommandPayload( + payload, + "reroot", + ( + candidate, + ): candidate is Pick< + SpatiallyIndexedSkeletonNode, + "nodeId" | "segmentId" | "parentNodeId" + > => { + const node = candidate as { + nodeId?: number; + segmentId?: number; + parentNodeId?: number; + }; + return ( + isFiniteNumber(node.nodeId) && + isFiniteNumber(node.segmentId) && + isOptionalFiniteNumber(node.parentNodeId) + ); + }, + ); +} + +function requireCatmaidSplitCommandPayload(payload: object) { + return requireCatmaidCommandPayload( + payload, + "split", + ( + candidate, + ): candidate is Pick< + SpatiallyIndexedSkeletonNode, + "nodeId" | "segmentId" + > => { + const node = candidate as { + nodeId?: number; + segmentId?: number; + }; + return isFiniteNumber(node.nodeId) && isFiniteNumber(node.segmentId); + }, + ); +} + +function requireCatmaidMergeCommandPayload(payload: object) { + return requireCatmaidCommandPayload( + payload, + "merge", + (candidate): candidate is CatmaidSpatialSkeletonMergeCommandPayload => { + const options = candidate as { + firstNode?: object; + secondNode?: object; + }; + return ( + isCatmaidMergeEndpoint(options.firstNode) && + isCatmaidMergeEndpoint(options.secondNode) + ); + }, ); } function isCatmaidSpatialSkeletonEditApi( - value: unknown, + value: object | undefined, ): value is CatmaidSpatialSkeletonEditApi { return ( hasFunction(value, "getSkeletonRootNode") && @@ -83,8 +387,7 @@ function isCatmaidSpatialSkeletonEditApi( hasFunction(value, "moveNode") && hasFunction(value, "deleteNode") && hasFunction(value, "updateDescription") && - hasFunction(value, "setTrueEnd") && - hasFunction(value, "removeTrueEnd") && + hasFunction(value, "toggleTrueEnd") && hasFunction(value, "updateRadius") && hasFunction(value, "updateConfidence") && hasFunction(value, "mergeSkeletons") && @@ -136,9 +439,7 @@ function getEditableSkeletonSourceForLayer(layer: SegmentationUserLayer): { "No spatially indexed skeleton source is currently loaded.", ); } - const skeletonSource = isCatmaidSpatialSkeletonEditApi( - skeletonLayer.source, - ) + const skeletonSource = isCatmaidSpatialSkeletonEditApi(skeletonLayer.source) ? skeletonLayer.source : undefined; if (skeletonSource === undefined) { @@ -467,9 +768,10 @@ async function applyNodeDescriptionAndTrueEnd( }; } if (node.isTrueEnd !== nextTrueEnd || (descriptionChanged && nextTrueEnd)) { - const trueEndResult = nextTrueEnd - ? await skeletonSource.setTrueEnd(node.nodeId) - : await skeletonSource.removeTrueEnd(node.nodeId); + const trueEndResult = await skeletonSource.toggleTrueEnd( + node.nodeId, + nextTrueEnd, + ); updatedNode = { ...updatedNode, sourceState: trueEndResult.sourceState ?? updatedNode.sourceState, @@ -1183,9 +1485,10 @@ class NodeTrueEndCommand implements SpatialSkeletonCommand { if (node.isTrueEnd === nextIsTrueEnd) { return; } - const result = nextIsTrueEnd - ? await skeletonSource.setTrueEnd(node.nodeId) - : await skeletonSource.removeTrueEnd(node.nodeId); + const result = await skeletonSource.toggleTrueEnd( + node.nodeId, + nextIsTrueEnd, + ); this.layer.spatialSkeletonState.updateCachedNode( node.nodeId, (candidate) => { @@ -1542,7 +1845,7 @@ class MergeCommand implements SpatialSkeletonCommand { private stableFirstSegmentId: number | undefined, private stableSecondNodeId: number, private stableSecondSegmentId: number | undefined, - private secondNodeSourceState: unknown, + private secondNodeSourceState: SpatialSkeletonSourceState | undefined, ) {} private async merge(statusPrefix: string) { @@ -1784,39 +2087,82 @@ class MergeCommand implements SpatialSkeletonCommand { } } -export class CatmaidSpatialSkeletonEditController - implements SpatialSkeletonEditController +export class CatmaidSpatialSkeletonEditCommandSource + implements SpatialSkeletonEditCommandSource { - readonly capabilities = { - nodeFeatures: { - description: true, - trueEnd: true, - radius: true, - confidenceValues: [0, 25, 50, 75, 100], + private readonly commandHandlers: Partial< + Record + > = { + [SpatialSkeletonActions.addNodes]: (layer, payload) => + this.createAddNodeCommand( + layer, + requireCatmaidAddNodeCommandOptions(payload), + ), + [SpatialSkeletonActions.insertNodes]: (layer, payload) => + this.createInsertNodeCommand( + layer, + requireCatmaidInsertNodeCommandOptions(payload), + ), + [SpatialSkeletonActions.moveNodes]: (layer, payload) => + this.createMoveNodeCommand( + layer, + requireCatmaidMoveNodeCommandOptions(payload), + ), + [SpatialSkeletonActions.deleteNodes]: (layer, payload) => + this.createDeleteNodeCommand( + layer, + requireCatmaidDeleteNodeCommandPayload(payload), + ), + [SpatialSkeletonActions.reroot]: (layer, payload) => + this.createRerootCommand( + layer, + requireCatmaidRerootCommandPayload(payload), + ), + [SpatialSkeletonActions.editNodeDescription]: (layer, payload) => + this.createNodeDescriptionCommand( + layer, + requireCatmaidNodeDescriptionCommandOptions(payload), + ), + [SpatialSkeletonActions.editNodeTrueEnd]: (layer, payload) => + this.createNodeTrueEndCommand( + layer, + requireCatmaidNodeTrueEndCommandOptions(payload), + ), + [SpatialSkeletonActions.editNodeProperties]: (layer, payload) => + this.createNodePropertiesCommand( + layer, + requireCatmaidNodePropertiesCommandOptions(payload), + ), + [SpatialSkeletonActions.mergeSkeletons]: (layer, payload) => { + const options = requireCatmaidMergeCommandPayload(payload); + return this.createMergeCommand( + layer, + options.firstNode, + options.secondNode, + ); }, + [SpatialSkeletonActions.splitSkeletons]: (layer, payload) => + this.createSplitCommand( + layer, + requireCatmaidSplitCommandPayload(payload), + ), }; - supports(action: string) { - switch (action) { - case SpatialSkeletonActions.addNodes: - case SpatialSkeletonActions.insertNodes: - case SpatialSkeletonActions.moveNodes: - case SpatialSkeletonActions.deleteNodes: - case SpatialSkeletonActions.reroot: - case SpatialSkeletonActions.editNodeDescription: - case SpatialSkeletonActions.editNodeTrueEnd: - case SpatialSkeletonActions.editNodeProperties: - case SpatialSkeletonActions.mergeSkeletons: - case SpatialSkeletonActions.splitSkeletons: - return true; - default: - return false; - } + supports(action: SpatialSkeletonAction) { + return this.commandHandlers[action] !== undefined; + } + + createCommand( + action: SpatialSkeletonAction, + layer: SegmentationUserLayer, + payload: object, + ) { + return this.commandHandlers[action]?.(layer, payload); } - createAddNodeCommand( + private createAddNodeCommand( layer: SegmentationUserLayer, - options: SpatialSkeletonAddNodeCommandOptions, + options: CatmaidSpatialSkeletonAddNodeCommandOptions, ) { const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; return new AddNodeCommand( @@ -1831,17 +2177,16 @@ export class CatmaidSpatialSkeletonEditController ); } - createInsertNodeCommand( + private createInsertNodeCommand( layer: SegmentationUserLayer, - options: SpatialSkeletonInsertNodeCommandOptions, + options: CatmaidSpatialSkeletonInsertNodeCommandOptions, ) { const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; return new InsertNodeCommand( layer, commandMappings.getStableOrCurrentNodeId(options.parentNodeId)!, options.childNodeIds.map( - (childNodeId) => - commandMappings.getStableOrCurrentNodeId(childNodeId)!, + (childNodeId) => commandMappings.getStableOrCurrentNodeId(childNodeId)!, ), commandMappings.getStableOrCurrentSegmentId(options.skeletonId) ?? options.skeletonId, @@ -1852,9 +2197,9 @@ export class CatmaidSpatialSkeletonEditController ); } - createMoveNodeCommand( + private createMoveNodeCommand( layer: SegmentationUserLayer, - options: SpatialSkeletonMoveNodeCommandOptions, + options: CatmaidSpatialSkeletonMoveNodeCommandOptions, ) { const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; return new MoveNodeCommand( @@ -1872,7 +2217,7 @@ export class CatmaidSpatialSkeletonEditController ); } - createDeleteNodeCommand( + private createDeleteNodeCommand( layer: SegmentationUserLayer, node: SpatiallyIndexedSkeletonNode, ) { @@ -1895,9 +2240,9 @@ export class CatmaidSpatialSkeletonEditController return new DeleteNodeCommand(layer, refreshedNode, childNodes); } - createNodeDescriptionCommand( + private createNodeDescriptionCommand( layer: SegmentationUserLayer, - options: SpatialSkeletonNodeDescriptionCommandOptions, + options: CatmaidSpatialSkeletonNodeDescriptionCommandOptions, ) { const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; return new NodeDescriptionCommand( @@ -1909,9 +2254,9 @@ export class CatmaidSpatialSkeletonEditController ); } - createNodeTrueEndCommand( + private createNodeTrueEndCommand( layer: SegmentationUserLayer, - options: SpatialSkeletonNodeTrueEndCommandOptions, + options: CatmaidSpatialSkeletonNodeTrueEndCommandOptions, ) { const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; return new NodeTrueEndCommand( @@ -1923,9 +2268,9 @@ export class CatmaidSpatialSkeletonEditController ); } - createNodePropertiesCommand( + private createNodePropertiesCommand( layer: SegmentationUserLayer, - options: SpatialSkeletonNodePropertiesCommandOptions, + options: CatmaidSpatialSkeletonNodePropertiesCommandOptions, ) { const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; return new NodePropertiesCommand( @@ -1940,7 +2285,7 @@ export class CatmaidSpatialSkeletonEditController ); } - createRerootCommand( + private createRerootCommand( layer: SegmentationUserLayer, node: Pick< SpatiallyIndexedSkeletonNode, @@ -1966,7 +2311,7 @@ export class CatmaidSpatialSkeletonEditController ); } - createSplitCommand( + private createSplitCommand( layer: SegmentationUserLayer, node: Pick, ) { @@ -1991,10 +2336,10 @@ export class CatmaidSpatialSkeletonEditController ); } - createMergeCommand( + private createMergeCommand( layer: SegmentationUserLayer, - firstNode: SpatialSkeletonMergeEndpoint, - secondNode: SpatialSkeletonMergeEndpoint, + firstNode: CatmaidSpatialSkeletonMergeEndpoint, + secondNode: CatmaidSpatialSkeletonMergeEndpoint, ) { const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; return new MergeCommand( diff --git a/src/layer/index.ts b/src/layer/index.ts index 1ac9d0ade..136f8b5a1 100644 --- a/src/layer/index.ts +++ b/src/layer/index.ts @@ -63,6 +63,7 @@ import type { RenderLayerRole, VisibilityTrackedRenderLayer, } from "#src/renderlayer.js"; +import type { SpatialSkeletonSourceState } from "#src/skeleton/api.js"; import type { VolumeType } from "#src/sliceview/volume/base.js"; import { StatusMessage } from "#src/status.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; @@ -1148,7 +1149,7 @@ export interface PickedSpatialSkeletonState { nodeId?: number; segmentId?: number; position?: Float32Array; - sourceState?: unknown; + sourceState?: SpatialSkeletonSourceState; } export interface PickState { diff --git a/src/layer/segmentation/index.spec.ts b/src/layer/segmentation/index.spec.ts index 1cf15eb01..3f41a48c4 100644 --- a/src/layer/segmentation/index.spec.ts +++ b/src/layer/segmentation/index.spec.ts @@ -44,20 +44,14 @@ function makeEditableSpatialSkeletonSource( undo: vi.fn(), redo: vi.fn(), }); + const supports = (action: string) => + action !== SpatialSkeletonActions.reroot || + options.rerootSkeleton !== undefined; return { - spatialSkeletonEditController: { - supports: (action: string) => - action !== SpatialSkeletonActions.reroot || - options.rerootSkeleton !== undefined, - createAddNodeCommand: createCommand, - createInsertNodeCommand: createCommand, - createMoveNodeCommand: createCommand, - createDeleteNodeCommand: createCommand, - createSplitCommand: createCommand, - createMergeCommand: createCommand, - ...(options.rerootSkeleton === undefined - ? {} - : { createRerootCommand: createCommand }), + spatialSkeletonEditCommandSource: { + supports, + createCommand: (action: string) => + supports(action) ? createCommand() : undefined, }, listSkeletons: async () => [], getSkeleton: async () => [], @@ -68,8 +62,7 @@ function makeEditableSpatialSkeletonSource( moveNode: async () => ({}), deleteNode: async () => ({}), updateDescription: async () => ({}), - setTrueEnd: async () => ({}), - removeTrueEnd: async () => ({}), + toggleTrueEnd: async () => ({}), updateRadius: async () => ({}), updateConfidence: async () => ({}), getSkeletonRootNode: async () => ({ diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index be5ac46ce..4415a314e 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -56,6 +56,7 @@ import { executeSpatialSkeletonNodePropertiesUpdate, executeSpatialSkeletonReroot, executeSpatialSkeletonNodeTrueEndUpdate, + getSpatialSkeletonEditCommandSource, } from "#src/layer/segmentation/spatial_skeleton_commands.js"; import { showSpatialSkeletonActionError } from "#src/layer/segmentation/spatial_skeleton_errors.js"; import { appendSpatialSkeletonSerializationState } from "#src/layer/segmentation/spatial_skeleton_serialization.js"; @@ -112,7 +113,10 @@ import { SpatialSkeletonActions, type SpatialSkeletonAction, } from "#src/skeleton/actions.js"; -import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; +import type { + SpatiallyIndexedSkeletonNode, + SpatialSkeletonSourceState, +} from "#src/skeleton/api.js"; import { findSpatiallyIndexedSkeletonNode, getSpatiallyIndexedSkeletonDirectChildren, @@ -139,7 +143,7 @@ import { type SpatialSkeletonDisplayNodeType, } from "#src/skeleton/node_types.js"; import { - getSpatialSkeletonEditController, + getEditableSpatiallyIndexedSkeletonSource, getSpatiallyIndexedSkeletonSource, SpatialSkeletonState, } from "#src/skeleton/spatial_skeleton_manager.js"; @@ -1246,7 +1250,7 @@ interface SelectedSpatialSkeletonNodeInfo { nodeId: number; segmentId?: number; position?: Float32Array; - sourceState?: unknown; + sourceState?: SpatialSkeletonSourceState; } function normalizeOptionalPositiveSafeInteger(value: unknown) { @@ -1385,7 +1389,7 @@ export class SegmentationUserLayer extends Base { options: { segmentId?: number; position?: ArrayLike; - sourceState?: unknown; + sourceState?: SpatialSkeletonSourceState; } = {}, ) => { const normalizedNodeId = normalizeOptionalPositiveSafeInteger(nodeId); @@ -1887,7 +1891,8 @@ export class SegmentationUserLayer extends Base { return getSpatiallyIndexedSkeletonSource(skeletonLayer) !== undefined; } return ( - getSpatialSkeletonEditController(skeletonLayer)?.supports(action) ?? false + getSpatialSkeletonEditCommandSource(skeletonLayer)?.supports(action) ?? + false ); } @@ -1955,7 +1960,7 @@ export class SegmentationUserLayer extends Base { "No active spatial skeleton layer found for delete action.", ); } - if (getSpatialSkeletonEditController(skeletonLayer) === undefined) { + if (getSpatialSkeletonEditCommandSource(skeletonLayer) === undefined) { throw new Error( "Unable to resolve editable skeleton source for the active layer.", ); @@ -2824,10 +2829,10 @@ export class SegmentationUserLayer extends Base { summaryRow.classList.add("neuroglancer-spatial-skeleton-selection-summary"); container.appendChild(summaryRow); - const skeletonEditController = - getSpatialSkeletonEditController(skeletonLayer); + const editCommandSource = + getSpatialSkeletonEditCommandSource(skeletonLayer); const rerootDisabledReason = - skeletonEditController === undefined + editCommandSource === undefined ? "Unable to resolve a reroot-capable skeleton source for the active layer." : segmentNodes === undefined ? "Load the active skeleton in the Skeleton tab before rerooting from Selection." @@ -2875,7 +2880,7 @@ export class SegmentationUserLayer extends Base { })(); }); const deleteDisabledReason = - skeletonEditController === undefined + editCommandSource === undefined ? "Unable to resolve editable skeleton source for the active layer." : segmentNodes === undefined ? "Load the active skeleton in the Skeleton tab before deleting from Selection." @@ -2897,7 +2902,7 @@ export class SegmentationUserLayer extends Base { deleteButton.addEventListener("click", () => { if ( deleteButton.disabled || - skeletonEditController === undefined || + editCommandSource === undefined || completeNodeInfo === undefined || deletePending ) { @@ -2978,7 +2983,7 @@ export class SegmentationUserLayer extends Base { const isLeaf = segmentNodes !== undefined && directChildNodeIds.length === 0; const leafTypeEditingDisabledReason = () => - skeletonEditController === undefined + editCommandSource === undefined ? "Unable to resolve editable skeleton source for the active layer." : cachedNodeInfo === undefined || segmentNodes === undefined ? "Load the active skeleton in the Skeleton tab before changing leaf type." @@ -3116,9 +3121,9 @@ export class SegmentationUserLayer extends Base { } else { appendValue("Node type", nodeTypeLabel); } - const nodeFeatureCapabilities = getSpatialSkeletonEditController( + const nodeFeatureCapabilities = getEditableSpatiallyIndexedSkeletonSource( this.getSpatiallyIndexedSkeletonLayer(), - )?.capabilities?.nodeFeatures; + )?.spatialSkeletonEditCapabilities?.nodeFeatures; const confidenceCapabilityValues = nodeFeatureCapabilities?.confidenceValues; const nodePropertiesEditable = @@ -3174,7 +3179,7 @@ export class SegmentationUserLayer extends Base { appendValue("Confidence level", confidenceControl); let savePending = false; const getPropertyEditingDisabledReason = () => - skeletonEditController === undefined + editCommandSource === undefined ? "Unable to resolve editable skeleton source for the active layer." : this.getSpatialSkeletonActionsDisabledReason( SpatialSkeletonActions.editNodeProperties, @@ -3323,7 +3328,7 @@ export class SegmentationUserLayer extends Base { const descriptionText = cachedNodeInfo?.description ?? completeNodeInfo?.description ?? ""; const descriptionEditingDisabledReason = - skeletonEditController === undefined + editCommandSource === undefined ? "Unable to resolve editable skeleton source for the active layer." : cachedNodeInfo === undefined ? "Load the active skeleton in the Skeleton tab before editing description." @@ -3339,10 +3344,7 @@ export class SegmentationUserLayer extends Base { descriptionElement.placeholder = "Description"; descriptionElement.value = descriptionText; descriptionElement.addEventListener("change", () => { - if ( - skeletonEditController === undefined || - cachedNodeInfo === undefined - ) { + if (editCommandSource === undefined || cachedNodeInfo === undefined) { return; } const nextDescription = descriptionElement.value; diff --git a/src/layer/segmentation/spatial_skeleton_commands.spec.ts b/src/layer/segmentation/spatial_skeleton_commands.spec.ts index 7b00cd89b..e59cbfb18 100644 --- a/src/layer/segmentation/spatial_skeleton_commands.spec.ts +++ b/src/layer/segmentation/spatial_skeleton_commands.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { CatmaidSpatialSkeletonEditController } from "#src/datasource/catmaid/spatial_skeleton_commands.js"; +import { CatmaidSpatialSkeletonEditCommandSource } from "#src/datasource/catmaid/spatial_skeleton_commands.js"; import { buildCatmaidNeighborhoodEditContext } from "#src/datasource/catmaid/edit_state.js"; import { makeCatmaidNodeSourceState } from "#src/datasource/catmaid/api.js"; import { @@ -19,6 +19,7 @@ import { getSpatiallyIndexedSkeletonDirectChildren, getSpatiallyIndexedSkeletonNodeParent, } from "#src/skeleton/edit_state.js"; +import { SpatialSkeletonActions } from "#src/skeleton/actions.js"; import { SpatialSkeletonCommandHistory } from "#src/skeleton/command_history.js"; import { SpatialSkeletonState } from "#src/skeleton/spatial_skeleton_manager.js"; import { StatusMessage } from "#src/status.js"; @@ -61,7 +62,8 @@ function setSegmentNodes( function makeEditableSkeletonSource(overrides: Record = {}) { return { - spatialSkeletonEditController: new CatmaidSpatialSkeletonEditController(), + spatialSkeletonEditCommandSource: + new CatmaidSpatialSkeletonEditCommandSource(), listSkeletons: vi.fn(), getSkeleton: vi.fn(), fetchNodes: vi.fn(), @@ -73,8 +75,7 @@ function makeEditableSkeletonSource(overrides: Record = {}) { deleteNode: vi.fn(), rerootSkeleton: vi.fn(), updateDescription: vi.fn(), - setTrueEnd: vi.fn(), - removeTrueEnd: vi.fn(), + toggleTrueEnd: vi.fn(), updateRadius: vi.fn(), updateConfidence: vi.fn(), mergeSkeletons: vi.fn(), @@ -104,7 +105,7 @@ describe("spatial_skeleton_commands", () => { vi.restoreAllMocks(); }); - it("executes opaque source-created commands through a valid edit controller", async () => { + it("executes opaque source-created commands through a valid edit source", async () => { const execute = vi.fn(); const undo = vi.fn(); const redo = vi.fn(); @@ -114,7 +115,6 @@ describe("spatial_skeleton_commands", () => { undo, redo, }; - const createMoveNodeCommand = vi.fn(() => command); const createCommand = vi.fn(() => command); const layer = { spatialSkeletonState: { @@ -122,19 +122,20 @@ describe("spatial_skeleton_commands", () => { }, getSpatiallyIndexedSkeletonLayer: () => ({ source: { - spatialSkeletonEditController: { + spatialSkeletonEditCommandSource: { supports: () => true, - createAddNodeCommand: createCommand, - createInsertNodeCommand: createCommand, - createMoveNodeCommand, - createDeleteNodeCommand: createCommand, - createSplitCommand: createCommand, - createMergeCommand: createCommand, + createCommand, }, listSkeletons: vi.fn(), getSkeleton: vi.fn(), fetchNodes: vi.fn(), getSpatialIndexMetadata: vi.fn(), + addNode: vi.fn(), + deleteNode: vi.fn(), + moveNode: vi.fn(), + splitSkeleton: vi.fn(), + mergeSkeletons: vi.fn(), + toggleTrueEnd: vi.fn(), }, }), }; @@ -152,42 +153,89 @@ describe("spatial_skeleton_commands", () => { await undoSpatialSkeletonCommand(layer as any); await redoSpatialSkeletonCommand(layer as any); - expect(createMoveNodeCommand).toHaveBeenCalledWith(layer, { - node, - nextPositionInModelSpace, - }); + expect(createCommand).toHaveBeenCalledWith( + SpatialSkeletonActions.moveNodes, + layer, + { + node, + nextPositionInModelSpace, + }, + ); expect(execute).toHaveBeenCalledTimes(1); expect(undo).toHaveBeenCalledTimes(1); expect(redo).toHaveBeenCalledTimes(1); }); - it("reports unsupported optional command factories clearly", () => { + it("does not treat a source missing createCommand as an edit command source", () => { + const layer = { + spatialSkeletonState: { + commandHistory: new SpatialSkeletonCommandHistory(), + }, + getSpatiallyIndexedSkeletonLayer: () => ({ + source: { + spatialSkeletonEditCommandSource: { + supports: () => true, + }, + listSkeletons: vi.fn(), + getSkeleton: vi.fn(), + fetchNodes: vi.fn(), + getSpatialIndexMetadata: vi.fn(), + addNode: vi.fn(), + deleteNode: vi.fn(), + moveNode: vi.fn(), + splitSkeleton: vi.fn(), + mergeSkeletons: vi.fn(), + toggleTrueEnd: vi.fn(), + }, + }), + }; + + expect(() => + executeSpatialSkeletonNodeDescriptionUpdate(layer as any, { + node: { + nodeId: 17, + segmentId: 23, + position: new Float32Array([1, 2, 3]), + }, + nextDescription: "next", + }), + ).toThrow( + "Unable to resolve editable skeleton source for the active layer.", + ); + }); + + it("reports unsupported command creation clearly", () => { const command = { label: "required command", execute: vi.fn(), undo: vi.fn(), redo: vi.fn(), }; - const createCommand = vi.fn(() => command); + const createCommand = vi.fn((action: string) => + action === SpatialSkeletonActions.editNodeDescription + ? undefined + : command, + ); const layer = { spatialSkeletonState: { commandHistory: new SpatialSkeletonCommandHistory(), }, getSpatiallyIndexedSkeletonLayer: () => ({ source: { - spatialSkeletonEditController: { + spatialSkeletonEditCommandSource: { supports: () => true, - createAddNodeCommand: createCommand, - createInsertNodeCommand: createCommand, - createMoveNodeCommand: createCommand, - createDeleteNodeCommand: createCommand, - createSplitCommand: createCommand, - createMergeCommand: createCommand, + createCommand, }, listSkeletons: vi.fn(), getSkeleton: vi.fn(), fetchNodes: vi.fn(), getSpatialIndexMetadata: vi.fn(), + addNode: vi.fn(), + deleteNode: vi.fn(), + moveNode: vi.fn(), + splitSkeleton: vi.fn(), + mergeSkeletons: vi.fn(), + toggleTrueEnd: vi.fn(), }, }), }; @@ -207,6 +255,61 @@ describe("spatial_skeleton_commands", () => { ); }); + it("derives CATMAID command support from registered handlers", () => { + const commandSource = new CatmaidSpatialSkeletonEditCommandSource(); + + expect(commandSource.supports(SpatialSkeletonActions.moveNodes)).toBe(true); + expect(commandSource.supports(SpatialSkeletonActions.inspect)).toBe(false); + expect( + commandSource.createCommand( + SpatialSkeletonActions.inspect, + {} as any, + {}, + ), + ).toBeUndefined(); + }); + + it("creates CATMAID commands from valid opaque payloads", () => { + const commandSource = new CatmaidSpatialSkeletonEditCommandSource(); + const layer = { + spatialSkeletonState: { + commandHistory: new SpatialSkeletonCommandHistory(), + }, + }; + const node: SpatiallyIndexedSkeletonNode = { + nodeId: 17, + segmentId: 23, + position: new Float32Array([1, 2, 3]), + }; + + const command = commandSource.createCommand( + SpatialSkeletonActions.moveNodes, + layer as any, + { + node, + nextPositionInModelSpace: new Float32Array([7, 8, 9]), + }, + ); + + expect(command?.label).toBe("Move node"); + }); + + it("reports invalid CATMAID command payloads clearly", () => { + const commandSource = new CatmaidSpatialSkeletonEditCommandSource(); + const layer = { + spatialSkeletonState: { + commandHistory: new SpatialSkeletonCommandHistory(), + }, + }; + + expect(() => + commandSource.createCommand(SpatialSkeletonActions.moveNodes, layer as any, { + node: {}, + nextPositionInModelSpace: new Float32Array([7, 8, 9]), + }), + ).toThrow("CATMAID move-node command received an invalid payload."); + }); + it("commits move-node commands using model-space positions", async () => { suppressStatusMessages(); diff --git a/src/layer/segmentation/spatial_skeleton_commands.ts b/src/layer/segmentation/spatial_skeleton_commands.ts index 6447b5cd5..d2f945338 100644 --- a/src/layer/segmentation/spatial_skeleton_commands.ts +++ b/src/layer/segmentation/spatial_skeleton_commands.ts @@ -16,31 +16,76 @@ import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; -import type { - SpatialSkeletonAddNodeCommandOptions, - SpatialSkeletonEditController, - SpatialSkeletonInsertNodeCommandOptions, - SpatialSkeletonMergeEndpoint, - SpatialSkeletonMoveNodeCommandOptions, - SpatialSkeletonNodeDescriptionCommandOptions, - SpatialSkeletonNodePropertiesCommandOptions, - SpatialSkeletonNodeTrueEndCommandOptions, -} from "#src/skeleton/edit_controller.js"; +import { + SpatialSkeletonActions, + type SpatialSkeletonAction, +} from "#src/skeleton/actions.js"; import type { SpatialSkeletonCommand } from "#src/skeleton/command_history.js"; -import { getSpatialSkeletonEditController } from "#src/skeleton/spatial_skeleton_manager.js"; +import { getEditableSpatiallyIndexedSkeletonSource } from "#src/skeleton/spatial_skeleton_manager.js"; import { StatusMessage } from "#src/status.js"; -function getController( +export type SpatialSkeletonCommandPayload = object; + +export interface SpatialSkeletonEditCommandSource { + supports(action: SpatialSkeletonAction): boolean; + createCommand( + action: SpatialSkeletonAction, + layer: SegmentationUserLayer, + payload: SpatialSkeletonCommandPayload, + ): SpatialSkeletonCommand | undefined; +} + +type SpatialSkeletonEditCommandSourceCandidate = { + supports?: (action: SpatialSkeletonAction) => boolean; + createCommand?: ( + action: SpatialSkeletonAction, + layer: SegmentationUserLayer, + payload: SpatialSkeletonCommandPayload, + ) => SpatialSkeletonCommand | undefined; +}; + +export function isSpatialSkeletonEditCommandSource( + value: object | undefined, +): value is SpatialSkeletonEditCommandSource { + return ( + value !== undefined && + typeof (value as SpatialSkeletonEditCommandSourceCandidate).supports === + "function" && + typeof (value as SpatialSkeletonEditCommandSourceCandidate) + .createCommand === + "function" + ); +} + +interface SpatialSkeletonSourceAccess { + source: object; +} + +export function getSpatialSkeletonEditCommandSource( + value: SpatialSkeletonSourceAccess | undefined, +): SpatialSkeletonEditCommandSource | undefined { + const source = getEditableSpatiallyIndexedSkeletonSource(value); + if (source === undefined) return undefined; + const editCommandSource = ( + source as { spatialSkeletonEditCommandSource?: object } + ).spatialSkeletonEditCommandSource; + return isSpatialSkeletonEditCommandSource(editCommandSource) + ? editCommandSource + : undefined; +} + +function getEditSource( layer: SegmentationUserLayer, -): SpatialSkeletonEditController { - const skeletonLayer = layer.getSpatiallyIndexedSkeletonLayer(); - const controller = getSpatialSkeletonEditController(skeletonLayer); - if (controller === undefined) { +): SpatialSkeletonEditCommandSource { + const source = getSpatialSkeletonEditCommandSource( + layer.getSpatiallyIndexedSkeletonLayer(), + ); + if (source === undefined) { throw new Error( "Unable to resolve editable skeleton source for the active layer.", ); } - return controller; + return source; } function requireCommand( @@ -68,61 +113,87 @@ function executeCommandWithPendingMessage( return promise.finally(() => status.dispose()); } +function createSpatialSkeletonCommand( + layer: SegmentationUserLayer, + action: SpatialSkeletonAction, + payload: SpatialSkeletonCommandPayload, + unsupportedMessage: string, +) { + return requireCommand( + getEditSource(layer).createCommand(action, layer, payload), + unsupportedMessage, + ); +} + export function executeSpatialSkeletonAddNode( layer: SegmentationUserLayer, - options: SpatialSkeletonAddNodeCommandOptions, + options: SpatialSkeletonCommandPayload, ) { + const command = createSpatialSkeletonCommand( + layer, + SpatialSkeletonActions.addNodes, + options, + "The active skeleton source does not support node creation.", + ); return executeCommandWithPendingMessage( - executeCommand( - layer, - getController(layer).createAddNodeCommand(layer, options), - ), + executeCommand(layer, command), "Creating node...", ); } export function executeSpatialSkeletonInsertNode( layer: SegmentationUserLayer, - options: SpatialSkeletonInsertNodeCommandOptions, + options: SpatialSkeletonCommandPayload, ) { + const command = createSpatialSkeletonCommand( + layer, + SpatialSkeletonActions.insertNodes, + options, + "The active skeleton source does not support node insertion.", + ); return executeCommandWithPendingMessage( - executeCommand( - layer, - getController(layer).createInsertNodeCommand(layer, options), - ), + executeCommand(layer, command), "Inserting node...", ); } export function executeSpatialSkeletonMoveNode( layer: SegmentationUserLayer, - options: SpatialSkeletonMoveNodeCommandOptions, + options: SpatialSkeletonCommandPayload, ) { - return executeCommand( + const command = createSpatialSkeletonCommand( layer, - getController(layer).createMoveNodeCommand(layer, options), + SpatialSkeletonActions.moveNodes, + options, + "The active skeleton source does not support node movement.", ); + return executeCommand(layer, command); } export function executeSpatialSkeletonDeleteNode( layer: SegmentationUserLayer, node: SpatiallyIndexedSkeletonNode, ) { + const command = createSpatialSkeletonCommand( + layer, + SpatialSkeletonActions.deleteNodes, + node, + "The active skeleton source does not support node deletion.", + ); return executeCommandWithPendingMessage( - executeCommand( - layer, - getController(layer).createDeleteNodeCommand(layer, node), - ), + executeCommand(layer, command), "Deleting node...", ); } export function executeSpatialSkeletonNodeDescriptionUpdate( layer: SegmentationUserLayer, - options: SpatialSkeletonNodeDescriptionCommandOptions, + options: SpatialSkeletonCommandPayload, ) { - const command = requireCommand( - getController(layer).createNodeDescriptionCommand?.(layer, options), + const command = createSpatialSkeletonCommand( + layer, + SpatialSkeletonActions.editNodeDescription, + options, "The active skeleton source does not support node description editing.", ); return executeCommand(layer, command); @@ -130,10 +201,12 @@ export function executeSpatialSkeletonNodeDescriptionUpdate( export function executeSpatialSkeletonNodeTrueEndUpdate( layer: SegmentationUserLayer, - options: SpatialSkeletonNodeTrueEndCommandOptions, + options: SpatialSkeletonCommandPayload, ) { - const command = requireCommand( - getController(layer).createNodeTrueEndCommand?.(layer, options), + const command = createSpatialSkeletonCommand( + layer, + SpatialSkeletonActions.editNodeTrueEnd, + options, "The active skeleton source does not support node true-end editing.", ); return executeCommand(layer, command); @@ -141,10 +214,12 @@ export function executeSpatialSkeletonNodeTrueEndUpdate( export function executeSpatialSkeletonNodePropertiesUpdate( layer: SegmentationUserLayer, - options: SpatialSkeletonNodePropertiesCommandOptions, + options: SpatialSkeletonCommandPayload, ) { - const command = requireCommand( - getController(layer).createNodePropertiesCommand?.(layer, options), + const command = createSpatialSkeletonCommand( + layer, + SpatialSkeletonActions.editNodeProperties, + options, "The active skeleton source does not support node property editing.", ); return executeCommand(layer, command); @@ -152,13 +227,12 @@ export function executeSpatialSkeletonNodePropertiesUpdate( export function executeSpatialSkeletonReroot( layer: SegmentationUserLayer, - node: Pick< - SpatiallyIndexedSkeletonNode, - "nodeId" | "segmentId" | "parentNodeId" - >, + node: SpatialSkeletonCommandPayload, ) { - const command = requireCommand( - getController(layer).createRerootCommand?.(layer, node), + const command = createSpatialSkeletonCommand( + layer, + SpatialSkeletonActions.reroot, + node, "The active skeleton source does not support skeleton rerooting.", ); return executeCommand(layer, command); @@ -166,27 +240,33 @@ export function executeSpatialSkeletonReroot( export function executeSpatialSkeletonSplit( layer: SegmentationUserLayer, - node: Pick, + node: SpatialSkeletonCommandPayload, ) { + const command = createSpatialSkeletonCommand( + layer, + SpatialSkeletonActions.splitSkeletons, + node, + "The active skeleton source does not support skeleton splitting.", + ); return executeCommandWithPendingMessage( - executeCommand( - layer, - getController(layer).createSplitCommand(layer, node), - ), + executeCommand(layer, command), "Splitting skeleton...", ); } export function executeSpatialSkeletonMerge( layer: SegmentationUserLayer, - firstNode: SpatialSkeletonMergeEndpoint, - secondNode: SpatialSkeletonMergeEndpoint, + firstNode: SpatialSkeletonCommandPayload, + secondNode: SpatialSkeletonCommandPayload, ) { + const command = createSpatialSkeletonCommand( + layer, + SpatialSkeletonActions.mergeSkeletons, + { firstNode, secondNode }, + "The active skeleton source does not support skeleton merging.", + ); return executeCommandWithPendingMessage( - executeCommand( - layer, - getController(layer).createMergeCommand(layer, firstNode, secondNode), - ), + executeCommand(layer, command), "Merging skeletons...", ); } diff --git a/src/rendered_data_panel.ts b/src/rendered_data_panel.ts index dfed10192..33ff9b320 100644 --- a/src/rendered_data_panel.ts +++ b/src/rendered_data_panel.ts @@ -32,6 +32,7 @@ import { clearOutOfBoundsPickData, getPickDiameter, } from "#src/rendered_data_panel_picking.js"; +import type { SpatialSkeletonSourceState } from "#src/skeleton/api.js"; import { StatusMessage } from "#src/status.js"; import type { TrackableValue } from "#src/trackable_value.js"; import { AutomaticallyFocusedElement } from "#src/util/automatic_focus.js"; @@ -65,7 +66,7 @@ interface SpatialSkeletonSelectableLayer { options?: { segmentId?: number; position?: ArrayLike; - sourceState?: unknown; + sourceState?: SpatialSkeletonSourceState; }, ) => void; clearSpatialSkeletonNodeSelection: ( diff --git a/src/skeleton/api.ts b/src/skeleton/api.ts index 178a94a69..40de1da9a 100644 --- a/src/skeleton/api.ts +++ b/src/skeleton/api.ts @@ -16,6 +16,15 @@ export type SpatialSkeletonVector = ArrayLike; +// Provider-specific node state that crosses the worker boundary must remain structured-cloneable. +export type SpatialSkeletonSourceState = + | null + | boolean + | number + | string + | readonly SpatialSkeletonSourceState[] + | { readonly [key: string]: SpatialSkeletonSourceState }; + export interface SpatialSkeletonBounds { lowerBounds: SpatialSkeletonVector; upperBounds: SpatialSkeletonVector; @@ -32,7 +41,7 @@ export interface SpatiallyIndexedSkeletonNodeBase { segmentId: number; position: SpatialSkeletonVector; parentNodeId?: number; - sourceState?: unknown; + sourceState?: SpatialSkeletonSourceState; } export interface SpatiallyIndexedSkeletonNode @@ -48,8 +57,23 @@ export interface SpatiallyIndexedSkeletonMetadata spatial: readonly SpatialSkeletonSpatialIndexLevel[]; } +export interface SpatialSkeletonNodeFeatureCapabilities { + description?: boolean; + trueEnd?: boolean; + radius?: boolean; + confidenceValues?: readonly number[]; +} + +export interface SpatialSkeletonEditCapabilities { + nodeFeatures?: SpatialSkeletonNodeFeatureCapabilities; +} + +export type SpatialSkeletonEditResult = object; +export type SpatialSkeletonEditOperation = ( + ...args: never[] +) => Promise; + export interface SpatiallyIndexedSkeletonSource { - readonly spatialSkeletonEditController?: unknown; listSkeletons(): Promise; getSkeleton( skeletonId: number, @@ -64,3 +88,19 @@ export interface SpatiallyIndexedSkeletonSource { }, ): Promise; } + +export interface EditableSpatiallyIndexedSkeletonSource + extends SpatiallyIndexedSkeletonSource { + readonly spatialSkeletonEditCapabilities?: SpatialSkeletonEditCapabilities; + addNode: SpatialSkeletonEditOperation; + deleteNode: SpatialSkeletonEditOperation; + moveNode: SpatialSkeletonEditOperation; + splitSkeleton: SpatialSkeletonEditOperation; + mergeSkeletons: SpatialSkeletonEditOperation; + toggleTrueEnd: SpatialSkeletonEditOperation; + insertNode?: SpatialSkeletonEditOperation; + rerootSkeleton?: SpatialSkeletonEditOperation; + updateDescription?: SpatialSkeletonEditOperation; + updateRadius?: SpatialSkeletonEditOperation; + updateConfidence?: SpatialSkeletonEditOperation; +} diff --git a/src/skeleton/backend.ts b/src/skeleton/backend.ts index 56242212d..4c88e98e3 100644 --- a/src/skeleton/backend.ts +++ b/src/skeleton/backend.ts @@ -39,6 +39,7 @@ import { getObjectKey, } from "#src/segmentation_display_state/base.js"; import type { SharedWatchableValue } from "#src/shared_watchable_value.js"; +import type { SpatialSkeletonSourceState } from "#src/skeleton/api.js"; import { SKELETON_LAYER_RPC_ID, SPATIALLY_INDEXED_SKELETON_RENDER_LAYER_RPC_ID, @@ -303,7 +304,7 @@ export class SpatiallyIndexedSkeletonChunk requestGeneration = -1; requestOwners = SpatiallyIndexedSkeletonChunkRequestOwner.NONE; nodeIds: Int32Array | undefined; - nodeSourceStates: unknown[] | undefined; + nodeSourceStates: Array | undefined; freeSystemMemory() { freeSkeletonChunkSystemMemory(this); diff --git a/src/skeleton/edit_controller.ts b/src/skeleton/edit_controller.ts deleted file mode 100644 index 76252dba0..000000000 --- a/src/skeleton/edit_controller.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * @license - * Copyright 2026 Google Inc. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { SpatialSkeletonAction } from "#src/skeleton/actions.js"; -import type { - SpatiallyIndexedSkeletonNode, - SpatialSkeletonVector, -} from "#src/skeleton/api.js"; -import type { SpatialSkeletonCommand } from "#src/skeleton/command_history.js"; - -export interface SpatialSkeletonNodeFeatureCapabilities { - description?: boolean; - trueEnd?: boolean; - radius?: boolean; - confidenceValues?: readonly number[]; -} - -export interface SpatialSkeletonEditCapabilities { - nodeFeatures?: SpatialSkeletonNodeFeatureCapabilities; -} - -export interface SpatialSkeletonAddNodeCommandOptions { - skeletonId: number; - parentNodeId: number | undefined; - positionInModelSpace: SpatialSkeletonVector; -} - -export interface SpatialSkeletonInsertNodeCommandOptions { - skeletonId: number; - parentNodeId: number; - childNodeIds: readonly number[]; - positionInModelSpace: SpatialSkeletonVector; -} - -export interface SpatialSkeletonMoveNodeCommandOptions { - node: SpatiallyIndexedSkeletonNode; - nextPositionInModelSpace: SpatialSkeletonVector; -} - -export interface SpatialSkeletonNodeDescriptionCommandOptions { - node: SpatiallyIndexedSkeletonNode; - nextDescription?: string; -} - -export interface SpatialSkeletonNodeTrueEndCommandOptions { - node: SpatiallyIndexedSkeletonNode; - nextIsTrueEnd: boolean; -} - -export interface SpatialSkeletonNodePropertiesCommandOptions { - node: SpatiallyIndexedSkeletonNode; - next: { radius: number; confidence: number }; -} - -export interface SpatialSkeletonMergeEndpoint { - nodeId: number; - segmentId: number; - sourceState?: unknown; -} - -export interface SpatialSkeletonEditController { - readonly capabilities?: SpatialSkeletonEditCapabilities; - supports(action: SpatialSkeletonAction): boolean; - createAddNodeCommand( - layer: any, - options: SpatialSkeletonAddNodeCommandOptions, - ): SpatialSkeletonCommand; - createInsertNodeCommand( - layer: any, - options: SpatialSkeletonInsertNodeCommandOptions, - ): SpatialSkeletonCommand; - createMoveNodeCommand( - layer: any, - options: SpatialSkeletonMoveNodeCommandOptions, - ): SpatialSkeletonCommand; - createDeleteNodeCommand( - layer: any, - node: SpatiallyIndexedSkeletonNode, - ): SpatialSkeletonCommand; - createNodeDescriptionCommand?( - layer: any, - options: SpatialSkeletonNodeDescriptionCommandOptions, - ): SpatialSkeletonCommand; - createNodeTrueEndCommand?( - layer: any, - options: SpatialSkeletonNodeTrueEndCommandOptions, - ): SpatialSkeletonCommand; - createNodePropertiesCommand?( - layer: any, - options: SpatialSkeletonNodePropertiesCommandOptions, - ): SpatialSkeletonCommand; - createRerootCommand?( - layer: any, - node: Pick< - SpatiallyIndexedSkeletonNode, - "nodeId" | "segmentId" | "parentNodeId" - >, - ): SpatialSkeletonCommand; - createSplitCommand( - layer: any, - node: Pick, - ): SpatialSkeletonCommand; - createMergeCommand( - layer: any, - firstNode: SpatialSkeletonMergeEndpoint, - secondNode: SpatialSkeletonMergeEndpoint, - ): SpatialSkeletonCommand; -} diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index dbbc515ea..2055b9823 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -67,7 +67,10 @@ import { SegmentationLayerSharedObject, } from "#src/segmentation_display_state/frontend.js"; import { SharedWatchableValue } from "#src/shared_watchable_value.js"; -import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; +import type { + SpatiallyIndexedSkeletonNode, + SpatialSkeletonSourceState, +} from "#src/skeleton/api.js"; import type { VertexAttributeInfo } from "#src/skeleton/base.js"; import { SKELETON_LAYER_RPC_ID, @@ -247,7 +250,7 @@ interface SkeletonChunkData { numVertices: number; vertexAttributeOffsets: Uint32Array; nodeIds?: Int32Array; - nodeSourceStates?: unknown[]; + nodeSourceStates?: Array; } type SpatiallyIndexedSkeletonPickData = @@ -1540,7 +1543,7 @@ export class SpatiallyIndexedSkeletonChunk vertexAttributeOffsets: Uint32Array; vertexAttributeTextures: (WebGLTexture | null)[] = []; nodeIds: Int32Array = new Int32Array(0); - nodeSourceStates: unknown[] = []; + nodeSourceStates: Array = []; lod: number | undefined; constructor( diff --git a/src/skeleton/skeleton_chunk_serialization.ts b/src/skeleton/skeleton_chunk_serialization.ts index ac29bf393..2e9b762eb 100644 --- a/src/skeleton/skeleton_chunk_serialization.ts +++ b/src/skeleton/skeleton_chunk_serialization.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import type { SpatialSkeletonSourceState } from "#src/skeleton/api.js"; import type { TypedNumberArray } from "#src/util/array.js"; export interface SkeletonChunkData { @@ -22,7 +23,7 @@ export interface SkeletonChunkData { indices: Uint32Array | null; lod?: number; nodeIds?: Int32Array; - nodeSourceStates?: unknown[]; + nodeSourceStates?: Array; } /** diff --git a/src/skeleton/spatial_skeleton_manager.spec.ts b/src/skeleton/spatial_skeleton_manager.spec.ts index 467b030b6..c036d07b1 100644 --- a/src/skeleton/spatial_skeleton_manager.spec.ts +++ b/src/skeleton/spatial_skeleton_manager.spec.ts @@ -22,73 +22,59 @@ import { getSkeletonRootNode, } from "#src/skeleton/navigation.js"; import { - getSpatialSkeletonEditController, + getEditableSpatiallyIndexedSkeletonSource, SpatialSkeletonState, } from "#src/skeleton/spatial_skeleton_manager.js"; -function makeRequiredEditControllerFactories() { - const createCommand = () => ({ - label: "test command", - execute: vi.fn(), - undo: vi.fn(), - redo: vi.fn(), - }); +function makeEditableSourceMethods() { return { - createAddNodeCommand: createCommand, - createInsertNodeCommand: createCommand, - createMoveNodeCommand: createCommand, - createDeleteNodeCommand: createCommand, - createSplitCommand: createCommand, - createMergeCommand: createCommand, + addNode: vi.fn(), + deleteNode: vi.fn(), + moveNode: vi.fn(), + splitSkeleton: vi.fn(), + mergeSkeletons: vi.fn(), + toggleTrueEnd: vi.fn(), }; } describe("skeleton/spatial_skeleton_manager", () => { - it("returns a source edit controller when all required factories are present", () => { - const controller = { - supports: () => true, - ...makeRequiredEditControllerFactories(), - }; + it("returns an editable source when mandatory edit actions are present", () => { const source = { - spatialSkeletonEditController: controller, + ...makeEditableSourceMethods(), listSkeletons: async () => [], getSkeleton: async () => [], fetchNodes: async () => [], getSpatialIndexMetadata: async () => null, }; - expect(getSpatialSkeletonEditController({ source })).toBe(controller); + expect(getEditableSpatiallyIndexedSkeletonSource({ source })).toBe(source); }); - it("does not treat a partial controller as editable", () => { + it("does not treat a source missing mandatory edit actions as editable", () => { const source = { - spatialSkeletonEditController: { - supports: () => true, - createMoveNodeCommand: vi.fn(), - }, + ...makeEditableSourceMethods(), + toggleTrueEnd: undefined, listSkeletons: async () => [], getSkeleton: async () => [], fetchNodes: async () => [], getSpatialIndexMetadata: async () => null, }; - expect(getSpatialSkeletonEditController({ source })).toBeUndefined(); + expect( + getEditableSpatiallyIndexedSkeletonSource({ source }), + ).toBeUndefined(); }); - it("does not require optional edit factories for controller validation", () => { - const controller = { - supports: () => false, - ...makeRequiredEditControllerFactories(), - }; + it("does not require optional edit actions for editable source validation", () => { const source = { - spatialSkeletonEditController: controller, + ...makeEditableSourceMethods(), listSkeletons: async () => [], getSkeleton: async () => [], fetchNodes: async () => [], getSpatialIndexMetadata: async () => null, }; - expect(getSpatialSkeletonEditController({ source })).toBe(controller); + expect(getEditableSpatiallyIndexedSkeletonSource({ source })).toBe(source); }); it("clears the full skeleton cache before notifying node data listeners", () => { diff --git a/src/skeleton/spatial_skeleton_manager.ts b/src/skeleton/spatial_skeleton_manager.ts index d4998afec..280c978f5 100644 --- a/src/skeleton/spatial_skeleton_manager.ts +++ b/src/skeleton/spatial_skeleton_manager.ts @@ -15,11 +15,12 @@ */ import type { + EditableSpatiallyIndexedSkeletonSource, SpatiallyIndexedSkeletonNode, + SpatialSkeletonSourceState, SpatiallyIndexedSkeletonSource, } from "#src/skeleton/api.js"; import { SpatialSkeletonCommandHistory } from "#src/skeleton/command_history.js"; -import type { SpatialSkeletonEditController } from "#src/skeleton/edit_controller.js"; import type { SpatiallyIndexedSkeletonLayer } from "#src/skeleton/frontend.js"; import { WatchableValue } from "#src/trackable_value.js"; import { RefCounted } from "#src/util/disposable.js"; @@ -50,19 +51,17 @@ export function isSpatiallyIndexedSkeletonSource( ); } -function isSpatialSkeletonEditController( +export function isEditableSpatiallyIndexedSkeletonSource( value: unknown, -): value is SpatialSkeletonEditController { +): value is EditableSpatiallyIndexedSkeletonSource { return ( - typeof value === "object" && - value !== null && - hasFunction(value, "supports") && - hasFunction(value, "createAddNodeCommand") && - hasFunction(value, "createInsertNodeCommand") && - hasFunction(value, "createMoveNodeCommand") && - hasFunction(value, "createDeleteNodeCommand") && - hasFunction(value, "createSplitCommand") && - hasFunction(value, "createMergeCommand") + isSpatiallyIndexedSkeletonSource(value) && + hasFunction(value, "addNode") && + hasFunction(value, "deleteNode") && + hasFunction(value, "moveNode") && + hasFunction(value, "splitSkeleton") && + hasFunction(value, "mergeSkeletons") && + hasFunction(value, "toggleTrueEnd") ); } @@ -75,12 +74,13 @@ export function getSpatiallyIndexedSkeletonSource( : undefined; } -export function getSpatialSkeletonEditController( +export function getEditableSpatiallyIndexedSkeletonSource( value: SpatialSkeletonSourceAccess | undefined, -): SpatialSkeletonEditController | undefined { - const source = getSpatiallyIndexedSkeletonSource(value); - const controller = source?.spatialSkeletonEditController; - return isSpatialSkeletonEditController(controller) ? controller : undefined; +): EditableSpatiallyIndexedSkeletonSource | undefined { + if (value === undefined) return undefined; + return isEditableSpatiallyIndexedSkeletonSource(value.source) + ? value.source + : undefined; } export function normalizeSpatiallyIndexedSkeletonNode( @@ -370,7 +370,10 @@ export class SpatialSkeletonState extends RefCounted { return true; } - setCachedNodeSourceState(nodeId: number, sourceState: unknown) { + setCachedNodeSourceState( + nodeId: number, + sourceState: SpatialSkeletonSourceState | undefined, + ) { if (sourceState === undefined) { return false; } @@ -388,7 +391,7 @@ export class SpatialSkeletonState extends RefCounted { setCachedNodeSourceStates( sourceStateUpdates: readonly { nodeId: number; - sourceState: unknown; + sourceState: SpatialSkeletonSourceState; }[], ) { let changed = false; diff --git a/src/ui/spatial_skeleton_edit_tool.spec.ts b/src/ui/spatial_skeleton_edit_tool.spec.ts index 17d7cb1b0..1d01f9d99 100644 --- a/src/ui/spatial_skeleton_edit_tool.spec.ts +++ b/src/ui/spatial_skeleton_edit_tool.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { makeCatmaidNodeSourceState } from "#src/datasource/catmaid/api.js"; -import { CatmaidSpatialSkeletonEditController } from "#src/datasource/catmaid/spatial_skeleton_commands.js"; +import { CatmaidSpatialSkeletonEditCommandSource } from "#src/datasource/catmaid/spatial_skeleton_commands.js"; import { executeSpatialSkeletonAddNode, executeSpatialSkeletonMerge, @@ -44,7 +44,8 @@ function makeVisibleSegmentsState(initialVisibleSegments: bigint[] = []) { function makeEditableSkeletonSource(overrides: Record = {}) { return { - spatialSkeletonEditController: new CatmaidSpatialSkeletonEditController(), + spatialSkeletonEditCommandSource: + new CatmaidSpatialSkeletonEditCommandSource(), listSkeletons: vi.fn(), getSkeleton: vi.fn(), fetchNodes: vi.fn(), @@ -56,8 +57,7 @@ function makeEditableSkeletonSource(overrides: Record = {}) { deleteNode: vi.fn(), rerootSkeleton: vi.fn(), updateDescription: vi.fn(), - setTrueEnd: vi.fn(), - removeTrueEnd: vi.fn(), + toggleTrueEnd: vi.fn(), updateRadius: vi.fn(), updateConfidence: vi.fn(), mergeSkeletons: vi.fn(), diff --git a/src/ui/spatial_skeleton_edit_tool.ts b/src/ui/spatial_skeleton_edit_tool.ts index 6a24f4595..e039bc20c 100644 --- a/src/ui/spatial_skeleton_edit_tool.ts +++ b/src/ui/spatial_skeleton_edit_tool.ts @@ -37,6 +37,10 @@ import { } from "#src/segmentation_display_state/base.js"; import { SpatialSkeletonActions } from "#src/skeleton/actions.js"; import { setSpatialSkeletonModesToLinesAndPoints } from "#src/skeleton/edit_mode_rendering.js"; +import type { + SpatialSkeletonSourceState, + SpatialSkeletonVector, +} from "#src/skeleton/api.js"; import type { SpatiallyIndexedSkeletonLayer } from "#src/skeleton/frontend.js"; import { PerspectiveViewSpatiallyIndexedSkeletonLayer, @@ -185,7 +189,7 @@ abstract class SpatialSkeletonToolBase extends LayerTool nodeId: number; segmentId?: number; position?: Float32Array; - sourceState?: unknown; + sourceState?: SpatialSkeletonSourceState; } | undefined { if (!this.mouseState.updateUnconditionally() || !this.mouseState.active) { @@ -384,8 +388,8 @@ abstract class SpatialSkeletonToolBase extends LayerTool | { nodeId: number; segmentId?: number; - position?: Float32Array; - sourceState?: unknown; + position?: SpatialSkeletonVector; + sourceState?: SpatialSkeletonSourceState; visible: boolean; } | undefined { @@ -1062,7 +1066,7 @@ class SpatialSkeletonMergeModeTool extends SpatialSkeletonToolBase { nodeId: number; segmentId?: number; position?: ArrayLike; - sourceState?: unknown; + sourceState?: SpatialSkeletonSourceState; }; let anchorSelection: MergeAnchorSelection | undefined; let statusOverride: string | undefined; From e140fb0529141c60ed92a0b248a37dbe9c5af12f Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Tue, 5 May 2026 12:59:13 +0100 Subject: [PATCH 04/11] feat: Remove LOD from generic api --- src/datasource/catmaid/api.spec.ts | 14 +- src/datasource/catmaid/api.ts | 24 ++- src/datasource/catmaid/backend.ts | 31 +--- src/datasource/catmaid/base.ts | 1 + src/datasource/catmaid/frontend.ts | 24 ++- src/layer/segmentation/index.spec.ts | 2 +- src/layer/segmentation/index.ts | 47 +---- src/layer/segmentation/json_keys.ts | 1 - .../spatial_skeleton_serialization.spec.ts | 10 +- .../spatial_skeleton_serialization.ts | 7 - src/skeleton/backend.spec.ts | 160 +---------------- src/skeleton/backend.ts | 167 +----------------- src/skeleton/frontend.ts | 141 ++------------- src/skeleton/skeleton_chunk_serialization.ts | 4 - 14 files changed, 89 insertions(+), 544 deletions(-) diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index cbc142fff..2ec8e58e5 100644 --- a/src/datasource/catmaid/api.spec.ts +++ b/src/datasource/catmaid/api.spec.ts @@ -18,6 +18,7 @@ import { describe, expect, it, vi } from "vitest"; import { CatmaidClient, + getCatmaidSpatialSkeletonGridCellBounds, makeCatmaidNodeSourceState, } from "#src/datasource/catmaid/api.js"; @@ -319,7 +320,7 @@ describe("CatmaidClient skeleton editing methods", () => { (client as any).fetch = fetchMock; await expect( - client.fetchNodes({ + client.fetchNodesInBoundingBox({ lowerBounds: [0, 0, 0], upperBounds: [10, 10, 10], }), @@ -343,13 +344,22 @@ describe("CatmaidClient skeleton editing methods", () => { expect(getFetchPath(fetchMock)).toMatch(/^node\/list\?/); }); + it("converts spatial skeleton grid cell indices to CATMAID bounds", () => { + expect( + getCatmaidSpatialSkeletonGridCellBounds([2, 3, 4], [10, 20, 30]), + ).toEqual({ + lowerBounds: [20, 60, 120], + upperBounds: [30, 80, 150], + }); + }); + it("rejects CATMAID node-list bounds with fewer than three coordinates", async () => { const client = new CatmaidClient("https://example.invalid", 1); const fetchMock = vi.fn(); (client as any).fetch = fetchMock; await expect( - client.fetchNodes({ + client.fetchNodesInBoundingBox({ lowerBounds: [0, 0], upperBounds: [10, 10], }), diff --git a/src/datasource/catmaid/api.ts b/src/datasource/catmaid/api.ts index fb2f7f0d7..2b40687bb 100644 --- a/src/datasource/catmaid/api.ts +++ b/src/datasource/catmaid/api.ts @@ -572,6 +572,28 @@ function normalizeBoundingBoxForNodeList(bounds: SpatialSkeletonBounds) { return { left, top, z1, right, bottom, z2 }; } +export function getCatmaidSpatialSkeletonGridCellBounds( + cellIndex: SpatialSkeletonVector, + chunkSize: SpatialSkeletonVector, +): SpatialSkeletonBounds { + const [cellX, cellY, cellZ] = requireCatmaidRank3Vector( + cellIndex, + "spatial skeleton grid cell index", + ); + const [sizeX, sizeY, sizeZ] = requireCatmaidRank3Vector( + chunkSize, + "spatial skeleton grid cell size", + ); + return { + lowerBounds: [cellX * sizeX, cellY * sizeY, cellZ * sizeZ], + upperBounds: [ + (cellX + 1) * sizeX, + (cellY + 1) * sizeY, + (cellZ + 1) * sizeZ, + ], + }; +} + function appendNodeUpdateRows( body: URLSearchParams, key: string, @@ -1303,7 +1325,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { })); } - async fetchNodes( + async fetchNodesInBoundingBox( bounds: SpatialSkeletonBounds, lod: number = 0, options: { diff --git a/src/datasource/catmaid/backend.ts b/src/datasource/catmaid/backend.ts index 4374721f7..8a3d52172 100644 --- a/src/datasource/catmaid/backend.ts +++ b/src/datasource/catmaid/backend.ts @@ -17,7 +17,10 @@ import { WithParameters } from "#src/chunk_manager/backend.js"; import { WithSharedCredentialsProviderCounterpart } from "#src/credentials_provider/shared_counterpart.js"; import type { CatmaidToken } from "#src/datasource/catmaid/api.js"; -import { CatmaidClient } from "#src/datasource/catmaid/api.js"; +import { + CatmaidClient, + getCatmaidSpatialSkeletonGridCellBounds, +} from "#src/datasource/catmaid/api.js"; import { CatmaidSkeletonSourceParameters, CatmaidCompleteSkeletonSourceParameters, @@ -31,7 +34,6 @@ import { SpatiallyIndexedSkeletonSourceBackend, SkeletonSource, } from "#src/skeleton/backend.js"; -import { vec3 } from "#src/util/geom.js"; import { registerSharedObject } from "#src/worker_rpc.js"; @registerSharedObject() @@ -64,28 +66,13 @@ export class CatmaidSpatiallyIndexedSkeletonSourceBackend extends WithParameters async download(chunk: SpatiallyIndexedSkeletonChunk, signal: AbortSignal) { const { chunkGridPosition } = chunk; const { chunkDataSize } = this.spec; - - const localMin = vec3.multiply( - vec3.create(), - chunkGridPosition as unknown as vec3, - chunkDataSize as unknown as vec3, - ); - const localMax = vec3.add( - vec3.create(), - localMin, - chunkDataSize as unknown as vec3, + const bounds = getCatmaidSpatialSkeletonGridCellBounds( + chunkGridPosition, + chunkDataSize, ); - - const bounds = { - lowerBounds: localMin, - upperBounds: localMax, - }; - - // Use LOD stored on the chunk to support per-view LODs on shared sources. - const lodValue = chunk.lod ?? this.currentLod; - // Get cache provider from parameters (passed from frontend) + const lodValue = this.parameters.catmaidLod ?? 0; const cacheProvider = this.parameters.catmaidParameters.cacheProvider; - const nodes = await this.client.fetchNodes(bounds, lodValue, { + const nodes = await this.client.fetchNodesInBoundingBox(bounds, lodValue, { cacheProvider, signal, }); diff --git a/src/datasource/catmaid/base.ts b/src/datasource/catmaid/base.ts index e1a7406f2..511711d47 100644 --- a/src/datasource/catmaid/base.ts +++ b/src/datasource/catmaid/base.ts @@ -25,6 +25,7 @@ export class CatmaidDataSourceParameters { export class CatmaidSkeletonSourceParameters extends SkeletonSourceParameters { catmaidParameters!: CatmaidDataSourceParameters; gridIndex?: number; + catmaidLod?: number; static RPC_ID = "catmaid/SkeletonSource"; } diff --git a/src/datasource/catmaid/frontend.ts b/src/datasource/catmaid/frontend.ts index 998616bbf..3c60dd234 100644 --- a/src/datasource/catmaid/frontend.ts +++ b/src/datasource/catmaid/frontend.ts @@ -39,6 +39,7 @@ import { CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES, CatmaidClient, credentialsKey, + getCatmaidSpatialSkeletonGridCellBounds, } from "#src/datasource/catmaid/api.js"; import { CatmaidSkeletonSourceParameters, @@ -57,7 +58,7 @@ import { } from "#src/segmentation_display_state/property_map.js"; import type { EditableSpatiallyIndexedSkeletonSource, - SpatialSkeletonBounds, + SpatialSkeletonGridCellIndex, SpatiallyIndexedSkeletonMetadata, SpatiallyIndexedSkeletonNode, SpatiallyIndexedSkeletonNodeBase, @@ -128,14 +129,22 @@ export class CatmaidSpatiallyIndexedSkeletonSource } fetchNodes( - bounds: SpatialSkeletonBounds, - lod?: number, - options?: { + cellIndex: SpatialSkeletonGridCellIndex, + options: { cacheProvider?: string; + lod?: number; signal?: AbortSignal; - }, + } = {}, ): Promise { - return this.client.fetchNodes(bounds, lod, options); + const bounds = getCatmaidSpatialSkeletonGridCellBounds( + cellIndex.cell, + this.spec.chunkDataSize, + ); + return this.client.fetchNodesInBoundingBox( + bounds, + options.lod ?? this.parameters.catmaidLod ?? 0, + options, + ); } getSkeletonRootNode(skeletonId: number) { @@ -299,6 +308,7 @@ export class CatmaidMultiscaleSpatiallyIndexedSkeletonSource extends MultiscaleS // Sorted by minimum dimension (Descending: Large/Coarse -> Small/Fine) const sortedGridSizes = this.sortedGridCellSizes; + const lastGridIndex = sortedGridSizes.length - 1; for (const [gridIndex, gridCellSize] of sortedGridSizes.entries()) { const chunkDataSize = Uint32Array.from([ gridCellSize.x, @@ -338,6 +348,8 @@ export class CatmaidMultiscaleSpatiallyIndexedSkeletonSource extends MultiscaleS parameters.catmaidParameters.projectId = this.projectId; parameters.catmaidParameters.cacheProvider = this.cacheProvider; parameters.gridIndex = gridIndex; + parameters.catmaidLod = + lastGridIndex <= 0 ? 0 : gridIndex / lastGridIndex; parameters.metadata = { transform: mat4.create(), vertexAttributes: new Map([ diff --git a/src/layer/segmentation/index.spec.ts b/src/layer/segmentation/index.spec.ts index 3f41a48c4..b9e1ce51d 100644 --- a/src/layer/segmentation/index.spec.ts +++ b/src/layer/segmentation/index.spec.ts @@ -164,7 +164,7 @@ describe("layer/segmentation spatial skeleton chunk stats", () => { }); describe("layer/segmentation spatial skeleton action gating", () => { - it("does not require max lod for skeleton actions", () => { + it("does not require a specific grid level for skeleton actions", () => { const layer = Object.assign( Object.create(SegmentationUserLayer.prototype), { diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 4415a314e..e85b7dc2b 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -153,7 +153,6 @@ import { SegmentationRenderLayer } from "#src/sliceview/volume/segmentation_rend import { StatusMessage } from "#src/status.js"; import { trackableAlphaValue } from "#src/trackable_alpha.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; -import { trackableFiniteFloat } from "#src/trackable_finite_float.js"; import type { TrackableValueInterface, WatchableValueInterface, @@ -580,7 +579,7 @@ class LinkedSegmentationGroupState< } type SpatialSkeletonGridSize = { x: number; y: number; z: number }; -type SpatialSkeletonGridLevel = { size: SpatialSkeletonGridSize; lod: number }; +type SpatialSkeletonGridLevel = { size: SpatialSkeletonGridSize }; function getSpatialSkeletonGridSpacing(size: SpatialSkeletonGridSize) { return Math.min(size.x, size.y, size.z); @@ -589,28 +588,7 @@ function getSpatialSkeletonGridSpacing(size: SpatialSkeletonGridSize) { function buildSpatialSkeletonGridLevels( gridSizes: SpatialSkeletonGridSize[], ): SpatialSkeletonGridLevel[] { - if (gridSizes.length === 0) return []; - const lastIndex = gridSizes.length - 1; - return gridSizes.map((size, index) => ({ - size, - lod: lastIndex === 0 ? 0 : index / lastIndex, - })); -} - -function findClosestSpatialSkeletonGridLevel( - levels: SpatialSkeletonGridLevel[], - lod: number, -): number { - let bestIndex = 0; - let bestDistance = Number.POSITIVE_INFINITY; - for (let i = 0; i < levels.length; ++i) { - const distance = Math.abs(levels[i].lod - lod); - if (distance < bestDistance) { - bestDistance = distance; - bestIndex = i; - } - } - return bestIndex; + return gridSizes.map((size) => ({ size })); } function findClosestSpatialSkeletonGridLevelBySpacing( @@ -904,7 +882,6 @@ class SegmentationUserLayerDisplayState implements SegmentationDisplayState { ); objectAlpha = trackableAlphaValue(1.0); hiddenObjectAlpha = trackableAlphaValue(0.5); - skeletonLod = trackableFiniteFloat(0.0); spatialSkeletonGridLevel2d = new TrackableValue( 0, verifyNonnegativeInt, @@ -942,7 +919,6 @@ class SegmentationUserLayerDisplayState implements SegmentationDisplayState { }); spatialSkeletonGridRenderScaleHistogram2d = new RenderScaleHistogram(); spatialSkeletonGridRenderScaleHistogram3d = new RenderScaleHistogram(); - spatialSkeletonLod2d = new WatchableValue(0); spatialSkeletonNodeQuery = new TrackableValue("", verifyString); spatialSkeletonNodeFilter = new TrackableEnum( SpatialSkeletonNodeFilterType, @@ -1016,7 +992,7 @@ class SegmentationUserLayerDisplayState implements SegmentationDisplayState { ) : this.spatialSkeletonGridLevel3dExplicit ? this.spatialSkeletonGridLevel3d.value - : findClosestSpatialSkeletonGridLevel(levels, this.skeletonLod.value); + : this.spatialSkeletonGridLevel3d.value; const resolved3dIndex = this.setSpatialSkeletonGridLevel( "3d", target3dIndex, @@ -1201,20 +1177,12 @@ class SegmentationUserLayerDisplayState implements SegmentationDisplayState { this.suppressSpatialSkeletonGridLevel2d = true; this.spatialSkeletonGridLevel2d.value = clampedIndex; this.suppressSpatialSkeletonGridLevel2d = false; - const nextLod = levels[clampedIndex].lod; - if (this.spatialSkeletonLod2d.value !== nextLod) { - this.spatialSkeletonLod2d.value = nextLod; - } return clampedIndex; } if (markExplicit) this.spatialSkeletonGridLevel3dExplicit = true; this.suppressSpatialSkeletonGridLevel3d = true; this.spatialSkeletonGridLevel3d.value = clampedIndex; this.suppressSpatialSkeletonGridLevel3d = false; - const nextLod = levels[clampedIndex].lod; - if (this.skeletonLod.value !== nextLod) { - this.skeletonLod.value = nextLod; - } return clampedIndex; } @@ -1613,9 +1581,6 @@ export class SegmentationUserLayer extends Base { this.displayState.hiddenObjectAlpha.changed.add( this.specificationChanged.dispatch, ); - this.displayState.skeletonLod.changed.add( - this.specificationChanged.dispatch, - ); this.displayState.spatialSkeletonNodeQuery.changed.add( this.specificationChanged.dispatch, ); @@ -2099,7 +2064,6 @@ export class SegmentationUserLayer extends Base { displayState, { gridLevel: displayState.spatialSkeletonGridLevel3d, - lod: displayState.skeletonLod, sources2d: slicePanelSources, selectedNodeId: this.selectedSpatialSkeletonNodeId, pendingNodePositionVersion: @@ -2135,7 +2099,6 @@ export class SegmentationUserLayer extends Base { displayState, { gridLevel: displayState.spatialSkeletonGridLevel3d, - lod: displayState.skeletonLod, selectedNodeId: this.selectedSpatialSkeletonNodeId, pendingNodePositionVersion: this.spatialSkeletonState.pendingNodePositionVersion, @@ -2346,9 +2309,6 @@ export class SegmentationUserLayer extends Base { this.displayState.hiddenObjectAlpha.restoreState( specification[json_keys.HIDDEN_OPACITY_3D_JSON_KEY], ); - this.displayState.skeletonLod.restoreState( - specification[json_keys.SKELETON_LOD_JSON_KEY], - ); this.displayState.spatialSkeletonNodeQuery.restoreState( specification[json_keys.SPATIAL_SKELETON_NODE_QUERY_JSON_KEY], ); @@ -2455,7 +2415,6 @@ export class SegmentationUserLayer extends Base { x, { hiddenObjectAlpha: this.displayState.hiddenObjectAlpha, - skeletonLod: this.displayState.skeletonLod, spatialSkeletonGridResolutionTarget2d: this.displayState.spatialSkeletonGridResolutionTarget2d, spatialSkeletonGridResolutionTarget3d: diff --git a/src/layer/segmentation/json_keys.ts b/src/layer/segmentation/json_keys.ts index 4b1a53469..46adf70c5 100644 --- a/src/layer/segmentation/json_keys.ts +++ b/src/layer/segmentation/json_keys.ts @@ -2,7 +2,6 @@ export const SELECTED_ALPHA_JSON_KEY = "selectedAlpha"; export const NOT_SELECTED_ALPHA_JSON_KEY = "notSelectedAlpha"; export const OBJECT_ALPHA_JSON_KEY = "objectAlpha"; export const HIDDEN_OPACITY_3D_JSON_KEY = "hiddenObjectAlpha"; -export const SKELETON_LOD_JSON_KEY = "skeletonLod"; export const SPATIAL_SKELETON_GRID_LEVEL_2D_JSON_KEY = "spatialSkeletonGridLevel2d"; export const SPATIAL_SKELETON_GRID_LEVEL_3D_JSON_KEY = diff --git a/src/layer/segmentation/spatial_skeleton_serialization.spec.ts b/src/layer/segmentation/spatial_skeleton_serialization.spec.ts index 9b91739d4..39fbe9953 100644 --- a/src/layer/segmentation/spatial_skeleton_serialization.spec.ts +++ b/src/layer/segmentation/spatial_skeleton_serialization.spec.ts @@ -20,7 +20,6 @@ import * as json_keys from "#src/layer/segmentation/json_keys.js"; import { appendSpatialSkeletonSerializationState } from "#src/layer/segmentation/spatial_skeleton_serialization.js"; import { trackableAlphaValue } from "#src/trackable_alpha.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; -import { trackableFiniteFloat } from "#src/trackable_finite_float.js"; import { TrackableValue } from "#src/trackable_value.js"; import { verifyFiniteNonNegativeFloat, @@ -30,7 +29,6 @@ import { function makeTrackables() { return { hiddenObjectAlpha: trackableAlphaValue(0.5), - skeletonLod: trackableFiniteFloat(0), spatialSkeletonGridResolutionTarget2d: new TrackableValue( 1, verifyFiniteNonNegativeFloat, @@ -63,9 +61,6 @@ describe("appendSpatialSkeletonSerializationState", () => { trackables.hiddenObjectAlpha.restoreState( legacySpec[json_keys.HIDDEN_OPACITY_3D_JSON_KEY], ); - trackables.skeletonLod.restoreState( - legacySpec[json_keys.SKELETON_LOD_JSON_KEY], - ); trackables.spatialSkeletonGridResolutionTarget2d.restoreState( legacySpec[json_keys.SPATIAL_SKELETON_GRID_RESOLUTION_TARGET_2D_JSON_KEY], ); @@ -100,7 +95,7 @@ describe("appendSpatialSkeletonSerializationState", () => { it("emits non-default values for non-spatial layers", () => { const trackables = makeTrackables(); - trackables.skeletonLod.value = 0.35; + trackables.hiddenObjectAlpha.value = 0.35; const serialized: Record = {}; appendSpatialSkeletonSerializationState( @@ -109,7 +104,7 @@ describe("appendSpatialSkeletonSerializationState", () => { /* includeDefaults= */ false, ); expect(serialized).toEqual({ - [json_keys.SKELETON_LOD_JSON_KEY]: 0.35, + [json_keys.HIDDEN_OPACITY_3D_JSON_KEY]: 0.35, }); }); @@ -124,7 +119,6 @@ describe("appendSpatialSkeletonSerializationState", () => { ); expect(serialized).toEqual({ [json_keys.HIDDEN_OPACITY_3D_JSON_KEY]: 0.5, - [json_keys.SKELETON_LOD_JSON_KEY]: 0, [json_keys.SPATIAL_SKELETON_GRID_RESOLUTION_TARGET_2D_JSON_KEY]: 1, [json_keys.SPATIAL_SKELETON_GRID_RESOLUTION_TARGET_3D_JSON_KEY]: 1, [json_keys.SPATIAL_SKELETON_GRID_RESOLUTION_RELATIVE_2D_JSON_KEY]: false, diff --git a/src/layer/segmentation/spatial_skeleton_serialization.ts b/src/layer/segmentation/spatial_skeleton_serialization.ts index 8a7e9658b..27e6a10f1 100644 --- a/src/layer/segmentation/spatial_skeleton_serialization.ts +++ b/src/layer/segmentation/spatial_skeleton_serialization.ts @@ -23,7 +23,6 @@ export interface JsonSerializableTrackable { export interface SpatialSkeletonSerializationTrackables { hiddenObjectAlpha: JsonSerializableTrackable; - skeletonLod: JsonSerializableTrackable; spatialSkeletonGridResolutionTarget2d: JsonSerializableTrackable; spatialSkeletonGridResolutionTarget3d: JsonSerializableTrackable; spatialSkeletonGridResolutionRelative2d: JsonSerializableTrackable; @@ -65,12 +64,6 @@ export function appendSpatialSkeletonSerializationState( trackables.hiddenObjectAlpha, includeDefaults, ); - setSerializedTrackable( - target, - json_keys.SKELETON_LOD_JSON_KEY, - trackables.skeletonLod, - includeDefaults, - ); setSerializedTrackable( target, json_keys.SPATIAL_SKELETON_GRID_RESOLUTION_TARGET_2D_JSON_KEY, diff --git a/src/skeleton/backend.spec.ts b/src/skeleton/backend.spec.ts index a7c64e0c9..f67d8e315 100644 --- a/src/skeleton/backend.spec.ts +++ b/src/skeleton/backend.spec.ts @@ -1,12 +1,6 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; -import { ChunkState } from "#src/chunk_manager/base.js"; -import { - cancelStaleSpatiallyIndexedSkeletonDownloads, - getSpatiallyIndexedSkeletonChunkPriority, - markSpatiallyIndexedSkeletonChunkRequested, - SpatiallyIndexedSkeletonChunkRequestOwner, -} from "#src/skeleton/backend.js"; +import { getSpatiallyIndexedSkeletonChunkPriority } from "#src/skeleton/backend.js"; describe("skeleton/backend chunk priority", () => { it("uses the standard chunk-origin distance rule for 3d chunks", () => { @@ -40,153 +34,3 @@ describe("skeleton/backend chunk priority", () => { ); }); }); - -describe("skeleton/backend stale LOD cancellation", () => { - function makeChunk(state = ChunkState.DOWNLOADING) { - return { - state, - requestGeneration: -1, - requestOwners: SpatiallyIndexedSkeletonChunkRequestOwner.NONE, - downloadAbortController: new AbortController(), - } as any; - } - - function makeSource(chunk: any) { - return { - chunks: new Map([["0,0,0:0", chunk]]), - } as any; - } - - function makeChunkManager() { - return { - queueManager: { - updateChunkState: vi.fn(), - }, - } as any; - } - - it("tracks both owners within the same recompute generation", () => { - const chunk = makeChunk(); - - markSpatiallyIndexedSkeletonChunkRequested( - chunk, - 5, - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_2D, - ); - markSpatiallyIndexedSkeletonChunkRequested( - chunk, - 5, - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_3D, - ); - - expect(chunk.requestGeneration).toBe(5); - expect(chunk.requestOwners).toBe( - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_2D | - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_3D, - ); - - markSpatiallyIndexedSkeletonChunkRequested( - chunk, - 6, - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_3D, - ); - - expect(chunk.requestGeneration).toBe(6); - expect(chunk.requestOwners).toBe( - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_3D, - ); - }); - - it("aborts stale downloading chunks that were not requested this recompute", () => { - const chunkManager = makeChunkManager(); - const chunk = makeChunk(); - const source = makeSource(chunk); - - markSpatiallyIndexedSkeletonChunkRequested( - chunk, - 4, - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_2D, - ); - - cancelStaleSpatiallyIndexedSkeletonDownloads(chunkManager, [source], 5); - - expect(chunk.downloadAbortController).toBeUndefined(); - expect(chunkManager.queueManager.updateChunkState).toHaveBeenCalledWith( - chunk, - ChunkState.QUEUED, - ); - }); - - it("keeps downloads requested by 3D in the current recompute", () => { - const chunkManager = makeChunkManager(); - const chunk = makeChunk(); - const source = makeSource(chunk); - - markSpatiallyIndexedSkeletonChunkRequested( - chunk, - 8, - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_3D, - ); - - cancelStaleSpatiallyIndexedSkeletonDownloads(chunkManager, [source], 8); - - expect(chunk.downloadAbortController?.signal.aborted).toBe(false); - expect(chunkManager.queueManager.updateChunkState).not.toHaveBeenCalled(); - }); - - it("keeps downloads requested by 2D in the current recompute", () => { - const chunkManager = makeChunkManager(); - const chunk = makeChunk(); - const source = makeSource(chunk); - - markSpatiallyIndexedSkeletonChunkRequested( - chunk, - 9, - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_2D, - ); - - cancelStaleSpatiallyIndexedSkeletonDownloads(chunkManager, [source], 9); - - expect(chunk.downloadAbortController?.signal.aborted).toBe(false); - expect(chunkManager.queueManager.updateChunkState).not.toHaveBeenCalled(); - }); - - it("keeps shared downloads when both owners still request the chunk", () => { - const chunkManager = makeChunkManager(); - const chunk = makeChunk(); - const source = makeSource(chunk); - - markSpatiallyIndexedSkeletonChunkRequested( - chunk, - 11, - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_2D, - ); - markSpatiallyIndexedSkeletonChunkRequested( - chunk, - 11, - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_3D, - ); - - cancelStaleSpatiallyIndexedSkeletonDownloads(chunkManager, [source], 11); - - expect(chunk.downloadAbortController?.signal.aborted).toBe(false); - expect(chunkManager.queueManager.updateChunkState).not.toHaveBeenCalled(); - }); - - it("does not touch queued chunks that never started downloading", () => { - const chunkManager = makeChunkManager(); - const chunk = makeChunk(ChunkState.QUEUED); - const source = makeSource(chunk); - - markSpatiallyIndexedSkeletonChunkRequested( - chunk, - 2, - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_2D, - ); - - cancelStaleSpatiallyIndexedSkeletonDownloads(chunkManager, [source], 3); - - expect(chunk.downloadAbortController?.signal.aborted).toBe(false); - expect(chunkManager.queueManager.updateChunkState).not.toHaveBeenCalled(); - }); -}); diff --git a/src/skeleton/backend.ts b/src/skeleton/backend.ts index 4c88e98e3..960fe0ab3 100644 --- a/src/skeleton/backend.ts +++ b/src/skeleton/backend.ts @@ -14,8 +14,6 @@ * limitations under the License. */ -import { debounce } from "lodash-es"; -import type { ChunkManager } from "#src/chunk_manager/backend.js"; import { Chunk, ChunkRenderLayerBackend, @@ -88,7 +86,6 @@ export interface SpatiallyIndexedSkeletonChunkSpecification } const SKELETON_CHUNK_PRIORITY = 60; -const SPATIALLY_INDEXED_SKELETON_LOD_DEBOUNCE_MS = 300; const tempCenter = vec3.create(); const tempChunkSize = vec3.create(); const tempCenterDataPosition = vec3.create(); @@ -106,59 +103,6 @@ export function getSpatiallyIndexedSkeletonChunkPriority( return -Math.sqrt(sum); } -export enum SpatiallyIndexedSkeletonChunkRequestOwner { - NONE = 0, - VIEW_2D = 1 << 0, - VIEW_3D = 1 << 1, -} - -export function markSpatiallyIndexedSkeletonChunkRequested( - chunk: SpatiallyIndexedSkeletonChunk, - currentGeneration: number, - owner: SpatiallyIndexedSkeletonChunkRequestOwner, -) { - if ( - owner === SpatiallyIndexedSkeletonChunkRequestOwner.NONE || - currentGeneration < 0 - ) { - return; - } - if (chunk.requestGeneration !== currentGeneration) { - chunk.requestGeneration = currentGeneration; - chunk.requestOwners = owner; - return; - } - chunk.requestOwners |= owner; -} - -export function cancelStaleSpatiallyIndexedSkeletonDownloads( - chunkManager: ChunkManager, - sources: Iterable, - currentGeneration: number, -) { - const queueManager = chunkManager.queueManager; - for (const source of sources) { - for (const chunk of source.chunks.values()) { - const typedChunk = chunk as SpatiallyIndexedSkeletonChunk; - if (typedChunk.state !== ChunkState.DOWNLOADING) continue; - if ( - typedChunk.requestGeneration === currentGeneration && - typedChunk.requestOwners !== - SpatiallyIndexedSkeletonChunkRequestOwner.NONE - ) { - continue; - } - const controller = typedChunk.downloadAbortController; - if (controller === undefined) continue; - typedChunk.downloadAbortController = undefined; - controller.abort( - new DOMException("stale spatial skeleton LOD download", "AbortError"), - ); - queueManager.updateChunkState(typedChunk, ChunkState.QUEUED); - } - } -} - registerRPC( SPATIALLY_INDEXED_SKELETON_RENDER_LAYER_UPDATE_SOURCES_RPC_ID, function (x) { @@ -300,9 +244,6 @@ export class SpatiallyIndexedSkeletonChunk vertexPositions: Float32Array | null = null; vertexAttributes: TypedNumberArray[] | null = null; indices: Uint32Array | null = null; - lod: number = 0; - requestGeneration = -1; - requestOwners = SpatiallyIndexedSkeletonChunkRequestOwner.NONE; nodeIds: Int32Array | undefined; nodeSourceStates: Array | undefined; @@ -328,27 +269,17 @@ export class SpatiallyIndexedSkeletonSourceBackend extends SliceViewChunkSourceB SpatiallyIndexedSkeletonChunk > { chunkConstructor = SpatiallyIndexedSkeletonChunk; - currentLod: number = 0; - currentRequestGeneration = -1; - currentRequestOwner = SpatiallyIndexedSkeletonChunkRequestOwner.NONE; getChunk(chunkGridPosition: Float32Array) { - const lodValue = this.currentLod; - const key = `${chunkGridPosition.join()}:${lodValue}`; + const key = chunkGridPosition.join(); let chunk = this.chunks.get(key); if (chunk === undefined) { chunk = this.getNewChunk_( this.chunkConstructor, ) as SpatiallyIndexedSkeletonChunk; chunk.initializeVolumeChunk(key, chunkGridPosition); - chunk.lod = lodValue; this.addChunk(chunk); } - markSpatiallyIndexedSkeletonChunkRequested( - chunk, - this.currentRequestGeneration, - this.currentRequestOwner, - ); return chunk; } } @@ -367,15 +298,12 @@ export class SpatiallyIndexedSkeletonRenderLayerBackend extends withChunkManager ) { localPosition: SharedWatchableValue; renderScaleTarget: SharedWatchableValue; - skeletonLod: SharedWatchableValue; skeletonGridLevel: SharedWatchableValue; - private pendingLodCleanup = false; constructor(rpc: RPC, options: any) { super(rpc, options); this.renderScaleTarget = rpc.get(options.renderScaleTarget); this.localPosition = rpc.get(options.localPosition); - this.skeletonLod = rpc.get(options.skeletonLod); this.skeletonGridLevel = rpc.get(options.skeletonGridLevel); const scheduleUpdateChunkPriorities = () => this.chunkManager.scheduleUpdateChunkPriorities(); @@ -388,49 +316,11 @@ export class SpatiallyIndexedSkeletonRenderLayerBackend extends withChunkManager this.registerDisposer( this.skeletonGridLevel.changed.add(scheduleUpdateChunkPriorities), ); - - // Debounce LOD changes to avoid making requests for every slider value - const debouncedLodUpdate = debounce(() => { - scheduleUpdateChunkPriorities(); - }, SPATIALLY_INDEXED_SKELETON_LOD_DEBOUNCE_MS); - this.registerDisposer(() => debouncedLodUpdate.cancel()); - - this.registerDisposer( - this.skeletonLod.changed.add(() => { - this.pendingLodCleanup = true; - debouncedLodUpdate(); - }), - ); this.registerDisposer( this.chunkManager.recomputeChunkPriorities.add(() => this.recomputeChunkPriorities(), ), ); - this.registerDisposer( - this.chunkManager.recomputeChunkPrioritiesLate.add(() => { - if (!this.pendingLodCleanup) return; - const sources = new Set(); - for (const attachment of this.attachments.values()) { - const attachmentState = attachment.state as - | SpatiallyIndexedSkeletonRenderLayerAttachmentState - | undefined; - if (attachmentState === undefined) continue; - for (const scales of attachmentState.transformedSources) { - for (const tsource of scales) { - sources.add( - tsource.source as SpatiallyIndexedSkeletonSourceBackend, - ); - } - } - } - cancelStaleSpatiallyIndexedSkeletonDownloads( - this.chunkManager, - sources, - this.chunkManager.recomputeChunkPriorities.count, - ); - this.pendingLodCleanup = false; - }), - ); } attach( @@ -458,7 +348,6 @@ export class SpatiallyIndexedSkeletonRenderLayerBackend extends withChunkManager private recomputeChunkPriorities() { this.chunkManager.registerLayer(this); - const currentGeneration = this.chunkManager.recomputeChunkPriorities.count; for (const attachment of this.attachments.values()) { const { view } = attachment; const visibility = view.visibility.value; @@ -607,7 +496,6 @@ export class SpatiallyIndexedSkeletonRenderLayerBackend extends withChunkManager return selected; }; - const lodValue = this.skeletonLod.value; for (const scales of transformedSources) { const selectedScales = selectScales(scales); for (const { tsource, scaleIndex } of selectedScales) { @@ -623,10 +511,6 @@ export class SpatiallyIndexedSkeletonRenderLayerBackend extends withChunkManager } const sourceBasePriority = basePriority + SCALE_PRIORITY_MULTIPLIER * scaleIndex; - source.currentLod = lodValue; - source.currentRequestGeneration = currentGeneration; - source.currentRequestOwner = - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_3D; forEachVisibleVolumetricChunk( projectionParameters, this.localPosition.value, @@ -658,71 +542,22 @@ export class SpatiallyIndexedSkeletonRenderLayerBackend extends withChunkManager @registerSharedObject(SPATIALLY_INDEXED_SKELETON_SLICEVIEW_RENDER_LAYER_RPC_ID) export class SpatiallyIndexedSkeletonSliceViewRenderLayerBackend extends SliceViewRenderLayerBackend { skeletonGridLevel: SharedWatchableValue; - skeletonLod: SharedWatchableValue; - private chunkManager_: ChunkManager; - private pendingLodCleanup = false; - private trackedSources = new Set(); constructor(rpc: RPC, options: any) { super(rpc, options); this.skeletonGridLevel = rpc.get(options.skeletonGridLevel); - this.skeletonLod = rpc.get(options.skeletonLod); const chunkManager = rpc.get(options.chunkManager); - this.chunkManager_ = chunkManager; const scheduleUpdateChunkPriorities = () => chunkManager.scheduleUpdateChunkPriorities(); this.registerDisposer( this.skeletonGridLevel.changed.add(scheduleUpdateChunkPriorities), ); - // Debounce LOD changes to avoid making requests for every slider value. - const debouncedLodUpdate = debounce(() => { - scheduleUpdateChunkPriorities(); - }, SPATIALLY_INDEXED_SKELETON_LOD_DEBOUNCE_MS); - this.registerDisposer(() => debouncedLodUpdate.cancel()); - - this.registerDisposer( - this.skeletonLod.changed.add(() => { - this.pendingLodCleanup = true; - debouncedLodUpdate(); - }), - ); - this.registerDisposer( - chunkManager.recomputeChunkPrioritiesLate.add(() => { - if (!this.pendingLodCleanup) return; - cancelStaleSpatiallyIndexedSkeletonDownloads( - chunkManager, - this.trackedSources, - chunkManager.recomputeChunkPriorities.count, - ); - this.pendingLodCleanup = false; - }), - ); - } - - prepareChunkSourceForRequest(source: SpatiallyIndexedSkeletonSourceBackend) { - this.trackedSources.add(source); - source.currentLod = this.skeletonLod.value; - source.currentRequestGeneration = - this.chunkManager_.recomputeChunkPriorities.count; - source.currentRequestOwner = - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_2D; } filterVisibleSources( sliceView: SliceViewBase, sources: readonly TransformedSource[], ): Iterable { - const lodValue = this.skeletonLod.value; - for (const tsource of sources) { - const source = tsource.source as SpatiallyIndexedSkeletonSourceBackend; - this.trackedSources.add(source); - source.currentLod = lodValue; - source.currentRequestGeneration = - this.chunkManager_.recomputeChunkPriorities.count; - source.currentRequestOwner = - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_2D; - } - if ( sources.length > 0 && sources.every( diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index 2055b9823..6dd8b4b56 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -1544,7 +1544,6 @@ export class SpatiallyIndexedSkeletonChunk vertexAttributeTextures: (WebGLTexture | null)[] = []; nodeIds: Int32Array = new Int32Array(0); nodeSourceStates: Array = []; - lod: number | undefined; constructor( source: SpatiallyIndexedSkeletonSource, @@ -1556,7 +1555,6 @@ export class SpatiallyIndexedSkeletonChunk this.numVertices = chunkData.numVertices; this.numIndices = indices.length; this.vertexAttributeOffsets = chunkData.vertexAttributeOffsets; - this.lod = (chunkData as any).lod; const nodeIdsData = (chunkData as any).nodeIds; if (nodeIdsData instanceof Int32Array) { this.nodeIds = nodeIdsData; @@ -1730,17 +1728,13 @@ export class MultiscaleSliceViewSpatiallyIndexedSkeletonLayer extends SliceViewR this.renderOptions.lineWidth.changed.add(this.redrawNeeded.dispatch), ); const rpc = this.chunkManager.rpc!; - const lod2d = (displayState as any).spatialSkeletonLod2d; - if (gridLevel2d !== undefined && lod2d !== undefined) { + if (gridLevel2d !== undefined) { this.rpcTransfer = { ...this.rpcTransfer, chunkManager: this.chunkManager.rpcId, skeletonGridLevel: this.registerDisposer( SharedWatchableValue.makeFromExisting(rpc, gridLevel2d), ).rpcId, - skeletonLod: this.registerDisposer( - SharedWatchableValue.makeFromExisting(rpc, lod2d), - ).rpcId, }; } this.initializeCounterpart(); @@ -1812,7 +1806,6 @@ export class MultiscaleSliceViewSpatiallyIndexedSkeletonLayer extends SliceViewR draw(renderContext: SliceViewRenderContext) { const displayState = this.displayState as any; - const lodValue = displayState.spatialSkeletonLod2d?.value as number; const sliceView = renderContext.sliceView; this.registerChunkStatsSliceView( sliceView as RefCounted & { rpcId: number }, @@ -1832,7 +1825,6 @@ export class MultiscaleSliceViewSpatiallyIndexedSkeletonLayer extends SliceViewR visibleSources, renderContext.projectionParameters, this.localPosition.value, - lodValue, ), ); } @@ -1843,7 +1835,6 @@ type SpatiallyIndexedSkeletonSourceEntry = interface SpatiallyIndexedSkeletonLayerOptions { gridLevel?: WatchableValueInterface; - lod?: WatchableValueInterface; sources2d?: SpatiallyIndexedSkeletonSourceEntry[]; selectedNodeId?: WatchableValueInterface; pendingNodePositionVersion?: WatchableValueInterface; @@ -1872,9 +1863,7 @@ interface SpatiallyIndexedSkeletonOverlayChunk extends SkeletonChunkInterface { function getSpatialSkeletonGridSpacing( transformedSource: TransformedSource, - levels: - | Array<{ size: { x: number; y: number; z: number }; lod: number }> - | undefined, + levels: Array<{ size: { x: number; y: number; z: number } }> | undefined, gridIndex: number, ) { const levelSize = levels?.[gridIndex]?.size; @@ -1891,18 +1880,14 @@ function updateSpatialSkeletonGridRenderScaleHistogram( transformedSources: readonly TransformedSource[][], projectionParameters: any, localPosition: Float32Array, - lod: number | undefined, - levels: - | Array<{ size: { x: number; y: number; z: number }; lod: number }> - | undefined, + levels: Array<{ size: { x: number; y: number; z: number } }> | undefined, relative: boolean, pixelSize: number, ) { histogram.begin(frameNumber); - if (lod === undefined || transformedSources.length === 0) { + if (transformedSources.length === 0) { return; } - const lodSuffix = `:${lod}`; const scales = transformedSources[0] ?? []; if (scales.length === 0) { return; @@ -1925,7 +1910,7 @@ function updateSpatialSkeletonGridRenderScaleHistogram( localPosition, tsource, (positionInChunks) => { - const key = `${positionInChunks.join()}${lodSuffix}`; + const key = positionInChunks.join(); const chunk = source.chunks.get(key) as | SpatiallyIndexedSkeletonChunk | undefined; @@ -2027,13 +2012,11 @@ function collectPlaneIntersectingSpatialChunkKeysBySource( transformedSources: readonly TransformedSource[], projectionParameters: any, localPosition: Float32Array, - lod: number | undefined, ) { const visibleChunkKeysBySource: VisibleSpatialChunkKeysBySource = new Map(); - if (lod === undefined || transformedSources.length === 0) { + if (transformedSources.length === 0) { return visibleChunkKeysBySource; } - const lodSuffix = `:${lod}`; const seenChunkKeysBySource = new Map>(); for (const tsource of transformedSources) { const sourceId = getObjectId(tsource.source); @@ -2056,7 +2039,7 @@ function collectPlaneIntersectingSpatialChunkKeysBySource( tsource, chunkLayout, (positionInChunks) => { - const chunkKey = `${positionInChunks.join()}${lodSuffix}`; + const chunkKey = positionInChunks.join(); if (seenChunkKeys!.has(chunkKey)) { return; } @@ -2114,7 +2097,6 @@ export class SpatiallyIndexedSkeletonLayer Map >(); gridLevel: WatchableValueInterface; - lod: WatchableValueInterface; private selectedNodeId: | WatchableValueInterface | undefined; @@ -2440,16 +2422,6 @@ export class SpatiallyIndexedSkeletonLayer return this.regularSkeletonLayerWatchable.value; } - private lodMatches( - chunk: SpatiallyIndexedSkeletonChunk, - targetLod: number | undefined, - ) { - if (targetLod === undefined || chunk.lod === undefined) { - return true; - } - return Math.abs(chunk.lod - targetLod) < 1e-6; - } - sources: SpatiallyIndexedSkeletonSourceEntry[]; sources2d: SpatiallyIndexedSkeletonSourceEntry[]; source: SpatiallyIndexedSkeletonSource; @@ -2511,8 +2483,6 @@ export class SpatiallyIndexedSkeletonLayer options.gridLevel ?? (displayState as any).spatialSkeletonGridLevel3d ?? new WatchableValue(0); - this.lod = - options.lod ?? (displayState as any).skeletonLod ?? new WatchableValue(0); this.selectedNodeId = options.selectedNodeId; this.pendingNodePositionVersion = options.pendingNodePositionVersion; this.getPendingNodePositionOverride = options.getPendingNodePosition; @@ -2570,14 +2540,6 @@ export class SpatiallyIndexedSkeletonLayer inspectionState.nodeDataVersion.changed.add(requestRedraw), ); } - // TODO (SKM): there should be maybe be a redraw on lod changing - // these were removed because there were too many of them - // a separate PR is working on a cleaner display state - // at that point we can likely add something back here - // for now it should be fine, because the chunks update - // as a result of the lod changing, which triggers a draw - // will check the pattern in slice view - // Create backend for perspective view chunk management const sharedObject = this.registerDisposer( new ChunkRenderLayerFrontend(this.layerChunkProgressInfo), @@ -2593,10 +2555,6 @@ export class SpatiallyIndexedSkeletonLayer ), ); - const skeletonLodWatchable = this.registerDisposer( - SharedWatchableValue.makeFromExisting(rpc, this.lod), - ); - const skeletonGridLevelWatchable = this.registerDisposer( SharedWatchableValue.makeFromExisting(rpc, this.gridLevel), ); @@ -2607,7 +2565,6 @@ export class SpatiallyIndexedSkeletonLayer SharedWatchableValue.makeFromExisting(rpc, this.localPosition), ).rpcId, renderScaleTarget: renderScaleTargetWatchable.rpcId, - skeletonLod: skeletonLodWatchable.rpcId, skeletonGridLevel: skeletonGridLevelWatchable.rpcId, }); this.backend = sharedObject; @@ -2735,7 +2692,6 @@ export class SpatiallyIndexedSkeletonLayer private *iterateCandidateChunks( selectedSources: readonly SpatiallyIndexedSkeletonSourceEntry[], - targetLod: number | undefined, options: { view?: SpatiallyIndexedSkeletonView; } = {}, @@ -2754,7 +2710,6 @@ export class SpatiallyIndexedSkeletonLayer continue; } for (const chunk of visibleChunks) { - if (!this.lodMatches(chunk, targetLod)) continue; if (chunk.state !== ChunkState.GPU_MEMORY) continue; yield chunk; } @@ -2762,7 +2717,6 @@ export class SpatiallyIndexedSkeletonLayer } for (const chunk of sourceEntry.chunkSource.chunks.values()) { const typedChunk = chunk as SpatiallyIndexedSkeletonChunk; - if (!this.lodMatches(typedChunk, targetLod)) continue; if (typedChunk.state !== ChunkState.GPU_MEMORY) continue; yield typedChunk; } @@ -2856,17 +2810,8 @@ export class SpatiallyIndexedSkeletonLayer view: SpatiallyIndexedSkeletonView, transformedSources: readonly TransformedSource[][], projectionParameters: any, - lod: number | undefined, renderedViewId?: number, ) { - if (lod === undefined) { - this.visibleChunksByView.delete(view); - if (renderedViewId !== undefined) { - this.clearVisibleChunkKeysForRenderedView(view, renderedViewId); - } - return; - } - const lodSuffix = `:${lod}`; const chunksBySource: VisibleSpatialChunksBySource = new Map(); const visibleChunkKeysBySource: VisibleSpatialChunkKeysBySource = new Map(); const seenChunkKeysBySource = new Map>(); @@ -2892,7 +2837,7 @@ export class SpatiallyIndexedSkeletonLayer this.localPosition.value, tsource, (positionInChunks) => { - const chunkKey = `${positionInChunks.join()}${lodSuffix}`; + const chunkKey = positionInChunks.join(); if (seenChunkKeys!.has(chunkKey)) { return; } @@ -2925,7 +2870,6 @@ export class SpatiallyIndexedSkeletonLayer private areVisibleChunksReady( transformedSources: readonly TransformedSource[][], projectionParameters: ProjectionParameters, - lod: number | undefined, ) { if ( this.displayState.objectAlpha.value <= 0.0 && @@ -2933,10 +2877,9 @@ export class SpatiallyIndexedSkeletonLayer ) { return true; } - if (lod === undefined || transformedSources.length === 0) { + if (transformedSources.length === 0) { return false; } - const lodSuffix = `:${lod}`; const seenChunkKeysBySource = new Map>(); let ready = true; for (const scales of transformedSources) { @@ -2955,7 +2898,7 @@ export class SpatiallyIndexedSkeletonLayer if (!ready) { return; } - const chunkKey = `${positionInChunks.join()}${lodSuffix}`; + const chunkKey = positionInChunks.join(); if (seenChunkKeys!.has(chunkKey)) { return; } @@ -2978,13 +2921,7 @@ export class SpatiallyIndexedSkeletonLayer return true; } - getNode( - nodeId: number, - options: { - lod?: number; - } = {}, - ): SpatiallyIndexedSkeletonNode | undefined { - void options; + getNode(nodeId: number): SpatiallyIndexedSkeletonNode | undefined { if (!Number.isSafeInteger(nodeId) || nodeId <= 0) return undefined; return this.getCachedNodeSnapshot(nodeId); } @@ -2992,10 +2929,8 @@ export class SpatiallyIndexedSkeletonLayer getNodes( options: { segmentId?: bigint; - lod?: number; } = {}, ): SpatiallyIndexedSkeletonNode[] { - void options.lod; const normalizedSegmentFilter = options.segmentId === undefined ? undefined @@ -3037,7 +2972,6 @@ export class SpatiallyIndexedSkeletonLayer pointDiameter: number, hasRegularSkeletonLayer: boolean, selectedSources: readonly SpatiallyIndexedSkeletonSourceEntry[], - targetLod: number | undefined, view: SpatiallyIndexedSkeletonView, ) { const { gl } = this; @@ -3101,13 +3035,7 @@ export class SpatiallyIndexedSkeletonLayer renderHelper.setPickID(gl, nodeShader, 0); renderHelper.setNodePickInstanceStride(gl, nodeShader, 0); } - for (const chunk of this.iterateCandidateChunks( - selectedSources, - targetLod, - { - view, - }, - )) { + for (const chunk of this.iterateCandidateChunks(selectedSources, { view })) { if (renderContext.emitPickID) { let edgePickId = 0; let edgePickStride = 0; @@ -3292,7 +3220,6 @@ export class SpatiallyIndexedSkeletonLayer drawOptions?: { view?: SpatiallyIndexedSkeletonView; gridLevel?: number; - lod?: number; }, ) { const lineWidth = renderOptions.lineWidth.value; @@ -3313,7 +3240,6 @@ export class SpatiallyIndexedSkeletonLayer const hasRegularSkeletonLayer = this.updateHasRegularSkeletonLayerWatchable( layer.userLayer, ); - const targetLod = drawOptions?.lod; const view = drawOptions?.view ?? "3d"; const pointDiameter = getSkeletonNodeDiameter( renderOptions.mode.value, @@ -3333,7 +3259,6 @@ export class SpatiallyIndexedSkeletonLayer pointDiameter, hasRegularSkeletonLayer, selectedSources, - targetLod, view, ); this.drawInspectionOverlayPass( @@ -3350,16 +3275,11 @@ export class SpatiallyIndexedSkeletonLayer isReady( transformedSources: readonly TransformedSource[][], projectionParameters: ProjectionParameters, - lod?: number, ) { // TODO (SKM) I don't think this is getting // called as expected, for example, I think // the screenshot should call this but it doesn't seem to - return this.areVisibleChunksReady( - transformedSources, - projectionParameters, - lod, - ); + return this.areVisibleChunksReady(transformedSources, projectionParameters); } } @@ -3617,16 +3537,14 @@ export class PerspectiveViewSpatiallyIndexedSkeletonLayer extends PerspectiveVie return; } const displayState = this.base.displayState as any; - const lodValue = displayState.skeletonLod?.value as number | undefined; this.base.updateVisibleChunksForView( "3d", this.transformedSources, renderContext.projectionParameters, - lodValue, attachment.view.rpcId, ); const levels = displayState.spatialSkeletonGridLevels?.value as - | Array<{ size: { x: number; y: number; z: number }; lod: number }> + | Array<{ size: { x: number; y: number; z: number } }> | undefined; const histogram = displayState.spatialSkeletonGridRenderScaleHistogram3d as | RenderScaleHistogram @@ -3643,7 +3561,6 @@ export class PerspectiveViewSpatiallyIndexedSkeletonLayer extends PerspectiveVie this.transformedSources, renderContext.projectionParameters, this.base.localPosition.value, - lodValue, levels, relative, pixelSize, @@ -3661,7 +3578,6 @@ export class PerspectiveViewSpatiallyIndexedSkeletonLayer extends PerspectiveVie gridLevel: displayState.spatialSkeletonGridLevel3d?.value as | number | undefined, - lod: lodValue, }, ); } @@ -3673,12 +3589,9 @@ export class PerspectiveViewSpatiallyIndexedSkeletonLayer extends PerspectiveVie ThreeDimensionalRenderLayerAttachmentState >, ) { - const displayState = this.base.displayState as any; - const lodValue = displayState.skeletonLod?.value as number | undefined; return this.base.isReady( this.transformedSources, renderContext.projectionParameters, - lodValue, ); } } @@ -3732,9 +3645,6 @@ export class SliceViewSpatiallyIndexedSkeletonLayer extends SliceViewRenderLayer draw(renderContext: SliceViewRenderContext) { const displayState = this.base.displayState as any; - const lodValue = displayState.spatialSkeletonLod2d?.value as - | number - | undefined; const sliceView = renderContext.sliceView; const sliceViewId = sliceView.rpcId; if (!this.trackedChunkStatsSliceViews.has(sliceViewId)) { @@ -3745,9 +3655,8 @@ export class SliceViewSpatiallyIndexedSkeletonLayer extends SliceViewRenderLayer }); } if ( - (displayState.objectAlpha?.value <= 0.0 && - displayState.hiddenObjectAlpha?.value <= 0.0) || - lodValue === undefined + displayState.objectAlpha?.value <= 0.0 && + displayState.hiddenObjectAlpha?.value <= 0.0 ) { this.base.clearVisibleChunkKeysForRenderedView("2d", sliceViewId); return; @@ -3761,7 +3670,6 @@ export class SliceViewSpatiallyIndexedSkeletonLayer extends SliceViewRenderLayer visibleSources, renderContext.projectionParameters, this.base.localPosition.value, - lodValue, ), ); } @@ -3794,10 +3702,6 @@ export class SliceViewPanelSpatiallyIndexedSkeletonLayer extends SliceViewPanelR gridLevel2d.changed.add(this.redrawNeeded.dispatch), ); } - const lod2d = (base.displayState as any).spatialSkeletonLod2d; - if (lod2d?.changed) { - this.registerDisposer(lod2d.changed.add(this.redrawNeeded.dispatch)); - } const histogram = (base.displayState as any) .spatialSkeletonGridRenderScaleHistogram2d as | RenderScaleHistogram @@ -3975,17 +3879,13 @@ export class SliceViewPanelSpatiallyIndexedSkeletonLayer extends SliceViewPanelR } } const displayState = this.base.displayState as any; - const lodValue = displayState.spatialSkeletonLod2d?.value as - | number - | undefined; this.base.updateVisibleChunksForView( "2d", this.transformedSources, renderContext.sliceView.projectionParameters.value, - lodValue, ); const levels = displayState.spatialSkeletonGridLevels?.value as - | Array<{ size: { x: number; y: number; z: number }; lod: number }> + | Array<{ size: { x: number; y: number; z: number } }> | undefined; const histogram = displayState.spatialSkeletonGridRenderScaleHistogram2d as | RenderScaleHistogram @@ -4002,7 +3902,6 @@ export class SliceViewPanelSpatiallyIndexedSkeletonLayer extends SliceViewPanelR this.transformedSources, renderContext.sliceView.projectionParameters.value, this.base.localPosition.value, - lodValue, levels, relative, pixelSize, @@ -4020,7 +3919,6 @@ export class SliceViewPanelSpatiallyIndexedSkeletonLayer extends SliceViewPanelR gridLevel: displayState.spatialSkeletonGridLevel2d?.value as | number | undefined, - lod: lodValue, }, ); } @@ -4032,14 +3930,9 @@ export class SliceViewPanelSpatiallyIndexedSkeletonLayer extends SliceViewPanelR ThreeDimensionalRenderLayerAttachmentState >, ) { - const displayState = this.base.displayState as any; - const lodValue = displayState.spatialSkeletonLod2d?.value as - | number - | undefined; return this.base.isReady( this.transformedSources, renderContext.projectionParameters, - lodValue, ); } } diff --git a/src/skeleton/skeleton_chunk_serialization.ts b/src/skeleton/skeleton_chunk_serialization.ts index 2e9b762eb..1fee9d2a6 100644 --- a/src/skeleton/skeleton_chunk_serialization.ts +++ b/src/skeleton/skeleton_chunk_serialization.ts @@ -21,7 +21,6 @@ export interface SkeletonChunkData { vertexPositions: Float32Array | null; vertexAttributes: TypedNumberArray[] | null; indices: Uint32Array | null; - lod?: number; nodeIds?: Int32Array; nodeSourceStates?: Array; } @@ -49,9 +48,6 @@ export function serializeSkeletonChunkData( msg: any, transfers: any[], ): void { - if (data.lod !== undefined) { - msg.lod = data.lod; - } const vertexPositions = data.vertexPositions!; const indices = data.indices!; msg.numVertices = vertexPositions.length / 3; From 2cc800e8f69c12fc829e3a1e6161b421c03a0b93 Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Tue, 5 May 2026 17:47:31 +0100 Subject: [PATCH 05/11] feat: Add read-only flag --- src/datasource/catmaid/api.spec.ts | 58 +++++-- src/datasource/catmaid/api.ts | 143 +++++++++++++----- src/datasource/catmaid/base.ts | 1 + src/datasource/catmaid/frontend.ts | 58 +++++-- src/layer/segmentation/index.spec.ts | 29 ++++ src/layer/segmentation/index.ts | 11 ++ src/skeleton/api.ts | 2 + src/skeleton/spatial_skeleton_manager.spec.ts | 15 ++ src/skeleton/spatial_skeleton_manager.ts | 9 ++ 9 files changed, 272 insertions(+), 54 deletions(-) diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index 2ec8e58e5..ae5c7c862 100644 --- a/src/datasource/catmaid/api.spec.ts +++ b/src/datasource/catmaid/api.spec.ts @@ -74,10 +74,12 @@ describe("CatmaidClient skeleton editing methods", () => { await expect(client.getSpatialIndexMetadata()).resolves.toEqual({ lowerBounds: [5, 6, 7], upperBounds: [25, 66, 127], + readOnly: false, spatial: [ { chunkSize: [15, 15, 15], gridShape: [2, 4, 8], + limit: 0, }, ], }); @@ -87,7 +89,7 @@ describe("CatmaidClient skeleton editing methods", () => { warnSpy.mockRestore(); }); - it("reads spatial skeleton chunk sizes from stack metadata", async () => { + it("reads spatial skeleton spatial index levels from stack metadata", async () => { const client = new CatmaidClient("https://example.invalid", 1); (client as any).listStacks = vi.fn().mockResolvedValue([{ id: 7 }]); (client as any).getStackInfo = vi.fn().mockResolvedValue({ @@ -95,10 +97,29 @@ describe("CatmaidClient skeleton editing methods", () => { resolution: { x: 2, y: 3, z: 4 }, translation: { x: 5, y: 6, z: 7 }, metadata: { - spatial_skeleton_chunk_sizes: [ - [120, 120, 120], - [60, 60, 60], - [30, 30, 30], + cache_provider: "cached_msgpack_grid", + read_only: true, + spatial: [ + { + chunk_size: [11168145, 11168145, 11168145], + limit: 500, + }, + { + chunk_size: [6632497, 6632497, 6632497], + limit: 500, + }, + { + chunk_size: [3939000, 3939000, 3939000], + limit: 7000, + }, + { + chunk_size: [2339000, 2339000, 2339000], + limit: 27500, + }, + { + chunk_size: [1500000, 1500000, 1500000], + limit: 70000, + }, ], }, }); @@ -106,21 +127,38 @@ describe("CatmaidClient skeleton editing methods", () => { await expect(client.getSpatialIndexMetadata()).resolves.toEqual({ lowerBounds: [5, 6, 7], upperBounds: [25, 66, 127], + readOnly: true, spatial: [ { - chunkSize: [120, 120, 120], + chunkSize: [11168145, 11168145, 11168145], + gridShape: [1, 1, 1], + limit: 500, + }, + { + chunkSize: [6632497, 6632497, 6632497], gridShape: [1, 1, 1], + limit: 500, }, { - chunkSize: [60, 60, 60], - gridShape: [1, 1, 2], + chunkSize: [3939000, 3939000, 3939000], + gridShape: [1, 1, 1], + limit: 7000, }, { - chunkSize: [30, 30, 30], - gridShape: [1, 2, 4], + chunkSize: [2339000, 2339000, 2339000], + gridShape: [1, 1, 1], + limit: 27500, + }, + { + chunkSize: [1500000, 1500000, 1500000], + gridShape: [1, 1, 1], + limit: 70000, }, ], }); + await expect(client.getCacheProvider()).resolves.toBe( + "cached_msgpack_grid", + ); }); it("parses live compact-detail history rows and label maps", async () => { diff --git a/src/datasource/catmaid/api.ts b/src/datasource/catmaid/api.ts index 2b40687bb..332203f70 100644 --- a/src/datasource/catmaid/api.ts +++ b/src/datasource/catmaid/api.ts @@ -19,6 +19,7 @@ import { fetchOkWithCredentials } from "#src/credentials_provider/http_request.j import type { CredentialsProvider } from "#src/credentials_provider/index.js"; import type { SpatialSkeletonBounds, + SpatialSkeletonSpatialIndexLevel, SpatialSkeletonSourceState, SpatialSkeletonVector, SpatiallyIndexedSkeletonMetadata, @@ -36,7 +37,11 @@ interface CatmaidStackInfo { translation: { x: number; y: number; z: number }; metadata?: { cache_provider?: string; - spatial_skeleton_chunk_sizes?: Array; + read_only?: boolean; + spatial?: Array<{ + chunk_size?: SpatialSkeletonVector; + limit?: number; + }>; }; } @@ -48,6 +53,7 @@ export const credentialsKey = "CATMAID"; const CATMAID_NO_MATCHING_NODE_PROVIDER_ERROR = "Could not find matching node provider for request"; const CATMAID_STATE_MATCHING_ERROR_TYPE = "StateMatchingError"; +const DEFAULT_SPATIAL_SKELETON_GRID_CELL_LIMIT = 0; type CatmaidStatePayload = object; @@ -550,6 +556,47 @@ function requireCatmaidRank3Vector( return values; } +function requireCatmaidPositiveRank3Vector( + value: unknown, + label: string, +): readonly [number, number, number] { + if (!Array.isArray(value) || value.length !== 3) { + throw new Error(`CATMAID ${label} must be a rank-3 array.`); + } + const values = [ + Number(value[0]), + Number(value[1]), + Number(value[2]), + ] as const; + if (values.some((x) => !Number.isFinite(x) || x <= 0)) { + throw new Error( + `CATMAID ${label} coordinates must be finite and positive.`, + ); + } + return values; +} + +function requireCatmaidPositiveInt(value: unknown, label: string): number { + const numberValue = Number(value); + if (!Number.isInteger(numberValue) || numberValue <= 0) { + throw new Error(`CATMAID ${label} must be a positive integer.`); + } + return numberValue; +} + +function parseOptionalCatmaidBoolean( + value: unknown, + label: string, +): boolean | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== "boolean") { + throw new Error(`CATMAID ${label} must be a boolean.`); + } + return value; +} + function normalizeBoundingBoxForNodeList(bounds: SpatialSkeletonBounds) { const [minX, minY, minZ] = requireCatmaidRank3Vector( bounds.lowerBounds, @@ -1201,41 +1248,58 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { } } - private getGridCellSizesFromMetadataInfo( - info: CatmaidStackInfo, - bounds = getCatmaidProjectSpaceBounds(info), - ): number[][] { - const gridSizes: number[][] = []; - - // Try to get all allowed spatial skeleton chunk sizes from metadata. - if (info.metadata?.spatial_skeleton_chunk_sizes) { - for (const chunkSize of info.metadata.spatial_skeleton_chunk_sizes) { - if ( - chunkSize.length === 3 && - Number.isFinite(chunkSize[0]) && - Number.isFinite(chunkSize[1]) && - Number.isFinite(chunkSize[2]) && - chunkSize[0] > 0 && - chunkSize[1] > 0 && - chunkSize[2] > 0 - ) { - gridSizes.push([chunkSize[0], chunkSize[1], chunkSize[2]]); - } - } + private getSpatialIndexLevelsFromSpatialMetadata( + metadata: CatmaidStackInfo["metadata"], + extents: readonly number[], + ): SpatialSkeletonSpatialIndexLevel[] | undefined { + const spatial = metadata?.spatial; + if (spatial === undefined) { + return undefined; } - - // If no chunk sizes are specified, use the bounds-derived default. - if (gridSizes.length === 0) { - gridSizes.push(getDefaultSpatiallyIndexedSkeletonChunkSize(bounds)); + if (!Array.isArray(spatial) || spatial.length === 0) { + throw new Error( + "CATMAID spatial skeleton metadata spatial must be a non-empty array.", + ); } + return spatial.map((level, index) => { + const chunkSize = requireCatmaidPositiveRank3Vector( + level?.chunk_size, + `spatial skeleton metadata spatial[${index}].chunk_size`, + ); + const limit = requireCatmaidPositiveInt( + level?.limit, + `spatial skeleton metadata spatial[${index}].limit`, + ); + return { + chunkSize, + gridShape: chunkSize.map((size, dim) => + Math.max(1, Math.ceil(extents[dim] / size)), + ), + limit, + }; + }); + } - return gridSizes; + private getDefaultSpatialIndexLevelsFromMetadataInfo( + bounds: SpatialSkeletonBounds, + extents: readonly number[], + ): SpatialSkeletonSpatialIndexLevel[] { + const chunkSize = getDefaultSpatiallyIndexedSkeletonChunkSize(bounds); + return [ + { + chunkSize, + gridShape: chunkSize.map((size, index) => + Math.max(1, Math.ceil(extents[index] / size)), + ), + limit: DEFAULT_SPATIAL_SKELETON_GRID_CELL_LIMIT, + }, + ]; } private getSpatialIndexLevelsFromMetadataInfo( info: CatmaidStackInfo, bounds = getCatmaidProjectSpaceBounds(info), - ) { + ): SpatialSkeletonSpatialIndexLevel[] { const [lowerX, lowerY, lowerZ] = requireCatmaidRank3Vector( bounds.lowerBounds, "spatial metadata lower bound", @@ -1245,13 +1309,21 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { "spatial metadata upper bound", ); const extents = [upperX - lowerX, upperY - lowerY, upperZ - lowerZ]; - return this.getGridCellSizesFromMetadataInfo(info, bounds).map( - (chunkSize) => ({ - chunkSize, - gridShape: chunkSize.map((size, index) => - Math.max(1, Math.ceil(extents[index] / size)), - ), - }), + return ( + this.getSpatialIndexLevelsFromSpatialMetadata(info.metadata, extents) ?? + this.getDefaultSpatialIndexLevelsFromMetadataInfo(bounds, extents) + ); + } + + private getSpatialSkeletonReadOnlyFromMetadataInfo( + info: CatmaidStackInfo, + ): boolean { + const metadata = info.metadata; + return ( + parseOptionalCatmaidBoolean( + metadata?.read_only, + "spatial skeleton metadata read_only", + ) ?? false ); } @@ -1264,6 +1336,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { return { ...bounds, spatial: this.getSpatialIndexLevelsFromMetadataInfo(info, bounds), + readOnly: this.getSpatialSkeletonReadOnlyFromMetadataInfo(info), }; } diff --git a/src/datasource/catmaid/base.ts b/src/datasource/catmaid/base.ts index 511711d47..f3724fb43 100644 --- a/src/datasource/catmaid/base.ts +++ b/src/datasource/catmaid/base.ts @@ -20,6 +20,7 @@ export class CatmaidDataSourceParameters { url!: string; projectId!: number; cacheProvider?: string; + spatialSkeletonsReadOnly?: boolean; } export class CatmaidSkeletonSourceParameters extends SkeletonSourceParameters { diff --git a/src/datasource/catmaid/frontend.ts b/src/datasource/catmaid/frontend.ts index 3c60dd234..7dd95ac65 100644 --- a/src/datasource/catmaid/frontend.ts +++ b/src/datasource/catmaid/frontend.ts @@ -58,6 +58,7 @@ import { } from "#src/segmentation_display_state/property_map.js"; import type { EditableSpatiallyIndexedSkeletonSource, + SpatialSkeletonEditCapabilities, SpatialSkeletonGridCellIndex, SpatiallyIndexedSkeletonMetadata, SpatiallyIndexedSkeletonNode, @@ -77,6 +78,15 @@ import type { Borrowed } from "#src/util/disposable.js"; import { mat4, vec3 } from "#src/util/geom.js"; import "#src/datasource/catmaid/register_credentials_provider.js"; +const CATMAID_SPATIAL_SKELETON_EDIT_CAPABILITIES = { + nodeFeatures: { + description: true, + trueEnd: true, + radius: true, + confidenceValues: CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES, + }, +} satisfies SpatialSkeletonEditCapabilities; + export class CatmaidSpatiallyIndexedSkeletonSource extends WithParameters( WithCredentialsProvider()(SpatiallyIndexedSkeletonSource), @@ -86,18 +96,32 @@ export class CatmaidSpatiallyIndexedSkeletonSource CatmaidSpatialSkeletonEditApi, EditableSpatiallyIndexedSkeletonSource { - readonly spatialSkeletonEditCapabilities = { - nodeFeatures: { - description: true, - trueEnd: true, - radius: true, - confidenceValues: CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES, - }, - }; - readonly spatialSkeletonEditCommandSource = + private readonly editableSpatialSkeletonEditCommandSource = new CatmaidSpatialSkeletonEditCommandSource(); private client_?: CatmaidClient; + get spatialSkeletonReadOnly() { + return this.parameters.catmaidParameters.spatialSkeletonsReadOnly === true; + } + + get spatialSkeletonEditCapabilities() { + return this.spatialSkeletonReadOnly + ? undefined + : CATMAID_SPATIAL_SKELETON_EDIT_CAPABILITIES; + } + + get spatialSkeletonEditCommandSource() { + return this.spatialSkeletonReadOnly + ? undefined + : this.editableSpatialSkeletonEditCommandSource; + } + + private ensureSpatialSkeletonEditable() { + if (this.spatialSkeletonReadOnly) { + throw new Error("CATMAID spatial skeleton source is read-only."); + } + } + private get client() { let client = this.client_; if (client !== undefined) { @@ -159,6 +183,7 @@ export class CatmaidSpatiallyIndexedSkeletonSource parentId?: number, editContext?: CatmaidEditContext, ): Promise { + this.ensureSpatialSkeletonEditable(); return this.client.addNode(skeletonId, x, y, z, parentId, editContext); } @@ -171,6 +196,7 @@ export class CatmaidSpatiallyIndexedSkeletonSource childNodeIds: readonly number[], editContext?: CatmaidEditContext, ): Promise { + this.ensureSpatialSkeletonEditable(); return this.client.insertNode( skeletonId, x, @@ -189,6 +215,7 @@ export class CatmaidSpatiallyIndexedSkeletonSource z: number, editContext?: CatmaidEditContext, ): Promise { + this.ensureSpatialSkeletonEditable(); return this.client.moveNode(nodeId, x, y, z, editContext); } @@ -199,10 +226,12 @@ export class CatmaidSpatiallyIndexedSkeletonSource editContext?: CatmaidEditContext; }, ): Promise { + this.ensureSpatialSkeletonEditable(); return this.client.deleteNode(nodeId, options); } rerootSkeleton(nodeId: number, editContext?: CatmaidEditContext) { + this.ensureSpatialSkeletonEditable(); return this.client.rerootSkeleton(nodeId, editContext); } @@ -210,6 +239,7 @@ export class CatmaidSpatiallyIndexedSkeletonSource nodeId: number, description: string, ): Promise { + this.ensureSpatialSkeletonEditable(); return this.client.updateDescription(nodeId, description); } @@ -217,6 +247,7 @@ export class CatmaidSpatiallyIndexedSkeletonSource nodeId: number, nextIsTrueEnd: boolean, ): Promise { + this.ensureSpatialSkeletonEditable(); return this.client.toggleTrueEnd(nodeId, nextIsTrueEnd); } @@ -225,6 +256,7 @@ export class CatmaidSpatiallyIndexedSkeletonSource radius: number, editContext?: CatmaidEditContext, ): Promise { + this.ensureSpatialSkeletonEditable(); return this.client.updateRadius(nodeId, radius, editContext); } @@ -233,6 +265,7 @@ export class CatmaidSpatiallyIndexedSkeletonSource confidence: number, editContext?: CatmaidEditContext, ): Promise { + this.ensureSpatialSkeletonEditable(); return this.client.updateConfidence(nodeId, confidence, editContext); } @@ -241,6 +274,7 @@ export class CatmaidSpatiallyIndexedSkeletonSource toNodeId: number, editContext?: CatmaidEditContext, ): Promise { + this.ensureSpatialSkeletonEditable(); return this.client.mergeSkeletons(fromNodeId, toNodeId, editContext); } @@ -248,6 +282,7 @@ export class CatmaidSpatiallyIndexedSkeletonSource nodeId: number, editContext?: CatmaidEditContext, ): Promise { + this.ensureSpatialSkeletonEditable(); return this.client.splitSkeleton(nodeId, editContext); } } @@ -278,6 +313,7 @@ export class CatmaidMultiscaleSpatiallyIndexedSkeletonSource extends MultiscaleS private upperBoundsInNanometers: Float32Array, gridCellSizes: Array<{ x: number; y: number; z: number }>, private cacheProvider?: string, + private spatialSkeletonsReadOnly = false, ) { super(chunkManager); this.sortedGridCellSizes = [...gridCellSizes].sort( @@ -347,6 +383,8 @@ export class CatmaidMultiscaleSpatiallyIndexedSkeletonSource extends MultiscaleS parameters.catmaidParameters.url = this.baseUrl; parameters.catmaidParameters.projectId = this.projectId; parameters.catmaidParameters.cacheProvider = this.cacheProvider; + parameters.catmaidParameters.spatialSkeletonsReadOnly = + this.spatialSkeletonsReadOnly; parameters.gridIndex = gridIndex; parameters.catmaidLod = lastGridIndex <= 0 ? 0 : gridIndex / lastGridIndex; @@ -449,6 +487,7 @@ export class CatmaidDataSourceProvider implements DataSourceProvider { lowerBounds: projectLowerBounds, upperBounds: projectUpperBounds, spatial, + readOnly, } = spatialIndexMetadata; const gridCellSizes = spatial.map(({ chunkSize }) => ({ x: Number(chunkSize[0]), @@ -499,6 +538,7 @@ export class CatmaidDataSourceProvider implements DataSourceProvider { upperCoordinateBound, gridCellSizes, cacheProvider, + readOnly, ); // Create complete skeleton source (non-chunked) const completeSkeletonParameters = diff --git a/src/layer/segmentation/index.spec.ts b/src/layer/segmentation/index.spec.ts index 5b03feb5e..1e1381484 100644 --- a/src/layer/segmentation/index.spec.ts +++ b/src/layer/segmentation/index.spec.ts @@ -240,6 +240,35 @@ describe("layer/segmentation spatial skeleton action gating", () => { "The active spatial skeleton source does not support skeleton rerooting.", ); }); + + it("reports read-only spatial skeleton sources explicitly", () => { + const layer = Object.assign( + Object.create(SegmentationUserLayer.prototype), + { + getSpatiallyIndexedSkeletonLayer: () => + makeSpatialSkeletonLayerWithSource({ + ...makeEditableSpatialSkeletonSource({ + rerootSkeleton: async () => {}, + }), + spatialSkeletonReadOnly: true, + }), + spatialSkeletonVisibleChunksLoaded: new WatchableValue(true), + spatialSkeletonVisibleChunksNeeded: new WatchableValue(0), + spatialSkeletonVisibleChunksAvailable: new WatchableValue(0), + }, + ); + + expect( + layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.addNodes, + ), + ).toBe("The active spatial skeleton source is read-only."); + expect( + layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.inspect, + ), + ).toBeUndefined(); + }); }); describe("layer/segmentation spatial skeleton selection serialization", () => { diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 54396a7dc..f218b21c0 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -144,6 +144,7 @@ import { import { getEditableSpatiallyIndexedSkeletonSource, getSpatiallyIndexedSkeletonSource, + isSpatiallyIndexedSkeletonSourceReadOnly, SpatialSkeletonState, } from "#src/skeleton/spatial_skeleton_manager.js"; import { DataType, VolumeType } from "#src/sliceview/volume/base.js"; @@ -1600,9 +1601,19 @@ export class SegmentationUserLayer extends Base { private getMissingSpatialSkeletonSupportReason( requiredActions: SpatialSkeletonAction | readonly SpatialSkeletonAction[], ) { + const skeletonLayer = this.getSpatiallyIndexedSkeletonLayer(); const requirements = Array.isArray(requiredActions) ? requiredActions : [requiredActions]; + if ( + skeletonLayer !== undefined && + requirements.some( + (action) => action !== SpatialSkeletonActions.inspect, + ) && + isSpatiallyIndexedSkeletonSourceReadOnly(skeletonLayer) + ) { + return "The active spatial skeleton source is read-only."; + } const missingRequirements = requirements.filter( (action) => !this.supportsSpatialSkeletonAction(action), ); diff --git a/src/skeleton/api.ts b/src/skeleton/api.ts index c4876c984..9e7363327 100644 --- a/src/skeleton/api.ts +++ b/src/skeleton/api.ts @@ -59,6 +59,7 @@ export interface SpatiallyIndexedSkeletonNode export interface SpatiallyIndexedSkeletonMetadata extends SpatialSkeletonBounds { spatial: readonly SpatialSkeletonSpatialIndexLevel[]; + readOnly: boolean; } export interface SpatialSkeletonNodeFeatureCapabilities { @@ -78,6 +79,7 @@ export type SpatialSkeletonEditOperation = ( ) => Promise; export interface SpatiallyIndexedSkeletonSource { + readonly spatialSkeletonReadOnly?: boolean; listSkeletons(): Promise; getSkeleton( skeletonId: number, diff --git a/src/skeleton/spatial_skeleton_manager.spec.ts b/src/skeleton/spatial_skeleton_manager.spec.ts index c036d07b1..f7d4aeebe 100644 --- a/src/skeleton/spatial_skeleton_manager.spec.ts +++ b/src/skeleton/spatial_skeleton_manager.spec.ts @@ -77,6 +77,21 @@ describe("skeleton/spatial_skeleton_manager", () => { expect(getEditableSpatiallyIndexedSkeletonSource({ source })).toBe(source); }); + it("does not treat a read-only source with edit methods as editable", () => { + const source = { + ...makeEditableSourceMethods(), + spatialSkeletonReadOnly: true, + listSkeletons: async () => [], + getSkeleton: async () => [], + fetchNodes: async () => [], + getSpatialIndexMetadata: async () => null, + }; + + expect( + getEditableSpatiallyIndexedSkeletonSource({ source }), + ).toBeUndefined(); + }); + it("clears the full skeleton cache before notifying node data listeners", () => { const state = new SpatialSkeletonState(); const cachedSegmentId = 11; diff --git a/src/skeleton/spatial_skeleton_manager.ts b/src/skeleton/spatial_skeleton_manager.ts index 280c978f5..7983f505b 100644 --- a/src/skeleton/spatial_skeleton_manager.ts +++ b/src/skeleton/spatial_skeleton_manager.ts @@ -56,6 +56,7 @@ export function isEditableSpatiallyIndexedSkeletonSource( ): value is EditableSpatiallyIndexedSkeletonSource { return ( isSpatiallyIndexedSkeletonSource(value) && + value.spatialSkeletonReadOnly !== true && hasFunction(value, "addNode") && hasFunction(value, "deleteNode") && hasFunction(value, "moveNode") && @@ -74,6 +75,14 @@ export function getSpatiallyIndexedSkeletonSource( : undefined; } +export function isSpatiallyIndexedSkeletonSourceReadOnly( + value: SpatialSkeletonSourceAccess | undefined, +): boolean { + return ( + getSpatiallyIndexedSkeletonSource(value)?.spatialSkeletonReadOnly === true + ); +} + export function getEditableSpatiallyIndexedSkeletonSource( value: SpatialSkeletonSourceAccess | undefined, ): EditableSpatiallyIndexedSkeletonSource | undefined { From 5ad1f09dc938ca0cc0f5f2532c2dc35389153e9e Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Tue, 5 May 2026 19:34:34 +0100 Subject: [PATCH 06/11] feat: Make spatially indexed skeletons metadata mandatory --- src/datasource/catmaid/api.spec.ts | 20 ++++++- src/datasource/catmaid/api.ts | 35 +++--------- src/datasource/catmaid/frontend.ts | 13 +++-- .../catmaid/spatial_skeleton_commands.ts | 2 +- .../segmentation/spatial_skeleton_commands.ts | 44 ++------------- src/skeleton/api.ts | 7 ++- src/skeleton/edit_command_source.ts | 53 +++++++++++++++++++ src/skeleton/spatial_skeleton_manager.spec.ts | 28 +++++++++- src/skeleton/spatial_skeleton_manager.ts | 16 +++++- 9 files changed, 137 insertions(+), 81 deletions(-) create mode 100644 src/skeleton/edit_command_source.ts diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index ae5c7c862..210a7e2ce 100644 --- a/src/datasource/catmaid/api.spec.ts +++ b/src/datasource/catmaid/api.spec.ts @@ -68,6 +68,9 @@ describe("CatmaidClient skeleton editing methods", () => { dimension: { x: 10, y: 20, z: 30 }, resolution: { x: 2, y: 3, z: 4 }, translation: { x: 5, y: 6, z: 7 }, + metadata: { + spatial: [{ chunk_size: [15, 15, 15], limit: 1 }], + }, }); await expect(client.getSpatialIndexMetadata()).resolves.toBeNull(); @@ -79,7 +82,7 @@ describe("CatmaidClient skeleton editing methods", () => { { chunkSize: [15, 15, 15], gridShape: [2, 4, 8], - limit: 0, + limit: 1, }, ], }); @@ -89,6 +92,21 @@ describe("CatmaidClient skeleton editing methods", () => { warnSpy.mockRestore(); }); + it("rejects CATMAID stack metadata without spatial skeleton levels", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + (client as any).listStacks = vi.fn().mockResolvedValue([{ id: 7 }]); + (client as any).getStackInfo = vi.fn().mockResolvedValue({ + dimension: { x: 10, y: 20, z: 30 }, + resolution: { x: 2, y: 3, z: 4 }, + translation: { x: 5, y: 6, z: 7 }, + metadata: {}, + }); + + await expect(client.getSpatialIndexMetadata()).rejects.toThrow( + /metadata\.spatial/i, + ); + }); + it("reads spatial skeleton spatial index levels from stack metadata", async () => { const client = new CatmaidClient("https://example.invalid", 1); (client as any).listStacks = vi.fn().mockResolvedValue([{ id: 7 }]); diff --git a/src/datasource/catmaid/api.ts b/src/datasource/catmaid/api.ts index 332203f70..90c1d705a 100644 --- a/src/datasource/catmaid/api.ts +++ b/src/datasource/catmaid/api.ts @@ -28,7 +28,6 @@ import type { } from "#src/skeleton/api.js"; import { SpatialSkeletonEditConflictError } from "#src/skeleton/edit_errors.js"; import type { SpatiallyIndexedSkeletonNavigationTarget } from "#src/skeleton/navigation.js"; -import { getDefaultSpatiallyIndexedSkeletonChunkSize } from "#src/skeleton/spatial_chunk_sizing.js"; import { HttpError } from "#src/util/http_request.js"; interface CatmaidStackInfo { @@ -39,8 +38,8 @@ interface CatmaidStackInfo { cache_provider?: string; read_only?: boolean; spatial?: Array<{ - chunk_size?: SpatialSkeletonVector; - limit?: number; + chunk_size: SpatialSkeletonVector; + limit: number; }>; }; } @@ -53,7 +52,6 @@ export const credentialsKey = "CATMAID"; const CATMAID_NO_MATCHING_NODE_PROVIDER_ERROR = "Could not find matching node provider for request"; const CATMAID_STATE_MATCHING_ERROR_TYPE = "StateMatchingError"; -const DEFAULT_SPATIAL_SKELETON_GRID_CELL_LIMIT = 0; type CatmaidStatePayload = object; @@ -1251,14 +1249,16 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { private getSpatialIndexLevelsFromSpatialMetadata( metadata: CatmaidStackInfo["metadata"], extents: readonly number[], - ): SpatialSkeletonSpatialIndexLevel[] | undefined { + ): SpatialSkeletonSpatialIndexLevel[] { const spatial = metadata?.spatial; if (spatial === undefined) { - return undefined; + throw new Error( + "CATMAID stack metadata must define spatial skeleton metadata at metadata.spatial.", + ); } if (!Array.isArray(spatial) || spatial.length === 0) { throw new Error( - "CATMAID spatial skeleton metadata spatial must be a non-empty array.", + "CATMAID stack metadata.spatial must be a non-empty spatial skeleton metadata array.", ); } return spatial.map((level, index) => { @@ -1280,22 +1280,6 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { }); } - private getDefaultSpatialIndexLevelsFromMetadataInfo( - bounds: SpatialSkeletonBounds, - extents: readonly number[], - ): SpatialSkeletonSpatialIndexLevel[] { - const chunkSize = getDefaultSpatiallyIndexedSkeletonChunkSize(bounds); - return [ - { - chunkSize, - gridShape: chunkSize.map((size, index) => - Math.max(1, Math.ceil(extents[index] / size)), - ), - limit: DEFAULT_SPATIAL_SKELETON_GRID_CELL_LIMIT, - }, - ]; - } - private getSpatialIndexLevelsFromMetadataInfo( info: CatmaidStackInfo, bounds = getCatmaidProjectSpaceBounds(info), @@ -1309,10 +1293,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { "spatial metadata upper bound", ); const extents = [upperX - lowerX, upperY - lowerY, upperZ - lowerZ]; - return ( - this.getSpatialIndexLevelsFromSpatialMetadata(info.metadata, extents) ?? - this.getDefaultSpatialIndexLevelsFromMetadataInfo(bounds, extents) - ); + return this.getSpatialIndexLevelsFromSpatialMetadata(info.metadata, extents); } private getSpatialSkeletonReadOnlyFromMetadataInfo( diff --git a/src/datasource/catmaid/frontend.ts b/src/datasource/catmaid/frontend.ts index 7dd95ac65..a42a0d1f6 100644 --- a/src/datasource/catmaid/frontend.ts +++ b/src/datasource/catmaid/frontend.ts @@ -111,9 +111,7 @@ export class CatmaidSpatiallyIndexedSkeletonSource } get spatialSkeletonEditCommandSource() { - return this.spatialSkeletonReadOnly - ? undefined - : this.editableSpatialSkeletonEditCommandSource; + return this.editableSpatialSkeletonEditCommandSource; } private ensureSpatialSkeletonEditable() { @@ -155,8 +153,6 @@ export class CatmaidSpatiallyIndexedSkeletonSource fetchNodes( cellIndex: SpatialSkeletonGridCellIndex, options: { - cacheProvider?: string; - lod?: number; signal?: AbortSignal; } = {}, ): Promise { @@ -166,8 +162,11 @@ export class CatmaidSpatiallyIndexedSkeletonSource ); return this.client.fetchNodesInBoundingBox( bounds, - options.lod ?? this.parameters.catmaidLod ?? 0, - options, + this.parameters.catmaidLod ?? 0, + { + cacheProvider: this.parameters.catmaidParameters.cacheProvider, + signal: options.signal, + }, ); } diff --git a/src/datasource/catmaid/spatial_skeleton_commands.ts b/src/datasource/catmaid/spatial_skeleton_commands.ts index a73ae3185..ae11d3015 100644 --- a/src/datasource/catmaid/spatial_skeleton_commands.ts +++ b/src/datasource/catmaid/spatial_skeleton_commands.ts @@ -40,7 +40,6 @@ import type { SpatialSkeletonSourceState, SpatialSkeletonVector, } from "#src/skeleton/api.js"; -import type { SpatialSkeletonEditCommandSource } from "#src/layer/segmentation/spatial_skeleton_commands.js"; import { SpatialSkeletonActions, type SpatialSkeletonAction, @@ -49,6 +48,7 @@ import type { SpatialSkeletonCommand, SpatialSkeletonCommandContext, } from "#src/skeleton/command_history.js"; +import type { SpatialSkeletonEditCommandSource } from "#src/skeleton/edit_command_source.js"; import { findSpatiallyIndexedSkeletonNode, getSpatiallyIndexedSkeletonDirectChildren, diff --git a/src/layer/segmentation/spatial_skeleton_commands.ts b/src/layer/segmentation/spatial_skeleton_commands.ts index d2f945338..1bd147cc2 100644 --- a/src/layer/segmentation/spatial_skeleton_commands.ts +++ b/src/layer/segmentation/spatial_skeleton_commands.ts @@ -20,43 +20,14 @@ import { SpatialSkeletonActions, type SpatialSkeletonAction, } from "#src/skeleton/actions.js"; +import type { + SpatialSkeletonCommandPayload, + SpatialSkeletonEditCommandSource, +} from "#src/skeleton/edit_command_source.js"; import type { SpatialSkeletonCommand } from "#src/skeleton/command_history.js"; import { getEditableSpatiallyIndexedSkeletonSource } from "#src/skeleton/spatial_skeleton_manager.js"; import { StatusMessage } from "#src/status.js"; -export type SpatialSkeletonCommandPayload = object; - -export interface SpatialSkeletonEditCommandSource { - supports(action: SpatialSkeletonAction): boolean; - createCommand( - action: SpatialSkeletonAction, - layer: SegmentationUserLayer, - payload: SpatialSkeletonCommandPayload, - ): SpatialSkeletonCommand | undefined; -} - -type SpatialSkeletonEditCommandSourceCandidate = { - supports?: (action: SpatialSkeletonAction) => boolean; - createCommand?: ( - action: SpatialSkeletonAction, - layer: SegmentationUserLayer, - payload: SpatialSkeletonCommandPayload, - ) => SpatialSkeletonCommand | undefined; -}; - -export function isSpatialSkeletonEditCommandSource( - value: object | undefined, -): value is SpatialSkeletonEditCommandSource { - return ( - value !== undefined && - typeof (value as SpatialSkeletonEditCommandSourceCandidate).supports === - "function" && - typeof (value as SpatialSkeletonEditCommandSourceCandidate) - .createCommand === - "function" - ); -} - interface SpatialSkeletonSourceAccess { source: object; } @@ -66,12 +37,7 @@ export function getSpatialSkeletonEditCommandSource( ): SpatialSkeletonEditCommandSource | undefined { const source = getEditableSpatiallyIndexedSkeletonSource(value); if (source === undefined) return undefined; - const editCommandSource = ( - source as { spatialSkeletonEditCommandSource?: object } - ).spatialSkeletonEditCommandSource; - return isSpatialSkeletonEditCommandSource(editCommandSource) - ? editCommandSource - : undefined; + return source.spatialSkeletonEditCommandSource; } function getEditSource( diff --git a/src/skeleton/api.ts b/src/skeleton/api.ts index 9e7363327..5704bf21c 100644 --- a/src/skeleton/api.ts +++ b/src/skeleton/api.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import type { SpatialSkeletonEditCommandSource } from "#src/skeleton/edit_command_source.js"; + export type SpatialSkeletonVector = ArrayLike; // Provider-specific node state that crosses the worker boundary must remain structured-cloneable. @@ -37,7 +39,7 @@ export interface SpatialSkeletonGridCellIndex { export interface SpatialSkeletonSpatialIndexLevel { chunkSize: SpatialSkeletonVector; gridShape: readonly number[]; - limit?: number; + limit: number; } export interface SpatiallyIndexedSkeletonNodeBase { @@ -96,13 +98,14 @@ export interface SpatiallyIndexedSkeletonSource { export interface EditableSpatiallyIndexedSkeletonSource extends SpatiallyIndexedSkeletonSource { + readonly spatialSkeletonEditCommandSource: SpatialSkeletonEditCommandSource; readonly spatialSkeletonEditCapabilities?: SpatialSkeletonEditCapabilities; addNode: SpatialSkeletonEditOperation; deleteNode: SpatialSkeletonEditOperation; moveNode: SpatialSkeletonEditOperation; splitSkeleton: SpatialSkeletonEditOperation; mergeSkeletons: SpatialSkeletonEditOperation; - toggleTrueEnd: SpatialSkeletonEditOperation; + toggleTrueEnd?: SpatialSkeletonEditOperation; insertNode?: SpatialSkeletonEditOperation; rerootSkeleton?: SpatialSkeletonEditOperation; updateDescription?: SpatialSkeletonEditOperation; diff --git a/src/skeleton/edit_command_source.ts b/src/skeleton/edit_command_source.ts new file mode 100644 index 000000000..cfa9ea9a8 --- /dev/null +++ b/src/skeleton/edit_command_source.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; +import type { SpatialSkeletonAction } from "#src/skeleton/actions.js"; +import type { SpatialSkeletonCommand } from "#src/skeleton/command_history.js"; + +export type SpatialSkeletonCommandPayload = object; + +export interface SpatialSkeletonEditCommandSource { + supports(action: SpatialSkeletonAction): boolean; + createCommand( + action: SpatialSkeletonAction, + layer: SegmentationUserLayer, + payload: SpatialSkeletonCommandPayload, + ): SpatialSkeletonCommand | undefined; +} + +type SpatialSkeletonEditCommandSourceCandidate = { + supports?: (action: SpatialSkeletonAction) => boolean; + createCommand?: ( + action: SpatialSkeletonAction, + layer: SegmentationUserLayer, + payload: SpatialSkeletonCommandPayload, + ) => SpatialSkeletonCommand | undefined; +}; + +export function isSpatialSkeletonEditCommandSource( + value: unknown, +): value is SpatialSkeletonEditCommandSource { + return ( + typeof value === "object" && + value !== null && + typeof (value as SpatialSkeletonEditCommandSourceCandidate).supports === + "function" && + typeof (value as SpatialSkeletonEditCommandSourceCandidate) + .createCommand === + "function" + ); +} diff --git a/src/skeleton/spatial_skeleton_manager.spec.ts b/src/skeleton/spatial_skeleton_manager.spec.ts index f7d4aeebe..f4782e387 100644 --- a/src/skeleton/spatial_skeleton_manager.spec.ts +++ b/src/skeleton/spatial_skeleton_manager.spec.ts @@ -33,7 +33,13 @@ function makeEditableSourceMethods() { moveNode: vi.fn(), splitSkeleton: vi.fn(), mergeSkeletons: vi.fn(), - toggleTrueEnd: vi.fn(), + }; +} + +function makeEditCommandSource() { + return { + supports: vi.fn(), + createCommand: vi.fn(), }; } @@ -41,6 +47,7 @@ describe("skeleton/spatial_skeleton_manager", () => { it("returns an editable source when mandatory edit actions are present", () => { const source = { ...makeEditableSourceMethods(), + spatialSkeletonEditCommandSource: makeEditCommandSource(), listSkeletons: async () => [], getSkeleton: async () => [], fetchNodes: async () => [], @@ -53,7 +60,22 @@ describe("skeleton/spatial_skeleton_manager", () => { it("does not treat a source missing mandatory edit actions as editable", () => { const source = { ...makeEditableSourceMethods(), - toggleTrueEnd: undefined, + mergeSkeletons: undefined, + spatialSkeletonEditCommandSource: makeEditCommandSource(), + listSkeletons: async () => [], + getSkeleton: async () => [], + fetchNodes: async () => [], + getSpatialIndexMetadata: async () => null, + }; + + expect( + getEditableSpatiallyIndexedSkeletonSource({ source }), + ).toBeUndefined(); + }); + + it("does not treat a source without an edit command source as editable", () => { + const source = { + ...makeEditableSourceMethods(), listSkeletons: async () => [], getSkeleton: async () => [], fetchNodes: async () => [], @@ -68,6 +90,7 @@ describe("skeleton/spatial_skeleton_manager", () => { it("does not require optional edit actions for editable source validation", () => { const source = { ...makeEditableSourceMethods(), + spatialSkeletonEditCommandSource: makeEditCommandSource(), listSkeletons: async () => [], getSkeleton: async () => [], fetchNodes: async () => [], @@ -81,6 +104,7 @@ describe("skeleton/spatial_skeleton_manager", () => { const source = { ...makeEditableSourceMethods(), spatialSkeletonReadOnly: true, + spatialSkeletonEditCommandSource: makeEditCommandSource(), listSkeletons: async () => [], getSkeleton: async () => [], fetchNodes: async () => [], diff --git a/src/skeleton/spatial_skeleton_manager.ts b/src/skeleton/spatial_skeleton_manager.ts index 7983f505b..0ac24fb1d 100644 --- a/src/skeleton/spatial_skeleton_manager.ts +++ b/src/skeleton/spatial_skeleton_manager.ts @@ -21,6 +21,7 @@ import type { SpatiallyIndexedSkeletonSource, } from "#src/skeleton/api.js"; import { SpatialSkeletonCommandHistory } from "#src/skeleton/command_history.js"; +import { isSpatialSkeletonEditCommandSource } from "#src/skeleton/edit_command_source.js"; import type { SpatiallyIndexedSkeletonLayer } from "#src/skeleton/frontend.js"; import { WatchableValue } from "#src/trackable_value.js"; import { RefCounted } from "#src/util/disposable.js"; @@ -40,6 +41,15 @@ function hasFunction( ); } +function getProperty( + value: unknown, + property: T, +): unknown { + return typeof value === "object" && value !== null + ? (value as Record)[property] + : undefined; +} + export function isSpatiallyIndexedSkeletonSource( value: unknown, ): value is SpatiallyIndexedSkeletonSource { @@ -57,12 +67,14 @@ export function isEditableSpatiallyIndexedSkeletonSource( return ( isSpatiallyIndexedSkeletonSource(value) && value.spatialSkeletonReadOnly !== true && + isSpatialSkeletonEditCommandSource( + getProperty(value, "spatialSkeletonEditCommandSource"), + ) && hasFunction(value, "addNode") && hasFunction(value, "deleteNode") && hasFunction(value, "moveNode") && hasFunction(value, "splitSkeleton") && - hasFunction(value, "mergeSkeletons") && - hasFunction(value, "toggleTrueEnd") + hasFunction(value, "mergeSkeletons") ); } From 9516fd04b911d1bbfe80d128ea82e34dcc8a8fcb Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Tue, 5 May 2026 19:35:41 +0100 Subject: [PATCH 07/11] docs: Update user guide --- docs/user-guide/skeleton_editing.rst | 50 +++++++++++++++++++++------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/docs/user-guide/skeleton_editing.rst b/docs/user-guide/skeleton_editing.rst index dbaac90e3..ca875df79 100644 --- a/docs/user-guide/skeleton_editing.rst +++ b/docs/user-guide/skeleton_editing.rst @@ -19,14 +19,37 @@ CATMAID documentation to set up a CATMAID server. At minimum you will need: The project stack dimensions and resolution are used to inform the bounding box of the data in neuroglancer as their product. Skeletons in CATMAID are in 1 nm -units. Optionally, you can also configure stack metadata, which is intended to be used alongside the CATMAID multiple-LOD cache -grid for faster fetches of spatially indexed skeletons. The stack metadata can specify the key ``spatial_skeleton_chunk_sizes`` to define the chunk sizes at each LOD level in neuroglancer in nm. These chunk sizes are not required to be the same as the CATMAID LOD cache grid chunk sizes, and chunks are not required to have an exact match in the cache. Neuroglancer will always request to CATMAID to provide nodes from a cache if present. In addition, neuroglancer will request chunks at: - -.. math:: - - \mathrm{lod}\!\left(\frac{k}{n - 1}\right) - -in CATMAID, where :math:`k` is the LOD index level in neuroglancer, and :math:`n` is the number of LOD levels. +units. + +The linked CATMAID stack must define spatial skeleton metadata. Neuroglancer +uses this metadata to build the spatially indexed skeleton source required for +editing. Add a ``spatial`` array to the stack metadata, with one entry for each +spatial index level: + +.. code-block:: json + + { + "spatial": [ + { + "chunk_size": [11168145, 11168145, 11168145], + "limit": 500 + }, + { + "chunk_size": [3939000, 3939000, 3939000], + "limit": 7000 + } + ], + "cache_provider": "cached_msgpack_grid", + "read_only": false + } + +``chunk_size`` is specified in CATMAID project-space nanometers. ``limit`` is +the maximum node count expected for that spatial level and is required. +``cache_provider`` is optional and, when present, is passed to CATMAID node-list +requests. ``read_only`` is optional and disables editing when set to ``true``. + +If ``spatial`` is absent or empty, Neuroglancer rejects the CATMAID datasource +because it cannot construct the spatially indexed skeleton source. After setting this up, enter ``catmaid:/`` as a data source in neuroglancer. @@ -43,12 +66,13 @@ In the **Render** tab you can adjust: - **Opacity (3d)** — controls the opacity of fully loaded, visible skeletons. - **Hidden Opacity (3d)** — controls the opacity of hidden skeletons, which represent - LOD-influenced spatial indicators of nodes in space. + spatially indexed indicators of nodes in space. When you make a skeleton visible, a full fetch is triggered and you are guaranteed to see all nodes and details of that skeleton. Otherwise you see whatever is -provided by the LOD spatial information. The selected LOD is controllable via the -**Resoltion (skeleton grid 2D)** and **Resolution (skeleton grid 3D)** resolution settings. +provided by the spatial index level selected for the current view. The selected +grid size is controlled via the **Resolution (skeleton grid 2D)** and +**Resolution (skeleton grid 3D)** settings. The **Seg** tab works as normal for a segmentation layer, allowing you to set the visibility of segments/skeletons by their ID or by label if one has been assigned. @@ -60,7 +84,9 @@ Skeleton Tab The **Skeleton** tab is used for editing and viewing information about skeletons. It is only available for CATMAID sources with an active spatially indexed skeleton -subsource, and only visible skeletons appear here. +subsource, and only visible skeletons appear here. If the CATMAID stack metadata +sets ``read_only`` to ``true``, inspection remains available but edit actions are +disabled. You can find a node by ID or by description, and filter nodes to show only: From 9992aa3d65038890c920b91d6dffcad0826b4aa4 Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Wed, 6 May 2026 12:35:49 +0100 Subject: [PATCH 08/11] feat: Use single source of truth for read-only --- src/datasource/catmaid/api.spec.ts | 41 +++++++- src/datasource/catmaid/api.ts | 19 +++- src/datasource/catmaid/base.ts | 2 +- src/datasource/catmaid/frontend.ts | 17 ++-- .../catmaid/spatial_skeleton_commands.ts | 15 ++- src/layer/segmentation/index.spec.ts | 3 +- .../spatial_skeleton_commands.spec.ts | 95 ++++++++++++++++++- src/skeleton/api.ts | 2 +- src/skeleton/spatial_skeleton_manager.spec.ts | 12 ++- src/skeleton/spatial_skeleton_manager.ts | 12 +-- 10 files changed, 185 insertions(+), 33 deletions(-) diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index 210a7e2ce..6f669eb8e 100644 --- a/src/datasource/catmaid/api.spec.ts +++ b/src/datasource/catmaid/api.spec.ts @@ -77,7 +77,7 @@ describe("CatmaidClient skeleton editing methods", () => { await expect(client.getSpatialIndexMetadata()).resolves.toEqual({ lowerBounds: [5, 6, 7], upperBounds: [25, 66, 127], - readOnly: false, + readOnly: true, spatial: [ { chunkSize: [15, 15, 15], @@ -92,6 +92,24 @@ describe("CatmaidClient skeleton editing methods", () => { warnSpy.mockRestore(); }); + it("honors explicit writable CATMAID spatial skeleton metadata", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + (client as any).listStacks = vi.fn().mockResolvedValue([{ id: 7 }]); + (client as any).getStackInfo = vi.fn().mockResolvedValue({ + dimension: { x: 10, y: 20, z: 30 }, + resolution: { x: 2, y: 3, z: 4 }, + translation: { x: 5, y: 6, z: 7 }, + metadata: { + read_only: false, + spatial: [{ chunk_size: [15, 15, 15], limit: 1 }], + }, + }); + + await expect(client.getSpatialIndexMetadata()).resolves.toMatchObject({ + readOnly: false, + }); + }); + it("rejects CATMAID stack metadata without spatial skeleton levels", async () => { const client = new CatmaidClient("https://example.invalid", 1); (client as any).listStacks = vi.fn().mockResolvedValue([{ id: 7 }]); @@ -813,6 +831,27 @@ describe("CatmaidClient skeleton editing methods", () => { expect(requestBody.get("delete_existing")).toBe("true"); }); + it("preserves true-end labels while replacing description labels", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi + .fn() + .mockResolvedValue({ edition_time: "2026-03-29T13:05:00Z" }); + (client as any).fetch = fetchMock; + + await expect( + client.updateDescription(11, "updated description\nends", { + isTrueEnd: true, + }), + ).resolves.toEqual({ + description: "updated description", + sourceState: testSourceState("2026-03-29T13:05:00Z"), + }); + + const requestBody = getFetchBody(fetchMock); + expect(requestBody.get("tags")).toBe("updated description,ends"); + expect(requestBody.get("delete_existing")).toBe("true"); + }); + it("toggles true-end labels without CATMAID node state", async () => { const client = new CatmaidClient("https://example.invalid", 1); const fetchMock = vi diff --git a/src/datasource/catmaid/api.ts b/src/datasource/catmaid/api.ts index 90c1d705a..a8912cf6c 100644 --- a/src/datasource/catmaid/api.ts +++ b/src/datasource/catmaid/api.ts @@ -103,6 +103,10 @@ export interface CatmaidDescriptionUpdateResult description?: string; } +export interface CatmaidDescriptionUpdateOptions { + isTrueEnd?: boolean; +} + export type CatmaidDeleteNodeResult = CatmaidSkeletonEditResult; export type CatmaidRerootResult = CatmaidSkeletonEditResult; @@ -170,6 +174,7 @@ export interface CatmaidSpatialSkeletonEditApi { updateDescription( nodeId: number, description: string, + options?: CatmaidDescriptionUpdateOptions, ): Promise; updateRadius( nodeId: number, @@ -1293,7 +1298,10 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { "spatial metadata upper bound", ); const extents = [upperX - lowerX, upperY - lowerY, upperZ - lowerZ]; - return this.getSpatialIndexLevelsFromSpatialMetadata(info.metadata, extents); + return this.getSpatialIndexLevelsFromSpatialMetadata( + info.metadata, + extents, + ); } private getSpatialSkeletonReadOnlyFromMetadataInfo( @@ -1304,7 +1312,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { parseOptionalCatmaidBoolean( metadata?.read_only, "spatial skeleton metadata read_only", - ) ?? false + ) ?? true ); } @@ -1804,9 +1812,14 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { async updateDescription( nodeId: number, description: string, + options: CatmaidDescriptionUpdateOptions = {}, ): Promise { const normalizedLabels = this.buildDescriptionLabels(description); - const response = await this.replaceNodeLabels(nodeId, normalizedLabels); + const labels = + options.isTrueEnd === true + ? [...normalizedLabels, CATMAID_TRUE_END_LABEL] + : normalizedLabels; + const response = await this.replaceNodeLabels(nodeId, labels); return { ...getCatmaidSingleNodeRevisionResult( normalizeCatmaidRevisionToken(response?.edition_time), diff --git a/src/datasource/catmaid/base.ts b/src/datasource/catmaid/base.ts index f3724fb43..ad8da7511 100644 --- a/src/datasource/catmaid/base.ts +++ b/src/datasource/catmaid/base.ts @@ -20,7 +20,7 @@ export class CatmaidDataSourceParameters { url!: string; projectId!: number; cacheProvider?: string; - spatialSkeletonsReadOnly?: boolean; + readOnly = true; } export class CatmaidSkeletonSourceParameters extends SkeletonSourceParameters { diff --git a/src/datasource/catmaid/frontend.ts b/src/datasource/catmaid/frontend.ts index a42a0d1f6..4e47c2152 100644 --- a/src/datasource/catmaid/frontend.ts +++ b/src/datasource/catmaid/frontend.ts @@ -26,6 +26,7 @@ import type { CredentialsProvider } from "#src/credentials_provider/index.js"; import type { CatmaidAddNodeResult, CatmaidDeleteNodeResult, + CatmaidDescriptionUpdateOptions, CatmaidDescriptionUpdateResult, CatmaidEditContext, CatmaidInsertNodeResult, @@ -100,12 +101,12 @@ export class CatmaidSpatiallyIndexedSkeletonSource new CatmaidSpatialSkeletonEditCommandSource(); private client_?: CatmaidClient; - get spatialSkeletonReadOnly() { - return this.parameters.catmaidParameters.spatialSkeletonsReadOnly === true; + get readOnly() { + return this.parameters.catmaidParameters.readOnly !== false; } get spatialSkeletonEditCapabilities() { - return this.spatialSkeletonReadOnly + return this.readOnly ? undefined : CATMAID_SPATIAL_SKELETON_EDIT_CAPABILITIES; } @@ -115,7 +116,7 @@ export class CatmaidSpatiallyIndexedSkeletonSource } private ensureSpatialSkeletonEditable() { - if (this.spatialSkeletonReadOnly) { + if (this.readOnly) { throw new Error("CATMAID spatial skeleton source is read-only."); } } @@ -237,9 +238,10 @@ export class CatmaidSpatiallyIndexedSkeletonSource updateDescription( nodeId: number, description: string, + options?: CatmaidDescriptionUpdateOptions, ): Promise { this.ensureSpatialSkeletonEditable(); - return this.client.updateDescription(nodeId, description); + return this.client.updateDescription(nodeId, description, options); } toggleTrueEnd( @@ -312,7 +314,7 @@ export class CatmaidMultiscaleSpatiallyIndexedSkeletonSource extends MultiscaleS private upperBoundsInNanometers: Float32Array, gridCellSizes: Array<{ x: number; y: number; z: number }>, private cacheProvider?: string, - private spatialSkeletonsReadOnly = false, + private readOnly = true, ) { super(chunkManager); this.sortedGridCellSizes = [...gridCellSizes].sort( @@ -382,8 +384,7 @@ export class CatmaidMultiscaleSpatiallyIndexedSkeletonSource extends MultiscaleS parameters.catmaidParameters.url = this.baseUrl; parameters.catmaidParameters.projectId = this.projectId; parameters.catmaidParameters.cacheProvider = this.cacheProvider; - parameters.catmaidParameters.spatialSkeletonsReadOnly = - this.spatialSkeletonsReadOnly; + parameters.catmaidParameters.readOnly = this.readOnly; parameters.gridIndex = gridIndex; parameters.catmaidLod = lastGridIndex <= 0 ? 0 : gridIndex / lastGridIndex; diff --git a/src/datasource/catmaid/spatial_skeleton_commands.ts b/src/datasource/catmaid/spatial_skeleton_commands.ts index ae11d3015..f677c9013 100644 --- a/src/datasource/catmaid/spatial_skeleton_commands.ts +++ b/src/datasource/catmaid/spatial_skeleton_commands.ts @@ -130,8 +130,7 @@ function isSpatialSkeletonVector( value: object | undefined, ): value is SpatialSkeletonVector { return ( - value !== undefined && - isFiniteNumber((value as { length?: number }).length) + value !== undefined && isFiniteNumber((value as { length?: number }).length) ); } @@ -175,7 +174,9 @@ function isCatmaidMergeEndpoint( nodeId?: number; segmentId?: number; }; - return isFiniteNumber(candidate.nodeId) && isFiniteNumber(candidate.segmentId); + return ( + isFiniteNumber(candidate.nodeId) && isFiniteNumber(candidate.segmentId) + ); } function requireCatmaidCommandPayload( @@ -212,7 +213,9 @@ function requireCatmaidInsertNodeCommandOptions(payload: object) { return requireCatmaidCommandPayload( payload, "insert-node", - (candidate): candidate is CatmaidSpatialSkeletonInsertNodeCommandOptions => { + ( + candidate, + ): candidate is CatmaidSpatialSkeletonInsertNodeCommandOptions => { const options = candidate as { skeletonId?: number; parentNodeId?: number; @@ -760,6 +763,7 @@ async function applyNodeDescriptionAndTrueEnd( const descriptionResult = await skeletonSource.updateDescription( node.nodeId, nextDescription ?? "", + { isTrueEnd: nextTrueEnd }, ); updatedNode = { ...updatedNode, @@ -767,7 +771,7 @@ async function applyNodeDescriptionAndTrueEnd( sourceState: descriptionResult.sourceState ?? updatedNode.sourceState, }; } - if (node.isTrueEnd !== nextTrueEnd || (descriptionChanged && nextTrueEnd)) { + if (!descriptionChanged && node.isTrueEnd !== nextTrueEnd) { const trueEndResult = await skeletonSource.toggleTrueEnd( node.nodeId, nextTrueEnd, @@ -1419,6 +1423,7 @@ class NodeDescriptionCommand implements SpatialSkeletonCommand { const result = await skeletonSource.updateDescription( node.nodeId, nextDescription ?? "", + { isTrueEnd: node.isTrueEnd === true }, ); this.layer.spatialSkeletonState.updateCachedNode( node.nodeId, diff --git a/src/layer/segmentation/index.spec.ts b/src/layer/segmentation/index.spec.ts index 1e1381484..efc101e11 100644 --- a/src/layer/segmentation/index.spec.ts +++ b/src/layer/segmentation/index.spec.ts @@ -48,6 +48,7 @@ function makeEditableSpatialSkeletonSource( action !== SpatialSkeletonActions.reroot || options.rerootSkeleton !== undefined; return { + readOnly: false, spatialSkeletonEditCommandSource: { supports, createCommand: (action: string) => @@ -250,7 +251,7 @@ describe("layer/segmentation spatial skeleton action gating", () => { ...makeEditableSpatialSkeletonSource({ rerootSkeleton: async () => {}, }), - spatialSkeletonReadOnly: true, + readOnly: true, }), spatialSkeletonVisibleChunksLoaded: new WatchableValue(true), spatialSkeletonVisibleChunksNeeded: new WatchableValue(0), diff --git a/src/layer/segmentation/spatial_skeleton_commands.spec.ts b/src/layer/segmentation/spatial_skeleton_commands.spec.ts index e59cbfb18..a1673fee1 100644 --- a/src/layer/segmentation/spatial_skeleton_commands.spec.ts +++ b/src/layer/segmentation/spatial_skeleton_commands.spec.ts @@ -62,6 +62,7 @@ function setSegmentNodes( function makeEditableSkeletonSource(overrides: Record = {}) { return { + readOnly: false, spatialSkeletonEditCommandSource: new CatmaidSpatialSkeletonEditCommandSource(), listSkeletons: vi.fn(), @@ -122,6 +123,7 @@ describe("spatial_skeleton_commands", () => { }, getSpatiallyIndexedSkeletonLayer: () => ({ source: { + readOnly: false, spatialSkeletonEditCommandSource: { supports: () => true, createCommand, @@ -173,6 +175,7 @@ describe("spatial_skeleton_commands", () => { }, getSpatiallyIndexedSkeletonLayer: () => ({ source: { + readOnly: false, spatialSkeletonEditCommandSource: { supports: () => true, }, @@ -222,6 +225,7 @@ describe("spatial_skeleton_commands", () => { }, getSpatiallyIndexedSkeletonLayer: () => ({ source: { + readOnly: false, spatialSkeletonEditCommandSource: { supports: () => true, createCommand, @@ -303,10 +307,14 @@ describe("spatial_skeleton_commands", () => { }; expect(() => - commandSource.createCommand(SpatialSkeletonActions.moveNodes, layer as any, { - node: {}, - nextPositionInModelSpace: new Float32Array([7, 8, 9]), - }), + commandSource.createCommand( + SpatialSkeletonActions.moveNodes, + layer as any, + { + node: {}, + nextPositionInModelSpace: new Float32Array([7, 8, 9]), + }, + ), ).toThrow("CATMAID move-node command received an invalid payload."); }); @@ -379,6 +387,85 @@ describe("spatial_skeleton_commands", () => { expect(skeletonLayer.invalidateSourceCaches).not.toHaveBeenCalled(); }); + it("preserves CATMAID true-end labels when editing node descriptions", async () => { + suppressStatusMessages(); + + let cachedNode: SpatiallyIndexedSkeletonNode = { + nodeId: 17, + segmentId: 23, + position: new Float32Array([1, 2, 3]), + description: "before", + isTrueEnd: true, + sourceState: testSourceState("before"), + }; + const updateDescription = vi.fn().mockResolvedValue({ + description: "after", + sourceState: testSourceState("after"), + }); + const toggleTrueEnd = vi.fn(); + const skeletonLayer = { + source: makeEditableSkeletonSource({ updateDescription, toggleTrueEnd }), + getNode: vi.fn((nodeId: number) => + nodeId === cachedNode.nodeId ? cachedNode : undefined, + ), + invalidateSourceCaches: vi.fn(), + }; + const commandHistory = new SpatialSkeletonCommandHistory(); + const updateCachedNode = vi.fn( + ( + nodeId: number, + updater: ( + candidate: SpatiallyIndexedSkeletonNode, + ) => SpatiallyIndexedSkeletonNode, + ) => { + if (nodeId === cachedNode.nodeId) { + cachedNode = updater(cachedNode); + } + }, + ); + const setCachedNodeSourceState = vi.fn( + (nodeId: number, sourceState: unknown) => { + if (nodeId === cachedNode.nodeId) { + cachedNode = { ...cachedNode, sourceState: sourceState as any }; + } + }, + ); + const markSpatialSkeletonNodeDataChanged = vi.fn(); + const layer = { + spatialSkeletonState: { + commandHistory, + getCachedNode: vi.fn((nodeId: number) => + nodeId === cachedNode.nodeId ? cachedNode : undefined, + ), + getCachedSegmentNodes: vi.fn((segmentId: number) => + segmentId === cachedNode.segmentId ? [cachedNode] : undefined, + ), + updateCachedNode, + setCachedNodeSourceState, + }, + getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, + markSpatialSkeletonNodeDataChanged, + }; + + await executeSpatialSkeletonNodeDescriptionUpdate(layer as any, { + node: cachedNode, + nextDescription: "after", + }); + + expect(updateDescription).toHaveBeenCalledWith(17, "after", { + isTrueEnd: true, + }); + expect(toggleTrueEnd).not.toHaveBeenCalled(); + expect(cachedNode).toMatchObject({ + description: "after", + isTrueEnd: true, + sourceState: testSourceState("after"), + }); + expect(markSpatialSkeletonNodeDataChanged).toHaveBeenCalledWith({ + invalidateFullSkeletonCache: false, + }); + }); + it("moves to the parent node when undoing an add-node command", async () => { suppressStatusMessages(); diff --git a/src/skeleton/api.ts b/src/skeleton/api.ts index 5704bf21c..d425da8b3 100644 --- a/src/skeleton/api.ts +++ b/src/skeleton/api.ts @@ -81,7 +81,7 @@ export type SpatialSkeletonEditOperation = ( ) => Promise; export interface SpatiallyIndexedSkeletonSource { - readonly spatialSkeletonReadOnly?: boolean; + readonly readOnly: boolean; listSkeletons(): Promise; getSkeleton( skeletonId: number, diff --git a/src/skeleton/spatial_skeleton_manager.spec.ts b/src/skeleton/spatial_skeleton_manager.spec.ts index f4782e387..860ec899f 100644 --- a/src/skeleton/spatial_skeleton_manager.spec.ts +++ b/src/skeleton/spatial_skeleton_manager.spec.ts @@ -47,6 +47,7 @@ describe("skeleton/spatial_skeleton_manager", () => { it("returns an editable source when mandatory edit actions are present", () => { const source = { ...makeEditableSourceMethods(), + readOnly: false, spatialSkeletonEditCommandSource: makeEditCommandSource(), listSkeletons: async () => [], getSkeleton: async () => [], @@ -61,6 +62,7 @@ describe("skeleton/spatial_skeleton_manager", () => { const source = { ...makeEditableSourceMethods(), mergeSkeletons: undefined, + readOnly: false, spatialSkeletonEditCommandSource: makeEditCommandSource(), listSkeletons: async () => [], getSkeleton: async () => [], @@ -76,6 +78,7 @@ describe("skeleton/spatial_skeleton_manager", () => { it("does not treat a source without an edit command source as editable", () => { const source = { ...makeEditableSourceMethods(), + readOnly: false, listSkeletons: async () => [], getSkeleton: async () => [], fetchNodes: async () => [], @@ -90,6 +93,7 @@ describe("skeleton/spatial_skeleton_manager", () => { it("does not require optional edit actions for editable source validation", () => { const source = { ...makeEditableSourceMethods(), + readOnly: false, spatialSkeletonEditCommandSource: makeEditCommandSource(), listSkeletons: async () => [], getSkeleton: async () => [], @@ -103,7 +107,7 @@ describe("skeleton/spatial_skeleton_manager", () => { it("does not treat a read-only source with edit methods as editable", () => { const source = { ...makeEditableSourceMethods(), - spatialSkeletonReadOnly: true, + readOnly: true, spatialSkeletonEditCommandSource: makeEditCommandSource(), listSkeletons: async () => [], getSkeleton: async () => [], @@ -317,6 +321,7 @@ describe("skeleton/spatial_skeleton_manager", () => { ); const skeletonLayer = { source: { + readOnly: false, listSkeletons: async () => [], getSkeleton, fetchNodes: async () => [], @@ -369,6 +374,7 @@ describe("skeleton/spatial_skeleton_manager", () => { const pending = state.getFullSegmentNodes( { source: { + readOnly: false, listSkeletons: async () => [], getSkeleton, fetchNodes: async () => [], @@ -404,6 +410,7 @@ describe("skeleton/spatial_skeleton_manager", () => { const pending = state.getFullSegmentNodes( { source: { + readOnly: false, listSkeletons: async () => [], getSkeleton, fetchNodes: async () => [], @@ -439,6 +446,7 @@ describe("skeleton/spatial_skeleton_manager", () => { const pending = state.getFullSegmentNodes( { source: { + readOnly: false, listSkeletons: async () => [], getSkeleton, fetchNodes: async () => [], @@ -469,6 +477,7 @@ describe("skeleton/spatial_skeleton_manager", () => { ]); const skeletonLayer = { source: { + readOnly: false, listSkeletons: async () => [], getSkeleton, fetchNodes: async () => [], @@ -521,6 +530,7 @@ describe("skeleton/spatial_skeleton_manager", () => { state.getFullSegmentNodes( { source: { + readOnly: false, listSkeletons: async () => [], getSkeleton, fetchNodes: async () => [], diff --git a/src/skeleton/spatial_skeleton_manager.ts b/src/skeleton/spatial_skeleton_manager.ts index 0ac24fb1d..cabfb4db0 100644 --- a/src/skeleton/spatial_skeleton_manager.ts +++ b/src/skeleton/spatial_skeleton_manager.ts @@ -41,10 +41,7 @@ function hasFunction( ); } -function getProperty( - value: unknown, - property: T, -): unknown { +function getProperty(value: unknown, property: T): unknown { return typeof value === "object" && value !== null ? (value as Record)[property] : undefined; @@ -54,6 +51,7 @@ export function isSpatiallyIndexedSkeletonSource( value: unknown, ): value is SpatiallyIndexedSkeletonSource { return ( + typeof getProperty(value, "readOnly") === "boolean" && hasFunction(value, "listSkeletons") && hasFunction(value, "getSkeleton") && hasFunction(value, "getSpatialIndexMetadata") && @@ -66,7 +64,7 @@ export function isEditableSpatiallyIndexedSkeletonSource( ): value is EditableSpatiallyIndexedSkeletonSource { return ( isSpatiallyIndexedSkeletonSource(value) && - value.spatialSkeletonReadOnly !== true && + !value.readOnly && isSpatialSkeletonEditCommandSource( getProperty(value, "spatialSkeletonEditCommandSource"), ) && @@ -90,9 +88,7 @@ export function getSpatiallyIndexedSkeletonSource( export function isSpatiallyIndexedSkeletonSourceReadOnly( value: SpatialSkeletonSourceAccess | undefined, ): boolean { - return ( - getSpatiallyIndexedSkeletonSource(value)?.spatialSkeletonReadOnly === true - ); + return getSpatiallyIndexedSkeletonSource(value)?.readOnly === true; } export function getEditableSpatiallyIndexedSkeletonSource( From 4e3a5a920f02ce633e4d1cabc6212d6c36d74452 Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Wed, 6 May 2026 15:44:08 +0100 Subject: [PATCH 09/11] refactor: Modify edit interface to require commands --- src/datasource/catmaid/api.spec.ts | 21 + src/datasource/catmaid/api.ts | 9 +- src/datasource/catmaid/edit_state.ts | 10 + src/datasource/catmaid/frontend.ts | 169 +--- .../catmaid/spatial_skeleton_commands.ts | 806 ++++++++++-------- .../catmaid/spatial_skeleton_edit_api.ts | 131 +++ src/layer/segmentation/index.spec.ts | 54 +- src/layer/segmentation/index.ts | 48 +- .../spatial_skeleton_commands.spec.ts | 543 ++++++------ .../segmentation/spatial_skeleton_commands.ts | 66 +- src/skeleton/api.ts | 41 +- src/skeleton/edit_command_source.ts | 67 +- src/skeleton/spatial_skeleton_manager.spec.ts | 44 +- src/skeleton/spatial_skeleton_manager.ts | 85 +- src/ui/spatial_skeleton_edit_tool.spec.ts | 94 +- 15 files changed, 1280 insertions(+), 908 deletions(-) create mode 100644 src/datasource/catmaid/spatial_skeleton_edit_api.ts diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index 6f669eb8e..bb0812698 100644 --- a/src/datasource/catmaid/api.spec.ts +++ b/src/datasource/catmaid/api.spec.ts @@ -197,6 +197,27 @@ describe("CatmaidClient skeleton editing methods", () => { ); }); + it("accepts zero CATMAID spatial skeleton metadata limits", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + (client as any).listStacks = vi.fn().mockResolvedValue([{ id: 7 }]); + (client as any).getStackInfo = vi.fn().mockResolvedValue({ + dimension: { x: 10, y: 20, z: 30 }, + resolution: { x: 2, y: 3, z: 4 }, + translation: { x: 5, y: 6, z: 7 }, + metadata: { + spatial: [{ chunk_size: [15, 15, 15], limit: 0 }], + }, + }); + + await expect(client.getSpatialIndexMetadata()).resolves.toMatchObject({ + spatial: [ + { + limit: 0, + }, + ], + }); + }); + it("parses live compact-detail history rows and label maps", async () => { const client = new CatmaidClient("https://example.invalid", 1); const fetchMock = vi.fn().mockResolvedValue([ diff --git a/src/datasource/catmaid/api.ts b/src/datasource/catmaid/api.ts index a8912cf6c..1104b664e 100644 --- a/src/datasource/catmaid/api.ts +++ b/src/datasource/catmaid/api.ts @@ -579,10 +579,11 @@ function requireCatmaidPositiveRank3Vector( return values; } -function requireCatmaidPositiveInt(value: unknown, label: string): number { + +function requireCatmaidNonNegativeInt(value: unknown, label: string): number { const numberValue = Number(value); - if (!Number.isInteger(numberValue) || numberValue <= 0) { - throw new Error(`CATMAID ${label} must be a positive integer.`); + if (!Number.isInteger(numberValue) || numberValue < 0) { + throw new Error(`CATMAID ${label} must be a non-negative integer.`); } return numberValue; } @@ -1271,7 +1272,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { level?.chunk_size, `spatial skeleton metadata spatial[${index}].chunk_size`, ); - const limit = requireCatmaidPositiveInt( + const limit = requireCatmaidNonNegativeInt( level?.limit, `spatial skeleton metadata spatial[${index}].limit`, ); diff --git a/src/datasource/catmaid/edit_state.ts b/src/datasource/catmaid/edit_state.ts index 580cd67dd..6886f317e 100644 --- a/src/datasource/catmaid/edit_state.ts +++ b/src/datasource/catmaid/edit_state.ts @@ -81,6 +81,16 @@ export function buildCatmaidRerootEditContext( }; } +export function buildCatmaidInsertEditContext( + parentNode: SpatiallyIndexedSkeletonNode, + childNodes: readonly SpatiallyIndexedSkeletonNode[], +): CatmaidEditContext { + return { + node: buildCatmaidNodeEditContext(parentNode).node, + children: childNodes.map(toCatmaidEditParentContext), + }; +} + export function buildCatmaidMultiNodeEditContext( ...nodes: SpatiallyIndexedSkeletonNode[] ): CatmaidEditContext { diff --git a/src/datasource/catmaid/frontend.ts b/src/datasource/catmaid/frontend.ts index 4e47c2152..fa5a57f35 100644 --- a/src/datasource/catmaid/frontend.ts +++ b/src/datasource/catmaid/frontend.ts @@ -23,19 +23,7 @@ import { } from "#src/coordinate_transform.js"; import { WithCredentialsProvider } from "#src/credentials_provider/chunk_source_frontend.js"; import type { CredentialsProvider } from "#src/credentials_provider/index.js"; -import type { - CatmaidAddNodeResult, - CatmaidDeleteNodeResult, - CatmaidDescriptionUpdateOptions, - CatmaidDescriptionUpdateResult, - CatmaidEditContext, - CatmaidInsertNodeResult, - CatmaidMergeResult, - CatmaidNodeSourceStateResult, - CatmaidSplitResult, - CatmaidSpatialSkeletonEditApi, - CatmaidToken, -} from "#src/datasource/catmaid/api.js"; +import type { CatmaidToken } from "#src/datasource/catmaid/api.js"; import { CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES, CatmaidClient, @@ -47,7 +35,7 @@ import { CatmaidCompleteSkeletonSourceParameters, CatmaidDataSourceParameters, } from "#src/datasource/catmaid/base.js"; -import { CatmaidSpatialSkeletonEditCommandSource } from "#src/datasource/catmaid/spatial_skeleton_commands.js"; +import { CatmaidSpatialSkeletonEditCommands } from "#src/datasource/catmaid/spatial_skeleton_commands.js"; import type { DataSource, DataSourceProvider, @@ -58,7 +46,6 @@ import { normalizeInlineSegmentPropertyMap, } from "#src/segmentation_display_state/property_map.js"; import type { - EditableSpatiallyIndexedSkeletonSource, SpatialSkeletonEditCapabilities, SpatialSkeletonGridCellIndex, SpatiallyIndexedSkeletonMetadata, @@ -88,17 +75,15 @@ const CATMAID_SPATIAL_SKELETON_EDIT_CAPABILITIES = { }, } satisfies SpatialSkeletonEditCapabilities; -export class CatmaidSpatiallyIndexedSkeletonSource - extends WithParameters( - WithCredentialsProvider()(SpatiallyIndexedSkeletonSource), - CatmaidSkeletonSourceParameters, - ) - implements - CatmaidSpatialSkeletonEditApi, - EditableSpatiallyIndexedSkeletonSource -{ - private readonly editableSpatialSkeletonEditCommandSource = - new CatmaidSpatialSkeletonEditCommandSource(); +export class CatmaidSpatiallyIndexedSkeletonSource extends WithParameters( + WithCredentialsProvider()(SpatiallyIndexedSkeletonSource), + CatmaidSkeletonSourceParameters, +) { + private readonly spatialSkeletonEditCommands = + new CatmaidSpatialSkeletonEditCommands({ + ensureEditable: () => this.ensureSpatialSkeletonEditable(), + getClient: () => this.client, + }); private client_?: CatmaidClient; get readOnly() { @@ -111,9 +96,23 @@ export class CatmaidSpatiallyIndexedSkeletonSource : CATMAID_SPATIAL_SKELETON_EDIT_CAPABILITIES; } - get spatialSkeletonEditCommandSource() { - return this.editableSpatialSkeletonEditCommandSource; - } + readonly addNodesCommand = this.spatialSkeletonEditCommands.addNodesCommand; + readonly insertNodesCommand = + this.spatialSkeletonEditCommands.insertNodesCommand; + readonly moveNodesCommand = this.spatialSkeletonEditCommands.moveNodesCommand; + readonly deleteNodesCommand = + this.spatialSkeletonEditCommands.deleteNodesCommand; + readonly rerootCommand = this.spatialSkeletonEditCommands.rerootCommand; + readonly editNodeDescriptionCommand = + this.spatialSkeletonEditCommands.editNodeDescriptionCommand; + readonly editNodeTrueEndCommand = + this.spatialSkeletonEditCommands.editNodeTrueEndCommand; + readonly editNodePropertiesCommand = + this.spatialSkeletonEditCommands.editNodePropertiesCommand; + readonly mergeSkeletonsCommand = + this.spatialSkeletonEditCommands.mergeSkeletonsCommand; + readonly splitSkeletonsCommand = + this.spatialSkeletonEditCommands.splitSkeletonsCommand; private ensureSpatialSkeletonEditable() { if (this.readOnly) { @@ -174,118 +173,6 @@ export class CatmaidSpatiallyIndexedSkeletonSource getSkeletonRootNode(skeletonId: number) { return this.client.getSkeletonRootNode(skeletonId); } - - addNode( - skeletonId: number, - x: number, - y: number, - z: number, - parentId?: number, - editContext?: CatmaidEditContext, - ): Promise { - this.ensureSpatialSkeletonEditable(); - return this.client.addNode(skeletonId, x, y, z, parentId, editContext); - } - - insertNode( - skeletonId: number, - x: number, - y: number, - z: number, - parentId: number, - childNodeIds: readonly number[], - editContext?: CatmaidEditContext, - ): Promise { - this.ensureSpatialSkeletonEditable(); - return this.client.insertNode( - skeletonId, - x, - y, - z, - parentId, - childNodeIds, - editContext, - ); - } - - moveNode( - nodeId: number, - x: number, - y: number, - z: number, - editContext?: CatmaidEditContext, - ): Promise { - this.ensureSpatialSkeletonEditable(); - return this.client.moveNode(nodeId, x, y, z, editContext); - } - - deleteNode( - nodeId: number, - options: { - childNodeIds?: readonly number[]; - editContext?: CatmaidEditContext; - }, - ): Promise { - this.ensureSpatialSkeletonEditable(); - return this.client.deleteNode(nodeId, options); - } - - rerootSkeleton(nodeId: number, editContext?: CatmaidEditContext) { - this.ensureSpatialSkeletonEditable(); - return this.client.rerootSkeleton(nodeId, editContext); - } - - updateDescription( - nodeId: number, - description: string, - options?: CatmaidDescriptionUpdateOptions, - ): Promise { - this.ensureSpatialSkeletonEditable(); - return this.client.updateDescription(nodeId, description, options); - } - - toggleTrueEnd( - nodeId: number, - nextIsTrueEnd: boolean, - ): Promise { - this.ensureSpatialSkeletonEditable(); - return this.client.toggleTrueEnd(nodeId, nextIsTrueEnd); - } - - updateRadius( - nodeId: number, - radius: number, - editContext?: CatmaidEditContext, - ): Promise { - this.ensureSpatialSkeletonEditable(); - return this.client.updateRadius(nodeId, radius, editContext); - } - - updateConfidence( - nodeId: number, - confidence: number, - editContext?: CatmaidEditContext, - ): Promise { - this.ensureSpatialSkeletonEditable(); - return this.client.updateConfidence(nodeId, confidence, editContext); - } - - mergeSkeletons( - fromNodeId: number, - toNodeId: number, - editContext?: CatmaidEditContext, - ): Promise { - this.ensureSpatialSkeletonEditable(); - return this.client.mergeSkeletons(fromNodeId, toNodeId, editContext); - } - - splitSkeleton( - nodeId: number, - editContext?: CatmaidEditContext, - ): Promise { - this.ensureSpatialSkeletonEditable(); - return this.client.splitSkeleton(nodeId, editContext); - } } export class CatmaidSkeletonSource extends WithParameters( diff --git a/src/datasource/catmaid/spatial_skeleton_commands.ts b/src/datasource/catmaid/spatial_skeleton_commands.ts index f677c9013..b37b26c74 100644 --- a/src/datasource/catmaid/spatial_skeleton_commands.ts +++ b/src/datasource/catmaid/spatial_skeleton_commands.ts @@ -15,17 +15,9 @@ */ import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; -import type { - CatmaidAddNodeResult, - CatmaidEditContext, - CatmaidInsertNodeResult, - CatmaidMergeResult, - CatmaidSkeletonNodeSourceStateUpdate, - CatmaidSplitResult, - CatmaidSpatialSkeletonEditApi, -} from "#src/datasource/catmaid/api.js"; -import { getCatmaidRevisionToken } from "#src/datasource/catmaid/api.js"; +import type { CatmaidClient } from "#src/datasource/catmaid/api.js"; import { + buildCatmaidInsertEditContext, buildCatmaidMultiNodeEditContext, buildCatmaidNeighborhoodEditContext, buildCatmaidNodeEditContext, @@ -40,6 +32,28 @@ import type { SpatialSkeletonSourceState, SpatialSkeletonVector, } from "#src/skeleton/api.js"; +import type { + CatmaidSpatialSkeletonAddNodeRequest, + CatmaidSpatialSkeletonAddNodeResult, + CatmaidSpatialSkeletonConfidenceUpdateRequest, + CatmaidSpatialSkeletonDeleteNodeRequest, + CatmaidSpatialSkeletonDeleteNodeResult, + CatmaidSpatialSkeletonDescriptionUpdateRequest, + CatmaidSpatialSkeletonDescriptionUpdateResult, + CatmaidSpatialSkeletonInsertNodeRequest, + CatmaidSpatialSkeletonInsertNodeResult, + CatmaidSpatialSkeletonMergeRequest, + CatmaidSpatialSkeletonMergeResult, + CatmaidSpatialSkeletonMoveNodeRequest, + CatmaidSpatialSkeletonNodeSourceStateResult, + CatmaidSpatialSkeletonNodeSourceStateUpdate, + CatmaidSpatialSkeletonRadiusUpdateRequest, + CatmaidSpatialSkeletonRerootRequest, + CatmaidSpatialSkeletonRerootResult, + CatmaidSpatialSkeletonSplitRequest, + CatmaidSpatialSkeletonSplitResult, + CatmaidSpatialSkeletonTrueEndUpdateRequest, +} from "#src/datasource/catmaid/spatial_skeleton_edit_api.js"; import { SpatialSkeletonActions, type SpatialSkeletonAction, @@ -48,12 +62,13 @@ import type { SpatialSkeletonCommand, SpatialSkeletonCommandContext, } from "#src/skeleton/command_history.js"; -import type { SpatialSkeletonEditCommandSource } from "#src/skeleton/edit_command_source.js"; +import type { SpatialSkeletonEditCommandFactory } from "#src/skeleton/edit_command_source.js"; import { findSpatiallyIndexedSkeletonNode, getSpatiallyIndexedSkeletonDirectChildren, } from "#src/skeleton/edit_state.js"; import type { SpatiallyIndexedSkeletonLayer } from "#src/skeleton/frontend.js"; +import { getEditableSpatiallyIndexedSkeletonSource } from "#src/skeleton/spatial_skeleton_manager.js"; import { StatusMessage } from "#src/status.js"; import { formatErrorMessage } from "#src/util/error.js"; @@ -101,21 +116,45 @@ interface CatmaidSpatialSkeletonMergeCommandPayload { secondNode: CatmaidSpatialSkeletonMergeEndpoint; } -type CatmaidSpatialSkeletonCommandHandler = ( - layer: SegmentationUserLayer, - payload: object, -) => SpatialSkeletonCommand; +export interface CatmaidSpatialSkeletonEditCommandContext { + ensureEditable(): void; + getClient(): CatmaidClient; +} -function hasFunction( - value: object | undefined, - property: T, -): value is Record object> { - return ( - value !== undefined && - typeof (value as Partial object>>)[ - property - ] === "function" - ); +interface CatmaidSpatialSkeletonEditOperations { + commitAddNode( + request: CatmaidSpatialSkeletonAddNodeRequest, + ): Promise; + commitInsertNode( + request: CatmaidSpatialSkeletonInsertNodeRequest, + ): Promise; + commitMoveNode( + request: CatmaidSpatialSkeletonMoveNodeRequest, + ): Promise; + commitDeleteNode( + request: CatmaidSpatialSkeletonDeleteNodeRequest, + ): Promise; + commitReroot( + request: CatmaidSpatialSkeletonRerootRequest, + ): Promise; + commitDescription( + request: CatmaidSpatialSkeletonDescriptionUpdateRequest, + ): Promise; + commitTrueEnd( + request: CatmaidSpatialSkeletonTrueEndUpdateRequest, + ): Promise; + commitRadius( + request: CatmaidSpatialSkeletonRadiusUpdateRequest, + ): Promise; + commitConfidence( + request: CatmaidSpatialSkeletonConfidenceUpdateRequest, + ): Promise; + commitMerge( + request: CatmaidSpatialSkeletonMergeRequest, + ): Promise; + commitSplit( + request: CatmaidSpatialSkeletonSplitRequest, + ): Promise; } function isFiniteNumber(value: number | undefined) { @@ -380,24 +419,6 @@ function requireCatmaidMergeCommandPayload(payload: object) { ); } -function isCatmaidSpatialSkeletonEditApi( - value: object | undefined, -): value is CatmaidSpatialSkeletonEditApi { - return ( - hasFunction(value, "getSkeletonRootNode") && - hasFunction(value, "addNode") && - hasFunction(value, "insertNode") && - hasFunction(value, "moveNode") && - hasFunction(value, "deleteNode") && - hasFunction(value, "updateDescription") && - hasFunction(value, "toggleTrueEnd") && - hasFunction(value, "updateRadius") && - hasFunction(value, "updateConfidence") && - hasFunction(value, "mergeSkeletons") && - hasFunction(value, "splitSkeleton") - ); -} - function toCatmaidPositionInModelSpace( position: SpatialSkeletonVector, label: string, @@ -434,7 +455,6 @@ function cloneNodeSnapshot( function getEditableSkeletonSourceForLayer(layer: SegmentationUserLayer): { skeletonLayer: SpatiallyIndexedSkeletonLayer; - skeletonSource: CatmaidSpatialSkeletonEditApi; } { const skeletonLayer = layer.getSpatiallyIndexedSkeletonLayer(); if (skeletonLayer === undefined) { @@ -442,15 +462,12 @@ function getEditableSkeletonSourceForLayer(layer: SegmentationUserLayer): { "No spatially indexed skeleton source is currently loaded.", ); } - const skeletonSource = isCatmaidSpatialSkeletonEditApi(skeletonLayer.source) - ? skeletonLayer.source - : undefined; - if (skeletonSource === undefined) { + if (getEditableSpatiallyIndexedSkeletonSource(skeletonLayer) === undefined) { throw new Error( - "Unable to resolve CATMAID editable skeleton source for the active layer.", + "Unable to resolve editable skeleton source for the active layer.", ); } - return { skeletonLayer, skeletonSource }; + return { skeletonLayer }; } function ensureVisibleSegment( @@ -512,7 +529,6 @@ function findRootNode(segmentNodes: readonly SpatiallyIndexedSkeletonNode[]) { interface ResolvedSpatialSkeletonEditNode { skeletonLayer: SpatiallyIndexedSkeletonLayer; - skeletonSource: CatmaidSpatialSkeletonEditApi; segmentNodes: readonly SpatiallyIndexedSkeletonNode[]; node: SpatiallyIndexedSkeletonNode; } @@ -522,7 +538,6 @@ interface ResolvedSpatialSkeletonEditNodeContext { segmentId: number; cachedNode: SpatiallyIndexedSkeletonNode | undefined; skeletonLayer: SpatiallyIndexedSkeletonLayer; - skeletonSource: CatmaidSpatialSkeletonEditApi; } function getResolvedNodeContextForEdit( @@ -535,8 +550,7 @@ function getResolvedNodeContextForEdit( if (currentNodeId === undefined) { throw new Error(`Unable to resolve current node ${stableNodeId}.`); } - const { skeletonLayer, skeletonSource } = - getEditableSkeletonSourceForLayer(layer); + const { skeletonLayer } = getEditableSkeletonSourceForLayer(layer); const cachedNode = layer.spatialSkeletonState.getCachedNode(currentNodeId) ?? skeletonLayer.getNode(currentNodeId); @@ -552,7 +566,6 @@ function getResolvedNodeContextForEdit( segmentId: candidateSegmentId, cachedNode, skeletonLayer, - skeletonSource, }; } @@ -565,7 +578,6 @@ async function getResolvedNodeForEdit( currentNodeId, segmentId: candidateSegmentId, skeletonLayer, - skeletonSource, } = getResolvedNodeContextForEdit(layer, stableNodeId, stableSegmentId); let segmentNodes = layer.spatialSkeletonState.getCachedSegmentNodes(candidateSegmentId); @@ -583,30 +595,11 @@ async function getResolvedNodeForEdit( } return { skeletonLayer, - skeletonSource, segmentNodes, node, }; } -function buildInsertEditContext( - parentNode: SpatiallyIndexedSkeletonNode, - childNodes: readonly SpatiallyIndexedSkeletonNode[], -): CatmaidEditContext { - return { - node: buildCatmaidNodeEditContext(parentNode).node, - children: childNodes.map((child) => { - const revisionToken = getCatmaidRevisionToken(child.sourceState); - if (revisionToken === undefined) { - throw new Error( - `Inspected CATMAID child node ${child.nodeId} is missing revision metadata.`, - ); - } - return { nodeId: child.nodeId, revisionToken }; - }), - }; -} - async function refreshTopologySegments( layer: SegmentationUserLayer, segmentIds: readonly number[], @@ -635,7 +628,7 @@ async function refreshTopologySegments( function applyAddNodeToCache( layer: SegmentationUserLayer, skeletonLayer: SpatiallyIndexedSkeletonLayer, - committedNode: CatmaidAddNodeResult, + committedNode: CatmaidSpatialSkeletonAddNodeResult, parentNodeId: number | undefined, positionInModelSpace: Float32Array, options: { @@ -699,7 +692,7 @@ function applyDeleteNodeToCache( options: { moveView: boolean; }, - nodeSourceStateUpdates: readonly CatmaidSkeletonNodeSourceStateUpdate[] = [], + nodeSourceStateUpdates: readonly CatmaidSpatialSkeletonNodeSourceStateUpdate[] = [], ) { const { node, parentNode, childNodes } = deleteContext; const directChildIds = childNodes.map((child) => child.nodeId); @@ -744,7 +737,7 @@ function applyDeleteNodeToCache( } async function applyNodeDescriptionAndTrueEnd( - skeletonSource: CatmaidSpatialSkeletonEditApi, + editOperations: CatmaidSpatialSkeletonEditOperations, node: SpatiallyIndexedSkeletonNode, next: { description?: string; @@ -760,11 +753,11 @@ async function applyNodeDescriptionAndTrueEnd( }; const descriptionChanged = node.description !== nextDescription; if (descriptionChanged) { - const descriptionResult = await skeletonSource.updateDescription( - node.nodeId, - nextDescription ?? "", - { isTrueEnd: nextTrueEnd }, - ); + const descriptionResult = await editOperations.commitDescription({ + node, + description: nextDescription ?? "", + isTrueEnd: nextTrueEnd, + }); updatedNode = { ...updatedNode, description: descriptionResult.description, @@ -772,10 +765,10 @@ async function applyNodeDescriptionAndTrueEnd( }; } if (!descriptionChanged && node.isTrueEnd !== nextTrueEnd) { - const trueEndResult = await skeletonSource.toggleTrueEnd( - node.nodeId, - nextTrueEnd, - ); + const trueEndResult = await editOperations.commitTrueEnd({ + node, + isTrueEnd: nextTrueEnd, + }); updatedNode = { ...updatedNode, sourceState: trueEndResult.sourceState ?? updatedNode.sourceState, @@ -786,17 +779,16 @@ async function applyNodeDescriptionAndTrueEnd( async function restoreNodeAttributes( layer: SegmentationUserLayer, - skeletonSource: CatmaidSpatialSkeletonEditApi, + editOperations: CatmaidSpatialSkeletonEditOperations, createdNode: SpatiallyIndexedSkeletonNode, snapshot: SpatiallyIndexedSkeletonNode, ) { let nextNode = cloneNodeSnapshot(createdNode); if (snapshot.radius !== undefined && snapshot.radius !== nextNode.radius) { - const radiusResult = await skeletonSource.updateRadius( - createdNode.nodeId, - snapshot.radius, - buildCatmaidNodeEditContext(nextNode), - ); + const radiusResult = await editOperations.commitRadius({ + node: nextNode, + radius: snapshot.radius, + }); nextNode = { ...nextNode, radius: snapshot.radius, @@ -807,16 +799,10 @@ async function restoreNodeAttributes( snapshot.confidence !== undefined && snapshot.confidence !== nextNode.confidence ) { - if (getCatmaidRevisionToken(nextNode.sourceState) === undefined) { - throw new Error( - `Node ${createdNode.nodeId} is missing revision metadata required to restore confidence.`, - ); - } - const confidenceResult = await skeletonSource.updateConfidence( - createdNode.nodeId, - snapshot.confidence, - buildCatmaidNodeEditContext(nextNode), - ); + const confidenceResult = await editOperations.commitConfidence({ + node: nextNode, + confidence: snapshot.confidence, + }); nextNode = { ...nextNode, confidence: snapshot.confidence, @@ -828,7 +814,7 @@ async function restoreNodeAttributes( nextNode.isTrueEnd !== snapshot.isTrueEnd ) { nextNode = await applyNodeDescriptionAndTrueEnd( - skeletonSource, + editOperations, nextNode, snapshot, ); @@ -847,6 +833,7 @@ class AddNodeCommand implements SpatialSkeletonCommand { private stableParentNodeId: number | undefined, private targetSkeletonId: number, private positionInModelSpace: Float32Array, + private editOperations: CatmaidSpatialSkeletonEditOperations, ) {} private async addNode( @@ -857,19 +844,17 @@ class AddNodeCommand implements SpatialSkeletonCommand { statusPrefix: string; }, ) { - const { skeletonLayer, skeletonSource } = getEditableSkeletonSourceForLayer( - this.layer, - ); + const { skeletonLayer } = getEditableSkeletonSourceForLayer(this.layer); const currentParentNodeId = this.stableParentNodeId === undefined ? undefined : this.layer.spatialSkeletonState.commandHistory.mappings.resolveNodeId( this.stableParentNodeId, ); - let resolvedEditContext: CatmaidEditContext | undefined; + let parentNode: SpatiallyIndexedSkeletonNode | undefined; let resolvedSkeletonId = this.targetSkeletonId; if (currentParentNodeId !== undefined) { - const parentNode = ( + parentNode = ( await getResolvedNodeForEdit( this.layer, this.stableParentNodeId!, @@ -879,16 +864,12 @@ class AddNodeCommand implements SpatialSkeletonCommand { ) ).node; resolvedSkeletonId = parentNode.segmentId; - resolvedEditContext = buildCatmaidNodeEditContext(parentNode); } - const result = await skeletonSource.addNode( - resolvedSkeletonId, - Number(this.positionInModelSpace[0]), - Number(this.positionInModelSpace[1]), - Number(this.positionInModelSpace[2]), - currentParentNodeId, - resolvedEditContext, - ); + const result = await this.editOperations.commitAddNode({ + segmentId: resolvedSkeletonId, + position: this.positionInModelSpace, + parentNode, + }); if (this.stableNodeId === undefined) { this.stableNodeId = result.nodeId; } else { @@ -943,16 +924,11 @@ class AddNodeCommand implements SpatialSkeletonCommand { await this.layer.getSpatialSkeletonDeleteOperationContext( resolvedNode.node, ); - const result = await resolvedNode.skeletonSource.deleteNode( - resolvedNode.node.nodeId, - { - childNodeIds: [], - editContext: buildCatmaidNeighborhoodEditContext( - deleteContext.node, - resolvedNode.segmentNodes, - ), - }, - ); + const result = await this.editOperations.commitDeleteNode({ + node: deleteContext.node, + childNodes: [], + segmentNodes: resolvedNode.segmentNodes, + }); applyDeleteNodeToCache( this.layer, deleteContext, @@ -984,6 +960,7 @@ class InsertNodeCommand implements SpatialSkeletonCommand { private stableChildNodeIds: readonly number[], private targetSkeletonId: number, private positionInModelSpace: Float32Array, + private editOperations: CatmaidSpatialSkeletonEditOperations, ) {} private async insertNode(options: { @@ -991,9 +968,7 @@ class InsertNodeCommand implements SpatialSkeletonCommand { pinSegment: boolean; statusPrefix: string; }) { - const { skeletonLayer, skeletonSource } = getEditableSkeletonSourceForLayer( - this.layer, - ); + const { skeletonLayer } = getEditableSkeletonSourceForLayer(this.layer); const parentNode = ( await getResolvedNodeForEdit( this.layer, @@ -1010,15 +985,12 @@ class InsertNodeCommand implements SpatialSkeletonCommand { ).then((result) => result.node), ), ); - const result = await skeletonSource.insertNode( - parentNode.segmentId, - Number(this.positionInModelSpace[0]), - Number(this.positionInModelSpace[1]), - Number(this.positionInModelSpace[2]), - parentNode.nodeId, - childNodes.map((child) => child.nodeId), - buildInsertEditContext(parentNode, childNodes), - ); + const result = await this.editOperations.commitInsertNode({ + segmentId: parentNode.segmentId, + position: this.positionInModelSpace, + parentNode, + childNodes, + }); if (this.stableNodeId === undefined) { this.stableNodeId = result.nodeId; } else { @@ -1098,16 +1070,11 @@ class InsertNodeCommand implements SpatialSkeletonCommand { await this.layer.getSpatialSkeletonDeleteOperationContext( resolvedNode.node, ); - const result = await resolvedNode.skeletonSource.deleteNode( - resolvedNode.node.nodeId, - { - childNodeIds: deleteContext.childNodes.map((child) => child.nodeId), - editContext: buildCatmaidNeighborhoodEditContext( - deleteContext.node, - resolvedNode.segmentNodes, - ), - }, - ); + const result = await this.editOperations.commitDeleteNode({ + node: deleteContext.node, + childNodes: deleteContext.childNodes, + segmentNodes: resolvedNode.segmentNodes, + }); applyDeleteNodeToCache( this.layer, deleteContext, @@ -1150,25 +1117,22 @@ class MoveNodeCommand implements SpatialSkeletonCommand { private stableSegmentId: number | undefined, private beforePositionInModelSpace: Float32Array, private afterPositionInModelSpace: Float32Array, + private editOperations: CatmaidSpatialSkeletonEditOperations, ) {} private async moveTo( positionInModelSpace: Float32Array, statusPrefix: string, ) { - const { node, skeletonLayer, skeletonSource } = - await getResolvedNodeForEdit( - this.layer, - this.stableNodeId, - this.stableSegmentId, - ); - const result = await skeletonSource.moveNode( - node.nodeId, - Number(positionInModelSpace[0]), - Number(positionInModelSpace[1]), - Number(positionInModelSpace[2]), - buildCatmaidNodeEditContext(node), + const { node, skeletonLayer } = await getResolvedNodeForEdit( + this.layer, + this.stableNodeId, + this.stableSegmentId, ); + const result = await this.editOperations.commitMoveNode({ + node, + position: positionInModelSpace, + }); skeletonLayer.retainOverlaySegment(node.segmentId); this.layer.spatialSkeletonState.moveCachedNode( node.nodeId, @@ -1213,6 +1177,7 @@ class DeleteNodeCommand implements SpatialSkeletonCommand { private layer: SegmentationUserLayer, node: SpatiallyIndexedSkeletonNode, childNodes: readonly SpatiallyIndexedSkeletonNode[], + private editOperations: CatmaidSpatialSkeletonEditOperations, ) { const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; this.stableDeletedNodeId = commandMappings.getStableOrCurrentNodeId( @@ -1243,16 +1208,11 @@ class DeleteNodeCommand implements SpatialSkeletonCommand { await this.layer.getSpatialSkeletonDeleteOperationContext( resolvedNode.node, ); - const result = await resolvedNode.skeletonSource.deleteNode( - resolvedNode.node.nodeId, - { - childNodeIds: deleteContext.childNodes.map((child) => child.nodeId), - editContext: buildCatmaidNeighborhoodEditContext( - deleteContext.node, - resolvedNode.segmentNodes, - ), - }, - ); + const result = await this.editOperations.commitDeleteNode({ + node: deleteContext.node, + childNodes: deleteContext.childNodes, + segmentNodes: resolvedNode.segmentNodes, + }); applyDeleteNodeToCache( this.layer, deleteContext, @@ -1266,7 +1226,7 @@ class DeleteNodeCommand implements SpatialSkeletonCommand { } private async restoreDeletedNode(statusPrefix: string) { - const { skeletonSource } = getEditableSkeletonSourceForLayer(this.layer); + getEditableSkeletonSourceForLayer(this.layer); const currentParentNode = this.stableParentNodeId === undefined ? undefined @@ -1286,32 +1246,28 @@ class DeleteNodeCommand implements SpatialSkeletonCommand { ).then((result) => result.node), ), ); - const createResult: CatmaidAddNodeResult | CatmaidInsertNodeResult = - currentChildNodes.length === 0 - ? await skeletonSource.addNode( - currentParentNode?.segmentId ?? 0, - Number(this.deletedSnapshot.position[0]), - Number(this.deletedSnapshot.position[1]), - Number(this.deletedSnapshot.position[2]), - currentParentNode?.nodeId, - currentParentNode === undefined - ? undefined - : buildCatmaidNodeEditContext(currentParentNode), - ) - : await skeletonSource.insertNode( - currentParentNode?.segmentId ?? this.deletedSnapshot.segmentId, - Number(this.deletedSnapshot.position[0]), - Number(this.deletedSnapshot.position[1]), - Number(this.deletedSnapshot.position[2]), - currentParentNode?.nodeId ?? - (() => { - throw new Error( - "Delete-node undo is missing the parent node needed for insertion.", - ); - })(), - currentChildNodes.map((child) => child.nodeId), - buildInsertEditContext(currentParentNode!, currentChildNodes), - ); + let createResult: + | CatmaidSpatialSkeletonAddNodeResult + | CatmaidSpatialSkeletonInsertNodeResult; + if (currentChildNodes.length === 0) { + createResult = await this.editOperations.commitAddNode({ + segmentId: currentParentNode?.segmentId ?? 0, + position: this.deletedSnapshot.position, + parentNode: currentParentNode, + }); + } else { + if (currentParentNode === undefined) { + throw new Error( + "Delete-node undo is missing the parent node needed for insertion.", + ); + } + createResult = await this.editOperations.commitInsertNode({ + segmentId: currentParentNode.segmentId, + position: this.deletedSnapshot.position, + parentNode: currentParentNode, + childNodes: currentChildNodes, + }); + } this.layer.spatialSkeletonState.commandHistory.mappings.remapNodeId( this.stableDeletedNodeId, createResult.nodeId, @@ -1357,7 +1313,7 @@ class DeleteNodeCommand implements SpatialSkeletonCommand { } const restoredNodeWithAttributes = await restoreNodeAttributes( this.layer, - skeletonSource, + this.editOperations, restoredNode, this.deletedSnapshot, ); @@ -1406,13 +1362,14 @@ class NodeDescriptionCommand implements SpatialSkeletonCommand { private stableSegmentId: number | undefined, private beforeDescription: string | undefined, private afterDescription: string | undefined, + private editOperations: CatmaidSpatialSkeletonEditOperations, ) {} private async applyDescription( nextDescription: string | undefined, statusPrefix: string, ) { - const { node, skeletonSource } = await getResolvedNodeForEdit( + const { node } = await getResolvedNodeForEdit( this.layer, this.stableNodeId, this.stableSegmentId, @@ -1420,11 +1377,11 @@ class NodeDescriptionCommand implements SpatialSkeletonCommand { if (node.description === nextDescription) { return; } - const result = await skeletonSource.updateDescription( - node.nodeId, - nextDescription ?? "", - { isTrueEnd: node.isTrueEnd === true }, - ); + const result = await this.editOperations.commitDescription({ + node, + description: nextDescription ?? "", + isTrueEnd: node.isTrueEnd === true, + }); this.layer.spatialSkeletonState.updateCachedNode( node.nodeId, (candidate) => { @@ -1479,10 +1436,11 @@ class NodeTrueEndCommand implements SpatialSkeletonCommand { private stableSegmentId: number | undefined, private beforeIsTrueEnd: boolean, private afterIsTrueEnd: boolean, + private editOperations: CatmaidSpatialSkeletonEditOperations, ) {} private async applyTrueEnd(nextIsTrueEnd: boolean, statusPrefix: string) { - const { node, skeletonSource } = await getResolvedNodeForEdit( + const { node } = await getResolvedNodeForEdit( this.layer, this.stableNodeId, this.stableSegmentId, @@ -1490,10 +1448,10 @@ class NodeTrueEndCommand implements SpatialSkeletonCommand { if (node.isTrueEnd === nextIsTrueEnd) { return; } - const result = await skeletonSource.toggleTrueEnd( - node.nodeId, - nextIsTrueEnd, - ); + const result = await this.editOperations.commitTrueEnd({ + node, + isTrueEnd: nextIsTrueEnd, + }); this.layer.spatialSkeletonState.updateCachedNode( node.nodeId, (candidate) => { @@ -1542,24 +1500,24 @@ class NodePropertiesCommand implements SpatialSkeletonCommand { private stableSegmentId: number | undefined, private before: { radius: number; confidence: number }, private after: { radius: number; confidence: number }, + private editOperations: CatmaidSpatialSkeletonEditOperations, ) {} private async applyProperties( next: { radius: number; confidence: number }, statusPrefix: string, ) { - const { node, skeletonSource } = await getResolvedNodeForEdit( + const { node } = await getResolvedNodeForEdit( this.layer, this.stableNodeId, this.stableSegmentId, ); let currentNode = cloneNodeSnapshot(node); if (currentNode.radius !== next.radius) { - const radiusResult = await skeletonSource.updateRadius( - node.nodeId, - next.radius, - buildCatmaidNodeEditContext(currentNode), - ); + const radiusResult = await this.editOperations.commitRadius({ + node: currentNode, + radius: next.radius, + }); currentNode = { ...currentNode, radius: next.radius, @@ -1567,16 +1525,10 @@ class NodePropertiesCommand implements SpatialSkeletonCommand { }; } if (currentNode.confidence !== next.confidence) { - if (getCatmaidRevisionToken(currentNode.sourceState) === undefined) { - throw new Error( - `Node ${node.nodeId} is missing revision metadata required to update confidence.`, - ); - } - const confidenceResult = await skeletonSource.updateConfidence( - node.nodeId, - next.confidence, - buildCatmaidNodeEditContext(currentNode), - ); + const confidenceResult = await this.editOperations.commitConfidence({ + node: currentNode, + confidence: next.confidence, + }); currentNode = { ...currentNode, confidence: next.confidence, @@ -1619,6 +1571,7 @@ class RerootCommand implements SpatialSkeletonCommand { private stableNodeId: number, private stableSegmentId: number | undefined, private stablePreviousRootNodeId: number, + private editOperations: CatmaidSpatialSkeletonEditOperations, ) {} private async rerootAt(stableTargetNodeId: number, statusPrefix: string) { @@ -1630,18 +1583,10 @@ class RerootCommand implements SpatialSkeletonCommand { if (resolvedNode.node.parentNodeId === undefined) { return; } - if (resolvedNode.skeletonSource.rerootSkeleton === undefined) { - throw new Error( - "Unable to resolve a reroot-capable skeleton source for the active layer.", - ); - } - const result = await resolvedNode.skeletonSource.rerootSkeleton( - resolvedNode.node.nodeId, - buildCatmaidRerootEditContext( - resolvedNode.node, - resolvedNode.segmentNodes, - ), - ); + const result = await this.editOperations.commitReroot({ + node: resolvedNode.node, + segmentNodes: resolvedNode.segmentNodes, + }); this.layer.spatialSkeletonState.rerootCachedSegment( resolvedNode.node.nodeId, ); @@ -1691,6 +1636,7 @@ class SplitCommand implements SpatialSkeletonCommand { private stableNodeId: number, private stableSegmentId: number | undefined, private stableFormerParentNodeId: number | undefined, + private editOperations: CatmaidSpatialSkeletonEditOperations, ) {} private async split(statusPrefix: string) { @@ -1699,15 +1645,12 @@ class SplitCommand implements SpatialSkeletonCommand { this.stableNodeId, this.stableSegmentId, ); - let result: CatmaidSplitResult; + let result: CatmaidSpatialSkeletonSplitResult; try { - result = await resolvedNode.skeletonSource.splitSkeleton( - resolvedNode.node.nodeId, - buildCatmaidNeighborhoodEditContext( - resolvedNode.node, - resolvedNode.segmentNodes, - ), - ); + result = await this.editOperations.commitSplit({ + node: resolvedNode.node, + segmentNodes: resolvedNode.segmentNodes, + }); } catch (error) { await refreshTopologySegments(this.layer, [resolvedNode.node.segmentId]); throw error; @@ -1767,13 +1710,12 @@ class SplitCommand implements SpatialSkeletonCommand { this.stableFormerParentNodeId, this.stableSegmentId, ); - let result: CatmaidMergeResult; + let result: CatmaidSpatialSkeletonMergeResult; try { - result = await formerParent.skeletonSource.mergeSkeletons( - formerParent.node.nodeId, - splitNode.node.nodeId, - buildCatmaidMultiNodeEditContext(formerParent.node, splitNode.node), - ); + result = await this.editOperations.commitMerge({ + fromNode: formerParent.node, + toNode: splitNode.node, + }); } catch (error) { await refreshTopologySegments(this.layer, [ splitNode.node.segmentId, @@ -1850,7 +1792,7 @@ class MergeCommand implements SpatialSkeletonCommand { private stableFirstSegmentId: number | undefined, private stableSecondNodeId: number, private stableSecondSegmentId: number | undefined, - private secondNodeSourceState: SpatialSkeletonSourceState | undefined, + private editOperations: CatmaidSpatialSkeletonEditOperations, ) {} private async merge(statusPrefix: string) { @@ -1859,55 +1801,17 @@ class MergeCommand implements SpatialSkeletonCommand { this.stableFirstNodeId, this.stableFirstSegmentId, ); - const secondNodeContext = getResolvedNodeContextForEdit( + const secondNode = await getResolvedNodeForEdit( this.layer, this.stableSecondNodeId, this.stableSecondSegmentId, ); - let secondNode: ResolvedSpatialSkeletonEditNode; - let preservedSecondRootNodeId: number | undefined; - const secondSegmentCached = - this.layer.spatialSkeletonState.getCachedSegmentNodes( - secondNodeContext.segmentId, - ) !== undefined; - const secondSourceState = - this.secondNodeSourceState ?? secondNodeContext.cachedNode?.sourceState; - if ( - secondSegmentCached || - getCatmaidRevisionToken(secondSourceState) === undefined - ) { - secondNode = await getResolvedNodeForEdit( - this.layer, - this.stableSecondNodeId, - this.stableSecondSegmentId, - ); - } else { - preservedSecondRootNodeId = ( - await secondNodeContext.skeletonSource.getSkeletonRootNode( - secondNodeContext.segmentId, - ) - ).nodeId; - secondNode = { - skeletonLayer: secondNodeContext.skeletonLayer, - skeletonSource: secondNodeContext.skeletonSource, - segmentNodes: [], - node: { - nodeId: secondNodeContext.currentNodeId, - segmentId: secondNodeContext.segmentId, - position: new Float32Array(3), - parentNodeId: secondNodeContext.cachedNode?.parentNodeId, - isTrueEnd: secondNodeContext.cachedNode?.isTrueEnd ?? false, - sourceState: secondSourceState, - }, - }; - } - let result: CatmaidMergeResult; + let result: CatmaidSpatialSkeletonMergeResult; try { - result = await firstNode.skeletonSource.mergeSkeletons( - firstNode.node.nodeId, - secondNode.node.nodeId, - buildCatmaidMultiNodeEditContext(firstNode.node, secondNode.node), - ); + result = await this.editOperations.commitMerge({ + fromNode: firstNode.node, + toNode: secondNode.node, + }); } catch (error) { await refreshTopologySegments(this.layer, [ firstNode.node.segmentId, @@ -1928,8 +1832,7 @@ class MergeCommand implements SpatialSkeletonCommand { const attachedRootNodeId = losingNode.segmentId === firstNode.node.segmentId ? findRootNode(firstNode.segmentNodes)?.nodeId - : (preservedSecondRootNodeId ?? - findRootNode(secondNode.segmentNodes)?.nodeId); + : findRootNode(secondNode.segmentNodes)?.nodeId; this.stableAttachedNodeId = this.stableAttachedNodeId ?? this.layer.spatialSkeletonState.commandHistory.mappings.getStableOrCurrentNodeId( @@ -1995,15 +1898,12 @@ class MergeCommand implements SpatialSkeletonCommand { this.stableAttachedNodeId, this.stableResultSegmentId ?? this.stableFirstSegmentId, ); - let splitResult: CatmaidSplitResult; + let splitResult: CatmaidSpatialSkeletonSplitResult; try { - splitResult = await attachedNode.skeletonSource.splitSkeleton( - attachedNode.node.nodeId, - buildCatmaidNeighborhoodEditContext( - attachedNode.node, - attachedNode.segmentNodes, - ), - ); + splitResult = await this.editOperations.commitSplit({ + node: attachedNode.node, + segmentNodes: attachedNode.segmentNodes, + }); } catch (error) { await refreshTopologySegments(this.layer, [attachedNode.node.segmentId]); throw error; @@ -2039,18 +1939,10 @@ class MergeCommand implements SpatialSkeletonCommand { this.stableDeletedSegmentId, ); if (restoredRoot.node.parentNodeId !== undefined) { - if (restoredRoot.skeletonSource.rerootSkeleton === undefined) { - throw new Error( - "The active skeleton source does not support reroot.", - ); - } - await restoredRoot.skeletonSource.rerootSkeleton( - restoredRoot.node.nodeId, - buildCatmaidRerootEditContext( - restoredRoot.node, - restoredRoot.segmentNodes, - ), - ); + await this.editOperations.commitReroot({ + node: restoredRoot.node, + segmentNodes: restoredRoot.segmentNodes, + }); await refreshTopologySegments(this.layer, [ survivingSegmentId, restoredSegmentId, @@ -2092,53 +1984,118 @@ class MergeCommand implements SpatialSkeletonCommand { } } -export class CatmaidSpatialSkeletonEditCommandSource - implements SpatialSkeletonEditCommandSource -{ - private readonly commandHandlers: Partial< - Record - > = { - [SpatialSkeletonActions.addNodes]: (layer, payload) => +function makeCatmaidCommandFactory( + action: TAction, + createCommand: ( + layer: SegmentationUserLayer, + payload: object, + ) => SpatialSkeletonCommand, +): SpatialSkeletonEditCommandFactory { + return { action, createCommand }; +} + +function getCatmaidEditPosition( + position: SpatialSkeletonVector, + label: string, +): [number, number, number] { + const values = toCatmaidPositionInModelSpace(position, label); + return [values[0], values[1], values[2]]; +} + +export class CatmaidSpatialSkeletonEditCommands { + constructor( + private readonly editContext: CatmaidSpatialSkeletonEditCommandContext, + ) {} + + private readonly editOperations: CatmaidSpatialSkeletonEditOperations = { + commitAddNode: (request) => this.commitAddNode(request), + commitInsertNode: (request) => this.commitInsertNode(request), + commitMoveNode: (request) => this.commitMoveNode(request), + commitDeleteNode: (request) => this.commitDeleteNode(request), + commitReroot: (request) => this.commitReroot(request), + commitDescription: (request) => this.commitDescription(request), + commitTrueEnd: (request) => this.commitTrueEnd(request), + commitRadius: (request) => this.commitRadius(request), + commitConfidence: (request) => this.commitConfidence(request), + commitMerge: (request) => this.commitMerge(request), + commitSplit: (request) => this.commitSplit(request), + }; + + readonly addNodesCommand = makeCatmaidCommandFactory( + SpatialSkeletonActions.addNodes, + (layer, payload) => this.createAddNodeCommand( layer, requireCatmaidAddNodeCommandOptions(payload), ), - [SpatialSkeletonActions.insertNodes]: (layer, payload) => + ); + + readonly insertNodesCommand = makeCatmaidCommandFactory( + SpatialSkeletonActions.insertNodes, + (layer, payload) => this.createInsertNodeCommand( layer, requireCatmaidInsertNodeCommandOptions(payload), ), - [SpatialSkeletonActions.moveNodes]: (layer, payload) => + ); + + readonly moveNodesCommand = makeCatmaidCommandFactory( + SpatialSkeletonActions.moveNodes, + (layer, payload) => this.createMoveNodeCommand( layer, requireCatmaidMoveNodeCommandOptions(payload), ), - [SpatialSkeletonActions.deleteNodes]: (layer, payload) => + ); + + readonly deleteNodesCommand = makeCatmaidCommandFactory( + SpatialSkeletonActions.deleteNodes, + (layer, payload) => this.createDeleteNodeCommand( layer, requireCatmaidDeleteNodeCommandPayload(payload), ), - [SpatialSkeletonActions.reroot]: (layer, payload) => + ); + + readonly rerootCommand = makeCatmaidCommandFactory( + SpatialSkeletonActions.reroot, + (layer, payload) => this.createRerootCommand( layer, requireCatmaidRerootCommandPayload(payload), ), - [SpatialSkeletonActions.editNodeDescription]: (layer, payload) => + ); + + readonly editNodeDescriptionCommand = makeCatmaidCommandFactory( + SpatialSkeletonActions.editNodeDescription, + (layer, payload) => this.createNodeDescriptionCommand( layer, requireCatmaidNodeDescriptionCommandOptions(payload), ), - [SpatialSkeletonActions.editNodeTrueEnd]: (layer, payload) => + ); + + readonly editNodeTrueEndCommand = makeCatmaidCommandFactory( + SpatialSkeletonActions.editNodeTrueEnd, + (layer, payload) => this.createNodeTrueEndCommand( layer, requireCatmaidNodeTrueEndCommandOptions(payload), ), - [SpatialSkeletonActions.editNodeProperties]: (layer, payload) => + ); + + readonly editNodePropertiesCommand = makeCatmaidCommandFactory( + SpatialSkeletonActions.editNodeProperties, + (layer, payload) => this.createNodePropertiesCommand( layer, requireCatmaidNodePropertiesCommandOptions(payload), ), - [SpatialSkeletonActions.mergeSkeletons]: (layer, payload) => { + ); + + readonly mergeSkeletonsCommand = makeCatmaidCommandFactory( + SpatialSkeletonActions.mergeSkeletons, + (layer, payload) => { const options = requireCatmaidMergeCommandPayload(payload); return this.createMergeCommand( layer, @@ -2146,23 +2103,165 @@ export class CatmaidSpatialSkeletonEditCommandSource options.secondNode, ); }, - [SpatialSkeletonActions.splitSkeletons]: (layer, payload) => + ); + + readonly splitSkeletonsCommand = makeCatmaidCommandFactory( + SpatialSkeletonActions.splitSkeletons, + (layer, payload) => this.createSplitCommand( layer, requireCatmaidSplitCommandPayload(payload), ), - }; + ); - supports(action: SpatialSkeletonAction) { - return this.commandHandlers[action] !== undefined; + private get client() { + return this.editContext.getClient(); } - createCommand( - action: SpatialSkeletonAction, - layer: SegmentationUserLayer, - payload: object, - ) { - return this.commandHandlers[action]?.(layer, payload); + private ensureEditable() { + this.editContext.ensureEditable(); + } + + private commitAddNode( + request: CatmaidSpatialSkeletonAddNodeRequest, + ): Promise { + this.ensureEditable(); + const [x, y, z] = getCatmaidEditPosition( + request.position, + "add-node position", + ); + return this.client.addNode( + request.segmentId, + x, + y, + z, + request.parentNode?.nodeId, + request.parentNode === undefined + ? undefined + : buildCatmaidNodeEditContext(request.parentNode), + ); + } + + private commitInsertNode( + request: CatmaidSpatialSkeletonInsertNodeRequest, + ): Promise { + this.ensureEditable(); + const [x, y, z] = getCatmaidEditPosition( + request.position, + "insert-node position", + ); + return this.client.insertNode( + request.segmentId, + x, + y, + z, + request.parentNode.nodeId, + request.childNodes.map((child) => child.nodeId), + buildCatmaidInsertEditContext(request.parentNode, request.childNodes), + ); + } + + private commitMoveNode( + request: CatmaidSpatialSkeletonMoveNodeRequest, + ): Promise { + this.ensureEditable(); + const [x, y, z] = getCatmaidEditPosition( + request.position, + "move-node position", + ); + return this.client.moveNode( + request.node.nodeId, + x, + y, + z, + buildCatmaidNodeEditContext(request.node), + ); + } + + private commitDeleteNode( + request: CatmaidSpatialSkeletonDeleteNodeRequest, + ): Promise { + this.ensureEditable(); + return this.client.deleteNode(request.node.nodeId, { + childNodeIds: request.childNodes.map((child) => child.nodeId), + editContext: buildCatmaidNeighborhoodEditContext( + request.node, + request.segmentNodes, + ), + }); + } + + private commitReroot( + request: CatmaidSpatialSkeletonRerootRequest, + ): Promise { + this.ensureEditable(); + return this.client.rerootSkeleton( + request.node.nodeId, + buildCatmaidRerootEditContext(request.node, request.segmentNodes), + ); + } + + private commitDescription( + request: CatmaidSpatialSkeletonDescriptionUpdateRequest, + ): Promise { + this.ensureEditable(); + return this.client.updateDescription( + request.node.nodeId, + request.description, + { + isTrueEnd: request.isTrueEnd ?? request.node.isTrueEnd === true, + }, + ); + } + + private commitTrueEnd( + request: CatmaidSpatialSkeletonTrueEndUpdateRequest, + ): Promise { + this.ensureEditable(); + return this.client.toggleTrueEnd(request.node.nodeId, request.isTrueEnd); + } + + private commitRadius( + request: CatmaidSpatialSkeletonRadiusUpdateRequest, + ): Promise { + this.ensureEditable(); + return this.client.updateRadius( + request.node.nodeId, + request.radius, + buildCatmaidNodeEditContext(request.node), + ); + } + + private commitConfidence( + request: CatmaidSpatialSkeletonConfidenceUpdateRequest, + ): Promise { + this.ensureEditable(); + return this.client.updateConfidence( + request.node.nodeId, + request.confidence, + buildCatmaidNodeEditContext(request.node), + ); + } + + private commitMerge( + request: CatmaidSpatialSkeletonMergeRequest, + ): Promise { + this.ensureEditable(); + return this.client.mergeSkeletons( + request.fromNode.nodeId, + request.toNode.nodeId, + buildCatmaidMultiNodeEditContext(request.fromNode, request.toNode), + ); + } + + private commitSplit( + request: CatmaidSpatialSkeletonSplitRequest, + ): Promise { + this.ensureEditable(); + return this.client.splitSkeleton( + request.node.nodeId, + buildCatmaidNeighborhoodEditContext(request.node, request.segmentNodes), + ); } private createAddNodeCommand( @@ -2179,6 +2278,7 @@ export class CatmaidSpatialSkeletonEditCommandSource options.positionInModelSpace, "add-node position", ), + this.editOperations, ); } @@ -2199,6 +2299,7 @@ export class CatmaidSpatialSkeletonEditCommandSource options.positionInModelSpace, "insert-node position", ), + this.editOperations, ); } @@ -2219,6 +2320,7 @@ export class CatmaidSpatialSkeletonEditCommandSource options.nextPositionInModelSpace, "move-node target position", ), + this.editOperations, ); } @@ -2242,7 +2344,12 @@ export class CatmaidSpatialSkeletonEditCommandSource segmentNodes, refreshedNode.nodeId, ); - return new DeleteNodeCommand(layer, refreshedNode, childNodes); + return new DeleteNodeCommand( + layer, + refreshedNode, + childNodes, + this.editOperations, + ); } private createNodeDescriptionCommand( @@ -2256,6 +2363,7 @@ export class CatmaidSpatialSkeletonEditCommandSource commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), options.node.description, options.nextDescription ?? options.node.description, + this.editOperations, ); } @@ -2270,6 +2378,7 @@ export class CatmaidSpatialSkeletonEditCommandSource commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), options.node.isTrueEnd ?? false, options.nextIsTrueEnd, + this.editOperations, ); } @@ -2287,6 +2396,7 @@ export class CatmaidSpatialSkeletonEditCommandSource confidence: options.node.confidence ?? 0, }, options.next, + this.editOperations, ); } @@ -2313,6 +2423,7 @@ export class CatmaidSpatialSkeletonEditCommandSource commandMappings.getStableOrCurrentNodeId(node.nodeId)!, commandMappings.getStableOrCurrentSegmentId(node.segmentId), commandMappings.getStableOrCurrentNodeId(rootNode.nodeId)!, + this.editOperations, ); } @@ -2338,6 +2449,7 @@ export class CatmaidSpatialSkeletonEditCommandSource commandMappings.getStableOrCurrentNodeId(splitNode.nodeId)!, commandMappings.getStableOrCurrentSegmentId(splitNode.segmentId), commandMappings.getStableOrCurrentNodeId(splitNode.parentNodeId), + this.editOperations, ); } @@ -2353,7 +2465,7 @@ export class CatmaidSpatialSkeletonEditCommandSource commandMappings.getStableOrCurrentSegmentId(firstNode.segmentId), commandMappings.getStableOrCurrentNodeId(secondNode.nodeId)!, commandMappings.getStableOrCurrentSegmentId(secondNode.segmentId), - secondNode.sourceState, + this.editOperations, ); } } diff --git a/src/datasource/catmaid/spatial_skeleton_edit_api.ts b/src/datasource/catmaid/spatial_skeleton_edit_api.ts new file mode 100644 index 000000000..b83b0e3ea --- /dev/null +++ b/src/datasource/catmaid/spatial_skeleton_edit_api.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { + SpatiallyIndexedSkeletonNode, + SpatialSkeletonSourceState, + SpatialSkeletonVector, +} from "#src/skeleton/api.js"; + +// CATMAID owns these payloads; the generic skeleton API only promises named edit operations. +export interface CatmaidSpatialSkeletonNodeSourceStateUpdate { + nodeId: number; + sourceState: SpatialSkeletonSourceState; +} + +export interface CatmaidSpatialSkeletonEditResult { + nodeSourceStateUpdates?: readonly CatmaidSpatialSkeletonNodeSourceStateUpdate[]; +} + +export interface CatmaidSpatialSkeletonAddNodeRequest { + segmentId: number; + position: SpatialSkeletonVector; + parentNode?: SpatiallyIndexedSkeletonNode; +} + +export interface CatmaidSpatialSkeletonAddNodeResult + extends CatmaidSpatialSkeletonEditResult { + nodeId: number; + segmentId: number; + sourceState?: SpatialSkeletonSourceState; + parentSourceState?: SpatialSkeletonSourceState; +} + +export interface CatmaidSpatialSkeletonInsertNodeRequest { + segmentId: number; + position: SpatialSkeletonVector; + parentNode: SpatiallyIndexedSkeletonNode; + childNodes: readonly SpatiallyIndexedSkeletonNode[]; +} + +export type CatmaidSpatialSkeletonInsertNodeResult = + CatmaidSpatialSkeletonAddNodeResult; + +export interface CatmaidSpatialSkeletonMoveNodeRequest { + node: SpatiallyIndexedSkeletonNode; + position: SpatialSkeletonVector; +} + +export interface CatmaidSpatialSkeletonNodeSourceStateResult + extends CatmaidSpatialSkeletonEditResult { + sourceState?: SpatialSkeletonSourceState; +} + +export interface CatmaidSpatialSkeletonDeleteNodeRequest { + node: SpatiallyIndexedSkeletonNode; + childNodes: readonly SpatiallyIndexedSkeletonNode[]; + segmentNodes: readonly SpatiallyIndexedSkeletonNode[]; +} + +export type CatmaidSpatialSkeletonDeleteNodeResult = + CatmaidSpatialSkeletonEditResult; + +export interface CatmaidSpatialSkeletonSplitRequest { + node: SpatiallyIndexedSkeletonNode; + segmentNodes: readonly SpatiallyIndexedSkeletonNode[]; +} + +export interface CatmaidSpatialSkeletonSplitResult + extends CatmaidSpatialSkeletonEditResult { + existingSegmentId: number | undefined; + newSegmentId: number | undefined; +} + +export interface CatmaidSpatialSkeletonMergeRequest { + fromNode: SpatiallyIndexedSkeletonNode; + toNode: SpatiallyIndexedSkeletonNode; +} + +export interface CatmaidSpatialSkeletonMergeResult + extends CatmaidSpatialSkeletonEditResult { + resultSegmentId: number | undefined; + deletedSegmentId: number | undefined; + directionAdjusted: boolean; +} + +export interface CatmaidSpatialSkeletonRerootRequest { + node: SpatiallyIndexedSkeletonNode; + segmentNodes: readonly SpatiallyIndexedSkeletonNode[]; +} + +export type CatmaidSpatialSkeletonRerootResult = + CatmaidSpatialSkeletonEditResult; + +export interface CatmaidSpatialSkeletonDescriptionUpdateRequest { + node: SpatiallyIndexedSkeletonNode; + description: string; + isTrueEnd?: boolean; +} + +export interface CatmaidSpatialSkeletonDescriptionUpdateResult + extends CatmaidSpatialSkeletonNodeSourceStateResult { + description?: string; +} + +export interface CatmaidSpatialSkeletonTrueEndUpdateRequest { + node: SpatiallyIndexedSkeletonNode; + isTrueEnd: boolean; +} + +export interface CatmaidSpatialSkeletonRadiusUpdateRequest { + node: SpatiallyIndexedSkeletonNode; + radius: number; +} + +export interface CatmaidSpatialSkeletonConfidenceUpdateRequest { + node: SpatiallyIndexedSkeletonNode; + confidence: number; +} diff --git a/src/layer/segmentation/index.spec.ts b/src/layer/segmentation/index.spec.ts index efc101e11..bbaab5037 100644 --- a/src/layer/segmentation/index.spec.ts +++ b/src/layer/segmentation/index.spec.ts @@ -35,7 +35,7 @@ const { SegmentSelectionState } = await import( function makeEditableSpatialSkeletonSource( options: { - rerootSkeleton?: (() => Promise) | undefined; + rerootCommand?: boolean; } = {}, ) { const createCommand = () => ({ @@ -44,44 +44,38 @@ function makeEditableSpatialSkeletonSource( undo: vi.fn(), redo: vi.fn(), }); - const supports = (action: string) => - action !== SpatialSkeletonActions.reroot || - options.rerootSkeleton !== undefined; + const makeCommand = (action: string) => ({ + action, + createCommand, + }); return { readOnly: false, - spatialSkeletonEditCommandSource: { - supports, - createCommand: (action: string) => - supports(action) ? createCommand() : undefined, - }, + addNodesCommand: makeCommand(SpatialSkeletonActions.addNodes), + insertNodesCommand: makeCommand(SpatialSkeletonActions.insertNodes), + moveNodesCommand: makeCommand(SpatialSkeletonActions.moveNodes), + deleteNodesCommand: makeCommand(SpatialSkeletonActions.deleteNodes), + editNodeDescriptionCommand: makeCommand( + SpatialSkeletonActions.editNodeDescription, + ), + editNodeTrueEndCommand: makeCommand(SpatialSkeletonActions.editNodeTrueEnd), + editNodePropertiesCommand: makeCommand( + SpatialSkeletonActions.editNodeProperties, + ), + mergeSkeletonsCommand: makeCommand(SpatialSkeletonActions.mergeSkeletons), + splitSkeletonsCommand: makeCommand(SpatialSkeletonActions.splitSkeletons), listSkeletons: async () => [], getSkeleton: async () => [], fetchNodes: async () => [], getSpatialIndexMetadata: async () => null, - addNode: async () => ({ nodeId: 1, segmentId: 1 }), - insertNode: async () => ({ nodeId: 1, segmentId: 1 }), - moveNode: async () => ({}), - deleteNode: async () => ({}), - updateDescription: async () => ({}), - toggleTrueEnd: async () => ({}), - updateRadius: async () => ({}), - updateConfidence: async () => ({}), getSkeletonRootNode: async () => ({ nodeId: 1, position: [0, 0, 0], }), - mergeSkeletons: async () => ({ - resultSegmentId: 1, - deletedSegmentId: 2, - directionAdjusted: false, - }), - splitSkeleton: async () => ({ - existingSegmentId: 1, - newSegmentId: 2, - }), - ...(options.rerootSkeleton === undefined + ...(options.rerootCommand !== true ? {} - : { rerootSkeleton: options.rerootSkeleton }), + : { + rerootCommand: makeCommand(SpatialSkeletonActions.reroot), + }), }; } @@ -162,7 +156,7 @@ describe("layer/segmentation spatial skeleton action gating", () => { getSpatiallyIndexedSkeletonLayer: () => makeSpatialSkeletonLayerWithSource( makeEditableSpatialSkeletonSource({ - rerootSkeleton: async () => {}, + rerootCommand: true, }), ), spatialSkeletonVisibleChunksLoaded: new WatchableValue(true), @@ -249,7 +243,7 @@ describe("layer/segmentation spatial skeleton action gating", () => { getSpatiallyIndexedSkeletonLayer: () => makeSpatialSkeletonLayerWithSource({ ...makeEditableSpatialSkeletonSource({ - rerootSkeleton: async () => {}, + rerootCommand: true, }), readOnly: true, }), diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index f218b21c0..312863a7a 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -56,7 +56,6 @@ import { executeSpatialSkeletonNodePropertiesUpdate, executeSpatialSkeletonReroot, executeSpatialSkeletonNodeTrueEndUpdate, - getSpatialSkeletonEditCommandSource, } from "#src/layer/segmentation/spatial_skeleton_commands.js"; import { showSpatialSkeletonActionError } from "#src/layer/segmentation/spatial_skeleton_errors.js"; import { @@ -1592,10 +1591,26 @@ export class SegmentationUserLayer extends Base { if (action === SpatialSkeletonActions.inspect) { return getSpatiallyIndexedSkeletonSource(skeletonLayer) !== undefined; } - return ( - getSpatialSkeletonEditCommandSource(skeletonLayer)?.supports(action) ?? - false - ); + const source = getEditableSpatiallyIndexedSkeletonSource(skeletonLayer); + if (source === undefined) return false; + switch (action) { + case SpatialSkeletonActions.addNodes: + case SpatialSkeletonActions.deleteNodes: + case SpatialSkeletonActions.moveNodes: + case SpatialSkeletonActions.splitSkeletons: + case SpatialSkeletonActions.mergeSkeletons: + return true; + case SpatialSkeletonActions.insertNodes: + return source.insertNodesCommand !== undefined; + case SpatialSkeletonActions.reroot: + return source.rerootCommand !== undefined; + case SpatialSkeletonActions.editNodeDescription: + return source.editNodeDescriptionCommand !== undefined; + case SpatialSkeletonActions.editNodeTrueEnd: + return source.editNodeTrueEndCommand !== undefined; + case SpatialSkeletonActions.editNodeProperties: + return source.editNodePropertiesCommand !== undefined; + } } private getMissingSpatialSkeletonSupportReason( @@ -1672,7 +1687,9 @@ export class SegmentationUserLayer extends Base { "No active spatial skeleton layer found for delete action.", ); } - if (getSpatialSkeletonEditCommandSource(skeletonLayer) === undefined) { + if ( + getEditableSpatiallyIndexedSkeletonSource(skeletonLayer) === undefined + ) { throw new Error( "Unable to resolve editable skeleton source for the active layer.", ); @@ -2502,10 +2519,9 @@ export class SegmentationUserLayer extends Base { summaryRow.classList.add("neuroglancer-spatial-skeleton-selection-summary"); container.appendChild(summaryRow); - const editCommandSource = - getSpatialSkeletonEditCommandSource(skeletonLayer); + const editSource = getEditableSpatiallyIndexedSkeletonSource(skeletonLayer); const rerootDisabledReason = - editCommandSource === undefined + editSource?.rerootCommand === undefined ? "Unable to resolve a reroot-capable skeleton source for the active layer." : segmentNodes === undefined ? "Load the active skeleton in the Skeleton tab before rerooting from Selection." @@ -2553,7 +2569,7 @@ export class SegmentationUserLayer extends Base { })(); }); const deleteDisabledReason = - editCommandSource === undefined + editSource === undefined ? "Unable to resolve editable skeleton source for the active layer." : segmentNodes === undefined ? "Load the active skeleton in the Skeleton tab before deleting from Selection." @@ -2575,7 +2591,7 @@ export class SegmentationUserLayer extends Base { deleteButton.addEventListener("click", () => { if ( deleteButton.disabled || - editCommandSource === undefined || + editSource === undefined || completeNodeInfo === undefined || deletePending ) { @@ -2656,7 +2672,7 @@ export class SegmentationUserLayer extends Base { const isLeaf = segmentNodes !== undefined && directChildNodeIds.length === 0; const leafTypeEditingDisabledReason = () => - editCommandSource === undefined + editSource === undefined ? "Unable to resolve editable skeleton source for the active layer." : cachedNodeInfo === undefined || segmentNodes === undefined ? "Load the active skeleton in the Skeleton tab before changing leaf type." @@ -2852,7 +2868,7 @@ export class SegmentationUserLayer extends Base { appendValue("Confidence level", confidenceControl); let savePending = false; const getPropertyEditingDisabledReason = () => - editCommandSource === undefined + editSource === undefined ? "Unable to resolve editable skeleton source for the active layer." : this.getSpatialSkeletonActionsDisabledReason( SpatialSkeletonActions.editNodeProperties, @@ -3001,7 +3017,7 @@ export class SegmentationUserLayer extends Base { const descriptionText = cachedNodeInfo?.description ?? completeNodeInfo?.description ?? ""; const descriptionEditingDisabledReason = - editCommandSource === undefined + editSource === undefined ? "Unable to resolve editable skeleton source for the active layer." : cachedNodeInfo === undefined ? "Load the active skeleton in the Skeleton tab before editing description." @@ -3017,7 +3033,7 @@ export class SegmentationUserLayer extends Base { descriptionElement.placeholder = "Description"; descriptionElement.value = descriptionText; descriptionElement.addEventListener("change", () => { - if (editCommandSource === undefined || cachedNodeInfo === undefined) { + if (editSource === undefined || cachedNodeInfo === undefined) { return; } const nextDescription = descriptionElement.value; @@ -3091,7 +3107,7 @@ export class SegmentationUserLayer extends Base { const { rank, globalToRenderLayerDimensions } = transform; const { globalPosition } = this.manager.root; const globalLayerPosition = new Float32Array(rank); - const renderToGlobalLayerDimensions = []; + const renderToGlobalLayerDimensions: number[] = []; for (let i = 0; i < rank; i++) { renderToGlobalLayerDimensions[globalToRenderLayerDimensions[i]] = i; } diff --git a/src/layer/segmentation/spatial_skeleton_commands.spec.ts b/src/layer/segmentation/spatial_skeleton_commands.spec.ts index a1673fee1..1a51d1fc1 100644 --- a/src/layer/segmentation/spatial_skeleton_commands.spec.ts +++ b/src/layer/segmentation/spatial_skeleton_commands.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { CatmaidSpatialSkeletonEditCommandSource } from "#src/datasource/catmaid/spatial_skeleton_commands.js"; +import { CatmaidSpatialSkeletonEditCommands } from "#src/datasource/catmaid/spatial_skeleton_commands.js"; import { buildCatmaidNeighborhoodEditContext } from "#src/datasource/catmaid/edit_state.js"; import { makeCatmaidNodeSourceState } from "#src/datasource/catmaid/api.js"; import { @@ -60,16 +60,22 @@ function setSegmentNodes( } } -function makeEditableSkeletonSource(overrides: Record = {}) { +const catmaidEditClientMethodNames = new Set([ + "addNode", + "insertNode", + "moveNode", + "deleteNode", + "rerootSkeleton", + "updateDescription", + "toggleTrueEnd", + "updateRadius", + "updateConfidence", + "mergeSkeletons", + "splitSkeleton", +]); + +function makeCatmaidClient(overrides: Record = {}) { return { - readOnly: false, - spatialSkeletonEditCommandSource: - new CatmaidSpatialSkeletonEditCommandSource(), - listSkeletons: vi.fn(), - getSkeleton: vi.fn(), - fetchNodes: vi.fn(), - getSpatialIndexMetadata: vi.fn(), - getSkeletonRootNode: vi.fn(), addNode: vi.fn(), insertNode: vi.fn(), moveNode: vi.fn(), @@ -85,6 +91,45 @@ function makeEditableSkeletonSource(overrides: Record = {}) { }; } +function makeCatmaidEditCommands(client = makeCatmaidClient()) { + return new CatmaidSpatialSkeletonEditCommands({ + ensureEditable: vi.fn(), + getClient: () => client as any, + }); +} + +function makeEditableSkeletonSource(overrides: Record = {}) { + const clientOverrides: Record = {}; + const sourceOverrides: Record = {}; + for (const [key, value] of Object.entries(overrides)) { + if (catmaidEditClientMethodNames.has(key)) { + clientOverrides[key] = value; + } else { + sourceOverrides[key] = value; + } + } + const commands = makeCatmaidEditCommands(makeCatmaidClient(clientOverrides)); + return { + readOnly: false, + addNodesCommand: commands.addNodesCommand, + insertNodesCommand: commands.insertNodesCommand, + moveNodesCommand: commands.moveNodesCommand, + deleteNodesCommand: commands.deleteNodesCommand, + rerootCommand: commands.rerootCommand, + editNodeDescriptionCommand: commands.editNodeDescriptionCommand, + editNodeTrueEndCommand: commands.editNodeTrueEndCommand, + editNodePropertiesCommand: commands.editNodePropertiesCommand, + mergeSkeletonsCommand: commands.mergeSkeletonsCommand, + splitSkeletonsCommand: commands.splitSkeletonsCommand, + listSkeletons: vi.fn(), + getSkeleton: vi.fn(), + fetchNodes: vi.fn(), + getSpatialIndexMetadata: vi.fn(), + getSkeletonRootNode: vi.fn(), + ...sourceOverrides, + }; +} + function testSourceState(revisionToken: string) { return makeCatmaidNodeSourceState(revisionToken); } @@ -123,21 +168,12 @@ describe("spatial_skeleton_commands", () => { }, getSpatiallyIndexedSkeletonLayer: () => ({ source: { - readOnly: false, - spatialSkeletonEditCommandSource: { - supports: () => true, - createCommand, - }, - listSkeletons: vi.fn(), - getSkeleton: vi.fn(), - fetchNodes: vi.fn(), - getSpatialIndexMetadata: vi.fn(), - addNode: vi.fn(), - deleteNode: vi.fn(), - moveNode: vi.fn(), - splitSkeleton: vi.fn(), - mergeSkeletons: vi.fn(), - toggleTrueEnd: vi.fn(), + ...makeEditableSkeletonSource({ + moveNodesCommand: { + action: SpatialSkeletonActions.moveNodes, + createCommand, + }, + }), }, }), }; @@ -155,40 +191,27 @@ describe("spatial_skeleton_commands", () => { await undoSpatialSkeletonCommand(layer as any); await redoSpatialSkeletonCommand(layer as any); - expect(createCommand).toHaveBeenCalledWith( - SpatialSkeletonActions.moveNodes, - layer, - { - node, - nextPositionInModelSpace, - }, - ); + expect(createCommand).toHaveBeenCalledWith(layer, { + node, + nextPositionInModelSpace, + }); expect(execute).toHaveBeenCalledTimes(1); expect(undo).toHaveBeenCalledTimes(1); expect(redo).toHaveBeenCalledTimes(1); }); - it("does not treat a source missing createCommand as an edit command source", () => { + it("does not treat a source with an invalid command factory as editable", () => { const layer = { spatialSkeletonState: { commandHistory: new SpatialSkeletonCommandHistory(), }, getSpatiallyIndexedSkeletonLayer: () => ({ source: { + ...makeEditableSkeletonSource(), readOnly: false, - spatialSkeletonEditCommandSource: { - supports: () => true, + editNodeDescriptionCommand: { + action: SpatialSkeletonActions.editNodeDescription, }, - listSkeletons: vi.fn(), - getSkeleton: vi.fn(), - fetchNodes: vi.fn(), - getSpatialIndexMetadata: vi.fn(), - addNode: vi.fn(), - deleteNode: vi.fn(), - moveNode: vi.fn(), - splitSkeleton: vi.fn(), - mergeSkeletons: vi.fn(), - toggleTrueEnd: vi.fn(), }, }), }; @@ -207,39 +230,17 @@ describe("spatial_skeleton_commands", () => { ); }); - it("reports unsupported command creation clearly", () => { - const command = { - label: "required command", - execute: vi.fn(), - undo: vi.fn(), - redo: vi.fn(), - }; - const createCommand = vi.fn((action: string) => - action === SpatialSkeletonActions.editNodeDescription - ? undefined - : command, - ); + it("reports unsupported commands clearly", () => { const layer = { spatialSkeletonState: { commandHistory: new SpatialSkeletonCommandHistory(), }, getSpatiallyIndexedSkeletonLayer: () => ({ source: { + ...makeEditableSkeletonSource({ + editNodeDescriptionCommand: undefined, + }), readOnly: false, - spatialSkeletonEditCommandSource: { - supports: () => true, - createCommand, - }, - listSkeletons: vi.fn(), - getSkeleton: vi.fn(), - fetchNodes: vi.fn(), - getSpatialIndexMetadata: vi.fn(), - addNode: vi.fn(), - deleteNode: vi.fn(), - moveNode: vi.fn(), - splitSkeleton: vi.fn(), - mergeSkeletons: vi.fn(), - toggleTrueEnd: vi.fn(), }, }), }; @@ -259,22 +260,17 @@ describe("spatial_skeleton_commands", () => { ); }); - it("derives CATMAID command support from registered handlers", () => { - const commandSource = new CatmaidSpatialSkeletonEditCommandSource(); + it("exposes CATMAID command factories for supported edit actions", () => { + const commandSource = makeCatmaidEditCommands(); - expect(commandSource.supports(SpatialSkeletonActions.moveNodes)).toBe(true); - expect(commandSource.supports(SpatialSkeletonActions.inspect)).toBe(false); - expect( - commandSource.createCommand( - SpatialSkeletonActions.inspect, - {} as any, - {}, - ), - ).toBeUndefined(); + expect(commandSource.moveNodesCommand.action).toBe( + SpatialSkeletonActions.moveNodes, + ); + expect((commandSource as any).inspectCommand).toBeUndefined(); }); it("creates CATMAID commands from valid opaque payloads", () => { - const commandSource = new CatmaidSpatialSkeletonEditCommandSource(); + const commandSource = makeCatmaidEditCommands(); const layer = { spatialSkeletonState: { commandHistory: new SpatialSkeletonCommandHistory(), @@ -286,20 +282,16 @@ describe("spatial_skeleton_commands", () => { position: new Float32Array([1, 2, 3]), }; - const command = commandSource.createCommand( - SpatialSkeletonActions.moveNodes, - layer as any, - { - node, - nextPositionInModelSpace: new Float32Array([7, 8, 9]), - }, - ); + const command = commandSource.moveNodesCommand.createCommand(layer as any, { + node, + nextPositionInModelSpace: new Float32Array([7, 8, 9]), + }); expect(command?.label).toBe("Move node"); }); it("reports invalid CATMAID command payloads clearly", () => { - const commandSource = new CatmaidSpatialSkeletonEditCommandSource(); + const commandSource = makeCatmaidEditCommands(); const layer = { spatialSkeletonState: { commandHistory: new SpatialSkeletonCommandHistory(), @@ -307,14 +299,10 @@ describe("spatial_skeleton_commands", () => { }; expect(() => - commandSource.createCommand( - SpatialSkeletonActions.moveNodes, - layer as any, - { - node: {}, - nextPositionInModelSpace: new Float32Array([7, 8, 9]), - }, - ), + commandSource.moveNodesCommand.createCommand(layer as any, { + node: {}, + nextPositionInModelSpace: new Float32Array([7, 8, 9]), + }), ).toThrow("CATMAID move-node command received an invalid payload."); }); @@ -365,13 +353,15 @@ describe("spatial_skeleton_commands", () => { nextPositionInModelSpace, }); - expect(moveNode).toHaveBeenCalledWith(17, 7, 8, 9, { - node: { - nodeId: 17, - parentNodeId: undefined, - revisionToken: "before", - }, - }); + expect(moveNode).toHaveBeenCalledWith( + 17, + 7, + 8, + 9, + expect.objectContaining({ + node: expect.objectContaining({ nodeId: 17 }), + }), + ); expect(skeletonLayer.retainOverlaySegment).toHaveBeenCalledWith(23); expect(moveCachedNode).toHaveBeenCalledWith( 17, @@ -579,18 +569,10 @@ describe("spatial_skeleton_commands", () => { expect(deleteNode).toHaveBeenCalledWith(2, { childNodeIds: [], - editContext: { - node: { - nodeId: 2, - parentNodeId: parentNode.nodeId, - revisionToken: "added-after-add", - }, - parent: { - nodeId: parentNode.nodeId, - revisionToken: "parent-after-add", - }, - children: [], - }, + editContext: expect.objectContaining({ + node: expect.objectContaining({ nodeId: 2 }), + parent: expect.objectContaining({ nodeId: parentNode.nodeId }), + }), }); expect(spatialSkeletonState.getCachedNode(2)).toBeUndefined(); expect(layer.selectAndMoveToSpatialSkeletonNode).toHaveBeenCalledWith( @@ -656,6 +638,7 @@ describe("spatial_skeleton_commands", () => { }, ], }); + const addNode = vi.fn(); const insertNode = vi.fn().mockResolvedValue({ nodeId: 20, segmentId, @@ -673,6 +656,7 @@ describe("spatial_skeleton_commands", () => { ], }); const skeletonSource = makeEditableSkeletonSource({ + addNode, deleteNode, insertNode, }); @@ -770,7 +754,7 @@ describe("spatial_skeleton_commands", () => { await undoSpatialSkeletonCommand(layer as any); - expect(skeletonSource.addNode).not.toHaveBeenCalled(); + expect(addNode).not.toHaveBeenCalled(); expect(insertNode).toHaveBeenCalledWith( segmentId, 4, @@ -778,23 +762,13 @@ describe("spatial_skeleton_commands", () => { 6, rootNode.nodeId, [firstChildNode.nodeId, secondChildNode.nodeId], - { - node: { - nodeId: rootNode.nodeId, - parentNodeId: undefined, - revisionToken: "root-after-delete", - }, - children: [ - { - nodeId: firstChildNode.nodeId, - revisionToken: "first-child-after-delete", - }, - { - nodeId: secondChildNode.nodeId, - revisionToken: "second-child-after-delete", - }, - ], - }, + expect.objectContaining({ + node: expect.objectContaining({ nodeId: rootNode.nodeId }), + children: expect.arrayContaining([ + expect.objectContaining({ nodeId: firstChildNode.nodeId }), + expect.objectContaining({ nodeId: secondChildNode.nodeId }), + ]), + }), ); const restoredNode = spatialSkeletonState.getCachedNode(20); @@ -870,27 +844,29 @@ describe("spatial_skeleton_commands", () => { ]); syncCacheFromServer(originalSegmentId); + const splitSkeleton = vi.fn(async () => { + serverSegments.set(originalSegmentId, [cloneNode(formerParentNode)]); + serverSegments.set(splitSegmentId, [cloneNode(splitNodeAfter)]); + return { + existingSegmentId: originalSegmentId, + newSegmentId: splitSegmentId, + }; + }); + const mergeSkeletons = vi.fn(async () => { + serverSegments.set(originalSegmentId, [ + cloneNode(formerParentNode), + cloneNode(splitNodeMergedBack), + ]); + serverSegments.delete(splitSegmentId); + return { + resultSegmentId: originalSegmentId, + deletedSegmentId: splitSegmentId, + directionAdjusted: false, + }; + }); const skeletonSource = makeEditableSkeletonSource({ - splitSkeleton: vi.fn(async () => { - serverSegments.set(originalSegmentId, [cloneNode(formerParentNode)]); - serverSegments.set(splitSegmentId, [cloneNode(splitNodeAfter)]); - return { - existingSegmentId: originalSegmentId, - newSegmentId: splitSegmentId, - }; - }), - mergeSkeletons: vi.fn(async () => { - serverSegments.set(originalSegmentId, [ - cloneNode(formerParentNode), - cloneNode(splitNodeMergedBack), - ]); - serverSegments.delete(splitSegmentId); - return { - resultSegmentId: originalSegmentId, - deletedSegmentId: splitSegmentId, - directionAdjusted: false, - }; - }), + splitSkeleton, + mergeSkeletons, }); const deleteSegmentColor = vi.fn(); @@ -968,10 +944,15 @@ describe("spatial_skeleton_commands", () => { await undoSpatialSkeletonCommand(layer as any); - expect(skeletonSource.mergeSkeletons).toHaveBeenCalledWith( + expect(mergeSkeletons).toHaveBeenCalledWith( formerParentNode.nodeId, splitNodeBefore.nodeId, - expect.any(Object), + expect.objectContaining({ + nodes: expect.arrayContaining([ + expect.objectContaining({ nodeId: formerParentNode.nodeId }), + expect.objectContaining({ nodeId: splitNodeBefore.nodeId }), + ]), + }), ); expect(deleteSegmentColor).toHaveBeenCalledWith(BigInt(splitSegmentId)); expect(skeletonLayer.suppressBrowseSegment).toHaveBeenCalledWith( @@ -1077,27 +1058,31 @@ describe("spatial_skeleton_commands", () => { ]); syncCacheFromServer(originalSegmentId); + const splitSkeleton = vi.fn(async () => { + serverSegments.set(originalSegmentId, [ + cloneNode(originalRootNode), + cloneNode(formerParentNode), + ]); + serverSegments.set(splitSegmentId, [cloneNode(splitNodeAfter)]); + return { + existingSegmentId: originalSegmentId, + newSegmentId: splitSegmentId, + }; + }); + const mergeSkeletons = vi.fn(async () => { + serverSegments.set(originalSegmentId, restoredNodes.map(cloneNode)); + serverSegments.delete(splitSegmentId); + return { + resultSegmentId: originalSegmentId, + deletedSegmentId: splitSegmentId, + directionAdjusted: false, + }; + }); + const rerootSkeleton = vi.fn(); const skeletonSource = makeEditableSkeletonSource({ - splitSkeleton: vi.fn(async () => { - serverSegments.set(originalSegmentId, [ - cloneNode(originalRootNode), - cloneNode(formerParentNode), - ]); - serverSegments.set(splitSegmentId, [cloneNode(splitNodeAfter)]); - return { - existingSegmentId: originalSegmentId, - newSegmentId: splitSegmentId, - }; - }), - mergeSkeletons: vi.fn(async () => { - serverSegments.set(originalSegmentId, restoredNodes.map(cloneNode)); - serverSegments.delete(splitSegmentId); - return { - resultSegmentId: originalSegmentId, - deletedSegmentId: splitSegmentId, - directionAdjusted: false, - }; - }), + splitSkeleton, + mergeSkeletons, + rerootSkeleton, }); const invalidateCachedSegments = vi.fn((segmentIds: Iterable) => { @@ -1164,18 +1149,23 @@ describe("spatial_skeleton_commands", () => { segmentId: originalSegmentId, }); - skeletonSource.rerootSkeleton.mockClear(); + rerootSkeleton.mockClear(); getFullSegmentNodes.mockClear(); invalidateCachedSegments.mockClear(); await undoSpatialSkeletonCommand(layer as any); - expect(skeletonSource.mergeSkeletons).toHaveBeenCalledWith( + expect(mergeSkeletons).toHaveBeenCalledWith( formerParentNode.nodeId, splitNodeBefore.nodeId, - expect.any(Object), + expect.objectContaining({ + nodes: expect.arrayContaining([ + expect.objectContaining({ nodeId: formerParentNode.nodeId }), + expect.objectContaining({ nodeId: splitNodeBefore.nodeId }), + ]), + }), ); - expect(skeletonSource.rerootSkeleton).not.toHaveBeenCalled(); + expect(rerootSkeleton).not.toHaveBeenCalled(); expect(invalidateCachedSegments).toHaveBeenCalledTimes(1); expect(invalidateCachedSegments).toHaveBeenCalledWith([ originalSegmentId, @@ -1204,7 +1194,7 @@ describe("spatial_skeleton_commands", () => { ]); }); - it("preserves full merge undo behavior for a hidden second pick via the root endpoint", async () => { + it("preserves full merge undo behavior for a hidden second pick", async () => { suppressStatusMessages(); const visibleSegmentId = 11; @@ -1303,38 +1293,41 @@ describe("spatial_skeleton_commands", () => { ]); syncCacheFromServer(visibleSegmentId); + const mergeSkeletons = vi.fn(async () => { + serverSegments.set(visibleSegmentId, mergedNodes.map(cloneNode)); + serverSegments.delete(hiddenSegmentId); + return { + resultSegmentId: visibleSegmentId, + deletedSegmentId: hiddenSegmentId, + directionAdjusted: false, + }; + }); + const splitSkeleton = vi.fn(async () => { + serverSegments.set(visibleSegmentId, [ + cloneNode(visibleRootNode), + cloneNode(visibleAnchorNode), + ]); + serverSegments.set( + hiddenSegmentId, + splitOnlyRestoredNodes.map(cloneNode), + ); + return { + existingSegmentId: visibleSegmentId, + newSegmentId: hiddenSegmentId, + }; + }); + const rerootSkeleton = vi.fn(async () => { + serverSegments.set(hiddenSegmentId, rerootedHiddenNodes.map(cloneNode)); + return {}; + }); const skeletonSource = makeEditableSkeletonSource({ getSkeletonRootNode: vi.fn(async () => ({ nodeId: hiddenRootNode.nodeId, position: hiddenRootNode.position, })), - mergeSkeletons: vi.fn(async () => { - serverSegments.set(visibleSegmentId, mergedNodes.map(cloneNode)); - serverSegments.delete(hiddenSegmentId); - return { - resultSegmentId: visibleSegmentId, - deletedSegmentId: hiddenSegmentId, - directionAdjusted: false, - }; - }), - splitSkeleton: vi.fn(async () => { - serverSegments.set(visibleSegmentId, [ - cloneNode(visibleRootNode), - cloneNode(visibleAnchorNode), - ]); - serverSegments.set( - hiddenSegmentId, - splitOnlyRestoredNodes.map(cloneNode), - ); - return { - existingSegmentId: visibleSegmentId, - newSegmentId: hiddenSegmentId, - }; - }), - rerootSkeleton: vi.fn(async () => { - serverSegments.set(hiddenSegmentId, rerootedHiddenNodes.map(cloneNode)); - return {}; - }), + mergeSkeletons, + splitSkeleton, + rerootSkeleton, }); const invalidateCachedSegments = vi.fn((segmentIds: Iterable) => { @@ -1416,31 +1409,40 @@ describe("spatial_skeleton_commands", () => { }, ); - expect(skeletonSource.getSkeletonRootNode).toHaveBeenCalledWith( - hiddenSegmentId, - ); - expect(skeletonSource.mergeSkeletons).toHaveBeenCalledWith( + expect(skeletonSource.getSkeletonRootNode).not.toHaveBeenCalled(); + expect(mergeSkeletons).toHaveBeenCalledWith( visibleAnchorNode.nodeId, hiddenAttachNodeBefore.nodeId, - expect.any(Object), + expect.objectContaining({ + nodes: expect.arrayContaining([ + expect.objectContaining({ nodeId: visibleAnchorNode.nodeId }), + expect.objectContaining({ nodeId: hiddenAttachNodeBefore.nodeId }), + ]), + }), + ); + expect(getFullSegmentNodes).toHaveBeenCalledTimes(3); + expect(getFullSegmentNodes.mock.invocationCallOrder[0]).toBeLessThan( + mergeSkeletons.mock.invocationCallOrder[0], ); - expect(getFullSegmentNodes).toHaveBeenCalledTimes(2); - expect( - skeletonSource.mergeSkeletons.mock.invocationCallOrder[0], - ).toBeLessThan(getFullSegmentNodes.mock.invocationCallOrder[0]); - skeletonSource.rerootSkeleton.mockClear(); + rerootSkeleton.mockClear(); hiddenSegmentVisibleDuringFetches.length = 0; await undoSpatialSkeletonCommand(layer as any); - expect(skeletonSource.splitSkeleton).toHaveBeenCalledWith( + expect(splitSkeleton).toHaveBeenCalledWith( hiddenAttachNodeBefore.nodeId, - expect.any(Object), + expect.objectContaining({ + node: expect.objectContaining({ + nodeId: hiddenAttachNodeBefore.nodeId, + }), + }), ); - expect(skeletonSource.rerootSkeleton).toHaveBeenCalledWith( + expect(rerootSkeleton).toHaveBeenCalledWith( hiddenRootNode.nodeId, - expect.any(Object), + expect.objectContaining({ + node: expect.objectContaining({ nodeId: hiddenRootNode.nodeId }), + }), ); expect(hiddenSegmentVisibleDuringFetches.length).toBeGreaterThan(0); expect(hiddenSegmentVisibleDuringFetches.every(Boolean)).toBe(true); @@ -1554,37 +1556,40 @@ describe("spatial_skeleton_commands", () => { ]); syncCacheFromServer(visibleSegmentId); + const mergeSkeletons = vi.fn(async () => { + serverSegments.set(visibleSegmentId, mergedNodes.map(cloneNode)); + serverSegments.delete(hiddenSegmentId); + return { + resultSegmentId: visibleSegmentId, + deletedSegmentId: hiddenSegmentId, + directionAdjusted: false, + }; + }); + const splitSkeleton = vi.fn(async () => { + serverSegments.set(visibleSegmentId, [ + cloneNode(visibleRootNode), + cloneNode(visibleAnchorNode), + ]); + serverSegments.set( + hiddenSegmentId, + splitOnlyRestoredNodes.map(cloneNode), + ); + return { + existingSegmentId: visibleSegmentId, + newSegmentId: hiddenSegmentId, + }; + }); + const rerootSkeleton = vi.fn(async () => { + throw new Error("reroot failed"); + }); const skeletonSource = makeEditableSkeletonSource({ getSkeletonRootNode: vi.fn(async () => ({ nodeId: hiddenRootNode.nodeId, position: hiddenRootNode.position, })), - mergeSkeletons: vi.fn(async () => { - serverSegments.set(visibleSegmentId, mergedNodes.map(cloneNode)); - serverSegments.delete(hiddenSegmentId); - return { - resultSegmentId: visibleSegmentId, - deletedSegmentId: hiddenSegmentId, - directionAdjusted: false, - }; - }), - splitSkeleton: vi.fn(async () => { - serverSegments.set(visibleSegmentId, [ - cloneNode(visibleRootNode), - cloneNode(visibleAnchorNode), - ]); - serverSegments.set( - hiddenSegmentId, - splitOnlyRestoredNodes.map(cloneNode), - ); - return { - existingSegmentId: visibleSegmentId, - newSegmentId: hiddenSegmentId, - }; - }), - rerootSkeleton: vi.fn(async () => { - throw new Error("reroot failed"); - }), + mergeSkeletons, + splitSkeleton, + rerootSkeleton, }); const getFullSegmentNodes = vi.fn( @@ -1659,13 +1664,19 @@ describe("spatial_skeleton_commands", () => { await expect(undoSpatialSkeletonCommand(layer as any)).resolves.toBe(true); - expect(skeletonSource.splitSkeleton).toHaveBeenCalledWith( + expect(splitSkeleton).toHaveBeenCalledWith( hiddenAttachNodeBefore.nodeId, - expect.any(Object), + expect.objectContaining({ + node: expect.objectContaining({ + nodeId: hiddenAttachNodeBefore.nodeId, + }), + }), ); - expect(skeletonSource.rerootSkeleton).toHaveBeenCalledWith( + expect(rerootSkeleton).toHaveBeenCalledWith( hiddenRootNode.nodeId, - expect.any(Object), + expect.objectContaining({ + node: expect.objectContaining({ nodeId: hiddenRootNode.nodeId }), + }), ); expect( cacheBySegment.get(hiddenSegmentId)?.map((node) => ({ @@ -1747,16 +1758,17 @@ describe("spatial_skeleton_commands", () => { ]); syncCacheFromServer(firstSegmentId); + const mergeSkeletons = vi.fn(async () => ({ + resultSegmentId: firstSegmentId, + deletedSegmentId: secondSegmentId, + directionAdjusted: false, + })); const skeletonSource = makeEditableSkeletonSource({ getSkeletonRootNode: vi.fn(async () => ({ nodeId: secondRootNode.nodeId, position: secondRootNode.position, })), - mergeSkeletons: vi.fn(async () => ({ - resultSegmentId: firstSegmentId, - deletedSegmentId: secondSegmentId, - directionAdjusted: false, - })), + mergeSkeletons, }); const getFullSegmentNodes = vi.fn( @@ -1832,10 +1844,15 @@ describe("spatial_skeleton_commands", () => { expect.anything(), secondSegmentId, ); - expect(skeletonSource.mergeSkeletons).toHaveBeenCalledWith( + expect(mergeSkeletons).toHaveBeenCalledWith( firstAnchorNode.nodeId, secondAttachNode.nodeId, - expect.any(Object), + expect.objectContaining({ + nodes: expect.arrayContaining([ + expect.objectContaining({ nodeId: firstAnchorNode.nodeId }), + expect.objectContaining({ nodeId: secondAttachNode.nodeId }), + ]), + }), ); }); diff --git a/src/layer/segmentation/spatial_skeleton_commands.ts b/src/layer/segmentation/spatial_skeleton_commands.ts index 1bd147cc2..6e3a542dd 100644 --- a/src/layer/segmentation/spatial_skeleton_commands.ts +++ b/src/layer/segmentation/spatial_skeleton_commands.ts @@ -15,14 +15,17 @@ */ import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; -import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; +import type { + EditableSpatiallyIndexedSkeletonSource, + SpatiallyIndexedSkeletonNode, +} from "#src/skeleton/api.js"; import { SpatialSkeletonActions, type SpatialSkeletonAction, } from "#src/skeleton/actions.js"; import type { SpatialSkeletonCommandPayload, - SpatialSkeletonEditCommandSource, + SpatialSkeletonEditCommandFactory, } from "#src/skeleton/edit_command_source.js"; import type { SpatialSkeletonCommand } from "#src/skeleton/command_history.js"; import { getEditableSpatiallyIndexedSkeletonSource } from "#src/skeleton/spatial_skeleton_manager.js"; @@ -32,18 +35,10 @@ interface SpatialSkeletonSourceAccess { source: object; } -export function getSpatialSkeletonEditCommandSource( - value: SpatialSkeletonSourceAccess | undefined, -): SpatialSkeletonEditCommandSource | undefined { - const source = getEditableSpatiallyIndexedSkeletonSource(value); - if (source === undefined) return undefined; - return source.spatialSkeletonEditCommandSource; -} - function getEditSource( layer: SegmentationUserLayer, -): SpatialSkeletonEditCommandSource { - const source = getSpatialSkeletonEditCommandSource( +): EditableSpatiallyIndexedSkeletonSource { + const source = getEditableSpatiallyIndexedSkeletonSource( layer.getSpatiallyIndexedSkeletonLayer(), ); if (source === undefined) { @@ -54,14 +49,36 @@ function getEditSource( return source; } -function requireCommand( - command: SpatialSkeletonCommand | undefined, - message: string, -) { - if (command === undefined) { - throw new Error(message); +export function getSpatialSkeletonEditCommandFactory( + value: SpatialSkeletonSourceAccess | undefined, + action: SpatialSkeletonAction, +): SpatialSkeletonEditCommandFactory | undefined { + const source = getEditableSpatiallyIndexedSkeletonSource(value); + if (source === undefined) return undefined; + switch (action) { + case SpatialSkeletonActions.addNodes: + return source.addNodesCommand; + case SpatialSkeletonActions.insertNodes: + return source.insertNodesCommand; + case SpatialSkeletonActions.moveNodes: + return source.moveNodesCommand; + case SpatialSkeletonActions.deleteNodes: + return source.deleteNodesCommand; + case SpatialSkeletonActions.reroot: + return source.rerootCommand; + case SpatialSkeletonActions.editNodeDescription: + return source.editNodeDescriptionCommand; + case SpatialSkeletonActions.editNodeTrueEnd: + return source.editNodeTrueEndCommand; + case SpatialSkeletonActions.editNodeProperties: + return source.editNodePropertiesCommand; + case SpatialSkeletonActions.mergeSkeletons: + return source.mergeSkeletonsCommand; + case SpatialSkeletonActions.splitSkeletons: + return source.splitSkeletonsCommand; + case SpatialSkeletonActions.inspect: + return undefined; } - return command; } function executeCommand( @@ -85,10 +102,15 @@ function createSpatialSkeletonCommand( payload: SpatialSkeletonCommandPayload, unsupportedMessage: string, ) { - return requireCommand( - getEditSource(layer).createCommand(action, layer, payload), - unsupportedMessage, + const source = getEditSource(layer); + const commandFactory = getSpatialSkeletonEditCommandFactory( + { source }, + action, ); + if (commandFactory === undefined) { + throw new Error(unsupportedMessage); + } + return commandFactory.createCommand(layer, payload); } export function executeSpatialSkeletonAddNode( diff --git a/src/skeleton/api.ts b/src/skeleton/api.ts index d425da8b3..3d4e6ad28 100644 --- a/src/skeleton/api.ts +++ b/src/skeleton/api.ts @@ -14,7 +14,18 @@ * limitations under the License. */ -import type { SpatialSkeletonEditCommandSource } from "#src/skeleton/edit_command_source.js"; +import type { + SpatialSkeletonAddNodesCommandFactory, + SpatialSkeletonDeleteNodesCommandFactory, + SpatialSkeletonEditNodeDescriptionCommandFactory, + SpatialSkeletonEditNodePropertiesCommandFactory, + SpatialSkeletonEditNodeTrueEndCommandFactory, + SpatialSkeletonInsertNodesCommandFactory, + SpatialSkeletonMergeSkeletonsCommandFactory, + SpatialSkeletonMoveNodesCommandFactory, + SpatialSkeletonRerootCommandFactory, + SpatialSkeletonSplitSkeletonsCommandFactory, +} from "#src/skeleton/edit_command_source.js"; export type SpatialSkeletonVector = ArrayLike; @@ -75,11 +86,6 @@ export interface SpatialSkeletonEditCapabilities { nodeFeatures?: SpatialSkeletonNodeFeatureCapabilities; } -export type SpatialSkeletonEditResult = object; -export type SpatialSkeletonEditOperation = ( - ...args: never[] -) => Promise; - export interface SpatiallyIndexedSkeletonSource { readonly readOnly: boolean; listSkeletons(): Promise; @@ -98,17 +104,16 @@ export interface SpatiallyIndexedSkeletonSource { export interface EditableSpatiallyIndexedSkeletonSource extends SpatiallyIndexedSkeletonSource { - readonly spatialSkeletonEditCommandSource: SpatialSkeletonEditCommandSource; + readonly readOnly: false; readonly spatialSkeletonEditCapabilities?: SpatialSkeletonEditCapabilities; - addNode: SpatialSkeletonEditOperation; - deleteNode: SpatialSkeletonEditOperation; - moveNode: SpatialSkeletonEditOperation; - splitSkeleton: SpatialSkeletonEditOperation; - mergeSkeletons: SpatialSkeletonEditOperation; - toggleTrueEnd?: SpatialSkeletonEditOperation; - insertNode?: SpatialSkeletonEditOperation; - rerootSkeleton?: SpatialSkeletonEditOperation; - updateDescription?: SpatialSkeletonEditOperation; - updateRadius?: SpatialSkeletonEditOperation; - updateConfidence?: SpatialSkeletonEditOperation; + readonly addNodesCommand: SpatialSkeletonAddNodesCommandFactory; + readonly deleteNodesCommand: SpatialSkeletonDeleteNodesCommandFactory; + readonly moveNodesCommand: SpatialSkeletonMoveNodesCommandFactory; + readonly splitSkeletonsCommand: SpatialSkeletonSplitSkeletonsCommandFactory; + readonly mergeSkeletonsCommand: SpatialSkeletonMergeSkeletonsCommandFactory; + readonly insertNodesCommand?: SpatialSkeletonInsertNodesCommandFactory; + readonly rerootCommand?: SpatialSkeletonRerootCommandFactory; + readonly editNodeDescriptionCommand?: SpatialSkeletonEditNodeDescriptionCommandFactory; + readonly editNodeTrueEndCommand?: SpatialSkeletonEditNodeTrueEndCommandFactory; + readonly editNodePropertiesCommand?: SpatialSkeletonEditNodePropertiesCommandFactory; } diff --git a/src/skeleton/edit_command_source.ts b/src/skeleton/edit_command_source.ts index cfa9ea9a8..d79fbf029 100644 --- a/src/skeleton/edit_command_source.ts +++ b/src/skeleton/edit_command_source.ts @@ -15,39 +15,74 @@ */ import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; -import type { SpatialSkeletonAction } from "#src/skeleton/actions.js"; +import { + SpatialSkeletonActions, + type SpatialSkeletonAction, +} from "#src/skeleton/actions.js"; import type { SpatialSkeletonCommand } from "#src/skeleton/command_history.js"; export type SpatialSkeletonCommandPayload = object; -export interface SpatialSkeletonEditCommandSource { - supports(action: SpatialSkeletonAction): boolean; +export interface SpatialSkeletonEditCommandFactory< + TAction extends SpatialSkeletonAction = SpatialSkeletonAction, +> { + readonly action: TAction; createCommand( - action: SpatialSkeletonAction, layer: SegmentationUserLayer, payload: SpatialSkeletonCommandPayload, - ): SpatialSkeletonCommand | undefined; + ): SpatialSkeletonCommand; } -type SpatialSkeletonEditCommandSourceCandidate = { - supports?: (action: SpatialSkeletonAction) => boolean; +type SpatialSkeletonEditCommandFactoryCandidate = { + action?: unknown; createCommand?: ( - action: SpatialSkeletonAction, layer: SegmentationUserLayer, payload: SpatialSkeletonCommandPayload, - ) => SpatialSkeletonCommand | undefined; + ) => SpatialSkeletonCommand; }; -export function isSpatialSkeletonEditCommandSource( +export function isSpatialSkeletonEditCommandFactory< + TAction extends SpatialSkeletonAction, +>( value: unknown, -): value is SpatialSkeletonEditCommandSource { + action: TAction, +): value is SpatialSkeletonEditCommandFactory { return ( typeof value === "object" && value !== null && - typeof (value as SpatialSkeletonEditCommandSourceCandidate).supports === - "function" && - typeof (value as SpatialSkeletonEditCommandSourceCandidate) - .createCommand === - "function" + (value as SpatialSkeletonEditCommandFactoryCandidate).action === action && + typeof (value as SpatialSkeletonEditCommandFactoryCandidate) + .createCommand === "function" ); } + +export type SpatialSkeletonAddNodesCommandFactory = + SpatialSkeletonEditCommandFactory; +export type SpatialSkeletonInsertNodesCommandFactory = + SpatialSkeletonEditCommandFactory; +export type SpatialSkeletonMoveNodesCommandFactory = + SpatialSkeletonEditCommandFactory; +export type SpatialSkeletonDeleteNodesCommandFactory = + SpatialSkeletonEditCommandFactory; +export type SpatialSkeletonRerootCommandFactory = + SpatialSkeletonEditCommandFactory; +export type SpatialSkeletonEditNodeDescriptionCommandFactory = + SpatialSkeletonEditCommandFactory< + typeof SpatialSkeletonActions.editNodeDescription + >; +export type SpatialSkeletonEditNodeTrueEndCommandFactory = + SpatialSkeletonEditCommandFactory< + typeof SpatialSkeletonActions.editNodeTrueEnd + >; +export type SpatialSkeletonEditNodePropertiesCommandFactory = + SpatialSkeletonEditCommandFactory< + typeof SpatialSkeletonActions.editNodeProperties + >; +export type SpatialSkeletonMergeSkeletonsCommandFactory = + SpatialSkeletonEditCommandFactory< + typeof SpatialSkeletonActions.mergeSkeletons + >; +export type SpatialSkeletonSplitSkeletonsCommandFactory = + SpatialSkeletonEditCommandFactory< + typeof SpatialSkeletonActions.splitSkeletons + >; diff --git a/src/skeleton/spatial_skeleton_manager.spec.ts b/src/skeleton/spatial_skeleton_manager.spec.ts index 860ec899f..81aa30361 100644 --- a/src/skeleton/spatial_skeleton_manager.spec.ts +++ b/src/skeleton/spatial_skeleton_manager.spec.ts @@ -21,34 +21,38 @@ import { getFlatListNodeIds, getSkeletonRootNode, } from "#src/skeleton/navigation.js"; +import { SpatialSkeletonActions } from "#src/skeleton/actions.js"; import { getEditableSpatiallyIndexedSkeletonSource, SpatialSkeletonState, } from "#src/skeleton/spatial_skeleton_manager.js"; -function makeEditableSourceMethods() { +function makeCommandFactory(action: string) { return { - addNode: vi.fn(), - deleteNode: vi.fn(), - moveNode: vi.fn(), - splitSkeleton: vi.fn(), - mergeSkeletons: vi.fn(), + action, + createCommand: vi.fn(), }; } -function makeEditCommandSource() { +function makeEditableSourceCommands() { return { - supports: vi.fn(), - createCommand: vi.fn(), + addNodesCommand: makeCommandFactory(SpatialSkeletonActions.addNodes), + deleteNodesCommand: makeCommandFactory(SpatialSkeletonActions.deleteNodes), + moveNodesCommand: makeCommandFactory(SpatialSkeletonActions.moveNodes), + splitSkeletonsCommand: makeCommandFactory( + SpatialSkeletonActions.splitSkeletons, + ), + mergeSkeletonsCommand: makeCommandFactory( + SpatialSkeletonActions.mergeSkeletons, + ), }; } describe("skeleton/spatial_skeleton_manager", () => { it("returns an editable source when mandatory edit actions are present", () => { const source = { - ...makeEditableSourceMethods(), + ...makeEditableSourceCommands(), readOnly: false, - spatialSkeletonEditCommandSource: makeEditCommandSource(), listSkeletons: async () => [], getSkeleton: async () => [], fetchNodes: async () => [], @@ -60,10 +64,9 @@ describe("skeleton/spatial_skeleton_manager", () => { it("does not treat a source missing mandatory edit actions as editable", () => { const source = { - ...makeEditableSourceMethods(), - mergeSkeletons: undefined, + ...makeEditableSourceCommands(), + mergeSkeletonsCommand: undefined, readOnly: false, - spatialSkeletonEditCommandSource: makeEditCommandSource(), listSkeletons: async () => [], getSkeleton: async () => [], fetchNodes: async () => [], @@ -75,9 +78,10 @@ describe("skeleton/spatial_skeleton_manager", () => { ).toBeUndefined(); }); - it("does not treat a source without an edit command source as editable", () => { + it("does not treat a command factory for the wrong action as editable", () => { const source = { - ...makeEditableSourceMethods(), + ...makeEditableSourceCommands(), + moveNodesCommand: makeCommandFactory(SpatialSkeletonActions.addNodes), readOnly: false, listSkeletons: async () => [], getSkeleton: async () => [], @@ -92,9 +96,8 @@ describe("skeleton/spatial_skeleton_manager", () => { it("does not require optional edit actions for editable source validation", () => { const source = { - ...makeEditableSourceMethods(), + ...makeEditableSourceCommands(), readOnly: false, - spatialSkeletonEditCommandSource: makeEditCommandSource(), listSkeletons: async () => [], getSkeleton: async () => [], fetchNodes: async () => [], @@ -104,11 +107,10 @@ describe("skeleton/spatial_skeleton_manager", () => { expect(getEditableSpatiallyIndexedSkeletonSource({ source })).toBe(source); }); - it("does not treat a read-only source with edit methods as editable", () => { + it("does not treat a read-only source with edit commands as editable", () => { const source = { - ...makeEditableSourceMethods(), + ...makeEditableSourceCommands(), readOnly: true, - spatialSkeletonEditCommandSource: makeEditCommandSource(), listSkeletons: async () => [], getSkeleton: async () => [], fetchNodes: async () => [], diff --git a/src/skeleton/spatial_skeleton_manager.ts b/src/skeleton/spatial_skeleton_manager.ts index cabfb4db0..90bed6649 100644 --- a/src/skeleton/spatial_skeleton_manager.ts +++ b/src/skeleton/spatial_skeleton_manager.ts @@ -20,8 +20,12 @@ import type { SpatialSkeletonSourceState, SpatiallyIndexedSkeletonSource, } from "#src/skeleton/api.js"; +import { + SpatialSkeletonActions, + type SpatialSkeletonAction, +} from "#src/skeleton/actions.js"; import { SpatialSkeletonCommandHistory } from "#src/skeleton/command_history.js"; -import { isSpatialSkeletonEditCommandSource } from "#src/skeleton/edit_command_source.js"; +import { isSpatialSkeletonEditCommandFactory } from "#src/skeleton/edit_command_source.js"; import type { SpatiallyIndexedSkeletonLayer } from "#src/skeleton/frontend.js"; import { WatchableValue } from "#src/trackable_value.js"; import { RefCounted } from "#src/util/disposable.js"; @@ -47,6 +51,29 @@ function getProperty(value: unknown, property: T): unknown { : undefined; } +function hasCommandFactory( + value: unknown, + property: T, + action: SpatialSkeletonAction, +) { + return isSpatialSkeletonEditCommandFactory( + getProperty(value, property), + action, + ); +} + +function hasOptionalCommandFactory( + value: unknown, + property: T, + action: SpatialSkeletonAction, +) { + const commandFactory = getProperty(value, property); + return ( + commandFactory === undefined || + isSpatialSkeletonEditCommandFactory(commandFactory, action) + ); +} + export function isSpatiallyIndexedSkeletonSource( value: unknown, ): value is SpatiallyIndexedSkeletonSource { @@ -65,14 +92,56 @@ export function isEditableSpatiallyIndexedSkeletonSource( return ( isSpatiallyIndexedSkeletonSource(value) && !value.readOnly && - isSpatialSkeletonEditCommandSource( - getProperty(value, "spatialSkeletonEditCommandSource"), + hasCommandFactory( + value, + "addNodesCommand", + SpatialSkeletonActions.addNodes, + ) && + hasCommandFactory( + value, + "deleteNodesCommand", + SpatialSkeletonActions.deleteNodes, + ) && + hasCommandFactory( + value, + "moveNodesCommand", + SpatialSkeletonActions.moveNodes, + ) && + hasCommandFactory( + value, + "splitSkeletonsCommand", + SpatialSkeletonActions.splitSkeletons, + ) && + hasCommandFactory( + value, + "mergeSkeletonsCommand", + SpatialSkeletonActions.mergeSkeletons, + ) && + hasOptionalCommandFactory( + value, + "insertNodesCommand", + SpatialSkeletonActions.insertNodes, + ) && + hasOptionalCommandFactory( + value, + "rerootCommand", + SpatialSkeletonActions.reroot, + ) && + hasOptionalCommandFactory( + value, + "editNodeDescriptionCommand", + SpatialSkeletonActions.editNodeDescription, + ) && + hasOptionalCommandFactory( + value, + "editNodeTrueEndCommand", + SpatialSkeletonActions.editNodeTrueEnd, ) && - hasFunction(value, "addNode") && - hasFunction(value, "deleteNode") && - hasFunction(value, "moveNode") && - hasFunction(value, "splitSkeleton") && - hasFunction(value, "mergeSkeletons") + hasOptionalCommandFactory( + value, + "editNodePropertiesCommand", + SpatialSkeletonActions.editNodeProperties, + ) ); } diff --git a/src/ui/spatial_skeleton_edit_tool.spec.ts b/src/ui/spatial_skeleton_edit_tool.spec.ts index 1d01f9d99..9cd4e1333 100644 --- a/src/ui/spatial_skeleton_edit_tool.spec.ts +++ b/src/ui/spatial_skeleton_edit_tool.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { makeCatmaidNodeSourceState } from "#src/datasource/catmaid/api.js"; -import { CatmaidSpatialSkeletonEditCommandSource } from "#src/datasource/catmaid/spatial_skeleton_commands.js"; +import { CatmaidSpatialSkeletonEditCommands } from "#src/datasource/catmaid/spatial_skeleton_commands.js"; import { executeSpatialSkeletonAddNode, executeSpatialSkeletonMerge, @@ -42,15 +42,22 @@ function makeVisibleSegmentsState(initialVisibleSegments: bigint[] = []) { }; } -function makeEditableSkeletonSource(overrides: Record = {}) { +const catmaidEditClientMethodNames = new Set([ + "addNode", + "insertNode", + "moveNode", + "deleteNode", + "rerootSkeleton", + "updateDescription", + "toggleTrueEnd", + "updateRadius", + "updateConfidence", + "mergeSkeletons", + "splitSkeleton", +]); + +function makeCatmaidClient(overrides: Record = {}) { return { - spatialSkeletonEditCommandSource: - new CatmaidSpatialSkeletonEditCommandSource(), - listSkeletons: vi.fn(), - getSkeleton: vi.fn(), - fetchNodes: vi.fn(), - getSpatialIndexMetadata: vi.fn(), - getSkeletonRootNode: vi.fn(), addNode: vi.fn(), insertNode: vi.fn(), moveNode: vi.fn(), @@ -66,6 +73,42 @@ function makeEditableSkeletonSource(overrides: Record = {}) { }; } +function makeEditableSkeletonSource(overrides: Record = {}) { + const clientOverrides: Record = {}; + const sourceOverrides: Record = {}; + for (const [key, value] of Object.entries(overrides)) { + if (catmaidEditClientMethodNames.has(key)) { + clientOverrides[key] = value; + } else { + sourceOverrides[key] = value; + } + } + const client = makeCatmaidClient(clientOverrides); + const commands = new CatmaidSpatialSkeletonEditCommands({ + ensureEditable: vi.fn(), + getClient: () => client as any, + }); + return { + readOnly: false, + addNodesCommand: commands.addNodesCommand, + insertNodesCommand: commands.insertNodesCommand, + moveNodesCommand: commands.moveNodesCommand, + deleteNodesCommand: commands.deleteNodesCommand, + rerootCommand: commands.rerootCommand, + editNodeDescriptionCommand: commands.editNodeDescriptionCommand, + editNodeTrueEndCommand: commands.editNodeTrueEndCommand, + editNodePropertiesCommand: commands.editNodePropertiesCommand, + mergeSkeletonsCommand: commands.mergeSkeletonsCommand, + splitSkeletonsCommand: commands.splitSkeletonsCommand, + listSkeletons: vi.fn(), + getSkeleton: vi.fn(), + fetchNodes: vi.fn(), + getSpatialIndexMetadata: vi.fn(), + getSkeletonRootNode: vi.fn(), + ...sourceOverrides, + }; +} + function testSourceState(revisionToken: string) { return makeCatmaidNodeSourceState(revisionToken); } @@ -180,13 +223,16 @@ describe("spatial_skeleton_edit_tool", () => { positionInModelSpace: position, }); - expect(addNode).toHaveBeenCalledWith(11, 1, 2, 3, 5, { - node: { - nodeId: 5, - parentNodeId: undefined, - revisionToken: "parent-before", - }, - }); + expect(addNode).toHaveBeenCalledWith( + 11, + 1, + 2, + 3, + 5, + expect.objectContaining({ + node: expect.objectContaining({ nodeId: 5 }), + }), + ); expect(upsertCachedNode).toHaveBeenCalledWith( { nodeId: 17, @@ -437,12 +483,16 @@ describe("spatial_skeleton_edit_tool", () => { { nodeId: 202, segmentId: 17 }, ); - expect(mergeSkeletons).toHaveBeenCalledWith(101, 202, { - nodes: [ - { nodeId: 101, revisionToken: "first-before" }, - { nodeId: 202, revisionToken: "second-before" }, - ], - }); + expect(mergeSkeletons).toHaveBeenCalledWith( + 101, + 202, + expect.objectContaining({ + nodes: expect.arrayContaining([ + expect.objectContaining({ nodeId: 101 }), + expect.objectContaining({ nodeId: 202 }), + ]), + }), + ); expect(invalidateCachedSegments).toHaveBeenCalledWith([17, 11]); expect(getFullSegmentNodes).toHaveBeenCalledTimes(2); expect(selectSegment).toHaveBeenCalledWith(17n, false); From 0e805ab84ed42cbd899c669d74cf94c77cd278ea Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Wed, 6 May 2026 19:21:36 +0100 Subject: [PATCH 10/11] feat: Split editNodeProperties into editNodeRadius and editNodeConfidence --- src/datasource/catmaid/frontend.ts | 86 +++-- .../catmaid/spatial_skeleton_commands.ts | 219 ++++++++----- src/layer/segmentation/index.spec.ts | 47 ++- src/layer/segmentation/index.ts | 308 ++++++++++-------- .../spatial_skeleton_commands.spec.ts | 109 ++++++- .../segmentation/spatial_skeleton_commands.ts | 25 +- src/skeleton/actions.ts | 9 +- src/skeleton/api.ts | 19 +- src/skeleton/edit_command_source.ts | 8 +- src/skeleton/spatial_skeleton_manager.spec.ts | 37 ++- src/skeleton/spatial_skeleton_manager.ts | 73 ++++- src/ui/spatial_skeleton_edit_tool.spec.ts | 4 +- 12 files changed, 660 insertions(+), 284 deletions(-) diff --git a/src/datasource/catmaid/frontend.ts b/src/datasource/catmaid/frontend.ts index fa5a57f35..b024e25e9 100644 --- a/src/datasource/catmaid/frontend.ts +++ b/src/datasource/catmaid/frontend.ts @@ -46,7 +46,7 @@ import { normalizeInlineSegmentPropertyMap, } from "#src/segmentation_display_state/property_map.js"; import type { - SpatialSkeletonEditCapabilities, + SpatialSkeletonConfidenceConfiguration, SpatialSkeletonGridCellIndex, SpatiallyIndexedSkeletonMetadata, SpatiallyIndexedSkeletonNode, @@ -66,14 +66,9 @@ import type { Borrowed } from "#src/util/disposable.js"; import { mat4, vec3 } from "#src/util/geom.js"; import "#src/datasource/catmaid/register_credentials_provider.js"; -const CATMAID_SPATIAL_SKELETON_EDIT_CAPABILITIES = { - nodeFeatures: { - description: true, - trueEnd: true, - radius: true, - confidenceValues: CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES, - }, -} satisfies SpatialSkeletonEditCapabilities; +const CATMAID_SPATIAL_SKELETON_CONFIDENCE_CONFIGURATION = { + values: CATMAID_SPATIAL_SKELETON_CONFIDENCE_VALUES, +} satisfies SpatialSkeletonConfidenceConfiguration; export class CatmaidSpatiallyIndexedSkeletonSource extends WithParameters( WithCredentialsProvider()(SpatiallyIndexedSkeletonSource), @@ -81,7 +76,6 @@ export class CatmaidSpatiallyIndexedSkeletonSource extends WithParameters( ) { private readonly spatialSkeletonEditCommands = new CatmaidSpatialSkeletonEditCommands({ - ensureEditable: () => this.ensureSpatialSkeletonEditable(), getClient: () => this.client, }); private client_?: CatmaidClient; @@ -90,34 +84,58 @@ export class CatmaidSpatiallyIndexedSkeletonSource extends WithParameters( return this.parameters.catmaidParameters.readOnly !== false; } - get spatialSkeletonEditCapabilities() { + get spatialSkeletonConfidenceConfiguration() { return this.readOnly ? undefined - : CATMAID_SPATIAL_SKELETON_EDIT_CAPABILITIES; + : CATMAID_SPATIAL_SKELETON_CONFIDENCE_CONFIGURATION; } - readonly addNodesCommand = this.spatialSkeletonEditCommands.addNodesCommand; - readonly insertNodesCommand = - this.spatialSkeletonEditCommands.insertNodesCommand; - readonly moveNodesCommand = this.spatialSkeletonEditCommands.moveNodesCommand; - readonly deleteNodesCommand = - this.spatialSkeletonEditCommands.deleteNodesCommand; - readonly rerootCommand = this.spatialSkeletonEditCommands.rerootCommand; - readonly editNodeDescriptionCommand = - this.spatialSkeletonEditCommands.editNodeDescriptionCommand; - readonly editNodeTrueEndCommand = - this.spatialSkeletonEditCommands.editNodeTrueEndCommand; - readonly editNodePropertiesCommand = - this.spatialSkeletonEditCommands.editNodePropertiesCommand; - readonly mergeSkeletonsCommand = - this.spatialSkeletonEditCommands.mergeSkeletonsCommand; - readonly splitSkeletonsCommand = - this.spatialSkeletonEditCommands.splitSkeletonsCommand; - - private ensureSpatialSkeletonEditable() { - if (this.readOnly) { - throw new Error("CATMAID spatial skeleton source is read-only."); - } + private get editableSpatialSkeletonEditCommands() { + return this.readOnly ? undefined : this.spatialSkeletonEditCommands; + } + + get addNodesCommand() { + return this.editableSpatialSkeletonEditCommands?.addNodesCommand; + } + + get insertNodesCommand() { + return this.editableSpatialSkeletonEditCommands?.insertNodesCommand; + } + + get moveNodesCommand() { + return this.editableSpatialSkeletonEditCommands?.moveNodesCommand; + } + + get deleteNodesCommand() { + return this.editableSpatialSkeletonEditCommands?.deleteNodesCommand; + } + + get rerootCommand() { + return this.editableSpatialSkeletonEditCommands?.rerootCommand; + } + + get editNodeDescriptionCommand() { + return this.editableSpatialSkeletonEditCommands?.editNodeDescriptionCommand; + } + + get editNodeTrueEndCommand() { + return this.editableSpatialSkeletonEditCommands?.editNodeTrueEndCommand; + } + + get editNodeRadiusCommand() { + return this.editableSpatialSkeletonEditCommands?.editNodeRadiusCommand; + } + + get editNodeConfidenceCommand() { + return this.editableSpatialSkeletonEditCommands?.editNodeConfidenceCommand; + } + + get mergeSkeletonsCommand() { + return this.editableSpatialSkeletonEditCommands?.mergeSkeletonsCommand; + } + + get splitSkeletonsCommand() { + return this.editableSpatialSkeletonEditCommands?.splitSkeletonsCommand; } private get client() { diff --git a/src/datasource/catmaid/spatial_skeleton_commands.ts b/src/datasource/catmaid/spatial_skeleton_commands.ts index b37b26c74..797f5e858 100644 --- a/src/datasource/catmaid/spatial_skeleton_commands.ts +++ b/src/datasource/catmaid/spatial_skeleton_commands.ts @@ -100,9 +100,14 @@ interface CatmaidSpatialSkeletonNodeTrueEndCommandOptions { nextIsTrueEnd: boolean; } -interface CatmaidSpatialSkeletonNodePropertiesCommandOptions { +interface CatmaidSpatialSkeletonNodeRadiusCommandOptions { node: SpatiallyIndexedSkeletonNode; - next: { radius: number; confidence: number }; + nextRadius: number; +} + +interface CatmaidSpatialSkeletonNodeConfidenceCommandOptions { + node: SpatiallyIndexedSkeletonNode; + nextConfidence: number; } interface CatmaidSpatialSkeletonMergeEndpoint { @@ -117,7 +122,6 @@ interface CatmaidSpatialSkeletonMergeCommandPayload { } export interface CatmaidSpatialSkeletonEditCommandContext { - ensureEditable(): void; getClient(): CatmaidClient; } @@ -335,25 +339,39 @@ function requireCatmaidNodeTrueEndCommandOptions(payload: object) { ); } -function requireCatmaidNodePropertiesCommandOptions(payload: object) { +function requireCatmaidNodeRadiusCommandOptions(payload: object) { + return requireCatmaidCommandPayload( + payload, + "node-radius", + ( + candidate, + ): candidate is CatmaidSpatialSkeletonNodeRadiusCommandOptions => { + const options = candidate as { + node?: object; + nextRadius?: number; + }; + return ( + isSpatiallyIndexedSkeletonNodePayload(options.node) && + isFiniteNumber(options.nextRadius) + ); + }, + ); +} + +function requireCatmaidNodeConfidenceCommandOptions(payload: object) { return requireCatmaidCommandPayload( payload, - "node-properties", + "node-confidence", ( candidate, - ): candidate is CatmaidSpatialSkeletonNodePropertiesCommandOptions => { + ): candidate is CatmaidSpatialSkeletonNodeConfidenceCommandOptions => { const options = candidate as { node?: object; - next?: object; + nextConfidence?: number; }; - const next = options.next as - | { radius?: number; confidence?: number } - | undefined; return ( isSpatiallyIndexedSkeletonNodePayload(options.node) && - next !== undefined && - isFiniteNumber(next.radius) && - isFiniteNumber(next.confidence) + isFiniteNumber(options.nextConfidence) ); }, ); @@ -1491,75 +1509,118 @@ class NodeTrueEndCommand implements SpatialSkeletonCommand { } } -class NodePropertiesCommand implements SpatialSkeletonCommand { - readonly label = "Edit node properties"; +class NodeRadiusCommand implements SpatialSkeletonCommand { + readonly label = "Edit node radius"; constructor( private layer: SegmentationUserLayer, private stableNodeId: number, private stableSegmentId: number | undefined, - private before: { radius: number; confidence: number }, - private after: { radius: number; confidence: number }, + private beforeRadius: number, + private afterRadius: number, private editOperations: CatmaidSpatialSkeletonEditOperations, ) {} - private async applyProperties( - next: { radius: number; confidence: number }, - statusPrefix: string, - ) { + private async applyRadius(nextRadius: number, statusPrefix: string) { const { node } = await getResolvedNodeForEdit( this.layer, this.stableNodeId, this.stableSegmentId, ); - let currentNode = cloneNodeSnapshot(node); - if (currentNode.radius !== next.radius) { - const radiusResult = await this.editOperations.commitRadius({ - node: currentNode, - radius: next.radius, - }); - currentNode = { - ...currentNode, - radius: next.radius, - sourceState: radiusResult.sourceState ?? currentNode.sourceState, - }; + if (node.radius === nextRadius) { + return; } - if (currentNode.confidence !== next.confidence) { - const confidenceResult = await this.editOperations.commitConfidence({ - node: currentNode, - confidence: next.confidence, - }); - currentNode = { - ...currentNode, - confidence: next.confidence, - sourceState: confidenceResult.sourceState ?? currentNode.sourceState, - }; + const radiusResult = await this.editOperations.commitRadius({ + node, + radius: nextRadius, + }); + this.layer.spatialSkeletonState.setNodeRadius(node.nodeId, nextRadius); + if (radiusResult.sourceState !== undefined) { + this.layer.spatialSkeletonState.setCachedNodeSourceState( + node.nodeId, + radiusResult.sourceState, + ); + } + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + StatusMessage.showTemporaryMessage( + `${statusPrefix} node ${node.nodeId} radius.`, + ); + } + + execute() { + return this.applyRadius(this.afterRadius, "Updated"); + } + + undo() { + return this.applyRadius(this.beforeRadius, "Undid radius update for"); + } + + redo() { + return this.applyRadius(this.afterRadius, "Redid radius update for"); + } +} + +class NodeConfidenceCommand implements SpatialSkeletonCommand { + readonly label = "Edit node confidence"; + + constructor( + private layer: SegmentationUserLayer, + private stableNodeId: number, + private stableSegmentId: number | undefined, + private beforeConfidence: number, + private afterConfidence: number, + private editOperations: CatmaidSpatialSkeletonEditOperations, + ) {} + + private async applyConfidence(nextConfidence: number, statusPrefix: string) { + const { node } = await getResolvedNodeForEdit( + this.layer, + this.stableNodeId, + this.stableSegmentId, + ); + if (node.confidence === nextConfidence) { + return; } - this.layer.spatialSkeletonState.setNodeProperties(node.nodeId, next); - if (currentNode.sourceState !== undefined) { + const confidenceResult = await this.editOperations.commitConfidence({ + node, + confidence: nextConfidence, + }); + this.layer.spatialSkeletonState.setNodeConfidence( + node.nodeId, + nextConfidence, + ); + if (confidenceResult.sourceState !== undefined) { this.layer.spatialSkeletonState.setCachedNodeSourceState( node.nodeId, - currentNode.sourceState, + confidenceResult.sourceState, ); } this.layer.markSpatialSkeletonNodeDataChanged({ invalidateFullSkeletonCache: false, }); StatusMessage.showTemporaryMessage( - `${statusPrefix} node ${node.nodeId} properties.`, + `${statusPrefix} node ${node.nodeId} confidence.`, ); } execute() { - return this.applyProperties(this.after, "Updated"); + return this.applyConfidence(this.afterConfidence, "Updated"); } undo() { - return this.applyProperties(this.before, "Undid property update for"); + return this.applyConfidence( + this.beforeConfidence, + "Undid confidence update for", + ); } redo() { - return this.applyProperties(this.after, "Redid property update for"); + return this.applyConfidence( + this.afterConfidence, + "Redid confidence update for", + ); } } @@ -2084,12 +2145,21 @@ export class CatmaidSpatialSkeletonEditCommands { ), ); - readonly editNodePropertiesCommand = makeCatmaidCommandFactory( - SpatialSkeletonActions.editNodeProperties, + readonly editNodeRadiusCommand = makeCatmaidCommandFactory( + SpatialSkeletonActions.editNodeRadius, (layer, payload) => - this.createNodePropertiesCommand( + this.createNodeRadiusCommand( layer, - requireCatmaidNodePropertiesCommandOptions(payload), + requireCatmaidNodeRadiusCommandOptions(payload), + ), + ); + + readonly editNodeConfidenceCommand = makeCatmaidCommandFactory( + SpatialSkeletonActions.editNodeConfidence, + (layer, payload) => + this.createNodeConfidenceCommand( + layer, + requireCatmaidNodeConfidenceCommandOptions(payload), ), ); @@ -2118,14 +2188,9 @@ export class CatmaidSpatialSkeletonEditCommands { return this.editContext.getClient(); } - private ensureEditable() { - this.editContext.ensureEditable(); - } - private commitAddNode( request: CatmaidSpatialSkeletonAddNodeRequest, ): Promise { - this.ensureEditable(); const [x, y, z] = getCatmaidEditPosition( request.position, "add-node position", @@ -2145,7 +2210,6 @@ export class CatmaidSpatialSkeletonEditCommands { private commitInsertNode( request: CatmaidSpatialSkeletonInsertNodeRequest, ): Promise { - this.ensureEditable(); const [x, y, z] = getCatmaidEditPosition( request.position, "insert-node position", @@ -2164,7 +2228,6 @@ export class CatmaidSpatialSkeletonEditCommands { private commitMoveNode( request: CatmaidSpatialSkeletonMoveNodeRequest, ): Promise { - this.ensureEditable(); const [x, y, z] = getCatmaidEditPosition( request.position, "move-node position", @@ -2181,7 +2244,6 @@ export class CatmaidSpatialSkeletonEditCommands { private commitDeleteNode( request: CatmaidSpatialSkeletonDeleteNodeRequest, ): Promise { - this.ensureEditable(); return this.client.deleteNode(request.node.nodeId, { childNodeIds: request.childNodes.map((child) => child.nodeId), editContext: buildCatmaidNeighborhoodEditContext( @@ -2194,7 +2256,6 @@ export class CatmaidSpatialSkeletonEditCommands { private commitReroot( request: CatmaidSpatialSkeletonRerootRequest, ): Promise { - this.ensureEditable(); return this.client.rerootSkeleton( request.node.nodeId, buildCatmaidRerootEditContext(request.node, request.segmentNodes), @@ -2204,7 +2265,6 @@ export class CatmaidSpatialSkeletonEditCommands { private commitDescription( request: CatmaidSpatialSkeletonDescriptionUpdateRequest, ): Promise { - this.ensureEditable(); return this.client.updateDescription( request.node.nodeId, request.description, @@ -2217,14 +2277,12 @@ export class CatmaidSpatialSkeletonEditCommands { private commitTrueEnd( request: CatmaidSpatialSkeletonTrueEndUpdateRequest, ): Promise { - this.ensureEditable(); return this.client.toggleTrueEnd(request.node.nodeId, request.isTrueEnd); } private commitRadius( request: CatmaidSpatialSkeletonRadiusUpdateRequest, ): Promise { - this.ensureEditable(); return this.client.updateRadius( request.node.nodeId, request.radius, @@ -2235,7 +2293,6 @@ export class CatmaidSpatialSkeletonEditCommands { private commitConfidence( request: CatmaidSpatialSkeletonConfidenceUpdateRequest, ): Promise { - this.ensureEditable(); return this.client.updateConfidence( request.node.nodeId, request.confidence, @@ -2246,7 +2303,6 @@ export class CatmaidSpatialSkeletonEditCommands { private commitMerge( request: CatmaidSpatialSkeletonMergeRequest, ): Promise { - this.ensureEditable(); return this.client.mergeSkeletons( request.fromNode.nodeId, request.toNode.nodeId, @@ -2257,7 +2313,6 @@ export class CatmaidSpatialSkeletonEditCommands { private commitSplit( request: CatmaidSpatialSkeletonSplitRequest, ): Promise { - this.ensureEditable(); return this.client.splitSkeleton( request.node.nodeId, buildCatmaidNeighborhoodEditContext(request.node, request.segmentNodes), @@ -2382,20 +2437,32 @@ export class CatmaidSpatialSkeletonEditCommands { ); } - private createNodePropertiesCommand( + private createNodeRadiusCommand( layer: SegmentationUserLayer, - options: CatmaidSpatialSkeletonNodePropertiesCommandOptions, + options: CatmaidSpatialSkeletonNodeRadiusCommandOptions, ) { const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; - return new NodePropertiesCommand( + return new NodeRadiusCommand( layer, commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), - { - radius: options.node.radius ?? 0, - confidence: options.node.confidence ?? 0, - }, - options.next, + options.node.radius ?? 0, + options.nextRadius, + this.editOperations, + ); + } + + private createNodeConfidenceCommand( + layer: SegmentationUserLayer, + options: CatmaidSpatialSkeletonNodeConfidenceCommandOptions, + ) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + return new NodeConfidenceCommand( + layer, + commandMappings.getStableOrCurrentNodeId(options.node.nodeId)!, + commandMappings.getStableOrCurrentSegmentId(options.node.segmentId), + options.node.confidence ?? 0, + options.nextConfidence, this.editOperations, ); } diff --git a/src/layer/segmentation/index.spec.ts b/src/layer/segmentation/index.spec.ts index bbaab5037..fd1949a67 100644 --- a/src/layer/segmentation/index.spec.ts +++ b/src/layer/segmentation/index.spec.ts @@ -35,6 +35,7 @@ const { SegmentSelectionState } = await import( function makeEditableSpatialSkeletonSource( options: { + confidenceConfiguration?: boolean; rerootCommand?: boolean; } = {}, ) { @@ -58,8 +59,9 @@ function makeEditableSpatialSkeletonSource( SpatialSkeletonActions.editNodeDescription, ), editNodeTrueEndCommand: makeCommand(SpatialSkeletonActions.editNodeTrueEnd), - editNodePropertiesCommand: makeCommand( - SpatialSkeletonActions.editNodeProperties, + editNodeRadiusCommand: makeCommand(SpatialSkeletonActions.editNodeRadius), + editNodeConfidenceCommand: makeCommand( + SpatialSkeletonActions.editNodeConfidence, ), mergeSkeletonsCommand: makeCommand(SpatialSkeletonActions.mergeSkeletons), splitSkeletonsCommand: makeCommand(SpatialSkeletonActions.splitSkeletons), @@ -71,6 +73,13 @@ function makeEditableSpatialSkeletonSource( nodeId: 1, position: [0, 0, 0], }), + ...(options.confidenceConfiguration !== true + ? {} + : { + spatialSkeletonConfidenceConfiguration: { + values: [0, 50, 100], + }, + }), ...(options.rerootCommand !== true ? {} : { @@ -236,6 +245,40 @@ describe("layer/segmentation spatial skeleton action gating", () => { ); }); + it("requires confidence configuration for confidence edit support", () => { + const layer = Object.assign( + Object.create(SegmentationUserLayer.prototype), + { + getSpatiallyIndexedSkeletonLayer: () => + makeSpatialSkeletonLayerWithSource(makeEditableSpatialSkeletonSource()), + spatialSkeletonVisibleChunksLoaded: new WatchableValue(true), + spatialSkeletonVisibleChunksNeeded: new WatchableValue(0), + spatialSkeletonVisibleChunksAvailable: new WatchableValue(0), + }, + ); + + expect( + layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.editNodeConfidence, + ), + ).toBe( + "The active spatial skeleton source does not support node confidence editing.", + ); + + layer.getSpatiallyIndexedSkeletonLayer = () => + makeSpatialSkeletonLayerWithSource( + makeEditableSpatialSkeletonSource({ + confidenceConfiguration: true, + }), + ); + + expect( + layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.editNodeConfidence, + ), + ).toBeUndefined(); + }); + it("reports read-only spatial skeleton sources explicitly", () => { const layer = Object.assign( Object.create(SegmentationUserLayer.prototype), diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 312863a7a..324ab3723 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -52,8 +52,9 @@ import { } from "#src/layer/segmentation/selection.js"; import { executeSpatialSkeletonDeleteNode, + executeSpatialSkeletonNodeConfidenceUpdate, executeSpatialSkeletonNodeDescriptionUpdate, - executeSpatialSkeletonNodePropertiesUpdate, + executeSpatialSkeletonNodeRadiusUpdate, executeSpatialSkeletonReroot, executeSpatialSkeletonNodeTrueEndUpdate, } from "#src/layer/segmentation/spatial_skeleton_commands.js"; @@ -1608,8 +1609,13 @@ export class SegmentationUserLayer extends Base { return source.editNodeDescriptionCommand !== undefined; case SpatialSkeletonActions.editNodeTrueEnd: return source.editNodeTrueEndCommand !== undefined; - case SpatialSkeletonActions.editNodeProperties: - return source.editNodePropertiesCommand !== undefined; + case SpatialSkeletonActions.editNodeRadius: + return source.editNodeRadiusCommand !== undefined; + case SpatialSkeletonActions.editNodeConfidence: + return ( + source.editNodeConfidenceCommand !== undefined && + source.spatialSkeletonConfidenceConfiguration !== undefined + ); } } @@ -2810,23 +2816,149 @@ export class SegmentationUserLayer extends Base { } else { appendValue("Node type", nodeTypeLabel); } - const nodeFeatureCapabilities = getEditableSpatiallyIndexedSkeletonSource( - this.getSpatiallyIndexedSkeletonLayer(), - )?.spatialSkeletonEditCapabilities?.nodeFeatures; - const confidenceCapabilityValues = - nodeFeatureCapabilities?.confidenceValues; - const nodePropertiesEditable = - (nodeFeatureCapabilities?.radius ?? false) && - confidenceCapabilityValues !== undefined; - if ( - cachedNodeInfo === undefined || - segmentNodes === undefined || - !nodePropertiesEditable - ) { + const confidenceConfiguration = + editSource?.spatialSkeletonConfidenceConfiguration; + const setPropertyInputValidity = ( + input: HTMLInputElement | HTMLSelectElement, + valid: boolean, + invalidTitle: string, + disabledReason: string | undefined, + ) => { + input.classList.toggle( + "neuroglancer-spatial-skeleton-properties-input-invalid", + !valid, + ); + if (disabledReason !== undefined) { + input.title = disabledReason; + } else if (!valid) { + input.title = invalidTitle; + } else { + input.removeAttribute("title"); + } + }; + const handlePropertyInputKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter") return; + event.preventDefault(); + (event.currentTarget as HTMLElement | null)?.blur(); + }; + const getCachedNodeForPropertyEdit = () => { + const currentNode = this.spatialSkeletonState.getCachedNode( + fullNodeInfo.nodeId, + ); + if (currentNode === undefined) { + throw new Error( + `Node ${fullNodeInfo.nodeId} is missing from the inspected skeleton cache.`, + ); + } + return currentNode; + }; + const radiusEditingDisabledReason = () => + editSource === undefined + ? "Unable to resolve editable skeleton source for the active layer." + : cachedNodeInfo === undefined + ? "Load the active skeleton in the Skeleton tab before editing radius." + : this.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.editNodeRadius, + ); + const confidenceEditingDisabledReason = () => + editSource === undefined + ? "Unable to resolve editable skeleton source for the active layer." + : cachedNodeInfo === undefined + ? "Load the active skeleton in the Skeleton tab before editing confidence." + : confidenceConfiguration === undefined + ? "The active skeleton source does not provide confidence value configuration." + : this.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.editNodeConfidence, + ); + + if (radiusEditingDisabledReason() !== undefined) { appendValue( "Radius", formatSpatialSkeletonEditableNumber(fullNodeInfo.radius, "Unavailable"), ); + } else { + let committedRadius = fullNodeInfo.radius ?? 0; + const radiusInput = document.createElement("input"); + radiusInput.className = "neuroglancer-spatial-skeleton-properties-input"; + radiusInput.type = "number"; + radiusInput.step = "any"; + radiusInput.value = formatSpatialSkeletonEditableNumber( + fullNodeInfo.radius, + ); + appendValue("Radius", radiusInput); + let radiusSavePending = false; + const getParsedRadius = () => { + const radius = Number(radiusInput.value); + return { + radius, + radiusValid: Number.isFinite(radius), + }; + }; + const updateRadiusEditorState = () => { + const disabledReason = radiusEditingDisabledReason(); + const { radiusValid } = getParsedRadius(); + radiusInput.disabled = + disabledReason !== undefined || radiusSavePending; + setPropertyInputValidity( + radiusInput, + radiusValid, + "Radius must be a finite number.", + disabledReason, + ); + }; + const resetRadiusInput = () => { + radiusInput.value = + formatSpatialSkeletonEditableNumber(committedRadius); + updateRadiusEditorState(); + }; + const commitRadius = () => { + if (radiusSavePending) return; + const disabledReason = radiusEditingDisabledReason(); + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + resetRadiusInput(); + return; + } + const { radius, radiusValid } = getParsedRadius(); + if (!radiusValid) { + StatusMessage.showTemporaryMessage("Radius must be a finite number."); + resetRadiusInput(); + return; + } + if (radius === committedRadius) { + resetRadiusInput(); + return; + } + radiusSavePending = true; + updateRadiusEditorState(); + void (async () => { + try { + await executeSpatialSkeletonNodeRadiusUpdate(this, { + node: getCachedNodeForPropertyEdit(), + nextRadius: radius, + }); + committedRadius = radius; + resetRadiusInput(); + } catch (error) { + showSpatialSkeletonActionError("update node radius", error); + resetRadiusInput(); + } finally { + radiusSavePending = false; + updateRadiusEditorState(); + } + })(); + }; + radiusInput.addEventListener("input", updateRadiusEditorState); + radiusInput.addEventListener("keydown", handlePropertyInputKeyDown); + radiusInput.addEventListener("change", commitRadius); + updateRadiusEditorState(); + } + + const confidenceConfigurationValues = confidenceConfiguration?.values; + if ( + confidenceEditingDisabledReason() !== undefined || + confidenceConfigurationValues === undefined + ) { appendValue( "Confidence level", formatSpatialSkeletonEditableNumber( @@ -2835,22 +2967,13 @@ export class SegmentationUserLayer extends Base { ), ); } else { - let committedRadius = fullNodeInfo.radius ?? 0; let committedConfidence = fullNodeInfo.confidence !== undefined && Number.isFinite(fullNodeInfo.confidence) ? Number(fullNodeInfo.confidence) : 0; - const radiusInput = document.createElement("input"); - radiusInput.className = "neuroglancer-spatial-skeleton-properties-input"; - radiusInput.type = "number"; - radiusInput.step = "any"; - radiusInput.value = formatSpatialSkeletonEditableNumber( - fullNodeInfo.radius, - ); - appendValue("Radius", radiusInput); const supportedConfidenceValues = Array.from( - new Set([...confidenceCapabilityValues!, committedConfidence]), + new Set([...confidenceConfigurationValues, committedConfidence]), ).filter((value): value is number => Number.isFinite(value)); const confidenceSelectValues = Array.from( new Set([...supportedConfidenceValues, committedConfidence]), @@ -2866,38 +2989,7 @@ export class SegmentationUserLayer extends Base { } confidenceControl.value = committedConfidence.toString(); appendValue("Confidence level", confidenceControl); - let savePending = false; - const getPropertyEditingDisabledReason = () => - editSource === undefined - ? "Unable to resolve editable skeleton source for the active layer." - : this.getSpatialSkeletonActionsDisabledReason( - SpatialSkeletonActions.editNodeProperties, - ); - const getConfidenceEditingDisabledReason = () => { - const disabledReason = getPropertyEditingDisabledReason(); - if (disabledReason !== undefined) { - return disabledReason; - } - return undefined; - }; - const setPropertyInputValidity = ( - input: HTMLInputElement | HTMLSelectElement, - valid: boolean, - invalidTitle: string, - disabledReason: string | undefined, - ) => { - input.classList.toggle( - "neuroglancer-spatial-skeleton-properties-input-invalid", - !valid, - ); - if (disabledReason !== undefined) { - input.title = disabledReason; - } else if (!valid) { - input.title = invalidTitle; - } else { - input.removeAttribute("title"); - } - }; + let confidenceSavePending = false; const getConfidenceValidationError = (confidence: number) => { if (!Number.isFinite(confidence)) { return "Confidence must be a finite number."; @@ -2906,34 +2998,21 @@ export class SegmentationUserLayer extends Base { ? undefined : "Confidence must use one of the supported values."; }; - const getParsedProperties = () => { - const radius = Number(radiusInput.value); + const getParsedConfidence = () => { const confidence = Number(confidenceControl.value); - const radiusValid = Number.isFinite(radius); const confidenceInvalidTitle = getConfidenceValidationError(confidence); return { - radius, confidence, - radiusValid, confidenceValid: confidenceInvalidTitle === undefined, confidenceInvalidTitle, }; }; - const updatePropertyEditorState = () => { - const radiusDisabledReason = getPropertyEditingDisabledReason(); - const confidenceDisabledReason = getConfidenceEditingDisabledReason(); - const { radiusValid, confidenceValid, confidenceInvalidTitle } = - getParsedProperties(); - radiusInput.disabled = - radiusDisabledReason !== undefined || savePending; + const updateConfidenceEditorState = () => { + const confidenceDisabledReason = confidenceEditingDisabledReason(); + const { confidenceValid, confidenceInvalidTitle } = + getParsedConfidence(); confidenceControl.disabled = - confidenceDisabledReason !== undefined || savePending; - setPropertyInputValidity( - radiusInput, - radiusValid, - "Radius must be a finite number.", - radiusDisabledReason, - ); + confidenceDisabledReason !== undefined || confidenceSavePending; setPropertyInputValidity( confidenceControl, confidenceValid, @@ -2941,78 +3020,53 @@ export class SegmentationUserLayer extends Base { confidenceDisabledReason, ); }; - const resetPropertyInputs = () => { - radiusInput.value = - formatSpatialSkeletonEditableNumber(committedRadius); + const resetConfidenceInput = () => { confidenceControl.value = committedConfidence.toString(); - updatePropertyEditorState(); + updateConfidenceEditorState(); }; - const handlePropertyInputKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Enter") return; - event.preventDefault(); - (event.currentTarget as HTMLElement | null)?.blur(); - }; - const commitProperties = () => { - if (savePending) return; - const disabledReason = getPropertyEditingDisabledReason(); + const commitConfidence = () => { + if (confidenceSavePending) return; + const disabledReason = confidenceEditingDisabledReason(); if (disabledReason !== undefined) { StatusMessage.showTemporaryMessage(disabledReason); - resetPropertyInputs(); + resetConfidenceInput(); return; } - const { - radius, - confidence, - radiusValid, - confidenceValid, - confidenceInvalidTitle, - } = getParsedProperties(); - if (!radiusValid || !confidenceValid) { + const { confidence, confidenceValid, confidenceInvalidTitle } = + getParsedConfidence(); + if (!confidenceValid) { StatusMessage.showTemporaryMessage( - confidenceInvalidTitle ?? "Enter a valid radius and confidence.", + confidenceInvalidTitle ?? "Confidence is invalid.", ); - resetPropertyInputs(); + resetConfidenceInput(); return; } - const radiusChanged = radius !== committedRadius; const confidenceChanged = confidence !== committedConfidence; - if (!radiusChanged && !confidenceChanged) { - resetPropertyInputs(); + if (!confidenceChanged) { + resetConfidenceInput(); return; } - savePending = true; - updatePropertyEditorState(); + confidenceSavePending = true; + updateConfidenceEditorState(); void (async () => { try { - const currentNode = this.spatialSkeletonState.getCachedNode( - fullNodeInfo.nodeId, - ); - if (currentNode === undefined) { - throw new Error( - `Node ${fullNodeInfo.nodeId} is missing from the inspected skeleton cache.`, - ); - } - await executeSpatialSkeletonNodePropertiesUpdate(this, { - node: currentNode, - next: { radius, confidence }, + await executeSpatialSkeletonNodeConfidenceUpdate(this, { + node: getCachedNodeForPropertyEdit(), + nextConfidence: confidence, }); - committedRadius = radius; committedConfidence = confidence; - resetPropertyInputs(); + resetConfidenceInput(); } catch (error) { - showSpatialSkeletonActionError("update node properties", error); - resetPropertyInputs(); + showSpatialSkeletonActionError("update node confidence", error); + resetConfidenceInput(); } finally { - savePending = false; - updatePropertyEditorState(); + confidenceSavePending = false; + updateConfidenceEditorState(); } })(); }; - radiusInput.addEventListener("input", updatePropertyEditorState); - radiusInput.addEventListener("keydown", handlePropertyInputKeyDown); - radiusInput.addEventListener("change", commitProperties); - confidenceControl.addEventListener("change", commitProperties); - updatePropertyEditorState(); + confidenceControl.addEventListener("change", commitConfidence); + updateConfidenceEditorState(); } const descriptionText = cachedNodeInfo?.description ?? completeNodeInfo?.description ?? ""; diff --git a/src/layer/segmentation/spatial_skeleton_commands.spec.ts b/src/layer/segmentation/spatial_skeleton_commands.spec.ts index 1a51d1fc1..ef77a4428 100644 --- a/src/layer/segmentation/spatial_skeleton_commands.spec.ts +++ b/src/layer/segmentation/spatial_skeleton_commands.spec.ts @@ -8,7 +8,9 @@ import { executeSpatialSkeletonDeleteNode, executeSpatialSkeletonMerge, executeSpatialSkeletonMoveNode, + executeSpatialSkeletonNodeConfidenceUpdate, executeSpatialSkeletonNodeDescriptionUpdate, + executeSpatialSkeletonNodeRadiusUpdate, executeSpatialSkeletonSplit, redoSpatialSkeletonCommand, undoSpatialSkeletonCommand, @@ -93,7 +95,6 @@ function makeCatmaidClient(overrides: Record = {}) { function makeCatmaidEditCommands(client = makeCatmaidClient()) { return new CatmaidSpatialSkeletonEditCommands({ - ensureEditable: vi.fn(), getClient: () => client as any, }); } @@ -118,7 +119,8 @@ function makeEditableSkeletonSource(overrides: Record = {}) { rerootCommand: commands.rerootCommand, editNodeDescriptionCommand: commands.editNodeDescriptionCommand, editNodeTrueEndCommand: commands.editNodeTrueEndCommand, - editNodePropertiesCommand: commands.editNodePropertiesCommand, + editNodeRadiusCommand: commands.editNodeRadiusCommand, + editNodeConfidenceCommand: commands.editNodeConfidenceCommand, mergeSkeletonsCommand: commands.mergeSkeletonsCommand, splitSkeletonsCommand: commands.splitSkeletonsCommand, listSkeletons: vi.fn(), @@ -266,6 +268,12 @@ describe("spatial_skeleton_commands", () => { expect(commandSource.moveNodesCommand.action).toBe( SpatialSkeletonActions.moveNodes, ); + expect(commandSource.editNodeRadiusCommand.action).toBe( + SpatialSkeletonActions.editNodeRadius, + ); + expect(commandSource.editNodeConfidenceCommand.action).toBe( + SpatialSkeletonActions.editNodeConfidence, + ); expect((commandSource as any).inspectCommand).toBeUndefined(); }); @@ -306,6 +314,103 @@ describe("spatial_skeleton_commands", () => { ).toThrow("CATMAID move-node command received an invalid payload."); }); + it("commits radius and confidence commands independently", async () => { + suppressStatusMessages(); + + const node: SpatiallyIndexedSkeletonNode = { + nodeId: 17, + segmentId: 23, + position: new Float32Array([1, 2, 3]), + radius: 4, + confidence: 50, + sourceState: testSourceState("before"), + }; + let cachedNode = node; + const updateRadius = vi.fn().mockResolvedValue({ + sourceState: testSourceState("after-radius"), + }); + const updateConfidence = vi.fn().mockResolvedValue({ + sourceState: testSourceState("after-confidence"), + }); + const skeletonLayer = { + source: makeEditableSkeletonSource({ updateRadius, updateConfidence }), + getNode: vi.fn((nodeId: number) => + nodeId === cachedNode.nodeId ? cachedNode : undefined, + ), + invalidateSourceCaches: vi.fn(), + }; + const commandHistory = new SpatialSkeletonCommandHistory(); + const setNodeRadius = vi.fn((nodeId: number, radius: number) => { + if (nodeId === cachedNode.nodeId) { + cachedNode = { ...cachedNode, radius }; + } + }); + const setNodeConfidence = vi.fn((nodeId: number, confidence: number) => { + if (nodeId === cachedNode.nodeId) { + cachedNode = { ...cachedNode, confidence }; + } + }); + const setCachedNodeSourceState = vi.fn( + (nodeId: number, sourceState: unknown) => { + if (nodeId === cachedNode.nodeId) { + cachedNode = { ...cachedNode, sourceState: sourceState as any }; + } + }, + ); + const markSpatialSkeletonNodeDataChanged = vi.fn(); + const layer = { + spatialSkeletonState: { + commandHistory, + getCachedNode: vi.fn((nodeId: number) => + nodeId === cachedNode.nodeId ? cachedNode : undefined, + ), + getCachedSegmentNodes: vi.fn((segmentId: number) => + segmentId === cachedNode.segmentId ? [cachedNode] : undefined, + ), + setNodeRadius, + setNodeConfidence, + setCachedNodeSourceState, + }, + getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, + markSpatialSkeletonNodeDataChanged, + }; + + await executeSpatialSkeletonNodeRadiusUpdate(layer as any, { + node: cachedNode, + nextRadius: 6, + }); + await executeSpatialSkeletonNodeConfidenceUpdate(layer as any, { + node: cachedNode, + nextConfidence: 75, + }); + + expect(updateRadius).toHaveBeenCalledWith( + 17, + 6, + expect.objectContaining({ + node: expect.objectContaining({ nodeId: 17 }), + }), + ); + expect(updateConfidence).toHaveBeenCalledWith( + 17, + 75, + expect.objectContaining({ + node: expect.objectContaining({ nodeId: 17 }), + }), + ); + expect(setNodeRadius).toHaveBeenCalledWith(17, 6); + expect(setNodeConfidence).toHaveBeenCalledWith(17, 75); + expect(setCachedNodeSourceState).toHaveBeenCalledWith( + 17, + testSourceState("after-radius"), + ); + expect(setCachedNodeSourceState).toHaveBeenCalledWith( + 17, + testSourceState("after-confidence"), + ); + expect(markSpatialSkeletonNodeDataChanged).toHaveBeenCalledTimes(2); + }); + it("commits move-node commands using model-space positions", async () => { suppressStatusMessages(); diff --git a/src/layer/segmentation/spatial_skeleton_commands.ts b/src/layer/segmentation/spatial_skeleton_commands.ts index 6e3a542dd..e8336e2e4 100644 --- a/src/layer/segmentation/spatial_skeleton_commands.ts +++ b/src/layer/segmentation/spatial_skeleton_commands.ts @@ -70,8 +70,10 @@ export function getSpatialSkeletonEditCommandFactory( return source.editNodeDescriptionCommand; case SpatialSkeletonActions.editNodeTrueEnd: return source.editNodeTrueEndCommand; - case SpatialSkeletonActions.editNodeProperties: - return source.editNodePropertiesCommand; + case SpatialSkeletonActions.editNodeRadius: + return source.editNodeRadiusCommand; + case SpatialSkeletonActions.editNodeConfidence: + return source.editNodeConfidenceCommand; case SpatialSkeletonActions.mergeSkeletons: return source.mergeSkeletonsCommand; case SpatialSkeletonActions.splitSkeletons: @@ -200,15 +202,28 @@ export function executeSpatialSkeletonNodeTrueEndUpdate( return executeCommand(layer, command); } -export function executeSpatialSkeletonNodePropertiesUpdate( +export function executeSpatialSkeletonNodeRadiusUpdate( layer: SegmentationUserLayer, options: SpatialSkeletonCommandPayload, ) { const command = createSpatialSkeletonCommand( layer, - SpatialSkeletonActions.editNodeProperties, + SpatialSkeletonActions.editNodeRadius, options, - "The active skeleton source does not support node property editing.", + "The active skeleton source does not support node radius editing.", + ); + return executeCommand(layer, command); +} + +export function executeSpatialSkeletonNodeConfidenceUpdate( + layer: SegmentationUserLayer, + options: SpatialSkeletonCommandPayload, +) { + const command = createSpatialSkeletonCommand( + layer, + SpatialSkeletonActions.editNodeConfidence, + options, + "The active skeleton source does not support node confidence editing.", ); return executeCommand(layer, command); } diff --git a/src/skeleton/actions.ts b/src/skeleton/actions.ts index a21efe2e0..e278567de 100644 --- a/src/skeleton/actions.ts +++ b/src/skeleton/actions.ts @@ -23,7 +23,8 @@ export const SpatialSkeletonActions = { reroot: "rerootSkeletons", editNodeDescription: "editNodeDescription", editNodeTrueEnd: "editNodeTrueEnd", - editNodeProperties: "editNodeProperties", + editNodeRadius: "editNodeRadius", + editNodeConfidence: "editNodeConfidence", mergeSkeletons: "mergeSkeletons", splitSkeletons: "splitSkeletons", } as const; @@ -57,8 +58,10 @@ export function getSpatialSkeletonActionSupportLabel( return "node description editing"; case SpatialSkeletonActions.editNodeTrueEnd: return "node true-end editing"; - case SpatialSkeletonActions.editNodeProperties: - return "node property editing"; + case SpatialSkeletonActions.editNodeRadius: + return "node radius editing"; + case SpatialSkeletonActions.editNodeConfidence: + return "node confidence editing"; case SpatialSkeletonActions.mergeSkeletons: return "skeleton merging"; case SpatialSkeletonActions.splitSkeletons: diff --git a/src/skeleton/api.ts b/src/skeleton/api.ts index 3d4e6ad28..cc04440b5 100644 --- a/src/skeleton/api.ts +++ b/src/skeleton/api.ts @@ -18,7 +18,8 @@ import type { SpatialSkeletonAddNodesCommandFactory, SpatialSkeletonDeleteNodesCommandFactory, SpatialSkeletonEditNodeDescriptionCommandFactory, - SpatialSkeletonEditNodePropertiesCommandFactory, + SpatialSkeletonEditNodeConfidenceCommandFactory, + SpatialSkeletonEditNodeRadiusCommandFactory, SpatialSkeletonEditNodeTrueEndCommandFactory, SpatialSkeletonInsertNodesCommandFactory, SpatialSkeletonMergeSkeletonsCommandFactory, @@ -75,15 +76,8 @@ export interface SpatiallyIndexedSkeletonMetadata readOnly: boolean; } -export interface SpatialSkeletonNodeFeatureCapabilities { - description?: boolean; - trueEnd?: boolean; - radius?: boolean; - confidenceValues?: readonly number[]; -} - -export interface SpatialSkeletonEditCapabilities { - nodeFeatures?: SpatialSkeletonNodeFeatureCapabilities; +export interface SpatialSkeletonConfidenceConfiguration { + values: readonly number[]; } export interface SpatiallyIndexedSkeletonSource { @@ -105,7 +99,6 @@ export interface SpatiallyIndexedSkeletonSource { export interface EditableSpatiallyIndexedSkeletonSource extends SpatiallyIndexedSkeletonSource { readonly readOnly: false; - readonly spatialSkeletonEditCapabilities?: SpatialSkeletonEditCapabilities; readonly addNodesCommand: SpatialSkeletonAddNodesCommandFactory; readonly deleteNodesCommand: SpatialSkeletonDeleteNodesCommandFactory; readonly moveNodesCommand: SpatialSkeletonMoveNodesCommandFactory; @@ -115,5 +108,7 @@ export interface EditableSpatiallyIndexedSkeletonSource readonly rerootCommand?: SpatialSkeletonRerootCommandFactory; readonly editNodeDescriptionCommand?: SpatialSkeletonEditNodeDescriptionCommandFactory; readonly editNodeTrueEndCommand?: SpatialSkeletonEditNodeTrueEndCommandFactory; - readonly editNodePropertiesCommand?: SpatialSkeletonEditNodePropertiesCommandFactory; + readonly editNodeRadiusCommand?: SpatialSkeletonEditNodeRadiusCommandFactory; + readonly editNodeConfidenceCommand?: SpatialSkeletonEditNodeConfidenceCommandFactory; + readonly spatialSkeletonConfidenceConfiguration?: SpatialSkeletonConfidenceConfiguration; } diff --git a/src/skeleton/edit_command_source.ts b/src/skeleton/edit_command_source.ts index d79fbf029..2f0803157 100644 --- a/src/skeleton/edit_command_source.ts +++ b/src/skeleton/edit_command_source.ts @@ -74,9 +74,13 @@ export type SpatialSkeletonEditNodeTrueEndCommandFactory = SpatialSkeletonEditCommandFactory< typeof SpatialSkeletonActions.editNodeTrueEnd >; -export type SpatialSkeletonEditNodePropertiesCommandFactory = +export type SpatialSkeletonEditNodeRadiusCommandFactory = SpatialSkeletonEditCommandFactory< - typeof SpatialSkeletonActions.editNodeProperties + typeof SpatialSkeletonActions.editNodeRadius + >; +export type SpatialSkeletonEditNodeConfidenceCommandFactory = + SpatialSkeletonEditCommandFactory< + typeof SpatialSkeletonActions.editNodeConfidence >; export type SpatialSkeletonMergeSkeletonsCommandFactory = SpatialSkeletonEditCommandFactory< diff --git a/src/skeleton/spatial_skeleton_manager.spec.ts b/src/skeleton/spatial_skeleton_manager.spec.ts index 81aa30361..b98a697df 100644 --- a/src/skeleton/spatial_skeleton_manager.spec.ts +++ b/src/skeleton/spatial_skeleton_manager.spec.ts @@ -107,6 +107,36 @@ describe("skeleton/spatial_skeleton_manager", () => { expect(getEditableSpatiallyIndexedSkeletonSource({ source })).toBe(source); }); + it("validates optional confidence configuration for editable sources", () => { + const source = { + ...makeEditableSourceCommands(), + editNodeConfidenceCommand: makeCommandFactory( + SpatialSkeletonActions.editNodeConfidence, + ), + spatialSkeletonConfidenceConfiguration: { + values: [0, 50, 100], + }, + readOnly: false, + listSkeletons: async () => [], + getSkeleton: async () => [], + fetchNodes: async () => [], + getSpatialIndexMetadata: async () => null, + }; + + expect(getEditableSpatiallyIndexedSkeletonSource({ source })).toBe(source); + + expect( + getEditableSpatiallyIndexedSkeletonSource({ + source: { + ...source, + spatialSkeletonConfidenceConfiguration: { + values: [0, Number.NaN, 100], + }, + }, + }), + ).toBeUndefined(); + }); + it("does not treat a read-only source with edit commands as editable", () => { const source = { ...makeEditableSourceCommands(), @@ -575,7 +605,7 @@ describe("skeleton/spatial_skeleton_manager", () => { expect(state.mergeAnchorNodeId.value).toBeUndefined(); }); - it("stores provided confidence when setting properties", () => { + it("stores provided radius and confidence independently", () => { const state = new SpatialSkeletonState(); (state as any).replaceCachedSegmentNodes(11, [ { @@ -588,9 +618,8 @@ describe("skeleton/spatial_skeleton_manager", () => { }, ]); - expect(state.setNodeProperties(1, { radius: 6, confidence: 63 })).toBe( - true, - ); + expect(state.setNodeRadius(1, 6)).toBe(true); + expect(state.setNodeConfidence(1, 63)).toBe(true); expect(state.getCachedNode(1)).toMatchObject({ radius: 6, confidence: 63, diff --git a/src/skeleton/spatial_skeleton_manager.ts b/src/skeleton/spatial_skeleton_manager.ts index 90bed6649..59b33a78d 100644 --- a/src/skeleton/spatial_skeleton_manager.ts +++ b/src/skeleton/spatial_skeleton_manager.ts @@ -16,6 +16,7 @@ import type { EditableSpatiallyIndexedSkeletonSource, + SpatialSkeletonConfidenceConfiguration, SpatiallyIndexedSkeletonNode, SpatialSkeletonSourceState, SpatiallyIndexedSkeletonSource, @@ -74,6 +75,34 @@ function hasOptionalCommandFactory( ); } +function isFiniteNumberArray(value: unknown): value is readonly number[] { + return ( + Array.isArray(value) && + value.every((entry) => typeof entry === "number" && Number.isFinite(entry)) + ); +} + +function isSpatialSkeletonConfidenceConfiguration( + value: unknown, +): value is SpatialSkeletonConfidenceConfiguration { + return ( + typeof value === "object" && + value !== null && + isFiniteNumberArray(getProperty(value, "values")) + ); +} + +function hasOptionalConfidenceConfiguration(value: unknown) { + const configuration = getProperty( + value, + "spatialSkeletonConfidenceConfiguration", + ); + return ( + configuration === undefined || + isSpatialSkeletonConfidenceConfiguration(configuration) + ); +} + export function isSpatiallyIndexedSkeletonSource( value: unknown, ): value is SpatiallyIndexedSkeletonSource { @@ -139,9 +168,15 @@ export function isEditableSpatiallyIndexedSkeletonSource( ) && hasOptionalCommandFactory( value, - "editNodePropertiesCommand", - SpatialSkeletonActions.editNodeProperties, - ) + "editNodeRadiusCommand", + SpatialSkeletonActions.editNodeRadius, + ) && + hasOptionalCommandFactory( + value, + "editNodeConfidenceCommand", + SpatialSkeletonActions.editNodeConfidence, + ) && + hasOptionalConfidenceConfiguration(value) ); } @@ -260,27 +295,35 @@ export class SpatialSkeletonState extends RefCounted { >(); private cachedNodesById = new Map(); - setNodeProperties( - nodeId: number, - properties: { radius: number; confidence: number }, - ) { + setNodeRadius(nodeId: number, radius: number) { const normalizedNodeId = this.normalizeNodeId(nodeId); - const radius = Number(properties.radius); - const confidence = Number(properties.confidence); - if ( - normalizedNodeId === undefined || - !Number.isFinite(radius) || - !Number.isFinite(confidence) - ) { + radius = Number(radius); + if (normalizedNodeId === undefined || !Number.isFinite(radius)) { return false; } return this.updateCachedNode(normalizedNodeId, (node) => { - if (node.radius === radius && node.confidence === confidence) { + if (node.radius === radius) { return node; } return { ...node, radius, + }; + }); + } + + setNodeConfidence(nodeId: number, confidence: number) { + const normalizedNodeId = this.normalizeNodeId(nodeId); + confidence = Number(confidence); + if (normalizedNodeId === undefined || !Number.isFinite(confidence)) { + return false; + } + return this.updateCachedNode(normalizedNodeId, (node) => { + if (node.confidence === confidence) { + return node; + } + return { + ...node, confidence, }; }); diff --git a/src/ui/spatial_skeleton_edit_tool.spec.ts b/src/ui/spatial_skeleton_edit_tool.spec.ts index 9cd4e1333..5cc53db60 100644 --- a/src/ui/spatial_skeleton_edit_tool.spec.ts +++ b/src/ui/spatial_skeleton_edit_tool.spec.ts @@ -85,7 +85,6 @@ function makeEditableSkeletonSource(overrides: Record = {}) { } const client = makeCatmaidClient(clientOverrides); const commands = new CatmaidSpatialSkeletonEditCommands({ - ensureEditable: vi.fn(), getClient: () => client as any, }); return { @@ -97,7 +96,8 @@ function makeEditableSkeletonSource(overrides: Record = {}) { rerootCommand: commands.rerootCommand, editNodeDescriptionCommand: commands.editNodeDescriptionCommand, editNodeTrueEndCommand: commands.editNodeTrueEndCommand, - editNodePropertiesCommand: commands.editNodePropertiesCommand, + editNodeRadiusCommand: commands.editNodeRadiusCommand, + editNodeConfidenceCommand: commands.editNodeConfidenceCommand, mergeSkeletonsCommand: commands.mergeSkeletonsCommand, splitSkeletonsCommand: commands.splitSkeletonsCommand, listSkeletons: vi.fn(), From ec43d1eb9201072edb714328f102968e8248b8fd Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Wed, 6 May 2026 21:45:17 +0100 Subject: [PATCH 11/11] hotfix: Force second request to catmaid to get live tags only --- src/datasource/catmaid/api.spec.ts | 188 +++++++++++++++++++---------- src/datasource/catmaid/api.ts | 17 ++- 2 files changed, 137 insertions(+), 68 deletions(-) diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index bb0812698..529f4fa7e 100644 --- a/src/datasource/catmaid/api.spec.ts +++ b/src/datasource/catmaid/api.spec.ts @@ -218,72 +218,83 @@ describe("CatmaidClient skeleton editing methods", () => { }); }); - it("parses live compact-detail history rows and label maps", async () => { + it("parses live compact-detail history rows and current label maps", async () => { const client = new CatmaidClient("https://example.invalid", 1); - const fetchMock = vi.fn().mockResolvedValue([ - [ - [ - 22107946, - null, - 2, - 23697030.0, - 15055839.0, - 16651262.0, - 2000.0, - 5, - "2026-03-29T10:15:00Z", - "2026-03-29T10:15:00Z", - ], - [ - 22107946, - null, - 2, - 23697030.0, - 15055839.0, - 16651262.0, - 2000.0, - 5, - "2026-03-28T08:00:00Z", - "2026-03-29T10:15:00Z", - ], - [ - 22107955, - 22107954, - 2, - 23705874.0, - 15093672.0, - 16682375.0, - 2000.0, - 5, - "2026-03-29T10:16:00Z", - "2026-03-29T10:15:00Z", - ], + const fetchMock = vi + .fn() + .mockResolvedValueOnce([ [ - 22107959, - 22107958, - 2, - 23704520.0, - 15085237.0, - 16708998.0, - 2000.0, - 5, - "2026-03-29T10:17:00Z", - "2026-03-29T10:16:00Z", - ], - ], - [], - { - "afonso reviewed it": [22107946], - "test 123 4": [ - [22107955, "2026-03-29 10:16:00.000000+00:00"], - [22107955, "2026-03-29 10:15:30.000000+00:00"], + [ + 22107946, + null, + 2, + 23697030.0, + 15055839.0, + 16651262.0, + 2000.0, + 5, + "2026-03-29T10:15:00Z", + "2026-03-29T10:15:00Z", + ], + [ + 22107946, + null, + 2, + 23697030.0, + 15055839.0, + 16651262.0, + 2000.0, + 5, + "2026-03-28T08:00:00Z", + "2026-03-29T10:15:00Z", + ], + [ + 22107955, + 22107954, + 2, + 23705874.0, + 15093672.0, + 16682375.0, + 2000.0, + 5, + "2026-03-29T10:16:00Z", + "2026-03-29T10:15:00Z", + ], + [ + 22107959, + 22107958, + 2, + 23704520.0, + 15085237.0, + 16708998.0, + 2000.0, + 5, + "2026-03-29T10:17:00Z", + "2026-03-29T10:16:00Z", + ], ], - "stale description": [[22107955, "2026-03-29 10:15:45.000000+00:00"]], - ends: [[22107959, "2026-03-29 10:17:00.000000+00:00"]], - }, - [], - [], - ]); + [], + {}, + [], + [], + ]) + .mockResolvedValueOnce([ + [], + [], + { + "afonso reviewed it": [22107946], + "test 123 4": [ + [22107955, "2026-03-29 10:16:00.000000+00:00"], + [22107955, "2026-03-29 10:15:30.000000+00:00"], + ], + "stale description": [ + [22107955, "2026-03-29 10:15:45.000000+00:00"], + ], + ends: [[22107959, "2026-03-29 10:17:00.000000+00:00"]], + }, + [], + [], + ]); (client as any).fetch = fetchMock; await expect(client.getSkeleton(2)).resolves.toEqual([ @@ -321,6 +332,57 @@ describe("CatmaidClient skeleton editing methods", () => { sourceState: testSourceState("2026-03-29T10:17:00Z"), }, ]); + expect(getFetchPath(fetchMock, 0)).toBe( + "skeletons/2/compact-detail?with_tags=true&with_history=true", + ); + expect(getFetchPath(fetchMock, 1)).toBe( + "skeletons/2/compact-detail?with_tags=true", + ); + }); + + it("ignores historical compact-detail labels that are not current", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi + .fn() + .mockResolvedValueOnce([ + [ + [ + 23218380, + null, + 1, + 24233266, + 13917594, + 15605623, + 0, + 5, + "2026-05-06 20:17:31.181383+00:00", + "2026-04-20 14:56:29.593124+00:00", + 1, + ], + ], + [], + { + ends: [[23218380, "2026-04-22 15:11:58.824455+00:00"]], + }, + [], + [], + ]) + .mockResolvedValueOnce([[], [], {}, [], []]); + (client as any).fetch = fetchMock; + + await expect(client.getSkeleton(2974940)).resolves.toEqual([ + { + nodeId: 23218380, + parentNodeId: undefined, + position: new Float32Array([24233266, 13917594, 15605623]), + segmentId: 2974940, + radius: 0, + confidence: 100, + description: undefined, + isTrueEnd: false, + sourceState: testSourceState("2026-05-06 20:17:31.181383+00:00"), + }, + ]); }); it("ignores zero-width history rows when compact-detail includes ordering", async () => { diff --git a/src/datasource/catmaid/api.ts b/src/datasource/catmaid/api.ts index 1104b664e..c5317f64c 100644 --- a/src/datasource/catmaid/api.ts +++ b/src/datasource/catmaid/api.ts @@ -1339,12 +1339,19 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { skeletonId: number, options: { signal?: AbortSignal } = {}, ): Promise { + const { signal } = options; let data: any; + let currentData: any; try { - data = await this.fetch( - `skeletons/${skeletonId}/compact-detail?with_tags=true&with_history=true`, - { signal: options.signal }, - ); + [data, currentData] = await Promise.all([ + this.fetch( + `skeletons/${skeletonId}/compact-detail?with_tags=true&with_history=true`, + { signal }, + ), + this.fetch(`skeletons/${skeletonId}/compact-detail?with_tags=true`, { + signal, + }), + ]); } catch (error) { if (error instanceof CatmaidNotFoundError) { return []; @@ -1353,7 +1360,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { } } const rawNodes = Array.isArray(data?.[0]) ? data[0] : []; - const labelsByNodeId = parseCatmaidNodeLabels(data?.[2]); + const labelsByNodeId = parseCatmaidNodeLabels(currentData?.[2]); const descriptionByNodeId = getCatmaidNodeDescriptions(labelsByNodeId); const trueEndByNodeId = getCatmaidTrueEndNodes(labelsByNodeId); const liveNodes = new Map();