diff --git a/src/datasource/catmaid/spatial_skeleton_commands.ts b/src/datasource/catmaid/spatial_skeleton_commands.ts index ea2b31c2b4..e19315f06a 100644 --- a/src/datasource/catmaid/spatial_skeleton_commands.ts +++ b/src/datasource/catmaid/spatial_skeleton_commands.ts @@ -52,20 +52,18 @@ import { addSegmentToVisibleSets, removeSegmentFromVisibleSets, } from "#src/segmentation_display_state/base.js"; -import { - SpatialSkeletonActions, - type SpatialSkeletonAction, -} from "#src/skeleton/actions.js"; import type { SpatiallyIndexedSkeletonNode, SpatialSkeletonSourceState, SpatialSkeletonVector, } from "#src/skeleton/api.js"; import type { SpatialSkeletonEditCommandFactory } from "#src/skeleton/command_factories.js"; -import type { - SpatialSkeletonCommand, - SpatialSkeletonCommandContext, -} from "#src/skeleton/command_history.js"; +import { + SpatialSkeletonActions, + type SpatialSkeletonAction, + type SpatialSkeletonCommand, + type SpatialSkeletonCommandContext, +} from "#src/skeleton/command_protocol.js"; import type { SpatiallyIndexedSkeletonLayer } from "#src/skeleton/frontend.js"; import { findSpatiallyIndexedSkeletonNode, diff --git a/src/layer/segmentation/index.spec.ts b/src/layer/segmentation/index.spec.ts index a0066483d7..eb835e8fd7 100644 --- a/src/layer/segmentation/index.spec.ts +++ b/src/layer/segmentation/index.spec.ts @@ -17,7 +17,7 @@ import { describe, expect, it, vi } from "vitest"; import type { RenderLayerTransform } from "#src/render_coordinate_transform.js"; -import { SpatialSkeletonActions } from "#src/skeleton/actions.js"; +import { SpatialSkeletonActions } from "#src/skeleton/command_protocol.js"; import { WatchableValue } from "#src/trackable_value.js"; if (!("WebGL2RenderingContext" in globalThis)) { diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 5ef75d3f13..db36fdd47c 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -96,16 +96,38 @@ import { SegmentationGraphSourceTab } from "#src/segmentation_graph/source.js"; import { SharedDisjointUint64Sets } from "#src/shared_disjoint_sets.js"; import { SharedWatchableValue } from "#src/shared_watchable_value.js"; import { - DEFAULT_SPATIAL_SKELETON_EDIT_ACTIONS, - getSpatialSkeletonActionSupportLabel, - isSpatialSkeletonEditAction, - SpatialSkeletonActions, - type SpatialSkeletonAction, + SKELETON_CYCLE_BRANCHES, + SKELETON_GO_BRANCH_END, + SKELETON_GO_BRANCH_START, + SKELETON_GO_CHILD, + SKELETON_GO_PARENT, + SKELETON_GO_ROOT, + SKELETON_GO_UNFINISHED, + SKELETON_REDO, + SKELETON_UNDO, } from "#src/skeleton/actions.js"; import type { SpatiallyIndexedSkeletonNode, SpatialSkeletonSourceState, } from "#src/skeleton/api.js"; +import { + DEFAULT_SPATIAL_SKELETON_EDIT_ACTIONS, + getSpatialSkeletonActionSupportLabel, + isSpatialSkeletonEditAction, + SpatialSkeletonActions, + type SpatialSkeletonAction, +} from "#src/skeleton/command_protocol.js"; +import { + executeSpatialSkeletonDeleteNode, + executeSpatialSkeletonNodeConfidenceUpdate, + executeSpatialSkeletonNodeDescriptionUpdate, + executeSpatialSkeletonNodeRadiusUpdate, + executeSpatialSkeletonReroot, + executeSpatialSkeletonNodeTrueEndUpdate, + redoSpatialSkeletonCommand, + showSpatialSkeletonActionError, + undoSpatialSkeletonCommand, +} from "#src/skeleton/commands.js"; import { PerspectiveViewSkeletonLayer, SkeletonLayer, @@ -117,6 +139,16 @@ import { SpatiallyIndexedSkeletonSource, MultiscaleSpatiallyIndexedSkeletonSource, } from "#src/skeleton/frontend.js"; +import { + buildSpatiallyIndexedSkeletonNavigationGraph, + getBranchEnd as getBranchEndFromGraph, + getBranchStart as getBranchStartFromGraph, + getNextCollapsedLevelNode as getNextCollapsedLevelNodeFromGraph, + getOpenLeaves as getOpenLeavesFromGraph, + getParentNode as getParentNodeFromGraph, + getRandomChildNode as getRandomChildNodeFromGraph, + getSkeletonRootNode as getSkeletonRootNodeFromGraph, +} from "#src/skeleton/navigation_graph.js"; import { findSpatiallyIndexedSkeletonNode, getSpatiallyIndexedSkeletonDirectChildren, @@ -129,15 +161,6 @@ import { SpatialSkeletonDisplayNodeType, SpatialSkeletonNodeFilterType, } from "#src/skeleton/node_types.js"; -import { - executeSpatialSkeletonDeleteNode, - executeSpatialSkeletonNodeConfidenceUpdate, - executeSpatialSkeletonNodeDescriptionUpdate, - executeSpatialSkeletonNodeRadiusUpdate, - executeSpatialSkeletonReroot, - executeSpatialSkeletonNodeTrueEndUpdate, - showSpatialSkeletonActionError, -} from "#src/skeleton/spatial_skeleton_commands.js"; import { editableSpatiallyIndexedSkeletonSourceSupportsAction, getEditableSpatiallyIndexedSkeletonSource, @@ -2017,8 +2040,205 @@ export class SegmentationUserLayer extends Base { } break; } + case SKELETON_GO_ROOT: + case SKELETON_GO_BRANCH_START: + case SKELETON_GO_BRANCH_END: + case SKELETON_CYCLE_BRANCHES: + case SKELETON_GO_PARENT: + case SKELETON_GO_CHILD: + case SKELETON_GO_UNFINISHED: + case SKELETON_UNDO: + case SKELETON_REDO: { + if (!this.shouldHandleGlobalSkeletonAction()) return; + void this.handleSkeletonNavigationAction(action); + break; + } + } + } + + private shouldHandleGlobalSkeletonAction(): boolean { + let skeletonLayerCount = 0; + for (const managedLayer of this.manager.root.layerManager.managedLayers) { + if (!managedLayer.visible || managedLayer.layer === null) continue; + const layer = managedLayer.layer; + if ( + layer instanceof SegmentationUserLayer && + layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.inspect, + { requireVisibleChunks: false }, + ) === undefined + ) { + skeletonLayerCount++; + if (skeletonLayerCount > 1) break; + } + } + return ( + skeletonLayerCount <= 1 || + this.managedLayer === this.manager.root.selectedLayer.layer + ); + } + + private async handleSkeletonNavigationAction(action: string): Promise { + const inspectDisabledReason = this.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.inspect, + { requireVisibleChunks: false }, + ); + if (inspectDisabledReason !== undefined) { + StatusMessage.showTemporaryMessage(inspectDisabledReason); + return; + } + + if (action === SKELETON_UNDO) { + try { + await undoSpatialSkeletonCommand(this); + } catch (error) { + showSpatialSkeletonActionError("undo", error); + } + return; + } + if (action === SKELETON_REDO) { + try { + await redoSpatialSkeletonCommand(this); + } catch (error) { + showSpatialSkeletonActionError("redo", error); + } + return; + } + + const nodeInfo = this.selectedSpatialSkeletonNodeInfo.value; + const cachedNode = + nodeInfo?.nodeId !== undefined + ? this.spatialSkeletonState.getCachedNode(nodeInfo.nodeId) + : undefined; + + const segmentId = + cachedNode?.segmentId ?? + nodeInfo?.segmentId ?? + getSegmentIdFromLayerSelectionValue( + this.manager.root.selectionState.value?.layers.find( + (entry) => entry.layer === this, + )?.state, + ); + + if (segmentId === undefined) { + StatusMessage.showTemporaryMessage("No segment/skeleton is selected."); + return; + } + + const segmentNodes = + this.spatialSkeletonState.getCachedSegmentNodes(segmentId); + if (segmentNodes === undefined || segmentNodes.length === 0) { + StatusMessage.showTemporaryMessage( + "A non-visible segment is selected. Make it visible to use skeleton navigation features.", + ); + return; + } + + const graph = buildSpatiallyIndexedSkeletonNavigationGraph(segmentNodes); + + try { + if (action === SKELETON_GO_ROOT) { + const target = getSkeletonRootNodeFromGraph(graph); + this.selectAndMoveToSpatialSkeletonNode({ + nodeId: target.nodeId, + segmentId, + position: target.position, + }); + return; + } + + const nodeId = cachedNode?.nodeId ?? nodeInfo?.nodeId; + if (nodeId === undefined) { + StatusMessage.showTemporaryMessage( + "No skeleton node is selected, only go to root is supported on skeleton edges.", + ); + return; + } + + switch (action) { + case SKELETON_GO_BRANCH_START: { + const target = getBranchStartFromGraph(graph, nodeId); + this.selectAndMoveToSpatialSkeletonNode({ + nodeId: target.nodeId, + segmentId, + position: target.position, + }); + break; + } + case SKELETON_GO_BRANCH_END: { + const target = getBranchEndFromGraph(graph, nodeId); + this.selectAndMoveToSpatialSkeletonNode({ + nodeId: target.nodeId, + segmentId, + position: target.position, + }); + break; + } + case SKELETON_CYCLE_BRANCHES: { + const target = getNextCollapsedLevelNodeFromGraph(graph, nodeId); + this.selectAndMoveToSpatialSkeletonNode({ + nodeId: target.nodeId, + segmentId, + position: target.position, + }); + break; + } + case SKELETON_GO_PARENT: { + const target = getParentNodeFromGraph(graph, nodeId); + if (target === undefined) { + StatusMessage.showTemporaryMessage("Selected node has no parent."); + return; + } + this.selectAndMoveToSpatialSkeletonNode({ + nodeId: target.nodeId, + segmentId, + position: target.position, + }); + break; + } + case SKELETON_GO_CHILD: { + const target = getRandomChildNodeFromGraph(graph, nodeId); + if (target === undefined) { + StatusMessage.showTemporaryMessage("Selected node has no child."); + return; + } + this.selectAndMoveToSpatialSkeletonNode({ + nodeId: target.nodeId, + segmentId, + position: target.position, + }); + break; + } + case SKELETON_GO_UNFINISHED: { + const openLeaves = getOpenLeavesFromGraph(graph, nodeId); + if (openLeaves.length === 0) { + StatusMessage.showTemporaryMessage( + "No unfinished branch was found in the current skeleton.", + ); + return; + } + openLeaves.sort((a, b) => + a.distance === b.distance + ? a.nodeId - b.nodeId + : a.distance - b.distance, + ); + const leaf = openLeaves[0]; + this.selectAndMoveToSpatialSkeletonNode({ + nodeId: leaf.nodeId, + segmentId, + position: leaf.position, + }); + break; + } + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + StatusMessage.showTemporaryMessage( + `Skeleton navigation failed: ${message}`, + ); } } + selectionStateFromJson(state: this["selectionState"], json: any) { super.selectionStateFromJson(state, json); let parsedValue = state.value; @@ -2295,12 +2515,14 @@ export class SegmentationUserLayer extends Base { ? "Load the active skeleton in the Skeleton tab before rerooting from Selection." : fullNodeInfo.parentNodeId === undefined ? "Selected node is already root." - : this.getSpatialSkeletonActionsDisabledReason( - SpatialSkeletonActions.reroot, - { - requireVisibleChunks: false, - }, - ); + : (fullNodeInfo.isTrueEnd ?? false) + ? "True end nodes cannot be set as root. Clear the true end state first." + : this.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.reroot, + { + requireVisibleChunks: false, + }, + ); const rerootButton = document.createElement("button"); rerootButton.type = "button"; rerootButton.className = "neuroglancer-selection-details-skeleton-action"; @@ -2319,7 +2541,8 @@ export class SegmentationUserLayer extends Base { rerootButton.disabled || rerootPending || completeNodeInfo === undefined || - completeNodeInfo.parentNodeId === undefined + completeNodeInfo.parentNodeId === undefined || + (completeNodeInfo.isTrueEnd ?? false) ) { return; } diff --git a/src/skeleton/actions.ts b/src/skeleton/actions.ts index 283cb7a7a7..c05b626b53 100644 --- a/src/skeleton/actions.ts +++ b/src/skeleton/actions.ts @@ -14,61 +14,33 @@ * limitations under the License. */ -export const SpatialSkeletonActions = { - inspect: "inspectSkeletons", - addNodes: "addNodes", - insertNodes: "insertNodes", - moveNodes: "moveNodes", - deleteNodes: "deleteNodes", - reroot: "rerootSkeletons", - editNodeDescription: "editNodeDescription", - editNodeTrueEnd: "editNodeTrueEnd", - editNodeRadius: "editNodeRadius", - editNodeConfidence: "editNodeConfidence", - mergeSkeletons: "mergeSkeletons", - splitSkeletons: "splitSkeletons", -} as const; +// Neuroglancer event action identifier strings for all skeleton UI. +// These are the "skeleton-*" prefixed action names used in EventActionMap bindings +// and registerActionListener calls throughout the skeleton tab and edit tool. +// Default key bindings live in src/ui/default_input_event_bindings.ts. -export type SpatialSkeletonAction = - (typeof SpatialSkeletonActions)[keyof typeof SpatialSkeletonActions]; +// --- Tab navigation --- +export const SKELETON_GO_ROOT = "skeleton-go-root"; +export const SKELETON_GO_BRANCH_START = "skeleton-go-branch-start"; +export const SKELETON_GO_BRANCH_END = "skeleton-go-branch-end"; +export const SKELETON_GO_PARENT = "skeleton-go-parent"; +export const SKELETON_GO_CHILD = "skeleton-go-child"; +export const SKELETON_CYCLE_BRANCHES = "skeleton-cycle-branches"; +export const SKELETON_GO_UNFINISHED = "skeleton-go-unfinished-branch"; +export const SKELETON_UNDO = "skeleton-undo"; +export const SKELETON_REDO = "skeleton-redo"; -export const DEFAULT_SPATIAL_SKELETON_EDIT_ACTIONS = [ - SpatialSkeletonActions.addNodes, - SpatialSkeletonActions.moveNodes, - SpatialSkeletonActions.deleteNodes, -] as const satisfies readonly SpatialSkeletonAction[]; +// --- Node mutations (tab list focus + edit tool viewer focus) --- +export const SKELETON_TOGGLE_TRUE_END = "skeleton-toggle-true-end"; +export const SKELETON_REROOT = "skeleton-reroot"; -export function isSpatialSkeletonEditAction(action: SpatialSkeletonAction) { - return action !== SpatialSkeletonActions.inspect; -} - -export function getSpatialSkeletonActionSupportLabel( - action: SpatialSkeletonAction, -) { - switch (action) { - case SpatialSkeletonActions.inspect: - return "full skeleton inspection"; - case SpatialSkeletonActions.addNodes: - return "node creation"; - case SpatialSkeletonActions.insertNodes: - return "internal node insertion"; - case SpatialSkeletonActions.moveNodes: - return "node movement"; - case SpatialSkeletonActions.deleteNodes: - return "node deletion"; - case SpatialSkeletonActions.reroot: - return "skeleton rerooting"; - case SpatialSkeletonActions.editNodeDescription: - return "node description editing"; - case SpatialSkeletonActions.editNodeTrueEnd: - return "node true-end editing"; - case SpatialSkeletonActions.editNodeRadius: - return "node radius editing"; - case SpatialSkeletonActions.editNodeConfidence: - return "node confidence editing"; - case SpatialSkeletonActions.mergeSkeletons: - return "skeleton merging"; - case SpatialSkeletonActions.splitSkeletons: - return "skeleton splitting"; - } -} +// --- Edit tool spatial actions --- +export const SKELETON_ADD_NODE = "skeleton-add-node"; +// Merge (m): enters merge mode; click the anchor node, then the target node. +export const SKELETON_ENTER_MERGE_MODE = "skeleton-enter-merge-mode"; +// Split (s): enters split mode; click the node to split. +export const SKELETON_ENTER_SPLIT_MODE = "skeleton-enter-split-mode"; +export const SKELETON_ENTER_CREATE = "skeleton-enter-create"; +export const SKELETON_PIN_NODE = "skeleton-pin-node"; +export const SKELETON_DELETE_NODE = "skeleton-delete-node"; +export const SKELETON_CLEAR_SELECTION = "skeleton-clear-node-selection"; diff --git a/src/skeleton/command_factories.ts b/src/skeleton/command_factories.ts index f889601bc0..5803a85a61 100644 --- a/src/skeleton/command_factories.ts +++ b/src/skeleton/command_factories.ts @@ -17,9 +17,9 @@ import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; import { SpatialSkeletonActions, + type SpatialSkeletonCommand, type SpatialSkeletonAction, -} from "#src/skeleton/actions.js"; -import type { SpatialSkeletonCommand } from "#src/skeleton/command_history.js"; +} from "#src/skeleton/command_protocol.js"; export type SpatialSkeletonCommandPayload = object; diff --git a/src/skeleton/command_history.spec.ts b/src/skeleton/command_history.spec.ts index ef3d36aaaa..a7f2467d9e 100644 --- a/src/skeleton/command_history.spec.ts +++ b/src/skeleton/command_history.spec.ts @@ -16,10 +16,8 @@ import { describe, expect, it } from "vitest"; -import { - SpatialSkeletonCommandHistory, - type SpatialSkeletonCommand, -} from "#src/skeleton/command_history.js"; +import { SpatialSkeletonCommandHistory } from "#src/skeleton/command_history.js"; +import { type SpatialSkeletonCommand } from "#src/skeleton/command_protocol.js"; function deferred() { let resolve: (() => void) | undefined; diff --git a/src/skeleton/command_history.ts b/src/skeleton/command_history.ts index 4ca7093210..c81aa18013 100644 --- a/src/skeleton/command_history.ts +++ b/src/skeleton/command_history.ts @@ -14,22 +14,12 @@ * limitations under the License. */ +import type { SpatialSkeletonCommand } from "#src/skeleton/command_protocol.js"; import { WatchableValue } from "#src/trackable_value.js"; import { RefCounted } from "#src/util/disposable.js"; export const SPATIAL_SKELETON_COMMAND_HISTORY_MAX_ENTRIES = 100; -export interface SpatialSkeletonCommandContext { - readonly mappings: SpatialSkeletonCommandMappings; -} - -export interface SpatialSkeletonCommand { - readonly label: string; - execute(context: SpatialSkeletonCommandContext): Promise; - undo(context: SpatialSkeletonCommandContext): Promise; - redo?(context: SpatialSkeletonCommandContext): Promise; -} - interface SpatialSkeletonCommandMappingSnapshot { nodeIdMappings: Array<[number, number]>; segmentIdMappings: Array<[number, number]>; diff --git a/src/skeleton/command_protocol.ts b/src/skeleton/command_protocol.ts new file mode 100644 index 0000000000..bfa367a78a --- /dev/null +++ b/src/skeleton/command_protocol.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const SpatialSkeletonActions = { + inspect: "inspectSkeletons", + addNodes: "addNodes", + insertNodes: "insertNodes", + moveNodes: "moveNodes", + deleteNodes: "deleteNodes", + reroot: "rerootSkeletons", + editNodeDescription: "editNodeDescription", + editNodeTrueEnd: "editNodeTrueEnd", + editNodeRadius: "editNodeRadius", + editNodeConfidence: "editNodeConfidence", + mergeSkeletons: "mergeSkeletons", + splitSkeletons: "splitSkeletons", +} as const; + +export type SpatialSkeletonAction = + (typeof SpatialSkeletonActions)[keyof typeof SpatialSkeletonActions]; + +export const DEFAULT_SPATIAL_SKELETON_EDIT_ACTIONS = [ + SpatialSkeletonActions.addNodes, + SpatialSkeletonActions.moveNodes, + SpatialSkeletonActions.deleteNodes, +] as const satisfies readonly SpatialSkeletonAction[]; + +export function isSpatialSkeletonEditAction(action: SpatialSkeletonAction) { + return action !== SpatialSkeletonActions.inspect; +} + +export function getSpatialSkeletonActionSupportLabel( + action: SpatialSkeletonAction, +) { + switch (action) { + case SpatialSkeletonActions.inspect: + return "full skeleton inspection"; + case SpatialSkeletonActions.addNodes: + return "node creation"; + case SpatialSkeletonActions.insertNodes: + return "internal node insertion"; + case SpatialSkeletonActions.moveNodes: + return "node movement"; + case SpatialSkeletonActions.deleteNodes: + return "node deletion"; + case SpatialSkeletonActions.reroot: + return "skeleton rerooting"; + case SpatialSkeletonActions.editNodeDescription: + return "node description editing"; + case SpatialSkeletonActions.editNodeTrueEnd: + return "node true-end editing"; + case SpatialSkeletonActions.editNodeRadius: + return "node radius editing"; + case SpatialSkeletonActions.editNodeConfidence: + return "node confidence editing"; + case SpatialSkeletonActions.mergeSkeletons: + return "skeleton merging"; + case SpatialSkeletonActions.splitSkeletons: + return "skeleton splitting"; + } +} + +export interface SpatialSkeletonCommandContext { + readonly mappings: { + resolveNodeId(nodeId: number | undefined): number | undefined; + resolveSegmentId(segmentId: number | undefined): number | undefined; + getStableNodeId(nodeId: number | undefined): number | undefined; + getStableSegmentId(segmentId: number | undefined): number | undefined; + getStableOrCurrentNodeId(nodeId: number | undefined): number | undefined; + getStableOrCurrentSegmentId( + segmentId: number | undefined, + ): number | undefined; + remapNodeId( + originalNodeId: number | undefined, + currentNodeId: number, + ): boolean; + remapSegmentId( + originalSegmentId: number | undefined, + currentSegmentId: number, + ): boolean; + }; +} + +export interface SpatialSkeletonCommand { + readonly label: string; + execute(context: SpatialSkeletonCommandContext): Promise; + undo(context: SpatialSkeletonCommandContext): Promise; + redo?(context: SpatialSkeletonCommandContext): Promise; +} diff --git a/src/skeleton/spatial_skeleton_commands.spec.ts b/src/skeleton/commands.spec.ts similarity index 99% rename from src/skeleton/spatial_skeleton_commands.spec.ts rename to src/skeleton/commands.spec.ts index 26db38d875..9987713a12 100644 --- a/src/skeleton/spatial_skeleton_commands.spec.ts +++ b/src/skeleton/commands.spec.ts @@ -19,14 +19,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { makeCatmaidNodeSourceState } from "#src/datasource/catmaid/api.js"; import { buildCatmaidNeighborhoodEditContext } from "#src/datasource/catmaid/edit_state.js"; import { CatmaidSpatialSkeletonEditCommands } from "#src/datasource/catmaid/spatial_skeleton_commands.js"; -import { SpatialSkeletonActions } from "#src/skeleton/actions.js"; import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; import { SpatialSkeletonCommandHistory } from "#src/skeleton/command_history.js"; -import { - findSpatiallyIndexedSkeletonNode, - getSpatiallyIndexedSkeletonDirectChildren, - getSpatiallyIndexedSkeletonNodeParent, -} from "#src/skeleton/node_traversal.js"; +import { SpatialSkeletonActions } from "#src/skeleton/command_protocol.js"; import { executeSpatialSkeletonAddNode, executeSpatialSkeletonDeleteNode, @@ -41,7 +36,12 @@ import { executeSpatialSkeletonSplit, redoSpatialSkeletonCommand, undoSpatialSkeletonCommand, -} from "#src/skeleton/spatial_skeleton_commands.js"; +} from "#src/skeleton/commands.js"; +import { + findSpatiallyIndexedSkeletonNode, + getSpatiallyIndexedSkeletonDirectChildren, + getSpatiallyIndexedSkeletonNodeParent, +} from "#src/skeleton/node_traversal.js"; import { SpatialSkeletonState } from "#src/skeleton/spatial_skeleton_manager.js"; import { StatusMessage } from "#src/status.js"; diff --git a/src/skeleton/spatial_skeleton_commands.ts b/src/skeleton/commands.ts similarity index 98% rename from src/skeleton/spatial_skeleton_commands.ts rename to src/skeleton/commands.ts index 83731a5f09..73798994ef 100644 --- a/src/skeleton/spatial_skeleton_commands.ts +++ b/src/skeleton/commands.ts @@ -14,10 +14,6 @@ * limitations under the License. */ -import { - SpatialSkeletonActions, - type SpatialSkeletonAction, -} from "#src/skeleton/actions.js"; import type { EditableSpatiallyIndexedSkeletonSource, SpatiallyIndexedSkeletonNode, @@ -26,7 +22,11 @@ import type { SpatialSkeletonCommandPayload, SpatialSkeletonEditCommandFactory, } from "#src/skeleton/command_factories.js"; -import type { SpatialSkeletonCommand } from "#src/skeleton/command_history.js"; +import { + SpatialSkeletonActions, + type SpatialSkeletonAction, + type SpatialSkeletonCommand, +} from "#src/skeleton/command_protocol.js"; import { getSpatialSkeletonActionErrorMessage } from "#src/skeleton/edit_errors.js"; import { getEditableSpatiallyIndexedSkeletonSource, @@ -69,7 +69,7 @@ function executeCommand( return layer.spatialSkeletonState.commandHistory.execute(command); } -function executeCommandWithPendingMessage( +async function executeCommandWithPendingMessage( promise: Promise, message: string, ) { diff --git a/src/skeleton/spatial_skeleton_manager.spec.ts b/src/skeleton/spatial_skeleton_manager.spec.ts index 64b7b4f54e..02fa70e9c9 100644 --- a/src/skeleton/spatial_skeleton_manager.spec.ts +++ b/src/skeleton/spatial_skeleton_manager.spec.ts @@ -16,7 +16,7 @@ import { describe, expect, it, vi } from "vitest"; -import { SpatialSkeletonActions } from "#src/skeleton/actions.js"; +import { SpatialSkeletonActions } from "#src/skeleton/command_protocol.js"; import { buildSpatiallyIndexedSkeletonNavigationGraph, getFlatListNodeIds, diff --git a/src/skeleton/spatial_skeleton_manager.ts b/src/skeleton/spatial_skeleton_manager.ts index d8d1c65389..ba39996136 100644 --- a/src/skeleton/spatial_skeleton_manager.ts +++ b/src/skeleton/spatial_skeleton_manager.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import type { SpatialSkeletonAction } from "#src/skeleton/actions.js"; import type { EditableSpatiallyIndexedSkeletonSource, SpatialSkeletonConfidenceConfiguration, @@ -29,6 +28,7 @@ import { SPATIAL_SKELETON_EDIT_COMMAND_METADATA, } from "#src/skeleton/command_factories.js"; import { SpatialSkeletonCommandHistory } from "#src/skeleton/command_history.js"; +import type { SpatialSkeletonAction } from "#src/skeleton/command_protocol.js"; import type { SpatiallyIndexedSkeletonLayer } from "#src/skeleton/frontend.js"; import { WatchableValue } from "#src/trackable_value.js"; import { RefCounted } from "#src/util/disposable.js"; diff --git a/src/ui/default_input_event_bindings.ts b/src/ui/default_input_event_bindings.ts index 3a1fc8429f..066a2e200f 100644 --- a/src/ui/default_input_event_bindings.ts +++ b/src/ui/default_input_event_bindings.ts @@ -14,6 +14,26 @@ * limitations under the License. */ +import { + SKELETON_ADD_NODE, + SKELETON_CLEAR_SELECTION, + SKELETON_CYCLE_BRANCHES, + SKELETON_DELETE_NODE, + SKELETON_ENTER_CREATE, + SKELETON_ENTER_MERGE_MODE, + SKELETON_ENTER_SPLIT_MODE, + SKELETON_GO_BRANCH_END, + SKELETON_GO_BRANCH_START, + SKELETON_GO_CHILD, + SKELETON_GO_PARENT, + SKELETON_GO_ROOT, + SKELETON_GO_UNFINISHED, + SKELETON_PIN_NODE, + SKELETON_REDO, + SKELETON_REROOT, + SKELETON_TOGGLE_TRUE_END, + SKELETON_UNDO, +} from "#src/skeleton/actions.js"; import { EventActionMap } from "#src/util/event_action_map.js"; import type { InputEventBindings } from "#src/viewer.js"; @@ -187,6 +207,92 @@ export function getDefaultSliceViewPanelBindings() { return defaultSliceViewPanelBindings; } +let defaultSkeletonTabBindings: EventActionMap | undefined; +export function getDefaultSkeletonTabBindings() { + if (defaultSkeletonTabBindings === undefined) { + defaultSkeletonTabBindings = EventActionMap.fromObject( + { + keyr: SKELETON_GO_ROOT, + "shift+keyr": SKELETON_REROOT, + keyb: SKELETON_GO_BRANCH_END, + "control+keyb": SKELETON_GO_BRANCH_START, + bracketleft: SKELETON_GO_PARENT, + bracketright: SKELETON_GO_CHILD, + keyl: SKELETON_CYCLE_BRANCHES, + keyf: SKELETON_GO_UNFINISHED, + "control+keyz": { action: SKELETON_UNDO, preventDefault: true }, + "control+shift+keyz": { action: SKELETON_REDO, preventDefault: true }, + }, + { label: "Skeleton Tab" }, + ); + } + return defaultSkeletonTabBindings; +} + +let defaultSkeletonListBindings: EventActionMap | undefined; +export function getDefaultSkeletonListBindings() { + if (defaultSkeletonListBindings === undefined) { + defaultSkeletonListBindings = EventActionMap.fromObject({ + keyt: SKELETON_TOGGLE_TRUE_END, + "shift+keyr": SKELETON_REROOT, + }); + } + return defaultSkeletonListBindings; +} + +let defaultSkeletonEditToolBindings: EventActionMap | undefined; +export function getDefaultSkeletonEditToolBindings() { + if (defaultSkeletonEditToolBindings === undefined) { + defaultSkeletonEditToolBindings = EventActionMap.fromObject({ + "at:mousedown1": "rotate-via-mouse-drag", + "at:control+mousedown1": "translate-via-mouse-drag", + "at:shift+mousedown0": SKELETON_ADD_NODE, + "at:keym": SKELETON_ENTER_MERGE_MODE, + "at:keys": SKELETON_ENTER_SPLIT_MODE, + "at:keyn": SKELETON_ENTER_CREATE, + "at:control+mousedown2": { + action: SKELETON_PIN_NODE, + stopPropagation: true, + preventDefault: true, + }, + "at:control+alt+mousedown2": { + action: SKELETON_DELETE_NODE, + stopPropagation: true, + preventDefault: true, + }, + }); + } + return defaultSkeletonEditToolBindings; +} + +let defaultSkeletonEditAuxBindings: EventActionMap | undefined; +export function getDefaultSkeletonEditAuxBindings() { + if (defaultSkeletonEditAuxBindings === undefined) { + defaultSkeletonEditAuxBindings = EventActionMap.fromObject({ + "at:shift+control+mousedown2": { + action: SKELETON_CLEAR_SELECTION, + stopPropagation: true, + preventDefault: true, + }, + }); + } + return defaultSkeletonEditAuxBindings; +} + +let defaultSkeletonEditNodeBindings: EventActionMap | undefined; +export function getDefaultSkeletonEditNodeBindings() { + if (defaultSkeletonEditNodeBindings === undefined) { + defaultSkeletonEditNodeBindings = EventActionMap.fromObject( + { + keyt: SKELETON_TOGGLE_TRUE_END, + "shift+keyr": SKELETON_REROOT, + }, + { label: "Skeleton Edit (node)" }, + ); + } + return defaultSkeletonEditNodeBindings; +} + export function setDefaultInputEventBindings( inputEventBindings: InputEventBindings, ) { diff --git a/src/ui/skeleton_edit_tool_messages.ts b/src/ui/skeleton_edit_tool_messages.ts index b26cf19749..675d11e72b 100644 --- a/src/ui/skeleton_edit_tool_messages.ts +++ b/src/ui/skeleton_edit_tool_messages.ts @@ -41,10 +41,19 @@ export const SPATIAL_SKELETON_EDIT_SELECTED_BANNER_MESSAGE = "Move node or append to selected node"; export const SPATIAL_SKELETON_MERGE_BANNER_MESSAGE = "Select 2 nodes to merge"; export const SPATIAL_SKELETON_MERGE_SELECTED_BANNER_MESSAGE = - "Select 2nd node from a different skeleton to merge with"; + "Select 2nd node from a different skeleton to merge with · release m to exit"; export const SPATIAL_SKELETON_SPLIT_BANNER_MESSAGE = "Select 1 node to split"; export const SPATIAL_SKELETON_MOVING_NODE_MESSAGE = "Moving node"; +export const SPATIAL_SKELETON_DEFAULT_BANNER_MESSAGE = + "Click node to select · drag to move · hold m to merge · hold s to split · hold n for new skeleton · shift+click to create"; +export const SPATIAL_SKELETON_DEFAULT_SELECTED_BANNER_MESSAGE = + "Node selected · drag to move · shift+click to create · hold m to merge · hold s to split · hold n for new skeleton"; +export const SPATIAL_SKELETON_CREATE_BANNER_MESSAGE = + "Click to place a new skeleton · release n to exit"; +export const SPATIAL_SKELETON_HIDDEN_SELECTED_BANNER_MESSAGE = + "Node selected from hidden skeleton · double-click to show it before moving or creating"; + export function formatSpatialSkeletonToolPoint( point: SpatialSkeletonToolPointInfo, ) { @@ -100,8 +109,8 @@ export function getSpatialSkeletonEditBannerMessage( selectedPoint: SpatialSkeletonToolPointInfo | undefined, ) { return selectedPoint === undefined - ? SPATIAL_SKELETON_EDIT_BANNER_MESSAGE - : SPATIAL_SKELETON_EDIT_SELECTED_BANNER_MESSAGE; + ? SPATIAL_SKELETON_DEFAULT_BANNER_MESSAGE + : SPATIAL_SKELETON_DEFAULT_SELECTED_BANNER_MESSAGE; } export function getSpatialSkeletonMergeBannerMessage( diff --git a/src/ui/skeleton_edit_tools.css b/src/ui/skeleton_edit_tools.css index 5858f92b15..b9e362d08e 100644 --- a/src/ui/skeleton_edit_tools.css +++ b/src/ui/skeleton_edit_tools.css @@ -45,3 +45,52 @@ .neuroglancer-skeleton-tool-status-point-field-value { color: inherit; } + +/* Per-mode cursor indicators — driven by data-skeleton-edit-mode on the panel element */ + +/* Shift held: left-click will translate (perspective) or rotate (slice) via NG's + default shift+mousedown0 binding, not grab a skeleton node. */ +.neuroglancer-rendered-data-panel[data-skeleton-edit-mode="shift"] { + cursor: move; +} + +.neuroglancer-rendered-data-panel[data-skeleton-edit-mode="add"] { + cursor: + url("data:image/svg+xml,+") + 16 16, + crosshair; +} + +.neuroglancer-rendered-data-panel[data-skeleton-edit-mode="merge"] { + cursor: + url("data:image/svg+xml,M") + 16 16, + crosshair; +} + +.neuroglancer-rendered-data-panel[data-skeleton-edit-mode="create"] { + cursor: + url("data:image/svg+xml,N") + 16 16, + crosshair; +} + +.neuroglancer-rendered-data-panel[data-skeleton-edit-mode="split"] { + cursor: + url("data:image/svg+xml,S") + 16 16, + crosshair; +} + +/* Transient press-state cursors — driven by data-skeleton-press-mode on the panel element */ +.neuroglancer-rendered-data-panel[data-skeleton-press-mode="rotate"] { + cursor: move; +} + +.neuroglancer-rendered-data-panel[data-skeleton-press-mode="pan"] { + cursor: grab; +} + +.neuroglancer-rendered-data-panel[data-skeleton-press-mode="move"] { + cursor: grabbing; +} diff --git a/src/ui/skeleton_edit_tools.spec.ts b/src/ui/skeleton_edit_tools.spec.ts index 698b9c0596..83f7123392 100644 --- a/src/ui/skeleton_edit_tools.spec.ts +++ b/src/ui/skeleton_edit_tools.spec.ts @@ -18,16 +18,16 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { makeCatmaidNodeSourceState } from "#src/datasource/catmaid/api.js"; import { CatmaidSpatialSkeletonEditCommands } from "#src/datasource/catmaid/spatial_skeleton_commands.js"; +import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; +import { SpatialSkeletonCommandHistory } from "#src/skeleton/command_history.js"; import { SpatialSkeletonActions, type SpatialSkeletonAction, -} from "#src/skeleton/actions.js"; -import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; -import { SpatialSkeletonCommandHistory } from "#src/skeleton/command_history.js"; +} from "#src/skeleton/command_protocol.js"; import { executeSpatialSkeletonAddNode, executeSpatialSkeletonMerge, -} from "#src/skeleton/spatial_skeleton_commands.js"; +} from "#src/skeleton/commands.js"; import { StatusMessage } from "#src/status.js"; if (!("WebGL2RenderingContext" in globalThis)) { @@ -46,11 +46,9 @@ if (!("WebGL2RenderingContext" in globalThis)) { const { setSpatialSkeletonModesToLinesAndPoints, SkeletonRenderMode } = await import("#src/skeleton/frontend.js"); -const { SpatialSkeletonEditModeTool } = await import( +const { SpatialSkeletonEditTool } = await import( "#src/ui/skeleton_edit_tools.js" ); -const { SpatialSkeletonMergeModeTool, SpatialSkeletonSplitModeTool } = - await import("#src/ui/skeleton_edit_tools.js"); function makeVisibleSegmentsState(initialVisibleSegments: bigint[] = []) { return { @@ -155,26 +153,6 @@ function makeChangedSignal() { }; } -function makeManualChangedSignal() { - const listeners: Array<() => void> = []; - return { - add: vi.fn((listener: () => void) => { - listeners.push(listener); - return () => { - const index = listeners.indexOf(listener); - if (index !== -1) { - listeners.splice(index, 1); - } - }; - }), - dispatch() { - for (const listener of listeners.slice()) { - listener(); - } - }, - }; -} - function makeModeWatchable(value = false) { return { value }; } @@ -491,9 +469,8 @@ describe("spatial_skeleton_edit_tool", () => { }); it("blocks appending a child to a selected true-end node", () => { - const getAddNodeBlockedReason = ( - SpatialSkeletonEditModeTool.prototype as any - ).getAddNodeBlockedReason as ( + const getAddNodeBlockedReason = (SpatialSkeletonEditTool.prototype as any) + .getAddNodeBlockedReason as ( this: any, skeletonLayer: any, parentNodeId: number | undefined, @@ -515,9 +492,8 @@ describe("spatial_skeleton_edit_tool", () => { getCachedNode, }, }, - getSelectedParentNodeForAdd: ( - SpatialSkeletonEditModeTool.prototype as any - ).getSelectedParentNodeForAdd, + getSelectedParentNodeForAdd: (SpatialSkeletonEditTool.prototype as any) + .getSelectedParentNodeForAdd, }; expect(getAddNodeBlockedReason.call(tool, { getNode }, 17)).toBe( @@ -648,11 +624,10 @@ describe("spatial_skeleton_edit_tool", () => { ).toHaveBeenCalledWith([firstNode.position, secondNode.position]); }); - it("clears the merge anchor when the clear-selection action runs in merge mode", () => { + it("clears the merge anchor when the clear-selection action runs with an active merge anchor", () => { suppressStatusMessages(); - const bindClearSelectionAction = ( - SpatialSkeletonEditModeTool.prototype as any - ).bindClearSelectionAction as (this: any, activation: any) => void; + const bindClearSelectionAction = (SpatialSkeletonEditTool.prototype as any) + .bindClearSelectionAction as (this: any, activation: any) => void; const clearSpatialSkeletonNodeSelection = vi.fn(); const clearSpatialSkeletonMergeAnchor = vi.fn(); const unpin = vi.fn(); @@ -703,13 +678,13 @@ describe("spatial_skeleton_edit_tool", () => { expect(unpin).not.toHaveBeenCalled(); }); - it("uses an existing selected node as the merge anchor when merge mode activates", () => { + it("enters merge mode from the hovered node when the merge action fires", () => { suppressStatusMessages(); - const selectedNode = { + const hoveredNode = { nodeId: 101, segmentId: 11, position: new Float32Array([1, 2, 3]), - sourceState: testSourceState("selected-before"), + sourceState: testSourceState("hovered"), }; const mergeAnchorNodeId = { value: undefined as number | undefined, @@ -724,12 +699,22 @@ describe("spatial_skeleton_edit_tool", () => { mergeAnchorNodeId.value = undefined; return true; }); - const clearSpatialSkeletonNodeSelection = vi.fn(); const skeletonLayer = { getNode: vi.fn((nodeId: number) => - nodeId === selectedNode.nodeId ? selectedNode : undefined, + nodeId === hoveredNode.nodeId ? hoveredNode : undefined, ), }; + const mouseState = { + pickedRenderLayer: undefined, + pickedSpatialSkeleton: { + nodeId: hoveredNode.nodeId, + segmentId: hoveredNode.segmentId, + position: hoveredNode.position, + sourceState: hoveredNode.sourceState, + }, + updateUnconditionally: vi.fn(() => true), + active: true, + }; const layer = { displayState: { ...makeSkeletonRenderingOptions(), @@ -737,241 +722,213 @@ describe("spatial_skeleton_edit_tool", () => { value: makeVisibleSegmentsState([11n]), }, }, + spatialSkeletonEditMode: makeModeWatchable(), spatialSkeletonMergeMode: makeModeWatchable(), selectedSpatialSkeletonNodeInfo: { - value: selectedNode, + value: undefined, changed: makeChangedSignal(), }, spatialSkeletonState: { mergeAnchorNodeId, getCachedNode: vi.fn(), + commandHistory: new SpatialSkeletonCommandHistory(), + clearPendingNodePositions: vi.fn(), }, manager: { root: { - layerSelectedValues: { - mouseState: { - pickedRenderLayer: undefined, - updateUnconditionally: vi.fn(() => true), - active: true, - }, - }, - selectionState: { - value: undefined, - }, + layerSelectedValues: { mouseState }, + selectionState: { value: undefined, changed: makeChangedSignal() }, + display: { panels: [] }, }, }, getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, getSpatialSkeletonActionsDisabledReason: vi.fn(() => undefined), + selectSegment: vi.fn(), selectSpatialSkeletonNode, setSpatialSkeletonMergeAnchor, clearSpatialSkeletonMergeAnchor, - clearSpatialSkeletonNodeSelection, + clearSpatialSkeletonNodeSelection: vi.fn(), layersChanged: makeChangedSignal(), }; - const { activation, dispose } = makeToolActivation(); + const { activation, actions, dispose } = makeToolActivation(); const tool = Object.assign( - Object.create(SpatialSkeletonMergeModeTool.prototype), + Object.create(SpatialSkeletonEditTool.prototype), { layer }, ); try { - SpatialSkeletonMergeModeTool.prototype.activate.call( - tool, - activation as any, - ); + SpatialSkeletonEditTool.prototype.activate.call(tool, activation as any); + + // Fire the merge action (simulates pressing "m" while hovering node 101). + actions.get("spatial-skeleton-enter-merge")?.({}); expect(selectSpatialSkeletonNode).toHaveBeenCalledWith( - selectedNode.nodeId, + hoveredNode.nodeId, true, - selectedNode, + expect.objectContaining({ nodeId: hoveredNode.nodeId }), ); expect(setSpatialSkeletonMergeAnchor).toHaveBeenCalledWith( - selectedNode.nodeId, + hoveredNode.nodeId, ); - expect(clearSpatialSkeletonNodeSelection).not.toHaveBeenCalled(); + expect(layer.spatialSkeletonMergeMode.value).toBe(true); } finally { dispose(); } }); - it("clears the merge anchor when a pick clears the selected node", () => { + it("executes a split on the hovered node when the split action fires", () => { suppressStatusMessages(); - const selectedNode = { - nodeId: 101, + const hoveredNode = { + nodeId: 77, segmentId: 11, - position: new Float32Array([1, 2, 3]), - sourceState: testSourceState("selected-before"), - }; - const selectedNodeChanged = makeManualChangedSignal(); - const mergeAnchorNodeId = { - value: undefined as number | undefined, - changed: makeChangedSignal(), + position: new Float32Array([7, 8, 9]), + sourceState: testSourceState("hovered"), }; - const selectSegment = vi.fn(); - const setSpatialSkeletonMergeAnchor = vi.fn((nodeId: number) => { - mergeAnchorNodeId.value = nodeId; - return true; - }); - const clearSpatialSkeletonMergeAnchor = vi.fn(() => { - mergeAnchorNodeId.value = undefined; - return true; - }); + const splitExecute = vi.fn(async () => {}); + const splitSkeletonsCommand = makeCommandFactory( + SpatialSkeletonActions.splitSkeletons, + splitExecute, + ); const skeletonLayer = { + source: makeCommandSkeletonSource({ splitSkeletonsCommand }), getNode: vi.fn((nodeId: number) => - nodeId === selectedNode.nodeId ? selectedNode : undefined, + nodeId === hoveredNode.nodeId ? hoveredNode : undefined, ), }; const mouseState = { pickedRenderLayer: undefined, - pickedSpatialSkeleton: { segmentId: 17 }, + pickedSpatialSkeleton: { + nodeId: hoveredNode.nodeId, + segmentId: hoveredNode.segmentId, + position: hoveredNode.position, + sourceState: hoveredNode.sourceState, + }, updateUnconditionally: vi.fn(() => true), active: true, }; + const selectSegment = vi.fn(); + const selectSpatialSkeletonNode = vi.fn(); const layer = { displayState: { ...makeSkeletonRenderingOptions(), segmentationGroupState: { - value: makeVisibleSegmentsState([11n, 17n]), + value: makeVisibleSegmentsState([11n]), }, }, + spatialSkeletonEditMode: makeModeWatchable(), spatialSkeletonMergeMode: makeModeWatchable(), selectedSpatialSkeletonNodeInfo: { - value: selectedNode as typeof selectedNode | undefined, - changed: selectedNodeChanged, + value: undefined, + changed: makeChangedSignal(), }, spatialSkeletonState: { - mergeAnchorNodeId, + commandHistory: new SpatialSkeletonCommandHistory(), getCachedNode: vi.fn(), + mergeAnchorNodeId: { value: undefined, changed: makeChangedSignal() }, + clearPendingNodePositions: vi.fn(), }, manager: { root: { - layerSelectedValues: { - mouseState, - }, - selectionState: { - value: undefined, - }, + layerSelectedValues: { mouseState }, + selectionState: { value: undefined, changed: makeChangedSignal() }, + display: { panels: [] }, }, }, getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, getSpatialSkeletonActionsDisabledReason: vi.fn(() => undefined), selectSegment, - selectSpatialSkeletonNode: vi.fn(), - setSpatialSkeletonMergeAnchor, - clearSpatialSkeletonMergeAnchor, - clearSpatialSkeletonNodeSelection: vi.fn(), + selectSpatialSkeletonNode, layersChanged: makeChangedSignal(), }; const { activation, actions, dispose } = makeToolActivation(); const tool = Object.assign( - Object.create(SpatialSkeletonMergeModeTool.prototype), + Object.create(SpatialSkeletonEditTool.prototype), { layer }, ); try { - SpatialSkeletonMergeModeTool.prototype.activate.call( - tool, - activation as any, + SpatialSkeletonEditTool.prototype.activate.call(tool, activation as any); + + // Fire the split action (simulates pressing "s" while hovering node 77). + actions.get("spatial-skeleton-split")?.({}); + + expect(selectSegment).toHaveBeenCalledWith(11n, true); + expect(selectSpatialSkeletonNode).toHaveBeenCalledWith( + hoveredNode.nodeId, + true, + expect.objectContaining({ nodeId: hoveredNode.nodeId }), ); - clearSpatialSkeletonMergeAnchor.mockClear(); - - actions.get("spatial-skeleton-pick-node")?.({ - detail: { - button: 2, - ctrlKey: true, - shiftKey: false, - altKey: false, - metaKey: false, - }, + expect(splitSkeletonsCommand.createCommand).toHaveBeenCalledWith(layer, { + nodeId: hoveredNode.nodeId, + segmentId: hoveredNode.segmentId, }); - layer.selectedSpatialSkeletonNodeInfo.value = undefined; - selectedNodeChanged.dispatch(); - - expect(selectSegment).toHaveBeenCalledWith(17n, true); - expect(clearSpatialSkeletonMergeAnchor).toHaveBeenCalledTimes(1); - expect(mergeAnchorNodeId.value).toBeUndefined(); + expect(splitExecute).toHaveBeenCalledTimes(1); } finally { dispose(); } }); - it("splits the existing selected node immediately when split mode activates", () => { + it("errors when ctrl+click has no selected parent node", () => { suppressStatusMessages(); - const selectedNode = { - nodeId: 77, - segmentId: 11, - position: new Float32Array([7, 8, 9]), - sourceState: testSourceState("selected-before"), - }; - const splitExecute = vi.fn(async () => {}); - const splitSkeletonsCommand = makeCommandFactory( - SpatialSkeletonActions.splitSkeletons, - splitExecute, - ); const skeletonLayer = { - source: makeCommandSkeletonSource({ splitSkeletonsCommand }), - getNode: vi.fn((nodeId: number) => - nodeId === selectedNode.nodeId ? selectedNode : undefined, - ), + getNode: vi.fn(), + }; + const mouseState = { + pickedRenderLayer: undefined, + pickedSpatialSkeleton: undefined, + updateUnconditionally: vi.fn(() => true), + active: true, + unsnappedPosition: new Float32Array([1, 2, 3]), }; - const selectSegment = vi.fn(); - const selectSpatialSkeletonNode = vi.fn(); const layer = { displayState: { ...makeSkeletonRenderingOptions(), segmentationGroupState: { - value: makeVisibleSegmentsState([11n]), + value: makeVisibleSegmentsState(), }, }, - spatialSkeletonSplitMode: makeModeWatchable(), - selectedSpatialSkeletonNodeInfo: { value: selectedNode }, + spatialSkeletonEditMode: makeModeWatchable(), + spatialSkeletonMergeMode: makeModeWatchable(), + selectedSpatialSkeletonNodeInfo: { + value: undefined, // No node selected. + changed: makeChangedSignal(), + }, spatialSkeletonState: { commandHistory: new SpatialSkeletonCommandHistory(), getCachedNode: vi.fn(), + mergeAnchorNodeId: { value: undefined, changed: makeChangedSignal() }, + clearPendingNodePositions: vi.fn(), }, manager: { root: { - layerSelectedValues: { - mouseState: { - pickedRenderLayer: undefined, - updateUnconditionally: vi.fn(() => true), - active: true, - }, - }, - selectionState: { - value: undefined, - }, + layerSelectedValues: { mouseState }, + selectionState: { value: undefined, changed: makeChangedSignal() }, + display: { panels: [] }, }, }, getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, getSpatialSkeletonActionsDisabledReason: vi.fn(() => undefined), - selectSegment, - selectSpatialSkeletonNode, + selectSegment: vi.fn(), + selectSpatialSkeletonNode: vi.fn(), layersChanged: makeChangedSignal(), }; - const { activation, dispose } = makeToolActivation(); + const { activation, actions, dispose } = makeToolActivation(); const tool = Object.assign( - Object.create(SpatialSkeletonSplitModeTool.prototype), + Object.create(SpatialSkeletonEditTool.prototype), { layer }, ); try { - SpatialSkeletonSplitModeTool.prototype.activate.call( - tool, - activation as any, - ); + SpatialSkeletonEditTool.prototype.activate.call(tool, activation as any); - expect(selectSegment).toHaveBeenCalledWith(11n, true); - expect(selectSpatialSkeletonNode).toHaveBeenCalledWith( - selectedNode.nodeId, - true, - selectedNode, - ); - expect(splitSkeletonsCommand.createCommand).toHaveBeenCalledWith(layer, { - nodeId: selectedNode.nodeId, - segmentId: selectedNode.segmentId, + actions.get("spatial-skeleton-add-node")?.({ + stopPropagation: vi.fn(), + detail: { preventDefault: vi.fn() }, }); - expect(splitExecute).toHaveBeenCalledTimes(1); + + expect(StatusMessage.showTemporaryMessage).toHaveBeenCalledWith( + expect.stringContaining("Select a node first"), + ); } finally { dispose(); } diff --git a/src/ui/skeleton_edit_tools.ts b/src/ui/skeleton_edit_tools.ts index a0dbe861c8..b996f11eb6 100644 --- a/src/ui/skeleton_edit_tools.ts +++ b/src/ui/skeleton_edit_tools.ts @@ -21,14 +21,35 @@ import { getSegmentIdFromLayerSelectionValue, hasSpatialSkeletonNodeSelection, } from "#src/layer/segmentation/selection.js"; +import { PerspectivePanel } from "#src/perspective_view/panel.js"; import { getChunkPositionFromCombinedGlobalLocalPositions } from "#src/render_coordinate_transform.js"; import { RenderedDataPanel } from "#src/rendered_data_panel.js"; import { getVisibleSegments } from "#src/segmentation_display_state/base.js"; -import { SpatialSkeletonActions } from "#src/skeleton/actions.js"; +import { + SKELETON_ADD_NODE, + SKELETON_CLEAR_SELECTION, + SKELETON_DELETE_NODE, + SKELETON_ENTER_CREATE, + SKELETON_ENTER_MERGE_MODE, + SKELETON_ENTER_SPLIT_MODE, + SKELETON_PIN_NODE, + SKELETON_REROOT, + SKELETON_TOGGLE_TRUE_END, +} from "#src/skeleton/actions.js"; import type { SpatialSkeletonSourceState, SpatialSkeletonVector, } from "#src/skeleton/api.js"; +import { SpatialSkeletonActions } from "#src/skeleton/command_protocol.js"; +import { + executeSpatialSkeletonAddNode, + executeSpatialSkeletonDeleteNode, + executeSpatialSkeletonMerge, + executeSpatialSkeletonMoveNode, + executeSpatialSkeletonNodeTrueEndUpdate, + executeSpatialSkeletonSplit, + showSpatialSkeletonActionError, +} from "#src/skeleton/commands.js"; import { type SpatiallyIndexedSkeletonLayer, setSpatialSkeletonModesToLinesAndPoints, @@ -37,23 +58,20 @@ import { PerspectiveViewSpatiallyIndexedSkeletonLayer, SliceViewPanelSpatiallyIndexedSkeletonLayer, } from "#src/skeleton/frontend.js"; -import { - executeSpatialSkeletonAddNode, - executeSpatialSkeletonDeleteNode, - executeSpatialSkeletonMerge, - executeSpatialSkeletonMoveNode, - executeSpatialSkeletonSplit, - showSpatialSkeletonActionError, -} from "#src/skeleton/spatial_skeleton_commands.js"; import { StatusMessage } from "#src/status.js"; +import { + getDefaultSkeletonEditAuxBindings, + getDefaultSkeletonEditNodeBindings, + getDefaultSkeletonEditToolBindings, +} from "#src/ui/default_input_event_bindings.js"; import type { SpatialSkeletonToolPointInfo } from "#src/ui/skeleton_edit_tool_messages.js"; import { - SPATIAL_SKELETON_SPLIT_BANNER_MESSAGE, - getSpatialSkeletonEditBannerMessage, - SPATIAL_SKELETON_MERGE_BANNER_MESSAGE, + SPATIAL_SKELETON_CREATE_BANNER_MESSAGE, + SPATIAL_SKELETON_HIDDEN_SELECTED_BANNER_MESSAGE, SPATIAL_SKELETON_MERGE_SELECTED_BANNER_MESSAGE, - getSpatialSkeletonToolPointStatusFields, SPATIAL_SKELETON_MOVING_NODE_MESSAGE, + getSpatialSkeletonEditBannerMessage, + getSpatialSkeletonToolPointStatusFields, } from "#src/ui/skeleton_edit_tool_messages.js"; import type { ToolActivation } from "#src/ui/tool.js"; import { @@ -63,47 +81,34 @@ import { } from "#src/ui/tool.js"; import { removeChildren } from "#src/util/dom.js"; import type { ActionEvent } from "#src/util/event_action_map.js"; -import { EventActionMap } from "#src/util/event_action_map.js"; import { vec3 } from "#src/util/geom.js"; import { startRelativeMouseDrag } from "#src/util/mouse_drag.js"; export const SPATIAL_SKELETON_EDIT_MODE_TOOL_ID = "spatialSkeletonEditMode"; -export const SPATIAL_SKELETON_MERGE_MODE_TOOL_ID = "spatialSkeletonMergeMode"; -export const SPATIAL_SKELETON_SPLIT_MODE_TOOL_ID = "spatialSkeletonSplitMode"; - -const SKELETON_EDIT_STATUS_INPUT_EVENT_MAP = EventActionMap.fromObject({ - // Only expose the primary edit actions in the auto-generated subtitle. - "at:control+mousedown0": "spatial-skeleton-add-node", - "at:alt+mousedown0": "spatial-skeleton-move-node", - "at:control+mousedown2": { - action: "spatial-skeleton-pin-node", - stopPropagation: true, - preventDefault: true, - }, - "at:control+alt+mousedown2": { - action: "spatial-skeleton-delete-node", - stopPropagation: true, - preventDefault: true, - }, -}); -const SPATIAL_SKELETON_AUX_INPUT_EVENT_MAP = EventActionMap.fromObject({ - "at:shift+control+mousedown2": { - action: "spatial-skeleton-clear-node-selection", - stopPropagation: true, - preventDefault: true, - }, -}); +// Internal mode enum for sustained editing states. +// Move and Select are both handled in Default. +const enum SkeletonEditMode { + Default = 0, + Merge = 1, + Create = 2, + Split = 3, +} -const SPATIAL_SKELETON_PICK_INPUT_EVENT_MAP = EventActionMap.fromObject({ - "at:control+mousedown2": { - action: "spatial-skeleton-pick-node", - stopPropagation: true, - preventDefault: true, - }, -}); +// In edit mode, left click is selection-only — it never rotates or pans. +// Navigation (rotate in perspective, pan in slice) is handled exclusively by +// middle mouse (mousedown1). mousedown0 is therefore handled only via the +// capture-phase DOM listeners in activate(); it is not in the EventActionMap. +// +// mousedown1 → rotate-via-mouse-drag covers perspective panels via the +// EventActionMap. Slice panels intercept middle mouse in the capture listener +// and call translateByViewportPixels directly, consuming the event before +// MouseEventBinder can dispatch this action. +// +// Default bindings are defined in getDefaultSkeletonEditToolBindings() / +// getDefaultSkeletonEditAuxBindings() in default_input_event_bindings.ts. -const DRAG_START_DISTANCE_PX = 4; +const DRAG_START_DISTANCE_PX = 2; function waitForNextAnimationFrame() { return new Promise((resolve) => { @@ -339,7 +344,7 @@ abstract class SpatialSkeletonToolBase extends LayerTool ) { const { showNodeSelectionMessage = true } = options; activation.bindAction( - "spatial-skeleton-pin-node", + SKELETON_PIN_NODE, (event: ActionEvent) => { event.stopPropagation(); event.detail.preventDefault(); @@ -376,7 +381,7 @@ abstract class SpatialSkeletonToolBase extends LayerTool protected bindClearSelectionAction(activation: ToolActivation) { activation.bindAction( - "spatial-skeleton-clear-node-selection", + SKELETON_CLEAR_SELECTION, (event: ActionEvent) => { event.stopPropagation(); event.detail.preventDefault(); @@ -413,58 +418,9 @@ abstract class SpatialSkeletonToolBase extends LayerTool modeWatchable.value = false; }); } - - protected registerAutoCancelOnDisabled( - activation: ToolActivation, - requiredActions: Parameters< - SegmentationUserLayer["getSpatialSkeletonActionsDisabledReason"] - >[0], - onReady?: () => void, - ) { - const handleStateChanged = () => { - const disabledReason = this.layer.getSpatialSkeletonActionsDisabledReason( - requiredActions, - { - ignoreCommandBusy: true, - }, - ); - if (disabledReason === undefined) { - onReady?.(); - return; - } - StatusMessage.showTemporaryMessage(disabledReason); - activation.cancel(); - }; - activation.registerDisposer( - this.layer.layersChanged.add(handleStateChanged), - ); - } - - protected cancelActivationIfPreconditionsFail( - activation: ToolActivation, - requiredAction: Parameters< - SegmentationUserLayer["getSpatialSkeletonActionsDisabledReason"] - >[0], - ): boolean { - const reason = - this.layer.getSpatialSkeletonActionsDisabledReason(requiredAction); - if (reason !== undefined) { - StatusMessage.showTemporaryMessage(reason); - queueMicrotask(() => activation.cancel()); - return false; - } - if (this.getActiveSpatiallyIndexedSkeletonLayer() === undefined) { - StatusMessage.showTemporaryMessage( - "No spatially indexed skeleton source is currently loaded.", - ); - queueMicrotask(() => activation.cancel()); - return false; - } - return true; - } } -export class SpatialSkeletonEditModeTool extends SpatialSkeletonToolBase { +export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { toJSON() { return SPATIAL_SKELETON_EDIT_MODE_TOOL_ID; } @@ -473,19 +429,13 @@ export class SpatialSkeletonEditModeTool extends SpatialSkeletonToolBase { return "Skeleton edit"; } + // Persistent coordinate-transform fields — created once, never reassigned. private curChunkRank = -1; private tempChunkPosition = new Float32Array(0); private readonly dragModelSpacePosition = vec3.create(); private readonly dragGlobalAnchorPosition = vec3.create(); private readonly dragGlobalPosition = vec3.create(); - // TODO (skm): really we can't handle a rank change right now - // and heavily assume rank 3. This is likely mostly fine - // but need to test a little more how it works if embedded in - // higher dim spaces or alongside images with a t dim / channel dim - // can also possibly remove this and just set tempChunkPosition - // to be vec3 instead of Float32Array - // will verify and clean up private handleRankChanged(rank: number) { if (rank === this.curChunkRank) return; this.curChunkRank = rank; @@ -555,829 +505,1052 @@ export class SpatialSkeletonEditModeTool extends SpatialSkeletonToolBase { return undefined; } - private getRenderedDataPanelForEvent( - event: MouseEvent, - ): RenderedDataPanel | undefined { - const display = this.layer.manager.root.display; - const target = event.target; - if (target instanceof Node) { - for (const panel of display.panels) { - if (!(panel instanceof RenderedDataPanel)) continue; - if (panel.element.contains(target)) { - return panel; - } - } - } - const clientX = event.clientX; - const clientY = event.clientY; + // Activation-scoped state — reset at the start of each activate() call. + private currentMode: SkeletonEditMode = SkeletonEditMode.Default; + private dragInProgress = false; + private pending = false; + private createPlacedThisHold = false; + // One-shot guards: prevent repeated fires while a key is held down. + private mergeKeyHeld = false; + private splitKeyHeld = false; + // Modifier-held state drives cursor indicators and blocks node actions. + private shiftHeld = false; + private statusOverride: string | undefined = undefined; + private statusPoint: SpatialSkeletonToolPointInfo | undefined = undefined; + // Set at activation start; cleared by the activation disposer to prevent + // post-deactivation UI writes. + private statusBody: HTMLElement | undefined = undefined; + + // --- Cursor helpers --- + + private setModeAttribute(mode: string | undefined) { + const { display } = this.layer.manager.root; for (const panel of display.panels) { if (!(panel instanceof RenderedDataPanel)) continue; - const rect = panel.element.getBoundingClientRect(); - if ( - clientX >= rect.left && - clientX <= rect.right && - clientY >= rect.top && - clientY <= rect.bottom - ) { - return panel; + if (mode === undefined) { + delete panel.element.dataset.skeletonEditMode; + } else { + panel.element.dataset.skeletonEditMode = mode; } } - return undefined; } - activate(activation: ToolActivation) { - const { layer } = this; - const rawInputEventMapBinder = activation.inputEventMapBinder; - const { body, header } = - makeToolActivationStatusMessageWithHeader(activation); - header.textContent = "Skeleton edit"; - let statusOverride: string | undefined; - let cachedNodeSummary: - | ReturnType - | undefined; - const clearCachedNodeSummary = () => { - cachedNodeSummary = undefined; - }; - const renderStatus = () => { - const selectedPoint = - cachedNodeSummary ?? this.getSelectedSpatialSkeletonNodeSummary(); + // Recomputes the correct data-skeleton-edit-mode attribute from current + // mode + held modifiers so callers don't have to care about that interaction. + // Priority: sustained tool modes > shift (add cursor hint). + private updateModeAttribute() { + if (this.currentMode === SkeletonEditMode.Merge) { + this.setModeAttribute("merge"); + } else if (this.currentMode === SkeletonEditMode.Create) { + this.setModeAttribute("create"); + } else if (this.currentMode === SkeletonEditMode.Split) { + this.setModeAttribute("split"); + } else if (this.shiftHeld) { + this.setModeAttribute("add"); + } else { + this.setModeAttribute(undefined); + } + } + + // --- Status rendering --- + + private renderStatus() { + if (this.statusBody === undefined) return; + const body = this.statusBody; + if (this.statusOverride !== undefined) { renderSpatialSkeletonToolStatus(body, { - message: - statusOverride ?? getSpatialSkeletonEditBannerMessage(selectedPoint), - point: selectedPoint, + message: this.statusOverride, + point: this.statusPoint, }); - }; - const setStatus = (nextStatus: string | undefined) => { - statusOverride = nextStatus; - renderStatus(); - }; - const setReadyStatus = () => { - setStatus(undefined); - }; + return; + } + if (this.currentMode === SkeletonEditMode.Merge) { + const anchorNodeId = + this.layer.spatialSkeletonState.mergeAnchorNodeId.value; + if (anchorNodeId !== undefined) { + const cachedNode = + this.getActiveSpatiallyIndexedSkeletonLayer()?.getNode( + anchorNodeId, + ) ?? this.layer.spatialSkeletonState.getCachedNode(anchorNodeId); + const point: SpatialSkeletonToolPointInfo = { + nodeId: anchorNodeId, + segmentId: cachedNode?.segmentId, + position: cachedNode?.position, + }; + if ( + cachedNode?.segmentId !== undefined && + !this.isSpatialSkeletonSegmentVisible(cachedNode.segmentId) + ) { + renderSpatialSkeletonToolStatus(body, { + message: + "Make this segment visible, then select a 2nd node to merge with · release m to exit", + point, + }); + } else { + renderSpatialSkeletonToolStatus(body, { + message: SPATIAL_SKELETON_MERGE_SELECTED_BANNER_MESSAGE, + point, + }); + } + } else { + renderSpatialSkeletonToolStatus(body, { + message: "Click a node to set as merge anchor · release m to exit", + }); + } + return; + } + if (this.currentMode === SkeletonEditMode.Split) { + renderSpatialSkeletonToolStatus(body, { + message: "Click a node to split · release s to exit", + }); + return; + } + if (this.currentMode === SkeletonEditMode.Create) { + renderSpatialSkeletonToolStatus(body, { + message: SPATIAL_SKELETON_CREATE_BANNER_MESSAGE, + }); + return; + } + // Default mode + const selectedPoint = this.getSelectedSpatialSkeletonNodeSummary(); + const isHidden = + selectedPoint?.segmentId !== undefined && + !this.isSpatialSkeletonSegmentVisible(selectedPoint.segmentId); + renderSpatialSkeletonToolStatus(body, { + message: isHidden + ? SPATIAL_SKELETON_HIDDEN_SELECTED_BANNER_MESSAGE + : getSpatialSkeletonEditBannerMessage(selectedPoint), + point: selectedPoint, + }); + } - const disableWithMessage = (message: string) => { - setStatus(message); - StatusMessage.showTemporaryMessage(message); - queueMicrotask(() => activation.cancel()); - }; + private setStatus( + message: string | undefined, + point?: SpatialSkeletonToolPointInfo, + ) { + this.statusOverride = message; + this.statusPoint = point; + this.renderStatus(); + } - const getEditSupportDisabledReason = () => - layer.getSpatialSkeletonActionsDisabledReason( - [SpatialSkeletonActions.addNodes, SpatialSkeletonActions.moveNodes], - { - ignoreCommandBusy: true, - requireVisibleChunks: false, - }, - ); - const getEditMutationDisabledReason = () => - layer.getSpatialSkeletonActionsDisabledReason([ - SpatialSkeletonActions.addNodes, - SpatialSkeletonActions.moveNodes, - ]); - const updateInteractionStatus = () => { - const reason = getEditMutationDisabledReason(); - if (reason === undefined) { - setReadyStatus(); - return undefined; + private clearStatus() { + this.setStatus(undefined, undefined); + } + + // --- Modifier tracking --- + + // Sync shiftHeld from the logical modifier flag on any event that carries it. + // This mirrors what NG's EventActionMap does via getEventModifierMask, so + // OS-level modifier rebindings are transparent — we never inspect key codes. + private syncModifiers(event: { shiftKey: boolean }) { + const isShift = event.shiftKey; + if (this.shiftHeld === isShift) return; + this.shiftHeld = isShift; + this.updateModeAttribute(); + } + + // --- Mode transitions --- + + private enterMerge(anchorNode?: { + nodeId: number; + segmentId?: number; + position?: SpatialSkeletonVector; + sourceState?: SpatialSkeletonSourceState; + }) { + if (anchorNode !== undefined) { + if (anchorNode.segmentId !== undefined) { + this.pinSegmentByNumber(anchorNode.segmentId); } - const message = `${reason} Node selection is still available.`; - setStatus(message); - return reason; - }; + this.layer.selectSpatialSkeletonNode(anchorNode.nodeId, true, anchorNode); + this.layer.setSpatialSkeletonMergeAnchor(anchorNode.nodeId); + } + this.layer.spatialSkeletonMergeMode.value = true; + this.currentMode = SkeletonEditMode.Merge; + this.updateModeAttribute(); + this.renderStatus(); + } - const disabledReason = getEditSupportDisabledReason(); - if (disabledReason !== undefined) { - disableWithMessage(disabledReason); + private exitMerge() { + if (this.currentMode !== SkeletonEditMode.Merge) return; + this.layer.clearSpatialSkeletonMergeAnchor(); + this.layer.spatialSkeletonMergeMode.value = false; + this.currentMode = SkeletonEditMode.Default; + this.updateModeAttribute(); + this.clearStatus(); + } + + private enterCreate() { + this.currentMode = SkeletonEditMode.Create; + this.createPlacedThisHold = false; + this.updateModeAttribute(); + this.renderStatus(); + } + + private exitCreate() { + if (this.currentMode !== SkeletonEditMode.Create) return; + this.currentMode = SkeletonEditMode.Default; + this.createPlacedThisHold = false; + this.updateModeAttribute(); + this.clearStatus(); + } + + private enterSplit() { + this.currentMode = SkeletonEditMode.Split; + this.layer.spatialSkeletonSplitMode.value = true; + this.updateModeAttribute(); + this.renderStatus(); + } + + private exitSplit() { + if (this.currentMode !== SkeletonEditMode.Split) return; + this.currentMode = SkeletonEditMode.Default; + this.layer.spatialSkeletonSplitMode.value = false; + this.updateModeAttribute(); + this.clearStatus(); + } + + // --- Mouse handlers --- + + private handleDefaultMousedown(event: MouseEvent, panel: RenderedDataPanel) { + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + const pickedNode = skeletonLayer + ? this.getPickedSpatialSkeletonNode() + : undefined; + + if (pickedNode === undefined) { + // Off-node left click: consume so NG's rotate/pan actions don't fire. + // Navigation is handled exclusively by middle mouse in edit mode. + event.stopPropagation(); + event.preventDefault(); return; } - if (this.getActiveSpatiallyIndexedSkeletonLayer() === undefined) { - disableWithMessage( - "No spatially indexed skeleton source is currently loaded.", - ); - return; + + // On a node: consume the event so NG doesn't also start a rotate/pan. + event.stopPropagation(); + event.preventDefault(); + if (skeletonLayer === undefined) return; + + const canMove = + this.layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.moveNodes, + ) === undefined; + const nodeInfo = canMove + ? skeletonLayer.getNode(pickedNode.nodeId) + : undefined; + + const pickedPosition = this.mouseState.position; + const hasPickedPosition = + pickedPosition.length >= 3 && + Number.isFinite(pickedPosition[0]) && + Number.isFinite(pickedPosition[1]) && + Number.isFinite(pickedPosition[2]); + + // Select immediately on mousedown so it always happens even if the drag + // finish callback never fires (e.g. pointer capture lost). + if (pickedNode.segmentId !== undefined) { + this.pinSegmentByNumber(pickedNode.segmentId); } + this.layer.selectSpatialSkeletonNode(pickedNode.nodeId, true, pickedNode); - this.activateModeWatchable(activation, layer.spatialSkeletonEditMode); - activation.bindInputEventMap(SKELETON_EDIT_STATUS_INPUT_EVENT_MAP); - rawInputEventMapBinder(SPATIAL_SKELETON_AUX_INPUT_EVENT_MAP, activation); - this.bindPinnedSelectionAction(activation, { - showNodeSelectionMessage: false, - }); - this.bindClearSelectionAction(activation); - updateInteractionStatus(); - activation.registerDisposer(() => { - layer.spatialSkeletonState.clearPendingNodePositions(); - }); - activation.registerDisposer( - layer.selectedSpatialSkeletonNodeInfo.changed.add(renderStatus), - ); - activation.registerDisposer( - layer.manager.root.selectionState.changed.add(renderStatus), - ); - activation.registerDisposer( - layer.spatialSkeletonState.commandHistory.isBusy.changed.add( - updateInteractionStatus, - ), - ); - activation.registerDisposer( - layer.layersChanged.add(() => { - const supportReason = getEditSupportDisabledReason(); - if (supportReason !== undefined) { - StatusMessage.showTemporaryMessage(supportReason); - activation.cancel(); - return; - } - const reason = updateInteractionStatus(); - if (reason !== undefined) { - StatusMessage.showTemporaryMessage(reason); - return; - } - setReadyStatus(); - }), + if (nodeInfo === undefined || !hasPickedPosition) { + return; // Can't drag: done after the select above. + } + + // Arm drag: if threshold exceeded, move the node. + let totalDeltaX = 0; + let totalDeltaY = 0; + let dragStarted = false; + let finished = false; + let moved = false; + + this.dragModelSpacePosition.set(nodeInfo.position); + vec3.set( + this.dragGlobalAnchorPosition, + Number(pickedPosition[0]), + Number(pickedPosition[1]), + Number(pickedPosition[2]), ); - activation.bindAction( - "spatial-skeleton-add-node", - (event: ActionEvent) => { - event.stopPropagation(); - event.detail.preventDefault(); - const disabledReason = layer.getSpatialSkeletonActionsDisabledReason( - SpatialSkeletonActions.addNodes, - ); - if (disabledReason !== undefined) { - StatusMessage.showTemporaryMessage(disabledReason); - return; - } - const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); - if (skeletonLayer === undefined) { - StatusMessage.showTemporaryMessage( - "No spatially indexed skeleton source is currently loaded.", - ); - return; - } - const selectedParentNodeId = - layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; - const addNodeBlockedReason = this.getAddNodeBlockedReason( - skeletonLayer, - selectedParentNodeId, - ); - if (addNodeBlockedReason !== undefined) { - StatusMessage.showTemporaryMessage(addNodeBlockedReason); - return; - } - if (selectedParentNodeId === undefined) { - const pickedSegmentId = this.getPickedSpatialSkeletonSegment(); - if (pickedSegmentId !== undefined) { - this.selectSegmentByNumber(pickedSegmentId); + startRelativeMouseDrag( + event, + (_dragEvent, deltaX, deltaY) => { + totalDeltaX += deltaX; + totalDeltaY += deltaY; + if (!dragStarted) { + const thresholdSq = DRAG_START_DISTANCE_PX * DRAG_START_DISTANCE_PX; + if ( + totalDeltaX * totalDeltaX + totalDeltaY * totalDeltaY < + thresholdSq + ) { return; } + dragStarted = true; + this.dragInProgress = true; + skeletonLayer!.markSegmentEdited(nodeInfo!.segmentId); + panel.element.dataset.skeletonPressMode = "move"; + this.setStatus(SPATIAL_SKELETON_MOVING_NODE_MESSAGE); } - const clickStartPosition = - this.getMousePositionInSkeletonCoordinates(skeletonLayer); - if (clickStartPosition === undefined) { - StatusMessage.showTemporaryMessage( - "Unable to resolve add-node position for this click.", - ); - return; - } - let dragDistanceSquared = 0; - startRelativeMouseDrag( - event.detail, - (_event, deltaX, deltaY) => { - dragDistanceSquared += deltaX * deltaX + deltaY * deltaY; - }, - (_finishEvent) => { - const thresholdSquared = - DRAG_START_DISTANCE_PX * DRAG_START_DISTANCE_PX; - // Block adding nodes if the mouse release position - // is too far from the click position - if (dragDistanceSquared > thresholdSquared) { - setReadyStatus(); - return; - } - const selectedParentNodeId = - layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; - const addNodeBlockedReason = this.getAddNodeBlockedReason( - skeletonLayer, - selectedParentNodeId, - ); - if (addNodeBlockedReason !== undefined) { - setReadyStatus(); - StatusMessage.showTemporaryMessage(addNodeBlockedReason); - return; - } - const selectedParentNode = this.getSelectedParentNodeForAdd( - skeletonLayer, - selectedParentNodeId, - ); - const targetSkeletonId = - selectedParentNode === undefined - ? 0 - : selectedParentNode.segmentId; - const clickPositionInModelSpace = - this.getMousePositionInSkeletonCoordinates(skeletonLayer); - if (clickPositionInModelSpace === undefined) return; - void (async () => { - try { - await executeSpatialSkeletonAddNode(layer, { - skeletonId: targetSkeletonId, - parentNodeId: selectedParentNodeId, - positionInModelSpace: new Float32Array( - clickPositionInModelSpace, - ), - }); - } catch (error) { - showSpatialSkeletonActionError("create node", error); - return; - } - setReadyStatus(); - })(); - }, - ); - }, - ); - - activation.bindAction( - "spatial-skeleton-move-node", - (event: ActionEvent) => { - event.stopPropagation(); - event.detail.preventDefault(); - const disabledReason = layer.getSpatialSkeletonActionsDisabledReason( - SpatialSkeletonActions.moveNodes, + panel.translateDataPointByViewportPixels( + this.dragGlobalPosition, + this.dragGlobalAnchorPosition, + totalDeltaX, + totalDeltaY, ); - if (disabledReason !== undefined) { - StatusMessage.showTemporaryMessage(disabledReason); - return; - } - const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); - if (skeletonLayer === undefined) { - StatusMessage.showTemporaryMessage( - "No spatially indexed skeleton source is currently loaded.", - ); - return; - } - const actionPanel = this.getRenderedDataPanelForEvent(event.detail); - const pickedNode = this.getPickedSpatialSkeletonNode(); - if (pickedNode === undefined) { - const pickedSegmentId = this.getPickedSpatialSkeletonSegment(); - if (pickedSegmentId !== undefined) { - this.selectSegmentByNumber(pickedSegmentId); - layer.clearSpatialSkeletonNodeSelection(false); - } - return; - } - const pickedPosition = this.mouseState.position; - const hasPickedPosition = - pickedPosition.length >= 3 && - Number.isFinite(pickedPosition[0]) && - Number.isFinite(pickedPosition[1]) && - Number.isFinite(pickedPosition[2]); - if (!hasPickedPosition) return; - const nodeInfo = skeletonLayer.getNode(pickedNode.nodeId); - if (nodeInfo === undefined) { + if ( + !Number.isFinite(this.dragGlobalPosition[0]) || + !Number.isFinite(this.dragGlobalPosition[1]) || + !Number.isFinite(this.dragGlobalPosition[2]) + ) { return; } - const dragPanel = actionPanel; - if (dragPanel === undefined) { - StatusMessage.showTemporaryMessage( - "Unable to resolve active panel for node drag.", + const modelPosition = this.globalToSkeletonCoordinates( + this.dragGlobalPosition, + skeletonLayer!, + ); + if (modelPosition === undefined) return; + const previewChanged = + this.layer.spatialSkeletonState.setPendingNodePosition( + pickedNode.nodeId, + modelPosition, ); - return; + if (!previewChanged) return; + moved = true; + this.dragModelSpacePosition.set(modelPosition); + }, + (_finishEvent) => { + if (finished) return; + finished = true; + if (this.dragInProgress) { + this.dragInProgress = false; + delete panel.element.dataset.skeletonPressMode; + this.clearStatus(); } - let moved = false; - let finished = false; - this.dragModelSpacePosition.set(nodeInfo.position); - vec3.set( - this.dragGlobalAnchorPosition, - Number(pickedPosition[0]), - Number(pickedPosition[1]), - Number(pickedPosition[2]), - ); - let totalDeltaX = 0; - let totalDeltaY = 0; - let dragStarted = false; - cachedNodeSummary = this.getSelectedSpatialSkeletonNodeSummary(); - setStatus(SPATIAL_SKELETON_MOVING_NODE_MESSAGE); - startRelativeMouseDrag( - event.detail, - (_event, deltaX, deltaY) => { - totalDeltaX += deltaX; - totalDeltaY += deltaY; - if (!dragStarted) { - const thresholdSquared = - DRAG_START_DISTANCE_PX * DRAG_START_DISTANCE_PX; - if ( - totalDeltaX * totalDeltaX + totalDeltaY * totalDeltaY < - thresholdSquared - ) - return; - dragStarted = true; - skeletonLayer.markSegmentEdited(nodeInfo.segmentId); - } - dragPanel.translateDataPointByViewportPixels( - this.dragGlobalPosition, - this.dragGlobalAnchorPosition, - totalDeltaX, - totalDeltaY, - ); - if ( - !Number.isFinite(this.dragGlobalPosition[0]) || - !Number.isFinite(this.dragGlobalPosition[1]) || - !Number.isFinite(this.dragGlobalPosition[2]) - ) { - return; - } - const modelPosition = this.globalToSkeletonCoordinates( - this.dragGlobalPosition, - skeletonLayer, - ); - if (modelPosition === undefined) return; - const previewChanged = - layer.spatialSkeletonState.setPendingNodePosition( + if (!dragStarted) return; // Pure click: selection already happened on mousedown. + if (moved) { + void executeSpatialSkeletonMoveNode(this.layer, { + node: nodeInfo!, + nextPositionInModelSpace: new Float32Array( + this.dragModelSpacePosition, + ), + }) + .then(() => { + this.layer.spatialSkeletonState.clearPendingNodePosition( pickedNode.nodeId, - modelPosition, ); - if (!previewChanged) return; - moved = true; - this.dragModelSpacePosition.set(modelPosition); - }, - (_finishEvent) => { - if (finished) return; - finished = true; - clearCachedNodeSummary(); - setReadyStatus(); - if (!dragStarted) { - return; - } - if (moved) { - void executeSpatialSkeletonMoveNode(layer, { - node: nodeInfo, - nextPositionInModelSpace: new Float32Array( - this.dragModelSpacePosition, - ), - }) - .then(() => { - layer.spatialSkeletonState.clearPendingNodePosition( - pickedNode.nodeId, - ); - }) - .catch((error) => { - layer.spatialSkeletonState.clearPendingNodePosition( - pickedNode.nodeId, - ); - showSpatialSkeletonActionError("move node", error); - }); - return; - } - layer.spatialSkeletonState.clearPendingNodePosition( - pickedNode.nodeId, - ); - }, - ); - }, - ); - - activation.bindAction( - "spatial-skeleton-delete-node", - (event: ActionEvent) => { - event.stopPropagation(); - event.detail.preventDefault(); - const disabledReason = layer.getSpatialSkeletonActionsDisabledReason( - SpatialSkeletonActions.deleteNodes, - ); - if (disabledReason !== undefined) { - StatusMessage.showTemporaryMessage(disabledReason); - return; - } - const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); - if (skeletonLayer === undefined) { - StatusMessage.showTemporaryMessage( - "No spatially indexed skeleton source is currently loaded.", - ); - return; - } - const pickedNode = this.getPickedSpatialSkeletonNode(); - if (pickedNode === undefined) { - return; - } - const nodeInfo = skeletonLayer.getNode(pickedNode.nodeId); - if (nodeInfo === undefined) { - StatusMessage.showTemporaryMessage( - `Unable to resolve node ${pickedNode.nodeId} for deletion.`, - ); + }) + .catch((error) => { + this.layer.spatialSkeletonState.clearPendingNodePosition( + pickedNode.nodeId, + ); + showSpatialSkeletonActionError("move node", error); + }); return; } - void layer - .getSpatialSkeletonDeleteOperationContext(nodeInfo) - .then(() => executeSpatialSkeletonDeleteNode(layer, nodeInfo)) - .catch((error) => { - showSpatialSkeletonActionError("delete node", error); - }); + this.layer.spatialSkeletonState.clearPendingNodePosition( + pickedNode.nodeId, + ); }, ); } -} -export class SpatialSkeletonMergeModeTool extends SpatialSkeletonToolBase { - toJSON() { - return SPATIAL_SKELETON_MERGE_MODE_TOOL_ID; + private executeSplitOnNode(pickedNode: { + nodeId: number; + segmentId: number; + position?: SpatialSkeletonVector; + }) { + this.pinSegmentByNumber(pickedNode.segmentId); + this.layer.selectSpatialSkeletonNode(pickedNode.nodeId, true, pickedNode); + const splitPoint: SpatialSkeletonToolPointInfo = { + nodeId: pickedNode.nodeId, + segmentId: pickedNode.segmentId, + position: pickedNode.position, + }; + this.pending = true; + this.setStatus("Splitting selected node.", splitPoint); + void (async () => { + try { + await executeSpatialSkeletonSplit(this.layer, { + nodeId: pickedNode.nodeId, + segmentId: pickedNode.segmentId, + }); + } catch (error) { + showSpatialSkeletonActionError("split skeleton", error); + } finally { + this.pending = false; + this.renderStatus(); + } + })(); } - get description() { - return "Skeleton merge"; + private handleSplitPick() { + // Caller (capture listener) already called stopPropagation/preventDefault. + if (this.pending) return; + + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) { + StatusMessage.showTemporaryMessage( + "No spatially indexed skeleton source is currently loaded.", + ); + return; + } + const pickedNode = this.resolvePickedNodeSelection(skeletonLayer); + if (pickedNode === undefined || pickedNode.segmentId === undefined) { + StatusMessage.showTemporaryMessage("Click a skeleton node to split."); + return; + } + this.executeSplitOnNode({ + nodeId: pickedNode.nodeId, + segmentId: pickedNode.segmentId, + position: pickedNode.position, + }); } - activate(activation: ToolActivation) { - if ( - !this.cancelActivationIfPreconditionsFail( - activation, - SpatialSkeletonActions.mergeSkeletons, - ) - ) + private handleMergeFirstPick() { + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) { + StatusMessage.showTemporaryMessage( + "No spatially indexed skeleton source is currently loaded.", + ); return; - const rawInputEventMapBinder = activation.inputEventMapBinder; + } + const pickedNode = this.resolvePickedNodeSelectionForMerge(skeletonLayer); + if (pickedNode === undefined || pickedNode.segmentId === undefined) { + StatusMessage.showTemporaryMessage( + "Click a skeleton node to set as merge anchor.", + ); + return; + } + if (!this.isSpatialSkeletonSegmentVisible(pickedNode.segmentId)) { + StatusMessage.showTemporaryMessage( + `Make skeleton ${pickedNode.segmentId} visible before merging.`, + ); + return; + } + if (pickedNode.segmentId !== undefined) { + this.pinSegmentByNumber(pickedNode.segmentId); + } + this.layer.selectSpatialSkeletonNode(pickedNode.nodeId, true, pickedNode); + this.layer.setSpatialSkeletonMergeAnchor(pickedNode.nodeId); + this.renderStatus(); + } - this.activateModeWatchable(activation, this.layer.spatialSkeletonMergeMode); - const { body, header } = - makeToolActivationStatusMessageWithHeader(activation); - header.textContent = "Spatial skeleton merge"; - let pending = false; - type MergeAnchorSelection = { - nodeId: number; - segmentId?: number; - position?: ArrayLike; - sourceState?: SpatialSkeletonSourceState; - }; - let anchorSelection: MergeAnchorSelection | undefined; - let statusOverride: string | undefined; + private handleMergeSecondPick() { + // Caller (capture listener) already called stopPropagation/preventDefault. + if (this.pending) return; + + const disabledReason = this.layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.mergeSkeletons, + ); + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + return; + } const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); - const selectedNode = - this.getSelectedSpatialSkeletonNodeForTool(skeletonLayer); - if (selectedNode !== undefined) { - anchorSelection = selectedNode; - this.layer.selectSpatialSkeletonNode( - selectedNode.nodeId, - true, - selectedNode, + if (skeletonLayer === undefined) { + StatusMessage.showTemporaryMessage( + "No spatially indexed skeleton source is currently loaded.", ); - this.layer.setSpatialSkeletonMergeAnchor(selectedNode.nodeId); - } else { - this.layer.clearSpatialSkeletonMergeAnchor(); + return; } - activation.registerDisposer(() => { - this.layer.clearSpatialSkeletonMergeAnchor(); - }); - const getAnchorNode = (): MergeAnchorSelection | undefined => { - const nodeId = this.layer.spatialSkeletonState.mergeAnchorNodeId.value; - if (nodeId === undefined || !Number.isSafeInteger(nodeId)) { - anchorSelection = undefined; - return undefined; - } - const cachedNode = - this.getActiveSpatiallyIndexedSkeletonLayer()?.getNode(nodeId) ?? - this.layer.spatialSkeletonState.getCachedNode(nodeId); - if ( - anchorSelection?.nodeId === nodeId && - (cachedNode === undefined || - anchorSelection.segmentId === cachedNode.segmentId) - ) { - return anchorSelection; - } - const anchorNode = { - nodeId, - segmentId: cachedNode?.segmentId, - position: cachedNode?.position, - sourceState: cachedNode?.sourceState, - }; - anchorSelection = anchorNode; - return anchorNode; + + const anchorNodeId = + this.layer.spatialSkeletonState.mergeAnchorNodeId.value; + if (anchorNodeId === undefined) { + // No anchor yet — this click sets the merge anchor. + this.handleMergeFirstPick(); + return; + } + const anchorNodeInfo = + skeletonLayer.getNode(anchorNodeId) ?? + this.layer.spatialSkeletonState.getCachedNode(anchorNodeId); + const firstNode = { + nodeId: anchorNodeId, + segmentId: anchorNodeInfo?.segmentId, + position: anchorNodeInfo?.position, + sourceState: anchorNodeInfo?.sourceState, }; - const renderStatus = () => { - const anchorNode = getAnchorNode(); - let mergeStatus: string; - if (anchorNode === undefined) { - mergeStatus = SPATIAL_SKELETON_MERGE_BANNER_MESSAGE; - } else if ( - anchorNode.segmentId !== undefined && - !this.isSpatialSkeletonSegmentVisible(anchorNode.segmentId) - ) { - mergeStatus = `Make this segment visible, then select a 2nd node to merge with`; - } else { - mergeStatus = SPATIAL_SKELETON_MERGE_SELECTED_BANNER_MESSAGE; + + const pickedNode = this.resolvePickedNodeSelectionForMerge(skeletonLayer); + if (pickedNode === undefined || pickedNode.segmentId === undefined) return; + + if ( + pickedNode.nodeId === anchorNodeId || + pickedNode.segmentId === firstNode.segmentId + ) { + StatusMessage.showTemporaryMessage( + "Select a node from a different skeleton to merge with.", + ); + return; + } + + if (firstNode.segmentId === undefined) { + StatusMessage.showTemporaryMessage( + "Unable to resolve merge anchor segment.", + ); + return; + } + if (!this.isSpatialSkeletonSegmentVisible(firstNode.segmentId)) { + StatusMessage.showTemporaryMessage( + `The first node selected for a merge operation must be from a visible skeleton. Make skeleton ${firstNode.segmentId} visible in the Seg tab or by double-clicking it in the viewer.`, + 3000, + ); + return; + } + + this.pinSegmentByNumber(pickedNode.segmentId); + this.layer.selectSpatialSkeletonNode(pickedNode.nodeId, true, pickedNode); + this.pending = true; + this.setStatus("Merging selected nodes."); + + void (async () => { + try { + await waitForNextAnimationFrame(); + await executeSpatialSkeletonMerge( + this.layer, + { + nodeId: firstNode.nodeId, + segmentId: firstNode.segmentId!, + position: firstNode.position, + sourceState: firstNode.sourceState, + }, + { + nodeId: pickedNode.nodeId, + segmentId: pickedNode.segmentId!, + position: pickedNode.position, + sourceState: pickedNode.sourceState, + }, + ); + } catch (error) { + showSpatialSkeletonActionError("merge skeletons", error); + } finally { + this.pending = false; + this.renderStatus(); // Keep merge mode — user may still be holding m. } - renderSpatialSkeletonToolStatus(body, { - message: statusOverride ?? mergeStatus, - point: anchorNode, - }); - }; - const setStatus = (nextStatus: string | undefined) => { - statusOverride = nextStatus; - renderStatus(); - }; - const setReadyStatus = () => { - setStatus(undefined); - }; - setReadyStatus(); - activation.bindInputEventMap(SPATIAL_SKELETON_PICK_INPUT_EVENT_MAP); - rawInputEventMapBinder(SPATIAL_SKELETON_AUX_INPUT_EVENT_MAP, activation); - this.bindClearSelectionAction(activation); - this.registerAutoCancelOnDisabled( - activation, + })(); + } + + private handleCreatePlace() { + // Caller (capture listener) already called stopPropagation/preventDefault. + if (this.pending || this.createPlacedThisHold) return; + + const disabledReason = this.layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.addNodes, + ); + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + return; + } + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) { + StatusMessage.showTemporaryMessage( + "No spatially indexed skeleton source is currently loaded.", + ); + return; + } + + const clickPosition = + this.getMousePositionInSkeletonCoordinates(skeletonLayer); + if (clickPosition === undefined) { + StatusMessage.showTemporaryMessage( + "Unable to resolve click position for new skeleton.", + ); + return; + } + + this.createPlacedThisHold = true; + this.pending = true; + this.setStatus("Creating new skeleton."); + + void (async () => { + try { + await executeSpatialSkeletonAddNode(this.layer, { + skeletonId: 0, + parentNodeId: undefined, + positionInModelSpace: new Float32Array(clickPosition), + }); + } catch (error) { + showSpatialSkeletonActionError("create skeleton", error); + } finally { + this.pending = false; + this.renderStatus(); + } + })(); + } + + // --- Action implementations --- + + // Merge (m): enters merge mode — click to pick the anchor node, then the target. + private onEnterMergeModeAction() { + if ( + this.mergeKeyHeld || + this.dragInProgress || + this.pending || + this.currentMode !== SkeletonEditMode.Default + ) + return; + this.mergeKeyHeld = true; + const disabledReason = this.layer.getSpatialSkeletonActionsDisabledReason( SpatialSkeletonActions.mergeSkeletons, - setReadyStatus, ); - activation.registerDisposer( - this.layer.spatialSkeletonState.mergeAnchorNodeId.changed.add( - renderStatus, - ), + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + return; + } + this.enterMerge(); + } + + private onEnterCreateAction() { + if ( + this.dragInProgress || + this.pending || + this.currentMode !== SkeletonEditMode.Default + ) + return; + this.enterCreate(); + } + + // Split (s): enters split mode — click the node to split. + private onEnterSplitModeAction() { + if ( + this.splitKeyHeld || + this.dragInProgress || + this.pending || + this.currentMode !== SkeletonEditMode.Default + ) + return; + this.splitKeyHeld = true; + const disabledReason = this.layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.splitSkeletons, ); - activation.registerDisposer( - this.layer.displayState.segmentationGroupState.value.visibleSegments.changed.add( - renderStatus, - ), + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + return; + } + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) { + StatusMessage.showTemporaryMessage( + "No spatially indexed skeleton source is currently loaded.", + ); + return; + } + this.enterSplit(); + } + + private onAddNodeAction(event: ActionEvent) { + event.stopPropagation(); + event.detail.preventDefault(); + + if (this.currentMode !== SkeletonEditMode.Default) return; + + const disabledReason = this.layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.addNodes, ); - activation.registerDisposer( - this.layer.selectedSpatialSkeletonNodeInfo.changed.add(() => { - const selectedNodeId = - this.layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; - if (selectedNodeId === undefined) { - if ( - this.layer.spatialSkeletonState.mergeAnchorNodeId.value !== - undefined - ) { - anchorSelection = undefined; - this.layer.clearSpatialSkeletonMergeAnchor(); - } - return; - } - if (this.layer.spatialSkeletonState.commandHistory.isBusy.value) { - this.layer.setSpatialSkeletonMergeAnchor(selectedNodeId); - } - renderStatus(); - }), + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + return; + } + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) { + StatusMessage.showTemporaryMessage( + "No spatially indexed skeleton source is currently loaded.", + ); + return; + } + + const selectedParentNodeId = + this.layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; + if (selectedParentNodeId === undefined) { + StatusMessage.showTemporaryMessage( + "Select a node first, then shift+click to append a child.", + ); + return; + } + const addNodeBlockedReason = this.getAddNodeBlockedReason( + skeletonLayer, + selectedParentNodeId, ); - activation.bindAction( - "spatial-skeleton-pick-node", - (_event: ActionEvent) => { - if (pending) return; - const disabledReason = - this.layer.getSpatialSkeletonActionsDisabledReason( - SpatialSkeletonActions.mergeSkeletons, - ); - if (disabledReason !== undefined) { - StatusMessage.showTemporaryMessage(disabledReason); - return; - } - const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); - if (skeletonLayer === undefined) { - StatusMessage.showTemporaryMessage( - "No spatially indexed skeleton source is currently loaded.", - ); - return; - } - const pickedNode = - this.resolvePickedNodeSelectionForMerge(skeletonLayer); - const anchorNode = getAnchorNode(); - if (pickedNode === undefined) { - const pickedSegmentId = this.getPickedSpatialSkeletonSegment(); - if (pickedSegmentId !== undefined) { - this.pinSegmentByNumber(pickedSegmentId); - if (anchorNode === undefined) { - this.layer.clearSpatialSkeletonNodeSelection(false); - } - renderStatus(); - } - return; - } - if (pickedNode.segmentId === undefined) { - return; - } - if ( - anchorNode === undefined || - anchorNode.nodeId === pickedNode.nodeId || - anchorNode.segmentId === pickedNode.segmentId - ) { - this.pinSegmentByNumber(pickedNode.segmentId); - anchorSelection = { - nodeId: pickedNode.nodeId, - segmentId: pickedNode.segmentId, - position: pickedNode.position, - sourceState: pickedNode.sourceState, - }; - this.layer.setSpatialSkeletonMergeAnchor(pickedNode.nodeId); - this.layer.selectSpatialSkeletonNode( - pickedNode.nodeId, - true, - pickedNode, - ); - renderStatus(); + if (addNodeBlockedReason !== undefined) { + StatusMessage.showTemporaryMessage(addNodeBlockedReason); + return; + } + + const clickStartPosition = + this.getMousePositionInSkeletonCoordinates(skeletonLayer); + if (clickStartPosition === undefined) { + StatusMessage.showTemporaryMessage( + "Unable to resolve add-node position for this click.", + ); + return; + } + + let dragDistanceSquared = 0; + startRelativeMouseDrag( + event.detail, + (_dragEvent, deltaX, deltaY) => { + dragDistanceSquared += deltaX * deltaX + deltaY * deltaY; + }, + (_finishEvent) => { + const thresholdSquared = + DRAG_START_DISTANCE_PX * DRAG_START_DISTANCE_PX; + if (dragDistanceSquared > thresholdSquared) { return; } - const firstNode = anchorNode; - const secondNode = { - nodeId: pickedNode.nodeId, - segmentId: pickedNode.segmentId, - position: pickedNode.position, - sourceState: pickedNode.sourceState, - }; - if ( - firstNode.segmentId === undefined || - secondNode.segmentId === undefined - ) { + const currentParentNodeId = + this.layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; + if (currentParentNodeId === undefined) { StatusMessage.showTemporaryMessage( - "Unable to resolve both merge segments.", + "Select a node first, then shift+click to append a child.", ); return; } - if (!this.isSpatialSkeletonSegmentVisible(firstNode.segmentId)) { - StatusMessage.showTemporaryMessage( - `The first node selected for a merge operation must be from a visible skeleton. Make skeleton ${firstNode.segmentId} visible in the Seg tab or by double-clicking it in the viewer.`, - 3000, - ); + const blockedReason = this.getAddNodeBlockedReason( + skeletonLayer, + currentParentNodeId, + ); + if (blockedReason !== undefined) { + StatusMessage.showTemporaryMessage(blockedReason); return; } - this.pinSegmentByNumber(pickedNode.segmentId); - this.layer.selectSpatialSkeletonNode( - pickedNode.nodeId, - true, - pickedNode, + const selectedParentNode = this.getSelectedParentNodeForAdd( + skeletonLayer, + currentParentNodeId, ); - pending = true; - setStatus("Merging selected nodes."); + const clickPositionInModelSpace = + this.getMousePositionInSkeletonCoordinates(skeletonLayer); + if (clickPositionInModelSpace === undefined) return; void (async () => { try { - await waitForNextAnimationFrame(); - await executeSpatialSkeletonMerge( - this.layer, - { - nodeId: firstNode.nodeId, - segmentId: firstNode.segmentId!, - position: firstNode.position, - sourceState: firstNode.sourceState, - }, - { - nodeId: secondNode.nodeId, - segmentId: secondNode.segmentId!, - position: secondNode.position, - sourceState: secondNode.sourceState, - }, - ); + await executeSpatialSkeletonAddNode(this.layer, { + skeletonId: selectedParentNode?.segmentId ?? 0, + parentNodeId: currentParentNodeId, + positionInModelSpace: new Float32Array(clickPositionInModelSpace), + }); } catch (error) { - showSpatialSkeletonActionError("merge skeletons", error); - } finally { - pending = false; - this.layer.setSpatialSkeletonMergeAnchor(secondNode.nodeId); - setReadyStatus(); + showSpatialSkeletonActionError("create node", error); } })(); }, ); } -} - -export class SpatialSkeletonSplitModeTool extends SpatialSkeletonToolBase { - toJSON() { - return SPATIAL_SKELETON_SPLIT_MODE_TOOL_ID; - } - get description() { - return "Skeleton split"; + private onDeleteNodeAction(event: ActionEvent) { + event.stopPropagation(); + event.detail.preventDefault(); + const disabledReason = this.layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.deleteNodes, + ); + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + return; + } + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) { + StatusMessage.showTemporaryMessage( + "No spatially indexed skeleton source is currently loaded.", + ); + return; + } + const pickedNode = this.getPickedSpatialSkeletonNode(); + if (pickedNode === undefined) { + return; + } + const nodeInfo = skeletonLayer.getNode(pickedNode.nodeId); + if (nodeInfo === undefined) { + StatusMessage.showTemporaryMessage( + `Unable to resolve node ${pickedNode.nodeId} for deletion.`, + ); + return; + } + void this.layer + .getSpatialSkeletonDeleteOperationContext(nodeInfo) + .then(() => executeSpatialSkeletonDeleteNode(this.layer, nodeInfo)) + .catch((error) => { + showSpatialSkeletonActionError("delete node", error); + }); } activate(activation: ToolActivation) { - if ( - !this.cancelActivationIfPreconditionsFail( - activation, - SpatialSkeletonActions.splitSkeletons, - ) - ) - return; + const { layer } = this; const rawInputEventMapBinder = activation.inputEventMapBinder; - this.activateModeWatchable(activation, this.layer.spatialSkeletonSplitMode); + // 1. Reset all activation-scoped state. + this.currentMode = SkeletonEditMode.Default; + this.dragInProgress = false; + this.pending = false; + this.createPlacedThisHold = false; + this.mergeKeyHeld = false; + this.splitKeyHeld = false; + this.shiftHeld = false; + this.statusOverride = undefined; + this.statusPoint = undefined; + + // 2. Create status UI. const { body, header } = makeToolActivationStatusMessageWithHeader(activation); - header.textContent = "Skeleton split"; - let pending = false; - let statusOverride: string | undefined; - let pendingPoint: SpatialSkeletonToolPointInfo | undefined; - const renderStatus = () => { - renderSpatialSkeletonToolStatus(body, { - message: statusOverride ?? SPATIAL_SKELETON_SPLIT_BANNER_MESSAGE, - point: pendingPoint, - }); - }; - const setStatus = ( - nextStatus: string | undefined, - nextPoint: SpatialSkeletonToolPointInfo | undefined = pendingPoint, - ) => { - statusOverride = nextStatus; - pendingPoint = nextPoint; - renderStatus(); - }; - const setReadyStatus = () => { - setStatus(undefined, undefined); - }; - const splitNode = ( - pickedNode: { - nodeId: number; - segmentId?: number; - position?: SpatialSkeletonVector; - sourceState?: SpatialSkeletonSourceState; - }, - options: { - selectNode?: boolean; - } = {}, - ) => { - if (pickedNode.segmentId === undefined) { - return false; - } - this.pinSegmentByNumber(pickedNode.segmentId); - if (options.selectNode ?? true) { - this.layer.selectSpatialSkeletonNode( - pickedNode.nodeId, - true, - pickedNode, - ); - } - const point = { - nodeId: pickedNode.nodeId, - segmentId: pickedNode.segmentId, - position: pickedNode.position, - }; - pending = true; - setStatus("Splitting selected node.", point); - void (async () => { - try { - await executeSpatialSkeletonSplit(this.layer, { - nodeId: pickedNode.nodeId, - segmentId: pickedNode.segmentId!, - }); - } catch (error) { - showSpatialSkeletonActionError("split skeleton", error); - } finally { - pending = false; - setReadyStatus(); - } - })(); - return true; - }; - setReadyStatus(); - activation.bindInputEventMap(SPATIAL_SKELETON_PICK_INPUT_EVENT_MAP); - rawInputEventMapBinder(SPATIAL_SKELETON_AUX_INPUT_EVENT_MAP, activation); + header.textContent = "Skeleton edit"; + this.statusBody = body; + + // 3. Precondition checks. + const disabledReason = layer.getSpatialSkeletonActionsDisabledReason( + [SpatialSkeletonActions.addNodes, SpatialSkeletonActions.moveNodes], + { ignoreCommandBusy: true, requireVisibleChunks: false }, + ); + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + renderSpatialSkeletonToolStatus(body, { message: disabledReason }); + queueMicrotask(() => activation.cancel()); + return; + } + if (this.getActiveSpatiallyIndexedSkeletonLayer() === undefined) { + const msg = "No spatially indexed skeleton source is currently loaded."; + StatusMessage.showTemporaryMessage(msg); + renderSpatialSkeletonToolStatus(body, { message: msg }); + queueMicrotask(() => activation.cancel()); + return; + } + + // 4. Register disposer: clear statusBody, reset mode attribute, and + // deactivate layer-level mode flags. + activation.registerDisposer(() => { + this.statusBody = undefined; + this.setModeAttribute(undefined); + layer.spatialSkeletonMergeMode.value = false; + layer.spatialSkeletonSplitMode.value = false; + layer.spatialSkeletonState.clearPendingNodePositions(); + }); + + // 5. Activate edit mode watchable. + this.activateModeWatchable(activation, layer.spatialSkeletonEditMode); + + // 6. Bind event maps. + activation.bindInputEventMap(getDefaultSkeletonEditToolBindings()); + rawInputEventMapBinder(getDefaultSkeletonEditAuxBindings(), activation); + rawInputEventMapBinder(getDefaultSkeletonEditNodeBindings(), activation); + this.bindPinnedSelectionAction(activation, { + showNodeSelectionMessage: false, + }); this.bindClearSelectionAction(activation); - this.registerAutoCancelOnDisabled( - activation, - SpatialSkeletonActions.splitSkeletons, - setReadyStatus, + + // 7. Register state-change watcher disposers. + activation.registerDisposer( + layer.selectedSpatialSkeletonNodeInfo.changed.add(() => + this.renderStatus(), + ), ); - const selectedNode = this.getSelectedSpatialSkeletonNodeForTool( - this.getActiveSpatiallyIndexedSkeletonLayer(), + activation.registerDisposer( + layer.manager.root.selectionState.changed.add(() => this.renderStatus()), ); - if ( - selectedNode?.segmentId !== undefined && - this.isSpatialSkeletonSegmentVisible(selectedNode.segmentId) - ) { - splitNode(selectedNode); - } - activation.bindAction( - "spatial-skeleton-pick-node", - (_event: ActionEvent) => { - if (pending) return; - const disabledReason = - this.layer.getSpatialSkeletonActionsDisabledReason( - SpatialSkeletonActions.splitSkeletons, - ); - if (disabledReason !== undefined) { - StatusMessage.showTemporaryMessage(disabledReason); + activation.registerDisposer( + layer.spatialSkeletonState.mergeAnchorNodeId.changed.add(() => + this.renderStatus(), + ), + ); + activation.registerDisposer( + layer.displayState.segmentationGroupState.value.visibleSegments.changed.add( + () => this.renderStatus(), + ), + ); + + // 8. Layer validity watcher. + activation.registerDisposer( + layer.layersChanged.add(() => { + const reason = layer.getSpatialSkeletonActionsDisabledReason( + [SpatialSkeletonActions.addNodes, SpatialSkeletonActions.moveNodes], + { ignoreCommandBusy: true, requireVisibleChunks: false }, + ); + if (reason !== undefined) { + StatusMessage.showTemporaryMessage(reason); + activation.cancel(); + } + }), + ); + + // 9. Global key/mouse listeners — thin lambda wrappers delegating to class methods. + const onKeyDown = (event: KeyboardEvent) => this.syncModifiers(event); + const onKeyUp = (event: KeyboardEvent) => { + if (event.code === "KeyM") { + this.mergeKeyHeld = false; + this.exitMerge(); + } + if (event.code === "KeyN") this.exitCreate(); + if (event.code === "KeyS") { + this.splitKeyHeld = false; + this.exitSplit(); + } + this.syncModifiers(event); + }; + // mousemove catches modifiers pressed/released while keyboard focus is + // outside the panel (e.g. a text input elsewhere in the UI). + const onMouseMove = (event: MouseEvent) => this.syncModifiers(event); + const onBlur = () => { + this.mergeKeyHeld = false; + this.splitKeyHeld = false; + this.shiftHeld = false; + this.exitMerge(); + this.exitCreate(); + this.exitSplit(); + this.updateModeAttribute(); + }; + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("blur", onBlur); + activation.registerDisposer(() => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("blur", onBlur); + }); + + // 10. Per-panel capture listeners — closures per panel; body delegates to class methods. + // Left click (mousedown0) is handled here rather than in the EventActionMap so that + // we can consume off-node clicks without accidentally shadowing EventActionMap actions + // at lower priority. All left clicks are now owned by the edit tool — they either + // select a node or do nothing. Navigation (rotate/pan) belongs exclusively to middle + // mouse and is handled via the EventActionMap + the slice-panel path below. + for (const panel of layer.manager.root.display.panels) { + if (!(panel instanceof RenderedDataPanel)) continue; + const captureMousedown = (event: MouseEvent) => { + // Middle mouse (plain): rotate in 3D (EventActionMap mousedown1 → rotate-via-mouse-drag), + // translate in 2D (intercepted here via startRelativeMouseDrag). + // Ctrl+middle: translate in 3D (EventActionMap control+mousedown1 → translate-via-mouse-drag), + // translate in 2D (intercepted here, same as plain middle). + if (event.button === 1) { + if (!(panel instanceof PerspectivePanel)) { + event.stopPropagation(); + event.preventDefault(); + startRelativeMouseDrag(event, (_dragEvent, deltaX, deltaY) => { + panel.context.flagContinuousCameraMotion(); + panel.translateByViewportPixels(deltaX, deltaY); + }); + } return; } - const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); - if (skeletonLayer === undefined) { - StatusMessage.showTemporaryMessage( - "No spatially indexed skeleton source is currently loaded.", - ); + + // shift+mousedown0 → EventActionMap (add-node); other buttons → normal dispatch. + // Both must pass through the capture listener unmodified. + if (event.button !== 0 || event.shiftKey) return; + if (this.currentMode === SkeletonEditMode.Merge) { + event.stopPropagation(); + event.preventDefault(); + this.handleMergeSecondPick(); return; } - const pickedNode = this.resolvePickedNodeSelection(skeletonLayer); - if (pickedNode === undefined) { - const pickedSegmentId = this.getPickedSpatialSkeletonSegment(); - if (pickedSegmentId !== undefined) { - this.pinSegmentByNumber(pickedSegmentId); - this.layer.clearSpatialSkeletonNodeSelection(false); - renderStatus(); - } + if (this.currentMode === SkeletonEditMode.Split) { + event.stopPropagation(); + event.preventDefault(); + this.handleSplitPick(); return; } - if (pickedNode.segmentId === undefined) { + if (this.currentMode === SkeletonEditMode.Create) { + event.stopPropagation(); + event.preventDefault(); + this.handleCreatePlace(); return; } - splitNode(pickedNode); - }, + // Default mode: only consume if hovering a node. + this.handleDefaultMousedown(event, panel); + }; + panel.element.addEventListener("mousedown", captureMousedown, { + capture: true, + }); + activation.registerDisposer(() => { + panel.element.removeEventListener("mousedown", captureMousedown, { + capture: true, + }); + }); + } + + // 11. Bind actions — thin one-liners delegating to class methods. + activation.bindAction(SKELETON_ENTER_MERGE_MODE, () => + this.onEnterMergeModeAction(), ); + activation.bindAction(SKELETON_ENTER_CREATE, () => + this.onEnterCreateAction(), + ); + activation.bindAction(SKELETON_ENTER_SPLIT_MODE, () => + this.onEnterSplitModeAction(), + ); + activation.bindAction(SKELETON_ADD_NODE, (event) => + this.onAddNodeAction(event as ActionEvent), + ); + activation.bindAction(SKELETON_DELETE_NODE, (event) => + this.onDeleteNodeAction(event as ActionEvent), + ); + activation.bindAction(SKELETON_TOGGLE_TRUE_END, () => { + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + const nodeId = this.layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; + if (nodeId === undefined) return; + const node = + skeletonLayer?.getNode(nodeId) ?? + this.layer.spatialSkeletonState.getCachedNode(nodeId); + if (node === undefined) { + StatusMessage.showTemporaryMessage( + `Node ${nodeId} is not available in the skeleton cache.`, + ); + return; + } + const nextIsTrueEnd = !(node.isTrueEnd ?? false); + if (nextIsTrueEnd) { + if (node.parentNodeId === undefined) { + StatusMessage.showTemporaryMessage( + "Cannot set the root node as a true end.", + ); + return; + } + const cachedSegmentNodes = + this.layer.spatialSkeletonState.getCachedSegmentNodes(node.segmentId); + if (cachedSegmentNodes !== undefined) { + const hasChildren = cachedSegmentNodes.some( + (candidate) => candidate.parentNodeId === node.nodeId, + ); + if (hasChildren) { + StatusMessage.showTemporaryMessage( + "Only leaf nodes can be marked as true ends.", + ); + return; + } + } + } + void executeSpatialSkeletonNodeTrueEndUpdate(this.layer, { + node, + nextIsTrueEnd, + }).catch((error) => + showSpatialSkeletonActionError("toggle true end", error), + ); + }); + activation.bindAction(SKELETON_REROOT, () => { + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + const nodeId = this.layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; + if (nodeId === undefined) return; + const node = + skeletonLayer?.getNode(nodeId) ?? + this.layer.spatialSkeletonState.getCachedNode(nodeId); + if (node === undefined) { + StatusMessage.showTemporaryMessage( + `Node ${nodeId} is not available in the skeleton cache.`, + ); + return; + } + if (node.isTrueEnd) { + StatusMessage.showTemporaryMessage( + "Cannot set a true end node as root. Clear the true end state first.", + ); + return; + } + void this.layer + .rerootSpatialSkeletonNode(node) + .catch((error) => showSpatialSkeletonActionError("reroot", error)); + }); + + // 12. Initial render. + this.renderStatus(); } } +// Backward-compat alias — external code referencing SpatialSkeletonEditModeTool still works. +export { SpatialSkeletonEditTool as SpatialSkeletonEditModeTool }; + function makeSpatialSkeletonToolLister(toolId: string) { return (layer: SegmentationUserLayer, onChange?: () => void) => { if (onChange !== undefined) { @@ -1396,19 +1569,7 @@ export function registerSpatialSkeletonEditModeTool( registerTool( contextType, SPATIAL_SKELETON_EDIT_MODE_TOOL_ID, - (layer) => new SpatialSkeletonEditModeTool(layer), + (layer) => new SpatialSkeletonEditTool(layer), makeSpatialSkeletonToolLister(SPATIAL_SKELETON_EDIT_MODE_TOOL_ID), ); - registerTool( - contextType, - SPATIAL_SKELETON_MERGE_MODE_TOOL_ID, - (layer) => new SpatialSkeletonMergeModeTool(layer), - makeSpatialSkeletonToolLister(SPATIAL_SKELETON_MERGE_MODE_TOOL_ID), - ); - registerTool( - contextType, - SPATIAL_SKELETON_SPLIT_MODE_TOOL_ID, - (layer) => new SpatialSkeletonSplitModeTool(layer), - makeSpatialSkeletonToolLister(SPATIAL_SKELETON_SPLIT_MODE_TOOL_ID), - ); } diff --git a/src/ui/skeleton_tab.css b/src/ui/skeleton_tab.css index 76396a9538..5503b3e738 100644 --- a/src/ui/skeleton_tab.css +++ b/src/ui/skeleton_tab.css @@ -115,6 +115,10 @@ flex: 0 0 auto; } +.neuroglancer-skeleton-filter-row .neuroglancer-tool-button { + margin-left: auto; +} + .neuroglancer-skeleton-navigation-bar { display: flex; align-items: center; diff --git a/src/ui/skeleton_tab.ts b/src/ui/skeleton_tab.ts index 7a49f32033..b63c1c6a7c 100644 --- a/src/ui/skeleton_tab.ts +++ b/src/ui/skeleton_tab.ts @@ -37,10 +37,30 @@ import { } from "#src/segmentation_display_state/base.js"; import { getBaseObjectColor } from "#src/segmentation_display_state/frontend.js"; import { - SpatialSkeletonActions, - type SpatialSkeletonAction, + SKELETON_CYCLE_BRANCHES, + SKELETON_GO_BRANCH_END, + SKELETON_GO_BRANCH_START, + SKELETON_GO_CHILD, + SKELETON_GO_PARENT, + SKELETON_GO_ROOT, + SKELETON_GO_UNFINISHED, + SKELETON_REDO, + SKELETON_REROOT, + SKELETON_TOGGLE_TRUE_END, + SKELETON_UNDO, } from "#src/skeleton/actions.js"; import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; +import { + SpatialSkeletonActions, + type SpatialSkeletonAction, +} from "#src/skeleton/command_protocol.js"; +import { + executeSpatialSkeletonDeleteNode, + executeSpatialSkeletonNodeTrueEndUpdate, + redoSpatialSkeletonCommand, + showSpatialSkeletonActionError, + undoSpatialSkeletonCommand, +} from "#src/skeleton/commands.js"; import { buildSpatiallyIndexedSkeletonNavigationGraph, getBranchEnd as getBranchEndFromGraph, @@ -60,20 +80,13 @@ import { SpatialSkeletonDisplayNodeType, SpatialSkeletonNodeFilterType, } from "#src/skeleton/node_types.js"; -import { - executeSpatialSkeletonDeleteNode, - executeSpatialSkeletonNodeTrueEndUpdate, - redoSpatialSkeletonCommand, - showSpatialSkeletonActionError, - undoSpatialSkeletonCommand, -} from "#src/skeleton/spatial_skeleton_commands.js"; import { StatusMessage } from "#src/status.js"; import { observeWatchable, registerNested } from "#src/trackable_value.js"; import { - SPATIAL_SKELETON_EDIT_MODE_TOOL_ID, - SPATIAL_SKELETON_MERGE_MODE_TOOL_ID, - SPATIAL_SKELETON_SPLIT_MODE_TOOL_ID, -} from "#src/ui/skeleton_edit_tools.js"; + getDefaultSkeletonListBindings, + getDefaultSkeletonTabBindings, +} from "#src/ui/default_input_event_bindings.js"; +import { SPATIAL_SKELETON_EDIT_MODE_TOOL_ID } from "#src/ui/skeleton_edit_tools.js"; import { buildSpatialSkeletonSegmentRenderState, type SpatialSkeletonSegmentRenderRow, @@ -81,6 +94,10 @@ import { } from "#src/ui/skeleton_tab_render.js"; import { makeToolButton } from "#src/ui/tool.js"; import type { ArraySpliceOp } from "#src/util/array.js"; +import { + registerActionListener, + KeyboardEventBinder, +} from "#src/util/keyboard_bindings.js"; import * as matrix from "#src/util/matrix.js"; import { formatScaleWithUnitAsString } from "#src/util/si_units.js"; import { Signal } from "#src/util/signal.js"; @@ -168,33 +185,34 @@ export class SpatialSkeletonEditTab extends Tab { const { element } = this; element.classList.add("neuroglancer-skeleton-tab"); - const toolbox = document.createElement("div"); - toolbox.className = - "neuroglancer-segmentation-toolbox neuroglancer-skeleton-toolbar"; - toolbox.appendChild( - makeToolButton(this, layer.toolBinder, { - toolJson: SPATIAL_SKELETON_EDIT_MODE_TOOL_ID, - label: "Edit", - title: "Toggle skeleton node edit mode", - }), - ); - toolbox.appendChild( - makeToolButton(this, layer.toolBinder, { - toolJson: SPATIAL_SKELETON_MERGE_MODE_TOOL_ID, - label: "Merge", - title: "Toggle skeleton merge mode", - }), - ); - toolbox.appendChild( - makeToolButton(this, layer.toolBinder, { - toolJson: SPATIAL_SKELETON_SPLIT_MODE_TOOL_ID, - label: "Split", - title: "Toggle skeleton split mode", - }), - ); const toolbarActions = document.createElement("div"); toolbarActions.className = "neuroglancer-skeleton-toolbar-actions"; + const formatKeyHint = (stroke: string): string => { + const parts = stroke.split("+").map((part) => { + if (part === "control") return "Ctrl"; + if (part === "shift") return "Shift"; + if (part === "alt") return "Alt"; + if (part.startsWith("key")) return part.slice(3).toUpperCase(); + if (part.startsWith("digit")) return part.slice(5); + if (part === "bracketleft") return "["; + if (part === "bracketright") return "]"; + return part.charAt(0).toUpperCase() + part.slice(1); + }); + return parts.join("+"); + }; + + const tabBindings = getDefaultSkeletonTabBindings(); + const keyHintFor = (action: string): string => { + for (const [, eventAction] of tabBindings.entries()) { + if (eventAction.action === action) { + const key = eventAction.originalEventIdentifier; + if (key !== undefined) return ` (${formatKeyHint(key)})`; + } + } + return ""; + }; + const makeIconButton = ( parent: HTMLElement, svg: string, @@ -211,28 +229,36 @@ export class SpatialSkeletonEditTab extends Tab { parent.appendChild(button); return button; }; - const undoButton = makeIconButton(toolbarActions, svg_undo, "Undo", () => { - if (undoButton.disabled) return; - void (async () => { - try { - await undoSpatialSkeletonCommand(layer); - } catch (error) { - showSpatialSkeletonActionError("undo", error); - } - })(); - }); - const redoButton = makeIconButton(toolbarActions, svg_redo, "Redo", () => { - if (redoButton.disabled) return; - void (async () => { - try { - await redoSpatialSkeletonCommand(layer); - } catch (error) { - showSpatialSkeletonActionError("redo", error); - } - })(); - }); - toolbox.appendChild(toolbarActions); - + const undoButton = makeIconButton( + toolbarActions, + svg_undo, + `Undo${keyHintFor(SKELETON_UNDO)}`, + () => { + if (undoButton.disabled) return; + void (async () => { + try { + await undoSpatialSkeletonCommand(layer); + } catch (error) { + showSpatialSkeletonActionError("undo", error); + } + })(); + }, + ); + const redoButton = makeIconButton( + toolbarActions, + svg_redo, + `Redo${keyHintFor(SKELETON_REDO)}`, + () => { + if (redoButton.disabled) return; + void (async () => { + try { + await redoSpatialSkeletonCommand(layer); + } catch (error) { + showSpatialSkeletonActionError("redo", error); + } + })(); + }, + ); const navTools = document.createElement("div"); navTools.className = "neuroglancer-skeleton-nav-tools"; @@ -293,15 +319,52 @@ export class SpatialSkeletonEditTab extends Tab { new VirtualList({ source: virtualListSource }), ); nodesList.element.className = "neuroglancer-skeleton-tree"; + nodeFilterTypeRow.appendChild( + makeToolButton(this, layer.toolBinder, { + toolJson: SPATIAL_SKELETON_EDIT_MODE_TOOL_ID, + label: "Edit", + title: "Toggle skeleton edit mode", + }), + ); nodesSection.appendChild(filterInput); nodesSection.appendChild(nodeFilterTypeRow); nodesNavigationBar.appendChild(navTools); + nodesNavigationBar.appendChild(toolbarActions); nodesSection.appendChild(nodesNavigationBar); nodesSummaryBar.appendChild(nodesSummary); nodesSection.appendChild(nodesSummaryBar); nodesSection.appendChild(nodesList.element); + // tabIndex=-1 makes nodesSection programmatically focusable so that clicking + // anywhere in the section (buttons, labels, whitespace) focuses it, which + // causes shouldIgnoreEvent to hit the el===this.target fast-path and allow + // all keyboard shortcuts without needing a list row to be focused. + nodesSection.tabIndex = -1; element.appendChild(nodesSection); + const sectionKeyBinder = this.registerDisposer( + new KeyboardEventBinder(nodesSection, getDefaultSkeletonTabBindings()), + ); + // modifierShortcutsAreGlobal=true (the default) blocks Alt/Ctrl shortcuts + // when a BUTTON child (nav or undo/redo buttons) has focus. Setting false + // lets those shortcuts through while still blocking them in the filter INPUT. + sectionKeyBinder.modifierShortcutsAreGlobal = false; + + const listKeyBinder = this.registerDisposer( + new KeyboardEventBinder( + nodesList.element, + getDefaultSkeletonListBindings(), + ), + ); + listKeyBinder.modifierShortcutsAreGlobal = false; + + // Add the tab navigation map to the viewer's slice and perspective view + // panels so shortcuts work when the user's focus is on a viewport, not just + // the sidebar. Scoped to this Tab's lifetime via `this` as the context. + layer.manager.root.toolBinder.bindInputEventMap( + getDefaultSkeletonTabBindings(), + this, + ); + let allNodes: SpatiallyIndexedSkeletonNode[] = []; let activeSegmentId: number | undefined; let nodesBySegment = new Map(); @@ -483,7 +546,7 @@ export class SpatialSkeletonEditTab extends Tab { ) => { const id = BigInt(segmentId); const hasSegmentSelectionModifiers = (event: MouseEvent) => - event.ctrlKey && !event.altKey && !event.metaKey; + event.ctrlKey && !event.altKey; element.addEventListener("mousedown", (event: MouseEvent) => { if (event.button !== 2 || !hasSegmentSelectionModifiers(event)) { return; @@ -504,8 +567,8 @@ export class SpatialSkeletonEditTab extends Tab { const getSegmentSelectionTitle = (segmentId: number) => `segment ${segmentId}\n` + - "Ctrl+right-click to pin selection\n" + - "Ctrl+shift+right-click to unpin"; + `Ctrl+right-click to pin selection\n` + + `Ctrl+shift+right-click to unpin`; const getNodeDescriptionText = (node: SpatiallyIndexedSkeletonNode) => layer.getSpatialSkeletonNodeDisplayDescription(node); @@ -743,6 +806,24 @@ export class SpatialSkeletonEditTab extends Tab { ) => { if (!ensureActionsAllowed(SpatialSkeletonActions.editNodeTrueEnd)) return; if (pendingTrueEndNodes.has(node.nodeId)) return; + if (present) { + if (node.parentNodeId === undefined) { + StatusMessage.showTemporaryMessage( + "Cannot set the root node as a true end.", + ); + return; + } + const segmentNodes = nodesBySegment.get(node.segmentId) ?? []; + const hasChildren = segmentNodes.some( + (candidate) => candidate.parentNodeId === node.nodeId, + ); + if (hasChildren) { + StatusMessage.showTemporaryMessage( + "Only leaf nodes can be marked as true ends.", + ); + return; + } + } pendingTrueEndNodes.add(node.nodeId); updateDisplay(); void (async () => { @@ -844,6 +925,12 @@ export class SpatialSkeletonEditTab extends Tab { StatusMessage.showTemporaryMessage("Selected node is already root."); return; } + if (node.isTrueEnd) { + StatusMessage.showTemporaryMessage( + "Cannot set a true end node as root. Clear the true end state first.", + ); + return; + } if (pendingRerootNodes.has(node.nodeId)) { return; } @@ -864,7 +951,7 @@ export class SpatialSkeletonEditTab extends Tab { const goRootButton = makeIconButton( navTools, svg_origin, - "Go to root", + `Go to root${keyHintFor(SKELETON_GO_ROOT)}`, () => { const segmentId = getSelectedNavigationContext( false /* requireNode */, @@ -888,7 +975,7 @@ export class SpatialSkeletonEditTab extends Tab { const goBranchStartButton = makeIconButton( navTools, svg_chevrons_left, - "Go to start of branch", + `Go to start of branch${keyHintFor(SKELETON_GO_BRANCH_START)}`, () => { const selectedNode = getSelectedNavigationContext(); if (selectedNode === undefined) return; @@ -910,7 +997,7 @@ export class SpatialSkeletonEditTab extends Tab { const goTreeEndButton = makeIconButton( navTools, svg_chevrons_right, - "Go to end of branch", + `Go to end of branch${keyHintFor(SKELETON_GO_BRANCH_END)}`, () => { const selectedNode = getSelectedNavigationContext(); if (selectedNode === undefined) return; @@ -932,7 +1019,7 @@ export class SpatialSkeletonEditTab extends Tab { const cycleBranchesButton = makeIconButton( navTools, svg_retweet, - "Cycle through level nodes", + `Cycle through level nodes${keyHintFor(SKELETON_CYCLE_BRANCHES)}`, () => { const selectedNode = getSelectedNavigationContext(); if (selectedNode === undefined) return; @@ -956,7 +1043,7 @@ export class SpatialSkeletonEditTab extends Tab { const goParentButton = makeIconButton( navTools, svg_arrow_left, - "Go to parent", + `Go to parent${keyHintFor(SKELETON_GO_PARENT)}`, () => { const selectedNode = getSelectedNavigationContext(); if (selectedNode === undefined) return; @@ -985,7 +1072,7 @@ export class SpatialSkeletonEditTab extends Tab { const goChildButton = makeIconButton( navTools, svg_arrow_right, - "Go to child", + `Go to child${keyHintFor(SKELETON_GO_CHILD)}`, () => { const selectedNode = getSelectedNavigationContext(); if (selectedNode === undefined) return; @@ -1012,13 +1099,11 @@ export class SpatialSkeletonEditTab extends Tab { const goUnfinishedBranchButton = makeIconButton( navTools, svg_chevron_right, - "Go to nearest unfinished leaf node", + `Go to nearest unfinished leaf node${keyHintFor(SKELETON_GO_UNFINISHED)}`, () => { goToClosestUnfinishedBranch(); }, ); - element.insertBefore(toolbox, nodesSection); - const gatedControls = [ goRootButton, goBranchStartButton, @@ -1330,7 +1415,11 @@ export class SpatialSkeletonEditTab extends Tab { const actions = document.createElement("div"); actions.className = "neuroglancer-skeleton-node-actions"; let rerootActionTitle = - node.parentNodeId === undefined ? "Already root" : "Set as root"; + node.parentNodeId === undefined + ? "Already root" + : nodeIsTrueEnd + ? "Clear true end state first to set as root" + : "Set as root"; if (pendingRerootNodes.has(node.nodeId)) { rerootActionTitle = "Setting root"; } @@ -1341,7 +1430,8 @@ export class SpatialSkeletonEditTab extends Tab { () => rerootNode(node), !nodeRerootAllowed || pendingRerootNodes.has(node.nodeId) || - node.parentNodeId === undefined, + node.parentNodeId === undefined || + nodeIsTrueEnd, ), ); let deleteActionTitle = "Delete node"; @@ -1752,6 +1842,36 @@ export class SpatialSkeletonEditTab extends Tab { updateDisplay(); }), ); + // List-level: node mutations + this.registerDisposer( + registerActionListener( + nodesList.element, + SKELETON_TOGGLE_TRUE_END, + () => { + const selectedNodeId = + layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; + if (selectedNodeId === undefined) return; + const selectedNode = allNodes.find( + (node) => node.nodeId === selectedNodeId, + ); + if (selectedNode === undefined) return; + updateTrueEndLabel(selectedNode, !(selectedNode.isTrueEnd ?? false)); + }, + ), + ); + this.registerDisposer( + registerActionListener(nodesList.element, SKELETON_REROOT, () => { + const selectedNodeId = + layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; + if (selectedNodeId === undefined) return; + const selectedNode = allNodes.find( + (node) => node.nodeId === selectedNodeId, + ); + if (selectedNode === undefined) return; + rerootNode(selectedNode); + }), + ); + updateGateStatus(); updateHistoryButtons(); updateHoveredViewerNode(); diff --git a/src/ui/tool.ts b/src/ui/tool.ts index 847bfe877b..30ecfb7f6d 100644 --- a/src/ui/tool.ts +++ b/src/ui/tool.ts @@ -317,6 +317,10 @@ export class GlobalToolBinder extends RefCounted { super(); } + bindInputEventMap(inputEventMap: EventActionMap, context: RefCounted) { + this.inputEventMapBinder(inputEventMap, context); + } + get(key: string): Borrowed | undefined { return this.bindings.get(key); } diff --git a/src/viewer.ts b/src/viewer.ts index 2c740bb3d5..0a8a2e283a 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -72,6 +72,17 @@ import { import { overlaysOpen } from "#src/overlay.js"; import { ScreenshotHandler } from "#src/python_integration/screenshots.js"; import { allRenderLayerRoles, RenderLayerRole } from "#src/renderlayer.js"; +import { + SKELETON_CYCLE_BRANCHES, + SKELETON_GO_BRANCH_END, + SKELETON_GO_BRANCH_START, + SKELETON_GO_CHILD, + SKELETON_GO_PARENT, + SKELETON_GO_ROOT, + SKELETON_GO_UNFINISHED, + SKELETON_REDO, + SKELETON_UNDO, +} from "#src/skeleton/actions.js"; import { StatusMessage } from "#src/status.js"; import { ElementVisibilityFromTrackableBoolean, @@ -1073,6 +1084,22 @@ export class Viewer extends RefCounted implements ViewerState { }); } + for (const action of [ + SKELETON_GO_ROOT, + SKELETON_GO_PARENT, + SKELETON_GO_CHILD, + SKELETON_GO_BRANCH_START, + SKELETON_GO_BRANCH_END, + SKELETON_CYCLE_BRANCHES, + SKELETON_GO_UNFINISHED, + SKELETON_UNDO, + SKELETON_REDO, + ]) { + this.bindAction(action, () => { + this.layerManager.invokeAction(action); + }); + } + for (const action of ["select", "star"]) { this.bindAction(action, () => { this.mouseState.updateUnconditionally();