From 87ff9133411fda108945887e8fbbe7674509bfed Mon Sep 17 00:00:00 2001 From: Afonso Pinto Date: Fri, 10 Jul 2026 14:44:23 +0100 Subject: [PATCH 1/2] Merge of MetaCell/feature/optimistic-preview feat: Add optimistic preview on add, move and delete node actions --- docs/user-guide/skeleton_editing.rst | 10 + src/datasource/catmaid/api.spec.ts | 95 + src/datasource/catmaid/api.ts | 51 +- src/datasource/catmaid/frontend.ts | 8 +- .../catmaid/spatial_skeleton_commands.ts | 1852 +++++++++++++++-- .../catmaid/spatial_skeleton_edit_api.ts | 4 + src/layer/segmentation/index.spec.ts | 38 + src/layer/segmentation/index.ts | 27 + src/layer/segmentation/json_keys.ts | 1 + src/skeleton/command_history.ts | 8 + src/skeleton/command_protocol.ts | 1 + src/skeleton/commands.spec.ts | 1606 +++++++++++++- src/skeleton/commands.ts | 48 +- src/skeleton/spatial_skeleton_manager.ts | 124 +- src/ui/layer_data_sources_tab.ts | 49 + src/ui/skeleton_edit_tools.spec.ts | 9 +- ...skeleton_optimistic_edit_queue_tab.spec.ts | 175 ++ src/ui/skeleton_optimistic_edit_queue_tab.ts | 226 ++ src/ui/skeleton_tab.css | 169 ++ src/ui/skeleton_tab.ts | 19 +- 20 files changed, 4346 insertions(+), 174 deletions(-) create mode 100644 src/ui/skeleton_optimistic_edit_queue_tab.spec.ts create mode 100644 src/ui/skeleton_optimistic_edit_queue_tab.ts diff --git a/docs/user-guide/skeleton_editing.rst b/docs/user-guide/skeleton_editing.rst index 385b129d14..b08e1884f2 100644 --- a/docs/user-guide/skeleton_editing.rst +++ b/docs/user-guide/skeleton_editing.rst @@ -196,6 +196,16 @@ With the Edit tool active: - **Start a new skeleton** — :kbd:`Control`-click with no node selected to add a root node with no parent. +For CATMAID sources, adding child nodes is optimistic by default: the node is +previewed locally before CATMAID confirms it. If CATMAID rejects the request, +the preview is rolled back. Starting a new skeleton uses the normal +command-history path because CATMAID does not perform parent state checks for +that request. The **Source** tab includes a **Use CATMAID state checks** checkbox +below the source URL for the non-optimistic mode that sends CATMAID revision +state and waits for server confirmation. The **Skeleton** tab shows a compact +optimistic edit queue debug panel while optimistic mode is enabled or queued +actions are present. + Merge Tool ~~~~~~~~~~ diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index bdc3349e89..de8280ec48 100644 --- a/src/datasource/catmaid/api.spec.ts +++ b/src/datasource/catmaid/api.spec.ts @@ -780,6 +780,43 @@ describe("CatmaidClient skeleton editing methods", () => { ); }); + it("sends nocheck addNode requests as CATMAID state", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const abortController = new AbortController(); + const fetchMock = vi.fn().mockResolvedValue({ + treenode_id: 88, + skeleton_id: 13, + edition_time: "2026-03-29T12:00:00Z", + parent_edition_time: "2026-03-29T12:00:01Z", + }); + (client as any).fetchProjectEndpoint = fetchMock; + + await expect( + client.addNode( + 13, + 1, + 2, + 3, + 7, + { + node: { + nodeId: 7, + revisionToken: "2026-03-29T11:59:00Z", + }, + }, + { nocheck: true, signal: abortController.signal }, + ), + ).resolves.toMatchObject({ + nodeId: 88, + segmentId: 13, + }); + + const requestBody = getFetchBody(fetchMock); + expect(requestBody.get("nocheck")).toBeNull(); + expect(requestBody.get("state")).toBe(JSON.stringify({ nocheck: true })); + expect(getFetchInit(fetchMock).signal).toBe(abortController.signal); + }); + it("inserts nodes using CATMAID local parent-and-child state", async () => { const client = new CatmaidClient("https://example.invalid", 1); const fetchMock = vi.fn().mockResolvedValue({ @@ -1025,6 +1062,34 @@ describe("CatmaidClient skeleton editing methods", () => { ); }); + it("moves nodes with nocheck state when requested", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.fn().mockResolvedValue({ + updated: 1, + old_treenodes: [[42, "2026-03-29T12:10:00Z", 1, 2, 3]], + old_connectors: [], + }); + (client as any).fetchProjectEndpoint = fetchMock; + + await client.moveNode( + 42, + 10, + 11, + 12, + { + node: { + nodeId: 42, + revisionToken: "2026-03-29T12:00:00Z", + }, + }, + { nocheck: true }, + ); + + expect(getFetchBody(fetchMock).get("state")).toBe( + JSON.stringify({ nocheck: true }), + ); + }); + it("deletes nodes using neighborhood state and returns child revisions", async () => { const client = new CatmaidClient("https://example.invalid", 1); const fetchMock = vi.fn().mockResolvedValue({ @@ -1075,6 +1140,36 @@ describe("CatmaidClient skeleton editing methods", () => { ); }); + it("deletes nodes with nocheck state when requested", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.fn().mockResolvedValue({ + success: "Removed treenode successfully.", + children: [[12, "2026-03-29T12:20:00Z"]], + }); + (client as any).fetchProjectEndpoint = fetchMock; + + await client.deleteNode(11, { + childNodeIds: [12], + editContext: { + node: { + nodeId: 11, + parentNodeId: 7, + revisionToken: "2026-03-29T12:15:00Z", + }, + parent: { + nodeId: 7, + revisionToken: "2026-03-29T12:14:00Z", + }, + children: [{ nodeId: 12, revisionToken: "2026-03-29T12:13:00Z" }], + }, + nocheck: true, + }); + + expect(getFetchBody(fetchMock).get("state")).toBe( + JSON.stringify({ nocheck: true }), + ); + }); + it("updates descriptions 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 9360e445b8..79ed7085b7 100644 --- a/src/datasource/catmaid/api.ts +++ b/src/datasource/catmaid/api.ts @@ -119,6 +119,15 @@ export interface CatmaidDescriptionUpdateOptions { isTrueEnd?: boolean; } +export interface CatmaidAddNodeOptions { + nocheck?: boolean; + signal?: AbortSignal; +} + +export interface CatmaidMoveNodeOptions { + nocheck?: boolean; +} + export type CatmaidDeleteNodeResult = CatmaidSkeletonEditResult; export type CatmaidRerootResult = CatmaidSkeletonEditResult; @@ -145,6 +154,7 @@ export interface CatmaidSpatialSkeletonEditApi { z: number, parentId?: number, editContext?: CatmaidEditContext, + options?: CatmaidAddNodeOptions, ): Promise; deleteNode( nodeId: number, @@ -156,6 +166,7 @@ export interface CatmaidSpatialSkeletonEditApi { y: number, z: number, editContext?: CatmaidEditContext, + options?: CatmaidMoveNodeOptions, ): Promise; splitSkeleton( nodeId: number, @@ -203,6 +214,7 @@ export interface CatmaidSpatialSkeletonEditApi { interface CatmaidDeleteNodeOptions { childNodeIds?: readonly number[]; editContext?: CatmaidEditContext; + nocheck?: boolean; } class CatmaidNotFoundError extends Error { @@ -1624,13 +1636,18 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { y: number, z: number, editContext?: CatmaidEditContext, + options: CatmaidMoveNodeOptions = {}, ): Promise { const body = new URLSearchParams(); appendNodeUpdateRows(body, "t", [[nodeId, x, y, z]]); - appendCatmaidState( - body, - buildCatmaidMultiNodeState("move-node", editContext, [nodeId]), - ); + if (options.nocheck === true) { + appendCatmaidState(body, { nocheck: true }); + } else { + appendCatmaidState( + body, + buildCatmaidMultiNodeState("move-node", editContext, [nodeId]), + ); + } const response = await this.fetchProjectEndpoint(`node/update`, { method: "POST", @@ -1691,13 +1708,17 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { const body = new URLSearchParams({ treenode_id: nodeId.toString(), }); - appendCatmaidState( - body, - buildCatmaidNeighborhoodState("delete-node", editContext, { - expectedNodeId: nodeId, - expectedChildIds: normalizedChildIds, - }), - ); + if (options.nocheck === true) { + appendCatmaidState(body, { nocheck: true }); + } else { + appendCatmaidState( + body, + buildCatmaidNeighborhoodState("delete-node", editContext, { + expectedNodeId: nodeId, + expectedChildIds: normalizedChildIds, + }), + ); + } const response = await this.fetchProjectEndpoint(`treenode/delete`, { method: "POST", body: body, @@ -1717,6 +1738,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { z: number, parentId?: number, editContext?: CatmaidEditContext, + options: CatmaidAddNodeOptions = {}, ): Promise { const body = new URLSearchParams({ x: x.toString(), @@ -1727,11 +1749,16 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { if (Number.isSafeInteger(skeletonId) && skeletonId > 0) { body.append("skeleton_id", skeletonId.toString()); } - appendCatmaidState(body, buildCatmaidAddNodeState(parentId, editContext)); + if (options.nocheck === true) { + appendCatmaidState(body, { nocheck: true }); + } else { + appendCatmaidState(body, buildCatmaidAddNodeState(parentId, editContext)); + } const res = await this.fetchProjectEndpoint(`treenode/create`, { method: "POST", body: body, + signal: options.signal, }); const treenodeId = Number(res?.treenode_id); const nextSkeletonId = Number(res?.skeleton_id); diff --git a/src/datasource/catmaid/frontend.ts b/src/datasource/catmaid/frontend.ts index ce6fcf189e..dcfa9d0fc4 100644 --- a/src/datasource/catmaid/frontend.ts +++ b/src/datasource/catmaid/frontend.ts @@ -87,6 +87,8 @@ export class CatmaidSpatiallyIndexedSkeletonSource extends WithParameters( private readonly spatialSkeletonEditCommands = new CatmaidSpatialSkeletonEditCommands({ getClient: () => this.client, + getOptimisticSkeletonEdits: (layer) => + layer.optimisticSkeletonEdits.value, }); private client_?: CatmaidClient; @@ -351,7 +353,11 @@ export class CatmaidDataSourceProvider implements DataSourceProvider { ) as CredentialsProvider; const client = makeCatmaidClient( - { url: baseUrl, projectId, readonly: true }, + { + url: baseUrl, + projectId, + readonly: true, + }, credentialsProvider, ); diff --git a/src/datasource/catmaid/spatial_skeleton_commands.ts b/src/datasource/catmaid/spatial_skeleton_commands.ts index a67d4e6850..50e837e453 100644 --- a/src/datasource/catmaid/spatial_skeleton_commands.ts +++ b/src/datasource/catmaid/spatial_skeleton_commands.ts @@ -72,7 +72,10 @@ import { getSpatiallyIndexedSkeletonPathToRoot, getSpatiallyIndexedSkeletonSubtreeNodes, } from "#src/skeleton/node_traversal.js"; -import { getEditableSpatiallyIndexedSkeletonSource } from "#src/skeleton/spatial_skeleton_manager.js"; +import { + getEditableSpatiallyIndexedSkeletonSource, + type SpatialSkeletonOptimisticEditQueue, +} from "#src/skeleton/spatial_skeleton_manager.js"; import { StatusMessage } from "#src/status.js"; import { formatErrorMessage } from "#src/util/error.js"; @@ -128,6 +131,7 @@ interface CatmaidSpatialSkeletonMergeCommandPayload { export interface CatmaidSpatialSkeletonEditCommandContext { getClient(): CatmaidClient; + getOptimisticSkeletonEdits?(layer: SegmentationUserLayer): boolean; } interface CatmaidSpatialSkeletonEditOperations { @@ -1003,173 +1007,1653 @@ async function applyNodeDescriptionAndTrueEnd( sourceState: trueEndResult.sourceState ?? updatedNode.sourceState, }; } - return updatedNode; -} + return updatedNode; +} + +async function restoreNodeAttributes( + layer: SegmentationUserLayer, + editOperations: CatmaidSpatialSkeletonEditOperations, + createdNode: SpatiallyIndexedSkeletonNode, + snapshot: SpatiallyIndexedSkeletonNode, + options: { applyLocalState?: boolean } = {}, +) { + let nextNode = cloneNodeSnapshot(createdNode); + if (snapshot.radius !== undefined && snapshot.radius !== nextNode.radius) { + const radiusResult = await editOperations.commitRadius({ + node: nextNode, + radius: snapshot.radius, + }); + nextNode = { + ...nextNode, + radius: snapshot.radius, + sourceState: radiusResult.sourceState ?? nextNode.sourceState, + }; + } + if ( + snapshot.confidence !== undefined && + snapshot.confidence !== nextNode.confidence + ) { + const confidenceResult = await editOperations.commitConfidence({ + node: nextNode, + confidence: snapshot.confidence, + }); + nextNode = { + ...nextNode, + confidence: snapshot.confidence, + sourceState: confidenceResult.sourceState ?? nextNode.sourceState, + }; + } + if ( + nextNode.description !== snapshot.description || + nextNode.isTrueEnd !== snapshot.isTrueEnd + ) { + nextNode = await applyNodeDescriptionAndTrueEnd( + editOperations, + nextNode, + snapshot, + ); + } + if (options.applyLocalState ?? true) { + layer.spatialSkeletonState.upsertCachedNode(nextNode); + } + return nextNode; +} + +interface ResolvedCatmaidAddNodeContext { + skeletonLayer: SpatiallyIndexedSkeletonLayer; + parentNode: SpatiallyIndexedSkeletonNode | undefined; + segmentId: number; +} + +class AddNodeCommand implements SpatialSkeletonCommand { + readonly label = "Add node"; + readonly executeOptimistically?: ( + context: SpatialSkeletonCommandContext, + ) => Promise; + private stableNodeId: number | undefined; + private stableSegmentId: number | undefined; + + constructor( + private layer: SegmentationUserLayer, + private stableParentNodeId: number | undefined, + private targetSkeletonId: number, + private positionInModelSpace: Float32Array, + private editOperations: CatmaidSpatialSkeletonEditOperations, + optimistic = false, + ) { + if (optimistic && stableParentNodeId !== undefined) { + this.executeOptimistically = async (context) => { + await getOrCreateCatmaidOptimisticEditQueue( + this.layer, + this.editOperations, + ).enqueueAddNode(this, context, { + moveView: true, + pinSegment: true, + statusPrefix: "Added", + }); + }; + } + } + + getPositionInModelSpace() { + return new Float32Array(this.positionInModelSpace); + } + + markOptimisticCommit(stableNodeId: number, stableSegmentId: number) { + this.stableNodeId = stableNodeId; + this.stableSegmentId = stableSegmentId; + } + + private recordCreatedNodeMapping( + result: CatmaidSpatialSkeletonAddNodeResult, + ) { + 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, + ); + } + } + + async resolveAddNodeContext(options: { + requireFullParent: boolean; + }): Promise { + const { skeletonLayer } = getEditableSkeletonSourceForLayer(this.layer); + const commandMappings = + this.layer.spatialSkeletonState.commandHistory.mappings; + const currentParentNodeId = + this.stableParentNodeId === undefined + ? undefined + : commandMappings.resolveNodeId(this.stableParentNodeId); + let parentNode: SpatiallyIndexedSkeletonNode | undefined; + let resolvedSkeletonId = + commandMappings.resolveSegmentId(this.targetSkeletonId) ?? + this.targetSkeletonId; + if (currentParentNodeId !== undefined) { + if (options.requireFullParent) { + parentNode = ( + await getResolvedNodeForEdit( + this.layer, + this.stableParentNodeId!, + commandMappings.getStableOrCurrentSegmentId(this.targetSkeletonId), + ) + ).node; + } else { + const resolvedNodeContext = getResolvedNodeContextForEdit( + this.layer, + this.stableParentNodeId!, + commandMappings.getStableOrCurrentSegmentId(this.targetSkeletonId), + ); + parentNode = + resolvedNodeContext.cachedNode ?? + resolvedNodeContext.skeletonLayer.getNode( + resolvedNodeContext.currentNodeId, + ); + } + if (parentNode === undefined) { + throw new Error( + `Unable to resolve parent node ${currentParentNodeId}.`, + ); + } + resolvedSkeletonId = parentNode.segmentId; + } + return { + skeletonLayer, + parentNode, + segmentId: resolvedSkeletonId, + }; + } + + private async addNode( + _context: SpatialSkeletonCommandContext, + options: { + moveView: boolean; + pinSegment: boolean; + statusPrefix: string; + }, + ) { + const { skeletonLayer, parentNode, segmentId } = + await this.resolveAddNodeContext({ requireFullParent: true }); + const result = await this.editOperations.commitAddNode({ + segmentId, + position: this.positionInModelSpace, + parentNode, + }); + this.recordCreatedNodeMapping(result); + applyCreatedNodeToCache( + this.layer, + skeletonLayer, + result, + parentNode?.nodeId, + this.positionInModelSpace, + { + focusSelection: true, + moveView: options.moveView, + pinSegment: options.pinSegment, + retainOverlaySegment: parentNode !== undefined, + }, + ); + 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 commitAndApplyDeleteNode( + this.layer, + this.editOperations, + this.stableNodeId, + this.stableSegmentId, + { + childMode: "none", + invalidateSourceCells: false, + moveView: false, + }, + ); + StatusMessage.showTemporaryMessage( + `Undid add node ${resolvedNode.node.nodeId}.`, + ); + } + + async redo(context: SpatialSkeletonCommandContext) { + await this.addNode(context, { + moveView: false, + pinSegment: false, + statusPrefix: "Redid add of", + }); + } +} + +enum CatmaidOptimisticEditStatus { + Pending = "pending", + InFlight = "inFlight", + CancelRequested = "cancelRequested", + Committed = "committed", + Failed = "failed", + RolledBack = "rolledBack", +} + +type CatmaidOptimisticEditKind = "addNode" | "moveNode" | "deleteNode"; + +interface CatmaidOptimisticEditEntryBase { + readonly operationId: number; + readonly kind: CatmaidOptimisticEditKind; + readonly dependencies: number[]; + inFlightWarningTimeout?: ReturnType; + inFlightWarning?: StatusMessage; + status: CatmaidOptimisticEditStatus; +} + +interface CatmaidOptimisticAddNodeEntry extends CatmaidOptimisticEditEntryBase { + readonly kind: "addNode"; + readonly command: AddNodeCommand; + readonly tempNodeId: number; + readonly positionInModelSpace: Float32Array; + readonly parentTempNodeId?: number; + parentNodeId: number; + parentNodeForServer?: SpatiallyIndexedSkeletonNode; + result?: CatmaidSpatialSkeletonAddNodeResult; +} + +interface CatmaidOptimisticMoveNodeEntry + extends CatmaidOptimisticEditEntryBase { + readonly kind: "moveNode"; + readonly command: MoveNodeCommand; + nodeId: number; + segmentId: number; + nodeForServer: SpatiallyIndexedSkeletonNode; + result?: CatmaidSpatialSkeletonNodeSourceStateResult; + readonly beforePositionInModelSpace: Float32Array; + readonly afterPositionInModelSpace: Float32Array; +} + +interface CatmaidOptimisticDeleteNodeEntry + extends CatmaidOptimisticEditEntryBase { + readonly kind: "deleteNode"; + readonly command: DeleteNodeCommand; + nodeId: number; + segmentId: number; + deleteContext: { + node: SpatiallyIndexedSkeletonNode; + parentNode: SpatiallyIndexedSkeletonNode | undefined; + childNodes: readonly SpatiallyIndexedSkeletonNode[]; + }; + segmentNodes: readonly SpatiallyIndexedSkeletonNode[]; + affectedPositions: readonly ArrayLike[]; + result?: CatmaidSpatialSkeletonDeleteNodeResult; +} + +type CatmaidOptimisticEditEntry = + | CatmaidOptimisticAddNodeEntry + | CatmaidOptimisticMoveNodeEntry + | CatmaidOptimisticDeleteNodeEntry; + +const CATMAID_OPTIMISTIC_TEMP_ID_START = Number.MAX_SAFE_INTEGER; +const CATMAID_OPTIMISTIC_IN_FLIGHT_WARNING_DELAY_MS = 30_000; + +const catmaidOptimisticEditQueues = new WeakMap< + object, + CatmaidOptimisticEditQueue +>(); + +function getOrCreateCatmaidOptimisticEditQueue( + layer: SegmentationUserLayer, + editOperations: CatmaidSpatialSkeletonEditOperations, +) { + const state = layer.spatialSkeletonState; + const existingQueue = catmaidOptimisticEditQueues.get(state); + if (existingQueue?.usesEditOperations(editOperations)) { + state.setOptimisticEditQueue(existingQueue); + return existingQueue; + } + existingQueue?.dispose(); + const queue = new CatmaidOptimisticEditQueue(layer, editOperations); + catmaidOptimisticEditQueues.set(state, queue); + state.setOptimisticEditQueue(queue); + return queue; +} + +function deleteCatmaidOptimisticEditQueue( + layer: SegmentationUserLayer, + queue: CatmaidOptimisticEditQueue, +) { + const state = layer.spatialSkeletonState; + if (catmaidOptimisticEditQueues.get(state) === queue) { + catmaidOptimisticEditQueues.delete(state); + } +} + +function positionsEqual( + first: ArrayLike | undefined, + second: ArrayLike, +) { + if (first === undefined || first.length < 3 || second.length < 3) { + return false; + } + return ( + first[0] === second[0] && first[1] === second[1] && first[2] === second[2] + ); +} + +class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { + private entries: CatmaidOptimisticEditEntry[] = []; + private nextOperationId = 1; + private nextTempId = CATMAID_OPTIMISTIC_TEMP_ID_START; + private draining = false; + private disposed = false; + + constructor( + private readonly layer: SegmentationUserLayer, + private readonly editOperations: CatmaidSpatialSkeletonEditOperations, + ) {} + + usesEditOperations(editOperations: CatmaidSpatialSkeletonEditOperations) { + return this.editOperations === editOperations; + } + + canUndo() { + return this.entries.some( + (entry) => + entry.status === CatmaidOptimisticEditStatus.Pending || + entry.status === CatmaidOptimisticEditStatus.InFlight, + ); + } + + hasUnconfirmedActions() { + return this.entries.some( + (entry) => + entry.status === CatmaidOptimisticEditStatus.Pending || + entry.status === CatmaidOptimisticEditStatus.InFlight || + entry.status === CatmaidOptimisticEditStatus.CancelRequested, + ); + } + + getDebugSnapshot() { + return this.entries.map((entry) => ({ + operationId: entry.operationId, + kind: entry.kind, + status: entry.status, + tempNodeId: entry.kind === "addNode" ? entry.tempNodeId : undefined, + parentNodeId: entry.kind === "addNode" ? entry.parentNodeId : undefined, + parentTempNodeId: + entry.kind === "addNode" ? entry.parentTempNodeId : undefined, + nodeId: entry.kind === "addNode" ? entry.result?.nodeId : entry.nodeId, + segmentId: + entry.kind === "addNode" + ? (entry.result?.segmentId ?? + this.layer.spatialSkeletonState.getCachedNode(entry.tempNodeId) + ?.segmentId) + : entry.segmentId, + })); + } + + clearSettled() { + const changed = this.pruneSettledEntries(); + if (changed) { + this.notifyChanged(); + } + return changed; + } + + clear() { + const changed = this.rollbackAndCancelForReset(); + if (changed) { + this.notifyChanged(); + } + return changed; + } + + dispose() { + const changed = this.rollbackAndCancelForReset(); + this.disposed = true; + deleteCatmaidOptimisticEditQueue(this.layer, this); + return changed; + } + + private pruneSettledEntries() { + const dependenciesOfUnsettledEntries = new Set(); + for (const entry of this.entries) { + if (this.isSettled(entry)) continue; + for (const dependency of entry.dependencies) { + dependenciesOfUnsettledEntries.add(dependency); + } + } + const removedEntries: CatmaidOptimisticEditEntry[] = []; + const nextEntries = this.entries.filter((entry) => { + const keep = + !this.isSettled(entry) || + dependenciesOfUnsettledEntries.has(entry.operationId); + if (!keep) { + removedEntries.push(entry); + } + return keep; + }); + if (nextEntries.length === this.entries.length) { + return false; + } + for (const entry of removedEntries) { + this.clearInFlightWarning(entry); + } + this.entries = nextEntries; + return true; + } + + private rollbackAndCancelForReset() { + const changed = this.entries.length !== 0; + for (const entry of [...this.entries].reverse()) { + this.clearInFlightWarning(entry); + if ( + entry.status === CatmaidOptimisticEditStatus.InFlight || + entry.status === CatmaidOptimisticEditStatus.CancelRequested + ) { + this.rollbackPreview(entry); + entry.status = CatmaidOptimisticEditStatus.CancelRequested; + } else if (entry.status === CatmaidOptimisticEditStatus.Pending) { + this.rollbackPreview(entry); + entry.status = CatmaidOptimisticEditStatus.RolledBack; + } + } + this.entries = this.entries.filter( + (entry) => entry.status === CatmaidOptimisticEditStatus.CancelRequested, + ); + return changed; + } + + private notifyChanged() { + if (this.disposed) { + return; + } + this.layer.spatialSkeletonState.notifyOptimisticEditQueueChanged(); + } + + private allocateTempId() { + while (this.nextTempId > 0) { + const tempId = this.nextTempId--; + if ( + this.layer.spatialSkeletonState.getCachedNode(tempId) === undefined && + this.layer.spatialSkeletonState.getCachedSegmentNodes(tempId) === + undefined && + !this.entries.some( + (entry) => entry.kind === "addNode" && entry.tempNodeId === tempId, + ) + ) { + return tempId; + } + } + throw new Error("Unable to allocate optimistic skeleton edit id."); + } + + private findEntryForTempNode( + nodeId: number | undefined, + ): CatmaidOptimisticAddNodeEntry | undefined { + if (nodeId === undefined) return undefined; + return this.entries.find( + (entry): entry is CatmaidOptimisticAddNodeEntry => + entry.kind === "addNode" && + entry.tempNodeId === nodeId && + entry.status !== CatmaidOptimisticEditStatus.Failed && + entry.status !== CatmaidOptimisticEditStatus.RolledBack, + ); + } + + private isSettled(entry: CatmaidOptimisticEditEntry) { + return ( + entry.status === CatmaidOptimisticEditStatus.Committed || + entry.status === CatmaidOptimisticEditStatus.Failed || + entry.status === CatmaidOptimisticEditStatus.RolledBack + ); + } + + private isActive(entry: CatmaidOptimisticEditEntry) { + return !this.isSettled(entry); + } + + private startInFlightWarning(entry: CatmaidOptimisticEditEntry) { + this.clearInFlightWarning(entry); + entry.inFlightWarningTimeout = setTimeout(() => { + entry.inFlightWarningTimeout = undefined; + if ( + this.disposed || + entry.status !== CatmaidOptimisticEditStatus.InFlight + ) { + return; + } + entry.inFlightWarning = StatusMessage.showErrorMessage( + "CATMAID has not confirmed the optimistic skeleton edit yet. Wait for it to finish before continuing.", + ); + }, CATMAID_OPTIMISTIC_IN_FLIGHT_WARNING_DELAY_MS); + } + + private clearInFlightWarning(entry: CatmaidOptimisticEditEntry) { + if (entry.inFlightWarningTimeout !== undefined) { + clearTimeout(entry.inFlightWarningTimeout); + entry.inFlightWarningTimeout = undefined; + } + if (entry.inFlightWarning !== undefined) { + entry.inFlightWarning.dispose(); + entry.inFlightWarning = undefined; + } + } + + private isDependencyCommitted(operationId: number) { + const dependency = this.entries.find( + (entry) => entry.operationId === operationId, + ); + // Committed dependency entries must be retained while active dependents + // reference them; if one is missing, the dependency is not satisfied. + return dependency?.status === CatmaidOptimisticEditStatus.Committed; + } + + private getActiveDependenciesForNodes(nodeIds: Iterable) { + const nodeIdSet = new Set(nodeIds); + const dependencies: number[] = []; + for (const entry of this.entries) { + if (!this.isActive(entry)) continue; + if (this.entryTouchesAnyNode(entry, nodeIdSet)) { + dependencies.push(entry.operationId); + } + } + return dependencies; + } + + private entryTouchesAnyNode( + entry: CatmaidOptimisticEditEntry, + nodeIds: ReadonlySet, + ) { + switch (entry.kind) { + case "addNode": + return ( + nodeIds.has(entry.tempNodeId) || + (entry.result !== undefined && nodeIds.has(entry.result.nodeId)) + ); + case "moveNode": + return nodeIds.has(entry.nodeId); + case "deleteNode": + return ( + nodeIds.has(entry.nodeId) || + entry.deleteContext.childNodes.some((child) => + nodeIds.has(child.nodeId), + ) + ); + } + } + + private getExpectedAddNodeSegmentId( + entry: CatmaidOptimisticAddNodeEntry, + fallbackSegmentId?: number, + ) { + return ( + this.layer.spatialSkeletonState.getCachedNode(entry.parentNodeId) + ?.segmentId ?? fallbackSegmentId + ); + } + + private hasActiveMoveDependent(entry: CatmaidOptimisticAddNodeEntry) { + return this.entries.some( + (candidate) => + candidate.kind === "moveNode" && + this.isActive(candidate) && + candidate.dependencies.includes(entry.operationId), + ); + } + + private hasActiveDeleteDependent(entry: CatmaidOptimisticAddNodeEntry) { + return this.entries.some( + (candidate) => + candidate.kind === "deleteNode" && + this.isActive(candidate) && + candidate.dependencies.includes(entry.operationId), + ); + } + + private previewAddNodeMatchesEntry( + entry: CatmaidOptimisticAddNodeEntry, + previewNode: SpatiallyIndexedSkeletonNode | undefined, + expectedSegmentId?: number, + ): previewNode is SpatiallyIndexedSkeletonNode { + return ( + previewNode !== undefined && + previewNode.nodeId === entry.tempNodeId && + previewNode.parentNodeId === entry.parentNodeId && + (positionsEqual(previewNode.position, entry.positionInModelSpace) || + this.hasActiveMoveDependent(entry)) && + (expectedSegmentId === undefined || + previewNode.segmentId === expectedSegmentId) + ); + } + + private handlePreviewCollision( + entry: CatmaidOptimisticAddNodeEntry, + previewNode: SpatiallyIndexedSkeletonNode | undefined, + expectedSegmentId?: number, + result?: CatmaidSpatialSkeletonAddNodeResult, + ) { + const segmentIds = [ + expectedSegmentId, + previewNode?.segmentId, + result?.segmentId, + ].filter( + (segmentId): segmentId is number => + segmentId !== undefined && + Number.isSafeInteger(segmentId) && + segmentId > 0, + ); + if (segmentIds.length !== 0) { + this.layer.spatialSkeletonState.invalidateCachedSegments(segmentIds); + } + skeletonLayerFromLayer(this.layer)?.invalidateSourceCellsForPositions([ + entry.positionInModelSpace, + previewNode?.position, + ]); + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + StatusMessage.showErrorMessage( + `Optimistic skeleton edit temporary id ${entry.tempNodeId} conflicted with loaded skeleton data. The affected skeleton cache was invalidated; refresh the skeleton to sync before continuing.`, + ); + } + + private rollbackPreview(entry: CatmaidOptimisticEditEntry) { + switch (entry.kind) { + case "addNode": + return this.rollbackAddNodePreview(entry); + case "moveNode": + return this.rollbackMoveNodePreview(entry); + case "deleteNode": + return this.rollbackDeleteNodePreview(entry); + } + } + + private rollbackAddNodePreview(entry: CatmaidOptimisticAddNodeEntry) { + const previewNode = this.layer.spatialSkeletonState.getCachedNode( + entry.tempNodeId, + ); + if (previewNode === undefined) { + return false; + } + const expectedSegmentId = this.getExpectedAddNodeSegmentId( + entry, + previewNode.segmentId, + ); + if ( + !this.previewAddNodeMatchesEntry(entry, previewNode, expectedSegmentId) + ) { + this.handlePreviewCollision(entry, previewNode, expectedSegmentId); + return false; + } + const parentNode = + previewNode.parentNodeId === undefined + ? undefined + : this.layer.spatialSkeletonState.getCachedNode( + previewNode.parentNodeId, + ); + this.layer.spatialSkeletonState.removeCachedNode(entry.tempNodeId, { + parentNodeId: previewNode.parentNodeId, + childNodeIds: [], + }); + const remainingNodes = + this.layer.spatialSkeletonState.getCachedSegmentNodes( + previewNode.segmentId, + ) ?? []; + if (remainingNodes.length === 0) { + removeVisibleSegment(this.layer, previewNode.segmentId, { + deselect: true, + }); + } + if ( + this.layer.selectedSpatialSkeletonNodeInfo.value?.nodeId === + entry.tempNodeId + ) { + if (parentNode !== undefined) { + this.layer.selectSpatialSkeletonNode( + parentNode.nodeId, + this.layer.manager.root.selectionState.pin.value, + { + segmentId: parentNode.segmentId, + position: parentNode.position, + }, + ); + } else { + this.layer.clearSpatialSkeletonNodeSelection( + this.layer.manager.root.selectionState.pin.value, + ); + } + } + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + return true; + } + + private rollbackMoveNodePreview(entry: CatmaidOptimisticMoveNodeEntry) { + const cachedNode = this.layer.spatialSkeletonState.getCachedNode( + entry.nodeId, + ); + if (cachedNode === undefined) { + return false; + } + this.layer.spatialSkeletonState.moveCachedNode( + entry.nodeId, + entry.beforePositionInModelSpace, + ); + if ( + this.layer.selectedSpatialSkeletonNodeInfo.value?.nodeId === entry.nodeId + ) { + this.layer.selectSpatialSkeletonNode( + entry.nodeId, + this.layer.manager.root.selectionState.pin.value, + { + segmentId: cachedNode.segmentId, + position: entry.beforePositionInModelSpace, + }, + ); + } + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + return true; + } + + private rollbackDeleteNodePreview(entry: CatmaidOptimisticDeleteNodeEntry) { + const { node, childNodes } = entry.deleteContext; + const restoredNode = { + ...node, + position: new Float32Array(node.position), + }; + this.layer.spatialSkeletonState.upsertCachedNode(restoredNode, { + allowUncachedSegment: true, + }); + for (const childNode of childNodes) { + this.layer.spatialSkeletonState.setCachedNodeParent( + childNode.nodeId, + restoredNode.nodeId, + ); + } + ensureVisibleSegment(this.layer, restoredNode.segmentId); + this.layer.selectSpatialSkeletonNode( + restoredNode.nodeId, + this.layer.manager.root.selectionState.pin.value, + { + segmentId: restoredNode.segmentId, + position: restoredNode.position, + }, + ); + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + return true; + } + + private removeRestoredDeletePreview(entry: CatmaidOptimisticDeleteNodeEntry) { + applyDeleteNodeToCache( + this.layer, + entry.deleteContext, + { moveView: false }, + entry.result?.nodeSourceStateUpdates, + ); + } + + private rollbackEntryAndDependents( + entry: CatmaidOptimisticEditEntry, + status: + | CatmaidOptimisticEditStatus.Failed + | CatmaidOptimisticEditStatus.RolledBack, + ) { + const affectedEntries = new Set(); + const visit = (currentEntry: CatmaidOptimisticEditEntry) => { + if (affectedEntries.has(currentEntry)) return; + affectedEntries.add(currentEntry); + for (const candidate of this.entries) { + if (this.isSettled(candidate)) { + continue; + } + if (candidate.dependencies.includes(currentEntry.operationId)) { + visit(candidate); + } + } + }; + visit(entry); + for (const affectedEntry of [...affectedEntries].reverse()) { + this.clearInFlightWarning(affectedEntry); + if ( + affectedEntry.status === CatmaidOptimisticEditStatus.InFlight || + affectedEntry.status === CatmaidOptimisticEditStatus.CancelRequested + ) { + this.rollbackPreview(affectedEntry); + affectedEntry.status = CatmaidOptimisticEditStatus.CancelRequested; + continue; + } + this.rollbackPreview(affectedEntry); + affectedEntry.status = status; + } + this.notifyChanged(); + } + + private rollbackUnconfirmedDependents( + entry: CatmaidOptimisticEditEntry, + status: + | CatmaidOptimisticEditStatus.Failed + | CatmaidOptimisticEditStatus.RolledBack, + ) { + const affectedEntries = new Set(); + const visit = (currentEntry: CatmaidOptimisticEditEntry) => { + for (const candidate of this.entries) { + if ( + this.isSettled(candidate) || + !candidate.dependencies.includes(currentEntry.operationId) || + affectedEntries.has(candidate) + ) { + continue; + } + affectedEntries.add(candidate); + visit(candidate); + } + }; + visit(entry); + for (const affectedEntry of [...affectedEntries].reverse()) { + this.clearInFlightWarning(affectedEntry); + if ( + affectedEntry.status === CatmaidOptimisticEditStatus.InFlight || + affectedEntry.status === CatmaidOptimisticEditStatus.CancelRequested + ) { + this.rollbackPreview(affectedEntry); + affectedEntry.status = CatmaidOptimisticEditStatus.CancelRequested; + continue; + } + this.rollbackPreview(affectedEntry); + affectedEntry.status = status; + } + if (affectedEntries.size !== 0) { + this.notifyChanged(); + } + } + + async undoLatest() { + const entry = [...this.entries] + .reverse() + .find( + (candidate) => + candidate.status === CatmaidOptimisticEditStatus.Pending || + candidate.status === CatmaidOptimisticEditStatus.InFlight, + ); + if (entry === undefined) { + return false; + } + if (entry.status === CatmaidOptimisticEditStatus.InFlight) { + entry.status = CatmaidOptimisticEditStatus.CancelRequested; + this.rollbackEntryAndDependents( + entry, + CatmaidOptimisticEditStatus.RolledBack, + ); + return true; + } + this.rollbackEntryAndDependents( + entry, + CatmaidOptimisticEditStatus.RolledBack, + ); + if (this.pruneSettledEntries()) { + this.notifyChanged(); + } + return true; + } + + async enqueueAddNode( + command: AddNodeCommand, + _context: SpatialSkeletonCommandContext, + options: { + moveView: boolean; + pinSegment: boolean; + statusPrefix: string; + }, + ) { + const { skeletonLayer, parentNode, segmentId } = + await command.resolveAddNodeContext({ requireFullParent: false }); + if (parentNode === undefined) { + throw new Error("Optimistic add-node requires a parent node."); + } + const tempNodeId = this.allocateTempId(); + const parentEntry = this.findEntryForTempNode(parentNode.nodeId); + const positionInModelSpace = command.getPositionInModelSpace(); + const entry: CatmaidOptimisticAddNodeEntry = { + operationId: this.nextOperationId++, + kind: "addNode", + command, + dependencies: parentEntry === undefined ? [] : [parentEntry.operationId], + tempNodeId, + positionInModelSpace, + parentNodeId: parentNode.nodeId, + parentTempNodeId: parentEntry?.tempNodeId, + status: CatmaidOptimisticEditStatus.Pending, + }; + this.entries.push(entry); + applyCreatedNodeToCache( + this.layer, + skeletonLayer, + { + nodeId: tempNodeId, + segmentId, + }, + parentNode.nodeId, + positionInModelSpace, + { + focusSelection: true, + moveView: options.moveView, + pinSegment: options.pinSegment, + retainOverlaySegment: true, + }, + ); + this.notifyChanged(); + void this.drain(); + } + + async enqueueMoveNode(command: MoveNodeCommand) { + const { node, skeletonLayer } = await command.resolveMoveContext(); + const dependencies = this.getActiveDependenciesForNodes([node.nodeId]); + const beforePositionInModelSpace = command.getBeforePositionInModelSpace(); + const afterPositionInModelSpace = command.getAfterPositionInModelSpace(); + const entry: CatmaidOptimisticMoveNodeEntry = { + operationId: this.nextOperationId++, + kind: "moveNode", + command, + dependencies, + nodeId: node.nodeId, + segmentId: node.segmentId, + nodeForServer: cloneNodeSnapshot(node), + beforePositionInModelSpace, + afterPositionInModelSpace, + status: CatmaidOptimisticEditStatus.Pending, + }; + this.entries.push(entry); + skeletonLayer.retainOverlaySegment(node.segmentId); + this.layer.spatialSkeletonState.moveCachedNode( + node.nodeId, + afterPositionInModelSpace, + ); + if ( + this.layer.selectedSpatialSkeletonNodeInfo.value?.nodeId === node.nodeId + ) { + this.layer.selectSpatialSkeletonNode( + node.nodeId, + this.layer.manager.root.selectionState.pin.value, + { + segmentId: node.segmentId, + position: afterPositionInModelSpace, + }, + ); + } + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + this.notifyChanged(); + void this.drain(); + } + + async enqueueDeleteNode(command: DeleteNodeCommand) { + const { resolvedNode, deleteContext } = + await command.resolveDeleteContext(); + const dependencies = this.getActiveDependenciesForNodes([ + deleteContext.node.nodeId, + ...deleteContext.childNodes.map((child) => child.nodeId), + ]); + const entry: CatmaidOptimisticDeleteNodeEntry = { + operationId: this.nextOperationId++, + kind: "deleteNode", + command, + dependencies, + nodeId: deleteContext.node.nodeId, + segmentId: deleteContext.node.segmentId, + deleteContext: { + node: cloneNodeSnapshot(deleteContext.node), + parentNode: + deleteContext.parentNode === undefined + ? undefined + : cloneNodeSnapshot(deleteContext.parentNode), + childNodes: deleteContext.childNodes.map(cloneNodeSnapshot), + }, + segmentNodes: resolvedNode.segmentNodes.map(cloneNodeSnapshot), + affectedPositions: collectUniqueNodePositions( + [deleteContext.node], + [deleteContext.parentNode], + deleteContext.childNodes, + ), + status: CatmaidOptimisticEditStatus.Pending, + }; + this.entries.push(entry); + applyDeleteNodeToCache(this.layer, deleteContext, { moveView: true }, []); + const remainingNodes = + this.layer.spatialSkeletonState.getCachedSegmentNodes( + deleteContext.node.segmentId, + ) ?? []; + if (remainingNodes.length > 0) { + resolvedNode.skeletonLayer.retainOverlaySegment( + deleteContext.node.segmentId, + ); + } else { + resolvedNode.skeletonLayer.markSegmentEdited( + deleteContext.node.segmentId, + ); + } + this.notifyChanged(); + void this.drain(); + } + + private async drain() { + if (this.draining) { + return; + } + this.draining = true; + try { + while (!this.disposed) { + const entry = this.entries.find( + (candidate) => + candidate.status === CatmaidOptimisticEditStatus.Pending && + candidate.dependencies.every((operationId) => + this.isDependencyCommitted(operationId), + ), + ); + if (entry === undefined) { + break; + } + await this.confirmEntry(entry); + this.pruneSettledEntries(); + } + } finally { + this.draining = false; + this.notifyChanged(); + } + } + + private async confirmEntry(entry: CatmaidOptimisticEditEntry) { + entry.status = CatmaidOptimisticEditStatus.InFlight; + this.startInFlightWarning(entry); + this.notifyChanged(); + try { + switch (entry.kind) { + case "addNode": + await this.confirmAddNodeEntry(entry); + return; + case "moveNode": + await this.confirmMoveNodeEntry(entry); + return; + case "deleteNode": + await this.confirmDeleteNodeEntry(entry); + return; + } + } finally { + this.clearInFlightWarning(entry); + this.pruneSettledEntries(); + this.notifyChanged(); + } + } + + private async confirmAddNodeEntry(entry: CatmaidOptimisticAddNodeEntry) { + try { + const resolvedContext = await entry.command.resolveAddNodeContext({ + requireFullParent: false, + }); + if (resolvedContext.parentNode === undefined) { + throw new Error("Optimistic add-node requires a parent node."); + } + entry.parentNodeForServer = resolvedContext.parentNode; + const result = await this.editOperations.commitAddNode({ + segmentId: resolvedContext.segmentId, + position: entry.positionInModelSpace, + parentNode: resolvedContext.parentNode, + nocheck: true, + }); + entry.result = result; + if (entry.status === CatmaidOptimisticEditStatus.CancelRequested) { + await this.compensateCanceledAddCommit(entry, result); + entry.status = CatmaidOptimisticEditStatus.RolledBack; + return; + } + await this.reconcileCommittedAddEntry( + entry, + resolvedContext.skeletonLayer, + resolvedContext.parentNode, + result, + ); + } catch (error) { + if (entry.status === CatmaidOptimisticEditStatus.CancelRequested) { + entry.status = CatmaidOptimisticEditStatus.RolledBack; + return; + } + entry.status = CatmaidOptimisticEditStatus.Failed; + this.rollbackEntryAndDependents( + entry, + CatmaidOptimisticEditStatus.Failed, + ); + StatusMessage.showErrorMessage( + `CATMAID rejected node creation. The optimistic preview was removed. ${formatErrorMessage(error)}`, + ); + } finally { + this.notifyChanged(); + } + } + + private async confirmMoveNodeEntry(entry: CatmaidOptimisticMoveNodeEntry) { + try { + const result = await this.editOperations.commitMoveNode({ + node: entry.nodeForServer, + position: entry.afterPositionInModelSpace, + nocheck: true, + }); + entry.result = result; + if (entry.status === CatmaidOptimisticEditStatus.CancelRequested) { + await this.compensateCanceledMoveCommit(entry, result); + entry.status = CatmaidOptimisticEditStatus.RolledBack; + return; + } + this.reconcileCommittedMoveEntry(entry, result); + entry.status = CatmaidOptimisticEditStatus.Committed; + await this.layer.spatialSkeletonState.commandHistory.recordExecuted( + entry.command, + ); + } catch (error) { + if (entry.status === CatmaidOptimisticEditStatus.CancelRequested) { + entry.status = CatmaidOptimisticEditStatus.RolledBack; + return; + } + entry.status = CatmaidOptimisticEditStatus.Failed; + this.rollbackEntryAndDependents( + entry, + CatmaidOptimisticEditStatus.Failed, + ); + StatusMessage.showErrorMessage( + `CATMAID rejected node movement. The optimistic preview was removed. ${formatErrorMessage(error)}`, + ); + } finally { + this.notifyChanged(); + } + } + + private async confirmDeleteNodeEntry( + entry: CatmaidOptimisticDeleteNodeEntry, + ) { + try { + const result = await this.editOperations.commitDeleteNode({ + node: entry.deleteContext.node, + childNodes: entry.deleteContext.childNodes, + segmentNodes: entry.segmentNodes, + nocheck: true, + }); + entry.result = result; + if (entry.status === CatmaidOptimisticEditStatus.CancelRequested) { + await this.compensateCanceledDeleteCommit(entry, result); + entry.status = CatmaidOptimisticEditStatus.RolledBack; + return; + } + this.reconcileCommittedDeleteEntry(entry, result); + entry.status = CatmaidOptimisticEditStatus.Committed; + await this.layer.spatialSkeletonState.commandHistory.recordExecuted( + entry.command, + ); + } catch (error) { + if (entry.status === CatmaidOptimisticEditStatus.CancelRequested) { + entry.status = CatmaidOptimisticEditStatus.RolledBack; + return; + } + entry.status = CatmaidOptimisticEditStatus.Failed; + this.rollbackEntryAndDependents( + entry, + CatmaidOptimisticEditStatus.Failed, + ); + StatusMessage.showErrorMessage( + `CATMAID rejected node deletion. The optimistic preview was removed. ${formatErrorMessage(error)}`, + ); + } finally { + this.notifyChanged(); + } + } + + private async reconcileCommittedAddEntry( + entry: CatmaidOptimisticAddNodeEntry, + skeletonLayer: SpatiallyIndexedSkeletonLayer, + parentNode: SpatiallyIndexedSkeletonNode, + result: CatmaidSpatialSkeletonAddNodeResult, + ) { + const wasSelected = + this.layer.selectedSpatialSkeletonNodeInfo.value?.nodeId === + entry.tempNodeId; + const expectedSegmentId = parentNode.segmentId; + const previewNode = this.layer.spatialSkeletonState.getCachedNode( + entry.tempNodeId, + ); + const previewRemovedByDelete = + previewNode === undefined && this.hasActiveDeleteDependent(entry); + if ( + previewNode !== undefined && + !this.previewAddNodeMatchesEntry(entry, previewNode, expectedSegmentId) + ) { + this.handlePreviewCollision( + entry, + previewNode, + expectedSegmentId, + result, + ); + entry.status = CatmaidOptimisticEditStatus.Failed; + this.rollbackUnconfirmedDependents( + entry, + CatmaidOptimisticEditStatus.RolledBack, + ); + return; + } + if (previewNode === undefined && !previewRemovedByDelete) { + this.handlePreviewCollision( + entry, + previewNode, + expectedSegmentId, + result, + ); + entry.status = CatmaidOptimisticEditStatus.Failed; + this.rollbackUnconfirmedDependents( + entry, + CatmaidOptimisticEditStatus.RolledBack, + ); + return; + } + this.layer.spatialSkeletonState.commandHistory.mappings.remapNodeId( + entry.tempNodeId, + result.nodeId, + ); + entry.command.markOptimisticCommit(entry.tempNodeId, result.segmentId); + if (result.parentSourceState !== undefined) { + this.layer.spatialSkeletonState.setCachedNodeSourceState( + parentNode.nodeId, + result.parentSourceState, + ); + } + const committedNodeSnapshot: SpatiallyIndexedSkeletonNode = { + nodeId: result.nodeId, + segmentId: result.segmentId, + position: new Float32Array( + previewNode?.position ?? entry.positionInModelSpace, + ), + parentNodeId: parentNode.nodeId, + isTrueEnd: false, + ...(result.sourceState === undefined + ? {} + : { sourceState: result.sourceState }), + }; + this.remapPendingEntriesForCommittedAdd( + entry, + parentNode, + committedNodeSnapshot, + ); + if (previewNode !== undefined) { + this.layer.spatialSkeletonState.removeCachedNode(entry.tempNodeId, { + parentNodeId: parentNode.nodeId, + childNodeIds: [], + }); + applyCreatedNodeToCache( + this.layer, + skeletonLayer, + { + ...result, + sourceState: committedNodeSnapshot.sourceState, + }, + parentNode.nodeId, + committedNodeSnapshot.position, + { + focusSelection: wasSelected, + markChanged: false, + moveView: false, + pinSegment: this.layer.manager.root.selectionState.pin.value, + retainOverlaySegment: true, + selectSegment: wasSelected, + }, + ); + } + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + entry.status = CatmaidOptimisticEditStatus.Committed; + await this.layer.spatialSkeletonState.commandHistory.recordExecuted( + entry.command, + ); + } + + private remapPendingEntriesForCommittedAdd( + entry: CatmaidOptimisticAddNodeEntry, + parentNode: SpatiallyIndexedSkeletonNode, + committedNode: SpatiallyIndexedSkeletonNode, + ) { + for (const dependentEntry of this.entries) { + if (this.isSettled(dependentEntry)) continue; + switch (dependentEntry.kind) { + case "addNode": + if (dependentEntry.parentTempNodeId === entry.tempNodeId) { + dependentEntry.parentNodeId = committedNode.nodeId; + this.layer.spatialSkeletonState.setCachedNodeParent( + dependentEntry.tempNodeId, + committedNode.nodeId, + ); + } + break; + case "moveNode": + if (dependentEntry.nodeId === entry.tempNodeId) { + dependentEntry.nodeId = committedNode.nodeId; + dependentEntry.segmentId = committedNode.segmentId; + dependentEntry.nodeForServer = { + ...dependentEntry.nodeForServer, + nodeId: committedNode.nodeId, + segmentId: committedNode.segmentId, + parentNodeId: parentNode.nodeId, + sourceState: committedNode.sourceState, + }; + } + break; + case "deleteNode": + this.remapDeleteEntryNode( + dependentEntry, + entry.tempNodeId, + committedNode, + ); + break; + } + } + } -async function restoreNodeAttributes( - layer: SegmentationUserLayer, - editOperations: CatmaidSpatialSkeletonEditOperations, - createdNode: SpatiallyIndexedSkeletonNode, - snapshot: SpatiallyIndexedSkeletonNode, -) { - let nextNode = cloneNodeSnapshot(createdNode); - if (snapshot.radius !== undefined && snapshot.radius !== nextNode.radius) { - const radiusResult = await editOperations.commitRadius({ - node: nextNode, - radius: snapshot.radius, - }); - nextNode = { - ...nextNode, - radius: snapshot.radius, - sourceState: radiusResult.sourceState ?? nextNode.sourceState, + private remapDeleteEntryNode( + entry: CatmaidOptimisticDeleteNodeEntry, + tempNodeId: number, + committedNode: SpatiallyIndexedSkeletonNode, + ) { + const remapNode = (node: SpatiallyIndexedSkeletonNode) => { + if (node.nodeId === tempNodeId) { + return { + ...node, + nodeId: committedNode.nodeId, + segmentId: committedNode.segmentId, + parentNodeId: committedNode.parentNodeId, + sourceState: committedNode.sourceState, + }; + } + if (node.parentNodeId === tempNodeId) { + return { + ...node, + parentNodeId: committedNode.nodeId, + }; + } + return node; }; + entry.deleteContext = { + node: remapNode(entry.deleteContext.node), + parentNode: + entry.deleteContext.parentNode === undefined + ? undefined + : remapNode(entry.deleteContext.parentNode), + childNodes: entry.deleteContext.childNodes.map(remapNode), + }; + entry.segmentNodes = entry.segmentNodes.map(remapNode); + if (entry.nodeId === tempNodeId) { + entry.nodeId = committedNode.nodeId; + entry.segmentId = committedNode.segmentId; + } } - if ( - snapshot.confidence !== undefined && - snapshot.confidence !== nextNode.confidence + + private reconcileCommittedMoveEntry( + entry: CatmaidOptimisticMoveNodeEntry, + result: CatmaidSpatialSkeletonNodeSourceStateResult, ) { - const confidenceResult = await editOperations.commitConfidence({ - node: nextNode, - confidence: snapshot.confidence, - }); - nextNode = { - ...nextNode, - confidence: snapshot.confidence, - sourceState: confidenceResult.sourceState ?? nextNode.sourceState, - }; + if (this.layer.spatialSkeletonState.getCachedNode(entry.nodeId)) { + const hasNewerMovePreview = this.entries.some( + (candidate) => + candidate.kind === "moveNode" && + candidate.nodeId === entry.nodeId && + candidate.operationId > entry.operationId && + this.isActive(candidate), + ); + if (!hasNewerMovePreview) { + this.layer.spatialSkeletonState.moveCachedNode( + entry.nodeId, + entry.afterPositionInModelSpace, + ); + } + if (result.sourceState !== undefined) { + this.layer.spatialSkeletonState.setCachedNodeSourceState( + entry.nodeId, + result.sourceState, + ); + } + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + } } - if ( - nextNode.description !== snapshot.description || - nextNode.isTrueEnd !== snapshot.isTrueEnd + + private reconcileCommittedDeleteEntry( + entry: CatmaidOptimisticDeleteNodeEntry, + result: CatmaidSpatialSkeletonDeleteNodeResult, ) { - nextNode = await applyNodeDescriptionAndTrueEnd( - editOperations, - nextNode, - snapshot, + if (result.nodeSourceStateUpdates?.length) { + this.layer.spatialSkeletonState.setCachedNodeSourceStates( + result.nodeSourceStateUpdates, + ); + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + } + skeletonLayerFromLayer(this.layer)?.invalidateSourceCellsForPositions( + entry.affectedPositions, ); } - 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 editOperations: CatmaidSpatialSkeletonEditOperations, - ) {} - private async addNode( - _context: SpatialSkeletonCommandContext, - options: { - moveView: boolean; - pinSegment: boolean; - statusPrefix: string; - }, + private async compensateCanceledAddCommit( + entry: CatmaidOptimisticAddNodeEntry, + result: CatmaidSpatialSkeletonAddNodeResult, ) { - const { skeletonLayer } = getEditableSkeletonSourceForLayer(this.layer); - const currentParentNodeId = - this.stableParentNodeId === undefined - ? undefined - : this.layer.spatialSkeletonState.commandHistory.mappings.resolveNodeId( - this.stableParentNodeId, - ); - let parentNode: SpatiallyIndexedSkeletonNode | undefined; - let resolvedSkeletonId = this.targetSkeletonId; - if (currentParentNodeId !== undefined) { - parentNode = ( - await getResolvedNodeForEdit( - this.layer, - this.stableParentNodeId!, - this.layer.spatialSkeletonState.commandHistory.mappings.getStableOrCurrentSegmentId( - this.targetSkeletonId, - ), - ) - ).node; - resolvedSkeletonId = parentNode.segmentId; - } - const result = await this.editOperations.commitAddNode({ - segmentId: resolvedSkeletonId, - position: this.positionInModelSpace, - parentNode, - }); - if (this.stableNodeId === undefined) { - this.stableNodeId = result.nodeId; - } else { - this.layer.spatialSkeletonState.commandHistory.mappings.remapNodeId( - this.stableNodeId, - result.nodeId, + const parentNode = + entry.parentNodeForServer === undefined || + result.parentSourceState === undefined + ? entry.parentNodeForServer + : { + ...entry.parentNodeForServer, + sourceState: result.parentSourceState, + }; + const createdNode: SpatiallyIndexedSkeletonNode = { + nodeId: result.nodeId, + segmentId: result.segmentId, + position: new Float32Array(entry.positionInModelSpace), + parentNodeId: parentNode?.nodeId, + isTrueEnd: false, + ...(result.sourceState === undefined + ? {} + : { sourceState: result.sourceState }), + }; + try { + const deleteResult = await this.editOperations.commitDeleteNode({ + node: createdNode, + childNodes: [], + segmentNodes: + parentNode === undefined ? [createdNode] : [parentNode, createdNode], + nocheck: true, + }); + if (!this.disposed && deleteResult.nodeSourceStateUpdates?.length) { + this.layer.spatialSkeletonState.setCachedNodeSourceStates( + deleteResult.nodeSourceStateUpdates, + ); + } + } catch (error) { + if (!this.disposed) { + skeletonLayerFromLayer(this.layer)?.invalidateSourceCellsForPositions([ + entry.positionInModelSpace, + parentNode?.position, + ]); + this.layer.spatialSkeletonState.invalidateCachedSegments([ + result.segmentId, + ]); + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + } + StatusMessage.showErrorMessage( + `CATMAID created a node after its optimistic preview was canceled, and automatic cleanup failed. Refresh the skeleton to sync. ${formatErrorMessage(error)}`, ); } - if (this.stableSegmentId === undefined) { - this.stableSegmentId = result.segmentId; - } else { - this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( - this.stableSegmentId, - result.segmentId, + } + + private async compensateCanceledMoveCommit( + entry: CatmaidOptimisticMoveNodeEntry, + result: CatmaidSpatialSkeletonNodeSourceStateResult, + ) { + const nodeForCompensation: SpatiallyIndexedSkeletonNode = { + ...entry.nodeForServer, + position: new Float32Array(entry.afterPositionInModelSpace), + ...(result.sourceState === undefined + ? {} + : { sourceState: result.sourceState }), + }; + try { + const compensationResult = await this.editOperations.commitMoveNode({ + node: nodeForCompensation, + position: entry.beforePositionInModelSpace, + nocheck: true, + }); + if (!this.disposed && compensationResult.sourceState !== undefined) { + this.layer.spatialSkeletonState.setCachedNodeSourceState( + entry.nodeId, + compensationResult.sourceState, + ); + } + } catch (error) { + if (!this.disposed) { + await refreshTopologySegments( + this.layer, + [entry.segmentId], + [entry.beforePositionInModelSpace, entry.afterPositionInModelSpace], + ); + } + StatusMessage.showErrorMessage( + `CATMAID moved a node after its optimistic preview was canceled, and automatic cleanup failed. Refresh the skeleton to sync. ${formatErrorMessage(error)}`, ); } - applyCreatedNodeToCache( - this.layer, - skeletonLayer, - result, - parentNode?.nodeId, - this.positionInModelSpace, - { - focusSelection: true, - moveView: options.moveView, - pinSegment: options.pinSegment, - retainOverlaySegment: parentNode !== undefined, - }, - ); - 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", - }); + private getDeleteCompensationNodeSnapshot( + node: SpatiallyIndexedSkeletonNode | undefined, + result: CatmaidSpatialSkeletonDeleteNodeResult, + ) { + if (node === undefined) { + return undefined; + } + const updatedSourceState = result.nodeSourceStateUpdates?.find( + (update) => update.nodeId === node.nodeId, + )?.sourceState; + return updatedSourceState === undefined + ? node + : { + ...node, + sourceState: updatedSourceState, + }; } - async undo(_context: SpatialSkeletonCommandContext) { - if (this.stableNodeId === undefined) { - throw new Error("Add-node undo is missing the created node id."); + private async restoreCanceledDeleteOnServer( + entry: CatmaidOptimisticDeleteNodeEntry, + result: CatmaidSpatialSkeletonDeleteNodeResult, + ) { + const parentNode = this.getDeleteCompensationNodeSnapshot( + entry.deleteContext.parentNode, + result, + ); + const childNodes = entry.deleteContext.childNodes.map( + (childNode) => this.getDeleteCompensationNodeSnapshot(childNode, result)!, + ); + let createResult: + | CatmaidSpatialSkeletonAddNodeResult + | CatmaidSpatialSkeletonInsertNodeResult; + if (childNodes.length === 0) { + createResult = await this.editOperations.commitAddNode({ + segmentId: parentNode?.segmentId ?? entry.segmentId, + position: entry.deleteContext.node.position, + parentNode, + }); + } else { + if (parentNode === undefined) { + throw new Error( + "Canceled delete compensation is missing the parent node needed for insertion.", + ); + } + createResult = await this.editOperations.commitInsertNode({ + segmentId: parentNode.segmentId, + position: entry.deleteContext.node.position, + parentNode, + childNodes, + }); } - const { resolvedNode } = await commitAndApplyDeleteNode( + const restoredNode: SpatiallyIndexedSkeletonNode = { + nodeId: createResult.nodeId, + segmentId: createResult.segmentId, + position: new Float32Array(entry.deleteContext.node.position), + parentNodeId: parentNode?.nodeId, + isTrueEnd: false, + ...(createResult.sourceState === undefined + ? {} + : { sourceState: createResult.sourceState }), + }; + await restoreNodeAttributes( this.layer, this.editOperations, - this.stableNodeId, - this.stableSegmentId, - { - childMode: "none", - invalidateSourceCells: false, - moveView: false, - }, - ); - StatusMessage.showTemporaryMessage( - `Undid add node ${resolvedNode.node.nodeId}.`, + restoredNode, + entry.deleteContext.node, + { applyLocalState: false }, ); } - async redo(context: SpatialSkeletonCommandContext) { - await this.addNode(context, { - moveView: false, - pinSegment: false, - statusPrefix: "Redid add of", - }); + private async compensateCanceledDeleteCommit( + entry: CatmaidOptimisticDeleteNodeEntry, + result: CatmaidSpatialSkeletonDeleteNodeResult, + ) { + entry.result = result; + try { + if (this.disposed) { + await this.restoreCanceledDeleteOnServer(entry, result); + return; + } + this.removeRestoredDeletePreview(entry); + await entry.command.restoreDeletedNode("Restored canceled deletion of", { + showStatus: false, + }); + } catch (error) { + if (!this.disposed) { + await refreshTopologySegments( + this.layer, + [entry.segmentId], + entry.affectedPositions, + ); + } + StatusMessage.showErrorMessage( + `CATMAID deleted a node after its optimistic preview was canceled, and automatic restore failed. Refresh the skeleton to sync. ${formatErrorMessage(error)}`, + ); + } } } +function skeletonLayerFromLayer(layer: SegmentationUserLayer) { + return layer.getSpatiallyIndexedSkeletonLayer(); +} + class InsertNodeCommand implements SpatialSkeletonCommand { readonly label = "Insert node"; private stableNodeId: number | undefined; @@ -1290,6 +2774,9 @@ class InsertNodeCommand implements SpatialSkeletonCommand { class MoveNodeCommand implements SpatialSkeletonCommand { readonly label = "Move node"; + readonly executeOptimistically?: ( + context: SpatialSkeletonCommandContext, + ) => Promise; constructor( private layer: SegmentationUserLayer, @@ -1298,17 +2785,39 @@ class MoveNodeCommand implements SpatialSkeletonCommand { private beforePositionInModelSpace: Float32Array, private afterPositionInModelSpace: Float32Array, private editOperations: CatmaidSpatialSkeletonEditOperations, - ) {} - - private async moveTo( - positionInModelSpace: Float32Array, - statusPrefix: string, + optimistic = false, ) { - const { node, skeletonLayer } = await getResolvedNodeForEdit( + if (optimistic) { + this.executeOptimistically = async () => { + await getOrCreateCatmaidOptimisticEditQueue( + this.layer, + this.editOperations, + ).enqueueMoveNode(this); + }; + } + } + + getBeforePositionInModelSpace() { + return new Float32Array(this.beforePositionInModelSpace); + } + + getAfterPositionInModelSpace() { + return new Float32Array(this.afterPositionInModelSpace); + } + + resolveMoveContext() { + return getResolvedNodeForEdit( this.layer, this.stableNodeId, this.stableSegmentId, ); + } + + private async moveTo( + positionInModelSpace: Float32Array, + statusPrefix: string, + ) { + const { node, skeletonLayer } = await this.resolveMoveContext(); const result = await this.editOperations.commitMoveNode({ node, position: positionInModelSpace, @@ -1347,6 +2856,9 @@ class MoveNodeCommand implements SpatialSkeletonCommand { class DeleteNodeCommand implements SpatialSkeletonCommand { readonly label = "Delete node"; + readonly executeOptimistically?: ( + context: SpatialSkeletonCommandContext, + ) => Promise; private stableDeletedNodeId: number; private stableSegmentId: number | undefined; private stableParentNodeId: number | undefined; @@ -1358,6 +2870,7 @@ class DeleteNodeCommand implements SpatialSkeletonCommand { node: SpatiallyIndexedSkeletonNode, childNodes: readonly SpatiallyIndexedSkeletonNode[], private editOperations: CatmaidSpatialSkeletonEditOperations, + optimistic = false, ) { const commandMappings = layer.spatialSkeletonState.commandHistory.mappings; this.stableDeletedNodeId = commandMappings.getStableOrCurrentNodeId( @@ -1373,6 +2886,27 @@ class DeleteNodeCommand implements SpatialSkeletonCommand { (child) => commandMappings.getStableOrCurrentNodeId(child.nodeId)!, ); this.deletedSnapshot = cloneNodeSnapshot(node); + if (optimistic) { + this.executeOptimistically = async () => { + await getOrCreateCatmaidOptimisticEditQueue( + this.layer, + this.editOperations, + ).enqueueDeleteNode(this); + }; + } + } + + async resolveDeleteContext() { + const resolvedNode = await getResolvedNodeForEdit( + this.layer, + this.stableDeletedNodeId, + this.stableSegmentId, + ); + const deleteContext = + await this.layer.getSpatialSkeletonDeleteOperationContext( + resolvedNode.node, + ); + return { resolvedNode, deleteContext }; } private async deleteNode(options: { @@ -1395,7 +2929,10 @@ class DeleteNodeCommand implements SpatialSkeletonCommand { ); } - private async restoreDeletedNode(statusPrefix: string) { + async restoreDeletedNode( + statusPrefix: string, + options: { showStatus?: boolean } = {}, + ) { const { skeletonLayer } = getEditableSkeletonSourceForLayer(this.layer); const currentParentNode = this.stableParentNodeId === undefined @@ -1484,9 +3021,11 @@ class DeleteNodeCommand implements SpatialSkeletonCommand { this.layer.markSpatialSkeletonNodeDataChanged({ invalidateFullSkeletonCache: false, }); - StatusMessage.showTemporaryMessage( - `${statusPrefix} node ${restoredNodeWithAttributes.nodeId}.`, - ); + if (options.showStatus ?? true) { + StatusMessage.showTemporaryMessage( + `${statusPrefix} node ${restoredNodeWithAttributes.nodeId}.`, + ); + } } execute() { @@ -2473,9 +4012,13 @@ export class CatmaidSpatialSkeletonEditCommands { y, z, request.parentNode?.nodeId, - request.parentNode === undefined + request.nocheck === true || request.parentNode === undefined ? undefined : buildCatmaidNodeEditContext(request.parentNode), + { + nocheck: request.nocheck, + signal: request.signal, + }, ); } @@ -2510,6 +4053,7 @@ export class CatmaidSpatialSkeletonEditCommands { y, z, buildCatmaidNodeEditContext(request.node), + { nocheck: request.nocheck }, ); } @@ -2522,6 +4066,7 @@ export class CatmaidSpatialSkeletonEditCommands { request.node, request.segmentNodes, ), + nocheck: request.nocheck, }); } @@ -2630,6 +4175,7 @@ export class CatmaidSpatialSkeletonEditCommands { "add-node position", ), this.editOperations, + this.editContext.getOptimisticSkeletonEdits?.(layer) === true, ); } @@ -2672,6 +4218,7 @@ export class CatmaidSpatialSkeletonEditCommands { "move-node target position", ), this.editOperations, + this.editContext.getOptimisticSkeletonEdits?.(layer) === true, ); } @@ -2700,6 +4247,7 @@ export class CatmaidSpatialSkeletonEditCommands { refreshedNode, childNodes, this.editOperations, + this.editContext.getOptimisticSkeletonEdits?.(layer) === true, ); } diff --git a/src/datasource/catmaid/spatial_skeleton_edit_api.ts b/src/datasource/catmaid/spatial_skeleton_edit_api.ts index 5af219a7da..4c1f8dad4a 100644 --- a/src/datasource/catmaid/spatial_skeleton_edit_api.ts +++ b/src/datasource/catmaid/spatial_skeleton_edit_api.ts @@ -41,6 +41,8 @@ export interface CatmaidSpatialSkeletonAddNodeRequest { segmentId: number; position: SpatialSkeletonVector; parentNode?: SpatiallyIndexedSkeletonNode; + nocheck?: boolean; + signal?: AbortSignal; } export type CatmaidSpatialSkeletonAddNodeResult = CatmaidAddNodeResult; @@ -57,6 +59,7 @@ export type CatmaidSpatialSkeletonInsertNodeResult = CatmaidInsertNodeResult; export interface CatmaidSpatialSkeletonMoveNodeRequest { node: SpatiallyIndexedSkeletonNode; position: SpatialSkeletonVector; + nocheck?: boolean; } export type CatmaidSpatialSkeletonNodeSourceStateResult = @@ -66,6 +69,7 @@ export interface CatmaidSpatialSkeletonDeleteNodeRequest { node: SpatiallyIndexedSkeletonNode; childNodes: readonly SpatiallyIndexedSkeletonNode[]; segmentNodes: readonly SpatiallyIndexedSkeletonNode[]; + nocheck?: boolean; } export type CatmaidSpatialSkeletonDeleteNodeResult = CatmaidDeleteNodeResult; diff --git a/src/layer/segmentation/index.spec.ts b/src/layer/segmentation/index.spec.ts index eb835e8fd7..9372306083 100644 --- a/src/layer/segmentation/index.spec.ts +++ b/src/layer/segmentation/index.spec.ts @@ -122,6 +122,7 @@ function makeSpatialSkeletonActionGateLayer(options: { commandHistory: { isBusy: new WatchableValue(options.commandBusy ?? false), }, + hasUnconfirmedOptimisticEdits: vi.fn(() => false), }, spatialSkeletonVisibleChunksLoaded: new WatchableValue( options.visibleChunksLoaded ?? true, @@ -240,6 +241,43 @@ describe("layer/segmentation spatial skeleton action gating", () => { ).toBeUndefined(); }); + it("allows queued optimistic edits and blocks stateful edits while optimistic skeleton edits are unconfirmed", () => { + const layer = makeSpatialSkeletonActionGateLayer({ + source: makeEditableSpatialSkeletonSource({ + confidenceConfiguration: true, + rerootCommand: true, + }), + }); + layer.spatialSkeletonState.hasUnconfirmedOptimisticEdits.mockReturnValue( + true, + ); + + for (const action of [ + SpatialSkeletonActions.addNodes, + SpatialSkeletonActions.moveNodes, + SpatialSkeletonActions.deleteNodes, + ]) { + expect( + layer.getSpatialSkeletonActionsDisabledReason(action), + ).toBeUndefined(); + } + + for (const action of [ + SpatialSkeletonActions.insertNodes, + SpatialSkeletonActions.mergeSkeletons, + SpatialSkeletonActions.splitSkeletons, + SpatialSkeletonActions.reroot, + SpatialSkeletonActions.editNodeDescription, + SpatialSkeletonActions.editNodeTrueEnd, + SpatialSkeletonActions.editNodeRadius, + SpatialSkeletonActions.editNodeConfidence, + ]) { + expect(layer.getSpatialSkeletonActionsDisabledReason(action)).toBe( + "Wait for pending optimistic skeleton edits to finish.", + ); + } + }); + it("still reports visible chunk loading when requested", () => { const layer = makeSpatialSkeletonActionGateLayer({ source: makeEditableSpatialSkeletonSource(), diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 98f5114aa8..74a2e95ea6 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -194,6 +194,7 @@ import { registerSegmentSelectTools } from "#src/ui/segment_select_tools.js"; import { registerSegmentSplitMergeTools } from "#src/ui/segment_split_merge_tools.js"; import { DisplayOptionsTab } from "#src/ui/segmentation_display_options_tab.js"; import { registerSpatialSkeletonEditModeTool } from "#src/ui/skeleton_edit_tools.js"; +import { maybeRegisterSpatialSkeletonOptimisticEditQueueTab } from "#src/ui/skeleton_optimistic_edit_queue_tab.js"; import { SpatialSkeletonEditTab } from "#src/ui/skeleton_tab.js"; import { Uint64Map } from "#src/uint64_map.js"; import { Uint64OrderedSet } from "#src/uint64_ordered_set.js"; @@ -816,6 +817,7 @@ export class SegmentationUserLayer extends Base { sliceViewRenderScaleHistogram = new RenderScaleHistogram(); sliceViewRenderScaleTarget = trackableRenderScaleTarget(1); codeVisible = new TrackableBoolean(true); + optimisticSkeletonEdits = new TrackableBoolean(true, true); readonly spatialSkeletonState = this.registerDisposer( new SpatialSkeletonState(), ); @@ -1201,6 +1203,9 @@ export class SegmentationUserLayer extends Base { this.displayState.silhouetteRendering.changed.add( this.specificationChanged.dispatch, ); + this.optimisticSkeletonEdits.changed.add( + this.specificationChanged.dispatch, + ); this.anchorSegment.changed.add(this.specificationChanged.dispatch); this.sliceViewRenderScaleTarget.changed.add( this.specificationChanged.dispatch, @@ -1250,6 +1255,10 @@ export class SegmentationUserLayer extends Base { getter: () => new SpatialSkeletonEditTab(this), hidden: hideSpatialSkeletonEditTab, }); + maybeRegisterSpatialSkeletonOptimisticEditQueueTab( + this, + hideSpatialSkeletonEditTab, + ); const hideGraphTab = this.registerDisposer( makeCachedDerivedWatchableValue( (x) => x === undefined, @@ -1474,6 +1483,19 @@ export class SegmentationUserLayer extends Base { ) { return "Wait for the current skeleton edit to finish."; } + if ( + !ignoreCommandBusy && + this.spatialSkeletonState.hasUnconfirmedOptimisticEdits() && + requirements.some( + (action) => + isSpatialSkeletonEditAction(action) && + action !== SpatialSkeletonActions.addNodes && + action !== SpatialSkeletonActions.moveNodes && + action !== SpatialSkeletonActions.deleteNodes, + ) + ) { + return "Wait for pending optimistic skeleton edits to finish."; + } if ( requireVisibleChunks && !this.spatialSkeletonVisibleChunksLoaded.value @@ -1911,6 +1933,9 @@ export class SegmentationUserLayer extends Base { this.displayState.ignoreNullVisibleSet.restoreState( specification[json_keys.IGNORE_NULL_VISIBLE_SET_JSON_KEY], ); + this.optimisticSkeletonEdits.restoreState( + specification[json_keys.OPTIMISTIC_SKELETON_EDITS_JSON_KEY], + ); const { skeletonRenderingOptions } = this.displayState; skeletonRenderingOptions.restoreState( @@ -1981,6 +2006,8 @@ export class SegmentationUserLayer extends Base { this.displayState.baseSegmentColoring.toJSON(); x[json_keys.IGNORE_NULL_VISIBLE_SET_JSON_KEY] = this.displayState.ignoreNullVisibleSet.toJSON(); + x[json_keys.OPTIMISTIC_SKELETON_EDITS_JSON_KEY] = + this.optimisticSkeletonEdits.toJSON(); x[json_keys.MESH_SILHOUETTE_RENDERING_JSON_KEY] = this.displayState.silhouetteRendering.toJSON(); x[json_keys.ANCHOR_SEGMENT_JSON_KEY] = this.anchorSegment diff --git a/src/layer/segmentation/json_keys.ts b/src/layer/segmentation/json_keys.ts index 475cd31977..99c32b226c 100644 --- a/src/layer/segmentation/json_keys.ts +++ b/src/layer/segmentation/json_keys.ts @@ -25,6 +25,7 @@ export const SKELETON_CODE_VISIBLE_KEY = "codeVisible"; export const SEGMENT_QUERY_JSON_KEY = "segmentQuery"; export const SKELETON_NODE_QUERY_JSON_KEY = "skeletonNodeQuery"; export const SKELETON_NODE_FILTER_JSON_KEY = "skeletonNodeFilter"; +export const OPTIMISTIC_SKELETON_EDITS_JSON_KEY = "optimisticSkeletonEdits"; export const MESH_SILHOUETTE_RENDERING_JSON_KEY = "meshSilhouetteRendering"; export const LINKED_SEGMENTATION_GROUP_JSON_KEY = "linkedSegmentationGroup"; export const LINKED_SEGMENTATION_COLOR_GROUP_JSON_KEY = diff --git a/src/skeleton/command_history.ts b/src/skeleton/command_history.ts index c81aa18013..d17c7d1076 100644 --- a/src/skeleton/command_history.ts +++ b/src/skeleton/command_history.ts @@ -271,6 +271,14 @@ export class SpatialSkeletonCommandHistory extends RefCounted { }); } + recordExecuted(command: SpatialSkeletonCommand) { + return this.runOperation(async () => { + this.redoEntries = []; + this.undoEntries.push({ command }); + this.trimUndoEntries(); + }); + } + undo() { return this.runOperation(async () => { const entry = this.undoEntries.at(-1); diff --git a/src/skeleton/command_protocol.ts b/src/skeleton/command_protocol.ts index bfa367a78a..a472b48bac 100644 --- a/src/skeleton/command_protocol.ts +++ b/src/skeleton/command_protocol.ts @@ -97,6 +97,7 @@ export interface SpatialSkeletonCommandContext { export interface SpatialSkeletonCommand { readonly label: string; execute(context: SpatialSkeletonCommandContext): Promise; + executeOptimistically?(context: SpatialSkeletonCommandContext): Promise; undo(context: SpatialSkeletonCommandContext): Promise; redo?(context: SpatialSkeletonCommandContext): Promise; } diff --git a/src/skeleton/commands.spec.ts b/src/skeleton/commands.spec.ts index 9987713a12..72fdb4ecff 100644 --- a/src/skeleton/commands.spec.ts +++ b/src/skeleton/commands.spec.ts @@ -112,9 +112,13 @@ function makeCatmaidClient(overrides: Record = {}) { }; } -function makeCatmaidEditCommands(client = makeCatmaidClient()) { +function makeCatmaidEditCommands( + client = makeCatmaidClient(), + options: { optimisticSkeletonEdits?: boolean } = {}, +) { return new CatmaidSpatialSkeletonEditCommands({ getClient: () => client as any, + getOptimisticSkeletonEdits: () => options.optimisticSkeletonEdits === true, }); } @@ -165,6 +169,9 @@ function suppressStatusMessages() { vi.spyOn(StatusMessage, "showMessage").mockImplementation( (_message: string) => fakeStatusMessage, ); + vi.spyOn(StatusMessage, "showErrorMessage").mockImplementation( + (_message: string) => fakeStatusMessage, + ); } function makeDisplayState(visibleSegmentIds: readonly number[]) { @@ -202,6 +209,117 @@ function makePinnedManager() { }; } +function makeOptimisticAddNodeTestLayer(options: { + addNode?: ReturnType; + deleteNode?: ReturnType; + insertNode?: ReturnType; + moveNode?: ReturnType; + initialNodes: readonly SpatiallyIndexedSkeletonNode[]; + segmentId: number; +}) { + const spatialSkeletonState = new SpatialSkeletonState(); + for (const node of options.initialNodes) { + spatialSkeletonState.upsertCachedNode(node, { + allowUncachedSegment: + spatialSkeletonState.getCachedSegmentNodes(node.segmentId) === + undefined, + }); + } + const client = makeCatmaidClient({ + addNode: options.addNode ?? vi.fn(), + deleteNode: options.deleteNode ?? vi.fn(), + insertNode: options.insertNode ?? vi.fn(), + moveNode: options.moveNode ?? vi.fn(), + }); + const commands = makeCatmaidEditCommands(client, { + optimisticSkeletonEdits: true, + }); + const skeletonSource = { + ...makeEditableSkeletonSource(), + addNodesCommand: commands.addNodesCommand, + deleteNodesCommand: commands.deleteNodesCommand, + moveNodesCommand: commands.moveNodesCommand, + }; + const skeletonLayer = { + source: skeletonSource, + getNode: vi.fn((nodeId: number) => + spatialSkeletonState.getCachedNode(nodeId), + ), + retainOverlaySegment: vi.fn(), + invalidateSourceCellsForPositions: vi.fn(), + }; + const selectedSpatialSkeletonNodeInfo = { + value: undefined as + | { + nodeId: number; + segmentId?: number; + position?: ArrayLike; + } + | undefined, + }; + const layer = { + displayState: makeDisplayState([options.segmentId]), + manager: makePinnedManager(), + selectedSpatialSkeletonNodeInfo, + spatialSkeletonState, + getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, + getCachedSpatialSkeletonSegmentNodesForEdit: (requestedSegmentId: number) => + spatialSkeletonState.getCachedSegmentNodes(requestedSegmentId) ?? [], + async getSpatialSkeletonDeleteOperationContext( + node: SpatiallyIndexedSkeletonNode, + ) { + const segmentNodes = + spatialSkeletonState.getCachedSegmentNodes(node.segmentId) ?? []; + const currentNode = findSpatiallyIndexedSkeletonNode( + segmentNodes, + node.nodeId, + ); + if (currentNode === undefined) { + throw new Error(`Unable to resolve cached node ${node.nodeId}.`); + } + const childNodes = getSpatiallyIndexedSkeletonDirectChildren( + segmentNodes, + currentNode.nodeId, + ); + return { + node: currentNode, + parentNode: getSpatiallyIndexedSkeletonNodeParent( + segmentNodes, + currentNode, + ), + childNodes, + }; + }, + selectSegment: vi.fn(), + selectAndMoveToSpatialSkeletonNode: vi.fn(), + selectSpatialSkeletonNode: vi.fn( + ( + nodeId: number, + _pin: boolean, + nodeInfo?: { segmentId?: number; position?: ArrayLike }, + ) => { + selectedSpatialSkeletonNodeInfo.value = { + nodeId, + segmentId: nodeInfo?.segmentId, + position: nodeInfo?.position, + }; + }, + ), + clearSpatialSkeletonNodeSelection: vi.fn(() => { + selectedSpatialSkeletonNodeInfo.value = undefined; + }), + moveViewToSpatialSkeletonNodePosition: vi.fn(), + markSpatialSkeletonNodeDataChanged: vi.fn(), + }; + return { client, layer, skeletonLayer, spatialSkeletonState }; +} + +async function waitForMicrotasks(count = 3) { + for (let i = 0; i < count; ++i) { + await Promise.resolve(); + } +} + describe("spatial_skeleton_commands", () => { afterEach(() => { vi.restoreAllMocks(); @@ -256,6 +374,41 @@ describe("spatial_skeleton_commands", () => { expect(redo).toHaveBeenCalledTimes(1); }); + it("blocks redo while optimistic skeleton edits are unconfirmed", async () => { + const fakeStatusMessage = { + dispose() {}, + } as unknown as StatusMessage; + const showTemporaryMessage = vi + .spyOn(StatusMessage, "showTemporaryMessage") + .mockReturnValue(fakeStatusMessage); + const spatialSkeletonState = new SpatialSkeletonState(); + const redo = vi.fn(); + const command = { + label: "Redo target", + execute: vi.fn(), + undo: vi.fn(), + redo, + }; + await spatialSkeletonState.commandHistory.execute(command); + await spatialSkeletonState.commandHistory.undo(); + spatialSkeletonState.setOptimisticEditQueue({ + canUndo: () => false, + hasUnconfirmedActions: () => true, + undoLatest: () => Promise.resolve(false), + }); + const layer = { + spatialSkeletonState, + }; + + await expect(redoSpatialSkeletonCommand(layer as any)).resolves.toBe(false); + + expect(redo).not.toHaveBeenCalled(); + expect(spatialSkeletonState.commandHistory.canRedo.value).toBe(true); + expect(showTemporaryMessage).toHaveBeenCalledWith( + "Wait for pending optimistic skeleton edits to finish.", + ); + }); + it("does not treat a source with an invalid command factory as editable", () => { const layer = { spatialSkeletonState: { @@ -679,6 +832,7 @@ describe("spatial_skeleton_commands", () => { expect.objectContaining({ node: expect.objectContaining({ nodeId: 17 }), }), + { nocheck: undefined }, ); expect(skeletonLayer.retainOverlaySegment).toHaveBeenCalledWith(23); expect(moveCachedNode).toHaveBeenCalledWith( @@ -893,8 +1047,1458 @@ describe("spatial_skeleton_commands", () => { node: expect.objectContaining({ nodeId: 2 }), parent: expect.objectContaining({ nodeId: parentNode.nodeId }), }), + nocheck: undefined, + }); + expect(spatialSkeletonState.getCachedNode(2)).toBeUndefined(); + }); + + it("removes a pending optimistic add-node preview without sending it", async () => { + suppressStatusMessages(); + const showTemporaryMessage = vi.mocked(StatusMessage.showTemporaryMessage); + + const segmentId = 23; + const parentNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("parent-before-add"), + }; + let resolveFirstAdd: + | ((value: { + nodeId: number; + segmentId: number; + sourceState: ReturnType; + parentSourceState: ReturnType; + }) => void) + | undefined; + const firstAddPromise = new Promise((resolve) => { + resolveFirstAdd = resolve; + }); + const addNode = vi.fn(() => firstAddPromise); + const { layer, spatialSkeletonState } = makeOptimisticAddNodeTestLayer({ + addNode, + initialNodes: [parentNode], + segmentId, + }); + + await executeSpatialSkeletonAddNode(layer as any, { + skeletonId: segmentId, + parentNodeId: parentNode.nodeId, + positionInModelSpace: new Float32Array([7, 8, 9]), + }); + await waitForMicrotasks(); + await executeSpatialSkeletonAddNode(layer as any, { + skeletonId: segmentId, + parentNodeId: parentNode.nodeId, + positionInModelSpace: new Float32Array([10, 11, 12]), + }); + await waitForMicrotasks(); + + expect(addNode).toHaveBeenCalledTimes(1); + expect( + spatialSkeletonState + .getCachedSegmentNodes(segmentId)! + .filter((node) => node.nodeId >= 1_000_000_000), + ).toHaveLength(2); + + await undoSpatialSkeletonCommand(layer as any); + + expect(showTemporaryMessage).not.toHaveBeenCalled(); + expect(addNode).toHaveBeenCalledTimes(1); + expect( + spatialSkeletonState + .getCachedSegmentNodes(segmentId)! + .filter((node) => node.nodeId >= 1_000_000_000), + ).toHaveLength(1); + + resolveFirstAdd!({ + nodeId: 2, + segmentId, + sourceState: testSourceState("node-after-add"), + parentSourceState: testSourceState("parent-after-add"), + }); + await waitForMicrotasks(5); + }); + + it("compensates when an in-flight optimistic add-node undo later commits", async () => { + suppressStatusMessages(); + const showTemporaryMessage = vi.mocked(StatusMessage.showTemporaryMessage); + + const segmentId = 23; + const parentNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("parent-before-add"), + }; + let resolveAdd: + | ((value: { + nodeId: number; + segmentId: number; + sourceState: ReturnType; + parentSourceState: ReturnType; + }) => void) + | undefined; + const addPromise = new Promise((resolve) => { + resolveAdd = resolve; + }); + const addNode = vi.fn(() => addPromise); + const deleteNode = vi.fn().mockResolvedValue({ + nodeSourceStateUpdates: [ + { + nodeId: parentNode.nodeId, + sourceState: testSourceState("parent-after-compensation"), + }, + ], + }); + const { layer, spatialSkeletonState } = makeOptimisticAddNodeTestLayer({ + addNode, + deleteNode, + initialNodes: [parentNode], + segmentId, + }); + + await executeSpatialSkeletonAddNode(layer as any, { + skeletonId: segmentId, + parentNodeId: parentNode.nodeId, + positionInModelSpace: new Float32Array([7, 8, 9]), + }); + await waitForMicrotasks(); + expect(addNode).toHaveBeenCalledTimes(1); + const requestOptions = ( + addNode.mock.calls[0] as unknown[] | undefined + )?.[6] as { signal?: AbortSignal } | undefined; + expect(requestOptions?.signal).toBeUndefined(); + + await undoSpatialSkeletonCommand(layer as any); + expect(requestOptions?.signal).toBeUndefined(); + expect( + spatialSkeletonState + .getCachedSegmentNodes(segmentId)! + .some((node) => node.nodeId >= 1_000_000_000), + ).toBe(false); + + resolveAdd!({ + nodeId: 2, + segmentId, + sourceState: testSourceState("node-after-add"), + parentSourceState: testSourceState("parent-after-add"), + }); + await waitForMicrotasks(5); + + expect(deleteNode).toHaveBeenCalledWith(2, { + childNodeIds: [], + editContext: expect.objectContaining({ + node: expect.objectContaining({ nodeId: 2 }), + parent: expect.objectContaining({ nodeId: parentNode.nodeId }), + }), + nocheck: true, + }); + expect(showTemporaryMessage).not.toHaveBeenCalled(); + expect(spatialSkeletonState.commandHistory.canUndo.value).toBe(false); + }); + + it("disposes the optimistic queue on runtime clear and ignores late cache mutations", async () => { + suppressStatusMessages(); + + const segmentId = 23; + const parentNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("parent-before-add"), + }; + let resolveFirstAdd: + | ((value: { + nodeId: number; + segmentId: number; + sourceState: ReturnType; + parentSourceState: ReturnType; + }) => void) + | undefined; + const firstAddPromise = new Promise((resolve) => { + resolveFirstAdd = resolve; + }); + const addNode = vi + .fn() + .mockReturnValueOnce(firstAddPromise) + .mockResolvedValueOnce({ + nodeId: 3, + segmentId, + sourceState: testSourceState("second-node-after-add"), + parentSourceState: testSourceState("parent-after-second-add"), + }); + const deleteNode = vi.fn().mockResolvedValue({ + nodeSourceStateUpdates: [ + { + nodeId: parentNode.nodeId, + sourceState: testSourceState("parent-after-first-compensation"), + }, + ], + }); + const { layer, spatialSkeletonState } = makeOptimisticAddNodeTestLayer({ + addNode, + deleteNode, + initialNodes: [parentNode], + segmentId, + }); + + await executeSpatialSkeletonAddNode(layer as any, { + skeletonId: segmentId, + parentNodeId: parentNode.nodeId, + positionInModelSpace: new Float32Array([7, 8, 9]), + }); + await waitForMicrotasks(); + + expect(addNode).toHaveBeenCalledTimes(1); + expect( + spatialSkeletonState + .getCachedSegmentNodes(segmentId)! + .some((node) => node.nodeId >= 1_000_000_000), + ).toBe(true); + + expect(spatialSkeletonState.clearRuntimeState()).toBe(true); + spatialSkeletonState.upsertCachedNode(parentNode, { + allowUncachedSegment: true, + }); + + await executeSpatialSkeletonAddNode(layer as any, { + skeletonId: segmentId, + parentNodeId: parentNode.nodeId, + positionInModelSpace: new Float32Array([10, 11, 12]), + }); + await waitForMicrotasks(5); + + expect(addNode).toHaveBeenCalledTimes(2); + expect(spatialSkeletonState.getCachedNode(3)).toMatchObject({ + nodeId: 3, + parentNodeId: parentNode.nodeId, + }); + + layer.markSpatialSkeletonNodeDataChanged.mockClear(); + + resolveFirstAdd!({ + nodeId: 2, + segmentId, + sourceState: testSourceState("first-node-after-add"), + parentSourceState: testSourceState("parent-after-first-add"), + }); + await waitForMicrotasks(5); + + expect(deleteNode).toHaveBeenCalledWith(2, { + childNodeIds: [], + editContext: expect.objectContaining({ + node: expect.objectContaining({ nodeId: 2 }), + parent: expect.objectContaining({ nodeId: parentNode.nodeId }), + }), + nocheck: true, }); expect(spatialSkeletonState.getCachedNode(2)).toBeUndefined(); + expect(spatialSkeletonState.getCachedNode(3)).toMatchObject({ + nodeId: 3, + parentNodeId: parentNode.nodeId, + }); + expect(layer.markSpatialSkeletonNodeDataChanged).not.toHaveBeenCalled(); + }); + + it("warns without aborting or rolling back when an in-flight optimistic edit takes too long", async () => { + vi.useFakeTimers(); + try { + suppressStatusMessages(); + const showErrorMessage = vi.mocked(StatusMessage.showErrorMessage); + + const segmentId = 23; + const parentNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("parent-before-add"), + }; + let resolveAdd: + | ((value: { + nodeId: number; + segmentId: number; + sourceState: ReturnType; + parentSourceState: ReturnType; + }) => void) + | undefined; + const addPromise = new Promise((resolve) => { + resolveAdd = resolve; + }); + const addNode = vi.fn(() => addPromise); + const { layer, spatialSkeletonState } = makeOptimisticAddNodeTestLayer({ + addNode, + initialNodes: [parentNode], + segmentId, + }); + + await executeSpatialSkeletonAddNode(layer as any, { + skeletonId: segmentId, + parentNodeId: parentNode.nodeId, + positionInModelSpace: new Float32Array([7, 8, 9]), + }); + await waitForMicrotasks(); + + const requestOptions = ( + addNode.mock.calls[0] as unknown[] | undefined + )?.[6] as { signal?: AbortSignal } | undefined; + expect(requestOptions?.signal).toBeUndefined(); + expect(spatialSkeletonState.hasUnconfirmedOptimisticEdits()).toBe(true); + + vi.advanceTimersByTime(30_000); + + expect(showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining("has not confirmed"), + ); + expect(spatialSkeletonState.hasUnconfirmedOptimisticEdits()).toBe(true); + expect(spatialSkeletonState.getCachedSegmentNodes(segmentId)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ nodeId: Number.MAX_SAFE_INTEGER }), + ]), + ); + + resolveAdd!({ + nodeId: 2, + segmentId, + sourceState: testSourceState("node-after-add"), + parentSourceState: testSourceState("parent-after-add"), + }); + await waitForMicrotasks(5); + } finally { + vi.useRealTimers(); + } + }); + + it("invalidates optimistic preview cache when a temporary id collision is detected", async () => { + suppressStatusMessages(); + const showErrorMessage = vi.mocked(StatusMessage.showErrorMessage); + + const segmentId = 23; + const collisionSegmentId = 29; + const parentNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("parent-before-add"), + }; + let resolveAdd: + | ((value: { + nodeId: number; + segmentId: number; + sourceState: ReturnType; + parentSourceState: ReturnType; + }) => void) + | undefined; + const addPromise = new Promise((resolve) => { + resolveAdd = resolve; + }); + const addNode = vi.fn(() => addPromise); + const deleteNode = vi.fn().mockResolvedValue({}); + const { layer, skeletonLayer, spatialSkeletonState } = + makeOptimisticAddNodeTestLayer({ + addNode, + deleteNode, + initialNodes: [parentNode], + segmentId, + }); + + await executeSpatialSkeletonAddNode(layer as any, { + skeletonId: segmentId, + parentNodeId: parentNode.nodeId, + positionInModelSpace: new Float32Array([7, 8, 9]), + }); + await waitForMicrotasks(); + + const optimisticNode = spatialSkeletonState + .getCachedSegmentNodes(segmentId)! + .find((node) => node.nodeId >= 1_000_000_000); + expect(optimisticNode?.nodeId).toBe(Number.MAX_SAFE_INTEGER); + + spatialSkeletonState.upsertCachedNode( + { + nodeId: optimisticNode!.nodeId, + segmentId: collisionSegmentId, + parentNodeId: undefined, + position: new Float32Array([90, 91, 92]), + isTrueEnd: false, + }, + { allowUncachedSegment: true }, + ); + + await undoSpatialSkeletonCommand(layer as any); + + expect(showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining("temporary id"), + ); + expect(skeletonLayer.invalidateSourceCellsForPositions).toHaveBeenCalled(); + expect( + spatialSkeletonState.getCachedSegmentNodes(segmentId), + ).toBeUndefined(); + expect( + spatialSkeletonState.getCachedSegmentNodes(collisionSegmentId), + ).toBeUndefined(); + + resolveAdd!({ + nodeId: 2, + segmentId, + sourceState: testSourceState("node-after-add"), + parentSourceState: testSourceState("parent-after-add"), + }); + await waitForMicrotasks(5); + }); + + it("rolls back a 3-level optimistic add chain when the root add is rejected", async () => { + suppressStatusMessages(); + const showErrorMessage = vi.mocked(StatusMessage.showErrorMessage); + + const segmentId = 23; + const parentNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("parent-before-add"), + }; + let rejectFirstAdd: ((error: Error) => void) | undefined; + const firstAddPromise = new Promise((_resolve, reject) => { + rejectFirstAdd = reject; + }); + const addNode = vi.fn(() => firstAddPromise); + const { layer, spatialSkeletonState } = makeOptimisticAddNodeTestLayer({ + addNode, + initialNodes: [parentNode], + segmentId, + }); + + await executeSpatialSkeletonAddNode(layer as any, { + skeletonId: segmentId, + parentNodeId: parentNode.nodeId, + positionInModelSpace: new Float32Array([7, 8, 9]), + }); + await waitForMicrotasks(); + + const optimisticParent = spatialSkeletonState + .getCachedSegmentNodes(segmentId)! + .find((node) => node.nodeId >= 1_000_000_000); + expect(optimisticParent).toBeDefined(); + + await executeSpatialSkeletonAddNode(layer as any, { + skeletonId: segmentId, + parentNodeId: optimisticParent!.nodeId, + positionInModelSpace: new Float32Array([10, 11, 12]), + }); + await waitForMicrotasks(); + + const optimisticChild = spatialSkeletonState + .getCachedSegmentNodes(segmentId)! + .find((node) => node.parentNodeId === optimisticParent!.nodeId); + expect(optimisticChild).toBeDefined(); + + await executeSpatialSkeletonAddNode(layer as any, { + skeletonId: segmentId, + parentNodeId: optimisticChild!.nodeId, + positionInModelSpace: new Float32Array([13, 14, 15]), + }); + await waitForMicrotasks(); + + expect(addNode).toHaveBeenCalledTimes(1); + expect( + spatialSkeletonState + .getCachedSegmentNodes(segmentId)! + .filter((node) => node.nodeId >= 1_000_000_000), + ).toHaveLength(3); + + rejectFirstAdd!(new Error("server rejected")); + await waitForMicrotasks(5); + + expect(showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining("CATMAID rejected node creation"), + ); + expect(addNode).toHaveBeenCalledTimes(1); + expect( + spatialSkeletonState + .getCachedSegmentNodes(segmentId)! + .some((node) => node.nodeId >= 1_000_000_000), + ).toBe(false); + }); + + it("rolls back a move queued against a pending optimistic add when the add is rejected", async () => { + suppressStatusMessages(); + + const segmentId = 23; + const parentNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("parent-before-add"), + }; + let rejectAdd: ((error: Error) => void) | undefined; + const addPromise = new Promise((_resolve, reject) => { + rejectAdd = reject; + }); + const addNode = vi.fn(() => addPromise); + const moveNode = vi.fn(); + const { layer, spatialSkeletonState } = makeOptimisticAddNodeTestLayer({ + addNode, + moveNode, + initialNodes: [parentNode], + segmentId, + }); + + await executeSpatialSkeletonAddNode(layer as any, { + skeletonId: segmentId, + parentNodeId: parentNode.nodeId, + positionInModelSpace: new Float32Array([7, 8, 9]), + }); + await waitForMicrotasks(); + + const optimisticNode = spatialSkeletonState + .getCachedSegmentNodes(segmentId)! + .find((node) => node.nodeId >= 1_000_000_000); + expect(optimisticNode).toBeDefined(); + + await executeSpatialSkeletonMoveNode(layer as any, { + node: optimisticNode!, + nextPositionInModelSpace: new Float32Array([10, 11, 12]), + }); + await waitForMicrotasks(); + + expect(moveNode).not.toHaveBeenCalled(); + expect( + Array.from( + spatialSkeletonState.getCachedNode(optimisticNode!.nodeId)!.position, + ), + ).toEqual([10, 11, 12]); + + rejectAdd!(new Error("server rejected")); + await waitForMicrotasks(5); + + expect(moveNode).not.toHaveBeenCalled(); + expect( + spatialSkeletonState + .getCachedSegmentNodes(segmentId)! + .some((node) => node.nodeId >= 1_000_000_000), + ).toBe(false); + }); + + it("rolls back a delete queued against a pending optimistic add when the add is rejected", async () => { + suppressStatusMessages(); + + const segmentId = 23; + const parentNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("parent-before-add"), + }; + let rejectAdd: ((error: Error) => void) | undefined; + const addPromise = new Promise((_resolve, reject) => { + rejectAdd = reject; + }); + const addNode = vi.fn(() => addPromise); + const deleteNode = vi.fn(); + const { layer, spatialSkeletonState } = makeOptimisticAddNodeTestLayer({ + addNode, + deleteNode, + initialNodes: [parentNode], + segmentId, + }); + + await executeSpatialSkeletonAddNode(layer as any, { + skeletonId: segmentId, + parentNodeId: parentNode.nodeId, + positionInModelSpace: new Float32Array([7, 8, 9]), + }); + await waitForMicrotasks(); + + const optimisticNode = spatialSkeletonState + .getCachedSegmentNodes(segmentId)! + .find((node) => node.nodeId >= 1_000_000_000); + expect(optimisticNode).toBeDefined(); + + await executeSpatialSkeletonDeleteNode(layer as any, optimisticNode!); + await waitForMicrotasks(); + + expect(deleteNode).not.toHaveBeenCalled(); + expect( + spatialSkeletonState.getCachedNode(optimisticNode!.nodeId), + ).toBeUndefined(); + + rejectAdd!(new Error("server rejected")); + await waitForMicrotasks(5); + + expect(deleteNode).not.toHaveBeenCalled(); + expect( + spatialSkeletonState + .getCachedSegmentNodes(segmentId)! + .some((node) => node.nodeId >= 1_000_000_000), + ).toBe(false); + }); + + it("records a confirmed optimistic add-node in undo history", async () => { + suppressStatusMessages(); + const showTemporaryMessage = vi.mocked(StatusMessage.showTemporaryMessage); + + const segmentId = 23; + const parentNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("parent-before-add"), + }; + const addNode = vi.fn().mockResolvedValue({ + nodeId: 2, + segmentId, + sourceState: testSourceState("node-after-add"), + parentSourceState: testSourceState("parent-after-add"), + }); + const deleteNode = vi.fn().mockResolvedValue({ + nodeSourceStateUpdates: [ + { + nodeId: parentNode.nodeId, + sourceState: testSourceState("parent-after-undo"), + }, + ], + }); + const { layer, spatialSkeletonState } = makeOptimisticAddNodeTestLayer({ + addNode, + deleteNode, + initialNodes: [parentNode], + segmentId, + }); + + await executeSpatialSkeletonAddNode(layer as any, { + skeletonId: segmentId, + parentNodeId: parentNode.nodeId, + positionInModelSpace: new Float32Array([7, 8, 9]), + }); + await waitForMicrotasks(5); + + expect( + spatialSkeletonState.getOptimisticEditQueueDebugSnapshot(), + ).toHaveLength(0); + expect(spatialSkeletonState.getCachedNode(2)).toMatchObject({ + nodeId: 2, + segmentId, + parentNodeId: parentNode.nodeId, + }); + expect(spatialSkeletonState.commandHistory.canUndo.value).toBe(true); + expect(showTemporaryMessage).not.toHaveBeenCalled(); + + await undoSpatialSkeletonCommand(layer as any); + + expect(deleteNode).toHaveBeenCalledWith(2, { + childNodeIds: [], + editContext: expect.objectContaining({ + node: expect.objectContaining({ nodeId: 2 }), + parent: expect.objectContaining({ nodeId: parentNode.nodeId }), + }), + nocheck: undefined, + }); + expect(spatialSkeletonState.getCachedNode(2)).toBeUndefined(); + }); + + it("uses normal command history for root add-node even when optimistic edits are enabled", async () => { + suppressStatusMessages(); + const showTemporaryMessage = vi.mocked(StatusMessage.showTemporaryMessage); + + const segmentId = 23; + const addNode = vi.fn().mockResolvedValue({ + nodeId: 2, + segmentId, + sourceState: testSourceState("root-after-add"), + }); + const { layer, spatialSkeletonState } = makeOptimisticAddNodeTestLayer({ + addNode, + initialNodes: [], + segmentId, + }); + + await executeSpatialSkeletonAddNode(layer as any, { + skeletonId: segmentId, + parentNodeId: undefined, + positionInModelSpace: new Float32Array([7, 8, 9]), + }); + await waitForMicrotasks(5); + + expect(addNode).toHaveBeenCalledTimes(1); + expect((addNode.mock.calls[0] as unknown[])[4]).toBeUndefined(); + expect( + ((addNode.mock.calls[0] as unknown[])[6] as { nocheck?: boolean }) + ?.nocheck, + ).toBeUndefined(); + expect( + spatialSkeletonState.getOptimisticEditQueueDebugSnapshot(), + ).toHaveLength(0); + expect(spatialSkeletonState.commandHistory.canUndo.value).toBe(true); + expect(spatialSkeletonState.getCachedNode(2)).toMatchObject({ + nodeId: 2, + segmentId, + parentNodeId: undefined, + }); + expect(showTemporaryMessage).toHaveBeenCalledWith( + "Added node 2 on segment 23.", + ); + }); + + it("auto-purges settled optimistic queue entries without clearing undo history", async () => { + suppressStatusMessages(); + + const segmentId = 23; + const parentNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("parent-before-add"), + }; + const addNode = vi.fn().mockResolvedValue({ + nodeId: 2, + segmentId, + sourceState: testSourceState("node-after-add"), + parentSourceState: testSourceState("parent-after-add"), + }); + const { layer, spatialSkeletonState } = makeOptimisticAddNodeTestLayer({ + addNode, + initialNodes: [parentNode], + segmentId, + }); + + await executeSpatialSkeletonAddNode(layer as any, { + skeletonId: segmentId, + parentNodeId: parentNode.nodeId, + positionInModelSpace: new Float32Array([7, 8, 9]), + }); + await waitForMicrotasks(5); + + expect( + spatialSkeletonState.getOptimisticEditQueueDebugSnapshot(), + ).toHaveLength(0); + expect(spatialSkeletonState.commandHistory.canUndo.value).toBe(true); + + expect(spatialSkeletonState.clearSettledOptimisticEdits()).toBe(false); + + expect( + spatialSkeletonState.getOptimisticEditQueueDebugSnapshot(), + ).toHaveLength(0); + expect(spatialSkeletonState.commandHistory.canUndo.value).toBe(true); + }); + + it("retains committed optimistic dependencies until their pending dependents settle", async () => { + suppressStatusMessages(); + + const segmentId = 23; + const parentNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("parent-before-add"), + }; + let resolveParentAdd: + | ((value: { + nodeId: number; + segmentId: number; + sourceState: ReturnType; + parentSourceState: ReturnType; + }) => void) + | undefined; + let resolveChildAdd: + | ((value: { + nodeId: number; + segmentId: number; + sourceState: ReturnType; + parentSourceState: ReturnType; + }) => void) + | undefined; + const parentAddPromise = new Promise((resolve) => { + resolveParentAdd = resolve; + }); + const childAddPromise = new Promise((resolve) => { + resolveChildAdd = resolve; + }); + const addNode = vi + .fn() + .mockReturnValueOnce(parentAddPromise) + .mockReturnValueOnce(childAddPromise); + const { layer, spatialSkeletonState } = makeOptimisticAddNodeTestLayer({ + addNode, + initialNodes: [parentNode], + segmentId, + }); + + await executeSpatialSkeletonAddNode(layer as any, { + skeletonId: segmentId, + parentNodeId: parentNode.nodeId, + positionInModelSpace: new Float32Array([7, 8, 9]), + }); + await waitForMicrotasks(); + + const optimisticParent = spatialSkeletonState + .getCachedSegmentNodes(segmentId)! + .find((node) => node.nodeId >= 1_000_000_000); + expect(optimisticParent).toBeDefined(); + + await executeSpatialSkeletonAddNode(layer as any, { + skeletonId: segmentId, + parentNodeId: optimisticParent!.nodeId, + positionInModelSpace: new Float32Array([10, 11, 12]), + }); + await waitForMicrotasks(); + + resolveParentAdd!({ + nodeId: 2, + segmentId, + sourceState: testSourceState("node-after-parent-add"), + parentSourceState: testSourceState("parent-after-parent-add"), + }); + await waitForMicrotasks(10); + + expect(addNode).toHaveBeenCalledTimes(2); + expect(spatialSkeletonState.getOptimisticEditQueueDebugSnapshot()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "addNode", + nodeId: 2, + status: "committed", + }), + expect.objectContaining({ + kind: "addNode", + status: "inFlight", + }), + ]), + ); + + resolveChildAdd!({ + nodeId: 3, + segmentId, + sourceState: testSourceState("node-after-child-add"), + parentSourceState: testSourceState("parent-after-child-add"), + }); + await waitForMicrotasks(5); + + expect( + spatialSkeletonState.getOptimisticEditQueueDebugSnapshot(), + ).toHaveLength(0); + }); + + it("restores a pending delete rollback with a remapped real parent id", async () => { + suppressStatusMessages(); + + const segmentId = 23; + const rootNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("root-before-add"), + }; + let resolveParentAdd: + | ((value: { + nodeId: number; + segmentId: number; + sourceState: ReturnType; + parentSourceState: ReturnType; + }) => void) + | undefined; + let resolveChildAdd: + | ((value: { + nodeId: number; + segmentId: number; + sourceState: ReturnType; + parentSourceState: ReturnType; + }) => void) + | undefined; + const parentAddPromise = new Promise((resolve) => { + resolveParentAdd = resolve; + }); + const childAddPromise = new Promise((resolve) => { + resolveChildAdd = resolve; + }); + const addNode = vi + .fn() + .mockReturnValueOnce(parentAddPromise) + .mockReturnValueOnce(childAddPromise); + const { layer, spatialSkeletonState } = makeOptimisticAddNodeTestLayer({ + addNode, + initialNodes: [rootNode], + segmentId, + }); + + await executeSpatialSkeletonAddNode(layer as any, { + skeletonId: segmentId, + parentNodeId: rootNode.nodeId, + positionInModelSpace: new Float32Array([7, 8, 9]), + }); + await waitForMicrotasks(); + + const optimisticParent = spatialSkeletonState + .getCachedSegmentNodes(segmentId)! + .find((node) => node.nodeId >= 1_000_000_000); + expect(optimisticParent).toBeDefined(); + + await executeSpatialSkeletonAddNode(layer as any, { + skeletonId: segmentId, + parentNodeId: optimisticParent!.nodeId, + positionInModelSpace: new Float32Array([10, 11, 12]), + }); + await waitForMicrotasks(); + + const optimisticChild = spatialSkeletonState + .getCachedSegmentNodes(segmentId)! + .find((node) => node.parentNodeId === optimisticParent!.nodeId); + expect(optimisticChild).toBeDefined(); + + await executeSpatialSkeletonDeleteNode(layer as any, optimisticChild!); + await waitForMicrotasks(); + + resolveParentAdd!({ + nodeId: 2, + segmentId, + sourceState: testSourceState("parent-after-add"), + parentSourceState: testSourceState("root-after-add"), + }); + await waitForMicrotasks(5); + + await undoSpatialSkeletonCommand(layer as any); + + expect( + spatialSkeletonState.getCachedNode(optimisticChild!.nodeId), + ).toMatchObject({ + nodeId: optimisticChild!.nodeId, + parentNodeId: 2, + }); + + resolveChildAdd!({ + nodeId: 3, + segmentId, + sourceState: testSourceState("child-after-add"), + parentSourceState: testSourceState("parent-after-child-add"), + }); + await waitForMicrotasks(5); + }); + + it("removes a pending optimistic move-node preview without sending it", async () => { + suppressStatusMessages(); + + const segmentId = 23; + const parentNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("parent-before"), + }; + let resolveAdd: + | ((value: { + nodeId: number; + segmentId: number; + sourceState: ReturnType; + parentSourceState: ReturnType; + }) => void) + | undefined; + const addPromise = new Promise((resolve) => { + resolveAdd = resolve; + }); + const addNode = vi.fn(() => addPromise); + const moveNode = vi.fn(); + const { layer, spatialSkeletonState } = makeOptimisticAddNodeTestLayer({ + addNode, + moveNode, + initialNodes: [parentNode], + segmentId, + }); + + await executeSpatialSkeletonAddNode(layer as any, { + skeletonId: segmentId, + parentNodeId: parentNode.nodeId, + positionInModelSpace: new Float32Array([7, 8, 9]), + }); + await waitForMicrotasks(); + await executeSpatialSkeletonMoveNode(layer as any, { + node: parentNode, + nextPositionInModelSpace: new Float32Array([10, 11, 12]), + }); + await waitForMicrotasks(); + + expect(moveNode).not.toHaveBeenCalled(); + expect( + Array.from( + spatialSkeletonState.getCachedNode(parentNode.nodeId)!.position, + ), + ).toEqual([10, 11, 12]); + + await undoSpatialSkeletonCommand(layer as any); + + expect(moveNode).not.toHaveBeenCalled(); + expect( + Array.from( + spatialSkeletonState.getCachedNode(parentNode.nodeId)!.position, + ), + ).toEqual([4, 5, 6]); + + resolveAdd!({ + nodeId: 2, + segmentId, + sourceState: testSourceState("node-after-add"), + parentSourceState: testSourceState("parent-after-add"), + }); + await waitForMicrotasks(5); + }); + + it("compensates when an in-flight optimistic move-node undo later commits", async () => { + suppressStatusMessages(); + const showTemporaryMessage = vi.mocked(StatusMessage.showTemporaryMessage); + + const segmentId = 23; + const node: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("node-before"), + }; + let resolveMove: + | ((value: { sourceState: ReturnType }) => void) + | undefined; + const movePromise = new Promise((resolve) => { + resolveMove = resolve; + }); + const moveNode = vi + .fn() + .mockReturnValueOnce(movePromise) + .mockResolvedValueOnce({ + sourceState: testSourceState("node-after-compensation"), + }); + const { layer, spatialSkeletonState } = makeOptimisticAddNodeTestLayer({ + moveNode, + initialNodes: [node], + segmentId, + }); + + await executeSpatialSkeletonMoveNode(layer as any, { + node, + nextPositionInModelSpace: new Float32Array([10, 11, 12]), + }); + await waitForMicrotasks(); + + expect(moveNode).toHaveBeenCalledWith( + node.nodeId, + 10, + 11, + 12, + expect.anything(), + { nocheck: true }, + ); + expect( + Array.from(spatialSkeletonState.getCachedNode(node.nodeId)!.position), + ).toEqual([10, 11, 12]); + + await undoSpatialSkeletonCommand(layer as any); + + expect(moveNode).toHaveBeenCalledTimes(1); + expect( + Array.from(spatialSkeletonState.getCachedNode(node.nodeId)!.position), + ).toEqual([4, 5, 6]); + + resolveMove!({ sourceState: testSourceState("node-after-move") }); + await waitForMicrotasks(5); + + expect(moveNode).toHaveBeenCalledTimes(2); + expect(moveNode).toHaveBeenLastCalledWith( + node.nodeId, + 4, + 5, + 6, + expect.anything(), + { nocheck: true }, + ); + expect(showTemporaryMessage).not.toHaveBeenCalled(); + expect(spatialSkeletonState.commandHistory.canUndo.value).toBe(false); + }); + + it("records a confirmed optimistic move-node in undo history", async () => { + suppressStatusMessages(); + const showTemporaryMessage = vi.mocked(StatusMessage.showTemporaryMessage); + + const segmentId = 23; + const node: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("node-before"), + }; + const moveNode = vi + .fn() + .mockResolvedValueOnce({ sourceState: testSourceState("node-after") }) + .mockResolvedValueOnce({ + sourceState: testSourceState("node-after-undo"), + }); + const { layer, spatialSkeletonState } = makeOptimisticAddNodeTestLayer({ + moveNode, + initialNodes: [node], + segmentId, + }); + + await executeSpatialSkeletonMoveNode(layer as any, { + node, + nextPositionInModelSpace: new Float32Array([10, 11, 12]), + }); + await waitForMicrotasks(5); + + expect(spatialSkeletonState.commandHistory.canUndo.value).toBe(true); + expect( + Array.from(spatialSkeletonState.getCachedNode(node.nodeId)!.position), + ).toEqual([10, 11, 12]); + expect(showTemporaryMessage).not.toHaveBeenCalled(); + + await undoSpatialSkeletonCommand(layer as any); + + expect(moveNode).toHaveBeenCalledTimes(2); + expect(moveNode).toHaveBeenLastCalledWith( + node.nodeId, + 4, + 5, + 6, + expect.anything(), + { nocheck: undefined }, + ); + expect( + Array.from(spatialSkeletonState.getCachedNode(node.nodeId)!.position), + ).toEqual([4, 5, 6]); + }); + + it("keeps the latest optimistic move preview when an older move commits", async () => { + suppressStatusMessages(); + + const segmentId = 23; + const node: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("node-before"), + }; + let resolveFirstMove: + | ((value: { sourceState: ReturnType }) => void) + | undefined; + let resolveSecondMove: + | ((value: { sourceState: ReturnType }) => void) + | undefined; + const firstMovePromise = new Promise((resolve) => { + resolveFirstMove = resolve; + }); + const secondMovePromise = new Promise((resolve) => { + resolveSecondMove = resolve; + }); + const moveNode = vi + .fn() + .mockReturnValueOnce(firstMovePromise) + .mockReturnValueOnce(secondMovePromise); + const { layer, spatialSkeletonState } = makeOptimisticAddNodeTestLayer({ + moveNode, + initialNodes: [node], + segmentId, + }); + + await executeSpatialSkeletonMoveNode(layer as any, { + node, + nextPositionInModelSpace: new Float32Array([10, 11, 12]), + }); + await waitForMicrotasks(); + expect( + Array.from(spatialSkeletonState.getCachedNode(node.nodeId)!.position), + ).toEqual([10, 11, 12]); + + await executeSpatialSkeletonMoveNode(layer as any, { + node: spatialSkeletonState.getCachedNode(node.nodeId)!, + nextPositionInModelSpace: new Float32Array([20, 21, 22]), + }); + await waitForMicrotasks(); + expect(moveNode).toHaveBeenCalledTimes(1); + expect( + Array.from(spatialSkeletonState.getCachedNode(node.nodeId)!.position), + ).toEqual([20, 21, 22]); + + resolveFirstMove!({ sourceState: testSourceState("after-first") }); + await waitForMicrotasks(5); + + expect(moveNode).toHaveBeenCalledTimes(2); + expect( + Array.from(spatialSkeletonState.getCachedNode(node.nodeId)!.position), + ).toEqual([20, 21, 22]); + + resolveSecondMove!({ sourceState: testSourceState("after-second") }); + await waitForMicrotasks(5); + + expect( + Array.from(spatialSkeletonState.getCachedNode(node.nodeId)!.position), + ).toEqual([20, 21, 22]); + }); + + it("removes a pending optimistic delete-node preview without sending it", async () => { + suppressStatusMessages(); + + const segmentId = 23; + const rootNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([1, 2, 3]), + isTrueEnd: false, + sourceState: testSourceState("root-before"), + }; + const deletedNode: SpatiallyIndexedSkeletonNode = { + nodeId: 2, + segmentId, + parentNodeId: rootNode.nodeId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("deleted-before"), + }; + const childNode: SpatiallyIndexedSkeletonNode = { + nodeId: 3, + segmentId, + parentNodeId: deletedNode.nodeId, + position: new Float32Array([7, 8, 9]), + isTrueEnd: false, + sourceState: testSourceState("child-before"), + }; + let resolveAdd: + | ((value: { + nodeId: number; + segmentId: number; + sourceState: ReturnType; + parentSourceState: ReturnType; + }) => void) + | undefined; + const addPromise = new Promise((resolve) => { + resolveAdd = resolve; + }); + const addNode = vi.fn(() => addPromise); + const deleteNode = vi.fn(); + const { layer, spatialSkeletonState } = makeOptimisticAddNodeTestLayer({ + addNode, + deleteNode, + initialNodes: [rootNode, deletedNode, childNode], + segmentId, + }); + + await executeSpatialSkeletonAddNode(layer as any, { + skeletonId: segmentId, + parentNodeId: rootNode.nodeId, + positionInModelSpace: new Float32Array([10, 11, 12]), + }); + await waitForMicrotasks(); + await executeSpatialSkeletonDeleteNode(layer as any, deletedNode); + await waitForMicrotasks(); + + expect(deleteNode).not.toHaveBeenCalled(); + expect( + spatialSkeletonState.getCachedNode(deletedNode.nodeId), + ).toBeUndefined(); + expect( + spatialSkeletonState.getCachedNode(childNode.nodeId)?.parentNodeId, + ).toBe(rootNode.nodeId); + + await undoSpatialSkeletonCommand(layer as any); + + expect(deleteNode).not.toHaveBeenCalled(); + expect( + spatialSkeletonState.getCachedNode(deletedNode.nodeId), + ).toMatchObject({ + nodeId: deletedNode.nodeId, + parentNodeId: rootNode.nodeId, + }); + expect( + spatialSkeletonState.getCachedNode(childNode.nodeId)?.parentNodeId, + ).toBe(deletedNode.nodeId); + + resolveAdd!({ + nodeId: 4, + segmentId, + sourceState: testSourceState("node-after-add"), + parentSourceState: testSourceState("root-after-add"), + }); + await waitForMicrotasks(5); + }); + + it("compensates when an in-flight optimistic delete-node undo later commits", async () => { + suppressStatusMessages(); + const showTemporaryMessage = vi.mocked(StatusMessage.showTemporaryMessage); + + const segmentId = 23; + const rootNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([1, 2, 3]), + isTrueEnd: false, + sourceState: testSourceState("root-before"), + }; + const deletedNode: SpatiallyIndexedSkeletonNode = { + nodeId: 2, + segmentId, + parentNodeId: rootNode.nodeId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("deleted-before"), + }; + let resolveDelete: + | ((value: { + nodeSourceStateUpdates: { + nodeId: number; + sourceState: ReturnType; + }[]; + }) => void) + | undefined; + const deletePromise = new Promise((resolve) => { + resolveDelete = resolve; + }); + const deleteNode = vi.fn().mockReturnValueOnce(deletePromise); + const addNode = vi.fn().mockResolvedValue({ + nodeId: 20, + segmentId, + sourceState: testSourceState("restored-after-compensation"), + parentSourceState: testSourceState("root-after-compensation"), + }); + const { layer, spatialSkeletonState } = makeOptimisticAddNodeTestLayer({ + addNode, + deleteNode, + initialNodes: [rootNode, deletedNode], + segmentId, + }); + + await executeSpatialSkeletonDeleteNode(layer as any, deletedNode); + await waitForMicrotasks(); + + expect(deleteNode).toHaveBeenCalledWith(deletedNode.nodeId, { + childNodeIds: [], + editContext: expect.objectContaining({ + node: expect.objectContaining({ nodeId: deletedNode.nodeId }), + parent: expect.objectContaining({ nodeId: rootNode.nodeId }), + }), + nocheck: true, + }); + expect( + spatialSkeletonState.getCachedNode(deletedNode.nodeId), + ).toBeUndefined(); + + await undoSpatialSkeletonCommand(layer as any); + + expect( + spatialSkeletonState.getCachedNode(deletedNode.nodeId), + ).toMatchObject({ + nodeId: deletedNode.nodeId, + parentNodeId: rootNode.nodeId, + }); + + resolveDelete!({ + nodeSourceStateUpdates: [ + { + nodeId: rootNode.nodeId, + sourceState: testSourceState("root-after-delete"), + }, + ], + }); + await waitForMicrotasks(5); + + expect(addNode).toHaveBeenCalledWith( + segmentId, + 4, + 5, + 6, + rootNode.nodeId, + expect.objectContaining({ + node: expect.objectContaining({ nodeId: rootNode.nodeId }), + }), + { nocheck: undefined, signal: undefined }, + ); + expect( + spatialSkeletonState.getCachedNode(deletedNode.nodeId), + ).toBeUndefined(); + expect(spatialSkeletonState.getCachedNode(20)).toMatchObject({ + nodeId: 20, + parentNodeId: rootNode.nodeId, + }); + expect(showTemporaryMessage).not.toHaveBeenCalled(); + expect(spatialSkeletonState.commandHistory.canUndo.value).toBe(false); + }); + + it("records a confirmed optimistic delete-node in undo history", async () => { + suppressStatusMessages(); + const showTemporaryMessage = vi.mocked(StatusMessage.showTemporaryMessage); + + const segmentId = 23; + const rootNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([1, 2, 3]), + isTrueEnd: false, + sourceState: testSourceState("root-before"), + }; + const deletedNode: SpatiallyIndexedSkeletonNode = { + nodeId: 2, + segmentId, + parentNodeId: rootNode.nodeId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("deleted-before"), + }; + const deleteNode = vi.fn().mockResolvedValue({ + nodeSourceStateUpdates: [ + { + nodeId: rootNode.nodeId, + sourceState: testSourceState("root-after-delete"), + }, + ], + }); + const addNode = vi.fn().mockResolvedValue({ + nodeId: 20, + segmentId, + sourceState: testSourceState("restored-after-undo"), + parentSourceState: testSourceState("root-after-undo"), + }); + const { layer, spatialSkeletonState } = makeOptimisticAddNodeTestLayer({ + addNode, + deleteNode, + initialNodes: [rootNode, deletedNode], + segmentId, + }); + + await executeSpatialSkeletonDeleteNode(layer as any, deletedNode); + await waitForMicrotasks(5); + + expect( + spatialSkeletonState.getCachedNode(deletedNode.nodeId), + ).toBeUndefined(); + expect(spatialSkeletonState.commandHistory.canUndo.value).toBe(true); + expect(showTemporaryMessage).not.toHaveBeenCalled(); + + await undoSpatialSkeletonCommand(layer as any); + + expect(addNode).toHaveBeenCalledWith( + segmentId, + 4, + 5, + 6, + rootNode.nodeId, + expect.objectContaining({ + node: expect.objectContaining({ nodeId: rootNode.nodeId }), + }), + { nocheck: undefined, signal: undefined }, + ); + expect(spatialSkeletonState.getCachedNode(20)).toMatchObject({ + nodeId: 20, + parentNodeId: rootNode.nodeId, + }); }); it("restores internal-node delete undo as an insertion in the local cache", async () => { diff --git a/src/skeleton/commands.ts b/src/skeleton/commands.ts index 73798994ef..d7c10abe89 100644 --- a/src/skeleton/commands.ts +++ b/src/skeleton/commands.ts @@ -31,6 +31,7 @@ import { getSpatialSkeletonActionErrorMessage } from "#src/skeleton/edit_errors. import { getEditableSpatiallyIndexedSkeletonSource, getSpatialSkeletonEditCommandFactoryForAction, + isSpatialSkeletonOptimisticEditState, type SpatialSkeletonLayerContext, } from "#src/skeleton/spatial_skeleton_manager.js"; import { StatusMessage } from "#src/status.js"; @@ -66,6 +67,11 @@ function executeCommand( layer: SpatialSkeletonLayerContext, command: SpatialSkeletonCommand, ) { + if (command.executeOptimistically !== undefined) { + return command.executeOptimistically({ + mappings: layer.spatialSkeletonState.commandHistory.mappings, + }); + } return layer.spatialSkeletonState.commandHistory.execute(command); } @@ -350,7 +356,25 @@ export async function undoSpatialSkeletonCommand( if (!changed) { return false; } - return true; + const optimisticEditState = isSpatialSkeletonOptimisticEditState( + layer.spatialSkeletonState, + ) + ? layer.spatialSkeletonState + : undefined; + if (optimisticEditState?.canUndoOptimisticEdit() === true) { + return optimisticEditState.undoLatestOptimisticEdit(); + } + const commandHistory = layer.spatialSkeletonState.commandHistory; + if (!commandHistory.canUndo.value) { + return false; + } + const undoLabel = commandHistory.undoLabel.value; + const pendingMessage = + undoLabel !== undefined ? `Undoing ${undoLabel}...` : "Undoing..."; + return executeCommandWithPendingMessage( + commandHistory.undo(), + pendingMessage, + ); } export async function redoSpatialSkeletonCommand( @@ -360,5 +384,25 @@ export async function redoSpatialSkeletonCommand( if (!changed) { return false; } - return true; + const optimisticEditState = isSpatialSkeletonOptimisticEditState( + layer.spatialSkeletonState, + ) + ? layer.spatialSkeletonState + : undefined; + if (optimisticEditState?.hasUnconfirmedOptimisticEdits() === true) { + StatusMessage.showTemporaryMessage( + "Wait for pending optimistic skeleton edits to finish.", + ); + return false; + } + if (!commandHistory.canRedo.value) { + return false; + } + const redoLabel = commandHistory.redoLabel.value; + const pendingMessage = + redoLabel !== undefined ? `Redoing ${redoLabel}...` : "Redoing..."; + return executeCommandWithPendingMessage( + commandHistory.redo(), + pendingMessage, + ); } diff --git a/src/skeleton/spatial_skeleton_manager.ts b/src/skeleton/spatial_skeleton_manager.ts index 6907f965ba..ba27ff3a58 100644 --- a/src/skeleton/spatial_skeleton_manager.ts +++ b/src/skeleton/spatial_skeleton_manager.ts @@ -38,6 +38,45 @@ interface SpatialSkeletonSourceAccess { source: unknown; } +export interface SpatialSkeletonOptimisticEditState { + hasUnconfirmedOptimisticEdits(): boolean; + canUndoOptimisticEdit(): boolean; + undoLatestOptimisticEdit(): Promise; +} + +export function isSpatialSkeletonOptimisticEditState( + value: unknown, +): value is SpatialSkeletonOptimisticEditState { + return ( + hasFunction(value, "hasUnconfirmedOptimisticEdits") && + hasFunction(value, "canUndoOptimisticEdit") && + hasFunction(value, "undoLatestOptimisticEdit") + ); +} + +export interface SpatialSkeletonOptimisticEditQueue { + canUndo(): boolean; + clear?(): boolean; + dispose?(): boolean; + hasUnconfirmedActions(): boolean; + undoLatest(): Promise; + + // Debug/inspection hooks used by the optimistic edit queue widget. + clearSettled?(): boolean; + getDebugSnapshot?(): readonly SpatialSkeletonOptimisticEditDebugEntry[]; +} + +export interface SpatialSkeletonOptimisticEditDebugEntry { + readonly operationId?: number; + readonly kind: string; + readonly status: string; + readonly tempNodeId?: number; + readonly parentNodeId?: number; + readonly parentTempNodeId?: number; + readonly nodeId?: number; + readonly segmentId?: number; +} + function hasFunction( value: unknown, property: T, @@ -235,7 +274,10 @@ function cloneSpatiallyIndexedSkeletonNode( */ const MAX_CONCURRENT_FULL_SEGMENT_NODE_FETCHES = 8; -export class SpatialSkeletonState extends RefCounted { +export class SpatialSkeletonState + extends RefCounted + implements SpatialSkeletonOptimisticEditState +{ readonly commandHistory = this.registerDisposer( new SpatialSkeletonCommandHistory(), ); @@ -252,6 +294,7 @@ export class SpatialSkeletonState extends RefCounted { readonly suppressSelectedNodeHighlight = new WatchableValue(false); readonly nodeDataVersion = new WatchableValue(0); readonly pendingNodePositionVersion = new WatchableValue(0); + readonly optimisticEditQueueVersion = new WatchableValue(0); private pendingNodePositions = new Map(); private fullSkeletonCacheGeneration = 0; @@ -279,6 +322,43 @@ export class SpatialSkeletonState extends RefCounted { ); }); private cachedNodesById = new Map(); + private optimisticEditQueue?: SpatialSkeletonOptimisticEditQueue; + + setOptimisticEditQueue( + optimisticEditQueue: SpatialSkeletonOptimisticEditQueue | undefined, + ) { + if (this.optimisticEditQueue === optimisticEditQueue) { + return false; + } + this.optimisticEditQueue = optimisticEditQueue; + this.notifyOptimisticEditQueueChanged(); + return true; + } + + notifyOptimisticEditQueueChanged() { + this.optimisticEditQueueVersion.value = + this.optimisticEditQueueVersion.value + 1; + } + + hasUnconfirmedOptimisticEdits() { + return this.optimisticEditQueue?.hasUnconfirmedActions() ?? false; + } + + getOptimisticEditQueueDebugSnapshot() { + return this.optimisticEditQueue?.getDebugSnapshot?.() ?? []; + } + + clearSettledOptimisticEdits() { + return this.optimisticEditQueue?.clearSettled?.() ?? false; + } + + canUndoOptimisticEdit() { + return this.optimisticEditQueue?.canUndo() ?? false; + } + + undoLatestOptimisticEdit() { + return this.optimisticEditQueue?.undoLatest() ?? Promise.resolve(false); + } setNodeRadius(nodeId: number, radius: number) { const normalizedNodeId = this.normalizeNodeId(nodeId); @@ -416,6 +496,48 @@ export class SpatialSkeletonState extends RefCounted { return true; } + clearRuntimeState() { + const optimisticQueue = this.optimisticEditQueue; + const optimisticQueueChanged = + optimisticQueue?.dispose?.() ?? optimisticQueue?.clear?.() ?? false; + if (this.optimisticEditQueue !== undefined) { + this.optimisticEditQueue = undefined; + this.notifyOptimisticEditQueueChanged(); + } + const cacheChanged = + this.fullSegmentNodeCache.size !== 0 || + this.pendingFullSegmentNodeFetches.size !== 0 || + this.cachedNodesById.size !== 0; + const pendingChanged = this.clearPendingNodePositions(); + const mergeAnchorChanged = this.clearMergeAnchor(); + let modeChanged = false; + if (this.editMode.value) { + this.editMode.value = false; + modeChanged = true; + } + if (this.mergeMode.value) { + this.mergeMode.value = false; + modeChanged = true; + } + if (this.splitMode.value) { + this.splitMode.value = false; + modeChanged = true; + } + const historyChanged = this.commandHistory.clear(); + if (cacheChanged) { + this.clearFullSkeletonCache(); + this.nodeDataVersion.value = this.nodeDataVersion.value + 1; + } + return ( + cacheChanged || + pendingChanged || + optimisticQueueChanged || + mergeAnchorChanged || + modeChanged || + historyChanged + ); + } + markNodeDataChanged(options: { invalidateFullSkeletonCache?: boolean } = {}) { if (options.invalidateFullSkeletonCache ?? true) { this.clearFullSkeletonCache(); diff --git a/src/ui/layer_data_sources_tab.ts b/src/ui/layer_data_sources_tab.ts index 7b53214290..5147d84caa 100644 --- a/src/ui/layer_data_sources_tab.ts +++ b/src/ui/layer_data_sources_tab.ts @@ -66,6 +66,30 @@ import { import { ProgressListenerWidget } from "#src/widget/progress_listener.js"; import { Tab } from "#src/widget/tab_view.js"; +interface OptimisticSkeletonEditsLayer { + optimisticSkeletonEdits: WatchableValueInterface; +} + +function getOptimisticSkeletonEditsModel(layer: UserLayer) { + const model = (layer as Partial) + .optimisticSkeletonEdits; + return typeof model?.value === "boolean" ? model : undefined; +} + +function getCatmaidStateChecksModel(layer: UserLayer) { + const optimisticSkeletonEdits = getOptimisticSkeletonEditsModel(layer); + if (optimisticSkeletonEdits === undefined) return undefined; + return { + changed: optimisticSkeletonEdits.changed, + get value() { + return !optimisticSkeletonEdits.value; + }, + set value(value: boolean) { + optimisticSkeletonEdits.value = !value; + }, + }; +} + const dataSourceUrlSyntaxHighlighter: SyntaxHighlighter = { splitPattern: /\|?[^|:/_]*(?:[:/_]+)?/g, getSeparatorNode: (text: string) => { @@ -366,6 +390,30 @@ export class DataSourceView extends RefCounted { const { element } = this; element.classList.add("neuroglancer-layer-data-source"); element.appendChild(urlInput.element); + const catmaidStateChecksModel = getCatmaidStateChecksModel(source.layer); + if ( + catmaidStateChecksModel !== undefined && + source.layer.dataSources[0] === source + ) { + const catmaidStateChecksElement = document.createElement("label"); + catmaidStateChecksElement.classList.add( + "neuroglancer-layer-data-sources-source-default", + ); + catmaidStateChecksElement.title = + "Send CATMAID revision state and wait for server confirmation instead of using optimistic previews."; + catmaidStateChecksElement.appendChild( + this.registerDisposer( + new TrackableBooleanCheckbox(catmaidStateChecksModel, { + enabledTitle: "CATMAID state checks are enabled.", + disabledTitle: "Optimistic skeleton edits are enabled.", + }), + ).element, + ); + catmaidStateChecksElement.appendChild( + document.createTextNode("Use CATMAID state checks"), + ); + element.appendChild(catmaidStateChecksElement); + } element.appendChild( this.registerDisposer(new MessagesView(source.messages)).element, ); @@ -557,6 +605,7 @@ export class LayerDataSourcesTab extends Tab { let view = sourceViews.get(source); if (view === undefined) { view = new DataSourceView(this, source); + view.registerDisposer(source.changed.add(this.reRender)); view.registerDisposer( view.urlInput.dirty.changed.add(this.reRender), ); diff --git a/src/ui/skeleton_edit_tools.spec.ts b/src/ui/skeleton_edit_tools.spec.ts index c18ee5b59f..0044f74bd0 100644 --- a/src/ui/skeleton_edit_tools.spec.ts +++ b/src/ui/skeleton_edit_tools.spec.ts @@ -349,6 +349,10 @@ describe("spatial_skeleton_edit_tool", () => { expect.objectContaining({ node: expect.objectContaining({ nodeId: 5 }), }), + { + nocheck: undefined, + signal: undefined, + }, ); expect(upsertCachedNode).toHaveBeenCalledWith( { @@ -439,7 +443,10 @@ describe("spatial_skeleton_edit_tool", () => { positionInModelSpace: position, }); - expect(addNode).toHaveBeenCalledWith(13, 4, 5, 6, undefined, undefined); + expect(addNode).toHaveBeenCalledWith(13, 4, 5, 6, undefined, undefined, { + nocheck: undefined, + signal: undefined, + }); expect(upsertCachedNode).toHaveBeenCalledWith( { nodeId: 29, diff --git a/src/ui/skeleton_optimistic_edit_queue_tab.spec.ts b/src/ui/skeleton_optimistic_edit_queue_tab.spec.ts new file mode 100644 index 0000000000..d233d5ca1e --- /dev/null +++ b/src/ui/skeleton_optimistic_edit_queue_tab.spec.ts @@ -0,0 +1,175 @@ +/** + * @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 { afterEach, describe, expect, it, vi } from "vitest"; + +import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; +import type { SpatialSkeletonOptimisticEditDebugEntry } from "#src/skeleton/spatial_skeleton_manager.js"; +import { + maybeRegisterSpatialSkeletonOptimisticEditQueueTab, + OPTIMISTIC_EDIT_QUEUE_DEBUG, + SpatialSkeletonOptimisticEditQueueTab, +} from "#src/ui/skeleton_optimistic_edit_queue_tab.js"; + +function makeSignal() { + const listeners = new Set<() => void>(); + return { + changed: { + add(listener: () => void) { + listeners.add(listener); + return { + dispose() { + listeners.delete(listener); + }, + }; + }, + }, + dispatch() { + for (const listener of listeners) { + listener(); + } + }, + }; +} + +function makeQueueTabLayer( + getEntries: () => readonly SpatialSkeletonOptimisticEditDebugEntry[], +) { + const optimisticEditQueueVersion = makeSignal(); + const clearSettledOptimisticEdits = vi.fn(); + const layer = { + spatialSkeletonState: { + optimisticEditQueueVersion, + getOptimisticEditQueueDebugSnapshot: getEntries, + clearSettledOptimisticEdits, + }, + tabs: { + add: vi.fn(), + }, + } as unknown as SegmentationUserLayer; + return { + layer, + clearSettledOptimisticEdits, + optimisticEditQueueVersion, + }; +} + +describe("SpatialSkeletonOptimisticEditQueueTab", () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it("keeps Queue tab registration disabled by the debug flag by default", () => { + const { layer } = makeQueueTabLayer(() => []); + const hidden = { value: false, changed: makeSignal().changed }; + + expect(OPTIMISTIC_EDIT_QUEUE_DEBUG).toBe(false); + expect( + maybeRegisterSpatialSkeletonOptimisticEditQueueTab(layer, hidden as any), + ).toBe(false); + expect(layer.tabs.add).not.toHaveBeenCalled(); + }); + + it("registers a dedicated Queue tab when debug registration is enabled", () => { + const { layer } = makeQueueTabLayer(() => []); + const hidden = { value: false, changed: makeSignal().changed }; + + expect( + maybeRegisterSpatialSkeletonOptimisticEditQueueTab( + layer, + hidden as any, + true, + ), + ).toBe(true); + + expect(layer.tabs.add).toHaveBeenCalledWith( + "skeletonQueue", + expect.objectContaining({ + label: "Queue", + order: -44, + hidden, + }), + ); + }); + + it("renders an empty queue state and disables Clear settled", () => { + const { layer } = makeQueueTabLayer(() => []); + + const tab = new SpatialSkeletonOptimisticEditQueueTab(layer); + + expect(tab.element.textContent).toContain("Optimistic edit queue"); + expect(tab.element.textContent).toContain("empty"); + expect(tab.element.textContent).toContain("No queued optimistic edits."); + expect( + tab.element.querySelector( + ".neuroglancer-skeleton-queue-debug-clear", + )?.disabled, + ).toBe(true); + tab.dispose(); + }); + + it("renders queue rows, updates on queue version changes, and clears settled entries", () => { + let entries: readonly SpatialSkeletonOptimisticEditDebugEntry[] = [ + { + operationId: 1, + kind: "addNode", + status: "pending", + tempNodeId: Number.MAX_SAFE_INTEGER, + parentNodeId: 10, + segmentId: 10, + }, + { + operationId: 2, + kind: "deleteNode", + status: "committed", + nodeId: 20, + segmentId: 10, + }, + ]; + const { layer, optimisticEditQueueVersion, clearSettledOptimisticEdits } = + makeQueueTabLayer(() => entries); + const tab = new SpatialSkeletonOptimisticEditQueueTab(layer); + + expect(tab.element.textContent).toContain("committed: 1"); + expect(tab.element.textContent).toContain("pending: 1"); + expect(tab.element.textContent).toContain("#1 addNode"); + expect(tab.element.textContent).toContain("#2 deleteNode"); + const clearSettledButton = tab.element.querySelector( + ".neuroglancer-skeleton-queue-debug-clear", + )!; + expect(clearSettledButton.disabled).toBe(false); + + clearSettledButton.click(); + + expect(clearSettledOptimisticEdits).toHaveBeenCalledTimes(1); + + entries = [ + { + operationId: 3, + kind: "moveNode", + status: "inFlight", + nodeId: 30, + segmentId: 10, + }, + ]; + optimisticEditQueueVersion.dispatch(); + + expect(tab.element.textContent).toContain("inFlight: 1"); + expect(tab.element.textContent).toContain("#3 moveNode"); + expect(tab.element.textContent).not.toContain("#1 addNode"); + tab.dispose(); + }); +}); diff --git a/src/ui/skeleton_optimistic_edit_queue_tab.ts b/src/ui/skeleton_optimistic_edit_queue_tab.ts new file mode 100644 index 0000000000..67b7138638 --- /dev/null +++ b/src/ui/skeleton_optimistic_edit_queue_tab.ts @@ -0,0 +1,226 @@ +/** + * @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 "#src/ui/skeleton_tab.css"; + +import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; +import type { SpatialSkeletonOptimisticEditDebugEntry } from "#src/skeleton/spatial_skeleton_manager.js"; +import type { WatchableValueInterface } from "#src/trackable_value.js"; +import { Tab } from "#src/widget/tab_view.js"; + +// Flip this locally when inspecting optimistic queue transitions. +export const OPTIMISTIC_EDIT_QUEUE_DEBUG = true; + +function getOptimisticEditQueueEntries(layer: SegmentationUserLayer) { + return layer.spatialSkeletonState.getOptimisticEditQueueDebugSnapshot(); +} + +function getSettledEntryCount( + entries: readonly SpatialSkeletonOptimisticEditDebugEntry[], +) { + return entries.filter( + (entry) => + entry.status === "committed" || + entry.status === "failed" || + entry.status === "rolledBack", + ).length; +} + +export class SpatialSkeletonOptimisticEditQueueTab extends Tab { + private readonly queueDebugSummary = document.createElement("div"); + private readonly queueDebugClearSettledButton = + document.createElement("button"); + private readonly queueDebugFlow = document.createElement("div"); + private readonly queueDebugList = document.createElement("div"); + + constructor(public layer: SegmentationUserLayer) { + super(); + const { element } = this; + element.classList.add("neuroglancer-skeleton-queue-tab"); + + const queueDebugPanel = document.createElement("section"); + queueDebugPanel.className = "neuroglancer-skeleton-queue-debug"; + const queueDebugHeader = document.createElement("div"); + queueDebugHeader.className = "neuroglancer-skeleton-queue-debug-header"; + const queueDebugTitle = document.createElement("div"); + queueDebugTitle.className = "neuroglancer-skeleton-queue-debug-title"; + queueDebugTitle.textContent = "Optimistic edit queue"; + this.queueDebugSummary.className = + "neuroglancer-skeleton-queue-debug-summary"; + this.queueDebugClearSettledButton.className = + "neuroglancer-skeleton-queue-debug-clear"; + this.queueDebugClearSettledButton.type = "button"; + this.queueDebugClearSettledButton.textContent = "Clear settled"; + this.queueDebugClearSettledButton.title = + "Remove committed, failed, and rolled-back optimistic queue entries."; + this.queueDebugClearSettledButton.addEventListener("click", () => { + layer.spatialSkeletonState.clearSettledOptimisticEdits(); + this.updateQueueDebugPanel(); + }); + const queueDebugHeaderActions = document.createElement("div"); + queueDebugHeaderActions.className = + "neuroglancer-skeleton-queue-debug-header-actions"; + queueDebugHeaderActions.appendChild(this.queueDebugSummary); + queueDebugHeaderActions.appendChild(this.queueDebugClearSettledButton); + queueDebugHeader.appendChild(queueDebugTitle); + queueDebugHeader.appendChild(queueDebugHeaderActions); + queueDebugPanel.appendChild(queueDebugHeader); + this.queueDebugFlow.className = "neuroglancer-skeleton-queue-debug-flow"; + queueDebugPanel.appendChild(this.queueDebugFlow); + this.queueDebugList.className = "neuroglancer-skeleton-queue-debug-list"; + queueDebugPanel.appendChild(this.queueDebugList); + element.appendChild(queueDebugPanel); + + this.registerDisposer( + layer.spatialSkeletonState.optimisticEditQueueVersion.changed.add(() => { + this.updateQueueDebugPanel(); + }), + ); + this.updateQueueDebugPanel(); + } + + private makeFlowLane( + entries: readonly SpatialSkeletonOptimisticEditDebugEntry[], + label: string, + statuses: readonly string[], + emptyLabel: string, + ) { + const lane = document.createElement("div"); + lane.className = "neuroglancer-skeleton-queue-debug-lane"; + const laneLabel = document.createElement("div"); + laneLabel.className = "neuroglancer-skeleton-queue-debug-lane-label"; + laneLabel.textContent = label; + const laneItems = document.createElement("div"); + laneItems.className = "neuroglancer-skeleton-queue-debug-lane-items"; + const laneEntries = entries.filter((entry) => + statuses.includes(entry.status), + ); + if (laneEntries.length === 0) { + const empty = document.createElement("span"); + empty.className = "neuroglancer-skeleton-queue-debug-empty"; + empty.textContent = emptyLabel; + laneItems.appendChild(empty); + } else { + for (const entry of laneEntries) { + const item = document.createElement("span"); + item.className = "neuroglancer-skeleton-queue-debug-chip"; + item.dataset.status = entry.status; + item.textContent = `#${entry.operationId ?? "-"}`; + item.title = `${entry.kind} ${entry.status}`; + laneItems.appendChild(item); + } + } + lane.appendChild(laneLabel); + lane.appendChild(laneItems); + return lane; + } + + private makeQueueDebugRow( + entry: SpatialSkeletonOptimisticEditDebugEntry, + index: number, + ) { + const row = document.createElement("div"); + row.className = "neuroglancer-skeleton-queue-debug-row"; + row.dataset.status = entry.status; + const tempNode = + entry.tempNodeId === undefined ? "-" : entry.tempNodeId.toString(); + const segment = + entry.segmentId === undefined ? "-" : entry.segmentId.toString(); + const parent = + entry.parentNodeId === undefined + ? (entry.parentTempNodeId?.toString() ?? "-") + : entry.parentNodeId.toString(); + const server = + entry.nodeId === undefined + ? "-" + : `${entry.nodeId}:${entry.segmentId ?? "-"}`; + const order = document.createElement("span"); + order.className = "neuroglancer-skeleton-queue-debug-order"; + order.textContent = `${index + 1}`; + const status = document.createElement("span"); + status.className = "neuroglancer-skeleton-queue-debug-status"; + status.textContent = entry.status; + const action = document.createElement("span"); + action.className = "neuroglancer-skeleton-queue-debug-action"; + action.textContent = `#${entry.operationId ?? "-"} ${entry.kind}`; + const details = document.createElement("span"); + details.className = "neuroglancer-skeleton-queue-debug-details"; + details.textContent = `temp ${tempNode} segment ${segment} parent ${parent} server ${server}`; + row.appendChild(order); + row.appendChild(status); + row.appendChild(action); + row.appendChild(details); + return row; + } + + updateQueueDebugPanel() { + const entries = getOptimisticEditQueueEntries(this.layer); + const statusCounts = new Map(); + for (const entry of entries) { + statusCounts.set(entry.status, (statusCounts.get(entry.status) ?? 0) + 1); + } + this.queueDebugClearSettledButton.disabled = + getSettledEntryCount(entries) === 0; + this.queueDebugSummary.textContent = + entries.length === 0 + ? "empty" + : Array.from(statusCounts, ([status, count]) => `${status}: ${count}`) + .sort() + .join(" / "); + this.queueDebugFlow.replaceChildren( + this.makeFlowLane(entries, "pending", ["pending"], "none"), + this.makeFlowLane( + entries, + "in flight", + ["inFlight", "cancelRequested"], + "idle", + ), + this.makeFlowLane( + entries, + "settled", + ["committed", "failed", "rolledBack"], + "none", + ), + ); + if (entries.length === 0) { + const empty = document.createElement("div"); + empty.className = "neuroglancer-skeleton-queue-debug-empty-state"; + empty.textContent = "No queued optimistic edits."; + this.queueDebugList.replaceChildren(empty); + return; + } + this.queueDebugList.replaceChildren( + ...entries.map((entry, index) => this.makeQueueDebugRow(entry, index)), + ); + } +} + +export function maybeRegisterSpatialSkeletonOptimisticEditQueueTab( + layer: SegmentationUserLayer, + hidden: WatchableValueInterface, + enabled = OPTIMISTIC_EDIT_QUEUE_DEBUG, +) { + if (!enabled) { + return false; + } + layer.tabs.add("skeletonQueue", { + label: "Queue", + order: -44, + getter: () => new SpatialSkeletonOptimisticEditQueueTab(layer), + hidden, + }); + return true; +} diff --git a/src/ui/skeleton_tab.css b/src/ui/skeleton_tab.css index a05017ae1c..405c572afc 100644 --- a/src/ui/skeleton_tab.css +++ b/src/ui/skeleton_tab.css @@ -168,6 +168,175 @@ min-width: 0; } +.neuroglancer-skeleton-queue-debug { + border-top: 1px solid #2a2a2a; + color: #d0d0d0; + font-family: monospace; + font-size: 11px; + line-height: 1.3; + padding: 5px 6px 6px; + background: #090909; +} + +.neuroglancer-skeleton-queue-debug-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.neuroglancer-skeleton-queue-debug-title { + color: #f0f0f0; + font-weight: 600; +} + +.neuroglancer-skeleton-queue-debug-summary { + color: #aeb4bb; + text-align: right; +} + +.neuroglancer-skeleton-queue-debug-header-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.neuroglancer-skeleton-queue-debug-clear { + border: 1px solid #3a3a3a; + background: #171717; + color: #d6d6d6; + cursor: pointer; + font: inherit; + padding: 1px 5px; + white-space: nowrap; +} + +.neuroglancer-skeleton-queue-debug-clear:hover:not(:disabled) { + border-color: #547fbd; + background: #11283f; +} + +.neuroglancer-skeleton-queue-debug-clear:disabled { + color: #707070; + cursor: default; +} + +.neuroglancer-skeleton-queue-debug-flow { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 4px; + margin-top: 5px; +} + +.neuroglancer-skeleton-queue-debug-lane { + min-width: 0; + border: 1px solid #2f2f2f; + background: #111; + padding: 4px; +} + +.neuroglancer-skeleton-queue-debug-lane-label { + color: #9ba2aa; + font-size: 10px; + text-transform: uppercase; +} + +.neuroglancer-skeleton-queue-debug-lane-items { + display: flex; + flex-wrap: wrap; + gap: 3px; + margin-top: 3px; + min-height: 17px; +} + +.neuroglancer-skeleton-queue-debug-chip, +.neuroglancer-skeleton-queue-debug-status { + border: 1px solid #3a3a3a; + background: #202020; + color: #f0f0f0; + padding: 1px 4px; +} + +.neuroglancer-skeleton-queue-debug-chip[data-status="pending"], +.neuroglancer-skeleton-queue-debug-row[data-status="pending"] + .neuroglancer-skeleton-queue-debug-status { + border-color: #65737e; + background: #1b252d; +} + +.neuroglancer-skeleton-queue-debug-chip[data-status="inFlight"], +.neuroglancer-skeleton-queue-debug-row[data-status="inFlight"] + .neuroglancer-skeleton-queue-debug-status { + border-color: #547fbd; + background: #11283f; +} + +.neuroglancer-skeleton-queue-debug-chip[data-status="cancelRequested"], +.neuroglancer-skeleton-queue-debug-row[data-status="cancelRequested"] + .neuroglancer-skeleton-queue-debug-status { + border-color: #bd8a3b; + background: #342412; +} + +.neuroglancer-skeleton-queue-debug-chip[data-status="committed"], +.neuroglancer-skeleton-queue-debug-row[data-status="committed"] + .neuroglancer-skeleton-queue-debug-status { + border-color: #4f8f65; + background: #15311f; +} + +.neuroglancer-skeleton-queue-debug-chip[data-status="failed"], +.neuroglancer-skeleton-queue-debug-row[data-status="failed"] + .neuroglancer-skeleton-queue-debug-status { + border-color: #b85b5b; + background: #361919; +} + +.neuroglancer-skeleton-queue-debug-chip[data-status="rolledBack"], +.neuroglancer-skeleton-queue-debug-row[data-status="rolledBack"] + .neuroglancer-skeleton-queue-debug-status { + border-color: #7d6f9e; + background: #201b31; +} + +.neuroglancer-skeleton-queue-debug-list { + display: flex; + flex-direction: column; + gap: 3px; + margin-top: 5px; + overflow-wrap: anywhere; +} + +.neuroglancer-skeleton-queue-debug-row { + display: grid; + grid-template-columns: 18px minmax(78px, 108px) minmax(86px, 112px) minmax( + 0, + 1fr + ); + align-items: center; + gap: 4px; + min-width: 0; +} + +.neuroglancer-skeleton-queue-debug-order { + color: #8d949c; + text-align: right; +} + +.neuroglancer-skeleton-queue-debug-action, +.neuroglancer-skeleton-queue-debug-details { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.neuroglancer-skeleton-queue-debug-details, +.neuroglancer-skeleton-queue-debug-empty, +.neuroglancer-skeleton-queue-debug-empty-state { + color: #9ba2aa; +} + .neuroglancer-skeleton-tree { display: flex; flex-direction: column; diff --git a/src/ui/skeleton_tab.ts b/src/ui/skeleton_tab.ts index 8eb3ff53aa..6a9b2840e9 100644 --- a/src/ui/skeleton_tab.ts +++ b/src/ui/skeleton_tab.ts @@ -1755,13 +1755,18 @@ export class SpatialSkeletonEditTab extends Tab { const undoLabel = commandHistory.undoLabel.value; const redoLabel = commandHistory.redoLabel.value; const busy = commandHistory.isBusy.value; - undoButton.disabled = busy || !commandHistory.canUndo.value; + const canUndoOptimistic = + layer.spatialSkeletonState.canUndoOptimisticEdit(); + undoButton.disabled = + busy || (!canUndoOptimistic && !commandHistory.canUndo.value); redoButton.disabled = busy || !commandHistory.canRedo.value; undoButton.title = busy ? "Wait for the current skeleton edit to finish." - : undoLabel === undefined - ? "Nothing to undo." - : `Undo ${undoLabel}`; + : canUndoOptimistic + ? "Undo pending node creation." + : undoLabel === undefined + ? "Nothing to undo." + : `Undo ${undoLabel}`; redoButton.title = busy ? "Wait for the current skeleton edit to finish." : redoLabel === undefined @@ -1844,6 +1849,12 @@ export class SpatialSkeletonEditTab extends Tab { updateHistoryButtons(); }), ); + this.registerDisposer( + layer.spatialSkeletonState.optimisticEditQueueVersion.changed.add(() => { + updateGateStatus(); + updateHistoryButtons(); + }), + ); this.registerDisposer( layer.manager.root.selectionState.changed.add(() => { const nextActiveSegmentId = getSelectedSegmentId(); From 25187f3e4ffe351a33462211465b4972c3a9bd04 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 14 Jul 2026 17:17:27 +0200 Subject: [PATCH 2/2] Merge of MetaCell/feature/optimistic-preview-2 Feature/optimistic preview 2 --- src/datasource/catmaid/api.spec.ts | 71 +- src/datasource/catmaid/api.ts | 68 +- .../catmaid/spatial_skeleton_commands.ts | 1555 ++++++++++++++++- .../catmaid/spatial_skeleton_edit_api.ts | 3 + src/layer/segmentation/index.spec.ts | 67 + src/layer/segmentation/index.ts | 20 +- src/skeleton/commands.spec.ts | 605 ++++++- src/skeleton/optimistic_edit_queue_config.ts | 19 + src/skeleton/spatial_skeleton_manager.spec.ts | 344 ++++ src/skeleton/spatial_skeleton_manager.ts | 246 ++- src/ui/skeleton_optimistic_edit_queue_tab.ts | 15 +- src/ui/skeleton_tab.ts | 2 +- 12 files changed, 2880 insertions(+), 135 deletions(-) create mode 100644 src/skeleton/optimistic_edit_queue_config.ts diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index de8280ec48..37cf6aee5f 100644 --- a/src/datasource/catmaid/api.spec.ts +++ b/src/datasource/catmaid/api.spec.ts @@ -611,6 +611,32 @@ describe("CatmaidClient skeleton editing methods", () => { ); }); + it("merges skeletons with nocheck state when requested", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.fn().mockResolvedValue({ + result_skeleton_id: 17, + deleted_skeleton_id: 21, + stable_annotation_swap: true, + }); + (client as any).fetchProjectEndpoint = fetchMock; + + await expect( + client.mergeSkeletons(101, 202, undefined, { nocheck: true }), + ).resolves.toEqual({ + resultSegmentId: 17, + deletedSegmentId: 21, + directionAdjusted: true, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const requestBody = getFetchBody(fetchMock); + expect(getFetchPath(fetchMock)).toBe("skeleton/join"); + expect(requestBody.get("from_id")).toBe("101"); + expect(requestBody.get("to_id")).toBe("202"); + expect(requestBody.get("nocheck")).toBeNull(); + expect(requestBody.get("state")).toBe(JSON.stringify({ nocheck: true })); + }); + it("parses browse node/list rows with revision tokens", async () => { const client = new CatmaidClient("https://example.invalid", 1); const fetchMock = vi.fn().mockResolvedValue([ @@ -919,6 +945,25 @@ describe("CatmaidClient skeleton editing methods", () => { ); }); + it("reroots skeletons with nocheck state for optimistic compensation", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.fn().mockResolvedValue({ + newroot: 202, + skeleton_id: 17, + }); + (client as any).fetchProjectEndpoint = fetchMock; + + await expect( + client.rerootSkeleton(202, undefined, { nocheck: true }), + ).resolves.toEqual({}); + + const requestBody = getFetchBody(fetchMock); + expect(getFetchPath(fetchMock)).toBe("skeleton/reroot"); + expect(requestBody.get("treenode_id")).toBe("202"); + expect(requestBody.get("nocheck")).toBeNull(); + expect(requestBody.get("state")).toBe(JSON.stringify({ nocheck: true })); + }); + it("rejects reroot state when the parent neighborhood is incomplete", async () => { const client = new CatmaidClient("https://example.invalid", 1); const fetchMock = vi.fn(); @@ -981,7 +1026,31 @@ describe("CatmaidClient skeleton editing methods", () => { ); }); - it("does not trust reroot response edition_time for revision state", async () => { + it("splits skeletons with nocheck state when requested", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.fn().mockResolvedValue({ + existing_skeleton_id: 17, + new_skeleton_id: 21, + }); + (client as any).fetchProjectEndpoint = fetchMock; + + await expect( + client.splitSkeleton(202, undefined, { nocheck: true }), + ).resolves.toEqual({ + existingSegmentId: 17, + newSegmentId: 21, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const requestBody = getFetchBody(fetchMock); + expect(getFetchPath(fetchMock)).toBe("skeleton/split"); + expect(requestBody.get("treenode_id")).toBe("202"); + expect(requestBody.get("downstream_annotation_map")).toBe("{}"); + expect(requestBody.get("nocheck")).toBeNull(); + expect(requestBody.get("state")).toBe(JSON.stringify({ nocheck: true })); + }); + + it("rejects reroot when the response is missing edition_time", async () => { const client = new CatmaidClient("https://example.invalid", 1); const fetchMock = vi.fn().mockResolvedValue({ newroot: 202, diff --git a/src/datasource/catmaid/api.ts b/src/datasource/catmaid/api.ts index 79ed7085b7..abd9126be9 100644 --- a/src/datasource/catmaid/api.ts +++ b/src/datasource/catmaid/api.ts @@ -128,6 +128,18 @@ export interface CatmaidMoveNodeOptions { nocheck?: boolean; } +export interface CatmaidMergeSkeletonOptions { + nocheck?: boolean; +} + +export interface CatmaidSplitSkeletonOptions { + nocheck?: boolean; +} + +export interface CatmaidRerootSkeletonOptions { + nocheck?: boolean; +} + export type CatmaidDeleteNodeResult = CatmaidSkeletonEditResult; export type CatmaidRerootResult = CatmaidSkeletonEditResult; @@ -171,11 +183,13 @@ export interface CatmaidSpatialSkeletonEditApi { splitSkeleton( nodeId: number, editContext?: CatmaidEditContext, + options?: CatmaidSplitSkeletonOptions, ): Promise; mergeSkeletons( fromNodeId: number, toNodeId: number, editContext?: CatmaidEditContext, + options?: CatmaidMergeSkeletonOptions, ): Promise; toggleTrueEnd( nodeId: number, @@ -193,6 +207,7 @@ export interface CatmaidSpatialSkeletonEditApi { rerootSkeleton( nodeId: number, editContext?: CatmaidEditContext, + options?: CatmaidRerootSkeletonOptions, ): Promise; updateDescription( nodeId: number, @@ -1670,16 +1685,21 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { async rerootSkeleton( nodeId: number, editContext?: CatmaidEditContext, + options: CatmaidRerootSkeletonOptions = {}, ): Promise { const body = new URLSearchParams({ treenode_id: nodeId.toString(), }); - appendCatmaidState( - body, - buildCatmaidNeighborhoodState("reroot-skeleton", editContext, { - expectedNodeId: nodeId, - }), - ); + if (options.nocheck === true) { + appendCatmaidState(body, { nocheck: true }); + } else { + appendCatmaidState( + body, + buildCatmaidNeighborhoodState("reroot-skeleton", editContext, { + expectedNodeId: nodeId, + }), + ); + } const response = await this.fetchProjectEndpoint(`skeleton/reroot`, { method: "POST", body, @@ -2060,18 +2080,23 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { fromNodeId: number, toNodeId: number, editContext?: CatmaidEditContext, + options: CatmaidMergeSkeletonOptions = {}, ): Promise { const body = new URLSearchParams({ from_id: fromNodeId.toString(), to_id: toNodeId.toString(), }); - appendCatmaidState( - body, - buildCatmaidMultiNodeState("merge-skeleton", editContext, [ - fromNodeId, - toNodeId, - ]), - ); + if (options.nocheck === true) { + appendCatmaidState(body, { nocheck: true }); + } else { + appendCatmaidState( + body, + buildCatmaidMultiNodeState("merge-skeleton", editContext, [ + fromNodeId, + toNodeId, + ]), + ); + } const response = await this.fetchProjectEndpoint(`skeleton/join`, { method: "POST", body, @@ -2092,17 +2117,22 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { async splitSkeleton( nodeId: number, editContext?: CatmaidEditContext, + options: CatmaidSplitSkeletonOptions = {}, ): Promise { const body = new URLSearchParams({ treenode_id: nodeId.toString(), downstream_annotation_map: JSON.stringify({}), }); - appendCatmaidState( - body, - buildCatmaidNeighborhoodState("split-skeleton", editContext, { - expectedNodeId: nodeId, - }), - ); + if (options.nocheck === true) { + appendCatmaidState(body, { nocheck: true }); + } else { + appendCatmaidState( + body, + buildCatmaidNeighborhoodState("split-skeleton", editContext, { + expectedNodeId: nodeId, + }), + ); + } const response = await this.fetchProjectEndpoint(`skeleton/split`, { method: "POST", body, diff --git a/src/datasource/catmaid/spatial_skeleton_commands.ts b/src/datasource/catmaid/spatial_skeleton_commands.ts index 50e837e453..8c536663c3 100644 --- a/src/datasource/catmaid/spatial_skeleton_commands.ts +++ b/src/datasource/catmaid/spatial_skeleton_commands.ts @@ -475,6 +475,48 @@ function cloneNodeSnapshot( }; } +function rerootSegmentNodeSnapshots( + segmentNodes: readonly SpatiallyIndexedSkeletonNode[], + targetNodeId: number, +) { + const nodes = segmentNodes.map(cloneNodeSnapshot); + const nodeById = new Map(nodes.map((node) => [node.nodeId, node])); + const targetNode = nodeById.get(targetNodeId); + if (targetNode === undefined) { + throw new Error( + `Unable to reroot preview at missing node ${targetNodeId}.`, + ); + } + const path: SpatiallyIndexedSkeletonNode[] = []; + const seen = new Set(); + let current: SpatiallyIndexedSkeletonNode | undefined = targetNode; + while (current !== undefined) { + if (seen.has(current.nodeId)) { + throw new Error("Unable to reroot cyclic skeleton preview."); + } + seen.add(current.nodeId); + path.push(current); + current = + current.parentNodeId === undefined + ? undefined + : nodeById.get(current.parentNodeId); + if (path.at(-1)?.parentNodeId !== undefined && current === undefined) { + throw new Error("Unable to reroot incomplete skeleton preview."); + } + } + let downstreamConfidence = path[0].confidence; + path[0].parentNodeId = undefined; + path[0].confidence = 100; + for (let index = 1; index < path.length; ++index) { + const node = path[index]; + const previousConfidence = node.confidence; + node.parentNodeId = path[index - 1].nodeId; + node.confidence = downstreamConfidence ?? node.confidence; + downstreamConfidence = previousConfidence; + } + return nodes; +} + function getEditableSkeletonSourceForLayer(layer: SegmentationUserLayer): { skeletonLayer: SpatiallyIndexedSkeletonLayer; } { @@ -732,6 +774,7 @@ async function getResolvedNodeForEdit( segmentNodes = await layer.spatialSkeletonState.getFullSegmentNodes( skeletonLayer, candidateSegmentId, + { retainWhileInactive: true }, ); } const node = findSpatiallyIndexedSkeletonNode(segmentNodes, currentNodeId); @@ -751,8 +794,8 @@ async function refreshTopologySegments( layer: SegmentationUserLayer, segmentIds: readonly number[], affectedPositions: Iterable>, + { invalidateSourceCells = true }: { invalidateSourceCells?: boolean } = {}, ) { - const preRefreshPositions = [...affectedPositions]; const normalizedSegmentIds = [ ...new Set( segmentIds @@ -764,16 +807,20 @@ async function refreshTopologySegments( return; } const { skeletonLayer } = getEditableSkeletonSourceForLayer(layer); - skeletonLayer.invalidateSourceCellsForPositions(preRefreshPositions); - layer.spatialSkeletonState.invalidateCachedSegments(normalizedSegmentIds); - layer.markSpatialSkeletonNodeDataChanged({ - invalidateFullSkeletonCache: false, - }); - await Promise.allSettled( - normalizedSegmentIds.map((segmentId) => - layer.spatialSkeletonState.getFullSegmentNodes(skeletonLayer, segmentId), - ), - ); + if (invalidateSourceCells) { + skeletonLayer.invalidateSourceCellsForPositions(affectedPositions); + } + if ( + await layer.spatialSkeletonState.refreshCachedSegments( + skeletonLayer, + normalizedSegmentIds, + { notify: false }, + ) + ) { + layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + } } function applyCreatedNodeToCache( @@ -1254,7 +1301,12 @@ enum CatmaidOptimisticEditStatus { RolledBack = "rolledBack", } -type CatmaidOptimisticEditKind = "addNode" | "moveNode" | "deleteNode"; +type CatmaidOptimisticEditKind = + | "addNode" + | "moveNode" + | "deleteNode" + | "splitSkeleton" + | "mergeSkeletons"; interface CatmaidOptimisticEditEntryBase { readonly operationId: number; @@ -1284,7 +1336,7 @@ interface CatmaidOptimisticMoveNodeEntry segmentId: number; nodeForServer: SpatiallyIndexedSkeletonNode; result?: CatmaidSpatialSkeletonNodeSourceStateResult; - readonly beforePositionInModelSpace: Float32Array; + beforePositionInModelSpace: Float32Array; readonly afterPositionInModelSpace: Float32Array; } @@ -1304,12 +1356,43 @@ interface CatmaidOptimisticDeleteNodeEntry result?: CatmaidSpatialSkeletonDeleteNodeResult; } +interface CatmaidOptimisticSplitSkeletonEntry + extends CatmaidOptimisticEditEntryBase { + readonly kind: "splitSkeleton"; + readonly command: SplitCommand; + nodeId: number; + originalSegmentId: number; + tempSegmentId: number; + formerParentNodeId: number; + originalSegmentNodes: SpatiallyIndexedSkeletonNode[]; + affectedPositions: readonly ArrayLike[]; + result?: CatmaidSpatialSkeletonSplitResult; +} + +interface CatmaidOptimisticMergeSkeletonsEntry + extends CatmaidOptimisticEditEntryBase { + readonly kind: "mergeSkeletons"; + readonly command: MergeCommand; + firstNodeId: number; + secondNodeId: number; + firstSegmentId: number; + secondSegmentId: number; + previewResultSegmentId: number; + firstSegmentNodes: SpatiallyIndexedSkeletonNode[]; + secondSegmentNodes: SpatiallyIndexedSkeletonNode[]; + affectedPositions: readonly ArrayLike[]; + result?: CatmaidSpatialSkeletonMergeResult; +} + type CatmaidOptimisticEditEntry = | CatmaidOptimisticAddNodeEntry | CatmaidOptimisticMoveNodeEntry - | CatmaidOptimisticDeleteNodeEntry; + | CatmaidOptimisticDeleteNodeEntry + | CatmaidOptimisticSplitSkeletonEntry + | CatmaidOptimisticMergeSkeletonsEntry; const CATMAID_OPTIMISTIC_TEMP_ID_START = Number.MAX_SAFE_INTEGER; +const CATMAID_OPTIMISTIC_TEMP_SEGMENT_ID_START = 0xffff_fffe; const CATMAID_OPTIMISTIC_IN_FLIGHT_WARNING_DELAY_MS = 30_000; const catmaidOptimisticEditQueues = new WeakMap< @@ -1360,8 +1443,12 @@ class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { private entries: CatmaidOptimisticEditEntry[] = []; private nextOperationId = 1; private nextTempId = CATMAID_OPTIMISTIC_TEMP_ID_START; + private nextTempSegmentId = CATMAID_OPTIMISTIC_TEMP_SEGMENT_ID_START; private draining = false; private disposed = false; + private reconcilingTopology = false; + private topologyRefreshSegmentIds = new Set(); + private topologyRefreshPositions: ArrayLike[] = []; constructor( private readonly layer: SegmentationUserLayer, @@ -1372,6 +1459,17 @@ class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { return this.editOperations === editOperations; } + canQueueAction(action: SpatialSkeletonAction) { + if (this.reconcilingTopology) return false; + return ( + action === SpatialSkeletonActions.addNodes || + action === SpatialSkeletonActions.moveNodes || + action === SpatialSkeletonActions.deleteNodes || + action === SpatialSkeletonActions.mergeSkeletons || + action === SpatialSkeletonActions.splitSkeletons + ); + } + canUndo() { return this.entries.some( (entry) => @@ -1381,31 +1479,65 @@ class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { } hasUnconfirmedActions() { - return this.entries.some( - (entry) => - entry.status === CatmaidOptimisticEditStatus.Pending || - entry.status === CatmaidOptimisticEditStatus.InFlight || - entry.status === CatmaidOptimisticEditStatus.CancelRequested, + return ( + this.reconcilingTopology || + this.entries.some( + (entry) => + entry.status === CatmaidOptimisticEditStatus.Pending || + entry.status === CatmaidOptimisticEditStatus.InFlight || + entry.status === CatmaidOptimisticEditStatus.CancelRequested, + ) ); } getDebugSnapshot() { - return this.entries.map((entry) => ({ - operationId: entry.operationId, - kind: entry.kind, - status: entry.status, - tempNodeId: entry.kind === "addNode" ? entry.tempNodeId : undefined, - parentNodeId: entry.kind === "addNode" ? entry.parentNodeId : undefined, - parentTempNodeId: - entry.kind === "addNode" ? entry.parentTempNodeId : undefined, - nodeId: entry.kind === "addNode" ? entry.result?.nodeId : entry.nodeId, - segmentId: - entry.kind === "addNode" - ? (entry.result?.segmentId ?? - this.layer.spatialSkeletonState.getCachedNode(entry.tempNodeId) - ?.segmentId) - : entry.segmentId, - })); + return this.entries.map((entry) => { + const base = { + operationId: entry.operationId, + kind: entry.kind, + status: entry.status, + dependencies: [...entry.dependencies], + }; + switch (entry.kind) { + case "addNode": + return { + ...base, + tempNodeId: entry.tempNodeId, + parentNodeId: entry.parentNodeId, + parentTempNodeId: entry.parentTempNodeId, + nodeId: entry.result?.nodeId, + segmentId: + entry.result?.segmentId ?? + this.layer.spatialSkeletonState.getCachedNode(entry.tempNodeId) + ?.segmentId, + }; + case "moveNode": + case "deleteNode": + return { + ...base, + nodeId: entry.nodeId, + segmentId: entry.segmentId, + }; + case "splitSkeleton": + return { + ...base, + nodeId: entry.nodeId, + segmentId: entry.originalSegmentId, + tempSegmentId: entry.tempSegmentId, + resultSegmentId: entry.result?.newSegmentId, + }; + case "mergeSkeletons": + return { + ...base, + nodeId: entry.firstNodeId, + secondNodeId: entry.secondNodeId, + segmentId: entry.firstSegmentId, + secondSegmentId: entry.secondSegmentId, + resultSegmentId: entry.result?.resultSegmentId, + deletedSegmentId: entry.result?.deletedSegmentId, + }; + } + }); } clearSettled() { @@ -1461,6 +1593,8 @@ class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { private rollbackAndCancelForReset() { const changed = this.entries.length !== 0; + this.topologyRefreshSegmentIds.clear(); + this.topologyRefreshPositions = []; for (const entry of [...this.entries].reverse()) { this.clearInFlightWarning(entry); if ( @@ -1495,7 +1629,9 @@ class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { this.layer.spatialSkeletonState.getCachedSegmentNodes(tempId) === undefined && !this.entries.some( - (entry) => entry.kind === "addNode" && entry.tempNodeId === tempId, + (entry) => + (entry.kind === "addNode" && entry.tempNodeId === tempId) || + (entry.kind === "splitSkeleton" && entry.tempSegmentId === tempId), ) ) { return tempId; @@ -1504,6 +1640,26 @@ class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { throw new Error("Unable to allocate optimistic skeleton edit id."); } + private allocateTempSegmentId() { + while (this.nextTempSegmentId > 0) { + const tempSegmentId = this.nextTempSegmentId--; + if ( + this.layer.spatialSkeletonState.getCachedNode(tempSegmentId) === + undefined && + this.layer.spatialSkeletonState.getCachedSegmentNodes(tempSegmentId) === + undefined && + !this.entries.some( + (entry) => + entry.kind === "splitSkeleton" && + entry.tempSegmentId === tempSegmentId, + ) + ) { + return tempSegmentId; + } + } + throw new Error("Unable to allocate optimistic skeleton segment id."); + } + private findEntryForTempNode( nodeId: number | undefined, ): CatmaidOptimisticAddNodeEntry | undefined { @@ -1565,12 +1721,19 @@ class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { return dependency?.status === CatmaidOptimisticEditStatus.Committed; } - private getActiveDependenciesForNodes(nodeIds: Iterable) { + private getActiveDependenciesForResources( + nodeIds: Iterable, + segmentIds: Iterable = [], + ) { const nodeIdSet = new Set(nodeIds); + const segmentIdSet = new Set(segmentIds); const dependencies: number[] = []; for (const entry of this.entries) { if (!this.isActive(entry)) continue; - if (this.entryTouchesAnyNode(entry, nodeIdSet)) { + if ( + this.entryTouchesAnyNode(entry, nodeIdSet) || + this.entryTouchesAnySegment(entry, segmentIdSet) + ) { dependencies.push(entry.operationId); } } @@ -1596,6 +1759,58 @@ class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { nodeIds.has(child.nodeId), ) ); + case "splitSkeleton": + return entry.originalSegmentNodes.some((node) => + nodeIds.has(node.nodeId), + ); + case "mergeSkeletons": + return ( + entry.firstSegmentNodes.some((node) => nodeIds.has(node.nodeId)) || + entry.secondSegmentNodes.some((node) => nodeIds.has(node.nodeId)) + ); + } + } + + private entryTouchesAnySegment( + entry: CatmaidOptimisticEditEntry, + segmentIds: ReadonlySet, + ) { + if (segmentIds.size === 0) return false; + switch (entry.kind) { + case "addNode": { + const previewSegmentId = + entry.result?.segmentId ?? + this.layer.spatialSkeletonState.getCachedNode(entry.tempNodeId) + ?.segmentId; + return ( + (previewSegmentId !== undefined && + segmentIds.has(previewSegmentId)) || + (entry.parentNodeForServer !== undefined && + segmentIds.has(entry.parentNodeForServer.segmentId)) + ); + } + case "moveNode": + case "deleteNode": + return segmentIds.has(entry.segmentId); + case "splitSkeleton": + return ( + segmentIds.has(entry.originalSegmentId) || + segmentIds.has(entry.tempSegmentId) || + (entry.result?.existingSegmentId !== undefined && + segmentIds.has(entry.result.existingSegmentId)) || + (entry.result?.newSegmentId !== undefined && + segmentIds.has(entry.result.newSegmentId)) + ); + case "mergeSkeletons": + return ( + segmentIds.has(entry.firstSegmentId) || + segmentIds.has(entry.secondSegmentId) || + segmentIds.has(entry.previewResultSegmentId) || + (entry.result?.resultSegmentId !== undefined && + segmentIds.has(entry.result.resultSegmentId)) || + (entry.result?.deletedSegmentId !== undefined && + segmentIds.has(entry.result.deletedSegmentId)) + ); } } @@ -1627,19 +1842,32 @@ class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { ); } + private hasActiveTopologyDependent(entry: CatmaidOptimisticAddNodeEntry) { + return this.entries.some( + (candidate) => + (candidate.kind === "splitSkeleton" || + candidate.kind === "mergeSkeletons") && + this.isActive(candidate) && + candidate.dependencies.includes(entry.operationId), + ); + } + private previewAddNodeMatchesEntry( entry: CatmaidOptimisticAddNodeEntry, previewNode: SpatiallyIndexedSkeletonNode | undefined, expectedSegmentId?: number, ): previewNode is SpatiallyIndexedSkeletonNode { + const hasTopologyDependent = this.hasActiveTopologyDependent(entry); return ( previewNode !== undefined && previewNode.nodeId === entry.tempNodeId && - previewNode.parentNodeId === entry.parentNodeId && + (previewNode.parentNodeId === entry.parentNodeId || + hasTopologyDependent) && (positionsEqual(previewNode.position, entry.positionInModelSpace) || this.hasActiveMoveDependent(entry)) && (expectedSegmentId === undefined || - previewNode.segmentId === expectedSegmentId) + previewNode.segmentId === expectedSegmentId || + hasTopologyDependent) ); } @@ -1682,6 +1910,10 @@ class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { return this.rollbackMoveNodePreview(entry); case "deleteNode": return this.rollbackDeleteNodePreview(entry); + case "splitSkeleton": + return this.rollbackSplitSkeletonPreview(entry); + case "mergeSkeletons": + return this.rollbackMergeSkeletonsPreview(entry); } } @@ -1805,6 +2037,152 @@ class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { return true; } + private applySplitSkeletonPreview( + entry: CatmaidOptimisticSplitSkeletonEntry, + ) { + const subtreeNodeIds = new Set( + getSpatiallyIndexedSkeletonSubtreeNodes( + entry.originalSegmentNodes, + entry.nodeId, + ).map((node) => node.nodeId), + ); + if (!subtreeNodeIds.has(entry.nodeId)) { + subtreeNodeIds.add(entry.nodeId); + } + const upstreamNodes: SpatiallyIndexedSkeletonNode[] = []; + const downstreamNodes: SpatiallyIndexedSkeletonNode[] = []; + for (const originalNode of entry.originalSegmentNodes) { + const node = cloneNodeSnapshot(originalNode); + if (subtreeNodeIds.has(node.nodeId)) { + node.segmentId = entry.tempSegmentId; + if (node.nodeId === entry.nodeId) { + node.parentNodeId = undefined; + } + downstreamNodes.push(node); + } else { + node.segmentId = entry.originalSegmentId; + upstreamNodes.push(node); + } + } + this.layer.spatialSkeletonState.replaceCachedSegmentSnapshots( + [ + [entry.originalSegmentId, upstreamNodes], + [entry.tempSegmentId, downstreamNodes], + ], + { notify: false }, + ); + ensureVisibleSegment(this.layer, entry.originalSegmentId); + ensureVisibleSegment(this.layer, entry.tempSegmentId); + selectSegment(this.layer, entry.tempSegmentId, true); + this.layer.selectSpatialSkeletonNode( + entry.nodeId, + this.layer.manager.root.selectionState.pin.value, + { segmentId: entry.tempSegmentId }, + ); + const skeletonLayer = skeletonLayerFromLayer(this.layer); + skeletonLayer?.retainOverlaySegment(entry.originalSegmentId); + skeletonLayer?.retainOverlaySegment(entry.tempSegmentId); + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + } + + private rollbackSplitSkeletonPreview( + entry: CatmaidOptimisticSplitSkeletonEntry, + ) { + this.layer.spatialSkeletonState.replaceCachedSegmentSnapshots( + [ + [entry.originalSegmentId, entry.originalSegmentNodes], + [entry.tempSegmentId, undefined], + ], + { notify: false }, + ); + ensureVisibleSegment(this.layer, entry.originalSegmentId); + removeVisibleSegment(this.layer, entry.tempSegmentId, { deselect: true }); + this.layer.selectSpatialSkeletonNode( + entry.nodeId, + this.layer.manager.root.selectionState.pin.value, + { segmentId: entry.originalSegmentId }, + ); + skeletonLayerFromLayer(this.layer)?.retainOverlaySegment( + entry.originalSegmentId, + ); + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + return true; + } + + private applyMergeSkeletonsPreview( + entry: CatmaidOptimisticMergeSkeletonsEntry, + ) { + const rerootedSecondNodes = rerootSegmentNodeSnapshots( + entry.secondSegmentNodes, + entry.secondNodeId, + ).map((node) => ({ + ...node, + segmentId: entry.previewResultSegmentId, + parentNodeId: + node.nodeId === entry.secondNodeId + ? entry.firstNodeId + : node.parentNodeId, + })); + const mergedNodes = [ + ...entry.firstSegmentNodes.map((node) => ({ + ...cloneNodeSnapshot(node), + segmentId: entry.previewResultSegmentId, + })), + ...rerootedSecondNodes, + ]; + this.layer.spatialSkeletonState.replaceCachedSegmentSnapshots( + [ + [entry.previewResultSegmentId, mergedNodes], + [entry.secondSegmentId, undefined], + ], + { notify: false }, + ); + ensureVisibleSegment(this.layer, entry.previewResultSegmentId); + removeVisibleSegment(this.layer, entry.secondSegmentId, { deselect: true }); + selectSegment(this.layer, entry.previewResultSegmentId, false); + this.layer.selectSpatialSkeletonNode( + entry.secondNodeId, + this.layer.manager.root.selectionState.pin.value, + { segmentId: entry.previewResultSegmentId }, + ); + const skeletonLayer = skeletonLayerFromLayer(this.layer); + skeletonLayer?.retainOverlaySegment(entry.previewResultSegmentId); + skeletonLayer?.markSegmentEdited(entry.secondSegmentId); + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + } + + private rollbackMergeSkeletonsPreview( + entry: CatmaidOptimisticMergeSkeletonsEntry, + ) { + this.layer.spatialSkeletonState.replaceCachedSegmentSnapshots( + [ + [entry.firstSegmentId, entry.firstSegmentNodes], + [entry.secondSegmentId, entry.secondSegmentNodes], + ], + { notify: false }, + ); + ensureVisibleSegment(this.layer, entry.firstSegmentId); + ensureVisibleSegment(this.layer, entry.secondSegmentId); + this.layer.selectSpatialSkeletonNode( + entry.secondNodeId, + this.layer.manager.root.selectionState.pin.value, + { segmentId: entry.secondSegmentId }, + ); + const skeletonLayer = skeletonLayerFromLayer(this.layer); + skeletonLayer?.retainOverlaySegment(entry.firstSegmentId); + skeletonLayer?.retainOverlaySegment(entry.secondSegmentId); + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + return true; + } + private removeRestoredDeletePreview(entry: CatmaidOptimisticDeleteNodeEntry) { applyDeleteNodeToCache( this.layer, @@ -1889,6 +2267,187 @@ class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { } } + private getActiveDependentEntries(entry: CatmaidOptimisticEditEntry) { + const dependentIds = new Set(); + const visit = (operationId: number) => { + for (const candidate of this.entries) { + if ( + this.isSettled(candidate) || + dependentIds.has(candidate.operationId) || + !candidate.dependencies.includes(operationId) + ) { + continue; + } + dependentIds.add(candidate.operationId); + visit(candidate.operationId); + } + }; + visit(entry.operationId); + return this.entries.filter((candidate) => + dependentIds.has(candidate.operationId), + ); + } + + private suspendDependentPreviews(entry: CatmaidOptimisticEditEntry) { + const dependents = this.getActiveDependentEntries(entry); + for (const dependent of [...dependents].reverse()) { + if (dependent.status === CatmaidOptimisticEditStatus.Pending) { + this.rollbackPreview(dependent); + } + } + return dependents; + } + + private async reapplyDependentPreview(entry: CatmaidOptimisticEditEntry) { + switch (entry.kind) { + case "addNode": { + const parentNode = this.layer.spatialSkeletonState.getCachedNode( + entry.parentNodeId, + ); + if (parentNode === undefined) { + throw new Error( + `Unable to reapply optimistic add below node ${entry.parentNodeId}.`, + ); + } + this.layer.spatialSkeletonState.upsertCachedNode({ + nodeId: entry.tempNodeId, + segmentId: parentNode.segmentId, + position: new Float32Array(entry.positionInModelSpace), + parentNodeId: parentNode.nodeId, + isTrueEnd: false, + }); + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + return; + } + case "moveNode": { + const node = this.layer.spatialSkeletonState.getCachedNode( + entry.nodeId, + ); + if (node === undefined) { + throw new Error( + `Unable to reapply optimistic move for node ${entry.nodeId}.`, + ); + } + entry.segmentId = node.segmentId; + entry.nodeForServer = cloneNodeSnapshot(node); + entry.beforePositionInModelSpace = new Float32Array(node.position); + this.layer.spatialSkeletonState.moveCachedNode( + entry.nodeId, + entry.afterPositionInModelSpace, + ); + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + return; + } + case "deleteNode": { + const node = this.layer.spatialSkeletonState.getCachedNode( + entry.nodeId, + ); + if (node === undefined) { + throw new Error( + `Unable to reapply optimistic deletion for node ${entry.nodeId}.`, + ); + } + const segmentNodes = + this.layer.spatialSkeletonState.getCachedSegmentNodes(node.segmentId); + if (segmentNodes === undefined) { + throw new Error( + `Unable to reapply optimistic deletion in segment ${node.segmentId}.`, + ); + } + const parentNode = getSpatiallyIndexedSkeletonNodeParent( + segmentNodes, + node, + ); + const childNodes = getSpatiallyIndexedSkeletonDirectChildren( + segmentNodes, + node.nodeId, + ); + entry.segmentId = node.segmentId; + entry.deleteContext = { + node: cloneNodeSnapshot(node), + parentNode: + parentNode === undefined + ? undefined + : cloneNodeSnapshot(parentNode), + childNodes: childNodes.map(cloneNodeSnapshot), + }; + entry.segmentNodes = segmentNodes.map(cloneNodeSnapshot); + entry.affectedPositions = collectUniqueNodePositions( + [node], + [parentNode], + childNodes, + ); + applyDeleteNodeToCache( + this.layer, + { node, parentNode, childNodes }, + { moveView: false }, + [], + ); + return; + } + case "splitSkeleton": { + const resolvedNode = await entry.command.resolveSplitContext(); + if (resolvedNode.node.parentNodeId === undefined) { + throw new Error("Cannot reapply an optimistic split at a root node."); + } + entry.nodeId = resolvedNode.node.nodeId; + entry.originalSegmentId = resolvedNode.node.segmentId; + entry.formerParentNodeId = resolvedNode.node.parentNodeId; + entry.originalSegmentNodes = + resolvedNode.segmentNodes.map(cloneNodeSnapshot); + entry.affectedPositions = collectUniqueNodePositions( + getSplitAffectedNodes(resolvedNode), + ); + this.applySplitSkeletonPreview(entry); + return; + } + case "mergeSkeletons": { + const { firstNode, secondNode } = + await entry.command.resolveMergeContext(true); + entry.firstNodeId = firstNode.node.nodeId; + entry.secondNodeId = secondNode.node.nodeId; + entry.firstSegmentId = firstNode.node.segmentId; + entry.secondSegmentId = secondNode.node.segmentId; + entry.previewResultSegmentId = firstNode.node.segmentId; + entry.firstSegmentNodes = firstNode.segmentNodes.map(cloneNodeSnapshot); + entry.secondSegmentNodes = + secondNode.segmentNodes.map(cloneNodeSnapshot); + entry.affectedPositions = collectUniqueNodePositions( + entry.firstSegmentNodes, + entry.secondSegmentNodes, + ); + this.applyMergeSkeletonsPreview(entry); + return; + } + } + } + + private async restoreDependentPreviews( + dependents: readonly CatmaidOptimisticEditEntry[], + ) { + for (let index = 0; index < dependents.length; ++index) { + const dependent = dependents[index]; + if (dependent.status !== CatmaidOptimisticEditStatus.Pending) continue; + try { + await this.reapplyDependentPreview(dependent); + } catch (error) { + for (const affected of dependents.slice(index)) { + if (affected.status === CatmaidOptimisticEditStatus.Pending) { + affected.status = CatmaidOptimisticEditStatus.RolledBack; + } + } + StatusMessage.showErrorMessage( + `A queued skeleton edit could not be reapplied after CATMAID reconciled an earlier topology edit. Refresh the affected skeleton before continuing. ${formatErrorMessage(error)}`, + ); + break; + } + } + } + async undoLatest() { const entry = [...this.entries] .reverse() @@ -1934,12 +2493,22 @@ class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { } const tempNodeId = this.allocateTempId(); const parentEntry = this.findEntryForTempNode(parentNode.nodeId); + const dependencies = this.getActiveDependenciesForResources( + [parentNode.nodeId], + [segmentId], + ); + if ( + parentEntry !== undefined && + !dependencies.includes(parentEntry.operationId) + ) { + dependencies.push(parentEntry.operationId); + } const positionInModelSpace = command.getPositionInModelSpace(); const entry: CatmaidOptimisticAddNodeEntry = { operationId: this.nextOperationId++, kind: "addNode", command, - dependencies: parentEntry === undefined ? [] : [parentEntry.operationId], + dependencies, tempNodeId, positionInModelSpace, parentNodeId: parentNode.nodeId, @@ -1969,7 +2538,10 @@ class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { async enqueueMoveNode(command: MoveNodeCommand) { const { node, skeletonLayer } = await command.resolveMoveContext(); - const dependencies = this.getActiveDependenciesForNodes([node.nodeId]); + const dependencies = this.getActiveDependenciesForResources( + [node.nodeId], + [node.segmentId], + ); const beforePositionInModelSpace = command.getBeforePositionInModelSpace(); const afterPositionInModelSpace = command.getAfterPositionInModelSpace(); const entry: CatmaidOptimisticMoveNodeEntry = { @@ -2012,10 +2584,13 @@ class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { async enqueueDeleteNode(command: DeleteNodeCommand) { const { resolvedNode, deleteContext } = await command.resolveDeleteContext(); - const dependencies = this.getActiveDependenciesForNodes([ - deleteContext.node.nodeId, - ...deleteContext.childNodes.map((child) => child.nodeId), - ]); + const dependencies = this.getActiveDependenciesForResources( + [ + deleteContext.node.nodeId, + ...deleteContext.childNodes.map((child) => child.nodeId), + ], + [deleteContext.node.segmentId], + ); const entry: CatmaidOptimisticDeleteNodeEntry = { operationId: this.nextOperationId++, kind: "deleteNode", @@ -2058,6 +2633,122 @@ class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { void this.drain(); } + async enqueueSplitSkeleton(command: SplitCommand) { + const resolvedNode = await command.resolveSplitContext(); + const formerParentNodeId = resolvedNode.node.parentNodeId; + if (formerParentNodeId === undefined) { + throw new Error("Cannot split at the root node."); + } + const tempSegmentId = this.allocateTempSegmentId(); + const originalSegmentNodes = + resolvedNode.segmentNodes.map(cloneNodeSnapshot); + const entry: CatmaidOptimisticSplitSkeletonEntry = { + operationId: this.nextOperationId++, + kind: "splitSkeleton", + command, + dependencies: this.getActiveDependenciesForResources( + originalSegmentNodes.map((node) => node.nodeId), + [resolvedNode.node.segmentId], + ), + nodeId: resolvedNode.node.nodeId, + originalSegmentId: resolvedNode.node.segmentId, + tempSegmentId, + formerParentNodeId, + originalSegmentNodes, + affectedPositions: collectUniqueNodePositions( + getSplitAffectedNodes(resolvedNode), + ), + status: CatmaidOptimisticEditStatus.Pending, + }; + this.entries.push(entry); + this.applySplitSkeletonPreview(entry); + this.notifyChanged(); + void this.drain(); + } + + async enqueueMergeSkeletons(command: MergeCommand) { + const { firstNode, secondNode } = await command.resolveMergeContext(true); + if (firstNode.node.segmentId === secondNode.node.segmentId) { + throw new Error("Cannot merge nodes from the same skeleton."); + } + const firstSegmentNodes = firstNode.segmentNodes.map(cloneNodeSnapshot); + const secondSegmentNodes = secondNode.segmentNodes.map(cloneNodeSnapshot); + const entry: CatmaidOptimisticMergeSkeletonsEntry = { + operationId: this.nextOperationId++, + kind: "mergeSkeletons", + command, + dependencies: this.getActiveDependenciesForResources( + [ + ...firstSegmentNodes.map((node) => node.nodeId), + ...secondSegmentNodes.map((node) => node.nodeId), + ], + [firstNode.node.segmentId, secondNode.node.segmentId], + ), + firstNodeId: firstNode.node.nodeId, + secondNodeId: secondNode.node.nodeId, + firstSegmentId: firstNode.node.segmentId, + secondSegmentId: secondNode.node.segmentId, + previewResultSegmentId: firstNode.node.segmentId, + firstSegmentNodes, + secondSegmentNodes, + affectedPositions: collectUniqueNodePositions( + firstSegmentNodes, + secondSegmentNodes, + ), + status: CatmaidOptimisticEditStatus.Pending, + }; + this.entries.push(entry); + this.applyMergeSkeletonsPreview(entry); + this.notifyChanged(); + void this.drain(); + } + + private scheduleTopologyRefresh( + segmentIds: Iterable, + positions: Iterable>, + ) { + for (const segmentId of segmentIds) { + const normalized = normalizePositiveSegmentId(segmentId); + if (normalized !== undefined) { + this.topologyRefreshSegmentIds.add(normalized); + } + } + this.topologyRefreshPositions.push(...positions); + } + + private async refreshReconciledTopology() { + if ( + this.disposed || + this.topologyRefreshSegmentIds.size === 0 || + this.entries.some( + (entry) => + entry.status === CatmaidOptimisticEditStatus.Pending || + entry.status === CatmaidOptimisticEditStatus.InFlight || + entry.status === CatmaidOptimisticEditStatus.CancelRequested, + ) + ) { + return; + } + const segmentIds = [...this.topologyRefreshSegmentIds]; + const positions = this.topologyRefreshPositions; + this.topologyRefreshSegmentIds.clear(); + this.topologyRefreshPositions = []; + this.reconcilingTopology = true; + this.notifyChanged(); + try { + await refreshTopologySegments(this.layer, segmentIds, positions, { + // Optimistic topology previews keep authoritative full-skeleton overlays + // for the edited segments. Their stale browse-pass copies are excluded, + // so deleting shared spatial cells here would only hide unrelated + // skeletons while CATMAID reloads those cells. + invalidateSourceCells: false, + }); + } finally { + this.reconcilingTopology = false; + this.notifyChanged(); + } + } + private async drain() { if (this.draining) { return; @@ -2078,6 +2769,7 @@ class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { await this.confirmEntry(entry); this.pruneSettledEntries(); } + await this.refreshReconciledTopology(); } finally { this.draining = false; this.notifyChanged(); @@ -2099,6 +2791,12 @@ class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { case "deleteNode": await this.confirmDeleteNodeEntry(entry); return; + case "splitSkeleton": + await this.confirmSplitSkeletonEntry(entry); + return; + case "mergeSkeletons": + await this.confirmMergeSkeletonsEntry(entry); + return; } } finally { this.clearInFlightWarning(entry); @@ -2227,6 +2925,150 @@ class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { } } + private async confirmSplitSkeletonEntry( + entry: CatmaidOptimisticSplitSkeletonEntry, + ) { + let result: CatmaidSpatialSkeletonSplitResult; + try { + const resolvedNode = await entry.command.resolveSplitContext(); + result = await this.editOperations.commitSplit({ + node: resolvedNode.node, + segmentNodes: resolvedNode.segmentNodes, + nocheck: true, + }); + entry.result = result; + } catch (error) { + if (entry.status === CatmaidOptimisticEditStatus.CancelRequested) { + entry.status = CatmaidOptimisticEditStatus.RolledBack; + return; + } + entry.status = CatmaidOptimisticEditStatus.Failed; + this.rollbackEntryAndDependents( + entry, + CatmaidOptimisticEditStatus.Failed, + ); + StatusMessage.showErrorMessage( + `CATMAID rejected skeleton splitting. The optimistic preview was removed. ${formatErrorMessage(error)}`, + ); + return; + } + + if (entry.status === CatmaidOptimisticEditStatus.CancelRequested) { + await this.compensateCanceledSplitCommit(entry, result); + entry.status = CatmaidOptimisticEditStatus.RolledBack; + return; + } + + entry.command.markOptimisticCommit( + entry.tempSegmentId, + result, + entry.originalSegmentId, + ); + try { + await this.reconcileCommittedSplitEntry(entry, result); + } catch (error) { + this.rollbackUnconfirmedDependents( + entry, + CatmaidOptimisticEditStatus.RolledBack, + ); + await refreshTopologySegments( + this.layer, + [ + result.existingSegmentId ?? entry.originalSegmentId, + result.newSegmentId ?? entry.tempSegmentId, + ], + entry.affectedPositions, + ); + StatusMessage.showErrorMessage( + `CATMAID split the skeleton, but optimistic reconciliation failed. The affected skeletons were refreshed. ${formatErrorMessage(error)}`, + ); + } + entry.status = CatmaidOptimisticEditStatus.Committed; + await this.layer.spatialSkeletonState.commandHistory.recordExecuted( + entry.command, + ); + } + + private async confirmMergeSkeletonsEntry( + entry: CatmaidOptimisticMergeSkeletonsEntry, + ) { + let result: CatmaidSpatialSkeletonMergeResult; + try { + const { firstNode, secondNode } = + await entry.command.resolveMergeContext(true); + result = await this.editOperations.commitMerge({ + fromNode: firstNode.node, + toNode: secondNode.node, + nocheck: true, + }); + entry.result = result; + } catch (error) { + if (entry.status === CatmaidOptimisticEditStatus.CancelRequested) { + entry.status = CatmaidOptimisticEditStatus.RolledBack; + return; + } + entry.status = CatmaidOptimisticEditStatus.Failed; + this.rollbackEntryAndDependents( + entry, + CatmaidOptimisticEditStatus.Failed, + ); + StatusMessage.showErrorMessage( + `CATMAID rejected skeleton merging. The optimistic preview was removed. ${formatErrorMessage(error)}`, + ); + return; + } + + if (entry.status === CatmaidOptimisticEditStatus.CancelRequested) { + await this.compensateCanceledMergeCommit(entry, result); + entry.status = CatmaidOptimisticEditStatus.RolledBack; + return; + } + + const firstContext: ResolvedSpatialSkeletonEditNode = { + skeletonLayer: getEditableSkeletonSourceForLayer(this.layer) + .skeletonLayer, + segmentNodes: entry.firstSegmentNodes, + node: + findSpatiallyIndexedSkeletonNode( + entry.firstSegmentNodes, + entry.firstNodeId, + ) ?? entry.firstSegmentNodes[0], + }; + const secondContext: ResolvedSpatialSkeletonEditNode = { + skeletonLayer: firstContext.skeletonLayer, + segmentNodes: entry.secondSegmentNodes, + node: + findSpatiallyIndexedSkeletonNode( + entry.secondSegmentNodes, + entry.secondNodeId, + ) ?? entry.secondSegmentNodes[0], + }; + entry.command.markOptimisticCommit(firstContext, secondContext, result); + try { + await this.reconcileCommittedMergeEntry(entry, result); + } catch (error) { + this.rollbackUnconfirmedDependents( + entry, + CatmaidOptimisticEditStatus.RolledBack, + ); + await refreshTopologySegments( + this.layer, + [ + result.resultSegmentId ?? entry.firstSegmentId, + result.deletedSegmentId ?? entry.secondSegmentId, + ], + entry.affectedPositions, + ); + StatusMessage.showErrorMessage( + `CATMAID merged the skeletons, but optimistic reconciliation failed. The affected skeletons were refreshed. ${formatErrorMessage(error)}`, + ); + } + entry.status = CatmaidOptimisticEditStatus.Committed; + await this.layer.spatialSkeletonState.commandHistory.recordExecuted( + entry.command, + ); + } + private async reconcileCommittedAddEntry( entry: CatmaidOptimisticAddNodeEntry, skeletonLayer: SpatiallyIndexedSkeletonLayer, @@ -2286,11 +3128,11 @@ class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { } const committedNodeSnapshot: SpatiallyIndexedSkeletonNode = { nodeId: result.nodeId, - segmentId: result.segmentId, + segmentId: previewNode?.segmentId ?? result.segmentId, position: new Float32Array( previewNode?.position ?? entry.positionInModelSpace, ), - parentNodeId: parentNode.nodeId, + parentNodeId: previewNode?.parentNodeId ?? parentNode.nodeId, isTrueEnd: false, ...(result.sourceState === undefined ? {} @@ -2302,28 +3144,34 @@ class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { committedNodeSnapshot, ); if (previewNode !== undefined) { - this.layer.spatialSkeletonState.removeCachedNode(entry.tempNodeId, { - parentNodeId: parentNode.nodeId, - childNodeIds: [], - }); - applyCreatedNodeToCache( - this.layer, - skeletonLayer, - { - ...result, - sourceState: committedNodeSnapshot.sourceState, - }, - parentNode.nodeId, - committedNodeSnapshot.position, - { - focusSelection: wasSelected, - markChanged: false, - moveView: false, - pinSegment: this.layer.manager.root.selectionState.pin.value, - retainOverlaySegment: true, - selectSegment: wasSelected, - }, + const previewSegmentNodes = + this.layer.spatialSkeletonState.getCachedSegmentNodes( + previewNode.segmentId, + ) ?? []; + const remappedSegmentNodes = previewSegmentNodes.map((node) => ({ + ...(node.nodeId === entry.tempNodeId + ? committedNodeSnapshot + : cloneNodeSnapshot(node)), + parentNodeId: + node.parentNodeId === entry.tempNodeId + ? result.nodeId + : node.parentNodeId, + })); + this.layer.spatialSkeletonState.replaceCachedSegmentSnapshots( + [[previewNode.segmentId, remappedSegmentNodes]], + { notify: false }, ); + if (wasSelected) { + this.layer.selectSpatialSkeletonNode( + result.nodeId, + this.layer.manager.root.selectionState.pin.value, + { + segmentId: previewNode.segmentId, + position: committedNodeSnapshot.position, + }, + ); + } + skeletonLayer.retainOverlaySegment(previewNode.segmentId); } this.layer.markSpatialSkeletonNodeDataChanged({ invalidateFullSkeletonCache: false, @@ -2339,8 +3187,28 @@ class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { parentNode: SpatiallyIndexedSkeletonNode, committedNode: SpatiallyIndexedSkeletonNode, ) { + const remapNode = (node: SpatiallyIndexedSkeletonNode) => { + if (node.nodeId === entry.tempNodeId) { + return { + ...node, + nodeId: committedNode.nodeId, + segmentId: committedNode.segmentId, + parentNodeId: committedNode.parentNodeId, + sourceState: committedNode.sourceState, + }; + } + if (node.parentNodeId === entry.tempNodeId) { + return { ...node, parentNodeId: committedNode.nodeId }; + } + return node; + }; for (const dependentEntry of this.entries) { - if (this.isSettled(dependentEntry)) continue; + if ( + this.isSettled(dependentEntry) || + dependentEntry.operationId === entry.operationId + ) { + continue; + } switch (dependentEntry.kind) { case "addNode": if (dependentEntry.parentTempNodeId === entry.tempNodeId) { @@ -2371,6 +3239,28 @@ class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { committedNode, ); break; + case "splitSkeleton": + if (dependentEntry.nodeId === entry.tempNodeId) { + dependentEntry.nodeId = committedNode.nodeId; + } + if (dependentEntry.formerParentNodeId === entry.tempNodeId) { + dependentEntry.formerParentNodeId = committedNode.nodeId; + } + dependentEntry.originalSegmentNodes = + dependentEntry.originalSegmentNodes.map(remapNode); + break; + case "mergeSkeletons": + if (dependentEntry.firstNodeId === entry.tempNodeId) { + dependentEntry.firstNodeId = committedNode.nodeId; + } + if (dependentEntry.secondNodeId === entry.tempNodeId) { + dependentEntry.secondNodeId = committedNode.nodeId; + } + dependentEntry.firstSegmentNodes = + dependentEntry.firstSegmentNodes.map(remapNode); + dependentEntry.secondSegmentNodes = + dependentEntry.secondSegmentNodes.map(remapNode); + break; } } } @@ -2460,6 +3350,367 @@ class CatmaidOptimisticEditQueue implements SpatialSkeletonOptimisticEditQueue { ); } + private remapPendingSegmentReferences( + remappings: ReadonlyMap, + excludedOperationId?: number, + ) { + const remapSegmentId = (segmentId: number) => + remappings.get(segmentId) ?? segmentId; + const remapNode = (node: SpatiallyIndexedSkeletonNode) => ({ + ...node, + segmentId: remapSegmentId(node.segmentId), + }); + for (const candidate of this.entries) { + if ( + this.isSettled(candidate) || + candidate.operationId === excludedOperationId + ) { + continue; + } + switch (candidate.kind) { + case "addNode": + if (candidate.parentNodeForServer !== undefined) { + candidate.parentNodeForServer = remapNode( + candidate.parentNodeForServer, + ); + } + break; + case "moveNode": + candidate.segmentId = remapSegmentId(candidate.segmentId); + candidate.nodeForServer = remapNode(candidate.nodeForServer); + break; + case "deleteNode": + candidate.segmentId = remapSegmentId(candidate.segmentId); + candidate.deleteContext = { + node: remapNode(candidate.deleteContext.node), + parentNode: + candidate.deleteContext.parentNode === undefined + ? undefined + : remapNode(candidate.deleteContext.parentNode), + childNodes: candidate.deleteContext.childNodes.map(remapNode), + }; + candidate.segmentNodes = candidate.segmentNodes.map(remapNode); + break; + case "splitSkeleton": + candidate.originalSegmentId = remapSegmentId( + candidate.originalSegmentId, + ); + candidate.tempSegmentId = remapSegmentId(candidate.tempSegmentId); + candidate.originalSegmentNodes = + candidate.originalSegmentNodes.map(remapNode); + break; + case "mergeSkeletons": + candidate.firstSegmentId = remapSegmentId(candidate.firstSegmentId); + candidate.secondSegmentId = remapSegmentId(candidate.secondSegmentId); + candidate.previewResultSegmentId = remapSegmentId( + candidate.previewResultSegmentId, + ); + candidate.firstSegmentNodes = + candidate.firstSegmentNodes.map(remapNode); + candidate.secondSegmentNodes = + candidate.secondSegmentNodes.map(remapNode); + break; + } + } + } + + private async reconcileCommittedSplitEntry( + entry: CatmaidOptimisticSplitSkeletonEntry, + result: CatmaidSpatialSkeletonSplitResult, + ) { + const newSegmentId = result.newSegmentId; + if (newSegmentId === undefined) { + throw new Error("CATMAID split response omitted the new skeleton id."); + } + const existingSegmentId = + result.existingSegmentId ?? entry.originalSegmentId; + const dependents = this.suspendDependentPreviews(entry); + const upstreamNodes = + this.layer.spatialSkeletonState.getCachedSegmentNodes( + entry.originalSegmentId, + ) ?? []; + const downstreamNodes = + this.layer.spatialSkeletonState.getCachedSegmentNodes( + entry.tempSegmentId, + ) ?? []; + const replacements = new Map< + number, + readonly SpatiallyIndexedSkeletonNode[] | undefined + >(); + replacements.set(entry.originalSegmentId, undefined); + replacements.set(entry.tempSegmentId, undefined); + replacements.set(existingSegmentId, upstreamNodes); + replacements.set(newSegmentId, downstreamNodes); + this.layer.spatialSkeletonState.replaceCachedSegmentSnapshots( + replacements, + { notify: false }, + ); + this.remapPendingSegmentReferences( + new Map([ + [entry.originalSegmentId, existingSegmentId], + [entry.tempSegmentId, newSegmentId], + ]), + entry.operationId, + ); + removeVisibleSegment(this.layer, entry.tempSegmentId, { deselect: true }); + ensureVisibleSegment(this.layer, existingSegmentId); + ensureVisibleSegment(this.layer, newSegmentId); + selectSegment(this.layer, newSegmentId, true); + this.layer.selectSpatialSkeletonNode( + entry.nodeId, + this.layer.manager.root.selectionState.pin.value, + { segmentId: newSegmentId }, + ); + const skeletonLayer = skeletonLayerFromLayer(this.layer); + skeletonLayer?.retainOverlaySegment(existingSegmentId); + skeletonLayer?.retainOverlaySegment(newSegmentId); + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + this.scheduleTopologyRefresh( + [existingSegmentId, newSegmentId], + entry.affectedPositions, + ); + await this.restoreDependentPreviews(dependents); + } + + private async reconcileCommittedMergeEntry( + entry: CatmaidOptimisticMergeSkeletonsEntry, + result: CatmaidSpatialSkeletonMergeResult, + ) { + const secondWins = result.resultSegmentId === entry.secondSegmentId; + const winnerNodes = secondWins + ? entry.secondSegmentNodes + : entry.firstSegmentNodes; + const loserNodes = secondWins + ? entry.firstSegmentNodes + : entry.secondSegmentNodes; + const winnerNodeId = secondWins ? entry.secondNodeId : entry.firstNodeId; + const loserNodeId = secondWins ? entry.firstNodeId : entry.secondNodeId; + const fallbackResultSegmentId = secondWins + ? entry.secondSegmentId + : entry.firstSegmentId; + const fallbackDeletedSegmentId = secondWins + ? entry.firstSegmentId + : entry.secondSegmentId; + const resultSegmentId = result.resultSegmentId ?? fallbackResultSegmentId; + const deletedSegmentId = + result.deletedSegmentId ?? fallbackDeletedSegmentId; + const dependents = this.suspendDependentPreviews(entry); + const rerootedLoserNodes = rerootSegmentNodeSnapshots( + loserNodes, + loserNodeId, + ).map((node) => ({ + ...node, + segmentId: resultSegmentId, + parentNodeId: + node.nodeId === loserNodeId ? winnerNodeId : node.parentNodeId, + })); + const mergedNodes = [ + ...winnerNodes.map((node) => ({ + ...cloneNodeSnapshot(node), + segmentId: resultSegmentId, + })), + ...rerootedLoserNodes, + ]; + const replacements = new Map< + number, + readonly SpatiallyIndexedSkeletonNode[] | undefined + >(); + replacements.set(entry.firstSegmentId, undefined); + replacements.set(entry.secondSegmentId, undefined); + replacements.set(resultSegmentId, mergedNodes); + this.layer.spatialSkeletonState.replaceCachedSegmentSnapshots( + replacements, + { notify: false }, + ); + const segmentRemappings = new Map([ + [entry.firstSegmentId, resultSegmentId], + [entry.secondSegmentId, resultSegmentId], + ]); + this.remapPendingSegmentReferences(segmentRemappings, entry.operationId); + const mappings = this.layer.spatialSkeletonState.commandHistory.mappings; + mappings.remapSegmentId(entry.firstSegmentId, resultSegmentId); + mappings.remapSegmentId(entry.secondSegmentId, resultSegmentId); + ensureVisibleSegment(this.layer, resultSegmentId); + removeVisibleSegment(this.layer, deletedSegmentId, { deselect: true }); + selectSegment(this.layer, resultSegmentId, false); + this.layer.selectSpatialSkeletonNode( + loserNodeId, + this.layer.manager.root.selectionState.pin.value, + { segmentId: resultSegmentId }, + ); + const skeletonLayer = skeletonLayerFromLayer(this.layer); + skeletonLayer?.markSegmentEdited(deletedSegmentId); + skeletonLayer?.retainOverlaySegment(resultSegmentId); + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + this.scheduleTopologyRefresh( + [resultSegmentId, deletedSegmentId], + entry.affectedPositions, + ); + await this.restoreDependentPreviews(dependents); + } + + private async compensateCanceledSplitCommit( + entry: CatmaidOptimisticSplitSkeletonEntry, + result: CatmaidSpatialSkeletonSplitResult, + ) { + const existingSegmentId = + result.existingSegmentId ?? entry.originalSegmentId; + const newSegmentId = result.newSegmentId; + const formerParent = findSpatiallyIndexedSkeletonNode( + entry.originalSegmentNodes, + entry.formerParentNodeId, + ); + const splitNode = findSpatiallyIndexedSkeletonNode( + entry.originalSegmentNodes, + entry.nodeId, + ); + try { + if ( + newSegmentId === undefined || + formerParent === undefined || + splitNode === undefined + ) { + throw new Error( + "Canceled split compensation is missing topology data.", + ); + } + const mergeResult = await this.editOperations.commitMerge({ + fromNode: { ...formerParent, segmentId: existingSegmentId }, + toNode: { + ...splitNode, + segmentId: newSegmentId, + parentNodeId: undefined, + }, + nocheck: true, + }); + if (!this.disposed) { + this.scheduleTopologyRefresh( + [ + mergeResult.resultSegmentId ?? existingSegmentId, + mergeResult.deletedSegmentId ?? newSegmentId, + ], + entry.affectedPositions, + ); + } + } catch (error) { + if (!this.disposed) { + await refreshTopologySegments( + this.layer, + [existingSegmentId, newSegmentId ?? entry.tempSegmentId], + entry.affectedPositions, + ); + } + StatusMessage.showErrorMessage( + `CATMAID split a skeleton after its optimistic preview was canceled, and automatic merge-back failed. Refresh the skeleton to sync. ${formatErrorMessage(error)}`, + ); + } + } + + private async compensateCanceledMergeCommit( + entry: CatmaidOptimisticMergeSkeletonsEntry, + result: CatmaidSpatialSkeletonMergeResult, + ) { + const secondWins = result.resultSegmentId === entry.secondSegmentId; + const winnerSegmentId = + result.resultSegmentId ?? + (secondWins ? entry.secondSegmentId : entry.firstSegmentId); + const loserSegmentId = secondWins + ? entry.firstSegmentId + : entry.secondSegmentId; + const loserNodeId = secondWins ? entry.firstNodeId : entry.secondNodeId; + const loserNodes = secondWins + ? entry.firstSegmentNodes + : entry.secondSegmentNodes; + const loserNode = findSpatiallyIndexedSkeletonNode(loserNodes, loserNodeId); + try { + if (loserNode === undefined) { + throw new Error( + "Canceled merge compensation is missing its split node.", + ); + } + const mergedServerNodes = rerootSegmentNodeSnapshots( + loserNodes, + loserNodeId, + ).map((node) => ({ ...node, segmentId: winnerSegmentId })); + const splitResult = await this.editOperations.commitSplit({ + node: { + ...loserNode, + segmentId: winnerSegmentId, + }, + segmentNodes: mergedServerNodes, + nocheck: true, + }); + const restoredSegmentId = splitResult.newSegmentId; + if (restoredSegmentId === undefined) { + throw new Error( + "Canceled merge compensation did not return a restored skeleton id.", + ); + } + const originalRoot = findRootNode(loserNodes); + if (originalRoot !== undefined && originalRoot.nodeId !== loserNodeId) { + const splitSideNodes = rerootSegmentNodeSnapshots( + loserNodes, + loserNodeId, + ).map((node) => ({ ...node, segmentId: restoredSegmentId })); + const rootForReroot = findSpatiallyIndexedSkeletonNode( + splitSideNodes, + originalRoot.nodeId, + ); + if (rootForReroot === undefined) { + throw new Error( + "Canceled merge compensation is missing its original root.", + ); + } + await this.editOperations.commitReroot({ + node: rootForReroot, + segmentNodes: splitSideNodes, + nocheck: true, + }); + } + if (!this.disposed) { + const restoredNodes = loserNodes.map((node) => ({ + ...cloneNodeSnapshot(node), + segmentId: restoredSegmentId, + })); + this.layer.spatialSkeletonState.replaceCachedSegmentSnapshots( + [ + [loserSegmentId, undefined], + [restoredSegmentId, restoredNodes], + ], + { notify: false }, + ); + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + loserSegmentId, + restoredSegmentId, + ); + removeVisibleSegment(this.layer, loserSegmentId, { deselect: true }); + ensureVisibleSegment(this.layer, restoredSegmentId); + this.layer.markSpatialSkeletonNodeDataChanged({ + invalidateFullSkeletonCache: false, + }); + this.scheduleTopologyRefresh( + [splitResult.existingSegmentId ?? winnerSegmentId, restoredSegmentId], + entry.affectedPositions, + ); + } + } catch (error) { + if (!this.disposed) { + await refreshTopologySegments( + this.layer, + [winnerSegmentId, loserSegmentId], + entry.affectedPositions, + ); + } + StatusMessage.showErrorMessage( + `CATMAID merged skeletons after their optimistic preview was canceled, and automatic split-back failed. Refresh the skeleton to sync. ${formatErrorMessage(error)}`, + ); + } + } + private async compensateCanceledAddCommit( entry: CatmaidOptimisticAddNodeEntry, result: CatmaidSpatialSkeletonAddNodeResult, @@ -3384,6 +4635,9 @@ class RerootCommand implements SpatialSkeletonCommand { class SplitCommand implements SpatialSkeletonCommand { readonly label = "Split skeleton"; + readonly executeOptimistically?: ( + context: SpatialSkeletonCommandContext, + ) => Promise; private stableNewSegmentId: number | undefined; constructor( @@ -3392,14 +4646,52 @@ class SplitCommand implements SpatialSkeletonCommand { private stableSegmentId: number | undefined, private stableFormerParentNodeId: number | undefined, private editOperations: CatmaidSpatialSkeletonEditOperations, - ) {} + optimistic = false, + ) { + if (optimistic) { + this.executeOptimistically = async () => { + await getOrCreateCatmaidOptimisticEditQueue( + this.layer, + this.editOperations, + ).enqueueSplitSkeleton(this); + }; + } + } - private async split(statusPrefix: string) { - const resolvedNode = await getResolvedNodeForEdit( + resolveSplitContext() { + return getResolvedNodeForEdit( this.layer, this.stableNodeId, this.stableSegmentId, ); + } + + markOptimisticCommit( + tempSegmentId: number, + result: CatmaidSpatialSkeletonSplitResult, + fallbackExistingSegmentId: number, + ) { + const newSegmentId = result.newSegmentId; + if (newSegmentId === undefined) { + throw new Error( + "The active skeleton source did not return a new skeleton id for the split.", + ); + } + this.stableNewSegmentId = tempSegmentId; + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + tempSegmentId, + newSegmentId, + ); + if (this.stableSegmentId !== undefined) { + this.layer.spatialSkeletonState.commandHistory.mappings.remapSegmentId( + this.stableSegmentId, + result.existingSegmentId ?? fallbackExistingSegmentId, + ); + } + } + + private async split(statusPrefix: string) { + const resolvedNode = await this.resolveSplitContext(); if (resolvedNode.node.parentNodeId === undefined) { StatusMessage.showTemporaryMessage("Cannot split at the root node."); return; @@ -3557,6 +4849,9 @@ class SplitCommand implements SpatialSkeletonCommand { class MergeCommand implements SpatialSkeletonCommand { readonly label = "Merge skeletons"; + readonly executeOptimistically?: ( + context: SpatialSkeletonCommandContext, + ) => Promise; private stableResultSegmentId: number | undefined; private stableDeletedSegmentId: number | undefined; private stableAttachedNodeId: number | undefined; @@ -3571,9 +4866,26 @@ class MergeCommand implements SpatialSkeletonCommand { private secondNodeSourceState: SpatialSkeletonSourceState | undefined, private secondNodePositionInModelSpace: Float32Array | undefined, private editOperations: CatmaidSpatialSkeletonEditOperations, - ) {} + optimistic = false, + ) { + if (optimistic) { + this.executeOptimistically = async () => { + await getOrCreateCatmaidOptimisticEditQueue( + this.layer, + this.editOperations, + ).enqueueMergeSkeletons(this); + }; + } + } - private async resolveSecondNodeForMerge() { + private async resolveSecondNodeForMerge(requireFullSegment = false) { + if (requireFullSegment) { + return getResolvedNodeForEdit( + this.layer, + this.stableSecondNodeId, + this.stableSecondSegmentId, + ); + } const secondNodeContext = getResolvedNodeContextForEdit( this.layer, this.stableSecondNodeId, @@ -3647,13 +4959,55 @@ class MergeCommand implements SpatialSkeletonCommand { }; } - private async merge(statusPrefix: string) { + async resolveMergeContext(requireFullSecondSegment = false) { const firstNode = await getResolvedNodeForEdit( this.layer, this.stableFirstNodeId, this.stableFirstSegmentId, ); - const secondNode = await this.resolveSecondNodeForMerge(); + const secondNode = await this.resolveSecondNodeForMerge( + requireFullSecondSegment, + ); + return { firstNode, secondNode }; + } + + markOptimisticCommit( + firstNode: ResolvedSpatialSkeletonEditNode, + secondNode: ResolvedSpatialSkeletonEditNode, + result: CatmaidSpatialSkeletonMergeResult, + ) { + 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 + : findRootNode(secondNode.segmentNodes)?.nodeId; + const mappings = this.layer.spatialSkeletonState.commandHistory.mappings; + this.stableAttachedNodeId = + this.stableAttachedNodeId ?? + mappings.getStableOrCurrentNodeId(losingNode.nodeId); + this.stableAttachedRootNodeId = + this.stableAttachedRootNodeId ?? + mappings.getStableOrCurrentNodeId(attachedRootNodeId); + this.stableResultSegmentId = + this.stableResultSegmentId ?? + mappings.getStableOrCurrentSegmentId(resultSkeletonId); + this.stableDeletedSegmentId = + this.stableDeletedSegmentId ?? + mappings.getStableOrCurrentSegmentId(deletedSkeletonId); + mappings.remapSegmentId(this.stableDeletedSegmentId, resultSkeletonId); + } + + private async merge(statusPrefix: string) { + const { firstNode, secondNode } = await this.resolveMergeContext(); let result: CatmaidSpatialSkeletonMergeResult; try { result = await this.editOperations.commitMerge({ @@ -4083,10 +5437,15 @@ export class CatmaidSpatialSkeletonEditCommands { request.segmentNodes, request.node, ).map((node) => node.nodeId); - const result = await this.client.rerootSkeleton( - request.node.nodeId, - buildCatmaidRerootEditContext(request.node, request.segmentNodes), - ); + const result = + request.nocheck === true + ? await this.client.rerootSkeleton(request.node.nodeId, undefined, { + nocheck: true, + }) + : await this.client.rerootSkeleton( + request.node.nodeId, + buildCatmaidRerootEditContext(request.node, request.segmentNodes), + ); let nodeSourceStateUpdates: readonly CatmaidSpatialSkeletonNodeSourceStateUpdate[]; try { nodeSourceStateUpdates = await getFreshRerootSourceStateUpdates( @@ -4144,20 +5503,34 @@ export class CatmaidSpatialSkeletonEditCommands { private commitMerge( request: CatmaidSpatialSkeletonMergeRequest, ): Promise { - return this.client.mergeSkeletons( - request.fromNode.nodeId, - request.toNode.nodeId, - buildCatmaidMultiNodeEditContext(request.fromNode, request.toNode), - ); + return request.nocheck === true + ? this.client.mergeSkeletons( + request.fromNode.nodeId, + request.toNode.nodeId, + undefined, + { nocheck: true }, + ) + : this.client.mergeSkeletons( + request.fromNode.nodeId, + request.toNode.nodeId, + buildCatmaidMultiNodeEditContext(request.fromNode, request.toNode), + ); } private commitSplit( request: CatmaidSpatialSkeletonSplitRequest, ): Promise { - return this.client.splitSkeleton( - request.node.nodeId, - buildCatmaidNeighborhoodEditContext(request.node, request.segmentNodes), - ); + return request.nocheck === true + ? this.client.splitSkeleton(request.node.nodeId, undefined, { + nocheck: true, + }) + : this.client.splitSkeleton( + request.node.nodeId, + buildCatmaidNeighborhoodEditContext( + request.node, + request.segmentNodes, + ), + ); } private createAddNodeCommand( @@ -4364,6 +5737,7 @@ export class CatmaidSpatialSkeletonEditCommands { commandMappings.getStableOrCurrentSegmentId(splitNode.segmentId), commandMappings.getStableOrCurrentNodeId(splitNode.parentNodeId), this.editOperations, + this.editContext.getOptimisticSkeletonEdits?.(layer) === true, ); } @@ -4387,6 +5761,7 @@ export class CatmaidSpatialSkeletonEditCommands { "merge second-node position", ), this.editOperations, + this.editContext.getOptimisticSkeletonEdits?.(layer) === true, ); } } diff --git a/src/datasource/catmaid/spatial_skeleton_edit_api.ts b/src/datasource/catmaid/spatial_skeleton_edit_api.ts index 4c1f8dad4a..d30d75c89d 100644 --- a/src/datasource/catmaid/spatial_skeleton_edit_api.ts +++ b/src/datasource/catmaid/spatial_skeleton_edit_api.ts @@ -77,6 +77,7 @@ export type CatmaidSpatialSkeletonDeleteNodeResult = CatmaidDeleteNodeResult; export interface CatmaidSpatialSkeletonSplitRequest { node: SpatiallyIndexedSkeletonNode; segmentNodes: readonly SpatiallyIndexedSkeletonNode[]; + nocheck?: boolean; } export type CatmaidSpatialSkeletonSplitResult = CatmaidSplitResult; @@ -84,6 +85,7 @@ export type CatmaidSpatialSkeletonSplitResult = CatmaidSplitResult; export interface CatmaidSpatialSkeletonMergeRequest { fromNode: SpatiallyIndexedSkeletonNode; toNode: SpatiallyIndexedSkeletonNode; + nocheck?: boolean; } export type CatmaidSpatialSkeletonMergeResult = CatmaidMergeResult; @@ -91,6 +93,7 @@ export type CatmaidSpatialSkeletonMergeResult = CatmaidMergeResult; export interface CatmaidSpatialSkeletonRerootRequest { node: SpatiallyIndexedSkeletonNode; segmentNodes: readonly SpatiallyIndexedSkeletonNode[]; + nocheck?: boolean; } export type CatmaidSpatialSkeletonRerootResult = CatmaidRerootResult; diff --git a/src/layer/segmentation/index.spec.ts b/src/layer/segmentation/index.spec.ts index 9372306083..8fcdf53211 100644 --- a/src/layer/segmentation/index.spec.ts +++ b/src/layer/segmentation/index.spec.ts @@ -114,6 +114,7 @@ function makeSpatialSkeletonActionGateLayer(options: { visibleChunksNeeded?: number; visibleChunksAvailable?: number; commandBusy?: boolean; + canQueueOptimisticAction?: (action: string) => boolean; }) { return Object.assign(Object.create(SegmentationUserLayer.prototype), { getSpatiallyIndexedSkeletonLayer: () => @@ -123,7 +124,11 @@ function makeSpatialSkeletonActionGateLayer(options: { isBusy: new WatchableValue(options.commandBusy ?? false), }, hasUnconfirmedOptimisticEdits: vi.fn(() => false), + ...(options.canQueueOptimisticAction === undefined + ? {} + : { canQueueOptimisticAction: options.canQueueOptimisticAction }), }, + optimisticSkeletonEdits: new WatchableValue(true), spatialSkeletonVisibleChunksLoaded: new WatchableValue( options.visibleChunksLoaded ?? true, ), @@ -278,6 +283,68 @@ describe("layer/segmentation spatial skeleton action gating", () => { } }); + it("allows optimistic merge and split actions when the queue advertises support", () => { + const canQueueOptimisticAction = vi.fn( + (action: string) => + action === SpatialSkeletonActions.mergeSkeletons || + action === SpatialSkeletonActions.splitSkeletons, + ); + const layer = makeSpatialSkeletonActionGateLayer({ + source: makeEditableSpatialSkeletonSource({ + confidenceConfiguration: true, + rerootCommand: true, + }), + canQueueOptimisticAction, + }); + layer.spatialSkeletonState.hasUnconfirmedOptimisticEdits.mockReturnValue( + true, + ); + + expect( + layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.mergeSkeletons, + ), + ).toBeUndefined(); + expect( + layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.splitSkeletons, + ), + ).toBeUndefined(); + expect( + layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.reroot, + ), + ).toBe("Wait for pending optimistic skeleton edits to finish."); + expect(canQueueOptimisticAction).toHaveBeenCalledWith( + SpatialSkeletonActions.mergeSkeletons, + ); + expect(canQueueOptimisticAction).toHaveBeenCalledWith( + SpatialSkeletonActions.splitSkeletons, + ); + expect(canQueueOptimisticAction).toHaveBeenCalledWith( + SpatialSkeletonActions.reroot, + ); + }); + + it("blocks merge and split when optimistic queue support is unavailable", () => { + const layer = makeSpatialSkeletonActionGateLayer({ + source: makeEditableSpatialSkeletonSource(), + canQueueOptimisticAction: () => false, + }); + layer.spatialSkeletonState.hasUnconfirmedOptimisticEdits.mockReturnValue( + true, + ); + + for (const action of [ + SpatialSkeletonActions.mergeSkeletons, + SpatialSkeletonActions.splitSkeletons, + ]) { + expect(layer.getSpatialSkeletonActionsDisabledReason(action)).toBe( + "Wait for pending optimistic skeleton edits to finish.", + ); + } + }); + it("still reports visible chunk loading when requested", () => { const layer = makeSpatialSkeletonActionGateLayer({ source: makeEditableSpatialSkeletonSource(), diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 74a2e95ea6..2e1b57ff8b 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -1486,13 +1486,19 @@ export class SegmentationUserLayer extends Base { if ( !ignoreCommandBusy && this.spatialSkeletonState.hasUnconfirmedOptimisticEdits() && - requirements.some( - (action) => - isSpatialSkeletonEditAction(action) && - action !== SpatialSkeletonActions.addNodes && - action !== SpatialSkeletonActions.moveNodes && - action !== SpatialSkeletonActions.deleteNodes, - ) + requirements.some((action) => { + if (!isSpatialSkeletonEditAction(action)) return false; + const state = this.spatialSkeletonState as unknown as { + canQueueOptimisticAction?: (action: SpatialSkeletonAction) => boolean; + }; + const legacyQueueSupport = + action === SpatialSkeletonActions.addNodes || + action === SpatialSkeletonActions.moveNodes || + action === SpatialSkeletonActions.deleteNodes; + const queueSupportsAction = + state.canQueueOptimisticAction?.(action) ?? legacyQueueSupport; + return !(this.optimisticSkeletonEdits.value && queueSupportsAction); + }) ) { return "Wait for pending optimistic skeleton edits to finish."; } diff --git a/src/skeleton/commands.spec.ts b/src/skeleton/commands.spec.ts index 72fdb4ecff..f3a08385c2 100644 --- a/src/skeleton/commands.spec.ts +++ b/src/skeleton/commands.spec.ts @@ -81,6 +81,21 @@ function setSegmentNodes( } } +function makeCachedSegmentRefresher( + getFullSegmentNodes: ReturnType, +) { + return vi.fn( + async (skeletonLayer: unknown, segmentIds: readonly number[]) => { + await Promise.all( + segmentIds.map((segmentId) => + getFullSegmentNodes(skeletonLayer, segmentId), + ), + ); + return true; + }, + ); +} + const catmaidEditClientMethodNames = new Set([ "addNode", "insertNode", @@ -213,9 +228,14 @@ function makeOptimisticAddNodeTestLayer(options: { addNode?: ReturnType; deleteNode?: ReturnType; insertNode?: ReturnType; + mergeSkeletons?: ReturnType; moveNode?: ReturnType; + rerootSkeleton?: ReturnType; + splitSkeleton?: ReturnType; + getSkeleton?: ReturnType; initialNodes: readonly SpatiallyIndexedSkeletonNode[]; segmentId: number; + segmentIds?: readonly number[]; }) { const spatialSkeletonState = new SpatialSkeletonState(); for (const node of options.initialNodes) { @@ -229,7 +249,10 @@ function makeOptimisticAddNodeTestLayer(options: { addNode: options.addNode ?? vi.fn(), deleteNode: options.deleteNode ?? vi.fn(), insertNode: options.insertNode ?? vi.fn(), + mergeSkeletons: options.mergeSkeletons ?? vi.fn(), moveNode: options.moveNode ?? vi.fn(), + rerootSkeleton: options.rerootSkeleton ?? vi.fn(), + splitSkeleton: options.splitSkeleton ?? vi.fn(), }); const commands = makeCatmaidEditCommands(client, { optimisticSkeletonEdits: true, @@ -239,6 +262,9 @@ function makeOptimisticAddNodeTestLayer(options: { addNodesCommand: commands.addNodesCommand, deleteNodesCommand: commands.deleteNodesCommand, moveNodesCommand: commands.moveNodesCommand, + mergeSkeletonsCommand: commands.mergeSkeletonsCommand, + splitSkeletonsCommand: commands.splitSkeletonsCommand, + getSkeleton: options.getSkeleton ?? vi.fn(), }; const skeletonLayer = { source: skeletonSource, @@ -246,6 +272,7 @@ function makeOptimisticAddNodeTestLayer(options: { spatialSkeletonState.getCachedNode(nodeId), ), retainOverlaySegment: vi.fn(), + markSegmentEdited: vi.fn(), invalidateSourceCellsForPositions: vi.fn(), }; const selectedSpatialSkeletonNodeInfo = { @@ -258,7 +285,7 @@ function makeOptimisticAddNodeTestLayer(options: { | undefined, }; const layer = { - displayState: makeDisplayState([options.segmentId]), + displayState: makeDisplayState(options.segmentIds ?? [options.segmentId]), manager: makePinnedManager(), selectedSpatialSkeletonNodeInfo, spatialSkeletonState, @@ -2501,6 +2528,563 @@ describe("spatial_skeleton_commands", () => { }); }); + it("previews and reconciles an optimistic skeleton split with nocheck", async () => { + suppressStatusMessages(); + const segmentId = 23; + const newSegmentId = 31; + const rootNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([1, 2, 3]), + isTrueEnd: false, + sourceState: testSourceState("root-before"), + }; + const splitNode: SpatiallyIndexedSkeletonNode = { + nodeId: 2, + segmentId, + parentNodeId: rootNode.nodeId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("split-before"), + }; + const childNode: SpatiallyIndexedSkeletonNode = { + nodeId: 3, + segmentId, + parentNodeId: splitNode.nodeId, + position: new Float32Array([7, 8, 9]), + isTrueEnd: false, + sourceState: testSourceState("child-before"), + }; + let resolveSplit: + | ((result: { existingSegmentId: number; newSegmentId: number }) => void) + | undefined; + const splitSkeleton = vi.fn( + () => + new Promise<{ + existingSegmentId: number; + newSegmentId: number; + }>((resolve) => { + resolveSplit = resolve; + }), + ); + const getSkeleton = vi.fn(async (requestedSegmentId: number) => { + if (requestedSegmentId === segmentId) return [rootNode]; + if (requestedSegmentId === newSegmentId) { + return [ + { + ...splitNode, + segmentId: newSegmentId, + parentNodeId: undefined, + sourceState: testSourceState("split-after"), + }, + { + ...childNode, + segmentId: newSegmentId, + sourceState: testSourceState("child-after"), + }, + ]; + } + return []; + }); + const { layer, skeletonLayer, spatialSkeletonState } = + makeOptimisticAddNodeTestLayer({ + initialNodes: [rootNode, splitNode, childNode], + segmentId, + splitSkeleton, + getSkeleton, + }); + + await executeSpatialSkeletonSplit(layer as any, splitNode); + + const previewSplitNode = spatialSkeletonState.getCachedNode( + splitNode.nodeId, + )!; + const tempSegmentId = previewSplitNode.segmentId; + expect(tempSegmentId).not.toBe(segmentId); + expect(previewSplitNode.parentNodeId).toBeUndefined(); + expect( + spatialSkeletonState + .getCachedSegmentNodes(tempSegmentId) + ?.map((node) => node.nodeId), + ).toEqual([splitNode.nodeId, childNode.nodeId]); + expect(splitSkeleton).toHaveBeenCalledWith(splitNode.nodeId, undefined, { + nocheck: true, + }); + + resolveSplit?.({ existingSegmentId: segmentId, newSegmentId }); + await waitForMicrotasks(12); + + expect(spatialSkeletonState.getCachedSegmentNodes(tempSegmentId)).toBe( + undefined, + ); + expect(spatialSkeletonState.getCachedNode(splitNode.nodeId)).toMatchObject({ + segmentId: newSegmentId, + parentNodeId: undefined, + }); + expect( + skeletonLayer.invalidateSourceCellsForPositions, + ).not.toHaveBeenCalled(); + expect(spatialSkeletonState.commandHistory.canUndo.value).toBe(true); + }); + + it("merges back a canceled in-flight optimistic split", async () => { + suppressStatusMessages(); + const segmentId = 23; + const splitSegmentId = 31; + const rootNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([1, 2, 3]), + isTrueEnd: false, + sourceState: testSourceState("root-before"), + }; + const splitNode: SpatiallyIndexedSkeletonNode = { + nodeId: 2, + segmentId, + parentNodeId: rootNode.nodeId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("split-before"), + }; + let resolveSplit: + | ((result: { existingSegmentId: number; newSegmentId: number }) => void) + | undefined; + const splitSkeleton = vi.fn( + () => + new Promise<{ + existingSegmentId: number; + newSegmentId: number; + }>((resolve) => { + resolveSplit = resolve; + }), + ); + const mergeSkeletons = vi.fn().mockResolvedValue({ + resultSegmentId: segmentId, + deletedSegmentId: splitSegmentId, + directionAdjusted: false, + }); + const getSkeleton = vi.fn(async (requestedSegmentId: number) => + requestedSegmentId === segmentId ? [rootNode, splitNode] : [], + ); + const { layer, spatialSkeletonState } = makeOptimisticAddNodeTestLayer({ + initialNodes: [rootNode, splitNode], + segmentId, + splitSkeleton, + mergeSkeletons, + getSkeleton, + }); + + await executeSpatialSkeletonSplit(layer as any, splitNode); + await expect(undoSpatialSkeletonCommand(layer as any)).resolves.toBe(true); + + expect(spatialSkeletonState.getCachedNode(splitNode.nodeId)).toMatchObject({ + segmentId, + parentNodeId: rootNode.nodeId, + }); + + resolveSplit?.({ + existingSegmentId: segmentId, + newSegmentId: splitSegmentId, + }); + await waitForMicrotasks(12); + + expect(mergeSkeletons).toHaveBeenCalledWith( + rootNode.nodeId, + splitNode.nodeId, + undefined, + { nocheck: true }, + ); + expect(spatialSkeletonState.commandHistory.canUndo.value).toBe(false); + }); + + it("keeps an optimistic merge visible while reconciling a reversed direction", async () => { + suppressStatusMessages(); + const firstSegmentId = 11; + const secondSegmentId = 17; + const firstRoot: SpatiallyIndexedSkeletonNode = { + nodeId: 101, + segmentId: firstSegmentId, + position: new Float32Array([1, 2, 3]), + isTrueEnd: false, + sourceState: testSourceState("first-root-before"), + }; + const firstNode: SpatiallyIndexedSkeletonNode = { + nodeId: 102, + segmentId: firstSegmentId, + parentNodeId: firstRoot.nodeId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("first-node-before"), + }; + const secondRoot: SpatiallyIndexedSkeletonNode = { + nodeId: 201, + segmentId: secondSegmentId, + position: new Float32Array([7, 8, 9]), + isTrueEnd: false, + sourceState: testSourceState("second-root-before"), + }; + const secondNode: SpatiallyIndexedSkeletonNode = { + nodeId: 202, + segmentId: secondSegmentId, + parentNodeId: secondRoot.nodeId, + position: new Float32Array([10, 11, 12]), + isTrueEnd: false, + sourceState: testSourceState("second-node-before"), + }; + let resolveMerge: + | ((result: { + resultSegmentId: number; + deletedSegmentId: number; + directionAdjusted: boolean; + }) => void) + | undefined; + const mergeSkeletons = vi.fn( + () => + new Promise<{ + resultSegmentId: number; + deletedSegmentId: number; + directionAdjusted: boolean; + }>((resolve) => { + resolveMerge = resolve; + }), + ); + const mergedServerNodes: SpatiallyIndexedSkeletonNode[] = [ + secondRoot, + secondNode, + { + ...firstNode, + segmentId: secondSegmentId, + parentNodeId: secondNode.nodeId, + sourceState: testSourceState("first-node-after"), + }, + { + ...firstRoot, + segmentId: secondSegmentId, + parentNodeId: firstNode.nodeId, + sourceState: testSourceState("first-root-after"), + }, + ]; + let resolveRefresh: + | ((nodes: SpatiallyIndexedSkeletonNode[]) => void) + | undefined; + const getSkeleton = vi.fn((requestedSegmentId: number) => + requestedSegmentId === secondSegmentId + ? new Promise((resolve) => { + resolveRefresh = resolve; + }) + : Promise.resolve([]), + ); + const { layer, skeletonLayer, spatialSkeletonState } = + makeOptimisticAddNodeTestLayer({ + initialNodes: [firstRoot, firstNode, secondRoot, secondNode], + segmentId: firstSegmentId, + segmentIds: [firstSegmentId, secondSegmentId], + mergeSkeletons, + getSkeleton, + }); + + await executeSpatialSkeletonMerge(layer as any, firstNode, secondNode); + + expect(spatialSkeletonState.getCachedSegmentNodes(secondSegmentId)).toBe( + undefined, + ); + expect(spatialSkeletonState.getCachedNode(secondNode.nodeId)).toMatchObject( + { + segmentId: firstSegmentId, + parentNodeId: firstNode.nodeId, + }, + ); + expect(mergeSkeletons).toHaveBeenCalledWith( + firstNode.nodeId, + secondNode.nodeId, + undefined, + { nocheck: true }, + ); + + resolveMerge?.({ + resultSegmentId: secondSegmentId, + deletedSegmentId: firstSegmentId, + directionAdjusted: true, + }); + await waitForMicrotasks(8); + + expect(getSkeleton).toHaveBeenCalledWith( + secondSegmentId, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect( + spatialSkeletonState + .getCachedSegmentNodes(secondSegmentId) + ?.map((node) => node.nodeId) + .sort((a, b) => a - b), + ).toEqual([ + firstRoot.nodeId, + firstNode.nodeId, + secondRoot.nodeId, + secondNode.nodeId, + ]); + expect(spatialSkeletonState.getCachedSegmentNodes(firstSegmentId)).toBe( + undefined, + ); + expect( + skeletonLayer.invalidateSourceCellsForPositions, + ).not.toHaveBeenCalled(); + + resolveRefresh?.(mergedServerNodes); + await waitForMicrotasks(12); + + expect(spatialSkeletonState.getCachedNode(firstNode.nodeId)).toMatchObject({ + segmentId: secondSegmentId, + parentNodeId: secondNode.nodeId, + }); + expect(spatialSkeletonState.getCachedSegmentNodes(firstSegmentId)).toEqual( + [], + ); + expect( + skeletonLayer.invalidateSourceCellsForPositions, + ).not.toHaveBeenCalled(); + expect(spatialSkeletonState.commandHistory.canUndo.value).toBe(true); + }); + + it("retains a hidden merge target fetch while resolving an optimistic preview", async () => { + suppressStatusMessages(); + const firstSegmentId = 11; + const secondSegmentId = 17; + const firstNode: SpatiallyIndexedSkeletonNode = { + nodeId: 101, + segmentId: firstSegmentId, + position: new Float32Array([1, 2, 3]), + isTrueEnd: false, + sourceState: testSourceState("first-before"), + }; + const secondNode: SpatiallyIndexedSkeletonNode = { + nodeId: 201, + segmentId: secondSegmentId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("second-before"), + }; + let secondFetchSignal: AbortSignal | undefined; + let resolveSecondFetch: + | ((nodes: SpatiallyIndexedSkeletonNode[]) => void) + | undefined; + let secondFetchResolved = false; + const mergedServerSecondNode: SpatiallyIndexedSkeletonNode = { + ...secondNode, + segmentId: firstSegmentId, + parentNodeId: firstNode.nodeId, + sourceState: testSourceState("second-after"), + }; + const getSkeleton = vi.fn( + (requestedSegmentId: number, options?: { signal?: AbortSignal }) => { + if (requestedSegmentId !== secondSegmentId) { + return Promise.resolve( + requestedSegmentId === firstSegmentId + ? [firstNode, mergedServerSecondNode] + : [], + ); + } + if (secondFetchResolved) return Promise.resolve([]); + secondFetchSignal = options?.signal; + return new Promise( + (resolve, reject) => { + resolveSecondFetch = resolve; + options?.signal?.addEventListener( + "abort", + () => reject(options.signal?.reason), + { once: true }, + ); + }, + ); + }, + ); + const mergeSkeletons = vi.fn().mockResolvedValue({ + resultSegmentId: firstSegmentId, + deletedSegmentId: secondSegmentId, + directionAdjusted: false, + }); + const { layer, spatialSkeletonState } = makeOptimisticAddNodeTestLayer({ + initialNodes: [firstNode], + segmentId: firstSegmentId, + mergeSkeletons, + getSkeleton, + }); + + const execution = executeSpatialSkeletonMerge( + layer as any, + firstNode, + secondNode, + ); + await waitForMicrotasks(4); + + expect(getSkeleton).toHaveBeenCalledWith( + secondSegmentId, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect( + spatialSkeletonState.evictInactiveSegmentNodes([firstSegmentId]), + ).toBe(false); + expect(secondFetchSignal?.aborted).toBe(false); + + secondFetchResolved = true; + resolveSecondFetch?.([secondNode]); + await execution; + await waitForMicrotasks(12); + + expect( + spatialSkeletonState + .getCachedSegmentNodes(firstSegmentId) + ?.map((node) => node.nodeId), + ).toEqual([firstNode.nodeId, secondNode.nodeId]); + expect(mergeSkeletons).toHaveBeenCalledWith( + firstNode.nodeId, + secondNode.nodeId, + undefined, + { nocheck: true }, + ); + }); + + it("splits back a canceled in-flight optimistic merge", async () => { + suppressStatusMessages(); + const firstSegmentId = 11; + const secondSegmentId = 17; + const restoredSegmentId = 19; + const firstNode: SpatiallyIndexedSkeletonNode = { + nodeId: 101, + segmentId: firstSegmentId, + position: new Float32Array([1, 2, 3]), + isTrueEnd: false, + sourceState: testSourceState("first-before"), + }; + const secondNode: SpatiallyIndexedSkeletonNode = { + nodeId: 201, + segmentId: secondSegmentId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("second-before"), + }; + let resolveMerge: + | ((result: { + resultSegmentId: number; + deletedSegmentId: number; + directionAdjusted: boolean; + }) => void) + | undefined; + const mergeSkeletons = vi.fn( + () => + new Promise<{ + resultSegmentId: number; + deletedSegmentId: number; + directionAdjusted: boolean; + }>((resolve) => { + resolveMerge = resolve; + }), + ); + const splitSkeleton = vi.fn().mockResolvedValue({ + existingSegmentId: firstSegmentId, + newSegmentId: restoredSegmentId, + }); + const getSkeleton = vi.fn(async (requestedSegmentId: number) => { + if (requestedSegmentId === firstSegmentId) return [firstNode]; + if (requestedSegmentId === restoredSegmentId) { + return [{ ...secondNode, segmentId: restoredSegmentId }]; + } + return []; + }); + const { layer, spatialSkeletonState } = makeOptimisticAddNodeTestLayer({ + initialNodes: [firstNode, secondNode], + segmentId: firstSegmentId, + segmentIds: [firstSegmentId, secondSegmentId], + mergeSkeletons, + splitSkeleton, + getSkeleton, + }); + + await executeSpatialSkeletonMerge(layer as any, firstNode, secondNode); + await expect(undoSpatialSkeletonCommand(layer as any)).resolves.toBe(true); + + expect(spatialSkeletonState.getCachedNode(secondNode.nodeId)).toMatchObject( + { + segmentId: secondSegmentId, + parentNodeId: undefined, + }, + ); + + resolveMerge?.({ + resultSegmentId: firstSegmentId, + deletedSegmentId: secondSegmentId, + directionAdjusted: false, + }); + await waitForMicrotasks(12); + + expect(splitSkeleton).toHaveBeenCalledWith(secondNode.nodeId, undefined, { + nocheck: true, + }); + expect(spatialSkeletonState.getCachedNode(secondNode.nodeId)).toMatchObject( + { + segmentId: restoredSegmentId, + parentNodeId: undefined, + }, + ); + expect(spatialSkeletonState.commandHistory.canUndo.value).toBe(false); + }); + + it("rolls back an optimistic move that depends on a rejected split", async () => { + suppressStatusMessages(); + const segmentId = 23; + const rootNode: SpatiallyIndexedSkeletonNode = { + nodeId: 1, + segmentId, + position: new Float32Array([1, 2, 3]), + isTrueEnd: false, + sourceState: testSourceState("root-before"), + }; + const splitNode: SpatiallyIndexedSkeletonNode = { + nodeId: 2, + segmentId, + parentNodeId: rootNode.nodeId, + position: new Float32Array([4, 5, 6]), + isTrueEnd: false, + sourceState: testSourceState("split-before"), + }; + let rejectSplit: ((error: Error) => void) | undefined; + const splitSkeleton = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectSplit = reject; + }), + ); + const moveNode = vi.fn(); + const { layer, spatialSkeletonState } = makeOptimisticAddNodeTestLayer({ + initialNodes: [rootNode, splitNode], + segmentId, + splitSkeleton, + moveNode, + }); + + await executeSpatialSkeletonSplit(layer as any, splitNode); + const splitPreview = spatialSkeletonState.getCachedNode(splitNode.nodeId)!; + await executeSpatialSkeletonMoveNode(layer as any, { + node: splitPreview, + nextPositionInModelSpace: new Float32Array([40, 50, 60]), + }); + expect( + spatialSkeletonState.getCachedNode(splitNode.nodeId)?.position, + ).toEqual(new Float32Array([40, 50, 60])); + + rejectSplit?.(new Error("split rejected")); + await waitForMicrotasks(10); + + expect(moveNode).not.toHaveBeenCalled(); + expect(spatialSkeletonState.getCachedNode(splitNode.nodeId)).toMatchObject({ + segmentId, + parentNodeId: rootNode.nodeId, + }); + expect( + spatialSkeletonState.getCachedNode(splitNode.nodeId)?.position, + ).toEqual(splitNode.position); + }); + it("restores internal-node delete undo as an insertion in the local cache", async () => { suppressStatusMessages(); @@ -2864,6 +3448,7 @@ describe("spatial_skeleton_commands", () => { getCachedSegmentNodes: (segmentId: number) => cacheBySegment.get(segmentId), getFullSegmentNodes, + refreshCachedSegments: makeCachedSegmentRefresher(getFullSegmentNodes), invalidateCachedSegments, }, getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, @@ -3029,6 +3614,7 @@ describe("spatial_skeleton_commands", () => { getCachedSegmentNodes: (segmentId: number) => cacheBySegment.get(segmentId), getFullSegmentNodes, + refreshCachedSegments: makeCachedSegmentRefresher(getFullSegmentNodes), invalidateCachedSegments, }, getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, @@ -3073,10 +3659,7 @@ describe("spatial_skeleton_commands", () => { true, { segmentId: originalSegmentId }, ); - expect(invalidateCachedSegments).toHaveBeenCalledWith([ - originalSegmentId, - splitSegmentId, - ]); + expect(invalidateCachedSegments).not.toHaveBeenCalled(); expect( skeletonLayer.invalidateSourceCellsForPositions, ).toHaveBeenCalledWith([ @@ -3251,6 +3834,7 @@ describe("spatial_skeleton_commands", () => { getCachedSegmentNodes: (segmentId: number) => cacheBySegment.get(segmentId), getFullSegmentNodes, + refreshCachedSegments: makeCachedSegmentRefresher(getFullSegmentNodes), invalidateCachedSegments, }, getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, @@ -3283,11 +3867,7 @@ describe("spatial_skeleton_commands", () => { }), ); expect(rerootSkeleton).not.toHaveBeenCalled(); - expect(invalidateCachedSegments).toHaveBeenCalledTimes(1); - expect(invalidateCachedSegments).toHaveBeenCalledWith([ - originalSegmentId, - splitSegmentId, - ]); + expect(invalidateCachedSegments).not.toHaveBeenCalled(); expect(getFullSegmentNodes).toHaveBeenCalledTimes(2); expect(cacheBySegment.get(splitSegmentId)).toBeUndefined(); expect( @@ -3505,6 +4085,7 @@ describe("spatial_skeleton_commands", () => { getCachedSegmentNodes: (segmentId: number) => cacheBySegment.get(segmentId), getFullSegmentNodes, + refreshCachedSegments: makeCachedSegmentRefresher(getFullSegmentNodes), invalidateCachedSegments, }, getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, @@ -3796,6 +4377,7 @@ describe("spatial_skeleton_commands", () => { getCachedSegmentNodes: (segmentId: number) => cacheBySegment.get(segmentId), getFullSegmentNodes, + refreshCachedSegments: makeCachedSegmentRefresher(getFullSegmentNodes), invalidateCachedSegments: vi.fn((segmentIds: Iterable) => { for (const segmentId of segmentIds) { setSegmentNodes(cacheBySegment, cacheByNode, segmentId, []); @@ -3996,6 +4578,7 @@ describe("spatial_skeleton_commands", () => { getCachedSegmentNodes: (segmentId: number) => cacheBySegment.get(segmentId), getFullSegmentNodes, + refreshCachedSegments: makeCachedSegmentRefresher(getFullSegmentNodes), invalidateCachedSegments: vi.fn((segmentIds: Iterable) => { for (const segmentId of segmentIds) { setSegmentNodes(cacheBySegment, cacheByNode, segmentId, []); @@ -4132,6 +4715,7 @@ describe("spatial_skeleton_commands", () => { getCachedSegmentNodes: (segmentId: number) => cacheBySegment.get(segmentId), getFullSegmentNodes, + refreshCachedSegments: makeCachedSegmentRefresher(getFullSegmentNodes), invalidateCachedSegments: vi.fn((segmentIds: Iterable) => { for (const segmentId of segmentIds) { setSegmentNodes(cacheBySegment, cacheByNode, segmentId, []); @@ -4251,6 +4835,7 @@ describe("spatial_skeleton_commands", () => { return undefined; }), getFullSegmentNodes: vi.fn(async () => []), + refreshCachedSegments: vi.fn(async () => true), invalidateCachedSegments: vi.fn(), }, getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, diff --git a/src/skeleton/optimistic_edit_queue_config.ts b/src/skeleton/optimistic_edit_queue_config.ts new file mode 100644 index 0000000000..7b89dada6c --- /dev/null +++ b/src/skeleton/optimistic_edit_queue_config.ts @@ -0,0 +1,19 @@ +/** + * @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. + */ + +// Flip this locally to register the Queue tab while developing or inspecting +// optimistic edit transitions. This does not control optimistic edit behavior. +export const OPTIMISTIC_EDIT_QUEUE_DEBUG = false; diff --git a/src/skeleton/spatial_skeleton_manager.spec.ts b/src/skeleton/spatial_skeleton_manager.spec.ts index 02fa70e9c9..d321db84f9 100644 --- a/src/skeleton/spatial_skeleton_manager.spec.ts +++ b/src/skeleton/spatial_skeleton_manager.spec.ts @@ -16,6 +16,7 @@ import { describe, expect, it, vi } from "vitest"; +import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; import { SpatialSkeletonActions } from "#src/skeleton/command_protocol.js"; import { buildSpatiallyIndexedSkeletonNavigationGraph, @@ -312,6 +313,185 @@ describe("skeleton/spatial_skeleton_manager", () => { ); }); + it("atomically repartitions cached segments and restores cloned snapshots", () => { + const state = new SpatialSkeletonState(); + state.replaceCachedSegmentSnapshots( + [ + [ + 11, + [ + { + nodeId: 1, + segmentId: 11, + position: new Float32Array([1, 1, 1]), + parentNodeId: undefined, + }, + { + nodeId: 2, + segmentId: 11, + position: new Float32Array([2, 2, 2]), + parentNodeId: 1, + }, + { + nodeId: 3, + segmentId: 11, + position: new Float32Array([3, 3, 3]), + parentNodeId: 2, + }, + ], + ] as const, + ], + { notify: false }, + ); + const original = state.snapshotCachedSegments([11, 17]); + + // A snapshot must not retain mutable node-position storage from the cache. + (state.getCachedNode(2)!.position as Float32Array)[0] = 99; + expect(original.get(11)?.[1].position).toEqual(new Float32Array([2, 2, 2])); + + const splitRoot = { + ...state.getCachedNode(2)!, + segmentId: 999, + position: new Float32Array([2, 2, 2]), + parentNodeId: undefined, + }; + const notificationSnapshots: unknown[] = []; + state.nodeDataVersion.changed.add(() => { + notificationSnapshots.push({ + originalNodeIds: state + .getCachedSegmentNodes(11) + ?.map((node) => node.nodeId), + splitNodeIds: state + .getCachedSegmentNodes(17) + ?.map((node) => node.nodeId), + splitRootSegmentId: state.getCachedNode(2)?.segmentId, + }); + }); + + expect( + state.replaceCachedSegmentSnapshots([ + [11, [state.getCachedNode(1)!]] as const, + [ + 17, + [ + splitRoot, + { + ...state.getCachedNode(3)!, + segmentId: 999, + }, + ], + ] as const, + ]), + ).toBe(true); + + expect(notificationSnapshots).toEqual([ + { + originalNodeIds: [1], + splitNodeIds: [2, 3], + splitRootSegmentId: 17, + }, + ]); + expect(state.getCachedNode(3)).toBe(state.getCachedSegmentNodes(17)?.[1]); + expect(state.getCachedNode(2)?.segmentId).toBe(17); + + // Replacement inputs are cloned and cannot mutate the live cache later. + splitRoot.position[0] = 123; + expect(state.getCachedNode(2)?.position).toEqual( + new Float32Array([2, 2, 2]), + ); + + expect(state.replaceCachedSegmentSnapshots(original)).toBe(true); + expect(state.getCachedSegmentNodes(17)).toBeUndefined(); + expect(state.getCachedNode(2)?.segmentId).toBe(11); + expect(state.getCachedNode(2)?.position).toEqual( + new Float32Array([2, 2, 2]), + ); + }); + + it("distinguishes known-empty and uncached segment snapshots", () => { + const state = new SpatialSkeletonState(); + const uncached = state.snapshotCachedSegments([21]); + + expect(uncached.get(21)).toBeUndefined(); + expect( + state.replaceCachedSegmentSnapshots([[21, []] as const], { + notify: false, + }), + ).toBe(true); + expect(state.getCachedSegmentNodes(21)).toEqual([]); + expect(state.snapshotCachedSegments([21]).get(21)).toEqual([]); + + expect( + state.replaceCachedSegmentSnapshots(uncached, { notify: false }), + ).toBe(true); + expect(state.getCachedSegmentNodes(21)).toBeUndefined(); + expect( + state.replaceCachedSegmentSnapshots(uncached, { notify: false }), + ).toBe(false); + }); + + it("rejects an inconsistent atomic replacement before changing the cache", () => { + const state = new SpatialSkeletonState(); + state.replaceCachedSegmentSnapshots( + [ + [ + 11, + [ + { + nodeId: 1, + segmentId: 11, + position: new Float32Array([1, 1, 1]), + }, + ], + ] as const, + [ + 13, + [ + { + nodeId: 2, + segmentId: 13, + position: new Float32Array([2, 2, 2]), + }, + ], + ] as const, + ], + { notify: false }, + ); + const before = state.snapshotCachedSegments([11, 13]); + const nodeDataVersion = state.nodeDataVersion.value; + + expect(() => + state.replaceCachedSegmentSnapshots([ + [ + 11, + [ + { + nodeId: 3, + segmentId: 11, + position: new Float32Array([3, 3, 3]), + }, + ], + ] as const, + [ + 13, + [ + { + nodeId: 3, + segmentId: 13, + position: new Float32Array([4, 4, 4]), + }, + ], + ] as const, + ]), + ).toThrow("node 3 is present in both segment 11 and segment 13"); + + expect(state.snapshotCachedSegments([11, 13])).toEqual(before); + expect(state.getCachedNode(1)?.segmentId).toBe(11); + expect(state.getCachedNode(2)?.segmentId).toBe(13); + expect(state.getCachedNode(3)).toBeUndefined(); + expect(state.nodeDataVersion.value).toBe(nodeDataVersion); + }); + it("can seed a brand-new cached segment from a local node mutation", () => { const state = new SpatialSkeletonState(); @@ -499,6 +679,60 @@ describe("skeleton/spatial_skeleton_manager", () => { expect(state.getCachedNode(5)).toBeUndefined(); }); + it("keeps stale cached nodes while a cache-bypassing refresh is pending", async () => { + const state = new SpatialSkeletonState(); + const staleNode = { + nodeId: 5, + segmentId: 11, + position: new Float32Array([1, 2, 3]), + parentNodeId: undefined, + isTrueEnd: false, + }; + state.replaceCachedSegmentSnapshots([[11, [staleNode]]]); + let resolveFetch: + | ((value: SpatiallyIndexedSkeletonNode[]) => void) + | undefined; + const getSkeleton = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + const skeletonLayer = { + source: { + readonly: false, + listSkeletons: async () => [], + getSkeleton, + fetchNodes: async () => [], + getSpatialIndexMetadata: async () => null, + }, + } as any; + let notifications = 0; + state.nodeDataVersion.changed.add(() => { + notifications += 1; + }); + + const pending = state.refreshCachedSegments(skeletonLayer, [11]); + + expect(getSkeleton).toHaveBeenCalledTimes(1); + expect(state.getCachedSegmentNodes(11)?.[0]?.position).toEqual( + new Float32Array([1, 2, 3]), + ); + expect(notifications).toBe(0); + + resolveFetch?.([ + { + ...staleNode, + position: new Float32Array([7, 8, 9]), + }, + ]); + await expect(pending).resolves.toBe(true); + expect(state.getCachedSegmentNodes(11)?.[0]?.position).toEqual( + new Float32Array([7, 8, 9]), + ); + expect(notifications).toBe(1); + }); + it("aborts pending full segment fetches when the cache generation is cleared", async () => { const state = new SpatialSkeletonState(); let receivedSignal: AbortSignal | undefined; @@ -607,6 +841,116 @@ describe("skeleton/spatial_skeleton_manager", () => { expect(state.getCachedNode(11)).toBeUndefined(); }); + it("retains a pending visual fetch when a command joins it", async () => { + const state = new SpatialSkeletonState(); + let receivedSignal: AbortSignal | undefined; + let resolveFetch: + | ((nodes: SpatiallyIndexedSkeletonNode[]) => void) + | undefined; + const getSkeleton = vi.fn( + (_segmentId: number, options?: { signal?: AbortSignal }) => + new Promise((resolve, reject) => { + receivedSignal = options?.signal; + resolveFetch = resolve; + options?.signal?.addEventListener( + "abort", + () => reject(options.signal?.reason), + { once: true }, + ); + }), + ); + const skeletonLayer = { + source: { + readonly: false, + listSkeletons: async () => [], + getSkeleton, + fetchNodes: async () => [], + getSpatialIndexMetadata: async () => null, + }, + } as any; + + const visualFetch = state.getFullSegmentNodes(skeletonLayer, 11); + const commandFetch = state.getFullSegmentNodes(skeletonLayer, 11, { + retainWhileInactive: true, + }); + + expect(getSkeleton).toHaveBeenCalledTimes(1); + expect(state.evictInactiveSegmentNodes([])).toBe(false); + expect(receivedSignal?.aborted).toBe(false); + + const fetchedNodes: SpatiallyIndexedSkeletonNode[] = [ + { + nodeId: 5, + segmentId: 11, + position: new Float32Array([1, 2, 3]), + isTrueEnd: false, + }, + ]; + resolveFetch?.(fetchedNodes); + + await expect(visualFetch).resolves.toEqual(fetchedNodes); + await expect(commandFetch).resolves.toEqual(fetchedNodes); + expect(state.getCachedSegmentNodes(11)?.map((node) => node.nodeId)).toEqual( + [5], + ); + }); + + it("aborts a pending fetch before atomically replacing its segment", async () => { + const state = new SpatialSkeletonState(); + let receivedSignal: AbortSignal | undefined; + const getSkeleton = vi.fn( + (_segmentId: number, options?: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + receivedSignal = options?.signal; + options?.signal?.addEventListener( + "abort", + () => reject(options.signal?.reason), + { once: true }, + ); + }), + ); + const pending = state.getFullSegmentNodes( + { + source: { + readonly: false, + listSkeletons: async () => [], + getSkeleton, + fetchNodes: async () => [], + getSpatialIndexMetadata: async () => null, + }, + } as any, + 11, + ); + let notifications = 0; + state.nodeDataVersion.changed.add(() => { + notifications += 1; + }); + + expect(receivedSignal?.aborted).toBe(false); + expect( + state.replaceCachedSegmentSnapshots([ + [ + 11, + [ + { + nodeId: 5, + segmentId: 11, + position: new Float32Array([1, 2, 3]), + }, + ], + ] as const, + ]), + ).toBe(true); + + expect(receivedSignal?.aborted).toBe(true); + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + expect(state.getCachedSegmentNodes(11)?.map((node) => node.nodeId)).toEqual( + [5], + ); + expect(state.getCachedNode(5)).toBe(state.getCachedSegmentNodes(11)?.[0]); + expect(notifications).toBe(1); + }); + it("notifies node data listeners after caching a fetched full segment", async () => { const state = new SpatialSkeletonState(); const getSkeleton = vi.fn(async () => [ diff --git a/src/skeleton/spatial_skeleton_manager.ts b/src/skeleton/spatial_skeleton_manager.ts index ba27ff3a58..7eba543cbd 100644 --- a/src/skeleton/spatial_skeleton_manager.ts +++ b/src/skeleton/spatial_skeleton_manager.ts @@ -56,6 +56,7 @@ export function isSpatialSkeletonOptimisticEditState( export interface SpatialSkeletonOptimisticEditQueue { canUndo(): boolean; + canQueueAction?(action: SpatialSkeletonAction): boolean; clear?(): boolean; dispose?(): boolean; hasUnconfirmedActions(): boolean; @@ -75,8 +76,22 @@ export interface SpatialSkeletonOptimisticEditDebugEntry { readonly parentTempNodeId?: number; readonly nodeId?: number; readonly segmentId?: number; + readonly dependencies?: readonly number[]; + readonly tempSegmentId?: number; + readonly secondNodeId?: number; + readonly secondSegmentId?: number; + readonly resultSegmentId?: number; + readonly deletedSegmentId?: number; } +/** + * A cloneable snapshot of a full cached segment. `undefined` represents an + * uncached segment, while an empty array represents a known empty segment. + */ +export type SpatialSkeletonCachedSegmentSnapshot = + | readonly SpatiallyIndexedSkeletonNode[] + | undefined; + function hasFunction( value: unknown, property: T, @@ -268,6 +283,40 @@ function cloneSpatiallyIndexedSkeletonNode( }; } +function cachedSkeletonNodesEqual( + a: SpatiallyIndexedSkeletonNode, + b: SpatiallyIndexedSkeletonNode, +) { + if ( + a.nodeId !== b.nodeId || + a.segmentId !== b.segmentId || + a.parentNodeId !== b.parentNodeId || + a.radius !== b.radius || + a.confidence !== b.confidence || + a.description !== b.description || + a.isTrueEnd !== b.isTrueEnd || + a.sourceState !== b.sourceState || + a.position.length !== b.position.length + ) { + return false; + } + for (let i = 0; i < a.position.length; ++i) { + if (a.position[i] !== b.position[i]) return false; + } + return true; +} + +function cachedSegmentSnapshotsEqual( + a: readonly SpatiallyIndexedSkeletonNode[] | undefined, + b: readonly SpatiallyIndexedSkeletonNode[] | undefined, +) { + if (a === undefined || b === undefined) return a === b; + return ( + a.length === b.length && + a.every((node, index) => cachedSkeletonNodesEqual(node, b[index])) + ); +} + /** * Full-segment skeleton fetches bypass the chunk queue manager, so they are * capped separately at min(this, the concurrentDownloads viewer setting). @@ -307,6 +356,7 @@ export class SpatialSkeletonState { promise: Promise; abortController: AbortController; + retainWhileInactive: boolean; } >(); private fullSegmentNodeFetchLimitLayer: @@ -344,6 +394,10 @@ export class SpatialSkeletonState return this.optimisticEditQueue?.hasUnconfirmedActions() ?? false; } + canQueueOptimisticAction(action: SpatialSkeletonAction) { + return this.optimisticEditQueue?.canQueueAction?.(action) ?? false; + } + getOptimisticEditQueueDebugSnapshot() { return this.optimisticEditQueue?.getDebugSnapshot?.() ?? []; } @@ -553,6 +607,128 @@ export class SpatialSkeletonState return this.cachedNodesById.get(nodeId); } + /** + * Captures independent copies of complete cached segments for optimistic + * topology edits and rollback. Missing cache entries are retained as + * `undefined`, so restoring the returned map recreates the exact cached / + * uncached distinction. + */ + snapshotCachedSegments(segmentIds: Iterable) { + const snapshots = new Map(); + for (const segmentId of segmentIds) { + const cachedNodes = this.fullSegmentNodeCache.get(segmentId); + snapshots.set( + segmentId, + cachedNodes?.map(cloneSpatiallyIndexedSkeletonNode), + ); + } + return snapshots; + } + + /** + * Atomically replaces complete cached segments. + * + * Each input node and its position are cloned, and its segment id is set to + * the entry key. `undefined` deletes a cache entry; `[]` records a known + * empty segment. All input is validated before any cache or pending fetch + * is changed. Node-data listeners are notified once after the whole + * replacement unless `notify` is false. + */ + replaceCachedSegmentSnapshots( + snapshots: Iterable< + readonly [number, SpatialSkeletonCachedSegmentSnapshot] + >, + options: { notify?: boolean } = {}, + ) { + const replacements = new Map< + number, + SpatiallyIndexedSkeletonNode[] | undefined + >(); + for (const [segmentId, snapshot] of snapshots) { + if (!Number.isSafeInteger(segmentId) || segmentId <= 0) { + throw new RangeError( + `Invalid spatial skeleton segment id: ${segmentId}`, + ); + } + if (snapshot === undefined) { + replacements.set(segmentId, undefined); + continue; + } + const clonedNodes: SpatiallyIndexedSkeletonNode[] = []; + for (const node of snapshot) { + if (!Number.isSafeInteger(node.nodeId) || node.nodeId <= 0) { + throw new RangeError( + `Invalid spatial skeleton node id: ${node.nodeId}`, + ); + } + clonedNodes.push( + cloneSpatiallyIndexedSkeletonNode({ ...node, segmentId }), + ); + } + replacements.set(segmentId, clonedNodes); + } + if (replacements.size === 0) return false; + + // Build the complete prospective reverse index before mutating either + // cache. Besides keeping the update atomic, this rejects accidentally + // placing one node in multiple segments. + const nextNodesById = new Map(); + const addSegmentNodesToIndex = ( + segmentId: number, + nodes: readonly SpatiallyIndexedSkeletonNode[], + ) => { + for (const node of nodes) { + const existing = nextNodesById.get(node.nodeId); + if (existing !== undefined) { + throw new Error( + `Spatial skeleton node ${node.nodeId} is present in both segment ${existing.segmentId} and segment ${segmentId}.`, + ); + } + nextNodesById.set(node.nodeId, node); + } + }; + for (const [segmentId, nodes] of this.fullSegmentNodeCache) { + if (replacements.has(segmentId)) continue; + addSegmentNodesToIndex(segmentId, nodes); + } + for (const [segmentId, nodes] of replacements) { + if (nodes !== undefined) addSegmentNodesToIndex(segmentId, nodes); + } + + let changed = false; + for (const [segmentId, nodes] of replacements) { + changed = + !cachedSegmentSnapshotsEqual( + this.fullSegmentNodeCache.get(segmentId), + nodes, + ) || changed; + } + + for (const segmentId of replacements.keys()) { + this.abortPendingFullSegmentNodeFetch( + segmentId, + "spatial skeleton full-segment inspection request replaced by atomic local cache update", + ); + } + if (!changed) return false; + + for (const [segmentId, nodes] of replacements) { + if (nodes === undefined) { + this.fullSegmentNodeCache.delete(segmentId); + } else { + this.fullSegmentNodeCache.set(segmentId, nodes); + } + } + this.cachedNodesById.clear(); + for (const [nodeId, node] of nextNodesById) { + this.cachedNodesById.set(nodeId, node); + } + if (options.notify ?? true) { + this.markNodeDataChanged({ invalidateFullSkeletonCache: false }); + } + return true; + } + private replaceCachedSegmentNodes( segmentId: number, nextSegmentNodes: readonly SpatiallyIndexedSkeletonNode[], @@ -972,8 +1148,14 @@ export class SpatialSkeletonState if (activeSegmentIdSet.has(segmentId)) continue; changed = this.deleteCachedSegment(segmentId) || changed; } - for (const segmentId of this.pendingFullSegmentNodeFetches.keys()) { - if (activeSegmentIdSet.has(segmentId)) continue; + for (const [segmentId, pendingEntry] of this + .pendingFullSegmentNodeFetches) { + if ( + activeSegmentIdSet.has(segmentId) || + pendingEntry.retainWhileInactive + ) { + continue; + } this.abortPendingFullSegmentNodeFetch( segmentId, "spatial skeleton full-segment inspection request evicted for inactive segment", @@ -982,16 +1164,70 @@ export class SpatialSkeletonState return changed; } - async getFullSegmentNodes( + /** + * Refreshes complete segments without removing their current cached values. + * Successful fetches are published together after every request settles, so + * renderers never observe an intermediate cache with those segments missing. + */ + async refreshCachedSegments( + skeletonLayer: SpatiallyIndexedSkeletonLayer, + segmentIds: readonly number[], + options: { notify?: boolean } = {}, + ) { + const refreshVersion = this.fullSkeletonCacheGeneration; + const refreshedSegments = await Promise.allSettled( + segmentIds.map( + async (segmentId) => + [ + segmentId, + await this.fetchFullSegmentNodes(skeletonLayer, segmentId, { + forceRefresh: true, + updateCache: false, + }), + ] as const, + ), + ); + if (this.fullSkeletonCacheGeneration !== refreshVersion) return false; + const replacements = new Map< + number, + readonly SpatiallyIndexedSkeletonNode[] + >(); + for (const refreshedSegment of refreshedSegments) { + if (refreshedSegment.status !== "fulfilled") continue; + replacements.set(...refreshedSegment.value); + } + return this.replaceCachedSegmentSnapshots(replacements, options); + } + + getFullSegmentNodes( skeletonLayer: SpatiallyIndexedSkeletonLayer, segmentId: number, + options: { retainWhileInactive?: boolean } = {}, + ): Promise { + return this.fetchFullSegmentNodes(skeletonLayer, segmentId, options); + } + + private async fetchFullSegmentNodes( + skeletonLayer: SpatiallyIndexedSkeletonLayer, + segmentId: number, + options: { + forceRefresh?: boolean; + retainWhileInactive?: boolean; + updateCache?: boolean; + } = {}, ): Promise { const cached = this.fullSegmentNodeCache.get(segmentId); - if (cached !== undefined) { + if (cached !== undefined && !options.forceRefresh) { return cached; } const pendingEntry = this.pendingFullSegmentNodeFetches.get(segmentId); if (pendingEntry !== undefined) { + if (options.retainWhileInactive) { + // A command may join a fetch that was originally started only for the + // render overlay. Promote the shared request so visibility-based cache + // eviction cannot cancel work that an edit is actively awaiting. + pendingEntry.retainWhileInactive = true; + } return pendingEntry.promise; } const skeletonSource = getSpatiallyIndexedSkeletonSource(skeletonLayer); @@ -1023,6 +1259,7 @@ export class SpatialSkeletonState } normalizedNodes.sort((a, b) => a.nodeId - b.nodeId); if ( + (options.updateCache ?? true) && this.fullSkeletonCacheGeneration === fetchVersion && pendingFetch.promise !== undefined && this.pendingFullSegmentNodeFetches.get(segmentId)?.promise === @@ -1047,6 +1284,7 @@ export class SpatialSkeletonState this.pendingFullSegmentNodeFetches.set(segmentId, { promise: fetchPromise, abortController, + retainWhileInactive: options.retainWhileInactive ?? false, }); return fetchPromise; } diff --git a/src/ui/skeleton_optimistic_edit_queue_tab.ts b/src/ui/skeleton_optimistic_edit_queue_tab.ts index 67b7138638..0a645d937f 100644 --- a/src/ui/skeleton_optimistic_edit_queue_tab.ts +++ b/src/ui/skeleton_optimistic_edit_queue_tab.ts @@ -17,12 +17,12 @@ import "#src/ui/skeleton_tab.css"; import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; +import { OPTIMISTIC_EDIT_QUEUE_DEBUG } from "#src/skeleton/optimistic_edit_queue_config.js"; import type { SpatialSkeletonOptimisticEditDebugEntry } from "#src/skeleton/spatial_skeleton_manager.js"; import type { WatchableValueInterface } from "#src/trackable_value.js"; import { Tab } from "#src/widget/tab_view.js"; -// Flip this locally when inspecting optimistic queue transitions. -export const OPTIMISTIC_EDIT_QUEUE_DEBUG = true; +export { OPTIMISTIC_EDIT_QUEUE_DEBUG } from "#src/skeleton/optimistic_edit_queue_config.js"; function getOptimisticEditQueueEntries(layer: SegmentationUserLayer) { return layer.spatialSkeletonState.getOptimisticEditQueueDebugSnapshot(); @@ -147,6 +147,15 @@ export class SpatialSkeletonOptimisticEditQueueTab extends Tab { entry.nodeId === undefined ? "-" : `${entry.nodeId}:${entry.segmentId ?? "-"}`; + const dependencies = entry.dependencies?.length + ? entry.dependencies.map((operationId) => `#${operationId}`).join(",") + : "-"; + const topology = + entry.tempSegmentId === undefined && + entry.secondSegmentId === undefined && + entry.resultSegmentId === undefined + ? "" + : ` tempSegment ${entry.tempSegmentId ?? "-"} second ${entry.secondNodeId ?? "-"}:${entry.secondSegmentId ?? "-"} result ${entry.resultSegmentId ?? "-"} deleted ${entry.deletedSegmentId ?? "-"}`; const order = document.createElement("span"); order.className = "neuroglancer-skeleton-queue-debug-order"; order.textContent = `${index + 1}`; @@ -158,7 +167,7 @@ export class SpatialSkeletonOptimisticEditQueueTab extends Tab { action.textContent = `#${entry.operationId ?? "-"} ${entry.kind}`; const details = document.createElement("span"); details.className = "neuroglancer-skeleton-queue-debug-details"; - details.textContent = `temp ${tempNode} segment ${segment} parent ${parent} server ${server}`; + details.textContent = `temp ${tempNode} segment ${segment} parent ${parent} server ${server} deps ${dependencies}${topology}`; row.appendChild(order); row.appendChild(status); row.appendChild(action); diff --git a/src/ui/skeleton_tab.ts b/src/ui/skeleton_tab.ts index 6a9b2840e9..9c0c26c565 100644 --- a/src/ui/skeleton_tab.ts +++ b/src/ui/skeleton_tab.ts @@ -1763,7 +1763,7 @@ export class SpatialSkeletonEditTab extends Tab { undoButton.title = busy ? "Wait for the current skeleton edit to finish." : canUndoOptimistic - ? "Undo pending node creation." + ? "Undo latest optimistic edit." : undoLabel === undefined ? "Nothing to undo." : `Undo ${undoLabel}`;