Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions rspack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
{
Expand Down
1 change: 1 addition & 0 deletions src/datasource/catmaid/api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/datasource/catmaid/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2068,6 +2068,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi {
): Promise<CatmaidSplitResult> {
const body = new URLSearchParams({
treenode_id: nodeId.toString(),
downstream_annotation_map: JSON.stringify({}),
});
appendCatmaidState(
body,
Expand Down
13 changes: 12 additions & 1 deletion src/datasource/catmaid/spatial_skeleton_commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1515,6 +1524,7 @@ class NodeDescriptionCommand implements SpatialSkeletonCommand {
nextDescription: string | undefined,
statusPrefix: string,
) {
validateCatmaidNodeDescription(nextDescription);
const { node } = await getResolvedNodeForEdit(
this.layer,
this.stableNodeId,
Expand Down Expand Up @@ -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 {
Expand Down
56 changes: 56 additions & 0 deletions src/display_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<PanelOverlaySource, PanelOverlayTarget>();
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;
Expand Down Expand Up @@ -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<ArrayBuffer> {
Expand Down
17 changes: 17 additions & 0 deletions src/layer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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, {
Expand Down
25 changes: 24 additions & 1 deletion src/layer/segmentation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -1626,13 +1637,19 @@ export class SegmentationUserLayer extends Base {
{
sources2d: slicePanelSources,
selectedNodeInfo: this.selectedSpatialSkeletonNodeInfo,
suppressSelectedNodeHighlight:
this.spatialSkeletonState.suppressSelectedNodeHighlight,
hoveredNodeInfo: this.hoveredSpatialSkeletonNodeInfo,
pendingNodePositionVersion:
this.spatialSkeletonState.pendingNodePositionVersion,
getPendingNodePosition: (nodeId) =>
this.spatialSkeletonState.getPendingNodePosition(nodeId),
getCachedNode: (nodeId) =>
this.spatialSkeletonState.getCachedNode(nodeId),
resolveGlobalPosition: (modelPosition) =>
this.getGlobalSelectionPositionFromModelPosition(
modelPosition,
),
inspectionState: this.spatialSkeletonState,
},
);
Expand Down Expand Up @@ -1660,13 +1677,19 @@ export class SegmentationUserLayer extends Base {
displayState,
{
selectedNodeInfo: this.selectedSpatialSkeletonNodeInfo,
suppressSelectedNodeHighlight:
this.spatialSkeletonState.suppressSelectedNodeHighlight,
hoveredNodeInfo: this.hoveredSpatialSkeletonNodeInfo,
pendingNodePositionVersion:
this.spatialSkeletonState.pendingNodePositionVersion,
getPendingNodePosition: (nodeId) =>
this.spatialSkeletonState.getPendingNodePosition(nodeId),
getCachedNode: (nodeId) =>
this.spatialSkeletonState.getCachedNode(nodeId),
resolveGlobalPosition: (modelPosition) =>
this.getGlobalSelectionPositionFromModelPosition(
modelPosition,
),
inspectionState: this.spatialSkeletonState,
},
);
Expand Down
21 changes: 19 additions & 2 deletions src/layer/segmentation/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ interface SpatialSkeletonViewerHoverMouseStateLike<TRenderLayer> {
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<TRenderLayer> {
Expand Down Expand Up @@ -192,7 +195,17 @@ function getSpatialSkeletonHoverInfoFromViewerHover<TRenderLayer>(
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(
Expand All @@ -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 {
Expand Down
33 changes: 33 additions & 0 deletions src/panel_overlay.css
Original file line number Diff line number Diff line change
@@ -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;
}
Loading