diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index 03e1fa60b..8648cba0e 100644 --- a/src/datasource/catmaid/api.spec.ts +++ b/src/datasource/catmaid/api.spec.ts @@ -845,6 +845,40 @@ describe("CatmaidClient skeleton editing methods", () => { ); }); + it("deletes skeletons without allowing multi-skeleton neuron deletion", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.fn().mockResolvedValue({ + skeleton_ids: ["17", 21.2, "invalid"], + }); + (client as any).fetchProjectEndpoint = fetchMock; + + await expect(client.deleteSkeleton(17)).resolves.toEqual({ + skeletonIds: [17, 21], + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(getFetchPath(fetchMock)).toBe("skeletons/17/delete"); + expect(getFetchBody(fetchMock).get("delete_multi_skeleton_neurons")).toBe( + "false", + ); + }); + + it("restores deleted skeletons", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.fn().mockResolvedValue({ + skeleton_id: "17", + }); + (client as any).fetchProjectEndpoint = fetchMock; + + await expect(client.restoreSkeleton(17)).resolves.toEqual({ + skeletonId: 17, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(getFetchPath(fetchMock)).toBe("skeletons/17/restore"); + expect(getFetchInit(fetchMock).method).toBe("POST"); + }); + it("rejects reroot when the response is missing edition_time", 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 eafb36556..4bb378cc2 100644 --- a/src/datasource/catmaid/api.ts +++ b/src/datasource/catmaid/api.ts @@ -120,6 +120,18 @@ export interface CatmaidDescriptionUpdateOptions { export type CatmaidDeleteNodeResult = CatmaidSkeletonEditResult; +export interface CatmaidDeleteSkeletonOptions { + deleteMultiSkeletonNeurons?: boolean; +} + +export interface CatmaidDeleteSkeletonResult { + skeletonIds: number[]; +} + +export interface CatmaidRestoreSkeletonResult { + skeletonId: number; +} + export type CatmaidRerootResult = CatmaidSkeletonEditResult; export interface CatmaidMergeResult extends CatmaidSkeletonEditResult { @@ -149,6 +161,11 @@ export interface CatmaidSpatialSkeletonEditApi { nodeId: number, options: CatmaidDeleteNodeOptions, ): Promise; + deleteSkeleton( + skeletonId: number, + options?: CatmaidDeleteSkeletonOptions, + ): Promise; + restoreSkeleton(skeletonId: number): Promise; moveNode( nodeId: number, x: number, @@ -2064,4 +2081,45 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { : undefined, }; } + + async deleteSkeleton( + skeletonId: number, + options: CatmaidDeleteSkeletonOptions = {}, + ): Promise { + const body = new URLSearchParams({ + delete_multi_skeleton_neurons: String( + options.deleteMultiSkeletonNeurons ?? false, + ), + }); + const response = await this.fetchProjectEndpoint( + `skeletons/${skeletonId}/delete`, + { + method: "POST", + body, + }, + ); + const skeletonIds = Array.isArray(response?.skeleton_ids) + ? response.skeleton_ids + .map((value: unknown) => Math.round(Number(value))) + .filter((value: number) => Number.isSafeInteger(value) && value > 0) + : []; + return { skeletonIds }; + } + + async restoreSkeleton( + skeletonId: number, + ): Promise { + const response = await this.fetchProjectEndpoint( + `skeletons/${skeletonId}/restore`, + { + method: "POST", + }, + ); + const restoredSkeletonId = Number(response?.skeleton_id); + return { + skeletonId: Number.isFinite(restoredSkeletonId) + ? Math.round(restoredSkeletonId) + : skeletonId, + }; + } } diff --git a/src/datasource/catmaid/frontend.ts b/src/datasource/catmaid/frontend.ts index 58b1b94b4..8d294f4bb 100644 --- a/src/datasource/catmaid/frontend.ts +++ b/src/datasource/catmaid/frontend.ts @@ -119,6 +119,10 @@ export class CatmaidSpatiallyIndexedSkeletonSource extends WithParameters( return this.editableSpatialSkeletonEditCommands?.deleteNodesCommand; } + get deleteSubtreesCommand() { + return this.editableSpatialSkeletonEditCommands?.deleteSubtreesCommand; + } + get rerootCommand() { return this.editableSpatialSkeletonEditCommands?.rerootCommand; } diff --git a/src/datasource/catmaid/spatial_skeleton_commands.ts b/src/datasource/catmaid/spatial_skeleton_commands.ts index 78e43a300..4a5356f9f 100644 --- a/src/datasource/catmaid/spatial_skeleton_commands.ts +++ b/src/datasource/catmaid/spatial_skeleton_commands.ts @@ -31,6 +31,8 @@ import type { CatmaidSpatialSkeletonConfidenceUpdateRequest, CatmaidSpatialSkeletonDeleteNodeRequest, CatmaidSpatialSkeletonDeleteNodeResult, + CatmaidSpatialSkeletonDeleteSkeletonRequest, + CatmaidSpatialSkeletonDeleteSkeletonResult, CatmaidSpatialSkeletonDescriptionUpdateRequest, CatmaidSpatialSkeletonDescriptionUpdateResult, CatmaidSpatialSkeletonInsertNodeRequest, @@ -41,6 +43,8 @@ import type { CatmaidSpatialSkeletonNodeSourceStateResult, CatmaidSpatialSkeletonNodeSourceStateUpdate, CatmaidSpatialSkeletonRadiusUpdateRequest, + CatmaidSpatialSkeletonRestoreSkeletonRequest, + CatmaidSpatialSkeletonRestoreSkeletonResult, CatmaidSpatialSkeletonRerootRequest, CatmaidSpatialSkeletonRerootResult, CatmaidSpatialSkeletonSplitRequest, @@ -145,6 +149,12 @@ interface CatmaidSpatialSkeletonEditOperations { commitDeleteNode( request: CatmaidSpatialSkeletonDeleteNodeRequest, ): Promise; + commitDeleteSkeleton( + request: CatmaidSpatialSkeletonDeleteSkeletonRequest, + ): Promise; + commitRestoreSkeleton( + request: CatmaidSpatialSkeletonRestoreSkeletonRequest, + ): Promise; commitReroot( request: CatmaidSpatialSkeletonRerootRequest, ): Promise; @@ -311,6 +321,25 @@ function requireCatmaidDeleteNodeCommandPayload(payload: object) { ); } +function requireCatmaidDeleteSubtreeCommandPayload(payload: object) { + return requireCatmaidCommandPayload( + payload, + "delete-subtree", + ( + candidate, + ): candidate is Pick< + SpatiallyIndexedSkeletonNode, + "nodeId" | "segmentId" + > => { + const node = candidate as { + nodeId?: number; + segmentId?: number; + }; + return isFiniteNumber(node.nodeId) && isFiniteNumber(node.segmentId); + }, + ); +} + function requireCatmaidNodeDescriptionCommandOptions(payload: object) { return requireCatmaidCommandPayload( payload, @@ -591,6 +620,23 @@ function getSplitAffectedNodes(resolvedNode: ResolvedSpatialSkeletonEditNode) { ]; } +function getDeleteSubtreeAffectedNodes( + node: SpatiallyIndexedSkeletonNode, + segmentNodes: readonly SpatiallyIndexedSkeletonNode[], +) { + if (node.parentNodeId === undefined) { + return segmentNodes; + } + const subtreeNodes = getSpatiallyIndexedSkeletonSubtreeNodes( + segmentNodes, + node.nodeId, + ); + return [ + ...subtreeNodes, + getSpatiallyIndexedSkeletonNodeParent(segmentNodes, node), + ]; +} + function getSegmentNodesBySegmentId( segmentId: number | undefined, ...resolvedNodes: readonly ResolvedSpatialSkeletonEditNode[] @@ -728,6 +774,58 @@ async function refreshTopologySegments( ); } +function clearSpatialSkeletonSelectionIfDeleted( + layer: SegmentationUserLayer, + stableDeletedNodeIds: ReadonlySet, +) { + const selectedNodeId = layer.selectedSpatialSkeletonNodeId.value; + if (selectedNodeId === undefined) { + return; + } + const stableSelectedNodeId = + layer.spatialSkeletonState.commandHistory.mappings.getStableNodeId( + selectedNodeId, + ) ?? selectedNodeId; + if (!stableDeletedNodeIds.has(stableSelectedNodeId)) { + return; + } + layer.clearSpatialSkeletonNodeSelection( + layer.manager.root.selectionState.pin.value, + ); +} + +function applyDeletedSkeletonSegmentsToCache( + layer: SegmentationUserLayer, + skeletonLayer: SpatiallyIndexedSkeletonLayer, + deletedSegmentIds: readonly number[], + affectedPositions: Iterable>, + stableDeletedNodeIds: ReadonlySet, +) { + const normalizedDeletedSegmentIds = [ + ...new Set( + deletedSegmentIds + .map(normalizePositiveSegmentId) + .filter((value): value is number => value !== undefined), + ), + ]; + if (normalizedDeletedSegmentIds.length === 0) { + return; + } + skeletonLayer.invalidateSourceCellsForPositions([...affectedPositions]); + layer.spatialSkeletonState.invalidateCachedSegments( + normalizedDeletedSegmentIds, + ); + for (const segmentId of normalizedDeletedSegmentIds) { + removeVisibleSegment(layer, segmentId, { deselect: true }); + layer.displayState.segmentStatedColors.value.delete(BigInt(segmentId)); + skeletonLayer.suppressBrowseSegment(segmentId); + } + clearSpatialSkeletonSelectionIfDeleted(layer, stableDeletedNodeIds); + layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); +} + function applyCreatedNodeToCache( layer: SegmentationUserLayer, skeletonLayer: SpatiallyIndexedSkeletonLayer, @@ -1936,6 +2034,401 @@ class SplitCommand implements SpatialSkeletonCommand { } } +class DeleteSubtreeCommand implements SpatialSkeletonCommand { + readonly label: string; + private readonly deleteWholeSkeleton: boolean; + private readonly stableNodeId: number; + private readonly stableSegmentId: number | undefined; + private readonly stableFormerParentNodeId: number | undefined; + private stableDeletedSegmentId: number | undefined; + private readonly affectedPositions: ArrayLike[]; + private readonly stableDeletedNodeIds = new Set(); + private undoLeftRestoredSplit = false; + + constructor( + private layer: SegmentationUserLayer, + node: SpatiallyIndexedSkeletonNode, + segmentNodes: readonly SpatiallyIndexedSkeletonNode[], + private editOperations: CatmaidSpatialSkeletonEditOperations, + ) { + const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; + this.deleteWholeSkeleton = node.parentNodeId === undefined; + this.label = this.deleteWholeSkeleton + ? "Delete skeleton" + : "Delete subskeleton"; + this.stableNodeId = commandMappings.getStableOrCurrentNodeId(node.nodeId)!; + this.stableSegmentId = commandMappings.getStableOrCurrentSegmentId( + node.segmentId, + ); + this.stableFormerParentNodeId = commandMappings.getStableOrCurrentNodeId( + node.parentNodeId, + ); + this.stableDeletedSegmentId = this.deleteWholeSkeleton + ? this.stableSegmentId + : undefined; + + const deletedNodes = this.deleteWholeSkeleton + ? segmentNodes + : getSpatiallyIndexedSkeletonSubtreeNodes(segmentNodes, node.nodeId); + for (const deletedNode of deletedNodes.length === 0 + ? [node] + : deletedNodes) { + const stableDeletedNodeId = commandMappings.getStableOrCurrentNodeId( + deletedNode.nodeId, + ); + if (stableDeletedNodeId !== undefined) { + this.stableDeletedNodeIds.add(stableDeletedNodeId); + } + } + this.affectedPositions = collectUniqueNodePositions( + getDeleteSubtreeAffectedNodes(node, segmentNodes), + ); + } + + private getCurrentDeletedSegmentId() { + const stableDeletedSegmentId = this.stableDeletedSegmentId; + if (stableDeletedSegmentId === undefined) { + throw new Error( + "Delete-subtree command is missing the deleted skeleton id.", + ); + } + const currentDeletedSegmentId = + this.layer.spatialSkeletonState.commandHistory.mappings.resolveSegmentId( + stableDeletedSegmentId, + ) ?? stableDeletedSegmentId; + if (normalizePositiveSegmentId(currentDeletedSegmentId) === undefined) { + throw new Error( + `Unable to resolve deleted skeleton ${stableDeletedSegmentId}.`, + ); + } + return currentDeletedSegmentId; + } + + private async deleteSkeletonSegment( + skeletonLayer: SpatiallyIndexedSkeletonLayer, + segmentId: number, + statusPrefix: string, + ) { + const result = await this.editOperations.commitDeleteSkeleton({ + segmentId, + deleteMultiSkeletonNeurons: false, + }); + const deletedSegmentIds = + result.skeletonIds.length === 0 ? [segmentId] : result.skeletonIds; + applyDeletedSkeletonSegmentsToCache( + this.layer, + skeletonLayer, + deletedSegmentIds, + this.affectedPositions, + this.stableDeletedNodeIds, + ); + StatusMessage.showTemporaryMessage( + `${statusPrefix} skeleton ${segmentId}.`, + ); + } + + private async deleteWhole(statusPrefix: string) { + const resolvedNode = await getResolvedNodeForEdit( + this.layer, + this.stableNodeId, + this.stableSegmentId, + ); + await this.deleteSkeletonSegment( + resolvedNode.skeletonLayer, + resolvedNode.node.segmentId, + statusPrefix, + ); + } + + private async restoreWhole(statusPrefix: string) { + const deletedSegmentId = this.getCurrentDeletedSegmentId(); + const { skeletonLayer } = getEditableSkeletonSourceForLayer(this.layer); + const result = await this.editOperations.commitRestoreSkeleton({ + segmentId: deletedSegmentId, + }); + const restoredSegmentId = result.skeletonId; + if (this.stableSegmentId !== undefined) { + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + this.stableSegmentId, + restoredSegmentId, + ); + } + ensureVisibleSegment(this.layer, restoredSegmentId); + selectSegment(this.layer, restoredSegmentId, true); + await refreshTopologySegments( + this.layer, + [restoredSegmentId], + this.affectedPositions, + ); + const restoredNodes = + this.layer.spatialSkeletonState.getCachedSegmentNodes( + restoredSegmentId, + ) ?? []; + const restoredNode = findSpatiallyIndexedSkeletonNode( + restoredNodes, + this.layer.spatialSkeletonState.commandHistory.mappings.resolveNodeId( + this.stableNodeId, + ) ?? this.stableNodeId, + ); + const selectionNode = restoredNode ?? findRootNode(restoredNodes); + if (selectionNode !== undefined) { + this.layer.selectSpatialSkeletonNode( + selectionNode.nodeId, + this.layer.manager.root.selectionState.pin.value, + { + segmentId: selectionNode.segmentId, + position: selectionNode.position, + }, + ); + } + skeletonLayer.retainOverlaySegment(restoredSegmentId); + StatusMessage.showTemporaryMessage( + `${statusPrefix} skeleton ${restoredSegmentId}.`, + ); + } + + private async mergeRestoredSubtree(statusPrefix: string) { + if (this.stableFormerParentNodeId === undefined) { + throw new Error("Delete-subtree undo is missing the former parent node."); + } + const splitNode = await getResolvedNodeForEdit( + this.layer, + this.stableNodeId, + this.stableDeletedSegmentId ?? this.stableSegmentId, + ); + const formerParent = await getResolvedNodeForEdit( + this.layer, + this.stableFormerParentNodeId, + this.stableSegmentId, + ); + let result: CatmaidSpatialSkeletonMergeResult; + try { + result = await this.editOperations.commitMerge({ + fromNode: formerParent.node, + toNode: splitNode.node, + }); + } catch (error) { + await refreshTopologySegments( + this.layer, + [splitNode.node.segmentId, formerParent.node.segmentId], + collectUniqueNodePositions( + splitNode.segmentNodes, + formerParent.segmentNodes, + ), + ); + 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.stableDeletedSegmentId !== undefined) { + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + this.stableDeletedSegmentId, + 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], + getMergeAffectedPositions( + result.deletedSegmentId, + splitNode, + formerParent, + ), + ); + StatusMessage.showTemporaryMessage( + `${statusPrefix} subskeleton rooted at node ${splitNode.node.nodeId}.`, + ); + } + + private async splitAndDelete(statusPrefix: string) { + const resolvedNode = await getResolvedNodeForEdit( + this.layer, + this.stableNodeId, + this.stableSegmentId, + ); + let result: CatmaidSpatialSkeletonSplitResult; + try { + result = await this.editOperations.commitSplit({ + node: resolvedNode.node, + segmentNodes: resolvedNode.segmentNodes, + }); + } catch (error) { + await refreshTopologySegments( + this.layer, + [resolvedNode.node.segmentId], + collectUniqueNodePositions(resolvedNode.segmentNodes), + ); + 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.stableDeletedSegmentId === undefined) { + this.stableDeletedSegmentId = newSkeletonId; + } else { + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + this.stableDeletedSegmentId, + newSkeletonId, + ); + } + if (this.stableSegmentId !== undefined) { + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + this.stableSegmentId, + existingSkeletonId, + ); + } + ensureVisibleSegment(this.layer, existingSkeletonId); + ensureVisibleSegment(this.layer, newSkeletonId); + await refreshTopologySegments( + this.layer, + [existingSkeletonId, newSkeletonId], + collectUniqueNodePositions(getSplitAffectedNodes(resolvedNode)), + ); + try { + await this.deleteSkeletonSegment( + resolvedNode.skeletonLayer, + newSkeletonId, + statusPrefix, + ); + } catch (error) { + try { + await this.mergeRestoredSubtree("Rolled back deletion of"); + } catch (rollbackError) { + StatusMessage.showTemporaryMessage( + `Failed to roll back split after delete failure. ${formatErrorMessage( + rollbackError, + )}`, + ); + } + throw error; + } + if (this.stableFormerParentNodeId !== undefined) { + const formerParent = await getResolvedNodeForEdit( + this.layer, + this.stableFormerParentNodeId, + this.stableSegmentId, + ).catch(() => undefined); + if (formerParent !== undefined) { + this.layer.selectSpatialSkeletonNode( + formerParent.node.nodeId, + this.layer.manager.root.selectionState.pin.value, + { + segmentId: formerParent.node.segmentId, + position: formerParent.node.position, + }, + ); + } + } + this.undoLeftRestoredSplit = false; + } + + private async restoreAndMerge(statusPrefix: string) { + const deletedSegmentId = this.getCurrentDeletedSegmentId(); + const result = await this.editOperations.commitRestoreSkeleton({ + segmentId: deletedSegmentId, + }); + const restoredSegmentId = result.skeletonId; + if (this.stableDeletedSegmentId !== undefined) { + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + this.stableDeletedSegmentId, + restoredSegmentId, + ); + } + ensureVisibleSegment(this.layer, restoredSegmentId); + selectSegment(this.layer, restoredSegmentId, false); + await refreshTopologySegments( + this.layer, + [restoredSegmentId], + this.affectedPositions, + ); + try { + await this.mergeRestoredSubtree(statusPrefix); + this.undoLeftRestoredSplit = false; + } catch (error) { + this.undoLeftRestoredSplit = true; + ensureVisibleSegment(this.layer, restoredSegmentId); + await refreshTopologySegments( + this.layer, + [ + restoredSegmentId, + this.layer.spatialSkeletonState.commandHistory.mappings.resolveSegmentId( + this.stableSegmentId, + ) ?? restoredSegmentId, + ], + this.affectedPositions, + ); + StatusMessage.showTemporaryMessage( + `Restored subskeleton ${restoredSegmentId}, but failed to merge it back. ${formatErrorMessage( + error, + )}`, + ); + } + } + + private async deleteRestoredSplit(statusPrefix: string) { + const deletedSegmentId = this.getCurrentDeletedSegmentId(); + const { skeletonLayer } = getEditableSkeletonSourceForLayer(this.layer); + await this.deleteSkeletonSegment( + skeletonLayer, + deletedSegmentId, + statusPrefix, + ); + this.undoLeftRestoredSplit = false; + } + + execute() { + return this.deleteWholeSkeleton + ? this.deleteWhole("Deleted") + : this.splitAndDelete("Deleted"); + } + + undo() { + return this.deleteWholeSkeleton + ? this.restoreWhole("Restored") + : this.restoreAndMerge("Restored"); + } + + redo() { + if (this.deleteWholeSkeleton) { + return this.deleteWhole("Redid deletion of"); + } + return this.undoLeftRestoredSplit + ? this.deleteRestoredSplit("Redid deletion of") + : this.splitAndDelete("Redid deletion of"); + } +} + class MergeCommand implements SpatialSkeletonCommand { readonly label = "Merge skeletons"; private stableResultSegmentId: number | undefined; @@ -2263,6 +2756,8 @@ export class CatmaidSpatialSkeletonEditCommands { commitInsertNode: (request) => this.commitInsertNode(request), commitMoveNode: (request) => this.commitMoveNode(request), commitDeleteNode: (request) => this.commitDeleteNode(request), + commitDeleteSkeleton: (request) => this.commitDeleteSkeleton(request), + commitRestoreSkeleton: (request) => this.commitRestoreSkeleton(request), commitReroot: (request) => this.commitReroot(request), commitDescription: (request) => this.commitDescription(request), commitTrueEnd: (request) => this.commitTrueEnd(request), @@ -2308,6 +2803,15 @@ export class CatmaidSpatialSkeletonEditCommands { ), ); + readonly deleteSubtreesCommand = makeCatmaidCommandFactory( + SpatialSkeletonActions.deleteSubtrees, + (layer, payload) => + this.createDeleteSubtreeCommand( + layer, + requireCatmaidDeleteSubtreeCommandPayload(payload), + ), + ); + readonly rerootCommand = makeCatmaidCommandFactory( SpatialSkeletonActions.reroot, (layer, payload) => @@ -2443,6 +2947,20 @@ export class CatmaidSpatialSkeletonEditCommands { }); } + private commitDeleteSkeleton( + request: CatmaidSpatialSkeletonDeleteSkeletonRequest, + ): Promise { + return this.client.deleteSkeleton(request.segmentId, { + deleteMultiSkeletonNeurons: request.deleteMultiSkeletonNeurons ?? false, + }); + } + + private commitRestoreSkeleton( + request: CatmaidSpatialSkeletonRestoreSkeletonRequest, + ): Promise { + return this.client.restoreSkeleton(request.segmentId); + } + private commitReroot( request: CatmaidSpatialSkeletonRerootRequest, ): Promise { @@ -2597,6 +3115,30 @@ export class CatmaidSpatialSkeletonEditCommands { ); } + private createDeleteSubtreeCommand( + layer: SegmentationUserLayer, + node: Pick, + ) { + const segmentNodes = layer.getCachedSpatialSkeletonSegmentNodesForEdit( + node.segmentId, + ); + const subtreeRoot = findSpatiallyIndexedSkeletonNode( + segmentNodes, + node.nodeId, + ); + if (subtreeRoot === undefined) { + throw new Error( + `Node ${node.nodeId} is not available in the inspected skeleton cache.`, + ); + } + return new DeleteSubtreeCommand( + layer, + subtreeRoot, + segmentNodes, + this.editOperations, + ); + } + private createNodeDescriptionCommand( layer: SegmentationUserLayer, options: CatmaidSpatialSkeletonNodeDescriptionCommandOptions, diff --git a/src/datasource/catmaid/spatial_skeleton_edit_api.ts b/src/datasource/catmaid/spatial_skeleton_edit_api.ts index 5af219a7d..0c0238e39 100644 --- a/src/datasource/catmaid/spatial_skeleton_edit_api.ts +++ b/src/datasource/catmaid/spatial_skeleton_edit_api.ts @@ -17,10 +17,12 @@ import type { CatmaidAddNodeResult, CatmaidDeleteNodeResult, + CatmaidDeleteSkeletonResult, CatmaidDescriptionUpdateResult, CatmaidInsertNodeResult, CatmaidMergeResult, CatmaidNodeSourceStateResult, + CatmaidRestoreSkeletonResult, CatmaidRerootResult, CatmaidSkeletonEditResult, CatmaidSkeletonNodeSourceStateUpdate, @@ -70,6 +72,21 @@ export interface CatmaidSpatialSkeletonDeleteNodeRequest { export type CatmaidSpatialSkeletonDeleteNodeResult = CatmaidDeleteNodeResult; +export interface CatmaidSpatialSkeletonDeleteSkeletonRequest { + segmentId: number; + deleteMultiSkeletonNeurons?: boolean; +} + +export type CatmaidSpatialSkeletonDeleteSkeletonResult = + CatmaidDeleteSkeletonResult; + +export interface CatmaidSpatialSkeletonRestoreSkeletonRequest { + segmentId: number; +} + +export type CatmaidSpatialSkeletonRestoreSkeletonResult = + CatmaidRestoreSkeletonResult; + export interface CatmaidSpatialSkeletonSplitRequest { node: SpatiallyIndexedSkeletonNode; segmentNodes: readonly SpatiallyIndexedSkeletonNode[]; diff --git a/src/layer/segmentation/spatial_skeleton_commands.spec.ts b/src/layer/segmentation/spatial_skeleton_commands.spec.ts index f637b838c..a9652e9d1 100644 --- a/src/layer/segmentation/spatial_skeleton_commands.spec.ts +++ b/src/layer/segmentation/spatial_skeleton_commands.spec.ts @@ -22,6 +22,7 @@ import { CatmaidSpatialSkeletonEditCommands } from "#src/datasource/catmaid/spat import { executeSpatialSkeletonAddNode, executeSpatialSkeletonDeleteNode, + executeSpatialSkeletonDeleteSubtree, executeSpatialSkeletonInsertNode, executeSpatialSkeletonMerge, executeSpatialSkeletonMoveNode, @@ -86,6 +87,8 @@ const catmaidEditClientMethodNames = new Set([ "insertNode", "moveNode", "deleteNode", + "deleteSkeleton", + "restoreSkeleton", "rerootSkeleton", "updateDescription", "toggleTrueEnd", @@ -101,6 +104,8 @@ function makeCatmaidClient(overrides: Record = {}) { insertNode: vi.fn(), moveNode: vi.fn(), deleteNode: vi.fn(), + deleteSkeleton: vi.fn(), + restoreSkeleton: vi.fn(), rerootSkeleton: vi.fn(), updateDescription: vi.fn(), toggleTrueEnd: vi.fn(), @@ -135,6 +140,7 @@ function makeEditableSkeletonSource(overrides: Record = {}) { insertNodesCommand: commands.insertNodesCommand, moveNodesCommand: commands.moveNodesCommand, deleteNodesCommand: commands.deleteNodesCommand, + deleteSubtreesCommand: commands.deleteSubtreesCommand, rerootCommand: commands.rerootCommand, editNodeDescriptionCommand: commands.editNodeDescriptionCommand, editNodeTrueEndCommand: commands.editNodeTrueEndCommand, @@ -202,6 +208,75 @@ function makePinnedManager() { }; } +function makeSpatialSkeletonCommandTestLayer(options: { + skeletonSource: object; + serverSegments: Map; + cacheBySegment: Map; + cacheByNode: Map; + visibleSegmentIds: readonly number[]; + selectedNodeId?: number; +}) { + const syncCacheFromServer = (segmentId: number) => { + setSegmentNodes( + options.cacheBySegment, + options.cacheByNode, + segmentId, + options.serverSegments.get(segmentId) ?? [], + ); + return options.cacheBySegment.get(segmentId) ?? []; + }; + const getFullSegmentNodes = vi.fn( + async (_skeletonLayer: unknown, segmentId: number) => + syncCacheFromServer(segmentId), + ); + const invalidateCachedSegments = vi.fn((segmentIds: Iterable) => { + for (const segmentId of segmentIds) { + setSegmentNodes( + options.cacheBySegment, + options.cacheByNode, + segmentId, + [], + ); + } + }); + const skeletonLayer = { + source: options.skeletonSource, + getNode: vi.fn((nodeId: number) => options.cacheByNode.get(nodeId)), + invalidateSourceCellsForPositions: vi.fn(), + suppressBrowseSegment: vi.fn(), + retainOverlaySegment: vi.fn(), + }; + const layer = { + displayState: makeDisplayState(options.visibleSegmentIds), + manager: makePinnedManager(), + selectedSpatialSkeletonNodeId: { + value: options.selectedNodeId, + }, + spatialSkeletonState: { + commandHistory: new SpatialSkeletonCommandHistory(), + getCachedNode: (nodeId: number) => options.cacheByNode.get(nodeId), + getCachedSegmentNodes: (segmentId: number) => + options.cacheBySegment.get(segmentId), + getFullSegmentNodes, + invalidateCachedSegments, + }, + getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, + getCachedSpatialSkeletonSegmentNodesForEdit: (segmentId: number) => + options.cacheBySegment.get(segmentId) ?? [], + selectSegment: vi.fn(), + selectSpatialSkeletonNode: vi.fn(), + clearSpatialSkeletonNodeSelection: vi.fn(), + markSpatialSkeletonNodeDataChanged: vi.fn(), + }; + return { + getFullSegmentNodes, + invalidateCachedSegments, + layer, + skeletonLayer, + syncCacheFromServer, + }; +} + describe("spatial_skeleton_commands", () => { afterEach(() => { vi.restoreAllMocks(); @@ -338,6 +413,9 @@ describe("spatial_skeleton_commands", () => { deleteNodesCommand: makeCommandFactory( SpatialSkeletonActions.deleteNodes, ), + deleteSubtreesCommand: makeCommandFactory( + SpatialSkeletonActions.deleteSubtrees, + ), rerootCommand: makeCommandFactory(SpatialSkeletonActions.reroot), editNodeDescriptionCommand: makeCommandFactory( SpatialSkeletonActions.editNodeDescription, @@ -375,6 +453,11 @@ describe("spatial_skeleton_commands", () => { }; const firstNode = { nodeId: 17, segmentId: 23 }; const secondNode = { nodeId: 29, segmentId: 31 }; + const subtreeNode = { + nodeId: 37, + segmentId: 41, + position: new Float32Array([4, 5, 6]), + }; const cases = [ { commandFactory: source.addNodesCommand, @@ -409,6 +492,12 @@ describe("spatial_skeleton_commands", () => { execute: () => executeSpatialSkeletonDeleteNode(layer as any, node), pendingMessage: "Deleting node...", }, + { + commandFactory: source.deleteSubtreesCommand, + execute: () => + executeSpatialSkeletonDeleteSubtree(layer as any, subtreeNode), + pendingMessage: "Deleting skeleton...", + }, { commandFactory: source.editNodeDescriptionCommand, execute: () => @@ -487,6 +576,9 @@ describe("spatial_skeleton_commands", () => { expect(commandSource.editNodeConfidenceCommand.action).toBe( SpatialSkeletonActions.editNodeConfidence, ); + expect(commandSource.deleteSubtreesCommand.action).toBe( + SpatialSkeletonActions.deleteSubtrees, + ); expect((commandSource as any).inspectCommand).toBeUndefined(); }); @@ -1502,6 +1594,425 @@ describe("spatial_skeleton_commands", () => { ).toEqual([formerParentNode.nodeId, splitNodeBefore.nodeId]); }); + it("deletes and restores whole skeletons as one undoable command", async () => { + suppressStatusMessages(); + + const segmentId = 11; + const rootNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([1, 0, 0]), + isTrueEnd: false, + sourceState: testSourceState("root-before"), + }; + const childNode: SpatiallyIndexedSkeletonNode = { + nodeId: 2, + segmentId, + parentNodeId: rootNode.nodeId, + position: new Float32Array([2, 0, 0]), + isTrueEnd: false, + sourceState: testSourceState("child-before"), + }; + const originalNodes = [rootNode, childNode]; + const serverSegments = new Map(); + const cacheBySegment = new Map(); + const cacheByNode = new Map(); + serverSegments.set(segmentId, originalNodes.map(cloneNode)); + + const deleteSkeleton = vi.fn( + async ( + skeletonId: number, + options: { deleteMultiSkeletonNeurons?: boolean }, + ) => { + expect(options.deleteMultiSkeletonNeurons).toBe(false); + serverSegments.delete(skeletonId); + return { skeletonIds: [skeletonId] }; + }, + ); + const restoreSkeleton = vi.fn(async (skeletonId: number) => { + serverSegments.set(skeletonId, originalNodes.map(cloneNode)); + return { skeletonId }; + }); + const skeletonSource = makeEditableSkeletonSource({ + deleteSkeleton, + restoreSkeleton, + }); + const { layer, skeletonLayer, syncCacheFromServer } = + makeSpatialSkeletonCommandTestLayer({ + skeletonSource, + serverSegments, + cacheBySegment, + cacheByNode, + visibleSegmentIds: [segmentId], + selectedNodeId: childNode.nodeId, + }); + syncCacheFromServer(segmentId); + + await executeSpatialSkeletonDeleteSubtree(layer as any, rootNode); + + expect(deleteSkeleton).toHaveBeenCalledWith(segmentId, { + deleteMultiSkeletonNeurons: false, + }); + expect(skeletonLayer.suppressBrowseSegment).toHaveBeenCalledWith(segmentId); + expect(layer.clearSpatialSkeletonNodeSelection).toHaveBeenCalledTimes(1); + expect( + layer.displayState.segmentationGroupState.value.visibleSegments.has( + BigInt(segmentId), + ), + ).toBe(false); + expect(cacheBySegment.get(segmentId)).toBeUndefined(); + + await undoSpatialSkeletonCommand(layer as any); + + expect(restoreSkeleton).toHaveBeenCalledWith(segmentId); + expect( + layer.displayState.segmentationGroupState.value.visibleSegments.has( + BigInt(segmentId), + ), + ).toBe(true); + expect(cacheBySegment.get(segmentId)?.map((node) => node.nodeId)).toEqual([ + rootNode.nodeId, + childNode.nodeId, + ]); + expect(skeletonLayer.retainOverlaySegment).toHaveBeenCalledWith(segmentId); + }); + + it("splits, deletes, restores, and merges subskeletons as one undoable command", async () => { + suppressStatusMessages(); + + const originalSegmentId = 11; + const splitSegmentId = 17; + const rootNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId: originalSegmentId, + position: new Float32Array([1, 0, 0]), + isTrueEnd: false, + sourceState: testSourceState("root-before"), + }; + const formerParentNode: SpatiallyIndexedSkeletonNode = { + nodeId: 2, + segmentId: originalSegmentId, + parentNodeId: rootNode.nodeId, + position: new Float32Array([2, 0, 0]), + isTrueEnd: false, + sourceState: testSourceState("parent-before"), + }; + const splitNodeBefore: SpatiallyIndexedSkeletonNode = { + nodeId: 3, + segmentId: originalSegmentId, + parentNodeId: formerParentNode.nodeId, + position: new Float32Array([3, 0, 0]), + isTrueEnd: false, + sourceState: testSourceState("split-before"), + }; + const splitChildNodeBefore: SpatiallyIndexedSkeletonNode = { + nodeId: 4, + segmentId: originalSegmentId, + parentNodeId: splitNodeBefore.nodeId, + position: new Float32Array([4, 0, 0]), + isTrueEnd: false, + sourceState: testSourceState("split-child-before"), + }; + const originalNodes = [ + rootNode, + formerParentNode, + splitNodeBefore, + splitChildNodeBefore, + ]; + const existingSideNodes = [rootNode, formerParentNode]; + const splitSideNodes = [ + { + ...splitNodeBefore, + segmentId: splitSegmentId, + parentNodeId: undefined, + sourceState: testSourceState("split-after"), + }, + { + ...splitChildNodeBefore, + segmentId: splitSegmentId, + sourceState: testSourceState("split-child-after"), + }, + ]; + const serverSegments = new Map(); + const cacheBySegment = new Map(); + const cacheByNode = new Map(); + serverSegments.set(originalSegmentId, originalNodes.map(cloneNode)); + + const splitSkeleton = vi.fn(async () => { + serverSegments.set(originalSegmentId, existingSideNodes.map(cloneNode)); + serverSegments.set(splitSegmentId, splitSideNodes.map(cloneNode)); + return { + existingSegmentId: originalSegmentId, + newSegmentId: splitSegmentId, + }; + }); + const deleteSkeleton = vi.fn(async (skeletonId: number) => { + serverSegments.delete(skeletonId); + return { skeletonIds: [skeletonId] }; + }); + const restoreSkeleton = vi.fn(async (skeletonId: number) => { + serverSegments.set(skeletonId, splitSideNodes.map(cloneNode)); + return { skeletonId }; + }); + const mergeSkeletons = vi.fn(async () => { + serverSegments.set(originalSegmentId, originalNodes.map(cloneNode)); + serverSegments.delete(splitSegmentId); + return { + resultSegmentId: originalSegmentId, + deletedSegmentId: splitSegmentId, + directionAdjusted: false, + }; + }); + const skeletonSource = makeEditableSkeletonSource({ + splitSkeleton, + deleteSkeleton, + restoreSkeleton, + mergeSkeletons, + }); + const { layer, syncCacheFromServer } = makeSpatialSkeletonCommandTestLayer({ + skeletonSource, + serverSegments, + cacheBySegment, + cacheByNode, + visibleSegmentIds: [originalSegmentId], + selectedNodeId: splitChildNodeBefore.nodeId, + }); + syncCacheFromServer(originalSegmentId); + + await executeSpatialSkeletonDeleteSubtree(layer as any, splitNodeBefore); + + expect(splitSkeleton).toHaveBeenCalledTimes(1); + expect(deleteSkeleton).toHaveBeenCalledWith(splitSegmentId, { + deleteMultiSkeletonNeurons: false, + }); + expect(layer.clearSpatialSkeletonNodeSelection).toHaveBeenCalledTimes(1); + expect(cacheBySegment.get(splitSegmentId)).toBeUndefined(); + + await undoSpatialSkeletonCommand(layer as any); + + expect(restoreSkeleton).toHaveBeenCalledWith(splitSegmentId); + expect(mergeSkeletons).toHaveBeenCalledWith( + formerParentNode.nodeId, + splitNodeBefore.nodeId, + expect.objectContaining({ + nodes: expect.arrayContaining([ + expect.objectContaining({ nodeId: formerParentNode.nodeId }), + expect.objectContaining({ nodeId: splitNodeBefore.nodeId }), + ]), + }), + ); + expect( + cacheBySegment.get(originalSegmentId)?.map((node) => node.nodeId), + ).toEqual([ + rootNode.nodeId, + formerParentNode.nodeId, + splitNodeBefore.nodeId, + splitChildNodeBefore.nodeId, + ]); + + splitSkeleton.mockClear(); + deleteSkeleton.mockClear(); + + await redoSpatialSkeletonCommand(layer as any); + + expect(splitSkeleton).toHaveBeenCalledTimes(1); + expect(deleteSkeleton).toHaveBeenCalledWith(splitSegmentId, { + deleteMultiSkeletonNeurons: false, + }); + }); + + it("rolls back a successful subskeleton split when deleting the split skeleton fails", async () => { + suppressStatusMessages(); + + const originalSegmentId = 11; + const splitSegmentId = 17; + const rootNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId: originalSegmentId, + position: new Float32Array([1, 0, 0]), + isTrueEnd: false, + sourceState: testSourceState("root-before"), + }; + const formerParentNode: SpatiallyIndexedSkeletonNode = { + nodeId: 2, + segmentId: originalSegmentId, + parentNodeId: rootNode.nodeId, + position: new Float32Array([2, 0, 0]), + isTrueEnd: false, + sourceState: testSourceState("parent-before"), + }; + const splitNodeBefore: SpatiallyIndexedSkeletonNode = { + nodeId: 3, + segmentId: originalSegmentId, + parentNodeId: formerParentNode.nodeId, + position: new Float32Array([3, 0, 0]), + isTrueEnd: false, + sourceState: testSourceState("split-before"), + }; + const originalNodes = [rootNode, formerParentNode, splitNodeBefore]; + const splitNodeAfter = { + ...splitNodeBefore, + segmentId: splitSegmentId, + parentNodeId: undefined, + sourceState: testSourceState("split-after"), + }; + const serverSegments = new Map(); + const cacheBySegment = new Map(); + const cacheByNode = new Map(); + serverSegments.set(originalSegmentId, originalNodes.map(cloneNode)); + + const splitSkeleton = vi.fn(async () => { + serverSegments.set( + originalSegmentId, + [rootNode, formerParentNode].map(cloneNode), + ); + serverSegments.set(splitSegmentId, [splitNodeAfter].map(cloneNode)); + return { + existingSegmentId: originalSegmentId, + newSegmentId: splitSegmentId, + }; + }); + const deleteSkeleton = vi.fn(async () => { + throw new Error("delete failed"); + }); + const mergeSkeletons = vi.fn(async () => { + serverSegments.set(originalSegmentId, originalNodes.map(cloneNode)); + serverSegments.delete(splitSegmentId); + return { + resultSegmentId: originalSegmentId, + deletedSegmentId: splitSegmentId, + directionAdjusted: false, + }; + }); + const skeletonSource = makeEditableSkeletonSource({ + splitSkeleton, + deleteSkeleton, + mergeSkeletons, + }); + const { layer, syncCacheFromServer } = makeSpatialSkeletonCommandTestLayer({ + skeletonSource, + serverSegments, + cacheBySegment, + cacheByNode, + visibleSegmentIds: [originalSegmentId], + }); + syncCacheFromServer(originalSegmentId); + + await expect( + executeSpatialSkeletonDeleteSubtree(layer as any, splitNodeBefore), + ).rejects.toThrow("delete failed"); + + expect(splitSkeleton).toHaveBeenCalledTimes(1); + expect(deleteSkeleton).toHaveBeenCalledTimes(1); + expect(mergeSkeletons).toHaveBeenCalledTimes(1); + expect( + cacheBySegment.get(originalSegmentId)?.map((node) => node.nodeId), + ).toEqual([ + rootNode.nodeId, + formerParentNode.nodeId, + splitNodeBefore.nodeId, + ]); + }); + + it("keeps a restored subskeleton visible when merge undo fails and redoes by deleting it directly", async () => { + suppressStatusMessages(); + + const originalSegmentId = 11; + const splitSegmentId = 17; + const rootNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId: originalSegmentId, + position: new Float32Array([1, 0, 0]), + isTrueEnd: false, + sourceState: testSourceState("root-before"), + }; + const formerParentNode: SpatiallyIndexedSkeletonNode = { + nodeId: 2, + segmentId: originalSegmentId, + parentNodeId: rootNode.nodeId, + position: new Float32Array([2, 0, 0]), + isTrueEnd: false, + sourceState: testSourceState("parent-before"), + }; + const splitNodeBefore: SpatiallyIndexedSkeletonNode = { + nodeId: 3, + segmentId: originalSegmentId, + parentNodeId: formerParentNode.nodeId, + position: new Float32Array([3, 0, 0]), + isTrueEnd: false, + sourceState: testSourceState("split-before"), + }; + const originalNodes = [rootNode, formerParentNode, splitNodeBefore]; + const existingSideNodes = [rootNode, formerParentNode]; + const splitSideNodes = [ + { + ...splitNodeBefore, + segmentId: splitSegmentId, + parentNodeId: undefined, + sourceState: testSourceState("split-after"), + }, + ]; + const serverSegments = new Map(); + const cacheBySegment = new Map(); + const cacheByNode = new Map(); + serverSegments.set(originalSegmentId, originalNodes.map(cloneNode)); + + const splitSkeleton = vi.fn(async () => { + serverSegments.set(originalSegmentId, existingSideNodes.map(cloneNode)); + serverSegments.set(splitSegmentId, splitSideNodes.map(cloneNode)); + return { + existingSegmentId: originalSegmentId, + newSegmentId: splitSegmentId, + }; + }); + const deleteSkeleton = vi.fn(async (skeletonId: number) => { + serverSegments.delete(skeletonId); + return { skeletonIds: [skeletonId] }; + }); + const restoreSkeleton = vi.fn(async (skeletonId: number) => { + serverSegments.set(skeletonId, splitSideNodes.map(cloneNode)); + return { skeletonId }; + }); + const mergeSkeletons = vi.fn(async () => { + throw new Error("merge failed"); + }); + const skeletonSource = makeEditableSkeletonSource({ + splitSkeleton, + deleteSkeleton, + restoreSkeleton, + mergeSkeletons, + }); + const { layer, syncCacheFromServer } = makeSpatialSkeletonCommandTestLayer({ + skeletonSource, + serverSegments, + cacheBySegment, + cacheByNode, + visibleSegmentIds: [originalSegmentId], + }); + syncCacheFromServer(originalSegmentId); + + await executeSpatialSkeletonDeleteSubtree(layer as any, splitNodeBefore); + expect(await undoSpatialSkeletonCommand(layer as any)).toBe(true); + + expect(restoreSkeleton).toHaveBeenCalledWith(splitSegmentId); + expect(mergeSkeletons).toHaveBeenCalledTimes(1); + expect( + layer.displayState.segmentationGroupState.value.visibleSegments.has( + BigInt(splitSegmentId), + ), + ).toBe(true); + + splitSkeleton.mockClear(); + deleteSkeleton.mockClear(); + + expect(await redoSpatialSkeletonCommand(layer as any)).toBe(true); + + expect(splitSkeleton).not.toHaveBeenCalled(); + expect(deleteSkeleton).toHaveBeenCalledWith(splitSegmentId, { + deleteMultiSkeletonNeurons: false, + }); + }); + it("uses the original skeleton side as the join winner when undoing a split", async () => { suppressStatusMessages(); diff --git a/src/layer/segmentation/spatial_skeleton_commands.ts b/src/layer/segmentation/spatial_skeleton_commands.ts index e172d8e11..0a18bc09e 100644 --- a/src/layer/segmentation/spatial_skeleton_commands.ts +++ b/src/layer/segmentation/spatial_skeleton_commands.ts @@ -144,6 +144,14 @@ const spatialSkeletonExecutionMetadata = new Map< pendingMessage: "Deleting node...", }, ], + [ + SpatialSkeletonActions.deleteSubtrees, + { + unsupportedMessage: + "The active skeleton source does not support skeleton/subskeleton deletion.", + pendingMessage: "Deleting skeleton...", + }, + ], [ SpatialSkeletonActions.editNodeDescription, { @@ -262,6 +270,17 @@ export function executeSpatialSkeletonDeleteNode( ); } +export function executeSpatialSkeletonDeleteSubtree( + layer: SegmentationUserLayer, + node: SpatiallyIndexedSkeletonNode, +) { + return executeSpatialSkeletonAction( + layer, + SpatialSkeletonActions.deleteSubtrees, + node, + ); +} + export function executeSpatialSkeletonNodeDescriptionUpdate( layer: SegmentationUserLayer, options: SpatialSkeletonCommandPayload, diff --git a/src/layer/segmentation/style.css b/src/layer/segmentation/style.css index 6f92b2f3a..e13359528 100644 --- a/src/layer/segmentation/style.css +++ b/src/layer/segmentation/style.css @@ -242,7 +242,7 @@ } .neuroglancer-spatial-skeleton-section { - --neuroglancer-skeleton-actions-width: 44px; + --neuroglancer-skeleton-actions-width: 66px; --neuroglancer-skeleton-type-width: 21px; border: 1px solid #2f2f2f; display: flex; diff --git a/src/skeleton/actions.ts b/src/skeleton/actions.ts index 283cb7a7a..967c67d2d 100644 --- a/src/skeleton/actions.ts +++ b/src/skeleton/actions.ts @@ -20,6 +20,7 @@ export const SpatialSkeletonActions = { insertNodes: "insertNodes", moveNodes: "moveNodes", deleteNodes: "deleteNodes", + deleteSubtrees: "deleteSubtrees", reroot: "rerootSkeletons", editNodeDescription: "editNodeDescription", editNodeTrueEnd: "editNodeTrueEnd", @@ -56,6 +57,8 @@ export function getSpatialSkeletonActionSupportLabel( return "node movement"; case SpatialSkeletonActions.deleteNodes: return "node deletion"; + case SpatialSkeletonActions.deleteSubtrees: + return "skeleton/subskeleton deletion"; case SpatialSkeletonActions.reroot: return "skeleton rerooting"; case SpatialSkeletonActions.editNodeDescription: diff --git a/src/skeleton/api.ts b/src/skeleton/api.ts index 1d09154e7..e695522ea 100644 --- a/src/skeleton/api.ts +++ b/src/skeleton/api.ts @@ -17,6 +17,7 @@ import type { SpatialSkeletonAddNodesCommandFactory, SpatialSkeletonDeleteNodesCommandFactory, + SpatialSkeletonDeleteSubtreesCommandFactory, SpatialSkeletonEditNodeDescriptionCommandFactory, SpatialSkeletonEditNodeConfidenceCommandFactory, SpatialSkeletonEditNodeRadiusCommandFactory, @@ -105,6 +106,7 @@ export interface EditableSpatiallyIndexedSkeletonSource readonly splitSkeletonsCommand: SpatialSkeletonSplitSkeletonsCommandFactory; readonly mergeSkeletonsCommand: SpatialSkeletonMergeSkeletonsCommandFactory; readonly insertNodesCommand?: SpatialSkeletonInsertNodesCommandFactory; + readonly deleteSubtreesCommand?: SpatialSkeletonDeleteSubtreesCommandFactory; readonly rerootCommand?: SpatialSkeletonRerootCommandFactory; readonly editNodeDescriptionCommand?: SpatialSkeletonEditNodeDescriptionCommandFactory; readonly editNodeTrueEndCommand?: SpatialSkeletonEditNodeTrueEndCommandFactory; diff --git a/src/skeleton/command_factories.ts b/src/skeleton/command_factories.ts index f889601bc..947b79a29 100644 --- a/src/skeleton/command_factories.ts +++ b/src/skeleton/command_factories.ts @@ -61,6 +61,7 @@ export type SpatialSkeletonEditCommandProperty = | "insertNodesCommand" | "moveNodesCommand" | "deleteNodesCommand" + | "deleteSubtreesCommand" | "rerootCommand" | "editNodeDescriptionCommand" | "editNodeTrueEndCommand" @@ -97,6 +98,11 @@ export const SPATIAL_SKELETON_EDIT_COMMAND_METADATA = [ commandProperty: "deleteNodesCommand", required: true, }, + { + action: SpatialSkeletonActions.deleteSubtrees, + commandProperty: "deleteSubtreesCommand", + required: false, + }, { action: SpatialSkeletonActions.reroot, commandProperty: "rerootCommand", @@ -173,6 +179,10 @@ export type SpatialSkeletonMoveNodesCommandFactory = SpatialSkeletonEditCommandFactory; export type SpatialSkeletonDeleteNodesCommandFactory = SpatialSkeletonEditCommandFactory; +export type SpatialSkeletonDeleteSubtreesCommandFactory = + SpatialSkeletonEditCommandFactory< + typeof SpatialSkeletonActions.deleteSubtrees + >; export type SpatialSkeletonRerootCommandFactory = SpatialSkeletonEditCommandFactory; export type SpatialSkeletonEditNodeDescriptionCommandFactory = diff --git a/src/skeleton/spatial_skeleton_manager.spec.ts b/src/skeleton/spatial_skeleton_manager.spec.ts index d47b7f092..cd6966ef4 100644 --- a/src/skeleton/spatial_skeleton_manager.spec.ts +++ b/src/skeleton/spatial_skeleton_manager.spec.ts @@ -116,6 +116,9 @@ describe("skeleton/spatial_skeleton_manager", () => { insertNodesCommand: makeCommandFactory( SpatialSkeletonActions.insertNodes, ), + deleteSubtreesCommand: makeCommandFactory( + SpatialSkeletonActions.deleteSubtrees, + ), rerootCommand: makeCommandFactory(SpatialSkeletonActions.reroot), readonly: false, listSkeletons: async () => [], @@ -136,6 +139,12 @@ describe("skeleton/spatial_skeleton_manager", () => { SpatialSkeletonActions.insertNodes, ), ).toBe(source.insertNodesCommand); + expect( + getSpatialSkeletonEditCommandFactoryForAction( + source as any, + SpatialSkeletonActions.deleteSubtrees, + ), + ).toBe(source.deleteSubtreesCommand); expect( getSpatialSkeletonEditCommandFactoryForAction( source as any, diff --git a/src/ui/skeleton_tab.ts b/src/ui/skeleton_tab.ts index 781cd7c0d..4d7802abd 100644 --- a/src/ui/skeleton_tab.ts +++ b/src/ui/skeleton_tab.ts @@ -21,6 +21,7 @@ import svg_chevron_right from "ikonate/icons/chevron-right.svg?raw"; import svg_chevrons_left from "ikonate/icons/chevrons-left.svg?raw"; import svg_chevrons_right from "ikonate/icons/chevrons-right.svg?raw"; import svg_circle from "ikonate/icons/circle.svg?raw"; +import svg_cut from "ikonate/icons/cut.svg?raw"; import svg_flag from "ikonate/icons/flag.svg?raw"; import svg_minus from "ikonate/icons/minus.svg?raw"; import svg_origin from "ikonate/icons/origin.svg?raw"; @@ -32,6 +33,7 @@ import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; import { getSegmentIdFromLayerSelectionValue } from "#src/layer/segmentation/selection.js"; import { executeSpatialSkeletonDeleteNode, + executeSpatialSkeletonDeleteSubtree, executeSpatialSkeletonNodeTrueEndUpdate, redoSpatialSkeletonCommand, showSpatialSkeletonActionError, @@ -310,12 +312,14 @@ export class SpatialSkeletonEditTab extends Tab { let navigationAllowed = false; let trueEndEditingAllowed = false; let nodeDeletionAllowed = false; + let subtreeDeletionAllowed = false; let nodeRerootAllowed = false; let pendingScrollToSelectedNode = false; let loadedNodeSummarySuffix = ""; let hoveredViewerNodeId: number | undefined; let hoveredListNodeId: number | undefined; const pendingDeleteNodes = new Set(); + const pendingDeleteSubtreeNodes = new Set(); const pendingRerootNodes = new Set(); const pendingTrueEndNodes = new Set(); const listIndexByNodeId = new Map(); @@ -832,6 +836,37 @@ export class SpatialSkeletonEditTab extends Tab { })(); }; + const deleteSubtree = (node: SpatiallyIndexedSkeletonNode) => { + if (!ensureActionsAllowed(SpatialSkeletonActions.deleteSubtrees)) return; + if (pendingDeleteSubtreeNodes.has(node.nodeId)) { + return; + } + const isWholeSkeleton = node.parentNodeId === undefined; + const confirmMessage = isWholeSkeleton + ? `Delete skeleton ${node.segmentId}? This can be undone.` + : `Delete subskeleton rooted at node ${node.nodeId}? This can be undone.`; + if (!globalThis.confirm(confirmMessage)) { + return; + } + pendingDeleteSubtreeNodes.add(node.nodeId); + updateDisplay(); + void (async () => { + try { + await executeSpatialSkeletonDeleteSubtree(layer, node); + refreshNodes(); + } catch (error) { + showSpatialSkeletonActionError( + isWholeSkeleton ? "delete skeleton" : "delete subskeleton", + error, + ); + updateDisplay(); + } finally { + pendingDeleteSubtreeNodes.delete(node.nodeId); + updateDisplay(); + } + })(); + }; + const rerootNode = (node: SpatiallyIndexedSkeletonNode) => { if ( !ensureActionsAllowed(SpatialSkeletonActions.reroot, { @@ -1358,6 +1393,24 @@ export class SpatialSkeletonEditTab extends Tab { !nodeDeletionAllowed || pendingDeleteNodes.has(node.nodeId), ), ); + let deleteSubtreeActionTitle = + node.parentNodeId === undefined + ? "delete skeleton" + : "delete subskeleton"; + if (pendingDeleteSubtreeNodes.has(node.nodeId)) { + deleteSubtreeActionTitle = + node.parentNodeId === undefined + ? "deleting skeleton" + : "deleting subskeleton"; + } + actions.appendChild( + makeRowActionButton( + svg_cut, + deleteSubtreeActionTitle, + () => deleteSubtree(node), + !subtreeDeletionAllowed || pendingDeleteSubtreeNodes.has(node.nodeId), + ), + ); row.appendChild(actions); row.appendChild(typeIcon); @@ -1563,6 +1616,10 @@ export class SpatialSkeletonEditTab extends Tab { layer.getSpatialSkeletonActionsDisabledReason( SpatialSkeletonActions.deleteNodes, ) === undefined; + const nextSubtreeDeletionAllowed = + layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.deleteSubtrees, + ) === undefined; const nextNodeRerootAllowed = layer.getSpatialSkeletonActionsDisabledReason( SpatialSkeletonActions.reroot, @@ -1575,12 +1632,14 @@ export class SpatialSkeletonEditTab extends Tab { navigationAllowed !== nextNavigationAllowed || trueEndEditingAllowed !== nextTrueEndEditingAllowed || nodeDeletionAllowed !== nextNodeDeletionAllowed || + subtreeDeletionAllowed !== nextSubtreeDeletionAllowed || nodeRerootAllowed !== nextNodeRerootAllowed; inspectionAllowed = nextInspectionAllowed; navigationAllowed = nextNavigationAllowed; trueEndEditingAllowed = nextTrueEndEditingAllowed; nodeDeletionAllowed = nextNodeDeletionAllowed; + subtreeDeletionAllowed = nextSubtreeDeletionAllowed; nodeRerootAllowed = nextNodeRerootAllowed; filterInput.disabled = !inspectionAllowed;