diff --git a/src/chunk_manager/frontend.ts b/src/chunk_manager/frontend.ts index 496e5914c9..82d5343ac6 100644 --- a/src/chunk_manager/frontend.ts +++ b/src/chunk_manager/frontend.ts @@ -332,7 +332,11 @@ export class ChunkQueueManager extends SharedObject { } function updateChunk(rpc: RPC, x: any) { - const source: ChunkSource = rpc.get(x.source); + const source = rpc.get(x.source) as ChunkSource | undefined; + if (source === undefined) { + // Source was removed while chunk update was in flight. + return; + } if (DEBUG_CHUNK_UPDATES) { console.log( `${Date.now()} Chunk.update received: ` + diff --git a/src/data_panel_layout.ts b/src/data_panel_layout.ts index f64f989f05..fb49436ebb 100644 --- a/src/data_panel_layout.ts +++ b/src/data_panel_layout.ts @@ -101,6 +101,7 @@ export interface ViewerUIState crossSectionBackgroundColor: TrackableRGB; perspectiveViewBackgroundColor: TrackableRGB; hideCrossSectionBackground3D: TrackableBoolean; + showPickingIndicator: TrackableBoolean; pickRadius: TrackableValue; } @@ -184,6 +185,7 @@ export function getCommonViewerState(viewer: ViewerUIState) { visibility: viewer.visibility, scaleBarOptions: viewer.scaleBarOptions, hideCrossSectionBackground3D: viewer.hideCrossSectionBackground3D, + showPickingIndicator: viewer.showPickingIndicator, pickRadius: viewer.pickRadius, }; } diff --git a/src/datasource/catmaid/frontend.ts b/src/datasource/catmaid/frontend.ts index 8003c43726..b0d50f201b 100644 --- a/src/datasource/catmaid/frontend.ts +++ b/src/datasource/catmaid/frontend.ts @@ -476,6 +476,9 @@ export class CatmaidDataSourceProvider implements DataSourceProvider { id: "skeletons-chunked", default: true, subsource: { mesh: multiscaleSource }, + layerRuntimeStateDisposal: { + kind: "spatiallyIndexedSkeleton", + }, }, { id: "skeletons", 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/datasource/index.ts b/src/datasource/index.ts index c630322332..a8121301a2 100644 --- a/src/datasource/index.ts +++ b/src/datasource/index.ts @@ -153,6 +153,10 @@ export interface CompleteUrlOptions extends CompleteUrlOptionsBase { signal: AbortSignal; } +export interface LayerRuntimeStateDisposalRequest { + kind: string; +} + export interface DataSubsourceEntry { /** * Unique identifier (within the group) for this subsource. Stored in the JSON state @@ -182,6 +186,12 @@ export interface DataSubsourceEntry { * Specifies whether this associated data source is enabled by default. */ default: boolean; + + /** + * Optional layer-owned runtime cleanup requested when this active subsource's + * datasource is replaced or cleared. + */ + layerRuntimeStateDisposal?: LayerRuntimeStateDisposalRequest; } export interface ChannelMetadata { diff --git a/src/help/input_event_bindings.ts b/src/help/input_event_bindings.ts index ff5ec6b1b2..97c1c0581b 100644 --- a/src/help/input_event_bindings.ts +++ b/src/help/input_event_bindings.ts @@ -32,6 +32,7 @@ import { type EventActionMap, } from "#src/util/event_action_map.js"; import { emptyToUndefined } from "#src/util/json.js"; +import { isMacPlatform } from "#src/util/platform.js"; declare let NEUROGLANCER_BUILD_INFO: | { tag: string; url?: string; timestamp?: string } @@ -51,8 +52,16 @@ export function formatKeyName(name: string) { } export function formatKeyStroke(stroke: string) { - const parts = stroke.split("+"); - return parts.map(formatKeyName).join("+"); + const mac = isMacPlatform(); + return stroke + .split("+") + .map((part) => { + if (mac && part === "control") return "⌘"; + if (mac && part === "alt") return "⌥"; + if (mac && part === "shift") return "⇧"; + return formatKeyName(part); + }) + .join("+"); } const DEFAULT_HELP_PANEL_LOCATION: SidePanelLocation = { diff --git a/src/layer/index.ts b/src/layer/index.ts index 7995083b43..12690d9f4c 100644 --- a/src/layer/index.ts +++ b/src/layer/index.ts @@ -38,7 +38,10 @@ import type { } from "#src/datasource/index.js"; import { makeEmptyDataSourceSpecification } from "#src/datasource/index.js"; import type { DisplayContext, RenderedPanel } from "#src/display_context.js"; -import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; +import type { + LayerDataSourceChangeRuntimeDisposalContext, + LoadedDataSubsource, +} from "#src/layer/layer_data_source.js"; import { LayerDataSource, layerDataSourceSpecificationFromJson, @@ -429,6 +432,14 @@ export class UserLayer extends RefCounted { subsources; } + // Derived classes may override to clear layer-owned runtime state for active + // datasources that explicitly request cleanup on source change. + disposeLayerRuntimeStateForDataSourceChange( + _context: LayerDataSourceChangeRuntimeDisposalContext, + ) { + return false; + } + updateDataSubsourceActivations() { function* getDataSubsources( this: UserLayer, diff --git a/src/layer/layer_data_source.ts b/src/layer/layer_data_source.ts index 63d07a7746..589627b047 100644 --- a/src/layer/layer_data_source.ts +++ b/src/layer/layer_data_source.ts @@ -31,6 +31,7 @@ import type { DataSourceWithRedirectInfo, DataSubsourceEntry, DataSubsourceSpecification, + LayerRuntimeStateDisposalRequest, } from "#src/datasource/index.js"; import { makeEmptyDataSourceSpecification } from "#src/datasource/index.js"; import type { UserLayer } from "#src/layer/index.js"; @@ -148,6 +149,7 @@ export class LoadedDataSubsource { enabled: boolean; activated: RefCounted | undefined = undefined; guardValues: any[] = []; + renderLayers = new Set(); messages = new MessageList(); isActiveChanged = new NullarySignal(); constructor( @@ -212,9 +214,13 @@ export class LoadedDataSubsource { addRenderLayer(renderLayer: Owned) { const activated = this.activated!; - activated.registerDisposer( - this.loadedDataSource.layer.addRenderLayer(renderLayer), - ); + const removeRenderLayer = + this.loadedDataSource.layer.addRenderLayer(renderLayer); + this.renderLayers.add(renderLayer); + activated.registerDisposer(() => { + this.renderLayers.delete(renderLayer); + removeRenderLayer(); + }); activated.registerDisposer(this.messages.addChild(renderLayer.messages)); } @@ -301,6 +307,16 @@ export class LoadedLayerDataSource extends RefCounted { } } +export type LayerDataSourceChangeReason = "replace" | "clear"; + +export interface LayerDataSourceChangeRuntimeDisposalContext { + request: LayerRuntimeStateDisposalRequest; + reason: LayerDataSourceChangeReason; + layerDataSource: LayerDataSource; + loadedDataSource: LoadedLayerDataSource; + loadedSubsource: LoadedDataSubsource; +} + export type LayerDataSourceLoadState = | { error: Error; @@ -368,10 +384,36 @@ export class LayerDataSource extends RefCounted { return this.loadState_; } + private disposeRuntimeStateForDataSourceChange( + reason: LayerDataSourceChangeReason, + ) { + const { loadState } = this; + if (loadState === undefined || loadState.error !== undefined) return false; + const handledRequestKinds = new Set(); + let changed = false; + for (const loadedSubsource of loadState.subsources) { + if (loadedSubsource.activated === undefined) continue; + const request = loadedSubsource.subsourceEntry.layerRuntimeStateDisposal; + if (request === undefined) continue; + if (handledRequestKinds.has(request.kind)) continue; + handledRequestKinds.add(request.kind); + changed = + this.layer.disposeLayerRuntimeStateForDataSourceChange({ + request, + reason, + layerDataSource: this, + loadedDataSource: loadState, + loadedSubsource, + }) || changed; + } + return changed; + } + set spec(spec: DataSourceSpecification) { const { layer } = this; this.messages.clearMessages(); if (spec.url.length === 0) { + this.disposeRuntimeStateForDataSourceChange("clear"); if (layer.dataSources.length !== 1) { const index = layer.dataSources.indexOf(this); if (index !== -1) { @@ -395,6 +437,7 @@ export class LayerDataSource extends RefCounted { disposableOnce(layer.markLoading()), ); if (this.refCounted_ !== undefined) { + this.disposeRuntimeStateForDataSourceChange("replace"); this.refCounted_.dispose(); this.loadState_ = undefined; } 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 f135e34d0b..eaffc0cc5f 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -42,7 +42,10 @@ import { registerVolumeLayerType, UserLayer, } from "#src/layer/index.js"; -import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; +import type { + LayerDataSourceChangeRuntimeDisposalContext, + LoadedDataSubsource, +} from "#src/layer/layer_data_source.js"; import { layerDataSourceSpecificationFromJson } from "#src/layer/layer_data_source.js"; import * as json_keys from "#src/layer/segmentation/json_keys.js"; import { registerLayerControls } from "#src/layer/segmentation/layer_controls.js"; @@ -96,16 +99,39 @@ 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_TOGGLE_HIDDEN, + 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 +143,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 +165,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, @@ -195,6 +222,7 @@ import { verifyString, } from "#src/util/json.js"; import * as matrix from "#src/util/matrix.js"; +import { isMacPlatform } from "#src/util/platform.js"; import { Signal } from "#src/util/signal.js"; import { TrackableEnum } from "#src/util/trackable_enum.js"; import { makeWatchableShaderError } from "#src/webgl/dynamic_shader.js"; @@ -283,7 +311,7 @@ function bindSpatialSkeletonSegmentSelection( ) { const id = BigInt(segmentId); const hasSegmentSelectionModifiers = (event: MouseEvent) => - event.ctrlKey && !event.altKey && !event.metaKey; + (isMacPlatform() ? event.metaKey : event.ctrlKey) && !event.altKey; element.addEventListener("mousedown", (event: MouseEvent) => { if (event.button !== 2 || !hasSegmentSelectionModifiers(event)) return; selectSegment(id, event.shiftKey ? "force-unpin" : true); @@ -788,6 +816,9 @@ function copyOptionalSpatialSkeletonPosition( return new Float32Array(Array.from(value, Number)); } +const SPATIALLY_INDEXED_SKELETON_RUNTIME_DISPOSAL_KIND = + "spatiallyIndexedSkeleton"; + const Base = UserLayerWithAnnotationsMixin(UserLayer); export class SegmentationUserLayer extends Base { sliceViewRenderScaleHistogram = new RenderScaleHistogram(); @@ -799,7 +830,7 @@ export class SegmentationUserLayer extends Base { readonly selectedSpatialSkeletonNodeInfo = new WatchableValue< SelectedSpatialSkeletonNodeInfo | undefined >(undefined); - readonly hoveredSpatialSkeletonNodeId = this.registerDisposer( + readonly hoveredSpatialSkeletonNodeInfo = this.registerDisposer( new SpatialSkeletonHoverState(), ); readonly spatialSkeletonVisibleChunksNeeded = new WatchableValue(0); @@ -1059,6 +1090,8 @@ export class SegmentationUserLayer extends Base { x === undefined ? undefined : parseUint64(x), ); + private savedHiddenObjectAlpha: number | undefined; + constructor(managedLayer: Borrowed) { super(managedLayer); this.codeVisible.changed.add(this.specificationChanged.dispatch); @@ -1118,7 +1151,7 @@ export class SegmentationUserLayer extends Base { ), ); syncSelectedSpatialSkeletonNodeIdFromGlobalSelection(); - this.hoveredSpatialSkeletonNodeId.bindTo( + this.hoveredSpatialSkeletonNodeInfo.bindTo( this.manager.layerSelectedValues, this, ); @@ -1535,6 +1568,31 @@ export class SegmentationUserLayer extends Base { this.spatialSkeletonState.markNodeDataChanged(options); } + disposeLayerRuntimeStateForDataSourceChange( + context: LayerDataSourceChangeRuntimeDisposalContext, + ) { + if ( + context.request.kind !== SPATIALLY_INDEXED_SKELETON_RUNTIME_DISPOSAL_KIND + ) { + return super.disposeLayerRuntimeStateForDataSourceChange(context); + } + let changed = false; + const spatialSkeletonLayers = new Set(); + for (const renderLayer of context.loadedSubsource.renderLayers) { + if ( + renderLayer instanceof PerspectiveViewSpatiallyIndexedSkeletonLayer || + renderLayer instanceof SliceViewPanelSpatiallyIndexedSkeletonLayer + ) { + spatialSkeletonLayers.add(renderLayer.base); + } + } + for (const spatialSkeletonLayer of spatialSkeletonLayers) { + changed = spatialSkeletonLayer.disposeRuntimeState() || changed; + } + changed = this.spatialSkeletonState.clearRuntimeState() || changed; + return changed; + } + activateDataSubsources(subsources: Iterable) { const updatedSegmentPropertyMaps: SegmentPropertyMap[] = []; const isGroupRoot = @@ -1603,6 +1661,7 @@ export class SegmentationUserLayer extends Base { { sources2d: slicePanelSources, selectedNodeInfo: this.selectedSpatialSkeletonNodeInfo, + hoveredNodeInfo: this.hoveredSpatialSkeletonNodeInfo, pendingNodePositionVersion: this.spatialSkeletonState.pendingNodePositionVersion, getPendingNodePosition: (nodeId) => @@ -1636,6 +1695,7 @@ export class SegmentationUserLayer extends Base { displayState, { selectedNodeInfo: this.selectedSpatialSkeletonNodeInfo, + hoveredNodeInfo: this.hoveredSpatialSkeletonNodeInfo, pendingNodePositionVersion: this.spatialSkeletonState.pendingNodePositionVersion, getPendingNodePosition: (nodeId) => @@ -2015,8 +2075,216 @@ export class SegmentationUserLayer extends Base { } break; } + case SKELETON_TOGGLE_HIDDEN: { + const { hiddenObjectAlpha } = this.displayState; + if (this.savedHiddenObjectAlpha !== undefined) { + hiddenObjectAlpha.value = this.savedHiddenObjectAlpha; + this.savedHiddenObjectAlpha = undefined; + } else { + this.savedHiddenObjectAlpha = hiddenObjectAlpha.value; + hiddenObjectAlpha.value = 0; + } + 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; @@ -2227,10 +2495,11 @@ export class SegmentationUserLayer extends Base { segmentIdChip.textContent = `${segmentId}`; segmentIdChip.style.backgroundColor = segmentChipColors.background; segmentIdChip.style.color = segmentChipColors.foreground; + const modifierKeyLabel = isMacPlatform() ? "Cmd" : "Ctrl"; segmentIdChip.title = `Segment ${segmentId}\n` + - "Ctrl+right-click to pin selection\n" + - "Ctrl+shift+right-click to unpin"; + `${modifierKeyLabel}+right-click to pin selection\n` + + `${modifierKeyLabel}+shift+right-click to unpin`; bindSpatialSkeletonSegmentSelection( segmentIdChip, this.selectSegment, @@ -2293,12 +2562,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"; @@ -2317,7 +2588,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/layer/segmentation/selection.spec.ts b/src/layer/segmentation/selection.spec.ts index 34f7228aa9..4d28fc209f 100644 --- a/src/layer/segmentation/selection.spec.ts +++ b/src/layer/segmentation/selection.spec.ts @@ -144,7 +144,7 @@ describe("layer/segmentation/selection", () => { let mouseState: { active: boolean; pickedRenderLayer: unknown; - pickedSpatialSkeleton?: { nodeId?: unknown }; + pickedSpatialSkeleton?: { nodeId?: unknown; segmentId?: unknown }; } = { active: false, pickedRenderLayer: null, @@ -175,7 +175,15 @@ describe("layer/segmentation/selection", () => { pickedSpatialSkeleton: { nodeId: 31 }, }; trigger(); - expect(hoverState.value).toBe(31); + expect(hoverState.value).toEqual({ nodeId: 31 }); + + mouseState = { + active: true, + pickedRenderLayer: renderLayerA, + pickedSpatialSkeleton: { nodeId: 31, segmentId: 7 }, + }; + trigger(); + expect(hoverState.value).toEqual({ nodeId: 31, segmentId: 7 }); mouseState = { active: true, diff --git a/src/layer/segmentation/selection.ts b/src/layer/segmentation/selection.ts index 2d15b10751..c843b0635d 100644 --- a/src/layer/segmentation/selection.ts +++ b/src/layer/segmentation/selection.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import type { LayerSelectedValues } from "#src/layer/index.js"; +import type { + LayerSelectedValues, + PickedSpatialSkeletonState, +} from "#src/layer/index.js"; import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; import { RefCounted } from "#src/util/disposable.js"; import { parseUint64 } from "#src/util/json.js"; @@ -23,11 +26,12 @@ import { NullarySignal } from "#src/util/signal.js"; interface SpatialSkeletonViewerHoverMouseStateLike { active: boolean; pickedRenderLayer: TRenderLayer | null | undefined; - pickedSpatialSkeleton?: - | { - nodeId?: unknown; - } - | undefined; + pickedSpatialSkeleton?: PickedSpatialSkeletonState; +} + +export interface SpatialSkeletonHoverInfo { + readonly nodeId: number; + readonly segmentId?: number; } interface SpatialSkeletonViewerHoverLayerLike { @@ -88,12 +92,6 @@ function getSelectionValueIdString(value: unknown) { } } -function normalizeSpatialSkeletonViewerHoverNodeId(value: unknown) { - return typeof value === "number" && Number.isSafeInteger(value) && value > 0 - ? value - : undefined; -} - export function getNodeIdFromLayerSelectionState( state: { nodeId?: unknown; value?: unknown } | undefined, ) { @@ -175,10 +173,10 @@ export function getNodeIdFromViewerSelection( ); } -function getSpatialSkeletonNodeIdFromViewerHover( +function getSpatialSkeletonHoverInfoFromViewerHover( mouseState: SpatialSkeletonViewerHoverMouseStateLike, layer: SpatialSkeletonViewerHoverLayerLike, -) { +): SpatialSkeletonHoverInfo | undefined { if (!mouseState.active) return undefined; const pickedRenderLayer = mouseState.pickedRenderLayer; if (pickedRenderLayer !== null) { @@ -189,18 +187,29 @@ function getSpatialSkeletonNodeIdFromViewerHover( return undefined; } } - // TODO (SKM): I think we can inline this function - return normalizeSpatialSkeletonViewerHoverNodeId( - mouseState.pickedSpatialSkeleton?.nodeId, - ); + const pickedSpatialSkeleton = mouseState.pickedSpatialSkeleton; + const nodeId = pickedSpatialSkeleton?.nodeId; + if (nodeId === undefined) return undefined; + const segmentId = pickedSpatialSkeleton?.segmentId; + if (segmentId === undefined) return undefined; + return segmentId === undefined ? { nodeId } : { nodeId, segmentId }; +} + +function spatialSkeletonHoverInfoEqual( + a: SpatialSkeletonHoverInfo | undefined, + b: SpatialSkeletonHoverInfo | undefined, +) { + if (a === b) return true; + if (a === undefined || b === undefined) return false; + return a.nodeId === b.nodeId && a.segmentId === b.segmentId; } export class SpatialSkeletonHoverState extends RefCounted { - value: number | undefined = undefined; + value: SpatialSkeletonHoverInfo | undefined = undefined; readonly changed = new NullarySignal(); - setValue(value: number | undefined) { - if (this.value !== value) { + setValue(value: SpatialSkeletonHoverInfo | undefined) { + if (!spatialSkeletonHoverInfoEqual(this.value, value)) { this.value = value; this.changed.dispatch(); } @@ -213,7 +222,7 @@ export class SpatialSkeletonHoverState extends RefCounted { this.registerDisposer( layerSelectedValues.changed.add(() => { this.setValue( - getSpatialSkeletonNodeIdFromViewerHover( + getSpatialSkeletonHoverInfoFromViewerHover( layerSelectedValues.mouseState, layer, ), diff --git a/src/layer_group_viewer.ts b/src/layer_group_viewer.ts index fa68aacdd4..1c9898b372 100644 --- a/src/layer_group_viewer.ts +++ b/src/layer_group_viewer.ts @@ -104,6 +104,7 @@ export interface LayerGroupViewerState { crossSectionBackgroundColor: TrackableRGB; perspectiveViewBackgroundColor: TrackableRGB; hideCrossSectionBackground3D: TrackableBoolean; + showPickingIndicator: TrackableBoolean; pickRadius: TrackableValue; } @@ -364,6 +365,9 @@ export class LayerGroupViewer extends RefCounted { get hideCrossSectionBackground3D() { return this.viewerState.hideCrossSectionBackground3D; } + get showPickingIndicator() { + return this.viewerState.showPickingIndicator; + } get showScaleBar() { return this.viewerState.showScaleBar; } diff --git a/src/layer_groups_layout.ts b/src/layer_groups_layout.ts index e50f4ff388..4736be0c1c 100644 --- a/src/layer_groups_layout.ts +++ b/src/layer_groups_layout.ts @@ -421,6 +421,7 @@ function getCommonViewerState(viewer: Viewer) { crossSectionBackgroundColor: viewer.crossSectionBackgroundColor, perspectiveViewBackgroundColor: viewer.perspectiveViewBackgroundColor, hideCrossSectionBackground3D: viewer.hideCrossSectionBackground3D, + showPickingIndicator: viewer.showPickingIndicator, pickRadius: viewer.uiConfiguration.pickRadius, }; } diff --git a/src/perspective_view/panel.ts b/src/perspective_view/panel.ts index f07ad56735..90a5ef4282 100644 --- a/src/perspective_view/panel.ts +++ b/src/perspective_view/panel.ts @@ -29,6 +29,7 @@ import type { PerspectiveViewRenderContext, } from "#src/perspective_view/render_layer.js"; import { PerspectiveViewRenderLayer } from "#src/perspective_view/render_layer.js"; +import { PickingIndicatorHelper } from "#src/picking_indicator.js"; import type { ProjectionParameters } from "#src/projection_parameters.js"; import { updateProjectionParametersFromInverseViewAndProjection } from "#src/projection_parameters.js"; import type { @@ -95,6 +96,7 @@ export interface PerspectiveViewerState extends RenderedDataViewerState { crossSectionBackgroundColor: TrackableRGB; perspectiveViewBackgroundColor: TrackableRGB; hideCrossSectionBackground3D: TrackableBoolean; + showPickingIndicator: WatchableValueInterface; rpc: RPC; } @@ -324,6 +326,9 @@ export class PerspectivePanel extends RenderedDataPanel { ); private axesLineHelper = this.registerDisposer(AxesLineHelper.get(this.gl)); + private pickingIndicatorHelper = this.registerDisposer( + PickingIndicatorHelper.get(this.gl), + ); protected offscreenFramebuffer = this.registerDisposer( new FramebufferConfiguration(this.gl, { colorBuffers: [ @@ -575,6 +580,16 @@ export class PerspectivePanel extends RenderedDataPanel { this.registerDisposer( viewer.showAxisLines.changed.add(() => this.scheduleRedraw()), ); + this.registerDisposer( + viewer.showPickingIndicator.changed.add(() => this.scheduleRedraw()), + ); + this.registerDisposer( + viewer.mouseState.changed.add(() => { + if (this.viewer.showPickingIndicator.value) { + this.scheduleRedraw(); + } + }), + ); this.registerDisposer( viewer.crossSectionBackgroundColor.changed.add(() => this.scheduleRedraw(), @@ -1421,6 +1436,9 @@ export class PerspectivePanel extends RenderedDataPanel { ); gl.disable(WebGL2RenderingContext.BLEND); } + if (this.viewer.showPickingIndicator.value) { + this.drawPickingIndicator(renderContext); + } this.offscreenFramebuffer.unbind(); // Draw the texture over the whole viewport. @@ -1506,6 +1524,48 @@ export class PerspectivePanel extends RenderedDataPanel { ); } + private drawPickingIndicator(renderContext: PerspectiveViewRenderContext) { + const { mouseState } = this.viewer; + if (!mouseState.active) return; + const { + viewProjectionMat, + logicalWidth, + logicalHeight, + displayDimensionRenderInfo: { displayDimensionIndices }, + } = renderContext.projectionParameters; + // mouseState.position is in global voxel space; extract display-space components. + const displayPos = tempVec3; + displayPos[0] = + displayDimensionIndices[0] >= 0 + ? mouseState.position[displayDimensionIndices[0]] + : 0; + displayPos[1] = + displayDimensionIndices[1] >= 0 + ? mouseState.position[displayDimensionIndices[1]] + : 0; + displayPos[2] = + displayDimensionIndices[2] >= 0 + ? mouseState.position[displayDimensionIndices[2]] + : 0; + vec3.transformMat4(displayPos, displayPos, viewProjectionMat); + if (displayPos[2] < -1 || displayPos[2] > 1) return; + const { gl } = this; + gl.drawBuffers([gl.COLOR_ATTACHMENT0]); + gl.disable(gl.DEPTH_TEST); + gl.depthMask(false); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + this.pickingIndicatorHelper.draw( + displayPos[0], + displayPos[1], + logicalWidth, + logicalHeight, + ); + gl.disable(gl.BLEND); + gl.depthMask(true); + gl.enable(gl.DEPTH_TEST); + } + zoomByMouse(factor: number) { this.navigationState.zoomBy(factor); } diff --git a/src/perspective_view/render_layer.ts b/src/perspective_view/render_layer.ts index 8935b061f9..597de6aeeb 100644 --- a/src/perspective_view/render_layer.ts +++ b/src/perspective_view/render_layer.ts @@ -86,6 +86,11 @@ export interface PerspectiveViewRenderContext * Specifies how to assign the max projection emitter */ maxProjectionEmit?: (builder: ShaderBuilder) => void | undefined; + + /** + * Specifies whether the picking indicator ring should be drawn at the cursor position. + */ + showPickingIndicator?: boolean; } // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging diff --git a/src/picking_indicator.ts b/src/picking_indicator.ts new file mode 100644 index 0000000000..6e5059e3a4 --- /dev/null +++ b/src/picking_indicator.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2024 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RefCounted } from "#src/util/disposable.js"; +import type { GL } from "#src/webgl/context.js"; +import { ShaderBuilder, type ShaderProgram } from "#src/webgl/shader.js"; + +const RADIUS_PIXELS = 14; + +export class PickingIndicatorHelper extends RefCounted { + private readonly shader: ShaderProgram; + + constructor(private readonly gl: GL) { + super(); + const builder = new ShaderBuilder(gl); + builder.addUniform("highp vec2", "uNDCPosition"); + builder.addUniform("highp vec2", "uSizeNDC"); + builder.addVarying("highp vec2", "vUV"); + builder.setVertexMain(` +vec2 corner = vec2(float(gl_VertexID & 1) * 2.0 - 1.0, + float((gl_VertexID >> 1) & 1) * 2.0 - 1.0); +vUV = corner; +gl_Position = vec4(uNDCPosition + corner * uSizeNDC, 0.0, 1.0); +`); + builder.addOutputBuffer("vec4", "out_color", 0); + builder.setFragmentMain(` +float dist = length(vUV); +if (dist > 1.0 || dist < 0.5) discard; +// White ring bordered by black on both sides for contrast on any background. +float isWhite = step(0.62, dist) * step(dist, 0.87); +out_color = vec4(vec3(isWhite), 0.92); +`); + this.shader = this.registerDisposer(builder.build()); + } + + draw( + ndcX: number, + ndcY: number, + logicalWidth: number, + logicalHeight: number, + ) { + const { gl, shader } = this; + shader.bind(); + gl.uniform2f(shader.uniform("uNDCPosition"), ndcX, ndcY); + gl.uniform2f( + shader.uniform("uSizeNDC"), + RADIUS_PIXELS / logicalWidth, + RADIUS_PIXELS / logicalHeight, + ); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + } + + static get(gl: GL) { + return gl.memoize.get( + "picking_indicator/PickingIndicatorHelper", + () => new PickingIndicatorHelper(gl), + ); + } +} diff --git a/src/rendered_data_panel.ts b/src/rendered_data_panel.ts index 3b9a440c6f..442183eb5a 100644 --- a/src/rendered_data_panel.ts +++ b/src/rendered_data_panel.ts @@ -47,6 +47,7 @@ import { KeyboardEventBinder } from "#src/util/keyboard_bindings.js"; import * as matrix from "#src/util/matrix.js"; import { MouseEventBinder } from "#src/util/mouse_bindings.js"; import { startRelativeMouseDrag } from "#src/util/mouse_drag.js"; +import { isMacPlatform } from "#src/util/platform.js"; import type { TouchPinchInfo, TouchTranslateInfo, @@ -467,8 +468,8 @@ export abstract class RenderedDataPanel extends RenderedPanel { typeof NEUROGLANCER_SHOW_OBJECT_SELECTION_TOOLTIP !== "undefined" && NEUROGLANCER_SHOW_OBJECT_SELECTION_TOOLTIP === true ) { - element.title = - "Double click to toggle display of object under mouse pointer. Control+rightclick to pin/unpin selection."; + const modifierKeyLabel = isMacPlatform() ? "Cmd" : "Control"; + element.title = `Double click to toggle display of object under mouse pointer. ${modifierKeyLabel}+rightclick to pin/unpin selection.`; } this.registerDisposer(new AutomaticallyFocusedElement(element)); diff --git a/src/skeleton/actions.ts b/src/skeleton/actions.ts index 283cb7a7a7..54e3cedc44 100644 --- a/src/skeleton/actions.ts +++ b/src/skeleton/actions.ts @@ -14,61 +14,36 @@ * 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; -} +// --- 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"; -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"; - } -} +// --- Display toggles --- +export const SKELETON_TOGGLE_HIDDEN = "skeleton-toggle-hidden"; 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..3fac6e2943 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]>; @@ -87,6 +77,10 @@ export class SpatialSkeletonCommandMappings { private nodeIdMappings = new Map(); private segmentIdMappings = new Map(); + get empty() { + return this.nodeIdMappings.size === 0 && this.segmentIdMappings.size === 0; + } + clear() { this.nodeIdMappings.clear(); this.segmentIdMappings.clear(); @@ -251,10 +245,15 @@ export class SpatialSkeletonCommandHistory extends RefCounted { } clear() { + const changed = + this.undoEntries.length !== 0 || + this.redoEntries.length !== 0 || + !this.mappings.empty; this.undoEntries = []; this.redoEntries = []; this.mappings.clear(); this.updateState(); + return changed; } setSource(source: unknown) { 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 89% rename from src/skeleton/spatial_skeleton_commands.ts rename to src/skeleton/commands.ts index 83731a5f09..c48b7cf389 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, ) { @@ -346,19 +346,43 @@ export function executeSpatialSkeletonMerge( export async function undoSpatialSkeletonCommand( layer: SpatialSkeletonLayerContext, ) { - const changed = await layer.spatialSkeletonState.commandHistory.undo(); - if (!changed) { + const { commandHistory } = layer.spatialSkeletonState; + if (commandHistory.isBusy.value) { + StatusMessage.showTemporaryMessage( + "Wait for the current skeleton edit to finish.", + ); return false; } - return true; + if (!commandHistory.canUndo.value) { + return false; + } + const undoLabel = commandHistory.undoLabel.value; + const pendingMessage = + undoLabel !== undefined ? `Undoing ${undoLabel}...` : "Undoing..."; + return executeCommandWithPendingMessage( + commandHistory.undo(), + pendingMessage, + ); } export async function redoSpatialSkeletonCommand( layer: SpatialSkeletonLayerContext, ) { - const changed = await layer.spatialSkeletonState.commandHistory.redo(); - if (!changed) { + const { commandHistory } = layer.spatialSkeletonState; + if (commandHistory.isBusy.value) { + StatusMessage.showTemporaryMessage( + "Wait for the current skeleton edit to finish.", + ); return false; } - return true; + if (!commandHistory.canRedo.value) { + return false; + } + const redoLabel = commandHistory.redoLabel.value; + const pendingMessage = + redoLabel !== undefined ? `Redoing ${redoLabel}...` : "Redoing..."; + return executeCommandWithPendingMessage( + commandHistory.redo(), + pendingMessage, + ); } diff --git a/src/skeleton/frontend.spec.ts b/src/skeleton/frontend.spec.ts index b19d500be3..9d9c26f623 100644 --- a/src/skeleton/frontend.spec.ts +++ b/src/skeleton/frontend.spec.ts @@ -134,22 +134,23 @@ describe("SpatiallyIndexedSkeletonLayer selected node outline color", () => { const layer = Object.assign( Object.create(SpatiallyIndexedSkeletonLayer.prototype), { - selectedNodeId: { value: 101 }, + selectedNodeInfo: { value: { nodeId: 101 } }, selectedNodeOutlineColor: vec3.create(), - selectedNodeOutlineColorGeneration: 0, - cachedSelectedNodeOutlineColorGeneration: -1, + highlightedNodeOutlineColor: vec3.create(), + nodeOutlineColorGeneration: 0, + cachedNodeOutlineColorGeneration: -1, displayState, }, ); - const outlineColor = (layer as any).getSelectedNodeOutlineColor(); - const cachedOutlineColor = (layer as any).getSelectedNodeOutlineColor(); + (layer as any).updateNodeOutlineColorPair(); + const outlineColor = (layer as any).selectedNodeOutlineColor; + (layer as any).updateNodeOutlineColorPair(); + const cachedOutlineColor = (layer as any).selectedNodeOutlineColor; expect(isSelected).not.toHaveBeenCalled(); expect(cachedOutlineColor).toBe(outlineColor); - expect(outlineColor[0]).toBeCloseTo(1); - expect(outlineColor[1]).toBeCloseTo(0.95); - expect(outlineColor[2]).toBeCloseTo(0.35); + // The outline color is chosen for high contrast against the segment color. expect(getContrastRatio(outlineColor, sourceColor)).toBeGreaterThanOrEqual( 3, ); @@ -183,16 +184,17 @@ describe("SpatiallyIndexedSkeletonLayer selected node outline color", () => { { selectedNodeInfo: { value: { nodeId: 101 } }, selectedNodeOutlineColor: vec3.create(), - selectedNodeOutlineColorGeneration: 0, - cachedSelectedNodeOutlineColorGeneration: -1, + highlightedNodeOutlineColor: vec3.create(), + nodeOutlineColorGeneration: 0, + cachedNodeOutlineColorGeneration: -1, displayState, }, ); - (layer as any).getSelectedNodeOutlineColor(); + (layer as any).updateNodeOutlineColorPair(); selectedNodeId.value = 202; - ++(layer as any).selectedNodeOutlineColorGeneration; - (layer as any).getSelectedNodeOutlineColor(); + ++(layer as any).nodeOutlineColorGeneration; + (layer as any).updateNodeOutlineColorPair(); expect(computeSegmentColor).toHaveBeenCalledTimes(2); }); @@ -224,18 +226,104 @@ describe("SpatiallyIndexedSkeletonLayer selected node outline color", () => { { selectedNodeInfo: { value: { nodeId: 101 } }, selectedNodeOutlineColor: vec3.create(), - selectedNodeOutlineColorGeneration: 0, - cachedSelectedNodeOutlineColorGeneration: -1, + highlightedNodeOutlineColor: vec3.create(), + nodeOutlineColorGeneration: 0, + cachedNodeOutlineColorGeneration: -1, displayState, }, ); - (layer as any).getSelectedNodeOutlineColor(); - ++(layer as any).selectedNodeOutlineColorGeneration; - (layer as any).getSelectedNodeOutlineColor(); + (layer as any).updateNodeOutlineColorPair(); + ++(layer as any).nodeOutlineColorGeneration; + (layer as any).updateNodeOutlineColorPair(); expect(computeSegmentColor).toHaveBeenCalledTimes(2); }); + + it("derives the hovered-node outline color from the hovered segment when nothing is selected", () => { + const sourceColor = vec3.fromValues(1, 1, 1); + const displayState = { + segmentationColorGroupState: { + value: { + segmentStatedColors: new Map(), + segmentDefaultColor: { value: sourceColor }, + segmentColorHash: { compute: vi.fn() }, + }, + }, + saturation: { value: 0 }, + hoverHighlight: { value: true }, + segmentSelectionState: { isSelected: vi.fn(() => false), baseValue: 0n }, + }; + const layer = Object.assign( + Object.create(SpatiallyIndexedSkeletonLayer.prototype), + { + selectedNodeInfo: { value: undefined }, + hoveredNodeInfo: { value: { nodeId: 303, segmentId: 202 } }, + selectedNodeOutlineColor: vec3.create(), + highlightedNodeOutlineColor: vec3.create(), + nodeOutlineColorGeneration: 0, + cachedNodeOutlineColorGeneration: -1, + displayState, + }, + ); + + (layer as any).updateNodeOutlineColorPair(); + const highlightedColor = (layer as any).highlightedNodeOutlineColor; + + // The hovered outline is chosen for high contrast against its own (white) + // segment color. + expect( + getContrastRatio(highlightedColor, sourceColor), + ).toBeGreaterThanOrEqual(3); + }); + + it("derives each outline from its own segment when selected and hovered nodes belong to different segments", () => { + // Selected node on a dark segment, hovered node on a bright segment, as + // happens when hovering a merge target on a differently colored skeleton. + const selectedSegmentColor = vec3.fromValues(0, 0, 0); + const hoveredSegmentColor = vec3.fromValues(1, 1, 1); + const displayState = { + segmentationColorGroupState: { + value: { + segmentStatedColors: new Map([ + [101n, 0x000000n], + [202n, 0xffffffn], + ]), + segmentDefaultColor: { value: undefined }, + segmentColorHash: { compute: vi.fn() }, + }, + }, + saturation: { value: 0 }, + hoverHighlight: { value: true }, + segmentSelectionState: { isSelected: vi.fn(() => false), baseValue: 0n }, + }; + const layer = Object.assign( + Object.create(SpatiallyIndexedSkeletonLayer.prototype), + { + selectedNodeInfo: { value: { nodeId: 101, segmentId: 101 } }, + hoveredNodeInfo: { value: { nodeId: 303, segmentId: 202 } }, + selectedNodeOutlineColor: vec3.create(), + highlightedNodeOutlineColor: vec3.create(), + nodeOutlineColorGeneration: 0, + cachedNodeOutlineColorGeneration: -1, + displayState, + }, + ); + + (layer as any).updateNodeOutlineColorPair(); + const selectedColor = (layer as any).selectedNodeOutlineColor; + const highlightedColor = (layer as any).highlightedNodeOutlineColor; + + // Each outline contrasts against its own segment color... + expect( + getContrastRatio(selectedColor, selectedSegmentColor), + ).toBeGreaterThanOrEqual(3); + expect( + getContrastRatio(highlightedColor, hoveredSegmentColor), + ).toBeGreaterThanOrEqual(3); + // ...and the two outlines are different colors. + expect([...selectedColor]).not.toEqual([...highlightedColor]); + }); }); describe("SpatiallyIndexedSkeletonLayer targeted source invalidation", () => { diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index 4008459ab1..cf84f23435 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -117,7 +117,11 @@ import { } from "#src/trackable_value.js"; import { Uint64Set } from "#src/uint64_set.js"; import { gatherUpdate } from "#src/util/array.js"; -import { computeHighVisibilityContrastColor } from "#src/util/color.js"; +import { + getSaturation, + pickHighestContrastColor, + saturateColor, +} from "#src/util/color.js"; import { hsvToRgb } from "#src/util/colorspace.js"; import { DataType } from "#src/util/data_type.js"; import { RefCounted } from "#src/util/disposable.js"; @@ -186,20 +190,49 @@ import type { RPC } from "#src/worker_rpc.js"; const DEBUG_SPATIAL_SKELETON_OVERLAY = false; const DEBUG_EXCLUDED_SEGMENTS = false; const DEBUG_SPATIAL_SKELETON_CHUNKS = false; -// Used for debugging chunks via a different color for each chunk -const tempChunkKeyToColorMap = new Map(); -const tempMat4 = mat4.create(); const DEFAULT_FRAGMENT_MAIN = `void main() { emitDefault(); } `; - const SELECTED_NODE_OUTLINE_FALLBACK_COLOR = vec3.fromValues(1.0, 0.95, 0.35); -const SELECTED_NODE_OUTLINE_MIN_WIDTH_2D = "1.75"; -const SELECTED_NODE_OUTLINE_MAX_WIDTH_2D = "3.0"; -const SELECTED_NODE_OUTLINE_MIN_WIDTH_3D = "1.5"; -const SELECTED_NODE_OUTLINE_MAX_WIDTH_3D = "2.5"; +const SELECTED_NODE_OUTLINE_MIN_WIDTH_2D = "3.5"; +const SELECTED_NODE_OUTLINE_MAX_WIDTH_2D = "8.0"; +const SELECTED_NODE_OUTLINE_MIN_WIDTH_3D = "3.0"; +const SELECTED_NODE_OUTLINE_MAX_WIDTH_3D = "7.0"; +// Fraction of the node diameter used as the highlight outline width before +// clamping to the min/max above. Nodes are small (~5-6px), so this mostly hits +// the min for typical nodes and scales up the ring for larger nodes. +const SELECTED_NODE_OUTLINE_DIAMETER_FRACTION = "0.5"; +const NODE_BORDER_OUTLINE_DIAMETER_FRACTION = "0.15"; +const NODE_BORDER_OUTLINE_MIN_WIDTH_2D = "1.0"; +const NODE_BORDER_OUTLINE_MAX_WIDTH_2D = "2.5"; +const NODE_BORDER_OUTLINE_MIN_WIDTH_3D = "1.0"; +const NODE_BORDER_OUTLINE_MAX_WIDTH_3D = "2.0"; + +// Saturation adjustment factors for the highlighted (hovered) node border: each +// moves the segment's color away from (>1) or towards (<1) the perceptual-grey +// axis by this multiplier, clamped to [0, 1]. A segment color that is already +// very saturated has little room left to move further from grey, so boosting it +// further is barely visible; in that case the color is desaturated instead, which +// remains a visible change in either direction. Mirrors the saturation-flip +// logic in getObjectColor (segmentation_display_state/frontend.ts). +const HIGHLIGHTED_NODE_BORDER_SATURATION_BOOST_FACTOR = 1.5; +const HIGHLIGHTED_NODE_BORDER_SATURATION_REDUCE_FACTOR = 0.5; +const HIGHLIGHTED_NODE_BORDER_SATURATION_THRESHOLD = 0.5; +const SELECTED_NODE_BORDER_OUTLINE_GLSL_COLOR = "1.0, 1.0, 1.0"; +const HIGHLIGHTED_NODE_BORDER_OUTLINE_GLSL_COLOR = "0.0, 0.0, 0.0"; + +// Muted colors for the selected (pinned) node -- less vibrant. +const SELECTED_NODE_HIGHLIGHT_COLORS: readonly vec3[] = [ + vec3.fromValues(0.1, 0.1, 0.1), // near-black + vec3.fromValues(0.7, 0.67, 0.6), // stone (light warm gray) + vec3.fromValues(0.5, 0.45, 0.15), // olive +]; + +// Used for debugging chunks via a different color for each chunk +const tempChunkKeyToColorMap = new Map(); +const tempMat4 = mat4.create(); interface VertexAttributeRenderInfo extends VertexAttributeInfo { name: string; @@ -760,17 +793,28 @@ void emitDefault() { ); builder.addUniform("highp float", "uNodeDiameter"); let selectedOutlineWidthExpression = "0.0"; + let borderOutlineWidthExpression = "0.0"; if (this.nodeIdAttributeIndex !== undefined) { builder.addUniform("highp vec3", "uSelectedNodeOutlineColor"); builder.addUniform("highp int", "uSelectedNodeId"); builder.addVarying("highp float", "vSelectedNode", "flat"); + builder.addUniform("highp vec3", "uHighlightedNodeOutlineColor"); + builder.addUniform("highp int", "uHighlightedNodeId"); + builder.addVarying("highp float", "vHighlightedNode", "flat"); const selectedOutlineMinWidth = this.targetIsSliceView ? SELECTED_NODE_OUTLINE_MIN_WIDTH_2D : SELECTED_NODE_OUTLINE_MIN_WIDTH_3D; const selectedOutlineMaxWidth = this.targetIsSliceView ? SELECTED_NODE_OUTLINE_MAX_WIDTH_2D : SELECTED_NODE_OUTLINE_MAX_WIDTH_3D; - selectedOutlineWidthExpression = `(vSelectedNode * clamp(0.25 * uNodeDiameter, ${selectedOutlineMinWidth}, ${selectedOutlineMaxWidth}))`; + selectedOutlineWidthExpression = `(max(vSelectedNode, vHighlightedNode) * clamp(${SELECTED_NODE_OUTLINE_DIAMETER_FRACTION} * uNodeDiameter, ${selectedOutlineMinWidth}, ${selectedOutlineMaxWidth}))`; + const borderOutlineMinWidth = this.targetIsSliceView + ? NODE_BORDER_OUTLINE_MIN_WIDTH_2D + : NODE_BORDER_OUTLINE_MIN_WIDTH_3D; + const borderOutlineMaxWidth = this.targetIsSliceView + ? NODE_BORDER_OUTLINE_MAX_WIDTH_2D + : NODE_BORDER_OUTLINE_MAX_WIDTH_3D; + borderOutlineWidthExpression = `(max(vSelectedNode, vHighlightedNode) * clamp(${NODE_BORDER_OUTLINE_DIAMETER_FRACTION} * uNodeDiameter, ${borderOutlineMinWidth}, ${borderOutlineMaxWidth}))`; } let vertexMain = ` highp uint vertexIndex = uint(gl_InstanceID); @@ -783,6 +827,7 @@ highp vec3 vertexPosition = readAttribute0(vertexIndex); } if (this.nodeIdAttributeIndex !== undefined) { vertexMain += `vSelectedNode = float(readAttribute${this.nodeIdAttributeIndex}(vertexIndex).value == uSelectedNodeId);\n`; + vertexMain += `vHighlightedNode = float(readAttribute${this.nodeIdAttributeIndex}(vertexIndex).value == uHighlightedNodeId);\n`; } if ( skeletonParams.dynamicSegmentAppearance && @@ -794,7 +839,8 @@ highp vec3 vertexPosition = readAttribute0(vertexIndex); emitCircle( uProjection * vec4(vertexPosition, 1.0), uNodeDiameter, - ${selectedOutlineWidthExpression} + ${selectedOutlineWidthExpression}, + ${borderOutlineWidthExpression} ); `; const segmentColorExpression = this.getSegmentColorExpression(); @@ -807,8 +853,13 @@ emitCircle( // getSegmentAppearance(). uColor is unused in this path. const segmentExpression = `vSegmentValue`; const hasNodeIdSelection = this.nodeIdAttributeIndex !== undefined; + // Apply the selected outline first, then the hovered outline, so the + // hovered color wins when a node is both selected and hovered. const borderColorExpression = hasNodeIdSelection - ? `mix(renderColor, vec4(uSelectedNodeOutlineColor, renderColor.a), vSelectedNode)` + ? `mix(mix(renderColor, vec4(uSelectedNodeOutlineColor, renderColor.a), vSelectedNode), vec4(uHighlightedNodeOutlineColor, renderColor.a), vHighlightedNode)` + : "renderColor"; + const borderOutlineColorExpression = hasNodeIdSelection + ? `mix(mix(renderColor, vec4(${SELECTED_NODE_BORDER_OUTLINE_GLSL_COLOR}, renderColor.a), vSelectedNode), vec4(${HIGHLIGHTED_NODE_BORDER_OUTLINE_GLSL_COLOR}, renderColor.a), vHighlightedNode)` : "renderColor"; builder.addFragmentCode(` vec4 segmentColor() { @@ -820,7 +871,8 @@ void emitRGBA(vec4 color) { if (alpha <= 0.0) discard; vec4 renderColor = vec4(color.rgb, alpha); vec4 borderColor = ${borderColorExpression}; - vec4 circleColor = getCircleColor(renderColor, borderColor); + vec4 borderOutlineColor = ${borderOutlineColorExpression}; + vec4 circleColor = getCircleColor(renderColor, borderColor, borderOutlineColor); emit(vec4(circleColor.rgb * circleColor.a, circleColor.a), vPickID); } void emitRGB(vec3 color) { @@ -853,8 +905,13 @@ void emitDefault() { // Per-vertex color attribute path: color comes from a per-vertex // attribute; alpha is taken from the attribute's alpha component. const hasNodeIdSelection = this.nodeIdAttributeIndex !== undefined; + // Apply the selected outline first, then the hovered outline, so the + // hovered color wins when a node is both selected and hovered. const borderColorExpression = hasNodeIdSelection - ? `mix(renderColor, vec4(uSelectedNodeOutlineColor, renderColor.a), vSelectedNode)` + ? `mix(mix(renderColor, vec4(uSelectedNodeOutlineColor, renderColor.a), vSelectedNode), vec4(uHighlightedNodeOutlineColor, renderColor.a), vHighlightedNode)` + : "renderColor"; + const borderOutlineColorExpression = hasNodeIdSelection + ? `mix(mix(renderColor, vec4(${SELECTED_NODE_BORDER_OUTLINE_GLSL_COLOR}, renderColor.a), vSelectedNode), vec4(${HIGHLIGHTED_NODE_BORDER_OUTLINE_GLSL_COLOR}, renderColor.a), vHighlightedNode)` : "renderColor"; builder.addFragmentCode(` vec4 segmentColor() { @@ -863,7 +920,8 @@ vec4 segmentColor() { void emitRGBA(vec4 color) { vec4 renderColor = color; vec4 borderColor = ${borderColorExpression}; - vec4 circleColor = getCircleColor(renderColor, borderColor); + vec4 borderOutlineColor = ${borderOutlineColorExpression}; + vec4 circleColor = getCircleColor(renderColor, borderColor, borderOutlineColor); emit(vec4(circleColor.rgb * circleColor.a, circleColor.a), vPickID); } void emitRGB(vec3 color) { @@ -960,7 +1018,6 @@ void emitDefault() { nodeShader: ShaderProgram, skeletonGpuGeometry: SkeletonGPUGeometry, projectionParameters: { width: number; height: number }, - drawNodes: boolean, ) { // Bind vertex attribute textures to be used across edge and node shaders // The edge shader and node shader share the same texture unit for each attribute @@ -1000,8 +1057,8 @@ void emitDefault() { gl.disableVertexAttribArray(aVertexIndex); } - // Draw nodes if in line and node mode - if (drawNodes) { + // Draw nodes + { nodeShader.bind(); initializeCircleShader(nodeShader, projectionParameters, { featherWidthInPixels: this.targetIsSliceView ? 1.0 : 0.0, @@ -1333,9 +1390,6 @@ export class SkeletonLayer extends RefCounted implements SkeletonShaderContext { const { shaderControlState } = this.displayState.skeletonRenderingOptions; - const drawNodes = - renderOptions.mode.value === SkeletonRenderMode.LINES_AND_POINTS; - edgeShader.bind(); renderHelper.beginLayer(gl, edgeShader, renderContext, modelMatrix); renderHelper.setPickInstanceStride(gl, edgeShader, 0); @@ -1348,24 +1402,21 @@ export class SkeletonLayer extends RefCounted implements SkeletonShaderContext { gl.uniform1f(edgeShader.uniform("uLineWidth"), lineWidth!); gl.uniform1f( edgeShader.uniform("uLineEndpointClipRadius"), - drawNodes ? pointDiameter / 2 : 0, + pointDiameter / 2, ); - if (drawNodes) { - nodeShader.bind(); - renderHelper.beginLayer(gl, nodeShader, renderContext, modelMatrix); - gl.uniform1f(nodeShader.uniform("uNodeDiameter"), pointDiameter); - renderHelper.setPickInstanceStride(gl, nodeShader, 0); - setControlsInShader( - gl, - nodeShader, - shaderControlState, - nodeShaderParameters.parseResult, - ); - } + nodeShader.bind(); + renderHelper.beginLayer(gl, nodeShader, renderContext, modelMatrix); + gl.uniform1f(nodeShader.uniform("uNodeDiameter"), pointDiameter); + renderHelper.setPickInstanceStride(gl, nodeShader, 0); + setControlsInShader( + gl, + nodeShader, + shaderControlState, + nodeShaderParameters.parseResult, + ); const skeletons = source.chunks; - forEachVisibleSegmentToDraw( displayState, layer, @@ -1380,21 +1431,19 @@ export class SkeletonLayer extends RefCounted implements SkeletonShaderContext { ) { return; } + edgeShader.bind(); if (color !== undefined) { - edgeShader.bind(); renderHelper.setColor(gl, edgeShader, color); - if (drawNodes) { - nodeShader.bind(); - renderHelper.setColor(gl, nodeShader, color); - } } if (pickIndex !== undefined) { - edgeShader.bind(); renderHelper.setPickID(gl, edgeShader, pickIndex); - if (drawNodes) { - nodeShader.bind(); - renderHelper.setPickID(gl, nodeShader, pickIndex); - } + } + nodeShader.bind(); + if (color !== undefined) { + renderHelper.setColor(gl, nodeShader, color); + } + if (pickIndex !== undefined) { + renderHelper.setPickID(gl, nodeShader, pickIndex); } renderHelper.drawSkeletons( gl, @@ -1402,7 +1451,6 @@ export class SkeletonLayer extends RefCounted implements SkeletonShaderContext { nodeShader, skeleton, renderContext.projectionParameters, - drawNodes, ); }, ); @@ -1463,7 +1511,11 @@ export class PerspectiveViewSkeletonLayer extends PerspectiveViewRenderLayer { } get isTransparent() { - return this.base.displayState.objectAlpha.value < 1.0; + return true; + // TODO (SKM) the below is the correct way to do this + // but non-transparent rendering interacts badly with the + // volume rendering + // return this.base.displayState.objectAlpha.value < 1.0; } draw( @@ -1784,6 +1836,46 @@ export class SpatiallyIndexedSkeletonSource extends SliceViewChunkSource< } } +export interface SpatiallyIndexedSkeletonSourceRuntimeDisposalOptions { + invalidateCache?: boolean; +} + +export function disposeSpatiallyIndexedSkeletonSourceRuntimeState( + sources: Iterable, + options: SpatiallyIndexedSkeletonSourceRuntimeDisposalOptions = {}, +) { + const uniqueSources = new Set(sources); + const invalidateCache = options.invalidateCache ?? true; + const chunkQueueManagersWithDeletedChunks = new Set< + ChunkManager["chunkQueueManager"] + >(); + let changed = false; + for (const source of uniqueSources) { + if (source.chunks.size !== 0) { + for (const chunkKey of source.chunks.keys()) { + source.deleteChunk(chunkKey); + } + chunkQueueManagersWithDeletedChunks.add( + source.chunkManager.chunkQueueManager, + ); + changed = true; + } + if ( + invalidateCache && + source.wasDisposed !== true && + source.rpc !== null && + source.rpcId !== null + ) { + source.invalidateCache(); + changed = true; + } + } + for (const chunkQueueManager of chunkQueueManagersWithDeletedChunks) { + chunkQueueManager.visibleChunksChanged.dispatch(); + } + return changed; +} + // Options are provided by the SliceView framework for scale selection, // but spatial skeleton sources expose all grid levels unconditionally. // TODO (SKM): validate if this is an ok deviation from the SliceView @@ -1844,6 +1936,9 @@ interface SpatiallyIndexedSkeletonLayerOptions { selectedNodeInfo?: WatchableValueInterface< SelectedSkeletonNodeInfo | undefined >; + hoveredNodeInfo?: WatchableValueInterface< + SelectedSkeletonNodeInfo | undefined + >; pendingNodePositionVersion?: WatchableValueInterface; getPendingNodePosition?: (nodeId: number) => ArrayLike | undefined; getCachedNode?: (nodeId: number) => SpatiallyIndexedSkeletonNode | undefined; @@ -2073,6 +2168,9 @@ export class SpatiallyIndexedSkeletonLayer private selectedNodeInfo: | WatchableValueInterface | undefined; + private hoveredNodeInfo: + | WatchableValueInterface + | undefined; private pendingNodePositionVersion: | WatchableValueInterface | undefined; @@ -2096,13 +2194,70 @@ export class SpatiallyIndexedSkeletonLayer private readonly selectedNodeOutlineColor = vec3.clone( SELECTED_NODE_OUTLINE_FALLBACK_COLOR, ); - private selectedNodeOutlineColorGeneration = 0; - private cachedSelectedNodeOutlineColorGeneration = -1; + private readonly highlightedNodeOutlineColor = vec3.clone( + SELECTED_NODE_OUTLINE_FALLBACK_COLOR, + ); + // The selected and hovered outline colors are derived together from a single + // source segment color, so they share one cache generation. + private nodeOutlineColorGeneration = 0; + private cachedNodeOutlineColorGeneration = -1; private disposeOverlayChunk() { + const changed = + this.overlayChunk !== undefined || this.overlayGeometryKey !== undefined; this.overlayChunk?.dispose(this.gl); this.overlayChunk = undefined; this.overlayGeometryKey = undefined; + return changed; + } + + getUniqueChunkSources() { + const sources = new Set(); + for (const sourceEntry of [...this.sources, ...this.sources2d]) { + sources.add(sourceEntry.chunkSource); + } + return sources; + } + + private clearOverlayRuntimeState() { + let changed = this.disposeOverlayChunk(); + if (this.pendingOverlaySegmentLoads.size !== 0) { + this.pendingOverlaySegmentLoads.clear(); + changed = true; + } + if (this.editedSegmentIds.size !== 0) { + this.editedSegmentIds.clear(); + changed = true; + } + if (this.retainedOverlaySegmentIds.length !== 0) { + this.retainedOverlaySegmentIds = []; + changed = true; + } + if (this.browseExcludedSegments.size !== 0) { + this.browseExcludedSegments.clear(); + changed = true; + } + if (this.browseExcludedSegmentsKey !== undefined) { + this.browseExcludedSegmentsKey = undefined; + changed = true; + } + this.overlayRebuildFrame = -1; + return changed; + } + + disposeRuntimeState( + options: SpatiallyIndexedSkeletonSourceRuntimeDisposalOptions = {}, + ) { + const overlayChanged = this.clearOverlayRuntimeState(); + const sourceChanged = disposeSpatiallyIndexedSkeletonSourceRuntimeState( + this.getUniqueChunkSources(), + options, + ); + const changed = overlayChanged || sourceChanged; + if (changed) { + this.redrawNeeded.dispatch(); + } + return changed; } private requestOverlaySegmentLoad(segmentId: number) { @@ -2150,27 +2305,75 @@ export class SpatiallyIndexedSkeletonLayer return segmentIds; } - private getSelectedNodeOutlineColor() { - const nodeInfo = this.selectedNodeInfo?.value; - if (nodeInfo === undefined) { - return SELECTED_NODE_OUTLINE_FALLBACK_COLOR; - } - const currentGeneration = this.selectedNodeOutlineColorGeneration; - if (this.cachedSelectedNodeOutlineColorGeneration === currentGeneration) { - return this.selectedNodeOutlineColor; - } + // Segment fill color a node's outline should contrast against, or undefined + // when no segment can be resolved. Falls back to the currently selected + // segment when the node carries no segment id. + private getNodeSegmentColor( + nodeInfo: SelectedSkeletonNodeInfo, + ): Float32Array | undefined { const segmentId = nodeInfo.segmentId !== undefined ? BigInt(nodeInfo.segmentId) : this.displayState.segmentSelectionState.baseValue; if (segmentId === undefined) { - return SELECTED_NODE_OUTLINE_FALLBACK_COLOR; + return undefined; + } + return getBaseObjectColor(this.displayState, segmentId); + } + + // Updates `selectedNodeOutlineColor` and `highlightedNodeOutlineColor` in + // place. Each outline is chosen, independently of the other, for high contrast + // against its own node's segment color: the selected node uses the muted + // palette, and the hovered node uses its own segment color pushed away from + // (or, if already very saturated, towards) grey. Because the two are computed + // independently, a given segment color always yields the same selected color + // and the same hovered color. + private updateNodeOutlineColorPair() { + const currentGeneration = this.nodeOutlineColorGeneration; + if (this.cachedNodeOutlineColorGeneration === currentGeneration) { + return; + } + this.cachedNodeOutlineColorGeneration = currentGeneration; + + const selectedNodeInfo = this.selectedNodeInfo?.value; + const selectedSegmentColor = + selectedNodeInfo !== undefined + ? this.getNodeSegmentColor(selectedNodeInfo) + : undefined; + if (selectedSegmentColor !== undefined) { + this.selectedNodeOutlineColor.set( + pickHighestContrastColor( + SELECTED_NODE_HIGHLIGHT_COLORS, + selectedSegmentColor, + ), + ); + } else { + vec3.copy( + this.selectedNodeOutlineColor, + SELECTED_NODE_OUTLINE_FALLBACK_COLOR, + ); + } + + const hoveredNodeInfo = this.hoveredNodeInfo?.value; + const hoveredSegmentColor = + hoveredNodeInfo !== undefined + ? this.getNodeSegmentColor(hoveredNodeInfo) + : undefined; + if (hoveredSegmentColor !== undefined) { + const saturationFactor = + getSaturation(hoveredSegmentColor) > + HIGHLIGHTED_NODE_BORDER_SATURATION_THRESHOLD + ? HIGHLIGHTED_NODE_BORDER_SATURATION_REDUCE_FACTOR + : HIGHLIGHTED_NODE_BORDER_SATURATION_BOOST_FACTOR; + this.highlightedNodeOutlineColor.set( + saturateColor(hoveredSegmentColor, saturationFactor), + ); + } else { + vec3.copy( + this.highlightedNodeOutlineColor, + SELECTED_NODE_OUTLINE_FALLBACK_COLOR, + ); } - this.cachedSelectedNodeOutlineColorGeneration = currentGeneration; - return computeHighVisibilityContrastColor( - this.selectedNodeOutlineColor, - getBaseObjectColor(this.displayState, segmentId), - ); } getRetainedOverlaySegmentIds() { @@ -2333,7 +2536,7 @@ export class SpatiallyIndexedSkeletonLayer ) { super(); this.registerDisposer(() => { - this.disposeOverlayChunk(); + this.disposeRuntimeState(); }); let sources3d: SpatiallyIndexedSkeletonSourceEntry[]; let sources2d = options.sources2d ?? []; @@ -2372,6 +2575,7 @@ export class SpatiallyIndexedSkeletonLayer ), ); this.selectedNodeInfo = options.selectedNodeInfo; + this.hoveredNodeInfo = options.hoveredNodeInfo; this.pendingNodePositionVersion = options.pendingNodePositionVersion; this.getPendingNodePositionOverride = options.getPendingNodePosition; this.getCachedNodeInfo = options.getCachedNode; @@ -2384,8 +2588,8 @@ export class SpatiallyIndexedSkeletonLayer ), ); registerRedrawWhenSegmentationDisplayState3DChanged(displayState, this); - const invalidateSelectedNodeOutlineColor = () => { - ++this.selectedNodeOutlineColorGeneration; + const invalidateNodeOutlineColors = () => { + ++this.nodeOutlineColorGeneration; }; this.displayState.shaderError.value = undefined; const { skeletonRenderingOptions: renderingOptions } = displayState; @@ -2437,17 +2641,17 @@ export class SpatiallyIndexedSkeletonLayer registerNested((context, colorGroupState) => { context.registerDisposer( colorGroupState.segmentColorHash.changed.add( - invalidateSelectedNodeOutlineColor, + invalidateNodeOutlineColors, ), ); context.registerDisposer( colorGroupState.segmentStatedColors.changed.add( - invalidateSelectedNodeOutlineColor, + invalidateNodeOutlineColors, ), ); context.registerDisposer( colorGroupState.segmentDefaultColor.changed.add( - invalidateSelectedNodeOutlineColor, + invalidateNodeOutlineColors, ), ); }, this.displayState.segmentationColorGroupState), @@ -2482,7 +2686,17 @@ export class SpatiallyIndexedSkeletonLayer if (this.selectedNodeInfo?.changed) { this.registerDisposer( this.selectedNodeInfo.changed.add(() => { - invalidateSelectedNodeOutlineColor(); + invalidateNodeOutlineColors(); + requestRedraw(); + }), + ); + } + if (this.hoveredNodeInfo?.changed) { + this.registerDisposer( + this.hoveredNodeInfo.changed.add(() => { + // The hovered node drives both which node is outlined and the source + // segment color of its outline. + invalidateNodeOutlineColors(); requestRedraw(); }), ); @@ -2497,7 +2711,7 @@ export class SpatiallyIndexedSkeletonLayer if (inspectionState !== undefined) { this.registerDisposer( inspectionState.nodeDataVersion.changed.add(() => { - invalidateSelectedNodeOutlineColor(); + invalidateNodeOutlineColors(); this.redrawNeeded.dispatch(); }), ); @@ -2803,7 +3017,6 @@ export class SpatiallyIndexedSkeletonLayer modelMatrix: mat4, lineWidth: number, pointDiameter: number, - renderMode: SkeletonRenderMode, excludedGPUTable?: GPUHashTable, ): | { @@ -2829,7 +3042,6 @@ export class SpatiallyIndexedSkeletonLayer nodeShaderResult; if (edgeShader === null || nodeShader === null) return undefined; - const drawNodes = renderMode === SkeletonRenderMode.LINES_AND_POINTS; const { shaderControlState } = this.displayState.skeletonRenderingOptions; edgeShader.bind(); @@ -2837,7 +3049,7 @@ export class SpatiallyIndexedSkeletonLayer gl.uniform1f(edgeShader.uniform("uLineWidth"), lineWidth); gl.uniform1f( edgeShader.uniform("uLineEndpointClipRadius"), - drawNodes ? pointDiameter / 2 : 0, + pointDiameter / 2, ); renderHelper.setPickInstanceStride(gl, edgeShader, 0); setControlsInShader( @@ -2854,25 +3066,23 @@ export class SpatiallyIndexedSkeletonLayer excludedGPUTable, ); - if (drawNodes) { - nodeShader.bind(); - renderHelper.beginLayer(gl, nodeShader, renderContext, modelMatrix); - gl.uniform1f(nodeShader.uniform("uNodeDiameter"), pointDiameter); - renderHelper.setPickInstanceStride(gl, nodeShader, 0); - setControlsInShader( - gl, - nodeShader, - shaderControlState, - nodeShaderParameters.parseResult, - ); - renderHelper.setColor(gl, nodeShader, kOneVec4); - renderHelper.maybeEnableDynamicSegmentAppearance( - gl, - nodeShader, - skeletonParams, - excludedGPUTable, - ); - } + nodeShader.bind(); + renderHelper.beginLayer(gl, nodeShader, renderContext, modelMatrix); + gl.uniform1f(nodeShader.uniform("uNodeDiameter"), pointDiameter); + renderHelper.setPickInstanceStride(gl, nodeShader, 0); + setControlsInShader( + gl, + nodeShader, + shaderControlState, + nodeShaderParameters.parseResult, + ); + renderHelper.setColor(gl, nodeShader, kOneVec4); + renderHelper.maybeEnableDynamicSegmentAppearance( + gl, + nodeShader, + skeletonParams, + excludedGPUTable, + ); return { gl, @@ -2888,20 +3098,17 @@ export class SpatiallyIndexedSkeletonLayer edgeShader: ShaderProgram, nodeShader: ShaderProgram, skeletonParams: SkeletonShaderParameters, - drawNodes: boolean, ) { renderHelper.maybeDisableDynamicSegmentAppearance( gl, edgeShader, skeletonParams, ); - if (drawNodes) { - renderHelper.maybeDisableDynamicSegmentAppearance( - gl, - nodeShader, - skeletonParams, - ); - } + renderHelper.maybeDisableDynamicSegmentAppearance( + gl, + nodeShader, + skeletonParams, + ); renderHelper.endLayer(gl, edgeShader, nodeShader); } @@ -2912,7 +3119,6 @@ export class SpatiallyIndexedSkeletonLayer modelMatrix: mat4, lineWidth: number, pointDiameter: number, - renderMode: SkeletonRenderMode, visibleChunks: VisibleChunk[], ) { if (visibleChunks.length === 0) return; @@ -2924,24 +3130,29 @@ export class SpatiallyIndexedSkeletonLayer modelMatrix, lineWidth, pointDiameter, - renderMode, hasExcludedSegments ? this.gpuBrowseExcludedSegmentsHashTable : undefined, ); if (passState === undefined) return; const { gl, edgeShader, nodeShader, skeletonParams } = passState; - const drawNodes = renderMode === SkeletonRenderMode.LINES_AND_POINTS; - if (drawNodes) { - nodeShader.bind(); - gl.uniform3fv( - nodeShader.uniform("uSelectedNodeOutlineColor"), - this.getSelectedNodeOutlineColor(), - ); - gl.uniform1i( - nodeShader.uniform("uSelectedNodeId"), - this.selectedNodeInfo?.value?.nodeId ?? -1, - ); - } + nodeShader.bind(); + this.updateNodeOutlineColorPair(); + gl.uniform3fv( + nodeShader.uniform("uSelectedNodeOutlineColor"), + this.selectedNodeOutlineColor, + ); + gl.uniform1i( + nodeShader.uniform("uSelectedNodeId"), + this.selectedNodeInfo?.value?.nodeId ?? -1, + ); + gl.uniform3fv( + nodeShader.uniform("uHighlightedNodeOutlineColor"), + this.highlightedNodeOutlineColor, + ); + gl.uniform1i( + nodeShader.uniform("uHighlightedNodeId"), + this.hoveredNodeInfo?.value?.nodeId ?? -1, + ); const chunkOrigin = vec3.create(); const chunkBound = vec3.create(); @@ -2951,10 +3162,8 @@ export class SpatiallyIndexedSkeletonLayer vec3.add(chunkBound, chunkOrigin, chunkLayout.size); edgeShader.bind(); renderHelper.setChunkBounds(gl, edgeShader, chunkOrigin, chunkBound); - if (drawNodes) { - nodeShader.bind(); - renderHelper.setChunkBounds(gl, nodeShader, chunkOrigin, chunkBound); - } + nodeShader.bind(); + renderHelper.setChunkBounds(gl, nodeShader, chunkOrigin, chunkBound); } if (renderContext.emitPickID) { let edgePickId = 0; @@ -2973,7 +3182,7 @@ export class SpatiallyIndexedSkeletonLayer ); edgePickStride = 1; } - if (chunk.numVertices > 0 && drawNodes) { + if (chunk.numVertices > 0) { nodePickId = renderContext.pickIDs.register( layer, chunk.numVertices, @@ -2988,11 +3197,9 @@ export class SpatiallyIndexedSkeletonLayer edgeShader.bind(); renderHelper.setPickID(gl, edgeShader, edgePickId); renderHelper.setPickInstanceStride(gl, edgeShader, edgePickStride); - if (drawNodes) { - nodeShader.bind(); - renderHelper.setPickID(gl, nodeShader, nodePickId); - renderHelper.setPickInstanceStride(gl, nodeShader, nodePickStride); - } + nodeShader.bind(); + renderHelper.setPickID(gl, nodeShader, nodePickId); + renderHelper.setPickInstanceStride(gl, nodeShader, nodePickStride); } // Render each chunk with different node/edge colors for debugging @@ -3014,13 +3221,11 @@ export class SpatiallyIndexedSkeletonLayer tempChunkKeyToColorMap.set(chunkKey, randomColor); } if (skeletonParams.hasSegmentDefaultColor) { - if (drawNodes) { - nodeShader.bind(); - gl.uniform3fv( - nodeShader.uniform("uSegmentDefaultColor"), - randomColor, - ); - } + nodeShader.bind(); + gl.uniform3fv( + nodeShader.uniform("uSegmentDefaultColor"), + randomColor, + ); edgeShader.bind(); gl.uniform3fv( edgeShader.uniform("uSegmentDefaultColor"), @@ -3035,7 +3240,6 @@ export class SpatiallyIndexedSkeletonLayer nodeShader, chunk, renderContext.projectionParameters, - drawNodes, ); } this.endSkeletonRenderPass( @@ -3044,7 +3248,6 @@ export class SpatiallyIndexedSkeletonLayer edgeShader, nodeShader, skeletonParams, - drawNodes, ); } @@ -3055,7 +3258,6 @@ export class SpatiallyIndexedSkeletonLayer modelMatrix: mat4, lineWidth: number, pointDiameter: number, - renderMode: SkeletonRenderMode, ) { const overlayChunk = this.resolveSourceBackedOverlayChunk(); if (overlayChunk === undefined) return; @@ -3065,23 +3267,28 @@ export class SpatiallyIndexedSkeletonLayer modelMatrix, lineWidth, pointDiameter, - renderMode, ); if (passState === undefined) return; const { gl, edgeShader, nodeShader, skeletonParams } = passState; - const drawNodes = renderMode === SkeletonRenderMode.LINES_AND_POINTS; - if (drawNodes) { - nodeShader.bind(); - gl.uniform3fv( - nodeShader.uniform("uSelectedNodeOutlineColor"), - this.getSelectedNodeOutlineColor(), - ); - gl.uniform1i( - nodeShader.uniform("uSelectedNodeId"), - this.selectedNodeInfo?.value?.nodeId ?? -1, - ); - } + nodeShader.bind(); + this.updateNodeOutlineColorPair(); + gl.uniform3fv( + nodeShader.uniform("uSelectedNodeOutlineColor"), + this.selectedNodeOutlineColor, + ); + gl.uniform1i( + nodeShader.uniform("uSelectedNodeId"), + this.selectedNodeInfo?.value?.nodeId ?? -1, + ); + gl.uniform3fv( + nodeShader.uniform("uHighlightedNodeOutlineColor"), + this.highlightedNodeOutlineColor, + ); + gl.uniform1i( + nodeShader.uniform("uHighlightedNodeId"), + this.hoveredNodeInfo?.value?.nodeId ?? -1, + ); if (renderContext.emitPickID) { const edgePickId = @@ -3106,32 +3313,30 @@ export class SpatiallyIndexedSkeletonLayer edgePickId === 0 ? 0 : 1, ); - if (drawNodes) { - const nodePickId = - overlayChunk.numVertices > 0 && - overlayChunk.pickNodeIds !== undefined && - overlayChunk.pickNodePositions !== undefined && - overlayChunk.pickSegmentIds !== undefined - ? renderContext.pickIDs.register( - layer, - overlayChunk.numVertices, - 0n, - { - kind: "node", - nodeIds: overlayChunk.pickNodeIds, - nodePositions: overlayChunk.pickNodePositions, - segmentIds: overlayChunk.pickSegmentIds, - } satisfies SpatiallyIndexedSkeletonPickData, - ) - : 0; - nodeShader.bind(); - renderHelper.setPickID(gl, nodeShader, nodePickId); - renderHelper.setPickInstanceStride( - gl, - nodeShader, - nodePickId === 0 ? 0 : 1, - ); - } + const nodePickId = + overlayChunk.numVertices > 0 && + overlayChunk.pickNodeIds !== undefined && + overlayChunk.pickNodePositions !== undefined && + overlayChunk.pickSegmentIds !== undefined + ? renderContext.pickIDs.register( + layer, + overlayChunk.numVertices, + 0n, + { + kind: "node", + nodeIds: overlayChunk.pickNodeIds, + nodePositions: overlayChunk.pickNodePositions, + segmentIds: overlayChunk.pickSegmentIds, + } satisfies SpatiallyIndexedSkeletonPickData, + ) + : 0; + nodeShader.bind(); + renderHelper.setPickID(gl, nodeShader, nodePickId); + renderHelper.setPickInstanceStride( + gl, + nodeShader, + nodePickId === 0 ? 0 : 1, + ); } renderHelper.drawSkeletons( @@ -3140,7 +3345,6 @@ export class SpatiallyIndexedSkeletonLayer nodeShader, overlayChunk, renderContext.projectionParameters, - drawNodes, ); this.endSkeletonRenderPass( renderHelper, @@ -3148,7 +3352,6 @@ export class SpatiallyIndexedSkeletonLayer edgeShader, nodeShader, skeletonParams, - drawNodes, ); } @@ -3182,7 +3385,6 @@ export class SpatiallyIndexedSkeletonLayer modelMatrix, lineWidth, pointDiameter, - renderOptions.mode.value, visibleChunks, ); this.drawInspectionOverlayPass( @@ -3192,7 +3394,6 @@ export class SpatiallyIndexedSkeletonLayer modelMatrix, lineWidth, pointDiameter, - renderOptions.mode.value, ); } @@ -3403,12 +3604,16 @@ export class PerspectiveViewSpatiallyIndexedSkeletonLayer extends PerspectiveVie } get isTransparent() { - const { objectAlpha, hiddenObjectAlpha } = this.base.displayState; - const opaque = - (objectAlpha.value == 1.0 && - (hiddenObjectAlpha.value == 1.0 || hiddenObjectAlpha.value == 0.0)) || - (objectAlpha.value == 0.0 && hiddenObjectAlpha.value == 1.0); - return !opaque; + return true; + // TODO (SKM) the below is the correct way to do this + // but non-transparent rendering interacts badly with the + // volume rendering + // const { objectAlpha, hiddenObjectAlpha } = this.base.displayState; + // const opaque = + // (objectAlpha.value == 1.0 && + // (hiddenObjectAlpha.value == 1.0 || hiddenObjectAlpha.value == 0.0)) || + // (objectAlpha.value == 0.0 && hiddenObjectAlpha.value == 1.0); + // return !opaque; } getValueAt(_position: Float32Array) { 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..bd9a19de06 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"; @@ -411,6 +411,40 @@ export class SpatialSkeletonState extends RefCounted { return true; } + clearRuntimeState() { + const cacheChanged = + this.fullSegmentNodeCache.size !== 0 || + this.pendingFullSegmentNodeFetches.size !== 0 || + this.cachedNodesById.size !== 0; + const pendingChanged = this.clearPendingNodePositions(); + const mergeAnchorChanged = this.clearMergeAnchor(); + let modeChanged = false; + if (this.editMode.value) { + this.editMode.value = false; + modeChanged = true; + } + if (this.mergeMode.value) { + this.mergeMode.value = false; + modeChanged = true; + } + if (this.splitMode.value) { + this.splitMode.value = false; + modeChanged = true; + } + const historyChanged = this.commandHistory.clear(); + if (cacheChanged) { + this.clearFullSkeletonCache(); + this.nodeDataVersion.value = this.nodeDataVersion.value + 1; + } + return ( + cacheChanged || + pendingChanged || + mergeAnchorChanged || + modeChanged || + historyChanged + ); + } + markNodeDataChanged(options: { invalidateFullSkeletonCache?: boolean } = {}) { if (options.invalidateFullSkeletonCache ?? true) { this.clearFullSkeletonCache(); diff --git a/src/sliceview/panel.ts b/src/sliceview/panel.ts index 980a38dc7d..35657056e4 100644 --- a/src/sliceview/panel.ts +++ b/src/sliceview/panel.ts @@ -19,6 +19,7 @@ import type { DisplayContext } from "#src/display_context.js"; import type { VisibleRenderLayerTracker } from "#src/layer/index.js"; import { makeRenderedPanelVisibleLayerTracker } from "#src/layer/index.js"; import { PickIDManager } from "#src/object_picking.js"; +import { PickingIndicatorHelper } from "#src/picking_indicator.js"; import type { FramePickingData, RenderedDataViewerState, @@ -66,6 +67,7 @@ export interface SliceViewerState extends RenderedDataViewerState { scaleBarOptions: TrackableScaleBarOptions; crossSectionBackgroundColor: TrackableRGB; hideCrossSectionBackground3D: TrackableBoolean; + showPickingIndicator: TrackableBoolean; } export enum OffscreenTextures { @@ -104,6 +106,9 @@ export class SliceViewPanel extends RenderedDataPanel { private sliceViewRenderHelper; private axesLineHelper = this.registerDisposer(AxesLineHelper.get(this.gl)); + private pickingIndicatorHelper = this.registerDisposer( + PickingIndicatorHelper.get(this.gl), + ); private colorFactor = vec4.fromValues(1, 1, 1, 1); private pickIDs = new PickIDManager(); @@ -276,6 +281,18 @@ export class SliceViewPanel extends RenderedDataPanel { } }), ); + this.registerDisposer( + viewer.showPickingIndicator.changed.add(() => { + if (this.visible) this.scheduleRedraw(); + }), + ); + this.registerDisposer( + viewer.mouseState.changed.add(() => { + if (this.viewer.showPickingIndicator.value && this.visible) { + this.scheduleRedraw(); + } + }), + ); } translateByViewportPixels(deltaX: number, deltaY: number): void { @@ -433,6 +450,10 @@ export class SliceViewPanel extends RenderedDataPanel { } } + if (this.viewer.showPickingIndicator.value) { + this.offscreenFramebuffer.bindSingle(OffscreenTextures.COLOR); + this.drawPickingIndicator(); + } this.offscreenFramebuffer.unbind(); // Draw the texture over the whole viewport. @@ -532,6 +553,49 @@ export class SliceViewPanel extends RenderedDataPanel { setStateFromRelative(pickRadius, pickRadius, 0); } + private drawPickingIndicator() { + const { mouseState } = this.viewer; + if (!mouseState.active) return; + const { + viewProjectionMat, + logicalWidth, + logicalHeight, + width, + height, + displayDimensionRenderInfo: { displayDimensionIndices }, + } = this.sliceView.projectionParameters.value; + const displayPos = tempVec3; + displayPos[0] = + displayDimensionIndices[0] >= 0 + ? mouseState.position[displayDimensionIndices[0]] + : 0; + displayPos[1] = + displayDimensionIndices[1] >= 0 + ? mouseState.position[displayDimensionIndices[1]] + : 0; + displayPos[2] = + displayDimensionIndices[2] >= 0 + ? mouseState.position[displayDimensionIndices[2]] + : 0; + vec3.transformMat4(displayPos, displayPos, viewProjectionMat); + if (displayPos[2] < -1 || displayPos[2] > 1) return; + const { gl } = this; + gl.disable(gl.SCISSOR_TEST); + gl.disable(gl.DEPTH_TEST); + gl.depthMask(false); + gl.viewport(0, 0, width, height); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + this.pickingIndicatorHelper.draw( + displayPos[0], + displayPos[1], + logicalWidth, + logicalHeight, + ); + gl.disable(gl.BLEND); + gl.depthMask(true); + } + /** * Zooms by the specified factor, maintaining the data position that projects to the current mouse * position. diff --git a/src/status.ts b/src/status.ts index 5713c3737c..4c60190c41 100644 --- a/src/status.ts +++ b/src/status.ts @@ -146,9 +146,9 @@ export class StatusMessage { if (this.modalElementWrapper !== undefined) { modalStatusContainer!.removeChild(this.modalElementWrapper); this.modalElementWrapper = undefined; - getStatusContainer().appendChild(this.element); + getStatusContainer().prepend(this.element); } else if (this.element.parentElement === null) { - getStatusContainer().appendChild(this.element); + getStatusContainer().prepend(this.element); } } } diff --git a/src/ui/command_palette.spec.ts b/src/ui/command_palette.spec.ts index a067a03ec3..7675f93484 100644 --- a/src/ui/command_palette.spec.ts +++ b/src/ui/command_palette.spec.ts @@ -18,26 +18,49 @@ import { describe, expect, it } from "vitest"; import { collectActionBindings, CommandCatalog, + type CommandCatalogContext, } from "#src/ui/command_palette.js"; import { EventActionMap } from "#src/util/event_action_map.js"; -import type { Viewer } from "#src/viewer.js"; +import type { InputEventBindings } from "#src/viewer.js"; -function makeViewer( +function makeInputEventBindings( global: EventActionMap, sliceView = new EventActionMap(), perspectiveView = new EventActionMap(), -): Viewer { +): InputEventBindings { return { - inputEventBindings: { global, sliceView, perspectiveView }, - globalToolBinder: { bindings: new Map(), localBinders: new Set() }, - } as unknown as Viewer; + global, + sliceView, + perspectiveView, + } as unknown as InputEventBindings; +} + +const noopSignal = { add: () => () => {} }; + +function makeContext( + inputEventBindings = makeInputEventBindings(new EventActionMap()), +): CommandCatalogContext { + return { + globalToolBinder: { + changed: noopSignal, + bindings: new Map(), + localBinders: new Set(), + }, + layerManager: { + layersChanged: noopSignal, + managedLayers: [], + getLayerByName: () => undefined, + }, + selectedLayer: {}, + inputEventBindings, + } as unknown as CommandCatalogContext; } describe("collectActionBindings", () => { it("collects keyboard bindings", () => { const map = new EventActionMap(); map.set("keya", "some-action"); - const bindings = collectActionBindings(makeViewer(map)); + const bindings = collectActionBindings(makeInputEventBindings(map)); expect(bindings.map((binding) => binding.actionId)).toContain( "some-action", ); @@ -48,7 +71,9 @@ describe("collectActionBindings", () => { map.set("at:mousedown0", "mouse-action"); map.set("at:wheel", "wheel-action"); map.set("keya", "keyboard-action"); - const ids = collectActionBindings(makeViewer(map)).map((b) => b.actionId); + const ids = collectActionBindings(makeInputEventBindings(map)).map( + (b) => b.actionId, + ); expect(ids).toContain("keyboard-action"); expect(ids).not.toContain("mouse-action"); expect(ids).not.toContain("wheel-action"); @@ -59,7 +84,9 @@ describe("collectActionBindings", () => { globalMap.set("keya", "shared-action"); const sliceMap = new EventActionMap(); sliceMap.set("keyb", "shared-action"); - const bindings = collectActionBindings(makeViewer(globalMap, sliceMap)); + const bindings = collectActionBindings( + makeInputEventBindings(globalMap, sliceMap), + ); const forAction = bindings.filter((b) => b.actionId === "shared-action"); expect(forAction).toHaveLength(1); expect(forAction[0].eventAction.originalEventIdentifier).toBe("keya"); @@ -69,7 +96,9 @@ describe("collectActionBindings", () => { const map = new EventActionMap(); map.set("f1", "open-command-palette"); map.set("keya", "some-action"); - const ids = collectActionBindings(makeViewer(map)).map((b) => b.actionId); + const ids = collectActionBindings(makeInputEventBindings(map)).map( + (b) => b.actionId, + ); expect(ids).not.toContain("open-command-palette"); expect(ids).toContain("some-action"); }); @@ -79,12 +108,7 @@ describe("CommandCatalog.filter", () => { // With empty bindings the catalog contains only the two supplemental commands: // "Edit JSON State" and "Screenshot". function makeCatalog() { - return new CommandCatalog( - { - globalToolBinder: { bindings: new Map(), localBinders: new Set() }, - } as unknown as Viewer, - [], - ); + return new CommandCatalog(makeContext()); } it("returns all commands for an empty query", () => { diff --git a/src/ui/command_palette.ts b/src/ui/command_palette.ts index f928cc45ae..cec82a0956 100644 --- a/src/ui/command_palette.ts +++ b/src/ui/command_palette.ts @@ -15,17 +15,33 @@ */ import "#src/ui/command_palette.css"; +import type { LayerManager, SelectedLayerState } from "#src/layer/index.js"; import { UserLayer } from "#src/layer/index.js"; import { Overlay } from "#src/overlay.js"; -import { getMatchingTools, restoreTool } from "#src/ui/tool.js"; +import { + getMatchingTools, + restoreTool, + type GlobalToolBinder, +} from "#src/ui/tool.js"; import { parseToolQuery } from "#src/ui/tool_query.js"; +import { animationFrameDebounce } from "#src/util/animation_frame_debounce.js"; +import { RefCounted } from "#src/util/disposable.js"; import type { ActionIdentifier, EventAction, NormalizedEventIdentifier, } from "#src/util/event_action_map.js"; import { friendlyEventIdentifier } from "#src/util/event_action_map.js"; -import type { Viewer } from "#src/viewer.js"; +import { isMacPlatform } from "#src/util/platform.js"; +import { Signal } from "#src/util/signal.js"; +import type { InputEventBindings, Viewer } from "#src/viewer.js"; + +export interface CommandCatalogContext { + globalToolBinder: GlobalToolBinder; + layerManager: LayerManager; + selectedLayer: SelectedLayerState; + inputEventBindings: InputEventBindings; +} const SUPPLEMENTAL_COMMANDS: readonly { actionId: ActionIdentifier; @@ -49,9 +65,13 @@ export interface CommandPaletteEntry { } function formatKeyStroke(stroke: string): string { + const mac = isMacPlatform(); return stroke .split("+") .map((part) => { + if (mac && part === "control") return "⌘"; + if (mac && part === "alt") return "⌥"; + if (mac && part === "shift") return "⇧"; if (part.startsWith("key")) return part.substring(3); if (part.startsWith("digit")) return part.substring(5); if (part.startsWith("arrow")) return part.substring(5); @@ -60,6 +80,12 @@ function formatKeyStroke(stroke: string): string { .join("+"); } +function formatModifierName(modifier: "control" | "alt"): string { + const mac = isMacPlatform(); + if (modifier === "control") return mac ? "⌘" : "Ctrl"; + return mac ? "⌥" : "Alt"; +} + function actionIdToLabel(actionId: ActionIdentifier): string { return actionId .split("-") @@ -78,7 +104,7 @@ function isKeyboardEvent(normalizedId: NormalizedEventIdentifier): boolean { // Creates a Tool instance from a palette-form JSON object (with optional "layer" field). // Caller is responsible for disposing the returned tool. -function createToolFromJson(viewer: Viewer, toolJson: unknown) { +function createToolFromJson(context: CommandCatalogContext, toolJson: unknown) { try { const json = typeof toolJson === "object" && toolJson !== null @@ -87,19 +113,24 @@ function createToolFromJson(viewer: Viewer, toolJson: unknown) { const layerName = typeof json?.layer === "string" ? json.layer : undefined; if (layerName !== undefined) { const { layer: _ignored, ...rest } = json!; - const managedLayer = viewer.layerManager.getLayerByName(layerName); + const managedLayer = context.layerManager.getLayerByName(layerName); const userLayer = managedLayer?.layer ?? null; if (userLayer === null) return undefined; return restoreTool(userLayer, rest); } - return restoreTool(viewer, toolJson); + // context is the viewer instance; restoreTool walks its prototype chain + // to find the registered tool factory. + return restoreTool(context, toolJson); } catch { return undefined; } } -function getToolDescription(viewer: Viewer, toolJson: unknown): string { - const tool = createToolFromJson(viewer, toolJson); +function getToolDescription( + context: CommandCatalogContext, + toolJson: unknown, +): string { + const tool = createToolFromJson(context, toolJson); if (tool === undefined) return toolJsonToLabel(toolJson); const label = tool.context instanceof UserLayer @@ -133,96 +164,38 @@ function toolJsonToLabel(toolJson: unknown): string { return layerName !== undefined ? `${base} — ${layerName}` : base; } -function isToolLayerVisible(viewer: Viewer, toolJson: unknown): boolean { +function isToolLayerVisible( + context: CommandCatalogContext, + toolJson: unknown, +): boolean { const json = typeof toolJson === "object" && toolJson !== null ? (toolJson as Record) : undefined; const layerName = typeof json?.layer === "string" ? json.layer : undefined; if (layerName === undefined) return true; - const managedLayer = viewer.layerManager.getLayerByName(layerName); + const managedLayer = context.layerManager.getLayerByName(layerName); return managedLayer !== undefined && managedLayer.visible; } -// Tracks letter keys that were temporarily bound by the palette (viewer → key → tool). -// WeakMap allows GC if the viewer is destroyed. -const paletteActivatedKeys = new WeakMap>(); - -// Removes any palette-activated temp bindings whose tool is no longer the active tool. -// Called each time the palette opens or before activating a new unbound tool. -function sweepPaletteActivatedKeys(viewer: Viewer): void { - const tracked = paletteActivatedKeys.get(viewer as object); - if (tracked === undefined) return; - const activeTool = viewer.globalToolBinder.activeTool_?.tool; - for (const [key, trackedTool] of tracked) { - const currentTool = viewer.globalToolBinder.bindings.get(key); - if (currentTool !== trackedTool) { - // Our tool was replaced or removed at this key by something else — stop tracking. - tracked.delete(key); - } else if (currentTool !== activeTool) { - // Our tool is still bound here but no longer active — clean up the temp binding. - viewer.globalToolBinder.set(key, undefined); - tracked.delete(key); - } - // currentTool === trackedTool === activeTool: still active, keep tracking. - } -} - -function activateUnboundTool(viewer: Viewer, toolJson: unknown): void { - const tool = createToolFromJson(viewer, toolJson); +function activateUnboundTool( + context: CommandCatalogContext, + toolJson: unknown, +): void { + const tool = createToolFromJson(context, toolJson); if (tool === undefined) return; - - // GlobalToolBinder.set deduplicates by JSON string within a localBinder: - // it removes any existing binding with the same serialized tool JSON before - // adding the new one. If the same tool type is already bound to a key - // (e.g. the tool appeared as "unbound" in the palette due to a JSON mismatch), - // activate that existing key rather than calling set and clobbering the user's binding. - const localBinder = tool.localBinder; - const existingKey = localBinder.jsonToKey.get(JSON.stringify(tool.toJSON())); + // If the same tool is already bound to a key, activate that key directly + // rather than creating a duplicate. + const existingKey = tool.localBinder.jsonToKey.get( + JSON.stringify(tool.toJSON()), + ); if (existingKey !== undefined) { tool.dispose(); - viewer.globalToolBinder.activate(existingKey); + context.globalToolBinder.activate(existingKey); return; } - - // Prefer reusing the active palette-tool's key slot: it will be deactivated - // when the new tool activates anyway, so taking a new letter would just cause - // the slots to bounce (A → B → A → B …) as each old binding lingers until - // the next sweep. - const tracked = paletteActivatedKeys.get(viewer as object); - const activeTool = viewer.globalToolBinder.activeTool_?.tool; - let targetKey: string | undefined; - if (tracked !== undefined && activeTool !== undefined) { - for (const [key, trackedTool] of tracked) { - if (trackedTool === activeTool) { - targetKey = key; - break; - } - } - } - - if (targetKey === undefined) { - // No active palette slot to reuse; sweep stale entries then find a free key. - sweepPaletteActivatedKeys(viewer); - targetKey = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - .split("") - .find((key) => !viewer.globalToolBinder.bindings.has(key)); - if (targetKey === undefined) { - tool.dispose(); - return; - } - } - - let newTracked = paletteActivatedKeys.get(viewer as object); - if (newTracked === undefined) { - newTracked = new Map(); - paletteActivatedKeys.set(viewer as object, newTracked); - } - newTracked.delete(targetKey); - newTracked.set(targetKey, tool as object); - - viewer.globalToolBinder.set(targetKey, tool); - viewer.globalToolBinder.activate(targetKey); + // No key binding — activate directly without allocating a letter slot. + context.globalToolBinder.activateDirect(tool); } /** @@ -231,7 +204,7 @@ function activateUnboundTool(viewer: Viewer, toolJson: unknown): void { * action is kept; subsequent bindings for the same action are ignored. */ export function collectActionBindings( - viewer: Viewer, + inputEventBindings: InputEventBindings, ): readonly ActionBinding[] { const seenBindings = new Map(); @@ -247,9 +220,9 @@ export function collectActionBindings( } }; - collect(viewer.inputEventBindings.global.entries()); - collect(viewer.inputEventBindings.sliceView.entries()); - collect(viewer.inputEventBindings.perspectiveView.entries()); + collect(inputEventBindings.global.entries()); + collect(inputEventBindings.sliceView.entries()); + collect(inputEventBindings.perspectiveView.entries()); return Array.from(seenBindings.entries(), ([actionId, eventAction]) => ({ actionId, @@ -258,10 +231,10 @@ export function collectActionBindings( } /** - * Take raw ActionBindings and map them to user-facing CommandPaletteEntries. - * All available tools are discovered via getMatchingTools. Unbound tools - * are included with an execute callback that temporarily binds them to the - * first available letter slot and activates them. + * Persistent, signal-driven catalog of command palette entries. Subscribes to + * tool-binding and layer changes and rebuilds automatically via + * animationFrameDebounce so the palette always reflects current viewer state + * without rebuilding from scratch on every open. * * Actions can be represented hierarchically, with parent entries that * expand to show child entries when activated. For example, @@ -269,28 +242,46 @@ export function collectActionBindings( * replaced by three hierarchical entries whose children are the individual * layer rows, enabling a two-step layer picker instead of a flat list. */ -export class CommandCatalog { - readonly commands: CommandPaletteEntry[] = []; - - constructor(viewer: Viewer, bindings: readonly ActionBinding[]) { - sweepPaletteActivatedKeys(viewer); - - // "Deactivate Active Tool" goes first so it's always one keystroke away - // when a tool is running. - if (viewer.globalToolBinder.activeTool_ !== undefined) { - this.commands.push({ - label: "Deactivate Active Tool", - shortcut: "", - actionId: "deactivate-active-tool", - }); - } +export class CommandCatalog extends RefCounted { + commands: readonly CommandPaletteEntry[] = []; + readonly changed = new Signal(); + + constructor(private readonly context: CommandCatalogContext) { + super(); + const debouncedRebuild = this.registerCancellable( + animationFrameDebounce(() => this.rebuild()), + ); + this.registerDisposer( + context.globalToolBinder.changed.add(debouncedRebuild), + ); + this.registerDisposer( + context.layerManager.layersChanged.add(debouncedRebuild), + ); + this.rebuild(); + } + + private rebuild() { + const { + globalToolBinder, + layerManager, + selectedLayer, + inputEventBindings, + } = this.context; + const commands: CommandPaletteEntry[] = []; + + // "Deactivate Active Tool" is always present — harmless no-op when nothing is active. + commands.push({ + label: "Deactivate Active Tool", + shortcut: "", + actionId: "deactivate-active-tool", + }); // Hierarchical layer actions — each group entry opens a sub-palette of layers. // The first 9 layers carry their digit-key shortcuts so users can see they // still work directly from the keyboard without opening the sub-palette. - const layers = viewer.layerManager?.managedLayers ?? []; + const layers = layerManager?.managedLayers ?? []; - this.commands.push({ + commands.push({ label: "Toggle Layer", shortcut: "1–9", actionId: "toggle-layer-group" as ActionIdentifier, @@ -302,28 +293,31 @@ export class CommandCatalog { })), }); - this.commands.push({ + const controlModifierLabel = formatModifierName("control"); + const altModifierLabel = formatModifierName("alt"); + + commands.push({ label: "Select Layer", - shortcut: "Ctrl+1–9", + shortcut: `${controlModifierLabel}+1–9`, actionId: "select-layer-group" as ActionIdentifier, children: layers.map((layer, index) => ({ label: layer.name, - shortcut: index < 9 ? `Ctrl+${index + 1}` : "", + shortcut: index < 9 ? `${controlModifierLabel}+${index + 1}` : "", actionId: `select-layer-name:${layer.name}` as ActionIdentifier, execute: () => { - viewer.selectedLayer.layer = layer; - viewer.selectedLayer.visible = true; + selectedLayer.layer = layer; + selectedLayer.visible = true; }, })), }); - this.commands.push({ + commands.push({ label: "Toggle Pick Layer", - shortcut: "Alt+1–9", + shortcut: `${altModifierLabel}+1–9`, actionId: "toggle-pick-layer-group" as ActionIdentifier, children: layers.map((layer, index) => ({ label: layer.name, - shortcut: index < 9 ? `Alt+${index + 1}` : "", + shortcut: index < 9 ? `${altModifierLabel}+${index + 1}` : "", actionId: `toggle-pick-layer-name:${layer.name}` as ActionIdentifier, execute: () => { layer.pickEnabled = !layer.pickEnabled; @@ -331,6 +325,7 @@ export class CommandCatalog { })), }); + const bindings = collectActionBindings(inputEventBindings); const shortcutByAction = new Map(); for (const { actionId, eventAction } of bindings) { shortcutByAction.set( @@ -343,62 +338,68 @@ export class CommandCatalog { for (const { actionId, eventAction } of bindings) { if (/^tool-[A-Z]$/.test(actionId)) continue; - // Layer-index actions are replaced by hierarchical group entries below. + // Layer-index actions are replaced by hierarchical group entries above. if (/^(toggle|select|toggle-pick)-layer-\d+$/.test(actionId)) continue; const label = actionIdToLabel(actionId); const shortcut = formatKeyStroke( friendlyEventIdentifier(eventAction.originalEventIdentifier ?? ""), ); - this.commands.push({ label, shortcut, actionId }); + commands.push({ label, shortcut, actionId }); } for (const { actionId, label } of SUPPLEMENTAL_COMMANDS) { - this.commands.push({ label, shortcut: "", actionId }); + commands.push({ label, shortcut: "", actionId }); } const toolQueryResult = parseToolQuery("+"); if ("query" in toolQueryResult) { const toolMatches = getMatchingTools( - viewer.globalToolBinder, + globalToolBinder, toolQueryResult.query, ); // Build a reverse lookup from palette-JSON key to letter for currently-bound tools. + // Keys must include getCommonToolProperties() to match the keys produced by + // getMatchingTools, which merges commonProperties into every yielded tool JSON. const boundByJsonKey = new Map(); - for (const [letter, tool] of viewer.globalToolBinder.bindings) { - const paletteJson = tool.localBinder.convertLocalJSONToPaletteJSON( - tool.toJSON(), - ); + for (const [letter, tool] of globalToolBinder.bindings) { + const paletteJson = { + ...tool.localBinder.convertLocalJSONToPaletteJSON(tool.toJSON()), + ...tool.localBinder.getCommonToolProperties(), + }; boundByJsonKey.set(JSON.stringify(paletteJson), letter); } for (const [jsonKey, toolJson] of toolMatches) { - if (!isToolLayerVisible(viewer, toolJson)) continue; + if (!isToolLayerVisible(this.context, toolJson)) continue; const boundLetter = boundByJsonKey.get(jsonKey); if (boundLetter !== undefined) { const actionId: ActionIdentifier = `tool-${boundLetter}`; - const tool = viewer.globalToolBinder.bindings.get(boundLetter)!; + const tool = globalToolBinder.bindings.get(boundLetter)!; const label = tool.context instanceof UserLayer ? `${tool.description} — ${tool.context.managedLayer.name}` : tool.description; - this.commands.push({ + commands.push({ label, shortcut: shortcutByAction.get(actionId) ?? "", actionId, }); } else { const capturedToolJson = toolJson; - this.commands.push({ - label: getToolDescription(viewer, toolJson), + commands.push({ + label: getToolDescription(this.context, toolJson), shortcut: "", actionId: `tool-json:${jsonKey}` as ActionIdentifier, - execute: () => activateUnboundTool(viewer, capturedToolJson), + execute: () => activateUnboundTool(this.context, capturedToolJson), }); } } } + + this.commands = commands; + this.changed.dispatch(); } filter(searchString: string): readonly CommandPaletteEntry[] { @@ -421,7 +422,6 @@ export class CommandCatalog { export class CommandPalette extends Overlay { private readonly searchInput: HTMLInputElement; private readonly resultsList: HTMLElement; - private readonly catalog: CommandCatalog; private readonly rowByCommand = new Map(); private readonly emptyElement: HTMLElement; private readonly pickerHeaderElement: HTMLElement; @@ -476,14 +476,12 @@ export class CommandPalette extends Overlay { }; constructor( - viewer: Viewer, + private readonly catalog: CommandCatalog, private readonly actionDispatchTarget: HTMLElement, ) { super(); this.content.classList.add("neuroglancer-command-palette"); - const bindings = collectActionBindings(viewer); - this.catalog = new CommandCatalog(viewer, bindings); this.currentCommands = this.catalog.commands; const pickerHeader = (this.pickerHeaderElement = @@ -676,3 +674,41 @@ export class CommandPalette extends Overlay { } } } + +/** + * Binds the command palette to a viewer: registers the "open-command-palette" + * action and a document-level Ctrl+P capture listener so the palette opens + * regardless of where focus currently sits. + * + * Call from the standalone setup (e.g. setupDefaultViewer). Embedders who do + * not want the document-level key capture simply omit this call. + */ +export function bindCommandPalette( + viewer: Viewer, + catalog: CommandCatalog, +): void { + // Guard prevents double-open when both the element-level action listener and + // the document capture listener fire for the same keypress. + let openPalette: CommandPalette | undefined; + const openCommandPalette = () => { + if (openPalette !== undefined && !openPalette.wasDisposed) return; + const prevFocused = document.activeElement; + const dispatchTarget = + prevFocused instanceof HTMLElement && viewer.element.contains(prevFocused) + ? prevFocused + : viewer.element; + openPalette = new CommandPalette(catalog, dispatchTarget); + }; + viewer.bindAction("open-command-palette", openCommandPalette); + viewer.registerEventListener( + document, + "keydown", + (event: KeyboardEvent) => { + if (event.code === "KeyP" && event.ctrlKey) { + event.preventDefault(); + openCommandPalette(); + } + }, + { capture: true }, + ); +} diff --git a/src/ui/default_input_event_bindings.ts b/src/ui/default_input_event_bindings.ts index 3a1fc8429f..ac685d5135 100644 --- a/src/ui/default_input_event_bindings.ts +++ b/src/ui/default_input_event_bindings.ts @@ -14,6 +14,27 @@ * 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_HIDDEN, + 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"; @@ -44,6 +65,7 @@ export function getDefaultGlobalBindings() { map.set("keyn", "add-layer"); map.set("keyh", "help"); + map.set("keyg", SKELETON_TOGGLE_HIDDEN); map.set("space", "toggle-layout"); map.set("shift+space", "toggle-layout-alternative"); @@ -187,6 +209,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/default_viewer_setup.ts b/src/ui/default_viewer_setup.ts index fc6bb1a406..19a92ca261 100644 --- a/src/ui/default_viewer_setup.ts +++ b/src/ui/default_viewer_setup.ts @@ -15,6 +15,7 @@ */ import { StatusMessage } from "#src/status.js"; +import { bindCommandPalette, CommandCatalog } from "#src/ui/command_palette.js"; import { bindDefaultCopyHandler, bindDefaultPasteHandler, @@ -62,6 +63,8 @@ export function setupDefaultViewer(options?: Partial) { bindDefaultCopyHandler(viewer); bindDefaultPasteHandler(viewer); + const catalog = viewer.registerDisposer(new CommandCatalog(viewer)); + bindCommandPalette(viewer, catalog); return viewer; } diff --git a/src/ui/selection_details.ts b/src/ui/selection_details.ts index 0c6f5bb7a9..b282065a14 100644 --- a/src/ui/selection_details.ts +++ b/src/ui/selection_details.ts @@ -29,6 +29,7 @@ import { SidePanel } from "#src/ui/side_panel.js"; import { setClipboard } from "#src/util/clipboard.js"; import type { Borrowed } from "#src/util/disposable.js"; import { MouseEventBinder } from "#src/util/mouse_bindings.js"; +import { isMacPlatform } from "#src/util/platform.js"; import { CheckboxIcon } from "#src/widget/checkbox_icon.js"; import { makeCopyButton } from "#src/widget/copy_button.js"; import { DependentViewWidget } from "#src/widget/dependent_view_widget.js"; @@ -72,15 +73,15 @@ export class SelectionDetailsPanel extends SidePanel { }); titleBar.appendChild(backButton); titleBar.appendChild(forwardButton); + const modifierKeyLabel = isMacPlatform() ? "cmd" : "ctrl"; titleBar.appendChild( this.registerDisposer( new CheckboxIcon(state.pin, { // Note: \ufe0e forces text display, as otherwise the pin icon may as an emoji with // color. text: "📌\ufe0e", - enableTitle: "Pin selection\nctrl+rightclick to select and pin", - disableTitle: - "Unpin selection\nctrl+shift+rightclick to select on hover", + enableTitle: `Pin selection\n${modifierKeyLabel}+rightclick to select and pin`, + disableTitle: `Unpin selection\n${modifierKeyLabel}+shift+rightclick to select on hover`, }), ).element, ); 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.spec.ts b/src/ui/skeleton_tab.spec.ts index 8bf0a3298a..4c2065aa8b 100644 --- a/src/ui/skeleton_tab.spec.ts +++ b/src/ui/skeleton_tab.spec.ts @@ -39,7 +39,7 @@ function makeNode( }; } -async function getBuildSpatialSkeletonVirtualListItems() { +function stubWebGLContext() { const webglContextStub = new Proxy( {}, { @@ -49,10 +49,19 @@ async function getBuildSpatialSkeletonVirtualListItems() { ( globalThis as { WebGL2RenderingContext?: unknown } ).WebGL2RenderingContext ??= webglContextStub; +} + +async function getBuildSpatialSkeletonVirtualListItems() { + stubWebGLContext(); return (await import("#src/ui/skeleton_tab.js")) .buildSpatialSkeletonVirtualListItems; } +async function getSpatialSkeletonEmptyListText() { + stubWebGLContext(); + return (await import("#src/ui/skeleton_tab.js")).getSpatialSkeletonEmptyListText; +} + describe("spatial skeleton edit tab render state", () => { it("shows only directly matching nodes for text filtering", () => { const graph = buildSpatiallyIndexedSkeletonNavigationGraph([ @@ -293,3 +302,75 @@ describe("spatial skeleton edit tab virtual list items", () => { expect(flattened.listIndexByNodeId.get(leafCount + 1)).toBe(leafCount + 1); }); }); + +describe("spatial skeleton edit tab empty list text", () => { + it("tells the user to make the segment visible when a non-visible segment is selected", async () => { + const getEmptyListText = await getSpatialSkeletonEmptyListText(); + + const text = getEmptyListText({ + activeSegmentId: undefined, + selectedSegmentNotVisible: true, + segmentState: undefined, + filterText: "", + nodeFilterType: SpatialSkeletonNodeFilterType.DEFAULT, + }); + + expect(text).not.toBe("Select a skeleton segment to inspect editable nodes."); + expect(text).toMatch(/not visible|make it visible/i); + }); + + it("still asks the user to select a segment when nothing is selected at all", async () => { + const getEmptyListText = await getSpatialSkeletonEmptyListText(); + + const text = getEmptyListText({ + activeSegmentId: undefined, + selectedSegmentNotVisible: false, + segmentState: undefined, + filterText: "", + nodeFilterType: SpatialSkeletonNodeFilterType.DEFAULT, + }); + + expect(text).toBe("Select a skeleton segment to inspect editable nodes."); + }); + + it("reports no loaded nodes for an active segment with no cached nodes, regardless of visibility", async () => { + const getEmptyListText = await getSpatialSkeletonEmptyListText(); + + const text = getEmptyListText({ + activeSegmentId: 20380, + selectedSegmentNotVisible: false, + segmentState: undefined, + filterText: "", + nodeFilterType: SpatialSkeletonNodeFilterType.DEFAULT, + }); + + expect(text).toBe("No loaded nodes."); + }); + + it("reports no matching nodes when a filter excludes all loaded nodes", async () => { + const getEmptyListText = await getSpatialSkeletonEmptyListText(); + const graph = buildSpatiallyIndexedSkeletonNavigationGraph([ + makeNode(1, undefined), + ]); + const segmentState = { + ...buildSpatialSkeletonSegmentRenderState(20380, graph, { + filterText: "no-match", + nodeFilterType: SpatialSkeletonNodeFilterType.DEFAULT, + getNodeDescription() { + return undefined; + }, + }), + segmentLabel: undefined, + }; + + const text = getEmptyListText({ + activeSegmentId: 20380, + selectedSegmentNotVisible: false, + segmentState, + filterText: "no-match", + nodeFilterType: SpatialSkeletonNodeFilterType.DEFAULT, + }); + + expect(text).toBe("No matching nodes."); + }); +}); diff --git a/src/ui/skeleton_tab.ts b/src/ui/skeleton_tab.ts index 005b0291fb..767368e1a3 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,7 +94,12 @@ 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 { isMacPlatform } from "#src/util/platform.js"; import { formatScaleWithUnitAsString } from "#src/util/si_units.js"; import { Signal } from "#src/util/signal.js"; import { EnumSelectWidget } from "#src/widget/enum_widget.js"; @@ -95,6 +113,8 @@ const NO_NODE_SELECTED_MESSAGE = "No skeleton node is selected, only go to root is supported on skeleton edges."; const NAVIGATE_FROM_SPATIAL_INDEX_MESSAGE = "A non-visible segment is selected. Make it visible to use skeleton navigation features."; +const SEGMENT_NOT_VISIBLE_LIST_MESSAGE = + "A non-visible segment is selected. Make it visible to inspect its nodes here."; export type SegmentDisplayState = SpatialSkeletonSegmentRenderState & { segmentLabel: string | undefined; @@ -123,6 +143,37 @@ export function buildSpatialSkeletonVirtualListItems( return { items, listIndexByNodeId }; } +export function getSpatialSkeletonEmptyListText(options: { + activeSegmentId: number | undefined; + selectedSegmentNotVisible: boolean; + segmentState: SegmentDisplayState | undefined; + filterText: string; + nodeFilterType: SpatialSkeletonNodeFilterType; +}): string { + const { + activeSegmentId, + selectedSegmentNotVisible, + segmentState, + filterText, + nodeFilterType, + } = options; + if (activeSegmentId === undefined) { + return selectedSegmentNotVisible + ? SEGMENT_NOT_VISIBLE_LIST_MESSAGE + : "Select a skeleton segment to inspect editable nodes."; + } + if ( + segmentState === undefined || + segmentState.totalNodeCount === 0 || + (filterText.length === 0 && + (nodeFilterType === SpatialSkeletonNodeFilterType.DEFAULT || + nodeFilterType === SpatialSkeletonNodeFilterType.NONE)) + ) { + return "No loaded nodes."; + } + return "No matching nodes."; +} + interface SpatiallyIndexedSkeletonNavigationApi { getSkeletonRootNode( skeletonId: number, @@ -168,33 +219,35 @@ 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 mac = isMacPlatform(); + const parts = stroke.split("+").map((part) => { + if (part === "control") return mac ? "⌘" : "Ctrl"; + if (part === "shift") return mac ? "⇧" : "Shift"; + if (part === "alt") return mac ? "⌥" : "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(mac ? "" : "+"); + }; + + 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 +264,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,17 +354,55 @@ 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 selectedSegmentNotVisible = false; let nodesBySegment = new Map(); let inspectionAllowed = false; let navigationAllowed = false; @@ -483,7 +582,7 @@ export class SpatialSkeletonEditTab extends Tab { ) => { const id = BigInt(segmentId); const hasSegmentSelectionModifiers = (event: MouseEvent) => - event.ctrlKey && !event.altKey && !event.metaKey; + (isMacPlatform() ? event.metaKey : event.ctrlKey) && !event.altKey; element.addEventListener("mousedown", (event: MouseEvent) => { if (event.button !== 2 || !hasSegmentSelectionModifiers(event)) { return; @@ -502,16 +601,20 @@ 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"; + const getSegmentSelectionTitle = (segmentId: number) => { + const modKey = isMacPlatform() ? "Cmd" : "Ctrl"; + return ( + `segment ${segmentId}\n` + + `${modKey}+right-click to pin selection\n` + + `${modKey}+shift+right-click to unpin` + ); + }; const getNodeDescriptionText = (node: SpatiallyIndexedSkeletonNode) => layer.getSpatialSkeletonNodeDisplayDescription(node); const getHoveredNodeIdFromViewer = () => { - return layer.hoveredSpatialSkeletonNodeId.value; + return layer.hoveredSpatialSkeletonNodeInfo.value?.nodeId; }; const getSelectedSegmentId = () => { @@ -743,6 +846,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 +965,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 +991,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 +1015,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 +1037,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 +1059,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 +1083,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 +1112,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 +1139,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, @@ -1194,8 +1319,8 @@ export class SpatialSkeletonEditTab extends Tab { if (inspectionAllowed) { row.tabIndex = 0; row.setAttribute("role", "button"); - row.title = - "Click to move to node and pin selection. Right-click to move to node. Ctrl+right-click to pin selection without moving."; + const nodeRowModifierKeyLabel = isMacPlatform() ? "Cmd" : "Ctrl"; + row.title = `Click to move to node and pin selection. Right-click to move to node. ${nodeRowModifierKeyLabel}+right-click to pin selection without moving.`; row.addEventListener("click", (event: MouseEvent) => { const target = event.target; if ( @@ -1330,7 +1455,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 +1470,8 @@ export class SpatialSkeletonEditTab extends Tab { () => rerootNode(node), !nodeRerootAllowed || pendingRerootNodes.has(node.nodeId) || - node.parentNodeId === undefined, + node.parentNodeId === undefined || + nodeIsTrueEnd, ), ); let deleteActionTitle = "Delete node"; @@ -1419,24 +1549,14 @@ export class SpatialSkeletonEditTab extends Tab { } }; - const getEmptyListText = ( - segmentState: SegmentDisplayState | undefined, - ) => { - if (activeSegmentId === undefined) { - return "Select a skeleton segment to inspect editable nodes."; - } - if ( - segmentState === undefined || - segmentState.totalNodeCount === 0 || - (getFilterText().length === 0 && - (nodeFilterTypeModel.value === - SpatialSkeletonNodeFilterType.DEFAULT || - nodeFilterTypeModel.value === SpatialSkeletonNodeFilterType.NONE)) - ) { - return "No loaded nodes."; - } - return "No matching nodes."; - }; + const getEmptyListText = (segmentState: SegmentDisplayState | undefined) => + getSpatialSkeletonEmptyListText({ + activeSegmentId, + selectedSegmentNotVisible, + segmentState, + filterText: getFilterText(), + nodeFilterType: nodeFilterTypeModel.value, + }); const updateList = (segmentState: SegmentDisplayState | undefined) => { const flattened = buildSpatialSkeletonVirtualListItems( @@ -1514,6 +1634,9 @@ export class SpatialSkeletonEditTab extends Tab { cachedSelectedSegmentNodes === undefined ? undefined : selectedSegmentId; + selectedSegmentNotVisible = + selectedSegmentId !== undefined && + cachedSelectedSegmentNodes === undefined; loadedNodeSummarySuffix = ""; if ( skeletonLayer === undefined || @@ -1723,7 +1846,7 @@ export class SpatialSkeletonEditTab extends Tab { }), ); this.registerDisposer( - layer.hoveredSpatialSkeletonNodeId.changed.add(() => { + layer.hoveredSpatialSkeletonNodeInfo.changed.add(() => { updateHoveredViewerNode(); }), ); @@ -1752,6 +1875,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..52d021a236 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); } @@ -491,6 +495,20 @@ export class GlobalToolBinder extends RefCounted { public deactivate() { this.debounceDeactivate(); } + + // Activate a tool that has no letter-key binding. The tool is treated as + // toggle-mode (stays active until explicitly deactivated). The ToolActivation + // takes ownership of the tool via registerDisposer so the tool is disposed + // automatically when the activation ends. + activateDirect(tool: Owned): void { + this.queuedTool = undefined; // explicit activation clears any queued toggle tool + this.deactivate_(); // cancels debounce + disposes current activation + const activation = new ToolActivation(tool, this.inputEventMapBinder); + activation.registerDisposer(tool); + this.activeTool_ = activation; + tool.activate(activation); + this.changed.dispatch(); + } } export class LocalToolBinder< diff --git a/src/ui/viewer_settings.ts b/src/ui/viewer_settings.ts index 913cb39749..57f706fb19 100644 --- a/src/ui/viewer_settings.ts +++ b/src/ui/viewer_settings.ts @@ -133,6 +133,7 @@ export class ViewerSettingsPanel extends SidePanel { "Enable adaptive downsampling", viewer.enableAdaptiveDownsampling, ); + addCheckbox("Show picking indicator", viewer.showPickingIndicator); const addColor = (label: string, value: WatchableValueInterface) => { const labelElement = document.createElement("label"); diff --git a/src/util/color.browser_test.ts b/src/util/color.browser_test.ts deleted file mode 100644 index 0f1c32d9f2..0000000000 --- a/src/util/color.browser_test.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * @license - * Copyright 2018 Google Inc. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect } from "vitest"; -import { - computeHighVisibilityContrastColor, - getContrastRatio, - parseColorSerialization, - parseRGBColorSpecification, - packColor, - serializeColor, - useWhiteBackground, -} from "#src/util/color.js"; -import { vec3, vec4 } from "#src/util/geom.js"; - -describe("color", () => { - it("parseColorSerialization works", () => { - expect(parseColorSerialization("#000000")).toEqual([0, 0, 0, 1]); - expect(parseColorSerialization("#123456")).toEqual([0x12, 0x34, 0x56, 1]); - expect(parseColorSerialization("rgba(101, 102, 103, 0.45)")).toEqual([ - 101, 102, 103, 0.45, - ]); - }); - - it("serializeColor works", () => { - expect( - serializeColor(vec3.fromValues(0x12 / 255, 0x34 / 255, 0x56 / 255)), - ).toEqual("#123456"); - expect( - serializeColor(vec4.fromValues(101 / 255, 102 / 255, 103 / 255, 0.45)), - ).toEqual("rgba(101, 102, 103, 0.45)"); - }); - - it("parseRGBColorSpecification works", () => { - expect(parseRGBColorSpecification("white")).toEqual( - vec3.fromValues(1, 1, 1), - ); - expect(parseRGBColorSpecification("black")).toEqual( - vec3.fromValues(0, 0, 0), - ); - expect(parseRGBColorSpecification("red")).toEqual(vec3.fromValues(1, 0, 0)); - expect(parseRGBColorSpecification("lime")).toEqual( - vec3.fromValues(0, 1, 0), - ); - expect(parseRGBColorSpecification("blue")).toEqual( - vec3.fromValues(0, 0, 1), - ); - }); - - it("packColor works", () => { - expect(packColor(vec3.fromValues(0, 0, 0))).toEqual(0x000000); - expect(packColor(vec3.fromValues(0.2, 0, 1))).toEqual(0xff0033); - expect(packColor(vec3.fromValues(0, 0.4, 1.0))).toEqual(0xff6600); - expect(packColor(vec3.fromValues(0.6, 0.4, 0))).toEqual(0x006699); - expect(packColor(vec3.fromValues(1, 0.6, 0.8))).toEqual(0xcc99ff); - expect(packColor(vec3.fromValues(1, 1, 1))).toEqual(0xffffff); - - expect(packColor(vec3.fromValues(-1, 0, 0))).toEqual(0x000000); - expect(packColor(vec3.fromValues(0, 0.2, 2))).toEqual(0xff3300); - expect(packColor(vec3.fromValues(0.4, 4.4, -0.4))).toEqual(0x00ff66); - - expect(packColor(vec4.fromValues(0, 0, 0, 0))).toEqual(0x00000000); - expect(packColor(vec4.fromValues(0.2, 0, 1, 0.2))).toEqual(0x33ff0033); - expect(packColor(vec4.fromValues(0, 0.4, 1.0, 0.4))).toEqual(0x66ff6600); - expect(packColor(vec4.fromValues(0.6, 0.4, 0, 0.6))).toEqual(0x99006699); - expect(packColor(vec4.fromValues(1, 0.6, 0.8, 0.8))).toEqual(0xcccc99ff); - expect(packColor(vec4.fromValues(1, 1, 1, 1))).toEqual(0xffffffff); - - expect(packColor(vec4.fromValues(-1, 0, 0, -1))).toEqual(0x00000000); - expect(packColor(vec4.fromValues(0, 0.2, 2, 1))).toEqual(0xffff3300); - expect(packColor(vec4.fromValues(0.4, 4.4, -0.4, 4))).toEqual(0xff00ff66); - }); -}); - -function expectColorClose(actual: Float32Array, expected: readonly number[]) { - for (let i = 0; i < 3; ++i) { - expect(actual[i]).toBeCloseTo(expected[i]); - } -} - -describe("useWhiteBackground", () => { - it("works for simple cases", () => { - expect(useWhiteBackground(vec3.fromValues(0, 0, 0))).toBe(true); - expect(useWhiteBackground(vec3.fromValues(1, 1, 1))).toBe(false); - expect(useWhiteBackground(vec3.fromValues(1, 0, 0))).toBe(false); - expect(useWhiteBackground(vec3.fromValues(0, 1, 0))).toBe(false); - expect(useWhiteBackground(vec3.fromValues(0, 0, 1))).toBe(true); - }); -}); - -describe("getContrastRatio", () => { - it("matches WCAG contrast-ratio reference values", () => { - expect( - getContrastRatio(vec3.fromValues(0, 0, 0), vec3.fromValues(1, 1, 1)), - ).toBeCloseTo(21); - expect( - getContrastRatio( - vec3.fromValues(0.5, 0.5, 0.5), - vec3.fromValues(0.5, 0.5, 0.5), - ), - ).toBeCloseTo(1); - }); -}); - -describe("computeHighVisibilityContrastColor", () => { - it("prefers yellow for dark colors", () => { - const sourceColor = vec3.fromValues(0, 0, 0); - const color = computeHighVisibilityContrastColor( - vec3.create(), - sourceColor, - ); - - expectColorClose(color, [1, 0.95, 0.35]); - expect(getContrastRatio(color, sourceColor)).toBeGreaterThanOrEqual(3); - }); - - it("uses red for bright colors", () => { - const sourceColor = vec3.fromValues(1, 1, 1); - const color = computeHighVisibilityContrastColor( - vec3.create(), - sourceColor, - ); - - expectColorClose(color, [1, 0, 0]); - expect(getContrastRatio(color, sourceColor)).toBeGreaterThanOrEqual(3); - }); - - it("uses yellow for red segment colors", () => { - const sourceColor = vec3.fromValues(1, 0, 0); - const color = computeHighVisibilityContrastColor( - vec3.create(), - sourceColor, - ); - - expectColorClose(color, [1, 0.95, 0.35]); - expect(getContrastRatio(color, sourceColor)).toBeGreaterThanOrEqual(3); - }); - - it("uses yellow for low-saturation midtone colors", () => { - const sourceColor = vec3.fromValues(0.5, 0.5, 0.5); - const color = computeHighVisibilityContrastColor( - vec3.create(), - sourceColor, - ); - - expectColorClose(color, [1, 0.95, 0.35]); - expect(getContrastRatio(color, sourceColor)).toBeGreaterThanOrEqual(3); - }); - - it("uses yellow for near-black colors", () => { - const sourceColor = vec3.fromValues(0.05, 0.05, 0.05); - const color = computeHighVisibilityContrastColor( - vec3.create(), - sourceColor, - ); - - expectColorClose(color, [1, 0.95, 0.35]); - }); - - it("uses red for near-white colors", () => { - const sourceColor = vec3.fromValues(0.95, 0.95, 0.95); - const color = computeHighVisibilityContrastColor( - vec3.create(), - sourceColor, - ); - - expectColorClose(color, [1, 0, 0]); - }); - - it("uses red for yellow-like segment colors", () => { - const sourceColor = vec3.fromValues(1, 0.95, 0.35); - const color = computeHighVisibilityContrastColor( - vec3.create(), - sourceColor, - ); - - expectColorClose(color, [1, 0, 0]); - }); - - it("uses red when yellow would be close to the segment color", () => { - const sourceColor = vec3.fromValues(0.35, 1, 0.35); - const color = computeHighVisibilityContrastColor( - vec3.create(), - sourceColor, - ); - - expectColorClose(color, [1, 0, 0]); - }); -}); diff --git a/src/util/color.ts b/src/util/color.ts index 45d8058b81..f89d2d392c 100644 --- a/src/util/color.ts +++ b/src/util/color.ts @@ -142,9 +142,15 @@ export function serializeColor(x: vec3 | vec4) { return result; } -// Converts an sRGB color component to the gamma-expanded ("linear") value. -export function srgbGammaExpand(value: number) { - return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; +// Determines whether a white background would provide higher contrast than a black background for +// the given foreground color. +// +// This is determined according to the Web Content Accessibility Guidelines (WCAG) 2.0: +// https://www.w3.org/TR/WCAG20/#contrast-ratiodef +// +// https://stackoverflow.com/a/3943023 +export function useWhiteBackground(foregroundColor: vec3 | vec4) { + return getRelativeLuminance(foregroundColor) <= 0.179; } // Computes the relative luminance according to Web Content Accessibility Guidelines (WCAG) 2.0 @@ -153,6 +159,11 @@ export function srgbGammaExpand(value: number) { // // @param color sRGB color export function getRelativeLuminance(color: ArrayLike) { + // Converts an sRGB color component to the gamma-expanded ("linear") value. + function srgbGammaExpand(value: number) { + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + } + return ( 0.2126 * srgbGammaExpand(color[0]) + 0.7152 * srgbGammaExpand(color[1]) + @@ -171,35 +182,41 @@ export function getContrastRatio( return (lighter + 0.05) / (darker + 0.05); } -// Determines whether a white background would provide higher contrast than a black background for -// the given foreground color. -// -// This is determined according to the Web Content Accessibility Guidelines (WCAG) 2.0: -// https://www.w3.org/TR/WCAG20/#contrast-ratiodef -// -// https://stackoverflow.com/a/3943023 -export function useWhiteBackground(foregroundColor: vec3 | vec4) { - return getRelativeLuminance(foregroundColor) <= 0.179; +// Returns the HSV saturation of `color`: the fraction by which its most intense +// channel exceeds its least intense channel. 0 for greys, 1 for fully saturated colors. +export function getSaturation(color: ArrayLike): number { + const max = Math.max(color[0], color[1], color[2]); + if (max <= 0) return 0; + const min = Math.min(color[0], color[1], color[2]); + return (max - min) / max; } -const yellowHighlight = vec3.fromValues(1, 0.95, 0.35); -const redHighlight = vec3.fromValues(1, 0, 0); -const YELLOW_HIGHLIGHT_CONTRAST_BIAS = 1.2; +// Returns a copy of `color` with saturation boosted by `factor` (moves each channel +// away from the perceptual-grey axis by the given multiplier, clamped to [0, 1]). +export function saturateColor(color: ArrayLike, factor: number): vec3 { + const lum = getRelativeLuminance(color); + return vec3.fromValues( + Math.min(1.0, Math.max(0.0, lum + (color[0] - lum) * factor)), + Math.min(1.0, Math.max(0.0, lum + (color[1] - lum) * factor)), + Math.min(1.0, Math.max(0.0, lum + (color[2] - lum) * factor)), + ); +} -export function computeHighVisibilityContrastColor( - out: T, +// Returns the palette color with the highest contrast against `sourceColor`. +export function pickHighestContrastColor( + palette: readonly vec3[], sourceColor: ArrayLike, -) { - const yellowContrast = getContrastRatio(yellowHighlight, sourceColor); - const redContrast = getContrastRatio(redHighlight, sourceColor); - const color = - redContrast > yellowContrast * YELLOW_HIGHLIGHT_CONTRAST_BIAS - ? redHighlight - : yellowHighlight; - out[0] = color[0]; - out[1] = color[1]; - out[2] = color[2]; - return out; +): vec3 { + let bestColor = palette[0]; + let bestContrast = -1; + for (const candidate of palette) { + const contrast = getContrastRatio(candidate, sourceColor); + if (contrast > bestContrast) { + bestContrast = contrast; + bestColor = candidate; + } + } + return bestColor; } export class TrackableRGB extends WatchableValue { diff --git a/src/util/event_action_map.ts b/src/util/event_action_map.ts index 912c7074d9..99c1c96e4e 100644 --- a/src/util/event_action_map.ts +++ b/src/util/event_action_map.ts @@ -17,6 +17,7 @@ import { registerEventListener } from "#src/util/disposable.js"; import type { HierarchicalMapInterface } from "#src/util/hierarchical_map.js"; import { HierarchicalMap } from "#src/util/hierarchical_map.js"; +import { isMacPlatform } from "#src/util/platform.js"; /** * @file Facilities for dispatching user-defined actions in response to input events. @@ -463,8 +464,18 @@ export function dispatchEventWithModifiers( detail: any, eventMap: EventActionMapInterface, ) { + let modifiers = getEventModifierMask(originalEvent); + // On Mac, treat Cmd (meta) as Ctrl for shortcut matching so that + // "control+key" bindings fire when the user presses Cmd+key. + if ( + isMacPlatform() && + modifiers & Modifiers.META && + !(modifiers & Modifiers.CONTROL) + ) { + modifiers = (modifiers & ~Modifiers.META) | Modifiers.CONTROL; + } dispatchEvent( - getStrokeIdentifier(baseIdentifier, getEventModifierMask(originalEvent)), + getStrokeIdentifier(baseIdentifier, modifiers), originalEvent, originalEvent.eventPhase, detail, diff --git a/src/util/platform.spec.ts b/src/util/platform.spec.ts new file mode 100644 index 0000000000..804605d2b9 --- /dev/null +++ b/src/util/platform.spec.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { isMacPlatform } from "#src/util/platform.js"; + +describe("isMacPlatform", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns true for userAgentData.platform 'macOS'", () => { + vi.stubGlobal("navigator", { userAgentData: { platform: "macOS" } }); + expect(isMacPlatform()).toBe(true); + }); + + it("returns true for legacy navigator.platform 'MacIntel'", () => { + vi.stubGlobal("navigator", { platform: "MacIntel" }); + expect(isMacPlatform()).toBe(true); + }); + + it("returns false for userAgentData.platform 'Windows'", () => { + vi.stubGlobal("navigator", { userAgentData: { platform: "Windows" } }); + expect(isMacPlatform()).toBe(false); + }); + + it("returns false for userAgentData.platform 'Linux'", () => { + vi.stubGlobal("navigator", { userAgentData: { platform: "Linux" } }); + expect(isMacPlatform()).toBe(false); + }); +}); diff --git a/src/util/platform.ts b/src/util/platform.ts new file mode 100644 index 0000000000..36a2ec747a --- /dev/null +++ b/src/util/platform.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function isMacPlatform(): boolean { + if (typeof navigator === "undefined") return false; + // `userAgentData` (Client Hints) is preferred where available; `navigator.platform` is + // deprecated but remains the only option in Firefox and Safari, which do not implement + // `userAgentData`. + return /mac/i.test( + (navigator as any).userAgentData?.platform ?? navigator.platform ?? "", + ); +} diff --git a/src/viewer.ts b/src/viewer.ts index 2c740bb3d5..ba43a6128b 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -72,6 +72,18 @@ 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_TOGGLE_HIDDEN, + SKELETON_UNDO, +} from "#src/skeleton/actions.js"; import { StatusMessage } from "#src/status.js"; import { ElementVisibilityFromTrackableBoolean, @@ -83,7 +95,6 @@ import { observeWatchable, TrackableValue, } from "#src/trackable_value.js"; -import { CommandPalette } from "#src/ui/command_palette.js"; import { LayerArchiveCountWidget, LayerListPanel, @@ -109,7 +120,7 @@ import { import { AutomaticallyFocusedElement } from "#src/util/automatic_focus.js"; import { TrackableRGB } from "#src/util/color.js"; import type { Borrowed, Owned } from "#src/util/disposable.js"; -import { RefCounted, registerEventListener } from "#src/util/disposable.js"; +import { RefCounted } from "#src/util/disposable.js"; import { removeFromParent } from "#src/util/dom.js"; import type { ActionEvent } from "#src/util/event_action_map.js"; import { registerActionListener } from "#src/util/event_action_map.js"; @@ -266,6 +277,7 @@ class TrackableViewerState extends CompoundTrackable { this.add("enableAdaptiveDownsampling", viewer.enableAdaptiveDownsampling); this.add("showScaleBar", viewer.showScaleBar); this.add("showDefaultAnnotations", viewer.showDefaultAnnotations); + this.add("showPickingIndicator", viewer.showPickingIndicator); this.add("showSlices", viewer.showPerspectiveSliceViews); this.add( @@ -441,6 +453,7 @@ export class Viewer extends RefCounted implements ViewerState { showScaleBar = new TrackableBoolean(true, true); showPerspectiveSliceViews = new TrackableBoolean(true, true); hideCrossSectionBackground3D = new TrackableBoolean(false, false); + showPickingIndicator = new TrackableBoolean(false, false); visibleLayerRoles = allRenderLayerRoles(); showDefaultAnnotations = new TrackableBoolean(true, true); crossSectionBackgroundColor = new TrackableRGB( @@ -1067,7 +1080,27 @@ export class Viewer extends RefCounted implements ViewerState { * Called once by the constructor to register the action listeners. */ private registerActionListeners() { - for (const action of ["recolor", "clear-segments"]) { + for (const action of [ + "recolor", + "clear-segments", + SKELETON_TOGGLE_HIDDEN, + ]) { + this.bindAction(action, () => { + this.layerManager.invokeAction(action); + }); + } + + 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); }); @@ -1159,33 +1192,6 @@ export class Viewer extends RefCounted implements ViewerState { ); this.bindAction("toggle-show-statistics", () => this.showStatistics()); - // Guard prevents double-open when both the element-level action listener and - // the document capture listener fire for the same keypress. - let openPalette: CommandPalette | undefined; - const openCommandPalette = () => { - if (openPalette !== undefined && !openPalette.wasDisposed) return; - const prevFocused = document.activeElement; - const dispatchTarget = - prevFocused instanceof HTMLElement && this.element.contains(prevFocused) - ? prevFocused - : this.element; - openPalette = new CommandPalette(this, dispatchTarget); - }; - this.bindAction("open-command-palette", openCommandPalette); - // Document-level capture to ensure that the command palette opens even when focus is inside a tool's input element outside viewer.element. - this.registerDisposer( - registerEventListener( - document, - "keydown", - (event: KeyboardEvent) => { - if (event.code === "KeyP" && event.ctrlKey) { - event.preventDefault(); - openCommandPalette(); - } - }, - { capture: true }, - ), - ); this.bindAction("deactivate-active-tool", () => this.globalToolBinder.deactivate(), ); diff --git a/src/webgl/circles.ts b/src/webgl/circles.ts index 3015542127..b98ae743e4 100644 --- a/src/webgl/circles.ts +++ b/src/webgl/circles.ts @@ -39,22 +39,29 @@ export function defineCircleShader( // 2-D position within circle quad, ranging from [-1, -1] to [1, 1]. builder.addVarying("highp vec4", "vCircleCoord"); + // Normalized radius where the first border ends and the border outline begins. + builder.addVarying("highp float", "vCircleBorderFraction"); builder.addVertexCode(` -void emitCircle(vec4 position, float diameter, float borderWidth) { +void emitCircle(vec4 position, float diameter, float borderWidth, float borderOutlineWidth) { gl_Position = position; - float totalDiameter = diameter + 2.0 * (borderWidth + uCircleParams.z); + float totalDiameter = diameter + 2.0 * (borderWidth + borderOutlineWidth + uCircleParams.z); if (diameter == 0.0) totalDiameter = 0.0; vec2 circleCornerOffset = getQuadVertexPosition(vec2(-1.0, -1.0), vec2(1.0, 1.0)); gl_Position.xy += circleCornerOffset * uCircleParams.xy * gl_Position.w * totalDiameter; vCircleCoord.xy = circleCornerOffset; - if (borderWidth == 0.0) { + if (borderWidth == 0.0 && borderOutlineWidth == 0.0) { vCircleCoord.z = totalDiameter; vCircleCoord.w = 1e-6; + vCircleBorderFraction = totalDiameter; } else { vCircleCoord.z = diameter / totalDiameter; + vCircleBorderFraction = (diameter + 2.0 * borderWidth) / totalDiameter; vCircleCoord.w = uCircleParams.z / totalDiameter; } } +void emitCircle(vec4 position, float diameter, float borderWidth) { + emitCircle(position, diameter, borderWidth, 0.0); +} `); if (crossSectionFade) { builder.addFragmentCode(` @@ -70,18 +77,23 @@ float getCircleAlphaMultiplier() { `); } builder.addFragmentCode(` -vec4 getCircleColor(vec4 interiorColor, vec4 borderColor) { +vec4 getCircleColor(vec4 interiorColor, vec4 borderColor, vec4 borderOutlineColor) { float radius = length(vCircleCoord.xy); if (radius > 1.0) { discard; } float borderColorFraction = clamp((radius - vCircleCoord.z) / vCircleCoord.w, 0.0, 1.0); + float outlineColorFraction = clamp((radius - vCircleBorderFraction) / vCircleCoord.w, 0.0, 1.0); float feather = clamp((1.0 - radius) / vCircleCoord.w, 0.0, 1.0); vec4 color = mix(interiorColor, borderColor, borderColorFraction); + color = mix(color, borderOutlineColor, outlineColorFraction); return vec4(color.rgb, color.a * feather * getCircleAlphaMultiplier()); } +vec4 getCircleColor(vec4 interiorColor, vec4 borderColor) { + return getCircleColor(interiorColor, borderColor, borderColor); +} `); } diff --git a/src/webgl/lines.ts b/src/webgl/lines.ts index 55a8740fcd..5a345c1ec0 100644 --- a/src/webgl/lines.ts +++ b/src/webgl/lines.ts @@ -134,9 +134,10 @@ vec4 getRoundedLineColor(vec4 interiorColor, vec4 borderColor) { builder.addFragmentCode(` float getLineAlpha() { if (uLineEndpointClipRadius > 0.0) { + float radiusFactor = 0.99; float distFromA = length(vec2(vLineOffsetX * vEdgeLengthInPixels, vLineCoord * vHalfTotalLineWidth)); float distFromB = length(vec2((1.0 - vLineOffsetX) * vEdgeLengthInPixels, vLineCoord * vHalfTotalLineWidth)); - if (distFromA < uLineEndpointClipRadius || distFromB < uLineEndpointClipRadius) discard; + if (distFromA < uLineEndpointClipRadius * radiusFactor || distFromB < uLineEndpointClipRadius * radiusFactor) discard; } return clamp((1.0 - abs(vLineCoord)) / vLineFeatherFraction, 0.0, 1.0); }