diff --git a/rspack.config.ts b/rspack.config.ts index e7c0c70e71..555c8e672e 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -53,6 +53,16 @@ export default defineConfig((env, args) => { resourceQuery: /raw/, type: "asset/source", }, + // Inline these specific cursor SVGs as base64 data URIs so the + // browser has nothing to fetch the first time a CSS cursor: url(...) + // rule referencing them is applied (see skeleton_edit_tools.css) — + // without this, rspack's default url-dependency handling always + // emits a separate fetched file regardless of size, which caused the + // custom cursor to only appear starting from the second activation. + { + test: /src[\\/]ui[\\/]images[\\/](.*_cursor)\.svg$/, + type: "asset/inline", + }, // Needed for .html assets used for auth redirect pages for the // brainmaps and bossDB data sources. { diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index 79707f766e..bdc3349e89 100644 --- a/src/datasource/catmaid/api.spec.ts +++ b/src/datasource/catmaid/api.spec.ts @@ -933,6 +933,7 @@ describe("CatmaidClient skeleton editing methods", () => { const requestBody = getFetchBody(fetchMock); expect(getFetchPath(fetchMock)).toBe("skeleton/split"); expect(requestBody.get("treenode_id")).toBe("202"); + expect(requestBody.get("downstream_annotation_map")).toBe("{}"); expect(requestBody.get("state")).toBe( JSON.stringify({ edition_time: "2026-03-29T12:05:00Z", diff --git a/src/datasource/catmaid/api.ts b/src/datasource/catmaid/api.ts index bc01c9b439..9360e445b8 100644 --- a/src/datasource/catmaid/api.ts +++ b/src/datasource/catmaid/api.ts @@ -2068,6 +2068,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { ): Promise { const body = new URLSearchParams({ treenode_id: nodeId.toString(), + downstream_annotation_map: JSON.stringify({}), }); appendCatmaidState( body, diff --git a/src/datasource/catmaid/spatial_skeleton_commands.ts b/src/datasource/catmaid/spatial_skeleton_commands.ts index e19315f06a..a67d4e6850 100644 --- a/src/datasource/catmaid/spatial_skeleton_commands.ts +++ b/src/datasource/catmaid/spatial_skeleton_commands.ts @@ -446,6 +446,15 @@ function requireCatmaidMergeCommandPayload(payload: object) { ); } +function validateCatmaidNodeDescription(description: string | undefined) { + if (description === undefined) return; + for (const line of description.split(/\r?\n/)) { + if (line.trim().includes(",")) { + throw new Error("Node descriptions containing commas are not supported."); + } + } +} + function cloneNodeSnapshot( node: SpatiallyIndexedSkeletonNode, ): SpatiallyIndexedSkeletonNode { @@ -1515,6 +1524,7 @@ class NodeDescriptionCommand implements SpatialSkeletonCommand { nextDescription: string | undefined, statusPrefix: string, ) { + validateCatmaidNodeDescription(nextDescription); const { node } = await getResolvedNodeForEdit( this.layer, this.stableNodeId, @@ -1852,7 +1862,8 @@ class SplitCommand implements SpatialSkeletonCommand { this.stableSegmentId, ); if (resolvedNode.node.parentNodeId === undefined) { - throw new Error("Cannot split at the root node."); + StatusMessage.showTemporaryMessage("Cannot split at the root node."); + return; } let result: CatmaidSpatialSkeletonSplitResult; try { diff --git a/src/display_context.ts b/src/display_context.ts index b15d64a68f..2219ac0870 100644 --- a/src/display_context.ts +++ b/src/display_context.ts @@ -17,6 +17,10 @@ import { debounce } from "lodash-es"; import type { FrameNumberCounter } from "#src/chunk_manager/frontend.js"; +import type { + PanelOverlaySource, + PanelOverlayTarget, +} from "#src/panel_overlay.js"; import { TrackableValue } from "#src/trackable_value.js"; import { animationFrameDebounce } from "#src/util/animation_frame_debounce.js"; import type { Borrowed } from "#src/util/disposable.js"; @@ -302,6 +306,16 @@ export abstract class RenderedPanel extends RefCounted { abstract draw(): void; + // Repositions this panel's DOM overlays. Default no-op; overridden by panels + // that support overlays. + updateOverlays(): void {} + + scheduleOverlayUpdate(): void { + if (this.visible) { + this.context.scheduleOverlayUpdate(); + } + } + disposed() { this.context.unmonitorPanel(this.element, this.monitorState); this.context.removePanel(this); @@ -640,6 +654,45 @@ export class DisplayContext extends RefCounted implements FrameNumberCounter { animationFrameDebounce(() => this.draw()), ); + // Overlay sources shown on data panels, each with its optional panel-type + // target. Panels observe `panelOverlaysChanged` to add/remove their bindings. + readonly panelOverlays = new Map(); + readonly panelOverlaysChanged = new NullarySignal(); + + /** + * Registers an overlay source shown on the data panels matching `target` (every + * data panel by default). Returns a disposer that removes it. + */ + registerPanelOverlay( + source: PanelOverlaySource, + target: PanelOverlayTarget = {}, + ): () => void { + this.panelOverlays.set(source, target); + this.panelOverlaysChanged.dispatch(); + return () => { + if (this.panelOverlays.delete(source)) { + this.panelOverlaysChanged.dispatch(); + } + }; + } + + // Repositions DOM overlays across all panels, coalesced per animation frame + // and independent of `scheduleRedraw`. + readonly scheduleOverlayUpdate = this.registerCancellable( + animationFrameDebounce(() => this.updateOverlays()), + ); + + private updateOverlays() { + this.ensureBoundsUpdated(); + for (const panel of this.panels) { + if (!panel.shouldDraw) continue; + panel.ensureBoundsUpdated(); + const { renderViewport } = panel; + if (renderViewport.width === 0 || renderViewport.height === 0) continue; + panel.updateOverlays(); + } + } + ensureBoundsUpdated() { const { resizeGeneration } = this; if (this.boundsGeneration === resizeGeneration) return; @@ -684,6 +737,9 @@ export class DisplayContext extends RefCounted implements FrameNumberCounter { this.updateFinished.dispatch(); this.framerateMonitor.endLastTimeQuery(gl, ext); this.framerateMonitor.grabAnyFinishedQueryResults(gl); + // Each panel's draw() already updated its overlays, so drop any pending + // overlay-only update. + this.scheduleOverlayUpdate.cancel(); } getDepthArray(): Float32Array { diff --git a/src/layer/index.ts b/src/layer/index.ts index 7995083b43..e3c0359242 100644 --- a/src/layer/index.ts +++ b/src/layer/index.ts @@ -53,6 +53,8 @@ import { PlaybackManager, Position, } from "#src/navigation_state.js"; +import type { PanelOverlaySource } from "#src/panel_overlay.js"; +import { isPanelOverlaySource } from "#src/panel_overlay.js"; import type { RenderLayerTransform } from "#src/render_coordinate_transform.js"; import { RENDERED_VIEW_ADD_LAYER_RPC_ID, @@ -1678,6 +1680,21 @@ export function makeRenderedPanelVisibleLayerTracker< info.registerDisposer( layer.redrawNeeded.add(() => panel.scheduleRedraw()), ); + // Layers that contribute DOM panel overlays (e.g. skeleton + // selected/hovered node highlights) are bound to this panel; the binding + // (container + update wiring) is scoped to this per-(layer,panel) info. + const overlayPanel = panel as Partial<{ + bindOverlaySource( + source: PanelOverlaySource, + owner: RefCounted, + ): void; + }>; + if ( + isPanelOverlaySource(layer) && + typeof overlayPanel.bindOverlaySource === "function" + ) { + overlayPanel.bindOverlaySource(layer, info); + } const { backend } = layer; if (backend) { backend.rpc!.invoke(RENDERED_VIEW_ADD_LAYER_RPC_ID, { diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index db36fdd47c..98f5114aa8 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -941,7 +941,16 @@ export class SegmentationUserLayer extends Base { const requestedSegmentId = options.segmentId ?? selectedNodeInfo?.segmentId ?? undefined; const segmentId = normalizeOptionalPositiveSafeInteger(requestedSegmentId); - const selectedNodePosition = options.position ?? selectedNodeInfo?.position; + const previousSelectedInfo = this.selectedSpatialSkeletonNodeInfo.value; + // Keep a model-space position available even when the node isn't currently + // cached, so the highlight overlay can still be placed: prefer the explicit + // option / cache, else retain the position captured for the same node. + const selectedNodePosition = + options.position ?? + selectedNodeInfo?.position ?? + (previousSelectedInfo?.nodeId === normalizedNodeId + ? previousSelectedInfo.position + : undefined); const selectedGlobalPosition = this.getGlobalSelectionPositionFromModelPosition(selectedNodePosition); const sourceState = options.sourceState ?? selectedNodeInfo?.sourceState; @@ -1075,6 +1084,8 @@ export class SegmentationUserLayer extends Base { readonly spatialSkeletonEditMode = this.spatialSkeletonState.editMode; readonly spatialSkeletonMergeMode = this.spatialSkeletonState.mergeMode; readonly spatialSkeletonSplitMode = this.spatialSkeletonState.splitMode; + readonly spatialSkeletonSuppressSelectedNodeHighlight = + this.spatialSkeletonState.suppressSelectedNodeHighlight; readonly spatialSkeletonNodeDataVersion = this.spatialSkeletonState.nodeDataVersion; @@ -1626,6 +1637,8 @@ export class SegmentationUserLayer extends Base { { sources2d: slicePanelSources, selectedNodeInfo: this.selectedSpatialSkeletonNodeInfo, + suppressSelectedNodeHighlight: + this.spatialSkeletonState.suppressSelectedNodeHighlight, hoveredNodeInfo: this.hoveredSpatialSkeletonNodeInfo, pendingNodePositionVersion: this.spatialSkeletonState.pendingNodePositionVersion, @@ -1633,6 +1646,10 @@ export class SegmentationUserLayer extends Base { this.spatialSkeletonState.getPendingNodePosition(nodeId), getCachedNode: (nodeId) => this.spatialSkeletonState.getCachedNode(nodeId), + resolveGlobalPosition: (modelPosition) => + this.getGlobalSelectionPositionFromModelPosition( + modelPosition, + ), inspectionState: this.spatialSkeletonState, }, ); @@ -1660,6 +1677,8 @@ export class SegmentationUserLayer extends Base { displayState, { selectedNodeInfo: this.selectedSpatialSkeletonNodeInfo, + suppressSelectedNodeHighlight: + this.spatialSkeletonState.suppressSelectedNodeHighlight, hoveredNodeInfo: this.hoveredSpatialSkeletonNodeInfo, pendingNodePositionVersion: this.spatialSkeletonState.pendingNodePositionVersion, @@ -1667,6 +1686,10 @@ export class SegmentationUserLayer extends Base { this.spatialSkeletonState.getPendingNodePosition(nodeId), getCachedNode: (nodeId) => this.spatialSkeletonState.getCachedNode(nodeId), + resolveGlobalPosition: (modelPosition) => + this.getGlobalSelectionPositionFromModelPosition( + modelPosition, + ), inspectionState: this.spatialSkeletonState, }, ); diff --git a/src/layer/segmentation/selection.ts b/src/layer/segmentation/selection.ts index c843b0635d..26e7a433d7 100644 --- a/src/layer/segmentation/selection.ts +++ b/src/layer/segmentation/selection.ts @@ -32,6 +32,9 @@ interface SpatialSkeletonViewerHoverMouseStateLike { export interface SpatialSkeletonHoverInfo { readonly nodeId: number; readonly segmentId?: number; + // Model-space position of the picked node, carried so the highlight overlay can + // still be placed when the node's skeleton is not currently loaded/cached. + readonly position?: Float32Array; } interface SpatialSkeletonViewerHoverLayerLike { @@ -192,7 +195,17 @@ function getSpatialSkeletonHoverInfoFromViewerHover( if (nodeId === undefined) return undefined; const segmentId = pickedSpatialSkeleton?.segmentId; if (segmentId === undefined) return undefined; - return segmentId === undefined ? { nodeId } : { nodeId, segmentId }; + return { nodeId, segmentId, position: pickedSpatialSkeleton?.position }; +} + +function positionsEqual( + a: Float32Array | undefined, + b: Float32Array | undefined, +) { + if (a === b) return true; + if (a === undefined || b === undefined || a.length !== b.length) return false; + for (let i = 0; i < a.length; ++i) if (a[i] !== b[i]) return false; + return true; } function spatialSkeletonHoverInfoEqual( @@ -201,7 +214,11 @@ function spatialSkeletonHoverInfoEqual( ) { if (a === b) return true; if (a === undefined || b === undefined) return false; - return a.nodeId === b.nodeId && a.segmentId === b.segmentId; + return ( + a.nodeId === b.nodeId && + a.segmentId === b.segmentId && + positionsEqual(a.position, b.position) + ); } export class SpatialSkeletonHoverState extends RefCounted { diff --git a/src/panel_overlay.css b/src/panel_overlay.css new file mode 100644 index 0000000000..e83787d330 --- /dev/null +++ b/src/panel_overlay.css @@ -0,0 +1,33 @@ +/** + * @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. + */ + +/* Per-panel overlay container: covers the panel, never intercepts pointer + events, and clips overlays to the panel bounds. */ +.neuroglancer-panel-overlay-container { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 10; + overflow: hidden; +} + +/* Per-source sub-container within a panel; z-index is set from the source's + overlayPriority. */ +.neuroglancer-panel-overlay-source { + position: absolute; + inset: 0; + pointer-events: none; +} diff --git a/src/panel_overlay.ts b/src/panel_overlay.ts new file mode 100644 index 0000000000..15caf5922e --- /dev/null +++ b/src/panel_overlay.ts @@ -0,0 +1,236 @@ +/** + * @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. + */ + +/** + * DOM overlays positioned by projecting world-space positions to screen (e.g. + * the picking indicator and skeleton node highlights), updated on a coalesced, + * redraw-free pass independent of the WebGL render loop. + * + * The contract contains no neuroglancer-internal types, so render layers, + * built-ins, and external code implement it identically. Positions passed to + * `PanelOverlayContext.project` are in the global coordinate space. + */ + +import "#src/panel_overlay.css"; + +import type { WatchableValueInterface } from "#src/trackable_value.js"; +import { RefCounted } from "#src/util/disposable.js"; +import type { NullarySignal } from "#src/util/signal.js"; + +export interface PanelOverlayContext { + /** + * Projects a global-coordinate position to this panel's logical CSS pixels, or + * returns `undefined` if it is off-screen / behind the camera / culled by the + * cross-section slab. `scale` (default 1) conveys depth (perspective view); + * `opacity` (default 1) is the cross-section fade in slice views (1 on the + * slice plane, falling to 0 at the slab edge). + */ + project( + position: Float32Array, + ): { x: number; y: number; scale?: number; opacity?: number } | undefined; + + /** + * The source's container for this panel; the source reconciles its children. + * Created and removed by the panel. + */ + readonly container: HTMLElement; + + /** + * CSS pixels per render-viewport device pixel, for sizing overlays specified in + * device pixels. + */ + readonly cssPerDevicePixel: number; + + /** + * The panel's type tags (e.g. `"perspective"`, `"cross-section"`), so a source + * can adapt its rendering to the panel it is drawing in. + */ + readonly panelTypes: readonly string[]; +} + +export interface PanelOverlaySource { + /** Higher draws on top of lower. Default 0. (Picking indicator uses 100.) */ + readonly overlayPriority?: number; + + /** Dispatch to reposition the overlay on the next frame without a GL redraw. */ + readonly overlayUpdateNeeded: NullarySignal; + + /** + * Optional runtime show/hide. When present and `false`, the panel hides this + * source's container and skips its update; changes trigger a coalesced pass. + */ + readonly overlayVisible?: WatchableValueInterface; + + /** Cheap, DOM-only update for one panel. Must not touch the GL canvas. */ + updatePanelOverlays(ctx: PanelOverlayContext): void; +} + +export function isPanelOverlaySource(x: unknown): x is PanelOverlaySource { + return ( + typeof (x as Partial | null | undefined) + ?.updatePanelOverlays === "function" + ); +} + +/** + * Restricts a globally-registered source to a subset of panels. When + * `panelTypes` is omitted the source is shown on every data panel; otherwise it + * is shown on a panel iff one of its {@link PanelOverlayHost.panelTypes} tags is + * listed. (An empty `panelTypes` therefore matches no panel.) + */ +export interface PanelOverlayTarget { + readonly panelTypes?: readonly string[]; +} + +function panelMatchesTarget( + target: PanelOverlayTarget, + panelTypes: readonly string[], +): boolean { + const { panelTypes: wanted } = target; + return ( + wanted === undefined || wanted.some((type) => panelTypes.includes(type)) + ); +} + +/** The panel capabilities required by {@link PanelOverlayManager}. */ +export interface PanelOverlayHost { + readonly element: HTMLElement; + readonly visible: boolean; + readonly cssPerDevicePixel: number; + readonly panelTypes: readonly string[]; + project( + position: Float32Array, + ): { x: number; y: number; scale?: number; opacity?: number } | undefined; +} + +/** + * Owns a panel's overlay DOM and drives its updates. Holds a per-panel + * container with one child per bound {@link PanelOverlaySource} (z-index from + * `overlayPriority`), binds the viewer-level sources registered on the + * DisplayContext, and repositions every source on `update()`. + */ +export class PanelOverlayManager extends RefCounted { + private readonly container = document.createElement("div"); + private readonly bindings = new Map(); + private readonly globalOwners = new Map(); + + constructor( + private readonly host: PanelOverlayHost, + // Viewer-level sources (with their optional panel-type target) applied to this + // panel when the target matches, and the signal fired when the map changes. + private readonly globalSources: ReadonlyMap< + PanelOverlaySource, + PanelOverlayTarget + >, + globalSourcesChanged: NullarySignal, + // Requests a coalesced, redraw-free overlay pass. + private readonly requestUpdate: () => void, + ) { + super(); + this.container.className = "neuroglancer-panel-overlay-container"; + host.element.appendChild(this.container); + this.registerDisposer(() => this.container.remove()); + this.registerDisposer( + globalSourcesChanged.add(() => this.syncGlobalSources()), + ); + this.registerDisposer(() => { + for (const owner of this.globalOwners.values()) owner.dispose(); + this.globalOwners.clear(); + }); + this.syncGlobalSources(); + } + + /** + * Binds `source`. `owner` scopes the binding's lifetime; the source's + * sub-container is removed when `owner` is disposed. + */ + bindSource(source: PanelOverlaySource, owner: RefCounted) { + const subContainer = document.createElement("div"); + subContainer.className = "neuroglancer-panel-overlay-source"; + subContainer.style.zIndex = `${source.overlayPriority ?? 0}`; + this.container.appendChild(subContainer); + this.bindings.set(source, subContainer); + owner.registerDisposer(() => { + subContainer.remove(); + this.bindings.delete(source); + this.requestUpdate(); + }); + owner.registerDisposer(source.overlayUpdateNeeded.add(this.requestUpdate)); + const { overlayVisible } = source; + if (overlayVisible !== undefined) { + owner.registerDisposer(overlayVisible.changed.add(this.requestUpdate)); + } + this.requestUpdate(); + } + + private syncGlobalSources() { + const { globalSources, globalOwners, host } = this; + for (const [source, owner] of globalOwners) { + const target = globalSources.get(source); + if ( + target === undefined || + !panelMatchesTarget(target, host.panelTypes) + ) { + owner.dispose(); + globalOwners.delete(source); + } + } + for (const [source, target] of globalSources) { + if ( + !globalOwners.has(source) && + panelMatchesTarget(target, host.panelTypes) + ) { + const owner = new RefCounted(); + globalOwners.set(source, owner); + this.bindSource(source, owner); + } + } + } + + /** Repositions every bound source. DOM only; does not touch the GL canvas. */ + update() { + const { host } = this; + if (!host.visible) return; + const { cssPerDevicePixel, panelTypes } = host; + const project = (p: Float32Array) => host.project(p); + for (const [source, container] of this.bindings) { + if (source.overlayVisible?.value === false) { + if (container.style.display !== "none") + container.style.display = "none"; + continue; + } + if (container.style.display === "none") container.style.display = ""; + source.updatePanelOverlays({ + project, + container, + cssPerDevicePixel, + panelTypes, + }); + } + } + + /** + * Hides every bound source's container without touching the GL canvas. Used + * when the panel failed to draw (so it rendered nothing this frame) to avoid + * leaving stale overlays over cleared canvas content. {@link update} + * restores visibility on the next successful frame. + */ + clear() { + for (const container of this.bindings.values()) { + if (container.style.display !== "none") container.style.display = "none"; + } + } +} diff --git a/src/perspective_view/panel.ts b/src/perspective_view/panel.ts index f07ad56735..11d073d5c2 100644 --- a/src/perspective_view/panel.ts +++ b/src/perspective_view/panel.ts @@ -183,6 +183,11 @@ const tempVec3 = vec3.create(); const tempVec4 = vec4.create(); const tempMat4 = mat4.create(); +// Clamp range for the depth-based picking-indicator scale (relative to the base +// diameter at the focal plane). Keeps the ring from becoming extreme. +const PICKING_INDICATOR_MIN_DEPTH_SCALE = 0.6; +const PICKING_INDICATOR_MAX_DEPTH_SCALE = 1.7; + // Copy the OIT values to the main color buffer function defineTransparencyCopyShader(builder: ShaderBuilder) { builder.addOutputBuffer("vec4", "v4f_fragColor", null); @@ -1506,6 +1511,65 @@ export class PerspectivePanel extends RenderedDataPanel { ); } + readonly overlayPanelTypes = ["perspective"]; + + protected projectGlobalPosition(position: Float32Array) { + const { + viewProjectionMat, + logicalWidth, + logicalHeight, + displayDimensionRenderInfo: { displayDimensionIndices }, + } = this.projectionParameters.value; + // `position` is in global voxel space; extract display-space components. + const px = + displayDimensionIndices[0] >= 0 + ? position[displayDimensionIndices[0]] + : 0; + const py = + displayDimensionIndices[1] >= 0 + ? position[displayDimensionIndices[1]] + : 0; + const pz = + displayDimensionIndices[2] >= 0 + ? position[displayDimensionIndices[2]] + : 0; + const displayPos = tempVec3; + displayPos[0] = px; + displayPos[1] = py; + displayPos[2] = pz; + vec3.transformMat4(displayPos, displayPos, viewProjectionMat); + if (displayPos[2] < -1 || displayPos[2] > 1) return undefined; + + // Scale the indicator with depth to convey 3D position: the clip-space w is + // proportional to view-space depth for a perspective projection, so the + // ratio of the navigation center's w to the picked point's w is 1 at the + // focal plane, >1 nearer (larger ring), <1 farther (smaller ring). In + // orthographic mode m[3]=m[7]=m[11]=0, so both w values equal m[15] and the + // scale is 1 (constant size), needing no special-casing. + const m = viewProjectionMat; + const clipW = (x: number, y: number, z: number) => + m[3] * x + m[7] * y + m[11] * z + m[15]; + const pickedW = clipW(px, py, pz); + const center = this.navigationState.position.value; + const centerW = clipW( + displayDimensionIndices[0] >= 0 ? center[displayDimensionIndices[0]] : 0, + displayDimensionIndices[1] >= 0 ? center[displayDimensionIndices[1]] : 0, + displayDimensionIndices[2] >= 0 ? center[displayDimensionIndices[2]] : 0, + ); + let scale = 1; + if (pickedW > 1e-6 && centerW > 1e-6) { + scale = Math.min( + PICKING_INDICATOR_MAX_DEPTH_SCALE, + Math.max(PICKING_INDICATOR_MIN_DEPTH_SCALE, centerW / pickedW), + ); + } + return { + x: (displayPos[0] * 0.5 + 0.5) * logicalWidth, + y: (1 - (displayPos[1] * 0.5 + 0.5)) * logicalHeight, + scale, + }; + } + zoomByMouse(factor: number) { this.navigationState.zoomBy(factor); } diff --git a/src/picking_indicator_overlay.css b/src/picking_indicator_overlay.css new file mode 100644 index 0000000000..979f0a20f5 --- /dev/null +++ b/src/picking_indicator_overlay.css @@ -0,0 +1,32 @@ +/** + * @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. + */ + +/* Ring drawn at the cursor's picked position. White, bordered by black on both + sides for contrast on any background. Size, opacity and position are set per + frame. */ +.neuroglancer-picking-indicator { + position: absolute; + left: 0; + top: 0; + box-sizing: border-box; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.92); + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.92), + inset 0 0 0 1px rgba(0, 0, 0, 0.92); + pointer-events: none; + will-change: transform; +} diff --git a/src/picking_indicator_overlay.ts b/src/picking_indicator_overlay.ts new file mode 100644 index 0000000000..454bf4555c --- /dev/null +++ b/src/picking_indicator_overlay.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import "#src/picking_indicator_overlay.css"; + +import type { MouseSelectionState } from "#src/layer/index.js"; +import type { + PanelOverlayContext, + PanelOverlaySource, +} from "#src/panel_overlay.js"; +import type { NullarySignal } from "#src/util/signal.js"; + +// Base ring diameter in CSS pixels; scaled by the projection's depth `scale`. +const PICKING_INDICATOR_DIAMETER = 14; + +function createRing(): HTMLElement { + const element = document.createElement("div"); + element.className = "neuroglancer-picking-indicator"; + return element; +} + +/** A ring drawn at the cursor's picked position, driven by the mouse state. */ +export class PickingIndicatorOverlay implements PanelOverlaySource { + readonly overlayPriority = 100; + + constructor(private readonly mouseState: MouseSelectionState) {} + + get overlayUpdateNeeded(): NullarySignal { + return this.mouseState.changed; + } + + updatePanelOverlays(ctx: PanelOverlayContext): void { + const { container } = ctx; + const { mouseState } = this; + const pos = mouseState.active + ? ctx.project(mouseState.position) + : undefined; + let element = container.firstElementChild as HTMLElement | null; + if (pos === undefined) { + if (element !== null) element.style.display = "none"; + return; + } + if (element === null) { + element = createRing(); + container.appendChild(element); + } + const size = PICKING_INDICATOR_DIAMETER * (pos.scale ?? 1); + const { style } = element; + style.display = ""; + style.width = `${size}px`; + style.height = `${size}px`; + style.opacity = `${pos.opacity ?? 1}`; + style.transform = `translate(${pos.x - size / 2}px, ${pos.y - size / 2}px)`; + } +} diff --git a/src/rendered_data_panel.ts b/src/rendered_data_panel.ts index 3b9a440c6f..e531acb309 100644 --- a/src/rendered_data_panel.ts +++ b/src/rendered_data_panel.ts @@ -24,6 +24,8 @@ import { RenderedPanel } from "#src/display_context.js"; import { hasSpatialSkeletonNodeSelection } from "#src/layer/segmentation/selection.js"; import type { NavigationState } from "#src/navigation_state.js"; import { PickIDManager } from "#src/object_picking.js"; +import type { PanelOverlaySource } from "#src/panel_overlay.js"; +import { PanelOverlayManager } from "#src/panel_overlay.js"; import { displayToLayerCoordinates, layerToDisplayCoordinates, @@ -36,7 +38,7 @@ import type { SpatialSkeletonSourceState } from "#src/skeleton/api.js"; import { StatusMessage } from "#src/status.js"; import type { TrackableValue } from "#src/trackable_value.js"; import { AutomaticallyFocusedElement } from "#src/util/automatic_focus.js"; -import type { Borrowed } from "#src/util/disposable.js"; +import type { Borrowed, RefCounted } from "#src/util/disposable.js"; import type { ActionEvent, EventActionMap, @@ -362,8 +364,13 @@ export abstract class RenderedDataPanel extends RenderedPanel { newPickingData.pickIDs.clear(); if (!this.drawWithPicking(newPickingData)) { newPickingData.frameNumber = -1; + // The panel rendered nothing this frame; drop its overlays so stale + // markers don't linger over the cleared canvas region. + this.clearOverlays(); return; } + // Reposition overlays for the new view. + this.updateOverlays(); // For the new frame, allow new pick requests regardless of interval since last request. this.nextPickRequestTime = 0; if (this.mouseX >= 0) { @@ -373,6 +380,39 @@ export abstract class RenderedDataPanel extends RenderedPanel { abstract drawWithPicking(pickingData: FramePickingData): boolean; + /** + * Projects a global-coordinate position to this panel's logical CSS pixels, or + * returns `undefined` if it is off-screen / behind the camera / culled by the + * cross-section slab. `scale` (default 1) conveys depth (perspective); + * `opacity` (default 1) is the cross-section fade in slice views. Implemented + * per panel using its own projection. + */ + protected abstract projectGlobalPosition( + position: Float32Array, + ): { x: number; y: number; scale?: number; opacity?: number } | undefined; + + /** + * Type tags used to target overlays (see {@link PanelOverlayTarget}), e.g. + * `["perspective"]` or `["cross-section"]`. + */ + abstract readonly overlayPanelTypes: readonly string[]; + + private overlays: PanelOverlayManager; + + // Called by the visible-layer tracker to bind a layer's overlay source; `owner` + // is the per-(layer,panel) attachment. + bindOverlaySource(source: PanelOverlaySource, owner: RefCounted) { + this.overlays.bindSource(source, owner); + } + + override updateOverlays() { + this.overlays.update(); + } + + clearOverlays() { + this.overlays.clear(); + } + private nextPickRequestTime = 0; private pendingPickRequestTimerId = -1; @@ -460,6 +500,29 @@ export abstract class RenderedDataPanel extends RenderedPanel { super(context, element, viewer.visibility); this.inputEventMap = viewer.inputEventMap; + const self = this; + this.overlays = this.registerDisposer( + new PanelOverlayManager( + { + element, + get visible() { + return self.visible; + }, + get cssPerDevicePixel() { + const { width, logicalWidth } = self.renderViewport; + return width > 0 ? logicalWidth / width : 1; + }, + get panelTypes() { + return self.overlayPanelTypes; + }, + project: (p) => self.projectGlobalPosition(p), + }, + context.panelOverlays, + context.panelOverlaysChanged, + () => this.scheduleOverlayUpdate(), + ), + ); + element.classList.add("neuroglancer-rendered-data-panel"); element.classList.add("neuroglancer-panel"); element.classList.add("neuroglancer-noselect"); diff --git a/src/skeleton/actions.ts b/src/skeleton/actions.ts index c05b626b53..df2cf06a67 100644 --- a/src/skeleton/actions.ts +++ b/src/skeleton/actions.ts @@ -42,5 +42,5 @@ export const SKELETON_ENTER_MERGE_MODE = "skeleton-enter-merge-mode"; 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_ENTER_DELETE_MODE = "skeleton-enter-delete-mode"; export const SKELETON_CLEAR_SELECTION = "skeleton-clear-node-selection"; diff --git a/src/skeleton/backend.ts b/src/skeleton/backend.ts index 65b9c67f34..8e33f17643 100644 --- a/src/skeleton/backend.ts +++ b/src/skeleton/backend.ts @@ -295,12 +295,14 @@ export class SpatiallyIndexedSkeletonRenderLayerBackend extends withChunkManager localPosition: SharedWatchableValue; skeletonSpacingTarget: SharedWatchableValue; skeletonSpacingTarget2d: SharedWatchableValue; + hiddenSkeletonsVisible: SharedWatchableValue; constructor(rpc: RPC, options: any) { super(rpc, options); this.skeletonSpacingTarget = rpc.get(options.skeletonSpacingTarget); this.skeletonSpacingTarget2d = rpc.get(options.skeletonSpacingTarget2d); this.localPosition = rpc.get(options.localPosition); + this.hiddenSkeletonsVisible = rpc.get(options.hiddenSkeletonsVisible); const scheduleUpdateChunkPriorities = () => this.chunkManager.scheduleUpdateChunkPriorities(); this.registerDisposer( @@ -312,6 +314,9 @@ export class SpatiallyIndexedSkeletonRenderLayerBackend extends withChunkManager this.registerDisposer( this.skeletonSpacingTarget2d.changed.add(scheduleUpdateChunkPriorities), ); + this.registerDisposer( + this.hiddenSkeletonsVisible.changed.add(scheduleUpdateChunkPriorities), + ); this.registerDisposer( this.chunkManager.recomputeChunkPriorities.add(() => this.recomputeChunkPriorities(), @@ -343,6 +348,9 @@ export class SpatiallyIndexedSkeletonRenderLayerBackend extends withChunkManager } private recomputeChunkPriorities() { + if (!this.hiddenSkeletonsVisible.value) { + return; + } this.chunkManager.registerLayer(this); for (const attachment of this.attachments.values()) { const { view } = attachment; diff --git a/src/skeleton/frontend.css b/src/skeleton/frontend.css new file mode 100644 index 0000000000..03b57b14d6 --- /dev/null +++ b/src/skeleton/frontend.css @@ -0,0 +1,32 @@ +/** + * @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. + */ + +/* Ring outlining a selected/hovered skeleton node. A pure border around the + node with a single 1px contrast halo on the outside, so it never covers the + node point. Size, border width/color and the halo color are set per marker; + the halo color adapts to the ring color's luminance (white for dark rings, + black for light ones). */ +.neuroglancer-skeleton-node-highlight { + position: absolute; + left: 0; + top: 0; + box-sizing: border-box; + border-radius: 50%; + border-style: solid; + box-shadow: 0 0 0 1px var(--ng-node-highlight-outline, rgba(0, 0, 0, 0.75)); + pointer-events: none; + will-change: transform; +} diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index 6d606d95d7..0fcb81959c 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import "#src/skeleton/frontend.css"; + import { ChunkState, LayerChunkProgressInfo } from "#src/chunk_manager/base.js"; import type { ChunkManager } from "#src/chunk_manager/frontend.js"; import { @@ -30,6 +32,10 @@ import type { PickState, VisibleLayerInfo, } from "#src/layer/index.js"; +import type { + PanelOverlayContext, + PanelOverlaySource, +} from "#src/panel_overlay.js"; import type { PerspectivePanel } from "#src/perspective_view/panel.js"; import type { PerspectiveViewReadyRenderContext, @@ -56,6 +62,8 @@ import { forEachVisibleSegment, getVisibleSegments, getObjectKey, + onTemporaryVisibleSegmentsStateChanged, + onVisibleSegmentsStateChanged, } from "#src/segmentation_display_state/base.js"; import type { SegmentationDisplayState3D } from "#src/segmentation_display_state/frontend.js"; import { @@ -110,6 +118,7 @@ import { SliceViewPanelRenderLayer } from "#src/sliceview/renderlayer.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; import type { WatchableValueInterface } from "#src/trackable_value.js"; import { + makeCachedDerivedWatchableValue, makeCachedLazyDerivedWatchableValue, TrackableValue, WatchableValue, @@ -118,6 +127,7 @@ import { import { Uint64Set } from "#src/uint64_set.js"; import { gatherUpdate } from "#src/util/array.js"; import { + getRelativeLuminance, getSaturation, pickHighestContrastColor, saturateColor, @@ -195,11 +205,21 @@ const DEFAULT_FRAGMENT_MAIN = `void main() { emitDefault(); } `; -// If use values like 8.0, need to ensure JS keeps the decimal place for GLSL -const ACTIVE_NODE_BORDER_MIN_WIDTH = 3.5; -const ACTIVE_NODE_BORDER_MAX_WIDTH = 8.5; -const ACTIVE_NODE_BORDER_DIAMETER_FRACTION = 0.5; -const ACTIVE_NODE_OUTLINE_DIAMETER_FRACTION = 0.25; + +// Converts a linear 0..1 RGB triple to a CSS `rgb(...)` string for DOM markers. +function vec3ToCssColor(color: vec3): string { + return `rgb(${Math.round(color[0] * 255)}, ${Math.round( + color[1] * 255, + )}, ${Math.round(color[2] * 255)})`; +} +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"; // Saturation adjustment factor and threshold for the highlighted (hovered) node border: each // moves the segment's color away from (>1) or towards (<1) the perceptual-grey @@ -211,10 +231,7 @@ const ACTIVE_NODE_OUTLINE_DIAMETER_FRACTION = 0.25; const HIGHLIGHTED_NODE_BORDER_SATURATION_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"; -const ACTIVE_NODE_BORDER_FALLBACK_COLOR = vec3.fromValues(1.0, 0.95, 0.35); -// Muted colors for the selected (pinned) node +// 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) @@ -308,7 +325,6 @@ class RenderHelper extends RefCounted { private vertexIdHelper; private segmentAttributeIndex: number | undefined; private segmentColorAttributeIndex: number | undefined; - private nodeIdAttributeIndex: number | undefined; private visibleSegmentsShaderManager = new HashSetShaderManager( "visibleSegments", ); @@ -391,7 +407,7 @@ void spatialChunkCull() { ); } for (let i = 1; i < numAttributes; ++i) { - if (i === this.segmentAttributeIndex || i === this.nodeIdAttributeIndex) { + if (i === this.segmentAttributeIndex) { continue; } const info = vertexAttributes[i]; @@ -618,11 +634,6 @@ vec4 getSegmentAppearance(highp uint segmentValue) { this.segmentAttributeIndex = segmentAttrIndex >= 0 ? segmentAttrIndex : undefined; this.segmentColorAttributeIndex = base.segmentColorAttributeIndex; - const nodeIdAttrIndex = this.vertexAttributes.findIndex( - (x) => x.name === nodeIdAttribute.name, - ); - this.nodeIdAttributeIndex = - nodeIdAttrIndex >= 0 ? nodeIdAttrIndex : undefined; const segmentationGroupState = base.displayState.segmentationGroupState.value; @@ -783,24 +794,6 @@ void emitDefault() { /*crossSectionFade=*/ this.targetIsSliceView, ); 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"); - selectedOutlineWidthExpression = `(max(vSelectedNode, vHighlightedNode) * clamp(${ACTIVE_NODE_BORDER_DIAMETER_FRACTION} * uNodeDiameter, ${ACTIVE_NODE_BORDER_MIN_WIDTH}, ${ACTIVE_NODE_BORDER_MAX_WIDTH}))`; - const borderOutlineMinWidth = - ACTIVE_NODE_BORDER_MIN_WIDTH * - ACTIVE_NODE_OUTLINE_DIAMETER_FRACTION; - const borderOutlineMaxWidth = - ACTIVE_NODE_BORDER_MAX_WIDTH * - ACTIVE_NODE_OUTLINE_DIAMETER_FRACTION; - borderOutlineWidthExpression = `(max(vSelectedNode, vHighlightedNode) * clamp(${ACTIVE_NODE_OUTLINE_DIAMETER_FRACTION} * uNodeDiameter, ${borderOutlineMinWidth}, ${borderOutlineMaxWidth}))`; - } let vertexMain = ` highp uint vertexIndex = uint(gl_InstanceID); highp uint pickOffset = vertexIndex * uPickInstanceStride; @@ -810,10 +803,6 @@ highp vec3 vertexPosition = readAttribute0(vertexIndex); if (skeletonParams.spatialChunkCulling) { vertexMain += `vCullPos = vertexPosition;\n`; } - 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 && this.segmentAttributeIndex !== undefined @@ -821,12 +810,7 @@ highp vec3 vertexPosition = readAttribute0(vertexIndex); vertexMain += `vSegmentValue = toRaw(readAttribute${this.segmentAttributeIndex}(vertexIndex));\n`; } vertexMain += ` -emitCircle( - uProjection * vec4(vertexPosition, 1.0), - uNodeDiameter, - ${selectedOutlineWidthExpression}, - ${borderOutlineWidthExpression} -); +emitCircle(uProjection * vec4(vertexPosition, 1.0), uNodeDiameter, 0.0); `; const segmentColorExpression = this.getSegmentColorExpression(); if ( @@ -835,17 +819,9 @@ emitCircle( ) { // Dynamic path (spatial skeletons): per-segment color, visibility, // saturation and hover highlight all resolved in the shader via - // getSegmentAppearance(). uColor is unused in this path. + // getSegmentAppearance(). uColor is unused in this path. Selected and + // hovered node highlights are drawn as DOM overlays, not in-shader. 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(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() { return getSegmentAppearance(${segmentExpression}); @@ -855,9 +831,7 @@ void emitRGBA(vec4 color) { highp float alpha = color.a * baseColor.a; if (alpha <= 0.0) discard; vec4 renderColor = vec4(color.rgb, alpha); - vec4 borderColor = ${borderColorExpression}; - vec4 borderOutlineColor = ${borderOutlineColorExpression}; - vec4 circleColor = getCircleColor(renderColor, borderColor, borderOutlineColor); + vec4 circleColor = getCircleColor(renderColor, renderColor); emit(vec4(circleColor.rgb * circleColor.a, circleColor.a), vPickID); } void emitRGB(vec3 color) { @@ -889,24 +863,13 @@ void emitDefault() { } else { // 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(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() { return ${segmentColorExpression}; } void emitRGBA(vec4 color) { vec4 renderColor = color; - vec4 borderColor = ${borderColorExpression}; - vec4 borderOutlineColor = ${borderOutlineColorExpression}; - vec4 circleColor = getCircleColor(renderColor, borderColor, borderOutlineColor); + vec4 circleColor = getCircleColor(renderColor, renderColor); emit(vec4(circleColor.rgb * circleColor.a, circleColor.a), vPickID); } void emitRGB(vec3 color) { @@ -1173,6 +1136,82 @@ function getSkeletonNodeDiameter( return lineWidth; } +// A selected/hovered node highlight to draw as a DOM ring overlay. `diameter` +// and `borderWidth` are in render-viewport device px (matching the node's +// on-screen size); the panel converts them to CSS px via `cssPerDevicePixel`. +interface HighlightMarker { + position: Float32Array; // global coordinate space + kind: "selected" | "hovered"; + color: string; // CSS ring color, derived from the node's segment color + outlineColor: string; // CSS halo color, contrasting with `color` + diameter: number; + borderWidth: number; +} + +// Reconciles the ring child elements of an overlay source's per-panel container +// to `markers`, projecting each via the panel context. Reuses/pools children. +function updateSkeletonHighlightOverlay( + markers: HighlightMarker[], + ctx: PanelOverlayContext, +) { + const { container, cssPerDevicePixel } = ctx; + let count = 0; + for (const marker of markers) { + const pos = ctx.project(marker.position); + if (pos === undefined) continue; + let element = container.children[count] as HTMLElement | undefined; + if (element === undefined) { + element = document.createElement("div"); + element.className = "neuroglancer-skeleton-node-highlight"; + container.appendChild(element); + } + ++count; + const size = marker.diameter * cssPerDevicePixel; + const { style } = element; + style.display = ""; + style.width = `${size}px`; + style.height = `${size}px`; + style.borderWidth = `${Math.max(1, marker.borderWidth * cssPerDevicePixel)}px`; + style.borderColor = marker.color; + style.setProperty("--ng-node-highlight-outline", marker.outlineColor); + style.opacity = `${pos.opacity ?? 1}`; + style.transform = `translate(${pos.x - size / 2}px, ${pos.y - size / 2}px)`; + } + const { children } = container; + for (let i = count; i < children.length; ++i) { + (children[i] as HTMLElement).style.display = "none"; + } +} + +// On-screen size (render-viewport device px) of a node's selection ring, +// matching the old in-shader outline: a band of `borderWidth` sitting just +// outside the node, so the outer `diameter` = nodeDiameter + 2 * outline. +function getSkeletonNodeHighlightRing( + renderMode: SkeletonRenderMode, + lineWidth: number, + targetIsSliceView: boolean, +): { diameter: number; borderWidth: number } { + const nodeDiameter = getSkeletonNodeDiameter(renderMode, lineWidth); + const minWidth = Number( + targetIsSliceView + ? SELECTED_NODE_OUTLINE_MIN_WIDTH_2D + : SELECTED_NODE_OUTLINE_MIN_WIDTH_3D, + ); + const maxWidth = Number( + targetIsSliceView + ? SELECTED_NODE_OUTLINE_MAX_WIDTH_2D + : SELECTED_NODE_OUTLINE_MAX_WIDTH_3D, + ); + const outline = Math.min( + maxWidth, + Math.max( + minWidth, + Number(SELECTED_NODE_OUTLINE_DIAMETER_FRACTION) * nodeDiameter, + ), + ); + return { diameter: nodeDiameter + 2 * outline, borderWidth: outline }; +} + function setMouseStatePositionFromSpatialSkeletonNode( mouseState: MouseSelectionState, nodePosition: Float32Array, @@ -1599,14 +1638,6 @@ const segmentAttribute: VertexAttributeRenderInfo = { glslDataType: getShaderType(DataType.UINT32, 1), }; -const nodeIdAttribute: VertexAttributeRenderInfo = { - dataType: DataType.INT32, - numComponents: 1, - name: "nodeId", - webglDataType: WebGL2RenderingContext.INT, - glslDataType: getShaderType(DataType.INT32, 1), -}; - interface SkeletonChunkBase extends SkeletonGPUGeometry { vertexAttributes: Uint8Array; vertexAttributeOffsets: Uint32Array; @@ -1722,31 +1753,6 @@ export class SpatiallyIndexedSkeletonChunk copyToGPU(gl: GL) { super.copyToGPU(gl); uploadSkeletonChunkToGPU(gl, this); - // Upload nodeIds as the 3rd vertex attribute texture (index 2). - // vertexAttributeOffsets only covers position (0) and segment (1), so we - // handle nodeId separately here since it is stored outside the packed buffer. - const nodeIdFormat = this.source.attributeTextureFormats[2]; - if ( - nodeIdFormat !== undefined && - this.nodeIds.length === this.numVertices && - this.numVertices > 0 - ) { - const texture = gl.createTexture(); - gl.bindTexture(WebGL2RenderingContext.TEXTURE_2D, texture); - setOneDimensionalTextureData( - gl, - nodeIdFormat, - new Uint8Array( - this.nodeIds.buffer, - this.nodeIds.byteOffset, - this.nodeIds.byteLength, - ), - ); - gl.bindTexture(WebGL2RenderingContext.TEXTURE_2D, null); - this.vertexAttributeTextures[2] = texture; - } else { - this.vertexAttributeTextures[2] = null; - } } freeGPUMemory(gl: GL) { @@ -1763,7 +1769,6 @@ type SpatiallyIndexedSkeletonChunkListener = ( const spatiallyIndexedSkeletonTextureAttributeSpecs = Object.freeze([ { name: "position", dataType: DataType.FLOAT32, numComponents: 3 }, { name: "segment", dataType: DataType.UINT32, numComponents: 1 }, - { name: "nodeId", dataType: DataType.INT32, numComponents: 1 }, ]); export class SpatiallyIndexedSkeletonSource extends SliceViewChunkSource< @@ -1776,11 +1781,7 @@ export class SpatiallyIndexedSkeletonSource extends SliceViewChunkSource< constructor(chunkManager: ChunkManager, options: any) { super(chunkManager, options); - this.vertexAttributes = [ - vertexPositionAttribute, - segmentAttribute, - nodeIdAttribute, - ]; + this.vertexAttributes = [vertexPositionAttribute, segmentAttribute]; } get attributeTextureFormats() { @@ -1870,6 +1871,7 @@ type SpatiallyIndexedSkeletonSourceEntry = interface SelectedSkeletonNodeInfo { readonly nodeId: number; readonly segmentId?: number; + readonly position?: Float32Array; } interface SpatiallyIndexedSkeletonLayerOptions { @@ -1877,12 +1879,20 @@ interface SpatiallyIndexedSkeletonLayerOptions { selectedNodeInfo?: WatchableValueInterface< SelectedSkeletonNodeInfo | undefined >; + // When true, the selected-node highlight is hidden even though a node may be + // selected (used while entering merge/split modes). + suppressSelectedNodeHighlight?: WatchableValueInterface; hoveredNodeInfo?: WatchableValueInterface< SelectedSkeletonNodeInfo | undefined >; pendingNodePositionVersion?: WatchableValueInterface; getPendingNodePosition?: (nodeId: number) => ArrayLike | undefined; getCachedNode?: (nodeId: number) => SpatiallyIndexedSkeletonNode | undefined; + // Transforms a node's model-space position into the global coordinate space + // used by the panels, so node highlights can be projected to screen. + resolveGlobalPosition?: ( + modelPosition: ArrayLike, + ) => Float32Array | undefined; inspectionState?: SpatiallyIndexedSkeletonInspectionState; maxRetainedOverlaySegments?: number; } @@ -1926,11 +1936,6 @@ class SkeletonOverlayChunk implements SkeletonGPUGeometry { geometry.segmentIds.byteOffset, geometry.segmentIds.byteLength, ), - new Uint8Array( - geometry.nodeIds.buffer, - geometry.nodeIds.byteOffset, - geometry.nodeIds.byteLength, - ), ]; const overlayTextures: (WebGLTexture | null)[] = (this.vertexAttributeTextures = []); @@ -2085,7 +2090,6 @@ export class SpatiallyIndexedSkeletonLayer redrawNeeded = new NullarySignal(); vertexAttributes: VertexAttributeRenderInfo[]; segmentColorAttributeIndex: number | undefined; - nodeIdAttributeIndex: number | undefined; readonly browsePassLayerView: SkeletonShaderContext; readonly skeletonShaderParameters: WatchableValue; readonly browsePassSkeletonShaderParameters: WatchableValueInterface; @@ -2109,6 +2113,9 @@ export class SpatiallyIndexedSkeletonLayer private selectedNodeInfo: | WatchableValueInterface | undefined; + private suppressSelectedNodeHighlight: + | WatchableValueInterface + | undefined; private hoveredNodeInfo: | WatchableValueInterface | undefined; @@ -2121,6 +2128,12 @@ export class SpatiallyIndexedSkeletonLayer private getCachedNodeInfo: | ((nodeId: number) => SpatiallyIndexedSkeletonNode | undefined) | undefined; + private resolveGlobalPosition: + | ((modelPosition: ArrayLike) => Float32Array | undefined) + | undefined; + // Fires when the set of highlighted nodes (selected/hovered) changes, so panels + // can reposition their DOM node-highlight markers without a full canvas redraw. + readonly highlightMarkersChanged = new NullarySignal(); private inspectionState: SpatiallyIndexedSkeletonInspectionState | undefined; private overlayChunk: SkeletonOverlayChunk | undefined; private overlayGeometryKey: string | undefined; @@ -2210,7 +2223,14 @@ export class SpatiallyIndexedSkeletonLayer return getBaseObjectColor(this.displayState, segmentId); } - private updateNodeOutlineColors() { + // 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; @@ -2373,8 +2393,8 @@ export class SpatiallyIndexedSkeletonLayer if (this.overlayChunk !== undefined) { if (this.overlayGeometryKey === overlayGeometryKey) { - // Geometry unchanged — selection is driven by uSelectedNodeId uniform - // at draw time, so no GPU rebuild is needed when selection changes. + // Geometry unchanged — selection/hover highlights are DOM overlays, so no + // GPU rebuild is needed when selection changes. return this.overlayChunk; } } @@ -2457,10 +2477,12 @@ export class SpatiallyIndexedSkeletonLayer ), ); this.selectedNodeInfo = options.selectedNodeInfo; + this.suppressSelectedNodeHighlight = options.suppressSelectedNodeHighlight; this.hoveredNodeInfo = options.hoveredNodeInfo; this.pendingNodePositionVersion = options.pendingNodePositionVersion; this.getPendingNodePositionOverride = options.getPendingNodePosition; this.getCachedNodeInfo = options.getCachedNode; + this.resolveGlobalPosition = options.resolveGlobalPosition; this.inspectionState = options.inspectionState; this.maxRetainedOverlaySegments = Math.max( 1, @@ -2560,16 +2582,24 @@ export class SpatiallyIndexedSkeletonLayer displayState: this.displayState, skeletonShaderParameters: this.browsePassSkeletonShaderParameters, }; - const nodeIdIndex = this.vertexAttributes.findIndex( - (x) => x.name === nodeIdAttribute.name, - ); - this.nodeIdAttributeIndex = nodeIdIndex >= 0 ? nodeIdIndex : undefined; const requestRedraw = () => this.redrawNeeded.dispatch(); + // Node highlights are DOM overlays, so a selected/hovered change repositions + // the markers without a canvas redraw. if (this.selectedNodeInfo?.changed) { this.registerDisposer( this.selectedNodeInfo.changed.add(() => { + // Recompute the marker's contrast color for the new node. invalidateNodeOutlineColors(); - requestRedraw(); + this.highlightMarkersChanged.dispatch(); + }), + ); + } + if (this.suppressSelectedNodeHighlight?.changed) { + this.registerDisposer( + // The selected-node ring is a DOM overlay, so toggling suppression must + // refresh the markers (not just request a canvas redraw). + this.suppressSelectedNodeHighlight.changed.add(() => { + this.highlightMarkersChanged.dispatch(); }), ); } @@ -2577,14 +2607,18 @@ export class SpatiallyIndexedSkeletonLayer this.registerDisposer( this.hoveredNodeInfo.changed.add(() => { invalidateNodeOutlineColors(); - requestRedraw(); + this.highlightMarkersChanged.dispatch(); }), ); } const pendingNodePositionVersion = options.pendingNodePositionVersion; if (pendingNodePositionVersion?.changed) { this.registerDisposer( - pendingNodePositionVersion.changed.add(requestRedraw), + pendingNodePositionVersion.changed.add(() => { + // A node's position moved: redraw geometry and reposition markers. + requestRedraw(); + this.highlightMarkersChanged.dispatch(); + }), ); } const inspectionState = this.inspectionState; @@ -2593,9 +2627,37 @@ export class SpatiallyIndexedSkeletonLayer inspectionState.nodeDataVersion.changed.add(() => { invalidateNodeOutlineColors(); this.redrawNeeded.dispatch(); + // A highlighted node's cached position may now be available. + this.highlightMarkersChanged.dispatch(); }), ); } + // A marker is emitted only when its node's skeleton would be drawn (see + // computeHighlightMarkers), so its visibility depends on the object alphas + // and the visible-segment set. Refresh the overlay when any of those change. + const refreshHighlightVisibility = () => { + this.highlightMarkersChanged.dispatch(); + }; + this.registerDisposer( + this.displayState.objectAlpha.changed.add(refreshHighlightVisibility), + ); + this.registerDisposer( + this.displayState.hiddenObjectAlpha.changed.add( + refreshHighlightVisibility, + ), + ); + const segmentationGroupState = + this.displayState.segmentationGroupState.value; + onVisibleSegmentsStateChanged( + this, + segmentationGroupState, + refreshHighlightVisibility, + ); + onTemporaryVisibleSegmentsStateChanged( + this, + segmentationGroupState, + refreshHighlightVisibility, + ); // Create backend for perspective view chunk management const sharedObject = this.registerDisposer( new ChunkRenderLayerFrontend(this.layerChunkProgressInfo), @@ -2618,6 +2680,18 @@ export class SpatiallyIndexedSkeletonLayer ), ); + const hiddenSkeletonsVisibleWatchable = this.registerDisposer( + SharedWatchableValue.makeFromExisting( + rpc, + this.registerDisposer( + makeCachedDerivedWatchableValue( + (alpha) => alpha > 0, + [this.displayState.hiddenObjectAlpha], + ), + ), + ), + ); + sharedObject.initializeCounterpart(rpc, { chunkManager: chunkManager.rpcId, localPosition: this.registerDisposer( @@ -2625,6 +2699,7 @@ export class SpatiallyIndexedSkeletonLayer ).rpcId, skeletonSpacingTarget: skeletonSpacingTargetWatchable.rpcId, skeletonSpacingTarget2d: skeletonSpacingTarget2dWatchable.rpcId, + hiddenSkeletonsVisible: hiddenSkeletonsVisibleWatchable.rpcId, }); this.backend = sharedObject; this.gpuBrowseExcludedSegmentsHashTable = this.registerDisposer( @@ -2658,6 +2733,95 @@ export class SpatiallyIndexedSkeletonLayer }; } + /** + * Builds highlight markers for the selected/hovered nodes. `diameter` and + * `borderWidth` are the node's on-screen ring size (device px) for the calling + * view, so the marker matches the node's size — the old in-shader outline sat + * just outside the node with the same thickness. Positions are resolved from + * the stored info (model space) or the node cache, then transformed to global + * space; entries whose position is unavailable are omitted. + */ + computeHighlightMarkers( + diameter: number, + borderWidth: number, + ): HighlightMarker[] { + const { resolveGlobalPosition } = this; + if (resolveGlobalPosition === undefined) return []; + // Refresh the per-node contrast colors (selected uses the muted palette, + // hovered uses its saturated segment color) so markers match the previous + // in-shader outline colors. + this.updateNodeOutlineColorPair(); + // Mirror the shader's per-segment visibility so a ring is never drawn over a + // skeleton that isn't rendered: a segment draws at `objectAlpha` when it is + // visible/selected and at `hiddenObjectAlpha` otherwise. + const visibleSegments = getVisibleSegments( + this.displayState.segmentationGroupState.value, + ); + const objectAlpha = this.displayState.objectAlpha.value; + const hiddenObjectAlpha = this.displayState.hiddenObjectAlpha.value; + const markers: HighlightMarker[] = []; + const add = ( + info: SelectedSkeletonNodeInfo | undefined, + kind: HighlightMarker["kind"], + color: vec3, + ) => { + const nodeId = info?.nodeId; + if (nodeId === undefined) return; + const segmentId = info?.segmentId; + if (segmentId !== undefined) { + const effectiveAlpha = visibleSegments.has(BigInt(segmentId)) + ? objectAlpha + : hiddenObjectAlpha; + if (effectiveAlpha <= 0) return; + } else if (objectAlpha <= 0 && hiddenObjectAlpha <= 0) { + // Unknown segment: fall back to the whole-layer invisibility test. + return; + } + // Prefer the live cached position (which applies any pending move) so the + // marker stays in sync when the node moves; fall back to the position + // captured at selection time if the node isn't currently cached. + const modelPosition = + this.getCachedNodeSnapshot(nodeId)?.position ?? info?.position; + if (modelPosition === undefined) return; + const global = resolveGlobalPosition(modelPosition); + if (global === undefined) return; + // Halo contrasts with the ring color: white around a dark ring, black + // around a light one (WCAG black/white crossover luminance ~0.179). + const outlineColor = + getRelativeLuminance(color) < 0.179 + ? "rgba(255, 255, 255, 0.85)" + : "rgba(0, 0, 0, 0.75)"; + markers.push({ + position: global, + kind, + color: vec3ToCssColor(color), + outlineColor, + diameter, + borderWidth, + }); + }; + const selectedNodeId = this.suppressSelectedNodeHighlight?.value + ? undefined + : this.selectedNodeInfo?.value?.nodeId; + const hoveredNodeId = this.hoveredNodeInfo?.value?.nodeId; + // When the same node is both selected and hovered, show only the hovered + // marker (as the old shader did — hovered won over selected), avoiding an + // overlapping ring. + if (selectedNodeId !== undefined && selectedNodeId !== hoveredNodeId) { + add( + this.selectedNodeInfo?.value, + "selected", + this.selectedNodeOutlineColor, + ); + } + add( + this.hoveredNodeInfo?.value, + "hovered", + this.highlightedNodeOutlineColor, + ); + return markers; + } + invalidateSourceCellsForPositions( positions: Iterable | undefined>, ) { @@ -3016,23 +3180,6 @@ export class SpatiallyIndexedSkeletonLayer const { gl, edgeShader, nodeShader, skeletonParams } = passState; nodeShader.bind(); - this.updateNodeOutlineColors(); - 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(); @@ -3152,23 +3299,6 @@ export class SpatiallyIndexedSkeletonLayer const { gl, edgeShader, nodeShader, skeletonParams } = passState; nodeShader.bind(); - this.updateNodeOutlineColors(); - 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 = @@ -3435,7 +3565,10 @@ function attachSpatiallyIndexedSkeletonLayer( ); } -export class PerspectiveViewSpatiallyIndexedSkeletonLayer extends PerspectiveViewRenderLayer { +export class PerspectiveViewSpatiallyIndexedSkeletonLayer + extends PerspectiveViewRenderLayer + implements PanelOverlaySource +{ private renderHelper: RenderHelper; private browseRenderHelper: RenderHelper; private renderOptions: ViewSpecificSkeletonRenderingOptions; @@ -3469,6 +3602,23 @@ export class PerspectiveViewSpatiallyIndexedSkeletonLayer extends PerspectiveVie this.registerDisposer(histogram3d.visibility.add(this.visibility)); } + readonly overlayPriority = 0; + get overlayUpdateNeeded() { + return this.base.highlightMarkersChanged; + } + updatePanelOverlays(ctx: PanelOverlayContext) { + const { renderOptions } = this; + const ring = getSkeletonNodeHighlightRing( + renderOptions.mode.value, + renderOptions.lineWidth.value, + /*targetIsSliceView=*/ false, + ); + updateSkeletonHighlightOverlay( + this.base.computeHighlightMarkers(ring.diameter, ring.borderWidth), + ctx, + ); + } + attach( attachment: VisibleLayerInfo< PerspectivePanel, @@ -3610,7 +3760,10 @@ export class PerspectiveViewSpatiallyIndexedSkeletonLayer extends PerspectiveVie } } -export class SliceViewPanelSpatiallyIndexedSkeletonLayer extends SliceViewPanelRenderLayer { +export class SliceViewPanelSpatiallyIndexedSkeletonLayer + extends SliceViewPanelRenderLayer + implements PanelOverlaySource +{ private renderHelper: RenderHelper; private browseRenderHelper: RenderHelper; private renderOptions: ViewSpecificSkeletonRenderingOptions; @@ -3647,6 +3800,23 @@ export class SliceViewPanelSpatiallyIndexedSkeletonLayer extends SliceViewPanelR return this.base.gl; } + readonly overlayPriority = 0; + get overlayUpdateNeeded() { + return this.base.highlightMarkersChanged; + } + updatePanelOverlays(ctx: PanelOverlayContext) { + const { renderOptions } = this; + const ring = getSkeletonNodeHighlightRing( + renderOptions.mode.value, + renderOptions.lineWidth.value, + /*targetIsSliceView=*/ true, + ); + updateSkeletonHighlightOverlay( + this.base.computeHighlightMarkers(ring.diameter, ring.borderWidth), + ctx, + ); + } + getValueAt(_position: Float32Array) { return undefined; } diff --git a/src/skeleton/spatial_skeleton_manager.ts b/src/skeleton/spatial_skeleton_manager.ts index ba39996136..6907f965ba 100644 --- a/src/skeleton/spatial_skeleton_manager.ts +++ b/src/skeleton/spatial_skeleton_manager.ts @@ -245,6 +245,11 @@ export class SpatialSkeletonState extends RefCounted { readonly mergeAnchorNodeId = new WatchableValue( undefined, ); + // When true, the selected-node highlight is hidden even if a node is + // selected. Driven by the edit tool so that entering merge/split mode does + // not display a stale highlight until the user makes their first click + // (merge) or is suppressed entirely until a click/exit (split). + readonly suppressSelectedNodeHighlight = new WatchableValue(false); readonly nodeDataVersion = new WatchableValue(0); readonly pendingNodePositionVersion = new WatchableValue(0); diff --git a/src/sliceview/panel.ts b/src/sliceview/panel.ts index 980a38dc7d..7782a73b75 100644 --- a/src/sliceview/panel.ts +++ b/src/sliceview/panel.ts @@ -532,6 +532,40 @@ export class SliceViewPanel extends RenderedDataPanel { setStateFromRelative(pickRadius, pickRadius, 0); } + readonly overlayPanelTypes = ["cross-section"]; + + protected projectGlobalPosition(position: Float32Array) { + const { + viewProjectionMat, + logicalWidth, + logicalHeight, + displayDimensionRenderInfo: { displayDimensionIndices }, + } = this.sliceView.projectionParameters.value; + const displayPos = tempVec3; + displayPos[0] = + displayDimensionIndices[0] >= 0 + ? position[displayDimensionIndices[0]] + : 0; + displayPos[1] = + displayDimensionIndices[1] >= 0 + ? position[displayDimensionIndices[1]] + : 0; + displayPos[2] = + displayDimensionIndices[2] >= 0 + ? position[displayDimensionIndices[2]] + : 0; + vec3.transformMat4(displayPos, displayPos, viewProjectionMat); + const ndcZ = displayPos[2]; + if (ndcZ < -1 || ndcZ > 1) return undefined; + return { + x: (displayPos[0] * 0.5 + 0.5) * logicalWidth, + y: (1 - (displayPos[1] * 0.5 + 0.5)) * logicalHeight, + // Cross-section fade: match the node's on-screen alpha (1 on the slice + // plane, → 0 at the slab edge). See getCircleAlphaMultiplier in circles.ts. + opacity: 1 - Math.abs(ndcZ), + }; + } + /** * Zooms by the specified factor, maintaining the data position that projects to the current mouse * position. diff --git a/src/ui/default_input_event_bindings.ts b/src/ui/default_input_event_bindings.ts index 066a2e200f..ff6512a969 100644 --- a/src/ui/default_input_event_bindings.ts +++ b/src/ui/default_input_event_bindings.ts @@ -18,8 +18,8 @@ import { SKELETON_ADD_NODE, SKELETON_CLEAR_SELECTION, SKELETON_CYCLE_BRANCHES, - SKELETON_DELETE_NODE, SKELETON_ENTER_CREATE, + SKELETON_ENTER_DELETE_MODE, SKELETON_ENTER_MERGE_MODE, SKELETON_ENTER_SPLIT_MODE, SKELETON_GO_BRANCH_END, @@ -246,20 +246,23 @@ export function getDefaultSkeletonEditToolBindings() { defaultSkeletonEditToolBindings = EventActionMap.fromObject({ "at:mousedown1": "rotate-via-mouse-drag", "at:control+mousedown1": "translate-via-mouse-drag", + // Trackpad-friendly aliases for the middle-mouse scheme above: on + // perspective panels these dispatch here; on slice panels they're + // intercepted directly in the capture-phase listener in + // skeleton_edit_tools.ts before they can bubble to this map (mirrors + // how mousedown1 is handled for slice panels). + "at:control+mousedown0": "rotate-via-mouse-drag", + "at:control+shift+mousedown0": "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:keyd": SKELETON_ENTER_DELETE_MODE, "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; diff --git a/src/ui/images/add_node_cursor.svg b/src/ui/images/add_node_cursor.svg new file mode 100644 index 0000000000..ef99492d87 --- /dev/null +++ b/src/ui/images/add_node_cursor.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/ui/images/crosshair_cursor.svg b/src/ui/images/crosshair_cursor.svg new file mode 100644 index 0000000000..160b0e5f68 --- /dev/null +++ b/src/ui/images/crosshair_cursor.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/ui/images/delete_cursor.svg b/src/ui/images/delete_cursor.svg new file mode 100644 index 0000000000..be3b7518bd --- /dev/null +++ b/src/ui/images/delete_cursor.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/ui/images/drag_cursor.svg b/src/ui/images/drag_cursor.svg new file mode 100644 index 0000000000..4859a0320f --- /dev/null +++ b/src/ui/images/drag_cursor.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/ui/images/merge_cursor.svg b/src/ui/images/merge_cursor.svg new file mode 100644 index 0000000000..a17cbf72d4 --- /dev/null +++ b/src/ui/images/merge_cursor.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/ui/images/new_skeleton_cursor.svg b/src/ui/images/new_skeleton_cursor.svg new file mode 100644 index 0000000000..6c9f5daa3f --- /dev/null +++ b/src/ui/images/new_skeleton_cursor.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/ui/images/split_cursor.svg b/src/ui/images/split_cursor.svg new file mode 100644 index 0000000000..eee01d075b --- /dev/null +++ b/src/ui/images/split_cursor.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/ui/skeleton_edit_tool_messages.spec.ts b/src/ui/skeleton_edit_tool_messages.spec.ts index c66c808acb..3122794606 100644 --- a/src/ui/skeleton_edit_tool_messages.spec.ts +++ b/src/ui/skeleton_edit_tool_messages.spec.ts @@ -1,16 +1,20 @@ import { describe, expect, it } from "vitest"; import { - SPATIAL_SKELETON_EDIT_BANNER_MESSAGE, - SPATIAL_SKELETON_EDIT_SELECTED_BANNER_MESSAGE, - SPATIAL_SKELETON_MERGE_BANNER_MESSAGE, - SPATIAL_SKELETON_MERGE_SELECTED_BANNER_MESSAGE, - SPATIAL_SKELETON_SPLIT_BANNER_MESSAGE, + SPATIAL_SKELETON_ROTATE_PAN_HINT, formatSpatialSkeletonToolPoint, + getSpatialSkeletonCreateIdleStatusText, + getSpatialSkeletonCreatingStatusText, + getSpatialSkeletonDefaultStatusText, + getSpatialSkeletonDeleteIdleStatusText, + getSpatialSkeletonDeletingStatusText, + getSpatialSkeletonMergeStatusText, + getSpatialSkeletonMergingStatusText, + getSpatialSkeletonMovingStatusText, + getSpatialSkeletonSplitIdleStatusText, + getSpatialSkeletonSplittingStatusText, getSpatialSkeletonToolPointSummaryRow, getSpatialSkeletonToolPointStatusFields, - getSpatialSkeletonEditBannerMessage, - getSpatialSkeletonMergeBannerMessage, } from "#src/ui/skeleton_edit_tool_messages.js"; describe("spatial_skeleton_tool_messages", () => { @@ -48,27 +52,183 @@ describe("spatial_skeleton_tool_messages", () => { }); }); - it("switches edit banner copy when a node is selected", () => { - expect(getSpatialSkeletonEditBannerMessage(undefined)).toBe( - SPATIAL_SKELETON_EDIT_BANNER_MESSAGE, - ); - expect( - getSpatialSkeletonEditBannerMessage({ nodeId: 8, segmentId: 12 }), - ).toBe(SPATIAL_SKELETON_EDIT_SELECTED_BANNER_MESSAGE); + describe("getSpatialSkeletonDefaultStatusText", () => { + it("no selection", () => { + expect(getSpatialSkeletonDefaultStatusText("none", false)).toEqual({ + status: "No selection", + actions: `Click to select · drag to move · hold m to merge · hold s to split · hold n for new skeleton · hold d to delete · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + + it("selected, visible skeleton", () => { + expect( + getSpatialSkeletonDefaultStatusText("selected-visible", false), + ).toEqual({ + status: "Node selected", + actions: `Click to select · drag to move · shift+click to add node · hold m to merge · hold s to split · hold n for new skeleton · hold d to delete · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + + it("selected, visible skeleton, shift held", () => { + expect( + getSpatialSkeletonDefaultStatusText("selected-visible", true), + ).toEqual({ + status: "Ready to place new node", + actions: `Click to select · drag to move · shift+click to add node · hold m to merge · hold s to split · hold n for new skeleton · hold d to delete · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + + it("selected, non-visible skeleton", () => { + expect( + getSpatialSkeletonDefaultStatusText("selected-hidden", false), + ).toEqual({ + status: "Node selected from non-visible skeleton", + actions: `Double-click skeleton to show it · hold m to merge · hold s to split · hold n for new skeleton · hold d to delete · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + + it("selected, non-visible skeleton, shift held — unaffected by shift", () => { + expect( + getSpatialSkeletonDefaultStatusText("selected-hidden", true), + ).toEqual({ + status: "Node selected from non-visible skeleton", + actions: `Double-click skeleton to show it · hold m to merge · hold s to split · hold n for new skeleton · hold d to delete · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); }); - it("switches merge banner copy after the first point is selected", () => { - expect(getSpatialSkeletonMergeBannerMessage(undefined)).toBe( - SPATIAL_SKELETON_MERGE_BANNER_MESSAGE, - ); - expect( - getSpatialSkeletonMergeBannerMessage({ nodeId: 8, segmentId: 12 }), - ).toBe(SPATIAL_SKELETON_MERGE_SELECTED_BANNER_MESSAGE); + it("returns a static moving-node status", () => { + expect(getSpatialSkeletonMovingStatusText()).toEqual({ + status: "Moving node", + actions: SPATIAL_SKELETON_ROTATE_PAN_HINT, + }); }); - it("keeps the split banner copy stable", () => { - expect(SPATIAL_SKELETON_SPLIT_BANNER_MESSAGE).toBe( - "Select 1 node to split", - ); + describe("getSpatialSkeletonMergeStatusText", () => { + it("no from node, key held", () => { + expect(getSpatialSkeletonMergeStatusText("no-from-node", true)).toEqual({ + status: "Merge · click a node to merge from", + actions: `Click to select node · release m to exit merge · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + + it("no from node, key not held", () => { + expect(getSpatialSkeletonMergeStatusText("no-from-node", false)).toEqual({ + status: "Merge · click a node to merge from", + actions: `Click to select node · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + + it("from node selected on a visible skeleton, key held", () => { + expect( + getSpatialSkeletonMergeStatusText("from-node-visible", true), + ).toEqual({ + status: "Merge · click a node to merge to", + actions: `Click to select node · release m to exit merge · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + + it("from node selected on a visible skeleton, key not held", () => { + expect( + getSpatialSkeletonMergeStatusText("from-node-visible", false), + ).toEqual({ + status: "Merge · click a node to merge to", + actions: `Click to select node · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + + it("from node on a non-visible skeleton, key held", () => { + expect( + getSpatialSkeletonMergeStatusText("from-node-hidden", true), + ).toEqual({ + status: "Merge · make the from-node skeleton visible", + actions: `Double-click skeleton to show it · release m to exit merge · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + + it("from node on a non-visible skeleton, key not held", () => { + expect( + getSpatialSkeletonMergeStatusText("from-node-hidden", false), + ).toEqual({ + status: "Merge · make the from-node skeleton visible", + actions: `Double-click skeleton to show it · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + }); + + it("returns a static merging status", () => { + expect(getSpatialSkeletonMergingStatusText()).toEqual({ + status: "Merge · merging nodes…", + actions: SPATIAL_SKELETON_ROTATE_PAN_HINT, + }); + }); + + describe("getSpatialSkeletonSplitIdleStatusText", () => { + it("key held", () => { + expect(getSpatialSkeletonSplitIdleStatusText(true)).toEqual({ + status: "Split · click a node to form the root of a new skeleton", + actions: `Click to select node · release s to exit split · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + + it("key not held", () => { + expect(getSpatialSkeletonSplitIdleStatusText(false)).toEqual({ + status: "Split · click a node to form the root of a new skeleton", + actions: `Click to select node · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + }); + + it("returns a static splitting status", () => { + expect(getSpatialSkeletonSplittingStatusText()).toEqual({ + status: "Split · splitting node…", + actions: SPATIAL_SKELETON_ROTATE_PAN_HINT, + }); + }); + + describe("getSpatialSkeletonDeleteIdleStatusText", () => { + it("key held", () => { + expect(getSpatialSkeletonDeleteIdleStatusText(true)).toEqual({ + status: "Delete · no selected nodes", + actions: `Click a node to delete · release d to exit delete · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + + it("key not held", () => { + expect(getSpatialSkeletonDeleteIdleStatusText(false)).toEqual({ + status: "Delete · no selected nodes", + actions: `Click a node to delete · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + }); + + it("returns a static deleting status", () => { + expect(getSpatialSkeletonDeletingStatusText()).toEqual({ + status: "Delete · deleting node…", + actions: SPATIAL_SKELETON_ROTATE_PAN_HINT, + }); + }); + + describe("getSpatialSkeletonCreateIdleStatusText", () => { + it("key held", () => { + expect(getSpatialSkeletonCreateIdleStatusText(true)).toEqual({ + status: "Create · ready to place", + actions: `Click to place a new skeleton · release n to exit create · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + + it("key not held", () => { + expect(getSpatialSkeletonCreateIdleStatusText(false)).toEqual({ + status: "Create · ready to place", + actions: `Click to place a new skeleton · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + }); + + it("returns a static creating status", () => { + expect(getSpatialSkeletonCreatingStatusText()).toEqual({ + status: "Create · creating skeleton…", + actions: SPATIAL_SKELETON_ROTATE_PAN_HINT, + }); }); }); diff --git a/src/ui/skeleton_edit_tool_messages.ts b/src/ui/skeleton_edit_tool_messages.ts index 675d11e72b..83c714698d 100644 --- a/src/ui/skeleton_edit_tool_messages.ts +++ b/src/ui/skeleton_edit_tool_messages.ts @@ -35,25 +35,6 @@ export interface SpatialSkeletonToolStatusField { value: string; } -export const SPATIAL_SKELETON_EDIT_BANNER_MESSAGE = - "Move nodes, select a node to append or click to start a new skeleton"; -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 · 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, ) { @@ -105,18 +86,166 @@ export function getSpatialSkeletonToolPointStatusFields( return fields; } -export function getSpatialSkeletonEditBannerMessage( - selectedPoint: SpatialSkeletonToolPointInfo | undefined, -) { - return selectedPoint === undefined - ? SPATIAL_SKELETON_DEFAULT_BANNER_MESSAGE - : SPATIAL_SKELETON_DEFAULT_SELECTED_BANNER_MESSAGE; +// --- Name / status / actions message system --- +// +// The tool's status bar is split into three parts: a name (rendered by the +// caller via a fixed header, see SPATIAL_SKELETON_EDIT_TOOL_NAME), a short +// `status` describing what's currently true, and a short `actions` list +// describing what's currently doable. Keeping these separate (rather than +// one long banner string) avoids mixing state with instructions, and lets +// the no-selection default state stop advertising actions that don't apply +// yet (e.g. shift+click, which requires an existing selection). +// +// User-facing copy says "from node" rather than "merge anchor" — the +// internal name (mergeAnchorNodeId, etc.) is unaffected. + +export interface SpatialSkeletonToolStatusText { + status: string; + actions: string; +} + +export const SPATIAL_SKELETON_EDIT_TOOL_NAME = "Skeleton editing"; +export const SPATIAL_SKELETON_ROTATE_PAN_HINT = + "middle-click or ctrl+click to rotate/pan"; + +export type SpatialSkeletonDefaultSelectionState = + | "none" + | "selected-visible" + | "selected-hidden"; + +export function getSpatialSkeletonDefaultStatusText( + state: SpatialSkeletonDefaultSelectionState, + shiftHeld: boolean, +): SpatialSkeletonToolStatusText { + switch (state) { + case "none": + return { + status: "No selection", + actions: `Click to select · drag to move · hold m to merge · hold s to split · hold n for new skeleton · hold d to delete · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }; + case "selected-visible": + return { + status: shiftHeld ? "Ready to place new node" : "Node selected", + actions: `Click to select · drag to move · shift+click to add node · hold m to merge · hold s to split · hold n for new skeleton · hold d to delete · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }; + case "selected-hidden": + return { + status: "Node selected from non-visible skeleton", + actions: `Double-click skeleton to show it · hold m to merge · hold s to split · hold n for new skeleton · hold d to delete · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }; + } +} + +export function getSpatialSkeletonMovingStatusText(): SpatialSkeletonToolStatusText { + return { status: "Moving node", actions: SPATIAL_SKELETON_ROTATE_PAN_HINT }; } -export function getSpatialSkeletonMergeBannerMessage( - selectedPoint: SpatialSkeletonToolPointInfo | undefined, +export type SpatialSkeletonMergeState = + | "no-from-node" + | "from-node-visible" + | "from-node-hidden"; + +function withExitHint( + action: string, + canExitWithKey: boolean, + exitHint: string, ) { - return selectedPoint === undefined - ? SPATIAL_SKELETON_MERGE_BANNER_MESSAGE - : SPATIAL_SKELETON_MERGE_SELECTED_BANNER_MESSAGE; + return canExitWithKey + ? `${action} · ${exitHint} · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}` + : `${action} · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`; +} + +export function getSpatialSkeletonMergeStatusText( + state: SpatialSkeletonMergeState, + canExitWithKey: boolean, +): SpatialSkeletonToolStatusText { + const exitHint = "release m to exit merge"; + switch (state) { + case "no-from-node": + return { + status: "Merge · click a node to merge from", + actions: withExitHint("Click to select node", canExitWithKey, exitHint), + }; + case "from-node-visible": + return { + status: "Merge · click a node to merge to", + actions: withExitHint("Click to select node", canExitWithKey, exitHint), + }; + case "from-node-hidden": + return { + status: "Merge · make the from-node skeleton visible", + actions: withExitHint( + "Double-click skeleton to show it", + canExitWithKey, + exitHint, + ), + }; + } +} + +export function getSpatialSkeletonMergingStatusText(): SpatialSkeletonToolStatusText { + return { + status: "Merge · merging nodes…", + actions: SPATIAL_SKELETON_ROTATE_PAN_HINT, + }; +} + +export function getSpatialSkeletonSplitIdleStatusText( + canExitWithKey: boolean, +): SpatialSkeletonToolStatusText { + return { + status: "Split · click a node to form the root of a new skeleton", + actions: withExitHint( + "Click to select node", + canExitWithKey, + "release s to exit split", + ), + }; +} + +export function getSpatialSkeletonSplittingStatusText(): SpatialSkeletonToolStatusText { + return { + status: "Split · splitting node…", + actions: SPATIAL_SKELETON_ROTATE_PAN_HINT, + }; +} + +export function getSpatialSkeletonDeleteIdleStatusText( + canExitWithKey: boolean, +): SpatialSkeletonToolStatusText { + return { + status: "Delete · no selected nodes", + actions: withExitHint( + "Click a node to delete", + canExitWithKey, + "release d to exit delete", + ), + }; +} + +export function getSpatialSkeletonDeletingStatusText(): SpatialSkeletonToolStatusText { + return { + status: "Delete · deleting node…", + actions: SPATIAL_SKELETON_ROTATE_PAN_HINT, + }; +} + +export function getSpatialSkeletonCreateIdleStatusText( + canExitWithKey: boolean, +): SpatialSkeletonToolStatusText { + return { + status: "Create · ready to place", + actions: withExitHint( + "Click to place a new skeleton", + canExitWithKey, + "release n to exit create", + ), + }; +} + +export function getSpatialSkeletonCreatingStatusText(): SpatialSkeletonToolStatusText { + return { + status: "Create · creating skeleton…", + actions: SPATIAL_SKELETON_ROTATE_PAN_HINT, + }; } diff --git a/src/ui/skeleton_edit_tools.css b/src/ui/skeleton_edit_tools.css index b9e362d08e..a6f15e5ae5 100644 --- a/src/ui/skeleton_edit_tools.css +++ b/src/ui/skeleton_edit_tools.css @@ -16,69 +16,82 @@ .neuroglancer-skeleton-tool-status { display: flex; - flex-wrap: wrap; + flex: 1; align-items: center; - gap: 0.4rem; + gap: 0.75rem; + min-width: 0; } -.neuroglancer-skeleton-tool-status-message { - display: inline-flex; - align-items: center; -} - -.neuroglancer-skeleton-tool-status-point { - display: inline-flex; - flex-wrap: wrap; - align-items: center; - gap: 0.55rem; - color: #e6cb57; +/* Sits right after the divider, immediately beside the tool name in the + header, sized to its own content (not flex-grow) rather than stretching + across the bar. */ +.neuroglancer-skeleton-tool-status-divider { + flex: 0 0 auto; + color: #6b6c6f; + line-height: 1.25rem; } -.neuroglancer-skeleton-tool-status-point-field { - display: inline-flex; - align-items: center; - gap: 0.2rem; - font-weight: 600; +.neuroglancer-skeleton-tool-status-text { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.25rem; } -.neuroglancer-skeleton-tool-status-point-field-label, -.neuroglancer-skeleton-tool-status-point-field-value { - color: inherit; +/* margin-left: auto consumes all remaining space on the main axis before + justify-content is applied, so the actions list is pushed to the far + right regardless of the status text's width or the parent's + justify-content (neuroglass-theme.css sets justify-content: flex-end on + this same element via a higher-specificity nested rule). */ +.neuroglancer-skeleton-tool-status-actions { + flex: 0 1 auto; + margin-left: auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #999ca0; + font-size: 0.8125rem; + line-height: 1.25rem; + text-align: right; } /* 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="default"] { + cursor: + url("./images/crosshair_cursor.svg") 20 20, + crosshair; } .neuroglancer-rendered-data-panel[data-skeleton-edit-mode="add"] { cursor: - url("data:image/svg+xml,+") - 16 16, + url("./images/add_node_cursor.svg") 20 20, crosshair; } .neuroglancer-rendered-data-panel[data-skeleton-edit-mode="merge"] { cursor: - url("data:image/svg+xml,M") - 16 16, + url("./images/merge_cursor.svg") 20 20, crosshair; } .neuroglancer-rendered-data-panel[data-skeleton-edit-mode="create"] { cursor: - url("data:image/svg+xml,N") - 16 16, + url("./images/new_skeleton_cursor.svg") 20 20, crosshair; } .neuroglancer-rendered-data-panel[data-skeleton-edit-mode="split"] { cursor: - url("data:image/svg+xml,S") - 16 16, + url("./images/split_cursor.svg") 20 20, + crosshair; +} + +.neuroglancer-rendered-data-panel[data-skeleton-edit-mode="delete"] { + cursor: + url("./images/delete_cursor.svg") 20 20, crosshair; } @@ -88,9 +101,11 @@ } .neuroglancer-rendered-data-panel[data-skeleton-press-mode="pan"] { - cursor: grab; + cursor: move; } .neuroglancer-rendered-data-panel[data-skeleton-press-mode="move"] { - cursor: grabbing; + cursor: + url("./images/drag_cursor.svg") 20 20, + grabbing; } diff --git a/src/ui/skeleton_edit_tools.spec.ts b/src/ui/skeleton_edit_tools.spec.ts index 83f7123392..c18ee5b59f 100644 --- a/src/ui/skeleton_edit_tools.spec.ts +++ b/src/ui/skeleton_edit_tools.spec.ts @@ -724,6 +724,8 @@ describe("spatial_skeleton_edit_tool", () => { }, spatialSkeletonEditMode: makeModeWatchable(), spatialSkeletonMergeMode: makeModeWatchable(), + spatialSkeletonSplitMode: makeModeWatchable(), + spatialSkeletonSuppressSelectedNodeHighlight: makeModeWatchable(), selectedSpatialSkeletonNodeInfo: { value: undefined, changed: makeChangedSignal(), @@ -817,6 +819,8 @@ describe("spatial_skeleton_edit_tool", () => { }, spatialSkeletonEditMode: makeModeWatchable(), spatialSkeletonMergeMode: makeModeWatchable(), + spatialSkeletonSplitMode: makeModeWatchable(), + spatialSkeletonSuppressSelectedNodeHighlight: makeModeWatchable(), selectedSpatialSkeletonNodeInfo: { value: undefined, changed: makeChangedSignal(), @@ -889,6 +893,8 @@ describe("spatial_skeleton_edit_tool", () => { }, spatialSkeletonEditMode: makeModeWatchable(), spatialSkeletonMergeMode: makeModeWatchable(), + spatialSkeletonSplitMode: makeModeWatchable(), + spatialSkeletonSuppressSelectedNodeHighlight: makeModeWatchable(), selectedSpatialSkeletonNodeInfo: { value: undefined, // No node selected. changed: makeChangedSignal(), diff --git a/src/ui/skeleton_edit_tools.ts b/src/ui/skeleton_edit_tools.ts index b996f11eb6..fb98733803 100644 --- a/src/ui/skeleton_edit_tools.ts +++ b/src/ui/skeleton_edit_tools.ts @@ -28,8 +28,8 @@ import { getVisibleSegments } from "#src/segmentation_display_state/base.js"; import { SKELETON_ADD_NODE, SKELETON_CLEAR_SELECTION, - SKELETON_DELETE_NODE, SKELETON_ENTER_CREATE, + SKELETON_ENTER_DELETE_MODE, SKELETON_ENTER_MERGE_MODE, SKELETON_ENTER_SPLIT_MODE, SKELETON_PIN_NODE, @@ -64,14 +64,19 @@ import { getDefaultSkeletonEditNodeBindings, getDefaultSkeletonEditToolBindings, } from "#src/ui/default_input_event_bindings.js"; -import type { SpatialSkeletonToolPointInfo } from "#src/ui/skeleton_edit_tool_messages.js"; +import type { SpatialSkeletonToolStatusText } from "#src/ui/skeleton_edit_tool_messages.js"; import { - SPATIAL_SKELETON_CREATE_BANNER_MESSAGE, - SPATIAL_SKELETON_HIDDEN_SELECTED_BANNER_MESSAGE, - SPATIAL_SKELETON_MERGE_SELECTED_BANNER_MESSAGE, - SPATIAL_SKELETON_MOVING_NODE_MESSAGE, - getSpatialSkeletonEditBannerMessage, - getSpatialSkeletonToolPointStatusFields, + SPATIAL_SKELETON_EDIT_TOOL_NAME, + getSpatialSkeletonCreateIdleStatusText, + getSpatialSkeletonCreatingStatusText, + getSpatialSkeletonDefaultStatusText, + getSpatialSkeletonDeleteIdleStatusText, + getSpatialSkeletonDeletingStatusText, + getSpatialSkeletonMergeStatusText, + getSpatialSkeletonMergingStatusText, + getSpatialSkeletonMovingStatusText, + getSpatialSkeletonSplitIdleStatusText, + getSpatialSkeletonSplittingStatusText, } from "#src/ui/skeleton_edit_tool_messages.js"; import type { ToolActivation } from "#src/ui/tool.js"; import { @@ -93,16 +98,22 @@ const enum SkeletonEditMode { Merge = 1, Create = 2, Split = 3, + Delete = 4, } -// 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 +// In edit mode, plain left click is selection-only — it never rotates or +// pans. Navigation (rotate in perspective, pan in slice) is handled by +// middle mouse (mousedown1), plus trackpad-friendly aliases on the +// navigation modifier + left mouse (control+mousedown0 on most platforms, +// cmd+mousedown0 on Mac — see hasNavigationModifier below): the modifier +// alone mirrors plain middle-click, and modifier+shift mirrors +// control+middle-click. 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 +// mousedown1 / control?+mousedown0 → rotate-via-mouse-drag covers perspective +// panels via the EventActionMap. Slice panels intercept middle mouse and the +// navigation-modifier chords in the capture listener and call +// translateByViewportPixels directly, consuming the event before // MouseEventBinder can dispatch this action. // // Default bindings are defined in getDefaultSkeletonEditToolBindings() / @@ -110,6 +121,22 @@ const enum SkeletonEditMode { const DRAG_START_DISTANCE_PX = 2; +// Physical key codes that exit the corresponding momentary mode on keyup +// (see the onKeyUp handler in activate()). Centralized here so the "which +// key exits which mode" association — inherently duplicated between the +// exit trigger and the status-bar hint that describes it — lives in exactly +// one place. Tool-scoped bindings like these aren't wired into the app's +// input-event-map rebinding system, so this can't be derived generically. +const MERGE_EXIT_KEY_CODE = "KeyM"; +const SPLIT_EXIT_KEY_CODE = "KeyS"; +const CREATE_EXIT_KEY_CODE = "KeyN"; +const DELETE_EXIT_KEY_CODE = "KeyD"; + +function hasNavigationModifier(event: { ctrlKey: boolean; metaKey: boolean }) { + // TODO replace by mac check + return event.metaKey || event.ctrlKey; +} + function waitForNextAnimationFrame() { return new Promise((resolve) => { if (typeof requestAnimationFrame !== "function") { @@ -122,36 +149,25 @@ function waitForNextAnimationFrame() { function renderSpatialSkeletonToolStatus( body: HTMLElement, - options: { - message: string; - point?: SpatialSkeletonToolPointInfo; - }, + text: SpatialSkeletonToolStatusText, ) { removeChildren(body); body.classList.add("neuroglancer-skeleton-tool-status"); - const message = document.createElement("span"); - message.className = "neuroglancer-skeleton-tool-status-message"; - message.textContent = options.message; - body.appendChild(message); - if (options.point === undefined) { + const dividerElement = document.createElement("span"); + dividerElement.className = "neuroglancer-skeleton-tool-status-divider"; + dividerElement.textContent = "—"; + body.appendChild(dividerElement); + const statusElement = document.createElement("span"); + statusElement.className = "neuroglancer-skeleton-tool-status-text"; + statusElement.textContent = text.status; + body.appendChild(statusElement); + if (text.actions.length === 0) { return; } - const point = document.createElement("span"); - point.className = "neuroglancer-skeleton-tool-status-point"; - for (const field of getSpatialSkeletonToolPointStatusFields(options.point)) { - const fieldElement = document.createElement("span"); - fieldElement.className = "neuroglancer-skeleton-tool-status-point-field"; - const label = document.createElement("span"); - label.className = "neuroglancer-skeleton-tool-status-point-field-label"; - label.textContent = field.label; - fieldElement.appendChild(label); - const value = document.createElement("span"); - value.className = "neuroglancer-skeleton-tool-status-point-field-value"; - value.textContent = field.value; - fieldElement.appendChild(value); - point.appendChild(fieldElement); - } - body.appendChild(point); + const actionsElement = document.createElement("span"); + actionsElement.className = "neuroglancer-skeleton-tool-status-actions"; + actionsElement.textContent = text.actions; + body.appendChild(actionsElement); } abstract class SpatialSkeletonToolBase extends LayerTool { @@ -513,10 +529,19 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { // One-shot guards: prevent repeated fires while a key is held down. private mergeKeyHeld = false; private splitKeyHeld = false; + private deleteKeyHeld = false; // Modifier-held state drives cursor indicators and blocks node actions. private shiftHeld = false; - private statusOverride: string | undefined = undefined; - private statusPoint: SpatialSkeletonToolPointInfo | undefined = undefined; + // Navigation modifier (ctrl, or cmd on Mac — see hasNavigationModifier). + // While held, the shift-driven "add node" cursor/status must be + // suppressed, since modifier+shift now means pan, not add-node. + private ctrlHeld = false; + // Physical key codes currently held down — used only to decide whether the + // status actions text should show a "release to exit" hint. Merge/ + // split/create can also be entered via a synthetic dispatched action (no + // physical keydown), in which case that hint would be misleading. + private heldPhysicalKeyCodes = new Set(); + private statusOverride: SpatialSkeletonToolStatusText | undefined = undefined; // Set at activation start; cleared by the activation disposer to prevent // post-deactivation UI writes. private statusBody: HTMLElement | undefined = undefined; @@ -545,10 +570,12 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.setModeAttribute("create"); } else if (this.currentMode === SkeletonEditMode.Split) { this.setModeAttribute("split"); - } else if (this.shiftHeld) { + } else if (this.currentMode === SkeletonEditMode.Delete) { + this.setModeAttribute("delete"); + } else if (this.shiftHeld && !this.ctrlHeld) { this.setModeAttribute("add"); } else { - this.setModeAttribute(undefined); + this.setModeAttribute("default"); } } @@ -558,57 +585,61 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { if (this.statusBody === undefined) return; const body = this.statusBody; if (this.statusOverride !== undefined) { - renderSpatialSkeletonToolStatus(body, { - message: this.statusOverride, - point: this.statusPoint, - }); + renderSpatialSkeletonToolStatus(body, this.statusOverride); return; } if (this.currentMode === SkeletonEditMode.Merge) { const anchorNodeId = this.layer.spatialSkeletonState.mergeAnchorNodeId.value; + const canExitWithKey = this.heldPhysicalKeyCodes.has(MERGE_EXIT_KEY_CODE); 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 ( + const isHidden = 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, - }); - } + !this.isSpatialSkeletonSegmentVisible(cachedNode.segmentId); + renderSpatialSkeletonToolStatus( + body, + getSpatialSkeletonMergeStatusText( + isHidden ? "from-node-hidden" : "from-node-visible", + canExitWithKey, + ), + ); } else { - renderSpatialSkeletonToolStatus(body, { - message: "Click a node to set as merge anchor · release m to exit", - }); + renderSpatialSkeletonToolStatus( + body, + getSpatialSkeletonMergeStatusText("no-from-node", canExitWithKey), + ); } return; } if (this.currentMode === SkeletonEditMode.Split) { - renderSpatialSkeletonToolStatus(body, { - message: "Click a node to split · release s to exit", - }); + renderSpatialSkeletonToolStatus( + body, + getSpatialSkeletonSplitIdleStatusText( + this.heldPhysicalKeyCodes.has(SPLIT_EXIT_KEY_CODE), + ), + ); return; } if (this.currentMode === SkeletonEditMode.Create) { - renderSpatialSkeletonToolStatus(body, { - message: SPATIAL_SKELETON_CREATE_BANNER_MESSAGE, - }); + renderSpatialSkeletonToolStatus( + body, + getSpatialSkeletonCreateIdleStatusText( + this.heldPhysicalKeyCodes.has(CREATE_EXIT_KEY_CODE), + ), + ); + return; + } + if (this.currentMode === SkeletonEditMode.Delete) { + renderSpatialSkeletonToolStatus( + body, + getSpatialSkeletonDeleteIdleStatusText( + this.heldPhysicalKeyCodes.has(DELETE_EXIT_KEY_CODE), + ), + ); return; } // Default mode @@ -616,37 +647,46 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { const isHidden = selectedPoint?.segmentId !== undefined && !this.isSpatialSkeletonSegmentVisible(selectedPoint.segmentId); - renderSpatialSkeletonToolStatus(body, { - message: isHidden - ? SPATIAL_SKELETON_HIDDEN_SELECTED_BANNER_MESSAGE - : getSpatialSkeletonEditBannerMessage(selectedPoint), - point: selectedPoint, - }); + renderSpatialSkeletonToolStatus( + body, + getSpatialSkeletonDefaultStatusText( + selectedPoint === undefined + ? "none" + : isHidden + ? "selected-hidden" + : "selected-visible", + this.shiftHeld && !this.ctrlHeld, + ), + ); } - private setStatus( - message: string | undefined, - point?: SpatialSkeletonToolPointInfo, - ) { - this.statusOverride = message; - this.statusPoint = point; + private setStatus(text: SpatialSkeletonToolStatusText | undefined) { + this.statusOverride = text; this.renderStatus(); } private clearStatus() { - this.setStatus(undefined, undefined); + this.setStatus(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 }) { + // Sync shiftHeld/ctrlHeld from the logical modifier flags on any event + // that carries them. 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; + ctrlKey: boolean; + metaKey: boolean; + }) { const isShift = event.shiftKey; - if (this.shiftHeld === isShift) return; + const isCtrl = hasNavigationModifier(event); + if (this.shiftHeld === isShift && this.ctrlHeld === isCtrl) return; this.shiftHeld = isShift; + this.ctrlHeld = isCtrl; this.updateModeAttribute(); + this.renderStatus(); } // --- Mode transitions --- @@ -664,6 +704,11 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.layer.selectSpatialSkeletonNode(anchorNode.nodeId, true, anchorNode); this.layer.setSpatialSkeletonMergeAnchor(anchorNode.nodeId); } + // In merge mode the selected-node highlight is only shown once a from node + // has been picked (the first click). When entered with an explicit anchor, + // that first pick has effectively already happened. + this.layer.spatialSkeletonSuppressSelectedNodeHighlight.value = + anchorNode === undefined; this.layer.spatialSkeletonMergeMode.value = true; this.currentMode = SkeletonEditMode.Merge; this.updateModeAttribute(); @@ -674,6 +719,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { if (this.currentMode !== SkeletonEditMode.Merge) return; this.layer.clearSpatialSkeletonMergeAnchor(); this.layer.spatialSkeletonMergeMode.value = false; + this.layer.spatialSkeletonSuppressSelectedNodeHighlight.value = false; this.currentMode = SkeletonEditMode.Default; this.updateModeAttribute(); this.clearStatus(); @@ -697,6 +743,9 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { private enterSplit() { this.currentMode = SkeletonEditMode.Split; this.layer.spatialSkeletonSplitMode.value = true; + // In split mode the selected-node highlight stays hidden until the user + // clicks a node to split (or exits back to default mode). + this.layer.spatialSkeletonSuppressSelectedNodeHighlight.value = true; this.updateModeAttribute(); this.renderStatus(); } @@ -705,6 +754,20 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { if (this.currentMode !== SkeletonEditMode.Split) return; this.currentMode = SkeletonEditMode.Default; this.layer.spatialSkeletonSplitMode.value = false; + this.layer.spatialSkeletonSuppressSelectedNodeHighlight.value = false; + this.updateModeAttribute(); + this.clearStatus(); + } + + private enterDelete() { + this.currentMode = SkeletonEditMode.Delete; + this.updateModeAttribute(); + this.renderStatus(); + } + + private exitDelete() { + if (this.currentMode !== SkeletonEditMode.Delete) return; + this.currentMode = SkeletonEditMode.Default; this.updateModeAttribute(); this.clearStatus(); } @@ -788,7 +851,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.dragInProgress = true; skeletonLayer!.markSegmentEdited(nodeInfo!.segmentId); panel.element.dataset.skeletonPressMode = "move"; - this.setStatus(SPATIAL_SKELETON_MOVING_NODE_MESSAGE); + this.setStatus(getSpatialSkeletonMovingStatusText()); } panel.translateDataPointByViewportPixels( this.dragGlobalPosition, @@ -860,13 +923,10 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { }) { this.pinSegmentByNumber(pickedNode.segmentId); this.layer.selectSpatialSkeletonNode(pickedNode.nodeId, true, pickedNode); - const splitPoint: SpatialSkeletonToolPointInfo = { - nodeId: pickedNode.nodeId, - segmentId: pickedNode.segmentId, - position: pickedNode.position, - }; + // A node was clicked: reveal the selected-node highlight for it. + this.layer.spatialSkeletonSuppressSelectedNodeHighlight.value = false; this.pending = true; - this.setStatus("Splitting selected node.", splitPoint); + this.setStatus(getSpatialSkeletonSplittingStatusText()); void (async () => { try { await executeSpatialSkeletonSplit(this.layer, { @@ -931,6 +991,8 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { } this.layer.selectSpatialSkeletonNode(pickedNode.nodeId, true, pickedNode); this.layer.setSpatialSkeletonMergeAnchor(pickedNode.nodeId); + // First click made: reveal the selected-node highlight for the from node. + this.layer.spatialSkeletonSuppressSelectedNodeHighlight.value = false; this.renderStatus(); } @@ -1000,7 +1062,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.pinSegmentByNumber(pickedNode.segmentId); this.layer.selectSpatialSkeletonNode(pickedNode.nodeId, true, pickedNode); this.pending = true; - this.setStatus("Merging selected nodes."); + this.setStatus(getSpatialSkeletonMergingStatusText()); void (async () => { try { @@ -1059,7 +1121,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.createPlacedThisHold = true; this.pending = true; - this.setStatus("Creating new skeleton."); + this.setStatus(getSpatialSkeletonCreatingStatusText()); void (async () => { try { @@ -1136,6 +1198,33 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.enterSplit(); } + // Delete (d): enters delete mode — click a node to delete it. + private onEnterDeleteModeAction() { + if ( + this.deleteKeyHeld || + this.dragInProgress || + this.pending || + this.currentMode !== SkeletonEditMode.Default + ) + return; + this.deleteKeyHeld = true; + 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; + } + this.enterDelete(); + } + private onAddNodeAction(event: ActionEvent) { event.stopPropagation(); event.detail.preventDefault(); @@ -1233,9 +1322,10 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { ); } - private onDeleteNodeAction(event: ActionEvent) { - event.stopPropagation(); - event.detail.preventDefault(); + private handleDeletePick() { + // Caller (capture listener) already called stopPropagation/preventDefault. + if (this.pending) return; + const disabledReason = this.layer.getSpatialSkeletonActionsDisabledReason( SpatialSkeletonActions.deleteNodes, ); @@ -1252,6 +1342,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { } const pickedNode = this.getPickedSpatialSkeletonNode(); if (pickedNode === undefined) { + StatusMessage.showTemporaryMessage("Click a skeleton node to delete."); return; } const nodeInfo = skeletonLayer.getNode(pickedNode.nodeId); @@ -1261,11 +1352,17 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { ); return; } + this.pending = true; + this.setStatus(getSpatialSkeletonDeletingStatusText()); void this.layer .getSpatialSkeletonDeleteOperationContext(nodeInfo) .then(() => executeSpatialSkeletonDeleteNode(this.layer, nodeInfo)) .catch((error) => { showSpatialSkeletonActionError("delete node", error); + }) + .finally(() => { + this.pending = false; + this.renderStatus(); }); } @@ -1280,14 +1377,17 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.createPlacedThisHold = false; this.mergeKeyHeld = false; this.splitKeyHeld = false; + this.deleteKeyHeld = false; this.shiftHeld = false; + this.ctrlHeld = false; + this.heldPhysicalKeyCodes = new Set(); this.statusOverride = undefined; - this.statusPoint = undefined; + layer.spatialSkeletonSuppressSelectedNodeHighlight.value = false; // 2. Create status UI. const { body, header } = makeToolActivationStatusMessageWithHeader(activation); - header.textContent = "Skeleton edit"; + header.textContent = SPATIAL_SKELETON_EDIT_TOOL_NAME; this.statusBody = body; // 3. Precondition checks. @@ -1297,14 +1397,17 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { ); if (disabledReason !== undefined) { StatusMessage.showTemporaryMessage(disabledReason); - renderSpatialSkeletonToolStatus(body, { message: disabledReason }); + renderSpatialSkeletonToolStatus(body, { + status: disabledReason, + actions: "", + }); 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 }); + renderSpatialSkeletonToolStatus(body, { status: msg, actions: "" }); queueMicrotask(() => activation.cancel()); return; } @@ -1316,6 +1419,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.setModeAttribute(undefined); layer.spatialSkeletonMergeMode.value = false; layer.spatialSkeletonSplitMode.value = false; + layer.spatialSkeletonSuppressSelectedNodeHighlight.value = false; layer.spatialSkeletonState.clearPendingNodePositions(); }); @@ -1366,17 +1470,28 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { ); // 9. Global key/mouse listeners — thin lambda wrappers delegating to class methods. - const onKeyDown = (event: KeyboardEvent) => this.syncModifiers(event); + const onKeyDown = (event: KeyboardEvent) => { + this.syncModifiers(event); + if (!this.heldPhysicalKeyCodes.has(event.code)) { + this.heldPhysicalKeyCodes.add(event.code); + this.renderStatus(); + } + }; const onKeyUp = (event: KeyboardEvent) => { - if (event.code === "KeyM") { + this.heldPhysicalKeyCodes.delete(event.code); + if (event.code === MERGE_EXIT_KEY_CODE) { this.mergeKeyHeld = false; this.exitMerge(); } - if (event.code === "KeyN") this.exitCreate(); - if (event.code === "KeyS") { + if (event.code === CREATE_EXIT_KEY_CODE) this.exitCreate(); + if (event.code === SPLIT_EXIT_KEY_CODE) { this.splitKeyHeld = false; this.exitSplit(); } + if (event.code === DELETE_EXIT_KEY_CODE) { + this.deleteKeyHeld = false; + this.exitDelete(); + } this.syncModifiers(event); }; // mousemove catches modifiers pressed/released while keyboard focus is @@ -1385,10 +1500,14 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { const onBlur = () => { this.mergeKeyHeld = false; this.splitKeyHeld = false; + this.deleteKeyHeld = false; this.shiftHeld = false; + this.ctrlHeld = false; + this.heldPhysicalKeyCodes = new Set(); this.exitMerge(); this.exitCreate(); this.exitSplit(); + this.exitDelete(); this.updateModeAttribute(); }; window.addEventListener("keydown", onKeyDown); @@ -1405,9 +1524,11 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { // 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. + // at lower priority. All plain/shift left clicks are owned by the edit tool — they + // either select a node, add a node, or do nothing. Navigation (rotate/pan) belongs to + // middle mouse and to the navigation-modifier + left-click aliases below (for trackpad + // users without a reliable middle-click), 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) => { @@ -1416,13 +1537,63 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { // 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)) { + if (panel instanceof PerspectivePanel) { + panel.element.dataset.skeletonPressMode = "rotate"; + const onMouseUp = () => { + delete panel.element.dataset.skeletonPressMode; + window.removeEventListener("mouseup", onMouseUp); + }; + window.addEventListener("mouseup", onMouseUp); + } else { event.stopPropagation(); event.preventDefault(); - startRelativeMouseDrag(event, (_dragEvent, deltaX, deltaY) => { - panel.context.flagContinuousCameraMotion(); - panel.translateByViewportPixels(deltaX, deltaY); - }); + panel.element.dataset.skeletonPressMode = "pan"; + startRelativeMouseDrag( + event, + (_dragEvent, deltaX, deltaY) => { + panel.context.flagContinuousCameraMotion(); + panel.translateByViewportPixels(deltaX, deltaY); + }, + () => { + delete panel.element.dataset.skeletonPressMode; + }, + ); + } + return; + } + + // Trackpad-friendly aliases for the middle-mouse scheme above. + // Navigation modifier + left (plain): rotate in 3D (EventActionMap + // control+mousedown0 → rotate-via-mouse-drag), pan in 2D + // (intercepted here) — mirrors plain middle mouse. + // Navigation modifier + shift + left: translate in 3D + // (EventActionMap control+shift+mousedown0 → translate-via-mouse-drag), + // pan in 2D (intercepted here, same as above) — mirrors ctrl+middle + // mouse. Checked before the shift guard below so it takes priority + // over the shift+mousedown0 add-node chord; hasNavigationModifier is + // the discriminator (add-node never has the modifier held). + if (event.button === 0 && hasNavigationModifier(event)) { + if (panel instanceof PerspectivePanel) { + panel.element.dataset.skeletonPressMode = "rotate"; + const onMouseUp = () => { + delete panel.element.dataset.skeletonPressMode; + window.removeEventListener("mouseup", onMouseUp); + }; + window.addEventListener("mouseup", onMouseUp); + } else { + event.stopPropagation(); + event.preventDefault(); + panel.element.dataset.skeletonPressMode = "pan"; + startRelativeMouseDrag( + event, + (_dragEvent, deltaX, deltaY) => { + panel.context.flagContinuousCameraMotion(); + panel.translateByViewportPixels(deltaX, deltaY); + }, + () => { + delete panel.element.dataset.skeletonPressMode; + }, + ); } return; } @@ -1448,6 +1619,12 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.handleCreatePlace(); return; } + if (this.currentMode === SkeletonEditMode.Delete) { + event.stopPropagation(); + event.preventDefault(); + this.handleDeletePick(); + return; + } // Default mode: only consume if hovering a node. this.handleDefaultMousedown(event, panel); }; @@ -1474,8 +1651,8 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { 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_ENTER_DELETE_MODE, () => + this.onEnterDeleteModeAction(), ); activation.bindAction(SKELETON_TOGGLE_TRUE_END, () => { const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); diff --git a/src/ui/skeleton_tab.css b/src/ui/skeleton_tab.css index 5503b3e738..a05017ae1c 100644 --- a/src/ui/skeleton_tab.css +++ b/src/ui/skeleton_tab.css @@ -81,7 +81,6 @@ } .neuroglancer-skeleton-section { - --neuroglancer-skeleton-actions-width: 44px; --neuroglancer-skeleton-type-width: 21px; border: 1px solid #2f2f2f; display: flex; @@ -182,7 +181,6 @@ .neuroglancer-skeleton-tree-row { display: grid; grid-template-columns: - var(--neuroglancer-skeleton-actions-width) var(--neuroglancer-skeleton-type-width) minmax(58px, 104px) minmax(0, 1fr); @@ -207,10 +205,6 @@ display: block; } -.neuroglancer-skeleton-list-header-actions { - width: var(--neuroglancer-skeleton-actions-width); -} - .neuroglancer-skeleton-list-header-type { width: var(--neuroglancer-skeleton-type-width); } @@ -225,6 +219,7 @@ } .neuroglancer-skeleton-tree-row { + position: relative; min-height: 1.6em; color: #d2d2d2; font-family: monospace; @@ -342,13 +337,15 @@ } .neuroglancer-skeleton-node-actions { + position: absolute; + right: 4px; + top: 0; + bottom: 0; display: inline-flex; align-items: center; gap: 0.1em; - width: var(--neuroglancer-skeleton-actions-width); visibility: hidden; pointer-events: none; - justify-content: flex-start; } .neuroglancer-skeleton-tree-entry[data-list-hovered="true"] diff --git a/src/ui/skeleton_tab.ts b/src/ui/skeleton_tab.ts index b63c1c6a7c..8eb3ff53aa 100644 --- a/src/ui/skeleton_tab.ts +++ b/src/ui/skeleton_tab.ts @@ -374,6 +374,11 @@ export class SpatialSkeletonEditTab extends Tab { let nodeDeletionAllowed = false; let nodeRerootAllowed = false; let pendingScrollToSelectedNode = false; + const MAX_SCROLL_RETRY_FRAMES = 6; + const SCROLL_IN_VIEW_EPSILON = 1; + let scrollRetryHandle: number | undefined; + let scrollRetriesRemaining = 0; + let scrollRetryNodeId: number | undefined; let loadedNodeSummarySuffix = ""; let hoveredViewerNodeId: number | undefined; let hoveredListNodeId: number | undefined; @@ -600,14 +605,111 @@ export class SpatialSkeletonEditTab extends Tab { } }; - const scrollListItemIntoView = (index: number) => { - if (nodesList.getItemElement(index) !== undefined) { - nodesList.scrollItemIntoView(index); + const cancelScrollRetry = () => { + if (scrollRetryHandle !== undefined) { + cancelAnimationFrame(scrollRetryHandle); + scrollRetryHandle = undefined; + } + }; + + // True when the row is fully visible below the sticky header (or is simply + // taller than the available viewport, in which case aligning its top is the + // best we can do). + const isRowFullyInView = (element: HTMLElement) => { + const listRect = nodesList.element.getBoundingClientRect(); + const viewportTop = listRect.top + nodesList.header.offsetHeight; + const viewportBottom = listRect.bottom; + const rowRect = element.getBoundingClientRect(); + const topVisible = rowRect.top >= viewportTop - SCROLL_IN_VIEW_EPSILON; + const bottomVisible = + rowRect.bottom <= viewportBottom + SCROLL_IN_VIEW_EPSILON; + const tallerThanViewport = rowRect.height > viewportBottom - viewportTop; + return topVisible && (bottomVisible || tallerThanViewport); + }; + + // Reveal the currently selected node's row in the virtual list. The virtual + // list renders asynchronously (animation-frame debounced) and positions + // unrendered rows using size *estimates*, so a single synchronous attempt is + // unreliable. We keep `pendingScrollToSelectedNode` set until the target row + // is genuinely rendered and fully in view, correcting the scroll position + // against the real measured geometry across a bounded number of frames. + const attemptScrollToSelectedNode = () => { + scrollRetryHandle = undefined; + const selectedNodeId = + layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; + if (selectedNodeId === undefined) { + pendingScrollToSelectedNode = false; + return; + } + // A newer selection superseded this loop. + if (selectedNodeId !== scrollRetryNodeId) return; + + const index = listIndexByNodeId.get(selectedNodeId); + if (index === undefined) { + // The node is not in the current list yet (async load, or a different + // segment). Leave the pending flag set without scheduling a frame or + // consuming the retry budget; `updateList` re-triggers this once the + // list is rebuilt with the node present. + return; + } + + const renderedElement = nodesList.getItemElement(index); + if (renderedElement !== undefined && isRowFullyInView(renderedElement)) { + pendingScrollToSelectedNode = false; + return; + } + if (scrollRetriesRemaining <= 0) { + // Found and rendered but still won't fit after several corrections; stop + // retrying so `updateList` doesn't loop forever. + pendingScrollToSelectedNode = false; return; } + scrollRetriesRemaining--; + + const headerHeight = nodesList.header.offsetHeight; nodesList.state.anchorIndex = index; - nodesList.state.anchorClientOffset = 0; + if (renderedElement === undefined) { + // Not rendered: anchor its top just below the sticky header and let the + // next frame render + measure it. + nodesList.state.anchorClientOffset = headerHeight; + } else { + // Rendered but out of view: correct using the real measured rect. + const listRect = nodesList.element.getBoundingClientRect(); + const rowRect = renderedElement.getBoundingClientRect(); + const relTop = rowRect.top - listRect.top; + if (relTop < headerHeight) { + nodesList.state.anchorClientOffset = headerHeight; + } else { + nodesList.state.anchorClientOffset = listRect.height - rowRect.height; + } + } + // Drives VirtualList's own debouncedUpdateView; its rAF is registered + // before ours below, so it runs first and our next attempt measures the + // freshly rendered row. virtualListRenderChanged.dispatch(); + scrollRetryHandle = requestAnimationFrame(attemptScrollToSelectedNode); + }; + + const scrollSelectedNodeIntoView = () => { + const selectedNodeId = + layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; + if (selectedNodeId === undefined) { + pendingScrollToSelectedNode = false; + cancelScrollRetry(); + return; + } + // A loop is already converging on this node; let it continue rather than + // restarting (and resetting) it on every list rebuild / hover update. + if ( + scrollRetryHandle !== undefined && + scrollRetryNodeId === selectedNodeId + ) { + return; + } + cancelScrollRetry(); + scrollRetryNodeId = selectedNodeId; + scrollRetriesRemaining = MAX_SCROLL_RETRY_FRAMES; + attemptScrollToSelectedNode(); }; const applyRowInteractionState = ( @@ -627,14 +729,7 @@ export class SpatialSkeletonEditTab extends Tab { entry.dataset.listHovered = String(isListHovered); }); if (options.scrollSelectedIntoView) { - pendingScrollToSelectedNode = false; - const selectedIndex = - selectedNodeId === undefined - ? undefined - : listIndexByNodeId.get(selectedNodeId); - if (selectedIndex !== undefined) { - scrollListItemIntoView(selectedIndex); - } + scrollSelectedNodeIntoView(); } }; @@ -1179,9 +1274,6 @@ export class SpatialSkeletonEditTab extends Tab { const makeListHeader = () => { const listHeader = document.createElement("div"); listHeader.className = "neuroglancer-skeleton-list-header"; - const headerActionsSpacer = document.createElement("span"); - headerActionsSpacer.className = - "neuroglancer-skeleton-list-header-spacer neuroglancer-skeleton-list-header-actions"; const headerTypeSpacer = document.createElement("span"); headerTypeSpacer.className = "neuroglancer-skeleton-list-header-spacer neuroglancer-skeleton-list-header-type"; @@ -1197,7 +1289,6 @@ export class SpatialSkeletonEditTab extends Tab { dimSpan.textContent = dimLabel; headerCoordinates.appendChild(dimSpan); } - listHeader.appendChild(headerActionsSpacer); listHeader.appendChild(headerTypeSpacer); listHeader.appendChild(headerId); listHeader.appendChild(headerCoordinates); @@ -1218,9 +1309,6 @@ export class SpatialSkeletonEditTab extends Tab { const segmentRow = document.createElement("div"); segmentRow.className = "neuroglancer-skeleton-tree-row neuroglancer-skeleton-segment-row"; - const segmentActionsSpacer = document.createElement("span"); - segmentActionsSpacer.className = - "neuroglancer-skeleton-list-header-spacer neuroglancer-skeleton-list-header-actions"; const segmentTypeSpacer = document.createElement("span"); segmentTypeSpacer.className = "neuroglancer-skeleton-list-header-spacer neuroglancer-skeleton-list-header-type"; @@ -1249,7 +1337,6 @@ export class SpatialSkeletonEditTab extends Tab { segmentMetaLine.appendChild(segmentName); segmentMetaLine.appendChild(segmentRatio); segmentMeta.appendChild(segmentMetaLine); - segmentRow.appendChild(segmentActionsSpacer); segmentRow.appendChild(segmentTypeSpacer); segmentRow.appendChild(segmentIdCell); segmentRow.appendChild(segmentMeta); @@ -1414,26 +1501,6 @@ export class SpatialSkeletonEditTab extends Tab { const actions = document.createElement("div"); actions.className = "neuroglancer-skeleton-node-actions"; - let rerootActionTitle = - 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"; - } - actions.appendChild( - makeRowActionButton( - svg_origin, - rerootActionTitle, - () => rerootNode(node), - !nodeRerootAllowed || - pendingRerootNodes.has(node.nodeId) || - node.parentNodeId === undefined || - nodeIsTrueEnd, - ), - ); let deleteActionTitle = "Delete node"; if (pendingDeleteNodes.has(node.nodeId)) { deleteActionTitle = "Deleting node"; @@ -1447,10 +1514,10 @@ export class SpatialSkeletonEditTab extends Tab { ), ); - row.appendChild(actions); row.appendChild(typeIcon); row.appendChild(idCell); row.appendChild(coordinatesCell); + row.appendChild(actions); entry.appendChild(row); return entry; }; @@ -1806,6 +1873,7 @@ export class SpatialSkeletonEditTab extends Tab { ); }, layer.displayState.segmentationColorGroupState), ); + this.registerDisposer(() => cancelScrollRetry()); this.registerDisposer( layer.selectedSpatialSkeletonNodeInfo.changed.add(() => { pendingScrollToSelectedNode = true; diff --git a/src/util/color.browser_test.ts b/src/util/color.browser_test.ts index 90b60f53fb..4915ddde3a 100644 --- a/src/util/color.browser_test.ts +++ b/src/util/color.browser_test.ts @@ -80,4 +80,4 @@ describe("color", () => { expect(packColor(vec4.fromValues(0, 0.2, 2, 1))).toEqual(0xffff3300); expect(packColor(vec4.fromValues(0.4, 4.4, -0.4, 4))).toEqual(0xff00ff66); }); -}); \ No newline at end of file +}); diff --git a/src/viewer.ts b/src/viewer.ts index 0a8a2e283a..1dc73d2b38 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -70,6 +70,7 @@ import { WatchableDisplayDimensionRenderInfo, } from "#src/navigation_state.js"; import { overlaysOpen } from "#src/overlay.js"; +import { PickingIndicatorOverlay } from "#src/picking_indicator_overlay.js"; import { ScreenshotHandler } from "#src/python_integration/screenshots.js"; import { allRenderLayerRoles, RenderLayerRole } from "#src/renderlayer.js"; import { @@ -543,6 +544,12 @@ export class Viewer extends RefCounted implements ViewerState { options: Partial = {}, ) { super(); + // Show the picking indicator on every data panel. + this.registerDisposer( + display.registerPanelOverlay( + new PickingIndicatorOverlay(this.mouseState), + ), + ); this.screenshotHandler = this.registerDisposer(new ScreenshotHandler(this)); this.screenshotManager = this.registerDisposer(new ScreenshotManager(this)); const {