From 094cfdc0f6cb1382c7e527be6e074952609fd2d6 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:42 +0100 Subject: [PATCH 001/251] feat: add new dummy layer type: voxel annotation (vox) --- .gitignore | 2 + package.json | 6 +++ src/layer/enabled_frontend_modules.ts | 1 + src/layer/vox/index.ts | 56 +++++++++++++++++++++++++++ 4 files changed, 65 insertions(+) create mode 100644 src/layer/vox/index.ts diff --git a/.gitignore b/.gitignore index 07243e00f7..4e575e1dff 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,5 @@ tsconfig.tsbuildinfo -/docs/python/api/index.rst /docs/python/api/*.rst /.vite +/.idea +/.local diff --git a/package.json b/package.json index 2c7b4c2cf8..05bbe0a154 100644 --- a/package.json +++ b/package.json @@ -521,6 +521,12 @@ "neuroglancer/layer/single_mesh:disabled": "./src/util/false.ts", "default": "./src/layer/single_mesh/index.ts" }, + "#layer/vox": { + "neuroglancer/layer/vox:enabled": "./src/layer/vox/index.ts", + "neuroglancer/layer:none_by_default": "./src/util/false.ts", + "neuroglancer/layer/vox:disabled": "./src/util/false.ts", + "default": "./src/layer/vox/index.ts" + }, "#main": { "neuroglancer/python": "./src/main_python.ts", "default": "./src/main.ts" diff --git a/src/layer/enabled_frontend_modules.ts b/src/layer/enabled_frontend_modules.ts index d192997999..2f2e03fb84 100644 --- a/src/layer/enabled_frontend_modules.ts +++ b/src/layer/enabled_frontend_modules.ts @@ -3,3 +3,4 @@ import "#layer/annotation"; import "#layer/image"; import "#layer/segmentation"; import "#layer/single_mesh"; +import "#layer/vox"; diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts new file mode 100644 index 0000000000..f8941d7094 --- /dev/null +++ b/src/layer/vox/index.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2025. + * 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 type { ManagedUserLayer } from "#src/layer/index.js"; +import { registerLayerType, UserLayer } from "#src/layer/index.js"; +import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; +import type { Borrowed } from "#src/util/disposable.js"; +import { Tab } from "#src/widget/tab_view.js"; + +class VoxHelloTab extends Tab { + constructor() { + super(); + const { element } = this; + element.classList.add("neuroglancer-vox-hello-tab"); + element.textContent = "Hello world"; + } +} + +export class VoxUserLayer extends UserLayer { + static type = "vox"; + static typeAbbreviation = "vox"; + + constructor(managedLayer: Borrowed) { + super(managedLayer); + this.tabs.add("vox", { + label: "Voxel", + order: 0, + getter: () => new VoxHelloTab(), + }); + this.tabs.default = "vox"; + } + + // For now, this layer does not consume data sources. + activateDataSubsources(_subsources: Iterable): void { + // No-op: voxel annotation UI only (initial stub). + for (const sub of _subsources) { + // Disable all subsources as not compatible (stub layer for now). + sub.deactivate("Not compatible with vox layer (stub)"); + } + } +} + +registerLayerType(VoxUserLayer); From bb1c2563decadabf753ac79148ef96298c4f81f6 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:42 +0100 Subject: [PATCH 002/251] feat: add a new dummy pixel tool --- src/datasource/local.ts | 48 ++++++++++++++++++++++ src/layer/vox/index.ts | 82 +++++++++++++++++++++++++++++++++---- src/sliceview/README.md | 2 +- src/ui/voxel_annotations.ts | 35 ++++++++++++++++ 4 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 src/ui/voxel_annotations.ts diff --git a/src/datasource/local.ts b/src/datasource/local.ts index 4df6dc0443..eae04ff337 100644 --- a/src/datasource/local.ts +++ b/src/datasource/local.ts @@ -31,10 +31,12 @@ import { createIdentity } from "#src/util/matrix.js"; export const localAnnotationsUrl = "local://annotations"; export const localEquivalencesUrl = "local://equivalences"; +export const localVoxelAnnotationsUrl = "local://voxel-annotations"; export enum LocalDataSource { annotations = 0, equivalences = 1, + voxelAnnotations = 2, } export class LocalDataSourceProvider implements DataSourceProvider { @@ -104,6 +106,48 @@ export class LocalDataSourceProvider implements DataSourceProvider { ], }; } + case localVoxelAnnotationsUrl: { + const { transform } = options; + let modelTransform: CoordinateSpaceTransform; + if (transform === undefined) { + const baseSpace = options.globalCoordinateSpace.value; + const { rank, names, scales, units } = baseSpace; + const inputSpace = makeCoordinateSpace({ + rank, + scales, + units, + names: names.map((_, i) => `${i}`), + }); + const outputSpace = makeCoordinateSpace({ + rank, + scales, + units, + names, + }); + modelTransform = { + rank, + sourceRank: rank, + inputSpace, + outputSpace, + transform: createIdentity(Float64Array, rank + 1), + }; + } else { + modelTransform = makeIdentityTransform(emptyValidCoordinateSpace); + } + return { + modelTransform, + canChangeModelSpaceRank: true, + subsources: [ + { + id: "default", + default: true, + subsource: { + local: LocalDataSource.voxelAnnotations, + }, + }, + ], + }; + } } throw new Error("Invalid local data source URL"); } @@ -123,6 +167,10 @@ export class LocalDataSourceProvider implements DataSourceProvider { description: "Segmentation equivalence graph stored in the JSON state", }, + { + value: "voxel-annotations", + description: "Voxel annotations stored in the JSON state", + }, ], (x) => x.value, (x) => x.description, diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index f8941d7094..0f45bdf7e3 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -14,9 +14,14 @@ * limitations under the License. */ +import type { CoordinateTransformSpecification } from "#src/coordinate_transform.js"; +import type { DataSourceSpecification } from "#src/datasource/index.js"; +import { LocalDataSource, localVoxelAnnotationsUrl } from "#src/datasource/local.js"; import type { ManagedUserLayer } from "#src/layer/index.js"; -import { registerLayerType, UserLayer } from "#src/layer/index.js"; +import { registerLayerType, registerLayerTypeDetector, UserLayer } from "#src/layer/index.js"; import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; +import { makeToolButton } from "#src/ui/tool.js"; +import { PIXEL_TOOL_ID, VoxelPixelLegacyTool, registerVoxelAnnotationTools } from "#src/ui/voxel_annotations.js"; import type { Borrowed } from "#src/util/disposable.js"; import { Tab } from "#src/widget/tab_view.js"; @@ -29,6 +34,25 @@ class VoxHelloTab extends Tab { } } +class VoxToolTab extends Tab { + constructor(public layer: VoxUserLayer) { + super(); + const { element } = this; + element.classList.add("neuroglancer-vox-tools-tab"); + const toolbox = document.createElement("div"); + toolbox.className = "neuroglancer-vox-toolbox"; + const legacyButton = document.createElement("button"); + legacyButton.textContent = "Pixel (annotate)"; + legacyButton.title = "Select legacy pixel tool for ctrl+click annotate"; + legacyButton.addEventListener("click", () => { + this.layer.tool.value = new VoxelPixelLegacyTool(this.layer); + }); + toolbox.appendChild(legacyButton); + element.appendChild(toolbox); + } +} + + export class VoxUserLayer extends UserLayer { static type = "vox"; static typeAbbreviation = "vox"; @@ -40,17 +64,61 @@ export class VoxUserLayer extends UserLayer { order: 0, getter: () => new VoxHelloTab(), }); + this.tabs.add("vox_tools", { + label: "Draw", + order: 1, + getter: () => new VoxToolTab(this), + }); this.tabs.default = "vox"; } - // For now, this layer does not consume data sources. - activateDataSubsources(_subsources: Iterable): void { - // No-op: voxel annotation UI only (initial stub). - for (const sub of _subsources) { - // Disable all subsources as not compatible (stub layer for now). - sub.deactivate("Not compatible with vox layer (stub)"); + getLegacyDataSourceSpecifications( + sourceSpec: string | undefined, + layerSpec: any, + legacyTransform: CoordinateTransformSpecification | undefined, + explicitSpecs: DataSourceSpecification[], + ): DataSourceSpecification[] { + if (Object.prototype.hasOwnProperty.call(layerSpec, "source")) { + // Respect explicit source definitions. + return super.getLegacyDataSourceSpecifications( + sourceSpec, + layerSpec, + legacyTransform, + explicitSpecs, + ); + } + // Default to the special local voxel annotations data source. + return [ + { + url: localVoxelAnnotationsUrl, + transform: legacyTransform, + enableDefaultSubsources: true, + subsources: new Map(), + }, + ]; + } + + activateDataSubsources(subsources: Iterable): void { + for (const loadedSubsource of subsources) { + const { subsourceEntry } = loadedSubsource; + const { subsource } = subsourceEntry; + if (subsource.local === LocalDataSource.voxelAnnotations) { + // Accept this data source; no render layers yet. + loadedSubsource.activate(() => {}); + continue; + } + loadedSubsource.deactivate( + "Not compatible with vox layer; only local://voxel-annotations is supported", + ); } } } +registerVoxelAnnotationTools(); registerLayerType(VoxUserLayer); +registerLayerTypeDetector((subsource) => { + if (subsource.local === LocalDataSource.voxelAnnotations) { + return { layerConstructor: VoxUserLayer, priority: 100 }; + } + return undefined; +}); diff --git a/src/sliceview/README.md b/src/sliceview/README.md index 8851e64279..8939962e17 100644 --- a/src/sliceview/README.md +++ b/src/sliceview/README.md @@ -2,7 +2,7 @@ This directory contains the code for `SliceView`, which provides the cross-secti # Architecture -A volume is divided into a regular grid of 3-d chunks. Each chunk has voxel dimensions `chunkDataSize` (a 3-d vector of positive integers). All chunks have the same dimensions, except at the the upper bound of the volume in each dimension, where the chunks are allowed to be truncated to fit within the volume dimensions. +A volume is divided into a regular grid of 3-d chunks. Each chunk has voxel dimensions `chunkDataSize` (a 3-d vector of positive integers). All chunks have the same dimensions, except at the upper bound of the volume in each dimension, where the chunks are allowed to be truncated to fit within the volume dimensions. Chunks are the unit at which portions of the volume are queued, retrieved, transcoded (if necessary), copied to the GPU, and rendered: diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts new file mode 100644 index 0000000000..bc88d855fb --- /dev/null +++ b/src/ui/voxel_annotations.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2025. + * 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 type { VoxUserLayer } from "#src/layer/vox/index.js"; +import { LegacyTool, registerLegacyTool } from "#src/ui/tool.js"; + +export const PIXEL_TOOL_ID = "voxPixel"; + +export class VoxelPixelLegacyTool extends LegacyTool { + description = "pixel"; + toJSON() { + return PIXEL_TOOL_ID; + } + trigger(_mouseState: any) { + // eslint-disable-next-line no-console + console.log("|hello world|"); + } +} + +export function registerVoxelAnnotationTools() { + registerLegacyTool(PIXEL_TOOL_ID, (layer) => new VoxelPixelLegacyTool(layer)); +} From 69b1e22a6a0f585e36a39bd541d28384ead76ab7 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:42 +0100 Subject: [PATCH 003/251] feat: retreive mouse position and current LOD scale --- src/layer/vox/index.ts | 7 +-- src/ui/voxel_annotations.ts | 116 +++++++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 7 deletions(-) diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 0f45bdf7e3..2dd0008d31 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -20,8 +20,7 @@ import { LocalDataSource, localVoxelAnnotationsUrl } from "#src/datasource/local import type { ManagedUserLayer } from "#src/layer/index.js"; import { registerLayerType, registerLayerTypeDetector, UserLayer } from "#src/layer/index.js"; import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; -import { makeToolButton } from "#src/ui/tool.js"; -import { PIXEL_TOOL_ID, VoxelPixelLegacyTool, registerVoxelAnnotationTools } from "#src/ui/voxel_annotations.js"; +import { VoxelPixelLegacyTool, registerVoxelAnnotationTools } from "#src/ui/voxel_annotations.js"; import type { Borrowed } from "#src/util/disposable.js"; import { Tab } from "#src/widget/tab_view.js"; @@ -42,8 +41,8 @@ class VoxToolTab extends Tab { const toolbox = document.createElement("div"); toolbox.className = "neuroglancer-vox-toolbox"; const legacyButton = document.createElement("button"); - legacyButton.textContent = "Pixel (annotate)"; - legacyButton.title = "Select legacy pixel tool for ctrl+click annotate"; + legacyButton.textContent = "Pixel"; + legacyButton.title = "ctrl+click to paint a pixel"; legacyButton.addEventListener("click", () => { this.layer.tool.value = new VoxelPixelLegacyTool(this.layer); }); diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index bc88d855fb..6b6c84735e 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -14,8 +14,11 @@ * limitations under the License. */ +import type { MouseSelectionState } from "#src/layer/index.js"; import type { VoxUserLayer } from "#src/layer/vox/index.js"; +import { RenderedDataPanel } from "#src/rendered_data_panel.js"; import { LegacyTool, registerLegacyTool } from "#src/ui/tool.js"; +import { formatScaleWithUnitAsString } from "#src/util/si_units.js"; export const PIXEL_TOOL_ID = "voxPixel"; @@ -24,9 +27,116 @@ export class VoxelPixelLegacyTool extends LegacyTool { toJSON() { return PIXEL_TOOL_ID; } - trigger(_mouseState: any) { - // eslint-disable-next-line no-console - console.log("|hello world|"); + trigger(mouseState: MouseSelectionState) { + try { + const layer = this.layer; + const display = layer.manager.root.display; + const panels = display.panels; + // Compute mouse position relative to canvas + const pageX = mouseState?.pageX ?? 0; + const pageY = mouseState?.pageY ?? 0; + const rect = display.canvasRect ?? display.canvas.getBoundingClientRect(); + const canvasX = pageX - rect.left; + const canvasY = pageY - rect.top; + + // Find the RenderedDataPanel under the mouse + let chosenPanel: RenderedDataPanel | undefined; + for (const panel of panels) { + if (!(panel instanceof RenderedDataPanel)) continue; + const left = panel.canvasRelativeClippedLeft; + const top = panel.canvasRelativeClippedTop; + const right = left + panel.renderViewport.width; + const bottom = top + panel.renderViewport.height; + if (canvasX >= left && canvasX < right && canvasY >= top && canvasY < bottom) { + chosenPanel = panel; + break; + } + } + if (!chosenPanel) { + // Fallback: pick the first RenderedDataPanel if any + for (const p of panels) { + if (p instanceof RenderedDataPanel) { + chosenPanel = p; + break; + } + } + } + + // Mouse voxel position string + let mousePosStr = "unknown"; + const cs = mouseState?.coordinateSpace; + const pos = mouseState?.position; + if (mouseState?.active && cs && pos) { + const { rank, names } = cs; + const parts: string[] = []; + for (let i = 0; i < rank; ++i) { + parts.push(`${names[i]} ${Math.floor(pos[i])}`); + } + mousePosStr = parts.join(" "); + } + + // Zoom and viewport scale + let zoomStr = "n/a"; + const imageScaleParts: string[] = []; + if (chosenPanel) { + const nav = (chosenPanel as any).navigationState; + const zoom = nav?.zoomFactor?.value; + if (typeof zoom === "number" && !Number.isNaN(zoom)) { + zoomStr = String(zoom); + } + const info = nav?.displayDimensionRenderInfo?.value; + if (info) { + const { + displayDimensionIndices, + displayDimensionUnits, + globalDimensionNames, + } = info; + + // Try to compute per-image-pixel sizes (current LOD texel size) from the SliceView. + const panelAny = chosenPanel as any; + const sliceView = panelAny?.sliceView; + const minImagePixelSize = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY]; + if (sliceView?.visibleLayers instanceof Map) { + for (const layerInfo of sliceView.visibleLayers.values()) { + const visibleSources = layerInfo?.visibleSources as any[] | undefined; + if (!Array.isArray(visibleSources)) continue; + for (const tsource of visibleSources) { + const evs: Float32Array | number[] | undefined = (tsource as any)?.effectiveVoxelSize; + if (!evs) continue; + for (let i = 0; i < 3; ++i) { + const v = evs[i]; + if (typeof v === "number" && v > 0) { + if (v < minImagePixelSize[i]) minImagePixelSize[i] = v; + } + } + } + } + } + + for (let i = 0; i < 3; ++i) { + const dim = displayDimensionIndices[i]; + if (dim === -1) continue; + const pxSize = minImagePixelSize[i]; + if (Number.isFinite(pxSize)) { + const formatted = formatScaleWithUnitAsString( + pxSize, + displayDimensionUnits[i], + { precision: 2, elide1: false }, + ); + imageScaleParts.push(`${globalDimensionNames[dim]} ${formatted}/imgPx`); + } + } + + // Fallback: if we couldn't determine image pixel sizes, skip logging them. + } + } + + console.log( + `Mouse: ${mousePosStr} | Zoom: ${zoomStr} | Viewport scale: ${imageScaleParts.join(", ")}`, + ); + } catch (e) { + console.log("[VoxelPixelLegacyTool] Error computing info:", e); + } } } From 488c1841e4a70bbd5e7c176d9efa6907082504ee Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:42 +0100 Subject: [PATCH 004/251] feat: add support for voxel annotation rendering and specification - Introduced a new dummy `MultiscaleVolumeChunkSource`. - Added `VoxelAnnotationRenderLayer` for voxel annotation rendering. - Implemented `VoxUserLayer` with dummy data source and rendering. - Added tools and logs for voxel layer interactions and debugging. - Documented voxel annotation specification and implementation details. --- .gitignore | 1 + .../MultiscaleVolumeChunkSource.md | 156 ++++++++++++++++++ NOTES/custom-gc.md | 44 +++++ NOTES/image-vs-segmentation-volumeType.md | 78 +++++++++ NOTES/voxel-annotation-specification.md | 31 ++++ src/layer/vox/index.ts | 46 +++++- src/sliceview/frontend.ts | 8 + src/sliceview/volume/renderlayer.ts | 7 + src/ui/voxel_annotations.ts | 4 +- .../dummy_volume_chunk_source.ts | 81 +++++++++ src/voxel_annotation/renderlayer.ts | 74 +++++++++ 11 files changed, 528 insertions(+), 2 deletions(-) create mode 100644 NOTES/classExplanations/MultiscaleVolumeChunkSource.md create mode 100644 NOTES/custom-gc.md create mode 100644 NOTES/image-vs-segmentation-volumeType.md create mode 100644 NOTES/voxel-annotation-specification.md create mode 100644 src/voxel_annotation/dummy_volume_chunk_source.ts create mode 100644 src/voxel_annotation/renderlayer.ts diff --git a/.gitignore b/.gitignore index 4e575e1dff..d83cb0a983 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ tsconfig.tsbuildinfo /.vite /.idea /.local +/.env diff --git a/NOTES/classExplanations/MultiscaleVolumeChunkSource.md b/NOTES/classExplanations/MultiscaleVolumeChunkSource.md new file mode 100644 index 0000000000..0ab8cabfd3 --- /dev/null +++ b/NOTES/classExplanations/MultiscaleVolumeChunkSource.md @@ -0,0 +1,156 @@ +### What MultiscaleVolumeChunkSource is and why it exists +MultiscaleVolumeChunkSource is the frontend abstraction for volumetric data in Neuroglancer that can be viewed at multiple resolutions and/or orientations. It doesn’t load or store voxels itself; instead it: +- Defines the set of per-scale, per-orientation chunk sources that the renderer can query. +- Encodes the coordinate transforms needed to map each chunk space into the layer’s “multiscale” space. +- Supplies metadata such as rank, data type, and volume type (image vs segmentation) to drive shader code paths and default compression decisions. + +Concretely, the type is defined in src/sliceview/volume/frontend.ts: +- MultiscaleVolumeChunkSource extends the generic MultiscaleSliceViewChunkSource with Source = VolumeChunkSource and Options = VolumeSourceOptions. You must implement: + - rank: number — typically 3 for a 3D volume (or 4 if you include channels as a dimension in chunking). + - dataType: DataType — e.g., UINT8, UINT16, FLOAT32, UINT32/UINT64 (segmentation). + - volumeType: VolumeType — IMAGE or SEGMENTATION (affects shader behavior and default compression rules downstream). + - getSources(options: VolumeSourceOptions): SliceViewSingleResolutionSource[][] — returns a 2D array: [orientation][scale]. Each element supplies: + - chunkSource: VolumeChunkSource — the per-scale chunk producer/holder. + - chunkToMultiscaleTransform: mat (rank+1 x rank+1) mapping chunk voxel coordinates into the multiscale space for this source. + - lowerClipBound/upperClipBound (optional) — clip region in chunk voxel space. + +How the renderer uses it (high level): +- SliceView requests transformed sources via getVolumetricTransformedSources (src/sliceview/frontend.ts). That function: + 1) Calls your getSources with the view’s transforms and channel mapping. + 2) Computes, for each source, the transforms between chunk space, multiscale space, and the 2D view, plus an effective voxel size at that scale. + 3) Chooses which scale(s) to render given current zoom, pixel size, and RenderLayer settings. + 4) Enumerates visible chunks for those sources and asks the ChunkManager to fetch them. + +Where the actual voxel bytes come from: +- VolumeChunkSource (also in src/sliceview/volume/frontend.ts) is the frontend pair to your selected spec (VolumeChunkSpecification). It defines chunk layout/format and provides getValueAt for picking. +- The frontend VolumeChunkSource depends on a backend chunk source implementation (in workers) to fill chunk data on demand. Without a backend, no data arrives; rendering either shows nothing or can still draw “proced“procedural” effects that don’t sample the chunk textures. + ural” effects that don’t sample the chunk textures. + +Helpful related APIs: +- makeVolumeChunkSpecification in src/sliceview/volume/base.ts builds the spec (rank, bounds, chunk size, data type, etc.). +- makeVolumeChunkSpecificationWithDefaultCompression can choose compressed segmentation blocks for segmentation data. +- SliceViewVolumeRenderLayer in src/sliceview/volume/renderlayer.ts is the default renderer that consumes your MultiscaleVolumeChunkSource and handles WebGL setup, transforms, chunk iteration, and shader integration. + + +### How to use MultiscaleVolumeChunkSource +Typical usage pattern when building a layer: +1) Construct a subclass instance and pass it to a SliceViewVolumeRenderLayer (or your own subclass of it), e.g.: + - const multiscale = new MyMultiscaleSource(chunkManager); + - const renderLayer = new SliceViewVolumeRenderLayer(multiscale, { ... }); +2) The layer uses your getSources to choose appropriate scales and request chunks through the ChunkManager. +3) A backend implementation for VolumeChunkSource provides the voxel bytes when requested. + + +### How to extend it (implement your own) +To implement a custom multiscale volume: +- Extend MultiscaleVolumeChunkSource. +- Define rank, dataType, and volumeType. +- Implement getSources(options). For each scale/orientation you want to expose: + 1) Create a VolumeChunkSpecification via makeVolumeChunkSpecification (or the default-compression variant for segmentation). You must provide at least: + - rank + - chunkDataSize (Uint32Array length = rank) + - lowerVoxelBound (defaults to zeros if not given) + - upperVoxelBound (required) + - dataType + 2) Obtain a frontend VolumeChunkSource from the ChunkManager: + - const source = chunkManager.getChunkSource(VolumeChunkSource, { spec }) + 3) Provide chunkToMultiscaleTransform (Float32Array of size (rank+1)^2). This defines the voxel size/axis orientation and any downsampling between the chunk’s voxel grid and your multiscale space. + 4) Optionally specify lowerClipBound/upperClipBound to restrict rendering. + 5) Push a SliceViewSingleResolutionSource { chunkSource, chunkToMultiscaleTransform, ... } into the returned arrays. The outer array indexes orientations; the inner array indexes scales from fine to coarse (or vice-versa; the utility code reorders as needed, but keep a consistent order, typically coarse-to-fine or fine-to-coarse). The filterVisibleSources logic in src/sliceview/base.ts picks suitable scales given zoom. + +Multiple scales example sketch: +- For a three-scale pyramid, you might set chunkToMultiscaleTransform with voxel sizes [1,1,1], [2,2,2], [4,4,4] (or encode that into the matrix). Each scale also can have different chunkDataSize to better match the level’s voxel size. + +Backends: +- For real data, implement a corresponding backend chunk source (worker) that understands your source’s spec key and returns bytes. Most datasources under src/datasource/* demonstrate this by subclassing GenericMultiscaleVolumeChunkSource or MultiscaleVolumeChunkSource and providing a backend counterpart. + + +### Review of your DummyMultiscaleVolumeChunkSource +File: src/voxel_annotation/dummy_volume_chunk_source.ts + +What it sets up: +- Extends MultiscaleVolumeChunkSource with: + - dataType = DataType.UINT32 + - volumeType = VolumeType.SEGMENTATION + - rank = 3 +- getSources returns a single orientation with a single scale: + - chunkDataSize = [64, 64, 64] + - upperVoxelBound = [1000, 1000, 1000] + - lowerVoxelBound defaults to [0, 0, 0] via makeSliceViewChunkSpecification + - spec = makeVolumeChunkSpecification({ rank, dataType, chunkDataSize, upperVoxelBound }) + - chunkSource = chunkManager.getChunkSource(VolumeChunkSource, { spec }) + - chunkToMultiscaleTransform = identity (no scaling, no rotation, 1 voxel unit per multiscale unit) + - lowerClipBound = spec.lowerVoxelBound; upperClipBound = spec.upperVoxelBound + - returns [[single]] + +What this means in practice: +- Geometry and bounds: + - Your multiscale space is a simple axis-aligned 1000x1000x1000 volume with voxel size implicitly equal to 1 in all axes (identity transform). Chunks are 64^3. +- Data type and volume type: + - You chose segmentation semantics (VolumeType.SEGMENTATION) with UINT32 values. This is coherent; many segmentations are UINT32. It will influence shader behavior in the stock SliceViewVolumeRenderLayer (e.g., how interpolation and histogram calculations are treated). +- Multiscale levels: + - Only a single scale is provided. The viewer won’t be able to switch to a coarser level as you zoom out. For testing this is fine; for large volumes, consider adding multiple scales. +- Backend data: + - The frontend VolumeChunkSource expects the backend to provide chunk bytes. As written, there is no backend companion to actually fill data. Your VoxelAnnotationRenderLayer’s shader currently emits a procedural checkerboard using vChunkPosition and uChunkDataSize, which can render without sampling voxel textures — that’s why this can still “show something” even without real data. However, if you later want to read voxel values in the shader (e.g., segmentation ID), you’ll need a backend chunk provider. + +Correctness/consistency observations: +- Using makeVolumeChunkSpecification with minimal fields is valid; lowerVoxelBound defaults correctly. +- The identity chunkToMultiscaleTransform is valid; it means multiscale coordinates and chunk voxel coordinates coincide. If your layer’s model/render transforms assume a different physical voxel size (e.g., anisotropic data), you should encode that scale into this matrix. +- VolumeType.SEGMENTATION + UINT32 can optionally benefit from compressed segmentation block sizes, but that is set on the spec via makeVolumeChunkSpecificationWithDefaultCompression (and requires chunkToMultiscaleTransform and options.multiscaleToViewTransform). For a dummy source, skipping compression is fine. +- The return shape [[single]] is correct: outer index is orientation (only one), inner is scale (only one). + +Suggestions to evolve DummyMultiscaleVolumeChunkSource: +- Multiple scales: Create a list of specs for different resolutions. For each coarser level: + - Either encode a larger voxel size into chunkToMultiscaleTransform (e.g., 2x, 4x) and keep a similar chunkDataSize, or keep voxel size = 1 and adjust transforms so that coarser scales map appropriately into multiscale space. + - Return [[level0, level1, level2]] ordered from fine to coarse (or vice-versa consistently). +- Anisotropic voxels: If your data units are not isotropic, build chunkToMultiscaleTransform with per-axis scales (e.g., diag([sx, sy, sz, 1])). +- Clip bounds: You can tighten lowerClipBound/upperClipBound (floats allowed) to define a visible subregion without changing retrieval bounds. +- Backend stub: For development, add a backend VolumeChunkSource that fills chunks procedurally (e.g., write a pattern or ID = x+y+z) so you can test sampling in shaders and getValueAt. + + +### Quick look at your VoxelAnnotationRenderLayer (to see integration) +File: src/voxel_annotation/renderlayer.ts +- Extends SliceViewVolumeRenderLayer and overrides defineShader to render a 2D checkerboard using vChunkPosition.xy and uChunkDataSize.xy, without sampling volume data. This is consistent with your dummy source and is why you can render even without actual chunk bytes. +- initializeShader is a no-op (fine for now). The base class takes care of binding uniforms like uChunkDataSize, uLowerClipBound, uUpperClipBound, etc. + +If/when you want to use real voxel values in the shader, you’ll need to: +- Let defineChunkDataShaderAccess (already wired by the base class) provide sampling functions and texture bindings. +- Ensure your backend supplies chunk data with the right format for the selected DataType. + + +### Minimal template for a multiscale source you can extend +- class MyMultiscaleSource extends MultiscaleVolumeChunkSource { + - dataType = DataType.UINT32; + - volumeType = VolumeType.SEGMENTATION; + - get rank() { return 3; } + - constructor(cm) { super(cm); } + - getSources(options) { + - const rank = this.rank; + - const upperVoxelBound = new Float32Array([X, Y, Z]); + - const scales = [1, 2, 4]; // voxel size multipliers + - const sources = scales.map(s => { + - const spec = makeVolumeChunkSpecification({ + rank, + dataType: this.dataType, + chunkDataSize: new Uint32Array([64,64,64]), + upperVoxelBound, + }); + - const chunkSource = this.chunkManager.getChunkSource(VolumeChunkSource, { spec }); + - const xform = new Float32Array((rank+1)*(rank+1)); + // set identity and scale diagonal by s + - for (let i=0;i void | Disposable) to collect cleanup actions. + - invokeDisposers in reverse order for safe teardown. + - registerEventListener(target, type, listener, options) that returns an unregister function (and RefCounted.registerEventListener wraps it so it’s auto-removed on dispose). + - registerCancellable(cancellable) to call cancel() during dispose. + - disposableOnce(...) to guard one-time cleanup. +- Owned / Borrowed type aliases to express ownership semantics in function signatures (convention: Owned donates a reference; Borrowed does not increase refCount). +- Debug aids (DEBUG_REF_COUNTS, disposedStacks) for leak/early-dispose diagnosis. diff --git a/NOTES/image-vs-segmentation-volumeType.md b/NOTES/image-vs-segmentation-volumeType.md new file mode 100644 index 0000000000..0505af2d84 --- /dev/null +++ b/NOTES/image-vs-segmentation-volumeType.md @@ -0,0 +1,78 @@ +### High-level difference +- IMAGE: Continuous-valued voxels (intensities). Intended for interpolation, contrast/brightness adjustments, and colormap visualization. +- SEGMENTATION: Discrete/categorical labels (segment IDs). Must not be interpolated; visualized by mapping IDs to colors, supporting selection/highlighting of segments. + +### Semantics and typical data types +- IMAGE + - Common types: UINT8, UINT16, FLOAT32 (sometimes INT16, etc.). + - Often multi-channel (RGB, multi-stain, etc.). +- SEGMENTATION + - Common types: UINT32, UINT64 (single-channel ID field). + - Values represent object IDs; exact integrity of values matters. + +### Sampling and interpolation +- IMAGE + - Linear interpolation for smooth zooming and slicing. + - Pyramids/scales typically produced via averaging or linear filters. +- SEGMENTATION + - Nearest-neighbor sampling (no linear interpolation) to avoid fractional/invalid IDs. + - Pyramids/scales should be built with label-aware reducers (e.g., majority vote), not averaging. + +### Rendering and shader behavior +- IMAGE + - Intensity pipelines: window/level, colormaps, per-channel blending, histograms. + - Smooth transitions; edges may be anti-aliased by interpolation. +- SEGMENTATION + - ID-to-color mapping (hash/lookup) with crisp, non-interpolated boundaries. + - UI and shaders support features like selected/visible segments, recoloring, and highlighting. + +### Compression and storage defaults +- IMAGE + - Uses standard chunk formats; compression (if any) is typically external/transport-level. +- SEGMENTATION + - Eligible for compressed segmentation formats (blockwise) when rank/type conditions match (e.g., 3D, UINT32/UINT64). This reduces bandwidth and memory for uniform regions. + - In Neuroglancer’s code, makeVolumeChunkSpecificationWithDefaultCompression enables compressedSegmentationBlockSize when volumeType is SEGMENTATION (or discreteValues is true) and other criteria are met. + +### Picking and interaction +- IMAGE + - Picking returns intensities (possibly per-channel). Useful for measurements/QA. +- SEGMENTATION + - Picking returns a segment ID. The UI typically supports selecting, showing/hiding segments, equivalence mapping, and integration with meshes/skeletons for that ID. + +### Histograms and UI controls +- IMAGE + - Histogram-based contrast controls, colormap selection, per-channel adjustments. +- SEGMENTATION + - No meaningful intensity histogram. UI focuses on segment sets, visibility, and highlighting. + +### Multiscale generation expectations +- IMAGE: Averaging/linear filtering for downsampling. +- SEGMENTATION: Mode/majority voting or other label-preserving downsampling. + +### Channel semantics +- IMAGE: Multi-channel common; RGB or arbitrary channel mixing. +- SEGMENTATION: Typically single-channel ID. Multiple channels would imply multiple label volumes and need custom handling. + +### Choosing between IMAGE and SEGMENTATION +Pick SEGMENTATION if: +- Voxels encode labels/IDs that must be exact (no interpolation). +- You need segment selection/highlighting and ID-centric tooling. +- You want segmentation block compression benefits. + +Pick IMAGE if: +- Voxels are continuous intensities. +- You want linear interpolation, window/level, and colormaps. +- You handle multi-channel blending or RGB imagery. + +### Practical impact in this codebase +- VolumeType is defined in src/sliceview/volume/base.ts and used by multiscale and render paths to pick defaults. +- Compression choice: shouldTranscodeToCompressedSegmentation and makeVolumeChunkSpecificationWithDefaultCompression check VolumeType and DataType to set compressedSegmentationBlockSize for segmentation. +- Render paths for sampling, decoding, and shader helpers differ for segmentation vs image (e.g., nearest sampling and optional decompression for segmentation). + +### Notes for your DummyMultiscaleVolumeChunkSource +- You set volumeType = SEGMENTATION and dataType = UINT32, which is appropriate for label volumes. +- Your shader currently draws a procedural checkerboard and doesn’t sample voxel data; it won’t yet exercise segmentation decoding or nearest sampling. If you later sample voxel values to color by ID or enable segment picking, the SEGMENTATION setting will align with the right defaults and UI behavior. + +### Summary +- IMAGE = continuous intensities, linear interpolation, histogram/colormap UI, typical UINT8/16/F32, averaged pyramids. +- SEGMENTATION = discrete labels/IDs, nearest sampling, segment-centric UI, typical UINT32/64, label-preserving pyramids, compressed segmentation support. diff --git a/NOTES/voxel-annotation-specification.md b/NOTES/voxel-annotation-specification.md new file mode 100644 index 0000000000..52cdc9c581 --- /dev/null +++ b/NOTES/voxel-annotation-specification.md @@ -0,0 +1,31 @@ +# Voxel Annotation Specification + +## Overview + +The objective of the voxel annotation is to allow precise labeling, synchronized with the underlying image data (same scale...), for deep learning training and validation. The already present annotation system in Neuroglancer is not well suited for this task since it is not voxel-precise, does not implement fast and ergonomic drawing tools and no export to standard formats. The choice could have been made to extend the existing annotation system, but it being based on vector graphics, it would have been a major overhaul. Instead, a new voxel annotation system is being developed in parallel. + +## Tools + +Giving the user modern drawing tools is one of the key requirements of the voxel annotation system. The following tools are planned: +- Brush (circular, adjustable size) +- Flood fill +- Eraser (circular, adjustable size) + +For the MVP, a more simple Pixel tool (1 voxel at a time) is sufficient to validate the concept. + +## LOD, scaling and performance + +Similarely to the rendering of image or segmentation data, the voxel annotation data should be rendered at the appropriate LOD depending on the zoom level. But with voxel annotations we do not have access to the pre-calculated mipmaps, those should be computed on the fly, this present a real performance challenge. This point is still under investigation, for the MVP we will avoid the issue by rendering voxel annotations only at their drawn resolution (no LOD), and hide them when zoomed out/in. + +## Data storage + +After investigation of the storage of the current annotation system of Neuroglancer, altho not plugable to our new system, its implementation still seems well design for our needs. We will probably inspire ourself to write the storage part of the voxel annotation system. The key feature rely in the asynchronous saving from the front to the back, this allow for fluid user experience (not waiting for the save to complete before being able to continue drawing). The data will be stored in a local data source (local://voxel-annotations) as a 3D array of uint32, with 0 meaning no annotation, and values 1..n meaning different labels. The data will be chunked in 64x64x64 blocks. + +## Implementation plan + +A new layer type should be created for voxel annotations (abv: 'vox'): +- class name: VoxUserLayer extending UserLayer +- file: layer/vox/index.ts + +A local data source (local://voxel-annotations) should be created to store the voxel annotations + diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 2dd0008d31..cdd338b791 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -15,13 +15,18 @@ */ import type { CoordinateTransformSpecification } from "#src/coordinate_transform.js"; +import { makeCoordinateSpace, makeIdentityTransform, WatchableCoordinateSpaceTransform } from "#src/coordinate_transform.js"; import type { DataSourceSpecification } from "#src/datasource/index.js"; import { LocalDataSource, localVoxelAnnotationsUrl } from "#src/datasource/local.js"; import type { ManagedUserLayer } from "#src/layer/index.js"; import { registerLayerType, registerLayerTypeDetector, UserLayer } from "#src/layer/index.js"; import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; +import { getWatchableRenderLayerTransform } from "#src/render_coordinate_transform.js"; +import { RenderScaleHistogram, trackableRenderScaleTarget } from "#src/render_scale_statistics.js"; import { VoxelPixelLegacyTool, registerVoxelAnnotationTools } from "#src/ui/voxel_annotations.js"; import type { Borrowed } from "#src/util/disposable.js"; +import { DummyMultiscaleVolumeChunkSource } from "#src/voxel_annotation/dummy_volume_chunk_source.js"; +import { VoxelAnnotationRenderLayer } from "#src/voxel_annotation/renderlayer.js"; import { Tab } from "#src/widget/tab_view.js"; class VoxHelloTab extends Tab { @@ -53,6 +58,9 @@ class VoxToolTab extends Tab { export class VoxUserLayer extends UserLayer { + // Match Image/Segmentation layers: provide a per-layer cross-section render scale target/histogram. + sliceViewRenderScaleHistogram = new RenderScaleHistogram(); + sliceViewRenderScaleTarget = trackableRenderScaleTarget(1); static type = "vox"; static typeAbbreviation = "vox"; @@ -103,7 +111,43 @@ export class VoxUserLayer extends UserLayer { const { subsource } = subsourceEntry; if (subsource.local === LocalDataSource.voxelAnnotations) { // Accept this data source; no render layers yet. - loadedSubsource.activate(() => {}); + loadedSubsource.activate(() => { + console.log('Activating voxel annotation data subsource.'); + const dummySource = new DummyMultiscaleVolumeChunkSource( + this.manager.chunkManager, + ); + this.addRenderLayer( + new VoxelAnnotationRenderLayer( + dummySource, + { + // Provide a synthetic 3D identity transform to match our 3D dummy volume. + // The default transform from local://voxel-annotations has rank 0, which results in + // no sources becoming visible. We bind an identity 3D model transform to the viewer/global + // and layer/local spaces so SliceView can select sources correctly. + transform: getWatchableRenderLayerTransform( + this.manager.root.coordinateSpace, + this.localPosition.coordinateSpace, + new WatchableCoordinateSpaceTransform( + makeIdentityTransform( + // Construct a generic 3D model space. Names/units are placeholders but sufficient. + makeCoordinateSpace({ + rank: 3, + names: ["x", "y", "z"], + units: ["", "", ""], + scales: new Float64Array([1, 1, 1]), + }), + ), + ), + undefined, + undefined, + ), + renderScaleTarget: this.sliceViewRenderScaleTarget, + renderScaleHistogram: undefined, + localPosition: this.localPosition, + } as any, + ), + ); + }); continue; } loadedSubsource.deactivate( diff --git a/src/sliceview/frontend.ts b/src/sliceview/frontend.ts index b87475efb6..2fc5ed678c 100644 --- a/src/sliceview/frontend.ts +++ b/src/sliceview/frontend.ts @@ -434,6 +434,10 @@ export class SliceView extends Base { lastSeenGeneration: curUpdateGeneration, displayDimensionRenderInfo, }; + if ((renderLayer as any).constructor?.type === 'vox') { + console.log('[SliceView.updateVisibleLayersNow] new vox layerInfo created, allSources orientations=', layerInfo.allSources.length, + 'first orientation scales=', layerInfo.allSources[0]?.length ?? 0); + } disposers.push(renderLayer.messages.addChild(layerInfo.messages)); visibleLayers.set(renderLayer.addRef(), layerInfo); this.bindVisibleRenderLayer(renderLayer, disposers); @@ -451,6 +455,10 @@ export class SliceView extends Base { renderLayer, layerInfo.messages, ); + if ((renderLayer as any).constructor?.type === 'vox') { + console.log('[SliceView.updateVisibleLayersNow] vox layer transform changed, new allSources orientations=', layerInfo.allSources.length, + 'first orientation scales=', layerInfo.allSources[0]?.length ?? 0); + } disposeTransformedSources(renderLayer, allSources); layerInfo.visibleSources.length = 0; layerInfo.displayDimensionRenderInfo = displayDimensionRenderInfo; diff --git a/src/sliceview/volume/renderlayer.ts b/src/sliceview/volume/renderlayer.ts index e189483934..8152ec3e68 100644 --- a/src/sliceview/volume/renderlayer.ts +++ b/src/sliceview/volume/renderlayer.ts @@ -348,6 +348,7 @@ export abstract class SliceViewVolumeRenderLayer< multiscaleSource: MultiscaleVolumeChunkSource, options: RenderLayerOptions, ) { + console.log("SliceViewVolumeRenderLayer constructor called with options DEBUG 1"); const { shaderError = makeWatchableShaderError(), shaderParameters } = options; super(multiscaleSource.chunkManager, multiscaleSource, options); @@ -380,6 +381,7 @@ export abstract class SliceViewVolumeRenderLayer< ], ), ); + console.log("calling parameterizedContextDependentShaderGetter") this.shaderGetter = parameterizedContextDependentShaderGetter(this, gl, { memoizeKey: `volume/RenderLayer:${getObjectId(this.constructor)}`, fallbackParameters: options.fallbackShaderParameters, @@ -396,6 +398,7 @@ export abstract class SliceViewVolumeRenderLayer< parameters: ShaderParameters, extraParameters: ShaderContext, ) => { + console.log("defining shader with parameters DEBUG 3"); const { chunkFormat, dataHistogramsEnabled } = context; const { dataHistogramChannelSpecifications, numChannelDimensions } = extraParameters; @@ -407,6 +410,7 @@ void emit(vec4 color) { } `); if (chunkFormat === null) { + console.log("no chunk format"); return; } defineChunkDataShaderAccess( @@ -453,6 +457,7 @@ void main() { } #define main userMain\n`); } + console.log("defining shader with parameters DEBUG 2", parameters); this.defineShader(builder, parameters); }, getContextKey: (context) => @@ -460,6 +465,7 @@ void main() { }); this.tempChunkPosition = new Float32Array(multiscaleSource.rank); this.initializeCounterpart(); + console.log("constructor done"); } get dataType() { @@ -591,6 +597,7 @@ void main() { this.endSlice(sliceView, shader, shaderResult.parameters); }; let newSource = true; + console.log("number of visible sources:", visibleSources.length); for (const transformedSource of visibleSources) { const chunkLayout = getNormalizedChunkLayout( projectionParameters, diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 6b6c84735e..52dbbce0b9 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -28,6 +28,8 @@ export class VoxelPixelLegacyTool extends LegacyTool { return PIXEL_TOOL_ID; } trigger(mouseState: MouseSelectionState) { + // Defensive runtime check: this tool is intended for VoxUserLayer only. + if ((this.layer as any)?.constructor?.type !== 'vox') return; try { const layer = this.layer; const display = layer.manager.root.display; @@ -141,5 +143,5 @@ export class VoxelPixelLegacyTool extends LegacyTool { } export function registerVoxelAnnotationTools() { - registerLegacyTool(PIXEL_TOOL_ID, (layer) => new VoxelPixelLegacyTool(layer)); + registerLegacyTool(PIXEL_TOOL_ID, (layer) => new VoxelPixelLegacyTool(layer as unknown as VoxUserLayer)); } diff --git a/src/voxel_annotation/dummy_volume_chunk_source.ts b/src/voxel_annotation/dummy_volume_chunk_source.ts new file mode 100644 index 0000000000..c769eafeb8 --- /dev/null +++ b/src/voxel_annotation/dummy_volume_chunk_source.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2024 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import type { ChunkManager } from '#src/chunk_manager/frontend.js'; +import type { SliceViewSingleResolutionSource } from '#src/sliceview/frontend.js'; +import type { VolumeSourceOptions } from '#src/sliceview/volume/base.js'; +import { makeVolumeChunkSpecification, VolumeType } from '#src/sliceview/volume/base.js'; +import { + MultiscaleVolumeChunkSource, + VolumeChunkSource, +} from '#src/sliceview/volume/frontend.js'; +import { DataType } from '#src/util/data_type.js'; + +/** + * This is an abstract representation of 3D (volumetric) data that can exist at multiple resolutions or "scales." + * Think of it as a way to access a very large 3D image or a 3D array of values (like segmentation IDs, or in your case, voxel annotation data). + * + * Its primary job is to provide chunks of this volumetric data to the renderer. When you zoom in, zoom out, or pan through the 3D space, + * the `MultiscaleVolumeChunkSource` efficiently determines which resolution and which specific 3D "chunks" of data are needed for the current view and makes them available. + * + * Key Characteristics: + * - Multiscale: It manages different levels of detail for the same underlying data, allowing for efficient rendering at various zoom levels. + * - Chunking: Data is divided into smaller, manageable 3D blocks (chunks) to optimize loading and memory usage. + * - Asynchronous: Data loading is typically asynchronous, as it might involve fetching from a remote server or reading from large local files. + */ +export class DummyMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSource { + dataType = DataType.UINT32; + volumeType = VolumeType.SEGMENTATION; + get rank() { + return 3; + } + + constructor(chunkManager: ChunkManager) { + super(chunkManager); + } + + getSources(_options: VolumeSourceOptions) { + // Provide a single-scale, single-orientation dummy source. + const rank = this.rank; + const chunkDataSize = new Uint32Array([64, 64, 64]); + const upperVoxelBound = new Float32Array([1000, 1000, 1000]); + + const spec = makeVolumeChunkSpecification({ + rank, + dataType: this.dataType, + chunkDataSize, + upperVoxelBound, + }); + + const chunkSource = new VolumeChunkSource(this.chunkManager, { spec }); + + const identity = new Float32Array((rank + 1) * (rank + 1)); + for (let i = 0; i < rank; ++i) { + identity[i * (rank + 1) + i] = 1; + } + identity[rank * (rank + 1) + rank] = 1; + + const single: SliceViewSingleResolutionSource = { + chunkSource, + chunkToMultiscaleTransform: identity, + lowerClipBound: spec.lowerVoxelBound, + upperClipBound: spec.upperVoxelBound, + }; + // Outer array: orientations. Inner: scales (just one). + return [[single]]; + } +} diff --git a/src/voxel_annotation/renderlayer.ts b/src/voxel_annotation/renderlayer.ts new file mode 100644 index 0000000000..48bb4d36cb --- /dev/null +++ b/src/voxel_annotation/renderlayer.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2024 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; +import type { RenderLayerOptions } from "#src/sliceview/volume/renderlayer.js"; +import { SliceViewVolumeRenderLayer } from "#src/sliceview/volume/renderlayer.js"; +import { constantWatchableValue } from "#src/trackable_value.js"; +import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; + +/** + * This is a specialized rendering layer that knows how to take data from a `MultiscaleVolumeChunkSource` + * and render it as a 2D slice in the Neuroglancer viewer. + * + * Its main responsibilities include: + * - Data Request: It requests the necessary 3D data chunks from the `MultiscaleVolumeChunkSource` that intersect with the current 2D slice being viewed. + * - WebGL Management: It manages the WebGL resources (like textures and buffers) required to efficiently upload and display this 3D data as a 2D image. + * - Shader Logic: It provides the core shader program (via its `defineShader` method) that interprets the raw 3D volume data (e.g., a voxel value) and converts it into a visual representation (e.g., a color). + * - Interaction: It handles interactions like picking, allowing you to identify the specific 3D voxel or segment under the mouse cursor on the 2D slice. + */ +type EmptyParams = Record; + +export class VoxelAnnotationRenderLayer extends SliceViewVolumeRenderLayer { + constructor( + multiscaleSource: MultiscaleVolumeChunkSource, + options: RenderLayerOptions, + ) { + console.log('$$$$$$$ VoxelAnnotationRenderLayer creation with options:', options); + super(multiscaleSource, { + ...options, + shaderParameters: options.shaderParameters ?? constantWatchableValue({} as EmptyParams), + encodeShaderParameters: () => 0, + }); + console.log('###### VoxelAnnotationRenderLayer created with options:', options); + } + + defineShader(builder: ShaderBuilder) { + // The checkerboard shader logic. Assumes required varyings/uniforms are set up by base class. + builder.setFragmentMain(` +void main() { + // vChunkPosition is in voxel coords [0..uChunkDataSize]; normalize XY. + vec2 tex = vChunkPosition.xy / uChunkDataSize.xy; + float u = tex.x; + float v = tex.y; + float checker = mod(floor(u * 16.0) + floor(v * 16.0), 2.0); + vec4 color = checker > 0.5 ? vec4(1.0, 0.0, 1.0, 0.5) : vec4(0.5, 0.0, 0.5, 0.5); + emit(color); +} + `); + console.log('VoxelAnnotationRenderLayer fragment shader:'); + } + + initializeShader( + _sliceView: any, + _shader: ShaderProgram, + _parameters: EmptyParams, + _fallback: boolean, + ) { + // No specific uniforms for the checkerboard yet, but this is where they would go. + console.log('VoxelAnnotationRenderLayer shader initialized.'); + } +} From fa6ecd5a077d554770beff686f98c2faac1f6944 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:42 +0100 Subject: [PATCH 005/251] feat: introduce VoxDummyChunkSource for procedural voxel annotation demo - Added a backend `VoxDummyChunkSource` that generates a checkerboard pattern for voxel annotations. - Implemented frontend `VoxDummyChunkSource` with RPC pairing to the backend. - Updated documentation with details on chunk source architecture and implementation. --- NOTES/chunk-source.md | 127 ++++++++++++++++++ NOTES/voxel-annotation-specification.md | 9 +- src/voxel_annotation/backend.ts | 84 ++++++++++++ .../dummy_volume_chunk_source.ts | 7 +- src/voxel_annotation/frontend.ts | 22 +++ 5 files changed, 243 insertions(+), 6 deletions(-) create mode 100644 NOTES/chunk-source.md create mode 100644 src/voxel_annotation/backend.ts create mode 100644 src/voxel_annotation/frontend.ts diff --git a/NOTES/chunk-source.md b/NOTES/chunk-source.md new file mode 100644 index 0000000000..fdcf683ea5 --- /dev/null +++ b/NOTES/chunk-source.md @@ -0,0 +1,127 @@ +### What a “chunk source” is (mental model) + +- Think of a gigantic 3D image (a volume). It’s too big to load at once, so we split it into many small 3D bricks called chunks (like Lego blocks). Each chunk is a small 3D array (e.g., 64×64×64 voxels). +- Now imagine the same big 3D image at multiple zoom levels (resolutions). That’s your multiscale pyramid: full-res, half-res, quarter-res, etc. Each level is also split into chunks. +- A chunk source is the object that knows how to find, prepare, and deliver those chunk bricks when the view needs them. + +In Neuroglancer, the chunk source is split into: +- Frontend chunk source: lives on the main thread; it integrates with rendering (WebGL) and decides what to ask for. +- Backend chunk source: lives in a Web Worker; it actually fetches/decodes/generates the raw chunk data and streams it back over RPC. + + +### Why two halves (frontend vs backend) + +- Rendering must remain smooth; heavy I/O and compute live off the main thread. +- The main thread (frontend) plans what to show: which parts of the 3D volume are visible, which scale is appropriate, how to transform coordinates, which chunks to request. +- The worker (backend) executes: it receives chunk requests, loads or synthesizes the bytes, and transfers ArrayBuffers back. +- The two halves are paired via an RPC system (a tiny object-remoting layer). The frontend has the “owner” object; the worker holds the “counterpart.” They are linked by a type ID and a runtime map. + + +### Key classes and where they fit + +- MultiscaleVolumeChunkSource (frontend): a high-level source that can return sources at multiple scales/orientations. It does not deliver bytes directly; it returns per-scale frontend VolumeChunkSource owners wrapped in SliceViewSingleResolutionSource records. +- VolumeChunkSource (frontend): lower-level, per-resolution source. Holds the spec (chunk sizes, data type, bounds, etc.), integrates with WebGL via a ChunkFormatHandler, and maintains an in-memory map of loaded chunks for sampling. +- VolumeChunkSource (backend): the worker-side counterpart. It computes chunk bounds/clipping and manages Chunk instances that carry data. +- Your class DummyMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSource (frontend) and returns a single-resolution source whose owner is VoxDummyChunkSource (frontend). +- VoxDummyChunkSource (frontend) extends the frontend VolumeChunkSource to set up RPC pairing (by using the same type ID as the backend class). +- VoxDummyChunkSource (backend) extends the backend VolumeChunkSource and implements download to synthesize data. + + +### What is in the “spec” and why it matters + +- VolumeChunkSpecification (spec) is the contract that defines the chunk grid and how data is represented. + - rank: dimensionality (3 for a 3D volume). + - dataType: e.g., UINT32 for segmentation-like data. + - chunkDataSize: base chunk size in voxels (e.g., [64, 64, 64]). + - upperVoxelBound/lowerVoxelBound: global data bounds used for clipping. + - baseVoxelOffset: the global offset of the chunk grid. +- The frontend builds this spec (e.g., via makeVolumeChunkSpecification), and it gets serialized and shipped to the backend. Both sides must agree on it. + + +### How a MultiscaleVolumeChunkSource hands out sources + +- It returns a 2D array: outer dimension is orientations, inner dimension is scales. For many use cases it’s 1 orientation × N scales; your dummy returns 1×1. +- Each inner element is a SliceViewSingleResolutionSource containing: + - chunkSource: the per-resolution frontend owner (VolumeChunkSource-derived class). + - chunkToMultiscaleTransform: how to transform chunk coordinates into the multiscale/layer space (identity in your dummy). + - clip bounds. + +In your dummy implementation: +- You create spec with rank=3, dataType=UINT32, chunkDataSize=[64,64,64], upper bound ~ [1000,1000,1000]. +- You get a frontend owner chunk source via chunkManager.getChunkSource(VoxDummyChunkSource, {spec}). +- You wrap it as a single SliceViewSingleResolutionSource with identity transform and return [[single]]. + + +### What happens when rendering starts (end-to-end story) + +1) Frontend builds and adds a layer that references your MultiscaleVolumeChunkSource. +2) The layer asks the source for sources at the appropriate scales and passes them to the backend via an RPC call (e.g., SLICEVIEW_ADD_VISIBLE_LAYER_RPC_ID). The payload includes references to sources and metadata like transforms and bounds. +3) During serialization, the frontend VolumeChunkSource owner is represented as a shared object reference. The worker resolves that reference to the backend counterpart via rpc.getRef. +4) The backend now has TransformedSource objects (deserializeTransformedSources) containing: + - source: the backend VolumeChunkSource counterpart + - transforms/bounds and precomputed layout +5) As the camera changes, the backend computes visible chunks (forEachPlaneIntersectingVolumetricChunk + chunk layouts) and schedules chunk downloads. +6) For each needed chunk, the backend asks the backend VolumeChunkSource for a Chunk, computes chunk bounds via computeChunkBounds, and calls download. In your vox dummy backend, download fills a typed array with a checkerboard pattern and attaches it to chunk.data. +7) The chunk is serialized (ArrayBuffers transferred) back to the frontend. The frontend VolumeChunkSource’s ChunkFormatHandler then uploads the bytes to GPU textures or caches them in CPU memory for sampling. +8) The render layer samples those textures to draw slices or do 3D rendering. + + +### The RPC binding between the two halves + +- The frontend owner object calls initializeCounterpart which sends an RPC: SharedObject.new with type= and options (e.g., spec). +- The worker must have previously registered a constructor under that same identifier via registerSharedObject("id"). +- When SharedObject.new arrives in the worker: + - worker_rpc looks up sharedObjectConstructors.get(typeName) + - It calls new constructorFunction(rpc, options) + - The new backend object is recorded in the RPC map and linked to the same id +- From then on, any time the frontend sends a reference {id, gen}, the worker can resolve it to the concrete backend object with rpc.getRef. + +In your code: +- Frontend: VoxDummyChunkSource sets its prototype.RPC_TYPE_ID = VOX_DUMMY_CHUNK_SOURCE_RPC_ID. +- Backend: VoxDummyChunkSource is decorated with @registerSharedObject(VOX_DUMMY_CHUNK_SOURCE_RPC_ID) so it registers its constructor under the same ID. + + +### Why MultiscaleVolumeChunkSource exists at all + +- It lets Neuroglancer render the same dataset at multiple levels of detail and orientations without changing the rest of the rendering pipeline. +- It abstracts: “here is the set of sources to use for the current view and resolution.” The core sliceview logic can then pick the best scale based on zoom and request those chunks only. + + +### How “a single value at a position” is read + +- Frontend VolumeChunkSource.getValueAt does a small lookup: + - Convert a voxel coordinate to a chunk grid coordinate and an index within the chunk (modulo chunk size). + - Fetch the chunk by key from the in-memory map. If missing, return null/undefined. + - Ask the chunk object to read the typed array at the computed offset. + +This is useful for picking/hover reads and is why chunks are kept in a hash map keyed by grid position. + + +### Reference counting and disposal (brief) + +- Both owner and counterpart are ref-counted SharedObjects. When no visible layers reference a source anymore, ref counts drop; the system eventually sends dispose messages across RPC to free memory on both sides. +- The generation fields (referencedGeneration/unreferencedGeneration) guard against stale references as messages can cross. + + +### Common pitfalls and quick checks (ties to earlier errors) + +- The backend class not being loaded in the Worker bundle: + - Even if you call registerSharedObject in backend.ts, it will not run unless that file is actually imported by chunk_worker.bundle.js (or one of its transitively imported modules). + - Symptom 1: worker_rpc.ts:443 constructorFunction is not a constructor (actually undefined). That’s exactly what happens when sharedObjectConstructors.get(type) finds nothing because your backend module never ran its registration. + - Fix: ensure src/voxel_annotation/backend.ts is imported from the worker entry (e.g., via a central “enabled backend modules” file) so the decorator executes. +- RPC type ID mismatch: + - Frontend prototype.RPC_TYPE_ID must equal the identifier used by @registerSharedObject in the backend. If they differ, the worker won’t find a constructor. +- Wrong base classes: + - Frontend source should extend sliceview/volume/frontend VolumeChunkSource; backend should extend sliceview/volume/backend VolumeChunkSource. Mixing these up breaks chunk and spec handling. +- Spec inconsistencies: + - rank, dataType, and chunkDataSize must be consistent. If computeChunkBounds clips a chunk, backend must set chunk.chunkDataSize appropriately so the frontend knows the actual size. + + +### TL;DR flow + +- You create a MultiscaleVolumeChunkSource that returns one or more per-resolution frontend VolumeChunkSources (owners) plus transforms/bounds. +- The frontend sends these to the worker; the worker resolves the backend counterparts via a shared ID system. +- The backend decides which chunks to download and calls your backend source’s download to fill typed arrays. +- Chunks are transferred back; the frontend uploads to GPU and renders. + +If you want, I can sketch the minimal import line(s) needed so your vox backend class is included in chunk_worker.bundle.js, which should resolve the constructorFunction is not a constructor error you saw earlier. diff --git a/NOTES/voxel-annotation-specification.md b/NOTES/voxel-annotation-specification.md index 52cdc9c581..85d08e35ef 100644 --- a/NOTES/voxel-annotation-specification.md +++ b/NOTES/voxel-annotation-specification.md @@ -15,11 +15,11 @@ For the MVP, a more simple Pixel tool (1 voxel at a time) is sufficient to valid ## LOD, scaling and performance -Similarely to the rendering of image or segmentation data, the voxel annotation data should be rendered at the appropriate LOD depending on the zoom level. But with voxel annotations we do not have access to the pre-calculated mipmaps, those should be computed on the fly, this present a real performance challenge. This point is still under investigation, for the MVP we will avoid the issue by rendering voxel annotations only at their drawn resolution (no LOD), and hide them when zoomed out/in. +Similarely to the rendering of image or segmentation data, the voxel annotation data should be rendered at the appropriate LOD depending on the zoom level. But with voxel annotations we do not have access to the pre-calculated mipmaps, those should be computed on the fly, this presents a real performance challenge. This point is still under investigation, for the MVP we will avoid the issue by rendering voxel annotations only at their drawn resolution (no LOD) and hide them when zoomed out/in. ## Data storage -After investigation of the storage of the current annotation system of Neuroglancer, altho not plugable to our new system, its implementation still seems well design for our needs. We will probably inspire ourself to write the storage part of the voxel annotation system. The key feature rely in the asynchronous saving from the front to the back, this allow for fluid user experience (not waiting for the save to complete before being able to continue drawing). The data will be stored in a local data source (local://voxel-annotations) as a 3D array of uint32, with 0 meaning no annotation, and values 1..n meaning different labels. The data will be chunked in 64x64x64 blocks. +After investigation of the storage of the current annotation system of Neuroglancer, although not plugable to our new system, its implementation still seems well-designed for our needs. We will probably inspire ourselves to write the storage part of the voxel annotation system. The key feature relies on the asynchronous saving from the front to the back, this allows for fluid user experience (not waiting for the save to complete before being able to continue drawing). The data will be stored in a local data source (local://voxel-annotations) as a 3D array of uint32, with 0 meaning no annotation, and values 1..n meaning different labels. The data will be chunked in 64x64x64 blocks. ## Implementation plan @@ -29,3 +29,8 @@ A new layer type should be created for voxel annotations (abv: 'vox'): A local data source (local://voxel-annotations) should be created to store the voxel annotations +A subclass of MultiscaleVolumeChunkSource called VoxChunkSource should be created, this will allow us to implement custom chunk fetching to fit our special needs for the LOD, as the core algorithm for the LOD system is still not designed, this custom class will first allow us the use of single resolution chunks for the MVP while still providing a scalable solution for the future (see LOD section). In an attempt to make a "MVP of MVP", a Dummy chunk source was created, which will later be replaced by the real one. + +A VoxelAnnotationRenderLayer extending SliceViewVolumeRenderLayer will allow us to render the voxel annotations. + +A front/base/back arch similar to the existing annotation system should be created, this will handle the saving of the annotations and may also allow us to delegate the sampling work to the backend if sampling there is to be done for the LOD. diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts new file mode 100644 index 0000000000..48aecb6f0e --- /dev/null +++ b/src/voxel_annotation/backend.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2025. + */ + +import type { RPC } from '#src/worker_rpc.js'; +import { registerSharedObject } from '#src/worker_rpc.js'; +import { VolumeChunkSource as BaseVolumeChunkSource } from '#src/sliceview/volume/backend.js'; +import type { VolumeChunk } from '#src/sliceview/volume/backend.js'; + +// RPC id for the vox dummy chunk source +export const VOX_DUMMY_CHUNK_SOURCE_RPC_ID = 'vox/VoxDummyChunkSource'; + +/** + * Minimal backend volume source that procedurally generates data for voxel annotations demo. + * It fills chunk.data with a simple pattern (checkerboard based on voxel coords). + */ +@registerSharedObject(VOX_DUMMY_CHUNK_SOURCE_RPC_ID) +export class VoxDummyChunkSource extends BaseVolumeChunkSource { + constructor(rpc: RPC, options: any) { + super(rpc, options); + } + + async download(chunk: VolumeChunk, signal: AbortSignal): Promise { + // Respect aborts + if (signal.aborted) throw signal.reason ?? new Error('aborted'); + const { spec } = this; + const { dataType, fillValue } = spec; + // Compute bounds and chunkDataSize (may be clipped at upper bound) + const origin = this.computeChunkBounds(chunk); + + // Allocate a typed array matching the spec.dataType and size + const size = this.getChunkVoxelCount(chunk); + const array = this.allocateTypedArray(dataType, size, Number(fillValue ?? 0)); + + // Populate a simple 3D pattern for visualization + const cds = chunk.chunkDataSize!; + let index = 0; + for (let z = 0; z < cds[2]; ++z) { + for (let y = 0; y < cds[1]; ++y) { + for (let x = 0; x < cds[0]; ++x, ++index) { + const gx = origin[0] + x; + const gy = origin[1] + y; + const gz = origin[2] + z; + // Checker pattern in world space with large squares + const square = ((Math.floor(gx / 16) + Math.floor(gy / 16) + Math.floor(gz / 16)) & 1) !== 0; + array[index] = square ? 255 : 0; + } + } + } + + // Stash data on chunk for transfer to frontend + (chunk as any).data = array; + } + + private getChunkVoxelCount(chunk: VolumeChunk) { + const cds = chunk.chunkDataSize!; + let n = 1; + for (let i = 0; i < cds.length; ++i) n *= cds[i]; + return n; + } + + private allocateTypedArray(dataType: number, size: number, fill: number) { + // UINT32 is expected by vox dummy; keep simple mapping + switch (dataType) { + case 2: // DataType.UINT8 + return new Uint8Array(size).fill(fill & 0xff); + case 3: // DataType.INT8 + return new Int8Array(size).fill(fill & 0xff); + case 4: // DataType.UINT16 + return new Uint16Array(size).fill(fill & 0xffff); + case 5: // DataType.INT16 + return new Int16Array(size).fill(fill & 0xffff); + case 6: // DataType.UINT32 + return new Uint32Array(size).fill(fill >>> 0); + case 7: // DataType.INT32 + return new Int32Array(size).fill(fill | 0); + case 1: // DataType.FLOAT32 + return new Float32Array(size).fill(fill); + default: + return new Uint32Array(size).fill(fill >>> 0); + } + } +} diff --git a/src/voxel_annotation/dummy_volume_chunk_source.ts b/src/voxel_annotation/dummy_volume_chunk_source.ts index c769eafeb8..1f376bc5d6 100644 --- a/src/voxel_annotation/dummy_volume_chunk_source.ts +++ b/src/voxel_annotation/dummy_volume_chunk_source.ts @@ -14,16 +14,15 @@ * limitations under the License. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars import type { ChunkManager } from '#src/chunk_manager/frontend.js'; import type { SliceViewSingleResolutionSource } from '#src/sliceview/frontend.js'; import type { VolumeSourceOptions } from '#src/sliceview/volume/base.js'; import { makeVolumeChunkSpecification, VolumeType } from '#src/sliceview/volume/base.js'; import { MultiscaleVolumeChunkSource, - VolumeChunkSource, } from '#src/sliceview/volume/frontend.js'; import { DataType } from '#src/util/data_type.js'; +import { VoxDummyChunkSource } from '#src/voxel_annotation/frontend.js'; /** * This is an abstract representation of 3D (volumetric) data that can exist at multiple resolutions or "scales." @@ -61,7 +60,7 @@ export class DummyMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSourc upperVoxelBound, }); - const chunkSource = new VolumeChunkSource(this.chunkManager, { spec }); + const chunkSource: VoxDummyChunkSource = this.chunkManager.getChunkSource(VoxDummyChunkSource as any, { spec }); const identity = new Float32Array((rank + 1) * (rank + 1)); for (let i = 0; i < rank; ++i) { @@ -69,7 +68,7 @@ export class DummyMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSourc } identity[rank * (rank + 1) + rank] = 1; - const single: SliceViewSingleResolutionSource = { + const single: SliceViewSingleResolutionSource = { chunkSource, chunkToMultiscaleTransform: identity, lowerClipBound: spec.lowerVoxelBound, diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts new file mode 100644 index 0000000000..e9a2bbcaa0 --- /dev/null +++ b/src/voxel_annotation/frontend.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2025. + */ + +import type { ChunkManager } from '#src/chunk_manager/frontend.js'; +import type { VolumeChunkSpecification } from '#src/sliceview/volume/base.js'; +import { VolumeChunkSource as BaseVolumeChunkSource } from '#src/sliceview/volume/frontend.js'; +import { VOX_DUMMY_CHUNK_SOURCE_RPC_ID } from '#src/voxel_annotation/backend.js'; + +/** + * Frontend owner for VoxDummyChunkSource. It simply sets the RPC_TYPE_ID so the backend + * counterpart created in the worker matches our vox implementation that synthesizes data. + */ +export class VoxDummyChunkSource extends BaseVolumeChunkSource { + constructor(chunkManager: ChunkManager, options: { spec: VolumeChunkSpecification }) { + super(chunkManager, options); + } +} + +// Register owner type id so ChunkManager can initialize the correct backend counterpart. +(VoxDummyChunkSource as any).prototype.RPC_TYPE_ID = VOX_DUMMY_CHUNK_SOURCE_RPC_ID; From a52bad193b440206689b7d7c5726629bbaf15e9b Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:42 +0100 Subject: [PATCH 006/251] feat: no errors but no checkboard tho --- src/chunk_worker.bundle.js | 1 + src/datasource/local.ts | 31 +++++++++++-------- src/layer/vox/index.ts | 31 ++++++++++--------- src/voxel_annotation/backend.ts | 29 ++++++++++------- .../dummy_volume_chunk_source.ts | 4 +-- src/voxel_annotation/renderlayer.ts | 23 ++++++++++---- 6 files changed, 72 insertions(+), 47 deletions(-) diff --git a/src/chunk_worker.bundle.js b/src/chunk_worker.bundle.js index a463171535..1ce9c822d1 100644 --- a/src/chunk_worker.bundle.js +++ b/src/chunk_worker.bundle.js @@ -12,3 +12,4 @@ import "#src/annotation/backend.js"; import "#src/datasource/enabled_backend_modules.js"; import "#src/kvstore/enabled_backend_modules.js"; import "#src/worker_rpc_context.js"; +import "#src/voxel_annotation/backend.js"; diff --git a/src/datasource/local.ts b/src/datasource/local.ts index eae04ff337..47474e866f 100644 --- a/src/datasource/local.ts +++ b/src/datasource/local.ts @@ -107,31 +107,36 @@ export class LocalDataSourceProvider implements DataSourceProvider { }; } case localVoxelAnnotationsUrl: { + // Voxels data source: by default, provide a fixed 3D identity model transform. + // Rationale: Many voxel-based layers (like our demo vox layer) expect a concrete 3D + // model space. Mirroring the global space rank/names can lead to ambiguous or rank-0 + // cases depending on viewer state. Keeping a stable 3D identity model transform here + // reduces surprises while still allowing an explicit transform override via options. const { transform } = options; let modelTransform: CoordinateSpaceTransform; if (transform === undefined) { - const baseSpace = options.globalCoordinateSpace.value; - const { rank, names, scales, units } = baseSpace; const inputSpace = makeCoordinateSpace({ - rank, - scales, - units, - names: names.map((_, i) => `${i}`), + rank: 3, + scales: new Float64Array([1, 1, 1]), + units: ["", "", ""], + names: ["x", "y", "z"], }); const outputSpace = makeCoordinateSpace({ - rank, - scales, - units, - names, + rank: 3, + scales: new Float64Array([1, 1, 1]), + units: ["", "", ""], + names: ["x", "y", "z"], }); modelTransform = { - rank, - sourceRank: rank, + rank: 3, + sourceRank: 3, inputSpace, outputSpace, - transform: createIdentity(Float64Array, rank + 1), + transform: createIdentity(Float64Array, 4), }; } else { + // If an explicit transform is provided, just pass through an identity over empty space, + // consistent with other local sources. modelTransform = makeIdentityTransform(emptyValidCoordinateSpace); } return { diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index cdd338b791..8cbcf12ed7 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -116,20 +116,19 @@ export class VoxUserLayer extends UserLayer { const dummySource = new DummyMultiscaleVolumeChunkSource( this.manager.chunkManager, ); - this.addRenderLayer( + loadedSubsource.addRenderLayer( new VoxelAnnotationRenderLayer( dummySource, { - // Provide a synthetic 3D identity transform to match our 3D dummy volume. - // The default transform from local://voxel-annotations has rank 0, which results in - // no sources becoming visible. We bind an identity 3D model transform to the viewer/global - // and layer/local spaces so SliceView can select sources correctly. - transform: getWatchableRenderLayerTransform( - this.manager.root.coordinateSpace, - this.localPosition.coordinateSpace, - new WatchableCoordinateSpaceTransform( + // IMPORTANT: Use an explicit 3D identity model transform, then convert it to a + // WatchableRenderLayerTransform. In this project, relying on the subsource-provided + // transform for local://voxel-annotations can yield a rank-0/ambiguous mapping and + // hide the chunk sources, meaning the checkerboard shader is never invoked. The + // identity 3D model space ensures proper detection and visibility of our dummy + // volume chunks while still integrating with global/local spaces. + transform: ((): any => { + const identity3D = new WatchableCoordinateSpaceTransform( makeIdentityTransform( - // Construct a generic 3D model space. Names/units are placeholders but sufficient. makeCoordinateSpace({ rank: 3, names: ["x", "y", "z"], @@ -137,10 +136,14 @@ export class VoxUserLayer extends UserLayer { scales: new Float64Array([1, 1, 1]), }), ), - ), - undefined, - undefined, - ), + ); + return getWatchableRenderLayerTransform( + this.manager.root.coordinateSpace, + this.localPosition.coordinateSpace, + identity3D, + undefined, + ); + })(), renderScaleTarget: this.sliceViewRenderScaleTarget, renderScaleHistogram: undefined, localPosition: this.localPosition, diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 48aecb6f0e..093a7553ca 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -3,10 +3,11 @@ * Copyright 2025. */ +import type { VolumeChunk } from '#src/sliceview/volume/backend.js'; +import { VolumeChunkSource as BaseVolumeChunkSource } from '#src/sliceview/volume/backend.js'; +import { DataType } from '#src/util/data_type.js'; import type { RPC } from '#src/worker_rpc.js'; import { registerSharedObject } from '#src/worker_rpc.js'; -import { VolumeChunkSource as BaseVolumeChunkSource } from '#src/sliceview/volume/backend.js'; -import type { VolumeChunk } from '#src/sliceview/volume/backend.js'; // RPC id for the vox dummy chunk source export const VOX_DUMMY_CHUNK_SOURCE_RPC_ID = 'vox/VoxDummyChunkSource'; @@ -61,21 +62,25 @@ export class VoxDummyChunkSource extends BaseVolumeChunkSource { } private allocateTypedArray(dataType: number, size: number, fill: number) { - // UINT32 is expected by vox dummy; keep simple mapping switch (dataType) { - case 2: // DataType.UINT8 + case DataType.UINT8: return new Uint8Array(size).fill(fill & 0xff); - case 3: // DataType.INT8 - return new Int8Array(size).fill(fill & 0xff); - case 4: // DataType.UINT16 + case DataType.INT8: + return new Int8Array(size).fill((fill << 24) >> 24); + case DataType.UINT16: return new Uint16Array(size).fill(fill & 0xffff); - case 5: // DataType.INT16 - return new Int16Array(size).fill(fill & 0xffff); - case 6: // DataType.UINT32 + case DataType.INT16: + return new Int16Array(size).fill((fill << 16) >> 16); + case DataType.UINT32: return new Uint32Array(size).fill(fill >>> 0); - case 7: // DataType.INT32 + case DataType.INT32: return new Int32Array(size).fill(fill | 0); - case 1: // DataType.FLOAT32 + case DataType.UINT64: { + // Represent as 64-bit unsigned. Use BigUint64Array; frontend will reinterpret as Uint32Array. + const big = BigInt(fill >>> 0); + return new BigUint64Array(size).fill(big); + } + case DataType.FLOAT32: return new Float32Array(size).fill(fill); default: return new Uint32Array(size).fill(fill >>> 0); diff --git a/src/voxel_annotation/dummy_volume_chunk_source.ts b/src/voxel_annotation/dummy_volume_chunk_source.ts index 1f376bc5d6..ace388d587 100644 --- a/src/voxel_annotation/dummy_volume_chunk_source.ts +++ b/src/voxel_annotation/dummy_volume_chunk_source.ts @@ -50,8 +50,8 @@ export class DummyMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSourc getSources(_options: VolumeSourceOptions) { // Provide a single-scale, single-orientation dummy source. const rank = this.rank; - const chunkDataSize = new Uint32Array([64, 64, 64]); - const upperVoxelBound = new Float32Array([1000, 1000, 1000]); + const chunkDataSize = new Uint32Array([32, 32, 32]); + const upperVoxelBound = new Float32Array([1024, 1024, 1024]); const spec = makeVolumeChunkSpecification({ rank, diff --git a/src/voxel_annotation/renderlayer.ts b/src/voxel_annotation/renderlayer.ts index 48bb4d36cb..a5b54f9bb7 100644 --- a/src/voxel_annotation/renderlayer.ts +++ b/src/voxel_annotation/renderlayer.ts @@ -33,6 +33,16 @@ import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; type EmptyParams = Record; export class VoxelAnnotationRenderLayer extends SliceViewVolumeRenderLayer { + // Defensive draw override to avoid crashes if layerInfo is not initialized yet. + override draw(renderContext: any) { + const { sliceView } = renderContext; + const layerInfo = sliceView.visibleLayers.get(this); + if (layerInfo === undefined) { + // Visible layers not ready yet; skip drawing this frame. + return; + } + super.draw(renderContext); + } constructor( multiscaleSource: MultiscaleVolumeChunkSource, options: RenderLayerOptions, @@ -47,19 +57,20 @@ export class VoxelAnnotationRenderLayer extends SliceViewVolumeRenderLayer 0.5 ? vec4(1.0, 0.0, 1.0, 0.5) : vec4(0.5, 0.0, 0.5, 0.5); + float tiles = 8.0; // fewer tiles for larger squares + float u = tex.x * tiles; + float v = tex.y * tiles; + float checker = mod(floor(u) + floor(v), 2.0); + vec4 color = checker > 0.5 ? vec4(1.0, 1.0, 0.0, 1.0) : vec4(0.0, 0.0, 0.0, 1.0); emit(color); } `); - console.log('VoxelAnnotationRenderLayer fragment shader:'); + console.log('VoxelAnnotationRenderLayer fragment shader installed.'); } initializeShader( From dcf7e12e347357859a79e383438d8e08852b68b4 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:42 +0100 Subject: [PATCH 007/251] feat: finaly the checkboard is showing, but it is a bit bugged out, it seems there is some fighting. --- NOTES/voxel-annotation-specification.md | 3 +++ src/sliceview/volume/renderlayer.ts | 1 - src/voxel_annotation/backend.ts | 3 +-- src/voxel_annotation/base.ts | 2 ++ src/voxel_annotation/frontend.ts | 2 +- src/voxel_annotation/renderlayer.ts | 21 +++++++++++++------ src/webgl/shader.ts | 28 +++++++++++++++++++++++++ 7 files changed, 50 insertions(+), 10 deletions(-) create mode 100644 src/voxel_annotation/base.ts diff --git a/NOTES/voxel-annotation-specification.md b/NOTES/voxel-annotation-specification.md index 85d08e35ef..1f0cd77ea7 100644 --- a/NOTES/voxel-annotation-specification.md +++ b/NOTES/voxel-annotation-specification.md @@ -21,6 +21,9 @@ Similarely to the rendering of image or segmentation data, the voxel annotation After investigation of the storage of the current annotation system of Neuroglancer, although not plugable to our new system, its implementation still seems well-designed for our needs. We will probably inspire ourselves to write the storage part of the voxel annotation system. The key feature relies on the asynchronous saving from the front to the back, this allows for fluid user experience (not waiting for the save to complete before being able to continue drawing). The data will be stored in a local data source (local://voxel-annotations) as a 3D array of uint32, with 0 meaning no annotation, and values 1..n meaning different labels. The data will be chunked in 64x64x64 blocks. +Using the current local://voxel-annotations is one way of saving the data, with this approach one could retreive the data thanks to some kind of exporting feature that remains to be design. An other way of saving the data may be with a distant server providing the datasource and centralizing the users, this would allow for live multi-users annotation. The best would be to have both. + + ## Implementation plan A new layer type should be created for voxel annotations (abv: 'vox'): diff --git a/src/sliceview/volume/renderlayer.ts b/src/sliceview/volume/renderlayer.ts index 8152ec3e68..d7646a6741 100644 --- a/src/sliceview/volume/renderlayer.ts +++ b/src/sliceview/volume/renderlayer.ts @@ -597,7 +597,6 @@ void main() { this.endSlice(sliceView, shader, shaderResult.parameters); }; let newSource = true; - console.log("number of visible sources:", visibleSources.length); for (const transformedSource of visibleSources) { const chunkLayout = getNormalizedChunkLayout( projectionParameters, diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 093a7553ca..7295239d56 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -6,11 +6,10 @@ import type { VolumeChunk } from '#src/sliceview/volume/backend.js'; import { VolumeChunkSource as BaseVolumeChunkSource } from '#src/sliceview/volume/backend.js'; import { DataType } from '#src/util/data_type.js'; +import { VOX_DUMMY_CHUNK_SOURCE_RPC_ID } from "#src/voxel_annotation/base.js"; import type { RPC } from '#src/worker_rpc.js'; import { registerSharedObject } from '#src/worker_rpc.js'; -// RPC id for the vox dummy chunk source -export const VOX_DUMMY_CHUNK_SOURCE_RPC_ID = 'vox/VoxDummyChunkSource'; /** * Minimal backend volume source that procedurally generates data for voxel annotations demo. diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts new file mode 100644 index 0000000000..76f5a354bd --- /dev/null +++ b/src/voxel_annotation/base.ts @@ -0,0 +1,2 @@ +// RPC id for the vox dummy chunk source +export const VOX_DUMMY_CHUNK_SOURCE_RPC_ID = 'vox.VoxDummyChunkSource'; diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index e9a2bbcaa0..bcc59b89d4 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -6,7 +6,7 @@ import type { ChunkManager } from '#src/chunk_manager/frontend.js'; import type { VolumeChunkSpecification } from '#src/sliceview/volume/base.js'; import { VolumeChunkSource as BaseVolumeChunkSource } from '#src/sliceview/volume/frontend.js'; -import { VOX_DUMMY_CHUNK_SOURCE_RPC_ID } from '#src/voxel_annotation/backend.js'; +import { VOX_DUMMY_CHUNK_SOURCE_RPC_ID } from '#src/voxel_annotation/base.js'; /** * Frontend owner for VoxDummyChunkSource. It simply sets the RPC_TYPE_ID so the backend diff --git a/src/voxel_annotation/renderlayer.ts b/src/voxel_annotation/renderlayer.ts index a5b54f9bb7..9d6c42761f 100644 --- a/src/voxel_annotation/renderlayer.ts +++ b/src/voxel_annotation/renderlayer.ts @@ -57,20 +57,30 @@ export class VoxelAnnotationRenderLayer extends SliceViewVolumeRenderLayer 0.5 ? vec4(1.0, 1.0, 0.0, 1.0) : vec4(0.0, 0.0, 0.0, 1.0); + vec4 color = checker > 0.5 ? vec4(1.0, 1.0, 0.0, 0.5) : vec4(0.0, 1.0, 0.0, 0.5); emit(color); -} `); - console.log('VoxelAnnotationRenderLayer fragment shader installed.'); + + /** + * Notes on the shader building: + * - The shader is not built until the first draw call, in the `draw` method. + * - the SliceViewVolumeRenderLayer build a shader getter in the constructor, the getter builds the shader and memoize it. + * - when the build process fails no error is thrown, this makes a wrong shader hard to debug. + * - here we add a try/catch to log the error if the build fails, its soul purpose is for debugging. + */ + try { + builder.build(); + }catch (e) { + builder.print() + console.error(e) + } } initializeShader( @@ -80,6 +90,5 @@ void main() { _fallback: boolean, ) { // No specific uniforms for the checkerboard yet, but this is where they would go. - console.log('VoxelAnnotationRenderLayer shader initialized.'); } } diff --git a/src/webgl/shader.ts b/src/webgl/shader.ts index ed2f4220db..1b2890ac85 100644 --- a/src/webgl/shader.ts +++ b/src/webgl/shader.ts @@ -663,6 +663,34 @@ ${this.fragmentMain} } return shader; } + + print() { + const vertexSource = `#version 300 es +precision highp float; +precision highp int; +${this.uniformsCode} +${this.attributesCode} +${this.varyingsCodeVS} +float defaultMaxProjectionIntensity = 0.0; +${this.vertexCode} +void main() { +${this.vertexMain} +} +`; + const fragmentSource = `#version 300 es +${this.fragmentExtensions} +precision highp float; +precision highp int; +${this.uniformsCode} +${this.varyingsCodeFS} +${this.outputBufferCode} +float defaultMaxProjectionIntensity = 0.0; +${this.fragmentCode} +${this.fragmentMain} +`; + console.log('----- VERTEX SHADER -----\n' + vertexSource); + console.log('----- FRAGMENT SHADER -----\n' + fragmentSource); + } } export function shaderContainsIdentifiers( From 935509b6cebecf7258fb6950164b55e43f40ec75 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:42 +0100 Subject: [PATCH 008/251] doc: rework voxel annotation specs --- NOTES/voxel-annotation-specification.md | 89 +++++++++++++++++++------ src/layer/vox/index.ts | 2 +- 2 files changed, 68 insertions(+), 23 deletions(-) diff --git a/NOTES/voxel-annotation-specification.md b/NOTES/voxel-annotation-specification.md index 1f0cd77ea7..305b594736 100644 --- a/NOTES/voxel-annotation-specification.md +++ b/NOTES/voxel-annotation-specification.md @@ -1,39 +1,84 @@ -# Voxel Annotation Specification +# Voxel Annotation Specification (Revised) -## Overview +## 1. Overview -The objective of the voxel annotation is to allow precise labeling, synchronized with the underlying image data (same scale...), for deep learning training and validation. The already present annotation system in Neuroglancer is not well suited for this task since it is not voxel-precise, does not implement fast and ergonomic drawing tools and no export to standard formats. The choice could have been made to extend the existing annotation system, but it being based on vector graphics, it would have been a major overhaul. Instead, a new voxel annotation system is being developed in parallel. +The objective of the voxel annotation feature is to allow precise, voxel-aligned labeling of image data, primarily for deep learning training and validation. The existing annotation system in Neuroglancer is vector-based and not suited for this task. This specification outlines a new, parallel voxel annotation system designed for performance, scalability, and ergonomic use. -## Tools +## 2. Core Features & Tools -Giving the user modern drawing tools is one of the key requirements of the voxel annotation system. The following tools are planned: -- Brush (circular, adjustable size) -- Flood fill -- Eraser (circular, adjustable size) +The user will be provided with a suite of drawing tools for efficient annotation. -For the MVP, a more simple Pixel tool (1 voxel at a time) is sufficient to validate the concept. +* **Brush**: A circular brush with adjustable size. +* **Flood Fill (2D/3D)**: A tool to fill contiguous areas of the same underlying data value or annotation label. +* **Eraser**: A circular eraser with adjustable size. +* **MVP Tool**: A single-voxel "Pixel" tool to validate the core architecture. -## LOD, scaling and performance +## 3. Data Storage and State Management -Similarely to the rendering of image or segmentation data, the voxel annotation data should be rendered at the appropriate LOD depending on the zoom level. But with voxel annotations we do not have access to the pre-calculated mipmaps, those should be computed on the fly, this presents a real performance challenge. This point is still under investigation, for the MVP we will avoid the issue by rendering voxel annotations only at their drawn resolution (no LOD) and hide them when zoomed out/in. +To ensure a responsive user experience while maintaining data integrity, we will implement a three-tier data management architecture. This formalizes the asynchronous saving process and clarifies the role of each component. -## Data storage +``` +┌──────────────────┐ User Edit ┌────────────────┐ (Debounced) ┌────────────────────────┐ +│ Frontend UI ├──────────────>│ Worker State ├────────────────>│ Persistent Storage │ +│ (Render Layer) │ │ (Chunked Map) │ │ (local:// or http://) │ +└──────────┬───────┘ └────────┬───────┘ └────────────────────────┘ + │ │ + │ User draws, updates │ Receives edit actions, + │ "Hot" cache instantly │ applies to chunks, marks + │ & sends action to worker │ them as "dirty" for saving + v + [Frontend "Hot" Cache] +``` -After investigation of the storage of the current annotation system of Neuroglancer, although not plugable to our new system, its implementation still seems well-designed for our needs. We will probably inspire ourselves to write the storage part of the voxel annotation system. The key feature relies on the asynchronous saving from the front to the back, this allows for fluid user experience (not waiting for the save to complete before being able to continue drawing). The data will be stored in a local data source (local://voxel-annotations) as a 3D array of uint32, with 0 meaning no annotation, and values 1..n meaning different labels. The data will be chunked in 64x64x64 blocks. +#### Tier 1: Frontend State (The "Hot" Cache) -Using the current local://voxel-annotations is one way of saving the data, with this approach one could retreive the data thanks to some kind of exporting feature that remains to be design. An other way of saving the data may be with a distant server providing the datasource and centralizing the users, this would allow for live multi-users annotation. The best would be to have both. +* **Location**: Frontend (UI thread). +* **Purpose**: Provide immediate visual feedback to the user. +* **Mechanism**: When a user draws, the edit is applied to an immediate, in-memory representation and rendered instantly. Simultaneously, an "action" describing the edit is dispatched to the web worker. +#### Tier 2: Worker State (The "Warm" Source of Truth) -## Implementation plan +* **Location**: Web Worker. +* **Purpose**: To act as the authoritative, canonical state of the annotations. +* **Mechanism**: The worker maintains a map of all annotation chunks (`Map`). It listens for actions from the frontend, applies them to the corresponding chunks, and marks those chunks as "dirty." -A new layer type should be created for voxel annotations (abv: 'vox'): -- class name: VoxUserLayer extending UserLayer -- file: layer/vox/index.ts +#### Tier 3: Persistent Storage (The "Cold" Layer) -A local data source (local://voxel-annotations) should be created to store the voxel annotations +* **Location**: The data source (e.g., `local://voxel-annotations`). +* **Purpose**: Long-term, durable storage. +* **Mechanism**: The worker uses a throttled or debounced function to periodically write all "dirty" chunks from its state (Tier 2) to the persistent data source. This ensures that frequent edits do not overload the storage backend and that the UI never waits for a save operation. -A subclass of MultiscaleVolumeChunkSource called VoxChunkSource should be created, this will allow us to implement custom chunk fetching to fit our special needs for the LOD, as the core algorithm for the LOD system is still not designed, this custom class will first allow us the use of single resolution chunks for the MVP while still providing a scalable solution for the future (see LOD section). In an attempt to make a "MVP of MVP", a Dummy chunk source was created, which will later be replaced by the real one. +#### Tier 4: Multi-users -A VoxelAnnotationRenderLayer extending SliceViewVolumeRenderLayer will allow us to render the voxel annotations. +The arch should have all the necessary components to support multi-user annotation, such a feature could be implemented in the future. This multi-user feature would be similar to the one found in Google Docs. -A front/base/back arch similar to the existing annotation system should be created, this will handle the saving of the annotations and may also allow us to delegate the sampling work to the backend if sampling there is to be done for the LOD. +### 3.1. MVP In-Memory Data Structure +For the MVP, we will simplify the problem by restricting annotations to a single, user-selectable scale. This provides a clear structure for organizing the data within the worker's memory. + +* Annotation Scale Selection: The VoxUserLayer UI will include a dropdown or similar control that allows the user to select which scale (resolution) from a reference image layer they wish to annotate on. All subsequent drawing actions will apply to this single, chosen scale. +* In-Worker Data Structure: The worker will namespace the chunks in its internal Map using a key that combines the scale and chunk identifiers. This prevents collisions if the user switches between annotating different scales. + * Map Key Format: / + * Example Key: "4_4_40/0-64_0-64_0-64" +* In-Memory Chunk Format: + * Each chunk will be stored in the worker's map as a Uint32Array. + * The total length of the array will be chunkSizeX * chunkSizeY * chunkSizeZ (e.g., 64x64x64 = 262,144 elements). + * The value 0 represents an un-annotated voxel. Values 1..n correspond to different user-defined labels. + +## 4. LOD, Scaling, and Performance + +The absence of pre-computed mipmaps for user-drawn data presents the primary performance challenge. We will tackle this with a phased approach. + +### MVP Strategy + +Render annotations only at their native resolution. The annotation layer will be hidden when the view is zoomed too far out or in, avoiding the LOD problem entirely to validate the core drawing and saving functionality. + +### Phase 2: On-the-Fly Worker Downsampling + +* The `VoxChunkSource` will be responsible for generating lower-resolution chunks. +* When the renderer requests a chunk at a lower LOD (e.g., LOD 1), the `VoxChunkSource` will request the corresponding 8 chunks at the higher resolution (LOD 0) from the Worker State. +* It will then compute a downsampled chunk on-the-fly (e.g., using a majority vote for the label in each 2x2x2 region). +* **Caching**: Generated low-LOD chunks will be cached in the worker to avoid re-computation. This cache is invalidated when any of the underlying high-resolution data changes. + +### Phase 3 (Future): Sparse Voxel Structures + +For ultimate performance and memory efficiency with very sparse annotations, the worker could manage the data in a hierarchical structure like a Sparse Voxel Octree (SVO). This would be a major undertaking but would provide the most scalable solution. diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 8cbcf12ed7..0abe757dc1 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -133,7 +133,7 @@ export class VoxUserLayer extends UserLayer { rank: 3, names: ["x", "y", "z"], units: ["", "", ""], - scales: new Float64Array([1, 1, 1]), + scales: new Float64Array([0.000001, 0.000001, 0.000001]), }), ), ); From d0ce4ca75f5599c85077a6485d12b98c9aa75c40 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:42 +0100 Subject: [PATCH 009/251] feat: working on the pixel tool, there are interaction but a bug seems to corruped the chunk after the usage of the tool. Added a front end buffer which is the only drawing storage for now. Added user settings to set the voxel_annotation layer scale and bounds. Added a second empty source to DummyMultiscaleVolumeChunkSource to prevent crashs when zoomed out too much --- NOTES/RPC.md | 188 +++++++++++++++ NOTES/annotation-chunk-source-and-sync.md | 147 ++++++++++++ NOTES/{ => classExplanations}/chunk-source.md | 0 src/layer/vox/index.ts | 217 ++++++++++++++---- src/layer/vox/style.css | 98 ++++++++ src/ui/voxel_annotations.ts | 11 + src/voxel_annotation/backend.ts | 8 +- src/voxel_annotation/base.ts | 3 +- .../dummy_volume_chunk_source.ts | 82 +++++-- src/voxel_annotation/edit_controller.ts | 23 ++ src/voxel_annotation/frontend.ts | 150 +++++++++++- src/voxel_annotation/renderlayer.ts | 12 +- 12 files changed, 856 insertions(+), 83 deletions(-) create mode 100644 NOTES/RPC.md create mode 100644 NOTES/annotation-chunk-source-and-sync.md rename NOTES/{ => classExplanations}/chunk-source.md (100%) create mode 100644 src/layer/vox/style.css create mode 100644 src/voxel_annotation/edit_controller.ts diff --git a/NOTES/RPC.md b/NOTES/RPC.md new file mode 100644 index 0000000000..c470412d40 --- /dev/null +++ b/NOTES/RPC.md @@ -0,0 +1,188 @@ +### What “SharedObject”, the decorators, and the RPC are (in plain words) + +Here’s the mental model Neuroglancer uses to let the main thread (frontend) and the worker (backend) coordinate: + +- RPC is the postal system. You “register” named message handlers on both sides and you “invoke” those names with payloads. Some RPC calls return values via a promise protocol with cancellation and progress. +- SharedObject is a cross-thread object handle with reference counting. A class instance owned on one side has a corresponding lightweight counterpart on the other side. They stay in sync by sending RPC messages. When both sides drop references, the pair auto-disposes correctly. +- Decorators are just a convenient way to register classes with the RPC factory so the other side can construct the right counterpart class by name. + +Once you keep those three ideas in mind, the rest of the patterns (like sharing a watchable value or a visibility priority) are applications of the same basic mechanism. + +--- + +### RPC in this codebase + +Core file: src/worker_rpc.ts + +- Message router + - registerRPC(name, handler) records a function capable of handling messages named “name”. + - rpc.invoke(name, payload, transfers?) serializes your payload and posts it to the other side. The other side looks up handler by name and calls it. + +- Promise RPC (request/response) + - registerPromiseRPC(name, handlerWithProgress) wraps a handler so responses go back via a standard reply channel and the caller gets a Promise. + - rpc.promiseInvoke(name, payload, { signal, progressListener, transfers }) sends the request and returns a Promise. If you pass an AbortSignal, the other side will receive a cancellation via the standard PROMISE_CANCEL_ID. If you pass a progressListener, the other side can emit progress spans back. + +Key snippets (file: src/worker_rpc.ts): +- Registry and invoke: handlers map, registerRPC, RPC.invoke (lines ~42–46, 225–236). +- Promise protocol: registerPromiseRPC and rpc.promiseInvoke with cancel and progress (lines ~74–108, 238–269, 110–145, 130–140). +- Ready/queue: when the peer worker isn’t ready yet, outgoing messages get queued until onPeerReady flushes them (lines ~158–189). + +Why this matters: It turns postMessage into a tiny RPC framework with named calls, requests that can be cancelled, and streamable progress events. + +--- + +### SharedObject: a cross-thread, ref-counted object pair + +Core file: src/worker_rpc.ts + +- A SharedObject is a RefCounted instance that exists on both sides (owner and counterpart). The owner creates the counterpart using a factory call; the counterpart is a lightweight representation used to send signals back to the owner. Both halves refer to each other via an RPC id. + +- Ownership and lifecycle + - Owner side calls initializeCounterpart(rpc, options). That: + 1) sets up bookkeeping (rpc, rpcId), + 2) marks itself as owner, + 3) invokes SharedObject.new with type and options so the other side constructs the counterpart (lines ~290–298). + - Counterpart creation (other side): SharedObject.new handler looks up the registered constructor by the type string and new()’s it (lines ~439–446). Counterpart starts with refCount zero. + - Reference model: addCounterpartRef() returns { id, gen }, where gen is a monotonically increasing generation number that tracks references flowing to the other side (line ~307–309). When a counterpart’s refcount drops to zero, it notifies the owner via SharedObject.refCountReachedZero (lines ~398–402), passing back the generation that reached zero. + - Cleanup: + - If the owner’s own refCount hits zero and the most recent generation has been released by the counterpart (generations match), ownerDispose() runs and tells the counterpart to dispose (lines ~311–337). + - The SharedObject.dispose RPC validates refCount is zero, deletes the mapping, and nulls fields (lines ~382–396). + +- Key fields + - rpc, rpcId: the communication endpoint and the numeric id that identifies this object across the channel. + - isOwner: true on the side that initiated the counterpart creation; false on the counterpart; undefined before init. + - referencedGeneration, unreferencedGeneration: let the owner track which counterpart reference generation has hit zero. This avoids races if multiple references are sent over time. + +Plain-English analogy: imagine the frontend owns a remote handle in the worker. You can pass out references to that handle. When the worker is done with a reference, it says “generation 5 released.” Only when both the frontend has no refs and the last released generation equals the last handed-out generation is it safe to actually tear down the pair. + +--- + +### The decorators: registering types for cross-thread construction + +Also in src/worker_rpc.ts + +- @registerSharedObjectOwner(identifier) + - Sets RPC_TYPE_ID on the class prototype to the given string (lines ~411–415). This is used when the owner class will initiate a counterpart. On initializeCounterpart, that RPC_TYPE_ID is sent so the other side knows which constructor to call. + +- @registerSharedObject(identifier?) + - Registers a class constructor in a global map keyed by identifier (lines ~425–437). This is meant for counterpart classes (the classes to construct when a “SharedObject.new” message arrives). If you omit the identifier, the class’s prototype must already have RPC_TYPE_ID. + +- How they combine: + - Owner side: a class decorated with @registerSharedObjectOwner("My.Type") will send type: "My.Type" when it calls initializeCounterpart(), which triggers a SharedObject.new RPC. + - Counterpart side: a class decorated with @registerSharedObject("My.Type") is discoverable by the SharedObject.new handler, which constructs it with (rpc, options). + +In the code: +- Owner example (frontend): + - src/annotation/renderlayer.ts: AnnotationLayerSharedObject is decorated with @registerSharedObjectOwner(ANNOTATION_RENDER_LAYER_RPC_ID) and calls this.initializeCounterpart(...) to spin up the backend counterpart with the same identifier (lines ~182–201). +- Counterpart example (backend): + - src/annotation/backend.ts has @registerSharedObject(ANNOTATION_RENDER_LAYER_RPC_ID) on the class that implements the backend side of that layer (see search results). When SharedObject.new arrives with that id, this class is constructed. + +This pattern appears broadly across the codebase for chunk sources, mesh layers, slice views, credentials, etc. See search results for @registerSharedObject and @registerSharedObjectOwner. + +--- + +### Example: sharing visibility across threads with a mixin + +Files: +- src/visibility_priority/frontend.ts +- src/visibility_priority/backend.ts + +withSharedVisibility is a mixin that augments a SharedObject-based class with a “visibility” property that’s actually a shared, cross-thread WatchableValue. It demonstrates how to embed another shared object inside your own options during initializeCounterpart. + +- Frontend side mixin (owner): + - Adds visibility = new VisibilityPriorityAggregator() (an aggregator of watchable priorities). + - In initializeCounterpart, it constructs a SharedWatchableValue from the existing WatchableValue and injects the rpcId into options.visibility before calling super.initializeCounterpart (frontend.ts lines ~96–105). This means the backend will receive an rpc id to a SharedWatchableValue. + +- Backend side mixin (counterpart): + - In constructor(rpc, options), it grabs the shared watchable from rpc.get(options.visibility), subscribes to changes, and reacts (e.g., reprioritize chunk requests) (backend.ts lines ~35–46). + +So a field in your class can itself be a shared object, referenced by id in the “options” payload used to construct the counterpart. + +--- + +### Example: SharedWatchableValue in detail (a simple shared data container) + +File: src/shared_watchable_value.ts + +- It’s a counterpart class that implements WatchableValueInterface and is decorated with @registerSharedObject("SharedWatchableValue"). +- You typically create one on the owner side via SharedWatchableValue.makeFromExisting(rpc, someWatchableValue). That sets up change listeners that forward updates across the RPC. +- On the counterpart side, the constructor builds a WatchableValue and wires up a handler for a CHANGED_RPC_METHOD_ID so that remote changes update the local WatchableValue (lines ~45–51, 58–74, 104–109). + +This is the building block used by withSharedVisibility and also elsewhere when a simple shared scalar or object needs to stay in sync. + +--- + +### Putting it all together: a typical flow + +Let’s walk through a concrete case from annotations (simplified): + +1) Frontend creates an owner object + - class AnnotationLayerSharedObject extends withSharedVisibility(...) is decorated with @registerSharedObjectOwner(ANNOTATION_RENDER_LAYER_RPC_ID). + - Its constructor calls initializeCounterpart(this.chunkManager.rpc, { source: source.rpcId, segmentationStates: ..., visibility: SharedWatchableValue.makeFromExisting(...).rpcId }) + +2) RPC constructs the backend counterpart + - The owner call triggers rpc.invoke("SharedObject.new", { id, type: ANNOTATION_RENDER_LAYER_RPC_ID, ...options }). + - On the backend, the SharedObject.new handler looks up the registered constructor for that id (registered by @registerSharedObject on the backend class) and constructs it with (rpc, options). + - The backend counterpart receives options.visibility as a reference id and does rpc.get(options.visibility) to obtain the SharedWatchableValue handle for ongoing updates. + +3) Runtime updates + - If frontend changes visibility, SharedWatchableValue sends a CHANGED message; backend’s handler updates its copy and may reprioritize chunk requests. + - If backend needs to respond with progress or results, it uses RPC handlers or registerPromiseRPC to return data. + +4) Cleanup + - Any references sent to the other side are stamped with a generation (addCounterpartRef()). When the counterpart’s refcount drops to zero, it notifies the owner (SharedObject.refCountReachedZero). When both sides are done for the current generation and the owner’s own refcount is zero, the owner sends SharedObject.dispose, and both sides free their mapping. + +--- + +### How to define your own shared class (step-by-step) + +- Decide which side “owns” it (the side that will call initializeCounterpart()). +- On the owner class: + - Decorate: @registerSharedObjectOwner("my.unique.type") + - Derive from SharedObject or a mixin that includes it (e.g., withSharedVisibility(SharedObject)). + - In your constructor, call this.initializeCounterpart(rpc, { ...options }) and include any nested shared object ids (e.g., visibility: SharedWatchableValue.makeFromExisting(rpc, myWatchable).rpcId). + +- On the counterpart class (other thread): + - Decorate: @registerSharedObject("my.unique.type") + - Derive from SharedObjectCounterpart or another mixin chain suitable for the backend (e.g., withSharedVisibility(ChunkRequesterBase)). + - In the constructor(rpc, options), read back nested shared objects using rpc.get(options.someSharedId) and wire up listeners. + +- For request/response operations, expose named RPC endpoints: + - registerPromiseRPC("MyType.doThing", function (x, { signal, progressListener }) { … return Promise<{ value, transfers? }>; }) + - From the caller side, await rpc.promiseInvoke("MyType.doThing", { … }, { signal, progressListener }) + +--- + +### Debugging tips + +- Confirm the type id matches on both sides + - The string passed to @registerSharedObject on the counterpart must match the RPC_TYPE_ID of the owner class (or the string you gave to @registerSharedObjectOwner). Mismatches lead to SharedObject.new failing to find a constructor. + +- Check map sizes and ids + - RPC keeps a map of id -> object on each side. If you leak references, numObjects will grow. The debug logs (guarded by DEBUG) can help trace lifecycle. + +- Progress/cancel plumbing + - If you pass a progressListener to promiseInvoke, ensure the backend handler is registered with registerPromiseRPC and that it uses the provided progressListener to add/remove spans. Cancellation will call abortController.abort() on the backend. + +- Be careful with structured clone + - Payloads sent via rpc.invoke must be structured-cloneable. If you need to share a non-cloneable resource, wrap it as a SharedObject and pass ids instead. + +--- + +### Pointers to concrete code you can read next + +- RPC core, SharedObject lifecycle, and decorators: + - src/worker_rpc.ts + +- A minimal, reusable shared value: + - src/shared_watchable_value.ts + +- A realistic composite use (visibility sharing): + - src/visibility_priority/frontend.ts (owner side mixin) + - src/visibility_priority/backend.ts (counterpart side mixin) + +- End-to-end example around a real feature: + - Owner side: src/annotation/renderlayer.ts (AnnotationLayerSharedObject, @registerSharedObjectOwner) + - Counterpart side: src/annotation/backend.ts (classes with @registerSharedObject matching the same ids) + +If you want, tell me which class or feature you plan to modify (e.g., voxel annotation buffering), and I’ll map out the exact owner/counterpart classes and the RPC surface you’ll need to extend. diff --git a/NOTES/annotation-chunk-source-and-sync.md b/NOTES/annotation-chunk-source-and-sync.md new file mode 100644 index 0000000000..bd56437b65 --- /dev/null +++ b/NOTES/annotation-chunk-source-and-sync.md @@ -0,0 +1,147 @@ +### What “chunk sources” are in the annotation system + +In Neuroglancer, rendering and data flow are built around chunked sources: + +- Frontend chunk sources live on the main thread and integrate with rendering, visibility, and GPU upload. +- Backend chunk sources live in a Web Worker and actually fetch/produce the bytes for each chunk. +- The two halves are paired via a small RPC layer. The frontend owner has a type id; the backend counterpart class registers itself under the same id. When the frontend initializes, it requests the backend to construct the counterpart, and they talk by sending messages with ids. + +For annotations, the system uses three closely-related chunk sources on the frontend side (with backend counterparts): +- AnnotationGeometryChunkSource: provides spatially indexed geometry of annotations to draw (slice-view geometry per chunk). +- AnnotationSubsetGeometryChunkSource: a filtered geometry source tied to segmentation relationships; supplies geometry subsets keyed by segment id. +- AnnotationMetadataChunkSource: per-annotation metadata keyed by annotation id (used to keep the value of AnnotationReference in sync). + +These objects are owned on the frontend and mirrored on the backend. They’re coordinated by MultiscaleAnnotationSource, which: +- Holds and wires the three sources together. +- Keeps local references and local-update state for edits (add/update/delete). +- Initializes its counterparts in the worker (passing nested shared-object references, like its metadata/filtered sources and the chunk manager id). + +The voxel_annotation dummy volume you added (VoxDummyChunkSource) uses the same pairing mechanism as the standard volume/annotation sources: the frontend owner sets a shared type id; the backend counterpart registers with the same id and implements download(), which fills chunk.data with a procedurally generated pattern. + + +### Frontend↔Backend synchronization: the RPC pairing + +- On the owner side, classes are decorated with @registerSharedObjectOwner("…ID…"). When they call initializeCounterpart(rpc, options), the RPC sends a SharedObject.new(type=ID, options) message. +- On the worker side, counterpart classes are decorated with @registerSharedObject("…ID…"). The worker’s SharedObject.new handler looks up the constructor for that id and constructs the backend instance. +- Both sides keep ref-counted object handles with a shared numeric id. You can nest references to other shared objects inside options (e.g., pass a MetadataChunkSource id to the backend inside the parent’s initialize payload). + +For annotation commit flow specifically, there are two named RPCs (strings exported from annotation/base): +- ANNOTATION_COMMIT_UPDATE_RPC_ID: frontend→backend to request an add/update/delete commit. +- ANNOTATION_COMMIT_UPDATE_RESULT_RPC_ID: backend→frontend to return success/failure and the updated annotation (or null for deletion). + + +### The annotation edit pipeline (buffering + commit system) + +The key design goal is to show edits immediately on the frontend (optimistic UI), while guaranteeing consistency as the authoritative backend accepts/rejects them. + +1) Local overlay buffering on the frontend +- MultiscaleAnnotationSource maintains: + - references: Map from annotation id to AnnotationReference; each holds the current value and a changed signal for listeners. + - localUpdates: Map from id → LocalUpdateUndoState. Tracks: + - existingAnnotation: the server-committed annotation prior to local edits (if any). + - commitInProgress: the annotation payload (or null for deletion) that has been sent and is awaiting backend result. + - pendingCommit: a queued annotation payload to send after commitInProgress finishes (if the user edited again before the prior commit returned). + - temporary: an in-memory “temporary geometry chunk” overlay that stores serialized bytes for the edited version of an annotation until the commit completes. + +- When you call add/update/delete (or add followed by commit): + - applyLocalUpdate() moves geometry bytes out of any existing visible geometry chunks (deleteAnnotation from those chunks) and writes the edited geometry into the temporary overlay chunk (updateAnnotation). This ensures rendering immediately reflects the local edit. + - It updates the AnnotationReference.value on the frontend and notifies listeners (notifyChanged), causing render invalidation and UI updates without waiting for the backend. + +2) Sending the commit request +- If commit=true, applyLocalUpdate() either: + - queues the new edit into pendingCommit if a commit is already in-flight for that annotation, or + - calls sendCommitRequest(): + - increments a global commit-in-progress counter (used to show a StatusMessage like “Committing annotations”). + - sets commitInProgress to the payload. + - invokes ANNOTATION_COMMIT_UPDATE_RPC_ID with { id: this.rpcId, annotationId?, newAnnotation? }: + - annotationId undefined + newAnnotation → add + - annotationId set + newAnnotation → update + - annotationId set + newAnnotation null → delete + +3) Backend receives commit +- The worker-side registerRPC(ANNOTATION_COMMIT_UPDATE_RPC_ID, …) handler looks up the AnnotationSource counterpart object from x.id and dispatches to obj.add/delete/update as appropriate. Those methods are expected to return a Promise with the outcome. +- Once resolved, it invokes ANNOTATION_COMMIT_UPDATE_RESULT_RPC_ID to the frontend with { id, annotationId, newAnnotation | error }. Note there’s a FIXME in the backend handler: “Handle new chunks requested prior to update but not yet sent to frontend.” This is a hint that the backend does not yet buffer/resynchronize in-flight visible-chunk streams vs. the commit result; the frontend overlay is the primary buffering mechanism for edits. + +4) Frontend applies commit result +- The frontend registerRPC(ANNOTATION_COMMIT_UPDATE_RESULT_RPC_ID, …) handler calls either handleSuccessfulUpdate or handleFailedUpdate. + +- On success (handleSuccessfulUpdate): + - Decrement the global commit counter and potentially clear the “Committing annotations” StatusMessage. + - If the server returned a new id (common on add), re-key all local state: + - Update AnnotationReference.id and references map entries. + - If there is an overlay entry in the temporary chunk, delete the old-id overlay and write a new overlay with the updated id. + - Set existingAnnotation to the newAnnotation (or undefined if null), clear commitInProgress. + - If there was a pendingCommit queued during the in-flight commit, update its id to the returned id (if needed) and immediately send a new commit request. Otherwise, revert the local overlay to finish the cycle (see below). + +- On failure (handleFailedUpdate): + - Show an error StatusMessage. + - Revert local overlay and references (revertLocalUpdate): + - Remove any edited overlay geometry for this id from the temporary chunk. + - If there was an existingAnnotation, add its geometry back into visible geometry chunks (updateAnnotation for those chunks) so the display matches server state. + - Restore AnnotationReference.value to existingAnnotation (or null) and dispatch its changed signal. + - Decrement the global commit counter. + +5) Reverting overlay after a successful cycle +- If there is no pending commit, revertLocalUpdate() is called to remove the overlay and restore the world to a “no local edits pending” state. Since existingAnnotation has already been updated to the committed version, the visible chunks + metadata now represent the committed data, and the temporary overlay can be dropped. + +6) Metadata sync for live references +- MetadataChunkSource is used so that references.get(id) consumers stay synced: when a metadata chunk arrives for an id, AnnotationMetadataChunkSource.addChunk sets the associated AnnotationReference.value and dispatches changed. +- notifyChanged() is also called whenever local overlay changes the value, so UI stays responsive. + + +### How chunk streaming and visibility interact with edits + +- Geometry chunks are streamed independently of commits. The backend recomputes priorities for visible annotation chunks based on the view, and for each needed chunk requests it from the appropriate backend geometry source (spatially indexed or subset by segmentation). When bytes arrive, the frontend replaces or updates the corresponding chunk’s AnnotationGeometryData. +- The frontend overlay logic in temporary ensures local edits appear immediately, regardless of when backend geometry chunks stream in. The overlay is kept separate from streamed chunks and is applied/removed deterministically during the commit flow. +- A note in backend commit handling acknowledges a potential race: a chunk could be requested based on an outdated state. The overlay strategy on the frontend is what guarantees the user sees their edits; any mismatches are corrected as commits resolve and overlay is removed. + + +### The buffering model in a nutshell + +- Frontend buffering: a dedicated temporary chunk stores serialized geometry for locally edited annotations. It is immediately read by the renderer to display edits. This buffer is the single source of truth for in-flight user edits. +- Queuing and coalescing: if an edit for the same annotation happens while a commit is in-flight, the new payload is queued in pendingCommit. As soon as the in-flight commit returns, the queued payload is updated with the authoritative id (if needed) and is sent immediately. This effectively debounces rapid user edits into a linear sequence of commits without losing intermediate UI responsiveness. +- Backend buffering: the backend does not do significant edit buffering; it executes add/update/delete and returns results. The FIXME suggests future work could better correlate pre-commit chunk requests with post-commit state, but the current design relies on the frontend overlay to mask such transitions. + + +### Where to look in code (ready-made pointers) + +Frontend (src/annotation/frontend_source.ts): +- MultiscaleAnnotationSource + - applyLocalUpdate() — creates/updates the local overlay, manages pending/active commit flags. + - sendCommitRequest() — sends ANNOTATION_COMMIT_UPDATE_RPC_ID and marks commitInProgress. + - handleSuccessfulUpdate() — applies server result, re-keys ids, chains pending commits, and reverts overlay when done. + - handleFailedUpdate() — shows error, reverts overlay to the last committed state. + - revertLocalUpdate() — the overlay/undo routine. + - notifyChanged() — synchronizes AnnotationReference and invalidates rendering. +- AnnotationGeometryChunkSource, AnnotationSubsetGeometryChunkSource, AnnotationMetadataChunkSource — the three chunk sources used by the layer to render and to keep references synced. + +Backend (src/annotation/backend.ts): +- registerRPC(ANNOTATION_COMMIT_UPDATE_RPC_ID, …) — receives commit requests, routes them to add/update/delete, sends result via ANNOTATION_COMMIT_UPDATE_RESULT_RPC_ID. +- AnnotationSpatiallyIndexedRenderLayerBackend.recomputeChunkPriorities() — visibility-driven chunk scheduling that requests geometry chunks. + + +### Relation to your VoxDummyChunkSource + +Your voxel_annotation VoxDummyChunkSource mirrors the standard infrastructure used above: +- Frontend owner: VoxDummyChunkSource (src/voxel_annotation/frontend.ts) extends volume/frontend VolumeChunkSource and is annotated with @registerSharedObjectOwner(VOX_DUMMY_CHUNK_SOURCE_RPC_ID). +- Backend counterpart: VoxDummyChunkSource (src/voxel_annotation/backend.ts) extends volume/backend VolumeChunkSource and is decorated with @registerSharedObject(VOX_DUMMY_CHUNK_SOURCE_RPC_ID). It implements download() to fill chunk.data with a checkerboard pattern. +- The RPC pairing and chunk lifecycle are the same: frontend requests visible chunks, backend download() produces bytes, they’re transferred back and uploaded to GPU by the frontend’s format handler; rendering samples those textures in your custom render layer. + + +### Practical implications for modifying or extending the commit/buffering logic + +- To change how many edits can be coalesced: adjust logic around pendingCommit and commitInProgress in applyLocalUpdate, handleSuccessfulUpdate, and sendCommitRequest. The current model serializes edits: one in-flight + at most one queued per annotation id. You could extend it to keep a small queue and squash updates. +- To draw overlay differently (e.g., highlight uncommitted edits): modify how the temporary chunk is fed into the shader/render mix. Today, temporary bytes are written in a separate chunk object; your render layer or geometry-data upload path could add a visual flag. +- To ensure consistency with streaming chunks: if you need stronger guarantees that streamed chunks reflect post-commit state, you could implement a small backend-side buffer or generation tracking in the annotation geometry sources, then drop or re-request chunks when a commit completes. +- To wire new properties into commit: extend AnnotationPropertySerializer and the serialize/deserialize paths used by updateAnnotation/deleteAnnotation/computeNumPickIds. + + +### TL;DR flow + +- User edits → frontend immediately updates a “temporary” overlay chunk and updates the AnnotationReference value; UI responds instantly. +- If commit requested → frontend sends ANNOTATION_COMMIT_UPDATE_RPC_ID to worker, marks commitInProgress; subsequent edit on same id sets pendingCommit. +- Backend performs add/update/delete; returns via ANNOTATION_COMMIT_UPDATE_RESULT_RPC_ID. +- Frontend success: re-key ids if needed, chain any pendingCommit, or revert overlay to the committed state; failure: revert overlay to prior committed state and show error. +- Meanwhile, visible annotation geometry chunks stream independently; the overlay ensures visual correctness during the transition. + +If you want, I can also trace the exact WebGL upload path for AnnotationGeometryData and where the temporary overlay’s bytes are combined with streamed chunks at draw time, or sketch how to add a visual “pending commit” tint to uncommitted annotations. diff --git a/NOTES/chunk-source.md b/NOTES/classExplanations/chunk-source.md similarity index 100% rename from NOTES/chunk-source.md rename to NOTES/classExplanations/chunk-source.md diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 0abe757dc1..f0dec95393 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import "#src/layer/vox/style.css"; + import type { CoordinateTransformSpecification } from "#src/coordinate_transform.js"; import { makeCoordinateSpace, makeIdentityTransform, WatchableCoordinateSpaceTransform } from "#src/coordinate_transform.js"; import type { DataSourceSpecification } from "#src/datasource/index.js"; @@ -26,15 +28,108 @@ import { RenderScaleHistogram, trackableRenderScaleTarget } from "#src/render_sc import { VoxelPixelLegacyTool, registerVoxelAnnotationTools } from "#src/ui/voxel_annotations.js"; import type { Borrowed } from "#src/util/disposable.js"; import { DummyMultiscaleVolumeChunkSource } from "#src/voxel_annotation/dummy_volume_chunk_source.js"; +import { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; import { VoxelAnnotationRenderLayer } from "#src/voxel_annotation/renderlayer.js"; import { Tab } from "#src/widget/tab_view.js"; -class VoxHelloTab extends Tab { - constructor() { +class VoxSettingsTab extends Tab { + constructor(public layer: VoxUserLayer) { super(); const { element } = this; - element.classList.add("neuroglancer-vox-hello-tab"); - element.textContent = "Hello world"; + element.classList.add("neuroglancer-vox-settings-tab"); + + const row = (label: string, inputs: HTMLElement[]) => { + const div = document.createElement("div"); + div.className = "neuroglancer-vox-row"; + const lab = document.createElement("label"); + lab.textContent = label; + lab.style.display = "inline-block"; + lab.style.width = "140px"; + div.appendChild(lab); + for (const inp of inputs) { + inp.classList.add("neuroglancer-vox-input"); + inp.setAttribute("size", "8"); + div.appendChild(inp); + } + return div; + }; + + const makeNumberInput = (value: number, step: string) => { + const inp = document.createElement("input"); + inp.type = "number"; + inp.step = step; + inp.value = String(value); + return inp; + }; + + const sMeters = this.layer.voxScale; // stored in meters + const b = this.layer.voxUpperBound; + + // Unit helpers + const unitFactor: Record = { m: 1, mm: 1e-3, "µm": 1e-6, nm: 1e-9 }; + const currentUnit = this.layer.voxScaleUnit in unitFactor ? this.layer.voxScaleUnit : "m"; + const factor = (u: string) => unitFactor[u] ?? 1; + + // Prepare UI elements + const unitSel = document.createElement("select"); + for (const u of ["m", "mm", "µm", "nm"]) { + const opt = document.createElement("option"); + opt.value = u; + opt.textContent = u; + if (u === currentUnit) opt.selected = true; + unitSel.appendChild(opt); + } + let prevUnit = currentUnit; + + // Show scale values in the chosen unit for convenience + const sx = makeNumberInput(sMeters[0] / factor(currentUnit), "any"); + const sy = makeNumberInput(sMeters[1] / factor(currentUnit), "any"); + const sz = makeNumberInput(sMeters[2] / factor(currentUnit), "any"); + + const bx = makeNumberInput(b[0], "1"); + const by = makeNumberInput(b[1], "1"); + const bz = makeNumberInput(b[2], "1"); + + element.appendChild(row("Scale (x,y,z)", [sx, sy, sz])); + element.appendChild(row("Scale unit", [unitSel])); + element.appendChild(row("Upper bounds (x,y,z)", [bx, by, bz])); + + // When unit changes, rescale the displayed numbers to preserve physical value in meters + unitSel.addEventListener("change", () => { + const newU = unitSel.value; + const conv = factor(prevUnit) / factor(newU); + // Update the input values in-place + const x = Number.parseFloat(sx.value); + const y = Number.parseFloat(sy.value); + const z = Number.parseFloat(sz.value); + if (Number.isFinite(x)) sx.value = String(x * conv); + if (Number.isFinite(y)) sy.value = String(y * conv); + if (Number.isFinite(z)) sz.value = String(z * conv); + prevUnit = newU; + }); + + const apply = document.createElement("button"); + apply.textContent = "Apply"; + apply.addEventListener("click", () => { + const u = unitSel.value || currentUnit; + const f = factor(u); + // Convert user-entered values back to meters + const sxNum = Number.parseFloat(sx.value); + const syNum = Number.parseFloat(sy.value); + const szNum = Number.parseFloat(sz.value); + const ns = new Float64Array([ + Number.isFinite(sxNum) ? sxNum * f : sMeters[0], + Number.isFinite(syNum) ? syNum * f : sMeters[1], + Number.isFinite(szNum) ? szNum * f : sMeters[2], + ]); + const nb = new Float32Array([ + Math.max(1, Math.floor(Number(bx.value) || this.layer.voxUpperBound[0])), + Math.max(1, Math.floor(Number(by.value) || this.layer.voxUpperBound[1])), + Math.max(1, Math.floor(Number(bz.value) || this.layer.voxUpperBound[2])), + ]); + this.layer.applyVoxSettings(ns, u, nb); + }); + element.appendChild(apply); } } @@ -63,13 +158,20 @@ export class VoxUserLayer extends UserLayer { sliceViewRenderScaleTarget = trackableRenderScaleTarget(1); static type = "vox"; static typeAbbreviation = "vox"; + voxEditController?: VoxelEditController; + + // Settings state + voxScale: Float64Array = new Float64Array([0.000000008, 0.000000008, 0.000000008]); + voxScaleUnit: string = "nm"; + voxUpperBound: Float32Array = new Float32Array([1_000_000, 1_000_000, 1_000_000]); + private voxLoadedSubsource?: LoadedDataSubsource; constructor(managedLayer: Borrowed) { super(managedLayer); this.tabs.add("vox", { label: "Voxel", order: 0, - getter: () => new VoxHelloTab(), + getter: () => new VoxSettingsTab(this), }); this.tabs.add("vox_tools", { label: "Draw", @@ -79,6 +181,67 @@ export class VoxUserLayer extends UserLayer { this.tabs.default = "vox"; } + applyVoxSettings(scale: Float64Array, unit: string, upperBound: Float32Array) { + // Update and rebuild if values changed. + let changed = false; + for (let i = 0; i < 3; ++i) { + if (this.voxScale[i] !== scale[i]) { this.voxScale[i] = scale[i]; changed = true; } + if (this.voxUpperBound[i] !== upperBound[i]) { this.voxUpperBound[i] = upperBound[i]; changed = true; } + } + if (this.voxScaleUnit !== unit) { this.voxScaleUnit = unit; changed = true; } + if (changed) this.buildOrRebuildVoxLayer(); + } + + private buildOrRebuildVoxLayer() { + const ls = this.voxLoadedSubsource; + if (!ls) return; + const guardScale = Array.from(this.voxScale); + const guardBounds = Array.from(this.voxUpperBound); + const guardUnit = this.voxScaleUnit; + ls.activate(() => { + const dummySource = new DummyMultiscaleVolumeChunkSource( + this.manager.chunkManager, + { + chunkDataSize: new Uint32Array([64, 64, 64]), + upperVoxelBound: this.voxUpperBound, + }, + ); + // Expose a controller so tools can paint voxels via the source. + this.voxEditController = new VoxelEditController(dummySource); + + // Build transform with current scale and units. + const units = [this.voxScaleUnit, this.voxScaleUnit, this.voxScaleUnit] as string[]; + const identity3D = new WatchableCoordinateSpaceTransform( + makeIdentityTransform( + makeCoordinateSpace({ + rank: 3, + names: ["x", "y", "z"], + units, + scales: new Float64Array(this.voxScale), + }), + ), + ); + const transform = getWatchableRenderLayerTransform( + this.manager.root.coordinateSpace, + this.localPosition.coordinateSpace, + identity3D, + undefined, + ); + + ls.addRenderLayer( + new VoxelAnnotationRenderLayer( + dummySource, + { + transform: transform as any, + renderScaleTarget: this.sliceViewRenderScaleTarget, + renderScaleHistogram: undefined, + localPosition: this.localPosition, + } as any, + ), + ); + }, guardScale, guardBounds, guardUnit); + } + getLegacyDataSourceSpecifications( sourceSpec: string | undefined, layerSpec: any, @@ -110,47 +273,9 @@ export class VoxUserLayer extends UserLayer { const { subsourceEntry } = loadedSubsource; const { subsource } = subsourceEntry; if (subsource.local === LocalDataSource.voxelAnnotations) { - // Accept this data source; no render layers yet. - loadedSubsource.activate(() => { - console.log('Activating voxel annotation data subsource.'); - const dummySource = new DummyMultiscaleVolumeChunkSource( - this.manager.chunkManager, - ); - loadedSubsource.addRenderLayer( - new VoxelAnnotationRenderLayer( - dummySource, - { - // IMPORTANT: Use an explicit 3D identity model transform, then convert it to a - // WatchableRenderLayerTransform. In this project, relying on the subsource-provided - // transform for local://voxel-annotations can yield a rank-0/ambiguous mapping and - // hide the chunk sources, meaning the checkerboard shader is never invoked. The - // identity 3D model space ensures proper detection and visibility of our dummy - // volume chunks while still integrating with global/local spaces. - transform: ((): any => { - const identity3D = new WatchableCoordinateSpaceTransform( - makeIdentityTransform( - makeCoordinateSpace({ - rank: 3, - names: ["x", "y", "z"], - units: ["", "", ""], - scales: new Float64Array([0.000001, 0.000001, 0.000001]), - }), - ), - ); - return getWatchableRenderLayerTransform( - this.manager.root.coordinateSpace, - this.localPosition.coordinateSpace, - identity3D, - undefined, - ); - })(), - renderScaleTarget: this.sliceViewRenderScaleTarget, - renderScaleHistogram: undefined, - localPosition: this.localPosition, - } as any, - ), - ); - }); + // Accept this data source; remember it and build the layer from current settings. + this.voxLoadedSubsource = loadedSubsource; + this.buildOrRebuildVoxLayer(); continue; } loadedSubsource.deactivate( diff --git a/src/layer/vox/style.css b/src/layer/vox/style.css new file mode 100644 index 0000000000..d95b573ca1 --- /dev/null +++ b/src/layer/vox/style.css @@ -0,0 +1,98 @@ +:root { + /* best-effort variables if app doesn't define them */ + --ng-bg: rgba(20, 22, 27, 0.9); + --ng-card: rgba(255, 255, 255, 0.04); + --ng-border: rgba(255, 255, 255, 0.15); + --ng-text: rgba(230, 230, 235, 0.95); + --ng-muted: rgba(230, 230, 235, 0.65); + --ng-accent: #3a6df0; +} + +.neuroglancer-vox-settings-tab, +.neuroglancer-vox-tools-tab { + box-sizing: border-box; + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 10px; + color: var(--ng-text); + max-width: 100%; + overflow-x: hidden; +} + +.neuroglancer-vox-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + padding: 6px 8px; + background: var(--ng-card); + border: 1px solid var(--ng-border); + border-radius: 6px; + width: 100%; + box-sizing: border-box; +} + +.neuroglancer-vox-row label { + flex: 0 0 140px; /* fixed column for label */ + min-width: 0; + font-weight: 500; + color: var(--ng-muted); +} + +.neuroglancer-vox-input, +.neuroglancer-vox-settings-tab select { + box-sizing: border-box; + flex: 1 1 8.5em; /* prefer ~8.5em but allow shrink/grow */ + min-width: 0; /* critical to allow shrinking within flex rows */ + width: auto; + max-width: 100%; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--ng-border); + background: rgba(255, 255, 255, 0.06); + color: var(--ng-text); + outline: none; +} + +/* Ensure any non-label child in a row can shrink and wrap instead of forcing horizontal overflow */ +.neuroglancer-vox-row > :not(label) { + flex: 1 1 0; + min-width: 0; +} + +.neuroglancer-vox-input:focus, +.neuroglancer-vox-settings-tab select:focus { + border-color: color-mix(in oklab, var(--ng-accent) 60%, var(--ng-border)); + box-shadow: 0 0 0 2px color-mix(in oklab, var(--ng-accent) 25%, transparent); +} + +.neuroglancer-vox-settings-tab button, +.neuroglancer-vox-tools-tab button { + align-self: flex-start; + padding: 8px 12px; + border-radius: 6px; + border: 1px solid color-mix(in oklab, var(--ng-accent) 55%, var(--ng-border)); + background: linear-gradient(180deg, + color-mix(in oklab, var(--ng-accent) 88%, #2a2a2a) 0%, + color-mix(in oklab, var(--ng-accent) 70%, #1f1f1f) 100%); + color: #fff; + cursor: pointer; + transition: filter 120ms ease, transform 60ms ease; +} + +.neuroglancer-vox-settings-tab button:hover, +.neuroglancer-vox-tools-tab button:hover { + filter: brightness(1.06); +} + +.neuroglancer-vox-settings-tab button:active, +.neuroglancer-vox-tools-tab button:active { + transform: translateY(1px); +} + +.neuroglancer-vox-toolbox { + display: flex; + flex-wrap: wrap; + gap: 8px; +} diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 52dbbce0b9..2840e8c540 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -136,6 +136,17 @@ export class VoxelPixelLegacyTool extends LegacyTool { console.log( `Mouse: ${mousePosStr} | Zoom: ${zoomStr} | Viewport scale: ${imageScaleParts.join(", ")}`, ); + + // Connect to the controller: paint a voxel under the mouse if we have voxel coords. + if (mouseState?.active && pos && cs) { + const vx = Math.floor(pos[0] ?? 0); + const vy = Math.floor(pos[1] ?? 0); + const vz = Math.floor((pos[2] as number | undefined) ?? 0); + (this.layer as any).voxEditController?.paintVoxel( + new Float32Array([vx, vy, vz]), + 42, + ); + } } catch (e) { console.log("[VoxelPixelLegacyTool] Error computing info:", e); } diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 7295239d56..d73f3cf0f8 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -6,7 +6,7 @@ import type { VolumeChunk } from '#src/sliceview/volume/backend.js'; import { VolumeChunkSource as BaseVolumeChunkSource } from '#src/sliceview/volume/backend.js'; import { DataType } from '#src/util/data_type.js'; -import { VOX_DUMMY_CHUNK_SOURCE_RPC_ID } from "#src/voxel_annotation/base.js"; +import { VOX_CHUNK_SOURCE_RPC_ID } from "#src/voxel_annotation/base.js"; import type { RPC } from '#src/worker_rpc.js'; import { registerSharedObject } from '#src/worker_rpc.js'; @@ -15,8 +15,8 @@ import { registerSharedObject } from '#src/worker_rpc.js'; * Minimal backend volume source that procedurally generates data for voxel annotations demo. * It fills chunk.data with a simple pattern (checkerboard based on voxel coords). */ -@registerSharedObject(VOX_DUMMY_CHUNK_SOURCE_RPC_ID) -export class VoxDummyChunkSource extends BaseVolumeChunkSource { +@registerSharedObject(VOX_CHUNK_SOURCE_RPC_ID) +export class VoxChunkSource extends BaseVolumeChunkSource { constructor(rpc: RPC, options: any) { super(rpc, options); } @@ -44,7 +44,7 @@ export class VoxDummyChunkSource extends BaseVolumeChunkSource { const gz = origin[2] + z; // Checker pattern in world space with large squares const square = ((Math.floor(gx / 16) + Math.floor(gy / 16) + Math.floor(gz / 16)) & 1) !== 0; - array[index] = square ? 255 : 0; + array[index] = square ? 5 : 0; } } } diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index 76f5a354bd..9e8830311e 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -1,2 +1 @@ -// RPC id for the vox dummy chunk source -export const VOX_DUMMY_CHUNK_SOURCE_RPC_ID = 'vox.VoxDummyChunkSource'; +export const VOX_CHUNK_SOURCE_RPC_ID = 'vox.VoxChunkSource'; diff --git a/src/voxel_annotation/dummy_volume_chunk_source.ts b/src/voxel_annotation/dummy_volume_chunk_source.ts index ace388d587..0a03659b45 100644 --- a/src/voxel_annotation/dummy_volume_chunk_source.ts +++ b/src/voxel_annotation/dummy_volume_chunk_source.ts @@ -22,7 +22,7 @@ import { MultiscaleVolumeChunkSource, } from '#src/sliceview/volume/frontend.js'; import { DataType } from '#src/util/data_type.js'; -import { VoxDummyChunkSource } from '#src/voxel_annotation/frontend.js'; +import { VoxChunkSource } from '#src/voxel_annotation/frontend.js'; /** * This is an abstract representation of 3D (volumetric) data that can exist at multiple resolutions or "scales." @@ -36,6 +36,11 @@ import { VoxDummyChunkSource } from '#src/voxel_annotation/frontend.js'; * - Chunking: Data is divided into smaller, manageable 3D blocks (chunks) to optimize loading and memory usage. * - Asynchronous: Data loading is typically asynchronous, as it might involve fetching from a remote server or reading from large local files. */ +export interface DummyMultiscaleOptions { + chunkDataSize?: Uint32Array | number[]; + upperVoxelBound?: Float32Array | number[]; +} + export class DummyMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSource { dataType = DataType.UINT32; volumeType = VolumeType.SEGMENTATION; @@ -43,38 +48,83 @@ export class DummyMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSourc return 3; } - constructor(chunkManager: ChunkManager) { + private cfgChunkDataSize: Uint32Array; + private cfgUpperVoxelBound: Float32Array; + + constructor(chunkManager: ChunkManager, options?: DummyMultiscaleOptions) { super(chunkManager); + this.cfgChunkDataSize = new Uint32Array( + options?.chunkDataSize ? Array.from(options.chunkDataSize) : [64, 64, 64], + ); + this.cfgUpperVoxelBound = new Float32Array( + options?.upperVoxelBound ? Array.from(options.upperVoxelBound) : [1_000, 1_000, 1_000], + ); } getSources(_options: VolumeSourceOptions) { - // Provide a single-scale, single-orientation dummy source. + // Provide a base scale and a coarse "guard" scale to avoid memory blowups at extreme zoom out. const rank = this.rank; - const chunkDataSize = new Uint32Array([32, 32, 32]); - const upperVoxelBound = new Float32Array([1024, 1024, 1024]); - const spec = makeVolumeChunkSpecification({ + // Base (fine) scale specification. + const baseSpec = makeVolumeChunkSpecification({ rank, dataType: this.dataType, - chunkDataSize, - upperVoxelBound, + chunkDataSize: this.cfgChunkDataSize, + upperVoxelBound: this.cfgUpperVoxelBound, }); + const baseSource: VoxChunkSource = this.chunkManager.getChunkSource( + VoxChunkSource as any, + { spec: baseSpec }, + ); - const chunkSource: VoxDummyChunkSource = this.chunkManager.getChunkSource(VoxDummyChunkSource as any, { spec }); - + // Identity transform for base scale. const identity = new Float32Array((rank + 1) * (rank + 1)); for (let i = 0; i < rank; ++i) { identity[i * (rank + 1) + i] = 1; } identity[rank * (rank + 1) + rank] = 1; - const single: SliceViewSingleResolutionSource = { - chunkSource, + const base: SliceViewSingleResolutionSource = { + chunkSource: baseSource, chunkToMultiscaleTransform: identity, - lowerClipBound: spec.lowerVoxelBound, - upperClipBound: spec.upperVoxelBound, + lowerClipBound: baseSpec.lowerVoxelBound, + upperClipBound: baseSpec.upperVoxelBound, }; - // Outer array: orientations. Inner: scales (just one). - return [[single]]; + + // Coarse guard scale: no chunks will be created (zero-sized bounds) but it will be selected + // at extremely low zoom levels due to a very large voxel scale transform. + const guardSpec = makeVolumeChunkSpecification({ + rank, + dataType: this.dataType, + // Use same chunk size; since bounds are empty below, no chunks are actually requested. + chunkDataSize: this.cfgChunkDataSize, + // Zero-sized bounds => lowerChunkBound === upperChunkBound, therefore 0 chunks. + upperVoxelBound: new Float32Array(rank), + lowerVoxelBound: new Float32Array(rank), + }); + + const guardSource: VoxChunkSource = this.chunkManager.getChunkSource( + VoxChunkSource as any, + { spec: guardSpec }, + ); + + // Large diagonal scale to make effective voxel size huge, ensuring guard scale is used when + // zoomed out. Homogeneous (rank+1)x(rank+1) matrix. + const scale = 1 << 3; + const guardXform = new Float32Array((rank + 1) * (rank + 1)); + for (let i = 0; i < rank; ++i) { + guardXform[i * (rank + 1) + i] = scale; + } + guardXform[rank * (rank + 1) + rank] = 1; + + const guard: SliceViewSingleResolutionSource = { + chunkSource: guardSource, + chunkToMultiscaleTransform: guardXform, + lowerClipBound: guardSpec.lowerVoxelBound, + upperClipBound: guardSpec.upperVoxelBound, + }; + + // Outer array: orientations. Inner array: scales ordered from finest -> coarsest. + return [[base, guard]]; } } diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts new file mode 100644 index 0000000000..4310dc3258 --- /dev/null +++ b/src/voxel_annotation/edit_controller.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2025. + */ + +import type { MultiscaleVolumeChunkSource } from '#src/sliceview/volume/frontend.js'; +import type { VoxChunkSource } from '#src/voxel_annotation/frontend.js'; + +/** Tiny controller to forward voxel edits from tools to the VoxChunkSource. */ +export class VoxelEditController { + constructor(private multiscale: MultiscaleVolumeChunkSource) {} + + paintVoxel(voxel: Float32Array, value: number) { + try { + const sources2D = this.multiscale.getSources({} as any); + const single = sources2D?.[0]?.[0]; + const source = single?.chunkSource as VoxChunkSource | undefined; + source?.paintVoxel(voxel, value); + } catch { + // no-op + } + } +} diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index bcc59b89d4..fcb27e1702 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -3,20 +3,156 @@ * Copyright 2025. */ +import { ChunkState } from '#src/chunk_manager/base.js'; import type { ChunkManager } from '#src/chunk_manager/frontend.js'; import type { VolumeChunkSpecification } from '#src/sliceview/volume/base.js'; +import type { VolumeChunk } from '#src/sliceview/volume/frontend.js'; import { VolumeChunkSource as BaseVolumeChunkSource } from '#src/sliceview/volume/frontend.js'; -import { VOX_DUMMY_CHUNK_SOURCE_RPC_ID } from '#src/voxel_annotation/base.js'; +import type { TypedArray } from '#src/util/array.js'; +import { VOX_CHUNK_SOURCE_RPC_ID } from '#src/voxel_annotation/base.js'; +import { registerSharedObjectOwner } from "#src/worker_rpc.js"; + +/** Small sparse overlay storing per-chunk edits as index->value maps. */ +class VoxelEditOverlay { + private edits = new Map>(); + + applyEdit(key: string, localIndex: number, value: number) { + let m = this.edits.get(key); + if (!m) { + m = new Map(); + this.edits.set(key, m); + } + m.set(localIndex, value); + console.log(`Applied edit to chunk ${key} at index ${localIndex} with value ${value}`); + } + + mergeIntoChunkData(key: string, baseArray: TypedArray) { + const m = this.edits.get(key); + if (!m) return; + for (const [idx, val] of m) { + if (idx >= 0 && idx < baseArray.length) { + (baseArray as any)[idx] = val as any; + console.log(`Merged edit into chunk ${key} at index ${idx} with value ${val}`); + } + } + console.log(`Merged edits into chunk ${key}`); + } + + getOverlayValue(key: string, localIndex: number): number | undefined { + const m = this.edits.get(key); + return m?.get(localIndex); + } + + discardChunk(key: string) { + this.edits.delete(key); + } + + clear() { + this.edits.clear(); + } +} /** - * Frontend owner for VoxDummyChunkSource. It simply sets the RPC_TYPE_ID so the backend - * counterpart created in the worker matches our vox implementation that synthesizes data. + * Frontend owner for VoxChunkSource, extended with a local optimistic edit overlay. */ -export class VoxDummyChunkSource extends BaseVolumeChunkSource { +@registerSharedObjectOwner(VOX_CHUNK_SOURCE_RPC_ID) +export class VoxChunkSource extends BaseVolumeChunkSource { + private overlay = new VoxelEditOverlay(); + private tempVoxChunkGridPosition = new Float32Array(3); + private tempLocalPosition = new Uint32Array(3); + constructor(chunkManager: ChunkManager, options: { spec: VolumeChunkSpecification }) { super(chunkManager, options); } -} -// Register owner type id so ChunkManager can initialize the correct backend counterpart. -(VoxDummyChunkSource as any).prototype.RPC_TYPE_ID = VOX_DUMMY_CHUNK_SOURCE_RPC_ID; + /** Patch newly added chunks with any overlayed voxels and force re-upload. */ + override addChunk(key: string, chunk: VolumeChunk) { + super.addChunk(key, chunk); + const baseArray = this.getCpuArrayForChunk(chunk); + if (baseArray) { + this.overlay.mergeIntoChunkData(key, baseArray); + this.invalidateChunkUpload(chunk); + } + } + + /** Public paint API called by the tool/controller. */ + paintVoxel(voxel: Float32Array, value: number) { + const { key, localIndex } = this.computeChunkKeyAndIndex(voxel); + if (localIndex < 0) return; + this.overlay.applyEdit(key, localIndex, value); + const chunk = this.chunks.get(key) as VolumeChunk | undefined; + if (chunk) { + const baseArray = this.getCpuArrayForChunk(chunk); + if (baseArray) { + // Merge and mark for re-upload. + this.overlay.mergeIntoChunkData(key, baseArray); + this.invalidateChunkUpload(chunk); + } + } + console.log("Painted voxel at ", chunk, " to value ", value); + // Request redraw. + this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); + } + + /** getValueAt that respects overlay if present. */ + override getValueAt(chunkPosition: Float32Array, channelAccess: any) { + // Compute key and local position based on the provided chunkPosition in voxel coordinates. + const rank = this.spec.rank; + const keyParts: number[] = []; + const local = this.tempLocalPosition; + const { chunkDataSize } = this.spec; + for (let dim = 0; dim < rank; ++dim) { + const voxel = chunkPosition[dim]; + const size = chunkDataSize[dim]; + const c = Math.floor(voxel / size); + keyParts.push(c); + local[dim] = Math.floor(voxel - c * size); + } + const key = keyParts.join(); + const chunk = this.chunks.get(key) as VolumeChunk | undefined; + if (chunk) { + const cds = chunk.chunkDataSize; + if (cds && (local[0] >= cds[0] || local[1] >= cds[1] || local[2] >= cds[2])) { + return undefined; + } + const localIndex = this.localIndexFromLocalPosition(local, cds ?? this.spec.chunkDataSize); + const ov = this.overlay.getOverlayValue(key, localIndex); + if (ov !== undefined) return ov; + } + return super.getValueAt(chunkPosition, channelAccess); + } + + private localIndexFromLocalPosition(local: Uint32Array, size: Uint32Array) { + // (z * sy + y) * sx + x + return (local[2] * size[1] + local[1]) * size[0] + local[0]; + } + + private computeChunkKeyAndIndex(voxel: Float32Array) { + const rank = this.spec.rank; + const { baseVoxelOffset, chunkDataSize } = this.spec as any; + const keyParts = this.tempVoxChunkGridPosition; + const local = this.tempLocalPosition; + for (let i = 0; i < rank; ++i) { + const v = voxel[i] - baseVoxelOffset[i]; + const size = chunkDataSize[i]; + const c = Math.floor(v / size); + keyParts[i] = c; + local[i] = Math.floor(v - c * size); + } + const key = `${keyParts[0]},${keyParts[1]},${keyParts[2]}`; + const chunk = this.chunks.get(key) as VolumeChunk | undefined; + const size = (chunk?.chunkDataSize as Uint32Array) ?? (this.spec.chunkDataSize as Uint32Array); + const localIndex = this.localIndexFromLocalPosition(local, size); + return { key, localIndex }; + } + + private getCpuArrayForChunk(chunk: VolumeChunk): TypedArray | null { + const data = (chunk as any).data as TypedArray | null | undefined; + return (data ?? null); + } + + private invalidateChunkUpload(chunk: VolumeChunk) { + // Force re-upload on next frame. + chunk.state = ChunkState.SYSTEM_MEMORY; + } +} diff --git a/src/voxel_annotation/renderlayer.ts b/src/voxel_annotation/renderlayer.ts index 9d6c42761f..9985f9dac3 100644 --- a/src/voxel_annotation/renderlayer.ts +++ b/src/voxel_annotation/renderlayer.ts @@ -33,7 +33,7 @@ import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; type EmptyParams = Record; export class VoxelAnnotationRenderLayer extends SliceViewVolumeRenderLayer { - // Defensive draw override to avoid crashes if layerInfo is not initialized yet. + override draw(renderContext: any) { const { sliceView } = renderContext; const layerInfo = sliceView.visibleLayers.get(this); @@ -41,6 +41,7 @@ export class VoxelAnnotationRenderLayer extends SliceViewVolumeRenderLayer 0.5 ? vec4(1.0, 1.0, 0.0, 0.5) : vec4(0.0, 1.0, 0.0, 0.5); + float t = float(getDataValue().value); + vec4 color = t > 10.0 ? vec4(1.0, 1.0, 0.0, 1.0) : vec4(clamp(t, 0.0, 1.0), 0.0, 0.0, t > 0.0 ? 0.3 : 0.0); emit(color); `); From 767006cf1a4eda40fd3affa1a5e5a57dc809f791 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:42 +0100 Subject: [PATCH 010/251] feat: pixel tool is now working as intended --- src/voxel_annotation/frontend.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index fcb27e1702..9409d646a8 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -152,7 +152,13 @@ export class VoxChunkSource extends BaseVolumeChunkSource { } private invalidateChunkUpload(chunk: VolumeChunk) { - // Force re-upload on next frame. - chunk.state = ChunkState.SYSTEM_MEMORY; + // Re-upload updated CPU data to the GPU immediately so the chunk continues to render. + const gl = chunk.gl; + if (chunk.state === ChunkState.GPU_MEMORY) { + // Release the old texture before uploading the new data to avoid leaks. + chunk.freeGPUMemory(gl); + } + // Upload the latest CPU-side data and mark the chunk as GPU resident. + chunk.copyToGPU(gl); } } From d94ee5a9a4a6f92233165b12a25c6bdd2b7db762 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:42 +0100 Subject: [PATCH 011/251] feat: fix pixel tool not working when the scale is not equal to the global one (there where a missing convertion) ; add a primitive brush tool --- NOTES/data-saving-plan.md | 400 ++++++++++++++++++++++++ NOTES/first_drawing.png | Bin 0 -> 225375 bytes src/layer/vox/index.ts | 183 ++++++++--- src/ui/voxel_annotations.ts | 148 ++------- src/voxel_annotation/backend.ts | 3 +- src/voxel_annotation/edit_controller.ts | 29 ++ src/voxel_annotation/frontend.ts | 1 - 7 files changed, 592 insertions(+), 172 deletions(-) create mode 100644 NOTES/data-saving-plan.md create mode 100644 NOTES/first_drawing.png diff --git a/NOTES/data-saving-plan.md b/NOTES/data-saving-plan.md new file mode 100644 index 0000000000..8e5225338e --- /dev/null +++ b/NOTES/data-saving-plan.md @@ -0,0 +1,400 @@ +### Goal +Implement backend-side saving for the voxel annotation “map,” mirroring the annotation system’s commit/buffer architecture, add a map-initialization endpoint, and evaluate persistent storage in the browser (WebWorker-accessible) for offline resilience. + +Below is a concrete design that fits the current codebase and follows the attached notes. It references actual files and lines to make implementation straightforward. + +--- + +### What we already have in the repo +- Frontend Vox chunk owner with optimistic edit overlay: + - src/voxel_annotation/frontend.ts + - VoxChunkSource extends a volume chunk source owner and adds an in-memory sparse overlay for immediate visual feedback. + - paintVoxel(voxel, value): updates overlay and triggers re-upload to GPU (L164 lines total; key logic at lines 67–94 and helpers at 124–146). +- Backend Vox chunk counterpart producing procedural data: + - src/voxel_annotation/backend.ts + - VoxChunkSource backend counterpart returns a checkerboard in download() (lines 24–54). +- A dummy multiscale provider and layer hookup: + - src/voxel_annotation/dummy_volume_chunk_source.ts: builds multiscale and returns our frontend VoxChunkSource (lines 64–129). + - src/layer/vox/index.ts: the Vox layer, its settings/draw tabs, and the render layer. It already has UI hooks to rebuild based on scale/bounds (lines 194–253), and a simple toolset that calls VoxelEditController which calls VoxChunkSource.paintVoxel() (src/voxel_annotation/edit_controller.ts lines 10–22 and 24–52; ui tools in src/ui/voxel_annotations.ts lines 26–187). + +This is already close to the “tiered” architecture in the spec: +- Tier 1 (Frontend hot cache): the sparse overlay in src/voxel_annotation/frontend.ts lines 15–52. +- Tier 2 (Worker authoritative map): to be added in backend VoxChunkSource (this proposal). +- Tier 3 (Persistent storage): to be added (this proposal; IndexedDB/OPFS in worker). + +--- + +### High-level design + +#### 1) Worker-side authoritative state (Tier 2) +Add an in-worker map of chunk data keyed by scale and chunk-id, a dirty set, and a debounced saver. + +- Data structures in src/voxel_annotation/backend.ts (backend VoxChunkSource instance): + - mapId: string (unique id for this map instance) + - spec metadata: chunkDataSize, upperVoxelBound, dataType (already in spec) + - voxels: Map where key = `${scaleKey}/${cx},${cy},${cz}` + - dirty: Set of keys needing persistence + - saver: debounced function to flush dirty chunks to persistent storage + +- Chunk keying and scale: + - MVP assumes a single user-selected scale (as per spec §3.1). We can encode that as scaleKey = `${spec.chunkDataSize[0]}_${spec.chunkDataSize[1]}_${spec.chunkDataSize[2]}` or a numeric “scaleId” supplied at init. + - Chunk id format: the grid coords string `${cx},${cy},${cz}` (consistent with the frontend overlay key at src/voxel_annotation/frontend.ts line 141). If you prefer the spec example (ranges like `0-64_0-64_0-64`) we can derive that on persistence, but the grid format is simpler and consistent with running code. + +- download(chunk): + - Compute cx,cy,cz and use key = `${scaleKey}/${cx},${cy},${cz}`. + - Look up voxels.get(key), or lazily allocate a zero-filled Uint32Array sized for the clipped chunkDataSize; store it in the map and return it as chunk.data. + - This makes the worker the authoritative “warm” state backing the streamed chunks. + +- Edit API in worker: + - Implement RPC to handle edits: set-voxel (and later brush/fill batches). For performance, always batch by chunk: payload is { key, edits: Uint32Array or array of [localIndex, value] pairs }. + - Apply into voxels map and mark dirty; schedule saver. + +This mirrors the annotation commit pipeline where the frontend immediately shows edits while the backend is authoritative and persists results (see NOTES/annotation-chunk-source-and-sync.md, esp. the optimistic overlay + commit queue flow at lines 33–105). + +#### 2) Frontend→Worker edit flow (Tier 1→Tier 2) +Keep the current “optimistic overlay” in the frontend, but also send edits to worker as actions: + +- In src/voxel_annotation/frontend.ts VoxChunkSource: + - In paintVoxel(...), after overlay.applyEdit, send an RPC to the backend counterpart with the chunk key and localIndex+value. This is analogous to ANNOTATION_COMMIT_UPDATE_RPC_ID from the notes (lines 28–61) but tailored for voxel edits. + - Batch calls: for brush tool, aggregate per-chunk edits client-side and send one RPC per dirty chunk. + +- RPC identifiers (in a new file src/voxel_annotation/base.ts): + - export const VOX_CHUNK_SOURCE_RPC_ID = 'voxChunkSource'; (already exists and used) + - export const VOX_EDIT_APPLY_RPC_ID = 'vox/edit/apply'; // frontend→worker + - export const VOX_SAVE_STATUS_RPC_ID = 'vox/save/status'; // worker→frontend (optional) + - export const VOX_MAP_INIT_RPC_ID = 'vox/map/init'; // frontend→worker + - export const VOX_MAP_META_RPC_ID = 'vox/map/meta'; // worker→frontend (optional) + +- Semantics: + - VOX_EDIT_APPLY_RPC_ID payload: { id: backendObjectId, key: string, edits: Array<[number /*localIndex*/, number /*value*/]> } + - Worker applies immediately and returns success or throws error. Frontend does not block rendering on this. + +This aligns with the annotation approach: immediate local overlay + a commit-like call to the worker (see notes lines 50–63). + +#### 3) Persistent storage (Tier 3) +localStorage is not available in Web Workers and is synchronous (bad for large data). Recommended options that work in workers: + +- IndexedDB (IDB) + - Available in dedicated workers. Good for large binary blobs. Transactional. + - Store per-chunk ArrayBuffers and a small metadata store for maps. + +- OPFS (Origin Private File System) + - Available in workers (File System Access API). Sync access handle (FileSystemSyncAccessHandle) is worker-only and ideal for chunk files; supports atomic writes. + - Simplifies storing each chunk as its own file under /maps/{mapId}/{scaleKey}/{cx},{cy},{cz}.bin. + +Recommendation: IndexedDB is widely used and integrates well with existing code. OPFS is excellent for very large datasets and low-latency writes if you need it later. I’ll outline IDB now and note where OPFS would plug in similarly. + +IndexedDB schema (db name: 'neuroglancer_vox'): +- objectStore 'maps' (key: mapId: string) → { mapId, createdAt, dataType, chunkDataSize [3], upperVoxelBound [3], unit, scaleKey } +- objectStore 'chunks' (key: `${mapId}:${scaleKey}:${cx},${cy},${cz}`) → ArrayBuffer (Uint32Array.buffer) + optional small header for clipping size. + +Saving strategy: +- Maintain dirty: Set of keys in worker. A debounced saver runs every e.g. 750 ms or when dirty size exceeds e.g. 32 chunks. +- On flush: open a 'chunks' readwrite transaction and put each dirty chunk, then clear them from dirty. +- Crash safety: each put is a separate record; IDB is durable. Optionally store a compact “dirtyIndex” record before and after flush for recovery. + +Loading strategy: +- On download() for a chunk: + - If not present in voxels map, try IDB.get(key). If found, deserialize into a typed array and put into voxels map; otherwise allocate zero array. + - Return typed array as chunk.data. + +Offline behavior: +- Because saving is local (IDB), edits persist without network. If you also have an HTTP backend, you can add a second “cloud sync” layer: write to IDB first, try to POST to server when navigator.onLine, retry later. + +--- + +### Map Initialization Endpoint +We need a way to create a map with user-specified dimensions and scale. The repo currently sets these in the UI (src/layer/vox/index.ts lines 174–177 for scale/unit/bounds) and constructs a DummyMultiscaleVolumeChunkSource (lines 212–219) with chunkDataSize and upperVoxelBound. + +Add a programmatic initialize step between the frontend owner and the worker counterpart: + +- RPC VOX_MAP_INIT_RPC_ID (frontend→worker): + - Request: { id, mapId?: string, dataType: number, chunkDataSize: [x,y,z], upperVoxelBound: [x,y,z], unit: string, scaleKey?: string } + - Behavior: if mapId missing, generate one (e.g., UUID). Store metadata in worker instance and persist to IDB 'maps'. Return { mapId, scaleKey }. + - On subsequent restores, the UI can pass a known mapId to re-open the same dataset. + +- Wire from UI: + - In src/layer/vox/index.ts, VoxUserLayer.applyVoxSettings(...) (lines 194–203) currently rebuilds the layer; extend buildOrRebuildVoxLayer() (lines 205–253) to call a new method on VoxChunkSource owner to initialize the map in the worker. + - Implementation path: + - After creating DummyMultiscaleVolumeChunkSource, call getSources(), take base source, grab its chunkSource (our VoxChunkSource owner instance) and call source.initializeMap(...) which internally calls the RPC. + +- Frontend owner changes (src/voxel_annotation/frontend.ts): + - Add initializeMap(opts) on VoxChunkSource owner which calls rpc.invoke(VOX_MAP_INIT_RPC_ID, { id: this.rpcId, ...opts }). You already have @registerSharedObjectOwner for this type (line 57), so you can use the existing counterpart wiring. + +This mirrors the “counterpart initialization” mechanism described in NOTES/annotation-chunk-source-and-sync.md lines 22–31. + +--- + +### Concrete API and pseudo-code + +#### Constants (new) src/voxel_annotation/base.ts +```ts +export const VOX_CHUNK_SOURCE_RPC_ID = 'voxChunkSource'; // already exists +export const VOX_MAP_INIT_RPC_ID = 'vox/map/init'; +export const VOX_EDIT_APPLY_RPC_ID = 'vox/edit/apply'; +export const VOX_SAVE_STATUS_RPC_ID = 'vox/save/status'; // optional progress events +``` + +#### Frontend owner additions src/voxel_annotation/frontend.ts +- Add initializeMap() and sendEdits() methods. +- Call sendEdits() from paintVoxel() (batch for brush). + +Pseudo-snippets around existing code: +```ts +@registerSharedObjectOwner(VOX_CHUNK_SOURCE_RPC_ID) +export class VoxChunkSource extends BaseVolumeChunkSource { + // ...existing code... + + async initializeMap(opts: { + mapId?: string; + dataType?: number; // default DataType.UINT32 + chunkDataSize: [number, number, number]; + upperVoxelBound: [number, number, number]; + unit?: string; + scaleKey?: string; // optional explicit scale identifier + }) { + const resp = await (this as any).rpc!.invoke(VOX_MAP_INIT_RPC_ID, { + id: (this as any).rpcId, + ...opts, + }); + return resp; // { mapId, scaleKey } + } + + private pendingChunkEdits = new Map>(); + private editFlushHandle: number | undefined; + + private queueEdit(key: string, localIndex: number, value: number) { + let a = this.pendingChunkEdits.get(key); + if (!a) { a = []; this.pendingChunkEdits.set(key, a); } + a.push([localIndex, value]); + if (this.editFlushHandle === undefined) { + this.editFlushHandle = self.setTimeout(() => this.flushEdits(), 16); + } + } + + private async flushEdits() { + const entries = Array.from(this.pendingChunkEdits.entries()); + this.pendingChunkEdits.clear(); + this.editFlushHandle = undefined; + const rpc = (this as any).rpc!; + for (const [key, edits] of entries) { + try { + await rpc.invoke(VOX_EDIT_APPLY_RPC_ID, { id: (this as any).rpcId, key, edits }); + } catch (e) { + console.warn('Failed to apply voxel edits to worker', e); + } + } + } + + paintVoxel(voxel: Float32Array, value: number) { + const { key, localIndex } = this.computeChunkKeyAndIndex(voxel); + if (localIndex < 0) return; + this.overlay.applyEdit(key, localIndex, value); + // Existing CPU array merge + reupload + const chunk = this.chunks.get(key) as VolumeChunk | undefined; + if (chunk) { + const baseArray = this.getCpuArrayForChunk(chunk); + if (baseArray) { + this.overlay.mergeIntoChunkData(key, baseArray); + this.invalidateChunkUpload(chunk); + } + } + // NEW: forward to worker authoritative state. + this.queueEdit(key, localIndex, value); + this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); + } +} +``` + +#### Backend counterpart additions src/voxel_annotation/backend.ts +- Maintain map state + IDB. +- Register RPCs: init and apply edits. +- Modify download() to source from voxels map/IDB instead of procedural. + +Pseudo-structure inside class VoxChunkSource: +```ts +@registerSharedObject(VOX_CHUNK_SOURCE_RPC_ID) +export class VoxChunkSource extends BaseVolumeChunkSource { + private mapId: string = 'default'; + private scaleKey = ''; + private voxels = new Map(); + private dirty = new Set(); + private dbPromise: Promise | null = null; + private saveTimer: number | undefined; + + constructor(rpc: RPC, options: any) { + super(rpc, options); + this.scaleKey = `${this.spec.chunkDataSize[0]}_${this.spec.chunkDataSize[1]}_${this.spec.chunkDataSize[2]}`; + // register RPCs + (this as any).rpc!.register(VOX_MAP_INIT_RPC_ID, ({ id, ...opts }: any) => this.handleInit(opts)); + (this as any).rpc!.register(VOX_EDIT_APPLY_RPC_ID, ({ id, key, edits }: any) => this.handleApplyEdits(key, edits)); + } + + private async handleInit(opts: { mapId?: string; unit?: string; dataType?: number; chunkDataSize?: number[]; upperVoxelBound?: number[]; scaleKey?: string; }) { + // adopt metadata + if (opts.scaleKey) this.scaleKey = opts.scaleKey; + if (opts.mapId) this.mapId = opts.mapId; else this.mapId = crypto.randomUUID?.() ?? String(Date.now()); + // Open IDB and persist metadata row + const db = await this.getDb(); + await put(db, 'maps', { mapId: this.mapId, dataType: this.spec.dataType, chunkDataSize: Array.from(this.spec.chunkDataSize), upperVoxelBound: Array.from(this.spec.upperVoxelBound ?? []), unit: opts.unit ?? '', scaleKey: this.scaleKey, createdAt: Date.now() }); + return { mapId: this.mapId, scaleKey: this.scaleKey }; + } + + private async handleApplyEdits(key: string, edits: Array<[number, number]>) { + const arr = await this.getOrLoadChunk(key); + for (const [idx, val] of edits) { + if (idx >= 0 && idx < arr.length) arr[idx] = val >>> 0; + } + this.dirty.add(key); + this.scheduleSave(); + } + + async download(chunk: VolumeChunk, signal: AbortSignal): Promise { + if (signal.aborted) throw signal.reason ?? new Error('aborted'); + const origin = this.computeChunkBounds(chunk); // existing helper + const cds = chunk.chunkDataSize!; // clipped size + const [cx, cy, cz] = [ + Math.floor(origin[0] / this.spec.chunkDataSize[0]), + Math.floor(origin[1] / this.spec.chunkDataSize[1]), + Math.floor(origin[2] / this.spec.chunkDataSize[2]), + ]; + const key = `${this.scaleKey}/${cx},${cy},${cz}`; + const arr = await this.getOrLoadChunk(key, cds); + (chunk as any).data = arr; + } + + private async getOrLoadChunk(key: string, cdsMaybe?: Uint32Array): Promise { + let arr = this.voxels.get(key); + if (arr) return arr; + // Try IDB + const db = await this.getDb(); + const buf = await get(db, 'chunks', `${this.mapId}:${key}`); + if (buf instanceof ArrayBuffer) { + arr = new Uint32Array(buf); + this.voxels.set(key, arr); + return arr; + } + // allocate zero + const cds = cdsMaybe ?? this.spec.chunkDataSize as Uint32Array; + let n = 1; for (let i = 0; i < 3; ++i) n *= cds[i]; + arr = new Uint32Array(n); + this.voxels.set(key, arr); + return arr; + } + + private scheduleSave() { + if (this.saveTimer !== undefined) return; + this.saveTimer = setTimeout(() => this.flushSaves(), 750) as unknown as number; + } + + private async flushSaves() { + const keys = Array.from(this.dirty); + if (keys.length === 0) { this.saveTimer = undefined; return; } + this.dirty.clear(); + const db = await this.getDb(); + const tx = db.transaction('chunks', 'readwrite'); + const store = tx.objectStore('chunks'); + for (const key of keys) { + const arr = this.voxels.get(key); + if (!arr) continue; + await reqAsPromise(store.put(arr.buffer, `${this.mapId}:${key}`)); + } + await txDone(tx); + this.saveTimer = undefined; + } + + // Helpers: IDB open and promisified ops (implementation omitted here for brevity) +} +``` + +You can swap the IDB bits for OPFS by writing to files under `/maps/${mapId}/${key}.bin` using FileSystemDirectoryHandle + FileSystemSyncAccessHandle (great for large data and atomic writes). The rest of the flow is identical. + +--- + +### UI/Layer wiring for initialization +- src/layer/vox/index.ts already exposes a VoxSettingsTab with controls for scale/unit/bounds. Add an “Initialize Map” button that triggers map init after settings are applied. +- In buildOrRebuildVoxLayer() (lines 205–253), after creating DummyMultiscaleVolumeChunkSource and before adding the render layer, call something like: + +```ts +const sources2D = dummySource.getSources({} as any); +const base = sources2D[0][0]; +const source = base.chunkSource as any; // VoxChunkSource (frontend owner) +await source.initializeMap({ + dataType: dummySource.dataType, + chunkDataSize: Array.from(dummySource['cfgChunkDataSize'] ?? [64,64,64]), + upperVoxelBound: Array.from(this.voxUpperBound), + unit: this.voxScaleUnit, +}); +``` + +This ensures the worker knows the map identity and has persisted metadata before any edits. + +--- + +### How this mirrors the annotation system +- Optimistic UI and buffering: frontend overlay mirrors “temporary chunk” approach from NOTES/annotation-chunk-source-and-sync.md lines 37–49, 84–96, 99–105. +- Commit requests: VOX_EDIT_APPLY_RPC_ID plays the role of ANNOTATION_COMMIT_UPDATE_RPC_ID (lines 28–61). We intentionally keep this simple (no per-id coalescing) because voxel edits are applied per chunk; batching per chunk provides similar debouncing semantics (spec §3 Tier 3 debounced writes). +- Backend counterpart object: Registered with the same shared id (VOX_CHUNK_SOURCE_RPC_ID) and receives RPCs for edits and map init (notes lines 22–31). + +--- + +### Offline persistence study +- localStorage: Not available in Web Workers (and synchronous, low capacity). Not recommended. +- IndexedDB: Available in workers, supports large binary data and transactions. Good default. Write amplification is acceptable if batching edits. +- Cache Storage API: Good for HTTP response caching, less suited to mutable structured data per chunk. +- OPFS (Origin Private File System): Available in workers. Ideal for large persistent data, supports atomic, lock-free sync access in workers. Higher performance for heavy write loads than IDB in some browsers. Requires more code for directory/handle management, but is a strong option for “plus” offline feature. + +Recommendation: Start with IndexedDB for MVP; keep the persistence layer abstract so OPFS can be plugged in. + +--- + +### Edge cases and details +- Chunk clipping: Neuroglancer chunks near the upper bound may be smaller. Persist the full logical chunk size and optionally store clipped size per record if needed; or keep the array sized to actual chunkDataSize and let the geometry handle clipping. +- DataType: MVP DataType.UINT32 (as already configured in dummy multiscale). Keep type in map metadata; if supporting multiple types later, convert appropriately on load/save. +- Multi-user future: Store a per-map generation and per-chunk version; define conflict policy (e.g., last-writer-wins or CRDT). For now, single-user writes. +- Save frequency: Tune debounce (e.g., 250–1000 ms) and a max batch size (e.g., 64 chunks per flush). On tab close, hook self.onclose to flush synchronously if possible. +- Loading existing maps: Allow passing mapId to initializeMap to reopen; otherwise create a new one. +- Networked backend (optional future): + - POST /maps to init; GET/PUT /maps/{id}/chunks/{key} to read/write chunks + - Worker download(): fetch if not in IDB (then cache in IDB). Edits: write-through to IDB then attempt PUT to server. Retry queue when offline. + +--- + +### Step-by-step implementation checklist +1) Add new RPC ids in src/voxel_annotation/base.ts. +2) Frontend owner (src/voxel_annotation/frontend.ts): + - Add initializeMap() invoking VOX_MAP_INIT_RPC_ID. + - Add batching queueEdit/flushEdits; call from paintVoxel(). +3) Backend counterpart (src/voxel_annotation/backend.ts): + - Add fields voxels Map, dirty Set, mapId, scaleKey. + - Register VOX_MAP_INIT_RPC_ID and VOX_EDIT_APPLY_RPC_ID handlers. + - Replace procedural download() body to use getOrLoadChunk() and return stored Uint32Array. + - Implement debounced flush to IDB (and IDB helpers). +4) UI wiring (src/layer/vox/index.ts): + - After creating DummyMultiscaleVolumeChunkSource and before adding render layer, call initializeMap() with the UI settings. + - Optionally add an explicit “Initialize Map” button in VoxSettingsTab to force re-init/reset. +5) Optional: Add a small status notifier (VOX_SAVE_STATUS_RPC_ID) for “Saving…” progress. + +--- + +### Minimal changes by file (where to edit) +- src/voxel_annotation/base.ts: define VOX_MAP_INIT_RPC_ID, VOX_EDIT_APPLY_RPC_ID, VOX_SAVE_STATUS_RPC_ID. +- src/voxel_annotation/frontend.ts: + - Add initializeMap() method to VoxChunkSource owner. + - Add batching RPC for edits (queueEdit/flushEdits) and call it from paintVoxel(). +- src/voxel_annotation/backend.ts: + - Add worker state (voxels, dirty, mapId, scaleKey) and IDB persistence, register RPC handlers, change download() to load from state/IDB. +- src/layer/vox/index.ts: + - In buildOrRebuildVoxLayer(), after DummyMultiscaleVolumeChunkSource creation, grab the base source’s VoxChunkSource and call initializeMap() with current settings. + - (Optional) add UI button to “Initialize/Reset Map”. + +--- + +### Summary +- Keep the existing optimistic frontend overlay (fast UI). +- Make the worker the authoritative source of voxel data and persist it with a debounced saver. +- Add an initialization RPC to create/open a map with user-provided dimensions and scale. +- Use IndexedDB in the worker for persistence; OPFS is a strong future option. Avoid localStorage. +- The design mirrors the annotation system’s paired shared objects + RPC commit result loop, adapted for chunked voxel data. + +If you want, I can follow up with concrete IDB helper utilities (openDb, get, put, txDone, reqAsPromise) and exact code patches to each file to accelerate implementation. diff --git a/NOTES/first_drawing.png b/NOTES/first_drawing.png new file mode 100644 index 0000000000000000000000000000000000000000..edc622f85f9909711b893af36c9e03445842fa37 GIT binary patch literal 225375 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�|5xg~jYhiM^lLeMVpR%rufNi1Z5N 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, - map: VoxMapConfig, - lodFactor: number, -) { - const { chunkSource: inner } = level; - const voxWrapped = manager.getChunkSource(VoxEditableVolumeSource, { - spec: inner.spec, - inner, - map, - lodFactor, - }); - return { - ...level, - chunkSource: voxWrapped, - }; -} -``` - -#### Phase 3 — Migrate editing controllers to use the overlay -- Update `src/voxel_annotation/edit_controller.ts` to call methods on `VoxVolumeChunkSource` (or a service it exposes) for: - - Paint/fill operations scheduling - - Persistent save via RPC - - Invalidate by affected chunk keys -- Remove direct coupling to old `VoxChunkSource` frontend methods. - -#### Phase 4 — Pair with LocalVoxSource (backend overlay) -- Instantiate and initialize `LocalVoxSource` inside `VoxVolumeChunkSource` backend using the provided `map`. -- Implement merge routine without assumptions about array type other than validated `UINT32`. - -#### Phase 5 — Invalidation and cache coherence -- When edits are committed, compute affected chunk keys with LOD and call `invalidateChunksByKey` on the overlay frontend, which calls through to the backend overlay and the underlying `inner` for proper cache invalidation. -- Ensure invalidation bridges both CPU cache and GPU textures via sliceview’s existing invalidation pathways. - -#### Phase 6 — Deprecate old classes in stages -- Mark `VoxMultiscaleVolumeChunkSource` and old frontend/backend `VoxChunkSource` as deprecated. -- Switch the vox layer to the overlay implementation behind a feature flag `voxOverlay.enabled` (default on in dev). -- After verification, delete old classes and their references. - -### Detailed implementation checklist - -1) Overlay backend implementation details -- Class `VoxVolumeChunkSource` extends backend `VolumeChunkSource`. -- Fields: `inner: VolumeChunkSource`, `local: LocalVoxSource`, `lodFactor: number`. -- `initialize(options)`: resolve `inner` from RPC id; validate types; set `lodFactor` from options; init `local` with `map`. -- `download(chunk, signal)`: - - `await this.inner.download(chunk, signal)`; - - get `cds = chunk.chunkDataSize` from `inner`; - - compute `key = chunk.chunkGridPosition.join()`; - - `const saved = await local.getSavedChunk(makeVoxChunkKey(key, lodFactor));` - - if `saved`, overlay using safe copier that clamps to min extents. -- Expose RPC for invalidation; internally use the chunk manager to invalidate the wrapped source’s key. - -2) Overlay frontend implementation details -- Class `VoxVolumeChunkSource` extends `SliceViewChunkSource` but delegates to `inner: VolumeChunkSource` for `fetchChunk`, `getChunk`, `getValueAt`. -- `initializeCounterpart(rpc, options)`: create backend counterpart for overlay and send `VOX_MAP_INIT_RPC_ID` with `map`. -- Provide editing API surface: - - `beginEdit()` / `commitEdit(edits)` → RPC to backend edit service (already exists via `edit_backend.js`), then `invalidate(keys)`. - - Implement `invalidate(keys: string[])` that forwards to backend overlay and triggers visible-chunk re-fetch. - -3) Layer glue -- In `src/layer/vox/index.ts`, when constructing visible sources, wrap the datasource-provided multiscale levels with `VoxVolumeChunkSource` as per Phase 2 sketch. -- Compute `lodFactor` per level deterministically: - - Prefer explicit `map.steps[index]`. - - Alternatively, derive from transform if steps are not provided; if derivation is ambiguous (non-uniform scale), throw. - -4) Strict typing additions -- Add `VoxMapConfig` fields used by overlay: `steps: number[]`, `chunkDataSize: [number,number,number]`, `upperVoxelBound: [number,number,number]`, `baseVoxelOffset: [number,number,number]`, optional `serverUrl`, `token`. -- Define `VoxEditBatch` shape used by `commitEdit` path to ensure edits map cleanly to chunk keys. - -5) Error handling policy -- Throw on: - - Missing `innerSourceRpcId` or it resolves to a non-`VolumeChunkSource`. - - Unsupported `dataType` or `volumeType`. - - Missing `lodFactor` for a level. - - Any attempt to edit without initialized map. - -### Example minimal code snippets - -Backend overlay merge loop (typed and bounds-checked): -```ts -function overlaySavedIntoChunk( - dst: Uint32Array, - dstSize: readonly [number, number, number], - src: Uint32Array, - srcSize: readonly [number, number, number], -) { - const ox = Math.min(srcSize[0], dstSize[0]); - const oy = Math.min(srcSize[1], dstSize[1]); - const oz = Math.min(srcSize[2], dstSize[2]); - for (let z = 0; z < oz; z++) { - for (let y = 0; y < oy; y++) { - const s0 = (z * srcSize[1] + y) * srcSize[0]; - const d0 = (z * dstSize[1] + y) * dstSize[0]; - dst.set(src.subarray(s0, s0 + ox), d0); - } - } -} -``` - -Frontend wrapper fetch delegation with typed guard: -```ts -fetchChunk(position: Float32Array, transform: (c: VolumeChunk) => void) { - if (!this.inner) throw new Error("inner source is not set"); - return this.inner.fetchChunk(position, transform); -} -``` - -### Testing plan - -- Unit tests - - `overlay_backend`: merging logic overlays correctly for different sizes and partially clipped chunks. - - Type guards throw on unsupported `dataType` and invalid `inner` references. -- Worker integration tests - - Initialize overlay with a mock `inner` that returns deterministic data; verify merge with `LocalVoxSource` saved chunk. - - Verify invalidation: commit an edit, ensure subsequent `download` sees the overlayed data. -- Frontend integration - - Wrap a `ZarrVolumeChunkSource` level, render, paint single voxel, commit, and expect visual update without page reload. -- Performance checks - - Measure `download` timings with and without overlay for typical chunk sizes; ensure O(n) merge overhead is acceptable. - -### Rollout plan with PR slicing - -1) PR1: Introduce backend overlay class and copier utility. No references; covered by unit tests. -2) PR2: Introduce frontend overlay class; basic delegation tests. -3) PR3: Wire vox layer to wrap existing multiscale sources; behind a feature flag. -4) PR4: Migrate edit controller to call overlay wrapper; enable invalidation path. -5) PR5: Remove `VoxMultiscaleVolumeChunkSource` from layer; keep class deprecated but unused. -6) PR6: Delete old `VoxChunkSource` frontend/backend, consolidate RPC initializers. -7) PR7: Clean-up and documentation: update `NOTES/vox-annotation-project-overview.md`. - -### Risks and mitigations -- Risk: Cache invalidation gaps cause stale visuals. - - Mitigation: Comprehensive integration tests around `invalidateChunksByKey` and visible chunk refetch. -- Risk: Datasource variations (e.g., channel dims) complicate `getValueAt`. - - Mitigation: Delegate all value access to `inner`; overlay only touches raw array during merge. -- Risk: Ambiguous LOD factor. - - Mitigation: Require explicit `map.steps[index]` for MVP; throw otherwise. - -### Definition of done -- Vox edits visualize and persist correctly when wrapping at least one external datasource (zarr) with no changes to core base classes. -- Old `VoxMultiscaleVolumeChunkSource` and old `VoxChunkSource` are removed. -- All new code paths have unit/integration tests and pass CI. -- Type validations prevent unsupported modes and clearly explain errors. diff --git a/NOTES/vox-annotation-project-overview.md b/NOTES/vox-annotation-project-overview.md deleted file mode 100644 index 944feeed3d..0000000000 --- a/NOTES/vox-annotation-project-overview.md +++ /dev/null @@ -1,268 +0,0 @@ -# Vox Annotation Project — Motivation, Vision, State, and Roadmap - -Updated: 2025-09-12 10:55 (local) - -TL;DR -- Goal: A performant voxel annotation workflow in Neuroglancer where users can paint integer labels directly on the voxel grid with smooth UX, predictable storage, and optional collaboration via a simple HTTP server. -- Today: A working Vox layer with pixel/brush tools, immediate visual feedback, debounced persistence to IndexedDB, and optional remote save/load via a Zarr-based HTTP server design. Label lists can be created/managed. Rendering integrates with existing sliceview/volume infrastructure. -- Next: Add flood-fill, LOD/downsampling pipeline, compression, better caching and memory bounds, improved remote features (auth, multi-user?), and export/import tools. - - -1) Motivation -- Traditional Neuroglancer annotations are vector-based (points, segments, etc.). They are great for geometry-centric workflows but not for dense voxel labeling required in ML training, segmentation curation, and painting workflows. -- Need: A voxel-aligned labeling system that: - - Writes label IDs into a 3D grid (uint32/uint64), lives nicely with multiscale viewing. - - Feels responsive: edits appear immediately; saving is asynchronous and robust. - - Can work offline (local browser storage) and switch to online collaboration (HTTP API over Zarr) without major UI changes. - - -2) Vision and Principles -- Ergonomic painting: - - Pixel and brush tools as baseline; flood-fill and eraser next; plane-aware disk brush by default, spherical brush optionally. - - Label palette that maps integers to colors deterministically. -- Performance and scalability: - - Chunked editing and streaming via standard sliceview/volume system. - - Immediate local overlay/display + debounced background persistence to avoid UI stalls. - - Multiscale integration for zoomed-out views with downsampling over time. -- Portability and openness: - - Zarr v2 layout for persistent storage and an HTTP server spec compatible with CDNs/object stores. - - Simple security: magic-link auth in MVP. -- Extensibility: - - Clean separation of frontend UI, worker-side authoritative state, and persistence backends (IndexedDB or HTTP server). - - Future multi-user support following doc-edit style concurrency (last-writer-wins MVP, richer models later). - - -3) Current State (What works now) -3.1 Layer, tools, and interaction -- Vox layer type with settings and tool tabs: src/layer/vox/index.ts - - UI for choosing scale/region (voxel bounds), brush size/shape, eraser mode, remote URL/token parsing, and label selection. - - Hooks to rebuild sources when settings change. -- Tools: src/ui/voxel_annotations.ts - - Pixel tool (VoxelPixelLegacyTool): line interpolation to fill continuous strokes. - - Brush tool (VoxelBrushLegacyTool): disk (aligned to slice plane) or sphere; configurable radius; oriented-disk uses the current slice basis when available. - - Tools call VoxelEditController which routes edits to the vox chunk source. -- Edit controller: src/voxel_annotation/edit_controller.ts - - paintVoxelsBatch for arbitrary point lists. - - paintBrushWithShape for disk/sphere brush generation with plane orientation support. - -3.2 Data flow and chunk sources -- Multiscale source: src/voxel_annotation/volume_chunk_source.ts - - Returns a base scale with real bounds and a coarse “guard” scale (empty bounds but huge voxel transform) to prevent extreme-zoom memory blow-ups. - - DataType = UINT32, VolumeType = SEGMENTATION, rank = 3. - - Passes optional vox serverUrl/token to worker. -- Frontend chunk owner: src/voxel_annotation/frontend.ts (class VoxChunkSource) - - Pairs with a worker counterpart via RPC type id VOX_CHUNK_SOURCE_RPC_ID. - - Local optimistic edit path: paintVoxelsBatch computes the chunk/local indices, writes into the CPU array if present, and invalidates GPU uploads per chunk to achieve immediate visual updates. - - Sends batched edit RPCs (VOX_COMMIT_VOXELS_RPC_ID) to backend with {key, indices, value, size}. - - Map initialization RPC (VOX_MAP_INIT_RPC_ID): best-effort init of worker storage and metadata. - - Label APIs: VOX_LABELS_GET_RPC_ID, VOX_LABELS_ADD_RPC_ID. -- Backend counterpart: src/voxel_annotation/backend.ts (class VoxChunkSource) - - Chooses a persistence backend: - - LocalVoxSource for IndexedDB (default). - - RemoteVoxSource when serverUrl/token supplied (HTTP). - - download(...) computes chunk bounds, returns a typed array matching spec dtype, and overlays any saved chunk data for in-bounds region (merges saved content into the allocated array). - - commitVoxels applies batched edits into the authoritative source; saving is debounced. - -3.3 Persistence backends (authoritative state in worker) -- Shared helper/types: src/voxel_annotation/index.ts - - toScaleKey(chunkDataSize, baseVoxelOffset, upperVoxelBound). - - compositeChunkDbKey(mapId, scaleKey, chunkKey) and compositeLabelsDbKey. - - VoxSource abstract base managing: mapId, scaleKey, chunkDataSize, base/upper bounds, dtype, unit, in-memory LRU cache, dirty set, and debounced flush (≈750ms). - - applyEditsIntoChunk supports both single value and per-index values arrays; typed arrays switch (Uint32 vs BigUint64 for future UINT64 support). -- LocalVoxSource (IndexedDB): - - IDB stores: maps (metadata), chunks (ArrayBuffer per chunk), labels (label list). - - Debounced flush writes dirty chunks; in-memory LRU avoids unbounded growth; avoids evicting dirty entries. - - Label persistence: getLabelIds, addLabel ensure id uniqueness. -- RemoteVoxSource (HTTP): - - Base URL + optional token; GET /chunk?mapId&chunkKey returns bytes or 404 for missing; PUT /chunk persists bytes. - - Map init: best-effort GET /init?mapId&scaleKey&dtype. - - Label endpoints: GET/PUT /labels. - - Maintains a small LRU and reuses the same debounced flush policy; failed PUT keeps keys dirty for retry. - -3.4 Rendering -- Custom render layer: src/voxel_annotation/renderlayer.ts - - Extends SliceViewVolumeRenderLayer and colors non-zero labels via SegmentColorHash; zero is transparent with alpha 0; non-zero alpha ≈ 0.5. - - Uses standard data sampling hooks (getDataValue/getUint64DataValue path) so real chunk data shows; includes helpful shader build error logging. - -3.5 Remote URL provider -- src/datasource/vox_remote.ts - - Provides a DataSource for vox+http(s):// URLs used by the Vox layer; mainly a stub that allows the layer to detect a remote source and pass URL/token to worker. - -3.6 Labels UI state -- Layer wires a simple label list; frontend/backend support getting and adding labels. Rendering maps label ids to colors via hashing; there is no named palette UI yet (hash-based is deterministic). - - -4) Storage and API (Backend) -4.1 Zarr-based HTTP server (MVP) -- See NOTES/backend.md for full spec. Summary: - - Zarr v2, arrays per scale 0/, 1/, ... under a root with NGFF multiscales. - - Missing chunk => fill_value (0) semantics. - - Chunk addressing at 0/ix/iy/iz. -- Key endpoints: - - GET /info → dataset metadata union (.zattrs + .zarray summaries) + publicBase. - - GET /chunk?mapId&chunkKey → raw bytes of a full chunk (padded at edges). - - PUT /chunk?mapId&chunkKey → raw bytes; writes in-bounds region for edge chunks; last-writer-wins. - - GET /init?mapId&scaleKey&dtype → initialize new map metadata. - - GET /labels?mapId → { labels: number[] }. - - PUT /labels?mapId → { labels: number[] } (adds new label id). -- Non-functional (MVP): - - CORS for configured origins; simple metrics; health checks; magic-link auth; local single-node Docker Compose with MinIO (S3-compatible) for development. - -4.2 Scale key and chunk key -- Scale key format used throughout (frontend and backend helpers): - - toScaleKey(chunkDataSize, baseVoxelOffset, upperVoxelBound) → "cx_cy_cz:lx_ly_lz-ux_uy_uz" (e.g., 64_64_64:0_0_0-1024_1024_1024). -- Chunk key: - - toChunkKey([cx, cy, cz]) → "cx,cy,cz" (e.g., 0,0,0). - - -5) Codebase Map (vox annotation related) -- Layer/UI - - src/layer/vox/index.ts — VoxUserLayer: settings tab (scale, bounds, remote URL/token), tool tab (tools, labels UI), wiring to render layer and multiscale. - - src/ui/voxel_annotations.ts — Legacy tools (pixel, brush) and registration. Generates voxel positions, handles stroke interpolation, uses oriented disk brush. -- Chunk sources / rendering - - src/voxel_annotation/volume_chunk_source.ts — VoxMultiscaleVolumeChunkSource: returns base and guard scales, passes vox server options. - - src/voxel_annotation/renderlayer.ts — VoxelAnnotationRenderLayer: simple segment-hash coloring of non-zero labels. -- Edit logic and RPC owner/counterpart - - src/voxel_annotation/frontend.ts — Frontend VoxChunkSource with optimistic CPU updates, batched commit RPCs, map init, label RPCs. - - src/voxel_annotation/backend.ts — Backend VoxChunkSource resolves LocalVoxSource vs RemoteVoxSource, downloads/saves chunk data, responds to RPCs. - - src/voxel_annotation/index.ts — VoxSource base; LocalVoxSource (IndexedDB); RemoteVoxSource (HTTP); scale/chunk key helpers; IDB utils. - - src/voxel_annotation/edit_controller.ts — Bridges layer tools to VoxChunkSource. -- Datasource integration - - src/datasource/vox_remote.ts — Provider for vox+http(s):// schemes to pass remote info into the layer. -- Reference and architecture notes - - NOTES/voxel-annotation-specification.md — Overall voxel annotation spec and tiered architecture. - - NOTES/annotation-chunk-source-and-sync.md — How frontend/backend chunk sources pair and how optimistic buffering works. - - NOTES/classExplanations/*.md — Deeper dives into MultiscaleVolumeChunkSource and chunk-source concepts. - - NOTES/backend.md — Zarr HTTP server requirements and API. - - NOTES/TODOs.md — Current to-do list. - - -6) Editing Model (UX + Data) -- Immediate visual feedback: edits write into CPU arrays of visible chunks when present; GPU uploads are invalidated and refreshed on the next frame. -- Authoritative state: worker holds canonical per-chunk arrays via VoxSource; writes are batched and saved after debounce to IDB or PUT to remote server. -- Batched per-chunk commits: indices are linearized local indices in the canonical chunk size; backend handles edge clips and merges into the in-memory state. -- Label management: labels are simple integer lists scoped to map/scale; API supports GET and ADD; used to drive color mapping and selected label value in UI. - - -7) Brainstorming / Reflections / Debates -- Flood fill: - - Start with 2D fill in current slice plane; impose max expansion safeguards to prevent runaway fills. - - For 3D fill, consider connected-components with thresholding against underlying image/segmentation data. -- LOD / downsampling: - - MVP: hide when zoomed too far, or use guard scale to avoid huge memory usage. - - Phase 2: On-the-fly downsampling in worker: request 8 children at LOD0 to synthesize LOD1 with majority voting; cache generated lower-LOD chunks and invalidate on parent edits. - - Persistence across scales: consider propagating writes upward (write-through) and merging on load. Conflicts arise when values differ across scales; needs a deleted-marker and per-chunk timestamps to disambiguate absence vs deletion vs disagreement. - - Undo/future: Do not resolve conflicts “live” if it precludes implementing undo/redo; prefer to defer resolution or track lineage with timestamps. -- Compression and memory: - - Compressed segmentation block formats reduce RAM/IndexDB usage. Integration is non-trivial for hot-edit rendering because in-place CPU texture updates are needed for smooth UX. Explore per-chunk compressed backing store + uncompressed hot copy for visible chunks. - - Investigate RAM usage spikes during heavy painting; ensure chunk eviction policies consider viewport visibility to avoid flicker (see TODO on uncaching without visibility awareness). -- Multi-user / concurrency: - - Remote server MVP uses last-writer-wins. For collaborative editing, introduce per-chunk versions/ETags, server-side mergers, or operational transforms tuned for voxel arrays (conflict resolution policy per-voxel or per-chunk). - - Live updates: server can emit change streams or polling-based invalidation to notify clients of updated chunks. -- Authentication / Security: - - Magic-link token is a pragmatic MVP. Add CORS configs, short metadata caching, and health endpoints. Long-lived caching of 404s should be avoided. -- Import/Export: - - “ExternalVoxSource” concept: For zarr:// or precomputed:// reads, load remote for display; keep edits local (IDB) and implement export that merges local modifications back into a chosen persistent format. - - -8) Roadmap and TODOs -8.1 From NOTES/TODOs.md (selected and grouped) -- Storage/robustness - - Add redundancy to avoid corrupt/unsaved chunks on remote (e.g., write temp objects then rename, MD5/ETag checks). - - Test token authentication thoroughly. - - Add Uint64 label id support end-to-end (frontend arrays, server dtype, render sampling already supports uint64 colors). -- Performance/UX - - Fix brush disk orientation edge cases; ensure correct plane basis on arbitrary slices. - - Visibility-aware eviction to avoid flicker when LocalVoxSource evicts unseen chunks; integrate with chunk manager visible set. - - Investigate and reduce RAM usage on heavy painting sessions. - - Segmentation compression strategy compatible with hot updates. -- Tools - - Flood fill tool (start 2D, plane normal z is ok for v1). Add eraser tooling (value 0 path exists; improve UX toggles/shortcuts). -- LOD - - Implement LOD rendering by propagating writes upward and fetching across scales; ensure deleted-marker and per-chunk timestamps to tackle conflicts; do not auto-resolve live to keep undo viable. -- Data workflows - - Import precomputed/Zarr segmentation into server; support full dataset retrieval and merge with local modifications; export to desired format. -- Remote labels sync - - Current remote server code supports labels endpoints; ensure layer UI syncs and handles errors. - -8.2 Additional tasks inferred from code and commits -- Finish wiring of map initialization from layer UI (ensure scaleKey matches UI region and chunk sizes, call initializeMap on source creation). -- Improve error handling for remote PUT/GET (status messages, retries, and user feedback). -- Add basic metrics/observability overlays (chunk read/write counters) for development. -- Provide example docker-compose and client connection snippet in docs. - - -9) Git History Highlights (vox-related) -- 7c1a3b4e feat: replace setLabelIds with addLabel for label management. -- 02e28fd4 feat: remote voxel sources via HTTP(S) (note: labels not sync initially). -- 07a59c38 feat: RPC-based voxel label persistence. -- e04b3167 feat: voxel label creation, persistence via IndexedDB, enhanced UI. -- e70c9ff3 feat: expand TODOs (compression, multi-user, tools). -- 92c3c215 feat: region-based voxel initialization with corners; update map options and UI. -- 19f4e103 feat: new local voxel storage with IndexedDB; map initialization; improved backend edits. -- faf0947d feat: persist voxel edits to backend and improve drawing responsiveness. -- 3017fe4c feat: continuous drawing and brush shape selection. -- 238958a8 feat: brush size, eraser mode, minor optimization. -- a3f05989 refactor: rename DummyMultiscaleVolumeChunkSource→VoxMultiscaleVolumeChunkSource. -- 44a6754f feat: fix pixel tool scaling issues; add primitive brush tool. -- 14336ab4 feat: pixel tool working as intended. -- 65565130 feat: WIP pixel tool; added front-end buffer; layer settings for scale/bounds; added guard scale to prevent zoom-out crashes. -- 6140a28b doc: rework voxel annotation specs. -- cbe55c86 feat: introduce VoxDummyChunkSource procedural demo. -- c0ceef34 feat: add support for voxel annotation rendering and spec. -- 9b71be4f feat: add new dummy layer type: voxel annotation (vox). - -These commits capture the evolution from a procedural/demo stage to a functional editing and persistence pipeline with labels and remote integration. - - -10) How everything connects (end-to-end) -- User paints with a tool → UI generates voxel positions (points or brush patterns). -- VoxelEditController forwards edits to the frontend VoxChunkSource. -- Frontend VoxChunkSource: - - Computes chunk indices and local offsets. - - Writes into CPU arrays when available and invalidates GPU uploads (instant feedback). - - Batches linearized indices per chunk and sends VOX_COMMIT_VOXELS_RPC_ID to the worker, including canonical size. -- Backend VoxChunkSource receives the RPC and applies edits via VoxSource (Local or Remote) — authoritative state updated immediately. -- Debounced saver writes chunks to IDB or HTTP server /chunk endpoint. -- When chunks stream (or re-stream) to the frontend (e.g., on navigation), download merges saved data and provides typed arrays; the render layer displays labels (zero → transparent, nonzero → colored). - - -11) Open Questions -- Undo/redo: Requires a journal of edits or chunk snapshots. Interaction with LOD propagation needs careful design. -- Multi-user semantics: Per-voxel conflict resolution vs per-chunk; latency trade-offs; server push vs polling. -- Remote cache invalidation: How do clients learn about external updates? ETag + If-None-Match and/or change streams. -- Label metadata: Should labels be plain integers only or have names/colors? Today rendering uses a deterministic hash; UI for named palettes could be added. -- Security: Token format and rotation; scope per-map vs per-store; server-side audit. - - -12) Quickstart (MVP) -- Local-only (IndexedDB): - 1) Add a Vox layer, set bounds and chunk size in the Settings tab. - 2) Pick a label value, select Pixel/Brush tool, paint. Data persists into your browser (IndexedDB). -- Remote (HTTP server): - 1) Run the Zarr server (see NOTES/backend.md for spec; Docker Compose recommended with MinIO for S3-like storage). - 2) In Vox layer, set source to vox+http://host:port/?token=... (or vox+https://...). - 3) Paint. Edits are PUT to the server; missing chunks read as zeros. - - -13) Glossary -- Chunk: A small 3D block of voxels (e.g., 64×64×64) used for efficient storage and rendering. -- Multiscale: Multiple resolutions of the same volume for performance at varying zoom levels. -- LOD: Level of detail; lower resolution representation used when zoomed out. -- NGFF/Zarr: Open formats for n-dimensional arrays with chunked storage; used here for persistence. - - -14) References (in repo) -- NOTES/backend.md — server API/requirements. -- NOTES/voxel-annotation-specification.md — tiered architecture, tools, and phases. -- NOTES/annotation-chunk-source-and-sync.md — RPC pairing and buffering model. -- NOTES/classExplanations/MultiscaleVolumeChunkSource.md — multiscale details. -- NOTES/classExplanations/chunk-source.md — owner/counterpart model; visibility-driven chunking. -- src/* — see Codebase Map above. - - -Appendix A) Helper formulas -- Scale key: - toScaleKey(chunkDataSize, baseVoxelOffset, upperVoxelBound) → "cx_cy_cz:lx_ly_lz-ux_uy_uz". -- Chunk key: - toChunkKey([cx, cy, cz]) → "cx,cy,cz". diff --git a/NOTES/vox-layer-v2.md b/NOTES/vox-layer-v2.md deleted file mode 100644 index 1e5ac87d06..0000000000 --- a/NOTES/vox-layer-v2.md +++ /dev/null @@ -1,47 +0,0 @@ -L'implementation actuelle du vox layer ne suis pas le principe fondamental de neuroglancer visant a separer les layer des data source. Cela ce manifest dans le fait que le vox layer ne support seulement le format zarr v2 sans compression grace a deux fichiers temporaires que sont [import_from_zarr.ts](../src/voxel_annotation/import_from_zarr.ts) et [export_to_zarr.ts](../src/voxel_annotation/export_to_zarr.ts). Ces fichiers ont ete cree pour un besoin de proofs of concept (POC) mais il va de soi qu'une implementation propre du vox layer ne sera possible que lorsque les differents datasource seront supporte. Mais supporter ces datasource n'est pas trivial, cela require de faire des changements dans le code de neuroglancer qui ne support que les operation de lecture. Alors Kvstore, datasource et autre auront besoin d'etre augmenter de capacite d'ecriture. -En sommes, nous arrivons a un point de bascule qui require un potentiel fort refactor de l'implementation actuelle du vox layer, mais cette tache est complexe a cadrer et c'est pourquoi notre premier travail sera de realiser un etat des lieu de l'implementation et d'en reecrire par la suite la nouvelle architecture. - - -Jalon no1: -Abstraction des datasources, writtable kvstore&datasource: -- L'utilisation de l'indexedDB (local datasource) ne doit plus etre systematique, celle-ci doit etre encapsulee dans un datasource a l'instar de zarr ou precomputed. -- Deux nouvelles classes - -## What do we have right now - -### Custom map system / data source - -To facilitate the creation of a proof of concept, I bypassed the data handling pipeline of neuroglancer, I used a dummy `local://voxel_annotation` datasource and created a secondary simple data management system that includes a ui for map (the term I used to refer to a specific dataset) creation, selection and importation/exportation from/to zarr v2 uncompressed dataset on s3 buckets. The data is also saved in a local IndexedDB (note: the use of OPFS would have been more appropriate) with a custom format for persistence. -The related code include: -- [map.ts](../src/voxel_annotation/map.ts) map utils including a VoxMapConfig object to transmit the selected map config -- [index.ts](../src/voxel_annotation/index.ts) and [local_source.ts](../src/voxel_annotation/local_source.ts) the data source and its indexedDB management, it exposed a `VoxSource` which is read-only and destined to be used by the chunk source and a `VoxSourceWriter` which is writable and destined to be used by the unique edit controller -- [settings.ts](../src/layer/vox/tabs/settings.ts) the ui for map creation and selection -- [import_from_zarr.ts](../src/voxel_annotation/import_from_zarr.ts) and [export_to_zarr.ts](../src/voxel_annotation/export_to_zarr.ts) the zarr v2 import/export tools - -### Rendering - -A simple `VoxelAnnotationRenderLayer` ([renderlayer.ts](../src/voxel_annotation/renderlayer.ts)) provides a shader who proceduraly assigns a color to an uint64 label value (similarely to the segmentation layer) and renders those colors at 50% opacity. If the label is 0, nothing is displayed. - -### Chunking - -A `VoxMultiscaleVolumeChunkSource` ([volume_chunk_source.ts](../src/voxel_annotation/volume_chunk_source.ts)) handles the multi-resolution chunking for the voxel data. It generates a hierarchy of resolutions (levels of detail) based on the `steps` defined in the `VoxMapConfig`. For each resolution level, it creates a `VoxChunkSource` ([frontend.ts](../src/voxel_annotation/frontend.ts)) which is responsible for fetching individual data chunks. The backend counterpart, [backend.ts](../src/voxel_annotation/backend.ts), retrieves chunk data from the local IndexedDB source or the remote Zarr import source if available, otherwise returning an empty, zero-filled chunk. - -### Editing - -Only the max resolution voxels can be painted, when painting, the chunk are scheduled to be downsampled. The edited chunk are directly updated in the frontend and then persisted to the data source. We can paint using a brush (disk shape or sphere) or a flood fill tool (flooding only on 2d slices). - -The choice was made to first make the annotation work at this max resolution detail and later look at how to make upscaling work (two approach are possible, one direct like the downscaling or one delegated to when we actually need the chunk, the first one is simple but will not work for approx 3/4 levels max, the second could be less limited but comport confict handling issue which would require us to design a more complex system) - -Editing functionality is managed through a `VoxelEditController` which acts as a bridge between the user interface and the backend data storage. The frontend controller ([edit_controller.ts](../src/voxel_annotation/edit_controller.ts)) receives edit commands, such as painting with a brush, from UI tools defined in [voxel_annotations.ts](../src/ui/voxel_annotations.ts). - -These edits are then batched and sent via RPC to the `VoxelEditController` backend ([edit_backend.ts](../src/voxel_annotation/edit_backend.ts)). The backend owns the authoritative `VoxSourceWriter` for a given map and applies these edits. To persist the changes, the edited chunks are written to the local IndexedDB. The backend also queue Downsampling jobs for each edited chunk, when downsampled, a chunk is then triggered to be realoaded (note: this reloading feature still needs to be correctly implemented, for now I invalidate a whole VoxChunkSource to force a reload of the chunk). - -The live preview of paintings and chunk reloading are handled in the `VoxChunkSource`, as well as some calculations for the painting tool (e.g. the flood fill algorithm for example, which requires reading voxels around the target). - -## What we want to achieve - -Obviously, we want to replace the current custom data handling with the one of neuroglancer. But neuroglancer has been design as a read-only system. Without delving too much into the technical details, we first need to choose how we want to consider our writting path: -- we could require to use datasource that are read-write, then if we want to modify a segmentation the user would need to copy the wanted area to a writtable source. -- we could add a way to specify a secondary writtable datasource aside of the read-only one. Then the writtable source would contain an overlay of edits to apply over the original data. - -This new approach would replace the whole `custom map system / data source` detailed above. diff --git a/NOTES/voxel-annotation-specification.md b/NOTES/voxel-annotation-specification.md deleted file mode 100644 index 8ba2379fb3..0000000000 --- a/NOTES/voxel-annotation-specification.md +++ /dev/null @@ -1,85 +0,0 @@ -# Voxel Annotation Specification (Revised) - -## 1. Overview - -The objective of the voxel annotation feature is to allow precise, voxel-aligned labeling of image data, primarily for deep learning training and validation. The existing annotation system in Neuroglancer is vector-based and not suited for this task. This specification outlines a new, parallel voxel annotation system designed for performance, scalability, and ergonomic use. - -## 2. Core Features & Tools - -The user will be provided with a suite of drawing tools for efficient annotation. - -- **Brush**: A circular brush with adjustable size. -- **Flood Fill (2D/3D)**: A tool to fill contiguous areas of the same underlying data value or annotation label. -- **Eraser**: A circular eraser with adjustable size. -- **MVP Tool**: A single-voxel "Pixel" tool to validate the core architecture. - -## 3. Data Storage and State Management - -To ensure a responsive user experience while maintaining data integrity, we will implement a three-tier data management architecture. This formalizes the asynchronous saving process and clarifies the role of each component. - -``` -┌──────────────────┐ User Edit ┌────────────────┐ (Debounced) ┌────────────────────────┐ -│ Frontend UI ├──────────────>│ Worker State ├────────────────>│ Persistent Storage │ -│ (Render Layer) │ │ (Chunked Map) │ │ (local:// or http://) │ -└──────────┬───────┘ └────────┬───────┘ └────────────────────────┘ - │ │ - │ User draws, updates │ Receives edit actions, - │ "Hot" cache instantly │ applies to chunks, marks - │ & sends action to worker │ them as "dirty" for saving - v - [Frontend "Hot" Cache] -``` - -#### Tier 1: Frontend State (The "Hot" Cache) - -- **Location**: Frontend (UI thread). -- **Purpose**: Provide immediate visual feedback to the user. -- **Mechanism**: When a user draws, the edit is applied to an immediate, in-memory representation and rendered instantly. Simultaneously, an "action" describing the edit is dispatched to the web worker. - -#### Tier 2: Worker State (The "Warm" Source of Truth) - -- **Location**: Web Worker. -- **Purpose**: To act as the authoritative, canonical state of the annotations. -- **Mechanism**: The worker maintains a map of all annotation chunks (`Map`). It listens for actions from the frontend, applies them to the corresponding chunks, and marks those chunks as "dirty." - -#### Tier 3: Persistent Storage (The "Cold" Layer) - -- **Location**: The data source (e.g., `local://voxel-annotations`). -- **Purpose**: Long-term, durable storage. -- **Mechanism**: The worker uses a throttled or debounced function to periodically write all "dirty" chunks from its state (Tier 2) to the persistent data source. This ensures that frequent edits do not overload the storage backend and that the UI never waits for a save operation. - -#### Tier 4: Multi-users - -The arch should have all the necessary components to support multi-user annotation, such a feature could be implemented in the future. This multi-user feature would be similar to the one found in Google Docs. - -### 3.1. MVP In-Memory Data Structure - -For the MVP, we will simplify the problem by restricting annotations to a single, user-selectable scale. This provides a clear structure for organizing the data within the worker's memory. - -- Annotation Scale Selection: The VoxUserLayer UI will include a dropdown or similar control that allows the user to select which scale (resolution) from a reference image layer they wish to annotate on. All subsequent drawing actions will apply to this single, chosen scale. -- In-Worker Data Structure: The worker will namespace the chunks in its internal Map using a key that combines the scale and chunk identifiers. This prevents collisions if the user switches between annotating different scales. - - Map Key Format: / - - Example Key: "4_4_40/0-64_0-64_0-64" -- In-Memory Chunk Format: - - Each chunk will be stored in the worker's map as a Uint32Array. - - The total length of the array will be chunkSizeX _ chunkSizeY _ chunkSizeZ (e.g., 64x64x64 = 262,144 elements). - - The value 0 represents an un-annotated voxel. Values 1..n correspond to different user-defined labels. - -## 4. LOD, Scaling, and Performance - -The absence of pre-computed mipmaps for user-drawn data presents the primary performance challenge. We will tackle this with a phased approach. - -### MVP Strategy - -Render annotations only at their native resolution. The annotation layer will be hidden when the view is zoomed too far out or in, avoiding the LOD problem entirely to validate the core drawing and saving functionality. - -### Phase 2: On-the-Fly Worker Downsampling - -- The `VoxChunkSource` will be responsible for generating lower-resolution chunks. -- When the renderer requests a chunk at a lower LOD (e.g., LOD 1), the `VoxChunkSource` will request the corresponding 8 chunks at the higher resolution (LOD 0) from the Worker State. -- It will then compute a downsampled chunk on-the-fly (e.g., using a majority vote for the label in each 2x2x2 region). -- **Caching**: Generated low-LOD chunks will be cached in the worker to avoid re-computation. This cache is invalidated when any of the underlying high-resolution data changes. - -### Phase 3 (Future): Sparse Voxel Structures - -For ultimate performance and memory efficiency with very sparse annotations, the worker could manage the data in a hierarchical structure like a Sparse Voxel Octree (SVO). This would be a major undertaking but would provide the most scalable solution. diff --git a/NOTES/weekly-progress.md b/NOTES/weekly-progress.md deleted file mode 100644 index 42d5b17373..0000000000 --- a/NOTES/weekly-progress.md +++ /dev/null @@ -1,100 +0,0 @@ -# Weekly progress on the vox annotation project - -## Week 1 (2025-09-02 → 2025-09-07) - -### Weekly narrative -The first week established the foundations for voxel annotations in Neuroglancer. The goal was to first familiarize myself with the project and the codebase while standing up a minimal yet end-to-end path for visualizing vox data and validating the rendering contract. We introduced the Vox layer type, a procedural dummy source to feed predictable data (a simple checkerboard). The main challenges were stabilizing the initial rendering path (fighting artifacts) and iterating on a concise but extensible annotation specification. - -### Delivered capabilities -- Bootstrapped the Vox layer type and initial tooling for voxel annotations. -- Implemented the first rendering path and specification; added a procedural demo (VoxDummyChunkSource) to visualize data. -- Brought up the checkerboard demo and iterated on rendering stability. -- Reworked/expanded the voxel annotation specifications documentation. - -### Notable commits (by brieuc.crosson) -- 2025-09-02 9b71be4f feat: add new dummy layer type: voxel annotation (vox) -- 2025-09-02 c1d2e802 feat: add a new dummy pixel tool -- 2025-09-02 07c116ad feat: retreive mouse position and current LOD scale -- 2025-09-04 c0ceef34 feat: add support for voxel annotation rendering and specification -- 2025-09-04 cbe55c86 feat: introduce VoxDummyChunkSource for procedural voxel annotation demo -- 2025-09-04 ff13ca26 feat: no errors but no checkboard tho -- 2025-09-05 8ea20800 feat: finaly the checkboard is showing, but it is a bit bugged out, it seems there is some fighting. -- 2025-09-05 6140a28b doc: rework voxel annotation specs - -## Week 2 (2025-09-08 → 2025-09-14) - -### Weekly narrative -This week focused on making editing practical and robust. We hardened the pixel tool, added a brush with configurable size, and tackled UX responsiveness during drawing. To persist user work, we introduced a local IndexedDB-backed store with RPC plumbing and laid groundwork for labels. We also began exploring remote sources and improved settings to handle extreme zoom-out safely. Key hurdles included a data corruption bug during edits and a coordinate conversion bug when scales differed; both were resolved while redesigning the toolbox UI. - -### Delivered capabilities -- Made the pixel tool robust and added a brush tool with radius, eraser mode, continuous strokes, and disk/sphere shapes. -- Added user settings for scale and bounds; introduced a guard source for safe extreme zoom-out. -- Persisted edits to the backend and improved drawing responsiveness; redesigned the toolbox UI with structured layout. -- Implemented IndexedDB-backed local storage for maps/chunks/labels with RPC plumbing; added label creation and UI rendering. -- Supported region-based voxel initialization and expanded map options in the UI. -- Introduced remote HTTP(S) voxel source; migrated label API to addLabel; added project overview and process documentation. - -### Notable commits (by brieuc.crosson) -- 2025-09-08 65565130 feat: working on the pixel tool, there are interaction but a bug seems to corruped the chunk after the usage of the tool. Added a front end buffer which is the only drawing storage for now. Added user settings to set the voxel_annotation layer scale and bounds. Added a second empty source to DummyMultiscaleVolumeChunkSource to prevent crashs when zoomed out too much -- 2025-09-08 14336ab4 feat: pixel tool is now working as intended -- 2025-09-08 44a6754f feat: fix pixel tool not working when the scale is not equal to the global one (there where a missing convertion) ; add a primitive brush tool -- 2025-09-08 a3f05989 refactor: rename DummyMultiscaleVolumeChunkSource to VoxMultiscaleVolumeChunkSource and update related imports -- 2025-09-08 238958a8 feat: brush size, eraser mode and little trivial optimization -- 2025-09-08 3017fe4c feat: continuous drawing and shape selection for the brush -- 2025-09-08 c517586e doc: add TODO list -- 2025-09-09 94af3d47 feat: small improvement on the drawing render delay -- 2025-09-09 faf0947d feat: persist voxel edits to backend and improve drawing responsiveness -- 2025-09-09 29d53634 feat: redesign toolbox with structured layout, tool selection, and expanded brush settings -- 2025-09-09 19f4e103 feat: implement new local voxel storage with IndexedDB, map initialization, and improved backend edit handling -- 2025-09-09 5172bc76 doc: brainstorming LOD -- 2025-09-10 92c3c215 feat: support region-based voxel initialization with corners, update map options and UI settings -- 2025-09-10 e70c9ff3 feat: expand TODOs with plans for segmentation compression, multi-user remote workflows, label creation, and new drawing tools -- 2025-09-10 e04b3167 feat: implement voxel label creation, persistence via IndexedDB, and enhanced UI rendering -- 2025-09-11 07a59c38 feat: implement RPC-based voxel label persistence -- 2025-09-11 73460e1d refactor: ran 'npm run format:fix' -- 2025-09-11 02e28fd4 feat: add support for remote voxel sources via HTTP(S) - (note: the labels are not sync currently) -- 2025-09-12 7c1a3b4e feat: replace `setLabelIds` with `addLabel` for label management -- 2025-09-12 673cea63 doc: add guidelines for junie and write project overview file -- 2025-09-12 d90b5569 feat: map creation and selection, the min scale is currently not saved and part of the codebase for this feature is subject to rewritting because of ugly code. -- 2025-09-12 39b3ff6f feat: cleanup map init/selection implementation, the remote still needs an update to align with the new architecture - -## Week 3 (2025-09-15 → 2025-09-22) - -### Weekly narrative -We turned to multiscale workflows and consistency across levels of detail. LOD-based painting and LOD locking shipped, and we began modularizing VoxSource implementations. We added chunk reload and downsample propagation to keep edited data coherent. An attempted “dirty-tree upscaling” approach was explored and intentionally dropped after discovering fundamental conflicts and quality loss when reconciling upscaled strokes. The system gained a centralized VoxelEditController, better invalidation/reload handling, a flood fill tool, a downscale job queue, and an export flow. - -### Delivered capabilities -- Advanced multiscale/LOD workflow: enabled LOD-based brush painting and LOD locking; began moving VoxSource implementations into separate files. -- Introduced chunk reload and downsample propagation APIs; experimented with dirty-tree upscaling, then disabled it due to conflicts/quality loss; restricted brush size for stability. -- Added VoxelEditController to centralize edit flows; improved chunk invalidation and reload mechanics. -- Added flood fill tool; implemented a downscale job queue; refined reload handling and improved flood fill stability. -- Added Zarr export and reworked map settings UI, and started working on the import flow. - -### Notable commits (by brieuc.crosson) -- 2025-09-15 0992672c refactor: remove `VoxelPixelLegacyTool`, update references, and enable LOD-based brush painting -- 2025-09-15 b08e9dd2 feat: add LOD locking for voxel rendering and extend brush size range -- 2025-09-15 92edee99 feat: move local and remote VoxSource to separate files, updated the LocalVoxSource and VoxChunkSource backend for handling of different lod level chunks -- 2025-09-15 e5ae7112 feat: move local and remote VoxSource to separate files, updated the LocalVoxSource and VoxChunkSource backend for handling of different lod level chunks -- 2025-09-16 6ec2674c feat: introduce chunk reload and downsample propagation APIs -- 2025-09-16 6ed8adda feat: chunk reloading from the backend -> currently do not work due to a design issue: the VoxSource is not unique, one is created for each VoxChunkSource -- 2025-09-16 1360c3ec feat: add Zarr export functionality and dirty-tree upscaling (not working for now) -- 2025-09-17 c8464f87 feat: dirty tree upscaling is kinda working, at least enough to conclude that this upscaling method wont work due to unsolvable conficts and lost unavoidable lost of quality due to upscaling of downscaled strokes. A new approach will be to enqueue every upscale and downscale and throttle the user when the queue is too full, with some kind of indicator in the ui. We also may need to restrict the max brush size to avoid too long waiting time. -- 2025-09-17 958df676 feat: restrict brush size and disable dirty tree upscaling -- 2025-09-18 c53888fc feat: introduce VoxelEditController for centralized edit handling and map management -- 2025-09-18 2508c671 refactor: improve chunk invalidation and reload workflows -- 2025-09-18 5335bc94 feat: add flood fill tool and export UI improvements -- 2025-09-18 e42d4fa4 feat: implement downscale job queue and improve chunk reload handling -- 2025-09-19 64f5f38e feat: enhance flood fill stability and optimize Zarr export -- 2025-09-19 ddc5c73e feat: rework map settings UI and add import/export improvements -- 2025-09-19 2addad4e doc: update TODOs - -## Week 4 (2025-09-22 → 2025-09-29) — ongoing - -### Weekly narrative -Week 4 kicked off the final leg of the basic I/O story by adding Zarr import and a remote-chunk fallback path. The motivation is to ensure people can round-trip data and recover missing local chunks from a remote source when needed. Early challenges include aligning fallback semantics with caching and ensuring consistency across LODs during import. Work is in progress. - -### Delivered capabilities -- Started Week 4 with Zarr import support and integration of remote chunk fallback to complete the basic I/O path. - -### Notable commits (by brieuc.crosson) -- 2025-09-22 73eb4ec3 feat: add Zarr import support and integrate remote chunk fallback diff --git a/src/chunk_manager/backend.ts b/src/chunk_manager/backend.ts index 0fa16cd5e2..e5c4c5d68c 100644 --- a/src/chunk_manager/backend.ts +++ b/src/chunk_manager/backend.ts @@ -15,12 +15,12 @@ */ import { throttle } from "lodash-es"; -import { - CHUNK_SOURCE_INVALIDATE_CHUNKS_RPC_ID, +import type { ChunkSourceParametersConstructor, LayerChunkProgressInfo, } from "#src/chunk_manager/base.js"; import { + CHUNK_SOURCE_INVALIDATE_CHUNKS_RPC_ID, CHUNK_LAYER_STATISTICS_RPC_ID, CHUNK_MANAGER_RPC_ID, CHUNK_QUEUE_MANAGER_RPC_ID, @@ -1111,10 +1111,10 @@ export class ChunkQueueManager extends SharedObjectCounterpart { } } - invalidateCachedChunks(source: ChunkSource, keys: string[]){ + invalidateCachedChunks(source: ChunkSource, keys: string[]) { for (const key of keys) { const chunk = source.chunks.get(key); - if(!chunk) continue; + if (!chunk) continue; switch (chunk.state) { case ChunkState.DOWNLOADING: cancelChunkDownload(chunk); @@ -1129,7 +1129,7 @@ export class ChunkQueueManager extends SharedObjectCounterpart { } invalidateSourceCache(source: ChunkSource) { - this.invalidateCachedChunks(source, [...source.chunks.keys()]) + this.invalidateCachedChunks(source, [...source.chunks.keys()]); this.rpc!.invoke("Chunk.update", { source: source.rpcId }); this.scheduleUpdate(); } diff --git a/src/chunk_manager/base.ts b/src/chunk_manager/base.ts index 18afd3e3f0..f897fe7d56 100644 --- a/src/chunk_manager/base.ts +++ b/src/chunk_manager/base.ts @@ -97,7 +97,8 @@ export const PREFETCH_PRIORITY_MULTIPLIER = 1e13; export const CHUNK_QUEUE_MANAGER_RPC_ID = "ChunkQueueManager"; export const CHUNK_MANAGER_RPC_ID = "ChunkManager"; export const CHUNK_SOURCE_INVALIDATE_RPC_ID = "ChunkSource.invalidate"; -export const CHUNK_SOURCE_INVALIDATE_CHUNKS_RPC_ID = "ChunkSource.invalidateChunks"; +export const CHUNK_SOURCE_INVALIDATE_CHUNKS_RPC_ID = + "ChunkSource.invalidateChunks"; export const REQUEST_CHUNK_STATISTICS_RPC_ID = "ChunkQueueManager.requestChunkStatistics"; diff --git a/src/chunk_manager/frontend.ts b/src/chunk_manager/frontend.ts index 798e783d62..dfd8b68dce 100644 --- a/src/chunk_manager/frontend.ts +++ b/src/chunk_manager/frontend.ts @@ -14,12 +14,12 @@ * limitations under the License. */ -import { - CHUNK_SOURCE_INVALIDATE_CHUNKS_RPC_ID, +import type { ChunkSourceParametersConstructor, LayerChunkProgressInfo, } from "#src/chunk_manager/base.js"; import { + CHUNK_SOURCE_INVALIDATE_CHUNKS_RPC_ID, CHUNK_LAYER_STATISTICS_RPC_ID, CHUNK_MANAGER_RPC_ID, CHUNK_QUEUE_MANAGER_RPC_ID, @@ -477,7 +477,10 @@ export class ChunkSource extends SharedObject { } if (validKeys.length > 0) { - this.rpc!.invoke(CHUNK_SOURCE_INVALIDATE_CHUNKS_RPC_ID, { id: this.rpcId, keys: validKeys }); + this.rpc!.invoke(CHUNK_SOURCE_INVALIDATE_CHUNKS_RPC_ID, { + id: this.rpcId, + keys: validKeys, + }); } if (changed) { diff --git a/src/chunk_worker.bundle.js b/src/chunk_worker.bundle.js index 6141f24968..4c1fa3e86c 100644 --- a/src/chunk_worker.bundle.js +++ b/src/chunk_worker.bundle.js @@ -12,4 +12,4 @@ import "#src/annotation/backend.js"; import "#src/datasource/enabled_backend_modules.js"; import "#src/kvstore/enabled_backend_modules.js"; import "#src/worker_rpc_context.js"; -import "#src/voxel_annotation/edit_backend.js" +import "#src/voxel_annotation/edit_backend.js"; diff --git a/src/datasource/local.ts b/src/datasource/local.ts index f67e8abaf4..4df6dc0443 100644 --- a/src/datasource/local.ts +++ b/src/datasource/local.ts @@ -122,7 +122,7 @@ export class LocalDataSourceProvider implements DataSourceProvider { value: "equivalences", description: "Segmentation equivalence graph stored in the JSON state", - } + }, ], (x) => x.value, (x) => x.description, diff --git a/src/datasource/zarr/backend.ts b/src/datasource/zarr/backend.ts index c06d64fc97..9411789684 100644 --- a/src/datasource/zarr/backend.ts +++ b/src/datasource/zarr/backend.ts @@ -28,13 +28,13 @@ import { import "#src/datasource/zarr/codec/gzip/decode.js"; import "#src/datasource/zarr/codec/sharding_indexed/decode.js"; import "#src/datasource/zarr/codec/transpose/decode.js"; +import { encodeArray } from "#src/datasource/zarr/codec/encode.js"; import { ChunkKeyEncoding } from "#src/datasource/zarr/metadata/index.js"; import { WithSharedKvStoreContextCounterpart } from "#src/kvstore/backend.js"; import { postProcessRawData } from "#src/sliceview/backend_chunk_decoders/postprocess.js"; import type { VolumeChunk } from "#src/sliceview/volume/backend.js"; import { VolumeChunkSource } from "#src/sliceview/volume/backend.js"; import { registerSharedObject } from "#src/worker_rpc.js"; -import { encodeArray } from "#src/datasource/zarr/codec/encode.js"; @registerSharedObject() export class ZarrVolumeChunkSource extends WithParameters( @@ -102,14 +102,20 @@ export class ZarrVolumeChunkSource extends WithParameters( async writeChunk(chunk: VolumeChunk): Promise { const { kvStore, getChunkKey, decodeCodecs } = this.chunkKvStore as any; if (!kvStore.write) { - throw new Error("ZarrVolumeChunkSource.writeChunk: underlying kvStore is not writable"); + throw new Error( + "ZarrVolumeChunkSource.writeChunk: underlying kvStore is not writable", + ); } if (!chunk.data) { throw new Error("ZarrVolumeChunkSource.writeChunk: missing chunk.data"); } // Encode using the same codecs chain that was used to decode, but in reverse; our minimal // encodeArray currently only supports raw 'bytes'. - const encoded = await encodeArray(decodeCodecs, chunk.data as ArrayBufferView, new AbortController().signal); + const encoded = await encodeArray( + decodeCodecs, + chunk.data as ArrayBufferView, + new AbortController().signal, + ); // Compute base key same as in download. const { parameters } = this; diff --git a/src/kvstore/index.ts b/src/kvstore/index.ts index afb249a7ba..763d3190fa 100644 --- a/src/kvstore/index.ts +++ b/src/kvstore/index.ts @@ -96,7 +96,10 @@ export interface WritableKvStore { delete?: (key: string) => Promise; } -export interface KvStore extends ReadableKvStore, ListableKvStore, WritableKvStore { +export interface KvStore + extends ReadableKvStore, + ListableKvStore, + WritableKvStore { // Indicates that the only valid key is the empty string. singleKey?: boolean; } diff --git a/src/kvstore/opfs/backend.ts b/src/kvstore/opfs/backend.ts index 14ba875950..3da1cd0063 100644 --- a/src/kvstore/opfs/backend.ts +++ b/src/kvstore/opfs/backend.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import type { SharedKvStoreContextCounterpart } from "#src/kvstore/backend.js"; import type { DriverListOptions, DriverReadOptions, @@ -23,12 +24,19 @@ import type { StatOptions, StatResponse, } from "#src/kvstore/index.js"; -import type { SharedKvStoreContextCounterpart } from "#src/kvstore/backend.js"; -import { encodePathForUrl, kvstoreEnsureDirectoryPipelineUrl } from "#src/kvstore/url.js"; +import { + encodePathForUrl, + kvstoreEnsureDirectoryPipelineUrl, +} from "#src/kvstore/url.js"; function ensureOpfsAvailable(context: string): void { - if (typeof navigator === "undefined" || (navigator as any).storage === undefined) { - throw new Error(`${context}: OPFS (navigator.storage) is not available in this environment`); + if ( + typeof navigator === "undefined" || + (navigator as any).storage === undefined + ) { + throw new Error( + `${context}: OPFS (navigator.storage) is not available in this environment`, + ); } } @@ -73,50 +81,87 @@ export class OpfsKvStore implements KvStore { private readonly basePathSegments: string[]; private rootDirectoryPromise: Promise | undefined; - constructor(public sharedKvStoreContext: SharedKvStoreContextCounterpart, basePath: string) { + constructor( + public sharedKvStoreContext: SharedKvStoreContextCounterpart, + basePath: string, + ) { this.basePathSegments = splitPath(basePath); } private getRoot(): Promise { - if (this.rootDirectoryPromise !== undefined) return this.rootDirectoryPromise; + if (this.rootDirectoryPromise !== undefined) + return this.rootDirectoryPromise; this.rootDirectoryPromise = getRootDirectoryHandle(); return this.rootDirectoryPromise; } private async getBaseDirectory(): Promise { const root = await this.getRoot(); - return await getDirectoryHandleForPath(root, this.basePathSegments, /*create=*/ true); + return await getDirectoryHandleForPath( + root, + this.basePathSegments, + /*create=*/ true, + ); } - async stat(key: string, _options: StatOptions): Promise { + async stat( + key: string, + _options: StatOptions, + ): Promise { const base = await this.getBaseDirectory(); const pathSegments = splitPath(key); try { - const fileHandle = await getFileHandleForPath(base, pathSegments, /*create=*/ false); + const fileHandle = await getFileHandleForPath( + base, + pathSegments, + /*create=*/ false, + ); const file = await fileHandle.getFile(); return { totalSize: file.size }; } catch (e) { - if (e instanceof DOMException && (e.name === "NotFoundError" || e.name === "NotAllowedError")) { + if ( + e instanceof DOMException && + (e.name === "NotFoundError" || e.name === "NotAllowedError") + ) { return undefined; } - throw new Error(`stat(${key}) failed for ${this.getUrl(key)}: ${String((e as Error).message ?? e)}`); + throw new Error( + `stat(${key}) failed for ${this.getUrl(key)}: ${String((e as Error).message ?? e)}`, + ); } } - async read(key: string, _options: DriverReadOptions): Promise { + async read( + key: string, + _options: DriverReadOptions, + ): Promise { const base = await this.getBaseDirectory(); const pathSegments = splitPath(key); try { - const fileHandle = await getFileHandleForPath(base, pathSegments, /*create=*/ false); + const fileHandle = await getFileHandleForPath( + base, + pathSegments, + /*create=*/ false, + ); const file = await fileHandle.getFile(); const buffer = await file.arrayBuffer(); const response = new Response(buffer); - return { response, offset: 0, length: buffer.byteLength, totalSize: buffer.byteLength }; + return { + response, + offset: 0, + length: buffer.byteLength, + totalSize: buffer.byteLength, + }; } catch (e) { - if (e instanceof DOMException && (e.name === "NotFoundError" || e.name === "NotAllowedError")) { + if ( + e instanceof DOMException && + (e.name === "NotFoundError" || e.name === "NotAllowedError") + ) { return undefined; } - throw new Error(`read(${key}) failed for ${this.getUrl(key)}: ${String((e as Error).message ?? e)}`); + throw new Error( + `read(${key}) failed for ${this.getUrl(key)}: ${String((e as Error).message ?? e)}`, + ); } } @@ -124,7 +169,9 @@ export class OpfsKvStore implements KvStore { const base = await this.getBaseDirectory(); const pathSegments = splitPath(key); const fh = await getFileHandleForPath(base, pathSegments, /*create=*/ true); - const writable = await (fh as any).createWritable({ keepExistingData: false }); + const writable = await (fh as any).createWritable({ + keepExistingData: false, + }); try { await writable.write(new Uint8Array(value)); } finally { @@ -136,17 +183,30 @@ export class OpfsKvStore implements KvStore { const base = await this.getBaseDirectory(); const parts = splitPath(key); if (parts.length === 0) throw new Error("delete: empty key"); - const parent = await getDirectoryHandleForPath(base, parts.slice(0, -1), /*create=*/ false); - await (parent as any).removeEntry(parts[parts.length - 1], { recursive: false }); + const parent = await getDirectoryHandleForPath( + base, + parts.slice(0, -1), + /*create=*/ false, + ); + await (parent as any).removeEntry(parts[parts.length - 1], { + recursive: false, + }); } - async list(prefix: string, _options: DriverListOptions): Promise { + async list( + prefix: string, + _options: DriverListOptions, + ): Promise { const base = await this.getBaseDirectory(); const prefixSegments = splitPath(prefix); const dirForPrefix = await (async () => { try { - return await getDirectoryHandleForPath(base, prefixSegments, /*create=*/ false); + return await getDirectoryHandleForPath( + base, + prefixSegments, + /*create=*/ false, + ); } catch (e) { if (e instanceof DOMException && e.name === "NotFoundError") { return undefined; @@ -162,8 +222,13 @@ export class OpfsKvStore implements KvStore { const entries: Array<{ key: string }> = []; const directories = new Set(); - for await (const [name, handle] of (dirForPrefix as any).entries() as AsyncIterable<[string, FileSystemHandle]>) { - const fullKey = (prefix === "" ? name : `${prefix}${prefix.endsWith("/") ? "" : "/"}${name}`); + for await (const [name, handle] of ( + dirForPrefix as any + ).entries() as AsyncIterable<[string, FileSystemHandle]>) { + const fullKey = + prefix === "" + ? name + : `${prefix}${prefix.endsWith("/") ? "" : "/"}${name}`; if ((handle as FileSystemDirectoryHandle).kind === "directory") { directories.add(fullKey); } else { @@ -171,19 +236,28 @@ export class OpfsKvStore implements KvStore { } } - const sortedEntries = entries.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); - const sortedDirectories = Array.from(directories).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + const sortedEntries = entries.sort((a, b) => + a.key < b.key ? -1 : a.key > b.key ? 1 : 0, + ); + const sortedDirectories = Array.from(directories).sort((a, b) => + a < b ? -1 : a > b ? 1 : 0, + ); return { entries: sortedEntries, directories: sortedDirectories }; } getUrl(key: string): string { const base = this.basePathSegments.join("/"); - const baseUrl = base === "" ? "opfs://" : `opfs://${encodePathForUrl(base)}/`; + const baseUrl = + base === "" ? "opfs://" : `opfs://${encodePathForUrl(base)}/`; const ensured = kvstoreEnsureDirectoryPipelineUrl(baseUrl); return ensured + (key === "" ? "" : encodePathForUrl(key)); } - get supportsOffsetReads(): boolean { return false; } - get supportsSuffixReads(): boolean { return false; } + get supportsOffsetReads(): boolean { + return false; + } + get supportsSuffixReads(): boolean { + return false; + } } diff --git a/src/kvstore/opfs/common.ts b/src/kvstore/opfs/common.ts index ba385dca77..3e61171429 100644 --- a/src/kvstore/opfs/common.ts +++ b/src/kvstore/opfs/common.ts @@ -22,21 +22,30 @@ import type { } from "#src/kvstore/register.js"; import type { UrlWithParsedScheme } from "#src/kvstore/url.js"; -function parseOpfsUrlSuffix(suffix: string | undefined): { basePath: string; path: string } { +function parseOpfsUrlSuffix(suffix: string | undefined): { + basePath: string; + path: string; +} { // Accept opfs://, opfs:/, or opfs: const s = suffix ?? ""; const m = s.match(/^\/?\/?(.*)$/); if (m === null) { - throw new Error(`Invalid opfs URL suffix ${JSON.stringify(s)}; expected opfs://`); + throw new Error( + `Invalid opfs URL suffix ${JSON.stringify(s)}; expected opfs://`, + ); } const decoded = decodeURIComponent(m[1] ?? ""); // Choose to have basePath be empty and return full path as initial kv path. return { basePath: "", path: decoded }; } -export function registerProviders( +export function registerProviders< + SharedKvStoreContext extends SharedKvStoreContextBase, +>( registry: KvStoreProviderRegistry, - OpfsKvStoreClass: { new (sharedKvStoreContext: SharedKvStoreContext, basePath: string): KvStore }, + OpfsKvStoreClass: { + new (sharedKvStoreContext: SharedKvStoreContext, basePath: string): KvStore; + }, ) { const provider: (context: SharedKvStoreContext) => BaseKvStoreProvider = ( sharedKvStoreContext: SharedKvStoreContext, @@ -45,7 +54,10 @@ export function registerProviders { const url = joinBaseUrlAndPath(this.baseUrl, key); try { await this.fetchOkImpl(url, { - method: 'PUT', + method: "PUT", body: value, }); } catch (e) { - throw new Error( - `Failed to write to ${url}.`, - { cause: e } - ); + throw new Error(`Failed to write to ${url}.`, { cause: e }); } } @@ -108,16 +104,13 @@ export class S3KvStoreBase< const url = joinBaseUrlAndPath(this.baseUrl, key); try { await this.fetchOkImpl(url, { - method: 'DELETE', + method: "DELETE", }); } catch (e) { if (e instanceof HttpError && e.status === 404) { return; } - throw new Error( - `Failed to delete ${url}.`, - { cause: e } - ); + throw new Error(`Failed to delete ${url}.`, { cause: e }); } } diff --git a/src/kvstore/ssa_s3/README.md b/src/kvstore/ssa_s3/README.md new file mode 100644 index 0000000000..c00ea880fe --- /dev/null +++ b/src/kvstore/ssa_s3/README.md @@ -0,0 +1,2 @@ +The Stateless S3 Authenticator (SSA) is an authentication service that uses an OIDC portal to verify user identity. It then generates secure, temporary, pre-signed URLs that allow Neuroglancer to directly read from and write to private S3 buckets. +See [TODO: link the github here after it is created...] for more details. diff --git a/src/kvstore/ssa_s3/credentials_provider.ts b/src/kvstore/ssa_s3/credentials_provider.ts index 157111a721..8031250677 100644 --- a/src/kvstore/ssa_s3/credentials_provider.ts +++ b/src/kvstore/ssa_s3/credentials_provider.ts @@ -50,13 +50,19 @@ function parseSsaConfiguration(json: unknown): SsaConfiguration { return { issuer }; } -async function discoverSsaConfiguration(workerOrigin: string): Promise { - const response = await fetchOk(`${workerOrigin}/.well-known/ssa-configuration`); +async function discoverSsaConfiguration( + workerOrigin: string, +): Promise { + const response = await fetchOk( + `${workerOrigin}/.well-known/ssa-configuration`, + ); const config = parseSsaConfiguration(await response.json()); return config; } -async function discoverOpenIdConfiguration(issuer: string): Promise { +async function discoverOpenIdConfiguration( + issuer: string, +): Promise { const response = await fetchOk(`${issuer}/.well-known/openid-configuration`); const json = verifyObject(await response.json()); const authorization_endpoint = verifyObjectProperty( @@ -64,7 +70,11 @@ async function discoverOpenIdConfiguration(issuer: string): Promise { } function generateRandomAscii(length: number): string { - const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + const charset = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; const random = new Uint8Array(length); crypto.getRandomValues(random); let s = ""; @@ -141,9 +155,14 @@ function generateRandomAscii(length: number): string { return s; } -async function createPkcePair(): Promise<{ verifier: string; challenge: string }> { +async function createPkcePair(): Promise<{ + verifier: string; + challenge: string; +}> { const verifier = generateRandomAscii(128); - const challenge = base64UrlEncode(await sha256Bytes(new TextEncoder().encode(verifier))); + const challenge = base64UrlEncode( + await sha256Bytes(new TextEncoder().encode(verifier)), + ); return { verifier, challenge }; } @@ -168,10 +187,12 @@ function loadStoredSsaToken(workerOrigin: string): StoredSsaToken | null { const accessToken = verifyObjectProperty(obj, "accessToken", verifyString); const refreshToken = verifyObjectProperty(obj, "refreshToken", verifyString); const tokenType = verifyObjectProperty(obj, "tokenType", verifyString); - const expiresAt = Number(verifyObjectProperty(obj, "expiresAt", (v) => { - if (typeof v !== "number") throw new Error("expiresAt must be a number"); - return v; - })); + const expiresAt = Number( + verifyObjectProperty(obj, "expiresAt", (v) => { + if (typeof v !== "number") throw new Error("expiresAt must be a number"); + return v; + }), + ); const email = verifyOptionalObjectProperty(obj, "email", verifyString); return { accessToken, refreshToken, tokenType, expiresAt, email }; } @@ -196,7 +217,7 @@ export class SsaCredentialsProvider extends CredentialsProvider { const abortController = new AbortController(); - const combined = AbortSignal.any([abortController.signal, innerSignal, options.signal]); + const combined = AbortSignal.any([ + abortController.signal, + innerSignal, + options.signal, + ]); try { const popup = openPopupCentered(popupUrl, 450, 700); monitorAuthPopupWindow(popup, abortController); @@ -260,16 +287,33 @@ export class SsaCredentialsProvider extends CredentialsProvider { - if (typeof v !== "number") throw new Error("expires_in must be a number"); + if (typeof v !== "number") + throw new Error("expires_in must be a number"); return v; }), ); - const email = verifyOptionalObjectProperty(tokenJson, "email", verifyString); + const email = verifyOptionalObjectProperty( + tokenJson, + "email", + verifyString, + ); const stored: StoredSsaToken = { accessToken: access_token, refreshToken: refresh_token, @@ -290,12 +334,17 @@ export class SsaCredentialsProvider extends CredentialsProvider { + private async refreshTokenSilently( + refreshToken: string, + signal: AbortSignal, + ): Promise { const { issuer } = await discoverSsaConfiguration(this.workerOrigin); const { token_endpoint } = await discoverOpenIdConfiguration(issuer); const clientId = "neuroglancer"; @@ -311,12 +360,19 @@ export class SsaCredentialsProvider extends CredentialsProvider { - if (typeof v !== "number") throw new Error("expires_in must be a number"); + if (typeof v !== "number") + throw new Error("expires_in must be a number"); return v; }), ); @@ -337,13 +393,24 @@ export class SsaCredentialsProvider extends CredentialsProvider { const { url } = options; const parsed = ensureSsaHttpsUrl(url.url); - const { workerOrigin, datasetBasePrefix } = getWorkerOriginAndDatasetPrefix(parsed); + const { workerOrigin, datasetBasePrefix } = + getWorkerOriginAndDatasetPrefix(parsed); - const credentialsProvider = sharedContext.credentialsManager.getCredentialsProvider( - "ssa", - workerOrigin, - ); - const fetchOkToWorker = fetchOkWithOAuth2CredentialsAdapter(credentialsProvider); + const credentialsProvider = + sharedContext.credentialsManager.getCredentialsProvider( + "ssa", + workerOrigin, + ); + const fetchOkToWorker = + fetchOkWithOAuth2CredentialsAdapter(credentialsProvider); const authenticateResponse = parseAuthenticateResponseLite( - await (await fetchOkToWorker(`${workerOrigin}/authenticate`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: "{}", - signal: options.signal, - })).json(), + await ( + await fetchOkToWorker(`${workerOrigin}/authenticate`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + signal: options.signal, + }) + ).json(), ); // Determine context for completion. @@ -84,7 +108,9 @@ async function completeSsaUrl( // Root-level completion: suggest directories from read permissions. if (dir === "") { - const candidates = authenticateResponse.permissions.read.map((p) => (p.endsWith("/") ? p : p + "/")); + const candidates = authenticateResponse.permissions.read.map((p) => + p.endsWith("/") ? p : p + "/", + ); const matches = candidates .filter((p) => p.startsWith(base)) .map((p) => ({ value: p })); @@ -94,14 +120,23 @@ async function completeSsaUrl( // Within a directory: use list-files for current dir prefix. const listResponse = verifyObject( - await (await fetchOkToWorker(`${workerOrigin}${authenticateResponse.endpoints.listFiles}`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ prefix: dir }), - signal: options.signal, - })).json(), + await ( + await fetchOkToWorker( + `${workerOrigin}${authenticateResponse.endpoints.listFiles}`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prefix: dir }), + signal: options.signal, + }, + ) + ).json(), + ); + const objects = verifyObjectProperty( + listResponse, + "objects", + (x) => x as unknown as any[], ); - const objects = verifyObjectProperty(listResponse, "objects", (x) => x as unknown as any[]); const childDirs = new Set(); const childFiles = new Set(); for (const entry of objects) { @@ -128,14 +163,17 @@ async function completeSsaUrl( return { offset, completions: matches }; } -function ssaFrontendProvider(sharedContext: SharedKvStoreContext): BaseKvStoreProvider { +function ssaFrontendProvider( + sharedContext: SharedKvStoreContext, +): BaseKvStoreProvider { return { scheme: "ssa+https", description: "Stateless S3 Authenticator (SSA) over HTTPS", getKvStore(parsedUrl) { // parsedUrl.url is full string like ssa+https://host/path const parsed = ensureSsaHttpsUrl(parsedUrl.url); - const { workerOrigin, datasetBasePrefix } = getWorkerOriginAndDatasetPrefix(parsed); + const { workerOrigin, datasetBasePrefix } = + getWorkerOriginAndDatasetPrefix(parsed); const displayBase = getDisplayBase(parsedUrl.url); return { store: new SsaS3KvStore(sharedContext, workerOrigin, "", displayBase), @@ -148,4 +186,6 @@ function ssaFrontendProvider(sharedContext: SharedKvStoreContext): BaseKvStorePr }; } -frontendOnlyKvStoreProviderRegistry.registerBaseKvStoreProvider(ssaFrontendProvider); +frontendOnlyKvStoreProviderRegistry.registerBaseKvStoreProvider( + ssaFrontendProvider, +); diff --git a/src/kvstore/ssa_s3/ssa_s3_kvstore.ts b/src/kvstore/ssa_s3/ssa_s3_kvstore.ts index ace64aa608..95986c75f7 100644 --- a/src/kvstore/ssa_s3/ssa_s3_kvstore.ts +++ b/src/kvstore/ssa_s3/ssa_s3_kvstore.ts @@ -35,7 +35,10 @@ import { verifyString, verifyStringArray, } from "#src/util/json.js"; -import { MultiConsumerProgressListener, ProgressSpan } from "#src/util/progress_listener.js"; +import { + MultiConsumerProgressListener, + ProgressSpan, +} from "#src/util/progress_listener.js"; function joinPath(base: string, suffix: string) { if (base === "") return suffix; @@ -63,7 +66,11 @@ function parseAuthenticateResponse(json: unknown): SsaAuthenticateResponse { return { bucket, endpoints: { - signRequests: verifyObjectProperty(endpointsObj, "signRequests", verifyString), + signRequests: verifyObjectProperty( + endpointsObj, + "signRequests", + verifyString, + ), listFiles: verifyObjectProperty(endpointsObj, "listFiles", verifyString), }, permissions: { @@ -80,29 +87,41 @@ interface SsaSignRequestBody { }>; } -interface SsaSignRequestsResponseItem { key: string; url: string } +interface SsaSignRequestsResponseItem { + key: string; + url: string; +} interface SsaSignRequestsResponse { signedRequests: SsaSignRequestsResponseItem[]; } function parseSignRequestsResponse(json: unknown): SsaSignRequestsResponse { const obj = verifyObject(json); - const signedRequestsArrayUnknown = verifyObjectProperty(obj, "signedRequests", (v) => { - if (!Array.isArray(v)) { - throw new Error("signedRequests must be an array"); - } - return v as unknown[]; - }); - const signedRequests: SsaSignRequestsResponseItem[] = signedRequestsArrayUnknown.map((entry) => { - const entryObj = verifyObject(entry); - const key = verifyObjectProperty(entryObj, "key", verifyString); - const url = verifyObjectProperty(entryObj, "url", verifyString); - return { key, url }; - }); + const signedRequestsArrayUnknown = verifyObjectProperty( + obj, + "signedRequests", + (v) => { + if (!Array.isArray(v)) { + throw new Error("signedRequests must be an array"); + } + return v as unknown[]; + }, + ); + const signedRequests: SsaSignRequestsResponseItem[] = + signedRequestsArrayUnknown.map((entry) => { + const entryObj = verifyObject(entry); + const key = verifyObjectProperty(entryObj, "key", verifyString); + const url = verifyObjectProperty(entryObj, "url", verifyString); + return { key, url }; + }); return { signedRequests }; } -interface SsaListFilesObject { key: string; size: number; lastModified: string } +interface SsaListFilesObject { + key: string; + size: number; + lastModified: string; +} interface SsaListFilesResponse { prefix: string; objects: SsaListFilesObject[]; @@ -111,7 +130,11 @@ interface SsaListFilesResponse { function parseListFilesResponse(json: unknown): SsaListFilesResponse { const obj = verifyObject(json); const prefix = verifyObjectProperty(obj, "prefix", verifyString); - const objectsArray = verifyObjectProperty(obj, "objects", (x) => x as unknown as any[]); + const objectsArray = verifyObjectProperty( + obj, + "objects", + (x) => x as unknown as any[], + ); const objects: SsaListFilesObject[] = objectsArray.map((entry) => { const e = verifyObject(entry); const key = verifyObjectProperty(e, "key", verifyString); @@ -143,10 +166,11 @@ export class SsaS3KvStore implements KvStore { this.workerOrigin = workerOrigin; this.datasetBasePrefix = datasetBasePrefix; this.displayBaseUrl = displayBaseUrl; - this.credentialsProvider = sharedKvStoreContext.credentialsManager.getCredentialsProvider( - "ssa", - workerOrigin, - ) as unknown as SsaCredentialsProvider; + this.credentialsProvider = + sharedKvStoreContext.credentialsManager.getCredentialsProvider( + "ssa", + workerOrigin, + ) as unknown as SsaCredentialsProvider; this.fetchOkToWorker = fetchOkWithOAuth2CredentialsAdapter( this.credentialsProvider, ); @@ -164,7 +188,9 @@ export class SsaS3KvStore implements KvStore { return true; } - private async ensureAuthenticated(signal?: AbortSignal): Promise { + private async ensureAuthenticated( + signal?: AbortSignal, + ): Promise { if (this.authenticatePromise === undefined) { this.authenticatePromise = this.performAuthenticate(signal).catch((e) => { // Clear cached promise on failure to allow retry. @@ -175,17 +201,22 @@ export class SsaS3KvStore implements KvStore { return this.authenticatePromise; } - private async performAuthenticate(signal?: AbortSignal): Promise { + private async performAuthenticate( + signal?: AbortSignal, + ): Promise { using _span = new ProgressSpan(new MultiConsumerProgressListener(), { message: `Connecting to SSA worker at ${this.workerOrigin}`, }); try { - const response = await this.fetchOkToWorker(`${this.workerOrigin}/authenticate`, { - method: "POST", - signal, - headers: { "content-type": "application/json" }, - body: "{}", - }); + const response = await this.fetchOkToWorker( + `${this.workerOrigin}/authenticate`, + { + method: "POST", + signal, + headers: { "content-type": "application/json" }, + body: "{}", + }, + ); const result = parseAuthenticateResponse(await response.json()); return result; } catch (e) { @@ -205,7 +236,7 @@ export class SsaS3KvStore implements KvStore { private async signSingleUrl( fullKey: string, - type: 'GET' | 'PUT' | 'HEAD' | 'DELETE', + type: "GET" | "PUT" | "HEAD" | "DELETE", signal?: AbortSignal, ): Promise { const { endpoints } = await this.ensureAuthenticated(signal); @@ -221,7 +252,9 @@ export class SsaS3KvStore implements KvStore { } satisfies SsaSignRequestBody), }, ); - const { signedRequests } = parseSignRequestsResponse(await response.json()); + const { signedRequests } = parseSignRequestsResponse( + await response.json(), + ); if (signedRequests.length !== 1) { throw new Error( `SSA /sign-requests returned ${signedRequests.length} entries, expected 1 for key ${JSON.stringify(fullKey)}`, @@ -291,17 +324,26 @@ export class SsaS3KvStore implements KvStore { } } - async stat(key: string, options: StatOptions): Promise { + async stat( + key: string, + options: StatOptions, + ): Promise { const fullKey = joinPath(this.datasetBasePrefix, key); const url = await this.signSingleUrl(fullKey, "HEAD", options.signal); try { - const response = await fetchOk(url, { method: "HEAD", signal: options.signal, progressListener: options.progressListener }); + const response = await fetchOk(url, { + method: "HEAD", + signal: options.signal, + progressListener: options.progressListener, + }); const contentLength = response.headers.get("content-length"); let totalSize: number | undefined; if (contentLength !== null) { const n = Number(contentLength); if (!Number.isFinite(n) || n < 0) { - throw new Error(`Invalid content-length returned by S3 for ${JSON.stringify(fullKey)}: ${JSON.stringify(contentLength)}`); + throw new Error( + `Invalid content-length returned by S3 for ${JSON.stringify(fullKey)}: ${JSON.stringify(contentLength)}`, + ); } totalSize = n; } @@ -320,7 +362,10 @@ export class SsaS3KvStore implements KvStore { } } - async read(key: string, options: DriverReadOptions): Promise { + async read( + key: string, + options: DriverReadOptions, + ): Promise { const fullKey = joinPath(this.datasetBasePrefix, key); const url = await this.signSingleUrl(fullKey, "GET", options.signal); @@ -331,7 +376,10 @@ export class SsaS3KvStore implements KvStore { if ("suffixLength" in byteRange) { // For suffix reads we must know total size; issue HEAD first then compute exact range. const statResponse = await this.stat(key, { signal: options.signal }); - if (statResponse === undefined || statResponse.totalSize === undefined) { + if ( + statResponse === undefined || + statResponse.totalSize === undefined + ) { throw new Error( `Failed to determine total size of ${this.getUrl(key)} in order to fetch suffix bytes`, ); @@ -357,7 +405,11 @@ export class SsaS3KvStore implements KvStore { signal: options.signal, progressListener: options.progressListener, headers: rangeHeader ? { range: rangeHeader } : undefined, - cache: rangeHeader ? (navigator.userAgent.indexOf("Chrome") !== -1 ? "no-store" : "default") : undefined, + cache: rangeHeader + ? navigator.userAgent.indexOf("Chrome") !== -1 + ? "no-store" + : "default" + : undefined, }); // Interpret response similar to http/read.ts logic. @@ -381,7 +433,9 @@ export class SsaS3KvStore implements KvStore { // Some servers omit content-range; use requested range info where possible. if ("suffixLength" in byteRange) { // Already computed via HEAD. - const statResponse = await this.stat(key, { signal: options.signal }); + const statResponse = await this.stat(key, { + signal: options.signal, + }); totalSize = statResponse?.totalSize; if (totalSize === undefined) { throw new Error("Missing total size for suffix read"); @@ -394,7 +448,12 @@ export class SsaS3KvStore implements KvStore { offset = byteRange.offset; length = 0; // Return empty body for zero-length reads. - return { response: new Response(new Uint8Array(0)), offset, length, totalSize }; + return { + response: new Response(new Uint8Array(0)), + offset, + length, + totalSize, + }; } else { offset = byteRange.offset; length = byteRange.length; @@ -406,7 +465,9 @@ export class SsaS3KvStore implements KvStore { if (cl !== null) { const n = Number(cl); if (!Number.isFinite(n) || n < 0) { - throw new Error(`Invalid content-length header for ${this.getUrl(key)}: ${JSON.stringify(cl)}`); + throw new Error( + `Invalid content-length header for ${this.getUrl(key)}: ${JSON.stringify(cl)}`, + ); } length = n; totalSize = n; @@ -437,7 +498,10 @@ export class SsaS3KvStore implements KvStore { } } - async list(prefix: string, options: { signal?: AbortSignal } = {}): Promise { + async list( + prefix: string, + options: { signal?: AbortSignal } = {}, + ): Promise { const fullPrefix = joinPath(this.datasetBasePrefix, prefix); const { endpoints } = await this.ensureAuthenticated(options.signal); try { diff --git a/src/kvstore/ssa_s3/url_utils.ts b/src/kvstore/ssa_s3/url_utils.ts index 6c70f51380..ef1d68012e 100644 --- a/src/kvstore/ssa_s3/url_utils.ts +++ b/src/kvstore/ssa_s3/url_utils.ts @@ -18,18 +18,28 @@ export const SSA_SCHEME_PREFIX = "ssa+"; export function ensureSsaHttpsUrl(url: string): URL { if (!url.startsWith("ssa+https://")) { - throw new Error(`Invalid URL ${JSON.stringify(url)}: expected ssa+https scheme`); + throw new Error( + `Invalid URL ${JSON.stringify(url)}: expected ssa+https scheme`, + ); } const httpUrl = url.substring(SSA_SCHEME_PREFIX.length); const parsed = new URL(httpUrl); if (parsed.hash) throw new Error("Fragment not supported in ssa+https URLs"); - if (parsed.username || parsed.password) throw new Error("Basic auth credentials are not supported in ssa+https URLs"); + if (parsed.username || parsed.password) + throw new Error( + "Basic auth credentials are not supported in ssa+https URLs", + ); return parsed; } -export function getWorkerOriginAndDatasetPrefix(parsed: URL): { workerOrigin: string; datasetBasePrefix: string } { +export function getWorkerOriginAndDatasetPrefix(parsed: URL): { + workerOrigin: string; + datasetBasePrefix: string; +} { const workerOrigin = parsed.origin; - const datasetBasePrefix = decodeURIComponent(parsed.pathname.replace(/^\//, "")); + const datasetBasePrefix = decodeURIComponent( + parsed.pathname.replace(/^\//, ""), + ); return { workerOrigin, datasetBasePrefix }; } diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 4569961134..0be659575b 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -32,18 +32,12 @@ import { getChunkPositionFromCombinedGlobalLocalPositions, getChunkTransformParameters, } from "#src/render_coordinate_transform.js"; -import { - trackableRenderScaleTarget, -} from "#src/render_scale_statistics.js"; +import { trackableRenderScaleTarget } from "#src/render_scale_statistics.js"; import type { SliceViewSourceOptions } from "#src/sliceview/base.js"; import { DataType } from "#src/sliceview/base.js"; import { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; -import { - constantWatchableValue, -} from "#src/trackable_value.js"; -import { - registerVoxelAnnotationTools, -} from "#src/ui/voxel_annotations.js"; +import { constantWatchableValue } from "#src/trackable_value.js"; +import { registerVoxelAnnotationTools } from "#src/ui/voxel_annotations.js"; import type { Borrowed } from "#src/util/disposable.js"; import * as matrix from "#src/util/matrix.js"; import { NullarySignal } from "#src/util/signal.js"; @@ -59,7 +53,7 @@ export class VoxUserLayer extends UserLayer { static type = "vox"; static typeAbbreviation = "vox"; voxEditController?: VoxelEditController; - voxLabelsManager : LabelsManager; + voxLabelsManager: LabelsManager; labelsChanged = new NullarySignal(); // Draw tool state @@ -86,7 +80,9 @@ export class VoxUserLayer extends UserLayer { beginRenderLodLock(lockedIndex: number): void { if (!Number.isInteger(lockedIndex) || lockedIndex < 0) { - throw new Error("beginRenderLodLock: lockedIndex must be a non-negative integer"); + throw new Error( + "beginRenderLodLock: lockedIndex must be a non-negative integer", + ); } const rl = this.voxRenderLayerInstance; if (!rl) { @@ -155,7 +151,10 @@ export class VoxUserLayer extends UserLayer { const multiscaleSource = renderLayer.multiscaleSource; const options: SliceViewSourceOptions = { displayRank: multiscaleSource.rank, - multiscaleToViewTransform: matrix.createIdentity(Float32Array, multiscaleSource.rank * multiscaleSource.rank), + multiscaleToViewTransform: matrix.createIdentity( + Float32Array, + multiscaleSource.rank * multiscaleSource.rank, + ), modelChannelDimensionIndices: [], }; const sources = multiscaleSource.getSources(options); @@ -178,8 +177,13 @@ export class VoxUserLayer extends UserLayer { const chunkTransform = this.cachedChunkTransform; if (chunkTransform === undefined) return undefined; - if (this.cachedVoxelPosition.length !== chunkTransform.modelTransform.unpaddedRank) { - this.cachedVoxelPosition = new Float32Array(chunkTransform.modelTransform.unpaddedRank); + if ( + this.cachedVoxelPosition.length !== + chunkTransform.modelTransform.unpaddedRank + ) { + this.cachedVoxelPosition = new Float32Array( + chunkTransform.modelTransform.unpaddedRank, + ); } const ok = getChunkPositionFromCombinedGlobalLocalPositions( @@ -222,8 +226,7 @@ export class VoxUserLayer extends UserLayer { activateDataSubsources(subsources: Iterable): void { for (const loadedSubsource of subsources) { - const { volume } = - loadedSubsource.subsourceEntry.subsource; + const { volume } = loadedSubsource.subsourceEntry.subsource; if (volume instanceof MultiscaleVolumeChunkSource) { if (volume === undefined) { loadedSubsource.deactivate("No volume source"); @@ -231,10 +234,16 @@ export class VoxUserLayer extends UserLayer { } switch (volume.dataType) { case DataType.UINT32: - this.voxLabelsManager = new LabelsManager(DataType.UINT32, this.labelsChanged.dispatch); + this.voxLabelsManager = new LabelsManager( + DataType.UINT32, + this.labelsChanged.dispatch, + ); break; case DataType.UINT64: - this.voxLabelsManager = new LabelsManager(DataType.UINT64, this.labelsChanged.dispatch); + this.voxLabelsManager = new LabelsManager( + DataType.UINT64, + this.labelsChanged.dispatch, + ); break; default: loadedSubsource.deactivate( @@ -243,26 +252,22 @@ export class VoxUserLayer extends UserLayer { continue; } this.voxEditController = new VoxelEditController(this, volume); - loadedSubsource.activate( - () => { - const renderLayer = new VoxelAnnotationRenderLayer(volume, { - transform: loadedSubsource.getRenderLayerTransform(), - renderScaleTarget: this.sliceViewRenderScaleTarget, - localPosition: this.localPosition, - shaderParameters: constantWatchableValue({}) - }); + loadedSubsource.activate(() => { + const renderLayer = new VoxelAnnotationRenderLayer(volume, { + transform: loadedSubsource.getRenderLayerTransform(), + renderScaleTarget: this.sliceViewRenderScaleTarget, + localPosition: this.localPosition, + shaderParameters: constantWatchableValue({}), + }); - this.voxRenderLayerInstance = renderLayer; - loadedSubsource.addRenderLayer(renderLayer); - } - ); + this.voxRenderLayerInstance = renderLayer; + loadedSubsource.addRenderLayer(renderLayer); + }); continue; } // Reject anything else. - loadedSubsource.deactivate( - "Not compatible with vox layer", - ); + loadedSubsource.deactivate("Not compatible with vox layer"); } } } diff --git a/src/layer/vox/style.css b/src/layer/vox/style.css index 979b70b340..eab560129c 100644 --- a/src/layer/vox/style.css +++ b/src/layer/vox/style.css @@ -1,3 +1,19 @@ +/** + * @license + * Copyright 2025 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. + */ + :root { /* best-effort variables if app doesn't define them */ --ng-bg: rgba(20, 22, 27, 0.9); diff --git a/src/layer/vox/tabs/tools.ts b/src/layer/vox/tabs/tools.ts index 7e248fabdd..a769712724 100644 --- a/src/layer/vox/tabs/tools.ts +++ b/src/layer/vox/tabs/tools.ts @@ -1,6 +1,19 @@ /** - * Vox Tool tab UI split from index.ts + * @license + * Copyright 2025 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 type { VoxUserLayer } from "#src/layer/vox/index.js"; import { VoxelBrushLegacyTool, @@ -25,7 +38,6 @@ function formatUnsignedId(id: bigint, dataType: DataType): string { return id.toString(); } - export class VoxToolTab extends Tab { private labelsContainer!: HTMLDivElement; private labelsError!: HTMLDivElement; @@ -51,7 +63,10 @@ export class VoxToolTab extends Tab { sw.style.background = this.layer.voxLabelsManager.colorForValue(lab); // id text (monospace) const txt = document.createElement("div"); - txt.textContent = formatUnsignedId(lab, this.layer.voxLabelsManager.dataType); + txt.textContent = formatUnsignedId( + lab, + this.layer.voxLabelsManager.dataType, + ); txt.style.fontFamily = "monospace"; txt.style.whiteSpace = "nowrap"; txt.style.overflow = "hidden"; @@ -113,15 +128,16 @@ export class VoxToolTab extends Tab { const floodButton = document.createElement("button"); floodButton.textContent = "Flood fill"; - floodButton.title = "Click a voxel to flood fill the connected region on the current Z plane"; + floodButton.title = + "Click a voxel to flood fill the connected region on the current Z plane"; floodButton.addEventListener("click", () => { this.layer.tool.value = new VoxelFloodFillLegacyTool(this.layer); }); - const adoptBtn = document.createElement("button"); adoptBtn.textContent = "Pick"; - adoptBtn.title = "Activate tool: click a non-zero voxel to add its ID as a label"; + adoptBtn.title = + "Activate tool: click a non-zero voxel to add its ID as a label"; adoptBtn.addEventListener("click", () => { this.layer.tool.value = new AdoptVoxelLabelTool(this.layer); }); @@ -168,14 +184,18 @@ export class VoxToolTab extends Tab { if (ctrl) { undoButton.disabled = ctrl.undoCount.value === 0; redoButton.disabled = ctrl.redoCount.value === 0; - this.registerDisposer(ctrl.undoCount.changed.add(() => { - undoButton.disabled = ctrl.undoCount.value === 0; - })); - this.registerDisposer(ctrl.redoCount.changed.add(() => { - redoButton.disabled = ctrl.redoCount.value === 0; - })); + this.registerDisposer( + ctrl.undoCount.changed.add(() => { + undoButton.disabled = ctrl.undoCount.value === 0; + }), + ); + this.registerDisposer( + ctrl.redoCount.changed.add(() => { + redoButton.disabled = ctrl.redoCount.value === 0; + }), + ); } else { - console.error("TODO") + console.error("TODO"); } historyButtons.appendChild(undoButton); @@ -364,7 +384,6 @@ export class VoxToolTab extends Tab { }); buttonsRow.appendChild(createBtn); - this.labelsContainer = document.createElement("div"); this.labelsContainer.className = "neuroglancer-vox-labels"; this.labelsContainer.style.display = "flex"; diff --git a/src/sliceview/base.ts b/src/sliceview/base.ts index ae31e5ed78..667cd99013 100644 --- a/src/sliceview/base.ts +++ b/src/sliceview/base.ts @@ -697,7 +697,11 @@ export function* filterVisibleSources( // First: allow a render layer to force a specific multiscale index for safety-critical flows. const forcedIndex = renderLayer.getForcedSourceIndexOverride?.(); if (forcedIndex !== undefined) { - if (!Number.isInteger(forcedIndex) || forcedIndex < 0 || forcedIndex >= sources.length) { + if ( + !Number.isInteger(forcedIndex) || + forcedIndex < 0 || + forcedIndex >= sources.length + ) { throw new Error( `filterVisibleSources: forced source index ${forcedIndex} is out of range [0, ${sources.length - 1}]`, ); diff --git a/src/sliceview/chunk_base.ts b/src/sliceview/chunk_base.ts index 568098ceac..27146c992a 100644 --- a/src/sliceview/chunk_base.ts +++ b/src/sliceview/chunk_base.ts @@ -1,3 +1,19 @@ +/** + * @license + * Copyright 2025 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 { ChunkState } from "#src/chunk_manager/base.js"; import { Chunk } from "#src/chunk_manager/frontend.js"; import type { SliceViewChunkSource } from "#src/sliceview/frontend.js"; diff --git a/src/sliceview/frontend.ts b/src/sliceview/frontend.ts index 4b595e45de..91a81bf87a 100644 --- a/src/sliceview/frontend.ts +++ b/src/sliceview/frontend.ts @@ -19,10 +19,9 @@ import { ChunkState } from "#src/chunk_manager/base.js"; import type { ChunkManager, ChunkRequesterState, + Chunk, } from "#src/chunk_manager/frontend.js"; -import { Chunk, ChunkSource } from "#src/chunk_manager/frontend.js"; -export { SliceViewChunk } from "#src/sliceview/chunk_base.js"; -import type { SliceViewChunk as SliceViewChunk } from "#src/sliceview/chunk_base.js"; +import { ChunkSource } from "#src/chunk_manager/frontend.js"; import { applyRenderViewportToProjectionMatrix } from "#src/display_context.js"; import type { LayerManager } from "#src/layer/index.js"; import type { @@ -62,6 +61,7 @@ import { SliceViewBase, SliceViewProjectionParameters, } from "#src/sliceview/base.js"; +import type { SliceViewChunk } from "#src/sliceview/chunk_base.js"; import { ChunkLayout } from "#src/sliceview/chunk_layout.js"; import type { SliceViewerState } from "#src/sliceview/panel.js"; import { SliceViewRenderLayer } from "#src/sliceview/renderlayer.js"; @@ -91,6 +91,8 @@ import { getSquareCornersBuffer } from "#src/webgl/square_corners_buffer.js"; import type { RPC } from "#src/worker_rpc.js"; import { registerSharedObjectOwner } from "#src/worker_rpc.js"; +export { SliceViewChunk } from "#src/sliceview/chunk_base.js"; + export type GenericChunkKey = string; class FrontendSliceViewBase extends SliceViewBase< diff --git a/src/sliceview/single_texture_chunk_format.ts b/src/sliceview/single_texture_chunk_format.ts index 2cbd711050..36d244b405 100644 --- a/src/sliceview/single_texture_chunk_format.ts +++ b/src/sliceview/single_texture_chunk_format.ts @@ -14,11 +14,11 @@ * limitations under the License. */ +import { VolumeChunk } from "#src/sliceview/volume/chunk.js"; import type { VolumeChunkSource, ChunkFormat, } from "#src/sliceview/volume/frontend.js"; -import { VolumeChunk } from "#src/sliceview/volume/chunk.js"; import type { TypedArray } from "#src/util/array.js"; import type { DataType } from "#src/util/data_type.js"; import type { Disposable } from "#src/util/disposable.js"; diff --git a/src/sliceview/volume/backend.ts b/src/sliceview/volume/backend.ts index f640cd7a60..aac4017a2f 100644 --- a/src/sliceview/volume/backend.ts +++ b/src/sliceview/volume/backend.ts @@ -16,12 +16,15 @@ import type { Chunk } from "#src/chunk_manager/backend.js"; import { ChunkState } from "#src/chunk_manager/base.js"; -import { SliceViewChunk, SliceViewChunkSourceBackend } from "#src/sliceview/backend.js"; +import { + SliceViewChunk, + SliceViewChunkSourceBackend, +} from "#src/sliceview/backend.js"; import type { SliceViewChunkSpecification } from "#src/sliceview/base.js"; import { DataType } from "#src/sliceview/base.js"; import type { VolumeChunkSource as VolumeChunkSourceInterface, - VolumeChunkSpecification + VolumeChunkSpecification, } from "#src/sliceview/volume/base.js"; import type { TypedArray } from "#src/util/array.js"; import { DATA_TYPE_ARRAY_CONSTRUCTOR } from "#src/util/data_type.js"; @@ -159,15 +162,24 @@ export class VolumeChunkSource // Override in data source backends to actually persist the chunk. // Default throws to ensure write capability is explicitly implemented. async writeChunk(_chunk: VolumeChunk): Promise { - throw new Error("VolumeChunkSource.writeChunk not implemented for this datasource"); + throw new Error( + "VolumeChunkSource.writeChunk not implemented for this datasource", + ); } - async applyEdits(chunkKey: string, indices: ArrayLike, values: ArrayLike): Promise { + async applyEdits( + chunkKey: string, + indices: ArrayLike, + values: ArrayLike, + ): Promise { if (indices.length !== values.length) { throw new Error("applyEdits: indices and values length mismatch"); } - const chunkGridPosition = new Float32Array(chunkKey.split(',').map(Number)); - if (chunkGridPosition.length !== this.spec.rank || chunkGridPosition.some((v) => !Number.isFinite(v))) { + const chunkGridPosition = new Float32Array(chunkKey.split(",").map(Number)); + if ( + chunkGridPosition.length !== this.spec.rank || + chunkGridPosition.some((v) => !Number.isFinite(v)) + ) { throw new Error(`applyEdits: invalid chunk key ${chunkKey}`); } const chunk = this.getChunk(chunkGridPosition) as VolumeChunk; @@ -193,7 +205,9 @@ export class VolumeChunkSource this.computeChunkBounds(chunk); } if (!chunk.chunkDataSize) { - throw new Error(`applyEdits: Cannot create new chunk ${chunkKey} because its size is unknown.`); + throw new Error( + `applyEdits: Cannot create new chunk ${chunkKey} because its size is unknown.`, + ); } const numElements = chunk.chunkDataSize.reduce((a, b) => a * b, 1); const Ctor = DATA_TYPE_ARRAY_CONSTRUCTOR[this.spec.dataType]; @@ -206,14 +220,19 @@ export class VolumeChunkSource const indicesCopy = new Uint32Array(indices); const newValuesArray = new ArrayCtor(values.length); for (let i = 0; i < values.length; ++i) { - newValuesArray[i] = this.spec.dataType === DataType.UINT32 ? Number(values[i]!) : values[i]!; + newValuesArray[i] = + this.spec.dataType === DataType.UINT32 + ? Number(values[i]!) + : values[i]!; } const oldValuesArray = new ArrayCtor(indices.length); for (let i = 0; i < indices.length; ++i) { const idx = indices[i]!; if (idx < 0 || idx >= data.length) { - throw new Error(`applyEdits: index ${idx} out of bounds for chunk ${chunkKey}`); + throw new Error( + `applyEdits: index ${idx} out of bounds for chunk ${chunkKey}`, + ); } oldValuesArray[i] = data[idx]; data[idx] = newValuesArray[i]; @@ -234,10 +253,15 @@ export class VolumeChunkSource if (e instanceof HttpError && e.status < 500 && e.status !== 429) { break; } - await new Promise(resolve => setTimeout(resolve, 250 * Math.pow(2, i))); + await new Promise((resolve) => + setTimeout(resolve, 250 * Math.pow(2, i)), + ); } } - throw new Error(`Failed to write chunk ${chunkKey} after ${maxRetries} attempts.`, { cause: lastError }); + throw new Error( + `Failed to write chunk ${chunkKey} after ${maxRetries} attempts.`, + { cause: lastError }, + ); } } VolumeChunkSource.prototype.chunkConstructor = VolumeChunk; diff --git a/src/sliceview/volume/chunk.ts b/src/sliceview/volume/chunk.ts index 36be6cace0..b764cdfabd 100644 --- a/src/sliceview/volume/chunk.ts +++ b/src/sliceview/volume/chunk.ts @@ -1,5 +1,24 @@ +/** + * @license + * Copyright 2025 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 { SliceViewChunk } from "#src/sliceview/chunk_base.js"; -import type { ChunkFormat, VolumeChunkSource } from "#src/sliceview/volume/frontend.js"; +import type { + ChunkFormat, + VolumeChunkSource, +} from "#src/sliceview/volume/frontend.js"; import type { GL } from "#src/webgl/context.js"; export abstract class VolumeChunk extends SliceViewChunk { diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index 9911ad4fa0..75f36cff81 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -16,7 +16,10 @@ import type { ChunkManager } from "#src/chunk_manager/frontend.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; -import type { DataType, SliceViewChunkSpecification } from "#src/sliceview/base.js"; +import type { + DataType, + SliceViewChunkSpecification, +} from "#src/sliceview/base.js"; import { SLICEVIEW_REQUEST_CHUNK_RPC_ID } from "#src/sliceview/base.js"; import { ChunkFormat as CompressedChunkFormat } from "#src/sliceview/compressed_segmentation/chunk_format.js"; import { decodeChannel as decodeChannelUint32 } from "#src/sliceview/compressed_segmentation/decode_uint32.js"; @@ -24,21 +27,24 @@ import { decodeChannel as decodeChannelUint64 } from "#src/sliceview/compressed_ import { encodeChannel as encodeChannelUint32 } from "#src/sliceview/compressed_segmentation/encode_uint32.js"; import { encodeChannel as encodeChannelUint64 } from "#src/sliceview/compressed_segmentation/encode_uint64.js"; import type { SliceViewChunk } from "#src/sliceview/frontend.js"; -import { MultiscaleSliceViewChunkSource, SliceViewChunkSource } from "#src/sliceview/frontend.js"; import { - ChunkFormat as UncompressedChunkFormat, + MultiscaleSliceViewChunkSource, + SliceViewChunkSource, +} from "#src/sliceview/frontend.js"; +import type { UncompressedChunkFormatHandler, UncompressedVolumeChunk, } from "#src/sliceview/uncompressed_chunk_format.js"; +import { ChunkFormat as UncompressedChunkFormat } from "#src/sliceview/uncompressed_chunk_format.js"; import type { VolumeChunkSource as VolumeChunkSourceInterface, VolumeChunkSpecification, VolumeSourceOptions, - VolumeType + VolumeType, } from "#src/sliceview/volume/base.js"; import { VolumeChunk } from "#src/sliceview/volume/chunk.js"; import { getChunkFormatHandler } from "#src/sliceview/volume/registry.js"; -import type { TypedArray} from "#src/util/array.js"; +import type { TypedArray } from "#src/util/array.js"; import { TypedArrayBuilder } from "#src/util/array.js"; import { DATA_TYPE_ARRAY_CONSTRUCTOR, @@ -167,7 +173,6 @@ export interface ChunkFormatHandler extends Disposable { getChunk(source: SliceViewChunkSource, x: any): SliceViewChunk; } - export class VolumeChunkSource extends SliceViewChunkSource implements VolumeChunkSourceInterface @@ -230,14 +235,19 @@ export class VolumeChunkSource chunkGridPosition: chunkGridPosition, }); } catch (e) { - console.error(`Failed to fetch chunk for position ${chunkPosition.join()}:`, e); + console.error( + `Failed to fetch chunk for position ${chunkPosition.join()}:`, + e, + ); return null; } return this.getValueAt(chunkPosition, channelAccess); } - applyLocalEdits(edits: Map): void { + applyLocalEdits( + edits: Map, + ): void { const chunksToUpdate = new Set(); const fetches: Promise[] = []; @@ -255,8 +265,8 @@ export class VolumeChunkSource if (cpuArray === null) { // If the chunk currently has the shared fill value texture, we must // detach it so that a new texture is created for the edited data. - const handler = - uncompressedChunk.source.chunkFormatHandler as UncompressedChunkFormatHandler; + const handler = uncompressedChunk.source + .chunkFormatHandler as UncompressedChunkFormatHandler; if (uncompressedChunk.texture === handler.fillValueChunk.texture) { uncompressedChunk.texture = null; uncompressedChunk.textureLayout = null; @@ -270,8 +280,7 @@ export class VolumeChunkSource cpuArray = new (Ctor as any)(numElements); uncompressedChunk.data = cpuArray; } - if (cpuArray === null) - throw new Error("Unexpected null chunk data"); + if (cpuArray === null) throw new Error("Unexpected null chunk data"); const { dataType } = chunkFormat; for (const index of edit.indices) { if (dataType === DataTypeUtil.UINT32) { @@ -287,23 +296,54 @@ export class VolumeChunkSource // TODO: rework this const compressedData = (targetChunk as any).data as Uint32Array; const { chunkDataSize } = targetChunk; - const numElements = chunkDataSize[0] * chunkDataSize[1] * chunkDataSize[2]; + const numElements = + chunkDataSize[0] * chunkDataSize[1] * chunkDataSize[2]; const { dataType, subchunkSize } = chunkFormat; const baseOffset = compressedData[0]; - const outputBuilder = new TypedArrayBuilder(Uint32Array, compressedData.length); + const outputBuilder = new TypedArrayBuilder( + Uint32Array, + compressedData.length, + ); outputBuilder.resize(1); outputBuilder.data[0] = 1; if (dataType === DataTypeUtil.UINT32) { const uncompressedData = new Uint32Array(numElements); - decodeChannelUint32(uncompressedData, compressedData, baseOffset, chunkDataSize, subchunkSize); - for (const index of edit.indices) { uncompressedData[index] = Number(edit.value); } - encodeChannelUint32(outputBuilder, subchunkSize, uncompressedData, chunkDataSize); - } else { // Assumes UINT64 + decodeChannelUint32( + uncompressedData, + compressedData, + baseOffset, + chunkDataSize, + subchunkSize, + ); + for (const index of edit.indices) { + uncompressedData[index] = Number(edit.value); + } + encodeChannelUint32( + outputBuilder, + subchunkSize, + uncompressedData, + chunkDataSize, + ); + } else { + // Assumes UINT64 const uncompressedData = new BigUint64Array(numElements); - decodeChannelUint64(uncompressedData, compressedData, baseOffset, chunkDataSize, subchunkSize); - for (const index of edit.indices) { uncompressedData[index] = edit.value; } - encodeChannelUint64(outputBuilder, subchunkSize, uncompressedData, chunkDataSize); + decodeChannelUint64( + uncompressedData, + compressedData, + baseOffset, + chunkDataSize, + subchunkSize, + ); + for (const index of edit.indices) { + uncompressedData[index] = edit.value; + } + encodeChannelUint64( + outputBuilder, + subchunkSize, + uncompressedData, + chunkDataSize, + ); } (targetChunk as any).data = outputBuilder.view; @@ -314,10 +354,17 @@ export class VolumeChunkSource if ((chunk as any).data) { processEdit(chunk); } else { - const fetchPromise = this.fetchChunk(chunk.chunkGridPosition, (fetchedChunk) => { - processEdit(fetchedChunk as VolumeChunk); - }, {}).catch(err => { - console.error(`Failed to fetch chunk ${key} for local edit preview:`, err); + const fetchPromise = this.fetchChunk( + chunk.chunkGridPosition, + (fetchedChunk) => { + processEdit(fetchedChunk as VolumeChunk); + }, + {}, + ).catch((err) => { + console.error( + `Failed to fetch chunk ${key} for local edit preview:`, + err, + ); }); fetches.push(fetchPromise); } @@ -354,7 +401,9 @@ export class VolumeChunkSource const chunkSize = chunkDataSize[chunkDim]; const chunkIndex = Math.floor(voxel / chunkSize); chunkGridPosition[chunkDim] = chunkIndex; - positionWithinChunk[chunkDim] = Math.floor(voxel - chunkSize * chunkIndex); + positionWithinChunk[chunkDim] = Math.floor( + voxel - chunkSize * chunkIndex, + ); } return { chunkGridPosition, positionWithinChunk }; } diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 6c8a4b72bf..5e9e98d174 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -23,8 +23,8 @@ import { vec3 } from "#src/util/geom.js"; export const BRUSH_TOOL_ID = "voxBrush"; export const FLOODFILL_TOOL_ID = "voxFloodFill"; export const ADOPT_VOXEL_LABEL_TOOL_ID = "adoptVoxelLabel"; - - abstract class BaseVoxelLegacyTool extends LegacyTool { + +abstract class BaseVoxelLegacyTool extends LegacyTool { protected isDrawing = false; protected lastPoint: Int32Array | undefined; protected mouseDisposer: (() => void) | undefined; @@ -46,13 +46,18 @@ export const ADOPT_VOXEL_LABEL_TOOL_ID = "adoptVoxelLabel"; } const layer = this.layer as unknown as VoxUserLayer; - const value = layer.voxLabelsManager.getCurrentLabelValue(layer.voxEraseMode); + const value = layer.voxLabelsManager.getCurrentLabelValue( + layer.voxEraseMode, + ); const cur = this.getPoint(this.latestMouseState); this.latestMouseState = null; // mark processed if (cur) { const last = this.lastPoint; - if (last && (cur[0] !== last[0] || cur[1] !== last[1] || cur[2] !== last[2])) { + if ( + last && + (cur[0] !== last[0] || cur[1] !== last[1] || cur[2] !== last[2]) + ) { const points = this.linePoints(last, cur); if (points.length > 0) { this.paintPoints(points, value); @@ -79,7 +84,8 @@ export const ADOPT_VOXEL_LABEL_TOOL_ID = "adoptVoxelLabel"; const CHUNK_POSITION_EPSILON = 1e-3; const shiftedVox = new Float32Array(3); for (let i = 0; i < 3; ++i) { - shiftedVox[i] = vox[i] + CHUNK_POSITION_EPSILON * Math.abs(planeNormal[i]); + shiftedVox[i] = + vox[i] + CHUNK_POSITION_EPSILON * Math.abs(planeNormal[i]); } return new Int32Array([ @@ -121,7 +127,10 @@ export const ADOPT_VOXEL_LABEL_TOOL_ID = "adoptVoxelLabel"; this.currentMouseState = mouseState; const layer = this.layer as unknown as VoxUserLayer; - const brushRadius = Math.max(1, Math.floor((layer as any).voxBrushRadius ?? 3)); + const brushRadius = Math.max( + 1, + Math.floor((layer as any).voxBrushRadius ?? 3), + ); if (!Number.isFinite(brushRadius) || brushRadius <= 0) { throw new Error("startDrawing: invalid brushRadius"); } @@ -129,14 +138,18 @@ export const ADOPT_VOXEL_LABEL_TOOL_ID = "adoptVoxelLabel"; // Compute starting point and lock render LOD before first paint. const start = this.getPoint(mouseState); if (!start) { - throw new Error("startDrawing: could not compute a starting voxel position from mouse"); + throw new Error( + "startDrawing: could not compute a starting voxel position from mouse", + ); } const centerCanonical = new Float32Array([start[0], start[1], start[2]]); const editLodIndex = 0; // locked to 0 rn layer.beginRenderLodLock(editLodIndex); - const value = layer.voxLabelsManager.getCurrentLabelValue(layer.voxEraseMode); + const value = layer.voxLabelsManager.getCurrentLabelValue( + layer.voxEraseMode, + ); this.paintPoints([centerCanonical], value); this.lastPoint = start; @@ -153,7 +166,10 @@ export const ADOPT_VOXEL_LABEL_TOOL_ID = "adoptVoxelLabel"; const mouseUpHandler = () => { this.stopDrawing(); window.removeEventListener("mouseup", mouseUpHandler); - if (this.mouseDisposer) { this.mouseDisposer(); this.mouseDisposer = undefined; } + if (this.mouseDisposer) { + this.mouseDisposer(); + this.mouseDisposer = undefined; + } }; window.addEventListener("mouseup", mouseUpHandler); @@ -217,11 +233,13 @@ export class VoxelBrushLegacyTool extends BaseVoxelLegacyTool { (this.layer as any).voxBrushShape === "sphere" ? "sphere" : "disk"; const ctrl = (this.layer as any).voxEditController; let basis = undefined; - if (shape === 'disk' && this.currentMouseState?.planeNormal) { + if (shape === "disk" && this.currentMouseState?.planeNormal) { const n = this.currentMouseState.planeNormal; const u = vec3.create(); - const tempVec = Math.abs(vec3.dot(n, vec3.fromValues(1, 0, 0))) < 0.9 ? - vec3.fromValues(1, 0, 0) : vec3.fromValues(0, 1, 0); + const tempVec = + Math.abs(vec3.dot(n, vec3.fromValues(1, 0, 0))) < 0.9 + ? vec3.fromValues(1, 0, 0) + : vec3.fromValues(0, 1, 0); vec3.cross(u, tempVec, n); vec3.normalize(u, u); const v = vec3.cross(vec3.create(), n, u); @@ -252,18 +270,25 @@ export class VoxelFloodFillLegacyTool extends LegacyTool { return; } - const pos = layer.getVoxelPositionFromMouse?.(mouseState) as Float32Array | undefined; + const pos = layer.getVoxelPositionFromMouse?.(mouseState) as + | Float32Array + | undefined; const planeNormal = mouseState.planeNormal; if (!pos || pos.length < 3 || !planeNormal) { - throw new Error("Flood fill: failed to get voxel position or plane normal."); + throw new Error( + "Flood fill: failed to get voxel position or plane normal.", + ); } - - const value = layer.voxLabelsManager.getCurrentLabelValue(layer.voxEraseMode); + const value = layer.voxLabelsManager.getCurrentLabelValue( + layer.voxEraseMode, + ); const max = Number((layer as any).voxFloodMaxVoxels); if (!Number.isFinite(max) || max <= 0) { - throw new Error("Flood fill: invalid max voxels; set it in the tool panel"); + throw new Error( + "Flood fill: invalid max voxels; set it in the tool panel", + ); } const ctrl = layer.voxEditController; if (!ctrl) throw new Error("Flood fill: drawing backend not ready yet"); @@ -274,21 +299,33 @@ export class VoxelFloodFillLegacyTool extends LegacyTool { Math.floor(pos[2]!), ]); - console.info("[VoxFloodFill] starting flood fill", { seed: Array.from(seed), value: value, max: Math.floor(max) }); - ctrl.floodFillPlane2D(seed, value, Math.floor(max), planeNormal).then(({ edits, filledCount }) => { - console.info("[VoxFloodFill] BFS completed", { filledCount, editsByChunk: edits.length }); - - if (edits.length === 0) return; - if (typeof ctrl.commitEdits === "function") { - ctrl.commitEdits(edits); - console.info("[VoxFloodFill] committed edits"); - } else if ((ctrl as any).rpc && (ctrl as any).rpc.invoke) { - (ctrl as any).rpc.invoke("VOX_EDIT_COMMIT_VOXELS", { rpcId: (ctrl as any).rpcId, edits }); - console.info("[VoxFloodFill] committed edits via fallback path"); - } else { - throw new Error("Flood fill: no way to commit edits"); - } + console.info("[VoxFloodFill] starting flood fill", { + seed: Array.from(seed), + value: value, + max: Math.floor(max), }); + ctrl + .floodFillPlane2D(seed, value, Math.floor(max), planeNormal) + .then(({ edits, filledCount }) => { + console.info("[VoxFloodFill] BFS completed", { + filledCount, + editsByChunk: edits.length, + }); + + if (edits.length === 0) return; + if (typeof ctrl.commitEdits === "function") { + ctrl.commitEdits(edits); + console.info("[VoxFloodFill] committed edits"); + } else if ((ctrl as any).rpc && (ctrl as any).rpc.invoke) { + (ctrl as any).rpc.invoke("VOX_EDIT_COMMIT_VOXELS", { + rpcId: (ctrl as any).rpcId, + edits, + }); + console.info("[VoxFloodFill] committed edits via fallback path"); + } else { + throw new Error("Flood fill: no way to commit edits"); + } + }); } catch (e: any) { const msg = typeof e?.message === "string" ? e.message : String(e); try { @@ -302,7 +339,9 @@ export class VoxelFloodFillLegacyTool extends LegacyTool { export class AdoptVoxelLabelTool extends LegacyTool { description = "label picker"; - toJSON() { return ADOPT_VOXEL_LABEL_TOOL_ID; } + toJSON() { + return ADOPT_VOXEL_LABEL_TOOL_ID; + } trigger(mouseState: MouseSelectionState) { if (!mouseState?.active) return; const layer = this.layer as VoxUserLayer; @@ -325,28 +364,29 @@ export class AdoptVoxelLabelTool extends LegacyTool { return; } - const source = editController.getSourceForLOD( - 0, - ); + const source = editController.getSourceForLOD(0); const channelAccess = editController.singleChannelAccess; - StatusMessage.forPromise( - source.getEnsuredValueAt(pos, channelAccess).then((value: bigint | number | null) => { - if (value === null) { - throw new Error("Voxel data not available at the selected position."); - } - const label = BigInt(value); - if (label === 0n) { - StatusMessage.showTemporaryMessage( - "Cannot adopt background label (0).", - 3000, - ); - return; - } - layer.voxLabelsManager.addLabel(label); - StatusMessage.showTemporaryMessage(`Adopted label: ${label}`, 3000); - }), + source + .getEnsuredValueAt(pos, channelAccess) + .then((value: bigint | number | null) => { + if (value === null) { + throw new Error( + "Voxel data not available at the selected position.", + ); + } + const label = BigInt(value); + if (label === 0n) { + StatusMessage.showTemporaryMessage( + "Cannot adopt background label (0).", + 3000, + ); + return; + } + layer.voxLabelsManager.addLabel(label); + StatusMessage.showTemporaryMessage(`Adopted label: ${label}`, 3000); + }), { initialMessage: "Picking voxel label...", delay: true, diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md new file mode 100644 index 0000000000..b64cac4643 --- /dev/null +++ b/src/voxel_annotation/TODOs.md @@ -0,0 +1,21 @@ + +### priority +- fix the flood fill for compressed chunks +- test uint64 support +- url completion for the ssa+https source +- different mouse cursors for the different tools + +### later +- rework the ui (draw tabs) to fit neuroglancer style +- optimize flood fill tool (it is too slow on area containing uncached chunks, due to the getEnsuredValueAt() calls) +- rework the drawing preview for compressed chunk (see applyLocalEdits()) +- rework the url autocomplete for the ssa+https source. +- the flood fill sometimes leaves artifacts in sharp areas (maybe increase fillBorderRegion() radius) +- add shortcuts for tools (switching tools, toggle erase mode and adjusting brush size) and label creation +- write a testsuite for the downsampler and ensure its proper working on exotic lod levels +- fix undo/redo buttons activation states (see tabs/tools.ts) + + +### questionable +- design a dataset creation feature +- adapt the brush size to the zoom level linearly diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index 1007f94b9e..797fce4a50 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -1,3 +1,19 @@ +/** + * @license + * Copyright 2025 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + export const VOX_RELOAD_CHUNKS_RPC_ID = "vox.chunk.reload"; export const VOX_EDIT_BACKEND_RPC_ID = "vox.EditBackend"; export const VOX_EDIT_COMMIT_VOXELS_RPC_ID = "vox.edit.commitVoxels"; @@ -31,16 +47,24 @@ export function makeVoxChunkKey(chunkKey: string, lodIndex: number) { return `lod${lodIndex}#${chunkKey}`; } -export function makeChunkKey(x: number, y : number, z: number) { +export function makeChunkKey(x: number, y: number, z: number) { return `${x},${y},${z}`; } export function parseVoxChunkKey(key: string) { - const parts = [Number(key.split("#")[0].substring(3)), - ...key.split("#")[1].split(",").map(Number)]; + const parts = [ + Number(key.split("#")[0].substring(3)), + ...key.split("#")[1].split(",").map(Number), + ]; if (parts.length !== 4 || parts.some(isNaN)) { console.warn(`Invalid chunk key format: ${key}`); return null; } - return { lodIndex: parts[0], x: parts[1], y: parts[2], z: parts[3], chunkKey: key.split("#")[1] }; + return { + lodIndex: parts[0], + x: parts[1], + y: parts[2], + z: parts[3], + chunkKey: key.split("#")[1], + }; } diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/edit_backend.ts index 69f24e3960..0c375a572a 100644 --- a/src/voxel_annotation/edit_backend.ts +++ b/src/voxel_annotation/edit_backend.ts @@ -1,3 +1,19 @@ +/** + * @license + * Copyright 2025 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 type { VolumeChunkSource } from "#src/sliceview/volume/backend.js"; import { mat4, vec3 } from "#src/util/geom.js"; import * as matrix from "#src/util/matrix.js"; @@ -19,12 +35,21 @@ import { makeChunkKey, } from "#src/voxel_annotation/base.js"; import type { RPC } from "#src/worker_rpc.js"; -import { registerPromiseRPC , SharedObject , registerRPC, registerSharedObject, initializeSharedObjectCounterpart } from "#src/worker_rpc.js"; +import { + registerPromiseRPC, + SharedObject, + registerRPC, + registerSharedObject, + initializeSharedObjectCounterpart, +} from "#src/worker_rpc.js"; @registerSharedObject(VOX_EDIT_BACKEND_RPC_ID) export class VoxelEditController extends SharedObject { private sources = new Map(); - private resolutions = new Map(); + private resolutions = new Map< + number, + VoxelLayerResolution & { invTransform: mat4 } + >(); private pendingEdits: { key: string; @@ -52,10 +77,7 @@ export class VoxelEditController extends SharedObject { const passedResolutions = options?.resolutions as | VoxelLayerResolution[] | undefined; - if ( - passedResolutions === undefined || - !Array.isArray(passedResolutions) - ) { + if (passedResolutions === undefined || !Array.isArray(passedResolutions)) { throw new Error( "VoxelEditBackend: missing required 'resolutions' array during initialization", ); @@ -64,8 +86,17 @@ export class VoxelEditController extends SharedObject { for (const res of passedResolutions) { const rank = res.chunkSize.length; const invTransform = new Float32Array((rank + 1) ** 2); - matrix.inverse(invTransform, rank + 1, new Float32Array(res.transform), rank + 1, rank + 1); - this.resolutions.set(res.lodIndex, { ...res, invTransform: invTransform as mat4 }); + matrix.inverse( + invTransform, + rank + 1, + new Float32Array(res.transform), + rank + 1, + rank + 1, + ); + this.resolutions.set(res.lodIndex, { + ...res, + invTransform: invTransform as mat4, + }); const resolved = rpc.get(res.sourceRpc) as VolumeChunkSource | undefined; if (!resolved) { throw new Error( @@ -199,7 +230,9 @@ export class VoxelEditController extends SharedObject { ) { for (const e of edits) { if (!e || !e.key || !e.indices) { - throw new Error("VoxelEditController.commitVoxels: invalid edit payload"); + throw new Error( + "VoxelEditController.commitVoxels: invalid edit payload", + ); } this.pendingEdits.push(e); } @@ -250,7 +283,9 @@ export class VoxelEditController extends SharedObject { } } - private async performDownsampleCascadeForKey(sourceKey: string): Promise { + private async performDownsampleCascadeForKey( + sourceKey: string, + ): Promise { let currentKey: string | null = sourceKey; while (currentKey !== null) { currentKey = await this.downsampleStep(currentKey); @@ -270,20 +305,27 @@ export class VoxelEditController extends SharedObject { } const childSource = this.sources.get(childInfo.lodIndex); if (!childSource) { - console.error(`[Downsample] No source found for child LOD: ${childInfo.lodIndex}`); + console.error( + `[Downsample] No source found for child LOD: ${childInfo.lodIndex}`, + ); return null; } - const childChunk = childSource.getChunk(new Float32Array([childInfo.x, childInfo.y, childInfo.z])) as any; + const childChunk = childSource.getChunk( + new Float32Array([childInfo.x, childInfo.y, childInfo.z]), + ) as any; if (!childChunk.data) { try { await childSource.download(childChunk, new AbortController().signal); } catch (e) { - console.warn(`[Downsample] Failed to download source chunk ${childKey}:`, e); + console.warn( + `[Downsample] Failed to download source chunk ${childKey}:`, + e, + ); return null; } } - const childChunkData = childChunk.data as (Uint32Array | BigUint64Array); + const childChunkData = childChunk.data as Uint32Array | BigUint64Array; const childRes = this.resolutions.get(childInfo.lodIndex)!; // 2. Determine the parent chunk that corresponds to this child chunk. @@ -295,17 +337,29 @@ export class VoxelEditController extends SharedObject { const { parentKey, parentSource, parentRes } = parentInfo; // 3. Calculate the update for the parent chunk based on the child chunk's data. - const update = this._calculateParentUpdate(childChunkData, childRes, parentRes, childInfo); + const update = this._calculateParentUpdate( + childChunkData, + childRes, + parentRes, + childInfo, + ); if (update.indices.length === 0) { return parentKey; } // 4. Commit the update to the parent chunk and notify the frontend. try { - await parentSource.applyEdits(parentInfo.chunkKey, update.indices, update.values); + await parentSource.applyEdits( + parentInfo.chunkKey, + update.indices, + update.values, + ); this.callChunkReload([parentKey]); } catch (e) { - console.error(`[Downsample] Failed to apply edits to parent chunk ${parentKey}:`, e); + console.error( + `[Downsample] Failed to apply edits to parent chunk ${parentKey}:`, + e, + ); this.rpc?.invoke(VOX_EDIT_FAILURE_RPC_ID, { rpcId: this.rpcId, voxChunkKeys: [parentKey], @@ -320,7 +374,10 @@ export class VoxelEditController extends SharedObject { /** * Helper to find and describe the parent chunk. */ - private _getParentChunkInfo(childKey: string, childRes: VoxelLayerResolution) { + private _getParentChunkInfo( + childKey: string, + childRes: VoxelLayerResolution, + ) { const childInfo = parseVoxChunkKey(childKey)!; const parentLodIndex = childInfo.lodIndex + 1; const parentRes = this.resolutions.get(parentLodIndex); @@ -337,23 +394,40 @@ export class VoxelEditController extends SharedObject { childInfo.z * childRes.chunkSize[2], ]); const childPhysOrigin = new Float32Array(rank); - matrix.transformPoint(childPhysOrigin, new Float32Array(childRes.transform), rank + 1, childVoxelOrigin, rank); + matrix.transformPoint( + childPhysOrigin, + new Float32Array(childRes.transform), + rank + 1, + childVoxelOrigin, + rank, + ); // Transform that world coordinate into the parent's voxel space const parentVoxelCoordOfChildOrigin = new Float32Array(rank); - matrix.transformPoint(parentVoxelCoordOfChildOrigin, parentRes.invTransform, rank + 1, childPhysOrigin, rank); + matrix.transformPoint( + parentVoxelCoordOfChildOrigin, + parentRes.invTransform, + rank + 1, + childPhysOrigin, + rank, + ); // Determine the parent chunk's grid position - const parentX = Math.floor(parentVoxelCoordOfChildOrigin[0] / parentRes.chunkSize[0]); - const parentY = Math.floor(parentVoxelCoordOfChildOrigin[1] / parentRes.chunkSize[1]); - const parentZ = Math.floor(parentVoxelCoordOfChildOrigin[2] / parentRes.chunkSize[2]); + const parentX = Math.floor( + parentVoxelCoordOfChildOrigin[0] / parentRes.chunkSize[0], + ); + const parentY = Math.floor( + parentVoxelCoordOfChildOrigin[1] / parentRes.chunkSize[1], + ); + const parentZ = Math.floor( + parentVoxelCoordOfChildOrigin[2] / parentRes.chunkSize[2], + ); const parentChunkKey = makeChunkKey(parentX, parentY, parentZ); const parentKey = makeVoxChunkKey(parentChunkKey, parentLodIndex); return { parentKey, chunkKey: parentChunkKey, parentRes, parentSource }; } - /** * Calculates the downsampled voxel values for a region of a parent chunk. * This is the core aggregation logic. @@ -362,7 +436,7 @@ export class VoxelEditController extends SharedObject { childChunkData: Uint32Array | BigUint64Array, childRes: VoxelLayerResolution & { invTransform: mat4 }, parentRes: VoxelLayerResolution & { invTransform: mat4 }, - childInfo: { x: number, y: number, z: number } + childInfo: { x: number; y: number; z: number }, ) { const indices: number[] = []; const values: bigint[] = []; @@ -374,7 +448,7 @@ export class VoxelEditController extends SharedObject { const parentVoxelToChildVoxelTransform = mat4.multiply( mat4.create(), childRes.invTransform, - new Float32Array(parentRes.transform) as mat4 + new Float32Array(parentRes.transform) as mat4, ); // Calculate the child chunk's origin and extent in absolute child-voxel-space @@ -391,15 +465,39 @@ export class VoxelEditController extends SharedObject { // Transform child chunk bounds to physical space const childPhysOrigin = new Float32Array(rank); - matrix.transformPoint(childPhysOrigin, new Float32Array(childRes.transform), rank + 1, childChunkOrigin, rank); + matrix.transformPoint( + childPhysOrigin, + new Float32Array(childRes.transform), + rank + 1, + childChunkOrigin, + rank, + ); const childPhysMax = new Float32Array(rank); - matrix.transformPoint(childPhysMax, new Float32Array(childRes.transform), rank + 1, childChunkMax, rank); + matrix.transformPoint( + childPhysMax, + new Float32Array(childRes.transform), + rank + 1, + childChunkMax, + rank, + ); // Transform to parent-voxel-space to find the affected region const parentVoxelMin = new Float32Array(rank); - matrix.transformPoint(parentVoxelMin, parentRes.invTransform, rank + 1, childPhysOrigin, rank); + matrix.transformPoint( + parentVoxelMin, + parentRes.invTransform, + rank + 1, + childPhysOrigin, + rank, + ); const parentVoxelMax = new Float32Array(rank); - matrix.transformPoint(parentVoxelMax, parentRes.invTransform, rank + 1, childPhysMax, rank); + matrix.transformPoint( + parentVoxelMax, + parentRes.invTransform, + rank + 1, + childPhysMax, + rank, + ); // Determine which parent chunk this corresponds to (should match _getParentChunkInfo) const parentChunkGridX = Math.floor(parentVoxelMin[0] / parentChunkSize[0]); @@ -417,8 +515,14 @@ export class VoxelEditController extends SharedObject { const parentLocalMin = new Float32Array(rank); const parentLocalMax = new Float32Array(rank); for (let i = 0; i < rank; ++i) { - parentLocalMin[i] = Math.max(0, Math.floor(parentVoxelMin[i] - parentChunkOriginInParentVoxels[i])); - parentLocalMax[i] = Math.min(parentChunkSize[i], Math.ceil(parentVoxelMax[i] - parentChunkOriginInParentVoxels[i])); + parentLocalMin[i] = Math.max( + 0, + Math.floor(parentVoxelMin[i] - parentChunkOriginInParentVoxels[i]), + ); + parentLocalMax[i] = Math.min( + parentChunkSize[i], + Math.ceil(parentVoxelMax[i] - parentChunkOriginInParentVoxels[i]), + ); } const [startX, startY, startZ] = parentLocalMin; @@ -451,7 +555,11 @@ export class VoxelEditController extends SharedObject { // Transform corners to absolute child-voxel-space for (let i = 0; i < 8; ++i) { - vec3.transformMat4(transformedCorners[i], corners[i], parentVoxelToChildVoxelTransform); + vec3.transformMat4( + transformedCorners[i], + corners[i], + parentVoxelToChildVoxelTransform, + ); } // Find bounding box in absolute child-voxel-space @@ -529,7 +637,7 @@ export class VoxelEditController extends SharedObject { sourceStack: EditAction[], targetStack: EditAction[], useOldValues: boolean, - actionDescription: 'undo' | 'redo' + actionDescription: "undo" | "redo", ): Promise { await this.flushPending(); @@ -550,11 +658,18 @@ export class VoxelEditController extends SharedObject { const valuesToApply = useOldValues ? change.oldValues : change.newValues; try { - await source.applyEdits(parsedKey.chunkKey, change.indices, valuesToApply); + await source.applyEdits( + parsedKey.chunkKey, + change.indices, + valuesToApply, + ); chunksToReload.add(voxKey); } catch (e) { success = false; - console.error(`performUndoRedo: failed to apply edits for ${voxKey}`, e); + console.error( + `performUndoRedo: failed to apply edits for ${voxKey}`, + e, + ); this.rpc?.invoke(VOX_EDIT_FAILURE_RPC_ID, { rpcId: this.rpcId, voxChunkKeys: [voxKey], @@ -584,11 +699,11 @@ export class VoxelEditController extends SharedObject { } public async undo(): Promise { - await this.performUndoRedo(this.undoStack, this.redoStack, true, 'undo'); + await this.performUndoRedo(this.undoStack, this.redoStack, true, "undo"); } public async redo(): Promise { - await this.performUndoRedo(this.redoStack, this.undoStack, false, 'redo'); + await this.performUndoRedo(this.redoStack, this.undoStack, false, "redo"); } } diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 6ef18939ef..5c365f93b5 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -1,16 +1,29 @@ /** * @license - * Copyright 2025. + * Copyright 2025 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 type { VoxUserLayer } from "#src/layer/vox/index.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; -import type { VolumeChunkSource , MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; +import type { + VolumeChunkSource, + MultiscaleVolumeChunkSource, +} from "#src/sliceview/volume/frontend.js"; import { StatusMessage } from "#src/status.js"; import { WatchableValue } from "#src/trackable_value.js"; import { vec3 } from "#src/util/geom.js"; -import type { - VoxelLayerResolution} from "#src/voxel_annotation/base.js"; +import type { VoxelLayerResolution } from "#src/voxel_annotation/base.js"; import { VOX_EDIT_BACKEND_RPC_ID, VOX_EDIT_COMMIT_VOXELS_RPC_ID, @@ -20,7 +33,7 @@ import { VOX_EDIT_REDO_RPC_ID, VOX_EDIT_HISTORY_UPDATE_RPC_ID, makeVoxChunkKey, - parseVoxChunkKey + parseVoxChunkKey, } from "#src/voxel_annotation/base.js"; import { registerRPC, @@ -33,18 +46,27 @@ export class VoxelEditController extends SharedObject { public undoCount = new WatchableValue(0); public redoCount = new WatchableValue(0); - constructor(private layer: VoxUserLayer, private multiscale: MultiscaleVolumeChunkSource) { + constructor( + private layer: VoxUserLayer, + private multiscale: MultiscaleVolumeChunkSource, + ) { super(); const rpc = (this.multiscale as any)?.chunkManager?.rpc; if (!rpc) { - throw new Error("VoxelEditController: Missing RPC from multiscale chunk manager."); + throw new Error( + "VoxelEditController: Missing RPC from multiscale chunk manager.", + ); } // Get all sources for all scales and orientations - const sourcesByScale = this.multiscale.getSources(this.getIdentitySliceViewSourceOptions()); + const sourcesByScale = this.multiscale.getSources( + this.getIdentitySliceViewSourceOptions(), + ); const sources = sourcesByScale[0]; if (!sources) { - throw new Error("VoxelEditController: Could not retrieve sources from multiscale object."); + throw new Error( + "VoxelEditController: Could not retrieve sources from multiscale object.", + ); } const resolutions: VoxelLayerResolution[] = []; @@ -53,13 +75,15 @@ export class VoxelEditController extends SharedObject { const source = sources[i]!.chunkSource; const rpcId = source.rpcId; if (rpcId == null) { - throw new Error(`VoxelEditController: Source at LOD index ${i} has null rpcId during initialization.`); + throw new Error( + `VoxelEditController: Source at LOD index ${i} has null rpcId during initialization.`, + ); } resolutions.push({ lodIndex: i, transform: Array.from(sources[i]!.chunkToMultiscaleTransform), chunkSize: Array.from(source.spec.chunkDataSize), - sourceRpc: rpcId + sourceRpc: rpcId, }); } @@ -80,10 +104,9 @@ export class VoxelEditController extends SharedObject { numChannels: 1, channelSpaceShape: new Uint32Array([]), chunkChannelDimensionIndices: [], - chunkChannelCoordinates: new Uint32Array([0]) + chunkChannelCoordinates: new Uint32Array([0]), }; - private getIdentitySliceViewSourceOptions() { const rank = (this.multiscale as any).rank as number | undefined; if (!Number.isInteger(rank) || (rank as number) <= 0) { @@ -107,15 +130,21 @@ export class VoxelEditController extends SharedObject { } getSourceForLOD(lodIndex: number): VolumeChunkSource { - const sourcesByScale = this.multiscale.getSources(this.getIdentitySliceViewSourceOptions()); + const sourcesByScale = this.multiscale.getSources( + this.getIdentitySliceViewSourceOptions(), + ); // Assuming a single orientation, which is correct for this use case. const sources = sourcesByScale[0]; if (!sources || sources.length <= lodIndex) { - throw new Error(`VoxelEditController: LOD index ${lodIndex} is out of bounds.`); + throw new Error( + `VoxelEditController: LOD index ${lodIndex} is out of bounds.`, + ); } const source = sources[lodIndex]?.chunkSource; if (!source) { - throw new Error(`VoxelEditController: No chunk source found for LOD index ${lodIndex}.`); + throw new Error( + `VoxelEditController: No chunk source found for LOD index ${lodIndex}.`, + ); } return source; } @@ -169,27 +198,32 @@ export class VoxelEditController extends SharedObject { } } else { if (basis === undefined) { - throw new Error("paintBrushWithShape: 'basis' must be defined for disk alignment."); + throw new Error( + "paintBrushWithShape: 'basis' must be defined for disk alignment.", + ); } - const { u, v } = basis; - for (let j = -r; j <= r; ++j) { - for (let i = -r; i <= r; ++i) { - if (i * i + j * j <= rr) { - const point = vec3.fromValues(cx, cy, cz); - vec3.scaleAndAdd(point, point, u as vec3, i); - vec3.scaleAndAdd(point, point, v as vec3, j); - voxelsToPaint.push(point as Float32Array); - } + const { u, v } = basis; + for (let j = -r; j <= r; ++j) { + for (let i = -r; i <= r; ++i) { + if (i * i + j * j <= rr) { + const point = vec3.fromValues(cx, cy, cz); + vec3.scaleAndAdd(point, point, u as vec3, i); + vec3.scaleAndAdd(point, point, v as vec3, j); + voxelsToPaint.push(point as Float32Array); } - + } } } if (!voxelsToPaint || voxelsToPaint.length === 0) return; - const editsByVoxKey = new Map(); + const editsByVoxKey = new Map< + string, + { indices: number[]; value: bigint } + >(); for (const voxelCoord of voxelsToPaint) { - const { chunkGridPosition, positionWithinChunk } = source.computeChunkIndices(voxelCoord); + const { chunkGridPosition, positionWithinChunk } = + source.computeChunkIndices(voxelCoord); const chunkKey = chunkGridPosition.join(); const voxKey = makeVoxChunkKey(chunkKey, sourceIndex); @@ -200,12 +234,15 @@ export class VoxelEditController extends SharedObject { } const { chunkDataSize } = source.spec; - const index = (positionWithinChunk[2] * chunkDataSize[1] + positionWithinChunk[1]) * chunkDataSize[0] + positionWithinChunk[0]; + const index = + (positionWithinChunk[2] * chunkDataSize[1] + positionWithinChunk[1]) * + chunkDataSize[0] + + positionWithinChunk[0]; entry.indices.push(index); } // Apply edits locally on the specific source for immediate feedback. - const localEdits = new Map(); + const localEdits = new Map(); for (const [voxKey, edit] of editsByVoxKey.entries()) { const parsed = parseVoxChunkKey(voxKey); if (!parsed) continue; @@ -213,19 +250,38 @@ export class VoxelEditController extends SharedObject { } source.applyLocalEdits(localEdits); - const backendEdits = [] as { key: string; indices: number[]; value: bigint }[]; + const backendEdits = [] as { + key: string; + indices: number[]; + value: bigint; + }[]; for (const [voxKey, edit] of editsByVoxKey.entries()) { - backendEdits.push({ key: voxKey, indices: edit.indices, value: edit.value }); + backendEdits.push({ + key: voxKey, + indices: edit.indices, + value: edit.value, + }); } this.commitEdits(backendEdits); } /** Commit helper for UI tools. */ - commitEdits(edits: { key: string; indices: number[] | Uint32Array; value?: bigint; values?: ArrayLike; size?: number[] }[]): void { - if (!this.rpc) throw new Error("VoxelEditController.commitEdits: RPC not initialized."); + commitEdits( + edits: { + key: string; + indices: number[] | Uint32Array; + value?: bigint; + values?: ArrayLike; + size?: number[]; + }[], + ): void { + if (!this.rpc) + throw new Error("VoxelEditController.commitEdits: RPC not initialized."); if (!Array.isArray(edits)) { - throw new Error("VoxelEditController.commitEdits: edits must be an array."); + throw new Error( + "VoxelEditController.commitEdits: edits must be an array.", + ); } this.rpc.invoke(VOX_EDIT_COMMIT_VOXELS_RPC_ID, { rpcId: this.rpcId, @@ -243,16 +299,31 @@ export class VoxelEditController extends SharedObject { fillValue: bigint, maxVoxels: number, planeNormal: vec3, // MUST be a normalized vector - ): Promise<{ edits: { key: string; indices: number[]; value: bigint }[]; filledCount: number; originalValue: bigint }> { - const sourceIndex = 0; - const source = this.getSourceForLOD(sourceIndex); - const startVoxelLod = vec3.round(vec3.create(), startPositionCanonical as vec3); - - const originalValueResult = await source.getEnsuredValueAt(startVoxelLod as Float32Array, this.singleChannelAccess); - if (originalValueResult === null) { - throw new Error("Flood fill seed is in an unloaded or out-of-bounds chunk."); + ): Promise<{ + edits: { key: string; indices: number[]; value: bigint }[]; + filledCount: number; + originalValue: bigint; + }> { + const sourceIndex = 0; + const source = this.getSourceForLOD(sourceIndex); + const startVoxelLod = vec3.round( + vec3.create(), + startPositionCanonical as vec3, + ); + + const originalValueResult = await source.getEnsuredValueAt( + startVoxelLod as Float32Array, + this.singleChannelAccess, + ); + if (originalValueResult === null) { + throw new Error( + "Flood fill seed is in an unloaded or out-of-bounds chunk.", + ); } - const originalValue = typeof originalValueResult !== "bigint" ? BigInt(originalValueResult as number) : originalValueResult; + const originalValue = + typeof originalValueResult !== "bigint" + ? BigInt(originalValueResult as number) + : originalValueResult; if (originalValue === fillValue) { return { edits: [], filledCount: 0, originalValue }; @@ -260,8 +331,10 @@ export class VoxelEditController extends SharedObject { const U = vec3.create(); const V = vec3.create(); - const tempVec = Math.abs(vec3.dot(planeNormal, vec3.fromValues(1, 0, 0))) < 0.9 ? - vec3.fromValues(1, 0, 0) : vec3.fromValues(0, 1, 0); + const tempVec = + Math.abs(vec3.dot(planeNormal, vec3.fromValues(1, 0, 0))) < 0.9 + ? vec3.fromValues(1, 0, 0) + : vec3.fromValues(0, 1, 0); vec3.cross(U, tempVec, planeNormal); vec3.normalize(U, U); vec3.cross(V, planeNormal, U); @@ -272,7 +345,6 @@ export class VoxelEditController extends SharedObject { let filledCount = 0; const voxelsToFill: Float32Array[] = []; - const map2dTo3d = (u: number, v: number): vec3 => { const point = vec3.clone(startVoxelLod); vec3.scaleAndAdd(point, point, U, u); @@ -281,9 +353,13 @@ export class VoxelEditController extends SharedObject { }; const isFillable = async (p: vec3): Promise => { - const value = await source.getEnsuredValueAt(p as Float32Array, this.singleChannelAccess); + const value = await source.getEnsuredValueAt( + p as Float32Array, + this.singleChannelAccess, + ); if (value === null) return false; - const bigValue = (typeof value !== "bigint") ? BigInt(value as number) : value; + const bigValue = + typeof value !== "bigint" ? BigInt(value as number) : value; if (originalValue === 0n) return bigValue === 0n; return bigValue === originalValue; }; @@ -298,7 +374,13 @@ export class VoxelEditController extends SharedObject { return Math.min(thickness, this.morphologicalConfig.maxSize); }; - const hasThickEnoughChannel = async (u: number, v: number, nu: number, nv: number, requiredThickness: number): Promise => { + const hasThickEnoughChannel = async ( + u: number, + v: number, + nu: number, + nv: number, + requiredThickness: number, + ): Promise => { if (requiredThickness <= 1) return true; const halfThickness = Math.floor(requiredThickness / 2); @@ -315,7 +397,7 @@ export class VoxelEditController extends SharedObject { const testV = nv + perpV * offset; const pointToTest = map2dTo3d(testU, testV); - if (!await isFillable(pointToTest)) { + if (!(await isFillable(pointToTest))) { return false; } } @@ -323,7 +405,11 @@ export class VoxelEditController extends SharedObject { return true; }; - const fillBorderRegion = async (startU: number, startV: number, requiredThickness: number) => { + const fillBorderRegion = async ( + startU: number, + startV: number, + requiredThickness: number, + ) => { const subQueue: [number, number][] = []; // The bounding box for the local fill is defined in the (u, v) coordinate system const halfSize = Math.floor(requiredThickness / 2) + 1; @@ -341,11 +427,20 @@ export class VoxelEditController extends SharedObject { filledCount++; voxelsToFill.push(currentPoint as Float32Array); - const neighbors2d: [number, number][] = [[u + 1, v], [u - 1, v], [u, v + 1], [u, v - 1]]; + const neighbors2d: [number, number][] = [ + [u + 1, v], + [u - 1, v], + [u, v + 1], + [u, v - 1], + ]; for (const [nu, nv] of neighbors2d) { // Constrain this local search to a small bounding box - if (nu < startU - halfSize || nu > startU + halfSize || - nv < startV - halfSize || nv > startV + halfSize) { + if ( + nu < startU - halfSize || + nu > startU + halfSize || + nv < startV - halfSize || + nv > startV + halfSize + ) { continue; } @@ -366,7 +461,9 @@ export class VoxelEditController extends SharedObject { while (queue.length > 0) { if (filledCount >= maxVoxels) { - throw new Error(`Flood fill region exceeds the limit of ${maxVoxels} voxels.`); + throw new Error( + `Flood fill region exceeds the limit of ${maxVoxels} voxels.`, + ); } const [u, v] = queue.shift()!; @@ -375,7 +472,12 @@ export class VoxelEditController extends SharedObject { voxelsToFill.push(currentPoint as Float32Array); const requiredThickness = getCurrentThickness(); - const neighbors2d: [number, number][] = [[u + 1, v], [u - 1, v], [u, v + 1], [u, v - 1]]; + const neighbors2d: [number, number][] = [ + [u + 1, v], + [u - 1, v], + [u, v + 1], + [u, v - 1], + ]; for (const [nu, nv] of neighbors2d) { const k = `${nu},${nv}`; @@ -393,9 +495,13 @@ export class VoxelEditController extends SharedObject { } } - const editsByVoxKey = new Map(); + const editsByVoxKey = new Map< + string, + { indices: number[]; value: bigint } + >(); for (const voxelCoord of voxelsToFill) { - const { chunkGridPosition, positionWithinChunk } = source.computeChunkIndices(voxelCoord); + const { chunkGridPosition, positionWithinChunk } = + source.computeChunkIndices(voxelCoord); const chunkKey = chunkGridPosition.join(); const voxKey = makeVoxChunkKey(chunkKey, sourceIndex); let entry = editsByVoxKey.get(voxKey); @@ -404,19 +510,27 @@ export class VoxelEditController extends SharedObject { editsByVoxKey.set(voxKey, entry); } const { chunkDataSize } = source.spec; - const index = (positionWithinChunk[2] * chunkDataSize[1] + positionWithinChunk[1]) * chunkDataSize[0] + positionWithinChunk[0]; + const index = + (positionWithinChunk[2] * chunkDataSize[1] + positionWithinChunk[1]) * + chunkDataSize[0] + + positionWithinChunk[0]; entry.indices.push(index); } - const localEdits = new Map(); + const localEdits = new Map(); for (const [voxKey, edit] of editsByVoxKey.entries()) { const parsed = parseVoxChunkKey(voxKey); if (!parsed) continue; localEdits.set(parsed.chunkKey, edit); } source.applyLocalEdits(localEdits); - const backendEdits: { key: string; indices: number[]; value: bigint }[] = []; + const backendEdits: { key: string; indices: number[]; value: bigint }[] = + []; for (const [voxKey, edit] of editsByVoxKey.entries()) { - backendEdits.push({ key: voxKey, indices: edit.indices, value: edit.value }); + backendEdits.push({ + key: voxKey, + indices: edit.indices, + value: edit.value, + }); } this.commitEdits(backendEdits); @@ -426,7 +540,9 @@ export class VoxelEditController extends SharedObject { callChunkReload(voxChunkKeys: string[]) { if (!Array.isArray(voxChunkKeys) || voxChunkKeys.length === 0) return; // This assumes the multiscale source has a single orientation. - const sourcesByScale = (this.multiscale as any).getSources(this.getIdentitySliceViewSourceOptions()); + const sourcesByScale = (this.multiscale as any).getSources( + this.getIdentitySliceViewSourceOptions(), + ); const sources = sourcesByScale && sourcesByScale[0]; if (!sources) return; @@ -435,7 +551,9 @@ export class VoxelEditController extends SharedObject { for (const voxKey of voxChunkKeys) { const parsed = parseVoxChunkKey(voxKey); if (!parsed) continue; - const source = sources[parsed.lodIndex]?.chunkSource as VolumeChunkSource | undefined; + const source = sources[parsed.lodIndex]?.chunkSource as + | VolumeChunkSource + | undefined; if (!source) continue; let arr = chunksToInvalidateBySource.get(source); if (!arr) { @@ -462,21 +580,27 @@ export class VoxelEditController extends SharedObject { } public undo(): void { - if (!this.rpc) throw new Error("VoxelEditController.undo: RPC not initialized."); + if (!this.rpc) + throw new Error("VoxelEditController.undo: RPC not initialized."); console.log("VoxelEditController.undo"); - this.rpc.promiseInvoke(VOX_EDIT_UNDO_RPC_ID, { rpcId: this.rpcId }).catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - StatusMessage.showTemporaryMessage(`Undo failed: ${message}`, 3000); - }); + this.rpc + .promiseInvoke(VOX_EDIT_UNDO_RPC_ID, { rpcId: this.rpcId }) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + StatusMessage.showTemporaryMessage(`Undo failed: ${message}`, 3000); + }); } public redo(): void { - if (!this.rpc) throw new Error("VoxelEditController.redo: RPC not initialized."); + if (!this.rpc) + throw new Error("VoxelEditController.redo: RPC not initialized."); console.log("VoxelEditController.redo"); - this.rpc.promiseInvoke(VOX_EDIT_REDO_RPC_ID, { rpcId: this.rpcId }).catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - StatusMessage.showTemporaryMessage(`Redo failed: ${message}`, 3000); - }); + this.rpc + .promiseInvoke(VOX_EDIT_REDO_RPC_ID, { rpcId: this.rpcId }) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + StatusMessage.showTemporaryMessage(`Redo failed: ${message}`, 3000); + }); } } @@ -489,14 +613,15 @@ registerRPC(VOX_RELOAD_CHUNKS_RPC_ID, function (x: any) { registerRPC(VOX_EDIT_FAILURE_RPC_ID, function (x: any) { const obj = this.get(x.rpcId) as VoxelEditController; const keys: string[] = Array.isArray(x.voxChunkKeys) ? x.voxChunkKeys : []; - const message: string = typeof x.message === 'string' ? x.message : 'Voxel edit failed.'; + const message: string = + typeof x.message === "string" ? x.message : "Voxel edit failed."; obj.handleCommitFailure(keys, message); }); registerRPC(VOX_EDIT_HISTORY_UPDATE_RPC_ID, function (x: any) { const obj = this.get(x.rpcId) as VoxelEditController; - const undoCount = typeof x.undoCount === 'number' ? x.undoCount : 0; - const redoCount = typeof x.redoCount === 'number' ? x.redoCount : 0; + const undoCount = typeof x.undoCount === "number" ? x.undoCount : 0; + const redoCount = typeof x.redoCount === "number" ? x.redoCount : 0; obj.undoCount.value = undoCount; obj.redoCount.value = redoCount; }); diff --git a/src/voxel_annotation/labels.ts b/src/voxel_annotation/labels.ts index 04af4e3fdb..f5b5a54206 100644 --- a/src/voxel_annotation/labels.ts +++ b/src/voxel_annotation/labels.ts @@ -1,3 +1,19 @@ +/** + * @license + * Copyright 2025 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 { SegmentColorHash } from "#src/segment_color.js"; import { DataType } from "#src/util/data_type.js"; @@ -11,15 +27,18 @@ export class LabelsManager { private nextLocalId: bigint = 1n; private idMask: bigint; - constructor(public dataType: DataType, private onLabelsChanged?: () => void) { - switch (dataType){ + constructor( + public dataType: DataType, + private onLabelsChanged?: () => void, + ) { + switch (dataType) { case DataType.UINT32: this.sessionPrefix = BigInt(Date.now() << 20); - this.idMask = 0xFFFFFFFFn; + this.idMask = 0xffffffffn; break; case DataType.UINT64: this.sessionPrefix = BigInt(getRandomUint32()) << 32n; - this.idMask = 0xFFFFFFFFFFFFFFFFn; + this.idMask = 0xffffffffffffffffn; break; default: throw new Error(`LabelsManager: Unsupported data type: ${dataType}`); @@ -28,7 +47,7 @@ export class LabelsManager { } private generateNewGuid(): bigint { - const newId = this.sessionPrefix | this.nextLocalId & this.idMask; + const newId = this.sessionPrefix | (this.nextLocalId & this.idMask); this.nextLocalId++; return newId; } diff --git a/src/voxel_annotation/renderlayer.ts b/src/voxel_annotation/renderlayer.ts index 9e95bc7216..7e27899c38 100644 --- a/src/voxel_annotation/renderlayer.ts +++ b/src/voxel_annotation/renderlayer.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2024 Google Inc. + * Copyright 2025 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 @@ -21,16 +21,6 @@ import { SliceViewVolumeRenderLayer } from "#src/sliceview/volume/renderlayer.js import { constantWatchableValue } from "#src/trackable_value.js"; import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; -/** - * This is a specialized rendering layer that knows how to take data from a `MultiscaleVolumeChunkSource` - * and render it as a 2D slice in the Neuroglancer viewer. - * - * Its main responsibilities include: - * - Data Request: It requests the necessary 3D data chunks from the `MultiscaleVolumeChunkSource` that intersect with the current 2D slice being viewed. - * - WebGL Management: It manages the WebGL resources (like textures and buffers) required to efficiently upload and display this 3D data as a 2D image. - * - Shader Logic: It provides the core shader program (via its `defineShader` method) that interprets the raw 3D volume data (e.g., a voxel value) and converts it into a visual representation (e.g., a color). - * - Interaction: It handles interactions like picking, allowing you to identify the specific 3D voxel or segment under the mouse cursor on the 2D slice. - */ type EmptyParams = Record; export class VoxelAnnotationRenderLayer extends SliceViewVolumeRenderLayer { @@ -48,7 +38,9 @@ export class VoxelAnnotationRenderLayer extends SliceViewVolumeRenderLayer Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 083/251] fix: properly align flood fill seed --- src/ui/voxel_annotations.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 5e9e98d174..3851ebc9c5 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -252,7 +252,10 @@ export class VoxelBrushLegacyTool extends BaseVoxelLegacyTool { } } -export class VoxelFloodFillLegacyTool extends LegacyTool { +export class VoxelFloodFillLegacyTool extends BaseVoxelLegacyTool { + protected paintPoints(_points: Float32Array[], _value: bigint): void { + throw new Error("Method not implemented."); + } description = "flood fill"; toJSON() { @@ -270,12 +273,10 @@ export class VoxelFloodFillLegacyTool extends LegacyTool { return; } - const pos = layer.getVoxelPositionFromMouse?.(mouseState) as - | Float32Array - | undefined; + const seed = this.getPoint(mouseState); const planeNormal = mouseState.planeNormal; - if (!pos || pos.length < 3 || !planeNormal) { + if (!seed || !planeNormal) { throw new Error( "Flood fill: failed to get voxel position or plane normal.", ); @@ -293,19 +294,13 @@ export class VoxelFloodFillLegacyTool extends LegacyTool { const ctrl = layer.voxEditController; if (!ctrl) throw new Error("Flood fill: drawing backend not ready yet"); - const seed = new Float32Array([ - Math.floor(pos[0]!), - Math.floor(pos[1]!), - Math.floor(pos[2]!), - ]); - console.info("[VoxFloodFill] starting flood fill", { seed: Array.from(seed), value: value, max: Math.floor(max), }); ctrl - .floodFillPlane2D(seed, value, Math.floor(max), planeNormal) + .floodFillPlane2D(new Float32Array(seed), value, Math.floor(max), planeNormal) .then(({ edits, filledCount }) => { console.info("[VoxFloodFill] BFS completed", { filledCount, From 176f96a2ea3de802381506631d3be6cedd1a93c1 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 084/251] refactor: start switching vox tools to the new tool system --- src/layer/vox/controls.ts | 67 +++ src/layer/vox/index.ts | 42 +- src/layer/vox/style.css | 114 +----- src/layer/vox/tabs/tools.ts | 518 ++++++++---------------- src/ui/voxel_annotations.ts | 265 +++++------- src/voxel_annotation/TODOs.md | 6 +- src/voxel_annotation/edit_controller.ts | 6 +- src/widget/layer_control_button.ts | 35 ++ 8 files changed, 401 insertions(+), 652 deletions(-) create mode 100644 src/layer/vox/controls.ts create mode 100644 src/widget/layer_control_button.ts diff --git a/src/layer/vox/controls.ts b/src/layer/vox/controls.ts new file mode 100644 index 0000000000..2f6170d979 --- /dev/null +++ b/src/layer/vox/controls.ts @@ -0,0 +1,67 @@ +import { LayerActionContext } from "#src/layer/index.js"; +import type { VoxUserLayer} from "#src/layer/vox/index.js"; +import type { LayerControlDefinition } from "#src/widget/layer_control.js"; +import { registerLayerControl } from "#src/widget/layer_control.js"; +import { buttonLayerControl } from "#src/widget/layer_control_button.js"; +import { checkboxLayerControl } from "#src/widget/layer_control_checkbox.js"; +import { enumLayerControl } from "#src/widget/layer_control_enum.js"; +import { rangeLayerControl } from "#src/widget/layer_control_range.js"; + +export const VOXEL_LAYER_CONTROLS: LayerControlDefinition[] = [ + { + label: "Brush size", + toolJson: { type: "vox-brush-size" }, + ...rangeLayerControl((layer) => ({ + value: layer.voxBrushRadius, + options: { min: 1, max: 64, step: 1 }, + })), + }, + { + label: "Eraser", + toolJson: { type: "vox-erase-mode" }, + ...checkboxLayerControl((layer) => layer.voxEraseMode), + }, + { + label: "Brush shape", + toolJson: { type: "vox-brush-shape" }, + ...enumLayerControl((layer: VoxUserLayer) => layer.voxBrushShape), + }, + { + label: "Max fill voxels", + toolJson: { type: "vox-flood-max-voxels" }, + ...rangeLayerControl((layer) => ({ + value: layer.voxFloodMaxVoxels, + options: { min: 1, max: 1000000, step: 1000 }, + })), + }, + { + label: "", + toolJson: { type: "vox:undo" }, + ...buttonLayerControl({ + text: "Undo", + onClick: (layer) => layer.handleAction("undo", new LayerActionContext()), + }), + }, + { + label: "", + toolJson: { type: "vox:redo" }, + ...buttonLayerControl({ + text: "Redo", + onClick: (layer) => layer.handleAction("redo", new LayerActionContext()), + }), + }, + { + label: "", + toolJson: { type: "vox:new-label" }, + ...buttonLayerControl({ + text: "New Label", + onClick: (layer) => layer.handleAction("new-label", new LayerActionContext()), + }), + }, +]; + +export function registerLayerControls(layerType: typeof VoxUserLayer) { + for (const control of VOXEL_LAYER_CONTROLS) { + registerLayerControl(layerType, control); + } +} diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 0be659575b..26f23b3d18 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -19,6 +19,7 @@ import "#src/layer/vox/style.css"; import type { CoordinateTransformSpecification } from "#src/coordinate_transform.js"; import type { DataSourceSpecification } from "#src/datasource/index.js"; import { + LayerActionContext, type ManagedUserLayer, type MouseSelectionState, registerLayerType, @@ -26,6 +27,7 @@ import { UserLayer, } from "#src/layer/index.js"; import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; +import { registerLayerControls } from "#src/layer/vox/controls.js"; import { VoxToolTab } from "#src/layer/vox/tabs/tools.js"; import type { ChunkTransformParameters } from "#src/render_coordinate_transform.js"; import { @@ -36,15 +38,23 @@ import { trackableRenderScaleTarget } from "#src/render_scale_statistics.js"; import type { SliceViewSourceOptions } from "#src/sliceview/base.js"; import { DataType } from "#src/sliceview/base.js"; import { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; -import { constantWatchableValue } from "#src/trackable_value.js"; -import { registerVoxelAnnotationTools } from "#src/ui/voxel_annotations.js"; +import { TrackableBoolean } from "#src/trackable_boolean.js"; +import { constantWatchableValue, TrackableValue } from "#src/trackable_value.js"; +import { registerVoxelTools } from "#src/ui/voxel_annotations.js"; import type { Borrowed } from "#src/util/disposable.js"; +import { verifyFiniteFloat, verifyInt } from "#src/util/json.js"; import * as matrix from "#src/util/matrix.js"; import { NullarySignal } from "#src/util/signal.js"; +import { TrackableEnum } from "#src/util/trackable_enum.js"; import { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; import { LabelsManager } from "#src/voxel_annotation/labels.js"; import { VoxelAnnotationRenderLayer } from "#src/voxel_annotation/renderlayer.js"; +export enum BrushShape { + disk = 0, + sphere = 1, +} + export class VoxUserLayer extends UserLayer { // While drawing, we keep a reference to the vox render layer to control temporary LOD locks. private voxRenderLayerInstance?: VoxelAnnotationRenderLayer; @@ -56,10 +66,11 @@ export class VoxUserLayer extends UserLayer { voxLabelsManager: LabelsManager; labelsChanged = new NullarySignal(); - // Draw tool state - voxBrushRadius: number = 3; - voxEraseMode: boolean = false; - voxBrushShape: "disk" | "sphere" = "disk"; + // Draw tool state (trackables for widget integration) + voxBrushRadius = new TrackableValue(3, verifyInt); + voxEraseMode = new TrackableBoolean(false); + voxBrushShape = new TrackableEnum(BrushShape, BrushShape.disk); + voxFloodMaxVoxels = new TrackableValue(10000, verifyFiniteFloat); // Cached transform and voxel buffer to avoid recomputation/allocation on every mouse move private cachedChunkTransform: ChunkTransformParameters | undefined; @@ -78,6 +89,22 @@ export class VoxUserLayer extends UserLayer { } } + handleAction(action: string, _context: LayerActionContext): void { + if(!this.voxEditController || !this.voxLabelsManager) + return; + switch (action) { + case "undo": + this.voxEditController.undo(); + break; + case "redo": + this.voxEditController.redo(); + break; + case "new-label": + this.voxLabelsManager.createNewLabel(); + break; + } + } + beginRenderLodLock(lockedIndex: number): void { if (!Number.isInteger(lockedIndex) || lockedIndex < 0) { throw new Error( @@ -272,7 +299,8 @@ export class VoxUserLayer extends UserLayer { } } -registerVoxelAnnotationTools(); +registerVoxelTools(VoxUserLayer); +registerLayerControls(VoxUserLayer); registerLayerType(VoxUserLayer); registerLayerTypeDetector((subsource) => { // Accept non-local datasources at low priority to avoid interfering with other layers. diff --git a/src/layer/vox/style.css b/src/layer/vox/style.css index eab560129c..ac59d6366f 100644 --- a/src/layer/vox/style.css +++ b/src/layer/vox/style.css @@ -24,66 +24,13 @@ --ng-accent: #3a6df0; } -.neuroglancer-vox-settings-tab, -.neuroglancer-vox-tools-tab { - box-sizing: border-box; - padding: 8px 10px; - display: flex; - flex-direction: column; - gap: 10px; - color: var(--ng-text); - max-width: 100%; - overflow-x: hidden; -} - -.neuroglancer-vox-row { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 10px; - padding: 6px 8px; - background: var(--ng-card); - border: 1px solid var(--ng-border); - border-radius: 6px; - width: 100%; - box-sizing: border-box; -} - .neuroglancer-vox-row label { - flex: 0 0 140px; /* fixed column for label */ + flex: 0 0 140px; min-width: 0; font-weight: 500; color: var(--ng-muted); } -.neuroglancer-vox-input, -.neuroglancer-vox-settings-tab select { - box-sizing: border-box; - flex: 1 1 8.5em; /* prefer ~8.5em but allow shrink/grow */ - min-width: 0; /* critical to allow shrinking within flex rows */ - width: auto; - max-width: 100%; - padding: 6px 8px; - border-radius: 6px; - border: 1px solid var(--ng-border); - background: rgba(255, 255, 255, 0.06); - color: var(--ng-text); - outline: none; -} - -/* Ensure any non-label child in a row can shrink and wrap instead of forcing horizontal overflow */ -.neuroglancer-vox-row > :not(label) { - flex: 1 1 0; - min-width: 0; -} - -/* Buttons inside rows should not shrink to unreadable widths */ -.neuroglancer-vox-row button { - flex: 0 0 auto; - white-space: nowrap; -} - -/* Status text can occupy a full line to improve readability */ .neuroglancer-vox-status { display: block; flex: 1 1 100%; @@ -92,30 +39,6 @@ color: var(--ng-muted); } -.neuroglancer-vox-input:focus, -.neuroglancer-vox-settings-tab select:focus { - border-color: color-mix(in oklab, var(--ng-accent) 60%, var(--ng-border)); - box-shadow: 0 0 0 2px color-mix(in oklab, var(--ng-accent) 25%, transparent); -} - -.neuroglancer-vox-settings-tab button, -.neuroglancer-vox-tools-tab button { - align-self: flex-start; - padding: 8px 12px; - border-radius: 6px; - border: 1px solid color-mix(in oklab, var(--ng-accent) 55%, var(--ng-border)); - background: linear-gradient( - 180deg, - color-mix(in oklab, var(--ng-accent) 88%, #2a2a2a) 0%, - color-mix(in oklab, var(--ng-accent) 70%, #1f1f1f) 100% - ); - color: #fff; - cursor: pointer; - transition: - filter 120ms ease, - transform 60ms ease; -} - .neuroglancer-vox-settings-tab button:hover, .neuroglancer-vox-tools-tab button:hover { filter: brightness(1.06); @@ -131,38 +54,3 @@ flex-direction: column; gap: 8px; } - -/* Slider styling */ -.neuroglancer-vox-tools-tab input[type="range"] { - -webkit-appearance: none; - appearance: none; - width: 100%; - height: 6px; - background: linear-gradient( - 90deg, - var(--ng-accent), - color-mix(in oklab, var(--ng-accent) 35%, #333) - ); - border-radius: 999px; - outline: none; -} -.neuroglancer-vox-tools-tab input[type="range"]::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 14px; - height: 14px; - border-radius: 50%; - background: #fff; - border: 1px solid var(--ng-border); - box-shadow: 0 0 0 2px color-mix(in oklab, var(--ng-accent) 45%, transparent); - cursor: pointer; -} -.neuroglancer-vox-tools-tab input[type="range"]::-moz-range-thumb { - width: 14px; - height: 14px; - border-radius: 50%; - background: #fff; - border: 1px solid var(--ng-border); - box-shadow: 0 0 0 2px color-mix(in oklab, var(--ng-accent) 45%, transparent); - cursor: pointer; -} diff --git a/src/layer/vox/tabs/tools.ts b/src/layer/vox/tabs/tools.ts index a769712724..3ee09bcd8c 100644 --- a/src/layer/vox/tabs/tools.ts +++ b/src/layer/vox/tabs/tools.ts @@ -14,13 +14,20 @@ * limitations under the License. */ +import { VOXEL_LAYER_CONTROLS } from "#src/layer/vox/controls.js"; import type { VoxUserLayer } from "#src/layer/vox/index.js"; +import { observeWatchable } from "#src/trackable_value.js"; +import { makeToolButton } from "#src/ui/tool.js"; import { - VoxelBrushLegacyTool, - VoxelFloodFillLegacyTool, - AdoptVoxelLabelTool, + ADOPT_VOXEL_LABEL_TOOL_ID, + BRUSH_TOOL_ID, + FLOODFILL_TOOL_ID, } from "#src/ui/voxel_annotations.js"; import { DataType } from "#src/util/data_type.js"; +import type { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; +import type { LabelsManager } from "#src/voxel_annotation/labels.js"; +import { DependentViewWidget } from "#src/widget/dependent_view_widget.js"; +import { addLayerControlToOptionsTab } from "#src/widget/layer_control.js"; import { Tab } from "#src/widget/tab_view.js"; function formatUnsignedId(id: bigint, dataType: DataType): string { @@ -34,335 +41,98 @@ function formatUnsignedId(id: bigint, dataType: DataType): string { if (dataType === DataType.UINT64) { return ((1n << 64n) + id).toString(); } - // Fallback for other types, though this case is unlikely for labels. return id.toString(); } export class VoxToolTab extends Tab { - private labelsContainer!: HTMLDivElement; - private labelsError!: HTMLDivElement; - private drawErrorContainer!: HTMLDivElement; - private renderLabels() { - const cont = this.labelsContainer; - cont.innerHTML = ""; - const labels = this.layer.voxLabelsManager.labels; - const selected = this.layer.voxLabelsManager.selectedLabelId; - for (const lab of labels) { - const row = document.createElement("div"); - row.className = "neuroglancer-vox-label-row"; - row.style.display = "grid"; - row.style.gridTemplateColumns = "16px 1fr"; - row.style.alignItems = "center"; - row.style.gap = "8px"; - // color swatch - const sw = document.createElement("div"); - sw.style.width = "16px"; - sw.style.height = "16px"; - sw.style.borderRadius = "3px"; - sw.style.border = "1px solid rgba(0,0,0,0.2)"; - sw.style.background = this.layer.voxLabelsManager.colorForValue(lab); - // id text (monospace) - const txt = document.createElement("div"); - txt.textContent = formatUnsignedId( - lab, - this.layer.voxLabelsManager.dataType, - ); - txt.style.fontFamily = "monospace"; - txt.style.whiteSpace = "nowrap"; - txt.style.overflow = "hidden"; - txt.style.textOverflow = "ellipsis"; - row.appendChild(sw); - row.appendChild(txt); - // selection styling - const isSel = lab === selected; - row.style.cursor = "pointer"; - row.style.padding = "2px 4px"; - row.style.borderRadius = "4px"; - if (isSel) { - row.style.background = "rgba(100,150,255,0.15)"; - row.style.outline = "1px solid rgba(100,150,255,0.6)"; - } - row.addEventListener("click", () => { - this.layer.voxLabelsManager.selectVoxLabel(lab); - }); - cont.appendChild(row); - } - // Update error message area - const err = this.layer.voxLabelsManager.labelsError; - if (err && err.length > 0) { - this.labelsError.textContent = err; - this.labelsError.style.display = "block"; - } else { - this.labelsError.textContent = ""; - this.labelsError.style.display = "none"; - } - } constructor(public layer: VoxUserLayer) { super(); - this.registerDisposer( - this.layer.labelsChanged.add(() => { - this.renderLabels(); - }), - ); - const { element } = this; element.classList.add("neuroglancer-vox-tools-tab"); + + const toolbox = document.createElement("div"); toolbox.className = "neuroglancer-vox-toolbox"; - // Section: Tool selection const toolsRow = document.createElement("div"); toolsRow.className = "neuroglancer-vox-row"; - const toolsLabel = document.createElement("label"); - toolsLabel.textContent = "Tool"; - const toolsWrap = document.createElement("div"); - toolsWrap.style.display = "flex"; - toolsWrap.style.gap = "8px"; - - const brushButton = document.createElement("button"); - brushButton.textContent = "Brush"; - brushButton.title = "ctrl+click to paint a small sphere"; - brushButton.addEventListener("click", () => { - this.layer.tool.value = new VoxelBrushLegacyTool(this.layer); + const toolsTitle = document.createElement("div"); + toolsTitle.textContent = "Tools"; + toolsTitle.style.fontWeight = "600"; + toolsRow.appendChild(toolsTitle); + + const toolButtonsContainer = document.createElement("div"); + toolButtonsContainer.style.display = "flex"; + toolButtonsContainer.style.gap = "8px"; + + const brushButton = makeToolButton(this, layer.toolBinder, { + toolJson: { type: BRUSH_TOOL_ID }, + label: "Brush", }); - const floodButton = document.createElement("button"); - floodButton.textContent = "Flood fill"; - floodButton.title = - "Click a voxel to flood fill the connected region on the current Z plane"; - floodButton.addEventListener("click", () => { - this.layer.tool.value = new VoxelFloodFillLegacyTool(this.layer); + const floodFillButton = makeToolButton(this, layer.toolBinder, { + toolJson: { type: FLOODFILL_TOOL_ID }, + label: "Flood Fill", }); - const adoptBtn = document.createElement("button"); - adoptBtn.textContent = "Pick"; - adoptBtn.title = - "Activate tool: click a non-zero voxel to add its ID as a label"; - adoptBtn.addEventListener("click", () => { - this.layer.tool.value = new AdoptVoxelLabelTool(this.layer); + const pickButton = makeToolButton(this, layer.toolBinder, { + toolJson: { type: ADOPT_VOXEL_LABEL_TOOL_ID }, + label: "Seg Picker", }); - toolsWrap.appendChild(adoptBtn); - toolsWrap.appendChild(brushButton); - toolsWrap.appendChild(floodButton); - toolsRow.appendChild(toolsLabel); - toolsRow.appendChild(toolsWrap); - toolbox.appendChild(toolsRow); - // Section: History (Undo/Redo) - const historyRow = document.createElement("div"); - historyRow.className = "neuroglancer-vox-row"; - const historyLabel = document.createElement("label"); - historyLabel.textContent = "History"; - const historyButtons = document.createElement("div"); - historyButtons.style.display = "flex"; - historyButtons.style.gap = "8px"; - - const undoButton = document.createElement("button"); - undoButton.textContent = "Undo"; - undoButton.title = "Undo last voxel edit"; - undoButton.addEventListener("click", () => { - const controller = this.layer.voxEditController; - if (!controller) { - throw new Error("Undo failed: voxEditController is not available."); - } - controller.undo(); - }); + toolButtonsContainer.appendChild(brushButton); + toolButtonsContainer.appendChild(floodFillButton); + toolButtonsContainer.appendChild(pickButton); + toolsRow.appendChild(toolButtonsContainer); + toolbox.appendChild(toolsRow); - const redoButton = document.createElement("button"); - redoButton.textContent = "Redo"; - redoButton.title = "Redo last undone voxel edit"; - redoButton.addEventListener("click", () => { - const controller = this.layer.voxEditController; - if (!controller) { - throw new Error("Redo failed: voxEditController is not available."); - } - controller.redo(); - }); - // TODO: move this logic to a signal activated function, so it runs after the voxEditController is instantiated - const ctrl = this.layer.voxEditController; - if (ctrl) { - undoButton.disabled = ctrl.undoCount.value === 0; - redoButton.disabled = ctrl.redoCount.value === 0; - this.registerDisposer( - ctrl.undoCount.changed.add(() => { - undoButton.disabled = ctrl.undoCount.value === 0; - }), + for (const controlDef of VOXEL_LAYER_CONTROLS) { + const controlElement = addLayerControlToOptionsTab( + this, + this.layer, + this.visibility, + controlDef, ); - this.registerDisposer( - ctrl.redoCount.changed.add(() => { - redoButton.disabled = ctrl.redoCount.value === 0; - }), - ); - } else { - console.error("TODO"); - } - - historyButtons.appendChild(undoButton); - historyButtons.appendChild(redoButton); - historyRow.appendChild(historyLabel); - historyRow.appendChild(historyButtons); - toolbox.appendChild(historyRow); - - // Section: Brush settings - const brushRow = document.createElement("div"); - brushRow.className = "neuroglancer-vox-row"; - - // Brush size as slider + number readout - const sizeLabel = document.createElement("label"); - sizeLabel.textContent = "Brush size"; - const sizeControls = document.createElement("div"); - sizeControls.style.display = "flex"; - sizeControls.style.alignItems = "center"; - sizeControls.style.gap = "8px"; - - const sizeSlider = document.createElement("input"); - sizeSlider.type = "range"; - sizeSlider.min = "1"; - sizeSlider.max = "64"; - sizeSlider.step = "1"; - sizeSlider.value = String(this.layer.voxBrushRadius ?? 3); - - const sizeNumber = document.createElement("input"); - sizeNumber.type = "number"; - sizeNumber.className = "neuroglancer-vox-input"; - sizeNumber.min = "1"; - sizeNumber.step = "1"; - sizeNumber.value = String(this.layer.voxBrushRadius ?? 3); - - const syncSize = (v: number) => { - const clamped = Math.max(1, Math.min(128, Math.floor(v))); - this.layer.voxBrushRadius = clamped; - sizeSlider.value = String(clamped); - sizeNumber.value = String(clamped); - }; - - sizeSlider.addEventListener("input", () => { - syncSize(Number(sizeSlider.value) || 1); - }); - sizeNumber.addEventListener("change", () => { - syncSize(Number(sizeNumber.value) || 1); - }); - - sizeControls.appendChild(sizeSlider); - sizeControls.appendChild(sizeNumber); - - // Eraser toggle - const erLabel = document.createElement("label"); - erLabel.textContent = "Eraser"; - const erChk = document.createElement("input"); - erChk.type = "checkbox"; - erChk.checked = !!this.layer.voxEraseMode; - erChk.addEventListener("change", () => { - this.layer.voxEraseMode = !!erChk.checked; - }); - - // Brush shape selector - const shapeLabel = document.createElement("label"); - shapeLabel.textContent = "Brush shape"; - const shapeSel = document.createElement("select"); - const optDisk = document.createElement("option"); - optDisk.value = "disk"; - optDisk.textContent = "disk"; - const optSphere = document.createElement("option"); - optSphere.value = "sphere"; - optSphere.textContent = "sphere"; - shapeSel.appendChild(optDisk); - shapeSel.appendChild(optSphere); - shapeSel.value = this.layer.voxBrushShape === "sphere" ? "sphere" : "disk"; - shapeSel.addEventListener("change", () => { - const v = shapeSel.value === "sphere" ? "sphere" : "disk"; - this.layer.voxBrushShape = v; - shapeSel.value = v; - }); - - // Layout within the brushRow: size controls, shape, eraser - const group = document.createElement("div"); - group.style.display = "grid"; - group.style.gridTemplateColumns = "minmax(120px,auto) 1fr"; - group.style.columnGap = "8px"; - group.style.rowGap = "8px"; - - // Row 1: Brush size - const sizeLabelCell = document.createElement("div"); - sizeLabelCell.appendChild(sizeLabel); - const sizeControlsCell = document.createElement("div"); - sizeControlsCell.appendChild(sizeControls); - - // Row 2: Brush shape - const shapeLabelCell = document.createElement("div"); - shapeLabelCell.appendChild(shapeLabel); - const shapeControlCell = document.createElement("div"); - shapeControlCell.appendChild(shapeSel); - - // Row 3: Eraser - const erLabelCell = document.createElement("div"); - erLabelCell.appendChild(erLabel); - const erControlCell = document.createElement("div"); - erControlCell.appendChild(erChk); - - group.appendChild(sizeLabelCell); - group.appendChild(sizeControlsCell); - group.appendChild(shapeLabelCell); - group.appendChild(shapeControlCell); - group.appendChild(erLabelCell); - group.appendChild(erControlCell); - - brushRow.appendChild(group); - toolbox.appendChild(brushRow); - - // Section: Flood fill settings - const floodRow = document.createElement("div"); - floodRow.className = "neuroglancer-vox-row"; - - const floodLabel = document.createElement("label"); - floodLabel.textContent = "Max fill voxels"; - const floodControls = document.createElement("div"); - floodControls.style.display = "flex"; - floodControls.style.alignItems = "center"; - floodControls.style.gap = "8px"; - - const floodMaxInput = document.createElement("input"); - floodMaxInput.type = "number"; - floodMaxInput.className = "neuroglancer-vox-input"; - floodMaxInput.min = "1"; - floodMaxInput.step = "1"; - - // Initialize with an explicit safe default if not set. - if (!Number.isFinite((this.layer as any).voxFloodMaxVoxels)) { - (this.layer as any).voxFloodMaxVoxels = 10000; - } - floodMaxInput.value = String((this.layer as any).voxFloodMaxVoxels); - floodMaxInput.addEventListener("change", () => { - const v = Math.floor(Number(floodMaxInput.value)); - if (!Number.isFinite(v) || v <= 0) { - throw new Error("VoxToolTab: Invalid max fill voxels value"); + if ( + controlDef.toolJson.type === "vox:undo" || + controlDef.toolJson.type === "vox:redo" + ) { + const button = controlElement.querySelector("button"); + if (button) { + this.registerDisposer( + new DependentViewWidget( + { + changed: this.layer.layersChanged, + get value() { + return layer.voxEditController; + }, + }, + (controller: VoxelEditController | undefined, _parent, context) => { + if (!controller) { + button.disabled = true; + return; + } + const watchable = + controlDef.toolJson.type === "vox:undo" + ? controller.undoCount + : controller.redoCount; + context.registerDisposer( + observeWatchable((count) => { + button.disabled = count === 0; + }, watchable), + ); + }, + this.visibility, + ), + ); + } } - (this.layer as any).voxFloodMaxVoxels = v; - floodMaxInput.value = String(v); - }); - - floodControls.appendChild(floodMaxInput); - const floodGroup = document.createElement("div"); - floodGroup.style.display = "grid"; - floodGroup.style.gridTemplateColumns = "minmax(120px,auto) 1fr"; - floodGroup.style.columnGap = "8px"; - floodGroup.style.rowGap = "8px"; - - const floodLabelCell = document.createElement("div"); - floodLabelCell.appendChild(floodLabel); - const floodControlsCell = document.createElement("div"); - floodControlsCell.appendChild(floodControls); - - floodGroup.appendChild(floodLabelCell); - floodGroup.appendChild(floodControlsCell); - - floodRow.appendChild(floodGroup); - toolbox.appendChild(floodRow); + toolbox.appendChild(controlElement); + } - // Section: Labels (moved to end, title on top for full width) const labelsSection = document.createElement("div"); labelsSection.style.display = "flex"; labelsSection.style.flexDirection = "column"; @@ -373,63 +143,105 @@ export class VoxToolTab extends Tab { labelsTitle.textContent = "Labels"; labelsTitle.style.fontWeight = "600"; - const buttonsRow = document.createElement("div"); - buttonsRow.style.display = "flex"; - buttonsRow.style.gap = "8px"; - const createBtn = document.createElement("button"); - createBtn.textContent = "New label"; - createBtn.addEventListener("click", () => { - this.layer.voxLabelsManager.createNewLabel(); - }); - buttonsRow.appendChild(createBtn); - - this.labelsContainer = document.createElement("div"); - this.labelsContainer.className = "neuroglancer-vox-labels"; - this.labelsContainer.style.display = "flex"; - this.labelsContainer.style.flexDirection = "column"; - this.labelsContainer.style.gap = "4px"; - this.labelsContainer.style.maxHeight = "180px"; - this.labelsContainer.style.overflowY = "auto"; - - this.labelsError = document.createElement("div"); - this.labelsError.className = "neuroglancer-vox-labels-error"; - this.labelsError.style.color = "#b00020"; // Material red 700-ish - this.labelsError.style.fontSize = "12px"; - this.labelsError.style.whiteSpace = "pre-wrap"; - this.labelsError.style.display = "none"; + const labelsWidget = this.registerDisposer( + new DependentViewWidget( + { + changed: this.layer.labelsChanged, + get value() { + return layer.voxLabelsManager; + }, + }, + (labelsManager: LabelsManager | undefined, parent) => { + if (labelsManager === undefined) return; + + const list = document.createElement("div"); + list.className = "neuroglancer-vox-labels"; + list.style.display = "flex"; + list.style.flexDirection = "column"; + list.style.gap = "4px"; + list.style.maxHeight = "180px"; + list.style.overflowY = "auto"; + + for (const label of labelsManager.labels) { + const row = document.createElement("div"); + row.className = "neuroglancer-vox-label-row"; + row.style.display = "grid"; + row.style.gridTemplateColumns = "16px 1fr"; + row.style.alignItems = "center"; + row.style.gap = "8px"; + + const swatch = document.createElement("div"); + swatch.style.width = "16px"; + swatch.style.height = "16px"; + swatch.style.borderRadius = "3px"; + swatch.style.border = "1px solid rgba(0,0,0,0.2)"; + swatch.style.background = labelsManager.colorForValue(label); + + const text = document.createElement("div"); + text.textContent = formatUnsignedId(label, labelsManager.dataType); + text.style.fontFamily = "monospace"; + text.style.whiteSpace = "nowrap"; + text.style.overflow = "hidden"; + text.style.textOverflow = "ellipsis"; + + row.appendChild(swatch); + row.appendChild(text); + + if (label === labelsManager.selectedLabelId) { + row.style.background = "rgba(100,150,255,0.15)"; + row.style.outline = "1px solid rgba(100,150,255,0.6)"; + } + row.style.cursor = "pointer"; + row.style.padding = "2px 4px"; + row.style.borderRadius = "4px"; + row.addEventListener("click", () => { + labelsManager.selectVoxLabel(label); + }); + + list.appendChild(row); + } + + if (labelsManager.labelsError) { + const errorDiv = document.createElement("div"); + errorDiv.className = "neuroglancer-vox-labels-error"; + errorDiv.style.color = "#b00020"; + errorDiv.style.fontSize = "12px"; + errorDiv.style.whiteSpace = "pre-wrap"; + errorDiv.textContent = labelsManager.labelsError; + parent.appendChild(errorDiv); + } + + parent.appendChild(list); + }, + this.visibility, + ), + ); labelsSection.appendChild(labelsTitle); - labelsSection.appendChild(buttonsRow); - labelsSection.appendChild(this.labelsContainer); - labelsSection.appendChild(this.labelsError); - + labelsSection.appendChild(labelsWidget.element); toolbox.appendChild(labelsSection); - // Draw error message area at the very end of the Draw tab - this.drawErrorContainer = document.createElement("div"); - this.drawErrorContainer.className = "neuroglancer-vox-draw-error"; - this.drawErrorContainer.style.color = "#b00020"; - this.drawErrorContainer.style.fontSize = "12px"; - this.drawErrorContainer.style.whiteSpace = "pre-wrap"; - this.drawErrorContainer.style.marginTop = "8px"; - this.drawErrorContainer.style.display = "none"; - toolbox.appendChild(this.drawErrorContainer); - - const updateDrawError = () => { + const drawErrorContainer = document.createElement("div"); + drawErrorContainer.className = "neuroglancer-vox-draw-error"; + drawErrorContainer.style.color = "#b00020"; + drawErrorContainer.style.fontSize = "12px"; + drawErrorContainer.style.whiteSpace = "pre-wrap"; + drawErrorContainer.style.marginTop = "8px"; + drawErrorContainer.style.display = "none"; + toolbox.appendChild(drawErrorContainer); + + this.layer.onDrawMessageChanged = () => { const msg = this.layer.voxDrawErrorMessage; if (msg && msg.length > 0) { - this.drawErrorContainer.textContent = msg; - this.drawErrorContainer.style.display = "block"; + drawErrorContainer.textContent = msg; + drawErrorContainer.style.display = "block"; } else { - this.drawErrorContainer.textContent = ""; - this.drawErrorContainer.style.display = "none"; + drawErrorContainer.textContent = ""; + drawErrorContainer.style.display = "none"; } }; - - this.layer.onDrawMessageChanged = () => updateDrawError(); - - updateDrawError(); + this.layer.onDrawMessageChanged(); element.appendChild(toolbox); } diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 3851ebc9c5..a1727e960a 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -16,25 +16,23 @@ import type { MouseSelectionState } from "#src/layer/index.js"; import type { VoxUserLayer } from "#src/layer/vox/index.js"; +import { BrushShape } from "#src/layer/vox/index.js"; import { StatusMessage } from "#src/status.js"; -import { LegacyTool, registerLegacyTool } from "#src/ui/tool.js"; +import { LayerTool, registerTool, type ToolActivation } from "#src/ui/tool.js"; import { vec3 } from "#src/util/geom.js"; -export const BRUSH_TOOL_ID = "voxBrush"; -export const FLOODFILL_TOOL_ID = "voxFloodFill"; -export const ADOPT_VOXEL_LABEL_TOOL_ID = "adoptVoxelLabel"; +export const BRUSH_TOOL_ID = "vox-brush"; +export const FLOODFILL_TOOL_ID = "vox-flood-fill"; +export const ADOPT_VOXEL_LABEL_TOOL_ID = "vox-pick-label"; -abstract class BaseVoxelLegacyTool extends LegacyTool { +abstract class BaseVoxelTool extends LayerTool { protected isDrawing = false; protected lastPoint: Int32Array | undefined; protected mouseDisposer: (() => void) | undefined; - protected onMouseUp = () => this.stopDrawing(); protected currentMouseState: MouseSelectionState | undefined; - // Store the latest mouse state without processing it immediately. private latestMouseState: MouseSelectionState | null = null; private animationFrameHandle: number | null = null; - // The main drawing loop synchronized to display refresh private drawLoop = (): void => { if (!this.isDrawing) { this.animationFrameHandle = null; @@ -44,14 +42,8 @@ abstract class BaseVoxelLegacyTool extends LegacyTool { this.animationFrameHandle = requestAnimationFrame(this.drawLoop); return; } - - const layer = this.layer as unknown as VoxUserLayer; - const value = layer.voxLabelsManager.getCurrentLabelValue( - layer.voxEraseMode, - ); const cur = this.getPoint(this.latestMouseState); - this.latestMouseState = null; // mark processed - + this.latestMouseState = null; if (cur) { const last = this.lastPoint; if ( @@ -60,34 +52,30 @@ abstract class BaseVoxelLegacyTool extends LegacyTool { ) { const points = this.linePoints(last, cur); if (points.length > 0) { + const value = this.layer.voxLabelsManager.getCurrentLabelValue( + this.layer.voxEraseMode.value, + ); this.paintPoints(points, value); } } this.lastPoint = cur; } - this.animationFrameHandle = requestAnimationFrame(this.drawLoop); }; protected getPoint(mouseState: MouseSelectionState): Int32Array | undefined { - const vox = (this.layer as any).getVoxelPositionFromMouse?.(mouseState) as + const vox = this.layer.getVoxelPositionFromMouse?.(mouseState) as | Float32Array | undefined; if (!mouseState?.active || !vox) return undefined; const planeNormal = mouseState?.planeNormal; - if (!mouseState?.active || !vox || !planeNormal) return undefined; - - // Replicate the exact logic from the vertex shader to ensure the CPU and GPU - // agree on the voxel coordinate. - const CHUNK_POSITION_EPSILON = 1e-3; const shiftedVox = new Float32Array(3); for (let i = 0; i < 3; ++i) { shiftedVox[i] = vox[i] + CHUNK_POSITION_EPSILON * Math.abs(planeNormal[i]); } - return new Int32Array([ Math.floor(shiftedVox[0]), Math.floor(shiftedVox[1]), @@ -126,16 +114,6 @@ abstract class BaseVoxelLegacyTool extends LegacyTool { this.isDrawing = true; this.currentMouseState = mouseState; - const layer = this.layer as unknown as VoxUserLayer; - const brushRadius = Math.max( - 1, - Math.floor((layer as any).voxBrushRadius ?? 3), - ); - if (!Number.isFinite(brushRadius) || brushRadius <= 0) { - throw new Error("startDrawing: invalid brushRadius"); - } - - // Compute starting point and lock render LOD before first paint. const start = this.getPoint(mouseState); if (!start) { throw new Error( @@ -143,37 +121,22 @@ abstract class BaseVoxelLegacyTool extends LegacyTool { ); } - const centerCanonical = new Float32Array([start[0], start[1], start[2]]); - const editLodIndex = 0; // locked to 0 rn - layer.beginRenderLodLock(editLodIndex); + // Lock render LOD to base level during edits + this.layer.beginRenderLodLock(0); - const value = layer.voxLabelsManager.getCurrentLabelValue( - layer.voxEraseMode, + const value = this.layer.voxLabelsManager.getCurrentLabelValue( + this.layer.voxEraseMode.value, ); - this.paintPoints([centerCanonical], value); + this.paintPoints([new Float32Array([start[0], start[1], start[2]])], value); this.lastPoint = start; - // Initialize latest mouse state so RAF can process immediately this.latestMouseState = mouseState; - // On mouse move, just update the latest position. this.mouseDisposer = mouseState.changed.add(() => { this.latestMouseState = mouseState; this.currentMouseState = mouseState; }); - // On mouse up, stop drawing and cleanup. - const mouseUpHandler = () => { - this.stopDrawing(); - window.removeEventListener("mouseup", mouseUpHandler); - if (this.mouseDisposer) { - this.mouseDisposer(); - this.mouseDisposer = undefined; - } - }; - window.addEventListener("mouseup", mouseUpHandler); - - // Start the animation loop if not running if (this.animationFrameHandle === null) { this.animationFrameHandle = requestAnimationFrame(this.drawLoop); } @@ -183,57 +146,54 @@ abstract class BaseVoxelLegacyTool extends LegacyTool { if (!this.isDrawing) return; this.isDrawing = false; this.lastPoint = undefined; - if (this.animationFrameHandle !== null) { cancelAnimationFrame(this.animationFrameHandle); this.animationFrameHandle = null; } - if (this.mouseDisposer) { this.mouseDisposer(); this.mouseDisposer = undefined; } - - // Always release any active render LOD lock. try { this.layer.endRenderLodLock(); - } catch (e) { - console.warn("stopDrawing: failed to end render LOD lock:", e); + } catch { + /* ignore */ } } +} - trigger(mouseState: MouseSelectionState) { - if ((this.layer as any)?.constructor?.type !== "vox") return; - try { - this.startDrawing(mouseState); - } catch (e) { - console.error(`[${this.constructor.name}] Error:`, e); - this.stopDrawing(); - } +export class VoxelBrushTool extends BaseVoxelTool { + constructor(layer: VoxUserLayer) { + super(layer, /*toggle=*/ true); } - deactivate() { - this.stopDrawing(); + toJSON() { + return BRUSH_TOOL_ID; } -} -export class VoxelBrushLegacyTool extends BaseVoxelLegacyTool { - description = "brush"; + get description() { + return "Brush tool"; + } - toJSON() { - return BRUSH_TOOL_ID; + activate(activation: ToolActivation): void { + // Bind mouse down to start drawing, release to stop + activation.bindAction("mousedown0", (event) => { + event.stopPropagation(); + this.startDrawing(this.mouseState); + }); + activation.registerEventListener(window, "mouseup", () => { + this.stopDrawing(); + }); } protected paintPoints(points: Float32Array[], value: bigint) { - const radius = Math.max( - 1, - Math.floor((this.layer as any).voxBrushRadius ?? 3), - ); - const shape = - (this.layer as any).voxBrushShape === "sphere" ? "sphere" : "disk"; - const ctrl = (this.layer as any).voxEditController; - let basis = undefined; - if (shape === "disk" && this.currentMouseState?.planeNormal) { + const radius = Math.max(1, Math.floor(this.layer.voxBrushRadius.value ?? 3)); + const shapeEnum = this.layer.voxBrushShape.value; + const ctrl = this.layer.voxEditController; + let basis = undefined as + | undefined + | { u: Float32Array; v: Float32Array }; + if (shapeEnum === BrushShape.disk && this.currentMouseState?.planeNormal) { const n = this.currentMouseState.planeNormal; const u = vec3.create(); const tempVec = @@ -246,101 +206,71 @@ export class VoxelBrushLegacyTool extends BaseVoxelLegacyTool { vec3.normalize(v, v); basis = { u, v }; } - for (const point of points) { - ctrl?.paintBrushWithShape(point, radius, value, shape, basis); - } + for (const p of points) ctrl?.paintBrushWithShape(p, radius, value, shapeEnum, basis); } } -export class VoxelFloodFillLegacyTool extends BaseVoxelLegacyTool { - protected paintPoints(_points: Float32Array[], _value: bigint): void { - throw new Error("Method not implemented."); +export class VoxelFloodFillTool extends BaseVoxelTool { + constructor(layer: VoxUserLayer) { + super(layer, /*toggle=*/ true); } - description = "flood fill"; toJSON() { return FLOODFILL_TOOL_ID; } - trigger(mouseState: MouseSelectionState) { - const layer = this.layer as unknown as VoxUserLayer; - try { - // Clear any previous draw error message - layer.setDrawErrorMessage(undefined); - - if (!mouseState?.active) { - console.info("[VoxFloodFill] trigger ignored: mouse inactive"); - return; - } - - const seed = this.getPoint(mouseState); - const planeNormal = mouseState.planeNormal; - - if (!seed || !planeNormal) { - throw new Error( - "Flood fill: failed to get voxel position or plane normal.", - ); - } + get description() { + return "Flood fill tool"; + } - const value = layer.voxLabelsManager.getCurrentLabelValue( - layer.voxEraseMode, - ); - const max = Number((layer as any).voxFloodMaxVoxels); - if (!Number.isFinite(max) || max <= 0) { - throw new Error( - "Flood fill: invalid max voxels; set it in the tool panel", - ); - } - const ctrl = layer.voxEditController; - if (!ctrl) throw new Error("Flood fill: drawing backend not ready yet"); - - console.info("[VoxFloodFill] starting flood fill", { - seed: Array.from(seed), - value: value, - max: Math.floor(max), - }); - ctrl - .floodFillPlane2D(new Float32Array(seed), value, Math.floor(max), planeNormal) - .then(({ edits, filledCount }) => { - console.info("[VoxFloodFill] BFS completed", { - filledCount, - editsByChunk: edits.length, - }); - - if (edits.length === 0) return; - if (typeof ctrl.commitEdits === "function") { - ctrl.commitEdits(edits); - console.info("[VoxFloodFill] committed edits"); - } else if ((ctrl as any).rpc && (ctrl as any).rpc.invoke) { - (ctrl as any).rpc.invoke("VOX_EDIT_COMMIT_VOXELS", { - rpcId: (ctrl as any).rpcId, - edits, - }); - console.info("[VoxFloodFill] committed edits via fallback path"); - } else { - throw new Error("Flood fill: no way to commit edits"); - } - }); - } catch (e: any) { - const msg = typeof e?.message === "string" ? e.message : String(e); + activate(activation: ToolActivation): void { + activation.bindAction("mousedown0", (event) => { + event.stopPropagation(); + const seed = this.getPoint(this.mouseState); + const planeNormal = this.mouseState.planeNormal; + if (!seed || !planeNormal) return; + const layer = this.layer; try { - layer.setDrawErrorMessage(msg); - } catch { - /* ignore */ + layer.setDrawErrorMessage(undefined); + const value = layer.voxLabelsManager.getCurrentLabelValue( + layer.voxEraseMode.value, + ); + const max = Number(layer.voxFloodMaxVoxels.value); + if (!Number.isFinite(max) || max <= 0) { + throw new Error("Invalid max fill voxels setting"); + } + const ctrl = layer.voxEditController; + if (!ctrl) throw new Error("Drawing backend not ready yet"); + ctrl + .floodFillPlane2D(new Float32Array(seed), value, Math.floor(max), planeNormal) + .catch((e: any) => layer.setDrawErrorMessage?.(String(e?.message ?? e))); + } catch (e: any) { + layer.setDrawErrorMessage?.(String(e?.message ?? e)); } - } + }); + } + protected paintPoints(): void { + /* not used */ } } -export class AdoptVoxelLabelTool extends LegacyTool { - description = "label picker"; +export class AdoptVoxelLabelTool extends LayerTool { + constructor(layer: VoxUserLayer) { + super(layer, /*toggle=*/ false); + } + toJSON() { return ADOPT_VOXEL_LABEL_TOOL_ID; } - trigger(mouseState: MouseSelectionState) { - if (!mouseState?.active) return; + + get description() { + return "Picking tool"; + } + + activate(_activation: ToolActivation): void { + if (!this.mouseState?.active) return; const layer = this.layer as VoxUserLayer; - const pos = layer.getVoxelPositionFromMouse?.(mouseState); + const pos = layer.getVoxelPositionFromMouse?.(this.mouseState); if (!pos || pos.length < 3) { StatusMessage.showTemporaryMessage( @@ -391,17 +321,8 @@ export class AdoptVoxelLabelTool extends LegacyTool { } } -export function registerVoxelAnnotationTools() { - registerLegacyTool( - BRUSH_TOOL_ID, - (layer) => new VoxelBrushLegacyTool(layer as unknown as VoxUserLayer), - ); - registerLegacyTool( - FLOODFILL_TOOL_ID, - (layer) => new VoxelFloodFillLegacyTool(layer as unknown as VoxUserLayer), - ); - registerLegacyTool( - ADOPT_VOXEL_LABEL_TOOL_ID, - (layer) => new AdoptVoxelLabelTool(layer as unknown as VoxUserLayer), - ); +export function registerVoxelTools(LayerCtor: any) { + registerTool(LayerCtor, BRUSH_TOOL_ID, (layer: VoxUserLayer) => new VoxelBrushTool(layer)); + registerTool(LayerCtor, FLOODFILL_TOOL_ID, (layer: VoxUserLayer) => new VoxelFloodFillTool(layer)); + registerTool(LayerCtor, ADOPT_VOXEL_LABEL_TOOL_ID, (layer: VoxUserLayer) => new AdoptVoxelLabelTool(layer)); } diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index b64cac4643..8c520dfdfb 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -1,21 +1,19 @@ ### priority -- fix the flood fill for compressed chunks -- test uint64 support - url completion for the ssa+https source - different mouse cursors for the different tools +- should we support compressed chunks? if yes, we should find a better way to handle them. + ### later - rework the ui (draw tabs) to fit neuroglancer style - optimize flood fill tool (it is too slow on area containing uncached chunks, due to the getEnsuredValueAt() calls) -- rework the drawing preview for compressed chunk (see applyLocalEdits()) - rework the url autocomplete for the ssa+https source. - the flood fill sometimes leaves artifacts in sharp areas (maybe increase fillBorderRegion() radius) - add shortcuts for tools (switching tools, toggle erase mode and adjusting brush size) and label creation - write a testsuite for the downsampler and ensure its proper working on exotic lod levels - fix undo/redo buttons activation states (see tabs/tools.ts) - ### questionable - design a dataset creation feature - adapt the brush size to the zoom level linearly diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 5c365f93b5..efd19ec37e 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { VoxUserLayer } from "#src/layer/vox/index.js"; +import { BrushShape, VoxUserLayer } from "#src/layer/vox/index.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; import type { VolumeChunkSource, @@ -154,7 +154,7 @@ export class VoxelEditController extends SharedObject { centerCanonical: Float32Array, radiusCanonical: number, value: bigint, - shape: "disk" | "sphere" = "disk", + shape: BrushShape, basis?: { u: Float32Array; v: Float32Array }, ) { if (!Number.isFinite(radiusCanonical) || radiusCanonical <= 0) { @@ -186,7 +186,7 @@ export class VoxelEditController extends SharedObject { const voxelsToPaint: Float32Array[] = []; - if (shape === "sphere") { + if (shape === BrushShape.sphere) { for (let dz = -r; dz <= r; ++dz) { for (let dy = -r; dy <= r; ++dy) { for (let dx = -r; dx <= r; ++dx) { diff --git a/src/widget/layer_control_button.ts b/src/widget/layer_control_button.ts new file mode 100644 index 0000000000..a74af12c6a --- /dev/null +++ b/src/widget/layer_control_button.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2024 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { UserLayer } from "#src/layer/index.js"; +import type { LayerControlFactory } from "#src/widget/layer_control.js"; + +export function buttonLayerControl(options: { + text: string; + onClick: (layer: LayerType) => void; +}): LayerControlFactory { + return { + makeControl: (layer, context) => { + const control = document.createElement("button"); + control.textContent = options.text; + context.registerEventListener(control, "click", () => options.onClick(layer)); + return { control, controlElement: control }; + }, + activateTool: (activation) => { + options.onClick(activation.tool.layer as LayerType); + }, + }; +} From 8c7fadba1b82a215c17afc83939a92b7ee77015d Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 085/251] refactor: fully migrate voxel tools to new tool system - Integrate `EventActionMap` and replace legacy mouse event handlers. - Abstract tool activation and deactivation callbacks into `BaseVoxelTool`. - Refactor brush, flood fill, and picker tools to leverage the new framework. - Standardize brush shape enums (`DISK`, `SPHERE`) for consistency. - Simplify and clean up redundant logic in paint and flood fill processes. - Improve modularity and update brush tool with enhanced draw flow logic. --- src/layer/vox/index.ts | 6 +- src/ui/voxel_annotations.ts | 234 ++++++++++++++---------- src/voxel_annotation/edit_controller.ts | 5 +- 3 files changed, 139 insertions(+), 106 deletions(-) diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 26f23b3d18..26afe8cd77 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -51,8 +51,8 @@ import { LabelsManager } from "#src/voxel_annotation/labels.js"; import { VoxelAnnotationRenderLayer } from "#src/voxel_annotation/renderlayer.js"; export enum BrushShape { - disk = 0, - sphere = 1, + DISK = 0, + SPHERE = 1, } export class VoxUserLayer extends UserLayer { @@ -69,7 +69,7 @@ export class VoxUserLayer extends UserLayer { // Draw tool state (trackables for widget integration) voxBrushRadius = new TrackableValue(3, verifyInt); voxEraseMode = new TrackableBoolean(false); - voxBrushShape = new TrackableEnum(BrushShape, BrushShape.disk); + voxBrushShape = new TrackableEnum(BrushShape, BrushShape.DISK); voxFloodMaxVoxels = new TrackableValue(10000, verifyFiniteFloat); // Cached transform and voxel buffer to avoid recomputation/allocation on every mouse move diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index a1727e960a..b8d881e2b1 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -20,48 +20,19 @@ import { BrushShape } from "#src/layer/vox/index.js"; import { StatusMessage } from "#src/status.js"; import { LayerTool, registerTool, type ToolActivation } from "#src/ui/tool.js"; import { vec3 } from "#src/util/geom.js"; +import { EventActionMap } from "#src/util/mouse_bindings.js"; +import { startRelativeMouseDrag } from "#src/util/mouse_drag.js"; export const BRUSH_TOOL_ID = "vox-brush"; export const FLOODFILL_TOOL_ID = "vox-flood-fill"; export const ADOPT_VOXEL_LABEL_TOOL_ID = "vox-pick-label"; -abstract class BaseVoxelTool extends LayerTool { - protected isDrawing = false; - protected lastPoint: Int32Array | undefined; - protected mouseDisposer: (() => void) | undefined; - protected currentMouseState: MouseSelectionState | undefined; - private latestMouseState: MouseSelectionState | null = null; - private animationFrameHandle: number | null = null; +const VOX_TOOL_INPUT_MAP = EventActionMap.fromObject({ + ["at:control+mousedown0"]: "paint-voxels", +}); - private drawLoop = (): void => { - if (!this.isDrawing) { - this.animationFrameHandle = null; - return; - } - if (this.latestMouseState === null) { - this.animationFrameHandle = requestAnimationFrame(this.drawLoop); - return; - } - const cur = this.getPoint(this.latestMouseState); - this.latestMouseState = null; - if (cur) { - const last = this.lastPoint; - if ( - last && - (cur[0] !== last[0] || cur[1] !== last[1] || cur[2] !== last[2]) - ) { - const points = this.linePoints(last, cur); - if (points.length > 0) { - const value = this.layer.voxLabelsManager.getCurrentLabelValue( - this.layer.voxEraseMode.value, - ); - this.paintPoints(points, value); - } - } - this.lastPoint = cur; - } - this.animationFrameHandle = requestAnimationFrame(this.drawLoop); - }; +abstract class BaseVoxelTool extends LayerTool { + protected latestMouseState: MouseSelectionState | null = null; protected getPoint(mouseState: MouseSelectionState): Int32Array | undefined { const vox = this.layer.getVoxelPositionFromMouse?.(mouseState) as @@ -107,9 +78,88 @@ abstract class BaseVoxelTool extends LayerTool { return out; } - protected abstract paintPoints(points: Float32Array[], value: bigint): void; + activate(activation: ToolActivation): void { + activation.bindInputEventMap(VOX_TOOL_INPUT_MAP); - protected startDrawing(mouseState: MouseSelectionState) { + activation.bindAction("paint-voxels", (event) => { + event.stopPropagation(); + this.activationCallback(activation); + startRelativeMouseDrag( + event.detail as MouseEvent, + () => { + this.latestMouseState = this.mouseState; + }, + () => { + this.deactivationCallback(activation); + }, + ); + + return true; + }); + + } + + abstract activationCallback(activation: ToolActivation): void; + abstract deactivationCallback(activation: ToolActivation): void; + +} + +export class VoxelBrushTool extends BaseVoxelTool { + private isDrawing = false; + private lastPoint: Int32Array | undefined; + private mouseDisposer: (() => void) | undefined; + private currentMouseState: MouseSelectionState | undefined; + private animationFrameHandle: number | null = null; + + activationCallback(_activation: ToolActivation): void { + this.startDrawing(this.mouseState); + } + deactivationCallback(_activation: ToolActivation): void { + this.stopDrawing(); + } + constructor(layer: VoxUserLayer) { + super(layer, /*toggle=*/ true); + } + + toJSON() { + return BRUSH_TOOL_ID; + } + + get description() { + return "Brush tool"; + } + + private drawLoop = (): void => { + if (!this.isDrawing) { + this.animationFrameHandle = null; + return; + } + if (this.latestMouseState === null) { + this.animationFrameHandle = requestAnimationFrame(this.drawLoop); + return; + } + const cur = this.getPoint(this.latestMouseState); + this.latestMouseState = null; + if (cur) { + const last = this.lastPoint; + if ( + last && + (cur[0] !== last[0] || cur[1] !== last[1] || cur[2] !== last[2]) + ) { + const points = this.linePoints(last, cur); + if (points.length > 0) { + const value = this.layer.voxLabelsManager.getCurrentLabelValue( + this.layer.voxEraseMode.value, + ); + this.paintPoints(points, value); + } + } + this.lastPoint = cur; + } + this.animationFrameHandle = requestAnimationFrame(this.drawLoop); + }; + + private startDrawing(mouseState: MouseSelectionState) { if (this.isDrawing) return; this.isDrawing = true; this.currentMouseState = mouseState; @@ -142,7 +192,7 @@ abstract class BaseVoxelTool extends LayerTool { } } - protected stopDrawing() { + private stopDrawing() { if (!this.isDrawing) return; this.isDrawing = false; this.lastPoint = undefined; @@ -160,40 +210,16 @@ abstract class BaseVoxelTool extends LayerTool { /* ignore */ } } -} - -export class VoxelBrushTool extends BaseVoxelTool { - constructor(layer: VoxUserLayer) { - super(layer, /*toggle=*/ true); - } - - toJSON() { - return BRUSH_TOOL_ID; - } - - get description() { - return "Brush tool"; - } - - activate(activation: ToolActivation): void { - // Bind mouse down to start drawing, release to stop - activation.bindAction("mousedown0", (event) => { - event.stopPropagation(); - this.startDrawing(this.mouseState); - }); - activation.registerEventListener(window, "mouseup", () => { - this.stopDrawing(); - }); - } - protected paintPoints(points: Float32Array[], value: bigint) { - const radius = Math.max(1, Math.floor(this.layer.voxBrushRadius.value ?? 3)); + private paintPoints(points: Float32Array[], value: bigint) { + const radius = Math.max( + 1, + Math.floor(this.layer.voxBrushRadius.value ?? 3), + ); const shapeEnum = this.layer.voxBrushShape.value; const ctrl = this.layer.voxEditController; - let basis = undefined as - | undefined - | { u: Float32Array; v: Float32Array }; - if (shapeEnum === BrushShape.disk && this.currentMouseState?.planeNormal) { + let basis = undefined as undefined | { u: Float32Array; v: Float32Array }; + if (shapeEnum === BrushShape.DISK && this.currentMouseState?.planeNormal) { const n = this.currentMouseState.planeNormal; const u = vec3.create(); const tempVec = @@ -206,11 +232,47 @@ export class VoxelBrushTool extends BaseVoxelTool { vec3.normalize(v, v); basis = { u, v }; } - for (const p of points) ctrl?.paintBrushWithShape(p, radius, value, shapeEnum, basis); + for (const p of points) + ctrl?.paintBrushWithShape(p, radius, value, shapeEnum, basis); } } export class VoxelFloodFillTool extends BaseVoxelTool { + activationCallback(_activation: ToolActivation): void { + const seed = this.getPoint(this.mouseState); + const planeNormal = this.mouseState.planeNormal; + if (!seed || !planeNormal) return; + const layer = this.layer; + try { + layer.setDrawErrorMessage(undefined); + const value = layer.voxLabelsManager.getCurrentLabelValue( + layer.voxEraseMode.value, + ); + const max = Number(layer.voxFloodMaxVoxels.value); + if (!Number.isFinite(max) || max <= 0) { + throw new Error("Invalid max fill voxels setting"); + } + const ctrl = layer.voxEditController; + if (!ctrl) throw new Error("Drawing backend not ready yet"); + ctrl + .floodFillPlane2D( + new Float32Array(seed), + value, + Math.floor(max), + planeNormal, + ) + .catch((e: any) => + layer.setDrawErrorMessage?.(String(e?.message ?? e)), + ); + } catch (e: any) { + layer.setDrawErrorMessage?.(String(e?.message ?? e)); + } + } + + deactivationCallback(_activation: ToolActivation): void { + return; + } + constructor(layer: VoxUserLayer) { super(layer, /*toggle=*/ true); } @@ -222,36 +284,6 @@ export class VoxelFloodFillTool extends BaseVoxelTool { get description() { return "Flood fill tool"; } - - activate(activation: ToolActivation): void { - activation.bindAction("mousedown0", (event) => { - event.stopPropagation(); - const seed = this.getPoint(this.mouseState); - const planeNormal = this.mouseState.planeNormal; - if (!seed || !planeNormal) return; - const layer = this.layer; - try { - layer.setDrawErrorMessage(undefined); - const value = layer.voxLabelsManager.getCurrentLabelValue( - layer.voxEraseMode.value, - ); - const max = Number(layer.voxFloodMaxVoxels.value); - if (!Number.isFinite(max) || max <= 0) { - throw new Error("Invalid max fill voxels setting"); - } - const ctrl = layer.voxEditController; - if (!ctrl) throw new Error("Drawing backend not ready yet"); - ctrl - .floodFillPlane2D(new Float32Array(seed), value, Math.floor(max), planeNormal) - .catch((e: any) => layer.setDrawErrorMessage?.(String(e?.message ?? e))); - } catch (e: any) { - layer.setDrawErrorMessage?.(String(e?.message ?? e)); - } - }); - } - protected paintPoints(): void { - /* not used */ - } } export class AdoptVoxelLabelTool extends LayerTool { diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index efd19ec37e..b1a330f27b 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import { BrushShape, VoxUserLayer } from "#src/layer/vox/index.js"; +import type { VoxUserLayer } from "#src/layer/vox/index.js"; +import { BrushShape } from "#src/layer/vox/index.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; import type { VolumeChunkSource, @@ -186,7 +187,7 @@ export class VoxelEditController extends SharedObject { const voxelsToPaint: Float32Array[] = []; - if (shape === BrushShape.sphere) { + if (shape === BrushShape.SPHERE) { for (let dz = -r; dz <= r; ++dz) { for (let dy = -r; dy <= r; ++dy) { for (let dx = -r; dx <= r; ++dx) { From a1d56f1adfba2313a91b73dcba4efbdf4865fb7c Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 086/251] feat: add dynamic cursor support for brush and flood fill tools --- src/ui/voxel_annotations.ts | 87 +++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index b8d881e2b1..e4e8f49dff 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -17,11 +17,13 @@ import type { MouseSelectionState } from "#src/layer/index.js"; import type { VoxUserLayer } from "#src/layer/vox/index.js"; import { BrushShape } from "#src/layer/vox/index.js"; +import type { RenderedDataPanel } from "#src/rendered_data_panel.js"; import { StatusMessage } from "#src/status.js"; import { LayerTool, registerTool, type ToolActivation } from "#src/ui/tool.js"; import { vec3 } from "#src/util/geom.js"; import { EventActionMap } from "#src/util/mouse_bindings.js"; import { startRelativeMouseDrag } from "#src/util/mouse_drag.js"; +import { NullarySignal } from "#src/util/signal.js"; export const BRUSH_TOOL_ID = "vox-brush"; export const FLOODFILL_TOOL_ID = "vox-flood-fill"; @@ -102,6 +104,17 @@ abstract class BaseVoxelTool extends LayerTool { abstract activationCallback(activation: ToolActivation): void; abstract deactivationCallback(activation: ToolActivation): void; + protected setCursor(cursor: string) { + for (const panel of this.layer.manager.root.display.panels) { + panel.element.style.setProperty("cursor", cursor, "important"); + } + } + + protected resetCursor() { + for (const panel of this.layer.manager.root.display.panels) { + panel.element.style.removeProperty("cursor"); + } + } } export class VoxelBrushTool extends BaseVoxelTool { @@ -111,12 +124,57 @@ export class VoxelBrushTool extends BaseVoxelTool { private currentMouseState: MouseSelectionState | undefined; private animationFrameHandle: number | null = null; + activate(activation: ToolActivation) { + super.activate(activation); + const getZoom = () => { + const panels = Array.from(this.layer.manager.root.display.panels) as RenderedDataPanel[]; + if (panels.length > 0) { + return panels[0].navigationState.zoomFactor.value; + } + return 1.0; + }; + + const getZoomChangedSignal = () => { + const panels = Array.from(this.layer.manager.root.display.panels) as RenderedDataPanel[]; + return panels.length > 0 ? panels[0].navigationState.zoomFactor.changed : new NullarySignal(); + }; + + const updateCursor = () => { + const radiusInVoxels = this.layer.voxBrushRadius.value; + const zoom = getZoom(); + const radiusInPixels = Math.max(1, radiusInVoxels / zoom); + const svgSize = 2 * radiusInPixels + 4; + const svgCenter = svgSize / 2; + + const svgString = ` + + + + + `.replace(/\s\s+/g, " "); + + const cursorURL = `url('data:image/svg+xml;utf8,${encodeURIComponent(svgString)}')`; + this.setCursor(`${cursorURL} ${svgCenter} ${svgCenter}, crosshair`) + }; + + updateCursor(); + activation.registerDisposer(this.layer.voxBrushRadius.changed.add(updateCursor)); + activation.registerDisposer(getZoomChangedSignal().add(updateCursor)); + activation.registerDisposer(() => { + this.resetCursor(); + }); + } + activationCallback(_activation: ToolActivation): void { this.startDrawing(this.mouseState); } + deactivationCallback(_activation: ToolActivation): void { this.stopDrawing(); } + constructor(layer: VoxUserLayer) { super(layer, /*toggle=*/ true); } @@ -237,7 +295,36 @@ export class VoxelBrushTool extends BaseVoxelTool { } } +const floodFillSVG = ` + + + + + + + + +`.replace(/\s\s+/g, " "); + +const floodFillCursor = `url('data:image/svg+xml;utf8,${encodeURIComponent(floodFillSVG)}') 4 19, crosshair`; + + export class VoxelFloodFillTool extends BaseVoxelTool { + activate(activation: ToolActivation) { + super.activate(activation); + this.setCursor(floodFillCursor); + activation.registerDisposer(() => {this.resetCursor()}) + } + activationCallback(_activation: ToolActivation): void { const seed = this.getPoint(this.mouseState); const planeNormal = this.mouseState.planeNormal; From c845d0dc2086d5e1d7232b86a0cdd85272109e77 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 087/251] feat: improve voxel layer rendering and UI enhancements --- src/layer/vox/controls.ts | 12 ++++++------ src/layer/vox/index.ts | 2 +- src/ui/voxel_annotations.ts | 17 ++++++++++++++++- src/voxel_annotation/TODOs.md | 5 ----- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/layer/vox/controls.ts b/src/layer/vox/controls.ts index 2f6170d979..c11c779825 100644 --- a/src/layer/vox/controls.ts +++ b/src/layer/vox/controls.ts @@ -35,24 +35,24 @@ export const VOXEL_LAYER_CONTROLS: LayerControlDefinition[] = [ })), }, { - label: "", - toolJson: { type: "vox:undo" }, + label: "Undo", + toolJson: { type: "vox-undo" }, ...buttonLayerControl({ text: "Undo", onClick: (layer) => layer.handleAction("undo", new LayerActionContext()), }), }, { - label: "", - toolJson: { type: "vox:redo" }, + label: "Redo", + toolJson: { type: "vox-redo" }, ...buttonLayerControl({ text: "Redo", onClick: (layer) => layer.handleAction("redo", new LayerActionContext()), }), }, { - label: "", - toolJson: { type: "vox:new-label" }, + label: "New label", + toolJson: { type: "vox-new-label" }, ...buttonLayerControl({ text: "New Label", onClick: (layer) => layer.handleAction("new-label", new LayerActionContext()), diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 26afe8cd77..2d171f8a11 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -57,7 +57,7 @@ export enum BrushShape { export class VoxUserLayer extends UserLayer { // While drawing, we keep a reference to the vox render layer to control temporary LOD locks. - private voxRenderLayerInstance?: VoxelAnnotationRenderLayer; + voxRenderLayerInstance?: VoxelAnnotationRenderLayer; // Match Image/Segmentation layers: provide a per-layer cross-section render scale target/histogram. sliceViewRenderScaleTarget = trackableRenderScaleTarget(1); static type = "vox"; diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index e4e8f49dff..d63f6d7c52 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -408,7 +408,22 @@ export class AdoptVoxelLabelTool extends LayerTool { return; } - const source = editController.getSourceForLOD(0); + const renderLayer = layer.voxRenderLayerInstance; + if (!renderLayer) { + StatusMessage.showTemporaryMessage("Render layer not available.", 3000); + return; + } + + const visibleSources = renderLayer.visibleSourcesList; + if (visibleSources.length === 0) { + StatusMessage.showTemporaryMessage( + "No data is visible at the current zoom level.", + 3000, + ); + return; + } + + const source = visibleSources[0].source; const channelAccess = editController.singleChannelAccess; StatusMessage.forPromise( diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 8c520dfdfb..549b5e2a93 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -1,18 +1,13 @@ ### priority - url completion for the ssa+https source -- different mouse cursors for the different tools - should we support compressed chunks? if yes, we should find a better way to handle them. ### later -- rework the ui (draw tabs) to fit neuroglancer style - optimize flood fill tool (it is too slow on area containing uncached chunks, due to the getEnsuredValueAt() calls) -- rework the url autocomplete for the ssa+https source. - the flood fill sometimes leaves artifacts in sharp areas (maybe increase fillBorderRegion() radius) -- add shortcuts for tools (switching tools, toggle erase mode and adjusting brush size) and label creation - write a testsuite for the downsampler and ensure its proper working on exotic lod levels -- fix undo/redo buttons activation states (see tabs/tools.ts) ### questionable - design a dataset creation feature From fdef2c013e7b446e3b343d0dda9d01e15a03e739 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 088/251] refactor: Merging vox layer into seg and img layers see TODOs.md --- src/layer/image/index.ts | 17 +- src/layer/segmentation/index.ts | 17 +- src/layer/vox/controls.ts | 13 +- src/layer/vox/index.ts | 463 +++++++++++++----------- src/layer/vox/tabs/tools.ts | 6 +- src/sliceview/volume/frontend.ts | 36 +- src/ui/voxel_annotations.ts | 74 ++-- src/voxel_annotation/TODOs.md | 56 +++ src/voxel_annotation/edit_controller.ts | 47 ++- 9 files changed, 446 insertions(+), 283 deletions(-) diff --git a/src/layer/image/index.ts b/src/layer/image/index.ts index 241b69853e..e23fc06274 100644 --- a/src/layer/image/index.ts +++ b/src/layer/image/index.ts @@ -34,6 +34,7 @@ import { UserLayer, } from "#src/layer/index.js"; import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; +import { UserLayerWithVoxelEditingMixin } from "#src/layer/vox/index.js"; import { Overlay } from "#src/overlay.js"; import { getChannelSpace } from "#src/render_coordinate_transform.js"; import { @@ -119,7 +120,9 @@ export interface ImageLayerSelectionState extends UserLayerSelectionState { value: any; } -const Base = UserLayerWithAnnotationsMixin(UserLayer); +const Base = UserLayerWithVoxelEditingMixin( + UserLayerWithAnnotationsMixin(UserLayer), +); const [ volumeRenderingDepthSamplesOriginLogScale, volumeRenderingDepthSamplesMaxLogScale, @@ -188,6 +191,18 @@ export class ImageUserLayer extends Base { }; } + _createVoxelRenderLayer( + source: MultiscaleVolumeChunkSource, + ): ImageRenderLayer { + return new ImageRenderLayer(source, { + ...this.displayState, + transform: this.transform, + renderScaleTarget: this.sliceViewRenderScaleTarget, + renderScaleHistogram: this.sliceViewRenderScaleHistogram, + localPosition: this.localPosition, + }); + } + addCoordinateSpace( coordinateSpace: WatchableValueInterface, ) { diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 43a21b05cd..cd576bbdda 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -35,6 +35,7 @@ import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; import { layerDataSourceSpecificationFromJson } from "#src/layer/layer_data_source.js"; import * as json_keys from "#src/layer/segmentation/json_keys.js"; import { registerLayerControls } from "#src/layer/segmentation/layer_controls.js"; +import { UserLayerWithVoxelEditingMixin } from "#src/layer/vox/index.js"; import { MeshLayer, MeshSource, @@ -579,7 +580,9 @@ interface SegmentationActionContext extends LayerActionContext { segmentationToggleSegmentState?: boolean | undefined; } -const Base = UserLayerWithAnnotationsMixin(UserLayer); +const Base = UserLayerWithVoxelEditingMixin( + UserLayerWithAnnotationsMixin(UserLayer), +); export class SegmentationUserLayer extends Base { sliceViewRenderScaleHistogram = new RenderScaleHistogram(); sliceViewRenderScaleTarget = trackableRenderScaleTarget(1); @@ -606,6 +609,18 @@ export class SegmentationUserLayer extends Base { ); }; + _createVoxelRenderLayer( + source: MultiscaleVolumeChunkSource, + ): SegmentationRenderLayer { + return new SegmentationRenderLayer(source, { + ...this.displayState, + transform: this.transform, + renderScaleTarget: this.sliceViewRenderScaleTarget, + renderScaleHistogram: this.sliceViewRenderScaleHistogram, + localPosition: this.localPosition, + }); + } + filterBySegmentLabel = (id: bigint) => { const augmented = augmentSegmentId(this.displayState, id); const { label } = augmented; diff --git a/src/layer/vox/controls.ts b/src/layer/vox/controls.ts index c11c779825..f5c1a9cfb6 100644 --- a/src/layer/vox/controls.ts +++ b/src/layer/vox/controls.ts @@ -1,13 +1,12 @@ import { LayerActionContext } from "#src/layer/index.js"; -import type { VoxUserLayer} from "#src/layer/vox/index.js"; +import type { UserLayerWithVoxelEditing } from "#src/layer/vox/index.js"; import type { LayerControlDefinition } from "#src/widget/layer_control.js"; -import { registerLayerControl } from "#src/widget/layer_control.js"; import { buttonLayerControl } from "#src/widget/layer_control_button.js"; import { checkboxLayerControl } from "#src/widget/layer_control_checkbox.js"; import { enumLayerControl } from "#src/widget/layer_control_enum.js"; import { rangeLayerControl } from "#src/widget/layer_control_range.js"; -export const VOXEL_LAYER_CONTROLS: LayerControlDefinition[] = [ +export const VOXEL_LAYER_CONTROLS: LayerControlDefinition[] = [ { label: "Brush size", toolJson: { type: "vox-brush-size" }, @@ -24,7 +23,7 @@ export const VOXEL_LAYER_CONTROLS: LayerControlDefinition[] = [ { label: "Brush shape", toolJson: { type: "vox-brush-shape" }, - ...enumLayerControl((layer: VoxUserLayer) => layer.voxBrushShape), + ...enumLayerControl((layer: UserLayerWithVoxelEditing) => layer.voxBrushShape), }, { label: "Max fill voxels", @@ -59,9 +58,3 @@ export const VOXEL_LAYER_CONTROLS: LayerControlDefinition[] = [ }), }, ]; - -export function registerLayerControls(layerType: typeof VoxUserLayer) { - for (const control of VOXEL_LAYER_CONTROLS) { - registerLayerControl(layerType, control); - } -} diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 2d171f8a11..c2f4be22c9 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025. + * Copyright 2025 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 @@ -14,190 +14,108 @@ * limitations under the License. */ -import "#src/layer/vox/style.css"; - -import type { CoordinateTransformSpecification } from "#src/coordinate_transform.js"; -import type { DataSourceSpecification } from "#src/datasource/index.js"; -import { - LayerActionContext, - type ManagedUserLayer, - type MouseSelectionState, - registerLayerType, - registerLayerTypeDetector, - UserLayer, -} from "#src/layer/index.js"; +import type { LayerActionContext, MouseSelectionState, UserLayer } from "#src/layer/index.js" import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; -import { registerLayerControls } from "#src/layer/vox/controls.js"; import { VoxToolTab } from "#src/layer/vox/tabs/tools.js"; import type { ChunkTransformParameters } from "#src/render_coordinate_transform.js"; import { getChunkPositionFromCombinedGlobalLocalPositions, getChunkTransformParameters, } from "#src/render_coordinate_transform.js"; -import { trackableRenderScaleTarget } from "#src/render_scale_statistics.js"; import type { SliceViewSourceOptions } from "#src/sliceview/base.js"; -import { DataType } from "#src/sliceview/base.js"; -import { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; +import type { + MultiscaleVolumeChunkSource} from "#src/sliceview/volume/frontend.js"; +import { + InMemoryVolumeChunkSource +} from "#src/sliceview/volume/frontend.js"; +import type { ImageRenderLayer } from "#src/sliceview/volume/image_renderlayer.js"; +import type { SegmentationRenderLayer } from "#src/sliceview/volume/segmentation_renderlayer.js"; +import { BLEND_MODES } from "#src/trackable_blend.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; -import { constantWatchableValue, TrackableValue } from "#src/trackable_value.js"; -import { registerVoxelTools } from "#src/ui/voxel_annotations.js"; -import type { Borrowed } from "#src/util/disposable.js"; +import { + makeDerivedWatchableValue, + TrackableValue, + WatchableValue, +} from "#src/trackable_value.js"; +import type { + UserLayerWithAnnotations, +} from "#src/ui/annotations.js"; +import { RefCounted } from "#src/util/disposable.js"; import { verifyFiniteFloat, verifyInt } from "#src/util/json.js"; -import * as matrix from "#src/util/matrix.js"; import { NullarySignal } from "#src/util/signal.js"; import { TrackableEnum } from "#src/util/trackable_enum.js"; +import type { VoxelEditControllerHost } from "#src/voxel_annotation/edit_controller.js"; import { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; import { LabelsManager } from "#src/voxel_annotation/labels.js"; -import { VoxelAnnotationRenderLayer } from "#src/voxel_annotation/renderlayer.js"; export enum BrushShape { DISK = 0, SPHERE = 1, } -export class VoxUserLayer extends UserLayer { - // While drawing, we keep a reference to the vox render layer to control temporary LOD locks. - voxRenderLayerInstance?: VoxelAnnotationRenderLayer; - // Match Image/Segmentation layers: provide a per-layer cross-section render scale target/histogram. - sliceViewRenderScaleTarget = trackableRenderScaleTarget(1); - static type = "vox"; - static typeAbbreviation = "vox"; - voxEditController?: VoxelEditController; - voxLabelsManager: LabelsManager; - labelsChanged = new NullarySignal(); - - // Draw tool state (trackables for widget integration) - voxBrushRadius = new TrackableValue(3, verifyInt); - voxEraseMode = new TrackableBoolean(false); - voxBrushShape = new TrackableEnum(BrushShape, BrushShape.DISK); - voxFloodMaxVoxels = new TrackableValue(10000, verifyFiniteFloat); - - // Cached transform and voxel buffer to avoid recomputation/allocation on every mouse move +export class VoxelEditingContext + extends RefCounted + implements VoxelEditControllerHost +{ + controller: VoxelEditController; + private cachedChunkTransform: ChunkTransformParameters | undefined; private cachedTransformGeneration: number = -1; private cachedVoxelPosition: Float32Array = new Float32Array(3); - // Draw tab error messaging - voxDrawErrorMessage: string | undefined = undefined; - onDrawMessageChanged?: () => void; - setDrawErrorMessage(message: string | undefined): void { - this.voxDrawErrorMessage = message; - try { - this.onDrawMessageChanged?.(); - } catch { - /* ignore */ - } - } - handleAction(action: string, _context: LayerActionContext): void { - if(!this.voxEditController || !this.voxLabelsManager) - return; - switch (action) { - case "undo": - this.voxEditController.undo(); - break; - case "redo": - this.voxEditController.redo(); - break; - case "new-label": - this.voxLabelsManager.createNewLabel(); - break; - } + constructor( + public hostLayer: UserLayerWithVoxelEditing, + public primarySource: MultiscaleVolumeChunkSource, + public previewSource: InMemoryVolumeChunkSource, + public optimisticRenderLayer: ImageRenderLayer | SegmentationRenderLayer, + ) { + super(); + this.controller = new VoxelEditController(this); + this.registerDisposer(optimisticRenderLayer); + this.registerDisposer(previewSource); } - beginRenderLodLock(lockedIndex: number): void { - if (!Number.isInteger(lockedIndex) || lockedIndex < 0) { - throw new Error( - "beginRenderLodLock: lockedIndex must be a non-negative integer", - ); - } - const rl = this.voxRenderLayerInstance; - if (!rl) { - throw new Error("beginRenderLodLock: render layer is not ready"); - } - // Validate against available levels in current pyramid. - const { multiscaleSource } = rl; - const rank = multiscaleSource.rank; - const options: SliceViewSourceOptions = { - displayRank: rank, - multiscaleToViewTransform: new Float32Array(rank * rank), - modelChannelDimensionIndices: [], - }; - // Create an identity transform matrix. - for (let i = 0; i < rank; ++i) { - options.multiscaleToViewTransform[i * rank + i] = 1; - } - const sources2D = multiscaleSource.getSources(options); - const levels = sources2D?.[0]?.length ?? 0; - if (levels <= 0) { - throw new Error("beginRenderLodLock: multiscale source has no levels"); - } - if (lockedIndex >= levels) { - throw new Error( - `beginRenderLodLock: requested LOD ${lockedIndex} exceeds available levels (${levels})`, - ); - } - rl.setForcedSourceIndexLock(lockedIndex); - console.log("beginRenderLodLock: lockedIndex", lockedIndex); + // VoxelEditControllerHost implementation + get labelsManager(): LabelsManager { + return this.hostLayer.voxLabelsManager!; } - - endRenderLodLock(): void { - const rl = this.voxRenderLayerInstance; - if (!rl) return; - rl.setForcedSourceIndexLock(undefined); - console.log("endRenderLodLock"); + get rpc() { + return this.hostLayer.manager.chunkManager.rpc!; + } + setDrawErrorMessage(message: string | undefined): void { + this.hostLayer.setDrawErrorMessage(message); } - constructor(managedLayer: Borrowed) { - super(managedLayer); - this.tabs.add("vox_tools", { - label: "Draw", - order: 1, - getter: () => new VoxToolTab(this), - }); - this.tabs.default = "vox_tools"; + disposed() { + this.controller.dispose(); + super.disposed(); } getVoxelPositionFromMouse( mouseState: MouseSelectionState, ): Float32Array | undefined { - const renderLayer = this.voxRenderLayerInstance; - if (renderLayer === undefined) { - return undefined; - } - + const renderLayer = this.optimisticRenderLayer; const renderLayerTransform = renderLayer.transform.value; if (renderLayerTransform.error !== undefined) { return undefined; } - // Caching logic for chunk transform parameters const transformGeneration = renderLayer.transform.changed.count; if (this.cachedTransformGeneration !== transformGeneration) { this.cachedChunkTransform = undefined; - const multiscaleSource = renderLayer.multiscaleSource; - const options: SliceViewSourceOptions = { - displayRank: multiscaleSource.rank, - multiscaleToViewTransform: matrix.createIdentity( - Float32Array, - multiscaleSource.rank * multiscaleSource.rank, - ), - modelChannelDimensionIndices: [], - }; - const sources = multiscaleSource.getSources(options); - if (sources.length > 0 && sources[0].length > 0) { - const baseSource = sources[0][0]; - try { - this.cachedChunkTransform = getChunkTransformParameters( - renderLayerTransform, - baseSource.chunkToMultiscaleTransform, - ); - this.cachedTransformGeneration = transformGeneration; - } catch (e) { - this.cachedTransformGeneration = -1; - console.error("Error computing chunk transform parameters:", e); - return undefined; - } + try { + this.cachedChunkTransform = getChunkTransformParameters( + renderLayerTransform, + this.primarySource.getSources( + this.hostLayer.getIdentitySliceViewSourceOptions(), + )[0][0]!.chunkToMultiscaleTransform, + ); + this.cachedTransformGeneration = transformGeneration; + } catch (e) { + this.cachedTransformGeneration = -1; + console.error("Error computing chunk transform parameters:", e); + return undefined; } } @@ -216,7 +134,7 @@ export class VoxUserLayer extends UserLayer { const ok = getChunkPositionFromCombinedGlobalLocalPositions( this.cachedVoxelPosition, mouseState.unsnappedPosition, - this.localPosition.value, + this.hostLayer.localPosition.value, chunkTransform.layerRank, chunkTransform.combinedGlobalLocalToChunkTransform, ); @@ -225,87 +143,196 @@ export class VoxUserLayer extends UserLayer { return this.cachedVoxelPosition; } - getLegacyDataSourceSpecifications( - sourceSpec: string | undefined, - layerSpec: any, - legacyTransform: CoordinateTransformSpecification | undefined, - explicitSpecs: DataSourceSpecification[], - ): DataSourceSpecification[] { - if (Object.prototype.hasOwnProperty.call(layerSpec, "source")) { - // Respect explicit source definitions. - return super.getLegacyDataSourceSpecifications( - sourceSpec, - layerSpec, - legacyTransform, - explicitSpecs, - ); - } - // Default to the special local voxel annotations data source. - return [ - { - url: "TODO", - transform: legacyTransform, - enableDefaultSubsources: true, - subsources: new Map(), - }, - ]; + + get voxRenderLayerInstance(): + | ImageRenderLayer + | SegmentationRenderLayer + | undefined { + // TODO + return undefined; } +} - activateDataSubsources(subsources: Iterable): void { - for (const loadedSubsource of subsources) { - const { volume } = loadedSubsource.subsourceEntry.subsource; - if (volume instanceof MultiscaleVolumeChunkSource) { - if (volume === undefined) { - loadedSubsource.deactivate("No volume source"); - continue; - } - switch (volume.dataType) { - case DataType.UINT32: - this.voxLabelsManager = new LabelsManager( - DataType.UINT32, - this.labelsChanged.dispatch, - ); - break; - case DataType.UINT64: - this.voxLabelsManager = new LabelsManager( - DataType.UINT64, - this.labelsChanged.dispatch, - ); - break; - default: - loadedSubsource.deactivate( - "Data type not compatible with segmentation layer", - ); - continue; +export declare abstract class UserLayerWithVoxelEditing extends UserLayer { + voxLabelsManager?: LabelsManager; + labelsChanged: NullarySignal; + isEditable: WatchableValue; + onDrawMessageChanged?: () => void; + voxDrawErrorMessage: string | undefined; + + voxBrushRadius: TrackableValue; + voxEraseMode: TrackableBoolean; + voxBrushShape: TrackableEnum; + voxFloodMaxVoxels: TrackableValue; + + editingContexts: Map; + + abstract _createVoxelRenderLayer( + source: MultiscaleVolumeChunkSource, + ): ImageRenderLayer | SegmentationRenderLayer; + + initializeVoxelEditingForSubsource(loadedSubsource: LoadedDataSubsource): void; + deinitializeVoxelEditingForSubsource( + loadedSubsource: LoadedDataSubsource, + ): void; + + getIdentitySliceViewSourceOptions(): SliceViewSourceOptions; + setDrawErrorMessage(message: string | undefined): void; +} + +export function UserLayerWithVoxelEditingMixin< + TBase extends { new (...args: any[]): UserLayerWithAnnotations }, +>(Base: TBase) { + abstract class C extends Base implements UserLayerWithVoxelEditing { + editingContexts = new Map(); + voxLabelsManager?: LabelsManager; + labelsChanged = new NullarySignal(); + isEditable = new WatchableValue(false); + + // Brush properties + voxBrushRadius = new TrackableValue(3, verifyInt); + voxEraseMode = new TrackableBoolean(false); + voxBrushShape = new TrackableEnum(BrushShape, BrushShape.DISK); + voxFloodMaxVoxels = new TrackableValue(10000, verifyFiniteFloat); + + + voxDrawErrorMessage: string | undefined = undefined; + onDrawMessageChanged?: () => void; + setDrawErrorMessage(message: string | undefined): void { + this.voxDrawErrorMessage = message; + this.onDrawMessageChanged?.(); + } + + constructor(...args: any[]) { + super(...args); + this.registerDisposer(() => { + for (const context of this.editingContexts.values()) { + context.dispose(); } - this.voxEditController = new VoxelEditController(this, volume); - loadedSubsource.activate(() => { - const renderLayer = new VoxelAnnotationRenderLayer(volume, { - transform: loadedSubsource.getRenderLayerTransform(), - renderScaleTarget: this.sliceViewRenderScaleTarget, - localPosition: this.localPosition, - shaderParameters: constantWatchableValue({}), - }); - - this.voxRenderLayerInstance = renderLayer; - loadedSubsource.addRenderLayer(renderLayer); - }); - continue; + this.editingContexts.clear(); + }); + this.voxBrushRadius.changed.add(this.specificationChanged.dispatch); + this.voxEraseMode.changed.add(this.specificationChanged.dispatch); + this.voxBrushShape.changed.add(this.specificationChanged.dispatch); + this.voxFloodMaxVoxels.changed.add(this.specificationChanged.dispatch); + this.tabs.add("Draw", { + label: "Draw", + order: 10, + getter: () => new VoxToolTab(this), + }); + } + + abstract _createVoxelRenderLayer( + source: MultiscaleVolumeChunkSource, + ): ImageRenderLayer | SegmentationRenderLayer; + + + initializeVoxelEditingForSubsource(loadedSubsource: LoadedDataSubsource) { + if (this.editingContexts.has(loadedSubsource)) return; + + const primarySource = loadedSubsource.subsourceEntry.subsource + .volume as MultiscaleVolumeChunkSource; + const baseSpec = primarySource.getSources( + this.getIdentitySliceViewSourceOptions(), + )[0][0]!.chunkSource.spec; + + if (this.voxLabelsManager === undefined) { + this.voxLabelsManager = new LabelsManager( + baseSpec.dataType, + this.labelsChanged.dispatch, + ); } - // Reject anything else. - loadedSubsource.deactivate("Not compatible with vox layer"); - } + const previewSource = new InMemoryVolumeChunkSource( + this.manager.chunkManager, + baseSpec, + ); + + const optimisticRenderLayer = this._createVoxelRenderLayer( + previewSource as any, + ); + + const originalFragmentMain = optimisticRenderLayer.fragmentMain; + optimisticRenderLayer.fragmentMain = makeDerivedWatchableValue( + (originalShader) => ` +void main() { + if (toRaw(getDataValue()) == 0) { + emitTransparent(); + return; } + ${originalShader} } +`, + [originalFragmentMain], + ); + optimisticRenderLayer.blendMode.value = BLEND_MODES.ADDITIVE; -registerVoxelTools(VoxUserLayer); -registerLayerControls(VoxUserLayer); -registerLayerType(VoxUserLayer); -registerLayerTypeDetector((subsource) => { - // Accept non-local datasources at low priority to avoid interfering with other layers. - if (subsource.local === undefined) { - return { layerConstructor: VoxUserLayer, priority: 0 }; + const context = new VoxelEditingContext( + this, + primarySource, + previewSource, + optimisticRenderLayer, + ); + this.editingContexts.set(loadedSubsource, context); + this.addRenderLayer(optimisticRenderLayer); + + if (!this.isEditable.value) { + this.isEditable.value = true; + this.tabs.add("voxel-editing", { + label: "Draw", + getter: () => new VoxToolTab(this as any), + order: 50, + }); + this.tabs.changed.dispatch(); + } + } + + deinitializeVoxelEditingForSubsource(loadedSubsource: LoadedDataSubsource) { + const context = this.editingContexts.get(loadedSubsource); + if (context) { + this.removeRenderLayer(context.optimisticRenderLayer); + context.dispose(); + this.editingContexts.delete(loadedSubsource); + } + if (this.editingContexts.size === 0 && this.isEditable.value) { + this.isEditable.value = false; + } + } + + getIdentitySliceViewSourceOptions(): SliceViewSourceOptions { + const rank = this.localCoordinateSpace.value.rank; + const displayRank = rank; + const multiscaleToViewTransform = new Float32Array(displayRank * rank); + for (let chunkDim = 0; chunkDim < rank; ++chunkDim) { + for (let displayDim = 0; displayDim < displayRank; ++displayDim) { + multiscaleToViewTransform[displayRank * chunkDim + displayDim] = + chunkDim === displayDim ? 1 : 0; + } + } + return { + displayRank, + multiscaleToViewTransform, + modelChannelDimensionIndices: [], + }; + } + + handleAction(action: string, context: LayerActionContext): void { + super.handleAction(action, context); + const firstContext = this.editingContexts.values().next().value; + if (!firstContext) return; + const controller = firstContext.controller; + switch (action) { + case "undo": + controller.undo(); + break; + case "redo": + controller.redo(); + break; + case "new-label": + this.voxLabelsManager?.createNewLabel(); + break; + } + } } - return undefined; -}); + return C; +} diff --git a/src/layer/vox/tabs/tools.ts b/src/layer/vox/tabs/tools.ts index 3ee09bcd8c..a44670b56e 100644 --- a/src/layer/vox/tabs/tools.ts +++ b/src/layer/vox/tabs/tools.ts @@ -15,7 +15,7 @@ */ import { VOXEL_LAYER_CONTROLS } from "#src/layer/vox/controls.js"; -import type { VoxUserLayer } from "#src/layer/vox/index.js"; +import type { UserLayerWithVoxelEditing } from "#src/layer/vox/index.js"; import { observeWatchable } from "#src/trackable_value.js"; import { makeToolButton } from "#src/ui/tool.js"; import { @@ -45,7 +45,7 @@ function formatUnsignedId(id: bigint, dataType: DataType): string { } export class VoxToolTab extends Tab { - constructor(public layer: VoxUserLayer) { + constructor(public layer: UserLayerWithVoxelEditing) { super(); const { element } = this; element.classList.add("neuroglancer-vox-tools-tab"); @@ -106,7 +106,7 @@ export class VoxToolTab extends Tab { { changed: this.layer.layersChanged, get value() { - return layer.voxEditController; + return layer.editingContexts.values().next().value.controller; }, }, (controller: VoxelEditController | undefined, _parent, context) => { diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index 75f36cff81..6e1699dfc0 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { ChunkState } from "#src/chunk_manager/base.js"; import type { ChunkManager } from "#src/chunk_manager/frontend.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; import type { @@ -32,10 +33,11 @@ import { SliceViewChunkSource, } from "#src/sliceview/frontend.js"; import type { - UncompressedChunkFormatHandler, + UncompressedChunkFormatHandler} from "#src/sliceview/uncompressed_chunk_format.js"; +import { UncompressedVolumeChunk, + ChunkFormat as UncompressedChunkFormat } from "#src/sliceview/uncompressed_chunk_format.js"; -import { ChunkFormat as UncompressedChunkFormat } from "#src/sliceview/uncompressed_chunk_format.js"; import type { VolumeChunkSource as VolumeChunkSourceInterface, VolumeChunkSpecification, @@ -463,7 +465,35 @@ export class VolumeChunkSource } } -// VolumeChunk moved to src/sliceview/volume/chunk.ts +export class InMemoryVolumeChunkSource extends VolumeChunkSource { + constructor(chunkManager: ChunkManager, spec: VolumeChunkSpecification) { + super(chunkManager, { spec }); + } + + initializeCounterpart() {} + + getChunk(chunkGridPosition: Float32Array): UncompressedVolumeChunk { + const key = chunkGridPosition.join(); + let chunk = this.chunks.get(key) as UncompressedVolumeChunk | undefined; + if (chunk === undefined) { + const { spec } = this; + const chunkDataSize = spec.chunkDataSize; + const numElements = chunkDataSize.reduce((a, b) => a * b, 1); + const Ctor = DATA_TYPE_ARRAY_CONSTRUCTOR[spec.dataType]; + const data = new (Ctor as any)(numElements) as TypedArray; + + chunk = new UncompressedVolumeChunk(this, { + chunkGridPosition: chunkGridPosition.slice(), + data: data, + chunkDataSize: chunkDataSize, + }); + + this.addChunk(key, chunk); + chunk.state = ChunkState.GPU_MEMORY; + } + return chunk; + } +} export abstract class MultiscaleVolumeChunkSource extends MultiscaleSliceViewChunkSource< VolumeChunkSource, diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index d63f6d7c52..bb23037a73 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -15,7 +15,10 @@ */ import type { MouseSelectionState } from "#src/layer/index.js"; -import type { VoxUserLayer } from "#src/layer/vox/index.js"; +import type { + UserLayerWithVoxelEditing, + VoxelEditingContext, +} from "#src/layer/vox/index.js"; import { BrushShape } from "#src/layer/vox/index.js"; import type { RenderedDataPanel } from "#src/rendered_data_panel.js"; import { StatusMessage } from "#src/status.js"; @@ -33,11 +36,13 @@ const VOX_TOOL_INPUT_MAP = EventActionMap.fromObject({ ["at:control+mousedown0"]: "paint-voxels", }); -abstract class BaseVoxelTool extends LayerTool { +abstract class BaseVoxelTool extends LayerTool { protected latestMouseState: MouseSelectionState | null = null; protected getPoint(mouseState: MouseSelectionState): Int32Array | undefined { - const vox = this.layer.getVoxelPositionFromMouse?.(mouseState) as + // TODO: maybe getVoxelPositionFromMouse() would best fit in the userLayer + const editController = this.layer.editingContexts.values().next().value.controller; + const vox = editController.getVoxelPositionFromMouse(mouseState) as | Float32Array | undefined; if (!mouseState?.active || !vox) return undefined; @@ -175,7 +180,7 @@ export class VoxelBrushTool extends BaseVoxelTool { this.stopDrawing(); } - constructor(layer: VoxUserLayer) { + constructor(layer: UserLayerWithVoxelEditing) { super(layer, /*toggle=*/ true); } @@ -206,6 +211,8 @@ export class VoxelBrushTool extends BaseVoxelTool { ) { const points = this.linePoints(last, cur); if (points.length > 0) { + if (!this.layer.voxLabelsManager) + throw new Error("Drawing backend not ready yet"); const value = this.layer.voxLabelsManager.getCurrentLabelValue( this.layer.voxEraseMode.value, ); @@ -229,9 +236,8 @@ export class VoxelBrushTool extends BaseVoxelTool { ); } - // Lock render LOD to base level during edits - this.layer.beginRenderLodLock(0); - + if (!this.layer.voxLabelsManager) + throw new Error("Drawing backend not ready yet"); const value = this.layer.voxLabelsManager.getCurrentLabelValue( this.layer.voxEraseMode.value, ); @@ -262,11 +268,6 @@ export class VoxelBrushTool extends BaseVoxelTool { this.mouseDisposer(); this.mouseDisposer = undefined; } - try { - this.layer.endRenderLodLock(); - } catch { - /* ignore */ - } } private paintPoints(points: Float32Array[], value: bigint) { @@ -275,7 +276,6 @@ export class VoxelBrushTool extends BaseVoxelTool { Math.floor(this.layer.voxBrushRadius.value ?? 3), ); const shapeEnum = this.layer.voxBrushShape.value; - const ctrl = this.layer.voxEditController; let basis = undefined as undefined | { u: Float32Array; v: Float32Array }; if (shapeEnum === BrushShape.DISK && this.currentMouseState?.planeNormal) { const n = this.currentMouseState.planeNormal; @@ -290,8 +290,10 @@ export class VoxelBrushTool extends BaseVoxelTool { vec3.normalize(v, v); basis = { u, v }; } - for (const p of points) - ctrl?.paintBrushWithShape(p, radius, value, shapeEnum, basis); + + for (const [_, ed] of this.layer.editingContexts) + for (const p of points) + ed.controller?.paintBrushWithShape(p, radius, value, shapeEnum, basis); } } @@ -332,6 +334,7 @@ export class VoxelFloodFillTool extends BaseVoxelTool { const layer = this.layer; try { layer.setDrawErrorMessage(undefined); + if (!layer.voxLabelsManager) throw new Error("Drawing backend not ready yet"); const value = layer.voxLabelsManager.getCurrentLabelValue( layer.voxEraseMode.value, ); @@ -339,9 +342,8 @@ export class VoxelFloodFillTool extends BaseVoxelTool { if (!Number.isFinite(max) || max <= 0) { throw new Error("Invalid max fill voxels setting"); } - const ctrl = layer.voxEditController; - if (!ctrl) throw new Error("Drawing backend not ready yet"); - ctrl + for (const [_, ed] of this.layer.editingContexts) + ed.controller .floodFillPlane2D( new Float32Array(seed), value, @@ -360,7 +362,7 @@ export class VoxelFloodFillTool extends BaseVoxelTool { return; } - constructor(layer: VoxUserLayer) { + constructor(layer: UserLayerWithVoxelEditing) { super(layer, /*toggle=*/ true); } @@ -373,8 +375,8 @@ export class VoxelFloodFillTool extends BaseVoxelTool { } } -export class AdoptVoxelLabelTool extends LayerTool { - constructor(layer: VoxUserLayer) { +export class AdoptVoxelLabelTool extends LayerTool { + constructor(layer: UserLayerWithVoxelEditing) { super(layer, /*toggle=*/ false); } @@ -388,32 +390,33 @@ export class AdoptVoxelLabelTool extends LayerTool { activate(_activation: ToolActivation): void { if (!this.mouseState?.active) return; - const layer = this.layer as VoxUserLayer; - const pos = layer.getVoxelPositionFromMouse?.(this.mouseState); - if (!pos || pos.length < 3) { + const voxelEditingContext = this.layer.editingContexts.values().next().value as VoxelEditingContext; + if (!voxelEditingContext) { StatusMessage.showTemporaryMessage( - "Cannot pick label: position is not valid.", + "Cannot pick label: layer is not ready.", 3000, ); return; } - const editController = layer.voxEditController; - if (!editController) { + const pos = voxelEditingContext.getVoxelPositionFromMouse(this.mouseState); + + if (!pos || pos.length < 3) { StatusMessage.showTemporaryMessage( - "Cannot pick label: layer is not ready.", + "Cannot pick label: position is not valid.", 3000, ); return; } - const renderLayer = layer.voxRenderLayerInstance; + const renderLayer = voxelEditingContext.voxRenderLayerInstance; if (!renderLayer) { StatusMessage.showTemporaryMessage("Render layer not available.", 3000); return; } + const visibleSources = renderLayer.visibleSourcesList; if (visibleSources.length === 0) { StatusMessage.showTemporaryMessage( @@ -424,7 +427,7 @@ export class AdoptVoxelLabelTool extends LayerTool { } const source = visibleSources[0].source; - const channelAccess = editController.singleChannelAccess; + const channelAccess = voxelEditingContext.controller.singleChannelAccess; StatusMessage.forPromise( source @@ -443,7 +446,10 @@ export class AdoptVoxelLabelTool extends LayerTool { ); return; } - layer.voxLabelsManager.addLabel(label); + if (!this.layer.voxLabelsManager) { + throw new Error("Drawing backend not ready yet"); + } + this.layer.voxLabelsManager.addLabel(label); StatusMessage.showTemporaryMessage(`Adopted label: ${label}`, 3000); }), { @@ -456,7 +462,7 @@ export class AdoptVoxelLabelTool extends LayerTool { } export function registerVoxelTools(LayerCtor: any) { - registerTool(LayerCtor, BRUSH_TOOL_ID, (layer: VoxUserLayer) => new VoxelBrushTool(layer)); - registerTool(LayerCtor, FLOODFILL_TOOL_ID, (layer: VoxUserLayer) => new VoxelFloodFillTool(layer)); - registerTool(LayerCtor, ADOPT_VOXEL_LABEL_TOOL_ID, (layer: VoxUserLayer) => new AdoptVoxelLabelTool(layer)); + registerTool(LayerCtor, BRUSH_TOOL_ID, (layer: UserLayerWithVoxelEditing) => new VoxelBrushTool(layer)); + registerTool(LayerCtor, FLOODFILL_TOOL_ID, (layer: UserLayerWithVoxelEditing) => new VoxelFloodFillTool(layer)); + registerTool(LayerCtor, ADOPT_VOXEL_LABEL_TOOL_ID, (layer: UserLayerWithVoxelEditing) => new AdoptVoxelLabelTool(layer)); } diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 549b5e2a93..0b093b5942 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -1,4 +1,6 @@ +## TODOs + ### priority - url completion for the ssa+https source @@ -12,3 +14,57 @@ ### questionable - design a dataset creation feature - adapt the brush size to the zoom level linearly + + +## Merging vox layer into seg and img layers + +The proposed architecture integrates voxel editing directly into the existing Image and Segmentation layers by leveraging their inherent capabilities, rather than introducing a separate, simplified render layer. The core of the design is a new UserLayerWithVoxelEditingMixin which equips a host UserLayer with an editing controller and an associated in-memory VolumeChunkSource for optimistic previews. When a user paints, the edits are applied locally to this in-memory source. A second instance of the layer's primary, feature-rich RenderLayer class is then used to draw these edits as an overlay. This ensures the live preview is rendered with the exact same user-defined shaders and settings as the base data for perfect visual fidelity, while also elegantly handling the performance issue of editing compressed chunks by operating on an uncompressed in-memory source. This architecture reuses existing components, simplifies the overall codebase by eliminating the need for a separate VoxelAnnotationRenderLayer, and cleanly separates the concerns of displaying committed data versus previewing transient edits. + +```mermaid +sequenceDiagram +participant User +participant Tool as VoxelBrushTool +participant ControllerFE as VoxelEditController (FE) +participant EditSourceFE as OverlayChunkSource (FE) +participant BaseSourceFE as VolumeChunkSource (FE) +participant ControllerBE as VoxelEditController (BE) +participant BaseSourceBE as VolumeChunkSource (BE) + + User->>Tool: Mouse Down/Drag + Tool->>ControllerFE: paintBrushWithShape(mouse, ...) + ControllerFE->>ControllerFE: Calculates affected voxels and chunks + + ControllerFE->>EditSourceFE: applyLocalEdits(chunkKeys, ...) + activate EditSourceFE + EditSourceFE->>EditSourceFE: Modifies its own in-memory chunk data + note over EditSourceFE: This chunk's texture is re-uploaded to the GPU + deactivate EditSourceFE + + ControllerFE->>ControllerBE: commitEdits(edits, ...) [RPC] + + activate ControllerBE + ControllerBE->>ControllerBE: Debounces and batches edits + ControllerBE->>BaseSourceBE: applyEdits(chunkKeys, ...) + activate BaseSourceBE + BaseSourceBE-->>ControllerBE: Returns VoxelChange (for undo stack) + deactivate BaseSourceBE + ControllerBE->>ControllerFE: callChunkReload(chunkKeys) [RPC] + activate ControllerFE + ControllerFE->>BaseSourceFE: invalidateChunks(chunkKeys) + note over BaseSourceFE: BaseSourceFE re-fetches chunk with the now-permanent edit. + ControllerFE->>EditSourceFE: clearOptimisticChunk(chunkKeys) + deactivate ControllerFE + + ControllerBE->>ControllerBE: Pushes change to Undo Stack & enqueues for downsampling + deactivate ControllerBE + + loop Downsampling & Reload Cascade + ControllerBE->>ControllerBE: downsampleStep(chunkKeys) + ControllerBE->>ControllerFE: callChunkReload(chunkKeys) [RPC] + activate ControllerFE + ControllerFE->>BaseSourceFE: invalidateChunks(chunkKeys) + note over BaseSourceFE: BaseSourceFE re-fetches chunk with the now-permanent edit. + ControllerFE->>EditSourceFE: clearOptimisticChunk(chunkKeys) + deactivate ControllerFE + end +``` diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index b1a330f27b..81c9788e00 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -14,12 +14,12 @@ * limitations under the License. */ -import type { VoxUserLayer } from "#src/layer/vox/index.js"; import { BrushShape } from "#src/layer/vox/index.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; import type { VolumeChunkSource, MultiscaleVolumeChunkSource, + InMemoryVolumeChunkSource, } from "#src/sliceview/volume/frontend.js"; import { StatusMessage } from "#src/status.js"; import { WatchableValue } from "#src/trackable_value.js"; @@ -36,23 +36,33 @@ import { makeVoxChunkKey, parseVoxChunkKey, } from "#src/voxel_annotation/base.js"; +import type { LabelsManager } from "#src/voxel_annotation/labels.js"; +import type { + RPC} from "#src/worker_rpc.js"; import { registerRPC, registerSharedObjectOwner, SharedObject, } from "#src/worker_rpc.js"; +export interface VoxelEditControllerHost { + primarySource: MultiscaleVolumeChunkSource; + previewSource?: InMemoryVolumeChunkSource; + labelsManager: LabelsManager; + rpc: RPC; + setDrawErrorMessage(message: string | undefined): void; +} + @registerSharedObjectOwner(VOX_EDIT_BACKEND_RPC_ID) export class VoxelEditController extends SharedObject { public undoCount = new WatchableValue(0); public redoCount = new WatchableValue(0); constructor( - private layer: VoxUserLayer, - private multiscale: MultiscaleVolumeChunkSource, + private host: VoxelEditControllerHost ) { super(); - const rpc = (this.multiscale as any)?.chunkManager?.rpc; + const rpc = this.host.rpc; if (!rpc) { throw new Error( "VoxelEditController: Missing RPC from multiscale chunk manager.", @@ -60,7 +70,7 @@ export class VoxelEditController extends SharedObject { } // Get all sources for all scales and orientations - const sourcesByScale = this.multiscale.getSources( + const sourcesByScale = this.host.primarySource.getSources( this.getIdentitySliceViewSourceOptions(), ); const sources = sourcesByScale[0]; @@ -109,7 +119,7 @@ export class VoxelEditController extends SharedObject { }; private getIdentitySliceViewSourceOptions() { - const rank = (this.multiscale as any).rank as number | undefined; + const rank = this.host.primarySource.rank as number | undefined; if (!Number.isInteger(rank) || (rank as number) <= 0) { throw new Error("VoxelEditController: Invalid multiscale rank."); } @@ -131,7 +141,7 @@ export class VoxelEditController extends SharedObject { } getSourceForLOD(lodIndex: number): VolumeChunkSource { - const sourcesByScale = this.multiscale.getSources( + const sourcesByScale = this.host.primarySource.getSources( this.getIdentitySliceViewSourceOptions(), ); // Assuming a single orientation, which is correct for this use case. @@ -171,7 +181,12 @@ export class VoxelEditController extends SharedObject { // For V1 we use the minimum LOD (index 0) const voxelSize = 1; const sourceIndex = 0; - const source = this.getSourceForLOD(sourceIndex); + const source = this.host.previewSource; + if (!source) { + throw new Error( + "paintBrushWithShape: Missing preview source", + ); + } // Convert center and radius to the level’s voxel grid. const cx = Math.round((centerCanonical[0] ?? 0) / voxelSize); @@ -496,13 +511,19 @@ export class VoxelEditController extends SharedObject { } } + const previewSource = this.host.previewSource; + if (!previewSource) { + throw new Error( + "paintBrushWithShape: Missing preview source", + ); + } const editsByVoxKey = new Map< string, { indices: number[]; value: bigint } >(); for (const voxelCoord of voxelsToFill) { const { chunkGridPosition, positionWithinChunk } = - source.computeChunkIndices(voxelCoord); + previewSource.computeChunkIndices(voxelCoord); const chunkKey = chunkGridPosition.join(); const voxKey = makeVoxChunkKey(chunkKey, sourceIndex); let entry = editsByVoxKey.get(voxKey); @@ -510,7 +531,7 @@ export class VoxelEditController extends SharedObject { entry = { indices: [], value: fillValue }; editsByVoxKey.set(voxKey, entry); } - const { chunkDataSize } = source.spec; + const { chunkDataSize } = previewSource.spec; const index = (positionWithinChunk[2] * chunkDataSize[1] + positionWithinChunk[1]) * chunkDataSize[0] + @@ -523,7 +544,7 @@ export class VoxelEditController extends SharedObject { if (!parsed) continue; localEdits.set(parsed.chunkKey, edit); } - source.applyLocalEdits(localEdits); + previewSource.applyLocalEdits(localEdits); const backendEdits: { key: string; indices: number[]; value: bigint }[] = []; for (const [voxKey, edit] of editsByVoxKey.entries()) { @@ -541,7 +562,7 @@ export class VoxelEditController extends SharedObject { callChunkReload(voxChunkKeys: string[]) { if (!Array.isArray(voxChunkKeys) || voxChunkKeys.length === 0) return; // This assumes the multiscale source has a single orientation. - const sourcesByScale = (this.multiscale as any).getSources( + const sourcesByScale = this.host.primarySource.getSources( this.getIdentitySliceViewSourceOptions(), ); const sources = sourcesByScale && sourcesByScale[0]; @@ -576,7 +597,7 @@ export class VoxelEditController extends SharedObject { try { this.callChunkReload(voxChunkKeys); } finally { - this.layer.setDrawErrorMessage(message); + this.host.setDrawErrorMessage(message); } } From b46224e1253167ebbbf94345c3e53f75b47c37b2 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:45 +0100 Subject: [PATCH 089/251] feat: took care of every remaining ts errors --- src/layer/image/index.ts | 90 +++++++++++++++++---------------- src/layer/segmentation/index.ts | 4 +- src/layer/vox/index.ts | 44 +++++----------- 3 files changed, 64 insertions(+), 74 deletions(-) diff --git a/src/layer/image/index.ts b/src/layer/image/index.ts index e23fc06274..e1fd3039f0 100644 --- a/src/layer/image/index.ts +++ b/src/layer/image/index.ts @@ -21,88 +21,66 @@ import { CoordinateSpaceCombiner, isChannelDimension, isLocalDimension, - TrackableCoordinateSpace, + TrackableCoordinateSpace } from "#src/coordinate_transform.js"; -import type { - ManagedUserLayer, - UserLayerSelectionState, -} from "#src/layer/index.js"; -import { - registerLayerType, - registerLayerTypeDetector, - registerVolumeLayerType, - UserLayer, -} from "#src/layer/index.js"; +import type { ManagedUserLayer, UserLayerSelectionState } from "#src/layer/index.js"; +import { registerLayerType, registerLayerTypeDetector, registerVolumeLayerType, UserLayer } from "#src/layer/index.js"; import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; import { UserLayerWithVoxelEditingMixin } from "#src/layer/vox/index.js"; import { Overlay } from "#src/overlay.js"; +import type { RenderLayerTransformOrError } from "#src/render_coordinate_transform.js"; import { getChannelSpace } from "#src/render_coordinate_transform.js"; -import { - RenderScaleHistogram, - trackableRenderScaleTarget, -} from "#src/render_scale_statistics.js"; +import { RenderScaleHistogram, trackableRenderScaleTarget } from "#src/render_scale_statistics.js"; import { DataType, VolumeType } from "#src/sliceview/volume/base.js"; import { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; import { defineImageLayerShader, getTrackableFragmentMain, - ImageRenderLayer, + ImageRenderLayer } from "#src/sliceview/volume/image_renderlayer.js"; import { trackableAlphaValue } from "#src/trackable_alpha.js"; -import { trackableBlendModeValue } from "#src/trackable_blend.js"; +import { BLEND_MODES, trackableBlendModeValue } from "#src/trackable_blend.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; import { trackableFiniteFloat } from "#src/trackable_finite_float.js"; import type { WatchableValueInterface } from "#src/trackable_value.js"; import { makeCachedDerivedWatchableValue, makeCachedLazyDerivedWatchableValue, + makeDerivedWatchableValue, registerNested, - WatchableValue, + TrackableValue, + WatchableValue } from "#src/trackable_value.js"; import { UserLayerWithAnnotationsMixin } from "#src/ui/annotations.js"; import { setClipboard } from "#src/util/clipboard.js"; import type { Borrowed } from "#src/util/disposable.js"; import { makeValueOrError } from "#src/util/error.js"; -import { verifyOptionalObjectProperty } from "#src/util/json.js"; +import { verifyFloat01, verifyOptionalObjectProperty } from "#src/util/json.js"; +import { TrackableEnum } from "#src/util/trackable_enum.js"; import { trackableShaderModeValue, - VolumeRenderingModes, + VolumeRenderingModes } from "#src/volume_rendering/trackable_volume_rendering_mode.js"; import { getVolumeRenderingDepthSamplesBoundsLogScale, VOLUME_RENDERING_DEPTH_SAMPLES_DEFAULT_VALUE, - VolumeRenderingRenderLayer, + VolumeRenderingRenderLayer } from "#src/volume_rendering/volume_render_layer.js"; import type { ParameterizedShaderGetterResult } from "#src/webgl/dynamic_shader.js"; import { makeWatchableShaderError } from "#src/webgl/dynamic_shader.js"; import type { ShaderControlsBuilderState } from "#src/webgl/shader_ui_controls.js"; -import { - setControlsInShader, - ShaderControlState, -} from "#src/webgl/shader_ui_controls.js"; +import { setControlsInShader, ShaderControlState } from "#src/webgl/shader_ui_controls.js"; import { ChannelDimensionsWidget } from "#src/widget/channel_dimensions_widget.js"; import { makeCopyButton } from "#src/widget/copy_button.js"; import type { DependentViewContext } from "#src/widget/dependent_view_widget.js"; import type { LayerControlDefinition } from "#src/widget/layer_control.js"; -import { - addLayerControlToOptionsTab, - registerLayerControl, -} from "#src/widget/layer_control.js"; +import { addLayerControlToOptionsTab, registerLayerControl } from "#src/widget/layer_control.js"; import { enumLayerControl } from "#src/widget/layer_control_enum.js"; import { rangeLayerControl } from "#src/widget/layer_control_range.js"; -import { - renderScaleLayerControl, - VolumeRenderingRenderScaleWidget, -} from "#src/widget/render_scale_widget.js"; -import { - makeShaderCodeWidgetTopRow, - ShaderCodeWidget, -} from "#src/widget/shader_code_widget.js"; +import { renderScaleLayerControl, VolumeRenderingRenderScaleWidget } from "#src/widget/render_scale_widget.js"; +import { makeShaderCodeWidgetTopRow, ShaderCodeWidget } from "#src/widget/shader_code_widget.js"; import type { LegendShaderOptions } from "#src/widget/shader_controls.js"; -import { - registerLayerShaderControlsTool, - ShaderControls, -} from "#src/widget/shader_controls.js"; +import { registerLayerShaderControlsTool, ShaderControls } from "#src/widget/shader_controls.js"; import { Tab } from "#src/widget/tab_view.js"; const OPACITY_JSON_KEY = "opacity"; @@ -193,13 +171,39 @@ export class ImageUserLayer extends Base { _createVoxelRenderLayer( source: MultiscaleVolumeChunkSource, + transform: WatchableValueInterface, ): ImageRenderLayer { + const wrappedFragmentMain = makeDerivedWatchableValue( + (originalShader: string) => ` +void main() { + if (toRaw(getDataValue()) == 0) { + emitTransparent(); + return; + } + ${originalShader} +} +`, + this.fragmentMain, + ); + this.registerDisposer(wrappedFragmentMain); + + const shaderControlState = new ShaderControlState( + wrappedFragmentMain, + this.shaderControlState.dataContext, + this.channelCoordinateSpaceCombiner, + ); + this.registerDisposer(shaderControlState); + return new ImageRenderLayer(source, { - ...this.displayState, - transform: this.transform, + opacity: new TrackableValue(1.0, verifyFloat01), + blendMode: new TrackableEnum(BLEND_MODES, BLEND_MODES.ADDITIVE), + shaderControlState: shaderControlState, + shaderError: this.shaderError, + transform: transform, renderScaleTarget: this.sliceViewRenderScaleTarget, renderScaleHistogram: this.sliceViewRenderScaleHistogram, localPosition: this.localPosition, + channelCoordinateSpace: this.channelCoordinateSpace, }); } diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index cd576bbdda..136886cfdf 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -42,6 +42,7 @@ import { MultiscaleMeshLayer, MultiscaleMeshSource, } from "#src/mesh/frontend.js"; +import type { RenderLayerTransformOrError } from "#src/render_coordinate_transform.js"; import { RenderScaleHistogram, trackableRenderScaleTarget, @@ -611,10 +612,11 @@ export class SegmentationUserLayer extends Base { _createVoxelRenderLayer( source: MultiscaleVolumeChunkSource, + transform: WatchableValueInterface, ): SegmentationRenderLayer { return new SegmentationRenderLayer(source, { ...this.displayState, - transform: this.transform, + transform: transform, renderScaleTarget: this.sliceViewRenderScaleTarget, renderScaleHistogram: this.sliceViewRenderScaleHistogram, localPosition: this.localPosition, diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index c2f4be22c9..9bbea56f05 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -17,7 +17,10 @@ import type { LayerActionContext, MouseSelectionState, UserLayer } from "#src/layer/index.js" import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; import { VoxToolTab } from "#src/layer/vox/tabs/tools.js"; -import type { ChunkTransformParameters } from "#src/render_coordinate_transform.js"; +import type { + ChunkTransformParameters, + RenderLayerTransformOrError, +} from "#src/render_coordinate_transform.js"; import { getChunkPositionFromCombinedGlobalLocalPositions, getChunkTransformParameters, @@ -30,12 +33,12 @@ import { } from "#src/sliceview/volume/frontend.js"; import type { ImageRenderLayer } from "#src/sliceview/volume/image_renderlayer.js"; import type { SegmentationRenderLayer } from "#src/sliceview/volume/segmentation_renderlayer.js"; -import { BLEND_MODES } from "#src/trackable_blend.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; +import type { + WatchableValueInterface} from "#src/trackable_value.js"; import { - makeDerivedWatchableValue, TrackableValue, - WatchableValue, + WatchableValue } from "#src/trackable_value.js"; import type { UserLayerWithAnnotations, @@ -169,6 +172,7 @@ export declare abstract class UserLayerWithVoxelEditing extends UserLayer { abstract _createVoxelRenderLayer( source: MultiscaleVolumeChunkSource, + transform: WatchableValueInterface, ): ImageRenderLayer | SegmentationRenderLayer; initializeVoxelEditingForSubsource(loadedSubsource: LoadedDataSubsource): void; @@ -217,13 +221,14 @@ export function UserLayerWithVoxelEditingMixin< this.voxFloodMaxVoxels.changed.add(this.specificationChanged.dispatch); this.tabs.add("Draw", { label: "Draw", - order: 10, + order: 20, getter: () => new VoxToolTab(this), }); } abstract _createVoxelRenderLayer( source: MultiscaleVolumeChunkSource, + transform: WatchableValueInterface, ): ImageRenderLayer | SegmentationRenderLayer; @@ -248,25 +253,13 @@ export function UserLayerWithVoxelEditingMixin< baseSpec, ); + const transform = loadedSubsource.getRenderLayerTransform(); + const optimisticRenderLayer = this._createVoxelRenderLayer( previewSource as any, + transform, ); - const originalFragmentMain = optimisticRenderLayer.fragmentMain; - optimisticRenderLayer.fragmentMain = makeDerivedWatchableValue( - (originalShader) => ` -void main() { - if (toRaw(getDataValue()) == 0) { - emitTransparent(); - return; - } - ${originalShader} -} -`, - [originalFragmentMain], - ); - optimisticRenderLayer.blendMode.value = BLEND_MODES.ADDITIVE; - const context = new VoxelEditingContext( this, primarySource, @@ -275,18 +268,9 @@ void main() { ); this.editingContexts.set(loadedSubsource, context); this.addRenderLayer(optimisticRenderLayer); - - if (!this.isEditable.value) { - this.isEditable.value = true; - this.tabs.add("voxel-editing", { - label: "Draw", - getter: () => new VoxToolTab(this as any), - order: 50, - }); - this.tabs.changed.dispatch(); - } } + deinitializeVoxelEditingForSubsource(loadedSubsource: LoadedDataSubsource) { const context = this.editingContexts.get(loadedSubsource); if (context) { From a8feb091c3a6fab15b399097d88c8b1973c552a7 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:45 +0100 Subject: [PATCH 090/251] feat: add writable subsource support and enable voxel editing - Introduce `writable` property to subsource specifications. - Enable voxel editing initialization and teardown for writable subsources. - Update UI to reflect writable state with checkbox and label. - Refactor related code, improving modularity and maintainability. --- src/datasource/index.ts | 2 + src/datasource/zarr/frontend.ts | 2 +- src/layer/image/index.ts | 8 ++++ src/layer/layer_data_source.ts | 5 +++ src/layer/segmentation/index.ts | 16 ++++++-- src/ui/layer_data_sources_tab.css | 4 ++ src/ui/layer_data_sources_tab.ts | 61 ++++++++++++++++++++++++------- 7 files changed, 80 insertions(+), 18 deletions(-) diff --git a/src/datasource/index.ts b/src/datasource/index.ts index 38d2332bc5..a5703c2852 100644 --- a/src/datasource/index.ts +++ b/src/datasource/index.ts @@ -132,6 +132,7 @@ export interface DataSubsource { singleMesh?: SingleMeshSource; segmentPropertyMap?: SegmentPropertyMap; segmentationGraph?: SegmentationGraphSource; + isPotentiallyWritable?: boolean; } export interface CompleteUrlOptionsBase extends Partial { @@ -216,6 +217,7 @@ export interface DataSourceWithRedirectInfo extends DataSource { export interface DataSubsourceSpecification { enabled?: boolean; + writable?: boolean; } export interface DataSourceSpecification { diff --git a/src/datasource/zarr/frontend.ts b/src/datasource/zarr/frontend.ts index c501eebec5..1c034b4c4e 100644 --- a/src/datasource/zarr/frontend.ts +++ b/src/datasource/zarr/frontend.ts @@ -556,7 +556,7 @@ export class ZarrDataSource implements KvStoreBasedDataSourceProvider { id: "default", default: true, url: undefined, - subsource: { volume }, + subsource: { volume, isPotentiallyWritable: true }, }, { id: "bounds", diff --git a/src/layer/image/index.ts b/src/layer/image/index.ts index e1fd3039f0..e43b629419 100644 --- a/src/layer/image/index.ts +++ b/src/layer/image/index.ts @@ -309,6 +309,14 @@ void main() { }, this.volumeRenderingMode), ); this.shaderError.changed.dispatch(); + context.registerDisposer(registerNested((context, isWritable) => { + if (isWritable) { + this.initializeVoxelEditingForSubsource(loadedSubsource); + context.registerDisposer(() => { + this.deinitializeVoxelEditingForSubsource(loadedSubsource); + }); + } + }, loadedSubsource.writable)); }); } this.dataType.value = dataType; diff --git a/src/layer/layer_data_source.ts b/src/layer/layer_data_source.ts index 63d07a7746..cfc7a20ddc 100644 --- a/src/layer/layer_data_source.ts +++ b/src/layer/layer_data_source.ts @@ -36,6 +36,7 @@ import { makeEmptyDataSourceSpecification } from "#src/datasource/index.js"; import type { UserLayer } from "#src/layer/index.js"; import { getWatchableRenderLayerTransform } from "#src/render_coordinate_transform.js"; import type { RenderLayer } from "#src/renderlayer.js"; +import { TrackableBoolean } from "#src/trackable_boolean.js"; import type { WatchableValueInterface } from "#src/trackable_value.js"; import { arraysEqual } from "#src/util/array.js"; import type { Borrowed, Owned } from "#src/util/disposable.js"; @@ -63,6 +64,7 @@ export function parseDataSubsourceSpecificationFromJson( verifyObject(json); return { enabled: verifyOptionalObjectProperty(json, "enabled", verifyBoolean), + writable: verifyOptionalObjectProperty(json, "writable", verifyBoolean), }; } @@ -146,6 +148,7 @@ export class LoadedDataSubsource { subsourceToModelSubspaceTransform: Float32Array; modelSubspaceDimensionIndices: number[]; enabled: boolean; + writable: TrackableBoolean; activated: RefCounted | undefined = undefined; guardValues: any[] = []; messages = new MessageList(); @@ -178,6 +181,8 @@ export class LoadedDataSubsource { ), } = subsourceEntry; this.enabled = enabled; + this.writable = new TrackableBoolean(subsourceSpec?.writable ?? false, false); + this.writable.changed.add(loadedDataSource.layer.dataSourcesChanged.dispatch); this.subsourceToModelSubspaceTransform = subsourceToModelSubspaceTransform; this.modelSubspaceDimensionIndices = modelSubspaceDimensionIndices; this.isActiveChanged.add( diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 136886cfdf..1d3ef3b799 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -89,7 +89,8 @@ import { SegmentationRenderLayer } from "#src/sliceview/volume/segmentation_rend import { StatusMessage } from "#src/status.js"; import { trackableAlphaValue } from "#src/trackable_alpha.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; -import type { +import { + registerNested, TrackableValueInterface, WatchableValueInterface, } from "#src/trackable_value.js"; @@ -792,7 +793,7 @@ export class SegmentationUserLayer extends Base { } hasVolume = true; loadedSubsource.activate( - () => + (context) => { loadedSubsource.addRenderLayer( new SegmentationRenderLayer(volume, { ...this.displayState, @@ -801,7 +802,16 @@ export class SegmentationUserLayer extends Base { renderScaleHistogram: this.sliceViewRenderScaleHistogram, localPosition: this.localPosition, }), - ), + ) + context.registerDisposer(registerNested((context, isWritable) => { + if (isWritable) { + this.initializeVoxelEditingForSubsource(loadedSubsource); + context.registerDisposer(() => { + this.deinitializeVoxelEditingForSubsource(loadedSubsource); + }); + } + }, loadedSubsource.writable)); + }, this.displayState.segmentationGroupState.value, ); } else if (mesh !== undefined) { diff --git a/src/ui/layer_data_sources_tab.css b/src/ui/layer_data_sources_tab.css index a18860239f..e4df9dcaa3 100644 --- a/src/ui/layer_data_sources_tab.css +++ b/src/ui/layer_data_sources_tab.css @@ -19,6 +19,10 @@ flex-direction: column; } +.neuroglancer-layer-data-source-writable-label { + margin-right: 5px; +} + .neuroglancer-layer-data-sources-container { overflow-y: auto; display: flex; diff --git a/src/ui/layer_data_sources_tab.ts b/src/ui/layer_data_sources_tab.ts index 7b53214290..143ef2eaae 100644 --- a/src/ui/layer_data_sources_tab.ts +++ b/src/ui/layer_data_sources_tab.ts @@ -37,9 +37,15 @@ import { createImageLayerAsMultiChannel } from "#src/layer/multi_channel_setup.j import { MeshSource, MultiscaleMeshSource } from "#src/mesh/frontend.js"; import { SkeletonSource } from "#src/skeleton/frontend.js"; import { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; -import { TrackableBooleanCheckbox } from "#src/trackable_boolean.js"; -import type { WatchableValueInterface } from "#src/trackable_value.js"; -import { WatchableValue } from "#src/trackable_value.js"; +import { + ElementVisibilityFromTrackableBoolean, + TrackableBooleanCheckbox, +} from "#src/trackable_boolean.js"; +import type { + WatchableValueInterface} from "#src/trackable_value.js"; +import { + makeCachedDerivedWatchableValue +, WatchableValue } from "#src/trackable_value.js"; import type { DebouncedFunction } from "#src/util/animation_frame_debounce.js"; import { animationFrameDebounce } from "#src/util/animation_frame_debounce.js"; import { DataType } from "#src/util/data_type.js"; @@ -193,22 +199,49 @@ export class DataSourceSubsourceView extends RefCounted { this.registerDisposer( loadedSource.enabledSubsourcesChanged.add(updateActiveAttribute), ); + const enabledState: WatchableValueInterface & { set value(v: boolean) } = { + get value() { + return loadedSubsource.enabled; + }, + set value(value: boolean) { + if (loadedSubsource.enabled === value) return; + loadedSubsource.enabled = value; + loadedSource.enableDefaultSubsources = false; + loadedSource.enabledSubsourcesChanged.dispatch(); + }, + changed: loadedSource.enabledSubsourcesChanged, + }; const enabledCheckbox = this.registerDisposer( - new TrackableBooleanCheckbox({ - get value() { - return loadedSubsource.enabled; - }, - set value(value: boolean) { - loadedSubsource.enabled = value; - loadedSource.enableDefaultSubsources = false; - loadedSource.enabledSubsourcesChanged.dispatch(); - }, - changed: loadedSource.enabledSubsourcesChanged, - }), + new TrackableBooleanCheckbox(enabledState), ); sourceInfoLine.classList.add("neuroglancer-layer-data-sources-info-line"); sourceInfoLine.appendChild(enabledCheckbox.element); + if (loadedSubsource.subsourceEntry.subsource.volume instanceof MultiscaleVolumeChunkSource) { + const writableCheckbox = this.registerDisposer( + new TrackableBooleanCheckbox(loadedSubsource.writable), + ); + writableCheckbox.element.title = "Enable voxel editing for this source"; + const writableLabel = document.createElement("label"); + writableLabel.className = "neuroglancer-layer-data-source-writable-label"; + writableLabel.appendChild(writableCheckbox.element); + writableLabel.appendChild(document.createTextNode("[Writable?]")); + + + this.registerDisposer(new ElementVisibilityFromTrackableBoolean( + makeCachedDerivedWatchableValue( + (enabled, isPotentiallyWritable) => enabled && isPotentiallyWritable, + [ + enabledState, + new WatchableValue(loadedSubsource.subsourceEntry.subsource.isPotentiallyWritable ?? false), + ], + ), + writableLabel, + )); + + sourceInfoLine.appendChild(writableLabel); + } + const sourceId = document.createElement("span"); sourceId.classList.add("neuroglancer-layer-data-sources-source-id"); const { id } = loadedSubsource.subsourceEntry; From 595bba04ede7fe935e065ce9c4af2100c03072ff Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:45 +0100 Subject: [PATCH 091/251] feat: add in-memory voxel editing capabilities - Introduce `InMemoryVolumeChunkSource` for frontend-only editing. - Register shared backend and frontend chunk sources for in-memory operations. - Extend voxel editing initialization with `VolumeType` support. - Enable dynamic voxel editing tools and controls for image and segmentation layers. - Refactor chunk source logic for improved modularity and safeguard handling. --- src/layer/image/index.ts | 6 ++- src/layer/segmentation/index.ts | 14 +++--- src/layer/vox/controls.ts | 13 ++++- src/layer/vox/index.ts | 24 ++++++++-- src/sliceview/volume/backend.ts | 27 ++++++++++- src/sliceview/volume/base.ts | 1 + src/sliceview/volume/frontend.ts | 81 ++++++++++++++++++++++++++++---- 7 files changed, 143 insertions(+), 23 deletions(-) diff --git a/src/layer/image/index.ts b/src/layer/image/index.ts index e43b629419..8060cb3d6e 100644 --- a/src/layer/image/index.ts +++ b/src/layer/image/index.ts @@ -26,6 +26,7 @@ import { import type { ManagedUserLayer, UserLayerSelectionState } from "#src/layer/index.js"; import { registerLayerType, registerLayerTypeDetector, registerVolumeLayerType, UserLayer } from "#src/layer/index.js"; import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; +import { registerVoxelLayerControls } from "#src/layer/vox/controls.js"; import { UserLayerWithVoxelEditingMixin } from "#src/layer/vox/index.js"; import { Overlay } from "#src/overlay.js"; import type { RenderLayerTransformOrError } from "#src/render_coordinate_transform.js"; @@ -52,6 +53,7 @@ import { WatchableValue } from "#src/trackable_value.js"; import { UserLayerWithAnnotationsMixin } from "#src/ui/annotations.js"; +import { registerVoxelTools } from "#src/ui/voxel_annotations.js"; import { setClipboard } from "#src/util/clipboard.js"; import type { Borrowed } from "#src/util/disposable.js"; import { makeValueOrError } from "#src/util/error.js"; @@ -311,7 +313,7 @@ void main() { this.shaderError.changed.dispatch(); context.registerDisposer(registerNested((context, isWritable) => { if (isWritable) { - this.initializeVoxelEditingForSubsource(loadedSubsource); + this.initializeVoxelEditingForSubsource(loadedSubsource, VolumeType.IMAGE); context.registerDisposer(() => { this.deinitializeVoxelEditingForSubsource(loadedSubsource); }); @@ -615,6 +617,8 @@ class ShaderCodeOverlay extends Overlay { } registerLayerType(ImageUserLayer); +registerVoxelTools(ImageUserLayer); +registerVoxelLayerControls(ImageUserLayer); registerVolumeLayerType(VolumeType.IMAGE, ImageUserLayer); // Use ImageUserLayer as a fallback layer type if there is a `volume` subsource. registerLayerTypeDetector((subsource) => { diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 1d3ef3b799..f6e4e19f69 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -35,6 +35,7 @@ import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; import { layerDataSourceSpecificationFromJson } from "#src/layer/layer_data_source.js"; import * as json_keys from "#src/layer/segmentation/json_keys.js"; import { registerLayerControls } from "#src/layer/segmentation/layer_controls.js"; +import { registerVoxelLayerControls } from "#src/layer/vox/controls.js"; import { UserLayerWithVoxelEditingMixin } from "#src/layer/vox/index.js"; import { MeshLayer, @@ -89,12 +90,11 @@ import { SegmentationRenderLayer } from "#src/sliceview/volume/segmentation_rend import { StatusMessage } from "#src/status.js"; import { trackableAlphaValue } from "#src/trackable_alpha.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; -import { - registerNested, +import type { TrackableValueInterface, - WatchableValueInterface, -} from "#src/trackable_value.js"; + WatchableValueInterface} from "#src/trackable_value.js"; import { + registerNested, IndirectTrackableValue, IndirectWatchableValue, makeCachedDerivedWatchableValue, @@ -109,6 +109,7 @@ import { SegmentDisplayTab } from "#src/ui/segment_list.js"; import { registerSegmentSelectTools } from "#src/ui/segment_select_tools.js"; import { registerSegmentSplitMergeTools } from "#src/ui/segment_split_merge_tools.js"; import { DisplayOptionsTab } from "#src/ui/segmentation_display_options_tab.js"; +import { registerVoxelTools } from "#src/ui/voxel_annotations.js"; import { Uint64Map } from "#src/uint64_map.js"; import { Uint64OrderedSet } from "#src/uint64_ordered_set.js"; import { Uint64Set } from "#src/uint64_set.js"; @@ -805,7 +806,7 @@ export class SegmentationUserLayer extends Base { ) context.registerDisposer(registerNested((context, isWritable) => { if (isWritable) { - this.initializeVoxelEditingForSubsource(loadedSubsource); + this.initializeVoxelEditingForSubsource(loadedSubsource, VolumeType.SEGMENTATION); context.registerDisposer(() => { this.deinitializeVoxelEditingForSubsource(loadedSubsource); }); @@ -1418,7 +1419,8 @@ export class SegmentationUserLayer extends Base { } registerLayerControls(SegmentationUserLayer); - +registerVoxelTools(SegmentationUserLayer); +registerVoxelLayerControls(SegmentationUserLayer); registerLayerType(SegmentationUserLayer); registerVolumeLayerType(VolumeType.SEGMENTATION, SegmentationUserLayer); registerLayerTypeDetector((subsource) => { diff --git a/src/layer/vox/controls.ts b/src/layer/vox/controls.ts index f5c1a9cfb6..752d67806e 100644 --- a/src/layer/vox/controls.ts +++ b/src/layer/vox/controls.ts @@ -1,6 +1,9 @@ -import { LayerActionContext } from "#src/layer/index.js"; +import { LayerActionContext, UserLayerConstructor } from "#src/layer/index.js"; import type { UserLayerWithVoxelEditing } from "#src/layer/vox/index.js"; -import type { LayerControlDefinition } from "#src/widget/layer_control.js"; +import { + LayerControlDefinition, + registerLayerControl, +} from "#src/widget/layer_control.js"; import { buttonLayerControl } from "#src/widget/layer_control_button.js"; import { checkboxLayerControl } from "#src/widget/layer_control_checkbox.js"; import { enumLayerControl } from "#src/widget/layer_control_enum.js"; @@ -58,3 +61,9 @@ export const VOXEL_LAYER_CONTROLS: LayerControlDefinition) { + for (const control of VOXEL_LAYER_CONTROLS) { + registerLayerControl(layerType, control); + } +} diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 9bbea56f05..36fc6b194b 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -26,9 +26,14 @@ import { getChunkTransformParameters, } from "#src/render_coordinate_transform.js"; import type { SliceViewSourceOptions } from "#src/sliceview/base.js"; +import type { + VolumeChunkSpecification, + VolumeType, +} from "#src/sliceview/volume/base.js"; import type { MultiscaleVolumeChunkSource} from "#src/sliceview/volume/frontend.js"; import { + SingleScaleVolumeChunkSource, InMemoryVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; import type { ImageRenderLayer } from "#src/sliceview/volume/image_renderlayer.js"; @@ -175,7 +180,7 @@ export declare abstract class UserLayerWithVoxelEditing extends UserLayer { transform: WatchableValueInterface, ): ImageRenderLayer | SegmentationRenderLayer; - initializeVoxelEditingForSubsource(loadedSubsource: LoadedDataSubsource): void; + initializeVoxelEditingForSubsource(loadedSubsource: LoadedDataSubsource, volumeType: VolumeType): void; deinitializeVoxelEditingForSubsource( loadedSubsource: LoadedDataSubsource, ): void; @@ -232,7 +237,7 @@ export function UserLayerWithVoxelEditingMixin< ): ImageRenderLayer | SegmentationRenderLayer; - initializeVoxelEditingForSubsource(loadedSubsource: LoadedDataSubsource) { + initializeVoxelEditingForSubsource(loadedSubsource: LoadedDataSubsource, volumeType: VolumeType) { if (this.editingContexts.has(loadedSubsource)) return; const primarySource = loadedSubsource.subsourceEntry.subsource @@ -248,15 +253,26 @@ export function UserLayerWithVoxelEditingMixin< ); } + const previewSpec: VolumeChunkSpecification = { + ...baseSpec, + compressedSegmentationBlockSize: undefined, + }; + const previewSource = new InMemoryVolumeChunkSource( this.manager.chunkManager, - baseSpec, + previewSpec, + ); + + const multiscalePreviewSource = new SingleScaleVolumeChunkSource( + this.manager.chunkManager, + previewSource, + volumeType ); const transform = loadedSubsource.getRenderLayerTransform(); const optimisticRenderLayer = this._createVoxelRenderLayer( - previewSource as any, + multiscalePreviewSource, transform, ); diff --git a/src/sliceview/volume/backend.ts b/src/sliceview/volume/backend.ts index aac4017a2f..3e23c0b343 100644 --- a/src/sliceview/volume/backend.ts +++ b/src/sliceview/volume/backend.ts @@ -24,7 +24,9 @@ import type { SliceViewChunkSpecification } from "#src/sliceview/base.js"; import { DataType } from "#src/sliceview/base.js"; import type { VolumeChunkSource as VolumeChunkSourceInterface, - VolumeChunkSpecification, + VolumeChunkSpecification} from "#src/sliceview/volume/base.js"; +import { + IN_MEMORY_VOLUME_CHUNK_SOURCE_RPC_ID } from "#src/sliceview/volume/base.js"; import type { TypedArray } from "#src/util/array.js"; import { DATA_TYPE_ARRAY_CONSTRUCTOR } from "#src/util/data_type.js"; @@ -33,6 +35,7 @@ import { HttpError } from "#src/util/http_request.js"; import * as vector from "#src/util/vector.js"; import type { VoxelChange } from "#src/voxel_annotation/base.js"; import type { RPC } from "#src/worker_rpc.js"; +import { registerSharedObject } from "#src/worker_rpc.js"; export class VolumeChunk extends SliceViewChunk { source: VolumeChunkSource | null = null; @@ -264,4 +267,26 @@ export class VolumeChunkSource ); } } + +@registerSharedObject(IN_MEMORY_VOLUME_CHUNK_SOURCE_RPC_ID) +export class InMemoryVolumeChunkSourceBackend extends SliceViewChunkSourceBackend { + // This is a dummy backend for a frontend-only source. + // It should never be called upon to download data. + download(chunk: Chunk, _signal: AbortSignal): Promise { + chunk.state = ChunkState.FAILED; + return Promise.reject(new Error(`Attempted to download from an in-memory source for chunk ${chunk.key}.`)); + } + + getChunk(chunkGridPosition: Float32Array): SliceViewChunk { + const chunk = super.getChunk(chunkGridPosition); + if (chunk.state === ChunkState.NEW) { + // This is a new chunk. Immediately mark it as available in system memory + // on the worker. This prevents the chunk manager from trying to download it. + // Since this is a dummy source, there's no actual data to associate with it. + this.chunkManager.queueManager.updateChunkState(chunk, ChunkState.SYSTEM_MEMORY_WORKER); + } + return chunk; + } +} +InMemoryVolumeChunkSourceBackend.prototype.chunkConstructor = VolumeChunk; VolumeChunkSource.prototype.chunkConstructor = VolumeChunk; diff --git a/src/sliceview/volume/base.ts b/src/sliceview/volume/base.ts index 583d8653e7..839373e2ab 100644 --- a/src/sliceview/volume/base.ts +++ b/src/sliceview/volume/base.ts @@ -310,3 +310,4 @@ export interface VolumeChunkSource extends SliceViewChunkSource { } export const VOLUME_RPC_ID = "volume"; +export const IN_MEMORY_VOLUME_CHUNK_SOURCE_RPC_ID = "sliceview/volume/InMemoryChunkSource"; diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index 6e1699dfc0..5e8b7eac77 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -19,15 +19,17 @@ import type { ChunkManager } from "#src/chunk_manager/frontend.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; import type { DataType, - SliceViewChunkSpecification, -} from "#src/sliceview/base.js"; + SliceViewChunkSpecification} from "#src/sliceview/base.js"; import { SLICEVIEW_REQUEST_CHUNK_RPC_ID } from "#src/sliceview/base.js"; import { ChunkFormat as CompressedChunkFormat } from "#src/sliceview/compressed_segmentation/chunk_format.js"; import { decodeChannel as decodeChannelUint32 } from "#src/sliceview/compressed_segmentation/decode_uint32.js"; import { decodeChannel as decodeChannelUint64 } from "#src/sliceview/compressed_segmentation/decode_uint64.js"; import { encodeChannel as encodeChannelUint32 } from "#src/sliceview/compressed_segmentation/encode_uint32.js"; import { encodeChannel as encodeChannelUint64 } from "#src/sliceview/compressed_segmentation/encode_uint64.js"; -import type { SliceViewChunk } from "#src/sliceview/frontend.js"; +import type { + SliceViewChunk, + SliceViewSingleResolutionSource, +} from "#src/sliceview/frontend.js"; import { MultiscaleSliceViewChunkSource, SliceViewChunkSource, @@ -42,7 +44,9 @@ import type { VolumeChunkSource as VolumeChunkSourceInterface, VolumeChunkSpecification, VolumeSourceOptions, - VolumeType, + VolumeType} from "#src/sliceview/volume/base.js"; +import { + IN_MEMORY_VOLUME_CHUNK_SOURCE_RPC_ID } from "#src/sliceview/volume/base.js"; import { VolumeChunk } from "#src/sliceview/volume/chunk.js"; import { getChunkFormatHandler } from "#src/sliceview/volume/registry.js"; @@ -53,9 +57,11 @@ import { DataType as DataTypeUtil, } from "#src/util/data_type.js"; import type { Disposable } from "#src/util/disposable.js"; +import * as matrix from "#src/util/matrix.js"; import type { GL } from "#src/webgl/context.js"; import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; import { getShaderType, glsl_mixLinear } from "#src/webgl/shader_lib.js"; +import { registerSharedObjectOwner } from "#src/worker_rpc.js"; export type VolumeChunkKey = string; @@ -465,15 +471,15 @@ export class VolumeChunkSource } } +@registerSharedObjectOwner(IN_MEMORY_VOLUME_CHUNK_SOURCE_RPC_ID) export class InMemoryVolumeChunkSource extends VolumeChunkSource { constructor(chunkManager: ChunkManager, spec: VolumeChunkSpecification) { super(chunkManager, { spec }); + this.initializeCounterpart(this.chunkManager.rpc!, {}); } - initializeCounterpart() {} - getChunk(chunkGridPosition: Float32Array): UncompressedVolumeChunk { - const key = chunkGridPosition.join(); + const key = Array.from(chunkGridPosition).join(); let chunk = this.chunks.get(key) as UncompressedVolumeChunk | undefined; if (chunk === undefined) { const { spec } = this; @@ -483,13 +489,13 @@ export class InMemoryVolumeChunkSource extends VolumeChunkSource { const data = new (Ctor as any)(numElements) as TypedArray; chunk = new UncompressedVolumeChunk(this, { - chunkGridPosition: chunkGridPosition.slice(), + chunkGridPosition: Array.from(chunkGridPosition), data: data, chunkDataSize: chunkDataSize, }); this.addChunk(key, chunk); - chunk.state = ChunkState.GPU_MEMORY; + chunk.state = ChunkState.SYSTEM_MEMORY; } return chunk; } @@ -503,4 +509,61 @@ export abstract class MultiscaleVolumeChunkSource extends MultiscaleSliceViewChu abstract volumeType: VolumeType; } +export class SingleScaleVolumeChunkSource extends MultiscaleVolumeChunkSource { + dataType: DataType; + volumeType: VolumeType; + baseSpec: VolumeChunkSpecification; + + constructor( + chunkManager: ChunkManager, + public baseSource: VolumeChunkSource, // Renamed for clarity + volumeType: VolumeType, + ) { + super(chunkManager); + this.baseSpec = baseSource.spec; + this.dataType = this.baseSpec.dataType; + this.volumeType = volumeType; + } + + get rank() { + return this.baseSpec.rank; + } + + getSources( + _options: VolumeSourceOptions, + ): SliceViewSingleResolutionSource[][] { + const { baseSource, baseSpec } = this; + const { rank } = baseSpec; + + // --- Level 0: The actual editing source --- + const fineSource: SliceViewSingleResolutionSource = { + chunkSource: baseSource, + chunkToMultiscaleTransform: matrix.createIdentity(Float32Array, rank + 1), + }; + + // --- Level 1: The coarse safeguard source --- + // This source has a single chunk that covers the entire volume. + const coarseChunkSize = new Uint32Array(rank); + for (let i = 0; i < rank; ++i) { + coarseChunkSize[i] = baseSpec.upperVoxelBound[i] - baseSpec.lowerVoxelBound[i]; + } + const coarseSpec: VolumeChunkSpecification = { + ...baseSpec, + chunkDataSize: coarseChunkSize, + }; + + const coarseSource = this.chunkManager.memoize.get( + `VoxelEditingCoarseSafeguard:${JSON.stringify(coarseSpec)}`, + () => new InMemoryVolumeChunkSource(this.chunkManager, coarseSpec) + ); + + const coarseSourceSpec: SliceViewSingleResolutionSource = { + chunkSource: coarseSource, + chunkToMultiscaleTransform: matrix.createIdentity(Float32Array, rank + 1), + }; + + return [[fineSource, coarseSourceSpec]]; + } +} + export { VolumeChunk }; From ba01de60a1c74027413d684052eb0181e16485b0 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:45 +0100 Subject: [PATCH 092/251] feat: add support for compressed chunks in applyEdits() and zarr write() --- src/datasource/zarr/backend.ts | 51 +++++++- src/layer/vox/controls.ts | 12 +- src/layer/vox/index.ts | 4 +- src/sliceview/volume/backend.ts | 138 ++++++++++++++++------ src/sliceview/volume/frontend.ts | 193 ++++++------------------------- src/ui/voxel_annotations.ts | 37 ++++-- 6 files changed, 222 insertions(+), 213 deletions(-) diff --git a/src/datasource/zarr/backend.ts b/src/datasource/zarr/backend.ts index 9411789684..0db65d89a6 100644 --- a/src/datasource/zarr/backend.ts +++ b/src/datasource/zarr/backend.ts @@ -32,8 +32,11 @@ import { encodeArray } from "#src/datasource/zarr/codec/encode.js"; import { ChunkKeyEncoding } from "#src/datasource/zarr/metadata/index.js"; import { WithSharedKvStoreContextCounterpart } from "#src/kvstore/backend.js"; import { postProcessRawData } from "#src/sliceview/backend_chunk_decoders/postprocess.js"; +import { decodeChannel as decodeChannelUint32 } from "#src/sliceview/compressed_segmentation/decode_uint32.js"; +import { decodeChannel as decodeChannelUint64 } from "#src/sliceview/compressed_segmentation/decode_uint64.js"; import type { VolumeChunk } from "#src/sliceview/volume/backend.js"; import { VolumeChunkSource } from "#src/sliceview/volume/backend.js"; +import { DataType } from "#src/util/data_type.js"; import { registerSharedObject } from "#src/worker_rpc.js"; @registerSharedObject() @@ -109,15 +112,53 @@ export class ZarrVolumeChunkSource extends WithParameters( if (!chunk.data) { throw new Error("ZarrVolumeChunkSource.writeChunk: missing chunk.data"); } - // Encode using the same codecs chain that was used to decode, but in reverse; our minimal - // encodeArray currently only supports raw 'bytes'. + let dataToWrite = chunk.data; + + const { compressedSegmentationBlockSize } = this.spec; + if (compressedSegmentationBlockSize !== undefined) { + const compressedData = chunk.data as Uint32Array; + const { chunkDataSize } = chunk; + if (!chunkDataSize) { + throw new Error("Cannot write chunk with unknown size."); + } + const numElements = + chunkDataSize[0] * chunkDataSize[1] * chunkDataSize[2]; + const { dataType } = this.spec; + const baseOffset = compressedData.length > 0 ? compressedData[0] : 0; + + if (dataType === DataType.UINT32) { + const uncompressedData = new Uint32Array(numElements); + if (baseOffset !== 0) { + decodeChannelUint32( + uncompressedData, + compressedData, + baseOffset, + chunkDataSize, + compressedSegmentationBlockSize, + ); + } + dataToWrite = uncompressedData; + } else { + const uncompressedData = new BigUint64Array(numElements); + if (baseOffset !== 0) { + decodeChannelUint64( + uncompressedData, + compressedData, + baseOffset, + chunkDataSize, + compressedSegmentationBlockSize, + ); + } + dataToWrite = uncompressedData; + } + } + const encoded = await encodeArray( decodeCodecs, - chunk.data as ArrayBufferView, + dataToWrite as ArrayBufferView, new AbortController().signal, ); - // Compute base key same as in download. const { parameters } = this; const { chunkGridPosition } = chunk; const { metadata } = parameters; @@ -155,8 +196,6 @@ export class ZarrVolumeChunkSource extends WithParameters( } const key = getChunkKey(chunkGridPosition, baseKey) as string | unknown; - // Ensure we provide an ArrayBuffer-backed payload. If encoded is backed by SharedArrayBuffer, - // copy it into a new ArrayBuffer. const arrayBuffer = new Uint8Array(encoded).buffer; await kvStore.write!(key as any, arrayBuffer); } diff --git a/src/layer/vox/controls.ts b/src/layer/vox/controls.ts index 752d67806e..f6f573b020 100644 --- a/src/layer/vox/controls.ts +++ b/src/layer/vox/controls.ts @@ -1,7 +1,9 @@ -import { LayerActionContext, UserLayerConstructor } from "#src/layer/index.js"; +import type { UserLayerConstructor } from "#src/layer/index.js"; +import { LayerActionContext } from "#src/layer/index.js"; import type { UserLayerWithVoxelEditing } from "#src/layer/vox/index.js"; +import type { + LayerControlDefinition} from "#src/widget/layer_control.js"; import { - LayerControlDefinition, registerLayerControl, } from "#src/widget/layer_control.js"; import { buttonLayerControl } from "#src/widget/layer_control_button.js"; @@ -41,7 +43,7 @@ export const VOXEL_LAYER_CONTROLS: LayerControlDefinition layer.handleAction("undo", new LayerActionContext()), + onClick: (layer) => layer.handleVoxAction("undo", new LayerActionContext()), }), }, { @@ -49,7 +51,7 @@ export const VOXEL_LAYER_CONTROLS: LayerControlDefinition layer.handleAction("redo", new LayerActionContext()), + onClick: (layer) => layer.handleVoxAction("redo", new LayerActionContext()), }), }, { @@ -57,7 +59,7 @@ export const VOXEL_LAYER_CONTROLS: LayerControlDefinition layer.handleAction("new-label", new LayerActionContext()), + onClick: (layer) => layer.handleVoxAction("new-label", new LayerActionContext()), }), }, ]; diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 36fc6b194b..3b3c9a511b 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -147,7 +147,6 @@ export class VoxelEditingContext chunkTransform.combinedGlobalLocalToChunkTransform, ); if (!ok) return undefined; - return this.cachedVoxelPosition; } @@ -187,6 +186,7 @@ export declare abstract class UserLayerWithVoxelEditing extends UserLayer { getIdentitySliceViewSourceOptions(): SliceViewSourceOptions; setDrawErrorMessage(message: string | undefined): void; + handleVoxAction(action: string, context: LayerActionContext): void; } export function UserLayerWithVoxelEditingMixin< @@ -316,7 +316,7 @@ export function UserLayerWithVoxelEditingMixin< }; } - handleAction(action: string, context: LayerActionContext): void { + handleVoxAction(action: string, context: LayerActionContext): void { super.handleAction(action, context); const firstContext = this.editingContexts.values().next().value; if (!firstContext) return; diff --git a/src/sliceview/volume/backend.ts b/src/sliceview/volume/backend.ts index 3e23c0b343..32a7b9b690 100644 --- a/src/sliceview/volume/backend.ts +++ b/src/sliceview/volume/backend.ts @@ -16,19 +16,20 @@ import type { Chunk } from "#src/chunk_manager/backend.js"; import { ChunkState } from "#src/chunk_manager/base.js"; -import { - SliceViewChunk, - SliceViewChunkSourceBackend, -} from "#src/sliceview/backend.js"; +import { SliceViewChunk, SliceViewChunkSourceBackend } from "#src/sliceview/backend.js"; import type { SliceViewChunkSpecification } from "#src/sliceview/base.js"; import { DataType } from "#src/sliceview/base.js"; +import { decodeChannel as decodeChannelUint32 } from "#src/sliceview/compressed_segmentation/decode_uint32.js"; +import { decodeChannel as decodeChannelUint64 } from "#src/sliceview/compressed_segmentation/decode_uint64.js"; +import { encodeChannel as encodeChannelUint32 } from "#src/sliceview/compressed_segmentation/encode_uint32.js"; +import { encodeChannel as encodeChannelUint64 } from "#src/sliceview/compressed_segmentation/encode_uint64.js"; import type { VolumeChunkSource as VolumeChunkSourceInterface, - VolumeChunkSpecification} from "#src/sliceview/volume/base.js"; -import { - IN_MEMORY_VOLUME_CHUNK_SOURCE_RPC_ID + VolumeChunkSpecification } from "#src/sliceview/volume/base.js"; -import type { TypedArray } from "#src/util/array.js"; +import { IN_MEMORY_VOLUME_CHUNK_SOURCE_RPC_ID } from "#src/sliceview/volume/base.js"; +import type { TypedArray} from "#src/util/array.js"; +import { TypedArrayBuilder } from "#src/util/array.js"; import { DATA_TYPE_ARRAY_CONSTRUCTOR } from "#src/util/data_type.js"; import type { vec3 } from "#src/util/geom.js"; import { HttpError } from "#src/util/http_request.js"; @@ -185,39 +186,27 @@ export class VolumeChunkSource ) { throw new Error(`applyEdits: invalid chunk key ${chunkKey}`); } - const chunk = this.getChunk(chunkGridPosition) as VolumeChunk; - if (chunk.state > ChunkState.SYSTEM_MEMORY_WORKER) { + const chunk = this.getChunk(chunkGridPosition) as VolumeChunk; + if (chunk.state > ChunkState.SYSTEM_MEMORY_WORKER || !chunk.data) { const ac = new AbortController(); await this.download(chunk, ac.signal); } - if (!chunk.data) { - try { - const ac = new AbortController(); - await this.download(chunk, ac.signal); - } catch { - // - } + if (!chunk.chunkDataSize) { + this.computeChunkBounds(chunk); + } + if (!chunk.chunkDataSize) { + throw new Error( + `applyEdits: Cannot create new chunk ${chunkKey} because its size is unknown.`, + ); } if (!chunk.data) { - // If chunk.data is null, the chunk does not exist at the source or was evicted. - // Create a new, zero-filled chunk to apply the edits to. - if (!chunk.chunkDataSize) { - this.computeChunkBounds(chunk); - } - if (!chunk.chunkDataSize) { - throw new Error( - `applyEdits: Cannot create new chunk ${chunkKey} because its size is unknown.`, - ); - } const numElements = chunk.chunkDataSize.reduce((a, b) => a * b, 1); const Ctor = DATA_TYPE_ARRAY_CONSTRUCTOR[this.spec.dataType]; - chunk.data = new (Ctor as any)(numElements); - // The new TypedArray is already zero-filled. + chunk.data = new (Ctor as any)(numElements) as TypedArray; } - const data = chunk.data as TypedArray; const ArrayCtor = DATA_TYPE_ARRAY_CONSTRUCTOR[this.spec.dataType] as any; const indicesCopy = new Uint32Array(indices); @@ -230,16 +219,91 @@ export class VolumeChunkSource } const oldValuesArray = new ArrayCtor(indices.length); - for (let i = 0; i < indices.length; ++i) { - const idx = indices[i]!; - if (idx < 0 || idx >= data.length) { - throw new Error( - `applyEdits: index ${idx} out of bounds for chunk ${chunkKey}`, + if (this.spec.compressedSegmentationBlockSize !== undefined) { + const compressedData = chunk.data as Uint32Array; + const { chunkDataSize } = chunk; + const numElements = + chunkDataSize[0] * chunkDataSize[1] * chunkDataSize[2]; + const { dataType, compressedSegmentationBlockSize: subchunkSize } = + this.spec; + const baseOffset = compressedData.length > 0 ? compressedData[0] : 0; + + let uncompressedData: Uint32Array | BigUint64Array; + if (dataType === DataType.UINT32) { + uncompressedData = new Uint32Array(numElements); + if (baseOffset !== 0) { + decodeChannelUint32( + uncompressedData, + compressedData, + baseOffset, + chunkDataSize, + subchunkSize!, + ); + } + } else { + uncompressedData = new BigUint64Array(numElements); + if (baseOffset !== 0) { + decodeChannelUint64( + uncompressedData, + compressedData, + baseOffset, + chunkDataSize, + subchunkSize!, + ); + } + } + + const ArrayCtor = DATA_TYPE_ARRAY_CONSTRUCTOR[dataType] as any; + const oldValuesArray = new ArrayCtor(indices.length); + const newValuesArray = new ArrayCtor(values.length); + + for (let i = 0; i < indices.length; ++i) { + const idx = indices[i]!; + oldValuesArray[i] = uncompressedData[idx]; + if (dataType === DataType.UINT32) { + (uncompressedData as Uint32Array)[idx] = Number(values[i]!); + newValuesArray[i] = Number(values[i]!); + } else { + (uncompressedData as BigUint64Array)[idx] = values[i]! as bigint; + newValuesArray[i] = values[i]! as bigint; + } + } + + const outputBuilder = new TypedArrayBuilder(Uint32Array); + outputBuilder.resize(1); + outputBuilder.data[0] = 1; + + if (dataType === DataType.UINT32) { + encodeChannelUint32( + outputBuilder, + subchunkSize!, + uncompressedData as Uint32Array, + chunkDataSize, + ); + } else { + encodeChannelUint64( + outputBuilder, + subchunkSize!, + uncompressedData as BigUint64Array, + chunkDataSize, ); } - oldValuesArray[i] = data[idx]; - data[idx] = newValuesArray[i]; + + chunk.data = outputBuilder.view; + } else { + const data = chunk.data as TypedArray; + for (let i = 0; i < indices.length; ++i) { + const idx = indices[i]!; + if (idx < 0 || idx >= data.byteLength) { + throw new Error( + `applyEdits: index ${idx} out of bounds for chunk ${chunkKey}`, + ); + } + oldValuesArray[i] = data[idx]; + data[idx] = newValuesArray[i]; + } } + const maxRetries = 3; let lastError: Error | undefined; diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index 5e8b7eac77..8ae145aa0b 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -18,14 +18,9 @@ import { ChunkState } from "#src/chunk_manager/base.js"; import type { ChunkManager } from "#src/chunk_manager/frontend.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; import type { - DataType, SliceViewChunkSpecification} from "#src/sliceview/base.js"; -import { SLICEVIEW_REQUEST_CHUNK_RPC_ID } from "#src/sliceview/base.js"; -import { ChunkFormat as CompressedChunkFormat } from "#src/sliceview/compressed_segmentation/chunk_format.js"; -import { decodeChannel as decodeChannelUint32 } from "#src/sliceview/compressed_segmentation/decode_uint32.js"; -import { decodeChannel as decodeChannelUint64 } from "#src/sliceview/compressed_segmentation/decode_uint64.js"; -import { encodeChannel as encodeChannelUint32 } from "#src/sliceview/compressed_segmentation/encode_uint32.js"; -import { encodeChannel as encodeChannelUint64 } from "#src/sliceview/compressed_segmentation/encode_uint64.js"; +import { + DataType, SLICEVIEW_REQUEST_CHUNK_RPC_ID } from "#src/sliceview/base.js"; import type { SliceViewChunk, SliceViewSingleResolutionSource, @@ -34,11 +29,8 @@ import { MultiscaleSliceViewChunkSource, SliceViewChunkSource, } from "#src/sliceview/frontend.js"; -import type { - UncompressedChunkFormatHandler} from "#src/sliceview/uncompressed_chunk_format.js"; import { UncompressedVolumeChunk, - ChunkFormat as UncompressedChunkFormat } from "#src/sliceview/uncompressed_chunk_format.js"; import type { VolumeChunkSource as VolumeChunkSourceInterface, @@ -51,10 +43,8 @@ import { import { VolumeChunk } from "#src/sliceview/volume/chunk.js"; import { getChunkFormatHandler } from "#src/sliceview/volume/registry.js"; import type { TypedArray } from "#src/util/array.js"; -import { TypedArrayBuilder } from "#src/util/array.js"; import { DATA_TYPE_ARRAY_CONSTRUCTOR, - DataType as DataTypeUtil, } from "#src/util/data_type.js"; import type { Disposable } from "#src/util/disposable.js"; import * as matrix from "#src/util/matrix.js"; @@ -63,7 +53,6 @@ import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; import { getShaderType, glsl_mixLinear } from "#src/webgl/shader_lib.js"; import { registerSharedObjectOwner } from "#src/worker_rpc.js"; -export type VolumeChunkKey = string; export interface ChunkFormat { shaderKey: string; @@ -253,148 +242,6 @@ export class VolumeChunkSource return this.getValueAt(chunkPosition, channelAccess); } - applyLocalEdits( - edits: Map, - ): void { - const chunksToUpdate = new Set(); - const fetches: Promise[] = []; - - for (const [key, edit] of edits.entries()) { - const chunk = this.chunks.get(key) as VolumeChunk | undefined; - if (!chunk) { - continue; - } - - const processEdit = (targetChunk: VolumeChunk) => { - const chunkFormat = targetChunk.chunkFormat; - if (chunkFormat instanceof UncompressedChunkFormat) { - const uncompressedChunk = targetChunk as UncompressedVolumeChunk; - let cpuArray = uncompressedChunk.data as TypedArray | null; - if (cpuArray === null) { - // If the chunk currently has the shared fill value texture, we must - // detach it so that a new texture is created for the edited data. - const handler = uncompressedChunk.source - .chunkFormatHandler as UncompressedChunkFormatHandler; - if (uncompressedChunk.texture === handler.fillValueChunk.texture) { - uncompressedChunk.texture = null; - uncompressedChunk.textureLayout = null; - } - - // Chunk data is null, meaning it's an empty/unloaded chunk. - // We must create a zero-filled buffer to apply the preview edit. - const { chunkDataSize, source } = uncompressedChunk; - const numElements = chunkDataSize.reduce((a, b) => a * b, 1); - const Ctor = DATA_TYPE_ARRAY_CONSTRUCTOR[source.spec.dataType]; - cpuArray = new (Ctor as any)(numElements); - uncompressedChunk.data = cpuArray; - } - if (cpuArray === null) throw new Error("Unexpected null chunk data"); - const { dataType } = chunkFormat; - for (const index of edit.indices) { - if (dataType === DataTypeUtil.UINT32) { - cpuArray[index] = Number(edit.value); - } else { - // Assumes UINT64 - cpuArray[index] = edit.value; - } - } - chunksToUpdate.add(targetChunk); - } else if (chunkFormat instanceof CompressedChunkFormat) { - // using an idiotic logic to handle compressed chunks: uncompress -> edit -> recompress - // TODO: rework this - const compressedData = (targetChunk as any).data as Uint32Array; - const { chunkDataSize } = targetChunk; - const numElements = - chunkDataSize[0] * chunkDataSize[1] * chunkDataSize[2]; - const { dataType, subchunkSize } = chunkFormat; - const baseOffset = compressedData[0]; - const outputBuilder = new TypedArrayBuilder( - Uint32Array, - compressedData.length, - ); - outputBuilder.resize(1); - outputBuilder.data[0] = 1; - - if (dataType === DataTypeUtil.UINT32) { - const uncompressedData = new Uint32Array(numElements); - decodeChannelUint32( - uncompressedData, - compressedData, - baseOffset, - chunkDataSize, - subchunkSize, - ); - for (const index of edit.indices) { - uncompressedData[index] = Number(edit.value); - } - encodeChannelUint32( - outputBuilder, - subchunkSize, - uncompressedData, - chunkDataSize, - ); - } else { - // Assumes UINT64 - const uncompressedData = new BigUint64Array(numElements); - decodeChannelUint64( - uncompressedData, - compressedData, - baseOffset, - chunkDataSize, - subchunkSize, - ); - for (const index of edit.indices) { - uncompressedData[index] = edit.value; - } - encodeChannelUint64( - outputBuilder, - subchunkSize, - uncompressedData, - chunkDataSize, - ); - } - - (targetChunk as any).data = outputBuilder.view; - chunksToUpdate.add(targetChunk); - } - }; - - if ((chunk as any).data) { - processEdit(chunk); - } else { - const fetchPromise = this.fetchChunk( - chunk.chunkGridPosition, - (fetchedChunk) => { - processEdit(fetchedChunk as VolumeChunk); - }, - {}, - ).catch((err) => { - console.error( - `Failed to fetch chunk ${key} for local edit preview:`, - err, - ); - }); - fetches.push(fetchPromise); - } - } - - this.invalidateGpuData(chunksToUpdate); - - if (fetches.length > 0) { - Promise.all(fetches).then(() => { - this.invalidateGpuData(chunksToUpdate); - }); - } - } - - private invalidateGpuData(chunks: Set): void { - if (chunks.size === 0) return; - for (const chunk of chunks) { - chunk.updateFromCpuData(this.chunkManager.chunkQueueManager.gl); - } - this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); - } - computeChunkIndices(voxelCoord: Float32Array): { chunkGridPosition: Float32Array; positionWithinChunk: Uint32Array; @@ -478,6 +325,41 @@ export class InMemoryVolumeChunkSource extends VolumeChunkSource { this.initializeCounterpart(this.chunkManager.rpc!, {}); } + private invalidateGpuData(chunks: Set): void { + if (chunks.size === 0) return; + for (const chunk of chunks) { + chunk.updateFromCpuData(this.chunkManager.chunkQueueManager.gl); + } + this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); + } + + applyLocalEdits(edits: Map): void { + const chunksToUpdate = new Set(); + const { dataType } = this.spec; + + console.log("applyLocalEdits", edits); + + for (const [key, edit] of edits.entries()) { + const chunkGridPosition = new Float32Array(key.split(",").map(Number)); + + // getChunk on InMemoryVolumeChunkSource is guaranteed to return a chunk + const chunk = this.getChunk(chunkGridPosition); + chunksToUpdate.add(chunk); + + const cpuArray = chunk.data!; + + for (const index of edit.indices) { + if (dataType === DataType.UINT32) { + cpuArray[index] = Number(edit.value); + } else { + (cpuArray as BigUint64Array)[index] = edit.value; + } + } + } + + this.invalidateGpuData(chunksToUpdate); + } + getChunk(chunkGridPosition: Float32Array): UncompressedVolumeChunk { const key = Array.from(chunkGridPosition).join(); let chunk = this.chunks.get(key) as UncompressedVolumeChunk | undefined; @@ -542,7 +424,6 @@ export class SingleScaleVolumeChunkSource extends MultiscaleVolumeChunkSource { }; // --- Level 1: The coarse safeguard source --- - // This source has a single chunk that covers the entire volume. const coarseChunkSize = new Uint32Array(rank); for (let i = 0; i < rank; ++i) { coarseChunkSize[i] = baseSpec.upperVoxelBound[i] - baseSpec.lowerVoxelBound[i]; diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index bb23037a73..1e98c42d07 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -39,10 +39,15 @@ const VOX_TOOL_INPUT_MAP = EventActionMap.fromObject({ abstract class BaseVoxelTool extends LayerTool { protected latestMouseState: MouseSelectionState | null = null; + protected getEditingContext(): VoxelEditingContext | undefined { + return this.layer.editingContexts.values().next().value; + } + protected getPoint(mouseState: MouseSelectionState): Int32Array | undefined { // TODO: maybe getVoxelPositionFromMouse() would best fit in the userLayer - const editController = this.layer.editingContexts.values().next().value.controller; - const vox = editController.getVoxelPositionFromMouse(mouseState) as + const editContext = this.getEditingContext(); + if (editContext === undefined) return undefined; + const vox = editContext.getVoxelPositionFromMouse(mouseState) as | Float32Array | undefined; if (!mouseState?.active || !vox) return undefined; @@ -173,6 +178,14 @@ export class VoxelBrushTool extends BaseVoxelTool { } activationCallback(_activation: ToolActivation): void { + if (this.getEditingContext() === undefined) { + StatusMessage.showTemporaryMessage( + 'Voxel editing is not available. Please select a writable volume source in the "Source" tab.', + 5000, + ); + this.stopDrawing(); + return; + } this.startDrawing(this.mouseState); } @@ -291,9 +304,12 @@ export class VoxelBrushTool extends BaseVoxelTool { basis = { u, v }; } - for (const [_, ed] of this.layer.editingContexts) - for (const p of points) - ed.controller?.paintBrushWithShape(p, radius, value, shapeEnum, basis); + const editContext = this.getEditingContext(); + if (editContext === undefined) { + throw new Error("editContext is undefined"); + } + for (const p of points) + editContext.controller?.paintBrushWithShape(p, radius, value, shapeEnum, basis); } } @@ -328,6 +344,14 @@ export class VoxelFloodFillTool extends BaseVoxelTool { } activationCallback(_activation: ToolActivation): void { + const editContext = this.getEditingContext(); + if (editContext === undefined) { + StatusMessage.showTemporaryMessage( + 'Voxel editing is not available. Please select a writable volume source in the "Source" tab.', + 5000, + ); + return; + } const seed = this.getPoint(this.mouseState); const planeNormal = this.mouseState.planeNormal; if (!seed || !planeNormal) return; @@ -342,8 +366,7 @@ export class VoxelFloodFillTool extends BaseVoxelTool { if (!Number.isFinite(max) || max <= 0) { throw new Error("Invalid max fill voxels setting"); } - for (const [_, ed] of this.layer.editingContexts) - ed.controller + editContext.controller .floodFillPlane2D( new Float32Array(seed), value, From b2cc38d9da01bc3fca2e49bf02448a52ebf5d560 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:45 +0100 Subject: [PATCH 093/251] feat: add dynamic cursor for voxel picker tool --- src/ui/voxel_annotations.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 1e98c42d07..84625d016a 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -398,11 +398,34 @@ export class VoxelFloodFillTool extends BaseVoxelTool { } } +const pickerSVG = ` + + + + + +`; + +const pickerCursor = `url('data:image/svg+xml;utf8,${encodeURIComponent(pickerSVG)}') 4 19, crosshair`; + + export class AdoptVoxelLabelTool extends LayerTool { constructor(layer: UserLayerWithVoxelEditing) { super(layer, /*toggle=*/ false); } + protected setCursor(cursor: string) { + for (const panel of this.layer.manager.root.display.panels) { + panel.element.style.setProperty("cursor", cursor, "important"); + } + } + + protected resetCursor() { + for (const panel of this.layer.manager.root.display.panels) { + panel.element.style.removeProperty("cursor"); + } + } + toJSON() { return ADOPT_VOXEL_LABEL_TOOL_ID; } @@ -411,8 +434,10 @@ export class AdoptVoxelLabelTool extends LayerTool { return "Picking tool"; } - activate(_activation: ToolActivation): void { + activate(activation: ToolActivation): void { if (!this.mouseState?.active) return; + this.setCursor(pickerCursor); + activation.registerDisposer(() => {this.resetCursor()}) const voxelEditingContext = this.layer.editingContexts.values().next().value as VoxelEditingContext; if (!voxelEditingContext) { From 1a20724f3d012cf6348e68c89944d3d725073333 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:45 +0100 Subject: [PATCH 094/251] refactor: replace the SingleScaleVolumeChunkSource with a proper Multiscale source (VoxelPreviewMultiscaleSource). The preview is still not working tho --- src/layer/image/index.ts | 2 +- src/layer/segmentation/index.ts | 2 +- src/layer/vox/index.ts | 31 ++--- src/sliceview/volume/frontend.ts | 62 +--------- .../PreviewMultiscaleChunkSource.ts | 51 ++++++++ src/voxel_annotation/edit_backend.ts | 5 + src/voxel_annotation/edit_controller.ts | 7 +- src/voxel_annotation/renderlayer.ts | 111 ------------------ 8 files changed, 72 insertions(+), 199 deletions(-) create mode 100644 src/voxel_annotation/PreviewMultiscaleChunkSource.ts delete mode 100644 src/voxel_annotation/renderlayer.ts diff --git a/src/layer/image/index.ts b/src/layer/image/index.ts index 8060cb3d6e..c2b03088de 100644 --- a/src/layer/image/index.ts +++ b/src/layer/image/index.ts @@ -313,7 +313,7 @@ void main() { this.shaderError.changed.dispatch(); context.registerDisposer(registerNested((context, isWritable) => { if (isWritable) { - this.initializeVoxelEditingForSubsource(loadedSubsource, VolumeType.IMAGE); + this.initializeVoxelEditingForSubsource(loadedSubsource); context.registerDisposer(() => { this.deinitializeVoxelEditingForSubsource(loadedSubsource); }); diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index f6e4e19f69..cacbf6e984 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -806,7 +806,7 @@ export class SegmentationUserLayer extends Base { ) context.registerDisposer(registerNested((context, isWritable) => { if (isWritable) { - this.initializeVoxelEditingForSubsource(loadedSubsource, VolumeType.SEGMENTATION); + this.initializeVoxelEditingForSubsource(loadedSubsource); context.registerDisposer(() => { this.deinitializeVoxelEditingForSubsource(loadedSubsource); }); diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 3b3c9a511b..a9f92b91ed 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -27,14 +27,10 @@ import { } from "#src/render_coordinate_transform.js"; import type { SliceViewSourceOptions } from "#src/sliceview/base.js"; import type { - VolumeChunkSpecification, VolumeType, } from "#src/sliceview/volume/base.js"; import type { - MultiscaleVolumeChunkSource} from "#src/sliceview/volume/frontend.js"; -import { - SingleScaleVolumeChunkSource, - InMemoryVolumeChunkSource + MultiscaleVolumeChunkSource, } from "#src/sliceview/volume/frontend.js"; import type { ImageRenderLayer } from "#src/sliceview/volume/image_renderlayer.js"; import type { SegmentationRenderLayer } from "#src/sliceview/volume/segmentation_renderlayer.js"; @@ -52,6 +48,7 @@ import { RefCounted } from "#src/util/disposable.js"; import { verifyFiniteFloat, verifyInt } from "#src/util/json.js"; import { NullarySignal } from "#src/util/signal.js"; import { TrackableEnum } from "#src/util/trackable_enum.js"; +import { VoxelPreviewMultiscaleSource } from "#src/voxel_annotation/PreviewMultiscaleChunkSource.js"; import type { VoxelEditControllerHost } from "#src/voxel_annotation/edit_controller.js"; import { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; import { LabelsManager } from "#src/voxel_annotation/labels.js"; @@ -75,13 +72,12 @@ export class VoxelEditingContext constructor( public hostLayer: UserLayerWithVoxelEditing, public primarySource: MultiscaleVolumeChunkSource, - public previewSource: InMemoryVolumeChunkSource, + public previewSource: VoxelPreviewMultiscaleSource, public optimisticRenderLayer: ImageRenderLayer | SegmentationRenderLayer, ) { super(); this.controller = new VoxelEditController(this); - this.registerDisposer(optimisticRenderLayer); - this.registerDisposer(previewSource); + //this.registerDisposer(optimisticRenderLayer); } // VoxelEditControllerHost implementation @@ -237,7 +233,7 @@ export function UserLayerWithVoxelEditingMixin< ): ImageRenderLayer | SegmentationRenderLayer; - initializeVoxelEditingForSubsource(loadedSubsource: LoadedDataSubsource, volumeType: VolumeType) { + initializeVoxelEditingForSubsource(loadedSubsource: LoadedDataSubsource) { if (this.editingContexts.has(loadedSubsource)) return; const primarySource = loadedSubsource.subsourceEntry.subsource @@ -253,26 +249,15 @@ export function UserLayerWithVoxelEditingMixin< ); } - const previewSpec: VolumeChunkSpecification = { - ...baseSpec, - compressedSegmentationBlockSize: undefined, - }; - - const previewSource = new InMemoryVolumeChunkSource( - this.manager.chunkManager, - previewSpec, - ); - - const multiscalePreviewSource = new SingleScaleVolumeChunkSource( + const previewSource = new VoxelPreviewMultiscaleSource( this.manager.chunkManager, - previewSource, - volumeType + primarySource ); const transform = loadedSubsource.getRenderLayerTransform(); const optimisticRenderLayer = this._createVoxelRenderLayer( - multiscalePreviewSource, + previewSource, transform, ); diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index 8ae145aa0b..b3818f9a98 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -23,7 +23,6 @@ import { DataType, SLICEVIEW_REQUEST_CHUNK_RPC_ID } from "#src/sliceview/base.js"; import type { SliceViewChunk, - SliceViewSingleResolutionSource, } from "#src/sliceview/frontend.js"; import { MultiscaleSliceViewChunkSource, @@ -47,7 +46,6 @@ import { DATA_TYPE_ARRAY_CONSTRUCTOR, } from "#src/util/data_type.js"; import type { Disposable } from "#src/util/disposable.js"; -import * as matrix from "#src/util/matrix.js"; import type { GL } from "#src/webgl/context.js"; import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; import { getShaderType, glsl_mixLinear } from "#src/webgl/shader_lib.js"; @@ -320,8 +318,8 @@ export class VolumeChunkSource @registerSharedObjectOwner(IN_MEMORY_VOLUME_CHUNK_SOURCE_RPC_ID) export class InMemoryVolumeChunkSource extends VolumeChunkSource { - constructor(chunkManager: ChunkManager, spec: VolumeChunkSpecification) { - super(chunkManager, { spec }); + constructor(chunkManager: ChunkManager, options: { spec: VolumeChunkSpecification }) { + super(chunkManager, options); this.initializeCounterpart(this.chunkManager.rpc!, {}); } @@ -391,60 +389,4 @@ export abstract class MultiscaleVolumeChunkSource extends MultiscaleSliceViewChu abstract volumeType: VolumeType; } -export class SingleScaleVolumeChunkSource extends MultiscaleVolumeChunkSource { - dataType: DataType; - volumeType: VolumeType; - baseSpec: VolumeChunkSpecification; - - constructor( - chunkManager: ChunkManager, - public baseSource: VolumeChunkSource, // Renamed for clarity - volumeType: VolumeType, - ) { - super(chunkManager); - this.baseSpec = baseSource.spec; - this.dataType = this.baseSpec.dataType; - this.volumeType = volumeType; - } - - get rank() { - return this.baseSpec.rank; - } - - getSources( - _options: VolumeSourceOptions, - ): SliceViewSingleResolutionSource[][] { - const { baseSource, baseSpec } = this; - const { rank } = baseSpec; - - // --- Level 0: The actual editing source --- - const fineSource: SliceViewSingleResolutionSource = { - chunkSource: baseSource, - chunkToMultiscaleTransform: matrix.createIdentity(Float32Array, rank + 1), - }; - - // --- Level 1: The coarse safeguard source --- - const coarseChunkSize = new Uint32Array(rank); - for (let i = 0; i < rank; ++i) { - coarseChunkSize[i] = baseSpec.upperVoxelBound[i] - baseSpec.lowerVoxelBound[i]; - } - const coarseSpec: VolumeChunkSpecification = { - ...baseSpec, - chunkDataSize: coarseChunkSize, - }; - - const coarseSource = this.chunkManager.memoize.get( - `VoxelEditingCoarseSafeguard:${JSON.stringify(coarseSpec)}`, - () => new InMemoryVolumeChunkSource(this.chunkManager, coarseSpec) - ); - - const coarseSourceSpec: SliceViewSingleResolutionSource = { - chunkSource: coarseSource, - chunkToMultiscaleTransform: matrix.createIdentity(Float32Array, rank + 1), - }; - - return [[fineSource, coarseSourceSpec]]; - } -} - export { VolumeChunk }; diff --git a/src/voxel_annotation/PreviewMultiscaleChunkSource.ts b/src/voxel_annotation/PreviewMultiscaleChunkSource.ts new file mode 100644 index 0000000000..02b515e9de --- /dev/null +++ b/src/voxel_annotation/PreviewMultiscaleChunkSource.ts @@ -0,0 +1,51 @@ +import type { ChunkManager } from "#src/chunk_manager/frontend.js"; +import type { SliceViewSingleResolutionSource } from "#src/sliceview/frontend.js"; +import type { VolumeChunkSpecification, VolumeSourceOptions , DataType, VolumeType } from "#src/sliceview/volume/base.js"; +import { + InMemoryVolumeChunkSource, + MultiscaleVolumeChunkSource, + type VolumeChunkSource +} from "#src/sliceview/volume/frontend.js"; + +export class VoxelPreviewMultiscaleSource extends MultiscaleVolumeChunkSource { + dataType: DataType; + volumeType: VolumeType; + rank: number; + + constructor( + chunkManager: ChunkManager, + public primarySource: MultiscaleVolumeChunkSource, + ) { + super(chunkManager); + this.dataType = primarySource.dataType; + this.volumeType = primarySource.volumeType; + this.rank = primarySource.rank; + } + + getSources( + options: VolumeSourceOptions, + ): SliceViewSingleResolutionSource[][] { + const sourcesByScale = this.primarySource.getSources(options); + + return sourcesByScale.map(orientation => { + return orientation.map(primaryResSource => { + const spec = primaryResSource.chunkSource.spec; + + const previewSpec: VolumeChunkSpecification = { + ...spec, + compressedSegmentationBlockSize: undefined, + }; + + const previewSource = this.chunkManager.getChunkSource( + InMemoryVolumeChunkSource, + { spec: previewSpec }, + ); + + return { + chunkSource: previewSource, + chunkToMultiscaleTransform: primaryResSource.chunkToMultiscaleTransform, + }; + }); + }); + } +} diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/edit_backend.ts index 0c375a572a..6ebb0b7cbf 100644 --- a/src/voxel_annotation/edit_backend.ts +++ b/src/voxel_annotation/edit_backend.ts @@ -192,6 +192,7 @@ export class VoxelEditController extends SharedObject { if (firstErrorMessage === undefined) firstErrorMessage = msg; } } + this.callChunkReload(editsByVoxKey.keys().toArray()); if (newAction.changes.size > 0) { this.undoStack.push(newAction); @@ -336,6 +337,8 @@ export class VoxelEditController extends SharedObject { } const { parentKey, parentSource, parentRes } = parentInfo; + // TODO: decompress the data if it's compressed + // 3. Calculate the update for the parent chunk based on the child chunk's data. const update = this._calculateParentUpdate( childChunkData, @@ -347,6 +350,8 @@ export class VoxelEditController extends SharedObject { return parentKey; } + // TODO: recompress the data if uncompressed before + // 4. Commit the update to the parent chunk and notify the frontend. try { await parentSource.applyEdits( diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 81c9788e00..9142d9d799 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -24,6 +24,7 @@ import type { import { StatusMessage } from "#src/status.js"; import { WatchableValue } from "#src/trackable_value.js"; import { vec3 } from "#src/util/geom.js"; +import type { VoxelPreviewMultiscaleSource } from "#src/voxel_annotation/PreviewMultiscaleChunkSource.js"; import type { VoxelLayerResolution } from "#src/voxel_annotation/base.js"; import { VOX_EDIT_BACKEND_RPC_ID, @@ -47,7 +48,7 @@ import { export interface VoxelEditControllerHost { primarySource: MultiscaleVolumeChunkSource; - previewSource?: InMemoryVolumeChunkSource; + previewSource: VoxelPreviewMultiscaleSource; labelsManager: LabelsManager; rpc: RPC; setDrawErrorMessage(message: string | undefined): void; @@ -181,7 +182,7 @@ export class VoxelEditController extends SharedObject { // For V1 we use the minimum LOD (index 0) const voxelSize = 1; const sourceIndex = 0; - const source = this.host.previewSource; + const source = this.host.previewSource.getSources(this.getIdentitySliceViewSourceOptions())[0][sourceIndex]!.chunkSource as InMemoryVolumeChunkSource; if (!source) { throw new Error( "paintBrushWithShape: Missing preview source", @@ -511,7 +512,7 @@ export class VoxelEditController extends SharedObject { } } - const previewSource = this.host.previewSource; + const previewSource = this.host.previewSource.getSources(this.getIdentitySliceViewSourceOptions())[0][sourceIndex]!.chunkSource as InMemoryVolumeChunkSource; if (!previewSource) { throw new Error( "paintBrushWithShape: Missing preview source", diff --git a/src/voxel_annotation/renderlayer.ts b/src/voxel_annotation/renderlayer.ts deleted file mode 100644 index 7e27899c38..0000000000 --- a/src/voxel_annotation/renderlayer.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * @license - * Copyright 2025 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 { SegmentColorShaderManager } from "#src/segment_color.js"; -import type { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; -import type { RenderLayerOptions } from "#src/sliceview/volume/renderlayer.js"; -import { SliceViewVolumeRenderLayer } from "#src/sliceview/volume/renderlayer.js"; -import { constantWatchableValue } from "#src/trackable_value.js"; -import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; - -type EmptyParams = Record; - -export class VoxelAnnotationRenderLayer extends SliceViewVolumeRenderLayer { - private segmentColorShaderManager = new SegmentColorShaderManager( - "segmentColorHash", - ); - private forcedSourceIndexLock: number | undefined; - - /** Expose forced LOD index to SliceView.filterVisibleSources when a stroke is active. */ - getForcedSourceIndexOverride(): number | undefined { - return this.forcedSourceIndexLock; - } - - /** Set or clear the forced LOD index. Triggers a visible-sources recomputation. */ - setForcedSourceIndexLock(index: number | undefined): void { - if (index !== undefined) { - if (!Number.isInteger(index) || index < 0) { - throw new Error( - "setForcedSourceIndexLock: index must be a non-negative integer", - ); - } - } - this.forcedSourceIndexLock = index; - // Nudge the sliceview to recompute visible sources by toggling the render scale target. - const current = this.renderScaleTarget.value; - const epsilon = 1e-9; - this.renderScaleTarget.value = current + epsilon; - this.renderScaleTarget.value = current; - // Ensure a redraw as well. - this.redrawNeeded.dispatch(); - this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); - } - - constructor( - multiscaleSource: MultiscaleVolumeChunkSource, - options: RenderLayerOptions, - ) { - super(multiscaleSource, { - ...options, - shaderParameters: - options.shaderParameters ?? constantWatchableValue({} as EmptyParams), - encodeShaderParameters: () => 0, - }); - } - - defineShader(builder: ShaderBuilder) { - // Define segment color hashing function and uint64 helpers - this.segmentColorShaderManager.defineShader(builder); - builder.addFragmentCode(` -uint64_t getUint64DataValue() { - return toUint64(getDataValue()); -} -`); - - builder.setFragmentMain(` - uint64_t v64 = getUint64DataValue(); - vec3 rgb = segmentColorHash(v64); - // Transparent if zero, otherwise semi-opaque - bool isZero = (v64.value[0] == 0u && v64.value[1] == 0u); - float alpha = isZero ? 0.0 : 0.5; - emit(vec4(rgb, alpha)); - `); - - /** - * Notes on the shader building: - * - The shader is not built until the first draw call, in the `draw` method. - * - the SliceViewVolumeRenderLayer build a shader getter in the constructor, the getter builds the shader and memoize it. - * - when the build process fails no error is thrown, this makes a wrong shader hard to debug. - * - here we add a try/catch to log the error if the build fails, its soul purpose is for debugging. - */ - try { - builder.build(); - } catch (e) { - builder.print(); - console.error(e); - } - } - - initializeShader( - _sliceView: any, - shader: ShaderProgram, - _parameters: EmptyParams, - _fallback: boolean, - ) { - // Use default seed 0 to match UI hashing (SegmentColorHash.getDefault()) - this.segmentColorShaderManager.enable(this.gl, shader, 0); - } -} From 557ce29daf56b5ad0e3ab7d48b94e73ca8ab0a29 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:45 +0100 Subject: [PATCH 095/251] fix: finally found the bug of the drawing preview -> getChunk of InMemoryVolumeChunkSource was using the wrong argument format, but since the arg type is any the error was not catch and was making the chunk keys [NaN, NaN, NaN] --- src/layer/image/index.ts | 17 +++-- src/layer/segmentation/index.ts | 9 +-- src/layer/vox/index.ts | 18 ++--- src/sliceview/single_texture_chunk_format.ts | 69 ++----------------- src/sliceview/volume/frontend.ts | 41 ++++++++--- src/sliceview/volume/renderlayer.ts | 8 +++ src/ui/voxel_annotations.ts | 4 +- .../PreviewMultiscaleChunkSource.ts | 7 ++ src/voxel_annotation/edit_backend.ts | 28 ++++++-- 9 files changed, 96 insertions(+), 105 deletions(-) diff --git a/src/layer/image/index.ts b/src/layer/image/index.ts index c2b03088de..be9b81e79e 100644 --- a/src/layer/image/index.ts +++ b/src/layer/image/index.ts @@ -177,12 +177,16 @@ export class ImageUserLayer extends Base { ): ImageRenderLayer { const wrappedFragmentMain = makeDerivedWatchableValue( (originalShader: string) => ` +#define main userMain +${originalShader} +#undef main + void main() { - if (toRaw(getDataValue()) == 0) { + if (toRaw(getDataValue()) == 0n) { emitTransparent(); return; } - ${originalShader} + userMain(); } `, this.fragmentMain, @@ -267,8 +271,7 @@ void main() { } dataType = volume.dataType; loadedSubsource.activate((context) => { - loadedSubsource.addRenderLayer( - new ImageRenderLayer(volume, { + const imageRenderLayer = new ImageRenderLayer(volume, { opacity: this.opacity, blendMode: this.blendMode, shaderControlState: this.shaderControlState, @@ -280,7 +283,9 @@ void main() { renderScaleHistogram: this.sliceViewRenderScaleHistogram, localPosition: this.localPosition, channelCoordinateSpace: this.channelCoordinateSpace, - }), + }); + loadedSubsource.addRenderLayer( + imageRenderLayer ); const volumeRenderLayer = context.registerDisposer( new VolumeRenderingRenderLayer({ @@ -313,7 +318,7 @@ void main() { this.shaderError.changed.dispatch(); context.registerDisposer(registerNested((context, isWritable) => { if (isWritable) { - this.initializeVoxelEditingForSubsource(loadedSubsource); + this.initializeVoxelEditingForSubsource(loadedSubsource, imageRenderLayer); context.registerDisposer(() => { this.deinitializeVoxelEditingForSubsource(loadedSubsource); }); diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index cacbf6e984..8b4704d505 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -795,18 +795,19 @@ export class SegmentationUserLayer extends Base { hasVolume = true; loadedSubsource.activate( (context) => { - loadedSubsource.addRenderLayer( - new SegmentationRenderLayer(volume, { + const segmentationRenderLayer = new SegmentationRenderLayer(volume, { ...this.displayState, transform: loadedSubsource.getRenderLayerTransform(), renderScaleTarget: this.sliceViewRenderScaleTarget, renderScaleHistogram: this.sliceViewRenderScaleHistogram, localPosition: this.localPosition, - }), + }); + loadedSubsource.addRenderLayer( + segmentationRenderLayer ) context.registerDisposer(registerNested((context, isWritable) => { if (isWritable) { - this.initializeVoxelEditingForSubsource(loadedSubsource); + this.initializeVoxelEditingForSubsource(loadedSubsource, segmentationRenderLayer); context.registerDisposer(() => { this.deinitializeVoxelEditingForSubsource(loadedSubsource); }); diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index a9f92b91ed..2b9f0d4663 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -26,9 +26,6 @@ import { getChunkTransformParameters, } from "#src/render_coordinate_transform.js"; import type { SliceViewSourceOptions } from "#src/sliceview/base.js"; -import type { - VolumeType, -} from "#src/sliceview/volume/base.js"; import type { MultiscaleVolumeChunkSource, } from "#src/sliceview/volume/frontend.js"; @@ -74,6 +71,7 @@ export class VoxelEditingContext public primarySource: MultiscaleVolumeChunkSource, public previewSource: VoxelPreviewMultiscaleSource, public optimisticRenderLayer: ImageRenderLayer | SegmentationRenderLayer, + public primaryRenderLayer: ImageRenderLayer | SegmentationRenderLayer, ) { super(); this.controller = new VoxelEditController(this); @@ -145,15 +143,6 @@ export class VoxelEditingContext if (!ok) return undefined; return this.cachedVoxelPosition; } - - - get voxRenderLayerInstance(): - | ImageRenderLayer - | SegmentationRenderLayer - | undefined { - // TODO - return undefined; - } } export declare abstract class UserLayerWithVoxelEditing extends UserLayer { @@ -175,7 +164,7 @@ export declare abstract class UserLayerWithVoxelEditing extends UserLayer { transform: WatchableValueInterface, ): ImageRenderLayer | SegmentationRenderLayer; - initializeVoxelEditingForSubsource(loadedSubsource: LoadedDataSubsource, volumeType: VolumeType): void; + initializeVoxelEditingForSubsource(loadedSubsource: LoadedDataSubsource, renderlayer: SegmentationRenderLayer | ImageRenderLayer): void; deinitializeVoxelEditingForSubsource( loadedSubsource: LoadedDataSubsource, ): void; @@ -233,7 +222,7 @@ export function UserLayerWithVoxelEditingMixin< ): ImageRenderLayer | SegmentationRenderLayer; - initializeVoxelEditingForSubsource(loadedSubsource: LoadedDataSubsource) { + initializeVoxelEditingForSubsource(loadedSubsource: LoadedDataSubsource, renderlayer: SegmentationRenderLayer | ImageRenderLayer): void { if (this.editingContexts.has(loadedSubsource)) return; const primarySource = loadedSubsource.subsourceEntry.subsource @@ -266,6 +255,7 @@ export function UserLayerWithVoxelEditingMixin< primarySource, previewSource, optimisticRenderLayer, + renderlayer ); this.editingContexts.set(loadedSubsource, context); this.addRenderLayer(optimisticRenderLayer); diff --git a/src/sliceview/single_texture_chunk_format.ts b/src/sliceview/single_texture_chunk_format.ts index 36d244b405..4a281e2545 100644 --- a/src/sliceview/single_texture_chunk_format.ts +++ b/src/sliceview/single_texture_chunk_format.ts @@ -152,79 +152,20 @@ export abstract class SingleTextureVolumeChunk< ) { if (this.data == null) return; - // If there is no existing texture, just perform the normal upload path. if (this.texture == null) { this.copyToGPU(gl); return; } - const fmt = this.chunkFormat as any; // Both uncompressed and compressed implement TextureFormat-like fields const textureTarget = textureTargetForSamplerType[this.chunkFormat.shaderSamplerType]; gl.bindTexture(textureTarget, this.texture); - gl.pixelStorei(WebGL2RenderingContext.UNPACK_ALIGNMENT, 1); - - // If we have a textureLayout with a definite shape (uncompressed path), we can sub-update. - const layout: any = this.textureLayout; - const hasShape = - layout && layout.textureShape && layout.textureShape.length >= 2; - try { - // Prefer texSubImage path when we can compute exact sizes (uncompressed formats): - if (hasShape && typeof fmt.textureDims === "number") { - const texelsPerElement = fmt.texelsPerElement ?? 1; - const w = layout.textureShape[0] * texelsPerElement; - const h = layout.textureShape[1] ?? 1; - const d = - fmt.textureDims === 3 ? (layout.textureShape[2] ?? 1) : undefined; - - // Ensure typed array type matches GL expectations - let data: any = this.data; - const ctor = fmt.arrayConstructor as - | { new (b: ArrayBuffer, o: number, l: number): any } - | undefined; - if (ctor && data.constructor !== ctor) { - data = new (ctor as any)( - data.buffer, - data.byteOffset, - data.byteLength / (ctor as any).BYTES_PER_ELEMENT, - ); - } - - if (fmt.textureDims === 3 && d !== undefined) { - // 3D update - gl.texSubImage3D( - WebGL2RenderingContext.TEXTURE_3D, - /*level=*/ 0, - /*xoffset=*/ 0, - /*yoffset=*/ 0, - /*zoffset=*/ 0, - /*width=*/ w, - /*height=*/ h, - /*depth=*/ d, - fmt.textureFormat, - fmt.texelType, - data, - ); - } else { - // 2D update - gl.texSubImage2D( - WebGL2RenderingContext.TEXTURE_2D, - /*level=*/ 0, - /*xoffset=*/ 0, - /*yoffset=*/ 0, - /*width=*/ w, - /*height=*/ h, - fmt.textureFormat, - fmt.texelType, - data, - ); - } - } else { - // Fallback: re-specify the texture contents onto the existing texture object. - // This still avoids delete+create and the associated driver sync. - this.setTextureData(gl); - } + this.chunkFormat.setTextureData( + gl, + this.textureLayout!, + this.data as unknown as TypedArray, + ); } finally { gl.bindTexture(textureTarget, null); } diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index b3818f9a98..e831fb92d1 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -335,22 +335,33 @@ export class InMemoryVolumeChunkSource extends VolumeChunkSource { const chunksToUpdate = new Set(); const { dataType } = this.spec; - console.log("applyLocalEdits", edits); - for (const [key, edit] of edits.entries()) { const chunkGridPosition = new Float32Array(key.split(",").map(Number)); // getChunk on InMemoryVolumeChunkSource is guaranteed to return a chunk - const chunk = this.getChunk(chunkGridPosition); + const chunk = this.getChunk({ chunkGridPosition: chunkGridPosition }); chunksToUpdate.add(chunk); const cpuArray = chunk.data!; for (const index of edit.indices) { - if (dataType === DataType.UINT32) { - cpuArray[index] = Number(edit.value); - } else { - (cpuArray as BigUint64Array)[index] = edit.value; + const value = edit.value; + switch (dataType) { + case DataType.UINT8: + case DataType.INT8: + case DataType.UINT16: + case DataType.INT16: + case DataType.UINT32: + case DataType.INT32: + case DataType.FLOAT32: + cpuArray[index] = Number(value); + break; + case DataType.UINT64: + (cpuArray as BigUint64Array)[index] = value; + break; + default: + console.warn(`Unsupported data type for editing: ${DataType[dataType]}`); + break; } } } @@ -358,18 +369,26 @@ export class InMemoryVolumeChunkSource extends VolumeChunkSource { this.invalidateGpuData(chunksToUpdate); } - getChunk(chunkGridPosition: Float32Array): UncompressedVolumeChunk { - const key = Array.from(chunkGridPosition).join(); + // at least 10h of debugging just because this parameter is of a type any -- this was horrible + getChunk(x: any): UncompressedVolumeChunk { + const key = Array.from(x.chunkGridPosition).join(); let chunk = this.chunks.get(key) as UncompressedVolumeChunk | undefined; + if (chunk === undefined) { const { spec } = this; const chunkDataSize = spec.chunkDataSize; const numElements = chunkDataSize.reduce((a, b) => a * b, 1); const Ctor = DATA_TYPE_ARRAY_CONSTRUCTOR[spec.dataType]; const data = new (Ctor as any)(numElements) as TypedArray; - + if ( + spec.dataType === DataType.UINT64 + ) { + (data as BigUint64Array).fill(1n); + } else if( spec.dataType === DataType.UINT32) { + (data as Uint32Array).fill(1); + } chunk = new UncompressedVolumeChunk(this, { - chunkGridPosition: Array.from(chunkGridPosition), + chunkGridPosition: Array.from(x.chunkGridPosition), data: data, chunkDataSize: chunkDataSize, }); diff --git a/src/sliceview/volume/renderlayer.ts b/src/sliceview/volume/renderlayer.ts index e189483934..3ec256939e 100644 --- a/src/sliceview/volume/renderlayer.ts +++ b/src/sliceview/volume/renderlayer.ts @@ -265,6 +265,14 @@ function drawChunk( chunkPosition: vec3, wireFrame: boolean, ) { + if (chunkPosition.some(isNaN)) { + throw new Error( + `Attempted to draw chunk with NaN position: [${chunkPosition.join( + ",", + )}]. This indicates a problem with the layer's coordinate transforms.`, + ); + } + gl.uniform3fv(shader.uniform("uTranslation"), chunkPosition); if (wireFrame) { drawLines(shader.gl, 6, 1); diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 84625d016a..036bf5406b 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -439,7 +439,7 @@ export class AdoptVoxelLabelTool extends LayerTool { this.setCursor(pickerCursor); activation.registerDisposer(() => {this.resetCursor()}) - const voxelEditingContext = this.layer.editingContexts.values().next().value as VoxelEditingContext; + const voxelEditingContext = this.layer.editingContexts.values().next().value; if (!voxelEditingContext) { StatusMessage.showTemporaryMessage( "Cannot pick label: layer is not ready.", @@ -458,7 +458,7 @@ export class AdoptVoxelLabelTool extends LayerTool { return; } - const renderLayer = voxelEditingContext.voxRenderLayerInstance; + const renderLayer = voxelEditingContext.primaryRenderLayer; if (!renderLayer) { StatusMessage.showTemporaryMessage("Render layer not available.", 3000); return; diff --git a/src/voxel_annotation/PreviewMultiscaleChunkSource.ts b/src/voxel_annotation/PreviewMultiscaleChunkSource.ts index 02b515e9de..be8fa84111 100644 --- a/src/voxel_annotation/PreviewMultiscaleChunkSource.ts +++ b/src/voxel_annotation/PreviewMultiscaleChunkSource.ts @@ -41,6 +41,13 @@ export class VoxelPreviewMultiscaleSource extends MultiscaleVolumeChunkSource { { spec: previewSpec }, ); + console.log( + "%c[CHECKPOINT 5]%c Preview source created:", + "color: purple; font-weight: bold;", + "", + previewSource, + ); + return { chunkSource: previewSource, chunkToMultiscaleTransform: primaryResSource.chunkToMultiscaleTransform, diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/edit_backend.ts index 6ebb0b7cbf..79b97c2bd5 100644 --- a/src/voxel_annotation/edit_backend.ts +++ b/src/voxel_annotation/edit_backend.ts @@ -14,6 +14,9 @@ * limitations under the License. */ +import { DataType } from "#src/sliceview/base.js"; +import { decodeChannel as decodeChannelUint32 } from "#src/sliceview/compressed_segmentation/decode_uint32.js"; +import { decodeChannel as decodeChannelUint64 } from "#src/sliceview/compressed_segmentation/decode_uint64.js" import type { VolumeChunkSource } from "#src/sliceview/volume/backend.js"; import { mat4, vec3 } from "#src/util/geom.js"; import * as matrix from "#src/util/matrix.js"; @@ -337,11 +340,30 @@ export class VoxelEditController extends SharedObject { } const { parentKey, parentSource, parentRes } = parentInfo; - // TODO: decompress the data if it's compressed + let dataToProcess = childChunkData; + const { compressedSegmentationBlockSize, dataType, chunkDataSize } = childSource.spec; + if (compressedSegmentationBlockSize !== undefined) { + const numElements = chunkDataSize[0] * chunkDataSize[1] * chunkDataSize[2]; + const compressedData = childChunkData as Uint32Array; + const baseOffset = compressedData.length > 0 ? compressedData[0] : 0; + if (dataType === DataType.UINT32) { + const uncompressedData = new Uint32Array(numElements); + if (baseOffset !== 0) { + decodeChannelUint32(uncompressedData, compressedData, baseOffset, chunkDataSize, compressedSegmentationBlockSize); + } + dataToProcess = uncompressedData; + } else { // Assumes UINT64 + const uncompressedData = new BigUint64Array(numElements); + if (baseOffset !== 0) { + decodeChannelUint64(uncompressedData, compressedData, baseOffset, chunkDataSize, compressedSegmentationBlockSize); + } + dataToProcess = uncompressedData; + } + } // 3. Calculate the update for the parent chunk based on the child chunk's data. const update = this._calculateParentUpdate( - childChunkData, + dataToProcess, childRes, parentRes, childInfo, @@ -350,8 +372,6 @@ export class VoxelEditController extends SharedObject { return parentKey; } - // TODO: recompress the data if uncompressed before - // 4. Commit the update to the parent chunk and notify the frontend. try { await parentSource.applyEdits( From c7edca0090708b5514b823b885a59cc6bd8e8b82 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:45 +0100 Subject: [PATCH 096/251] refactor: rewrite the InMemoryVolumeChunkSource to no longer create an unnecessary amount of 0 filled chunks, this fixed the perfomance issue --- src/sliceview/volume/backend.ts | 23 ++++----------- src/sliceview/volume/frontend.ts | 50 +++++++++----------------------- 2 files changed, 18 insertions(+), 55 deletions(-) diff --git a/src/sliceview/volume/backend.ts b/src/sliceview/volume/backend.ts index 32a7b9b690..8f0fcf1a50 100644 --- a/src/sliceview/volume/backend.ts +++ b/src/sliceview/volume/backend.ts @@ -333,24 +333,11 @@ export class VolumeChunkSource } @registerSharedObject(IN_MEMORY_VOLUME_CHUNK_SOURCE_RPC_ID) -export class InMemoryVolumeChunkSourceBackend extends SliceViewChunkSourceBackend { - // This is a dummy backend for a frontend-only source. - // It should never be called upon to download data. - download(chunk: Chunk, _signal: AbortSignal): Promise { - chunk.state = ChunkState.FAILED; - return Promise.reject(new Error(`Attempted to download from an in-memory source for chunk ${chunk.key}.`)); - } - - getChunk(chunkGridPosition: Float32Array): SliceViewChunk { - const chunk = super.getChunk(chunkGridPosition); - if (chunk.state === ChunkState.NEW) { - // This is a new chunk. Immediately mark it as available in system memory - // on the worker. This prevents the chunk manager from trying to download it. - // Since this is a dummy source, there's no actual data to associate with it. - this.chunkManager.queueManager.updateChunkState(chunk, ChunkState.SYSTEM_MEMORY_WORKER); - } - return chunk; +export class InMemoryVolumeChunkSourceBackend extends VolumeChunkSource { + async download(chunk: VolumeChunk, _signal: AbortSignal): Promise { + chunk.data = null; + return new Promise(((_resolve) => {})) } } -InMemoryVolumeChunkSourceBackend.prototype.chunkConstructor = VolumeChunk; + VolumeChunkSource.prototype.chunkConstructor = VolumeChunk; diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index e831fb92d1..f7e8dee72f 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { ChunkState } from "#src/chunk_manager/base.js"; import type { ChunkManager } from "#src/chunk_manager/frontend.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; import type { @@ -28,7 +27,7 @@ import { MultiscaleSliceViewChunkSource, SliceViewChunkSource, } from "#src/sliceview/frontend.js"; -import { +import type { UncompressedVolumeChunk, } from "#src/sliceview/uncompressed_chunk_format.js"; import type { @@ -42,9 +41,7 @@ import { import { VolumeChunk } from "#src/sliceview/volume/chunk.js"; import { getChunkFormatHandler } from "#src/sliceview/volume/registry.js"; import type { TypedArray } from "#src/util/array.js"; -import { - DATA_TYPE_ARRAY_CONSTRUCTOR, -} from "#src/util/data_type.js"; +import { DATA_TYPE_ARRAY_CONSTRUCTOR } from "#src/util/data_type.js"; import type { Disposable } from "#src/util/disposable.js"; import type { GL } from "#src/webgl/context.js"; import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; @@ -338,8 +335,17 @@ export class InMemoryVolumeChunkSource extends VolumeChunkSource { for (const [key, edit] of edits.entries()) { const chunkGridPosition = new Float32Array(key.split(",").map(Number)); - // getChunk on InMemoryVolumeChunkSource is guaranteed to return a chunk - const chunk = this.getChunk({ chunkGridPosition: chunkGridPosition }); + let chunk = this.chunks.get(key) as UncompressedVolumeChunk | undefined; + if (chunk === undefined) { + chunk = this.getChunk({ chunkGridPosition: chunkGridPosition }) as UncompressedVolumeChunk; + this.addChunk(key, chunk); + } + + if (chunk.data == undefined) { + const numElements = chunk.chunkDataSize.reduce((a, b) => a * b, 1); + const Ctor = DATA_TYPE_ARRAY_CONSTRUCTOR[dataType]; + chunk.data = new (Ctor as any)(numElements) as TypedArray; + } chunksToUpdate.add(chunk); const cpuArray = chunk.data!; @@ -368,36 +374,6 @@ export class InMemoryVolumeChunkSource extends VolumeChunkSource { this.invalidateGpuData(chunksToUpdate); } - - // at least 10h of debugging just because this parameter is of a type any -- this was horrible - getChunk(x: any): UncompressedVolumeChunk { - const key = Array.from(x.chunkGridPosition).join(); - let chunk = this.chunks.get(key) as UncompressedVolumeChunk | undefined; - - if (chunk === undefined) { - const { spec } = this; - const chunkDataSize = spec.chunkDataSize; - const numElements = chunkDataSize.reduce((a, b) => a * b, 1); - const Ctor = DATA_TYPE_ARRAY_CONSTRUCTOR[spec.dataType]; - const data = new (Ctor as any)(numElements) as TypedArray; - if ( - spec.dataType === DataType.UINT64 - ) { - (data as BigUint64Array).fill(1n); - } else if( spec.dataType === DataType.UINT32) { - (data as Uint32Array).fill(1); - } - chunk = new UncompressedVolumeChunk(this, { - chunkGridPosition: Array.from(x.chunkGridPosition), - data: data, - chunkDataSize: chunkDataSize, - }); - - this.addChunk(key, chunk); - chunk.state = ChunkState.SYSTEM_MEMORY; - } - return chunk; - } } export abstract class MultiscaleVolumeChunkSource extends MultiscaleSliceViewChunkSource< From 0f38e501ef82aae6ce5da6bbda6e954b91b7ce8a Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:45 +0100 Subject: [PATCH 097/251] feat: add chunk invalidation for the preview sources --- src/chunk_manager/frontend.ts | 4 -- src/sliceview/volume/frontend.ts | 20 ++++++++ .../PreviewMultiscaleChunkSource.ts | 7 --- src/voxel_annotation/edit_controller.ts | 49 ++++++++++++++----- 4 files changed, 57 insertions(+), 23 deletions(-) diff --git a/src/chunk_manager/frontend.ts b/src/chunk_manager/frontend.ts index dfd8b68dce..890475efbc 100644 --- a/src/chunk_manager/frontend.ts +++ b/src/chunk_manager/frontend.ts @@ -466,13 +466,11 @@ export class ChunkSource extends SharedObject { invalidateChunks(keys: string[]): void { const validKeys: string[] = []; - let changed = false; for (const key of keys) { const chunk = this.chunks.get(key); if (chunk) { validKeys.push(key); this.deleteChunk(key); - changed = true; } } @@ -481,9 +479,7 @@ export class ChunkSource extends SharedObject { id: this.rpcId, keys: validKeys, }); - } - if (changed) { this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); } } diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index f7e8dee72f..518958ab9c 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -328,6 +328,26 @@ export class InMemoryVolumeChunkSource extends VolumeChunkSource { this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); } + invalidateChunks(keys: string[]): void { + const update = () => { + const validKeys: string[] = []; + for (const key of keys) { + const chunk = this.chunks.get(key); + if (chunk) { + validKeys.push(key); + this.deleteChunk(key); + } + } + + if (validKeys.length > 0) { + this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); + } + } + // adding a small delay to avoid flickering since the base source will take some time to download the new data + setTimeout(update, 100); + } + + applyLocalEdits(edits: Map): void { const chunksToUpdate = new Set(); const { dataType } = this.spec; diff --git a/src/voxel_annotation/PreviewMultiscaleChunkSource.ts b/src/voxel_annotation/PreviewMultiscaleChunkSource.ts index be8fa84111..02b515e9de 100644 --- a/src/voxel_annotation/PreviewMultiscaleChunkSource.ts +++ b/src/voxel_annotation/PreviewMultiscaleChunkSource.ts @@ -41,13 +41,6 @@ export class VoxelPreviewMultiscaleSource extends MultiscaleVolumeChunkSource { { spec: previewSpec }, ); - console.log( - "%c[CHECKPOINT 5]%c Preview source created:", - "color: purple; font-weight: bold;", - "", - previewSource, - ); - return { chunkSource: previewSource, chunkToMultiscaleTransform: primaryResSource.chunkToMultiscaleTransform, diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 9142d9d799..474a73d472 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -562,28 +562,53 @@ export class VoxelEditController extends SharedObject { callChunkReload(voxChunkKeys: string[]) { if (!Array.isArray(voxChunkKeys) || voxChunkKeys.length === 0) return; - // This assumes the multiscale source has a single orientation. - const sourcesByScale = this.host.primarySource.getSources( + const baseSources = this.host.primarySource.getSources( this.getIdentitySliceViewSourceOptions(), - ); - const sources = sourcesByScale && sourcesByScale[0]; - if (!sources) return; + )[0]; + if (!baseSources) + { + throw new Error( + "VoxelEditController.callChunkReload: Missing base source", + ); + } + const previewSources = this.host.previewSource.getSources( + this.getIdentitySliceViewSourceOptions(), + )[0]; + if (!previewSources) { + throw new Error( + "VoxelEditController.callChunkReload: Missing preview source", + ); + } const chunksToInvalidateBySource = new Map(); for (const voxKey of voxChunkKeys) { const parsed = parseVoxChunkKey(voxKey); if (!parsed) continue; - const source = sources[parsed.lodIndex]?.chunkSource as + const baseSource = baseSources[parsed.lodIndex]?.chunkSource as + | VolumeChunkSource + | undefined; + const previewSource = previewSources[parsed.lodIndex]?.chunkSource as | VolumeChunkSource | undefined; - if (!source) continue; - let arr = chunksToInvalidateBySource.get(source); - if (!arr) { - arr = []; - chunksToInvalidateBySource.set(source, arr); + if (previewSource) + { + let arr = chunksToInvalidateBySource.get(previewSource); + if (!arr) { + arr = []; + chunksToInvalidateBySource.set(previewSource, arr); + } + arr.push(parsed.chunkKey); + } + if (baseSource) + { + let arr = chunksToInvalidateBySource.get(baseSource); + if (!arr) { + arr = []; + chunksToInvalidateBySource.set(baseSource, arr); + } + arr.push(parsed.chunkKey); } - arr.push(parsed.chunkKey); } for (const [source, keys] of chunksToInvalidateBySource.entries()) { From d89174a738e3470ff0e265d5928cb79de3e3e647 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:45 +0100 Subject: [PATCH 098/251] fix: undo stack was corrupted --- src/sliceview/volume/backend.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/sliceview/volume/backend.ts b/src/sliceview/volume/backend.ts index 8f0fcf1a50..e9ab9657ee 100644 --- a/src/sliceview/volume/backend.ts +++ b/src/sliceview/volume/backend.ts @@ -253,10 +253,6 @@ export class VolumeChunkSource } } - const ArrayCtor = DATA_TYPE_ARRAY_CONSTRUCTOR[dataType] as any; - const oldValuesArray = new ArrayCtor(indices.length); - const newValuesArray = new ArrayCtor(values.length); - for (let i = 0; i < indices.length; ++i) { const idx = indices[i]!; oldValuesArray[i] = uncompressedData[idx]; From 85e7f2d12584001a1c402ac7e993f3f13e6ca8e7 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:45 +0100 Subject: [PATCH 099/251] chore: lint + format --- src/layer/image/index.ts | 101 +++++++++------ src/layer/layer_data_source.ts | 9 +- src/layer/segmentation/index.ts | 39 +++--- src/layer/vox/controls.ts | 121 +++++++++--------- src/layer/vox/index.ts | 40 +++--- src/layer/vox/tabs/tools.ts | 9 +- src/sliceview/volume/backend.ts | 11 +- src/sliceview/volume/base.ts | 3 +- src/sliceview/volume/frontend.ts | 62 ++++----- src/ui/layer_data_sources_tab.ts | 42 +++--- src/ui/voxel_annotations.ts | 86 +++++++++---- .../PreviewMultiscaleChunkSource.ts | 32 ++++- src/voxel_annotation/TODOs.md | 5 +- src/voxel_annotation/edit_backend.ts | 27 +++- src/voxel_annotation/edit_controller.ts | 35 ++--- src/widget/layer_control_button.ts | 4 +- 16 files changed, 372 insertions(+), 254 deletions(-) diff --git a/src/layer/image/index.ts b/src/layer/image/index.ts index be9b81e79e..01886601e0 100644 --- a/src/layer/image/index.ts +++ b/src/layer/image/index.ts @@ -21,23 +21,34 @@ import { CoordinateSpaceCombiner, isChannelDimension, isLocalDimension, - TrackableCoordinateSpace + TrackableCoordinateSpace, } from "#src/coordinate_transform.js"; -import type { ManagedUserLayer, UserLayerSelectionState } from "#src/layer/index.js"; -import { registerLayerType, registerLayerTypeDetector, registerVolumeLayerType, UserLayer } from "#src/layer/index.js"; +import type { + ManagedUserLayer, + UserLayerSelectionState, +} from "#src/layer/index.js"; +import { + registerLayerType, + registerLayerTypeDetector, + registerVolumeLayerType, + UserLayer, +} from "#src/layer/index.js"; import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; import { registerVoxelLayerControls } from "#src/layer/vox/controls.js"; import { UserLayerWithVoxelEditingMixin } from "#src/layer/vox/index.js"; import { Overlay } from "#src/overlay.js"; import type { RenderLayerTransformOrError } from "#src/render_coordinate_transform.js"; import { getChannelSpace } from "#src/render_coordinate_transform.js"; -import { RenderScaleHistogram, trackableRenderScaleTarget } from "#src/render_scale_statistics.js"; +import { + RenderScaleHistogram, + trackableRenderScaleTarget, +} from "#src/render_scale_statistics.js"; import { DataType, VolumeType } from "#src/sliceview/volume/base.js"; import { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; import { defineImageLayerShader, getTrackableFragmentMain, - ImageRenderLayer + ImageRenderLayer, } from "#src/sliceview/volume/image_renderlayer.js"; import { trackableAlphaValue } from "#src/trackable_alpha.js"; import { BLEND_MODES, trackableBlendModeValue } from "#src/trackable_blend.js"; @@ -50,7 +61,7 @@ import { makeDerivedWatchableValue, registerNested, TrackableValue, - WatchableValue + WatchableValue, } from "#src/trackable_value.js"; import { UserLayerWithAnnotationsMixin } from "#src/ui/annotations.js"; import { registerVoxelTools } from "#src/ui/voxel_annotations.js"; @@ -61,28 +72,43 @@ import { verifyFloat01, verifyOptionalObjectProperty } from "#src/util/json.js"; import { TrackableEnum } from "#src/util/trackable_enum.js"; import { trackableShaderModeValue, - VolumeRenderingModes + VolumeRenderingModes, } from "#src/volume_rendering/trackable_volume_rendering_mode.js"; import { getVolumeRenderingDepthSamplesBoundsLogScale, VOLUME_RENDERING_DEPTH_SAMPLES_DEFAULT_VALUE, - VolumeRenderingRenderLayer + VolumeRenderingRenderLayer, } from "#src/volume_rendering/volume_render_layer.js"; import type { ParameterizedShaderGetterResult } from "#src/webgl/dynamic_shader.js"; import { makeWatchableShaderError } from "#src/webgl/dynamic_shader.js"; import type { ShaderControlsBuilderState } from "#src/webgl/shader_ui_controls.js"; -import { setControlsInShader, ShaderControlState } from "#src/webgl/shader_ui_controls.js"; +import { + setControlsInShader, + ShaderControlState, +} from "#src/webgl/shader_ui_controls.js"; import { ChannelDimensionsWidget } from "#src/widget/channel_dimensions_widget.js"; import { makeCopyButton } from "#src/widget/copy_button.js"; import type { DependentViewContext } from "#src/widget/dependent_view_widget.js"; import type { LayerControlDefinition } from "#src/widget/layer_control.js"; -import { addLayerControlToOptionsTab, registerLayerControl } from "#src/widget/layer_control.js"; +import { + addLayerControlToOptionsTab, + registerLayerControl, +} from "#src/widget/layer_control.js"; import { enumLayerControl } from "#src/widget/layer_control_enum.js"; import { rangeLayerControl } from "#src/widget/layer_control_range.js"; -import { renderScaleLayerControl, VolumeRenderingRenderScaleWidget } from "#src/widget/render_scale_widget.js"; -import { makeShaderCodeWidgetTopRow, ShaderCodeWidget } from "#src/widget/shader_code_widget.js"; +import { + renderScaleLayerControl, + VolumeRenderingRenderScaleWidget, +} from "#src/widget/render_scale_widget.js"; +import { + makeShaderCodeWidgetTopRow, + ShaderCodeWidget, +} from "#src/widget/shader_code_widget.js"; import type { LegendShaderOptions } from "#src/widget/shader_controls.js"; -import { registerLayerShaderControlsTool, ShaderControls } from "#src/widget/shader_controls.js"; +import { + registerLayerShaderControlsTool, + ShaderControls, +} from "#src/widget/shader_controls.js"; import { Tab } from "#src/widget/tab_view.js"; const OPACITY_JSON_KEY = "opacity"; @@ -272,21 +298,19 @@ void main() { dataType = volume.dataType; loadedSubsource.activate((context) => { const imageRenderLayer = new ImageRenderLayer(volume, { - opacity: this.opacity, - blendMode: this.blendMode, - shaderControlState: this.shaderControlState, - shaderError: this.shaderError, - transform: loadedSubsource.getRenderLayerTransform( - this.channelCoordinateSpace, - ), - renderScaleTarget: this.sliceViewRenderScaleTarget, - renderScaleHistogram: this.sliceViewRenderScaleHistogram, - localPosition: this.localPosition, - channelCoordinateSpace: this.channelCoordinateSpace, - }); - loadedSubsource.addRenderLayer( - imageRenderLayer - ); + opacity: this.opacity, + blendMode: this.blendMode, + shaderControlState: this.shaderControlState, + shaderError: this.shaderError, + transform: loadedSubsource.getRenderLayerTransform( + this.channelCoordinateSpace, + ), + renderScaleTarget: this.sliceViewRenderScaleTarget, + renderScaleHistogram: this.sliceViewRenderScaleHistogram, + localPosition: this.localPosition, + channelCoordinateSpace: this.channelCoordinateSpace, + }); + loadedSubsource.addRenderLayer(imageRenderLayer); const volumeRenderLayer = context.registerDisposer( new VolumeRenderingRenderLayer({ gain: this.volumeRenderingGain, @@ -316,14 +340,19 @@ void main() { }, this.volumeRenderingMode), ); this.shaderError.changed.dispatch(); - context.registerDisposer(registerNested((context, isWritable) => { - if (isWritable) { - this.initializeVoxelEditingForSubsource(loadedSubsource, imageRenderLayer); - context.registerDisposer(() => { - this.deinitializeVoxelEditingForSubsource(loadedSubsource); - }); - } - }, loadedSubsource.writable)); + context.registerDisposer( + registerNested((context, isWritable) => { + if (isWritable) { + this.initializeVoxelEditingForSubsource( + loadedSubsource, + imageRenderLayer, + ); + context.registerDisposer(() => { + this.deinitializeVoxelEditingForSubsource(loadedSubsource); + }); + } + }, loadedSubsource.writable), + ); }); } this.dataType.value = dataType; diff --git a/src/layer/layer_data_source.ts b/src/layer/layer_data_source.ts index cfc7a20ddc..d1166c5f61 100644 --- a/src/layer/layer_data_source.ts +++ b/src/layer/layer_data_source.ts @@ -181,8 +181,13 @@ export class LoadedDataSubsource { ), } = subsourceEntry; this.enabled = enabled; - this.writable = new TrackableBoolean(subsourceSpec?.writable ?? false, false); - this.writable.changed.add(loadedDataSource.layer.dataSourcesChanged.dispatch); + this.writable = new TrackableBoolean( + subsourceSpec?.writable ?? false, + false, + ); + this.writable.changed.add( + loadedDataSource.layer.dataSourcesChanged.dispatch, + ); this.subsourceToModelSubspaceTransform = subsourceToModelSubspaceTransform; this.modelSubspaceDimensionIndices = modelSubspaceDimensionIndices; this.isActiveChanged.add( diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 8b4704d505..3a04ed89a3 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -92,7 +92,8 @@ import { trackableAlphaValue } from "#src/trackable_alpha.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; import type { TrackableValueInterface, - WatchableValueInterface} from "#src/trackable_value.js"; + WatchableValueInterface, +} from "#src/trackable_value.js"; import { registerNested, IndirectTrackableValue, @@ -793,29 +794,29 @@ export class SegmentationUserLayer extends Base { continue; } hasVolume = true; - loadedSubsource.activate( - (context) => { - const segmentationRenderLayer = new SegmentationRenderLayer(volume, { - ...this.displayState, - transform: loadedSubsource.getRenderLayerTransform(), - renderScaleTarget: this.sliceViewRenderScaleTarget, - renderScaleHistogram: this.sliceViewRenderScaleHistogram, - localPosition: this.localPosition, - }); - loadedSubsource.addRenderLayer( - segmentationRenderLayer - ) - context.registerDisposer(registerNested((context, isWritable) => { + loadedSubsource.activate((context) => { + const segmentationRenderLayer = new SegmentationRenderLayer(volume, { + ...this.displayState, + transform: loadedSubsource.getRenderLayerTransform(), + renderScaleTarget: this.sliceViewRenderScaleTarget, + renderScaleHistogram: this.sliceViewRenderScaleHistogram, + localPosition: this.localPosition, + }); + loadedSubsource.addRenderLayer(segmentationRenderLayer); + context.registerDisposer( + registerNested((context, isWritable) => { if (isWritable) { - this.initializeVoxelEditingForSubsource(loadedSubsource, segmentationRenderLayer); + this.initializeVoxelEditingForSubsource( + loadedSubsource, + segmentationRenderLayer, + ); context.registerDisposer(() => { this.deinitializeVoxelEditingForSubsource(loadedSubsource); }); } - }, loadedSubsource.writable)); - }, - this.displayState.segmentationGroupState.value, - ); + }, loadedSubsource.writable), + ); + }, this.displayState.segmentationGroupState.value); } else if (mesh !== undefined) { loadedSubsource.activate(() => { const displayState = { diff --git a/src/layer/vox/controls.ts b/src/layer/vox/controls.ts index f6f573b020..3903532e0d 100644 --- a/src/layer/vox/controls.ts +++ b/src/layer/vox/controls.ts @@ -1,70 +1,75 @@ import type { UserLayerConstructor } from "#src/layer/index.js"; import { LayerActionContext } from "#src/layer/index.js"; import type { UserLayerWithVoxelEditing } from "#src/layer/vox/index.js"; -import type { - LayerControlDefinition} from "#src/widget/layer_control.js"; -import { - registerLayerControl, -} from "#src/widget/layer_control.js"; +import type { LayerControlDefinition } from "#src/widget/layer_control.js"; +import { registerLayerControl } from "#src/widget/layer_control.js"; import { buttonLayerControl } from "#src/widget/layer_control_button.js"; import { checkboxLayerControl } from "#src/widget/layer_control_checkbox.js"; import { enumLayerControl } from "#src/widget/layer_control_enum.js"; import { rangeLayerControl } from "#src/widget/layer_control_range.js"; -export const VOXEL_LAYER_CONTROLS: LayerControlDefinition[] = [ - { - label: "Brush size", - toolJson: { type: "vox-brush-size" }, - ...rangeLayerControl((layer) => ({ - value: layer.voxBrushRadius, - options: { min: 1, max: 64, step: 1 }, - })), - }, - { - label: "Eraser", - toolJson: { type: "vox-erase-mode" }, - ...checkboxLayerControl((layer) => layer.voxEraseMode), - }, - { - label: "Brush shape", - toolJson: { type: "vox-brush-shape" }, - ...enumLayerControl((layer: UserLayerWithVoxelEditing) => layer.voxBrushShape), - }, - { - label: "Max fill voxels", - toolJson: { type: "vox-flood-max-voxels" }, - ...rangeLayerControl((layer) => ({ - value: layer.voxFloodMaxVoxels, - options: { min: 1, max: 1000000, step: 1000 }, - })), - }, - { - label: "Undo", - toolJson: { type: "vox-undo" }, - ...buttonLayerControl({ - text: "Undo", - onClick: (layer) => layer.handleVoxAction("undo", new LayerActionContext()), - }), - }, - { - label: "Redo", - toolJson: { type: "vox-redo" }, - ...buttonLayerControl({ - text: "Redo", - onClick: (layer) => layer.handleVoxAction("redo", new LayerActionContext()), - }), - }, - { - label: "New label", - toolJson: { type: "vox-new-label" }, - ...buttonLayerControl({ - text: "New Label", - onClick: (layer) => layer.handleVoxAction("new-label", new LayerActionContext()), - }), - }, -]; +export const VOXEL_LAYER_CONTROLS: LayerControlDefinition[] = + [ + { + label: "Brush size", + toolJson: { type: "vox-brush-size" }, + ...rangeLayerControl((layer) => ({ + value: layer.voxBrushRadius, + options: { min: 1, max: 64, step: 1 }, + })), + }, + { + label: "Eraser", + toolJson: { type: "vox-erase-mode" }, + ...checkboxLayerControl((layer) => layer.voxEraseMode), + }, + { + label: "Brush shape", + toolJson: { type: "vox-brush-shape" }, + ...enumLayerControl( + (layer: UserLayerWithVoxelEditing) => layer.voxBrushShape, + ), + }, + { + label: "Max fill voxels", + toolJson: { type: "vox-flood-max-voxels" }, + ...rangeLayerControl((layer) => ({ + value: layer.voxFloodMaxVoxels, + options: { min: 1, max: 1000000, step: 1000 }, + })), + }, + { + label: "Undo", + toolJson: { type: "vox-undo" }, + ...buttonLayerControl({ + text: "Undo", + onClick: (layer) => + layer.handleVoxAction("undo", new LayerActionContext()), + }), + }, + { + label: "Redo", + toolJson: { type: "vox-redo" }, + ...buttonLayerControl({ + text: "Redo", + onClick: (layer) => + layer.handleVoxAction("redo", new LayerActionContext()), + }), + }, + { + label: "New label", + toolJson: { type: "vox-new-label" }, + ...buttonLayerControl({ + text: "New Label", + onClick: (layer) => + layer.handleVoxAction("new-label", new LayerActionContext()), + }), + }, + ]; -export function registerVoxelLayerControls(layerType: UserLayerConstructor) { +export function registerVoxelLayerControls( + layerType: UserLayerConstructor, +) { for (const control of VOXEL_LAYER_CONTROLS) { registerLayerControl(layerType, control); } diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 2b9f0d4663..906947416f 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import type { LayerActionContext, MouseSelectionState, UserLayer } from "#src/layer/index.js" +import type { + LayerActionContext, + MouseSelectionState, + UserLayer, +} from "#src/layer/index.js"; import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; import { VoxToolTab } from "#src/layer/vox/tabs/tools.js"; import type { @@ -26,21 +30,13 @@ import { getChunkTransformParameters, } from "#src/render_coordinate_transform.js"; import type { SliceViewSourceOptions } from "#src/sliceview/base.js"; -import type { - MultiscaleVolumeChunkSource, -} from "#src/sliceview/volume/frontend.js"; +import type { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; import type { ImageRenderLayer } from "#src/sliceview/volume/image_renderlayer.js"; import type { SegmentationRenderLayer } from "#src/sliceview/volume/segmentation_renderlayer.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; -import type { - WatchableValueInterface} from "#src/trackable_value.js"; -import { - TrackableValue, - WatchableValue -} from "#src/trackable_value.js"; -import type { - UserLayerWithAnnotations, -} from "#src/ui/annotations.js"; +import type { WatchableValueInterface } from "#src/trackable_value.js"; +import { TrackableValue, WatchableValue } from "#src/trackable_value.js"; +import type { UserLayerWithAnnotations } from "#src/ui/annotations.js"; import { RefCounted } from "#src/util/disposable.js"; import { verifyFiniteFloat, verifyInt } from "#src/util/json.js"; import { NullarySignal } from "#src/util/signal.js"; @@ -65,7 +61,6 @@ export class VoxelEditingContext private cachedTransformGeneration: number = -1; private cachedVoxelPosition: Float32Array = new Float32Array(3); - constructor( public hostLayer: UserLayerWithVoxelEditing, public primarySource: MultiscaleVolumeChunkSource, @@ -164,7 +159,10 @@ export declare abstract class UserLayerWithVoxelEditing extends UserLayer { transform: WatchableValueInterface, ): ImageRenderLayer | SegmentationRenderLayer; - initializeVoxelEditingForSubsource(loadedSubsource: LoadedDataSubsource, renderlayer: SegmentationRenderLayer | ImageRenderLayer): void; + initializeVoxelEditingForSubsource( + loadedSubsource: LoadedDataSubsource, + renderlayer: SegmentationRenderLayer | ImageRenderLayer, + ): void; deinitializeVoxelEditingForSubsource( loadedSubsource: LoadedDataSubsource, ): void; @@ -189,7 +187,6 @@ export function UserLayerWithVoxelEditingMixin< voxBrushShape = new TrackableEnum(BrushShape, BrushShape.DISK); voxFloodMaxVoxels = new TrackableValue(10000, verifyFiniteFloat); - voxDrawErrorMessage: string | undefined = undefined; onDrawMessageChanged?: () => void; setDrawErrorMessage(message: string | undefined): void { @@ -221,8 +218,10 @@ export function UserLayerWithVoxelEditingMixin< transform: WatchableValueInterface, ): ImageRenderLayer | SegmentationRenderLayer; - - initializeVoxelEditingForSubsource(loadedSubsource: LoadedDataSubsource, renderlayer: SegmentationRenderLayer | ImageRenderLayer): void { + initializeVoxelEditingForSubsource( + loadedSubsource: LoadedDataSubsource, + renderlayer: SegmentationRenderLayer | ImageRenderLayer, + ): void { if (this.editingContexts.has(loadedSubsource)) return; const primarySource = loadedSubsource.subsourceEntry.subsource @@ -240,7 +239,7 @@ export function UserLayerWithVoxelEditingMixin< const previewSource = new VoxelPreviewMultiscaleSource( this.manager.chunkManager, - primarySource + primarySource, ); const transform = loadedSubsource.getRenderLayerTransform(); @@ -255,13 +254,12 @@ export function UserLayerWithVoxelEditingMixin< primarySource, previewSource, optimisticRenderLayer, - renderlayer + renderlayer, ); this.editingContexts.set(loadedSubsource, context); this.addRenderLayer(optimisticRenderLayer); } - deinitializeVoxelEditingForSubsource(loadedSubsource: LoadedDataSubsource) { const context = this.editingContexts.get(loadedSubsource); if (context) { diff --git a/src/layer/vox/tabs/tools.ts b/src/layer/vox/tabs/tools.ts index a44670b56e..5bcf2193c7 100644 --- a/src/layer/vox/tabs/tools.ts +++ b/src/layer/vox/tabs/tools.ts @@ -50,7 +50,6 @@ export class VoxToolTab extends Tab { const { element } = this; element.classList.add("neuroglancer-vox-tools-tab"); - const toolbox = document.createElement("div"); toolbox.className = "neuroglancer-vox-toolbox"; @@ -86,7 +85,6 @@ export class VoxToolTab extends Tab { toolsRow.appendChild(toolButtonsContainer); toolbox.appendChild(toolsRow); - for (const controlDef of VOXEL_LAYER_CONTROLS) { const controlElement = addLayerControlToOptionsTab( this, @@ -109,7 +107,11 @@ export class VoxToolTab extends Tab { return layer.editingContexts.values().next().value.controller; }, }, - (controller: VoxelEditController | undefined, _parent, context) => { + ( + controller: VoxelEditController | undefined, + _parent, + context, + ) => { if (!controller) { button.disabled = true; return; @@ -143,7 +145,6 @@ export class VoxToolTab extends Tab { labelsTitle.textContent = "Labels"; labelsTitle.style.fontWeight = "600"; - const labelsWidget = this.registerDisposer( new DependentViewWidget( { diff --git a/src/sliceview/volume/backend.ts b/src/sliceview/volume/backend.ts index e9ab9657ee..2b9a134b66 100644 --- a/src/sliceview/volume/backend.ts +++ b/src/sliceview/volume/backend.ts @@ -16,7 +16,10 @@ import type { Chunk } from "#src/chunk_manager/backend.js"; import { ChunkState } from "#src/chunk_manager/base.js"; -import { SliceViewChunk, SliceViewChunkSourceBackend } from "#src/sliceview/backend.js"; +import { + SliceViewChunk, + SliceViewChunkSourceBackend, +} from "#src/sliceview/backend.js"; import type { SliceViewChunkSpecification } from "#src/sliceview/base.js"; import { DataType } from "#src/sliceview/base.js"; import { decodeChannel as decodeChannelUint32 } from "#src/sliceview/compressed_segmentation/decode_uint32.js"; @@ -25,10 +28,10 @@ import { encodeChannel as encodeChannelUint32 } from "#src/sliceview/compressed_ import { encodeChannel as encodeChannelUint64 } from "#src/sliceview/compressed_segmentation/encode_uint64.js"; import type { VolumeChunkSource as VolumeChunkSourceInterface, - VolumeChunkSpecification + VolumeChunkSpecification, } from "#src/sliceview/volume/base.js"; import { IN_MEMORY_VOLUME_CHUNK_SOURCE_RPC_ID } from "#src/sliceview/volume/base.js"; -import type { TypedArray} from "#src/util/array.js"; +import type { TypedArray } from "#src/util/array.js"; import { TypedArrayBuilder } from "#src/util/array.js"; import { DATA_TYPE_ARRAY_CONSTRUCTOR } from "#src/util/data_type.js"; import type { vec3 } from "#src/util/geom.js"; @@ -332,7 +335,7 @@ export class VolumeChunkSource export class InMemoryVolumeChunkSourceBackend extends VolumeChunkSource { async download(chunk: VolumeChunk, _signal: AbortSignal): Promise { chunk.data = null; - return new Promise(((_resolve) => {})) + return new Promise((_resolve) => {}); } } diff --git a/src/sliceview/volume/base.ts b/src/sliceview/volume/base.ts index 839373e2ab..f5c5e49b74 100644 --- a/src/sliceview/volume/base.ts +++ b/src/sliceview/volume/base.ts @@ -310,4 +310,5 @@ export interface VolumeChunkSource extends SliceViewChunkSource { } export const VOLUME_RPC_ID = "volume"; -export const IN_MEMORY_VOLUME_CHUNK_SOURCE_RPC_ID = "sliceview/volume/InMemoryChunkSource"; +export const IN_MEMORY_VOLUME_CHUNK_SOURCE_RPC_ID = + "sliceview/volume/InMemoryChunkSource"; diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index 518958ab9c..30f64172c1 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -16,28 +16,24 @@ import type { ChunkManager } from "#src/chunk_manager/frontend.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; -import type { - SliceViewChunkSpecification} from "#src/sliceview/base.js"; +import type { SliceViewChunkSpecification } from "#src/sliceview/base.js"; import { - DataType, SLICEVIEW_REQUEST_CHUNK_RPC_ID } from "#src/sliceview/base.js"; -import type { - SliceViewChunk, -} from "#src/sliceview/frontend.js"; + DataType, + SLICEVIEW_REQUEST_CHUNK_RPC_ID, +} from "#src/sliceview/base.js"; +import type { SliceViewChunk } from "#src/sliceview/frontend.js"; import { MultiscaleSliceViewChunkSource, SliceViewChunkSource, } from "#src/sliceview/frontend.js"; -import type { - UncompressedVolumeChunk, -} from "#src/sliceview/uncompressed_chunk_format.js"; +import type { UncompressedVolumeChunk } from "#src/sliceview/uncompressed_chunk_format.js"; import type { VolumeChunkSource as VolumeChunkSourceInterface, VolumeChunkSpecification, VolumeSourceOptions, - VolumeType} from "#src/sliceview/volume/base.js"; -import { - IN_MEMORY_VOLUME_CHUNK_SOURCE_RPC_ID + VolumeType, } from "#src/sliceview/volume/base.js"; +import { IN_MEMORY_VOLUME_CHUNK_SOURCE_RPC_ID } from "#src/sliceview/volume/base.js"; import { VolumeChunk } from "#src/sliceview/volume/chunk.js"; import { getChunkFormatHandler } from "#src/sliceview/volume/registry.js"; import type { TypedArray } from "#src/util/array.js"; @@ -48,7 +44,6 @@ import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; import { getShaderType, glsl_mixLinear } from "#src/webgl/shader_lib.js"; import { registerSharedObjectOwner } from "#src/worker_rpc.js"; - export interface ChunkFormat { shaderKey: string; @@ -315,7 +310,10 @@ export class VolumeChunkSource @registerSharedObjectOwner(IN_MEMORY_VOLUME_CHUNK_SOURCE_RPC_ID) export class InMemoryVolumeChunkSource extends VolumeChunkSource { - constructor(chunkManager: ChunkManager, options: { spec: VolumeChunkSpecification }) { + constructor( + chunkManager: ChunkManager, + options: { spec: VolumeChunkSpecification }, + ) { super(chunkManager, options); this.initializeCounterpart(this.chunkManager.rpc!, {}); } @@ -330,25 +328,27 @@ export class InMemoryVolumeChunkSource extends VolumeChunkSource { invalidateChunks(keys: string[]): void { const update = () => { - const validKeys: string[] = []; - for (const key of keys) { - const chunk = this.chunks.get(key); - if (chunk) { - validKeys.push(key); - this.deleteChunk(key); + const validKeys: string[] = []; + for (const key of keys) { + const chunk = this.chunks.get(key); + if (chunk) { + validKeys.push(key); + this.deleteChunk(key); + } } - } - if (validKeys.length > 0) { - this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); - } - } + if (validKeys.length > 0) { + this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); + } + }; // adding a small delay to avoid flickering since the base source will take some time to download the new data + // TODO: it would be better to reload the preview once the base source is good, with big brushes this delay is not sufficient setTimeout(update, 100); } - - applyLocalEdits(edits: Map): void { + applyLocalEdits( + edits: Map, + ): void { const chunksToUpdate = new Set(); const { dataType } = this.spec; @@ -357,7 +357,9 @@ export class InMemoryVolumeChunkSource extends VolumeChunkSource { let chunk = this.chunks.get(key) as UncompressedVolumeChunk | undefined; if (chunk === undefined) { - chunk = this.getChunk({ chunkGridPosition: chunkGridPosition }) as UncompressedVolumeChunk; + chunk = this.getChunk({ + chunkGridPosition: chunkGridPosition, + }) as UncompressedVolumeChunk; this.addChunk(key, chunk); } @@ -386,7 +388,9 @@ export class InMemoryVolumeChunkSource extends VolumeChunkSource { (cpuArray as BigUint64Array)[index] = value; break; default: - console.warn(`Unsupported data type for editing: ${DataType[dataType]}`); + console.warn( + `Unsupported data type for editing: ${DataType[dataType]}`, + ); break; } } diff --git a/src/ui/layer_data_sources_tab.ts b/src/ui/layer_data_sources_tab.ts index 143ef2eaae..cc92773039 100644 --- a/src/ui/layer_data_sources_tab.ts +++ b/src/ui/layer_data_sources_tab.ts @@ -41,11 +41,11 @@ import { ElementVisibilityFromTrackableBoolean, TrackableBooleanCheckbox, } from "#src/trackable_boolean.js"; -import type { - WatchableValueInterface} from "#src/trackable_value.js"; +import type { WatchableValueInterface } from "#src/trackable_value.js"; import { - makeCachedDerivedWatchableValue -, WatchableValue } from "#src/trackable_value.js"; + makeCachedDerivedWatchableValue, + WatchableValue, +} from "#src/trackable_value.js"; import type { DebouncedFunction } from "#src/util/animation_frame_debounce.js"; import { animationFrameDebounce } from "#src/util/animation_frame_debounce.js"; import { DataType } from "#src/util/data_type.js"; @@ -199,7 +199,9 @@ export class DataSourceSubsourceView extends RefCounted { this.registerDisposer( loadedSource.enabledSubsourcesChanged.add(updateActiveAttribute), ); - const enabledState: WatchableValueInterface & { set value(v: boolean) } = { + const enabledState: WatchableValueInterface & { + set value(v: boolean); + } = { get value() { return loadedSubsource.enabled; }, @@ -217,7 +219,10 @@ export class DataSourceSubsourceView extends RefCounted { sourceInfoLine.classList.add("neuroglancer-layer-data-sources-info-line"); sourceInfoLine.appendChild(enabledCheckbox.element); - if (loadedSubsource.subsourceEntry.subsource.volume instanceof MultiscaleVolumeChunkSource) { + if ( + loadedSubsource.subsourceEntry.subsource.volume instanceof + MultiscaleVolumeChunkSource + ) { const writableCheckbox = this.registerDisposer( new TrackableBooleanCheckbox(loadedSubsource.writable), ); @@ -227,17 +232,22 @@ export class DataSourceSubsourceView extends RefCounted { writableLabel.appendChild(writableCheckbox.element); writableLabel.appendChild(document.createTextNode("[Writable?]")); - - this.registerDisposer(new ElementVisibilityFromTrackableBoolean( - makeCachedDerivedWatchableValue( - (enabled, isPotentiallyWritable) => enabled && isPotentiallyWritable, - [ - enabledState, - new WatchableValue(loadedSubsource.subsourceEntry.subsource.isPotentiallyWritable ?? false), - ], + this.registerDisposer( + new ElementVisibilityFromTrackableBoolean( + makeCachedDerivedWatchableValue( + (enabled, isPotentiallyWritable) => + enabled && isPotentiallyWritable, + [ + enabledState, + new WatchableValue( + loadedSubsource.subsourceEntry.subsource + .isPotentiallyWritable ?? false, + ), + ], + ), + writableLabel, ), - writableLabel, - )); + ); sourceInfoLine.appendChild(writableLabel); } diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 036bf5406b..04cf3ce5b8 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -96,19 +96,18 @@ abstract class BaseVoxelTool extends LayerTool { activation.bindAction("paint-voxels", (event) => { event.stopPropagation(); this.activationCallback(activation); - startRelativeMouseDrag( - event.detail as MouseEvent, - () => { - this.latestMouseState = this.mouseState; - }, - () => { - this.deactivationCallback(activation); - }, - ); + startRelativeMouseDrag( + event.detail as MouseEvent, + () => { + this.latestMouseState = this.mouseState; + }, + () => { + this.deactivationCallback(activation); + }, + ); - return true; + return true; }); - } abstract activationCallback(activation: ToolActivation): void; @@ -137,7 +136,9 @@ export class VoxelBrushTool extends BaseVoxelTool { activate(activation: ToolActivation) { super.activate(activation); const getZoom = () => { - const panels = Array.from(this.layer.manager.root.display.panels) as RenderedDataPanel[]; + const panels = Array.from( + this.layer.manager.root.display.panels, + ) as RenderedDataPanel[]; if (panels.length > 0) { return panels[0].navigationState.zoomFactor.value; } @@ -145,8 +146,12 @@ export class VoxelBrushTool extends BaseVoxelTool { }; const getZoomChangedSignal = () => { - const panels = Array.from(this.layer.manager.root.display.panels) as RenderedDataPanel[]; - return panels.length > 0 ? panels[0].navigationState.zoomFactor.changed : new NullarySignal(); + const panels = Array.from( + this.layer.manager.root.display.panels, + ) as RenderedDataPanel[]; + return panels.length > 0 + ? panels[0].navigationState.zoomFactor.changed + : new NullarySignal(); }; const updateCursor = () => { @@ -166,11 +171,13 @@ export class VoxelBrushTool extends BaseVoxelTool { `.replace(/\s\s+/g, " "); const cursorURL = `url('data:image/svg+xml;utf8,${encodeURIComponent(svgString)}')`; - this.setCursor(`${cursorURL} ${svgCenter} ${svgCenter}, crosshair`) + this.setCursor(`${cursorURL} ${svgCenter} ${svgCenter}, crosshair`); }; updateCursor(); - activation.registerDisposer(this.layer.voxBrushRadius.changed.add(updateCursor)); + activation.registerDisposer( + this.layer.voxBrushRadius.changed.add(updateCursor), + ); activation.registerDisposer(getZoomChangedSignal().add(updateCursor)); activation.registerDisposer(() => { this.resetCursor(); @@ -309,11 +316,18 @@ export class VoxelBrushTool extends BaseVoxelTool { throw new Error("editContext is undefined"); } for (const p of points) - editContext.controller?.paintBrushWithShape(p, radius, value, shapeEnum, basis); + editContext.controller?.paintBrushWithShape( + p, + radius, + value, + shapeEnum, + basis, + ); } } -const floodFillSVG = ` @@ -335,12 +349,13 @@ const floodFillSVG = ` { return; } - const visibleSources = renderLayer.visibleSourcesList; if (visibleSources.length === 0) { StatusMessage.showTemporaryMessage( @@ -510,7 +528,19 @@ export class AdoptVoxelLabelTool extends LayerTool { } export function registerVoxelTools(LayerCtor: any) { - registerTool(LayerCtor, BRUSH_TOOL_ID, (layer: UserLayerWithVoxelEditing) => new VoxelBrushTool(layer)); - registerTool(LayerCtor, FLOODFILL_TOOL_ID, (layer: UserLayerWithVoxelEditing) => new VoxelFloodFillTool(layer)); - registerTool(LayerCtor, ADOPT_VOXEL_LABEL_TOOL_ID, (layer: UserLayerWithVoxelEditing) => new AdoptVoxelLabelTool(layer)); + registerTool( + LayerCtor, + BRUSH_TOOL_ID, + (layer: UserLayerWithVoxelEditing) => new VoxelBrushTool(layer), + ); + registerTool( + LayerCtor, + FLOODFILL_TOOL_ID, + (layer: UserLayerWithVoxelEditing) => new VoxelFloodFillTool(layer), + ); + registerTool( + LayerCtor, + ADOPT_VOXEL_LABEL_TOOL_ID, + (layer: UserLayerWithVoxelEditing) => new AdoptVoxelLabelTool(layer), + ); } diff --git a/src/voxel_annotation/PreviewMultiscaleChunkSource.ts b/src/voxel_annotation/PreviewMultiscaleChunkSource.ts index 02b515e9de..43f3b63153 100644 --- a/src/voxel_annotation/PreviewMultiscaleChunkSource.ts +++ b/src/voxel_annotation/PreviewMultiscaleChunkSource.ts @@ -1,10 +1,31 @@ +/** + * @license + * Copyright 2025 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 type { ChunkManager } from "#src/chunk_manager/frontend.js"; import type { SliceViewSingleResolutionSource } from "#src/sliceview/frontend.js"; -import type { VolumeChunkSpecification, VolumeSourceOptions , DataType, VolumeType } from "#src/sliceview/volume/base.js"; +import type { + VolumeChunkSpecification, + VolumeSourceOptions, + DataType, + VolumeType, +} from "#src/sliceview/volume/base.js"; import { InMemoryVolumeChunkSource, MultiscaleVolumeChunkSource, - type VolumeChunkSource + type VolumeChunkSource, } from "#src/sliceview/volume/frontend.js"; export class VoxelPreviewMultiscaleSource extends MultiscaleVolumeChunkSource { @@ -27,8 +48,8 @@ export class VoxelPreviewMultiscaleSource extends MultiscaleVolumeChunkSource { ): SliceViewSingleResolutionSource[][] { const sourcesByScale = this.primarySource.getSources(options); - return sourcesByScale.map(orientation => { - return orientation.map(primaryResSource => { + return sourcesByScale.map((orientation) => { + return orientation.map((primaryResSource) => { const spec = primaryResSource.chunkSource.spec; const previewSpec: VolumeChunkSpecification = { @@ -43,7 +64,8 @@ export class VoxelPreviewMultiscaleSource extends MultiscaleVolumeChunkSource { return { chunkSource: previewSource, - chunkToMultiscaleTransform: primaryResSource.chunkToMultiscaleTransform, + chunkToMultiscaleTransform: + primaryResSource.chunkToMultiscaleTransform, }; }); }); diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 0b093b5942..69b2e4e11d 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -1,21 +1,22 @@ - ## TODOs ### priority + - url completion for the ssa+https source - should we support compressed chunks? if yes, we should find a better way to handle them. ### later + - optimize flood fill tool (it is too slow on area containing uncached chunks, due to the getEnsuredValueAt() calls) - the flood fill sometimes leaves artifacts in sharp areas (maybe increase fillBorderRegion() radius) - write a testsuite for the downsampler and ensure its proper working on exotic lod levels ### questionable + - design a dataset creation feature - adapt the brush size to the zoom level linearly - ## Merging vox layer into seg and img layers The proposed architecture integrates voxel editing directly into the existing Image and Segmentation layers by leveraging their inherent capabilities, rather than introducing a separate, simplified render layer. The core of the design is a new UserLayerWithVoxelEditingMixin which equips a host UserLayer with an editing controller and an associated in-memory VolumeChunkSource for optimistic previews. When a user paints, the edits are applied locally to this in-memory source. A second instance of the layer's primary, feature-rich RenderLayer class is then used to draw these edits as an overlay. This ensures the live preview is rendered with the exact same user-defined shaders and settings as the base data for perfect visual fidelity, while also elegantly handling the performance issue of editing compressed chunks by operating on an uncompressed in-memory source. This architecture reuses existing components, simplifies the overall codebase by eliminating the need for a separate VoxelAnnotationRenderLayer, and cleanly separates the concerns of displaying committed data versus previewing transient edits. diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/edit_backend.ts index 79b97c2bd5..5d8e0f4db2 100644 --- a/src/voxel_annotation/edit_backend.ts +++ b/src/voxel_annotation/edit_backend.ts @@ -16,7 +16,7 @@ import { DataType } from "#src/sliceview/base.js"; import { decodeChannel as decodeChannelUint32 } from "#src/sliceview/compressed_segmentation/decode_uint32.js"; -import { decodeChannel as decodeChannelUint64 } from "#src/sliceview/compressed_segmentation/decode_uint64.js" +import { decodeChannel as decodeChannelUint64 } from "#src/sliceview/compressed_segmentation/decode_uint64.js"; import type { VolumeChunkSource } from "#src/sliceview/volume/backend.js"; import { mat4, vec3 } from "#src/util/geom.js"; import * as matrix from "#src/util/matrix.js"; @@ -341,21 +341,36 @@ export class VoxelEditController extends SharedObject { const { parentKey, parentSource, parentRes } = parentInfo; let dataToProcess = childChunkData; - const { compressedSegmentationBlockSize, dataType, chunkDataSize } = childSource.spec; + const { compressedSegmentationBlockSize, dataType, chunkDataSize } = + childSource.spec; if (compressedSegmentationBlockSize !== undefined) { - const numElements = chunkDataSize[0] * chunkDataSize[1] * chunkDataSize[2]; + const numElements = + chunkDataSize[0] * chunkDataSize[1] * chunkDataSize[2]; const compressedData = childChunkData as Uint32Array; const baseOffset = compressedData.length > 0 ? compressedData[0] : 0; if (dataType === DataType.UINT32) { const uncompressedData = new Uint32Array(numElements); if (baseOffset !== 0) { - decodeChannelUint32(uncompressedData, compressedData, baseOffset, chunkDataSize, compressedSegmentationBlockSize); + decodeChannelUint32( + uncompressedData, + compressedData, + baseOffset, + chunkDataSize, + compressedSegmentationBlockSize, + ); } dataToProcess = uncompressedData; - } else { // Assumes UINT64 + } else { + // Assumes UINT64 const uncompressedData = new BigUint64Array(numElements); if (baseOffset !== 0) { - decodeChannelUint64(uncompressedData, compressedData, baseOffset, chunkDataSize, compressedSegmentationBlockSize); + decodeChannelUint64( + uncompressedData, + compressedData, + baseOffset, + chunkDataSize, + compressedSegmentationBlockSize, + ); } dataToProcess = uncompressedData; } diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 474a73d472..f4726c6e8c 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -38,8 +38,7 @@ import { parseVoxChunkKey, } from "#src/voxel_annotation/base.js"; import type { LabelsManager } from "#src/voxel_annotation/labels.js"; -import type { - RPC} from "#src/worker_rpc.js"; +import type { RPC } from "#src/worker_rpc.js"; import { registerRPC, registerSharedObjectOwner, @@ -59,9 +58,7 @@ export class VoxelEditController extends SharedObject { public undoCount = new WatchableValue(0); public redoCount = new WatchableValue(0); - constructor( - private host: VoxelEditControllerHost - ) { + constructor(private host: VoxelEditControllerHost) { super(); const rpc = this.host.rpc; if (!rpc) { @@ -182,11 +179,11 @@ export class VoxelEditController extends SharedObject { // For V1 we use the minimum LOD (index 0) const voxelSize = 1; const sourceIndex = 0; - const source = this.host.previewSource.getSources(this.getIdentitySliceViewSourceOptions())[0][sourceIndex]!.chunkSource as InMemoryVolumeChunkSource; + const source = this.host.previewSource.getSources( + this.getIdentitySliceViewSourceOptions(), + )[0][sourceIndex]!.chunkSource as InMemoryVolumeChunkSource; if (!source) { - throw new Error( - "paintBrushWithShape: Missing preview source", - ); + throw new Error("paintBrushWithShape: Missing preview source"); } // Convert center and radius to the level’s voxel grid. @@ -512,11 +509,11 @@ export class VoxelEditController extends SharedObject { } } - const previewSource = this.host.previewSource.getSources(this.getIdentitySliceViewSourceOptions())[0][sourceIndex]!.chunkSource as InMemoryVolumeChunkSource; + const previewSource = this.host.previewSource.getSources( + this.getIdentitySliceViewSourceOptions(), + )[0][sourceIndex]!.chunkSource as InMemoryVolumeChunkSource; if (!previewSource) { - throw new Error( - "paintBrushWithShape: Missing preview source", - ); + throw new Error("paintBrushWithShape: Missing preview source"); } const editsByVoxKey = new Map< string, @@ -565,8 +562,7 @@ export class VoxelEditController extends SharedObject { const baseSources = this.host.primarySource.getSources( this.getIdentitySliceViewSourceOptions(), )[0]; - if (!baseSources) - { + if (!baseSources) { throw new Error( "VoxelEditController.callChunkReload: Missing base source", ); @@ -591,8 +587,7 @@ export class VoxelEditController extends SharedObject { const previewSource = previewSources[parsed.lodIndex]?.chunkSource as | VolumeChunkSource | undefined; - if (previewSource) - { + if (previewSource) { let arr = chunksToInvalidateBySource.get(previewSource); if (!arr) { arr = []; @@ -600,8 +595,7 @@ export class VoxelEditController extends SharedObject { } arr.push(parsed.chunkKey); } - if (baseSource) - { + if (baseSource) { let arr = chunksToInvalidateBySource.get(baseSource); if (!arr) { arr = []; @@ -618,7 +612,6 @@ export class VoxelEditController extends SharedObject { } } - /** Backend failure notification handler: revert optimistic preview and show UI message. */ handleCommitFailure(voxChunkKeys: string[], message: string): void { try { this.callChunkReload(voxChunkKeys); @@ -630,7 +623,6 @@ export class VoxelEditController extends SharedObject { public undo(): void { if (!this.rpc) throw new Error("VoxelEditController.undo: RPC not initialized."); - console.log("VoxelEditController.undo"); this.rpc .promiseInvoke(VOX_EDIT_UNDO_RPC_ID, { rpcId: this.rpcId }) .catch((error: unknown) => { @@ -642,7 +634,6 @@ export class VoxelEditController extends SharedObject { public redo(): void { if (!this.rpc) throw new Error("VoxelEditController.redo: RPC not initialized."); - console.log("VoxelEditController.redo"); this.rpc .promiseInvoke(VOX_EDIT_REDO_RPC_ID, { rpcId: this.rpcId }) .catch((error: unknown) => { diff --git a/src/widget/layer_control_button.ts b/src/widget/layer_control_button.ts index a74af12c6a..2c41f1424d 100644 --- a/src/widget/layer_control_button.ts +++ b/src/widget/layer_control_button.ts @@ -25,7 +25,9 @@ export function buttonLayerControl(options: { makeControl: (layer, context) => { const control = document.createElement("button"); control.textContent = options.text; - context.registerEventListener(control, "click", () => options.onClick(layer)); + context.registerEventListener(control, "click", () => + options.onClick(layer), + ); return { control, controlElement: control }; }, activateTool: (activation) => { From 83316aaff3560a15a9da868210bab185f2f6bb45 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:45 +0100 Subject: [PATCH 100/251] feat: save writable state to json state --- src/layer/layer_data_source.ts | 4 +++- src/voxel_annotation/TODOs.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/layer/layer_data_source.ts b/src/layer/layer_data_source.ts index d1166c5f61..eb83a6023a 100644 --- a/src/layer/layer_data_source.ts +++ b/src/layer/layer_data_source.ts @@ -110,7 +110,8 @@ export function layerDataSourceSpecificationFromJson( } function dataSubsourceSpecificationToJson(spec: DataSubsourceSpecification) { - return spec.enabled; + const { enabled, writable } = spec; + return { enabled, writable }; } export function layerDataSourceSpecificationToJson( @@ -501,6 +502,7 @@ export class LayerDataSource extends RefCounted { loadedSubsource.enabled !== defaultEnabledValue ? loadedSubsource.enabled : undefined, + writable: loadedSubsource.writable.value ? true : undefined, }, ]; }), diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 69b2e4e11d..25736f9ec8 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -3,7 +3,9 @@ ### priority - url completion for the ssa+https source - +- add json state for every tools and settings of voxel painting +- replace the current label system with a new system linked to the segment in the segmantation layer and with a color picker for the image layer +- fix the case of multiple datasources in the same layer - should we support compressed chunks? if yes, we should find a better way to handle them. ### later From b7dec47b40e65c4d6ee30a063d229a664edb9e2e Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:45 +0100 Subject: [PATCH 101/251] refactor: replace label system with paint value mechanism and simplify voxel editing logic --- src/layer/vox/controls.ts | 66 ++++++++++++- src/layer/vox/index.ts | 57 ++++------- src/layer/vox/tabs/tools.ts | 125 ------------------------ src/ui/voxel_annotations.ts | 59 ++++------- src/voxel_annotation/TODOs.md | 6 +- src/voxel_annotation/edit_controller.ts | 5 +- 6 files changed, 104 insertions(+), 214 deletions(-) diff --git a/src/layer/vox/controls.ts b/src/layer/vox/controls.ts index 3903532e0d..8678ad09fe 100644 --- a/src/layer/vox/controls.ts +++ b/src/layer/vox/controls.ts @@ -1,13 +1,42 @@ import type { UserLayerConstructor } from "#src/layer/index.js"; import { LayerActionContext } from "#src/layer/index.js"; import type { UserLayerWithVoxelEditing } from "#src/layer/vox/index.js"; +import type { WatchableValueInterface } from "#src/trackable_value.js"; +import { observeWatchable } from "#src/trackable_value.js"; +import { unpackRGB } from "#src/util/color.js"; +import { RefCounted } from "#src/util/disposable.js"; +import { vec3 } from "#src/util/geom.js"; +import { NullarySignal } from "#src/util/signal.js"; import type { LayerControlDefinition } from "#src/widget/layer_control.js"; import { registerLayerControl } from "#src/widget/layer_control.js"; import { buttonLayerControl } from "#src/widget/layer_control_button.js"; import { checkboxLayerControl } from "#src/widget/layer_control_checkbox.js"; +import { colorLayerControl } from "#src/widget/layer_control_color.js"; import { enumLayerControl } from "#src/widget/layer_control_enum.js"; import { rangeLayerControl } from "#src/widget/layer_control_range.js"; +class BigIntAsTrackableRGB extends RefCounted implements WatchableValueInterface { + changed = new NullarySignal(); + private tempColor = vec3.create(); + + constructor(public source: WatchableValueInterface) { + super(); + this.registerDisposer(source.changed.add(this.changed.dispatch)); + } + + get value(): vec3 { + const bigintValue = this.source.value; + const [r, g, b] = unpackRGB(Number(bigintValue & 0xffffffn)); + vec3.set(this.tempColor, r, g, b); + return this.tempColor; + } + + set value(newValue: vec3) { + const rgb = newValue.map((c: number) => Math.round(c * 255)); + this.source.value = BigInt((rgb[0] << 16) | (rgb[1] << 8) | rgb[2]); + } +} + export const VOXEL_LAYER_CONTROLS: LayerControlDefinition[] = [ { @@ -57,12 +86,41 @@ export const VOXEL_LAYER_CONTROLS: LayerControlDefinition new BigIntAsTrackableRGB(layer.paintValue)), + }, + { + label: "Paint Value", + toolJson: { type: "vox-paint-value" }, + makeControl: (layer, context) => { + const control = document.createElement("input"); + control.type = "text"; + control.title = "Specify segment ID or intensity value to paint"; + control.addEventListener("change", () => { + try { + layer.setVoxelPaintValue(BigInt(control.value)); + } catch { + control.value = layer.paintValue.value.toString(); + } + }); + context.registerDisposer( + observeWatchable((value) => { + control.value = value.toString(); + }, layer.paintValue), + ); + control.value = layer.paintValue.value.toString(); + return { control, controlElement: control, parent: context }; + }, + activateTool: () => {}, + }, + { + label: "New Random Value", + toolJson: { type: "vox-random-value" }, ...buttonLayerControl({ - text: "New Label", + text: "Random", onClick: (layer) => - layer.handleVoxAction("new-label", new LayerActionContext()), + layer.handleVoxAction("randomize-paint-value", new LayerActionContext()), }), }, ]; diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 906947416f..1a72aea68b 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -37,14 +37,13 @@ import { TrackableBoolean } from "#src/trackable_boolean.js"; import type { WatchableValueInterface } from "#src/trackable_value.js"; import { TrackableValue, WatchableValue } from "#src/trackable_value.js"; import type { UserLayerWithAnnotations } from "#src/ui/annotations.js"; +import { randomUint64 } from "#src/util/bigint.js"; import { RefCounted } from "#src/util/disposable.js"; -import { verifyFiniteFloat, verifyInt } from "#src/util/json.js"; -import { NullarySignal } from "#src/util/signal.js"; +import { parseUint64, verifyFiniteFloat, verifyInt } from "#src/util/json.js"; import { TrackableEnum } from "#src/util/trackable_enum.js"; import { VoxelPreviewMultiscaleSource } from "#src/voxel_annotation/PreviewMultiscaleChunkSource.js"; import type { VoxelEditControllerHost } from "#src/voxel_annotation/edit_controller.js"; import { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; -import { LabelsManager } from "#src/voxel_annotation/labels.js"; export enum BrushShape { DISK = 0, @@ -73,16 +72,9 @@ export class VoxelEditingContext //this.registerDisposer(optimisticRenderLayer); } - // VoxelEditControllerHost implementation - get labelsManager(): LabelsManager { - return this.hostLayer.voxLabelsManager!; - } get rpc() { return this.hostLayer.manager.chunkManager.rpc!; } - setDrawErrorMessage(message: string | undefined): void { - this.hostLayer.setDrawErrorMessage(message); - } disposed() { this.controller.dispose(); @@ -141,16 +133,13 @@ export class VoxelEditingContext } export declare abstract class UserLayerWithVoxelEditing extends UserLayer { - voxLabelsManager?: LabelsManager; - labelsChanged: NullarySignal; isEditable: WatchableValue; - onDrawMessageChanged?: () => void; - voxDrawErrorMessage: string | undefined; voxBrushRadius: TrackableValue; voxEraseMode: TrackableBoolean; voxBrushShape: TrackableEnum; voxFloodMaxVoxels: TrackableValue; + paintValue: TrackableValue; editingContexts: Map; @@ -158,6 +147,8 @@ export declare abstract class UserLayerWithVoxelEditing extends UserLayer { source: MultiscaleVolumeChunkSource, transform: WatchableValueInterface, ): ImageRenderLayer | SegmentationRenderLayer; + abstract getVoxelPaintValue(erase: boolean): bigint; + abstract setVoxelPaintValue(value: bigint): void; initializeVoxelEditingForSubsource( loadedSubsource: LoadedDataSubsource, @@ -168,7 +159,6 @@ export declare abstract class UserLayerWithVoxelEditing extends UserLayer { ): void; getIdentitySliceViewSourceOptions(): SliceViewSourceOptions; - setDrawErrorMessage(message: string | undefined): void; handleVoxAction(action: string, context: LayerActionContext): void; } @@ -177,9 +167,8 @@ export function UserLayerWithVoxelEditingMixin< >(Base: TBase) { abstract class C extends Base implements UserLayerWithVoxelEditing { editingContexts = new Map(); - voxLabelsManager?: LabelsManager; - labelsChanged = new NullarySignal(); isEditable = new WatchableValue(false); + paintValue = new TrackableValue(1n, (x) => parseUint64(x)); // Brush properties voxBrushRadius = new TrackableValue(3, verifyInt); @@ -187,13 +176,6 @@ export function UserLayerWithVoxelEditingMixin< voxBrushShape = new TrackableEnum(BrushShape, BrushShape.DISK); voxFloodMaxVoxels = new TrackableValue(10000, verifyFiniteFloat); - voxDrawErrorMessage: string | undefined = undefined; - onDrawMessageChanged?: () => void; - setDrawErrorMessage(message: string | undefined): void { - this.voxDrawErrorMessage = message; - this.onDrawMessageChanged?.(); - } - constructor(...args: any[]) { super(...args); this.registerDisposer(() => { @@ -206,6 +188,7 @@ export function UserLayerWithVoxelEditingMixin< this.voxEraseMode.changed.add(this.specificationChanged.dispatch); this.voxBrushShape.changed.add(this.specificationChanged.dispatch); this.voxFloodMaxVoxels.changed.add(this.specificationChanged.dispatch); + this.paintValue.changed.add(this.specificationChanged.dispatch); this.tabs.add("Draw", { label: "Draw", order: 20, @@ -213,6 +196,15 @@ export function UserLayerWithVoxelEditingMixin< }); } + getVoxelPaintValue(erase: boolean): bigint { + if (erase) return 0n; + return this.paintValue.value; + } + setVoxelPaintValue(value: bigint) { + this.paintValue.value = value; + } + + abstract _createVoxelRenderLayer( source: MultiscaleVolumeChunkSource, transform: WatchableValueInterface, @@ -226,16 +218,6 @@ export function UserLayerWithVoxelEditingMixin< const primarySource = loadedSubsource.subsourceEntry.subsource .volume as MultiscaleVolumeChunkSource; - const baseSpec = primarySource.getSources( - this.getIdentitySliceViewSourceOptions(), - )[0][0]!.chunkSource.spec; - - if (this.voxLabelsManager === undefined) { - this.voxLabelsManager = new LabelsManager( - baseSpec.dataType, - this.labelsChanged.dispatch, - ); - } const previewSource = new VoxelPreviewMultiscaleSource( this.manager.chunkManager, @@ -289,8 +271,7 @@ export function UserLayerWithVoxelEditingMixin< }; } - handleVoxAction(action: string, context: LayerActionContext): void { - super.handleAction(action, context); + handleVoxAction(action: string, _context: LayerActionContext): void { const firstContext = this.editingContexts.values().next().value; if (!firstContext) return; const controller = firstContext.controller; @@ -301,8 +282,8 @@ export function UserLayerWithVoxelEditingMixin< case "redo": controller.redo(); break; - case "new-label": - this.voxLabelsManager?.createNewLabel(); + case "randomize-paint-value": + this.paintValue.value = randomUint64(); break; } } diff --git a/src/layer/vox/tabs/tools.ts b/src/layer/vox/tabs/tools.ts index 5bcf2193c7..8cb2ed2692 100644 --- a/src/layer/vox/tabs/tools.ts +++ b/src/layer/vox/tabs/tools.ts @@ -23,27 +23,11 @@ import { BRUSH_TOOL_ID, FLOODFILL_TOOL_ID, } from "#src/ui/voxel_annotations.js"; -import { DataType } from "#src/util/data_type.js"; import type { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; -import type { LabelsManager } from "#src/voxel_annotation/labels.js"; import { DependentViewWidget } from "#src/widget/dependent_view_widget.js"; import { addLayerControlToOptionsTab } from "#src/widget/layer_control.js"; import { Tab } from "#src/widget/tab_view.js"; -function formatUnsignedId(id: bigint, dataType: DataType): string { - if (id >= 0n) { - return id.toString(); - } - // Handle two's complement representation for negative BigInts. - if (dataType === DataType.UINT32) { - return ((1n << 32n) + id).toString(); - } - if (dataType === DataType.UINT64) { - return ((1n << 64n) + id).toString(); - } - return id.toString(); -} - export class VoxToolTab extends Tab { constructor(public layer: UserLayerWithVoxelEditing) { super(); @@ -135,115 +119,6 @@ export class VoxToolTab extends Tab { toolbox.appendChild(controlElement); } - const labelsSection = document.createElement("div"); - labelsSection.style.display = "flex"; - labelsSection.style.flexDirection = "column"; - labelsSection.style.gap = "6px"; - labelsSection.style.marginTop = "8px"; - - const labelsTitle = document.createElement("div"); - labelsTitle.textContent = "Labels"; - labelsTitle.style.fontWeight = "600"; - - const labelsWidget = this.registerDisposer( - new DependentViewWidget( - { - changed: this.layer.labelsChanged, - get value() { - return layer.voxLabelsManager; - }, - }, - (labelsManager: LabelsManager | undefined, parent) => { - if (labelsManager === undefined) return; - - const list = document.createElement("div"); - list.className = "neuroglancer-vox-labels"; - list.style.display = "flex"; - list.style.flexDirection = "column"; - list.style.gap = "4px"; - list.style.maxHeight = "180px"; - list.style.overflowY = "auto"; - - for (const label of labelsManager.labels) { - const row = document.createElement("div"); - row.className = "neuroglancer-vox-label-row"; - row.style.display = "grid"; - row.style.gridTemplateColumns = "16px 1fr"; - row.style.alignItems = "center"; - row.style.gap = "8px"; - - const swatch = document.createElement("div"); - swatch.style.width = "16px"; - swatch.style.height = "16px"; - swatch.style.borderRadius = "3px"; - swatch.style.border = "1px solid rgba(0,0,0,0.2)"; - swatch.style.background = labelsManager.colorForValue(label); - - const text = document.createElement("div"); - text.textContent = formatUnsignedId(label, labelsManager.dataType); - text.style.fontFamily = "monospace"; - text.style.whiteSpace = "nowrap"; - text.style.overflow = "hidden"; - text.style.textOverflow = "ellipsis"; - - row.appendChild(swatch); - row.appendChild(text); - - if (label === labelsManager.selectedLabelId) { - row.style.background = "rgba(100,150,255,0.15)"; - row.style.outline = "1px solid rgba(100,150,255,0.6)"; - } - row.style.cursor = "pointer"; - row.style.padding = "2px 4px"; - row.style.borderRadius = "4px"; - row.addEventListener("click", () => { - labelsManager.selectVoxLabel(label); - }); - - list.appendChild(row); - } - - if (labelsManager.labelsError) { - const errorDiv = document.createElement("div"); - errorDiv.className = "neuroglancer-vox-labels-error"; - errorDiv.style.color = "#b00020"; - errorDiv.style.fontSize = "12px"; - errorDiv.style.whiteSpace = "pre-wrap"; - errorDiv.textContent = labelsManager.labelsError; - parent.appendChild(errorDiv); - } - - parent.appendChild(list); - }, - this.visibility, - ), - ); - - labelsSection.appendChild(labelsTitle); - labelsSection.appendChild(labelsWidget.element); - toolbox.appendChild(labelsSection); - - const drawErrorContainer = document.createElement("div"); - drawErrorContainer.className = "neuroglancer-vox-draw-error"; - drawErrorContainer.style.color = "#b00020"; - drawErrorContainer.style.fontSize = "12px"; - drawErrorContainer.style.whiteSpace = "pre-wrap"; - drawErrorContainer.style.marginTop = "8px"; - drawErrorContainer.style.display = "none"; - toolbox.appendChild(drawErrorContainer); - - this.layer.onDrawMessageChanged = () => { - const msg = this.layer.voxDrawErrorMessage; - if (msg && msg.length > 0) { - drawErrorContainer.textContent = msg; - drawErrorContainer.style.display = "block"; - } else { - drawErrorContainer.textContent = ""; - drawErrorContainer.style.display = "none"; - } - }; - this.layer.onDrawMessageChanged(); - element.appendChild(toolbox); } } diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 04cf3ce5b8..0ad4fff458 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -15,10 +15,7 @@ */ import type { MouseSelectionState } from "#src/layer/index.js"; -import type { - UserLayerWithVoxelEditing, - VoxelEditingContext, -} from "#src/layer/vox/index.js"; +import type { UserLayerWithVoxelEditing, VoxelEditingContext } from "#src/layer/vox/index.js"; import { BrushShape } from "#src/layer/vox/index.js"; import type { RenderedDataPanel } from "#src/rendered_data_panel.js"; import { StatusMessage } from "#src/status.js"; @@ -231,9 +228,7 @@ export class VoxelBrushTool extends BaseVoxelTool { ) { const points = this.linePoints(last, cur); if (points.length > 0) { - if (!this.layer.voxLabelsManager) - throw new Error("Drawing backend not ready yet"); - const value = this.layer.voxLabelsManager.getCurrentLabelValue( + const value = this.layer.getVoxelPaintValue( this.layer.voxEraseMode.value, ); this.paintPoints(points, value); @@ -256,9 +251,7 @@ export class VoxelBrushTool extends BaseVoxelTool { ); } - if (!this.layer.voxLabelsManager) - throw new Error("Drawing backend not ready yet"); - const value = this.layer.voxLabelsManager.getCurrentLabelValue( + const value = this.layer.getVoxelPaintValue( this.layer.voxEraseMode.value, ); @@ -370,15 +363,11 @@ export class VoxelFloodFillTool extends BaseVoxelTool { const seed = this.getPoint(this.mouseState); const planeNormal = this.mouseState.planeNormal; if (!seed || !planeNormal) return; - const layer = this.layer; try { - layer.setDrawErrorMessage(undefined); - if (!layer.voxLabelsManager) - throw new Error("Drawing backend not ready yet"); - const value = layer.voxLabelsManager.getCurrentLabelValue( - layer.voxEraseMode.value, + const value = this.layer.getVoxelPaintValue( + this.layer.voxEraseMode.value, ); - const max = Number(layer.voxFloodMaxVoxels.value); + const max = Number(this.layer.voxFloodMaxVoxels.value); if (!Number.isFinite(max) || max <= 0) { throw new Error("Invalid max fill voxels setting"); } @@ -390,10 +379,10 @@ export class VoxelFloodFillTool extends BaseVoxelTool { planeNormal, ) .catch((e: any) => - layer.setDrawErrorMessage?.(String(e?.message ?? e)), + StatusMessage.showTemporaryMessage(String(e?.message ?? e)), ); } catch (e: any) { - layer.setDrawErrorMessage?.(String(e?.message ?? e)); + StatusMessage.showTemporaryMessage(String(e?.message ?? e)); } } @@ -424,7 +413,7 @@ const pickerSVG = ` { if (!pos || pos.length < 3) { StatusMessage.showTemporaryMessage( - "Cannot pick label: position is not valid.", + "Cannot pick value: position is not valid.", 3000, ); return; @@ -498,30 +487,20 @@ export class AdoptVoxelLabelTool extends LayerTool { StatusMessage.forPromise( source .getEnsuredValueAt(pos, channelAccess) - .then((value: bigint | number | null) => { - if (value === null) { + .then((data: bigint | number | null) => { + if (data === null) { throw new Error( "Voxel data not available at the selected position.", ); } - const label = BigInt(value); - if (label === 0n) { - StatusMessage.showTemporaryMessage( - "Cannot adopt background label (0).", - 3000, - ); - return; - } - if (!this.layer.voxLabelsManager) { - throw new Error("Drawing backend not ready yet"); - } - this.layer.voxLabelsManager.addLabel(label); - StatusMessage.showTemporaryMessage(`Adopted label: ${label}`, 3000); + const value = BigInt(data); + this.layer.setVoxelPaintValue(value); + StatusMessage.showTemporaryMessage(`Adopted value: ${value}`, 3000); }), { - initialMessage: "Picking voxel label...", + initialMessage: "Picking voxel value...", delay: true, - errorPrefix: "Error picking label: ", + errorPrefix: "Error picking value: ", }, ); } @@ -541,6 +520,6 @@ export function registerVoxelTools(LayerCtor: any) { registerTool( LayerCtor, ADOPT_VOXEL_LABEL_TOOL_ID, - (layer: UserLayerWithVoxelEditing) => new AdoptVoxelLabelTool(layer), + (layer: UserLayerWithVoxelEditing) => new AdoptVoxelValueTool(layer), ); } diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 25736f9ec8..7299986646 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -3,13 +3,13 @@ ### priority - url completion for the ssa+https source -- add json state for every tools and settings of voxel painting -- replace the current label system with a new system linked to the segment in the segmantation layer and with a color picker for the image layer +- add json state for all tools and settings of voxel painting +- replace the current label system with a new system linked to the segment in the segmentation layer and with a color picker for the image layer -> FOR TOMORROW - fix the case of multiple datasources in the same layer -- should we support compressed chunks? if yes, we should find a better way to handle them. ### later +- add preview for the undo/redo - optimize flood fill tool (it is too slow on area containing uncached chunks, due to the getEnsuredValueAt() calls) - the flood fill sometimes leaves artifacts in sharp areas (maybe increase fillBorderRegion() radius) - write a testsuite for the downsampler and ensure its proper working on exotic lod levels diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index f4726c6e8c..fa1383f391 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -37,7 +37,6 @@ import { makeVoxChunkKey, parseVoxChunkKey, } from "#src/voxel_annotation/base.js"; -import type { LabelsManager } from "#src/voxel_annotation/labels.js"; import type { RPC } from "#src/worker_rpc.js"; import { registerRPC, @@ -48,9 +47,7 @@ import { export interface VoxelEditControllerHost { primarySource: MultiscaleVolumeChunkSource; previewSource: VoxelPreviewMultiscaleSource; - labelsManager: LabelsManager; rpc: RPC; - setDrawErrorMessage(message: string | undefined): void; } @registerSharedObjectOwner(VOX_EDIT_BACKEND_RPC_ID) @@ -616,7 +613,7 @@ export class VoxelEditController extends SharedObject { try { this.callChunkReload(voxChunkKeys); } finally { - this.host.setDrawErrorMessage(message); + StatusMessage.showTemporaryMessage(message); } } From c1cbb8b52c61b6f5c5de38602916e613e8e7ab21 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:45 +0100 Subject: [PATCH 102/251] feat: add state serialization and restoration for voxel editing context --- src/layer/vox/index.ts | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 1a72aea68b..d939d28382 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -39,7 +39,12 @@ import { TrackableValue, WatchableValue } from "#src/trackable_value.js"; import type { UserLayerWithAnnotations } from "#src/ui/annotations.js"; import { randomUint64 } from "#src/util/bigint.js"; import { RefCounted } from "#src/util/disposable.js"; -import { parseUint64, verifyFiniteFloat, verifyInt } from "#src/util/json.js"; +import { + parseUint64, + verifyFiniteFloat, + verifyInt, + verifyOptionalObjectProperty, +} from "#src/util/json.js"; import { TrackableEnum } from "#src/util/trackable_enum.js"; import { VoxelPreviewMultiscaleSource } from "#src/voxel_annotation/PreviewMultiscaleChunkSource.js"; import type { VoxelEditControllerHost } from "#src/voxel_annotation/edit_controller.js"; @@ -50,6 +55,12 @@ export enum BrushShape { SPHERE = 1, } +const BRUSH_SIZE_JSON_KEY = "brushSize"; +const ERASE_MODE_JSON_KEY = "eraseMode"; +const BRUSH_SHAPE_JSON_KEY = "brushShape"; +const FLOOD_FILL_MAX_VOXELS_JSON_KEY = "floodFillMaxVoxels"; +const PAINT_VALUE_JSON_KEY = "paintValue"; + export class VoxelEditingContext extends RefCounted implements VoxelEditControllerHost @@ -196,6 +207,25 @@ export function UserLayerWithVoxelEditingMixin< }); } + toJSON() { + const json = super.toJSON(); + json[BRUSH_SIZE_JSON_KEY] = this.voxBrushRadius.toJSON(); + json[ERASE_MODE_JSON_KEY] = this.voxEraseMode.toJSON(); + json[BRUSH_SHAPE_JSON_KEY] = this.voxBrushShape.toJSON(); + json[FLOOD_FILL_MAX_VOXELS_JSON_KEY] = this.voxFloodMaxVoxels.toJSON(); + json[PAINT_VALUE_JSON_KEY] = this.paintValue.toJSON(); + return json; + } + + restoreState(specification: any) { + super.restoreState(specification); + verifyOptionalObjectProperty(specification, BRUSH_SIZE_JSON_KEY, v => this.voxBrushRadius.restoreState(v)); + verifyOptionalObjectProperty(specification, ERASE_MODE_JSON_KEY, v => this.voxEraseMode.restoreState(v)); + verifyOptionalObjectProperty(specification, BRUSH_SHAPE_JSON_KEY, v => this.voxBrushShape.restoreState(v)); + verifyOptionalObjectProperty(specification, FLOOD_FILL_MAX_VOXELS_JSON_KEY, v => this.voxFloodMaxVoxels.restoreState(v)); + verifyOptionalObjectProperty(specification, PAINT_VALUE_JSON_KEY, v => this.paintValue.restoreState(v)); + } + getVoxelPaintValue(erase: boolean): bigint { if (erase) return 0n; return this.paintValue.value; From 3acf73f0e30c391ca0f709b09635bdd3c971527a Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:45 +0100 Subject: [PATCH 103/251] fix: planeNormal used to aligned floddfill and brush was wrong if the local coord space was not aligned with the global (e.g. x and z inverted for example, which is common). --- src/layer/vox/index.ts | 24 ++++++++++++++++++++++++ src/ui/voxel_annotations.ts | 17 ++++++++++------- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index d939d28382..6559c762f3 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -39,6 +39,7 @@ import { TrackableValue, WatchableValue } from "#src/trackable_value.js"; import type { UserLayerWithAnnotations } from "#src/ui/annotations.js"; import { randomUint64 } from "#src/util/bigint.js"; import { RefCounted } from "#src/util/disposable.js"; +import { vec3 } from "#src/util/geom.js"; import { parseUint64, verifyFiniteFloat, @@ -141,6 +142,29 @@ export class VoxelEditingContext if (!ok) return undefined; return this.cachedVoxelPosition; } + + transformGlobalToVoxelNormal(globalNormal: vec3): vec3 { + const chunkTransform = this.cachedChunkTransform; + if (chunkTransform === undefined) + throw new Error("Chunk transform not computed"); + const { modelTransform, layerToChunkTransform, layerRank } = chunkTransform; + const { globalToRenderLayerDimensions } = modelTransform; + const globalRank = globalToRenderLayerDimensions.length; + const voxelNormal = vec3.create(); + + for (let chunkDim = 0; chunkDim < 3; ++chunkDim) { + let sum = 0; + for (let globalDim = 0; globalDim < Math.min(globalRank, 3); ++globalDim) { + const layerDim = globalToRenderLayerDimensions[globalDim]; + if (layerDim !== -1) { + sum += layerToChunkTransform[chunkDim + layerDim * (layerRank + 1)] * globalNormal[globalDim]; + } + } + voxelNormal[chunkDim] = sum; + } + vec3.normalize(voxelNormal, voxelNormal); + return voxelNormal; + } } export declare abstract class UserLayerWithVoxelEditing extends UserLayer { diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 0ad4fff458..4a1a436649 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -48,7 +48,8 @@ abstract class BaseVoxelTool extends LayerTool { | Float32Array | undefined; if (!mouseState?.active || !vox) return undefined; - const planeNormal = mouseState?.planeNormal; + if(!mouseState.planeNormal) return; + const planeNormal = editContext.transformGlobalToVoxelNormal(mouseState.planeNormal); if (!mouseState?.active || !vox || !planeNormal) return undefined; const CHUNK_POSITION_EPSILON = 1e-3; const shiftedVox = new Float32Array(3); @@ -288,10 +289,14 @@ export class VoxelBrushTool extends BaseVoxelTool { 1, Math.floor(this.layer.voxBrushRadius.value ?? 3), ); + const editContext = this.getEditingContext(); + if (editContext === undefined) { + throw new Error("editContext is undefined"); + } const shapeEnum = this.layer.voxBrushShape.value; let basis = undefined as undefined | { u: Float32Array; v: Float32Array }; if (shapeEnum === BrushShape.DISK && this.currentMouseState?.planeNormal) { - const n = this.currentMouseState.planeNormal; + const n = editContext.transformGlobalToVoxelNormal(this.currentMouseState.planeNormal); const u = vec3.create(); const tempVec = Math.abs(vec3.dot(n, vec3.fromValues(1, 0, 0))) < 0.9 @@ -304,10 +309,7 @@ export class VoxelBrushTool extends BaseVoxelTool { basis = { u, v }; } - const editContext = this.getEditingContext(); - if (editContext === undefined) { - throw new Error("editContext is undefined"); - } + for (const p of points) editContext.controller?.paintBrushWithShape( p, @@ -361,7 +363,8 @@ export class VoxelFloodFillTool extends BaseVoxelTool { return; } const seed = this.getPoint(this.mouseState); - const planeNormal = this.mouseState.planeNormal; + if(!this.mouseState.planeNormal) return; + const planeNormal = editContext.transformGlobalToVoxelNormal(this.mouseState.planeNormal); if (!seed || !planeNormal) return; try { const value = this.layer.getVoxelPaintValue( From 48761ed61393de2b18dc462c9436f3137c3830f4 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:45 +0100 Subject: [PATCH 104/251] refactor: format + lint + cleanup --- package.json | 7 -- src/kvstore/opfs/backend.ts | 2 +- src/kvstore/opfs/common.ts | 2 +- src/kvstore/opfs/register_backend.ts | 2 +- src/kvstore/opfs/register_frontend.ts | 2 +- src/layer/enabled_frontend_modules.ts | 1 - src/layer/vox/controls.ts | 31 ++++++++- src/layer/vox/index.ts | 43 ++++++++---- src/layer/vox/style.css | 12 ---- src/sliceview/frontend.ts | 25 +------ src/sliceview/volume/registry.ts | 2 +- src/ui/voxel_annotations.ts | 28 +++++--- src/voxel_annotation/TODOs.md | 3 +- src/voxel_annotation/base.ts | 15 ++++ src/voxel_annotation/edit_controller.ts | 16 ++--- src/voxel_annotation/labels.ts | 92 ------------------------- src/widget/layer_control_button.ts | 2 +- 17 files changed, 101 insertions(+), 184 deletions(-) delete mode 100644 src/voxel_annotation/labels.ts diff --git a/package.json b/package.json index eec4b197a8..2e0e26be03 100644 --- a/package.json +++ b/package.json @@ -528,7 +528,6 @@ "neuroglancer/kvstore/zip:disabled": "./src/util/false.ts", "default": "./src/kvstore/zip/register_backend.ts" }, - "#kvstore/indexeddb/register": "./src/kvstore/indexeddb/register.ts", "#layer/annotation": { "neuroglancer/layer/annotation:enabled": "./src/layer/annotation/index.ts", "neuroglancer/layer:none_by_default": "./src/util/false.ts", @@ -553,12 +552,6 @@ "neuroglancer/layer/single_mesh:disabled": "./src/util/false.ts", "default": "./src/layer/single_mesh/index.ts" }, - "#layer/vox": { - "neuroglancer/layer/vox:enabled": "./src/layer/vox/index.ts", - "neuroglancer/layer:none_by_default": "./src/util/false.ts", - "neuroglancer/layer/vox:disabled": "./src/util/false.ts", - "default": "./src/layer/vox/index.ts" - }, "#main": { "neuroglancer/python": "./src/main_python.ts", "default": "./src/main.ts" diff --git a/src/kvstore/opfs/backend.ts b/src/kvstore/opfs/backend.ts index 3da1cd0063..54e12ce9ec 100644 --- a/src/kvstore/opfs/backend.ts +++ b/src/kvstore/opfs/backend.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 + * Copyright 2025 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 diff --git a/src/kvstore/opfs/common.ts b/src/kvstore/opfs/common.ts index 3e61171429..5b78682adb 100644 --- a/src/kvstore/opfs/common.ts +++ b/src/kvstore/opfs/common.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 + * Copyright 2025 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 diff --git a/src/kvstore/opfs/register_backend.ts b/src/kvstore/opfs/register_backend.ts index b3ea60904e..0caced1a1c 100644 --- a/src/kvstore/opfs/register_backend.ts +++ b/src/kvstore/opfs/register_backend.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 + * Copyright 2025 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 diff --git a/src/kvstore/opfs/register_frontend.ts b/src/kvstore/opfs/register_frontend.ts index c4ef3ee3bb..4a946cc90e 100644 --- a/src/kvstore/opfs/register_frontend.ts +++ b/src/kvstore/opfs/register_frontend.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 + * Copyright 2025 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 diff --git a/src/layer/enabled_frontend_modules.ts b/src/layer/enabled_frontend_modules.ts index 2f2e03fb84..d192997999 100644 --- a/src/layer/enabled_frontend_modules.ts +++ b/src/layer/enabled_frontend_modules.ts @@ -3,4 +3,3 @@ import "#layer/annotation"; import "#layer/image"; import "#layer/segmentation"; import "#layer/single_mesh"; -import "#layer/vox"; diff --git a/src/layer/vox/controls.ts b/src/layer/vox/controls.ts index 8678ad09fe..7ef19b0d54 100644 --- a/src/layer/vox/controls.ts +++ b/src/layer/vox/controls.ts @@ -1,3 +1,19 @@ +/** + * @license + * Copyright 2025 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 type { UserLayerConstructor } from "#src/layer/index.js"; import { LayerActionContext } from "#src/layer/index.js"; import type { UserLayerWithVoxelEditing } from "#src/layer/vox/index.js"; @@ -15,7 +31,10 @@ import { colorLayerControl } from "#src/widget/layer_control_color.js"; import { enumLayerControl } from "#src/widget/layer_control_enum.js"; import { rangeLayerControl } from "#src/widget/layer_control_range.js"; -class BigIntAsTrackableRGB extends RefCounted implements WatchableValueInterface { +class BigIntAsTrackableRGB + extends RefCounted + implements WatchableValueInterface +{ changed = new NullarySignal(); private tempColor = vec3.create(); @@ -88,7 +107,10 @@ export const VOXEL_LAYER_CONTROLS: LayerControlDefinition new BigIntAsTrackableRGB(layer.paintValue)), + ...colorLayerControl( + (layer: UserLayerWithVoxelEditing) => + new BigIntAsTrackableRGB(layer.paintValue), + ), }, { label: "Paint Value", @@ -120,7 +142,10 @@ export const VOXEL_LAYER_CONTROLS: LayerControlDefinition - layer.handleVoxAction("randomize-paint-value", new LayerActionContext()), + layer.handleVoxAction( + "randomize-paint-value", + new LayerActionContext(), + ), }), }, ]; diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 6559c762f3..23b3c4f9c8 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -17,8 +17,8 @@ import type { LayerActionContext, MouseSelectionState, - UserLayer, } from "#src/layer/index.js"; +import { UserLayer } from "#src/layer/index.js"; import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; import { VoxToolTab } from "#src/layer/vox/tabs/tools.js"; import type { @@ -48,14 +48,10 @@ import { } from "#src/util/json.js"; import { TrackableEnum } from "#src/util/trackable_enum.js"; import { VoxelPreviewMultiscaleSource } from "#src/voxel_annotation/PreviewMultiscaleChunkSource.js"; -import type { VoxelEditControllerHost } from "#src/voxel_annotation/edit_controller.js"; +import type { VoxelEditControllerHost } from "#src/voxel_annotation/base.js"; +import { BrushShape } from "#src/voxel_annotation/base.js"; import { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; -export enum BrushShape { - DISK = 0, - SPHERE = 1, -} - const BRUSH_SIZE_JSON_KEY = "brushSize"; const ERASE_MODE_JSON_KEY = "eraseMode"; const BRUSH_SHAPE_JSON_KEY = "brushShape"; @@ -154,10 +150,16 @@ export class VoxelEditingContext for (let chunkDim = 0; chunkDim < 3; ++chunkDim) { let sum = 0; - for (let globalDim = 0; globalDim < Math.min(globalRank, 3); ++globalDim) { + for ( + let globalDim = 0; + globalDim < Math.min(globalRank, 3); + ++globalDim + ) { const layerDim = globalToRenderLayerDimensions[globalDim]; if (layerDim !== -1) { - sum += layerToChunkTransform[chunkDim + layerDim * (layerRank + 1)] * globalNormal[globalDim]; + sum += + layerToChunkTransform[chunkDim + layerDim * (layerRank + 1)] * + globalNormal[globalDim]; } } voxelNormal[chunkDim] = sum; @@ -243,11 +245,23 @@ export function UserLayerWithVoxelEditingMixin< restoreState(specification: any) { super.restoreState(specification); - verifyOptionalObjectProperty(specification, BRUSH_SIZE_JSON_KEY, v => this.voxBrushRadius.restoreState(v)); - verifyOptionalObjectProperty(specification, ERASE_MODE_JSON_KEY, v => this.voxEraseMode.restoreState(v)); - verifyOptionalObjectProperty(specification, BRUSH_SHAPE_JSON_KEY, v => this.voxBrushShape.restoreState(v)); - verifyOptionalObjectProperty(specification, FLOOD_FILL_MAX_VOXELS_JSON_KEY, v => this.voxFloodMaxVoxels.restoreState(v)); - verifyOptionalObjectProperty(specification, PAINT_VALUE_JSON_KEY, v => this.paintValue.restoreState(v)); + verifyOptionalObjectProperty(specification, BRUSH_SIZE_JSON_KEY, (v) => + this.voxBrushRadius.restoreState(v), + ); + verifyOptionalObjectProperty(specification, ERASE_MODE_JSON_KEY, (v) => + this.voxEraseMode.restoreState(v), + ); + verifyOptionalObjectProperty(specification, BRUSH_SHAPE_JSON_KEY, (v) => + this.voxBrushShape.restoreState(v), + ); + verifyOptionalObjectProperty( + specification, + FLOOD_FILL_MAX_VOXELS_JSON_KEY, + (v) => this.voxFloodMaxVoxels.restoreState(v), + ); + verifyOptionalObjectProperty(specification, PAINT_VALUE_JSON_KEY, (v) => + this.paintValue.restoreState(v), + ); } getVoxelPaintValue(erase: boolean): bigint { @@ -258,7 +272,6 @@ export function UserLayerWithVoxelEditingMixin< this.paintValue.value = value; } - abstract _createVoxelRenderLayer( source: MultiscaleVolumeChunkSource, transform: WatchableValueInterface, diff --git a/src/layer/vox/style.css b/src/layer/vox/style.css index ac59d6366f..81d2ca9b14 100644 --- a/src/layer/vox/style.css +++ b/src/layer/vox/style.css @@ -14,21 +14,10 @@ * limitations under the License. */ -:root { - /* best-effort variables if app doesn't define them */ - --ng-bg: rgba(20, 22, 27, 0.9); - --ng-card: rgba(255, 255, 255, 0.04); - --ng-border: rgba(255, 255, 255, 0.15); - --ng-text: rgba(230, 230, 235, 0.95); - --ng-muted: rgba(230, 230, 235, 0.65); - --ng-accent: #3a6df0; -} - .neuroglancer-vox-row label { flex: 0 0 140px; min-width: 0; font-weight: 500; - color: var(--ng-muted); } .neuroglancer-vox-status { @@ -36,7 +25,6 @@ flex: 1 1 100%; min-width: 100%; padding-top: 4px; - color: var(--ng-muted); } .neuroglancer-vox-settings-tab button:hover, diff --git a/src/sliceview/frontend.ts b/src/sliceview/frontend.ts index 91a81bf87a..99697b202b 100644 --- a/src/sliceview/frontend.ts +++ b/src/sliceview/frontend.ts @@ -438,14 +438,6 @@ export class SliceView extends Base { lastSeenGeneration: curUpdateGeneration, displayDimensionRenderInfo, }; - if ((renderLayer as any).constructor?.type === "vox") { - console.log( - "[SliceView.updateVisibleLayersNow] new vox layerInfo created, allSources orientations=", - layerInfo.allSources.length, - "first orientation scales=", - layerInfo.allSources[0]?.length ?? 0, - ); - } disposers.push(renderLayer.messages.addChild(layerInfo.messages)); visibleLayers.set(renderLayer.addRef(), layerInfo); this.bindVisibleRenderLayer(renderLayer, disposers); @@ -463,14 +455,6 @@ export class SliceView extends Base { renderLayer, layerInfo.messages, ); - if ((renderLayer as any).constructor?.type === "vox") { - console.log( - "[SliceView.updateVisibleLayersNow] vox layer transform changed, new allSources orientations=", - layerInfo.allSources.length, - "first orientation scales=", - layerInfo.allSources[0]?.length ?? 0, - ); - } disposeTransformedSources(renderLayer, allSources); layerInfo.visibleSources.length = 0; layerInfo.displayDimensionRenderInfo = displayDimensionRenderInfo; @@ -730,14 +714,7 @@ export interface SliceViewChunkSource { /* export class SliceViewChunk extends Chunk { - chunkGridPosition: vec3; - declare source: SliceViewChunkSource; - - constructor(source: SliceViewChunkSource, x: any) { - super(source); - this.chunkGridPosition = x.chunkGridPosition; - this.state = ChunkState.SYSTEM_MEMORY; - } + // MOVED to chunk_base.ts to avoid import loop } */ diff --git a/src/sliceview/volume/registry.ts b/src/sliceview/volume/registry.ts index 97992a80f9..59a8ae6e64 100644 --- a/src/sliceview/volume/registry.ts +++ b/src/sliceview/volume/registry.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2020 Google Inc. + * Copyright 2025 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 diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 4a1a436649..0280e40bfc 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -15,8 +15,10 @@ */ import type { MouseSelectionState } from "#src/layer/index.js"; -import type { UserLayerWithVoxelEditing, VoxelEditingContext } from "#src/layer/vox/index.js"; -import { BrushShape } from "#src/layer/vox/index.js"; +import type { + UserLayerWithVoxelEditing, + VoxelEditingContext, +} from "#src/layer/vox/index.js"; import type { RenderedDataPanel } from "#src/rendered_data_panel.js"; import { StatusMessage } from "#src/status.js"; import { LayerTool, registerTool, type ToolActivation } from "#src/ui/tool.js"; @@ -24,6 +26,7 @@ import { vec3 } from "#src/util/geom.js"; import { EventActionMap } from "#src/util/mouse_bindings.js"; import { startRelativeMouseDrag } from "#src/util/mouse_drag.js"; import { NullarySignal } from "#src/util/signal.js"; +import { BrushShape } from "#src/voxel_annotation/base.js"; export const BRUSH_TOOL_ID = "vox-brush"; export const FLOODFILL_TOOL_ID = "vox-flood-fill"; @@ -48,8 +51,10 @@ abstract class BaseVoxelTool extends LayerTool { | Float32Array | undefined; if (!mouseState?.active || !vox) return undefined; - if(!mouseState.planeNormal) return; - const planeNormal = editContext.transformGlobalToVoxelNormal(mouseState.planeNormal); + if (!mouseState.planeNormal) return; + const planeNormal = editContext.transformGlobalToVoxelNormal( + mouseState.planeNormal, + ); if (!mouseState?.active || !vox || !planeNormal) return undefined; const CHUNK_POSITION_EPSILON = 1e-3; const shiftedVox = new Float32Array(3); @@ -252,9 +257,7 @@ export class VoxelBrushTool extends BaseVoxelTool { ); } - const value = this.layer.getVoxelPaintValue( - this.layer.voxEraseMode.value, - ); + const value = this.layer.getVoxelPaintValue(this.layer.voxEraseMode.value); this.paintPoints([new Float32Array([start[0], start[1], start[2]])], value); this.lastPoint = start; @@ -296,7 +299,9 @@ export class VoxelBrushTool extends BaseVoxelTool { const shapeEnum = this.layer.voxBrushShape.value; let basis = undefined as undefined | { u: Float32Array; v: Float32Array }; if (shapeEnum === BrushShape.DISK && this.currentMouseState?.planeNormal) { - const n = editContext.transformGlobalToVoxelNormal(this.currentMouseState.planeNormal); + const n = editContext.transformGlobalToVoxelNormal( + this.currentMouseState.planeNormal, + ); const u = vec3.create(); const tempVec = Math.abs(vec3.dot(n, vec3.fromValues(1, 0, 0))) < 0.9 @@ -309,7 +314,6 @@ export class VoxelBrushTool extends BaseVoxelTool { basis = { u, v }; } - for (const p of points) editContext.controller?.paintBrushWithShape( p, @@ -363,8 +367,10 @@ export class VoxelFloodFillTool extends BaseVoxelTool { return; } const seed = this.getPoint(this.mouseState); - if(!this.mouseState.planeNormal) return; - const planeNormal = editContext.transformGlobalToVoxelNormal(this.mouseState.planeNormal); + if (!this.mouseState.planeNormal) return; + const planeNormal = editContext.transformGlobalToVoxelNormal( + this.mouseState.planeNormal, + ); if (!seed || !planeNormal) return; try { const value = this.layer.getVoxelPaintValue( diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 7299986646..1edcff786d 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -3,12 +3,11 @@ ### priority - url completion for the ssa+https source -- add json state for all tools and settings of voxel painting -- replace the current label system with a new system linked to the segment in the segmentation layer and with a color picker for the image layer -> FOR TOMORROW - fix the case of multiple datasources in the same layer ### later +- should we allow drawing even when there are no writable volume, and in that case inform the user about it and only draw edits in the preview layer? I am not sure about the real use cases tho. - add preview for the undo/redo - optimize flood fill tool (it is too slow on area containing uncached chunks, due to the getEnsuredValueAt() calls) - the flood fill sometimes leaves artifacts in sharp areas (maybe increase fillBorderRegion() radius) diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index 797fce4a50..d715ecc966 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -14,6 +14,10 @@ * limitations under the License. */ +import type { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; +import type { VoxelPreviewMultiscaleSource } from "#src/voxel_annotation/PreviewMultiscaleChunkSource.js"; +import type { RPC } from "#src/worker_rpc.js"; + export const VOX_RELOAD_CHUNKS_RPC_ID = "vox.chunk.reload"; export const VOX_EDIT_BACKEND_RPC_ID = "vox.EditBackend"; export const VOX_EDIT_COMMIT_VOXELS_RPC_ID = "vox.edit.commitVoxels"; @@ -68,3 +72,14 @@ export function parseVoxChunkKey(key: string) { chunkKey: key.split("#")[1], }; } + +export enum BrushShape { + DISK = 0, + SPHERE = 1, +} + +export interface VoxelEditControllerHost { + primarySource: MultiscaleVolumeChunkSource; + previewSource: VoxelPreviewMultiscaleSource; + rpc: RPC; +} diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index fa1383f391..92288351aa 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -14,19 +14,20 @@ * limitations under the License. */ -import { BrushShape } from "#src/layer/vox/index.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; import type { VolumeChunkSource, - MultiscaleVolumeChunkSource, InMemoryVolumeChunkSource, } from "#src/sliceview/volume/frontend.js"; import { StatusMessage } from "#src/status.js"; import { WatchableValue } from "#src/trackable_value.js"; import { vec3 } from "#src/util/geom.js"; -import type { VoxelPreviewMultiscaleSource } from "#src/voxel_annotation/PreviewMultiscaleChunkSource.js"; -import type { VoxelLayerResolution } from "#src/voxel_annotation/base.js"; +import type { + VoxelEditControllerHost, + VoxelLayerResolution, +} from "#src/voxel_annotation/base.js"; import { + BrushShape, VOX_EDIT_BACKEND_RPC_ID, VOX_EDIT_COMMIT_VOXELS_RPC_ID, VOX_RELOAD_CHUNKS_RPC_ID, @@ -37,19 +38,12 @@ import { makeVoxChunkKey, parseVoxChunkKey, } from "#src/voxel_annotation/base.js"; -import type { RPC } from "#src/worker_rpc.js"; import { registerRPC, registerSharedObjectOwner, SharedObject, } from "#src/worker_rpc.js"; -export interface VoxelEditControllerHost { - primarySource: MultiscaleVolumeChunkSource; - previewSource: VoxelPreviewMultiscaleSource; - rpc: RPC; -} - @registerSharedObjectOwner(VOX_EDIT_BACKEND_RPC_ID) export class VoxelEditController extends SharedObject { public undoCount = new WatchableValue(0); diff --git a/src/voxel_annotation/labels.ts b/src/voxel_annotation/labels.ts deleted file mode 100644 index f5b5a54206..0000000000 --- a/src/voxel_annotation/labels.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * @license - * Copyright 2025 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 { SegmentColorHash } from "#src/segment_color.js"; -import { DataType } from "#src/util/data_type.js"; - -export class LabelsManager { - labels: Set; - selectedLabelId: bigint; - labelsError: string | undefined = undefined; - segmentColorHash = SegmentColorHash.getDefault(); - - private sessionPrefix: bigint; - private nextLocalId: bigint = 1n; - private idMask: bigint; - - constructor( - public dataType: DataType, - private onLabelsChanged?: () => void, - ) { - switch (dataType) { - case DataType.UINT32: - this.sessionPrefix = BigInt(Date.now() << 20); - this.idMask = 0xffffffffn; - break; - case DataType.UINT64: - this.sessionPrefix = BigInt(getRandomUint32()) << 32n; - this.idMask = 0xffffffffffffffffn; - break; - default: - throw new Error(`LabelsManager: Unsupported data type: ${dataType}`); - } - this.labels = new Set(); - } - - private generateNewGuid(): bigint { - const newId = this.sessionPrefix | (this.nextLocalId & this.idMask); - this.nextLocalId++; - return newId; - } - - colorForValue(v: bigint): string { - return this.segmentColorHash.computeCssColor(v); - } - - createNewLabel() { - const newId = this.generateNewGuid(); - this.addLabel(newId); - } - - addLabel(id: number | bigint) { - const newId = BigInt(id); - if (newId === 0n) return; - this.selectedLabelId = newId; - this.labels.add(newId); - this.onLabelsChanged?.(); - } - - selectVoxLabel(id: bigint) { - if (this.labels.has(id)) { - this.selectedLabelId = id; - this.onLabelsChanged?.(); - } - } - - getCurrentLabelValue(eraseMode: boolean): bigint { - if (eraseMode) return 0n; - return this.selectedLabelId ? this.selectedLabelId : 0n; - } -} - -function getRandomUint32(): number { - if (typeof crypto !== "undefined" && (crypto as any).getRandomValues) { - const a = new Uint32Array(1); - (crypto as any).getRandomValues(a); - return a[0]! >>> 0; - } - return Math.floor(Math.random() * 0xffffffff) >>> 0; -} diff --git a/src/widget/layer_control_button.ts b/src/widget/layer_control_button.ts index 2c41f1424d..273162bd62 100644 --- a/src/widget/layer_control_button.ts +++ b/src/widget/layer_control_button.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2024 Google Inc. + * Copyright 2025 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 From 8e11ffd23c53dfa01c98dd05e5f214a550a93f45 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:45 +0100 Subject: [PATCH 105/251] feat: ensure the draw tab is only visible if there is a writable source. --- src/layer/vox/index.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 23b3c4f9c8..fd8b4f9765 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -35,7 +35,11 @@ import type { ImageRenderLayer } from "#src/sliceview/volume/image_renderlayer.j import type { SegmentationRenderLayer } from "#src/sliceview/volume/segmentation_renderlayer.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; import type { WatchableValueInterface } from "#src/trackable_value.js"; -import { TrackableValue, WatchableValue } from "#src/trackable_value.js"; +import { + makeDerivedWatchableValue, + TrackableValue, + WatchableValue, +} from "#src/trackable_value.js"; import type { UserLayerWithAnnotations } from "#src/ui/annotations.js"; import { randomUint64 } from "#src/util/bigint.js"; import { RefCounted } from "#src/util/disposable.js"; @@ -229,6 +233,10 @@ export function UserLayerWithVoxelEditingMixin< this.tabs.add("Draw", { label: "Draw", order: 20, + hidden: makeDerivedWatchableValue( + (editable) => !editable, + this.isEditable, + ), getter: () => new VoxToolTab(this), }); } @@ -307,6 +315,7 @@ export function UserLayerWithVoxelEditingMixin< ); this.editingContexts.set(loadedSubsource, context); this.addRenderLayer(optimisticRenderLayer); + this.isEditable.value = true; } deinitializeVoxelEditingForSubsource(loadedSubsource: LoadedDataSubsource) { From e119731bc0dd472c5767cecc937530fe919f4b01 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:45 +0100 Subject: [PATCH 106/251] fix: the brush cursor was disappearing because browsers have a limitation on cursor sizes (128 for chrome), to fix this, the brush cursor is now drawn on an overlay canvas. This explains why other web-based drawing tools have inertia (we now have) on the brush cursor. --- src/rendered_data_panel.ts | 43 ++++++++++++++++++++ src/ui/voxel_annotations.ts | 79 ++++++++++++++++--------------------- 2 files changed, 76 insertions(+), 46 deletions(-) diff --git a/src/rendered_data_panel.ts b/src/rendered_data_panel.ts index fd8a82ff01..213ec678c0 100644 --- a/src/rendered_data_panel.ts +++ b/src/rendered_data_panel.ts @@ -165,6 +165,9 @@ export abstract class RenderedDataPanel extends RenderedPanel { */ pickRequestPending = false; + private overlay_canvas: HTMLCanvasElement; + private overlay_context: CanvasRenderingContext2D; + private mouseStateForcer = () => this.blockOnPickRequest(); protected isMovingToMousePosition: boolean = false; @@ -812,6 +815,46 @@ export abstract class RenderedDataPanel extends RenderedPanel { } }, ); + + this.overlay_canvas = document.createElement('canvas'); + this.overlay_canvas.style.position = 'absolute'; + this.overlay_canvas.style.top = '0'; + this.overlay_canvas.style.left = '0'; + this.overlay_canvas.style.width = '100%'; + this.overlay_canvas.style.height = '100%'; + this.overlay_canvas.style.pointerEvents = 'none'; + this.overlay_canvas.style.zIndex = '10'; + this.element.appendChild(this.overlay_canvas); + this.overlay_context = this.overlay_canvas.getContext('2d')!; + + this.boundsUpdated.add(() => { + this.overlay_canvas.width = this.renderViewport.logicalWidth; + this.overlay_canvas.height = this.renderViewport.logicalHeight; + }); + } + + drawBrushCursor(x: number, y: number, radius: number) { + const ctx = this.overlay_context; + const { logicalWidth, logicalHeight } = this.renderViewport; + + ctx.clearRect(0, 0, logicalWidth, logicalHeight); + + if (radius > 0) { + ctx.beginPath(); + ctx.arc(x, y, radius, 0, 2 * Math.PI); + ctx.fillStyle = 'rgba(255, 255, 255, 0.2)'; + ctx.fill(); + ctx.strokeStyle = 'rgba(255, 255, 255, 1)'; + ctx.lineWidth = 3; + ctx.stroke(); + ctx.strokeStyle = 'rgba(0, 0, 0, 1)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + } + + clearOverlay() { + this.overlay_context.clearRect(0, 0, this.overlay_canvas.width, this.overlay_canvas.height); } abstract translateDataPointByViewportPixels( diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 0280e40bfc..9ded5d9b55 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -19,13 +19,12 @@ import type { UserLayerWithVoxelEditing, VoxelEditingContext, } from "#src/layer/vox/index.js"; -import type { RenderedDataPanel } from "#src/rendered_data_panel.js"; +import { RenderedDataPanel } from "#src/rendered_data_panel.js"; import { StatusMessage } from "#src/status.js"; import { LayerTool, registerTool, type ToolActivation } from "#src/ui/tool.js"; import { vec3 } from "#src/util/geom.js"; import { EventActionMap } from "#src/util/mouse_bindings.js"; import { startRelativeMouseDrag } from "#src/util/mouse_drag.js"; -import { NullarySignal } from "#src/util/signal.js"; import { BrushShape } from "#src/voxel_annotation/base.js"; export const BRUSH_TOOL_ID = "vox-brush"; @@ -138,53 +137,41 @@ export class VoxelBrushTool extends BaseVoxelTool { activate(activation: ToolActivation) { super.activate(activation); - const getZoom = () => { - const panels = Array.from( - this.layer.manager.root.display.panels, - ) as RenderedDataPanel[]; - if (panels.length > 0) { - return panels[0].navigationState.zoomFactor.value; - } - return 1.0; - }; - - const getZoomChangedSignal = () => { - const panels = Array.from( - this.layer.manager.root.display.panels, - ) as RenderedDataPanel[]; - return panels.length > 0 - ? panels[0].navigationState.zoomFactor.changed - : new NullarySignal(); - }; - - const updateCursor = () => { - const radiusInVoxels = this.layer.voxBrushRadius.value; - const zoom = getZoom(); - const radiusInPixels = Math.max(1, radiusInVoxels / zoom); - const svgSize = 2 * radiusInPixels + 4; - const svgCenter = svgSize / 2; - - const svgString = ` - - - - - `.replace(/\s\s+/g, " "); - - const cursorURL = `url('data:image/svg+xml;utf8,${encodeURIComponent(svgString)}')`; - this.setCursor(`${cursorURL} ${svgCenter} ${svgCenter}, crosshair`); - }; - - updateCursor(); - activation.registerDisposer( - this.layer.voxBrushRadius.changed.add(updateCursor), - ); - activation.registerDisposer(getZoomChangedSignal().add(updateCursor)); + activation.registerDisposer(() => { + this.getActivePanel()?.clearOverlay(); this.resetCursor(); }); + activation.registerDisposer( + this.mouseState.changed.add(() => { + this.updateBrushOutline(); + }) + ); + } + + private getActivePanel(): RenderedDataPanel | undefined { + let activePanel: RenderedDataPanel | undefined; + for (const panel of this.layer.manager.root.display.panels) { + if (panel instanceof RenderedDataPanel) { + if (panel.mouseX !== -1) { + activePanel = panel; + } else { + panel.clearOverlay(); + } + } + } + return activePanel; + } + + private updateBrushOutline() { + const panel = this.getActivePanel(); + if (!panel) return; + + const zoom = panel.navigationState.zoomFactor.value; + const radiusInVoxels = this.layer.voxBrushRadius.value; + const radiusInPixels = radiusInVoxels / zoom; + + panel.drawBrushCursor(panel.mouseX, panel.mouseY, radiusInPixels); } activationCallback(_activation: ToolActivation): void { From bef93e3f38ffcf582f2cd644e924ec26b17fc5d4 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:45 +0100 Subject: [PATCH 107/251] fix: ensure brush cursor is shown on tool activation --- src/ui/voxel_annotations.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 9ded5d9b55..a8c2a3fa92 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -137,6 +137,7 @@ export class VoxelBrushTool extends BaseVoxelTool { activate(activation: ToolActivation) { super.activate(activation); + this.updateBrushOutline(); activation.registerDisposer(() => { this.getActivePanel()?.clearOverlay(); From b306e07f7a846c14a182766c0ae49bb301c8e06b Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:45 +0100 Subject: [PATCH 108/251] refactor(voxel-annotations): remove obsolete color controls and limite the value to the valid range for the data type. --- src/layer/vox/controls.ts | 41 +-------------------------- src/layer/vox/index.ts | 50 ++++++++++++++++++++++++++++++--- src/voxel_annotation/TODOs.md | 53 ----------------------------------- 3 files changed, 47 insertions(+), 97 deletions(-) diff --git a/src/layer/vox/controls.ts b/src/layer/vox/controls.ts index 7ef19b0d54..e06d515de0 100644 --- a/src/layer/vox/controls.ts +++ b/src/layer/vox/controls.ts @@ -17,45 +17,14 @@ import type { UserLayerConstructor } from "#src/layer/index.js"; import { LayerActionContext } from "#src/layer/index.js"; import type { UserLayerWithVoxelEditing } from "#src/layer/vox/index.js"; -import type { WatchableValueInterface } from "#src/trackable_value.js"; import { observeWatchable } from "#src/trackable_value.js"; -import { unpackRGB } from "#src/util/color.js"; -import { RefCounted } from "#src/util/disposable.js"; -import { vec3 } from "#src/util/geom.js"; -import { NullarySignal } from "#src/util/signal.js"; import type { LayerControlDefinition } from "#src/widget/layer_control.js"; import { registerLayerControl } from "#src/widget/layer_control.js"; import { buttonLayerControl } from "#src/widget/layer_control_button.js"; import { checkboxLayerControl } from "#src/widget/layer_control_checkbox.js"; -import { colorLayerControl } from "#src/widget/layer_control_color.js"; import { enumLayerControl } from "#src/widget/layer_control_enum.js"; import { rangeLayerControl } from "#src/widget/layer_control_range.js"; -class BigIntAsTrackableRGB - extends RefCounted - implements WatchableValueInterface -{ - changed = new NullarySignal(); - private tempColor = vec3.create(); - - constructor(public source: WatchableValueInterface) { - super(); - this.registerDisposer(source.changed.add(this.changed.dispatch)); - } - - get value(): vec3 { - const bigintValue = this.source.value; - const [r, g, b] = unpackRGB(Number(bigintValue & 0xffffffn)); - vec3.set(this.tempColor, r, g, b); - return this.tempColor; - } - - set value(newValue: vec3) { - const rgb = newValue.map((c: number) => Math.round(c * 255)); - this.source.value = BigInt((rgb[0] << 16) | (rgb[1] << 8) | rgb[2]); - } -} - export const VOXEL_LAYER_CONTROLS: LayerControlDefinition[] = [ { @@ -104,14 +73,6 @@ export const VOXEL_LAYER_CONTROLS: LayerControlDefinition - new BigIntAsTrackableRGB(layer.paintValue), - ), - }, { label: "Paint Value", toolJson: { type: "vox-paint-value" }, @@ -121,7 +82,7 @@ export const VOXEL_LAYER_CONTROLS: LayerControlDefinition { try { - layer.setVoxelPaintValue(BigInt(control.value)); + layer.setVoxelPaintValue(control.value); } catch { control.value = layer.paintValue.value.toString(); } diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index fd8b4f9765..3702f3f000 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -30,6 +30,7 @@ import { getChunkTransformParameters, } from "#src/render_coordinate_transform.js"; import type { SliceViewSourceOptions } from "#src/sliceview/base.js"; +import { DataType } from "#src/sliceview/base.js"; import type { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; import type { ImageRenderLayer } from "#src/sliceview/volume/image_renderlayer.js"; import type { SegmentationRenderLayer } from "#src/sliceview/volume/segmentation_renderlayer.js"; @@ -62,6 +63,16 @@ const BRUSH_SHAPE_JSON_KEY = "brushShape"; const FLOOD_FILL_MAX_VOXELS_JSON_KEY = "floodFillMaxVoxels"; const PAINT_VALUE_JSON_KEY = "paintValue"; +const DATA_TYPE_BIT_INFO = { + [DataType.UINT8]: { bits: 8, signed: false }, + [DataType.INT8]: { bits: 8, signed: true }, + [DataType.UINT16]: { bits: 16, signed: false }, + [DataType.INT16]: { bits: 16, signed: true }, + [DataType.UINT32]: { bits: 32, signed: false }, + [DataType.INT32]: { bits: 32, signed: true }, + [DataType.UINT64]: { bits: 64, signed: false }, +}; + export class VoxelEditingContext extends RefCounted implements VoxelEditControllerHost @@ -189,7 +200,7 @@ export declare abstract class UserLayerWithVoxelEditing extends UserLayer { transform: WatchableValueInterface, ): ImageRenderLayer | SegmentationRenderLayer; abstract getVoxelPaintValue(erase: boolean): bigint; - abstract setVoxelPaintValue(value: bigint): void; + abstract setVoxelPaintValue(value: any): bigint; initializeVoxelEditingForSubsource( loadedSubsource: LoadedDataSubsource, @@ -276,10 +287,41 @@ export function UserLayerWithVoxelEditingMixin< if (erase) return 0n; return this.paintValue.value; } - setVoxelPaintValue(value: bigint) { - this.paintValue.value = value; + + setVoxelPaintValue(x: any) { + const editContext = this.editingContexts.values().next().value; + const dataType = editContext.primarySource.dataType; + let value: bigint; + + if (dataType === DataType.FLOAT32) { + const floatValue = parseFloat(String(x)); + value = BigInt(Math.round(floatValue)); + } else { + value = BigInt(x); + } + + const info = DATA_TYPE_BIT_INFO[dataType as keyof typeof DATA_TYPE_BIT_INFO]; + if (!info) { + this.paintValue.value = value; + return value; + } + + const { bits, signed } = info; + const mask = (1n << BigInt(bits)) - 1n; + let truncated = value & mask; + + if (signed) { + const signBit = 1n << BigInt(bits - 1); + if ((truncated & signBit) !== 0n) { + truncated -= (1n << BigInt(bits)); + } + } + + this.paintValue.value = truncated; + return truncated; } + abstract _createVoxelRenderLayer( source: MultiscaleVolumeChunkSource, transform: WatchableValueInterface, @@ -359,7 +401,7 @@ export function UserLayerWithVoxelEditingMixin< controller.redo(); break; case "randomize-paint-value": - this.paintValue.value = randomUint64(); + this.setVoxelPaintValue(randomUint64()); break; } } diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 1edcff786d..711fdf428e 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -17,56 +17,3 @@ - design a dataset creation feature - adapt the brush size to the zoom level linearly - -## Merging vox layer into seg and img layers - -The proposed architecture integrates voxel editing directly into the existing Image and Segmentation layers by leveraging their inherent capabilities, rather than introducing a separate, simplified render layer. The core of the design is a new UserLayerWithVoxelEditingMixin which equips a host UserLayer with an editing controller and an associated in-memory VolumeChunkSource for optimistic previews. When a user paints, the edits are applied locally to this in-memory source. A second instance of the layer's primary, feature-rich RenderLayer class is then used to draw these edits as an overlay. This ensures the live preview is rendered with the exact same user-defined shaders and settings as the base data for perfect visual fidelity, while also elegantly handling the performance issue of editing compressed chunks by operating on an uncompressed in-memory source. This architecture reuses existing components, simplifies the overall codebase by eliminating the need for a separate VoxelAnnotationRenderLayer, and cleanly separates the concerns of displaying committed data versus previewing transient edits. - -```mermaid -sequenceDiagram -participant User -participant Tool as VoxelBrushTool -participant ControllerFE as VoxelEditController (FE) -participant EditSourceFE as OverlayChunkSource (FE) -participant BaseSourceFE as VolumeChunkSource (FE) -participant ControllerBE as VoxelEditController (BE) -participant BaseSourceBE as VolumeChunkSource (BE) - - User->>Tool: Mouse Down/Drag - Tool->>ControllerFE: paintBrushWithShape(mouse, ...) - ControllerFE->>ControllerFE: Calculates affected voxels and chunks - - ControllerFE->>EditSourceFE: applyLocalEdits(chunkKeys, ...) - activate EditSourceFE - EditSourceFE->>EditSourceFE: Modifies its own in-memory chunk data - note over EditSourceFE: This chunk's texture is re-uploaded to the GPU - deactivate EditSourceFE - - ControllerFE->>ControllerBE: commitEdits(edits, ...) [RPC] - - activate ControllerBE - ControllerBE->>ControllerBE: Debounces and batches edits - ControllerBE->>BaseSourceBE: applyEdits(chunkKeys, ...) - activate BaseSourceBE - BaseSourceBE-->>ControllerBE: Returns VoxelChange (for undo stack) - deactivate BaseSourceBE - ControllerBE->>ControllerFE: callChunkReload(chunkKeys) [RPC] - activate ControllerFE - ControllerFE->>BaseSourceFE: invalidateChunks(chunkKeys) - note over BaseSourceFE: BaseSourceFE re-fetches chunk with the now-permanent edit. - ControllerFE->>EditSourceFE: clearOptimisticChunk(chunkKeys) - deactivate ControllerFE - - ControllerBE->>ControllerBE: Pushes change to Undo Stack & enqueues for downsampling - deactivate ControllerBE - - loop Downsampling & Reload Cascade - ControllerBE->>ControllerBE: downsampleStep(chunkKeys) - ControllerBE->>ControllerFE: callChunkReload(chunkKeys) [RPC] - activate ControllerFE - ControllerFE->>BaseSourceFE: invalidateChunks(chunkKeys) - note over BaseSourceFE: BaseSourceFE re-fetches chunk with the now-permanent edit. - ControllerFE->>EditSourceFE: clearOptimisticChunk(chunkKeys) - deactivate ControllerFE - end -``` From 3b7650e9d32eb353b31763eb314a16bb346f30a4 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:45 +0100 Subject: [PATCH 109/251] fix(voxel-annotations): do not display the brush cursor in the 3d view --- src/layer/vox/index.ts | 6 +++--- src/rendered_data_panel.ts | 31 ++++++++++++++++++------------- src/ui/voxel_annotations.ts | 5 +++-- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 3702f3f000..1b99d036d5 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -300,7 +300,8 @@ export function UserLayerWithVoxelEditingMixin< value = BigInt(x); } - const info = DATA_TYPE_BIT_INFO[dataType as keyof typeof DATA_TYPE_BIT_INFO]; + const info = + DATA_TYPE_BIT_INFO[dataType as keyof typeof DATA_TYPE_BIT_INFO]; if (!info) { this.paintValue.value = value; return value; @@ -313,7 +314,7 @@ export function UserLayerWithVoxelEditingMixin< if (signed) { const signBit = 1n << BigInt(bits - 1); if ((truncated & signBit) !== 0n) { - truncated -= (1n << BigInt(bits)); + truncated -= 1n << BigInt(bits); } } @@ -321,7 +322,6 @@ export function UserLayerWithVoxelEditingMixin< return truncated; } - abstract _createVoxelRenderLayer( source: MultiscaleVolumeChunkSource, transform: WatchableValueInterface, diff --git a/src/rendered_data_panel.ts b/src/rendered_data_panel.ts index 213ec678c0..28ad4e6b9e 100644 --- a/src/rendered_data_panel.ts +++ b/src/rendered_data_panel.ts @@ -816,16 +816,16 @@ export abstract class RenderedDataPanel extends RenderedPanel { }, ); - this.overlay_canvas = document.createElement('canvas'); - this.overlay_canvas.style.position = 'absolute'; - this.overlay_canvas.style.top = '0'; - this.overlay_canvas.style.left = '0'; - this.overlay_canvas.style.width = '100%'; - this.overlay_canvas.style.height = '100%'; - this.overlay_canvas.style.pointerEvents = 'none'; - this.overlay_canvas.style.zIndex = '10'; + this.overlay_canvas = document.createElement("canvas"); + this.overlay_canvas.style.position = "absolute"; + this.overlay_canvas.style.top = "0"; + this.overlay_canvas.style.left = "0"; + this.overlay_canvas.style.width = "100%"; + this.overlay_canvas.style.height = "100%"; + this.overlay_canvas.style.pointerEvents = "none"; + this.overlay_canvas.style.zIndex = "10"; this.element.appendChild(this.overlay_canvas); - this.overlay_context = this.overlay_canvas.getContext('2d')!; + this.overlay_context = this.overlay_canvas.getContext("2d")!; this.boundsUpdated.add(() => { this.overlay_canvas.width = this.renderViewport.logicalWidth; @@ -842,19 +842,24 @@ export abstract class RenderedDataPanel extends RenderedPanel { if (radius > 0) { ctx.beginPath(); ctx.arc(x, y, radius, 0, 2 * Math.PI); - ctx.fillStyle = 'rgba(255, 255, 255, 0.2)'; + ctx.fillStyle = "rgba(255, 255, 255, 0.2)"; ctx.fill(); - ctx.strokeStyle = 'rgba(255, 255, 255, 1)'; + ctx.strokeStyle = "rgba(255, 255, 255, 1)"; ctx.lineWidth = 3; ctx.stroke(); - ctx.strokeStyle = 'rgba(0, 0, 0, 1)'; + ctx.strokeStyle = "rgba(0, 0, 0, 1)"; ctx.lineWidth = 1.5; ctx.stroke(); } } clearOverlay() { - this.overlay_context.clearRect(0, 0, this.overlay_canvas.width, this.overlay_canvas.height); + this.overlay_context.clearRect( + 0, + 0, + this.overlay_canvas.width, + this.overlay_canvas.height, + ); } abstract translateDataPointByViewportPixels( diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index a8c2a3fa92..a975e76a5e 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -20,6 +20,7 @@ import type { VoxelEditingContext, } from "#src/layer/vox/index.js"; import { RenderedDataPanel } from "#src/rendered_data_panel.js"; +import { SliceViewPanel } from "#src/sliceview/panel.js"; import { StatusMessage } from "#src/status.js"; import { LayerTool, registerTool, type ToolActivation } from "#src/ui/tool.js"; import { vec3 } from "#src/util/geom.js"; @@ -146,7 +147,7 @@ export class VoxelBrushTool extends BaseVoxelTool { activation.registerDisposer( this.mouseState.changed.add(() => { this.updateBrushOutline(); - }) + }), ); } @@ -154,7 +155,7 @@ export class VoxelBrushTool extends BaseVoxelTool { let activePanel: RenderedDataPanel | undefined; for (const panel of this.layer.manager.root.display.panels) { if (panel instanceof RenderedDataPanel) { - if (panel.mouseX !== -1) { + if (panel.mouseX !== -1 && panel instanceof SliceViewPanel) { activePanel = panel; } else { panel.clearOverlay(); From 3a7ebbce33dc95e2745d84faf00afbe7e53f9063 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:45 +0100 Subject: [PATCH 110/251] refactor(voxel-annotations): rework chunk reloading pipeline -> wait for the entire downsample cascade before reloading chunks, this fixes the disappearing preview at low res. --- src/sliceview/volume/frontend.ts | 1 - src/voxel_annotation/TODOs.md | 4 +--- src/voxel_annotation/edit_backend.ts | 18 +++++++----------- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index 30f64172c1..c1d531fd29 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -342,7 +342,6 @@ export class InMemoryVolumeChunkSource extends VolumeChunkSource { } }; // adding a small delay to avoid flickering since the base source will take some time to download the new data - // TODO: it would be better to reload the preview once the base source is good, with big brushes this delay is not sufficient setTimeout(update, 100); } diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 711fdf428e..cea26fe024 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -7,13 +7,11 @@ ### later -- should we allow drawing even when there are no writable volume, and in that case inform the user about it and only draw edits in the preview layer? I am not sure about the real use cases tho. - add preview for the undo/redo -- optimize flood fill tool (it is too slow on area containing uncached chunks, due to the getEnsuredValueAt() calls) - the flood fill sometimes leaves artifacts in sharp areas (maybe increase fillBorderRegion() radius) -- write a testsuite for the downsampler and ensure its proper working on exotic lod levels ### questionable +- write a testsuite for the downsampler and ensure its proper working on exotic lod levels - design a dataset creation feature - adapt the brush size to the zoom level linearly diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/edit_backend.ts index 5d8e0f4db2..ce42db50b1 100644 --- a/src/voxel_annotation/edit_backend.ts +++ b/src/voxel_annotation/edit_backend.ts @@ -195,7 +195,6 @@ export class VoxelEditController extends SharedObject { if (firstErrorMessage === undefined) firstErrorMessage = msg; } } - this.callChunkReload(editsByVoxKey.keys().toArray()); if (newAction.changes.size > 0) { this.undoStack.push(newAction); @@ -273,7 +272,13 @@ export class VoxelEditController extends SharedObject { while (this.downsampleQueue.length > 0) { const key = this.downsampleQueue.shift() as string; this.downsampleQueueSet.delete(key); - await this.performDownsampleCascadeForKey(key); + const allModifiedKeys = new Array(); + let currentKey: string | null = key; + while (currentKey !== null) { + allModifiedKeys.push(currentKey); + currentKey = await this.downsampleStep(currentKey); + } + this.callChunkReload(allModifiedKeys); } } finally { this.isProcessingDownsampleQueue = false; @@ -287,15 +292,6 @@ export class VoxelEditController extends SharedObject { } } - private async performDownsampleCascadeForKey( - sourceKey: string, - ): Promise { - let currentKey: string | null = sourceKey; - while (currentKey !== null) { - currentKey = await this.downsampleStep(currentKey); - } - } - /** * Performs a single downsampling step from a child chunk to its parent. * @returns The key of the parent chunk that was updated, or null if the cascade should stop. From 8eca17e47ac93b7a492132556f37fdeae8a91c86 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 15:12:28 +0100 Subject: [PATCH 111/251] refactor(voxel-annotations): previous commit introduced serious flickering on chunk reload, the reloading pipeline was reverted for the primaryRenderLayer and the new logic only kept for the optimistic one. --- src/layer/vox/index.ts | 18 ++++++++++- src/voxel_annotation/edit_backend.ts | 7 +++-- src/voxel_annotation/edit_controller.ts | 41 +++++++------------------ 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 1b99d036d5..902bf9b846 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -29,8 +29,13 @@ import { getChunkPositionFromCombinedGlobalLocalPositions, getChunkTransformParameters, } from "#src/render_coordinate_transform.js"; -import type { SliceViewSourceOptions } from "#src/sliceview/base.js"; +import type { + SliceViewBase, + SliceViewSourceOptions, + TransformedSource, +} from "#src/sliceview/base.js"; import { DataType } from "#src/sliceview/base.js"; +import type { SliceViewRenderLayer } from "#src/sliceview/renderlayer.js"; import type { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; import type { ImageRenderLayer } from "#src/sliceview/volume/image_renderlayer.js"; import type { SegmentationRenderLayer } from "#src/sliceview/volume/segmentation_renderlayer.js"; @@ -348,6 +353,17 @@ export function UserLayerWithVoxelEditingMixin< transform, ); + // since we only allow drawing at max res, we can lock the optimistic render layer to it + optimisticRenderLayer.filterVisibleSources = function* ( + this: SliceViewRenderLayer, + _sliceView: SliceViewBase, + sources: readonly TransformedSource[], + ): Iterable { + if (sources.length > 0) { + yield sources[0]; + } + }; + const context = new VoxelEditingContext( this, primarySource, diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/edit_backend.ts index ce42db50b1..1bf030bfcc 100644 --- a/src/voxel_annotation/edit_backend.ts +++ b/src/voxel_annotation/edit_backend.ts @@ -196,6 +196,8 @@ export class VoxelEditController extends SharedObject { } } + this.callChunkReload(editsByVoxKey.keys().toArray()); + if (newAction.changes.size > 0) { this.undoStack.push(newAction); if (this.undoStack.length > this.MAX_HISTORY_SIZE) { @@ -246,10 +248,11 @@ export class VoxelEditController extends SharedObject { }, this.commitDebounceDelayMs) as unknown as number; } - callChunkReload(voxChunkKeys: string[]) { + callChunkReload(voxChunkKeys: string[], isForPreviewChunks = false) { this.rpc?.invoke(VOX_RELOAD_CHUNKS_RPC_ID, { rpcId: this.rpcId, voxChunkKeys: voxChunkKeys, + isForPreviewChunks, }); } @@ -278,7 +281,7 @@ export class VoxelEditController extends SharedObject { allModifiedKeys.push(currentKey); currentKey = await this.downsampleStep(currentKey); } - this.callChunkReload(allModifiedKeys); + this.callChunkReload(allModifiedKeys, true); } } finally { this.isProcessingDownsampleQueue = false; diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 92288351aa..0c22161a6a 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -548,49 +548,30 @@ export class VoxelEditController extends SharedObject { return { edits: backendEdits, filledCount, originalValue }; } - callChunkReload(voxChunkKeys: string[]) { + callChunkReload(voxChunkKeys: string[], isForPreviewChunks: boolean) { if (!Array.isArray(voxChunkKeys) || voxChunkKeys.length === 0) return; - const baseSources = this.host.primarySource.getSources( - this.getIdentitySliceViewSourceOptions(), - )[0]; - if (!baseSources) { + const sources = ( + isForPreviewChunks ? this.host.previewSource : this.host.primarySource + ).getSources(this.getIdentitySliceViewSourceOptions())[0]; + if (!sources) { throw new Error( "VoxelEditController.callChunkReload: Missing base source", ); } - const previewSources = this.host.previewSource.getSources( - this.getIdentitySliceViewSourceOptions(), - )[0]; - if (!previewSources) { - throw new Error( - "VoxelEditController.callChunkReload: Missing preview source", - ); - } const chunksToInvalidateBySource = new Map(); for (const voxKey of voxChunkKeys) { const parsed = parseVoxChunkKey(voxKey); if (!parsed) continue; - const baseSource = baseSources[parsed.lodIndex]?.chunkSource as + const source = sources[parsed.lodIndex]?.chunkSource as | VolumeChunkSource | undefined; - const previewSource = previewSources[parsed.lodIndex]?.chunkSource as - | VolumeChunkSource - | undefined; - if (previewSource) { - let arr = chunksToInvalidateBySource.get(previewSource); - if (!arr) { - arr = []; - chunksToInvalidateBySource.set(previewSource, arr); - } - arr.push(parsed.chunkKey); - } - if (baseSource) { - let arr = chunksToInvalidateBySource.get(baseSource); + if (source) { + let arr = chunksToInvalidateBySource.get(source); if (!arr) { arr = []; - chunksToInvalidateBySource.set(baseSource, arr); + chunksToInvalidateBySource.set(source, arr); } arr.push(parsed.chunkKey); } @@ -605,7 +586,7 @@ export class VoxelEditController extends SharedObject { handleCommitFailure(voxChunkKeys: string[], message: string): void { try { - this.callChunkReload(voxChunkKeys); + this.callChunkReload(voxChunkKeys, true); } finally { StatusMessage.showTemporaryMessage(message); } @@ -637,7 +618,7 @@ export class VoxelEditController extends SharedObject { registerRPC(VOX_RELOAD_CHUNKS_RPC_ID, function (x: any) { const obj = this.get(x.rpcId) as VoxelEditController; const keys: string[] = Array.isArray(x.voxChunkKeys) ? x.voxChunkKeys : []; - obj.callChunkReload(keys); + obj.callChunkReload(keys, x.isForPreviewChunks); }); registerRPC(VOX_EDIT_FAILURE_RPC_ID, function (x: any) { From 9c1cfd02c1816d8e0665844286c7f2aba7a9fc73 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 15:30:11 +0100 Subject: [PATCH 112/251] chore(voxel-annotations): Adjust flood fill to avoid small artifacts. --- src/voxel_annotation/TODOs.md | 1 - src/voxel_annotation/edit_controller.ts | 13 +++++-------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index cea26fe024..7d4d615a8c 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -8,7 +8,6 @@ ### later - add preview for the undo/redo -- the flood fill sometimes leaves artifacts in sharp areas (maybe increase fillBorderRegion() radius) ### questionable diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 0c22161a6a..1463b17349 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -417,7 +417,7 @@ export class VoxelEditController extends SharedObject { ) => { const subQueue: [number, number][] = []; // The bounding box for the local fill is defined in the (u, v) coordinate system - const halfSize = Math.floor(requiredThickness / 2) + 1; + const halfSize = requiredThickness * 2; // multiply by 2 to avoid small artefacts const startKey = `${startU},${startV}`; if (visited.has(startKey)) return; @@ -439,13 +439,10 @@ export class VoxelEditController extends SharedObject { [u, v - 1], ]; for (const [nu, nv] of neighbors2d) { - // Constrain this local search to a small bounding box - if ( - nu < startU - halfSize || - nu > startU + halfSize || - nv < startV - halfSize || - nv > startV + halfSize - ) { + const du = nu - startU; + const dv = nv - startV; + const distanceSquared = du * du + dv * dv; + if (distanceSquared > halfSize * halfSize) { continue; } From 2513c55747a356d450535a92de2a4c33cea05d6d Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Tue, 11 Nov 2025 15:43:30 +0100 Subject: [PATCH 113/251] feat(voxel-annotations): add support for the seg picker tool to cycle through volumes (writable or not) in the goal of supporting a "writing over read-only sources" workflow --- src/layer/image/index.ts | 17 ++-- src/layer/segmentation/index.ts | 17 ++-- src/layer/vox/index.ts | 76 +++++++++------- src/ui/voxel_annotations.ts | 115 ++++++++++++++---------- src/voxel_annotation/TODOs.md | 1 - src/voxel_annotation/base.ts | 2 +- src/voxel_annotation/edit_controller.ts | 23 ++++- 7 files changed, 148 insertions(+), 103 deletions(-) diff --git a/src/layer/image/index.ts b/src/layer/image/index.ts index 01886601e0..c44cb70ddb 100644 --- a/src/layer/image/index.ts +++ b/src/layer/image/index.ts @@ -342,15 +342,14 @@ void main() { this.shaderError.changed.dispatch(); context.registerDisposer( registerNested((context, isWritable) => { - if (isWritable) { - this.initializeVoxelEditingForSubsource( - loadedSubsource, - imageRenderLayer, - ); - context.registerDisposer(() => { - this.deinitializeVoxelEditingForSubsource(loadedSubsource); - }); - } + this.initializeVoxelEditingForSubsource( + loadedSubsource, + imageRenderLayer, + isWritable, + ); + context.registerDisposer(() => { + this.deinitializeVoxelEditingForSubsource(loadedSubsource); + }); }, loadedSubsource.writable), ); }); diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 3a04ed89a3..6bf727292c 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -805,15 +805,14 @@ export class SegmentationUserLayer extends Base { loadedSubsource.addRenderLayer(segmentationRenderLayer); context.registerDisposer( registerNested((context, isWritable) => { - if (isWritable) { - this.initializeVoxelEditingForSubsource( - loadedSubsource, - segmentationRenderLayer, - ); - context.registerDisposer(() => { - this.deinitializeVoxelEditingForSubsource(loadedSubsource); - }); - } + this.initializeVoxelEditingForSubsource( + loadedSubsource, + segmentationRenderLayer, + isWritable, + ); + context.registerDisposer(() => { + this.deinitializeVoxelEditingForSubsource(loadedSubsource); + }); }, loadedSubsource.writable), ); }, this.displayState.segmentationGroupState.value); diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 902bf9b846..026b2f372e 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -82,22 +82,53 @@ export class VoxelEditingContext extends RefCounted implements VoxelEditControllerHost { - controller: VoxelEditController; + controller: VoxelEditController | undefined = undefined; private cachedChunkTransform: ChunkTransformParameters | undefined; private cachedTransformGeneration: number = -1; private cachedVoxelPosition: Float32Array = new Float32Array(3); + optimisticRenderLayer: + | ImageRenderLayer + | SegmentationRenderLayer + | undefined = undefined; + previewSource: VoxelPreviewMultiscaleSource | undefined = undefined; constructor( public hostLayer: UserLayerWithVoxelEditing, public primarySource: MultiscaleVolumeChunkSource, - public previewSource: VoxelPreviewMultiscaleSource, - public optimisticRenderLayer: ImageRenderLayer | SegmentationRenderLayer, public primaryRenderLayer: ImageRenderLayer | SegmentationRenderLayer, + public writable: boolean, ) { super(); + + if (!writable) return; + + this.previewSource = new VoxelPreviewMultiscaleSource( + this.hostLayer.manager.chunkManager, + primarySource, + ); + + const transform = primaryRenderLayer.transform; + + this.optimisticRenderLayer = this.hostLayer._createVoxelRenderLayer( + this.previewSource, + transform, + ); + + // since we only allow drawing at max res, we can lock the optimistic render layer to it + this.optimisticRenderLayer.filterVisibleSources = function* ( + this: SliceViewRenderLayer, + _sliceView: SliceViewBase, + sources: readonly TransformedSource[], + ): Iterable { + if (sources.length > 0) { + yield sources[0]; + } + }; + + this.hostLayer.addRenderLayer(this.optimisticRenderLayer); + this.controller = new VoxelEditController(this); - //this.registerDisposer(optimisticRenderLayer); } get rpc() { @@ -105,14 +136,16 @@ export class VoxelEditingContext } disposed() { - this.controller.dispose(); + if (this.controller) this.controller.dispose(); + if (this.optimisticRenderLayer) + this.hostLayer.removeRenderLayer(this.optimisticRenderLayer); super.disposed(); } getVoxelPositionFromMouse( mouseState: MouseSelectionState, ): Float32Array | undefined { - const renderLayer = this.optimisticRenderLayer; + const renderLayer = this.primaryRenderLayer; const renderLayerTransform = renderLayer.transform.value; if (renderLayerTransform.error !== undefined) { return undefined; @@ -335,51 +368,26 @@ export function UserLayerWithVoxelEditingMixin< initializeVoxelEditingForSubsource( loadedSubsource: LoadedDataSubsource, renderlayer: SegmentationRenderLayer | ImageRenderLayer, + writable: boolean = true, ): void { if (this.editingContexts.has(loadedSubsource)) return; const primarySource = loadedSubsource.subsourceEntry.subsource .volume as MultiscaleVolumeChunkSource; - const previewSource = new VoxelPreviewMultiscaleSource( - this.manager.chunkManager, - primarySource, - ); - - const transform = loadedSubsource.getRenderLayerTransform(); - - const optimisticRenderLayer = this._createVoxelRenderLayer( - previewSource, - transform, - ); - - // since we only allow drawing at max res, we can lock the optimistic render layer to it - optimisticRenderLayer.filterVisibleSources = function* ( - this: SliceViewRenderLayer, - _sliceView: SliceViewBase, - sources: readonly TransformedSource[], - ): Iterable { - if (sources.length > 0) { - yield sources[0]; - } - }; - const context = new VoxelEditingContext( this, primarySource, - previewSource, - optimisticRenderLayer, renderlayer, + writable, ); this.editingContexts.set(loadedSubsource, context); - this.addRenderLayer(optimisticRenderLayer); - this.isEditable.value = true; + this.isEditable.value = writable; } deinitializeVoxelEditingForSubsource(loadedSubsource: LoadedDataSubsource) { const context = this.editingContexts.get(loadedSubsource); if (context) { - this.removeRenderLayer(context.optimisticRenderLayer); context.dispose(); this.editingContexts.delete(loadedSubsource); } diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index a975e76a5e..e974f643f1 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -19,6 +19,7 @@ import type { UserLayerWithVoxelEditing, VoxelEditingContext, } from "#src/layer/vox/index.js"; +import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; import { RenderedDataPanel } from "#src/rendered_data_panel.js"; import { SliceViewPanel } from "#src/sliceview/panel.js"; import { StatusMessage } from "#src/status.js"; @@ -40,7 +41,12 @@ abstract class BaseVoxelTool extends LayerTool { protected latestMouseState: MouseSelectionState | null = null; protected getEditingContext(): VoxelEditingContext | undefined { - return this.layer.editingContexts.values().next().value; + const it = this.layer.editingContexts.values(); + let ctx: VoxelEditingContext; + while ((ctx = it.next().value) !== undefined) { + if (ctx.writable) return ctx; + } + return undefined; } protected getPoint(mouseState: MouseSelectionState): Int32Array | undefined { @@ -369,6 +375,8 @@ export class VoxelFloodFillTool extends BaseVoxelTool { if (!Number.isFinite(max) || max <= 0) { throw new Error("Invalid max fill voxels setting"); } + if (!editContext.controller) + throw new Error("Error: No controller available"); editContext.controller .floodFillPlane2D( new Float32Array(seed), @@ -412,6 +420,16 @@ const pickerSVG = ` { + for (let i = 0; i < numSources; ++i) { + const sourceIndex = (startIndex + i) % numSources; + const context = allContexts[sourceIndex]!; - const visibleSources = renderLayer.visibleSourcesList; - if (visibleSources.length === 0) { + const voxelCoord = context.getVoxelPositionFromMouse(this.mouseState); + if (voxelCoord === undefined) continue; + + const source = context.primarySource.getSources( + this.layer.getIdentitySliceViewSourceOptions(), + )[0][0]!.chunkSource; + + const valueResult = await source.getEnsuredValueAt( + voxelCoord, + this.singleChannelAccess, + ); + const value = Array.isArray(valueResult) ? valueResult[0] : valueResult; + const bigValue = BigInt(value || 0); + + if (bigValue !== 0n) { + this.layer.setVoxelPaintValue(bigValue); + this.lastCheckedSourceIndex = sourceIndex; + StatusMessage.showTemporaryMessage( + `Adopted value: ${bigValue} (from source ${sourceIndex + 1}/${numSources})`, + 3000, + ); + return; + } + } + + this.lastCheckedSourceIndex = -1; StatusMessage.showTemporaryMessage( - "No data is visible at the current zoom level.", + "No further segments found at this position.", 3000, ); - return; - } + }; - const source = visibleSources[0].source; - const channelAccess = voxelEditingContext.controller.singleChannelAccess; - - StatusMessage.forPromise( - source - .getEnsuredValueAt(pos, channelAccess) - .then((data: bigint | number | null) => { - if (data === null) { - throw new Error( - "Voxel data not available at the selected position.", - ); - } - const value = BigInt(data); - this.layer.setVoxelPaintValue(value); - StatusMessage.showTemporaryMessage(`Adopted value: ${value}`, 3000); - }), - { - initialMessage: "Picking voxel value...", - delay: true, - errorPrefix: "Error picking value: ", - }, - ); + StatusMessage.forPromise(checkNextSource(), { + initialMessage: "Picking voxel value...", + delay: true, + errorPrefix: "Error picking value: ", + }); } } diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 7d4d615a8c..998942046c 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -3,7 +3,6 @@ ### priority - url completion for the ssa+https source -- fix the case of multiple datasources in the same layer ### later diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index d715ecc966..6aef84109e 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -80,6 +80,6 @@ export enum BrushShape { export interface VoxelEditControllerHost { primarySource: MultiscaleVolumeChunkSource; - previewSource: VoxelPreviewMultiscaleSource; + previewSource?: VoxelPreviewMultiscaleSource; rpc: RPC; } diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 1463b17349..b52c3ee693 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -170,6 +170,10 @@ export class VoxelEditController extends SharedObject { // For V1 we use the minimum LOD (index 0) const voxelSize = 1; const sourceIndex = 0; + if (!this.host.previewSource) + throw new Error( + "paintBrushWithShape: ERROR Missing preview source from host.", + ); const source = this.host.previewSource.getSources( this.getIdentitySliceViewSourceOptions(), )[0][sourceIndex]!.chunkSource as InMemoryVolumeChunkSource; @@ -496,7 +500,10 @@ export class VoxelEditController extends SharedObject { } } } - + if (!this.host.previewSource) + throw new Error( + "paintBrushWithShape: ERROR Missing preview source from host.", + ); const previewSource = this.host.previewSource.getSources( this.getIdentitySliceViewSourceOptions(), )[0][sourceIndex]!.chunkSource as InMemoryVolumeChunkSource; @@ -547,9 +554,17 @@ export class VoxelEditController extends SharedObject { callChunkReload(voxChunkKeys: string[], isForPreviewChunks: boolean) { if (!Array.isArray(voxChunkKeys) || voxChunkKeys.length === 0) return; - const sources = ( - isForPreviewChunks ? this.host.previewSource : this.host.primarySource - ).getSources(this.getIdentitySliceViewSourceOptions())[0]; + const multiscaleSource = isForPreviewChunks + ? this.host.previewSource + : this.host.primarySource; + if (!multiscaleSource) { + throw new Error( + "VoxelEditController.callChunkReload: ERROR Missing source", + ); + } + const sources = multiscaleSource.getSources( + this.getIdentitySliceViewSourceOptions(), + )[0]; if (!sources) { throw new Error( "VoxelEditController.callChunkReload: Missing base source", From fb029f6d8d6bacddb97256a997fe28ff115f3ba4 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 12 Nov 2025 12:01:45 +0100 Subject: [PATCH 114/251] feat(datasource): add POC for dataset creation feature : ui + main pipeline + hardcoded zarr metadata creation --- src/datasource/index.ts | 13 ++ src/datasource/zarr/backend.ts | 4 +- src/datasource/zarr/frontend.ts | 63 ++++++++ src/kvstore/proxy.ts | 43 +++++ src/kvstore/shared_common.ts | 1 + src/layer/layer_data_source.ts | 19 +++ src/ui/dataset_creation.ts | 276 ++++++++++++++++++++++++++++++++ 7 files changed, 417 insertions(+), 2 deletions(-) create mode 100644 src/ui/dataset_creation.ts diff --git a/src/datasource/index.ts b/src/datasource/index.ts index a5703c2852..2812d0f179 100644 --- a/src/datasource/index.ts +++ b/src/datasource/index.ts @@ -240,6 +240,13 @@ export function makeEmptyDataSourceSpecification(): DataSourceSpecification { }; } +export interface CreateDataSourceOptions { + kvStoreUrl: string; + metadata: Record; + registry: DataSourceRegistry; + signal?: AbortSignal; +} + export interface DataSourceProvider { scheme: string; description?: string; @@ -264,6 +271,7 @@ export interface KvStoreBasedDataSourceProvider { completeUrl?: ( options: GetKvStoreBasedDataSourceOptions, ) => Promise; + create?(options: CreateDataSourceOptions): Promise; } export interface GetKvStoreBasedDataSourceOptions @@ -298,6 +306,11 @@ export class DataSourceRegistry extends RefCounted { registerKvStoreBasedProvider(provider: KvStoreBasedDataSourceProvider) { this.kvStoreBasedDataSources.set(provider.scheme, provider); } + getKvStoreBasedProvider( + scheme: string, + ): KvStoreBasedDataSourceProvider | undefined { + return this.kvStoreBasedDataSources.get(scheme); + } getProvider(url: string): [DataSourceProvider, string, string] { const m = url.match(schemePattern); diff --git a/src/datasource/zarr/backend.ts b/src/datasource/zarr/backend.ts index 0db65d89a6..f9518566c0 100644 --- a/src/datasource/zarr/backend.ts +++ b/src/datasource/zarr/backend.ts @@ -195,8 +195,8 @@ export class ZarrVolumeChunkSource extends WithParameters( sep = metadata.dimensionSeparator; } - const key = getChunkKey(chunkGridPosition, baseKey) as string | unknown; + const key = getChunkKey(chunkGridPosition, baseKey); const arrayBuffer = new Uint8Array(encoded).buffer; - await kvStore.write!(key as any, arrayBuffer); + await kvStore.write!(key, arrayBuffer); } } diff --git a/src/datasource/zarr/frontend.ts b/src/datasource/zarr/frontend.ts index 1c034b4c4e..148ab7efc5 100644 --- a/src/datasource/zarr/frontend.ts +++ b/src/datasource/zarr/frontend.ts @@ -27,6 +27,7 @@ import { } from "#src/coordinate_transform.js"; import type { ChannelMetadata, + CreateDataSourceOptions, DataSource, GetKvStoreBasedDataSourceOptions, KvStoreBasedDataSourceProvider, @@ -57,8 +58,10 @@ import { simpleFilePresenceAutoDetectDirectorySpec } from "#src/kvstore/auto_det import { WithSharedKvStoreContext } from "#src/kvstore/chunk_source_frontend.js"; import type { CompletionResult } from "#src/kvstore/context.js"; import type { SharedKvStoreContext } from "#src/kvstore/frontend.js"; +import { proxyWrite } from "#src/kvstore/proxy.js"; import { joinBaseUrlAndPath, + joinPath, kvstoreEnsureDirectoryPipelineUrl, parseUrlSuffix, pipelineUrlJoin, @@ -597,6 +600,66 @@ export class ZarrDataSource implements KvStoreBasedDataSourceProvider { ), ); } + async create(options: CreateDataSourceOptions): Promise { + const { kvStoreUrl, metadata } = options; + const { sharedKvStoreContext } = options.registry; + const { kvStoreContext } = sharedKvStoreContext; + + const kvStore = kvStoreContext.getKvStore(kvStoreUrl); + + const zarrVersion = metadata.zarrVersion || this.zarrVersion || 2; + let metadataFilename: string; + let metadataContent: string; + + if (zarrVersion === 3) { + metadataFilename = "zarr.json"; + const zarrMetadata = { + zarr_format: 3, + node_type: "array", + shape: metadata.shape, + data_type: metadata.dataType, + chunk_grid: { + name: "regular", + configuration: { chunk_shape: metadata.chunkShape }, + }, + chunk_key_encoding: { + name: "default", + configuration: { separator: "/" }, + }, + codecs: metadata.codecs || [ + { name: "bytes", configuration: { endian: "little" } }, + { name: "gzip", configuration: { level: 1 } }, + ], + fill_value: metadata.fillValue || 0, + attributes: metadata.attributes || {}, + }; + metadataContent = JSON.stringify(zarrMetadata, null, 2); + } else { + // zarrVersion === 2 + metadataFilename = ".zarray"; + const zarrMetadata = { + zarr_format: 2, + shape: metadata.shape, + chunks: metadata.chunkShape, + dtype: metadata.dtype, + compressor: metadata.compressor || { id: "gzip", level: 1 }, + fill_value: metadata.fillValue || 0, + order: "C", + filters: null, + }; + metadataContent = JSON.stringify(zarrMetadata, null, 2); + } + + const metadataUrl = kvStore.store.getUrl( + joinPath(kvStore.path, metadataFilename), + ); + + await proxyWrite( + sharedKvStoreContext, + metadataUrl, + new TextEncoder().encode(metadataContent).buffer as ArrayBuffer, + ); + } } export function registerAutoDetectV2(registry: AutoDetectRegistry) { diff --git a/src/kvstore/proxy.ts b/src/kvstore/proxy.ts index f1a2f4bda3..9774ffd96f 100644 --- a/src/kvstore/proxy.ts +++ b/src/kvstore/proxy.ts @@ -30,6 +30,7 @@ import { READ_RPC_ID, STAT_RPC_ID, COMPLETE_URL_RPC_ID, + WRITE_RPC_ID, } from "#src/kvstore/shared_common.js"; import { finalPipelineUrlComponent, @@ -227,6 +228,48 @@ registerPromiseRPC( }, ); +export async function proxyWrite( + sharedKvStoreContext: SharedKvStoreContextBase, + url: string, + data: ArrayBuffer, +): Promise { + await sharedKvStoreContext.rpc!.promiseInvoke( + WRITE_RPC_ID, + { + sharedKvStoreContext: sharedKvStoreContext.rpcId, + url, + data, + }, + { transfers: [data] }, + ); +} + +registerPromiseRPC( + WRITE_RPC_ID, + async function ( + this: RPC, + options: { + sharedKvStoreContext: number; + url: string; + data: ArrayBuffer; + }, + ) { + const sharedKvStoreContext: SharedKvStoreContextBase = this.get( + options.sharedKvStoreContext, + ); + const { store, path } = sharedKvStoreContext.kvStoreContext.getKvStore( + options.url, + ); + if (store.write === undefined) { + throw new Error( + `The specified storage location is not writable: ${options.url}`, + ); + } + await store.write(path, options.data); + return { value: undefined }; + }, +); + export abstract class ProxyReadableKvStore { constructor(public sharedKvStoreContext: SharedKvStoreContextBase) {} diff --git a/src/kvstore/shared_common.ts b/src/kvstore/shared_common.ts index fdf31f3480..ae781510d1 100644 --- a/src/kvstore/shared_common.ts +++ b/src/kvstore/shared_common.ts @@ -19,4 +19,5 @@ export const SHARED_KVSTORE_CONTEXT_RPC_ID = "SharedKvStoreContext"; export const STAT_RPC_ID = "SharedKvStoreContext.stat"; export const READ_RPC_ID = "SharedKvStoreContext.read"; export const LIST_RPC_ID = "SharedKvStoreContext.list"; +export const WRITE_RPC_ID = "SharedKvStoreContext.write"; export const COMPLETE_URL_RPC_ID = "SharedKvStoreContext.completeUrl"; diff --git a/src/layer/layer_data_source.ts b/src/layer/layer_data_source.ts index eb83a6023a..0663e33cf2 100644 --- a/src/layer/layer_data_source.ts +++ b/src/layer/layer_data_source.ts @@ -36,8 +36,10 @@ import { makeEmptyDataSourceSpecification } from "#src/datasource/index.js"; import type { UserLayer } from "#src/layer/index.js"; import { getWatchableRenderLayerTransform } from "#src/render_coordinate_transform.js"; import type { RenderLayer } from "#src/renderlayer.js"; +import { StatusMessage } from "#src/status.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; import type { WatchableValueInterface } from "#src/trackable_value.js"; +import { DatasetCreationDialog } from "#src/ui/dataset_creation.js"; import { arraysEqual } from "#src/util/array.js"; import type { Borrowed, Owned } from "#src/util/disposable.js"; import { disposableOnce, RefCounted } from "#src/util/disposable.js"; @@ -462,6 +464,23 @@ export class LayerDataSource extends RefCounted { if (refCounted.wasDisposed) return; this.loadState_ = { error }; this.messages.clearMessages(); + + const status = new StatusMessage(/*delay=*/ false, /*modal=*/ true); + status.element.innerHTML = `Dataset not found at ${this.spec_.url}. `; + const createButton = document.createElement("button"); + createButton.textContent = "Create Dataset"; + createButton.addEventListener("click", () => { + status.dispose(); + new DatasetCreationDialog(this.layer.manager, this.spec_.url); + }); + const cancelButton = document.createElement("button"); + cancelButton.textContent = "Cancel"; + cancelButton.addEventListener("click", () => { + status.dispose(); + }); + status.element.appendChild(createButton); + status.element.appendChild(cancelButton); + this.messages.addMessage({ severity: MessageSeverity.error, message: formatErrorMessage(error), diff --git a/src/ui/dataset_creation.ts b/src/ui/dataset_creation.ts new file mode 100644 index 0000000000..3ddb744dfa --- /dev/null +++ b/src/ui/dataset_creation.ts @@ -0,0 +1,276 @@ +// src/ui/dataset_creation.ts + +import { makeCoordinateSpace } from "#src/coordinate_transform.js"; +import type { LayerListSpecification } from "#src/layer/index.js"; +import { LayerReference } from "#src/layer/index.js"; +import { Overlay } from "#src/overlay.js"; +import { StatusMessage } from "#src/status.js"; +import { TrackableValue } from "#src/trackable_value.js"; +import { DataType } from "#src/util/data_type.js"; +import type { Owned } from "#src/util/disposable.js"; +import { LayerReferenceWidget } from "#src/widget/layer_reference.js"; + +export class DatasetCreationDialog extends Overlay { + private format = new TrackableValue<"precomputed" | "zarr" | "n5">( + "precomputed", + (x) => x as any, + ); + private copySource = new TrackableValue<"manual" | "copy">( + "manual", + (x) => x, + ); + + private dataType = new TrackableValue(DataType.UINT8, (x) => x); + private bounds = new TrackableValue("128,128,128", (x) => x); + private resolution = new TrackableValue("4,4,40", (x) => x); + private chunkSize = new TrackableValue("64,64,64", (x) => x); + + private layerReference: Owned; + + constructor( + public manager: LayerListSpecification, + public url: string, + ) { + super(); + + this.layerReference = this.registerDisposer( + new LayerReference(this.manager.rootLayers.addRef(), () => true), + ); + this.registerDisposer( + this.layerReference.changed.add(() => this.copyLayerConfig()), + ); + + const { content } = this; + content.classList.add("neuroglancer-dataset-creation-dialog"); + + content.innerHTML = ` +

Create New Dataset

+
At: ${this.url}
+
+ + +
+
+ +
+ + +
+
+ +
+ Dataset Properties +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ `; + + // Bindings and event listeners + const formatSelect = + content.querySelector("#format-select")!; + formatSelect.addEventListener("change", () => { + this.format.value = formatSelect.value as any; + }); + + const configSourceRadios = content.querySelectorAll( + 'input[name="config-source"]', + ); + const copyLayerContainer = content.querySelector( + "#copy-layer-widget-container", + )!; + configSourceRadios.forEach((radio) => { + radio.addEventListener("change", () => { + this.copySource.value = radio.value as any; + copyLayerContainer.style.display = + this.copySource.value === "copy" ? "block" : "none"; + }); + }); + + const layerRefWidget = this.registerDisposer( + new LayerReferenceWidget(this.layerReference), + ); + copyLayerContainer.appendChild(layerRefWidget.element); + + const dataTypeSelect = + content.querySelector("#data-type-select")!; + dataTypeSelect.addEventListener("change", () => { + this.dataType.value = parseInt(dataTypeSelect.value, 10); + }); + this.dataType.changed.add( + () => (dataTypeSelect.value = this.dataType.value.toString()), + ); + + const boundsInput = + content.querySelector("#bounds-input")!; + boundsInput.addEventListener( + "input", + () => (this.bounds.value = boundsInput.value), + ); + this.bounds.changed.add(() => (boundsInput.value = this.bounds.value)); + boundsInput.value = this.bounds.value; + + const resolutionInput = + content.querySelector("#resolution-input")!; + resolutionInput.addEventListener( + "input", + () => (this.resolution.value = resolutionInput.value), + ); + this.resolution.changed.add( + () => (resolutionInput.value = this.resolution.value), + ); + resolutionInput.value = this.resolution.value; + + const chunkSizeInput = + content.querySelector("#chunk-size-input")!; + chunkSizeInput.addEventListener( + "input", + () => (this.chunkSize.value = chunkSizeInput.value), + ); + this.chunkSize.changed.add( + () => (chunkSizeInput.value = this.chunkSize.value), + ); + chunkSizeInput.value = this.chunkSize.value; + + content + .querySelector("#create-button")! + .addEventListener("click", () => this.createDataset()); + content + .querySelector("#cancel-button")! + .addEventListener("click", () => this.dispose()); + } + + private copyLayerConfig() { + const layer = this.layerReference.layer?.layer; + if (!layer || !layer.dataSources[0]) return; + + const source = layer.dataSources[0]; + const { loadState } = source; + if (loadState === undefined || loadState.error) return; + + const { subsourceEntry } = loadState.subsources[0]; + if (!subsourceEntry.subsource.volume) return; + const multiscaleSource = subsourceEntry.subsource.volume; + if (!multiscaleSource) return; + + const finestResolutionSource = multiscaleSource.getSources({ + displayRank: 3, + multiscaleToViewTransform: new Float32Array(9), + modelChannelDimensionIndices: [], + })[0][0]; + + if (!finestResolutionSource) return; + const spec = finestResolutionSource.chunkSource.spec; + + this.dataType.value = spec.dataType; + this.bounds.value = spec.upperVoxelBound.join(","); + this.chunkSize.value = spec.chunkDataSize.join(","); + + const coordSpace = loadState.transform.value.inputSpace; + this.resolution.value = coordSpace.scales.map((s) => s * 1e9).join(","); + } + + private createDataset() { + try { + const parseVec = (input: string) => { + const parts = input.split(",").map((s) => parseFloat(s.trim())); + if (parts.length !== 3 || parts.some(isNaN)) { + throw new Error(`Invalid vector format: "${input}"`); + } + return new Float32Array(parts); + }; + + const boundsVec = parseVec(this.bounds.value); + const resolutionVec = parseVec(this.resolution.value).map((s) => s / 1e9); // nm to m + const chunkSizeVec = parseVec(this.chunkSize.value); + + const coordinateSpace = makeCoordinateSpace({ + rank: 3, + names: ["x", "y", "z"], + units: ["m", "m", "m"], + scales: new Float64Array(resolutionVec), + bounds: { + lowerBounds: new Float64Array([0, 0, 0]), + upperBounds: new Float64Array(boundsVec), + voxelCenterAtIntegerCoordinates: [false, false, false], + }, + }); + + const configuration = { + dataType: this.dataType.value, + coordinateSpace, + chunkSize: new Uint32Array(chunkSizeVec), + format: this.format.value, + }; + + const dataSourceProvider = this.manager.dataSourceProviderRegistry; + const provider = dataSourceProvider.getKvStoreBasedProvider( + configuration.format, + ); + + if (provider?.create === undefined) { + throw new Error( + `Dataset creation not supported for format: "${configuration}"`, + ); + } + + const promise = provider.create({ + registry: dataSourceProvider, + kvStoreUrl: this.url, + metadata: configuration, + }); + + StatusMessage.forPromise(promise, { + initialMessage: `Creating ${this.format.value} dataset at ${this.url}`, + errorPrefix: "Creation failed: ", + delay: true, + }); + + promise.then(() => { + for (const layer of this.manager.rootLayers.managedLayers) { + const dataSource = layer.layer?.dataSources.find( + (ds) => ds.spec.url === this.url, + ); + if (dataSource) { + dataSource.spec = { ...dataSource.spec }; + break; + } + } + this.dispose(); + }); + } catch (e) { + StatusMessage.showTemporaryMessage(`Error: ${(e as Error).message}`); + } + } +} From c6592b04185df1352d7c1137232d73bdfd82a8ac Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 12 Nov 2025 13:56:19 +0100 Subject: [PATCH 115/251] fix(datasource): correct the dataset creation POC to actually produce valid zarr metadata --- src/datasource/zarr/frontend.ts | 141 ++++++++++++++++++++------------ src/voxel_annotation/TODOs.md | 2 +- 2 files changed, 89 insertions(+), 54 deletions(-) diff --git a/src/datasource/zarr/frontend.ts b/src/datasource/zarr/frontend.ts index 148ab7efc5..13d4d74377 100644 --- a/src/datasource/zarr/frontend.ts +++ b/src/datasource/zarr/frontend.ts @@ -475,6 +475,11 @@ function resolveUrl(options: GetKvStoreBasedDataSourceOptions) { }; } +const dataTypeToZarrV2Dtype: { [key: string]: string } = { + "uint8": "|u1", "uint16": "): string { + const { scales, axes, name } = metadata; + const datasets = scales.map((scale: any, i: number) => ({ + path: `s${i}`, + coordinateTransformations: [{ + type: "scale", + scale: scale.transform, + }], + })); + + const omeMetadata = { + multiscales: [{ + version: "0.4", + axes: axes, + datasets: datasets, + name: name || 'default', + }], + }; + return JSON.stringify(omeMetadata, null, 2); + } + + private buildZarray(scaleMetadata: Record): string { + const { shape, chunks, dtype, compressor, fillValue } = scaleMetadata; + const zarrMetadata = { + zarr_format: 2, + shape: shape, + chunks: chunks, + dtype: dtype, + compressor: compressor, + fill_value: fillValue || 0, + order: 'C', + filters: null, + }; + return JSON.stringify(zarrMetadata, null, 2); + } + async create(options: CreateDataSourceOptions): Promise { - const { kvStoreUrl, metadata } = options; - const { sharedKvStoreContext } = options.registry; - const { kvStoreContext } = sharedKvStoreContext; - - const kvStore = kvStoreContext.getKvStore(kvStoreUrl); - - const zarrVersion = metadata.zarrVersion || this.zarrVersion || 2; - let metadataFilename: string; - let metadataContent: string; - - if (zarrVersion === 3) { - metadataFilename = "zarr.json"; - const zarrMetadata = { - zarr_format: 3, - node_type: "array", - shape: metadata.shape, - data_type: metadata.dataType, - chunk_grid: { - name: "regular", - configuration: { chunk_shape: metadata.chunkShape }, - }, - chunk_key_encoding: { - name: "default", - configuration: { separator: "/" }, - }, - codecs: metadata.codecs || [ - { name: "bytes", configuration: { endian: "little" } }, - { name: "gzip", configuration: { level: 1 } }, - ], - fill_value: metadata.fillValue || 0, - attributes: metadata.attributes || {}, - }; - metadataContent = JSON.stringify(zarrMetadata, null, 2); - } else { - // zarrVersion === 2 - metadataFilename = ".zarray"; - const zarrMetadata = { - zarr_format: 2, - shape: metadata.shape, - chunks: metadata.chunkShape, - dtype: metadata.dtype, - compressor: metadata.compressor || { id: "gzip", level: 1 }, - fill_value: metadata.fillValue || 0, - order: "C", - filters: null, - }; - metadataContent = JSON.stringify(zarrMetadata, null, 2); + const { kvStoreUrl, registry } = options; + const { sharedKvStoreContext } = registry; + const kvStore = sharedKvStoreContext.kvStoreContext.getKvStore(kvStoreUrl); + + const baseShape = [30024, 30024, 30024]; + const chunkShape = [64, 64, 64]; + const dataType = "uint32"; + const baseVoxelSize = [4, 4, 40]; + const voxelUnit = "nanometer"; + const numScales = 6; + const downsamplingFactor = [2, 2, 2]; + + const scales = []; + for (let i = 0; i < numScales; ++i) { + const downsampleCoeffs = downsamplingFactor.map(f => Math.pow(f, i)); + scales.push({ + shape: baseShape.map((dim, j) => Math.ceil(dim / downsampleCoeffs[j])), + chunks: chunkShape, + dtype: dataTypeToZarrV2Dtype[dataType], + compressor: null, + transform: baseVoxelSize.map((v, j) => v * downsampleCoeffs[j]), + }); } - const metadataUrl = kvStore.store.getUrl( - joinPath(kvStore.path, metadataFilename), - ); + const metadata = { + scales, + axes: [ + { name: 'x', type: 'space', unit: voxelUnit }, + { name: 'y', type: 'space', unit: voxelUnit }, + { name: 'z', type: 'space', unit: voxelUnit }, + ], + }; - await proxyWrite( + const zattrsContent = this.buildOmeZattrs(metadata); + const zattrsUrl = kvStore.store.getUrl(joinPath(kvStore.path, '.zattrs')); + const writeZattrsPromise = proxyWrite( sharedKvStoreContext, - metadataUrl, - new TextEncoder().encode(metadataContent).buffer as ArrayBuffer, + zattrsUrl, + new TextEncoder().encode(zattrsContent).buffer as ArrayBuffer, ); + + const writeZarrayPromises = metadata.scales.map((scale: any, i: number) => { + const zarrayUrl = kvStore.store.getUrl(joinPath(kvStore.path, `s${i}`, '.zarray')); + const zarrayContent = this.buildZarray(scale); + return proxyWrite( + sharedKvStoreContext, + zarrayUrl, + new TextEncoder().encode(zarrayContent).buffer as ArrayBuffer, + ); + }); + + await Promise.all([writeZattrsPromise, ...writeZarrayPromises]); } } diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 998942046c..480e2e7fe9 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -2,6 +2,7 @@ ### priority +- the writing pipeline is only working for uint32 data, which was to be expected as I only used uint32 throughout the development, now that the dataset creation ~~is~~ (will soon be) working (so it is easy to test for every data type), I should fix this. - url completion for the ssa+https source ### later @@ -11,5 +12,4 @@ ### questionable - write a testsuite for the downsampler and ensure its proper working on exotic lod levels -- design a dataset creation feature - adapt the brush size to the zoom level linearly From 0ab5bd542b15dd3330e8ef8cf18bd3b8c07d9649 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 12 Nov 2025 17:08:36 +0100 Subject: [PATCH 116/251] feat(datasource): started generalizing dataset creation feature --- src/datasource/index.ts | 19 +- src/datasource/zarr/creation.ts | 194 ++++++++++++ src/datasource/zarr/frontend.ts | 121 ++------ src/layer/index.ts | 72 ++++- src/ui/dataset_creation.css | 79 +++++ src/ui/dataset_creation.ts | 510 ++++++++++++++++---------------- src/voxel_annotation/TODOs.md | 1 + 7 files changed, 644 insertions(+), 352 deletions(-) create mode 100644 src/datasource/zarr/creation.ts create mode 100644 src/ui/dataset_creation.css diff --git a/src/datasource/index.ts b/src/datasource/index.ts index 2812d0f179..fb43634634 100644 --- a/src/datasource/index.ts +++ b/src/datasource/index.ts @@ -54,10 +54,12 @@ import { emptyCompletionResult, getPrefixMatchesWithDescriptions, } from "#src/util/completion.js"; +import type { DataType } from "#src/util/data_type.js"; import { RefCounted } from "#src/util/disposable.js"; import type { vec3 } from "#src/util/geom.js"; import { type ProgressOptions } from "#src/util/progress_listener.js"; import type { Trackable } from "#src/util/trackable.js"; +import { CompoundTrackable } from "#src/util/trackable.js"; export type CompletionResult = BasicCompletionResult; @@ -240,11 +242,23 @@ export function makeEmptyDataSourceSpecification(): DataSourceSpecification { }; } +export interface CommonCreationMetadata { + shape: number[]; + dataType: DataType; + voxelSize: number[]; + voxelUnit: string; + numScales: number; + downsamplingFactor: number[]; + name: string; +} +export abstract class DataSourceCreationState extends CompoundTrackable {} export interface CreateDataSourceOptions { kvStoreUrl: string; - metadata: Record; registry: DataSourceRegistry; - signal?: AbortSignal; + metadata: { + common: CommonCreationMetadata; + sourceRelated?: DataSourceCreationState; + }; } export interface DataSourceProvider { @@ -272,6 +286,7 @@ export interface KvStoreBasedDataSourceProvider { options: GetKvStoreBasedDataSourceOptions, ) => Promise; create?(options: CreateDataSourceOptions): Promise; + creationState?: CompoundTrackable; } export interface GetKvStoreBasedDataSourceOptions diff --git a/src/datasource/zarr/creation.ts b/src/datasource/zarr/creation.ts new file mode 100644 index 0000000000..e37a231655 --- /dev/null +++ b/src/datasource/zarr/creation.ts @@ -0,0 +1,194 @@ +/** + * @license + * Copyright 2025 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 type { + CreateDataSourceOptions, + CommonCreationMetadata, +} from "#src/datasource/index.js"; +import { proxyWrite } from '#src/kvstore/proxy.js'; +import { joinPath } from '#src/kvstore/url.js'; +import { DataType } from '#src/util/data_type.js'; + +const dataTypeToZarrV2Dtype: { [key in DataType]?: string } = { + [DataType.UINT8]: '|u1', + [DataType.UINT16]: '; +} + +class ZarrV2Creator implements ZarrCreator { + async create(options: CreateDataSourceOptions): Promise { + const { kvStoreUrl, registry, metadata } = options; + const { sharedKvStoreContext } = registry; + const kvStore = sharedKvStoreContext.kvStoreContext.getKvStore(kvStoreUrl); + + const commonMetadata = metadata.common as CommonCreationMetadata; + //const zarrMetadata = metadata.sourceRelated as ZarrCreationState; + + const scales = []; + for (let i = 0; i < commonMetadata.numScales; ++i) { + const downsampleCoeffs = commonMetadata.downsamplingFactor.map((f: number) => Math.pow(f, i)); + scales.push({ + shape: commonMetadata.shape.map((dim: number, j: number) => Math.ceil(dim / downsampleCoeffs[j])), + chunks: [64, 64, 64], + dtype: dataTypeToZarrV2Dtype[commonMetadata.dataType], + compressor: null, + transform: commonMetadata.voxelSize.map((v: number, j: number) => v * downsampleCoeffs[j]), + }); + } + + const zattrsContent = this._buildV2OmeZattrs(commonMetadata, scales); + const writeZattrsPromise = proxyWrite( + sharedKvStoreContext, + kvStore.store.getUrl(joinPath(kvStore.path, '.zattrs')), + new TextEncoder().encode(zattrsContent).buffer as ArrayBuffer + ); + + const writeZarrayPromises = scales.map((scale: any, i: number) => { + const zarrayUrl = kvStore.store.getUrl(joinPath(kvStore.path, `s${i}`, '.zarray')); + const zarrayContent = this._buildV2Zarray(scale); + return proxyWrite( + sharedKvStoreContext, + zarrayUrl, + new TextEncoder().encode(zarrayContent).buffer as ArrayBuffer + ); + }); + + await Promise.all([writeZattrsPromise, ...writeZarrayPromises]); + } + + private _buildV2OmeZattrs(common: CommonCreationMetadata, scales: any[]): string { + const datasets = scales.map((scale, i) => ({ + path: `s${i}`, + coordinateTransformations: [{ + type: 'scale', + scale: scale.transform, + }], + })); + + const omeMetadata = { + multiscales: [{ + version: '0.4', + axes: [ + { name: 'x', type: 'space', unit: common.voxelUnit }, + { name: 'y', type: 'space', unit: common.voxelUnit }, + { name: 'z', type: 'space', unit: common.voxelUnit }, + ], + datasets, + name: common.name || 'default', + }], + }; + return JSON.stringify(omeMetadata, null, 2); + } + + private _buildV2Zarray(scaleMetadata: any): string { + const { shape, chunks, dtype, compressor } = scaleMetadata; + const zarrMetadata = { + zarr_format: 2, + shape: shape, + chunks: chunks, + dtype: dtype, + compressor: compressor, + fill_value: 0, + order: 'C', + filters: null, + }; + return JSON.stringify(zarrMetadata, null, 2); + } +} + +class ZarrV3Creator implements ZarrCreator { + async create(options: CreateDataSourceOptions): Promise { + const { kvStoreUrl, registry, metadata } = options; + const { sharedKvStoreContext } = registry; + const kvStore = sharedKvStoreContext.kvStoreContext.getKvStore(kvStoreUrl); + + // This logic is a placeholder and needs to be filled out with the specifics + // of generating Zarr v3 metadata files. + + const rootGroupContent = this._buildV3RootGroupMetadata(metadata.common); + const writeRootPromise = proxyWrite( + sharedKvStoreContext, + kvStore.store.getUrl(joinPath(kvStore.path, 'zarr.json')), + new TextEncoder().encode(rootGroupContent).buffer as ArrayBuffer, + ); + + // Placeholder for scale calculation. + const scales: any[] = []; + + const writeArrayPromises = scales.map((scale: any, i: number) => { + const arrayMetaUrl = kvStore.store.getUrl(joinPath(kvStore.path, `s${i}`, 'zarr.json')); + const arrayMetaContent = this._buildV3ArrayMetadata(scale); + return proxyWrite( + sharedKvStoreContext, + arrayMetaUrl, + new TextEncoder().encode(arrayMetaContent).buffer as ArrayBuffer + ); + }); + + await Promise.all([writeRootPromise, ...writeArrayPromises]); + } + + private _buildV3RootGroupMetadata(metadata: any): string { + // Generates the root zarr.json for an OME-NGFF group using Zarr v3 spec. + // This is where you would construct the OME-NGFF v0.5+ multiscale metadata. + console.log("Building V3 Root Group Metadata with:", metadata); + const zarrV3Root = { + "zarr_format": 3, + "node_type": "group", + "attributes": { + "multiscales": [ + // OME-NGFF v0.5+ multiscale object goes here + ] + } + }; + return JSON.stringify(zarrV3Root, null, 2); + } + + private _buildV3ArrayMetadata(scaleMetadata: any): string { + const zarrV3Array = { + "zarr_format": 3, + "node_type": "array", + "shape": scaleMetadata.shape, + "data_type": "uint32", // This needs to be mapped from DataType enum + "chunk_grid": { "name": "regular", "configuration": { "chunk_shape": scaleMetadata.chunks } }, + "codecs": [ + // Codec configuration (e.g., blosc, gzip) goes here + ] + }; + return JSON.stringify(zarrV3Array, null, 2); + } +} + +export function getZarrCreator(version: number | undefined): ZarrCreator { + switch (version) { + case 2: + return new ZarrV2Creator(); + case 3: + return new ZarrV3Creator(); + default: + throw new Error(`Unsupported Zarr version: ${version}`); + } +} diff --git a/src/datasource/zarr/frontend.ts b/src/datasource/zarr/frontend.ts index 13d4d74377..559c55683b 100644 --- a/src/datasource/zarr/frontend.ts +++ b/src/datasource/zarr/frontend.ts @@ -30,7 +30,9 @@ import type { CreateDataSourceOptions, DataSource, GetKvStoreBasedDataSourceOptions, - KvStoreBasedDataSourceProvider, + KvStoreBasedDataSourceProvider} from "#src/datasource/index.js"; +import { + DataSourceCreationState } from "#src/datasource/index.js"; import { getKvStorePathCompletions } from "#src/datasource/kvstore_completions.js"; import { VolumeChunkSourceParameters } from "#src/datasource/zarr/base.js"; @@ -39,6 +41,7 @@ import "#src/datasource/zarr/codec/crc32c/resolve.js"; import "#src/datasource/zarr/codec/gzip/resolve.js"; import "#src/datasource/zarr/codec/sharding_indexed/resolve.js"; import "#src/datasource/zarr/codec/transpose/resolve.js"; +import { getZarrCreator } from "#src/datasource/zarr/creation.js"; import type { ArrayMetadata, DimensionSeparator, @@ -58,10 +61,8 @@ import { simpleFilePresenceAutoDetectDirectorySpec } from "#src/kvstore/auto_det import { WithSharedKvStoreContext } from "#src/kvstore/chunk_source_frontend.js"; import type { CompletionResult } from "#src/kvstore/context.js"; import type { SharedKvStoreContext } from "#src/kvstore/frontend.js"; -import { proxyWrite } from "#src/kvstore/proxy.js"; import { joinBaseUrlAndPath, - joinPath, kvstoreEnsureDirectoryPipelineUrl, parseUrlSuffix, pipelineUrlJoin, @@ -90,6 +91,21 @@ import { import * as matrix from "#src/util/matrix.js"; import type { ProgressOptions } from "#src/util/progress_listener.js"; import { ProgressSpan } from "#src/util/progress_listener.js"; +import { TrackableEnum } from "#src/util/trackable_enum.js"; + +export enum ZarrCompression { + raw = 0, + gzip = 1, +} + +export class ZarrCreationState extends DataSourceCreationState { + compression = new TrackableEnum(ZarrCompression, ZarrCompression.raw); + + constructor() { + super(); + this.add("compression", this.compression); + } +} class ZarrVolumeChunkSource extends WithParameters( WithSharedKvStoreContext(VolumeChunkSource), @@ -475,11 +491,6 @@ function resolveUrl(options: GetKvStoreBasedDataSourceOptions) { }; } -const dataTypeToZarrV2Dtype: { [key: string]: string } = { - "uint8": "|u1", "uint16": " { let { kvStoreUrl, additionalPath, fragment } = resolveUrl(options); kvStoreUrl = kvstoreEnsureDirectoryPipelineUrl( @@ -605,95 +619,10 @@ export class ZarrDataSource implements KvStoreBasedDataSourceProvider { ), ); } - private buildOmeZattrs(metadata: Record): string { - const { scales, axes, name } = metadata; - const datasets = scales.map((scale: any, i: number) => ({ - path: `s${i}`, - coordinateTransformations: [{ - type: "scale", - scale: scale.transform, - }], - })); - - const omeMetadata = { - multiscales: [{ - version: "0.4", - axes: axes, - datasets: datasets, - name: name || 'default', - }], - }; - return JSON.stringify(omeMetadata, null, 2); - } - - private buildZarray(scaleMetadata: Record): string { - const { shape, chunks, dtype, compressor, fillValue } = scaleMetadata; - const zarrMetadata = { - zarr_format: 2, - shape: shape, - chunks: chunks, - dtype: dtype, - compressor: compressor, - fill_value: fillValue || 0, - order: 'C', - filters: null, - }; - return JSON.stringify(zarrMetadata, null, 2); - } - async create(options: CreateDataSourceOptions): Promise { - const { kvStoreUrl, registry } = options; - const { sharedKvStoreContext } = registry; - const kvStore = sharedKvStoreContext.kvStoreContext.getKvStore(kvStoreUrl); - - const baseShape = [30024, 30024, 30024]; - const chunkShape = [64, 64, 64]; - const dataType = "uint32"; - const baseVoxelSize = [4, 4, 40]; - const voxelUnit = "nanometer"; - const numScales = 6; - const downsamplingFactor = [2, 2, 2]; - - const scales = []; - for (let i = 0; i < numScales; ++i) { - const downsampleCoeffs = downsamplingFactor.map(f => Math.pow(f, i)); - scales.push({ - shape: baseShape.map((dim, j) => Math.ceil(dim / downsampleCoeffs[j])), - chunks: chunkShape, - dtype: dataTypeToZarrV2Dtype[dataType], - compressor: null, - transform: baseVoxelSize.map((v, j) => v * downsampleCoeffs[j]), - }); - } - - const metadata = { - scales, - axes: [ - { name: 'x', type: 'space', unit: voxelUnit }, - { name: 'y', type: 'space', unit: voxelUnit }, - { name: 'z', type: 'space', unit: voxelUnit }, - ], - }; - - const zattrsContent = this.buildOmeZattrs(metadata); - const zattrsUrl = kvStore.store.getUrl(joinPath(kvStore.path, '.zattrs')); - const writeZattrsPromise = proxyWrite( - sharedKvStoreContext, - zattrsUrl, - new TextEncoder().encode(zattrsContent).buffer as ArrayBuffer, - ); - - const writeZarrayPromises = metadata.scales.map((scale: any, i: number) => { - const zarrayUrl = kvStore.store.getUrl(joinPath(kvStore.path, `s${i}`, '.zarray')); - const zarrayContent = this.buildZarray(scale); - return proxyWrite( - sharedKvStoreContext, - zarrayUrl, - new TextEncoder().encode(zarrayContent).buffer as ArrayBuffer, - ); - }); - - await Promise.all([writeZattrsPromise, ...writeZarrayPromises]); + console.log("ZarrDataSource.create", options); + const creator = getZarrCreator(this.zarrVersion); + await creator.create(options); } } diff --git a/src/layer/index.ts b/src/layer/index.ts index 5d51f23296..343d85c561 100644 --- a/src/layer/index.ts +++ b/src/layer/index.ts @@ -32,6 +32,7 @@ import { TrackableCoordinateSpace, } from "#src/coordinate_transform.js"; import type { + CommonCreationMetadata, DataSourceRegistry, DataSourceSpecification, DataSubsource, @@ -64,6 +65,7 @@ import type { VisibilityTrackedRenderLayer, } from "#src/renderlayer.js"; import type { VolumeType } from "#src/sliceview/volume/base.js"; +import { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; import { StatusMessage } from "#src/status.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; import type { @@ -85,7 +87,7 @@ import { LayerToolBinder, SelectedLegacyTool } from "#src/ui/tool.js"; import { gatherUpdate } from "#src/util/array.js"; import type { Borrowed, Owned } from "#src/util/disposable.js"; import { invokeDisposers, RefCounted } from "#src/util/disposable.js"; -import type { vec3 } from "#src/util/geom.js"; +import { vec3 } from "#src/util/geom.js"; import { emptyToUndefined, parseArray, @@ -847,6 +849,74 @@ export class ManagedUserLayer extends RefCounted { this.layerChanged.dispatch(); } + getCreationMetadata(): CommonCreationMetadata | undefined { + const userLayer = this.layer; + if (userLayer === null) return undefined; + + for (const dataSource of userLayer.dataSources) { + const loadState = dataSource.loadState; + if (loadState === undefined || loadState.error !== undefined) continue; + + for (const subsource of loadState.subsources) { + if (!subsource.enabled) continue; + const { volume } = subsource.subsourceEntry.subsource; + + if (volume instanceof MultiscaleVolumeChunkSource) { + const { modelTransform } = loadState.dataSource; + const modelSpace = modelTransform.outputSpace; + const { rank } = modelSpace; + + const identityOptions = { + displayRank: rank, + multiscaleToViewTransform: new Float32Array(rank * rank).fill(0), + modelChannelDimensionIndices: [], + }; + for (let i = 0; i < rank; ++i) + identityOptions.multiscaleToViewTransform[i * rank + i] = 1; + + const scales = volume.getSources(identityOptions)[0]; + if (!scales || scales.length === 0) continue; + + const highResSource = scales[0]; + const shape = Array.from( + highResSource.chunkSource.spec.upperVoxelBound, + ); + const highResTransform = highResSource.chunkToMultiscaleTransform; + const voxelSize = new Array(rank); + for (let i = 0; i < rank; ++i) { + voxelSize[i] = highResTransform[i * (rank + 1) + i]; + } + + const numScales = scales.length; + + const downsamplingFactor = vec3.fromValues(1, 1, 1); + if (scales.length > 1) { + const lowResSource = scales[1]; + const lowResTransform = lowResSource.chunkToMultiscaleTransform; + for (let i = 0; i < rank; ++i) { + const highResScale = highResTransform[i * (rank + 1) + i]; + const lowResScale = lowResTransform[i * (rank + 1) + i]; + if (highResScale !== 0) { + downsamplingFactor[i] = Math.round(lowResScale / highResScale); + } + } + } + + return { + shape, + dataType: volume.dataType, + voxelSize, + voxelUnit: modelSpace.units[0] || "", + numScales, + downsamplingFactor: Array.from(downsamplingFactor), + name: `${this.name}_copy`, + }; + } + } + } + return undefined; + } + disposed() { this.layer = null; super.disposed(); diff --git a/src/ui/dataset_creation.css b/src/ui/dataset_creation.css new file mode 100644 index 0000000000..f6f6632934 --- /dev/null +++ b/src/ui/dataset_creation.css @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2025 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. + */ + +.neuroglancer-dataset-creation-dialog { + display: flex; + flex-direction: column; + padding: 10px; + max-width: 600px; + font-family: sans-serif; +} + +.neuroglancer-dataset-creation-dialog h2 { + margin-top: 0; + margin-bottom: 15px; + font-size: 18px; + text-align: center; +} + +.neuroglancer-creation-top-controls { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 8px 10px; + margin-bottom: 15px; +} + +.neuroglancer-creation-fields-grid { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 8px 10px; + margin-bottom: 15px; +} + +.neuroglancer-creation-fields-grid > label { + justify-self: end; + font-weight: 500; +} + +.neuroglancer-vec3-widget { + display: flex; + gap: 5px; +} + +.neuroglancer-vec3-widget > .neuroglancer-number-input { + flex: 1; +} + +.neuroglancer-creation-datasource-options { + border: 1px solid #555; + border-radius: 4px; + padding: 10px; + margin-top: 5px; + margin-bottom: 15px; +} + +.neuroglancer-creation-datasource-options > legend { + padding: 0 5px; + color: #bbb; +} + +.neuroglancer-creation-actions { + display: flex; + justify-content: flex-end; + margin-top: 10px; +} diff --git a/src/ui/dataset_creation.ts b/src/ui/dataset_creation.ts index 3ddb744dfa..8fd85db5c2 100644 --- a/src/ui/dataset_creation.ts +++ b/src/ui/dataset_creation.ts @@ -1,276 +1,280 @@ -// src/ui/dataset_creation.ts - -import { makeCoordinateSpace } from "#src/coordinate_transform.js"; -import type { LayerListSpecification } from "#src/layer/index.js"; -import { LayerReference } from "#src/layer/index.js"; -import { Overlay } from "#src/overlay.js"; -import { StatusMessage } from "#src/status.js"; -import { TrackableValue } from "#src/trackable_value.js"; -import { DataType } from "#src/util/data_type.js"; -import type { Owned } from "#src/util/disposable.js"; -import { LayerReferenceWidget } from "#src/widget/layer_reference.js"; +/** + * @license + * Copyright 2025 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 not use this file except in compliance with the License. + * + * 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 type { CreateDataSourceOptions, CommonCreationMetadata , DataSourceCreationState } from '#src/datasource/index.js'; +import type { LayerListSpecification } from '#src/layer/index.js'; +import { Overlay } from '#src/overlay.js'; +import { StatusMessage } from '#src/status.js'; +import { TrackableValue } from '#src/trackable_value.js'; +import { TrackableVec3 } from '#src/trackable_vec3.js'; +import { DataType } from '#src/util/data_type.js'; +import { removeChildren } from '#src/util/dom.js'; +import { vec3 } from '#src/util/geom.js'; +import { verifyInt, verifyString } from '#src/util/json.js'; +import { CompoundTrackable, type Trackable } from '#src/util/trackable.js'; +import { TrackableEnum } from '#src/util/trackable_enum.js'; +import { DependentViewWidget } from "#src/widget/dependent_view_widget.js"; +import { EnumSelectWidget } from '#src/widget/enum_widget.js'; +import { NumberInputWidget } from '#src/widget/number_input_widget.js'; +import { TextInputWidget } from '#src/widget/text_input.js'; +import { Vec3Widget } from '#src/widget/vec3_entry_widget.js'; + + +function createControlForTrackable(trackable: Trackable): HTMLElement { + if (trackable instanceof TrackableVec3) { + return new Vec3Widget(trackable).element; + } + if (trackable instanceof TrackableEnum) { + return new EnumSelectWidget(trackable).element; + } + if (trackable instanceof TrackableValue) { + const value = trackable.value; + if (typeof value === 'number') { + return new NumberInputWidget(trackable as TrackableValue).element; + } + if (typeof value === 'string') { + return new TextInputWidget(trackable as TrackableValue).element; + } + } + const unsupportedElement = document.createElement('div'); + unsupportedElement.textContent = `Unsupported control type`; + return unsupportedElement; +} -export class DatasetCreationDialog extends Overlay { - private format = new TrackableValue<"precomputed" | "zarr" | "n5">( - "precomputed", - (x) => x as any, - ); - private copySource = new TrackableValue<"manual" | "copy">( - "manual", - (x) => x, - ); - - private dataType = new TrackableValue(DataType.UINT8, (x) => x); - private bounds = new TrackableValue("128,128,128", (x) => x); - private resolution = new TrackableValue("4,4,40", (x) => x); - private chunkSize = new TrackableValue("64,64,64", (x) => x); - - private layerReference: Owned; - - constructor( - public manager: LayerListSpecification, - public url: string, - ) { +class CommonMetadataState extends CompoundTrackable { + shape = new TrackableVec3(vec3.fromValues(30024, 30024, 30024), vec3.fromValues(30024, 30024, 30024)); + dataType = new TrackableEnum(DataType, DataType.UINT32); + voxelSize = new TrackableVec3(vec3.fromValues(4, 4, 40), vec3.fromValues(4, 4, 40)); + voxelUnit = new TrackableValue('nm', verifyString); + numScales = new TrackableValue(6, verifyInt); + downsamplingFactor = new TrackableVec3(vec3.fromValues(2, 2, 2), vec3.fromValues(2, 2, 2)); + name = new TrackableValue('new-dataset', verifyString); + + constructor() { super(); + this.add('shape', this.shape); + this.add('dataType', this.dataType); + this.add('voxelSize', this.voxelSize); + this.add('voxelUnit', this.voxelUnit); + this.add('numScales', this.numScales); + this.add('downsamplingFactor', this.downsamplingFactor); + this.add('name', this.name); + } - this.layerReference = this.registerDisposer( - new LayerReference(this.manager.rootLayers.addRef(), () => true), - ); - this.registerDisposer( - this.layerReference.changed.add(() => this.copyLayerConfig()), - ); + toJSON(): CommonCreationMetadata { + return { + shape: Array.from(this.shape.value), + dataType: this.dataType.value, + voxelSize: Array.from(this.voxelSize.value), + voxelUnit: this.voxelUnit.value, + numScales: this.numScales.value, + downsamplingFactor: Array.from(this.downsamplingFactor.value), + name: this.name.value, + }; + } - const { content } = this; - content.classList.add("neuroglancer-dataset-creation-dialog"); - - content.innerHTML = ` -

Create New Dataset

-
At: ${this.url}
-
- - -
-
- -
- - -
-
- -
- Dataset Properties -
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
- `; - - // Bindings and event listeners - const formatSelect = - content.querySelector("#format-select")!; - formatSelect.addEventListener("change", () => { - this.format.value = formatSelect.value as any; - }); + restoreState(_obj: any) {} + reset() {} +} - const configSourceRadios = content.querySelectorAll( - 'input[name="config-source"]', - ); - const copyLayerContainer = content.querySelector( - "#copy-layer-widget-container", - )!; - configSourceRadios.forEach((radio) => { - radio.addEventListener("change", () => { - this.copySource.value = radio.value as any; - copyLayerContainer.style.display = - this.copySource.value === "copy" ? "block" : "none"; - }); - }); - const layerRefWidget = this.registerDisposer( - new LayerReferenceWidget(this.layerReference), - ); - copyLayerContainer.appendChild(layerRefWidget.element); +export class DatasetCreationDialog extends Overlay { + state = new CommonMetadataState(); + dataSourceType = new TrackableValue('', verifyString); + private dataSourceOptions: DataSourceCreationState | undefined; - const dataTypeSelect = - content.querySelector("#data-type-select")!; - dataTypeSelect.addEventListener("change", () => { - this.dataType.value = parseInt(dataTypeSelect.value, 10); - }); - this.dataType.changed.add( - () => (dataTypeSelect.value = this.dataType.value.toString()), - ); - - const boundsInput = - content.querySelector("#bounds-input")!; - boundsInput.addEventListener( - "input", - () => (this.bounds.value = boundsInput.value), - ); - this.bounds.changed.add(() => (boundsInput.value = this.bounds.value)); - boundsInput.value = this.bounds.value; - - const resolutionInput = - content.querySelector("#resolution-input")!; - resolutionInput.addEventListener( - "input", - () => (this.resolution.value = resolutionInput.value), - ); - this.resolution.changed.add( - () => (resolutionInput.value = this.resolution.value), - ); - resolutionInput.value = this.resolution.value; - - const chunkSizeInput = - content.querySelector("#chunk-size-input")!; - chunkSizeInput.addEventListener( - "input", - () => (this.chunkSize.value = chunkSizeInput.value), - ); - this.chunkSize.changed.add( - () => (chunkSizeInput.value = this.chunkSize.value), - ); - chunkSizeInput.value = this.chunkSize.value; - - content - .querySelector("#create-button")! - .addEventListener("click", () => this.createDataset()); - content - .querySelector("#cancel-button")! - .addEventListener("click", () => this.dispose()); - } + constructor(public manager: LayerListSpecification, public url: string) { + super(); - private copyLayerConfig() { - const layer = this.layerReference.layer?.layer; - if (!layer || !layer.dataSources[0]) return; + const { content } = this; + content.classList.add('neuroglancer-dataset-creation-dialog'); - const source = layer.dataSources[0]; - const { loadState } = source; - if (loadState === undefined || loadState.error) return; + const titleElement = document.createElement('h2'); + titleElement.textContent = 'Create New Dataset'; + content.appendChild(titleElement); - const { subsourceEntry } = loadState.subsources[0]; - if (!subsourceEntry.subsource.volume) return; - const multiscaleSource = subsourceEntry.subsource.volume; - if (!multiscaleSource) return; + const topControls = document.createElement('div'); + topControls.className = 'neuroglancer-creation-top-controls'; + content.appendChild(topControls); - const finestResolutionSource = multiscaleSource.getSources({ - displayRank: 3, - multiscaleToViewTransform: new Float32Array(9), - modelChannelDimensionIndices: [], - })[0][0]; + topControls.appendChild(this.registerDisposer(new DependentViewWidget( + { changed: this.manager.rootLayers.layersChanged, get value() { return null; } }, + (_value, parentElement) => { + const compatibleLayers = this.manager.rootLayers.managedLayers.filter( + layer => layer.getCreationMetadata() !== undefined + ); + if (compatibleLayers.length === 0) return; + + const label = document.createElement('label'); + label.textContent = 'Copy settings from layer'; + parentElement.appendChild(label); + + const select = document.createElement('select'); + const defaultOption = document.createElement('option'); + defaultOption.textContent = 'None'; + defaultOption.value = ''; + select.appendChild(defaultOption); + + compatibleLayers.forEach(layer => { + const option = document.createElement('option'); + option.textContent = layer.name; + option.value = layer.name; + select.appendChild(option); + }); + + this.registerEventListener(select, 'change', () => { + if (!select.value) return; + const layer = this.manager.rootLayers.getLayerByName(select.value); + if (layer) { + const metadata = layer.getCreationMetadata(); + if (metadata) { + this.state.shape.value = vec3.fromValues(metadata.shape[0], metadata.shape[1], metadata.shape[2]); + (this.state.dataType as TrackableEnum).value = metadata.dataType; + this.state.voxelSize.value = vec3.fromValues(metadata.voxelSize[0], metadata.voxelSize[1], metadata.voxelSize[2]); + this.state.voxelUnit.value = metadata.voxelUnit; + this.state.name.value = metadata.name; + } + } + }); + parentElement.appendChild(select); + } + )).element); + + const commonFields = document.createElement('div'); + commonFields.className = 'neuroglancer-creation-fields-grid'; + content.appendChild(commonFields); + + const addCommonControl = (trackable: Trackable, label: string) => { + const labelElement = document.createElement('label'); + labelElement.textContent = label; + commonFields.appendChild(labelElement); + commonFields.appendChild(createControlForTrackable(trackable)); + }; + + addCommonControl(this.state.name, 'Name'); + addCommonControl(this.state.shape, 'Shape'); + addCommonControl(this.state.dataType, 'Data Type'); + addCommonControl(this.state.voxelSize, 'Voxel Size'); + addCommonControl(this.state.voxelUnit, 'Voxel Unit'); + addCommonControl(this.state.numScales, 'Number of Scales'); + addCommonControl(this.state.downsamplingFactor, 'Downsampling Factor'); + + const dataSourceSelect = document.createElement('select'); + const creatableProviders = Array.from( + this.manager.dataSourceProviderRegistry.kvStoreBasedDataSources.values() + ).filter(p => p.creationState !== undefined); + + creatableProviders.forEach(p => { + const option = document.createElement('option'); + option.value = p.scheme; + option.textContent = p.description || p.scheme; + dataSourceSelect.appendChild(option); + }); + + if (creatableProviders.length > 0) { + this.dataSourceType.value = creatableProviders[0].scheme; + } else { + const noProviderMessage = document.createElement('div'); + noProviderMessage.textContent = 'No creatable data source types are configured.'; + content.appendChild(noProviderMessage); + } - if (!finestResolutionSource) return; - const spec = finestResolutionSource.chunkSource.spec; + const dsLabel = document.createElement('label'); + dsLabel.textContent = 'Data Source Type'; + topControls.appendChild(dsLabel); + topControls.appendChild(dataSourceSelect); - this.dataType.value = spec.dataType; - this.bounds.value = spec.upperVoxelBound.join(","); - this.chunkSize.value = spec.chunkDataSize.join(","); + this.registerEventListener(dataSourceSelect, 'change', () => { + this.dataSourceType.value = dataSourceSelect.value; + }); - const coordSpace = loadState.transform.value.inputSpace; - this.resolution.value = coordSpace.scales.map((s) => s * 1e9).join(","); + const optionsContainer = document.createElement('fieldset'); + optionsContainer.className = 'neuroglancer-creation-datasource-options'; + const optionsLegend = document.createElement('legend'); + optionsContainer.appendChild(optionsLegend); + const optionsGrid = document.createElement('div'); + optionsGrid.className = 'neuroglancer-creation-fields-grid'; + optionsContainer.appendChild(optionsGrid); + content.appendChild(optionsContainer); + + this.registerDisposer(this.dataSourceType.changed.add(() => { + this.updateDataSourceOptions(optionsGrid, optionsLegend); + })); + this.updateDataSourceOptions(optionsGrid, optionsLegend); + + const actions = document.createElement('div'); + actions.className = 'neuroglancer-creation-actions'; + const createButton = document.createElement('button'); + createButton.textContent = 'Create'; + this.registerEventListener(createButton, 'click', () => this.createDataset()); + actions.appendChild(createButton); + content.appendChild(actions); } - private createDataset() { - try { - const parseVec = (input: string) => { - const parts = input.split(",").map((s) => parseFloat(s.trim())); - if (parts.length !== 3 || parts.some(isNaN)) { - throw new Error(`Invalid vector format: "${input}"`); + private updateDataSourceOptions(container: HTMLElement, legend: HTMLLegendElement) { + if (this.dataSourceOptions) { + this.dataSourceOptions.dispose(); + this.dataSourceOptions = undefined; + } + removeChildren(container); + const provider = this.manager.dataSourceProviderRegistry.getKvStoreBasedProvider(this.dataSourceType.value); + legend.textContent = `${provider?.description || this.dataSourceType.value} Options`; + const creationState = provider?.creationState as (DataSourceCreationState | undefined); + if (creationState) { + this.dataSourceOptions = creationState; + for (const key of Object.keys(creationState)) { + if (key === 'changed' || key === 'toJSON' || key === 'restoreState' || key === 'reset') continue; + const trackable = (creationState as any)[key]; + if (trackable && typeof trackable.changed?.add === 'function') { + const labelElement = document.createElement('label'); + labelElement.textContent = key; + container.appendChild(labelElement); + container.appendChild(createControlForTrackable(trackable)); } - return new Float32Array(parts); - }; - - const boundsVec = parseVec(this.bounds.value); - const resolutionVec = parseVec(this.resolution.value).map((s) => s / 1e9); // nm to m - const chunkSizeVec = parseVec(this.chunkSize.value); - - const coordinateSpace = makeCoordinateSpace({ - rank: 3, - names: ["x", "y", "z"], - units: ["m", "m", "m"], - scales: new Float64Array(resolutionVec), - bounds: { - lowerBounds: new Float64Array([0, 0, 0]), - upperBounds: new Float64Array(boundsVec), - voxelCenterAtIntegerCoordinates: [false, false, false], - }, - }); - - const configuration = { - dataType: this.dataType.value, - coordinateSpace, - chunkSize: new Uint32Array(chunkSizeVec), - format: this.format.value, - }; - - const dataSourceProvider = this.manager.dataSourceProviderRegistry; - const provider = dataSourceProvider.getKvStoreBasedProvider( - configuration.format, - ); - - if (provider?.create === undefined) { - throw new Error( - `Dataset creation not supported for format: "${configuration}"`, - ); } + } + } - const promise = provider.create({ - registry: dataSourceProvider, - kvStoreUrl: this.url, - metadata: configuration, - }); - StatusMessage.forPromise(promise, { - initialMessage: `Creating ${this.format.value} dataset at ${this.url}`, - errorPrefix: "Creation failed: ", - delay: true, - }); - - promise.then(() => { - for (const layer of this.manager.rootLayers.managedLayers) { - const dataSource = layer.layer?.dataSources.find( - (ds) => ds.spec.url === this.url, - ); - if (dataSource) { - dataSource.spec = { ...dataSource.spec }; - break; - } - } - this.dispose(); - }); - } catch (e) { - StatusMessage.showTemporaryMessage(`Error: ${(e as Error).message}`); + private async createDataset() { + const provider = this.manager.dataSourceProviderRegistry.getKvStoreBasedProvider(this.dataSourceType.value); + if (!provider?.create) { + StatusMessage.showTemporaryMessage(`Data source '${this.dataSourceType.value}' does not support creation.`, 5000); + return; } + + const options: CreateDataSourceOptions = { + kvStoreUrl: this.url, + registry: this.manager.dataSourceProviderRegistry, + metadata: { + common: this.state.toJSON(), + sourceRelated: this.dataSourceOptions, + } + }; + + StatusMessage.forPromise( + provider.create(options), + { + initialMessage: `Creating dataset at ${this.url}...`, + delay: true, + errorPrefix: 'Creation failed: ', + } + ).then(() => { + StatusMessage.showTemporaryMessage('Dataset created successfully.', 3000); + this.dispose(); + }); } } diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 480e2e7fe9..72b497c8ae 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -4,6 +4,7 @@ - the writing pipeline is only working for uint32 data, which was to be expected as I only used uint32 throughout the development, now that the dataset creation ~~is~~ (will soon be) working (so it is easy to test for every data type), I should fix this. - url completion for the ssa+https source +- the brush circle is not always correct: it is currently aligned with the global voxel size and not the local one, also it may not always be a circle? ### later From 77b131784cdd56165849a8990285625a12ee4ce3 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 12 Nov 2025 17:09:12 +0100 Subject: [PATCH 117/251] chore: run format --- src/datasource/zarr/creation.ts | 121 +++++++----- src/datasource/zarr/frontend.ts | 9 +- src/layer/index.ts | 2 +- src/ui/dataset_creation.ts | 326 +++++++++++++++++++------------- 4 files changed, 267 insertions(+), 191 deletions(-) diff --git a/src/datasource/zarr/creation.ts b/src/datasource/zarr/creation.ts index e37a231655..1e2e73f826 100644 --- a/src/datasource/zarr/creation.ts +++ b/src/datasource/zarr/creation.ts @@ -18,22 +18,21 @@ import type { CreateDataSourceOptions, CommonCreationMetadata, } from "#src/datasource/index.js"; -import { proxyWrite } from '#src/kvstore/proxy.js'; -import { joinPath } from '#src/kvstore/url.js'; -import { DataType } from '#src/util/data_type.js'; +import { proxyWrite } from "#src/kvstore/proxy.js"; +import { joinPath } from "#src/kvstore/url.js"; +import { DataType } from "#src/util/data_type.js"; const dataTypeToZarrV2Dtype: { [key in DataType]?: string } = { - [DataType.UINT8]: '|u1', - [DataType.UINT16]: '; } @@ -49,56 +48,71 @@ class ZarrV2Creator implements ZarrCreator { const scales = []; for (let i = 0; i < commonMetadata.numScales; ++i) { - const downsampleCoeffs = commonMetadata.downsamplingFactor.map((f: number) => Math.pow(f, i)); + const downsampleCoeffs = commonMetadata.downsamplingFactor.map( + (f: number) => Math.pow(f, i), + ); scales.push({ - shape: commonMetadata.shape.map((dim: number, j: number) => Math.ceil(dim / downsampleCoeffs[j])), + shape: commonMetadata.shape.map((dim: number, j: number) => + Math.ceil(dim / downsampleCoeffs[j]), + ), chunks: [64, 64, 64], dtype: dataTypeToZarrV2Dtype[commonMetadata.dataType], compressor: null, - transform: commonMetadata.voxelSize.map((v: number, j: number) => v * downsampleCoeffs[j]), + transform: commonMetadata.voxelSize.map( + (v: number, j: number) => v * downsampleCoeffs[j], + ), }); } const zattrsContent = this._buildV2OmeZattrs(commonMetadata, scales); const writeZattrsPromise = proxyWrite( sharedKvStoreContext, - kvStore.store.getUrl(joinPath(kvStore.path, '.zattrs')), - new TextEncoder().encode(zattrsContent).buffer as ArrayBuffer + kvStore.store.getUrl(joinPath(kvStore.path, ".zattrs")), + new TextEncoder().encode(zattrsContent).buffer as ArrayBuffer, ); const writeZarrayPromises = scales.map((scale: any, i: number) => { - const zarrayUrl = kvStore.store.getUrl(joinPath(kvStore.path, `s${i}`, '.zarray')); + const zarrayUrl = kvStore.store.getUrl( + joinPath(kvStore.path, `s${i}`, ".zarray"), + ); const zarrayContent = this._buildV2Zarray(scale); return proxyWrite( sharedKvStoreContext, zarrayUrl, - new TextEncoder().encode(zarrayContent).buffer as ArrayBuffer + new TextEncoder().encode(zarrayContent).buffer as ArrayBuffer, ); }); await Promise.all([writeZattrsPromise, ...writeZarrayPromises]); } - private _buildV2OmeZattrs(common: CommonCreationMetadata, scales: any[]): string { + private _buildV2OmeZattrs( + common: CommonCreationMetadata, + scales: any[], + ): string { const datasets = scales.map((scale, i) => ({ path: `s${i}`, - coordinateTransformations: [{ - type: 'scale', - scale: scale.transform, - }], + coordinateTransformations: [ + { + type: "scale", + scale: scale.transform, + }, + ], })); const omeMetadata = { - multiscales: [{ - version: '0.4', - axes: [ - { name: 'x', type: 'space', unit: common.voxelUnit }, - { name: 'y', type: 'space', unit: common.voxelUnit }, - { name: 'z', type: 'space', unit: common.voxelUnit }, - ], - datasets, - name: common.name || 'default', - }], + multiscales: [ + { + version: "0.4", + axes: [ + { name: "x", type: "space", unit: common.voxelUnit }, + { name: "y", type: "space", unit: common.voxelUnit }, + { name: "z", type: "space", unit: common.voxelUnit }, + ], + datasets, + name: common.name || "default", + }, + ], }; return JSON.stringify(omeMetadata, null, 2); } @@ -112,7 +126,7 @@ class ZarrV2Creator implements ZarrCreator { dtype: dtype, compressor: compressor, fill_value: 0, - order: 'C', + order: "C", filters: null, }; return JSON.stringify(zarrMetadata, null, 2); @@ -131,7 +145,7 @@ class ZarrV3Creator implements ZarrCreator { const rootGroupContent = this._buildV3RootGroupMetadata(metadata.common); const writeRootPromise = proxyWrite( sharedKvStoreContext, - kvStore.store.getUrl(joinPath(kvStore.path, 'zarr.json')), + kvStore.store.getUrl(joinPath(kvStore.path, "zarr.json")), new TextEncoder().encode(rootGroupContent).buffer as ArrayBuffer, ); @@ -139,12 +153,14 @@ class ZarrV3Creator implements ZarrCreator { const scales: any[] = []; const writeArrayPromises = scales.map((scale: any, i: number) => { - const arrayMetaUrl = kvStore.store.getUrl(joinPath(kvStore.path, `s${i}`, 'zarr.json')); + const arrayMetaUrl = kvStore.store.getUrl( + joinPath(kvStore.path, `s${i}`, "zarr.json"), + ); const arrayMetaContent = this._buildV3ArrayMetadata(scale); return proxyWrite( sharedKvStoreContext, arrayMetaUrl, - new TextEncoder().encode(arrayMetaContent).buffer as ArrayBuffer + new TextEncoder().encode(arrayMetaContent).buffer as ArrayBuffer, ); }); @@ -156,27 +172,30 @@ class ZarrV3Creator implements ZarrCreator { // This is where you would construct the OME-NGFF v0.5+ multiscale metadata. console.log("Building V3 Root Group Metadata with:", metadata); const zarrV3Root = { - "zarr_format": 3, - "node_type": "group", - "attributes": { - "multiscales": [ + zarr_format: 3, + node_type: "group", + attributes: { + multiscales: [ // OME-NGFF v0.5+ multiscale object goes here - ] - } + ], + }, }; return JSON.stringify(zarrV3Root, null, 2); } private _buildV3ArrayMetadata(scaleMetadata: any): string { const zarrV3Array = { - "zarr_format": 3, - "node_type": "array", - "shape": scaleMetadata.shape, - "data_type": "uint32", // This needs to be mapped from DataType enum - "chunk_grid": { "name": "regular", "configuration": { "chunk_shape": scaleMetadata.chunks } }, - "codecs": [ + zarr_format: 3, + node_type: "array", + shape: scaleMetadata.shape, + data_type: "uint32", // This needs to be mapped from DataType enum + chunk_grid: { + name: "regular", + configuration: { chunk_shape: scaleMetadata.chunks }, + }, + codecs: [ // Codec configuration (e.g., blosc, gzip) goes here - ] + ], }; return JSON.stringify(zarrV3Array, null, 2); } diff --git a/src/datasource/zarr/frontend.ts b/src/datasource/zarr/frontend.ts index 559c55683b..5f8ef3ac5c 100644 --- a/src/datasource/zarr/frontend.ts +++ b/src/datasource/zarr/frontend.ts @@ -30,10 +30,9 @@ import type { CreateDataSourceOptions, DataSource, GetKvStoreBasedDataSourceOptions, - KvStoreBasedDataSourceProvider} from "#src/datasource/index.js"; -import { - DataSourceCreationState + KvStoreBasedDataSourceProvider, } from "#src/datasource/index.js"; +import { DataSourceCreationState } from "#src/datasource/index.js"; import { getKvStorePathCompletions } from "#src/datasource/kvstore_completions.js"; import { VolumeChunkSourceParameters } from "#src/datasource/zarr/base.js"; import "#src/datasource/zarr/codec/bytes/resolve.js"; @@ -505,7 +504,9 @@ export class ZarrDataSource implements KvStoreBasedDataSourceProvider { return `Zarr${versionStr} data source`; } - get creationState(){ return this.zarrVersion ? new ZarrCreationState() : undefined}; + get creationState() { + return this.zarrVersion ? new ZarrCreationState() : undefined; + } get(options: GetKvStoreBasedDataSourceOptions): Promise { let { kvStoreUrl, additionalPath, fragment } = resolveUrl(options); diff --git a/src/layer/index.ts b/src/layer/index.ts index 343d85c561..3a1c6b61c4 100644 --- a/src/layer/index.ts +++ b/src/layer/index.ts @@ -876,7 +876,7 @@ export class ManagedUserLayer extends RefCounted { const scales = volume.getSources(identityOptions)[0]; if (!scales || scales.length === 0) continue; - + const highResSource = scales[0]; const shape = Array.from( highResSource.chunkSource.spec.upperVoxelBound, diff --git a/src/ui/dataset_creation.ts b/src/ui/dataset_creation.ts index 8fd85db5c2..f2645f8970 100644 --- a/src/ui/dataset_creation.ts +++ b/src/ui/dataset_creation.ts @@ -14,24 +14,27 @@ * limitations under the License. */ -import type { CreateDataSourceOptions, CommonCreationMetadata , DataSourceCreationState } from '#src/datasource/index.js'; -import type { LayerListSpecification } from '#src/layer/index.js'; -import { Overlay } from '#src/overlay.js'; -import { StatusMessage } from '#src/status.js'; -import { TrackableValue } from '#src/trackable_value.js'; -import { TrackableVec3 } from '#src/trackable_vec3.js'; -import { DataType } from '#src/util/data_type.js'; -import { removeChildren } from '#src/util/dom.js'; -import { vec3 } from '#src/util/geom.js'; -import { verifyInt, verifyString } from '#src/util/json.js'; -import { CompoundTrackable, type Trackable } from '#src/util/trackable.js'; -import { TrackableEnum } from '#src/util/trackable_enum.js'; +import type { + CreateDataSourceOptions, + CommonCreationMetadata, + DataSourceCreationState, +} from "#src/datasource/index.js"; +import type { LayerListSpecification } from "#src/layer/index.js"; +import { Overlay } from "#src/overlay.js"; +import { StatusMessage } from "#src/status.js"; +import { TrackableValue } from "#src/trackable_value.js"; +import { TrackableVec3 } from "#src/trackable_vec3.js"; +import { DataType } from "#src/util/data_type.js"; +import { removeChildren } from "#src/util/dom.js"; +import { vec3 } from "#src/util/geom.js"; +import { verifyInt, verifyString } from "#src/util/json.js"; +import { CompoundTrackable, type Trackable } from "#src/util/trackable.js"; +import { TrackableEnum } from "#src/util/trackable_enum.js"; import { DependentViewWidget } from "#src/widget/dependent_view_widget.js"; -import { EnumSelectWidget } from '#src/widget/enum_widget.js'; -import { NumberInputWidget } from '#src/widget/number_input_widget.js'; -import { TextInputWidget } from '#src/widget/text_input.js'; -import { Vec3Widget } from '#src/widget/vec3_entry_widget.js'; - +import { EnumSelectWidget } from "#src/widget/enum_widget.js"; +import { NumberInputWidget } from "#src/widget/number_input_widget.js"; +import { TextInputWidget } from "#src/widget/text_input.js"; +import { Vec3Widget } from "#src/widget/vec3_entry_widget.js"; function createControlForTrackable(trackable: Trackable): HTMLElement { if (trackable instanceof TrackableVec3) { @@ -42,36 +45,45 @@ function createControlForTrackable(trackable: Trackable): HTMLElement { } if (trackable instanceof TrackableValue) { const value = trackable.value; - if (typeof value === 'number') { + if (typeof value === "number") { return new NumberInputWidget(trackable as TrackableValue).element; } - if (typeof value === 'string') { + if (typeof value === "string") { return new TextInputWidget(trackable as TrackableValue).element; } } - const unsupportedElement = document.createElement('div'); + const unsupportedElement = document.createElement("div"); unsupportedElement.textContent = `Unsupported control type`; return unsupportedElement; } class CommonMetadataState extends CompoundTrackable { - shape = new TrackableVec3(vec3.fromValues(30024, 30024, 30024), vec3.fromValues(30024, 30024, 30024)); + shape = new TrackableVec3( + vec3.fromValues(30024, 30024, 30024), + vec3.fromValues(30024, 30024, 30024), + ); dataType = new TrackableEnum(DataType, DataType.UINT32); - voxelSize = new TrackableVec3(vec3.fromValues(4, 4, 40), vec3.fromValues(4, 4, 40)); - voxelUnit = new TrackableValue('nm', verifyString); + voxelSize = new TrackableVec3( + vec3.fromValues(4, 4, 40), + vec3.fromValues(4, 4, 40), + ); + voxelUnit = new TrackableValue("nm", verifyString); numScales = new TrackableValue(6, verifyInt); - downsamplingFactor = new TrackableVec3(vec3.fromValues(2, 2, 2), vec3.fromValues(2, 2, 2)); - name = new TrackableValue('new-dataset', verifyString); + downsamplingFactor = new TrackableVec3( + vec3.fromValues(2, 2, 2), + vec3.fromValues(2, 2, 2), + ); + name = new TrackableValue("new-dataset", verifyString); constructor() { super(); - this.add('shape', this.shape); - this.add('dataType', this.dataType); - this.add('voxelSize', this.voxelSize); - this.add('voxelUnit', this.voxelUnit); - this.add('numScales', this.numScales); - this.add('downsamplingFactor', this.downsamplingFactor); - this.add('name', this.name); + this.add("shape", this.shape); + this.add("dataType", this.dataType); + this.add("voxelSize", this.voxelSize); + this.add("voxelUnit", this.voxelUnit); + this.add("numScales", this.numScales); + this.add("downsamplingFactor", this.downsamplingFactor); + this.add("name", this.name); } toJSON(): CommonCreationMetadata { @@ -90,95 +102,118 @@ class CommonMetadataState extends CompoundTrackable { reset() {} } - export class DatasetCreationDialog extends Overlay { state = new CommonMetadataState(); - dataSourceType = new TrackableValue('', verifyString); + dataSourceType = new TrackableValue("", verifyString); private dataSourceOptions: DataSourceCreationState | undefined; - constructor(public manager: LayerListSpecification, public url: string) { + constructor( + public manager: LayerListSpecification, + public url: string, + ) { super(); const { content } = this; - content.classList.add('neuroglancer-dataset-creation-dialog'); + content.classList.add("neuroglancer-dataset-creation-dialog"); - const titleElement = document.createElement('h2'); - titleElement.textContent = 'Create New Dataset'; + const titleElement = document.createElement("h2"); + titleElement.textContent = "Create New Dataset"; content.appendChild(titleElement); - const topControls = document.createElement('div'); - topControls.className = 'neuroglancer-creation-top-controls'; + const topControls = document.createElement("div"); + topControls.className = "neuroglancer-creation-top-controls"; content.appendChild(topControls); - topControls.appendChild(this.registerDisposer(new DependentViewWidget( - { changed: this.manager.rootLayers.layersChanged, get value() { return null; } }, - (_value, parentElement) => { - const compatibleLayers = this.manager.rootLayers.managedLayers.filter( - layer => layer.getCreationMetadata() !== undefined - ); - if (compatibleLayers.length === 0) return; - - const label = document.createElement('label'); - label.textContent = 'Copy settings from layer'; - parentElement.appendChild(label); - - const select = document.createElement('select'); - const defaultOption = document.createElement('option'); - defaultOption.textContent = 'None'; - defaultOption.value = ''; - select.appendChild(defaultOption); - - compatibleLayers.forEach(layer => { - const option = document.createElement('option'); - option.textContent = layer.name; - option.value = layer.name; - select.appendChild(option); - }); - - this.registerEventListener(select, 'change', () => { - if (!select.value) return; - const layer = this.manager.rootLayers.getLayerByName(select.value); - if (layer) { - const metadata = layer.getCreationMetadata(); - if (metadata) { - this.state.shape.value = vec3.fromValues(metadata.shape[0], metadata.shape[1], metadata.shape[2]); - (this.state.dataType as TrackableEnum).value = metadata.dataType; - this.state.voxelSize.value = vec3.fromValues(metadata.voxelSize[0], metadata.voxelSize[1], metadata.voxelSize[2]); - this.state.voxelUnit.value = metadata.voxelUnit; - this.state.name.value = metadata.name; - } - } - }); - parentElement.appendChild(select); - } - )).element); - - const commonFields = document.createElement('div'); - commonFields.className = 'neuroglancer-creation-fields-grid'; + topControls.appendChild( + this.registerDisposer( + new DependentViewWidget( + { + changed: this.manager.rootLayers.layersChanged, + get value() { + return null; + }, + }, + (_value, parentElement) => { + const compatibleLayers = + this.manager.rootLayers.managedLayers.filter( + (layer) => layer.getCreationMetadata() !== undefined, + ); + if (compatibleLayers.length === 0) return; + + const label = document.createElement("label"); + label.textContent = "Copy settings from layer"; + parentElement.appendChild(label); + + const select = document.createElement("select"); + const defaultOption = document.createElement("option"); + defaultOption.textContent = "None"; + defaultOption.value = ""; + select.appendChild(defaultOption); + + compatibleLayers.forEach((layer) => { + const option = document.createElement("option"); + option.textContent = layer.name; + option.value = layer.name; + select.appendChild(option); + }); + + this.registerEventListener(select, "change", () => { + if (!select.value) return; + const layer = this.manager.rootLayers.getLayerByName( + select.value, + ); + if (layer) { + const metadata = layer.getCreationMetadata(); + if (metadata) { + this.state.shape.value = vec3.fromValues( + metadata.shape[0], + metadata.shape[1], + metadata.shape[2], + ); + (this.state.dataType as TrackableEnum).value = + metadata.dataType; + this.state.voxelSize.value = vec3.fromValues( + metadata.voxelSize[0], + metadata.voxelSize[1], + metadata.voxelSize[2], + ); + this.state.voxelUnit.value = metadata.voxelUnit; + this.state.name.value = metadata.name; + } + } + }); + parentElement.appendChild(select); + }, + ), + ).element, + ); + + const commonFields = document.createElement("div"); + commonFields.className = "neuroglancer-creation-fields-grid"; content.appendChild(commonFields); const addCommonControl = (trackable: Trackable, label: string) => { - const labelElement = document.createElement('label'); + const labelElement = document.createElement("label"); labelElement.textContent = label; commonFields.appendChild(labelElement); commonFields.appendChild(createControlForTrackable(trackable)); }; - addCommonControl(this.state.name, 'Name'); - addCommonControl(this.state.shape, 'Shape'); - addCommonControl(this.state.dataType, 'Data Type'); - addCommonControl(this.state.voxelSize, 'Voxel Size'); - addCommonControl(this.state.voxelUnit, 'Voxel Unit'); - addCommonControl(this.state.numScales, 'Number of Scales'); - addCommonControl(this.state.downsamplingFactor, 'Downsampling Factor'); + addCommonControl(this.state.name, "Name"); + addCommonControl(this.state.shape, "Shape"); + addCommonControl(this.state.dataType, "Data Type"); + addCommonControl(this.state.voxelSize, "Voxel Size"); + addCommonControl(this.state.voxelUnit, "Voxel Unit"); + addCommonControl(this.state.numScales, "Number of Scales"); + addCommonControl(this.state.downsamplingFactor, "Downsampling Factor"); - const dataSourceSelect = document.createElement('select'); + const dataSourceSelect = document.createElement("select"); const creatableProviders = Array.from( - this.manager.dataSourceProviderRegistry.kvStoreBasedDataSources.values() - ).filter(p => p.creationState !== undefined); + this.manager.dataSourceProviderRegistry.kvStoreBasedDataSources.values(), + ).filter((p) => p.creationState !== undefined); - creatableProviders.forEach(p => { - const option = document.createElement('option'); + creatableProviders.forEach((p) => { + const option = document.createElement("option"); option.value = p.scheme; option.textContent = p.description || p.scheme; dataSourceSelect.appendChild(option); @@ -187,59 +222,78 @@ export class DatasetCreationDialog extends Overlay { if (creatableProviders.length > 0) { this.dataSourceType.value = creatableProviders[0].scheme; } else { - const noProviderMessage = document.createElement('div'); - noProviderMessage.textContent = 'No creatable data source types are configured.'; + const noProviderMessage = document.createElement("div"); + noProviderMessage.textContent = + "No creatable data source types are configured."; content.appendChild(noProviderMessage); } - const dsLabel = document.createElement('label'); - dsLabel.textContent = 'Data Source Type'; + const dsLabel = document.createElement("label"); + dsLabel.textContent = "Data Source Type"; topControls.appendChild(dsLabel); topControls.appendChild(dataSourceSelect); - this.registerEventListener(dataSourceSelect, 'change', () => { + this.registerEventListener(dataSourceSelect, "change", () => { this.dataSourceType.value = dataSourceSelect.value; }); - const optionsContainer = document.createElement('fieldset'); - optionsContainer.className = 'neuroglancer-creation-datasource-options'; - const optionsLegend = document.createElement('legend'); + const optionsContainer = document.createElement("fieldset"); + optionsContainer.className = "neuroglancer-creation-datasource-options"; + const optionsLegend = document.createElement("legend"); optionsContainer.appendChild(optionsLegend); - const optionsGrid = document.createElement('div'); - optionsGrid.className = 'neuroglancer-creation-fields-grid'; + const optionsGrid = document.createElement("div"); + optionsGrid.className = "neuroglancer-creation-fields-grid"; optionsContainer.appendChild(optionsGrid); content.appendChild(optionsContainer); - this.registerDisposer(this.dataSourceType.changed.add(() => { - this.updateDataSourceOptions(optionsGrid, optionsLegend); - })); + this.registerDisposer( + this.dataSourceType.changed.add(() => { + this.updateDataSourceOptions(optionsGrid, optionsLegend); + }), + ); this.updateDataSourceOptions(optionsGrid, optionsLegend); - const actions = document.createElement('div'); - actions.className = 'neuroglancer-creation-actions'; - const createButton = document.createElement('button'); - createButton.textContent = 'Create'; - this.registerEventListener(createButton, 'click', () => this.createDataset()); + const actions = document.createElement("div"); + actions.className = "neuroglancer-creation-actions"; + const createButton = document.createElement("button"); + createButton.textContent = "Create"; + this.registerEventListener(createButton, "click", () => + this.createDataset(), + ); actions.appendChild(createButton); content.appendChild(actions); } - private updateDataSourceOptions(container: HTMLElement, legend: HTMLLegendElement) { + private updateDataSourceOptions( + container: HTMLElement, + legend: HTMLLegendElement, + ) { if (this.dataSourceOptions) { this.dataSourceOptions.dispose(); this.dataSourceOptions = undefined; } removeChildren(container); - const provider = this.manager.dataSourceProviderRegistry.getKvStoreBasedProvider(this.dataSourceType.value); + const provider = + this.manager.dataSourceProviderRegistry.getKvStoreBasedProvider( + this.dataSourceType.value, + ); legend.textContent = `${provider?.description || this.dataSourceType.value} Options`; - const creationState = provider?.creationState as (DataSourceCreationState | undefined); + const creationState = provider?.creationState as + | DataSourceCreationState + | undefined; if (creationState) { this.dataSourceOptions = creationState; for (const key of Object.keys(creationState)) { - if (key === 'changed' || key === 'toJSON' || key === 'restoreState' || key === 'reset') continue; + if ( + key === "changed" || + key === "toJSON" || + key === "restoreState" || + key === "reset" + ) + continue; const trackable = (creationState as any)[key]; - if (trackable && typeof trackable.changed?.add === 'function') { - const labelElement = document.createElement('label'); + if (trackable && typeof trackable.changed?.add === "function") { + const labelElement = document.createElement("label"); labelElement.textContent = key; container.appendChild(labelElement); container.appendChild(createControlForTrackable(trackable)); @@ -248,11 +302,16 @@ export class DatasetCreationDialog extends Overlay { } } - private async createDataset() { - const provider = this.manager.dataSourceProviderRegistry.getKvStoreBasedProvider(this.dataSourceType.value); + const provider = + this.manager.dataSourceProviderRegistry.getKvStoreBasedProvider( + this.dataSourceType.value, + ); if (!provider?.create) { - StatusMessage.showTemporaryMessage(`Data source '${this.dataSourceType.value}' does not support creation.`, 5000); + StatusMessage.showTemporaryMessage( + `Data source '${this.dataSourceType.value}' does not support creation.`, + 5000, + ); return; } @@ -262,18 +321,15 @@ export class DatasetCreationDialog extends Overlay { metadata: { common: this.state.toJSON(), sourceRelated: this.dataSourceOptions, - } + }, }; - StatusMessage.forPromise( - provider.create(options), - { - initialMessage: `Creating dataset at ${this.url}...`, - delay: true, - errorPrefix: 'Creation failed: ', - } - ).then(() => { - StatusMessage.showTemporaryMessage('Dataset created successfully.', 3000); + StatusMessage.forPromise(provider.create(options), { + initialMessage: `Creating dataset at ${this.url}...`, + delay: true, + errorPrefix: "Creation failed: ", + }).then(() => { + StatusMessage.showTemporaryMessage("Dataset created successfully.", 3000); this.dispose(); }); } From f67b9b8b03af6a20c9b1d5dbd81f143ef1785f43 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 13 Nov 2025 14:22:41 +0100 Subject: [PATCH 118/251] refactor(datasource): simplify dataset creation UI and remove unused CSS styles --- src/datasource/zarr/frontend.ts | 21 +-- .../zarr/{ => metadata}/creation.ts | 83 ++++++++++-- src/ui/dataset_creation.css | 79 ----------- src/ui/dataset_creation.ts | 124 ++++++++++-------- 4 files changed, 146 insertions(+), 161 deletions(-) rename src/datasource/zarr/{ => metadata}/creation.ts (75%) delete mode 100644 src/ui/dataset_creation.css diff --git a/src/datasource/zarr/frontend.ts b/src/datasource/zarr/frontend.ts index 5f8ef3ac5c..1e69dc76a7 100644 --- a/src/datasource/zarr/frontend.ts +++ b/src/datasource/zarr/frontend.ts @@ -32,7 +32,6 @@ import type { GetKvStoreBasedDataSourceOptions, KvStoreBasedDataSourceProvider, } from "#src/datasource/index.js"; -import { DataSourceCreationState } from "#src/datasource/index.js"; import { getKvStorePathCompletions } from "#src/datasource/kvstore_completions.js"; import { VolumeChunkSourceParameters } from "#src/datasource/zarr/base.js"; import "#src/datasource/zarr/codec/bytes/resolve.js"; @@ -40,7 +39,10 @@ import "#src/datasource/zarr/codec/crc32c/resolve.js"; import "#src/datasource/zarr/codec/gzip/resolve.js"; import "#src/datasource/zarr/codec/sharding_indexed/resolve.js"; import "#src/datasource/zarr/codec/transpose/resolve.js"; -import { getZarrCreator } from "#src/datasource/zarr/creation.js"; +import { + getZarrCreator, + ZarrCreationState, +} from "#src/datasource/zarr/metadata/creation.js"; import type { ArrayMetadata, DimensionSeparator, @@ -90,21 +92,6 @@ import { import * as matrix from "#src/util/matrix.js"; import type { ProgressOptions } from "#src/util/progress_listener.js"; import { ProgressSpan } from "#src/util/progress_listener.js"; -import { TrackableEnum } from "#src/util/trackable_enum.js"; - -export enum ZarrCompression { - raw = 0, - gzip = 1, -} - -export class ZarrCreationState extends DataSourceCreationState { - compression = new TrackableEnum(ZarrCompression, ZarrCompression.raw); - - constructor() { - super(); - this.add("compression", this.compression); - } -} class ZarrVolumeChunkSource extends WithParameters( WithSharedKvStoreContext(VolumeChunkSource), diff --git a/src/datasource/zarr/creation.ts b/src/datasource/zarr/metadata/creation.ts similarity index 75% rename from src/datasource/zarr/creation.ts rename to src/datasource/zarr/metadata/creation.ts index 1e2e73f826..baf9ecd18e 100644 --- a/src/datasource/zarr/creation.ts +++ b/src/datasource/zarr/metadata/creation.ts @@ -18,10 +18,40 @@ import type { CreateDataSourceOptions, CommonCreationMetadata, } from "#src/datasource/index.js"; +import { DataSourceCreationState } from "#src/datasource/index.js"; import { proxyWrite } from "#src/kvstore/proxy.js"; import { joinPath } from "#src/kvstore/url.js"; import { DataType } from "#src/util/data_type.js"; +import { TrackableEnum } from "#src/util/trackable_enum.js"; + +export enum ZarrCompression { + raw = 0, + //gzip = 1, + // ... +} + +export class ZarrCreationState extends DataSourceCreationState { + compression = new TrackableEnum(ZarrCompression, ZarrCompression.raw); + + constructor() { + super(); + this.add("compression", this.compression); + } +} + +const zarrV2UnitMapping: { [key: string]: string } = { + nm: "nanometer", + um: "micrometer", + mm: "millimeter", + cm: "centimeter", + m: "meter", + s: "second", + ms: "millisecond", + us: "microsecond", + ns: "nanosecond", +}; + const dataTypeToZarrV2Dtype: { [key in DataType]?: string } = { [DataType.UINT8]: "|u1", [DataType.UINT16]: " v * downsampleCoeffs[j], ), @@ -83,13 +120,27 @@ class ZarrV2Creator implements ZarrCreator { ); }); - await Promise.all([writeZattrsPromise, ...writeZarrayPromises]); + await Promise.all([ + writeZgroupPromise, + writeZattrsPromise, + ...writeZarrayPromises, + ]); } private _buildV2OmeZattrs( common: CommonCreationMetadata, scales: any[], ): string { + const fullVoxelUnit = + zarrV2UnitMapping[common.voxelUnit] ?? common.voxelUnit; + const rank = common.shape.length; + const defaultAxes = ["x", "y", "z", "c", "t"]; + const axes = Array.from({ length: rank }, (_, i) => ({ + name: defaultAxes[i] || `dim_${i}`, + type: "space", + unit: fullVoxelUnit, + })); + const datasets = scales.map((scale, i) => ({ path: `s${i}`, coordinateTransformations: [ @@ -104,19 +155,35 @@ class ZarrV2Creator implements ZarrCreator { multiscales: [ { version: "0.4", - axes: [ - { name: "x", type: "space", unit: common.voxelUnit }, - { name: "y", type: "space", unit: common.voxelUnit }, - { name: "z", type: "space", unit: common.voxelUnit }, - ], + axes, datasets, name: common.name || "default", + type: "unknown", + metadata: null, }, ], }; return JSON.stringify(omeMetadata, null, 2); } + private _buildV2ZarrayCompressorMetadata( + zarrState: ZarrCreationState, + ): object | null { + switch (zarrState.compression.value) { + /*case ZarrCompressor.BLOSC: + return { + id: "blosc", + cname: zarrState.bloscCodec.value, + clevel: zarrState.bloscLevel.value, + shuffle: zarrState.bloscShuffle.value, + }; + case ZarrCompressor.GZIP: + return { id: "gzip", level: 1 };*/ + case ZarrCompression.raw: + return null; + } + } + private _buildV2Zarray(scaleMetadata: any): string { const { shape, chunks, dtype, compressor } = scaleMetadata; const zarrMetadata = { diff --git a/src/ui/dataset_creation.css b/src/ui/dataset_creation.css deleted file mode 100644 index f6f6632934..0000000000 --- a/src/ui/dataset_creation.css +++ /dev/null @@ -1,79 +0,0 @@ -/** - * @license - * Copyright 2025 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. - */ - -.neuroglancer-dataset-creation-dialog { - display: flex; - flex-direction: column; - padding: 10px; - max-width: 600px; - font-family: sans-serif; -} - -.neuroglancer-dataset-creation-dialog h2 { - margin-top: 0; - margin-bottom: 15px; - font-size: 18px; - text-align: center; -} - -.neuroglancer-creation-top-controls { - display: grid; - grid-template-columns: auto 1fr; - align-items: center; - gap: 8px 10px; - margin-bottom: 15px; -} - -.neuroglancer-creation-fields-grid { - display: grid; - grid-template-columns: auto 1fr; - align-items: center; - gap: 8px 10px; - margin-bottom: 15px; -} - -.neuroglancer-creation-fields-grid > label { - justify-self: end; - font-weight: 500; -} - -.neuroglancer-vec3-widget { - display: flex; - gap: 5px; -} - -.neuroglancer-vec3-widget > .neuroglancer-number-input { - flex: 1; -} - -.neuroglancer-creation-datasource-options { - border: 1px solid #555; - border-radius: 4px; - padding: 10px; - margin-top: 5px; - margin-bottom: 15px; -} - -.neuroglancer-creation-datasource-options > legend { - padding: 0 5px; - color: #bbb; -} - -.neuroglancer-creation-actions { - display: flex; - justify-content: flex-end; - margin-top: 10px; -} diff --git a/src/ui/dataset_creation.ts b/src/ui/dataset_creation.ts index f2645f8970..18a6a56e35 100644 --- a/src/ui/dataset_creation.ts +++ b/src/ui/dataset_creation.ts @@ -107,6 +107,22 @@ export class DatasetCreationDialog extends Overlay { dataSourceType = new TrackableValue("", verifyString); private dataSourceOptions: DataSourceCreationState | undefined; + addControl = (trackable: Trackable, label: string, parent: HTMLElement) => { + const container = document.createElement("div"); + container.style.display = "flex"; + const labelElement = document.createElement("label"); + labelElement.textContent = label + ": "; + container.appendChild(labelElement); + const ctrl = createControlForTrackable(trackable); + const ctrlContainer = document.createElement("div"); + ctrlContainer.style.display = "flex"; + ctrlContainer.style.flexGrow = "1"; + ctrlContainer.style.justifyContent = "flex-end"; + ctrlContainer.appendChild(ctrl); + container.appendChild(ctrlContainer); + parent.appendChild(container); + }; + constructor( public manager: LayerListSpecification, public url: string, @@ -114,16 +130,47 @@ export class DatasetCreationDialog extends Overlay { super(); const { content } = this; - content.classList.add("neuroglancer-dataset-creation-dialog"); const titleElement = document.createElement("h2"); titleElement.textContent = "Create New Dataset"; content.appendChild(titleElement); const topControls = document.createElement("div"); - topControls.className = "neuroglancer-creation-top-controls"; + topControls.style.display = "flex"; + topControls.style.flexDirection = "column"; + content.appendChild(topControls); + const dataSourceSelect = document.createElement("select"); + const creatableProviders = Array.from( + this.manager.dataSourceProviderRegistry.kvStoreBasedDataSources.values(), + ).filter((p) => p.creationState !== undefined); + + creatableProviders.forEach((p) => { + const option = document.createElement("option"); + option.value = p.scheme; + option.textContent = p.description || p.scheme; + dataSourceSelect.appendChild(option); + }); + + if (creatableProviders.length > 0) { + this.dataSourceType.value = creatableProviders[0].scheme; + } else { + const noProviderMessage = document.createElement("div"); + noProviderMessage.textContent = + "No creatable data source types are configured."; + content.appendChild(noProviderMessage); + } + + const dsLabel = document.createElement("label"); + dsLabel.textContent = "Data Source Type: "; + topControls.appendChild(dsLabel); + topControls.appendChild(dataSourceSelect); + + this.registerEventListener(dataSourceSelect, "change", () => { + this.dataSourceType.value = dataSourceSelect.value; + }); + topControls.appendChild( this.registerDisposer( new DependentViewWidget( @@ -141,7 +188,7 @@ export class DatasetCreationDialog extends Overlay { if (compatibleLayers.length === 0) return; const label = document.createElement("label"); - label.textContent = "Copy settings from layer"; + label.textContent = "Copy settings from layer: "; parentElement.appendChild(label); const select = document.createElement("select"); @@ -188,61 +235,28 @@ export class DatasetCreationDialog extends Overlay { ).element, ); - const commonFields = document.createElement("div"); - commonFields.className = "neuroglancer-creation-fields-grid"; + const commonFields = document.createElement("fieldset"); + const commonLegend = document.createElement("legend"); + commonLegend.textContent = "Common Metadata"; + commonFields.appendChild(commonLegend); content.appendChild(commonFields); - const addCommonControl = (trackable: Trackable, label: string) => { - const labelElement = document.createElement("label"); - labelElement.textContent = label; - commonFields.appendChild(labelElement); - commonFields.appendChild(createControlForTrackable(trackable)); - }; - - addCommonControl(this.state.name, "Name"); - addCommonControl(this.state.shape, "Shape"); - addCommonControl(this.state.dataType, "Data Type"); - addCommonControl(this.state.voxelSize, "Voxel Size"); - addCommonControl(this.state.voxelUnit, "Voxel Unit"); - addCommonControl(this.state.numScales, "Number of Scales"); - addCommonControl(this.state.downsamplingFactor, "Downsampling Factor"); - - const dataSourceSelect = document.createElement("select"); - const creatableProviders = Array.from( - this.manager.dataSourceProviderRegistry.kvStoreBasedDataSources.values(), - ).filter((p) => p.creationState !== undefined); - - creatableProviders.forEach((p) => { - const option = document.createElement("option"); - option.value = p.scheme; - option.textContent = p.description || p.scheme; - dataSourceSelect.appendChild(option); - }); - - if (creatableProviders.length > 0) { - this.dataSourceType.value = creatableProviders[0].scheme; - } else { - const noProviderMessage = document.createElement("div"); - noProviderMessage.textContent = - "No creatable data source types are configured."; - content.appendChild(noProviderMessage); - } - - const dsLabel = document.createElement("label"); - dsLabel.textContent = "Data Source Type"; - topControls.appendChild(dsLabel); - topControls.appendChild(dataSourceSelect); - - this.registerEventListener(dataSourceSelect, "change", () => { - this.dataSourceType.value = dataSourceSelect.value; - }); + this.addControl(this.state.name, "Name", commonFields); + this.addControl(this.state.shape, "Shape", commonFields); + this.addControl(this.state.dataType, "Data Type", commonFields); + this.addControl(this.state.voxelSize, "Voxel Size", commonFields); + this.addControl(this.state.voxelUnit, "Voxel Unit", commonFields); + this.addControl(this.state.numScales, "Number of Scales", commonFields); + this.addControl( + this.state.downsamplingFactor, + "Downsampling Factor", + commonFields, + ); const optionsContainer = document.createElement("fieldset"); - optionsContainer.className = "neuroglancer-creation-datasource-options"; const optionsLegend = document.createElement("legend"); optionsContainer.appendChild(optionsLegend); const optionsGrid = document.createElement("div"); - optionsGrid.className = "neuroglancer-creation-fields-grid"; optionsContainer.appendChild(optionsGrid); content.appendChild(optionsContainer); @@ -254,7 +268,6 @@ export class DatasetCreationDialog extends Overlay { this.updateDataSourceOptions(optionsGrid, optionsLegend); const actions = document.createElement("div"); - actions.className = "neuroglancer-creation-actions"; const createButton = document.createElement("button"); createButton.textContent = "Create"; this.registerEventListener(createButton, "click", () => @@ -277,7 +290,7 @@ export class DatasetCreationDialog extends Overlay { this.manager.dataSourceProviderRegistry.getKvStoreBasedProvider( this.dataSourceType.value, ); - legend.textContent = `${provider?.description || this.dataSourceType.value} Options`; + legend.textContent = `${provider?.description || this.dataSourceType.value} Metadata`; const creationState = provider?.creationState as | DataSourceCreationState | undefined; @@ -293,10 +306,7 @@ export class DatasetCreationDialog extends Overlay { continue; const trackable = (creationState as any)[key]; if (trackable && typeof trackable.changed?.add === "function") { - const labelElement = document.createElement("label"); - labelElement.textContent = key; - container.appendChild(labelElement); - container.appendChild(createControlForTrackable(trackable)); + this.addControl(trackable, key, container); } } } From 898d8f51320826e01bfb0188ccf63954eecdcae1 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 13 Nov 2025 14:40:45 +0100 Subject: [PATCH 119/251] fix(datasource): ensure datasource gets reloaded upon creation --- src/datasource/zarr/frontend.ts | 1 - src/ui/dataset_creation.ts | 20 +++++++++++++++----- src/voxel_annotation/TODOs.md | 8 +++++++- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/datasource/zarr/frontend.ts b/src/datasource/zarr/frontend.ts index 1e69dc76a7..cce22ee5e4 100644 --- a/src/datasource/zarr/frontend.ts +++ b/src/datasource/zarr/frontend.ts @@ -608,7 +608,6 @@ export class ZarrDataSource implements KvStoreBasedDataSourceProvider { ); } async create(options: CreateDataSourceOptions): Promise { - console.log("ZarrDataSource.create", options); const creator = getZarrCreator(this.zarrVersion); await creator.create(options); } diff --git a/src/ui/dataset_creation.ts b/src/ui/dataset_creation.ts index 18a6a56e35..8b4bd6df9f 100644 --- a/src/ui/dataset_creation.ts +++ b/src/ui/dataset_creation.ts @@ -59,13 +59,13 @@ function createControlForTrackable(trackable: Trackable): HTMLElement { class CommonMetadataState extends CompoundTrackable { shape = new TrackableVec3( - vec3.fromValues(30024, 30024, 30024), - vec3.fromValues(30024, 30024, 30024), + vec3.fromValues(42000, 42000, 42000), + vec3.fromValues(42000, 42000, 42000), ); dataType = new TrackableEnum(DataType, DataType.UINT32); voxelSize = new TrackableVec3( - vec3.fromValues(4, 4, 40), - vec3.fromValues(4, 4, 40), + vec3.fromValues(8, 8, 8), + vec3.fromValues(8, 8, 8), ); voxelUnit = new TrackableValue("nm", verifyString); numScales = new TrackableValue(6, verifyInt); @@ -340,7 +340,17 @@ export class DatasetCreationDialog extends Overlay { errorPrefix: "Creation failed: ", }).then(() => { StatusMessage.showTemporaryMessage("Dataset created successfully.", 3000); - this.dispose(); + for (const layer of this.manager.rootLayers.managedLayers) { + if (layer.layer) { + for (const ds of layer.layer.dataSources) { + if (ds.spec.url === this.url) { + ds.spec = { ...ds.spec }; + this.dispose(); + return; + } + } + } + } }); } } diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 72b497c8ae..26e83ece28 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -3,12 +3,18 @@ ### priority - the writing pipeline is only working for uint32 data, which was to be expected as I only used uint32 throughout the development, now that the dataset creation ~~is~~ (will soon be) working (so it is easy to test for every data type), I should fix this. -- url completion for the ssa+https source - the brush circle is not always correct: it is currently aligned with the global voxel size and not the local one, also it may not always be a circle? +- look into @chrisj comment +- rework zarr writing to support compression and v3 + +- Dataset creation: + - the copy from existing seems to not be right on all settings + - complete zarr support (compression and zarr v3) ### later - add preview for the undo/redo +- url completion for the ssa+https source ### questionable From d4c171a879d1045cbd233c1b6ab8a893280db07c Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 13 Nov 2025 15:52:34 +0100 Subject: [PATCH 120/251] fix(voxel-annotation): support every data type (expect float32) --- src/sliceview/volume/backend.ts | 6 +++--- src/voxel_annotation/TODOs.md | 25 ++++++++++++++++++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/sliceview/volume/backend.ts b/src/sliceview/volume/backend.ts index 2b9a134b66..c17748244d 100644 --- a/src/sliceview/volume/backend.ts +++ b/src/sliceview/volume/backend.ts @@ -216,9 +216,9 @@ export class VolumeChunkSource const newValuesArray = new ArrayCtor(values.length); for (let i = 0; i < values.length; ++i) { newValuesArray[i] = - this.spec.dataType === DataType.UINT32 - ? Number(values[i]!) - : values[i]!; + this.spec.dataType === DataType.UINT64 + ? values[i]! + : Number(values[i]!); } const oldValuesArray = new ArrayCtor(indices.length); diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 26e83ece28..b60b064cec 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -2,14 +2,33 @@ ### priority -- the writing pipeline is only working for uint32 data, which was to be expected as I only used uint32 throughout the development, now that the dataset creation ~~is~~ (will soon be) working (so it is easy to test for every data type), I should fix this. -- the brush circle is not always correct: it is currently aligned with the global voxel size and not the local one, also it may not always be a circle? +- preview colors are wrong with signed dataset +- writable float32 dataset is not working (expected), either block its usage or fix +- the brush circle is only correct in Euclidean space (expected since it cannot be an ellipse) - look into @chrisj comment - rework zarr writing to support compression and v3 - Dataset creation: - the copy from existing seems to not be right on all settings - - complete zarr support (compression and zarr v3) + - upon creation of a uint64 dataset, when trying to paint, the preview is getting updated correctly, but the writing pipeline seems to fail, it causes the following error: + +``` +decode_common.ts:56 Uncaught TypeError: Cannot mix BigInt and other types, use explicit conversions + at decodeValueOffset (decode_common.ts:56:32) + at readSingleChannelValueUint64 (decode_common.ts:120:5) + at CompressedSegmentationVolumeChunk.getValueAt (chunk_format.ts:322:42) + at ZarrVolumeChunkSource.getValueAt (frontend.ts:286:20) + at SegmentationRenderLayer.getValueAt (renderlayer.ts:491:29) + at SegmentationUserLayer.getValueAt (index.ts:602:22) + at SegmentationUserLayer.captureSelectionState (index.ts:332:24) + at SegmentationUserLayer.captureSelectionState (annotations.ts:1943:13) + at LayerSelectedValues.update (index.ts:1313:21) + at LayerSelectedValues.get (index.ts:1320:10) +``` + +after a page reload, the painting works again with no issues; the previously painted voxels are not present. + +- complete zarr support (compression and zarr v3) ### later From cdd14be582e690298928337c0e3cc80e66fe950e Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Fri, 14 Nov 2025 14:06:44 +0100 Subject: [PATCH 121/251] feat(datasource): implement support for gzip and blosc codecs in encoding pipeline and complete Zarr v3 metadata creation --- src/async_computation/encode_blosc.ts | 9 + src/async_computation/encode_blosc_request.ts | 6 + src/datasource/zarr/async_computation.ts | 1 + src/datasource/zarr/backend.ts | 4 + src/datasource/zarr/codec/blosc/encode.ts | 14 ++ src/datasource/zarr/codec/bytes/encode.ts | 22 +++ src/datasource/zarr/codec/decode.ts | 6 +- src/datasource/zarr/codec/encode.ts | 82 +++++++-- src/datasource/zarr/codec/gzip/encode.ts | 19 ++ src/datasource/zarr/codec/index.ts | 5 + src/datasource/zarr/metadata/creation.ts | 167 ++++++++++++++---- src/util/gzip.ts | 15 ++ 12 files changed, 288 insertions(+), 62 deletions(-) create mode 100644 src/async_computation/encode_blosc.ts create mode 100644 src/async_computation/encode_blosc_request.ts create mode 100644 src/datasource/zarr/codec/blosc/encode.ts create mode 100644 src/datasource/zarr/codec/bytes/encode.ts create mode 100644 src/datasource/zarr/codec/gzip/encode.ts diff --git a/src/async_computation/encode_blosc.ts b/src/async_computation/encode_blosc.ts new file mode 100644 index 0000000000..d48ddc1526 --- /dev/null +++ b/src/async_computation/encode_blosc.ts @@ -0,0 +1,9 @@ +import { encodeBlosc } from "#src/async_computation/encode_blosc_request.js"; +import { registerAsyncComputation } from "#src/async_computation/handler.js"; + +registerAsyncComputation(encodeBlosc, async (data, config) => { + const { default: Blosc } = await import("numcodecs/blosc"); + const codec = Blosc.fromConfig({ id: "blosc", ...config }); + const result = await codec.encode(data); + return { value: result, transfer: [result.buffer] }; +}); diff --git a/src/async_computation/encode_blosc_request.ts b/src/async_computation/encode_blosc_request.ts new file mode 100644 index 0000000000..8ac946ebc3 --- /dev/null +++ b/src/async_computation/encode_blosc_request.ts @@ -0,0 +1,6 @@ +import { asyncComputation } from "#src/async_computation/index.js"; + +export const encodeBlosc = + asyncComputation<(data: Uint8Array, config: any) => Uint8Array>( + "encodeBlosc", + ); diff --git a/src/datasource/zarr/async_computation.ts b/src/datasource/zarr/async_computation.ts index db56b860ff..678e1d6bfc 100644 --- a/src/datasource/zarr/async_computation.ts +++ b/src/datasource/zarr/async_computation.ts @@ -1,2 +1,3 @@ import "#src/async_computation/decode_blosc.js"; import "#src/async_computation/decode_zstd.js"; +import "#src/async_computation/encode_blosc.js"; diff --git a/src/datasource/zarr/backend.ts b/src/datasource/zarr/backend.ts index f9518566c0..4103e20490 100644 --- a/src/datasource/zarr/backend.ts +++ b/src/datasource/zarr/backend.ts @@ -19,6 +19,10 @@ import "#src/datasource/zarr/codec/zstd/decode.js"; import "#src/datasource/zarr/codec/bytes/decode.js"; import "#src/datasource/zarr/codec/crc32c/decode.js"; +import "#src/datasource/zarr/codec/bytes/encode.js"; +import "#src/datasource/zarr/codec/gzip/encode.js"; +import "#src/datasource/zarr/codec/blosc/encode.js"; + import { WithParameters } from "#src/chunk_manager/backend.js"; import { VolumeChunkSourceParameters } from "#src/datasource/zarr/base.js"; import { diff --git a/src/datasource/zarr/codec/blosc/encode.ts b/src/datasource/zarr/codec/blosc/encode.ts new file mode 100644 index 0000000000..404bee40e4 --- /dev/null +++ b/src/datasource/zarr/codec/blosc/encode.ts @@ -0,0 +1,14 @@ +import { encodeBlosc } from "#src/async_computation/encode_blosc_request.js"; +import { requestAsyncComputation } from "#src/async_computation/request.js"; +import type { Configuration } from "#src/datasource/zarr/codec/blosc/resolve.js"; +import { registerCodec } from "#src/datasource/zarr/codec/encode.js"; +import { CodecKind } from "#src/datasource/zarr/codec/index.js"; + +registerCodec({ + name: "blosc", + kind: CodecKind.bytesToBytes, + encode(configuration: Configuration, + decoded: Uint8Array, signal: AbortSignal): Promise { + return requestAsyncComputation(encodeBlosc, signal, [decoded.buffer], decoded, configuration); + }, +}); diff --git a/src/datasource/zarr/codec/bytes/encode.ts b/src/datasource/zarr/codec/bytes/encode.ts new file mode 100644 index 0000000000..cdf8264b85 --- /dev/null +++ b/src/datasource/zarr/codec/bytes/encode.ts @@ -0,0 +1,22 @@ +import type { Configuration } from "#src/datasource/zarr/codec/bytes/resolve.js"; +import { registerCodec } from "#src/datasource/zarr/codec/encode.js"; +import { + type CodecArrayInfo, + CodecKind, +} from "#src/datasource/zarr/codec/index.js"; +import { DATA_TYPE_BYTES } from "#src/sliceview/base.js"; +import { convertEndian } from "#src/util/endian.js"; + +registerCodec({ + name: "bytes", + kind: CodecKind.arrayToBytes, + async encode( + configuration: Configuration, + encodedArrayInfo: CodecArrayInfo, + decoded: ArrayBufferView, + ): Promise { + const bytesPerElement = DATA_TYPE_BYTES[encodedArrayInfo.dataType]; + convertEndian(decoded, configuration.endian, bytesPerElement); + return new Uint8Array(decoded.buffer, decoded.byteOffset, decoded.byteLength); + }, +}); diff --git a/src/datasource/zarr/codec/decode.ts b/src/datasource/zarr/codec/decode.ts index 2cc3ea9184..9c97c8819e 100644 --- a/src/datasource/zarr/codec/decode.ts +++ b/src/datasource/zarr/codec/decode.ts @@ -18,16 +18,12 @@ import type { ChunkManager } from "#src/chunk_manager/backend.js"; import type { CodecArrayInfo, CodecChainSpec, + Codec, } from "#src/datasource/zarr/codec/index.js"; import { CodecKind } from "#src/datasource/zarr/codec/index.js"; import type { KvStoreWithPath, ReadableKvStore } from "#src/kvstore/index.js"; import type { RefCounted } from "#src/util/disposable.js"; -export interface Codec { - name: string; - kind: CodecKind; -} - export interface ArrayToArrayCodec extends Codec { kind: CodecKind.arrayToArray; decode( diff --git a/src/datasource/zarr/codec/encode.ts b/src/datasource/zarr/codec/encode.ts index 3f368939c9..6c200238e0 100644 --- a/src/datasource/zarr/codec/encode.ts +++ b/src/datasource/zarr/codec/encode.ts @@ -1,25 +1,69 @@ -/** - * Minimal Zarr encode pipeline to persist chunks. - * Supports only the common case of raw bytes (no transpose/compression/sharding). - */ -import type { CodecChainSpec } from "#src/datasource/zarr/codec/index.js"; +import type { + CodecChainSpec, + Codec, + CodecArrayInfo, +} from "#src/datasource/zarr/codec/index.js"; import { CodecKind } from "#src/datasource/zarr/codec/index.js"; +interface ArrayToBytesCodec extends Codec { + kind: CodecKind.arrayToBytes; + encode( + configuration: Configuration, + encodedArrayInfo: CodecArrayInfo, + decoded: ArrayBufferView, + signal: AbortSignal, + ): Promise; +} + +interface BytesToBytesCodec extends Codec { + kind: CodecKind.bytesToBytes; + encode( + configuration: Configuration, + decoded: Uint8Array, + signal: AbortSignal, + ): Promise; +} + +const codecRegistry = { + [CodecKind.arrayToBytes]: new Map(), + [CodecKind.bytesToBytes]: new Map(), +}; + +export function registerCodec( + codec: ArrayToBytesCodec | BytesToBytesCodec, +) { + codecRegistry[codec.kind].set(codec.name, codec as any); +} + export async function encodeArray( codecs: CodecChainSpec, - typed: ArrayBufferView, - _signal: AbortSignal, -): Promise> { - // Only support simple "bytes" encoding with no array-to-array and no bytes-to-bytes codecs. - const hasArrayToArray = codecs[CodecKind.arrayToArray].length > 0; - const hasBytesToBytes = codecs[CodecKind.bytesToBytes].length > 0; - const arrayToBytes = codecs[CodecKind.arrayToBytes]; - if (hasArrayToArray || hasBytesToBytes || arrayToBytes.name !== "bytes") { - throw new Error( - `encodeArray: Unsupported codec chain; only raw 'bytes' without additional codecs is supported. Got arrayToArray=${hasArrayToArray}, bytesToBytes=${hasBytesToBytes}, arrayToBytes=${arrayToBytes.name}`, - ); + decoded: ArrayBufferView, + signal: AbortSignal, +): Promise { + if (codecs[CodecKind.arrayToArray].length > 0) { + throw new Error("array -> array codecs are not supported for writing."); + } + + const arrayToBytesCodecSpec = codecs[CodecKind.arrayToBytes]; + const arrayToBytesImpl = codecRegistry[CodecKind.arrayToBytes].get(arrayToBytesCodecSpec.name); + if (!arrayToBytesImpl) { + throw new Error(`Unsupported array -> bytes codec for writing: ${arrayToBytesCodecSpec.name}`); } - // For raw bytes, we can write the underlying buffer. - const { buffer, byteOffset, byteLength } = typed; - return new Uint8Array(buffer, byteOffset, byteLength); + const arrayInfo = codecs.arrayInfo[codecs.arrayInfo.length - 1]; + let data = await arrayToBytesImpl.encode( + arrayToBytesCodecSpec.configuration, + arrayInfo, + decoded, + signal, + ); + + for (const codecSpec of codecs[CodecKind.bytesToBytes]) { + const bytesToBytesImpl = codecRegistry[CodecKind.bytesToBytes].get(codecSpec.name); + if (!bytesToBytesImpl) { + throw new Error(`Unsupported bytes -> bytes codec for writing: ${codecSpec.name}`); + } + data = await bytesToBytesImpl.encode(codecSpec.configuration, data, signal); + } + + return data; } diff --git a/src/datasource/zarr/codec/gzip/encode.ts b/src/datasource/zarr/codec/gzip/encode.ts new file mode 100644 index 0000000000..1de01f1b8e --- /dev/null +++ b/src/datasource/zarr/codec/gzip/encode.ts @@ -0,0 +1,19 @@ +import { registerCodec } from "#src/datasource/zarr/codec/encode.js"; +import type { Configuration } from "#src/datasource/zarr/codec/gzip/resolve.js"; +import { CodecKind } from "#src/datasource/zarr/codec/index.js"; +import { encodeGzip } from "#src/util/gzip.js"; + +for (const [name, compressionFormat] of [ + ["gzip", "gzip"], + ["zlib", "deflate"], +] as const) { + registerCodec({ + name, + kind: CodecKind.bytesToBytes, + async encode(configuration: Configuration, decoded: Uint8Array): Promise { + configuration; + const result = await encodeGzip(decoded, compressionFormat); + return new Uint8Array(result); + }, + }); +} diff --git a/src/datasource/zarr/codec/index.ts b/src/datasource/zarr/codec/index.ts index bb99f48ebb..999a95bef1 100644 --- a/src/datasource/zarr/codec/index.ts +++ b/src/datasource/zarr/codec/index.ts @@ -16,6 +16,11 @@ import type { DataType } from "#src/util/data_type.js"; +export interface Codec { + name: string; + kind: CodecKind; +} + export enum CodecKind { arrayToArray = 0, arrayToBytes = 1, diff --git a/src/datasource/zarr/metadata/creation.ts b/src/datasource/zarr/metadata/creation.ts index baf9ecd18e..4831bd5ec3 100644 --- a/src/datasource/zarr/metadata/creation.ts +++ b/src/datasource/zarr/metadata/creation.ts @@ -26,13 +26,13 @@ import { DataType } from "#src/util/data_type.js"; import { TrackableEnum } from "#src/util/trackable_enum.js"; export enum ZarrCompression { - raw = 0, - //gzip = 1, - // ... + RAW = 0, + GZIP = 1, + BLOSC = 2, } export class ZarrCreationState extends DataSourceCreationState { - compression = new TrackableEnum(ZarrCompression, ZarrCompression.raw); + compression = new TrackableEnum(ZarrCompression, ZarrCompression.RAW); constructor() { super(); @@ -40,7 +40,7 @@ export class ZarrCreationState extends DataSourceCreationState { } } -const zarrV2UnitMapping: { [key: string]: string } = { +const zarrUnitMapping: { [key: string]: string } = { nm: "nanometer", um: "micrometer", mm: "millimeter", @@ -132,7 +132,7 @@ class ZarrV2Creator implements ZarrCreator { scales: any[], ): string { const fullVoxelUnit = - zarrV2UnitMapping[common.voxelUnit] ?? common.voxelUnit; + zarrUnitMapping[common.voxelUnit] ?? common.voxelUnit; const rank = common.shape.length; const defaultAxes = ["x", "y", "z", "c", "t"]; const axes = Array.from({ length: rank }, (_, i) => ({ @@ -170,16 +170,17 @@ class ZarrV2Creator implements ZarrCreator { zarrState: ZarrCreationState, ): object | null { switch (zarrState.compression.value) { - /*case ZarrCompressor.BLOSC: + case ZarrCompression.BLOSC: return { id: "blosc", - cname: zarrState.bloscCodec.value, - clevel: zarrState.bloscLevel.value, - shuffle: zarrState.bloscShuffle.value, + cname: "lz4", + clevel: 5, + shuffle: 1, }; - case ZarrCompressor.GZIP: - return { id: "gzip", level: 1 };*/ - case ZarrCompression.raw: + case ZarrCompression.GZIP: + return { id: "gzip", level: 1 }; + case ZarrCompression.RAW: + default: return null; } } @@ -200,15 +201,24 @@ class ZarrV2Creator implements ZarrCreator { } } +const dataTypeToZarrV3Dtype: { [key in DataType]?: string } = { + [DataType.UINT8]: "uint8", + [DataType.UINT16]: "uint16", + [DataType.UINT32]: "uint32", + [DataType.UINT64]: "uint64", + [DataType.INT8]: "int8", + [DataType.INT16]: "int16", + [DataType.INT32]: "int32", + [DataType.FLOAT32]: "float32", +}; + + class ZarrV3Creator implements ZarrCreator { async create(options: CreateDataSourceOptions): Promise { const { kvStoreUrl, registry, metadata } = options; const { sharedKvStoreContext } = registry; const kvStore = sharedKvStoreContext.kvStoreContext.getKvStore(kvStoreUrl); - // This logic is a placeholder and needs to be filled out with the specifics - // of generating Zarr v3 metadata files. - const rootGroupContent = this._buildV3RootGroupMetadata(metadata.common); const writeRootPromise = proxyWrite( sharedKvStoreContext, @@ -216,14 +226,34 @@ class ZarrV3Creator implements ZarrCreator { new TextEncoder().encode(rootGroupContent).buffer as ArrayBuffer, ); - // Placeholder for scale calculation. - const scales: any[] = []; + const commonMetadata = metadata.common as CommonCreationMetadata; + const zarrMetadata = metadata.sourceRelated as ZarrCreationState; + + const scales = []; + for (let i = 0; i < commonMetadata.numScales; ++i) { + const downsampleCoeffs = commonMetadata.downsamplingFactor.map( + (f: number) => Math.pow(f, i), + ); + scales.push({ + shape: commonMetadata.shape.map((dim: number, j: number) => + Math.ceil(dim / downsampleCoeffs[j]), + ), + chunks: [64, 64, 64], + dataType: dataTypeToZarrV3Dtype[commonMetadata.dataType], + transform: commonMetadata.voxelSize.map( + (v: number, j: number) => v * downsampleCoeffs[j], + ), + }); + } const writeArrayPromises = scales.map((scale: any, i: number) => { const arrayMetaUrl = kvStore.store.getUrl( joinPath(kvStore.path, `s${i}`, "zarr.json"), ); - const arrayMetaContent = this._buildV3ArrayMetadata(scale); + const arrayMetaContent = this._buildV3ArrayMetadata( + scale, + zarrMetadata, + ); return proxyWrite( sharedKvStoreContext, arrayMetaUrl, @@ -234,35 +264,96 @@ class ZarrV3Creator implements ZarrCreator { await Promise.all([writeRootPromise, ...writeArrayPromises]); } - private _buildV3RootGroupMetadata(metadata: any): string { - // Generates the root zarr.json for an OME-NGFF group using Zarr v3 spec. - // This is where you would construct the OME-NGFF v0.5+ multiscale metadata. - console.log("Building V3 Root Group Metadata with:", metadata); - const zarrV3Root = { + private _buildV3RootGroupMetadata(common: CommonCreationMetadata): string { + const rank = common.shape.length; + const defaultAxes = ["x", "y", "z", "c", "t"]; + const axes = Array.from({ length: rank }, (_, i) => ({ + name: defaultAxes[i] || `dim_${i}`, + type: "space", + unit: zarrUnitMapping[common.voxelUnit], + })); + + const datasets = Array.from({ length: common.numScales }, (_, i) => ({ + path: `s${i}`, + coordinateTransformations: [ + { + type: "scale", + scale: common.downsamplingFactor.map((f, j) => + (common.voxelSize[j] * Math.pow(f, i)), + ), + }, + ], + })); + + const omeMetadata = { + multiscales: [ + { + version: "0.5", // OME-NGFF version compatible with Zarr v3 + axes, + datasets, + name: common.name || "default", + }, + ], + }; + + return JSON.stringify({ zarr_format: 3, node_type: "group", - attributes: { - multiscales: [ - // OME-NGFF v0.5+ multiscale object goes here - ], - }, - }; - return JSON.stringify(zarrV3Root, null, 2); + attributes: omeMetadata, + }, null, 2); } - private _buildV3ArrayMetadata(scaleMetadata: any): string { + private _buildV3ArrayMetadata( + scaleMetadata: any, + zarrState: ZarrCreationState, + ): string { + const { shape, chunks, dataType } = scaleMetadata; + + const codecs: { name: string; configuration?: any }[] = [ + { + name: "bytes", + configuration: { + endian: "little", + }, + }, + ]; + + switch (zarrState.compression.value) { + case ZarrCompression.GZIP: + codecs.push({ name: "gzip", configuration: { level: 1 } }); + break; + case ZarrCompression.BLOSC: + codecs.push({ + name: "blosc", + configuration: { + cname: "lz4", + clevel: 5, + shuffle: "bit", + }, + }); + break; + } + const zarrV3Array = { zarr_format: 3, node_type: "array", - shape: scaleMetadata.shape, - data_type: "uint32", // This needs to be mapped from DataType enum + shape: shape, + data_type: dataType, chunk_grid: { name: "regular", - configuration: { chunk_shape: scaleMetadata.chunks }, + configuration: { + chunk_shape: chunks, + }, }, - codecs: [ - // Codec configuration (e.g., blosc, gzip) goes here - ], + chunk_key_encoding: { + name: "default", + configuration: { + separator: "/", + }, + }, + codecs: codecs, + fill_value: 0, + attributes: {}, }; return JSON.stringify(zarrV3Array, null, 2); } diff --git a/src/util/gzip.ts b/src/util/gzip.ts index 4b412bf5ca..c62d86a9e7 100644 --- a/src/util/gzip.ts +++ b/src/util/gzip.ts @@ -67,3 +67,18 @@ export async function maybeDecompressGzip(data: ArrayBuffer | ArrayBufferView) { } return byteView; } + +export async function encodeGzip( + data: Uint8Array | ArrayBuffer, + format: CompressionFormat, +): Promise { + const readableStream = new ReadableStream({ + start(controller) { + controller.enqueue(data); + controller.close(); + }, + }); + const compressionStream = new CompressionStream(format); + const compressedStream = readableStream.pipeThrough(compressionStream); + return await new Response(compressedStream).arrayBuffer(); +} From bc84ca0da146124bc6a862c08b583dea5ba29403 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Fri, 14 Nov 2025 14:08:50 +0100 Subject: [PATCH 122/251] chore: format code + update TODOs --- src/datasource/zarr/codec/blosc/encode.ts | 15 ++++++++-- src/datasource/zarr/codec/bytes/encode.ts | 6 +++- src/datasource/zarr/codec/encode.ts | 16 ++++++++--- src/datasource/zarr/codec/gzip/encode.ts | 5 +++- src/datasource/zarr/metadata/creation.ts | 34 ++++++++++++----------- src/voxel_annotation/TODOs.md | 4 +-- 6 files changed, 52 insertions(+), 28 deletions(-) diff --git a/src/datasource/zarr/codec/blosc/encode.ts b/src/datasource/zarr/codec/blosc/encode.ts index 404bee40e4..97ed9f2791 100644 --- a/src/datasource/zarr/codec/blosc/encode.ts +++ b/src/datasource/zarr/codec/blosc/encode.ts @@ -7,8 +7,17 @@ import { CodecKind } from "#src/datasource/zarr/codec/index.js"; registerCodec({ name: "blosc", kind: CodecKind.bytesToBytes, - encode(configuration: Configuration, - decoded: Uint8Array, signal: AbortSignal): Promise { - return requestAsyncComputation(encodeBlosc, signal, [decoded.buffer], decoded, configuration); + encode( + configuration: Configuration, + decoded: Uint8Array, + signal: AbortSignal, + ): Promise { + return requestAsyncComputation( + encodeBlosc, + signal, + [decoded.buffer], + decoded, + configuration, + ); }, }); diff --git a/src/datasource/zarr/codec/bytes/encode.ts b/src/datasource/zarr/codec/bytes/encode.ts index cdf8264b85..dca2801f98 100644 --- a/src/datasource/zarr/codec/bytes/encode.ts +++ b/src/datasource/zarr/codec/bytes/encode.ts @@ -17,6 +17,10 @@ registerCodec({ ): Promise { const bytesPerElement = DATA_TYPE_BYTES[encodedArrayInfo.dataType]; convertEndian(decoded, configuration.endian, bytesPerElement); - return new Uint8Array(decoded.buffer, decoded.byteOffset, decoded.byteLength); + return new Uint8Array( + decoded.buffer, + decoded.byteOffset, + decoded.byteLength, + ); }, }); diff --git a/src/datasource/zarr/codec/encode.ts b/src/datasource/zarr/codec/encode.ts index 6c200238e0..f2ec602c87 100644 --- a/src/datasource/zarr/codec/encode.ts +++ b/src/datasource/zarr/codec/encode.ts @@ -45,9 +45,13 @@ export async function encodeArray( } const arrayToBytesCodecSpec = codecs[CodecKind.arrayToBytes]; - const arrayToBytesImpl = codecRegistry[CodecKind.arrayToBytes].get(arrayToBytesCodecSpec.name); + const arrayToBytesImpl = codecRegistry[CodecKind.arrayToBytes].get( + arrayToBytesCodecSpec.name, + ); if (!arrayToBytesImpl) { - throw new Error(`Unsupported array -> bytes codec for writing: ${arrayToBytesCodecSpec.name}`); + throw new Error( + `Unsupported array -> bytes codec for writing: ${arrayToBytesCodecSpec.name}`, + ); } const arrayInfo = codecs.arrayInfo[codecs.arrayInfo.length - 1]; let data = await arrayToBytesImpl.encode( @@ -58,9 +62,13 @@ export async function encodeArray( ); for (const codecSpec of codecs[CodecKind.bytesToBytes]) { - const bytesToBytesImpl = codecRegistry[CodecKind.bytesToBytes].get(codecSpec.name); + const bytesToBytesImpl = codecRegistry[CodecKind.bytesToBytes].get( + codecSpec.name, + ); if (!bytesToBytesImpl) { - throw new Error(`Unsupported bytes -> bytes codec for writing: ${codecSpec.name}`); + throw new Error( + `Unsupported bytes -> bytes codec for writing: ${codecSpec.name}`, + ); } data = await bytesToBytesImpl.encode(codecSpec.configuration, data, signal); } diff --git a/src/datasource/zarr/codec/gzip/encode.ts b/src/datasource/zarr/codec/gzip/encode.ts index 1de01f1b8e..aa21ea4d86 100644 --- a/src/datasource/zarr/codec/gzip/encode.ts +++ b/src/datasource/zarr/codec/gzip/encode.ts @@ -10,7 +10,10 @@ for (const [name, compressionFormat] of [ registerCodec({ name, kind: CodecKind.bytesToBytes, - async encode(configuration: Configuration, decoded: Uint8Array): Promise { + async encode( + configuration: Configuration, + decoded: Uint8Array, + ): Promise { configuration; const result = await encodeGzip(decoded, compressionFormat); return new Uint8Array(result); diff --git a/src/datasource/zarr/metadata/creation.ts b/src/datasource/zarr/metadata/creation.ts index 4831bd5ec3..5c7fe041de 100644 --- a/src/datasource/zarr/metadata/creation.ts +++ b/src/datasource/zarr/metadata/creation.ts @@ -32,7 +32,10 @@ export enum ZarrCompression { } export class ZarrCreationState extends DataSourceCreationState { - compression = new TrackableEnum(ZarrCompression, ZarrCompression.RAW); + compression = new TrackableEnum( + ZarrCompression, + ZarrCompression.RAW, + ); constructor() { super(); @@ -131,8 +134,7 @@ class ZarrV2Creator implements ZarrCreator { common: CommonCreationMetadata, scales: any[], ): string { - const fullVoxelUnit = - zarrUnitMapping[common.voxelUnit] ?? common.voxelUnit; + const fullVoxelUnit = zarrUnitMapping[common.voxelUnit] ?? common.voxelUnit; const rank = common.shape.length; const defaultAxes = ["x", "y", "z", "c", "t"]; const axes = Array.from({ length: rank }, (_, i) => ({ @@ -212,7 +214,6 @@ const dataTypeToZarrV3Dtype: { [key in DataType]?: string } = { [DataType.FLOAT32]: "float32", }; - class ZarrV3Creator implements ZarrCreator { async create(options: CreateDataSourceOptions): Promise { const { kvStoreUrl, registry, metadata } = options; @@ -250,10 +251,7 @@ class ZarrV3Creator implements ZarrCreator { const arrayMetaUrl = kvStore.store.getUrl( joinPath(kvStore.path, `s${i}`, "zarr.json"), ); - const arrayMetaContent = this._buildV3ArrayMetadata( - scale, - zarrMetadata, - ); + const arrayMetaContent = this._buildV3ArrayMetadata(scale, zarrMetadata); return proxyWrite( sharedKvStoreContext, arrayMetaUrl, @@ -270,7 +268,7 @@ class ZarrV3Creator implements ZarrCreator { const axes = Array.from({ length: rank }, (_, i) => ({ name: defaultAxes[i] || `dim_${i}`, type: "space", - unit: zarrUnitMapping[common.voxelUnit], + unit: zarrUnitMapping[common.voxelUnit], })); const datasets = Array.from({ length: common.numScales }, (_, i) => ({ @@ -278,8 +276,8 @@ class ZarrV3Creator implements ZarrCreator { coordinateTransformations: [ { type: "scale", - scale: common.downsamplingFactor.map((f, j) => - (common.voxelSize[j] * Math.pow(f, i)), + scale: common.downsamplingFactor.map( + (f, j) => common.voxelSize[j] * Math.pow(f, i), ), }, ], @@ -296,11 +294,15 @@ class ZarrV3Creator implements ZarrCreator { ], }; - return JSON.stringify({ - zarr_format: 3, - node_type: "group", - attributes: omeMetadata, - }, null, 2); + return JSON.stringify( + { + zarr_format: 3, + node_type: "group", + attributes: omeMetadata, + }, + null, + 2, + ); } private _buildV3ArrayMetadata( diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index b60b064cec..fdc927faa9 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -6,7 +6,7 @@ - writable float32 dataset is not working (expected), either block its usage or fix - the brush circle is only correct in Euclidean space (expected since it cannot be an ellipse) - look into @chrisj comment -- rework zarr writing to support compression and v3 +- blosc encoding is wrong - Dataset creation: - the copy from existing seems to not be right on all settings @@ -28,8 +28,6 @@ decode_common.ts:56 Uncaught TypeError: Cannot mix BigInt and other types, use e after a page reload, the painting works again with no issues; the previously painted voxels are not present. -- complete zarr support (compression and zarr v3) - ### later - add preview for the undo/redo From b5d90b8bbbf6918ecfd4087941ca48bafda2ee2b Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Fri, 14 Nov 2025 14:38:01 +0100 Subject: [PATCH 123/251] refactor(datasource): following @chrisj comment: generalize KvStore interface key type and update codecs to use it instead of the ReadableKvStore subclass --- src/datasource/zarr/backend.ts | 2 +- src/datasource/zarr/codec/decode.ts | 10 +++++++--- src/kvstore/index.ts | 12 ++++++------ src/voxel_annotation/TODOs.md | 1 - 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/datasource/zarr/backend.ts b/src/datasource/zarr/backend.ts index 4103e20490..ddb3985dc7 100644 --- a/src/datasource/zarr/backend.ts +++ b/src/datasource/zarr/backend.ts @@ -107,7 +107,7 @@ export class ZarrVolumeChunkSource extends WithParameters( } async writeChunk(chunk: VolumeChunk): Promise { - const { kvStore, getChunkKey, decodeCodecs } = this.chunkKvStore as any; + const { kvStore, getChunkKey, decodeCodecs } = this.chunkKvStore; if (!kvStore.write) { throw new Error( "ZarrVolumeChunkSource.writeChunk: underlying kvStore is not writable", diff --git a/src/datasource/zarr/codec/decode.ts b/src/datasource/zarr/codec/decode.ts index 9c97c8819e..519be93a15 100644 --- a/src/datasource/zarr/codec/decode.ts +++ b/src/datasource/zarr/codec/decode.ts @@ -21,7 +21,11 @@ import type { Codec, } from "#src/datasource/zarr/codec/index.js"; import { CodecKind } from "#src/datasource/zarr/codec/index.js"; -import type { KvStoreWithPath, ReadableKvStore } from "#src/kvstore/index.js"; +import type { + KvStore, + KvStoreWithPath, + ReadableKvStore, +} from "#src/kvstore/index.js"; import type { RefCounted } from "#src/util/disposable.js"; export interface ArrayToArrayCodec extends Codec { @@ -141,14 +145,14 @@ export function applySharding( codecs: CodecChainSpec, baseKvStore: KvStoreWithPath, ): { - kvStore: ReadableKvStore; + kvStore: KvStore; getChunkKey: ( chunkGridPosition: ArrayLike, baseKey: string, ) => unknown; decodeCodecs: CodecChainSpec; } { - let kvStore: ReadableKvStore = baseKvStore.store; + let kvStore: KvStore = baseKvStore.store; let curCodecs = codecs; while (true) { const { shardingInfo } = curCodecs; diff --git a/src/kvstore/index.ts b/src/kvstore/index.ts index 763d3190fa..159e6caa7c 100644 --- a/src/kvstore/index.ts +++ b/src/kvstore/index.ts @@ -91,15 +91,15 @@ export interface ListableKvStore { list?: (prefix: string, options: DriverListOptions) => Promise; } -export interface WritableKvStore { - write?: (key: string, value: ArrayBuffer) => Promise; - delete?: (key: string) => Promise; +export interface WritableKvStore { + write?: (key: Key, value: ArrayBuffer) => Promise; + delete?: (key: Key) => Promise; } -export interface KvStore - extends ReadableKvStore, +export interface KvStore + extends ReadableKvStore, ListableKvStore, - WritableKvStore { + WritableKvStore { // Indicates that the only valid key is the empty string. singleKey?: boolean; } diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index fdc927faa9..9caa8a2c7f 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -5,7 +5,6 @@ - preview colors are wrong with signed dataset - writable float32 dataset is not working (expected), either block its usage or fix - the brush circle is only correct in Euclidean space (expected since it cannot be an ellipse) -- look into @chrisj comment - blosc encoding is wrong - Dataset creation: From 9078c418fee2c95047c899bbc349442ee385b6df Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 17 Nov 2025 11:03:56 +0100 Subject: [PATCH 124/251] feat(voxel-annotation): support elliptical brush outlines (cursor) --- src/rendered_data_panel.ts | 14 +++-- src/ui/voxel_annotations.ts | 97 ++++++++++++++++++++++++++++++++--- src/voxel_annotation/TODOs.md | 1 - 3 files changed, 102 insertions(+), 10 deletions(-) diff --git a/src/rendered_data_panel.ts b/src/rendered_data_panel.ts index 28ad4e6b9e..caf66e53f9 100644 --- a/src/rendered_data_panel.ts +++ b/src/rendered_data_panel.ts @@ -833,15 +833,23 @@ export abstract class RenderedDataPanel extends RenderedPanel { }); } - drawBrushCursor(x: number, y: number, radius: number) { + drawBrushCursor( + x: number, + y: number, + radiusX: number, + radiusY: number, + rotation: number, + ) { const ctx = this.overlay_context; const { logicalWidth, logicalHeight } = this.renderViewport; ctx.clearRect(0, 0, logicalWidth, logicalHeight); - if (radius > 0) { + if (radiusX > 0 && radiusY > 0) { + ctx.save(); ctx.beginPath(); - ctx.arc(x, y, radius, 0, 2 * Math.PI); + ctx.ellipse(x, y, radiusX, radiusY, rotation, 0, 2 * Math.PI); + ctx.restore(); ctx.fillStyle = "rgba(255, 255, 255, 0.2)"; ctx.fill(); ctx.strokeStyle = "rgba(255, 255, 255, 1)"; diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index e974f643f1..1d332e6f95 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -24,7 +24,7 @@ import { RenderedDataPanel } from "#src/rendered_data_panel.js"; import { SliceViewPanel } from "#src/sliceview/panel.js"; import { StatusMessage } from "#src/status.js"; import { LayerTool, registerTool, type ToolActivation } from "#src/ui/tool.js"; -import { vec3 } from "#src/util/geom.js"; +import { vec3, mat3 } from "#src/util/geom.js"; import { EventActionMap } from "#src/util/mouse_bindings.js"; import { startRelativeMouseDrag } from "#src/util/mouse_drag.js"; import { BrushShape } from "#src/voxel_annotation/base.js"; @@ -50,7 +50,6 @@ abstract class BaseVoxelTool extends LayerTool { } protected getPoint(mouseState: MouseSelectionState): Int32Array | undefined { - // TODO: maybe getVoxelPositionFromMouse() would best fit in the userLayer const editContext = this.getEditingContext(); if (editContext === undefined) return undefined; const vox = editContext.getVoxelPositionFromMouse(mouseState) as @@ -173,13 +172,99 @@ export class VoxelBrushTool extends BaseVoxelTool { private updateBrushOutline() { const panel = this.getActivePanel(); - if (!panel) return; + if (!panel || !(panel instanceof SliceViewPanel)) { + if (panel) panel.clearOverlay(); + return; + } + + const { projectionParameters } = panel.sliceView; + const { displayDimensionRenderInfo, viewMatrix } = + projectionParameters.value; + const { canonicalVoxelFactors, displayRank } = displayDimensionRenderInfo; + + if (displayRank < 2) { + panel.clearOverlay(); + return; + } - const zoom = panel.navigationState.zoomFactor.value; const radiusInVoxels = this.layer.voxBrushRadius.value; - const radiusInPixels = radiusInVoxels / zoom; - panel.drawBrushCursor(panel.mouseX, panel.mouseY, radiusInPixels); + const n_canonical = + projectionParameters.value.viewportNormalInCanonicalCoordinates; + + const canonicalVoxelFactorsVec3 = vec3.fromValues( + canonicalVoxelFactors[0], + canonicalVoxelFactors[1], + canonicalVoxelFactors[2], + ); + + // Convert to voxel coordinates by dividing by canonical voxel factors. + const n_vox = vec3.create(); + vec3.divide(n_vox, n_canonical as vec3, canonicalVoxelFactorsVec3); + vec3.normalize(n_vox, n_vox); + + // Create an orthonormal basis for the plane in voxel coordinates + const u_vox = vec3.create(); + const tempVec = vec3.fromValues(1, 0, 0); + if (Math.abs(vec3.dot(n_vox, tempVec)) > 0.999) { + vec3.set(tempVec, 0, 1, 0); + } + vec3.cross(u_vox, n_vox, tempVec); + vec3.normalize(u_vox, u_vox); + + const v_vox = vec3.cross(vec3.create(), n_vox, u_vox); + + // Scale basis vectors by radius to get two orthogonal radius vectors of the brush circle + // in voxel coordinates. + vec3.scale(u_vox, u_vox, radiusInVoxels); + vec3.scale(v_vox, v_vox, radiusInVoxels); + + const u_cam = vec3.create(); + const v_cam = vec3.create(); + // The viewMatrix transforms from world/voxel space to camera space. + // We use a mat3 to only apply rotation and scaling, not translation. + const viewMatrix3 = mat3.fromMat4(mat3.create(), viewMatrix); + + // Transform voxel-space vectors directly to camera-space vectors. + // This avoids the double-scaling error. + vec3.transformMat3(u_cam, u_vox, viewMatrix3); + vec3.transformMat3(v_cam, v_vox, viewMatrix3); + + // The x, y components of these vectors are conjugate semi-diameters of the ellipse on screen. + const u_scr_x = u_cam[0]; + const u_scr_y = u_cam[1]; + const v_scr_x = v_cam[0]; + const v_scr_y = v_cam[1]; + + // From the conjugate semi-diameters, compute the ellipse parameters (radii and rotation). + // We analyze the quadratic form matrix Q = A * A^T where A = [[u_scr_x, v_scr_x], [u_scr_y, v_scr_y]]. + const Q11 = u_scr_x * u_scr_x + v_scr_x * v_scr_x; + const Q12 = u_scr_x * u_scr_y + v_scr_x * v_scr_y; + const Q22 = u_scr_y * u_scr_y + v_scr_y * v_scr_y; + + const trace = Q11 + Q22; + const det = Q11 * Q22 - Q12 * Q12; + + // Eigenvalues are roots of lambda^2 - trace*lambda + det = 0 + const D_sq = trace * trace - 4 * det; + const D = D_sq < 0 ? 0 : Math.sqrt(D_sq); + + const lambda1 = (trace + D) / 2; + const lambda2 = (trace - D) / 2; + + const radiusX = Math.sqrt(lambda1); + const radiusY = Math.sqrt(lambda2); + + // Eigenvector for lambda1 is proportional to [Q12, lambda1 - Q11] + const rotation = Math.atan2(lambda1 - Q11, Q12); + + panel.drawBrushCursor( + panel.mouseX, + panel.mouseY, + radiusX, + radiusY, + rotation, + ); } activationCallback(_activation: ToolActivation): void { diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 9caa8a2c7f..ea5cc3fbc6 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -4,7 +4,6 @@ - preview colors are wrong with signed dataset - writable float32 dataset is not working (expected), either block its usage or fix -- the brush circle is only correct in Euclidean space (expected since it cannot be an ellipse) - blosc encoding is wrong - Dataset creation: From c2f9016d10947552b48a6beea767626bda584447 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 17 Nov 2025 12:11:22 +0100 Subject: [PATCH 125/251] chore(voxel-annotation): fix diagnosis of the preview color issue and suggest fix --- src/voxel_annotation/TODOs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index ea5cc3fbc6..bfaf14ee9e 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -2,7 +2,7 @@ ### priority -- preview colors are wrong with signed dataset +- ~~preview colors are wrong with signed dataset~~ -> happens when the seg value is higher than the max value of the datatype (other than uint64), preview will render this value, but it will be truncated when writing the data... selecting a value higher than the max value should not be possible but can be achieved by first going to a uint64 dataset, for example, and then switching to a uint16; the seg value is not reset. -> fix by resetting the seg value when switching dataset - writable float32 dataset is not working (expected), either block its usage or fix - blosc encoding is wrong From 1a4d3fd2592fbdb4863f3d577a71c240650e4f50 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 17 Nov 2025 12:18:34 +0100 Subject: [PATCH 126/251] fix(voxel-annotation): ensure paint value is clamped in the datatype range when changing datasource --- src/layer/vox/index.ts | 2 ++ src/voxel_annotation/TODOs.md | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 026b2f372e..99be6a2e6d 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -383,6 +383,8 @@ export function UserLayerWithVoxelEditingMixin< ); this.editingContexts.set(loadedSubsource, context); this.isEditable.value = writable; + // This will trigger the datatype validation + this.setVoxelPaintValue(this.paintValue.value); } deinitializeVoxelEditingForSubsource(loadedSubsource: LoadedDataSubsource) { diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index bfaf14ee9e..1d817241d0 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -2,7 +2,6 @@ ### priority -- ~~preview colors are wrong with signed dataset~~ -> happens when the seg value is higher than the max value of the datatype (other than uint64), preview will render this value, but it will be truncated when writing the data... selecting a value higher than the max value should not be possible but can be achieved by first going to a uint64 dataset, for example, and then switching to a uint16; the seg value is not reset. -> fix by resetting the seg value when switching dataset - writable float32 dataset is not working (expected), either block its usage or fix - blosc encoding is wrong From a32360c3422a6a4555b2dcd6a8d21a153d763da6 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 17 Nov 2025 14:32:27 +0100 Subject: [PATCH 127/251] fix(voxel-annotation): Correct buffer type for new compressed segmentation chunks --- src/sliceview/volume/backend.ts | 7 ++++++- src/voxel_annotation/TODOs.md | 17 ----------------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/src/sliceview/volume/backend.ts b/src/sliceview/volume/backend.ts index c17748244d..09c7782dd3 100644 --- a/src/sliceview/volume/backend.ts +++ b/src/sliceview/volume/backend.ts @@ -207,7 +207,12 @@ export class VolumeChunkSource if (!chunk.data) { const numElements = chunk.chunkDataSize.reduce((a, b) => a * b, 1); - const Ctor = DATA_TYPE_ARRAY_CONSTRUCTOR[this.spec.dataType]; + let Ctor; + if (this.spec.compressedSegmentationBlockSize !== undefined) { + Ctor = Uint32Array; + } else { + Ctor = DATA_TYPE_ARRAY_CONSTRUCTOR[this.spec.dataType]; + } chunk.data = new (Ctor as any)(numElements) as TypedArray; } diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 1d817241d0..db45c23dcc 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -7,23 +7,6 @@ - Dataset creation: - the copy from existing seems to not be right on all settings - - upon creation of a uint64 dataset, when trying to paint, the preview is getting updated correctly, but the writing pipeline seems to fail, it causes the following error: - -``` -decode_common.ts:56 Uncaught TypeError: Cannot mix BigInt and other types, use explicit conversions - at decodeValueOffset (decode_common.ts:56:32) - at readSingleChannelValueUint64 (decode_common.ts:120:5) - at CompressedSegmentationVolumeChunk.getValueAt (chunk_format.ts:322:42) - at ZarrVolumeChunkSource.getValueAt (frontend.ts:286:20) - at SegmentationRenderLayer.getValueAt (renderlayer.ts:491:29) - at SegmentationUserLayer.getValueAt (index.ts:602:22) - at SegmentationUserLayer.captureSelectionState (index.ts:332:24) - at SegmentationUserLayer.captureSelectionState (annotations.ts:1943:13) - at LayerSelectedValues.update (index.ts:1313:21) - at LayerSelectedValues.get (index.ts:1320:10) -``` - -after a page reload, the painting works again with no issues; the previously painted voxels are not present. ### later From 1ac34bd6b2ca71d7f66d0aa36e53fd682854733a Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 17 Nov 2025 15:03:04 +0100 Subject: [PATCH 128/251] fix(datasource): resolve incorrect blosc encoding The issue was comming from the decoder not resolving the config and using an empty one... so the config is now discard for the encoder --- src/datasource/zarr/codec/blosc/encode.ts | 3 ++- src/voxel_annotation/TODOs.md | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/datasource/zarr/codec/blosc/encode.ts b/src/datasource/zarr/codec/blosc/encode.ts index 97ed9f2791..2da2d1f5de 100644 --- a/src/datasource/zarr/codec/blosc/encode.ts +++ b/src/datasource/zarr/codec/blosc/encode.ts @@ -12,12 +12,13 @@ registerCodec({ decoded: Uint8Array, signal: AbortSignal, ): Promise { + configuration; return requestAsyncComputation( encodeBlosc, signal, [decoded.buffer], decoded, - configuration, + {}, ); }, }); diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index db45c23dcc..5f040f27fa 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -3,7 +3,6 @@ ### priority - writable float32 dataset is not working (expected), either block its usage or fix -- blosc encoding is wrong - Dataset creation: - the copy from existing seems to not be right on all settings From d84f0b05e03bde6dd2254618d8b57c6028fbdbda Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 17 Nov 2025 16:59:58 +0100 Subject: [PATCH 129/251] feat(dataset-creation): generalize to ranks and fix copying of units and voxel size --- src/datasource/index.ts | 2 +- src/datasource/zarr/metadata/creation.ts | 5 +- src/layer/index.ts | 7 +- src/ui/dataset_creation.ts | 140 +++++++++++++++++------ src/voxel_annotation/TODOs.md | 2 +- 5 files changed, 115 insertions(+), 41 deletions(-) diff --git a/src/datasource/index.ts b/src/datasource/index.ts index fb43634634..60227fbd08 100644 --- a/src/datasource/index.ts +++ b/src/datasource/index.ts @@ -246,7 +246,7 @@ export interface CommonCreationMetadata { shape: number[]; dataType: DataType; voxelSize: number[]; - voxelUnit: string; + voxelUnit: string[]; numScales: number; downsamplingFactor: number[]; name: string; diff --git a/src/datasource/zarr/metadata/creation.ts b/src/datasource/zarr/metadata/creation.ts index 5c7fe041de..f43c3f053b 100644 --- a/src/datasource/zarr/metadata/creation.ts +++ b/src/datasource/zarr/metadata/creation.ts @@ -134,13 +134,12 @@ class ZarrV2Creator implements ZarrCreator { common: CommonCreationMetadata, scales: any[], ): string { - const fullVoxelUnit = zarrUnitMapping[common.voxelUnit] ?? common.voxelUnit; const rank = common.shape.length; const defaultAxes = ["x", "y", "z", "c", "t"]; const axes = Array.from({ length: rank }, (_, i) => ({ name: defaultAxes[i] || `dim_${i}`, type: "space", - unit: fullVoxelUnit, + unit: zarrUnitMapping[common.voxelUnit[i]], })); const datasets = scales.map((scale, i) => ({ @@ -268,7 +267,7 @@ class ZarrV3Creator implements ZarrCreator { const axes = Array.from({ length: rank }, (_, i) => ({ name: defaultAxes[i] || `dim_${i}`, type: "space", - unit: zarrUnitMapping[common.voxelUnit], + unit: zarrUnitMapping[common.voxelUnit[i]], })); const datasets = Array.from({ length: common.numScales }, (_, i) => ({ diff --git a/src/layer/index.ts b/src/layer/index.ts index 3a1c6b61c4..d7ec9588df 100644 --- a/src/layer/index.ts +++ b/src/layer/index.ts @@ -874,6 +874,7 @@ export class ManagedUserLayer extends RefCounted { for (let i = 0; i < rank; ++i) identityOptions.multiscaleToViewTransform[i * rank + i] = 1; + const inputSpace = modelTransform.inputSpace; const scales = volume.getSources(identityOptions)[0]; if (!scales || scales.length === 0) continue; @@ -889,7 +890,7 @@ export class ManagedUserLayer extends RefCounted { const numScales = scales.length; - const downsamplingFactor = vec3.fromValues(1, 1, 1); + const downsamplingFactor = new Array(rank).fill(1); if (scales.length > 1) { const lowResSource = scales[1]; const lowResTransform = lowResSource.chunkToMultiscaleTransform; @@ -905,8 +906,8 @@ export class ManagedUserLayer extends RefCounted { return { shape, dataType: volume.dataType, - voxelSize, - voxelUnit: modelSpace.units[0] || "", + voxelSize: Array.from(inputSpace.scales), + voxelUnit: Array.from(inputSpace.units), numScales, downsamplingFactor: Array.from(downsamplingFactor), name: `${this.name}_copy`, diff --git a/src/ui/dataset_creation.ts b/src/ui/dataset_creation.ts index 8b4bd6df9f..3b82346af4 100644 --- a/src/ui/dataset_creation.ts +++ b/src/ui/dataset_creation.ts @@ -3,7 +3,7 @@ * Copyright 2025 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 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 * @@ -25,9 +25,16 @@ import { StatusMessage } from "#src/status.js"; import { TrackableValue } from "#src/trackable_value.js"; import { TrackableVec3 } from "#src/trackable_vec3.js"; import { DataType } from "#src/util/data_type.js"; +import { RefCounted } from "#src/util/disposable.js"; import { removeChildren } from "#src/util/dom.js"; -import { vec3 } from "#src/util/geom.js"; -import { verifyInt, verifyString } from "#src/util/json.js"; +import { + parseArray, + verifyFiniteFloat, + verifyFinitePositiveFloat, + verifyInt, + verifyString, + verifyStringArray, +} from "#src/util/json.js"; import { CompoundTrackable, type Trackable } from "#src/util/trackable.js"; import { TrackableEnum } from "#src/util/trackable_enum.js"; import { DependentViewWidget } from "#src/widget/dependent_view_widget.js"; @@ -36,6 +43,62 @@ import { NumberInputWidget } from "#src/widget/number_input_widget.js"; import { TextInputWidget } from "#src/widget/text_input.js"; import { Vec3Widget } from "#src/widget/vec3_entry_widget.js"; +const verifyNumberArray = (value: unknown) => parseArray(value, verifyFiniteFloat); +const verifyPositiveNumberArray = (value: unknown) => parseArray(value, verifyFinitePositiveFloat); + +class DynamicVectorWidget extends RefCounted { + element = document.createElement("div"); + private inputs: HTMLInputElement[] = []; + + constructor( + public trackable: TrackableValue, + private isStringArray: boolean = false, + ) { + super(); + this.element.style.display = "flex"; + this.element.style.gap = "4px"; + this.registerDisposer(trackable.changed.add(() => this.updateView())); + this.updateView(); + } + + private updateView() { + const value = this.trackable.value; + const rank = value.length; + + if (this.inputs.length !== rank) { + removeChildren(this.element); + this.inputs = []; + for (let i = 0; i < rank; i++) { + const input = document.createElement("input"); + input.type = this.isStringArray ? "text" : "number"; + if (!this.isStringArray) input.step = "any"; + input.addEventListener("change", () => { + if (this.isStringArray) { + const newValues = [...this.trackable.value] as string[]; + newValues[i] = input.value; + (this.trackable as TrackableValue).value = newValues; + } else { + const newValues = [...this.trackable.value] as number[]; + const parsedValue = parseFloat(input.value); + if (!isNaN(parsedValue)) { + newValues[i] = parsedValue; + (this.trackable as TrackableValue).value = newValues; + } else { + input.value = (this.trackable.value[i] ?? "").toString(); + } + } + }); + this.inputs.push(input); + this.element.appendChild(input); + } + } + + for (let i = 0; i < rank; i++) { + this.inputs[i].value = value[i].toString(); + } + } +} + function createControlForTrackable(trackable: Trackable): HTMLElement { if (trackable instanceof TrackableVec3) { return new Vec3Widget(trackable).element; @@ -51,6 +114,10 @@ function createControlForTrackable(trackable: Trackable): HTMLElement { if (typeof value === "string") { return new TextInputWidget(trackable as TrackableValue).element; } + if (Array.isArray(value)) { + const isString = value.every((v) => typeof v === "string"); + return new DynamicVectorWidget(trackable as TrackableValue, isString).element; + } } const unsupportedElement = document.createElement("div"); unsupportedElement.textContent = `Unsupported control type`; @@ -58,22 +125,14 @@ function createControlForTrackable(trackable: Trackable): HTMLElement { } class CommonMetadataState extends CompoundTrackable { - shape = new TrackableVec3( - vec3.fromValues(42000, 42000, 42000), - vec3.fromValues(42000, 42000, 42000), - ); - dataType = new TrackableEnum(DataType, DataType.UINT32); - voxelSize = new TrackableVec3( - vec3.fromValues(8, 8, 8), - vec3.fromValues(8, 8, 8), - ); - voxelUnit = new TrackableValue("nm", verifyString); + shape = new TrackableValue([42000, 42000, 42000], verifyNumberArray); + dataType = new TrackableEnum(DataType, DataType.UINT32); + voxelSize = new TrackableValue([8, 8, 8], verifyPositiveNumberArray); + voxelUnit = new TrackableValue(["nm", "nm", "nm"], verifyStringArray); numScales = new TrackableValue(6, verifyInt); - downsamplingFactor = new TrackableVec3( - vec3.fromValues(2, 2, 2), - vec3.fromValues(2, 2, 2), - ); + downsamplingFactor = new TrackableValue([2, 2, 2], verifyPositiveNumberArray); name = new TrackableValue("new-dataset", verifyString); + rank = new TrackableValue(3, verifyInt); constructor() { super(); @@ -84,16 +143,37 @@ class CommonMetadataState extends CompoundTrackable { this.add("numScales", this.numScales); this.add("downsamplingFactor", this.downsamplingFactor); this.add("name", this.name); + this.add("rank", this.rank); + + this.rank.changed.add(() => { + const newRank = this.rank.value; + const resize = ( + trackable: TrackableValue, + defaultValue: number | string, + ) => { + const arr = trackable.value; + if (arr.length === newRank) return; + const newArr = new Array(newRank); + for (let i = 0; i < newRank; ++i) { + newArr[i] = i < arr.length ? arr[i] : defaultValue; + } + trackable.value = newArr; + }; + resize(this.shape, 42000); + resize(this.voxelSize, 8); + resize(this.voxelUnit, "nm"); + resize(this.downsamplingFactor, 2); + }); } toJSON(): CommonCreationMetadata { return { - shape: Array.from(this.shape.value), + shape: this.shape.value, dataType: this.dataType.value, - voxelSize: Array.from(this.voxelSize.value), + voxelSize: this.voxelSize.value, voxelUnit: this.voxelUnit.value, numScales: this.numScales.value, - downsamplingFactor: Array.from(this.downsamplingFactor.value), + downsamplingFactor: this.downsamplingFactor.value, name: this.name.value, }; } @@ -188,7 +268,7 @@ export class DatasetCreationDialog extends Overlay { if (compatibleLayers.length === 0) return; const label = document.createElement("label"); - label.textContent = "Copy settings from layer: "; + label.textContent = "Copy metadata from data source: "; parentElement.appendChild(label); const select = document.createElement("select"); @@ -212,20 +292,13 @@ export class DatasetCreationDialog extends Overlay { if (layer) { const metadata = layer.getCreationMetadata(); if (metadata) { - this.state.shape.value = vec3.fromValues( - metadata.shape[0], - metadata.shape[1], - metadata.shape[2], - ); - (this.state.dataType as TrackableEnum).value = - metadata.dataType; - this.state.voxelSize.value = vec3.fromValues( - metadata.voxelSize[0], - metadata.voxelSize[1], - metadata.voxelSize[2], - ); + this.state.rank.value = metadata.shape.length; + this.state.shape.value = metadata.shape; + this.state.dataType.value = metadata.dataType; + this.state.voxelSize.value = metadata.voxelSize; this.state.voxelUnit.value = metadata.voxelUnit; this.state.name.value = metadata.name; + this.state.downsamplingFactor.value = metadata.downsamplingFactor; } } }); @@ -242,6 +315,7 @@ export class DatasetCreationDialog extends Overlay { content.appendChild(commonFields); this.addControl(this.state.name, "Name", commonFields); + this.addControl(this.state.rank, "Rank", commonFields); this.addControl(this.state.shape, "Shape", commonFields); this.addControl(this.state.dataType, "Data Type", commonFields); this.addControl(this.state.voxelSize, "Voxel Size", commonFields); diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 5f040f27fa..3b5e282603 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -5,7 +5,7 @@ - writable float32 dataset is not working (expected), either block its usage or fix - Dataset creation: - - the copy from existing seems to not be right on all settings + - the chunk size is currently hardcoded to 64x64x64, preventing use of rank different than 3 ### later From 0e0a105cc5b703472e37f0849cfe1fb8ddcab2a0 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 17 Nov 2025 17:01:23 +0100 Subject: [PATCH 130/251] chore: format + update TODOs --- src/layer/index.ts | 2 +- src/ui/dataset_creation.ts | 34 ++++++++++++++++++++++++++-------- src/voxel_annotation/TODOs.md | 3 ++- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/layer/index.ts b/src/layer/index.ts index d7ec9588df..c8b3d91ebd 100644 --- a/src/layer/index.ts +++ b/src/layer/index.ts @@ -87,7 +87,7 @@ import { LayerToolBinder, SelectedLegacyTool } from "#src/ui/tool.js"; import { gatherUpdate } from "#src/util/array.js"; import type { Borrowed, Owned } from "#src/util/disposable.js"; import { invokeDisposers, RefCounted } from "#src/util/disposable.js"; -import { vec3 } from "#src/util/geom.js"; +import type { vec3 } from "#src/util/geom.js"; import { emptyToUndefined, parseArray, diff --git a/src/ui/dataset_creation.ts b/src/ui/dataset_creation.ts index 3b82346af4..d5e4ec0a31 100644 --- a/src/ui/dataset_creation.ts +++ b/src/ui/dataset_creation.ts @@ -43,8 +43,10 @@ import { NumberInputWidget } from "#src/widget/number_input_widget.js"; import { TextInputWidget } from "#src/widget/text_input.js"; import { Vec3Widget } from "#src/widget/vec3_entry_widget.js"; -const verifyNumberArray = (value: unknown) => parseArray(value, verifyFiniteFloat); -const verifyPositiveNumberArray = (value: unknown) => parseArray(value, verifyFinitePositiveFloat); +const verifyNumberArray = (value: unknown) => + parseArray(value, verifyFiniteFloat); +const verifyPositiveNumberArray = (value: unknown) => + parseArray(value, verifyFinitePositiveFloat); class DynamicVectorWidget extends RefCounted { element = document.createElement("div"); @@ -116,7 +118,10 @@ function createControlForTrackable(trackable: Trackable): HTMLElement { } if (Array.isArray(value)) { const isString = value.every((v) => typeof v === "string"); - return new DynamicVectorWidget(trackable as TrackableValue, isString).element; + return new DynamicVectorWidget( + trackable as TrackableValue, + isString, + ).element; } } const unsupportedElement = document.createElement("div"); @@ -125,12 +130,24 @@ function createControlForTrackable(trackable: Trackable): HTMLElement { } class CommonMetadataState extends CompoundTrackable { - shape = new TrackableValue([42000, 42000, 42000], verifyNumberArray); + shape = new TrackableValue( + [42000, 42000, 42000], + verifyNumberArray, + ); dataType = new TrackableEnum(DataType, DataType.UINT32); - voxelSize = new TrackableValue([8, 8, 8], verifyPositiveNumberArray); - voxelUnit = new TrackableValue(["nm", "nm", "nm"], verifyStringArray); + voxelSize = new TrackableValue( + [8, 8, 8], + verifyPositiveNumberArray, + ); + voxelUnit = new TrackableValue( + ["nm", "nm", "nm"], + verifyStringArray, + ); numScales = new TrackableValue(6, verifyInt); - downsamplingFactor = new TrackableValue([2, 2, 2], verifyPositiveNumberArray); + downsamplingFactor = new TrackableValue( + [2, 2, 2], + verifyPositiveNumberArray, + ); name = new TrackableValue("new-dataset", verifyString); rank = new TrackableValue(3, verifyInt); @@ -298,7 +315,8 @@ export class DatasetCreationDialog extends Overlay { this.state.voxelSize.value = metadata.voxelSize; this.state.voxelUnit.value = metadata.voxelUnit; this.state.name.value = metadata.name; - this.state.downsamplingFactor.value = metadata.downsamplingFactor; + this.state.downsamplingFactor.value = + metadata.downsamplingFactor; } } }); diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 3b5e282603..549a1743b1 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -5,7 +5,8 @@ - writable float32 dataset is not working (expected), either block its usage or fix - Dataset creation: - - the chunk size is currently hardcoded to 64x64x64, preventing use of rank different than 3 + - the chunk size is currently hardcoded to 64x64x64, preventing use of rank different from 3 + - review the copy from data sources implementation as it currently is partly a copy from layer ### later From 22f083e736fa0b7b84075916e489a67b092a1ed1 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Tue, 18 Nov 2025 11:54:46 +0100 Subject: [PATCH 131/251] refactor(dataset-creation): enhance metadata copying --- src/datasource/zarr/metadata/creation.ts | 4 +- src/layer/index.ts | 71 ------------------------ src/sliceview/volume/frontend.ts | 53 ++++++++++++++++++ src/ui/dataset_creation.ts | 64 ++++++++++++++++----- src/voxel_annotation/TODOs.md | 7 +-- 5 files changed, 106 insertions(+), 93 deletions(-) diff --git a/src/datasource/zarr/metadata/creation.ts b/src/datasource/zarr/metadata/creation.ts index f43c3f053b..7624f41cd9 100644 --- a/src/datasource/zarr/metadata/creation.ts +++ b/src/datasource/zarr/metadata/creation.ts @@ -95,7 +95,7 @@ class ZarrV2Creator implements ZarrCreator { shape: commonMetadata.shape.map((dim: number, j: number) => Math.ceil(dim / downsampleCoeffs[j]), ), - chunks: [64, 64, 64], + chunks: new Array(commonMetadata.shape.length).fill(64), dtype: dataTypeToZarrV2Dtype[commonMetadata.dataType], compressor: this._buildV2ZarrayCompressorMetadata(zarrMetadata), transform: commonMetadata.voxelSize.map( @@ -238,7 +238,7 @@ class ZarrV3Creator implements ZarrCreator { shape: commonMetadata.shape.map((dim: number, j: number) => Math.ceil(dim / downsampleCoeffs[j]), ), - chunks: [64, 64, 64], + chunks: new Array(commonMetadata.shape.length).fill(64), dataType: dataTypeToZarrV3Dtype[commonMetadata.dataType], transform: commonMetadata.voxelSize.map( (v: number, j: number) => v * downsampleCoeffs[j], diff --git a/src/layer/index.ts b/src/layer/index.ts index c8b3d91ebd..5d51f23296 100644 --- a/src/layer/index.ts +++ b/src/layer/index.ts @@ -32,7 +32,6 @@ import { TrackableCoordinateSpace, } from "#src/coordinate_transform.js"; import type { - CommonCreationMetadata, DataSourceRegistry, DataSourceSpecification, DataSubsource, @@ -65,7 +64,6 @@ import type { VisibilityTrackedRenderLayer, } from "#src/renderlayer.js"; import type { VolumeType } from "#src/sliceview/volume/base.js"; -import { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; import { StatusMessage } from "#src/status.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; import type { @@ -849,75 +847,6 @@ export class ManagedUserLayer extends RefCounted { this.layerChanged.dispatch(); } - getCreationMetadata(): CommonCreationMetadata | undefined { - const userLayer = this.layer; - if (userLayer === null) return undefined; - - for (const dataSource of userLayer.dataSources) { - const loadState = dataSource.loadState; - if (loadState === undefined || loadState.error !== undefined) continue; - - for (const subsource of loadState.subsources) { - if (!subsource.enabled) continue; - const { volume } = subsource.subsourceEntry.subsource; - - if (volume instanceof MultiscaleVolumeChunkSource) { - const { modelTransform } = loadState.dataSource; - const modelSpace = modelTransform.outputSpace; - const { rank } = modelSpace; - - const identityOptions = { - displayRank: rank, - multiscaleToViewTransform: new Float32Array(rank * rank).fill(0), - modelChannelDimensionIndices: [], - }; - for (let i = 0; i < rank; ++i) - identityOptions.multiscaleToViewTransform[i * rank + i] = 1; - - const inputSpace = modelTransform.inputSpace; - const scales = volume.getSources(identityOptions)[0]; - if (!scales || scales.length === 0) continue; - - const highResSource = scales[0]; - const shape = Array.from( - highResSource.chunkSource.spec.upperVoxelBound, - ); - const highResTransform = highResSource.chunkToMultiscaleTransform; - const voxelSize = new Array(rank); - for (let i = 0; i < rank; ++i) { - voxelSize[i] = highResTransform[i * (rank + 1) + i]; - } - - const numScales = scales.length; - - const downsamplingFactor = new Array(rank).fill(1); - if (scales.length > 1) { - const lowResSource = scales[1]; - const lowResTransform = lowResSource.chunkToMultiscaleTransform; - for (let i = 0; i < rank; ++i) { - const highResScale = highResTransform[i * (rank + 1) + i]; - const lowResScale = lowResTransform[i * (rank + 1) + i]; - if (highResScale !== 0) { - downsamplingFactor[i] = Math.round(lowResScale / highResScale); - } - } - } - - return { - shape, - dataType: volume.dataType, - voxelSize: Array.from(inputSpace.scales), - voxelUnit: Array.from(inputSpace.units), - numScales, - downsamplingFactor: Array.from(downsamplingFactor), - name: `${this.name}_copy`, - }; - } - } - } - return undefined; - } - disposed() { this.layer = null; super.disposed(); diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index c1d531fd29..576d873661 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -15,6 +15,8 @@ */ import type { ChunkManager } from "#src/chunk_manager/frontend.js"; +import type { CoordinateSpace } from "#src/coordinate_transform.js"; +import type { CommonCreationMetadata } from "#src/datasource/index.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; import type { SliceViewChunkSpecification } from "#src/sliceview/base.js"; import { @@ -405,6 +407,57 @@ export abstract class MultiscaleVolumeChunkSource extends MultiscaleSliceViewChu > { abstract dataType: DataType; abstract volumeType: VolumeType; + + getCreationMetadata( + layerName: string, + inputSpace: CoordinateSpace, + ): CommonCreationMetadata { + const rank = this.rank; + const identityOptions = { + displayRank: rank, + multiscaleToViewTransform: new Float32Array(rank * rank).fill(0), + modelChannelDimensionIndices: [], + }; + for (let i = 0; i < rank; ++i) + identityOptions.multiscaleToViewTransform[i * rank + i] = 1; + + const scales = this.getSources(identityOptions)[0]; + if (!scales || scales.length === 0) { + throw new Error("Data source has no resolution scales."); + } + + const highResSource = scales[0]; + const shape = Array.from(highResSource.chunkSource.spec.upperVoxelBound); + const highResTransform = highResSource.chunkToMultiscaleTransform; + const voxelSize = new Array(rank); + for (let i = 0; i < rank; ++i) { + voxelSize[i] = highResTransform[i * (rank + 1) + i]; + } + + const numScales = scales.length; + const downsamplingFactor = new Array(rank).fill(1); + if (scales.length > 1) { + const lowResSource = scales[1]; + const lowResTransform = lowResSource.chunkToMultiscaleTransform; + for (let i = 0; i < rank; ++i) { + const highResScale = highResTransform[i * (rank + 1) + i]; + const lowResScale = lowResTransform[i * (rank + 1) + i]; + if (highResScale !== 0) { + downsamplingFactor[i] = Math.round(lowResScale / highResScale); + } + } + } + + return { + shape, + dataType: this.dataType, + voxelSize: Array.from(inputSpace.scales), + voxelUnit: Array.from(inputSpace.units), + numScales, + downsamplingFactor, + name: `${layerName}_copy`, + }; + } } export { VolumeChunk }; diff --git a/src/ui/dataset_creation.ts b/src/ui/dataset_creation.ts index d5e4ec0a31..fe8accb634 100644 --- a/src/ui/dataset_creation.ts +++ b/src/ui/dataset_creation.ts @@ -14,13 +14,19 @@ * limitations under the License. */ +import type { CoordinateSpace } from "#src/coordinate_transform.js"; import type { CreateDataSourceOptions, CommonCreationMetadata, DataSourceCreationState, } from "#src/datasource/index.js"; -import type { LayerListSpecification } from "#src/layer/index.js"; +import type { + LayerListSpecification, + ManagedUserLayer, +} from "#src/layer/index.js"; +import type { LayerDataSource } from "#src/layer/layer_data_source.js"; import { Overlay } from "#src/overlay.js"; +import type { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; import { StatusMessage } from "#src/status.js"; import { TrackableValue } from "#src/trackable_value.js"; import { TrackableVec3 } from "#src/trackable_vec3.js"; @@ -278,11 +284,34 @@ export class DatasetCreationDialog extends Overlay { }, }, (_value, parentElement) => { - const compatibleLayers = - this.manager.rootLayers.managedLayers.filter( - (layer) => layer.getCreationMetadata() !== undefined, - ); - if (compatibleLayers.length === 0) return; + const compatibleDataSources: { + layer: ManagedUserLayer; + dataSource: LayerDataSource; + inputSpace: CoordinateSpace; + volume: MultiscaleVolumeChunkSource; + }[] = []; + + for (const layer of this.manager.rootLayers.managedLayers) { + if (!layer.layer) continue; + for (const dataSource of layer.layer.dataSources) { + const loadState = dataSource.loadState; + if (loadState === undefined || loadState.error !== undefined) + continue; + + for (const subsource of loadState.dataSource.subsources) { + const volume = subsource.subsource.volume; + if (volume) { + compatibleDataSources.push({ + layer, + dataSource, + inputSpace: + loadState.dataSource.modelTransform.inputSpace, + volume, + }); + } + } + } + } const label = document.createElement("label"); label.textContent = "Copy metadata from data source: "; @@ -294,20 +323,25 @@ export class DatasetCreationDialog extends Overlay { defaultOption.value = ""; select.appendChild(defaultOption); - compatibleLayers.forEach((layer) => { + compatibleDataSources.forEach(({ layer, dataSource }, index) => { const option = document.createElement("option"); - option.textContent = layer.name; - option.value = layer.name; + option.textContent = `${layer.name} - ${dataSource.spec.url}`; + option.value = index.toString(); select.appendChild(option); }); this.registerEventListener(select, "change", () => { - if (!select.value) return; - const layer = this.manager.rootLayers.getLayerByName( - select.value, - ); - if (layer) { - const metadata = layer.getCreationMetadata(); + const selectedIndex = parseInt(select.value, 10); + if (selectedIndex === -1) return; + + const selection = compatibleDataSources[selectedIndex]; + if (selection) { + const { layer, inputSpace, volume } = selection; + + const metadata = volume.getCreationMetadata( + layer.name, + inputSpace, + ); if (metadata) { this.state.rank.value = metadata.shape.length; this.state.shape.value = metadata.shape; diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 549a1743b1..e39562318b 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -2,18 +2,15 @@ ### priority -- writable float32 dataset is not working (expected), either block its usage or fix - -- Dataset creation: - - the chunk size is currently hardcoded to 64x64x64, preventing use of rank different from 3 - - review the copy from data sources implementation as it currently is partly a copy from layer ### later - add preview for the undo/redo - url completion for the ssa+https source +- writable float32 dataset is not working (expected), either block its usage or fix ### questionable +- add support for volumes with rank different from 3 - write a testsuite for the downsampler and ensure its proper working on exotic lod levels - adapt the brush size to the zoom level linearly From d484dd299e36d558f4657055235ee7fbfb169ef6 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 19 Nov 2025 15:32:30 +0100 Subject: [PATCH 132/251] test(sliceview/volume/backend.ts): add tests for applyEdits ; added dependency for coverage ; fix out-of-bounds check in applyEdits --- package-lock.json | 281 ++++++++++++++++++++- package.json | 1 + src/sliceview/volume/backend.spec.ts | 318 ++++++++++++++++++++++++ src/sliceview/volume/backend.ts | 7 +- src/voxel_annotation/TODOs.md | 1 - src/voxel_annotation/edit_controller.ts | 2 +- 6 files changed, 596 insertions(+), 14 deletions(-) create mode 100644 src/sliceview/volume/backend.spec.ts diff --git a/package-lock.json b/package-lock.json index 742813a51b..c299a0e8ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "@types/node": "^22.13.1", "@types/yargs": "^17.0.33", "@vitest/browser": "^3.0.5", + "@vitest/coverage-v8": "^3.0.5", "@vitest/ui": "^3.0.5", "@vitest/web-worker": "^3.0.5", "cookie": "^1.0.2", @@ -78,6 +79,20 @@ "node": ">=0.10.0" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-2.8.3.tgz", @@ -113,11 +128,22 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -216,6 +242,22 @@ "node": ">=4" } }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.26.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz", @@ -229,6 +271,30 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bundled-es-modules/cookie": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", @@ -1228,6 +1294,16 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -1262,8 +1338,6 @@ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", "dev": true, - "optional": true, - "peer": true, "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", @@ -1278,8 +1352,6 @@ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, - "optional": true, - "peer": true, "engines": { "node": ">=6.0.0" } @@ -1289,8 +1361,6 @@ "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true, - "optional": true, - "peer": true, "engines": { "node": ">=6.0.0" } @@ -1318,8 +1388,6 @@ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, - "optional": true, - "peer": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -3130,6 +3198,39 @@ "node": ">=18" } }, + "node_modules/@vitest/coverage-v8": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.5.tgz", + "integrity": "sha512-zOOWIsj5fHh3jjGwQg+P+J1FW3s4jBu1Zqga0qW60yutsBtqEqNEJKWYh7cYn1yGD+1bdPsPdC/eL4eVK56xMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "debug": "^4.4.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.0.5", + "vitest": "3.0.5" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.5.tgz", @@ -9389,6 +9490,60 @@ "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.0.1.tgz", @@ -9917,6 +10072,34 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -12966,6 +13149,82 @@ "optional": true, "peer": true }, + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/test-exclude/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/test-exclude/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/text-decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.1.1.tgz", diff --git a/package.json b/package.json index 2e0e26be03..e761a78439 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@types/node": "^22.13.1", "@types/yargs": "^17.0.33", "@vitest/browser": "^3.0.5", + "@vitest/coverage-v8": "^3.0.5", "@vitest/ui": "^3.0.5", "@vitest/web-worker": "^3.0.5", "cookie": "^1.0.2", diff --git a/src/sliceview/volume/backend.spec.ts b/src/sliceview/volume/backend.spec.ts new file mode 100644 index 0000000000..2f7e52cd8c --- /dev/null +++ b/src/sliceview/volume/backend.spec.ts @@ -0,0 +1,318 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { ChunkState } from "#src/chunk_manager/base.js"; +import type { VolumeChunk } from "#src/sliceview/volume/backend.js"; +import { VolumeChunkSource } from "#src/sliceview/volume/backend.js"; +import { DataType } from "#src/util/data_type.js"; +import { HttpError } from "#src/util/http_request.js"; +import type { RPC } from "#src/worker_rpc.js"; + +vi.mock("#src/sliceview/compressed_segmentation/decode_uint64.js", () => ({ + decodeChannel: vi.fn((out) => out.fill(5n)), +})); +vi.mock("#src/sliceview/compressed_segmentation/encode_uint64.js", () => ({ + encodeChannel: vi.fn((builder) => { + builder.data = new Uint32Array([888]); + }), +})); +vi.mock("#src/sliceview/compressed_segmentation/decode_uint32.js", () => ({ + decodeChannel: vi.fn((out) => out.fill(5)), +})); +vi.mock("#src/sliceview/compressed_segmentation/encode_uint32.js", () => ({ + encodeChannel: vi.fn((builder) => { + builder.data = new Uint32Array([444]); + }), +})); + +vi.mock("#src/sliceview/volume/registry.js", () => ({ + getChunkFormatHandler: vi.fn().mockReturnValue({ + chunkFormat: { dataType: 0 }, + dispose: vi.fn(), + getChunk: (source: any, x: any) => new source.chunkConstructor(source, x), + }), +})); + +class MockBackendSource extends VolumeChunkSource { + public serverStorage = new Map(); + + async download(chunk: VolumeChunk) { + const key = chunk.chunkGridPosition.join(","); + if (this.serverStorage.has(key)) { + chunk.data = new Uint8Array(this.serverStorage.get(key)!.slice(0)); + } + } + + async writeChunk(chunk: VolumeChunk) { + const key = chunk.chunkGridPosition.join(","); + this.serverStorage.set(key, chunk.data!.buffer.slice(0) as ArrayBuffer); + } +} + +describe("VolumeChunkSource: applyEdits", () => { + let mockRpc: RPC; + let source: MockBackendSource; + + const BASE_SPEC = { + rank: 3, + dataType: DataType.UINT64, + chunkDataSize: Uint32Array.from([2, 2, 2]), + upperVoxelBound: Float32Array.from([10, 10, 10]), + baseVoxelOffset: Float32Array.from([0, 0, 0]), + compressedSegmentationBlockSize: undefined, + }; + + beforeEach(() => { + const mockQueueManager = { + sources: new Set(), + adjustCapacitiesForChunk: vi.fn(), + updateChunkState: vi.fn(), + scheduleUpdate: vi.fn(), + moveChunkToFrontend: vi.fn(), + markRecentlyUsed: vi.fn(), + gl: {}, + }; + + const mockChunkManager = { + queueManager: mockQueueManager, + chunkQueueManager: mockQueueManager, + rpc: null, + memoize: { get: (_k: string, fn: Function) => fn() }, + }; + + mockRpc = { + newId: () => 0, + set: vi.fn(), + get: vi.fn().mockReturnValue(mockChunkManager), + invoke: vi.fn(), + promiseInvoke: vi.fn(), + } as unknown as RPC; + + source = new MockBackendSource(mockRpc, { + spec: { ...BASE_SPEC }, + chunkManager: 0, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("Input Validation", () => { + it("should throw if indices and values lengths mismatch", async () => { + await expect(source.applyEdits("0,0,0", [1], [1n, 2n])).rejects.toThrow( + /length mismatch/, + ); + }); + + it("should throw for invalid chunk keys (wrong rank)", async () => { + await expect(source.applyEdits("0,0", [0], [1n])).rejects.toThrow( + /invalid chunk key/, + ); + }); + + it("should throw for invalid chunk keys (NaN)", async () => { + await expect(source.applyEdits("0,NaN,0", [0], [1n])).rejects.toThrow( + /invalid chunk key/, + ); + }); + }); + + describe("Uncompressed Data (UINT64)", () => { + it("should initialize data if missing", async () => { + const writeSpy = vi.spyOn(source, "writeChunk"); + const result = await source.applyEdits("0,0,0", [0], [100n]); + + const chunk = source.chunks.get("0,0,0")! as VolumeChunk; + + expect(chunk.data).toBeInstanceOf(BigUint64Array); + expect((chunk.data as BigUint64Array)[0]).toBe(100n); + expect(writeSpy).toHaveBeenCalled(); + expect(result.newValues[0]).toBe(100n); + }); + + it("should update existing data", async () => { + const chunk = source.getChunk(new Float32Array([0, 0, 0])) as VolumeChunk; + chunk.data = new BigUint64Array(8); + chunk.state = ChunkState.SYSTEM_MEMORY; + (chunk.data as BigUint64Array)[0] = 50n; + + const result = await source.applyEdits("0,0,0", [0], [100n]); + + expect(result.oldValues[0]).toBe(50n); + expect(result.newValues[0]).toBe(100n); + }); + + it("should throw on out-of-bounds index", async () => { + await expect(source.applyEdits("0,0,0", [9], [1n])).rejects.toThrow( + /index 9 out of bounds/, + ); + }); + }); + + describe("Uncompressed Data (UINT32)", () => { + it("should handle edits correctly", async () => { + const uint32Spec = { ...BASE_SPEC, dataType: DataType.UINT32 }; + const uint32Source = new MockBackendSource(mockRpc, { + spec: uint32Spec, + chunkManager: 0, + }); + + const result = await uint32Source.applyEdits("0,0,0", [0], [123]); + + const chunk = uint32Source.chunks.get("0,0,0")! as VolumeChunk; + expect(chunk.data).toBeInstanceOf(Uint32Array); + expect((chunk.data as Uint32Array)[0]).toBe(123); + expect(result.newValues[0]).toBe(123); + }); + }); + + describe("Compressed Segmentation", () => { + it("should handle UINT64 compressed segmentation", async () => { + const compressedSpec = { + ...BASE_SPEC, + compressedSegmentationBlockSize: Uint32Array.from([2, 2, 1]), + }; + const compressedSource = new MockBackendSource(mockRpc, { + spec: compressedSpec, + chunkManager: 0, + }); + + const chunk = compressedSource.getChunk( + new Float32Array([0, 0, 0]), + ) as VolumeChunk; + chunk.data = new Uint32Array([123]); + + const result = await compressedSource.applyEdits("0,0,0", [0], [99n]); + + expect(result.oldValues[0]).toBe(5n); + expect((chunk.data as Uint32Array)[0]).toBe(888); + }); + + it("should handle UINT32 compressed segmentation", async () => { + const compressedSpec = { + ...BASE_SPEC, + dataType: DataType.UINT32, + compressedSegmentationBlockSize: Uint32Array.from([2, 2, 1]), + }; + const compressedSource = new MockBackendSource(mockRpc, { + spec: compressedSpec, + chunkManager: 0, + }); + + const chunk = compressedSource.getChunk( + new Float32Array([0, 0, 0]), + ) as VolumeChunk; + chunk.data = new Uint32Array([123]); + + const result = await compressedSource.applyEdits("0,0,0", [0], [77]); + + expect(result.oldValues[0]).toBe(5); + expect(result.newValues[0]).toBe(77); + expect((chunk.data as Uint32Array)[0]).toBe(444); + }); + + it("should handle zero-offset compressed data (empty/new)", async () => { + const compressedSpec = { + ...BASE_SPEC, + compressedSegmentationBlockSize: Uint32Array.from([2, 2, 1]), + }; + const compressedSource = new MockBackendSource(mockRpc, { + spec: compressedSpec, + chunkManager: 0, + }); + + const chunk = compressedSource.getChunk( + new Float32Array([0, 0, 0]), + ) as VolumeChunk; + chunk.data = new Uint32Array([]); + + await compressedSource.applyEdits("0,0,0", [0], [50n]); + expect((chunk.data as Uint32Array)[0]).toBe(888); + }); + + it("should handle zero-offset compressed data for UINT32", async () => { + const compressedSpec = { + ...BASE_SPEC, + dataType: DataType.UINT32, + compressedSegmentationBlockSize: Uint32Array.from([2, 2, 1]), + }; + const compressedSource = new MockBackendSource(mockRpc, { + spec: compressedSpec, + chunkManager: 0, + }); + + const chunk = compressedSource.getChunk( + new Float32Array([0, 0, 0]), + ) as VolumeChunk; + chunk.data = new Uint32Array([]); + + await compressedSource.applyEdits("0,0,0", [0], [50]); + expect((chunk.data as Uint32Array)[0]).toBe(444); + }); + }); + + describe("Error Handling & Bounds", () => { + it("should throw if chunk size cannot be determined", async () => { + const computeSpy = vi + .spyOn(source, "computeChunkBounds") + .mockImplementation(() => new Float32Array()); + + const chunk = source.getChunk(new Float32Array([0, 0, 0])) as VolumeChunk; + chunk.chunkDataSize = null; + + await expect(source.applyEdits("0,0,0", [0], [1n])).rejects.toThrow( + /size is unknown/, + ); + + computeSpy.mockRestore(); + }); + + it("should retry on 500 errors and eventually succeed", async () => { + vi.useFakeTimers(); + const writeSpy = vi + .spyOn(source, "writeChunk") + .mockRejectedValueOnce(new HttpError("", 500, "")) + .mockRejectedValueOnce(new HttpError("", 503, "")) + .mockResolvedValue(undefined); + + const promise = source.applyEdits("0,0,0", [0], [1n]); + await vi.runAllTimersAsync(); + await promise; + + expect(writeSpy).toHaveBeenCalledTimes(3); + vi.useRealTimers(); + }); + + it("should stop retrying at one point", async () => { + vi.useFakeTimers(); + + vi.spyOn(source, "writeChunk").mockRejectedValue( + new Error("Fatal DB Error"), + ); + + const promise = source.applyEdits("0,0,0", [0], [1n]); + + const assertRejection = expect(promise).rejects.toThrow( + /Failed to write chunk/, + ); + + await vi.runAllTimersAsync(); + await assertRejection; + + vi.useRealTimers(); + }); + + it("should NOT retry on 400 errors", async () => { + vi.useFakeTimers(); + const writeSpy = vi + .spyOn(source, "writeChunk") + .mockRejectedValue(new HttpError("Bad Request", 400, "")); + + const promise = source.applyEdits("0,0,0", [0], [1n]); + + await expect(promise).rejects.toThrow(/Failed to write chunk/); + + expect(writeSpy).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + }); +}); diff --git a/src/sliceview/volume/backend.ts b/src/sliceview/volume/backend.ts index 09c7782dd3..812969a87d 100644 --- a/src/sliceview/volume/backend.ts +++ b/src/sliceview/volume/backend.ts @@ -263,6 +263,11 @@ export class VolumeChunkSource for (let i = 0; i < indices.length; ++i) { const idx = indices[i]!; + if (idx < 0 || idx >= uncompressedData.length) { + throw new Error( + `applyEdits: index ${idx} out of bounds for chunk ${chunkKey}`, + ); + } oldValuesArray[i] = uncompressedData[idx]; if (dataType === DataType.UINT32) { (uncompressedData as Uint32Array)[idx] = Number(values[i]!); @@ -298,7 +303,7 @@ export class VolumeChunkSource const data = chunk.data as TypedArray; for (let i = 0; i < indices.length; ++i) { const idx = indices[i]!; - if (idx < 0 || idx >= data.byteLength) { + if (idx < 0 || idx >= data.length) { throw new Error( `applyEdits: index ${idx} out of bounds for chunk ${chunkKey}`, ); diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index e39562318b..e93aa64998 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -2,7 +2,6 @@ ### priority - ### later - add preview for the undo/redo diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index b52c3ee693..c841c49084 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -421,7 +421,7 @@ export class VoxelEditController extends SharedObject { ) => { const subQueue: [number, number][] = []; // The bounding box for the local fill is defined in the (u, v) coordinate system - const halfSize = requiredThickness * 2; // multiply by 2 to avoid small artefacts + const halfSize = requiredThickness * 2; // multiply by 2 to avoid small artifacts const startKey = `${startU},${startV}`; if (visited.has(startKey)) return; From 61483e925fec9a69573d7faf9aa5271539744a65 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 20 Nov 2025 11:28:24 +0100 Subject: [PATCH 133/251] test(voxel-annotation): add tests for `_calculateParentUpdate` ; add tie break to mode selection logic in downsampling --- src/kvstore/ssa_s3/README.md | 2 +- src/voxel_annotation/TODOs.md | 24 +- src/voxel_annotation/edit_backend.spec.ts | 368 ++++++++++++++++++++++ src/voxel_annotation/edit_backend.ts | 2 + 4 files changed, 394 insertions(+), 2 deletions(-) create mode 100644 src/voxel_annotation/edit_backend.spec.ts diff --git a/src/kvstore/ssa_s3/README.md b/src/kvstore/ssa_s3/README.md index c00ea880fe..19fff34dbc 100644 --- a/src/kvstore/ssa_s3/README.md +++ b/src/kvstore/ssa_s3/README.md @@ -1,2 +1,2 @@ The Stateless S3 Authenticator (SSA) is an authentication service that uses an OIDC portal to verify user identity. It then generates secure, temporary, pre-signed URLs that allow Neuroglancer to directly read from and write to private S3 buckets. -See [TODO: link the github here after it is created...] for more details. +See [TODO: link the github here after its creation...] for more details. diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index e93aa64998..f1c20266e7 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -11,5 +11,27 @@ ### questionable - add support for volumes with rank different from 3 -- write a testsuite for the downsampler and ensure its proper working on exotic lod levels - adapt the brush size to the zoom level linearly + +## Tests + +- src/voxel_annotation/edit_backend.ts + - [x] \_calculateParentUpdate + - [ ] \_getParentChunkInfo + - [ ] downsampleStep + - [ ] undo/redo + - [ ] flushPending +- src/voxel_annotation/edit_controller.ts + - [ ] floodFillPlane2D + - [ ] paintBrushWithShape +- src/layer/vox/index.ts + - [ ] getVoxelPositionFromMouse + - [ ] setVoxelPaintValue + - [ ] transformGlobalToVoxelNormal +- src/sliceview/volume/backend.ts + - [x] applyEdits + - [ ] computeChunkBounds +- src/sliceview/volume/frontend.ts + - [ ] applyLocalEdits +- src/datasource/zarr/backend.ts + - [ ] writeChunk diff --git a/src/voxel_annotation/edit_backend.spec.ts b/src/voxel_annotation/edit_backend.spec.ts new file mode 100644 index 0000000000..f22680f3b2 --- /dev/null +++ b/src/voxel_annotation/edit_backend.spec.ts @@ -0,0 +1,368 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mat4 } from "#src/util/geom.js"; +import { VoxelEditController } from "#src/voxel_annotation/edit_backend.js"; +import type { RPC } from "#src/worker_rpc.js"; + +const mockRpc = { + get: vi.fn(), + invoke: vi.fn(), + newId: () => 0, + register: vi.fn(), + set: vi.fn(), + delete: vi.fn(), +} as unknown as RPC; + +const resConfig = ( + lod: number, + scale: [number, number, number], + chunkSize: [number, number, number], + translation: [number, number, number] = [0, 0, 0], +) => { + const transform = new Float32Array(16); + mat4.identity(transform as unknown as mat4); + mat4.translate( + transform as unknown as mat4, + transform as unknown as mat4, + translation as any, + ); + mat4.scale( + transform as unknown as mat4, + transform as unknown as mat4, + scale as any, + ); + return { + lodIndex: lod, + transform: Array.from(transform), + chunkSize, + sourceRpc: 100 + lod, + }; +}; + +type Grid3D = (number | bigint)[][][]; // Z -> Y -> X + +function flattenGrid(grid: Grid3D, Ctor: any = Uint32Array) { + const d = grid.length; + const h = grid[0].length; + const w = grid[0][0].length; + const data = new Ctor(w * h * d); + + for (let z = 0; z < d; z++) { + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + data[x + y * w + z * w * h] = grid[z][y][x]; + } + } + } + return { data, size: [w, h, d] as [number, number, number] }; +} + +describe("VoxelEditController: _calculateParentUpdate", () => { + let controller: VoxelEditController; + let runDownsample: Function; + + beforeEach(() => { + vi.resetAllMocks(); + (mockRpc.get as any).mockImplementation((id: number) => ({ + rpcId: id, + spec: { rank: 3, chunkDataSize: new Uint32Array([2, 2, 2]), dataType: 0 }, + })); + }); + + const runScenario = ( + scale: [number, number, number], + parentChunkSize: [number, number, number], + inputGrid: Grid3D, + expectedUpdates: { + x: number; + y: number; + z: number; + val: number | bigint; + }[], + childChunkOffset: { x: number; y: number; z: number } = { + x: 0, + y: 0, + z: 0, + }, + dataCtor: any = Uint32Array, + translation: [number, number, number] = [0, 0, 0], + ) => { + const { data: childData, size: childSize } = flattenGrid( + inputGrid, + dataCtor, + ); + + const childRes = resConfig(0, [1, 1, 1], childSize); + const parentRes = resConfig(1, scale, parentChunkSize, translation); + controller = new VoxelEditController(mockRpc, { + resolutions: [childRes, parentRes], + }); + runDownsample = (controller as any)._calculateParentUpdate.bind(controller); + + const result = runDownsample( + childData, + (controller as any).resolutions.get(0), + (controller as any).resolutions.get(1), + childChunkOffset, + ); + + const [pw, ph, pd] = parentChunkSize; + const actualUpdatesMap = new Map(); + + for (let i = 0; i < result.indices.length; i++) { + const idx = result.indices[i]; + const val = result.values[i]; + const maxIdx = pw * ph * pd; + expect(idx).toBeLessThan(maxIdx); + expect(idx).toBeGreaterThanOrEqual(0); + + const pz = Math.floor(idx / (pw * ph)); + const rem = idx % (pw * ph); + const py = Math.floor(rem / pw); + const px = rem % pw; + actualUpdatesMap.set(`${px},${py},${pz}`, BigInt(val)); + } + + for (const { x, y, z, val } of expectedUpdates) { + const key = `${x},${y},${z}`; + const actual = actualUpdatesMap.get(key); + expect(actual, `Missing update at Parent(${x},${y},${z})`).toBeDefined(); + expect(actual, `Incorrect value at Parent(${x},${y},${z})`).toBe( + BigInt(val), + ); + actualUpdatesMap.delete(key); + } + + if (actualUpdatesMap.size > 0) { + const extras = Array.from(actualUpdatesMap.entries()) + .map(([k, v]) => `(${k}): ${v}`) + .join(", "); + throw new Error(`Unexpected updates at: ${extras}`); + } + }; + + it("Standard 2x2x2 Downsampling", () => { + runScenario( + [2, 2, 2], + [2, 2, 2], + [ + [ + [1, 1, 2, 2], + [1, 1, 2, 3], + [4, 4, 0, 0], + [4, 4, 0, 0], + ], + [ + [1, 1, 2, 2], + [1, 1, 3, 3], + [4, 4, 0, 0], + [4, 4, 0, 0], + ], + [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ], + [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ], + ], + [ + { x: 0, y: 0, z: 0, val: 1 }, + { x: 1, y: 0, z: 0, val: 2 }, + { x: 0, y: 1, z: 0, val: 4 }, + { x: 1, y: 1, z: 0, val: 0 }, + { x: 0, y: 0, z: 1, val: 0 }, + { x: 1, y: 0, z: 1, val: 0 }, + { x: 0, y: 1, z: 1, val: 0 }, + { x: 1, y: 1, z: 1, val: 0 }, + ], + ); + }); + + it("Anisotropic 1x2x1", () => { + runScenario( + [1, 2, 1], + [2, 2, 2], + [ + [ + [5, 5], + [5, 5], + [6, 6], + [7, 7], + ], + [ + [0, 0], + [0, 0], + [0, 0], + [0, 0], + ], + ], + [ + { x: 0, y: 0, z: 0, val: 5 }, + { x: 1, y: 0, z: 0, val: 5 }, + { x: 0, y: 1, z: 0, val: 6 }, + { x: 1, y: 1, z: 0, val: 6 }, + { x: 0, y: 0, z: 1, val: 0 }, + { x: 1, y: 0, z: 1, val: 0 }, + { x: 0, y: 1, z: 1, val: 0 }, + { x: 1, y: 1, z: 1, val: 0 }, + ], + ); + }); + + it("Odd Factors: 3x2x5", () => { + runScenario( + [3, 2, 5], + [3, 3, 1], + [ + [ + [1, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ], + [ + [0, 0, 0, 2], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ], + [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 3, 0, 0], + [0, 0, 0, 0], + ], + [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 4, 0], + ], + ], + [ + { x: 0, y: 0, z: 0, val: 1 }, + { x: 1, y: 0, z: 0, val: 2 }, + { x: 0, y: 1, z: 0, val: 3 }, + { x: 1, y: 1, z: 0, val: 0 }, + ], + ); + }); + + it("Erasure: Single non-zero pixel cleared", () => { + runScenario( + [2, 2, 1], + [1, 1, 2], + [ + [ + [0, 0], + [0, 0], + ], + [ + [0, 0], + [0, 0], + ], + ], + [ + { x: 0, y: 0, z: 0, val: 0 }, + { x: 0, y: 0, z: 1, val: 0 }, + ], + ); + }); + + it("Offset Parent: Update at z=6", () => { + runScenario( + [2, 2, 2], + [4, 4, 8], + [ + [ + [99, 99], + [99, 99], + ], + [ + [99, 99], + [99, 99], + ], + ], + [{ x: 2, y: 2, z: 6, val: 99 }], + { x: 2, y: 2, z: 6 }, + ); + }); + + it("BigUint64Array: Supports large integers > 2^53", () => { + const bigVal = BigInt(Number.MAX_SAFE_INTEGER) + 50n; + runScenario( + [2, 2, 2], + [2, 2, 2], + [ + [ + [bigVal, bigVal], + [bigVal, bigVal], + ], + ], + [{ x: 0, y: 0, z: 0, val: bigVal }], + { x: 0, y: 0, z: 0 }, + BigUint64Array, + ); + }); + + it("Uint8Array: Supports lower precision types", () => { + runScenario( + [2, 2, 2], + [2, 2, 2], + [ + [ + [255, 255], + [255, 255], + ], + ], + [{ x: 0, y: 0, z: 0, val: 255 }], + { x: 0, y: 0, z: 0 }, + Uint8Array, + ); + }); + + it("Tie Breaking: Lowest value wins when counts are equal", () => { + runScenario( + [2, 2, 1], + [2, 2, 1], + [ + [ + [10, 10], + [5, 5], + ], + ], + [{ x: 0, y: 0, z: 0, val: 5 }], + ); + }); + + it("Non-Zero Dominance: 0 only wins if all values are 0", () => { + runScenario( + [2, 2, 1], + [2, 2, 1], + [ + [ + [0, 0], + [0, 9], + ], + ], + [{ x: 0, y: 0, z: 0, val: 9 }], + ); + }); + + it("Matrix Translation: Handles misaligned grids", () => { + runScenario( + [1, 1, 1], + [4, 1, 1], + [[[42]]], + [{ x: 2, y: 0, z: 0, val: 42 }], + { x: 0, y: 0, z: 0 }, + Uint32Array, + [-2, 0, 0], + ); + }); +}); diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/edit_backend.ts index 1bf030bfcc..adc3f40bb6 100644 --- a/src/voxel_annotation/edit_backend.ts +++ b/src/voxel_annotation/edit_backend.ts @@ -660,6 +660,8 @@ export class VoxelEditController extends SharedObject { if (c > maxCount) { maxCount = c; mode = bigV; + } else if (c === maxCount && bigV < mode) { + mode = bigV; } } return mode; From 701b139f5e8fee10cda54ee81005e6df2bce3e92 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 20 Nov 2025 13:31:27 +0100 Subject: [PATCH 134/251] test(voxel-annotation): add tests for `_getParentChunkInfo` --- src/voxel_annotation/TODOs.md | 2 +- src/voxel_annotation/edit_backend.spec.ts | 113 ++++++++++++++++++++++ src/voxel_annotation/edit_backend.ts | 7 ++ 3 files changed, 121 insertions(+), 1 deletion(-) diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index f1c20266e7..77dbd8c2df 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -17,7 +17,7 @@ - src/voxel_annotation/edit_backend.ts - [x] \_calculateParentUpdate - - [ ] \_getParentChunkInfo + - [x] \_getParentChunkInfo - [ ] downsampleStep - [ ] undo/redo - [ ] flushPending diff --git a/src/voxel_annotation/edit_backend.spec.ts b/src/voxel_annotation/edit_backend.spec.ts index f22680f3b2..baeeada5d6 100644 --- a/src/voxel_annotation/edit_backend.spec.ts +++ b/src/voxel_annotation/edit_backend.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { mat4 } from "#src/util/geom.js"; +import { makeVoxChunkKey } from "#src/voxel_annotation/base.js"; import { VoxelEditController } from "#src/voxel_annotation/edit_backend.js"; import type { RPC } from "#src/worker_rpc.js"; @@ -365,4 +366,116 @@ describe("VoxelEditController: _calculateParentUpdate", () => { [-2, 0, 0], ); }); + + it("Fractional Scale: Aggregates across fractional boundaries (2.5x)", () => { + runScenario( + [2.5, 1, 1], + [2, 1, 1], + [[[1, 1, 2, 2, 2]]], + [ + { x: 0, y: 0, z: 0, val: 1 }, + { x: 1, y: 0, z: 0, val: 2 }, + ], + ); + }); +}); + +describe("VoxelEditController: _getParentChunkInfo (Coordinate Mapping)", () => { + let controller: VoxelEditController; + + const setupController = (resConfigs: any[]) => { + (mockRpc.get as any).mockImplementation((id: number) => ({ + rpcId: id, + spec: { rank: 3, chunkDataSize: new Uint32Array([2, 2, 2]), dataType: 0 }, + applyEdits: vi.fn(), + getChunk: vi.fn(), + })); + controller = new VoxelEditController(mockRpc, { resolutions: resConfigs }); + return controller; + }; + + it("Standard Alignment: 2x scaling", () => { + const childRes = resConfig(0, [1, 1, 1], [4, 4, 4]); + const parentRes = resConfig(1, [2, 2, 2], [4, 4, 4]); + + setupController([childRes, parentRes]); + const getInfo = (controller as any)._getParentChunkInfo.bind(controller); + + let res = getInfo(makeVoxChunkKey("0,0,0", 0), childRes); + expect(res.chunkKey).toBe("0,0,0"); + expect(res.parentKey).toBe(makeVoxChunkKey("0,0,0", 1)); + + res = getInfo(makeVoxChunkKey("1,0,0", 0), childRes); + expect(res.chunkKey).toBe("0,0,0"); + + res = getInfo(makeVoxChunkKey("2,0,0", 0), childRes); + expect(res.chunkKey).toBe("1,0,0"); + }); + + it("Matrix Translation: Parent Origin Shift", () => { + const childRes = resConfig(0, [1, 1, 1], [4, 4, 4]); + const parentRes = resConfig(1, [1, 1, 1], [4, 4, 4], [-4, 0, 0]); + + setupController([childRes, parentRes]); + const getInfo = (controller as any)._getParentChunkInfo.bind(controller); + + const res = getInfo(makeVoxChunkKey("0,0,0", 0), childRes); + expect(res.chunkKey).toBe("1,0,0"); + }); + + it("Negative Coordinates", () => { + const childRes = resConfig(0, [1, 1, 1], [4, 4, 4]); + const parentRes = resConfig(1, [1, 1, 1], [4, 4, 4]); + + setupController([childRes, parentRes]); + const getInfo = (controller as any)._getParentChunkInfo.bind(controller); + + const res = getInfo(makeVoxChunkKey("-1,-1,-1", 0), childRes); + expect(res.chunkKey).toBe("-1,-1,-1"); + }); + + it("Max LOD Boundary", () => { + const childRes = resConfig(0, [1, 1, 1], [4, 4, 4]); + setupController([childRes]); + const getInfo = (controller as any)._getParentChunkInfo.bind(controller); + + const res = getInfo(makeVoxChunkKey("0,0,0", 0), childRes); + expect(res).toBeNull(); + }); + + it("Odd Integer Scale (3x)", () => { + const childRes = resConfig(0, [1, 1, 1], [2, 2, 2]); + const parentRes = resConfig(1, [3, 3, 3], [2, 2, 2]); + + setupController([childRes, parentRes]); + const getInfo = (controller as any)._getParentChunkInfo.bind(controller); + + const res = getInfo(makeVoxChunkKey("3,0,0", 0), childRes); + expect(res.chunkKey).toBe("1,0,0"); + }); + + it("Fractional Scale (2.5x)", () => { + const childRes = resConfig(0, [1, 1, 1], [10, 10, 10]); + const parentRes = resConfig(1, [2.5, 2.5, 2.5], [10, 10, 10]); + + setupController([childRes, parentRes]); + const getInfo = (controller as any)._getParentChunkInfo.bind(controller); + + let res = getInfo(makeVoxChunkKey("2,0,0", 0), childRes); + expect(res.chunkKey).toBe("0,0,0"); + + res = getInfo(makeVoxChunkKey("3,0,0", 0), childRes); + expect(res.chunkKey).toBe("1,0,0"); + }); + + it("Anisotropic Scale (1x, 2x, 5x)", () => { + const childRes = resConfig(0, [1, 1, 1], [10, 10, 10]); + const parentRes = resConfig(1, [1, 2, 5], [10, 10, 10]); + + setupController([childRes, parentRes]); + const getInfo = (controller as any)._getParentChunkInfo.bind(controller); + + const res = getInfo(makeVoxChunkKey("1,1,1", 0), childRes); + expect(res.chunkKey).toBe("1,0,0"); + }); }); diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/edit_backend.ts index adc3f40bb6..379fbddd0b 100644 --- a/src/voxel_annotation/edit_backend.ts +++ b/src/voxel_annotation/edit_backend.ts @@ -258,6 +258,13 @@ export class VoxelEditController extends SharedObject { // --- Start of Downsampling Logic --- + /** + * NOTE: Architecture Limitation + * The current downsampling architecture assumes a Many-to-1 (or 1-to-1) mapping between + * child chunks and parent chunks. It calculates a single parent chunk key for a given + * child chunk. + */ + private enqueueDownsample(key: string): void { if (key.length === 0) return; if (!this.downsampleQueueSet.has(key)) { From f5993ce3a280c9c1b51b6261dd0afc9e5701b555 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 20 Nov 2025 15:02:08 +0100 Subject: [PATCH 135/251] test(voxel-annotation): add integration tests for downsampling in `VoxelEditController` --- src/voxel_annotation/edit_backend.spec.ts | 180 +++++++++++++++++++++- 1 file changed, 179 insertions(+), 1 deletion(-) diff --git a/src/voxel_annotation/edit_backend.spec.ts b/src/voxel_annotation/edit_backend.spec.ts index baeeada5d6..2fac23b97e 100644 --- a/src/voxel_annotation/edit_backend.spec.ts +++ b/src/voxel_annotation/edit_backend.spec.ts @@ -380,7 +380,7 @@ describe("VoxelEditController: _calculateParentUpdate", () => { }); }); -describe("VoxelEditController: _getParentChunkInfo (Coordinate Mapping)", () => { +describe("VoxelEditController: _getParentChunkInfo", () => { let controller: VoxelEditController; const setupController = (resConfigs: any[]) => { @@ -479,3 +479,181 @@ describe("VoxelEditController: _getParentChunkInfo (Coordinate Mapping)", () => expect(res.chunkKey).toBe("1,0,0"); }); }); + +describe("VoxelEditController: Downsampling Integration", () => { + let controller: VoxelEditController; + let childSource: any; + let parentSource: any; + let grandParentSource: any; + + const setupIntegration = (numLevels: number = 2) => { + childSource = { + spec: { rank: 3, chunkDataSize: new Uint32Array([2, 2, 2]), dataType: 0 }, + getChunk: vi.fn().mockReturnValue({ data: new Uint32Array(8).fill(1) }), + download: vi.fn().mockResolvedValue(undefined), + applyEdits: vi.fn().mockResolvedValue({}), + invalidateChunks: vi.fn(), + }; + + parentSource = { + spec: { rank: 3, chunkDataSize: new Uint32Array([2, 2, 2]), dataType: 0 }, + getChunk: vi.fn().mockReturnValue({ data: new Uint32Array(8).fill(0) }), + download: vi.fn().mockResolvedValue(undefined), + applyEdits: vi.fn().mockResolvedValue({}), + invalidateChunks: vi.fn(), + }; + + grandParentSource = { + spec: { rank: 3, chunkDataSize: new Uint32Array([2, 2, 2]), dataType: 0 }, + getChunk: vi.fn().mockReturnValue({ data: new Uint32Array(8).fill(0) }), + download: vi.fn().mockResolvedValue(undefined), + applyEdits: vi.fn().mockResolvedValue({}), + invalidateChunks: vi.fn(), + }; + + (mockRpc.get as any).mockImplementation((id: number) => { + if (id === 100) return childSource; + if (id === 101) return parentSource; + if (id === 102) return grandParentSource; + return null; + }); + + const resolutions = [ + resConfig(0, [1, 1, 1], [2, 2, 2]), // Child + resConfig(1, [2, 2, 2], [2, 2, 2]), // Parent (2x scale) + ]; + + if (numLevels > 2) { + resolutions.push(resConfig(2, [4, 4, 4], [2, 2, 2])); // Grandparent (4x scale) + } + + controller = new VoxelEditController(mockRpc, { resolutions }); + + vi.spyOn(controller as any, "callChunkReload"); + }; + + it("Single Step Flow: Writes to parent and notifies frontend", async () => { + setupIntegration(2); + const key = makeVoxChunkKey("0,0,0", 0); + + (controller as any).enqueueDownsample(key); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(childSource.getChunk).toHaveBeenCalled(); + + expect(parentSource.applyEdits).toHaveBeenCalledWith( + "0,0,0", + expect.any(Array), + expect.arrayContaining([1n]), + ); + + expect((controller as any).callChunkReload).toHaveBeenCalledWith([ + makeVoxChunkKey("0,0,0", 1), + ]); + + expect((controller as any).callChunkReload).toHaveBeenCalledWith( + [makeVoxChunkKey("0,0,0", 0), makeVoxChunkKey("0,0,0", 1)], + true, // isForPreviewChunks + ); + }); + + it("Recursive Propagation: L0 -> L1 -> L2", async () => { + setupIntegration(3); + + parentSource.applyEdits.mockImplementation(async () => { + parentSource.getChunk.mockReturnValue({ + data: new Uint32Array(8).fill(1), + }); + }); + + const key = makeVoxChunkKey("0,0,0", 0); + (controller as any).enqueueDownsample(key); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(parentSource.applyEdits).toHaveBeenCalled(); + + expect(grandParentSource.applyEdits).toHaveBeenCalled(); + + const reloadCalls = (controller as any).callChunkReload.mock.calls; + const keysReloaded = reloadCalls.flatMap((c: any) => c[0]); + + expect(keysReloaded).toContain(makeVoxChunkKey("0,0,0", 1)); + expect(keysReloaded).toContain(makeVoxChunkKey("0,0,0", 2)); + }); + + it("Queue Deduplication: Processes same key once per batch", async () => { + setupIntegration(2); + const key = makeVoxChunkKey("0,0,0", 0); + + (controller as any).enqueueDownsample(key); + (controller as any).enqueueDownsample(key); + (controller as any).enqueueDownsample(key); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(parentSource.applyEdits).toHaveBeenCalledTimes(1); + }); + + it("Lazy Loading: Downloads child chunk if missing", async () => { + setupIntegration(2); + + const emptyChunk = { data: null }; + childSource.getChunk.mockReturnValue(emptyChunk); + + childSource.download.mockImplementation(async (chunk: any) => { + chunk.data = new Uint32Array(8).fill(1); + }); + + const key = makeVoxChunkKey("0,0,0", 0); + (controller as any).enqueueDownsample(key); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(childSource.download).toHaveBeenCalled(); + expect(parentSource.applyEdits).toHaveBeenCalled(); + }); + + it("Error Handling: Child download failure aborts chain gracefully", async () => { + setupIntegration(2); + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + childSource.getChunk.mockReturnValue({ data: null }); + childSource.download.mockRejectedValue(new Error("Network Error")); + + const key = makeVoxChunkKey("0,0,0", 0); + (controller as any).enqueueDownsample(key); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(parentSource.applyEdits).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it("Error Handling: Parent write failure reports error and stops recursion", async () => { + setupIntegration(3); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + parentSource.applyEdits.mockRejectedValue(new Error("Write Failed")); + + const key = makeVoxChunkKey("0,0,0", 0); + (controller as any).enqueueDownsample(key); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(parentSource.applyEdits).toHaveBeenCalled(); + + expect(grandParentSource.applyEdits).not.toHaveBeenCalled(); + + expect(mockRpc.invoke).toHaveBeenCalledWith( + "vox.edit.failure", + expect.objectContaining({ + voxChunkKeys: [makeVoxChunkKey("0,0,0", 1)], + }), + ); + + consoleSpy.mockRestore(); + }); +}); From 7e8b6d457c36aecfe2b0bd8b33441d6656723b8d Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 20 Nov 2025 15:44:15 +0100 Subject: [PATCH 136/251] test(voxel-annotation): add `flushPending` tests --- src/voxel_annotation/TODOs.md | 4 +- src/voxel_annotation/edit_backend.spec.ts | 199 +++++++++++++++++++++- 2 files changed, 199 insertions(+), 4 deletions(-) diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 77dbd8c2df..5a98a660cc 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -18,9 +18,9 @@ - src/voxel_annotation/edit_backend.ts - [x] \_calculateParentUpdate - [x] \_getParentChunkInfo - - [ ] downsampleStep + - [x] downsampleStep - [ ] undo/redo - - [ ] flushPending + - [x] flushPending - src/voxel_annotation/edit_controller.ts - [ ] floodFillPlane2D - [ ] paintBrushWithShape diff --git a/src/voxel_annotation/edit_backend.spec.ts b/src/voxel_annotation/edit_backend.spec.ts index 2fac23b97e..90033e847d 100644 --- a/src/voxel_annotation/edit_backend.spec.ts +++ b/src/voxel_annotation/edit_backend.spec.ts @@ -1,6 +1,10 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { mat4 } from "#src/util/geom.js"; -import { makeVoxChunkKey } from "#src/voxel_annotation/base.js"; +import { + makeVoxChunkKey, + VOX_EDIT_FAILURE_RPC_ID, + VOX_EDIT_HISTORY_UPDATE_RPC_ID, +} from "#src/voxel_annotation/base.js"; import { VoxelEditController } from "#src/voxel_annotation/edit_backend.js"; import type { RPC } from "#src/worker_rpc.js"; @@ -657,3 +661,194 @@ describe("VoxelEditController: Downsampling Integration", () => { consoleSpy.mockRestore(); }); }); + +describe("VoxelEditController: flushPending", () => { + let controller: VoxelEditController; + let mockSource0: any; + let mockSource1: any; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + + mockSource0 = { + spec: { rank: 3, chunkDataSize: new Uint32Array([2, 2, 2]), dataType: 0 }, + applyEdits: vi.fn().mockResolvedValue({ + indices: new Uint32Array([]), + oldValues: new BigUint64Array([]), + newValues: new BigUint64Array([]), + }), + }; + + mockSource1 = { + spec: { rank: 3, chunkDataSize: new Uint32Array([2, 2, 2]), dataType: 0 }, + applyEdits: vi.fn().mockResolvedValue({}), + }; + + (mockRpc.get as any).mockImplementation((id: number) => { + if (id === 100) return mockSource0; + if (id === 101) return mockSource1; + return null; + }); + + controller = new VoxelEditController(mockRpc, { + resolutions: [ + resConfig(0, [1, 1, 1], [2, 2, 2]), + resConfig(1, [2, 2, 2], [2, 2, 2]), + ], + }); + + vi.spyOn(controller as any, "enqueueDownsample").mockImplementation( + () => {}, + ); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("Batching: Aggregates multiple edits to the same chunk into one write", async () => { + const key = makeVoxChunkKey("0,0,0", 0); + + await controller.commitVoxels([ + { key, indices: [1], value: 50n }, + { key, indices: [2], value: 60n }, + ]); + + const otherKey = makeVoxChunkKey("1,0,0", 0); + await controller.commitVoxels([ + { key: otherKey, indices: [5], value: 99n }, + ]); + + await controller.commitVoxels([ + { key, indices: [1], value: 42n }, + { key, indices: [3], value: 70n }, + ]); + + await vi.runAllTimersAsync(); + + expect(mockSource0.applyEdits).toHaveBeenCalledWith( + "0,0,0", + [1, 2, 3], + [42n, 60n, 70n], + ); + + expect(mockSource0.applyEdits).toHaveBeenCalledWith("1,0,0", [5], [99n]); + }); + + it("History: Updates stacks and notifies frontend correctly", async () => { + const key = makeVoxChunkKey("0,0,0", 0); + + (controller as any).redoStack.push({ + changes: new Map(), + timestamp: 0, + description: "dummy", + }); + expect((controller as any).redoStack.length).toBe(1); + + await controller.commitVoxels([{ key, indices: [1], value: 50n }]); + await vi.runAllTimersAsync(); + + expect((controller as any).undoStack.length).toBe(1); + + expect((controller as any).redoStack.length).toBe(0); + + expect(mockRpc.invoke).toHaveBeenCalledWith( + VOX_EDIT_HISTORY_UPDATE_RPC_ID, + expect.objectContaining({ + undoCount: 1, + redoCount: 0, + }), + ); + }); + + it("Partial Failure: Succeeds for valid chunks even if one chunk fails", async () => { + const validKey = makeVoxChunkKey("0,0,0", 0); + const failKey = makeVoxChunkKey("1,0,0", 0); + + mockSource0.applyEdits.mockImplementation((chunkKey: string) => { + if (chunkKey === "1,0,0") { + return Promise.reject(new Error("Network Error")); + } + return Promise.resolve({ + indices: new Uint32Array([1]), + oldValues: new BigUint64Array([0n]), + newValues: new BigUint64Array([50n]), + }); + }); + + await controller.commitVoxels([ + { key: validKey, indices: [1], value: 50n }, + { key: failKey, indices: [1], value: 50n }, + ]); + + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await vi.runAllTimersAsync(); + + expect(mockSource0.applyEdits).toHaveBeenCalledWith( + "0,0,0", + expect.anything(), + expect.anything(), + ); + + expect(mockSource0.applyEdits).toHaveBeenCalledWith( + "1,0,0", + expect.anything(), + expect.anything(), + ); + + expect(mockRpc.invoke).toHaveBeenCalledWith( + VOX_EDIT_FAILURE_RPC_ID, + expect.objectContaining({ + voxChunkKeys: [failKey], + }), + ); + + const undoStack = (controller as any).undoStack; + expect(undoStack.length).toBe(1); + expect(undoStack[0].changes.has(validKey)).toBe(true); + expect(undoStack[0].changes.has(failKey)).toBe(false); + + errorSpy.mockRestore(); + }); + + it("Invalid Data: Handles malformed keys gracefully without crashing", async () => { + const validKey = makeVoxChunkKey("0,0,0", 0); + const badKey = "invalid_format_key"; + + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await controller.commitVoxels([ + { key: validKey, indices: [1], value: 50n }, + { key: badKey, indices: [1], value: 50n }, + ]); + + await vi.runAllTimersAsync(); + + expect(mockSource0.applyEdits).toHaveBeenCalledWith( + "0,0,0", + expect.anything(), + expect.anything(), + ); + + expect(mockRpc.invoke).toHaveBeenCalledWith( + VOX_EDIT_FAILURE_RPC_ID, + expect.objectContaining({ + voxChunkKeys: [badKey], + }), + ); + + errorSpy.mockRestore(); + }); + + it("Downsample Trigger: Enqueues modified keys for processing", async () => { + const key = makeVoxChunkKey("0,0,0", 0); + const enqueueSpy = vi.spyOn(controller as any, "enqueueDownsample"); + + await controller.commitVoxels([{ key, indices: [1], value: 50n }]); + await vi.runAllTimersAsync(); + + expect(enqueueSpy).toHaveBeenCalledWith(key); + }); +}); From b537ed8d19297bc9712745bb5701802fe72919dc Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Fri, 21 Nov 2025 11:55:38 +0100 Subject: [PATCH 137/251] test(voxel-annotation): add undo/redo tests --- src/voxel_annotation/TODOs.md | 2 +- src/voxel_annotation/edit_backend.spec.ts | 227 ++++++++++++++++++++++ 2 files changed, 228 insertions(+), 1 deletion(-) diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 5a98a660cc..d47f516285 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -19,7 +19,7 @@ - [x] \_calculateParentUpdate - [x] \_getParentChunkInfo - [x] downsampleStep - - [ ] undo/redo + - [x] undo/redo - [x] flushPending - src/voxel_annotation/edit_controller.ts - [ ] floodFillPlane2D diff --git a/src/voxel_annotation/edit_backend.spec.ts b/src/voxel_annotation/edit_backend.spec.ts index 90033e847d..0b79d84ca4 100644 --- a/src/voxel_annotation/edit_backend.spec.ts +++ b/src/voxel_annotation/edit_backend.spec.ts @@ -852,3 +852,230 @@ describe("VoxelEditController: flushPending", () => { expect(enqueueSpy).toHaveBeenCalledWith(key); }); }); + +describe("VoxelEditController: Undo/Redo", () => { + let controller: VoxelEditController; + let mockSource0: any; + + beforeEach(() => { + vi.clearAllMocks(); + + mockSource0 = { + spec: { rank: 3, chunkDataSize: new Uint32Array([2, 2, 2]), dataType: 0 }, + applyEdits: vi.fn().mockResolvedValue({ + indices: new Uint32Array([]), + oldValues: new BigUint64Array([]), + newValues: new BigUint64Array([]), + }), + invalidateChunks: vi.fn(), + }; + + (mockRpc.get as any).mockImplementation((id: number) => { + if (id === 100) return mockSource0; + return null; + }); + + controller = new VoxelEditController(mockRpc, { + resolutions: [resConfig(0, [1, 1, 1], [2, 2, 2])], + }); + + vi.spyOn(controller as any, "callChunkReload"); + vi.spyOn(controller as any, "enqueueDownsample").mockImplementation( + () => {}, + ); + (mockRpc.invoke as any).mockClear(); + }); + + it("Successful Undo and Redo Lifecycle", async () => { + const key = makeVoxChunkKey("0,0,0", 0); + const editAction = { + changes: new Map([ + [ + key, + { + indices: new Uint32Array([0]), + oldValues: new BigUint64Array([10n]), + newValues: new BigUint64Array([20n]), + }, + ], + ]), + timestamp: Date.now(), + description: "Test Action", + }; + + (controller as any).undoStack.push(editAction); + + await controller.undo(); + + expect(mockSource0.applyEdits).toHaveBeenCalledWith( + "0,0,0", + expect.any(Uint32Array), + expect.any(BigUint64Array), + ); + const undoCallArgs = mockSource0.applyEdits.mock.calls[0]; + expect(undoCallArgs[2][0]).toBe(10n); + + expect((controller as any).callChunkReload).toHaveBeenCalledWith([key]); + expect(mockRpc.invoke).toHaveBeenCalledWith( + VOX_EDIT_HISTORY_UPDATE_RPC_ID, + expect.objectContaining({ undoCount: 0, redoCount: 1 }), + ); + + expect((controller as any).undoStack.length).toBe(0); + expect((controller as any).redoStack.length).toBe(1); + + mockSource0.applyEdits.mockClear(); + (mockRpc.invoke as any).mockClear(); + + await controller.redo(); + + expect(mockSource0.applyEdits).toHaveBeenCalledWith( + "0,0,0", + expect.any(Uint32Array), + expect.any(BigUint64Array), + ); + const redoCallArgs = mockSource0.applyEdits.mock.calls[0]; + expect(redoCallArgs[2][0]).toBe(20n); + + expect((controller as any).callChunkReload).toHaveBeenCalledWith([key]); + expect(mockRpc.invoke).toHaveBeenCalledWith( + VOX_EDIT_HISTORY_UPDATE_RPC_ID, + expect.objectContaining({ undoCount: 1, redoCount: 0 }), + ); + + expect((controller as any).redoStack.length).toBe(0); + expect((controller as any).undoStack.length).toBe(1); + }); + + it("Empty Stack Behavior", async () => { + await expect(controller.undo()).rejects.toThrow(/Nothing to undo/); + await expect(controller.redo()).rejects.toThrow(/Nothing to redo/); + }); + + it("Undo Failure Handling", async () => { + const key = makeVoxChunkKey("0,0,0", 0); + const editAction = { + changes: new Map([ + [ + key, + { + indices: new Uint32Array([0]), + oldValues: new BigUint64Array([10n]), + newValues: new BigUint64Array([20n]), + }, + ], + ]), + timestamp: Date.now(), + description: "Test Action", + }; + (controller as any).undoStack.push(editAction); + + mockSource0.applyEdits.mockRejectedValue(new Error("Backend Write Failed")); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await controller.undo(); + + expect(mockRpc.invoke).toHaveBeenCalledWith( + VOX_EDIT_FAILURE_RPC_ID, + expect.objectContaining({ + voxChunkKeys: [key], + message: "Undo failed.", + }), + ); + + expect((controller as any).undoStack.length).toBe(1); + expect((controller as any).redoStack.length).toBe(0); + + expect((controller as any).callChunkReload).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it("Redo Failure Handling", async () => { + const key = makeVoxChunkKey("0,0,0", 0); + const editAction = { + changes: new Map([ + [ + key, + { + indices: new Uint32Array([0]), + oldValues: new BigUint64Array([10n]), + newValues: new BigUint64Array([20n]), + }, + ], + ]), + timestamp: Date.now(), + description: "Test Action", + }; + (controller as any).redoStack.push(editAction); + + mockSource0.applyEdits.mockRejectedValue(new Error("Backend Write Failed")); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await controller.redo(); + + expect(mockRpc.invoke).toHaveBeenCalledWith( + VOX_EDIT_FAILURE_RPC_ID, + expect.objectContaining({ + voxChunkKeys: [key], + message: "Redo failed.", + }), + ); + + expect((controller as any).redoStack.length).toBe(1); + expect((controller as any).undoStack.length).toBe(0); + + consoleSpy.mockRestore(); + }); + + it("Multi-Chunk Action Consistency", async () => { + const key1 = makeVoxChunkKey("0,0,0", 0); + const key2 = makeVoxChunkKey("1,0,0", 0); + + const editAction = { + changes: new Map([ + [ + key1, + { + indices: new Uint32Array([0]), + oldValues: new BigUint64Array([1n]), + newValues: new BigUint64Array([2n]), + }, + ], + [ + key2, + { + indices: new Uint32Array([0]), + oldValues: new BigUint64Array([3n]), + newValues: new BigUint64Array([4n]), + }, + ], + ]), + timestamp: Date.now(), + description: "Multi Chunk Action", + }; + + (controller as any).undoStack.push(editAction); + + await controller.undo(); + + expect(mockSource0.applyEdits).toHaveBeenCalledTimes(2); + expect(mockSource0.applyEdits).toHaveBeenCalledWith( + "0,0,0", + expect.anything(), + expect.anything(), + ); + expect(mockSource0.applyEdits).toHaveBeenCalledWith( + "1,0,0", + expect.anything(), + expect.anything(), + ); + + expect((controller as any).callChunkReload).toHaveBeenCalledWith( + expect.arrayContaining([key1, key2]), + ); + + expect((controller as any).undoStack.length).toBe(0); + expect((controller as any).redoStack.length).toBe(1); + }); +}); From 844b1a1c86e913ce34cf832b39e438ea97cf4cdb Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Fri, 21 Nov 2025 13:02:31 +0100 Subject: [PATCH 138/251] feat(voxel-annotation): add constraint validation on voxel annotation activation --- src/layer/vox/index.ts | 89 +++++++++++++++++++++++++++++++---- src/voxel_annotation/TODOs.md | 4 +- 2 files changed, 81 insertions(+), 12 deletions(-) diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 99be6a2e6d..9401a5ef8f 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -39,6 +39,7 @@ import type { SliceViewRenderLayer } from "#src/sliceview/renderlayer.js"; import type { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; import type { ImageRenderLayer } from "#src/sliceview/volume/image_renderlayer.js"; import type { SegmentationRenderLayer } from "#src/sliceview/volume/segmentation_renderlayer.js"; +import { StatusMessage } from "#src/status.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; import type { WatchableValueInterface } from "#src/trackable_value.js"; import { @@ -103,6 +104,15 @@ export class VoxelEditingContext if (!writable) return; + // NOTE: each of the following 3 checks may be removed if support for the checked contraint is added + if (primarySource.rank !== 3) { + throw new Error(`Voxel annotation only supports rank 3 volumes (got ${primarySource.rank}).`); + } + if (primarySource.dataType === DataType.FLOAT32) { + throw new Error(`Voxel annotation does not support Float32 datasets.`); + } + this.validateHierarchy(primarySource); + this.previewSource = new VoxelPreviewMultiscaleSource( this.hostLayer.manager.chunkManager, primarySource, @@ -142,6 +152,57 @@ export class VoxelEditingContext super.disposed(); } + /** + * Verifies that the size of a parent chunk is an integer multiple + * of the size of a child chunk. + */ + private validateHierarchy(primarySource: MultiscaleVolumeChunkSource) { + const rank = primarySource.rank; + + const identityOptions = this.hostLayer.getIdentitySliceViewSourceOptions(); + const scales = primarySource.getSources(identityOptions)[0]; + + if (!scales || scales.length < 2) return; + + const getPhysicalChunkExtent = (lodIndex: number) => { + const source = scales[lodIndex]; + const transform = source.chunkToMultiscaleTransform; + const chunkVoxels = source.chunkSource.spec.chunkDataSize; + + const extent = new Float32Array(rank); + + + for (let i = 0; i < rank; i++) { + let sumSq = 0; + for (let row = 0; row < rank; row++) { + const val = transform[i * (rank + 1) + row]; + sumSq += val * val; + } + const scaleFactor = Math.sqrt(sumSq); + extent[i] = chunkVoxels[i] * scaleFactor; + } + return extent; + }; + + for (let i = 0; i < scales.length - 1; i++) { + const childExtents = getPhysicalChunkExtent(i); + const parentExtents = getPhysicalChunkExtent(i + 1); + + for (let d = 0; d < rank; d++) { + const ratio = parentExtents[d] / childExtents[d]; + const isInteger = Math.abs(ratio - Math.round(ratio)) < 0.001; + + if (!isInteger) { + throw new Error( + `Hierarchy mismatch between LOD ${i} and ${i + 1}. ` + + `Parent chunk must contain a whole number of child chunks. ` + + `Ratio dim ${d}: ${ratio.toFixed(3)}` + ); + } + } + } + } + getVoxelPositionFromMouse( mouseState: MouseSelectionState, ): Float32Array | undefined { @@ -375,16 +436,24 @@ export function UserLayerWithVoxelEditingMixin< const primarySource = loadedSubsource.subsourceEntry.subsource .volume as MultiscaleVolumeChunkSource; - const context = new VoxelEditingContext( - this, - primarySource, - renderlayer, - writable, - ); - this.editingContexts.set(loadedSubsource, context); - this.isEditable.value = writable; - // This will trigger the datatype validation - this.setVoxelPaintValue(this.paintValue.value); + try { + const context = new VoxelEditingContext( + this, + primarySource, + renderlayer, + writable, + ); + this.editingContexts.set(loadedSubsource, context); + this.isEditable.value = writable; + this.setVoxelPaintValue(this.paintValue.value); + } catch (e) { + if (writable) { + loadedSubsource.writable.value = false; + const msg = e instanceof Error ? e.message : String(e); + console.warn("Failed to initialize voxel editing:", msg); + StatusMessage.showTemporaryMessage(msg, 5000); + } + } } deinitializeVoxelEditingForSubsource(loadedSubsource: LoadedDataSubsource) { diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index d47f516285..3b9e4e0c69 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -6,11 +6,12 @@ - add preview for the undo/redo - url completion for the ssa+https source -- writable float32 dataset is not working (expected), either block its usage or fix ### questionable - add support for volumes with rank different from 3 +- add support to float32 dataset +- add support to unaligned hierarchy (e.g. child chunks that may have multiple parents) - adapt the brush size to the zoom level linearly ## Tests @@ -30,7 +31,6 @@ - [ ] transformGlobalToVoxelNormal - src/sliceview/volume/backend.ts - [x] applyEdits - - [ ] computeChunkBounds - src/sliceview/volume/frontend.ts - [ ] applyLocalEdits - src/datasource/zarr/backend.ts From 16bb033ed20f535dd74297d887f778ea13235030 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Fri, 21 Nov 2025 15:45:14 +0100 Subject: [PATCH 139/251] test(voxel-annotation): add unit tests for getVoxelPositionFromMouse, setVoxelPaintValue and transformGlobalToVoxelNormal --- src/layer/vox/index.spec.ts | 365 ++++++++++++++++++++++++++++++++++ src/layer/vox/index.ts | 9 +- src/voxel_annotation/TODOs.md | 6 +- 3 files changed, 373 insertions(+), 7 deletions(-) create mode 100644 src/layer/vox/index.spec.ts diff --git a/src/layer/vox/index.spec.ts b/src/layer/vox/index.spec.ts new file mode 100644 index 0000000000..1416f95d06 --- /dev/null +++ b/src/layer/vox/index.spec.ts @@ -0,0 +1,365 @@ +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + beforeAll, +} from "vitest"; +import { + getChunkPositionFromCombinedGlobalLocalPositions, + getChunkTransformParameters, +} from "#src/render_coordinate_transform.js"; +import { WatchableValue } from "#src/trackable_value.js"; +import { DataType } from "#src/util/data_type.js"; +import { RefCounted } from "#src/util/disposable.js"; +import { vec3 } from "#src/util/geom.js"; + +vi.mock("#src/render_coordinate_transform.js", () => ({ + getChunkTransformParameters: vi.fn(), + getChunkPositionFromCombinedGlobalLocalPositions: vi.fn(), +})); + +vi.mock("#src/voxel_annotation/edit_controller.js", () => ({ + VoxelEditController: class { + dispose() {} + }, +})); + +vi.mock("#src/voxel_annotation/PreviewMultiscaleChunkSource.js", () => ({ + VoxelPreviewMultiscaleSource: class { + getSources() { + return [[{ chunkToMultiscaleTransform: new Float32Array(16) }]]; + } + }, +})); + +vi.mock("#src/layer/index.js", () => ({ + UserLayer: class { + dataSources = []; + layersChanged = { add: () => {}, remove: () => {}, dispatch: () => {} }; + messages = { addChild: () => {} }; + toJSON() { + return {}; + } + restoreState() {} + dispose() {} + registerDisposer() {} + }, + LayerActionContext: class {}, +})); + +vi.mock("#src/layer/vox/tabs/tools.js", () => ({ + VoxToolTab: class {}, +})); + +vi.mock("#src/sliceview/volume/frontend.js", () => ({ + MultiscaleVolumeChunkSource: class {}, + InMemoryVolumeChunkSource: class {}, +})); + +let UserLayerWithVoxelEditingMixin: any; +let VoxelEditingContext: any; + +beforeAll(async () => { + if (typeof WebGL2RenderingContext === "undefined") { + global.WebGL2RenderingContext = class { + static VERTEX_SHADER = 0; + static FRAGMENT_SHADER = 1; + static ARRAY_BUFFER = 34962; + static STATIC_DRAW = 35044; + } as any; + } + if (typeof WebGLTexture === "undefined") { + global.WebGLTexture = class {} as any; + } + + const mod = await import("#src/layer/vox/index.js"); + UserLayerWithVoxelEditingMixin = mod.UserLayerWithVoxelEditingMixin; + VoxelEditingContext = mod.VoxelEditingContext; +}); + +class MockBaseLayer extends RefCounted { + manager = { + chunkManager: { + rpc: {}, + }, + }; + tabs = { + add: vi.fn(), + }; + specificationChanged = { + dispatch: vi.fn(), + }; + layersChanged = { + dispatch: vi.fn(), + }; + toJSON() { + return {}; + } + restoreState() {} +} + +describe("VoxelEditingContext", () => { + let hostLayer: any; + let primarySource: any; + let primaryRenderLayer: any; + let context: any; + let ConcreteVoxelLayer: any; + + beforeEach(() => { + vi.clearAllMocks(); + ConcreteVoxelLayer = UserLayerWithVoxelEditingMixin(MockBaseLayer as any); + + hostLayer = new ConcreteVoxelLayer(); + hostLayer.localPosition = new WatchableValue(new Float32Array([0, 0, 0])); + hostLayer._createVoxelRenderLayer = vi.fn().mockReturnValue({ + filterVisibleSources: vi.fn(), + dispose: vi.fn(), + messages: { addChild: vi.fn() }, + layerChanged: { add: vi.fn(), remove: vi.fn() }, + }); + hostLayer.addRenderLayer = vi.fn(); + hostLayer.removeRenderLayer = vi.fn(); + hostLayer.getIdentitySliceViewSourceOptions = vi.fn(); + + primarySource = { + rank: 3, + getSources: vi + .fn() + .mockReturnValue([ + [{ chunkToMultiscaleTransform: new Float32Array(16) }], + ]), + }; + + primaryRenderLayer = { + transform: new WatchableValue({}), + }; + + context = new VoxelEditingContext( + hostLayer, + primarySource, + primaryRenderLayer, + true, + ); + }); + + afterEach(() => { + if (context) context.dispose(); + }); + + describe("getVoxelPositionFromMouse", () => { + it("Success: returns mapped voxel position", () => { + primaryRenderLayer.transform.value = { + rank: 3, + globalToRenderLayerDimensions: [0, 1, 2], + }; + + const mockChunkTransform = { + modelTransform: { unpaddedRank: 3 }, + layerRank: 3, + combinedGlobalLocalToChunkTransform: new Float32Array(16), + }; + (getChunkTransformParameters as any).mockReturnValue(mockChunkTransform); + + ( + getChunkPositionFromCombinedGlobalLocalPositions as any + ).mockImplementation((out: Float32Array) => { + out[0] = 10; + out[1] = 20; + out[2] = 30; + return true; + }); + + const mouseState = { + unsnappedPosition: new Float32Array([100, 200, 300]), + }; + + const result = context.getVoxelPositionFromMouse(mouseState as any); + + expect(result).toBeDefined(); + expect(result![0]).toBe(10); + expect(result![1]).toBe(20); + expect(result![2]).toBe(30); + expect(getChunkTransformParameters).toHaveBeenCalled(); + }); + + it("Transform Error: returns undefined", () => { + primaryRenderLayer.transform.value = { error: "Some error" }; + const mouseState = { unsnappedPosition: new Float32Array(3) }; + + const result = context.getVoxelPositionFromMouse(mouseState as any); + + expect(result).toBeUndefined(); + }); + + it("Calculation Failure (Throw): returns undefined", () => { + primaryRenderLayer.transform.value = {}; + (getChunkTransformParameters as any).mockImplementation(() => { + throw new Error("Calculation failed"); + }); + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + const mouseState = { unsnappedPosition: new Float32Array(3) }; + const result = context.getVoxelPositionFromMouse(mouseState as any); + + expect(result).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalled(); + }); + + it("Out of Bounds: returns undefined", () => { + primaryRenderLayer.transform.value = {}; + (getChunkTransformParameters as any).mockReturnValue({ + modelTransform: { unpaddedRank: 3 }, + }); + (getChunkPositionFromCombinedGlobalLocalPositions as any).mockReturnValue( + false, + ); + + const mouseState = { unsnappedPosition: new Float32Array(3) }; + const result = context.getVoxelPositionFromMouse(mouseState as any); + + expect(result).toBeUndefined(); + }); + }); + + describe("transformGlobalToVoxelNormal", () => { + it("Uninitialized Cache: throws error", () => { + expect(() => { + context.transformGlobalToVoxelNormal(vec3.create()); + }).toThrow("Chunk transform not computed"); + }); + + it("Identity Transform: returns same vector", () => { + primaryRenderLayer.transform.value = {}; + (getChunkTransformParameters as any).mockReturnValue({ + modelTransform: { globalToRenderLayerDimensions: [0, 1, 2] }, + layerToChunkTransform: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + layerRank: 3, + combinedGlobalLocalToChunkTransform: new Float32Array(16), + }); + (getChunkPositionFromCombinedGlobalLocalPositions as any).mockReturnValue( + true, + ); + context.getVoxelPositionFromMouse({ + unsnappedPosition: new Float32Array(3), + } as any); + + const globalNormal = vec3.fromValues(1, 0, 0); + const result = context.transformGlobalToVoxelNormal(globalNormal); + + expect(result).toEqual(globalNormal); + }); + + it("Rotation/Permutation: transforms vector", () => { + primaryRenderLayer.transform.value = {}; + const permMatrix = [0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + + (getChunkTransformParameters as any).mockReturnValue({ + modelTransform: { globalToRenderLayerDimensions: [0, 1, 2] }, + layerToChunkTransform: permMatrix, + layerRank: 3, + combinedGlobalLocalToChunkTransform: new Float32Array(16), + }); + (getChunkPositionFromCombinedGlobalLocalPositions as any).mockReturnValue( + true, + ); + context.getVoxelPositionFromMouse({ + unsnappedPosition: new Float32Array(3), + } as any); + + const globalNormal = vec3.fromValues(1, 0, 0); + const result = context.transformGlobalToVoxelNormal(globalNormal); + + expect(result[0]).toBeCloseTo(0); + expect(result[1]).toBeCloseTo(1); + expect(result[2]).toBeCloseTo(0); + }); + }); + + it("Non-aligned Normal with Scaling: correctly transforms and normalizes", () => { + const scaleMatrix = [2, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + + (getChunkTransformParameters as any).mockReturnValue({ + modelTransform: { globalToRenderLayerDimensions: [0, 1, 2] }, + layerToChunkTransform: scaleMatrix, + layerRank: 3, + combinedGlobalLocalToChunkTransform: new Float32Array(16), + }); + (getChunkPositionFromCombinedGlobalLocalPositions as any).mockReturnValue( + true, + ); + + context.getVoxelPositionFromMouse({ + unsnappedPosition: new Float32Array(3), + } as any); + + const inputLen = Math.sqrt(2); + const globalNormal = vec3.fromValues(1 / inputLen, 1 / inputLen, 0); + + const result = context.transformGlobalToVoxelNormal(globalNormal); + + const expectedX = 2 / Math.sqrt(5); + const expectedY = 1 / Math.sqrt(5); + + expect(result[0]).toBeCloseTo(expectedX); + expect(result[1]).toBeCloseTo(expectedY); + expect(result[2]).toBeCloseTo(0); + }); +}); + +describe("UserLayerWithVoxelEditingMixin: setVoxelPaintValue", () => { + let layer: any; + let mockContext: { primarySource: { dataType: DataType } }; + let ConcreteVoxelLayer: any; + + beforeEach(() => { + ConcreteVoxelLayer = UserLayerWithVoxelEditingMixin(MockBaseLayer as any); + layer = new ConcreteVoxelLayer(); + mockContext = { primarySource: { dataType: DataType.UINT8 } }; + layer.editingContexts.values = vi.fn().mockReturnValue({ + next: () => ({ value: mockContext }), + }); + }); + + it("UINT8: Clamps/Wraps correctly", () => { + mockContext.primarySource.dataType = DataType.UINT8; + + expect(layer.setVoxelPaintValue(255)).toBe(255n); + expect(layer.setVoxelPaintValue(256)).toBe(0n); + expect(layer.setVoxelPaintValue(-1)).toBe(255n); + }); + + it("INT8: Signed wrapping", () => { + mockContext.primarySource.dataType = DataType.INT8; + + expect(layer.setVoxelPaintValue(127)).toBe(127n); + expect(layer.setVoxelPaintValue(128)).toBe(-128n); + expect(layer.setVoxelPaintValue(-129)).toBe(127n); + }); + + it("FLOAT32: Parses and rounds", () => { + mockContext.primarySource.dataType = DataType.FLOAT32; + + expect(layer.setVoxelPaintValue("10.6")).toBe(11n); + expect(layer.setVoxelPaintValue(10.4)).toBe(10n); + }); + + it("UINT64: Handles BigInts", () => { + mockContext.primarySource.dataType = DataType.UINT64; + const bigVal = BigInt(Number.MAX_SAFE_INTEGER) + 10n; + + expect(layer.setVoxelPaintValue(bigVal)).toBe(bigVal); + }); + + it("No Context: Fails", () => { + layer.editingContexts.values = vi.fn().mockReturnValue({ + next: () => ({ value: undefined }), + }); + + expect(() => layer.setVoxelPaintValue(1)).toThrow(); + }); +}); diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 9401a5ef8f..5124b9d954 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -106,7 +106,9 @@ export class VoxelEditingContext // NOTE: each of the following 3 checks may be removed if support for the checked contraint is added if (primarySource.rank !== 3) { - throw new Error(`Voxel annotation only supports rank 3 volumes (got ${primarySource.rank}).`); + throw new Error( + `Voxel annotation only supports rank 3 volumes (got ${primarySource.rank}).`, + ); } if (primarySource.dataType === DataType.FLOAT32) { throw new Error(`Voxel annotation does not support Float32 datasets.`); @@ -171,7 +173,6 @@ export class VoxelEditingContext const extent = new Float32Array(rank); - for (let i = 0; i < rank; i++) { let sumSq = 0; for (let row = 0; row < rank; row++) { @@ -195,8 +196,8 @@ export class VoxelEditingContext if (!isInteger) { throw new Error( `Hierarchy mismatch between LOD ${i} and ${i + 1}. ` + - `Parent chunk must contain a whole number of child chunks. ` + - `Ratio dim ${d}: ${ratio.toFixed(3)}` + `Parent chunk must contain a whole number of child chunks. ` + + `Ratio dim ${d}: ${ratio.toFixed(3)}`, ); } } diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 3b9e4e0c69..87b4713b85 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -26,9 +26,9 @@ - [ ] floodFillPlane2D - [ ] paintBrushWithShape - src/layer/vox/index.ts - - [ ] getVoxelPositionFromMouse - - [ ] setVoxelPaintValue - - [ ] transformGlobalToVoxelNormal + - [x] getVoxelPositionFromMouse + - [x] setVoxelPaintValue + - [x] transformGlobalToVoxelNormal - src/sliceview/volume/backend.ts - [x] applyEdits - src/sliceview/volume/frontend.ts From 6ff5830c326a5de5720e91cec0cf748fa6a9fd0b Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Fri, 21 Nov 2025 17:09:25 +0100 Subject: [PATCH 140/251] test(voxel-annotation): add unit tests for applyLocalEdits --- src/sliceview/volume/frontend.spec.ts | 164 ++++++++++++++++++++++++++ src/voxel_annotation/TODOs.md | 2 +- 2 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 src/sliceview/volume/frontend.spec.ts diff --git a/src/sliceview/volume/frontend.spec.ts b/src/sliceview/volume/frontend.spec.ts new file mode 100644 index 0000000000..a51be5b313 --- /dev/null +++ b/src/sliceview/volume/frontend.spec.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest"; +import { DataType } from "#src/util/data_type.js"; + +class MockChunk { + data: any = null; + chunkDataSize: Uint32Array; + chunkGridPosition: Float32Array; + state = 0; + + constructor( + public source: any, + x: any, + ) { + this.chunkGridPosition = x.chunkGridPosition; + this.chunkDataSize = source.spec.chunkDataSize; + } + + initializeVolumeChunk() {} + dispose() {} + + updateFromCpuData = vi.fn(); +} + +vi.mock("#src/sliceview/volume/registry.js", () => ({ + getChunkFormatHandler: () => ({ + chunkFormat: { + dataType: 0, + }, + getChunk: (source: any, x: any) => { + return new MockChunk(source, x); + }, + dispose: () => {}, + }), +})); + +describe("InMemoryVolumeChunkSource", () => { + let InMemoryVolumeChunkSource: any; + let chunkManagerMock: any; + let glMock: any; + let visibleChunksChangedMock: any; + + beforeAll(async () => { + if (typeof WebGL2RenderingContext === "undefined") { + global.WebGL2RenderingContext = class { + static VERTEX_SHADER = 35633; + static FRAGMENT_SHADER = 35632; + static ARRAY_BUFFER = 34962; + static STATIC_DRAW = 35044; + static TEXTURE_2D = 3553; + static TEXTURE_3D = 32879; + } as any; + } + if (typeof WebGLTexture === "undefined") { + global.WebGLTexture = class {} as any; + } + + const mod = await import("#src/sliceview/volume/frontend.js"); + InMemoryVolumeChunkSource = mod.InMemoryVolumeChunkSource; + }); + + beforeEach(() => { + glMock = { name: "mockGL" }; + visibleChunksChangedMock = { dispatch: vi.fn() }; + chunkManagerMock = { + chunkQueueManager: { + gl: glMock, + visibleChunksChanged: visibleChunksChangedMock, + sources: { add: () => {}, delete: () => {} }, + }, + rpc: { + newId: () => 0, + invoke: () => {}, + register: () => {}, + delete: () => {}, + get: () => {}, + set: () => {}, + }, + }; + }); + + const createSource = (dataType: DataType) => { + const spec: any = { + rank: 3, + chunkDataSize: Uint32Array.from([2, 2, 2]), + dataType, + upperVoxelBound: Float32Array.from([10, 10, 10]), + lowerVoxelBound: Float32Array.from([0, 0, 0]), + baseVoxelOffset: Float32Array.from([0, 0, 0]), + }; + return new InMemoryVolumeChunkSource(chunkManagerMock, { spec }); + }; + + it("Lazy Creation: creates a chunk if it does not exist", () => { + const source = createSource(DataType.UINT64); + const edits = new Map(); + edits.set("0,0,0", { indices: [0], value: 123n }); + + expect(source.chunks.size).toBe(0); + source.applyLocalEdits(edits); + expect(source.chunks.size).toBe(1); + expect(source.chunks.has("0,0,0")).toBe(true); + }); + + it("Lazy Allocation: allocates data buffer if null", () => { + const source = createSource(DataType.UINT64); + const edits = new Map(); + edits.set("0,0,0", { indices: [0], value: 123n }); + + source.applyLocalEdits(edits); + const chunk = source.chunks.get("0,0,0") as unknown as MockChunk; + expect(chunk.data).toBeInstanceOf(BigUint64Array); + expect(chunk.data).toHaveLength(8); // 2*2*2 + expect(chunk.data[0]).toBe(123n); + }); + + it("Data Type Handling: UINT32", () => { + const source = createSource(DataType.UINT32); + const edits = new Map(); + edits.set("0,0,0", { indices: [1], value: 456n }); + + source.applyLocalEdits(edits); + const chunk = source.chunks.get("0,0,0") as unknown as MockChunk; + expect(chunk.data).toBeInstanceOf(Uint32Array); + expect(chunk.data[1]).toBe(456); + }); + + it("Data Type Handling: UINT8", () => { + const source = createSource(DataType.UINT8); + const edits = new Map(); + edits.set("0,0,0", { indices: [2], value: 255n }); + + source.applyLocalEdits(edits); + const chunk = source.chunks.get("0,0,0") as unknown as MockChunk; + expect(chunk.data).toBeInstanceOf(Uint8Array); + expect(chunk.data[2]).toBe(255); + }); + + it("GPU Trigger: calls updateFromCpuData and dispatches change", () => { + const source = createSource(DataType.UINT64); + const edits = new Map(); + edits.set("0,0,0", { indices: [0], value: 123n }); + + source.applyLocalEdits(edits); + const chunk = source.chunks.get("0,0,0") as unknown as MockChunk; + + expect(chunk.updateFromCpuData).toHaveBeenCalledWith(glMock); + expect(visibleChunksChangedMock.dispatch).toHaveBeenCalled(); + }); + + it("Updates existing chunk data", () => { + const source = createSource(DataType.UINT64); + source.applyLocalEdits(new Map([["0,0,0", { indices: [0], value: 123n }]])); + + const chunk = source.chunks.get("0,0,0") as unknown as MockChunk; + chunk.updateFromCpuData.mockClear(); + visibleChunksChangedMock.dispatch.mockClear(); + + source.applyLocalEdits(new Map([["0,0,0", { indices: [0], value: 456n }]])); + + expect(chunk.data[0]).toBe(456n); + expect(chunk.updateFromCpuData).toHaveBeenCalledWith(glMock); + expect(visibleChunksChangedMock.dispatch).toHaveBeenCalled(); + }); +}); diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 87b4713b85..239f630a38 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -32,6 +32,6 @@ - src/sliceview/volume/backend.ts - [x] applyEdits - src/sliceview/volume/frontend.ts - - [ ] applyLocalEdits + - [x] applyLocalEdits - src/datasource/zarr/backend.ts - [ ] writeChunk From d68342a95f2c6d399b2104d96bae01a4800f461c Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 24 Nov 2025 11:45:58 +0100 Subject: [PATCH 141/251] test(voxel-annotation): add unit tests for floodFillPlane2D and paintBrushWithShape in VoxelEditController --- src/voxel_annotation/TODOs.md | 8 +- src/voxel_annotation/edit_controller.spec.ts | 308 +++++++++++++++++++ 2 files changed, 314 insertions(+), 2 deletions(-) create mode 100644 src/voxel_annotation/edit_controller.spec.ts diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 239f630a38..2f8d4b7525 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -2,6 +2,10 @@ ### priority +- finish the unit tests list below +- add integration tests +- fix the security flaw (see PR) + ### later - add preview for the undo/redo @@ -23,8 +27,8 @@ - [x] undo/redo - [x] flushPending - src/voxel_annotation/edit_controller.ts - - [ ] floodFillPlane2D - - [ ] paintBrushWithShape + - [x] floodFillPlane2D + - [x] paintBrushWithShape - src/layer/vox/index.ts - [x] getVoxelPositionFromMouse - [x] setVoxelPaintValue diff --git a/src/voxel_annotation/edit_controller.spec.ts b/src/voxel_annotation/edit_controller.spec.ts new file mode 100644 index 0000000000..ed02c9caeb --- /dev/null +++ b/src/voxel_annotation/edit_controller.spec.ts @@ -0,0 +1,308 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { vec3 } from "#src/util/geom.js"; +import { + BrushShape, + VOX_EDIT_COMMIT_VOXELS_RPC_ID, +} from "#src/voxel_annotation/base.js"; +import { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; +import type { RPC } from "#src/worker_rpc.js"; + +const mockRpc = { + invoke: vi.fn(), + newId: () => 0, + register: vi.fn(), + set: vi.fn(), + get: vi.fn(), + delete: vi.fn(), +} as unknown as RPC; + +class MockVolumeSource { + rpcId = 100; + spec = { + chunkDataSize: new Uint32Array([100, 100, 100]), + rank: 3, + }; + + dataMap = new Map(); + + getEnsuredValueAt = vi.fn(async (pos: Float32Array) => { + const key = `${Math.round(pos[0])},${Math.round(pos[1])},${Math.round(pos[2])}`; + return this.dataMap.get(key) ?? 0n; + }); + + computeChunkIndices(voxelCoord: Float32Array) { + return { + chunkGridPosition: new Float32Array([0, 0, 0]), + positionWithinChunk: new Uint32Array([ + voxelCoord[0], + voxelCoord[1], + voxelCoord[2], + ]), + }; + } + + chunkToMultiscaleTransform = new Float32Array(16).fill(0); + + applyLocalEdits = vi.fn(); +} + +describe("VoxelEditController", () => { + let controller: VoxelEditController; + let mockPrimarySource: MockVolumeSource; + let mockPreviewSource: MockVolumeSource; + + beforeEach(() => { + vi.clearAllMocks(); + mockPrimarySource = new MockVolumeSource(); + mockPreviewSource = new MockVolumeSource(); + + mockPrimarySource.chunkToMultiscaleTransform[0] = 1; + mockPrimarySource.chunkToMultiscaleTransform[5] = 1; + mockPrimarySource.chunkToMultiscaleTransform[10] = 1; + mockPrimarySource.chunkToMultiscaleTransform[15] = 1; + + const host = { + rpc: mockRpc, + primarySource: { + rank: 3, + getSources: () => [ + [ + { + chunkSource: mockPrimarySource, + chunkToMultiscaleTransform: + mockPrimarySource.chunkToMultiscaleTransform, + }, + ], + ], + } as any, + previewSource: { + getSources: () => [ + [ + { + chunkSource: mockPreviewSource, + chunkToMultiscaleTransform: + mockPrimarySource.chunkToMultiscaleTransform, + }, + ], + ], + } as any, + }; + + controller = new VoxelEditController(host); + (mockRpc.invoke as any).mockClear(); + }); + + describe("paintBrushWithShape", () => { + it("paints a 3D Sphere correctly", () => { + const center = new Float32Array([10, 10, 10]); + const radius = 2; + const value = 5n; + + controller.paintBrushWithShape( + center, + radius, + value, + BrushShape.SPHERE, + undefined, + ); + + expect(mockRpc.invoke).toHaveBeenCalledWith( + VOX_EDIT_COMMIT_VOXELS_RPC_ID, + expect.objectContaining({ + edits: expect.any(Array), + }), + ); + + const calls = (mockRpc.invoke as any).mock.calls; + const commitCall = calls.find( + (c: any[]) => c[0] === VOX_EDIT_COMMIT_VOXELS_RPC_ID, + ); + expect(commitCall).toBeDefined(); + + const args = commitCall[1]; + const edits = args.edits; + + expect(edits.length).toBeGreaterThan(0); + const indicesSet = new Set(edits[0].indices); + + const getIdx = (x: number, y: number, z: number) => + z * 10000 + y * 100 + x; + + expect(indicesSet.has(getIdx(10, 10, 10))).toBe(true); + expect(indicesSet.has(getIdx(12, 10, 10))).toBe(true); + expect(indicesSet.has(getIdx(11, 11, 10))).toBe(true); + expect(indicesSet.has(getIdx(12, 11, 10))).toBe(false); + }); + + it("paints a 2D Disk aligned to basis vectors", () => { + const center = new Float32Array([10, 10, 5]); + const radius = 2; + const value = 3n; + const basis = { + u: new Float32Array([1, 0, 0]), + v: new Float32Array([0, 1, 0]), + }; + + controller.paintBrushWithShape( + center, + radius, + value, + BrushShape.DISK, + basis, + ); + + const calls = (mockRpc.invoke as any).mock.calls; + const commitCall = calls.find( + (c: any[]) => c[0] === VOX_EDIT_COMMIT_VOXELS_RPC_ID, + ); + expect(commitCall).toBeDefined(); + + const args = commitCall[1]; + const edits = args.edits; + const indices = edits[0].indices; + + for (const idx of indices) { + const z = Math.floor(idx / 10000); + expect(z).toBe(5); + } + + expect(indices.length).toBeGreaterThan(0); + }); + }); + + describe("floodFillPlane2D", () => { + it("fills a bounded region (The Bucket)", async () => { + mockPrimarySource.dataMap.clear(); + + for (let x = 1; x <= 5; x++) { + mockPrimarySource.dataMap.set(`${x},1,0`, 1n); + mockPrimarySource.dataMap.set(`${x},5,0`, 1n); + } + for (let y = 1; y <= 5; y++) { + mockPrimarySource.dataMap.set(`1,${y},0`, 1n); + mockPrimarySource.dataMap.set(`5,${y},0`, 1n); + } + + const seed = new Float32Array([3, 3, 0]); + const fillValue = 5n; + const maxVoxels = 100; + const planeNormal = vec3.fromValues(0, 0, 1); + + const result = await controller.floodFillPlane2D( + seed, + fillValue, + maxVoxels, + planeNormal, + ); + + expect(result.filledCount).toBe(9); + + expect(mockRpc.invoke).toHaveBeenCalledWith( + VOX_EDIT_COMMIT_VOXELS_RPC_ID, + expect.anything(), + ); + }); + + it("respects the plane constraint", async () => { + const seed = new Float32Array([50, 50, 5]); + const maxVoxels = 20; + const planeNormal = vec3.fromValues(0, 0, 1); + + await expect( + controller.floodFillPlane2D(seed, 2n, maxVoxels, planeNormal), + ).rejects.toThrow(/exceeds the limit/); + + mockPrimarySource.dataMap.set("51,50,5", 1n); + mockPrimarySource.dataMap.set("49,50,5", 1n); + mockPrimarySource.dataMap.set("50,51,5", 1n); + mockPrimarySource.dataMap.set("50,49,5", 1n); + + const result = await controller.floodFillPlane2D( + seed, + 2n, + 100, + planeNormal, + ); + + expect(result.filledCount).toBe(1); + expect(result.edits[0].indices.length).toBe(1); + + const idx = result.edits[0].indices[0]; + const z = Math.floor(idx / 10000); + expect(z).toBe(5); + }); + + it("throws when max voxels exceeded", async () => { + const seed = new Float32Array([10, 10, 0]); + const maxVoxels = 10; + + await expect( + controller.floodFillPlane2D( + seed, + 9n, + maxVoxels, + vec3.fromValues(0, 0, 1), + ), + ).rejects.toThrow("Flood fill region exceeds the limit"); + }); + + it("does nothing if seed value equals fill value", async () => { + mockPrimarySource.dataMap.set("10,10,0", 5n); + const seed = new Float32Array([10, 10, 0]); + + const result = await controller.floodFillPlane2D( + seed, + 5n, + 100, + vec3.fromValues(0, 0, 1), + ); + + expect(result.filledCount).toBe(0); + expect(result.edits.length).toBe(0); + expect(mockRpc.invoke).not.toHaveBeenCalledWith( + VOX_EDIT_COMMIT_VOXELS_RPC_ID, + expect.anything(), + ); + }); + + it("prevents leak through small gaps using morphological thickening", async () => { + // Override config to trigger thickening early + (controller as any).morphologicalConfig = { + growthThresholds: [{ count: 10, size: 3 }], + maxSize: 9, + }; + + mockPrimarySource.dataMap.clear(); + + const size = 20; + for (let i = 0; i <= size; i++) { + mockPrimarySource.dataMap.set(`${i},0,0`, 1n); + mockPrimarySource.dataMap.set(`${i},${size},0`, 1n); + mockPrimarySource.dataMap.set(`0,${i},0`, 1n); + if (i !== 10) { + mockPrimarySource.dataMap.set(`${size},${i},0`, 1n); + } + } + + const seed = new Float32Array([10, 10, 0]); + const fillValue = 2n; + const maxVoxels = 2000; + const planeNormal = vec3.fromValues(0, 0, 1); + + const result = await controller.floodFillPlane2D( + seed, + fillValue, + maxVoxels, + planeNormal, + ); + + expect(result.filledCount).toBeLessThan(1000); + expect(result.filledCount).toBeGreaterThan(300); + + expect(mockRpc.invoke).toHaveBeenCalledWith( + VOX_EDIT_COMMIT_VOXELS_RPC_ID, + expect.anything(), + ); + }); + }); +}); From 1a58e55cd9914170fd016e05d85fd881ddba55be Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Tue, 25 Nov 2025 14:51:43 +0100 Subject: [PATCH 142/251] feat(voxel-annotation): add user confirmation prompt for write actions and refactor controller access --- src/layer/vox/index.ts | 101 +++++++++++++++++++++++++++++++++--- src/ui/voxel_annotations.ts | 16 ++---- 2 files changed, 99 insertions(+), 18 deletions(-) diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 5124b9d954..0c134e83cb 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -83,7 +83,9 @@ export class VoxelEditingContext extends RefCounted implements VoxelEditControllerHost { - controller: VoxelEditController | undefined = undefined; + private readonly _controller: VoxelEditController | undefined = undefined; + private _pendingPermissionPromise: Promise | undefined; + private hasUserConfirmedWriting = false; private cachedChunkTransform: ChunkTransformParameters | undefined; private cachedTransformGeneration: number = -1; @@ -99,6 +101,7 @@ export class VoxelEditingContext public primarySource: MultiscaleVolumeChunkSource, public primaryRenderLayer: ImageRenderLayer | SegmentationRenderLayer, public writable: boolean, + public dataSourceUrl: string | undefined, ) { super(); @@ -140,7 +143,90 @@ export class VoxelEditingContext this.hostLayer.addRenderLayer(this.optimisticRenderLayer); - this.controller = new VoxelEditController(this); + this._controller = new VoxelEditController(this); + } + + private async checkPermission(): Promise { + if (this.hasUserConfirmedWriting) { + return true; + } + if (this._pendingPermissionPromise) { + return this._pendingPermissionPromise; + } + + this._pendingPermissionPromise = new Promise((resolve) => { + const msg = new StatusMessage(/*delay=*/ false, /*modal=*/ true); + msg.element.textContent = `Are you sure you want to write to ${this.dataSourceUrl} `; + + const yes = document.createElement("button"); + yes.textContent = "Yes"; + yes.onclick = () => { + this.hasUserConfirmedWriting = true; + msg.dispose(); + resolve(true); + }; + const no = document.createElement("button"); + no.textContent = "No"; + no.onclick = () => { + msg.dispose(); + resolve(false); + }; + msg.element.appendChild(yes); + msg.element.appendChild(no); + msg.setVisible(true); + }).then((result) => { + this._pendingPermissionPromise = undefined; + return result; + }); + + return this._pendingPermissionPromise; + } + + async paintBrushWithShape( + centerCanonical: Float32Array, + radiusCanonical: number, + value: bigint, + shape: BrushShape, + basis?: { u: Float32Array; v: Float32Array }, + ) { + if (await this.checkPermission()) { + this._controller?.paintBrushWithShape( + centerCanonical, + radiusCanonical, + value, + shape, + basis, + ); + } + } + + async floodFillPlane2D( + startPositionCanonical: Float32Array, + fillValue: bigint, + maxVoxels: number, + planeNormal: vec3, + ) { + if (await this.checkPermission()) { + return this._controller?.floodFillPlane2D( + startPositionCanonical, + fillValue, + maxVoxels, + planeNormal, + ); + } + return undefined; + } + + async undo() { + if (await this.checkPermission()) { + this._controller?.undo(); + } + } + + async redo() { + if (await this.checkPermission()) { + this._controller?.redo(); + } } get rpc() { @@ -148,7 +234,7 @@ export class VoxelEditingContext } disposed() { - if (this.controller) this.controller.dispose(); + if (this._controller) this._controller.dispose(); if (this.optimisticRenderLayer) this.hostLayer.removeRenderLayer(this.optimisticRenderLayer); super.disposed(); @@ -443,6 +529,7 @@ export function UserLayerWithVoxelEditingMixin< primarySource, renderlayer, writable, + loadedSubsource.loadedDataSource.dataSource.canonicalUrl, ); this.editingContexts.set(loadedSubsource, context); this.isEditable.value = writable; @@ -486,15 +573,15 @@ export function UserLayerWithVoxelEditingMixin< } handleVoxAction(action: string, _context: LayerActionContext): void { - const firstContext = this.editingContexts.values().next().value; + const firstContext = this.editingContexts.values().next() + .value as VoxelEditingContext; if (!firstContext) return; - const controller = firstContext.controller; switch (action) { case "undo": - controller.undo(); + void firstContext.undo(); break; case "redo": - controller.redo(); + void firstContext.redo(); break; case "randomize-paint-value": this.setVoxelPaintValue(randomUint64()); diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 1d332e6f95..d4005bf230 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -394,14 +394,9 @@ export class VoxelBrushTool extends BaseVoxelTool { basis = { u, v }; } - for (const p of points) - editContext.controller?.paintBrushWithShape( - p, - radius, - value, - shapeEnum, - basis, - ); + for (const p of points) { + void editContext.paintBrushWithShape(p, radius, value, shapeEnum, basis); + } } } @@ -460,9 +455,8 @@ export class VoxelFloodFillTool extends BaseVoxelTool { if (!Number.isFinite(max) || max <= 0) { throw new Error("Invalid max fill voxels setting"); } - if (!editContext.controller) - throw new Error("Error: No controller available"); - editContext.controller + + void editContext .floodFillPlane2D( new Float32Array(seed), value, From 2452a4eedeb6e6d9dea54f2c68ef236cedd5eb8c Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Tue, 25 Nov 2025 16:51:27 +0100 Subject: [PATCH 143/251] chore: dev utilities --- .dockerignore | 12 ++++++++++++ .mpp.txt | 3 +++ Dockerfile | 43 +++++++++++++++++++++++++++++++++++++++++++ run_docker_tests.sh | 20 ++++++++++++++++++++ 4 files changed, 78 insertions(+) create mode 100644 .dockerignore create mode 100644 .mpp.txt create mode 100644 Dockerfile create mode 100755 run_docker_tests.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..56665950cf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +dist +.git +.idea +.vscode +test-results +playwright-report +blob-report +.venv +**/.venv +**/__pycache__ +*.pyc \ No newline at end of file diff --git a/.mpp.txt b/.mpp.txt new file mode 100644 index 0000000000..314a7bc969 --- /dev/null +++ b/.mpp.txt @@ -0,0 +1,3 @@ +base: -i src/datasource/* src/kvstore/* src/voxel_annotation/* src/layer/* src/layer/*/* src/ui/voxel_annotations.ts src/sliceview/* src/chunk_manager/* src/sliceview/volume/* src/*.ts README.md tsconfig.json tslint.json package.json +zarr: -i src/datasource/* src/kvstore/* src/voxel_annotation/* src/layer/* src/layer/*/* src/ui/voxel_annotations.ts src/datasource/zarr/* src/datasource/zarr/*/* src/sliceview/* src/chunk_manager/* src/sliceview/volume/* src/*.ts README.md tsconfig.json tslint.json package.json +test: -i src/datasource/* src/kvstore/* src/voxel_annotation/* src/layer/* src/layer/*/* src/ui/voxel_annotations.ts src/datasource/zarr/* src/datasource/zarr/*/* src/sliceview/* src/chunk_manager/* src/sliceview/volume/* tests/*/* src/*.ts .github/*/* vitest.workspace.ts playwright.config.ts README.md tsconfig.json tslint.json package.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..2d84f003da --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# Use the official Playwright image with the version matching package.json +# syntax=docker/dockerfile:1 +FROM mcr.microsoft.com/playwright:v1.50.1-jammy + +# Set environment variables +ENV CI=true +ENV DEBIAN_FRONTEND=noninteractive +ENV NODE_OPTIONS="--dns-result-order=ipv4first" + +# Install Node.js 22 optimized by copying from a pre-built image +COPY --from=node:22-bookworm-slim /usr/local/bin/node /usr/local/bin/node +COPY --from=node:22-bookworm-slim /usr/local/lib/node_modules /usr/local/lib/node_modules + +# Install uv (required for python tests/fixtures) +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Install Go (required for fake-gcs-server) +COPY --from=golang:1.24-bookworm /usr/local/go /usr/local/go +ENV PATH="/usr/local/go/bin:${PATH}" + +RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ + ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx && \ + npm install -g pnpm + +# Set working directory +WORKDIR /app + +# Copy package files first to leverage cache +COPY package.json package-lock.json ./ + +# Copy build_tools as it is required for the prepare script during npm ci +COPY build_tools ./build_tools + +# Install dependencies using cache mount for npm +RUN --mount=type=cache,target=/root/.npm \ + npm ci + +# Copy the rest of the application code +COPY . . + +# Default command to run Playwright tests +ENTRYPOINT ["npm", "run", "test"] +CMD [] diff --git a/run_docker_tests.sh b/run_docker_tests.sh new file mode 100755 index 0000000000..eb838925ef --- /dev/null +++ b/run_docker_tests.sh @@ -0,0 +1,20 @@ +set -e + +export DOCKER_BUILDKIT=1 + +IMAGE_NAME="neuroglancer-playwright-runner" + +echo "Building Docker image: $IMAGE_NAME..." +echo " (Note: First build may be slow. Subsequent builds use cache.)" + +docker build -t $IMAGE_NAME . + +echo "Running Playwright tests with arguments: $@" + +mkdir -p playwright-report test-results + +docker run --rm \ + -v "$(pwd)/playwright-report:/app/playwright-report" \ + -v "$(pwd)/test-results:/app/test-results" \ + --ipc=host \ + $IMAGE_NAME "$@" From bdbc575906125e1ae582765d9b75fcf85c04e0fa Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 26 Nov 2025 16:35:41 +0100 Subject: [PATCH 144/251] test(voxel-annotation): finally able to run browser_test and create one... NixOS is *sometimes* a *bit* annoying --- Dockerfile | 15 +- .../pipeline_zarr_s3.browser_test.ts | 219 ++++++++++++++++++ 2 files changed, 222 insertions(+), 12 deletions(-) create mode 100644 tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts diff --git a/Dockerfile b/Dockerfile index 2d84f003da..95cfb0b05c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,15 @@ -# Use the official Playwright image with the version matching package.json -# syntax=docker/dockerfile:1 FROM mcr.microsoft.com/playwright:v1.50.1-jammy -# Set environment variables ENV CI=true ENV DEBIAN_FRONTEND=noninteractive ENV NODE_OPTIONS="--dns-result-order=ipv4first" +ENV UV_PYTHON=3.12 -# Install Node.js 22 optimized by copying from a pre-built image COPY --from=node:22-bookworm-slim /usr/local/bin/node /usr/local/bin/node COPY --from=node:22-bookworm-slim /usr/local/lib/node_modules /usr/local/lib/node_modules -# Install uv (required for python tests/fixtures) COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv -# Install Go (required for fake-gcs-server) COPY --from=golang:1.24-bookworm /usr/local/go /usr/local/go ENV PATH="/usr/local/go/bin:${PATH}" @@ -22,22 +17,18 @@ RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx && \ npm install -g pnpm -# Set working directory WORKDIR /app -# Copy package files first to leverage cache COPY package.json package-lock.json ./ -# Copy build_tools as it is required for the prepare script during npm ci COPY build_tools ./build_tools -# Install dependencies using cache mount for npm RUN --mount=type=cache,target=/root/.npm \ npm ci -# Copy the rest of the application code +RUN cd build_tools/vitest/python_tools && uv sync --frozen + COPY . . -# Default command to run Playwright tests ENTRYPOINT ["npm", "run", "test"] CMD [] diff --git a/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts b/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts new file mode 100644 index 0000000000..06bbb83202 --- /dev/null +++ b/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts @@ -0,0 +1,219 @@ +import "#src/datasource/zarr/register_default.js"; +import "#src/kvstore/s3/register_frontend.js"; +import "#src/sliceview/uncompressed_chunk_format.js"; +import "#src/layer/segmentation/index.js"; + +import { http, HttpResponse } from "msw"; +import { test, beforeEach, afterEach } from "vitest"; +import { DisplayContext } from "#src/display_context.js"; +import { makeLayer } from "#src/layer/index.js"; +import type { + VoxelEditingContext, + UserLayerWithVoxelEditing, +} from "#src/layer/vox/index.js"; +import { Viewer } from "#src/viewer.js"; +import { mswFixture } from "#tests/fixtures/msw"; + +const msw = mswFixture(); +let viewer: Viewer | undefined; + +beforeEach(() => { + const display = new DisplayContext(document.createElement("div")); + viewer = new Viewer(display, { + showLayerDialog: false, + resetStateWhenEmpty: false, + }); +}); + +afterEach(() => { + if (viewer) { + viewer.dispose(); + viewer = undefined; + } +}); + +async function poll( + condition: () => boolean | Promise, + what: string, + timeout = 20000, +) { + const start = Date.now(); + while (Date.now() - start < timeout) { + if (await condition()) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + if (viewer) { + const layer = viewer.layerManager.managedLayers[0]?.layer; + if (layer) { + console.log(`[Debug] Layer messages:`, layer.messages); + if (layer.dataSources.length > 0) { + const ds = layer.dataSources[0]; + console.log(`[Debug] DataSource spec url:`, ds.spec.url); + if (ds.loadState) { + if (ds.loadState.error) { + console.log(`[Debug] DataSource load error:`, ds.loadState.error); + } else { + console.log( + `[Debug] DataSource loaded. Subsources:`, + ds.loadState.subsources.length, + ); + ds.loadState.subsources.forEach((sub, i) => { + console.log( + `[Debug] Subsource ${i} enabled=${sub.enabled} active=${!!sub.activated} messages=`, + sub.messages, + ); + }); + } + } else { + console.log(`[Debug] DataSource loadState is undefined (loading?)`); + } + } + } else { + console.log(`[Debug] No layer found in viewer`); + } + } + + throw new Error("Timeout polling condition: " + what); +} + +function parseBucketKey( + urlStr: string, +): { bucket: string; key: string } | null { + const url = new URL(urlStr); + const path = url.pathname.slice(1); + const parts = path.split("/"); + if (parts.length < 2) return null; + return { bucket: parts[0], key: parts.slice(1).join("/") }; +} + +test("Pipeline: Undo/Redo (Zarr V2)", async () => { + const storage = new Map(); + const baseUrl = "http://localhost:9000"; + + (await msw()).use( + http.put(`${baseUrl}/*`, async ({ request }) => { + const parsed = parseBucketKey(request.url); + console.log(`[MSW] PUT ${request.url} -> ${parsed ? "OK" : "400"}`); + if (!parsed) return new HttpResponse(null, { status: 400 }); + const storageKey = `${parsed.bucket}/${parsed.key}`; + const buffer = await request.arrayBuffer(); + storage.set(storageKey, buffer); + return new HttpResponse(null, { status: 200 }); + }), + http.get(`${baseUrl}/*`, ({ request }) => { + const parsed = parseBucketKey(request.url); + const storageKey = parsed ? `${parsed.bucket}/${parsed.key}` : ""; + const exists = storage.has(storageKey); + console.log(`[MSW] GET ${request.url} -> ${exists ? "200" : "404"}`); + + if (!parsed) return new HttpResponse(null, { status: 400 }); + const data = storage.get(storageKey); + if (!data) return new HttpResponse(null, { status: 404 }); + return new HttpResponse(data); + }), + http.head(`${baseUrl}/*`, ({ request }) => { + const parsed = parseBucketKey(request.url); + const storageKey = parsed ? `${parsed.bucket}/${parsed.key}` : ""; + const exists = storage.has(storageKey); + console.log(`[MSW] HEAD ${request.url} -> ${exists ? "200" : "404"}`); + + if (!parsed) return new HttpResponse(null, { status: 400 }); + const data = storage.get(storageKey); + if (!data) return new HttpResponse(null, { status: 404 }); + return new HttpResponse(null, { + status: 200, + headers: { + "Content-Length": data.byteLength.toString(), + }, + }); + }), + ); + + const BUCKET = "undo-redo-test"; + + const zarray = JSON.stringify({ + zarr_format: 2, + shape: [64, 64, 64], + chunks: [32, 32, 32], + dtype: "|u1", + fill_value: 0, + order: "C", + dimension_separator: ".", + compressor: null, + }); + storage.set( + `${BUCKET}/data.zarr/.zarray`, + new TextEncoder().encode(zarray).buffer, + ); + storage.set( + `${BUCKET}/data.zarr/.zgroup`, + new TextEncoder().encode("{}").buffer, + ); + + console.log( + `[Test] Setup storage with .zarray at ${BUCKET}/data.zarr/.zarray`, + ); + + const sourceUrl = `s3+http://localhost:9000/${BUCKET}/data.zarr`; + + if (!viewer) throw new Error("Viewer not initialized"); + + const layer = makeLayer(viewer.layerSpecification, "volume", { + type: "segmentation", + source: { + url: sourceUrl, + subsources: { + default: { enabled: true, writable: true }, + }, + enableDefaultSubsources: false, + }, + }); + + viewer.layerSpecification.add(layer); + + await poll(() => { + const userLayer = viewer!.layerManager.managedLayers[0] + ?.layer as UserLayerWithVoxelEditing; + return userLayer?.editingContexts?.size > 0; + }, "Wait for Editing Context"); + + const userLayer = viewer.layerManager.managedLayers[0] + .layer as UserLayerWithVoxelEditing; + const context = userLayer.editingContexts.values().next() + .value as VoxelEditingContext; + (context as any).hasUserConfirmedWriting = true; + + const center = new Float32Array([16, 16, 16]); + await context.paintBrushWithShape(center, 5, 100n, 0 /* DISK */, { + u: new Float32Array([1, 0, 0]), + v: new Float32Array([0, 1, 0]), + }); + + const chunkKey = `${BUCKET}/data.zarr/0.0.0`; + + await poll(() => { + const data = storage.get(chunkKey); + if (!data) return false; + const arr = new Uint8Array(data); + return arr.some((v) => v === 100); + }, "Verify painted chunk"); + + await (context as any)._controller.undo(); + + await poll(() => { + const data = storage.get(chunkKey); + if (!data) return true; + const arr = new Uint8Array(data); + return arr.every((v) => v === 0); + }, "Verify undo"); + + await (context as any)._controller.redo(); + + await poll(() => { + const data = storage.get(chunkKey); + if (!data) return false; + const arr = new Uint8Array(data); + return arr.some((v) => v === 100); + }, "Verify redo"); +}); From f7510f40fa39294fe13b6e155e533a7542b40677 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 26 Nov 2025 17:27:08 +0100 Subject: [PATCH 145/251] test(voxel-annotation): expand browser tests with additional scenarios for Zarr pipelines --- .../pipeline_zarr_s3.browser_test.ts | 356 ++++++++++++------ 1 file changed, 244 insertions(+), 112 deletions(-) diff --git a/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts b/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts index 06bbb83202..050d3b60a1 100644 --- a/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts +++ b/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts @@ -2,6 +2,7 @@ import "#src/datasource/zarr/register_default.js"; import "#src/kvstore/s3/register_frontend.js"; import "#src/sliceview/uncompressed_chunk_format.js"; import "#src/layer/segmentation/index.js"; +import "#src/layer/image/index.js"; import { http, HttpResponse } from "msw"; import { test, beforeEach, afterEach } from "vitest"; @@ -11,90 +12,26 @@ import type { VoxelEditingContext, UserLayerWithVoxelEditing, } from "#src/layer/vox/index.js"; +import { vec3 } from "#src/util/geom.js"; import { Viewer } from "#src/viewer.js"; import { mswFixture } from "#tests/fixtures/msw"; const msw = mswFixture(); let viewer: Viewer | undefined; +const storage = new Map(); +const baseUrl = "http://localhost:9000"; -beforeEach(() => { +beforeEach(async () => { + storage.clear(); const display = new DisplayContext(document.createElement("div")); viewer = new Viewer(display, { showLayerDialog: false, resetStateWhenEmpty: false, }); -}); - -afterEach(() => { - if (viewer) { - viewer.dispose(); - viewer = undefined; - } -}); - -async function poll( - condition: () => boolean | Promise, - what: string, - timeout = 20000, -) { - const start = Date.now(); - while (Date.now() - start < timeout) { - if (await condition()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - - if (viewer) { - const layer = viewer.layerManager.managedLayers[0]?.layer; - if (layer) { - console.log(`[Debug] Layer messages:`, layer.messages); - if (layer.dataSources.length > 0) { - const ds = layer.dataSources[0]; - console.log(`[Debug] DataSource spec url:`, ds.spec.url); - if (ds.loadState) { - if (ds.loadState.error) { - console.log(`[Debug] DataSource load error:`, ds.loadState.error); - } else { - console.log( - `[Debug] DataSource loaded. Subsources:`, - ds.loadState.subsources.length, - ); - ds.loadState.subsources.forEach((sub, i) => { - console.log( - `[Debug] Subsource ${i} enabled=${sub.enabled} active=${!!sub.activated} messages=`, - sub.messages, - ); - }); - } - } else { - console.log(`[Debug] DataSource loadState is undefined (loading?)`); - } - } - } else { - console.log(`[Debug] No layer found in viewer`); - } - } - - throw new Error("Timeout polling condition: " + what); -} - -function parseBucketKey( - urlStr: string, -): { bucket: string; key: string } | null { - const url = new URL(urlStr); - const path = url.pathname.slice(1); - const parts = path.split("/"); - if (parts.length < 2) return null; - return { bucket: parts[0], key: parts.slice(1).join("/") }; -} - -test("Pipeline: Undo/Redo (Zarr V2)", async () => { - const storage = new Map(); - const baseUrl = "http://localhost:9000"; (await msw()).use( http.put(`${baseUrl}/*`, async ({ request }) => { const parsed = parseBucketKey(request.url); - console.log(`[MSW] PUT ${request.url} -> ${parsed ? "OK" : "400"}`); if (!parsed) return new HttpResponse(null, { status: 400 }); const storageKey = `${parsed.bucket}/${parsed.key}`; const buffer = await request.arrayBuffer(); @@ -104,10 +41,6 @@ test("Pipeline: Undo/Redo (Zarr V2)", async () => { http.get(`${baseUrl}/*`, ({ request }) => { const parsed = parseBucketKey(request.url); const storageKey = parsed ? `${parsed.bucket}/${parsed.key}` : ""; - const exists = storage.has(storageKey); - console.log(`[MSW] GET ${request.url} -> ${exists ? "200" : "404"}`); - - if (!parsed) return new HttpResponse(null, { status: 400 }); const data = storage.get(storageKey); if (!data) return new HttpResponse(null, { status: 404 }); return new HttpResponse(data); @@ -115,10 +48,6 @@ test("Pipeline: Undo/Redo (Zarr V2)", async () => { http.head(`${baseUrl}/*`, ({ request }) => { const parsed = parseBucketKey(request.url); const storageKey = parsed ? `${parsed.bucket}/${parsed.key}` : ""; - const exists = storage.has(storageKey); - console.log(`[MSW] HEAD ${request.url} -> ${exists ? "200" : "404"}`); - - if (!parsed) return new HttpResponse(null, { status: 400 }); const data = storage.get(storageKey); if (!data) return new HttpResponse(null, { status: 404 }); return new HttpResponse(null, { @@ -129,9 +58,55 @@ test("Pipeline: Undo/Redo (Zarr V2)", async () => { }); }), ); +}); + +afterEach(() => { + if (viewer) { + viewer.dispose(); + viewer = undefined; + } +}); + +async function poll( + condition: () => boolean | Promise, + what: string, + timeout = 5000, +) { + const start = Date.now(); + while (Date.now() - start < timeout) { + if (await condition()) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error("Timeout polling condition: " + what); +} + +function parseBucketKey( + urlStr: string, +): { bucket: string; key: string } | null { + const url = new URL(urlStr); + const path = url.pathname.slice(1); + const parts = path.split("/"); + if (parts.length < 2) return null; + return { bucket: parts[0], key: parts.slice(1).join("/") }; +} - const BUCKET = "undo-redo-test"; +async function waitForEditingContext() { + if (!viewer) throw new Error("Viewer not initialized"); + await poll(() => { + const userLayer = viewer!.layerManager.managedLayers[0] + ?.layer as UserLayerWithVoxelEditing; + return userLayer?.editingContexts?.size > 0; + }, "Wait for Editing Context"); + const userLayer = viewer.layerManager.managedLayers[0] + .layer as UserLayerWithVoxelEditing; + const context = userLayer.editingContexts.values().next() + .value as VoxelEditingContext; + (context as any).hasUserConfirmedWriting = true; + return { userLayer, context }; +} +test("Pipeline: Zarr V2 (UINT8) Undo/Redo with Brush", async () => { + const BUCKET = "test-v2-uint8"; const zarray = JSON.stringify({ zarr_format: 2, shape: [64, 64, 64], @@ -151,38 +126,17 @@ test("Pipeline: Undo/Redo (Zarr V2)", async () => { new TextEncoder().encode("{}").buffer, ); - console.log( - `[Test] Setup storage with .zarray at ${BUCKET}/data.zarr/.zarray`, - ); - - const sourceUrl = `s3+http://localhost:9000/${BUCKET}/data.zarr`; - - if (!viewer) throw new Error("Viewer not initialized"); - - const layer = makeLayer(viewer.layerSpecification, "volume", { - type: "segmentation", + const layer = makeLayer(viewer!.layerSpecification, "volume", { + type: "image", source: { - url: sourceUrl, - subsources: { - default: { enabled: true, writable: true }, - }, + url: `s3+http://localhost:9000/${BUCKET}/data.zarr`, + subsources: { default: { enabled: true, writable: true } }, enableDefaultSubsources: false, }, }); + viewer!.layerSpecification.add(layer); - viewer.layerSpecification.add(layer); - - await poll(() => { - const userLayer = viewer!.layerManager.managedLayers[0] - ?.layer as UserLayerWithVoxelEditing; - return userLayer?.editingContexts?.size > 0; - }, "Wait for Editing Context"); - - const userLayer = viewer.layerManager.managedLayers[0] - .layer as UserLayerWithVoxelEditing; - const context = userLayer.editingContexts.values().next() - .value as VoxelEditingContext; - (context as any).hasUserConfirmedWriting = true; + const { context } = await waitForEditingContext(); const center = new Float32Array([16, 16, 16]); await context.paintBrushWithShape(center, 5, 100n, 0 /* DISK */, { @@ -199,21 +153,199 @@ test("Pipeline: Undo/Redo (Zarr V2)", async () => { return arr.some((v) => v === 100); }, "Verify painted chunk"); - await (context as any)._controller.undo(); - + await context.undo(); await poll(() => { const data = storage.get(chunkKey); if (!data) return true; - const arr = new Uint8Array(data); - return arr.every((v) => v === 0); + return new Uint8Array(data).every((v) => v === 0); }, "Verify undo"); - await (context as any)._controller.redo(); + await context.redo(); + await poll(() => { + const data = storage.get(chunkKey); + if (!data) return false; + return new Uint8Array(data).some((v) => v === 100); + }, "Verify redo"); +}); + +test("Pipeline: Zarr V3 (UINT64) Brush", async () => { + const BUCKET = "test-v3-uint64"; + const zarrJson = JSON.stringify({ + zarr_format: 3, + node_type: "array", + shape: [64, 64, 64], + data_type: "uint64", + chunk_grid: { + name: "regular", + configuration: { chunk_shape: [32, 32, 32] }, + }, + chunk_key_encoding: { + name: "default", + configuration: { separator: "/" }, + }, + codecs: [{ name: "bytes", configuration: { endian: "little" } }], + fill_value: 0, + attributes: {}, + }); + + storage.set( + `${BUCKET}/data.zarr/zarr.json`, + new TextEncoder().encode(zarrJson).buffer, + ); + + const layer = makeLayer(viewer!.layerSpecification, "volume", { + type: "segmentation", + source: { + url: `s3+http://localhost:9000/${BUCKET}/data.zarr|zarr3:`, + subsources: { default: { enabled: true, writable: true } }, + enableDefaultSubsources: false, + }, + }); + viewer!.layerSpecification.add(layer); + + const { context } = await waitForEditingContext(); + + const center = new Float32Array([16, 16, 16]); + const paintVal = 123456789n; + await context.paintBrushWithShape(center, 2, paintVal, 0 /* DISK */, { + u: new Float32Array([1, 0, 0]), + v: new Float32Array([0, 1, 0]), + }); + + const chunkKey = `${BUCKET}/data.zarr/c/0/0/0`; + + await poll(() => { + const data = storage.get(chunkKey); + if (!data) return false; + const arr = new BigUint64Array(data); + return arr.some((v) => v === paintVal); + }, "Verify painted chunk (UINT64)"); +}); + +test("Pipeline: Zarr V2 (UINT32) with Slash Separator", async () => { + const BUCKET = "test-v2-sep"; + const zarray = JSON.stringify({ + zarr_format: 2, + shape: [64, 64, 64], + chunks: [32, 32, 32], + dtype: "new TextEncoder().encode(zarray).buffer, + ); + storage.set( + `${BUCKET}/data.zarr/.zgroup`, + new TextEncoder().encode("{}").buffer, + ); + + const layer = makeLayer(viewer!.layerSpecification, "volume", { + type: "segmentation", + source: { + url: `s3+http://localhost:9000/${BUCKET}/data.zarr`, + subsources: { default: { enabled: true, writable: true } }, + enableDefaultSubsources: false, + }, + }); + viewer!.layerSpecification.add(layer); + + const { context } = await waitForEditingContext(); + + const center = new Float32Array([10, 10, 10]); + const paintVal = 42n; + await context.paintBrushWithShape(center, 2, paintVal, 0 /* DISK */, { + u: new Float32Array([1, 0, 0]), + v: new Float32Array([0, 1, 0]), + }); + + const chunkKey = `${BUCKET}/data.zarr/0/0/0`; + + await poll(() => { + const data = storage.get(chunkKey); + if (!data) return false; + const arr = new Uint32Array(data); + return arr.some((v) => v === 42); + }, "Verify painted chunk with slash separator"); +}); + +test("Pipeline: Flood Fill (Zarr V2 UINT8 on img layer)", async () => { + const BUCKET = "test-flood-fill"; + const CHUNK_SIZE = 32; + const zarray = JSON.stringify({ + zarr_format: 2, + shape: [64, 64, 64], + chunks: [CHUNK_SIZE, CHUNK_SIZE, CHUNK_SIZE], + dtype: "|u1", + fill_value: 0, + order: "C", + dimension_separator: ".", + compressor: null, + }); + storage.set( + `${BUCKET}/data.zarr/.zarray`, + new TextEncoder().encode(zarray).buffer, + ); + storage.set( + `${BUCKET}/data.zarr/.zgroup`, + new TextEncoder().encode("{}").buffer, + ); + + const chunkData = new Uint8Array(CHUNK_SIZE * CHUNK_SIZE * CHUNK_SIZE); + // Create a hollow box from 5,5,0 to 25,25,0 in z=0 slice + for (let y = 5; y <= 25; y++) { + for (let x = 5; x <= 25; x++) { + if (x === 5 || x === 25 || y === 5 || y === 25) { + const index = 0 * CHUNK_SIZE * CHUNK_SIZE + y * CHUNK_SIZE + x; + chunkData[index] = 255; + } + } + } + const chunkKey = `${BUCKET}/data.zarr/0.0.0`; + storage.set(chunkKey, chunkData.buffer); + + const layer = makeLayer(viewer!.layerSpecification, "volume", { + type: "image", + source: { + url: `s3+http://localhost:9000/${BUCKET}/data.zarr`, + subsources: { default: { enabled: true, writable: true } }, + enableDefaultSubsources: false, + }, + }); + viewer!.layerSpecification.add(layer); + + const { context } = await waitForEditingContext(); + + const seed = new Float32Array([15, 15, 0]); + const fillValue = 128n; + const maxVoxels = 1000; + const planeNormal = vec3.fromValues(0, 0, 1); + + await poll( + async () => { + try { + await context.floodFillPlane2D(seed, fillValue, maxVoxels, planeNormal); + return true; + } catch (e: any) { + if (e.message.includes("unloaded")) { + return false; + } + throw e; + } + }, + "Execute flood fill", + 5000, + ); await poll(() => { const data = storage.get(chunkKey); if (!data) return false; const arr = new Uint8Array(data); - return arr.some((v) => v === 100); - }, "Verify redo"); + const insideIndex = 0 * CHUNK_SIZE * CHUNK_SIZE + 15 * CHUNK_SIZE + 15; + const outsideIndex = 0 * CHUNK_SIZE * CHUNK_SIZE + 2 * CHUNK_SIZE + 2; + return arr[insideIndex] === 128 && arr[outsideIndex] === 0; + }, "Verify flood fill result"); }); From 40f588b1267d6339ca19aabc36e34b97a03e96d4 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 27 Nov 2025 12:46:48 +0100 Subject: [PATCH 146/251] test(voxel-annotation): migrate unit tests to browser tests for layer/vox/index.ts --- src/layer/vox/index.browser_test.ts | 422 ++++++++++++++++++++++++++++ src/layer/vox/index.spec.ts | 365 ------------------------ 2 files changed, 422 insertions(+), 365 deletions(-) create mode 100644 src/layer/vox/index.browser_test.ts delete mode 100644 src/layer/vox/index.spec.ts diff --git a/src/layer/vox/index.browser_test.ts b/src/layer/vox/index.browser_test.ts new file mode 100644 index 0000000000..ad89048a28 --- /dev/null +++ b/src/layer/vox/index.browser_test.ts @@ -0,0 +1,422 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import type { ChunkManager } from "#src/chunk_manager/frontend.js"; +import type { CoordinateSpaceTransform } from "#src/coordinate_transform.js"; +import { + makeCoordinateSpace, + makeIdentityTransform, +} from "#src/coordinate_transform.js"; +import { getDefaultCredentialsManager } from "#src/credentials_provider/default_manager.js"; +import { SharedCredentialsManager } from "#src/credentials_provider/shared.js"; +import { DataManagementContext } from "#src/data_management_context.js"; +import { + DataSourceRegistry, + makeEmptyDataSourceSpecification, +} from "#src/datasource/index.js"; +import { DisplayContext } from "#src/display_context.js"; +import { SharedKvStoreContext } from "#src/kvstore/frontend.js"; +import { + LayerManager, + LayerSelectedValues, + ManagedUserLayer, + MouseSelectionState, + SelectedLayerState, + TopLevelLayerListSpecification, + TrackableDataSelectionState, +} from "#src/layer/index.js"; +import { + LayerDataSource, + LoadedDataSubsource, + LoadedLayerDataSource, +} from "#src/layer/layer_data_source.js"; +import { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; +import { Position } from "#src/navigation_state.js"; +import { + DataType, + VolumeType, + makeVolumeChunkSpecification, +} from "#src/sliceview/volume/base.js"; +import { + InMemoryVolumeChunkSource, + MultiscaleVolumeChunkSource, +} from "#src/sliceview/volume/frontend.js"; +import { SegmentationRenderLayer } from "#src/sliceview/volume/segmentation_renderlayer.js"; +import { WatchableValue } from "#src/trackable_value.js"; +import { GlobalToolBinder } from "#src/ui/tool.js"; +import { mat4, vec3 } from "#src/util/geom.js"; +import "#src/sliceview/uncompressed_chunk_format.js"; + +class TestMultiscaleSource extends MultiscaleVolumeChunkSource { + constructor( + chunkManager: ChunkManager, + public dataType: DataType, + ) { + super(chunkManager); + } + get volumeType() { + return VolumeType.SEGMENTATION; + } + get rank() { + return 3; + } + getSources(options: any) { + void options; + const spec = makeVolumeChunkSpecification({ + dataType: this.dataType, + chunkDataSize: Uint32Array.from([32, 32, 32]), + lowerVoxelBound: Float32Array.from([0, 0, 0]), + upperVoxelBound: Float32Array.from([100, 100, 100]), + rank: 3, + }); + const chunkSource = this.chunkManager.getChunkSource( + InMemoryVolumeChunkSource, + { spec }, + ); + return [ + [ + { + chunkSource, + chunkToMultiscaleTransform: new Float32Array([ + 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, + ]), + }, + ], + ]; + } +} + +describe("Voxel Editing Utilities", () => { + let display: DisplayContext; + let dataContext: DataManagementContext; + + const createLayer = ( + dataType: DataType = DataType.UINT64, + modelTransform?: CoordinateSpaceTransform, + ) => { + const credentialsManager = new SharedCredentialsManager( + getDefaultCredentialsManager(), + dataContext.rpc, + ); + const kvStoreContext = new SharedKvStoreContext( + dataContext.chunkManager, + credentialsManager, + ); + const dataSourceProvider = new DataSourceRegistry(kvStoreContext); + + const layerManager = new LayerManager(); + const layerSelectedValues = new LayerSelectedValues( + layerManager, + new MouseSelectionState(), + ); + const coordinateSpace = new WatchableValue( + makeCoordinateSpace({ + names: ["x", "y", "z"], + units: ["m", "m", "m"], + scales: Float64Array.of(1, 1, 1), + }), + ); + const selectionState = new TrackableDataSelectionState( + coordinateSpace, + layerSelectedValues, + ); + const selectedLayer = new SelectedLayerState(layerManager); + const globalPosition = new Position(coordinateSpace); + const toolBinder = new GlobalToolBinder(() => {}, {} as any); + + const layerSpecification = new TopLevelLayerListSpecification( + display, + dataSourceProvider, + layerManager, + dataContext.chunkManager, + selectionState, + selectedLayer, + coordinateSpace, + globalPosition, + toolBinder, + ); + + const managedLayer = new ManagedUserLayer("test-layer", layerSpecification); + const userLayer = new SegmentationUserLayer(managedLayer); + managedLayer.layer = userLayer; + + const multiscaleSource = new TestMultiscaleSource( + dataContext.chunkManager, + dataType, + ); + + const dataSource = new LayerDataSource(userLayer); + const loadedLayerDataSource = new LoadedLayerDataSource( + dataSource, + { + canonicalUrl: "test", + modelTransform: + modelTransform ?? + makeIdentityTransform( + makeCoordinateSpace({ + names: ["x", "y", "z"], + scales: Float64Array.of(1, 1, 1), + units: ["m", "m", "m"], + }), + ), + subsources: [], + } as any, + makeEmptyDataSourceSpecification(), + ); + + userLayer.addCoordinateSpace(loadedLayerDataSource.transform.outputSpace); + + const subsourceEntry = { + id: "default", + default: true, + subsource: { volume: multiscaleSource }, + }; + + const loadedSubsource = new LoadedDataSubsource( + loadedLayerDataSource, + subsourceEntry, + undefined, + 0, + true, + ); + + let renderLayer: SegmentationRenderLayer | undefined; + + loadedSubsource.activate(() => { + const transform = loadedSubsource.getRenderLayerTransform(); + renderLayer = new SegmentationRenderLayer(multiscaleSource, { + ...userLayer.displayState, + transform, + renderScaleTarget: userLayer.sliceViewRenderScaleTarget, + renderScaleHistogram: userLayer.sliceViewRenderScaleHistogram, + localPosition: userLayer.localPosition, + }); + loadedSubsource.addRenderLayer(renderLayer); + + loadedSubsource.writable.value = true; + userLayer.initializeVoxelEditingForSubsource( + loadedSubsource, + renderLayer, + ); + }); + + if (!renderLayer) throw new Error("Failed to create renderLayer"); + + return { userLayer, loadedSubsource, renderLayer }; + }; + + beforeEach(() => { + display = new DisplayContext(document.createElement("div")); + dataContext = new DataManagementContext(display.gl, display); + }); + + afterEach(() => { + display.dispose(); + dataContext.dispose(); + }); + + describe("getVoxelPositionFromMouse", () => { + it("Success: returns mapped voxel position", () => { + const space = makeCoordinateSpace({ + names: ["x", "y", "z"], + scales: Float64Array.of(1, 1, 1), + units: ["m", "m", "m"], + }); + const transform = new Float32Array(16); + mat4.identity(transform as unknown as mat4); + mat4.translate( + transform as unknown as mat4, + transform as unknown as mat4, + [10, 5, 0], + ); + mat4.scale( + transform as unknown as mat4, + transform as unknown as mat4, + [2, 0.5, 1], + ); + + const modelTransform = { + inputSpace: space, + outputSpace: space, + transform: transform as unknown as Float64Array, + rank: 3, + sourceRank: 3, + }; + + const { userLayer, loadedSubsource } = createLayer( + DataType.UINT64, + modelTransform, + ); + const context = userLayer.editingContexts.get(loadedSubsource)!; + + const mouseState = new MouseSelectionState(); + mouseState.unsnappedPosition = Float32Array.of(20, 10, 5); + + const result = context.getVoxelPositionFromMouse(mouseState); + + expect(result).toBeDefined(); + expect(result![0]).toBeCloseTo(5); + expect(result![1]).toBeCloseTo(10); + expect(result![2]).toBeCloseTo(5); + }); + + it("Transform Error: returns undefined", () => { + const { userLayer, loadedSubsource, renderLayer } = createLayer( + DataType.UINT64, + ); + const context = userLayer.editingContexts.get(loadedSubsource)!; + + renderLayer.transform.value = { error: "Transform error" } as any; + + const mouseState = new MouseSelectionState(); + mouseState.unsnappedPosition = Float32Array.of(10, 10, 10); + + const result = context.getVoxelPositionFromMouse(mouseState); + expect(result).toBeUndefined(); + }); + + it("Out of Bounds: returns coordinate", () => { + const { userLayer, loadedSubsource } = createLayer(DataType.UINT64); + const context = userLayer.editingContexts.get(loadedSubsource)!; + + const mouseState = new MouseSelectionState(); + mouseState.unsnappedPosition = Float32Array.of(1000, 2000, 3000); + + const result = context.getVoxelPositionFromMouse(mouseState); + expect(result).toBeDefined(); + expect(result![0]).toBeCloseTo(1000); + expect(result![1]).toBeCloseTo(2000); + expect(result![2]).toBeCloseTo(3000); + }); + }); + + describe("transformGlobalToVoxelNormal", () => { + it("Uninitialized Cache: throws error", () => { + const { userLayer, loadedSubsource } = createLayer(DataType.UINT64); + const context = userLayer.editingContexts.get(loadedSubsource)!; + expect(() => { + context.transformGlobalToVoxelNormal(vec3.create()); + }).toThrow("Chunk transform not computed"); + }); + + it("Identity Transform: returns same vector", () => { + const { userLayer, loadedSubsource } = createLayer(DataType.UINT64); + const context = userLayer.editingContexts.get(loadedSubsource)!; + + const mouseState = new MouseSelectionState(); + mouseState.unsnappedPosition = Float32Array.of(10, 10, 10); + context.getVoxelPositionFromMouse(mouseState); + + const globalNormal = vec3.fromValues(1, 0, 0); + const result = context.transformGlobalToVoxelNormal(globalNormal); + + expect(result).toEqual(globalNormal); + }); + + it("Rotation/Permutation: transforms vector", () => { + const space = makeCoordinateSpace({ + names: ["x", "y", "z"], + scales: Float64Array.of(1, 1, 1), + units: ["m", "m", "m"], + }); + + const transform = new Float64Array([ + 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, + ]); + + const modelTransform = { + inputSpace: space, + outputSpace: space, + transform: transform, + rank: 3, + sourceRank: 3, + }; + + const { userLayer, loadedSubsource } = createLayer( + DataType.UINT64, + modelTransform, + ); + const context = userLayer.editingContexts.get(loadedSubsource)!; + + const mouseState = new MouseSelectionState(); + mouseState.unsnappedPosition = Float32Array.of(10, 10, 10); + context.getVoxelPositionFromMouse(mouseState); + + const globalNormal = vec3.fromValues(1, 0, 0); + const result = context.transformGlobalToVoxelNormal(globalNormal); + + expect(result[0]).toBeCloseTo(0); + expect(result[1]).toBeCloseTo(1); + expect(result[2]).toBeCloseTo(0); + }); + + it("Non-aligned Normal with Scaling: correctly transforms and normalizes", () => { + const space = makeCoordinateSpace({ + names: ["x", "y", "z"], + scales: Float64Array.of(1, 1, 1), + units: ["m", "m", "m"], + }); + + const transform = new Float64Array([ + 2, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, + ]); + + const modelTransform = { + inputSpace: space, + outputSpace: space, + transform: transform, + rank: 3, + sourceRank: 3, + }; + + const { userLayer, loadedSubsource } = createLayer( + DataType.UINT64, + modelTransform, + ); + const context = userLayer.editingContexts.get(loadedSubsource)!; + + const mouseState = new MouseSelectionState(); + mouseState.unsnappedPosition = Float32Array.of(10, 10, 10); + context.getVoxelPositionFromMouse(mouseState); + + const inputLen = Math.sqrt(2); + const globalNormal = vec3.fromValues(1 / inputLen, 1 / inputLen, 0); + + const result = context.transformGlobalToVoxelNormal(globalNormal); + + // transform(2,1,1) * (1,1,0) -> (2,1,0). Normalized -> (2,1,0)/sqrt(5). + const expectedX = 1 / Math.sqrt(5); + const expectedY = 2 / Math.sqrt(5); + + expect(result[0]).toBeCloseTo(expectedX); + expect(result[1]).toBeCloseTo(expectedY); + expect(result[2]).toBeCloseTo(0); + }); + }); + + describe("setVoxelPaintValue", () => { + it("UINT8: Clamps and wraps", () => { + const { userLayer } = createLayer(DataType.UINT8); + expect(userLayer.setVoxelPaintValue(255)).toBe(255n); + expect(userLayer.setVoxelPaintValue(256)).toBe(0n); + expect(userLayer.setVoxelPaintValue(-1)).toBe(255n); + }); + + it("INT8: Signed wrapping", () => { + const { userLayer } = createLayer(DataType.INT8); + expect(userLayer.setVoxelPaintValue(127)).toBe(127n); + expect(userLayer.setVoxelPaintValue(128)).toBe(-128n); + expect(userLayer.setVoxelPaintValue(-129)).toBe(127n); + }); + + it("UINT64: Handles BigInts", () => { + const { userLayer } = createLayer(DataType.UINT64); + const bigVal = BigInt(Number.MAX_SAFE_INTEGER) + 10n; + expect(userLayer.setVoxelPaintValue(bigVal)).toBe(bigVal); + }); + + it("No Context: Fails", () => { + const { userLayer } = createLayer(DataType.UINT64); + userLayer.editingContexts.clear(); + expect(() => userLayer.setVoxelPaintValue(1)).toThrow(); + }); + }); +}); diff --git a/src/layer/vox/index.spec.ts b/src/layer/vox/index.spec.ts deleted file mode 100644 index 1416f95d06..0000000000 --- a/src/layer/vox/index.spec.ts +++ /dev/null @@ -1,365 +0,0 @@ -import { - describe, - it, - expect, - vi, - beforeEach, - afterEach, - beforeAll, -} from "vitest"; -import { - getChunkPositionFromCombinedGlobalLocalPositions, - getChunkTransformParameters, -} from "#src/render_coordinate_transform.js"; -import { WatchableValue } from "#src/trackable_value.js"; -import { DataType } from "#src/util/data_type.js"; -import { RefCounted } from "#src/util/disposable.js"; -import { vec3 } from "#src/util/geom.js"; - -vi.mock("#src/render_coordinate_transform.js", () => ({ - getChunkTransformParameters: vi.fn(), - getChunkPositionFromCombinedGlobalLocalPositions: vi.fn(), -})); - -vi.mock("#src/voxel_annotation/edit_controller.js", () => ({ - VoxelEditController: class { - dispose() {} - }, -})); - -vi.mock("#src/voxel_annotation/PreviewMultiscaleChunkSource.js", () => ({ - VoxelPreviewMultiscaleSource: class { - getSources() { - return [[{ chunkToMultiscaleTransform: new Float32Array(16) }]]; - } - }, -})); - -vi.mock("#src/layer/index.js", () => ({ - UserLayer: class { - dataSources = []; - layersChanged = { add: () => {}, remove: () => {}, dispatch: () => {} }; - messages = { addChild: () => {} }; - toJSON() { - return {}; - } - restoreState() {} - dispose() {} - registerDisposer() {} - }, - LayerActionContext: class {}, -})); - -vi.mock("#src/layer/vox/tabs/tools.js", () => ({ - VoxToolTab: class {}, -})); - -vi.mock("#src/sliceview/volume/frontend.js", () => ({ - MultiscaleVolumeChunkSource: class {}, - InMemoryVolumeChunkSource: class {}, -})); - -let UserLayerWithVoxelEditingMixin: any; -let VoxelEditingContext: any; - -beforeAll(async () => { - if (typeof WebGL2RenderingContext === "undefined") { - global.WebGL2RenderingContext = class { - static VERTEX_SHADER = 0; - static FRAGMENT_SHADER = 1; - static ARRAY_BUFFER = 34962; - static STATIC_DRAW = 35044; - } as any; - } - if (typeof WebGLTexture === "undefined") { - global.WebGLTexture = class {} as any; - } - - const mod = await import("#src/layer/vox/index.js"); - UserLayerWithVoxelEditingMixin = mod.UserLayerWithVoxelEditingMixin; - VoxelEditingContext = mod.VoxelEditingContext; -}); - -class MockBaseLayer extends RefCounted { - manager = { - chunkManager: { - rpc: {}, - }, - }; - tabs = { - add: vi.fn(), - }; - specificationChanged = { - dispatch: vi.fn(), - }; - layersChanged = { - dispatch: vi.fn(), - }; - toJSON() { - return {}; - } - restoreState() {} -} - -describe("VoxelEditingContext", () => { - let hostLayer: any; - let primarySource: any; - let primaryRenderLayer: any; - let context: any; - let ConcreteVoxelLayer: any; - - beforeEach(() => { - vi.clearAllMocks(); - ConcreteVoxelLayer = UserLayerWithVoxelEditingMixin(MockBaseLayer as any); - - hostLayer = new ConcreteVoxelLayer(); - hostLayer.localPosition = new WatchableValue(new Float32Array([0, 0, 0])); - hostLayer._createVoxelRenderLayer = vi.fn().mockReturnValue({ - filterVisibleSources: vi.fn(), - dispose: vi.fn(), - messages: { addChild: vi.fn() }, - layerChanged: { add: vi.fn(), remove: vi.fn() }, - }); - hostLayer.addRenderLayer = vi.fn(); - hostLayer.removeRenderLayer = vi.fn(); - hostLayer.getIdentitySliceViewSourceOptions = vi.fn(); - - primarySource = { - rank: 3, - getSources: vi - .fn() - .mockReturnValue([ - [{ chunkToMultiscaleTransform: new Float32Array(16) }], - ]), - }; - - primaryRenderLayer = { - transform: new WatchableValue({}), - }; - - context = new VoxelEditingContext( - hostLayer, - primarySource, - primaryRenderLayer, - true, - ); - }); - - afterEach(() => { - if (context) context.dispose(); - }); - - describe("getVoxelPositionFromMouse", () => { - it("Success: returns mapped voxel position", () => { - primaryRenderLayer.transform.value = { - rank: 3, - globalToRenderLayerDimensions: [0, 1, 2], - }; - - const mockChunkTransform = { - modelTransform: { unpaddedRank: 3 }, - layerRank: 3, - combinedGlobalLocalToChunkTransform: new Float32Array(16), - }; - (getChunkTransformParameters as any).mockReturnValue(mockChunkTransform); - - ( - getChunkPositionFromCombinedGlobalLocalPositions as any - ).mockImplementation((out: Float32Array) => { - out[0] = 10; - out[1] = 20; - out[2] = 30; - return true; - }); - - const mouseState = { - unsnappedPosition: new Float32Array([100, 200, 300]), - }; - - const result = context.getVoxelPositionFromMouse(mouseState as any); - - expect(result).toBeDefined(); - expect(result![0]).toBe(10); - expect(result![1]).toBe(20); - expect(result![2]).toBe(30); - expect(getChunkTransformParameters).toHaveBeenCalled(); - }); - - it("Transform Error: returns undefined", () => { - primaryRenderLayer.transform.value = { error: "Some error" }; - const mouseState = { unsnappedPosition: new Float32Array(3) }; - - const result = context.getVoxelPositionFromMouse(mouseState as any); - - expect(result).toBeUndefined(); - }); - - it("Calculation Failure (Throw): returns undefined", () => { - primaryRenderLayer.transform.value = {}; - (getChunkTransformParameters as any).mockImplementation(() => { - throw new Error("Calculation failed"); - }); - const consoleSpy = vi - .spyOn(console, "error") - .mockImplementation(() => {}); - - const mouseState = { unsnappedPosition: new Float32Array(3) }; - const result = context.getVoxelPositionFromMouse(mouseState as any); - - expect(result).toBeUndefined(); - expect(consoleSpy).toHaveBeenCalled(); - }); - - it("Out of Bounds: returns undefined", () => { - primaryRenderLayer.transform.value = {}; - (getChunkTransformParameters as any).mockReturnValue({ - modelTransform: { unpaddedRank: 3 }, - }); - (getChunkPositionFromCombinedGlobalLocalPositions as any).mockReturnValue( - false, - ); - - const mouseState = { unsnappedPosition: new Float32Array(3) }; - const result = context.getVoxelPositionFromMouse(mouseState as any); - - expect(result).toBeUndefined(); - }); - }); - - describe("transformGlobalToVoxelNormal", () => { - it("Uninitialized Cache: throws error", () => { - expect(() => { - context.transformGlobalToVoxelNormal(vec3.create()); - }).toThrow("Chunk transform not computed"); - }); - - it("Identity Transform: returns same vector", () => { - primaryRenderLayer.transform.value = {}; - (getChunkTransformParameters as any).mockReturnValue({ - modelTransform: { globalToRenderLayerDimensions: [0, 1, 2] }, - layerToChunkTransform: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], - layerRank: 3, - combinedGlobalLocalToChunkTransform: new Float32Array(16), - }); - (getChunkPositionFromCombinedGlobalLocalPositions as any).mockReturnValue( - true, - ); - context.getVoxelPositionFromMouse({ - unsnappedPosition: new Float32Array(3), - } as any); - - const globalNormal = vec3.fromValues(1, 0, 0); - const result = context.transformGlobalToVoxelNormal(globalNormal); - - expect(result).toEqual(globalNormal); - }); - - it("Rotation/Permutation: transforms vector", () => { - primaryRenderLayer.transform.value = {}; - const permMatrix = [0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; - - (getChunkTransformParameters as any).mockReturnValue({ - modelTransform: { globalToRenderLayerDimensions: [0, 1, 2] }, - layerToChunkTransform: permMatrix, - layerRank: 3, - combinedGlobalLocalToChunkTransform: new Float32Array(16), - }); - (getChunkPositionFromCombinedGlobalLocalPositions as any).mockReturnValue( - true, - ); - context.getVoxelPositionFromMouse({ - unsnappedPosition: new Float32Array(3), - } as any); - - const globalNormal = vec3.fromValues(1, 0, 0); - const result = context.transformGlobalToVoxelNormal(globalNormal); - - expect(result[0]).toBeCloseTo(0); - expect(result[1]).toBeCloseTo(1); - expect(result[2]).toBeCloseTo(0); - }); - }); - - it("Non-aligned Normal with Scaling: correctly transforms and normalizes", () => { - const scaleMatrix = [2, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; - - (getChunkTransformParameters as any).mockReturnValue({ - modelTransform: { globalToRenderLayerDimensions: [0, 1, 2] }, - layerToChunkTransform: scaleMatrix, - layerRank: 3, - combinedGlobalLocalToChunkTransform: new Float32Array(16), - }); - (getChunkPositionFromCombinedGlobalLocalPositions as any).mockReturnValue( - true, - ); - - context.getVoxelPositionFromMouse({ - unsnappedPosition: new Float32Array(3), - } as any); - - const inputLen = Math.sqrt(2); - const globalNormal = vec3.fromValues(1 / inputLen, 1 / inputLen, 0); - - const result = context.transformGlobalToVoxelNormal(globalNormal); - - const expectedX = 2 / Math.sqrt(5); - const expectedY = 1 / Math.sqrt(5); - - expect(result[0]).toBeCloseTo(expectedX); - expect(result[1]).toBeCloseTo(expectedY); - expect(result[2]).toBeCloseTo(0); - }); -}); - -describe("UserLayerWithVoxelEditingMixin: setVoxelPaintValue", () => { - let layer: any; - let mockContext: { primarySource: { dataType: DataType } }; - let ConcreteVoxelLayer: any; - - beforeEach(() => { - ConcreteVoxelLayer = UserLayerWithVoxelEditingMixin(MockBaseLayer as any); - layer = new ConcreteVoxelLayer(); - mockContext = { primarySource: { dataType: DataType.UINT8 } }; - layer.editingContexts.values = vi.fn().mockReturnValue({ - next: () => ({ value: mockContext }), - }); - }); - - it("UINT8: Clamps/Wraps correctly", () => { - mockContext.primarySource.dataType = DataType.UINT8; - - expect(layer.setVoxelPaintValue(255)).toBe(255n); - expect(layer.setVoxelPaintValue(256)).toBe(0n); - expect(layer.setVoxelPaintValue(-1)).toBe(255n); - }); - - it("INT8: Signed wrapping", () => { - mockContext.primarySource.dataType = DataType.INT8; - - expect(layer.setVoxelPaintValue(127)).toBe(127n); - expect(layer.setVoxelPaintValue(128)).toBe(-128n); - expect(layer.setVoxelPaintValue(-129)).toBe(127n); - }); - - it("FLOAT32: Parses and rounds", () => { - mockContext.primarySource.dataType = DataType.FLOAT32; - - expect(layer.setVoxelPaintValue("10.6")).toBe(11n); - expect(layer.setVoxelPaintValue(10.4)).toBe(10n); - }); - - it("UINT64: Handles BigInts", () => { - mockContext.primarySource.dataType = DataType.UINT64; - const bigVal = BigInt(Number.MAX_SAFE_INTEGER) + 10n; - - expect(layer.setVoxelPaintValue(bigVal)).toBe(bigVal); - }); - - it("No Context: Fails", () => { - layer.editingContexts.values = vi.fn().mockReturnValue({ - next: () => ({ value: undefined }), - }); - - expect(() => layer.setVoxelPaintValue(1)).toThrow(); - }); -}); From 76d55f2e004ea78bf5c4279776a56ad18b39fb38 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 26 Nov 2025 11:50:06 +0100 Subject: [PATCH 147/251] cleanup: Remove OPFS and SSA-S3 KV store implementations and related components. --- package.json | 31 - src/kvstore/enabled_backend_modules.ts | 2 - src/kvstore/enabled_frontend_modules.ts | 3 - src/kvstore/opfs/backend.ts | 263 --------- src/kvstore/opfs/common.ts | 64 -- src/kvstore/opfs/frontend.ts | 47 -- src/kvstore/opfs/register_backend.ts | 21 - src/kvstore/opfs/register_frontend.ts | 21 - src/kvstore/ssa_s3/README.md | 2 - src/kvstore/ssa_s3/credentials_provider.ts | 427 -------------- src/kvstore/ssa_s3/register_backend.ts | 48 -- .../ssa_s3/register_credentials_provider.ts | 22 - src/kvstore/ssa_s3/register_frontend.ts | 191 ------ src/kvstore/ssa_s3/ssa_s3_kvstore.ts | 550 ------------------ src/kvstore/ssa_s3/url_utils.ts | 49 -- 15 files changed, 1741 deletions(-) delete mode 100644 src/kvstore/opfs/backend.ts delete mode 100644 src/kvstore/opfs/common.ts delete mode 100644 src/kvstore/opfs/frontend.ts delete mode 100644 src/kvstore/opfs/register_backend.ts delete mode 100644 src/kvstore/opfs/register_frontend.ts delete mode 100644 src/kvstore/ssa_s3/README.md delete mode 100644 src/kvstore/ssa_s3/credentials_provider.ts delete mode 100644 src/kvstore/ssa_s3/register_backend.ts delete mode 100644 src/kvstore/ssa_s3/register_credentials_provider.ts delete mode 100644 src/kvstore/ssa_s3/register_frontend.ts delete mode 100644 src/kvstore/ssa_s3/ssa_s3_kvstore.ts delete mode 100644 src/kvstore/ssa_s3/url_utils.ts diff --git a/package.json b/package.json index e761a78439..dbd7f30dc7 100644 --- a/package.json +++ b/package.json @@ -486,37 +486,6 @@ "neuroglancer/kvstore/s3:disabled": "./src/util/false.ts", "default": "./src/kvstore/s3/register_backend.ts" }, - "#kvstore/opfs/register_frontend": { - "neuroglancer/kvstore/opfs:enabled": "./src/kvstore/opfs/register_frontend.ts", - "neuroglancer/kvstore:none_by_default": "./src/util/false.ts", - "neuroglancer/kvstore/opfs:disabled": "./src/util/false.ts", - "default": "./src/kvstore/opfs/register_frontend.ts" - }, - "#kvstore/opfs/register_backend": { - "neuroglancer/kvstore/opfs:enabled": "./src/kvstore/opfs/register_backend.ts", - "neuroglancer/kvstore:none_by_default": "./src/util/false.ts", - "neuroglancer/kvstore/opfs:disabled": "./src/util/false.ts", - "default": "./src/kvstore/opfs/register_backend.ts" - }, - "#kvstore/ssa_s3/register_credentials_provider": { - "neuroglancer/python": "./src/util/false.ts", - "neuroglancer/kvstore/ssa_s3:enabled": "./src/kvstore/ssa_s3/register_credentials_provider.ts", - "neuroglancer/kvstore:none_by_default": "./src/util/false.ts", - "neuroglancer/kvstore/ssa_s3:disabled": "./src/util/false.ts", - "default": "./src/kvstore/ssa_s3/register_credentials_provider.ts" - }, - "#kvstore/ssa_s3/register_frontend": { - "neuroglancer/kvstore/ssa_s3:enabled": "./src/kvstore/ssa_s3/register_frontend.ts", - "neuroglancer/kvstore:none_by_default": "./src/util/false.ts", - "neuroglancer/kvstore/ssa_s3:disabled": "./src/util/false.ts", - "default": "./src/kvstore/ssa_s3/register_frontend.ts" - }, - "#kvstore/ssa_s3/register_backend": { - "neuroglancer/kvstore/ssa_s3:enabled": "./src/kvstore/ssa_s3/register_backend.ts", - "neuroglancer/kvstore:none_by_default": "./src/util/false.ts", - "neuroglancer/kvstore/ssa_s3:disabled": "./src/util/false.ts", - "default": "./src/kvstore/ssa_s3/register_backend.ts" - }, "#kvstore/zip/register_frontend": { "neuroglancer/kvstore/zip:enabled": "./src/kvstore/zip/register_frontend.ts", "neuroglancer/kvstore:none_by_default": "./src/util/false.ts", diff --git a/src/kvstore/enabled_backend_modules.ts b/src/kvstore/enabled_backend_modules.ts index 42333d904d..335031dc7d 100644 --- a/src/kvstore/enabled_backend_modules.ts +++ b/src/kvstore/enabled_backend_modules.ts @@ -9,5 +9,3 @@ import "#kvstore/ngauth/register"; import "#kvstore/ocdbt/register_backend"; import "#kvstore/s3/register_backend"; import "#kvstore/zip/register_backend"; -import "#kvstore/ssa_s3/register_backend"; -import "#kvstore/opfs/register_backend"; diff --git a/src/kvstore/enabled_frontend_modules.ts b/src/kvstore/enabled_frontend_modules.ts index 8af8e40cfb..476e2f6d1d 100644 --- a/src/kvstore/enabled_frontend_modules.ts +++ b/src/kvstore/enabled_frontend_modules.ts @@ -10,7 +10,4 @@ import "#kvstore/ngauth/register"; import "#kvstore/ngauth/register_credentials_provider"; import "#kvstore/ocdbt/register_frontend"; import "#kvstore/s3/register_frontend"; -import "#kvstore/ssa_s3/register_credentials_provider"; -import "#kvstore/ssa_s3/register_frontend"; import "#kvstore/zip/register_frontend"; -import "#kvstore/opfs/register_frontend"; diff --git a/src/kvstore/opfs/backend.ts b/src/kvstore/opfs/backend.ts deleted file mode 100644 index 54e12ce9ec..0000000000 --- a/src/kvstore/opfs/backend.ts +++ /dev/null @@ -1,263 +0,0 @@ -/** - * @license - * Copyright 2025 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 type { SharedKvStoreContextCounterpart } from "#src/kvstore/backend.js"; -import type { - DriverListOptions, - DriverReadOptions, - KvStore, - ListResponse, - ReadResponse, - StatOptions, - StatResponse, -} from "#src/kvstore/index.js"; -import { - encodePathForUrl, - kvstoreEnsureDirectoryPipelineUrl, -} from "#src/kvstore/url.js"; - -function ensureOpfsAvailable(context: string): void { - if ( - typeof navigator === "undefined" || - (navigator as any).storage === undefined - ) { - throw new Error( - `${context}: OPFS (navigator.storage) is not available in this environment`, - ); - } -} - -async function getRootDirectoryHandle(): Promise { - ensureOpfsAvailable("opfs"); - return await (navigator as any).storage.getDirectory(); -} - -function splitPath(path: string): string[] { - const normalized = path.replace(/\\+/g, "/").replace(/^\/+|\/+$/g, ""); - return normalized === "" ? [] : normalized.split("/"); -} - -async function getDirectoryHandleForPath( - baseDir: FileSystemDirectoryHandle, - pathSegments: string[], - create: boolean, -): Promise { - let current: FileSystemDirectoryHandle = baseDir; - for (const segment of pathSegments) { - if (segment === "") continue; - current = await current.getDirectoryHandle(segment, { create }); - } - return current; -} - -async function getFileHandleForPath( - baseDir: FileSystemDirectoryHandle, - pathSegments: string[], - create: boolean, -): Promise { - if (pathSegments.length === 0) { - throw new Error("getFileHandleForPath: empty path provided"); - } - const dirSegments = pathSegments.slice(0, -1); - const fileName = pathSegments[pathSegments.length - 1]; - const parent = await getDirectoryHandleForPath(baseDir, dirSegments, create); - return await parent.getFileHandle(fileName, { create }); -} - -export class OpfsKvStore implements KvStore { - private readonly basePathSegments: string[]; - private rootDirectoryPromise: Promise | undefined; - - constructor( - public sharedKvStoreContext: SharedKvStoreContextCounterpart, - basePath: string, - ) { - this.basePathSegments = splitPath(basePath); - } - - private getRoot(): Promise { - if (this.rootDirectoryPromise !== undefined) - return this.rootDirectoryPromise; - this.rootDirectoryPromise = getRootDirectoryHandle(); - return this.rootDirectoryPromise; - } - - private async getBaseDirectory(): Promise { - const root = await this.getRoot(); - return await getDirectoryHandleForPath( - root, - this.basePathSegments, - /*create=*/ true, - ); - } - - async stat( - key: string, - _options: StatOptions, - ): Promise { - const base = await this.getBaseDirectory(); - const pathSegments = splitPath(key); - try { - const fileHandle = await getFileHandleForPath( - base, - pathSegments, - /*create=*/ false, - ); - const file = await fileHandle.getFile(); - return { totalSize: file.size }; - } catch (e) { - if ( - e instanceof DOMException && - (e.name === "NotFoundError" || e.name === "NotAllowedError") - ) { - return undefined; - } - throw new Error( - `stat(${key}) failed for ${this.getUrl(key)}: ${String((e as Error).message ?? e)}`, - ); - } - } - - async read( - key: string, - _options: DriverReadOptions, - ): Promise { - const base = await this.getBaseDirectory(); - const pathSegments = splitPath(key); - try { - const fileHandle = await getFileHandleForPath( - base, - pathSegments, - /*create=*/ false, - ); - const file = await fileHandle.getFile(); - const buffer = await file.arrayBuffer(); - const response = new Response(buffer); - return { - response, - offset: 0, - length: buffer.byteLength, - totalSize: buffer.byteLength, - }; - } catch (e) { - if ( - e instanceof DOMException && - (e.name === "NotFoundError" || e.name === "NotAllowedError") - ) { - return undefined; - } - throw new Error( - `read(${key}) failed for ${this.getUrl(key)}: ${String((e as Error).message ?? e)}`, - ); - } - } - - async write(key: string, value: ArrayBuffer): Promise { - const base = await this.getBaseDirectory(); - const pathSegments = splitPath(key); - const fh = await getFileHandleForPath(base, pathSegments, /*create=*/ true); - const writable = await (fh as any).createWritable({ - keepExistingData: false, - }); - try { - await writable.write(new Uint8Array(value)); - } finally { - await writable.close(); - } - } - - async delete(key: string): Promise { - const base = await this.getBaseDirectory(); - const parts = splitPath(key); - if (parts.length === 0) throw new Error("delete: empty key"); - const parent = await getDirectoryHandleForPath( - base, - parts.slice(0, -1), - /*create=*/ false, - ); - await (parent as any).removeEntry(parts[parts.length - 1], { - recursive: false, - }); - } - - async list( - prefix: string, - _options: DriverListOptions, - ): Promise { - const base = await this.getBaseDirectory(); - const prefixSegments = splitPath(prefix); - - const dirForPrefix = await (async () => { - try { - return await getDirectoryHandleForPath( - base, - prefixSegments, - /*create=*/ false, - ); - } catch (e) { - if (e instanceof DOMException && e.name === "NotFoundError") { - return undefined; - } - throw e; - } - })(); - - if (dirForPrefix === undefined) { - return { entries: [], directories: [] }; - } - - const entries: Array<{ key: string }> = []; - const directories = new Set(); - - for await (const [name, handle] of ( - dirForPrefix as any - ).entries() as AsyncIterable<[string, FileSystemHandle]>) { - const fullKey = - prefix === "" - ? name - : `${prefix}${prefix.endsWith("/") ? "" : "/"}${name}`; - if ((handle as FileSystemDirectoryHandle).kind === "directory") { - directories.add(fullKey); - } else { - entries.push({ key: fullKey }); - } - } - - const sortedEntries = entries.sort((a, b) => - a.key < b.key ? -1 : a.key > b.key ? 1 : 0, - ); - const sortedDirectories = Array.from(directories).sort((a, b) => - a < b ? -1 : a > b ? 1 : 0, - ); - - return { entries: sortedEntries, directories: sortedDirectories }; - } - - getUrl(key: string): string { - const base = this.basePathSegments.join("/"); - const baseUrl = - base === "" ? "opfs://" : `opfs://${encodePathForUrl(base)}/`; - const ensured = kvstoreEnsureDirectoryPipelineUrl(baseUrl); - return ensured + (key === "" ? "" : encodePathForUrl(key)); - } - - get supportsOffsetReads(): boolean { - return false; - } - get supportsSuffixReads(): boolean { - return false; - } -} diff --git a/src/kvstore/opfs/common.ts b/src/kvstore/opfs/common.ts deleted file mode 100644 index 5b78682adb..0000000000 --- a/src/kvstore/opfs/common.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * @license - * Copyright 2025 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 type { BaseKvStoreProvider } from "#src/kvstore/context.js"; -import type { KvStore } from "#src/kvstore/index.js"; -import type { - KvStoreProviderRegistry, - SharedKvStoreContextBase, -} from "#src/kvstore/register.js"; -import type { UrlWithParsedScheme } from "#src/kvstore/url.js"; - -function parseOpfsUrlSuffix(suffix: string | undefined): { - basePath: string; - path: string; -} { - // Accept opfs://, opfs:/, or opfs: - const s = suffix ?? ""; - const m = s.match(/^\/?\/?(.*)$/); - if (m === null) { - throw new Error( - `Invalid opfs URL suffix ${JSON.stringify(s)}; expected opfs://`, - ); - } - const decoded = decodeURIComponent(m[1] ?? ""); - // Choose to have basePath be empty and return full path as initial kv path. - return { basePath: "", path: decoded }; -} - -export function registerProviders< - SharedKvStoreContext extends SharedKvStoreContextBase, ->( - registry: KvStoreProviderRegistry, - OpfsKvStoreClass: { - new (sharedKvStoreContext: SharedKvStoreContext, basePath: string): KvStore; - }, -) { - const provider: (context: SharedKvStoreContext) => BaseKvStoreProvider = ( - sharedKvStoreContext: SharedKvStoreContext, - ) => ({ - scheme: "opfs", - description: "Origin Private File System (browser)", - getKvStore(url: UrlWithParsedScheme) { - const { basePath, path } = parseOpfsUrlSuffix(url.suffix); - return { - store: new OpfsKvStoreClass(sharedKvStoreContext, basePath), - path, - }; - }, - }); - registry.registerBaseKvStoreProvider(provider); -} diff --git a/src/kvstore/opfs/frontend.ts b/src/kvstore/opfs/frontend.ts deleted file mode 100644 index 40d57b9a14..0000000000 --- a/src/kvstore/opfs/frontend.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * @license - * Copyright 2025 - * 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 type { SharedKvStoreContext } from "#src/kvstore/frontend.js"; -import { ProxyKvStore } from "#src/kvstore/proxy.js"; -import { - encodePathForUrl, - kvstoreEnsureDirectoryPipelineUrl, -} from "#src/kvstore/url.js"; - -export class OpfsKvStore extends ProxyKvStore { - constructor( - public override sharedKvStoreContext: SharedKvStoreContext, - private readonly basePath: string, - ) { - super(sharedKvStoreContext); - } - - getUrl(key: string): string { - const base = - this.basePath === "" - ? "opfs://" - : `opfs://${encodePathForUrl(this.basePath)}/`; - const ensured = kvstoreEnsureDirectoryPipelineUrl(base); - return ensured + (key === "" ? "" : encodePathForUrl(key)); - } - - get supportsOffsetReads(): boolean { - return false; - } - get supportsSuffixReads(): boolean { - return false; - } -} diff --git a/src/kvstore/opfs/register_backend.ts b/src/kvstore/opfs/register_backend.ts deleted file mode 100644 index 0caced1a1c..0000000000 --- a/src/kvstore/opfs/register_backend.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @license - * Copyright 2025 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 { backendOnlyKvStoreProviderRegistry } from "#src/kvstore/backend.js"; -import { OpfsKvStore } from "#src/kvstore/opfs/backend.js"; -import { registerProviders } from "#src/kvstore/opfs/common.js"; - -registerProviders(backendOnlyKvStoreProviderRegistry, OpfsKvStore); diff --git a/src/kvstore/opfs/register_frontend.ts b/src/kvstore/opfs/register_frontend.ts deleted file mode 100644 index 4a946cc90e..0000000000 --- a/src/kvstore/opfs/register_frontend.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @license - * Copyright 2025 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 { frontendOnlyKvStoreProviderRegistry } from "#src/kvstore/frontend.js"; -import { registerProviders } from "#src/kvstore/opfs/common.js"; -import { OpfsKvStore } from "#src/kvstore/opfs/frontend.js"; - -registerProviders(frontendOnlyKvStoreProviderRegistry, OpfsKvStore); diff --git a/src/kvstore/ssa_s3/README.md b/src/kvstore/ssa_s3/README.md deleted file mode 100644 index 19fff34dbc..0000000000 --- a/src/kvstore/ssa_s3/README.md +++ /dev/null @@ -1,2 +0,0 @@ -The Stateless S3 Authenticator (SSA) is an authentication service that uses an OIDC portal to verify user identity. It then generates secure, temporary, pre-signed URLs that allow Neuroglancer to directly read from and write to private S3 buckets. -See [TODO: link the github here after its creation...] for more details. diff --git a/src/kvstore/ssa_s3/credentials_provider.ts b/src/kvstore/ssa_s3/credentials_provider.ts deleted file mode 100644 index 8031250677..0000000000 --- a/src/kvstore/ssa_s3/credentials_provider.ts +++ /dev/null @@ -1,427 +0,0 @@ -/** - * @license - * Copyright 2025 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 { - CredentialsProvider, - makeCredentialsGetter, -} from "#src/credentials_provider/index.js"; -import { - getCredentialsWithStatus, - monitorAuthPopupWindow, -} from "#src/credentials_provider/interactive_credentials_provider.js"; -import type { OAuth2Credentials } from "#src/credentials_provider/oauth2.js"; -import { raceWithAbort } from "#src/util/abort.js"; -import { fetchOk } from "#src/util/http_request.js"; -import { - verifyObject, - verifyObjectProperty, - verifyOptionalObjectProperty, - verifyString, -} from "#src/util/json.js"; -import type { ProgressOptions } from "#src/util/progress_listener.js"; -import { ProgressSpan } from "#src/util/progress_listener.js"; - -interface SsaConfiguration { - // OIDC issuer for the SSA deployment. - issuer: string; -} - -interface OidcConfiguration { - authorization_endpoint: string; - token_endpoint: string; -} - -function parseSsaConfiguration(json: unknown): SsaConfiguration { - const obj = verifyObject(json); - const issuer = verifyObjectProperty(obj, "issuer", verifyString); - return { issuer }; -} - -async function discoverSsaConfiguration( - workerOrigin: string, -): Promise { - const response = await fetchOk( - `${workerOrigin}/.well-known/ssa-configuration`, - ); - const config = parseSsaConfiguration(await response.json()); - return config; -} - -async function discoverOpenIdConfiguration( - issuer: string, -): Promise { - const response = await fetchOk(`${issuer}/.well-known/openid-configuration`); - const json = verifyObject(await response.json()); - const authorization_endpoint = verifyObjectProperty( - json, - "authorization_endpoint", - verifyString, - ); - const token_endpoint = verifyObjectProperty( - json, - "token_endpoint", - verifyString, - ); - return { authorization_endpoint, token_endpoint }; -} - -interface OidcCodeMessage { - type: "oidc_code"; - code: string; - state: string; -} - -async function waitForOidcCodeMessage( - expectedOrigin: string, - source: Window, - signal: AbortSignal, -): Promise { - return new Promise((resolve, reject) => { - window.addEventListener( - "message", - (event: MessageEvent) => { - if (event.source !== source) return; - if (event.origin !== expectedOrigin) return; - try { - const data = verifyObject(event.data); - const type = verifyObjectProperty(data, "type", verifyString); - if (type !== "oidc_code") return; - const code = verifyObjectProperty(data, "code", verifyString); - const state = verifyObjectProperty(data, "state", verifyString); - resolve({ type: "oidc_code", code, state }); - } catch (e) { - reject( - new Error( - `Received unexpected OIDC authorization response: ${(e as Error).message}`, - ), - ); - } - }, - { signal }, - ); - }); -} - -function openPopupCentered(url: string, width: number, height: number) { - const top = - window.outerHeight - - window.innerHeight + - window.innerHeight / 2 - - height / 2; - const left = window.innerWidth / 2 - width / 2; - const popup = window.open( - url, - undefined, - `toolbar=no, menubar=no, width=${width}, height=${height}, top=${top}, left=${left}`, - ); - if (popup === null) { - throw new Error("Failed to create authentication popup window"); - } - return popup; -} - -function base64UrlEncode(bytes: Uint8Array): string { - const s = btoa(String.fromCharCode(...bytes)); - return s.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - -async function sha256Bytes(input: Uint8Array): Promise { - const digest = await crypto.subtle.digest("SHA-256", input); - return new Uint8Array(digest); -} - -function generateRandomAscii(length: number): string { - const charset = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; - const random = new Uint8Array(length); - crypto.getRandomValues(random); - let s = ""; - for (let i = 0; i < length; ++i) { - s += charset[random[i] % charset.length]; - } - return s; -} - -async function createPkcePair(): Promise<{ - verifier: string; - challenge: string; -}> { - const verifier = generateRandomAscii(128); - const challenge = base64UrlEncode( - await sha256Bytes(new TextEncoder().encode(verifier)), - ); - return { verifier, challenge }; -} - -interface StoredSsaToken { - accessToken: string; - refreshToken: string; - tokenType: string; - expiresAt: number; - email?: string; -} - -function getLocalStorageKeyForWorker(workerOrigin: string): string { - return `ssa_oidc_token_${workerOrigin}`; -} - -function loadStoredSsaToken(workerOrigin: string): StoredSsaToken | null { - const key = getLocalStorageKeyForWorker(workerOrigin); - const raw = localStorage.getItem(key); - if (raw === null) return null; - const parsed = JSON.parse(raw); - const obj = verifyObject(parsed); - const accessToken = verifyObjectProperty(obj, "accessToken", verifyString); - const refreshToken = verifyObjectProperty(obj, "refreshToken", verifyString); - const tokenType = verifyObjectProperty(obj, "tokenType", verifyString); - const expiresAt = Number( - verifyObjectProperty(obj, "expiresAt", (v) => { - if (typeof v !== "number") throw new Error("expiresAt must be a number"); - return v; - }), - ); - const email = verifyOptionalObjectProperty(obj, "email", verifyString); - return { accessToken, refreshToken, tokenType, expiresAt, email }; -} - -function saveStoredSsaToken(workerOrigin: string, value: StoredSsaToken): void { - const key = getLocalStorageKeyForWorker(workerOrigin); - localStorage.setItem(key, JSON.stringify(value)); -} - -function clearStoredSsaToken(workerOrigin: string): void { - const key = getLocalStorageKeyForWorker(workerOrigin); - localStorage.removeItem(key); -} - -export class SsaCredentialsProvider extends CredentialsProvider { - constructor(public readonly workerOrigin: string) { - super(); - try { - // Throws if invalid URL. - const parsed = new URL(workerOrigin); - if (parsed.origin !== workerOrigin) { - throw new Error("workerOrigin must be an origin like https://host"); - } - } catch (e) { - throw new Error(`Invalid worker origin ${JSON.stringify(workerOrigin)}`, { - cause: e, - }); - } - } - - private async performInteractiveLogin( - options: ProgressOptions, - ): Promise { - using _span = new ProgressSpan(options.progressListener, { - message: `Requesting SSA login via ${this.workerOrigin}`, - }); - - const { issuer } = await discoverSsaConfiguration(this.workerOrigin); - const { authorization_endpoint, token_endpoint } = - await discoverOpenIdConfiguration(issuer); - - const clientId = "neuroglancer"; - const redirectUri = `${location.origin}/`; - const scope = "openid profile email"; - const state = generateRandomAscii(32); - const { verifier: codeVerifier, challenge: codeChallenge } = - await createPkcePair(); - - const authParams = new URLSearchParams({ - response_type: "code", - client_id: clientId, - redirect_uri: redirectUri, - scope, - state, - code_challenge: codeChallenge, - code_challenge_method: "S256", - }); - const popupUrl = `${authorization_endpoint}?${authParams.toString()}`; - - await getCredentialsWithStatus( - { - description: `SSA at ${this.workerOrigin}`, - requestDescription: "login", - get: async (innerSignal) => { - const abortController = new AbortController(); - const combined = AbortSignal.any([ - abortController.signal, - innerSignal, - options.signal, - ]); - try { - const popup = openPopupCentered(popupUrl, 450, 700); - monitorAuthPopupWindow(popup, abortController); - const appOrigin = new URL(redirectUri).origin; - const { code, state: returnedState } = await raceWithAbort( - waitForOidcCodeMessage(appOrigin, popup, abortController.signal), - combined, - ); - if (returnedState !== state) { - throw new Error("OIDC state mismatch detected"); - } - const tokenResp = await fetchOk(token_endpoint, { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: redirectUri, - client_id: clientId, - code_verifier: codeVerifier, - }), - signal: combined, - }); - const tokenJson = verifyObject(await tokenResp.json()); - const access_token = verifyObjectProperty( - tokenJson, - "access_token", - verifyString, - ); - const token_type = verifyObjectProperty( - tokenJson, - "token_type", - verifyString, - ); - const refresh_token = verifyObjectProperty( - tokenJson, - "refresh_token", - verifyString, - ); - const expires_in = Number( - verifyObjectProperty(tokenJson, "expires_in", (v) => { - if (typeof v !== "number") - throw new Error("expires_in must be a number"); - return v; - }), - ); - const email = verifyOptionalObjectProperty( - tokenJson, - "email", - verifyString, - ); - const stored: StoredSsaToken = { - accessToken: access_token, - refreshToken: refresh_token, - tokenType: token_type, - expiresAt: Date.now() + expires_in * 1000, - email, - }; - saveStoredSsaToken(this.workerOrigin, stored); - return { tokenType: token_type, accessToken: access_token, email }; - } finally { - abortController.abort(); - } - }, - }, - options.signal, - ); - - // The above getCredentialsWithStatus returns OAuth2Credentials. We already saved full token. - const stored = loadStoredSsaToken(this.workerOrigin); - if (stored === null) { - throw new Error( - "Failed to persist SSA token to localStorage after interactive login", - ); - } - return stored; - } - - private async refreshTokenSilently( - refreshToken: string, - signal: AbortSignal, - ): Promise { - const { issuer } = await discoverSsaConfiguration(this.workerOrigin); - const { token_endpoint } = await discoverOpenIdConfiguration(issuer); - const clientId = "neuroglancer"; - - const resp = await fetchOk(token_endpoint, { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: clientId, - }), - signal, - }); - const json = verifyObject(await resp.json()); - const access_token = verifyObjectProperty( - json, - "access_token", - verifyString, - ); - const token_type = verifyObjectProperty(json, "token_type", verifyString); - const new_refresh = - verifyOptionalObjectProperty(json, "refresh_token", verifyString) ?? - refreshToken; - const expires_in = Number( - verifyObjectProperty(json, "expires_in", (v) => { - if (typeof v !== "number") - throw new Error("expires_in must be a number"); - return v; - }), - ); - const email = verifyOptionalObjectProperty(json, "email", verifyString); - const stored: StoredSsaToken = { - accessToken: access_token, - refreshToken: new_refresh, - tokenType: token_type, - expiresAt: Date.now() + expires_in * 1000, - email, - }; - saveStoredSsaToken(this.workerOrigin, stored); - return stored; - } - - get = makeCredentialsGetter(async (options) => { - // 1) Try localStorage - const existing = loadStoredSsaToken(this.workerOrigin); - if (existing !== null) { - if (Date.now() < existing.expiresAt) { - return { - tokenType: existing.tokenType, - accessToken: existing.accessToken, - email: existing.email, - }; - } - // Try silent refresh - try { - const refreshed = await this.refreshTokenSilently( - existing.refreshToken, - options.signal, - ); - return { - tokenType: refreshed.tokenType, - accessToken: refreshed.accessToken, - email: refreshed.email, - }; - } catch { - clearStoredSsaToken(this.workerOrigin); - // Fall through to interactive login - } - } - - // 4) Interactive login - const stored = await this.performInteractiveLogin(options); - return { - tokenType: stored.tokenType, - accessToken: stored.accessToken, - email: stored.email, - }; - }); -} diff --git a/src/kvstore/ssa_s3/register_backend.ts b/src/kvstore/ssa_s3/register_backend.ts deleted file mode 100644 index cc9e1fbefa..0000000000 --- a/src/kvstore/ssa_s3/register_backend.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @license - * Copyright 2025 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 type { BaseKvStoreProvider } from "#src/kvstore/context.js"; -import type { SharedKvStoreContextBase } from "#src/kvstore/register.js"; -import { frontendBackendIsomorphicKvStoreProviderRegistry } from "#src/kvstore/register.js"; -import { SsaS3KvStore } from "#src/kvstore/ssa_s3/ssa_s3_kvstore.js"; -import { - ensureSsaHttpsUrl, - getWorkerOriginAndDatasetPrefix, - getDisplayBase, -} from "#src/kvstore/ssa_s3/url_utils.js"; - -function ssaIsomorphicProvider( - context: SharedKvStoreContextBase, -): BaseKvStoreProvider { - return { - scheme: "ssa+https", - description: "Stateless S3 Authenticator (SSA) over HTTPS", - getKvStore(parsedUrl) { - const parsed = ensureSsaHttpsUrl(parsedUrl.url); - const { workerOrigin, datasetBasePrefix } = - getWorkerOriginAndDatasetPrefix(parsed); - const displayBase = getDisplayBase(parsedUrl.url); - return { - store: new SsaS3KvStore(context, workerOrigin, "", displayBase), - path: datasetBasePrefix, - }; - }, - }; -} - -frontendBackendIsomorphicKvStoreProviderRegistry.registerBaseKvStoreProvider( - ssaIsomorphicProvider, -); diff --git a/src/kvstore/ssa_s3/register_credentials_provider.ts b/src/kvstore/ssa_s3/register_credentials_provider.ts deleted file mode 100644 index 2924b0ced4..0000000000 --- a/src/kvstore/ssa_s3/register_credentials_provider.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @license - * Copyright 2025 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 { registerDefaultCredentialsProvider } from "#src/credentials_provider/default_manager.js"; -import { SsaCredentialsProvider } from "#src/kvstore/ssa_s3/credentials_provider.js"; - -registerDefaultCredentialsProvider("ssa", (workerOrigin: string) => { - return new SsaCredentialsProvider(workerOrigin); -}); diff --git a/src/kvstore/ssa_s3/register_frontend.ts b/src/kvstore/ssa_s3/register_frontend.ts deleted file mode 100644 index ccb2d3cdc8..0000000000 --- a/src/kvstore/ssa_s3/register_frontend.ts +++ /dev/null @@ -1,191 +0,0 @@ -/** - * @license - * Copyright 2025 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 type { OAuth2Credentials } from "#src/credentials_provider/oauth2.js"; -import { fetchOkWithOAuth2CredentialsAdapter } from "#src/credentials_provider/oauth2.js"; -import type { - BaseKvStoreProvider, - BaseKvStoreCompleteUrlOptions, - CompletionResult, -} from "#src/kvstore/context.js"; -import type { SharedKvStoreContext } from "#src/kvstore/frontend.js"; -import { frontendOnlyKvStoreProviderRegistry } from "#src/kvstore/frontend.js"; -import { SsaS3KvStore } from "#src/kvstore/ssa_s3/ssa_s3_kvstore.js"; -import { - ensureSsaHttpsUrl, - getWorkerOriginAndDatasetPrefix, - getDisplayBase, -} from "#src/kvstore/ssa_s3/url_utils.js"; -import { - verifyObject, - verifyObjectProperty, - verifyString, - verifyStringArray, -} from "#src/util/json.js"; - -interface SsaAuthenticateResponseLite { - permissions: { read: string[]; write: string[] }; - endpoints: { signRequests: string; listFiles: string }; -} - -function parseAuthenticateResponseLite( - json: unknown, -): SsaAuthenticateResponseLite { - const obj = verifyObject(json); - const endpointsObj = verifyObjectProperty(obj, "endpoints", verifyObject); - const permissionsObj = verifyObjectProperty(obj, "permissions", verifyObject); - return { - permissions: { - read: verifyObjectProperty(permissionsObj, "read", verifyStringArray), - write: verifyObjectProperty(permissionsObj, "write", verifyStringArray), - }, - endpoints: { - signRequests: verifyObjectProperty( - endpointsObj, - "signRequests", - verifyString, - ), - listFiles: verifyObjectProperty(endpointsObj, "listFiles", verifyString), - }, - }; -} - -function dirnameAndBasename(path: string): { dir: string; base: string } { - const idx = path.lastIndexOf("/"); - if (idx === -1) return { dir: "", base: path }; - return { dir: path.substring(0, idx + 1), base: path.substring(idx + 1) }; -} - -function joinPath(base: string, suffix: string) { - if (base === "") return suffix; - if (base.endsWith("/")) return base + suffix; - return base + "/" + suffix; -} - -async function completeSsaUrl( - sharedContext: SharedKvStoreContext, - options: BaseKvStoreCompleteUrlOptions, -): Promise { - const { url } = options; - const parsed = ensureSsaHttpsUrl(url.url); - const { workerOrigin, datasetBasePrefix } = - getWorkerOriginAndDatasetPrefix(parsed); - - const credentialsProvider = - sharedContext.credentialsManager.getCredentialsProvider( - "ssa", - workerOrigin, - ); - const fetchOkToWorker = - fetchOkWithOAuth2CredentialsAdapter(credentialsProvider); - - const authenticateResponse = parseAuthenticateResponseLite( - await ( - await fetchOkToWorker(`${workerOrigin}/authenticate`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: "{}", - signal: options.signal, - }) - ).json(), - ); - - // Determine context for completion. - const { dir, base } = dirnameAndBasename(datasetBasePrefix); - - // Root-level completion: suggest directories from read permissions. - if (dir === "") { - const candidates = authenticateResponse.permissions.read.map((p) => - p.endsWith("/") ? p : p + "/", - ); - const matches = candidates - .filter((p) => p.startsWith(base)) - .map((p) => ({ value: p })); - const offset = url.url.length - base.length; - return { offset, completions: matches }; - } - - // Within a directory: use list-files for current dir prefix. - const listResponse = verifyObject( - await ( - await fetchOkToWorker( - `${workerOrigin}${authenticateResponse.endpoints.listFiles}`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ prefix: dir }), - signal: options.signal, - }, - ) - ).json(), - ); - const objects = verifyObjectProperty( - listResponse, - "objects", - (x) => x as unknown as any[], - ); - const childDirs = new Set(); - const childFiles = new Set(); - for (const entry of objects) { - const obj = verifyObject(entry); - const key = verifyObjectProperty(obj, "key", verifyString); - if (!key.startsWith(dir)) continue; - const remainder = key.substring(dir.length); - const slash = remainder.indexOf("/"); - if (slash === -1) { - if (remainder !== "") childFiles.add(remainder); - } else { - const first = remainder.substring(0, slash + 1); - childDirs.add(first); - } - } - const candidates = [ - ...Array.from(childDirs).map((d) => (d.endsWith("/") ? d : d + "/")), - ...Array.from(childFiles), - ]; - const matches = candidates - .filter((p) => p.startsWith(base)) - .map((p) => ({ value: joinPath(dir, p) })); - const offset = url.url.length - base.length; - return { offset, completions: matches }; -} - -function ssaFrontendProvider( - sharedContext: SharedKvStoreContext, -): BaseKvStoreProvider { - return { - scheme: "ssa+https", - description: "Stateless S3 Authenticator (SSA) over HTTPS", - getKvStore(parsedUrl) { - // parsedUrl.url is full string like ssa+https://host/path - const parsed = ensureSsaHttpsUrl(parsedUrl.url); - const { workerOrigin, datasetBasePrefix } = - getWorkerOriginAndDatasetPrefix(parsed); - const displayBase = getDisplayBase(parsedUrl.url); - return { - store: new SsaS3KvStore(sharedContext, workerOrigin, "", displayBase), - path: datasetBasePrefix, - }; - }, - async completeUrl(options) { - return await completeSsaUrl(sharedContext, options); - }, - }; -} - -frontendOnlyKvStoreProviderRegistry.registerBaseKvStoreProvider( - ssaFrontendProvider, -); diff --git a/src/kvstore/ssa_s3/ssa_s3_kvstore.ts b/src/kvstore/ssa_s3/ssa_s3_kvstore.ts deleted file mode 100644 index 95986c75f7..0000000000 --- a/src/kvstore/ssa_s3/ssa_s3_kvstore.ts +++ /dev/null @@ -1,550 +0,0 @@ -/** - * @license - * Copyright 2025 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 type { OAuth2Credentials } from "#src/credentials_provider/oauth2.js"; -import { fetchOkWithOAuth2CredentialsAdapter } from "#src/credentials_provider/oauth2.js"; -import type { - DriverReadOptions, - KvStore, - ListResponse, - StatOptions, - StatResponse, - ReadResponse, -} from "#src/kvstore/index.js"; -import type { SharedKvStoreContextBase } from "#src/kvstore/register.js"; -import type { SsaCredentialsProvider } from "#src/kvstore/ssa_s3/credentials_provider.js"; -import { pipelineUrlJoin } from "#src/kvstore/url.js"; -import type { FetchOk } from "#src/util/http_request.js"; -import { fetchOk, HttpError } from "#src/util/http_request.js"; -import { - verifyObject, - verifyObjectProperty, - verifyString, - verifyStringArray, -} from "#src/util/json.js"; -import { - MultiConsumerProgressListener, - ProgressSpan, -} from "#src/util/progress_listener.js"; - -function joinPath(base: string, suffix: string) { - if (base === "") return suffix; - if (base.endsWith("/")) return base + suffix; - return base + "/" + suffix; -} - -interface SsaAuthenticateResponse { - bucket: string; - endpoints: { - signRequests: string; // path relative to worker origin, e.g. "/sign-requests" - listFiles: string; // path relative to worker origin, e.g. "/list-files" - }; - permissions: { - read: string[]; - write: string[]; - }; -} - -function parseAuthenticateResponse(json: unknown): SsaAuthenticateResponse { - const obj = verifyObject(json); - const bucket = verifyObjectProperty(obj, "bucket", verifyString); - const endpointsObj = verifyObjectProperty(obj, "endpoints", verifyObject); - const permissionsObj = verifyObjectProperty(obj, "permissions", verifyObject); - return { - bucket, - endpoints: { - signRequests: verifyObjectProperty( - endpointsObj, - "signRequests", - verifyString, - ), - listFiles: verifyObjectProperty(endpointsObj, "listFiles", verifyString), - }, - permissions: { - read: verifyObjectProperty(permissionsObj, "read", verifyStringArray), - write: verifyObjectProperty(permissionsObj, "write", verifyStringArray), - }, - }; -} - -interface SsaSignRequestBody { - requests: Array<{ - action: "GET" | "PUT" | "HEAD" | "DELETE"; - key: string; // key within the SSA-managed bucket - }>; -} - -interface SsaSignRequestsResponseItem { - key: string; - url: string; -} -interface SsaSignRequestsResponse { - signedRequests: SsaSignRequestsResponseItem[]; -} - -function parseSignRequestsResponse(json: unknown): SsaSignRequestsResponse { - const obj = verifyObject(json); - const signedRequestsArrayUnknown = verifyObjectProperty( - obj, - "signedRequests", - (v) => { - if (!Array.isArray(v)) { - throw new Error("signedRequests must be an array"); - } - return v as unknown[]; - }, - ); - const signedRequests: SsaSignRequestsResponseItem[] = - signedRequestsArrayUnknown.map((entry) => { - const entryObj = verifyObject(entry); - const key = verifyObjectProperty(entryObj, "key", verifyString); - const url = verifyObjectProperty(entryObj, "url", verifyString); - return { key, url }; - }); - return { signedRequests }; -} - -interface SsaListFilesObject { - key: string; - size: number; - lastModified: string; -} -interface SsaListFilesResponse { - prefix: string; - objects: SsaListFilesObject[]; -} - -function parseListFilesResponse(json: unknown): SsaListFilesResponse { - const obj = verifyObject(json); - const prefix = verifyObjectProperty(obj, "prefix", verifyString); - const objectsArray = verifyObjectProperty( - obj, - "objects", - (x) => x as unknown as any[], - ); - const objects: SsaListFilesObject[] = objectsArray.map((entry) => { - const e = verifyObject(entry); - const key = verifyObjectProperty(e, "key", verifyString); - const sizeStr = verifyObjectProperty(e, "size", (v) => { - if (typeof v !== "number") throw new Error("Expected number"); - return v; - }); - const lastModified = verifyObjectProperty(e, "lastModified", verifyString); - return { key, size: sizeStr, lastModified }; - }); - return { prefix, objects }; -} - -export class SsaS3KvStore implements KvStore { - private readonly fetchOkToWorker: FetchOk; - private readonly credentialsProvider: SsaCredentialsProvider; - private readonly workerOrigin: string; - private readonly datasetBasePrefix: string; - private readonly displayBaseUrl: string; - - private authenticatePromise: Promise | undefined; - - constructor( - public readonly sharedKvStoreContext: SharedKvStoreContextBase, - workerOrigin: string, - datasetBasePrefix: string, - displayBaseUrl: string, - ) { - this.workerOrigin = workerOrigin; - this.datasetBasePrefix = datasetBasePrefix; - this.displayBaseUrl = displayBaseUrl; - this.credentialsProvider = - sharedKvStoreContext.credentialsManager.getCredentialsProvider( - "ssa", - workerOrigin, - ) as unknown as SsaCredentialsProvider; - this.fetchOkToWorker = fetchOkWithOAuth2CredentialsAdapter( - this.credentialsProvider, - ); - } - - getUrl(path: string): string { - return pipelineUrlJoin(this.displayBaseUrl, path); - } - - get supportsOffsetReads() { - return true; - } - - get supportsSuffixReads() { - return true; - } - - private async ensureAuthenticated( - signal?: AbortSignal, - ): Promise { - if (this.authenticatePromise === undefined) { - this.authenticatePromise = this.performAuthenticate(signal).catch((e) => { - // Clear cached promise on failure to allow retry. - this.authenticatePromise = undefined; - throw e; - }); - } - return this.authenticatePromise; - } - - private async performAuthenticate( - signal?: AbortSignal, - ): Promise { - using _span = new ProgressSpan(new MultiConsumerProgressListener(), { - message: `Connecting to SSA worker at ${this.workerOrigin}`, - }); - try { - const response = await this.fetchOkToWorker( - `${this.workerOrigin}/authenticate`, - { - method: "POST", - signal, - headers: { "content-type": "application/json" }, - body: "{}", - }, - ); - const result = parseAuthenticateResponse(await response.json()); - return result; - } catch (e) { - if (e instanceof HttpError) { - if (e.status === 401 || e.status === 403) { - throw new Error( - `Failed to authenticate with SSA service at ${this.workerOrigin}: access denied (${e.status}).`, - ); - } - } - throw new Error( - `Failed to connect to SSA service at ${this.workerOrigin}: ${(e as Error).message}`, - { cause: e }, - ); - } - } - - private async signSingleUrl( - fullKey: string, - type: "GET" | "PUT" | "HEAD" | "DELETE", - signal?: AbortSignal, - ): Promise { - const { endpoints } = await this.ensureAuthenticated(signal); - try { - const response = await this.fetchOkToWorker( - `${this.workerOrigin}${endpoints.signRequests}`, - { - method: "POST", - signal, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - requests: [{ action: type, key: fullKey }], - } satisfies SsaSignRequestBody), - }, - ); - const { signedRequests } = parseSignRequestsResponse( - await response.json(), - ); - if (signedRequests.length !== 1) { - throw new Error( - `SSA /sign-requests returned ${signedRequests.length} entries, expected 1 for key ${JSON.stringify(fullKey)}`, - ); - } - return signedRequests[0].url; - } catch (e) { - if (e instanceof HttpError && (e.status === 401 || e.status === 403)) { - throw new Error( - `Permission denied by SSA while signing ${JSON.stringify(fullKey)} (HTTP ${e.status}).`, - { cause: e }, - ); - } - throw new Error( - `Failed to sign request for ${JSON.stringify(fullKey)} via SSA: ${(e as Error).message}`, - { cause: e }, - ); - } - } - - async write(key: string, value: ArrayBuffer): Promise { - const fullKey = joinPath(this.datasetBasePrefix, key); - const url = await this.signSingleUrl(fullKey, "PUT"); - try { - await fetchOk(url, { - method: "PUT", - body: value, - }); - } catch (e) { - if (e instanceof HttpError && (e.status === 401 || e.status === 403)) { - throw new Error( - `Permission denied by SSA while writing ${this.getUrl(key)} (HTTP ${e.status}).`, - { cause: e }, - ); - } - throw new Error( - `Failed to write ${this.getUrl(key)} via SSA-signed URL: ${(e as Error).message}`, - { cause: e }, - ); - } - } - - async delete(key: string): Promise { - const fullKey = joinPath(this.datasetBasePrefix, key); - const url = await this.signSingleUrl(fullKey, "DELETE"); - try { - await fetchOk(url, { - method: "DELETE", - }); - } catch (e) { - if (e instanceof HttpError) { - if (e.status === 404) { - // Deleting a non-existent object is not considered an error in S3. - return; - } - if (e.status === 401 || e.status === 403) { - throw new Error( - `Permission denied by SSA while deleting ${this.getUrl(key)} (HTTP ${e.status}).`, - { cause: e }, - ); - } - } - throw new Error( - `Failed to delete ${this.getUrl(key)} via SSA-signed URL: ${(e as Error).message}`, - { cause: e }, - ); - } - } - - async stat( - key: string, - options: StatOptions, - ): Promise { - const fullKey = joinPath(this.datasetBasePrefix, key); - const url = await this.signSingleUrl(fullKey, "HEAD", options.signal); - try { - const response = await fetchOk(url, { - method: "HEAD", - signal: options.signal, - progressListener: options.progressListener, - }); - const contentLength = response.headers.get("content-length"); - let totalSize: number | undefined; - if (contentLength !== null) { - const n = Number(contentLength); - if (!Number.isFinite(n) || n < 0) { - throw new Error( - `Invalid content-length returned by S3 for ${JSON.stringify(fullKey)}: ${JSON.stringify(contentLength)}`, - ); - } - totalSize = n; - } - return { totalSize }; - } catch (e) { - if (e instanceof HttpError && e.status === 404) { - if (options.throwIfMissing === true) { - throw new Error(`${this.getUrl(key)} not found`, { cause: e }); - } - return undefined; - } - throw new Error( - `Failed to stat ${this.getUrl(key)} via SSA-signed URL: ${(e as Error).message}`, - { cause: e }, - ); - } - } - - async read( - key: string, - options: DriverReadOptions, - ): Promise { - const fullKey = joinPath(this.datasetBasePrefix, key); - const url = await this.signSingleUrl(fullKey, "GET", options.signal); - - // Construct Range header based on options.byteRange for efficient reads. - let rangeHeader: string | undefined; - const { byteRange } = options; - if (byteRange !== undefined) { - if ("suffixLength" in byteRange) { - // For suffix reads we must know total size; issue HEAD first then compute exact range. - const statResponse = await this.stat(key, { signal: options.signal }); - if ( - statResponse === undefined || - statResponse.totalSize === undefined - ) { - throw new Error( - `Failed to determine total size of ${this.getUrl(key)} in order to fetch suffix bytes`, - ); - } - const total = statResponse.totalSize; - const len = Math.min(byteRange.suffixLength, total); - const start = total - len; - rangeHeader = `bytes=${start}-${total - 1}`; - } else { - if (byteRange.length === 0) { - // Request 1 byte and discard per HTTP semantics for 0-length workaround. - const start = Math.max(byteRange.offset - 1, 0); - rangeHeader = `bytes=${start}-${start}`; - } else { - rangeHeader = `bytes=${byteRange.offset}-${byteRange.offset + byteRange.length - 1}`; - } - } - } - - try { - const response = await fetchOk(url, { - method: "GET", - signal: options.signal, - progressListener: options.progressListener, - headers: rangeHeader ? { range: rangeHeader } : undefined, - cache: rangeHeader - ? navigator.userAgent.indexOf("Chrome") !== -1 - ? "no-store" - : "default" - : undefined, - }); - - // Interpret response similar to http/read.ts logic. - let offset: number | undefined; - let length: number | undefined; - let totalSize: number | undefined; - if (response.status === 206) { - const contentRange = response.headers.get("content-range"); - if (contentRange !== null) { - const m = contentRange.match(/bytes ([0-9]+)-([0-9]+)\/(\*|[0-9]+)/); - if (m === null) { - throw new Error( - `Invalid content-range header from S3 for ${this.getUrl(key)}: ${JSON.stringify(contentRange)}`, - ); - } - offset = Number(m[1]); - const endPos = Number(m[2]); - length = endPos - offset + 1; - if (m[3] !== "*") totalSize = Number(m[3]); - } else if (byteRange !== undefined) { - // Some servers omit content-range; use requested range info where possible. - if ("suffixLength" in byteRange) { - // Already computed via HEAD. - const statResponse = await this.stat(key, { - signal: options.signal, - }); - totalSize = statResponse?.totalSize; - if (totalSize === undefined) { - throw new Error("Missing total size for suffix read"); - } - const len = Math.min(byteRange.suffixLength, totalSize); - offset = totalSize - len; - length = len; - } else { - if (byteRange.length === 0) { - offset = byteRange.offset; - length = 0; - // Return empty body for zero-length reads. - return { - response: new Response(new Uint8Array(0)), - offset, - length, - totalSize, - }; - } else { - offset = byteRange.offset; - length = byteRange.length; - } - } - } - } else { - const cl = response.headers.get("content-length"); - if (cl !== null) { - const n = Number(cl); - if (!Number.isFinite(n) || n < 0) { - throw new Error( - `Invalid content-length header for ${this.getUrl(key)}: ${JSON.stringify(cl)}`, - ); - } - length = n; - totalSize = n; - offset = 0; - } - } - if (offset === undefined) offset = 0; - return { response, offset, length, totalSize }; - } catch (e) { - if (e instanceof HttpError) { - if (e.status === 404) { - if (options.throwIfMissing === true) { - throw new Error(`${this.getUrl(key)} not found`, { cause: e }); - } - return undefined; - } - if (e.status === 401 || e.status === 403) { - throw new Error( - `Permission denied while reading ${this.getUrl(key)} (HTTP ${e.status}).`, - { cause: e }, - ); - } - } - throw new Error( - `Failed to read ${this.getUrl(key)} via SSA-signed URL: ${(e as Error).message}`, - { cause: e }, - ); - } - } - - async list( - prefix: string, - options: { signal?: AbortSignal } = {}, - ): Promise { - const fullPrefix = joinPath(this.datasetBasePrefix, prefix); - const { endpoints } = await this.ensureAuthenticated(options.signal); - try { - const response = await this.fetchOkToWorker( - `${this.workerOrigin}${endpoints.listFiles}`, - { - method: "POST", - signal: options.signal, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ prefix: fullPrefix }), - }, - ); - const parsed = parseListFilesResponse(await response.json()); - // Compute immediate children relative to the requested prefix. - const childDirSet = new Set(); - const childFileSet = new Set(); - for (const obj of parsed.objects) { - const key = obj.key; - if (!key.startsWith(parsed.prefix)) continue; - const remainder = key.substring(parsed.prefix.length); - if (remainder === "") continue; - const slash = remainder.indexOf("/"); - if (slash === -1) { - childFileSet.add(remainder); - } else { - childDirSet.add(remainder.substring(0, slash + 1)); - } - } - return { - directories: Array.from(childDirSet), - entries: Array.from(childFileSet).map((k) => ({ key: k })), - }; - } catch (e) { - if (e instanceof HttpError && (e.status === 401 || e.status === 403)) { - throw new Error( - `Permission denied by SSA while listing ${this.getUrl(prefix)} (HTTP ${e.status}).`, - { cause: e }, - ); - } - throw new Error( - `Failed to list files for ${this.getUrl(prefix)} via SSA: ${(e as Error).message}`, - { cause: e }, - ); - } - } -} diff --git a/src/kvstore/ssa_s3/url_utils.ts b/src/kvstore/ssa_s3/url_utils.ts deleted file mode 100644 index ef1d68012e..0000000000 --- a/src/kvstore/ssa_s3/url_utils.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * @license - * Copyright 2025 Google Inc. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export const SSA_SCHEME_PREFIX = "ssa+"; - -export function ensureSsaHttpsUrl(url: string): URL { - if (!url.startsWith("ssa+https://")) { - throw new Error( - `Invalid URL ${JSON.stringify(url)}: expected ssa+https scheme`, - ); - } - const httpUrl = url.substring(SSA_SCHEME_PREFIX.length); - const parsed = new URL(httpUrl); - if (parsed.hash) throw new Error("Fragment not supported in ssa+https URLs"); - if (parsed.username || parsed.password) - throw new Error( - "Basic auth credentials are not supported in ssa+https URLs", - ); - return parsed; -} - -export function getWorkerOriginAndDatasetPrefix(parsed: URL): { - workerOrigin: string; - datasetBasePrefix: string; -} { - const workerOrigin = parsed.origin; - const datasetBasePrefix = decodeURIComponent( - parsed.pathname.replace(/^\//, ""), - ); - return { workerOrigin, datasetBasePrefix }; -} - -export function getDisplayBase(url: string): string { - const parsed = ensureSsaHttpsUrl(url); - return `${SSA_SCHEME_PREFIX}${parsed.origin}/`; -} From 9871115450d4c109ca78657e1b08856118254b6b Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 26 Nov 2025 12:26:25 +0100 Subject: [PATCH 148/251] refactor(voxel-annotation): use the already implemented multiscale locking mechanism for the preview renderlayer instead of recreating it in the VoxelEditContext --- src/layer/vox/index.ts | 16 ++++------------ src/sliceview/frontend.ts | 1 + 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 0c134e83cb..64a1f6d413 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -30,12 +30,10 @@ import { getChunkTransformParameters, } from "#src/render_coordinate_transform.js"; import type { - SliceViewBase, SliceViewSourceOptions, - TransformedSource, + SliceViewRenderLayer, } from "#src/sliceview/base.js"; import { DataType } from "#src/sliceview/base.js"; -import type { SliceViewRenderLayer } from "#src/sliceview/renderlayer.js"; import type { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; import type { ImageRenderLayer } from "#src/sliceview/volume/image_renderlayer.js"; import type { SegmentationRenderLayer } from "#src/sliceview/volume/segmentation_renderlayer.js"; @@ -131,15 +129,9 @@ export class VoxelEditingContext ); // since we only allow drawing at max res, we can lock the optimistic render layer to it - this.optimisticRenderLayer.filterVisibleSources = function* ( - this: SliceViewRenderLayer, - _sliceView: SliceViewBase, - sources: readonly TransformedSource[], - ): Iterable { - if (sources.length > 0) { - yield sources[0]; - } - }; + ( + this.optimisticRenderLayer as SliceViewRenderLayer + ).getForcedSourceIndexOverride = () => 0; this.hostLayer.addRenderLayer(this.optimisticRenderLayer); diff --git a/src/sliceview/frontend.ts b/src/sliceview/frontend.ts index 99697b202b..76b9fe8f5f 100644 --- a/src/sliceview/frontend.ts +++ b/src/sliceview/frontend.ts @@ -1072,6 +1072,7 @@ export function getVolumetricTransformedSources( effectiveVoxelSize[i] * globalScales[i], ); } + effectiveVoxelSize.fill(1, displayRank); return { layerRank, lowerClipBound, From d3f77a2b91c7e51252078cda64ec7afccfb8c494 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 26 Nov 2025 12:10:14 +0100 Subject: [PATCH 149/251] cleanup: Remove dataset creation feature. --- src/datasource/index.ts | 28 -- src/datasource/zarr/frontend.ts | 13 - src/datasource/zarr/metadata/creation.ts | 372 ----------------- src/layer/layer_data_source.ts | 18 - src/main.ts | 17 - src/sliceview/volume/frontend.ts | 53 --- src/ui/dataset_creation.ts | 482 ----------------------- 7 files changed, 983 deletions(-) delete mode 100644 src/datasource/zarr/metadata/creation.ts delete mode 100644 src/ui/dataset_creation.ts diff --git a/src/datasource/index.ts b/src/datasource/index.ts index 60227fbd08..a5703c2852 100644 --- a/src/datasource/index.ts +++ b/src/datasource/index.ts @@ -54,12 +54,10 @@ import { emptyCompletionResult, getPrefixMatchesWithDescriptions, } from "#src/util/completion.js"; -import type { DataType } from "#src/util/data_type.js"; import { RefCounted } from "#src/util/disposable.js"; import type { vec3 } from "#src/util/geom.js"; import { type ProgressOptions } from "#src/util/progress_listener.js"; import type { Trackable } from "#src/util/trackable.js"; -import { CompoundTrackable } from "#src/util/trackable.js"; export type CompletionResult = BasicCompletionResult; @@ -242,25 +240,6 @@ export function makeEmptyDataSourceSpecification(): DataSourceSpecification { }; } -export interface CommonCreationMetadata { - shape: number[]; - dataType: DataType; - voxelSize: number[]; - voxelUnit: string[]; - numScales: number; - downsamplingFactor: number[]; - name: string; -} -export abstract class DataSourceCreationState extends CompoundTrackable {} -export interface CreateDataSourceOptions { - kvStoreUrl: string; - registry: DataSourceRegistry; - metadata: { - common: CommonCreationMetadata; - sourceRelated?: DataSourceCreationState; - }; -} - export interface DataSourceProvider { scheme: string; description?: string; @@ -285,8 +264,6 @@ export interface KvStoreBasedDataSourceProvider { completeUrl?: ( options: GetKvStoreBasedDataSourceOptions, ) => Promise; - create?(options: CreateDataSourceOptions): Promise; - creationState?: CompoundTrackable; } export interface GetKvStoreBasedDataSourceOptions @@ -321,11 +298,6 @@ export class DataSourceRegistry extends RefCounted { registerKvStoreBasedProvider(provider: KvStoreBasedDataSourceProvider) { this.kvStoreBasedDataSources.set(provider.scheme, provider); } - getKvStoreBasedProvider( - scheme: string, - ): KvStoreBasedDataSourceProvider | undefined { - return this.kvStoreBasedDataSources.get(scheme); - } getProvider(url: string): [DataSourceProvider, string, string] { const m = url.match(schemePattern); diff --git a/src/datasource/zarr/frontend.ts b/src/datasource/zarr/frontend.ts index cce22ee5e4..9fc3223c0e 100644 --- a/src/datasource/zarr/frontend.ts +++ b/src/datasource/zarr/frontend.ts @@ -27,7 +27,6 @@ import { } from "#src/coordinate_transform.js"; import type { ChannelMetadata, - CreateDataSourceOptions, DataSource, GetKvStoreBasedDataSourceOptions, KvStoreBasedDataSourceProvider, @@ -39,10 +38,6 @@ import "#src/datasource/zarr/codec/crc32c/resolve.js"; import "#src/datasource/zarr/codec/gzip/resolve.js"; import "#src/datasource/zarr/codec/sharding_indexed/resolve.js"; import "#src/datasource/zarr/codec/transpose/resolve.js"; -import { - getZarrCreator, - ZarrCreationState, -} from "#src/datasource/zarr/metadata/creation.js"; import type { ArrayMetadata, DimensionSeparator, @@ -491,10 +486,6 @@ export class ZarrDataSource implements KvStoreBasedDataSourceProvider { return `Zarr${versionStr} data source`; } - get creationState() { - return this.zarrVersion ? new ZarrCreationState() : undefined; - } - get(options: GetKvStoreBasedDataSourceOptions): Promise { let { kvStoreUrl, additionalPath, fragment } = resolveUrl(options); kvStoreUrl = kvstoreEnsureDirectoryPipelineUrl( @@ -607,10 +598,6 @@ export class ZarrDataSource implements KvStoreBasedDataSourceProvider { ), ); } - async create(options: CreateDataSourceOptions): Promise { - const creator = getZarrCreator(this.zarrVersion); - await creator.create(options); - } } export function registerAutoDetectV2(registry: AutoDetectRegistry) { diff --git a/src/datasource/zarr/metadata/creation.ts b/src/datasource/zarr/metadata/creation.ts deleted file mode 100644 index 7624f41cd9..0000000000 --- a/src/datasource/zarr/metadata/creation.ts +++ /dev/null @@ -1,372 +0,0 @@ -/** - * @license - * Copyright 2025 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 type { - CreateDataSourceOptions, - CommonCreationMetadata, -} from "#src/datasource/index.js"; -import { DataSourceCreationState } from "#src/datasource/index.js"; -import { proxyWrite } from "#src/kvstore/proxy.js"; -import { joinPath } from "#src/kvstore/url.js"; -import { DataType } from "#src/util/data_type.js"; - -import { TrackableEnum } from "#src/util/trackable_enum.js"; - -export enum ZarrCompression { - RAW = 0, - GZIP = 1, - BLOSC = 2, -} - -export class ZarrCreationState extends DataSourceCreationState { - compression = new TrackableEnum( - ZarrCompression, - ZarrCompression.RAW, - ); - - constructor() { - super(); - this.add("compression", this.compression); - } -} - -const zarrUnitMapping: { [key: string]: string } = { - nm: "nanometer", - um: "micrometer", - mm: "millimeter", - cm: "centimeter", - m: "meter", - s: "second", - ms: "millisecond", - us: "microsecond", - ns: "nanosecond", -}; - -const dataTypeToZarrV2Dtype: { [key in DataType]?: string } = { - [DataType.UINT8]: "|u1", - [DataType.UINT16]: "; -} - -class ZarrV2Creator implements ZarrCreator { - async create(options: CreateDataSourceOptions): Promise { - const { kvStoreUrl, registry, metadata } = options; - const { sharedKvStoreContext } = registry; - const kvStore = sharedKvStoreContext.kvStoreContext.getKvStore(kvStoreUrl); - - const zgroupContent = JSON.stringify({ zarr_format: 2 }); - const writeZgroupPromise = proxyWrite( - sharedKvStoreContext, - kvStore.store.getUrl(joinPath(kvStore.path, ".zgroup")), - new TextEncoder().encode(zgroupContent).buffer as ArrayBuffer, - ); - - const commonMetadata = metadata.common as CommonCreationMetadata; - const zarrMetadata = metadata.sourceRelated as ZarrCreationState; - - const scales = []; - for (let i = 0; i < commonMetadata.numScales; ++i) { - const downsampleCoeffs = commonMetadata.downsamplingFactor.map( - (f: number) => Math.pow(f, i), - ); - scales.push({ - shape: commonMetadata.shape.map((dim: number, j: number) => - Math.ceil(dim / downsampleCoeffs[j]), - ), - chunks: new Array(commonMetadata.shape.length).fill(64), - dtype: dataTypeToZarrV2Dtype[commonMetadata.dataType], - compressor: this._buildV2ZarrayCompressorMetadata(zarrMetadata), - transform: commonMetadata.voxelSize.map( - (v: number, j: number) => v * downsampleCoeffs[j], - ), - }); - } - - const zattrsContent = this._buildV2OmeZattrs(commonMetadata, scales); - const writeZattrsPromise = proxyWrite( - sharedKvStoreContext, - kvStore.store.getUrl(joinPath(kvStore.path, ".zattrs")), - new TextEncoder().encode(zattrsContent).buffer as ArrayBuffer, - ); - - const writeZarrayPromises = scales.map((scale: any, i: number) => { - const zarrayUrl = kvStore.store.getUrl( - joinPath(kvStore.path, `s${i}`, ".zarray"), - ); - const zarrayContent = this._buildV2Zarray(scale); - return proxyWrite( - sharedKvStoreContext, - zarrayUrl, - new TextEncoder().encode(zarrayContent).buffer as ArrayBuffer, - ); - }); - - await Promise.all([ - writeZgroupPromise, - writeZattrsPromise, - ...writeZarrayPromises, - ]); - } - - private _buildV2OmeZattrs( - common: CommonCreationMetadata, - scales: any[], - ): string { - const rank = common.shape.length; - const defaultAxes = ["x", "y", "z", "c", "t"]; - const axes = Array.from({ length: rank }, (_, i) => ({ - name: defaultAxes[i] || `dim_${i}`, - type: "space", - unit: zarrUnitMapping[common.voxelUnit[i]], - })); - - const datasets = scales.map((scale, i) => ({ - path: `s${i}`, - coordinateTransformations: [ - { - type: "scale", - scale: scale.transform, - }, - ], - })); - - const omeMetadata = { - multiscales: [ - { - version: "0.4", - axes, - datasets, - name: common.name || "default", - type: "unknown", - metadata: null, - }, - ], - }; - return JSON.stringify(omeMetadata, null, 2); - } - - private _buildV2ZarrayCompressorMetadata( - zarrState: ZarrCreationState, - ): object | null { - switch (zarrState.compression.value) { - case ZarrCompression.BLOSC: - return { - id: "blosc", - cname: "lz4", - clevel: 5, - shuffle: 1, - }; - case ZarrCompression.GZIP: - return { id: "gzip", level: 1 }; - case ZarrCompression.RAW: - default: - return null; - } - } - - private _buildV2Zarray(scaleMetadata: any): string { - const { shape, chunks, dtype, compressor } = scaleMetadata; - const zarrMetadata = { - zarr_format: 2, - shape: shape, - chunks: chunks, - dtype: dtype, - compressor: compressor, - fill_value: 0, - order: "C", - filters: null, - }; - return JSON.stringify(zarrMetadata, null, 2); - } -} - -const dataTypeToZarrV3Dtype: { [key in DataType]?: string } = { - [DataType.UINT8]: "uint8", - [DataType.UINT16]: "uint16", - [DataType.UINT32]: "uint32", - [DataType.UINT64]: "uint64", - [DataType.INT8]: "int8", - [DataType.INT16]: "int16", - [DataType.INT32]: "int32", - [DataType.FLOAT32]: "float32", -}; - -class ZarrV3Creator implements ZarrCreator { - async create(options: CreateDataSourceOptions): Promise { - const { kvStoreUrl, registry, metadata } = options; - const { sharedKvStoreContext } = registry; - const kvStore = sharedKvStoreContext.kvStoreContext.getKvStore(kvStoreUrl); - - const rootGroupContent = this._buildV3RootGroupMetadata(metadata.common); - const writeRootPromise = proxyWrite( - sharedKvStoreContext, - kvStore.store.getUrl(joinPath(kvStore.path, "zarr.json")), - new TextEncoder().encode(rootGroupContent).buffer as ArrayBuffer, - ); - - const commonMetadata = metadata.common as CommonCreationMetadata; - const zarrMetadata = metadata.sourceRelated as ZarrCreationState; - - const scales = []; - for (let i = 0; i < commonMetadata.numScales; ++i) { - const downsampleCoeffs = commonMetadata.downsamplingFactor.map( - (f: number) => Math.pow(f, i), - ); - scales.push({ - shape: commonMetadata.shape.map((dim: number, j: number) => - Math.ceil(dim / downsampleCoeffs[j]), - ), - chunks: new Array(commonMetadata.shape.length).fill(64), - dataType: dataTypeToZarrV3Dtype[commonMetadata.dataType], - transform: commonMetadata.voxelSize.map( - (v: number, j: number) => v * downsampleCoeffs[j], - ), - }); - } - - const writeArrayPromises = scales.map((scale: any, i: number) => { - const arrayMetaUrl = kvStore.store.getUrl( - joinPath(kvStore.path, `s${i}`, "zarr.json"), - ); - const arrayMetaContent = this._buildV3ArrayMetadata(scale, zarrMetadata); - return proxyWrite( - sharedKvStoreContext, - arrayMetaUrl, - new TextEncoder().encode(arrayMetaContent).buffer as ArrayBuffer, - ); - }); - - await Promise.all([writeRootPromise, ...writeArrayPromises]); - } - - private _buildV3RootGroupMetadata(common: CommonCreationMetadata): string { - const rank = common.shape.length; - const defaultAxes = ["x", "y", "z", "c", "t"]; - const axes = Array.from({ length: rank }, (_, i) => ({ - name: defaultAxes[i] || `dim_${i}`, - type: "space", - unit: zarrUnitMapping[common.voxelUnit[i]], - })); - - const datasets = Array.from({ length: common.numScales }, (_, i) => ({ - path: `s${i}`, - coordinateTransformations: [ - { - type: "scale", - scale: common.downsamplingFactor.map( - (f, j) => common.voxelSize[j] * Math.pow(f, i), - ), - }, - ], - })); - - const omeMetadata = { - multiscales: [ - { - version: "0.5", // OME-NGFF version compatible with Zarr v3 - axes, - datasets, - name: common.name || "default", - }, - ], - }; - - return JSON.stringify( - { - zarr_format: 3, - node_type: "group", - attributes: omeMetadata, - }, - null, - 2, - ); - } - - private _buildV3ArrayMetadata( - scaleMetadata: any, - zarrState: ZarrCreationState, - ): string { - const { shape, chunks, dataType } = scaleMetadata; - - const codecs: { name: string; configuration?: any }[] = [ - { - name: "bytes", - configuration: { - endian: "little", - }, - }, - ]; - - switch (zarrState.compression.value) { - case ZarrCompression.GZIP: - codecs.push({ name: "gzip", configuration: { level: 1 } }); - break; - case ZarrCompression.BLOSC: - codecs.push({ - name: "blosc", - configuration: { - cname: "lz4", - clevel: 5, - shuffle: "bit", - }, - }); - break; - } - - const zarrV3Array = { - zarr_format: 3, - node_type: "array", - shape: shape, - data_type: dataType, - chunk_grid: { - name: "regular", - configuration: { - chunk_shape: chunks, - }, - }, - chunk_key_encoding: { - name: "default", - configuration: { - separator: "/", - }, - }, - codecs: codecs, - fill_value: 0, - attributes: {}, - }; - return JSON.stringify(zarrV3Array, null, 2); - } -} - -export function getZarrCreator(version: number | undefined): ZarrCreator { - switch (version) { - case 2: - return new ZarrV2Creator(); - case 3: - return new ZarrV3Creator(); - default: - throw new Error(`Unsupported Zarr version: ${version}`); - } -} diff --git a/src/layer/layer_data_source.ts b/src/layer/layer_data_source.ts index 0663e33cf2..ec5dd59841 100644 --- a/src/layer/layer_data_source.ts +++ b/src/layer/layer_data_source.ts @@ -36,10 +36,8 @@ import { makeEmptyDataSourceSpecification } from "#src/datasource/index.js"; import type { UserLayer } from "#src/layer/index.js"; import { getWatchableRenderLayerTransform } from "#src/render_coordinate_transform.js"; import type { RenderLayer } from "#src/renderlayer.js"; -import { StatusMessage } from "#src/status.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; import type { WatchableValueInterface } from "#src/trackable_value.js"; -import { DatasetCreationDialog } from "#src/ui/dataset_creation.js"; import { arraysEqual } from "#src/util/array.js"; import type { Borrowed, Owned } from "#src/util/disposable.js"; import { disposableOnce, RefCounted } from "#src/util/disposable.js"; @@ -465,22 +463,6 @@ export class LayerDataSource extends RefCounted { this.loadState_ = { error }; this.messages.clearMessages(); - const status = new StatusMessage(/*delay=*/ false, /*modal=*/ true); - status.element.innerHTML = `Dataset not found at ${this.spec_.url}. `; - const createButton = document.createElement("button"); - createButton.textContent = "Create Dataset"; - createButton.addEventListener("click", () => { - status.dispose(); - new DatasetCreationDialog(this.layer.manager, this.spec_.url); - }); - const cancelButton = document.createElement("button"); - cancelButton.textContent = "Cancel"; - cancelButton.addEventListener("click", () => { - status.dispose(); - }); - status.element.appendChild(createButton); - status.element.appendChild(cancelButton); - this.messages.addMessage({ severity: MessageSeverity.error, message: formatErrorMessage(error), diff --git a/src/main.ts b/src/main.ts index d440a0bb21..8a06431aed 100644 --- a/src/main.ts +++ b/src/main.ts @@ -20,21 +20,4 @@ import { setupDefaultViewer } from "#src/ui/default_viewer_setup.js"; import "#src/util/google_tag_manager.js"; -(function maybeHandleOidcCallback() { - try { - // Only handle when running in a popup opened by our app and when code/state are present. - if (window.opener === null) return; - const params = new URLSearchParams(window.location.search); - const code = params.get("code"); - const state = params.get("state"); - if (code === null || state === null) return; - // Post message back to opener; opener will validate origin and state. - window.opener.postMessage({ type: "oidc_code", code, state }, "*"); - // Close this popup window. - window.close(); - } catch { - // Swallow errors; fall through to normal app startup. - } -})(); - setupDefaultViewer(); diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index 576d873661..c1d531fd29 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -15,8 +15,6 @@ */ import type { ChunkManager } from "#src/chunk_manager/frontend.js"; -import type { CoordinateSpace } from "#src/coordinate_transform.js"; -import type { CommonCreationMetadata } from "#src/datasource/index.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; import type { SliceViewChunkSpecification } from "#src/sliceview/base.js"; import { @@ -407,57 +405,6 @@ export abstract class MultiscaleVolumeChunkSource extends MultiscaleSliceViewChu > { abstract dataType: DataType; abstract volumeType: VolumeType; - - getCreationMetadata( - layerName: string, - inputSpace: CoordinateSpace, - ): CommonCreationMetadata { - const rank = this.rank; - const identityOptions = { - displayRank: rank, - multiscaleToViewTransform: new Float32Array(rank * rank).fill(0), - modelChannelDimensionIndices: [], - }; - for (let i = 0; i < rank; ++i) - identityOptions.multiscaleToViewTransform[i * rank + i] = 1; - - const scales = this.getSources(identityOptions)[0]; - if (!scales || scales.length === 0) { - throw new Error("Data source has no resolution scales."); - } - - const highResSource = scales[0]; - const shape = Array.from(highResSource.chunkSource.spec.upperVoxelBound); - const highResTransform = highResSource.chunkToMultiscaleTransform; - const voxelSize = new Array(rank); - for (let i = 0; i < rank; ++i) { - voxelSize[i] = highResTransform[i * (rank + 1) + i]; - } - - const numScales = scales.length; - const downsamplingFactor = new Array(rank).fill(1); - if (scales.length > 1) { - const lowResSource = scales[1]; - const lowResTransform = lowResSource.chunkToMultiscaleTransform; - for (let i = 0; i < rank; ++i) { - const highResScale = highResTransform[i * (rank + 1) + i]; - const lowResScale = lowResTransform[i * (rank + 1) + i]; - if (highResScale !== 0) { - downsamplingFactor[i] = Math.round(lowResScale / highResScale); - } - } - } - - return { - shape, - dataType: this.dataType, - voxelSize: Array.from(inputSpace.scales), - voxelUnit: Array.from(inputSpace.units), - numScales, - downsamplingFactor, - name: `${layerName}_copy`, - }; - } } export { VolumeChunk }; diff --git a/src/ui/dataset_creation.ts b/src/ui/dataset_creation.ts deleted file mode 100644 index fe8accb634..0000000000 --- a/src/ui/dataset_creation.ts +++ /dev/null @@ -1,482 +0,0 @@ -/** - * @license - * Copyright 2025 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 type { CoordinateSpace } from "#src/coordinate_transform.js"; -import type { - CreateDataSourceOptions, - CommonCreationMetadata, - DataSourceCreationState, -} from "#src/datasource/index.js"; -import type { - LayerListSpecification, - ManagedUserLayer, -} from "#src/layer/index.js"; -import type { LayerDataSource } from "#src/layer/layer_data_source.js"; -import { Overlay } from "#src/overlay.js"; -import type { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; -import { StatusMessage } from "#src/status.js"; -import { TrackableValue } from "#src/trackable_value.js"; -import { TrackableVec3 } from "#src/trackable_vec3.js"; -import { DataType } from "#src/util/data_type.js"; -import { RefCounted } from "#src/util/disposable.js"; -import { removeChildren } from "#src/util/dom.js"; -import { - parseArray, - verifyFiniteFloat, - verifyFinitePositiveFloat, - verifyInt, - verifyString, - verifyStringArray, -} from "#src/util/json.js"; -import { CompoundTrackable, type Trackable } from "#src/util/trackable.js"; -import { TrackableEnum } from "#src/util/trackable_enum.js"; -import { DependentViewWidget } from "#src/widget/dependent_view_widget.js"; -import { EnumSelectWidget } from "#src/widget/enum_widget.js"; -import { NumberInputWidget } from "#src/widget/number_input_widget.js"; -import { TextInputWidget } from "#src/widget/text_input.js"; -import { Vec3Widget } from "#src/widget/vec3_entry_widget.js"; - -const verifyNumberArray = (value: unknown) => - parseArray(value, verifyFiniteFloat); -const verifyPositiveNumberArray = (value: unknown) => - parseArray(value, verifyFinitePositiveFloat); - -class DynamicVectorWidget extends RefCounted { - element = document.createElement("div"); - private inputs: HTMLInputElement[] = []; - - constructor( - public trackable: TrackableValue, - private isStringArray: boolean = false, - ) { - super(); - this.element.style.display = "flex"; - this.element.style.gap = "4px"; - this.registerDisposer(trackable.changed.add(() => this.updateView())); - this.updateView(); - } - - private updateView() { - const value = this.trackable.value; - const rank = value.length; - - if (this.inputs.length !== rank) { - removeChildren(this.element); - this.inputs = []; - for (let i = 0; i < rank; i++) { - const input = document.createElement("input"); - input.type = this.isStringArray ? "text" : "number"; - if (!this.isStringArray) input.step = "any"; - input.addEventListener("change", () => { - if (this.isStringArray) { - const newValues = [...this.trackable.value] as string[]; - newValues[i] = input.value; - (this.trackable as TrackableValue).value = newValues; - } else { - const newValues = [...this.trackable.value] as number[]; - const parsedValue = parseFloat(input.value); - if (!isNaN(parsedValue)) { - newValues[i] = parsedValue; - (this.trackable as TrackableValue).value = newValues; - } else { - input.value = (this.trackable.value[i] ?? "").toString(); - } - } - }); - this.inputs.push(input); - this.element.appendChild(input); - } - } - - for (let i = 0; i < rank; i++) { - this.inputs[i].value = value[i].toString(); - } - } -} - -function createControlForTrackable(trackable: Trackable): HTMLElement { - if (trackable instanceof TrackableVec3) { - return new Vec3Widget(trackable).element; - } - if (trackable instanceof TrackableEnum) { - return new EnumSelectWidget(trackable).element; - } - if (trackable instanceof TrackableValue) { - const value = trackable.value; - if (typeof value === "number") { - return new NumberInputWidget(trackable as TrackableValue).element; - } - if (typeof value === "string") { - return new TextInputWidget(trackable as TrackableValue).element; - } - if (Array.isArray(value)) { - const isString = value.every((v) => typeof v === "string"); - return new DynamicVectorWidget( - trackable as TrackableValue, - isString, - ).element; - } - } - const unsupportedElement = document.createElement("div"); - unsupportedElement.textContent = `Unsupported control type`; - return unsupportedElement; -} - -class CommonMetadataState extends CompoundTrackable { - shape = new TrackableValue( - [42000, 42000, 42000], - verifyNumberArray, - ); - dataType = new TrackableEnum(DataType, DataType.UINT32); - voxelSize = new TrackableValue( - [8, 8, 8], - verifyPositiveNumberArray, - ); - voxelUnit = new TrackableValue( - ["nm", "nm", "nm"], - verifyStringArray, - ); - numScales = new TrackableValue(6, verifyInt); - downsamplingFactor = new TrackableValue( - [2, 2, 2], - verifyPositiveNumberArray, - ); - name = new TrackableValue("new-dataset", verifyString); - rank = new TrackableValue(3, verifyInt); - - constructor() { - super(); - this.add("shape", this.shape); - this.add("dataType", this.dataType); - this.add("voxelSize", this.voxelSize); - this.add("voxelUnit", this.voxelUnit); - this.add("numScales", this.numScales); - this.add("downsamplingFactor", this.downsamplingFactor); - this.add("name", this.name); - this.add("rank", this.rank); - - this.rank.changed.add(() => { - const newRank = this.rank.value; - const resize = ( - trackable: TrackableValue, - defaultValue: number | string, - ) => { - const arr = trackable.value; - if (arr.length === newRank) return; - const newArr = new Array(newRank); - for (let i = 0; i < newRank; ++i) { - newArr[i] = i < arr.length ? arr[i] : defaultValue; - } - trackable.value = newArr; - }; - resize(this.shape, 42000); - resize(this.voxelSize, 8); - resize(this.voxelUnit, "nm"); - resize(this.downsamplingFactor, 2); - }); - } - - toJSON(): CommonCreationMetadata { - return { - shape: this.shape.value, - dataType: this.dataType.value, - voxelSize: this.voxelSize.value, - voxelUnit: this.voxelUnit.value, - numScales: this.numScales.value, - downsamplingFactor: this.downsamplingFactor.value, - name: this.name.value, - }; - } - - restoreState(_obj: any) {} - reset() {} -} - -export class DatasetCreationDialog extends Overlay { - state = new CommonMetadataState(); - dataSourceType = new TrackableValue("", verifyString); - private dataSourceOptions: DataSourceCreationState | undefined; - - addControl = (trackable: Trackable, label: string, parent: HTMLElement) => { - const container = document.createElement("div"); - container.style.display = "flex"; - const labelElement = document.createElement("label"); - labelElement.textContent = label + ": "; - container.appendChild(labelElement); - const ctrl = createControlForTrackable(trackable); - const ctrlContainer = document.createElement("div"); - ctrlContainer.style.display = "flex"; - ctrlContainer.style.flexGrow = "1"; - ctrlContainer.style.justifyContent = "flex-end"; - ctrlContainer.appendChild(ctrl); - container.appendChild(ctrlContainer); - parent.appendChild(container); - }; - - constructor( - public manager: LayerListSpecification, - public url: string, - ) { - super(); - - const { content } = this; - - const titleElement = document.createElement("h2"); - titleElement.textContent = "Create New Dataset"; - content.appendChild(titleElement); - - const topControls = document.createElement("div"); - topControls.style.display = "flex"; - topControls.style.flexDirection = "column"; - - content.appendChild(topControls); - - const dataSourceSelect = document.createElement("select"); - const creatableProviders = Array.from( - this.manager.dataSourceProviderRegistry.kvStoreBasedDataSources.values(), - ).filter((p) => p.creationState !== undefined); - - creatableProviders.forEach((p) => { - const option = document.createElement("option"); - option.value = p.scheme; - option.textContent = p.description || p.scheme; - dataSourceSelect.appendChild(option); - }); - - if (creatableProviders.length > 0) { - this.dataSourceType.value = creatableProviders[0].scheme; - } else { - const noProviderMessage = document.createElement("div"); - noProviderMessage.textContent = - "No creatable data source types are configured."; - content.appendChild(noProviderMessage); - } - - const dsLabel = document.createElement("label"); - dsLabel.textContent = "Data Source Type: "; - topControls.appendChild(dsLabel); - topControls.appendChild(dataSourceSelect); - - this.registerEventListener(dataSourceSelect, "change", () => { - this.dataSourceType.value = dataSourceSelect.value; - }); - - topControls.appendChild( - this.registerDisposer( - new DependentViewWidget( - { - changed: this.manager.rootLayers.layersChanged, - get value() { - return null; - }, - }, - (_value, parentElement) => { - const compatibleDataSources: { - layer: ManagedUserLayer; - dataSource: LayerDataSource; - inputSpace: CoordinateSpace; - volume: MultiscaleVolumeChunkSource; - }[] = []; - - for (const layer of this.manager.rootLayers.managedLayers) { - if (!layer.layer) continue; - for (const dataSource of layer.layer.dataSources) { - const loadState = dataSource.loadState; - if (loadState === undefined || loadState.error !== undefined) - continue; - - for (const subsource of loadState.dataSource.subsources) { - const volume = subsource.subsource.volume; - if (volume) { - compatibleDataSources.push({ - layer, - dataSource, - inputSpace: - loadState.dataSource.modelTransform.inputSpace, - volume, - }); - } - } - } - } - - const label = document.createElement("label"); - label.textContent = "Copy metadata from data source: "; - parentElement.appendChild(label); - - const select = document.createElement("select"); - const defaultOption = document.createElement("option"); - defaultOption.textContent = "None"; - defaultOption.value = ""; - select.appendChild(defaultOption); - - compatibleDataSources.forEach(({ layer, dataSource }, index) => { - const option = document.createElement("option"); - option.textContent = `${layer.name} - ${dataSource.spec.url}`; - option.value = index.toString(); - select.appendChild(option); - }); - - this.registerEventListener(select, "change", () => { - const selectedIndex = parseInt(select.value, 10); - if (selectedIndex === -1) return; - - const selection = compatibleDataSources[selectedIndex]; - if (selection) { - const { layer, inputSpace, volume } = selection; - - const metadata = volume.getCreationMetadata( - layer.name, - inputSpace, - ); - if (metadata) { - this.state.rank.value = metadata.shape.length; - this.state.shape.value = metadata.shape; - this.state.dataType.value = metadata.dataType; - this.state.voxelSize.value = metadata.voxelSize; - this.state.voxelUnit.value = metadata.voxelUnit; - this.state.name.value = metadata.name; - this.state.downsamplingFactor.value = - metadata.downsamplingFactor; - } - } - }); - parentElement.appendChild(select); - }, - ), - ).element, - ); - - const commonFields = document.createElement("fieldset"); - const commonLegend = document.createElement("legend"); - commonLegend.textContent = "Common Metadata"; - commonFields.appendChild(commonLegend); - content.appendChild(commonFields); - - this.addControl(this.state.name, "Name", commonFields); - this.addControl(this.state.rank, "Rank", commonFields); - this.addControl(this.state.shape, "Shape", commonFields); - this.addControl(this.state.dataType, "Data Type", commonFields); - this.addControl(this.state.voxelSize, "Voxel Size", commonFields); - this.addControl(this.state.voxelUnit, "Voxel Unit", commonFields); - this.addControl(this.state.numScales, "Number of Scales", commonFields); - this.addControl( - this.state.downsamplingFactor, - "Downsampling Factor", - commonFields, - ); - - const optionsContainer = document.createElement("fieldset"); - const optionsLegend = document.createElement("legend"); - optionsContainer.appendChild(optionsLegend); - const optionsGrid = document.createElement("div"); - optionsContainer.appendChild(optionsGrid); - content.appendChild(optionsContainer); - - this.registerDisposer( - this.dataSourceType.changed.add(() => { - this.updateDataSourceOptions(optionsGrid, optionsLegend); - }), - ); - this.updateDataSourceOptions(optionsGrid, optionsLegend); - - const actions = document.createElement("div"); - const createButton = document.createElement("button"); - createButton.textContent = "Create"; - this.registerEventListener(createButton, "click", () => - this.createDataset(), - ); - actions.appendChild(createButton); - content.appendChild(actions); - } - - private updateDataSourceOptions( - container: HTMLElement, - legend: HTMLLegendElement, - ) { - if (this.dataSourceOptions) { - this.dataSourceOptions.dispose(); - this.dataSourceOptions = undefined; - } - removeChildren(container); - const provider = - this.manager.dataSourceProviderRegistry.getKvStoreBasedProvider( - this.dataSourceType.value, - ); - legend.textContent = `${provider?.description || this.dataSourceType.value} Metadata`; - const creationState = provider?.creationState as - | DataSourceCreationState - | undefined; - if (creationState) { - this.dataSourceOptions = creationState; - for (const key of Object.keys(creationState)) { - if ( - key === "changed" || - key === "toJSON" || - key === "restoreState" || - key === "reset" - ) - continue; - const trackable = (creationState as any)[key]; - if (trackable && typeof trackable.changed?.add === "function") { - this.addControl(trackable, key, container); - } - } - } - } - - private async createDataset() { - const provider = - this.manager.dataSourceProviderRegistry.getKvStoreBasedProvider( - this.dataSourceType.value, - ); - if (!provider?.create) { - StatusMessage.showTemporaryMessage( - `Data source '${this.dataSourceType.value}' does not support creation.`, - 5000, - ); - return; - } - - const options: CreateDataSourceOptions = { - kvStoreUrl: this.url, - registry: this.manager.dataSourceProviderRegistry, - metadata: { - common: this.state.toJSON(), - sourceRelated: this.dataSourceOptions, - }, - }; - - StatusMessage.forPromise(provider.create(options), { - initialMessage: `Creating dataset at ${this.url}...`, - delay: true, - errorPrefix: "Creation failed: ", - }).then(() => { - StatusMessage.showTemporaryMessage("Dataset created successfully.", 3000); - for (const layer of this.manager.rootLayers.managedLayers) { - if (layer.layer) { - for (const ds of layer.layer.dataSources) { - if (ds.spec.url === this.url) { - ds.spec = { ...ds.spec }; - this.dispose(); - return; - } - } - } - } - }); - } -} From 5624813d8fad901518189bfac2d6798ccf901f82 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 27 Nov 2025 13:27:33 +0100 Subject: [PATCH 150/251] cleanup: remove dev files (docker for testsuite and dev scripts) and update TODOs.md --- .dockerignore | 12 ------------ .mpp.txt | 3 --- Dockerfile | 34 ---------------------------------- run_docker_tests.sh | 20 -------------------- src/voxel_annotation/TODOs.md | 27 --------------------------- 5 files changed, 96 deletions(-) delete mode 100644 .dockerignore delete mode 100644 .mpp.txt delete mode 100644 Dockerfile delete mode 100755 run_docker_tests.sh diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 56665950cf..0000000000 --- a/.dockerignore +++ /dev/null @@ -1,12 +0,0 @@ -node_modules -dist -.git -.idea -.vscode -test-results -playwright-report -blob-report -.venv -**/.venv -**/__pycache__ -*.pyc \ No newline at end of file diff --git a/.mpp.txt b/.mpp.txt deleted file mode 100644 index 314a7bc969..0000000000 --- a/.mpp.txt +++ /dev/null @@ -1,3 +0,0 @@ -base: -i src/datasource/* src/kvstore/* src/voxel_annotation/* src/layer/* src/layer/*/* src/ui/voxel_annotations.ts src/sliceview/* src/chunk_manager/* src/sliceview/volume/* src/*.ts README.md tsconfig.json tslint.json package.json -zarr: -i src/datasource/* src/kvstore/* src/voxel_annotation/* src/layer/* src/layer/*/* src/ui/voxel_annotations.ts src/datasource/zarr/* src/datasource/zarr/*/* src/sliceview/* src/chunk_manager/* src/sliceview/volume/* src/*.ts README.md tsconfig.json tslint.json package.json -test: -i src/datasource/* src/kvstore/* src/voxel_annotation/* src/layer/* src/layer/*/* src/ui/voxel_annotations.ts src/datasource/zarr/* src/datasource/zarr/*/* src/sliceview/* src/chunk_manager/* src/sliceview/volume/* tests/*/* src/*.ts .github/*/* vitest.workspace.ts playwright.config.ts README.md tsconfig.json tslint.json package.json diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 95cfb0b05c..0000000000 --- a/Dockerfile +++ /dev/null @@ -1,34 +0,0 @@ -FROM mcr.microsoft.com/playwright:v1.50.1-jammy - -ENV CI=true -ENV DEBIAN_FRONTEND=noninteractive -ENV NODE_OPTIONS="--dns-result-order=ipv4first" -ENV UV_PYTHON=3.12 - -COPY --from=node:22-bookworm-slim /usr/local/bin/node /usr/local/bin/node -COPY --from=node:22-bookworm-slim /usr/local/lib/node_modules /usr/local/lib/node_modules - -COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv - -COPY --from=golang:1.24-bookworm /usr/local/go /usr/local/go -ENV PATH="/usr/local/go/bin:${PATH}" - -RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ - ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx && \ - npm install -g pnpm - -WORKDIR /app - -COPY package.json package-lock.json ./ - -COPY build_tools ./build_tools - -RUN --mount=type=cache,target=/root/.npm \ - npm ci - -RUN cd build_tools/vitest/python_tools && uv sync --frozen - -COPY . . - -ENTRYPOINT ["npm", "run", "test"] -CMD [] diff --git a/run_docker_tests.sh b/run_docker_tests.sh deleted file mode 100755 index eb838925ef..0000000000 --- a/run_docker_tests.sh +++ /dev/null @@ -1,20 +0,0 @@ -set -e - -export DOCKER_BUILDKIT=1 - -IMAGE_NAME="neuroglancer-playwright-runner" - -echo "Building Docker image: $IMAGE_NAME..." -echo " (Note: First build may be slow. Subsequent builds use cache.)" - -docker build -t $IMAGE_NAME . - -echo "Running Playwright tests with arguments: $@" - -mkdir -p playwright-report test-results - -docker run --rm \ - -v "$(pwd)/playwright-report:/app/playwright-report" \ - -v "$(pwd)/test-results:/app/test-results" \ - --ipc=host \ - $IMAGE_NAME "$@" diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 2f8d4b7525..39b8f08a87 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -2,14 +2,9 @@ ### priority -- finish the unit tests list below -- add integration tests -- fix the security flaw (see PR) - ### later - add preview for the undo/redo -- url completion for the ssa+https source ### questionable @@ -17,25 +12,3 @@ - add support to float32 dataset - add support to unaligned hierarchy (e.g. child chunks that may have multiple parents) - adapt the brush size to the zoom level linearly - -## Tests - -- src/voxel_annotation/edit_backend.ts - - [x] \_calculateParentUpdate - - [x] \_getParentChunkInfo - - [x] downsampleStep - - [x] undo/redo - - [x] flushPending -- src/voxel_annotation/edit_controller.ts - - [x] floodFillPlane2D - - [x] paintBrushWithShape -- src/layer/vox/index.ts - - [x] getVoxelPositionFromMouse - - [x] setVoxelPaintValue - - [x] transformGlobalToVoxelNormal -- src/sliceview/volume/backend.ts - - [x] applyEdits -- src/sliceview/volume/frontend.ts - - [x] applyLocalEdits -- src/datasource/zarr/backend.ts - - [ ] writeChunk From 22394eb3c6e51514be01d32db01ae5b21b6ff997 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 27 Nov 2025 13:42:18 +0100 Subject: [PATCH 151/251] chore: add missing license headers across multiple files in the codebase --- src/async_computation/encode_blosc.ts | 16 ++++++++++++++++ src/async_computation/encode_blosc_request.ts | 16 ++++++++++++++++ src/datasource/zarr/codec/blosc/encode.ts | 16 ++++++++++++++++ src/datasource/zarr/codec/bytes/encode.ts | 16 ++++++++++++++++ src/datasource/zarr/codec/encode.ts | 16 ++++++++++++++++ src/datasource/zarr/codec/gzip/encode.ts | 16 ++++++++++++++++ src/layer/vox/index.browser_test.ts | 16 ++++++++++++++++ src/sliceview/volume/backend.spec.ts | 16 ++++++++++++++++ src/sliceview/volume/frontend.spec.ts | 16 ++++++++++++++++ src/ui/voxel_annotations.ts | 2 +- src/voxel_annotation/edit_backend.spec.ts | 16 ++++++++++++++++ src/voxel_annotation/edit_controller.spec.ts | 16 ++++++++++++++++ .../pipeline_zarr_s3.browser_test.ts | 16 ++++++++++++++++ 13 files changed, 193 insertions(+), 1 deletion(-) diff --git a/src/async_computation/encode_blosc.ts b/src/async_computation/encode_blosc.ts index d48ddc1526..443b166cbf 100644 --- a/src/async_computation/encode_blosc.ts +++ b/src/async_computation/encode_blosc.ts @@ -1,3 +1,19 @@ +/** + * @license + * Copyright 2025 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 { encodeBlosc } from "#src/async_computation/encode_blosc_request.js"; import { registerAsyncComputation } from "#src/async_computation/handler.js"; diff --git a/src/async_computation/encode_blosc_request.ts b/src/async_computation/encode_blosc_request.ts index 8ac946ebc3..c1151f0819 100644 --- a/src/async_computation/encode_blosc_request.ts +++ b/src/async_computation/encode_blosc_request.ts @@ -1,3 +1,19 @@ +/** + * @license + * Copyright 2025 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 { asyncComputation } from "#src/async_computation/index.js"; export const encodeBlosc = diff --git a/src/datasource/zarr/codec/blosc/encode.ts b/src/datasource/zarr/codec/blosc/encode.ts index 2da2d1f5de..58871abd7c 100644 --- a/src/datasource/zarr/codec/blosc/encode.ts +++ b/src/datasource/zarr/codec/blosc/encode.ts @@ -1,3 +1,19 @@ +/** + * @license + * Copyright 2025 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 { encodeBlosc } from "#src/async_computation/encode_blosc_request.js"; import { requestAsyncComputation } from "#src/async_computation/request.js"; import type { Configuration } from "#src/datasource/zarr/codec/blosc/resolve.js"; diff --git a/src/datasource/zarr/codec/bytes/encode.ts b/src/datasource/zarr/codec/bytes/encode.ts index dca2801f98..b1fa5d127d 100644 --- a/src/datasource/zarr/codec/bytes/encode.ts +++ b/src/datasource/zarr/codec/bytes/encode.ts @@ -1,3 +1,19 @@ +/** + * @license + * Copyright 2025 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 type { Configuration } from "#src/datasource/zarr/codec/bytes/resolve.js"; import { registerCodec } from "#src/datasource/zarr/codec/encode.js"; import { diff --git a/src/datasource/zarr/codec/encode.ts b/src/datasource/zarr/codec/encode.ts index f2ec602c87..38bc755672 100644 --- a/src/datasource/zarr/codec/encode.ts +++ b/src/datasource/zarr/codec/encode.ts @@ -1,3 +1,19 @@ +/** + * @license + * Copyright 2025 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 type { CodecChainSpec, Codec, diff --git a/src/datasource/zarr/codec/gzip/encode.ts b/src/datasource/zarr/codec/gzip/encode.ts index aa21ea4d86..613d25bf4c 100644 --- a/src/datasource/zarr/codec/gzip/encode.ts +++ b/src/datasource/zarr/codec/gzip/encode.ts @@ -1,3 +1,19 @@ +/** + * @license + * Copyright 2025 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 { registerCodec } from "#src/datasource/zarr/codec/encode.js"; import type { Configuration } from "#src/datasource/zarr/codec/gzip/resolve.js"; import { CodecKind } from "#src/datasource/zarr/codec/index.js"; diff --git a/src/layer/vox/index.browser_test.ts b/src/layer/vox/index.browser_test.ts index ad89048a28..fe5d35f561 100644 --- a/src/layer/vox/index.browser_test.ts +++ b/src/layer/vox/index.browser_test.ts @@ -1,3 +1,19 @@ +/** + * @license + * Copyright 2025 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import { describe, it, expect, beforeEach, afterEach } from "vitest"; import type { ChunkManager } from "#src/chunk_manager/frontend.js"; import type { CoordinateSpaceTransform } from "#src/coordinate_transform.js"; diff --git a/src/sliceview/volume/backend.spec.ts b/src/sliceview/volume/backend.spec.ts index 2f7e52cd8c..59216dda0d 100644 --- a/src/sliceview/volume/backend.spec.ts +++ b/src/sliceview/volume/backend.spec.ts @@ -1,3 +1,19 @@ +/** + * @license + * Copyright 2025 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { ChunkState } from "#src/chunk_manager/base.js"; import type { VolumeChunk } from "#src/sliceview/volume/backend.js"; diff --git a/src/sliceview/volume/frontend.spec.ts b/src/sliceview/volume/frontend.spec.ts index a51be5b313..5175d25d18 100644 --- a/src/sliceview/volume/frontend.spec.ts +++ b/src/sliceview/volume/frontend.spec.ts @@ -1,3 +1,19 @@ +/** + * @license + * Copyright 2025 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest"; import { DataType } from "#src/util/data_type.js"; diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index d4005bf230..76e204abf2 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025. + * Copyright 2025 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 diff --git a/src/voxel_annotation/edit_backend.spec.ts b/src/voxel_annotation/edit_backend.spec.ts index 0b79d84ca4..486a7dd3eb 100644 --- a/src/voxel_annotation/edit_backend.spec.ts +++ b/src/voxel_annotation/edit_backend.spec.ts @@ -1,3 +1,19 @@ +/** + * @license + * Copyright 2025 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { mat4 } from "#src/util/geom.js"; import { diff --git a/src/voxel_annotation/edit_controller.spec.ts b/src/voxel_annotation/edit_controller.spec.ts index ed02c9caeb..0980fda306 100644 --- a/src/voxel_annotation/edit_controller.spec.ts +++ b/src/voxel_annotation/edit_controller.spec.ts @@ -1,3 +1,19 @@ +/** + * @license + * Copyright 2025 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import { describe, it, expect, vi, beforeEach } from "vitest"; import { vec3 } from "#src/util/geom.js"; import { diff --git a/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts b/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts index 050d3b60a1..4da6a4540f 100644 --- a/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts +++ b/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts @@ -1,3 +1,19 @@ +/** + * @license + * Copyright 2025 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/datasource/zarr/register_default.js"; import "#src/kvstore/s3/register_frontend.js"; import "#src/sliceview/uncompressed_chunk_format.js"; From 91a71e4ec3d396c468bcf081ec20d76835f9b5f3 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 27 Nov 2025 15:48:46 +0100 Subject: [PATCH 152/251] refactor(voxel-annotation): remove unused styles, improve toolbox structure, and enforce controller checks --- src/layer/vox/index.ts | 20 +++++++++++------ src/layer/vox/style.css | 44 ------------------------------------- src/layer/vox/tabs/tools.ts | 26 ++++++++++++++++------ 3 files changed, 32 insertions(+), 58 deletions(-) delete mode 100644 src/layer/vox/style.css diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 64a1f6d413..e4d5ee27bc 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -121,11 +121,9 @@ export class VoxelEditingContext primarySource, ); - const transform = primaryRenderLayer.transform; - this.optimisticRenderLayer = this.hostLayer._createVoxelRenderLayer( this.previewSource, - transform, + primaryRenderLayer.transform, ); // since we only allow drawing at max res, we can lock the optimistic render layer to it @@ -181,8 +179,10 @@ export class VoxelEditingContext shape: BrushShape, basis?: { u: Float32Array; v: Float32Array }, ) { + if (!this._controller) + throw new Error("Cannot use paintBrushWithShape without a controller"); if (await this.checkPermission()) { - this._controller?.paintBrushWithShape( + this._controller.paintBrushWithShape( centerCanonical, radiusCanonical, value, @@ -198,8 +198,10 @@ export class VoxelEditingContext maxVoxels: number, planeNormal: vec3, ) { + if (!this._controller) + throw new Error("Cannot use floodFillPlane2D without a controller"); if (await this.checkPermission()) { - return this._controller?.floodFillPlane2D( + return this._controller.floodFillPlane2D( startPositionCanonical, fillValue, maxVoxels, @@ -210,14 +212,18 @@ export class VoxelEditingContext } async undo() { + if (!this._controller) + throw new Error("Cannot use undo without a controller"); if (await this.checkPermission()) { - this._controller?.undo(); + this._controller.undo(); } } async redo() { + if (!this._controller) + throw new Error("Cannot use redo without a controller"); if (await this.checkPermission()) { - this._controller?.redo(); + this._controller.redo(); } } diff --git a/src/layer/vox/style.css b/src/layer/vox/style.css deleted file mode 100644 index 81d2ca9b14..0000000000 --- a/src/layer/vox/style.css +++ /dev/null @@ -1,44 +0,0 @@ -/** - * @license - * Copyright 2025 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. - */ - -.neuroglancer-vox-row label { - flex: 0 0 140px; - min-width: 0; - font-weight: 500; -} - -.neuroglancer-vox-status { - display: block; - flex: 1 1 100%; - min-width: 100%; - padding-top: 4px; -} - -.neuroglancer-vox-settings-tab button:hover, -.neuroglancer-vox-tools-tab button:hover { - filter: brightness(1.06); -} - -.neuroglancer-vox-settings-tab button:active, -.neuroglancer-vox-tools-tab button:active { - transform: translateY(1px); -} - -.neuroglancer-vox-toolbox { - display: flex; - flex-direction: column; - gap: 8px; -} diff --git a/src/layer/vox/tabs/tools.ts b/src/layer/vox/tabs/tools.ts index 8cb2ed2692..25b6181eb6 100644 --- a/src/layer/vox/tabs/tools.ts +++ b/src/layer/vox/tabs/tools.ts @@ -32,13 +32,10 @@ export class VoxToolTab extends Tab { constructor(public layer: UserLayerWithVoxelEditing) { super(); const { element } = this; - element.classList.add("neuroglancer-vox-tools-tab"); const toolbox = document.createElement("div"); - toolbox.className = "neuroglancer-vox-toolbox"; const toolsRow = document.createElement("div"); - toolsRow.className = "neuroglancer-vox-row"; const toolsTitle = document.createElement("div"); toolsTitle.textContent = "Tools"; toolsTitle.style.fontWeight = "600"; @@ -69,6 +66,11 @@ export class VoxToolTab extends Tab { toolsRow.appendChild(toolButtonsContainer); toolbox.appendChild(toolsRow); + const settingsTitle = document.createElement("div"); + settingsTitle.textContent = "Settings"; + settingsTitle.style.fontWeight = "600"; + toolbox.appendChild(settingsTitle); + for (const controlDef of VOXEL_LAYER_CONTROLS) { const controlElement = addLayerControlToOptionsTab( this, @@ -78,8 +80,8 @@ export class VoxToolTab extends Tab { ); if ( - controlDef.toolJson.type === "vox:undo" || - controlDef.toolJson.type === "vox:redo" + controlDef.toolJson.type === "vox-undo" || + controlDef.toolJson.type === "vox-redo" ) { const button = controlElement.querySelector("button"); if (button) { @@ -88,7 +90,10 @@ export class VoxToolTab extends Tab { { changed: this.layer.layersChanged, get value() { - return layer.editingContexts.values().next().value.controller; + return ( + layer.editingContexts.values().next().value?._controller ?? + undefined + ); }, }, ( @@ -101,7 +106,7 @@ export class VoxToolTab extends Tab { return; } const watchable = - controlDef.toolJson.type === "vox:undo" + controlDef.toolJson.type === "vox-undo" ? controller.undoCount : controller.redoCount; context.registerDisposer( @@ -116,6 +121,13 @@ export class VoxToolTab extends Tab { } } + if (controlDef.toolJson.type === "vox-undo") { + const actionsTitle = document.createElement("div"); + actionsTitle.textContent = "Actions"; + actionsTitle.style.fontWeight = "600"; + toolbox.appendChild(actionsTitle); + } + toolbox.appendChild(controlElement); } From df9092a50a1dcebc289839d7f7fd425bd06b0260 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 27 Nov 2025 16:33:42 +0100 Subject: [PATCH 153/251] refactor(voxel-annotation): replace `planeNormal` with `basis` in floodFillPlane2D and related methods, update tests accordingly --- src/layer/vox/index.ts | 4 +- src/layer/vox/tabs/tools.ts | 4 +- src/sliceview/base.ts | 2 +- src/sliceview/volume/frontend.ts | 12 +--- src/ui/voxel_annotations.ts | 68 +++++++++---------- src/voxel_annotation/edit_controller.spec.ts | 59 ++++++++-------- src/voxel_annotation/edit_controller.ts | 17 +---- .../pipeline_zarr_s3.browser_test.ts | 8 ++- 8 files changed, 78 insertions(+), 96 deletions(-) diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index e4d5ee27bc..0ee730d86c 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -196,7 +196,7 @@ export class VoxelEditingContext startPositionCanonical: Float32Array, fillValue: bigint, maxVoxels: number, - planeNormal: vec3, + basis: { u: Float32Array; v: Float32Array }, ) { if (!this._controller) throw new Error("Cannot use floodFillPlane2D without a controller"); @@ -205,7 +205,7 @@ export class VoxelEditingContext startPositionCanonical, fillValue, maxVoxels, - planeNormal, + basis, ); } return undefined; diff --git a/src/layer/vox/tabs/tools.ts b/src/layer/vox/tabs/tools.ts index 25b6181eb6..4bdff714b3 100644 --- a/src/layer/vox/tabs/tools.ts +++ b/src/layer/vox/tabs/tools.ts @@ -19,7 +19,7 @@ import type { UserLayerWithVoxelEditing } from "#src/layer/vox/index.js"; import { observeWatchable } from "#src/trackable_value.js"; import { makeToolButton } from "#src/ui/tool.js"; import { - ADOPT_VOXEL_LABEL_TOOL_ID, + SEG_PICKER_TOOL_ID, BRUSH_TOOL_ID, FLOODFILL_TOOL_ID, } from "#src/ui/voxel_annotations.js"; @@ -56,7 +56,7 @@ export class VoxToolTab extends Tab { }); const pickButton = makeToolButton(this, layer.toolBinder, { - toolJson: { type: ADOPT_VOXEL_LABEL_TOOL_ID }, + toolJson: { type: SEG_PICKER_TOOL_ID }, label: "Seg Picker", }); diff --git a/src/sliceview/base.ts b/src/sliceview/base.ts index 667cd99013..855ae6d7da 100644 --- a/src/sliceview/base.ts +++ b/src/sliceview/base.ts @@ -694,7 +694,7 @@ export function* filterVisibleSources( renderLayer: SliceViewRenderLayer, sources: readonly TransformedSource[], ): Iterable { - // First: allow a render layer to force a specific multiscale index for safety-critical flows. + // allows a render layer to force a specific multiscale const forcedIndex = renderLayer.getForcedSourceIndexOverride?.(); if (forcedIndex !== undefined) { if ( diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index c1d531fd29..ce6a72efc8 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -206,15 +206,7 @@ export class VolumeChunkSource return initialValue; } - const { spec } = this; - const { rank, chunkDataSize } = spec; - const chunkGridPosition = this.tempChunkGridPosition; - - for (let chunkDim = 0; chunkDim < rank; ++chunkDim) { - const voxel = chunkPosition[chunkDim]; - const chunkSize = chunkDataSize[chunkDim]; - chunkGridPosition[chunkDim] = Math.floor(voxel / chunkSize); - } + const { chunkGridPosition } = this.computeChunkIndices(chunkPosition); try { await this.rpc!.promiseInvoke(SLICEVIEW_REQUEST_CHUNK_RPC_ID, { @@ -341,7 +333,7 @@ export class InMemoryVolumeChunkSource extends VolumeChunkSource { this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); } }; - // adding a small delay to avoid flickering since the base source will take some time to download the new data + // adding a small delay to avoid flickering due to the base source taking some time to download the new data setTimeout(update, 100); } diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 76e204abf2..b5ddb464e6 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -31,7 +31,7 @@ import { BrushShape } from "#src/voxel_annotation/base.js"; export const BRUSH_TOOL_ID = "vox-brush"; export const FLOODFILL_TOOL_ID = "vox-flood-fill"; -export const ADOPT_VOXEL_LABEL_TOOL_ID = "vox-pick-label"; +export const SEG_PICKER_TOOL_ID = "vox-seg-picker"; const VOX_TOOL_INPUT_MAP = EventActionMap.fromObject({ ["at:control+mousedown0"]: "paint-voxels", @@ -39,6 +39,7 @@ const VOX_TOOL_INPUT_MAP = EventActionMap.fromObject({ abstract class BaseVoxelTool extends LayerTool { protected latestMouseState: MouseSelectionState | null = null; + private lastNormal: vec3 | undefined = undefined; protected getEditingContext(): VoxelEditingContext | undefined { const it = this.layer.editingContexts.values(); @@ -57,15 +58,14 @@ abstract class BaseVoxelTool extends LayerTool { | undefined; if (!mouseState?.active || !vox) return undefined; if (!mouseState.planeNormal) return; - const planeNormal = editContext.transformGlobalToVoxelNormal( + this.lastNormal = editContext.transformGlobalToVoxelNormal( mouseState.planeNormal, ); - if (!mouseState?.active || !vox || !planeNormal) return undefined; const CHUNK_POSITION_EPSILON = 1e-3; const shiftedVox = new Float32Array(3); for (let i = 0; i < 3; ++i) { shiftedVox[i] = - vox[i] + CHUNK_POSITION_EPSILON * Math.abs(planeNormal[i]); + vox[i] + CHUNK_POSITION_EPSILON * Math.abs(this.lastNormal[i]); } return new Int32Array([ Math.floor(shiftedVox[0]), @@ -132,13 +132,27 @@ abstract class BaseVoxelTool extends LayerTool { panel.element.style.removeProperty("cursor"); } } + + protected getBasis() { + const n = this.lastNormal; + if (!n) return undefined; // Should never happen as getPoint is always called before... this is not clean + const u = vec3.create(); + const tempVec = + Math.abs(vec3.dot(n, vec3.fromValues(1, 0, 0))) < 0.9 + ? vec3.fromValues(1, 0, 0) + : vec3.fromValues(0, 1, 0); + vec3.cross(u, tempVec, n); + vec3.normalize(u, u); + const v = vec3.cross(vec3.create(), n, u); + vec3.normalize(v, v); + return { u, v }; + } } export class VoxelBrushTool extends BaseVoxelTool { private isDrawing = false; private lastPoint: Int32Array | undefined; private mouseDisposer: (() => void) | undefined; - private currentMouseState: MouseSelectionState | undefined; private animationFrameHandle: number | null = null; activate(activation: ToolActivation) { @@ -328,7 +342,6 @@ export class VoxelBrushTool extends BaseVoxelTool { private startDrawing(mouseState: MouseSelectionState) { if (this.isDrawing) return; this.isDrawing = true; - this.currentMouseState = mouseState; const start = this.getPoint(mouseState); if (!start) { @@ -345,7 +358,6 @@ export class VoxelBrushTool extends BaseVoxelTool { this.mouseDisposer = mouseState.changed.add(() => { this.latestMouseState = mouseState; - this.currentMouseState = mouseState; }); if (this.animationFrameHandle === null) { @@ -377,21 +389,9 @@ export class VoxelBrushTool extends BaseVoxelTool { throw new Error("editContext is undefined"); } const shapeEnum = this.layer.voxBrushShape.value; - let basis = undefined as undefined | { u: Float32Array; v: Float32Array }; - if (shapeEnum === BrushShape.DISK && this.currentMouseState?.planeNormal) { - const n = editContext.transformGlobalToVoxelNormal( - this.currentMouseState.planeNormal, - ); - const u = vec3.create(); - const tempVec = - Math.abs(vec3.dot(n, vec3.fromValues(1, 0, 0))) < 0.9 - ? vec3.fromValues(1, 0, 0) - : vec3.fromValues(0, 1, 0); - vec3.cross(u, tempVec, n); - vec3.normalize(u, u); - const v = vec3.cross(vec3.create(), n, u); - vec3.normalize(v, v); - basis = { u, v }; + let basis: undefined | { u: Float32Array; v: Float32Array } = undefined; + if (shapeEnum === BrushShape.DISK) { + basis = this.getBasis(); } for (const p of points) { @@ -442,11 +442,14 @@ export class VoxelFloodFillTool extends BaseVoxelTool { return; } const seed = this.getPoint(this.mouseState); - if (!this.mouseState.planeNormal) return; - const planeNormal = editContext.transformGlobalToVoxelNormal( - this.mouseState.planeNormal, - ); - if (!seed || !planeNormal) return; + const basis = this.getBasis(); + if (!seed || !basis) { + StatusMessage.showTemporaryMessage( + "Unable to retrieve mouse position. Please try again.", + 5000, + ); + return; + } try { const value = this.layer.getVoxelPaintValue( this.layer.voxEraseMode.value, @@ -457,12 +460,7 @@ export class VoxelFloodFillTool extends BaseVoxelTool { } void editContext - .floodFillPlane2D( - new Float32Array(seed), - value, - Math.floor(max), - planeNormal, - ) + .floodFillPlane2D(new Float32Array(seed), value, Math.floor(max), basis) .catch((e: any) => StatusMessage.showTemporaryMessage(String(e?.message ?? e)), ); @@ -526,7 +524,7 @@ export class AdoptVoxelValueTool extends LayerTool { } toJSON() { - return ADOPT_VOXEL_LABEL_TOOL_ID; + return SEG_PICKER_TOOL_ID; } get description() { @@ -621,7 +619,7 @@ export function registerVoxelTools(LayerCtor: any) { ); registerTool( LayerCtor, - ADOPT_VOXEL_LABEL_TOOL_ID, + SEG_PICKER_TOOL_ID, (layer: UserLayerWithVoxelEditing) => new AdoptVoxelValueTool(layer), ); } diff --git a/src/voxel_annotation/edit_controller.spec.ts b/src/voxel_annotation/edit_controller.spec.ts index 0980fda306..a90d6db665 100644 --- a/src/voxel_annotation/edit_controller.spec.ts +++ b/src/voxel_annotation/edit_controller.spec.ts @@ -15,7 +15,6 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { vec3 } from "#src/util/geom.js"; import { BrushShape, VOX_EDIT_COMMIT_VOXELS_RPC_ID, @@ -202,13 +201,16 @@ describe("VoxelEditController", () => { const seed = new Float32Array([3, 3, 0]); const fillValue = 5n; const maxVoxels = 100; - const planeNormal = vec3.fromValues(0, 0, 1); + const basis = { + u: new Float32Array([1, 0, 0]), + v: new Float32Array([0, 1, 0]), + }; const result = await controller.floodFillPlane2D( seed, fillValue, maxVoxels, - planeNormal, + basis, ); expect(result.filledCount).toBe(9); @@ -222,10 +224,13 @@ describe("VoxelEditController", () => { it("respects the plane constraint", async () => { const seed = new Float32Array([50, 50, 5]); const maxVoxels = 20; - const planeNormal = vec3.fromValues(0, 0, 1); + const basis = { + u: new Float32Array([1, 0, 0]), + v: new Float32Array([0, 1, 0]), + }; await expect( - controller.floodFillPlane2D(seed, 2n, maxVoxels, planeNormal), + controller.floodFillPlane2D(seed, 2n, maxVoxels, basis), ).rejects.toThrow(/exceeds the limit/); mockPrimarySource.dataMap.set("51,50,5", 1n); @@ -233,12 +238,7 @@ describe("VoxelEditController", () => { mockPrimarySource.dataMap.set("50,51,5", 1n); mockPrimarySource.dataMap.set("50,49,5", 1n); - const result = await controller.floodFillPlane2D( - seed, - 2n, - 100, - planeNormal, - ); + const result = await controller.floodFillPlane2D(seed, 2n, 100, basis); expect(result.filledCount).toBe(1); expect(result.edits[0].indices.length).toBe(1); @@ -251,27 +251,25 @@ describe("VoxelEditController", () => { it("throws when max voxels exceeded", async () => { const seed = new Float32Array([10, 10, 0]); const maxVoxels = 10; + const basis = { + u: new Float32Array([1, 0, 0]), + v: new Float32Array([0, 1, 0]), + }; await expect( - controller.floodFillPlane2D( - seed, - 9n, - maxVoxels, - vec3.fromValues(0, 0, 1), - ), + controller.floodFillPlane2D(seed, 9n, maxVoxels, basis), ).rejects.toThrow("Flood fill region exceeds the limit"); }); it("does nothing if seed value equals fill value", async () => { mockPrimarySource.dataMap.set("10,10,0", 5n); const seed = new Float32Array([10, 10, 0]); + const basis = { + u: new Float32Array([1, 0, 0]), + v: new Float32Array([0, 1, 0]), + }; - const result = await controller.floodFillPlane2D( - seed, - 5n, - 100, - vec3.fromValues(0, 0, 1), - ); + const result = await controller.floodFillPlane2D(seed, 5n, 100, basis); expect(result.filledCount).toBe(0); expect(result.edits.length).toBe(0); @@ -292,24 +290,27 @@ describe("VoxelEditController", () => { const size = 20; for (let i = 0; i <= size; i++) { - mockPrimarySource.dataMap.set(`${i},0,0`, 1n); - mockPrimarySource.dataMap.set(`${i},${size},0`, 1n); + mockPrimarySource.dataMap.set(`0,0,${i}`, 1n); + mockPrimarySource.dataMap.set(`0,${size},${i}`, 1n); mockPrimarySource.dataMap.set(`0,${i},0`, 1n); if (i !== 10) { - mockPrimarySource.dataMap.set(`${size},${i},0`, 1n); + mockPrimarySource.dataMap.set(`0,${i},${size}`, 1n); } } - const seed = new Float32Array([10, 10, 0]); + const seed = new Float32Array([0, 10, 10]); const fillValue = 2n; const maxVoxels = 2000; - const planeNormal = vec3.fromValues(0, 0, 1); + const basis = { + u: new Float32Array([0, 0, 1]), + v: new Float32Array([0, 1, 0]), + }; const result = await controller.floodFillPlane2D( seed, fillValue, maxVoxels, - planeNormal, + basis, ); expect(result.filledCount).toBeLessThan(1000); diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index c841c49084..136a1fea3e 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -307,7 +307,7 @@ export class VoxelEditController extends SharedObject { startPositionCanonical: Float32Array, fillValue: bigint, maxVoxels: number, - planeNormal: vec3, // MUST be a normalized vector + basis: { u: Float32Array; v: Float32Array }, ): Promise<{ edits: { key: string; indices: number[]; value: bigint }[]; filledCount: number; @@ -338,17 +338,6 @@ export class VoxelEditController extends SharedObject { return { edits: [], filledCount: 0, originalValue }; } - const U = vec3.create(); - const V = vec3.create(); - const tempVec = - Math.abs(vec3.dot(planeNormal, vec3.fromValues(1, 0, 0))) < 0.9 - ? vec3.fromValues(1, 0, 0) - : vec3.fromValues(0, 1, 0); - vec3.cross(U, tempVec, planeNormal); - vec3.normalize(U, U); - vec3.cross(V, planeNormal, U); - vec3.normalize(V, V); - const visited = new Set(); const queue: [number, number][] = []; let filledCount = 0; @@ -356,8 +345,8 @@ export class VoxelEditController extends SharedObject { const map2dTo3d = (u: number, v: number): vec3 => { const point = vec3.clone(startVoxelLod); - vec3.scaleAndAdd(point, point, U, u); - vec3.scaleAndAdd(point, point, V, v); + vec3.scaleAndAdd(point, point, basis.u as vec3, u); + vec3.scaleAndAdd(point, point, basis.v as vec3, v); return vec3.round(vec3.create(), point); }; diff --git a/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts b/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts index 4da6a4540f..b610ca7dd8 100644 --- a/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts +++ b/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts @@ -28,7 +28,6 @@ import type { VoxelEditingContext, UserLayerWithVoxelEditing, } from "#src/layer/vox/index.js"; -import { vec3 } from "#src/util/geom.js"; import { Viewer } from "#src/viewer.js"; import { mswFixture } from "#tests/fixtures/msw"; @@ -338,12 +337,15 @@ test("Pipeline: Flood Fill (Zarr V2 UINT8 on img layer)", async () => { const seed = new Float32Array([15, 15, 0]); const fillValue = 128n; const maxVoxels = 1000; - const planeNormal = vec3.fromValues(0, 0, 1); + const basis = { + u: new Float32Array([1, 0, 0]), + v: new Float32Array([0, 1, 0]), + }; await poll( async () => { try { - await context.floodFillPlane2D(seed, fillValue, maxVoxels, planeNormal); + await context.floodFillPlane2D(seed, fillValue, maxVoxels, basis); return true; } catch (e: any) { if (e.message.includes("unloaded")) { From b0d8421ed8356e43f08d849bdaabcbacbacdeee5 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 27 Nov 2025 17:15:54 +0100 Subject: [PATCH 154/251] cleanup(voxel-annotation): remove redundant or clarify comments --- src/voxel_annotation/edit_backend.ts | 3 --- src/voxel_annotation/edit_controller.ts | 17 +++-------------- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/edit_backend.ts index 379fbddd0b..92947728ec 100644 --- a/src/voxel_annotation/edit_backend.ts +++ b/src/voxel_annotation/edit_backend.ts @@ -723,16 +723,13 @@ export class VoxelEditController extends SharedObject { voxChunkKeys: [voxKey], message: useOldValues ? "Undo failed." : "Redo failed.", }); - // Stop processing this action on the first failure break; } } if (success) { - // Only move the action to the target stack if all operations succeeded. targetStack.push(action); } else { - // On failure, return the action to its original stack to maintain consistency. sourceStack.push(action); } diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 136a1fea3e..87c0711a85 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -58,7 +58,6 @@ export class VoxelEditController extends SharedObject { ); } - // Get all sources for all scales and orientations const sourcesByScale = this.host.primarySource.getSources( this.getIdentitySliceViewSourceOptions(), ); @@ -113,7 +112,6 @@ export class VoxelEditController extends SharedObject { throw new Error("VoxelEditController: Invalid multiscale rank."); } const r = rank as number; - // Identity mapping from multiscale to view for our purposes. const displayRank = r; const multiscaleToViewTransform = new Float32Array(displayRank * r); for (let chunkDim = 0; chunkDim < r; ++chunkDim) { @@ -149,7 +147,7 @@ export class VoxelEditController extends SharedObject { return source; } - // Paint a disk (slice-aligned via basis) or sphere in WORLD/ canonical units; we transform to LOD grid before sending. + // Paint a disk (require the basis) or a sphere paintBrushWithShape( centerCanonical: Float32Array, radiusCanonical: number, @@ -158,7 +156,6 @@ export class VoxelEditController extends SharedObject { basis?: { u: Float32Array; v: Float32Array }, ) { if (!Number.isFinite(radiusCanonical) || radiusCanonical <= 0) { - void basis; // basis is currently unused for disk alignment in this refactor throw new Error("paintBrushWithShape: 'radius' must be > 0."); } if (!centerCanonical || centerCanonical.length < 3) { @@ -167,7 +164,7 @@ export class VoxelEditController extends SharedObject { ); } - // For V1 we use the minimum LOD (index 0) + // Hardcode drawing at LOD 0 for now. const voxelSize = 1; const sourceIndex = 0; if (!this.host.previewSource) @@ -181,7 +178,6 @@ export class VoxelEditController extends SharedObject { throw new Error("paintBrushWithShape: Missing preview source"); } - // Convert center and radius to the level’s voxel grid. const cx = Math.round((centerCanonical[0] ?? 0) / voxelSize); const cy = Math.round((centerCanonical[1] ?? 0) / voxelSize); const cz = Math.round((centerCanonical[2] ?? 0) / voxelSize); @@ -250,7 +246,6 @@ export class VoxelEditController extends SharedObject { entry.indices.push(index); } - // Apply edits locally on the specific source for immediate feedback. const localEdits = new Map(); for (const [voxKey, edit] of editsByVoxKey.entries()) { const parsed = parseVoxChunkKey(voxKey); @@ -275,7 +270,6 @@ export class VoxelEditController extends SharedObject { this.commitEdits(backendEdits); } - /** Commit helper for UI tools. */ commitEdits( edits: { key: string; @@ -299,9 +293,7 @@ export class VoxelEditController extends SharedObject { } /** - * Frontend 2D flood fill helper: computes on currently selected LOD and returns an edits payload - * suitable for VOX_EDIT_COMMIT_VOXELS without committing. Hard-cap deny semantics. - * The seed is simply the first clicked voxel in canonical/world units. + * 2D flood fill with failsafe to avoid propagating via small holes (see morphologicalConfig to configure). */ async floodFillPlane2D( startPositionCanonical: Float32Array, @@ -385,11 +377,9 @@ export class VoxelEditController extends SharedObject { const du = nu - u; const dv = nv - v; - // Perpendicular direction const perpU = -dv; const perpV = du; - // Check if the NEIGHBOR position has sufficient thickness on both sides for (let offset = -halfThickness; offset <= halfThickness; ++offset) { const testU = nu + perpU * offset; const testV = nv + perpV * offset; @@ -409,7 +399,6 @@ export class VoxelEditController extends SharedObject { requiredThickness: number, ) => { const subQueue: [number, number][] = []; - // The bounding box for the local fill is defined in the (u, v) coordinate system const halfSize = requiredThickness * 2; // multiply by 2 to avoid small artifacts const startKey = `${startU},${startV}`; if (visited.has(startKey)) return; From 9f41d3b2bb34316646a14dc178144cd5b9983b79 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 27 Nov 2025 19:07:12 +0100 Subject: [PATCH 155/251] refactor(voxel-annotation): rename `writable` to `writingEnabled` across codebase to enhance clarity and consistency --- src/datasource/index.ts | 3 +- src/datasource/zarr/frontend.ts | 5 ++- src/layer/image/index.ts | 6 ++-- src/layer/layer_data_source.ts | 22 +++++++----- src/layer/segmentation/index.ts | 6 ++-- src/layer/vox/index.browser_test.ts | 6 ++-- src/layer/vox/index.ts | 34 +++++++++++-------- src/ui/layer_data_sources_tab.ts | 4 +-- .../pipeline_zarr_s3.browser_test.ts | 8 ++--- 9 files changed, 56 insertions(+), 38 deletions(-) diff --git a/src/datasource/index.ts b/src/datasource/index.ts index a5703c2852..f6e865db2b 100644 --- a/src/datasource/index.ts +++ b/src/datasource/index.ts @@ -132,6 +132,7 @@ export interface DataSubsource { singleMesh?: SingleMeshSource; segmentPropertyMap?: SegmentPropertyMap; segmentationGraph?: SegmentationGraphSource; + // specify whether the datasource & kvstore implementations supports writing, is also responsible for showing the enableWriting checkbox in the UI isPotentiallyWritable?: boolean; } @@ -217,7 +218,7 @@ export interface DataSourceWithRedirectInfo extends DataSource { export interface DataSubsourceSpecification { enabled?: boolean; - writable?: boolean; + writingEnabled?: boolean; } export interface DataSourceSpecification { diff --git a/src/datasource/zarr/frontend.ts b/src/datasource/zarr/frontend.ts index 9fc3223c0e..7eb4f8392f 100644 --- a/src/datasource/zarr/frontend.ts +++ b/src/datasource/zarr/frontend.ts @@ -508,6 +508,9 @@ export class ZarrDataSource implements KvStoreBasedDataSourceProvider { options, async (progressOptions) => { const { sharedKvStoreContext } = options.registry; + const isPotentiallyWritable = + sharedKvStoreContext.kvStoreContext.getKvStore(kvStoreUrl).store + .write !== undefined; const metadata = await getMetadata(sharedKvStoreContext, kvStoreUrl, { ...progressOptions, zarrVersion: this.zarrVersion, @@ -557,7 +560,7 @@ export class ZarrDataSource implements KvStoreBasedDataSourceProvider { id: "default", default: true, url: undefined, - subsource: { volume, isPotentiallyWritable: true }, + subsource: { volume, isPotentiallyWritable }, }, { id: "bounds", diff --git a/src/layer/image/index.ts b/src/layer/image/index.ts index c44cb70ddb..ddf38e879e 100644 --- a/src/layer/image/index.ts +++ b/src/layer/image/index.ts @@ -341,16 +341,16 @@ void main() { ); this.shaderError.changed.dispatch(); context.registerDisposer( - registerNested((context, isWritable) => { + registerNested((context, writingEnabled) => { this.initializeVoxelEditingForSubsource( loadedSubsource, imageRenderLayer, - isWritable, + writingEnabled, ); context.registerDisposer(() => { this.deinitializeVoxelEditingForSubsource(loadedSubsource); }); - }, loadedSubsource.writable), + }, loadedSubsource.writingEnabled), ); }); } diff --git a/src/layer/layer_data_source.ts b/src/layer/layer_data_source.ts index ec5dd59841..06acebfaaa 100644 --- a/src/layer/layer_data_source.ts +++ b/src/layer/layer_data_source.ts @@ -64,7 +64,11 @@ export function parseDataSubsourceSpecificationFromJson( verifyObject(json); return { enabled: verifyOptionalObjectProperty(json, "enabled", verifyBoolean), - writable: verifyOptionalObjectProperty(json, "writable", verifyBoolean), + writingEnabled: verifyOptionalObjectProperty( + json, + "writingEnabled", + verifyBoolean, + ), }; } @@ -110,8 +114,8 @@ export function layerDataSourceSpecificationFromJson( } function dataSubsourceSpecificationToJson(spec: DataSubsourceSpecification) { - const { enabled, writable } = spec; - return { enabled, writable }; + const { enabled, writingEnabled } = spec; + return { enabled, writingEnabled }; } export function layerDataSourceSpecificationToJson( @@ -149,7 +153,7 @@ export class LoadedDataSubsource { subsourceToModelSubspaceTransform: Float32Array; modelSubspaceDimensionIndices: number[]; enabled: boolean; - writable: TrackableBoolean; + writingEnabled: TrackableBoolean; activated: RefCounted | undefined = undefined; guardValues: any[] = []; messages = new MessageList(); @@ -182,11 +186,11 @@ export class LoadedDataSubsource { ), } = subsourceEntry; this.enabled = enabled; - this.writable = new TrackableBoolean( - subsourceSpec?.writable ?? false, + this.writingEnabled = new TrackableBoolean( + subsourceSpec?.writingEnabled ?? false, false, ); - this.writable.changed.add( + this.writingEnabled.changed.add( loadedDataSource.layer.dataSourcesChanged.dispatch, ); this.subsourceToModelSubspaceTransform = subsourceToModelSubspaceTransform; @@ -503,7 +507,9 @@ export class LayerDataSource extends RefCounted { loadedSubsource.enabled !== defaultEnabledValue ? loadedSubsource.enabled : undefined, - writable: loadedSubsource.writable.value ? true : undefined, + writingEnabled: loadedSubsource.writingEnabled.value + ? true + : undefined, }, ]; }), diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 6bf727292c..494d180812 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -804,16 +804,16 @@ export class SegmentationUserLayer extends Base { }); loadedSubsource.addRenderLayer(segmentationRenderLayer); context.registerDisposer( - registerNested((context, isWritable) => { + registerNested((context, writingEnabled) => { this.initializeVoxelEditingForSubsource( loadedSubsource, segmentationRenderLayer, - isWritable, + writingEnabled, ); context.registerDisposer(() => { this.deinitializeVoxelEditingForSubsource(loadedSubsource); }); - }, loadedSubsource.writable), + }, loadedSubsource.writingEnabled), ); }, this.displayState.segmentationGroupState.value); } else if (mesh !== undefined) { diff --git a/src/layer/vox/index.browser_test.ts b/src/layer/vox/index.browser_test.ts index fe5d35f561..67f999facc 100644 --- a/src/layer/vox/index.browser_test.ts +++ b/src/layer/vox/index.browser_test.ts @@ -207,7 +207,7 @@ describe("Voxel Editing Utilities", () => { }); loadedSubsource.addRenderLayer(renderLayer); - loadedSubsource.writable.value = true; + loadedSubsource.writingEnabled.value = true; userLayer.initializeVoxelEditingForSubsource( loadedSubsource, renderLayer, @@ -432,7 +432,9 @@ describe("Voxel Editing Utilities", () => { it("No Context: Fails", () => { const { userLayer } = createLayer(DataType.UINT64); userLayer.editingContexts.clear(); - expect(() => userLayer.setVoxelPaintValue(1)).toThrow(); + expect(() => userLayer.setVoxelPaintValue(1)).toThrow( + "No voxel editing context available", + ); }); }); }); diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 0ee730d86c..42206f55ea 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -98,12 +98,12 @@ export class VoxelEditingContext public hostLayer: UserLayerWithVoxelEditing, public primarySource: MultiscaleVolumeChunkSource, public primaryRenderLayer: ImageRenderLayer | SegmentationRenderLayer, - public writable: boolean, + public writingEnabled: boolean, public dataSourceUrl: string | undefined, ) { super(); - if (!writable) return; + if (!writingEnabled) return; // NOTE: each of the following 3 checks may be removed if support for the checked contraint is added if (primarySource.rank !== 3) { @@ -369,7 +369,7 @@ export class VoxelEditingContext } export declare abstract class UserLayerWithVoxelEditing extends UserLayer { - isEditable: WatchableValue; + hasSubsourcesWithWritingEnabled: WatchableValue; voxBrushRadius: TrackableValue; voxEraseMode: TrackableBoolean; @@ -393,7 +393,7 @@ export declare abstract class UserLayerWithVoxelEditing extends UserLayer { deinitializeVoxelEditingForSubsource( loadedSubsource: LoadedDataSubsource, ): void; - + updateHasSubsourcesWithWritingEnabled(): void; getIdentitySliceViewSourceOptions(): SliceViewSourceOptions; handleVoxAction(action: string, context: LayerActionContext): void; } @@ -403,7 +403,7 @@ export function UserLayerWithVoxelEditingMixin< >(Base: TBase) { abstract class C extends Base implements UserLayerWithVoxelEditing { editingContexts = new Map(); - isEditable = new WatchableValue(false); + hasSubsourcesWithWritingEnabled = new WatchableValue(false); paintValue = new TrackableValue(1n, (x) => parseUint64(x)); // Brush properties @@ -430,7 +430,7 @@ export function UserLayerWithVoxelEditingMixin< order: 20, hidden: makeDerivedWatchableValue( (editable) => !editable, - this.isEditable, + this.hasSubsourcesWithWritingEnabled, ), getter: () => new VoxToolTab(this), }); @@ -474,6 +474,7 @@ export function UserLayerWithVoxelEditingMixin< setVoxelPaintValue(x: any) { const editContext = this.editingContexts.values().next().value; + if (!editContext) throw new Error("No voxel editing context available"); const dataType = editContext.primarySource.dataType; let value: bigint; @@ -511,10 +512,16 @@ export function UserLayerWithVoxelEditingMixin< transform: WatchableValueInterface, ): ImageRenderLayer | SegmentationRenderLayer; + updateHasSubsourcesWithWritingEnabled(): void { + this.hasSubsourcesWithWritingEnabled.value = this.editingContexts + .entries() + .some((value) => value[1].writingEnabled); + } + initializeVoxelEditingForSubsource( loadedSubsource: LoadedDataSubsource, renderlayer: SegmentationRenderLayer | ImageRenderLayer, - writable: boolean = true, + writingEnabled: boolean = true, ): void { if (this.editingContexts.has(loadedSubsource)) return; @@ -526,15 +533,16 @@ export function UserLayerWithVoxelEditingMixin< this, primarySource, renderlayer, - writable, + writingEnabled, loadedSubsource.loadedDataSource.dataSource.canonicalUrl, ); this.editingContexts.set(loadedSubsource, context); - this.isEditable.value = writable; + this.updateHasSubsourcesWithWritingEnabled(); this.setVoxelPaintValue(this.paintValue.value); } catch (e) { - if (writable) { - loadedSubsource.writable.value = false; + if (writingEnabled) { + loadedSubsource.writingEnabled.value = false; + this.updateHasSubsourcesWithWritingEnabled(); const msg = e instanceof Error ? e.message : String(e); console.warn("Failed to initialize voxel editing:", msg); StatusMessage.showTemporaryMessage(msg, 5000); @@ -548,9 +556,7 @@ export function UserLayerWithVoxelEditingMixin< context.dispose(); this.editingContexts.delete(loadedSubsource); } - if (this.editingContexts.size === 0 && this.isEditable.value) { - this.isEditable.value = false; - } + this.updateHasSubsourcesWithWritingEnabled(); } getIdentitySliceViewSourceOptions(): SliceViewSourceOptions { diff --git a/src/ui/layer_data_sources_tab.ts b/src/ui/layer_data_sources_tab.ts index cc92773039..3b394b4270 100644 --- a/src/ui/layer_data_sources_tab.ts +++ b/src/ui/layer_data_sources_tab.ts @@ -224,13 +224,13 @@ export class DataSourceSubsourceView extends RefCounted { MultiscaleVolumeChunkSource ) { const writableCheckbox = this.registerDisposer( - new TrackableBooleanCheckbox(loadedSubsource.writable), + new TrackableBooleanCheckbox(loadedSubsource.writingEnabled), ); writableCheckbox.element.title = "Enable voxel editing for this source"; const writableLabel = document.createElement("label"); writableLabel.className = "neuroglancer-layer-data-source-writable-label"; writableLabel.appendChild(writableCheckbox.element); - writableLabel.appendChild(document.createTextNode("[Writable?]")); + writableLabel.appendChild(document.createTextNode("[Enable writing?]")); this.registerDisposer( new ElementVisibilityFromTrackableBoolean( diff --git a/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts b/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts index b610ca7dd8..d74de7c405 100644 --- a/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts +++ b/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts @@ -145,7 +145,7 @@ test("Pipeline: Zarr V2 (UINT8) Undo/Redo with Brush", async () => { type: "image", source: { url: `s3+http://localhost:9000/${BUCKET}/data.zarr`, - subsources: { default: { enabled: true, writable: true } }, + subsources: { default: { enabled: true, writingEnabled: true } }, enableDefaultSubsources: false, }, }); @@ -212,7 +212,7 @@ test("Pipeline: Zarr V3 (UINT64) Brush", async () => { type: "segmentation", source: { url: `s3+http://localhost:9000/${BUCKET}/data.zarr|zarr3:`, - subsources: { default: { enabled: true, writable: true } }, + subsources: { default: { enabled: true, writingEnabled: true } }, enableDefaultSubsources: false, }, }); @@ -262,7 +262,7 @@ test("Pipeline: Zarr V2 (UINT32) with Slash Separator", async () => { type: "segmentation", source: { url: `s3+http://localhost:9000/${BUCKET}/data.zarr`, - subsources: { default: { enabled: true, writable: true } }, + subsources: { default: { enabled: true, writingEnabled: true } }, enableDefaultSubsources: false, }, }); @@ -326,7 +326,7 @@ test("Pipeline: Flood Fill (Zarr V2 UINT8 on img layer)", async () => { type: "image", source: { url: `s3+http://localhost:9000/${BUCKET}/data.zarr`, - subsources: { default: { enabled: true, writable: true } }, + subsources: { default: { enabled: true, writingEnabled: true } }, enableDefaultSubsources: false, }, }); From 46fb66dc47ec9ee3f544132cb17b7556f2465fea Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 1 Dec 2025 11:28:26 +0100 Subject: [PATCH 156/251] fix(voxel-annotation): rename writable to writingEnabled in getActiveContext --- src/ui/voxel_annotations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index b5ddb464e6..bbf6cbd207 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -45,7 +45,7 @@ abstract class BaseVoxelTool extends LayerTool { const it = this.layer.editingContexts.values(); let ctx: VoxelEditingContext; while ((ctx = it.next().value) !== undefined) { - if (ctx.writable) return ctx; + if (ctx.writingEnabled) return ctx; } return undefined; } From b3256b64a240d8b06d9ca84b44b8c43dedcc8a1f Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 1 Dec 2025 17:34:52 +0100 Subject: [PATCH 157/251] fix(voxel-annotation): fix paintValue json serialization --- src/layer/vox/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 42206f55ea..b7f947fc64 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -442,7 +442,8 @@ export function UserLayerWithVoxelEditingMixin< json[ERASE_MODE_JSON_KEY] = this.voxEraseMode.toJSON(); json[BRUSH_SHAPE_JSON_KEY] = this.voxBrushShape.toJSON(); json[FLOOD_FILL_MAX_VOXELS_JSON_KEY] = this.voxFloodMaxVoxels.toJSON(); - json[PAINT_VALUE_JSON_KEY] = this.paintValue.toJSON(); + const pv = this.paintValue.toJSON(); + json[PAINT_VALUE_JSON_KEY] = pv === undefined ? undefined : pv.toString(); return json; } From 1d6689b68062bda0cc50b25c785a42fb1f625466 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 1 Dec 2025 18:07:45 +0100 Subject: [PATCH 158/251] feat(voxel-annotation): update TODOs with some found bugs and quality of life features; add a different color to the cursors in eraser mode --- src/rendered_data_panel.ts | 12 +++++++++--- src/ui/voxel_annotations.ts | 29 ++++++++++++++++++----------- src/voxel_annotation/TODOs.md | 8 ++++++++ 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/src/rendered_data_panel.ts b/src/rendered_data_panel.ts index caf66e53f9..06ea72e342 100644 --- a/src/rendered_data_panel.ts +++ b/src/rendered_data_panel.ts @@ -839,6 +839,8 @@ export abstract class RenderedDataPanel extends RenderedPanel { radiusX: number, radiusY: number, rotation: number, + color: string, + isEraser: boolean, ) { const ctx = this.overlay_context; const { logicalWidth, logicalHeight } = this.renderViewport; @@ -850,12 +852,16 @@ export abstract class RenderedDataPanel extends RenderedPanel { ctx.beginPath(); ctx.ellipse(x, y, radiusX, radiusY, rotation, 0, 2 * Math.PI); ctx.restore(); - ctx.fillStyle = "rgba(255, 255, 255, 0.2)"; + ctx.fillStyle = isEraser ? "red" : color; + ctx.globalAlpha = 0.2; ctx.fill(); - ctx.strokeStyle = "rgba(255, 255, 255, 1)"; + ctx.globalAlpha = 1; + ctx.strokeStyle = isEraser + ? "rgb(255,136,136)" + : "rgba(255, 255, 255, 1)"; ctx.lineWidth = 3; ctx.stroke(); - ctx.strokeStyle = "rgba(0, 0, 0, 1)"; + ctx.strokeStyle = isEraser ? "rgb(97,0,0)" : "rgba(0, 0, 0, 1)"; ctx.lineWidth = 1.5; ctx.stroke(); } diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index bbf6cbd207..98193d0399 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -278,6 +278,8 @@ export class VoxelBrushTool extends BaseVoxelTool { radiusX, radiusY, rotation, + "white", + this.layer.voxEraseMode.value, ); } @@ -400,33 +402,38 @@ export class VoxelBrushTool extends BaseVoxelTool { } } -const floodFillSVG = - ` + stroke="${lightColor}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/> + stroke="${lightColor}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/> + stroke="${lightColor}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/> + stroke="${darkColor}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> + stroke="${darkColor}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> + stroke="${darkColor}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> `.replace(/\s\s+/g, " "); -const floodFillCursor = `url('data:image/svg+xml;utf8,${encodeURIComponent(floodFillSVG)}') 4 19, crosshair`; + return `url('data:image/svg+xml;utf8,${encodeURIComponent(floodFillSVG)}') 4 19, crosshair`; + } -export class VoxelFloodFillTool extends BaseVoxelTool { activate(activation: ToolActivation) { super.activate(activation); - this.setCursor(floodFillCursor); + this.setCursor(this.getCursor()); activation.registerDisposer(() => { this.resetCursor(); }); diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 39b8f08a87..38d942b5d2 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -2,12 +2,20 @@ ### priority +- add dynamic brush cursor resizing +- align brush cursor transform with brush transform +- look into the sometime disappearing preview +- add value-based erasing +- preview is missing when erasing +- flood fill wont work on erase mode + ### later - add preview for the undo/redo ### questionable +- add color feedback on the brush cursor - add support for volumes with rank different from 3 - add support to float32 dataset - add support to unaligned hierarchy (e.g. child chunks that may have multiple parents) From b434958a3d8df02671d9456247ab92ff6f168867 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Tue, 2 Dec 2025 12:33:41 +0100 Subject: [PATCH 159/251] fix(voxel-annotation): prevent redundant chunk reloads in downsampling causing the preview to sometimes disappear --- src/voxel_annotation/TODOs.md | 2 -- src/voxel_annotation/edit_backend.ts | 4 +++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 38d942b5d2..7f4d2c4eaf 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -4,10 +4,8 @@ - add dynamic brush cursor resizing - align brush cursor transform with brush transform -- look into the sometime disappearing preview - add value-based erasing - preview is missing when erasing -- flood fill wont work on erase mode ### later diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/edit_backend.ts index 92947728ec..2eecebb0c4 100644 --- a/src/voxel_annotation/edit_backend.ts +++ b/src/voxel_annotation/edit_backend.ts @@ -288,7 +288,9 @@ export class VoxelEditController extends SharedObject { allModifiedKeys.push(currentKey); currentKey = await this.downsampleStep(currentKey); } - this.callChunkReload(allModifiedKeys, true); + const pendingKeys = new Set(this.pendingEdits.map((e) => e.key)); + const keysToReload = allModifiedKeys.filter((k) => !pendingKeys.has(k)); + if (keysToReload.length > 0) this.callChunkReload(keysToReload, true); } } finally { this.isProcessingDownsampleQueue = false; From b1bcaec99e87d9215a1359fa294dfbc594ab965f Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 3 Dec 2025 18:54:29 +0100 Subject: [PATCH 160/251] feat(voxel-annotation): render the brush cursor when changing the brush size --- src/layer/vox/controls.ts | 41 ++++- src/layer/vox/index.browser_test.ts | 8 - src/layer/vox/index.ts | 13 +- src/ui/voxel_annotations.ts | 233 ++++++++++++++-------------- 4 files changed, 162 insertions(+), 133 deletions(-) diff --git a/src/layer/vox/controls.ts b/src/layer/vox/controls.ts index e06d515de0..dc8939cecf 100644 --- a/src/layer/vox/controls.ts +++ b/src/layer/vox/controls.ts @@ -18,6 +18,10 @@ import type { UserLayerConstructor } from "#src/layer/index.js"; import { LayerActionContext } from "#src/layer/index.js"; import type { UserLayerWithVoxelEditing } from "#src/layer/vox/index.js"; import { observeWatchable } from "#src/trackable_value.js"; +import { + getActivePanel, + updateBrushOutline, +} from "#src/ui/voxel_annotations.js"; import type { LayerControlDefinition } from "#src/widget/layer_control.js"; import { registerLayerControl } from "#src/widget/layer_control.js"; import { buttonLayerControl } from "#src/widget/layer_control_button.js"; @@ -30,10 +34,39 @@ export const VOXEL_LAYER_CONTROLS: LayerControlDefinition ({ - value: layer.voxBrushRadius, - options: { min: 1, max: 64, step: 1 }, - })), + ...(() => { + const control = rangeLayerControl( + (layer: UserLayerWithVoxelEditing) => ({ + value: layer.voxBrushRadius, + options: { min: 1, max: 64, step: 1 }, + }), + ); + const originalActivateTool = control.activateTool; + return { + ...control, + activateTool: (activation, controlContext) => { + originalActivateTool(activation, controlContext as any); + + const layer = activation.tool.layer as UserLayerWithVoxelEditing; + const updateCursor = () => { + updateBrushOutline(layer); + }; + + updateCursor(); + activation.registerDisposer( + layer.manager.root.layerSelectedValues.mouseState.changed.add( + updateCursor, + ), + ); + activation.registerDisposer( + layer.voxBrushRadius.changed.add(updateCursor), + ); + activation.registerDisposer(() => { + getActivePanel(layer)?.clearOverlay(); + }); + }, + }; + })(), }, { label: "Eraser", diff --git a/src/layer/vox/index.browser_test.ts b/src/layer/vox/index.browser_test.ts index 67f999facc..edda068d8c 100644 --- a/src/layer/vox/index.browser_test.ts +++ b/src/layer/vox/index.browser_test.ts @@ -305,14 +305,6 @@ describe("Voxel Editing Utilities", () => { }); describe("transformGlobalToVoxelNormal", () => { - it("Uninitialized Cache: throws error", () => { - const { userLayer, loadedSubsource } = createLayer(DataType.UINT64); - const context = userLayer.editingContexts.get(loadedSubsource)!; - expect(() => { - context.transformGlobalToVoxelNormal(vec3.create()); - }).toThrow("Chunk transform not computed"); - }); - it("Identity Transform: returns same vector", () => { const { userLayer, loadedSubsource } = createLayer(DataType.UINT64); const context = userLayer.editingContexts.get(loadedSubsource)!; diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index b7f947fc64..1cb5dd881a 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -288,9 +288,7 @@ export class VoxelEditingContext } } - getVoxelPositionFromMouse( - mouseState: MouseSelectionState, - ): Float32Array | undefined { + getChunkTransform(): ChunkTransformParameters | undefined { const renderLayer = this.primaryRenderLayer; const renderLayerTransform = renderLayer.transform.value; if (renderLayerTransform.error !== undefined) { @@ -314,8 +312,13 @@ export class VoxelEditingContext return undefined; } } + return this.cachedChunkTransform; + } - const chunkTransform = this.cachedChunkTransform; + getVoxelPositionFromMouse( + mouseState: MouseSelectionState, + ): Float32Array | undefined { + const chunkTransform = this.getChunkTransform(); if (chunkTransform === undefined) return undefined; if ( @@ -339,7 +342,7 @@ export class VoxelEditingContext } transformGlobalToVoxelNormal(globalNormal: vec3): vec3 { - const chunkTransform = this.cachedChunkTransform; + const chunkTransform = this.getChunkTransform(); if (chunkTransform === undefined) throw new Error("Chunk transform not computed"); const { modelTransform, layerToChunkTransform, layerRank } = chunkTransform; diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 98193d0399..5c870c5cb7 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -149,6 +149,120 @@ abstract class BaseVoxelTool extends LayerTool { } } +export function getActivePanel( + layer: UserLayerWithVoxelEditing, +): RenderedDataPanel | undefined { + let activePanel: RenderedDataPanel | undefined; + for (const panel of layer.manager.root.display.panels) { + if (panel instanceof RenderedDataPanel) { + if (panel.mouseX !== -1 && panel instanceof SliceViewPanel) { + activePanel = panel; + } else { + panel.clearOverlay(); + } + } + } + return activePanel; +} + +export function updateBrushOutline(layer: UserLayerWithVoxelEditing) { + const panel = getActivePanel(layer); + if (!panel || !(panel instanceof SliceViewPanel)) { + if (panel) panel.clearOverlay(); + return; + } + + const { projectionParameters } = panel.sliceView; + const { displayDimensionRenderInfo, viewMatrix } = projectionParameters.value; + const { canonicalVoxelFactors, displayRank } = displayDimensionRenderInfo; + + if (displayRank < 2) { + panel.clearOverlay(); + return; + } + + const radiusInVoxels = layer.voxBrushRadius.value; + + const n_canonical = + projectionParameters.value.viewportNormalInCanonicalCoordinates; + + const canonicalVoxelFactorsVec3 = vec3.fromValues( + canonicalVoxelFactors[0], + canonicalVoxelFactors[1], + canonicalVoxelFactors[2], + ); + + // Convert to voxel coordinates by dividing by canonical voxel factors. + const n_vox = vec3.create(); + vec3.divide(n_vox, n_canonical as vec3, canonicalVoxelFactorsVec3); + vec3.normalize(n_vox, n_vox); + + // Create an orthonormal basis for the plane in voxel coordinates + const u_vox = vec3.create(); + const tempVec = vec3.fromValues(1, 0, 0); + if (Math.abs(vec3.dot(n_vox, tempVec)) > 0.999) { + vec3.set(tempVec, 0, 1, 0); + } + vec3.cross(u_vox, n_vox, tempVec); + vec3.normalize(u_vox, u_vox); + + const v_vox = vec3.cross(vec3.create(), n_vox, u_vox); + + // Scale basis vectors by radius to get two orthogonal radius vectors of the brush circle + // in voxel coordinates. + vec3.scale(u_vox, u_vox, radiusInVoxels); + vec3.scale(v_vox, v_vox, radiusInVoxels); + + const u_cam = vec3.create(); + const v_cam = vec3.create(); + // The viewMatrix transforms from world/voxel space to camera space. + // We use a mat3 to only apply rotation and scaling, not translation. + const viewMatrix3 = mat3.fromMat4(mat3.create(), viewMatrix); + + // Transform voxel-space vectors directly to camera-space vectors. + // This avoids the double-scaling error. + vec3.transformMat3(u_cam, u_vox, viewMatrix3); + vec3.transformMat3(v_cam, v_vox, viewMatrix3); + + // The x, y components of these vectors are conjugate semi-diameters of the ellipse on screen. + const u_scr_x = u_cam[0]; + const u_scr_y = u_cam[1]; + const v_scr_x = v_cam[0]; + const v_scr_y = v_cam[1]; + + // From the conjugate semi-diameters, compute the ellipse parameters (radii and rotation). + // We analyze the quadratic form matrix Q = A * A^T where A = [[u_scr_x, v_scr_x], [u_scr_y, v_scr_y]]. + const Q11 = u_scr_x * u_scr_x + v_scr_x * v_scr_x; + const Q12 = u_scr_x * u_scr_y + v_scr_x * v_scr_y; + const Q22 = u_scr_y * u_scr_y + v_scr_y * v_scr_y; + + const trace = Q11 + Q22; + const det = Q11 * Q22 - Q12 * Q12; + + // Eigenvalues are roots of lambda^2 - trace*lambda + det = 0 + const D_sq = trace * trace - 4 * det; + const D = D_sq < 0 ? 0 : Math.sqrt(D_sq); + + const lambda1 = (trace + D) / 2; + const lambda2 = (trace - D) / 2; + + const radiusX = Math.sqrt(lambda1); + const radiusY = Math.sqrt(lambda2); + + // Eigenvector for lambda1 is proportional to [Q12, lambda1 - Q11] + const rotation = Math.atan2(lambda1 - Q11, Q12); + + panel.drawBrushCursor( + panel.mouseX, + panel.mouseY, + radiusX, + radiusY, + rotation, + "white", + layer.voxEraseMode.value, + ); +} + export class VoxelBrushTool extends BaseVoxelTool { private isDrawing = false; private lastPoint: Int32Array | undefined; @@ -157,132 +271,19 @@ export class VoxelBrushTool extends BaseVoxelTool { activate(activation: ToolActivation) { super.activate(activation); - this.updateBrushOutline(); + updateBrushOutline(this.layer); activation.registerDisposer(() => { - this.getActivePanel()?.clearOverlay(); + getActivePanel(this.layer)?.clearOverlay(); this.resetCursor(); }); activation.registerDisposer( this.mouseState.changed.add(() => { - this.updateBrushOutline(); + updateBrushOutline(this.layer); }), ); } - private getActivePanel(): RenderedDataPanel | undefined { - let activePanel: RenderedDataPanel | undefined; - for (const panel of this.layer.manager.root.display.panels) { - if (panel instanceof RenderedDataPanel) { - if (panel.mouseX !== -1 && panel instanceof SliceViewPanel) { - activePanel = panel; - } else { - panel.clearOverlay(); - } - } - } - return activePanel; - } - - private updateBrushOutline() { - const panel = this.getActivePanel(); - if (!panel || !(panel instanceof SliceViewPanel)) { - if (panel) panel.clearOverlay(); - return; - } - - const { projectionParameters } = panel.sliceView; - const { displayDimensionRenderInfo, viewMatrix } = - projectionParameters.value; - const { canonicalVoxelFactors, displayRank } = displayDimensionRenderInfo; - - if (displayRank < 2) { - panel.clearOverlay(); - return; - } - - const radiusInVoxels = this.layer.voxBrushRadius.value; - - const n_canonical = - projectionParameters.value.viewportNormalInCanonicalCoordinates; - - const canonicalVoxelFactorsVec3 = vec3.fromValues( - canonicalVoxelFactors[0], - canonicalVoxelFactors[1], - canonicalVoxelFactors[2], - ); - - // Convert to voxel coordinates by dividing by canonical voxel factors. - const n_vox = vec3.create(); - vec3.divide(n_vox, n_canonical as vec3, canonicalVoxelFactorsVec3); - vec3.normalize(n_vox, n_vox); - - // Create an orthonormal basis for the plane in voxel coordinates - const u_vox = vec3.create(); - const tempVec = vec3.fromValues(1, 0, 0); - if (Math.abs(vec3.dot(n_vox, tempVec)) > 0.999) { - vec3.set(tempVec, 0, 1, 0); - } - vec3.cross(u_vox, n_vox, tempVec); - vec3.normalize(u_vox, u_vox); - - const v_vox = vec3.cross(vec3.create(), n_vox, u_vox); - - // Scale basis vectors by radius to get two orthogonal radius vectors of the brush circle - // in voxel coordinates. - vec3.scale(u_vox, u_vox, radiusInVoxels); - vec3.scale(v_vox, v_vox, radiusInVoxels); - - const u_cam = vec3.create(); - const v_cam = vec3.create(); - // The viewMatrix transforms from world/voxel space to camera space. - // We use a mat3 to only apply rotation and scaling, not translation. - const viewMatrix3 = mat3.fromMat4(mat3.create(), viewMatrix); - - // Transform voxel-space vectors directly to camera-space vectors. - // This avoids the double-scaling error. - vec3.transformMat3(u_cam, u_vox, viewMatrix3); - vec3.transformMat3(v_cam, v_vox, viewMatrix3); - - // The x, y components of these vectors are conjugate semi-diameters of the ellipse on screen. - const u_scr_x = u_cam[0]; - const u_scr_y = u_cam[1]; - const v_scr_x = v_cam[0]; - const v_scr_y = v_cam[1]; - - // From the conjugate semi-diameters, compute the ellipse parameters (radii and rotation). - // We analyze the quadratic form matrix Q = A * A^T where A = [[u_scr_x, v_scr_x], [u_scr_y, v_scr_y]]. - const Q11 = u_scr_x * u_scr_x + v_scr_x * v_scr_x; - const Q12 = u_scr_x * u_scr_y + v_scr_x * v_scr_y; - const Q22 = u_scr_y * u_scr_y + v_scr_y * v_scr_y; - - const trace = Q11 + Q22; - const det = Q11 * Q22 - Q12 * Q12; - - // Eigenvalues are roots of lambda^2 - trace*lambda + det = 0 - const D_sq = trace * trace - 4 * det; - const D = D_sq < 0 ? 0 : Math.sqrt(D_sq); - - const lambda1 = (trace + D) / 2; - const lambda2 = (trace - D) / 2; - - const radiusX = Math.sqrt(lambda1); - const radiusY = Math.sqrt(lambda2); - - // Eigenvector for lambda1 is proportional to [Q12, lambda1 - Q11] - const rotation = Math.atan2(lambda1 - Q11, Q12); - - panel.drawBrushCursor( - panel.mouseX, - panel.mouseY, - radiusX, - radiusY, - rotation, - "white", - this.layer.voxEraseMode.value, - ); - } - activationCallback(_activation: ToolActivation): void { if (this.getEditingContext() === undefined) { StatusMessage.showTemporaryMessage( From d023f9fbf00cac0530fcb1a45aac516934939325 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 4 Dec 2025 17:04:36 +0100 Subject: [PATCH 161/251] refactor(voxel-annotation): fix missing keybind display for voxel annotation tools ; restructure draw tab: unify tool and control definitions and refactor related toolbox generation logic. --- src/layer/vox/controls.ts | 246 +++++++++++++++++++--------------- src/layer/vox/tabs/tools.ts | 159 +++++++++------------- src/ui/voxel_annotations.ts | 11 +- src/voxel_annotation/TODOs.md | 1 - src/voxel_annotation/base.ts | 4 + 5 files changed, 211 insertions(+), 210 deletions(-) diff --git a/src/layer/vox/controls.ts b/src/layer/vox/controls.ts index dc8939cecf..b310c4e4f8 100644 --- a/src/layer/vox/controls.ts +++ b/src/layer/vox/controls.ts @@ -22,6 +22,11 @@ import { getActivePanel, updateBrushOutline, } from "#src/ui/voxel_annotations.js"; +import { + BRUSH_TOOL_ID, + FLOODFILL_TOOL_ID, + SEG_PICKER_TOOL_ID, +} from "#src/voxel_annotation/base.js"; import type { LayerControlDefinition } from "#src/widget/layer_control.js"; import { registerLayerControl } from "#src/widget/layer_control.js"; import { buttonLayerControl } from "#src/widget/layer_control_button.js"; @@ -29,120 +34,139 @@ import { checkboxLayerControl } from "#src/widget/layer_control_checkbox.js"; import { enumLayerControl } from "#src/widget/layer_control_enum.js"; import { rangeLayerControl } from "#src/widget/layer_control_range.js"; -export const VOXEL_LAYER_CONTROLS: LayerControlDefinition[] = - [ - { - label: "Brush size", - toolJson: { type: "vox-brush-size" }, - ...(() => { - const control = rangeLayerControl( - (layer: UserLayerWithVoxelEditing) => ({ - value: layer.voxBrushRadius, - options: { min: 1, max: 64, step: 1 }, - }), - ); - const originalActivateTool = control.activateTool; - return { - ...control, - activateTool: (activation, controlContext) => { - originalActivateTool(activation, controlContext as any); +export type VoxelTabElement = + | { type: "header"; label: string } + | { type: "tool-row"; tools: { toolId: string; label: string }[] } + | LayerControlDefinition; - const layer = activation.tool.layer as UserLayerWithVoxelEditing; - const updateCursor = () => { - updateBrushOutline(layer); - }; +export const VOXEL_TAB_LAYOUT: VoxelTabElement[] = [ + { type: "header", label: "Tools" }, + { + type: "tool-row", + tools: [ + { toolId: BRUSH_TOOL_ID, label: "Brush" }, + { toolId: FLOODFILL_TOOL_ID, label: "Flood Fill" }, + { toolId: SEG_PICKER_TOOL_ID, label: "Seg Picker" }, + ], + }, + { type: "header", label: "Settings" }, + { + label: "Brush size", + toolJson: { type: "vox-brush-size" }, + ...(() => { + const control = rangeLayerControl((layer: UserLayerWithVoxelEditing) => ({ + value: layer.voxBrushRadius, + options: { min: 1, max: 64, step: 1 }, + })); + const originalActivateTool = control.activateTool; + return { + ...control, + activateTool: (activation, controlContext) => { + originalActivateTool(activation, controlContext as any); - updateCursor(); - activation.registerDisposer( - layer.manager.root.layerSelectedValues.mouseState.changed.add( - updateCursor, - ), - ); - activation.registerDisposer( - layer.voxBrushRadius.changed.add(updateCursor), - ); - activation.registerDisposer(() => { - getActivePanel(layer)?.clearOverlay(); - }); - }, - }; - })(), - }, - { - label: "Eraser", - toolJson: { type: "vox-erase-mode" }, - ...checkboxLayerControl((layer) => layer.voxEraseMode), - }, - { - label: "Brush shape", - toolJson: { type: "vox-brush-shape" }, - ...enumLayerControl( - (layer: UserLayerWithVoxelEditing) => layer.voxBrushShape, - ), - }, - { - label: "Max fill voxels", - toolJson: { type: "vox-flood-max-voxels" }, - ...rangeLayerControl((layer) => ({ - value: layer.voxFloodMaxVoxels, - options: { min: 1, max: 1000000, step: 1000 }, - })), - }, - { - label: "Undo", - toolJson: { type: "vox-undo" }, - ...buttonLayerControl({ - text: "Undo", - onClick: (layer) => - layer.handleVoxAction("undo", new LayerActionContext()), - }), - }, - { - label: "Redo", - toolJson: { type: "vox-redo" }, - ...buttonLayerControl({ - text: "Redo", - onClick: (layer) => - layer.handleVoxAction("redo", new LayerActionContext()), - }), - }, - { - label: "Paint Value", - toolJson: { type: "vox-paint-value" }, - makeControl: (layer, context) => { - const control = document.createElement("input"); - control.type = "text"; - control.title = "Specify segment ID or intensity value to paint"; - control.addEventListener("change", () => { - try { - layer.setVoxelPaintValue(control.value); - } catch { - control.value = layer.paintValue.value.toString(); - } - }); - context.registerDisposer( - observeWatchable((value) => { - control.value = value.toString(); - }, layer.paintValue), - ); - control.value = layer.paintValue.value.toString(); - return { control, controlElement: control, parent: context }; - }, - activateTool: () => {}, - }, - { - label: "New Random Value", - toolJson: { type: "vox-random-value" }, - ...buttonLayerControl({ - text: "Random", - onClick: (layer) => - layer.handleVoxAction( - "randomize-paint-value", - new LayerActionContext(), - ), - }), + const layer = activation.tool.layer as UserLayerWithVoxelEditing; + const updateCursor = () => { + updateBrushOutline(layer); + }; + + updateCursor(); + activation.registerDisposer( + layer.manager.root.layerSelectedValues.mouseState.changed.add( + updateCursor, + ), + ); + activation.registerDisposer( + layer.voxBrushRadius.changed.add(updateCursor), + ); + activation.registerDisposer(() => { + getActivePanel(layer)?.clearOverlay(); + }); + }, + }; + })(), + }, + { + label: "Eraser", + toolJson: { type: "vox-erase-mode" }, + ...checkboxLayerControl((layer) => layer.voxEraseMode), + }, + { + label: "Brush shape", + toolJson: { type: "vox-brush-shape" }, + ...enumLayerControl( + (layer: UserLayerWithVoxelEditing) => layer.voxBrushShape, + ), + }, + { + label: "Max fill voxels", + toolJson: { type: "vox-flood-max-voxels" }, + ...rangeLayerControl((layer) => ({ + value: layer.voxFloodMaxVoxels, + options: { min: 1, max: 1000000, step: 1000 }, + })), + }, + { type: "header", label: "Actions" }, + { + label: "Undo", + toolJson: { type: "vox-undo" }, + ...buttonLayerControl({ + text: "Undo", + onClick: (layer) => + layer.handleVoxAction("undo", new LayerActionContext()), + }), + }, + { + label: "Redo", + toolJson: { type: "vox-redo" }, + ...buttonLayerControl({ + text: "Redo", + onClick: (layer) => + layer.handleVoxAction("redo", new LayerActionContext()), + }), + }, + { + label: "Paint Value", + toolJson: { type: "vox-paint-value" }, + makeControl: (layer, context) => { + const control = document.createElement("input"); + control.type = "text"; + control.title = "Specify segment ID or intensity value to paint"; + control.addEventListener("change", () => { + try { + layer.setVoxelPaintValue(control.value); + } catch { + control.value = layer.paintValue.value.toString(); + } + }); + context.registerDisposer( + observeWatchable((value) => { + control.value = value.toString(); + }, layer.paintValue), + ); + control.value = layer.paintValue.value.toString(); + return { control, controlElement: control, parent: context }; }, - ]; + activateTool: () => {}, + }, + { + label: "New Random Value", + toolJson: { type: "vox-random-value" }, + ...buttonLayerControl({ + text: "Random", + onClick: (layer) => + layer.handleVoxAction( + "randomize-paint-value", + new LayerActionContext(), + ), + }), + }, +]; + +export const VOXEL_LAYER_CONTROLS: LayerControlDefinition[] = + VOXEL_TAB_LAYOUT.filter( + (x): x is LayerControlDefinition => + !("type" in x) || (x.type !== "header" && x.type !== "tool-row"), + ); export function registerVoxelLayerControls( layerType: UserLayerConstructor, diff --git a/src/layer/vox/tabs/tools.ts b/src/layer/vox/tabs/tools.ts index 4bdff714b3..f1aa928eba 100644 --- a/src/layer/vox/tabs/tools.ts +++ b/src/layer/vox/tabs/tools.ts @@ -14,15 +14,10 @@ * limitations under the License. */ -import { VOXEL_LAYER_CONTROLS } from "#src/layer/vox/controls.js"; +import { VOXEL_TAB_LAYOUT } from "#src/layer/vox/controls.js"; import type { UserLayerWithVoxelEditing } from "#src/layer/vox/index.js"; import { observeWatchable } from "#src/trackable_value.js"; import { makeToolButton } from "#src/ui/tool.js"; -import { - SEG_PICKER_TOOL_ID, - BRUSH_TOOL_ID, - FLOODFILL_TOOL_ID, -} from "#src/ui/voxel_annotations.js"; import type { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; import { DependentViewWidget } from "#src/widget/dependent_view_widget.js"; import { addLayerControlToOptionsTab } from "#src/widget/layer_control.js"; @@ -35,100 +30,78 @@ export class VoxToolTab extends Tab { const toolbox = document.createElement("div"); - const toolsRow = document.createElement("div"); - const toolsTitle = document.createElement("div"); - toolsTitle.textContent = "Tools"; - toolsTitle.style.fontWeight = "600"; - toolsRow.appendChild(toolsTitle); + for (const elementDef of VOXEL_TAB_LAYOUT) { + if ("type" in elementDef && elementDef.type === "header") { + const title = document.createElement("div"); + title.textContent = elementDef.label; + title.style.fontWeight = "600"; + toolbox.appendChild(title); + } else if ("type" in elementDef && elementDef.type === "tool-row") { + const toolButtonsContainer = document.createElement("div"); + toolButtonsContainer.style.display = "flex"; + toolButtonsContainer.style.gap = "8px"; - const toolButtonsContainer = document.createElement("div"); - toolButtonsContainer.style.display = "flex"; - toolButtonsContainer.style.gap = "8px"; - - const brushButton = makeToolButton(this, layer.toolBinder, { - toolJson: { type: BRUSH_TOOL_ID }, - label: "Brush", - }); - - const floodFillButton = makeToolButton(this, layer.toolBinder, { - toolJson: { type: FLOODFILL_TOOL_ID }, - label: "Flood Fill", - }); - - const pickButton = makeToolButton(this, layer.toolBinder, { - toolJson: { type: SEG_PICKER_TOOL_ID }, - label: "Seg Picker", - }); - - toolButtonsContainer.appendChild(brushButton); - toolButtonsContainer.appendChild(floodFillButton); - toolButtonsContainer.appendChild(pickButton); - toolsRow.appendChild(toolButtonsContainer); - toolbox.appendChild(toolsRow); - - const settingsTitle = document.createElement("div"); - settingsTitle.textContent = "Settings"; - settingsTitle.style.fontWeight = "600"; - toolbox.appendChild(settingsTitle); - - for (const controlDef of VOXEL_LAYER_CONTROLS) { - const controlElement = addLayerControlToOptionsTab( - this, - this.layer, - this.visibility, - controlDef, - ); + for (const tool of elementDef.tools) { + const button = makeToolButton(this, layer.toolBinder, { + toolJson: tool.toolId, + label: tool.label, + }); + toolButtonsContainer.appendChild(button); + } + toolbox.appendChild(toolButtonsContainer); + } else { + const controlDef = elementDef as any; + const controlElement = addLayerControlToOptionsTab( + this, + this.layer, + this.visibility, + controlDef, + ); - if ( - controlDef.toolJson.type === "vox-undo" || - controlDef.toolJson.type === "vox-redo" - ) { - const button = controlElement.querySelector("button"); - if (button) { - this.registerDisposer( - new DependentViewWidget( - { - changed: this.layer.layersChanged, - get value() { - return ( - layer.editingContexts.values().next().value?._controller ?? - undefined + if ( + controlDef.toolJson.type === "vox-undo" || + controlDef.toolJson.type === "vox-redo" + ) { + const button = controlElement.querySelector("button"); + if (button) { + this.registerDisposer( + new DependentViewWidget( + { + changed: this.layer.layersChanged, + get value() { + return ( + layer.editingContexts.values().next().value + ?._controller ?? undefined + ); + }, + }, + ( + controller: VoxelEditController | undefined, + _parent, + context, + ) => { + if (!controller) { + button.disabled = true; + return; + } + const watchable = + controlDef.toolJson.type === "vox-undo" + ? controller.undoCount + : controller.redoCount; + context.registerDisposer( + observeWatchable((count) => { + button.disabled = count === 0; + }, watchable), ); }, - }, - ( - controller: VoxelEditController | undefined, - _parent, - context, - ) => { - if (!controller) { - button.disabled = true; - return; - } - const watchable = - controlDef.toolJson.type === "vox-undo" - ? controller.undoCount - : controller.redoCount; - context.registerDisposer( - observeWatchable((count) => { - button.disabled = count === 0; - }, watchable), - ); - }, - this.visibility, - ), - ); + this.visibility, + ), + ); + } } - } - if (controlDef.toolJson.type === "vox-undo") { - const actionsTitle = document.createElement("div"); - actionsTitle.textContent = "Actions"; - actionsTitle.style.fontWeight = "600"; - toolbox.appendChild(actionsTitle); + toolbox.appendChild(controlElement); } - - toolbox.appendChild(controlElement); } element.appendChild(toolbox); diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 5c870c5cb7..f087403e32 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -27,11 +27,12 @@ import { LayerTool, registerTool, type ToolActivation } from "#src/ui/tool.js"; import { vec3, mat3 } from "#src/util/geom.js"; import { EventActionMap } from "#src/util/mouse_bindings.js"; import { startRelativeMouseDrag } from "#src/util/mouse_drag.js"; -import { BrushShape } from "#src/voxel_annotation/base.js"; - -export const BRUSH_TOOL_ID = "vox-brush"; -export const FLOODFILL_TOOL_ID = "vox-flood-fill"; -export const SEG_PICKER_TOOL_ID = "vox-seg-picker"; +import { + BrushShape, + BRUSH_TOOL_ID, + FLOODFILL_TOOL_ID, + SEG_PICKER_TOOL_ID, +} from "#src/voxel_annotation/base.js"; const VOX_TOOL_INPUT_MAP = EventActionMap.fromObject({ ["at:control+mousedown0"]: "paint-voxels", diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 7f4d2c4eaf..eed502f8ad 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -2,7 +2,6 @@ ### priority -- add dynamic brush cursor resizing - align brush cursor transform with brush transform - add value-based erasing - preview is missing when erasing diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index 6aef84109e..9fb4db3699 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -26,6 +26,10 @@ export const VOX_EDIT_UNDO_RPC_ID = "vox.edit.undo"; export const VOX_EDIT_REDO_RPC_ID = "vox.edit.redo"; export const VOX_EDIT_HISTORY_UPDATE_RPC_ID = "vox.edit.historyUpdate"; +export const BRUSH_TOOL_ID = "vox-brush"; +export const FLOODFILL_TOOL_ID = "vox-flood-fill"; +export const SEG_PICKER_TOOL_ID = "vox-seg-picker"; + export interface VoxelLayerResolution { lodIndex: number; transform: number[]; From d1eb0d5a9b0eb83e3ee4b1058d6b9873d3d9cf09 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Fri, 5 Dec 2025 10:22:39 +0100 Subject: [PATCH 162/251] refactor(voxel-annotation): renamed edit_controller.ts and edit_backend.ts to frontend.ts and backend.ts, renamed layer/vox/ to layer/voxel-annotation, moved layer/vox/tabs/tools.ts to layer/voxel-annotation/draw_tab.ts --- src/chunk_worker.bundle.js | 2 +- src/layer/image/index.ts | 4 ++-- src/layer/segmentation/index.ts | 4 ++-- src/layer/{vox => voxel_annotation}/controls.ts | 2 +- .../{vox/tabs/tools.ts => voxel_annotation/draw_tab.ts} | 6 +++--- src/layer/{vox => voxel_annotation}/index.browser_test.ts | 0 src/layer/{vox => voxel_annotation}/index.ts | 4 ++-- src/ui/voxel_annotations.ts | 2 +- .../{edit_backend.spec.ts => backend.spec.ts} | 2 +- src/voxel_annotation/{edit_backend.ts => backend.ts} | 0 .../{edit_controller.spec.ts => frontend.spec.ts} | 2 +- src/voxel_annotation/{edit_controller.ts => frontend.ts} | 0 tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts | 2 +- 13 files changed, 15 insertions(+), 15 deletions(-) rename src/layer/{vox => voxel_annotation}/controls.ts (98%) rename src/layer/{vox/tabs/tools.ts => voxel_annotation/draw_tab.ts} (95%) rename src/layer/{vox => voxel_annotation}/index.browser_test.ts (100%) rename src/layer/{vox => voxel_annotation}/index.ts (99%) rename src/voxel_annotation/{edit_backend.spec.ts => backend.spec.ts} (99%) rename src/voxel_annotation/{edit_backend.ts => backend.ts} (100%) rename src/voxel_annotation/{edit_controller.spec.ts => frontend.spec.ts} (99%) rename src/voxel_annotation/{edit_controller.ts => frontend.ts} (100%) diff --git a/src/chunk_worker.bundle.js b/src/chunk_worker.bundle.js index 4c1fa3e86c..1909119100 100644 --- a/src/chunk_worker.bundle.js +++ b/src/chunk_worker.bundle.js @@ -12,4 +12,4 @@ import "#src/annotation/backend.js"; import "#src/datasource/enabled_backend_modules.js"; import "#src/kvstore/enabled_backend_modules.js"; import "#src/worker_rpc_context.js"; -import "#src/voxel_annotation/edit_backend.js"; +import "#src/voxel_annotation/backend.ts"; diff --git a/src/layer/image/index.ts b/src/layer/image/index.ts index ddf38e879e..a4333fd5e3 100644 --- a/src/layer/image/index.ts +++ b/src/layer/image/index.ts @@ -34,8 +34,8 @@ import { UserLayer, } from "#src/layer/index.js"; import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; -import { registerVoxelLayerControls } from "#src/layer/vox/controls.js"; -import { UserLayerWithVoxelEditingMixin } from "#src/layer/vox/index.js"; +import { registerVoxelLayerControls } from "#src/layer/voxel_annotation/controls.js"; +import { UserLayerWithVoxelEditingMixin } from "#src/layer/voxel_annotation/index.js"; import { Overlay } from "#src/overlay.js"; import type { RenderLayerTransformOrError } from "#src/render_coordinate_transform.js"; import { getChannelSpace } from "#src/render_coordinate_transform.js"; diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 494d180812..cc137a9a16 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -35,8 +35,8 @@ import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; import { layerDataSourceSpecificationFromJson } from "#src/layer/layer_data_source.js"; import * as json_keys from "#src/layer/segmentation/json_keys.js"; import { registerLayerControls } from "#src/layer/segmentation/layer_controls.js"; -import { registerVoxelLayerControls } from "#src/layer/vox/controls.js"; -import { UserLayerWithVoxelEditingMixin } from "#src/layer/vox/index.js"; +import { registerVoxelLayerControls } from "#src/layer/voxel_annotation/controls.js"; +import { UserLayerWithVoxelEditingMixin } from "#src/layer/voxel_annotation/index.js"; import { MeshLayer, MeshSource, diff --git a/src/layer/vox/controls.ts b/src/layer/voxel_annotation/controls.ts similarity index 98% rename from src/layer/vox/controls.ts rename to src/layer/voxel_annotation/controls.ts index b310c4e4f8..c4130d1095 100644 --- a/src/layer/vox/controls.ts +++ b/src/layer/voxel_annotation/controls.ts @@ -16,7 +16,7 @@ import type { UserLayerConstructor } from "#src/layer/index.js"; import { LayerActionContext } from "#src/layer/index.js"; -import type { UserLayerWithVoxelEditing } from "#src/layer/vox/index.js"; +import type { UserLayerWithVoxelEditing } from "#src/layer/voxel_annotation/index.js"; import { observeWatchable } from "#src/trackable_value.js"; import { getActivePanel, diff --git a/src/layer/vox/tabs/tools.ts b/src/layer/voxel_annotation/draw_tab.ts similarity index 95% rename from src/layer/vox/tabs/tools.ts rename to src/layer/voxel_annotation/draw_tab.ts index f1aa928eba..e3a5894e9e 100644 --- a/src/layer/vox/tabs/tools.ts +++ b/src/layer/voxel_annotation/draw_tab.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import { VOXEL_TAB_LAYOUT } from "#src/layer/vox/controls.js"; -import type { UserLayerWithVoxelEditing } from "#src/layer/vox/index.js"; +import { VOXEL_TAB_LAYOUT } from "#src/layer/voxel_annotation/controls.js"; +import type { UserLayerWithVoxelEditing } from "#src/layer/voxel_annotation/index.js"; import { observeWatchable } from "#src/trackable_value.js"; import { makeToolButton } from "#src/ui/tool.js"; -import type { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; +import type { VoxelEditController } from "#src/voxel_annotation/frontend.js"; import { DependentViewWidget } from "#src/widget/dependent_view_widget.js"; import { addLayerControlToOptionsTab } from "#src/widget/layer_control.js"; import { Tab } from "#src/widget/tab_view.js"; diff --git a/src/layer/vox/index.browser_test.ts b/src/layer/voxel_annotation/index.browser_test.ts similarity index 100% rename from src/layer/vox/index.browser_test.ts rename to src/layer/voxel_annotation/index.browser_test.ts diff --git a/src/layer/vox/index.ts b/src/layer/voxel_annotation/index.ts similarity index 99% rename from src/layer/vox/index.ts rename to src/layer/voxel_annotation/index.ts index 1cb5dd881a..ff920b8af6 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -20,7 +20,7 @@ import type { } from "#src/layer/index.js"; import { UserLayer } from "#src/layer/index.js"; import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; -import { VoxToolTab } from "#src/layer/vox/tabs/tools.js"; +import { VoxToolTab } from "#src/layer/voxel_annotation/draw_tab.js"; import type { ChunkTransformParameters, RenderLayerTransformOrError, @@ -59,7 +59,7 @@ import { TrackableEnum } from "#src/util/trackable_enum.js"; import { VoxelPreviewMultiscaleSource } from "#src/voxel_annotation/PreviewMultiscaleChunkSource.js"; import type { VoxelEditControllerHost } from "#src/voxel_annotation/base.js"; import { BrushShape } from "#src/voxel_annotation/base.js"; -import { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; +import { VoxelEditController } from "#src/voxel_annotation/frontend.js"; const BRUSH_SIZE_JSON_KEY = "brushSize"; const ERASE_MODE_JSON_KEY = "eraseMode"; diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index f087403e32..78882bd008 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -18,7 +18,7 @@ import type { MouseSelectionState } from "#src/layer/index.js"; import type { UserLayerWithVoxelEditing, VoxelEditingContext, -} from "#src/layer/vox/index.js"; +} from "#src/layer/voxel_annotation/index.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; import { RenderedDataPanel } from "#src/rendered_data_panel.js"; import { SliceViewPanel } from "#src/sliceview/panel.js"; diff --git a/src/voxel_annotation/edit_backend.spec.ts b/src/voxel_annotation/backend.spec.ts similarity index 99% rename from src/voxel_annotation/edit_backend.spec.ts rename to src/voxel_annotation/backend.spec.ts index 486a7dd3eb..23737d7112 100644 --- a/src/voxel_annotation/edit_backend.spec.ts +++ b/src/voxel_annotation/backend.spec.ts @@ -16,12 +16,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { mat4 } from "#src/util/geom.js"; +import { VoxelEditController } from "#src/voxel_annotation/backend.js"; import { makeVoxChunkKey, VOX_EDIT_FAILURE_RPC_ID, VOX_EDIT_HISTORY_UPDATE_RPC_ID, } from "#src/voxel_annotation/base.js"; -import { VoxelEditController } from "#src/voxel_annotation/edit_backend.js"; import type { RPC } from "#src/worker_rpc.js"; const mockRpc = { diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/backend.ts similarity index 100% rename from src/voxel_annotation/edit_backend.ts rename to src/voxel_annotation/backend.ts diff --git a/src/voxel_annotation/edit_controller.spec.ts b/src/voxel_annotation/frontend.spec.ts similarity index 99% rename from src/voxel_annotation/edit_controller.spec.ts rename to src/voxel_annotation/frontend.spec.ts index a90d6db665..1c5f160da8 100644 --- a/src/voxel_annotation/edit_controller.spec.ts +++ b/src/voxel_annotation/frontend.spec.ts @@ -19,7 +19,7 @@ import { BrushShape, VOX_EDIT_COMMIT_VOXELS_RPC_ID, } from "#src/voxel_annotation/base.js"; -import { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; +import { VoxelEditController } from "#src/voxel_annotation/frontend.js"; import type { RPC } from "#src/worker_rpc.js"; const mockRpc = { diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/frontend.ts similarity index 100% rename from src/voxel_annotation/edit_controller.ts rename to src/voxel_annotation/frontend.ts diff --git a/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts b/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts index d74de7c405..8094afd45e 100644 --- a/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts +++ b/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts @@ -27,7 +27,7 @@ import { makeLayer } from "#src/layer/index.js"; import type { VoxelEditingContext, UserLayerWithVoxelEditing, -} from "#src/layer/vox/index.js"; +} from "#src/layer/voxel_annotation/index.js"; import { Viewer } from "#src/viewer.js"; import { mswFixture } from "#tests/fixtures/msw"; From 4d8b3bdb7b680e48f8ee4e6769032dc8ebe39084 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Fri, 5 Dec 2025 15:00:02 +0100 Subject: [PATCH 163/251] feat(voxel-annotation): add selective erase mode and refactor edit methods --- src/layer/voxel_annotation/controls.ts | 7 +- src/layer/voxel_annotation/index.ts | 37 +++- src/ui/voxel_annotations.ts | 47 +++-- src/voxel_annotation/frontend.spec.ts | 13 +- src/voxel_annotation/frontend.ts | 269 +++++++++++++------------ 5 files changed, 218 insertions(+), 155 deletions(-) diff --git a/src/layer/voxel_annotation/controls.ts b/src/layer/voxel_annotation/controls.ts index c4130d1095..502ff7ee8a 100644 --- a/src/layer/voxel_annotation/controls.ts +++ b/src/layer/voxel_annotation/controls.ts @@ -86,7 +86,12 @@ export const VOXEL_TAB_LAYOUT: VoxelTabElement[] = [ })(), }, { - label: "Eraser", + label: "Eraser (selected value)", + toolJson: { type: "vox-erase-selected-mode" }, + ...checkboxLayerControl((layer) => layer.voxEraseSelectedMode), + }, + { + label: "Eraser (everything)", toolJson: { type: "vox-erase-mode" }, ...checkboxLayerControl((layer) => layer.voxEraseMode), }, diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index ff920b8af6..4c9d1d1be4 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -63,6 +63,7 @@ import { VoxelEditController } from "#src/voxel_annotation/frontend.js"; const BRUSH_SIZE_JSON_KEY = "brushSize"; const ERASE_MODE_JSON_KEY = "eraseMode"; +const ERASE_SELECTED_MODE_JSON_KEY = "eraseSelectedMode"; const BRUSH_SHAPE_JSON_KEY = "brushShape"; const FLOOD_FILL_MAX_VOXELS_JSON_KEY = "floodFillMaxVoxels"; const PAINT_VALUE_JSON_KEY = "paintValue"; @@ -178,16 +179,18 @@ export class VoxelEditingContext value: bigint, shape: BrushShape, basis?: { u: Float32Array; v: Float32Array }, + filterValue?: bigint, ) { if (!this._controller) throw new Error("Cannot use paintBrushWithShape without a controller"); if (await this.checkPermission()) { - this._controller.paintBrushWithShape( + await this._controller.paintBrushWithShape( centerCanonical, radiusCanonical, value, shape, basis, + filterValue, ); } } @@ -197,6 +200,7 @@ export class VoxelEditingContext fillValue: bigint, maxVoxels: number, basis: { u: Float32Array; v: Float32Array }, + filterValue?: bigint, ) { if (!this._controller) throw new Error("Cannot use floodFillPlane2D without a controller"); @@ -206,6 +210,7 @@ export class VoxelEditingContext fillValue, maxVoxels, basis, + filterValue, ); } return undefined; @@ -376,6 +381,7 @@ export declare abstract class UserLayerWithVoxelEditing extends UserLayer { voxBrushRadius: TrackableValue; voxEraseMode: TrackableBoolean; + voxEraseSelectedMode: TrackableBoolean; voxBrushShape: TrackableEnum; voxFloodMaxVoxels: TrackableValue; paintValue: TrackableValue; @@ -388,6 +394,7 @@ export declare abstract class UserLayerWithVoxelEditing extends UserLayer { ): ImageRenderLayer | SegmentationRenderLayer; abstract getVoxelPaintValue(erase: boolean): bigint; abstract setVoxelPaintValue(value: any): bigint; + shouldErase(): boolean; initializeVoxelEditingForSubsource( loadedSubsource: LoadedDataSubsource, @@ -412,6 +419,7 @@ export function UserLayerWithVoxelEditingMixin< // Brush properties voxBrushRadius = new TrackableValue(3, verifyInt); voxEraseMode = new TrackableBoolean(false); + voxEraseSelectedMode = new TrackableBoolean(false); voxBrushShape = new TrackableEnum(BrushShape, BrushShape.DISK); voxFloodMaxVoxels = new TrackableValue(10000, verifyFiniteFloat); @@ -425,9 +433,26 @@ export function UserLayerWithVoxelEditingMixin< }); this.voxBrushRadius.changed.add(this.specificationChanged.dispatch); this.voxEraseMode.changed.add(this.specificationChanged.dispatch); + this.voxEraseSelectedMode.changed.add(this.specificationChanged.dispatch); this.voxBrushShape.changed.add(this.specificationChanged.dispatch); this.voxFloodMaxVoxels.changed.add(this.specificationChanged.dispatch); this.paintValue.changed.add(this.specificationChanged.dispatch); + + this.registerDisposer( + this.voxEraseMode.changed.add(() => { + if (this.voxEraseMode.value) { + this.voxEraseSelectedMode.value = false; + } + }), + ); + this.registerDisposer( + this.voxEraseSelectedMode.changed.add(() => { + if (this.voxEraseSelectedMode.value) { + this.voxEraseMode.value = false; + } + }), + ); + this.tabs.add("Draw", { label: "Draw", order: 20, @@ -439,10 +464,15 @@ export function UserLayerWithVoxelEditingMixin< }); } + shouldErase(): boolean { + return this.voxEraseMode.value || this.voxEraseSelectedMode.value; + } + toJSON() { const json = super.toJSON(); json[BRUSH_SIZE_JSON_KEY] = this.voxBrushRadius.toJSON(); json[ERASE_MODE_JSON_KEY] = this.voxEraseMode.toJSON(); + json[ERASE_SELECTED_MODE_JSON_KEY] = this.voxEraseSelectedMode.toJSON(); json[BRUSH_SHAPE_JSON_KEY] = this.voxBrushShape.toJSON(); json[FLOOD_FILL_MAX_VOXELS_JSON_KEY] = this.voxFloodMaxVoxels.toJSON(); const pv = this.paintValue.toJSON(); @@ -458,6 +488,11 @@ export function UserLayerWithVoxelEditingMixin< verifyOptionalObjectProperty(specification, ERASE_MODE_JSON_KEY, (v) => this.voxEraseMode.restoreState(v), ); + verifyOptionalObjectProperty( + specification, + ERASE_SELECTED_MODE_JSON_KEY, + (v) => this.voxEraseSelectedMode.restoreState(v), + ); verifyOptionalObjectProperty(specification, BRUSH_SHAPE_JSON_KEY, (v) => this.voxBrushShape.restoreState(v), ); diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 78882bd008..017d3d099d 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -260,7 +260,7 @@ export function updateBrushOutline(layer: UserLayerWithVoxelEditing) { radiusY, rotation, "white", - layer.voxEraseMode.value, + layer.shouldErase(), ); } @@ -332,10 +332,7 @@ export class VoxelBrushTool extends BaseVoxelTool { ) { const points = this.linePoints(last, cur); if (points.length > 0) { - const value = this.layer.getVoxelPaintValue( - this.layer.voxEraseMode.value, - ); - this.paintPoints(points, value); + this.paintPoints(points); } } this.lastPoint = cur; @@ -354,9 +351,7 @@ export class VoxelBrushTool extends BaseVoxelTool { ); } - const value = this.layer.getVoxelPaintValue(this.layer.voxEraseMode.value); - - this.paintPoints([new Float32Array([start[0], start[1], start[2]])], value); + this.paintPoints([new Float32Array([start[0], start[1], start[2]])]); this.lastPoint = start; this.latestMouseState = mouseState; @@ -383,7 +378,7 @@ export class VoxelBrushTool extends BaseVoxelTool { } } - private paintPoints(points: Float32Array[], value: bigint) { + private paintPoints(points: Float32Array[]) { const radius = Math.max( 1, Math.floor(this.layer.voxBrushRadius.value ?? 3), @@ -398,16 +393,28 @@ export class VoxelBrushTool extends BaseVoxelTool { basis = this.getBasis(); } + const value = this.layer.getVoxelPaintValue(this.layer.shouldErase()); + const filterValue = this.layer.voxEraseSelectedMode + ? this.layer.getVoxelPaintValue(false) + : undefined; + for (const p of points) { - void editContext.paintBrushWithShape(p, radius, value, shapeEnum, basis); + void editContext.paintBrushWithShape( + p, + radius, + value, + shapeEnum, + basis, + filterValue, + ); } } } export class VoxelFloodFillTool extends BaseVoxelTool { private getCursor() { - const lightColor = this.layer.voxEraseMode.value ? "#FF8888" : "#FFFFFF"; - const darkColor = this.layer.voxEraseMode.value ? "#610000" : "#000000"; + const lightColor = this.layer.shouldErase() ? "#FF8888" : "#FFFFFF"; + const darkColor = this.layer.shouldErase() ? "#610000" : "#000000"; const floodFillSVG = ` StatusMessage.showTemporaryMessage(String(e?.message ?? e)), ); diff --git a/src/voxel_annotation/frontend.spec.ts b/src/voxel_annotation/frontend.spec.ts index 1c5f160da8..09fc8d73a9 100644 --- a/src/voxel_annotation/frontend.spec.ts +++ b/src/voxel_annotation/frontend.spec.ts @@ -45,6 +45,11 @@ class MockVolumeSource { return this.dataMap.get(key) ?? 0n; }); + getValueAt = vi.fn((pos: Float32Array) => { + const key = `${Math.round(pos[0])},${Math.round(pos[1])},${Math.round(pos[2])}`; + return this.dataMap.get(key) ?? 0n; + }); + computeChunkIndices(voxelCoord: Float32Array) { return { chunkGridPosition: new Float32Array([0, 0, 0]), @@ -108,12 +113,12 @@ describe("VoxelEditController", () => { }); describe("paintBrushWithShape", () => { - it("paints a 3D Sphere correctly", () => { + it("paints a 3D Sphere correctly", async () => { const center = new Float32Array([10, 10, 10]); const radius = 2; const value = 5n; - controller.paintBrushWithShape( + await controller.paintBrushWithShape( center, radius, value, @@ -149,7 +154,7 @@ describe("VoxelEditController", () => { expect(indicesSet.has(getIdx(12, 11, 10))).toBe(false); }); - it("paints a 2D Disk aligned to basis vectors", () => { + it("paints a 2D Disk aligned to basis vectors", async () => { const center = new Float32Array([10, 10, 5]); const radius = 2; const value = 3n; @@ -158,7 +163,7 @@ describe("VoxelEditController", () => { v: new Float32Array([0, 1, 0]), }; - controller.paintBrushWithShape( + await controller.paintBrushWithShape( center, radius, value, diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 87c0711a85..8ba67b6d28 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -18,6 +18,7 @@ import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transf import type { VolumeChunkSource, InMemoryVolumeChunkSource, + MultiscaleVolumeChunkSource, } from "#src/sliceview/volume/frontend.js"; import { StatusMessage } from "#src/status.js"; import { WatchableValue } from "#src/trackable_value.js"; @@ -127,11 +128,17 @@ export class VoxelEditController extends SharedObject { } as const; } - getSourceForLOD(lodIndex: number): VolumeChunkSource { - const sourcesByScale = this.host.primarySource.getSources( + private getSourceForLOD( + multiscale: MultiscaleVolumeChunkSource | undefined, + lodIndex: number, + ): VolumeChunkSource { + if (!multiscale) + throw new Error( + `VoxelEditController: Invalid multiscale object: ${multiscale}`, + ); + const sourcesByScale = multiscale.getSources( this.getIdentitySliceViewSourceOptions(), ); - // Assuming a single orientation, which is correct for this use case. const sources = sourcesByScale[0]; if (!sources || sources.length <= lodIndex) { throw new Error( @@ -147,13 +154,106 @@ export class VoxelEditController extends SharedObject { return source; } + private setupSources(lodIndex: number) { + const primarySource = this.getSourceForLOD( + this.host.primarySource, + lodIndex, + ); + const previewSource = this.getSourceForLOD( + this.host.previewSource, + lodIndex, + ) as InMemoryVolumeChunkSource; + + return { + primarySource, + previewSource, + getEnsuredValue: this.getEnsuredValueBuilder( + previewSource, + primarySource, + ), + }; + } + + private getEnsuredValueBuilder = + (previewSource: VolumeChunkSource, primarySource: VolumeChunkSource) => + async (voxelCoord: Float32Array): Promise => { + let val = previewSource.getValueAt(voxelCoord, this.singleChannelAccess); + if (val != null) { + val = typeof val === "bigint" ? val : BigInt(val); + if (val !== 0n) return val; + } + val = await primarySource.getEnsuredValueAt( + voxelCoord, + this.singleChannelAccess, + ); + if (val === null) return null; + return typeof val === "bigint" ? val : BigInt(val as number); + }; + + private processEdits( + voxelsToPaint: Float32Array[], + previewSource: InMemoryVolumeChunkSource, + value: bigint, + lodIndex: number, + ) { + const editsByVoxKey = new Map< + string, + { indices: number[]; value: bigint } + >(); + + for (const voxelCoord of voxelsToPaint) { + const { chunkGridPosition, positionWithinChunk } = + previewSource.computeChunkIndices(voxelCoord); + const chunkKey = chunkGridPosition.join(); + const voxKey = makeVoxChunkKey(chunkKey, lodIndex); + + let entry = editsByVoxKey.get(voxKey); + if (!entry) { + entry = { indices: [], value }; + editsByVoxKey.set(voxKey, entry); + } + + const { chunkDataSize } = previewSource.spec; + const index = + (positionWithinChunk[2] * chunkDataSize[1] + positionWithinChunk[1]) * + chunkDataSize[0] + + positionWithinChunk[0]; + entry.indices.push(index); + } + + const localEdits = new Map(); + for (const [voxKey, edit] of editsByVoxKey.entries()) { + const parsed = parseVoxChunkKey(voxKey); + if (!parsed) continue; + localEdits.set(parsed.chunkKey, edit); + } + previewSource.applyLocalEdits(localEdits); + + const backendEdits = [] as { + key: string; + indices: number[]; + value: bigint; + }[]; + for (const [voxKey, edit] of editsByVoxKey.entries()) { + backendEdits.push({ + key: voxKey, + indices: edit.indices, + value: edit.value, + }); + } + + this.commitEdits(backendEdits); + return backendEdits; + } + // Paint a disk (require the basis) or a sphere - paintBrushWithShape( + async paintBrushWithShape( centerCanonical: Float32Array, radiusCanonical: number, value: bigint, shape: BrushShape, basis?: { u: Float32Array; v: Float32Array }, + filterValue?: bigint, ) { if (!Number.isFinite(radiusCanonical) || radiusCanonical <= 0) { throw new Error("paintBrushWithShape: 'radius' must be > 0."); @@ -167,16 +267,7 @@ export class VoxelEditController extends SharedObject { // Hardcode drawing at LOD 0 for now. const voxelSize = 1; const sourceIndex = 0; - if (!this.host.previewSource) - throw new Error( - "paintBrushWithShape: ERROR Missing preview source from host.", - ); - const source = this.host.previewSource.getSources( - this.getIdentitySliceViewSourceOptions(), - )[0][sourceIndex]!.chunkSource as InMemoryVolumeChunkSource; - if (!source) { - throw new Error("paintBrushWithShape: Missing preview source"); - } + const { previewSource, getEnsuredValue } = this.setupSources(sourceIndex); const cx = Math.round((centerCanonical[0] ?? 0) / voxelSize); const cy = Math.round((centerCanonical[1] ?? 0) / voxelSize); @@ -191,13 +282,19 @@ export class VoxelEditController extends SharedObject { const voxelsToPaint: Float32Array[] = []; + const pushIf = async (point: Float32Array) => { + const v = await getEnsuredValue(point); + if (v === value || (filterValue !== undefined && v !== filterValue)) + return; + voxelsToPaint.push(point); + }; + if (shape === BrushShape.SPHERE) { for (let dz = -r; dz <= r; ++dz) { for (let dy = -r; dy <= r; ++dy) { for (let dx = -r; dx <= r; ++dx) { - if (dx * dx + dy * dy + dz * dz <= rr) { - voxelsToPaint.push(new Float32Array([cx + dx, cy + dy, cz + dz])); - } + if (dx * dx + dy * dy + dz * dz <= rr) + await pushIf(new Float32Array([cx + dx, cy + dy, cz + dz])); } } } @@ -214,60 +311,13 @@ export class VoxelEditController extends SharedObject { const point = vec3.fromValues(cx, cy, cz); vec3.scaleAndAdd(point, point, u as vec3, i); vec3.scaleAndAdd(point, point, v as vec3, j); - voxelsToPaint.push(point as Float32Array); + await pushIf(point as Float32Array); } } } } - if (!voxelsToPaint || voxelsToPaint.length === 0) return; - const editsByVoxKey = new Map< - string, - { indices: number[]; value: bigint } - >(); - - for (const voxelCoord of voxelsToPaint) { - const { chunkGridPosition, positionWithinChunk } = - source.computeChunkIndices(voxelCoord); - const chunkKey = chunkGridPosition.join(); - const voxKey = makeVoxChunkKey(chunkKey, sourceIndex); - - let entry = editsByVoxKey.get(voxKey); - if (!entry) { - entry = { indices: [], value }; - editsByVoxKey.set(voxKey, entry); - } - - const { chunkDataSize } = source.spec; - const index = - (positionWithinChunk[2] * chunkDataSize[1] + positionWithinChunk[1]) * - chunkDataSize[0] + - positionWithinChunk[0]; - entry.indices.push(index); - } - - const localEdits = new Map(); - for (const [voxKey, edit] of editsByVoxKey.entries()) { - const parsed = parseVoxChunkKey(voxKey); - if (!parsed) continue; - localEdits.set(parsed.chunkKey, edit); - } - source.applyLocalEdits(localEdits); - - const backendEdits = [] as { - key: string; - indices: number[]; - value: bigint; - }[]; - for (const [voxKey, edit] of editsByVoxKey.entries()) { - backendEdits.push({ - key: voxKey, - indices: edit.indices, - value: edit.value, - }); - } - - this.commitEdits(backendEdits); + this.processEdits(voxelsToPaint, previewSource, value, sourceIndex); } commitEdits( @@ -300,31 +350,27 @@ export class VoxelEditController extends SharedObject { fillValue: bigint, maxVoxels: number, basis: { u: Float32Array; v: Float32Array }, + filterValue?: bigint, ): Promise<{ edits: { key: string; indices: number[]; value: bigint }[]; filledCount: number; originalValue: bigint; }> { const sourceIndex = 0; - const source = this.getSourceForLOD(sourceIndex); + const { previewSource, getEnsuredValue } = this.setupSources(sourceIndex); const startVoxelLod = vec3.round( vec3.create(), startPositionCanonical as vec3, ); - const originalValueResult = await source.getEnsuredValueAt( - startVoxelLod as Float32Array, - this.singleChannelAccess, - ); - if (originalValueResult === null) { + const originalValue = await getEnsuredValue(startVoxelLod as Float32Array); + if (originalValue === null) { throw new Error( "Flood fill seed is in an unloaded or out-of-bounds chunk.", ); } - const originalValue = - typeof originalValueResult !== "bigint" - ? BigInt(originalValueResult as number) - : originalValueResult; + if (filterValue !== undefined && originalValue !== filterValue) + throw new Error("This is not the value selected for erasing"); if (originalValue === fillValue) { return { edits: [], filledCount: 0, originalValue }; @@ -343,15 +389,10 @@ export class VoxelEditController extends SharedObject { }; const isFillable = async (p: vec3): Promise => { - const value = await source.getEnsuredValueAt( - p as Float32Array, - this.singleChannelAccess, - ); + const value = await getEnsuredValue(p as Float32Array); if (value === null) return false; - const bigValue = - typeof value !== "bigint" ? BigInt(value as number) : value; - if (originalValue === 0n) return bigValue === 0n; - return bigValue === originalValue; + if (originalValue === 0n) return value === 0n; + return value === originalValue; }; const getCurrentThickness = (): number => { @@ -478,56 +519,18 @@ export class VoxelEditController extends SharedObject { } } } - if (!this.host.previewSource) - throw new Error( - "paintBrushWithShape: ERROR Missing preview source from host.", - ); - const previewSource = this.host.previewSource.getSources( - this.getIdentitySliceViewSourceOptions(), - )[0][sourceIndex]!.chunkSource as InMemoryVolumeChunkSource; - if (!previewSource) { - throw new Error("paintBrushWithShape: Missing preview source"); - } - const editsByVoxKey = new Map< - string, - { indices: number[]; value: bigint } - >(); - for (const voxelCoord of voxelsToFill) { - const { chunkGridPosition, positionWithinChunk } = - previewSource.computeChunkIndices(voxelCoord); - const chunkKey = chunkGridPosition.join(); - const voxKey = makeVoxChunkKey(chunkKey, sourceIndex); - let entry = editsByVoxKey.get(voxKey); - if (!entry) { - entry = { indices: [], value: fillValue }; - editsByVoxKey.set(voxKey, entry); - } - const { chunkDataSize } = previewSource.spec; - const index = - (positionWithinChunk[2] * chunkDataSize[1] + positionWithinChunk[1]) * - chunkDataSize[0] + - positionWithinChunk[0]; - entry.indices.push(index); - } - const localEdits = new Map(); - for (const [voxKey, edit] of editsByVoxKey.entries()) { - const parsed = parseVoxChunkKey(voxKey); - if (!parsed) continue; - localEdits.set(parsed.chunkKey, edit); - } - previewSource.applyLocalEdits(localEdits); - const backendEdits: { key: string; indices: number[]; value: bigint }[] = - []; - for (const [voxKey, edit] of editsByVoxKey.entries()) { - backendEdits.push({ - key: voxKey, - indices: edit.indices, - value: edit.value, - }); - } - this.commitEdits(backendEdits); - return { edits: backendEdits, filledCount, originalValue }; + const edits = this.processEdits( + voxelsToFill, + previewSource, + fillValue, + sourceIndex, + ); + return { + edits, + filledCount, + originalValue, + }; } callChunkReload(voxChunkKeys: string[], isForPreviewChunks: boolean) { From 86a2009a505e4663160680791ae70a057abe4250 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Tue, 9 Dec 2025 12:32:19 +0100 Subject: [PATCH 164/251] fix(voxel-annotation): correctly retrieve value from voxEraseSelectedMode --- src/ui/voxel_annotations.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 017d3d099d..c55ac1ccb3 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -394,7 +394,7 @@ export class VoxelBrushTool extends BaseVoxelTool { } const value = this.layer.getVoxelPaintValue(this.layer.shouldErase()); - const filterValue = this.layer.voxEraseSelectedMode + const filterValue = this.layer.voxEraseSelectedMode.value ? this.layer.getVoxelPaintValue(false) : undefined; @@ -473,7 +473,7 @@ export class VoxelFloodFillTool extends BaseVoxelTool { throw new Error("Invalid max fill voxels setting"); } - const filterValue = this.layer.voxEraseSelectedMode + const filterValue = this.layer.voxEraseSelectedMode.value ? this.layer.getVoxelPaintValue(false) : undefined; From 00de4cfeeb69fbc725004ac7d766a696a8dcb89c Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Tue, 9 Dec 2025 16:55:58 +0100 Subject: [PATCH 165/251] feat(voxel-annotation): remove double-opacity artifact from the optimistic renderlayer on the seg layer -> merged the draw call of both renderlayers so it uses the depth buffer and can benefit from the depth function WebGL2RenderingContext.LESS ; the next step is to introduce a sentinel or a mask to handle the erasing case --- src/layer/voxel_annotation/index.ts | 10 +- src/renderlayer.ts | 1 + src/sliceview/frontend.ts | 4 +- .../volume/segmentation_renderlayer.ts | 22 +++- src/voxel_annotation/base.ts | 3 + src/voxel_annotation/frontend.ts | 118 +++++++++--------- 6 files changed, 93 insertions(+), 65 deletions(-) diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index 4c9d1d1be4..c1c12504fd 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -36,7 +36,7 @@ import type { import { DataType } from "#src/sliceview/base.js"; import type { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; import type { ImageRenderLayer } from "#src/sliceview/volume/image_renderlayer.js"; -import type { SegmentationRenderLayer } from "#src/sliceview/volume/segmentation_renderlayer.js"; +import { SegmentationRenderLayer } from "#src/sliceview/volume/segmentation_renderlayer.js"; import { StatusMessage } from "#src/status.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; import type { WatchableValueInterface } from "#src/trackable_value.js"; @@ -127,6 +127,14 @@ export class VoxelEditingContext primaryRenderLayer.transform, ); + if ( + this.primaryRenderLayer instanceof SegmentationRenderLayer && + this.optimisticRenderLayer instanceof SegmentationRenderLayer + ) { + this.optimisticRenderLayer.forceHiddenFromMainRenderLoop = true; + this.primaryRenderLayer.setVoxelPreviewLayer(this.optimisticRenderLayer); + } + // since we only allow drawing at max res, we can lock the optimistic render layer to it ( this.optimisticRenderLayer as SliceViewRenderLayer diff --git a/src/renderlayer.ts b/src/renderlayer.ts index 583e2026ae..c880c7dc43 100644 --- a/src/renderlayer.ts +++ b/src/renderlayer.ts @@ -71,6 +71,7 @@ export class RenderLayer extends RefCounted { layerChanged = new NullarySignal(); redrawNeeded = new NullarySignal(); layerChunkProgressInfo = new LayerChunkProgressInfo(); + forceHiddenFromMainRenderLoop = false; handleAction(_action: string) { // Do nothing by default. diff --git a/src/sliceview/frontend.ts b/src/sliceview/frontend.ts index 76b9fe8f5f..ea6ff2a111 100644 --- a/src/sliceview/frontend.ts +++ b/src/sliceview/frontend.ts @@ -424,7 +424,9 @@ export class SliceView extends Base { visibleLayerList.length = 0; for (const renderLayer of this.layerManager.readyRenderLayers()) { if (renderLayer instanceof SliceViewRenderLayer) { - visibleLayerList.push(renderLayer); + if (!renderLayer.forceHiddenFromMainRenderLoop) { + visibleLayerList.push(renderLayer); + } let layerInfo = visibleLayers.get(renderLayer); if (layerInfo === undefined) { const disposers: Disposer[] = []; diff --git a/src/sliceview/volume/segmentation_renderlayer.ts b/src/sliceview/volume/segmentation_renderlayer.ts index c4a6c44b16..1e6887021f 100644 --- a/src/sliceview/volume/segmentation_renderlayer.ts +++ b/src/sliceview/volume/segmentation_renderlayer.ts @@ -35,6 +35,7 @@ import type { SliceView, SliceViewSingleResolutionSource, } from "#src/sliceview/frontend.js"; +import type { SliceViewRenderContext } from "#src/sliceview/renderlayer.js"; import type { MultiscaleVolumeChunkSource, VolumeChunkSource, @@ -43,6 +44,7 @@ import type { RenderLayerBaseOptions } from "#src/sliceview/volume/renderlayer.j import { SliceViewVolumeRenderLayer } from "#src/sliceview/volume/renderlayer.js"; import type { WatchableValueInterface } from "#src/trackable_value.js"; import { + WatchableValue, AggregateWatchableValue, makeCachedDerivedWatchableValue, } from "#src/trackable_value.js"; @@ -85,6 +87,7 @@ interface ShaderParameters { hideSegmentZero: boolean; hasSegmentDefaultColor: boolean; hasHighlightColor: boolean; + forceDiscard: boolean; } const HAS_SELECTED_SEGMENT_FLAG = 1; @@ -108,11 +111,14 @@ export class SegmentationRenderLayer extends SliceViewVolumeRenderLayer; constructor( multiscaleSource: MultiscaleVolumeChunkSource, public displayState: SliceViewSegmentationDisplayState, ) { + const forceDiscard = new WatchableValue(false); super(multiscaleSource, { shaderParameters: new AggregateWatchableValue((refCounted) => ({ hasEquivalences: refCounted.registerDisposer( @@ -163,12 +169,14 @@ export class SegmentationRenderLayer extends SliceViewVolumeRenderLayer(); - - for (const voxelCoord of voxelsToPaint) { - const { chunkGridPosition, positionWithinChunk } = - previewSource.computeChunkIndices(voxelCoord); - const chunkKey = chunkGridPosition.join(); - const voxKey = makeVoxChunkKey(chunkKey, lodIndex); - - let entry = editsByVoxKey.get(voxKey); - if (!entry) { - entry = { indices: [], value }; - editsByVoxKey.set(voxKey, entry); - } - - const { chunkDataSize } = previewSource.spec; - const index = - (positionWithinChunk[2] * chunkDataSize[1] + positionWithinChunk[1]) * - chunkDataSize[0] + - positionWithinChunk[0]; - entry.indices.push(index); - } + private processEditsBuilder = + (previewSource: InMemoryVolumeChunkSource) => + (voxelsToPaint: Float32Array[], value: bigint, lodIndex: number) => { + const editsByVoxKey = new Map< + string, + { indices: number[]; value: bigint } + >(); + + for (const voxelCoord of voxelsToPaint) { + const { chunkGridPosition, positionWithinChunk } = + previewSource.computeChunkIndices(voxelCoord); + const chunkKey = chunkGridPosition.join(); + const voxKey = makeVoxChunkKey(chunkKey, lodIndex); + + let entry = editsByVoxKey.get(voxKey); + if (!entry) { + entry = { indices: [], value }; + editsByVoxKey.set(voxKey, entry); + } - const localEdits = new Map(); - for (const [voxKey, edit] of editsByVoxKey.entries()) { - const parsed = parseVoxChunkKey(voxKey); - if (!parsed) continue; - localEdits.set(parsed.chunkKey, edit); - } - previewSource.applyLocalEdits(localEdits); + const { chunkDataSize } = previewSource.spec; + const index = + (positionWithinChunk[2] * chunkDataSize[1] + positionWithinChunk[1]) * + chunkDataSize[0] + + positionWithinChunk[0]; + entry.indices.push(index); + } - const backendEdits = [] as { - key: string; - indices: number[]; - value: bigint; - }[]; - for (const [voxKey, edit] of editsByVoxKey.entries()) { - backendEdits.push({ - key: voxKey, - indices: edit.indices, - value: edit.value, - }); - } + const localEdits = new Map< + string, + { indices: number[]; value: bigint } + >(); + for (const [voxKey, edit] of editsByVoxKey.entries()) { + const parsed = parseVoxChunkKey(voxKey); + if (!parsed) continue; + localEdits.set(parsed.chunkKey, edit); + } + previewSource.applyLocalEdits(localEdits); + + const backendEdits = [] as { + key: string; + indices: number[]; + value: bigint; + }[]; + for (const [voxKey, edit] of editsByVoxKey.entries()) { + backendEdits.push({ + key: voxKey, + indices: edit.indices, + value: edit.value, + }); + } - this.commitEdits(backendEdits); - return backendEdits; - } + this.commitEdits(backendEdits); + return backendEdits; + }; // Paint a disk (require the basis) or a sphere async paintBrushWithShape( @@ -267,7 +268,7 @@ export class VoxelEditController extends SharedObject { // Hardcode drawing at LOD 0 for now. const voxelSize = 1; const sourceIndex = 0; - const { previewSource, getEnsuredValue } = this.setupSources(sourceIndex); + const { processEdits, getEnsuredValue } = this.setupSources(sourceIndex); const cx = Math.round((centerCanonical[0] ?? 0) / voxelSize); const cy = Math.round((centerCanonical[1] ?? 0) / voxelSize); @@ -317,7 +318,7 @@ export class VoxelEditController extends SharedObject { } } if (!voxelsToPaint || voxelsToPaint.length === 0) return; - this.processEdits(voxelsToPaint, previewSource, value, sourceIndex); + processEdits(voxelsToPaint, value, sourceIndex); } commitEdits( @@ -357,7 +358,7 @@ export class VoxelEditController extends SharedObject { originalValue: bigint; }> { const sourceIndex = 0; - const { previewSource, getEnsuredValue } = this.setupSources(sourceIndex); + const { processEdits, getEnsuredValue } = this.setupSources(sourceIndex); const startVoxelLod = vec3.round( vec3.create(), startPositionCanonical as vec3, @@ -520,12 +521,7 @@ export class VoxelEditController extends SharedObject { } } - const edits = this.processEdits( - voxelsToFill, - previewSource, - fillValue, - sourceIndex, - ); + const edits = processEdits(voxelsToFill, fillValue, sourceIndex); return { edits, filledCount, From 0bd9fd91d387d2ec0c794e8925960193b32f380c Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 10 Dec 2025 14:54:25 +0100 Subject: [PATCH 166/251] feat(voxel-annotation): added eraser preview with the help of a sentinel value (~1n) --- src/layer/segmentation/index.ts | 11 +++++ src/layer/voxel_annotation/index.ts | 16 ++++--- .../volume/segmentation_renderlayer.ts | 25 +++++++--- src/ui/voxel_annotations.ts | 4 +- src/voxel_annotation/base.ts | 2 + src/voxel_annotation/frontend.spec.ts | 28 +++++++---- src/voxel_annotation/frontend.ts | 46 +++++++++++-------- .../pipeline_zarr_s3.browser_test.ts | 13 ++++-- 8 files changed, 98 insertions(+), 47 deletions(-) diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index cc137a9a16..b0379e901d 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -134,6 +134,10 @@ import { verifyString, } from "#src/util/json.js"; import { Signal } from "#src/util/signal.js"; +import { + SEG_ERASE_SENTINEL, + type VoxelValueGetter, +} from "#src/voxel_annotation/base.js"; import { makeWatchableShaderError } from "#src/webgl/dynamic_shader.js"; import type { DependentViewContext } from "#src/widget/dependent_view_widget.js"; import { registerLayerShaderControlsTool } from "#src/widget/shader_controls.js"; @@ -626,6 +630,13 @@ export class SegmentationUserLayer extends Base { }); } + getVoxelPaintValue(erase: boolean): VoxelValueGetter { + return (isPreview) => { + if (erase) return isPreview ? SEG_ERASE_SENTINEL : 0n; + return this.paintValue.value; + }; + } + filterBySegmentLabel = (id: bigint) => { const augmented = augmentSegmentId(this.displayState, id); const { label } = augmented; diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index c1c12504fd..6b14b80a64 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -57,7 +57,10 @@ import { } from "#src/util/json.js"; import { TrackableEnum } from "#src/util/trackable_enum.js"; import { VoxelPreviewMultiscaleSource } from "#src/voxel_annotation/PreviewMultiscaleChunkSource.js"; -import type { VoxelEditControllerHost } from "#src/voxel_annotation/base.js"; +import type { + VoxelEditControllerHost, + VoxelValueGetter, +} from "#src/voxel_annotation/base.js"; import { BrushShape } from "#src/voxel_annotation/base.js"; import { VoxelEditController } from "#src/voxel_annotation/frontend.js"; @@ -184,7 +187,7 @@ export class VoxelEditingContext async paintBrushWithShape( centerCanonical: Float32Array, radiusCanonical: number, - value: bigint, + value: VoxelValueGetter, shape: BrushShape, basis?: { u: Float32Array; v: Float32Array }, filterValue?: bigint, @@ -205,7 +208,7 @@ export class VoxelEditingContext async floodFillPlane2D( startPositionCanonical: Float32Array, - fillValue: bigint, + fillValue: VoxelValueGetter, maxVoxels: number, basis: { u: Float32Array; v: Float32Array }, filterValue?: bigint, @@ -400,7 +403,7 @@ export declare abstract class UserLayerWithVoxelEditing extends UserLayer { source: MultiscaleVolumeChunkSource, transform: WatchableValueInterface, ): ImageRenderLayer | SegmentationRenderLayer; - abstract getVoxelPaintValue(erase: boolean): bigint; + abstract getVoxelPaintValue(erase: boolean): VoxelValueGetter; abstract setVoxelPaintValue(value: any): bigint; shouldErase(): boolean; @@ -514,9 +517,8 @@ export function UserLayerWithVoxelEditingMixin< ); } - getVoxelPaintValue(erase: boolean): bigint { - if (erase) return 0n; - return this.paintValue.value; + getVoxelPaintValue(erase: boolean): VoxelValueGetter { + return (_isPreview: boolean) => (erase ? 0n : this.paintValue.value); } setVoxelPaintValue(x: any) { diff --git a/src/sliceview/volume/segmentation_renderlayer.ts b/src/sliceview/volume/segmentation_renderlayer.ts index 1e6887021f..582046d5ce 100644 --- a/src/sliceview/volume/segmentation_renderlayer.ts +++ b/src/sliceview/volume/segmentation_renderlayer.ts @@ -87,7 +87,7 @@ interface ShaderParameters { hideSegmentZero: boolean; hasSegmentDefaultColor: boolean; hasHighlightColor: boolean; - forceDiscard: boolean; + isForOptimisticPreview: boolean; } const HAS_SELECTED_SEGMENT_FLAG = 1; @@ -112,13 +112,13 @@ export class SegmentationRenderLayer extends SliceViewVolumeRenderLayer; + public isForOptimisticPreview: WatchableValue; constructor( multiscaleSource: MultiscaleVolumeChunkSource, public displayState: SliceViewSegmentationDisplayState, ) { - const forceDiscard = new WatchableValue(false); + const isForOptimisticPreview = new WatchableValue(false); super(multiscaleSource, { shaderParameters: new AggregateWatchableValue((refCounted) => ({ hasEquivalences: refCounted.registerDisposer( @@ -169,14 +169,14 @@ export class SegmentationRenderLayer extends SliceViewVolumeRenderLayer bigint; + export interface VoxelLayerResolution { lodIndex: number; transform: number[]; diff --git a/src/voxel_annotation/frontend.spec.ts b/src/voxel_annotation/frontend.spec.ts index 09fc8d73a9..ec987f80b0 100644 --- a/src/voxel_annotation/frontend.spec.ts +++ b/src/voxel_annotation/frontend.spec.ts @@ -117,11 +117,12 @@ describe("VoxelEditController", () => { const center = new Float32Array([10, 10, 10]); const radius = 2; const value = 5n; + const getter = (_isPreview: boolean) => value; await controller.paintBrushWithShape( center, radius, - value, + getter, BrushShape.SPHERE, undefined, ); @@ -158,6 +159,7 @@ describe("VoxelEditController", () => { const center = new Float32Array([10, 10, 5]); const radius = 2; const value = 3n; + const getter = (_isPreview: boolean) => value; const basis = { u: new Float32Array([1, 0, 0]), v: new Float32Array([0, 1, 0]), @@ -166,7 +168,7 @@ describe("VoxelEditController", () => { await controller.paintBrushWithShape( center, radius, - value, + getter, BrushShape.DISK, basis, ); @@ -213,7 +215,7 @@ describe("VoxelEditController", () => { const result = await controller.floodFillPlane2D( seed, - fillValue, + (_) => fillValue, maxVoxels, basis, ); @@ -235,7 +237,7 @@ describe("VoxelEditController", () => { }; await expect( - controller.floodFillPlane2D(seed, 2n, maxVoxels, basis), + controller.floodFillPlane2D(seed, (_) => 2n, maxVoxels, basis), ).rejects.toThrow(/exceeds the limit/); mockPrimarySource.dataMap.set("51,50,5", 1n); @@ -243,7 +245,12 @@ describe("VoxelEditController", () => { mockPrimarySource.dataMap.set("50,51,5", 1n); mockPrimarySource.dataMap.set("50,49,5", 1n); - const result = await controller.floodFillPlane2D(seed, 2n, 100, basis); + const result = await controller.floodFillPlane2D( + seed, + (_) => 2n, + 100, + basis, + ); expect(result.filledCount).toBe(1); expect(result.edits[0].indices.length).toBe(1); @@ -262,7 +269,7 @@ describe("VoxelEditController", () => { }; await expect( - controller.floodFillPlane2D(seed, 9n, maxVoxels, basis), + controller.floodFillPlane2D(seed, (_) => 9n, maxVoxels, basis), ).rejects.toThrow("Flood fill region exceeds the limit"); }); @@ -274,7 +281,12 @@ describe("VoxelEditController", () => { v: new Float32Array([0, 1, 0]), }; - const result = await controller.floodFillPlane2D(seed, 5n, 100, basis); + const result = await controller.floodFillPlane2D( + seed, + (_) => 5n, + 100, + basis, + ); expect(result.filledCount).toBe(0); expect(result.edits.length).toBe(0); @@ -313,7 +325,7 @@ describe("VoxelEditController", () => { const result = await controller.floodFillPlane2D( seed, - fillValue, + (_) => fillValue, maxVoxels, basis, ); diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 6a0a8c6c66..4e045c5a91 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -26,6 +26,7 @@ import { vec3 } from "#src/util/geom.js"; import type { VoxelEditControllerHost, VoxelLayerResolution, + VoxelValueGetter, } from "#src/voxel_annotation/base.js"; import { BrushShape, @@ -193,11 +194,12 @@ export class VoxelEditController extends SharedObject { private processEditsBuilder = (previewSource: InMemoryVolumeChunkSource) => - (voxelsToPaint: Float32Array[], value: bigint, lodIndex: number) => { - const editsByVoxKey = new Map< - string, - { indices: number[]; value: bigint } - >(); + ( + voxelsToPaint: Float32Array[], + valueGetter: VoxelValueGetter, + lodIndex: number, + ) => { + const indicesByVoxKey = new Map(); for (const voxelCoord of voxelsToPaint) { const { chunkGridPosition, positionWithinChunk } = @@ -205,10 +207,10 @@ export class VoxelEditController extends SharedObject { const chunkKey = chunkGridPosition.join(); const voxKey = makeVoxChunkKey(chunkKey, lodIndex); - let entry = editsByVoxKey.get(voxKey); - if (!entry) { - entry = { indices: [], value }; - editsByVoxKey.set(voxKey, entry); + let indices = indicesByVoxKey.get(voxKey); + if (!indices) { + indices = []; + indicesByVoxKey.set(voxKey, indices); } const { chunkDataSize } = previewSource.spec; @@ -216,30 +218,32 @@ export class VoxelEditController extends SharedObject { (positionWithinChunk[2] * chunkDataSize[1] + positionWithinChunk[1]) * chunkDataSize[0] + positionWithinChunk[0]; - entry.indices.push(index); + indices.push(index); } + const previewValue = valueGetter(true); const localEdits = new Map< string, { indices: number[]; value: bigint } >(); - for (const [voxKey, edit] of editsByVoxKey.entries()) { + for (const [voxKey, indices] of indicesByVoxKey.entries()) { const parsed = parseVoxChunkKey(voxKey); if (!parsed) continue; - localEdits.set(parsed.chunkKey, edit); + localEdits.set(parsed.chunkKey, { indices, value: previewValue }); } previewSource.applyLocalEdits(localEdits); + const storageValue = valueGetter(false); const backendEdits = [] as { key: string; indices: number[]; value: bigint; }[]; - for (const [voxKey, edit] of editsByVoxKey.entries()) { + for (const [voxKey, indices] of indicesByVoxKey.entries()) { backendEdits.push({ key: voxKey, - indices: edit.indices, - value: edit.value, + indices: indices, + value: storageValue, }); } @@ -251,7 +255,7 @@ export class VoxelEditController extends SharedObject { async paintBrushWithShape( centerCanonical: Float32Array, radiusCanonical: number, - value: bigint, + valueGetter: VoxelValueGetter, shape: BrushShape, basis?: { u: Float32Array; v: Float32Array }, filterValue?: bigint, @@ -283,6 +287,8 @@ export class VoxelEditController extends SharedObject { const voxelsToPaint: Float32Array[] = []; + const value = valueGetter(false); + const pushIf = async (point: Float32Array) => { const v = await getEnsuredValue(point); if (v === value || (filterValue !== undefined && v !== filterValue)) @@ -318,7 +324,7 @@ export class VoxelEditController extends SharedObject { } } if (!voxelsToPaint || voxelsToPaint.length === 0) return; - processEdits(voxelsToPaint, value, sourceIndex); + processEdits(voxelsToPaint, valueGetter, sourceIndex); } commitEdits( @@ -348,7 +354,7 @@ export class VoxelEditController extends SharedObject { */ async floodFillPlane2D( startPositionCanonical: Float32Array, - fillValue: bigint, + fillValueGetter: VoxelValueGetter, maxVoxels: number, basis: { u: Float32Array; v: Float32Array }, filterValue?: bigint, @@ -373,6 +379,8 @@ export class VoxelEditController extends SharedObject { if (filterValue !== undefined && originalValue !== filterValue) throw new Error("This is not the value selected for erasing"); + const fillValue = fillValueGetter(false); + if (originalValue === fillValue) { return { edits: [], filledCount: 0, originalValue }; } @@ -521,7 +529,7 @@ export class VoxelEditController extends SharedObject { } } - const edits = processEdits(voxelsToFill, fillValue, sourceIndex); + const edits = processEdits(voxelsToFill, fillValueGetter, sourceIndex); return { edits, filledCount, diff --git a/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts b/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts index 8094afd45e..6415ccdac6 100644 --- a/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts +++ b/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts @@ -154,7 +154,7 @@ test("Pipeline: Zarr V2 (UINT8) Undo/Redo with Brush", async () => { const { context } = await waitForEditingContext(); const center = new Float32Array([16, 16, 16]); - await context.paintBrushWithShape(center, 5, 100n, 0 /* DISK */, { + await context.paintBrushWithShape(center, 5, (_) => 100n, 0 /* DISK */, { u: new Float32Array([1, 0, 0]), v: new Float32Array([0, 1, 0]), }); @@ -222,7 +222,7 @@ test("Pipeline: Zarr V3 (UINT64) Brush", async () => { const center = new Float32Array([16, 16, 16]); const paintVal = 123456789n; - await context.paintBrushWithShape(center, 2, paintVal, 0 /* DISK */, { + await context.paintBrushWithShape(center, 2, (_) => paintVal, 0 /* DISK */, { u: new Float32Array([1, 0, 0]), v: new Float32Array([0, 1, 0]), }); @@ -272,7 +272,7 @@ test("Pipeline: Zarr V2 (UINT32) with Slash Separator", async () => { const center = new Float32Array([10, 10, 10]); const paintVal = 42n; - await context.paintBrushWithShape(center, 2, paintVal, 0 /* DISK */, { + await context.paintBrushWithShape(center, 2, (_) => paintVal, 0 /* DISK */, { u: new Float32Array([1, 0, 0]), v: new Float32Array([0, 1, 0]), }); @@ -345,7 +345,12 @@ test("Pipeline: Flood Fill (Zarr V2 UINT8 on img layer)", async () => { await poll( async () => { try { - await context.floodFillPlane2D(seed, fillValue, maxVoxels, basis); + await context.floodFillPlane2D( + seed, + (_) => fillValue, + maxVoxels, + basis, + ); return true; } catch (e: any) { if (e.message.includes("unloaded")) { From ff5b7528856bc29dde571a48be2fc7af7dc3861d Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 8 Dec 2025 16:23:20 +0100 Subject: [PATCH 167/251] feat(voxel-annotation): add a bottom bar when a voxel tool is active, the respective tool specific settings havve been moved to this bar --- src/layer/voxel_annotation/controls.ts | 253 ++++++++++++++++++------- src/ui/voxel_annotations.css | 29 +++ src/ui/voxel_annotations.ts | 231 +++++++++++----------- src/voxel_annotation/TODOs.md | 5 +- 4 files changed, 328 insertions(+), 190 deletions(-) create mode 100644 src/ui/voxel_annotations.css diff --git a/src/layer/voxel_annotation/controls.ts b/src/layer/voxel_annotation/controls.ts index 502ff7ee8a..80e2e34d7f 100644 --- a/src/layer/voxel_annotation/controls.ts +++ b/src/layer/voxel_annotation/controls.ts @@ -17,11 +17,10 @@ import type { UserLayerConstructor } from "#src/layer/index.js"; import { LayerActionContext } from "#src/layer/index.js"; import type { UserLayerWithVoxelEditing } from "#src/layer/voxel_annotation/index.js"; +import { RenderedDataPanel } from "#src/rendered_data_panel.js"; +import { SliceViewPanel } from "#src/sliceview/panel.js"; import { observeWatchable } from "#src/trackable_value.js"; -import { - getActivePanel, - updateBrushOutline, -} from "#src/ui/voxel_annotations.js"; +import { mat3, vec3 } from "#src/util/geom.js"; import { BRUSH_TOOL_ID, FLOODFILL_TOOL_ID, @@ -34,57 +33,183 @@ import { checkboxLayerControl } from "#src/widget/layer_control_checkbox.js"; import { enumLayerControl } from "#src/widget/layer_control_enum.js"; import { rangeLayerControl } from "#src/widget/layer_control_range.js"; +export function getActivePanel( + layer: UserLayerWithVoxelEditing, +): RenderedDataPanel | undefined { + let activePanel: RenderedDataPanel | undefined; + for (const panel of layer.manager.root.display.panels) { + if (panel instanceof RenderedDataPanel) { + if (panel.mouseX !== -1 && panel instanceof SliceViewPanel) { + activePanel = panel; + } else { + panel.clearOverlay(); + } + } + } + return activePanel; +} + +export function updateBrushOutline(layer: UserLayerWithVoxelEditing) { + const panel = getActivePanel(layer); + if (!panel || !(panel instanceof SliceViewPanel)) { + if (panel) panel.clearOverlay(); + return; + } + + const { projectionParameters } = panel.sliceView; + const { displayDimensionRenderInfo, viewMatrix } = projectionParameters.value; + const { canonicalVoxelFactors, displayRank } = displayDimensionRenderInfo; + + if (displayRank < 2) { + panel.clearOverlay(); + return; + } + + const radiusInVoxels = layer.voxBrushRadius.value; + + const n_canonical = + projectionParameters.value.viewportNormalInCanonicalCoordinates; + + const canonicalVoxelFactorsVec3 = vec3.fromValues( + canonicalVoxelFactors[0], + canonicalVoxelFactors[1], + canonicalVoxelFactors[2], + ); + + // Convert to voxel coordinates by dividing by canonical voxel factors. + const n_vox = vec3.create(); + vec3.divide(n_vox, n_canonical as vec3, canonicalVoxelFactorsVec3); + vec3.normalize(n_vox, n_vox); + + // Create an orthonormal basis for the plane in voxel coordinates + const u_vox = vec3.create(); + const tempVec = vec3.fromValues(1, 0, 0); + if (Math.abs(vec3.dot(n_vox, tempVec)) > 0.999) { + vec3.set(tempVec, 0, 1, 0); + } + vec3.cross(u_vox, n_vox, tempVec); + vec3.normalize(u_vox, u_vox); + + const v_vox = vec3.cross(vec3.create(), n_vox, u_vox); + + // Scale basis vectors by radius to get two orthogonal radius vectors of the brush circle + // in voxel coordinates. + vec3.scale(u_vox, u_vox, radiusInVoxels); + vec3.scale(v_vox, v_vox, radiusInVoxels); + + const u_cam = vec3.create(); + const v_cam = vec3.create(); + // The viewMatrix transforms from world/voxel space to camera space. + // We use a mat3 to only apply rotation and scaling, not translation. + const viewMatrix3 = mat3.fromMat4(mat3.create(), viewMatrix); + + // Transform voxel-space vectors directly to camera-space vectors. + // This avoids the double-scaling error. + vec3.transformMat3(u_cam, u_vox, viewMatrix3); + vec3.transformMat3(v_cam, v_vox, viewMatrix3); + + // The x, y components of these vectors are conjugate semi-diameters of the ellipse on screen. + const u_scr_x = u_cam[0]; + const u_scr_y = u_cam[1]; + const v_scr_x = v_cam[0]; + const v_scr_y = v_cam[1]; + + // From the conjugate semi-diameters, compute the ellipse parameters (radii and rotation). + // We analyze the quadratic form matrix Q = A * A^T where A = [[u_scr_x, v_scr_x], [u_scr_y, v_scr_y]]. + const Q11 = u_scr_x * u_scr_x + v_scr_x * v_scr_x; + const Q12 = u_scr_x * u_scr_y + v_scr_x * v_scr_y; + const Q22 = u_scr_y * u_scr_y + v_scr_y * v_scr_y; + + const trace = Q11 + Q22; + const det = Q11 * Q22 - Q12 * Q12; + + // Eigenvalues are roots of lambda^2 - trace*lambda + det = 0 + const D_sq = trace * trace - 4 * det; + const D = D_sq < 0 ? 0 : Math.sqrt(D_sq); + + const lambda1 = (trace + D) / 2; + const lambda2 = (trace - D) / 2; + + const radiusX = Math.sqrt(lambda1); + const radiusY = Math.sqrt(lambda2); + + // Eigenvector for lambda1 is proportional to [Q12, lambda1 - Q11] + const rotation = Math.atan2(lambda1 - Q11, Q12); + + panel.drawBrushCursor( + panel.mouseX, + panel.mouseY, + radiusX, + radiusY, + rotation, + "white", + layer.shouldErase(), + ); +} + export type VoxelTabElement = | { type: "header"; label: string } | { type: "tool-row"; tools: { toolId: string; label: string }[] } | LayerControlDefinition; -export const VOXEL_TAB_LAYOUT: VoxelTabElement[] = [ - { type: "header", label: "Tools" }, - { - type: "tool-row", - tools: [ - { toolId: BRUSH_TOOL_ID, label: "Brush" }, - { toolId: FLOODFILL_TOOL_ID, label: "Flood Fill" }, - { toolId: SEG_PICKER_TOOL_ID, label: "Seg Picker" }, - ], - }, +const TOOL_SPECIFIC_CONTROLS: LayerControlDefinition[] = + [ + { + label: "Brush size", + toolJson: { type: "vox-brush-size" }, + ...(() => { + const control = rangeLayerControl( + (layer: UserLayerWithVoxelEditing) => ({ + value: layer.voxBrushRadius, + options: { min: 1, max: 64, step: 1 }, + }), + ); + const originalActivateTool = control.activateTool; + return { + ...control, + activateTool: (activation, controlContext) => { + originalActivateTool(activation, controlContext as any); + + const layer = activation.tool.layer as UserLayerWithVoxelEditing; + const updateCursor = () => { + updateBrushOutline(layer); + }; + + updateCursor(); + activation.registerDisposer( + layer.manager.root.layerSelectedValues.mouseState.changed.add( + updateCursor, + ), + ); + activation.registerDisposer( + layer.voxBrushRadius.changed.add(updateCursor), + ); + activation.registerDisposer(() => { + getActivePanel(layer)?.clearOverlay(); + }); + }, + }; + })(), + }, + { + label: "Brush shape", + toolJson: { type: "vox-brush-shape" }, + ...enumLayerControl( + (layer: UserLayerWithVoxelEditing) => layer.voxBrushShape, + ), + }, + { + label: "Max fill voxels", + toolJson: { type: "vox-flood-max-voxels" }, + ...rangeLayerControl((layer) => ({ + value: layer.voxFloodMaxVoxels, + options: { min: 1, max: 1000000, step: 1000 }, + })), + }, + ]; + +const COMMON_CONTROLS: VoxelTabElement[] = [ { type: "header", label: "Settings" }, - { - label: "Brush size", - toolJson: { type: "vox-brush-size" }, - ...(() => { - const control = rangeLayerControl((layer: UserLayerWithVoxelEditing) => ({ - value: layer.voxBrushRadius, - options: { min: 1, max: 64, step: 1 }, - })); - const originalActivateTool = control.activateTool; - return { - ...control, - activateTool: (activation, controlContext) => { - originalActivateTool(activation, controlContext as any); - - const layer = activation.tool.layer as UserLayerWithVoxelEditing; - const updateCursor = () => { - updateBrushOutline(layer); - }; - - updateCursor(); - activation.registerDisposer( - layer.manager.root.layerSelectedValues.mouseState.changed.add( - updateCursor, - ), - ); - activation.registerDisposer( - layer.voxBrushRadius.changed.add(updateCursor), - ); - activation.registerDisposer(() => { - getActivePanel(layer)?.clearOverlay(); - }); - }, - }; - })(), - }, { label: "Eraser (selected value)", toolJson: { type: "vox-erase-selected-mode" }, @@ -95,21 +220,6 @@ export const VOXEL_TAB_LAYOUT: VoxelTabElement[] = [ toolJson: { type: "vox-erase-mode" }, ...checkboxLayerControl((layer) => layer.voxEraseMode), }, - { - label: "Brush shape", - toolJson: { type: "vox-brush-shape" }, - ...enumLayerControl( - (layer: UserLayerWithVoxelEditing) => layer.voxBrushShape, - ), - }, - { - label: "Max fill voxels", - toolJson: { type: "vox-flood-max-voxels" }, - ...rangeLayerControl((layer) => ({ - value: layer.voxFloodMaxVoxels, - options: { min: 1, max: 1000000, step: 1000 }, - })), - }, { type: "header", label: "Actions" }, { label: "Undo", @@ -168,11 +278,24 @@ export const VOXEL_TAB_LAYOUT: VoxelTabElement[] = [ ]; export const VOXEL_LAYER_CONTROLS: LayerControlDefinition[] = - VOXEL_TAB_LAYOUT.filter( + [...TOOL_SPECIFIC_CONTROLS, ...COMMON_CONTROLS].filter( (x): x is LayerControlDefinition => !("type" in x) || (x.type !== "header" && x.type !== "tool-row"), ); +export const VOXEL_TAB_LAYOUT: VoxelTabElement[] = [ + { type: "header", label: "Tools" }, + { + type: "tool-row", + tools: [ + { toolId: BRUSH_TOOL_ID, label: "Brush" }, + { toolId: FLOODFILL_TOOL_ID, label: "Flood Fill" }, + { toolId: SEG_PICKER_TOOL_ID, label: "Seg Picker" }, + ], + }, + ...COMMON_CONTROLS, +]; + export function registerVoxelLayerControls( layerType: UserLayerConstructor, ) { diff --git a/src/ui/voxel_annotations.css b/src/ui/voxel_annotations.css new file mode 100644 index 0000000000..f7ce9a849d --- /dev/null +++ b/src/ui/voxel_annotations.css @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2025 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. + */ + +.neuroglancer-voxel-tool-options-body { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + gap: 15px; +} + +.neuroglancer-tool-activation-status-header { + white-space: nowrap; + font-weight: bold; + margin: 5px; +} diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index bc7466ee5d..f82fbe9d41 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -14,30 +14,51 @@ * limitations under the License. */ +import "#src/ui/voxel_annotations.css"; + import type { MouseSelectionState } from "#src/layer/index.js"; +import { + getActivePanel, + updateBrushOutline, + VOXEL_LAYER_CONTROLS, +} from "#src/layer/voxel_annotation/controls.js"; import type { UserLayerWithVoxelEditing, VoxelEditingContext, } from "#src/layer/voxel_annotation/index.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; -import { RenderedDataPanel } from "#src/rendered_data_panel.js"; -import { SliceViewPanel } from "#src/sliceview/panel.js"; import { StatusMessage } from "#src/status.js"; -import { LayerTool, registerTool, type ToolActivation } from "#src/ui/tool.js"; -import { vec3, mat3 } from "#src/util/geom.js"; +import { + LayerTool, + makeToolActivationStatusMessageWithHeader, + registerTool, + ToolBindingWidget, + type ToolActivation, +} from "#src/ui/tool.js"; +import { vec3 } from "#src/util/geom.js"; import { EventActionMap } from "#src/util/mouse_bindings.js"; import { startRelativeMouseDrag } from "#src/util/mouse_drag.js"; +import { WatchableVisibilityPriority } from "#src/visibility_priority/frontend.js"; import { - BrushShape, BRUSH_TOOL_ID, + BrushShape, FLOODFILL_TOOL_ID, SEG_PICKER_TOOL_ID, } from "#src/voxel_annotation/base.js"; -const VOX_TOOL_INPUT_MAP = EventActionMap.fromObject({ +const BRUSH_INPUT_MAP = EventActionMap.fromObject({ ["at:control+mousedown0"]: "paint-voxels", }); +const FLOOD_INPUT_MAP = EventActionMap.fromObject({ + ["at:control+mousedown0"]: "paint-voxels", +}); + +const CONTROLS_FOR_TOOL = new Map([ + [BRUSH_TOOL_ID, ["vox-brush-size", "vox-brush-shape"]], + [FLOODFILL_TOOL_ID, ["vox-flood-max-voxels"]], +]); + abstract class BaseVoxelTool extends LayerTool { protected latestMouseState: MouseSelectionState | null = null; private lastNormal: vec3 | undefined = undefined; @@ -99,8 +120,11 @@ abstract class BaseVoxelTool extends LayerTool { return out; } + abstract bindToolInput(activation: ToolActivation): void; + activate(activation: ToolActivation): void { - activation.bindInputEventMap(VOX_TOOL_INPUT_MAP); + this.showToolOptionsBar(activation); + this.bindToolInput(activation); activation.bindAction("paint-voxels", (event) => { event.stopPropagation(); @@ -119,6 +143,77 @@ abstract class BaseVoxelTool extends LayerTool { }); } + private showToolOptionsBar(activation: ToolActivation) { + const toolId = this.toJSON(); + const controlTypes = CONTROLS_FOR_TOOL.get(toolId); + + const { header, body } = + makeToolActivationStatusMessageWithHeader(activation); + header.textContent = `${this.layer.managedLayer.name} - ${this.description}`; + header.classList.add("neuroglancer-tool-activation-status-header"); + body.classList.add("neuroglancer-voxel-tool-options-body"); + + if (!controlTypes) return; + + const visibility = new WatchableVisibilityPriority( + WatchableVisibilityPriority.VISIBLE, + ); + + for (const type of controlTypes) { + const def = VOXEL_LAYER_CONTROLS.find( + (c) => c.toolJson && c.toolJson.type === type, + ); + if (!def) continue; + + const controlContainer = document.createElement("label"); + controlContainer.classList.add("neuroglancer-layer-control-container"); + controlContainer.addEventListener("mousedown", (event) => { + event.stopPropagation(); + }); + + const labelContainer = document.createElement("div"); + labelContainer.classList.add( + "neuroglancer-layer-control-label-container", + ); + controlContainer.appendChild(labelContainer); + + const label = document.createElement("div"); + label.classList.add("neuroglancer-layer-control-label"); + if (def.title) { + label.title = def.title; + } + labelContainer.appendChild(label); + + const labelTextContainer = document.createElement("div"); + labelTextContainer.classList.add( + "neuroglancer-layer-control-label-text-container", + ); + labelTextContainer.textContent = def.label; + label.appendChild(labelTextContainer); + + const { controlElement } = def.makeControl(this.layer, activation, { + labelContainer, + labelTextContainer, + display: this.layer.manager.root.display, + visibility, + }); + controlElement.classList.add("neuroglancer-layer-control-control"); + controlContainer.appendChild(controlElement); + + if (def.toolJson) { + const widget = new ToolBindingWidget( + this.layer.toolBinder, + def.toolJson, + controlContainer, + ); + activation.registerDisposer(widget); + label.prepend(widget.element); + } + + body.appendChild(controlContainer); + } + } + abstract activationCallback(activation: ToolActivation): void; abstract deactivationCallback(activation: ToolActivation): void; @@ -150,120 +245,6 @@ abstract class BaseVoxelTool extends LayerTool { } } -export function getActivePanel( - layer: UserLayerWithVoxelEditing, -): RenderedDataPanel | undefined { - let activePanel: RenderedDataPanel | undefined; - for (const panel of layer.manager.root.display.panels) { - if (panel instanceof RenderedDataPanel) { - if (panel.mouseX !== -1 && panel instanceof SliceViewPanel) { - activePanel = panel; - } else { - panel.clearOverlay(); - } - } - } - return activePanel; -} - -export function updateBrushOutline(layer: UserLayerWithVoxelEditing) { - const panel = getActivePanel(layer); - if (!panel || !(panel instanceof SliceViewPanel)) { - if (panel) panel.clearOverlay(); - return; - } - - const { projectionParameters } = panel.sliceView; - const { displayDimensionRenderInfo, viewMatrix } = projectionParameters.value; - const { canonicalVoxelFactors, displayRank } = displayDimensionRenderInfo; - - if (displayRank < 2) { - panel.clearOverlay(); - return; - } - - const radiusInVoxels = layer.voxBrushRadius.value; - - const n_canonical = - projectionParameters.value.viewportNormalInCanonicalCoordinates; - - const canonicalVoxelFactorsVec3 = vec3.fromValues( - canonicalVoxelFactors[0], - canonicalVoxelFactors[1], - canonicalVoxelFactors[2], - ); - - // Convert to voxel coordinates by dividing by canonical voxel factors. - const n_vox = vec3.create(); - vec3.divide(n_vox, n_canonical as vec3, canonicalVoxelFactorsVec3); - vec3.normalize(n_vox, n_vox); - - // Create an orthonormal basis for the plane in voxel coordinates - const u_vox = vec3.create(); - const tempVec = vec3.fromValues(1, 0, 0); - if (Math.abs(vec3.dot(n_vox, tempVec)) > 0.999) { - vec3.set(tempVec, 0, 1, 0); - } - vec3.cross(u_vox, n_vox, tempVec); - vec3.normalize(u_vox, u_vox); - - const v_vox = vec3.cross(vec3.create(), n_vox, u_vox); - - // Scale basis vectors by radius to get two orthogonal radius vectors of the brush circle - // in voxel coordinates. - vec3.scale(u_vox, u_vox, radiusInVoxels); - vec3.scale(v_vox, v_vox, radiusInVoxels); - - const u_cam = vec3.create(); - const v_cam = vec3.create(); - // The viewMatrix transforms from world/voxel space to camera space. - // We use a mat3 to only apply rotation and scaling, not translation. - const viewMatrix3 = mat3.fromMat4(mat3.create(), viewMatrix); - - // Transform voxel-space vectors directly to camera-space vectors. - // This avoids the double-scaling error. - vec3.transformMat3(u_cam, u_vox, viewMatrix3); - vec3.transformMat3(v_cam, v_vox, viewMatrix3); - - // The x, y components of these vectors are conjugate semi-diameters of the ellipse on screen. - const u_scr_x = u_cam[0]; - const u_scr_y = u_cam[1]; - const v_scr_x = v_cam[0]; - const v_scr_y = v_cam[1]; - - // From the conjugate semi-diameters, compute the ellipse parameters (radii and rotation). - // We analyze the quadratic form matrix Q = A * A^T where A = [[u_scr_x, v_scr_x], [u_scr_y, v_scr_y]]. - const Q11 = u_scr_x * u_scr_x + v_scr_x * v_scr_x; - const Q12 = u_scr_x * u_scr_y + v_scr_x * v_scr_y; - const Q22 = u_scr_y * u_scr_y + v_scr_y * v_scr_y; - - const trace = Q11 + Q22; - const det = Q11 * Q22 - Q12 * Q12; - - // Eigenvalues are roots of lambda^2 - trace*lambda + det = 0 - const D_sq = trace * trace - 4 * det; - const D = D_sq < 0 ? 0 : Math.sqrt(D_sq); - - const lambda1 = (trace + D) / 2; - const lambda2 = (trace - D) / 2; - - const radiusX = Math.sqrt(lambda1); - const radiusY = Math.sqrt(lambda2); - - // Eigenvector for lambda1 is proportional to [Q12, lambda1 - Q11] - const rotation = Math.atan2(lambda1 - Q11, Q12); - - panel.drawBrushCursor( - panel.mouseX, - panel.mouseY, - radiusX, - radiusY, - rotation, - "white", - layer.shouldErase(), - ); -} - export class VoxelBrushTool extends BaseVoxelTool { private isDrawing = false; private lastPoint: Int32Array | undefined; @@ -313,6 +294,10 @@ export class VoxelBrushTool extends BaseVoxelTool { return "Brush tool"; } + bindToolInput(activation: ToolActivation) { + activation.bindInputEventMap(BRUSH_INPUT_MAP); + } + private drawLoop = (): void => { if (!this.isDrawing) { this.animationFrameHandle = null; @@ -493,6 +478,10 @@ export class VoxelFloodFillTool extends BaseVoxelTool { } } + bindToolInput(activation: ToolActivation) { + activation.bindInputEventMap(FLOOD_INPUT_MAP); + } + deactivationCallback(_activation: ToolActivation): void { return; } diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index eed502f8ad..9d270d043d 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -3,16 +3,13 @@ ### priority - align brush cursor transform with brush transform -- add value-based erasing -- preview is missing when erasing ### later -- add preview for the undo/redo - ### questionable - add color feedback on the brush cursor +- add preview for the undo/redo - add support for volumes with rank different from 3 - add support to float32 dataset - add support to unaligned hierarchy (e.g. child chunks that may have multiple parents) From 52dd6421eb6d78e42a863b125db16d729cc4c1c6 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Fri, 12 Dec 2025 16:30:28 +0100 Subject: [PATCH 168/251] fix(voxel-annotation): ensure proper cleanup of optimistic render layer in the segmentation layer context --- src/layer/voxel_annotation/index.ts | 9 ++++++++- src/sliceview/volume/segmentation_renderlayer.ts | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index 6b14b80a64..351f731952 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -249,8 +249,15 @@ export class VoxelEditingContext disposed() { if (this._controller) this._controller.dispose(); - if (this.optimisticRenderLayer) + if (this.optimisticRenderLayer) { + if ( + this.primaryRenderLayer instanceof SegmentationRenderLayer && + this.optimisticRenderLayer instanceof SegmentationRenderLayer + ) { + this.primaryRenderLayer.setVoxelPreviewLayer(undefined); + } this.hostLayer.removeRenderLayer(this.optimisticRenderLayer); + } super.disposed(); } diff --git a/src/sliceview/volume/segmentation_renderlayer.ts b/src/sliceview/volume/segmentation_renderlayer.ts index 582046d5ce..d2dd6b5760 100644 --- a/src/sliceview/volume/segmentation_renderlayer.ts +++ b/src/sliceview/volume/segmentation_renderlayer.ts @@ -475,9 +475,9 @@ uint64_t getMappedObjectId(uint64_t value) { } super.endSlice(sliceView, shader, parameters); } - setVoxelPreviewLayer(layer: SegmentationRenderLayer) { + setVoxelPreviewLayer(layer: SegmentationRenderLayer | undefined) { this.voxelPreviewLayer = layer; - layer.isForOptimisticPreview.value = true; + if (layer) layer.isForOptimisticPreview.value = true; } draw(renderContext: SliceViewRenderContext) { From 4acff9eccab515623b45affe8a9c6626041d20f4 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Fri, 12 Dec 2025 16:40:26 +0100 Subject: [PATCH 169/251] fix(voxel-annotation): enforce the activation of single writable source at once since the current painting pipeline will only draw to one source --- src/layer/voxel_annotation/index.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index 351f731952..1df1973efe 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -579,6 +579,17 @@ export function UserLayerWithVoxelEditingMixin< renderlayer: SegmentationRenderLayer | ImageRenderLayer, writingEnabled: boolean = true, ): void { + if (writingEnabled) { + for (const [otherSubsource, _] of this.editingContexts) { + if ( + otherSubsource !== loadedSubsource && + otherSubsource.writingEnabled.value + ) { + otherSubsource.writingEnabled.value = false; + } + } + } + if (this.editingContexts.has(loadedSubsource)) return; const primarySource = loadedSubsource.subsourceEntry.subsource From 17ea6511f9919f099c93528339c0627191cba4d4 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 15 Dec 2025 11:03:29 +0100 Subject: [PATCH 170/251] fix(voxel-annotation): ensure brush outline/cursor always aligns with the painted area --- src/layer/voxel_annotation/controls.ts | 108 +++++++++++++++---------- src/ui/voxel_annotations.ts | 29 +++---- src/voxel_annotation/TODOs.md | 2 - 3 files changed, 78 insertions(+), 61 deletions(-) diff --git a/src/layer/voxel_annotation/controls.ts b/src/layer/voxel_annotation/controls.ts index 80e2e34d7f..2679c82ac4 100644 --- a/src/layer/voxel_annotation/controls.ts +++ b/src/layer/voxel_annotation/controls.ts @@ -16,9 +16,13 @@ import type { UserLayerConstructor } from "#src/layer/index.js"; import { LayerActionContext } from "#src/layer/index.js"; -import type { UserLayerWithVoxelEditing } from "#src/layer/voxel_annotation/index.js"; +import type { + UserLayerWithVoxelEditing, + VoxelEditingContext, +} from "#src/layer/voxel_annotation/index.js"; import { RenderedDataPanel } from "#src/rendered_data_panel.js"; import { SliceViewPanel } from "#src/sliceview/panel.js"; +import { StatusMessage } from "#src/status.js"; import { observeWatchable } from "#src/trackable_value.js"; import { mat3, vec3 } from "#src/util/geom.js"; import { @@ -49,7 +53,27 @@ export function getActivePanel( return activePanel; } +export function getEditingContext( + layer: UserLayerWithVoxelEditing, +): VoxelEditingContext | undefined { + const it = layer.editingContexts.values(); + let ctx: VoxelEditingContext; + while ((ctx = it.next().value) !== undefined) { + if (ctx.writingEnabled) return ctx; + } + return undefined; +} + export function updateBrushOutline(layer: UserLayerWithVoxelEditing) { + const context = getEditingContext(layer); + if (context === undefined) { + StatusMessage.showTemporaryMessage( + 'Voxel editing is not available. Please select a writable volume source in the "Source" tab.', + 5000, + ); + return; + } + const panel = getActivePanel(layer); if (!panel || !(panel instanceof SliceViewPanel)) { if (panel) panel.clearOverlay(); @@ -58,64 +82,69 @@ export function updateBrushOutline(layer: UserLayerWithVoxelEditing) { const { projectionParameters } = panel.sliceView; const { displayDimensionRenderInfo, viewMatrix } = projectionParameters.value; - const { canonicalVoxelFactors, displayRank } = displayDimensionRenderInfo; + const { displayRank } = displayDimensionRenderInfo; if (displayRank < 2) { panel.clearOverlay(); return; } - const radiusInVoxels = layer.voxBrushRadius.value; + const chunkTransform = context.getChunkTransform(); + if (!chunkTransform) { + panel.clearOverlay(); + return; + } + const { chunkToLayerTransform, layerRank } = chunkTransform; + const { globalToRenderLayerDimensions } = chunkTransform.modelTransform; + const stride = layerRank + 1; - const n_canonical = + const n_world = projectionParameters.value.viewportNormalInCanonicalCoordinates; + const n_chunk = context.transformGlobalToVoxelNormal(n_world); - const canonicalVoxelFactorsVec3 = vec3.fromValues( - canonicalVoxelFactors[0], - canonicalVoxelFactors[1], - canonicalVoxelFactors[2], - ); - - // Convert to voxel coordinates by dividing by canonical voxel factors. - const n_vox = vec3.create(); - vec3.divide(n_vox, n_canonical as vec3, canonicalVoxelFactorsVec3); - vec3.normalize(n_vox, n_vox); + // TODO: regroupe this with the getBasis of VoxToolBase + const u_chunk = vec3.create(); + const tempVec = + Math.abs(vec3.dot(n_chunk, vec3.fromValues(1, 0, 0))) < 0.9 + ? vec3.fromValues(1, 0, 0) + : vec3.fromValues(0, 1, 0); + vec3.cross(u_chunk, tempVec, n_chunk); + vec3.normalize(u_chunk, u_chunk); + const v_chunk = vec3.cross(vec3.create(), n_chunk, u_chunk); + vec3.normalize(v_chunk, v_chunk); - // Create an orthonormal basis for the plane in voxel coordinates - const u_vox = vec3.create(); - const tempVec = vec3.fromValues(1, 0, 0); - if (Math.abs(vec3.dot(n_vox, tempVec)) > 0.999) { - vec3.set(tempVec, 0, 1, 0); - } - vec3.cross(u_vox, n_vox, tempVec); - vec3.normalize(u_vox, u_vox); + const radius = layer.voxBrushRadius.value; + vec3.scale(u_chunk, u_chunk, radius); + vec3.scale(v_chunk, v_chunk, radius); - const v_vox = vec3.cross(vec3.create(), n_vox, u_vox); + const chunkToCam3 = mat3.create(); - // Scale basis vectors by radius to get two orthogonal radius vectors of the brush circle - // in voxel coordinates. - vec3.scale(u_vox, u_vox, radiusInVoxels); - vec3.scale(v_vox, v_vox, radiusInVoxels); + // manually creating chunkToCam3 matrix to avoid any unwanted scaling + for (let row = 0; row < 3; ++row) { + for (let col = 0; col < 3; ++col) { + let sum = 0; + for (let globalDim = 0; globalDim < 3; ++globalDim) { + const layerDim = globalToRenderLayerDimensions[globalDim]; + if (layerDim !== -1) { + const viewVal = viewMatrix[globalDim * 4 + row]; + const layerVal = chunkToLayerTransform[col * stride + layerDim]; + sum += viewVal * layerVal; + } + } + chunkToCam3[col * 3 + row] = sum; + } + } const u_cam = vec3.create(); const v_cam = vec3.create(); - // The viewMatrix transforms from world/voxel space to camera space. - // We use a mat3 to only apply rotation and scaling, not translation. - const viewMatrix3 = mat3.fromMat4(mat3.create(), viewMatrix); + vec3.transformMat3(u_cam, u_chunk, chunkToCam3); + vec3.transformMat3(v_cam, v_chunk, chunkToCam3); - // Transform voxel-space vectors directly to camera-space vectors. - // This avoids the double-scaling error. - vec3.transformMat3(u_cam, u_vox, viewMatrix3); - vec3.transformMat3(v_cam, v_vox, viewMatrix3); - - // The x, y components of these vectors are conjugate semi-diameters of the ellipse on screen. const u_scr_x = u_cam[0]; const u_scr_y = u_cam[1]; const v_scr_x = v_cam[0]; const v_scr_y = v_cam[1]; - // From the conjugate semi-diameters, compute the ellipse parameters (radii and rotation). - // We analyze the quadratic form matrix Q = A * A^T where A = [[u_scr_x, v_scr_x], [u_scr_y, v_scr_y]]. const Q11 = u_scr_x * u_scr_x + v_scr_x * v_scr_x; const Q12 = u_scr_x * u_scr_y + v_scr_x * v_scr_y; const Q22 = u_scr_y * u_scr_y + v_scr_y * v_scr_y; @@ -123,7 +152,6 @@ export function updateBrushOutline(layer: UserLayerWithVoxelEditing) { const trace = Q11 + Q22; const det = Q11 * Q22 - Q12 * Q12; - // Eigenvalues are roots of lambda^2 - trace*lambda + det = 0 const D_sq = trace * trace - 4 * det; const D = D_sq < 0 ? 0 : Math.sqrt(D_sq); @@ -133,7 +161,6 @@ export function updateBrushOutline(layer: UserLayerWithVoxelEditing) { const radiusX = Math.sqrt(lambda1); const radiusY = Math.sqrt(lambda2); - // Eigenvector for lambda1 is proportional to [Q12, lambda1 - Q11] const rotation = Math.atan2(lambda1 - Q11, Q12); panel.drawBrushCursor( @@ -146,7 +173,6 @@ export function updateBrushOutline(layer: UserLayerWithVoxelEditing) { layer.shouldErase(), ); } - export type VoxelTabElement = | { type: "header"; label: string } | { type: "tool-row"; tools: { toolId: string; label: string }[] } diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index f82fbe9d41..abbe2bb20c 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -19,13 +19,11 @@ import "#src/ui/voxel_annotations.css"; import type { MouseSelectionState } from "#src/layer/index.js"; import { getActivePanel, + getEditingContext, updateBrushOutline, VOXEL_LAYER_CONTROLS, } from "#src/layer/voxel_annotation/controls.js"; -import type { - UserLayerWithVoxelEditing, - VoxelEditingContext, -} from "#src/layer/voxel_annotation/index.js"; +import type { UserLayerWithVoxelEditing } from "#src/layer/voxel_annotation/index.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; import { StatusMessage } from "#src/status.js"; import { @@ -63,17 +61,8 @@ abstract class BaseVoxelTool extends LayerTool { protected latestMouseState: MouseSelectionState | null = null; private lastNormal: vec3 | undefined = undefined; - protected getEditingContext(): VoxelEditingContext | undefined { - const it = this.layer.editingContexts.values(); - let ctx: VoxelEditingContext; - while ((ctx = it.next().value) !== undefined) { - if (ctx.writingEnabled) return ctx; - } - return undefined; - } - protected getPoint(mouseState: MouseSelectionState): Int32Array | undefined { - const editContext = this.getEditingContext(); + const editContext = getEditingContext(this.layer); if (editContext === undefined) return undefined; const vox = editContext.getVoxelPositionFromMouse(mouseState) as | Float32Array @@ -231,7 +220,11 @@ abstract class BaseVoxelTool extends LayerTool { protected getBasis() { const n = this.lastNormal; - if (!n) return undefined; // Should never happen as getPoint is always called before... this is not clean + if (!n) { + // Should never happen as getPoint is always called before... this could be cleaner + console.error("getBasis: Unexpected behavior: lastNormal is undefined"); + return undefined; + } const u = vec3.create(); const tempVec = Math.abs(vec3.dot(n, vec3.fromValues(1, 0, 0))) < 0.9 @@ -267,7 +260,7 @@ export class VoxelBrushTool extends BaseVoxelTool { } activationCallback(_activation: ToolActivation): void { - if (this.getEditingContext() === undefined) { + if (getEditingContext(this.layer) === undefined) { StatusMessage.showTemporaryMessage( 'Voxel editing is not available. Please select a writable volume source in the "Source" tab.', 5000, @@ -368,7 +361,7 @@ export class VoxelBrushTool extends BaseVoxelTool { 1, Math.floor(this.layer.voxBrushRadius.value ?? 3), ); - const editContext = this.getEditingContext(); + const editContext = getEditingContext(this.layer); if (editContext === undefined) { throw new Error("editContext is undefined"); } @@ -434,7 +427,7 @@ export class VoxelFloodFillTool extends BaseVoxelTool { } activationCallback(_activation: ToolActivation): void { - const editContext = this.getEditingContext(); + const editContext = getEditingContext(this.layer); if (editContext === undefined) { StatusMessage.showTemporaryMessage( 'Voxel editing is not available. Please select a writable volume source in the "Source" tab.', diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 9d270d043d..1de8019421 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -2,8 +2,6 @@ ### priority -- align brush cursor transform with brush transform - ### later ### questionable From 243715d5a3ef0638be5dcfc1cfe22d7b0fa9b82b Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 15 Dec 2025 12:42:26 +0100 Subject: [PATCH 171/251] refactor(voxel-annotation): extract `getBasisFromNormal` --- src/layer/voxel_annotation/controls.ts | 12 ++---------- src/ui/voxel_annotations.ts | 13 ++----------- src/voxel_annotation/base.ts | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 21 deletions(-) diff --git a/src/layer/voxel_annotation/controls.ts b/src/layer/voxel_annotation/controls.ts index 2679c82ac4..bcff327091 100644 --- a/src/layer/voxel_annotation/controls.ts +++ b/src/layer/voxel_annotation/controls.ts @@ -28,6 +28,7 @@ import { mat3, vec3 } from "#src/util/geom.js"; import { BRUSH_TOOL_ID, FLOODFILL_TOOL_ID, + getBasisFromNormal, SEG_PICKER_TOOL_ID, } from "#src/voxel_annotation/base.js"; import type { LayerControlDefinition } from "#src/widget/layer_control.js"; @@ -102,16 +103,7 @@ export function updateBrushOutline(layer: UserLayerWithVoxelEditing) { projectionParameters.value.viewportNormalInCanonicalCoordinates; const n_chunk = context.transformGlobalToVoxelNormal(n_world); - // TODO: regroupe this with the getBasis of VoxToolBase - const u_chunk = vec3.create(); - const tempVec = - Math.abs(vec3.dot(n_chunk, vec3.fromValues(1, 0, 0))) < 0.9 - ? vec3.fromValues(1, 0, 0) - : vec3.fromValues(0, 1, 0); - vec3.cross(u_chunk, tempVec, n_chunk); - vec3.normalize(u_chunk, u_chunk); - const v_chunk = vec3.cross(vec3.create(), n_chunk, u_chunk); - vec3.normalize(v_chunk, v_chunk); + const { u: u_chunk, v: v_chunk } = getBasisFromNormal(n_chunk); const radius = layer.voxBrushRadius.value; vec3.scale(u_chunk, u_chunk, radius); diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index abbe2bb20c..a58abc91b1 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -41,6 +41,7 @@ import { BRUSH_TOOL_ID, BrushShape, FLOODFILL_TOOL_ID, + getBasisFromNormal, SEG_PICKER_TOOL_ID, } from "#src/voxel_annotation/base.js"; @@ -221,20 +222,10 @@ abstract class BaseVoxelTool extends LayerTool { protected getBasis() { const n = this.lastNormal; if (!n) { - // Should never happen as getPoint is always called before... this could be cleaner console.error("getBasis: Unexpected behavior: lastNormal is undefined"); return undefined; } - const u = vec3.create(); - const tempVec = - Math.abs(vec3.dot(n, vec3.fromValues(1, 0, 0))) < 0.9 - ? vec3.fromValues(1, 0, 0) - : vec3.fromValues(0, 1, 0); - vec3.cross(u, tempVec, n); - vec3.normalize(u, u); - const v = vec3.cross(vec3.create(), n, u); - vec3.normalize(v, v); - return { u, v }; + return getBasisFromNormal(n); } } diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index 9201078043..0ad9b55105 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -15,6 +15,7 @@ */ import type { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; +import { vec3 } from "#src/util/geom.js"; import type { VoxelPreviewMultiscaleSource } from "#src/voxel_annotation/PreviewMultiscaleChunkSource.js"; import type { RPC } from "#src/worker_rpc.js"; @@ -82,6 +83,19 @@ export function parseVoxChunkKey(key: string) { }; } +export function getBasisFromNormal(n: vec3) { + const u = vec3.create(); + const tempVec = + Math.abs(vec3.dot(n, vec3.fromValues(1, 0, 0))) < 0.9 + ? vec3.fromValues(1, 0, 0) + : vec3.fromValues(0, 1, 0); + vec3.cross(u, tempVec, n); + vec3.normalize(u, u); + const v = vec3.cross(vec3.create(), n, u); + vec3.normalize(v, v); + return { u, v }; +} + export enum BrushShape { DISK = 0, SPHERE = 1, From f33e54aac5ae8b740f7cade45a051a97d39021d0 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 15 Dec 2025 14:40:51 +0100 Subject: [PATCH 172/251] feat(voxel-annotation): implement a gap filling painting postprocessing for non-axis-aligned slices --- src/voxel_annotation/frontend.ts | 96 +++++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 2 deletions(-) diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 4e045c5a91..3d94e68dc4 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -46,6 +46,16 @@ import { SharedObject, } from "#src/worker_rpc.js"; +const OFFSETS_26_CONNECTED: number[][] = []; +for (let z = -1; z <= 1; z++) { + for (let y = -1; y <= 1; y++) { + for (let x = -1; x <= 1; x++) { + if (x === 0 && y === 0 && z === 0) continue; + OFFSETS_26_CONNECTED.push([x, y, z]); + } + } +} + @registerSharedObjectOwner(VOX_EDIT_BACKEND_RPC_ID) export class VoxelEditController extends SharedObject { public undoCount = new WatchableValue(0); @@ -176,6 +186,68 @@ export class VoxelEditController extends SharedObject { }; } + // when painting in not axis-aligned slices, flood fill and brush algorithms will leave gaps; this function fills them. + private fillPlaneAliasingGaps( + voxels: Float32Array[], + basis: { u: Float32Array; v: Float32Array }, + center: Float32Array, + ): Float32Array[] { + const u = basis.u as vec3; + const v = basis.v as vec3; + const normal = vec3.create(); + vec3.cross(normal, u, v); + vec3.normalize(normal, normal); + + // skip if we are axis aligned + const SKIP_THRESHOLD = 0.99; + if ( + Math.abs(normal[0]) > SKIP_THRESHOLD || + Math.abs(normal[1]) > SKIP_THRESHOLD || + Math.abs(normal[2]) > SKIP_THRESHOLD + ) { + return voxels; + } + + const d = -vec3.dot(normal, center as vec3); + + const voxelSet = new Set(); + const output = [...voxels]; + for (const v of voxels) { + voxelSet.add( + `${Math.round(v[0])},${Math.round(v[1])},${Math.round(v[2])}`, + ); + } + + const DISTANCE_THRESHOLD = + Math.abs(normal[0]) + Math.abs(normal[1]) + Math.abs(normal[2]) + 1e-5; + + for (const p of voxels) { + const px = Math.round(p[0]); + const py = Math.round(p[1]); + const pz = Math.round(p[2]); + + for (const [ox, oy, oz] of OFFSETS_26_CONNECTED) { + const nx = px + ox; + const ny = py + oy; + const nz = pz + oz; + const key = `${nx},${ny},${nz}`; + + if (voxelSet.has(key)) continue; + + const dist = Math.abs( + normal[0] * nx + normal[1] * ny + normal[2] * nz + d, + ); + + if (dist <= DISTANCE_THRESHOLD) { + voxelSet.add(key); + const newVoxel = new Float32Array([nx, ny, nz]); + output.push(newVoxel); + } + } + } + return output; + } + private getEnsuredValueBuilder = (previewSource: VolumeChunkSource, primarySource: VolumeChunkSource) => async (voxelCoord: Float32Array): Promise => { @@ -198,8 +270,16 @@ export class VoxelEditController extends SharedObject { voxelsToPaint: Float32Array[], valueGetter: VoxelValueGetter, lodIndex: number, + basis?: { u: Float32Array; v: Float32Array }, + center?: Float32Array, ) => { const indicesByVoxKey = new Map(); + if (basis && center) + voxelsToPaint = this.fillPlaneAliasingGaps( + voxelsToPaint, + basis, + center, + ); for (const voxelCoord of voxelsToPaint) { const { chunkGridPosition, positionWithinChunk } = @@ -324,7 +404,13 @@ export class VoxelEditController extends SharedObject { } } if (!voxelsToPaint || voxelsToPaint.length === 0) return; - processEdits(voxelsToPaint, valueGetter, sourceIndex); + processEdits( + voxelsToPaint, + valueGetter, + sourceIndex, + basis, + centerCanonical, + ); } commitEdits( @@ -529,7 +615,13 @@ export class VoxelEditController extends SharedObject { } } - const edits = processEdits(voxelsToFill, fillValueGetter, sourceIndex); + const edits = processEdits( + voxelsToFill, + fillValueGetter, + sourceIndex, + basis, + startPositionCanonical, + ); return { edits, filledCount, From e920a98c4bf6ed237acb8de20fd1d4fa48eeaa9c Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Tue, 16 Dec 2025 10:48:02 +0100 Subject: [PATCH 173/251] fix(voxel-annotation): made the settings in the bottom bar of the painting tools non-draggable --- src/ui/voxel_annotations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index a58abc91b1..ec714ca09e 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -194,7 +194,7 @@ abstract class BaseVoxelTool extends LayerTool { const widget = new ToolBindingWidget( this.layer.toolBinder, def.toolJson, - controlContainer, + undefined, ); activation.registerDisposer(widget); label.prepend(widget.element); From fa11f98fcf48a3c4fff0d1ad42900af716a81a8c Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Tue, 16 Dec 2025 15:23:53 +0100 Subject: [PATCH 174/251] refactor(voxel-annotation): move erase mode to contole+shift+mousedown0 ; rename voxel editing properties --- src/layer/voxel_annotation/controls.ts | 19 +++---- src/layer/voxel_annotation/index.ts | 72 ++++++++++---------------- src/ui/voxel_annotations.ts | 31 ++++++----- 3 files changed, 52 insertions(+), 70 deletions(-) diff --git a/src/layer/voxel_annotation/controls.ts b/src/layer/voxel_annotation/controls.ts index bcff327091..e7b3e8f2f7 100644 --- a/src/layer/voxel_annotation/controls.ts +++ b/src/layer/voxel_annotation/controls.ts @@ -105,7 +105,7 @@ export function updateBrushOutline(layer: UserLayerWithVoxelEditing) { const { u: u_chunk, v: v_chunk } = getBasisFromNormal(n_chunk); - const radius = layer.voxBrushRadius.value; + const radius = layer.brushRadius.value; vec3.scale(u_chunk, u_chunk, radius); vec3.scale(v_chunk, v_chunk, radius); @@ -178,7 +178,7 @@ const TOOL_SPECIFIC_CONTROLS: LayerControlDefinition[ ...(() => { const control = rangeLayerControl( (layer: UserLayerWithVoxelEditing) => ({ - value: layer.voxBrushRadius, + value: layer.brushRadius, options: { min: 1, max: 64, step: 1 }, }), ); @@ -200,7 +200,7 @@ const TOOL_SPECIFIC_CONTROLS: LayerControlDefinition[ ), ); activation.registerDisposer( - layer.voxBrushRadius.changed.add(updateCursor), + layer.brushRadius.changed.add(updateCursor), ); activation.registerDisposer(() => { getActivePanel(layer)?.clearOverlay(); @@ -213,14 +213,14 @@ const TOOL_SPECIFIC_CONTROLS: LayerControlDefinition[ label: "Brush shape", toolJson: { type: "vox-brush-shape" }, ...enumLayerControl( - (layer: UserLayerWithVoxelEditing) => layer.voxBrushShape, + (layer: UserLayerWithVoxelEditing) => layer.brushShape, ), }, { label: "Max fill voxels", toolJson: { type: "vox-flood-max-voxels" }, ...rangeLayerControl((layer) => ({ - value: layer.voxFloodMaxVoxels, + value: layer.floodMaxVoxels, options: { min: 1, max: 1000000, step: 1000 }, })), }, @@ -229,14 +229,9 @@ const TOOL_SPECIFIC_CONTROLS: LayerControlDefinition[ const COMMON_CONTROLS: VoxelTabElement[] = [ { type: "header", label: "Settings" }, { - label: "Eraser (selected value)", - toolJson: { type: "vox-erase-selected-mode" }, - ...checkboxLayerControl((layer) => layer.voxEraseSelectedMode), - }, - { - label: "Eraser (everything)", + label: "Erase only selected value", toolJson: { type: "vox-erase-mode" }, - ...checkboxLayerControl((layer) => layer.voxEraseMode), + ...checkboxLayerControl((layer) => layer.lockToSelectedValue), }, { type: "header", label: "Actions" }, { diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index 1df1973efe..2d0f78af0d 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -65,7 +65,6 @@ import { BrushShape } from "#src/voxel_annotation/base.js"; import { VoxelEditController } from "#src/voxel_annotation/frontend.js"; const BRUSH_SIZE_JSON_KEY = "brushSize"; -const ERASE_MODE_JSON_KEY = "eraseMode"; const ERASE_SELECTED_MODE_JSON_KEY = "eraseSelectedMode"; const BRUSH_SHAPE_JSON_KEY = "brushShape"; const FLOOD_FILL_MAX_VOXELS_JSON_KEY = "floodFillMaxVoxels"; @@ -397,11 +396,10 @@ export class VoxelEditingContext export declare abstract class UserLayerWithVoxelEditing extends UserLayer { hasSubsourcesWithWritingEnabled: WatchableValue; - voxBrushRadius: TrackableValue; - voxEraseMode: TrackableBoolean; - voxEraseSelectedMode: TrackableBoolean; - voxBrushShape: TrackableEnum; - voxFloodMaxVoxels: TrackableValue; + brushRadius: TrackableValue; + lockToSelectedValue: TrackableBoolean; + brushShape: TrackableEnum; + floodMaxVoxels: TrackableValue; paintValue: TrackableValue; editingContexts: Map; @@ -412,6 +410,7 @@ export declare abstract class UserLayerWithVoxelEditing extends UserLayer { ): ImageRenderLayer | SegmentationRenderLayer; abstract getVoxelPaintValue(erase: boolean): VoxelValueGetter; abstract setVoxelPaintValue(value: any): bigint; + setEraseState(erase: boolean): void; shouldErase(): boolean; initializeVoxelEditingForSubsource( @@ -435,11 +434,12 @@ export function UserLayerWithVoxelEditingMixin< paintValue = new TrackableValue(1n, (x) => parseUint64(x)); // Brush properties - voxBrushRadius = new TrackableValue(3, verifyInt); - voxEraseMode = new TrackableBoolean(false); - voxEraseSelectedMode = new TrackableBoolean(false); - voxBrushShape = new TrackableEnum(BrushShape, BrushShape.DISK); - voxFloodMaxVoxels = new TrackableValue(10000, verifyFiniteFloat); + brushRadius = new TrackableValue(3, verifyInt); + lockToSelectedValue = new TrackableBoolean(false); + brushShape = new TrackableEnum(BrushShape, BrushShape.DISK); + floodMaxVoxels = new TrackableValue(10000, verifyFiniteFloat); + + private _isInEraseState = false; constructor(...args: any[]) { super(...args); @@ -449,28 +449,12 @@ export function UserLayerWithVoxelEditingMixin< } this.editingContexts.clear(); }); - this.voxBrushRadius.changed.add(this.specificationChanged.dispatch); - this.voxEraseMode.changed.add(this.specificationChanged.dispatch); - this.voxEraseSelectedMode.changed.add(this.specificationChanged.dispatch); - this.voxBrushShape.changed.add(this.specificationChanged.dispatch); - this.voxFloodMaxVoxels.changed.add(this.specificationChanged.dispatch); + this.brushRadius.changed.add(this.specificationChanged.dispatch); + this.lockToSelectedValue.changed.add(this.specificationChanged.dispatch); + this.brushShape.changed.add(this.specificationChanged.dispatch); + this.floodMaxVoxels.changed.add(this.specificationChanged.dispatch); this.paintValue.changed.add(this.specificationChanged.dispatch); - this.registerDisposer( - this.voxEraseMode.changed.add(() => { - if (this.voxEraseMode.value) { - this.voxEraseSelectedMode.value = false; - } - }), - ); - this.registerDisposer( - this.voxEraseSelectedMode.changed.add(() => { - if (this.voxEraseSelectedMode.value) { - this.voxEraseMode.value = false; - } - }), - ); - this.tabs.add("Draw", { label: "Draw", order: 20, @@ -482,17 +466,20 @@ export function UserLayerWithVoxelEditingMixin< }); } + setEraseState(erase: boolean): void { + this._isInEraseState = erase; + } + shouldErase(): boolean { - return this.voxEraseMode.value || this.voxEraseSelectedMode.value; + return this._isInEraseState; } toJSON() { const json = super.toJSON(); - json[BRUSH_SIZE_JSON_KEY] = this.voxBrushRadius.toJSON(); - json[ERASE_MODE_JSON_KEY] = this.voxEraseMode.toJSON(); - json[ERASE_SELECTED_MODE_JSON_KEY] = this.voxEraseSelectedMode.toJSON(); - json[BRUSH_SHAPE_JSON_KEY] = this.voxBrushShape.toJSON(); - json[FLOOD_FILL_MAX_VOXELS_JSON_KEY] = this.voxFloodMaxVoxels.toJSON(); + json[BRUSH_SIZE_JSON_KEY] = this.brushRadius.toJSON(); + json[ERASE_SELECTED_MODE_JSON_KEY] = this.lockToSelectedValue.toJSON(); + json[BRUSH_SHAPE_JSON_KEY] = this.brushShape.toJSON(); + json[FLOOD_FILL_MAX_VOXELS_JSON_KEY] = this.floodMaxVoxels.toJSON(); const pv = this.paintValue.toJSON(); json[PAINT_VALUE_JSON_KEY] = pv === undefined ? undefined : pv.toString(); return json; @@ -501,23 +488,20 @@ export function UserLayerWithVoxelEditingMixin< restoreState(specification: any) { super.restoreState(specification); verifyOptionalObjectProperty(specification, BRUSH_SIZE_JSON_KEY, (v) => - this.voxBrushRadius.restoreState(v), - ); - verifyOptionalObjectProperty(specification, ERASE_MODE_JSON_KEY, (v) => - this.voxEraseMode.restoreState(v), + this.brushRadius.restoreState(v), ); verifyOptionalObjectProperty( specification, ERASE_SELECTED_MODE_JSON_KEY, - (v) => this.voxEraseSelectedMode.restoreState(v), + (v) => this.lockToSelectedValue.restoreState(v), ); verifyOptionalObjectProperty(specification, BRUSH_SHAPE_JSON_KEY, (v) => - this.voxBrushShape.restoreState(v), + this.brushShape.restoreState(v), ); verifyOptionalObjectProperty( specification, FLOOD_FILL_MAX_VOXELS_JSON_KEY, - (v) => this.voxFloodMaxVoxels.restoreState(v), + (v) => this.floodMaxVoxels.restoreState(v), ); verifyOptionalObjectProperty(specification, PAINT_VALUE_JSON_KEY, (v) => this.paintValue.restoreState(v), diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index ec714ca09e..29e1973f21 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -46,11 +46,11 @@ import { } from "#src/voxel_annotation/base.js"; const BRUSH_INPUT_MAP = EventActionMap.fromObject({ - ["at:control+mousedown0"]: "paint-voxels", + ["at:control+shift?+mousedown0"]: "paint-voxels", }); const FLOOD_INPUT_MAP = EventActionMap.fromObject({ - ["at:control+mousedown0"]: "paint-voxels", + ["at:control+shift?+mousedown0"]: "paint-voxels", }); const CONTROLS_FOR_TOOL = new Map([ @@ -118,6 +118,9 @@ abstract class BaseVoxelTool extends LayerTool { activation.bindAction("paint-voxels", (event) => { event.stopPropagation(); + if ((event.detail as MouseEvent).shiftKey) { + this.layer.setEraseState(true); + } this.activationCallback(activation); startRelativeMouseDrag( event.detail as MouseEvent, @@ -126,6 +129,7 @@ abstract class BaseVoxelTool extends LayerTool { }, () => { this.deactivationCallback(activation); + this.layer.setEraseState(false); }, ); @@ -348,24 +352,22 @@ export class VoxelBrushTool extends BaseVoxelTool { } private paintPoints(points: Float32Array[]) { - const radius = Math.max( - 1, - Math.floor(this.layer.voxBrushRadius.value ?? 3), - ); + const radius = Math.max(1, Math.floor(this.layer.brushRadius.value ?? 3)); const editContext = getEditingContext(this.layer); if (editContext === undefined) { throw new Error("editContext is undefined"); } - const shapeEnum = this.layer.voxBrushShape.value; + const shapeEnum = this.layer.brushShape.value; let basis: undefined | { u: Float32Array; v: Float32Array } = undefined; if (shapeEnum === BrushShape.DISK) { basis = this.getBasis(); } const value = this.layer.getVoxelPaintValue(this.layer.shouldErase()); - const filterValue = this.layer.voxEraseSelectedMode.value - ? this.layer.getVoxelPaintValue(false)(false) - : undefined; + const filterValue = + this.layer.lockToSelectedValue.value && this.layer.shouldErase() + ? this.layer.getVoxelPaintValue(false)(false) + : undefined; for (const p of points) { void editContext.paintBrushWithShape( @@ -437,14 +439,15 @@ export class VoxelFloodFillTool extends BaseVoxelTool { } try { const value = this.layer.getVoxelPaintValue(this.layer.shouldErase()); - const max = Number(this.layer.voxFloodMaxVoxels.value); + const max = Number(this.layer.floodMaxVoxels.value); if (!Number.isFinite(max) || max <= 0) { throw new Error("Invalid max fill voxels setting"); } - const filterValue = this.layer.voxEraseSelectedMode.value - ? this.layer.getVoxelPaintValue(false)(false) - : undefined; + const filterValue = + this.layer.lockToSelectedValue.value && this.layer.shouldErase() + ? this.layer.getVoxelPaintValue(false)(false) + : undefined; void editContext .floodFillPlane2D( From 72b326cee056e8fdb941aabb53225228d2bccc92 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Tue, 16 Dec 2025 15:32:00 +0100 Subject: [PATCH 175/251] refactor(voxel-annotation): refactor erase action binding to ensure its keybind is displayed to the user --- src/ui/voxel_annotations.ts | 44 ++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 29e1973f21..65d52f2890 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -34,6 +34,7 @@ import { type ToolActivation, } from "#src/ui/tool.js"; import { vec3 } from "#src/util/geom.js"; +import type { ActionEvent } from "#src/util/mouse_bindings.js"; import { EventActionMap } from "#src/util/mouse_bindings.js"; import { startRelativeMouseDrag } from "#src/util/mouse_drag.js"; import { WatchableVisibilityPriority } from "#src/visibility_priority/frontend.js"; @@ -46,11 +47,13 @@ import { } from "#src/voxel_annotation/base.js"; const BRUSH_INPUT_MAP = EventActionMap.fromObject({ - ["at:control+shift?+mousedown0"]: "paint-voxels", + ["at:control+mousedown0"]: "paint-voxels", + ["at:control+shift+mousedown0"]: "erase-voxels", }); const FLOOD_INPUT_MAP = EventActionMap.fromObject({ - ["at:control+shift?+mousedown0"]: "paint-voxels", + ["at:control+mousedown0"]: "paint-voxels", + ["at:control+shift+mousedown0"]: "erase-voxels", }); const CONTROLS_FOR_TOOL = new Map([ @@ -116,25 +119,26 @@ abstract class BaseVoxelTool extends LayerTool { this.showToolOptionsBar(activation); this.bindToolInput(activation); - activation.bindAction("paint-voxels", (event) => { - event.stopPropagation(); - if ((event.detail as MouseEvent).shiftKey) { - this.layer.setEraseState(true); - } - this.activationCallback(activation); - startRelativeMouseDrag( - event.detail as MouseEvent, - () => { - this.latestMouseState = this.mouseState; - }, - () => { - this.deactivationCallback(activation); - this.layer.setEraseState(false); - }, - ); + const paintCallback = + (erasing: boolean) => (event: ActionEvent) => { + event.stopPropagation(); + this.layer.setEraseState(erasing); + this.activationCallback(activation); + startRelativeMouseDrag( + event.detail as MouseEvent, + () => { + this.latestMouseState = this.mouseState; + }, + () => { + this.deactivationCallback(activation); + this.layer.setEraseState(false); + }, + ); + return true; + }; - return true; - }); + activation.bindAction("paint-voxels", paintCallback(false)); + activation.bindAction("erase-voxels", paintCallback(true)); } private showToolOptionsBar(activation: ToolActivation) { From 9bf17dba3ae930bf3b991839f02e770cff439000 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Tue, 16 Dec 2025 17:57:24 +0100 Subject: [PATCH 176/251] feat(voxel-annotation): made painting tool cursor show erase mode when controle+shift is pressed --- src/layer/voxel_annotation/controls.ts | 9 ++++++--- src/ui/voxel_annotations.ts | 27 ++++++++++++++++++++++---- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/layer/voxel_annotation/controls.ts b/src/layer/voxel_annotation/controls.ts index e7b3e8f2f7..c379da70ab 100644 --- a/src/layer/voxel_annotation/controls.ts +++ b/src/layer/voxel_annotation/controls.ts @@ -65,7 +65,10 @@ export function getEditingContext( return undefined; } -export function updateBrushOutline(layer: UserLayerWithVoxelEditing) { +export function updateBrushOutline( + layer: UserLayerWithVoxelEditing, + eraseMode: boolean, +) { const context = getEditingContext(layer); if (context === undefined) { StatusMessage.showTemporaryMessage( @@ -162,7 +165,7 @@ export function updateBrushOutline(layer: UserLayerWithVoxelEditing) { radiusY, rotation, "white", - layer.shouldErase(), + eraseMode, ); } export type VoxelTabElement = @@ -190,7 +193,7 @@ const TOOL_SPECIFIC_CONTROLS: LayerControlDefinition[ const layer = activation.tool.layer as UserLayerWithVoxelEditing; const updateCursor = () => { - updateBrushOutline(layer); + updateBrushOutline(layer, layer.shouldErase()); }; updateCursor(); diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 65d52f2890..035a5fd979 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -26,6 +26,7 @@ import { import type { UserLayerWithVoxelEditing } from "#src/layer/voxel_annotation/index.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; import { StatusMessage } from "#src/status.js"; +import { TrackableBoolean } from "#src/trackable_boolean.js"; import { LayerTool, makeToolActivationStatusMessageWithHeader, @@ -64,6 +65,7 @@ const CONTROLS_FOR_TOOL = new Map([ abstract class BaseVoxelTool extends LayerTool { protected latestMouseState: MouseSelectionState | null = null; private lastNormal: vec3 | undefined = undefined; + protected cursorEraseMode = new TrackableBoolean(false); protected getPoint(mouseState: MouseSelectionState): Int32Array | undefined { const editContext = getEditingContext(this.layer); @@ -119,6 +121,13 @@ abstract class BaseVoxelTool extends LayerTool { this.showToolOptionsBar(activation); this.bindToolInput(activation); + const updateCursorState = (e: KeyboardEvent | MouseEvent) => { + this.cursorEraseMode.value = e.ctrlKey && e.shiftKey; + }; + activation.registerEventListener(window, "keydown", updateCursorState); + activation.registerEventListener(window, "keyup", updateCursorState); + activation.registerEventListener(window, "mousemove", updateCursorState); + const paintCallback = (erasing: boolean) => (event: ActionEvent) => { event.stopPropagation(); @@ -245,15 +254,20 @@ export class VoxelBrushTool extends BaseVoxelTool { activate(activation: ToolActivation) { super.activate(activation); - updateBrushOutline(this.layer); + updateBrushOutline(this.layer, this.cursorEraseMode.value); + activation.registerDisposer( + this.cursorEraseMode.changed.add(() => { + updateBrushOutline(this.layer, this.cursorEraseMode.value); + }), + ); activation.registerDisposer(() => { getActivePanel(this.layer)?.clearOverlay(); this.resetCursor(); }); activation.registerDisposer( this.mouseState.changed.add(() => { - updateBrushOutline(this.layer); + updateBrushOutline(this.layer, this.cursorEraseMode.value); }), ); } @@ -388,8 +402,8 @@ export class VoxelBrushTool extends BaseVoxelTool { export class VoxelFloodFillTool extends BaseVoxelTool { private getCursor() { - const lightColor = this.layer.shouldErase() ? "#FF8888" : "#FFFFFF"; - const darkColor = this.layer.shouldErase() ? "#610000" : "#000000"; + const lightColor = this.cursorEraseMode.value ? "#FF8888" : "#FFFFFF"; + const darkColor = this.cursorEraseMode.value ? "#610000" : "#000000"; const floodFillSVG = `) { super.activate(activation); this.setCursor(this.getCursor()); + activation.registerDisposer( + this.cursorEraseMode.changed.add(() => { + this.setCursor(this.getCursor()); + }), + ); activation.registerDisposer(() => { this.resetCursor(); }); From b9df814302437489faf2751ca3d9a2aa80053f1a Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 17 Dec 2025 13:56:07 +0100 Subject: [PATCH 177/251] fix(voxel-annotation): prevent activation of voxel tools when no writable source is selected --- src/ui/voxel_annotations.ts | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 035a5fd979..164fb2a14e 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -117,7 +117,15 @@ abstract class BaseVoxelTool extends LayerTool { abstract bindToolInput(activation: ToolActivation): void; - activate(activation: ToolActivation): void { + activate(activation: ToolActivation): boolean { + if (!this.layer.hasSubsourcesWithWritingEnabled.value) { + StatusMessage.showTemporaryMessage( + 'Voxel editing is not available. Please select a writable volume source in the "Source" tab.', + 5000, + ); + activation.cancel(); + return false; + } this.showToolOptionsBar(activation); this.bindToolInput(activation); @@ -148,6 +156,7 @@ abstract class BaseVoxelTool extends LayerTool { activation.bindAction("paint-voxels", paintCallback(false)); activation.bindAction("erase-voxels", paintCallback(true)); + return true; } private showToolOptionsBar(activation: ToolActivation) { @@ -252,8 +261,8 @@ export class VoxelBrushTool extends BaseVoxelTool { private mouseDisposer: (() => void) | undefined; private animationFrameHandle: number | null = null; - activate(activation: ToolActivation) { - super.activate(activation); + activate(activation: ToolActivation): boolean { + if (!super.activate(activation)) return false; updateBrushOutline(this.layer, this.cursorEraseMode.value); activation.registerDisposer( @@ -270,6 +279,7 @@ export class VoxelBrushTool extends BaseVoxelTool { updateBrushOutline(this.layer, this.cursorEraseMode.value); }), ); + return true; } activationCallback(_activation: ToolActivation): void { @@ -430,7 +440,7 @@ export class VoxelFloodFillTool extends BaseVoxelTool { } activate(activation: ToolActivation) { - super.activate(activation); + if (!super.activate(activation)) return false; this.setCursor(this.getCursor()); activation.registerDisposer( this.cursorEraseMode.changed.add(() => { @@ -440,6 +450,7 @@ export class VoxelFloodFillTool extends BaseVoxelTool { activation.registerDisposer(() => { this.resetCursor(); }); + return true; } activationCallback(_activation: ToolActivation): void { @@ -555,6 +566,14 @@ export class AdoptVoxelValueTool extends LayerTool { } activate(activation: ToolActivation): void { + if (!this.layer.hasSubsourcesWithWritingEnabled.value) { + StatusMessage.showTemporaryMessage( + 'Voxel editing is not available. Please select a writable volume source in the "Source" tab.', + 5000, + ); + activation.cancel(); + return; + } if (!this.mouseState?.active) return; this.setCursor(pickerCursor); activation.registerDisposer(() => { From 64a832363988add7791af2bd45163f2fbc868515 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 7 Jan 2026 10:29:15 +0100 Subject: [PATCH 178/251] refactor(kvstore): remove unused proxy write logic --- src/kvstore/proxy.ts | 43 -------------------------------- src/kvstore/shared_common.ts | 1 - src/voxel_annotation/frontend.ts | 3 ++- 3 files changed, 2 insertions(+), 45 deletions(-) diff --git a/src/kvstore/proxy.ts b/src/kvstore/proxy.ts index 9774ffd96f..f1a2f4bda3 100644 --- a/src/kvstore/proxy.ts +++ b/src/kvstore/proxy.ts @@ -30,7 +30,6 @@ import { READ_RPC_ID, STAT_RPC_ID, COMPLETE_URL_RPC_ID, - WRITE_RPC_ID, } from "#src/kvstore/shared_common.js"; import { finalPipelineUrlComponent, @@ -228,48 +227,6 @@ registerPromiseRPC( }, ); -export async function proxyWrite( - sharedKvStoreContext: SharedKvStoreContextBase, - url: string, - data: ArrayBuffer, -): Promise { - await sharedKvStoreContext.rpc!.promiseInvoke( - WRITE_RPC_ID, - { - sharedKvStoreContext: sharedKvStoreContext.rpcId, - url, - data, - }, - { transfers: [data] }, - ); -} - -registerPromiseRPC( - WRITE_RPC_ID, - async function ( - this: RPC, - options: { - sharedKvStoreContext: number; - url: string; - data: ArrayBuffer; - }, - ) { - const sharedKvStoreContext: SharedKvStoreContextBase = this.get( - options.sharedKvStoreContext, - ); - const { store, path } = sharedKvStoreContext.kvStoreContext.getKvStore( - options.url, - ); - if (store.write === undefined) { - throw new Error( - `The specified storage location is not writable: ${options.url}`, - ); - } - await store.write(path, options.data); - return { value: undefined }; - }, -); - export abstract class ProxyReadableKvStore { constructor(public sharedKvStoreContext: SharedKvStoreContextBase) {} diff --git a/src/kvstore/shared_common.ts b/src/kvstore/shared_common.ts index ae781510d1..fdf31f3480 100644 --- a/src/kvstore/shared_common.ts +++ b/src/kvstore/shared_common.ts @@ -19,5 +19,4 @@ export const SHARED_KVSTORE_CONTEXT_RPC_ID = "SharedKvStoreContext"; export const STAT_RPC_ID = "SharedKvStoreContext.stat"; export const READ_RPC_ID = "SharedKvStoreContext.read"; export const LIST_RPC_ID = "SharedKvStoreContext.list"; -export const WRITE_RPC_ID = "SharedKvStoreContext.write"; export const COMPLETE_URL_RPC_ID = "SharedKvStoreContext.completeUrl"; diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 3d94e68dc4..c039518345 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -143,10 +143,11 @@ export class VoxelEditController extends SharedObject { multiscale: MultiscaleVolumeChunkSource | undefined, lodIndex: number, ): VolumeChunkSource { - if (!multiscale) + if (!multiscale) { throw new Error( `VoxelEditController: Invalid multiscale object: ${multiscale}`, ); + } const sourcesByScale = multiscale.getSources( this.getIdentitySliceViewSourceOptions(), ); From ac372bf9026a84e2ee642503cc9e6e306d779a73 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 7 Jan 2026 17:55:04 +0100 Subject: [PATCH 179/251] refactor(voxel-annotation): move drawing logic to the backend - first iteration --- src/layer/voxel_annotation/index.ts | 5 +- src/sliceview/volume/base.ts | 16 + src/sliceview/volume/frontend.ts | 60 +-- src/ui/voxel_annotations.ts | 9 +- src/voxel_annotation/backend.ts | 508 +++++++++++++++++++++++++- src/voxel_annotation/base.ts | 32 ++ src/voxel_annotation/frontend.ts | 545 ++++------------------------ 7 files changed, 641 insertions(+), 534 deletions(-) diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index 2d0f78af0d..f41ba4069e 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -188,7 +188,7 @@ export class VoxelEditingContext radiusCanonical: number, value: VoxelValueGetter, shape: BrushShape, - basis?: { u: Float32Array; v: Float32Array }, + basis: { u: Float32Array; v: Float32Array }, filterValue?: bigint, ) { if (!this._controller) @@ -215,7 +215,7 @@ export class VoxelEditingContext if (!this._controller) throw new Error("Cannot use floodFillPlane2D without a controller"); if (await this.checkPermission()) { - return this._controller.floodFillPlane2D( + await this._controller.floodFillPlane2D( startPositionCanonical, fillValue, maxVoxels, @@ -223,7 +223,6 @@ export class VoxelEditingContext filterValue, ); } - return undefined; } async undo() { diff --git a/src/sliceview/volume/base.ts b/src/sliceview/volume/base.ts index f5c5e49b74..a272452d51 100644 --- a/src/sliceview/volume/base.ts +++ b/src/sliceview/volume/base.ts @@ -153,6 +153,22 @@ export function makeVolumeChunkSpecification( }; } +export function computeChunkGridPosition( + chunkGridPosition: Float32Array, + positionWithinChunk: Uint32Array, + voxelCoord: Float32Array, + chunkDataSize: Uint32Array | Float32Array, +) { + const rank = chunkGridPosition.length; + for (let i = 0; i < rank; ++i) { + const voxel = voxelCoord[i]; + const size = chunkDataSize[i]; + const chunkIndex = Math.floor(voxel / size); + chunkGridPosition[i] = chunkIndex; + positionWithinChunk[i] = Math.floor(voxel - size * chunkIndex); + } +} + function shouldTranscodeToCompressedSegmentation( options: VolumeChunkSpecificationDefaultCompressionOptions & VolumeChunkSpecificationOptions & diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index ce6a72efc8..382f4a4e02 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -19,7 +19,6 @@ import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transf import type { SliceViewChunkSpecification } from "#src/sliceview/base.js"; import { DataType, - SLICEVIEW_REQUEST_CHUNK_RPC_ID, } from "#src/sliceview/base.js"; import type { SliceViewChunk } from "#src/sliceview/frontend.js"; import { @@ -31,9 +30,10 @@ import type { VolumeChunkSource as VolumeChunkSourceInterface, VolumeChunkSpecification, VolumeSourceOptions, - VolumeType, -} from "#src/sliceview/volume/base.js"; -import { IN_MEMORY_VOLUME_CHUNK_SOURCE_RPC_ID } from "#src/sliceview/volume/base.js"; + VolumeType} from "#src/sliceview/volume/base.js"; +import { + computeChunkGridPosition +, IN_MEMORY_VOLUME_CHUNK_SOURCE_RPC_ID } from "#src/sliceview/volume/base.js"; import { VolumeChunk } from "#src/sliceview/volume/chunk.js"; import { getChunkFormatHandler } from "#src/sliceview/volume/registry.js"; import type { TypedArray } from "#src/util/array.js"; @@ -197,52 +197,20 @@ export class VolumeChunkSource return this.chunkFormatHandler.chunkFormat; } - async getEnsuredValueAt( - chunkPosition: Float32Array, - channelAccess: ChunkChannelAccessParameters, - ): Promise { - const initialValue = this.getValueAt(chunkPosition, channelAccess); - if (initialValue != null) { - return initialValue; - } - - const { chunkGridPosition } = this.computeChunkIndices(chunkPosition); - - try { - await this.rpc!.promiseInvoke(SLICEVIEW_REQUEST_CHUNK_RPC_ID, { - source: this.rpcId, - chunkGridPosition: chunkGridPosition, - }); - } catch (e) { - console.error( - `Failed to fetch chunk for position ${chunkPosition.join()}:`, - e, - ); - return null; - } - - return this.getValueAt(chunkPosition, channelAccess); - } - computeChunkIndices(voxelCoord: Float32Array): { chunkGridPosition: Float32Array; positionWithinChunk: Uint32Array; } { - const { spec } = this; - const { rank, chunkDataSize } = spec; - const chunkGridPosition = this.tempChunkGridPosition; - const positionWithinChunk = this.tempPositionWithinChunk; - - for (let chunkDim = 0; chunkDim < rank; ++chunkDim) { - const voxel = voxelCoord[chunkDim]; - const chunkSize = chunkDataSize[chunkDim]; - const chunkIndex = Math.floor(voxel / chunkSize); - chunkGridPosition[chunkDim] = chunkIndex; - positionWithinChunk[chunkDim] = Math.floor( - voxel - chunkSize * chunkIndex, - ); - } - return { chunkGridPosition, positionWithinChunk }; + computeChunkGridPosition( + this.tempChunkGridPosition, + this.tempPositionWithinChunk, + voxelCoord, + this.spec.chunkDataSize, + ); + return { + chunkGridPosition: this.tempChunkGridPosition, + positionWithinChunk: this.tempPositionWithinChunk, + }; } getValueAt( diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 164fb2a14e..a4bdadb9e6 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -41,7 +41,6 @@ import { startRelativeMouseDrag } from "#src/util/mouse_drag.js"; import { WatchableVisibilityPriority } from "#src/visibility_priority/frontend.js"; import { BRUSH_TOOL_ID, - BrushShape, FLOODFILL_TOOL_ID, getBasisFromNormal, SEG_PICKER_TOOL_ID, @@ -386,9 +385,9 @@ export class VoxelBrushTool extends BaseVoxelTool { throw new Error("editContext is undefined"); } const shapeEnum = this.layer.brushShape.value; - let basis: undefined | { u: Float32Array; v: Float32Array } = undefined; - if (shapeEnum === BrushShape.DISK) { - basis = this.getBasis(); + const basis = this.getBasis(); + if (!basis) { + throw new Error("basis is undefined"); } const value = this.layer.getVoxelPaintValue(this.layer.shouldErase()); @@ -615,7 +614,7 @@ export class AdoptVoxelValueTool extends LayerTool { this.layer.getIdentitySliceViewSourceOptions(), )[0][0]!.chunkSource; - const valueResult = await source.getEnsuredValueAt( + const valueResult = source.getValueAt( voxelCoord, this.singleChannelAccess, ); diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 2eecebb0c4..d98826f5c1 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -14,16 +14,24 @@ * limitations under the License. */ +import { ChunkState } from "#src/chunk_manager/base.js"; import { DataType } from "#src/sliceview/base.js"; import { decodeChannel as decodeChannelUint32 } from "#src/sliceview/compressed_segmentation/decode_uint32.js"; import { decodeChannel as decodeChannelUint64 } from "#src/sliceview/compressed_segmentation/decode_uint64.js"; -import type { VolumeChunkSource } from "#src/sliceview/volume/backend.js"; +import type { + VolumeChunk, + VolumeChunkSource, +} from "#src/sliceview/volume/backend.js"; +import { computeChunkGridPosition } from "#src/sliceview/volume/base.js"; import { mat4, vec3 } from "#src/util/geom.js"; import * as matrix from "#src/util/matrix.js"; import type { VoxelLayerResolution, EditAction, VoxelChange, + VoxelOperation, + BrushOperation, + FloodFillOperation, } from "#src/voxel_annotation/base.js"; import { VOX_EDIT_BACKEND_RPC_ID, @@ -33,6 +41,9 @@ import { VOX_EDIT_UNDO_RPC_ID, VOX_EDIT_REDO_RPC_ID, VOX_EDIT_HISTORY_UPDATE_RPC_ID, + VOX_EDIT_OPERATION_RPC_ID, + VoxelOperationType, + BrushShape, makeVoxChunkKey, parseVoxChunkKey, makeChunkKey, @@ -46,6 +57,166 @@ import { initializeSharedObjectCounterpart, } from "#src/worker_rpc.js"; +const OFFSETS_26_CONNECTED_BACKEND: number[][] = []; +for (let z = -1; z <= 1; z++) { + for (let y = -1; y <= 1; y++) { + for (let x = -1; x <= 1; x++) { + if (x === 0 && y === 0 && z === 0) continue; + OFFSETS_26_CONNECTED_BACKEND.push([x, y, z]); + } + } +} + + +class BackendVoxelAccessor { + private activeChunk: VolumeChunk | null = null; + private activeChunkData: Uint32Array | BigUint64Array | Uint8Array | Int8Array | Uint16Array | Int16Array | Int32Array | Float32Array | null = null; + private activeChunkGridKey: string | null = null; + + private activeBoundsMinX = 0; + private activeBoundsMaxX = 0; + private activeBoundsMinY = 0; + private activeBoundsMaxY = 0; + private activeBoundsMinZ = 0; + private activeBoundsMaxZ = 0; + + private activeStrideX = 1; + private activeStrideY = 1; + private activeStrideZ = 1; + + private fillValue: bigint; + + constructor(private source: VolumeChunkSource) { + const fv = source.spec.fillValue; + this.fillValue = typeof fv === 'bigint' ? fv : BigInt(fv); + } + + async getValue(x: number, y: number, z: number): Promise { + const { lowerVoxelBound, upperVoxelBound } = this.source.spec; + if ( + x < lowerVoxelBound[0] || x >= upperVoxelBound[0] || + y < lowerVoxelBound[1] || y >= upperVoxelBound[1] || + z < lowerVoxelBound[2] || z >= upperVoxelBound[2] + ) { + return null; + } + + if ( + this.activeChunk && + x >= this.activeBoundsMinX && x < this.activeBoundsMaxX && + y >= this.activeBoundsMinY && y < this.activeBoundsMaxY && + z >= this.activeBoundsMinZ && z < this.activeBoundsMaxZ + ) { + return this.readLocal(x, y, z); + } + + return this.loadChunk(x, y, z); + } + + private readLocal(x: number, y: number, z: number): bigint { + if (this.activeChunkData !== null) { + const lx = x - this.activeBoundsMinX; + const ly = y - this.activeBoundsMinY; + const lz = z - this.activeBoundsMinZ; + + const index = lz * this.activeStrideZ + ly * this.activeStrideY + lx * this.activeStrideX; + const val = this.activeChunkData[index]; + return typeof val === 'bigint' ? val : BigInt(val); + } + return this.fillValue; + } + + private async loadChunk(x: number, y: number, z: number): Promise { + const { chunkDataSize } = this.source.spec; + + const gx = Math.floor(x / chunkDataSize[0]); + const gy = Math.floor(y / chunkDataSize[1]); + const gz = Math.floor(z / chunkDataSize[2]); + const key = `${gx},${gy},${gz}`; + + if (this.activeChunkGridKey === key) { + return this.readLocal(x, y, z); + } + + const chunkGridPosition = new Float32Array([gx, gy, gz]); + + let chunk = this.source.chunks.get(key) as VolumeChunk | undefined; + if (!chunk) { + chunk = this.source.getChunk(chunkGridPosition) as VolumeChunk; + } + + if (!chunk.chunkDataSize) { + this.source.computeChunkBounds(chunk); + } + + if (chunk.state > ChunkState.SYSTEM_MEMORY_WORKER || !chunk.data) { + try { + await this.source.download(chunk, new AbortController().signal); + if (!chunk.chunkDataSize) this.source.computeChunkBounds(chunk); + } catch { + // Download failed + } + } + + this.activeChunk = chunk; + this.activeChunkGridKey = key; + + const size = chunk.chunkDataSize || chunkDataSize; + this.activeBoundsMinX = gx * chunkDataSize[0]; + this.activeBoundsMinY = gy * chunkDataSize[1]; + this.activeBoundsMinZ = gz * chunkDataSize[2]; + this.activeBoundsMaxX = this.activeBoundsMinX + size[0]; + this.activeBoundsMaxY = this.activeBoundsMinY + size[1]; + this.activeBoundsMaxZ = this.activeBoundsMinZ + size[2]; + + this.activeStrideX = 1; + this.activeStrideY = size[0]; + this.activeStrideZ = size[0] * size[1]; + + if (chunk.data) { + this.prepareData(chunk); + } else { + this.activeChunkData = null; + } + + if ( + x >= this.activeBoundsMinX && x < this.activeBoundsMaxX && + y >= this.activeBoundsMinY && y < this.activeBoundsMaxY && + z >= this.activeBoundsMinZ && z < this.activeBoundsMaxZ + ) { + return this.readLocal(x, y, z); + } + return null; + } + + private prepareData(chunk: VolumeChunk) { + const { spec } = this.source; + if (spec.compressedSegmentationBlockSize) { + const size = chunk.chunkDataSize!; + const numElements = size[0] * size[1] * size[2]; + const compressedData = chunk.data as Uint32Array; + const baseOffset = compressedData.length > 0 ? compressedData[0] : 0; + + const { dataType, compressedSegmentationBlockSize: subchunkSize } = spec; + + if (dataType === DataType.UINT32) { + const out = new Uint32Array(numElements); + if (baseOffset !== 0) { + decodeChannelUint32(out, compressedData, baseOffset, size, subchunkSize!); + } + this.activeChunkData = out; + } else { + const out = new BigUint64Array(numElements); + if (baseOffset !== 0) { + decodeChannelUint64(out, compressedData, baseOffset, size, subchunkSize!); + } + this.activeChunkData = out; + } + } else { + this.activeChunkData = chunk.data as any; + } + } +} @registerSharedObject(VOX_EDIT_BACKEND_RPC_ID) export class VoxelEditController extends SharedObject { private sources = new Map(); @@ -73,6 +244,16 @@ export class VoxelEditController extends SharedObject { private downsampleQueueSet: Set = new Set(); private isProcessingDownsampleQueue: boolean = false; + private morphologicalConfig = { + growthThresholds: [ + { count: 100, size: 1 }, + { count: 1000, size: 3 }, + { count: 10000, size: 5 }, + { count: 100000, size: 7 }, + ], + maxSize: 9, + }; + constructor(rpc: RPC, options: any) { super(); initializeSharedObjectCounterpart(this, rpc, options); @@ -752,6 +933,322 @@ export class VoxelEditController extends SharedObject { public async redo(): Promise { await this.performUndoRedo(this.redoStack, this.undoStack, false, "redo"); } + + async performOperation(operation: VoxelOperation): Promise { + switch (operation.type) { + case VoxelOperationType.BRUSH: + return this.performBrush(operation); + case VoxelOperationType.FLOOD_FILL: + return this.performFloodFill(operation); + default: + throw new Error( + `Unknown voxel operation type: ${(operation as any).type}`, + ); + } + } + + private async performBrush(op: BrushOperation): Promise { + const { center, radius, value, shape, basis, filterValue } = op; + const voxelSize = 1; // Hardcoded LOD 0 + const sourceIndex = 0; + const source = this.sources.get(sourceIndex); + if (!source) + throw new Error(`Brush operation requires a source.`); + const accessor = new BackendVoxelAccessor(source); + + const cx = Math.round((center[0] ?? 0) / voxelSize); + const cy = Math.round((center[1] ?? 0) / voxelSize); + const cz = Math.round((center[2] ?? 0) / voxelSize); + const r = Math.round(radius / voxelSize); + if (r <= 0) + throw new Error(`Brush radius must be positive.`); + const rr = r * r; + + const voxelsToPaint: Float32Array[] = []; + + const pushIf = async (point: Float32Array) => { + const v = await accessor.getValue(point[0], point[1], point[2]); + if (v === null) return; + if (v === value || (filterValue !== undefined && v !== filterValue)) + return; + voxelsToPaint.push(point); + }; + + if (shape === BrushShape.SPHERE) { + for (let dz = -r; dz <= r; ++dz) { + for (let dy = -r; dy <= r; ++dy) { + for (let dx = -r; dx <= r; ++dx) { + if (dx * dx + dy * dy + dz * dz <= rr) + await pushIf(new Float32Array([cx + dx, cy + dy, cz + dz])); + } + } + } + } else { + if (basis === undefined) + throw new Error("Brush shape requires a basis."); + const { u, v } = basis; + for (let j = -r; j <= r; ++j) { + for (let i = -r; i <= r; ++i) { + if (i * i + j * j <= rr) { + const point = vec3.fromValues(cx, cy, cz); + vec3.scaleAndAdd(point, point, u as vec3, i); + vec3.scaleAndAdd(point, point, v as vec3, j); + await pushIf(point as Float32Array); + } + } + } + } + + if (voxelsToPaint.length === 0) return; + + let finalVoxels = voxelsToPaint; + if (basis && shape === BrushShape.DISK) { + finalVoxels = this.fillPlaneAliasingGaps(voxelsToPaint, basis, center); + } + + await this.processBackendEdits(finalVoxels, value, sourceIndex); + } + + private async performFloodFill(op: FloodFillOperation): Promise { + const { seed, value: fillValue, maxVoxels, basis, filterValue } = op; + const sourceIndex = 0; + const source = this.sources.get(sourceIndex); + if (!source) return; + const accessor = new BackendVoxelAccessor(source); + + const startVoxelLod = vec3.round(vec3.create(), seed as vec3); + const originalValue = await accessor.getValue( + startVoxelLod[0], + startVoxelLod[1], + startVoxelLod[2], + ); + + if (originalValue === null) return; + if (filterValue !== undefined && originalValue !== filterValue) return; + if (originalValue === fillValue) return; + + const visited = new Set(); + const queue: [number, number][] = []; + let filledCount = 0; + const voxelsToFill: Float32Array[] = []; + + const map2dTo3d = (u: number, v: number): vec3 => { + const point = vec3.clone(startVoxelLod); + vec3.scaleAndAdd(point, point, basis.u as vec3, u); + vec3.scaleAndAdd(point, point, basis.v as vec3, v); + return vec3.round(vec3.create(), point); + }; + + const isFillable = async (p: vec3): Promise => { + const val = await accessor.getValue(p[0], p[1], p[2]); + if (val === null) return false; + if (originalValue === 0n) return val === 0n; + return val === originalValue; + }; + + const getCurrentThickness = (): number => { + let thickness = 1; + for (const threshold of this.morphologicalConfig.growthThresholds) { + if (filledCount >= threshold.count) { + thickness = Math.max(thickness, threshold.size); + } + } + return Math.min(thickness, this.morphologicalConfig.maxSize); + }; + + const hasThickEnoughChannel = async ( + u: number, + v: number, + nu: number, + nv: number, + requiredThickness: number, + ): Promise => { + if (requiredThickness <= 1) return true; + const halfThickness = Math.floor(requiredThickness / 2); + const du = nu - u; + const dv = nv - v; + const perpU = -dv; + const perpV = du; + + for (let offset = -halfThickness; offset <= halfThickness; ++offset) { + const testU = nu + perpU * offset; + const testV = nv + perpV * offset; + const pointToTest = map2dTo3d(testU, testV); + if (!(await isFillable(pointToTest))) return false; + } + return true; + }; + + const fillBorderRegion = async ( + startU: number, + startV: number, + requiredThickness: number, + ) => { + const subQueue: [number, number][] = []; + const halfSize = requiredThickness * 2; + const startKey = `${startU},${startV}`; + if (visited.has(startKey)) return; + + subQueue.push([startU, startV]); + visited.add(startKey); + + while (subQueue.length > 0) { + if (filledCount >= maxVoxels) return; + const [u, v] = subQueue.shift()!; + const currentPoint = map2dTo3d(u, v); + filledCount++; + voxelsToFill.push(currentPoint as Float32Array); + + const neighbors2d: [number, number][] = [ + [u + 1, v], + [u - 1, v], + [u, v + 1], + [u, v - 1], + ]; + for (const [nu, nv] of neighbors2d) { + const du = nu - startU; + const dv = nv - startV; + if (du * du + dv * dv > halfSize * halfSize) continue; + const neighborKey = `${nu},${nv}`; + if (visited.has(neighborKey)) continue; + if (await isFillable(map2dTo3d(nu, nv))) { + visited.add(neighborKey); + subQueue.push([nu, nv]); + } + } + } + }; + + queue.push([0, 0]); + visited.add("0,0"); + + while (queue.length > 0) { + if (filledCount >= maxVoxels) break; // Or throw error + const [u, v] = queue.shift()!; + const currentPoint = map2dTo3d(u, v); + filledCount++; + voxelsToFill.push(currentPoint as Float32Array); + + const requiredThickness = getCurrentThickness(); + const neighbors2d: [number, number][] = [ + [u + 1, v], + [u - 1, v], + [u, v + 1], + [u, v - 1], + ]; + + for (const [nu, nv] of neighbors2d) { + const k = `${nu},${nv}`; + if (visited.has(k)) continue; + const neighborPoint = map2dTo3d(nu, nv); + if (await isFillable(neighborPoint)) { + if (await hasThickEnoughChannel(u, v, nu, nv, requiredThickness)) { + visited.add(k); + queue.push([nu, nv]); + } else { + await fillBorderRegion(nu, nv, requiredThickness); + } + } + } + } + + const finalVoxels = this.fillPlaneAliasingGaps(voxelsToFill, basis, seed); + await this.processBackendEdits(finalVoxels, fillValue, sourceIndex); + } + + private fillPlaneAliasingGaps( + voxels: Float32Array[], + basis: { u: Float32Array; v: Float32Array }, + center: Float32Array, + ): Float32Array[] { + const u = basis.u as vec3; + const v = basis.v as vec3; + const normal = vec3.create(); + vec3.cross(normal, u, v); + vec3.normalize(normal, normal); + + const SKIP_THRESHOLD = 0.99; + if ( + Math.abs(normal[0]) > SKIP_THRESHOLD || + Math.abs(normal[1]) > SKIP_THRESHOLD || + Math.abs(normal[2]) > SKIP_THRESHOLD + ) { + return voxels; + } + + const d = -vec3.dot(normal, center as vec3); + const voxelSet = new Set(); + const output = [...voxels]; + for (const v of voxels) { + voxelSet.add( + `${Math.round(v[0])},${Math.round(v[1])},${Math.round(v[2])}`, + ); + } + + const DISTANCE_THRESHOLD = + Math.abs(normal[0]) + Math.abs(normal[1]) + Math.abs(normal[2]) + 1e-5; + + for (const p of voxels) { + const px = Math.round(p[0]); + const py = Math.round(p[1]); + const pz = Math.round(p[2]); + + for (const [ox, oy, oz] of OFFSETS_26_CONNECTED_BACKEND) { + const nx = px + ox; + const ny = py + oy; + const nz = pz + oz; + const key = `${nx},${ny},${nz}`; + if (voxelSet.has(key)) continue; + + const dist = Math.abs( + normal[0] * nx + normal[1] * ny + normal[2] * nz + d, + ); + if (dist <= DISTANCE_THRESHOLD) { + voxelSet.add(key); + output.push(new Float32Array([nx, ny, nz])); + } + } + } + return output; + } + + private async processBackendEdits( + voxels: Float32Array[], + value: bigint, + lodIndex: number, + ) { + const source = this.sources.get(lodIndex); + if (!source) return; + + const { rank, chunkDataSize } = source.spec; + const tempGridPos = new Float32Array(rank); + const tempPosInChunk = new Uint32Array(rank); + + const indicesByVoxKey = new Map(); + for (const voxelCoord of voxels) { + computeChunkGridPosition(tempGridPos, tempPosInChunk, voxelCoord, chunkDataSize); + const chunkKey = tempGridPos.join(); + const voxKey = makeVoxChunkKey(chunkKey, lodIndex); + + let indices = indicesByVoxKey.get(voxKey); + if (!indices) { + indices = []; + indicesByVoxKey.set(voxKey, indices); + } + + const index = + (tempPosInChunk[2] * chunkDataSize[1] + tempPosInChunk[1]) * + chunkDataSize[0] + + tempPosInChunk[0]; + indices.push(index); + } + + const backendEdits = []; + for (const [voxKey, indices] of indicesByVoxKey.entries()) { + backendEdits.push({ key: voxKey, indices, value }); + } + await this.commitVoxels(backendEdits); + } } registerRPC(VOX_EDIT_COMMIT_VOXELS_RPC_ID, function (x: any) { @@ -770,3 +1267,12 @@ registerPromiseRPC(VOX_EDIT_REDO_RPC_ID, async function (this: RPC, x: any) { await obj.redo(); return { value: undefined }; }); + +registerPromiseRPC( + VOX_EDIT_OPERATION_RPC_ID, + async function (this: RPC, x: any) { + const obj = this.get(x.rpcId) as VoxelEditController; + await obj.performOperation(x.operation); + return { value: undefined }; + }, +); diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index 0ad9b55105..c0e5a0d0e2 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -27,6 +27,38 @@ export const VOX_EDIT_UNDO_RPC_ID = "vox.edit.undo"; export const VOX_EDIT_REDO_RPC_ID = "vox.edit.redo"; export const VOX_EDIT_HISTORY_UPDATE_RPC_ID = "vox.edit.historyUpdate"; +export const VOX_EDIT_OPERATION_RPC_ID = "vox.edit.operation"; + +export enum VoxelOperationType { + BRUSH = 0, + FLOOD_FILL = 1, +} + +export interface VoxelOperationBase { + type: VoxelOperationType; +} + +export interface BrushOperation extends VoxelOperationBase { + type: VoxelOperationType.BRUSH; + center: Float32Array; + radius: number; + value: bigint; + shape: BrushShape; + basis: { u: Float32Array; v: Float32Array }; + filterValue?: bigint; +} + +export interface FloodFillOperation extends VoxelOperationBase { + type: VoxelOperationType.FLOOD_FILL; + seed: Float32Array; + value: bigint; + maxVoxels: number; + basis: { u: Float32Array; v: Float32Array }; + filterValue?: bigint; +} + +export type VoxelOperation = BrushOperation | FloodFillOperation; + export const BRUSH_TOOL_ID = "vox-brush"; export const FLOODFILL_TOOL_ID = "vox-flood-fill"; export const SEG_PICKER_TOOL_ID = "vox-seg-picker"; diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index c039518345..fa7d4d0093 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -18,7 +18,6 @@ import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transf import type { VolumeChunkSource, InMemoryVolumeChunkSource, - MultiscaleVolumeChunkSource, } from "#src/sliceview/volume/frontend.js"; import { StatusMessage } from "#src/status.js"; import { WatchableValue } from "#src/trackable_value.js"; @@ -27,17 +26,17 @@ import type { VoxelEditControllerHost, VoxelLayerResolution, VoxelValueGetter, -} from "#src/voxel_annotation/base.js"; + VoxelOperation, + BrushShape} from "#src/voxel_annotation/base.js"; import { - BrushShape, VOX_EDIT_BACKEND_RPC_ID, - VOX_EDIT_COMMIT_VOXELS_RPC_ID, VOX_RELOAD_CHUNKS_RPC_ID, VOX_EDIT_FAILURE_RPC_ID, VOX_EDIT_UNDO_RPC_ID, VOX_EDIT_REDO_RPC_ID, VOX_EDIT_HISTORY_UPDATE_RPC_ID, - makeVoxChunkKey, + VOX_EDIT_OPERATION_RPC_ID, + VoxelOperationType, parseVoxChunkKey, } from "#src/voxel_annotation/base.js"; import { @@ -46,16 +45,6 @@ import { SharedObject, } from "#src/worker_rpc.js"; -const OFFSETS_26_CONNECTED: number[][] = []; -for (let z = -1; z <= 1; z++) { - for (let y = -1; y <= 1; y++) { - for (let x = -1; x <= 1; x++) { - if (x === 0 && y === 0 && z === 0) continue; - OFFSETS_26_CONNECTED.push([x, y, z]); - } - } -} - @registerSharedObjectOwner(VOX_EDIT_BACKEND_RPC_ID) export class VoxelEditController extends SharedObject { public undoCount = new WatchableValue(0); @@ -101,15 +90,13 @@ export class VoxelEditController extends SharedObject { this.initializeCounterpart(rpc, { resolutions }); } - private morphologicalConfig = { - growthThresholds: [ - { count: 100, size: 1 }, - { count: 1000, size: 3 }, - { count: 10000, size: 5 }, - { count: 100000, size: 7 }, - ], - maxSize: 9, - }; + private async dispatchOperation(operation: VoxelOperation) { + if (!this.rpc) throw new Error("RPC unavailable"); + await this.rpc.promiseInvoke(VOX_EDIT_OPERATION_RPC_ID, { + rpcId: this.rpcId, + operation, + }); + } readonly singleChannelAccess: ChunkChannelAccessParameters = { numChannels: 1, @@ -139,495 +126,95 @@ export class VoxelEditController extends SharedObject { } as const; } - private getSourceForLOD( - multiscale: MultiscaleVolumeChunkSource | undefined, - lodIndex: number, - ): VolumeChunkSource { - if (!multiscale) { - throw new Error( - `VoxelEditController: Invalid multiscale object: ${multiscale}`, - ); - } - const sourcesByScale = multiscale.getSources( - this.getIdentitySliceViewSourceOptions(), - ); - const sources = sourcesByScale[0]; - if (!sources || sources.length <= lodIndex) { - throw new Error( - `VoxelEditController: LOD index ${lodIndex} is out of bounds.`, - ); - } - const source = sources[lodIndex]?.chunkSource; - if (!source) { - throw new Error( - `VoxelEditController: No chunk source found for LOD index ${lodIndex}.`, - ); - } - return source; - } - - private setupSources(lodIndex: number) { - const primarySource = this.getSourceForLOD( - this.host.primarySource, - lodIndex, - ); - const previewSource = this.getSourceForLOD( - this.host.previewSource, - lodIndex, - ) as InMemoryVolumeChunkSource; - - return { - primarySource, - previewSource, - getEnsuredValue: this.getEnsuredValueBuilder( - previewSource, - primarySource, - ), - processEdits: this.processEditsBuilder(previewSource), - }; - } - - // when painting in not axis-aligned slices, flood fill and brush algorithms will leave gaps; this function fills them. - private fillPlaneAliasingGaps( - voxels: Float32Array[], - basis: { u: Float32Array; v: Float32Array }, - center: Float32Array, - ): Float32Array[] { - const u = basis.u as vec3; - const v = basis.v as vec3; - const normal = vec3.create(); - vec3.cross(normal, u, v); - vec3.normalize(normal, normal); - - // skip if we are axis aligned - const SKIP_THRESHOLD = 0.99; - if ( - Math.abs(normal[0]) > SKIP_THRESHOLD || - Math.abs(normal[1]) > SKIP_THRESHOLD || - Math.abs(normal[2]) > SKIP_THRESHOLD - ) { - return voxels; - } - - const d = -vec3.dot(normal, center as vec3); - - const voxelSet = new Set(); - const output = [...voxels]; - for (const v of voxels) { - voxelSet.add( - `${Math.round(v[0])},${Math.round(v[1])},${Math.round(v[2])}`, - ); - } - - const DISTANCE_THRESHOLD = - Math.abs(normal[0]) + Math.abs(normal[1]) + Math.abs(normal[2]) + 1e-5; - - for (const p of voxels) { - const px = Math.round(p[0]); - const py = Math.round(p[1]); - const pz = Math.round(p[2]); - - for (const [ox, oy, oz] of OFFSETS_26_CONNECTED) { - const nx = px + ox; - const ny = py + oy; - const nz = pz + oz; - const key = `${nx},${ny},${nz}`; - - if (voxelSet.has(key)) continue; - - const dist = Math.abs( - normal[0] * nx + normal[1] * ny + normal[2] * nz + d, - ); - - if (dist <= DISTANCE_THRESHOLD) { - voxelSet.add(key); - const newVoxel = new Float32Array([nx, ny, nz]); - output.push(newVoxel); - } - } - } - return output; - } - - private getEnsuredValueBuilder = - (previewSource: VolumeChunkSource, primarySource: VolumeChunkSource) => - async (voxelCoord: Float32Array): Promise => { - let val = previewSource.getValueAt(voxelCoord, this.singleChannelAccess); - if (val != null) { - val = typeof val === "bigint" ? val : BigInt(val); - if (val !== 0n) return val; - } - val = await primarySource.getEnsuredValueAt( - voxelCoord, - this.singleChannelAccess, - ); - if (val === null) return null; - return typeof val === "bigint" ? val : BigInt(val as number); - }; - - private processEditsBuilder = - (previewSource: InMemoryVolumeChunkSource) => - ( - voxelsToPaint: Float32Array[], - valueGetter: VoxelValueGetter, - lodIndex: number, - basis?: { u: Float32Array; v: Float32Array }, - center?: Float32Array, - ) => { - const indicesByVoxKey = new Map(); - if (basis && center) - voxelsToPaint = this.fillPlaneAliasingGaps( - voxelsToPaint, - basis, - center, - ); - - for (const voxelCoord of voxelsToPaint) { - const { chunkGridPosition, positionWithinChunk } = - previewSource.computeChunkIndices(voxelCoord); - const chunkKey = chunkGridPosition.join(); - const voxKey = makeVoxChunkKey(chunkKey, lodIndex); - - let indices = indicesByVoxKey.get(voxKey); - if (!indices) { - indices = []; - indicesByVoxKey.set(voxKey, indices); - } - - const { chunkDataSize } = previewSource.spec; - const index = - (positionWithinChunk[2] * chunkDataSize[1] + positionWithinChunk[1]) * - chunkDataSize[0] + - positionWithinChunk[0]; - indices.push(index); - } - - const previewValue = valueGetter(true); - const localEdits = new Map< - string, - { indices: number[]; value: bigint } - >(); - for (const [voxKey, indices] of indicesByVoxKey.entries()) { - const parsed = parseVoxChunkKey(voxKey); - if (!parsed) continue; - localEdits.set(parsed.chunkKey, { indices, value: previewValue }); - } - previewSource.applyLocalEdits(localEdits); - - const storageValue = valueGetter(false); - const backendEdits = [] as { - key: string; - indices: number[]; - value: bigint; - }[]; - for (const [voxKey, indices] of indicesByVoxKey.entries()) { - backendEdits.push({ - key: voxKey, - indices: indices, - value: storageValue, - }); - } - - this.commitEdits(backendEdits); - return backendEdits; - }; - - // Paint a disk (require the basis) or a sphere async paintBrushWithShape( centerCanonical: Float32Array, radiusCanonical: number, valueGetter: VoxelValueGetter, shape: BrushShape, - basis?: { u: Float32Array; v: Float32Array }, + basis: { u: Float32Array; v: Float32Array }, filterValue?: bigint, ) { - if (!Number.isFinite(radiusCanonical) || radiusCanonical <= 0) { - throw new Error("paintBrushWithShape: 'radius' must be > 0."); - } - if (!centerCanonical || centerCanonical.length < 3) { - throw new Error( - "paintBrushWithShape: 'center' must be a Float32Array[3].", - ); - } - - // Hardcode drawing at LOD 0 for now. - const voxelSize = 1; - const sourceIndex = 0; - const { processEdits, getEnsuredValue } = this.setupSources(sourceIndex); - + const voxelSize = 1; // Assuming LOD 0 const cx = Math.round((centerCanonical[0] ?? 0) / voxelSize); const cy = Math.round((centerCanonical[1] ?? 0) / voxelSize); const cz = Math.round((centerCanonical[2] ?? 0) / voxelSize); const r = Math.round(radiusCanonical / voxelSize); - if (r <= 0) { - throw new Error( - "paintBrushWithShape: radius too small for selected LOD.", - ); - } - const rr = r * r; - - const voxelsToPaint: Float32Array[] = []; - - const value = valueGetter(false); - - const pushIf = async (point: Float32Array) => { - const v = await getEnsuredValue(point); - if (v === value || (filterValue !== undefined && v !== filterValue)) - return; - voxelsToPaint.push(point); - }; - - if (shape === BrushShape.SPHERE) { - for (let dz = -r; dz <= r; ++dz) { - for (let dy = -r; dy <= r; ++dy) { - for (let dx = -r; dx <= r; ++dx) { - if (dx * dx + dy * dy + dz * dz <= rr) - await pushIf(new Float32Array([cx + dx, cy + dy, cz + dz])); - } - } - } - } else { - if (basis === undefined) { - throw new Error( - "paintBrushWithShape: 'basis' must be defined for disk alignment.", - ); + if (r <= 0) + { + throw new Error("Brush radius must be positive."); } + const rr = r * r; const { u, v } = basis; + const voxelsToPaint: Float32Array[] = []; + for (let j = -r; j <= r; ++j) { for (let i = -r; i <= r; ++i) { if (i * i + j * j <= rr) { const point = vec3.fromValues(cx, cy, cz); vec3.scaleAndAdd(point, point, u as vec3, i); vec3.scaleAndAdd(point, point, v as vec3, j); - await pushIf(point as Float32Array); + voxelsToPaint.push(point as Float32Array); } } } - } - if (!voxelsToPaint || voxelsToPaint.length === 0) return; - processEdits( - voxelsToPaint, - valueGetter, - sourceIndex, - basis, - centerCanonical, - ); - } - commitEdits( - edits: { - key: string; - indices: number[] | Uint32Array; - value?: bigint; - values?: ArrayLike; - size?: number[]; - }[], - ): void { - if (!this.rpc) - throw new Error("VoxelEditController.commitEdits: RPC not initialized."); - if (!Array.isArray(edits)) { - throw new Error( - "VoxelEditController.commitEdits: edits must be an array.", - ); - } - this.rpc.invoke(VOX_EDIT_COMMIT_VOXELS_RPC_ID, { - rpcId: this.rpcId, - edits, + if (voxelsToPaint.length > 0) { + const previewSource = this.host.previewSource!.getSources( + this.getIdentitySliceViewSourceOptions(), + )[0][0].chunkSource as InMemoryVolumeChunkSource; + const value = valueGetter(true); + + const edits = new Map(); + + for (const voxelCoord of voxelsToPaint) { + const { chunkGridPosition, positionWithinChunk } = + previewSource.computeChunkIndices(voxelCoord); + const key = chunkGridPosition.join(); + let entry = edits.get(key); + if (!entry) { + entry = { indices: [], value }; + edits.set(key, entry); + } + const { chunkDataSize } = previewSource.spec; + const index = + (positionWithinChunk[2] * chunkDataSize[1] + + positionWithinChunk[1]) * + chunkDataSize[0] + + positionWithinChunk[0]; + entry.indices.push(index); + } + previewSource.applyLocalEdits(edits); + } + + const storageValue = valueGetter(false); + await this.dispatchOperation({ + type: VoxelOperationType.BRUSH, + center: centerCanonical, + radius: radiusCanonical, + value: storageValue, + shape, + basis, + filterValue, }); } - /** - * 2D flood fill with failsafe to avoid propagating via small holes (see morphologicalConfig to configure). - */ async floodFillPlane2D( startPositionCanonical: Float32Array, fillValueGetter: VoxelValueGetter, maxVoxels: number, basis: { u: Float32Array; v: Float32Array }, filterValue?: bigint, - ): Promise<{ - edits: { key: string; indices: number[]; value: bigint }[]; - filledCount: number; - originalValue: bigint; - }> { - const sourceIndex = 0; - const { processEdits, getEnsuredValue } = this.setupSources(sourceIndex); - const startVoxelLod = vec3.round( - vec3.create(), - startPositionCanonical as vec3, - ); - - const originalValue = await getEnsuredValue(startVoxelLod as Float32Array); - if (originalValue === null) { - throw new Error( - "Flood fill seed is in an unloaded or out-of-bounds chunk.", - ); - } - if (filterValue !== undefined && originalValue !== filterValue) - throw new Error("This is not the value selected for erasing"); - - const fillValue = fillValueGetter(false); - - if (originalValue === fillValue) { - return { edits: [], filledCount: 0, originalValue }; - } - - const visited = new Set(); - const queue: [number, number][] = []; - let filledCount = 0; - const voxelsToFill: Float32Array[] = []; - - const map2dTo3d = (u: number, v: number): vec3 => { - const point = vec3.clone(startVoxelLod); - vec3.scaleAndAdd(point, point, basis.u as vec3, u); - vec3.scaleAndAdd(point, point, basis.v as vec3, v); - return vec3.round(vec3.create(), point); - }; - - const isFillable = async (p: vec3): Promise => { - const value = await getEnsuredValue(p as Float32Array); - if (value === null) return false; - if (originalValue === 0n) return value === 0n; - return value === originalValue; - }; - - const getCurrentThickness = (): number => { - let thickness = 1; - for (const threshold of this.morphologicalConfig.growthThresholds) { - if (filledCount >= threshold.count) { - thickness = Math.max(thickness, threshold.size); - } - } - return Math.min(thickness, this.morphologicalConfig.maxSize); - }; - - const hasThickEnoughChannel = async ( - u: number, - v: number, - nu: number, - nv: number, - requiredThickness: number, - ): Promise => { - if (requiredThickness <= 1) return true; - - const halfThickness = Math.floor(requiredThickness / 2); - const du = nu - u; - const dv = nv - v; - - const perpU = -dv; - const perpV = du; - - for (let offset = -halfThickness; offset <= halfThickness; ++offset) { - const testU = nu + perpU * offset; - const testV = nv + perpV * offset; - const pointToTest = map2dTo3d(testU, testV); - - if (!(await isFillable(pointToTest))) { - return false; - } - } - - return true; - }; - - const fillBorderRegion = async ( - startU: number, - startV: number, - requiredThickness: number, - ) => { - const subQueue: [number, number][] = []; - const halfSize = requiredThickness * 2; // multiply by 2 to avoid small artifacts - const startKey = `${startU},${startV}`; - if (visited.has(startKey)) return; - - subQueue.push([startU, startV]); - visited.add(startKey); - - while (subQueue.length > 0) { - if (filledCount >= maxVoxels) return; - const [u, v] = subQueue.shift()!; - - const currentPoint = map2dTo3d(u, v); - filledCount++; - voxelsToFill.push(currentPoint as Float32Array); - - const neighbors2d: [number, number][] = [ - [u + 1, v], - [u - 1, v], - [u, v + 1], - [u, v - 1], - ]; - for (const [nu, nv] of neighbors2d) { - const du = nu - startU; - const dv = nv - startV; - const distanceSquared = du * du + dv * dv; - if (distanceSquared > halfSize * halfSize) { - continue; - } - - const neighborKey = `${nu},${nv}`; - if (visited.has(neighborKey)) continue; - - const neighborPoint = map2dTo3d(nu, nv); - if (await isFillable(neighborPoint)) { - visited.add(neighborKey); - subQueue.push([nu, nv]); - } - } - } - }; - - queue.push([0, 0]); - visited.add("0,0"); - - while (queue.length > 0) { - if (filledCount >= maxVoxels) { - throw new Error( - `Flood fill region exceeds the limit of ${maxVoxels} voxels.`, - ); - } - const [u, v] = queue.shift()!; - - const currentPoint = map2dTo3d(u, v); - filledCount++; - voxelsToFill.push(currentPoint as Float32Array); - - const requiredThickness = getCurrentThickness(); - const neighbors2d: [number, number][] = [ - [u + 1, v], - [u - 1, v], - [u, v + 1], - [u, v - 1], - ]; - - for (const [nu, nv] of neighbors2d) { - const k = `${nu},${nv}`; - if (visited.has(k)) continue; - - const neighborPoint = map2dTo3d(nu, nv); - if (await isFillable(neighborPoint)) { - if (await hasThickEnoughChannel(u, v, nu, nv, requiredThickness)) { - visited.add(k); - queue.push([nu, nv]); - } else { - await fillBorderRegion(nu, nv, requiredThickness); - } - } - } - } + ) { - const edits = processEdits( - voxelsToFill, - fillValueGetter, - sourceIndex, + const storageValue = fillValueGetter(false); + await this.dispatchOperation({ + type: VoxelOperationType.FLOOD_FILL, + seed: startPositionCanonical, + value: storageValue, + maxVoxels, basis, - startPositionCanonical, - ); - return { - edits, - filledCount, - originalValue, - }; + filterValue, + }); } callChunkReload(voxChunkKeys: string[], isForPreviewChunks: boolean) { From de7bb603970bb38915506c709eac237a51749935 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 8 Jan 2026 17:36:08 +0100 Subject: [PATCH 180/251] refactor(voxel-annotation): temporarily add different ways to render sphere preview --- src/sliceview/volume/frontend.ts | 12 +- src/voxel_annotation/backend.ts | 224 +++++++++++++++---------------- src/voxel_annotation/base.ts | 2 + src/voxel_annotation/frontend.ts | 116 ++++++++++------ 4 files changed, 195 insertions(+), 159 deletions(-) diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index 382f4a4e02..8a46849a40 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -17,9 +17,7 @@ import type { ChunkManager } from "#src/chunk_manager/frontend.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; import type { SliceViewChunkSpecification } from "#src/sliceview/base.js"; -import { - DataType, -} from "#src/sliceview/base.js"; +import { DataType } from "#src/sliceview/base.js"; import type { SliceViewChunk } from "#src/sliceview/frontend.js"; import { MultiscaleSliceViewChunkSource, @@ -30,10 +28,12 @@ import type { VolumeChunkSource as VolumeChunkSourceInterface, VolumeChunkSpecification, VolumeSourceOptions, - VolumeType} from "#src/sliceview/volume/base.js"; + VolumeType, +} from "#src/sliceview/volume/base.js"; import { - computeChunkGridPosition -, IN_MEMORY_VOLUME_CHUNK_SOURCE_RPC_ID } from "#src/sliceview/volume/base.js"; + computeChunkGridPosition, + IN_MEMORY_VOLUME_CHUNK_SOURCE_RPC_ID, +} from "#src/sliceview/volume/base.js"; import { VolumeChunk } from "#src/sliceview/volume/chunk.js"; import { getChunkFormatHandler } from "#src/sliceview/volume/registry.js"; import type { TypedArray } from "#src/util/array.js"; diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index d98826f5c1..64b5c0dd45 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -23,6 +23,7 @@ import type { VolumeChunkSource, } from "#src/sliceview/volume/backend.js"; import { computeChunkGridPosition } from "#src/sliceview/volume/base.js"; +import type { TypedArray } from "#src/util/array.js"; import { mat4, vec3 } from "#src/util/geom.js"; import * as matrix from "#src/util/matrix.js"; import type { @@ -67,156 +68,147 @@ for (let z = -1; z <= 1; z++) { } } +function getFlatChunkData( + chunk: VolumeChunk, + spec: any, +): Uint32Array | BigUint64Array | TypedArray | null { + if (!chunk.data) return null; + + if (!spec.compressedSegmentationBlockSize) { + return chunk.data as TypedArray; + } + + const size = chunk.chunkDataSize!; + const numElements = size[0] * size[1] * size[2]; + const compressedData = chunk.data as Uint32Array; + const baseOffset = compressedData.length > 0 ? compressedData[0] : 0; + const subchunkSize = spec.compressedSegmentationBlockSize; + + if (spec.dataType === DataType.UINT32) { + const out = new Uint32Array(numElements); + if (baseOffset !== 0) { + decodeChannelUint32(out, compressedData, baseOffset, size, subchunkSize); + } + return out; + } else { + const out = new BigUint64Array(numElements); + if (baseOffset !== 0) { + decodeChannelUint64(out, compressedData, baseOffset, size, subchunkSize); + } + return out; + } +} class BackendVoxelAccessor { - private activeChunk: VolumeChunk | null = null; - private activeChunkData: Uint32Array | BigUint64Array | Uint8Array | Int8Array | Uint16Array | Int16Array | Int32Array | Float32Array | null = null; - private activeChunkGridKey: string | null = null; + private activeData: TypedArray | null = null; + private activeKey: string | null = null; - private activeBoundsMinX = 0; - private activeBoundsMaxX = 0; - private activeBoundsMinY = 0; - private activeBoundsMaxY = 0; - private activeBoundsMinZ = 0; - private activeBoundsMaxZ = 0; + private minX = 0; + private maxX = 0; + private minY = 0; + private maxY = 0; + private minZ = 0; + private maxZ = 0; - private activeStrideX = 1; - private activeStrideY = 1; - private activeStrideZ = 1; + private strideY = 0; + private strideZ = 0; - private fillValue: bigint; + private readonly volMin: Float32Array; + private readonly volMax: Float32Array; + private readonly chunkDimension: Uint32Array; + private readonly fillValue: bigint; constructor(private source: VolumeChunkSource) { - const fv = source.spec.fillValue; - this.fillValue = typeof fv === 'bigint' ? fv : BigInt(fv); + const spec = source.spec; + this.volMin = spec.lowerVoxelBound; + this.volMax = spec.upperVoxelBound; + this.chunkDimension = spec.chunkDataSize; + const fv = spec.fillValue; + this.fillValue = typeof fv === "bigint" ? fv : BigInt(fv); } async getValue(x: number, y: number, z: number): Promise { - const { lowerVoxelBound, upperVoxelBound } = this.source.spec; if ( - x < lowerVoxelBound[0] || x >= upperVoxelBound[0] || - y < lowerVoxelBound[1] || y >= upperVoxelBound[1] || - z < lowerVoxelBound[2] || z >= upperVoxelBound[2] + x >= this.minX && + x < this.maxX && + y >= this.minY && + y < this.maxY && + z >= this.minZ && + z < this.maxZ ) { - return null; + return this.readLocal(x, y, z); } if ( - this.activeChunk && - x >= this.activeBoundsMinX && x < this.activeBoundsMaxX && - y >= this.activeBoundsMinY && y < this.activeBoundsMaxY && - z >= this.activeBoundsMinZ && z < this.activeBoundsMaxZ + x < this.volMin[0] || + x >= this.volMax[0] || + y < this.volMin[1] || + y >= this.volMax[1] || + z < this.volMin[2] || + z >= this.volMax[2] ) { - return this.readLocal(x, y, z); + return null; } - return this.loadChunk(x, y, z); + await this.loadChunk(x, y, z); + return this.readLocal(x, y, z); } private readLocal(x: number, y: number, z: number): bigint { - if (this.activeChunkData !== null) { - const lx = x - this.activeBoundsMinX; - const ly = y - this.activeBoundsMinY; - const lz = z - this.activeBoundsMinZ; - - const index = lz * this.activeStrideZ + ly * this.activeStrideY + lx * this.activeStrideX; - const val = this.activeChunkData[index]; - return typeof val === 'bigint' ? val : BigInt(val); - } - return this.fillValue; - } + if (!this.activeData) return this.fillValue; - private async loadChunk(x: number, y: number, z: number): Promise { - const { chunkDataSize } = this.source.spec; + const lx = x - this.minX; + const ly = y - this.minY; + const lz = z - this.minZ; + const index = lz * this.strideZ + ly * this.strideY + lx; - const gx = Math.floor(x / chunkDataSize[0]); - const gy = Math.floor(y / chunkDataSize[1]); - const gz = Math.floor(z / chunkDataSize[2]); - const key = `${gx},${gy},${gz}`; + const val = this.activeData[index]; + return typeof val === "bigint" ? val : BigInt(val); + } - if (this.activeChunkGridKey === key) { - return this.readLocal(x, y, z); - } + private async loadChunk(x: number, y: number, z: number) { + const cx = Math.floor(x / this.chunkDimension[0]); + const cy = Math.floor(y / this.chunkDimension[1]); + const cz = Math.floor(z / this.chunkDimension[2]); + const key = `${cx},${cy},${cz}`; - const chunkGridPosition = new Float32Array([gx, gy, gz]); + if (this.activeKey === key) return; let chunk = this.source.chunks.get(key) as VolumeChunk | undefined; if (!chunk) { - chunk = this.source.getChunk(chunkGridPosition) as VolumeChunk; - } - - if (!chunk.chunkDataSize) { - this.source.computeChunkBounds(chunk); + chunk = this.source.getChunk( + new Float32Array([cx, cy, cz]), + ) as VolumeChunk; } if (chunk.state > ChunkState.SYSTEM_MEMORY_WORKER || !chunk.data) { try { await this.source.download(chunk, new AbortController().signal); - if (!chunk.chunkDataSize) this.source.computeChunkBounds(chunk); } catch { - // Download failed + this.activeData = null; } } - this.activeChunk = chunk; - this.activeChunkGridKey = key; - - const size = chunk.chunkDataSize || chunkDataSize; - this.activeBoundsMinX = gx * chunkDataSize[0]; - this.activeBoundsMinY = gy * chunkDataSize[1]; - this.activeBoundsMinZ = gz * chunkDataSize[2]; - this.activeBoundsMaxX = this.activeBoundsMinX + size[0]; - this.activeBoundsMaxY = this.activeBoundsMinY + size[1]; - this.activeBoundsMaxZ = this.activeBoundsMinZ + size[2]; - - this.activeStrideX = 1; - this.activeStrideY = size[0]; - this.activeStrideZ = size[0] * size[1]; - - if (chunk.data) { - this.prepareData(chunk); - } else { - this.activeChunkData = null; - } - - if ( - x >= this.activeBoundsMinX && x < this.activeBoundsMaxX && - y >= this.activeBoundsMinY && y < this.activeBoundsMaxY && - z >= this.activeBoundsMinZ && z < this.activeBoundsMaxZ - ) { - return this.readLocal(x, y, z); + if (!chunk.chunkDataSize) { + this.source.computeChunkBounds(chunk); } - return null; - } - private prepareData(chunk: VolumeChunk) { - const { spec } = this.source; - if (spec.compressedSegmentationBlockSize) { - const size = chunk.chunkDataSize!; - const numElements = size[0] * size[1] * size[2]; - const compressedData = chunk.data as Uint32Array; - const baseOffset = compressedData.length > 0 ? compressedData[0] : 0; + this.activeKey = key; + this.activeData = getFlatChunkData(chunk, this.source.spec); - const { dataType, compressedSegmentationBlockSize: subchunkSize } = spec; + const size = chunk.chunkDataSize || this.chunkDimension; + this.minX = cx * this.chunkDimension[0]; + this.minY = cy * this.chunkDimension[1]; + this.minZ = cz * this.chunkDimension[2]; + this.maxX = this.minX + size[0]; + this.maxY = this.minY + size[1]; + this.maxZ = this.minZ + size[2]; - if (dataType === DataType.UINT32) { - const out = new Uint32Array(numElements); - if (baseOffset !== 0) { - decodeChannelUint32(out, compressedData, baseOffset, size, subchunkSize!); - } - this.activeChunkData = out; - } else { - const out = new BigUint64Array(numElements); - if (baseOffset !== 0) { - decodeChannelUint64(out, compressedData, baseOffset, size, subchunkSize!); - } - this.activeChunkData = out; - } - } else { - this.activeChunkData = chunk.data as any; - } + this.strideY = size[0]; + this.strideZ = size[0] * size[1]; } } + @registerSharedObject(VOX_EDIT_BACKEND_RPC_ID) export class VoxelEditController extends SharedObject { private sources = new Map(); @@ -952,16 +944,14 @@ export class VoxelEditController extends SharedObject { const voxelSize = 1; // Hardcoded LOD 0 const sourceIndex = 0; const source = this.sources.get(sourceIndex); - if (!source) - throw new Error(`Brush operation requires a source.`); + if (!source) throw new Error(`Brush operation requires a source.`); const accessor = new BackendVoxelAccessor(source); const cx = Math.round((center[0] ?? 0) / voxelSize); const cy = Math.round((center[1] ?? 0) / voxelSize); const cz = Math.round((center[2] ?? 0) / voxelSize); const r = Math.round(radius / voxelSize); - if (r <= 0) - throw new Error(`Brush radius must be positive.`); + if (r <= 0) throw new Error(`Brush radius must be positive.`); const rr = r * r; const voxelsToPaint: Float32Array[] = []; @@ -974,7 +964,7 @@ export class VoxelEditController extends SharedObject { voxelsToPaint.push(point); }; - if (shape === BrushShape.SPHERE) { + if (shape !== BrushShape.DISK) { for (let dz = -r; dz <= r; ++dz) { for (let dy = -r; dy <= r; ++dy) { for (let dx = -r; dx <= r; ++dx) { @@ -984,8 +974,7 @@ export class VoxelEditController extends SharedObject { } } } else { - if (basis === undefined) - throw new Error("Brush shape requires a basis."); + if (basis === undefined) throw new Error("Brush shape requires a basis."); const { u, v } = basis; for (let j = -r; j <= r; ++j) { for (let i = -r; i <= r; ++i) { @@ -1226,7 +1215,12 @@ export class VoxelEditController extends SharedObject { const indicesByVoxKey = new Map(); for (const voxelCoord of voxels) { - computeChunkGridPosition(tempGridPos, tempPosInChunk, voxelCoord, chunkDataSize); + computeChunkGridPosition( + tempGridPos, + tempPosInChunk, + voxelCoord, + chunkDataSize, + ); const chunkKey = tempGridPos.join(); const voxKey = makeVoxChunkKey(chunkKey, lodIndex); diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index c0e5a0d0e2..3c614fbe68 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -131,6 +131,8 @@ export function getBasisFromNormal(n: vec3) { export enum BrushShape { DISK = 0, SPHERE = 1, + SPHERE_DISPLAYING_DISK = 2, + SPHERE_DISPLAYING_3_DISKS = 3, } export interface VoxelEditControllerHost { diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index fa7d4d0093..ecfbb92053 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -16,8 +16,8 @@ import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; import type { - VolumeChunkSource, InMemoryVolumeChunkSource, + VolumeChunkSource, } from "#src/sliceview/volume/frontend.js"; import { StatusMessage } from "#src/status.js"; import { WatchableValue } from "#src/trackable_value.js"; @@ -25,19 +25,20 @@ import { vec3 } from "#src/util/geom.js"; import type { VoxelEditControllerHost, VoxelLayerResolution, - VoxelValueGetter, VoxelOperation, - BrushShape} from "#src/voxel_annotation/base.js"; + VoxelValueGetter, +} from "#src/voxel_annotation/base.js"; import { + BrushShape, + parseVoxChunkKey, VOX_EDIT_BACKEND_RPC_ID, - VOX_RELOAD_CHUNKS_RPC_ID, VOX_EDIT_FAILURE_RPC_ID, - VOX_EDIT_UNDO_RPC_ID, - VOX_EDIT_REDO_RPC_ID, VOX_EDIT_HISTORY_UPDATE_RPC_ID, VOX_EDIT_OPERATION_RPC_ID, + VOX_EDIT_REDO_RPC_ID, + VOX_EDIT_UNDO_RPC_ID, + VOX_RELOAD_CHUNKS_RPC_ID, VoxelOperationType, - parseVoxChunkKey, } from "#src/voxel_annotation/base.js"; import { registerRPC, @@ -139,52 +140,92 @@ export class VoxelEditController extends SharedObject { const cy = Math.round((centerCanonical[1] ?? 0) / voxelSize); const cz = Math.round((centerCanonical[2] ?? 0) / voxelSize); const r = Math.round(radiusCanonical / voxelSize); - if (r <= 0) - { + if (r <= 0) { throw new Error("Brush radius must be positive."); - } - const rr = r * r; - const { u, v } = basis; - const voxelsToPaint: Float32Array[] = []; + } + const rr = r * r; + const { u, v } = basis as { u: vec3; v: vec3 }; + const n = vec3.create(); + vec3.cross(n, u, v); + vec3.normalize(n, n); + const voxelsToPaint: Float32Array[] = []; + if ( + shape === BrushShape.DISK || + shape === BrushShape.SPHERE_DISPLAYING_DISK + ) { for (let j = -r; j <= r; ++j) { for (let i = -r; i <= r; ++i) { if (i * i + j * j <= rr) { const point = vec3.fromValues(cx, cy, cz); - vec3.scaleAndAdd(point, point, u as vec3, i); - vec3.scaleAndAdd(point, point, v as vec3, j); + vec3.scaleAndAdd(point, point, u, i); + vec3.scaleAndAdd(point, point, v, j); voxelsToPaint.push(point as Float32Array); } } } + } else if (shape === BrushShape.SPHERE) { + for (let dz = -r; dz <= r; ++dz) { + for (let dy = -r; dy <= r; ++dy) { + for (let dx = -r; dx <= r; ++dx) { + if (dx * dx + dy * dy + dz * dz <= rr) { + voxelsToPaint.push(new Float32Array([cx + dx, cy + dy, cz + dz])); + } + } + } + } + } else { + const point = vec3.create(); + const center = vec3.fromValues(cx, cy, cz); - if (voxelsToPaint.length > 0) { - const previewSource = this.host.previewSource!.getSources( - this.getIdentitySliceViewSourceOptions(), - )[0][0].chunkSource as InMemoryVolumeChunkSource; - const value = valueGetter(true); + for (let j = -r; j <= r; ++j) { + for (let i = -r; i <= r; ++i) { + if (i * i + j * j <= rr) { + vec3.copy(point, center); + vec3.scaleAndAdd(point, point, u, i); + vec3.scaleAndAdd(point, point, v, j); + voxelsToPaint.push(Float32Array.from(point)); - const edits = new Map(); + vec3.copy(point, center); + vec3.scaleAndAdd(point, point, u, i); + vec3.scaleAndAdd(point, point, n, j); + voxelsToPaint.push(Float32Array.from(point)); - for (const voxelCoord of voxelsToPaint) { - const { chunkGridPosition, positionWithinChunk } = - previewSource.computeChunkIndices(voxelCoord); - const key = chunkGridPosition.join(); - let entry = edits.get(key); - if (!entry) { - entry = { indices: [], value }; - edits.set(key, entry); + vec3.copy(point, center); + vec3.scaleAndAdd(point, point, n, i); + vec3.scaleAndAdd(point, point, v, j); + voxelsToPaint.push(Float32Array.from(point)); } - const { chunkDataSize } = previewSource.spec; - const index = - (positionWithinChunk[2] * chunkDataSize[1] + - positionWithinChunk[1]) * - chunkDataSize[0] + - positionWithinChunk[0]; - entry.indices.push(index); } - previewSource.applyLocalEdits(edits); } + } + + if (voxelsToPaint.length > 0) { + const previewSource = this.host.previewSource!.getSources( + this.getIdentitySliceViewSourceOptions(), + )[0][0].chunkSource as InMemoryVolumeChunkSource; + const value = valueGetter(true); + + const edits = new Map(); + + for (const voxelCoord of voxelsToPaint) { + const { chunkGridPosition, positionWithinChunk } = + previewSource.computeChunkIndices(voxelCoord); + const key = chunkGridPosition.join(); + let entry = edits.get(key); + if (!entry) { + entry = { indices: [], value }; + edits.set(key, entry); + } + const { chunkDataSize } = previewSource.spec; + const index = + (positionWithinChunk[2] * chunkDataSize[1] + positionWithinChunk[1]) * + chunkDataSize[0] + + positionWithinChunk[0]; + entry.indices.push(index); + } + previewSource.applyLocalEdits(edits); + } const storageValue = valueGetter(false); await this.dispatchOperation({ @@ -205,7 +246,6 @@ export class VoxelEditController extends SharedObject { basis: { u: Float32Array; v: Float32Array }, filterValue?: bigint, ) { - const storageValue = fillValueGetter(false); await this.dispatchOperation({ type: VoxelOperationType.FLOOD_FILL, From efacdaa4962b91b44187ba750d59fef7d0aeef75 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 8 Jan 2026 18:28:41 +0100 Subject: [PATCH 181/251] feat(voxel-annotation): discard the drawing action done before user confirmation --- src/layer/voxel_annotation/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index f41ba4069e..bf58efdfcc 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -152,7 +152,7 @@ export class VoxelEditingContext return true; } if (this._pendingPermissionPromise) { - return this._pendingPermissionPromise; + return false; // this._pendingPermissionPromise; } this._pendingPermissionPromise = new Promise((resolve) => { @@ -180,7 +180,7 @@ export class VoxelEditingContext return result; }); - return this._pendingPermissionPromise; + return false; // this._pendingPermissionPromise; } async paintBrushWithShape( From 9209334a82779dbd1ebac96fcd2024c3d4ebee6e Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Fri, 9 Jan 2026 17:19:50 +0100 Subject: [PATCH 182/251] test(voxel-annotation): remove obsolete frontend voxel annotation test files and extend backend tests --- src/voxel_annotation/backend.spec.ts | 320 ++++++++++++++++++++++++ src/voxel_annotation/frontend.spec.ts | 342 -------------------------- 2 files changed, 320 insertions(+), 342 deletions(-) delete mode 100644 src/voxel_annotation/frontend.spec.ts diff --git a/src/voxel_annotation/backend.spec.ts b/src/voxel_annotation/backend.spec.ts index 23737d7112..9aaaa3441a 100644 --- a/src/voxel_annotation/backend.spec.ts +++ b/src/voxel_annotation/backend.spec.ts @@ -15,12 +15,17 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { VolumeChunk } from "#src/sliceview/volume/backend.js"; +import { VolumeChunkSource } from "#src/sliceview/volume/backend.js"; +import { DATA_TYPE_ARRAY_CONSTRUCTOR, DataType } from "#src/util/data_type.js"; import { mat4 } from "#src/util/geom.js"; import { VoxelEditController } from "#src/voxel_annotation/backend.js"; import { makeVoxChunkKey, VOX_EDIT_FAILURE_RPC_ID, VOX_EDIT_HISTORY_UPDATE_RPC_ID, + VoxelOperationType, + BrushShape, } from "#src/voxel_annotation/base.js"; import type { RPC } from "#src/worker_rpc.js"; @@ -77,6 +82,27 @@ function flattenGrid(grid: Grid3D, Ctor: any = Uint32Array) { return { data, size: [w, h, d] as [number, number, number] }; } +class MockBackendSource extends VolumeChunkSource { + public serverStorage = new Map(); + + async download(chunk: VolumeChunk) { + const key = chunk.chunkGridPosition.join(","); + if (this.serverStorage.has(key)) { + const buffer = this.serverStorage.get(key)!; + const Ctor = + DATA_TYPE_ARRAY_CONSTRUCTOR[this.spec.dataType]; + chunk.data = new Ctor(buffer.slice(0)); + } + } + + async writeChunk(chunk: VolumeChunk) { + const key = chunk.chunkGridPosition.join(","); + if (chunk.data) { + this.serverStorage.set(key, chunk.data.buffer.slice(0) as ArrayBuffer); + } + } +} + describe("VoxelEditController: _calculateParentUpdate", () => { let controller: VoxelEditController; let runDownsample: Function; @@ -1095,3 +1121,297 @@ describe("VoxelEditController: Undo/Redo", () => { expect((controller as any).redoStack.length).toBe(1); }); }); + +describe("VoxelEditController: Tool Operations", () => { + let controller: VoxelEditController; + let mockSource: MockBackendSource; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + + const mockChunkManager = { + queueManager: { + sources: new Set(), + adjustCapacitiesForChunk: vi.fn(), + updateChunkState: vi.fn(), + scheduleUpdate: vi.fn(), + moveChunkToFrontend: vi.fn(), + markRecentlyUsed: vi.fn(), + gl: {}, + }, + }; + + (mockRpc.get as any).mockImplementation((id: number) => { + if (id === 0) return mockChunkManager; + return null; + }); + + const spec = { + rank: 3, + chunkDataSize: new Uint32Array([10, 10, 10]), + dataType: DataType.UINT64, + lowerVoxelBound: new Float32Array([0, 0, 0]), + upperVoxelBound: new Float32Array([100, 100, 100]), + baseVoxelOffset: new Float32Array([0, 0, 0]), + fillValue: 0n, + }; + mockSource = new MockBackendSource(mockRpc, { + spec: spec, + chunkManager: 0, + }); + vi.spyOn(mockSource, "applyEdits").mockResolvedValue({ + indices: new Uint32Array([]), + oldValues: new BigUint64Array([]), + newValues: new BigUint64Array([]), + }); + + (mockRpc.get as any).mockImplementation((id: number) => { + if (id === 0) return mockChunkManager; + if (id === 100) return mockSource; + return null; + }); + + controller = new VoxelEditController(mockRpc, { + resolutions: [resConfig(0, [1, 1, 1], [10, 10, 10])], + }); + + vi.spyOn(controller as any, "enqueueDownsample").mockImplementation( + () => {}, + ); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("paintBrushWithShape: 3D Sphere", async () => { + const center = new Float32Array([5, 5, 5]); + const radius = 2; + const value = 5n; + + await controller.performOperation({ + type: VoxelOperationType.BRUSH, + center, + radius, + value, + shape: BrushShape.SPHERE, + basis: { u: new Float32Array([1, 0, 0]), v: new Float32Array([0, 1, 0]) }, + }); + + await vi.runAllTimersAsync(); + + expect(mockSource.applyEdits).toHaveBeenCalledWith( + "0,0,0", + expect.any(Array), + expect.any(Array), + ); + + const call = (mockSource.applyEdits as any).mock.calls[0]; + const indices = call[1]; + const values = call[2]; + const indexSet = new Set(indices); + + const getIdx = (x: number, y: number, z: number) => z * 100 + y * 10 + x; + expect(indexSet.has(getIdx(5, 5, 5))).toBe(true); + expect(indexSet.has(getIdx(7, 5, 5))).toBe(true); + expect(indexSet.has(getIdx(8, 5, 5))).toBe(false); + expect(values[0]).toBe(5n); + }); + + it("paintBrushWithShape: 2D Disk", async () => { + const center = new Float32Array([5, 5, 5]); + const radius = 2; + const value = 3n; + const basis = { + u: new Float32Array([1, 0, 0]), + v: new Float32Array([0, 1, 0]), + }; + + await controller.performOperation({ + type: VoxelOperationType.BRUSH, + center, + radius, + value, + shape: BrushShape.DISK, + basis, + }); + + await vi.runAllTimersAsync(); + + const call = (mockSource.applyEdits as any).mock.calls[0]; + const indices = call[1]; + const values = call[2]; + const indexSet = new Set(indices); + const getIdx = (x: number, y: number, z: number) => z * 100 + y * 10 + x; + + for (const idx of indices) { + const z = Math.floor(idx / 100); + expect(z).toBe(5); + } + expect(indexSet.has(getIdx(5, 5, 5))).toBe(true); + expect(indexSet.has(getIdx(7, 5, 5))).toBe(true); + expect(values[0]).toBe(3n); + }); + + it("floodFillPlane2D: Bounded region (Bucket)", async () => { + const data = new BigUint64Array(1000); + for (let x = 3; x <= 7; x++) { + data[0 * 100 + 3 * 10 + x] = 1n; // y=3 + data[0 * 100 + 7 * 10 + x] = 1n; // y=7 + } + for (let y = 3; y <= 7; y++) { + data[0 * 100 + y * 10 + 3] = 1n; // x=3 + data[0 * 100 + y * 10 + 7] = 1n; // x=7 + } + mockSource.serverStorage.set("0,0,0", data.buffer); + + const seed = new Float32Array([5, 5, 0]); + const fillValue = 5n; + const maxVoxels = 100; + const basis = { + u: new Float32Array([1, 0, 0]), + v: new Float32Array([0, 1, 0]), + }; + + await controller.performOperation({ + type: VoxelOperationType.FLOOD_FILL, + seed, + value: fillValue, + maxVoxels, + basis, + }); + + await vi.runAllTimersAsync(); + + const call = (mockSource.applyEdits as any).mock.calls[0]; + const indices = call[1]; + expect(indices.length).toBe(9); + }); + + it("floodFillPlane2D: Plane constraint", async () => { + const data = new BigUint64Array(1000); + const z = 5; + for (let x = 3; x <= 7; x++) { + data[z * 100 + 3 * 10 + x] = 1n; + data[z * 100 + 7 * 10 + x] = 1n; + } + for (let y = 3; y <= 7; y++) { + data[z * 100 + y * 10 + 3] = 1n; + data[z * 100 + y * 10 + 7] = 1n; + } + mockSource.serverStorage.set("0,0,0", data.buffer); + + const seed = new Float32Array([5, 5, 5]); + const basis = { + u: new Float32Array([1, 0, 0]), + v: new Float32Array([0, 1, 0]), + }; + + await controller.performOperation({ + type: VoxelOperationType.FLOOD_FILL, + seed, + value: 2n, + maxVoxels: 100, + basis, + }); + + await vi.runAllTimersAsync(); + + const call = (mockSource.applyEdits as any).mock.calls[0]; + const indices = call[1]; + expect(indices.length).toBe(9); + for (const idx of indices) { + const cz = Math.floor(idx / 100); + expect(cz).toBe(5); + } + }); + + it("floodFillPlane2D: Max voxels exceeded", async () => { + const seed = new Float32Array([5, 5, 0]); + const maxVoxels = 5; + const basis = { + u: new Float32Array([1, 0, 0]), + v: new Float32Array([0, 1, 0]), + }; + + await controller.performOperation({ + type: VoxelOperationType.FLOOD_FILL, + seed, + value: 9n, + maxVoxels, + basis, + }); + + await vi.runAllTimersAsync(); + + const call = (mockSource.applyEdits as any).mock.calls[0]; + const indices = call[1]; + expect(indices.length).toBe(5); + }); + + it("floodFillPlane2D: Seed value equals fill value", async () => { + const data = new BigUint64Array(1000); + const seedIdx = 0 * 100 + 5 * 10 + 5; + data[seedIdx] = 5n; + mockSource.serverStorage.set("0,0,0", data.buffer); + + const seed = new Float32Array([5, 5, 0]); + const basis = { + u: new Float32Array([1, 0, 0]), + v: new Float32Array([0, 1, 0]), + }; + + await controller.performOperation({ + type: VoxelOperationType.FLOOD_FILL, + seed, + value: 5n, + maxVoxels: 100, + basis, + }); + + await vi.runAllTimersAsync(); + + expect(mockSource.applyEdits).not.toHaveBeenCalled(); + }); + + it("floodFillPlane2D: Leak prevention (morphological)", async () => { + (controller as any).morphologicalConfig = { + growthThresholds: [{ count: 5, size: 3 }], + maxSize: 9, + }; + + const data = new BigUint64Array(1000); + // Box 0..9 in X, Y, at Z=0. + for (let x = 0; x <= 9; x++) { + if (x !== 0) data[0 * 100 + 0 * 10 + x] = 1n; // y=0 + data[0 * 100 + 9 * 10 + x] = 1n; // y=9 + } + for (let y = 0; y <= 9; y++) { + if (y !== 5) data[0 * 100 + y * 10 + 0] = 1n; // x=0 (hole at y=5) + data[0 * 100 + y * 10 + 9] = 1n; // x=9 + } + mockSource.serverStorage.set("0,0,0", data.buffer); + + const seed = new Float32Array([5, 5, 0]); + const basis = { + u: new Float32Array([1, 0, 0]), + v: new Float32Array([0, 1, 0]), + }; + + await controller.performOperation({ + type: VoxelOperationType.FLOOD_FILL, + seed, + value: 2n, + maxVoxels: 1000, + basis, + }); + + await vi.runAllTimersAsync(); + + expect(mockSource.applyEdits).toHaveBeenCalled(); + const indices = (mockSource.applyEdits as any).mock.calls[0][1]; + expect(indices.length).toBeLessThan(100); + expect(indices.length).toBeGreaterThan(50); + }); +}); diff --git a/src/voxel_annotation/frontend.spec.ts b/src/voxel_annotation/frontend.spec.ts deleted file mode 100644 index ec987f80b0..0000000000 --- a/src/voxel_annotation/frontend.spec.ts +++ /dev/null @@ -1,342 +0,0 @@ -/** - * @license - * Copyright 2025 Google Inc. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { - BrushShape, - VOX_EDIT_COMMIT_VOXELS_RPC_ID, -} from "#src/voxel_annotation/base.js"; -import { VoxelEditController } from "#src/voxel_annotation/frontend.js"; -import type { RPC } from "#src/worker_rpc.js"; - -const mockRpc = { - invoke: vi.fn(), - newId: () => 0, - register: vi.fn(), - set: vi.fn(), - get: vi.fn(), - delete: vi.fn(), -} as unknown as RPC; - -class MockVolumeSource { - rpcId = 100; - spec = { - chunkDataSize: new Uint32Array([100, 100, 100]), - rank: 3, - }; - - dataMap = new Map(); - - getEnsuredValueAt = vi.fn(async (pos: Float32Array) => { - const key = `${Math.round(pos[0])},${Math.round(pos[1])},${Math.round(pos[2])}`; - return this.dataMap.get(key) ?? 0n; - }); - - getValueAt = vi.fn((pos: Float32Array) => { - const key = `${Math.round(pos[0])},${Math.round(pos[1])},${Math.round(pos[2])}`; - return this.dataMap.get(key) ?? 0n; - }); - - computeChunkIndices(voxelCoord: Float32Array) { - return { - chunkGridPosition: new Float32Array([0, 0, 0]), - positionWithinChunk: new Uint32Array([ - voxelCoord[0], - voxelCoord[1], - voxelCoord[2], - ]), - }; - } - - chunkToMultiscaleTransform = new Float32Array(16).fill(0); - - applyLocalEdits = vi.fn(); -} - -describe("VoxelEditController", () => { - let controller: VoxelEditController; - let mockPrimarySource: MockVolumeSource; - let mockPreviewSource: MockVolumeSource; - - beforeEach(() => { - vi.clearAllMocks(); - mockPrimarySource = new MockVolumeSource(); - mockPreviewSource = new MockVolumeSource(); - - mockPrimarySource.chunkToMultiscaleTransform[0] = 1; - mockPrimarySource.chunkToMultiscaleTransform[5] = 1; - mockPrimarySource.chunkToMultiscaleTransform[10] = 1; - mockPrimarySource.chunkToMultiscaleTransform[15] = 1; - - const host = { - rpc: mockRpc, - primarySource: { - rank: 3, - getSources: () => [ - [ - { - chunkSource: mockPrimarySource, - chunkToMultiscaleTransform: - mockPrimarySource.chunkToMultiscaleTransform, - }, - ], - ], - } as any, - previewSource: { - getSources: () => [ - [ - { - chunkSource: mockPreviewSource, - chunkToMultiscaleTransform: - mockPrimarySource.chunkToMultiscaleTransform, - }, - ], - ], - } as any, - }; - - controller = new VoxelEditController(host); - (mockRpc.invoke as any).mockClear(); - }); - - describe("paintBrushWithShape", () => { - it("paints a 3D Sphere correctly", async () => { - const center = new Float32Array([10, 10, 10]); - const radius = 2; - const value = 5n; - const getter = (_isPreview: boolean) => value; - - await controller.paintBrushWithShape( - center, - radius, - getter, - BrushShape.SPHERE, - undefined, - ); - - expect(mockRpc.invoke).toHaveBeenCalledWith( - VOX_EDIT_COMMIT_VOXELS_RPC_ID, - expect.objectContaining({ - edits: expect.any(Array), - }), - ); - - const calls = (mockRpc.invoke as any).mock.calls; - const commitCall = calls.find( - (c: any[]) => c[0] === VOX_EDIT_COMMIT_VOXELS_RPC_ID, - ); - expect(commitCall).toBeDefined(); - - const args = commitCall[1]; - const edits = args.edits; - - expect(edits.length).toBeGreaterThan(0); - const indicesSet = new Set(edits[0].indices); - - const getIdx = (x: number, y: number, z: number) => - z * 10000 + y * 100 + x; - - expect(indicesSet.has(getIdx(10, 10, 10))).toBe(true); - expect(indicesSet.has(getIdx(12, 10, 10))).toBe(true); - expect(indicesSet.has(getIdx(11, 11, 10))).toBe(true); - expect(indicesSet.has(getIdx(12, 11, 10))).toBe(false); - }); - - it("paints a 2D Disk aligned to basis vectors", async () => { - const center = new Float32Array([10, 10, 5]); - const radius = 2; - const value = 3n; - const getter = (_isPreview: boolean) => value; - const basis = { - u: new Float32Array([1, 0, 0]), - v: new Float32Array([0, 1, 0]), - }; - - await controller.paintBrushWithShape( - center, - radius, - getter, - BrushShape.DISK, - basis, - ); - - const calls = (mockRpc.invoke as any).mock.calls; - const commitCall = calls.find( - (c: any[]) => c[0] === VOX_EDIT_COMMIT_VOXELS_RPC_ID, - ); - expect(commitCall).toBeDefined(); - - const args = commitCall[1]; - const edits = args.edits; - const indices = edits[0].indices; - - for (const idx of indices) { - const z = Math.floor(idx / 10000); - expect(z).toBe(5); - } - - expect(indices.length).toBeGreaterThan(0); - }); - }); - - describe("floodFillPlane2D", () => { - it("fills a bounded region (The Bucket)", async () => { - mockPrimarySource.dataMap.clear(); - - for (let x = 1; x <= 5; x++) { - mockPrimarySource.dataMap.set(`${x},1,0`, 1n); - mockPrimarySource.dataMap.set(`${x},5,0`, 1n); - } - for (let y = 1; y <= 5; y++) { - mockPrimarySource.dataMap.set(`1,${y},0`, 1n); - mockPrimarySource.dataMap.set(`5,${y},0`, 1n); - } - - const seed = new Float32Array([3, 3, 0]); - const fillValue = 5n; - const maxVoxels = 100; - const basis = { - u: new Float32Array([1, 0, 0]), - v: new Float32Array([0, 1, 0]), - }; - - const result = await controller.floodFillPlane2D( - seed, - (_) => fillValue, - maxVoxels, - basis, - ); - - expect(result.filledCount).toBe(9); - - expect(mockRpc.invoke).toHaveBeenCalledWith( - VOX_EDIT_COMMIT_VOXELS_RPC_ID, - expect.anything(), - ); - }); - - it("respects the plane constraint", async () => { - const seed = new Float32Array([50, 50, 5]); - const maxVoxels = 20; - const basis = { - u: new Float32Array([1, 0, 0]), - v: new Float32Array([0, 1, 0]), - }; - - await expect( - controller.floodFillPlane2D(seed, (_) => 2n, maxVoxels, basis), - ).rejects.toThrow(/exceeds the limit/); - - mockPrimarySource.dataMap.set("51,50,5", 1n); - mockPrimarySource.dataMap.set("49,50,5", 1n); - mockPrimarySource.dataMap.set("50,51,5", 1n); - mockPrimarySource.dataMap.set("50,49,5", 1n); - - const result = await controller.floodFillPlane2D( - seed, - (_) => 2n, - 100, - basis, - ); - - expect(result.filledCount).toBe(1); - expect(result.edits[0].indices.length).toBe(1); - - const idx = result.edits[0].indices[0]; - const z = Math.floor(idx / 10000); - expect(z).toBe(5); - }); - - it("throws when max voxels exceeded", async () => { - const seed = new Float32Array([10, 10, 0]); - const maxVoxels = 10; - const basis = { - u: new Float32Array([1, 0, 0]), - v: new Float32Array([0, 1, 0]), - }; - - await expect( - controller.floodFillPlane2D(seed, (_) => 9n, maxVoxels, basis), - ).rejects.toThrow("Flood fill region exceeds the limit"); - }); - - it("does nothing if seed value equals fill value", async () => { - mockPrimarySource.dataMap.set("10,10,0", 5n); - const seed = new Float32Array([10, 10, 0]); - const basis = { - u: new Float32Array([1, 0, 0]), - v: new Float32Array([0, 1, 0]), - }; - - const result = await controller.floodFillPlane2D( - seed, - (_) => 5n, - 100, - basis, - ); - - expect(result.filledCount).toBe(0); - expect(result.edits.length).toBe(0); - expect(mockRpc.invoke).not.toHaveBeenCalledWith( - VOX_EDIT_COMMIT_VOXELS_RPC_ID, - expect.anything(), - ); - }); - - it("prevents leak through small gaps using morphological thickening", async () => { - // Override config to trigger thickening early - (controller as any).morphologicalConfig = { - growthThresholds: [{ count: 10, size: 3 }], - maxSize: 9, - }; - - mockPrimarySource.dataMap.clear(); - - const size = 20; - for (let i = 0; i <= size; i++) { - mockPrimarySource.dataMap.set(`0,0,${i}`, 1n); - mockPrimarySource.dataMap.set(`0,${size},${i}`, 1n); - mockPrimarySource.dataMap.set(`0,${i},0`, 1n); - if (i !== 10) { - mockPrimarySource.dataMap.set(`0,${i},${size}`, 1n); - } - } - - const seed = new Float32Array([0, 10, 10]); - const fillValue = 2n; - const maxVoxels = 2000; - const basis = { - u: new Float32Array([0, 0, 1]), - v: new Float32Array([0, 1, 0]), - }; - - const result = await controller.floodFillPlane2D( - seed, - (_) => fillValue, - maxVoxels, - basis, - ); - - expect(result.filledCount).toBeLessThan(1000); - expect(result.filledCount).toBeGreaterThan(300); - - expect(mockRpc.invoke).toHaveBeenCalledWith( - VOX_EDIT_COMMIT_VOXELS_RPC_ID, - expect.anything(), - ); - }); - }); -}); From b92370949f93a0fb5a3d9d07242573d60d6fc78a Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Fri, 9 Jan 2026 17:20:22 +0100 Subject: [PATCH 183/251] chore(voxel-annotation): format --- src/voxel_annotation/backend.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/voxel_annotation/backend.spec.ts b/src/voxel_annotation/backend.spec.ts index 9aaaa3441a..8d128d2bda 100644 --- a/src/voxel_annotation/backend.spec.ts +++ b/src/voxel_annotation/backend.spec.ts @@ -89,8 +89,7 @@ class MockBackendSource extends VolumeChunkSource { const key = chunk.chunkGridPosition.join(","); if (this.serverStorage.has(key)) { const buffer = this.serverStorage.get(key)!; - const Ctor = - DATA_TYPE_ARRAY_CONSTRUCTOR[this.spec.dataType]; + const Ctor = DATA_TYPE_ARRAY_CONSTRUCTOR[this.spec.dataType]; chunk.data = new Ctor(buffer.slice(0)); } } From 7c86fb0e3e687ffbe34276556727e6657e4e37af Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 12 Jan 2026 12:20:20 +0100 Subject: [PATCH 184/251] fix(voxel-annotation): fix `TypeError: Cannot convert undefined to a BigInt in readLocal` happening when drawing disk in an off-axis sliceview --- src/voxel_annotation/backend.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 64b5c0dd45..28378540c5 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -127,7 +127,9 @@ class BackendVoxelAccessor { this.fillValue = typeof fv === "bigint" ? fv : BigInt(fv); } - async getValue(x: number, y: number, z: number): Promise { + async getValue(point: Float32Array): Promise { + if (point.length !== 3) throw new Error("getValue: invalid point size"); + const [x, y, z] = point.map((v) => Math.round(v)); if ( x >= this.minX && x < this.maxX && @@ -957,7 +959,7 @@ export class VoxelEditController extends SharedObject { const voxelsToPaint: Float32Array[] = []; const pushIf = async (point: Float32Array) => { - const v = await accessor.getValue(point[0], point[1], point[2]); + const v = await accessor.getValue(point); if (v === null) return; if (v === value || (filterValue !== undefined && v !== filterValue)) return; @@ -1006,11 +1008,7 @@ export class VoxelEditController extends SharedObject { const accessor = new BackendVoxelAccessor(source); const startVoxelLod = vec3.round(vec3.create(), seed as vec3); - const originalValue = await accessor.getValue( - startVoxelLod[0], - startVoxelLod[1], - startVoxelLod[2], - ); + const originalValue = await accessor.getValue(startVoxelLod); if (originalValue === null) return; if (filterValue !== undefined && originalValue !== filterValue) return; @@ -1029,7 +1027,7 @@ export class VoxelEditController extends SharedObject { }; const isFillable = async (p: vec3): Promise => { - const val = await accessor.getValue(p[0], p[1], p[2]); + const val = await accessor.getValue(p); if (val === null) return false; if (originalValue === 0n) return val === 0n; return val === originalValue; From 9eb027805ae3c897600654a9e4174bc63f4b0b84 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 12 Jan 2026 15:31:31 +0100 Subject: [PATCH 185/251] feat(voxel-annotation): update tool cursors SVGs: added a crosshair and offset the icon to allow for a more accurate cursor (for floodfill and seg picker) --- src/ui/voxel_annotations.ts | 48 ++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index a4bdadb9e6..86ecefa500 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -414,28 +414,34 @@ export class VoxelFloodFillTool extends BaseVoxelTool { const lightColor = this.cursorEraseMode.value ? "#FF8888" : "#FFFFFF"; const darkColor = this.cursorEraseMode.value ? "#610000" : "#000000"; - const floodFillSVG = - ` - + + - - - - - + + + + + + `.replace(/\s\s+/g, " "); - return `url('data:image/svg+xml;utf8,${encodeURIComponent(floodFillSVG)}') 4 19, crosshair`; + return `url('data:image/svg+xml;utf8,${encodeURIComponent(floodFillSVG)}') 24 24, crosshair`; } activate(activation: ToolActivation) { @@ -519,15 +525,23 @@ export class VoxelFloodFillTool extends BaseVoxelTool { } } -const pickerSVG = ` - - - - - +const pickerSVG = ` + + + + + + + + + + + + + `; -const pickerCursor = `url('data:image/svg+xml;utf8,${encodeURIComponent(pickerSVG)}') 4 19, crosshair`; +const pickerCursor = `url('data:image/svg+xml;utf8,${encodeURIComponent(pickerSVG)}') 24 24, crosshair`; export class AdoptVoxelValueTool extends LayerTool { private lastPickPosition: Float32Array | undefined; From 6b01299b2445c0a22dee17c58604cc0cb3b922b4 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 12 Jan 2026 15:37:45 +0100 Subject: [PATCH 186/251] feat(voxel-annotation): adjust cursors aesthetic --- src/rendered_data_panel.ts | 2 +- src/ui/voxel_annotations.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/rendered_data_panel.ts b/src/rendered_data_panel.ts index 06ea72e342..27e117e67f 100644 --- a/src/rendered_data_panel.ts +++ b/src/rendered_data_panel.ts @@ -859,7 +859,7 @@ export abstract class RenderedDataPanel extends RenderedPanel { ctx.strokeStyle = isEraser ? "rgb(255,136,136)" : "rgba(255, 255, 255, 1)"; - ctx.lineWidth = 3; + ctx.lineWidth = 4; ctx.stroke(); ctx.strokeStyle = isEraser ? "rgb(97,0,0)" : "rgba(0, 0, 0, 1)"; ctx.lineWidth = 1.5; diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 86ecefa500..e3c3b7a797 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -418,12 +418,12 @@ export class VoxelFloodFillTool extends BaseVoxelTool { + stroke="${lightColor}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/> + stroke="${lightColor}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/> + stroke="${lightColor}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/> @@ -528,8 +528,8 @@ export class VoxelFloodFillTool extends BaseVoxelTool { const pickerSVG = ` - - + + From ab17db4c48a46be9af62904a991ebb9d1f7a8f6b Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 12 Jan 2026 16:45:16 +0100 Subject: [PATCH 187/251] feat(voxel-annotation): adjust the brush radius calculation minimum to allow drawing single voxels --- src/layer/voxel_annotation/controls.ts | 2 +- src/voxel_annotation/backend.ts | 3 ++- src/voxel_annotation/frontend.ts | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/layer/voxel_annotation/controls.ts b/src/layer/voxel_annotation/controls.ts index c379da70ab..381533e197 100644 --- a/src/layer/voxel_annotation/controls.ts +++ b/src/layer/voxel_annotation/controls.ts @@ -108,7 +108,7 @@ export function updateBrushOutline( const { u: u_chunk, v: v_chunk } = getBasisFromNormal(n_chunk); - const radius = layer.brushRadius.value; + const radius = layer.brushRadius.value - 0.5; vec3.scale(u_chunk, u_chunk, radius); vec3.scale(v_chunk, v_chunk, radius); diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 28378540c5..e14846f7cc 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -952,8 +952,9 @@ export class VoxelEditController extends SharedObject { const cx = Math.round((center[0] ?? 0) / voxelSize); const cy = Math.round((center[1] ?? 0) / voxelSize); const cz = Math.round((center[2] ?? 0) / voxelSize); - const r = Math.round(radius / voxelSize); + let r = Math.round(radius / voxelSize); if (r <= 0) throw new Error(`Brush radius must be positive.`); + r -= 1; const rr = r * r; const voxelsToPaint: Float32Array[] = []; diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index ecfbb92053..c7c1297764 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -139,10 +139,11 @@ export class VoxelEditController extends SharedObject { const cx = Math.round((centerCanonical[0] ?? 0) / voxelSize); const cy = Math.round((centerCanonical[1] ?? 0) / voxelSize); const cz = Math.round((centerCanonical[2] ?? 0) / voxelSize); - const r = Math.round(radiusCanonical / voxelSize); + let r = Math.round(radiusCanonical / voxelSize); if (r <= 0) { throw new Error("Brush radius must be positive."); } + r -= 1; const rr = r * r; const { u, v } = basis as { u: vec3; v: vec3 }; const n = vec3.create(); From e02b42439e31199e4e65d0de7a5745c2b712e68e Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Tue, 13 Jan 2026 17:06:11 +0100 Subject: [PATCH 188/251] feat(voxel-annotation): add stamina bar --- src/layer/voxel_annotation/controls.ts | 98 +++++++-------- src/layer/voxel_annotation/index.ts | 166 ++++++++++++++++++++++--- src/rendered_data_panel.ts | 56 ++++----- src/ui/voxel_annotations.ts | 27 ++-- src/voxel_annotation/TODOs.md | 7 ++ src/voxel_annotation/backend.ts | 16 ++- src/voxel_annotation/base.ts | 2 + src/voxel_annotation/frontend.ts | 19 ++- 8 files changed, 265 insertions(+), 126 deletions(-) diff --git a/src/layer/voxel_annotation/controls.ts b/src/layer/voxel_annotation/controls.ts index 381533e197..c878bdaadc 100644 --- a/src/layer/voxel_annotation/controls.ts +++ b/src/layer/voxel_annotation/controls.ts @@ -20,9 +20,8 @@ import type { UserLayerWithVoxelEditing, VoxelEditingContext, } from "#src/layer/voxel_annotation/index.js"; -import { RenderedDataPanel } from "#src/rendered_data_panel.js"; +import type { RenderedDataPanel } from "#src/rendered_data_panel.js"; import { SliceViewPanel } from "#src/sliceview/panel.js"; -import { StatusMessage } from "#src/status.js"; import { observeWatchable } from "#src/trackable_value.js"; import { mat3, vec3 } from "#src/util/geom.js"; import { @@ -38,22 +37,6 @@ import { checkboxLayerControl } from "#src/widget/layer_control_checkbox.js"; import { enumLayerControl } from "#src/widget/layer_control_enum.js"; import { rangeLayerControl } from "#src/widget/layer_control_range.js"; -export function getActivePanel( - layer: UserLayerWithVoxelEditing, -): RenderedDataPanel | undefined { - let activePanel: RenderedDataPanel | undefined; - for (const panel of layer.manager.root.display.panels) { - if (panel instanceof RenderedDataPanel) { - if (panel.mouseX !== -1 && panel instanceof SliceViewPanel) { - activePanel = panel; - } else { - panel.clearOverlay(); - } - } - } - return activePanel; -} - export function getEditingContext( layer: UserLayerWithVoxelEditing, ): VoxelEditingContext | undefined { @@ -65,22 +48,13 @@ export function getEditingContext( return undefined; } -export function updateBrushOutline( +export function drawBrushCursor( layer: UserLayerWithVoxelEditing, - eraseMode: boolean, + panel: RenderedDataPanel, + ctx: CanvasRenderingContext2D, ) { const context = getEditingContext(layer); - if (context === undefined) { - StatusMessage.showTemporaryMessage( - 'Voxel editing is not available. Please select a writable volume source in the "Source" tab.', - 5000, - ); - return; - } - - const panel = getActivePanel(layer); - if (!panel || !(panel instanceof SliceViewPanel)) { - if (panel) panel.clearOverlay(); + if (context === undefined || !(panel instanceof SliceViewPanel)) { return; } @@ -89,13 +63,11 @@ export function updateBrushOutline( const { displayRank } = displayDimensionRenderInfo; if (displayRank < 2) { - panel.clearOverlay(); return; } const chunkTransform = context.getChunkTransform(); if (!chunkTransform) { - panel.clearOverlay(); return; } const { chunkToLayerTransform, layerRank } = chunkTransform; @@ -158,16 +130,35 @@ export function updateBrushOutline( const rotation = Math.atan2(lambda1 - Q11, Q12); - panel.drawBrushCursor( - panel.mouseX, - panel.mouseY, - radiusX, - radiusY, - rotation, - "white", - eraseMode, - ); + if (radiusX > 0 && radiusY > 0) { + ctx.save(); + ctx.beginPath(); + ctx.ellipse( + panel.mouseX, + panel.mouseY, + radiusX, + radiusY, + rotation, + 0, + 2 * Math.PI, + ); + ctx.restore(); + + const isEraser = layer.shouldErase(); + const color = "white"; + ctx.fillStyle = isEraser ? "red" : color; + ctx.globalAlpha = 0.2; + ctx.fill(); + ctx.globalAlpha = 1; + ctx.strokeStyle = isEraser ? "rgb(255,136,136)" : "rgba(255, 255, 255, 1)"; + ctx.lineWidth = 4; + ctx.stroke(); + ctx.strokeStyle = isEraser ? "rgb(97,0,0)" : "rgba(0, 0, 0, 1)"; + ctx.lineWidth = 1.5; + ctx.stroke(); + } } + export type VoxelTabElement = | { type: "header"; label: string } | { type: "tool-row"; tools: { toolId: string; label: string }[] } @@ -185,28 +176,27 @@ const TOOL_SPECIFIC_CONTROLS: LayerControlDefinition[ options: { min: 1, max: 64, step: 1 }, }), ); - const originalActivateTool = control.activateTool; return { ...control, - activateTool: (activation, controlContext) => { - originalActivateTool(activation, controlContext as any); - + activateTool: (activation, _controlContext) => { const layer = activation.tool.layer as UserLayerWithVoxelEditing; - const updateCursor = () => { - updateBrushOutline(layer, layer.shouldErase()); + const trigger = () => { + for (const panel of layer.manager.root.display.panels) { + if (panel instanceof SliceViewPanel) { + panel.scheduleOverlayRedraw(); + } + } }; - updateCursor(); + trigger(); activation.registerDisposer( layer.manager.root.layerSelectedValues.mouseState.changed.add( - updateCursor, + trigger, ), ); - activation.registerDisposer( - layer.brushRadius.changed.add(updateCursor), - ); + activation.registerDisposer(layer.brushRadius.changed.add(trigger)); activation.registerDisposer(() => { - getActivePanel(layer)?.clearOverlay(); + trigger(); }); }, }; diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index bf58efdfcc..c8c069aaca 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -20,6 +20,7 @@ import type { } from "#src/layer/index.js"; import { UserLayer } from "#src/layer/index.js"; import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; +import { drawBrushCursor } from "#src/layer/voxel_annotation/controls.js"; import { VoxToolTab } from "#src/layer/voxel_annotation/draw_tab.js"; import type { ChunkTransformParameters, @@ -29,11 +30,13 @@ import { getChunkPositionFromCombinedGlobalLocalPositions, getChunkTransformParameters, } from "#src/render_coordinate_transform.js"; +import type { RenderedDataPanel } from "#src/rendered_data_panel.js"; import type { SliceViewSourceOptions, SliceViewRenderLayer, } from "#src/sliceview/base.js"; import { DataType } from "#src/sliceview/base.js"; +import { SliceViewPanel } from "#src/sliceview/panel.js"; import type { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; import type { ImageRenderLayer } from "#src/sliceview/volume/image_renderlayer.js"; import { SegmentationRenderLayer } from "#src/sliceview/volume/segmentation_renderlayer.js"; @@ -61,7 +64,11 @@ import type { VoxelEditControllerHost, VoxelValueGetter, } from "#src/voxel_annotation/base.js"; -import { BrushShape } from "#src/voxel_annotation/base.js"; +import { + BRUSH_TOOL_ID, + BrushShape, + MAX_VOXEL_EDIT_CAPACITY, +} from "#src/voxel_annotation/base.js"; import { VoxelEditController } from "#src/voxel_annotation/frontend.js"; const BRUSH_SIZE_JSON_KEY = "brushSize"; @@ -97,6 +104,9 @@ export class VoxelEditingContext | undefined = undefined; previewSource: VoxelPreviewMultiscaleSource | undefined = undefined; + private localLoadEstimate = new WatchableValue(0); + public totalPending: WatchableValueInterface; + constructor( public hostLayer: UserLayerWithVoxelEditing, public primarySource: MultiscaleVolumeChunkSource, @@ -145,6 +155,14 @@ export class VoxelEditingContext this.hostLayer.addRenderLayer(this.optimisticRenderLayer); this._controller = new VoxelEditController(this); + + this.totalPending = this.registerDisposer( + makeDerivedWatchableValue( + (local, backend) => local + backend, + this.localLoadEstimate, + this._controller.pendingOpCount, + ), + ); } private async checkPermission(): Promise { @@ -183,6 +201,20 @@ export class VoxelEditingContext return false; // this._pendingPermissionPromise; } + private async withCost( + cost: number, + op: () => Promise, + ): Promise { + this.localLoadEstimate.value += cost; + try { + if (await this.checkPermission()) { + return await op(); + } + } finally { + this.localLoadEstimate.value -= cost; + } + } + async paintBrushWithShape( centerCanonical: Float32Array, radiusCanonical: number, @@ -193,16 +225,17 @@ export class VoxelEditingContext ) { if (!this._controller) throw new Error("Cannot use paintBrushWithShape without a controller"); - if (await this.checkPermission()) { - await this._controller.paintBrushWithShape( + const cost = radiusCanonical * (shape === BrushShape.DISK ? 0.1 : 0.5); + await this.withCost(cost, () => + this._controller!.paintBrushWithShape( centerCanonical, radiusCanonical, value, shape, basis, filterValue, - ); - } + ), + ); } async floodFillPlane2D( @@ -214,31 +247,27 @@ export class VoxelEditingContext ) { if (!this._controller) throw new Error("Cannot use floodFillPlane2D without a controller"); - if (await this.checkPermission()) { - await this._controller.floodFillPlane2D( + await this.withCost(20, () => + this._controller!.floodFillPlane2D( startPositionCanonical, fillValue, maxVoxels, basis, filterValue, - ); - } + ), + ); } async undo() { if (!this._controller) throw new Error("Cannot use undo without a controller"); - if (await this.checkPermission()) { - this._controller.undo(); - } + await this.withCost(5, () => this._controller!.undo()); } async redo() { if (!this._controller) throw new Error("Cannot use redo without a controller"); - if (await this.checkPermission()) { - this._controller.redo(); - } + await this.withCost(5, () => this._controller!.redo()); } get rpc() { @@ -454,6 +483,34 @@ export function UserLayerWithVoxelEditingMixin< this.floodMaxVoxels.changed.add(this.specificationChanged.dispatch); this.paintValue.changed.add(this.specificationChanged.dispatch); + this.bindOverlayToPanels(); + this.registerDisposer( + this.manager.root.display.updateStarted.add(() => + this.bindOverlayToPanels(), + ), + ); + + const trigger = () => { + for (const panel of this.manager.root.display.panels) { + if (panel instanceof SliceViewPanel) { + panel.scheduleOverlayRedraw(); + } + } + }; + + this.brushRadius.changed.add(trigger); + this.manager.root.layerSelectedValues.mouseState.changed.add(trigger); + + this.layersChanged.add(() => { + const ctx = this.editingContexts.values().next().value as + | VoxelEditingContext + | undefined; + if (ctx) { + this.registerDisposer(ctx.totalPending.changed.add(trigger)); + } + }); + trigger(); + this.tabs.add("Draw", { label: "Draw", order: 20, @@ -465,6 +522,85 @@ export function UserLayerWithVoxelEditingMixin< }); } + private boundPanelCleanups = new Map void>(); + private bindOverlayToPanels() { + for (const panel of this.manager.root.display.panels) { + if ( + panel instanceof SliceViewPanel && + !this.boundPanelCleanups.has(panel) + ) { + const rm = panel.overlayDraw.add((ctx, _w, _h, p) => + this.handleOverlayDraw(ctx, p), + ); + this.boundPanelCleanups.set(panel, rm); + } + } + for (const [panel, cleanup] of this.boundPanelCleanups) { + if (!this.manager.root.display.panels.has(panel)) { + cleanup(); + this.boundPanelCleanups.delete(panel); + } + } + } + + private handleOverlayDraw( + ctx: CanvasRenderingContext2D, + panel: RenderedDataPanel, + ) { + if (panel.mouseX < 0 || panel.mouseY < 0) { + return; + } + const globalToolBinder = this.manager.root.toolBinder; + const activation = globalToolBinder.activeTool_; + let isBrushActive = false; + + if ( + activation && + activation.tool.localBinder === this.toolBinder && + activation.tool.toJSON() === BRUSH_TOOL_ID + ) { + drawBrushCursor(this, panel, ctx); + isBrushActive = true; + } + + const editContext = this.editingContexts.values().next().value as + | VoxelEditingContext + | undefined; + const pending = editContext?.totalPending.value || 0; + this.drawStaminaBar( + ctx, + panel.mouseX, + panel.mouseY, + pending, + isBrushActive, + ); + } + + private drawStaminaBar( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + pendingCount: number, + isBrushActive: boolean, + ) { + const ratio = Math.max(0, 1.0 - pendingCount / MAX_VOXEL_EDIT_CAPACITY); + if (ratio > 0.99) { + return; + } + + const w = 60; + const h = 6; + // TODO: use the radiusY of updateBrushCursor to properly offset + const yOffset = 40 + (isBrushActive ? this.brushRadius.value : 0); + const bx = x - w / 2; + const by = y + yOffset; + + ctx.fillStyle = "rgba(0,0,0,0.5)"; + ctx.fillRect(bx, by, w, h); + ctx.fillStyle = ratio > 0.3 ? "#00FF00" : "#FF0000"; + ctx.fillRect(bx, by, w * ratio, h); + } + setEraseState(erase: boolean): void { this._isInEraseState = erase; } diff --git a/src/rendered_data_panel.ts b/src/rendered_data_panel.ts index 27e117e67f..6cdc1d394d 100644 --- a/src/rendered_data_panel.ts +++ b/src/rendered_data_panel.ts @@ -40,6 +40,7 @@ import { KeyboardEventBinder } from "#src/util/keyboard_bindings.js"; import * as matrix from "#src/util/matrix.js"; import { MouseEventBinder } from "#src/util/mouse_bindings.js"; import { startRelativeMouseDrag } from "#src/util/mouse_drag.js"; +import { Signal } from "#src/util/signal.js"; import type { TouchPinchInfo, TouchTranslateInfo, @@ -833,46 +834,33 @@ export abstract class RenderedDataPanel extends RenderedPanel { }); } - drawBrushCursor( - x: number, - y: number, - radiusX: number, - radiusY: number, - rotation: number, - color: string, - isEraser: boolean, - ) { - const ctx = this.overlay_context; - const { logicalWidth, logicalHeight } = this.renderViewport; - - ctx.clearRect(0, 0, logicalWidth, logicalHeight); - - if (radiusX > 0 && radiusY > 0) { - ctx.save(); - ctx.beginPath(); - ctx.ellipse(x, y, radiusX, radiusY, rotation, 0, 2 * Math.PI); - ctx.restore(); - ctx.fillStyle = isEraser ? "red" : color; - ctx.globalAlpha = 0.2; - ctx.fill(); - ctx.globalAlpha = 1; - ctx.strokeStyle = isEraser - ? "rgb(255,136,136)" - : "rgba(255, 255, 255, 1)"; - ctx.lineWidth = 4; - ctx.stroke(); - ctx.strokeStyle = isEraser ? "rgb(97,0,0)" : "rgba(0, 0, 0, 1)"; - ctx.lineWidth = 1.5; - ctx.stroke(); + overlayDraw = new Signal< + ( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + panel: RenderedDataPanel, + ) => void + >(); + + scheduleOverlayRedraw() { + if (this.visible) { + requestAnimationFrame(this.drawOverlayInternal.bind(this)); } } - clearOverlay() { + private drawOverlayInternal() { this.overlay_context.clearRect( 0, 0, - this.overlay_canvas.width, - this.overlay_canvas.height, + this.renderViewport.logicalWidth, + this.renderViewport.logicalHeight, + ); + this.overlayDraw.dispatch( + this.overlay_context, + this.renderViewport.logicalWidth, + this.renderViewport.logicalHeight, + this, ); } diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index e3c3b7a797..4cca2f61c6 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -18,13 +18,12 @@ import "#src/ui/voxel_annotations.css"; import type { MouseSelectionState } from "#src/layer/index.js"; import { - getActivePanel, getEditingContext, - updateBrushOutline, VOXEL_LAYER_CONTROLS, } from "#src/layer/voxel_annotation/controls.js"; import type { UserLayerWithVoxelEditing } from "#src/layer/voxel_annotation/index.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; +import { SliceViewPanel } from "#src/sliceview/panel.js"; import { StatusMessage } from "#src/status.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; import { @@ -262,22 +261,20 @@ export class VoxelBrushTool extends BaseVoxelTool { activate(activation: ToolActivation): boolean { if (!super.activate(activation)) return false; - updateBrushOutline(this.layer, this.cursorEraseMode.value); - - activation.registerDisposer( - this.cursorEraseMode.changed.add(() => { - updateBrushOutline(this.layer, this.cursorEraseMode.value); - }), - ); + const trigger = () => { + for (const panel of this.layer.manager.root.display.panels) { + if (panel instanceof SliceViewPanel) { + panel.scheduleOverlayRedraw(); + } + } + }; + trigger(); + activation.registerDisposer(this.cursorEraseMode.changed.add(trigger)); activation.registerDisposer(() => { - getActivePanel(this.layer)?.clearOverlay(); + trigger(); this.resetCursor(); }); - activation.registerDisposer( - this.mouseState.changed.add(() => { - updateBrushOutline(this.layer, this.cursorEraseMode.value); - }), - ); + activation.registerDisposer(this.mouseState.changed.add(trigger)); return true; } diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 1de8019421..d613d116cd 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -1,7 +1,14 @@ ## TODOs +FOR TOMORROW: the "compute load indicator" + ### priority +- add `ctrl + middleclick` to flood fill when the brush is active +- rework the sphere/disk calculation to only calculate the difference between the new sphree/disk and the last one +- optimize spheres using the full chunk +- see about the list of pending edits for the preview + ### later ### questionable diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index e14846f7cc..3aa974a1ec 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -15,6 +15,7 @@ */ import { ChunkState } from "#src/chunk_manager/base.js"; +import type { SharedWatchableValue } from "#src/shared_watchable_value.js"; import { DataType } from "#src/sliceview/base.js"; import { decodeChannel as decodeChannelUint32 } from "#src/sliceview/compressed_segmentation/decode_uint32.js"; import { decodeChannel as decodeChannelUint64 } from "#src/sliceview/compressed_segmentation/decode_uint64.js"; @@ -226,6 +227,12 @@ export class VoxelEditController extends SharedObject { values?: ArrayLike; size?: number[]; }[] = []; + private _inFlightCount = 0; + public pendingOpCount: SharedWatchableValue; + + private updatePendingCount() { + this.pendingOpCount.value = this.pendingEdits.length + this._inFlightCount; + } private commitDebounceTimer: number | undefined; private readonly commitDebounceDelayMs: number = 300; @@ -250,6 +257,7 @@ export class VoxelEditController extends SharedObject { constructor(rpc: RPC, options: any) { super(); + this.pendingOpCount = rpc.get(options.pendingOpCount); initializeSharedObjectCounterpart(this, rpc, options); const passedResolutions = options?.resolutions as @@ -290,6 +298,7 @@ export class VoxelEditController extends SharedObject { private async flushPending(): Promise { const edits = this.pendingEdits; this.pendingEdits = []; + this.updatePendingCount(); this.commitDebounceTimer = undefined; if (edits.length === 0) { // Even if nothing to flush, history sizes may not have changed. @@ -399,7 +408,7 @@ export class VoxelEditController extends SharedObject { } } - async commitVoxels( + commitVoxels( edits: { key: string; indices: number[] | Uint32Array; @@ -416,6 +425,7 @@ export class VoxelEditController extends SharedObject { } this.pendingEdits.push(e); } + this.updatePendingCount(); if (this.commitDebounceTimer !== undefined) clearTimeout(this.commitDebounceTimer); this.commitDebounceTimer = setTimeout(() => { @@ -1240,13 +1250,13 @@ export class VoxelEditController extends SharedObject { for (const [voxKey, indices] of indicesByVoxKey.entries()) { backendEdits.push({ key: voxKey, indices, value }); } - await this.commitVoxels(backendEdits); + this.commitVoxels(backendEdits); } } registerRPC(VOX_EDIT_COMMIT_VOXELS_RPC_ID, function (x: any) { const obj = this.get(x.rpcId) as VoxelEditController; - void obj.commitVoxels(Array.isArray(x.edits) ? x.edits : []); + obj.commitVoxels(Array.isArray(x.edits) ? x.edits : []); }); registerPromiseRPC(VOX_EDIT_UNDO_RPC_ID, async function (this: RPC, x: any) { diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index 3c614fbe68..4f7b64a2b5 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -66,6 +66,8 @@ export const SEG_PICKER_TOOL_ID = "vox-seg-picker"; // Special value used to indicate to the optimistic renderer that a voxel has been erased export const SEG_ERASE_SENTINEL = ~1n; +export const MAX_VOXEL_EDIT_CAPACITY = 200; + export type VoxelValueGetter = (isPreview: boolean) => bigint; export interface VoxelLayerResolution { diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index c7c1297764..8d55306558 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -15,6 +15,7 @@ */ import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; +import { SharedWatchableValue } from "#src/shared_watchable_value.js"; import type { InMemoryVolumeChunkSource, VolumeChunkSource, @@ -50,6 +51,7 @@ import { export class VoxelEditController extends SharedObject { public undoCount = new WatchableValue(0); public redoCount = new WatchableValue(0); + public pendingOpCount: SharedWatchableValue; constructor(private host: VoxelEditControllerHost) { super(); @@ -88,7 +90,14 @@ export class VoxelEditController extends SharedObject { }); } - this.initializeCounterpart(rpc, { resolutions }); + this.pendingOpCount = this.registerDisposer( + SharedWatchableValue.make(this.host.rpc, 0), + ); + + this.initializeCounterpart(rpc, { + resolutions, + pendingOpCount: this.pendingOpCount.rpcId, + }); } private async dispatchOperation(operation: VoxelOperation) { @@ -310,10 +319,10 @@ export class VoxelEditController extends SharedObject { } } - public undo(): void { + public async undo() { if (!this.rpc) throw new Error("VoxelEditController.undo: RPC not initialized."); - this.rpc + await this.rpc .promiseInvoke(VOX_EDIT_UNDO_RPC_ID, { rpcId: this.rpcId }) .catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); @@ -321,10 +330,10 @@ export class VoxelEditController extends SharedObject { }); } - public redo(): void { + public async redo() { if (!this.rpc) throw new Error("VoxelEditController.redo: RPC not initialized."); - this.rpc + await this.rpc .promiseInvoke(VOX_EDIT_REDO_RPC_ID, { rpcId: this.rpcId }) .catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); From 66f52f2d72239996fda5666fbfc08035cb0922de Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Tue, 13 Jan 2026 18:42:41 +0100 Subject: [PATCH 189/251] feat(voxel-annotation): refine stamina computations and ui --- src/layer/voxel_annotation/controls.ts | 8 +++- src/layer/voxel_annotation/index.ts | 61 +++++++++++++++++++++----- src/voxel_annotation/backend.ts | 3 +- src/voxel_annotation/base.ts | 5 ++- 4 files changed, 62 insertions(+), 15 deletions(-) diff --git a/src/layer/voxel_annotation/controls.ts b/src/layer/voxel_annotation/controls.ts index c878bdaadc..9e19938338 100644 --- a/src/layer/voxel_annotation/controls.ts +++ b/src/layer/voxel_annotation/controls.ts @@ -26,6 +26,8 @@ import { observeWatchable } from "#src/trackable_value.js"; import { mat3, vec3 } from "#src/util/geom.js"; import { BRUSH_TOOL_ID, + FLOODFILL_MAX_POSSIBLE_VOXELS, + FLOODFILL_MIN_POSSIBLE_VOXELS, FLOODFILL_TOOL_ID, getBasisFromNormal, SEG_PICKER_TOOL_ID, @@ -214,7 +216,11 @@ const TOOL_SPECIFIC_CONTROLS: LayerControlDefinition[ toolJson: { type: "vox-flood-max-voxels" }, ...rangeLayerControl((layer) => ({ value: layer.floodMaxVoxels, - options: { min: 1, max: 1000000, step: 1000 }, + options: { + min: FLOODFILL_MIN_POSSIBLE_VOXELS, + max: FLOODFILL_MAX_POSSIBLE_VOXELS, + step: FLOODFILL_MIN_POSSIBLE_VOXELS, + }, })), }, ]; diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index c8c069aaca..b2ac1ed07a 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -65,6 +65,8 @@ import type { VoxelValueGetter, } from "#src/voxel_annotation/base.js"; import { + FLOODFILL_MAX_POSSIBLE_VOXELS, + FLOODFILL_MIN_POSSIBLE_VOXELS, BRUSH_TOOL_ID, BrushShape, MAX_VOXEL_EDIT_CAPACITY, @@ -205,6 +207,7 @@ export class VoxelEditingContext cost: number, op: () => Promise, ): Promise { + if (this.localLoadEstimate.value >= MAX_VOXEL_EDIT_CAPACITY) return; this.localLoadEstimate.value += cost; try { if (await this.checkPermission()) { @@ -225,7 +228,7 @@ export class VoxelEditingContext ) { if (!this._controller) throw new Error("Cannot use paintBrushWithShape without a controller"); - const cost = radiusCanonical * (shape === BrushShape.DISK ? 0.1 : 0.5); + const cost = radiusCanonical * (shape === BrushShape.DISK ? 0.05 : 0.5); await this.withCost(cost, () => this._controller!.paintBrushWithShape( centerCanonical, @@ -247,7 +250,11 @@ export class VoxelEditingContext ) { if (!this._controller) throw new Error("Cannot use floodFillPlane2D without a controller"); - await this.withCost(20, () => + const cost = + 50 + + (500 * (maxVoxels - FLOODFILL_MIN_POSSIBLE_VOXELS)) / + (FLOODFILL_MAX_POSSIBLE_VOXELS - FLOODFILL_MIN_POSSIBLE_VOXELS); + await this.withCost(cost, () => this._controller!.floodFillPlane2D( startPositionCanonical, fillValue, @@ -261,13 +268,13 @@ export class VoxelEditingContext async undo() { if (!this._controller) throw new Error("Cannot use undo without a controller"); - await this.withCost(5, () => this._controller!.undo()); + await this.withCost(100, () => this._controller!.undo()); } async redo() { if (!this._controller) throw new Error("Cannot use redo without a controller"); - await this.withCost(5, () => this._controller!.redo()); + await this.withCost(100, () => this._controller!.redo()); } get rpc() { @@ -588,17 +595,47 @@ export function UserLayerWithVoxelEditingMixin< return; } - const w = 60; - const h = 6; + const w = 42; + const h = 5; + const radius = h / 2; + // TODO: use the radiusY of updateBrushCursor to properly offset - const yOffset = 40 + (isBrushActive ? this.brushRadius.value : 0); - const bx = x - w / 2; + const yOffset = 16 + (isBrushActive ? this.brushRadius.value * 2 : 0); + const xOffset = 1; + const bx = x - w / 2 + xOffset; const by = y + yOffset; - ctx.fillStyle = "rgba(0,0,0,0.5)"; - ctx.fillRect(bx, by, w, h); - ctx.fillStyle = ratio > 0.3 ? "#00FF00" : "#FF0000"; - ctx.fillRect(bx, by, w * ratio, h); + ctx.save(); + + if (ratio === 0) { + const [text, textX, textY] = ["WAIT!", x + xOffset, by + 8]; + ctx.fillStyle = "#ff0000"; + ctx.textAlign = "center"; + ctx.font = "bold 16px monospace"; + ctx.lineWidth = 3; + ctx.strokeStyle = "rgba(0, 0, 0, 0.8)"; + ctx.lineJoin = "round"; + ctx.strokeText(text, textX, textY); + ctx.fillText(text, textX, textY); + } else { + ctx.beginPath(); + ctx.moveTo(bx + radius, by); + ctx.lineTo(bx + w - radius, by); + ctx.arcTo(bx + w, by, bx + w, by + h, radius); + ctx.arcTo(bx + w, by + h, bx, by + h, radius); + ctx.arcTo(bx, by + h, bx, by, radius); + ctx.arcTo(bx, by, bx + w, by, radius); + ctx.closePath(); + + ctx.fillStyle = "rgba(159,159,159,0.5)"; + ctx.fill(); + + ctx.clip(); + + ctx.fillStyle = ratio > 0.3 ? "#5e5e5e" : "#FF0000"; + ctx.fillRect(bx, by, w * ratio, h); + } + ctx.restore(); } setEraseState(erase: boolean): void { diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 3aa974a1ec..59de19b665 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -1121,7 +1121,8 @@ export class VoxelEditController extends SharedObject { visited.add("0,0"); while (queue.length > 0) { - if (filledCount >= maxVoxels) break; // Or throw error + if (filledCount >= maxVoxels) + throw new Error(`Flood fill failed: too many voxels filled.`); const [u, v] = queue.shift()!; const currentPoint = map2dTo3d(u, v); filledCount++; diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index 4f7b64a2b5..d9bcba5190 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -29,6 +29,9 @@ export const VOX_EDIT_HISTORY_UPDATE_RPC_ID = "vox.edit.historyUpdate"; export const VOX_EDIT_OPERATION_RPC_ID = "vox.edit.operation"; +export const FLOODFILL_MAX_POSSIBLE_VOXELS = 1000000; +export const FLOODFILL_MIN_POSSIBLE_VOXELS = 1000; + export enum VoxelOperationType { BRUSH = 0, FLOOD_FILL = 1, @@ -66,7 +69,7 @@ export const SEG_PICKER_TOOL_ID = "vox-seg-picker"; // Special value used to indicate to the optimistic renderer that a voxel has been erased export const SEG_ERASE_SENTINEL = ~1n; -export const MAX_VOXEL_EDIT_CAPACITY = 200; +export const MAX_VOXEL_EDIT_CAPACITY = 1000; export type VoxelValueGetter = (isPreview: boolean) => bigint; From 550b323ee35e5a8a51049dabdd89e0648ff564f9 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Tue, 13 Jan 2026 18:59:14 +0100 Subject: [PATCH 190/251] fix(voxel-annotation): ensure cursor overlay is only drawn when a writable source is present --- src/layer/voxel_annotation/index.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index b2ac1ed07a..55790f1ca7 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -20,7 +20,10 @@ import type { } from "#src/layer/index.js"; import { UserLayer } from "#src/layer/index.js"; import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; -import { drawBrushCursor } from "#src/layer/voxel_annotation/controls.js"; +import { + drawBrushCursor, + getEditingContext, +} from "#src/layer/voxel_annotation/controls.js"; import { VoxToolTab } from "#src/layer/voxel_annotation/draw_tab.js"; import type { ChunkTransformParameters, @@ -509,9 +512,7 @@ export function UserLayerWithVoxelEditingMixin< this.manager.root.layerSelectedValues.mouseState.changed.add(trigger); this.layersChanged.add(() => { - const ctx = this.editingContexts.values().next().value as - | VoxelEditingContext - | undefined; + const ctx = getEditingContext(this); if (ctx) { this.registerDisposer(ctx.totalPending.changed.add(trigger)); } @@ -554,7 +555,11 @@ export function UserLayerWithVoxelEditingMixin< ctx: CanvasRenderingContext2D, panel: RenderedDataPanel, ) { - if (panel.mouseX < 0 || panel.mouseY < 0) { + if ( + panel.mouseX < 0 || + panel.mouseY < 0 || + !this.hasSubsourcesWithWritingEnabled.value + ) { return; } const globalToolBinder = this.manager.root.toolBinder; @@ -570,9 +575,7 @@ export function UserLayerWithVoxelEditingMixin< isBrushActive = true; } - const editContext = this.editingContexts.values().next().value as - | VoxelEditingContext - | undefined; + const editContext = getEditingContext(this); const pending = editContext?.totalPending.value || 0; this.drawStaminaBar( ctx, From 622410a74d79043904c3bb63f9be93c30b1936cf Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 14 Jan 2026 14:29:20 +0100 Subject: [PATCH 191/251] test(voxel-annotation): adapt backend.spec.ts to the latest updates --- src/voxel_annotation/TODOs.md | 2 -- src/voxel_annotation/backend.spec.ts | 45 ++++++++++++++-------------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index d613d116cd..f724d65ccc 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -1,7 +1,5 @@ ## TODOs -FOR TOMORROW: the "compute load indicator" - ### priority - add `ctrl + middleclick` to flood fill when the brush is active diff --git a/src/voxel_annotation/backend.spec.ts b/src/voxel_annotation/backend.spec.ts index 8d128d2bda..b8ca5ee399 100644 --- a/src/voxel_annotation/backend.spec.ts +++ b/src/voxel_annotation/backend.spec.ts @@ -560,6 +560,7 @@ describe("VoxelEditController: Downsampling Integration", () => { if (id === 100) return childSource; if (id === 101) return parentSource; if (id === 102) return grandParentSource; + if (id === 999) return { value: 0 }; return null; }); @@ -572,7 +573,10 @@ describe("VoxelEditController: Downsampling Integration", () => { resolutions.push(resConfig(2, [4, 4, 4], [2, 2, 2])); // Grandparent (4x scale) } - controller = new VoxelEditController(mockRpc, { resolutions }); + controller = new VoxelEditController(mockRpc, { + resolutions, + pendingOpCount: 999, + }); vi.spyOn(controller as any, "callChunkReload"); }; @@ -729,6 +733,7 @@ describe("VoxelEditController: flushPending", () => { (mockRpc.get as any).mockImplementation((id: number) => { if (id === 100) return mockSource0; if (id === 101) return mockSource1; + if (id === 999) return { value: 0 }; return null; }); @@ -737,6 +742,7 @@ describe("VoxelEditController: flushPending", () => { resConfig(0, [1, 1, 1], [2, 2, 2]), resConfig(1, [2, 2, 2], [2, 2, 2]), ], + pendingOpCount: 999, }); vi.spyOn(controller as any, "enqueueDownsample").mockImplementation( @@ -913,11 +919,13 @@ describe("VoxelEditController: Undo/Redo", () => { (mockRpc.get as any).mockImplementation((id: number) => { if (id === 100) return mockSource0; + if (id === 999) return { value: 0 }; return null; }); controller = new VoxelEditController(mockRpc, { resolutions: [resConfig(0, [1, 1, 1], [2, 2, 2])], + pendingOpCount: 999, }); vi.spyOn(controller as any, "callChunkReload"); @@ -1143,6 +1151,8 @@ describe("VoxelEditController: Tool Operations", () => { (mockRpc.get as any).mockImplementation((id: number) => { if (id === 0) return mockChunkManager; + if (id === 100) return mockSource; + if (id === 999) return { value: 0 }; return null; }); @@ -1165,14 +1175,9 @@ describe("VoxelEditController: Tool Operations", () => { newValues: new BigUint64Array([]), }); - (mockRpc.get as any).mockImplementation((id: number) => { - if (id === 0) return mockChunkManager; - if (id === 100) return mockSource; - return null; - }); - controller = new VoxelEditController(mockRpc, { resolutions: [resConfig(0, [1, 1, 1], [10, 10, 10])], + pendingOpCount: 999, }); vi.spyOn(controller as any, "enqueueDownsample").mockImplementation( @@ -1186,7 +1191,7 @@ describe("VoxelEditController: Tool Operations", () => { it("paintBrushWithShape: 3D Sphere", async () => { const center = new Float32Array([5, 5, 5]); - const radius = 2; + const radius = 3; const value = 5n; await controller.performOperation({ @@ -1220,7 +1225,7 @@ describe("VoxelEditController: Tool Operations", () => { it("paintBrushWithShape: 2D Disk", async () => { const center = new Float32Array([5, 5, 5]); - const radius = 2; + const radius = 3; const value = 3n; const basis = { u: new Float32Array([1, 0, 0]), @@ -1334,19 +1339,15 @@ describe("VoxelEditController: Tool Operations", () => { v: new Float32Array([0, 1, 0]), }; - await controller.performOperation({ - type: VoxelOperationType.FLOOD_FILL, - seed, - value: 9n, - maxVoxels, - basis, - }); - - await vi.runAllTimersAsync(); - - const call = (mockSource.applyEdits as any).mock.calls[0]; - const indices = call[1]; - expect(indices.length).toBe(5); + await expect( + controller.performOperation({ + type: VoxelOperationType.FLOOD_FILL, + seed, + value: 9n, + maxVoxels, + basis, + }), + ).rejects.toThrow("Flood fill failed: too many voxels filled."); }); it("floodFillPlane2D: Seed value equals fill value", async () => { From 4ad6e83d531be1bc423a7a5de6ff0c8cbfae9931 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 14 Jan 2026 16:46:22 +0100 Subject: [PATCH 192/251] feat(voxel-annotation): introduce brush caching for performance optimization and add benchmarks to prouve the performance gain --- .../backend.benchmark.spec.ts | 192 +++++++++++++ src/voxel_annotation/backend.ts | 272 ++++++++++++++---- 2 files changed, 405 insertions(+), 59 deletions(-) create mode 100644 src/voxel_annotation/backend.benchmark.spec.ts diff --git a/src/voxel_annotation/backend.benchmark.spec.ts b/src/voxel_annotation/backend.benchmark.spec.ts new file mode 100644 index 0000000000..03ab32ac5e --- /dev/null +++ b/src/voxel_annotation/backend.benchmark.spec.ts @@ -0,0 +1,192 @@ +/** + * @license + * Copyright 2025 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi } from "vitest"; +import { DataType } from "#src/util/data_type.js"; +import { vec3 } from "#src/util/geom.js"; +import { VoxelEditController } from "#src/voxel_annotation/backend.js"; +import { BrushShape, VoxelOperationType } from "#src/voxel_annotation/base.js"; + +const mockChunkData = new BigUint64Array(32 * 32 * 32); // 32^3 block +const mockSource = { + rpcId: 100, + spec: { + rank: 3, + chunkDataSize: new Uint32Array([32, 32, 32]), + lowerVoxelBound: new Float32Array([0, 0, 0]), + upperVoxelBound: new Float32Array([1000, 1000, 1000]), + dataType: DataType.UINT64, + fillValue: 0n, + }, + chunks: new Map(), + getChunk: function () { + return { + chunkGridPosition: new Float32Array([0, 0, 0]), + chunkDataSize: this.spec.chunkDataSize, + data: mockChunkData, + state: 2, + }; + }, + download: async () => {}, + computeChunkBounds: () => {}, +}; + +const mockRpc = { + get: (id: number) => { + if (id === 100) return mockSource; + if (id === 999) return { value: 0 }; + return null; + }, + invoke: () => {}, + newId: () => 0, + register: () => {}, + set: () => {}, + promiseInvoke: async () => {}, +} as any; + +describe("VoxelEditController Performance", () => { + const controller = new VoxelEditController(mockRpc, { + resolutions: [ + { + lodIndex: 0, + transform: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + chunkSize: [32, 32, 32], + sourceRpc: 100, + }, + ], + pendingOpCount: 999, + }); + + vi.spyOn(controller, "commitVoxels").mockImplementation(() => {}); + + const runStroke = async ( + strokeLength: number, + radius: number, + useCache: boolean, + ) => { + (controller as any).brushCache.reset(); + + const center = new Float32Array(3); + const basis = { u: vec3.fromValues(1, 0, 0), v: vec3.fromValues(0, 1, 0) }; + + for (let i = 0; i < strokeLength; i++) { + if (!useCache) { + (controller as any).brushCache.reset(); + } + + center[0] = i; + center[1] = 0; + center[2] = 0; + + await (controller as any).performBrush({ + type: VoxelOperationType.BRUSH, + center: center, + radius: radius, + value: 1n, + shape: BrushShape.SPHERE, + basis: basis, + }); + } + }; + + it("Benchmark: Sphere Brush - 20px stroke, radius 32", async () => { + const STROKE_LENGTH = 20; + const RADIUS = 32; + + await runStroke(10, RADIUS, true); + + const startNoCache = performance.now(); + await runStroke(STROKE_LENGTH, RADIUS, false); + const endNoCache = performance.now(); + + const startCache = performance.now(); + await runStroke(STROKE_LENGTH, RADIUS, true); + const endCache = performance.now(); + + const timeNoCache = endNoCache - startNoCache; + const timeCache = endCache - startCache; + const speedup = timeNoCache / timeCache; + + console.log(` +================================================= + BRUSH OPTIMIZATION BENCHMARK + Shape: SPHERE | Length: ${STROKE_LENGTH}px | Radius: ${RADIUS} +================================================= + No Cache: ${timeNoCache.toFixed(2)} ms + With Cache: ${timeCache.toFixed(2)} ms +------------------------------------------------- + SPEEDUP: ${speedup.toFixed(2)}x FASTER +================================================= + `); + + expect(timeCache).toBeLessThan(timeNoCache); + }); + + it("Benchmark: Disk Brush - 200px stroke, radius 64", async () => { + const STROKE_LENGTH = 200; + const RADIUS = 64; + + const runDisk = async (useCache: boolean) => { + (controller as any).brushCache.reset(); + const center = new Float32Array(3); + const basis = { + u: vec3.fromValues(1, 0, 0), + v: vec3.fromValues(0, 1, 0), + }; + + for (let i = 0; i < STROKE_LENGTH; i++) { + if (!useCache) (controller as any).brushCache.reset(); + center[0] = i; + center[1] = 0; + center[2] = 0; + await (controller as any).performBrush({ + type: VoxelOperationType.BRUSH, + center: center, + radius: RADIUS, + value: 2n, + shape: BrushShape.DISK, + basis: basis, + }); + } + }; + + const startNoCache = performance.now(); + await runDisk(false); + const endNoCache = performance.now(); + + const startCache = performance.now(); + await runDisk(true); + const endCache = performance.now(); + + const timeNoCache = endNoCache - startNoCache; + const timeCache = endCache - startCache; + const speedup = timeNoCache / timeCache; + + console.log(` +================================================= + BRUSH OPTIMIZATION BENCHMARK + Shape: DISK | Length: ${STROKE_LENGTH}px | Radius: ${RADIUS} +================================================= + No Cache: ${timeNoCache.toFixed(2)} ms + With Cache: ${timeCache.toFixed(2)} ms +------------------------------------------------- + SPEEDUP: ${speedup.toFixed(2)}x FASTER +================================================= + `); + + expect(timeCache).toBeLessThan(timeNoCache); + }); +}); diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 59de19b665..41812564e7 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -100,19 +100,22 @@ function getFlatChunkData( } } -class BackendVoxelAccessor { - private activeData: TypedArray | null = null; - private activeKey: string | null = null; - - private minX = 0; - private maxX = 0; - private minY = 0; - private maxY = 0; - private minZ = 0; - private maxZ = 0; +interface ChunkContext { + key: string; + data: TypedArray | null; + minX: number; + maxX: number; + minY: number; + maxY: number; + minZ: number; + maxZ: number; + strideY: number; + strideZ: number; +} - private strideY = 0; - private strideZ = 0; +class BackendVoxelAccessor { + private chunkContexts = new Map(); + private pendingLoads = new Map>(); private readonly volMin: Float32Array; private readonly volMax: Float32Array; @@ -130,17 +133,9 @@ class BackendVoxelAccessor { async getValue(point: Float32Array): Promise { if (point.length !== 3) throw new Error("getValue: invalid point size"); - const [x, y, z] = point.map((v) => Math.round(v)); - if ( - x >= this.minX && - x < this.maxX && - y >= this.minY && - y < this.maxY && - z >= this.minZ && - z < this.maxZ - ) { - return this.readLocal(x, y, z); - } + const x = Math.round(point[0]); + const y = Math.round(point[1]); + const z = Math.round(point[2]); if ( x < this.volMin[0] || @@ -153,30 +148,60 @@ class BackendVoxelAccessor { return null; } - await this.loadChunk(x, y, z); - return this.readLocal(x, y, z); - } + const cx = Math.floor(x / this.chunkDimension[0]); + const cy = Math.floor(y / this.chunkDimension[1]); + const cz = Math.floor(z / this.chunkDimension[2]); + const key = `${cx},${cy},${cz}`; - private readLocal(x: number, y: number, z: number): bigint { - if (!this.activeData) return this.fillValue; + let ctx = this.chunkContexts.get(key); + if (!ctx) { + ctx = await this.getOrLoadChunkContext(key, cx, cy, cz); + } - const lx = x - this.minX; - const ly = y - this.minY; - const lz = z - this.minZ; - const index = lz * this.strideZ + ly * this.strideY + lx; + return this.readLocal(ctx, x, y, z); + } - const val = this.activeData[index]; - return typeof val === "bigint" ? val : BigInt(val); + private getOrLoadChunkContext( + key: string, + cx: number, + cy: number, + cz: number, + ): Promise { + let promise = this.pendingLoads.get(key); + if (promise) return promise; + + promise = this.loadChunkContext(key, cx, cy, cz).then((ctx) => { + this.chunkContexts.set(key, ctx); + this.pendingLoads.delete(key); + return ctx; + }); + this.pendingLoads.set(key, promise); + return promise; } - private async loadChunk(x: number, y: number, z: number) { - const cx = Math.floor(x / this.chunkDimension[0]); - const cy = Math.floor(y / this.chunkDimension[1]); - const cz = Math.floor(z / this.chunkDimension[2]); - const key = `${cx},${cy},${cz}`; + private readLocal( + ctx: ChunkContext, + x: number, + y: number, + z: number, + ): bigint { + if (!ctx.data) return this.fillValue; - if (this.activeKey === key) return; + const lx = x - ctx.minX; + const ly = y - ctx.minY; + const lz = z - ctx.minZ; + const index = lz * ctx.strideZ + ly * ctx.strideY + lx; + const val = ctx.data[index]; + return typeof val === "bigint" ? val : BigInt(val); + } + + private async loadChunkContext( + key: string, + cx: number, + cy: number, + cz: number, + ): Promise { let chunk = this.source.chunks.get(key) as VolumeChunk | undefined; if (!chunk) { chunk = this.source.getChunk( @@ -188,7 +213,7 @@ class BackendVoxelAccessor { try { await this.source.download(chunk, new AbortController().signal); } catch { - this.activeData = null; + return this.createContext(key, null, cx, cy, cz, chunk); } } @@ -196,19 +221,133 @@ class BackendVoxelAccessor { this.source.computeChunkBounds(chunk); } - this.activeKey = key; - this.activeData = getFlatChunkData(chunk, this.source.spec); + const flatData = getFlatChunkData(chunk, this.source.spec); + return this.createContext(key, flatData, cx, cy, cz, chunk); + } + private createContext( + key: string, + data: TypedArray | null, + cx: number, + cy: number, + cz: number, + chunk: VolumeChunk, + ): ChunkContext { const size = chunk.chunkDataSize || this.chunkDimension; - this.minX = cx * this.chunkDimension[0]; - this.minY = cy * this.chunkDimension[1]; - this.minZ = cz * this.chunkDimension[2]; - this.maxX = this.minX + size[0]; - this.maxY = this.minY + size[1]; - this.maxZ = this.minZ + size[2]; - - this.strideY = size[0]; - this.strideZ = size[0] * size[1]; + const minX = cx * this.chunkDimension[0]; + const minY = cy * this.chunkDimension[1]; + const minZ = cz * this.chunkDimension[2]; + + return { + key, + data, + minX, + maxX: minX + size[0], + minY, + maxY: minY + size[1], + minZ, + maxZ: minZ + size[2], + strideY: size[0], + strideZ: size[0] * size[1], + }; + } +} + +type Skipper = (x: number, y: number, z: number) => boolean; + +class BrushOptimizationCache { + private active = false; + + private val: bigint = 0n; + private shape: BrushShape = BrushShape.SPHERE; + private cx = 0; + private cy = 0; + private cz = 0; + private r2 = 0; + + private ux = 0; + private uy = 0; + private uz = 0; + private vx = 0; + private vy = 0; + private vz = 0; + + reset() { + this.active = false; + } + + buildSkipper( + newValue: bigint, + newShape: BrushShape, + newBasis?: { u: Float32Array; v: Float32Array }, + ): Skipper { + if (!this.active || this.val !== newValue || this.shape !== newShape) { + return () => false; + } + + const { cx, cy, cz, r2 } = this; + + if (this.shape === BrushShape.SPHERE) { + return (x: number, y: number, z: number) => { + const dx = x - cx; + const dy = y - cy; + const dz = z - cz; + return dx * dx + dy * dy + dz * dz <= r2; + }; + } + + if (this.shape === BrushShape.DISK && newBasis) { + const dotU = + this.ux * newBasis.u[0] + + this.uy * newBasis.u[1] + + this.uz * newBasis.u[2]; + const dotV = + this.vx * newBasis.v[0] + + this.vy * newBasis.v[1] + + this.vz * newBasis.v[2]; + + if (dotU < 0.9999 || dotV < 0.9999) { + return () => false; + } + + const { ux, uy, uz, vx, vy, vz } = this; + + return (x: number, y: number, z: number) => { + const dx = x - cx; + const dy = y - cy; + const dz = z - cz; + const distU = dx * ux + dy * uy + dz * uz; + const distV = dx * vx + dy * vy + dz * vz; + return distU * distU + distV * distV <= r2; + }; + } + + return () => false; + } + + update( + center: { x: number; y: number; z: number }, + radius: number, + value: bigint, + shape: BrushShape, + basis?: { u: Float32Array; v: Float32Array }, + ) { + this.active = true; + this.cx = center.x; + this.cy = center.y; + this.cz = center.z; + this.r2 = radius * radius; + this.val = value; + this.shape = shape; + + if (basis) { + this.ux = basis.u[0]; + this.uy = basis.u[1]; + this.uz = basis.u[2]; + this.vx = basis.v[0]; + this.vy = basis.v[1]; + this.vz = basis.v[2]; + } } } @@ -245,6 +384,8 @@ export class VoxelEditController extends SharedObject { private downsampleQueueSet: Set = new Set(); private isProcessingDownsampleQueue: boolean = false; + private brushCache = new BrushOptimizationCache(); + private morphologicalConfig = { growthThresholds: [ { count: 100, size: 1 }, @@ -296,6 +437,7 @@ export class VoxelEditController extends SharedObject { } private async flushPending(): Promise { + this.brushCache.reset(); const edits = this.pendingEdits; this.pendingEdits = []; this.updatePendingCount(); @@ -969,12 +1111,21 @@ export class VoxelEditController extends SharedObject { const voxelsToPaint: Float32Array[] = []; - const pushIf = async (point: Float32Array) => { - const v = await accessor.getValue(point); - if (v === null) return; - if (v === value || (filterValue !== undefined && v !== filterValue)) + const shouldSkip = this.brushCache.buildSkipper(value, shape, basis); + + const toAwait = new Set>(); + const pushIf = (point: Float32Array) => { + if (shouldSkip(point[0], point[1], point[2])) { return; - voxelsToPaint.push(point); + } + toAwait.add( + accessor.getValue(point).then((v) => { + if (v == null) return; + if (v === value || (filterValue !== undefined && v !== filterValue)) + return; + voxelsToPaint.push(point); + }), + ); }; if (shape !== BrushShape.DISK) { @@ -982,7 +1133,7 @@ export class VoxelEditController extends SharedObject { for (let dy = -r; dy <= r; ++dy) { for (let dx = -r; dx <= r; ++dx) { if (dx * dx + dy * dy + dz * dz <= rr) - await pushIf(new Float32Array([cx + dx, cy + dy, cz + dz])); + pushIf(new Float32Array([cx + dx, cy + dy, cz + dz])); } } } @@ -995,12 +1146,15 @@ export class VoxelEditController extends SharedObject { const point = vec3.fromValues(cx, cy, cz); vec3.scaleAndAdd(point, point, u as vec3, i); vec3.scaleAndAdd(point, point, v as vec3, j); - await pushIf(point as Float32Array); + pushIf(point as Float32Array); } } } } + await Promise.all(toAwait); + this.brushCache.update({ x: cx, y: cy, z: cz }, r, value, shape, basis); + if (voxelsToPaint.length === 0) return; let finalVoxels = voxelsToPaint; From d52b800961dc4a65d0a1bc1af446618ff0352863 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 14 Jan 2026 17:30:13 +0100 Subject: [PATCH 193/251] feat(voxel-annotation): make pushIf a sync function when filterValue is undefined, added a benchmark to see the performance gain --- .../backend.benchmark.spec.ts | 66 +++++++++++++++++++ src/voxel_annotation/backend.ts | 4 ++ 2 files changed, 70 insertions(+) diff --git a/src/voxel_annotation/backend.benchmark.spec.ts b/src/voxel_annotation/backend.benchmark.spec.ts index 03ab32ac5e..1bb58ffda8 100644 --- a/src/voxel_annotation/backend.benchmark.spec.ts +++ b/src/voxel_annotation/backend.benchmark.spec.ts @@ -190,3 +190,69 @@ describe("VoxelEditController Performance", () => { expect(timeCache).toBeLessThan(timeNoCache); }); }); + +describe("performBrush Benchmark: Sync vs Async Path", () => { + const controller = new VoxelEditController(mockRpc, { + resolutions: [ + { + lodIndex: 0, + transform: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + chunkSize: [32, 32, 32], + sourceRpc: 100, + }, + ], + pendingOpCount: 999, + }); + + vi.spyOn(controller, "commitVoxels").mockImplementation(() => {}); + + const RADIUS = 32; + + it("Compares Filter=undefined (Sync) vs Filter=42n (Async)", async () => { + (controller as any).brushCache.reset(); + const startSync = performance.now(); + + await (controller as any).performBrush({ + type: VoxelOperationType.BRUSH, + center: new Float32Array([100, 100, 100]), + radius: RADIUS, + value: 1n, + shape: BrushShape.SPHERE, + basis: { u: vec3.fromValues(1, 0, 0), v: vec3.fromValues(0, 1, 0) }, + filterValue: undefined, + }); + + const endSync = performance.now(); + + (controller as any).brushCache.reset(); + const startAsync = performance.now(); + + await (controller as any).performBrush({ + type: VoxelOperationType.BRUSH, + center: new Float32Array([200, 200, 200]), + radius: RADIUS, + value: 1n, + shape: BrushShape.SPHERE, + basis: { u: vec3.fromValues(1, 0, 0), v: vec3.fromValues(0, 1, 0) }, + filterValue: 42n, + }); + + const endAsync = performance.now(); + + const timeSync = endSync - startSync; + const timeAsync = endAsync - startAsync; + + console.log(` +================================================= + BRUSH BENCHMARK (Radius ${RADIUS}) +================================================= + With Filter (Async Path): ${timeAsync.toFixed(2)} ms + No Filter (Sync Path): ${timeSync.toFixed(2)} ms +------------------------------------------------- + SPEEDUP: ${(timeAsync / timeSync).toFixed(2)}x FASTER +================================================= + `); + + expect(timeAsync).toBeGreaterThan(timeSync); + }); +}); diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 41812564e7..9841c508d8 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -1118,6 +1118,10 @@ export class VoxelEditController extends SharedObject { if (shouldSkip(point[0], point[1], point[2])) { return; } + if (filterValue == undefined) { + voxelsToPaint.push(point); + return; + } toAwait.add( accessor.getValue(point).then((v) => { if (v == null) return; From 7796df13d60a0379f6d75d82b9b1130446f53d6a Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 14 Jan 2026 19:17:26 +0100 Subject: [PATCH 194/251] feat(voxel-annotation): optimize brush operations by removing memory allocation within the loops and breaking down the maths --- src/layer/voxel_annotation/index.ts | 2 +- src/voxel_annotation/TODOs.md | 3 + src/voxel_annotation/backend.ts | 235 +++++++++++++++++++--------- 3 files changed, 164 insertions(+), 76 deletions(-) diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index 55790f1ca7..1937dcf429 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -231,7 +231,7 @@ export class VoxelEditingContext ) { if (!this._controller) throw new Error("Cannot use paintBrushWithShape without a controller"); - const cost = radiusCanonical * (shape === BrushShape.DISK ? 0.05 : 0.5); + const cost = radiusCanonical * (shape === BrushShape.DISK ? 0.01 : 0.1); await this.withCost(cost, () => this._controller!.paintBrushWithShape( centerCanonical, diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index f724d65ccc..8d01bd89b9 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -2,6 +2,9 @@ ### priority +- optimize frontend brush (test caching etc..) +- optimize flushPendings and the downsampling +- ensure the operation load tracking goes until the end of the pipeline (e.g. the downsampling), currently it stops at the flushPendings - add `ctrl + middleclick` to flood fill when the brush is active - rework the sphere/disk calculation to only calculate the difference between the new sphree/disk and the last one - optimize spheres using the full chunk diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 9841c508d8..b57d771e9a 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -23,7 +23,6 @@ import type { VolumeChunk, VolumeChunkSource, } from "#src/sliceview/volume/backend.js"; -import { computeChunkGridPosition } from "#src/sliceview/volume/base.js"; import type { TypedArray } from "#src/util/array.js"; import { mat4, vec3 } from "#src/util/geom.js"; import * as matrix from "#src/util/matrix.js"; @@ -131,12 +130,7 @@ class BackendVoxelAccessor { this.fillValue = typeof fv === "bigint" ? fv : BigInt(fv); } - async getValue(point: Float32Array): Promise { - if (point.length !== 3) throw new Error("getValue: invalid point size"); - const x = Math.round(point[0]); - const y = Math.round(point[1]); - const z = Math.round(point[2]); - + async getValue(x: number, y: number, z: number): Promise { if ( x < this.volMin[0] || x >= this.volMax[0] || @@ -1109,25 +1103,36 @@ export class VoxelEditController extends SharedObject { r -= 1; const rr = r * r; - const voxelsToPaint: Float32Array[] = []; + // This capacity should ensure we never get out of bounds + const maxCapacity = Math.ceil((2 * r + 1) ** 3); + const voxelBuffer = new Int32Array(maxCapacity * 3); + let voxelCount = 0; + const bufferEnqueue = (x: number, y: number, z: number) => { + // don;t need to check bounds as the capacity is assumed to be large enough + const base = voxelCount * 3; + voxelBuffer[base] = x; + voxelBuffer[base + 1] = y; + voxelBuffer[base + 2] = z; + voxelCount++; + }; const shouldSkip = this.brushCache.buildSkipper(value, shape, basis); const toAwait = new Set>(); - const pushIf = (point: Float32Array) => { - if (shouldSkip(point[0], point[1], point[2])) { + const pushIf = (x: number, y: number, z: number) => { + if (shouldSkip(x, y, z)) { return; } if (filterValue == undefined) { - voxelsToPaint.push(point); + bufferEnqueue(x, y, z); return; } toAwait.add( - accessor.getValue(point).then((v) => { + accessor.getValue(x, y, z).then((v) => { if (v == null) return; if (v === value || (filterValue !== undefined && v !== filterValue)) return; - voxelsToPaint.push(point); + bufferEnqueue(x, y, z); }), ); }; @@ -1137,20 +1142,32 @@ export class VoxelEditController extends SharedObject { for (let dy = -r; dy <= r; ++dy) { for (let dx = -r; dx <= r; ++dx) { if (dx * dx + dy * dy + dz * dz <= rr) - pushIf(new Float32Array([cx + dx, cy + dy, cz + dz])); + pushIf(cx + dx, cy + dy, cz + dz); } } } } else { if (basis === undefined) throw new Error("Brush shape requires a basis."); - const { u, v } = basis; + const { u, v } = basis as { u: vec3; v: vec3 }; + const ux = u[0], + uy = u[1], + uz = u[2]; + const vx = v[0], + vy = v[1], + vz = v[2]; + for (let j = -r; j <= r; ++j) { + const j2 = j * j; + const vPartX = vx * j; + const vPartY = vy * j; + const vPartZ = vz * j; + for (let i = -r; i <= r; ++i) { - if (i * i + j * j <= rr) { - const point = vec3.fromValues(cx, cy, cz); - vec3.scaleAndAdd(point, point, u as vec3, i); - vec3.scaleAndAdd(point, point, v as vec3, j); - pushIf(point as Float32Array); + if (i * i + j2 <= rr) { + const px = Math.round(cx + ux * i + vPartX); + const py = Math.round(cy + uy * i + vPartY); + const pz = Math.round(cz + uz * i + vPartZ); + pushIf(px, py, pz); } } } @@ -1159,14 +1176,19 @@ export class VoxelEditController extends SharedObject { await Promise.all(toAwait); this.brushCache.update({ x: cx, y: cy, z: cz }, r, value, shape, basis); - if (voxelsToPaint.length === 0) return; + if (voxelCount === 0) return; - let finalVoxels = voxelsToPaint; if (basis && shape === BrushShape.DISK) { - finalVoxels = this.fillPlaneAliasingGaps(voxelsToPaint, basis, center); + const result = this.fillPlaneAliasingGaps( + voxelBuffer, + voxelCount, + basis, + center, + ); + this.processBackendEdits(result.buffer, result.count, value, sourceIndex); + } else { + this.processBackendEdits(voxelBuffer, voxelCount, value, sourceIndex); } - - await this.processBackendEdits(finalVoxels, value, sourceIndex); } private async performFloodFill(op: FloodFillOperation): Promise { @@ -1177,7 +1199,11 @@ export class VoxelEditController extends SharedObject { const accessor = new BackendVoxelAccessor(source); const startVoxelLod = vec3.round(vec3.create(), seed as vec3); - const originalValue = await accessor.getValue(startVoxelLod); + const originalValue = await accessor.getValue( + startVoxelLod[0], + startVoxelLod[1], + startVoxelLod[2], + ); if (originalValue === null) return; if (filterValue !== undefined && originalValue !== filterValue) return; @@ -1186,7 +1212,7 @@ export class VoxelEditController extends SharedObject { const visited = new Set(); const queue: [number, number][] = []; let filledCount = 0; - const voxelsToFill: Float32Array[] = []; + const voxelBuffer = new Int32Array(maxVoxels * 3 + 200); // +200 since fillBorderRegion may exceed the maxVoxelCount without failure const map2dTo3d = (u: number, v: number): vec3 => { const point = vec3.clone(startVoxelLod); @@ -1196,7 +1222,7 @@ export class VoxelEditController extends SharedObject { }; const isFillable = async (p: vec3): Promise => { - const val = await accessor.getValue(p); + const val = await accessor.getValue(p[0], p[1], p[2]); if (val === null) return false; if (originalValue === 0n) return val === 0n; return val === originalValue; @@ -1252,8 +1278,11 @@ export class VoxelEditController extends SharedObject { if (filledCount >= maxVoxels) return; const [u, v] = subQueue.shift()!; const currentPoint = map2dTo3d(u, v); + const base = filledCount * 3; + voxelBuffer[base] = currentPoint[0]; + voxelBuffer[base + 1] = currentPoint[1]; + voxelBuffer[base + 2] = currentPoint[2]; filledCount++; - voxelsToFill.push(currentPoint as Float32Array); const neighbors2d: [number, number][] = [ [u + 1, v], @@ -1283,8 +1312,11 @@ export class VoxelEditController extends SharedObject { throw new Error(`Flood fill failed: too many voxels filled.`); const [u, v] = queue.shift()!; const currentPoint = map2dTo3d(u, v); + const base = filledCount * 3; + voxelBuffer[base] = currentPoint[0]; + voxelBuffer[base + 1] = currentPoint[1]; + voxelBuffer[base + 2] = currentPoint[2]; filledCount++; - voxelsToFill.push(currentPoint as Float32Array); const requiredThickness = getCurrentThickness(); const neighbors2d: [number, number][] = [ @@ -1309,15 +1341,26 @@ export class VoxelEditController extends SharedObject { } } - const finalVoxels = this.fillPlaneAliasingGaps(voxelsToFill, basis, seed); - await this.processBackendEdits(finalVoxels, fillValue, sourceIndex); + const result = this.fillPlaneAliasingGaps( + voxelBuffer, + filledCount, + basis, + seed, + ); + this.processBackendEdits( + result.buffer, + result.count, + fillValue, + sourceIndex, + ); } private fillPlaneAliasingGaps( - voxels: Float32Array[], + inputBuffer: Int32Array, + inputCount: number, basis: { u: Float32Array; v: Float32Array }, center: Float32Array, - ): Float32Array[] { + ): { buffer: Int32Array; count: number } { const u = basis.u as vec3; const v = basis.v as vec3; const normal = vec3.create(); @@ -1330,79 +1373,121 @@ export class VoxelEditController extends SharedObject { Math.abs(normal[1]) > SKIP_THRESHOLD || Math.abs(normal[2]) > SKIP_THRESHOLD ) { - return voxels; + return { buffer: inputBuffer, count: inputCount }; } const d = -vec3.dot(normal, center as vec3); + const DISTANCE_THRESHOLD = + Math.abs(normal[0]) + Math.abs(normal[1]) + Math.abs(normal[2]) + 1e-5; + const voxelSet = new Set(); - const output = [...voxels]; - for (const v of voxels) { - voxelSet.add( - `${Math.round(v[0])},${Math.round(v[1])},${Math.round(v[2])}`, - ); + + let outputBuffer = inputBuffer; + let outputCount = inputCount; + if (inputCount * 6 > inputBuffer.length) { + const newBuf = new Int32Array(inputCount * 6); + newBuf.set(inputBuffer.subarray(0, inputCount * 3)); + outputBuffer = newBuf; } - const DISTANCE_THRESHOLD = - Math.abs(normal[0]) + Math.abs(normal[1]) + Math.abs(normal[2]) + 1e-5; + for (let i = 0; i < inputCount; i++) { + const base = i * 3; + const px = outputBuffer[base]; + const py = outputBuffer[base + 1]; + const pz = outputBuffer[base + 2]; + voxelSet.add(`${px},${py},${pz}`); + } - for (const p of voxels) { - const px = Math.round(p[0]); - const py = Math.round(p[1]); - const pz = Math.round(p[2]); + for (let i = 0; i < inputCount; i++) { + const base = i * 3; + const px = inputBuffer[base]; + const py = inputBuffer[base + 1]; + const pz = inputBuffer[base + 2]; for (const [ox, oy, oz] of OFFSETS_26_CONNECTED_BACKEND) { const nx = px + ox; const ny = py + oy; const nz = pz + oz; - const key = `${nx},${ny},${nz}`; - if (voxelSet.has(key)) continue; const dist = Math.abs( normal[0] * nx + normal[1] * ny + normal[2] * nz + d, ); if (dist <= DISTANCE_THRESHOLD) { - voxelSet.add(key); - output.push(new Float32Array([nx, ny, nz])); + const key = `${nx},${ny},${nz}`; + if (!voxelSet.has(key)) { + voxelSet.add(key); + + if (outputCount * 3 + 3 > outputBuffer.length) { + const newBuf = new Int32Array(outputBuffer.length * 2); + newBuf.set(outputBuffer); + outputBuffer = newBuf; + } + const outBase = outputCount * 3; + outputBuffer[outBase] = nx; + outputBuffer[outBase + 1] = ny; + outputBuffer[outBase + 2] = nz; + outputCount++; + } } } } - return output; + return { buffer: outputBuffer, count: outputCount }; } - private async processBackendEdits( - voxels: Float32Array[], + private processBackendEdits( + voxelBuffer: Int32Array, + voxelCount: number, value: bigint, lodIndex: number, ) { const source = this.sources.get(lodIndex); if (!source) return; - const { rank, chunkDataSize } = source.spec; - const tempGridPos = new Float32Array(rank); - const tempPosInChunk = new Uint32Array(rank); - + const { chunkDataSize } = source.spec; const indicesByVoxKey = new Map(); - for (const voxelCoord of voxels) { - computeChunkGridPosition( - tempGridPos, - tempPosInChunk, - voxelCoord, - chunkDataSize, - ); - const chunkKey = tempGridPos.join(); - const voxKey = makeVoxChunkKey(chunkKey, lodIndex); - let indices = indicesByVoxKey.get(voxKey); - if (!indices) { - indices = []; - indicesByVoxKey.set(voxKey, indices); + let lastGridX = -Infinity; + let lastGridY = -Infinity; + let lastGridZ = -Infinity; + let currentIndicesList: number[] | undefined; + + const sizeX = chunkDataSize[0]; + const sizeY = chunkDataSize[1]; + const sizeZ = chunkDataSize[2]; + const strideY = sizeX; + const strideZ = sizeX * sizeY; + + for (let i = 0; i < voxelCount; i++) { + const base = i * 3; + const vx = voxelBuffer[base]; + const vy = voxelBuffer[base + 1]; + const vz = voxelBuffer[base + 2]; + + const cx = Math.floor(vx / sizeX); + const cy = Math.floor(vy / sizeY); + const cz = Math.floor(vz / sizeZ); + + if (cx !== lastGridX || cy !== lastGridY || cz !== lastGridZ) { + lastGridX = cx; + lastGridY = cy; + lastGridZ = cz; + + const voxKey = `lod${lodIndex}#${cx},${cy},${cz}`; + + currentIndicesList = indicesByVoxKey.get(voxKey); + if (!currentIndicesList) { + currentIndicesList = []; + indicesByVoxKey.set(voxKey, currentIndicesList); + } } - const index = - (tempPosInChunk[2] * chunkDataSize[1] + tempPosInChunk[1]) * - chunkDataSize[0] + - tempPosInChunk[0]; - indices.push(index); + const lx = Math.floor(vx - sizeX * cx); + const ly = Math.floor(vy - sizeY * cy); + const lz = Math.floor(vz - sizeZ * cz); + + const index = lz * strideZ + ly * strideY + lx; + + currentIndicesList!.push(index); } const backendEdits = []; From a6ea83cdd9b4e272f7857bbc0efecd3ae710621d Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 21 Jan 2026 17:55:25 +0100 Subject: [PATCH 195/251] feat(voxel-annotation): benchmarked the entire drawing pipeline to properly calculate the stamina --- src/layer/voxel_annotation/index.ts | 28 +- src/voxel_annotation/TODOs.md | 6 +- .../backend.benchmark.spec.ts | 258 ----------- src/voxel_annotation/backend.ts | 25 +- src/voxel_annotation/base.ts | 17 +- .../staminaCalibration.benchmark.spec.ts | 423 ++++++++++++++++++ 6 files changed, 477 insertions(+), 280 deletions(-) delete mode 100644 src/voxel_annotation/backend.benchmark.spec.ts create mode 100644 src/voxel_annotation/staminaCalibration.benchmark.spec.ts diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index 1937dcf429..5569a11e84 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -68,11 +68,10 @@ import type { VoxelValueGetter, } from "#src/voxel_annotation/base.js"; import { - FLOODFILL_MAX_POSSIBLE_VOXELS, - FLOODFILL_MIN_POSSIBLE_VOXELS, + VOXEL_EDIT_STAMINA, BRUSH_TOOL_ID, BrushShape, - MAX_VOXEL_EDIT_CAPACITY, + MAX_VOXEL_EDIT_STAMINA, } from "#src/voxel_annotation/base.js"; import { VoxelEditController } from "#src/voxel_annotation/frontend.js"; @@ -210,7 +209,7 @@ export class VoxelEditingContext cost: number, op: () => Promise, ): Promise { - if (this.localLoadEstimate.value >= MAX_VOXEL_EDIT_CAPACITY) return; + if (this.localLoadEstimate.value >= MAX_VOXEL_EDIT_STAMINA) return; this.localLoadEstimate.value += cost; try { if (await this.checkPermission()) { @@ -231,7 +230,11 @@ export class VoxelEditingContext ) { if (!this._controller) throw new Error("Cannot use paintBrushWithShape without a controller"); - const cost = radiusCanonical * (shape === BrushShape.DISK ? 0.01 : 0.1); + const cost = VOXEL_EDIT_STAMINA.brush( + shape, + radiusCanonical, + filterValue !== undefined, + ); await this.withCost(cost, () => this._controller!.paintBrushWithShape( centerCanonical, @@ -253,10 +256,7 @@ export class VoxelEditingContext ) { if (!this._controller) throw new Error("Cannot use floodFillPlane2D without a controller"); - const cost = - 50 + - (500 * (maxVoxels - FLOODFILL_MIN_POSSIBLE_VOXELS)) / - (FLOODFILL_MAX_POSSIBLE_VOXELS - FLOODFILL_MIN_POSSIBLE_VOXELS); + const cost = VOXEL_EDIT_STAMINA.floodFill(maxVoxels); await this.withCost(cost, () => this._controller!.floodFillPlane2D( startPositionCanonical, @@ -271,13 +271,17 @@ export class VoxelEditingContext async undo() { if (!this._controller) throw new Error("Cannot use undo without a controller"); - await this.withCost(100, () => this._controller!.undo()); + await this.withCost(VOXEL_EDIT_STAMINA.undoRedo(), () => + this._controller!.undo(), + ); } async redo() { if (!this._controller) throw new Error("Cannot use redo without a controller"); - await this.withCost(100, () => this._controller!.redo()); + await this.withCost(VOXEL_EDIT_STAMINA.undoRedo(), () => + this._controller!.redo(), + ); } get rpc() { @@ -593,7 +597,7 @@ export function UserLayerWithVoxelEditingMixin< pendingCount: number, isBrushActive: boolean, ) { - const ratio = Math.max(0, 1.0 - pendingCount / MAX_VOXEL_EDIT_CAPACITY); + const ratio = Math.max(0, 1.0 - pendingCount / MAX_VOXEL_EDIT_STAMINA); if (ratio > 0.99) { return; } diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 8d01bd89b9..5976005638 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -4,11 +4,13 @@ - optimize frontend brush (test caching etc..) - optimize flushPendings and the downsampling -- ensure the operation load tracking goes until the end of the pipeline (e.g. the downsampling), currently it stops at the flushPendings +- optimize the flood fill - add `ctrl + middleclick` to flood fill when the brush is active -- rework the sphere/disk calculation to only calculate the difference between the new sphree/disk and the last one +- `ctrl + shift` is no longer displaying the red cursor +- preview of selective eraser is broken - optimize spheres using the full chunk - see about the list of pending edits for the preview +- when chunk write fails, the chunk is not reloaded ### later diff --git a/src/voxel_annotation/backend.benchmark.spec.ts b/src/voxel_annotation/backend.benchmark.spec.ts deleted file mode 100644 index 1bb58ffda8..0000000000 --- a/src/voxel_annotation/backend.benchmark.spec.ts +++ /dev/null @@ -1,258 +0,0 @@ -/** - * @license - * Copyright 2025 Google Inc. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect, vi } from "vitest"; -import { DataType } from "#src/util/data_type.js"; -import { vec3 } from "#src/util/geom.js"; -import { VoxelEditController } from "#src/voxel_annotation/backend.js"; -import { BrushShape, VoxelOperationType } from "#src/voxel_annotation/base.js"; - -const mockChunkData = new BigUint64Array(32 * 32 * 32); // 32^3 block -const mockSource = { - rpcId: 100, - spec: { - rank: 3, - chunkDataSize: new Uint32Array([32, 32, 32]), - lowerVoxelBound: new Float32Array([0, 0, 0]), - upperVoxelBound: new Float32Array([1000, 1000, 1000]), - dataType: DataType.UINT64, - fillValue: 0n, - }, - chunks: new Map(), - getChunk: function () { - return { - chunkGridPosition: new Float32Array([0, 0, 0]), - chunkDataSize: this.spec.chunkDataSize, - data: mockChunkData, - state: 2, - }; - }, - download: async () => {}, - computeChunkBounds: () => {}, -}; - -const mockRpc = { - get: (id: number) => { - if (id === 100) return mockSource; - if (id === 999) return { value: 0 }; - return null; - }, - invoke: () => {}, - newId: () => 0, - register: () => {}, - set: () => {}, - promiseInvoke: async () => {}, -} as any; - -describe("VoxelEditController Performance", () => { - const controller = new VoxelEditController(mockRpc, { - resolutions: [ - { - lodIndex: 0, - transform: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], - chunkSize: [32, 32, 32], - sourceRpc: 100, - }, - ], - pendingOpCount: 999, - }); - - vi.spyOn(controller, "commitVoxels").mockImplementation(() => {}); - - const runStroke = async ( - strokeLength: number, - radius: number, - useCache: boolean, - ) => { - (controller as any).brushCache.reset(); - - const center = new Float32Array(3); - const basis = { u: vec3.fromValues(1, 0, 0), v: vec3.fromValues(0, 1, 0) }; - - for (let i = 0; i < strokeLength; i++) { - if (!useCache) { - (controller as any).brushCache.reset(); - } - - center[0] = i; - center[1] = 0; - center[2] = 0; - - await (controller as any).performBrush({ - type: VoxelOperationType.BRUSH, - center: center, - radius: radius, - value: 1n, - shape: BrushShape.SPHERE, - basis: basis, - }); - } - }; - - it("Benchmark: Sphere Brush - 20px stroke, radius 32", async () => { - const STROKE_LENGTH = 20; - const RADIUS = 32; - - await runStroke(10, RADIUS, true); - - const startNoCache = performance.now(); - await runStroke(STROKE_LENGTH, RADIUS, false); - const endNoCache = performance.now(); - - const startCache = performance.now(); - await runStroke(STROKE_LENGTH, RADIUS, true); - const endCache = performance.now(); - - const timeNoCache = endNoCache - startNoCache; - const timeCache = endCache - startCache; - const speedup = timeNoCache / timeCache; - - console.log(` -================================================= - BRUSH OPTIMIZATION BENCHMARK - Shape: SPHERE | Length: ${STROKE_LENGTH}px | Radius: ${RADIUS} -================================================= - No Cache: ${timeNoCache.toFixed(2)} ms - With Cache: ${timeCache.toFixed(2)} ms -------------------------------------------------- - SPEEDUP: ${speedup.toFixed(2)}x FASTER -================================================= - `); - - expect(timeCache).toBeLessThan(timeNoCache); - }); - - it("Benchmark: Disk Brush - 200px stroke, radius 64", async () => { - const STROKE_LENGTH = 200; - const RADIUS = 64; - - const runDisk = async (useCache: boolean) => { - (controller as any).brushCache.reset(); - const center = new Float32Array(3); - const basis = { - u: vec3.fromValues(1, 0, 0), - v: vec3.fromValues(0, 1, 0), - }; - - for (let i = 0; i < STROKE_LENGTH; i++) { - if (!useCache) (controller as any).brushCache.reset(); - center[0] = i; - center[1] = 0; - center[2] = 0; - await (controller as any).performBrush({ - type: VoxelOperationType.BRUSH, - center: center, - radius: RADIUS, - value: 2n, - shape: BrushShape.DISK, - basis: basis, - }); - } - }; - - const startNoCache = performance.now(); - await runDisk(false); - const endNoCache = performance.now(); - - const startCache = performance.now(); - await runDisk(true); - const endCache = performance.now(); - - const timeNoCache = endNoCache - startNoCache; - const timeCache = endCache - startCache; - const speedup = timeNoCache / timeCache; - - console.log(` -================================================= - BRUSH OPTIMIZATION BENCHMARK - Shape: DISK | Length: ${STROKE_LENGTH}px | Radius: ${RADIUS} -================================================= - No Cache: ${timeNoCache.toFixed(2)} ms - With Cache: ${timeCache.toFixed(2)} ms -------------------------------------------------- - SPEEDUP: ${speedup.toFixed(2)}x FASTER -================================================= - `); - - expect(timeCache).toBeLessThan(timeNoCache); - }); -}); - -describe("performBrush Benchmark: Sync vs Async Path", () => { - const controller = new VoxelEditController(mockRpc, { - resolutions: [ - { - lodIndex: 0, - transform: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], - chunkSize: [32, 32, 32], - sourceRpc: 100, - }, - ], - pendingOpCount: 999, - }); - - vi.spyOn(controller, "commitVoxels").mockImplementation(() => {}); - - const RADIUS = 32; - - it("Compares Filter=undefined (Sync) vs Filter=42n (Async)", async () => { - (controller as any).brushCache.reset(); - const startSync = performance.now(); - - await (controller as any).performBrush({ - type: VoxelOperationType.BRUSH, - center: new Float32Array([100, 100, 100]), - radius: RADIUS, - value: 1n, - shape: BrushShape.SPHERE, - basis: { u: vec3.fromValues(1, 0, 0), v: vec3.fromValues(0, 1, 0) }, - filterValue: undefined, - }); - - const endSync = performance.now(); - - (controller as any).brushCache.reset(); - const startAsync = performance.now(); - - await (controller as any).performBrush({ - type: VoxelOperationType.BRUSH, - center: new Float32Array([200, 200, 200]), - radius: RADIUS, - value: 1n, - shape: BrushShape.SPHERE, - basis: { u: vec3.fromValues(1, 0, 0), v: vec3.fromValues(0, 1, 0) }, - filterValue: 42n, - }); - - const endAsync = performance.now(); - - const timeSync = endSync - startSync; - const timeAsync = endAsync - startAsync; - - console.log(` -================================================= - BRUSH BENCHMARK (Radius ${RADIUS}) -================================================= - With Filter (Async Path): ${timeAsync.toFixed(2)} ms - No Filter (Sync Path): ${timeSync.toFixed(2)} ms -------------------------------------------------- - SPEEDUP: ${(timeAsync / timeSync).toFixed(2)}x FASTER -================================================= - `); - - expect(timeAsync).toBeGreaterThan(timeSync); - }); -}); diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index b57d771e9a..fe9d6d622a 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -35,6 +35,7 @@ import type { FloodFillOperation, } from "#src/voxel_annotation/base.js"; import { + VOXEL_EDIT_STAMINA, VOX_EDIT_BACKEND_RPC_ID, VOX_EDIT_COMMIT_VOXELS_RPC_ID, VOX_RELOAD_CHUNKS_RPC_ID, @@ -360,12 +361,21 @@ export class VoxelEditController extends SharedObject { values?: ArrayLike; size?: number[]; }[] = []; - private _inFlightCount = 0; public pendingOpCount: SharedWatchableValue; private updatePendingCount() { - this.pendingOpCount.value = this.pendingEdits.length + this._inFlightCount; + const editedVoxel = this.pendingEdits.reduce( + (a, { indices }) => indices.length + a, + 0, + ); + const pendingEdits = VOXEL_EDIT_STAMINA.pendingEdits(editedVoxel); + const downsampling = VOXEL_EDIT_STAMINA.downsamplingJobs( + this.downsampleQueue.length, + this.resolutions.size, + ); + this.pendingOpCount.value = pendingEdits + downsampling; } + private commitDebounceTimer: number | undefined; private readonly commitDebounceDelayMs: number = 300; @@ -434,7 +444,6 @@ export class VoxelEditController extends SharedObject { this.brushCache.reset(); const edits = this.pendingEdits; this.pendingEdits = []; - this.updatePendingCount(); this.commitDebounceTimer = undefined; if (edits.length === 0) { // Even if nothing to flush, history sizes may not have changed. @@ -537,11 +546,12 @@ export class VoxelEditController extends SharedObject { }); } - const touched = new Set(); - for (const e of edits) touched.add(e.key); - for (const key of touched) { - this.enqueueDownsample(key); + for (const [voxKey, _] of editsByVoxKey.entries()) { + if (failedVoxChunkKeys.includes(voxKey)) continue; + this.enqueueDownsample(voxKey); } + + this.updatePendingCount(); } commitVoxels( @@ -612,6 +622,7 @@ export class VoxelEditController extends SharedObject { const pendingKeys = new Set(this.pendingEdits.map((e) => e.key)); const keysToReload = allModifiedKeys.filter((k) => !pendingKeys.has(k)); if (keysToReload.length > 0) this.callChunkReload(keysToReload, true); + this.updatePendingCount(); } } finally { this.isProcessingDownsampleQueue = false; diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index d9bcba5190..988d3164fc 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -69,7 +69,22 @@ export const SEG_PICKER_TOOL_ID = "vox-seg-picker"; // Special value used to indicate to the optimistic renderer that a voxel has been erased export const SEG_ERASE_SENTINEL = ~1n; -export const MAX_VOXEL_EDIT_CAPACITY = 1000; +export const VOXEL_EDIT_STAMINA = { + pendingEdits: (voxelCount: number) => Math.round(voxelCount * 0.0004), + downsamplingJobs: (count: number, downsamplingSteps: number) => + Math.round(count * 10 * downsamplingSteps), + brush: (shape: BrushShape, radius: number, hasFiltering: boolean) => { + const FILTERING = hasFiltering ? 7 : 1; + if (shape === BrushShape.DISK) { + return Math.round(0.035 * Math.pow(radius, 2) * FILTERING); + } else { + return Math.round(0.012 * Math.pow(radius, 3) * FILTERING); + } + }, + floodFill: (maxVoxels: number) => Math.round(maxVoxels * 0.005), + undoRedo: () => 20, +}; +export const MAX_VOXEL_EDIT_STAMINA = 10000; export type VoxelValueGetter = (isPreview: boolean) => bigint; diff --git a/src/voxel_annotation/staminaCalibration.benchmark.spec.ts b/src/voxel_annotation/staminaCalibration.benchmark.spec.ts new file mode 100644 index 0000000000..9edc6863cb --- /dev/null +++ b/src/voxel_annotation/staminaCalibration.benchmark.spec.ts @@ -0,0 +1,423 @@ +/** + * @license + * Copyright 2025 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 * as fs from "fs"; +import * as path from "path"; +import { describe, it, beforeAll, afterAll, vi } from "vitest"; +import type { VolumeChunk } from "#src/sliceview/volume/backend.js"; +import { VolumeChunkSource } from "#src/sliceview/volume/backend.js"; +import { DataType, DATA_TYPE_ARRAY_CONSTRUCTOR } from "#src/util/data_type.js"; +import { vec3 } from "#src/util/geom.js"; +import { VoxelEditController } from "#src/voxel_annotation/backend.js"; +import { + BrushShape, + VoxelOperationType, + makeVoxChunkKey, +} from "#src/voxel_annotation/base.js"; + +interface BenchmarkResult { + operation: string; + inputs: Record; + avgTimeMs: number; +} + +const results: BenchmarkResult[] = []; + +const NETWORK_LATENCY = 0; +class RealisticInMemorySource extends VolumeChunkSource { + public storage = new Map(); + + async download(chunk: VolumeChunk) { + if (NETWORK_LATENCY) + await new Promise((resolve) => setTimeout(resolve, NETWORK_LATENCY)); + + if (!chunk.chunkDataSize) { + this.computeChunkBounds(chunk); + } + + const numElements = chunk.chunkDataSize!.reduce((a, b) => a * b, 1); + const Ctor = DATA_TYPE_ARRAY_CONSTRUCTOR[this.spec.dataType]; + const key = chunk.chunkGridPosition.join(","); + + if (this.storage.has(key)) { + chunk.data = new (Ctor as any)(this.storage.get(key)!); + } else { + chunk.data = new (Ctor as any)(numElements); + } + } + + async writeChunk(chunk: VolumeChunk) { + if (NETWORK_LATENCY) + await new Promise((resolve) => setTimeout(resolve, NETWORK_LATENCY)); + + const key = chunk.chunkGridPosition.join(","); + const Ctor = DATA_TYPE_ARRAY_CONSTRUCTOR[this.spec.dataType]; + if (chunk.data) { + this.storage.set(key, new (Ctor as any)(chunk.data)); + } + } +} + +const createResConfig = (lod: number, chunkSize: number) => ({ + lodIndex: lod, + transform: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + chunkSize: [chunkSize, chunkSize, chunkSize], + sourceRpc: 100 + lod, +}); + +describe("Voxel Operation Cost Calibration (Realistic)", () => { + let controller: VoxelEditController; + let mockSource0: RealisticInMemorySource; + + const CHUNK_SIZE = 64; + const ITERATIONS = 5; + + const measure = async ( + operation: string, + inputs: Record, + fn: () => Promise, + ) => { + for (let i = 0; i < 2; i++) await fn(); + + const start = performance.now(); + for (let i = 0; i < ITERATIONS; i++) { + await fn(); + } + const end = performance.now(); + + const avgMs = (end - start) / ITERATIONS; + + const inputStr = Object.entries(inputs) + .map(([k, v]) => `${k}=${v}`) + .join(" "); + console.log( + `[BENCHMARK] ${operation.padEnd(20)} | ${inputStr.padEnd(40)} | ${avgMs.toFixed(4)} ms`, + ); + + results.push({ operation, inputs, avgTimeMs: avgMs }); + + return avgMs; + }; + + beforeAll(() => { + const spec = { + rank: 3, + chunkDataSize: new Uint32Array([CHUNK_SIZE, CHUNK_SIZE, CHUNK_SIZE]), + dataType: DataType.UINT64, + lowerVoxelBound: new Float32Array([0, 0, 0]), + upperVoxelBound: new Float32Array([10000, 10000, 10000]), + baseVoxelOffset: new Float32Array([0, 0, 0]), + fillValue: 0n, + }; + + const mockChunkQueueManager = { + sources: new Set(), + }; + + const mockChunkManager = { + queueManager: mockChunkQueueManager, + memoize: { get: (_k: string, f: Function) => f() }, + }; + + const rpcHandler = { + get: (id: number) => { + if (id === 0) return mockChunkManager; + if (id === 100) return mockSource0; + if (id === 999) return { value: 0 }; + return null; + }, + invoke: () => {}, + newId: () => 0, + register: () => {}, + set: () => {}, + promiseInvoke: async () => {}, + } as any; + + mockSource0 = new RealisticInMemorySource(rpcHandler, { + spec, + chunkManager: 0, + }); + + controller = new VoxelEditController(rpcHandler, { + resolutions: [createResConfig(0, CHUNK_SIZE)], + pendingOpCount: 999, + }); + + vi.spyOn(controller as any, "enqueueDownsample").mockImplementation( + () => {}, + ); + }); + + afterAll(() => { + const outputPath = path.resolve(__dirname, "calibration_results.json"); + fs.writeFileSync(outputPath, JSON.stringify(results, null, 2)); + console.log(`\n\n>>> Results saved to: ${outputPath}\n`); + console.table( + results.map(({ operation, inputs, avgTimeMs }) => ({ + operation, + inputs: JSON.stringify(inputs), + avgTimeMs: avgTimeMs.toFixed(4), + })), + ); + }); + + // -------------------------------------------------------------------------- + // 1. SYSTEM OVERHEAD (Commit) + // -------------------------------------------------------------------------- + + it("Calibrate: Chunk Commit", async () => { + const voxelsCounts = [1000, 10000, 100000]; + const numOfEdits = [1, 10, 50]; + + for (const numOfEdit of numOfEdits) { + for (const count of voxelsCounts) { + const indices = new Uint32Array(count); + const values = new BigUint64Array(count); + for (let i = 0; i < count; i++) { + indices[i] = i; + values[i] = BigInt(i); + } + const edits = [{ key: "lod0#0,0,0", indices, values }]; + for (let i = 1; i < numOfEdit; i++) + edits.push({ key: `lod0#0,0,${i}`, indices, values }); + + await measure( + "Commit", + { voxels: count, edits: numOfEdit, chunks: 1 }, + async () => { + (controller as any).pendingEdits.push(...edits); + await (controller as any).flushPending(); + }, + ); + } + } + }); + + // -------------------------------------------------------------------------- + // 2. DOWNSAMPLING + // -------------------------------------------------------------------------- + + it("Calibrate: Downsample Step (Worst Case Data)", async () => { + const childKey = "0,0,0"; + const chunk = mockSource0.getChunk( + new Float32Array([0, 0, 0]), + ) as VolumeChunk; + + if (!chunk.data) await mockSource0.download(chunk); + const data = chunk.data as BigUint64Array; + for (let i = 0; i < data.length; i++) { + data[i] = BigInt(i % 5); + } + + await measure("Downsample", { inputVoxels: CHUNK_SIZE ** 3 }, async () => { + await (controller as any).downsampleStep(makeVoxChunkKey(childKey, 0)); + }); + }); + + // -------------------------------------------------------------------------- + // 3. BRUSH STROKES + // -------------------------------------------------------------------------- + + const STROKE_LEN = 20; + const runStroke = async ( + shape: BrushShape, + radius: number, + useFilter: boolean, + ) => { + (controller as any).brushCache.reset(); + const center = new Float32Array(3); + const basis = { u: vec3.fromValues(1, 0, 0), v: vec3.fromValues(0, 1, 0) }; + + if (useFilter) { + const chunk = mockSource0.getChunk( + new Float32Array([0, 0, 0]), + ) as VolumeChunk; + if (!chunk.data) await mockSource0.download(chunk); + } + const filterVal = useFilter ? 999n : undefined; + + for (let i = 0; i < STROKE_LEN; i++) { + center[0] = 30 + i; + center[1] = 30; + center[2] = 30; + + await (controller as any).performBrush({ + type: VoxelOperationType.BRUSH, + center, + radius, + value: 1n, + shape, + basis, + filterValue: filterVal, + }); + } + }; + + const radii_sp = [4, 8, 12, 16, 20, 24, 28, 32]; + + radii_sp.forEach((r) => { + it(`Calibrate: Brush SPHERE r=${r} (No Filter)`, async () => { + await measure( + "Brush", + { shape: "SPHERE", radius: r, filter: false }, + async () => { + await runStroke(BrushShape.SPHERE, r, false); + }, + ); + }); + + it(`Calibrate: Brush SPHERE r=${r} (WITH Filter)`, async () => { + await measure( + "Brush", + { shape: "SPHERE", radius: r, filter: true }, + async () => { + await runStroke(BrushShape.SPHERE, r, true); + }, + ); + }); + }); + + const radii_dk = [16, 24, 32, 40, 48, 56, 64]; + + radii_dk.forEach((r) => { + it(`Calibrate: Brush DISK r=${r} (No Filter)`, async () => { + await measure( + "Brush", + { shape: "DISK", radius: r, filter: false }, + async () => { + await runStroke(BrushShape.DISK, r, false); + }, + ); + }); + + it(`Calibrate: Brush DISK r=${r} (WITH Filter)`, async () => { + await measure( + "Brush", + { shape: "DISK", radius: r, filter: true }, + async () => { + await runStroke(BrushShape.DISK, r, true); + }, + ); + }); + }); + + // -------------------------------------------------------------------------- + // 4. FLOOD FILL + // -------------------------------------------------------------------------- + + const setupFloodData = async () => { + mockSource0.storage.clear(); + const chunk = mockSource0.getChunk( + new Float32Array([0, 0, 0]), + ) as VolumeChunk; + chunk.data = null; + await mockSource0.download(chunk); + (chunk.data as BigUint64Array).fill(0n); + }; + + const floodSizes = [1000, 5000, 10000, 25000, 50000]; + + floodSizes.forEach((size) => { + it(`Calibrate: Flood Fill ${size} voxels`, async () => { + await setupFloodData(); + + await measure("FloodFill", { maxVoxels: size }, async () => { + const chunk = mockSource0.getChunk( + new Float32Array([0, 0, 0]), + ) as VolumeChunk; + (chunk.data as BigUint64Array).fill(0n); + + try { + await (controller as any).performFloodFill({ + type: VoxelOperationType.FLOOD_FILL, + seed: new Float32Array([32, 32, 32]), + value: 1n, + maxVoxels: size, + basis: { u: vec3.fromValues(1, 0, 0), v: vec3.fromValues(0, 1, 0) }, + }); + } catch (e: any) { + if (!e.message.includes("too many voxels")) throw e; + } + }); + }); + }); + + // -------------------------------------------------------------------------- + // 5. UNDO RESTORATION + // -------------------------------------------------------------------------- + + it("Calibrate: Undo", async () => { + // 1. Variable Chunks, Fixed Voxels (Chunk Overhead) + const chunkCounts = [1, 10, 50, 100]; + for (const count of chunkCounts) { + const changes = new Map(); + for (let i = 0; i < count; i++) { + const chunk = mockSource0.getChunk( + new Float32Array([i, 0, 0]), + ) as VolumeChunk; + if (!chunk.data) await mockSource0.download(chunk); + const key = `lod0#${i},0,0`; + changes.set(key, { + indices: new Uint32Array([0]), + oldValues: new BigUint64Array([1n]), + newValues: new BigUint64Array([2n]), + }); + } + (controller as any).undoStack.push({ + changes, + timestamp: 0, + description: "bench", + }); + + await measure("Undo", { chunks: count, voxelsTotal: count }, async () => { + const action = + (controller as any).redoStack.pop() || + (controller as any).undoStack.pop(); + (controller as any).undoStack.push(action); + await controller.undo(); + }); + } + + // 2. Fixed Chunk, Variable Voxels (Voxel Throughput) + const voxelCounts = [1000, 10000, 100000]; + for (const count of voxelCounts) { + const indices = new Uint32Array(count); + const vals = new BigUint64Array(count).fill(1n); + const changes = new Map([ + [`lod0#0,0,0`, { indices, oldValues: vals, newValues: vals }], + ]); + + const chunk = mockSource0.getChunk( + new Float32Array([0, 0, 0]), + ) as VolumeChunk; + if (!chunk.data) await mockSource0.download(chunk); + + (controller as any).undoStack.push({ + changes, + timestamp: 0, + description: "bench", + }); + + await measure("Undo", { chunks: 1, voxelsTotal: count }, async () => { + const action = + (controller as any).redoStack.pop() || + (controller as any).undoStack.pop(); + (controller as any).undoStack.push(action); + await controller.undo(); + }); + } + }); +}); From 55f57b464e1c283efa06cefa90f2aa4041d2f69d Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 22 Jan 2026 14:31:31 +0100 Subject: [PATCH 196/251] feat(voxel-annotation): update stamina bar logic to dynamically adjust offset based on brush radius --- src/layer/voxel_annotation/controls.ts | 13 +++++++++---- src/layer/voxel_annotation/index.ts | 21 ++++++++------------- src/voxel_annotation/TODOs.md | 2 +- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/layer/voxel_annotation/controls.ts b/src/layer/voxel_annotation/controls.ts index 9e19938338..b8936997ed 100644 --- a/src/layer/voxel_annotation/controls.ts +++ b/src/layer/voxel_annotation/controls.ts @@ -54,10 +54,11 @@ export function drawBrushCursor( layer: UserLayerWithVoxelEditing, panel: RenderedDataPanel, ctx: CanvasRenderingContext2D, -) { +): { radiusX: number; radiusY: number } { const context = getEditingContext(layer); + const radiusXY = { radiusX: -1, radiusY: -1 }; if (context === undefined || !(panel instanceof SliceViewPanel)) { - return; + return radiusXY; } const { projectionParameters } = panel.sliceView; @@ -65,12 +66,12 @@ export function drawBrushCursor( const { displayRank } = displayDimensionRenderInfo; if (displayRank < 2) { - return; + return radiusXY; } const chunkTransform = context.getChunkTransform(); if (!chunkTransform) { - return; + return radiusXY; } const { chunkToLayerTransform, layerRank } = chunkTransform; const { globalToRenderLayerDimensions } = chunkTransform.modelTransform; @@ -158,7 +159,11 @@ export function drawBrushCursor( ctx.strokeStyle = isEraser ? "rgb(97,0,0)" : "rgba(0, 0, 0, 1)"; ctx.lineWidth = 1.5; ctx.stroke(); + + return { radiusX, radiusY }; } + + return radiusXY; } export type VoxelTabElement = diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index 5569a11e84..4cebb74133 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -568,26 +568,22 @@ export function UserLayerWithVoxelEditingMixin< } const globalToolBinder = this.manager.root.toolBinder; const activation = globalToolBinder.activeTool_; - let isBrushActive = false; + let radiusY = 0; if ( activation && activation.tool.localBinder === this.toolBinder && activation.tool.toJSON() === BRUSH_TOOL_ID ) { - drawBrushCursor(this, panel, ctx); - isBrushActive = true; + const radiusXY = drawBrushCursor(this, panel, ctx); + if (radiusXY.radiusY > 0) { + radiusY = radiusXY.radiusY; + } } const editContext = getEditingContext(this); const pending = editContext?.totalPending.value || 0; - this.drawStaminaBar( - ctx, - panel.mouseX, - panel.mouseY, - pending, - isBrushActive, - ); + this.drawStaminaBar(ctx, panel.mouseX, panel.mouseY, pending, radiusY); } private drawStaminaBar( @@ -595,7 +591,7 @@ export function UserLayerWithVoxelEditingMixin< x: number, y: number, pendingCount: number, - isBrushActive: boolean, + brushOffset: number, ) { const ratio = Math.max(0, 1.0 - pendingCount / MAX_VOXEL_EDIT_STAMINA); if (ratio > 0.99) { @@ -606,8 +602,7 @@ export function UserLayerWithVoxelEditingMixin< const h = 5; const radius = h / 2; - // TODO: use the radiusY of updateBrushCursor to properly offset - const yOffset = 16 + (isBrushActive ? this.brushRadius.value * 2 : 0); + const yOffset = 16 + brushOffset; const xOffset = 1; const bx = x - w / 2 + xOffset; const by = y + yOffset; diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 5976005638..e171323b48 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -6,7 +6,7 @@ - optimize flushPendings and the downsampling - optimize the flood fill - add `ctrl + middleclick` to flood fill when the brush is active -- `ctrl + shift` is no longer displaying the red cursor +- `ctrl + shift` is no longer displaying the red cursor, it only appears after a click - preview of selective eraser is broken - optimize spheres using the full chunk - see about the list of pending edits for the preview From ae68146e8c446f769c86fd7808db285780cdcd4c Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 26 Jan 2026 17:43:03 +0100 Subject: [PATCH 197/251] refactor(voxel-annotation): use BackendVoxelAccessor for the downsmapling, keep the created BackendVoxelAccessor in cache and set a max cache size in BackendVoxelAccessor. --- src/voxel_annotation/backend.ts | 147 ++++++++++++++++---------------- 1 file changed, 72 insertions(+), 75 deletions(-) diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index fe9d6d622a..89e72fb61d 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -117,6 +117,9 @@ class BackendVoxelAccessor { private chunkContexts = new Map(); private pendingLoads = new Map>(); + // 64 chunks * (64^3 voxels * 8 bytes) ~= 134 MB max per LOD level if chunks are uncompressed 64^3 UINT64. + private readonly MAX_CACHE_SIZE = 64; + private readonly volMin: Float32Array; private readonly volMax: Float32Array; private readonly chunkDimension: Uint32Array; @@ -131,6 +134,11 @@ class BackendVoxelAccessor { this.fillValue = typeof fv === "bigint" ? fv : BigInt(fv); } + public invalidate(key: string) { + this.chunkContexts.delete(key); + this.pendingLoads.delete(key); + } + async getValue(x: number, y: number, z: number): Promise { if ( x < this.volMin[0] || @@ -149,14 +157,17 @@ class BackendVoxelAccessor { const key = `${cx},${cy},${cz}`; let ctx = this.chunkContexts.get(key); - if (!ctx) { + if (ctx) { + this.chunkContexts.delete(key); + this.chunkContexts.set(key, ctx); + } else { ctx = await this.getOrLoadChunkContext(key, cx, cy, cz); } return this.readLocal(ctx, x, y, z); } - private getOrLoadChunkContext( + public getOrLoadChunkContext( key: string, cx: number, cy: number, @@ -166,6 +177,13 @@ class BackendVoxelAccessor { if (promise) return promise; promise = this.loadChunkContext(key, cx, cy, cz).then((ctx) => { + if (this.chunkContexts.size >= this.MAX_CACHE_SIZE) { + const oldestKey = this.chunkContexts.keys().next().value; + if (oldestKey) { + this.chunkContexts.delete(oldestKey); + } + } + this.chunkContexts.set(key, ctx); this.pendingLoads.delete(key); return ctx; @@ -389,6 +407,7 @@ export class VoxelEditController extends SharedObject { private isProcessingDownsampleQueue: boolean = false; private brushCache = new BrushOptimizationCache(); + private accessors = new Map(); private morphologicalConfig = { growthThresholds: [ @@ -516,6 +535,9 @@ export class VoxelEditController extends SharedObject { indices, values, ); + const accessor = this.getAccessor(parsedKey.lodIndex); + accessor.invalidate(parsedKey.chunkKey); + newAction.changes.set(voxKey, change); } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -554,6 +576,17 @@ export class VoxelEditController extends SharedObject { this.updatePendingCount(); } + private getAccessor(lodIndex: number): BackendVoxelAccessor { + let accessor = this.accessors.get(lodIndex); + if (!accessor) { + const source = this.sources.get(lodIndex); + if (!source) throw new Error(`No source for LOD ${lodIndex}`); + accessor = new BackendVoxelAccessor(source); + this.accessors.set(lodIndex, accessor); + } + return accessor; + } + commitVoxels( edits: { key: string; @@ -641,99 +674,57 @@ export class VoxelEditController extends SharedObject { * @returns The key of the parent chunk that was updated, or null if the cascade should stop. */ private async downsampleStep(childKey: string): Promise { - // 1. Get child chunk and ensure its data is loaded. const childInfo = parseVoxChunkKey(childKey); if (childInfo === null) { console.error(`[Downsample] Invalid child key format: ${childKey}`); return null; } - const childSource = this.sources.get(childInfo.lodIndex); - if (!childSource) { - console.error( - `[Downsample] No source found for child LOD: ${childInfo.lodIndex}`, - ); + const childAccessor = this.getAccessor(childInfo.lodIndex); + const childCtx = await childAccessor.getOrLoadChunkContext( + childInfo.chunkKey, + childInfo.x, + childInfo.y, + childInfo.z, + ); + + if (!childCtx.data) { return null; } - const childChunk = childSource.getChunk( - new Float32Array([childInfo.x, childInfo.y, childInfo.z]), - ) as any; - if (!childChunk.data) { - try { - await childSource.download(childChunk, new AbortController().signal); - } catch (e) { - console.warn( - `[Downsample] Failed to download source chunk ${childKey}:`, - e, - ); - return null; - } - } - const childChunkData = childChunk.data as Uint32Array | BigUint64Array; + const childChunkData = childCtx.data as Uint32Array | BigUint64Array; const childRes = this.resolutions.get(childInfo.lodIndex)!; - // 2. Determine the parent chunk that corresponds to this child chunk. const parentInfo = this._getParentChunkInfo(childKey, childRes); if (parentInfo === null) { - // Reached the coarsest LOD, stop the cascade. return null; } const { parentKey, parentSource, parentRes } = parentInfo; - let dataToProcess = childChunkData; - const { compressedSegmentationBlockSize, dataType, chunkDataSize } = - childSource.spec; - if (compressedSegmentationBlockSize !== undefined) { - const numElements = - chunkDataSize[0] * chunkDataSize[1] * chunkDataSize[2]; - const compressedData = childChunkData as Uint32Array; - const baseOffset = compressedData.length > 0 ? compressedData[0] : 0; - if (dataType === DataType.UINT32) { - const uncompressedData = new Uint32Array(numElements); - if (baseOffset !== 0) { - decodeChannelUint32( - uncompressedData, - compressedData, - baseOffset, - chunkDataSize, - compressedSegmentationBlockSize, - ); - } - dataToProcess = uncompressedData; - } else { - // Assumes UINT64 - const uncompressedData = new BigUint64Array(numElements); - if (baseOffset !== 0) { - decodeChannelUint64( - uncompressedData, - compressedData, - baseOffset, - chunkDataSize, - compressedSegmentationBlockSize, - ); - } - dataToProcess = uncompressedData; - } - } + const childActualSize = [ + childCtx.maxX - childCtx.minX, + childCtx.maxY - childCtx.minY, + childCtx.maxZ - childCtx.minZ, + ]; - // 3. Calculate the update for the parent chunk based on the child chunk's data. const update = this._calculateParentUpdate( - dataToProcess, + childChunkData, childRes, parentRes, childInfo, + childActualSize ); if (update.indices.length === 0) { return parentKey; } - // 4. Commit the update to the parent chunk and notify the frontend. try { await parentSource.applyEdits( parentInfo.chunkKey, update.indices, update.values, ); + const parentAccessor = this.getAccessor(parentRes.lodIndex); + parentAccessor.invalidate(parentInfo.chunkKey); this.callChunkReload([parentKey]); } catch (e) { console.error( @@ -817,13 +808,17 @@ export class VoxelEditController extends SharedObject { childRes: VoxelLayerResolution & { invTransform: mat4 }, parentRes: VoxelLayerResolution & { invTransform: mat4 }, childInfo: { x: number; y: number; z: number }, + childActualSize: number[], ) { const indices: number[] = []; const values: bigint[] = []; const rank = childRes.chunkSize.length; const childChunkSize = childRes.chunkSize; + const [childDataW, childDataH, childDataD] = childActualSize; const parentChunkSize = parentRes.chunkSize; + const childDataLength = childChunkData.length; + // Transform to map a point in parent-voxel-space to a point in child-voxel-space. const parentVoxelToChildVoxelTransform = mat4.multiply( mat4.create(), @@ -911,7 +906,6 @@ export class VoxelEditController extends SharedObject { const corners = new Array(8).fill(0).map(() => vec3.create()); const transformedCorners = new Array(8).fill(0).map(() => vec3.create()); const sourceVoxels: bigint[] = []; - const [childW, childH, childD] = childChunkSize; const [parentW, parentH] = parentChunkSize; // Iterate over each voxel in the affected region of the parent chunk (in local coordinates) @@ -959,17 +953,24 @@ export class VoxelEditController extends SharedObject { // Collect all child voxels within this bounding box (in local coordinates) sourceVoxels.length = 0; const cStartX = Math.max(0, Math.floor(localChildMin[0])); - const cEndX = Math.min(childW, Math.ceil(localChildMax[0])); + const cEndX = Math.min(childDataW, Math.ceil(localChildMax[0])); const cStartY = Math.max(0, Math.floor(localChildMin[1])); - const cEndY = Math.min(childH, Math.ceil(localChildMax[1])); + const cEndY = Math.min(childDataH, Math.ceil(localChildMax[1])); const cStartZ = Math.max(0, Math.floor(localChildMin[2])); - const cEndZ = Math.min(childD, Math.ceil(localChildMax[2])); + const cEndZ = Math.min(childDataD, Math.ceil(localChildMax[2])); for (let cz = cStartZ; cz < cEndZ; ++cz) { for (let cy = cStartY; cy < cEndY; ++cy) { for (let cx = cStartX; cx < cEndX; ++cx) { - const srcIndex = cz * (childW * childH) + cy * childW + cx; - sourceVoxels.push(BigInt(childChunkData[srcIndex])); + const srcIndex = + cz * (childDataW * childDataH) + cy * childDataW + cx; + + if (srcIndex >= 0 && srcIndex < childDataLength) { + const val = childChunkData[srcIndex]; + if (val !== undefined) { + sourceVoxels.push(BigInt(val)); + } + } } } } @@ -1102,9 +1103,7 @@ export class VoxelEditController extends SharedObject { const { center, radius, value, shape, basis, filterValue } = op; const voxelSize = 1; // Hardcoded LOD 0 const sourceIndex = 0; - const source = this.sources.get(sourceIndex); - if (!source) throw new Error(`Brush operation requires a source.`); - const accessor = new BackendVoxelAccessor(source); + const accessor = this.getAccessor(sourceIndex); const cx = Math.round((center[0] ?? 0) / voxelSize); const cy = Math.round((center[1] ?? 0) / voxelSize); @@ -1205,9 +1204,7 @@ export class VoxelEditController extends SharedObject { private async performFloodFill(op: FloodFillOperation): Promise { const { seed, value: fillValue, maxVoxels, basis, filterValue } = op; const sourceIndex = 0; - const source = this.sources.get(sourceIndex); - if (!source) return; - const accessor = new BackendVoxelAccessor(source); + const accessor = this.getAccessor(sourceIndex); const startVoxelLod = vec3.round(vec3.create(), seed as vec3); const originalValue = await accessor.getValue( From 16df51faef07fa2a502144c16836c81c5165b6d7 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Tue, 27 Jan 2026 15:03:06 +0100 Subject: [PATCH 198/251] test(voxel-annotation): fix backend.spec.ts --- src/voxel_annotation/backend.spec.ts | 244 ++++++++++++++------------- src/voxel_annotation/backend.ts | 2 +- 2 files changed, 132 insertions(+), 114 deletions(-) diff --git a/src/voxel_annotation/backend.spec.ts b/src/voxel_annotation/backend.spec.ts index b8ca5ee399..0afda4869c 100644 --- a/src/voxel_annotation/backend.spec.ts +++ b/src/voxel_annotation/backend.spec.ts @@ -1,20 +1,5 @@ -/** - * @license - * Copyright 2025 Google Inc. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { ChunkState } from "#src/chunk_manager/base.js"; import type { VolumeChunk } from "#src/sliceview/volume/backend.js"; import { VolumeChunkSource } from "#src/sliceview/volume/backend.js"; import { DATA_TYPE_ARRAY_CONSTRUCTOR, DataType } from "#src/util/data_type.js"; @@ -29,6 +14,23 @@ import { } from "#src/voxel_annotation/base.js"; import type { RPC } from "#src/worker_rpc.js"; +const mockQueueManager = { + sources: new Set(), + adjustCapacitiesForChunk: vi.fn(), + updateChunkState: vi.fn(), + scheduleUpdate: vi.fn(), + moveChunkToFrontend: vi.fn(), + markRecentlyUsed: vi.fn(), + gl: {}, +}; + +const mockChunkManager = { + queueManager: mockQueueManager, + chunkQueueManager: mockQueueManager, + rpc: null, + memoize: { get: (_k: string, fn: Function) => fn() }, +}; + const mockRpc = { get: vi.fn(), invoke: vi.fn(), @@ -38,6 +40,48 @@ const mockRpc = { delete: vi.fn(), } as unknown as RPC; +const MOCK_SPEC = { + rank: 3, + chunkDataSize: new Uint32Array([2, 2, 2]), + dataType: 0, + lowerVoxelBound: new Float32Array([0, 0, 0]), + upperVoxelBound: new Float32Array([100, 100, 100]), + baseVoxelOffset: new Float32Array([0, 0, 0]), + fillValue: 0, +}; + +class MockBackendSource extends VolumeChunkSource { + public serverStorage = new Map(); + + async download(chunk: VolumeChunk) { + const key = chunk.chunkGridPosition.join(","); + if (this.serverStorage.has(key)) { + const buffer = this.serverStorage.get(key)!; + const Ctor = DATA_TYPE_ARRAY_CONSTRUCTOR[this.spec.dataType]; + chunk.data = new Ctor(buffer.slice(0)); + } + } + + async writeChunk(chunk: VolumeChunk) { + const key = chunk.chunkGridPosition.join(","); + if (chunk.data) { + this.serverStorage.set(key, chunk.data.buffer.slice(0) as ArrayBuffer); + } + } +} + +const createMockSource = (specOverride: any = {}) => { + const source = new MockBackendSource(mockRpc, { + spec: { ...MOCK_SPEC, ...specOverride }, + chunkManager: 0, + }); + vi.spyOn(source, "getChunk"); + vi.spyOn(source, "applyEdits"); + vi.spyOn(source, "download"); + vi.spyOn(source, "writeChunk"); + return source; +}; + const resConfig = ( lod: number, scale: [number, number, number], @@ -82,36 +126,18 @@ function flattenGrid(grid: Grid3D, Ctor: any = Uint32Array) { return { data, size: [w, h, d] as [number, number, number] }; } -class MockBackendSource extends VolumeChunkSource { - public serverStorage = new Map(); - - async download(chunk: VolumeChunk) { - const key = chunk.chunkGridPosition.join(","); - if (this.serverStorage.has(key)) { - const buffer = this.serverStorage.get(key)!; - const Ctor = DATA_TYPE_ARRAY_CONSTRUCTOR[this.spec.dataType]; - chunk.data = new Ctor(buffer.slice(0)); - } - } - - async writeChunk(chunk: VolumeChunk) { - const key = chunk.chunkGridPosition.join(","); - if (chunk.data) { - this.serverStorage.set(key, chunk.data.buffer.slice(0) as ArrayBuffer); - } - } -} - describe("VoxelEditController: _calculateParentUpdate", () => { let controller: VoxelEditController; let runDownsample: Function; beforeEach(() => { vi.resetAllMocks(); - (mockRpc.get as any).mockImplementation((id: number) => ({ - rpcId: id, - spec: { rank: 3, chunkDataSize: new Uint32Array([2, 2, 2]), dataType: 0 }, - })); + (mockRpc.get as any).mockImplementation((id: number) => { + if (id === 0) return mockChunkManager; + const source = createMockSource(); + source.rpcId = id; + return source; + }); }); const runScenario = ( @@ -149,6 +175,7 @@ describe("VoxelEditController: _calculateParentUpdate", () => { (controller as any).resolutions.get(0), (controller as any).resolutions.get(1), childChunkOffset, + childSize, ); const [pw, ph, pd] = parentChunkSize; @@ -429,12 +456,12 @@ describe("VoxelEditController: _getParentChunkInfo", () => { let controller: VoxelEditController; const setupController = (resConfigs: any[]) => { - (mockRpc.get as any).mockImplementation((id: number) => ({ - rpcId: id, - spec: { rank: 3, chunkDataSize: new Uint32Array([2, 2, 2]), dataType: 0 }, - applyEdits: vi.fn(), - getChunk: vi.fn(), - })); + (mockRpc.get as any).mockImplementation((id: number) => { + if (id === 0) return mockChunkManager; + const source = createMockSource(); + source.rpcId = id; + return source; + }); controller = new VoxelEditController(mockRpc, { resolutions: resConfigs }); return controller; }; @@ -532,31 +559,38 @@ describe("VoxelEditController: Downsampling Integration", () => { let grandParentSource: any; const setupIntegration = (numLevels: number = 2) => { - childSource = { - spec: { rank: 3, chunkDataSize: new Uint32Array([2, 2, 2]), dataType: 0 }, - getChunk: vi.fn().mockReturnValue({ data: new Uint32Array(8).fill(1) }), - download: vi.fn().mockResolvedValue(undefined), - applyEdits: vi.fn().mockResolvedValue({}), - invalidateChunks: vi.fn(), - }; + childSource = createMockSource(); + vi.spyOn(childSource, "getChunk").mockImplementation( + (pos: Float32Array) => ({ + data: new Uint32Array(8).fill(1), + chunkDataSize: MOCK_SPEC.chunkDataSize, + chunkGridPosition: pos, + state: ChunkState.SYSTEM_MEMORY, + }), + ); - parentSource = { - spec: { rank: 3, chunkDataSize: new Uint32Array([2, 2, 2]), dataType: 0 }, - getChunk: vi.fn().mockReturnValue({ data: new Uint32Array(8).fill(0) }), - download: vi.fn().mockResolvedValue(undefined), - applyEdits: vi.fn().mockResolvedValue({}), - invalidateChunks: vi.fn(), - }; + parentSource = createMockSource(); + vi.spyOn(parentSource, "getChunk").mockImplementation( + (pos: Float32Array) => ({ + data: new Uint32Array(8).fill(0), + chunkDataSize: MOCK_SPEC.chunkDataSize, + chunkGridPosition: pos, + state: ChunkState.SYSTEM_MEMORY, + }), + ); - grandParentSource = { - spec: { rank: 3, chunkDataSize: new Uint32Array([2, 2, 2]), dataType: 0 }, - getChunk: vi.fn().mockReturnValue({ data: new Uint32Array(8).fill(0) }), - download: vi.fn().mockResolvedValue(undefined), - applyEdits: vi.fn().mockResolvedValue({}), - invalidateChunks: vi.fn(), - }; + grandParentSource = createMockSource(); + vi.spyOn(grandParentSource, "getChunk").mockImplementation( + (pos: Float32Array) => ({ + data: new Uint32Array(8).fill(0), + chunkDataSize: MOCK_SPEC.chunkDataSize, + chunkGridPosition: pos, + state: ChunkState.SYSTEM_MEMORY, + }), + ); (mockRpc.get as any).mockImplementation((id: number) => { + if (id === 0) return mockChunkManager; if (id === 100) return childSource; if (id === 101) return parentSource; if (id === 102) return grandParentSource; @@ -613,6 +647,7 @@ describe("VoxelEditController: Downsampling Integration", () => { parentSource.applyEdits.mockImplementation(async () => { parentSource.getChunk.mockReturnValue({ data: new Uint32Array(8).fill(1), + chunkDataSize: MOCK_SPEC.chunkDataSize, }); }); @@ -648,7 +683,7 @@ describe("VoxelEditController: Downsampling Integration", () => { it("Lazy Loading: Downloads child chunk if missing", async () => { setupIntegration(2); - const emptyChunk = { data: null }; + const emptyChunk = { data: null, chunkDataSize: MOCK_SPEC.chunkDataSize }; childSource.getChunk.mockReturnValue(emptyChunk); childSource.download.mockImplementation(async (chunk: any) => { @@ -668,7 +703,10 @@ describe("VoxelEditController: Downsampling Integration", () => { setupIntegration(2); const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - childSource.getChunk.mockReturnValue({ data: null }); + childSource.getChunk.mockReturnValue({ + data: null, + chunkDataSize: MOCK_SPEC.chunkDataSize, + }); childSource.download.mockRejectedValue(new Error("Network Error")); const key = makeVoxChunkKey("0,0,0", 0); @@ -716,21 +754,18 @@ describe("VoxelEditController: flushPending", () => { vi.useFakeTimers(); vi.clearAllMocks(); - mockSource0 = { - spec: { rank: 3, chunkDataSize: new Uint32Array([2, 2, 2]), dataType: 0 }, - applyEdits: vi.fn().mockResolvedValue({ - indices: new Uint32Array([]), - oldValues: new BigUint64Array([]), - newValues: new BigUint64Array([]), - }), - }; + mockSource0 = createMockSource(); + vi.spyOn(mockSource0, "applyEdits").mockResolvedValue({ + indices: new Uint32Array([]), + oldValues: new BigUint64Array([]), + newValues: new BigUint64Array([]), + }); - mockSource1 = { - spec: { rank: 3, chunkDataSize: new Uint32Array([2, 2, 2]), dataType: 0 }, - applyEdits: vi.fn().mockResolvedValue({}), - }; + mockSource1 = createMockSource(); + vi.spyOn(mockSource1, "applyEdits").mockResolvedValue({}); (mockRpc.get as any).mockImplementation((id: number) => { + if (id === 0) return mockChunkManager; if (id === 100) return mockSource0; if (id === 101) return mockSource1; if (id === 999) return { value: 0 }; @@ -907,17 +942,15 @@ describe("VoxelEditController: Undo/Redo", () => { beforeEach(() => { vi.clearAllMocks(); - mockSource0 = { - spec: { rank: 3, chunkDataSize: new Uint32Array([2, 2, 2]), dataType: 0 }, - applyEdits: vi.fn().mockResolvedValue({ - indices: new Uint32Array([]), - oldValues: new BigUint64Array([]), - newValues: new BigUint64Array([]), - }), - invalidateChunks: vi.fn(), - }; + mockSource0 = createMockSource(); + vi.spyOn(mockSource0, "applyEdits").mockResolvedValue({ + indices: new Uint32Array([]), + oldValues: new BigUint64Array([]), + newValues: new BigUint64Array([]), + }); (mockRpc.get as any).mockImplementation((id: number) => { + if (id === 0) return mockChunkManager; if (id === 100) return mockSource0; if (id === 999) return { value: 0 }; return null; @@ -1137,25 +1170,6 @@ describe("VoxelEditController: Tool Operations", () => { vi.useFakeTimers(); vi.clearAllMocks(); - const mockChunkManager = { - queueManager: { - sources: new Set(), - adjustCapacitiesForChunk: vi.fn(), - updateChunkState: vi.fn(), - scheduleUpdate: vi.fn(), - moveChunkToFrontend: vi.fn(), - markRecentlyUsed: vi.fn(), - gl: {}, - }, - }; - - (mockRpc.get as any).mockImplementation((id: number) => { - if (id === 0) return mockChunkManager; - if (id === 100) return mockSource; - if (id === 999) return { value: 0 }; - return null; - }); - const spec = { rank: 3, chunkDataSize: new Uint32Array([10, 10, 10]), @@ -1165,16 +1179,20 @@ describe("VoxelEditController: Tool Operations", () => { baseVoxelOffset: new Float32Array([0, 0, 0]), fillValue: 0n, }; - mockSource = new MockBackendSource(mockRpc, { - spec: spec, - chunkManager: 0, - }); + mockSource = createMockSource({ ...spec }); vi.spyOn(mockSource, "applyEdits").mockResolvedValue({ indices: new Uint32Array([]), oldValues: new BigUint64Array([]), newValues: new BigUint64Array([]), }); + (mockRpc.get as any).mockImplementation((id: number) => { + if (id === 0) return mockChunkManager; + if (id === 100) return mockSource; + if (id === 999) return { value: 0 }; + return null; + }); + controller = new VoxelEditController(mockRpc, { resolutions: [resConfig(0, [1, 1, 1], [10, 10, 10])], pendingOpCount: 999, diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 89e72fb61d..a41f3064e2 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -711,7 +711,7 @@ export class VoxelEditController extends SharedObject { childRes, parentRes, childInfo, - childActualSize + childActualSize, ); if (update.indices.length === 0) { return parentKey; From 9b392db70af82b5694fbec758a5d6abfc3d0e7a6 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Tue, 27 Jan 2026 15:30:08 +0100 Subject: [PATCH 199/251] feat(voxel-annotation): remove the temporary brush shapes (kept SPHERE_DISPLYING_3_DISKS as the SPHERE), optimize the frontend brush by flattening vectors --- src/voxel_annotation/base.ts | 2 - src/voxel_annotation/frontend.ts | 135 ++++++++++++++++++++----------- 2 files changed, 87 insertions(+), 50 deletions(-) diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index 988d3164fc..e3c4909130 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -151,8 +151,6 @@ export function getBasisFromNormal(n: vec3) { export enum BrushShape { DISK = 0, SPHERE = 1, - SPHERE_DISPLAYING_DISK = 2, - SPHERE_DISPLAYING_3_DISKS = 3, } export interface VoxelEditControllerHost { diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 8d55306558..862810e2d0 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -154,84 +154,123 @@ export class VoxelEditController extends SharedObject { } r -= 1; const rr = r * r; - const { u, v } = basis as { u: vec3; v: vec3 }; + const { u: uVec, v: vVec } = basis as { u: vec3; v: vec3 }; const n = vec3.create(); - vec3.cross(n, u, v); + vec3.cross(n, uVec, vVec); vec3.normalize(n, n); - const voxelsToPaint: Float32Array[] = []; + const ux = uVec[0], + uy = uVec[1], + uz = uVec[2]; + const vx = vVec[0], + vy = vVec[1], + vz = vVec[2]; + const nx = n[0], + ny = n[1], + nz = n[2]; - if ( - shape === BrushShape.DISK || - shape === BrushShape.SPHERE_DISPLAYING_DISK - ) { + // WATCHOUT: update this value if the max possible voxel count changes + const maxCapacity = Math.ceil((2 * r + 1) ** 2 * 4); + const voxelBuffer = new Int32Array(maxCapacity * 3); + let voxelCount = 0; + + let baseSource: VolumeChunkSource | undefined; + const tempPos = new Float32Array(3); + + if (filterValue !== undefined) { + const sourcesByScale = this.host.primarySource.getSources( + this.getIdentitySliceViewSourceOptions(), + ); + baseSource = sourcesByScale[0][0].chunkSource as VolumeChunkSource; + } + + const addVoxel = (x: number, y: number, z: number) => { + if (filterValue && baseSource !== undefined) { + tempPos[0] = x; + tempPos[1] = y; + tempPos[2] = z; + const val = baseSource.getValueAt(tempPos, this.singleChannelAccess); + if (val != null) { + const bigVal = typeof val === "bigint" ? val : BigInt(val); + if (bigVal !== filterValue) return; + } + } + + const base = voxelCount * 3; + voxelBuffer[base] = x; + voxelBuffer[base + 1] = y; + voxelBuffer[base + 2] = z; + voxelCount++; + }; + + if (shape === BrushShape.DISK) { for (let j = -r; j <= r; ++j) { for (let i = -r; i <= r; ++i) { if (i * i + j * j <= rr) { - const point = vec3.fromValues(cx, cy, cz); - vec3.scaleAndAdd(point, point, u, i); - vec3.scaleAndAdd(point, point, v, j); - voxelsToPaint.push(point as Float32Array); - } - } - } - } else if (shape === BrushShape.SPHERE) { - for (let dz = -r; dz <= r; ++dz) { - for (let dy = -r; dy <= r; ++dy) { - for (let dx = -r; dx <= r; ++dx) { - if (dx * dx + dy * dy + dz * dz <= rr) { - voxelsToPaint.push(new Float32Array([cx + dx, cy + dy, cz + dz])); - } + const px = Math.round(cx + ux * i + vx * j); + const py = Math.round(cy + uy * i + vy * j); + const pz = Math.round(cz + uz * i + vz * j); + addVoxel(px, py, pz); } } } } else { - const point = vec3.create(); - const center = vec3.fromValues(cx, cy, cz); - for (let j = -r; j <= r; ++j) { for (let i = -r; i <= r; ++i) { if (i * i + j * j <= rr) { - vec3.copy(point, center); - vec3.scaleAndAdd(point, point, u, i); - vec3.scaleAndAdd(point, point, v, j); - voxelsToPaint.push(Float32Array.from(point)); - - vec3.copy(point, center); - vec3.scaleAndAdd(point, point, u, i); - vec3.scaleAndAdd(point, point, n, j); - voxelsToPaint.push(Float32Array.from(point)); - - vec3.copy(point, center); - vec3.scaleAndAdd(point, point, n, i); - vec3.scaleAndAdd(point, point, v, j); - voxelsToPaint.push(Float32Array.from(point)); + let px = Math.round(cx + ux * i + vx * j); + let py = Math.round(cy + uy * i + vy * j); + let pz = Math.round(cz + uz * i + vz * j); + addVoxel(px, py, pz); + + px = Math.round(cx + ux * i + nx * j); + py = Math.round(cy + uy * i + ny * j); + pz = Math.round(cz + uz * i + nz * j); + addVoxel(px, py, pz); + + px = Math.round(cx + nx * i + vx * j); + py = Math.round(cy + ny * i + vy * j); + pz = Math.round(cz + nz * i + vz * j); + addVoxel(px, py, pz); } } } } - if (voxelsToPaint.length > 0) { + if (voxelCount > 0) { const previewSource = this.host.previewSource!.getSources( this.getIdentitySliceViewSourceOptions(), )[0][0].chunkSource as InMemoryVolumeChunkSource; const value = valueGetter(true); const edits = new Map(); + const { chunkDataSize } = previewSource.spec; + const sizeX = chunkDataSize[0]; + const sizeY = chunkDataSize[1]; + const sizeZ = chunkDataSize[2]; + const strideY = sizeX; + const strideZ = sizeX * sizeY; + + for (let i = 0; i < voxelCount; ++i) { + const base = i * 3; + const x = voxelBuffer[base]; + const y = voxelBuffer[base + 1]; + const z = voxelBuffer[base + 2]; + + const cx = Math.floor(x / sizeX); + const cy = Math.floor(y / sizeY); + const cz = Math.floor(z / sizeZ); + + const lx = x - cx * sizeX; + const ly = y - cy * sizeY; + const lz = z - cz * sizeZ; - for (const voxelCoord of voxelsToPaint) { - const { chunkGridPosition, positionWithinChunk } = - previewSource.computeChunkIndices(voxelCoord); - const key = chunkGridPosition.join(); + const key = `${cx},${cy},${cz}`; let entry = edits.get(key); if (!entry) { entry = { indices: [], value }; edits.set(key, entry); } - const { chunkDataSize } = previewSource.spec; - const index = - (positionWithinChunk[2] * chunkDataSize[1] + positionWithinChunk[1]) * - chunkDataSize[0] + - positionWithinChunk[0]; + const index = lz * strideZ + ly * strideY + lx; entry.indices.push(index); } previewSource.applyLocalEdits(edits); From 818131cee2a5b841228e938dd67b79eba9db083f Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Tue, 27 Jan 2026 17:36:28 +0100 Subject: [PATCH 200/251] fix(voxel-annotation): make the brush cursor red again when `xtrl+shift` is held --- src/layer/voxel_annotation/controls.ts | 2 +- src/layer/voxel_annotation/index.ts | 32 ++++++++++++++++---------- src/ui/voxel_annotations.ts | 25 ++++++++++---------- src/voxel_annotation/TODOs.md | 8 +++---- 4 files changed, 38 insertions(+), 29 deletions(-) diff --git a/src/layer/voxel_annotation/controls.ts b/src/layer/voxel_annotation/controls.ts index b8936997ed..fcab76f16a 100644 --- a/src/layer/voxel_annotation/controls.ts +++ b/src/layer/voxel_annotation/controls.ts @@ -147,7 +147,7 @@ export function drawBrushCursor( ); ctx.restore(); - const isEraser = layer.shouldErase(); + const isEraser = layer.shouldErase() || layer.cursorInEraseMode.value; const color = "white"; ctx.fillStyle = isEraser ? "red" : color; ctx.globalAlpha = 0.2; diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index 4cebb74133..67d9cf7dc5 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -443,6 +443,7 @@ export declare abstract class UserLayerWithVoxelEditing extends UserLayer { brushShape: TrackableEnum; floodMaxVoxels: TrackableValue; paintValue: TrackableValue; + cursorInEraseMode: TrackableBoolean; editingContexts: Map; @@ -454,6 +455,7 @@ export declare abstract class UserLayerWithVoxelEditing extends UserLayer { abstract setVoxelPaintValue(value: any): bigint; setEraseState(erase: boolean): void; shouldErase(): boolean; + scheduleOverlayRedraw(): void; initializeVoxelEditingForSubsource( loadedSubsource: LoadedDataSubsource, @@ -480,6 +482,7 @@ export function UserLayerWithVoxelEditingMixin< lockToSelectedValue = new TrackableBoolean(false); brushShape = new TrackableEnum(BrushShape, BrushShape.DISK); floodMaxVoxels = new TrackableValue(10000, verifyFiniteFloat); + cursorInEraseMode = new TrackableBoolean(false, false); private _isInEraseState = false; @@ -504,24 +507,21 @@ export function UserLayerWithVoxelEditingMixin< ), ); - const trigger = () => { - for (const panel of this.manager.root.display.panels) { - if (panel instanceof SliceViewPanel) { - panel.scheduleOverlayRedraw(); - } - } - }; - - this.brushRadius.changed.add(trigger); - this.manager.root.layerSelectedValues.mouseState.changed.add(trigger); + this.brushRadius.changed.add(this.scheduleOverlayRedraw); + this.manager.root.layerSelectedValues.mouseState.changed.add( + this.scheduleOverlayRedraw, + ); + this.cursorInEraseMode.changed.add(this.scheduleOverlayRedraw); this.layersChanged.add(() => { const ctx = getEditingContext(this); if (ctx) { - this.registerDisposer(ctx.totalPending.changed.add(trigger)); + this.registerDisposer( + ctx.totalPending.changed.add(this.scheduleOverlayRedraw), + ); } }); - trigger(); + this.scheduleOverlayRedraw(); this.tabs.add("Draw", { label: "Draw", @@ -534,6 +534,14 @@ export function UserLayerWithVoxelEditingMixin< }); } + scheduleOverlayRedraw = () => { + for (const panel of this.manager.root.display.panels) { + if (panel instanceof SliceViewPanel) { + panel.scheduleOverlayRedraw(); + } + } + }; + private boundPanelCleanups = new Map void>(); private bindOverlayToPanels() { for (const panel of this.manager.root.display.panels) { diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 4cca2f61c6..1ce92e66e6 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -23,9 +23,9 @@ import { } from "#src/layer/voxel_annotation/controls.js"; import type { UserLayerWithVoxelEditing } from "#src/layer/voxel_annotation/index.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; -import { SliceViewPanel } from "#src/sliceview/panel.js"; import { StatusMessage } from "#src/status.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; +import { linkWatchableValue } from "#src/trackable_value.js"; import { LayerTool, makeToolActivationStatusMessageWithHeader, @@ -261,20 +261,21 @@ export class VoxelBrushTool extends BaseVoxelTool { activate(activation: ToolActivation): boolean { if (!super.activate(activation)) return false; - const trigger = () => { - for (const panel of this.layer.manager.root.display.panels) { - if (panel instanceof SliceViewPanel) { - panel.scheduleOverlayRedraw(); - } - } - }; - trigger(); - activation.registerDisposer(this.cursorEraseMode.changed.add(trigger)); + + activation.registerDisposer( + linkWatchableValue(this.cursorEraseMode, this.layer.cursorInEraseMode), + ); + activation.registerDisposer(() => { - trigger(); + this.layer.cursorInEraseMode.value = false; this.resetCursor(); + this.layer.scheduleOverlayRedraw(); }); - activation.registerDisposer(this.mouseState.changed.add(trigger)); + + activation.registerDisposer( + this.mouseState.changed.add(this.layer.scheduleOverlayRedraw), + ); + this.layer.scheduleOverlayRedraw(); return true; } diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index e171323b48..bbd0d1c632 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -2,15 +2,15 @@ ### priority -- optimize frontend brush (test caching etc..) -- optimize flushPendings and the downsampling -- optimize the flood fill - add `ctrl + middleclick` to flood fill when the brush is active - `ctrl + shift` is no longer displaying the red cursor, it only appears after a click - preview of selective eraser is broken +- when chunk write fails, the chunk is not reloaded + +- optimize flushPendings and the downsampling +- optimize the flood fill - optimize spheres using the full chunk - see about the list of pending edits for the preview -- when chunk write fails, the chunk is not reloaded ### later From 98eb68a4023bb3788e14f75aae7f177b4d45a497 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 28 Jan 2026 11:05:32 +0100 Subject: [PATCH 201/251] feat(voxel-annotation): add `ctrl+middleclick` shortcut for flood fill when brush tool is active --- src/ui/voxel_annotations.ts | 193 ++++++++++++++++++++-------------- src/voxel_annotation/TODOs.md | 4 +- 2 files changed, 115 insertions(+), 82 deletions(-) diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 1ce92e66e6..077155ccb9 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -48,6 +48,7 @@ import { const BRUSH_INPUT_MAP = EventActionMap.fromObject({ ["at:control+mousedown0"]: "paint-voxels", ["at:control+shift+mousedown0"]: "erase-voxels", + ["at:control+shift?+mousedown1"]: "flood-fill-shortcut", }); const FLOOD_INPUT_MAP = EventActionMap.fromObject({ @@ -60,6 +61,40 @@ const CONTROLS_FOR_TOOL = new Map([ [FLOODFILL_TOOL_ID, ["vox-flood-max-voxels"]], ]); +function getFloodFillCursor(erase: boolean) { + const lightColor = erase ? "#FF8888" : "#FFFFFF"; + const darkColor = erase ? "#610000" : "#000000"; + + const floodFillSVG = ` + + + + + + + + + + + + + + + + +`.replace(/\s\s+/g, " "); + + return `url('data:image/svg+xml;utf8,${encodeURIComponent(floodFillSVG)}') 24 24, crosshair`; +} + abstract class BaseVoxelTool extends LayerTool { protected latestMouseState: MouseSelectionState | null = null; private lastNormal: vec3 | undefined = undefined; @@ -228,6 +263,52 @@ abstract class BaseVoxelTool extends LayerTool { } } + protected async performFloodFill(erasing: boolean): Promise { + const editContext = getEditingContext(this.layer); + if (editContext === undefined) { + StatusMessage.showTemporaryMessage( + 'Voxel editing is not available. Please select a writable volume source in the "Source" tab.', + 5000, + ); + return; + } + const seed = this.getPoint(this.mouseState); + const basis = this.getBasis(); + if (!seed || !basis) { + StatusMessage.showTemporaryMessage( + "Unable to retrieve mouse position. Please try again.", + 5000, + ); + return; + } + try { + const value = this.layer.getVoxelPaintValue(erasing); + const max = Number(this.layer.floodMaxVoxels.value); + if (!Number.isFinite(max) || max <= 0) { + throw new Error("Invalid max fill voxels setting"); + } + + const filterValue = + this.layer.lockToSelectedValue.value && erasing + ? this.layer.getVoxelPaintValue(false)(false) + : undefined; + + void editContext + .floodFillPlane2D( + new Float32Array(seed), + value, + Math.floor(max), + basis, + filterValue, + ) + .catch((e: any) => + StatusMessage.showTemporaryMessage(String(e?.message ?? e)), + ); + } catch (e: any) { + StatusMessage.showTemporaryMessage(String(e?.message ?? e)); + } + } + abstract activationCallback(activation: ToolActivation): void; abstract deactivationCallback(activation: ToolActivation): void; @@ -258,6 +339,7 @@ export class VoxelBrushTool extends BaseVoxelTool { private lastPoint: Int32Array | undefined; private mouseDisposer: (() => void) | undefined; private animationFrameHandle: number | null = null; + private cursorResetTimer: number | null = null; activate(activation: ToolActivation): boolean { if (!super.activate(activation)) return false; @@ -267,6 +349,10 @@ export class VoxelBrushTool extends BaseVoxelTool { ); activation.registerDisposer(() => { + if (this.cursorResetTimer !== null) { + clearTimeout(this.cursorResetTimer); + this.cursorResetTimer = null; + } this.layer.cursorInEraseMode.value = false; this.resetCursor(); this.layer.scheduleOverlayRedraw(); @@ -276,9 +362,34 @@ export class VoxelBrushTool extends BaseVoxelTool { this.mouseState.changed.add(this.layer.scheduleOverlayRedraw), ); this.layer.scheduleOverlayRedraw(); + + activation.bindAction( + "flood-fill-shortcut", + (event: ActionEvent) => { + event.stopPropagation(); + this.triggerFloodFill(event.detail.shiftKey); + }, + ); return true; } + private triggerFloodFill(erasing: boolean) { + const wasErasing = this.layer.shouldErase(); + this.layer.setEraseState(erasing); + + if (this.cursorResetTimer !== null) clearTimeout(this.cursorResetTimer); + this.setCursor(getFloodFillCursor(erasing)); + + this.performFloodFill(erasing).finally(() => { + if (this.cursorResetTimer !== null) clearTimeout(this.cursorResetTimer); + this.cursorResetTimer = window.setTimeout(() => { + this.layer.setEraseState(wasErasing); + this.resetCursor(); + this.cursorResetTimer = null; + }, 1000); + }); + } + activationCallback(_activation: ToolActivation): void { if (getEditingContext(this.layer) === undefined) { StatusMessage.showTemporaryMessage( @@ -408,46 +519,12 @@ export class VoxelBrushTool extends BaseVoxelTool { } export class VoxelFloodFillTool extends BaseVoxelTool { - private getCursor() { - const lightColor = this.cursorEraseMode.value ? "#FF8888" : "#FFFFFF"; - const darkColor = this.cursorEraseMode.value ? "#610000" : "#000000"; - - const floodFillSVG = ` - - - - - - - - - - - - - - - - -`.replace(/\s\s+/g, " "); - - return `url('data:image/svg+xml;utf8,${encodeURIComponent(floodFillSVG)}') 24 24, crosshair`; - } - activate(activation: ToolActivation) { if (!super.activate(activation)) return false; - this.setCursor(this.getCursor()); + this.setCursor(getFloodFillCursor(this.cursorEraseMode.value)); activation.registerDisposer( this.cursorEraseMode.changed.add(() => { - this.setCursor(this.getCursor()); + this.setCursor(getFloodFillCursor(this.cursorEraseMode.value)); }), ); activation.registerDisposer(() => { @@ -457,49 +534,7 @@ export class VoxelFloodFillTool extends BaseVoxelTool { } activationCallback(_activation: ToolActivation): void { - const editContext = getEditingContext(this.layer); - if (editContext === undefined) { - StatusMessage.showTemporaryMessage( - 'Voxel editing is not available. Please select a writable volume source in the "Source" tab.', - 5000, - ); - return; - } - const seed = this.getPoint(this.mouseState); - const basis = this.getBasis(); - if (!seed || !basis) { - StatusMessage.showTemporaryMessage( - "Unable to retrieve mouse position. Please try again.", - 5000, - ); - return; - } - try { - const value = this.layer.getVoxelPaintValue(this.layer.shouldErase()); - const max = Number(this.layer.floodMaxVoxels.value); - if (!Number.isFinite(max) || max <= 0) { - throw new Error("Invalid max fill voxels setting"); - } - - const filterValue = - this.layer.lockToSelectedValue.value && this.layer.shouldErase() - ? this.layer.getVoxelPaintValue(false)(false) - : undefined; - - void editContext - .floodFillPlane2D( - new Float32Array(seed), - value, - Math.floor(max), - basis, - filterValue, - ) - .catch((e: any) => - StatusMessage.showTemporaryMessage(String(e?.message ?? e)), - ); - } catch (e: any) { - StatusMessage.showTemporaryMessage(String(e?.message ?? e)); - } + this.performFloodFill(this.layer.shouldErase()); } bindToolInput(activation: ToolActivation) { diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index bbd0d1c632..807d576307 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -2,10 +2,8 @@ ### priority -- add `ctrl + middleclick` to flood fill when the brush is active -- `ctrl + shift` is no longer displaying the red cursor, it only appears after a click -- preview of selective eraser is broken - when chunk write fails, the chunk is not reloaded +- add flood fill preview - optimize flushPendings and the downsampling - optimize the flood fill From bcedbb6bce8c5c37451c6485050f78e66265f1ab Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 28 Jan 2026 14:13:55 +0100 Subject: [PATCH 202/251] feat(voxel-annotation): add fast flood fill preview time limited (currently 50ms) and synced, this mean that it will temporarly override pixel of chunks not loaded in memory. This feature may be discussed as the caused artifacts may be judged too strong. --- src/voxel_annotation/frontend.ts | 148 +++++++++++++++++++++++++++++-- 1 file changed, 140 insertions(+), 8 deletions(-) diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 862810e2d0..2d1d6ffe54 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -30,6 +30,7 @@ import type { VoxelValueGetter, } from "#src/voxel_annotation/base.js"; import { + makeVoxChunkKey, BrushShape, parseVoxChunkKey, VOX_EDIT_BACKEND_RPC_ID, @@ -295,15 +296,146 @@ export class VoxelEditController extends SharedObject { basis: { u: Float32Array; v: Float32Array }, filterValue?: bigint, ) { + const previewValue = fillValueGetter(true); + const sourcesByScale = this.host.primarySource.getSources( + this.getIdentitySliceViewSourceOptions(), + ); + const primaryChunkSource = sourcesByScale[0][0] + .chunkSource as VolumeChunkSource; + + const previewMultiscale = this.host.previewSource; + if (!previewMultiscale) return; + const previewChunkSource = previewMultiscale.getSources( + this.getIdentitySliceViewSourceOptions(), + )[0][0].chunkSource as InMemoryVolumeChunkSource; + + const startX = Math.round(startPositionCanonical[0]); + const startY = Math.round(startPositionCanonical[1]); + const startZ = Math.round(startPositionCanonical[2]); + + const tempPos = new Float32Array(3); + + const getValue = (x: number, y: number, z: number): bigint | null => { + tempPos[0] = x; + tempPos[1] = y; + tempPos[2] = z; + + const previewVal = previewChunkSource.getValueAt( + tempPos, + this.singleChannelAccess, + ); + if (previewVal != null) { + return typeof previewVal === "bigint" ? previewVal : BigInt(previewVal); + } + + const primaryVal = primaryChunkSource.getValueAt( + tempPos, + this.singleChannelAccess, + ); + if (primaryVal != null) { + return typeof primaryVal === "bigint" ? primaryVal : BigInt(primaryVal); + } + return null; + }; + + const originalValue = getValue(startX, startY, startZ); + if (originalValue === null) return; + if (filterValue !== undefined && originalValue !== filterValue) return; + if (originalValue === previewValue) return; + + const visited = new Set(); + const queue: [number, number][] = [[0, 0]]; + visited.add("0,0"); + + const edits = new Map(); + const { chunkDataSize } = previewChunkSource.spec; + const sizeX = chunkDataSize[0]; + const sizeY = chunkDataSize[1]; + const sizeZ = chunkDataSize[2]; + const strideY = sizeX; + const strideZ = sizeX * sizeY; + + let filledCount = 0; + const start = Date.now(); + const MAX_TIME_MS = 50; + const ux = basis.u[0], + uy = basis.u[1], + uz = basis.u[2]; + const vx = basis.v[0], + vy = basis.v[1], + vz = basis.v[2]; + + const affectedKeys: string[] = []; + + while (queue.length > 0 && filledCount < maxVoxels) { + if ((filledCount & 63) === 0 && Date.now() - start > MAX_TIME_MS) break; + + const [u, v] = queue.shift()!; + const x = Math.round(startX + ux * u + vx * v); + const y = Math.round(startY + uy * u + vy * v); + const z = Math.round(startZ + uz * u + vz * v); + + const cx = Math.floor(x / sizeX); + const cy = Math.floor(y / sizeY); + const cz = Math.floor(z / sizeZ); + const lx = x - cx * sizeX; + const ly = y - cy * sizeY; + const lz = z - cz * sizeZ; + + const key = `${cx},${cy},${cz}`; + let entry = edits.get(key); + if (!entry) { + entry = { indices: [], value: previewValue }; + edits.set(key, entry); + affectedKeys.push(makeVoxChunkKey(key, 0)); + } + const index = lz * strideZ + ly * strideY + lx; + entry.indices.push(index); + filledCount++; + + const neighbors: [number, number][] = [ + [u + 1, v], + [u - 1, v], + [u, v + 1], + [u, v - 1], + ]; + + for (const [nu, nv] of neighbors) { + const k = `${nu},${nv}`; + if (visited.has(k)) continue; + visited.add(k); + + const nx = Math.round(startX + ux * nu + vx * nv); + const ny = Math.round(startY + uy * nu + vy * nv); + const nz = Math.round(startZ + uz * nu + vz * nv); + + const val = getValue(nx, ny, nz); + if (val !== null && val === originalValue) { + queue.push([nu, nv]); + } + } + } + + if (filledCount > 0) { + previewChunkSource.applyLocalEdits(edits); + } + const storageValue = fillValueGetter(false); - await this.dispatchOperation({ - type: VoxelOperationType.FLOOD_FILL, - seed: startPositionCanonical, - value: storageValue, - maxVoxels, - basis, - filterValue, - }); + try { + await this.dispatchOperation({ + type: VoxelOperationType.FLOOD_FILL, + seed: startPositionCanonical, + value: storageValue, + maxVoxels, + basis, + filterValue, + }); + } catch (e) { + if (affectedKeys.length > 0) { + this.callChunkReload(affectedKeys, true); + } + throw e; + } } callChunkReload(voxChunkKeys: string[], isForPreviewChunks: boolean) { From 019c40ed0eb3957a0baae6614f00937bd41102f0 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 29 Jan 2026 14:15:54 +0100 Subject: [PATCH 203/251] feat(voxel-annotation): add concurrency to downsampling jobs --- src/voxel_annotation/TODOs.md | 9 +-- src/voxel_annotation/backend.ts | 132 ++++++++++++++++++++++---------- src/voxel_annotation/base.ts | 4 +- 3 files changed, 96 insertions(+), 49 deletions(-) diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 807d576307..960ecd316d 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -3,12 +3,11 @@ ### priority - when chunk write fails, the chunk is not reloaded -- add flood fill preview -- optimize flushPendings and the downsampling -- optimize the flood fill -- optimize spheres using the full chunk -- see about the list of pending edits for the preview +- optimize flushPendings +- optimize the flood fill backend +- optimize spheres using the full chunk, see jmbs comment +- see about the list of pending edits for the preview, see jmbs comment ### later diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index a41f3064e2..45ad77a345 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -405,6 +405,9 @@ export class VoxelEditController extends SharedObject { private downsampleQueue: string[] = []; private downsampleQueueSet: Set = new Set(); private isProcessingDownsampleQueue: boolean = false; + private activeDownsamples = 0; + private readonly MAX_CONCURRENT_DOWNSAMPLES = 16; + private downsampleChunkLocks = new Map>(); private brushCache = new BrushOptimizationCache(); private accessors = new Map(); @@ -642,31 +645,74 @@ export class VoxelEditController extends SharedObject { } private async processDownsampleQueue(): Promise { - try { - while (this.downsampleQueue.length > 0) { + this.isProcessingDownsampleQueue = true; + + const scheduleNext = () => { + while ( + this.downsampleQueue.length > 0 && + this.activeDownsamples < this.MAX_CONCURRENT_DOWNSAMPLES + ) { const key = this.downsampleQueue.shift() as string; this.downsampleQueueSet.delete(key); - const allModifiedKeys = new Array(); - let currentKey: string | null = key; - while (currentKey !== null) { - allModifiedKeys.push(currentKey); - currentKey = await this.downsampleStep(currentKey); - } - const pendingKeys = new Set(this.pendingEdits.map((e) => e.key)); - const keysToReload = allModifiedKeys.filter((k) => !pendingKeys.has(k)); - if (keysToReload.length > 0) this.callChunkReload(keysToReload, true); - this.updatePendingCount(); + this.activeDownsamples++; + + this.processDownsampleChain(key).finally(() => { + this.activeDownsamples--; + scheduleNext(); + }); } - } finally { - this.isProcessingDownsampleQueue = false; - if ( - this.downsampleQueue.length > 0 && - !this.isProcessingDownsampleQueue - ) { - this.isProcessingDownsampleQueue = true; - Promise.resolve().then(() => this.processDownsampleQueue()); + + if (this.activeDownsamples === 0 && this.downsampleQueue.length === 0) { + this.isProcessingDownsampleQueue = false; } + }; + + scheduleNext(); + } + + private async processDownsampleChain(key: string): Promise { + const allModifiedKeys = new Array(); + let currentKey: string | null = key; + + while (currentKey !== null) { + allModifiedKeys.push(currentKey); + currentKey = await this.downsampleStep(currentKey); } + + const pendingKeys = new Set(this.pendingEdits.map((e) => e.key)); + const keysToReload = allModifiedKeys.filter((k) => !pendingKeys.has(k)); + if (keysToReload.length > 0) this.callChunkReload(keysToReload, true); + this.updatePendingCount(); + } + + private async withChunkLock( + key: string, + op: () => Promise, + ): Promise { + const prev = this.downsampleChunkLocks.get(key) || Promise.resolve(); + + const current = (async () => { + try { + await prev; + } catch { + // + } + return op(); + })(); + + const nextPromise = current.then( + () => {}, + () => {}, + ); + this.downsampleChunkLocks.set(key, nextPromise); + + nextPromise.then(() => { + if (this.downsampleChunkLocks.get(key) === nextPromise) { + this.downsampleChunkLocks.delete(key); + } + }); + + return current; } /** @@ -717,29 +763,31 @@ export class VoxelEditController extends SharedObject { return parentKey; } - try { - await parentSource.applyEdits( - parentInfo.chunkKey, - update.indices, - update.values, - ); - const parentAccessor = this.getAccessor(parentRes.lodIndex); - parentAccessor.invalidate(parentInfo.chunkKey); - this.callChunkReload([parentKey]); - } catch (e) { - console.error( - `[Downsample] Failed to apply edits to parent chunk ${parentKey}:`, - e, - ); - this.rpc?.invoke(VOX_EDIT_FAILURE_RPC_ID, { - rpcId: this.rpcId, - voxChunkKeys: [parentKey], - message: `Downsampling to ${parentKey} failed.`, - }); - return null; // Stop cascade on failure. - } + return this.withChunkLock(parentKey, async () => { + try { + await parentSource.applyEdits( + parentInfo.chunkKey, + update.indices, + update.values, + ); + this.callChunkReload([parentKey]); + const parentAccessor = this.getAccessor(parentRes.lodIndex); + parentAccessor.invalidate(parentInfo.chunkKey); + } catch (e) { + console.error( + `[Downsample] Failed to apply edits to parent chunk ${parentKey}:`, + e, + ); + this.rpc?.invoke(VOX_EDIT_FAILURE_RPC_ID, { + rpcId: this.rpcId, + voxChunkKeys: [parentKey], + message: `Downsampling to ${parentKey} failed.`, + }); + return null; + } - return parentKey; + return parentKey; + }); } /** diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index e3c4909130..dd46702038 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -76,9 +76,9 @@ export const VOXEL_EDIT_STAMINA = { brush: (shape: BrushShape, radius: number, hasFiltering: boolean) => { const FILTERING = hasFiltering ? 7 : 1; if (shape === BrushShape.DISK) { - return Math.round(0.035 * Math.pow(radius, 2) * FILTERING); + return Math.round(0.0035 * Math.pow(radius, 2) * FILTERING); } else { - return Math.round(0.012 * Math.pow(radius, 3) * FILTERING); + return Math.round(0.0012 * Math.pow(radius, 3) * FILTERING); } }, floodFill: (maxVoxels: number) => Math.round(maxVoxels * 0.005), From 54cfd7b2d79ccd814865c4ec68e88d36db3df231 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 29 Jan 2026 15:40:58 +0100 Subject: [PATCH 204/251] feat(voxel-annotation): group paintBrushWithShape calls into a single call per stroke to reduce the amount of applyLocalEdits calls --- src/layer/voxel_annotation/index.ts | 15 +- src/ui/voxel_annotations.ts | 18 ++- src/voxel_annotation/frontend.ts | 213 +++++++++++++++------------- 3 files changed, 134 insertions(+), 112 deletions(-) diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index 67d9cf7dc5..42c1f8fc8e 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -221,7 +221,7 @@ export class VoxelEditingContext } async paintBrushWithShape( - centerCanonical: Float32Array, + points: Float32Array[], radiusCanonical: number, value: VoxelValueGetter, shape: BrushShape, @@ -230,14 +230,15 @@ export class VoxelEditingContext ) { if (!this._controller) throw new Error("Cannot use paintBrushWithShape without a controller"); - const cost = VOXEL_EDIT_STAMINA.brush( - shape, - radiusCanonical, - filterValue !== undefined, - ); + const cost = + VOXEL_EDIT_STAMINA.brush( + shape, + radiusCanonical, + filterValue !== undefined, + ) * points.length; await this.withCost(cost, () => this._controller!.paintBrushWithShape( - centerCanonical, + points, radiusCanonical, value, shape, diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 077155ccb9..709970693e 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -505,16 +505,14 @@ export class VoxelBrushTool extends BaseVoxelTool { ? this.layer.getVoxelPaintValue(false)(false) : undefined; - for (const p of points) { - void editContext.paintBrushWithShape( - p, - radius, - value, - shapeEnum, - basis, - filterValue, - ); - } + void editContext.paintBrushWithShape( + points, + radius, + value, + shapeEnum, + basis, + filterValue, + ); } } diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 2d1d6ffe54..52845605b7 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -138,7 +138,7 @@ export class VoxelEditController extends SharedObject { } async paintBrushWithShape( - centerCanonical: Float32Array, + points: Float32Array[], radiusCanonical: number, valueGetter: VoxelValueGetter, shape: BrushShape, @@ -146,9 +146,6 @@ export class VoxelEditController extends SharedObject { filterValue?: bigint, ) { const voxelSize = 1; // Assuming LOD 0 - const cx = Math.round((centerCanonical[0] ?? 0) / voxelSize); - const cy = Math.round((centerCanonical[1] ?? 0) / voxelSize); - const cz = Math.round((centerCanonical[2] ?? 0) / voxelSize); let r = Math.round(radiusCanonical / voxelSize); if (r <= 0) { throw new Error("Brush radius must be positive."); @@ -172,7 +169,30 @@ export class VoxelEditController extends SharedObject { // WATCHOUT: update this value if the max possible voxel count changes const maxCapacity = Math.ceil((2 * r + 1) ** 2 * 4); const voxelBuffer = new Int32Array(maxCapacity * 3); - let voxelCount = 0; + + const edits = new Map(); + const previewValue = valueGetter(true); + const storageValue = valueGetter(false); + + let previewSource: InMemoryVolumeChunkSource | undefined; + let sizeX = 0, + sizeY = 0, + sizeZ = 0; + let strideY = 0, + strideZ = 0; + + if (this.host.previewSource) { + previewSource = this.host.previewSource.getSources( + this.getIdentitySliceViewSourceOptions(), + )[0][0].chunkSource as InMemoryVolumeChunkSource; + + const { chunkDataSize } = previewSource.spec; + sizeX = chunkDataSize[0]; + sizeY = chunkDataSize[1]; + sizeZ = chunkDataSize[2]; + strideY = sizeX; + strideZ = sizeX * sizeY; + } let baseSource: VolumeChunkSource | undefined; const tempPos = new Float32Array(3); @@ -184,109 +204,112 @@ export class VoxelEditController extends SharedObject { baseSource = sourcesByScale[0][0].chunkSource as VolumeChunkSource; } - const addVoxel = (x: number, y: number, z: number) => { - if (filterValue && baseSource !== undefined) { - tempPos[0] = x; - tempPos[1] = y; - tempPos[2] = z; - const val = baseSource.getValueAt(tempPos, this.singleChannelAccess); - if (val != null) { - const bigVal = typeof val === "bigint" ? val : BigInt(val); - if (bigVal !== filterValue) return; - } - } + const backendOps: Promise[] = []; - const base = voxelCount * 3; - voxelBuffer[base] = x; - voxelBuffer[base + 1] = y; - voxelBuffer[base + 2] = z; - voxelCount++; - }; + for (const centerCanonical of points) { + let voxelCount = 0; - if (shape === BrushShape.DISK) { - for (let j = -r; j <= r; ++j) { - for (let i = -r; i <= r; ++i) { - if (i * i + j * j <= rr) { - const px = Math.round(cx + ux * i + vx * j); - const py = Math.round(cy + uy * i + vy * j); - const pz = Math.round(cz + uz * i + vz * j); - addVoxel(px, py, pz); + const addVoxel = (x: number, y: number, z: number) => { + if (filterValue && baseSource !== undefined) { + tempPos[0] = x; + tempPos[1] = y; + tempPos[2] = z; + const val = baseSource.getValueAt(tempPos, this.singleChannelAccess); + if (val != null) { + const bigVal = typeof val === "bigint" ? val : BigInt(val); + if (bigVal !== filterValue) return; } } - } - } else { - for (let j = -r; j <= r; ++j) { - for (let i = -r; i <= r; ++i) { - if (i * i + j * j <= rr) { - let px = Math.round(cx + ux * i + vx * j); - let py = Math.round(cy + uy * i + vy * j); - let pz = Math.round(cz + uz * i + vz * j); - addVoxel(px, py, pz); - - px = Math.round(cx + ux * i + nx * j); - py = Math.round(cy + uy * i + ny * j); - pz = Math.round(cz + uz * i + nz * j); - addVoxel(px, py, pz); - - px = Math.round(cx + nx * i + vx * j); - py = Math.round(cy + ny * i + vy * j); - pz = Math.round(cz + nz * i + vz * j); - addVoxel(px, py, pz); + + const base = voxelCount * 3; + voxelBuffer[base] = x; + voxelBuffer[base + 1] = y; + voxelBuffer[base + 2] = z; + voxelCount++; + }; + + const cx = Math.round((centerCanonical[0] ?? 0) / voxelSize); + const cy = Math.round((centerCanonical[1] ?? 0) / voxelSize); + const cz = Math.round((centerCanonical[2] ?? 0) / voxelSize); + + if (shape === BrushShape.DISK) { + for (let j = -r; j <= r; ++j) { + for (let i = -r; i <= r; ++i) { + if (i * i + j * j <= rr) { + const px = Math.round(cx + ux * i + vx * j); + const py = Math.round(cy + uy * i + vy * j); + const pz = Math.round(cz + uz * i + vz * j); + addVoxel(px, py, pz); + } + } + } + } else { + for (let j = -r; j <= r; ++j) { + for (let i = -r; i <= r; ++i) { + if (i * i + j * j <= rr) { + let px = Math.round(cx + ux * i + vx * j); + let py = Math.round(cy + uy * i + vy * j); + let pz = Math.round(cz + uz * i + vz * j); + addVoxel(px, py, pz); + + px = Math.round(cx + ux * i + nx * j); + py = Math.round(cy + uy * i + ny * j); + pz = Math.round(cz + uz * i + nz * j); + addVoxel(px, py, pz); + + px = Math.round(cx + nx * i + vx * j); + py = Math.round(cy + ny * i + vy * j); + pz = Math.round(cz + nz * i + vz * j); + addVoxel(px, py, pz); + } } } } - } - if (voxelCount > 0) { - const previewSource = this.host.previewSource!.getSources( - this.getIdentitySliceViewSourceOptions(), - )[0][0].chunkSource as InMemoryVolumeChunkSource; - const value = valueGetter(true); - - const edits = new Map(); - const { chunkDataSize } = previewSource.spec; - const sizeX = chunkDataSize[0]; - const sizeY = chunkDataSize[1]; - const sizeZ = chunkDataSize[2]; - const strideY = sizeX; - const strideZ = sizeX * sizeY; - - for (let i = 0; i < voxelCount; ++i) { - const base = i * 3; - const x = voxelBuffer[base]; - const y = voxelBuffer[base + 1]; - const z = voxelBuffer[base + 2]; - - const cx = Math.floor(x / sizeX); - const cy = Math.floor(y / sizeY); - const cz = Math.floor(z / sizeZ); - - const lx = x - cx * sizeX; - const ly = y - cy * sizeY; - const lz = z - cz * sizeZ; - - const key = `${cx},${cy},${cz}`; - let entry = edits.get(key); - if (!entry) { - entry = { indices: [], value }; - edits.set(key, entry); + if (voxelCount > 0 && previewSource) { + for (let i = 0; i < voxelCount; ++i) { + const base = i * 3; + const x = voxelBuffer[base]; + const y = voxelBuffer[base + 1]; + const z = voxelBuffer[base + 2]; + + const chunkX = Math.floor(x / sizeX); + const chunkY = Math.floor(y / sizeY); + const chunkZ = Math.floor(z / sizeZ); + + const lx = x - chunkX * sizeX; + const ly = y - chunkY * sizeY; + const lz = z - chunkZ * sizeZ; + + const key = `${chunkX},${chunkY},${chunkZ}`; + let entry = edits.get(key); + if (!entry) { + entry = { indices: [], value: previewValue }; + edits.set(key, entry); + } + const index = lz * strideZ + ly * strideY + lx; + entry.indices.push(index); } - const index = lz * strideZ + ly * strideY + lx; - entry.indices.push(index); } + + backendOps.push( + this.dispatchOperation({ + type: VoxelOperationType.BRUSH, + center: centerCanonical, + radius: radiusCanonical, + value: storageValue, + shape, + basis, + filterValue, + }), + ); + } + + if (edits.size > 0 && previewSource) { previewSource.applyLocalEdits(edits); } - const storageValue = valueGetter(false); - await this.dispatchOperation({ - type: VoxelOperationType.BRUSH, - center: centerCanonical, - radius: radiusCanonical, - value: storageValue, - shape, - basis, - filterValue, - }); + await Promise.all(backendOps); } async floodFillPlane2D( From 76dce780b4d6c71477004813bcab04a463ff749d Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 29 Jan 2026 18:32:40 +0100 Subject: [PATCH 205/251] fix(voxel-annotation): adjust the chunk reloading in the downsampling and fix the integration tests --- src/voxel_annotation/backend.ts | 6 +++--- src/voxel_annotation/base.ts | 4 ++-- tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 45ad77a345..c9da311c75 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -680,8 +680,8 @@ export class VoxelEditController extends SharedObject { } const pendingKeys = new Set(this.pendingEdits.map((e) => e.key)); - const keysToReload = allModifiedKeys.filter((k) => !pendingKeys.has(k)); - if (keysToReload.length > 0) this.callChunkReload(keysToReload, true); + const keysToReload = allModifiedKeys.filter((k) => !pendingKeys.has(k) && !this.downsampleChunkLocks.has(k)); + if (keysToReload.length > 0) this.callChunkReload(keysToReload); this.updatePendingCount(); } @@ -770,7 +770,7 @@ export class VoxelEditController extends SharedObject { update.indices, update.values, ); - this.callChunkReload([parentKey]); + //this.callChunkReload([parentKey]); const parentAccessor = this.getAccessor(parentRes.lodIndex); parentAccessor.invalidate(parentInfo.chunkKey); } catch (e) { diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index dd46702038..783832d0e2 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -76,9 +76,9 @@ export const VOXEL_EDIT_STAMINA = { brush: (shape: BrushShape, radius: number, hasFiltering: boolean) => { const FILTERING = hasFiltering ? 7 : 1; if (shape === BrushShape.DISK) { - return Math.round(0.0035 * Math.pow(radius, 2) * FILTERING); + return Math.round(0.00035 * Math.pow(radius, 2) * FILTERING); } else { - return Math.round(0.0012 * Math.pow(radius, 3) * FILTERING); + return Math.round(0.00012 * Math.pow(radius, 3) * FILTERING); } }, floodFill: (maxVoxels: number) => Math.round(maxVoxels * 0.005), diff --git a/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts b/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts index 6415ccdac6..74e3e88300 100644 --- a/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts +++ b/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts @@ -154,7 +154,7 @@ test("Pipeline: Zarr V2 (UINT8) Undo/Redo with Brush", async () => { const { context } = await waitForEditingContext(); const center = new Float32Array([16, 16, 16]); - await context.paintBrushWithShape(center, 5, (_) => 100n, 0 /* DISK */, { + await context.paintBrushWithShape([center], 5, (_) => 100n, 0 /* DISK */, { u: new Float32Array([1, 0, 0]), v: new Float32Array([0, 1, 0]), }); @@ -222,7 +222,7 @@ test("Pipeline: Zarr V3 (UINT64) Brush", async () => { const center = new Float32Array([16, 16, 16]); const paintVal = 123456789n; - await context.paintBrushWithShape(center, 2, (_) => paintVal, 0 /* DISK */, { + await context.paintBrushWithShape([center], 2, (_) => paintVal, 0 /* DISK */, { u: new Float32Array([1, 0, 0]), v: new Float32Array([0, 1, 0]), }); @@ -272,7 +272,7 @@ test("Pipeline: Zarr V2 (UINT32) with Slash Separator", async () => { const center = new Float32Array([10, 10, 10]); const paintVal = 42n; - await context.paintBrushWithShape(center, 2, (_) => paintVal, 0 /* DISK */, { + await context.paintBrushWithShape([center], 2, (_) => paintVal, 0 /* DISK */, { u: new Float32Array([1, 0, 0]), v: new Float32Array([0, 1, 0]), }); From 8c6753d5e3c648a8fd55ec99dce66d56afd4e72d Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 29 Jan 2026 19:02:28 +0100 Subject: [PATCH 206/251] chroe(voxel-annotation): run format + revert chunk reload edits from last commit --- src/voxel_annotation/backend.ts | 8 ++++-- .../pipeline_zarr_s3.browser_test.ts | 28 +++++++++++++------ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index c9da311c75..be1cefbead 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -680,8 +680,10 @@ export class VoxelEditController extends SharedObject { } const pendingKeys = new Set(this.pendingEdits.map((e) => e.key)); - const keysToReload = allModifiedKeys.filter((k) => !pendingKeys.has(k) && !this.downsampleChunkLocks.has(k)); - if (keysToReload.length > 0) this.callChunkReload(keysToReload); + const keysToReload = allModifiedKeys.filter( + (k) => !pendingKeys.has(k) && !this.downsampleChunkLocks.has(k), + ); + if (keysToReload.length > 0) this.callChunkReload(keysToReload, true); this.updatePendingCount(); } @@ -770,7 +772,7 @@ export class VoxelEditController extends SharedObject { update.indices, update.values, ); - //this.callChunkReload([parentKey]); + this.callChunkReload([parentKey]); const parentAccessor = this.getAccessor(parentRes.lodIndex); parentAccessor.invalidate(parentInfo.chunkKey); } catch (e) { diff --git a/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts b/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts index 74e3e88300..49a2a3a0a6 100644 --- a/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts +++ b/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts @@ -222,10 +222,16 @@ test("Pipeline: Zarr V3 (UINT64) Brush", async () => { const center = new Float32Array([16, 16, 16]); const paintVal = 123456789n; - await context.paintBrushWithShape([center], 2, (_) => paintVal, 0 /* DISK */, { - u: new Float32Array([1, 0, 0]), - v: new Float32Array([0, 1, 0]), - }); + await context.paintBrushWithShape( + [center], + 2, + (_) => paintVal, + 0 /* DISK */, + { + u: new Float32Array([1, 0, 0]), + v: new Float32Array([0, 1, 0]), + }, + ); const chunkKey = `${BUCKET}/data.zarr/c/0/0/0`; @@ -272,10 +278,16 @@ test("Pipeline: Zarr V2 (UINT32) with Slash Separator", async () => { const center = new Float32Array([10, 10, 10]); const paintVal = 42n; - await context.paintBrushWithShape([center], 2, (_) => paintVal, 0 /* DISK */, { - u: new Float32Array([1, 0, 0]), - v: new Float32Array([0, 1, 0]), - }); + await context.paintBrushWithShape( + [center], + 2, + (_) => paintVal, + 0 /* DISK */, + { + u: new Float32Array([1, 0, 0]), + v: new Float32Array([0, 1, 0]), + }, + ); const chunkKey = `${BUCKET}/data.zarr/0/0/0`; From c1384abd88977f7cbc8d89bcc4eea8750f5a846c Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Fri, 30 Jan 2026 11:42:55 +0100 Subject: [PATCH 207/251] fix(voxel-annotation): handle cases with no downsampling (single resolution dataset) --- src/voxel_annotation/backend.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index be1cefbead..9a061f5af7 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -571,9 +571,14 @@ export class VoxelEditController extends SharedObject { }); } - for (const [voxKey, _] of editsByVoxKey.entries()) { - if (failedVoxChunkKeys.includes(voxKey)) continue; - this.enqueueDownsample(voxKey); + const hasDownsampling = this.resolutions.size > 1; + if (hasDownsampling) { + for (const [voxKey, _] of editsByVoxKey.entries()) { + if (failedVoxChunkKeys.includes(voxKey)) continue; + this.enqueueDownsample(voxKey); + } + } else { + this.callChunkReload(editsByVoxKey.keys().toArray(), true); } this.updatePendingCount(); @@ -1119,8 +1124,11 @@ export class VoxelEditController extends SharedObject { } if (chunksToReload.size > 0 && success) { - for (const key of chunksToReload) { - this.enqueueDownsample(key); + const hasDownsampling = this.resolutions.size > 1; + if (hasDownsampling) { + for (const key of chunksToReload) { + this.enqueueDownsample(key); + } } this.callChunkReload(Array.from(chunksToReload)); } From fae54db8c667176a602a34f0d9d1028956009836 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Sun, 22 Feb 2026 15:43:03 +0100 Subject: [PATCH 208/251] docs(voxel-annotation): add user guide for voxel annotation features --- docs/index.rst | 1 + docs/user-guide/voxel_annotation.rst | 115 +++++++++++++++++++++++++++ src/voxel_annotation/TODOs.md | 3 - 3 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 docs/user-guide/voxel_annotation.rst diff --git a/docs/index.rst b/docs/index.rst index aba9088945..cf3b08d20f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,6 +7,7 @@ Neuroglancer :caption: User Guide user-guide/navigation + user-guide/voxel_annotation .. toctree:: :hidden: diff --git a/docs/user-guide/voxel_annotation.rst b/docs/user-guide/voxel_annotation.rst new file mode 100644 index 0000000000..21b634c20d --- /dev/null +++ b/docs/user-guide/voxel_annotation.rst @@ -0,0 +1,115 @@ +.. _voxel-annotation: + +Voxel Annotation +================ + +Voxel annotation allows for direct painting and editing of volumetric data +within Neuroglancer. This feature is available for both :ref:`image-layer` and +:ref:`segmentation-layer`. + +Enabling Voxel Editing +---------------------- + +To enable voxel editing, you must first have a writable volume source. + +1. Open the **Source** tab of an Image or Segmentation layer. +2. Locate a volume source and click the **Write** checkbox next to it. + +.. note:: + Only one source can be writable at a time within a layer. Voxel annotation is currently supported only for 3D volumes. + +The first time you attempt a drawing operation (like a brush stroke) after enabling writing, a confirmation dialog will appear. Note that this initial operation will be canceled; you can resume drawing once you have confirmed. + +.. note:: + For the segmentation layer, it is recommended to deactivate the Highlight on hover option under the Render tab. + +Supported Storage and Formats +----------------------------- + +Voxel editing is currently supported for the following configurations: + +**Storage**: + +- Amazon S3 or any S3 compatible storage. + +**Data Format**: + +- Zarr v2 and Zarr v3 (including OME-Zarr), with the following Compression/Encoding: + - None (Raw) + - Blosc + - Gzip + +Tools +----- + +Voxel editing provides several tools for different annotation tasks. You can +bind these tools from the **Draw** tab to use/activate them. + +.. _voxel-brush-tool: + +Brush Tool +~~~~~~~~~~ + +The Brush tool allows you to paint voxels by clicking and dragging. + +- **Paint**: Hold :kbd:`Control` + :kbd:`Left Click` and drag. +- **Erase**: Hold :kbd:`Control` + :kbd:`Shift` + :kbd:`Left Click` and drag. +- **Quick Flood Fill**: Hold :kbd:`Control` + :kbd:`Right Click` to trigger a + flood fill at the current position. Hold :kbd:`Shift` as well to erase. + +Settings: + - **Brush size**: Adjust the radius of the brush. + - **Brush shape**: Choose between **Disk** and **Sphere** shapes. + +.. _voxel-flood-fill-tool: + +Flood Fill Tool +~~~~~~~~~~~~~~~ + +The Flood Fill tool fills a connected region of voxels on the current 2D plane. + +- **Fill**: Hold :kbd:`Control` + :kbd:`Left Click`. +- **Clear**: Hold :kbd:`Control` + :kbd:`Shift` + :kbd:`Left Click`. + +Settings: + - **Max fill voxels**: Limits the maximum number of voxels to fill to + prevent accidental large-scale changes. If the limit is exceeded, the + operation will be canceled. + +.. note:: + The flood fill will automatically fill small gaps in the connected region, proportionally to the number of voxels in the region. This feature may sometimes leave unpainted voxels in tight corners of the region. + +.. _voxel-seg-picker-tool: + +Seg Picker +~~~~~~~~~~ + +The Seg Picker tool allows you to adopt the voxel value at the current mouse +position as your active Paint Value. + +Common Controls +--------------- + +The **Draw** tab provides several common controls: + +- **Erase only selected value**: When enabled, the erase action only affects + voxels that match the current **Paint Value**. This feature will slow down + painting performance when erasing. +- **Undo / Redo**: Revert or re-apply recent changes. +- **Paint Value**: Manually specify the segment ID or intensity value to paint. +- **New Random Value**: Generates a new random segment ID. + +Stamina System +-------------- + +When you perform many edits quickly, a stamina bar will appear below your cursor. This bar represents the amount of remaining work before all of your edits are processed and saved. **If you reload the page while the stamina bar is visible, you will lose some edits**. If the bar gets emptied painting will be halted until the system is able to catch up, this prevents neuroglancer from crashing due to too many edits in a short period of time. + +About multi-resolution datasets +------------------------------- + +Any multi-resolution dataset that has many-to-1 mapping (i.e. one child cannot have multiple parents) can be used for voxel annotation. + +Although voxel annotation supports multi-resolution, any drawing operation will be performed on the highest resolution level, no matter what the current view is. Once an operation is completed, a downsampling pipeline will be triggered to update the lower resolution levels. + +.. note:: + Because of the 3D nature of the datasets, the downsampling may cause visual artifacts: when zoomed out you may see annotations that then disappear when zoomed in, those "invisible" annotations will be found on nearby slices. diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md index 960ecd316d..e52c4e0f8e 100644 --- a/src/voxel_annotation/TODOs.md +++ b/src/voxel_annotation/TODOs.md @@ -2,9 +2,6 @@ ### priority -- when chunk write fails, the chunk is not reloaded - -- optimize flushPendings - optimize the flood fill backend - optimize spheres using the full chunk, see jmbs comment - see about the list of pending edits for the preview, see jmbs comment From ec5ddbd3232d3a802ca669af8ebfb35b53427826 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Fri, 10 Apr 2026 11:41:35 +0200 Subject: [PATCH 209/251] docs(voxel-annotation): update from @seankmartin review --- docs/user-guide/voxel_annotation.rst | 21 +++++++++++++++------ src/kvstore/s3/index.rst | 28 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/docs/user-guide/voxel_annotation.rst b/docs/user-guide/voxel_annotation.rst index 21b634c20d..19e4a06d0e 100644 --- a/docs/user-guide/voxel_annotation.rst +++ b/docs/user-guide/voxel_annotation.rst @@ -16,12 +16,18 @@ To enable voxel editing, you must first have a writable volume source. 2. Locate a volume source and click the **Write** checkbox next to it. .. note:: - Only one source can be writable at a time within a layer. Voxel annotation is currently supported only for 3D volumes. + Only one source can be writable at a time within a layer. + +**Known limitations**: + +- Only 3D volumes are supported (2D volumes are not). +- Float32 data type is not supported. +- Multi-resolution datasets must have a strict many-to-one hierarchy (i.e. each child chunk can have at most one parent). Unaligned hierarchies are not supported. The first time you attempt a drawing operation (like a brush stroke) after enabling writing, a confirmation dialog will appear. Note that this initial operation will be canceled; you can resume drawing once you have confirmed. .. note:: - For the segmentation layer, it is recommended to deactivate the Highlight on hover option under the Render tab. + For the segmentation layer, it is recommended to deactivate the **Highlight on hover** option under the **Render** tab. When enabled, painted voxels become highlighted as the mouse moves over them, which can be visually distracting during annotation. Supported Storage and Formats ----------------------------- @@ -32,6 +38,9 @@ Voxel editing is currently supported for the following configurations: - Amazon S3 or any S3 compatible storage. +.. note:: + Write operations require that the S3 bucket's CORS policy allows ``PUT`` and ``DELETE`` methods. See :ref:`s3-kvstore` for a reference CORS policy. + **Data Format**: - Zarr v2 and Zarr v3 (including OME-Zarr), with the following Compression/Encoding: @@ -81,10 +90,10 @@ Settings: .. _voxel-seg-picker-tool: -Seg Picker -~~~~~~~~~~ +Value Picker +~~~~~~~~~~~~ -The Seg Picker tool allows you to adopt the voxel value at the current mouse +The Value Picker tool allows you to adopt the voxel value at the current mouse position as your active Paint Value. Common Controls @@ -97,7 +106,7 @@ The **Draw** tab provides several common controls: painting performance when erasing. - **Undo / Redo**: Revert or re-apply recent changes. - **Paint Value**: Manually specify the segment ID or intensity value to paint. -- **New Random Value**: Generates a new random segment ID. +- **New Random Value**: Generates a new random segment ID or intensity value. Stamina System -------------- diff --git a/src/kvstore/s3/index.rst b/src/kvstore/s3/index.rst index 01cbfc4a42..68abe05a8b 100644 --- a/src/kvstore/s3/index.rst +++ b/src/kvstore/s3/index.rst @@ -72,3 +72,31 @@ such as the following: "MaxAgeSeconds": 3000 } ] + +If the bucket also needs to support write operations (e.g. for :ref:`voxel-annotation`), ``PUT`` and ``DELETE`` must be added to ``AllowedMethods``: + +.. code-block:: json + + [ + { + "AllowedHeaders": [ + "*" + ], + "AllowedMethods": [ + "GET", + "HEAD", + "PUT", + "DELETE" + ], + "AllowedOrigins": [ + "*" + ], + "ExposeHeaders": [ + "ETag", + "Content-Range", + "Content-Encoding", + "Content-Length" + ], + "MaxAgeSeconds": 3000 + } + ] From 2a6f12ad4214557407aae4548f7762a41a4f979c Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Fri, 10 Apr 2026 11:48:37 +0200 Subject: [PATCH 210/251] docs(voxel-annotation): remove duplicate --- docs/user-guide/voxel_annotation.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-guide/voxel_annotation.rst b/docs/user-guide/voxel_annotation.rst index 19e4a06d0e..ba34612482 100644 --- a/docs/user-guide/voxel_annotation.rst +++ b/docs/user-guide/voxel_annotation.rst @@ -22,7 +22,7 @@ To enable voxel editing, you must first have a writable volume source. - Only 3D volumes are supported (2D volumes are not). - Float32 data type is not supported. -- Multi-resolution datasets must have a strict many-to-one hierarchy (i.e. each child chunk can have at most one parent). Unaligned hierarchies are not supported. +- Multi-resolution datasets must have a strict many-to-one hierarchy. See `About multi-resolution datasets`_ for more details. The first time you attempt a drawing operation (like a brush stroke) after enabling writing, a confirmation dialog will appear. Note that this initial operation will be canceled; you can resume drawing once you have confirmed. From 12e3f27e7336ffaf6694685824ac1d9742106515 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Fri, 10 Apr 2026 12:01:54 +0200 Subject: [PATCH 211/251] refactor(voxel-annotation): renamed seg picker to value picker --- docs/user-guide/voxel_annotation.rst | 4 ++-- src/layer/voxel_annotation/controls.ts | 4 ++-- src/ui/voxel_annotations.ts | 6 +++--- src/voxel_annotation/base.ts | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/user-guide/voxel_annotation.rst b/docs/user-guide/voxel_annotation.rst index ba34612482..ce61383b7f 100644 --- a/docs/user-guide/voxel_annotation.rst +++ b/docs/user-guide/voxel_annotation.rst @@ -88,7 +88,7 @@ Settings: .. note:: The flood fill will automatically fill small gaps in the connected region, proportionally to the number of voxels in the region. This feature may sometimes leave unpainted voxels in tight corners of the region. -.. _voxel-seg-picker-tool: +.. _voxel-value-picker-tool: Value Picker ~~~~~~~~~~~~ @@ -116,7 +116,7 @@ When you perform many edits quickly, a stamina bar will appear below your cursor About multi-resolution datasets ------------------------------- -Any multi-resolution dataset that has many-to-1 mapping (i.e. one child cannot have multiple parents) can be used for voxel annotation. +Any multi-resolution dataset that has many-to-1 chunk mapping (i.e. one child chunk cannot have multiple parents) can be used for voxel annotation. Although voxel annotation supports multi-resolution, any drawing operation will be performed on the highest resolution level, no matter what the current view is. Once an operation is completed, a downsampling pipeline will be triggered to update the lower resolution levels. diff --git a/src/layer/voxel_annotation/controls.ts b/src/layer/voxel_annotation/controls.ts index fcab76f16a..864cc2b001 100644 --- a/src/layer/voxel_annotation/controls.ts +++ b/src/layer/voxel_annotation/controls.ts @@ -30,7 +30,7 @@ import { FLOODFILL_MIN_POSSIBLE_VOXELS, FLOODFILL_TOOL_ID, getBasisFromNormal, - SEG_PICKER_TOOL_ID, + VALUE_PICKER_TOOL_ID, } from "#src/voxel_annotation/base.js"; import type { LayerControlDefinition } from "#src/widget/layer_control.js"; import { registerLayerControl } from "#src/widget/layer_control.js"; @@ -307,7 +307,7 @@ export const VOXEL_TAB_LAYOUT: VoxelTabElement[] = [ tools: [ { toolId: BRUSH_TOOL_ID, label: "Brush" }, { toolId: FLOODFILL_TOOL_ID, label: "Flood Fill" }, - { toolId: SEG_PICKER_TOOL_ID, label: "Seg Picker" }, + { toolId: VALUE_PICKER_TOOL_ID, label: "Value Picker" }, ], }, ...COMMON_CONTROLS, diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 709970693e..37c1994bda 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -42,7 +42,7 @@ import { BRUSH_TOOL_ID, FLOODFILL_TOOL_ID, getBasisFromNormal, - SEG_PICKER_TOOL_ID, + VALUE_PICKER_TOOL_ID, } from "#src/voxel_annotation/base.js"; const BRUSH_INPUT_MAP = EventActionMap.fromObject({ @@ -602,7 +602,7 @@ export class AdoptVoxelValueTool extends LayerTool { } toJSON() { - return SEG_PICKER_TOOL_ID; + return VALUE_PICKER_TOOL_ID; } get description() { @@ -705,7 +705,7 @@ export function registerVoxelTools(LayerCtor: any) { ); registerTool( LayerCtor, - SEG_PICKER_TOOL_ID, + VALUE_PICKER_TOOL_ID, (layer: UserLayerWithVoxelEditing) => new AdoptVoxelValueTool(layer), ); } diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index 783832d0e2..5367bd1829 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -64,7 +64,7 @@ export type VoxelOperation = BrushOperation | FloodFillOperation; export const BRUSH_TOOL_ID = "vox-brush"; export const FLOODFILL_TOOL_ID = "vox-flood-fill"; -export const SEG_PICKER_TOOL_ID = "vox-seg-picker"; +export const VALUE_PICKER_TOOL_ID = "vox-value-picker"; // Special value used to indicate to the optimistic renderer that a voxel has been erased export const SEG_ERASE_SENTINEL = ~1n; From b33d9138c839c5ab754ed61c8789d3b9873ef201 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Sat, 25 Apr 2026 14:42:47 +0200 Subject: [PATCH 212/251] fix(voxel-annotation): fix comment typo and remove shader debug method --- src/layer/voxel_annotation/index.ts | 2 +- src/webgl/shader.ts | 28 ---------------------------- 2 files changed, 1 insertion(+), 29 deletions(-) diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index 42c1f8fc8e..c8c6b80c33 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -122,7 +122,7 @@ export class VoxelEditingContext if (!writingEnabled) return; - // NOTE: each of the following 3 checks may be removed if support for the checked contraint is added + // The following checks are in place due to limitations in the implementation, and could be removed if support for the checked constraint is added. if (primarySource.rank !== 3) { throw new Error( `Voxel annotation only supports rank 3 volumes (got ${primarySource.rank}).`, diff --git a/src/webgl/shader.ts b/src/webgl/shader.ts index 3f7f4ac835..ed2f4220db 100644 --- a/src/webgl/shader.ts +++ b/src/webgl/shader.ts @@ -663,34 +663,6 @@ ${this.fragmentMain} } return shader; } - - print() { - const vertexSource = `#version 300 es -precision highp float; -precision highp int; -${this.uniformsCode} -${this.attributesCode} -${this.varyingsCodeVS} -float defaultMaxProjectionIntensity = 0.0; -${this.vertexCode} -void main() { -${this.vertexMain} -} -`; - const fragmentSource = `#version 300 es -${this.fragmentExtensions} -precision highp float; -precision highp int; -${this.uniformsCode} -${this.varyingsCodeFS} -${this.outputBufferCode} -float defaultMaxProjectionIntensity = 0.0; -${this.fragmentCode} -${this.fragmentMain} -`; - console.log("----- VERTEX SHADER -----\n" + vertexSource); - console.log("----- FRAGMENT SHADER -----\n" + fragmentSource); - } } export function shaderContainsIdentifiers( From f25e482e2c1bf57eeeafff6f7b78f7b687dbb2e0 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Sun, 26 Apr 2026 19:24:11 +0200 Subject: [PATCH 213/251] fix(voxel-annotation): rename snake_case vars to camelCase and fix incorrect await on commitVoxels --- src/layer/voxel_annotation/controls.ts | 32 +++++++++++++------------- src/voxel_annotation/backend.spec.ts | 16 ++++++------- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/src/layer/voxel_annotation/controls.ts b/src/layer/voxel_annotation/controls.ts index 864cc2b001..a989a38833 100644 --- a/src/layer/voxel_annotation/controls.ts +++ b/src/layer/voxel_annotation/controls.ts @@ -77,15 +77,15 @@ export function drawBrushCursor( const { globalToRenderLayerDimensions } = chunkTransform.modelTransform; const stride = layerRank + 1; - const n_world = + const nWorld = projectionParameters.value.viewportNormalInCanonicalCoordinates; - const n_chunk = context.transformGlobalToVoxelNormal(n_world); + const nChunk = context.transformGlobalToVoxelNormal(nWorld); - const { u: u_chunk, v: v_chunk } = getBasisFromNormal(n_chunk); + const { u: uChunk, v: vChunk } = getBasisFromNormal(nChunk); const radius = layer.brushRadius.value - 0.5; - vec3.scale(u_chunk, u_chunk, radius); - vec3.scale(v_chunk, v_chunk, radius); + vec3.scale(uChunk, uChunk, radius); + vec3.scale(vChunk, vChunk, radius); const chunkToCam3 = mat3.create(); @@ -105,19 +105,19 @@ export function drawBrushCursor( } } - const u_cam = vec3.create(); - const v_cam = vec3.create(); - vec3.transformMat3(u_cam, u_chunk, chunkToCam3); - vec3.transformMat3(v_cam, v_chunk, chunkToCam3); + const uCam = vec3.create(); + const vCam = vec3.create(); + vec3.transformMat3(uCam, uChunk, chunkToCam3); + vec3.transformMat3(vCam, vChunk, chunkToCam3); - const u_scr_x = u_cam[0]; - const u_scr_y = u_cam[1]; - const v_scr_x = v_cam[0]; - const v_scr_y = v_cam[1]; + const uScrX = uCam[0]; + const uScrY = uCam[1]; + const vScrX = vCam[0]; + const vScrY = vCam[1]; - const Q11 = u_scr_x * u_scr_x + v_scr_x * v_scr_x; - const Q12 = u_scr_x * u_scr_y + v_scr_x * v_scr_y; - const Q22 = u_scr_y * u_scr_y + v_scr_y * v_scr_y; + const Q11 = uScrX * uScrX + vScrX * vScrX; + const Q12 = uScrX * uScrY + vScrX * vScrY; + const Q22 = uScrY * uScrY + vScrY * vScrY; const trace = Q11 + Q22; const det = Q11 * Q22 - Q12 * Q12; diff --git a/src/voxel_annotation/backend.spec.ts b/src/voxel_annotation/backend.spec.ts index 0afda4869c..dfbd1387e3 100644 --- a/src/voxel_annotation/backend.spec.ts +++ b/src/voxel_annotation/backend.spec.ts @@ -792,17 +792,15 @@ describe("VoxelEditController: flushPending", () => { it("Batching: Aggregates multiple edits to the same chunk into one write", async () => { const key = makeVoxChunkKey("0,0,0", 0); - await controller.commitVoxels([ + controller.commitVoxels([ { key, indices: [1], value: 50n }, { key, indices: [2], value: 60n }, ]); const otherKey = makeVoxChunkKey("1,0,0", 0); - await controller.commitVoxels([ - { key: otherKey, indices: [5], value: 99n }, - ]); + controller.commitVoxels([{ key: otherKey, indices: [5], value: 99n }]); - await controller.commitVoxels([ + controller.commitVoxels([ { key, indices: [1], value: 42n }, { key, indices: [3], value: 70n }, ]); @@ -828,7 +826,7 @@ describe("VoxelEditController: flushPending", () => { }); expect((controller as any).redoStack.length).toBe(1); - await controller.commitVoxels([{ key, indices: [1], value: 50n }]); + controller.commitVoxels([{ key, indices: [1], value: 50n }]); await vi.runAllTimersAsync(); expect((controller as any).undoStack.length).toBe(1); @@ -859,7 +857,7 @@ describe("VoxelEditController: flushPending", () => { }); }); - await controller.commitVoxels([ + controller.commitVoxels([ { key: validKey, indices: [1], value: 50n }, { key: failKey, indices: [1], value: 50n }, ]); @@ -901,7 +899,7 @@ describe("VoxelEditController: flushPending", () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - await controller.commitVoxels([ + controller.commitVoxels([ { key: validKey, indices: [1], value: 50n }, { key: badKey, indices: [1], value: 50n }, ]); @@ -928,7 +926,7 @@ describe("VoxelEditController: flushPending", () => { const key = makeVoxChunkKey("0,0,0", 0); const enqueueSpy = vi.spyOn(controller as any, "enqueueDownsample"); - await controller.commitVoxels([{ key, indices: [1], value: 50n }]); + controller.commitVoxels([{ key, indices: [1], value: 50n }]); await vi.runAllTimersAsync(); expect(enqueueSpy).toHaveBeenCalledWith(key); From 181d1612571230437c9638b180c2e5a8706b6a5f Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 27 Apr 2026 11:28:38 +0200 Subject: [PATCH 214/251] refactor(voxel-annotation): rename _createVoxelRenderLayer to _createVoxelOverlayRenderLayer --- src/layer/image/index.ts | 2 +- src/layer/segmentation/index.ts | 2 +- src/layer/voxel_annotation/index.ts | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/layer/image/index.ts b/src/layer/image/index.ts index a4333fd5e3..5c8131bec1 100644 --- a/src/layer/image/index.ts +++ b/src/layer/image/index.ts @@ -197,7 +197,7 @@ export class ImageUserLayer extends Base { }; } - _createVoxelRenderLayer( + _createVoxelOverlayRenderLayer( source: MultiscaleVolumeChunkSource, transform: WatchableValueInterface, ): ImageRenderLayer { diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index b0379e901d..704f7c611b 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -617,7 +617,7 @@ export class SegmentationUserLayer extends Base { ); }; - _createVoxelRenderLayer( + _createVoxelOverlayRenderLayer( source: MultiscaleVolumeChunkSource, transform: WatchableValueInterface, ): SegmentationRenderLayer { diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index c8c6b80c33..cc80c699c0 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -138,7 +138,7 @@ export class VoxelEditingContext primarySource, ); - this.optimisticRenderLayer = this.hostLayer._createVoxelRenderLayer( + this.optimisticRenderLayer = this.hostLayer._createVoxelOverlayRenderLayer( this.previewSource, primaryRenderLayer.transform, ); @@ -448,7 +448,7 @@ export declare abstract class UserLayerWithVoxelEditing extends UserLayer { editingContexts: Map; - abstract _createVoxelRenderLayer( + abstract _createVoxelOverlayRenderLayer( source: MultiscaleVolumeChunkSource, transform: WatchableValueInterface, ): ImageRenderLayer | SegmentationRenderLayer; @@ -730,7 +730,7 @@ export function UserLayerWithVoxelEditingMixin< return truncated; } - abstract _createVoxelRenderLayer( + abstract _createVoxelOverlayRenderLayer( source: MultiscaleVolumeChunkSource, transform: WatchableValueInterface, ): ImageRenderLayer | SegmentationRenderLayer; From 4d2f0612d58249e2383f5ae8a8bbff095bdcac6f Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 27 Apr 2026 17:37:54 +0200 Subject: [PATCH 215/251] refactor(voxel-annotation): convert stamina benchmark to proper bench() API --- .../staminaCalibration.benchmark.spec.ts | 423 ------------------ .../staminaCalibration.benchmark.ts | 309 +++++++++++++ 2 files changed, 309 insertions(+), 423 deletions(-) delete mode 100644 src/voxel_annotation/staminaCalibration.benchmark.spec.ts create mode 100644 src/voxel_annotation/staminaCalibration.benchmark.ts diff --git a/src/voxel_annotation/staminaCalibration.benchmark.spec.ts b/src/voxel_annotation/staminaCalibration.benchmark.spec.ts deleted file mode 100644 index 9edc6863cb..0000000000 --- a/src/voxel_annotation/staminaCalibration.benchmark.spec.ts +++ /dev/null @@ -1,423 +0,0 @@ -/** - * @license - * Copyright 2025 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 * as fs from "fs"; -import * as path from "path"; -import { describe, it, beforeAll, afterAll, vi } from "vitest"; -import type { VolumeChunk } from "#src/sliceview/volume/backend.js"; -import { VolumeChunkSource } from "#src/sliceview/volume/backend.js"; -import { DataType, DATA_TYPE_ARRAY_CONSTRUCTOR } from "#src/util/data_type.js"; -import { vec3 } from "#src/util/geom.js"; -import { VoxelEditController } from "#src/voxel_annotation/backend.js"; -import { - BrushShape, - VoxelOperationType, - makeVoxChunkKey, -} from "#src/voxel_annotation/base.js"; - -interface BenchmarkResult { - operation: string; - inputs: Record; - avgTimeMs: number; -} - -const results: BenchmarkResult[] = []; - -const NETWORK_LATENCY = 0; -class RealisticInMemorySource extends VolumeChunkSource { - public storage = new Map(); - - async download(chunk: VolumeChunk) { - if (NETWORK_LATENCY) - await new Promise((resolve) => setTimeout(resolve, NETWORK_LATENCY)); - - if (!chunk.chunkDataSize) { - this.computeChunkBounds(chunk); - } - - const numElements = chunk.chunkDataSize!.reduce((a, b) => a * b, 1); - const Ctor = DATA_TYPE_ARRAY_CONSTRUCTOR[this.spec.dataType]; - const key = chunk.chunkGridPosition.join(","); - - if (this.storage.has(key)) { - chunk.data = new (Ctor as any)(this.storage.get(key)!); - } else { - chunk.data = new (Ctor as any)(numElements); - } - } - - async writeChunk(chunk: VolumeChunk) { - if (NETWORK_LATENCY) - await new Promise((resolve) => setTimeout(resolve, NETWORK_LATENCY)); - - const key = chunk.chunkGridPosition.join(","); - const Ctor = DATA_TYPE_ARRAY_CONSTRUCTOR[this.spec.dataType]; - if (chunk.data) { - this.storage.set(key, new (Ctor as any)(chunk.data)); - } - } -} - -const createResConfig = (lod: number, chunkSize: number) => ({ - lodIndex: lod, - transform: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], - chunkSize: [chunkSize, chunkSize, chunkSize], - sourceRpc: 100 + lod, -}); - -describe("Voxel Operation Cost Calibration (Realistic)", () => { - let controller: VoxelEditController; - let mockSource0: RealisticInMemorySource; - - const CHUNK_SIZE = 64; - const ITERATIONS = 5; - - const measure = async ( - operation: string, - inputs: Record, - fn: () => Promise, - ) => { - for (let i = 0; i < 2; i++) await fn(); - - const start = performance.now(); - for (let i = 0; i < ITERATIONS; i++) { - await fn(); - } - const end = performance.now(); - - const avgMs = (end - start) / ITERATIONS; - - const inputStr = Object.entries(inputs) - .map(([k, v]) => `${k}=${v}`) - .join(" "); - console.log( - `[BENCHMARK] ${operation.padEnd(20)} | ${inputStr.padEnd(40)} | ${avgMs.toFixed(4)} ms`, - ); - - results.push({ operation, inputs, avgTimeMs: avgMs }); - - return avgMs; - }; - - beforeAll(() => { - const spec = { - rank: 3, - chunkDataSize: new Uint32Array([CHUNK_SIZE, CHUNK_SIZE, CHUNK_SIZE]), - dataType: DataType.UINT64, - lowerVoxelBound: new Float32Array([0, 0, 0]), - upperVoxelBound: new Float32Array([10000, 10000, 10000]), - baseVoxelOffset: new Float32Array([0, 0, 0]), - fillValue: 0n, - }; - - const mockChunkQueueManager = { - sources: new Set(), - }; - - const mockChunkManager = { - queueManager: mockChunkQueueManager, - memoize: { get: (_k: string, f: Function) => f() }, - }; - - const rpcHandler = { - get: (id: number) => { - if (id === 0) return mockChunkManager; - if (id === 100) return mockSource0; - if (id === 999) return { value: 0 }; - return null; - }, - invoke: () => {}, - newId: () => 0, - register: () => {}, - set: () => {}, - promiseInvoke: async () => {}, - } as any; - - mockSource0 = new RealisticInMemorySource(rpcHandler, { - spec, - chunkManager: 0, - }); - - controller = new VoxelEditController(rpcHandler, { - resolutions: [createResConfig(0, CHUNK_SIZE)], - pendingOpCount: 999, - }); - - vi.spyOn(controller as any, "enqueueDownsample").mockImplementation( - () => {}, - ); - }); - - afterAll(() => { - const outputPath = path.resolve(__dirname, "calibration_results.json"); - fs.writeFileSync(outputPath, JSON.stringify(results, null, 2)); - console.log(`\n\n>>> Results saved to: ${outputPath}\n`); - console.table( - results.map(({ operation, inputs, avgTimeMs }) => ({ - operation, - inputs: JSON.stringify(inputs), - avgTimeMs: avgTimeMs.toFixed(4), - })), - ); - }); - - // -------------------------------------------------------------------------- - // 1. SYSTEM OVERHEAD (Commit) - // -------------------------------------------------------------------------- - - it("Calibrate: Chunk Commit", async () => { - const voxelsCounts = [1000, 10000, 100000]; - const numOfEdits = [1, 10, 50]; - - for (const numOfEdit of numOfEdits) { - for (const count of voxelsCounts) { - const indices = new Uint32Array(count); - const values = new BigUint64Array(count); - for (let i = 0; i < count; i++) { - indices[i] = i; - values[i] = BigInt(i); - } - const edits = [{ key: "lod0#0,0,0", indices, values }]; - for (let i = 1; i < numOfEdit; i++) - edits.push({ key: `lod0#0,0,${i}`, indices, values }); - - await measure( - "Commit", - { voxels: count, edits: numOfEdit, chunks: 1 }, - async () => { - (controller as any).pendingEdits.push(...edits); - await (controller as any).flushPending(); - }, - ); - } - } - }); - - // -------------------------------------------------------------------------- - // 2. DOWNSAMPLING - // -------------------------------------------------------------------------- - - it("Calibrate: Downsample Step (Worst Case Data)", async () => { - const childKey = "0,0,0"; - const chunk = mockSource0.getChunk( - new Float32Array([0, 0, 0]), - ) as VolumeChunk; - - if (!chunk.data) await mockSource0.download(chunk); - const data = chunk.data as BigUint64Array; - for (let i = 0; i < data.length; i++) { - data[i] = BigInt(i % 5); - } - - await measure("Downsample", { inputVoxels: CHUNK_SIZE ** 3 }, async () => { - await (controller as any).downsampleStep(makeVoxChunkKey(childKey, 0)); - }); - }); - - // -------------------------------------------------------------------------- - // 3. BRUSH STROKES - // -------------------------------------------------------------------------- - - const STROKE_LEN = 20; - const runStroke = async ( - shape: BrushShape, - radius: number, - useFilter: boolean, - ) => { - (controller as any).brushCache.reset(); - const center = new Float32Array(3); - const basis = { u: vec3.fromValues(1, 0, 0), v: vec3.fromValues(0, 1, 0) }; - - if (useFilter) { - const chunk = mockSource0.getChunk( - new Float32Array([0, 0, 0]), - ) as VolumeChunk; - if (!chunk.data) await mockSource0.download(chunk); - } - const filterVal = useFilter ? 999n : undefined; - - for (let i = 0; i < STROKE_LEN; i++) { - center[0] = 30 + i; - center[1] = 30; - center[2] = 30; - - await (controller as any).performBrush({ - type: VoxelOperationType.BRUSH, - center, - radius, - value: 1n, - shape, - basis, - filterValue: filterVal, - }); - } - }; - - const radii_sp = [4, 8, 12, 16, 20, 24, 28, 32]; - - radii_sp.forEach((r) => { - it(`Calibrate: Brush SPHERE r=${r} (No Filter)`, async () => { - await measure( - "Brush", - { shape: "SPHERE", radius: r, filter: false }, - async () => { - await runStroke(BrushShape.SPHERE, r, false); - }, - ); - }); - - it(`Calibrate: Brush SPHERE r=${r} (WITH Filter)`, async () => { - await measure( - "Brush", - { shape: "SPHERE", radius: r, filter: true }, - async () => { - await runStroke(BrushShape.SPHERE, r, true); - }, - ); - }); - }); - - const radii_dk = [16, 24, 32, 40, 48, 56, 64]; - - radii_dk.forEach((r) => { - it(`Calibrate: Brush DISK r=${r} (No Filter)`, async () => { - await measure( - "Brush", - { shape: "DISK", radius: r, filter: false }, - async () => { - await runStroke(BrushShape.DISK, r, false); - }, - ); - }); - - it(`Calibrate: Brush DISK r=${r} (WITH Filter)`, async () => { - await measure( - "Brush", - { shape: "DISK", radius: r, filter: true }, - async () => { - await runStroke(BrushShape.DISK, r, true); - }, - ); - }); - }); - - // -------------------------------------------------------------------------- - // 4. FLOOD FILL - // -------------------------------------------------------------------------- - - const setupFloodData = async () => { - mockSource0.storage.clear(); - const chunk = mockSource0.getChunk( - new Float32Array([0, 0, 0]), - ) as VolumeChunk; - chunk.data = null; - await mockSource0.download(chunk); - (chunk.data as BigUint64Array).fill(0n); - }; - - const floodSizes = [1000, 5000, 10000, 25000, 50000]; - - floodSizes.forEach((size) => { - it(`Calibrate: Flood Fill ${size} voxels`, async () => { - await setupFloodData(); - - await measure("FloodFill", { maxVoxels: size }, async () => { - const chunk = mockSource0.getChunk( - new Float32Array([0, 0, 0]), - ) as VolumeChunk; - (chunk.data as BigUint64Array).fill(0n); - - try { - await (controller as any).performFloodFill({ - type: VoxelOperationType.FLOOD_FILL, - seed: new Float32Array([32, 32, 32]), - value: 1n, - maxVoxels: size, - basis: { u: vec3.fromValues(1, 0, 0), v: vec3.fromValues(0, 1, 0) }, - }); - } catch (e: any) { - if (!e.message.includes("too many voxels")) throw e; - } - }); - }); - }); - - // -------------------------------------------------------------------------- - // 5. UNDO RESTORATION - // -------------------------------------------------------------------------- - - it("Calibrate: Undo", async () => { - // 1. Variable Chunks, Fixed Voxels (Chunk Overhead) - const chunkCounts = [1, 10, 50, 100]; - for (const count of chunkCounts) { - const changes = new Map(); - for (let i = 0; i < count; i++) { - const chunk = mockSource0.getChunk( - new Float32Array([i, 0, 0]), - ) as VolumeChunk; - if (!chunk.data) await mockSource0.download(chunk); - const key = `lod0#${i},0,0`; - changes.set(key, { - indices: new Uint32Array([0]), - oldValues: new BigUint64Array([1n]), - newValues: new BigUint64Array([2n]), - }); - } - (controller as any).undoStack.push({ - changes, - timestamp: 0, - description: "bench", - }); - - await measure("Undo", { chunks: count, voxelsTotal: count }, async () => { - const action = - (controller as any).redoStack.pop() || - (controller as any).undoStack.pop(); - (controller as any).undoStack.push(action); - await controller.undo(); - }); - } - - // 2. Fixed Chunk, Variable Voxels (Voxel Throughput) - const voxelCounts = [1000, 10000, 100000]; - for (const count of voxelCounts) { - const indices = new Uint32Array(count); - const vals = new BigUint64Array(count).fill(1n); - const changes = new Map([ - [`lod0#0,0,0`, { indices, oldValues: vals, newValues: vals }], - ]); - - const chunk = mockSource0.getChunk( - new Float32Array([0, 0, 0]), - ) as VolumeChunk; - if (!chunk.data) await mockSource0.download(chunk); - - (controller as any).undoStack.push({ - changes, - timestamp: 0, - description: "bench", - }); - - await measure("Undo", { chunks: 1, voxelsTotal: count }, async () => { - const action = - (controller as any).redoStack.pop() || - (controller as any).undoStack.pop(); - (controller as any).undoStack.push(action); - await controller.undo(); - }); - } - }); -}); diff --git a/src/voxel_annotation/staminaCalibration.benchmark.ts b/src/voxel_annotation/staminaCalibration.benchmark.ts new file mode 100644 index 0000000000..330d3a26e8 --- /dev/null +++ b/src/voxel_annotation/staminaCalibration.benchmark.ts @@ -0,0 +1,309 @@ +/** + * @license + * Copyright 2025 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, bench, vi } from "vitest"; +import type { VolumeChunk } from "#src/sliceview/volume/backend.js"; +import { VolumeChunkSource } from "#src/sliceview/volume/backend.js"; +import { DataType, DATA_TYPE_ARRAY_CONSTRUCTOR } from "#src/util/data_type.js"; +import { vec3 } from "#src/util/geom.js"; +import { VoxelEditController } from "#src/voxel_annotation/backend.js"; +import { + BrushShape, + VoxelOperationType, + makeVoxChunkKey, +} from "#src/voxel_annotation/base.js"; + +const NETWORK_LATENCY = 0; +class RealisticInMemorySource extends VolumeChunkSource { + public storage = new Map(); + + async download(chunk: VolumeChunk) { + if (NETWORK_LATENCY) + await new Promise((resolve) => setTimeout(resolve, NETWORK_LATENCY)); + + if (!chunk.chunkDataSize) { + this.computeChunkBounds(chunk); + } + + const numElements = chunk.chunkDataSize!.reduce((a, b) => a * b, 1); + const Ctor = DATA_TYPE_ARRAY_CONSTRUCTOR[this.spec.dataType]; + const key = chunk.chunkGridPosition.join(","); + + if (this.storage.has(key)) { + chunk.data = new (Ctor as any)(this.storage.get(key)!); + } else { + chunk.data = new (Ctor as any)(numElements); + } + } + + async writeChunk(chunk: VolumeChunk) { + if (NETWORK_LATENCY) + await new Promise((resolve) => setTimeout(resolve, NETWORK_LATENCY)); + + const key = chunk.chunkGridPosition.join(","); + const Ctor = DATA_TYPE_ARRAY_CONSTRUCTOR[this.spec.dataType]; + if (chunk.data) { + this.storage.set(key, new (Ctor as any)(chunk.data)); + } + } +} + +const createResConfig = (lod: number, chunkSize: number) => ({ + lodIndex: lod, + transform: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + chunkSize: [chunkSize, chunkSize, chunkSize], + sourceRpc: 100 + lod, +}); + +const CHUNK_SIZE = 64; + +const spec = { + rank: 3, + chunkDataSize: new Uint32Array([CHUNK_SIZE, CHUNK_SIZE, CHUNK_SIZE]), + dataType: DataType.UINT64, + lowerVoxelBound: new Float32Array([0, 0, 0]), + upperVoxelBound: new Float32Array([10000, 10000, 10000]), + baseVoxelOffset: new Float32Array([0, 0, 0]), + fillValue: 0n, +}; + +const mockChunkQueueManager = { sources: new Set() }; +const mockChunkManager = { + queueManager: mockChunkQueueManager, + memoize: { get: (_k: string, f: Function) => f() }, +}; + +// Forward-declare so the rpcHandler closure can reference it before assignment. +let mockSource0: RealisticInMemorySource; + +const rpcHandler = { + get: (id: number) => { + if (id === 0) return mockChunkManager; + if (id === 100) return mockSource0; + if (id === 999) return { value: 0 }; + return null; + }, + invoke: () => {}, + newId: () => 0, + register: () => {}, + set: () => {}, + promiseInvoke: async () => {}, +} as any; + +mockSource0 = new RealisticInMemorySource(rpcHandler, { + spec, + chunkManager: 0, +}); + +const controller = new VoxelEditController(rpcHandler, { + resolutions: [createResConfig(0, CHUNK_SIZE)], + pendingOpCount: 999, +}); + +vi.spyOn(controller as any, "enqueueDownsample").mockImplementation(() => {}); + +// Pre-populate the downsample chunk with worst-case data once. +const _initChunk = mockSource0.getChunk(new Float32Array([0, 0, 0])) as VolumeChunk; +await mockSource0.download(_initChunk); +const _initData = _initChunk.data as BigUint64Array; +for (let i = 0; i < _initData.length; i++) { + _initData[i] = BigInt(i % 5); +} + +// -------------------------------------------------------------------------- +// 1. SYSTEM OVERHEAD (Commit) +// -------------------------------------------------------------------------- + +describe("Commit", () => { + const voxelsCounts = [1000, 10000, 100000]; + const numOfEdits = [1, 10, 50]; + + for (const numOfEdit of numOfEdits) { + for (const count of voxelsCounts) { + // Pre-compute data at collection time, not during the bench run. + const indices = new Uint32Array(count); + const values = new BigUint64Array(count); + for (let i = 0; i < count; i++) { + indices[i] = i; + values[i] = BigInt(i); + } + const edits = [{ key: "lod0#0,0,0", indices, values }]; + for (let i = 1; i < numOfEdit; i++) + edits.push({ key: `lod0#0,0,${i}`, indices, values }); + + bench(`voxels=${count} edits=${numOfEdit}`, async () => { + (controller as any).pendingEdits.push(...edits); + await (controller as any).flushPending(); + }); + } + } +}); + +// -------------------------------------------------------------------------- +// 2. DOWNSAMPLING +// -------------------------------------------------------------------------- + +describe("Downsample", () => { + bench(`inputVoxels=${CHUNK_SIZE ** 3}`, async () => { + await (controller as any).downsampleStep(makeVoxChunkKey("0,0,0", 0)); + }); +}); + +// -------------------------------------------------------------------------- +// 3. BRUSH STROKES +// -------------------------------------------------------------------------- + +const STROKE_LEN = 20; +const runStroke = async ( + shape: BrushShape, + radius: number, + useFilter: boolean, +) => { + (controller as any).brushCache.reset(); + const center = new Float32Array(3); + const basis = { u: vec3.fromValues(1, 0, 0), v: vec3.fromValues(0, 1, 0) }; + + if (useFilter) { + const chunk = mockSource0.getChunk( + new Float32Array([0, 0, 0]), + ) as VolumeChunk; + if (!chunk.data) await mockSource0.download(chunk); + } + const filterVal = useFilter ? 999n : undefined; + + for (let i = 0; i < STROKE_LEN; i++) { + center[0] = 30 + i; + center[1] = 30; + center[2] = 30; + + await (controller as any).performBrush({ + type: VoxelOperationType.BRUSH, + center, + radius, + value: 1n, + shape, + basis, + filterValue: filterVal, + }); + } +}; + +describe("Brush SPHERE", () => { + for (const r of [4, 8, 12, 16, 20, 24, 28, 32]) { + bench(`r=${r} no filter`, async () => { + await runStroke(BrushShape.SPHERE, r, false); + }); + + bench(`r=${r} with filter`, async () => { + await runStroke(BrushShape.SPHERE, r, true); + }); + } +}); + +describe("Brush DISK", () => { + for (const r of [16, 24, 32, 40, 48, 56, 64]) { + bench(`r=${r} no filter`, async () => { + await runStroke(BrushShape.DISK, r, false); + }); + + bench(`r=${r} with filter`, async () => { + await runStroke(BrushShape.DISK, r, true); + }); + } +}); + +// -------------------------------------------------------------------------- +// 4. FLOOD FILL +// -------------------------------------------------------------------------- + +describe("FloodFill", () => { + for (const size of [1000, 5000, 10000, 25000, 50000]) { + bench(`maxVoxels=${size}`, async () => { + // Reset chunk data each iteration so the flood fill always starts clean. + const chunk = mockSource0.getChunk( + new Float32Array([0, 0, 0]), + ) as VolumeChunk; + (chunk.data as BigUint64Array).fill(0n); + + try { + await (controller as any).performFloodFill({ + type: VoxelOperationType.FLOOD_FILL, + seed: new Float32Array([32, 32, 32]), + value: 1n, + maxVoxels: size, + basis: { u: vec3.fromValues(1, 0, 0), v: vec3.fromValues(0, 1, 0) }, + }); + } catch (e: any) { + if (!e.message.includes("too many voxels")) throw e; + } + }); + } +}); + +// -------------------------------------------------------------------------- +// 5. UNDO +// Each iteration performs the setup (pushing to the undo stack) and the undo +// itself, so reported timings include both. The setup cost is minimal +// compared to the undo write-back. +// -------------------------------------------------------------------------- + +describe("Undo (chunks)", () => { + for (const count of [1, 10, 50, 100]) { + bench(`chunks=${count}`, async () => { + const changes = new Map(); + for (let i = 0; i < count; i++) { + const chunk = mockSource0.getChunk( + new Float32Array([i, 0, 0]), + ) as VolumeChunk; + if (!chunk.data) await mockSource0.download(chunk); + changes.set(`lod0#${i},0,0`, { + indices: new Uint32Array([0]), + oldValues: new BigUint64Array([1n]), + newValues: new BigUint64Array([2n]), + }); + } + (controller as any).undoStack.push({ + changes, + timestamp: 0, + description: "bench", + }); + await controller.undo(); + }); + } +}); + +describe("Undo (voxels, 1 chunk)", () => { + for (const count of [1000, 10000, 100000]) { + const indices = new Uint32Array(count); + const vals = new BigUint64Array(count).fill(1n); + + bench(`voxels=${count}`, async () => { + const chunk = mockSource0.getChunk( + new Float32Array([0, 0, 0]), + ) as VolumeChunk; + if (!chunk.data) await mockSource0.download(chunk); + + (controller as any).undoStack.push({ + changes: new Map([ + [`lod0#0,0,0`, { indices, oldValues: vals, newValues: vals }], + ]), + timestamp: 0, + description: "bench", + }); + await controller.undo(); + }); + } +}); From ac8262042777ca10c5eea9c3080ee122d8e93840 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Tue, 28 Apr 2026 15:54:46 +0200 Subject: [PATCH 216/251] refactor(voxel-annotation): define VOXEL_EMPTY_VALUE constant and document image layer paint limitation --- docs/user-guide/voxel_annotation.rst | 1 + src/layer/image/index.ts | 5 +++- src/layer/segmentation/index.ts | 3 ++- src/voxel_annotation/backend.spec.ts | 5 ++-- src/voxel_annotation/backend.ts | 7 +++--- src/voxel_annotation/base.ts | 2 ++ .../staminaCalibration.benchmark.ts | 25 ++++++++++--------- 7 files changed, 29 insertions(+), 19 deletions(-) diff --git a/docs/user-guide/voxel_annotation.rst b/docs/user-guide/voxel_annotation.rst index ce61383b7f..aef3ab9393 100644 --- a/docs/user-guide/voxel_annotation.rst +++ b/docs/user-guide/voxel_annotation.rst @@ -23,6 +23,7 @@ To enable voxel editing, you must first have a writable volume source. - Only 3D volumes are supported (2D volumes are not). - Float32 data type is not supported. - Multi-resolution datasets must have a strict many-to-one hierarchy. See `About multi-resolution datasets`_ for more details. +- On image layers, the value ``0`` (``VOXEL_EMPTY_VALUE``) cannot be used as a paint value, as it is reserved to represent empty (unedited) voxels in the overlay and is rendered as transparent. The first time you attempt a drawing operation (like a brush stroke) after enabling writing, a confirmation dialog will appear. Note that this initial operation will be canceled; you can resume drawing once you have confirmed. diff --git a/src/layer/image/index.ts b/src/layer/image/index.ts index 5c8131bec1..ed2a6bf7b3 100644 --- a/src/layer/image/index.ts +++ b/src/layer/image/index.ts @@ -79,6 +79,7 @@ import { VOLUME_RENDERING_DEPTH_SAMPLES_DEFAULT_VALUE, VolumeRenderingRenderLayer, } from "#src/volume_rendering/volume_render_layer.js"; +import { VOXEL_EMPTY_VALUE } from "#src/voxel_annotation/base.js"; import type { ParameterizedShaderGetterResult } from "#src/webgl/dynamic_shader.js"; import { makeWatchableShaderError } from "#src/webgl/dynamic_shader.js"; import type { ShaderControlsBuilderState } from "#src/webgl/shader_ui_controls.js"; @@ -208,7 +209,9 @@ ${originalShader} #undef main void main() { - if (toRaw(getDataValue()) == 0n) { + // VOXEL_EMPTY_VALUE is transparent in the overlay so the underlying data + // shows through. This means it cannot be used as a paint value on image layers. + if (toRaw(getDataValue()) == ${VOXEL_EMPTY_VALUE}n) { emitTransparent(); return; } diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 704f7c611b..5f562fbf36 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -136,6 +136,7 @@ import { import { Signal } from "#src/util/signal.js"; import { SEG_ERASE_SENTINEL, + VOXEL_EMPTY_VALUE, type VoxelValueGetter, } from "#src/voxel_annotation/base.js"; import { makeWatchableShaderError } from "#src/webgl/dynamic_shader.js"; @@ -632,7 +633,7 @@ export class SegmentationUserLayer extends Base { getVoxelPaintValue(erase: boolean): VoxelValueGetter { return (isPreview) => { - if (erase) return isPreview ? SEG_ERASE_SENTINEL : 0n; + if (erase) return isPreview ? SEG_ERASE_SENTINEL : VOXEL_EMPTY_VALUE; return this.paintValue.value; }; } diff --git a/src/voxel_annotation/backend.spec.ts b/src/voxel_annotation/backend.spec.ts index dfbd1387e3..2920b6c116 100644 --- a/src/voxel_annotation/backend.spec.ts +++ b/src/voxel_annotation/backend.spec.ts @@ -7,6 +7,7 @@ import { mat4 } from "#src/util/geom.js"; import { VoxelEditController } from "#src/voxel_annotation/backend.js"; import { makeVoxChunkKey, + VOXEL_EMPTY_VALUE, VOX_EDIT_FAILURE_RPC_ID, VOX_EDIT_HISTORY_UPDATE_RPC_ID, VoxelOperationType, @@ -852,7 +853,7 @@ describe("VoxelEditController: flushPending", () => { } return Promise.resolve({ indices: new Uint32Array([1]), - oldValues: new BigUint64Array([0n]), + oldValues: new BigUint64Array([VOXEL_EMPTY_VALUE]), newValues: new BigUint64Array([50n]), }); }); @@ -1175,7 +1176,7 @@ describe("VoxelEditController: Tool Operations", () => { lowerVoxelBound: new Float32Array([0, 0, 0]), upperVoxelBound: new Float32Array([100, 100, 100]), baseVoxelOffset: new Float32Array([0, 0, 0]), - fillValue: 0n, + fillValue: VOXEL_EMPTY_VALUE, }; mockSource = createMockSource({ ...spec }); vi.spyOn(mockSource, "applyEdits").mockResolvedValue({ diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 9a061f5af7..1f44ce7b5f 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -35,6 +35,7 @@ import type { FloodFillOperation, } from "#src/voxel_annotation/base.js"; import { + VOXEL_EMPTY_VALUE, VOXEL_EDIT_STAMINA, VOX_EDIT_BACKEND_RPC_ID, VOX_EDIT_COMMIT_VOXELS_RPC_ID, @@ -1045,13 +1046,13 @@ export class VoxelEditController extends SharedObject { } private _calculateMode(values: (bigint | number)[]): bigint { - if (values.length === 0) return 0n; + if (values.length === 0) return VOXEL_EMPTY_VALUE; const counts = new Map(); let maxCount = 0; let mode = 0n; for (const v of values) { const bigV = BigInt(v); - if (bigV === 0n) continue; + if (bigV === VOXEL_EMPTY_VALUE) continue; const c = (counts.get(bigV) ?? 0) + 1; counts.set(bigV, c); if (c > maxCount) { @@ -1290,7 +1291,7 @@ export class VoxelEditController extends SharedObject { const isFillable = async (p: vec3): Promise => { const val = await accessor.getValue(p[0], p[1], p[2]); if (val === null) return false; - if (originalValue === 0n) return val === 0n; + if (originalValue === VOXEL_EMPTY_VALUE) return val === VOXEL_EMPTY_VALUE; return val === originalValue; }; diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index 5367bd1829..02167ed1c3 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -66,6 +66,8 @@ export const BRUSH_TOOL_ID = "vox-brush"; export const FLOODFILL_TOOL_ID = "vox-flood-fill"; export const VALUE_PICKER_TOOL_ID = "vox-value-picker"; +export const VOXEL_EMPTY_VALUE = 0n; + // Special value used to indicate to the optimistic renderer that a voxel has been erased export const SEG_ERASE_SENTINEL = ~1n; diff --git a/src/voxel_annotation/staminaCalibration.benchmark.ts b/src/voxel_annotation/staminaCalibration.benchmark.ts index 330d3a26e8..965c893758 100644 --- a/src/voxel_annotation/staminaCalibration.benchmark.ts +++ b/src/voxel_annotation/staminaCalibration.benchmark.ts @@ -22,6 +22,7 @@ import { vec3 } from "#src/util/geom.js"; import { VoxelEditController } from "#src/voxel_annotation/backend.js"; import { BrushShape, + VOXEL_EMPTY_VALUE, VoxelOperationType, makeVoxChunkKey, } from "#src/voxel_annotation/base.js"; @@ -77,7 +78,7 @@ const spec = { lowerVoxelBound: new Float32Array([0, 0, 0]), upperVoxelBound: new Float32Array([10000, 10000, 10000]), baseVoxelOffset: new Float32Array([0, 0, 0]), - fillValue: 0n, + fillValue: VOXEL_EMPTY_VALUE, }; const mockChunkQueueManager = { sources: new Set() }; @@ -86,16 +87,13 @@ const mockChunkManager = { memoize: { get: (_k: string, f: Function) => f() }, }; -// Forward-declare so the rpcHandler closure can reference it before assignment. -let mockSource0: RealisticInMemorySource; +const rpcObjects = new Map([ + [0, mockChunkManager], + [999, { value: 0 }], +]); const rpcHandler = { - get: (id: number) => { - if (id === 0) return mockChunkManager; - if (id === 100) return mockSource0; - if (id === 999) return { value: 0 }; - return null; - }, + get: (id: number) => rpcObjects.get(id) ?? null, invoke: () => {}, newId: () => 0, register: () => {}, @@ -103,10 +101,11 @@ const rpcHandler = { promiseInvoke: async () => {}, } as any; -mockSource0 = new RealisticInMemorySource(rpcHandler, { +const mockSource0 = new RealisticInMemorySource(rpcHandler, { spec, chunkManager: 0, }); +rpcObjects.set(100, mockSource0); const controller = new VoxelEditController(rpcHandler, { resolutions: [createResConfig(0, CHUNK_SIZE)], @@ -116,7 +115,9 @@ const controller = new VoxelEditController(rpcHandler, { vi.spyOn(controller as any, "enqueueDownsample").mockImplementation(() => {}); // Pre-populate the downsample chunk with worst-case data once. -const _initChunk = mockSource0.getChunk(new Float32Array([0, 0, 0])) as VolumeChunk; +const _initChunk = mockSource0.getChunk( + new Float32Array([0, 0, 0]), +) as VolumeChunk; await mockSource0.download(_initChunk); const _initData = _initChunk.data as BigUint64Array; for (let i = 0; i < _initData.length; i++) { @@ -236,7 +237,7 @@ describe("FloodFill", () => { const chunk = mockSource0.getChunk( new Float32Array([0, 0, 0]), ) as VolumeChunk; - (chunk.data as BigUint64Array).fill(0n); + (chunk.data as BigUint64Array).fill(VOXEL_EMPTY_VALUE); try { await (controller as any).performFloodFill({ From abb9e11d27df4ae52062174a6807abfcf2d8084e Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Tue, 28 Apr 2026 16:02:11 +0200 Subject: [PATCH 217/251] refactor(voxel-annotation): replace remaining 0n erase value with VOXEL_EMPTY_VALUE --- src/layer/voxel_annotation/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index cc80c699c0..f6c1d39afc 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -69,6 +69,7 @@ import type { } from "#src/voxel_annotation/base.js"; import { VOXEL_EDIT_STAMINA, + VOXEL_EMPTY_VALUE, BRUSH_TOOL_ID, BrushShape, MAX_VOXEL_EDIT_STAMINA, @@ -692,7 +693,8 @@ export function UserLayerWithVoxelEditingMixin< } getVoxelPaintValue(erase: boolean): VoxelValueGetter { - return (_isPreview: boolean) => (erase ? 0n : this.paintValue.value); + return (_isPreview: boolean) => + erase ? VOXEL_EMPTY_VALUE : this.paintValue.value; } setVoxelPaintValue(x: any) { From 35c747e1a7e5a3c716111e7232478e84ffb30eb4 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Tue, 28 Apr 2026 17:17:55 +0200 Subject: [PATCH 218/251] refactor(voxel-annotation): throw on Float32 and extract cursor SVGs to files --- src/layer/voxel_annotation/index.ts | 8 ++--- src/ui/flood_fill_cursor.svg | 24 ++++++++++++++ src/ui/value_picker_cursor.svg | 14 ++++++++ src/ui/voxel_annotations.ts | 50 ++++------------------------- 4 files changed, 47 insertions(+), 49 deletions(-) create mode 100644 src/ui/flood_fill_cursor.svg create mode 100644 src/ui/value_picker_cursor.svg diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index f6c1d39afc..dca3b05fd4 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -701,15 +701,11 @@ export function UserLayerWithVoxelEditingMixin< const editContext = this.editingContexts.values().next().value; if (!editContext) throw new Error("No voxel editing context available"); const dataType = editContext.primarySource.dataType; - let value: bigint; - if (dataType === DataType.FLOAT32) { - const floatValue = parseFloat(String(x)); - value = BigInt(Math.round(floatValue)); - } else { - value = BigInt(x); + throw new Error("Voxel annotation does not support Float32 datasets."); } + const value = BigInt(x); const info = DATA_TYPE_BIT_INFO[dataType as keyof typeof DATA_TYPE_BIT_INFO]; if (!info) { diff --git a/src/ui/flood_fill_cursor.svg b/src/ui/flood_fill_cursor.svg new file mode 100644 index 0000000000..84a1a26c16 --- /dev/null +++ b/src/ui/flood_fill_cursor.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + diff --git a/src/ui/value_picker_cursor.svg b/src/ui/value_picker_cursor.svg new file mode 100644 index 0000000000..5eb7cf0f04 --- /dev/null +++ b/src/ui/value_picker_cursor.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 37c1994bda..059155b030 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -26,6 +26,7 @@ import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transf import { StatusMessage } from "#src/status.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; import { linkWatchableValue } from "#src/trackable_value.js"; +import svg_floodFillCursor from "#src/ui/flood_fill_cursor.svg?raw"; import { LayerTool, makeToolActivationStatusMessageWithHeader, @@ -33,6 +34,7 @@ import { ToolBindingWidget, type ToolActivation, } from "#src/ui/tool.js"; +import svg_valuePicker from "#src/ui/value_picker_cursor.svg?raw"; import { vec3 } from "#src/util/geom.js"; import type { ActionEvent } from "#src/util/mouse_bindings.js"; import { EventActionMap } from "#src/util/mouse_bindings.js"; @@ -65,32 +67,10 @@ function getFloodFillCursor(erase: boolean) { const lightColor = erase ? "#FF8888" : "#FFFFFF"; const darkColor = erase ? "#610000" : "#000000"; - const floodFillSVG = ` - - - - - - - - - - - - - - - - -`.replace(/\s\s+/g, " "); + const floodFillSVG = svg_floodFillCursor + .replaceAll("LIGHT_COLOR", lightColor) + .replaceAll("DARK_COLOR", darkColor) + .replace(/\s\s+/g, " "); return `url('data:image/svg+xml;utf8,${encodeURIComponent(floodFillSVG)}') 24 24, crosshair`; } @@ -556,23 +536,7 @@ export class VoxelFloodFillTool extends BaseVoxelTool { } } -const pickerSVG = ` - - - - - - - - - - - - - -`; - -const pickerCursor = `url('data:image/svg+xml;utf8,${encodeURIComponent(pickerSVG)}') 24 24, crosshair`; +const pickerCursor = `url('data:image/svg+xml;utf8,${encodeURIComponent(svg_valuePicker)}') 24 24, crosshair`; export class AdoptVoxelValueTool extends LayerTool { private lastPickPosition: Float32Array | undefined; From dc7c0702ff663d88b04ae22e29d3f8843260e6cd Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 29 Apr 2026 14:36:03 +0200 Subject: [PATCH 219/251] refactor(voxel-annotation): move Draw tab before Annotations and remove TODOs.md --- src/layer/voxel_annotation/index.ts | 2 +- src/voxel_annotation/TODOs.md | 18 ------------------ 2 files changed, 1 insertion(+), 19 deletions(-) delete mode 100644 src/voxel_annotation/TODOs.md diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index dca3b05fd4..546047f722 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -527,7 +527,7 @@ export function UserLayerWithVoxelEditingMixin< this.tabs.add("Draw", { label: "Draw", - order: 20, + order: 5, hidden: makeDerivedWatchableValue( (editable) => !editable, this.hasSubsourcesWithWritingEnabled, diff --git a/src/voxel_annotation/TODOs.md b/src/voxel_annotation/TODOs.md deleted file mode 100644 index e52c4e0f8e..0000000000 --- a/src/voxel_annotation/TODOs.md +++ /dev/null @@ -1,18 +0,0 @@ -## TODOs - -### priority - -- optimize the flood fill backend -- optimize spheres using the full chunk, see jmbs comment -- see about the list of pending edits for the preview, see jmbs comment - -### later - -### questionable - -- add color feedback on the brush cursor -- add preview for the undo/redo -- add support for volumes with rank different from 3 -- add support to float32 dataset -- add support to unaligned hierarchy (e.g. child chunks that may have multiple parents) -- adapt the brush size to the zoom level linearly From fa90d63d61c758061928fc0dbdef1b460053d4e5 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 29 Apr 2026 14:50:22 +0200 Subject: [PATCH 220/251] docs(voxel-annotation): clarify compressedSegmentationBlockSize decompression in zarr writeChunk --- src/datasource/zarr/backend.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/datasource/zarr/backend.ts b/src/datasource/zarr/backend.ts index ddb3985dc7..0ae5014f7c 100644 --- a/src/datasource/zarr/backend.ts +++ b/src/datasource/zarr/backend.ts @@ -118,6 +118,12 @@ export class ZarrVolumeChunkSource extends WithParameters( } let dataToWrite = chunk.data; + // For segmentation layers (uint32/uint64), neuroglancer automatically + // transcodes chunks into its own compressed segmentation format for GPU + // efficiency (see makeDefaultVolumeChunkSpecifications in + // sliceview/volume/base.ts). chunk.data is therefore stored in that + // compressed format in memory. Before writing back to the zarr store we + // must decompress it so the on-disk data stays as raw integers. const { compressedSegmentationBlockSize } = this.spec; if (compressedSegmentationBlockSize !== undefined) { const compressedData = chunk.data as Uint32Array; From 61b44076d71736b341ec03d3f4da865bee2952e7 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 30 Apr 2026 10:32:07 +0200 Subject: [PATCH 221/251] fix(voxel-annotation): restore backward-compatible subsource serialisation --- src/layer/layer_data_source.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/layer/layer_data_source.ts b/src/layer/layer_data_source.ts index 06acebfaaa..fc1a08679a 100644 --- a/src/layer/layer_data_source.ts +++ b/src/layer/layer_data_source.ts @@ -115,6 +115,9 @@ export function layerDataSourceSpecificationFromJson( function dataSubsourceSpecificationToJson(spec: DataSubsourceSpecification) { const { enabled, writingEnabled } = spec; + if (writingEnabled === undefined) { + return enabled; + } return { enabled, writingEnabled }; } From 70a2aa0b617d41156435e0301cb256c6e27b1fd1 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Tue, 12 May 2026 22:13:18 +0200 Subject: [PATCH 222/251] refactor(voxel-annotation): dispatch brush stroke once at mouseup instead of per rAF tick --- src/layer/voxel_annotation/index.ts | 31 +++- src/ui/voxel_annotations.ts | 67 ++++++-- src/voxel_annotation/backend.spec.ts | 4 +- src/voxel_annotation/backend.ts | 158 ++++++++++-------- src/voxel_annotation/base.ts | 2 +- src/voxel_annotation/frontend.ts | 38 +++-- .../staminaCalibration.benchmark.ts | 2 +- .../pipeline_zarr_s3.browser_test.ts | 6 +- 8 files changed, 188 insertions(+), 120 deletions(-) diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index 546047f722..6c659f4e35 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -221,7 +221,7 @@ export class VoxelEditingContext } } - async paintBrushWithShape( + async applyBrushPreview( points: Float32Array[], radiusCanonical: number, value: VoxelValueGetter, @@ -230,16 +230,37 @@ export class VoxelEditingContext filterValue?: bigint, ) { if (!this._controller) - throw new Error("Cannot use paintBrushWithShape without a controller"); + throw new Error("Cannot use applyBrushPreview without a controller"); + if (!(await this.checkPermission())) return; + await this._controller.applyBrushPreview( + points, + radiusCanonical, + value, + shape, + basis, + filterValue, + ); + } + + async dispatchBrushStroke( + centers: Float32Array[], + radiusCanonical: number, + value: VoxelValueGetter, + shape: BrushShape, + basis: { u: Float32Array; v: Float32Array }, + filterValue?: bigint, + ) { + if (!this._controller) + throw new Error("Cannot use dispatchBrushStroke without a controller"); const cost = VOXEL_EDIT_STAMINA.brush( shape, radiusCanonical, filterValue !== undefined, - ) * points.length; + ) * centers.length; await this.withCost(cost, () => - this._controller!.paintBrushWithShape( - points, + this._controller!.dispatchBrushStroke( + centers, radiusCanonical, value, shape, diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 059155b030..897b49573a 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -42,9 +42,11 @@ import { startRelativeMouseDrag } from "#src/util/mouse_drag.js"; import { WatchableVisibilityPriority } from "#src/visibility_priority/frontend.js"; import { BRUSH_TOOL_ID, + type BrushShape, FLOODFILL_TOOL_ID, getBasisFromNormal, VALUE_PICKER_TOOL_ID, + type VoxelValueGetter, } from "#src/voxel_annotation/base.js"; const BRUSH_INPUT_MAP = EventActionMap.fromObject({ @@ -320,6 +322,16 @@ export class VoxelBrushTool extends BaseVoxelTool { private mouseDisposer: (() => void) | undefined; private animationFrameHandle: number | null = null; private cursorResetTimer: number | null = null; + private accumulatedCenters: Float32Array[] = []; + private activeStroke: + | { + radius: number; + shape: BrushShape; + basis: { u: Float32Array; v: Float32Array }; + value: VoxelValueGetter; + filterValue: bigint | undefined; + } + | undefined = undefined; activate(activation: ToolActivation): boolean { if (!super.activate(activation)) return false; @@ -432,6 +444,17 @@ export class VoxelBrushTool extends BaseVoxelTool { private startDrawing(mouseState: MouseSelectionState) { if (this.isDrawing) return; this.isDrawing = true; + this.accumulatedCenters = []; + this.activeStroke = { + radius: this.layer.brushRadius.value, + shape: this.layer.brushShape.value, + basis: this.getBasis()!, + value: this.layer.getVoxelPaintValue(this.layer.shouldErase()), + filterValue: + this.layer.lockToSelectedValue.value && this.layer.shouldErase() + ? this.layer.getVoxelPaintValue(false)(false) + : undefined, + }; const start = this.getPoint(mouseState); if (!start) { @@ -465,33 +488,43 @@ export class VoxelBrushTool extends BaseVoxelTool { this.mouseDisposer(); this.mouseDisposer = undefined; } + + const centers = this.accumulatedCenters; + this.accumulatedCenters = []; + if (centers.length === 0) return; + + const editContext = getEditingContext(this.layer); + if (editContext === undefined) return; + const stroke = this.activeStroke!; + + void editContext.dispatchBrushStroke( + centers, + stroke.radius, + stroke.value, + stroke.shape, + stroke.basis, + stroke.filterValue, + ); } private paintPoints(points: Float32Array[]) { - const radius = Math.max(1, Math.floor(this.layer.brushRadius.value ?? 3)); const editContext = getEditingContext(this.layer); if (editContext === undefined) { throw new Error("editContext is undefined"); } - const shapeEnum = this.layer.brushShape.value; - const basis = this.getBasis(); - if (!basis) { - throw new Error("basis is undefined"); - } + const stroke = this.activeStroke!; - const value = this.layer.getVoxelPaintValue(this.layer.shouldErase()); - const filterValue = - this.layer.lockToSelectedValue.value && this.layer.shouldErase() - ? this.layer.getVoxelPaintValue(false)(false) - : undefined; + for (const p of points) { + this.accumulatedCenters.push(p); + } - void editContext.paintBrushWithShape( + void editContext.applyBrushPreview( points, - radius, - value, - shapeEnum, - basis, - filterValue, + stroke.radius, + stroke.value, + stroke.shape, + stroke.basis, + stroke.filterValue, ); } } diff --git a/src/voxel_annotation/backend.spec.ts b/src/voxel_annotation/backend.spec.ts index 2920b6c116..57aad45064 100644 --- a/src/voxel_annotation/backend.spec.ts +++ b/src/voxel_annotation/backend.spec.ts @@ -1213,7 +1213,7 @@ describe("VoxelEditController: Tool Operations", () => { await controller.performOperation({ type: VoxelOperationType.BRUSH, - center, + centers: [center], radius, value, shape: BrushShape.SPHERE, @@ -1251,7 +1251,7 @@ describe("VoxelEditController: Tool Operations", () => { await controller.performOperation({ type: VoxelOperationType.BRUSH, - center, + centers: [center], radius, value, shape: BrushShape.DISK, diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 1f44ce7b5f..176e189181 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -1159,14 +1159,11 @@ export class VoxelEditController extends SharedObject { } private async performBrush(op: BrushOperation): Promise { - const { center, radius, value, shape, basis, filterValue } = op; + const { centers, radius, value, shape, basis, filterValue } = op; const voxelSize = 1; // Hardcoded LOD 0 const sourceIndex = 0; const accessor = this.getAccessor(sourceIndex); - const cx = Math.round((center[0] ?? 0) / voxelSize); - const cy = Math.round((center[1] ?? 0) / voxelSize); - const cz = Math.round((center[2] ?? 0) / voxelSize); let r = Math.round(radius / voxelSize); if (r <= 0) throw new Error(`Brush radius must be positive.`); r -= 1; @@ -1175,88 +1172,101 @@ export class VoxelEditController extends SharedObject { // This capacity should ensure we never get out of bounds const maxCapacity = Math.ceil((2 * r + 1) ** 3); const voxelBuffer = new Int32Array(maxCapacity * 3); - let voxelCount = 0; - const bufferEnqueue = (x: number, y: number, z: number) => { - // don;t need to check bounds as the capacity is assumed to be large enough - const base = voxelCount * 3; - voxelBuffer[base] = x; - voxelBuffer[base + 1] = y; - voxelBuffer[base + 2] = z; - voxelCount++; - }; - const shouldSkip = this.brushCache.buildSkipper(value, shape, basis); + for (const center of centers) { + const cx = Math.round((center[0] ?? 0) / voxelSize); + const cy = Math.round((center[1] ?? 0) / voxelSize); + const cz = Math.round((center[2] ?? 0) / voxelSize); + + let voxelCount = 0; + const bufferEnqueue = (x: number, y: number, z: number) => { + // don;t need to check bounds as the capacity is assumed to be large enough + const base = voxelCount * 3; + voxelBuffer[base] = x; + voxelBuffer[base + 1] = y; + voxelBuffer[base + 2] = z; + voxelCount++; + }; - const toAwait = new Set>(); - const pushIf = (x: number, y: number, z: number) => { - if (shouldSkip(x, y, z)) { - return; - } - if (filterValue == undefined) { - bufferEnqueue(x, y, z); - return; - } - toAwait.add( - accessor.getValue(x, y, z).then((v) => { - if (v == null) return; - if (v === value || (filterValue !== undefined && v !== filterValue)) - return; + const shouldSkip = this.brushCache.buildSkipper(value, shape, basis); + + const toAwait = new Set>(); + const pushIf = (x: number, y: number, z: number) => { + if (shouldSkip(x, y, z)) { + return; + } + if (filterValue == undefined) { bufferEnqueue(x, y, z); - }), - ); - }; + return; + } + toAwait.add( + accessor.getValue(x, y, z).then((v) => { + if (v == null) return; + if (v === value || (filterValue !== undefined && v !== filterValue)) + return; + bufferEnqueue(x, y, z); + }), + ); + }; - if (shape !== BrushShape.DISK) { - for (let dz = -r; dz <= r; ++dz) { - for (let dy = -r; dy <= r; ++dy) { - for (let dx = -r; dx <= r; ++dx) { - if (dx * dx + dy * dy + dz * dz <= rr) - pushIf(cx + dx, cy + dy, cz + dz); + if (shape !== BrushShape.DISK) { + for (let dz = -r; dz <= r; ++dz) { + for (let dy = -r; dy <= r; ++dy) { + for (let dx = -r; dx <= r; ++dx) { + if (dx * dx + dy * dy + dz * dz <= rr) + pushIf(cx + dx, cy + dy, cz + dz); + } } } - } - } else { - if (basis === undefined) throw new Error("Brush shape requires a basis."); - const { u, v } = basis as { u: vec3; v: vec3 }; - const ux = u[0], - uy = u[1], - uz = u[2]; - const vx = v[0], - vy = v[1], - vz = v[2]; - - for (let j = -r; j <= r; ++j) { - const j2 = j * j; - const vPartX = vx * j; - const vPartY = vy * j; - const vPartZ = vz * j; - - for (let i = -r; i <= r; ++i) { - if (i * i + j2 <= rr) { - const px = Math.round(cx + ux * i + vPartX); - const py = Math.round(cy + uy * i + vPartY); - const pz = Math.round(cz + uz * i + vPartZ); - pushIf(px, py, pz); + } else { + if (basis === undefined) + throw new Error("Brush shape requires a basis."); + const { u, v } = basis as { u: vec3; v: vec3 }; + const ux = u[0], + uy = u[1], + uz = u[2]; + const vx = v[0], + vy = v[1], + vz = v[2]; + + for (let j = -r; j <= r; ++j) { + const j2 = j * j; + const vPartX = vx * j; + const vPartY = vy * j; + const vPartZ = vz * j; + + for (let i = -r; i <= r; ++i) { + if (i * i + j2 <= rr) { + const px = Math.round(cx + ux * i + vPartX); + const py = Math.round(cy + uy * i + vPartY); + const pz = Math.round(cz + uz * i + vPartZ); + pushIf(px, py, pz); + } } } } - } - await Promise.all(toAwait); - this.brushCache.update({ x: cx, y: cy, z: cz }, r, value, shape, basis); + await Promise.all(toAwait); + this.brushCache.update({ x: cx, y: cy, z: cz }, r, value, shape, basis); - if (voxelCount === 0) return; + if (voxelCount === 0) continue; - if (basis && shape === BrushShape.DISK) { - const result = this.fillPlaneAliasingGaps( - voxelBuffer, - voxelCount, - basis, - center, - ); - this.processBackendEdits(result.buffer, result.count, value, sourceIndex); - } else { - this.processBackendEdits(voxelBuffer, voxelCount, value, sourceIndex); + if (basis && shape === BrushShape.DISK) { + const result = this.fillPlaneAliasingGaps( + voxelBuffer, + voxelCount, + basis, + center, + ); + this.processBackendEdits( + result.buffer, + result.count, + value, + sourceIndex, + ); + } else { + this.processBackendEdits(voxelBuffer, voxelCount, value, sourceIndex); + } } } diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index 02167ed1c3..82c83b2223 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -43,7 +43,7 @@ export interface VoxelOperationBase { export interface BrushOperation extends VoxelOperationBase { type: VoxelOperationType.BRUSH; - center: Float32Array; + centers: Float32Array[]; radius: number; value: bigint; shape: BrushShape; diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 52845605b7..aaca33f5f1 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -137,7 +137,7 @@ export class VoxelEditController extends SharedObject { } as const; } - async paintBrushWithShape( + async applyBrushPreview( points: Float32Array[], radiusCanonical: number, valueGetter: VoxelValueGetter, @@ -172,7 +172,6 @@ export class VoxelEditController extends SharedObject { const edits = new Map(); const previewValue = valueGetter(true); - const storageValue = valueGetter(false); let previewSource: InMemoryVolumeChunkSource | undefined; let sizeX = 0, @@ -204,8 +203,6 @@ export class VoxelEditController extends SharedObject { baseSource = sourcesByScale[0][0].chunkSource as VolumeChunkSource; } - const backendOps: Promise[] = []; - for (const centerCanonical of points) { let voxelCount = 0; @@ -291,25 +288,32 @@ export class VoxelEditController extends SharedObject { entry.indices.push(index); } } - - backendOps.push( - this.dispatchOperation({ - type: VoxelOperationType.BRUSH, - center: centerCanonical, - radius: radiusCanonical, - value: storageValue, - shape, - basis, - filterValue, - }), - ); } if (edits.size > 0 && previewSource) { previewSource.applyLocalEdits(edits); } + } - await Promise.all(backendOps); + async dispatchBrushStroke( + centers: Float32Array[], + radiusCanonical: number, + valueGetter: VoxelValueGetter, + shape: BrushShape, + basis: { u: Float32Array; v: Float32Array }, + filterValue?: bigint, + ) { + if (centers.length === 0) return; + const storageValue = valueGetter(false); + await this.dispatchOperation({ + type: VoxelOperationType.BRUSH, + centers, + radius: radiusCanonical, + value: storageValue, + shape, + basis, + filterValue, + }); } async floodFillPlane2D( diff --git a/src/voxel_annotation/staminaCalibration.benchmark.ts b/src/voxel_annotation/staminaCalibration.benchmark.ts index 965c893758..aaebdd24f8 100644 --- a/src/voxel_annotation/staminaCalibration.benchmark.ts +++ b/src/voxel_annotation/staminaCalibration.benchmark.ts @@ -192,7 +192,7 @@ const runStroke = async ( await (controller as any).performBrush({ type: VoxelOperationType.BRUSH, - center, + centers: [center], radius, value: 1n, shape, diff --git a/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts b/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts index 49a2a3a0a6..261bdd16d6 100644 --- a/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts +++ b/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts @@ -154,7 +154,7 @@ test("Pipeline: Zarr V2 (UINT8) Undo/Redo with Brush", async () => { const { context } = await waitForEditingContext(); const center = new Float32Array([16, 16, 16]); - await context.paintBrushWithShape([center], 5, (_) => 100n, 0 /* DISK */, { + await context.dispatchBrushStroke([center], 5, (_) => 100n, 0 /* DISK */, { u: new Float32Array([1, 0, 0]), v: new Float32Array([0, 1, 0]), }); @@ -222,7 +222,7 @@ test("Pipeline: Zarr V3 (UINT64) Brush", async () => { const center = new Float32Array([16, 16, 16]); const paintVal = 123456789n; - await context.paintBrushWithShape( + await context.dispatchBrushStroke( [center], 2, (_) => paintVal, @@ -278,7 +278,7 @@ test("Pipeline: Zarr V2 (UINT32) with Slash Separator", async () => { const center = new Float32Array([10, 10, 10]); const paintVal = 42n; - await context.paintBrushWithShape( + await context.dispatchBrushStroke( [center], 2, (_) => paintVal, From 9e20ca60239a06cdf61717432007e8600cd2f1b4 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Sat, 23 May 2026 19:24:24 +0200 Subject: [PATCH 223/251] refactor(voxel-annotation): re-implement @chrisj sphere optimisations while keeping filterValue and disk shape - Add getSphereRowRangesKernel and getDiskStencilKernel in base.ts, both cached by radius - Export LocalVolumeEdit from sliceview/volume/frontend.ts with indexRanges and chunkGridPosition; applyLocalEdits uses TypedArray.fill() for contiguous ranges - Sphere fast path (no filterValue): row-range kernel + fill() - Sphere slow path (filterValue): same kernel expanded per-voxel with filter check - Disk: cached 2D stencil, basis projection prevents fill() - All shapes: center deduplication, pre-computed chunkGridPosition, early-return without previewSource --- src/sliceview/volume/frontend.ts | 62 ++++--- src/voxel_annotation/base.ts | 46 +++++ src/voxel_annotation/frontend.ts | 290 +++++++++++++++++++------------ 3 files changed, 260 insertions(+), 138 deletions(-) diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index 8a46849a40..57e0f463fb 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -44,6 +44,25 @@ import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; import { getShaderType, glsl_mixLinear } from "#src/webgl/shader_lib.js"; import { registerSharedObjectOwner } from "#src/worker_rpc.js"; +export interface LocalVolumeEdit { + indices: number[]; + indexRanges?: number[]; + value: bigint; + chunkGridPosition?: Float32Array; +} + +function parseChunkGridPositionKey(key: string): Float32Array { + const pos = new Float32Array(3); + let component = 0; + let start = 0; + for (let i = 0; i <= key.length && component < 3; ++i) { + if (i !== key.length && key.charCodeAt(i) !== 44) continue; + pos[component++] = Number(key.slice(start, i)); + start = i + 1; + } + return pos; +} + export interface ChunkFormat { shaderKey: string; @@ -305,19 +324,19 @@ export class InMemoryVolumeChunkSource extends VolumeChunkSource { setTimeout(update, 100); } - applyLocalEdits( - edits: Map, - ): void { + applyLocalEdits(edits: Map): void { const chunksToUpdate = new Set(); const { dataType } = this.spec; + const isUint64 = dataType === DataType.UINT64; for (const [key, edit] of edits.entries()) { - const chunkGridPosition = new Float32Array(key.split(",").map(Number)); + const chunkGridPosition = + edit.chunkGridPosition ?? parseChunkGridPositionKey(key); let chunk = this.chunks.get(key) as UncompressedVolumeChunk | undefined; if (chunk === undefined) { chunk = this.getChunk({ - chunkGridPosition: chunkGridPosition, + chunkGridPosition, }) as UncompressedVolumeChunk; this.addChunk(key, chunk); } @@ -330,27 +349,22 @@ export class InMemoryVolumeChunkSource extends VolumeChunkSource { chunksToUpdate.add(chunk); const cpuArray = chunk.data!; + const fillValue = isUint64 ? edit.value : Number(edit.value); + + const { indexRanges } = edit; + if (indexRanges !== undefined) { + for (let i = 0; i < indexRanges.length; i += 2) { + const start = indexRanges[i]!; + const length = indexRanges[i + 1]!; + (cpuArray as any).fill(fillValue, start, start + length); + } + } for (const index of edit.indices) { - const value = edit.value; - switch (dataType) { - case DataType.UINT8: - case DataType.INT8: - case DataType.UINT16: - case DataType.INT16: - case DataType.UINT32: - case DataType.INT32: - case DataType.FLOAT32: - cpuArray[index] = Number(value); - break; - case DataType.UINT64: - (cpuArray as BigUint64Array)[index] = value; - break; - default: - console.warn( - `Unsupported data type for editing: ${DataType[dataType]}`, - ); - break; + if (isUint64) { + (cpuArray as BigUint64Array)[index] = edit.value; + } else { + cpuArray[index] = fillValue as number; } } } diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index 82c83b2223..c27d31745d 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -155,6 +155,52 @@ export enum BrushShape { SPHERE = 1, } +const sphereRowRangesKernelCache = new Map(); + +export function getSphereRowRangesKernel(radius: number): Int16Array { + let kernel = sphereRowRangesKernelCache.get(radius); + if (kernel !== undefined) return kernel; + if (!Number.isInteger(radius) || radius < 0) { + throw new Error(`Invalid sphere radius: ${radius}`); + } + const rr = radius * radius; + const ranges: number[] = []; + for (let dz = -radius; dz <= radius; ++dz) { + const dz2 = dz * dz; + for (let dy = -radius; dy <= radius; ++dy) { + const remaining = rr - dz2 - dy * dy; + if (remaining < 0) continue; + const maxDx = Math.floor(Math.sqrt(remaining)); + ranges.push(dy, dz, -maxDx, maxDx + 1); + } + } + kernel = Int16Array.from(ranges); + sphereRowRangesKernelCache.set(radius, kernel); + return kernel; +} + +const diskStencilKernelCache = new Map(); + +export function getDiskStencilKernel(radius: number): Int16Array { + let kernel = diskStencilKernelCache.get(radius); + if (kernel !== undefined) return kernel; + if (!Number.isInteger(radius) || radius < 0) { + throw new Error(`Invalid disk radius: ${radius}`); + } + const rr = radius * radius; + const pairs: number[] = []; + for (let j = -radius; j <= radius; ++j) { + for (let i = -radius; i <= radius; ++i) { + if (i * i + j * j <= rr) { + pairs.push(i, j); + } + } + } + kernel = Int16Array.from(pairs); + diskStencilKernelCache.set(radius, kernel); + return kernel; +} + export interface VoxelEditControllerHost { primarySource: MultiscaleVolumeChunkSource; previewSource?: VoxelPreviewMultiscaleSource; diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index aaca33f5f1..8232793efa 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -18,11 +18,12 @@ import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transf import { SharedWatchableValue } from "#src/shared_watchable_value.js"; import type { InMemoryVolumeChunkSource, + LocalVolumeEdit, VolumeChunkSource, } from "#src/sliceview/volume/frontend.js"; import { StatusMessage } from "#src/status.js"; import { WatchableValue } from "#src/trackable_value.js"; -import { vec3 } from "#src/util/geom.js"; +import type { vec3 } from "#src/util/geom.js"; import type { VoxelEditControllerHost, VoxelLayerResolution, @@ -32,6 +33,8 @@ import type { import { makeVoxChunkKey, BrushShape, + getDiskStencilKernel, + getSphereRowRangesKernel, parseVoxChunkKey, VOX_EDIT_BACKEND_RPC_ID, VOX_EDIT_FAILURE_RPC_ID, @@ -145,57 +148,51 @@ export class VoxelEditController extends SharedObject { basis: { u: Float32Array; v: Float32Array }, filterValue?: bigint, ) { + if (!this.host.previewSource) return; + const voxelSize = 1; // Assuming LOD 0 let r = Math.round(radiusCanonical / voxelSize); if (r <= 0) { throw new Error("Brush radius must be positive."); } r -= 1; - const rr = r * r; - const { u: uVec, v: vVec } = basis as { u: vec3; v: vec3 }; - const n = vec3.create(); - vec3.cross(n, uVec, vVec); - vec3.normalize(n, n); - const ux = uVec[0], - uy = uVec[1], - uz = uVec[2]; - const vx = vVec[0], - vy = vVec[1], - vz = vVec[2]; - const nx = n[0], - ny = n[1], - nz = n[2]; - - // WATCHOUT: update this value if the max possible voxel count changes - const maxCapacity = Math.ceil((2 * r + 1) ** 2 * 4); - const voxelBuffer = new Int32Array(maxCapacity * 3); - const edits = new Map(); - const previewValue = valueGetter(true); + const previewSource = this.host.previewSource.getSources( + this.getIdentitySliceViewSourceOptions(), + )[0][0].chunkSource as InMemoryVolumeChunkSource; - let previewSource: InMemoryVolumeChunkSource | undefined; - let sizeX = 0, - sizeY = 0, - sizeZ = 0; - let strideY = 0, - strideZ = 0; + const { chunkDataSize } = previewSource.spec; + const sizeX = chunkDataSize[0]; + const sizeY = chunkDataSize[1]; + const sizeZ = chunkDataSize[2]; + const strideY = sizeX; + const strideZ = sizeX * sizeY; - if (this.host.previewSource) { - previewSource = this.host.previewSource.getSources( - this.getIdentitySliceViewSourceOptions(), - )[0][0].chunkSource as InMemoryVolumeChunkSource; - - const { chunkDataSize } = previewSource.spec; - sizeX = chunkDataSize[0]; - sizeY = chunkDataSize[1]; - sizeZ = chunkDataSize[2]; - strideY = sizeX; - strideZ = sizeX * sizeY; + // Deduplicate centers that round to the same voxel position + const centers: [number, number, number][] = []; + if (points.length <= 1) { + const c = points[0]!; + centers.push([ + Math.round((c[0] ?? 0) / voxelSize), + Math.round((c[1] ?? 0) / voxelSize), + Math.round((c[2] ?? 0) / voxelSize), + ]); + } else { + const seen = new Set(); + for (const c of points) { + const cx = Math.round((c[0] ?? 0) / voxelSize); + const cy = Math.round((c[1] ?? 0) / voxelSize); + const cz = Math.round((c[2] ?? 0) / voxelSize); + const key = `${cx},${cy},${cz}`; + if (seen.has(key)) continue; + seen.add(key); + centers.push([cx, cy, cz]); + } } + // filterValue setup (slow path only) let baseSource: VolumeChunkSource | undefined; const tempPos = new Float32Array(3); - if (filterValue !== undefined) { const sourcesByScale = this.host.primarySource.getSources( this.getIdentitySliceViewSourceOptions(), @@ -203,94 +200,147 @@ export class VoxelEditController extends SharedObject { baseSource = sourcesByScale[0][0].chunkSource as VolumeChunkSource; } - for (const centerCanonical of points) { - let voxelCount = 0; - - const addVoxel = (x: number, y: number, z: number) => { - if (filterValue && baseSource !== undefined) { - tempPos[0] = x; - tempPos[1] = y; - tempPos[2] = z; - const val = baseSource.getValueAt(tempPos, this.singleChannelAccess); - if (val != null) { - const bigVal = typeof val === "bigint" ? val : BigInt(val); - if (bigVal !== filterValue) return; - } - } + const passesFilter = (x: number, y: number, z: number): boolean => { + tempPos[0] = x; + tempPos[1] = y; + tempPos[2] = z; + const val = baseSource!.getValueAt(tempPos, this.singleChannelAccess); + if (val == null) return true; + const bigVal = typeof val === "bigint" ? val : BigInt(val); + return bigVal === filterValue; + }; + + type EditEntry = LocalVolumeEdit & { + indices: number[]; + indexRanges: number[]; + }; + const edits = new Map(); + const previewValue = valueGetter(true); - const base = voxelCount * 3; - voxelBuffer[base] = x; - voxelBuffer[base + 1] = y; - voxelBuffer[base + 2] = z; - voxelCount++; + const getOrCreateEdit = ( + chunkX: number, + chunkY: number, + chunkZ: number, + ): EditEntry => { + const key = `${chunkX},${chunkY},${chunkZ}`; + let entry = edits.get(key); + if (entry !== undefined) return entry; + entry = { + indices: [], + indexRanges: [], + value: previewValue, + chunkGridPosition: Float32Array.of(chunkX, chunkY, chunkZ), }; + edits.set(key, entry); + return entry; + }; - const cx = Math.round((centerCanonical[0] ?? 0) / voxelSize); - const cy = Math.round((centerCanonical[1] ?? 0) / voxelSize); - const cz = Math.round((centerCanonical[2] ?? 0) / voxelSize); - - if (shape === BrushShape.DISK) { - for (let j = -r; j <= r; ++j) { - for (let i = -r; i <= r; ++i) { - if (i * i + j * j <= rr) { - const px = Math.round(cx + ux * i + vx * j); - const py = Math.round(cy + uy * i + vy * j); - const pz = Math.round(cz + uz * i + vz * j); - addVoxel(px, py, pz); - } + // Append a single voxel to the correct chunk edit (used by slow paths) + const appendVoxel = (x: number, y: number, z: number) => { + const chunkX = Math.floor(x / sizeX); + const chunkY = Math.floor(y / sizeY); + const chunkZ = Math.floor(z / sizeZ); + const lx = x - chunkX * sizeX; + const ly = y - chunkY * sizeY; + const lz = z - chunkZ * sizeZ; + getOrCreateEdit(chunkX, chunkY, chunkZ).indices.push( + lz * strideZ + ly * strideY + lx, + ); + }; + + // Append a contiguous x-run [xStart, xEndExcl) at (y, z), splitting at chunk boundaries. + // Merges adjacent ranges within the same chunk entry. + const appendRange = ( + xStart: number, + xEndExcl: number, + y: number, + z: number, + ) => { + const chunkY = Math.floor(y / sizeY); + const chunkZ = Math.floor(z / sizeZ); + const ly = y - chunkY * sizeY; + const lz = z - chunkZ * sizeZ; + const baseIndex = lz * strideZ + ly * strideY; + + let currentX = xStart; + while (currentX < xEndExcl) { + const chunkX = Math.floor(currentX / sizeX); + const segEnd = Math.min(xEndExcl, (chunkX + 1) * sizeX); + const startIndex = baseIndex + (currentX - chunkX * sizeX); + const length = segEnd - currentX; + + const entry = getOrCreateEdit(chunkX, chunkY, chunkZ); + if (length === 1) { + entry.indices.push(startIndex); + } else { + const ranges = entry.indexRanges; + const last = ranges.length - 1; + if (last >= 1 && ranges[last - 1]! + ranges[last]! === startIndex) { + ranges[last] = ranges[last]! + length; + } else { + ranges.push(startIndex, length); + } + } + currentX = segEnd; + } + }; + + if (shape === BrushShape.SPHERE) { + const kernel = getSphereRowRangesKernel(r); + if (filterValue === undefined) { + // Fast path: contiguous row ranges → TypedArray.fill() + for (const [cx, cy, cz] of centers) { + for (let i = 0; i < kernel.length; i += 4) { + appendRange( + cx + kernel[i + 2]!, + cx + kernel[i + 3]!, + cy + kernel[i]!, + cz + kernel[i + 1]!, + ); } } } else { - for (let j = -r; j <= r; ++j) { - for (let i = -r; i <= r; ++i) { - if (i * i + j * j <= rr) { - let px = Math.round(cx + ux * i + vx * j); - let py = Math.round(cy + uy * i + vy * j); - let pz = Math.round(cz + uz * i + vz * j); - addVoxel(px, py, pz); - - px = Math.round(cx + ux * i + nx * j); - py = Math.round(cy + uy * i + ny * j); - pz = Math.round(cz + uz * i + nz * j); - addVoxel(px, py, pz); - - px = Math.round(cx + nx * i + vx * j); - py = Math.round(cy + ny * i + vy * j); - pz = Math.round(cz + nz * i + vz * j); - addVoxel(px, py, pz); + // Slow path: per-voxel filter check + for (const [cx, cy, cz] of centers) { + for (let i = 0; i < kernel.length; i += 4) { + const dy = kernel[i]!; + const dz = kernel[i + 1]!; + const xStart = kernel[i + 2]!; + const xEndExcl = kernel[i + 3]!; + for (let dx = xStart; dx < xEndExcl; ++dx) { + const x = cx + dx; + const y = cy + dy; + const z = cz + dz; + if (passesFilter(x, y, z)) appendVoxel(x, y, z); } } } } - - if (voxelCount > 0 && previewSource) { - for (let i = 0; i < voxelCount; ++i) { - const base = i * 3; - const x = voxelBuffer[base]; - const y = voxelBuffer[base + 1]; - const z = voxelBuffer[base + 2]; - - const chunkX = Math.floor(x / sizeX); - const chunkY = Math.floor(y / sizeY); - const chunkZ = Math.floor(z / sizeZ); - - const lx = x - chunkX * sizeX; - const ly = y - chunkY * sizeY; - const lz = z - chunkZ * sizeZ; - - const key = `${chunkX},${chunkY},${chunkZ}`; - let entry = edits.get(key); - if (!entry) { - entry = { indices: [], value: previewValue }; - edits.set(key, entry); - } - const index = lz * strideZ + ly * strideY + lx; - entry.indices.push(index); + } else { + // DISK: project stencil through basis vectors, no axis-aligned runs possible + const { u: uVec, v: vVec } = basis as { u: vec3; v: vec3 }; + const ux = uVec[0], + uy = uVec[1], + uz = uVec[2]; + const vx = vVec[0], + vy = vVec[1], + vz = vVec[2]; + const stencil = getDiskStencilKernel(r); + + for (const [cx, cy, cz] of centers) { + for (let i = 0; i < stencil.length; i += 2) { + const si = stencil[i]!; + const sj = stencil[i + 1]!; + const x = Math.round(cx + ux * si + vx * sj); + const y = Math.round(cy + uy * si + vy * sj); + const z = Math.round(cz + uz * si + vz * sj); + if (filterValue !== undefined && !passesFilter(x, y, z)) continue; + appendVoxel(x, y, z); } } } - if (edits.size > 0 && previewSource) { + if (edits.size > 0) { previewSource.applyLocalEdits(edits); } } @@ -366,7 +416,19 @@ export class VoxelEditController extends SharedObject { }; const originalValue = getValue(startX, startY, startZ); - if (originalValue === null) return; + if (originalValue === null) { + // Chunk not yet loaded on the frontend — skip preview and dispatch directly + // to the backend, which will load the chunk itself. + await this.dispatchOperation({ + type: VoxelOperationType.FLOOD_FILL, + seed: startPositionCanonical, + value: fillValueGetter(false), + maxVoxels, + basis, + filterValue, + }); + return; + } if (filterValue !== undefined && originalValue !== filterValue) return; if (originalValue === previewValue) return; From df28681db04b89da5e8666dff94b64f640f85788 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Sun, 24 May 2026 10:54:21 +0200 Subject: [PATCH 224/251] test(voxel-annotation): add back @chrisj tests for chunkGridPosition and indexRanges in applyLocalEdits --- src/sliceview/volume/frontend.spec.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/sliceview/volume/frontend.spec.ts b/src/sliceview/volume/frontend.spec.ts index 5175d25d18..22feec1cd3 100644 --- a/src/sliceview/volume/frontend.spec.ts +++ b/src/sliceview/volume/frontend.spec.ts @@ -177,4 +177,28 @@ describe("InMemoryVolumeChunkSource", () => { expect(chunk.updateFromCpuData).toHaveBeenCalledWith(glMock); expect(visibleChunksChangedMock.dispatch).toHaveBeenCalled(); }); + + it("Uses provided chunkGridPosition instead of parsing the key", () => { + const source = createSource(DataType.UINT64); + const chunkGridPosition = Float32Array.of(4, 5, 6); + + source.applyLocalEdits( + new Map([["4,5,6", { indices: [0], value: 123n, chunkGridPosition }]]), + ); + + const chunk = source.chunks.get("4,5,6") as unknown as MockChunk; + expect(chunk.chunkGridPosition).toBe(chunkGridPosition); + expect(chunk.data[0]).toBe(123n); + }); + + it("Applies contiguous index ranges with typed-array fills", () => { + const source = createSource(DataType.UINT64); + + source.applyLocalEdits( + new Map([["0,0,0", { indices: [], indexRanges: [1, 3], value: 99n }]]), + ); + + const chunk = source.chunks.get("0,0,0") as unknown as MockChunk; + expect(Array.from(chunk.data)).toEqual([0n, 99n, 99n, 99n, 0n, 0n, 0n, 0n]); + }); }); From 29e7a1221ccc6d5863d46ff2cd6f3b4794304ad2 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Fri, 29 May 2026 17:15:51 +0200 Subject: [PATCH 225/251] fix(voxel-annotation): restore shift+wheel brush size keybind The brush-size tool overrode rangeLayerControl's activateTool to add overlay redraw triggers but dropped the call to the original activateTool, losing the shift+wheel binding. Re-invoke it before layering on the redraw triggers. --- src/layer/voxel_annotation/controls.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/layer/voxel_annotation/controls.ts b/src/layer/voxel_annotation/controls.ts index a989a38833..c14422f0db 100644 --- a/src/layer/voxel_annotation/controls.ts +++ b/src/layer/voxel_annotation/controls.ts @@ -38,6 +38,7 @@ import { buttonLayerControl } from "#src/widget/layer_control_button.js"; import { checkboxLayerControl } from "#src/widget/layer_control_checkbox.js"; import { enumLayerControl } from "#src/widget/layer_control_enum.js"; import { rangeLayerControl } from "#src/widget/layer_control_range.js"; +import type { RangeWidget } from "#src/widget/range.js"; export function getEditingContext( layer: UserLayerWithVoxelEditing, @@ -183,9 +184,12 @@ const TOOL_SPECIFIC_CONTROLS: LayerControlDefinition[ options: { min: 1, max: 64, step: 1 }, }), ); + const originalActivateTool = control.activateTool; return { ...control, - activateTool: (activation, _controlContext) => { + activateTool: (activation, controlContext) => { + originalActivateTool(activation, controlContext as RangeWidget); + const layer = activation.tool.layer as UserLayerWithVoxelEditing; const trigger = () => { for (const panel of layer.manager.root.display.panels) { From c438791638c0f4af860cce747179c10337a64d2b Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 8 Jun 2026 14:20:09 +0200 Subject: [PATCH 226/251] fix(voxel-annotation): give overlay preview its own render scale histogram The voxel preview overlay shared the host layer's sliceViewRenderScaleHistogram, so its chunks were counted in the layer's visible-chunk statistics shown in the Rendering tab, giving a misleading count. Use a dedicated histogram instead. Addresses seankmartin review comment #8. --- src/layer/image/index.ts | 3 ++- src/layer/segmentation/index.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/layer/image/index.ts b/src/layer/image/index.ts index ed2a6bf7b3..b70ebdcf65 100644 --- a/src/layer/image/index.ts +++ b/src/layer/image/index.ts @@ -142,6 +142,7 @@ export class ImageUserLayer extends Base { shaderError = makeWatchableShaderError(); dataType = new WatchableValue(undefined); sliceViewRenderScaleHistogram = new RenderScaleHistogram(); + voxelOverlayRenderScaleHistogram = new RenderScaleHistogram(); sliceViewRenderScaleTarget = trackableRenderScaleTarget(1); volumeRenderingGain = trackableFiniteFloat(0); volumeRenderingChunkResolutionHistogram = new RenderScaleHistogram( @@ -236,7 +237,7 @@ void main() { shaderError: this.shaderError, transform: transform, renderScaleTarget: this.sliceViewRenderScaleTarget, - renderScaleHistogram: this.sliceViewRenderScaleHistogram, + renderScaleHistogram: this.voxelOverlayRenderScaleHistogram, localPosition: this.localPosition, channelCoordinateSpace: this.channelCoordinateSpace, }); diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 5f562fbf36..70aa9b755c 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -594,6 +594,7 @@ const Base = UserLayerWithVoxelEditingMixin( ); export class SegmentationUserLayer extends Base { sliceViewRenderScaleHistogram = new RenderScaleHistogram(); + voxelOverlayRenderScaleHistogram = new RenderScaleHistogram(); sliceViewRenderScaleTarget = trackableRenderScaleTarget(1); codeVisible = new TrackableBoolean(true); @@ -626,7 +627,7 @@ export class SegmentationUserLayer extends Base { ...this.displayState, transform: transform, renderScaleTarget: this.sliceViewRenderScaleTarget, - renderScaleHistogram: this.sliceViewRenderScaleHistogram, + renderScaleHistogram: this.voxelOverlayRenderScaleHistogram, localPosition: this.localPosition, }); } From e15641795d66c5429312f4bd48d5c4faba89daf9 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 6 Jul 2026 19:07:27 +0200 Subject: [PATCH 227/251] fix(voxel-annotation): compute stroke basis only after getPoint records the slice normal getBasis() reads the slice normal that getPoint() records. startDrawing snapshotted the stroke basis before calling getPoint, so the very first stroke of a session ran with an undefined basis. Same guard applied to performFloodFill. --- src/ui/voxel_annotations.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 897b49573a..4d6953c8bd 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -255,7 +255,9 @@ abstract class BaseVoxelTool extends LayerTool { return; } const seed = this.getPoint(this.mouseState); - const basis = this.getBasis(); + // getBasis is only meaningful after a successful getPoint (which records + // the slice normal it reads). + const basis = seed ? this.getBasis() : undefined; if (!seed || !basis) { StatusMessage.showTemporaryMessage( "Unable to retrieve mouse position. Please try again.", @@ -443,6 +445,14 @@ export class VoxelBrushTool extends BaseVoxelTool { private startDrawing(mouseState: MouseSelectionState) { if (this.isDrawing) return; + // getPoint must run before the stroke snapshot below: it also records the + // slice normal that getBasis() reads. + const start = this.getPoint(mouseState); + if (!start) { + throw new Error( + "startDrawing: could not compute a starting voxel position from mouse", + ); + } this.isDrawing = true; this.accumulatedCenters = []; this.activeStroke = { @@ -456,13 +466,6 @@ export class VoxelBrushTool extends BaseVoxelTool { : undefined, }; - const start = this.getPoint(mouseState); - if (!start) { - throw new Error( - "startDrawing: could not compute a starting voxel position from mouse", - ); - } - this.paintPoints([new Float32Array([start[0], start[1], start[2]])]); this.lastPoint = start; this.latestMouseState = mouseState; From 08e0c655edd3f545e8bba819efa4fe488b592462 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 6 Jul 2026 19:07:57 +0200 Subject: [PATCH 228/251] fix(voxel-annotation): apply edits on isolated chunks and invalidate backend cache after write The edit path (applyEdits, downsample reads) previously mutated shared chunk-cache entries: concurrent invalidation could dispose the chunk mid-download (chunk.source is null) and serialization to the frontend could detach its buffer mid-write, durably corrupting the stored object. - getIsolatedChunk(): chunk never registered in the shared cache, so the queue manager cannot dispose it while an edit is in flight. - applyEdits copies resident data synchronously (slice() throws loudly on a detached buffer) or downloads into the isolated chunk. - Deterministic decode failures ("Raw-format chunk is N bytes") are treated as corrupt stored objects and repaired on write; any other error fails the edit (fail-closed). - After a successful write, the shared cache entry is invalidated backend-side, covering chunks still DOWNLOADING that the frontend does not hold and that would otherwise later overwrite the edit. Unit tests migrate from getChunk stubs to serverStorage seeding (the edit path no longer consults getChunk) and cover corrupt-repair, transient-error propagation and post-write invalidation. --- src/sliceview/volume/backend.ts | 70 ++++++++++++++++- src/voxel_annotation/backend.spec.ts | 108 ++++++++++++++++++--------- src/voxel_annotation/backend.ts | 16 ++-- 3 files changed, 147 insertions(+), 47 deletions(-) diff --git a/src/sliceview/volume/backend.ts b/src/sliceview/volume/backend.ts index 812969a87d..debc08835d 100644 --- a/src/sliceview/volume/backend.ts +++ b/src/sliceview/volume/backend.ts @@ -148,6 +148,16 @@ export function computeChunkBounds( return chunkPosition; } +// Recognizes deterministic decode failures proving the stored bytes themselves +// are corrupt (e.g. the empty-body objects written by an earlier bug). Only +// these may be repaired by overwriting the stored chunk. Any UNRECOGNIZED +// error is treated as potentially transient (network, server, cancellation, +// codec OOM, wrapped kvstore errors) and must fail the edit instead: repairing +// on a false positive would durably overwrite valid data (fail-closed). +function isCorruptStoredChunkError(e: unknown): boolean { + return e instanceof Error && /Raw-format chunk is \d+ bytes/.test(e.message); +} + export class VolumeChunkSource extends SliceViewChunkSourceBackend implements VolumeChunkSourceInterface @@ -166,6 +176,23 @@ export class VolumeChunkSource return computeChunkBounds(this, chunk); } + /** + * Returns a chunk for `chunkGridPosition` that is NOT registered in the shared + * chunk cache. The queue manager never sees it, so concurrent invalidation or + * eviction cannot dispose it (`chunk.source = null`) while an edit-path + * download or write is in flight on it. Discard after use; it must never be + * added to `this.chunks`. + */ + getIsolatedChunk(chunkGridPosition: Float32Array): VolumeChunk { + const chunk = new (this.chunkConstructor as new () => VolumeChunk)(); + chunk.source = this; + chunk.initializeVolumeChunk( + chunkGridPosition.join(), + chunkGridPosition as vec3, + ); + return chunk; + } + // Override in data source backends to actually persist the chunk. // Default throws to ensure write capability is explicitly implemented. async writeChunk(_chunk: VolumeChunk): Promise { @@ -190,10 +217,36 @@ export class VolumeChunkSource throw new Error(`applyEdits: invalid chunk key ${chunkKey}`); } - const chunk = this.getChunk(chunkGridPosition) as VolumeChunk; - if (chunk.state > ChunkState.SYSTEM_MEMORY_WORKER || !chunk.data) { - const ac = new AbortController(); - await this.download(chunk, ac.signal); + // The whole read-modify-write runs on an isolated chunk the queue manager + // cannot see: mutating or encoding a shared cache entry races with + // concurrent invalidation (dispose nulls `chunk.source` mid-download) and + // with promotion/serialization (the buffer transfer to the frontend + // detaches it mid-write). If the shared entry has resident data, copy it + // synchronously (a detached source buffer makes `slice()` throw, failing + // the edit loudly instead of writing garbage); otherwise download. The + // shared entry itself is invalidated after the successful write below. + const resident = this.chunks.get(chunkKey) as VolumeChunk | undefined; + const chunk = this.getIsolatedChunk(chunkGridPosition); + if ( + resident !== undefined && + resident.state <= ChunkState.SYSTEM_MEMORY_WORKER && + resident.data + ) { + chunk.chunkDataSize = resident.chunkDataSize; + chunk.data = (resident.data as TypedArray).slice(); + } else { + try { + await this.download(chunk, new AbortController().signal); + } catch (e) { + if (!isCorruptStoredChunkError(e)) throw e; + // The stored object itself is corrupt: treat the chunk as absent so + // the edit proceeds on fill data and the write below repairs it. + console.warn( + `applyEdits: stored chunk ${chunkKey} is unreadable; ` + + `treating it as empty and repairing it on write.`, + e, + ); + } } if (!chunk.chunkDataSize) { @@ -319,6 +372,15 @@ export class VolumeChunkSource for (let i = 0; i < maxRetries; i++) { try { await this.writeChunk(chunk); + // The edit was written from an isolated chunk, so the shared cache + // entry (if any) still holds pre-edit data — or has a pre-edit + // download in flight that would otherwise later land as "fresh" and + // even be reused by the next edit, durably erasing this write. + // Invalidate it backend-side: routing through the frontend RPC would + // miss chunks the frontend does not hold (e.g. still DOWNLOADING). + const { queueManager } = this.chunkManager; + queueManager.invalidateCachedChunks(this, [chunkKey]); + queueManager.scheduleUpdate(); return { indices: indicesCopy, oldValues: oldValuesArray, diff --git a/src/voxel_annotation/backend.spec.ts b/src/voxel_annotation/backend.spec.ts index 57aad45064..dad42b5b68 100644 --- a/src/voxel_annotation/backend.spec.ts +++ b/src/voxel_annotation/backend.spec.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { ChunkState } from "#src/chunk_manager/base.js"; import type { VolumeChunk } from "#src/sliceview/volume/backend.js"; import { VolumeChunkSource } from "#src/sliceview/volume/backend.js"; import { DATA_TYPE_ARRAY_CONSTRUCTOR, DataType } from "#src/util/data_type.js"; import { mat4 } from "#src/util/geom.js"; +import { HttpError } from "#src/util/http_request.js"; import { VoxelEditController } from "#src/voxel_annotation/backend.js"; import { makeVoxChunkKey, @@ -22,6 +22,7 @@ const mockQueueManager = { scheduleUpdate: vi.fn(), moveChunkToFrontend: vi.fn(), markRecentlyUsed: vi.fn(), + invalidateCachedChunks: vi.fn(), gl: {}, }; @@ -560,35 +561,20 @@ describe("VoxelEditController: Downsampling Integration", () => { let grandParentSource: any; const setupIntegration = (numLevels: number = 2) => { + // Chunk data is injected through the mock server storage (the download + // path) rather than by stubbing `getChunk`: edit-path reads now download + // into isolated chunks and never consult `getChunk`. childSource = createMockSource(); - vi.spyOn(childSource, "getChunk").mockImplementation( - (pos: Float32Array) => ({ - data: new Uint32Array(8).fill(1), - chunkDataSize: MOCK_SPEC.chunkDataSize, - chunkGridPosition: pos, - state: ChunkState.SYSTEM_MEMORY, - }), + childSource.serverStorage.set( + "0,0,0", + new Uint8Array(8).fill(1).buffer, // dataType 0 = UINT8 ); parentSource = createMockSource(); - vi.spyOn(parentSource, "getChunk").mockImplementation( - (pos: Float32Array) => ({ - data: new Uint32Array(8).fill(0), - chunkDataSize: MOCK_SPEC.chunkDataSize, - chunkGridPosition: pos, - state: ChunkState.SYSTEM_MEMORY, - }), - ); + // Parent/grandparent are absent from server storage: applyEdits creates + // them as empty (zero-filled) chunks, matching the old zero-filled stubs. grandParentSource = createMockSource(); - vi.spyOn(grandParentSource, "getChunk").mockImplementation( - (pos: Float32Array) => ({ - data: new Uint32Array(8).fill(0), - chunkDataSize: MOCK_SPEC.chunkDataSize, - chunkGridPosition: pos, - state: ChunkState.SYSTEM_MEMORY, - }), - ); (mockRpc.get as any).mockImplementation((id: number) => { if (id === 0) return mockChunkManager; @@ -624,7 +610,7 @@ describe("VoxelEditController: Downsampling Integration", () => { await new Promise((resolve) => setTimeout(resolve, 0)); - expect(childSource.getChunk).toHaveBeenCalled(); + expect(childSource.download).toHaveBeenCalled(); expect(parentSource.applyEdits).toHaveBeenCalledWith( "0,0,0", @@ -645,11 +631,10 @@ describe("VoxelEditController: Downsampling Integration", () => { it("Recursive Propagation: L0 -> L1 -> L2", async () => { setupIntegration(3); + // After the L0->L1 write, the L1 chunk must read back as 1s so the L1->L2 + // step propagates. Simulate by publishing it to the mock server storage. parentSource.applyEdits.mockImplementation(async () => { - parentSource.getChunk.mockReturnValue({ - data: new Uint32Array(8).fill(1), - chunkDataSize: MOCK_SPEC.chunkDataSize, - }); + parentSource.serverStorage.set("0,0,0", new Uint8Array(8).fill(1).buffer); }); const key = makeVoxChunkKey("0,0,0", 0); @@ -684,9 +669,8 @@ describe("VoxelEditController: Downsampling Integration", () => { it("Lazy Loading: Downloads child chunk if missing", async () => { setupIntegration(2); - const emptyChunk = { data: null, chunkDataSize: MOCK_SPEC.chunkDataSize }; - childSource.getChunk.mockReturnValue(emptyChunk); - + // Chunk absent from server storage; the isolated-chunk download fills it. + childSource.serverStorage.delete("0,0,0"); childSource.download.mockImplementation(async (chunk: any) => { chunk.data = new Uint32Array(8).fill(1); }); @@ -704,10 +688,6 @@ describe("VoxelEditController: Downsampling Integration", () => { setupIntegration(2); const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - childSource.getChunk.mockReturnValue({ - data: null, - chunkDataSize: MOCK_SPEC.chunkDataSize, - }); childSource.download.mockRejectedValue(new Error("Network Error")); const key = makeVoxChunkKey("0,0,0", 0); @@ -1432,3 +1412,59 @@ describe("VoxelEditController: Tool Operations", () => { expect(indices.length).toBeGreaterThan(50); }); }); + +describe("VolumeChunkSource.applyEdits: unreadable stored chunk", () => { + it("treats a corrupt (undecodable) chunk as empty and repairs it on write", async () => { + const source = createMockSource(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + (source.download as any).mockRejectedValue( + new Error("Raw-format chunk is 0 bytes, but 8 * 1 = 8 bytes expected."), + ); + + const change = await source.applyEdits("0,0,0", [3], [7n]); + + expect(source.writeChunk).toHaveBeenCalled(); + const written = new Uint8Array(source.serverStorage.get("0,0,0")!); + expect(Array.from(written)).toEqual([0, 0, 0, 7, 0, 0, 0, 0]); + expect(Number((change.oldValues as any)[0])).toBe(0); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("propagates transient (network) failures without writing", async () => { + const source = createMockSource(); + (source.download as any).mockRejectedValue( + new HttpError("http://store/chunk", 503, "Service Unavailable"), + ); + + await expect(source.applyEdits("0,0,0", [3], [7n])).rejects.toThrow( + "HTTP error 503", + ); + expect(source.writeChunk).not.toHaveBeenCalled(); + }); + + it("fails closed on unrecognized read errors instead of repairing", async () => { + const source = createMockSource(); + (source.download as any).mockRejectedValue( + new Error("some wrapped kvstore failure"), + ); + + await expect(source.applyEdits("0,0,0", [3], [7n])).rejects.toThrow( + "some wrapped kvstore failure", + ); + expect(source.writeChunk).not.toHaveBeenCalled(); + }); + + it("invalidates the shared backend cache entry after a successful write", async () => { + const source = createMockSource(); + mockQueueManager.invalidateCachedChunks.mockClear(); + source.serverStorage.set("0,0,0", new Uint8Array(8).fill(2).buffer); + + await source.applyEdits("0,0,0", [3], [7n]); + + expect(mockQueueManager.invalidateCachedChunks).toHaveBeenCalledWith( + source, + ["0,0,0"], + ); + }); +}); diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 176e189181..cbfa0bdc8b 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -216,14 +216,16 @@ class BackendVoxelAccessor { cy: number, cz: number, ): Promise { + // Same pattern as `applyEdits`: only use the shared cache entry when its + // data is already resident (synchronous read, safe). Otherwise download + // into an isolated chunk the queue manager cannot dispose mid-download. let chunk = this.source.chunks.get(key) as VolumeChunk | undefined; - if (!chunk) { - chunk = this.source.getChunk( - new Float32Array([cx, cy, cz]), - ) as VolumeChunk; - } - - if (chunk.state > ChunkState.SYSTEM_MEMORY_WORKER || !chunk.data) { + if ( + chunk === undefined || + chunk.state > ChunkState.SYSTEM_MEMORY_WORKER || + !chunk.data + ) { + chunk = this.source.getIsolatedChunk(new Float32Array([cx, cy, cz])); try { await this.source.download(chunk, new AbortController().signal); } catch { From 081ff622f3a87de023dab735371e27e550686b16 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 6 Jul 2026 19:08:08 +0200 Subject: [PATCH 229/251] fix(zarr): refuse to write detached or size-mismatched chunk buffers A chunk whose ArrayBuffer was transferred to the frontend decodes/encodes as a zero-length view; writing it produced tiny corrupt stored objects (e.g. a 20-byte gzip of an empty stream) that silently erased data. writeChunk now validates the element count against chunkDataSize before encoding, and rejects a zero-length compressed-segmentation buffer for a non-empty chunk. --- src/datasource/zarr/backend.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/datasource/zarr/backend.ts b/src/datasource/zarr/backend.ts index 0ae5014f7c..2fad175a0f 100644 --- a/src/datasource/zarr/backend.ts +++ b/src/datasource/zarr/backend.ts @@ -134,6 +134,16 @@ export class ZarrVolumeChunkSource extends WithParameters( const numElements = chunkDataSize[0] * chunkDataSize[1] * chunkDataSize[2]; const { dataType } = this.spec; + // A zero-length compressed buffer for a non-empty chunk means the + // underlying ArrayBuffer was detached (transferred to the frontend). + // Proceeding would silently decode to a full-size zero-filled array and + // durably overwrite the stored chunk with zeros. + if (compressedData.length === 0 && numElements > 0) { + throw new Error( + "ZarrVolumeChunkSource.writeChunk: refusing to write chunk from a " + + "zero-length (detached?) compressed buffer.", + ); + } const baseOffset = compressedData.length > 0 ? compressedData[0] : 0; if (dataType === DataType.UINT32) { @@ -163,6 +173,25 @@ export class ZarrVolumeChunkSource extends WithParameters( } } + // Never write a body whose element count does not match the chunk. A + // zero-length view here means the underlying buffer was detached (its + // ArrayBuffer transferred to the frontend); writing it would durably + // corrupt the stored object (e.g. a 20-byte gzip of an empty stream). + const writeChunkDataSize = chunk.chunkDataSize; + if (!writeChunkDataSize) { + throw new Error("ZarrVolumeChunkSource.writeChunk: unknown chunk size"); + } + const expectedElements = writeChunkDataSize.reduce((a, b) => a * b, 1); + const actualElements = (dataToWrite as unknown as { length: number }) + .length; + if (expectedElements === 0 || actualElements !== expectedElements) { + throw new Error( + `ZarrVolumeChunkSource.writeChunk: refusing to write chunk with ` + + `${actualElements} elements (expected ${expectedElements}); ` + + `buffer detached or chunk size invalid.`, + ); + } + const encoded = await encodeArray( decodeCodecs, dataToWrite as ArrayBufferView, From 3d3f040b8153888805131c75db5cbd4cbc9c3e3c Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 6 Jul 2026 19:08:39 +0200 Subject: [PATCH 230/251] feat(voxel-annotation): clear optimistic overlay on fresh-chunk arrival instead of timers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the timer-based overlay clearing (setTimeout(100) in InMemoryVolumeChunkSource.invalidateChunks) with a swap-on-arrival mechanism, removing the flicker window between overlay removal and the refetched real chunk reaching the GPU: - ChunkSource gains one-shot freshChunkListeners keyed by chunk key, armed only when a real refetch (update.new) is observed and fired on that chunk's GPU arrival — an eviction/re-promotion of the stale chunk can never drop the overlay early. - invalidateChunks(keys, {lazy: true}) keeps the current GPU chunk on screen during the refetch; its texture is freed right before the swap. - The backend passes overlayKeysToClear ({realKey -> LOD-0 overlay key}) through the reload RPC, so when zoomed out the visible forced-LOD-0 overlay is cleared as soon as a covering downsampled parent arrives. - The eager preview-clear after downsampling is removed; each parent's real reload drops the originating overlay once rendered. --- src/chunk_manager/frontend.ts | 74 +++++++++++++++++++++++++++- src/sliceview/volume/frontend.ts | 32 ++++++------ src/voxel_annotation/backend.spec.ts | 12 ++--- src/voxel_annotation/backend.ts | 40 ++++++++++----- src/voxel_annotation/frontend.ts | 67 ++++++++++++++++++++++++- 5 files changed, 187 insertions(+), 38 deletions(-) diff --git a/src/chunk_manager/frontend.ts b/src/chunk_manager/frontend.ts index 890475efbc..e3f0f6cd38 100644 --- a/src/chunk_manager/frontend.ts +++ b/src/chunk_manager/frontend.ts @@ -258,8 +258,25 @@ export class ChunkQueueManager extends SharedObject { let chunk: Chunk; const key = update.id; if (update.new) { + // After a lazy invalidation the previous chunk is still present; + // free its GPU texture right before it is replaced so the swap + // happens in a single tick with no intermediate frame. Inert in + // every non-lazy flow, where the key is absent here. + const existing = source.chunks.get(key); + if ( + existing !== undefined && + existing.state === ChunkState.GPU_MEMORY + ) { + existing.freeGPUMemory(this.gl); + } chunk = source.getChunk(update); source.addChunk(key, chunk); + // A real refetch arrived for this key: arm its fresh-chunk listeners so + // they fire on this chunk's GPU arrival (below), not on some unrelated + // GPU transition of the stale chunk this one replaced. + if (source.freshChunkListeners?.has(key)) { + (source.pendingFreshChunkGpu ??= new Set()).add(key); + } } else { chunk = source.chunks.get(key)!; } @@ -293,6 +310,25 @@ export class ChunkQueueManager extends SharedObject { } } } + // Fire one-shot listeners only once the refetched chunk is actually on + // the GPU (displayed). Gating on `pendingFreshChunkGpu` — armed above when + // the `update.new` for this key arrived — rather than on GPU_MEMORY alone + // ensures we fire for the fresh chunk, not for an eviction/re-promotion of + // the stale chunk it replaced (which would drop the overlay before the + // real data is rendered, re-introducing the upload-latency flicker). + if ( + newState === ChunkState.GPU_MEMORY && + source.pendingFreshChunkGpu?.has(key) + ) { + source.pendingFreshChunkGpu.delete(key); + const freshListeners = source.freshChunkListeners?.get(key); + if (freshListeners !== undefined) { + source.freshChunkListeners!.delete(key); + for (const listener of freshListeners) { + listener(); + } + } + } } } return visibleChunksChanged; @@ -433,6 +469,31 @@ export class ChunkSource extends SharedObject { chunkRequesters: Map | undefined; + // One-shot listeners fired when fresh data (a `new` update) arrives for a key. + // Unlike `chunkRequesters`, these only fire on a real refetch, not on any + // `<= SYSTEM_MEMORY` transition (e.g. GPU eviction). + freshChunkListeners: Map void)[]> | undefined; + + // Keys for which a real refetch (`update.new`) has been observed and whose + // `freshChunkListeners` must fire on the *next* GPU_MEMORY arrival. Arming only + // after `update.new` is what makes the firing specific to the refetched chunk: + // an eviction/re-promotion of the stale, lazily kept chunk never sets this, so + // it cannot fire the swap before the fresh data is actually rendered. + pendingFreshChunkGpu: Set | undefined; + + onNextFreshChunk(key: string, listener: () => void): void { + let listeners = this.freshChunkListeners; + if (listeners === undefined) { + listeners = this.freshChunkListeners = new Map(); + } + const entry = listeners.get(key); + if (entry === undefined) { + listeners.set(key, [listener]); + } else { + entry.push(listener); + } + } + /** * If set to true, chunk updates will be applied to this source immediately, rather than queueing * them. Sources that dynamically update chunks and need to ensure a consistent order of @@ -462,15 +523,24 @@ export class ChunkSource extends SharedObject { chunk.freeGPUMemory(this.gl); } this.chunks.delete(key); + // The chunk is gone, so any fresh-chunk listener waiting on its GPU arrival + // will never fire — drop it (and its captured closure) instead of leaking. + this.freshChunkListeners?.delete(key); + this.pendingFreshChunkGpu?.delete(key); } - invalidateChunks(keys: string[]): void { + invalidateChunks(keys: string[], options?: { lazy?: boolean }): void { + // When `lazy` is set, the existing GPU chunk is kept on display; the + // refetched data swaps it in place via `applyChunkUpdate`, avoiding the + // lower-resolution fallback flicker. The backend cache is invalidated + // either way. + const lazy = options?.lazy ?? false; const validKeys: string[] = []; for (const key of keys) { const chunk = this.chunks.get(key); if (chunk) { validKeys.push(key); - this.deleteChunk(key); + if (!lazy) this.deleteChunk(key); } } diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index 57e0f463fb..d4e380e79b 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -305,23 +305,25 @@ export class InMemoryVolumeChunkSource extends VolumeChunkSource { this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); } - invalidateChunks(keys: string[]): void { - const update = () => { - const validKeys: string[] = []; - for (const key of keys) { - const chunk = this.chunks.get(key); - if (chunk) { - validKeys.push(key); - this.deleteChunk(key); - } + invalidateChunks(keys: string[], _options?: { lazy?: boolean }): void { + // Signature matches the base `ChunkSource.invalidateChunks`, but `lazy` does + // not apply here: an in-memory source has no backend refetch to swap in, so + // there is nothing to keep the stale chunk on screen for. Deletion is always + // immediate; the crossfade with the real data is timed by the caller (overlay + // dropped on real-chunk arrival via onNextFreshChunk, or as a rollback on + // write failure), not by a blind delay here. + const validKeys: string[] = []; + for (const key of keys) { + const chunk = this.chunks.get(key); + if (chunk) { + validKeys.push(key); + this.deleteChunk(key); } + } - if (validKeys.length > 0) { - this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); - } - }; - // adding a small delay to avoid flickering due to the base source taking some time to download the new data - setTimeout(update, 100); + if (validKeys.length > 0) { + this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); + } } applyLocalEdits(edits: Map): void { diff --git a/src/voxel_annotation/backend.spec.ts b/src/voxel_annotation/backend.spec.ts index dad42b5b68..2a139687fd 100644 --- a/src/voxel_annotation/backend.spec.ts +++ b/src/voxel_annotation/backend.spec.ts @@ -618,13 +618,13 @@ describe("VoxelEditController: Downsampling Integration", () => { expect.arrayContaining([1n]), ); - expect((controller as any).callChunkReload).toHaveBeenCalledWith([ - makeVoxChunkKey("0,0,0", 1), - ]); - + // The real parent is reloaded lazily; when it reaches the GPU it clears the + // originating LOD-0 overlay (swap-on-arrival), passed as a { real -> overlay } + // map. The old eager preview-clear call no longer exists. expect((controller as any).callChunkReload).toHaveBeenCalledWith( - [makeVoxChunkKey("0,0,0", 0), makeVoxChunkKey("0,0,0", 1)], - true, // isForPreviewChunks + [makeVoxChunkKey("0,0,0", 1)], + false, // isForPreviewChunks + { [makeVoxChunkKey("0,0,0", 1)]: makeVoxChunkKey("0,0,0", 0) }, ); }); diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index cbfa0bdc8b..3bfa837e98 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -580,9 +580,10 @@ export class VoxelEditController extends SharedObject { if (failedVoxChunkKeys.includes(voxKey)) continue; this.enqueueDownsample(voxKey); } - } else { - this.callChunkReload(editsByVoxKey.keys().toArray(), true); } + // The overlay is cleared by the swap-on-arrival registered in the real + // callChunkReload above (not here): on the no-downsampling path the max-res + // overlay is dropped when the refetched real chunk arrives. this.updatePendingCount(); } @@ -623,11 +624,22 @@ export class VoxelEditController extends SharedObject { }, this.commitDebounceDelayMs) as unknown as number; } - callChunkReload(voxChunkKeys: string[], isForPreviewChunks = false) { + // `overlayKeysToClear[realKey]` is the overlay vox key (LOD 0) to drop once the + // real chunk `realKey` reaches the GPU. A real key absent from the map defaults + // to itself on the frontend; downsampled parents map to the originating LOD-0 + // key so the visible (forced LOD-0) overlay is cleared as soon as any covering + // real LOD arrives. Using a keyed map (not parallel arrays) keeps real and + // overlay keys aligned regardless of ordering or partial population. + callChunkReload( + voxChunkKeys: string[], + isForPreviewChunks = false, + overlayKeysToClear?: Record, + ) { this.rpc?.invoke(VOX_RELOAD_CHUNKS_RPC_ID, { rpcId: this.rpcId, voxChunkKeys: voxChunkKeys, isForPreviewChunks, + overlayKeysToClear, }); } @@ -679,19 +691,16 @@ export class VoxelEditController extends SharedObject { } private async processDownsampleChain(key: string): Promise { - const allModifiedKeys = new Array(); let currentKey: string | null = key; while (currentKey !== null) { - allModifiedKeys.push(currentKey); - currentKey = await this.downsampleStep(currentKey); + currentKey = await this.downsampleStep(currentKey, key); } - const pendingKeys = new Set(this.pendingEdits.map((e) => e.key)); - const keysToReload = allModifiedKeys.filter( - (k) => !pendingKeys.has(k) && !this.downsampleChunkLocks.has(k), - ); - if (keysToReload.length > 0) this.callChunkReload(keysToReload, true); + // Note: the overlay is no longer cleared eagerly here. Each parent's real + // reload (above) drops the originating LOD-0 overlay once it reaches the + // GPU, so the visible overlay is never removed before its replacement is + // rendered — even when zoomed out. this.updatePendingCount(); } @@ -729,7 +738,10 @@ export class VoxelEditController extends SharedObject { * Performs a single downsampling step from a child chunk to its parent. * @returns The key of the parent chunk that was updated, or null if the cascade should stop. */ - private async downsampleStep(childKey: string): Promise { + private async downsampleStep( + childKey: string, + originKey: string, + ): Promise { const childInfo = parseVoxChunkKey(childKey); if (childInfo === null) { console.error(`[Downsample] Invalid child key format: ${childKey}`); @@ -780,7 +792,9 @@ export class VoxelEditController extends SharedObject { update.indices, update.values, ); - this.callChunkReload([parentKey]); + // Reload the real parent lazily; when it reaches the GPU, clear the + // originating LOD-0 overlay (the visible one when zoomed out). + this.callChunkReload([parentKey], false, { [parentKey]: originKey }); const parentAccessor = this.getAccessor(parentRes.lodIndex); parentAccessor.invalidate(parentInfo.chunkKey); } catch (e) { diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 8232793efa..af2c41bc55 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -527,7 +527,11 @@ export class VoxelEditController extends SharedObject { } } - callChunkReload(voxChunkKeys: string[], isForPreviewChunks: boolean) { + callChunkReload( + voxChunkKeys: string[], + isForPreviewChunks: boolean, + overlayKeysToClear?: Record, + ) { if (!Array.isArray(voxChunkKeys) || voxChunkKeys.length === 0) return; const multiscaleSource = isForPreviewChunks ? this.host.previewSource @@ -548,6 +552,61 @@ export class VoxelEditController extends SharedObject { const chunksToInvalidateBySource = new Map(); + if (!isForPreviewChunks) { + // Real chunks: invalidate lazily so the current GPU chunk stays on screen, + // and clear the matching overlay chunk only once the refetched real data + // has actually arrived on the GPU (swap-on-arrival), never on a timer. + // + // The overlay to clear defaults to the same key/LOD (max-res edits). For + // downsampled parents the backend passes the originating LOD-0 key, so the + // visible (forced LOD-0) overlay is cleared as soon as the real chunk of + // whatever LOD is on screen arrives. A never-swapped overlay simply stays + // displayed (correct) at worst leaking a little memory — far better than a + // timer that would clear it with nothing to show. + const previewSources = this.host.previewSource?.getSources( + this.getIdentitySliceViewSourceOptions(), + )[0]; + for (const voxKey of voxChunkKeys) { + const parsed = parseVoxChunkKey(voxKey); + if (!parsed) continue; + const source = sources[parsed.lodIndex]?.chunkSource as + | VolumeChunkSource + | undefined; + if (!source) continue; + const { chunkKey } = parsed; + + const overlayParsed = parseVoxChunkKey( + overlayKeysToClear?.[voxKey] ?? voxKey, + ); + const overlaySource = overlayParsed + ? (previewSources?.[overlayParsed.lodIndex]?.chunkSource as + | InMemoryVolumeChunkSource + | undefined) + : undefined; + if (overlaySource) { + const overlayChunkKey = overlayParsed!.chunkKey; + source.onNextFreshChunk(chunkKey, () => + overlaySource.invalidateChunks([overlayChunkKey]), + ); + } + let arr = chunksToInvalidateBySource.get(source); + if (!arr) { + arr = []; + chunksToInvalidateBySource.set(source, arr); + } + arr.push(chunkKey); + } + + for (const [source, keys] of chunksToInvalidateBySource.entries()) { + if (keys.length > 0) { + source.invalidateChunks(keys, { lazy: true }); + } + } + return; + } + + // Preview chunks: clear the optimistic overlay immediately (write-failure + // rollback, and downsampled-overlay cleanup). for (const voxKey of voxChunkKeys) { const parsed = parseVoxChunkKey(voxKey); if (!parsed) continue; @@ -605,7 +664,11 @@ export class VoxelEditController extends SharedObject { registerRPC(VOX_RELOAD_CHUNKS_RPC_ID, function (x: any) { const obj = this.get(x.rpcId) as VoxelEditController; const keys: string[] = Array.isArray(x.voxChunkKeys) ? x.voxChunkKeys : []; - obj.callChunkReload(keys, x.isForPreviewChunks); + const overlayKeys: Record | undefined = + x.overlayKeysToClear !== null && typeof x.overlayKeysToClear === "object" + ? x.overlayKeysToClear + : undefined; + obj.callChunkReload(keys, x.isForPreviewChunks, overlayKeys); }); registerRPC(VOX_EDIT_FAILURE_RPC_ID, function (x: any) { From ee30e9137f88204feb6a9d90e494526c62d53283 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 6 Jul 2026 19:08:55 +0200 Subject: [PATCH 231/251] fix(voxel-annotation): revalidate HTTP reads on sources this session writes to MinIO (and other stores) may serve chunk objects without Cache-Control; the browser's heuristic freshness then caches them for ~10% of their age, so a freshly written chunk kept being read back stale for hours: strokes visually vanished right after painting even though the store was correctly written (empty chunks were unaffected because 404s are not cached, hence "writes only work on empty chunks"). DriverReadOptions gains cacheMode, plumbed through the HTTP kvstore to fetch's RequestInit.cache. The voxel-edit controller marks every LOD source it writes to with requireRevalidatedReads, making their zarr downloads use "no-cache" (conditional revalidation, 304 when unchanged); all other sources keep normal caching. --- src/datasource/zarr/backend.ts | 5 ++++- src/kvstore/http/read.ts | 3 +++ src/kvstore/index.ts | 5 +++++ src/sliceview/volume/backend.ts | 6 ++++++ src/voxel_annotation/backend.ts | 5 +++++ 5 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/datasource/zarr/backend.ts b/src/datasource/zarr/backend.ts index 2fad175a0f..558c29e6ae 100644 --- a/src/datasource/zarr/backend.ts +++ b/src/datasource/zarr/backend.ts @@ -94,7 +94,10 @@ export class ZarrVolumeChunkSource extends WithParameters( const { chunkKvStore } = this; const response = await chunkKvStore.kvStore.read( chunkKvStore.getChunkKey(chunkGridPosition, baseKey), - { signal }, + { + signal, + cacheMode: this.requireRevalidatedReads ? "no-cache" : undefined, + }, ); if (response !== undefined) { const decoded = await decodeArray( diff --git a/src/kvstore/http/read.ts b/src/kvstore/http/read.ts index 2690c5b884..3c60d7feb0 100644 --- a/src/kvstore/http/read.ts +++ b/src/kvstore/http/read.ts @@ -131,6 +131,9 @@ export async function read( signal: options.signal, progressListener: options.progressListener, }; + if (options.cacheMode !== undefined) { + requestInit.cache = options.cacheMode; + } if (rangeHeader !== undefined) { requestInit.headers = { range: rangeHeader }; requestInit.cache = byteRangeCacheMode; diff --git a/src/kvstore/index.ts b/src/kvstore/index.ts index 159e6caa7c..5d654db8dd 100644 --- a/src/kvstore/index.ts +++ b/src/kvstore/index.ts @@ -38,6 +38,11 @@ export interface ReadResponse { export interface DriverReadOptions extends Partial { byteRange?: ByteRangeRequest; throwIfMissing?: boolean; + // Fetch cache mode for HTTP-backed stores. Use "no-cache" for data that may + // be mutated by this or another session: the browser's heuristic freshness + // (no Cache-Control header) can otherwise serve stale content for hours + // without revalidating. + cacheMode?: RequestCache; } export class NotFoundError extends Error { diff --git a/src/sliceview/volume/backend.ts b/src/sliceview/volume/backend.ts index debc08835d..2a9c93b7ce 100644 --- a/src/sliceview/volume/backend.ts +++ b/src/sliceview/volume/backend.ts @@ -165,6 +165,12 @@ export class VolumeChunkSource declare spec: VolumeChunkSpecification; tempChunkDataSize: Uint32Array; tempChunkPosition: Float32Array; + + // Set by the voxel-edit controller on sources it writes to. Chunk downloads + // must then revalidate with the server (`cache: "no-cache"`): the browser's + // heuristic freshness for responses without Cache-Control can otherwise + // serve stale cached chunks — hiding freshly written edits — for hours. + requireRevalidatedReads = false; constructor(rpc: RPC, options: any) { super(rpc, options); const rank = this.spec.rank; diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 3bfa837e98..60d828fcdf 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -459,6 +459,11 @@ export class VoxelEditController extends SharedObject { `VoxelEditBackend: failed to resolve VolumeChunkSource for LOD ${res.lodIndex}`, ); } + // This session writes to these sources: their reads must revalidate with + // the server instead of trusting the browser's heuristic HTTP cache, + // which would otherwise serve pre-edit chunks (strokes visually vanish + // even though the store was written). + resolved.requireRevalidatedReads = true; this.sources.set(res.lodIndex, resolved); } From 50aa60db0636b8cd55c75a741761c54a63a10a22 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 6 Jul 2026 19:08:55 +0200 Subject: [PATCH 232/251] test(voxel-annotation): browser tests for repaint, gzip, dense labels and multiscale parents Adds four end-to-end tests against the S3 (MinIO) zarr pipeline: repainting over already-written chunks, repaint with a gzip codec, dense high-bit uint64 labels, and a multiscale OME dataset verifying a non-empty downsampled parent after the cascade. --- .../pipeline_zarr_s3.browser_test.ts | 370 ++++++++++++++++++ 1 file changed, 370 insertions(+) diff --git a/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts b/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts index 261bdd16d6..7e8bfa3c06 100644 --- a/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts +++ b/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts @@ -243,6 +243,376 @@ test("Pipeline: Zarr V3 (UINT64) Brush", async () => { }, "Verify painted chunk (UINT64)"); }); +test("Pipeline: Repaint over existing chunk data (Zarr V3 UINT64)", async () => { + const BUCKET = "test-v3-repaint"; + const zarrJson = JSON.stringify({ + zarr_format: 3, + node_type: "array", + shape: [64, 64, 64], + data_type: "uint64", + chunk_grid: { + name: "regular", + configuration: { chunk_shape: [32, 32, 32] }, + }, + chunk_key_encoding: { + name: "default", + configuration: { separator: "/" }, + }, + codecs: [{ name: "bytes", configuration: { endian: "little" } }], + fill_value: 0, + attributes: {}, + }); + + storage.set( + `${BUCKET}/data.zarr/zarr.json`, + new TextEncoder().encode(zarrJson).buffer, + ); + + // Pre-seed the chunk with existing data: the brush must merge into it, not + // replace it (regression test for edits only working on empty chunks). + const existingVal = 7n; + const existing = new BigUint64Array(32 * 32 * 32).fill(existingVal); + const chunkKey = `${BUCKET}/data.zarr/c/0/0/0`; + storage.set(chunkKey, existing.buffer.slice(0) as ArrayBuffer); + + const layer = makeLayer(viewer!.layerSpecification, "volume", { + type: "segmentation", + source: { + url: `s3+http://localhost:9000/${BUCKET}/data.zarr|zarr3:`, + subsources: { default: { enabled: true, writingEnabled: true } }, + enableDefaultSubsources: false, + }, + }); + viewer!.layerSpecification.add(layer); + + const { context } = await waitForEditingContext(); + + const paintVal = 123n; + await context.dispatchBrushStroke( + [new Float32Array([16, 16, 16])], + 2, + (_) => paintVal, + 0 /* DISK */, + { + u: new Float32Array([1, 0, 0]), + v: new Float32Array([0, 1, 0]), + }, + ); + + await poll(() => { + const data = storage.get(chunkKey); + if (!data) return false; + const arr = new BigUint64Array(data); + return ( + arr.some((v) => v === paintVal) && arr.some((v) => v === existingVal) + ); + }, "Verify stroke applied AND pre-existing data preserved"); +}); + +test("Pipeline: Repaint over existing gzip-compressed chunk (Zarr V3 UINT64)", async () => { + const BUCKET = "test-v3-repaint-gzip"; + const zarrJson = JSON.stringify({ + zarr_format: 3, + node_type: "array", + shape: [64, 64, 64], + data_type: "uint64", + chunk_grid: { + name: "regular", + configuration: { chunk_shape: [32, 32, 32] }, + }, + chunk_key_encoding: { + name: "default", + configuration: { separator: "/" }, + }, + codecs: [ + { name: "bytes", configuration: { endian: "little" } }, + { name: "gzip", configuration: { level: 1 } }, + ], + fill_value: 0, + attributes: {}, + }); + + storage.set( + `${BUCKET}/data.zarr/zarr.json`, + new TextEncoder().encode(zarrJson).buffer, + ); + + const gzip = async (data: ArrayBuffer): Promise => { + const cs = new CompressionStream("gzip"); + const writer = cs.writable.getWriter(); + void writer.write(new Uint8Array(data)); + void writer.close(); + return await new Response(cs.readable).arrayBuffer(); + }; + const gunzip = async (data: ArrayBuffer): Promise => { + const ds = new DecompressionStream("gzip"); + const writer = ds.writable.getWriter(); + void writer.write(new Uint8Array(data)); + void writer.close(); + return await new Response(ds.readable).arrayBuffer(); + }; + + const existingVal = 7n; + const existing = new BigUint64Array(32 * 32 * 32).fill(existingVal); + const chunkKey = `${BUCKET}/data.zarr/c/0/0/0`; + storage.set(chunkKey, await gzip(existing.buffer as ArrayBuffer)); + + const layer = makeLayer(viewer!.layerSpecification, "volume", { + type: "segmentation", + source: { + url: `s3+http://localhost:9000/${BUCKET}/data.zarr|zarr3:`, + subsources: { default: { enabled: true, writingEnabled: true } }, + enableDefaultSubsources: false, + }, + }); + viewer!.layerSpecification.add(layer); + + const { context } = await waitForEditingContext(); + + const paintVal = 123n; + await context.dispatchBrushStroke( + [new Float32Array([16, 16, 16])], + 2, + (_) => paintVal, + 0 /* DISK */, + { + u: new Float32Array([1, 0, 0]), + v: new Float32Array([0, 1, 0]), + }, + ); + + await poll(async () => { + const data = storage.get(chunkKey); + if (!data) return false; + const arr = new BigUint64Array(await gunzip(data)); + return ( + arr.some((v) => v === paintVal) && arr.some((v) => v === existingVal) + ); + }, "Verify gzip repaint applied AND pre-existing data preserved"); +}); + +test("Pipeline: Repaint over dense large-label segmentation data (Zarr V3 UINT64)", async () => { + const BUCKET = "test-v3-repaint-dense"; + const zarrJson = JSON.stringify({ + zarr_format: 3, + node_type: "array", + shape: [64, 64, 64], + data_type: "uint64", + chunk_grid: { + name: "regular", + configuration: { chunk_shape: [32, 32, 32] }, + }, + chunk_key_encoding: { + name: "default", + configuration: { separator: "/" }, + }, + codecs: [ + { name: "bytes", configuration: { endian: "little" } }, + { name: "gzip", configuration: { level: 1 } }, + ], + fill_value: 0, + attributes: {}, + }); + + storage.set( + `${BUCKET}/data.zarr/zarr.json`, + new TextEncoder().encode(zarrJson).buffer, + ); + + const gzip = async (data: ArrayBuffer): Promise => { + const cs = new CompressionStream("gzip"); + const writer = cs.writable.getWriter(); + void writer.write(new Uint8Array(data)); + void writer.close(); + return await new Response(cs.readable).arrayBuffer(); + }; + const gunzip = async (data: ArrayBuffer): Promise => { + const ds = new DecompressionStream("gzip"); + const writer = ds.writable.getWriter(); + void writer.write(new Uint8Array(data)); + void writer.close(); + return await new Response(ds.readable).arrayBuffer(); + }; + + // Worst case for the compressed-segmentation in-memory transcode: every + // voxel a distinct large (>2^63) label, as produced by real segmentation + // pipelines — unlike the small uniform values of the other tests. + const existing = new BigUint64Array(32 * 32 * 32); + for (let i = 0; i < existing.length; ++i) { + existing[i] = 0x8000000000000000n + BigInt(i); + } + const sentinel = existing[0]; + const chunkKey = `${BUCKET}/data.zarr/c/0/0/0`; + storage.set(chunkKey, await gzip(existing.buffer as ArrayBuffer)); + + const layer = makeLayer(viewer!.layerSpecification, "volume", { + type: "segmentation", + source: { + url: `s3+http://localhost:9000/${BUCKET}/data.zarr|zarr3:`, + subsources: { default: { enabled: true, writingEnabled: true } }, + enableDefaultSubsources: false, + }, + }); + viewer!.layerSpecification.add(layer); + + const { context } = await waitForEditingContext(); + + const paintVal = 0x9999999999999999n; + await context.dispatchBrushStroke( + [new Float32Array([16, 16, 16])], + 2, + (_) => paintVal, + 0 /* DISK */, + { + u: new Float32Array([1, 0, 0]), + v: new Float32Array([0, 1, 0]), + }, + ); + + await poll(async () => { + const data = storage.get(chunkKey); + if (!data) return false; + const arr = new BigUint64Array(await gunzip(data)); + return arr.some((v) => v === paintVal) && arr[0] === sentinel; + }, "Verify dense-label repaint applied AND existing labels preserved"); +}); + +test("Pipeline: Multiscale repaint with NON-EMPTY downsample parent (OME zarr3)", async () => { + const BUCKET = "test-v3-multiscale"; + const groupJson = JSON.stringify({ + zarr_format: 3, + node_type: "group", + attributes: { + multiscales: [ + { + version: "0.5", + axes: [ + { name: "x", type: "space", unit: "nanometer" }, + { name: "y", type: "space", unit: "nanometer" }, + { name: "z", type: "space", unit: "nanometer" }, + ], + datasets: [ + { + path: "s0", + coordinateTransformations: [ + { type: "scale", scale: [32, 32, 32] }, + ], + }, + { + path: "s1", + coordinateTransformations: [ + { type: "scale", scale: [64, 64, 64] }, + ], + }, + ], + name: "test-multiscale", + }, + ], + }, + }); + const arrayJson = (shape: number) => + JSON.stringify({ + zarr_format: 3, + node_type: "array", + shape: [shape, shape, shape], + data_type: "uint64", + chunk_grid: { + name: "regular", + configuration: { chunk_shape: [32, 32, 32] }, + }, + chunk_key_encoding: { + name: "default", + configuration: { separator: "/" }, + }, + codecs: [ + { name: "bytes", configuration: { endian: "little" } }, + { name: "gzip", configuration: { level: 1 } }, + ], + fill_value: 0, + attributes: {}, + }); + + storage.set( + `${BUCKET}/data.zarr/zarr.json`, + new TextEncoder().encode(groupJson).buffer, + ); + storage.set( + `${BUCKET}/data.zarr/s0/zarr.json`, + new TextEncoder().encode(arrayJson(64)).buffer, + ); + storage.set( + `${BUCKET}/data.zarr/s1/zarr.json`, + new TextEncoder().encode(arrayJson(32)).buffer, + ); + + const gzip = async (data: ArrayBuffer): Promise => { + const cs = new CompressionStream("gzip"); + const writer = cs.writable.getWriter(); + void writer.write(new Uint8Array(data)); + void writer.close(); + return await new Response(cs.readable).arrayBuffer(); + }; + const gunzip = async (data: ArrayBuffer): Promise => { + const ds = new DecompressionStream("gzip"); + const writer = ds.writable.getWriter(); + void writer.write(new Uint8Array(data)); + void writer.close(); + return await new Response(ds.readable).arrayBuffer(); + }; + + // Both the edited chunk AND its downsample parent pre-exist with data — + // replicating painting over pipeline-produced regions (the untested case; + // blank regions have empty parents). + const existingVal = 7n; + const parentVal = 9n; + const child = new BigUint64Array(32 * 32 * 32).fill(existingVal); + const parent = new BigUint64Array(32 * 32 * 32).fill(parentVal); + const childKey = `${BUCKET}/data.zarr/s0/c/0/0/0`; + const parentKey = `${BUCKET}/data.zarr/s1/c/0/0/0`; + storage.set(childKey, await gzip(child.buffer as ArrayBuffer)); + storage.set(parentKey, await gzip(parent.buffer as ArrayBuffer)); + + const layer = makeLayer(viewer!.layerSpecification, "volume", { + type: "segmentation", + source: { + url: `s3+http://localhost:9000/${BUCKET}/data.zarr|zarr3:`, + subsources: { default: { enabled: true, writingEnabled: true } }, + enableDefaultSubsources: false, + }, + }); + viewer!.layerSpecification.add(layer); + + const { context } = await waitForEditingContext(); + + const paintVal = 123n; + await context.dispatchBrushStroke( + [new Float32Array([16, 16, 16])], + 4, + (_) => paintVal, + 1 /* SPHERE */, + { + u: new Float32Array([1, 0, 0]), + v: new Float32Array([0, 1, 0]), + }, + ); + + await poll(async () => { + const data = storage.get(childKey); + if (!data) return false; + const arr = new BigUint64Array(await gunzip(data)); + return ( + arr.some((v) => v === paintVal) && arr.some((v) => v === existingVal) + ); + }, "Verify s0 stroke applied AND existing s0 data preserved"); + + await poll(async () => { + const data = storage.get(parentKey); + if (!data) return false; + const arr = new BigUint64Array(await gunzip(data)); + return arr.some((v) => v === paintVal) && arr.some((v) => v === parentVal); + }, "Verify s1 downsample applied AND existing s1 data preserved"); +}); + test("Pipeline: Zarr V2 (UINT32) with Slash Separator", async () => { const BUCKET = "test-v2-sep"; const zarray = JSON.stringify({ From 5adcd05fdbab7672a331b7321f2712628de16ecf Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 15 Jul 2026 15:31:06 +0200 Subject: [PATCH 233/251] fix(voxel-annotation): clear overlay only when the written data covers all dispatched strokes The swap-on-arrival overlay clear fired as soon as a refetched real chunk reached the GPU, regardless of whether the write behind that refetch included every stroke the overlay represents. Painting a second stroke across chunks of a still-flushing first stroke made the second stroke vanish until its own flush landed (~1s), because the first stroke's reload cleared the shared overlay chunk. Every dispatched operation (brush, flood fill) now carries a monotonic frontend seq, threaded through pendingEdits. On flush, the backend records per chunk the max seq durably written (lastFlushedSeq) and echoes it in the existing reload message (coveredSeqs). The frontend tracks, per overlay chunk, the seq of the last dispatch whose preview touched it (lastDispatchedSeq) plus the keys of the in-progress stroke (undispatchedPreviewKeys), and clears an overlay chunk only when the arriving data covers its last dispatched stroke and no stroke is being painted on it. A skipped clear is always re-armed by the covering write's own reload. Reload RPCs also drain the frontend's pending chunk-update queue before arming listeners, so a stale refetch already queued cannot trigger a freshly armed clear. --- src/voxel_annotation/backend.ts | 54 ++++++- src/voxel_annotation/base.ts | 5 + src/voxel_annotation/frontend.spec.ts | 214 ++++++++++++++++++++++++++ src/voxel_annotation/frontend.ts | 66 +++++++- 4 files changed, 330 insertions(+), 9 deletions(-) create mode 100644 src/voxel_annotation/frontend.spec.ts diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 60d828fcdf..5b285d8b32 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -381,7 +381,14 @@ export class VoxelEditController extends SharedObject { value?: bigint; values?: ArrayLike; size?: number[]; + seq?: number; }[] = []; + + // Per LOD-0 vox key: the highest frontend dispatch seq whose edits have been + // durably written to that chunk. Echoed in reload messages (including + // downsample cascade reloads, keyed by origin) so the frontend can tell + // whether the refetched data covers everything its overlay represents. + private lastFlushedSeq = new Map(); public pendingOpCount: SharedWatchableValue; private updatePendingCount() { @@ -482,6 +489,7 @@ export class VoxelEditController extends SharedObject { } const editsByVoxKey = new Map>(); + const maxSeqByVoxKey = new Map(); for (const edit of edits) { let chunkMap = editsByVoxKey.get(edit.key); @@ -489,6 +497,12 @@ export class VoxelEditController extends SharedObject { chunkMap = new Map(); editsByVoxKey.set(edit.key, chunkMap); } + if (edit.seq !== undefined) { + maxSeqByVoxKey.set( + edit.key, + Math.max(maxSeqByVoxKey.get(edit.key) ?? 0, edit.seq), + ); + } const inds = edit.indices as ArrayLike; if (edit.values) { @@ -549,6 +563,14 @@ export class VoxelEditController extends SharedObject { const accessor = this.getAccessor(parsedKey.lodIndex); accessor.invalidate(parsedKey.chunkKey); + const flushedSeq = maxSeqByVoxKey.get(voxKey); + if (flushedSeq !== undefined) { + this.lastFlushedSeq.set( + voxKey, + Math.max(this.lastFlushedSeq.get(voxKey) ?? 0, flushedSeq), + ); + } + newAction.changes.set(voxKey, change); } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -558,7 +580,12 @@ export class VoxelEditController extends SharedObject { } } - this.callChunkReload(editsByVoxKey.keys().toArray()); + const flushedKeys = editsByVoxKey.keys().toArray(); + const coveredSeqs: Record = {}; + for (const voxKey of flushedKeys) { + coveredSeqs[voxKey] = this.lastFlushedSeq.get(voxKey) ?? 0; + } + this.callChunkReload(flushedKeys, false, undefined, coveredSeqs); if (newAction.changes.size > 0) { this.undoStack.push(newAction); @@ -611,6 +638,7 @@ export class VoxelEditController extends SharedObject { value?: bigint; values?: ArrayLike; size?: number[]; + seq?: number; }[], ) { for (const e of edits) { @@ -635,16 +663,23 @@ export class VoxelEditController extends SharedObject { // key so the visible (forced LOD-0) overlay is cleared as soon as any covering // real LOD arrives. Using a keyed map (not parallel arrays) keeps real and // overlay keys aligned regardless of ordering or partial population. + // `coveredSeqs[realKey]` is the highest frontend dispatch seq whose edits + // are guaranteed present in the stored data this reload refetches (for + // downsampled parents: the origin chunk's flushed seq at the time the child + // was read). The frontend clears the matching overlay only if this covers + // the last dispatched stroke that touched it. callChunkReload( voxChunkKeys: string[], isForPreviewChunks = false, overlayKeysToClear?: Record, + coveredSeqs?: Record, ) { this.rpc?.invoke(VOX_RELOAD_CHUNKS_RPC_ID, { rpcId: this.rpcId, voxChunkKeys: voxChunkKeys, isForPreviewChunks, overlayKeysToClear, + coveredSeqs, }); } @@ -1180,7 +1215,7 @@ export class VoxelEditController extends SharedObject { } private async performBrush(op: BrushOperation): Promise { - const { centers, radius, value, shape, basis, filterValue } = op; + const { centers, radius, value, shape, basis, filterValue, seq } = op; const voxelSize = 1; // Hardcoded LOD 0 const sourceIndex = 0; const accessor = this.getAccessor(sourceIndex); @@ -1284,15 +1319,22 @@ export class VoxelEditController extends SharedObject { result.count, value, sourceIndex, + seq, ); } else { - this.processBackendEdits(voxelBuffer, voxelCount, value, sourceIndex); + this.processBackendEdits( + voxelBuffer, + voxelCount, + value, + sourceIndex, + seq, + ); } } } private async performFloodFill(op: FloodFillOperation): Promise { - const { seed, value: fillValue, maxVoxels, basis, filterValue } = op; + const { seed, value: fillValue, maxVoxels, basis, filterValue, seq } = op; const sourceIndex = 0; const accessor = this.getAccessor(sourceIndex); @@ -1450,6 +1492,7 @@ export class VoxelEditController extends SharedObject { result.count, fillValue, sourceIndex, + seq, ); } @@ -1537,6 +1580,7 @@ export class VoxelEditController extends SharedObject { voxelCount: number, value: bigint, lodIndex: number, + seq?: number, ) { const source = this.sources.get(lodIndex); if (!source) return; @@ -1590,7 +1634,7 @@ export class VoxelEditController extends SharedObject { const backendEdits = []; for (const [voxKey, indices] of indicesByVoxKey.entries()) { - backendEdits.push({ key: voxKey, indices, value }); + backendEdits.push({ key: voxKey, indices, value, seq }); } this.commitVoxels(backendEdits); } diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index c27d31745d..f8873bd1f2 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -39,6 +39,11 @@ export enum VoxelOperationType { export interface VoxelOperationBase { type: VoxelOperationType; + // Frontend dispatch sequence number. Echoed back per written chunk (as the + // max seq the write covers) in reload messages, so the frontend clears an + // overlay chunk only once the stored data covers every dispatched stroke + // that touched it. + seq?: number; } export interface BrushOperation extends VoxelOperationBase { diff --git a/src/voxel_annotation/frontend.spec.ts b/src/voxel_annotation/frontend.spec.ts new file mode 100644 index 0000000000..2712e7b94f --- /dev/null +++ b/src/voxel_annotation/frontend.spec.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { RPC } from "#src/worker_rpc.js"; +import { makeVoxChunkKey } from "#src/voxel_annotation/base.js"; +import { VoxelEditController } from "#src/voxel_annotation/frontend.js"; + +const mockRpc = { + get: vi.fn(), + invoke: vi.fn(), + newId: () => 0, + register: vi.fn(), + set: vi.fn(), + delete: vi.fn(), +} as unknown as RPC; + +const mockChunkQueueManager = { + flushPendingChunkUpdates: vi.fn(), +}; + +// Real chunk source mock: captures the one-shot fresh-chunk listeners so tests +// can simulate the refetched chunk reaching the GPU at a chosen moment. +function createRealSourceMock() { + const freshChunkListeners = new Map void)[]>(); + return { + rpcId: 1, + spec: { chunkDataSize: new Uint32Array([2, 2, 2]) }, + chunkManager: { chunkQueueManager: mockChunkQueueManager }, + invalidateChunks: vi.fn(), + onNextFreshChunk: vi.fn((key: string, listener: () => void) => { + const entry = freshChunkListeners.get(key); + if (entry === undefined) freshChunkListeners.set(key, [listener]); + else entry.push(listener); + }), + // Simulates the fresh chunk arriving on the GPU: fires and drops the + // armed listeners, like ChunkQueueManager.applyChunkUpdate does. + fireFreshChunk(key: string) { + const listeners = freshChunkListeners.get(key) ?? []; + freshChunkListeners.delete(key); + for (const listener of listeners) listener(); + }, + }; +} + +function createOverlaySourceMock() { + return { + invalidateChunks: vi.fn(), + }; +} + +describe("VoxelEditController.callChunkReload: overlay clear coverage guard", () => { + let realSources: ReturnType[]; + let overlaySources: ReturnType[]; + let controller: VoxelEditController; + + beforeEach(() => { + vi.clearAllMocks(); + realSources = [createRealSourceMock(), createRealSourceMock()]; + overlaySources = [createOverlaySourceMock(), createOverlaySourceMock()]; + const makeMultiscale = (sources: unknown[]) => ({ + rank: 3, + getSources: () => [ + sources.map((chunkSource) => ({ + chunkSource, + chunkToMultiscaleTransform: Float32Array.of( + ...[1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + ), + })), + ], + }); + const host = { + rpc: mockRpc, + primarySource: makeMultiscale(realSources), + previewSource: makeMultiscale(overlaySources), + }; + controller = new VoxelEditController(host as any); + }); + + it("clears the overlay when the write covers the last dispatched stroke", () => { + // Stroke previewed on "0,0,0" then dispatched as seq 1. + controller.notePreviewTouched(["0,0,0"]); + expect(controller.takeDispatchSeq()).toBe(1); + + // Backend flushes seq 1 and reloads with coveredSeqs = 1. + const voxKey = makeVoxChunkKey("0,0,0", 0); + controller.callChunkReload([voxKey], false, undefined, { [voxKey]: 1 }); + + expect(realSources[0].invalidateChunks).toHaveBeenCalledWith(["0,0,0"], { + lazy: true, + }); + expect(overlaySources[0].invalidateChunks).not.toHaveBeenCalled(); + + realSources[0].fireFreshChunk("0,0,0"); + expect(overlaySources[0].invalidateChunks).toHaveBeenCalledWith(["0,0,0"]); + }); + + it("skips the clear while a stroke is in progress on the chunk", () => { + controller.notePreviewTouched(["0,0,0"]); + controller.takeDispatchSeq(); // seq 1 dispatched + + const voxKey = makeVoxChunkKey("0,0,0", 0); + controller.callChunkReload([voxKey], false, undefined, { [voxKey]: 1 }); + + // A second stroke is being painted over the same chunk (previewed, not + // yet dispatched) when the refetch arrives. + controller.notePreviewTouched(["0,0,0"]); + realSources[0].fireFreshChunk("0,0,0"); + + expect(overlaySources[0].invalidateChunks).not.toHaveBeenCalled(); + }); + + it("skips the clear when the write does not cover the last dispatched stroke", () => { + // Stroke 1 previewed and dispatched (seq 1); stroke 2 touches the same + // chunk and is dispatched (seq 2) before stroke 1's write lands. + controller.notePreviewTouched(["0,0,0"]); + controller.takeDispatchSeq(); + controller.notePreviewTouched(["0,0,0"]); + controller.takeDispatchSeq(); + + // The reload for stroke 1's flush only covers seq 1 < 2. + const voxKey = makeVoxChunkKey("0,0,0", 0); + controller.callChunkReload([voxKey], false, undefined, { [voxKey]: 1 }); + realSources[0].fireFreshChunk("0,0,0"); + expect(overlaySources[0].invalidateChunks).not.toHaveBeenCalled(); + + // Stroke 2's own flush covers seq 2: its reload performs the clear. + controller.callChunkReload([voxKey], false, undefined, { [voxKey]: 2 }); + realSources[0].fireFreshChunk("0,0,0"); + expect(overlaySources[0].invalidateChunks).toHaveBeenCalledWith(["0,0,0"]); + }); + + it("skips the clear when a reload carries no coverage but a dispatch touched the chunk", () => { + // e.g. an undo/redo reload: without echoed coverage it must not clear an + // overlay that a dispatched-but-unwritten stroke still owns. + controller.notePreviewTouched(["0,0,0"]); + controller.takeDispatchSeq(); + + controller.callChunkReload([makeVoxChunkKey("0,0,0", 0)], false); + realSources[0].fireFreshChunk("0,0,0"); + + expect(overlaySources[0].invalidateChunks).not.toHaveBeenCalled(); + }); + + it("clears without coverage info when no dispatch ever touched the chunk", () => { + controller.callChunkReload([makeVoxChunkKey("0,0,0", 0)], false); + realSources[0].fireFreshChunk("0,0,0"); + expect(overlaySources[0].invalidateChunks).toHaveBeenCalledWith(["0,0,0"]); + }); + + it("guards downsampled-parent reloads with the origin chunk's coverage", () => { + controller.notePreviewTouched(["1,2,3"]); + controller.takeDispatchSeq(); // seq 1 + controller.notePreviewTouched(["1,2,3"]); + controller.takeDispatchSeq(); // seq 2, not yet written + + const parentKey = makeVoxChunkKey("0,0,0", 1); + const originKey = makeVoxChunkKey("1,2,3", 0); + + // Cascade reload from stroke 1's flush: parent data only covers seq 1. + controller.callChunkReload( + [parentKey], + false, + { [parentKey]: originKey }, + { [parentKey]: 1 }, + ); + expect(realSources[1].invalidateChunks).toHaveBeenCalledWith(["0,0,0"], { + lazy: true, + }); + realSources[1].fireFreshChunk("0,0,0"); + expect(overlaySources[0].invalidateChunks).not.toHaveBeenCalled(); + + // Cascade re-run after stroke 2's flush covers seq 2. + controller.callChunkReload( + [parentKey], + false, + { [parentKey]: originKey }, + { [parentKey]: 2 }, + ); + realSources[1].fireFreshChunk("0,0,0"); + expect(overlaySources[0].invalidateChunks).toHaveBeenCalledWith(["1,2,3"]); + }); + + it("stale listeners from an earlier flush self-neutralize on a single arrival", () => { + // Two reloads armed on the same key before any refetch arrives (covered 1 + // then 2). Both listeners fire on the single arrival: the stale one skips + // (covered 1 < required 2), the up-to-date one clears. + controller.notePreviewTouched(["0,0,0"]); + controller.takeDispatchSeq(); + const voxKey = makeVoxChunkKey("0,0,0", 0); + controller.callChunkReload([voxKey], false, undefined, { [voxKey]: 1 }); + + controller.notePreviewTouched(["0,0,0"]); + controller.takeDispatchSeq(); + controller.callChunkReload([voxKey], false, undefined, { [voxKey]: 2 }); + + realSources[0].fireFreshChunk("0,0,0"); + expect(overlaySources[0].invalidateChunks).toHaveBeenCalledTimes(1); + expect(overlaySources[0].invalidateChunks).toHaveBeenCalledWith(["0,0,0"]); + }); + + it("drains the pending chunk-update queue before arming any listener", () => { + // A refetch predating the write may already sit in the frontend update + // queue when the reload RPC arrives; it must be applied before the new + // listener exists, so it can only reach older (coverage-guarded) + // listeners. + controller.callChunkReload([makeVoxChunkKey("0,0,0", 0)], false); + + expect(mockChunkQueueManager.flushPendingChunkUpdates).toHaveBeenCalled(); + const flushOrder = + mockChunkQueueManager.flushPendingChunkUpdates.mock + .invocationCallOrder[0]; + const armOrder = + realSources[0].onNextFreshChunk.mock.invocationCallOrder[0]; + expect(flushOrder).toBeLessThan(armOrder); + }); +}); diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index af2c41bc55..24f0974ce4 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -57,6 +57,33 @@ export class VoxelEditController extends SharedObject { public redoCount = new WatchableValue(0); public pendingOpCount: SharedWatchableValue; + // Monotonic counter identifying each operation dispatched to the backend. + private dispatchSeq = 0; + // Overlay chunk keys ("x,y,z", LOD 0) touched by previews since the last + // dispatch: an in-progress stroke. No overlay clear may run on these — the + // corresponding edits are not even dispatched yet. + private undispatchedPreviewKeys = new Set(); + // Per overlay chunk key: seq of the last dispatched operation whose preview + // touched it. An overlay chunk may only be cleared by a reload whose write + // covers this seq (coveredSeqs echoed by the backend). + private lastDispatchedSeq = new Map(); + + // Called by preview paths for every overlay chunk key they touch. + notePreviewTouched(keys: Iterable): void { + for (const key of keys) this.undispatchedPreviewKeys.add(key); + } + + // Called when dispatching an operation: the accumulated previewed keys are + // attributed to this new seq and no longer count as in-progress. + takeDispatchSeq(): number { + const seq = ++this.dispatchSeq; + for (const key of this.undispatchedPreviewKeys) { + this.lastDispatchedSeq.set(key, seq); + } + this.undispatchedPreviewKeys.clear(); + return seq; + } + constructor(private host: VoxelEditControllerHost) { super(); const rpc = this.host.rpc; @@ -342,6 +369,7 @@ export class VoxelEditController extends SharedObject { if (edits.size > 0) { previewSource.applyLocalEdits(edits); + this.notePreviewTouched(edits.keys()); } } @@ -357,6 +385,7 @@ export class VoxelEditController extends SharedObject { const storageValue = valueGetter(false); await this.dispatchOperation({ type: VoxelOperationType.BRUSH, + seq: this.takeDispatchSeq(), centers, radius: radiusCanonical, value: storageValue, @@ -507,12 +536,14 @@ export class VoxelEditController extends SharedObject { if (filledCount > 0) { previewChunkSource.applyLocalEdits(edits); + this.notePreviewTouched(edits.keys()); } const storageValue = fillValueGetter(false); try { await this.dispatchOperation({ type: VoxelOperationType.FLOOD_FILL, + seq: this.takeDispatchSeq(), seed: startPositionCanonical, value: storageValue, maxVoxels, @@ -531,6 +562,7 @@ export class VoxelEditController extends SharedObject { voxChunkKeys: string[], isForPreviewChunks: boolean, overlayKeysToClear?: Record, + coveredSeqs?: Record, ) { if (!Array.isArray(voxChunkKeys) || voxChunkKeys.length === 0) return; const multiscaleSource = isForPreviewChunks @@ -566,6 +598,17 @@ export class VoxelEditController extends SharedObject { const previewSources = this.host.previewSource?.getSources( this.getIdentitySliceViewSourceOptions(), )[0]; + // Worker chunk updates are queued and applied with a time budget, while + // this RPC runs at receipt: a refetch predating the write we are + // reloading for may already sit in that queue and would otherwise + // trigger the listeners armed below with pre-write data. Apply the + // queue now — stale updates can only reach previously armed listeners, + // each neutralized by its own generation guard. Combined with the + // backend cancelling in-flight downloads on write, the listeners armed + // below can only fire for refetches issued after the write. + sources + .find((s) => s?.chunkSource) + ?.chunkSource.chunkManager.chunkQueueManager.flushPendingChunkUpdates(); for (const voxKey of voxChunkKeys) { const parsed = parseVoxChunkKey(voxKey); if (!parsed) continue; @@ -585,9 +628,20 @@ export class VoxelEditController extends SharedObject { : undefined; if (overlaySource) { const overlayChunkKey = overlayParsed!.chunkKey; - source.onNextFreshChunk(chunkKey, () => - overlaySource.invalidateChunks([overlayChunkKey]), - ); + // The backend echoes, per reloaded chunk, the highest dispatch seq + // its write covers. Clear the overlay only if the arriving data + // covers the last dispatched stroke that touched it AND no stroke + // is in progress on it. A skipped clear is always re-armed later: + // whatever caused the skip ends up written, and that write's own + // reload carries a sufficient coveredSeq. Skipping is always safe: + // the overlay on screen is never older than the real chunk below. + const coveredSeq = coveredSeqs?.[voxKey] ?? 0; + source.onNextFreshChunk(chunkKey, () => { + if (this.undispatchedPreviewKeys.has(overlayChunkKey)) return; + const requiredSeq = this.lastDispatchedSeq.get(overlayChunkKey); + if (requiredSeq !== undefined && coveredSeq < requiredSeq) return; + overlaySource.invalidateChunks([overlayChunkKey]); + }); } let arr = chunksToInvalidateBySource.get(source); if (!arr) { @@ -668,7 +722,11 @@ registerRPC(VOX_RELOAD_CHUNKS_RPC_ID, function (x: any) { x.overlayKeysToClear !== null && typeof x.overlayKeysToClear === "object" ? x.overlayKeysToClear : undefined; - obj.callChunkReload(keys, x.isForPreviewChunks, overlayKeys); + const coveredSeqs: Record | undefined = + x.coveredSeqs !== null && typeof x.coveredSeqs === "object" + ? x.coveredSeqs + : undefined; + obj.callChunkReload(keys, x.isForPreviewChunks, overlayKeys, coveredSeqs); }); registerRPC(VOX_EDIT_FAILURE_RPC_ID, function (x: any) { From 9d7f173123c22fdce522b4e3ee5d1278f1edf818 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Wed, 15 Jul 2026 15:31:23 +0200 Subject: [PATCH 234/251] fix(voxel-annotation): capture cascade coverage at chain start and lock full downsample steps Two fixes for overlay clears and data integrity at LOD >= 1: - The coverage a cascade reload claims for the origin overlay is now captured once per chain, before the first child read, instead of at each step: a flush completing mid-chain is not present in the data the chain propagates, and claiming it cleared the overlay over a parent that lacked those edits. - The child read and parent-update computation now run inside withChunkLock(parentKey) along with the write. With only the write serialized, two concurrent chains targeting the same parent could land in the wrong order and durably overwrite the fresher parent with a result computed from a stale read; inside the lock, the later writer computed from the later read. --- src/voxel_annotation/backend.spec.ts | 62 +++++++++++++++++++- src/voxel_annotation/backend.ts | 87 +++++++++++++++++----------- 2 files changed, 115 insertions(+), 34 deletions(-) diff --git a/src/voxel_annotation/backend.spec.ts b/src/voxel_annotation/backend.spec.ts index 2a139687fd..df33c6ce40 100644 --- a/src/voxel_annotation/backend.spec.ts +++ b/src/voxel_annotation/backend.spec.ts @@ -620,11 +620,71 @@ describe("VoxelEditController: Downsampling Integration", () => { // The real parent is reloaded lazily; when it reaches the GPU it clears the // originating LOD-0 overlay (swap-on-arrival), passed as a { real -> overlay } - // map. The old eager preview-clear call no longer exists. + // map along with the origin's covered dispatch seq (0 here: no seq-tagged + // edit was flushed for the origin). The old eager preview-clear call no + // longer exists. expect((controller as any).callChunkReload).toHaveBeenCalledWith( [makeVoxChunkKey("0,0,0", 1)], false, // isForPreviewChunks { [makeVoxChunkKey("0,0,0", 1)]: makeVoxChunkKey("0,0,0", 0) }, + { [makeVoxChunkKey("0,0,0", 1)]: 0 }, + ); + }); + + it("Coverage echo: flush and cascade reloads carry the max flushed dispatch seq", async () => { + setupIntegration(2); + const key = makeVoxChunkKey("0,0,0", 0); + + // Two dispatched strokes touch the chunk before the flush runs: the write + // covers both, so the echoed coverage must be the max seq (7). + controller.commitVoxels([ + { key, indices: [0], value: 1n, seq: 3 }, + { key, indices: [1], value: 1n, seq: 7 }, + ]); + await (controller as any).flushPending(); + + expect((controller as any).callChunkReload).toHaveBeenCalledWith( + [key], + false, + undefined, + { [key]: 7 }, + ); + + // The cascade reload claims the origin's flushed coverage for the parent. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect((controller as any).callChunkReload).toHaveBeenCalledWith( + [makeVoxChunkKey("0,0,0", 1)], + false, + { [makeVoxChunkKey("0,0,0", 1)]: key }, + { [makeVoxChunkKey("0,0,0", 1)]: 7 }, + ); + }); + + it("Coverage echo: a chain claims its start-of-chain coverage even if a flush lands mid-chain", async () => { + setupIntegration(3); + const originKey = makeVoxChunkKey("0,0,0", 0); + + (controller as any).lastFlushedSeq.set(originKey, 1); + + // Mid-chain (during the L0->L1 write), a newer flush of the same origin + // completes (seq 5). The data propagated by the running chain derives from + // a read made before that write, so the L1->L2 reload must keep claiming + // seq 1 — claiming 5 would clear the overlay over a parent lacking those + // edits. + parentSource.applyEdits.mockImplementation(async () => { + parentSource.serverStorage.set("0,0,0", new Uint8Array(8).fill(1).buffer); + (controller as any).lastFlushedSeq.set(originKey, 5); + }); + + (controller as any).enqueueDownsample(originKey); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(grandParentSource.applyEdits).toHaveBeenCalled(); + expect((controller as any).callChunkReload).toHaveBeenCalledWith( + [makeVoxChunkKey("0,0,0", 2)], + false, + { [makeVoxChunkKey("0,0,0", 2)]: originKey }, + { [makeVoxChunkKey("0,0,0", 2)]: 1 }, ); }); diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 5b285d8b32..d2e4f303d6 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -731,10 +731,17 @@ export class VoxelEditController extends SharedObject { } private async processDownsampleChain(key: string): Promise { + // Coverage is captured once for the whole chain, before the first child + // read: every step propagates data derived from the LOD-0 content read at + // chain start, so a flush completing mid-chain (higher seq) is NOT + // included in what the later steps write — claiming it would clear the + // overlay over a parent that lacks those edits. Under-claiming is safe: + // that flush enqueues its own chain, which re-claims with its seq. + const chainCoveredSeq = this.lastFlushedSeq.get(key) ?? 0; let currentKey: string | null = key; while (currentKey !== null) { - currentKey = await this.downsampleStep(currentKey, key); + currentKey = await this.downsampleStep(currentKey, key, chainCoveredSeq); } // Note: the overlay is no longer cleared eagerly here. Each parent's real @@ -781,25 +788,13 @@ export class VoxelEditController extends SharedObject { private async downsampleStep( childKey: string, originKey: string, + originCoveredSeq: number, ): Promise { const childInfo = parseVoxChunkKey(childKey); if (childInfo === null) { console.error(`[Downsample] Invalid child key format: ${childKey}`); return null; } - const childAccessor = this.getAccessor(childInfo.lodIndex); - const childCtx = await childAccessor.getOrLoadChunkContext( - childInfo.chunkKey, - childInfo.x, - childInfo.y, - childInfo.z, - ); - - if (!childCtx.data) { - return null; - } - - const childChunkData = childCtx.data as Uint32Array | BigUint64Array; const childRes = this.resolutions.get(childInfo.lodIndex)!; const parentInfo = this._getParentChunkInfo(childKey, childRes); @@ -808,24 +803,44 @@ export class VoxelEditController extends SharedObject { } const { parentKey, parentSource, parentRes } = parentInfo; - const childActualSize = [ - childCtx.maxX - childCtx.minX, - childCtx.maxY - childCtx.minY, - childCtx.maxZ - childCtx.minZ, - ]; - - const update = this._calculateParentUpdate( - childChunkData, - childRes, - parentRes, - childInfo, - childActualSize, - ); - if (update.indices.length === 0) { - return parentKey; - } - + // The child read and the parent-update computation must run under the + // parent lock along with the write: with only the write serialized, two + // concurrent chains (e.g. successive flushes of the same origin) can + // compute from reads taken at different times and land in the wrong + // order, durably overwriting the fresher parent with a stale result. + // Inside the lock, the later writer computed from the later read. return this.withChunkLock(parentKey, async () => { + const childAccessor = this.getAccessor(childInfo.lodIndex); + const childCtx = await childAccessor.getOrLoadChunkContext( + childInfo.chunkKey, + childInfo.x, + childInfo.y, + childInfo.z, + ); + + if (!childCtx.data) { + return null; + } + + const childChunkData = childCtx.data as Uint32Array | BigUint64Array; + + const childActualSize = [ + childCtx.maxX - childCtx.minX, + childCtx.maxY - childCtx.minY, + childCtx.maxZ - childCtx.minZ, + ]; + + const update = this._calculateParentUpdate( + childChunkData, + childRes, + parentRes, + childInfo, + childActualSize, + ); + if (update.indices.length === 0) { + return parentKey; + } + try { await parentSource.applyEdits( parentInfo.chunkKey, @@ -833,8 +848,14 @@ export class VoxelEditController extends SharedObject { update.values, ); // Reload the real parent lazily; when it reaches the GPU, clear the - // originating LOD-0 overlay (the visible one when zoomed out). - this.callChunkReload([parentKey], false, { [parentKey]: originKey }); + // originating LOD-0 overlay (the visible one when zoomed out) — if + // the propagated data covers every stroke dispatched to the origin. + this.callChunkReload( + [parentKey], + false, + { [parentKey]: originKey }, + { [parentKey]: originCoveredSeq }, + ); const parentAccessor = this.getAccessor(parentRes.lodIndex); parentAccessor.invalidate(parentInfo.chunkKey); } catch (e) { From dc88631351a40fec6692d30a06f5d3b550b92b2e Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Fri, 17 Jul 2026 15:26:04 +0200 Subject: [PATCH 235/251] fix(voxel-annotation): tag overlay chunks with their stroke seq and roll back undispatched strokes The coverage guard tracked which strokes touched which overlay chunks in controller-side prediction state (undispatchedPreviewKeys / lastDispatchedSeq), populated by previews and consumed at dispatch. Any path where a previewed stroke never dispatched left that state stuck and permanently blocked the overlay chunk from clearing: withCost silently dropping the dispatch at the stamina cap or on permission refusal, stopDrawing bailing on an empty stroke or a vanished editing context, and preview/backend divergence on filtered brushes. The stroke seq is now allocated before the first preview (beginStroke, snapshotted into activeStroke) and tags the overlay chunks directly on InMemoryVolumeChunkSource as they are painted, so what the overlay shows and what the dispatch carries cannot diverge. The clear guard reduces to a single fire-time condition: coveredSeq >= overlay chunk's tag (an in-progress stroke's chunks are protected by construction, their tag is not yet covered by any write). Tags are purged with the chunk in deleteChunk, so the map only ever tracks live overlay chunks. Strokes whose edits will never be written are now rolled back explicitly (rollbackStroke) instead of waited for: the brush dispatch wrapper rolls back when withCost does not run the dispatch or the RPC fails, stopDrawing rolls back on its bail-out paths via the snapshotted context, and flood fill rolls back through the same mechanism (dropping the now-redundant affectedKeys tracking). The flood-fill fast path for unloaded chunks now carries a seq as well, so its write advances coverage like any other. --- src/layer/voxel_annotation/index.ts | 44 +++++-- src/sliceview/volume/frontend.spec.ts | 26 ++++ src/sliceview/volume/frontend.ts | 27 ++++ src/ui/voxel_annotations.ts | 31 ++++- src/voxel_annotation/frontend.spec.ts | 120 +++++++++++------- src/voxel_annotation/frontend.ts | 94 +++++++------- .../pipeline_zarr_s3.browser_test.ts | 21 ++- 7 files changed, 256 insertions(+), 107 deletions(-) diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index 6c659f4e35..e275bbe80a 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -221,12 +221,23 @@ export class VoxelEditingContext } } + beginStroke(): number { + if (!this._controller) + throw new Error("Cannot use beginStroke without a controller"); + return this._controller.beginStroke(); + } + + rollbackStroke(seq: number): void { + this._controller?.rollbackStroke(seq); + } + async applyBrushPreview( points: Float32Array[], radiusCanonical: number, value: VoxelValueGetter, shape: BrushShape, basis: { u: Float32Array; v: Float32Array }, + seq: number, filterValue?: bigint, ) { if (!this._controller) @@ -238,6 +249,7 @@ export class VoxelEditingContext value, shape, basis, + seq, filterValue, ); } @@ -248,6 +260,7 @@ export class VoxelEditingContext value: VoxelValueGetter, shape: BrushShape, basis: { u: Float32Array; v: Float32Array }, + seq: number, filterValue?: bigint, ) { if (!this._controller) @@ -258,16 +271,27 @@ export class VoxelEditingContext radiusCanonical, filterValue !== undefined, ) * centers.length; - await this.withCost(cost, () => - this._controller!.dispatchBrushStroke( - centers, - radiusCanonical, - value, - shape, - basis, - filterValue, - ), - ); + // The stroke's previews already tagged overlay chunks with `seq`. If the + // dispatch does not reach the backend — stamina or permission refusal in + // withCost, or an RPC failure — those edits will never be written and no + // reload would ever clear them: roll the stroke's overlay chunks back. + let dispatched = false; + try { + await this.withCost(cost, async () => { + await this._controller!.dispatchBrushStroke( + centers, + radiusCanonical, + value, + shape, + basis, + seq, + filterValue, + ); + dispatched = true; + }); + } finally { + if (!dispatched) this._controller.rollbackStroke(seq); + } } async floodFillPlane2D( diff --git a/src/sliceview/volume/frontend.spec.ts b/src/sliceview/volume/frontend.spec.ts index 22feec1cd3..892c8c8eb0 100644 --- a/src/sliceview/volume/frontend.spec.ts +++ b/src/sliceview/volume/frontend.spec.ts @@ -35,6 +35,7 @@ class MockChunk { dispose() {} updateFromCpuData = vi.fn(); + freeGPUMemory = vi.fn(); } vi.mock("#src/sliceview/volume/registry.js", () => ({ @@ -191,6 +192,31 @@ describe("InMemoryVolumeChunkSource", () => { expect(chunk.data[0]).toBe(123n); }); + it("Overlay seqs: default to 0, are set per key and filtered by seq", () => { + const source = createSource(DataType.UINT64); + expect(source.getOverlaySeq("0,0,0")).toBe(0); + + source.setOverlaySeq("0,0,0", 3); + source.setOverlaySeq("1,0,0", 3); + source.setOverlaySeq("2,0,0", 4); + + expect(source.getOverlaySeq("0,0,0")).toBe(3); + expect(source.keysWithOverlaySeq(3).sort()).toEqual(["0,0,0", "1,0,0"]); + expect(source.keysWithOverlaySeq(4)).toEqual(["2,0,0"]); + expect(source.keysWithOverlaySeq(5)).toEqual([]); + }); + + it("Overlay seqs: purged when the chunk is deleted", () => { + const source = createSource(DataType.UINT64); + source.applyLocalEdits(new Map([["0,0,0", { indices: [0], value: 1n }]])); + source.setOverlaySeq("0,0,0", 7); + + source.invalidateChunks(["0,0,0"]); + + expect(source.getOverlaySeq("0,0,0")).toBe(0); + expect(source.keysWithOverlaySeq(7)).toEqual([]); + }); + it("Applies contiguous index ranges with typed-array fills", () => { const source = createSource(DataType.UINT64); diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index d4e380e79b..2bc021f9ca 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -289,6 +289,33 @@ export class VolumeChunkSource @registerSharedObjectOwner(IN_MEMORY_VOLUME_CHUNK_SOURCE_RPC_ID) export class InMemoryVolumeChunkSource extends VolumeChunkSource { + // Stroke seq of the last local edit that touched each chunk. A reload may + // clear a chunk only once its write coverage reaches this seq. Entries live + // and die with the chunk (purged in deleteChunk), so the map never outgrows + // the set of live overlay chunks. + private overlaySeqs = new Map(); + + setOverlaySeq(key: string, seq: number): void { + this.overlaySeqs.set(key, seq); + } + + getOverlaySeq(key: string): number { + return this.overlaySeqs.get(key) ?? 0; + } + + keysWithOverlaySeq(seq: number): string[] { + const keys: string[] = []; + for (const [key, s] of this.overlaySeqs) { + if (s === seq) keys.push(key); + } + return keys; + } + + deleteChunk(key: string) { + this.overlaySeqs.delete(key); + super.deleteChunk(key); + } + constructor( chunkManager: ChunkManager, options: { spec: VolumeChunkSpecification }, diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 4d6953c8bd..4ce01c7263 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -21,7 +21,10 @@ import { getEditingContext, VOXEL_LAYER_CONTROLS, } from "#src/layer/voxel_annotation/controls.js"; -import type { UserLayerWithVoxelEditing } from "#src/layer/voxel_annotation/index.js"; +import type { + UserLayerWithVoxelEditing, + VoxelEditingContext, +} from "#src/layer/voxel_annotation/index.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; import { StatusMessage } from "#src/status.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; @@ -332,6 +335,10 @@ export class VoxelBrushTool extends BaseVoxelTool { basis: { u: Float32Array; v: Float32Array }; value: VoxelValueGetter; filterValue: bigint | undefined; + seq: number; + // Snapshotted so a stroke abandoned without dispatch can always be + // rolled back, even if the layer's editing context is gone by then. + context: VoxelEditingContext; } | undefined = undefined; @@ -445,6 +452,10 @@ export class VoxelBrushTool extends BaseVoxelTool { private startDrawing(mouseState: MouseSelectionState) { if (this.isDrawing) return; + const editContext = getEditingContext(this.layer); + if (editContext === undefined) { + throw new Error("editContext is undefined"); + } // getPoint must run before the stroke snapshot below: it also records the // slice normal that getBasis() reads. const start = this.getPoint(mouseState); @@ -464,6 +475,8 @@ export class VoxelBrushTool extends BaseVoxelTool { this.layer.lockToSelectedValue.value && this.layer.shouldErase() ? this.layer.getVoxelPaintValue(false)(false) : undefined, + seq: editContext.beginStroke(), + context: editContext, }; this.paintPoints([new Float32Array([start[0], start[1], start[2]])]); @@ -492,13 +505,21 @@ export class VoxelBrushTool extends BaseVoxelTool { this.mouseDisposer = undefined; } + const stroke = this.activeStroke!; const centers = this.accumulatedCenters; this.accumulatedCenters = []; - if (centers.length === 0) return; + if (centers.length === 0) { + // Nothing to dispatch: whatever the previews tagged must be rolled + // back, or it would wait forever for a write that never comes. + stroke.context.rollbackStroke(stroke.seq); + return; + } const editContext = getEditingContext(this.layer); - if (editContext === undefined) return; - const stroke = this.activeStroke!; + if (editContext === undefined) { + stroke.context.rollbackStroke(stroke.seq); + return; + } void editContext.dispatchBrushStroke( centers, @@ -506,6 +527,7 @@ export class VoxelBrushTool extends BaseVoxelTool { stroke.value, stroke.shape, stroke.basis, + stroke.seq, stroke.filterValue, ); } @@ -527,6 +549,7 @@ export class VoxelBrushTool extends BaseVoxelTool { stroke.value, stroke.shape, stroke.basis, + stroke.seq, stroke.filterValue, ); } diff --git a/src/voxel_annotation/frontend.spec.ts b/src/voxel_annotation/frontend.spec.ts index 2712e7b94f..06856e4a67 100644 --- a/src/voxel_annotation/frontend.spec.ts +++ b/src/voxel_annotation/frontend.spec.ts @@ -40,9 +40,19 @@ function createRealSourceMock() { }; } +// Overlay source mock mirroring InMemoryVolumeChunkSource's stroke-seq tags: +// invalidateChunks purges the tag, like the real deleteChunk does. function createOverlaySourceMock() { + const overlaySeqs = new Map(); return { - invalidateChunks: vi.fn(), + overlaySeqs, + setOverlaySeq: (key: string, seq: number) => overlaySeqs.set(key, seq), + getOverlaySeq: (key: string) => overlaySeqs.get(key) ?? 0, + keysWithOverlaySeq: (seq: number) => + [...overlaySeqs.entries()].filter(([, s]) => s === seq).map(([k]) => k), + invalidateChunks: vi.fn((keys: string[]) => { + for (const key of keys) overlaySeqs.delete(key); + }), }; } @@ -74,14 +84,18 @@ describe("VoxelEditController.callChunkReload: overlay clear coverage guard", () controller = new VoxelEditController(host as any); }); - it("clears the overlay when the write covers the last dispatched stroke", () => { - // Stroke previewed on "0,0,0" then dispatched as seq 1. - controller.notePreviewTouched(["0,0,0"]); - expect(controller.takeDispatchSeq()).toBe(1); + it("allocates monotonically increasing stroke seqs", () => { + expect(controller.beginStroke()).toBe(1); + expect(controller.beginStroke()).toBe(2); + expect(controller.beginStroke()).toBe(3); + }); + + it("clears the overlay when the write covers the stroke that tagged it", () => { + const seq = controller.beginStroke(); + overlaySources[0].setOverlaySeq("0,0,0", seq); - // Backend flushes seq 1 and reloads with coveredSeqs = 1. const voxKey = makeVoxChunkKey("0,0,0", 0); - controller.callChunkReload([voxKey], false, undefined, { [voxKey]: 1 }); + controller.callChunkReload([voxKey], false, undefined, { [voxKey]: seq }); expect(realSources[0].invalidateChunks).toHaveBeenCalledWith(["0,0,0"], { lazy: true, @@ -92,28 +106,10 @@ describe("VoxelEditController.callChunkReload: overlay clear coverage guard", () expect(overlaySources[0].invalidateChunks).toHaveBeenCalledWith(["0,0,0"]); }); - it("skips the clear while a stroke is in progress on the chunk", () => { - controller.notePreviewTouched(["0,0,0"]); - controller.takeDispatchSeq(); // seq 1 dispatched - - const voxKey = makeVoxChunkKey("0,0,0", 0); - controller.callChunkReload([voxKey], false, undefined, { [voxKey]: 1 }); - - // A second stroke is being painted over the same chunk (previewed, not - // yet dispatched) when the refetch arrives. - controller.notePreviewTouched(["0,0,0"]); - realSources[0].fireFreshChunk("0,0,0"); - - expect(overlaySources[0].invalidateChunks).not.toHaveBeenCalled(); - }); - - it("skips the clear when the write does not cover the last dispatched stroke", () => { - // Stroke 1 previewed and dispatched (seq 1); stroke 2 touches the same - // chunk and is dispatched (seq 2) before stroke 1's write lands. - controller.notePreviewTouched(["0,0,0"]); - controller.takeDispatchSeq(); - controller.notePreviewTouched(["0,0,0"]); - controller.takeDispatchSeq(); + it("skips the clear when the write does not cover the last stroke", () => { + controller.beginStroke(); // seq 1, written + const seq2 = controller.beginStroke(); // seq 2, dispatched but unwritten + overlaySources[0].setOverlaySeq("0,0,0", seq2); // The reload for stroke 1's flush only covers seq 1 < 2. const voxKey = makeVoxChunkKey("0,0,0", 0); @@ -127,11 +123,26 @@ describe("VoxelEditController.callChunkReload: overlay clear coverage guard", () expect(overlaySources[0].invalidateChunks).toHaveBeenCalledWith(["0,0,0"]); }); - it("skips the clear when a reload carries no coverage but a dispatch touched the chunk", () => { + it("reads the tag at fire time: a stroke touching the chunk after arming blocks the clear", () => { + const seq1 = controller.beginStroke(); + overlaySources[0].setOverlaySeq("0,0,0", seq1); + + const voxKey = makeVoxChunkKey("0,0,0", 0); + controller.callChunkReload([voxKey], false, undefined, { [voxKey]: seq1 }); + + // A new stroke's preview touches the chunk between arming and arrival: + // the arriving data cannot contain it. + const seq2 = controller.beginStroke(); + overlaySources[0].setOverlaySeq("0,0,0", seq2); + realSources[0].fireFreshChunk("0,0,0"); + + expect(overlaySources[0].invalidateChunks).not.toHaveBeenCalled(); + }); + + it("skips the clear when a reload carries no coverage but a stroke tagged the chunk", () => { // e.g. an undo/redo reload: without echoed coverage it must not clear an // overlay that a dispatched-but-unwritten stroke still owns. - controller.notePreviewTouched(["0,0,0"]); - controller.takeDispatchSeq(); + overlaySources[0].setOverlaySeq("0,0,0", controller.beginStroke()); controller.callChunkReload([makeVoxChunkKey("0,0,0", 0)], false); realSources[0].fireFreshChunk("0,0,0"); @@ -139,17 +150,16 @@ describe("VoxelEditController.callChunkReload: overlay clear coverage guard", () expect(overlaySources[0].invalidateChunks).not.toHaveBeenCalled(); }); - it("clears without coverage info when no dispatch ever touched the chunk", () => { + it("clears without coverage info when no stroke ever tagged the chunk", () => { controller.callChunkReload([makeVoxChunkKey("0,0,0", 0)], false); realSources[0].fireFreshChunk("0,0,0"); expect(overlaySources[0].invalidateChunks).toHaveBeenCalledWith(["0,0,0"]); }); it("guards downsampled-parent reloads with the origin chunk's coverage", () => { - controller.notePreviewTouched(["1,2,3"]); - controller.takeDispatchSeq(); // seq 1 - controller.notePreviewTouched(["1,2,3"]); - controller.takeDispatchSeq(); // seq 2, not yet written + controller.beginStroke(); // seq 1, written + const seq2 = controller.beginStroke(); // seq 2, unwritten + overlaySources[0].setOverlaySeq("1,2,3", seq2); const parentKey = makeVoxChunkKey("0,0,0", 1); const originKey = makeVoxChunkKey("1,2,3", 0); @@ -180,15 +190,15 @@ describe("VoxelEditController.callChunkReload: overlay clear coverage guard", () it("stale listeners from an earlier flush self-neutralize on a single arrival", () => { // Two reloads armed on the same key before any refetch arrives (covered 1 - // then 2). Both listeners fire on the single arrival: the stale one skips - // (covered 1 < required 2), the up-to-date one clears. - controller.notePreviewTouched(["0,0,0"]); - controller.takeDispatchSeq(); + // then 2, tag at 2). Both listeners fire on the single arrival: the stale + // one skips, the up-to-date one clears (and drops the tag), leaving the + // firing order irrelevant. + controller.beginStroke(); + const seq2 = controller.beginStroke(); + overlaySources[0].setOverlaySeq("0,0,0", seq2); + const voxKey = makeVoxChunkKey("0,0,0", 0); controller.callChunkReload([voxKey], false, undefined, { [voxKey]: 1 }); - - controller.notePreviewTouched(["0,0,0"]); - controller.takeDispatchSeq(); controller.callChunkReload([voxKey], false, undefined, { [voxKey]: 2 }); realSources[0].fireFreshChunk("0,0,0"); @@ -196,6 +206,28 @@ describe("VoxelEditController.callChunkReload: overlay clear coverage guard", () expect(overlaySources[0].invalidateChunks).toHaveBeenCalledWith(["0,0,0"]); }); + it("rollbackStroke drops exactly the overlay chunks tagged by that stroke", () => { + const seq1 = controller.beginStroke(); + overlaySources[0].setOverlaySeq("0,0,0", seq1); + const seq2 = controller.beginStroke(); + overlaySources[0].setOverlaySeq("1,0,0", seq2); + overlaySources[0].setOverlaySeq("2,0,0", seq2); + + controller.rollbackStroke(seq2); + + expect(overlaySources[0].invalidateChunks).toHaveBeenCalledTimes(1); + const [rolledBack] = overlaySources[0].invalidateChunks.mock.calls[0]; + expect([...rolledBack].sort()).toEqual(["1,0,0", "2,0,0"]); + // The other stroke's chunk is untouched and still tagged. + expect(overlaySources[0].getOverlaySeq("0,0,0")).toBe(seq1); + }); + + it("rollbackStroke with no tagged chunks is a no-op", () => { + const seq = controller.beginStroke(); + controller.rollbackStroke(seq); + expect(overlaySources[0].invalidateChunks).not.toHaveBeenCalled(); + }); + it("drains the pending chunk-update queue before arming any listener", () => { // A refetch predating the write may already sit in the frontend update // queue when the reload RPC arrives; it must be applied before the new diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 24f0974ce4..6f451a7ba0 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -31,7 +31,6 @@ import type { VoxelValueGetter, } from "#src/voxel_annotation/base.js"; import { - makeVoxChunkKey, BrushShape, getDiskStencilKernel, getSphereRowRangesKernel, @@ -57,31 +56,28 @@ export class VoxelEditController extends SharedObject { public redoCount = new WatchableValue(0); public pendingOpCount: SharedWatchableValue; - // Monotonic counter identifying each operation dispatched to the backend. + // Monotonic counter identifying each stroke/operation dispatched to the + // backend. private dispatchSeq = 0; - // Overlay chunk keys ("x,y,z", LOD 0) touched by previews since the last - // dispatch: an in-progress stroke. No overlay clear may run on these — the - // corresponding edits are not even dispatched yet. - private undispatchedPreviewKeys = new Set(); - // Per overlay chunk key: seq of the last dispatched operation whose preview - // touched it. An overlay chunk may only be cleared by a reload whose write - // covers this seq (coveredSeqs echoed by the backend). - private lastDispatchedSeq = new Map(); - - // Called by preview paths for every overlay chunk key they touch. - notePreviewTouched(keys: Iterable): void { - for (const key of keys) this.undispatchedPreviewKeys.add(key); + + // Allocates a stroke's seq before its first preview. Previews tag the + // overlay chunks they touch with it and the dispatch carries the same + // value, so what the overlay shows and what the write covers cannot + // diverge. + beginStroke(): number { + return ++this.dispatchSeq; } - // Called when dispatching an operation: the accumulated previewed keys are - // attributed to this new seq and no longer count as in-progress. - takeDispatchSeq(): number { - const seq = ++this.dispatchSeq; - for (const key of this.undispatchedPreviewKeys) { - this.lastDispatchedSeq.set(key, seq); - } - this.undispatchedPreviewKeys.clear(); - return seq; + // Drops the overlay chunks tagged by a stroke whose edits will never be + // written (stamina/permission refusal, empty stroke, dispatch failure): + // no reload would ever clear them. + rollbackStroke(seq: number): void { + const overlaySource = this.host.previewSource?.getSources( + this.getIdentitySliceViewSourceOptions(), + )[0]?.[0]?.chunkSource as InMemoryVolumeChunkSource | undefined; + if (overlaySource === undefined) return; + const keys = overlaySource.keysWithOverlaySeq(seq); + if (keys.length > 0) overlaySource.invalidateChunks(keys); } constructor(private host: VoxelEditControllerHost) { @@ -173,6 +169,7 @@ export class VoxelEditController extends SharedObject { valueGetter: VoxelValueGetter, shape: BrushShape, basis: { u: Float32Array; v: Float32Array }, + seq: number, filterValue?: bigint, ) { if (!this.host.previewSource) return; @@ -369,7 +366,7 @@ export class VoxelEditController extends SharedObject { if (edits.size > 0) { previewSource.applyLocalEdits(edits); - this.notePreviewTouched(edits.keys()); + for (const key of edits.keys()) previewSource.setOverlaySeq(key, seq); } } @@ -379,13 +376,19 @@ export class VoxelEditController extends SharedObject { valueGetter: VoxelValueGetter, shape: BrushShape, basis: { u: Float32Array; v: Float32Array }, + seq: number, filterValue?: bigint, ) { - if (centers.length === 0) return; + if (centers.length === 0) { + // Nothing will be written for this stroke; drop whatever its previews + // tagged so the overlay does not wait for a write that never comes. + this.rollbackStroke(seq); + return; + } const storageValue = valueGetter(false); await this.dispatchOperation({ type: VoxelOperationType.BRUSH, - seq: this.takeDispatchSeq(), + seq, centers, radius: radiusCanonical, value: storageValue, @@ -402,6 +405,7 @@ export class VoxelEditController extends SharedObject { basis: { u: Float32Array; v: Float32Array }, filterValue?: bigint, ) { + const seq = this.beginStroke(); const previewValue = fillValueGetter(true); const sourcesByScale = this.host.primarySource.getSources( this.getIdentitySliceViewSourceOptions(), @@ -450,6 +454,7 @@ export class VoxelEditController extends SharedObject { // to the backend, which will load the chunk itself. await this.dispatchOperation({ type: VoxelOperationType.FLOOD_FILL, + seq, seed: startPositionCanonical, value: fillValueGetter(false), maxVoxels, @@ -483,8 +488,6 @@ export class VoxelEditController extends SharedObject { vy = basis.v[1], vz = basis.v[2]; - const affectedKeys: string[] = []; - while (queue.length > 0 && filledCount < maxVoxels) { if ((filledCount & 63) === 0 && Date.now() - start > MAX_TIME_MS) break; @@ -505,7 +508,6 @@ export class VoxelEditController extends SharedObject { if (!entry) { entry = { indices: [], value: previewValue }; edits.set(key, entry); - affectedKeys.push(makeVoxChunkKey(key, 0)); } const index = lz * strideZ + ly * strideY + lx; entry.indices.push(index); @@ -536,14 +538,16 @@ export class VoxelEditController extends SharedObject { if (filledCount > 0) { previewChunkSource.applyLocalEdits(edits); - this.notePreviewTouched(edits.keys()); + for (const key of edits.keys()) { + previewChunkSource.setOverlaySeq(key, seq); + } } const storageValue = fillValueGetter(false); try { await this.dispatchOperation({ type: VoxelOperationType.FLOOD_FILL, - seq: this.takeDispatchSeq(), + seq, seed: startPositionCanonical, value: storageValue, maxVoxels, @@ -551,9 +555,7 @@ export class VoxelEditController extends SharedObject { filterValue, }); } catch (e) { - if (affectedKeys.length > 0) { - this.callChunkReload(affectedKeys, true); - } + this.rollbackStroke(seq); throw e; } } @@ -603,7 +605,7 @@ export class VoxelEditController extends SharedObject { // reloading for may already sit in that queue and would otherwise // trigger the listeners armed below with pre-write data. Apply the // queue now — stale updates can only reach previously armed listeners, - // each neutralized by its own generation guard. Combined with the + // each neutralized by its own coverage check. Combined with the // backend cancelling in-flight downloads on write, the listeners armed // below can only fire for refetches issued after the write. sources @@ -628,18 +630,20 @@ export class VoxelEditController extends SharedObject { : undefined; if (overlaySource) { const overlayChunkKey = overlayParsed!.chunkKey; - // The backend echoes, per reloaded chunk, the highest dispatch seq - // its write covers. Clear the overlay only if the arriving data - // covers the last dispatched stroke that touched it AND no stroke - // is in progress on it. A skipped clear is always re-armed later: - // whatever caused the skip ends up written, and that write's own - // reload carries a sufficient coveredSeq. Skipping is always safe: - // the overlay on screen is never older than the real chunk below. + // The backend echoes, per reloaded chunk, the highest stroke seq + // its write covers. The overlay chunk carries the seq of the last + // stroke whose preview touched it (including a stroke still under + // the mouse — its seq is allocated before its first preview), read + // at fire time. Clearing only when coverage reaches that tag + // guarantees the arriving data contains everything the overlay + // shows. A skipped clear is re-armed by the covering write's own + // reload; a stroke that never gets written is rolled back + // explicitly (rollbackStroke) instead of waited for. const coveredSeq = coveredSeqs?.[voxKey] ?? 0; source.onNextFreshChunk(chunkKey, () => { - if (this.undispatchedPreviewKeys.has(overlayChunkKey)) return; - const requiredSeq = this.lastDispatchedSeq.get(overlayChunkKey); - if (requiredSeq !== undefined && coveredSeq < requiredSeq) return; + if (coveredSeq < overlaySource.getOverlaySeq(overlayChunkKey)) { + return; + } overlaySource.invalidateChunks([overlayChunkKey]); }); } diff --git a/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts b/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts index 7e8bfa3c06..dbcabe4cdc 100644 --- a/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts +++ b/tests/voxel_annotation/pipeline_zarr_s3.browser_test.ts @@ -154,10 +154,17 @@ test("Pipeline: Zarr V2 (UINT8) Undo/Redo with Brush", async () => { const { context } = await waitForEditingContext(); const center = new Float32Array([16, 16, 16]); - await context.dispatchBrushStroke([center], 5, (_) => 100n, 0 /* DISK */, { - u: new Float32Array([1, 0, 0]), - v: new Float32Array([0, 1, 0]), - }); + await context.dispatchBrushStroke( + [center], + 5, + (_) => 100n, + 0 /* DISK */, + { + u: new Float32Array([1, 0, 0]), + v: new Float32Array([0, 1, 0]), + }, + context.beginStroke(), + ); const chunkKey = `${BUCKET}/data.zarr/0.0.0`; @@ -231,6 +238,7 @@ test("Pipeline: Zarr V3 (UINT64) Brush", async () => { u: new Float32Array([1, 0, 0]), v: new Float32Array([0, 1, 0]), }, + context.beginStroke(), ); const chunkKey = `${BUCKET}/data.zarr/c/0/0/0`; @@ -297,6 +305,7 @@ test("Pipeline: Repaint over existing chunk data (Zarr V3 UINT64)", async () => u: new Float32Array([1, 0, 0]), v: new Float32Array([0, 1, 0]), }, + context.beginStroke(), ); await poll(() => { @@ -379,6 +388,7 @@ test("Pipeline: Repaint over existing gzip-compressed chunk (Zarr V3 UINT64)", a u: new Float32Array([1, 0, 0]), v: new Float32Array([0, 1, 0]), }, + context.beginStroke(), ); await poll(async () => { @@ -467,6 +477,7 @@ test("Pipeline: Repaint over dense large-label segmentation data (Zarr V3 UINT64 u: new Float32Array([1, 0, 0]), v: new Float32Array([0, 1, 0]), }, + context.beginStroke(), ); await poll(async () => { @@ -594,6 +605,7 @@ test("Pipeline: Multiscale repaint with NON-EMPTY downsample parent (OME zarr3)" u: new Float32Array([1, 0, 0]), v: new Float32Array([0, 1, 0]), }, + context.beginStroke(), ); await poll(async () => { @@ -657,6 +669,7 @@ test("Pipeline: Zarr V2 (UINT32) with Slash Separator", async () => { u: new Float32Array([1, 0, 0]), v: new Float32Array([0, 1, 0]), }, + context.beginStroke(), ); const chunkKey = `${BUCKET}/data.zarr/0/0/0`; From 13034308239182e97653989492df2a0eac23cade Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Fri, 17 Jul 2026 15:32:45 +0200 Subject: [PATCH 236/251] perf(chunk): replace reload-time queue drain with receipt-seq causality on fresh-chunk listeners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voxel reload RPCs drained the entire pending chunk-update queue synchronously (flushPendingChunkUpdates) before arming fresh-chunk listeners, so that a stale refetch already queued could not trigger a freshly armed listener with pre-write data. That converted the queue's time-budgeted GPU upload work into synchronous stalls on every flush and cascade reload — a global remedy for a per-key ordering concern. Chunk updates are now stamped with a monotonic receipt seq as they arrive from the worker (queued or immediate). onNextFreshChunk records the seq current at arming, and firing only triggers listeners armed before the update that delivered the data; later-armed listeners stay armed for the next arrival. pendingFreshChunkGpu keeps the receipt of the update.new that delivered the data (not a later promotion update), since staleness is decided by when data was received, not when it reached the GPU. The drain in callChunkReload is removed: same causality guarantee, zero synchronous work at reload receipt, and the invariant no longer depends on queue-batching internals. --- src/chunk_manager/frontend.spec.ts | 72 +++++++++++++++++++++++ src/chunk_manager/frontend.ts | 83 +++++++++++++++++++-------- src/voxel_annotation/frontend.spec.ts | 21 ------- src/voxel_annotation/frontend.ts | 18 +++--- 4 files changed, 138 insertions(+), 56 deletions(-) create mode 100644 src/chunk_manager/frontend.spec.ts diff --git a/src/chunk_manager/frontend.spec.ts b/src/chunk_manager/frontend.spec.ts new file mode 100644 index 0000000000..8689be9d77 --- /dev/null +++ b/src/chunk_manager/frontend.spec.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ChunkSource } from "#src/chunk_manager/frontend.js"; + +describe("ChunkSource fresh-chunk listeners: receipt-seq causality", () => { + let chunkQueueManager: { chunkUpdateReceiptSeq: number }; + let source: ChunkSource; + + beforeEach(() => { + chunkQueueManager = { chunkUpdateReceiptSeq: 0 }; + source = new ChunkSource({ chunkQueueManager } as any); + }); + + it("fires a listener only for data received after it was armed", () => { + // An update is received (and possibly still queued), then the listener is + // armed: that update's data must not trigger it. + const staleReceipt = ++chunkQueueManager.chunkUpdateReceiptSeq; + const listener = vi.fn(); + source.onNextFreshChunk("0,0,0", listener); + + source.fireFreshChunkListeners("0,0,0", staleReceipt); + expect(listener).not.toHaveBeenCalled(); + + // A refetch received after arming fires it. + const freshReceipt = ++chunkQueueManager.chunkUpdateReceiptSeq; + source.fireFreshChunkListeners("0,0,0", freshReceipt); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("partitions listeners: pre-arming data fires old listeners, keeps new ones armed", () => { + const oldListener = vi.fn(); + source.onNextFreshChunk("0,0,0", oldListener); + + // Data received between the two armings. + const receipt = ++chunkQueueManager.chunkUpdateReceiptSeq; + const newListener = vi.fn(); + source.onNextFreshChunk("0,0,0", newListener); + + source.fireFreshChunkListeners("0,0,0", receipt); + expect(oldListener).toHaveBeenCalledTimes(1); + expect(newListener).not.toHaveBeenCalled(); + + // The kept listener fires on the next, newer arrival. + const nextReceipt = ++chunkQueueManager.chunkUpdateReceiptSeq; + source.fireFreshChunkListeners("0,0,0", nextReceipt); + expect(newListener).toHaveBeenCalledTimes(1); + expect(oldListener).toHaveBeenCalledTimes(1); + }); + + it("fired listeners are one-shot; keys with no remaining listeners are dropped", () => { + const listener = vi.fn(); + source.onNextFreshChunk("0,0,0", listener); + + const receipt = ++chunkQueueManager.chunkUpdateReceiptSeq; + source.fireFreshChunkListeners("0,0,0", receipt); + source.fireFreshChunkListeners("0,0,0", receipt); + + expect(listener).toHaveBeenCalledTimes(1); + expect(source.freshChunkListeners?.has("0,0,0")).toBe(false); + }); + + it("deleteChunk drops the key's listeners and pending-GPU entry", () => { + source.onNextFreshChunk("0,0,0", vi.fn()); + (source.pendingFreshChunkGpu ??= new Map()).set("0,0,0", 1); + // A resident chunk below GPU_MEMORY state, so deleteChunk needs no GL. + source.chunks.set("0,0,0", { state: 1, dispose: () => {} } as any); + + source.deleteChunk("0,0,0"); + + expect(source.freshChunkListeners?.has("0,0,0")).toBe(false); + expect(source.pendingFreshChunkGpu?.has("0,0,0")).toBe(false); + }); +}); diff --git a/src/chunk_manager/frontend.ts b/src/chunk_manager/frontend.ts index e3f0f6cd38..4dc2ce8646 100644 --- a/src/chunk_manager/frontend.ts +++ b/src/chunk_manager/frontend.ts @@ -103,6 +103,13 @@ export class ChunkQueueManager extends SharedObject { pendingChunkUpdates: any = null; pendingChunkUpdatesTail: any = null; + // Monotonic receipt seq stamped on every chunk update as it arrives from + // the worker (whether queued or applied immediately). Fresh-chunk listeners + // compare against it so that an update received before they were armed — + // e.g. a refetch predating the write they wait for, still sitting in the + // time-budgeted pending queue — cannot trigger them. + chunkUpdateReceiptSeq = 0; + /** * If non-null, deadline in milliseconds since epoch after which chunk copies to the GPU may not * start (until the next frame). @@ -273,9 +280,14 @@ export class ChunkQueueManager extends SharedObject { source.addChunk(key, chunk); // A real refetch arrived for this key: arm its fresh-chunk listeners so // they fire on this chunk's GPU arrival (below), not on some unrelated - // GPU transition of the stale chunk this one replaced. + // GPU transition of the stale chunk this one replaced. The update's + // receipt seq is kept so only listeners armed before this data was + // received end up firing. if (source.freshChunkListeners?.has(key)) { - (source.pendingFreshChunkGpu ??= new Set()).add(key); + (source.pendingFreshChunkGpu ??= new Map()).set( + key, + update.receipt, + ); } } else { chunk = source.chunks.get(key)!; @@ -316,17 +328,11 @@ export class ChunkQueueManager extends SharedObject { // ensures we fire for the fresh chunk, not for an eviction/re-promotion of // the stale chunk it replaced (which would drop the overlay before the // real data is rendered, re-introducing the upload-latency flicker). - if ( - newState === ChunkState.GPU_MEMORY && - source.pendingFreshChunkGpu?.has(key) - ) { - source.pendingFreshChunkGpu.delete(key); - const freshListeners = source.freshChunkListeners?.get(key); - if (freshListeners !== undefined) { - source.freshChunkListeners!.delete(key); - for (const listener of freshListeners) { - listener(); - } + if (newState === ChunkState.GPU_MEMORY) { + const dataReceipt = source.pendingFreshChunkGpu?.get(key); + if (dataReceipt !== undefined) { + source.pendingFreshChunkGpu!.delete(key); + source.fireFreshChunkListeners(key, dataReceipt); } } } @@ -363,6 +369,7 @@ function updateChunk(rpc: RPC, x: any) { ); } const queueManager = source.chunkManager.chunkQueueManager; + x.receipt = ++queueManager.chunkUpdateReceiptSeq; if (source.immediateChunkUpdates) { if (queueManager.applyChunkUpdate(x)) { queueManager.visibleChunksChanged.dispatch(); @@ -471,26 +478,54 @@ export class ChunkSource extends SharedObject { // One-shot listeners fired when fresh data (a `new` update) arrives for a key. // Unlike `chunkRequesters`, these only fire on a real refetch, not on any - // `<= SYSTEM_MEMORY` transition (e.g. GPU eviction). - freshChunkListeners: Map void)[]> | undefined; - - // Keys for which a real refetch (`update.new`) has been observed and whose - // `freshChunkListeners` must fire on the *next* GPU_MEMORY arrival. Arming only - // after `update.new` is what makes the firing specific to the refetched chunk: - // an eviction/re-promotion of the stale, lazily kept chunk never sets this, so - // it cannot fire the swap before the fresh data is actually rendered. - pendingFreshChunkGpu: Set | undefined; + // `<= SYSTEM_MEMORY` transition (e.g. GPU eviction). Each listener records + // the update-receipt seq current at arming: it only fires for data received + // from the worker after it was armed, so an older refetch still sitting in + // the time-budgeted pending-update queue when the listener is armed cannot + // trigger it. + freshChunkListeners: + | Map void; armedAt: number }[]> + | undefined; + + // Keys for which a real refetch (`update.new`) has been observed, mapped to + // that update's receipt seq, whose `freshChunkListeners` must fire on the + // *next* GPU_MEMORY arrival. Arming only after `update.new` is what makes + // the firing specific to the refetched chunk: an eviction/re-promotion of + // the stale, lazily kept chunk never sets this, so it cannot fire the swap + // before the fresh data is actually rendered. The recorded receipt is the + // update that delivered the data — not the later promotion update, if the + // GPU transition arrives separately — since data staleness is decided by + // when the data was received, not when it reached the GPU. + pendingFreshChunkGpu: Map | undefined; onNextFreshChunk(key: string, listener: () => void): void { let listeners = this.freshChunkListeners; if (listeners === undefined) { listeners = this.freshChunkListeners = new Map(); } + const armedAt = this.chunkManager.chunkQueueManager.chunkUpdateReceiptSeq; const entry = listeners.get(key); if (entry === undefined) { - listeners.set(key, [listener]); + listeners.set(key, [{ listener, armedAt }]); + } else { + entry.push({ listener, armedAt }); + } + } + + // Fires the listeners for `key` armed before the update (receipt + // `dataReceipt`) whose data just reached the GPU. Listeners armed after it + // are waiting for a newer refetch and stay armed for the next arrival. + fireFreshChunkListeners(key: string, dataReceipt: number): void { + const listeners = this.freshChunkListeners?.get(key); + if (listeners === undefined) return; + const remaining = listeners.filter((entry) => entry.armedAt >= dataReceipt); + if (remaining.length === 0) { + this.freshChunkListeners!.delete(key); } else { - entry.push(listener); + this.freshChunkListeners!.set(key, remaining); + } + for (const entry of listeners) { + if (entry.armedAt < dataReceipt) entry.listener(); } } diff --git a/src/voxel_annotation/frontend.spec.ts b/src/voxel_annotation/frontend.spec.ts index 06856e4a67..106faef9c4 100644 --- a/src/voxel_annotation/frontend.spec.ts +++ b/src/voxel_annotation/frontend.spec.ts @@ -12,10 +12,6 @@ const mockRpc = { delete: vi.fn(), } as unknown as RPC; -const mockChunkQueueManager = { - flushPendingChunkUpdates: vi.fn(), -}; - // Real chunk source mock: captures the one-shot fresh-chunk listeners so tests // can simulate the refetched chunk reaching the GPU at a chosen moment. function createRealSourceMock() { @@ -23,7 +19,6 @@ function createRealSourceMock() { return { rpcId: 1, spec: { chunkDataSize: new Uint32Array([2, 2, 2]) }, - chunkManager: { chunkQueueManager: mockChunkQueueManager }, invalidateChunks: vi.fn(), onNextFreshChunk: vi.fn((key: string, listener: () => void) => { const entry = freshChunkListeners.get(key); @@ -227,20 +222,4 @@ describe("VoxelEditController.callChunkReload: overlay clear coverage guard", () controller.rollbackStroke(seq); expect(overlaySources[0].invalidateChunks).not.toHaveBeenCalled(); }); - - it("drains the pending chunk-update queue before arming any listener", () => { - // A refetch predating the write may already sit in the frontend update - // queue when the reload RPC arrives; it must be applied before the new - // listener exists, so it can only reach older (coverage-guarded) - // listeners. - controller.callChunkReload([makeVoxChunkKey("0,0,0", 0)], false); - - expect(mockChunkQueueManager.flushPendingChunkUpdates).toHaveBeenCalled(); - const flushOrder = - mockChunkQueueManager.flushPendingChunkUpdates.mock - .invocationCallOrder[0]; - const armOrder = - realSources[0].onNextFreshChunk.mock.invocationCallOrder[0]; - expect(flushOrder).toBeLessThan(armOrder); - }); }); diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 6f451a7ba0..6c58ab8d5d 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -600,17 +600,13 @@ export class VoxelEditController extends SharedObject { const previewSources = this.host.previewSource?.getSources( this.getIdentitySliceViewSourceOptions(), )[0]; - // Worker chunk updates are queued and applied with a time budget, while - // this RPC runs at receipt: a refetch predating the write we are - // reloading for may already sit in that queue and would otherwise - // trigger the listeners armed below with pre-write data. Apply the - // queue now — stale updates can only reach previously armed listeners, - // each neutralized by its own coverage check. Combined with the - // backend cancelling in-flight downloads on write, the listeners armed - // below can only fire for refetches issued after the write. - sources - .find((s) => s?.chunkSource) - ?.chunkSource.chunkManager.chunkQueueManager.flushPendingChunkUpdates(); + // A refetch predating the write we are reloading for may still sit in + // the frontend's time-budgeted pending-update queue at this point; the + // receipt seq recorded by onNextFreshChunk at arming keeps such an + // update from triggering the listeners armed below (they only fire for + // data received after arming). Combined with the backend cancelling + // in-flight downloads on write, a fired listener's data is always from + // a refetch issued after the write it waits for. for (const voxKey of voxChunkKeys) { const parsed = parseVoxChunkKey(voxKey); if (!parsed) continue; From 6b8ee31ade88e2e996174a00b8c065d6d93d95ba Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Fri, 17 Jul 2026 15:52:51 +0200 Subject: [PATCH 237/251] refactor(voxel-annotation): resolve overlay swaps by observation, removing voxel hooks from the chunk manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The swap-on-arrival mechanism lived inside ChunkQueueManager / ChunkSource: per-key fresh-chunk listeners, a pending-GPU arming map and an update-receipt seq stamped on every chunk update, plus firing logic in applyChunkUpdate (~110 lines in the core chunk machinery). Core chunk logic is the last place a feature branch should carry code: the test suite's coverage cannot vouch for side effects there, and every touched line raises the review bar for merging. All of it is replaced by observation through existing public API. The controller keeps a pending-swap map (one entry per real chunk, overwritten by newer reloads) and scans it on visibleChunksChanged: an `update.new` always builds a fresh Chunk object, so object identity against the chunk recorded at arming detects the swap, and state === GPU_MEMORY detects display. The coverage guard is unchanged and still read at swap time. chunk_manager's total branch footprint is back to a single additive method — invalidateChunks with the lazy option (keep the frontend chunk on display while the backend cache is invalidated and refetched) — with zero upstream lines modified. The single-tick texture handover moves to a VolumeChunkSource.addChunk override, which also fixes a latent leak: plain chunks.set over an existing GPU-resident chunk orphaned its texture. Known, accepted trade-off (documented at the arming site): without receipt stamping, a pre-write refetch still queued at arming time can resolve a swap with stale data. The window needs the update queue to lag behind RPC processing and heals within one round trip thanks to the backend cancelling in-flight downloads on write — a rare transient flicker in exchange for keeping the core hook-free. --- src/chunk_manager/frontend.spec.ts | 72 ----------------- src/chunk_manager/frontend.ts | 107 +------------------------- src/sliceview/volume/frontend.ts | 21 ++++- src/voxel_annotation/frontend.spec.ts | 76 ++++++++++++------ src/voxel_annotation/frontend.ts | 85 ++++++++++++++++---- 5 files changed, 142 insertions(+), 219 deletions(-) delete mode 100644 src/chunk_manager/frontend.spec.ts diff --git a/src/chunk_manager/frontend.spec.ts b/src/chunk_manager/frontend.spec.ts deleted file mode 100644 index 8689be9d77..0000000000 --- a/src/chunk_manager/frontend.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { ChunkSource } from "#src/chunk_manager/frontend.js"; - -describe("ChunkSource fresh-chunk listeners: receipt-seq causality", () => { - let chunkQueueManager: { chunkUpdateReceiptSeq: number }; - let source: ChunkSource; - - beforeEach(() => { - chunkQueueManager = { chunkUpdateReceiptSeq: 0 }; - source = new ChunkSource({ chunkQueueManager } as any); - }); - - it("fires a listener only for data received after it was armed", () => { - // An update is received (and possibly still queued), then the listener is - // armed: that update's data must not trigger it. - const staleReceipt = ++chunkQueueManager.chunkUpdateReceiptSeq; - const listener = vi.fn(); - source.onNextFreshChunk("0,0,0", listener); - - source.fireFreshChunkListeners("0,0,0", staleReceipt); - expect(listener).not.toHaveBeenCalled(); - - // A refetch received after arming fires it. - const freshReceipt = ++chunkQueueManager.chunkUpdateReceiptSeq; - source.fireFreshChunkListeners("0,0,0", freshReceipt); - expect(listener).toHaveBeenCalledTimes(1); - }); - - it("partitions listeners: pre-arming data fires old listeners, keeps new ones armed", () => { - const oldListener = vi.fn(); - source.onNextFreshChunk("0,0,0", oldListener); - - // Data received between the two armings. - const receipt = ++chunkQueueManager.chunkUpdateReceiptSeq; - const newListener = vi.fn(); - source.onNextFreshChunk("0,0,0", newListener); - - source.fireFreshChunkListeners("0,0,0", receipt); - expect(oldListener).toHaveBeenCalledTimes(1); - expect(newListener).not.toHaveBeenCalled(); - - // The kept listener fires on the next, newer arrival. - const nextReceipt = ++chunkQueueManager.chunkUpdateReceiptSeq; - source.fireFreshChunkListeners("0,0,0", nextReceipt); - expect(newListener).toHaveBeenCalledTimes(1); - expect(oldListener).toHaveBeenCalledTimes(1); - }); - - it("fired listeners are one-shot; keys with no remaining listeners are dropped", () => { - const listener = vi.fn(); - source.onNextFreshChunk("0,0,0", listener); - - const receipt = ++chunkQueueManager.chunkUpdateReceiptSeq; - source.fireFreshChunkListeners("0,0,0", receipt); - source.fireFreshChunkListeners("0,0,0", receipt); - - expect(listener).toHaveBeenCalledTimes(1); - expect(source.freshChunkListeners?.has("0,0,0")).toBe(false); - }); - - it("deleteChunk drops the key's listeners and pending-GPU entry", () => { - source.onNextFreshChunk("0,0,0", vi.fn()); - (source.pendingFreshChunkGpu ??= new Map()).set("0,0,0", 1); - // A resident chunk below GPU_MEMORY state, so deleteChunk needs no GL. - source.chunks.set("0,0,0", { state: 1, dispose: () => {} } as any); - - source.deleteChunk("0,0,0"); - - expect(source.freshChunkListeners?.has("0,0,0")).toBe(false); - expect(source.pendingFreshChunkGpu?.has("0,0,0")).toBe(false); - }); -}); diff --git a/src/chunk_manager/frontend.ts b/src/chunk_manager/frontend.ts index 4dc2ce8646..258e18eaf0 100644 --- a/src/chunk_manager/frontend.ts +++ b/src/chunk_manager/frontend.ts @@ -103,13 +103,6 @@ export class ChunkQueueManager extends SharedObject { pendingChunkUpdates: any = null; pendingChunkUpdatesTail: any = null; - // Monotonic receipt seq stamped on every chunk update as it arrives from - // the worker (whether queued or applied immediately). Fresh-chunk listeners - // compare against it so that an update received before they were armed — - // e.g. a refetch predating the write they wait for, still sitting in the - // time-budgeted pending queue — cannot trigger them. - chunkUpdateReceiptSeq = 0; - /** * If non-null, deadline in milliseconds since epoch after which chunk copies to the GPU may not * start (until the next frame). @@ -265,30 +258,8 @@ export class ChunkQueueManager extends SharedObject { let chunk: Chunk; const key = update.id; if (update.new) { - // After a lazy invalidation the previous chunk is still present; - // free its GPU texture right before it is replaced so the swap - // happens in a single tick with no intermediate frame. Inert in - // every non-lazy flow, where the key is absent here. - const existing = source.chunks.get(key); - if ( - existing !== undefined && - existing.state === ChunkState.GPU_MEMORY - ) { - existing.freeGPUMemory(this.gl); - } chunk = source.getChunk(update); source.addChunk(key, chunk); - // A real refetch arrived for this key: arm its fresh-chunk listeners so - // they fire on this chunk's GPU arrival (below), not on some unrelated - // GPU transition of the stale chunk this one replaced. The update's - // receipt seq is kept so only listeners armed before this data was - // received end up firing. - if (source.freshChunkListeners?.has(key)) { - (source.pendingFreshChunkGpu ??= new Map()).set( - key, - update.receipt, - ); - } } else { chunk = source.chunks.get(key)!; } @@ -322,19 +293,6 @@ export class ChunkQueueManager extends SharedObject { } } } - // Fire one-shot listeners only once the refetched chunk is actually on - // the GPU (displayed). Gating on `pendingFreshChunkGpu` — armed above when - // the `update.new` for this key arrived — rather than on GPU_MEMORY alone - // ensures we fire for the fresh chunk, not for an eviction/re-promotion of - // the stale chunk it replaced (which would drop the overlay before the - // real data is rendered, re-introducing the upload-latency flicker). - if (newState === ChunkState.GPU_MEMORY) { - const dataReceipt = source.pendingFreshChunkGpu?.get(key); - if (dataReceipt !== undefined) { - source.pendingFreshChunkGpu!.delete(key); - source.fireFreshChunkListeners(key, dataReceipt); - } - } } } return visibleChunksChanged; @@ -369,7 +327,6 @@ function updateChunk(rpc: RPC, x: any) { ); } const queueManager = source.chunkManager.chunkQueueManager; - x.receipt = ++queueManager.chunkUpdateReceiptSeq; if (source.immediateChunkUpdates) { if (queueManager.applyChunkUpdate(x)) { queueManager.visibleChunksChanged.dispatch(); @@ -476,59 +433,6 @@ export class ChunkSource extends SharedObject { chunkRequesters: Map | undefined; - // One-shot listeners fired when fresh data (a `new` update) arrives for a key. - // Unlike `chunkRequesters`, these only fire on a real refetch, not on any - // `<= SYSTEM_MEMORY` transition (e.g. GPU eviction). Each listener records - // the update-receipt seq current at arming: it only fires for data received - // from the worker after it was armed, so an older refetch still sitting in - // the time-budgeted pending-update queue when the listener is armed cannot - // trigger it. - freshChunkListeners: - | Map void; armedAt: number }[]> - | undefined; - - // Keys for which a real refetch (`update.new`) has been observed, mapped to - // that update's receipt seq, whose `freshChunkListeners` must fire on the - // *next* GPU_MEMORY arrival. Arming only after `update.new` is what makes - // the firing specific to the refetched chunk: an eviction/re-promotion of - // the stale, lazily kept chunk never sets this, so it cannot fire the swap - // before the fresh data is actually rendered. The recorded receipt is the - // update that delivered the data — not the later promotion update, if the - // GPU transition arrives separately — since data staleness is decided by - // when the data was received, not when it reached the GPU. - pendingFreshChunkGpu: Map | undefined; - - onNextFreshChunk(key: string, listener: () => void): void { - let listeners = this.freshChunkListeners; - if (listeners === undefined) { - listeners = this.freshChunkListeners = new Map(); - } - const armedAt = this.chunkManager.chunkQueueManager.chunkUpdateReceiptSeq; - const entry = listeners.get(key); - if (entry === undefined) { - listeners.set(key, [{ listener, armedAt }]); - } else { - entry.push({ listener, armedAt }); - } - } - - // Fires the listeners for `key` armed before the update (receipt - // `dataReceipt`) whose data just reached the GPU. Listeners armed after it - // are waiting for a newer refetch and stay armed for the next arrival. - fireFreshChunkListeners(key: string, dataReceipt: number): void { - const listeners = this.freshChunkListeners?.get(key); - if (listeners === undefined) return; - const remaining = listeners.filter((entry) => entry.armedAt >= dataReceipt); - if (remaining.length === 0) { - this.freshChunkListeners!.delete(key); - } else { - this.freshChunkListeners!.set(key, remaining); - } - for (const entry of listeners) { - if (entry.armedAt < dataReceipt) entry.listener(); - } - } - /** * If set to true, chunk updates will be applied to this source immediately, rather than queueing * them. Sources that dynamically update chunks and need to ensure a consistent order of @@ -558,17 +462,12 @@ export class ChunkSource extends SharedObject { chunk.freeGPUMemory(this.gl); } this.chunks.delete(key); - // The chunk is gone, so any fresh-chunk listener waiting on its GPU arrival - // will never fire — drop it (and its captured closure) instead of leaking. - this.freshChunkListeners?.delete(key); - this.pendingFreshChunkGpu?.delete(key); } invalidateChunks(keys: string[], options?: { lazy?: boolean }): void { - // When `lazy` is set, the existing GPU chunk is kept on display; the - // refetched data swaps it in place via `applyChunkUpdate`, avoiding the - // lower-resolution fallback flicker. The backend cache is invalidated - // either way. + // When `lazy` is set, the frontend chunk is kept on display while the + // backend cache is invalidated and refetched; the fresh data replaces it + // in place on arrival, avoiding the lower-resolution fallback flicker. const lazy = options?.lazy ?? false; const validKeys: string[] = []; for (const key of keys) { diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index 2bc021f9ca..ceb4a19b5b 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import type { ChunkManager } from "#src/chunk_manager/frontend.js"; +import { ChunkState } from "#src/chunk_manager/base.js"; +import type { Chunk, ChunkManager } from "#src/chunk_manager/frontend.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; import type { SliceViewChunkSpecification } from "#src/sliceview/base.js"; import { DataType } from "#src/sliceview/base.js"; @@ -200,6 +201,18 @@ export class VolumeChunkSource this.tempPositionWithinChunk = new Uint32Array(rank); } + addChunk(key: string, chunk: Chunk) { + // A `new` chunk update can replace a chunk kept on display by a lazy + // invalidation. Free the replaced chunk's texture right before the swap: + // it happens in a single tick with no intermediate frame, and the plain + // `chunks.set` would otherwise orphan the texture. + const existing = this.chunks.get(key); + if (existing !== undefined && existing.state === ChunkState.GPU_MEMORY) { + existing.freeGPUMemory(this.gl); + } + super.addChunk(key, chunk); + } + static encodeSpec(spec: SliceViewChunkSpecification) { const s = spec as VolumeChunkSpecification; return { @@ -336,9 +349,9 @@ export class InMemoryVolumeChunkSource extends VolumeChunkSource { // Signature matches the base `ChunkSource.invalidateChunks`, but `lazy` does // not apply here: an in-memory source has no backend refetch to swap in, so // there is nothing to keep the stale chunk on screen for. Deletion is always - // immediate; the crossfade with the real data is timed by the caller (overlay - // dropped on real-chunk arrival via onNextFreshChunk, or as a rollback on - // write failure), not by a blind delay here. + // immediate; the crossfade with the real data is timed by the caller (the + // overlay is dropped when its pending swap resolves, or as a rollback on + // write failure or an undispatched stroke), not by a blind delay here. const validKeys: string[] = []; for (const key of keys) { const chunk = this.chunks.get(key); diff --git a/src/voxel_annotation/frontend.spec.ts b/src/voxel_annotation/frontend.spec.ts index 106faef9c4..c2264c16c1 100644 --- a/src/voxel_annotation/frontend.spec.ts +++ b/src/voxel_annotation/frontend.spec.ts @@ -1,4 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ChunkState } from "#src/chunk_manager/base.js"; +import { NullarySignal } from "#src/util/signal.js"; import type { RPC } from "#src/worker_rpc.js"; import { makeVoxChunkKey } from "#src/voxel_annotation/base.js"; import { VoxelEditController } from "#src/voxel_annotation/frontend.js"; @@ -12,25 +14,17 @@ const mockRpc = { delete: vi.fn(), } as unknown as RPC; -// Real chunk source mock: captures the one-shot fresh-chunk listeners so tests -// can simulate the refetched chunk reaching the GPU at a chosen moment. +// Real chunk source mock: `fireFreshChunk` replaces the chunk with a new +// object in GPU_MEMORY state, like applyChunkUpdate does for an `update.new` +// followed by its GPU promotion. function createRealSourceMock() { - const freshChunkListeners = new Map void)[]>(); return { rpcId: 1, spec: { chunkDataSize: new Uint32Array([2, 2, 2]) }, + chunks: new Map(), invalidateChunks: vi.fn(), - onNextFreshChunk: vi.fn((key: string, listener: () => void) => { - const entry = freshChunkListeners.get(key); - if (entry === undefined) freshChunkListeners.set(key, [listener]); - else entry.push(listener); - }), - // Simulates the fresh chunk arriving on the GPU: fires and drops the - // armed listeners, like ChunkQueueManager.applyChunkUpdate does. - fireFreshChunk(key: string) { - const listeners = freshChunkListeners.get(key) ?? []; - freshChunkListeners.delete(key); - for (const listener of listeners) listener(); + fireFreshChunk(key: string, state = ChunkState.GPU_MEMORY) { + this.chunks.set(key, { state }); }, }; } @@ -51,17 +45,20 @@ function createOverlaySourceMock() { }; } -describe("VoxelEditController.callChunkReload: overlay clear coverage guard", () => { +describe("VoxelEditController.callChunkReload: overlay swap observation", () => { let realSources: ReturnType[]; let overlaySources: ReturnType[]; + let visibleChunksChanged: NullarySignal; let controller: VoxelEditController; beforeEach(() => { vi.clearAllMocks(); realSources = [createRealSourceMock(), createRealSourceMock()]; overlaySources = [createOverlaySourceMock(), createOverlaySourceMock()]; + visibleChunksChanged = new NullarySignal(); const makeMultiscale = (sources: unknown[]) => ({ rank: 3, + chunkManager: { chunkQueueManager: { visibleChunksChanged } }, getSources: () => [ sources.map((chunkSource) => ({ chunkSource, @@ -85,9 +82,11 @@ describe("VoxelEditController.callChunkReload: overlay clear coverage guard", () expect(controller.beginStroke()).toBe(3); }); - it("clears the overlay when the write covers the stroke that tagged it", () => { + it("clears the overlay once the refetched chunk replaces the stale one on the GPU", () => { const seq = controller.beginStroke(); overlaySources[0].setOverlaySeq("0,0,0", seq); + // The stale chunk is on display when the reload arrives. + realSources[0].fireFreshChunk("0,0,0"); const voxKey = makeVoxChunkKey("0,0,0", 0); controller.callChunkReload([voxKey], false, undefined, { [voxKey]: seq }); @@ -95,9 +94,32 @@ describe("VoxelEditController.callChunkReload: overlay clear coverage guard", () expect(realSources[0].invalidateChunks).toHaveBeenCalledWith(["0,0,0"], { lazy: true, }); + + // Signal fires while the stale chunk is still displayed: no clear. + visibleChunksChanged.dispatch(); expect(overlaySources[0].invalidateChunks).not.toHaveBeenCalled(); + // The refetched chunk (a new object) reaches the GPU. realSources[0].fireFreshChunk("0,0,0"); + visibleChunksChanged.dispatch(); + expect(overlaySources[0].invalidateChunks).toHaveBeenCalledWith(["0,0,0"]); + }); + + it("waits until the refetched chunk actually reaches the GPU", () => { + const seq = controller.beginStroke(); + overlaySources[0].setOverlaySeq("0,0,0", seq); + + const voxKey = makeVoxChunkKey("0,0,0", 0); + controller.callChunkReload([voxKey], false, undefined, { [voxKey]: seq }); + + // Refetched data arrived in system memory only: keep the overlay. + realSources[0].fireFreshChunk("0,0,0", ChunkState.SYSTEM_MEMORY); + visibleChunksChanged.dispatch(); + expect(overlaySources[0].invalidateChunks).not.toHaveBeenCalled(); + + // Promotion to the GPU resolves the swap. + realSources[0].chunks.get("0,0,0")!.state = ChunkState.GPU_MEMORY; + visibleChunksChanged.dispatch(); expect(overlaySources[0].invalidateChunks).toHaveBeenCalledWith(["0,0,0"]); }); @@ -110,26 +132,29 @@ describe("VoxelEditController.callChunkReload: overlay clear coverage guard", () const voxKey = makeVoxChunkKey("0,0,0", 0); controller.callChunkReload([voxKey], false, undefined, { [voxKey]: 1 }); realSources[0].fireFreshChunk("0,0,0"); + visibleChunksChanged.dispatch(); expect(overlaySources[0].invalidateChunks).not.toHaveBeenCalled(); // Stroke 2's own flush covers seq 2: its reload performs the clear. controller.callChunkReload([voxKey], false, undefined, { [voxKey]: 2 }); realSources[0].fireFreshChunk("0,0,0"); + visibleChunksChanged.dispatch(); expect(overlaySources[0].invalidateChunks).toHaveBeenCalledWith(["0,0,0"]); }); - it("reads the tag at fire time: a stroke touching the chunk after arming blocks the clear", () => { + it("reads the tag at swap time: a stroke touching the chunk after arming blocks the clear", () => { const seq1 = controller.beginStroke(); overlaySources[0].setOverlaySeq("0,0,0", seq1); const voxKey = makeVoxChunkKey("0,0,0", 0); controller.callChunkReload([voxKey], false, undefined, { [voxKey]: seq1 }); - // A new stroke's preview touches the chunk between arming and arrival: + // A new stroke's preview touches the chunk before the refetch lands: // the arriving data cannot contain it. const seq2 = controller.beginStroke(); overlaySources[0].setOverlaySeq("0,0,0", seq2); realSources[0].fireFreshChunk("0,0,0"); + visibleChunksChanged.dispatch(); expect(overlaySources[0].invalidateChunks).not.toHaveBeenCalled(); }); @@ -141,6 +166,7 @@ describe("VoxelEditController.callChunkReload: overlay clear coverage guard", () controller.callChunkReload([makeVoxChunkKey("0,0,0", 0)], false); realSources[0].fireFreshChunk("0,0,0"); + visibleChunksChanged.dispatch(); expect(overlaySources[0].invalidateChunks).not.toHaveBeenCalled(); }); @@ -148,6 +174,7 @@ describe("VoxelEditController.callChunkReload: overlay clear coverage guard", () it("clears without coverage info when no stroke ever tagged the chunk", () => { controller.callChunkReload([makeVoxChunkKey("0,0,0", 0)], false); realSources[0].fireFreshChunk("0,0,0"); + visibleChunksChanged.dispatch(); expect(overlaySources[0].invalidateChunks).toHaveBeenCalledWith(["0,0,0"]); }); @@ -170,6 +197,7 @@ describe("VoxelEditController.callChunkReload: overlay clear coverage guard", () lazy: true, }); realSources[1].fireFreshChunk("0,0,0"); + visibleChunksChanged.dispatch(); expect(overlaySources[0].invalidateChunks).not.toHaveBeenCalled(); // Cascade re-run after stroke 2's flush covers seq 2. @@ -180,14 +208,14 @@ describe("VoxelEditController.callChunkReload: overlay clear coverage guard", () { [parentKey]: 2 }, ); realSources[1].fireFreshChunk("0,0,0"); + visibleChunksChanged.dispatch(); expect(overlaySources[0].invalidateChunks).toHaveBeenCalledWith(["1,2,3"]); }); - it("stale listeners from an earlier flush self-neutralize on a single arrival", () => { - // Two reloads armed on the same key before any refetch arrives (covered 1 - // then 2, tag at 2). Both listeners fire on the single arrival: the stale - // one skips, the up-to-date one clears (and drops the tag), leaving the - // firing order irrelevant. + it("a newer reload overwrites the pending swap for the same chunk", () => { + // Two reloads arm before any refetch arrives (covered 1 then 2, tag at + // 2): only the newest entry remains, so the single arrival clears once, + // with the newest coverage. controller.beginStroke(); const seq2 = controller.beginStroke(); overlaySources[0].setOverlaySeq("0,0,0", seq2); @@ -197,6 +225,8 @@ describe("VoxelEditController.callChunkReload: overlay clear coverage guard", () controller.callChunkReload([voxKey], false, undefined, { [voxKey]: 2 }); realSources[0].fireFreshChunk("0,0,0"); + visibleChunksChanged.dispatch(); + visibleChunksChanged.dispatch(); expect(overlaySources[0].invalidateChunks).toHaveBeenCalledTimes(1); expect(overlaySources[0].invalidateChunks).toHaveBeenCalledWith(["0,0,0"]); }); diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 6c58ab8d5d..7563cacc4e 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { ChunkState } from "#src/chunk_manager/base.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; import { SharedWatchableValue } from "#src/shared_watchable_value.js"; import type { @@ -60,6 +61,46 @@ export class VoxelEditController extends SharedObject { // backend. private dispatchSeq = 0; + // Overlay swaps awaiting their refetched real chunk, keyed by real vox + // chunk key and resolved by observing visibleChunksChanged: the real + // chunk's object identity distinguishes the lazily kept stale chunk + // (recorded at arming) from the refetched one, since a `new` chunk update + // always builds a fresh Chunk object. A newer reload for the same chunk + // overwrites its entry, so the map never grows beyond the set of chunks + // awaiting a swap. + private pendingOverlaySwaps = new Map< + string, + { + source: VolumeChunkSource; + chunkKey: string; + staleChunk: unknown; + overlaySource: InMemoryVolumeChunkSource; + overlayChunkKey: string; + coveredSeq: number; + } + >(); + + private processPendingOverlaySwaps(): void { + if (this.pendingOverlaySwaps.size === 0) return; + for (const [voxKey, swap] of this.pendingOverlaySwaps) { + const chunk = swap.source.chunks.get(swap.chunkKey); + // Still the stale chunk (or gone): the refetch has not landed yet. + if (chunk === undefined || chunk === swap.staleChunk) continue; + // Refetched but not yet displayed: keep waiting. + if (chunk.state !== ChunkState.GPU_MEMORY) continue; + this.pendingOverlaySwaps.delete(voxKey); + // The overlay tag is read now, at swap time: a stroke that touched the + // chunk since arming raised it above coveredSeq, keeping the overlay on + // screen; the covering write's own reload re-arms the swap. + if ( + swap.coveredSeq < swap.overlaySource.getOverlaySeq(swap.overlayChunkKey) + ) { + continue; + } + swap.overlaySource.invalidateChunks([swap.overlayChunkKey]); + } + } + // Allocates a stroke's seq before its first preview. Previews tag the // overlay chunks they touch with it and the dispatch carries the same // value, so what the overlay shows and what the write covers cannot @@ -125,6 +166,16 @@ export class VoxelEditController extends SharedObject { resolutions, pendingOpCount: this.pendingOpCount.rpcId, }); + + // Pending overlay swaps are resolved by observing chunk changes rather + // than by hooks inside the chunk manager: the signal fires after every + // applied batch of chunk updates, and the check below is a cheap scan of + // the (small) pending map. + this.registerDisposer( + this.host.primarySource.chunkManager.chunkQueueManager.visibleChunksChanged.add( + () => this.processPendingOverlaySwaps(), + ), + ); } private async dispatchOperation(operation: VoxelOperation) { @@ -600,13 +651,15 @@ export class VoxelEditController extends SharedObject { const previewSources = this.host.previewSource?.getSources( this.getIdentitySliceViewSourceOptions(), )[0]; - // A refetch predating the write we are reloading for may still sit in - // the frontend's time-budgeted pending-update queue at this point; the - // receipt seq recorded by onNextFreshChunk at arming keeps such an - // update from triggering the listeners armed below (they only fire for - // data received after arming). Combined with the backend cancelling - // in-flight downloads on write, a fired listener's data is always from - // a refetch issued after the write it waits for. + // Known, accepted race: a refetch predating the write we are reloading + // for may still sit in the frontend's pending-update queue at arming + // time; its arrival is indistinguishable from the fresh one and can + // clear the overlay over pre-write data. The window requires the queue + // to lag behind RPC processing (heavy load only), and the backend's + // in-flight download cancellation on write guarantees a correct + // refetch follows within one round trip, so the effect is a rare, + // self-healing flicker — the trade-off for keeping the chunk manager + // free of voxel-specific hooks. for (const voxKey of voxChunkKeys) { const parsed = parseVoxChunkKey(voxKey); if (!parsed) continue; @@ -625,22 +678,22 @@ export class VoxelEditController extends SharedObject { | undefined) : undefined; if (overlaySource) { - const overlayChunkKey = overlayParsed!.chunkKey; // The backend echoes, per reloaded chunk, the highest stroke seq // its write covers. The overlay chunk carries the seq of the last // stroke whose preview touched it (including a stroke still under // the mouse — its seq is allocated before its first preview), read - // at fire time. Clearing only when coverage reaches that tag - // guarantees the arriving data contains everything the overlay + // when the swap resolves. Clearing only when coverage reaches that + // tag guarantees the arriving data contains everything the overlay // shows. A skipped clear is re-armed by the covering write's own // reload; a stroke that never gets written is rolled back // explicitly (rollbackStroke) instead of waited for. - const coveredSeq = coveredSeqs?.[voxKey] ?? 0; - source.onNextFreshChunk(chunkKey, () => { - if (coveredSeq < overlaySource.getOverlaySeq(overlayChunkKey)) { - return; - } - overlaySource.invalidateChunks([overlayChunkKey]); + this.pendingOverlaySwaps.set(voxKey, { + source, + chunkKey, + staleChunk: source.chunks.get(chunkKey), + overlaySource, + overlayChunkKey: overlayParsed!.chunkKey, + coveredSeq: coveredSeqs?.[voxKey] ?? 0, }); } let arr = chunksToInvalidateBySource.get(source); From 58523e0834a4a7e7db99f0d7c3f1590e472fb38b Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Fri, 17 Jul 2026 16:12:03 +0200 Subject: [PATCH 238/251] fix(voxel-annotation): small review findings - Exclude failed keys from the flush's real-chunk reload: their store was not modified, so reloading refetched unchanged data and raced with the failure rollback. - Fix the downsample benchmark's downsampleStep call, left at the old single-argument arity behind an `as any` cast; it no longer represented the real cascade. - Deduplicate the RPC record guards in the VOX_RELOAD_CHUNKS handler (asRecordOrUndefined) and the two processBackendEdits call sites in performBrush. --- src/voxel_annotation/backend.ts | 32 +++++++++---------- src/voxel_annotation/frontend.ts | 21 ++++++------ .../staminaCalibration.benchmark.ts | 3 +- 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index d2e4f303d6..d103be2d1f 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -580,12 +580,20 @@ export class VoxelEditController extends SharedObject { } } - const flushedKeys = editsByVoxKey.keys().toArray(); + // Failed keys are excluded: their store was not modified, so a real + // reload would refetch unchanged data and race with the failure + // rollback below. + const flushedKeys = editsByVoxKey + .keys() + .toArray() + .filter((voxKey) => !failedVoxChunkKeys.includes(voxKey)); const coveredSeqs: Record = {}; for (const voxKey of flushedKeys) { coveredSeqs[voxKey] = this.lastFlushedSeq.get(voxKey) ?? 0; } - this.callChunkReload(flushedKeys, false, undefined, coveredSeqs); + if (flushedKeys.length > 0) { + this.callChunkReload(flushedKeys, false, undefined, coveredSeqs); + } if (newAction.changes.size > 0) { this.undoStack.push(newAction); @@ -1328,6 +1336,8 @@ export class VoxelEditController extends SharedObject { if (voxelCount === 0) continue; + let buffer: Int32Array = voxelBuffer; + let count = voxelCount; if (basis && shape === BrushShape.DISK) { const result = this.fillPlaneAliasingGaps( voxelBuffer, @@ -1335,22 +1345,10 @@ export class VoxelEditController extends SharedObject { basis, center, ); - this.processBackendEdits( - result.buffer, - result.count, - value, - sourceIndex, - seq, - ); - } else { - this.processBackendEdits( - voxelBuffer, - voxelCount, - value, - sourceIndex, - seq, - ); + buffer = result.buffer; + count = result.count; } + this.processBackendEdits(buffer, count, value, sourceIndex, seq); } } diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 7563cacc4e..4e23757586 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -768,18 +768,21 @@ export class VoxelEditController extends SharedObject { } } +function asRecordOrUndefined(x: unknown): Record | undefined { + return x !== null && typeof x === "object" + ? (x as Record) + : undefined; +} + registerRPC(VOX_RELOAD_CHUNKS_RPC_ID, function (x: any) { const obj = this.get(x.rpcId) as VoxelEditController; const keys: string[] = Array.isArray(x.voxChunkKeys) ? x.voxChunkKeys : []; - const overlayKeys: Record | undefined = - x.overlayKeysToClear !== null && typeof x.overlayKeysToClear === "object" - ? x.overlayKeysToClear - : undefined; - const coveredSeqs: Record | undefined = - x.coveredSeqs !== null && typeof x.coveredSeqs === "object" - ? x.coveredSeqs - : undefined; - obj.callChunkReload(keys, x.isForPreviewChunks, overlayKeys, coveredSeqs); + obj.callChunkReload( + keys, + x.isForPreviewChunks, + asRecordOrUndefined(x.overlayKeysToClear), + asRecordOrUndefined(x.coveredSeqs), + ); }); registerRPC(VOX_EDIT_FAILURE_RPC_ID, function (x: any) { diff --git a/src/voxel_annotation/staminaCalibration.benchmark.ts b/src/voxel_annotation/staminaCalibration.benchmark.ts index aaebdd24f8..db18c2e8f0 100644 --- a/src/voxel_annotation/staminaCalibration.benchmark.ts +++ b/src/voxel_annotation/staminaCalibration.benchmark.ts @@ -159,7 +159,8 @@ describe("Commit", () => { describe("Downsample", () => { bench(`inputVoxels=${CHUNK_SIZE ** 3}`, async () => { - await (controller as any).downsampleStep(makeVoxChunkKey("0,0,0", 0)); + const originKey = makeVoxChunkKey("0,0,0", 0); + await (controller as any).downsampleStep(originKey, originKey, 0); }); }); From 480608eced10668ad36d45d5bc7a2f02d7cc995d Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Sun, 19 Jul 2026 11:58:52 +0200 Subject: [PATCH 239/251] fix(voxel-annotation): clear overlays explicitly on undo/redo and prune flushed-seq entries Undo removes strokes from the store, so the coverage guard could never clear their overlays (no future data "contains" them): a quick undo after painting left a ghost stroke on screen until reload. Undone chunks now get an explicit overlay clear, like the write-failure rollback; the purged tags also neutralize the cascade reloads that follow. lastFlushedSeq entries are pruned once no cascade chain for their key is queued or running (the chain-start capture is the only reader that early pruning would break; a future flush recomputes a higher max from the globally monotonic dispatch seqs). The map is now bounded by cascades in flight instead of growing with every chunk ever edited. Also documents the accepted downsample-lock trade-off: the compute serializes with the write because applyEdits' network I/O dominates the lock anyway, and cross-parent parallelism is unaffected. --- src/voxel_annotation/backend.spec.ts | 39 +++++++++++++++++++++++++ src/voxel_annotation/backend.ts | 43 ++++++++++++++++++++++++---- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/src/voxel_annotation/backend.spec.ts b/src/voxel_annotation/backend.spec.ts index df33c6ce40..f9d7908a28 100644 --- a/src/voxel_annotation/backend.spec.ts +++ b/src/voxel_annotation/backend.spec.ts @@ -660,6 +660,39 @@ describe("VoxelEditController: Downsampling Integration", () => { ); }); + it("Coverage pruning: lastFlushedSeq entry dropped once the cascade completes", async () => { + setupIntegration(2); + const key = makeVoxChunkKey("0,0,0", 0); + + controller.commitVoxels([{ key, indices: [0], value: 1n, seq: 3 }]); + await (controller as any).flushPending(); + // Queued for cascade: still readable for the chain-start capture. + expect((controller as any).lastFlushedSeq.has(key)).toBe(true); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect((controller as any).lastFlushedSeq.has(key)).toBe(false); + }); + + it("Coverage pruning: a later flush still covers older overlay tags", async () => { + setupIntegration(2); + const key = makeVoxChunkKey("0,0,0", 0); + + controller.commitVoxels([{ key, indices: [0], value: 1n, seq: 3 }]); + await (controller as any).flushPending(); + await new Promise((resolve) => setTimeout(resolve, 0)); // cascade done, pruned + + // Dispatch seqs are globally monotonic: the recomputed coverage (7) + // exceeds anything an old tag could require. + controller.commitVoxels([{ key, indices: [1], value: 1n, seq: 7 }]); + await (controller as any).flushPending(); + expect((controller as any).callChunkReload).toHaveBeenCalledWith( + [key], + false, + undefined, + { [key]: 7 }, + ); + }); + it("Coverage echo: a chain claims its start-of-chain coverage even if a flush lands mid-chain", async () => { setupIntegration(3); const originKey = makeVoxChunkKey("0,0,0", 0); @@ -1036,6 +1069,12 @@ describe("VoxelEditController: Undo/Redo", () => { const undoCallArgs = mockSource0.applyEdits.mock.calls[0]; expect(undoCallArgs[2][0]).toBe(10n); + // Overlays are cleared explicitly (coverage cannot reason about undo), + // then the real chunks are reloaded. + expect((controller as any).callChunkReload).toHaveBeenCalledWith( + [key], + true, + ); expect((controller as any).callChunkReload).toHaveBeenCalledWith([key]); expect(mockRpc.invoke).toHaveBeenCalledWith( VOX_EDIT_HISTORY_UPDATE_RPC_ID, diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index d103be2d1f..f7e9f6911f 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -388,7 +388,21 @@ export class VoxelEditController extends SharedObject { // durably written to that chunk. Echoed in reload messages (including // downsample cascade reloads, keyed by origin) so the frontend can tell // whether the refetched data covers everything its overlay represents. + // Pruned via maybePruneFlushedSeq: bounded by the cascades in flight. private lastFlushedSeq = new Map(); + + // Running cascade chains per origin (a counter: re-enqueueing can put two + // chains in flight). + private activeChainCounts = new Map(); + + // A future flush recomputes a higher max (dispatch seqs are globally + // monotonic); the only reader early pruning breaks is a cascade capturing + // at chain start — hence the two guards. + private maybePruneFlushedSeq(key: string): void { + if (this.activeChainCounts.has(key)) return; + if (this.downsampleQueueSet.has(key)) return; + this.lastFlushedSeq.delete(key); + } public pendingOpCount: SharedWatchableValue; private updatePendingCount() { @@ -625,6 +639,11 @@ export class VoxelEditController extends SharedObject { // callChunkReload above (not here): on the no-downsampling path the max-res // overlay is dropped when the refetched real chunk arrives. + // With downsampling, the queue guard defers pruning to the chain-end hook. + for (const voxKey of editsByVoxKey.keys()) { + this.maybePruneFlushedSeq(voxKey); + } + this.updatePendingCount(); } @@ -723,9 +742,17 @@ export class VoxelEditController extends SharedObject { const key = this.downsampleQueue.shift() as string; this.downsampleQueueSet.delete(key); this.activeDownsamples++; + this.activeChainCounts.set( + key, + (this.activeChainCounts.get(key) ?? 0) + 1, + ); this.processDownsampleChain(key).finally(() => { this.activeDownsamples--; + const remaining = (this.activeChainCounts.get(key) ?? 1) - 1; + if (remaining <= 0) this.activeChainCounts.delete(key); + else this.activeChainCounts.set(key, remaining); + this.maybePruneFlushedSeq(key); scheduleNext(); }); } @@ -813,10 +840,11 @@ export class VoxelEditController extends SharedObject { // The child read and the parent-update computation must run under the // parent lock along with the write: with only the write serialized, two - // concurrent chains (e.g. successive flushes of the same origin) can - // compute from reads taken at different times and land in the wrong - // order, durably overwriting the fresher parent with a stale result. - // Inside the lock, the later writer computed from the later read. + // concurrent chains can compute from reads taken at different times and + // land in the wrong order, durably overwriting the fresher parent with a + // stale result. Serializing the compute alongside is accepted: + // applyEdits' network I/O dominates this lock anyway, and parallelism + // across distinct parents is unaffected. return this.withChunkLock(parentKey, async () => { const childAccessor = this.getAccessor(childInfo.lodIndex); const childCtx = await childAccessor.getOrLoadChunkContext( @@ -1210,13 +1238,18 @@ export class VoxelEditController extends SharedObject { } if (chunksToReload.size > 0 && success) { + const keys = Array.from(chunksToReload); + // Coverage can never clear these overlays (undo removes their strokes + // from the store): clear explicitly, like the write-failure rollback. + // Purging their tags also neutralizes the cascade reloads below. + this.callChunkReload(keys, true); const hasDownsampling = this.resolutions.size > 1; if (hasDownsampling) { for (const key of chunksToReload) { this.enqueueDownsample(key); } } - this.callChunkReload(Array.from(chunksToReload)); + this.callChunkReload(keys); } this.notifyHistoryChanged(); From 0655699e01308689fcbe3ef74c13afb82f7c8bea Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Sun, 19 Jul 2026 12:41:06 +0200 Subject: [PATCH 240/251] fix(voxel-annotation): self-correcting accessor loads and flush-first discrete operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A context load resolving after a write+invalidate re-cached pre-write data unconditionally, poisoning the accessor's per-voxel cache until the next write or LRU eviction: a flood fill or locked erase started right after a stroke could then walk stale values (e.g. fill straight through a fresh stroke). invalidate now bumps a per-key load generation instead of dropping the pending load; the load loops and reloads when its generation moved, so callers keep sharing one self-correcting promise. Ordering operations alone could not fix this: downsample cascades stay deliberately concurrent with flushes. Discrete operations (undo/redo, flood fill) now start with flushBefore: wait out the in-flight flush (now tracked in currentFlush), cancel the debounce timer and flush what is still pending. Undo previously called flushPending directly, which misses a flush already in flight — undoing right after two strokes could pop the first stroke's action while the second was mid-flush, reverting shared voxels and corrupting the stack. Undo now targets the latest stroke, and a fill launched within the debounce window sees the stroke it follows. The brush path stays free-running (ordered by seq coverage). --- src/voxel_annotation/backend.spec.ts | 103 +++++++++++++++++++++++++++ src/voxel_annotation/backend.ts | 61 ++++++++++++---- 2 files changed, 151 insertions(+), 13 deletions(-) diff --git a/src/voxel_annotation/backend.spec.ts b/src/voxel_annotation/backend.spec.ts index f9d7908a28..36c821a5ce 100644 --- a/src/voxel_annotation/backend.spec.ts +++ b/src/voxel_annotation/backend.spec.ts @@ -1040,6 +1040,34 @@ describe("VoxelEditController: Undo/Redo", () => { (mockRpc.invoke as any).mockClear(); }); + it("Undo flushes pending edits first, so it targets the latest stroke", async () => { + const key = makeVoxChunkKey("0,0,0", 0); + const action1 = { + changes: new Map([ + [ + key, + { + indices: new Uint32Array([0]), + oldValues: new BigUint64Array([0n]), + newValues: new BigUint64Array([10n]), + }, + ], + ]), + timestamp: Date.now(), + description: "stroke 1", + }; + (controller as any).undoStack.push(action1); + + // A second stroke is still pending (debounce not fired) when undo runs: + // it must be flushed and become the undo target, not stroke 1. + controller.commitVoxels([{ key, indices: [1], value: 42n, seq: 2 }]); + await controller.undo(); + + expect((controller as any).undoStack).toEqual([action1]); + expect((controller as any).redoStack.length).toBe(1); + expect((controller as any).redoStack[0]).not.toBe(action1); + }); + it("Successful Undo and Redo Lifecycle", async () => { const key = makeVoxChunkKey("0,0,0", 0); const editAction = { @@ -1567,3 +1595,78 @@ describe("VolumeChunkSource.applyEdits: unreadable stored chunk", () => { ); }); }); + +describe("BackendVoxelAccessor: mid-flight invalidation", () => { + let controller: VoxelEditController; + let source: any; + + beforeEach(() => { + vi.clearAllMocks(); + source = createMockSource(); + (mockRpc.get as any).mockImplementation((id: number) => { + if (id === 0) return mockChunkManager; + if (id === 100) return source; + if (id === 999) return { value: 0 }; + return null; + }); + controller = new VoxelEditController(mockRpc, { + resolutions: [resConfig(0, [1, 1, 1], [2, 2, 2])], + pendingOpCount: 999, + }); + }); + + it("a load in flight across a write does not re-cache pre-write data", async () => { + source.serverStorage.set("0,0,0", new Uint8Array(8).fill(1).buffer); + let release!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + source.download.mockImplementation(async (chunk: any) => { + // Snapshot at request time, deliver after the gate: simulates a read + // whose response predates a write that lands while it is in flight. + const buffer = source.serverStorage.get("0,0,0")!.slice(0); + await gate; + chunk.data = new Uint8Array(buffer); + }); + + const accessor = (controller as any).getAccessor(0); + const read = accessor.getValue(0, 0, 0); + + source.serverStorage.set("0,0,0", new Uint8Array(8).fill(9).buffer); + accessor.invalidate("0,0,0"); + release(); + + expect(await read).toBe(9n); + expect(await accessor.getValue(0, 0, 0)).toBe(9n); + }); + + it("callers keep sharing the in-flight load across an invalidation", async () => { + source.serverStorage.set("0,0,0", new Uint8Array(8).fill(1).buffer); + const accessor = (controller as any).getAccessor(0); + const p1 = accessor.getOrLoadChunkContext("0,0,0", 0, 0, 0); + accessor.invalidate("0,0,0"); + const p2 = accessor.getOrLoadChunkContext("0,0,0", 0, 0, 0); + expect(p2).toBe(p1); + await p1; + }); + + it("flood fill flushes pending edits before reading", async () => { + const key = makeVoxChunkKey("0,0,0", 0); + controller.commitVoxels([{ key, indices: [0], value: 5n, seq: 1 }]); + + // Fill value equals the seed's current value: early return right after + // the flush, keeping the test cheap. + await (controller as any).performOperation({ + type: VoxelOperationType.FLOOD_FILL, + seq: 2, + seed: new Float32Array([0, 0, 0]), + value: 5n, + maxVoxels: 8, + basis: { u: new Float32Array([1, 0, 0]), v: new Float32Array([0, 1, 0]) }, + }); + + expect(source.applyEdits).toHaveBeenCalledWith( + "0,0,0", + [0], + expect.arrayContaining([5n]), + ); + }); +}); diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index f7e9f6911f..17186ccd8e 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -135,9 +135,15 @@ class BackendVoxelAccessor { this.fillValue = typeof fv === "bigint" ? fv : BigInt(fv); } + // Bumped when a key is invalidated while its load is in flight, so the + // load reloads instead of re-caching pre-write data. Pruned on settle. + private loadGenerations = new Map(); + public invalidate(key: string) { + if (this.pendingLoads.has(key)) { + this.loadGenerations.set(key, (this.loadGenerations.get(key) ?? 0) + 1); + } this.chunkContexts.delete(key); - this.pendingLoads.delete(key); } async getValue(x: number, y: number, z: number): Promise { @@ -177,18 +183,27 @@ class BackendVoxelAccessor { let promise = this.pendingLoads.get(key); if (promise) return promise; - promise = this.loadChunkContext(key, cx, cy, cz).then((ctx) => { - if (this.chunkContexts.size >= this.MAX_CACHE_SIZE) { - const oldestKey = this.chunkContexts.keys().next().value; - if (oldestKey) { - this.chunkContexts.delete(oldestKey); + promise = (async () => { + try { + while (true) { + const generation = this.loadGenerations.get(key); + const ctx = await this.loadChunkContext(key, cx, cy, cz); + // Invalidated mid-load: the data may predate the write — reload. + if (this.loadGenerations.get(key) !== generation) continue; + if (this.chunkContexts.size >= this.MAX_CACHE_SIZE) { + const oldestKey = this.chunkContexts.keys().next().value; + if (oldestKey) { + this.chunkContexts.delete(oldestKey); + } + } + this.chunkContexts.set(key, ctx); + return ctx; } + } finally { + this.pendingLoads.delete(key); + this.loadGenerations.delete(key); } - - this.chunkContexts.set(key, ctx); - this.pendingLoads.delete(key); - return ctx; - }); + })(); this.pendingLoads.set(key, promise); return promise; } @@ -420,6 +435,23 @@ export class VoxelEditController extends SharedObject { private commitDebounceTimer: number | undefined; private readonly commitDebounceDelayMs: number = 300; + private currentFlush: Promise = Promise.resolve(); + + // Orders a discrete operation (undo/redo, flood fill) after every edit + // committed before it: waits out an in-flight flush, then flushes what is + // still pending. The brush path stays free-running (ordered by seq + // coverage). + private async flushBefore(): Promise { + await this.currentFlush; + if (this.commitDebounceTimer !== undefined) { + clearTimeout(this.commitDebounceTimer); + this.commitDebounceTimer = undefined; + } + if (this.pendingEdits.length > 0) { + this.currentFlush = this.flushPending(); + await this.currentFlush; + } + } // Undo/redo history private undoStack: EditAction[] = []; @@ -680,7 +712,7 @@ export class VoxelEditController extends SharedObject { if (this.commitDebounceTimer !== undefined) clearTimeout(this.commitDebounceTimer); this.commitDebounceTimer = setTimeout(() => { - void this.flushPending(); + this.currentFlush = this.flushPending(); }, this.commitDebounceDelayMs) as unknown as number; } @@ -1191,7 +1223,7 @@ export class VoxelEditController extends SharedObject { useOldValues: boolean, actionDescription: "undo" | "redo", ): Promise { - await this.flushPending(); + await this.flushBefore(); if (sourceStack.length === 0) { throw new Error(`Nothing to ${actionDescription}.`); @@ -1386,6 +1418,9 @@ export class VoxelEditController extends SharedObject { } private async performFloodFill(op: FloodFillOperation): Promise { + // The fill walk reads the store, which does not see pending edits: flush + // them first so a fill right after a stroke sees that stroke. + await this.flushBefore(); const { seed, value: fillValue, maxVoxels, basis, filterValue, seq } = op; const sourceIndex = 0; const accessor = this.getAccessor(sourceIndex); From a2874ca9da432535c3260179bcbed7e6036395ea Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Sun, 19 Jul 2026 12:56:40 +0200 Subject: [PATCH 241/251] fix(voxel-annotation): serialize LOD-0 writers through a single exclusion chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flushBefore ordered a discrete operation after the edits committed before it, but nothing ordered edits after an operation in progress: a debounced flush could fire during an undo's sequential per-chunk writes (or two rapid undos could overlap), and two concurrent applyEdits on the same chunk are isolated read-modify-writes — the last one silently drops the other's voxels, leaving a half-corrupted stroke and capturing oldValues out of order. All LOD-0 writers now run through one promise chain (runExclusive): the debounced flush, undo/redo (whole body, including the pending flush), and flood fill's flush-first step. Brush commits stay a synchronous append; only their flush enters the chain. Cascades stay outside: they write LOD >= 1 under per-parent locks, their LOD-0 reads self-correct via the load generations, and the last LOD-0 writer enqueues its own cascade. Replaces currentFlush/flushBefore. Also documents the accepted partial-undo limitation: chunks reverted before a mid-undo failure stay reverted while the action returns to the stack. --- src/voxel_annotation/backend.spec.ts | 95 ++++++++++++++++++++++++++++ src/voxel_annotation/backend.ts | 58 ++++++++++++----- 2 files changed, 136 insertions(+), 17 deletions(-) diff --git a/src/voxel_annotation/backend.spec.ts b/src/voxel_annotation/backend.spec.ts index 36c821a5ce..2ad708c778 100644 --- a/src/voxel_annotation/backend.spec.ts +++ b/src/voxel_annotation/backend.spec.ts @@ -1040,6 +1040,101 @@ describe("VoxelEditController: Undo/Redo", () => { (mockRpc.invoke as any).mockClear(); }); + it("A flush triggered during an undo waits for the undo to finish", async () => { + const key = makeVoxChunkKey("0,0,0", 0); + const order: string[] = []; + let releaseUndo!: () => void; + const undoGate = new Promise((resolve) => (releaseUndo = resolve)); + mockSource0.applyEdits.mockImplementation(async () => { + if (order.length === 0) { + order.push("undo-start"); + await undoGate; + order.push("undo-end"); + } else { + order.push("flush"); + } + return { + indices: new Uint32Array([]), + oldValues: new BigUint64Array([]), + newValues: new BigUint64Array([]), + }; + }); + (controller as any).undoStack.push({ + changes: new Map([ + [ + key, + { + indices: new Uint32Array([0]), + oldValues: new BigUint64Array([0n]), + newValues: new BigUint64Array([10n]), + }, + ], + ]), + timestamp: Date.now(), + description: "stroke", + }); + + const undoDone = controller.undo(); + await new Promise((resolve) => setTimeout(resolve, 0)); + // A stroke committed mid-undo: its flush must queue behind the undo. + controller.commitVoxels([{ key, indices: [1], value: 42n, seq: 2 }]); + const flushDone = (controller as any).runExclusive(() => + (controller as any).flushPendingLocked(), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(order).toEqual(["undo-start"]); + + releaseUndo(); + await undoDone; + await flushDone; + expect(order).toEqual(["undo-start", "undo-end", "flush"]); + }); + + it("Concurrent undos are serialized and pop in order", async () => { + const key = makeVoxChunkKey("0,0,0", 0); + const makeAction = (label: string) => ({ + changes: new Map([ + [ + key, + { + indices: new Uint32Array([0]), + oldValues: new BigUint64Array([0n]), + newValues: new BigUint64Array([10n]), + }, + ], + ]), + timestamp: Date.now(), + description: label, + }); + const action1 = makeAction("stroke 1"); + const action2 = makeAction("stroke 2"); + (controller as any).undoStack.push(action1, action2); + + let releaseFirst!: () => void; + const firstGate = new Promise((resolve) => (releaseFirst = resolve)); + let calls = 0; + mockSource0.applyEdits.mockImplementation(async () => { + if (++calls === 1) await firstGate; + return { + indices: new Uint32Array([]), + oldValues: new BigUint64Array([]), + newValues: new BigUint64Array([]), + }; + }); + + const undo1 = controller.undo(); + const undo2 = controller.undo(); + await new Promise((resolve) => setTimeout(resolve, 0)); + // The second undo must not have started while the first is writing. + expect(calls).toBe(1); + + releaseFirst(); + await undo1; + await undo2; + expect((controller as any).undoStack.length).toBe(0); + expect((controller as any).redoStack).toEqual([action2, action1]); + }); + it("Undo flushes pending edits first, so it targets the latest stroke", async () => { const key = makeVoxChunkKey("0,0,0", 0); const action1 = { diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 17186ccd8e..988032556b 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -435,22 +435,26 @@ export class VoxelEditController extends SharedObject { private commitDebounceTimer: number | undefined; private readonly commitDebounceDelayMs: number = 300; - private currentFlush: Promise = Promise.resolve(); - - // Orders a discrete operation (undo/redo, flood fill) after every edit - // committed before it: waits out an in-flight flush, then flushes what is - // still pending. The brush path stays free-running (ordered by seq - // coverage). - private async flushBefore(): Promise { - await this.currentFlush; + // Serializes the LOD-0 writers (debounced flush, undo/redo): two + // concurrent applyEdits on the same chunk are isolated read-modify-writes, + // so the last one would silently drop the other's voxels. Brush commits + // stay a synchronous append; only their flush enters the chain. + private opChain: Promise = Promise.resolve(); + + private runExclusive(op: () => Promise): Promise { + const run = this.opChain.then(op, op); + this.opChain = run.catch(() => {}); + return run; + } + + // Flushes edits committed before a discrete operation. Caller must hold + // the exclusion chain. + private async flushPendingLocked(): Promise { if (this.commitDebounceTimer !== undefined) { clearTimeout(this.commitDebounceTimer); this.commitDebounceTimer = undefined; } - if (this.pendingEdits.length > 0) { - this.currentFlush = this.flushPending(); - await this.currentFlush; - } + if (this.pendingEdits.length > 0) await this.flushPending(); } // Undo/redo history @@ -712,7 +716,7 @@ export class VoxelEditController extends SharedObject { if (this.commitDebounceTimer !== undefined) clearTimeout(this.commitDebounceTimer); this.commitDebounceTimer = setTimeout(() => { - this.currentFlush = this.flushPending(); + void this.runExclusive(() => this.flushPending()); }, this.commitDebounceDelayMs) as unknown as number; } @@ -1217,13 +1221,29 @@ export class VoxelEditController extends SharedObject { }); } - private async performUndoRedo( + private performUndoRedo( + sourceStack: EditAction[], + targetStack: EditAction[], + useOldValues: boolean, + actionDescription: "undo" | "redo", + ): Promise { + return this.runExclusive(() => + this.performUndoRedoLocked( + sourceStack, + targetStack, + useOldValues, + actionDescription, + ), + ); + } + + private async performUndoRedoLocked( sourceStack: EditAction[], targetStack: EditAction[], useOldValues: boolean, actionDescription: "undo" | "redo", ): Promise { - await this.flushBefore(); + await this.flushPendingLocked(); if (sourceStack.length === 0) { throw new Error(`Nothing to ${actionDescription}.`); @@ -1266,6 +1286,9 @@ export class VoxelEditController extends SharedObject { if (success) { targetStack.push(action); } else { + // Known limitation: chunks reverted before the failure stay reverted + // while the action returns to the stack (no transactional rollback); + // the failure was already surfaced via VOX_EDIT_FAILURE. sourceStack.push(action); } @@ -1419,8 +1442,9 @@ export class VoxelEditController extends SharedObject { private async performFloodFill(op: FloodFillOperation): Promise { // The fill walk reads the store, which does not see pending edits: flush - // them first so a fill right after a stroke sees that stroke. - await this.flushBefore(); + // them first so a fill right after a stroke sees that stroke. The walk + // itself stays outside the chain (reads only; its edits flush later). + await this.runExclusive(() => this.flushPendingLocked()); const { seed, value: fillValue, maxVoxels, basis, filterValue, seq } = op; const sourceIndex = 0; const accessor = this.getAccessor(sourceIndex); From e1f0227b95cf773d51760f72ebe1f8238501be98 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Sun, 19 Jul 2026 16:53:29 +0200 Subject: [PATCH 242/251] fix(voxel-annotation): swap undone overlays on arrival instead of clearing them immediately Undo cleared overlays at once, revealing the pre-stroke chunk on the GPU; a refetch of the just-flushed stroke already sent by the worker then landed on top, making the undone stroke blink back for a round trip before the reverted data arrived. Near-deterministic when undoing right after painting, since flush-first guarantees a fresh write (and its refetch) precedes every undo. Undo/redo now emit a single reload marked isRollback: the frontend purges the overlay tags (an undone stroke's tag can never be covered by a future write) and arms a normal swap, so the overlay keeps showing the stroke until real data arrives and every revealed state is consistent with the previous one. Known edge: Ctrl+Z mid-brush-drag drops the in-progress preview on the rolled-back chunks until its dispatch rewrites it. --- src/sliceview/volume/frontend.ts | 4 ++++ src/voxel_annotation/backend.spec.ts | 20 ++++++++++++++++---- src/voxel_annotation/backend.ts | 13 ++++++++----- src/voxel_annotation/frontend.spec.ts | 18 ++++++++++++++++++ src/voxel_annotation/frontend.ts | 9 +++++++++ 5 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index ceb4a19b5b..d4c96411a2 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -316,6 +316,10 @@ export class InMemoryVolumeChunkSource extends VolumeChunkSource { return this.overlaySeqs.get(key) ?? 0; } + clearOverlaySeq(key: string): void { + this.overlaySeqs.delete(key); + } + keysWithOverlaySeq(seq: number): string[] { const keys: string[] = []; for (const [key, s] of this.overlaySeqs) { diff --git a/src/voxel_annotation/backend.spec.ts b/src/voxel_annotation/backend.spec.ts index 2ad708c778..96a58dfd58 100644 --- a/src/voxel_annotation/backend.spec.ts +++ b/src/voxel_annotation/backend.spec.ts @@ -1192,13 +1192,15 @@ describe("VoxelEditController: Undo/Redo", () => { const undoCallArgs = mockSource0.applyEdits.mock.calls[0]; expect(undoCallArgs[2][0]).toBe(10n); - // Overlays are cleared explicitly (coverage cannot reason about undo), - // then the real chunks are reloaded. + // A single rollback reload: the frontend purges the overlay tags and the + // swap clears on first arrival. expect((controller as any).callChunkReload).toHaveBeenCalledWith( [key], + false, + undefined, + undefined, true, ); - expect((controller as any).callChunkReload).toHaveBeenCalledWith([key]); expect(mockRpc.invoke).toHaveBeenCalledWith( VOX_EDIT_HISTORY_UPDATE_RPC_ID, expect.objectContaining({ undoCount: 0, redoCount: 1 }), @@ -1220,7 +1222,13 @@ describe("VoxelEditController: Undo/Redo", () => { const redoCallArgs = mockSource0.applyEdits.mock.calls[0]; expect(redoCallArgs[2][0]).toBe(20n); - expect((controller as any).callChunkReload).toHaveBeenCalledWith([key]); + expect((controller as any).callChunkReload).toHaveBeenCalledWith( + [key], + false, + undefined, + undefined, + true, + ); expect(mockRpc.invoke).toHaveBeenCalledWith( VOX_EDIT_HISTORY_UPDATE_RPC_ID, expect.objectContaining({ undoCount: 1, redoCount: 0 }), @@ -1356,6 +1364,10 @@ describe("VoxelEditController: Undo/Redo", () => { expect((controller as any).callChunkReload).toHaveBeenCalledWith( expect.arrayContaining([key1, key2]), + false, + undefined, + undefined, + true, ); expect((controller as any).undoStack.length).toBe(0); diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 988032556b..09fc012160 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -731,11 +731,14 @@ export class VoxelEditController extends SharedObject { // downsampled parents: the origin chunk's flushed seq at the time the child // was read). The frontend clears the matching overlay only if this covers // the last dispatched stroke that touched it. + // `isRollback` marks a state rollback (undo/redo): the frontend purges the + // overlay tags so the swap clears on the first arrival, whatever it covers. callChunkReload( voxChunkKeys: string[], isForPreviewChunks = false, overlayKeysToClear?: Record, coveredSeqs?: Record, + isRollback = false, ) { this.rpc?.invoke(VOX_RELOAD_CHUNKS_RPC_ID, { rpcId: this.rpcId, @@ -743,6 +746,7 @@ export class VoxelEditController extends SharedObject { isForPreviewChunks, overlayKeysToClear, coveredSeqs, + isRollback, }); } @@ -1294,17 +1298,16 @@ export class VoxelEditController extends SharedObject { if (chunksToReload.size > 0 && success) { const keys = Array.from(chunksToReload); - // Coverage can never clear these overlays (undo removes their strokes - // from the store): clear explicitly, like the write-failure rollback. - // Purging their tags also neutralizes the cascade reloads below. - this.callChunkReload(keys, true); const hasDownsampling = this.resolutions.size > 1; if (hasDownsampling) { for (const key of chunksToReload) { this.enqueueDownsample(key); } } - this.callChunkReload(keys); + // Rollback reload: the overlay keeps showing the undone strokes until + // real data arrives. Clearing immediately would reveal older data and + // make the stroke blink back when a pre-undo refetch lands. + this.callChunkReload(keys, false, undefined, undefined, true); } this.notifyHistoryChanged(); diff --git a/src/voxel_annotation/frontend.spec.ts b/src/voxel_annotation/frontend.spec.ts index c2264c16c1..61e8374be7 100644 --- a/src/voxel_annotation/frontend.spec.ts +++ b/src/voxel_annotation/frontend.spec.ts @@ -39,6 +39,7 @@ function createOverlaySourceMock() { getOverlaySeq: (key: string) => overlaySeqs.get(key) ?? 0, keysWithOverlaySeq: (seq: number) => [...overlaySeqs.entries()].filter(([, s]) => s === seq).map(([k]) => k), + clearOverlaySeq: (key: string) => overlaySeqs.delete(key), invalidateChunks: vi.fn((keys: string[]) => { for (const key of keys) overlaySeqs.delete(key); }), @@ -212,6 +213,23 @@ describe("VoxelEditController.callChunkReload: overlay swap observation", () => expect(overlaySources[0].invalidateChunks).toHaveBeenCalledWith(["1,2,3"]); }); + it("a rollback reload clears the overlay on first arrival regardless of tags", () => { + // An undone stroke's tag can never be covered by a future write; the + // rollback purges it so the swap resolves unconditionally. + overlaySources[0].setOverlaySeq("0,0,0", controller.beginStroke()); + + const voxKey = makeVoxChunkKey("0,0,0", 0); + controller.callChunkReload([voxKey], false, undefined, undefined, true); + + // Not cleared before data arrives: the overlay keeps showing the stroke. + visibleChunksChanged.dispatch(); + expect(overlaySources[0].invalidateChunks).not.toHaveBeenCalled(); + + realSources[0].fireFreshChunk("0,0,0"); + visibleChunksChanged.dispatch(); + expect(overlaySources[0].invalidateChunks).toHaveBeenCalledWith(["0,0,0"]); + }); + it("a newer reload overwrites the pending swap for the same chunk", () => { // Two reloads arm before any refetch arrives (covered 1 then 2, tag at // 2): only the newest entry remains, so the single arrival clears once, diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 4e23757586..46f00f9a54 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -616,6 +616,7 @@ export class VoxelEditController extends SharedObject { isForPreviewChunks: boolean, overlayKeysToClear?: Record, coveredSeqs?: Record, + isRollback = false, ) { if (!Array.isArray(voxChunkKeys) || voxChunkKeys.length === 0) return; const multiscaleSource = isForPreviewChunks @@ -687,6 +688,13 @@ export class VoxelEditController extends SharedObject { // shows. A skipped clear is re-armed by the covering write's own // reload; a stroke that never gets written is rolled back // explicitly (rollbackStroke) instead of waited for. + if (isRollback) { + // Undo/redo: whatever arrives next is the truth — purge the tag + // so the swap clears on first arrival. An in-progress stroke's + // chunk would lose its preview until its dispatch rewrites it + // (Ctrl+Z mid-drag, accepted). + overlaySource.clearOverlaySeq(overlayParsed!.chunkKey); + } this.pendingOverlaySwaps.set(voxKey, { source, chunkKey, @@ -782,6 +790,7 @@ registerRPC(VOX_RELOAD_CHUNKS_RPC_ID, function (x: any) { x.isForPreviewChunks, asRecordOrUndefined(x.overlayKeysToClear), asRecordOrUndefined(x.coveredSeqs), + x.isRollback === true, ); }); From 55b15bf697330f7b26128d7bb0149a252f378db3 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Sat, 8 Aug 2026 10:56:57 +0200 Subject: [PATCH 243/251] fix(voxel-annotation): reconcile stroke overlays against the backend's write coverage Operations now resolve with the vox chunk keys their write covers; the frontend drops any overlay chunk the preview tagged that is not in this set. A flood-fill or filtered-brush preview that overfilled on data the frontend lacked previously left ghost overlay chunks forever, since only backend-written chunks were ever reloaded. Brush voxels skipped because the store already holds the value count as covered: clearing their overlay would flash pre-write data until the prior write's own reload lands. --- src/voxel_annotation/backend.spec.ts | 91 +++++++++++++++++++++++++++ src/voxel_annotation/backend.ts | 64 +++++++++++++++---- src/voxel_annotation/frontend.spec.ts | 19 +++++- src/voxel_annotation/frontend.ts | 59 ++++++++++++----- 4 files changed, 204 insertions(+), 29 deletions(-) diff --git a/src/voxel_annotation/backend.spec.ts b/src/voxel_annotation/backend.spec.ts index 96a58dfd58..63844135e7 100644 --- a/src/voxel_annotation/backend.spec.ts +++ b/src/voxel_annotation/backend.spec.ts @@ -1645,6 +1645,97 @@ describe("VoxelEditController: Tool Operations", () => { expect(indices.length).toBeLessThan(100); expect(indices.length).toBeGreaterThan(50); }); + + it("performOperation: flood fill returns the covered vox chunk keys", async () => { + const data = new BigUint64Array(1000); + for (let x = 3; x <= 7; x++) { + data[0 * 100 + 3 * 10 + x] = 1n; + data[0 * 100 + 7 * 10 + x] = 1n; + } + for (let y = 3; y <= 7; y++) { + data[0 * 100 + y * 10 + 3] = 1n; + data[0 * 100 + y * 10 + 7] = 1n; + } + mockSource.serverStorage.set("0,0,0", data.buffer); + + const covered = await controller.performOperation({ + type: VoxelOperationType.FLOOD_FILL, + seed: new Float32Array([5, 5, 0]), + value: 5n, + maxVoxels: 100, + basis: { u: new Float32Array([1, 0, 0]), v: new Float32Array([0, 1, 0]) }, + }); + + expect(covered).toEqual(["lod0#0,0,0"]); + await vi.runAllTimersAsync(); + }); + + it("performOperation: refused flood fill returns empty coverage", async () => { + const data = new BigUint64Array(1000); + data[0 * 100 + 5 * 10 + 5] = 5n; + mockSource.serverStorage.set("0,0,0", data.buffer); + + const covered = await controller.performOperation({ + type: VoxelOperationType.FLOOD_FILL, + seed: new Float32Array([5, 5, 0]), + value: 5n, + maxVoxels: 100, + basis: { u: new Float32Array([1, 0, 0]), v: new Float32Array([0, 1, 0]) }, + }); + + expect(covered).toEqual([]); + expect(mockSource.applyEdits).not.toHaveBeenCalled(); + }); + + it("performOperation: brush returns the covered vox chunk keys", async () => { + const covered = await controller.performOperation({ + type: VoxelOperationType.BRUSH, + centers: [new Float32Array([5, 5, 5])], + radius: 3, + value: 5n, + shape: BrushShape.SPHERE, + basis: { u: new Float32Array([1, 0, 0]), v: new Float32Array([0, 1, 0]) }, + }); + + expect(covered).toEqual(["lod0#0,0,0"]); + await vi.runAllTimersAsync(); + }); + + it("performOperation: filtered brush counts already-written voxels as covered", async () => { + const data = new BigUint64Array(1000).fill(5n); + mockSource.serverStorage.set("0,0,0", data.buffer); + + const covered = await controller.performOperation({ + type: VoxelOperationType.BRUSH, + centers: [new Float32Array([5, 5, 5])], + radius: 3, + value: 5n, + shape: BrushShape.SPHERE, + basis: { u: new Float32Array([1, 0, 0]), v: new Float32Array([0, 1, 0]) }, + filterValue: 1n, + }); + + expect(covered).toEqual(["lod0#0,0,0"]); + expect(mockSource.applyEdits).not.toHaveBeenCalled(); + }); + + it("performOperation: filtered brush leaves filtered-out chunks uncovered", async () => { + const data = new BigUint64Array(1000).fill(7n); + mockSource.serverStorage.set("0,0,0", data.buffer); + + const covered = await controller.performOperation({ + type: VoxelOperationType.BRUSH, + centers: [new Float32Array([5, 5, 5])], + radius: 3, + value: 5n, + shape: BrushShape.SPHERE, + basis: { u: new Float32Array([1, 0, 0]), v: new Float32Array([0, 1, 0]) }, + filterValue: 1n, + }); + + expect(covered).toEqual([]); + expect(mockSource.applyEdits).not.toHaveBeenCalled(); + }); }); describe("VolumeChunkSource.applyEdits: unreadable stored chunk", () => { diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 09fc012160..c10733205c 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -1321,7 +1321,9 @@ export class VoxelEditController extends SharedObject { await this.performUndoRedo(this.redoStack, this.undoStack, false, "redo"); } - async performOperation(operation: VoxelOperation): Promise { + // Resolves with the vox chunk keys whose stored data will contain the + // operation's overlay content once its edits flush ("covered" chunks). + async performOperation(operation: VoxelOperation): Promise { switch (operation.type) { case VoxelOperationType.BRUSH: return this.performBrush(operation); @@ -1334,12 +1336,34 @@ export class VoxelEditController extends SharedObject { } } - private async performBrush(op: BrushOperation): Promise { + private async performBrush(op: BrushOperation): Promise { const { centers, radius, value, shape, basis, filterValue, seq } = op; const voxelSize = 1; // Hardcoded LOD 0 const sourceIndex = 0; const accessor = this.getAccessor(sourceIndex); + const covered = new Set(); + // Voxels skipped because the store already holds the brush value are + // covered without being rewritten: clearing their overlay would flash + // pre-write data until the prior write's own reload lands. + const coveredSpec = this.sources.get(sourceIndex)?.spec; + const csizeX = coveredSpec?.chunkDataSize[0] ?? 1; + const csizeY = coveredSpec?.chunkDataSize[1] ?? 1; + const csizeZ = coveredSpec?.chunkDataSize[2] ?? 1; + let lastCX = -Infinity; + let lastCY = -Infinity; + let lastCZ = -Infinity; + const markCovered = (x: number, y: number, z: number) => { + const cx = Math.floor(x / csizeX); + const cy = Math.floor(y / csizeY); + const cz = Math.floor(z / csizeZ); + if (cx === lastCX && cy === lastCY && cz === lastCZ) return; + lastCX = cx; + lastCY = cy; + lastCZ = cz; + covered.add(`lod${sourceIndex}#${cx},${cy},${cz}`); + }; + let r = Math.round(radius / voxelSize); if (r <= 0) throw new Error(`Brush radius must be positive.`); r -= 1; @@ -1369,6 +1393,7 @@ export class VoxelEditController extends SharedObject { const toAwait = new Set>(); const pushIf = (x: number, y: number, z: number) => { if (shouldSkip(x, y, z)) { + markCovered(x, y, z); return; } if (filterValue == undefined) { @@ -1378,8 +1403,11 @@ export class VoxelEditController extends SharedObject { toAwait.add( accessor.getValue(x, y, z).then((v) => { if (v == null) return; - if (v === value || (filterValue !== undefined && v !== filterValue)) + if (v === value) { + markCovered(x, y, z); return; + } + if (filterValue !== undefined && v !== filterValue) return; bufferEnqueue(x, y, z); }), ); @@ -1439,11 +1467,20 @@ export class VoxelEditController extends SharedObject { buffer = result.buffer; count = result.count; } - this.processBackendEdits(buffer, count, value, sourceIndex, seq); + for (const key of this.processBackendEdits( + buffer, + count, + value, + sourceIndex, + seq, + )) { + covered.add(key); + } } + return Array.from(covered); } - private async performFloodFill(op: FloodFillOperation): Promise { + private async performFloodFill(op: FloodFillOperation): Promise { // The fill walk reads the store, which does not see pending edits: flush // them first so a fill right after a stroke sees that stroke. The walk // itself stays outside the chain (reads only; its edits flush later). @@ -1459,9 +1496,9 @@ export class VoxelEditController extends SharedObject { startVoxelLod[2], ); - if (originalValue === null) return; - if (filterValue !== undefined && originalValue !== filterValue) return; - if (originalValue === fillValue) return; + if (originalValue === null) return []; + if (filterValue !== undefined && originalValue !== filterValue) return []; + if (originalValue === fillValue) return []; const visited = new Set(); const queue: [number, number][] = []; @@ -1601,7 +1638,7 @@ export class VoxelEditController extends SharedObject { basis, seed, ); - this.processBackendEdits( + return this.processBackendEdits( result.buffer, result.count, fillValue, @@ -1695,9 +1732,9 @@ export class VoxelEditController extends SharedObject { value: bigint, lodIndex: number, seq?: number, - ) { + ): string[] { const source = this.sources.get(lodIndex); - if (!source) return; + if (!source) return []; const { chunkDataSize } = source.spec; const indicesByVoxKey = new Map(); @@ -1751,6 +1788,7 @@ export class VoxelEditController extends SharedObject { backendEdits.push({ key: voxKey, indices, value, seq }); } this.commitVoxels(backendEdits); + return Array.from(indicesByVoxKey.keys()); } } @@ -1775,7 +1813,7 @@ registerPromiseRPC( VOX_EDIT_OPERATION_RPC_ID, async function (this: RPC, x: any) { const obj = this.get(x.rpcId) as VoxelEditController; - await obj.performOperation(x.operation); - return { value: undefined }; + const coveredVoxKeys = await obj.performOperation(x.operation); + return { value: coveredVoxKeys }; }, ); diff --git a/src/voxel_annotation/frontend.spec.ts b/src/voxel_annotation/frontend.spec.ts index 61e8374be7..a0649dc738 100644 --- a/src/voxel_annotation/frontend.spec.ts +++ b/src/voxel_annotation/frontend.spec.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { ChunkState } from "#src/chunk_manager/base.js"; import { NullarySignal } from "#src/util/signal.js"; -import type { RPC } from "#src/worker_rpc.js"; import { makeVoxChunkKey } from "#src/voxel_annotation/base.js"; import { VoxelEditController } from "#src/voxel_annotation/frontend.js"; +import type { RPC } from "#src/worker_rpc.js"; const mockRpc = { get: vi.fn(), @@ -64,7 +64,22 @@ describe("VoxelEditController.callChunkReload: overlay swap observation", () => sources.map((chunkSource) => ({ chunkSource, chunkToMultiscaleTransform: Float32Array.of( - ...[1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, ), })), ], diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 46f00f9a54..5ba6a621c4 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -109,16 +109,39 @@ export class VoxelEditController extends SharedObject { return ++this.dispatchSeq; } - // Drops the overlay chunks tagged by a stroke whose edits will never be - // written (stamina/permission refusal, empty stroke, dispatch failure): - // no reload would ever clear them. - rollbackStroke(seq: number): void { - const overlaySource = this.host.previewSource?.getSources( + private getOverlaySource(): InMemoryVolumeChunkSource | undefined { + return this.host.previewSource?.getSources( this.getIdentitySliceViewSourceOptions(), )[0]?.[0]?.chunkSource as InMemoryVolumeChunkSource | undefined; + } + + // For a stroke whose edits will never be written: stamina/permission + // refusal, empty stroke, dispatch failure. + rollbackStroke(seq: number): void { + this.reconcileStroke(seq, []); + } + + // Drops the overlay chunks a stroke tagged that its backend write does not + // cover: nothing will be written there, so no reload would ever clear them + // and the real data beneath is already correct. Chunks re-tagged by a newer + // stroke no longer match `seq` and are left untouched. + reconcileStroke(seq: number, coveredVoxKeys: string[]): void { + const overlaySource = this.getOverlaySource(); if (overlaySource === undefined) return; - const keys = overlaySource.keysWithOverlaySeq(seq); - if (keys.length > 0) overlaySource.invalidateChunks(keys); + const tagged = overlaySource.keysWithOverlaySeq(seq); + if (tagged.length === 0) return; + let stale = tagged; + if (coveredVoxKeys.length > 0) { + const covered = new Set(); + for (const voxKey of coveredVoxKeys) { + const parsed = parseVoxChunkKey(voxKey); + if (parsed !== null && parsed.lodIndex === 0) { + covered.add(parsed.chunkKey); + } + } + stale = tagged.filter((key) => !covered.has(key)); + } + if (stale.length > 0) overlaySource.invalidateChunks(stale); } constructor(private host: VoxelEditControllerHost) { @@ -178,12 +201,18 @@ export class VoxelEditController extends SharedObject { ); } - private async dispatchOperation(operation: VoxelOperation) { + private async dispatchOperation( + operation: VoxelOperation, + ): Promise { if (!this.rpc) throw new Error("RPC unavailable"); - await this.rpc.promiseInvoke(VOX_EDIT_OPERATION_RPC_ID, { - rpcId: this.rpcId, - operation, - }); + const coveredVoxKeys = await this.rpc.promiseInvoke( + VOX_EDIT_OPERATION_RPC_ID, + { + rpcId: this.rpcId, + operation, + }, + ); + return Array.isArray(coveredVoxKeys) ? coveredVoxKeys : []; } readonly singleChannelAccess: ChunkChannelAccessParameters = { @@ -437,7 +466,7 @@ export class VoxelEditController extends SharedObject { return; } const storageValue = valueGetter(false); - await this.dispatchOperation({ + const coveredVoxKeys = await this.dispatchOperation({ type: VoxelOperationType.BRUSH, seq, centers, @@ -447,6 +476,7 @@ export class VoxelEditController extends SharedObject { basis, filterValue, }); + this.reconcileStroke(seq, coveredVoxKeys); } async floodFillPlane2D( @@ -596,7 +626,7 @@ export class VoxelEditController extends SharedObject { const storageValue = fillValueGetter(false); try { - await this.dispatchOperation({ + const coveredVoxKeys = await this.dispatchOperation({ type: VoxelOperationType.FLOOD_FILL, seq, seed: startPositionCanonical, @@ -605,6 +635,7 @@ export class VoxelEditController extends SharedObject { basis, filterValue, }); + this.reconcileStroke(seq, coveredVoxKeys); } catch (e) { this.rollbackStroke(seq); throw e; From f4be9945cadc4fa8a02208ef3e1ac242a66f3e57 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Sat, 8 Aug 2026 11:11:35 +0200 Subject: [PATCH 244/251] feat(voxel-annotation): make flood-fill morphological gating optional New layer JSON key "floodFillMorphological" (default true). When disabled the backend fill skips the channel-thickness gating and runs a plain 4-connected walk, matching the frontend preview's logic. --- src/layer/voxel_annotation/index.ts | 13 +++++++++++ src/ui/voxel_annotations.ts | 1 + src/voxel_annotation/backend.spec.ts | 34 ++++++++++++++++++++++++++++ src/voxel_annotation/backend.ts | 2 ++ src/voxel_annotation/base.ts | 3 +++ src/voxel_annotation/frontend.ts | 3 +++ 6 files changed, 56 insertions(+) diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index e275bbe80a..e64890b78b 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -80,6 +80,7 @@ const BRUSH_SIZE_JSON_KEY = "brushSize"; const ERASE_SELECTED_MODE_JSON_KEY = "eraseSelectedMode"; const BRUSH_SHAPE_JSON_KEY = "brushShape"; const FLOOD_FILL_MAX_VOXELS_JSON_KEY = "floodFillMaxVoxels"; +const FLOOD_FILL_MORPHOLOGICAL_JSON_KEY = "floodFillMorphological"; const PAINT_VALUE_JSON_KEY = "paintValue"; const DATA_TYPE_BIT_INFO = { @@ -300,6 +301,7 @@ export class VoxelEditingContext maxVoxels: number, basis: { u: Float32Array; v: Float32Array }, filterValue?: bigint, + morphological = true, ) { if (!this._controller) throw new Error("Cannot use floodFillPlane2D without a controller"); @@ -311,6 +313,7 @@ export class VoxelEditingContext maxVoxels, basis, filterValue, + morphological, ), ); } @@ -489,6 +492,7 @@ export declare abstract class UserLayerWithVoxelEditing extends UserLayer { lockToSelectedValue: TrackableBoolean; brushShape: TrackableEnum; floodMaxVoxels: TrackableValue; + floodMorphological: TrackableBoolean; paintValue: TrackableValue; cursorInEraseMode: TrackableBoolean; @@ -529,6 +533,7 @@ export function UserLayerWithVoxelEditingMixin< lockToSelectedValue = new TrackableBoolean(false); brushShape = new TrackableEnum(BrushShape, BrushShape.DISK); floodMaxVoxels = new TrackableValue(10000, verifyFiniteFloat); + floodMorphological = new TrackableBoolean(true); cursorInEraseMode = new TrackableBoolean(false, false); private _isInEraseState = false; @@ -545,6 +550,7 @@ export function UserLayerWithVoxelEditingMixin< this.lockToSelectedValue.changed.add(this.specificationChanged.dispatch); this.brushShape.changed.add(this.specificationChanged.dispatch); this.floodMaxVoxels.changed.add(this.specificationChanged.dispatch); + this.floodMorphological.changed.add(this.specificationChanged.dispatch); this.paintValue.changed.add(this.specificationChanged.dispatch); this.bindOverlayToPanels(); @@ -709,6 +715,8 @@ export function UserLayerWithVoxelEditingMixin< json[ERASE_SELECTED_MODE_JSON_KEY] = this.lockToSelectedValue.toJSON(); json[BRUSH_SHAPE_JSON_KEY] = this.brushShape.toJSON(); json[FLOOD_FILL_MAX_VOXELS_JSON_KEY] = this.floodMaxVoxels.toJSON(); + json[FLOOD_FILL_MORPHOLOGICAL_JSON_KEY] = + this.floodMorphological.toJSON(); const pv = this.paintValue.toJSON(); json[PAINT_VALUE_JSON_KEY] = pv === undefined ? undefined : pv.toString(); return json; @@ -732,6 +740,11 @@ export function UserLayerWithVoxelEditingMixin< FLOOD_FILL_MAX_VOXELS_JSON_KEY, (v) => this.floodMaxVoxels.restoreState(v), ); + verifyOptionalObjectProperty( + specification, + FLOOD_FILL_MORPHOLOGICAL_JSON_KEY, + (v) => this.floodMorphological.restoreState(v), + ); verifyOptionalObjectProperty(specification, PAINT_VALUE_JSON_KEY, (v) => this.paintValue.restoreState(v), ); diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 4ce01c7263..071a5b6e16 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -287,6 +287,7 @@ abstract class BaseVoxelTool extends LayerTool { Math.floor(max), basis, filterValue, + this.layer.floodMorphological.value, ) .catch((e: any) => StatusMessage.showTemporaryMessage(String(e?.message ?? e)), diff --git a/src/voxel_annotation/backend.spec.ts b/src/voxel_annotation/backend.spec.ts index 63844135e7..6813340ec8 100644 --- a/src/voxel_annotation/backend.spec.ts +++ b/src/voxel_annotation/backend.spec.ts @@ -1646,6 +1646,40 @@ describe("VoxelEditController: Tool Operations", () => { expect(indices.length).toBeGreaterThan(50); }); + it("floodFillPlane2D: morphological=false fills through thin channels", async () => { + (controller as any).morphologicalConfig = { + growthThresholds: [{ count: 5, size: 3 }], + maxSize: 9, + }; + + const data = new BigUint64Array(1000); + // Closed box 0..9 at z=0, split by a wall column at x=5 with a 1-voxel + // hole at y=5. + for (let i = 0; i <= 9; i++) { + data[0 * 100 + 0 * 10 + i] = 1n; + data[0 * 100 + 9 * 10 + i] = 1n; + data[0 * 100 + i * 10 + 0] = 1n; + data[0 * 100 + i * 10 + 9] = 1n; + if (i !== 5) data[0 * 100 + i * 10 + 5] = 1n; + } + mockSource.serverStorage.set("0,0,0", data.buffer); + + await controller.performOperation({ + type: VoxelOperationType.FLOOD_FILL, + seed: new Float32Array([2, 5, 0]), + value: 2n, + maxVoxels: 1000, + basis: { u: new Float32Array([1, 0, 0]), v: new Float32Array([0, 1, 0]) }, + morphological: false, + }); + + await vi.runAllTimersAsync(); + + // Left chamber (4×8) + hole + right chamber (3×8). + const indices = (mockSource.applyEdits as any).mock.calls[0][1]; + expect(indices.length).toBe(57); + }); + it("performOperation: flood fill returns the covered vox chunk keys", async () => { const data = new BigUint64Array(1000); for (let x = 3; x <= 7; x++) { diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index c10733205c..ad284024dd 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -1486,6 +1486,7 @@ export class VoxelEditController extends SharedObject { // itself stays outside the chain (reads only; its edits flush later). await this.runExclusive(() => this.flushPendingLocked()); const { seed, value: fillValue, maxVoxels, basis, filterValue, seq } = op; + const morphological = op.morphological !== false; const sourceIndex = 0; const accessor = this.getAccessor(sourceIndex); @@ -1520,6 +1521,7 @@ export class VoxelEditController extends SharedObject { }; const getCurrentThickness = (): number => { + if (!morphological) return 1; let thickness = 1; for (const threshold of this.morphologicalConfig.growthThresholds) { if (filledCount >= threshold.count) { diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index f8873bd1f2..8ff0979f4b 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -63,6 +63,9 @@ export interface FloodFillOperation extends VoxelOperationBase { maxVoxels: number; basis: { u: Float32Array; v: Float32Array }; filterValue?: bigint; + // Defaults to true; false disables the channel-thickness gating so the fill + // is a plain 4-connected walk. + morphological?: boolean; } export type VoxelOperation = BrushOperation | FloodFillOperation; diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 5ba6a621c4..04d3a82c3e 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -485,6 +485,7 @@ export class VoxelEditController extends SharedObject { maxVoxels: number, basis: { u: Float32Array; v: Float32Array }, filterValue?: bigint, + morphological = true, ) { const seq = this.beginStroke(); const previewValue = fillValueGetter(true); @@ -541,6 +542,7 @@ export class VoxelEditController extends SharedObject { maxVoxels, basis, filterValue, + morphological, }); return; } @@ -634,6 +636,7 @@ export class VoxelEditController extends SharedObject { maxVoxels, basis, filterValue, + morphological, }); this.reconcileStroke(seq, coveredVoxKeys); } catch (e) { From 6e6e81632467753e097419aa9df29f9de67ab74e Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Sat, 15 Aug 2026 15:11:47 +0200 Subject: [PATCH 245/251] test(voxel-annotation): update applyEdits spec for the isolated-chunk edit path applyEdits now edits an isolated chunk passed to writeChunk and invalidates the shared cache entry instead of mutating it in place (08e0c655). Mock the queueManager's invalidateCachedChunks, assert on the chunk captured by the writeChunk spy, and give resident chunks a state <= SYSTEM_MEMORY_WORKER so their data is picked up. --- src/sliceview/volume/backend.spec.ts | 37 +++++++++++++++++++--------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/src/sliceview/volume/backend.spec.ts b/src/sliceview/volume/backend.spec.ts index 59216dda0d..ba5a35c0f9 100644 --- a/src/sliceview/volume/backend.spec.ts +++ b/src/sliceview/volume/backend.spec.ts @@ -82,6 +82,7 @@ describe("VolumeChunkSource: applyEdits", () => { adjustCapacitiesForChunk: vi.fn(), updateChunkState: vi.fn(), scheduleUpdate: vi.fn(), + invalidateCachedChunks: vi.fn(), moveChunkToFrontend: vi.fn(), markRecentlyUsed: vi.fn(), gl: {}, @@ -137,11 +138,10 @@ describe("VolumeChunkSource: applyEdits", () => { const writeSpy = vi.spyOn(source, "writeChunk"); const result = await source.applyEdits("0,0,0", [0], [100n]); - const chunk = source.chunks.get("0,0,0")! as VolumeChunk; - - expect(chunk.data).toBeInstanceOf(BigUint64Array); - expect((chunk.data as BigUint64Array)[0]).toBe(100n); expect(writeSpy).toHaveBeenCalled(); + const written = writeSpy.mock.calls[0][0] as VolumeChunk; + expect(written.data).toBeInstanceOf(BigUint64Array); + expect((written.data as BigUint64Array)[0]).toBe(100n); expect(result.newValues[0]).toBe(100n); }); @@ -172,11 +172,12 @@ describe("VolumeChunkSource: applyEdits", () => { chunkManager: 0, }); + const writeSpy = vi.spyOn(uint32Source, "writeChunk"); const result = await uint32Source.applyEdits("0,0,0", [0], [123]); - const chunk = uint32Source.chunks.get("0,0,0")! as VolumeChunk; - expect(chunk.data).toBeInstanceOf(Uint32Array); - expect((chunk.data as Uint32Array)[0]).toBe(123); + const written = writeSpy.mock.calls[0][0] as VolumeChunk; + expect(written.data).toBeInstanceOf(Uint32Array); + expect((written.data as Uint32Array)[0]).toBe(123); expect(result.newValues[0]).toBe(123); }); }); @@ -196,11 +197,16 @@ describe("VolumeChunkSource: applyEdits", () => { new Float32Array([0, 0, 0]), ) as VolumeChunk; chunk.data = new Uint32Array([123]); + chunk.state = ChunkState.SYSTEM_MEMORY_WORKER; + const writeSpy = vi.spyOn(compressedSource, "writeChunk"); const result = await compressedSource.applyEdits("0,0,0", [0], [99n]); expect(result.oldValues[0]).toBe(5n); - expect((chunk.data as Uint32Array)[0]).toBe(888); + const written = writeSpy.mock.calls[0][0] as VolumeChunk; + expect((written.data as Uint32Array)[0]).toBe(888); + // The shared cache entry is invalidated, never mutated in place. + expect((chunk.data as Uint32Array)[0]).toBe(123); }); it("should handle UINT32 compressed segmentation", async () => { @@ -218,12 +224,15 @@ describe("VolumeChunkSource: applyEdits", () => { new Float32Array([0, 0, 0]), ) as VolumeChunk; chunk.data = new Uint32Array([123]); + chunk.state = ChunkState.SYSTEM_MEMORY_WORKER; + const writeSpy = vi.spyOn(compressedSource, "writeChunk"); const result = await compressedSource.applyEdits("0,0,0", [0], [77]); expect(result.oldValues[0]).toBe(5); expect(result.newValues[0]).toBe(77); - expect((chunk.data as Uint32Array)[0]).toBe(444); + const written = writeSpy.mock.calls[0][0] as VolumeChunk; + expect((written.data as Uint32Array)[0]).toBe(444); }); it("should handle zero-offset compressed data (empty/new)", async () => { @@ -240,9 +249,12 @@ describe("VolumeChunkSource: applyEdits", () => { new Float32Array([0, 0, 0]), ) as VolumeChunk; chunk.data = new Uint32Array([]); + chunk.state = ChunkState.SYSTEM_MEMORY_WORKER; + const writeSpy = vi.spyOn(compressedSource, "writeChunk"); await compressedSource.applyEdits("0,0,0", [0], [50n]); - expect((chunk.data as Uint32Array)[0]).toBe(888); + const written = writeSpy.mock.calls[0][0] as VolumeChunk; + expect((written.data as Uint32Array)[0]).toBe(888); }); it("should handle zero-offset compressed data for UINT32", async () => { @@ -260,9 +272,12 @@ describe("VolumeChunkSource: applyEdits", () => { new Float32Array([0, 0, 0]), ) as VolumeChunk; chunk.data = new Uint32Array([]); + chunk.state = ChunkState.SYSTEM_MEMORY_WORKER; + const writeSpy = vi.spyOn(compressedSource, "writeChunk"); await compressedSource.applyEdits("0,0,0", [0], [50]); - expect((chunk.data as Uint32Array)[0]).toBe(444); + const written = writeSpy.mock.calls[0][0] as VolumeChunk; + expect((written.data as Uint32Array)[0]).toBe(444); }); }); From 0b11543b45bb44cb28a21b57c869bdc5c251abf6 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Sat, 15 Aug 2026 15:40:43 +0200 Subject: [PATCH 246/251] refactor: address review nits from PR #858 - Rename PreviewMultiscaleChunkSource.ts and staminaCalibration.benchmark.ts to snake_case per repo convention. - Import #src/voxel_annotation/backend.js (not .ts) in chunk_worker.bundle.js. - Drop the unused _region parameter of updateFromCpuData. - S3 CORS docs: recommend explicit AllowedOrigins instead of "*" when enabling PUT/DELETE. - Rename DataSubsource.isPotentiallyWritable to supportsWriting and drop the UI-checkbox mention from its contract comment. --- src/chunk_worker.bundle.js | 2 +- src/datasource/index.ts | 4 ++-- src/datasource/zarr/frontend.ts | 4 ++-- src/kvstore/s3/index.rst | 4 ++-- src/layer/voxel_annotation/index.ts | 2 +- src/sliceview/single_texture_chunk_format.ts | 5 +---- src/ui/layer_data_sources_tab.ts | 7 +++---- src/voxel_annotation/base.ts | 2 +- ...leChunkSource.ts => preview_multiscale_chunk_source.ts} | 0 ...ation.benchmark.ts => stamina_calibration.benchmark.ts} | 0 10 files changed, 13 insertions(+), 17 deletions(-) rename src/voxel_annotation/{PreviewMultiscaleChunkSource.ts => preview_multiscale_chunk_source.ts} (100%) rename src/voxel_annotation/{staminaCalibration.benchmark.ts => stamina_calibration.benchmark.ts} (100%) diff --git a/src/chunk_worker.bundle.js b/src/chunk_worker.bundle.js index 1909119100..1ce9c822d1 100644 --- a/src/chunk_worker.bundle.js +++ b/src/chunk_worker.bundle.js @@ -12,4 +12,4 @@ import "#src/annotation/backend.js"; import "#src/datasource/enabled_backend_modules.js"; import "#src/kvstore/enabled_backend_modules.js"; import "#src/worker_rpc_context.js"; -import "#src/voxel_annotation/backend.ts"; +import "#src/voxel_annotation/backend.js"; diff --git a/src/datasource/index.ts b/src/datasource/index.ts index f6e865db2b..4213b19abf 100644 --- a/src/datasource/index.ts +++ b/src/datasource/index.ts @@ -132,8 +132,8 @@ export interface DataSubsource { singleMesh?: SingleMeshSource; segmentPropertyMap?: SegmentPropertyMap; segmentationGraph?: SegmentationGraphSource; - // specify whether the datasource & kvstore implementations supports writing, is also responsible for showing the enableWriting checkbox in the UI - isPotentiallyWritable?: boolean; + // Specifies whether the datasource & kvstore implementations support writing. + supportsWriting?: boolean; } export interface CompleteUrlOptionsBase extends Partial { diff --git a/src/datasource/zarr/frontend.ts b/src/datasource/zarr/frontend.ts index 7eb4f8392f..b2309536ab 100644 --- a/src/datasource/zarr/frontend.ts +++ b/src/datasource/zarr/frontend.ts @@ -508,7 +508,7 @@ export class ZarrDataSource implements KvStoreBasedDataSourceProvider { options, async (progressOptions) => { const { sharedKvStoreContext } = options.registry; - const isPotentiallyWritable = + const supportsWriting = sharedKvStoreContext.kvStoreContext.getKvStore(kvStoreUrl).store .write !== undefined; const metadata = await getMetadata(sharedKvStoreContext, kvStoreUrl, { @@ -560,7 +560,7 @@ export class ZarrDataSource implements KvStoreBasedDataSourceProvider { id: "default", default: true, url: undefined, - subsource: { volume, isPotentiallyWritable }, + subsource: { volume, supportsWriting }, }, { id: "bounds", diff --git a/src/kvstore/s3/index.rst b/src/kvstore/s3/index.rst index 68abe05a8b..7483326b7d 100644 --- a/src/kvstore/s3/index.rst +++ b/src/kvstore/s3/index.rst @@ -73,7 +73,7 @@ such as the following: } ] -If the bucket also needs to support write operations (e.g. for :ref:`voxel-annotation`), ``PUT`` and ``DELETE`` must be added to ``AllowedMethods``: +If the bucket also needs to support write operations (e.g. for :ref:`voxel-annotation`), ``PUT`` and ``DELETE`` must be added to ``AllowedMethods``. When allowing write methods, do not use a wildcard ``AllowedOrigins``; explicitly list the origins that should be permitted to write: .. code-block:: json @@ -89,7 +89,7 @@ If the bucket also needs to support write operations (e.g. for :ref:`voxel-annot "DELETE" ], "AllowedOrigins": [ - "*" + "https://example.com" ], "ExposeHeaders": [ "ETag", diff --git a/src/layer/voxel_annotation/index.ts b/src/layer/voxel_annotation/index.ts index e64890b78b..d46a2bd5cf 100644 --- a/src/layer/voxel_annotation/index.ts +++ b/src/layer/voxel_annotation/index.ts @@ -62,7 +62,7 @@ import { verifyOptionalObjectProperty, } from "#src/util/json.js"; import { TrackableEnum } from "#src/util/trackable_enum.js"; -import { VoxelPreviewMultiscaleSource } from "#src/voxel_annotation/PreviewMultiscaleChunkSource.js"; +import { VoxelPreviewMultiscaleSource } from "#src/voxel_annotation/preview_multiscale_chunk_source.js"; import type { VoxelEditControllerHost, VoxelValueGetter, diff --git a/src/sliceview/single_texture_chunk_format.ts b/src/sliceview/single_texture_chunk_format.ts index 4a281e2545..81958fa953 100644 --- a/src/sliceview/single_texture_chunk_format.ts +++ b/src/sliceview/single_texture_chunk_format.ts @@ -146,10 +146,7 @@ export abstract class SingleTextureVolumeChunk< gl.bindTexture(textureTarget, null); } - updateFromCpuData( - gl: GL, - _region?: { offset: Uint32Array; size: Uint32Array }, - ) { + updateFromCpuData(gl: GL) { if (this.data == null) return; if (this.texture == null) { diff --git a/src/ui/layer_data_sources_tab.ts b/src/ui/layer_data_sources_tab.ts index 3b394b4270..0cbf3e280c 100644 --- a/src/ui/layer_data_sources_tab.ts +++ b/src/ui/layer_data_sources_tab.ts @@ -235,13 +235,12 @@ export class DataSourceSubsourceView extends RefCounted { this.registerDisposer( new ElementVisibilityFromTrackableBoolean( makeCachedDerivedWatchableValue( - (enabled, isPotentiallyWritable) => - enabled && isPotentiallyWritable, + (enabled, supportsWriting) => enabled && supportsWriting, [ enabledState, new WatchableValue( - loadedSubsource.subsourceEntry.subsource - .isPotentiallyWritable ?? false, + loadedSubsource.subsourceEntry.subsource.supportsWriting ?? + false, ), ], ), diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index 8ff0979f4b..7d0093bfe9 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -16,7 +16,7 @@ import type { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; import { vec3 } from "#src/util/geom.js"; -import type { VoxelPreviewMultiscaleSource } from "#src/voxel_annotation/PreviewMultiscaleChunkSource.js"; +import type { VoxelPreviewMultiscaleSource } from "#src/voxel_annotation/preview_multiscale_chunk_source.js"; import type { RPC } from "#src/worker_rpc.js"; export const VOX_RELOAD_CHUNKS_RPC_ID = "vox.chunk.reload"; diff --git a/src/voxel_annotation/PreviewMultiscaleChunkSource.ts b/src/voxel_annotation/preview_multiscale_chunk_source.ts similarity index 100% rename from src/voxel_annotation/PreviewMultiscaleChunkSource.ts rename to src/voxel_annotation/preview_multiscale_chunk_source.ts diff --git a/src/voxel_annotation/staminaCalibration.benchmark.ts b/src/voxel_annotation/stamina_calibration.benchmark.ts similarity index 100% rename from src/voxel_annotation/staminaCalibration.benchmark.ts rename to src/voxel_annotation/stamina_calibration.benchmark.ts From 5a635b954479dbd7d050a99be30f11c26555c58c Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Sat, 15 Aug 2026 16:14:36 +0200 Subject: [PATCH 247/251] refactor(zarr): extract chunk key computation shared by download and writeChunk The 32-line chunk-key encoding (key-encoding prefix, physical-to-logical dimension permutation, read-chunk-to-chunk-shape division, separator join) was duplicated between download and writeChunk. Move it, along with the chunkKvStore.getChunkKey call, into a private getChunkStoreKey so the read and write paths cannot drift apart. The key type is unknown because sharded stores use structured keys. --- src/datasource/zarr/backend.ts | 56 ++++++++-------------------------- 1 file changed, 12 insertions(+), 44 deletions(-) diff --git a/src/datasource/zarr/backend.ts b/src/datasource/zarr/backend.ts index 558c29e6ae..b18621ac06 100644 --- a/src/datasource/zarr/backend.ts +++ b/src/datasource/zarr/backend.ts @@ -54,11 +54,9 @@ export class ZarrVolumeChunkSource extends WithParameters( this.sharedKvStoreContext.kvStoreContext.getKvStore(this.parameters.url), ); - async download(chunk: VolumeChunk, signal: AbortSignal) { - chunk.chunkDataSize = this.spec.chunkDataSize; - const { parameters } = this; - const { chunkGridPosition } = chunk; - const { metadata } = parameters; + // Sharded stores use structured (non-string) keys, hence the unknown type. + private getChunkStoreKey(chunkGridPosition: Float32Array): unknown { + const { metadata } = this.parameters; let baseKey = ""; const rank = this.spec.rank; const { physicalToLogicalDimension } = metadata.codecs.layoutInfo[0]; @@ -91,9 +89,15 @@ export class ZarrVolumeChunkSource extends WithParameters( baseKey += `${sep}${keyCoords[i]}`; sep = metadata.dimensionSeparator; } + return this.chunkKvStore.getChunkKey(chunkGridPosition, baseKey); + } + + async download(chunk: VolumeChunk, signal: AbortSignal) { + chunk.chunkDataSize = this.spec.chunkDataSize; + const { chunkGridPosition } = chunk; const { chunkKvStore } = this; const response = await chunkKvStore.kvStore.read( - chunkKvStore.getChunkKey(chunkGridPosition, baseKey), + this.getChunkStoreKey(chunkGridPosition), { signal, cacheMode: this.requireRevalidatedReads ? "no-cache" : undefined, @@ -110,7 +114,7 @@ export class ZarrVolumeChunkSource extends WithParameters( } async writeChunk(chunk: VolumeChunk): Promise { - const { kvStore, getChunkKey, decodeCodecs } = this.chunkKvStore; + const { kvStore, decodeCodecs } = this.chunkKvStore; if (!kvStore.write) { throw new Error( "ZarrVolumeChunkSource.writeChunk: underlying kvStore is not writable", @@ -201,43 +205,7 @@ export class ZarrVolumeChunkSource extends WithParameters( new AbortController().signal, ); - const { parameters } = this; - const { chunkGridPosition } = chunk; - const { metadata } = parameters; - let baseKey = ""; - const rank = this.spec.rank; - const { physicalToLogicalDimension } = metadata.codecs.layoutInfo[0]; - let sep: string; - if (metadata.chunkKeyEncoding === ChunkKeyEncoding.DEFAULT) { - baseKey += "c"; - sep = metadata.dimensionSeparator; - } else { - sep = ""; - if (rank === 0) { - baseKey += "0"; - } - } - const keyCoords = new Array(rank); - const { readChunkShape } = metadata.codecs.layoutInfo[0]; - const { chunkShape } = metadata; - for ( - let fOrderPhysicalDim = 0; - fOrderPhysicalDim < rank; - ++fOrderPhysicalDim - ) { - const decodedDim = - physicalToLogicalDimension[rank - 1 - fOrderPhysicalDim]; - keyCoords[decodedDim] = Math.floor( - (chunkGridPosition[fOrderPhysicalDim] * readChunkShape[decodedDim]) / - chunkShape[decodedDim], - ); - } - for (let i = 0; i < rank; ++i) { - baseKey += `${sep}${keyCoords[i]}`; - sep = metadata.dimensionSeparator; - } - - const key = getChunkKey(chunkGridPosition, baseKey); + const key = this.getChunkStoreKey(chunk.chunkGridPosition); const arrayBuffer = new Uint8Array(encoded).buffer; await kvStore.write!(key, arrayBuffer); } From 1e218c3e7fb9dccf95779bab471c93f3f477b0da Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Sat, 15 Aug 2026 16:26:58 +0200 Subject: [PATCH 248/251] refactor(kvstore): cache-bust S3 reads with a random query param instead of conditional no-cache Replace the cacheMode read option and the requireRevalidatedReads flag threaded from the voxel-edit controller down to the zarr download with the approach already used for GCS: append a random query string parameter (ignored by S3) to read/stat URLs so cached responses are never used. Besides staleness after writes, this also avoids 304 revalidation responses whose Access-Control-Allow-Origin header may be stale (https://bugs.chromium.org/p/chromium/issues/detail?id=1214563#c2), which applies to S3 since its CORS headers vary with the Origin. --- src/datasource/zarr/backend.ts | 5 +---- src/kvstore/http/read.ts | 3 --- src/kvstore/index.ts | 5 ----- src/kvstore/s3/common.ts | 18 ++++++++++++++---- src/sliceview/volume/backend.ts | 5 ----- src/voxel_annotation/backend.ts | 5 ----- 6 files changed, 15 insertions(+), 26 deletions(-) diff --git a/src/datasource/zarr/backend.ts b/src/datasource/zarr/backend.ts index b18621ac06..389bac8e1a 100644 --- a/src/datasource/zarr/backend.ts +++ b/src/datasource/zarr/backend.ts @@ -98,10 +98,7 @@ export class ZarrVolumeChunkSource extends WithParameters( const { chunkKvStore } = this; const response = await chunkKvStore.kvStore.read( this.getChunkStoreKey(chunkGridPosition), - { - signal, - cacheMode: this.requireRevalidatedReads ? "no-cache" : undefined, - }, + { signal }, ); if (response !== undefined) { const decoded = await decodeArray( diff --git a/src/kvstore/http/read.ts b/src/kvstore/http/read.ts index 3c60d7feb0..2690c5b884 100644 --- a/src/kvstore/http/read.ts +++ b/src/kvstore/http/read.ts @@ -131,9 +131,6 @@ export async function read( signal: options.signal, progressListener: options.progressListener, }; - if (options.cacheMode !== undefined) { - requestInit.cache = options.cacheMode; - } if (rangeHeader !== undefined) { requestInit.headers = { range: rangeHeader }; requestInit.cache = byteRangeCacheMode; diff --git a/src/kvstore/index.ts b/src/kvstore/index.ts index 5d654db8dd..159e6caa7c 100644 --- a/src/kvstore/index.ts +++ b/src/kvstore/index.ts @@ -38,11 +38,6 @@ export interface ReadResponse { export interface DriverReadOptions extends Partial { byteRange?: ByteRangeRequest; throwIfMissing?: boolean; - // Fetch cache mode for HTTP-backed stores. Use "no-cache" for data that may - // be mutated by this or another session: the browser's heuristic freshness - // (no Cache-Control header) can otherwise serve stale content for hours - // without revalidating. - cacheMode?: RequestCache; } export class NotFoundError extends Error { diff --git a/src/kvstore/s3/common.ts b/src/kvstore/s3/common.ts index 1b37bf67bc..92feb370f1 100644 --- a/src/kvstore/s3/common.ts +++ b/src/kvstore/s3/common.ts @@ -37,6 +37,7 @@ import { joinBaseUrlAndPath } from "#src/kvstore/url.js"; import type { FetchOk } from "#src/util/http_request.js"; import { HttpError, fetchOk } from "#src/util/http_request.js"; import { ProgressSpan } from "#src/util/progress_listener.js"; +import { getRandomHexString } from "#src/util/random.js"; export class S3KvStoreBase< SharedKvStoreContext extends SharedKvStoreContextBase, @@ -50,17 +51,26 @@ export class S3KvStoreBase< protected fetchOkImpl: FetchOk = fetchOk, ) {} + // Random query parameter (ignored by S3) so cached responses are never + // used — same rationale as GcsKvStore.getObjectUrl: stale ACAO headers on + // 304s (S3's CORS headers also vary with the Origin) and staleness after + // this or another session writes to the bucket. + private getObjectUrl(key: string): string { + return ( + joinBaseUrlAndPath(this.baseUrl, key) + + `?neuroglancer=${getRandomHexString()}` + ); + } + stat(key: string, options: StatOptions): Promise { - const url = joinBaseUrlAndPath(this.baseUrl, key); - return stat(this, key, url, options, this.fetchOkImpl); + return stat(this, key, this.getObjectUrl(key), options, this.fetchOkImpl); } read( key: string, options: DriverReadOptions, ): Promise { - const url = joinBaseUrlAndPath(this.baseUrl, key); - return read(this, key, url, options, this.fetchOkImpl); + return read(this, key, this.getObjectUrl(key), options, this.fetchOkImpl); } list(prefix: string, options: DriverListOptions): Promise { diff --git a/src/sliceview/volume/backend.ts b/src/sliceview/volume/backend.ts index 2a9c93b7ce..0e0b9b776b 100644 --- a/src/sliceview/volume/backend.ts +++ b/src/sliceview/volume/backend.ts @@ -166,11 +166,6 @@ export class VolumeChunkSource tempChunkDataSize: Uint32Array; tempChunkPosition: Float32Array; - // Set by the voxel-edit controller on sources it writes to. Chunk downloads - // must then revalidate with the server (`cache: "no-cache"`): the browser's - // heuristic freshness for responses without Cache-Control can otherwise - // serve stale cached chunks — hiding freshly written edits — for hours. - requireRevalidatedReads = false; constructor(rpc: RPC, options: any) { super(rpc, options); const rank = this.spec.rank; diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index ad284024dd..22d3ee77f3 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -516,11 +516,6 @@ export class VoxelEditController extends SharedObject { `VoxelEditBackend: failed to resolve VolumeChunkSource for LOD ${res.lodIndex}`, ); } - // This session writes to these sources: their reads must revalidate with - // the server instead of trusting the browser's heuristic HTTP cache, - // which would otherwise serve pre-edit chunks (strokes visually vanish - // even though the store was written). - resolved.requireRevalidatedReads = true; this.sources.set(res.lodIndex, resolved); } From 88046fd53129ad87e37ce0f303e8ba55218449e4 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Sun, 16 Aug 2026 09:46:50 +0200 Subject: [PATCH 249/251] fix(zarr): do not advertise writing support for sharded or transposed arrays supportsWriting was computed by duck-typing the base kvstore before the metadata was even read, so s3 + sharded zarr reported writable even though the sharded kvstore is read-only and the first write fails. Derive it from the parsed codec chain instead: every scale must be free of sharding and of array->array codecs (which encodeArray rejects). --- docs/user-guide/voxel_annotation.rst | 5 +++++ src/datasource/zarr/codec/index.ts | 7 +++++++ src/datasource/zarr/frontend.ts | 10 +++++++--- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/user-guide/voxel_annotation.rst b/docs/user-guide/voxel_annotation.rst index aef3ab9393..bc654a64c1 100644 --- a/docs/user-guide/voxel_annotation.rst +++ b/docs/user-guide/voxel_annotation.rst @@ -49,6 +49,11 @@ Voxel editing is currently supported for the following configurations: - Blosc - Gzip +.. note:: + Writing is not supported for arrays whose codec chain includes + ``sharding_indexed`` (the Zarr v3 sharded format) or an array-to-array codec + such as ``transpose``. + Tools ----- diff --git a/src/datasource/zarr/codec/index.ts b/src/datasource/zarr/codec/index.ts index 999a95bef1..7bafc22849 100644 --- a/src/datasource/zarr/codec/index.ts +++ b/src/datasource/zarr/codec/index.ts @@ -49,6 +49,13 @@ export interface ShardingInfo { subChunkCodecs: CodecChainSpec; } +export function codecChainSupportsWriting(codecs: CodecChainSpec): boolean { + return ( + codecs.shardingInfo === undefined && + codecs[CodecKind.arrayToArray].length === 0 + ); +} + export interface CodecArrayInfo { dataType: DataType; // Specifies the chunk shape, indexed by logical dimension. diff --git a/src/datasource/zarr/frontend.ts b/src/datasource/zarr/frontend.ts index b2309536ab..bc9d637a9d 100644 --- a/src/datasource/zarr/frontend.ts +++ b/src/datasource/zarr/frontend.ts @@ -33,6 +33,7 @@ import type { } from "#src/datasource/index.js"; import { getKvStorePathCompletions } from "#src/datasource/kvstore_completions.js"; import { VolumeChunkSourceParameters } from "#src/datasource/zarr/base.js"; +import { codecChainSupportsWriting } from "#src/datasource/zarr/codec/index.js"; import "#src/datasource/zarr/codec/bytes/resolve.js"; import "#src/datasource/zarr/codec/crc32c/resolve.js"; import "#src/datasource/zarr/codec/gzip/resolve.js"; @@ -508,9 +509,6 @@ export class ZarrDataSource implements KvStoreBasedDataSourceProvider { options, async (progressOptions) => { const { sharedKvStoreContext } = options.registry; - const supportsWriting = - sharedKvStoreContext.kvStoreContext.getKvStore(kvStoreUrl).store - .write !== undefined; const metadata = await getMetadata(sharedKvStoreContext, kvStoreUrl, { ...progressOptions, zarrVersion: this.zarrVersion, @@ -551,6 +549,12 @@ export class ZarrDataSource implements KvStoreBasedDataSourceProvider { sharedKvStoreContext, multiscaleInfo, ); + const supportsWriting = + sharedKvStoreContext.kvStoreContext.getKvStore(kvStoreUrl).store + .write !== undefined && + multiscaleInfo.scales.every((scale) => + codecChainSupportsWriting(scale.metadata.codecs), + ); return { canonicalUrl: `${kvStoreUrl}|zarr${metadata.zarrVersion}:`, modelTransform: makeIdentityTransform(volume.modelSpace), From 818af719215210f76a097777f51c3b94c62ba883 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 20 Aug 2026 15:02:03 +0200 Subject: [PATCH 250/251] refactor(sliceview): drop the NaN chunk-position tripwire in drawChunk Leftover debugging guard from the [NaN,NaN,NaN] preview-position bug; the underlying cause was fixed and the per-draw check is not worth keeping. Requested in PR #858 review. --- src/sliceview/volume/renderlayer.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/sliceview/volume/renderlayer.ts b/src/sliceview/volume/renderlayer.ts index 3ec256939e..e189483934 100644 --- a/src/sliceview/volume/renderlayer.ts +++ b/src/sliceview/volume/renderlayer.ts @@ -265,14 +265,6 @@ function drawChunk( chunkPosition: vec3, wireFrame: boolean, ) { - if (chunkPosition.some(isNaN)) { - throw new Error( - `Attempted to draw chunk with NaN position: [${chunkPosition.join( - ",", - )}]. This indicates a problem with the layer's coordinate transforms.`, - ); - } - gl.uniform3fv(shader.uniform("uTranslation"), chunkPosition); if (wireFrame) { drawLines(shader.gl, 6, 1); From 77b38374d6cbc120617a0285ff4f7553eb85e6bf Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Thu, 20 Aug 2026 19:09:39 +0200 Subject: [PATCH 251/251] fix(kvstore): reject dot-segment components in S3 object keys The fetch URL parser normalizes dot segments (even percent-encoded ones), so a key containing "." or ".." would silently address an object outside the dataset prefix. Now that the S3 driver also writes and deletes, read/stat/write/delete all reject such keys. Requested in PR #858 review. --- src/kvstore/s3/common.ts | 24 ++++++++++++++++++------ tests/kvstore/s3.spec.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/kvstore/s3/common.ts b/src/kvstore/s3/common.ts index 92feb370f1..bd3fc40866 100644 --- a/src/kvstore/s3/common.ts +++ b/src/kvstore/s3/common.ts @@ -39,6 +39,16 @@ import { HttpError, fetchOk } from "#src/util/http_request.js"; import { ProgressSpan } from "#src/util/progress_listener.js"; import { getRandomHexString } from "#src/util/random.js"; +function validateObjectKey(key: string) { + for (const component of key.split("/")) { + if (component === "." || component === "..") { + throw new Error( + `Invalid S3 object key ${JSON.stringify(key)}: "." and ".." path components are not supported`, + ); + } + } +} + export class S3KvStoreBase< SharedKvStoreContext extends SharedKvStoreContextBase, > implements KvStore @@ -51,15 +61,17 @@ export class S3KvStoreBase< protected fetchOkImpl: FetchOk = fetchOk, ) {} + private getBaseObjectUrl(key: string): string { + validateObjectKey(key); + return joinBaseUrlAndPath(this.baseUrl, key); + } + // Random query parameter (ignored by S3) so cached responses are never // used — same rationale as GcsKvStore.getObjectUrl: stale ACAO headers on // 304s (S3's CORS headers also vary with the Origin) and staleness after // this or another session writes to the bucket. private getObjectUrl(key: string): string { - return ( - joinBaseUrlAndPath(this.baseUrl, key) + - `?neuroglancer=${getRandomHexString()}` - ); + return this.getBaseObjectUrl(key) + `?neuroglancer=${getRandomHexString()}`; } stat(key: string, options: StatOptions): Promise { @@ -99,7 +111,7 @@ export class S3KvStoreBase< } async write(key: string, value: ArrayBuffer): Promise { - const url = joinBaseUrlAndPath(this.baseUrl, key); + const url = this.getBaseObjectUrl(key); try { await this.fetchOkImpl(url, { method: "PUT", @@ -111,7 +123,7 @@ export class S3KvStoreBase< } async delete(key: string): Promise { - const url = joinBaseUrlAndPath(this.baseUrl, key); + const url = this.getBaseObjectUrl(key); try { await this.fetchOkImpl(url, { method: "DELETE", diff --git a/tests/kvstore/s3.spec.ts b/tests/kvstore/s3.spec.ts index eeb1fd3690..75e2eb9c54 100644 --- a/tests/kvstore/s3.spec.ts +++ b/tests/kvstore/s3.spec.ts @@ -83,6 +83,34 @@ describe("http:// path-style URL", () => { testKvStore(constantFixture(`https://s3.amazonaws.com/${BUCKET}/`)); }); +describe("dot segment key components", () => { + test.for(["..", "../b", "a/../b", "a/./b"])( + "read rejects %s", + async (key) => { + const context = await sharedKvStoreContext(); + await expect( + context.kvStoreContext.read(`s3://${BUCKET}/${key}`, { + throwIfMissing: true, + }), + ).rejects.toThrow(/path components are not supported/); + }, + ); + test("write rejects a/../b", async () => { + const context = await sharedKvStoreContext(); + const { store } = context.kvStoreContext.getKvStore(`s3://${BUCKET}/`); + await expect(store.write!("a/../b", new ArrayBuffer(0))).rejects.toThrow( + /path components are not supported/, + ); + }); + test("delete rejects a/../b", async () => { + const context = await sharedKvStoreContext(); + const { store } = context.kvStoreContext.getKvStore(`s3://${BUCKET}/`); + await expect(store.delete!("a/../b")).rejects.toThrow( + /path components are not supported/, + ); + }); +}); + describe("special characters", () => { test.for(SPECIAL_CHAR_CODES)("charCode=%s", async (charCode) => { const context = await sharedKvStoreContext();

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 { + + const pixelButton = document.createElement("button"); + pixelButton.textContent = "Pixel"; + pixelButton.title = "ctrl+click to paint a pixel"; + pixelButton.addEventListener("click", () => { this.layer.tool.value = new VoxelPixelLegacyTool(this.layer); }); - toolbox.appendChild(legacyButton); + toolbox.appendChild(pixelButton); + + const brushButton = document.createElement("button"); + brushButton.textContent = "Brush"; + brushButton.title = "ctrl+click to paint a small sphere"; + brushButton.addEventListener("click", () => { + this.layer.tool.value = new VoxelBrushLegacyTool(this.layer); + }); + toolbox.appendChild(brushButton); + element.appendChild(toolbox); } } @@ -161,9 +182,13 @@ export class VoxUserLayer extends UserLayer { voxEditController?: VoxelEditController; // Settings state - voxScale: Float64Array = new Float64Array([0.000000008, 0.000000008, 0.000000008]); + voxScale: Float64Array = new Float64Array([ + 0.00000008, 0.00000008, 0.00000008, + ]); voxScaleUnit: string = "nm"; - voxUpperBound: Float32Array = new Float32Array([1_000_000, 1_000_000, 1_000_000]); + voxUpperBound: Float32Array = new Float32Array([ + 1_000_000, 1_000_000, 1_000_000, + ]); private voxLoadedSubsource?: LoadedDataSubsource; constructor(managedLayer: Borrowed) { @@ -181,65 +206,119 @@ export class VoxUserLayer extends UserLayer { this.tabs.default = "vox"; } - applyVoxSettings(scale: Float64Array, unit: string, upperBound: Float32Array) { + applyVoxSettings( + scale: Float64Array, + unit: string, + upperBound: Float32Array, + ) { // Update and rebuild if values changed. let changed = false; for (let i = 0; i < 3; ++i) { - if (this.voxScale[i] !== scale[i]) { this.voxScale[i] = scale[i]; changed = true; } - if (this.voxUpperBound[i] !== upperBound[i]) { this.voxUpperBound[i] = upperBound[i]; changed = true; } + if (this.voxScale[i] !== scale[i]) { + this.voxScale[i] = scale[i]; + changed = true; + } + if (this.voxUpperBound[i] !== upperBound[i]) { + this.voxUpperBound[i] = upperBound[i]; + changed = true; + } + } + if (this.voxScaleUnit !== unit) { + this.voxScaleUnit = unit; + changed = true; } - if (this.voxScaleUnit !== unit) { this.voxScaleUnit = unit; changed = true; } if (changed) this.buildOrRebuildVoxLayer(); } - private buildOrRebuildVoxLayer() { - const ls = this.voxLoadedSubsource; - if (!ls) return; - const guardScale = Array.from(this.voxScale); - const guardBounds = Array.from(this.voxUpperBound); - const guardUnit = this.voxScaleUnit; - ls.activate(() => { - const dummySource = new DummyMultiscaleVolumeChunkSource( - this.manager.chunkManager, - { - chunkDataSize: new Uint32Array([64, 64, 64]), - upperVoxelBound: this.voxUpperBound, - }, - ); - // Expose a controller so tools can paint voxels via the source. - this.voxEditController = new VoxelEditController(dummySource); - - // Build transform with current scale and units. - const units = [this.voxScaleUnit, this.voxScaleUnit, this.voxScaleUnit] as string[]; - const identity3D = new WatchableCoordinateSpaceTransform( - makeIdentityTransform( - makeCoordinateSpace({ - rank: 3, - names: ["x", "y", "z"], - units, - scales: new Float64Array(this.voxScale), - }), - ), - ); - const transform = getWatchableRenderLayerTransform( + private createIdentity3D() { + const units = [ + this.voxScaleUnit, + this.voxScaleUnit, + this.voxScaleUnit, + ] as string[]; + + return new WatchableCoordinateSpaceTransform( + makeIdentityTransform( + makeCoordinateSpace({ + rank: 3, + names: ["x", "y", "z"], + units, + scales: new Float64Array(this.voxScale), + }), + ), + ); + } + + getVoxelPositionFromMouse( + mouseState: MouseSelectionState, + ): Float32Array | undefined { + try { + if (!mouseState?.active || !mouseState?.position) return undefined; + + // There might be a simpler way to retrieve global transform? + const identity3D = this.createIdentity3D(); + const watchable = getWatchableRenderLayerTransform( this.manager.root.coordinateSpace, this.localPosition.coordinateSpace, identity3D, undefined, ); + const tOrError = watchable.value as any; + if (tOrError?.error) return undefined; + + const p = mouseState.position; - ls.addRenderLayer( - new VoxelAnnotationRenderLayer( - dummySource, + return vec3.transformMat4( + vec3.create(), + vec3.fromValues(p[0], p[1], p[2]), + mat4.invert(mat4.create(), tOrError.modelToRenderLayerTransform) || + mat4.identity(mat4.create()), + ); + } catch { + return undefined; + } + } + + private buildOrRebuildVoxLayer() { + const ls = this.voxLoadedSubsource; + if (!ls) return; + const guardScale = Array.from(this.voxScale); + const guardBounds = Array.from(this.voxUpperBound); + const guardUnit = this.voxScaleUnit; + ls.activate( + () => { + const dummySource = new DummyMultiscaleVolumeChunkSource( + this.manager.chunkManager, { + chunkDataSize: new Uint32Array([64, 64, 64]), + upperVoxelBound: this.voxUpperBound, + }, + ); + // Expose a controller so tools can paint voxels via the source. + this.voxEditController = new VoxelEditController(dummySource); + + // Build transform with current scale and units. + const identity3D = this.createIdentity3D(); + const transform = getWatchableRenderLayerTransform( + this.manager.root.coordinateSpace, + this.localPosition.coordinateSpace, + identity3D, + undefined, + ); + + ls.addRenderLayer( + new VoxelAnnotationRenderLayer(dummySource, { transform: transform as any, renderScaleTarget: this.sliceViewRenderScaleTarget, renderScaleHistogram: undefined, localPosition: this.localPosition, - } as any, - ), - ); - }, guardScale, guardBounds, guardUnit); + } as any), + ); + }, + guardScale, + guardBounds, + guardUnit, + ); } getLegacyDataSourceSpecifications( diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 2840e8c540..81a06f8d57 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -16,143 +16,55 @@ import type { MouseSelectionState } from "#src/layer/index.js"; import type { VoxUserLayer } from "#src/layer/vox/index.js"; -import { RenderedDataPanel } from "#src/rendered_data_panel.js"; import { LegacyTool, registerLegacyTool } from "#src/ui/tool.js"; -import { formatScaleWithUnitAsString } from "#src/util/si_units.js"; export const PIXEL_TOOL_ID = "voxPixel"; +export const BRUSH_TOOL_ID = "voxBrush"; export class VoxelPixelLegacyTool extends LegacyTool { description = "pixel"; toJSON() { return PIXEL_TOOL_ID; } + trigger(mouseState: MouseSelectionState) { - // Defensive runtime check: this tool is intended for VoxUserLayer only. if ((this.layer as any)?.constructor?.type !== 'vox') return; try { - const layer = this.layer; - const display = layer.manager.root.display; - const panels = display.panels; - // Compute mouse position relative to canvas - const pageX = mouseState?.pageX ?? 0; - const pageY = mouseState?.pageY ?? 0; - const rect = display.canvasRect ?? display.canvas.getBoundingClientRect(); - const canvasX = pageX - rect.left; - const canvasY = pageY - rect.top; - - // Find the RenderedDataPanel under the mouse - let chosenPanel: RenderedDataPanel | undefined; - for (const panel of panels) { - if (!(panel instanceof RenderedDataPanel)) continue; - const left = panel.canvasRelativeClippedLeft; - const top = panel.canvasRelativeClippedTop; - const right = left + panel.renderViewport.width; - const bottom = top + panel.renderViewport.height; - if (canvasX >= left && canvasX < right && canvasY >= top && canvasY < bottom) { - chosenPanel = panel; - break; - } - } - if (!chosenPanel) { - // Fallback: pick the first RenderedDataPanel if any - for (const p of panels) { - if (p instanceof RenderedDataPanel) { - chosenPanel = p; - break; - } - } - } - - // Mouse voxel position string - let mousePosStr = "unknown"; - const cs = mouseState?.coordinateSpace; - const pos = mouseState?.position; - if (mouseState?.active && cs && pos) { - const { rank, names } = cs; - const parts: string[] = []; - for (let i = 0; i < rank; ++i) { - parts.push(`${names[i]} ${Math.floor(pos[i])}`); - } - mousePosStr = parts.join(" "); - } - - // Zoom and viewport scale - let zoomStr = "n/a"; - const imageScaleParts: string[] = []; - if (chosenPanel) { - const nav = (chosenPanel as any).navigationState; - const zoom = nav?.zoomFactor?.value; - if (typeof zoom === "number" && !Number.isNaN(zoom)) { - zoomStr = String(zoom); - } - const info = nav?.displayDimensionRenderInfo?.value; - if (info) { - const { - displayDimensionIndices, - displayDimensionUnits, - globalDimensionNames, - } = info; - - // Try to compute per-image-pixel sizes (current LOD texel size) from the SliceView. - const panelAny = chosenPanel as any; - const sliceView = panelAny?.sliceView; - const minImagePixelSize = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY]; - if (sliceView?.visibleLayers instanceof Map) { - for (const layerInfo of sliceView.visibleLayers.values()) { - const visibleSources = layerInfo?.visibleSources as any[] | undefined; - if (!Array.isArray(visibleSources)) continue; - for (const tsource of visibleSources) { - const evs: Float32Array | number[] | undefined = (tsource as any)?.effectiveVoxelSize; - if (!evs) continue; - for (let i = 0; i < 3; ++i) { - const v = evs[i]; - if (typeof v === "number" && v > 0) { - if (v < minImagePixelSize[i]) minImagePixelSize[i] = v; - } - } - } - } - } - - for (let i = 0; i < 3; ++i) { - const dim = displayDimensionIndices[i]; - if (dim === -1) continue; - const pxSize = minImagePixelSize[i]; - if (Number.isFinite(pxSize)) { - const formatted = formatScaleWithUnitAsString( - pxSize, - displayDimensionUnits[i], - { precision: 2, elide1: false }, - ); - imageScaleParts.push(`${globalDimensionNames[dim]} ${formatted}/imgPx`); - } - } - - // Fallback: if we couldn't determine image pixel sizes, skip logging them. - } - } + const vox = (this.layer as any).getVoxelPositionFromMouse?.(mouseState) as Float32Array | undefined; + if (!mouseState?.active || !vox) return; + const vx = Math.floor(vox[0]); + const vy = Math.floor(vox[1]); + const vz = Math.floor(vox[2]); + (this.layer as any).voxEditController?.paintVoxel(new Float32Array([vx, vy, vz]), 42); + } catch (e) { + console.log('[VoxelPixelLegacyTool] Error computing voxel position:', e); + } + } +} - console.log( - `Mouse: ${mousePosStr} | Zoom: ${zoomStr} | Viewport scale: ${imageScaleParts.join(", ")}`, - ); +export class VoxelBrushLegacyTool extends LegacyTool { + description = "brush"; + radius = 3; + toJSON() { + return BRUSH_TOOL_ID; + } - // Connect to the controller: paint a voxel under the mouse if we have voxel coords. - if (mouseState?.active && pos && cs) { - const vx = Math.floor(pos[0] ?? 0); - const vy = Math.floor(pos[1] ?? 0); - const vz = Math.floor((pos[2] as number | undefined) ?? 0); - (this.layer as any).voxEditController?.paintVoxel( - new Float32Array([vx, vy, vz]), - 42, - ); - } + trigger(mouseState: MouseSelectionState) { + if ((this.layer as any)?.constructor?.type !== 'vox') return; + try { + const vox = (this.layer as any).getVoxelPositionFromMouse?.(mouseState) as Float32Array | undefined; + if (!mouseState?.active || !vox) return; + const cx = Math.floor(vox[0]); + const cy = Math.floor(vox[1]); + const cz = Math.floor(vox[2]); + (this.layer as any).voxEditController?.paintBrush(new Float32Array([cx, cy, cz]), this.radius, 42); } catch (e) { - console.log("[VoxelPixelLegacyTool] Error computing info:", e); + console.log('[VoxelBrushLegacyTool] Error:', e); } } } export function registerVoxelAnnotationTools() { registerLegacyTool(PIXEL_TOOL_ID, (layer) => new VoxelPixelLegacyTool(layer as unknown as VoxUserLayer)); + registerLegacyTool(BRUSH_TOOL_ID, (layer) => new VoxelBrushLegacyTool(layer as unknown as VoxUserLayer)); } diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index d73f3cf0f8..10f9934978 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -34,6 +34,7 @@ export class VoxChunkSource extends BaseVolumeChunkSource { const array = this.allocateTypedArray(dataType, size, Number(fillValue ?? 0)); // Populate a simple 3D pattern for visualization + const d = 1; const cds = chunk.chunkDataSize!; let index = 0; for (let z = 0; z < cds[2]; ++z) { @@ -43,7 +44,7 @@ export class VoxChunkSource extends BaseVolumeChunkSource { const gy = origin[1] + y; const gz = origin[2] + z; // Checker pattern in world space with large squares - const square = ((Math.floor(gx / 16) + Math.floor(gy / 16) + Math.floor(gz / 16)) & 1) !== 0; + const square = ((Math.floor(gx / d) + Math.floor(gy / d) + Math.floor(gz / d)) & 1) !== 0; array[index] = square ? 5 : 0; } } diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 4310dc3258..ec35da8eca 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -20,4 +20,33 @@ export class VoxelEditController { // no-op } } + + /** Paint a simple spherical brush (3D) of integer radius around center. */ + paintBrush(center: Float32Array, radius: number, value: number) { + if (!Number.isFinite(radius) || radius <= 0) return; + const r = Math.floor(radius); + const cx = Math.floor(center[0] ?? 0); + const cy = Math.floor(center[1] ?? 0); + const cz = Math.floor(center[2] ?? 0); + const rr = r * r; + // Attempt to get the source once to reduce repeated dereferencing. + let source: VoxChunkSource | undefined; + try { + const sources2D = this.multiscale.getSources({} as any); + const single = sources2D?.[0]?.[0]; + source = single?.chunkSource as VoxChunkSource | undefined; + } catch { + // ignore + } + if (!source) return; + for (let dz = -r; dz <= r; ++dz) { + for (let dy = -r; dy <= r; ++dy) { + for (let dx = -r; dx <= r; ++dx) { + if (dx*dx + dy*dy + dz*dz <= rr) { + source.paintVoxel(new Float32Array([cx + dx, cy + dy, cz + dz]), value); + } + } + } + } + } } diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 9409d646a8..872cff47f4 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -32,7 +32,6 @@ class VoxelEditOverlay { for (const [idx, val] of m) { if (idx >= 0 && idx < baseArray.length) { (baseArray as any)[idx] = val as any; - console.log(`Merged edit into chunk ${key} at index ${idx} with value ${val}`); } } console.log(`Merged edits into chunk ${key}`); From b81186ceb45e81d0053a6ac9418cced6609cf015 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:42 +0100 Subject: [PATCH 012/251] refactor: rename DummyMultiscaleVolumeChunkSource to VoxMultiscaleVolumeChunkSource and update related imports --- NOTES/classExplanations/MultiscaleVolumeChunkSource.md | 2 +- NOTES/data-saving-plan.md | 2 +- src/layer/vox/index.ts | 4 ++-- ...ummy_volume_chunk_source.ts => volume_chunk_source.ts} | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) rename src/voxel_annotation/{dummy_volume_chunk_source.ts => volume_chunk_source.ts} (95%) diff --git a/NOTES/classExplanations/MultiscaleVolumeChunkSource.md b/NOTES/classExplanations/MultiscaleVolumeChunkSource.md index 0ab8cabfd3..941fff5321 100644 --- a/NOTES/classExplanations/MultiscaleVolumeChunkSource.md +++ b/NOTES/classExplanations/MultiscaleVolumeChunkSource.md @@ -66,7 +66,7 @@ Backends: ### Review of your DummyMultiscaleVolumeChunkSource -File: src/voxel_annotation/dummy_volume_chunk_source.ts +File: src/voxel_annotation/volume_chunk_source.ts What it sets up: - Extends MultiscaleVolumeChunkSource with: diff --git a/NOTES/data-saving-plan.md b/NOTES/data-saving-plan.md index 8e5225338e..d10b6c4d65 100644 --- a/NOTES/data-saving-plan.md +++ b/NOTES/data-saving-plan.md @@ -14,7 +14,7 @@ Below is a concrete design that fits the current codebase and follows the attach - src/voxel_annotation/backend.ts - VoxChunkSource backend counterpart returns a checkerboard in download() (lines 24–54). - A dummy multiscale provider and layer hookup: - - src/voxel_annotation/dummy_volume_chunk_source.ts: builds multiscale and returns our frontend VoxChunkSource (lines 64–129). + - src/voxel_annotation/volume_chunk_source.ts: builds multiscale and returns our frontend VoxChunkSource (lines 64–129). - src/layer/vox/index.ts: the Vox layer, its settings/draw tabs, and the render layer. It already has UI hooks to rebuild based on scale/bounds (lines 194–253), and a simple toolset that calls VoxelEditController which calls VoxChunkSource.paintVoxel() (src/voxel_annotation/edit_controller.ts lines 10–22 and 24–52; ui tools in src/ui/voxel_annotations.ts lines 26–187). This is already close to the “tiered” architecture in the spec: diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 936542b724..8406e4365e 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -38,7 +38,7 @@ import { RenderScaleHistogram, trackableRenderScaleTarget } from "#src/render_sc import { registerVoxelAnnotationTools, VoxelBrushLegacyTool, VoxelPixelLegacyTool } from "#src/ui/voxel_annotations.js"; import type { Borrowed } from "#src/util/disposable.js"; import { mat4 } from "#src/util/geom.js"; -import { DummyMultiscaleVolumeChunkSource } from "#src/voxel_annotation/dummy_volume_chunk_source.js"; +import { VoxMultiscaleVolumeChunkSource } from "#src/voxel_annotation/volume_chunk_source.js"; import { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; import { VoxelAnnotationRenderLayer } from "#src/voxel_annotation/renderlayer.js"; import { Tab } from "#src/widget/tab_view.js"; @@ -287,7 +287,7 @@ export class VoxUserLayer extends UserLayer { const guardUnit = this.voxScaleUnit; ls.activate( () => { - const dummySource = new DummyMultiscaleVolumeChunkSource( + const dummySource = new VoxMultiscaleVolumeChunkSource( this.manager.chunkManager, { chunkDataSize: new Uint32Array([64, 64, 64]), diff --git a/src/voxel_annotation/dummy_volume_chunk_source.ts b/src/voxel_annotation/volume_chunk_source.ts similarity index 95% rename from src/voxel_annotation/dummy_volume_chunk_source.ts rename to src/voxel_annotation/volume_chunk_source.ts index 0a03659b45..00dcdf6a77 100644 --- a/src/voxel_annotation/dummy_volume_chunk_source.ts +++ b/src/voxel_annotation/volume_chunk_source.ts @@ -36,12 +36,12 @@ import { VoxChunkSource } from '#src/voxel_annotation/frontend.js'; * - Chunking: Data is divided into smaller, manageable 3D blocks (chunks) to optimize loading and memory usage. * - Asynchronous: Data loading is typically asynchronous, as it might involve fetching from a remote server or reading from large local files. */ -export interface DummyMultiscaleOptions { +export interface VoxMultiscaleOptions { chunkDataSize?: Uint32Array | number[]; upperVoxelBound?: Float32Array | number[]; } -export class DummyMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSource { +export class VoxMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSource { dataType = DataType.UINT32; volumeType = VolumeType.SEGMENTATION; get rank() { @@ -51,7 +51,7 @@ export class DummyMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSourc private cfgChunkDataSize: Uint32Array; private cfgUpperVoxelBound: Float32Array; - constructor(chunkManager: ChunkManager, options?: DummyMultiscaleOptions) { + constructor(chunkManager: ChunkManager, options?: VoxMultiscaleOptions) { super(chunkManager); this.cfgChunkDataSize = new Uint32Array( options?.chunkDataSize ? Array.from(options.chunkDataSize) : [64, 64, 64], @@ -110,7 +110,7 @@ export class DummyMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSourc // Large diagonal scale to make effective voxel size huge, ensuring guard scale is used when // zoomed out. Homogeneous (rank+1)x(rank+1) matrix. - const scale = 1 << 3; + const scale = 1 << 2; const guardXform = new Float32Array((rank + 1) * (rank + 1)); for (let i = 0; i < rank; ++i) { guardXform[i * (rank + 1) + i] = scale; From a121d4bb1d0b0c504184d232e47f05f75317639a Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:42 +0100 Subject: [PATCH 013/251] feat: brush size, eraser mode and little trivial optimization --- src/layer/vox/index.ts | 43 ++++++++++++++++++++++++- src/ui/voxel_annotations.ts | 8 +++-- src/voxel_annotation/edit_controller.ts | 6 ++-- src/voxel_annotation/frontend.ts | 27 ++++++++++++++-- 4 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 8406e4365e..52c0fe6c69 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -38,9 +38,9 @@ import { RenderScaleHistogram, trackableRenderScaleTarget } from "#src/render_sc import { registerVoxelAnnotationTools, VoxelBrushLegacyTool, VoxelPixelLegacyTool } from "#src/ui/voxel_annotations.js"; import type { Borrowed } from "#src/util/disposable.js"; import { mat4 } from "#src/util/geom.js"; -import { VoxMultiscaleVolumeChunkSource } from "#src/voxel_annotation/volume_chunk_source.js"; import { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; import { VoxelAnnotationRenderLayer } from "#src/voxel_annotation/renderlayer.js"; +import { VoxMultiscaleVolumeChunkSource } from "#src/voxel_annotation/volume_chunk_source.js"; import { Tab } from "#src/widget/tab_view.js"; class VoxSettingsTab extends Tab { @@ -168,6 +168,44 @@ class VoxToolTab extends Tab { }); toolbox.appendChild(brushButton); + // Brush size control + const sizeWrap = document.createElement("div"); + sizeWrap.style.display = "inline-flex"; + sizeWrap.style.alignItems = "center"; + sizeWrap.style.gap = "4px"; + const sizeLabel = document.createElement("label"); + sizeLabel.textContent = "Brush size"; + const sizeInput = document.createElement("input"); + sizeInput.type = "number"; + sizeInput.min = "1"; + sizeInput.step = "1"; + sizeInput.value = String(this.layer.voxBrushRadius ?? 3); + sizeInput.addEventListener("change", () => { + const v = Math.max(1, Math.floor(Number(sizeInput.value) || 1)); + this.layer.voxBrushRadius = v; + sizeInput.value = String(v); + }); + sizeWrap.appendChild(sizeLabel); + sizeWrap.appendChild(sizeInput); + toolbox.appendChild(sizeWrap); + + // Eraser toggle + const erWrap = document.createElement("div"); + erWrap.style.display = "inline-flex"; + erWrap.style.alignItems = "center"; + erWrap.style.gap = "4px"; + const erLabel = document.createElement("label"); + erLabel.textContent = "Eraser"; + const erChk = document.createElement("input"); + erChk.type = "checkbox"; + erChk.checked = !!this.layer.voxEraseMode; + erChk.addEventListener("change", () => { + this.layer.voxEraseMode = !!erChk.checked; + }); + erWrap.appendChild(erLabel); + erWrap.appendChild(erChk); + toolbox.appendChild(erWrap); + element.appendChild(toolbox); } } @@ -189,6 +227,9 @@ export class VoxUserLayer extends UserLayer { voxUpperBound: Float32Array = new Float32Array([ 1_000_000, 1_000_000, 1_000_000, ]); + // Draw tool state + voxBrushRadius: number = 3; + voxEraseMode: boolean = false; private voxLoadedSubsource?: LoadedDataSubsource; constructor(managedLayer: Borrowed) { diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 81a06f8d57..24c8e1de7e 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -35,7 +35,8 @@ export class VoxelPixelLegacyTool extends LegacyTool { const vx = Math.floor(vox[0]); const vy = Math.floor(vox[1]); const vz = Math.floor(vox[2]); - (this.layer as any).voxEditController?.paintVoxel(new Float32Array([vx, vy, vz]), 42); + const value = (this.layer as any).voxEraseMode ? 0 : 42; + (this.layer as any).voxEditController?.paintVoxel(new Float32Array([vx, vy, vz]), value); } catch (e) { console.log('[VoxelPixelLegacyTool] Error computing voxel position:', e); } @@ -44,7 +45,6 @@ export class VoxelPixelLegacyTool extends LegacyTool { export class VoxelBrushLegacyTool extends LegacyTool { description = "brush"; - radius = 3; toJSON() { return BRUSH_TOOL_ID; } @@ -57,7 +57,9 @@ export class VoxelBrushLegacyTool extends LegacyTool { const cx = Math.floor(vox[0]); const cy = Math.floor(vox[1]); const cz = Math.floor(vox[2]); - (this.layer as any).voxEditController?.paintBrush(new Float32Array([cx, cy, cz]), this.radius, 42); + const radius = Math.max(1, Math.floor((this.layer as any).voxBrushRadius ?? 3)); + const value = (this.layer as any).voxEraseMode ? 0 : 42; + (this.layer as any).voxEditController?.paintBrush(new Float32Array([cx, cy, cz]), radius, value); } catch (e) { console.log('[VoxelBrushLegacyTool] Error:', e); } diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index ec35da8eca..239892b5f9 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -21,7 +21,7 @@ export class VoxelEditController { } } - /** Paint a simple spherical brush (3D) of integer radius around center. */ + /** Paint a simple spherical brush (3D) of integer radius around center */ paintBrush(center: Float32Array, radius: number, value: number) { if (!Number.isFinite(radius) || radius <= 0) return; const r = Math.floor(radius); @@ -39,14 +39,16 @@ export class VoxelEditController { // ignore } if (!source) return; + const voxels: Float32Array[] = []; for (let dz = -r; dz <= r; ++dz) { for (let dy = -r; dy <= r; ++dy) { for (let dx = -r; dx <= r; ++dx) { if (dx*dx + dy*dy + dz*dz <= rr) { - source.paintVoxel(new Float32Array([cx + dx, cy + dy, cz + dz]), value); + voxels.push(new Float32Array([cx + dx, cy + dy, cz + dz])); } } } } + source.paintVoxelsBatch(voxels, value); } } diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 872cff47f4..68c4a038eb 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -34,7 +34,6 @@ class VoxelEditOverlay { (baseArray as any)[idx] = val as any; } } - console.log(`Merged edits into chunk ${key}`); } getOverlayValue(key: string, localIndex: number): number | undefined { @@ -88,11 +87,35 @@ export class VoxChunkSource extends BaseVolumeChunkSource { this.invalidateChunkUpload(chunk); } } - console.log("Painted voxel at ", chunk, " to value ", value); // Request redraw. this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); } + /** Batch paint API to minimize GPU uploads by chunk. */ + paintVoxelsBatch(voxels: Float32Array[], value: number) { + if (!voxels || voxels.length === 0) return; + const affectedKeys = new Set(); + // Apply edits to overlay and collect affected chunk keys. + for (const v of voxels) { + if (!v) continue; + const { key, localIndex } = this.computeChunkKeyAndIndex(v); + if (localIndex < 0) continue; + this.overlay.applyEdit(key, localIndex, value); + affectedKeys.add(key); + } + // For each affected chunk currently resident on CPU, merge and re-upload once. + for (const key of affectedKeys) { + const chunk = this.chunks.get(key) as VolumeChunk | undefined; + if (!chunk) continue; + const baseArray = this.getCpuArrayForChunk(chunk); + if (!baseArray) continue; + this.overlay.mergeIntoChunkData(key, baseArray); + this.invalidateChunkUpload(chunk); + } + // Request redraw once. + this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); + } + /** getValueAt that respects overlay if present. */ override getValueAt(chunkPosition: Float32Array, channelAccess: any) { // Compute key and local position based on the provided chunkPosition in voxel coordinates. From cbff7a4537f65c034358944557ce0a0dda81504d Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:42 +0100 Subject: [PATCH 014/251] feat: continuous drawing and shape selection for the brush --- src/layer/vox/index.ts | 27 ++++ src/ui/voxel_annotations.ts | 174 ++++++++++++++++++++++-- src/voxel_annotation/edit_controller.ts | 63 ++++++--- src/voxel_annotation/renderlayer.ts | 2 +- 4 files changed, 234 insertions(+), 32 deletions(-) diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 52c0fe6c69..c9120f6477 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -206,6 +206,32 @@ class VoxToolTab extends Tab { erWrap.appendChild(erChk); toolbox.appendChild(erWrap); + // Brush shape selector + const shapeWrap = document.createElement("div"); + shapeWrap.style.display = "inline-flex"; + shapeWrap.style.alignItems = "center"; + shapeWrap.style.gap = "4px"; + const shapeLabel = document.createElement("label"); + shapeLabel.textContent = "Brush shape"; + const shapeSel = document.createElement("select"); + const optDisk = document.createElement("option"); + optDisk.value = "disk"; + optDisk.textContent = "disk"; + const optSphere = document.createElement("option"); + optSphere.value = "sphere"; + optSphere.textContent = "sphere"; + shapeSel.appendChild(optDisk); + shapeSel.appendChild(optSphere); + shapeSel.value = (this.layer.voxBrushShape === 'sphere') ? 'sphere' : 'disk'; + shapeSel.addEventListener("change", () => { + const v = shapeSel.value === 'sphere' ? 'sphere' : 'disk'; + this.layer.voxBrushShape = v; + shapeSel.value = v; + }); + shapeWrap.appendChild(shapeLabel); + shapeWrap.appendChild(shapeSel); + toolbox.appendChild(shapeWrap); + element.appendChild(toolbox); } } @@ -230,6 +256,7 @@ export class VoxUserLayer extends UserLayer { // Draw tool state voxBrushRadius: number = 3; voxEraseMode: boolean = false; + voxBrushShape: 'disk' | 'sphere' = 'disk'; private voxLoadedSubsource?: LoadedDataSubsource; constructor(managedLayer: Borrowed) { diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 24c8e1de7e..b4375a3b67 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -23,47 +23,191 @@ export const BRUSH_TOOL_ID = "voxBrush"; export class VoxelPixelLegacyTool extends LegacyTool { description = "pixel"; + private isDrawing = false; + private lastVoxel: Int32Array | undefined; + private mouseDisposer: (() => void) | undefined; + private onMouseUp = () => this.stopDrawing(); + toJSON() { return PIXEL_TOOL_ID; } + private getVoxel(mouseState: MouseSelectionState): Int32Array | undefined { + const vox = (this.layer as any).getVoxelPositionFromMouse?.(mouseState) as Float32Array | undefined; + if (!mouseState?.active || !vox) return undefined; + return new Int32Array([Math.floor(vox[0]), Math.floor(vox[1]), Math.floor(vox[2])]); + } + + private lineVoxels(a: Int32Array, b: Int32Array): Float32Array[] { + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + const dz = b[2] - a[2]; + const steps = Math.max(Math.abs(dx), Math.abs(dy), Math.abs(dz)); + const out: Float32Array[] = []; + if (steps <= 0) return out; + let lastX = a[0], lastY = a[1], lastZ = a[2]; + for (let s = 1; s <= steps; ++s) { + const x = Math.round(a[0] + (dx * s) / steps); + const y = Math.round(a[1] + (dy * s) / steps); + const z = Math.round(a[2] + (dz * s) / steps); + if (x !== lastX || y !== lastY || z !== lastZ) { + out.push(new Float32Array([x, y, z])); + lastX = x; lastY = y; lastZ = z; + } + } + return out; + } + + private startDrawing(mouseState: MouseSelectionState) { + if (this.isDrawing) return; + this.isDrawing = true; + const start = this.getVoxel(mouseState); + const value = (this.layer as any).voxEraseMode ? 0 : 42; + if (start) { + (this.layer as any).voxEditController?.paintVoxel(new Float32Array([start[0], start[1], start[2]]), value); + this.lastVoxel = start; + } + // Subscribe to mouse moves to continue drawing. + this.mouseDisposer = mouseState.changed.add(() => { + if (!this.isDrawing) return; + const cur = this.getVoxel(mouseState); + if (!cur) return; + const last = this.lastVoxel; + if (!last) { + (this.layer as any).voxEditController?.paintVoxel(new Float32Array([cur[0], cur[1], cur[2]]), value); + this.lastVoxel = cur; + return; + } + if (cur[0] === last[0] && cur[1] === last[1] && cur[2] === last[2]) return; + const voxels = this.lineVoxels(last, cur); + if (voxels.length > 0) { + (this.layer as any).voxEditController?.paintVoxelsBatch(voxels, value); + } + this.lastVoxel = cur; + }); + window.addEventListener('mouseup', this.onMouseUp, { once: true }); + } + + private stopDrawing() { + if (!this.isDrawing) return; + this.isDrawing = false; + this.lastVoxel = undefined; + if (this.mouseDisposer) { + this.mouseDisposer(); + this.mouseDisposer = undefined; + } + } + trigger(mouseState: MouseSelectionState) { if ((this.layer as any)?.constructor?.type !== 'vox') return; try { - const vox = (this.layer as any).getVoxelPositionFromMouse?.(mouseState) as Float32Array | undefined; - if (!mouseState?.active || !vox) return; - const vx = Math.floor(vox[0]); - const vy = Math.floor(vox[1]); - const vz = Math.floor(vox[2]); - const value = (this.layer as any).voxEraseMode ? 0 : 42; - (this.layer as any).voxEditController?.paintVoxel(new Float32Array([vx, vy, vz]), value); + this.startDrawing(mouseState); } catch (e) { console.log('[VoxelPixelLegacyTool] Error computing voxel position:', e); } } + + deactivate() { + this.stopDrawing(); + } } export class VoxelBrushLegacyTool extends LegacyTool { description = "brush"; + private isDrawing = false; + private lastCenter: Int32Array | undefined; + private mouseDisposer: (() => void) | undefined; + private onMouseUp = () => this.stopDrawing(); + toJSON() { return BRUSH_TOOL_ID; } + private getCenter(mouseState: MouseSelectionState): Int32Array | undefined { + const vox = (this.layer as any).getVoxelPositionFromMouse?.(mouseState) as Float32Array | undefined; + if (!mouseState?.active || !vox) return undefined; + return new Int32Array([Math.floor(vox[0]), Math.floor(vox[1]), Math.floor(vox[2])]); + } + + private lineCenters(a: Int32Array, b: Int32Array): Float32Array[] { + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + const dz = b[2] - a[2]; + const steps = Math.max(Math.abs(dx), Math.abs(dy), Math.abs(dz)); + const out: Float32Array[] = []; + if (steps <= 0) return out; + let lastX = a[0], lastY = a[1], lastZ = a[2]; + for (let s = 1; s <= steps; ++s) { + const x = Math.round(a[0] + (dx * s) / steps); + const y = Math.round(a[1] + (dy * s) / steps); + const z = Math.round(a[2] + (dz * s) / steps); + if (x !== lastX || y !== lastY || z !== lastZ) { + out.push(new Float32Array([x, y, z])); + lastX = x; lastY = y; lastZ = z; + } + } + return out; + } + + private startDrawing(mouseState: MouseSelectionState) { + if (this.isDrawing) return; + this.isDrawing = true; + const radius = Math.max(1, Math.floor((this.layer as any).voxBrushRadius ?? 3)); + const value = (this.layer as any).voxEraseMode ? 0 : 42; + const shape = ((this.layer as any).voxBrushShape === 'sphere') ? 'sphere' : 'disk'; + + const start = this.getCenter(mouseState); + if (start) { + (this.layer as any).voxEditController?.paintBrushWithShape(new Float32Array([start[0], start[1], start[2]]), radius, value, shape); + this.lastCenter = start; + } + + this.mouseDisposer = mouseState.changed.add(() => { + if (!this.isDrawing) return; + const cur = this.getCenter(mouseState); + if (!cur) return; + const last = this.lastCenter; + if (!last) { + (this.layer as any).voxEditController?.paintBrushWithShape(new Float32Array([cur[0], cur[1], cur[2]]), radius, value, shape); + this.lastCenter = cur; + return; + } + if (cur[0] === last[0] && cur[1] === last[1] && cur[2] === last[2]) return; + const centers = this.lineCenters(last, cur); + if (centers.length > 0) { + // Stamp the brush along the path. To minimize uploads, we can aggregate all voxels, but keep it simple by calling per center. + const ctrl = (this.layer as any).voxEditController; + for (const c of centers) { + ctrl?.paintBrushWithShape(c, radius, value, shape); + } + } + this.lastCenter = cur; + }); + window.addEventListener('mouseup', this.onMouseUp, { once: true }); + } + + private stopDrawing() { + if (!this.isDrawing) return; + this.isDrawing = false; + this.lastCenter = undefined; + if (this.mouseDisposer) { + this.mouseDisposer(); + this.mouseDisposer = undefined; + } + } + trigger(mouseState: MouseSelectionState) { if ((this.layer as any)?.constructor?.type !== 'vox') return; try { - const vox = (this.layer as any).getVoxelPositionFromMouse?.(mouseState) as Float32Array | undefined; - if (!mouseState?.active || !vox) return; - const cx = Math.floor(vox[0]); - const cy = Math.floor(vox[1]); - const cz = Math.floor(vox[2]); - const radius = Math.max(1, Math.floor((this.layer as any).voxBrushRadius ?? 3)); - const value = (this.layer as any).voxEraseMode ? 0 : 42; - (this.layer as any).voxEditController?.paintBrush(new Float32Array([cx, cy, cz]), radius, value); + this.startDrawing(mouseState); } catch (e) { console.log('[VoxelBrushLegacyTool] Error:', e); } } + + deactivate() { + this.stopDrawing(); + } } export function registerVoxelAnnotationTools() { diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 239892b5f9..4820d03b7f 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -10,45 +10,76 @@ import type { VoxChunkSource } from '#src/voxel_annotation/frontend.js'; export class VoxelEditController { constructor(private multiscale: MultiscaleVolumeChunkSource) {} - paintVoxel(voxel: Float32Array, value: number) { + private getSource(): VoxChunkSource | undefined { try { const sources2D = this.multiscale.getSources({} as any); const single = sources2D?.[0]?.[0]; - const source = single?.chunkSource as VoxChunkSource | undefined; + return single?.chunkSource as VoxChunkSource | undefined; + } catch { + return undefined; + } + } + + paintVoxel(voxel: Float32Array, value: number) { + try { + const source = this.getSource(); source?.paintVoxel(voxel, value); } catch { // no-op } } - /** Paint a simple spherical brush (3D) of integer radius around center */ - paintBrush(center: Float32Array, radius: number, value: number) { + paintVoxelsBatch(voxels: Float32Array[], value: number) { + if (!voxels || voxels.length === 0) return; + try { + const source = this.getSource(); + source?.paintVoxelsBatch(voxels, value); + } catch { + // no-op + } + } + + /** Paint a brush with selectable shape: 'disk' (2D in XY plane) or 'sphere' (3D). Default: 'disk'. */ + paintBrushWithShape( + center: Float32Array, + radius: number, + value: number, + shape: 'disk' | 'sphere' = 'disk', + ) { if (!Number.isFinite(radius) || radius <= 0) return; const r = Math.floor(radius); const cx = Math.floor(center[0] ?? 0); const cy = Math.floor(center[1] ?? 0); const cz = Math.floor(center[2] ?? 0); const rr = r * r; - // Attempt to get the source once to reduce repeated dereferencing. - let source: VoxChunkSource | undefined; - try { - const sources2D = this.multiscale.getSources({} as any); - const single = sources2D?.[0]?.[0]; - source = single?.chunkSource as VoxChunkSource | undefined; - } catch { - // ignore - } + const source = this.getSource(); if (!source) return; const voxels: Float32Array[] = []; - for (let dz = -r; dz <= r; ++dz) { + if (shape === 'sphere') { + for (let dz = -r; dz <= r; ++dz) { + for (let dy = -r; dy <= r; ++dy) { + for (let dx = -r; dx <= r; ++dx) { + if (dx * dx + dy * dy + dz * dz <= rr) { + voxels.push(new Float32Array([cx + dx, cy + dy, cz + dz])); + } + } + } + } + } else { + // Disk in XY plane at fixed Z = cz for (let dy = -r; dy <= r; ++dy) { for (let dx = -r; dx <= r; ++dx) { - if (dx*dx + dy*dy + dz*dz <= rr) { - voxels.push(new Float32Array([cx + dx, cy + dy, cz + dz])); + if (dx * dx + dy * dy <= rr) { + voxels.push(new Float32Array([cx + dx, cy + dy, cz])); } } } } source.paintVoxelsBatch(voxels, value); } + + /** Backward-compat spherical brush API. */ + paintBrush(center: Float32Array, radius: number, value: number) { + this.paintBrushWithShape(center, radius, value, 'sphere'); + } } diff --git a/src/voxel_annotation/renderlayer.ts b/src/voxel_annotation/renderlayer.ts index 9985f9dac3..bba100fbba 100644 --- a/src/voxel_annotation/renderlayer.ts +++ b/src/voxel_annotation/renderlayer.ts @@ -60,7 +60,7 @@ export class VoxelAnnotationRenderLayer extends SliceViewVolumeRenderLayer 10.0 ? vec4(1.0, 1.0, 0.0, 1.0) : vec4(clamp(t, 0.0, 1.0), 0.0, 0.0, t > 0.0 ? 0.3 : 0.0); + vec4 color = t > 10.0 ? vec4(1.0, 1.0, 0.0, 0.5) : vec4(clamp(t, 0.0, 1.0), 0.0, 0.0, t > 0.0 ? 0.3 : 0.0); emit(color); `); From bbe859f75763d7efd8092c9e0b743ca86cc7dd76 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:42 +0100 Subject: [PATCH 015/251] doc: add TODO list --- NOTES/TODOs.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 NOTES/TODOs.md diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md new file mode 100644 index 0000000000..fdbcf19ca9 --- /dev/null +++ b/NOTES/TODOs.md @@ -0,0 +1,7 @@ +# TODO List +- connect front and backend (see [data-saving-plan.md](../NOTES/data-saving-plan.md)) +- optimize drawing tools (they are really responsive rn) +- add color picker +- create persistant storage (e.g. via a server or look into IndexedDB) (see [data-saving-plan.md](../NOTES/data-saving-plan.md)) + + From 7eba8ec6e6120959da1b9fbcb96fcff84ea7b594 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:42 +0100 Subject: [PATCH 016/251] feat: small improvement on the drawing render delay --- NOTES/TODOs.md | 4 + src/layer/vox/index.ts | 89 ++++++-- src/sliceview/single_texture_chunk_format.ts | 72 +++++++ src/ui/voxel_annotations.ts | 213 ++++++++----------- src/voxel_annotation/edit_controller.ts | 43 +++- src/voxel_annotation/frontend.ts | 46 ++-- src/voxel_annotation/renderlayer.ts | 10 - 7 files changed, 313 insertions(+), 164 deletions(-) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index fdbcf19ca9..5bc58dabad 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -3,5 +3,9 @@ - optimize drawing tools (they are really responsive rn) - add color picker - create persistant storage (e.g. via a server or look into IndexedDB) (see [data-saving-plan.md](../NOTES/data-saving-plan.md)) +- Fix the orientation of the disk in the brush tool +# Questions +- do we really need a frontend buffer? Should this buffer be a simple list of pixels and passed to the shader outside of the chunking system, this would let the chunking behaviour be managed by the backend? +- should the backend contians the full map or should it just contain the displayed and surrounding chunks and retrieve the rest live from the datasource (this is a yes for me) diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index c9120f6477..e11122d4ae 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -247,7 +247,7 @@ export class VoxUserLayer extends UserLayer { // Settings state voxScale: Float64Array = new Float64Array([ - 0.00000008, 0.00000008, 0.00000008, + 0.000000008, 0.000000008, 0.000000008, ]); voxScaleUnit: string = "nm"; voxUpperBound: Float32Array = new Float32Array([ @@ -256,7 +256,7 @@ export class VoxUserLayer extends UserLayer { // Draw tool state voxBrushRadius: number = 3; voxEraseMode: boolean = false; - voxBrushShape: 'disk' | 'sphere' = 'disk'; + voxBrushShape: "disk" | "sphere" = "disk"; private voxLoadedSubsource?: LoadedDataSubsource; constructor(managedLayer: Borrowed) { @@ -317,36 +317,91 @@ export class VoxUserLayer extends UserLayer { ); } + private getModelToVoxTransform(): mat4 | undefined { + const identity3D = this.createIdentity3D(); + const watchable = getWatchableRenderLayerTransform( + this.manager.root.coordinateSpace, + this.localPosition.coordinateSpace, + identity3D, + undefined, + ); + const tOrError = watchable.value as any; + if (tOrError?.error) return undefined; + return ( + mat4.invert(mat4.create(), tOrError.modelToRenderLayerTransform) || + mat4.identity(mat4.create()) + ); + } + getVoxelPositionFromMouse( mouseState: MouseSelectionState, ): Float32Array | undefined { try { if (!mouseState?.active || !mouseState?.position) return undefined; - - // There might be a simpler way to retrieve global transform? - const identity3D = this.createIdentity3D(); - const watchable = getWatchableRenderLayerTransform( - this.manager.root.coordinateSpace, - this.localPosition.coordinateSpace, - identity3D, - undefined, - ); - const tOrError = watchable.value as any; - if (tOrError?.error) return undefined; - + const inv = this.getModelToVoxTransform(); + if (!inv) return undefined; const p = mouseState.position; - return vec3.transformMat4( vec3.create(), vec3.fromValues(p[0], p[1], p[2]), - mat4.invert(mat4.create(), tOrError.modelToRenderLayerTransform) || - mat4.identity(mat4.create()), + inv, ); } catch { return undefined; } } + /** Returns in-plane basis vectors (u, v) for the current slice plane in voxel coordinates. + * Uses MouseSelectionState.displayDimensions to select the two displayed axes, then maps unit + * vectors along those axes through the renderLayer->voxel transform. + * TODO: this is not working, ai are dogshit at 3d stuffs. + */ + getBrushPlaneBasis(mouseState?: MouseSelectionState): { u: Float32Array; v: Float32Array } | undefined { + try { + const inv = this.getModelToVoxTransform(); + if (!inv) return undefined; + const di = mouseState?.displayDimensions?.displayDimensionIndices; + const rank = mouseState?.displayDimensions?.displayRank ?? 0; + const i0 = (di && rank >= 2) ? di[0] : 0; + const i1 = (di && rank >= 2) ? di[1] : 1; + + // Build origin and unit vectors in model/render-layer coordinate space aligned to displayed axes. + const p0 = vec3.transformMat4(vec3.create(), vec3.fromValues(0, 0, 0), inv); + const uModel = [0, 0, 0] as number[]; + const vModel = [0, 0, 0] as number[]; + if (i0 >= 0 && i0 < 3) uModel[i0] = 1; + if (i1 >= 0 && i1 < 3) vModel[i1] = 1; + const pU = vec3.transformMat4( + vec3.create(), + vec3.fromValues(uModel[0], uModel[1], uModel[2]), + inv, + ); + const pV = vec3.transformMat4( + vec3.create(), + vec3.fromValues(vModel[0], vModel[1], vModel[2]), + inv, + ); + + // Compute direction vectors and normalize. + const ux = pU[0] - p0[0]; + const uy = pU[1] - p0[1]; + const uz = pU[2] - p0[2]; + const vx = pV[0] - p0[0]; + const vy = pV[1] - p0[1]; + const vz = pV[2] - p0[2]; + + const ul = Math.hypot(ux, uy, uz); + const vl = Math.hypot(vx, vy, vz); + if (!Number.isFinite(ul) || ul === 0 || !Number.isFinite(vl) || vl === 0) return undefined; + + const u = new Float32Array([ux / ul, uy / ul, uz / ul]); + const v = new Float32Array([vx / vl, vy / vl, vz / vl]); + return { u, v }; + } catch { + return undefined; + } + } + private buildOrRebuildVoxLayer() { const ls = this.voxLoadedSubsource; if (!ls) return; diff --git a/src/sliceview/single_texture_chunk_format.ts b/src/sliceview/single_texture_chunk_format.ts index 53468aedfd..ae00b64f03 100644 --- a/src/sliceview/single_texture_chunk_format.ts +++ b/src/sliceview/single_texture_chunk_format.ts @@ -146,6 +146,78 @@ export abstract class SingleTextureVolumeChunk< gl.bindTexture(textureTarget, null); } + updateFromCpuData(gl: GL, _region?: { offset: Uint32Array; size: Uint32Array }) { + if (this.data == null) return; + + // If there is no existing texture, just perform the normal upload path. + if (this.texture == null) { + this.copyToGPU(gl); + return; + } + + const fmt = this.chunkFormat as any; // Both uncompressed and compressed implement TextureFormat-like fields + const textureTarget = textureTargetForSamplerType[this.chunkFormat.shaderSamplerType]; + gl.bindTexture(textureTarget, this.texture); + gl.pixelStorei(WebGL2RenderingContext.UNPACK_ALIGNMENT, 1); + + // If we have a textureLayout with a definite shape (uncompressed path), we can sub-update. + const layout: any = this.textureLayout; + const hasShape = layout && layout.textureShape && layout.textureShape.length >= 2; + + try { + // Prefer texSubImage path when we can compute exact sizes (uncompressed formats): + if (hasShape && typeof fmt.textureDims === 'number') { + const texelsPerElement = fmt.texelsPerElement ?? 1; + const w = layout.textureShape[0] * texelsPerElement; + const h = layout.textureShape[1] ?? 1; + const d = fmt.textureDims === 3 ? (layout.textureShape[2] ?? 1) : undefined; + + // Ensure typed array type matches GL expectations + let data: any = this.data; + const ctor = fmt.arrayConstructor as { new (b: ArrayBuffer, o: number, l: number): any } | undefined; + if (ctor && data.constructor !== ctor) { + data = new (ctor as any)(data.buffer, data.byteOffset, data.byteLength / (ctor as any).BYTES_PER_ELEMENT); + } + + if (fmt.textureDims === 3 && d !== undefined) { + // 3D update + gl.texSubImage3D( + WebGL2RenderingContext.TEXTURE_3D, + /*level=*/ 0, + /*xoffset=*/ 0, + /*yoffset=*/ 0, + /*zoffset=*/ 0, + /*width=*/ w, + /*height=*/ h, + /*depth=*/ d, + fmt.textureFormat, + fmt.texelType, + data, + ); + } else { + // 2D update + gl.texSubImage2D( + WebGL2RenderingContext.TEXTURE_2D, + /*level=*/ 0, + /*xoffset=*/ 0, + /*yoffset=*/ 0, + /*width=*/ w, + /*height=*/ h, + fmt.textureFormat, + fmt.texelType, + data, + ); + } + } else { + // Fallback: re-specify the texture contents onto the existing texture object. + // This still avoids delete+create and the associated driver sync. + this.setTextureData(gl); + } + } finally { + gl.bindTexture(textureTarget, null); + } + } + freeGPUMemory(gl: GL) { super.freeGPUMemory(gl); if (this.data === null) return; diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index b4375a3b67..f7fbaca76a 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -21,77 +21,89 @@ import { LegacyTool, registerLegacyTool } from "#src/ui/tool.js"; export const PIXEL_TOOL_ID = "voxPixel"; export const BRUSH_TOOL_ID = "voxBrush"; -export class VoxelPixelLegacyTool extends LegacyTool { - description = "pixel"; - private isDrawing = false; - private lastVoxel: Int32Array | undefined; - private mouseDisposer: (() => void) | undefined; - private onMouseUp = () => this.stopDrawing(); - - toJSON() { - return PIXEL_TOOL_ID; - } - - private getVoxel(mouseState: MouseSelectionState): Int32Array | undefined { - const vox = (this.layer as any).getVoxelPositionFromMouse?.(mouseState) as Float32Array | undefined; +abstract class BaseVoxelLegacyTool extends LegacyTool { + protected isDrawing = false; + protected lastPoint: Int32Array | undefined; + protected mouseDisposer: (() => void) | undefined; + protected onMouseUp = () => this.stopDrawing(); + protected currentMouseState: MouseSelectionState | undefined; + + protected getPoint(mouseState: MouseSelectionState): Int32Array | undefined { + const vox = (this.layer as any).getVoxelPositionFromMouse?.(mouseState) as + | Float32Array + | undefined; if (!mouseState?.active || !vox) return undefined; - return new Int32Array([Math.floor(vox[0]), Math.floor(vox[1]), Math.floor(vox[2])]); + return new Int32Array([ + Math.floor(vox[0]), + Math.floor(vox[1]), + Math.floor(vox[2]), + ]); } - private lineVoxels(a: Int32Array, b: Int32Array): Float32Array[] { + protected linePoints(a: Int32Array, b: Int32Array): Float32Array[] { const dx = b[0] - a[0]; const dy = b[1] - a[1]; const dz = b[2] - a[2]; const steps = Math.max(Math.abs(dx), Math.abs(dy), Math.abs(dz)); const out: Float32Array[] = []; if (steps <= 0) return out; - let lastX = a[0], lastY = a[1], lastZ = a[2]; + let lastX = a[0], + lastY = a[1], + lastZ = a[2]; for (let s = 1; s <= steps; ++s) { const x = Math.round(a[0] + (dx * s) / steps); const y = Math.round(a[1] + (dy * s) / steps); const z = Math.round(a[2] + (dz * s) / steps); if (x !== lastX || y !== lastY || z !== lastZ) { out.push(new Float32Array([x, y, z])); - lastX = x; lastY = y; lastZ = z; + lastX = x; + lastY = y; + lastZ = z; } } return out; } - private startDrawing(mouseState: MouseSelectionState) { + protected abstract paintPoint(point: Float32Array, value: number): void; + protected abstract paintPoints(points: Float32Array[], value: number): void; + + protected startDrawing(mouseState: MouseSelectionState) { if (this.isDrawing) return; this.isDrawing = true; - const start = this.getVoxel(mouseState); + this.currentMouseState = mouseState; const value = (this.layer as any).voxEraseMode ? 0 : 42; + const start = this.getPoint(mouseState); if (start) { - (this.layer as any).voxEditController?.paintVoxel(new Float32Array([start[0], start[1], start[2]]), value); - this.lastVoxel = start; + this.paintPoint(new Float32Array([start[0], start[1], start[2]]), value); + this.lastPoint = start; } - // Subscribe to mouse moves to continue drawing. + this.mouseDisposer = mouseState.changed.add(() => { if (!this.isDrawing) return; - const cur = this.getVoxel(mouseState); + this.currentMouseState = mouseState; + const cur = this.getPoint(mouseState); if (!cur) return; - const last = this.lastVoxel; + const last = this.lastPoint; if (!last) { - (this.layer as any).voxEditController?.paintVoxel(new Float32Array([cur[0], cur[1], cur[2]]), value); - this.lastVoxel = cur; + this.paintPoint(new Float32Array([cur[0], cur[1], cur[2]]), value); + this.lastPoint = cur; return; } - if (cur[0] === last[0] && cur[1] === last[1] && cur[2] === last[2]) return; - const voxels = this.lineVoxels(last, cur); - if (voxels.length > 0) { - (this.layer as any).voxEditController?.paintVoxelsBatch(voxels, value); + if (cur[0] === last[0] && cur[1] === last[1] && cur[2] === last[2]) + return; + const points = this.linePoints(last, cur); + if (points.length > 0) { + this.paintPoints(points, value); } - this.lastVoxel = cur; + this.lastPoint = cur; }); - window.addEventListener('mouseup', this.onMouseUp, { once: true }); + window.addEventListener("mouseup", this.onMouseUp, { once: true }); } - private stopDrawing() { + protected stopDrawing() { if (!this.isDrawing) return; this.isDrawing = false; - this.lastVoxel = undefined; + this.lastPoint = undefined; if (this.mouseDisposer) { this.mouseDisposer(); this.mouseDisposer = undefined; @@ -99,11 +111,11 @@ export class VoxelPixelLegacyTool extends LegacyTool { } trigger(mouseState: MouseSelectionState) { - if ((this.layer as any)?.constructor?.type !== 'vox') return; + if ((this.layer as any)?.constructor?.type !== "vox") return; try { this.startDrawing(mouseState); } catch (e) { - console.log('[VoxelPixelLegacyTool] Error computing voxel position:', e); + console.log(`[${this.constructor.name}] Error:`, e); } } @@ -112,105 +124,68 @@ export class VoxelPixelLegacyTool extends LegacyTool { } } -export class VoxelBrushLegacyTool extends LegacyTool { - description = "brush"; - private isDrawing = false; - private lastCenter: Int32Array | undefined; - private mouseDisposer: (() => void) | undefined; - private onMouseUp = () => this.stopDrawing(); +export class VoxelPixelLegacyTool extends BaseVoxelLegacyTool { + description = "pixel"; toJSON() { - return BRUSH_TOOL_ID; + return PIXEL_TOOL_ID; } - private getCenter(mouseState: MouseSelectionState): Int32Array | undefined { - const vox = (this.layer as any).getVoxelPositionFromMouse?.(mouseState) as Float32Array | undefined; - if (!mouseState?.active || !vox) return undefined; - return new Int32Array([Math.floor(vox[0]), Math.floor(vox[1]), Math.floor(vox[2])]); + protected paintPoint(point: Float32Array, value: number) { + (this.layer as any).voxEditController?.paintVoxel(point, value); } - private lineCenters(a: Int32Array, b: Int32Array): Float32Array[] { - const dx = b[0] - a[0]; - const dy = b[1] - a[1]; - const dz = b[2] - a[2]; - const steps = Math.max(Math.abs(dx), Math.abs(dy), Math.abs(dz)); - const out: Float32Array[] = []; - if (steps <= 0) return out; - let lastX = a[0], lastY = a[1], lastZ = a[2]; - for (let s = 1; s <= steps; ++s) { - const x = Math.round(a[0] + (dx * s) / steps); - const y = Math.round(a[1] + (dy * s) / steps); - const z = Math.round(a[2] + (dz * s) / steps); - if (x !== lastX || y !== lastY || z !== lastZ) { - out.push(new Float32Array([x, y, z])); - lastX = x; lastY = y; lastZ = z; - } - } - return out; + protected paintPoints(points: Float32Array[], value: number) { + (this.layer as any).voxEditController?.paintVoxelsBatch(points, value); } +} - private startDrawing(mouseState: MouseSelectionState) { - if (this.isDrawing) return; - this.isDrawing = true; - const radius = Math.max(1, Math.floor((this.layer as any).voxBrushRadius ?? 3)); - const value = (this.layer as any).voxEraseMode ? 0 : 42; - const shape = ((this.layer as any).voxBrushShape === 'sphere') ? 'sphere' : 'disk'; - - const start = this.getCenter(mouseState); - if (start) { - (this.layer as any).voxEditController?.paintBrushWithShape(new Float32Array([start[0], start[1], start[2]]), radius, value, shape); - this.lastCenter = start; - } +export class VoxelBrushLegacyTool extends BaseVoxelLegacyTool { + description = "brush"; - this.mouseDisposer = mouseState.changed.add(() => { - if (!this.isDrawing) return; - const cur = this.getCenter(mouseState); - if (!cur) return; - const last = this.lastCenter; - if (!last) { - (this.layer as any).voxEditController?.paintBrushWithShape(new Float32Array([cur[0], cur[1], cur[2]]), radius, value, shape); - this.lastCenter = cur; - return; - } - if (cur[0] === last[0] && cur[1] === last[1] && cur[2] === last[2]) return; - const centers = this.lineCenters(last, cur); - if (centers.length > 0) { - // Stamp the brush along the path. To minimize uploads, we can aggregate all voxels, but keep it simple by calling per center. - const ctrl = (this.layer as any).voxEditController; - for (const c of centers) { - ctrl?.paintBrushWithShape(c, radius, value, shape); - } - } - this.lastCenter = cur; - }); - window.addEventListener('mouseup', this.onMouseUp, { once: true }); + toJSON() { + return BRUSH_TOOL_ID; } - private stopDrawing() { - if (!this.isDrawing) return; - this.isDrawing = false; - this.lastCenter = undefined; - if (this.mouseDisposer) { - this.mouseDisposer(); - this.mouseDisposer = undefined; - } + protected paintPoint(point: Float32Array, value: number) { + const radius = Math.max( + 1, + Math.floor((this.layer as any).voxBrushRadius ?? 3), + ); + const shape = + (this.layer as any).voxBrushShape === "sphere" ? "sphere" : "disk"; + const basis = shape === "disk" ? (this.layer as any).getBrushPlaneBasis?.(this.currentMouseState) : undefined; + (this.layer as any).voxEditController?.paintBrushWithShape( + point, + radius, + value, + shape, + basis, + ); } - trigger(mouseState: MouseSelectionState) { - if ((this.layer as any)?.constructor?.type !== 'vox') return; - try { - this.startDrawing(mouseState); - } catch (e) { - console.log('[VoxelBrushLegacyTool] Error:', e); + protected paintPoints(points: Float32Array[], value: number) { + const radius = Math.max( + 1, + Math.floor((this.layer as any).voxBrushRadius ?? 3), + ); + const shape = + (this.layer as any).voxBrushShape === "sphere" ? "sphere" : "disk"; + const ctrl = (this.layer as any).voxEditController; + const basis = shape === "disk" ? (this.layer as any).getBrushPlaneBasis?.(this.currentMouseState) : undefined; + for (const point of points) { + ctrl?.paintBrushWithShape(point, radius, value, shape, basis); } } - - deactivate() { - this.stopDrawing(); - } } export function registerVoxelAnnotationTools() { - registerLegacyTool(PIXEL_TOOL_ID, (layer) => new VoxelPixelLegacyTool(layer as unknown as VoxUserLayer)); - registerLegacyTool(BRUSH_TOOL_ID, (layer) => new VoxelBrushLegacyTool(layer as unknown as VoxUserLayer)); + registerLegacyTool( + PIXEL_TOOL_ID, + (layer) => new VoxelPixelLegacyTool(layer as unknown as VoxUserLayer), + ); + registerLegacyTool( + BRUSH_TOOL_ID, + (layer) => new VoxelBrushLegacyTool(layer as unknown as VoxUserLayer), + ); } diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 4820d03b7f..0571469a36 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -39,12 +39,13 @@ export class VoxelEditController { } } - /** Paint a brush with selectable shape: 'disk' (2D in XY plane) or 'sphere' (3D). Default: 'disk'. */ + /** Paint a brush with selectable shape: 'disk' (2D oriented to slice plane) or 'sphere' (3D). Default: 'disk'. */ paintBrushWithShape( center: Float32Array, radius: number, value: number, shape: 'disk' | 'sphere' = 'disk', + basis?: { u: Float32Array; v: Float32Array }, ) { if (!Number.isFinite(radius) || radius <= 0) return; const r = Math.floor(radius); @@ -66,11 +67,41 @@ export class VoxelEditController { } } } else { - // Disk in XY plane at fixed Z = cz - for (let dy = -r; dy <= r; ++dy) { - for (let dx = -r; dx <= r; ++dx) { - if (dx * dx + dy * dy <= rr) { - voxels.push(new Float32Array([cx + dx, cy + dy, cz])); + // Oriented disk in the provided slice plane; if basis not provided, fall back to XY at fixed Z = cz. + const u = basis?.u; + const v = basis?.v; + if (u && v && Number.isFinite(u[0]) && Number.isFinite(u[1]) && Number.isFinite(u[2]) && Number.isFinite(v[0]) && Number.isFinite(v[1]) && Number.isFinite(v[2])) { + // Normalize u and v for safety. + const ul = Math.hypot(u[0], u[1], u[2]) || 1; + const vl = Math.hypot(v[0], v[1], v[2]) || 1; + const un = [u[0] / ul, u[1] / ul, u[2] / ul]; + const vn = [v[0] / vl, v[1] / vl, v[2] / vl]; + const seen = new Set(); + for (let dy = -r; dy <= r; ++dy) { + for (let dx = -r; dx <= r; ++dx) { + if (dx * dx + dy * dy <= rr) { + const px = cx + dx * un[0] + dy * vn[0]; + const py = cy + dx * un[1] + dy * vn[1]; + const pz = cz + dx * un[2] + dy * vn[2]; + const ix = Math.round(px); + const iy = Math.round(py); + const iz = Math.round(pz); + const key = ix + ',' + iy + ',' + iz; + if (!seen.has(key)) { + seen.add(key); + voxels.push(new Float32Array([ix, iy, iz])); + } + } + } + } + } else { + console.warn('No basis provided for disk brush, falling back to XY plane at fixed Z = cz.'); + // Fallback: Disk in XY plane at fixed Z = cz + for (let dy = -r; dy <= r; ++dy) { + for (let dx = -r; dx <= r; ++dx) { + if (dx * dx + dy * dy <= rr) { + voxels.push(new Float32Array([cx + dx, cy + dy, cz])); + } } } } diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 68c4a038eb..ecddbd3c00 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -8,6 +8,7 @@ import type { ChunkManager } from '#src/chunk_manager/frontend.js'; import type { VolumeChunkSpecification } from '#src/sliceview/volume/base.js'; import type { VolumeChunk } from '#src/sliceview/volume/frontend.js'; import { VolumeChunkSource as BaseVolumeChunkSource } from '#src/sliceview/volume/frontend.js'; +import { animationFrameDebounce } from "#src/util/animation_frame_debounce.js"; import type { TypedArray } from '#src/util/array.js'; import { VOX_CHUNK_SOURCE_RPC_ID } from '#src/voxel_annotation/base.js'; import { registerSharedObjectOwner } from "#src/worker_rpc.js"; @@ -23,7 +24,6 @@ class VoxelEditOverlay { this.edits.set(key, m); } m.set(localIndex, value); - console.log(`Applied edit to chunk ${key} at index ${localIndex} with value ${value}`); } mergeIntoChunkData(key: string, baseArray: TypedArray) { @@ -58,6 +58,10 @@ export class VoxChunkSource extends BaseVolumeChunkSource { private overlay = new VoxelEditOverlay(); private tempVoxChunkGridPosition = new Float32Array(3); private tempLocalPosition = new Uint32Array(3); + private dirtyChunks = new Set(); + private scheduleProcessPendingUploads = animationFrameDebounce( + () => this.processPendingUploads() + ); constructor(chunkManager: ChunkManager, options: { spec: VolumeChunkSpecification }) { super(chunkManager, options); @@ -84,10 +88,29 @@ export class VoxChunkSource extends BaseVolumeChunkSource { if (baseArray) { // Merge and mark for re-upload. this.overlay.mergeIntoChunkData(key, baseArray); - this.invalidateChunkUpload(chunk); + this.scheduleUpdate(key); } } - // Request redraw. + } + + private scheduleUpdate(key: string) { + this.dirtyChunks.add(key); + this.scheduleProcessPendingUploads(); + } + + private processPendingUploads() { + for (const key of this.dirtyChunks) { + const chunk = this.chunks.get(key) as VolumeChunk | undefined; + if (chunk && this.getCpuArrayForChunk(chunk)) { + // The original blocking upload logic is now here + const gl = chunk.gl; + if (chunk.state === ChunkState.GPU_MEMORY) { + chunk.freeGPUMemory(gl); + } + chunk.copyToGPU(gl); + } + } + this.dirtyChunks.clear(); this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); } @@ -110,11 +133,9 @@ export class VoxChunkSource extends BaseVolumeChunkSource { const baseArray = this.getCpuArrayForChunk(chunk); if (!baseArray) continue; this.overlay.mergeIntoChunkData(key, baseArray); - this.invalidateChunkUpload(chunk); + this.scheduleUpdate(key); + } } - // Request redraw once. - this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); - } /** getValueAt that respects overlay if present. */ override getValueAt(chunkPosition: Float32Array, channelAccess: any) { @@ -174,13 +195,14 @@ export class VoxChunkSource extends BaseVolumeChunkSource { } private invalidateChunkUpload(chunk: VolumeChunk) { - // Re-upload updated CPU data to the GPU immediately so the chunk continues to render. const gl = chunk.gl; - if (chunk.state === ChunkState.GPU_MEMORY) { - // Release the old texture before uploading the new data to avoid leaks. - chunk.freeGPUMemory(gl); + // If already on GPU and the concrete implementation supports in-place update, use it. + const anyChunk = chunk as any; + if (chunk.state === ChunkState.GPU_MEMORY && typeof anyChunk.updateFromCpuData === 'function') { + anyChunk.updateFromCpuData(gl); + return; } - // Upload the latest CPU-side data and mark the chunk as GPU resident. + // Otherwise, just upload (don’t free first). chunk.copyToGPU(gl); } } diff --git a/src/voxel_annotation/renderlayer.ts b/src/voxel_annotation/renderlayer.ts index bba100fbba..153d6f47ef 100644 --- a/src/voxel_annotation/renderlayer.ts +++ b/src/voxel_annotation/renderlayer.ts @@ -34,16 +34,6 @@ type EmptyParams = Record; export class VoxelAnnotationRenderLayer extends SliceViewVolumeRenderLayer { - override draw(renderContext: any) { - const { sliceView } = renderContext; - const layerInfo = sliceView.visibleLayers.get(this); - if (layerInfo === undefined) { - // Visible layers not ready yet; skip drawing this frame. - return; - } - console.log("drawn") - super.draw(renderContext); - } constructor( multiscaleSource: MultiscaleVolumeChunkSource, options: RenderLayerOptions, From 119450fa8eb586b816e8faaec04476178e8dd4b9 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:42 +0100 Subject: [PATCH 017/251] feat: persist voxel edits to backend and improve drawing responsiveness --- NOTES/TODOs.md | 6 +- src/voxel_annotation/backend.ts | 115 +++++++++++++++------- src/voxel_annotation/base.ts | 1 + src/voxel_annotation/frontend.ts | 163 ++++++++++++------------------- 4 files changed, 145 insertions(+), 140 deletions(-) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index 5bc58dabad..3d07412482 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,11 +1,11 @@ # TODO List - connect front and backend (see [data-saving-plan.md](../NOTES/data-saving-plan.md)) -- optimize drawing tools (they are really responsive rn) +- optimize drawing tools (they are not really responsive rn) - add color picker -- create persistant storage (e.g. via a server or look into IndexedDB) (see [data-saving-plan.md](../NOTES/data-saving-plan.md)) +- create persistant storage (e.g. via a server and local with IndexedDB) (see [data-saving-plan.md](../NOTES/data-saving-plan.md)) - Fix the orientation of the disk in the brush tool # Questions - do we really need a frontend buffer? Should this buffer be a simple list of pixels and passed to the shader outside of the chunking system, this would let the chunking behaviour be managed by the backend? -- should the backend contians the full map or should it just contain the displayed and surrounding chunks and retrieve the rest live from the datasource (this is a yes for me) +- should the backend contain the full map or should it just contain the displayed and surrounding chunks and retrieve the rest live from the datasource (this is a yes for me) diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 10f9934978..572398e728 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -6,61 +6,93 @@ import type { VolumeChunk } from '#src/sliceview/volume/backend.js'; import { VolumeChunkSource as BaseVolumeChunkSource } from '#src/sliceview/volume/backend.js'; import { DataType } from '#src/util/data_type.js'; -import { VOX_CHUNK_SOURCE_RPC_ID } from "#src/voxel_annotation/base.js"; +import { VOX_CHUNK_SOURCE_RPC_ID, VOX_COMMIT_VOXELS_RPC_ID } from "#src/voxel_annotation/base.js"; import type { RPC } from '#src/worker_rpc.js'; -import { registerSharedObject } from '#src/worker_rpc.js'; +import { registerRPC, registerSharedObject } from '#src/worker_rpc.js'; +/** Backend-side persisted storage for voxel annotation chunks. */ +interface SavedChunk { + data: ArrayBufferView; + size: Uint32Array; // [sx, sy, sz] used for linearization +} /** - * Minimal backend volume source that procedurally generates data for voxel annotations demo. - * It fills chunk.data with a simple pattern (checkerboard based on voxel coords). + * Backend volume source that persists voxel edits per chunk. It returns saved data if available, + * otherwise returns an empty chunk (filled with zeros). */ @registerSharedObject(VOX_CHUNK_SOURCE_RPC_ID) export class VoxChunkSource extends BaseVolumeChunkSource { + private saved = new Map(); + constructor(rpc: RPC, options: any) { super(rpc, options); } + /** Commit voxel edits from the frontend. */ + commitVoxels(edits: { key: string; indices: number[] | Uint32Array; value?: number; values?: ArrayLike; size?: number[] }[]) { + const { dataType } = this.spec; + for (const e of edits) { + const key = e.key; + // Determine the target size for this chunk. If not provided, fall back to spec chunkDataSize. + const sizeArr = e.size ? Uint32Array.from(e.size) : this.spec.chunkDataSize; + let entry = this.saved.get(key); + if (!entry || !arraysEqual(entry.size, sizeArr)) { + // Allocate new backing store for this key with the requested size. + const total = sizeArr[0] * sizeArr[1] * sizeArr[2]; + const arr = this.allocateTypedArray(dataType, total, 0); + entry = { data: arr, size: Uint32Array.from(sizeArr) }; + this.saved.set(key, entry); + } + const dest = entry.data as any; + const idxs: number[] = Array.from(e.indices as ArrayLike) as number[]; + if (e.values != null) { + const vals: ArrayLike = e.values as ArrayLike; + const n = Math.min(idxs.length, vals.length); + for (let i = 0; i < n; ++i) { + const idx = (idxs[i] as number) | 0; + if (idx >= 0 && idx < (dest as ArrayLike).length) (dest as any)[idx] = vals[i] as any; + } + } else if (e.value != null) { + const v = e.value as number; + for (let i = 0; i < idxs.length; ++i) { + const idx = (idxs[i] as number) | 0; + if (idx >= 0 && idx < (dest as ArrayLike).length) (dest as any)[idx] = v as any; + } + } + } + } + async download(chunk: VolumeChunk, signal: AbortSignal): Promise { - // Respect aborts if (signal.aborted) throw signal.reason ?? new Error('aborted'); - const { spec } = this; - const { dataType, fillValue } = spec; - // Compute bounds and chunkDataSize (may be clipped at upper bound) - const origin = this.computeChunkBounds(chunk); - - // Allocate a typed array matching the spec.dataType and size - const size = this.getChunkVoxelCount(chunk); - const array = this.allocateTypedArray(dataType, size, Number(fillValue ?? 0)); - - // Populate a simple 3D pattern for visualization - const d = 1; + // Determine chunk key and size (may be clipped at upper bound). + this.computeChunkBounds(chunk); const cds = chunk.chunkDataSize!; - let index = 0; - for (let z = 0; z < cds[2]; ++z) { - for (let y = 0; y < cds[1]; ++y) { - for (let x = 0; x < cds[0]; ++x, ++index) { - const gx = origin[0] + x; - const gy = origin[1] + y; - const gz = origin[2] + z; - // Checker pattern in world space with large squares - const square = ((Math.floor(gx / d) + Math.floor(gy / d) + Math.floor(gz / d)) & 1) !== 0; - array[index] = square ? 5 : 0; + const key = chunk.chunkGridPosition.join(); + const total = cds[0] * cds[1] * cds[2]; + const array = this.allocateTypedArray(this.spec.dataType, total, 0); + const saved = this.saved.get(key); + if (saved) { + // Copy overlapping region from saved into array, accounting for possibly different strides. + const sxS = saved.size[0], syS = saved.size[1], szS = saved.size[2]; + const sxD = cds[0], syD = cds[1], szD = cds[2]; + const ox = Math.min(sxS, sxD); + const oy = Math.min(syS, syD); + const oz = Math.min(szS, szD); + const src = saved.data as any; + const dst = array as any; + for (let z = 0; z < oz; ++z) { + for (let y = 0; y < oy; ++y) { + const baseSrc = (z * syS + y) * sxS; + const baseDst = (z * syD + y) * sxD; + for (let x = 0; x < ox; ++x) { + dst[baseDst + x] = src[baseSrc + x]; + } } } } - - // Stash data on chunk for transfer to frontend (chunk as any).data = array; } - private getChunkVoxelCount(chunk: VolumeChunk) { - const cds = chunk.chunkDataSize!; - let n = 1; - for (let i = 0; i < cds.length; ++i) n *= cds[i]; - return n; - } - private allocateTypedArray(dataType: number, size: number, fill: number) { switch (dataType) { case DataType.UINT8: @@ -76,7 +108,6 @@ export class VoxChunkSource extends BaseVolumeChunkSource { case DataType.INT32: return new Int32Array(size).fill(fill | 0); case DataType.UINT64: { - // Represent as 64-bit unsigned. Use BigUint64Array; frontend will reinterpret as Uint32Array. const big = BigInt(fill >>> 0); return new BigUint64Array(size).fill(big); } @@ -87,3 +118,15 @@ export class VoxChunkSource extends BaseVolumeChunkSource { } } } + +function arraysEqual(a: Uint32Array, b: Uint32Array) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; ++i) if (a[i] !== b[i]) return false; + return true; +} + +// RPC to commit voxel edits. +registerRPC(VOX_COMMIT_VOXELS_RPC_ID, function (x: any) { + const obj = this.get(x.id) as VoxChunkSource; + obj.commitVoxels(x.edits || []); +}); diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index 9e8830311e..96b7f444c3 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -1 +1,2 @@ export const VOX_CHUNK_SOURCE_RPC_ID = 'vox.VoxChunkSource'; +export const VOX_COMMIT_VOXELS_RPC_ID = 'vox.commitVoxels'; diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index ecddbd3c00..58d013a8eb 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -10,52 +10,15 @@ import type { VolumeChunk } from '#src/sliceview/volume/frontend.js'; import { VolumeChunkSource as BaseVolumeChunkSource } from '#src/sliceview/volume/frontend.js'; import { animationFrameDebounce } from "#src/util/animation_frame_debounce.js"; import type { TypedArray } from '#src/util/array.js'; -import { VOX_CHUNK_SOURCE_RPC_ID } from '#src/voxel_annotation/base.js'; +import { VOX_CHUNK_SOURCE_RPC_ID, VOX_COMMIT_VOXELS_RPC_ID } from '#src/voxel_annotation/base.js'; import { registerSharedObjectOwner } from "#src/worker_rpc.js"; -/** Small sparse overlay storing per-chunk edits as index->value maps. */ -class VoxelEditOverlay { - private edits = new Map>(); - - applyEdit(key: string, localIndex: number, value: number) { - let m = this.edits.get(key); - if (!m) { - m = new Map(); - this.edits.set(key, m); - } - m.set(localIndex, value); - } - - mergeIntoChunkData(key: string, baseArray: TypedArray) { - const m = this.edits.get(key); - if (!m) return; - for (const [idx, val] of m) { - if (idx >= 0 && idx < baseArray.length) { - (baseArray as any)[idx] = val as any; - } - } - } - - getOverlayValue(key: string, localIndex: number): number | undefined { - const m = this.edits.get(key); - return m?.get(localIndex); - } - - discardChunk(key: string) { - this.edits.delete(key); - } - - clear() { - this.edits.clear(); - } -} /** * Frontend owner for VoxChunkSource, extended with a local optimistic edit overlay. */ @registerSharedObjectOwner(VOX_CHUNK_SOURCE_RPC_ID) export class VoxChunkSource extends BaseVolumeChunkSource { - private overlay = new VoxelEditOverlay(); private tempVoxChunkGridPosition = new Float32Array(3); private tempLocalPosition = new Uint32Array(3); private dirtyChunks = new Set(); @@ -67,30 +30,32 @@ export class VoxChunkSource extends BaseVolumeChunkSource { super(chunkManager, options); } - /** Patch newly added chunks with any overlayed voxels and force re-upload. */ + /** Newly added chunks use whatever data backend provides; no overlay merge needed. */ override addChunk(key: string, chunk: VolumeChunk) { super.addChunk(key, chunk); - const baseArray = this.getCpuArrayForChunk(chunk); - if (baseArray) { - this.overlay.mergeIntoChunkData(key, baseArray); - this.invalidateChunkUpload(chunk); - } } /** Public paint API called by the tool/controller. */ paintVoxel(voxel: Float32Array, value: number) { - const { key, localIndex } = this.computeChunkKeyAndIndex(voxel); - if (localIndex < 0) return; - this.overlay.applyEdit(key, localIndex, value); - const chunk = this.chunks.get(key) as VolumeChunk | undefined; - if (chunk) { - const baseArray = this.getCpuArrayForChunk(chunk); + const { key, canonicalIndex, chunkLocalIndex } = this.computeIndices(voxel); + // Immediate draw if chunk is present in CPU memory + if (chunkLocalIndex >= 0) { + const chunk = this.chunks.get(key) as VolumeChunk | undefined; + const baseArray = chunk && this.getCpuArrayForChunk(chunk); if (baseArray) { - // Merge and mark for re-upload. - this.overlay.mergeIntoChunkData(key, baseArray); + (baseArray as any)[chunkLocalIndex] = value as any; this.scheduleUpdate(key); } } + // Persist to backend + try { + this.rpc!.invoke(VOX_COMMIT_VOXELS_RPC_ID, { + id: this.rpcId, + edits: [{ key, indices: [canonicalIndex], value, size: Array.from(this.spec.chunkDataSize) }], + }); + } catch { + // swallow rpc errors; rendering is already updated optimistically + } } private scheduleUpdate(key: string) { @@ -102,12 +67,7 @@ export class VoxChunkSource extends BaseVolumeChunkSource { for (const key of this.dirtyChunks) { const chunk = this.chunks.get(key) as VolumeChunk | undefined; if (chunk && this.getCpuArrayForChunk(chunk)) { - // The original blocking upload logic is now here - const gl = chunk.gl; - if (chunk.state === ChunkState.GPU_MEMORY) { - chunk.freeGPUMemory(gl); - } - chunk.copyToGPU(gl); + this.invalidateChunkUpload(chunk); } } this.dirtyChunks.clear(); @@ -117,51 +77,41 @@ export class VoxChunkSource extends BaseVolumeChunkSource { /** Batch paint API to minimize GPU uploads by chunk. */ paintVoxelsBatch(voxels: Float32Array[], value: number) { if (!voxels || voxels.length === 0) return; - const affectedKeys = new Set(); - // Apply edits to overlay and collect affected chunk keys. + const editsByKey = new Map(); + const chunksToUpdate = new Set(); + for (const v of voxels) { if (!v) continue; - const { key, localIndex } = this.computeChunkKeyAndIndex(v); - if (localIndex < 0) continue; - this.overlay.applyEdit(key, localIndex, value); - affectedKeys.add(key); - } - // For each affected chunk currently resident on CPU, merge and re-upload once. - for (const key of affectedKeys) { - const chunk = this.chunks.get(key) as VolumeChunk | undefined; - if (!chunk) continue; - const baseArray = this.getCpuArrayForChunk(chunk); - if (!baseArray) continue; - this.overlay.mergeIntoChunkData(key, baseArray); - this.scheduleUpdate(key); - } + const { key, canonicalIndex, chunkLocalIndex } = this.computeIndices(v); + // Immediate draw on CPU array if present + if (chunkLocalIndex >= 0) { + const chunk = this.chunks.get(key) as VolumeChunk | undefined; + const baseArray = chunk && this.getCpuArrayForChunk(chunk); + if (baseArray) { + (baseArray as any)[chunkLocalIndex] = value as any; + chunksToUpdate.add(key); + } + } + let arr = editsByKey.get(key); + if (!arr) editsByKey.set(key, (arr = [])); + arr.push(canonicalIndex); } - /** getValueAt that respects overlay if present. */ - override getValueAt(chunkPosition: Float32Array, channelAccess: any) { - // Compute key and local position based on the provided chunkPosition in voxel coordinates. - const rank = this.spec.rank; - const keyParts: number[] = []; - const local = this.tempLocalPosition; - const { chunkDataSize } = this.spec; - for (let dim = 0; dim < rank; ++dim) { - const voxel = chunkPosition[dim]; - const size = chunkDataSize[dim]; - const c = Math.floor(voxel / size); - keyParts.push(c); - local[dim] = Math.floor(voxel - c * size); - } - const key = keyParts.join(); - const chunk = this.chunks.get(key) as VolumeChunk | undefined; - if (chunk) { - const cds = chunk.chunkDataSize; - if (cds && (local[0] >= cds[0] || local[1] >= cds[1] || local[2] >= cds[2])) { - return undefined; + for (const key of chunksToUpdate) this.scheduleUpdate(key); + + if (editsByKey.size > 0) { + const size = Array.from(this.spec.chunkDataSize); + const edits = Array.from(editsByKey, ([key, indices]) => ({ key, indices, value, size })); + try { + this.rpc!.invoke(VOX_COMMIT_VOXELS_RPC_ID, { id: this.rpcId, edits }); + } catch { + // ignore } - const localIndex = this.localIndexFromLocalPosition(local, cds ?? this.spec.chunkDataSize); - const ov = this.overlay.getOverlayValue(key, localIndex); - if (ov !== undefined) return ov; } + } + + /** getValueAt simply defers to base; edits are persisted in backend and applied to CPU array when present. */ + override getValueAt(chunkPosition: Float32Array, channelAccess: any) { return super.getValueAt(chunkPosition, channelAccess); } @@ -170,7 +120,8 @@ export class VoxChunkSource extends BaseVolumeChunkSource { return (local[2] * size[1] + local[1]) * size[0] + local[0]; } - private computeChunkKeyAndIndex(voxel: Float32Array) { + /** Compute indices for both canonical (spec-sized) and actual loaded chunk. */ + private computeIndices(voxel: Float32Array) { const rank = this.spec.rank; const { baseVoxelOffset, chunkDataSize } = this.spec as any; const keyParts = this.tempVoxChunkGridPosition; @@ -183,12 +134,22 @@ export class VoxChunkSource extends BaseVolumeChunkSource { local[i] = Math.floor(v - c * size); } const key = `${keyParts[0]},${keyParts[1]},${keyParts[2]}`; + const canonicalIndex = this.localIndexFromLocalPosition(local, this.spec.chunkDataSize as Uint32Array); const chunk = this.chunks.get(key) as VolumeChunk | undefined; - const size = (chunk?.chunkDataSize as Uint32Array) ?? (this.spec.chunkDataSize as Uint32Array); - const localIndex = this.localIndexFromLocalPosition(local, size); - return { key, localIndex }; + let chunkLocalIndex = -1; + const cds = (chunk?.chunkDataSize as Uint32Array) ?? null; + if (cds) { + if (local[0] < cds[0] && local[1] < cds[1] && local[2] < cds[2]) { + chunkLocalIndex = this.localIndexFromLocalPosition(local, cds); + } + } else { + // If the chunk is not loaded yet, the spec size is a reasonable fallback for immediate updates (no-op if no CPU array). + chunkLocalIndex = this.localIndexFromLocalPosition(local, this.spec.chunkDataSize as Uint32Array); + } + return { key, canonicalIndex, chunkLocalIndex }; } + private getCpuArrayForChunk(chunk: VolumeChunk): TypedArray | null { const data = (chunk as any).data as TypedArray | null | undefined; return (data ?? null); From 76975cf17c4068c0d7d687ccf174d3c685066f4f Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:42 +0100 Subject: [PATCH 018/251] feat: redesign toolbox with structured layout, tool selection, and expanded brush settings --- src/layer/vox/index.ts | 122 +++++++++++++++++++++++++++++----------- src/layer/vox/style.css | 33 ++++++++++- 2 files changed, 121 insertions(+), 34 deletions(-) diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index e11122d4ae..ba9a0e121e 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -152,13 +152,21 @@ class VoxToolTab extends Tab { const toolbox = document.createElement("div"); toolbox.className = "neuroglancer-vox-toolbox"; + // Section: Tool selection + const toolsRow = document.createElement("div"); + toolsRow.className = "neuroglancer-vox-row"; + const toolsLabel = document.createElement("label"); + toolsLabel.textContent = "Tool"; + const toolsWrap = document.createElement("div"); + toolsWrap.style.display = "flex"; + toolsWrap.style.gap = "8px"; + const pixelButton = document.createElement("button"); pixelButton.textContent = "Pixel"; pixelButton.title = "ctrl+click to paint a pixel"; pixelButton.addEventListener("click", () => { this.layer.tool.value = new VoxelPixelLegacyTool(this.layer); }); - toolbox.appendChild(pixelButton); const brushButton = document.createElement("button"); brushButton.textContent = "Brush"; @@ -166,34 +174,57 @@ class VoxToolTab extends Tab { brushButton.addEventListener("click", () => { this.layer.tool.value = new VoxelBrushLegacyTool(this.layer); }); - toolbox.appendChild(brushButton); - // Brush size control - const sizeWrap = document.createElement("div"); - sizeWrap.style.display = "inline-flex"; - sizeWrap.style.alignItems = "center"; - sizeWrap.style.gap = "4px"; + toolsWrap.appendChild(pixelButton); + toolsWrap.appendChild(brushButton); + toolsRow.appendChild(toolsLabel); + toolsRow.appendChild(toolsWrap); + toolbox.appendChild(toolsRow); + + // Section: Brush settings + const brushRow = document.createElement("div"); + brushRow.className = "neuroglancer-vox-row"; + + // Brush size as slider + number readout const sizeLabel = document.createElement("label"); sizeLabel.textContent = "Brush size"; - const sizeInput = document.createElement("input"); - sizeInput.type = "number"; - sizeInput.min = "1"; - sizeInput.step = "1"; - sizeInput.value = String(this.layer.voxBrushRadius ?? 3); - sizeInput.addEventListener("change", () => { - const v = Math.max(1, Math.floor(Number(sizeInput.value) || 1)); - this.layer.voxBrushRadius = v; - sizeInput.value = String(v); + const sizeControls = document.createElement("div"); + sizeControls.style.display = "flex"; + sizeControls.style.alignItems = "center"; + sizeControls.style.gap = "8px"; + + const sizeSlider = document.createElement("input"); + sizeSlider.type = "range"; + sizeSlider.min = "1"; + sizeSlider.max = "64"; + sizeSlider.step = "1"; + sizeSlider.value = String(this.layer.voxBrushRadius ?? 3); + + const sizeNumber = document.createElement("input"); + sizeNumber.type = "number"; + sizeNumber.className = "neuroglancer-vox-input"; + sizeNumber.min = "1"; + sizeNumber.step = "1"; + sizeNumber.value = String(this.layer.voxBrushRadius ?? 3); + + const syncSize = (v: number) => { + const clamped = Math.max(1, Math.min(256, Math.floor(v))); + this.layer.voxBrushRadius = clamped; + sizeSlider.value = String(clamped); + sizeNumber.value = String(clamped); + }; + + sizeSlider.addEventListener("input", () => { + syncSize(Number(sizeSlider.value) || 1); + }); + sizeNumber.addEventListener("change", () => { + syncSize(Number(sizeNumber.value) || 1); }); - sizeWrap.appendChild(sizeLabel); - sizeWrap.appendChild(sizeInput); - toolbox.appendChild(sizeWrap); + + sizeControls.appendChild(sizeSlider); + sizeControls.appendChild(sizeNumber); // Eraser toggle - const erWrap = document.createElement("div"); - erWrap.style.display = "inline-flex"; - erWrap.style.alignItems = "center"; - erWrap.style.gap = "4px"; const erLabel = document.createElement("label"); erLabel.textContent = "Eraser"; const erChk = document.createElement("input"); @@ -202,15 +233,8 @@ class VoxToolTab extends Tab { erChk.addEventListener("change", () => { this.layer.voxEraseMode = !!erChk.checked; }); - erWrap.appendChild(erLabel); - erWrap.appendChild(erChk); - toolbox.appendChild(erWrap); // Brush shape selector - const shapeWrap = document.createElement("div"); - shapeWrap.style.display = "inline-flex"; - shapeWrap.style.alignItems = "center"; - shapeWrap.style.gap = "4px"; const shapeLabel = document.createElement("label"); shapeLabel.textContent = "Brush shape"; const shapeSel = document.createElement("select"); @@ -228,9 +252,41 @@ class VoxToolTab extends Tab { this.layer.voxBrushShape = v; shapeSel.value = v; }); - shapeWrap.appendChild(shapeLabel); - shapeWrap.appendChild(shapeSel); - toolbox.appendChild(shapeWrap); + + // Layout within the brushRow: size controls, shape, eraser + const group = document.createElement("div"); + group.style.display = "grid"; + group.style.gridTemplateColumns = "minmax(120px,auto) 1fr"; + group.style.columnGap = "8px"; + group.style.rowGap = "8px"; + + // Row 1: Brush size + const sizeLabelCell = document.createElement("div"); + sizeLabelCell.appendChild(sizeLabel); + const sizeControlsCell = document.createElement("div"); + sizeControlsCell.appendChild(sizeControls); + + // Row 2: Brush shape + const shapeLabelCell = document.createElement("div"); + shapeLabelCell.appendChild(shapeLabel); + const shapeControlCell = document.createElement("div"); + shapeControlCell.appendChild(shapeSel); + + // Row 3: Eraser + const erLabelCell = document.createElement("div"); + erLabelCell.appendChild(erLabel); + const erControlCell = document.createElement("div"); + erControlCell.appendChild(erChk); + + group.appendChild(sizeLabelCell); + group.appendChild(sizeControlsCell); + group.appendChild(shapeLabelCell); + group.appendChild(shapeControlCell); + group.appendChild(erLabelCell); + group.appendChild(erControlCell); + + brushRow.appendChild(group); + toolbox.appendChild(brushRow); element.appendChild(toolbox); } diff --git a/src/layer/vox/style.css b/src/layer/vox/style.css index d95b573ca1..6b18cc870a 100644 --- a/src/layer/vox/style.css +++ b/src/layer/vox/style.css @@ -93,6 +93,37 @@ .neuroglancer-vox-toolbox { display: flex; - flex-wrap: wrap; + flex-direction: column; gap: 8px; } + +/* Slider styling */ +.neuroglancer-vox-tools-tab input[type="range"] { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 6px; + background: linear-gradient(90deg, var(--ng-accent), color-mix(in oklab, var(--ng-accent) 35%, #333)); + border-radius: 999px; + outline: none; +} +.neuroglancer-vox-tools-tab input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + border: 1px solid var(--ng-border); + box-shadow: 0 0 0 2px color-mix(in oklab, var(--ng-accent) 45%, transparent); + cursor: pointer; +} +.neuroglancer-vox-tools-tab input[type="range"]::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + border: 1px solid var(--ng-border); + box-shadow: 0 0 0 2px color-mix(in oklab, var(--ng-accent) 45%, transparent); + cursor: pointer; +} From 514ecd8833bb0c8a2231075aba203909c2d413b3 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 019/251] feat: implement new local voxel storage with IndexedDB, map initialization, and improved backend edit handling --- NOTES/TODOs.md | 8 +- src/layer/vox/index.ts | 14 +- src/ui/voxel_annotations.ts | 2 +- src/voxel_annotation/backend.ts | 77 +++--- src/voxel_annotation/base.ts | 1 + src/voxel_annotation/edit_controller.ts | 9 - src/voxel_annotation/frontend.ts | 38 +-- src/voxel_annotation/index.ts | 244 ++++++++++++++++++++ src/voxel_annotation/volume_chunk_source.ts | 2 +- 9 files changed, 301 insertions(+), 94 deletions(-) create mode 100644 src/voxel_annotation/index.ts diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index 3d07412482..a038a9b755 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,11 +1,5 @@ # TODO List -- connect front and backend (see [data-saving-plan.md](../NOTES/data-saving-plan.md)) - optimize drawing tools (they are not really responsive rn) - add color picker -- create persistant storage (e.g. via a server and local with IndexedDB) (see [data-saving-plan.md](../NOTES/data-saving-plan.md)) - Fix the orientation of the disk in the brush tool - - -# Questions -- do we really need a frontend buffer? Should this buffer be a simple list of pixels and passed to the shader outside of the chunking system, this would let the chunking behaviour be managed by the backend? -- should the backend contain the full map or should it just contain the displayed and surrounding chunks and retrieve the rest live from the datasource (this is a yes for me) +- the uncaching of chunks the VoxSource is working great but since it has no way of knowing which chunks are in view it will delete them causing flickering of the drawings. diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index ba9a0e121e..37285f0d73 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -120,7 +120,8 @@ class VoxSettingsTab extends Tab { }); const apply = document.createElement("button"); - apply.textContent = "Apply"; + apply.textContent = "Regen source"; + apply.title = "Regenerate the source volume with the new settings, warning old local source will be deleted"; apply.addEventListener("click", () => { const u = unitSel.value || currentUnit; const f = factor(u); @@ -476,6 +477,17 @@ export class VoxUserLayer extends UserLayer { // Expose a controller so tools can paint voxels via the source. this.voxEditController = new VoxelEditController(dummySource); + // Initialize worker-side map persistence for this source (best-effort, fire-and-forget). + const sources2D = dummySource.getSources({} as any); + const base = sources2D[0][0]; + const source = (base.chunkSource as any); + void source.initializeMap({ + dataType: dummySource.dataType, + chunkDataSize: Array.from(((dummySource as any)['cfgChunkDataSize']) ?? [64, 64, 64]), + upperVoxelBound: Array.from(this.voxUpperBound), + unit: this.voxScaleUnit, + }).catch(() => { /* ignore init failures */ }); + // Build transform with current scale and units. const identity3D = this.createIdentity3D(); const transform = getWatchableRenderLayerTransform( diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index f7fbaca76a..fc985c8d2b 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -132,7 +132,7 @@ export class VoxelPixelLegacyTool extends BaseVoxelLegacyTool { } protected paintPoint(point: Float32Array, value: number) { - (this.layer as any).voxEditController?.paintVoxel(point, value); + (this.layer as any).voxEditController?.paintVoxelsBatch([point], value); } protected paintPoints(points: Float32Array[], value: number) { diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 572398e728..e4c9afd369 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -6,60 +6,42 @@ import type { VolumeChunk } from '#src/sliceview/volume/backend.js'; import { VolumeChunkSource as BaseVolumeChunkSource } from '#src/sliceview/volume/backend.js'; import { DataType } from '#src/util/data_type.js'; -import { VOX_CHUNK_SOURCE_RPC_ID, VOX_COMMIT_VOXELS_RPC_ID } from "#src/voxel_annotation/base.js"; +import { VOX_CHUNK_SOURCE_RPC_ID, VOX_COMMIT_VOXELS_RPC_ID, VOX_MAP_INIT_RPC_ID } from '#src/voxel_annotation/base.js'; +import { LocalVoxSource } from '#src/voxel_annotation/index.js'; import type { RPC } from '#src/worker_rpc.js'; import { registerRPC, registerSharedObject } from '#src/worker_rpc.js'; -/** Backend-side persisted storage for voxel annotation chunks. */ -interface SavedChunk { - data: ArrayBufferView; - size: Uint32Array; // [sx, sy, sz] used for linearization -} - /** * Backend volume source that persists voxel edits per chunk. It returns saved data if available, * otherwise returns an empty chunk (filled with zeros). */ @registerSharedObject(VOX_CHUNK_SOURCE_RPC_ID) export class VoxChunkSource extends BaseVolumeChunkSource { - private saved = new Map(); + private local = new LocalVoxSource(); constructor(rpc: RPC, options: any) { super(rpc, options); } + /** Initialize map metadata and persistence backend. */ + async initMap(opts: { mapId?: string; dataType?: number; chunkDataSize?: number[]; upperVoxelBound?: number[]; unit?: string; scaleKey?: string }) { + const cds = Array.from(opts.chunkDataSize ?? Array.from(this.spec.chunkDataSize)); + const uvb = Array.from(opts.upperVoxelBound ?? Array.from(this.spec.upperVoxelBound ?? [0, 0, 0] as any)); + const dt = opts.dataType ?? this.spec.dataType; + const scaleKey = opts.scaleKey ?? `${cds[0]}_${cds[1]}_${cds[2]}`; + return await this.local.init({ + mapId: opts.mapId, + dataType: dt, + chunkDataSize: cds, + upperVoxelBound: uvb, + unit: opts.unit, + scaleKey, + }); + } + /** Commit voxel edits from the frontend. */ - commitVoxels(edits: { key: string; indices: number[] | Uint32Array; value?: number; values?: ArrayLike; size?: number[] }[]) { - const { dataType } = this.spec; - for (const e of edits) { - const key = e.key; - // Determine the target size for this chunk. If not provided, fall back to spec chunkDataSize. - const sizeArr = e.size ? Uint32Array.from(e.size) : this.spec.chunkDataSize; - let entry = this.saved.get(key); - if (!entry || !arraysEqual(entry.size, sizeArr)) { - // Allocate new backing store for this key with the requested size. - const total = sizeArr[0] * sizeArr[1] * sizeArr[2]; - const arr = this.allocateTypedArray(dataType, total, 0); - entry = { data: arr, size: Uint32Array.from(sizeArr) }; - this.saved.set(key, entry); - } - const dest = entry.data as any; - const idxs: number[] = Array.from(e.indices as ArrayLike) as number[]; - if (e.values != null) { - const vals: ArrayLike = e.values as ArrayLike; - const n = Math.min(idxs.length, vals.length); - for (let i = 0; i < n; ++i) { - const idx = (idxs[i] as number) | 0; - if (idx >= 0 && idx < (dest as ArrayLike).length) (dest as any)[idx] = vals[i] as any; - } - } else if (e.value != null) { - const v = e.value as number; - for (let i = 0; i < idxs.length; ++i) { - const idx = (idxs[i] as number) | 0; - if (idx >= 0 && idx < (dest as ArrayLike).length) (dest as any)[idx] = v as any; - } - } - } + async commitVoxels(edits: { key: string; indices: number[] | Uint32Array; value?: number; values?: ArrayLike; size?: number[] }[]) { + await this.local.applyEdits(edits); } async download(chunk: VolumeChunk, signal: AbortSignal): Promise { @@ -69,10 +51,11 @@ export class VoxChunkSource extends BaseVolumeChunkSource { const cds = chunk.chunkDataSize!; const key = chunk.chunkGridPosition.join(); const total = cds[0] * cds[1] * cds[2]; + // Always produce a typed array matching the spec type; MVP uses UINT32 const array = this.allocateTypedArray(this.spec.dataType, total, 0); - const saved = this.saved.get(key); + // Load saved chunk if present and copy overlapping region + const saved = await this.local.getSavedChunk(key); if (saved) { - // Copy overlapping region from saved into array, accounting for possibly different strides. const sxS = saved.size[0], syS = saved.size[1], szS = saved.size[2]; const sxD = cds[0], syD = cds[1], szD = cds[2]; const ox = Math.min(sxS, sxD); @@ -119,14 +102,14 @@ export class VoxChunkSource extends BaseVolumeChunkSource { } } -function arraysEqual(a: Uint32Array, b: Uint32Array) { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; ++i) if (a[i] !== b[i]) return false; - return true; -} - // RPC to commit voxel edits. registerRPC(VOX_COMMIT_VOXELS_RPC_ID, function (x: any) { const obj = this.get(x.id) as VoxChunkSource; obj.commitVoxels(x.edits || []); }); + +// RPC to initialize map +registerRPC(VOX_MAP_INIT_RPC_ID, function (x: any) { + const obj = this.get(x.id) as VoxChunkSource; + return obj.initMap(x || {}); +}); diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index 96b7f444c3..28c08cd0c3 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -1,2 +1,3 @@ export const VOX_CHUNK_SOURCE_RPC_ID = 'vox.VoxChunkSource'; export const VOX_COMMIT_VOXELS_RPC_ID = 'vox.commitVoxels'; +export const VOX_MAP_INIT_RPC_ID = 'vox.map.init'; diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 0571469a36..04cf03dea8 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -20,15 +20,6 @@ export class VoxelEditController { } } - paintVoxel(voxel: Float32Array, value: number) { - try { - const source = this.getSource(); - source?.paintVoxel(voxel, value); - } catch { - // no-op - } - } - paintVoxelsBatch(voxels: Float32Array[], value: number) { if (!voxels || voxels.length === 0) return; try { diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 58d013a8eb..f4081330c9 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -10,7 +10,7 @@ import type { VolumeChunk } from '#src/sliceview/volume/frontend.js'; import { VolumeChunkSource as BaseVolumeChunkSource } from '#src/sliceview/volume/frontend.js'; import { animationFrameDebounce } from "#src/util/animation_frame_debounce.js"; import type { TypedArray } from '#src/util/array.js'; -import { VOX_CHUNK_SOURCE_RPC_ID, VOX_COMMIT_VOXELS_RPC_ID } from '#src/voxel_annotation/base.js'; +import { VOX_CHUNK_SOURCE_RPC_ID, VOX_COMMIT_VOXELS_RPC_ID, VOX_MAP_INIT_RPC_ID } from '#src/voxel_annotation/base.js'; import { registerSharedObjectOwner } from "#src/worker_rpc.js"; @@ -26,38 +26,20 @@ export class VoxChunkSource extends BaseVolumeChunkSource { () => this.processPendingUploads() ); - constructor(chunkManager: ChunkManager, options: { spec: VolumeChunkSpecification }) { - super(chunkManager, options); - } - - /** Newly added chunks use whatever data backend provides; no overlay merge needed. */ - override addChunk(key: string, chunk: VolumeChunk) { - super.addChunk(key, chunk); - } - - /** Public paint API called by the tool/controller. */ - paintVoxel(voxel: Float32Array, value: number) { - const { key, canonicalIndex, chunkLocalIndex } = this.computeIndices(voxel); - // Immediate draw if chunk is present in CPU memory - if (chunkLocalIndex >= 0) { - const chunk = this.chunks.get(key) as VolumeChunk | undefined; - const baseArray = chunk && this.getCpuArrayForChunk(chunk); - if (baseArray) { - (baseArray as any)[chunkLocalIndex] = value as any; - this.scheduleUpdate(key); - } - } - // Persist to backend + /** Initialize map in the worker/backend for this source. */ + async initializeMap(opts: { mapId?: string; dataType?: number; chunkDataSize?: number[]; upperVoxelBound?: number[]; unit?: string; scaleKey?: string }) { try { - this.rpc!.invoke(VOX_COMMIT_VOXELS_RPC_ID, { - id: this.rpcId, - edits: [{ key, indices: [canonicalIndex], value, size: Array.from(this.spec.chunkDataSize) }], - }); + return await this.rpc!.invoke(VOX_MAP_INIT_RPC_ID, { id: this.rpcId, ...opts }); } catch { - // swallow rpc errors; rendering is already updated optimistically + // initialization is best-effort; continue even if it fails + return undefined; } } + constructor(chunkManager: ChunkManager, options: { spec: VolumeChunkSpecification }) { + super(chunkManager, options); + } + private scheduleUpdate(key: string) { this.dirtyChunks.add(key); this.scheduleProcessPendingUploads(); diff --git a/src/voxel_annotation/index.ts b/src/voxel_annotation/index.ts new file mode 100644 index 0000000000..5aa90b8a40 --- /dev/null +++ b/src/voxel_annotation/index.ts @@ -0,0 +1,244 @@ +/** + * Local/Remote voxel annotation data sources and a shared base. + * The LocalVoxSource persists per-chunk arrays into IndexedDB with a debounced saver. + */ + +export interface VoxMapInitOptions { + mapId?: string; + dataType?: number; + chunkDataSize: number[] | Uint32Array; + upperVoxelBound?: number[] | Uint32Array | Float32Array; + unit?: string; + scaleKey?: string; +} + +export interface SavedChunk { + data: Uint32Array; // MVP stores UINT32 labels + size: Uint32Array; // canonical size used for linearization (usually spec.chunkDataSize) +} + +export class VoxSource { + protected mapId: string = 'default'; + protected scaleKey: string = ''; + protected chunkDataSize: Uint32Array = new Uint32Array([64, 64, 64]); + protected upperVoxelBound: Uint32Array = new Uint32Array([0, 0, 0]); + protected dataType: number = 6; // DataType.UINT32 default + protected unit: string = ''; + + // In-memory cache of loaded chunks + protected maxSavedChunks = 128; // cap to prevent unbounded growth + protected saved = new Map(); + + // Dirty tracking and debounced save + protected dirty = new Set(); + protected saveTimer: number | undefined; + + init(_opts: VoxMapInitOptions): Promise<{ mapId: string; scaleKey: string }> { + // Base provides default bookkeeping; persistence layer does real work. + const opts = _opts || ({} as VoxMapInitOptions); + this.mapId = opts.mapId || this.mapId || (typeof crypto !== 'undefined' && (crypto as any).randomUUID?.()) || String(Date.now()); + this.chunkDataSize = new Uint32Array(Array.from(opts.chunkDataSize)); + this.upperVoxelBound = new Uint32Array(Array.from(opts.upperVoxelBound ?? [0, 0, 0])); + this.dataType = opts.dataType ?? this.dataType; + this.unit = opts.unit ?? ''; + this.scaleKey = opts.scaleKey || `${this.chunkDataSize[0]}_${this.chunkDataSize[1]}_${this.chunkDataSize[2]}`; + return Promise.resolve({ mapId: this.mapId, scaleKey: this.scaleKey }); + } + + // Common helpers + protected markDirty(key: string) { + this.dirty.add(key); + this.scheduleSave(); + } + + protected scheduleSave() { + if (this.saveTimer !== undefined) return; + // Debounce writes ~750ms + this.saveTimer = (setTimeout(() => this.flushSaves(), 750) as unknown) as number; + } + + // Overridden by subclass to actually persist dirty chunks. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected async flushSaves(): Promise {} + + // Apply edits into an in-memory chunk array; returns the SavedChunk. + protected applyEditsIntoChunk(sc: SavedChunk, indices: ArrayLike, value?: number, values?: ArrayLike) { + const dst = sc.data; + if (values != null) { + const vv = values as ArrayLike; + const n = Math.min((indices as any).length ?? 0, (vv as any).length ?? 0); + for (let i = 0; i < n; ++i) { + const idx = (indices as any)[i] | 0; + if (idx >= 0 && idx < dst.length) dst[idx] = (vv as any)[i] >>> 0; + } + } else if (value != null) { + const v = value >>> 0; + const n = (indices as any).length ?? 0; + for (let i = 0; i < n; ++i) { + const idx = (indices as any)[i] | 0; + if (idx >= 0 && idx < dst.length) dst[idx] = v; + } + } + return sc; + } +} + +/** IndexedDB-backed local source. */ +export class LocalVoxSource extends VoxSource { + private dbPromise: Promise | null = null; + + private touch(key: string) { + const v = this.saved.get(key); + if (!v) return; + this.saved.delete(key); + this.saved.set(key, v); + } + + private enforceCap() { + // Evict only non-dirty entries to avoid losing unsaved edits. + while (this.saved.size > this.maxSavedChunks) { + let oldestKey: string | undefined = undefined; + for (const k of this.saved.keys()) { + if (!this.dirty.has(k)) { oldestKey = k; break; } + } + if (oldestKey === undefined) { + // All entries are dirty; wait until they are flushed before evicting. + break; + } + this.saved.delete(oldestKey); + } + } + + override async init(opts: VoxMapInitOptions) { + const meta = await super.init(opts); + const db = await this.getDb(); + // Persist/update map metadata + const tx = db.transaction('maps', 'readwrite'); + tx.objectStore('maps').put({ + mapId: this.mapId, + dataType: this.dataType, + chunkDataSize: Array.from(this.chunkDataSize), + upperVoxelBound: Array.from(this.upperVoxelBound), + unit: this.unit, + scaleKey: this.scaleKey, + updatedAt: Date.now(), + }, this.mapId); + await txDone(tx); + return meta; + } + + async getSavedChunk(key: string): Promise { + const existing = this.saved.get(key); + if (existing) { this.touch(key); return existing; } + const db = await this.getDb(); + const composite = this.compositeKey(key); + const buf = await idbGet(db, 'chunks', composite); + if (buf) { + const arr = new Uint32Array(buf); + const sc: SavedChunk = { data: arr, size: new Uint32Array(this.chunkDataSize) }; + this.saved.set(key, sc); + this.enforceCap(); + return sc; + } + return undefined; + } + + async ensureChunk(key: string, size?: Uint32Array | number[]): Promise { + let sc = this.saved.get(key); + if (sc) { this.touch(key); return sc; } + const db = await this.getDb(); + const composite = this.compositeKey(key); + const buf = await idbGet(db, 'chunks', composite); + if (buf) { + const arr = new Uint32Array(buf); + sc = { data: arr, size: new Uint32Array(this.chunkDataSize) }; + this.saved.set(key, sc); + this.enforceCap(); + return sc; + } + const sz = new Uint32Array(size ?? this.chunkDataSize); + let total = 1; for (let i = 0; i < 3; ++i) total *= sz[i]; + const arr = new Uint32Array(total); + sc = { data: arr, size: new Uint32Array(sz) }; + this.saved.set(key, sc); + this.enforceCap(); + this.markDirty(key); + return sc; + } + + async applyEdits(edits: { key: string; indices: ArrayLike; value?: number; values?: ArrayLike; size?: number[] }[]) { + for (const e of edits) { + const sc = await this.ensureChunk(e.key, e.size ? new Uint32Array(e.size) : this.chunkDataSize); + this.applyEditsIntoChunk(sc, e.indices, e.value, e.values); + this.markDirty(e.key); + } + } + + protected override async flushSaves() { + const keys = Array.from(this.dirty); + if (keys.length === 0) { this.saveTimer = undefined; return; } + this.dirty.clear(); + const db = await this.getDb(); + const tx = db.transaction('chunks', 'readwrite'); + const store = tx.objectStore('chunks'); + for (const key of keys) { + const sc = this.saved.get(key); + if (!sc) continue; + await idbPut(store, sc.data.buffer, this.compositeKey(key)); + } + await txDone(tx); + this.saveTimer = undefined; + } + + private compositeKey(key: string) { + return `${this.mapId}:${this.scaleKey}:${key}`; + } + + private async getDb(): Promise { + if (this.dbPromise) return this.dbPromise; + this.dbPromise = new Promise((resolve, reject) => { + const req = indexedDB.open('neuroglancer_vox', 1); + req.onerror = () => reject(req.error); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains('maps')) db.createObjectStore('maps'); + if (!db.objectStoreNames.contains('chunks')) db.createObjectStore('chunks'); + }; + req.onsuccess = () => resolve(req.result); + }); + return this.dbPromise; + } +} + +export class RemoteVoxSource extends VoxSource { + constructor(public url: string) { + super(); + } +} + +// --- Small IDB helpers --- +function idbGet(db: IDBDatabase, storeName: string, key: IDBValidKey): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, 'readonly'); + const store = tx.objectStore(storeName); + const req = store.get(key); + req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(req.result as any); + }); +} + +function idbPut(store: IDBObjectStore, value: any, key?: IDBValidKey) { + return new Promise((resolve, reject) => { + const req = key === undefined ? store.put(value) : store.put(value, key); + req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(); + }); +} + +function txDone(tx: IDBTransaction) { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); +} diff --git a/src/voxel_annotation/volume_chunk_source.ts b/src/voxel_annotation/volume_chunk_source.ts index 00dcdf6a77..8a7daee514 100644 --- a/src/voxel_annotation/volume_chunk_source.ts +++ b/src/voxel_annotation/volume_chunk_source.ts @@ -110,7 +110,7 @@ export class VoxMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSource // Large diagonal scale to make effective voxel size huge, ensuring guard scale is used when // zoomed out. Homogeneous (rank+1)x(rank+1) matrix. - const scale = 1 << 2; + const scale = 10; const guardXform = new Float32Array((rank + 1) * (rank + 1)); for (let i = 0; i < rank; ++i) { guardXform[i * (rank + 1) + i] = scale; From 85f6d69413b70e75f968b2c18ff00bec7d1a23ae Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 020/251] doc: brainstorming LOD --- NOTES/TODOs.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index a038a9b755..8b40be450b 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -3,3 +3,15 @@ - add color picker - Fix the orientation of the disk in the brush tool - the uncaching of chunks the VoxSource is working great but since it has no way of knowing which chunks are in view it will delete them causing flickering of the drawings. + +- exporting feature -> what format (a big map), which area (maybe add a setting in the voxel tab, or even better set by drawing a square), metadata?? + + + +# LOD + +- the saving of drawing data are already indexed with their scale, to allow for multiscale rendering we should also provide a way to display the data coming from different scales then the current one. This involve two steps: +1. on saving of data, we should propagate complete voxel cube to the upper levels (lower zoom levels) recursively +2. on loading of data, we should retreive not only the current scale chunks but also the ones from the lower zoom levels. +This last step will introduce conflictsm what if the same voxel does not have the same value in the different scales? And how to know if there are been deletion or if there are just no data? To solve this we must introduce a special value for the deleted voxels and also timestamp for the last chunk updates. ~~To avoid too many conficts, we should resolve them when loading the data.~~ Actually, we should not resolve those conflicts live as doing so will prevent us from implementing an undo feature. +1 From 451c69bc6266a49c23bc4951fe38d9d3427941db Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 021/251] feat: support region-based voxel initialization with corners, update map options and UI settings --- NOTES/TODOs.md | 1 + src/layer/vox/index.ts | 80 +++++++++++++++------ src/voxel_annotation/backend.ts | 15 ++-- src/voxel_annotation/frontend.ts | 2 +- src/voxel_annotation/index.ts | 14 +++- src/voxel_annotation/volume_chunk_source.ts | 6 ++ 6 files changed, 88 insertions(+), 30 deletions(-) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index 8b40be450b..b18d236a32 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,4 +1,5 @@ # TODO List +- look into compression format/datasource ans see for export/imporrt of data - optimize drawing tools (they are not really responsive rn) - add color picker - Fix the orientation of the disk in the brush tool diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 37285f0d73..6d796fe182 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -74,7 +74,8 @@ class VoxSettingsTab extends Tab { }; const sMeters = this.layer.voxScale; // stored in meters - const b = this.layer.voxUpperBound; + const a = this.layer.voxCornerA; + const c = this.layer.voxCornerB; // Unit helpers const unitFactor: Record = { m: 1, mm: 1e-3, "µm": 1e-6, nm: 1e-9 }; @@ -97,13 +98,18 @@ class VoxSettingsTab extends Tab { const sy = makeNumberInput(sMeters[1] / factor(currentUnit), "any"); const sz = makeNumberInput(sMeters[2] / factor(currentUnit), "any"); - const bx = makeNumberInput(b[0], "1"); - const by = makeNumberInput(b[1], "1"); - const bz = makeNumberInput(b[2], "1"); + const ax = makeNumberInput(a[0], "1"); + const ay = makeNumberInput(a[1], "1"); + const az = makeNumberInput(a[2], "1"); + + const bx = makeNumberInput(c[0], "1"); + const by = makeNumberInput(c[1], "1"); + const bz = makeNumberInput(c[2], "1"); element.appendChild(row("Scale (x,y,z)", [sx, sy, sz])); element.appendChild(row("Scale unit", [unitSel])); - element.appendChild(row("Upper bounds (x,y,z)", [bx, by, bz])); + element.appendChild(row("Corner A (x,y,z)", [ax, ay, az])); + element.appendChild(row("Corner B (x,y,z)", [bx, by, bz])); // When unit changes, rescale the displayed numbers to preserve physical value in meters unitSel.addEventListener("change", () => { @@ -134,12 +140,17 @@ class VoxSettingsTab extends Tab { Number.isFinite(syNum) ? syNum * f : sMeters[1], Number.isFinite(szNum) ? szNum * f : sMeters[2], ]); - const nb = new Float32Array([ - Math.max(1, Math.floor(Number(bx.value) || this.layer.voxUpperBound[0])), - Math.max(1, Math.floor(Number(by.value) || this.layer.voxUpperBound[1])), - Math.max(1, Math.floor(Number(bz.value) || this.layer.voxUpperBound[2])), + const ca = new Float32Array([ + Math.floor(Number(ax.value) || this.layer.voxCornerA[0]), + Math.floor(Number(ay.value) || this.layer.voxCornerA[1]), + Math.floor(Number(az.value) || this.layer.voxCornerA[2]), + ]); + const cb = new Float32Array([ + Math.floor(Number(bx.value) || this.layer.voxCornerB[0]), + Math.floor(Number(by.value) || this.layer.voxCornerB[1]), + Math.floor(Number(bz.value) || this.layer.voxCornerB[2]), ]); - this.layer.applyVoxSettings(ns, u, nb); + this.layer.applyVoxSettings(ns, u, ca, cb); }); element.appendChild(apply); } @@ -307,9 +318,9 @@ export class VoxUserLayer extends UserLayer { 0.000000008, 0.000000008, 0.000000008, ]); voxScaleUnit: string = "nm"; - voxUpperBound: Float32Array = new Float32Array([ - 1_000_000, 1_000_000, 1_000_000, - ]); + // Region selection via corners + voxCornerA: Float32Array = new Float32Array([0, 0, 0]); + voxCornerB: Float32Array = new Float32Array([1_000_000, 1_000_000, 1_000_000]); // Draw tool state voxBrushRadius: number = 3; voxEraseMode: boolean = false; @@ -318,8 +329,8 @@ export class VoxUserLayer extends UserLayer { constructor(managedLayer: Borrowed) { super(managedLayer); - this.tabs.add("vox", { - label: "Voxel", + this.tabs.add("vox_settings", { + label: "Settings", order: 0, getter: () => new VoxSettingsTab(this), }); @@ -334,19 +345,31 @@ export class VoxUserLayer extends UserLayer { applyVoxSettings( scale: Float64Array, unit: string, - upperBound: Float32Array, + cornerA: Float32Array, + cornerB: Float32Array, ) { // Update and rebuild if values changed. let changed = false; + // Update scale for (let i = 0; i < 3; ++i) { if (this.voxScale[i] !== scale[i]) { this.voxScale[i] = scale[i]; changed = true; } - if (this.voxUpperBound[i] !== upperBound[i]) { - this.voxUpperBound[i] = upperBound[i]; - changed = true; - } + } + // Normalize corners to an axis-aligned [lower, upper) box + const lower = new Float32Array(3); + const upper = new Float32Array(3); + for (let i = 0; i < 3; ++i) { + const lo = Math.floor(Math.min(cornerA[i], cornerB[i])); + const up = Math.ceil(Math.max(cornerA[i], cornerB[i])); + lower[i] = lo; + upper[i] = Math.max(up, lo + 1); // enforce non-empty + } + // Update stored corners and derived upper bound + for (let i = 0; i < 3; ++i) { + if (this.voxCornerA[i] !== cornerA[i]) { this.voxCornerA[i] = cornerA[i]; changed = true; } + if (this.voxCornerB[i] !== cornerB[i]) { this.voxCornerB[i] = cornerB[i]; changed = true; } } if (this.voxScaleUnit !== unit) { this.voxScaleUnit = unit; @@ -463,7 +486,16 @@ export class VoxUserLayer extends UserLayer { const ls = this.voxLoadedSubsource; if (!ls) return; const guardScale = Array.from(this.voxScale); - const guardBounds = Array.from(this.voxUpperBound); + // Derive region from corners for guard and source + const lower = new Float32Array(3); + const upper = new Float32Array(3); + for (let i = 0; i < 3; ++i) { + const lo = Math.floor(Math.min(this.voxCornerA[i], this.voxCornerB[i])); + const up = Math.ceil(Math.max(this.voxCornerA[i], this.voxCornerB[i])); + lower[i] = lo; + upper[i] = Math.max(up, lo + 1); + } + const guardBounds = Array.from(upper); const guardUnit = this.voxScaleUnit; ls.activate( () => { @@ -471,7 +503,8 @@ export class VoxUserLayer extends UserLayer { this.manager.chunkManager, { chunkDataSize: new Uint32Array([64, 64, 64]), - upperVoxelBound: this.voxUpperBound, + baseVoxelOffset: lower, + upperVoxelBound: upper, }, ); // Expose a controller so tools can paint voxels via the source. @@ -484,7 +517,8 @@ export class VoxUserLayer extends UserLayer { void source.initializeMap({ dataType: dummySource.dataType, chunkDataSize: Array.from(((dummySource as any)['cfgChunkDataSize']) ?? [64, 64, 64]), - upperVoxelBound: Array.from(this.voxUpperBound), + baseVoxelOffset: Array.from(lower as any), + upperVoxelBound: Array.from(upper as any), unit: this.voxScaleUnit, }).catch(() => { /* ignore init failures */ }); diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index e4c9afd369..0e8102ef3e 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -7,6 +7,7 @@ import type { VolumeChunk } from '#src/sliceview/volume/backend.js'; import { VolumeChunkSource as BaseVolumeChunkSource } from '#src/sliceview/volume/backend.js'; import { DataType } from '#src/util/data_type.js'; import { VOX_CHUNK_SOURCE_RPC_ID, VOX_COMMIT_VOXELS_RPC_ID, VOX_MAP_INIT_RPC_ID } from '#src/voxel_annotation/base.js'; +import type { VoxMapInitOptions } from '#src/voxel_annotation/index.js'; import { LocalVoxSource } from '#src/voxel_annotation/index.js'; import type { RPC } from '#src/worker_rpc.js'; import { registerRPC, registerSharedObject } from '#src/worker_rpc.js'; @@ -24,19 +25,23 @@ export class VoxChunkSource extends BaseVolumeChunkSource { } /** Initialize map metadata and persistence backend. */ - async initMap(opts: { mapId?: string; dataType?: number; chunkDataSize?: number[]; upperVoxelBound?: number[]; unit?: string; scaleKey?: string }) { + async initMap(opts: { mapId?: string; dataType?: number; chunkDataSize?: number[]; upperVoxelBound?: number[]; baseVoxelOffset?: number[]; unit?: string; scaleKey?: string}) { const cds = Array.from(opts.chunkDataSize ?? Array.from(this.spec.chunkDataSize)); const uvb = Array.from(opts.upperVoxelBound ?? Array.from(this.spec.upperVoxelBound ?? [0, 0, 0] as any)); const dt = opts.dataType ?? this.spec.dataType; const scaleKey = opts.scaleKey ?? `${cds[0]}_${cds[1]}_${cds[2]}`; - return await this.local.init({ + // Default base offset to spec.baseVoxelOffset if not provided + const bvo = Array.from(opts.baseVoxelOffset ?? Array.from((this.spec as any).baseVoxelOffset ?? [0, 0, 0])); + const initOpts = { mapId: opts.mapId, dataType: dt, - chunkDataSize: cds, - upperVoxelBound: uvb, + chunkDataSize: cds as number[], + upperVoxelBound: uvb as number[], + baseVoxelOffset: bvo as number[], unit: opts.unit, scaleKey, - }); + } satisfies VoxMapInitOptions; + return await this.local.init(initOpts); } /** Commit voxel edits from the frontend. */ diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index f4081330c9..64dbc34708 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -27,7 +27,7 @@ export class VoxChunkSource extends BaseVolumeChunkSource { ); /** Initialize map in the worker/backend for this source. */ - async initializeMap(opts: { mapId?: string; dataType?: number; chunkDataSize?: number[]; upperVoxelBound?: number[]; unit?: string; scaleKey?: string }) { + async initializeMap(opts: { mapId?: string; dataType?: number; chunkDataSize?: number[]; upperVoxelBound?: number[]; baseVoxelOffset?: number[]; unit?: string; scaleKey?: string}) { try { return await this.rpc!.invoke(VOX_MAP_INIT_RPC_ID, { id: this.rpcId, ...opts }); } catch { diff --git a/src/voxel_annotation/index.ts b/src/voxel_annotation/index.ts index 5aa90b8a40..d27f7dd49c 100644 --- a/src/voxel_annotation/index.ts +++ b/src/voxel_annotation/index.ts @@ -8,6 +8,7 @@ export interface VoxMapInitOptions { dataType?: number; chunkDataSize: number[] | Uint32Array; upperVoxelBound?: number[] | Uint32Array | Float32Array; + baseVoxelOffset?: number[] | Uint32Array | Float32Array; unit?: string; scaleKey?: string; } @@ -22,6 +23,7 @@ export class VoxSource { protected scaleKey: string = ''; protected chunkDataSize: Uint32Array = new Uint32Array([64, 64, 64]); protected upperVoxelBound: Uint32Array = new Uint32Array([0, 0, 0]); + protected baseVoxelOffset: Uint32Array = new Uint32Array([0, 0, 0]); protected dataType: number = 6; // DataType.UINT32 default protected unit: string = ''; @@ -39,9 +41,18 @@ export class VoxSource { this.mapId = opts.mapId || this.mapId || (typeof crypto !== 'undefined' && (crypto as any).randomUUID?.()) || String(Date.now()); this.chunkDataSize = new Uint32Array(Array.from(opts.chunkDataSize)); this.upperVoxelBound = new Uint32Array(Array.from(opts.upperVoxelBound ?? [0, 0, 0])); + this.baseVoxelOffset = new Uint32Array(Array.from(opts.baseVoxelOffset ?? [0, 0, 0])); this.dataType = opts.dataType ?? this.dataType; this.unit = opts.unit ?? ''; - this.scaleKey = opts.scaleKey || `${this.chunkDataSize[0]}_${this.chunkDataSize[1]}_${this.chunkDataSize[2]}`; + // Default scaleKey includes region to avoid collisions if not provided explicitly + if (opts.scaleKey) { + this.scaleKey = opts.scaleKey; + } else { + const cds = Array.from(this.chunkDataSize); + const lower = Array.from(this.baseVoxelOffset); + const upper = Array.from(this.upperVoxelBound); + this.scaleKey = `${cds[0]}_${cds[1]}_${cds[2]}:${lower[0]}_${lower[1]}_${lower[2]}-${upper[0]}_${upper[1]}_${upper[2]}`; + } return Promise.resolve({ mapId: this.mapId, scaleKey: this.scaleKey }); } @@ -119,6 +130,7 @@ export class LocalVoxSource extends VoxSource { dataType: this.dataType, chunkDataSize: Array.from(this.chunkDataSize), upperVoxelBound: Array.from(this.upperVoxelBound), + baseVoxelOffset: Array.from(this.baseVoxelOffset), unit: this.unit, scaleKey: this.scaleKey, updatedAt: Date.now(), diff --git a/src/voxel_annotation/volume_chunk_source.ts b/src/voxel_annotation/volume_chunk_source.ts index 8a7daee514..21f9cb5542 100644 --- a/src/voxel_annotation/volume_chunk_source.ts +++ b/src/voxel_annotation/volume_chunk_source.ts @@ -39,6 +39,7 @@ import { VoxChunkSource } from '#src/voxel_annotation/frontend.js'; export interface VoxMultiscaleOptions { chunkDataSize?: Uint32Array | number[]; upperVoxelBound?: Float32Array | number[]; + baseVoxelOffset?: Float32Array | number[]; } export class VoxMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSource { @@ -50,6 +51,7 @@ export class VoxMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSource private cfgChunkDataSize: Uint32Array; private cfgUpperVoxelBound: Float32Array; + private cfgBaseVoxelOffset: Float32Array; constructor(chunkManager: ChunkManager, options?: VoxMultiscaleOptions) { super(chunkManager); @@ -59,6 +61,9 @@ export class VoxMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSource this.cfgUpperVoxelBound = new Float32Array( options?.upperVoxelBound ? Array.from(options.upperVoxelBound) : [1_000, 1_000, 1_000], ); + this.cfgBaseVoxelOffset = new Float32Array( + options?.baseVoxelOffset ? Array.from(options.baseVoxelOffset) : [0, 0, 0], + ); } getSources(_options: VolumeSourceOptions) { @@ -71,6 +76,7 @@ export class VoxMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSource dataType: this.dataType, chunkDataSize: this.cfgChunkDataSize, upperVoxelBound: this.cfgUpperVoxelBound, + baseVoxelOffset: this.cfgBaseVoxelOffset, }); const baseSource: VoxChunkSource = this.chunkManager.getChunkSource( VoxChunkSource as any, From a8f945480ffcfd0b09536e6c687068158e7434d5 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 022/251] feat: expand TODOs with plans for segmentation compression, multi-user remote workflows, label creation, and new drawing tools --- NOTES/TODOs.md | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index b18d236a32..5e0cbed280 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,18 +1,20 @@ # TODO List -- look into compression format/datasource ans see for export/imporrt of data -- optimize drawing tools (they are not really responsive rn) -- add color picker +- continue to study the segmentation compression, using it should greatly reduce the ram and indexDB usage, but it no easy integration of the hot chunk reloading in the frontend for drawing tool responsiveness has been found. +- add label creation (with color, name, description...) +- add flood fill tool (with a max expansion safeguard), this tool should be 2d (e.g. act on a plane, the plane normal to the z axis is sufficient for a v1) - Fix the orientation of the disk in the brush tool -- the uncaching of chunks the VoxSource is working great but since it has no way of knowing which chunks are in view it will delete them causing flickering of the drawings. +- the uncaching of chunks the VoxSource is working great, but since it has no way of knowing which chunks are in view, it will delete them, causing flickering of the drawings. -- exporting feature -> what format (a big map), which area (maybe add a setting in the voxel tab, or even better set by drawing a square), metadata?? +# Saving/importing/exporting +The ExternalVoxSource will be activated when a zarr:// or precomputed:// link is provided. This will load the data from the remote to display, on edits, the data will still be saved in the local indexedDB. On retrieval of chunks, we must first check the IndexedDB and if not locally present, fetch the remote. An export feature should be added, this will hold the drawing capabilities, retrieve the entire data from the remote and merge it with the local modifications. Then reformat everything to the desired format. +The RemoteVoxSource will be activated when a https:// link to a specially made server is provided. This server will replace the local indexedDB and will be the new data owner, such a workflow may allow for multi-user collaboration. # LOD -- the saving of drawing data are already indexed with their scale, to allow for multiscale rendering we should also provide a way to display the data coming from different scales then the current one. This involve two steps: -1. on saving of data, we should propagate complete voxel cube to the upper levels (lower zoom levels) recursively -2. on loading of data, we should retreive not only the current scale chunks but also the ones from the lower zoom levels. -This last step will introduce conflictsm what if the same voxel does not have the same value in the different scales? And how to know if there are been deletion or if there are just no data? To solve this we must introduce a special value for the deleted voxels and also timestamp for the last chunk updates. ~~To avoid too many conficts, we should resolve them when loading the data.~~ Actually, we should not resolve those conflicts live as doing so will prevent us from implementing an undo feature. -1 +- the saving of drawing data is already indexed with their scale; to allow for multiscale rendering, we should also provide a way to display the data coming from different scales than the current one. This involves two steps: +1. on saving of data, we should propagate the complete voxel cube to the upper levels (lower zoom levels) recursively +2. on loading of data, we should retrieve not only the current scale chunks but also the ones from the lower zoom levels. +This last step will introduce conflicts what if the same voxel does not have the same value in the different scales? And how to know if there has been deletion or if there are just no data? To solve this, we must introduce a special value for the deleted voxels and also timestamp for the last chunk updates. ~~To avoid too many conficts, we should resolve them when loading the data.~~ Actually, we should not resolve those conflicts live as doing so will prevent us from implementing an undo feature. + From f6e35470c4b0eb8b4acb4e0968ab0323fe1dbb7e Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 023/251] feat: implement voxel label creation, persistence via IndexedDB, and enhanced UI rendering - Add label management with ID generation, selection, and default initialization. - Persist labels using IndexedDB with dedicated key management. - Integrate label creation and selection into the UI with real-time updates. - Update shaders for Uint64 handling and segment color hashing. - Modify voxel initialization to support deterministic scale keys. --- NOTES/TODOs.md | 3 +- src/layer/vox/index.ts | 242 +++++++++++++++++++++++++++- src/ui/voxel_annotations.ts | 2 +- src/voxel_annotation/backend.ts | 10 +- src/voxel_annotation/index.ts | 53 +++--- src/voxel_annotation/renderlayer.ts | 30 +++- 6 files changed, 299 insertions(+), 41 deletions(-) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index 5e0cbed280..6a9d3af480 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,9 +1,10 @@ # TODO List - continue to study the segmentation compression, using it should greatly reduce the ram and indexDB usage, but it no easy integration of the hot chunk reloading in the frontend for drawing tool responsiveness has been found. -- add label creation (with color, name, description...) +- add label creation (with color, name, description and uuid) - add flood fill tool (with a max expansion safeguard), this tool should be 2d (e.g. act on a plane, the plane normal to the z axis is sufficient for a v1) - Fix the orientation of the disk in the brush tool - the uncaching of chunks the VoxSource is working great, but since it has no way of knowing which chunks are in view, it will delete them, causing flickering of the drawings. +- Add Uint64 support for annotation id # Saving/importing/exporting diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 6d796fe182..e725f2c4f1 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -35,10 +35,12 @@ import { import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; import { getWatchableRenderLayerTransform } from "#src/render_coordinate_transform.js"; import { RenderScaleHistogram, trackableRenderScaleTarget } from "#src/render_scale_statistics.js"; +import { SegmentColorHash } from "#src/segment_color.js"; import { registerVoxelAnnotationTools, VoxelBrushLegacyTool, VoxelPixelLegacyTool } from "#src/ui/voxel_annotations.js"; import type { Borrowed } from "#src/util/disposable.js"; import { mat4 } from "#src/util/geom.js"; import { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; +import { openVoxDb, idbGet, idbPut, txDone, toScaleKey, compositeLabelsDbKey } from "#src/voxel_annotation/index.js"; import { VoxelAnnotationRenderLayer } from "#src/voxel_annotation/renderlayer.js"; import { VoxMultiscaleVolumeChunkSource } from "#src/voxel_annotation/volume_chunk_source.js"; import { Tab } from "#src/widget/tab_view.js"; @@ -157,6 +159,52 @@ class VoxSettingsTab extends Tab { } class VoxToolTab extends Tab { + public requestRenderLabels() { this.renderLabels(); } + private labelsContainer!: HTMLDivElement; + private renderLabels() { + const cont = this.labelsContainer; + cont.innerHTML = ""; + const labels = this.layer.voxLabels; + const selected = this.layer.voxSelectedLabelId; + for (const lab of labels) { + const row = document.createElement("div"); + row.className = "neuroglancer-vox-label-row"; + row.style.display = "grid"; + row.style.gridTemplateColumns = "16px 1fr"; + row.style.alignItems = "center"; + row.style.gap = "8px"; + // color swatch + const sw = document.createElement("div"); + sw.style.width = "16px"; + sw.style.height = "16px"; + sw.style.borderRadius = "3px"; + sw.style.border = "1px solid rgba(0,0,0,0.2)"; + sw.style.background = this.layer.colorForValue(lab.id); + // id text (monospace) + const txt = document.createElement("div"); + txt.textContent = String(lab.id >>> 0); + txt.style.fontFamily = "monospace"; + txt.style.whiteSpace = "nowrap"; + txt.style.overflow = "hidden"; + txt.style.textOverflow = "ellipsis"; + row.appendChild(sw); + row.appendChild(txt); + // selection styling + const isSel = lab.id === selected; + row.style.cursor = "pointer"; + row.style.padding = "2px 4px"; + row.style.borderRadius = "4px"; + if (isSel) { + row.style.background = "rgba(100,150,255,0.15)"; + row.style.outline = "1px solid rgba(100,150,255,0.6)"; + } + row.addEventListener("click", () => { + this.layer.selectVoxLabel(lab.id); + this.renderLabels(); + }); + cont.appendChild(row); + } + } constructor(public layer: VoxUserLayer) { super(); const { element } = this; @@ -193,6 +241,7 @@ class VoxToolTab extends Tab { toolsRow.appendChild(toolsWrap); toolbox.appendChild(toolsRow); + // Section: Brush settings const brushRow = document.createElement("div"); brushRow.className = "neuroglancer-vox-row"; @@ -300,12 +349,60 @@ class VoxToolTab extends Tab { brushRow.appendChild(group); toolbox.appendChild(brushRow); + // Section: Labels (moved to end, title on top for full width) + const labelsSection = document.createElement("div"); + labelsSection.style.display = "flex"; + labelsSection.style.flexDirection = "column"; + labelsSection.style.gap = "6px"; + labelsSection.style.marginTop = "8px"; + + const labelsTitle = document.createElement("div"); + labelsTitle.textContent = "Labels"; + labelsTitle.style.fontWeight = "600"; + + const buttonsRow = document.createElement("div"); + buttonsRow.style.display = "flex"; + buttonsRow.style.gap = "8px"; + + const createBtn = document.createElement("button"); + createBtn.textContent = "New label"; + createBtn.addEventListener("click", () => { + this.layer.createVoxLabel(); + // Rendering will be triggered by layer via onLabelsChanged callback. + }); + buttonsRow.appendChild(createBtn); + + this.labelsContainer = document.createElement("div"); + this.labelsContainer.className = "neuroglancer-vox-labels"; + this.labelsContainer.style.display = "flex"; + this.labelsContainer.style.flexDirection = "column"; + this.labelsContainer.style.gap = "4px"; + this.labelsContainer.style.maxHeight = "180px"; + this.labelsContainer.style.overflowY = "auto"; + + labelsSection.appendChild(labelsTitle); + labelsSection.appendChild(buttonsRow); + labelsSection.appendChild(this.labelsContainer); + + toolbox.appendChild(labelsSection); + + this.layer.onLabelsChanged = () => this.requestRenderLabels(); + this.renderLabels(); + element.appendChild(toolbox); } } export class VoxUserLayer extends UserLayer { + onLabelsChanged?: () => void; + private voxMapId: string | undefined; + private voxScaleKey: string | undefined; + private labelsDbPromise: Promise | null = null; + // Label state for painting: only store ids; colors are hashed from id on the fly + voxLabels: { id: number }[] = []; + voxSelectedLabelId: number | undefined = undefined; + segmentColorHash = SegmentColorHash.getDefault(); // Match Image/Segmentation layers: provide a per-layer cross-section render scale target/histogram. sliceViewRenderScaleHistogram = new RenderScaleHistogram(); sliceViewRenderScaleTarget = trackableRenderScaleTarget(1); @@ -327,8 +424,123 @@ export class VoxUserLayer extends UserLayer { voxBrushShape: "disk" | "sphere" = "disk"; private voxLoadedSubsource?: LoadedDataSubsource; + // --- Label helpers --- + private genId(): number { + // Generate a unique uint32 per layer session. Try crypto.getRandomValues; fallback to Math.random. + let id = 0; + const used = new Set(this.voxLabels.map(l => l.id)); + for (let attempts = 0; attempts < 10_000; attempts++) { + if (typeof crypto !== 'undefined' && (crypto as any).getRandomValues) { + const a = new Uint32Array(1); + (crypto as any).getRandomValues(a); + id = a[0] >>> 0; + } else { + id = (Math.floor(Math.random() * 0xffffffff) >>> 0); + } + if (id !== 0 && !used.has(id)) return id; + } + // As an ultimate fallback, probe sequentially from a time-based seed. + const base = (Date.now() ^ ((Math.random() * 0xffffffff) >>> 0)) >>> 0; + id = base || 1; + while (used.has(id)) id = (id + 1) >>> 0; + return id >>> 0; + } + colorForValue(v: number): string { + // Use segmentation-like color from SegmentColorHash seeded on numeric value + return this.segmentColorHash.computeCssColor(BigInt(v >>> 0)); + } + + // --- Labels persistence (IndexedDB) --- + private async getLabelsDb(): Promise { + if (this.labelsDbPromise) return this.labelsDbPromise; + this.labelsDbPromise = openVoxDb(); + return this.labelsDbPromise; + } + private compositeLabelsKey() { + const mapId = this.voxMapId || 'default'; + const scaleKey = this.voxScaleKey || 'default'; + return compositeLabelsDbKey(mapId, scaleKey); + } + private async saveLabelsToDb() { + try { + console.log('saveLabelsToDb'); + const db = await this.getLabelsDb(); + const tx = db.transaction('labels', 'readwrite'); + const store = tx.objectStore('labels'); + const key = this.compositeLabelsKey(); + const payload = this.voxLabels.map(l => l.id >>> 0); + await idbPut(store, payload, key); + await txDone(tx); + } catch { + // ignore persistence failures + } + } + private async loadLabelsFromDb() { + try { + console.log('loadLabelsFromDb'); + const db = await this.getLabelsDb(); + const key = this.compositeLabelsKey(); + const arr = await idbGet(db, 'labels', key); + const existing = new Set(this.voxLabels.map(l => l.id >>> 0)); + console.log("hey",existing, arr, key); + if (arr && Array.isArray(arr) && arr.length > 0) { + // Merge previously created labels (before init) with stored ones. + const mergedIds = new Set(arr.map(id => id >>> 0)); + for (const id of existing) mergedIds.add(id); + this.voxLabels = Array.from(mergedIds).map(id => ({ id })); + // Ensure selected label is valid + const sel = this.voxSelectedLabelId; + if (!sel || !this.voxLabels.some(l => l.id === sel)) { + this.voxSelectedLabelId = this.voxLabels[0].id; + } + console.log('loadLabelsFromDb', this.voxLabels); + console.log('loadLabelsFromDb', this.voxSelectedLabelId); + // Write back merged set to DB to keep in sync. + await this.saveLabelsToDb(); + } else { + // Nothing stored: if any labels were created pre-init, persist them; otherwise, create one. + if (this.voxLabels.length === 0) this.ensureDefaultLabel(); + await this.saveLabelsToDb(); + } + } catch { + // Fallback to default if load fails + if (this.voxLabels.length === 0) this.ensureDefaultLabel(); + } finally { + // Ensure UI reflects the loaded/merged labels. + try { this.onLabelsChanged?.(); } catch { /* ignore */ } + } + } + + ensureDefaultLabel() { + if (this.voxLabels.length > 0) return; + this.createVoxLabel(); + } + async createVoxLabel() { + const id = this.genId(); // unique uint32 + this.voxLabels.push({ id }); + this.voxSelectedLabelId = id; + // Persist immediately only once map meta is known to avoid wrong key. + console.log('createVoxLabel', this.voxMapId, this.voxScaleKey); + if (this.voxMapId && this.voxScaleKey) { + try { await this.saveLabelsToDb(); } catch { /* ignore */ } + } + // Notify UI to re-render labels list whenever a label is created. + try { this.onLabelsChanged?.(); } catch { /* ignore */ } + } + selectVoxLabel(id: number) { + const found = this.voxLabels.find(l => l.id === id); + if (found) this.voxSelectedLabelId = id; + } + getCurrentLabelValue(): number { + if (this.voxEraseMode) return 0; + if (!this.voxSelectedLabelId) this.ensureDefaultLabel(); + const cur = this.voxLabels.find(l => l.id === this.voxSelectedLabelId) || this.voxLabels[0]; + return cur ? (cur.id >>> 0) : 0; + } + constructor(managedLayer: Borrowed) { super(managedLayer); + // Do not create/save default label yet; wait for map init and load. this.tabs.add("vox_settings", { label: "Settings", order: 0, @@ -499,7 +711,7 @@ export class VoxUserLayer extends UserLayer { const guardUnit = this.voxScaleUnit; ls.activate( () => { - const dummySource = new VoxMultiscaleVolumeChunkSource( + const voxSource = new VoxMultiscaleVolumeChunkSource( this.manager.chunkManager, { chunkDataSize: new Uint32Array([64, 64, 64]), @@ -508,18 +720,32 @@ export class VoxUserLayer extends UserLayer { }, ); // Expose a controller so tools can paint voxels via the source. - this.voxEditController = new VoxelEditController(dummySource); + this.voxEditController = new VoxelEditController(voxSource); // Initialize worker-side map persistence for this source (best-effort, fire-and-forget). - const sources2D = dummySource.getSources({} as any); + const sources2D = voxSource.getSources({} as any); const base = sources2D[0][0]; const source = (base.chunkSource as any); + // Compute deterministic identifiers on the frontend to avoid relying on an RPC return value. + const cfgCds = new Uint32Array(Array.from(((voxSource as any)['cfgChunkDataSize']) ?? [64, 64, 64])); + const lowerArr: Float32Array = lower; + const upperArr: Float32Array = upper; + const scaleKey = toScaleKey(cfgCds, lowerArr, upperArr); + // mapId can be any stable string; default to 'local' unless already set. + if (!this.voxMapId) this.voxMapId = 'local'; + this.voxScaleKey = scaleKey; + // Kick off label load immediately; persistence now has proper keys. + void this.loadLabelsFromDb().catch(() => { + console.warn("Failed to load labels from IndexedDB"); + }); void source.initializeMap({ - dataType: dummySource.dataType, - chunkDataSize: Array.from(((dummySource as any)['cfgChunkDataSize']) ?? [64, 64, 64]), - baseVoxelOffset: Array.from(lower as any), - upperVoxelBound: Array.from(upper as any), + mapId: this.voxMapId, + dataType: voxSource.dataType, + chunkDataSize: cfgCds, + baseVoxelOffset: lowerArr, + upperVoxelBound: upperArr, unit: this.voxScaleUnit, + scaleKey, }).catch(() => { /* ignore init failures */ }); // Build transform with current scale and units. @@ -532,7 +758,7 @@ export class VoxUserLayer extends UserLayer { ); ls.addRenderLayer( - new VoxelAnnotationRenderLayer(dummySource, { + new VoxelAnnotationRenderLayer(voxSource, { transform: transform as any, renderScaleTarget: this.sliceViewRenderScaleTarget, renderScaleHistogram: undefined, diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index fc985c8d2b..a02f8edd4b 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -71,7 +71,7 @@ abstract class BaseVoxelLegacyTool extends LegacyTool { if (this.isDrawing) return; this.isDrawing = true; this.currentMouseState = mouseState; - const value = (this.layer as any).voxEraseMode ? 0 : 42; + const value = (this.layer as any).getCurrentLabelValue?.() ?? ((this.layer as any).voxEraseMode ? 0 : 42); const start = this.getPoint(mouseState); if (start) { this.paintPoint(new Float32Array([start[0], start[1], start[2]]), value); diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 0e8102ef3e..bfb8a2997d 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -8,7 +8,7 @@ import { VolumeChunkSource as BaseVolumeChunkSource } from '#src/sliceview/volum import { DataType } from '#src/util/data_type.js'; import { VOX_CHUNK_SOURCE_RPC_ID, VOX_COMMIT_VOXELS_RPC_ID, VOX_MAP_INIT_RPC_ID } from '#src/voxel_annotation/base.js'; import type { VoxMapInitOptions } from '#src/voxel_annotation/index.js'; -import { LocalVoxSource } from '#src/voxel_annotation/index.js'; +import { LocalVoxSource, toScaleKey } from '#src/voxel_annotation/index.js'; import type { RPC } from '#src/worker_rpc.js'; import { registerRPC, registerSharedObject } from '#src/worker_rpc.js'; @@ -26,12 +26,12 @@ export class VoxChunkSource extends BaseVolumeChunkSource { /** Initialize map metadata and persistence backend. */ async initMap(opts: { mapId?: string; dataType?: number; chunkDataSize?: number[]; upperVoxelBound?: number[]; baseVoxelOffset?: number[]; unit?: string; scaleKey?: string}) { - const cds = Array.from(opts.chunkDataSize ?? Array.from(this.spec.chunkDataSize)); - const uvb = Array.from(opts.upperVoxelBound ?? Array.from(this.spec.upperVoxelBound ?? [0, 0, 0] as any)); + const cds: number[] = Array.from(opts.chunkDataSize ?? Array.from(this.spec.chunkDataSize)); + const uvb: number[] = Array.from(opts.upperVoxelBound ?? Array.from(this.spec.upperVoxelBound ?? [0, 0, 0] as any)); const dt = opts.dataType ?? this.spec.dataType; - const scaleKey = opts.scaleKey ?? `${cds[0]}_${cds[1]}_${cds[2]}`; // Default base offset to spec.baseVoxelOffset if not provided - const bvo = Array.from(opts.baseVoxelOffset ?? Array.from((this.spec as any).baseVoxelOffset ?? [0, 0, 0])); + const bvo: number[] = Array.from(opts.baseVoxelOffset ?? Array.from((this.spec as any).baseVoxelOffset ?? [0, 0, 0])); + const scaleKey = opts.scaleKey ?? toScaleKey(cds, bvo, uvb); const initOpts = { mapId: opts.mapId, dataType: dt, diff --git a/src/voxel_annotation/index.ts b/src/voxel_annotation/index.ts index d27f7dd49c..e2d4f1918c 100644 --- a/src/voxel_annotation/index.ts +++ b/src/voxel_annotation/index.ts @@ -18,6 +18,21 @@ export interface SavedChunk { size: Uint32Array; // canonical size used for linearization (usually spec.chunkDataSize) } +export function toScaleKey(chunkDataSize: number[] | Uint32Array, baseVoxelOffset?: number[] | Uint32Array | Float32Array, upperVoxelBound?: number[] | Uint32Array | Float32Array): string { + const cds = Array.from(chunkDataSize); + const lower = Array.from(baseVoxelOffset ?? [0, 0, 0]); + const upper = Array.from(upperVoxelBound ?? [0, 0, 0]); + return `${cds[0]}_${cds[1]}_${cds[2]}:${lower[0]}_${lower[1]}_${lower[2]}-${upper[0]}_${upper[1]}_${upper[2]}`; +} + +export function compositeChunkDbKey(mapId: string, scaleKey: string, chunkKey: string): string { + return `${mapId}:${scaleKey}:${chunkKey}`; +} + +export function compositeLabelsDbKey(mapId: string, scaleKey: string): string { + return `${mapId}:${scaleKey}:labels`; +} + export class VoxSource { protected mapId: string = 'default'; protected scaleKey: string = ''; @@ -48,10 +63,7 @@ export class VoxSource { if (opts.scaleKey) { this.scaleKey = opts.scaleKey; } else { - const cds = Array.from(this.chunkDataSize); - const lower = Array.from(this.baseVoxelOffset); - const upper = Array.from(this.upperVoxelBound); - this.scaleKey = `${cds[0]}_${cds[1]}_${cds[2]}:${lower[0]}_${lower[1]}_${lower[2]}-${upper[0]}_${upper[1]}_${upper[2]}`; + this.scaleKey = toScaleKey(this.chunkDataSize, this.baseVoxelOffset, this.upperVoxelBound); } return Promise.resolve({ mapId: this.mapId, scaleKey: this.scaleKey }); } @@ -203,21 +215,12 @@ export class LocalVoxSource extends VoxSource { } private compositeKey(key: string) { - return `${this.mapId}:${this.scaleKey}:${key}`; + return compositeChunkDbKey(this.mapId, this.scaleKey, key); } private async getDb(): Promise { if (this.dbPromise) return this.dbPromise; - this.dbPromise = new Promise((resolve, reject) => { - const req = indexedDB.open('neuroglancer_vox', 1); - req.onerror = () => reject(req.error); - req.onupgradeneeded = () => { - const db = req.result; - if (!db.objectStoreNames.contains('maps')) db.createObjectStore('maps'); - if (!db.objectStoreNames.contains('chunks')) db.createObjectStore('chunks'); - }; - req.onsuccess = () => resolve(req.result); - }); + this.dbPromise = openVoxDb(); return this.dbPromise; } } @@ -228,8 +231,22 @@ export class RemoteVoxSource extends VoxSource { } } +export function openVoxDb(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open('neuroglancer_vox', 2); + req.onerror = () => reject(req.error); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains('maps')) db.createObjectStore('maps'); + if (!db.objectStoreNames.contains('chunks')) db.createObjectStore('chunks'); + if (!db.objectStoreNames.contains('labels')) db.createObjectStore('labels'); + }; + req.onsuccess = () => resolve(req.result); + }); +} + // --- Small IDB helpers --- -function idbGet(db: IDBDatabase, storeName: string, key: IDBValidKey): Promise { +export function idbGet(db: IDBDatabase, storeName: string, key: IDBValidKey): Promise { return new Promise((resolve, reject) => { const tx = db.transaction(storeName, 'readonly'); const store = tx.objectStore(storeName); @@ -239,7 +256,7 @@ function idbGet(db: IDBDatabase, storeName: string, key: IDBValidKey): Promis }); } -function idbPut(store: IDBObjectStore, value: any, key?: IDBValidKey) { +export function idbPut(store: IDBObjectStore, value: any, key?: IDBValidKey) { return new Promise((resolve, reject) => { const req = key === undefined ? store.put(value) : store.put(value, key); req.onerror = () => reject(req.error); @@ -247,7 +264,7 @@ function idbPut(store: IDBObjectStore, value: any, key?: IDBValidKey) { }); } -function txDone(tx: IDBTransaction) { +export function txDone(tx: IDBTransaction) { return new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); diff --git a/src/voxel_annotation/renderlayer.ts b/src/voxel_annotation/renderlayer.ts index 153d6f47ef..4ad483e336 100644 --- a/src/voxel_annotation/renderlayer.ts +++ b/src/voxel_annotation/renderlayer.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { SegmentColorShaderManager } from "#src/segment_color.js"; import type { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; import type { RenderLayerOptions } from "#src/sliceview/volume/renderlayer.js"; import { SliceViewVolumeRenderLayer } from "#src/sliceview/volume/renderlayer.js"; @@ -33,6 +34,7 @@ import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; type EmptyParams = Record; export class VoxelAnnotationRenderLayer extends SliceViewVolumeRenderLayer { + private segmentColorShaderManager = new SegmentColorShaderManager("segmentColorHash"); constructor( multiscaleSource: MultiscaleVolumeChunkSource, @@ -48,10 +50,21 @@ export class VoxelAnnotationRenderLayer extends SliceViewVolumeRenderLayer 10.0 ? vec4(1.0, 1.0, 0.0, 0.5) : vec4(clamp(t, 0.0, 1.0), 0.0, 0.0, t > 0.0 ? 0.3 : 0.0); - emit(color); + uint64_t v64 = getUint64DataValue(); + vec3 rgb = segmentColorHash(v64); + // Transparent if zero, otherwise semi-opaque + bool isZero = (v64.value[0] == 0u && v64.value[1] == 0u); + float alpha = isZero ? 0.0 : 0.5; + emit(vec4(rgb, alpha)); `); /** @@ -63,18 +76,19 @@ export class VoxelAnnotationRenderLayer extends SliceViewVolumeRenderLayer Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 024/251] feat: implement RPC-based voxel label persistence - Replace IndexedDB-based label management with RPC-driven persistence. - Add `getLabelIds` and `setLabelIds` methods for backend label synchronization. - Introduce new RPC handlers for label retrieval and updates. - Update frontend to initialize voxel map and manage labels via RPC calls. - Consolidate label persistence logic in `VoxSource` and its subclasses. - Remove debugging logs and redundant IndexedDB related code. --- NOTES/TODOs.md | 1 - src/layer/vox/index.ts | 62 +++++++------------------ src/sliceview/volume/renderlayer.ts | 6 --- src/voxel_annotation/backend.ts | 20 ++++++-- src/voxel_annotation/base.ts | 2 + src/voxel_annotation/edit_controller.ts | 19 ++++++++ src/voxel_annotation/frontend.ts | 27 +++++++++-- src/voxel_annotation/index.ts | 46 +++++++++++++++++- src/voxel_annotation/renderlayer.ts | 2 - 9 files changed, 121 insertions(+), 64 deletions(-) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index 6a9d3af480..b6e3d1f133 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,6 +1,5 @@ # TODO List - continue to study the segmentation compression, using it should greatly reduce the ram and indexDB usage, but it no easy integration of the hot chunk reloading in the frontend for drawing tool responsiveness has been found. -- add label creation (with color, name, description and uuid) - add flood fill tool (with a max expansion safeguard), this tool should be 2d (e.g. act on a plane, the plane normal to the z axis is sufficient for a v1) - Fix the orientation of the disk in the brush tool - the uncaching of chunks the VoxSource is working great, but since it has no way of knowing which chunks are in view, it will delete them, causing flickering of the drawings. diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index e725f2c4f1..2fe7882574 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -40,7 +40,7 @@ import { registerVoxelAnnotationTools, VoxelBrushLegacyTool, VoxelPixelLegacyToo import type { Borrowed } from "#src/util/disposable.js"; import { mat4 } from "#src/util/geom.js"; import { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; -import { openVoxDb, idbGet, idbPut, txDone, toScaleKey, compositeLabelsDbKey } from "#src/voxel_annotation/index.js"; +import { toScaleKey } from "#src/voxel_annotation/index.js"; import { VoxelAnnotationRenderLayer } from "#src/voxel_annotation/renderlayer.js"; import { VoxMultiscaleVolumeChunkSource } from "#src/voxel_annotation/volume_chunk_source.js"; import { Tab } from "#src/widget/tab_view.js"; @@ -397,8 +397,6 @@ class VoxToolTab extends Tab { export class VoxUserLayer extends UserLayer { onLabelsChanged?: () => void; private voxMapId: string | undefined; - private voxScaleKey: string | undefined; - private labelsDbPromise: Promise | null = null; // Label state for painting: only store ids; colors are hashed from id on the fly voxLabels: { id: number }[] = []; voxSelectedLabelId: number | undefined = undefined; @@ -450,39 +448,19 @@ export class VoxUserLayer extends UserLayer { return this.segmentColorHash.computeCssColor(BigInt(v >>> 0)); } - // --- Labels persistence (IndexedDB) --- - private async getLabelsDb(): Promise { - if (this.labelsDbPromise) return this.labelsDbPromise; - this.labelsDbPromise = openVoxDb(); - return this.labelsDbPromise; - } - private compositeLabelsKey() { - const mapId = this.voxMapId || 'default'; - const scaleKey = this.voxScaleKey || 'default'; - return compositeLabelsDbKey(mapId, scaleKey); - } - private async saveLabelsToDb() { + // --- Labels persistence (via VoxSource) --- + private async saveLabels() { try { - console.log('saveLabelsToDb'); - const db = await this.getLabelsDb(); - const tx = db.transaction('labels', 'readwrite'); - const store = tx.objectStore('labels'); - const key = this.compositeLabelsKey(); - const payload = this.voxLabels.map(l => l.id >>> 0); - await idbPut(store, payload, key); - await txDone(tx); + const ids = this.voxLabels.map(l => l.id >>> 0); + await this.voxEditController?.setLabelIds(ids); } catch { // ignore persistence failures } } - private async loadLabelsFromDb() { + private async loadLabels() { try { - console.log('loadLabelsFromDb'); - const db = await this.getLabelsDb(); - const key = this.compositeLabelsKey(); - const arr = await idbGet(db, 'labels', key); + const arr = await this.voxEditController?.getLabelIds(); const existing = new Set(this.voxLabels.map(l => l.id >>> 0)); - console.log("hey",existing, arr, key); if (arr && Array.isArray(arr) && arr.length > 0) { // Merge previously created labels (before init) with stored ones. const mergedIds = new Set(arr.map(id => id >>> 0)); @@ -493,14 +471,12 @@ export class VoxUserLayer extends UserLayer { if (!sel || !this.voxLabels.some(l => l.id === sel)) { this.voxSelectedLabelId = this.voxLabels[0].id; } - console.log('loadLabelsFromDb', this.voxLabels); - console.log('loadLabelsFromDb', this.voxSelectedLabelId); - // Write back merged set to DB to keep in sync. - await this.saveLabelsToDb(); + // Write back merged set to keep in sync. + await this.saveLabels(); } else { // Nothing stored: if any labels were created pre-init, persist them; otherwise, create one. if (this.voxLabels.length === 0) this.ensureDefaultLabel(); - await this.saveLabelsToDb(); + await this.saveLabels(); } } catch { // Fallback to default if load fails @@ -519,10 +495,9 @@ export class VoxUserLayer extends UserLayer { const id = this.genId(); // unique uint32 this.voxLabels.push({ id }); this.voxSelectedLabelId = id; - // Persist immediately only once map meta is known to avoid wrong key. - console.log('createVoxLabel', this.voxMapId, this.voxScaleKey); - if (this.voxMapId && this.voxScaleKey) { - try { await this.saveLabelsToDb(); } catch { /* ignore */ } + // Persist immediately once the source/controller is available. + if (this.voxEditController) { + try { await this.saveLabels(); } catch { /* ignore */ } } // Notify UI to re-render labels list whenever a label is created. try { this.onLabelsChanged?.(); } catch { /* ignore */ } @@ -733,12 +708,8 @@ export class VoxUserLayer extends UserLayer { const scaleKey = toScaleKey(cfgCds, lowerArr, upperArr); // mapId can be any stable string; default to 'local' unless already set. if (!this.voxMapId) this.voxMapId = 'local'; - this.voxScaleKey = scaleKey; - // Kick off label load immediately; persistence now has proper keys. - void this.loadLabelsFromDb().catch(() => { - console.warn("Failed to load labels from IndexedDB"); - }); - void source.initializeMap({ + // Initialize backend map first, then load labels from the chosen datasource. + source.initializeMap({ mapId: this.voxMapId, dataType: voxSource.dataType, chunkDataSize: cfgCds, @@ -746,7 +717,8 @@ export class VoxUserLayer extends UserLayer { upperVoxelBound: upperArr, unit: this.voxScaleUnit, scaleKey, - }).catch(() => { /* ignore init failures */ }); + }); + this.loadLabels(); // Build transform with current scale and units. const identity3D = this.createIdentity3D(); diff --git a/src/sliceview/volume/renderlayer.ts b/src/sliceview/volume/renderlayer.ts index d7646a6741..e189483934 100644 --- a/src/sliceview/volume/renderlayer.ts +++ b/src/sliceview/volume/renderlayer.ts @@ -348,7 +348,6 @@ export abstract class SliceViewVolumeRenderLayer< multiscaleSource: MultiscaleVolumeChunkSource, options: RenderLayerOptions, ) { - console.log("SliceViewVolumeRenderLayer constructor called with options DEBUG 1"); const { shaderError = makeWatchableShaderError(), shaderParameters } = options; super(multiscaleSource.chunkManager, multiscaleSource, options); @@ -381,7 +380,6 @@ export abstract class SliceViewVolumeRenderLayer< ], ), ); - console.log("calling parameterizedContextDependentShaderGetter") this.shaderGetter = parameterizedContextDependentShaderGetter(this, gl, { memoizeKey: `volume/RenderLayer:${getObjectId(this.constructor)}`, fallbackParameters: options.fallbackShaderParameters, @@ -398,7 +396,6 @@ export abstract class SliceViewVolumeRenderLayer< parameters: ShaderParameters, extraParameters: ShaderContext, ) => { - console.log("defining shader with parameters DEBUG 3"); const { chunkFormat, dataHistogramsEnabled } = context; const { dataHistogramChannelSpecifications, numChannelDimensions } = extraParameters; @@ -410,7 +407,6 @@ void emit(vec4 color) { } `); if (chunkFormat === null) { - console.log("no chunk format"); return; } defineChunkDataShaderAccess( @@ -457,7 +453,6 @@ void main() { } #define main userMain\n`); } - console.log("defining shader with parameters DEBUG 2", parameters); this.defineShader(builder, parameters); }, getContextKey: (context) => @@ -465,7 +460,6 @@ void main() { }); this.tempChunkPosition = new Float32Array(multiscaleSource.rank); this.initializeCounterpart(); - console.log("constructor done"); } get dataType() { diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index bfb8a2997d..e5ae187af7 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -6,11 +6,11 @@ import type { VolumeChunk } from '#src/sliceview/volume/backend.js'; import { VolumeChunkSource as BaseVolumeChunkSource } from '#src/sliceview/volume/backend.js'; import { DataType } from '#src/util/data_type.js'; -import { VOX_CHUNK_SOURCE_RPC_ID, VOX_COMMIT_VOXELS_RPC_ID, VOX_MAP_INIT_RPC_ID } from '#src/voxel_annotation/base.js'; +import { VOX_CHUNK_SOURCE_RPC_ID, VOX_COMMIT_VOXELS_RPC_ID, VOX_MAP_INIT_RPC_ID, VOX_LABELS_GET_RPC_ID, VOX_LABELS_SET_RPC_ID } from '#src/voxel_annotation/base.js'; import type { VoxMapInitOptions } from '#src/voxel_annotation/index.js'; import { LocalVoxSource, toScaleKey } from '#src/voxel_annotation/index.js'; import type { RPC } from '#src/worker_rpc.js'; -import { registerRPC, registerSharedObject } from '#src/worker_rpc.js'; +import { registerRPC, registerPromiseRPC, registerSharedObject } from '#src/worker_rpc.js'; /** * Backend volume source that persists voxel edits per chunk. It returns saved data if available, @@ -18,7 +18,7 @@ import { registerRPC, registerSharedObject } from '#src/worker_rpc.js'; */ @registerSharedObject(VOX_CHUNK_SOURCE_RPC_ID) export class VoxChunkSource extends BaseVolumeChunkSource { - private local = new LocalVoxSource(); + local = new LocalVoxSource(); constructor(rpc: RPC, options: any) { super(rpc, options); @@ -116,5 +116,17 @@ registerRPC(VOX_COMMIT_VOXELS_RPC_ID, function (x: any) { // RPC to initialize map registerRPC(VOX_MAP_INIT_RPC_ID, function (x: any) { const obj = this.get(x.id) as VoxChunkSource; - return obj.initMap(x || {}); + obj.initMap(x || {}); +}); + +// RPCs for label persistence (promise-based) +registerPromiseRPC(VOX_LABELS_GET_RPC_ID, async function (x: any): Promise { + const obj = this.get(x.rpcId) as VoxChunkSource; + const ids = await obj.local.getLabelIds(); + return { value: ids }; +}); + +registerRPC(VOX_LABELS_SET_RPC_ID, function (x: any) { + const obj = this.get(x.id) as VoxChunkSource; + obj.local.setLabelIds(Array.isArray(x?.ids) ? x.ids : []); }); diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index 28c08cd0c3..a8a5c578fe 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -1,3 +1,5 @@ export const VOX_CHUNK_SOURCE_RPC_ID = 'vox.VoxChunkSource'; export const VOX_COMMIT_VOXELS_RPC_ID = 'vox.commitVoxels'; export const VOX_MAP_INIT_RPC_ID = 'vox.map.init'; +export const VOX_LABELS_GET_RPC_ID = 'vox.labels.get'; +export const VOX_LABELS_SET_RPC_ID = 'vox.labels.set'; diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 04cf03dea8..9fb7acf99f 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -104,4 +104,23 @@ export class VoxelEditController { paintBrush(center: Float32Array, radius: number, value: number) { this.paintBrushWithShape(center, radius, value, 'sphere'); } + + async getLabelIds(): Promise { + try { + const source = this.getSource(); + if (!source) return []; + return await source.getLabelIds(); + } catch { + return []; + } + } + + setLabelIds(ids: number[]) { + try { + const source = this.getSource(); + source?.setLabelIds(ids); + } catch { + // ignore + } + } } diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 64dbc34708..24176c4dc5 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -10,7 +10,7 @@ import type { VolumeChunk } from '#src/sliceview/volume/frontend.js'; import { VolumeChunkSource as BaseVolumeChunkSource } from '#src/sliceview/volume/frontend.js'; import { animationFrameDebounce } from "#src/util/animation_frame_debounce.js"; import type { TypedArray } from '#src/util/array.js'; -import { VOX_CHUNK_SOURCE_RPC_ID, VOX_COMMIT_VOXELS_RPC_ID, VOX_MAP_INIT_RPC_ID } from '#src/voxel_annotation/base.js'; +import { VOX_CHUNK_SOURCE_RPC_ID, VOX_COMMIT_VOXELS_RPC_ID, VOX_MAP_INIT_RPC_ID, VOX_LABELS_GET_RPC_ID, VOX_LABELS_SET_RPC_ID } from '#src/voxel_annotation/base.js'; import { registerSharedObjectOwner } from "#src/worker_rpc.js"; @@ -27,12 +27,12 @@ export class VoxChunkSource extends BaseVolumeChunkSource { ); /** Initialize map in the worker/backend for this source. */ - async initializeMap(opts: { mapId?: string; dataType?: number; chunkDataSize?: number[]; upperVoxelBound?: number[]; baseVoxelOffset?: number[]; unit?: string; scaleKey?: string}) { + initializeMap(opts: { mapId?: string; dataType?: number; chunkDataSize?: number[]; upperVoxelBound?: number[]; baseVoxelOffset?: number[]; unit?: string; scaleKey?: string}) { try { - return await this.rpc!.invoke(VOX_MAP_INIT_RPC_ID, { id: this.rpcId, ...opts }); + this.rpc!.invoke(VOX_MAP_INIT_RPC_ID, { id: this.rpcId, ...opts }); } catch { // initialization is best-effort; continue even if it fails - return undefined; + console.warn('VoxChunkSource.initializeMap: Failed to initialize voxel map.'); } } @@ -40,6 +40,25 @@ export class VoxChunkSource extends BaseVolumeChunkSource { super(chunkManager, options); } + async getLabelIds(): Promise { + try { + /* + NOTE: do not pass the rpcId as { id: this.rpcId } since the id field it will be overwritten by promiseInvoke, use another name like { rpcId: this.rpcId } + */ + return await this.rpc!.promiseInvoke(VOX_LABELS_GET_RPC_ID, { rpcId: this.rpcId }); + } catch { + return []; + } + } + + setLabelIds(ids: number[]){ + try { + this.rpc!.invoke(VOX_LABELS_SET_RPC_ID, { id: this.rpcId, ids }); + } catch { + // ignore + } + } + private scheduleUpdate(key: string) { this.dirtyChunks.add(key); this.scheduleProcessPendingUploads(); diff --git a/src/voxel_annotation/index.ts b/src/voxel_annotation/index.ts index e2d4f1918c..194462e63a 100644 --- a/src/voxel_annotation/index.ts +++ b/src/voxel_annotation/index.ts @@ -33,7 +33,7 @@ export function compositeLabelsDbKey(mapId: string, scaleKey: string): string { return `${mapId}:${scaleKey}:labels`; } -export class VoxSource { +export abstract class VoxSource { protected mapId: string = 'default'; protected scaleKey: string = ''; protected chunkDataSize: Uint32Array = new Uint32Array([64, 64, 64]); @@ -50,6 +50,13 @@ export class VoxSource { protected dirty = new Set(); protected saveTimer: number | undefined; + /** + * Generic label persistence hooks. Subclasses override to connect to the chosen datasource. + * Default implementation is a no-op empty list. + */ + async getLabelIds(): Promise { return []; } + async setLabelIds(_ids: number[]): Promise { /* no-op */ } + init(_opts: VoxMapInitOptions): Promise<{ mapId: string; scaleKey: string }> { // Base provides default bookkeeping; persistence layer does real work. const opts = _opts || ({} as VoxMapInitOptions); @@ -81,7 +88,6 @@ export class VoxSource { } // Overridden by subclass to actually persist dirty chunks. - // eslint-disable-next-line @typescript-eslint/no-unused-vars protected async flushSaves(): Promise {} // Apply edits into an in-memory chunk array; returns the SavedChunk. @@ -110,6 +116,32 @@ export class VoxSource { export class LocalVoxSource extends VoxSource { private dbPromise: Promise | null = null; + override async getLabelIds(): Promise { + try { + const db = await this.getDb(); + const key = compositeLabelsDbKey(this.mapId, this.scaleKey); + const arr = await idbGet(db, 'labels', key); + if (arr && Array.isArray(arr)) return arr.map(v => v >>> 0); + return []; + } catch { + return []; + } + } + + override async setLabelIds(ids: number[]): Promise { + try { + const db = await this.getDb(); + const tx = db.transaction('labels', 'readwrite'); + const store = tx.objectStore('labels'); + const key = compositeLabelsDbKey(this.mapId, this.scaleKey); + const payload = ids.map(v => v >>> 0); + await idbPut(store, payload, key); + await txDone(tx); + } catch { + // ignore + } + } + private touch(key: string) { const v = this.saved.get(key); if (!v) return; @@ -226,9 +258,19 @@ export class LocalVoxSource extends VoxSource { } export class RemoteVoxSource extends VoxSource { + private labelsCache: number[] = []; constructor(public url: string) { super(); } + override async getLabelIds(): Promise { + // Placeholder: if a remote endpoint exists, fetch from `${url}/labels` with map/scale. + // For now, return in-memory cache. + return Array.from(this.labelsCache); + } + override async setLabelIds(ids: number[]): Promise { + // Placeholder: post to remote endpoint; cache locally as best-effort. + this.labelsCache = ids.map(v => v >>> 0); + } } export function openVoxDb(): Promise { diff --git a/src/voxel_annotation/renderlayer.ts b/src/voxel_annotation/renderlayer.ts index 4ad483e336..c5e587955a 100644 --- a/src/voxel_annotation/renderlayer.ts +++ b/src/voxel_annotation/renderlayer.ts @@ -40,13 +40,11 @@ export class VoxelAnnotationRenderLayer extends SliceViewVolumeRenderLayer, ) { - console.log('$$$$$$$ VoxelAnnotationRenderLayer creation with options:', options); super(multiscaleSource, { ...options, shaderParameters: options.shaderParameters ?? constantWatchableValue({} as EmptyParams), encodeShaderParameters: () => 0, }); - console.log('###### VoxelAnnotationRenderLayer created with options:', options); } defineShader(builder: ShaderBuilder) { From 732132e9d229d60204bd929013f8badd8654b02f Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 025/251] refactor: ran 'npm run format:fix' --- NOTES/RPC.md | 50 +++-- NOTES/TODOs.md | 7 +- NOTES/annotation-chunk-source-and-sync.md | 34 ++-- .../MultiscaleVolumeChunkSource.md | 70 ++++--- NOTES/classExplanations/chunk-source.md | 34 ++-- NOTES/custom-gc.md | 1 + NOTES/data-saving-plan.md | 157 +++++++++++----- NOTES/image-vs-segmentation-volumeType.md | 15 ++ NOTES/voxel-annotation-specification.md | 51 +++--- src/layer/vox/index.ts | 124 +++++++++---- src/layer/vox/style.css | 18 +- src/sliceview/frontend.ts | 20 +- src/sliceview/single_texture_chunk_format.ts | 26 ++- src/ui/voxel_annotations.ts | 14 +- src/voxel_annotation/backend.ts | 83 ++++++--- src/voxel_annotation/base.ts | 10 +- src/voxel_annotation/edit_controller.ts | 27 ++- src/voxel_annotation/frontend.ts | 75 +++++--- src/voxel_annotation/index.ts | 171 +++++++++++++----- src/voxel_annotation/renderlayer.ts | 7 +- src/voxel_annotation/volume_chunk_source.ts | 25 ++- src/webgl/shader.ts | 8 +- 22 files changed, 703 insertions(+), 324 deletions(-) diff --git a/NOTES/RPC.md b/NOTES/RPC.md index c470412d40..6ec5da8c3b 100644 --- a/NOTES/RPC.md +++ b/NOTES/RPC.md @@ -15,6 +15,7 @@ Once you keep those three ideas in mind, the rest of the patterns (like sharing Core file: src/worker_rpc.ts - Message router + - registerRPC(name, handler) records a function capable of handling messages named “name”. - rpc.invoke(name, payload, transfers?) serializes your payload and posts it to the other side. The other side looks up handler by name and calls it. @@ -23,6 +24,7 @@ Core file: src/worker_rpc.ts - rpc.promiseInvoke(name, payload, { signal, progressListener, transfers }) sends the request and returns a Promise. If you pass an AbortSignal, the other side will receive a cancellation via the standard PROMISE_CANCEL_ID. If you pass a progressListener, the other side can emit progress spans back. Key snippets (file: src/worker_rpc.ts): + - Registry and invoke: handlers map, registerRPC, RPC.invoke (lines ~42–46, 225–236). - Promise protocol: registerPromiseRPC and rpc.promiseInvoke with cancel and progress (lines ~74–108, 238–269, 110–145, 130–140). - Ready/queue: when the peer worker isn’t ready yet, outgoing messages get queued until onPeerReady flushes them (lines ~158–189). @@ -38,10 +40,11 @@ Core file: src/worker_rpc.ts - A SharedObject is a RefCounted instance that exists on both sides (owner and counterpart). The owner creates the counterpart using a factory call; the counterpart is a lightweight representation used to send signals back to the owner. Both halves refer to each other via an RPC id. - Ownership and lifecycle + - Owner side calls initializeCounterpart(rpc, options). That: - 1) sets up bookkeeping (rpc, rpcId), - 2) marks itself as owner, - 3) invokes SharedObject.new with type and options so the other side constructs the counterpart (lines ~290–298). + 1. sets up bookkeeping (rpc, rpcId), + 2. marks itself as owner, + 3. invokes SharedObject.new with type and options so the other side constructs the counterpart (lines ~290–298). - Counterpart creation (other side): SharedObject.new handler looks up the registered constructor by the type string and new()’s it (lines ~439–446). Counterpart starts with refCount zero. - Reference model: addCounterpartRef() returns { id, gen }, where gen is a monotonically increasing generation number that tracks references flowing to the other side (line ~307–309). When a counterpart’s refcount drops to zero, it notifies the owner via SharedObject.refCountReachedZero (lines ~398–402), passing back the generation that reached zero. - Cleanup: @@ -62,9 +65,11 @@ Plain-English analogy: imagine the frontend owns a remote handle in the worker. Also in src/worker_rpc.ts - @registerSharedObjectOwner(identifier) + - Sets RPC_TYPE_ID on the class prototype to the given string (lines ~411–415). This is used when the owner class will initiate a counterpart. On initializeCounterpart, that RPC_TYPE_ID is sent so the other side knows which constructor to call. - @registerSharedObject(identifier?) + - Registers a class constructor in a global map keyed by identifier (lines ~425–437). This is meant for counterpart classes (the classes to construct when a “SharedObject.new” message arrives). If you omit the identifier, the class’s prototype must already have RPC_TYPE_ID. - How they combine: @@ -72,6 +77,7 @@ Also in src/worker_rpc.ts - Counterpart side: a class decorated with @registerSharedObject("My.Type") is discoverable by the SharedObject.new handler, which constructs it with (rpc, options). In the code: + - Owner example (frontend): - src/annotation/renderlayer.ts: AnnotationLayerSharedObject is decorated with @registerSharedObjectOwner(ANNOTATION_RENDER_LAYER_RPC_ID) and calls this.initializeCounterpart(...) to spin up the backend counterpart with the same identifier (lines ~182–201). - Counterpart example (backend): @@ -84,12 +90,14 @@ This pattern appears broadly across the codebase for chunk sources, mesh layers, ### Example: sharing visibility across threads with a mixin Files: + - src/visibility_priority/frontend.ts - src/visibility_priority/backend.ts withSharedVisibility is a mixin that augments a SharedObject-based class with a “visibility” property that’s actually a shared, cross-thread WatchableValue. It demonstrates how to embed another shared object inside your own options during initializeCounterpart. - Frontend side mixin (owner): + - Adds visibility = new VisibilityPriorityAggregator() (an aggregator of watchable priorities). - In initializeCounterpart, it constructs a SharedWatchableValue from the existing WatchableValue and injects the rpcId into options.visibility before calling super.initializeCounterpart (frontend.ts lines ~96–105). This means the backend will receive an rpc id to a SharedWatchableValue. @@ -116,21 +124,25 @@ This is the building block used by withSharedVisibility and also elsewhere when Let’s walk through a concrete case from annotations (simplified): -1) Frontend creates an owner object - - class AnnotationLayerSharedObject extends withSharedVisibility(...) is decorated with @registerSharedObjectOwner(ANNOTATION_RENDER_LAYER_RPC_ID). - - Its constructor calls initializeCounterpart(this.chunkManager.rpc, { source: source.rpcId, segmentationStates: ..., visibility: SharedWatchableValue.makeFromExisting(...).rpcId }) +1. Frontend creates an owner object + +- class AnnotationLayerSharedObject extends withSharedVisibility(...) is decorated with @registerSharedObjectOwner(ANNOTATION_RENDER_LAYER_RPC_ID). +- Its constructor calls initializeCounterpart(this.chunkManager.rpc, { source: source.rpcId, segmentationStates: ..., visibility: SharedWatchableValue.makeFromExisting(...).rpcId }) + +2. RPC constructs the backend counterpart -2) RPC constructs the backend counterpart - - The owner call triggers rpc.invoke("SharedObject.new", { id, type: ANNOTATION_RENDER_LAYER_RPC_ID, ...options }). - - On the backend, the SharedObject.new handler looks up the registered constructor for that id (registered by @registerSharedObject on the backend class) and constructs it with (rpc, options). - - The backend counterpart receives options.visibility as a reference id and does rpc.get(options.visibility) to obtain the SharedWatchableValue handle for ongoing updates. +- The owner call triggers rpc.invoke("SharedObject.new", { id, type: ANNOTATION_RENDER_LAYER_RPC_ID, ...options }). +- On the backend, the SharedObject.new handler looks up the registered constructor for that id (registered by @registerSharedObject on the backend class) and constructs it with (rpc, options). +- The backend counterpart receives options.visibility as a reference id and does rpc.get(options.visibility) to obtain the SharedWatchableValue handle for ongoing updates. -3) Runtime updates - - If frontend changes visibility, SharedWatchableValue sends a CHANGED message; backend’s handler updates its copy and may reprioritize chunk requests. - - If backend needs to respond with progress or results, it uses RPC handlers or registerPromiseRPC to return data. +3. Runtime updates -4) Cleanup - - Any references sent to the other side are stamped with a generation (addCounterpartRef()). When the counterpart’s refcount drops to zero, it notifies the owner (SharedObject.refCountReachedZero). When both sides are done for the current generation and the owner’s own refcount is zero, the owner sends SharedObject.dispose, and both sides free their mapping. +- If frontend changes visibility, SharedWatchableValue sends a CHANGED message; backend’s handler updates its copy and may reprioritize chunk requests. +- If backend needs to respond with progress or results, it uses RPC handlers or registerPromiseRPC to return data. + +4. Cleanup + +- Any references sent to the other side are stamped with a generation (addCounterpartRef()). When the counterpart’s refcount drops to zero, it notifies the owner (SharedObject.refCountReachedZero). When both sides are done for the current generation and the owner’s own refcount is zero, the owner sends SharedObject.dispose, and both sides free their mapping. --- @@ -138,11 +150,13 @@ Let’s walk through a concrete case from annotations (simplified): - Decide which side “owns” it (the side that will call initializeCounterpart()). - On the owner class: + - Decorate: @registerSharedObjectOwner("my.unique.type") - Derive from SharedObject or a mixin that includes it (e.g., withSharedVisibility(SharedObject)). - In your constructor, call this.initializeCounterpart(rpc, { ...options }) and include any nested shared object ids (e.g., visibility: SharedWatchableValue.makeFromExisting(rpc, myWatchable).rpcId). - On the counterpart class (other thread): + - Decorate: @registerSharedObject("my.unique.type") - Derive from SharedObjectCounterpart or another mixin chain suitable for the backend (e.g., withSharedVisibility(ChunkRequesterBase)). - In the constructor(rpc, options), read back nested shared objects using rpc.get(options.someSharedId) and wire up listeners. @@ -156,12 +170,15 @@ Let’s walk through a concrete case from annotations (simplified): ### Debugging tips - Confirm the type id matches on both sides + - The string passed to @registerSharedObject on the counterpart must match the RPC_TYPE_ID of the owner class (or the string you gave to @registerSharedObjectOwner). Mismatches lead to SharedObject.new failing to find a constructor. - Check map sizes and ids + - RPC keeps a map of id -> object on each side. If you leak references, numObjects will grow. The debug logs (guarded by DEBUG) can help trace lifecycle. - Progress/cancel plumbing + - If you pass a progressListener to promiseInvoke, ensure the backend handler is registered with registerPromiseRPC and that it uses the provided progressListener to add/remove spans. Cancellation will call abortController.abort() on the backend. - Be careful with structured clone @@ -172,12 +189,15 @@ Let’s walk through a concrete case from annotations (simplified): ### Pointers to concrete code you can read next - RPC core, SharedObject lifecycle, and decorators: + - src/worker_rpc.ts - A minimal, reusable shared value: + - src/shared_watchable_value.ts - A realistic composite use (visibility sharing): + - src/visibility_priority/frontend.ts (owner side mixin) - src/visibility_priority/backend.ts (counterpart side mixin) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index b6e3d1f133..217e625661 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,7 +1,8 @@ # TODO List + - continue to study the segmentation compression, using it should greatly reduce the ram and indexDB usage, but it no easy integration of the hot chunk reloading in the frontend for drawing tool responsiveness has been found. - add flood fill tool (with a max expansion safeguard), this tool should be 2d (e.g. act on a plane, the plane normal to the z axis is sufficient for a v1) -- Fix the orientation of the disk in the brush tool +- Fix the orientation of the disk in the brush tool - the uncaching of chunks the VoxSource is working great, but since it has no way of knowing which chunks are in view, it will delete them, causing flickering of the drawings. - Add Uint64 support for annotation id @@ -14,7 +15,7 @@ The RemoteVoxSource will be activated when a https:// link to a specially made s # LOD - the saving of drawing data is already indexed with their scale; to allow for multiscale rendering, we should also provide a way to display the data coming from different scales than the current one. This involves two steps: + 1. on saving of data, we should propagate the complete voxel cube to the upper levels (lower zoom levels) recursively 2. on loading of data, we should retrieve not only the current scale chunks but also the ones from the lower zoom levels. -This last step will introduce conflicts what if the same voxel does not have the same value in the different scales? And how to know if there has been deletion or if there are just no data? To solve this, we must introduce a special value for the deleted voxels and also timestamp for the last chunk updates. ~~To avoid too many conficts, we should resolve them when loading the data.~~ Actually, we should not resolve those conflicts live as doing so will prevent us from implementing an undo feature. - + This last step will introduce conflicts what if the same voxel does not have the same value in the different scales? And how to know if there has been deletion or if there are just no data? To solve this, we must introduce a special value for the deleted voxels and also timestamp for the last chunk updates. ~~To avoid too many conficts, we should resolve them when loading the data.~~ Actually, we should not resolve those conflicts live as doing so will prevent us from implementing an undo feature. diff --git a/NOTES/annotation-chunk-source-and-sync.md b/NOTES/annotation-chunk-source-and-sync.md index bd56437b65..7ef3304aba 100644 --- a/NOTES/annotation-chunk-source-and-sync.md +++ b/NOTES/annotation-chunk-source-and-sync.md @@ -7,18 +7,19 @@ In Neuroglancer, rendering and data flow are built around chunked sources: - The two halves are paired via a small RPC layer. The frontend owner has a type id; the backend counterpart class registers itself under the same id. When the frontend initializes, it requests the backend to construct the counterpart, and they talk by sending messages with ids. For annotations, the system uses three closely-related chunk sources on the frontend side (with backend counterparts): + - AnnotationGeometryChunkSource: provides spatially indexed geometry of annotations to draw (slice-view geometry per chunk). - AnnotationSubsetGeometryChunkSource: a filtered geometry source tied to segmentation relationships; supplies geometry subsets keyed by segment id. - AnnotationMetadataChunkSource: per-annotation metadata keyed by annotation id (used to keep the value of AnnotationReference in sync). These objects are owned on the frontend and mirrored on the backend. They’re coordinated by MultiscaleAnnotationSource, which: + - Holds and wires the three sources together. - Keeps local references and local-update state for edits (add/update/delete). - Initializes its counterparts in the worker (passing nested shared-object references, like its metadata/filtered sources and the chunk manager id). The voxel_annotation dummy volume you added (VoxDummyChunkSource) uses the same pairing mechanism as the standard volume/annotation sources: the frontend owner sets a shared type id; the backend counterpart registers with the same id and implements download(), which fills chunk.data with a procedurally generated pattern. - ### Frontend↔Backend synchronization: the RPC pairing - On the owner side, classes are decorated with @registerSharedObjectOwner("…ID…"). When they call initializeCounterpart(rpc, options), the RPC sends a SharedObject.new(type=ID, options) message. @@ -26,16 +27,18 @@ The voxel_annotation dummy volume you added (VoxDummyChunkSource) uses the same - Both sides keep ref-counted object handles with a shared numeric id. You can nest references to other shared objects inside options (e.g., pass a MetadataChunkSource id to the backend inside the parent’s initialize payload). For annotation commit flow specifically, there are two named RPCs (strings exported from annotation/base): + - ANNOTATION_COMMIT_UPDATE_RPC_ID: frontend→backend to request an add/update/delete commit. - ANNOTATION_COMMIT_UPDATE_RESULT_RPC_ID: backend→frontend to return success/failure and the updated annotation (or null for deletion). - ### The annotation edit pipeline (buffering + commit system) The key design goal is to show edits immediately on the frontend (optimistic UI), while guaranteeing consistency as the authoritative backend accepts/rejects them. -1) Local overlay buffering on the frontend +1. Local overlay buffering on the frontend + - MultiscaleAnnotationSource maintains: + - references: Map from annotation id to AnnotationReference; each holds the current value and a changed signal for listeners. - localUpdates: Map from id → LocalUpdateUndoState. Tracks: - existingAnnotation: the server-committed annotation prior to local edits (if any). @@ -47,7 +50,8 @@ The key design goal is to show edits immediately on the frontend (optimistic UI) - applyLocalUpdate() moves geometry bytes out of any existing visible geometry chunks (deleteAnnotation from those chunks) and writes the edited geometry into the temporary overlay chunk (updateAnnotation). This ensures rendering immediately reflects the local edit. - It updates the AnnotationReference.value on the frontend and notifies listeners (notifyChanged), causing render invalidation and UI updates without waiting for the backend. -2) Sending the commit request +2. Sending the commit request + - If commit=true, applyLocalUpdate() either: - queues the new edit into pendingCommit if a commit is already in-flight for that annotation, or - calls sendCommitRequest(): @@ -58,14 +62,17 @@ The key design goal is to show edits immediately on the frontend (optimistic UI) - annotationId set + newAnnotation → update - annotationId set + newAnnotation null → delete -3) Backend receives commit +3. Backend receives commit + - The worker-side registerRPC(ANNOTATION_COMMIT_UPDATE_RPC_ID, …) handler looks up the AnnotationSource counterpart object from x.id and dispatches to obj.add/delete/update as appropriate. Those methods are expected to return a Promise with the outcome. - Once resolved, it invokes ANNOTATION_COMMIT_UPDATE_RESULT_RPC_ID to the frontend with { id, annotationId, newAnnotation | error }. Note there’s a FIXME in the backend handler: “Handle new chunks requested prior to update but not yet sent to frontend.” This is a hint that the backend does not yet buffer/resynchronize in-flight visible-chunk streams vs. the commit result; the frontend overlay is the primary buffering mechanism for edits. -4) Frontend applies commit result +4. Frontend applies commit result + - The frontend registerRPC(ANNOTATION_COMMIT_UPDATE_RESULT_RPC_ID, …) handler calls either handleSuccessfulUpdate or handleFailedUpdate. - On success (handleSuccessfulUpdate): + - Decrement the global commit counter and potentially clear the “Committing annotations” StatusMessage. - If the server returned a new id (common on add), re-key all local state: - Update AnnotationReference.id and references map entries. @@ -81,31 +88,31 @@ The key design goal is to show edits immediately on the frontend (optimistic UI) - Restore AnnotationReference.value to existingAnnotation (or null) and dispatch its changed signal. - Decrement the global commit counter. -5) Reverting overlay after a successful cycle +5. Reverting overlay after a successful cycle + - If there is no pending commit, revertLocalUpdate() is called to remove the overlay and restore the world to a “no local edits pending” state. Since existingAnnotation has already been updated to the committed version, the visible chunks + metadata now represent the committed data, and the temporary overlay can be dropped. -6) Metadata sync for live references +6. Metadata sync for live references + - MetadataChunkSource is used so that references.get(id) consumers stay synced: when a metadata chunk arrives for an id, AnnotationMetadataChunkSource.addChunk sets the associated AnnotationReference.value and dispatches changed. - notifyChanged() is also called whenever local overlay changes the value, so UI stays responsive. - ### How chunk streaming and visibility interact with edits - Geometry chunks are streamed independently of commits. The backend recomputes priorities for visible annotation chunks based on the view, and for each needed chunk requests it from the appropriate backend geometry source (spatially indexed or subset by segmentation). When bytes arrive, the frontend replaces or updates the corresponding chunk’s AnnotationGeometryData. - The frontend overlay logic in temporary ensures local edits appear immediately, regardless of when backend geometry chunks stream in. The overlay is kept separate from streamed chunks and is applied/removed deterministically during the commit flow. - A note in backend commit handling acknowledges a potential race: a chunk could be requested based on an outdated state. The overlay strategy on the frontend is what guarantees the user sees their edits; any mismatches are corrected as commits resolve and overlay is removed. - ### The buffering model in a nutshell - Frontend buffering: a dedicated temporary chunk stores serialized geometry for locally edited annotations. It is immediately read by the renderer to display edits. This buffer is the single source of truth for in-flight user edits. - Queuing and coalescing: if an edit for the same annotation happens while a commit is in-flight, the new payload is queued in pendingCommit. As soon as the in-flight commit returns, the queued payload is updated with the authoritative id (if needed) and is sent immediately. This effectively debounces rapid user edits into a linear sequence of commits without losing intermediate UI responsiveness. - Backend buffering: the backend does not do significant edit buffering; it executes add/update/delete and returns results. The FIXME suggests future work could better correlate pre-commit chunk requests with post-commit state, but the current design relies on the frontend overlay to mask such transitions. - ### Where to look in code (ready-made pointers) Frontend (src/annotation/frontend_source.ts): + - MultiscaleAnnotationSource - applyLocalUpdate() — creates/updates the local overlay, manages pending/active commit flags. - sendCommitRequest() — sends ANNOTATION_COMMIT_UPDATE_RPC_ID and marks commitInProgress. @@ -116,18 +123,18 @@ Frontend (src/annotation/frontend_source.ts): - AnnotationGeometryChunkSource, AnnotationSubsetGeometryChunkSource, AnnotationMetadataChunkSource — the three chunk sources used by the layer to render and to keep references synced. Backend (src/annotation/backend.ts): + - registerRPC(ANNOTATION_COMMIT_UPDATE_RPC_ID, …) — receives commit requests, routes them to add/update/delete, sends result via ANNOTATION_COMMIT_UPDATE_RESULT_RPC_ID. - AnnotationSpatiallyIndexedRenderLayerBackend.recomputeChunkPriorities() — visibility-driven chunk scheduling that requests geometry chunks. - ### Relation to your VoxDummyChunkSource Your voxel_annotation VoxDummyChunkSource mirrors the standard infrastructure used above: + - Frontend owner: VoxDummyChunkSource (src/voxel_annotation/frontend.ts) extends volume/frontend VolumeChunkSource and is annotated with @registerSharedObjectOwner(VOX_DUMMY_CHUNK_SOURCE_RPC_ID). - Backend counterpart: VoxDummyChunkSource (src/voxel_annotation/backend.ts) extends volume/backend VolumeChunkSource and is decorated with @registerSharedObject(VOX_DUMMY_CHUNK_SOURCE_RPC_ID). It implements download() to fill chunk.data with a checkerboard pattern. - The RPC pairing and chunk lifecycle are the same: frontend requests visible chunks, backend download() produces bytes, they’re transferred back and uploaded to GPU by the frontend’s format handler; rendering samples those textures in your custom render layer. - ### Practical implications for modifying or extending the commit/buffering logic - To change how many edits can be coalesced: adjust logic around pendingCommit and commitInProgress in applyLocalUpdate, handleSuccessfulUpdate, and sendCommitRequest. The current model serializes edits: one in-flight + at most one queued per annotation id. You could extend it to keep a small queue and squash updates. @@ -135,7 +142,6 @@ Your voxel_annotation VoxDummyChunkSource mirrors the standard infrastructure us - To ensure consistency with streaming chunks: if you need stronger guarantees that streamed chunks reflect post-commit state, you could implement a small backend-side buffer or generation tracking in the annotation geometry sources, then drop or re-request chunks when a commit completes. - To wire new properties into commit: extend AnnotationPropertySerializer and the serialize/deserialize paths used by updateAnnotation/deleteAnnotation/computeNumPickIds. - ### TL;DR flow - User edits → frontend immediately updates a “temporary” overlay chunk and updates the AnnotationReference value; UI responds instantly. diff --git a/NOTES/classExplanations/MultiscaleVolumeChunkSource.md b/NOTES/classExplanations/MultiscaleVolumeChunkSource.md index 941fff5321..2d855381f0 100644 --- a/NOTES/classExplanations/MultiscaleVolumeChunkSource.md +++ b/NOTES/classExplanations/MultiscaleVolumeChunkSource.md @@ -1,10 +1,13 @@ ### What MultiscaleVolumeChunkSource is and why it exists + MultiscaleVolumeChunkSource is the frontend abstraction for volumetric data in Neuroglancer that can be viewed at multiple resolutions and/or orientations. It doesn’t load or store voxels itself; instead it: + - Defines the set of per-scale, per-orientation chunk sources that the renderer can query. - Encodes the coordinate transforms needed to map each chunk space into the layer’s “multiscale” space. - Supplies metadata such as rank, data type, and volume type (image vs segmentation) to drive shader code paths and default compression decisions. Concretely, the type is defined in src/sliceview/volume/frontend.ts: + - MultiscaleVolumeChunkSource extends the generic MultiscaleSliceViewChunkSource with Source = VolumeChunkSource and Options = VolumeSourceOptions. You must implement: - rank: number — typically 3 for a 3D volume (or 4 if you include channels as a dimension in chunking). - dataType: DataType — e.g., UINT8, UINT16, FLOAT32, UINT32/UINT64 (segmentation). @@ -15,60 +18,70 @@ Concretely, the type is defined in src/sliceview/volume/frontend.ts: - lowerClipBound/upperClipBound (optional) — clip region in chunk voxel space. How the renderer uses it (high level): + - SliceView requests transformed sources via getVolumetricTransformedSources (src/sliceview/frontend.ts). That function: - 1) Calls your getSources with the view’s transforms and channel mapping. - 2) Computes, for each source, the transforms between chunk space, multiscale space, and the 2D view, plus an effective voxel size at that scale. - 3) Chooses which scale(s) to render given current zoom, pixel size, and RenderLayer settings. - 4) Enumerates visible chunks for those sources and asks the ChunkManager to fetch them. + 1. Calls your getSources with the view’s transforms and channel mapping. + 2. Computes, for each source, the transforms between chunk space, multiscale space, and the 2D view, plus an effective voxel size at that scale. + 3. Chooses which scale(s) to render given current zoom, pixel size, and RenderLayer settings. + 4. Enumerates visible chunks for those sources and asks the ChunkManager to fetch them. Where the actual voxel bytes come from: + - VolumeChunkSource (also in src/sliceview/volume/frontend.ts) is the frontend pair to your selected spec (VolumeChunkSpecification). It defines chunk layout/format and provides getValueAt for picking. - The frontend VolumeChunkSource depends on a backend chunk source implementation (in workers) to fill chunk data on demand. Without a backend, no data arrives; rendering either shows nothing or can still draw “proced“procedural” effects that don’t sample the chunk textures. ural” effects that don’t sample the chunk textures. Helpful related APIs: + - makeVolumeChunkSpecification in src/sliceview/volume/base.ts builds the spec (rank, bounds, chunk size, data type, etc.). - makeVolumeChunkSpecificationWithDefaultCompression can choose compressed segmentation blocks for segmentation data. - SliceViewVolumeRenderLayer in src/sliceview/volume/renderlayer.ts is the default renderer that consumes your MultiscaleVolumeChunkSource and handles WebGL setup, transforms, chunk iteration, and shader integration. - ### How to use MultiscaleVolumeChunkSource + Typical usage pattern when building a layer: -1) Construct a subclass instance and pass it to a SliceViewVolumeRenderLayer (or your own subclass of it), e.g.: - - const multiscale = new MyMultiscaleSource(chunkManager); - - const renderLayer = new SliceViewVolumeRenderLayer(multiscale, { ... }); -2) The layer uses your getSources to choose appropriate scales and request chunks through the ChunkManager. -3) A backend implementation for VolumeChunkSource provides the voxel bytes when requested. +1. Construct a subclass instance and pass it to a SliceViewVolumeRenderLayer (or your own subclass of it), e.g.: + +- const multiscale = new MyMultiscaleSource(chunkManager); +- const renderLayer = new SliceViewVolumeRenderLayer(multiscale, { ... }); + +2. The layer uses your getSources to choose appropriate scales and request chunks through the ChunkManager. +3. A backend implementation for VolumeChunkSource provides the voxel bytes when requested. ### How to extend it (implement your own) + To implement a custom multiscale volume: + - Extend MultiscaleVolumeChunkSource. - Define rank, dataType, and volumeType. - Implement getSources(options). For each scale/orientation you want to expose: - 1) Create a VolumeChunkSpecification via makeVolumeChunkSpecification (or the default-compression variant for segmentation). You must provide at least: - - rank - - chunkDataSize (Uint32Array length = rank) - - lowerVoxelBound (defaults to zeros if not given) - - upperVoxelBound (required) - - dataType - 2) Obtain a frontend VolumeChunkSource from the ChunkManager: - - const source = chunkManager.getChunkSource(VolumeChunkSource, { spec }) - 3) Provide chunkToMultiscaleTransform (Float32Array of size (rank+1)^2). This defines the voxel size/axis orientation and any downsampling between the chunk’s voxel grid and your multiscale space. - 4) Optionally specify lowerClipBound/upperClipBound to restrict rendering. - 5) Push a SliceViewSingleResolutionSource { chunkSource, chunkToMultiscaleTransform, ... } into the returned arrays. The outer array indexes orientations; the inner array indexes scales from fine to coarse (or vice-versa; the utility code reorders as needed, but keep a consistent order, typically coarse-to-fine or fine-to-coarse). The filterVisibleSources logic in src/sliceview/base.ts picks suitable scales given zoom. + 1. Create a VolumeChunkSpecification via makeVolumeChunkSpecification (or the default-compression variant for segmentation). You must provide at least: + - rank + - chunkDataSize (Uint32Array length = rank) + - lowerVoxelBound (defaults to zeros if not given) + - upperVoxelBound (required) + - dataType + 2. Obtain a frontend VolumeChunkSource from the ChunkManager: + - const source = chunkManager.getChunkSource(VolumeChunkSource, { spec }) + 3. Provide chunkToMultiscaleTransform (Float32Array of size (rank+1)^2). This defines the voxel size/axis orientation and any downsampling between the chunk’s voxel grid and your multiscale space. + 4. Optionally specify lowerClipBound/upperClipBound to restrict rendering. + 5. Push a SliceViewSingleResolutionSource { chunkSource, chunkToMultiscaleTransform, ... } into the returned arrays. The outer array indexes orientations; the inner array indexes scales from fine to coarse (or vice-versa; the utility code reorders as needed, but keep a consistent order, typically coarse-to-fine or fine-to-coarse). The filterVisibleSources logic in src/sliceview/base.ts picks suitable scales given zoom. Multiple scales example sketch: + - For a three-scale pyramid, you might set chunkToMultiscaleTransform with voxel sizes [1,1,1], [2,2,2], [4,4,4] (or encode that into the matrix). Each scale also can have different chunkDataSize to better match the level’s voxel size. Backends: -- For real data, implement a corresponding backend chunk source (worker) that understands your source’s spec key and returns bytes. Most datasources under src/datasource/* demonstrate this by subclassing GenericMultiscaleVolumeChunkSource or MultiscaleVolumeChunkSource and providing a backend counterpart. +- For real data, implement a corresponding backend chunk source (worker) that understands your source’s spec key and returns bytes. Most datasources under src/datasource/\* demonstrate this by subclassing GenericMultiscaleVolumeChunkSource or MultiscaleVolumeChunkSource and providing a backend counterpart. ### Review of your DummyMultiscaleVolumeChunkSource + File: src/voxel_annotation/volume_chunk_source.ts What it sets up: + - Extends MultiscaleVolumeChunkSource with: - dataType = DataType.UINT32 - volumeType = VolumeType.SEGMENTATION @@ -84,6 +97,7 @@ What it sets up: - returns [[single]] What this means in practice: + - Geometry and bounds: - Your multiscale space is a simple axis-aligned 1000x1000x1000 volume with voxel size implicitly equal to 1 in all axes (identity transform). Chunks are 64^3. - Data type and volume type: @@ -94,12 +108,14 @@ What this means in practice: - The frontend VolumeChunkSource expects the backend to provide chunk bytes. As written, there is no backend companion to actually fill data. Your VoxelAnnotationRenderLayer’s shader currently emits a procedural checkerboard using vChunkPosition and uChunkDataSize, which can render without sampling voxel textures — that’s why this can still “show something” even without real data. However, if you later want to read voxel values in the shader (e.g., segmentation ID), you’ll need a backend chunk provider. Correctness/consistency observations: + - Using makeVolumeChunkSpecification with minimal fields is valid; lowerVoxelBound defaults correctly. - The identity chunkToMultiscaleTransform is valid; it means multiscale coordinates and chunk voxel coordinates coincide. If your layer’s model/render transforms assume a different physical voxel size (e.g., anisotropic data), you should encode that scale into this matrix. - VolumeType.SEGMENTATION + UINT32 can optionally benefit from compressed segmentation block sizes, but that is set on the spec via makeVolumeChunkSpecificationWithDefaultCompression (and requires chunkToMultiscaleTransform and options.multiscaleToViewTransform). For a dummy source, skipping compression is fine. - The return shape [[single]] is correct: outer index is orientation (only one), inner is scale (only one). Suggestions to evolve DummyMultiscaleVolumeChunkSource: + - Multiple scales: Create a list of specs for different resolutions. For each coarser level: - Either encode a larger voxel size into chunkToMultiscaleTransform (e.g., 2x, 4x) and keep a similar chunkDataSize, or keep voxel size = 1 and adjust transforms so that coarser scales map appropriately into multiscale space. - Return [[level0, level1, level2]] ordered from fine to coarse (or vice-versa consistently). @@ -107,18 +123,20 @@ Suggestions to evolve DummyMultiscaleVolumeChunkSource: - Clip bounds: You can tighten lowerClipBound/upperClipBound (floats allowed) to define a visible subregion without changing retrieval bounds. - Backend stub: For development, add a backend VolumeChunkSource that fills chunks procedurally (e.g., write a pattern or ID = x+y+z) so you can test sampling in shaders and getValueAt. - ### Quick look at your VoxelAnnotationRenderLayer (to see integration) + File: src/voxel_annotation/renderlayer.ts + - Extends SliceViewVolumeRenderLayer and overrides defineShader to render a 2D checkerboard using vChunkPosition.xy and uChunkDataSize.xy, without sampling volume data. This is consistent with your dummy source and is why you can render even without actual chunk bytes. - initializeShader is a no-op (fine for now). The base class takes care of binding uniforms like uChunkDataSize, uLowerClipBound, uUpperClipBound, etc. If/when you want to use real voxel values in the shader, you’ll need to: + - Let defineChunkDataShaderAccess (already wired by the base class) provide sampling functions and texture bindings. - Ensure your backend supplies chunk data with the right format for the selected DataType. - ### Minimal template for a multiscale source you can extend + - class MyMultiscaleSource extends MultiscaleVolumeChunkSource { - dataType = DataType.UINT32; - volumeType = VolumeType.SEGMENTATION; @@ -136,7 +154,7 @@ If/when you want to use real voxel values in the shader, you’ll need to: upperVoxelBound, }); - const chunkSource = this.chunkManager.getChunkSource(VolumeChunkSource, { spec }); - - const xform = new Float32Array((rank+1)*(rank+1)); + - const xform = new Float32Array((rank+1)\*(rank+1)); // set identity and scale diagonal by s - for (let i=0;i where key = `${scaleKey}/${cx},${cy},${cz}` @@ -37,10 +42,12 @@ Add an in-worker map of chunk data keyed by scale and chunk-id, a dirty set, and - saver: debounced function to flush dirty chunks to persistent storage - Chunk keying and scale: + - MVP assumes a single user-selected scale (as per spec §3.1). We can encode that as scaleKey = `${spec.chunkDataSize[0]}_${spec.chunkDataSize[1]}_${spec.chunkDataSize[2]}` or a numeric “scaleId” supplied at init. - Chunk id format: the grid coords string `${cx},${cy},${cz}` (consistent with the frontend overlay key at src/voxel_annotation/frontend.ts line 141). If you prefer the spec example (ranges like `0-64_0-64_0-64`) we can derive that on persistence, but the grid format is simpler and consistent with running code. - download(chunk): + - Compute cx,cy,cz and use key = `${scaleKey}/${cx},${cy},${cz}`. - Look up voxels.get(key), or lazily allocate a zero-filled Uint32Array sized for the clipped chunkDataSize; store it in the map and return it as chunk.data. - This makes the worker the authoritative “warm” state backing the streamed chunks. @@ -52,18 +59,21 @@ Add an in-worker map of chunk data keyed by scale and chunk-id, a dirty set, and This mirrors the annotation commit pipeline where the frontend immediately shows edits while the backend is authoritative and persists results (see NOTES/annotation-chunk-source-and-sync.md, esp. the optimistic overlay + commit queue flow at lines 33–105). #### 2) Frontend→Worker edit flow (Tier 1→Tier 2) + Keep the current “optimistic overlay” in the frontend, but also send edits to worker as actions: - In src/voxel_annotation/frontend.ts VoxChunkSource: + - In paintVoxel(...), after overlay.applyEdit, send an RPC to the backend counterpart with the chunk key and localIndex+value. This is analogous to ANNOTATION_COMMIT_UPDATE_RPC_ID from the notes (lines 28–61) but tailored for voxel edits. - Batch calls: for brush tool, aggregate per-chunk edits client-side and send one RPC per dirty chunk. - RPC identifiers (in a new file src/voxel_annotation/base.ts): + - export const VOX_CHUNK_SOURCE_RPC_ID = 'voxChunkSource'; (already exists and used) - - export const VOX_EDIT_APPLY_RPC_ID = 'vox/edit/apply'; // frontend→worker - - export const VOX_SAVE_STATUS_RPC_ID = 'vox/save/status'; // worker→frontend (optional) - - export const VOX_MAP_INIT_RPC_ID = 'vox/map/init'; // frontend→worker - - export const VOX_MAP_META_RPC_ID = 'vox/map/meta'; // worker→frontend (optional) + - export const VOX_EDIT_APPLY_RPC_ID = 'vox/edit/apply'; // frontend→worker + - export const VOX_SAVE_STATUS_RPC_ID = 'vox/save/status'; // worker→frontend (optional) + - export const VOX_MAP_INIT_RPC_ID = 'vox/map/init'; // frontend→worker + - export const VOX_MAP_META_RPC_ID = 'vox/map/meta'; // worker→frontend (optional) - Semantics: - VOX_EDIT_APPLY_RPC_ID payload: { id: backendObjectId, key: string, edits: Array<[number /*localIndex*/, number /*value*/]> } @@ -72,9 +82,11 @@ Keep the current “optimistic overlay” in the frontend, but also send edits t This aligns with the annotation approach: immediate local overlay + a commit-like call to the worker (see notes lines 50–63). #### 3) Persistent storage (Tier 3) + localStorage is not available in Web Workers and is synchronous (bad for large data). Recommended options that work in workers: - IndexedDB (IDB) + - Available in dedicated workers. Good for large binary blobs. Transactional. - Store per-chunk ArrayBuffers and a small metadata store for maps. @@ -85,35 +97,42 @@ localStorage is not available in Web Workers and is synchronous (bad for large d Recommendation: IndexedDB is widely used and integrates well with existing code. OPFS is excellent for very large datasets and low-latency writes if you need it later. I’ll outline IDB now and note where OPFS would plug in similarly. IndexedDB schema (db name: 'neuroglancer_vox'): + - objectStore 'maps' (key: mapId: string) → { mapId, createdAt, dataType, chunkDataSize [3], upperVoxelBound [3], unit, scaleKey } - objectStore 'chunks' (key: `${mapId}:${scaleKey}:${cx},${cy},${cz}`) → ArrayBuffer (Uint32Array.buffer) + optional small header for clipping size. Saving strategy: + - Maintain dirty: Set of keys in worker. A debounced saver runs every e.g. 750 ms or when dirty size exceeds e.g. 32 chunks. - On flush: open a 'chunks' readwrite transaction and put each dirty chunk, then clear them from dirty. - Crash safety: each put is a separate record; IDB is durable. Optionally store a compact “dirtyIndex” record before and after flush for recovery. Loading strategy: + - On download() for a chunk: - If not present in voxels map, try IDB.get(key). If found, deserialize into a typed array and put into voxels map; otherwise allocate zero array. - Return typed array as chunk.data. Offline behavior: + - Because saving is local (IDB), edits persist without network. If you also have an HTTP backend, you can add a second “cloud sync” layer: write to IDB first, try to POST to server when navigator.onLine, retry later. --- ### Map Initialization Endpoint + We need a way to create a map with user-specified dimensions and scale. The repo currently sets these in the UI (src/layer/vox/index.ts lines 174–177 for scale/unit/bounds) and constructs a DummyMultiscaleVolumeChunkSource (lines 212–219) with chunkDataSize and upperVoxelBound. Add a programmatic initialize step between the frontend owner and the worker counterpart: - RPC VOX_MAP_INIT_RPC_ID (frontend→worker): + - Request: { id, mapId?: string, dataType: number, chunkDataSize: [x,y,z], upperVoxelBound: [x,y,z], unit: string, scaleKey?: string } - Behavior: if mapId missing, generate one (e.g., UUID). Store metadata in worker instance and persist to IDB 'maps'. Return { mapId, scaleKey }. - On subsequent restores, the UI can pass a known mapId to re-open the same dataset. - Wire from UI: + - In src/layer/vox/index.ts, VoxUserLayer.applyVoxSettings(...) (lines 194–203) currently rebuilds the layer; extend buildOrRebuildVoxLayer() (lines 205–253) to call a new method on VoxChunkSource owner to initialize the map in the worker. - Implementation path: - After creating DummyMultiscaleVolumeChunkSource, call getSources(), take base source, grab its chunkSource (our VoxChunkSource owner instance) and call source.initializeMap(...) which internally calls the RPC. @@ -128,18 +147,21 @@ This mirrors the “counterpart initialization” mechanism described in NOTES/a ### Concrete API and pseudo-code #### Constants (new) src/voxel_annotation/base.ts + ```ts -export const VOX_CHUNK_SOURCE_RPC_ID = 'voxChunkSource'; // already exists -export const VOX_MAP_INIT_RPC_ID = 'vox/map/init'; -export const VOX_EDIT_APPLY_RPC_ID = 'vox/edit/apply'; -export const VOX_SAVE_STATUS_RPC_ID = 'vox/save/status'; // optional progress events +export const VOX_CHUNK_SOURCE_RPC_ID = "voxChunkSource"; // already exists +export const VOX_MAP_INIT_RPC_ID = "vox/map/init"; +export const VOX_EDIT_APPLY_RPC_ID = "vox/edit/apply"; +export const VOX_SAVE_STATUS_RPC_ID = "vox/save/status"; // optional progress events ``` #### Frontend owner additions src/voxel_annotation/frontend.ts + - Add initializeMap() and sendEdits() methods. - Call sendEdits() from paintVoxel() (batch for brush). Pseudo-snippets around existing code: + ```ts @registerSharedObjectOwner(VOX_CHUNK_SOURCE_RPC_ID) export class VoxChunkSource extends BaseVolumeChunkSource { @@ -165,7 +187,10 @@ export class VoxChunkSource extends BaseVolumeChunkSource { private queueEdit(key: string, localIndex: number, value: number) { let a = this.pendingChunkEdits.get(key); - if (!a) { a = []; this.pendingChunkEdits.set(key, a); } + if (!a) { + a = []; + this.pendingChunkEdits.set(key, a); + } a.push([localIndex, value]); if (this.editFlushHandle === undefined) { this.editFlushHandle = self.setTimeout(() => this.flushEdits(), 16); @@ -179,9 +204,13 @@ export class VoxChunkSource extends BaseVolumeChunkSource { const rpc = (this as any).rpc!; for (const [key, edits] of entries) { try { - await rpc.invoke(VOX_EDIT_APPLY_RPC_ID, { id: (this as any).rpcId, key, edits }); + await rpc.invoke(VOX_EDIT_APPLY_RPC_ID, { + id: (this as any).rpcId, + key, + edits, + }); } catch (e) { - console.warn('Failed to apply voxel edits to worker', e); + console.warn("Failed to apply voxel edits to worker", e); } } } @@ -207,16 +236,18 @@ export class VoxChunkSource extends BaseVolumeChunkSource { ``` #### Backend counterpart additions src/voxel_annotation/backend.ts + - Maintain map state + IDB. - Register RPCs: init and apply edits. - Modify download() to source from voxels map/IDB instead of procedural. Pseudo-structure inside class VoxChunkSource: + ```ts @registerSharedObject(VOX_CHUNK_SOURCE_RPC_ID) export class VoxChunkSource extends BaseVolumeChunkSource { - private mapId: string = 'default'; - private scaleKey = ''; + private mapId: string = "default"; + private scaleKey = ""; private voxels = new Map(); private dirty = new Set(); private dbPromise: Promise | null = null; @@ -226,17 +257,38 @@ export class VoxChunkSource extends BaseVolumeChunkSource { super(rpc, options); this.scaleKey = `${this.spec.chunkDataSize[0]}_${this.spec.chunkDataSize[1]}_${this.spec.chunkDataSize[2]}`; // register RPCs - (this as any).rpc!.register(VOX_MAP_INIT_RPC_ID, ({ id, ...opts }: any) => this.handleInit(opts)); - (this as any).rpc!.register(VOX_EDIT_APPLY_RPC_ID, ({ id, key, edits }: any) => this.handleApplyEdits(key, edits)); + (this as any).rpc!.register(VOX_MAP_INIT_RPC_ID, ({ id, ...opts }: any) => + this.handleInit(opts), + ); + (this as any).rpc!.register( + VOX_EDIT_APPLY_RPC_ID, + ({ id, key, edits }: any) => this.handleApplyEdits(key, edits), + ); } - private async handleInit(opts: { mapId?: string; unit?: string; dataType?: number; chunkDataSize?: number[]; upperVoxelBound?: number[]; scaleKey?: string; }) { + private async handleInit(opts: { + mapId?: string; + unit?: string; + dataType?: number; + chunkDataSize?: number[]; + upperVoxelBound?: number[]; + scaleKey?: string; + }) { // adopt metadata if (opts.scaleKey) this.scaleKey = opts.scaleKey; - if (opts.mapId) this.mapId = opts.mapId; else this.mapId = crypto.randomUUID?.() ?? String(Date.now()); + if (opts.mapId) this.mapId = opts.mapId; + else this.mapId = crypto.randomUUID?.() ?? String(Date.now()); // Open IDB and persist metadata row const db = await this.getDb(); - await put(db, 'maps', { mapId: this.mapId, dataType: this.spec.dataType, chunkDataSize: Array.from(this.spec.chunkDataSize), upperVoxelBound: Array.from(this.spec.upperVoxelBound ?? []), unit: opts.unit ?? '', scaleKey: this.scaleKey, createdAt: Date.now() }); + await put(db, "maps", { + mapId: this.mapId, + dataType: this.spec.dataType, + chunkDataSize: Array.from(this.spec.chunkDataSize), + upperVoxelBound: Array.from(this.spec.upperVoxelBound ?? []), + unit: opts.unit ?? "", + scaleKey: this.scaleKey, + createdAt: Date.now(), + }); return { mapId: this.mapId, scaleKey: this.scaleKey }; } @@ -250,7 +302,7 @@ export class VoxChunkSource extends BaseVolumeChunkSource { } async download(chunk: VolumeChunk, signal: AbortSignal): Promise { - if (signal.aborted) throw signal.reason ?? new Error('aborted'); + if (signal.aborted) throw signal.reason ?? new Error("aborted"); const origin = this.computeChunkBounds(chunk); // existing helper const cds = chunk.chunkDataSize!; // clipped size const [cx, cy, cz] = [ @@ -263,20 +315,24 @@ export class VoxChunkSource extends BaseVolumeChunkSource { (chunk as any).data = arr; } - private async getOrLoadChunk(key: string, cdsMaybe?: Uint32Array): Promise { + private async getOrLoadChunk( + key: string, + cdsMaybe?: Uint32Array, + ): Promise { let arr = this.voxels.get(key); if (arr) return arr; // Try IDB const db = await this.getDb(); - const buf = await get(db, 'chunks', `${this.mapId}:${key}`); + const buf = await get(db, "chunks", `${this.mapId}:${key}`); if (buf instanceof ArrayBuffer) { arr = new Uint32Array(buf); this.voxels.set(key, arr); return arr; } // allocate zero - const cds = cdsMaybe ?? this.spec.chunkDataSize as Uint32Array; - let n = 1; for (let i = 0; i < 3; ++i) n *= cds[i]; + const cds = cdsMaybe ?? (this.spec.chunkDataSize as Uint32Array); + let n = 1; + for (let i = 0; i < 3; ++i) n *= cds[i]; arr = new Uint32Array(n); this.voxels.set(key, arr); return arr; @@ -284,16 +340,22 @@ export class VoxChunkSource extends BaseVolumeChunkSource { private scheduleSave() { if (this.saveTimer !== undefined) return; - this.saveTimer = setTimeout(() => this.flushSaves(), 750) as unknown as number; + this.saveTimer = setTimeout( + () => this.flushSaves(), + 750, + ) as unknown as number; } private async flushSaves() { const keys = Array.from(this.dirty); - if (keys.length === 0) { this.saveTimer = undefined; return; } + if (keys.length === 0) { + this.saveTimer = undefined; + return; + } this.dirty.clear(); const db = await this.getDb(); - const tx = db.transaction('chunks', 'readwrite'); - const store = tx.objectStore('chunks'); + const tx = db.transaction("chunks", "readwrite"); + const store = tx.objectStore("chunks"); for (const key of keys) { const arr = this.voxels.get(key); if (!arr) continue; @@ -312,6 +374,7 @@ You can swap the IDB bits for OPFS by writing to files under `/maps/${mapId}/${k --- ### UI/Layer wiring for initialization + - src/layer/vox/index.ts already exposes a VoxSettingsTab with controls for scale/unit/bounds. Add an “Initialize Map” button that triggers map init after settings are applied. - In buildOrRebuildVoxLayer() (lines 205–253), after creating DummyMultiscaleVolumeChunkSource and before adding the render layer, call something like: @@ -321,7 +384,7 @@ const base = sources2D[0][0]; const source = base.chunkSource as any; // VoxChunkSource (frontend owner) await source.initializeMap({ dataType: dummySource.dataType, - chunkDataSize: Array.from(dummySource['cfgChunkDataSize'] ?? [64,64,64]), + chunkDataSize: Array.from(dummySource["cfgChunkDataSize"] ?? [64, 64, 64]), upperVoxelBound: Array.from(this.voxUpperBound), unit: this.voxScaleUnit, }); @@ -332,6 +395,7 @@ This ensures the worker knows the map identity and has persisted metadata before --- ### How this mirrors the annotation system + - Optimistic UI and buffering: frontend overlay mirrors “temporary chunk” approach from NOTES/annotation-chunk-source-and-sync.md lines 37–49, 84–96, 99–105. - Commit requests: VOX_EDIT_APPLY_RPC_ID plays the role of ANNOTATION_COMMIT_UPDATE_RPC_ID (lines 28–61). We intentionally keep this simple (no per-id coalescing) because voxel edits are applied per chunk; batching per chunk provides similar debouncing semantics (spec §3 Tier 3 debounced writes). - Backend counterpart object: Registered with the same shared id (VOX_CHUNK_SOURCE_RPC_ID) and receives RPCs for edits and map init (notes lines 22–31). @@ -339,6 +403,7 @@ This ensures the worker knows the map identity and has persisted metadata before --- ### Offline persistence study + - localStorage: Not available in Web Workers (and synchronous, low capacity). Not recommended. - IndexedDB: Available in workers, supports large binary data and transactions. Good default. Write amplification is acceptable if batching edits. - Cache Storage API: Good for HTTP response caching, less suited to mutable structured data per chunk. @@ -349,6 +414,7 @@ Recommendation: Start with IndexedDB for MVP; keep the persistence layer abstrac --- ### Edge cases and details + - Chunk clipping: Neuroglancer chunks near the upper bound may be smaller. Persist the full logical chunk size and optionally store clipped size per record if needed; or keep the array sized to actual chunkDataSize and let the geometry handle clipping. - DataType: MVP DataType.UINT32 (as already configured in dummy multiscale). Keep type in map metadata; if supporting multiple types later, convert appropriately on load/save. - Multi-user future: Store a per-map generation and per-chunk version; define conflict policy (e.g., last-writer-wins or CRDT). For now, single-user writes. @@ -361,23 +427,31 @@ Recommendation: Start with IndexedDB for MVP; keep the persistence layer abstrac --- ### Step-by-step implementation checklist -1) Add new RPC ids in src/voxel_annotation/base.ts. -2) Frontend owner (src/voxel_annotation/frontend.ts): - - Add initializeMap() invoking VOX_MAP_INIT_RPC_ID. - - Add batching queueEdit/flushEdits; call from paintVoxel(). -3) Backend counterpart (src/voxel_annotation/backend.ts): - - Add fields voxels Map, dirty Set, mapId, scaleKey. - - Register VOX_MAP_INIT_RPC_ID and VOX_EDIT_APPLY_RPC_ID handlers. - - Replace procedural download() body to use getOrLoadChunk() and return stored Uint32Array. - - Implement debounced flush to IDB (and IDB helpers). -4) UI wiring (src/layer/vox/index.ts): - - After creating DummyMultiscaleVolumeChunkSource and before adding render layer, call initializeMap() with the UI settings. - - Optionally add an explicit “Initialize Map” button in VoxSettingsTab to force re-init/reset. -5) Optional: Add a small status notifier (VOX_SAVE_STATUS_RPC_ID) for “Saving…” progress. + +1. Add new RPC ids in src/voxel_annotation/base.ts. +2. Frontend owner (src/voxel_annotation/frontend.ts): + +- Add initializeMap() invoking VOX_MAP_INIT_RPC_ID. +- Add batching queueEdit/flushEdits; call from paintVoxel(). + +3. Backend counterpart (src/voxel_annotation/backend.ts): + +- Add fields voxels Map, dirty Set, mapId, scaleKey. +- Register VOX_MAP_INIT_RPC_ID and VOX_EDIT_APPLY_RPC_ID handlers. +- Replace procedural download() body to use getOrLoadChunk() and return stored Uint32Array. +- Implement debounced flush to IDB (and IDB helpers). + +4. UI wiring (src/layer/vox/index.ts): + +- After creating DummyMultiscaleVolumeChunkSource and before adding render layer, call initializeMap() with the UI settings. +- Optionally add an explicit “Initialize Map” button in VoxSettingsTab to force re-init/reset. + +5. Optional: Add a small status notifier (VOX_SAVE_STATUS_RPC_ID) for “Saving…” progress. --- ### Minimal changes by file (where to edit) + - src/voxel_annotation/base.ts: define VOX_MAP_INIT_RPC_ID, VOX_EDIT_APPLY_RPC_ID, VOX_SAVE_STATUS_RPC_ID. - src/voxel_annotation/frontend.ts: - Add initializeMap() method to VoxChunkSource owner. @@ -391,6 +465,7 @@ Recommendation: Start with IndexedDB for MVP; keep the persistence layer abstrac --- ### Summary + - Keep the existing optimistic frontend overlay (fast UI). - Make the worker the authoritative source of voxel data and persist it with a debounced saver. - Add an initialization RPC to create/open a map with user-provided dimensions and scale. diff --git a/NOTES/image-vs-segmentation-volumeType.md b/NOTES/image-vs-segmentation-volumeType.md index 0505af2d84..296a755ea7 100644 --- a/NOTES/image-vs-segmentation-volumeType.md +++ b/NOTES/image-vs-segmentation-volumeType.md @@ -1,8 +1,10 @@ ### High-level difference + - IMAGE: Continuous-valued voxels (intensities). Intended for interpolation, contrast/brightness adjustments, and colormap visualization. - SEGMENTATION: Discrete/categorical labels (segment IDs). Must not be interpolated; visualized by mapping IDs to colors, supporting selection/highlighting of segments. ### Semantics and typical data types + - IMAGE - Common types: UINT8, UINT16, FLOAT32 (sometimes INT16, etc.). - Often multi-channel (RGB, multi-stain, etc.). @@ -11,6 +13,7 @@ - Values represent object IDs; exact integrity of values matters. ### Sampling and interpolation + - IMAGE - Linear interpolation for smooth zooming and slicing. - Pyramids/scales typically produced via averaging or linear filters. @@ -19,6 +22,7 @@ - Pyramids/scales should be built with label-aware reducers (e.g., majority vote), not averaging. ### Rendering and shader behavior + - IMAGE - Intensity pipelines: window/level, colormaps, per-channel blending, histograms. - Smooth transitions; edges may be anti-aliased by interpolation. @@ -27,6 +31,7 @@ - UI and shaders support features like selected/visible segments, recoloring, and highlighting. ### Compression and storage defaults + - IMAGE - Uses standard chunk formats; compression (if any) is typically external/transport-level. - SEGMENTATION @@ -34,45 +39,55 @@ - In Neuroglancer’s code, makeVolumeChunkSpecificationWithDefaultCompression enables compressedSegmentationBlockSize when volumeType is SEGMENTATION (or discreteValues is true) and other criteria are met. ### Picking and interaction + - IMAGE - Picking returns intensities (possibly per-channel). Useful for measurements/QA. - SEGMENTATION - Picking returns a segment ID. The UI typically supports selecting, showing/hiding segments, equivalence mapping, and integration with meshes/skeletons for that ID. ### Histograms and UI controls + - IMAGE - Histogram-based contrast controls, colormap selection, per-channel adjustments. - SEGMENTATION - No meaningful intensity histogram. UI focuses on segment sets, visibility, and highlighting. ### Multiscale generation expectations + - IMAGE: Averaging/linear filtering for downsampling. - SEGMENTATION: Mode/majority voting or other label-preserving downsampling. ### Channel semantics + - IMAGE: Multi-channel common; RGB or arbitrary channel mixing. - SEGMENTATION: Typically single-channel ID. Multiple channels would imply multiple label volumes and need custom handling. ### Choosing between IMAGE and SEGMENTATION + Pick SEGMENTATION if: + - Voxels encode labels/IDs that must be exact (no interpolation). - You need segment selection/highlighting and ID-centric tooling. - You want segmentation block compression benefits. Pick IMAGE if: + - Voxels are continuous intensities. - You want linear interpolation, window/level, and colormaps. - You handle multi-channel blending or RGB imagery. ### Practical impact in this codebase + - VolumeType is defined in src/sliceview/volume/base.ts and used by multiscale and render paths to pick defaults. - Compression choice: shouldTranscodeToCompressedSegmentation and makeVolumeChunkSpecificationWithDefaultCompression check VolumeType and DataType to set compressedSegmentationBlockSize for segmentation. - Render paths for sampling, decoding, and shader helpers differ for segmentation vs image (e.g., nearest sampling and optional decompression for segmentation). ### Notes for your DummyMultiscaleVolumeChunkSource + - You set volumeType = SEGMENTATION and dataType = UINT32, which is appropriate for label volumes. - Your shader currently draws a procedural checkerboard and doesn’t sample voxel data; it won’t yet exercise segmentation decoding or nearest sampling. If you later sample voxel values to color by ID or enable segment picking, the SEGMENTATION setting will align with the right defaults and UI behavior. ### Summary + - IMAGE = continuous intensities, linear interpolation, histogram/colormap UI, typical UINT8/16/F32, averaged pyramids. - SEGMENTATION = discrete labels/IDs, nearest sampling, segment-centric UI, typical UINT32/64, label-preserving pyramids, compressed segmentation support. diff --git a/NOTES/voxel-annotation-specification.md b/NOTES/voxel-annotation-specification.md index 305b594736..8ba2379fb3 100644 --- a/NOTES/voxel-annotation-specification.md +++ b/NOTES/voxel-annotation-specification.md @@ -8,10 +8,10 @@ The objective of the voxel annotation feature is to allow precise, voxel-aligned The user will be provided with a suite of drawing tools for efficient annotation. -* **Brush**: A circular brush with adjustable size. -* **Flood Fill (2D/3D)**: A tool to fill contiguous areas of the same underlying data value or annotation label. -* **Eraser**: A circular eraser with adjustable size. -* **MVP Tool**: A single-voxel "Pixel" tool to validate the core architecture. +- **Brush**: A circular brush with adjustable size. +- **Flood Fill (2D/3D)**: A tool to fill contiguous areas of the same underlying data value or annotation label. +- **Eraser**: A circular eraser with adjustable size. +- **MVP Tool**: A single-voxel "Pixel" tool to validate the core architecture. ## 3. Data Storage and State Management @@ -32,37 +32,38 @@ To ensure a responsive user experience while maintaining data integrity, we will #### Tier 1: Frontend State (The "Hot" Cache) -* **Location**: Frontend (UI thread). -* **Purpose**: Provide immediate visual feedback to the user. -* **Mechanism**: When a user draws, the edit is applied to an immediate, in-memory representation and rendered instantly. Simultaneously, an "action" describing the edit is dispatched to the web worker. +- **Location**: Frontend (UI thread). +- **Purpose**: Provide immediate visual feedback to the user. +- **Mechanism**: When a user draws, the edit is applied to an immediate, in-memory representation and rendered instantly. Simultaneously, an "action" describing the edit is dispatched to the web worker. #### Tier 2: Worker State (The "Warm" Source of Truth) -* **Location**: Web Worker. -* **Purpose**: To act as the authoritative, canonical state of the annotations. -* **Mechanism**: The worker maintains a map of all annotation chunks (`Map`). It listens for actions from the frontend, applies them to the corresponding chunks, and marks those chunks as "dirty." +- **Location**: Web Worker. +- **Purpose**: To act as the authoritative, canonical state of the annotations. +- **Mechanism**: The worker maintains a map of all annotation chunks (`Map`). It listens for actions from the frontend, applies them to the corresponding chunks, and marks those chunks as "dirty." #### Tier 3: Persistent Storage (The "Cold" Layer) -* **Location**: The data source (e.g., `local://voxel-annotations`). -* **Purpose**: Long-term, durable storage. -* **Mechanism**: The worker uses a throttled or debounced function to periodically write all "dirty" chunks from its state (Tier 2) to the persistent data source. This ensures that frequent edits do not overload the storage backend and that the UI never waits for a save operation. +- **Location**: The data source (e.g., `local://voxel-annotations`). +- **Purpose**: Long-term, durable storage. +- **Mechanism**: The worker uses a throttled or debounced function to periodically write all "dirty" chunks from its state (Tier 2) to the persistent data source. This ensures that frequent edits do not overload the storage backend and that the UI never waits for a save operation. #### Tier 4: Multi-users The arch should have all the necessary components to support multi-user annotation, such a feature could be implemented in the future. This multi-user feature would be similar to the one found in Google Docs. ### 3.1. MVP In-Memory Data Structure + For the MVP, we will simplify the problem by restricting annotations to a single, user-selectable scale. This provides a clear structure for organizing the data within the worker's memory. -* Annotation Scale Selection: The VoxUserLayer UI will include a dropdown or similar control that allows the user to select which scale (resolution) from a reference image layer they wish to annotate on. All subsequent drawing actions will apply to this single, chosen scale. -* In-Worker Data Structure: The worker will namespace the chunks in its internal Map using a key that combines the scale and chunk identifiers. This prevents collisions if the user switches between annotating different scales. - * Map Key Format: / - * Example Key: "4_4_40/0-64_0-64_0-64" -* In-Memory Chunk Format: - * Each chunk will be stored in the worker's map as a Uint32Array. - * The total length of the array will be chunkSizeX * chunkSizeY * chunkSizeZ (e.g., 64x64x64 = 262,144 elements). - * The value 0 represents an un-annotated voxel. Values 1..n correspond to different user-defined labels. +- Annotation Scale Selection: The VoxUserLayer UI will include a dropdown or similar control that allows the user to select which scale (resolution) from a reference image layer they wish to annotate on. All subsequent drawing actions will apply to this single, chosen scale. +- In-Worker Data Structure: The worker will namespace the chunks in its internal Map using a key that combines the scale and chunk identifiers. This prevents collisions if the user switches between annotating different scales. + - Map Key Format: / + - Example Key: "4_4_40/0-64_0-64_0-64" +- In-Memory Chunk Format: + - Each chunk will be stored in the worker's map as a Uint32Array. + - The total length of the array will be chunkSizeX _ chunkSizeY _ chunkSizeZ (e.g., 64x64x64 = 262,144 elements). + - The value 0 represents an un-annotated voxel. Values 1..n correspond to different user-defined labels. ## 4. LOD, Scaling, and Performance @@ -74,10 +75,10 @@ Render annotations only at their native resolution. The annotation layer will be ### Phase 2: On-the-Fly Worker Downsampling -* The `VoxChunkSource` will be responsible for generating lower-resolution chunks. -* When the renderer requests a chunk at a lower LOD (e.g., LOD 1), the `VoxChunkSource` will request the corresponding 8 chunks at the higher resolution (LOD 0) from the Worker State. -* It will then compute a downsampled chunk on-the-fly (e.g., using a majority vote for the label in each 2x2x2 region). -* **Caching**: Generated low-LOD chunks will be cached in the worker to avoid re-computation. This cache is invalidated when any of the underlying high-resolution data changes. +- The `VoxChunkSource` will be responsible for generating lower-resolution chunks. +- When the renderer requests a chunk at a lower LOD (e.g., LOD 1), the `VoxChunkSource` will request the corresponding 8 chunks at the higher resolution (LOD 0) from the Worker State. +- It will then compute a downsampled chunk on-the-fly (e.g., using a majority vote for the label in each 2x2x2 region). +- **Caching**: Generated low-LOD chunks will be cached in the worker to avoid re-computation. This cache is invalidated when any of the underlying high-resolution data changes. ### Phase 3 (Future): Sparse Voxel Structures diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 2fe7882574..ff483ccb2a 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -21,22 +21,32 @@ import type { CoordinateTransformSpecification } from "#src/coordinate_transform import { makeCoordinateSpace, makeIdentityTransform, - WatchableCoordinateSpaceTransform + WatchableCoordinateSpaceTransform, } from "#src/coordinate_transform.js"; import type { DataSourceSpecification } from "#src/datasource/index.js"; -import { LocalDataSource, localVoxelAnnotationsUrl } from "#src/datasource/local.js"; +import { + LocalDataSource, + localVoxelAnnotationsUrl, +} from "#src/datasource/local.js"; import { type ManagedUserLayer, type MouseSelectionState, registerLayerType, registerLayerTypeDetector, - UserLayer + UserLayer, } from "#src/layer/index.js"; import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; import { getWatchableRenderLayerTransform } from "#src/render_coordinate_transform.js"; -import { RenderScaleHistogram, trackableRenderScaleTarget } from "#src/render_scale_statistics.js"; +import { + RenderScaleHistogram, + trackableRenderScaleTarget, +} from "#src/render_scale_statistics.js"; import { SegmentColorHash } from "#src/segment_color.js"; -import { registerVoxelAnnotationTools, VoxelBrushLegacyTool, VoxelPixelLegacyTool } from "#src/ui/voxel_annotations.js"; +import { + registerVoxelAnnotationTools, + VoxelBrushLegacyTool, + VoxelPixelLegacyTool, +} from "#src/ui/voxel_annotations.js"; import type { Borrowed } from "#src/util/disposable.js"; import { mat4 } from "#src/util/geom.js"; import { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; @@ -80,8 +90,14 @@ class VoxSettingsTab extends Tab { const c = this.layer.voxCornerB; // Unit helpers - const unitFactor: Record = { m: 1, mm: 1e-3, "µm": 1e-6, nm: 1e-9 }; - const currentUnit = this.layer.voxScaleUnit in unitFactor ? this.layer.voxScaleUnit : "m"; + const unitFactor: Record = { + m: 1, + mm: 1e-3, + µm: 1e-6, + nm: 1e-9, + }; + const currentUnit = + this.layer.voxScaleUnit in unitFactor ? this.layer.voxScaleUnit : "m"; const factor = (u: string) => unitFactor[u] ?? 1; // Prepare UI elements @@ -129,7 +145,8 @@ class VoxSettingsTab extends Tab { const apply = document.createElement("button"); apply.textContent = "Regen source"; - apply.title = "Regenerate the source volume with the new settings, warning old local source will be deleted"; + apply.title = + "Regenerate the source volume with the new settings, warning old local source will be deleted"; apply.addEventListener("click", () => { const u = unitSel.value || currentUnit; const f = factor(u); @@ -159,7 +176,9 @@ class VoxSettingsTab extends Tab { } class VoxToolTab extends Tab { - public requestRenderLabels() { this.renderLabels(); } + public requestRenderLabels() { + this.renderLabels(); + } private labelsContainer!: HTMLDivElement; private renderLabels() { const cont = this.labelsContainer; @@ -241,7 +260,6 @@ class VoxToolTab extends Tab { toolsRow.appendChild(toolsWrap); toolbox.appendChild(toolsRow); - // Section: Brush settings const brushRow = document.createElement("div"); brushRow.className = "neuroglancer-vox-row"; @@ -307,9 +325,9 @@ class VoxToolTab extends Tab { optSphere.textContent = "sphere"; shapeSel.appendChild(optDisk); shapeSel.appendChild(optSphere); - shapeSel.value = (this.layer.voxBrushShape === 'sphere') ? 'sphere' : 'disk'; + shapeSel.value = this.layer.voxBrushShape === "sphere" ? "sphere" : "disk"; shapeSel.addEventListener("change", () => { - const v = shapeSel.value === 'sphere' ? 'sphere' : 'disk'; + const v = shapeSel.value === "sphere" ? "sphere" : "disk"; this.layer.voxBrushShape = v; shapeSel.value = v; }); @@ -393,7 +411,6 @@ class VoxToolTab extends Tab { } } - export class VoxUserLayer extends UserLayer { onLabelsChanged?: () => void; private voxMapId: string | undefined; @@ -415,7 +432,9 @@ export class VoxUserLayer extends UserLayer { voxScaleUnit: string = "nm"; // Region selection via corners voxCornerA: Float32Array = new Float32Array([0, 0, 0]); - voxCornerB: Float32Array = new Float32Array([1_000_000, 1_000_000, 1_000_000]); + voxCornerB: Float32Array = new Float32Array([ + 1_000_000, 1_000_000, 1_000_000, + ]); // Draw tool state voxBrushRadius: number = 3; voxEraseMode: boolean = false; @@ -426,14 +445,14 @@ export class VoxUserLayer extends UserLayer { private genId(): number { // Generate a unique uint32 per layer session. Try crypto.getRandomValues; fallback to Math.random. let id = 0; - const used = new Set(this.voxLabels.map(l => l.id)); + const used = new Set(this.voxLabels.map((l) => l.id)); for (let attempts = 0; attempts < 10_000; attempts++) { - if (typeof crypto !== 'undefined' && (crypto as any).getRandomValues) { + if (typeof crypto !== "undefined" && (crypto as any).getRandomValues) { const a = new Uint32Array(1); (crypto as any).getRandomValues(a); id = a[0] >>> 0; } else { - id = (Math.floor(Math.random() * 0xffffffff) >>> 0); + id = Math.floor(Math.random() * 0xffffffff) >>> 0; } if (id !== 0 && !used.has(id)) return id; } @@ -451,7 +470,7 @@ export class VoxUserLayer extends UserLayer { // --- Labels persistence (via VoxSource) --- private async saveLabels() { try { - const ids = this.voxLabels.map(l => l.id >>> 0); + const ids = this.voxLabels.map((l) => l.id >>> 0); await this.voxEditController?.setLabelIds(ids); } catch { // ignore persistence failures @@ -460,15 +479,15 @@ export class VoxUserLayer extends UserLayer { private async loadLabels() { try { const arr = await this.voxEditController?.getLabelIds(); - const existing = new Set(this.voxLabels.map(l => l.id >>> 0)); + const existing = new Set(this.voxLabels.map((l) => l.id >>> 0)); if (arr && Array.isArray(arr) && arr.length > 0) { // Merge previously created labels (before init) with stored ones. - const mergedIds = new Set(arr.map(id => id >>> 0)); + const mergedIds = new Set(arr.map((id) => id >>> 0)); for (const id of existing) mergedIds.add(id); - this.voxLabels = Array.from(mergedIds).map(id => ({ id })); + this.voxLabels = Array.from(mergedIds).map((id) => ({ id })); // Ensure selected label is valid const sel = this.voxSelectedLabelId; - if (!sel || !this.voxLabels.some(l => l.id === sel)) { + if (!sel || !this.voxLabels.some((l) => l.id === sel)) { this.voxSelectedLabelId = this.voxLabels[0].id; } // Write back merged set to keep in sync. @@ -483,7 +502,11 @@ export class VoxUserLayer extends UserLayer { if (this.voxLabels.length === 0) this.ensureDefaultLabel(); } finally { // Ensure UI reflects the loaded/merged labels. - try { this.onLabelsChanged?.(); } catch { /* ignore */ } + try { + this.onLabelsChanged?.(); + } catch { + /* ignore */ + } } } @@ -497,20 +520,30 @@ export class VoxUserLayer extends UserLayer { this.voxSelectedLabelId = id; // Persist immediately once the source/controller is available. if (this.voxEditController) { - try { await this.saveLabels(); } catch { /* ignore */ } + try { + await this.saveLabels(); + } catch { + /* ignore */ + } } // Notify UI to re-render labels list whenever a label is created. - try { this.onLabelsChanged?.(); } catch { /* ignore */ } + try { + this.onLabelsChanged?.(); + } catch { + /* ignore */ + } } selectVoxLabel(id: number) { - const found = this.voxLabels.find(l => l.id === id); + const found = this.voxLabels.find((l) => l.id === id); if (found) this.voxSelectedLabelId = id; } getCurrentLabelValue(): number { if (this.voxEraseMode) return 0; if (!this.voxSelectedLabelId) this.ensureDefaultLabel(); - const cur = this.voxLabels.find(l => l.id === this.voxSelectedLabelId) || this.voxLabels[0]; - return cur ? (cur.id >>> 0) : 0; + const cur = + this.voxLabels.find((l) => l.id === this.voxSelectedLabelId) || + this.voxLabels[0]; + return cur ? cur.id >>> 0 : 0; } constructor(managedLayer: Borrowed) { @@ -555,8 +588,14 @@ export class VoxUserLayer extends UserLayer { } // Update stored corners and derived upper bound for (let i = 0; i < 3; ++i) { - if (this.voxCornerA[i] !== cornerA[i]) { this.voxCornerA[i] = cornerA[i]; changed = true; } - if (this.voxCornerB[i] !== cornerB[i]) { this.voxCornerB[i] = cornerB[i]; changed = true; } + if (this.voxCornerA[i] !== cornerA[i]) { + this.voxCornerA[i] = cornerA[i]; + changed = true; + } + if (this.voxCornerB[i] !== cornerB[i]) { + this.voxCornerB[i] = cornerB[i]; + changed = true; + } } if (this.voxScaleUnit !== unit) { this.voxScaleUnit = unit; @@ -623,17 +662,23 @@ export class VoxUserLayer extends UserLayer { * vectors along those axes through the renderLayer->voxel transform. * TODO: this is not working, ai are dogshit at 3d stuffs. */ - getBrushPlaneBasis(mouseState?: MouseSelectionState): { u: Float32Array; v: Float32Array } | undefined { + getBrushPlaneBasis( + mouseState?: MouseSelectionState, + ): { u: Float32Array; v: Float32Array } | undefined { try { const inv = this.getModelToVoxTransform(); if (!inv) return undefined; const di = mouseState?.displayDimensions?.displayDimensionIndices; const rank = mouseState?.displayDimensions?.displayRank ?? 0; - const i0 = (di && rank >= 2) ? di[0] : 0; - const i1 = (di && rank >= 2) ? di[1] : 1; + const i0 = di && rank >= 2 ? di[0] : 0; + const i1 = di && rank >= 2 ? di[1] : 1; // Build origin and unit vectors in model/render-layer coordinate space aligned to displayed axes. - const p0 = vec3.transformMat4(vec3.create(), vec3.fromValues(0, 0, 0), inv); + const p0 = vec3.transformMat4( + vec3.create(), + vec3.fromValues(0, 0, 0), + inv, + ); const uModel = [0, 0, 0] as number[]; const vModel = [0, 0, 0] as number[]; if (i0 >= 0 && i0 < 3) uModel[i0] = 1; @@ -659,7 +704,8 @@ export class VoxUserLayer extends UserLayer { const ul = Math.hypot(ux, uy, uz); const vl = Math.hypot(vx, vy, vz); - if (!Number.isFinite(ul) || ul === 0 || !Number.isFinite(vl) || vl === 0) return undefined; + if (!Number.isFinite(ul) || ul === 0 || !Number.isFinite(vl) || vl === 0) + return undefined; const u = new Float32Array([ux / ul, uy / ul, uz / ul]); const v = new Float32Array([vx / vl, vy / vl, vz / vl]); @@ -700,14 +746,16 @@ export class VoxUserLayer extends UserLayer { // Initialize worker-side map persistence for this source (best-effort, fire-and-forget). const sources2D = voxSource.getSources({} as any); const base = sources2D[0][0]; - const source = (base.chunkSource as any); + const source = base.chunkSource as any; // Compute deterministic identifiers on the frontend to avoid relying on an RPC return value. - const cfgCds = new Uint32Array(Array.from(((voxSource as any)['cfgChunkDataSize']) ?? [64, 64, 64])); + const cfgCds = new Uint32Array( + Array.from((voxSource as any)["cfgChunkDataSize"] ?? [64, 64, 64]), + ); const lowerArr: Float32Array = lower; const upperArr: Float32Array = upper; const scaleKey = toScaleKey(cfgCds, lowerArr, upperArr); // mapId can be any stable string; default to 'local' unless already set. - if (!this.voxMapId) this.voxMapId = 'local'; + if (!this.voxMapId) this.voxMapId = "local"; // Initialize backend map first, then load labels from the chosen datasource. source.initializeMap({ mapId: this.voxMapId, diff --git a/src/layer/vox/style.css b/src/layer/vox/style.css index 6b18cc870a..8c6c497b19 100644 --- a/src/layer/vox/style.css +++ b/src/layer/vox/style.css @@ -44,7 +44,7 @@ .neuroglancer-vox-settings-tab select { box-sizing: border-box; flex: 1 1 8.5em; /* prefer ~8.5em but allow shrink/grow */ - min-width: 0; /* critical to allow shrinking within flex rows */ + min-width: 0; /* critical to allow shrinking within flex rows */ width: auto; max-width: 100%; padding: 6px 8px; @@ -73,12 +73,16 @@ padding: 8px 12px; border-radius: 6px; border: 1px solid color-mix(in oklab, var(--ng-accent) 55%, var(--ng-border)); - background: linear-gradient(180deg, + background: linear-gradient( + 180deg, color-mix(in oklab, var(--ng-accent) 88%, #2a2a2a) 0%, - color-mix(in oklab, var(--ng-accent) 70%, #1f1f1f) 100%); + color-mix(in oklab, var(--ng-accent) 70%, #1f1f1f) 100% + ); color: #fff; cursor: pointer; - transition: filter 120ms ease, transform 60ms ease; + transition: + filter 120ms ease, + transform 60ms ease; } .neuroglancer-vox-settings-tab button:hover, @@ -103,7 +107,11 @@ appearance: none; width: 100%; height: 6px; - background: linear-gradient(90deg, var(--ng-accent), color-mix(in oklab, var(--ng-accent) 35%, #333)); + background: linear-gradient( + 90deg, + var(--ng-accent), + color-mix(in oklab, var(--ng-accent) 35%, #333) + ); border-radius: 999px; outline: none; } diff --git a/src/sliceview/frontend.ts b/src/sliceview/frontend.ts index 2fc5ed678c..dad55f6d5a 100644 --- a/src/sliceview/frontend.ts +++ b/src/sliceview/frontend.ts @@ -434,9 +434,13 @@ export class SliceView extends Base { lastSeenGeneration: curUpdateGeneration, displayDimensionRenderInfo, }; - if ((renderLayer as any).constructor?.type === 'vox') { - console.log('[SliceView.updateVisibleLayersNow] new vox layerInfo created, allSources orientations=', layerInfo.allSources.length, - 'first orientation scales=', layerInfo.allSources[0]?.length ?? 0); + if ((renderLayer as any).constructor?.type === "vox") { + console.log( + "[SliceView.updateVisibleLayersNow] new vox layerInfo created, allSources orientations=", + layerInfo.allSources.length, + "first orientation scales=", + layerInfo.allSources[0]?.length ?? 0, + ); } disposers.push(renderLayer.messages.addChild(layerInfo.messages)); visibleLayers.set(renderLayer.addRef(), layerInfo); @@ -455,9 +459,13 @@ export class SliceView extends Base { renderLayer, layerInfo.messages, ); - if ((renderLayer as any).constructor?.type === 'vox') { - console.log('[SliceView.updateVisibleLayersNow] vox layer transform changed, new allSources orientations=', layerInfo.allSources.length, - 'first orientation scales=', layerInfo.allSources[0]?.length ?? 0); + if ((renderLayer as any).constructor?.type === "vox") { + console.log( + "[SliceView.updateVisibleLayersNow] vox layer transform changed, new allSources orientations=", + layerInfo.allSources.length, + "first orientation scales=", + layerInfo.allSources[0]?.length ?? 0, + ); } disposeTransformedSources(renderLayer, allSources); layerInfo.visibleSources.length = 0; diff --git a/src/sliceview/single_texture_chunk_format.ts b/src/sliceview/single_texture_chunk_format.ts index ae00b64f03..c0a42eee9f 100644 --- a/src/sliceview/single_texture_chunk_format.ts +++ b/src/sliceview/single_texture_chunk_format.ts @@ -146,7 +146,10 @@ export abstract class SingleTextureVolumeChunk< gl.bindTexture(textureTarget, null); } - updateFromCpuData(gl: GL, _region?: { offset: Uint32Array; size: Uint32Array }) { + updateFromCpuData( + gl: GL, + _region?: { offset: Uint32Array; size: Uint32Array }, + ) { if (this.data == null) return; // If there is no existing texture, just perform the normal upload path. @@ -156,27 +159,36 @@ export abstract class SingleTextureVolumeChunk< } const fmt = this.chunkFormat as any; // Both uncompressed and compressed implement TextureFormat-like fields - const textureTarget = textureTargetForSamplerType[this.chunkFormat.shaderSamplerType]; + const textureTarget = + textureTargetForSamplerType[this.chunkFormat.shaderSamplerType]; gl.bindTexture(textureTarget, this.texture); gl.pixelStorei(WebGL2RenderingContext.UNPACK_ALIGNMENT, 1); // If we have a textureLayout with a definite shape (uncompressed path), we can sub-update. const layout: any = this.textureLayout; - const hasShape = layout && layout.textureShape && layout.textureShape.length >= 2; + const hasShape = + layout && layout.textureShape && layout.textureShape.length >= 2; try { // Prefer texSubImage path when we can compute exact sizes (uncompressed formats): - if (hasShape && typeof fmt.textureDims === 'number') { + if (hasShape && typeof fmt.textureDims === "number") { const texelsPerElement = fmt.texelsPerElement ?? 1; const w = layout.textureShape[0] * texelsPerElement; const h = layout.textureShape[1] ?? 1; - const d = fmt.textureDims === 3 ? (layout.textureShape[2] ?? 1) : undefined; + const d = + fmt.textureDims === 3 ? (layout.textureShape[2] ?? 1) : undefined; // Ensure typed array type matches GL expectations let data: any = this.data; - const ctor = fmt.arrayConstructor as { new (b: ArrayBuffer, o: number, l: number): any } | undefined; + const ctor = fmt.arrayConstructor as + | { new (b: ArrayBuffer, o: number, l: number): any } + | undefined; if (ctor && data.constructor !== ctor) { - data = new (ctor as any)(data.buffer, data.byteOffset, data.byteLength / (ctor as any).BYTES_PER_ELEMENT); + data = new (ctor as any)( + data.buffer, + data.byteOffset, + data.byteLength / (ctor as any).BYTES_PER_ELEMENT, + ); } if (fmt.textureDims === 3 && d !== undefined) { diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index a02f8edd4b..62a5b3995a 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -71,7 +71,9 @@ abstract class BaseVoxelLegacyTool extends LegacyTool { if (this.isDrawing) return; this.isDrawing = true; this.currentMouseState = mouseState; - const value = (this.layer as any).getCurrentLabelValue?.() ?? ((this.layer as any).voxEraseMode ? 0 : 42); + const value = + (this.layer as any).getCurrentLabelValue?.() ?? + ((this.layer as any).voxEraseMode ? 0 : 42); const start = this.getPoint(mouseState); if (start) { this.paintPoint(new Float32Array([start[0], start[1], start[2]]), value); @@ -154,7 +156,10 @@ export class VoxelBrushLegacyTool extends BaseVoxelLegacyTool { ); const shape = (this.layer as any).voxBrushShape === "sphere" ? "sphere" : "disk"; - const basis = shape === "disk" ? (this.layer as any).getBrushPlaneBasis?.(this.currentMouseState) : undefined; + const basis = + shape === "disk" + ? (this.layer as any).getBrushPlaneBasis?.(this.currentMouseState) + : undefined; (this.layer as any).voxEditController?.paintBrushWithShape( point, radius, @@ -172,7 +177,10 @@ export class VoxelBrushLegacyTool extends BaseVoxelLegacyTool { const shape = (this.layer as any).voxBrushShape === "sphere" ? "sphere" : "disk"; const ctrl = (this.layer as any).voxEditController; - const basis = shape === "disk" ? (this.layer as any).getBrushPlaneBasis?.(this.currentMouseState) : undefined; + const basis = + shape === "disk" + ? (this.layer as any).getBrushPlaneBasis?.(this.currentMouseState) + : undefined; for (const point of points) { ctrl?.paintBrushWithShape(point, radius, value, shape, basis); } diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index e5ae187af7..c7cde3d5b6 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -3,14 +3,24 @@ * Copyright 2025. */ -import type { VolumeChunk } from '#src/sliceview/volume/backend.js'; -import { VolumeChunkSource as BaseVolumeChunkSource } from '#src/sliceview/volume/backend.js'; -import { DataType } from '#src/util/data_type.js'; -import { VOX_CHUNK_SOURCE_RPC_ID, VOX_COMMIT_VOXELS_RPC_ID, VOX_MAP_INIT_RPC_ID, VOX_LABELS_GET_RPC_ID, VOX_LABELS_SET_RPC_ID } from '#src/voxel_annotation/base.js'; -import type { VoxMapInitOptions } from '#src/voxel_annotation/index.js'; -import { LocalVoxSource, toScaleKey } from '#src/voxel_annotation/index.js'; -import type { RPC } from '#src/worker_rpc.js'; -import { registerRPC, registerPromiseRPC, registerSharedObject } from '#src/worker_rpc.js'; +import type { VolumeChunk } from "#src/sliceview/volume/backend.js"; +import { VolumeChunkSource as BaseVolumeChunkSource } from "#src/sliceview/volume/backend.js"; +import { DataType } from "#src/util/data_type.js"; +import { + VOX_CHUNK_SOURCE_RPC_ID, + VOX_COMMIT_VOXELS_RPC_ID, + VOX_MAP_INIT_RPC_ID, + VOX_LABELS_GET_RPC_ID, + VOX_LABELS_SET_RPC_ID, +} from "#src/voxel_annotation/base.js"; +import type { VoxMapInitOptions } from "#src/voxel_annotation/index.js"; +import { LocalVoxSource, toScaleKey } from "#src/voxel_annotation/index.js"; +import type { RPC } from "#src/worker_rpc.js"; +import { + registerRPC, + registerPromiseRPC, + registerSharedObject, +} from "#src/worker_rpc.js"; /** * Backend volume source that persists voxel edits per chunk. It returns saved data if available, @@ -25,12 +35,28 @@ export class VoxChunkSource extends BaseVolumeChunkSource { } /** Initialize map metadata and persistence backend. */ - async initMap(opts: { mapId?: string; dataType?: number; chunkDataSize?: number[]; upperVoxelBound?: number[]; baseVoxelOffset?: number[]; unit?: string; scaleKey?: string}) { - const cds: number[] = Array.from(opts.chunkDataSize ?? Array.from(this.spec.chunkDataSize)); - const uvb: number[] = Array.from(opts.upperVoxelBound ?? Array.from(this.spec.upperVoxelBound ?? [0, 0, 0] as any)); + async initMap(opts: { + mapId?: string; + dataType?: number; + chunkDataSize?: number[]; + upperVoxelBound?: number[]; + baseVoxelOffset?: number[]; + unit?: string; + scaleKey?: string; + }) { + const cds: number[] = Array.from( + opts.chunkDataSize ?? Array.from(this.spec.chunkDataSize), + ); + const uvb: number[] = Array.from( + opts.upperVoxelBound ?? + Array.from(this.spec.upperVoxelBound ?? ([0, 0, 0] as any)), + ); const dt = opts.dataType ?? this.spec.dataType; // Default base offset to spec.baseVoxelOffset if not provided - const bvo: number[] = Array.from(opts.baseVoxelOffset ?? Array.from((this.spec as any).baseVoxelOffset ?? [0, 0, 0])); + const bvo: number[] = Array.from( + opts.baseVoxelOffset ?? + Array.from((this.spec as any).baseVoxelOffset ?? [0, 0, 0]), + ); const scaleKey = opts.scaleKey ?? toScaleKey(cds, bvo, uvb); const initOpts = { mapId: opts.mapId, @@ -45,12 +71,20 @@ export class VoxChunkSource extends BaseVolumeChunkSource { } /** Commit voxel edits from the frontend. */ - async commitVoxels(edits: { key: string; indices: number[] | Uint32Array; value?: number; values?: ArrayLike; size?: number[] }[]) { + async commitVoxels( + edits: { + key: string; + indices: number[] | Uint32Array; + value?: number; + values?: ArrayLike; + size?: number[]; + }[], + ) { await this.local.applyEdits(edits); } async download(chunk: VolumeChunk, signal: AbortSignal): Promise { - if (signal.aborted) throw signal.reason ?? new Error('aborted'); + if (signal.aborted) throw signal.reason ?? new Error("aborted"); // Determine chunk key and size (may be clipped at upper bound). this.computeChunkBounds(chunk); const cds = chunk.chunkDataSize!; @@ -61,8 +95,12 @@ export class VoxChunkSource extends BaseVolumeChunkSource { // Load saved chunk if present and copy overlapping region const saved = await this.local.getSavedChunk(key); if (saved) { - const sxS = saved.size[0], syS = saved.size[1], szS = saved.size[2]; - const sxD = cds[0], syD = cds[1], szD = cds[2]; + const sxS = saved.size[0], + syS = saved.size[1], + szS = saved.size[2]; + const sxD = cds[0], + syD = cds[1], + szD = cds[2]; const ox = Math.min(sxS, sxD); const oy = Math.min(syS, syD); const oz = Math.min(szS, szD); @@ -120,11 +158,14 @@ registerRPC(VOX_MAP_INIT_RPC_ID, function (x: any) { }); // RPCs for label persistence (promise-based) -registerPromiseRPC(VOX_LABELS_GET_RPC_ID, async function (x: any): Promise { - const obj = this.get(x.rpcId) as VoxChunkSource; - const ids = await obj.local.getLabelIds(); - return { value: ids }; -}); +registerPromiseRPC( + VOX_LABELS_GET_RPC_ID, + async function (x: any): Promise { + const obj = this.get(x.rpcId) as VoxChunkSource; + const ids = await obj.local.getLabelIds(); + return { value: ids }; + }, +); registerRPC(VOX_LABELS_SET_RPC_ID, function (x: any) { const obj = this.get(x.id) as VoxChunkSource; diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index a8a5c578fe..d79312b707 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -1,5 +1,5 @@ -export const VOX_CHUNK_SOURCE_RPC_ID = 'vox.VoxChunkSource'; -export const VOX_COMMIT_VOXELS_RPC_ID = 'vox.commitVoxels'; -export const VOX_MAP_INIT_RPC_ID = 'vox.map.init'; -export const VOX_LABELS_GET_RPC_ID = 'vox.labels.get'; -export const VOX_LABELS_SET_RPC_ID = 'vox.labels.set'; +export const VOX_CHUNK_SOURCE_RPC_ID = "vox.VoxChunkSource"; +export const VOX_COMMIT_VOXELS_RPC_ID = "vox.commitVoxels"; +export const VOX_MAP_INIT_RPC_ID = "vox.map.init"; +export const VOX_LABELS_GET_RPC_ID = "vox.labels.get"; +export const VOX_LABELS_SET_RPC_ID = "vox.labels.set"; diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 9fb7acf99f..692ac013e2 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -3,8 +3,8 @@ * Copyright 2025. */ -import type { MultiscaleVolumeChunkSource } from '#src/sliceview/volume/frontend.js'; -import type { VoxChunkSource } from '#src/voxel_annotation/frontend.js'; +import type { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; +import type { VoxChunkSource } from "#src/voxel_annotation/frontend.js"; /** Tiny controller to forward voxel edits from tools to the VoxChunkSource. */ export class VoxelEditController { @@ -35,7 +35,7 @@ export class VoxelEditController { center: Float32Array, radius: number, value: number, - shape: 'disk' | 'sphere' = 'disk', + shape: "disk" | "sphere" = "disk", basis?: { u: Float32Array; v: Float32Array }, ) { if (!Number.isFinite(radius) || radius <= 0) return; @@ -47,7 +47,7 @@ export class VoxelEditController { const source = this.getSource(); if (!source) return; const voxels: Float32Array[] = []; - if (shape === 'sphere') { + if (shape === "sphere") { for (let dz = -r; dz <= r; ++dz) { for (let dy = -r; dy <= r; ++dy) { for (let dx = -r; dx <= r; ++dx) { @@ -61,7 +61,16 @@ export class VoxelEditController { // Oriented disk in the provided slice plane; if basis not provided, fall back to XY at fixed Z = cz. const u = basis?.u; const v = basis?.v; - if (u && v && Number.isFinite(u[0]) && Number.isFinite(u[1]) && Number.isFinite(u[2]) && Number.isFinite(v[0]) && Number.isFinite(v[1]) && Number.isFinite(v[2])) { + if ( + u && + v && + Number.isFinite(u[0]) && + Number.isFinite(u[1]) && + Number.isFinite(u[2]) && + Number.isFinite(v[0]) && + Number.isFinite(v[1]) && + Number.isFinite(v[2]) + ) { // Normalize u and v for safety. const ul = Math.hypot(u[0], u[1], u[2]) || 1; const vl = Math.hypot(v[0], v[1], v[2]) || 1; @@ -77,7 +86,7 @@ export class VoxelEditController { const ix = Math.round(px); const iy = Math.round(py); const iz = Math.round(pz); - const key = ix + ',' + iy + ',' + iz; + const key = ix + "," + iy + "," + iz; if (!seen.has(key)) { seen.add(key); voxels.push(new Float32Array([ix, iy, iz])); @@ -86,7 +95,9 @@ export class VoxelEditController { } } } else { - console.warn('No basis provided for disk brush, falling back to XY plane at fixed Z = cz.'); + console.warn( + "No basis provided for disk brush, falling back to XY plane at fixed Z = cz.", + ); // Fallback: Disk in XY plane at fixed Z = cz for (let dy = -r; dy <= r; ++dy) { for (let dx = -r; dx <= r; ++dx) { @@ -102,7 +113,7 @@ export class VoxelEditController { /** Backward-compat spherical brush API. */ paintBrush(center: Float32Array, radius: number, value: number) { - this.paintBrushWithShape(center, radius, value, 'sphere'); + this.paintBrushWithShape(center, radius, value, "sphere"); } async getLabelIds(): Promise { diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 24176c4dc5..e9ef8df6b7 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -3,17 +3,22 @@ * Copyright 2025. */ -import { ChunkState } from '#src/chunk_manager/base.js'; -import type { ChunkManager } from '#src/chunk_manager/frontend.js'; -import type { VolumeChunkSpecification } from '#src/sliceview/volume/base.js'; -import type { VolumeChunk } from '#src/sliceview/volume/frontend.js'; -import { VolumeChunkSource as BaseVolumeChunkSource } from '#src/sliceview/volume/frontend.js'; +import { ChunkState } from "#src/chunk_manager/base.js"; +import type { ChunkManager } from "#src/chunk_manager/frontend.js"; +import type { VolumeChunkSpecification } from "#src/sliceview/volume/base.js"; +import type { VolumeChunk } from "#src/sliceview/volume/frontend.js"; +import { VolumeChunkSource as BaseVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; import { animationFrameDebounce } from "#src/util/animation_frame_debounce.js"; -import type { TypedArray } from '#src/util/array.js'; -import { VOX_CHUNK_SOURCE_RPC_ID, VOX_COMMIT_VOXELS_RPC_ID, VOX_MAP_INIT_RPC_ID, VOX_LABELS_GET_RPC_ID, VOX_LABELS_SET_RPC_ID } from '#src/voxel_annotation/base.js'; +import type { TypedArray } from "#src/util/array.js"; +import { + VOX_CHUNK_SOURCE_RPC_ID, + VOX_COMMIT_VOXELS_RPC_ID, + VOX_MAP_INIT_RPC_ID, + VOX_LABELS_GET_RPC_ID, + VOX_LABELS_SET_RPC_ID, +} from "#src/voxel_annotation/base.js"; import { registerSharedObjectOwner } from "#src/worker_rpc.js"; - /** * Frontend owner for VoxChunkSource, extended with a local optimistic edit overlay. */ @@ -22,21 +27,34 @@ export class VoxChunkSource extends BaseVolumeChunkSource { private tempVoxChunkGridPosition = new Float32Array(3); private tempLocalPosition = new Uint32Array(3); private dirtyChunks = new Set(); - private scheduleProcessPendingUploads = animationFrameDebounce( - () => this.processPendingUploads() + private scheduleProcessPendingUploads = animationFrameDebounce(() => + this.processPendingUploads(), ); /** Initialize map in the worker/backend for this source. */ - initializeMap(opts: { mapId?: string; dataType?: number; chunkDataSize?: number[]; upperVoxelBound?: number[]; baseVoxelOffset?: number[]; unit?: string; scaleKey?: string}) { + initializeMap(opts: { + mapId?: string; + dataType?: number; + chunkDataSize?: number[]; + upperVoxelBound?: number[]; + baseVoxelOffset?: number[]; + unit?: string; + scaleKey?: string; + }) { try { this.rpc!.invoke(VOX_MAP_INIT_RPC_ID, { id: this.rpcId, ...opts }); } catch { // initialization is best-effort; continue even if it fails - console.warn('VoxChunkSource.initializeMap: Failed to initialize voxel map.'); + console.warn( + "VoxChunkSource.initializeMap: Failed to initialize voxel map.", + ); } } - constructor(chunkManager: ChunkManager, options: { spec: VolumeChunkSpecification }) { + constructor( + chunkManager: ChunkManager, + options: { spec: VolumeChunkSpecification }, + ) { super(chunkManager, options); } @@ -45,13 +63,15 @@ export class VoxChunkSource extends BaseVolumeChunkSource { /* NOTE: do not pass the rpcId as { id: this.rpcId } since the id field it will be overwritten by promiseInvoke, use another name like { rpcId: this.rpcId } */ - return await this.rpc!.promiseInvoke(VOX_LABELS_GET_RPC_ID, { rpcId: this.rpcId }); + return await this.rpc!.promiseInvoke(VOX_LABELS_GET_RPC_ID, { + rpcId: this.rpcId, + }); } catch { return []; } } - setLabelIds(ids: number[]){ + setLabelIds(ids: number[]) { try { this.rpc!.invoke(VOX_LABELS_SET_RPC_ID, { id: this.rpcId, ids }); } catch { @@ -102,7 +122,12 @@ export class VoxChunkSource extends BaseVolumeChunkSource { if (editsByKey.size > 0) { const size = Array.from(this.spec.chunkDataSize); - const edits = Array.from(editsByKey, ([key, indices]) => ({ key, indices, value, size })); + const edits = Array.from(editsByKey, ([key, indices]) => ({ + key, + indices, + value, + size, + })); try { this.rpc!.invoke(VOX_COMMIT_VOXELS_RPC_ID, { id: this.rpcId, edits }); } catch { @@ -135,7 +160,10 @@ export class VoxChunkSource extends BaseVolumeChunkSource { local[i] = Math.floor(v - c * size); } const key = `${keyParts[0]},${keyParts[1]},${keyParts[2]}`; - const canonicalIndex = this.localIndexFromLocalPosition(local, this.spec.chunkDataSize as Uint32Array); + const canonicalIndex = this.localIndexFromLocalPosition( + local, + this.spec.chunkDataSize as Uint32Array, + ); const chunk = this.chunks.get(key) as VolumeChunk | undefined; let chunkLocalIndex = -1; const cds = (chunk?.chunkDataSize as Uint32Array) ?? null; @@ -145,22 +173,27 @@ export class VoxChunkSource extends BaseVolumeChunkSource { } } else { // If the chunk is not loaded yet, the spec size is a reasonable fallback for immediate updates (no-op if no CPU array). - chunkLocalIndex = this.localIndexFromLocalPosition(local, this.spec.chunkDataSize as Uint32Array); + chunkLocalIndex = this.localIndexFromLocalPosition( + local, + this.spec.chunkDataSize as Uint32Array, + ); } return { key, canonicalIndex, chunkLocalIndex }; } - private getCpuArrayForChunk(chunk: VolumeChunk): TypedArray | null { const data = (chunk as any).data as TypedArray | null | undefined; - return (data ?? null); + return data ?? null; } private invalidateChunkUpload(chunk: VolumeChunk) { const gl = chunk.gl; // If already on GPU and the concrete implementation supports in-place update, use it. const anyChunk = chunk as any; - if (chunk.state === ChunkState.GPU_MEMORY && typeof anyChunk.updateFromCpuData === 'function') { + if ( + chunk.state === ChunkState.GPU_MEMORY && + typeof anyChunk.updateFromCpuData === "function" + ) { anyChunk.updateFromCpuData(gl); return; } diff --git a/src/voxel_annotation/index.ts b/src/voxel_annotation/index.ts index 194462e63a..5112834f0c 100644 --- a/src/voxel_annotation/index.ts +++ b/src/voxel_annotation/index.ts @@ -18,14 +18,22 @@ export interface SavedChunk { size: Uint32Array; // canonical size used for linearization (usually spec.chunkDataSize) } -export function toScaleKey(chunkDataSize: number[] | Uint32Array, baseVoxelOffset?: number[] | Uint32Array | Float32Array, upperVoxelBound?: number[] | Uint32Array | Float32Array): string { +export function toScaleKey( + chunkDataSize: number[] | Uint32Array, + baseVoxelOffset?: number[] | Uint32Array | Float32Array, + upperVoxelBound?: number[] | Uint32Array | Float32Array, +): string { const cds = Array.from(chunkDataSize); const lower = Array.from(baseVoxelOffset ?? [0, 0, 0]); const upper = Array.from(upperVoxelBound ?? [0, 0, 0]); return `${cds[0]}_${cds[1]}_${cds[2]}:${lower[0]}_${lower[1]}_${lower[2]}-${upper[0]}_${upper[1]}_${upper[2]}`; } -export function compositeChunkDbKey(mapId: string, scaleKey: string, chunkKey: string): string { +export function compositeChunkDbKey( + mapId: string, + scaleKey: string, + chunkKey: string, +): string { return `${mapId}:${scaleKey}:${chunkKey}`; } @@ -34,13 +42,13 @@ export function compositeLabelsDbKey(mapId: string, scaleKey: string): string { } export abstract class VoxSource { - protected mapId: string = 'default'; - protected scaleKey: string = ''; + protected mapId: string = "default"; + protected scaleKey: string = ""; protected chunkDataSize: Uint32Array = new Uint32Array([64, 64, 64]); protected upperVoxelBound: Uint32Array = new Uint32Array([0, 0, 0]); protected baseVoxelOffset: Uint32Array = new Uint32Array([0, 0, 0]); protected dataType: number = 6; // DataType.UINT32 default - protected unit: string = ''; + protected unit: string = ""; // In-memory cache of loaded chunks protected maxSavedChunks = 128; // cap to prevent unbounded growth @@ -54,23 +62,39 @@ export abstract class VoxSource { * Generic label persistence hooks. Subclasses override to connect to the chosen datasource. * Default implementation is a no-op empty list. */ - async getLabelIds(): Promise { return []; } - async setLabelIds(_ids: number[]): Promise { /* no-op */ } + async getLabelIds(): Promise { + return []; + } + async setLabelIds(_ids: number[]): Promise { + /* no-op */ + } init(_opts: VoxMapInitOptions): Promise<{ mapId: string; scaleKey: string }> { // Base provides default bookkeeping; persistence layer does real work. const opts = _opts || ({} as VoxMapInitOptions); - this.mapId = opts.mapId || this.mapId || (typeof crypto !== 'undefined' && (crypto as any).randomUUID?.()) || String(Date.now()); + this.mapId = + opts.mapId || + this.mapId || + (typeof crypto !== "undefined" && (crypto as any).randomUUID?.()) || + String(Date.now()); this.chunkDataSize = new Uint32Array(Array.from(opts.chunkDataSize)); - this.upperVoxelBound = new Uint32Array(Array.from(opts.upperVoxelBound ?? [0, 0, 0])); - this.baseVoxelOffset = new Uint32Array(Array.from(opts.baseVoxelOffset ?? [0, 0, 0])); + this.upperVoxelBound = new Uint32Array( + Array.from(opts.upperVoxelBound ?? [0, 0, 0]), + ); + this.baseVoxelOffset = new Uint32Array( + Array.from(opts.baseVoxelOffset ?? [0, 0, 0]), + ); this.dataType = opts.dataType ?? this.dataType; - this.unit = opts.unit ?? ''; + this.unit = opts.unit ?? ""; // Default scaleKey includes region to avoid collisions if not provided explicitly if (opts.scaleKey) { this.scaleKey = opts.scaleKey; } else { - this.scaleKey = toScaleKey(this.chunkDataSize, this.baseVoxelOffset, this.upperVoxelBound); + this.scaleKey = toScaleKey( + this.chunkDataSize, + this.baseVoxelOffset, + this.upperVoxelBound, + ); } return Promise.resolve({ mapId: this.mapId, scaleKey: this.scaleKey }); } @@ -84,14 +108,22 @@ export abstract class VoxSource { protected scheduleSave() { if (this.saveTimer !== undefined) return; // Debounce writes ~750ms - this.saveTimer = (setTimeout(() => this.flushSaves(), 750) as unknown) as number; + this.saveTimer = setTimeout( + () => this.flushSaves(), + 750, + ) as unknown as number; } // Overridden by subclass to actually persist dirty chunks. protected async flushSaves(): Promise {} // Apply edits into an in-memory chunk array; returns the SavedChunk. - protected applyEditsIntoChunk(sc: SavedChunk, indices: ArrayLike, value?: number, values?: ArrayLike) { + protected applyEditsIntoChunk( + sc: SavedChunk, + indices: ArrayLike, + value?: number, + values?: ArrayLike, + ) { const dst = sc.data; if (values != null) { const vv = values as ArrayLike; @@ -120,8 +152,8 @@ export class LocalVoxSource extends VoxSource { try { const db = await this.getDb(); const key = compositeLabelsDbKey(this.mapId, this.scaleKey); - const arr = await idbGet(db, 'labels', key); - if (arr && Array.isArray(arr)) return arr.map(v => v >>> 0); + const arr = await idbGet(db, "labels", key); + if (arr && Array.isArray(arr)) return arr.map((v) => v >>> 0); return []; } catch { return []; @@ -131,10 +163,10 @@ export class LocalVoxSource extends VoxSource { override async setLabelIds(ids: number[]): Promise { try { const db = await this.getDb(); - const tx = db.transaction('labels', 'readwrite'); - const store = tx.objectStore('labels'); + const tx = db.transaction("labels", "readwrite"); + const store = tx.objectStore("labels"); const key = compositeLabelsDbKey(this.mapId, this.scaleKey); - const payload = ids.map(v => v >>> 0); + const payload = ids.map((v) => v >>> 0); await idbPut(store, payload, key); await txDone(tx); } catch { @@ -154,7 +186,10 @@ export class LocalVoxSource extends VoxSource { while (this.saved.size > this.maxSavedChunks) { let oldestKey: string | undefined = undefined; for (const k of this.saved.keys()) { - if (!this.dirty.has(k)) { oldestKey = k; break; } + if (!this.dirty.has(k)) { + oldestKey = k; + break; + } } if (oldestKey === undefined) { // All entries are dirty; wait until they are flushed before evicting. @@ -168,30 +203,39 @@ export class LocalVoxSource extends VoxSource { const meta = await super.init(opts); const db = await this.getDb(); // Persist/update map metadata - const tx = db.transaction('maps', 'readwrite'); - tx.objectStore('maps').put({ - mapId: this.mapId, - dataType: this.dataType, - chunkDataSize: Array.from(this.chunkDataSize), - upperVoxelBound: Array.from(this.upperVoxelBound), - baseVoxelOffset: Array.from(this.baseVoxelOffset), - unit: this.unit, - scaleKey: this.scaleKey, - updatedAt: Date.now(), - }, this.mapId); + const tx = db.transaction("maps", "readwrite"); + tx.objectStore("maps").put( + { + mapId: this.mapId, + dataType: this.dataType, + chunkDataSize: Array.from(this.chunkDataSize), + upperVoxelBound: Array.from(this.upperVoxelBound), + baseVoxelOffset: Array.from(this.baseVoxelOffset), + unit: this.unit, + scaleKey: this.scaleKey, + updatedAt: Date.now(), + }, + this.mapId, + ); await txDone(tx); return meta; } async getSavedChunk(key: string): Promise { const existing = this.saved.get(key); - if (existing) { this.touch(key); return existing; } + if (existing) { + this.touch(key); + return existing; + } const db = await this.getDb(); const composite = this.compositeKey(key); - const buf = await idbGet(db, 'chunks', composite); + const buf = await idbGet(db, "chunks", composite); if (buf) { const arr = new Uint32Array(buf); - const sc: SavedChunk = { data: arr, size: new Uint32Array(this.chunkDataSize) }; + const sc: SavedChunk = { + data: arr, + size: new Uint32Array(this.chunkDataSize), + }; this.saved.set(key, sc); this.enforceCap(); return sc; @@ -199,12 +243,18 @@ export class LocalVoxSource extends VoxSource { return undefined; } - async ensureChunk(key: string, size?: Uint32Array | number[]): Promise { + async ensureChunk( + key: string, + size?: Uint32Array | number[], + ): Promise { let sc = this.saved.get(key); - if (sc) { this.touch(key); return sc; } + if (sc) { + this.touch(key); + return sc; + } const db = await this.getDb(); const composite = this.compositeKey(key); - const buf = await idbGet(db, 'chunks', composite); + const buf = await idbGet(db, "chunks", composite); if (buf) { const arr = new Uint32Array(buf); sc = { data: arr, size: new Uint32Array(this.chunkDataSize) }; @@ -213,7 +263,8 @@ export class LocalVoxSource extends VoxSource { return sc; } const sz = new Uint32Array(size ?? this.chunkDataSize); - let total = 1; for (let i = 0; i < 3; ++i) total *= sz[i]; + let total = 1; + for (let i = 0; i < 3; ++i) total *= sz[i]; const arr = new Uint32Array(total); sc = { data: arr, size: new Uint32Array(sz) }; this.saved.set(key, sc); @@ -222,9 +273,20 @@ export class LocalVoxSource extends VoxSource { return sc; } - async applyEdits(edits: { key: string; indices: ArrayLike; value?: number; values?: ArrayLike; size?: number[] }[]) { + async applyEdits( + edits: { + key: string; + indices: ArrayLike; + value?: number; + values?: ArrayLike; + size?: number[]; + }[], + ) { for (const e of edits) { - const sc = await this.ensureChunk(e.key, e.size ? new Uint32Array(e.size) : this.chunkDataSize); + const sc = await this.ensureChunk( + e.key, + e.size ? new Uint32Array(e.size) : this.chunkDataSize, + ); this.applyEditsIntoChunk(sc, e.indices, e.value, e.values); this.markDirty(e.key); } @@ -232,11 +294,14 @@ export class LocalVoxSource extends VoxSource { protected override async flushSaves() { const keys = Array.from(this.dirty); - if (keys.length === 0) { this.saveTimer = undefined; return; } + if (keys.length === 0) { + this.saveTimer = undefined; + return; + } this.dirty.clear(); const db = await this.getDb(); - const tx = db.transaction('chunks', 'readwrite'); - const store = tx.objectStore('chunks'); + const tx = db.transaction("chunks", "readwrite"); + const store = tx.objectStore("chunks"); for (const key of keys) { const sc = this.saved.get(key); if (!sc) continue; @@ -269,28 +334,34 @@ export class RemoteVoxSource extends VoxSource { } override async setLabelIds(ids: number[]): Promise { // Placeholder: post to remote endpoint; cache locally as best-effort. - this.labelsCache = ids.map(v => v >>> 0); + this.labelsCache = ids.map((v) => v >>> 0); } } export function openVoxDb(): Promise { return new Promise((resolve, reject) => { - const req = indexedDB.open('neuroglancer_vox', 2); + const req = indexedDB.open("neuroglancer_vox", 2); req.onerror = () => reject(req.error); req.onupgradeneeded = () => { const db = req.result; - if (!db.objectStoreNames.contains('maps')) db.createObjectStore('maps'); - if (!db.objectStoreNames.contains('chunks')) db.createObjectStore('chunks'); - if (!db.objectStoreNames.contains('labels')) db.createObjectStore('labels'); + if (!db.objectStoreNames.contains("maps")) db.createObjectStore("maps"); + if (!db.objectStoreNames.contains("chunks")) + db.createObjectStore("chunks"); + if (!db.objectStoreNames.contains("labels")) + db.createObjectStore("labels"); }; req.onsuccess = () => resolve(req.result); }); } // --- Small IDB helpers --- -export function idbGet(db: IDBDatabase, storeName: string, key: IDBValidKey): Promise { +export function idbGet( + db: IDBDatabase, + storeName: string, + key: IDBValidKey, +): Promise { return new Promise((resolve, reject) => { - const tx = db.transaction(storeName, 'readonly'); + const tx = db.transaction(storeName, "readonly"); const store = tx.objectStore(storeName); const req = store.get(key); req.onerror = () => reject(req.error); diff --git a/src/voxel_annotation/renderlayer.ts b/src/voxel_annotation/renderlayer.ts index c5e587955a..96e9177e94 100644 --- a/src/voxel_annotation/renderlayer.ts +++ b/src/voxel_annotation/renderlayer.ts @@ -34,7 +34,9 @@ import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; type EmptyParams = Record; export class VoxelAnnotationRenderLayer extends SliceViewVolumeRenderLayer { - private segmentColorShaderManager = new SegmentColorShaderManager("segmentColorHash"); + private segmentColorShaderManager = new SegmentColorShaderManager( + "segmentColorHash", + ); constructor( multiscaleSource: MultiscaleVolumeChunkSource, @@ -42,7 +44,8 @@ export class VoxelAnnotationRenderLayer extends SliceViewVolumeRenderLayer 0, }); } diff --git a/src/voxel_annotation/volume_chunk_source.ts b/src/voxel_annotation/volume_chunk_source.ts index 21f9cb5542..90e7063113 100644 --- a/src/voxel_annotation/volume_chunk_source.ts +++ b/src/voxel_annotation/volume_chunk_source.ts @@ -14,15 +14,16 @@ * limitations under the License. */ -import type { ChunkManager } from '#src/chunk_manager/frontend.js'; -import type { SliceViewSingleResolutionSource } from '#src/sliceview/frontend.js'; -import type { VolumeSourceOptions } from '#src/sliceview/volume/base.js'; -import { makeVolumeChunkSpecification, VolumeType } from '#src/sliceview/volume/base.js'; +import type { ChunkManager } from "#src/chunk_manager/frontend.js"; +import type { SliceViewSingleResolutionSource } from "#src/sliceview/frontend.js"; +import type { VolumeSourceOptions } from "#src/sliceview/volume/base.js"; import { - MultiscaleVolumeChunkSource, -} from '#src/sliceview/volume/frontend.js'; -import { DataType } from '#src/util/data_type.js'; -import { VoxChunkSource } from '#src/voxel_annotation/frontend.js'; + makeVolumeChunkSpecification, + VolumeType, +} from "#src/sliceview/volume/base.js"; +import { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; +import { DataType } from "#src/util/data_type.js"; +import { VoxChunkSource } from "#src/voxel_annotation/frontend.js"; /** * This is an abstract representation of 3D (volumetric) data that can exist at multiple resolutions or "scales." @@ -59,10 +60,14 @@ export class VoxMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSource options?.chunkDataSize ? Array.from(options.chunkDataSize) : [64, 64, 64], ); this.cfgUpperVoxelBound = new Float32Array( - options?.upperVoxelBound ? Array.from(options.upperVoxelBound) : [1_000, 1_000, 1_000], + options?.upperVoxelBound + ? Array.from(options.upperVoxelBound) + : [1_000, 1_000, 1_000], ); this.cfgBaseVoxelOffset = new Float32Array( - options?.baseVoxelOffset ? Array.from(options.baseVoxelOffset) : [0, 0, 0], + options?.baseVoxelOffset + ? Array.from(options.baseVoxelOffset) + : [0, 0, 0], ); } diff --git a/src/webgl/shader.ts b/src/webgl/shader.ts index 1b2890ac85..3f7f4ac835 100644 --- a/src/webgl/shader.ts +++ b/src/webgl/shader.ts @@ -665,7 +665,7 @@ ${this.fragmentMain} } print() { - const vertexSource = `#version 300 es + const vertexSource = `#version 300 es precision highp float; precision highp int; ${this.uniformsCode} @@ -677,7 +677,7 @@ void main() { ${this.vertexMain} } `; - const fragmentSource = `#version 300 es + const fragmentSource = `#version 300 es ${this.fragmentExtensions} precision highp float; precision highp int; @@ -688,8 +688,8 @@ float defaultMaxProjectionIntensity = 0.0; ${this.fragmentCode} ${this.fragmentMain} `; - console.log('----- VERTEX SHADER -----\n' + vertexSource); - console.log('----- FRAGMENT SHADER -----\n' + fragmentSource); + console.log("----- VERTEX SHADER -----\n" + vertexSource); + console.log("----- FRAGMENT SHADER -----\n" + fragmentSource); } } From 2c50dcb50dbafd37e37a5880916486df81510837 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 026/251] feat: add support for remote voxel sources via HTTP(S) - (note: the labels are not sync currently) - Implement `VoxRemoteDataSourceProvider` to handle `vox+http(s)` URLs. - Extend backend and frontend components to support remote voxel annotations. - Add server URL and token configuration for remote connections. - Integrate URL validation and remote server health checks. - Update voxel source logic to switch between local and remote modes dynamically. - Enhance `VoxChunkSource` with remote chunk handlers and API calls. --- NOTES/backend.md | 146 ++++++++++++++ src/datasource/default_provider.ts | 4 + src/datasource/vox_remote.ts | 85 ++++++++ src/layer/vox/index.ts | 86 +++++++- src/voxel_annotation/backend.ts | 34 +++- src/voxel_annotation/frontend.ts | 30 ++- src/voxel_annotation/index.ts | 212 +++++++++++++++++++- src/voxel_annotation/volume_chunk_source.ts | 10 +- 8 files changed, 586 insertions(+), 21 deletions(-) create mode 100644 NOTES/backend.md create mode 100644 src/datasource/vox_remote.ts diff --git a/NOTES/backend.md b/NOTES/backend.md new file mode 100644 index 0000000000..aa911ac58d --- /dev/null +++ b/NOTES/backend.md @@ -0,0 +1,146 @@ +### Requirements Document: Zarr-based Voxel Annotation Server (MVP) + +#### 1) Overview and Scope +- Goal: Deliver a production-ready HTTP server to host and edit voxel annotation data (integer label volumes) stored in Zarr. The server must be easy to deploy via Docker Compose and suitable for browser clients (e.g., Neuroglancer-based UIs or custom viewers). +- Out of scope: Any form of backup, history, or undo functionality (explicitly excluded from this requirements list). + +#### 2) Context and Assumptions +- Data model: 3D label volumes (`uint32` or `uint64`) chunked in Zarr v2 layout; optional multiscale hierarchy following NGFF `multiscales` attribute. +- Hosting model: Reads served over plain HTTP/HTTPS from an object store or filesystem via the app or via a CDN/reverse proxy. Writes are authenticated and validated by the app and applied to the Zarr store. +- Authentication: Unique link (magic link). +- Clients: Browser-based viewers/editors. Clients read/write whole chunks; no sub-chunk partial writes. +- Deployment target: Single-node Docker Compose for development and small teams. + +#### 3) Data Format and Layout (Zarr) +- Zarr version: v2. +- Root group: `annotations.zarr/` containing: + - `.zgroup` (group marker) + - `.zattrs` with NGFF `multiscales` describing axes and coordinate transforms. + - One or more arrays for scale levels: `0/`, `1/`, ... (strings). +- Array (`0/`) `.zarray` baseline (example values): +```json +{ + "zarr_format": 2, + "shape": [Z, Y, X], + "chunks": [64, 64, 64], + "dtype": "uint32", + "compressor": {"id": "zlib", "level": 5}, + "order": "C", + "fill_value": 0 +} +``` +- Missing chunk semantics: Unwritten chunks are implicitly `fill_value` (0). +- Chunk addressing (v2): Files at `0/ix/iy/iz` for chunk indices `(ix, iy, iz)`. + +#### 4) Functional Requirements +- Dataset discovery and metadata + - The server exposes an endpoint to return dataset info (union of NGFF `.zattrs` and per-array `.zarray` summaries). + - The server must report shapes, chunk sizes, dtype, fill value, and the public base URL for direct HTTP reads (if configured). +- Read operations + - Clients can fetch chunks as raw binary blocks via the server or directly from the store/reverse proxy. + - Missing chunks must be interpreted as background (fill value 0). +- Write operations + - Clients upload full chunks for updates. Payload must match the logical chunk voxel count and dtype. + - Edges: For boundary chunks smaller than full chunk size, the server accepts a full-sized block and writes only the in-bounds subregion. + - Concurrency: MVP supports last-writer-wins. +- Dataset resize + - Resize for expanding the array shape (Zarr `resize`). +- Authentication and authorization + - Magic link token required for all endpoints. +- Multi-scale (optional) + - The server lists available scales; reading/writing operates per selected scale path (string). + +#### 5) Non-Functional Requirements +- Performance + - Target chunk size: 64 cubed for labels. Throughput goal: at least hundreds of chunk reads/s and tens of chunk writes/s on a single node with local/S3-like storage. + - Compression: zlib (level 5) or zstd; deterministic compressor to keep payloads predictable. +- Availability + - Single instance acceptable for MVP; health checks and graceful shutdown required. +- Consistency + - Per-chunk write is atomic from the client perspective. Readers may observe eventual consistency on object stores. +- Security + - CORS: Allow configured origins; methods `GET, HEAD, PUT, OPTIONS`. +- Caching + - Metadata: short `Cache-Control` (e.g., 60s) with ETags. Chunks: cacheable but consider short TTLs during active editing. Avoid long-lived caching of 404s. +- Observability + - Structured logs for all requests with dataset id, path, role, status, duration, payload size. + - Basic metrics: request counts, latencies, error codes, chunk read/write counters. +- Portability + - Storage backends via fsspec-compatible URLs (`file://`, S3, etc.). Docker-compose provides local S3-compatible MinIO for development. + +#### 6) API Specification (HTTP, JSON/binary) +- GET `/info` + - Response: datasets metadata including `publicBase` URL and a list of arrays: `{ path, shape, chunks, dtype, fill_value, compressor }`. +- GET `/chunk?mapId=&chunkKey=` + - Response: `application/octet-stream` raw bytes of a full chunk in row-major order with array dtype. Edge chunks are padded to full chunk size. +- PUT `/chunk?mapId=&chunkKey=` + - Request body: raw bytes matching `chunks[0]*chunks[1]*chunks[2]*dtype.itemsize`. + - Behavior: Writes the corresponding chunk region. For edge chunks, only in-bounds subset is written. + - Response: JSON `{ status: "ok" }` on success. +- GET `/init?mapId=&scaleKey=&dtype=` + - Behavior: Init a new map with id mapId, and sets up its metadata. If a map already exists, return an error. + - Response: `{ status: "ok" }` on success. +- GET `/health` + - Response: `200 OK` if the server is ready and can reach the storage. + +scale key calculation: +```ts +export function toScaleKey( + chunkDataSize: number[] | Uint32Array, + baseVoxelOffset?: number[] | Uint32Array | Float32Array, + upperVoxelBound?: number[] | Uint32Array | Float32Array, +): string { + const cds = Array.from(chunkDataSize); + const lower = Array.from(baseVoxelOffset ?? [0, 0, 0]); + const upper = Array.from(upperVoxelBound ?? [0, 0, 0]); + return `${cds[0]}_${cds[1]}_${cds[2]}:${lower[0]}_${lower[1]}_${lower[2]}-${upper[0]}_${upper[1]}_${upper[2]}`; // "cx_cy_cz:lx_ly_lz-ux_uy_uz" -> "64_64_64:0_0_0-1024_1024_1024" +} +``` + +chunk key calculation: +```ts +export function toChunkKey( + chunkIndices: number[] | Uint32Array, +): string { + const cis = Array.from(chunkIndices); + return `${cis[0]},${cis[1]},${cis[2]}`; // "cx,cy,cz" -> "0,0,0" +} +``` + +#### 7) Storage and Infrastructure +- Backends: Local filesystem or S3-compatible object store. Docker Compose includes MinIO for local S3-like storage. +- Directory and object naming + - One Zarr root per dataset (MVP). Scale arrays named `"0"`, `"1"`, ... + +#### 8) Deployment Architecture +- Components + - App server: Hosts the HTTP API, performs auth, validates input, reads/writes Zarr store. + - Object store: MinIO (compose); durable storage for Zarr. +- Read flow: Client → App → Store → App → Client. +- Write flow: Client → App → Store (write) → App response. + +#### 9) Configuration +- Environment variables (app) + - `ZARR_URL`: Zarr root URL (`file://` or `s3://zarr/annotations.zarr`). + - `PUBLIC_BASE`: Public base URL for direct reads (optional). + - `CORS_ORIGINS`: Comma-separated origins allowed. +- Environment variables (MinIO) + - `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`. +- Volumes + - Persistent volume for MinIO data. + - Optional bind mount for filesystem-backed Zarr. + +#### 10) Health, Logging, and Metrics +- Health endpoint: `GET /health` returns 200 when app is ready and can reach storage. +- Logging: JSON logs with timestamp, method, path, dataset id, http status, latency ms, bytes. + +#### 13) Risks and Mitigations +- Object-store eventual consistency: Edge cases where a just-written chunk isn’t visible immediately; mitigate with read-after-write via the app or retries. +- Misconfigured CORS: Prevents browser access; provide a CORS self-test on `/info`. +- Payload mismatch (size/dtype): Strict validation and clear error messages. + +#### 14) Operational Runbook (MVP) +- First start + - `docker compose up -d` + - Visit `http://localhost:8042/info?token=...` with a valid magic link token to verify connection, server should provide a token throw its console + - Connect neuroglancer to `zarr://http://localhost:8042/?token=...`, create a new map and try drawing diff --git a/src/datasource/default_provider.ts b/src/datasource/default_provider.ts index 85bf8b0aa7..816fe35754 100644 --- a/src/datasource/default_provider.ts +++ b/src/datasource/default_provider.ts @@ -25,6 +25,7 @@ import type { } from "#src/datasource/index.js"; import { DataSourceRegistry } from "#src/datasource/index.js"; import { LocalDataSourceProvider } from "#src/datasource/local.js"; +import { VoxRemoteDataSourceProvider } from "#src/datasource/vox_remote.js"; import { AutoDetectRegistry } from "#src/kvstore/auto_detect.js"; import type { SharedKvStoreContext } from "#src/kvstore/frontend.js"; @@ -50,6 +51,9 @@ export function registerKvStoreBasedDataProvider( export function getDefaultDataSourceProvider(options: ProviderOptions) { const registry = new DataSourceRegistry(options.kvStoreContext); registry.register(new LocalDataSourceProvider()); + // Register vox remote providers (HTTP/HTTPS) + registry.register(new VoxRemoteDataSourceProvider("vox+http")); + registry.register(new VoxRemoteDataSourceProvider("vox+https")); for (const provider of providers) { registry.register(provider); } diff --git a/src/datasource/vox_remote.ts b/src/datasource/vox_remote.ts new file mode 100644 index 0000000000..9fbe4f5384 --- /dev/null +++ b/src/datasource/vox_remote.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2025. + */ + +import { + emptyValidCoordinateSpace, + makeIdentityTransform, +} from "#src/coordinate_transform.js"; +import type { + CompleteUrlOptions, + DataSource, + DataSourceProvider, + GetDataSourceOptions, +} from "#src/datasource/index.js"; +import { getPrefixMatchesWithDescriptions } from "#src/util/completion.js"; + +/** + * Provider for vox+http(s):// URLs used by the Vox layer to connect to a remote voxel server. + * + * Accepted forms: + * vox+http://host(:port)/(?token=TOKEN) + * vox+https://host(:port)/(?token=TOKEN) + * + * The DataSource returned is a minimal stub whose presence allows the Vox layer to detect + * selection of a remote source. The actual data flow is handled by the Vox layer and + * voxel_annotation chunk sources, which read the URL directly from the layer spec and pass + * serverUrl/token to the worker. + */ +export class VoxRemoteDataSourceProvider implements DataSourceProvider { + constructor(private readonly schemeName: "vox+http" | "vox+https") {} + + get scheme() { + return this.schemeName; + } + + get description() { + return this.schemeName === "vox+http" + ? "Vox remote server over HTTP" + : "Vox remote server over HTTPS"; + } + + async get(options: GetDataSourceOptions): Promise { + // Minimal identity transform; Vox layer supplies its own render transform. + const modelTransform = makeIdentityTransform(emptyValidCoordinateSpace); + return { + modelTransform, + canChangeModelSpaceRank: false, + subsources: [ + { + id: "default", + default: true, + // Leave `subsource` as an empty object to indicate a non-local provider. + // The Vox layer will further validate the URL scheme. + subsource: {}, + }, + ], + // Preserve the canonical URL for later inspection by the layer. + canonicalUrl: `${this.schemeName}://${options.providerUrl}`, + }; + } + + async completeUrl(options: CompleteUrlOptions) { + // Offer simple skeletons for host and optional token. + // Completion UI will prefix with the full scheme automatically. + const items = [ + { + value: "", + description: "Enter host[:port]/ optionally followed by ?token=...", + }, + { value: "localhost:8080/", description: "Local development server" }, + { value: "example.com/", description: "Production server" }, + { value: "example.com/?token=", description: "With token parameter" }, + ]; + return { + offset: 0, + completions: getPrefixMatchesWithDescriptions( + options.providerUrl, + items, + (x) => x.value, + (x) => x.description, + ), + }; + } +} diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index ff483ccb2a..eac257962f 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -441,6 +441,10 @@ export class VoxUserLayer extends UserLayer { voxBrushShape: "disk" | "sphere" = "disk"; private voxLoadedSubsource?: LoadedDataSubsource; + // Remote server configuration when using vox+http(s):// data sources + private voxServerUrl?: string; + private voxServerToken?: string; + // --- Label helpers --- private genId(): number { // Generate a unique uint32 per layer session. Try crypto.getRandomValues; fallback to Math.random. @@ -715,6 +719,46 @@ export class VoxUserLayer extends UserLayer { } } + private qsToken(token?: string) { + const usp = new URLSearchParams(); + if (token) usp.set("token", token); + const s = usp.toString(); + return s ? `?${s}` : ""; + } + + private parseVoxRemoteUrl(url: string): { scheme: string; baseUrl: string; token?: string } | undefined { + const m = url.match(/^(vox\+https?):\/\/(.+)$/); + if (!m) return undefined; + const scheme = m[1]; // vox+http or vox+https + const rest = m[2]; + // Build a temporary URL for parsing. Always ensure there is a protocol. + const proto = scheme.substring(4); // http or https + // If rest already contains a path/query, URL will parse it. + let tmp: URL; + try { + tmp = new URL(`${proto}://${rest}`); + } catch { + return undefined; + } + const baseUrl = `${proto}://${tmp.host}`; + const token = tmp.searchParams.get("token") || undefined; + return { scheme, baseUrl, token }; + } + + private async verifyVoxRemote(baseUrl: string, token?: string): Promise { + const q = this.qsToken(token); + const health = await fetch(`${baseUrl}/health${q}`, { + method: "GET", + credentials: "omit", + }); + if (!health.ok) throw new Error(`/health -> ${health.status}`); + const info = await fetch(`${baseUrl}/info${q}`, { + method: "GET", + credentials: "omit", + }); + if (!info.ok) throw new Error(`/info -> ${info.status}`); + } + private buildOrRebuildVoxLayer() { const ls = this.voxLoadedSubsource; if (!ls) return; @@ -738,6 +782,8 @@ export class VoxUserLayer extends UserLayer { chunkDataSize: new Uint32Array([64, 64, 64]), baseVoxelOffset: lower, upperVoxelBound: upper, + voxServerUrl: this.voxServerUrl, + voxToken: this.voxServerToken, }, ); // Expose a controller so tools can paint voxels via the source. @@ -765,6 +811,8 @@ export class VoxUserLayer extends UserLayer { upperVoxelBound: upperArr, unit: this.voxScaleUnit, scaleKey, + serverUrl: this.voxServerUrl, + token: this.voxServerToken, }); this.loadLabels(); @@ -822,14 +870,42 @@ export class VoxUserLayer extends UserLayer { for (const loadedSubsource of subsources) { const { subsourceEntry } = loadedSubsource; const { subsource } = subsourceEntry; - if (subsource.local === LocalDataSource.voxelAnnotations) { - // Accept this data source; remember it and build the layer from current settings. + const isLocalVox = subsource.local === LocalDataSource.voxelAnnotations; + const urlStr = loadedSubsource.loadedDataSource.layerDataSource.spec.url; + + if (isLocalVox) { + // Local in-memory vox datasource. + this.voxServerUrl = undefined; + this.voxServerToken = undefined; + this.voxMapId = "local"; this.voxLoadedSubsource = loadedSubsource; this.buildOrRebuildVoxLayer(); continue; } + + // Non-local: only accept vox+http(s) schemes. + const parsed = this.parseVoxRemoteUrl(urlStr); + if (parsed) { + // Verify the remote server before activation. + (async () => { + try { + await this.verifyVoxRemote(parsed.baseUrl, parsed.token); + this.voxServerUrl = parsed.baseUrl; + this.voxServerToken = parsed.token; + this.voxMapId = "remote"; + this.voxLoadedSubsource = loadedSubsource; + this.buildOrRebuildVoxLayer(); + } catch (e: any) { + const msg = `Vox remote source check failed: ${e?.message || e}`; + loadedSubsource.deactivate(msg); + } + })(); + continue; + } + + // Reject anything else. loadedSubsource.deactivate( - "Not compatible with vox layer; only local://voxel-annotations is supported", + "Not compatible with vox layer; supported sources: local://voxel-annotations, vox+http://host[:port]/(?token=TOKEN), vox+https://host[:port]/(?token=TOKEN)", ); } } @@ -841,5 +917,9 @@ registerLayerTypeDetector((subsource) => { if (subsource.local === LocalDataSource.voxelAnnotations) { return { layerConstructor: VoxUserLayer, priority: 100 }; } + // Accept non-local datasources at low priority to avoid interfering with other layers. + if (subsource.local === undefined) { + return { layerConstructor: VoxUserLayer, priority: 0 }; + } return undefined; }); diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index c7cde3d5b6..fd6b3c15ee 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -14,7 +14,8 @@ import { VOX_LABELS_SET_RPC_ID, } from "#src/voxel_annotation/base.js"; import type { VoxMapInitOptions } from "#src/voxel_annotation/index.js"; -import { LocalVoxSource, toScaleKey } from "#src/voxel_annotation/index.js"; +import { LocalVoxSource, RemoteVoxSource, toScaleKey } from "#src/voxel_annotation/index.js"; +import type { VoxSource } from "#src/voxel_annotation/index.js"; import type { RPC } from "#src/worker_rpc.js"; import { registerRPC, @@ -28,10 +29,19 @@ import { */ @registerSharedObject(VOX_CHUNK_SOURCE_RPC_ID) export class VoxChunkSource extends BaseVolumeChunkSource { - local = new LocalVoxSource(); + source: VoxSource; + private voxServerUrl?: string; + private voxToken?: string; constructor(rpc: RPC, options: any) { super(rpc, options); + // Detect remote server configuration from options (flexible keys) + const o = options || {}; + this.voxServerUrl = o.voxServerUrl || o.serverUrl || o.vox?.serverUrl; + this.voxToken = o.voxToken || o.token || o.vox?.token; + this.source = this.voxServerUrl + ? new RemoteVoxSource(this.voxServerUrl, this.voxToken) + : new LocalVoxSource(); } /** Initialize map metadata and persistence backend. */ @@ -43,7 +53,17 @@ export class VoxChunkSource extends BaseVolumeChunkSource { baseVoxelOffset?: number[]; unit?: string; scaleKey?: string; + serverUrl?: string; + token?: string; }) { + // Allow runtime override of server settings via init options + if (opts.serverUrl) this.voxServerUrl = opts.serverUrl; + if (opts.token) this.voxToken = opts.token; + // Swap source if configuration changed + this.source = this.voxServerUrl + ? new RemoteVoxSource(this.voxServerUrl, this.voxToken) + : new LocalVoxSource(); + const cds: number[] = Array.from( opts.chunkDataSize ?? Array.from(this.spec.chunkDataSize), ); @@ -67,7 +87,7 @@ export class VoxChunkSource extends BaseVolumeChunkSource { unit: opts.unit, scaleKey, } satisfies VoxMapInitOptions; - return await this.local.init(initOpts); + return await this.source.init(initOpts); } /** Commit voxel edits from the frontend. */ @@ -80,7 +100,7 @@ export class VoxChunkSource extends BaseVolumeChunkSource { size?: number[]; }[], ) { - await this.local.applyEdits(edits); + await this.source.applyEdits(edits); } async download(chunk: VolumeChunk, signal: AbortSignal): Promise { @@ -93,7 +113,7 @@ export class VoxChunkSource extends BaseVolumeChunkSource { // Always produce a typed array matching the spec type; MVP uses UINT32 const array = this.allocateTypedArray(this.spec.dataType, total, 0); // Load saved chunk if present and copy overlapping region - const saved = await this.local.getSavedChunk(key); + const saved = await this.source.getSavedChunk(key); if (saved) { const sxS = saved.size[0], syS = saved.size[1], @@ -162,12 +182,12 @@ registerPromiseRPC( VOX_LABELS_GET_RPC_ID, async function (x: any): Promise { const obj = this.get(x.rpcId) as VoxChunkSource; - const ids = await obj.local.getLabelIds(); + const ids = await obj.source.getLabelIds(); return { value: ids }; }, ); registerRPC(VOX_LABELS_SET_RPC_ID, function (x: any) { const obj = this.get(x.id) as VoxChunkSource; - obj.local.setLabelIds(Array.isArray(x?.ids) ? x.ids : []); + obj.source.setLabelIds(Array.isArray(x?.ids) ? x.ids : []); }); diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index e9ef8df6b7..eb4436fa38 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -24,6 +24,11 @@ import { registerSharedObjectOwner } from "#src/worker_rpc.js"; */ @registerSharedObjectOwner(VOX_CHUNK_SOURCE_RPC_ID) export class VoxChunkSource extends BaseVolumeChunkSource { + declare OPTIONS: { + spec: VolumeChunkSpecification; + vox?: { serverUrl?: string; token?: string }; + }; + private voxOptions?: { serverUrl?: string; token?: string }; private tempVoxChunkGridPosition = new Float32Array(3); private tempLocalPosition = new Uint32Array(3); private dirtyChunks = new Set(); @@ -53,9 +58,32 @@ export class VoxChunkSource extends BaseVolumeChunkSource { constructor( chunkManager: ChunkManager, - options: { spec: VolumeChunkSpecification }, + options: { spec: VolumeChunkSpecification; vox?: { serverUrl?: string; token?: string } }, ) { super(chunkManager, options); + this.voxOptions = options.vox; + } + + override initializeCounterpart(rpc: any, options: any) { + const opts = { ...(options || {}), spec: this.spec }; + if (this.voxOptions) { + (opts as any).vox = { ...this.voxOptions }; + } + super.initializeCounterpart(rpc, opts); + } + + static override encodeOptions(options: { + spec: VolumeChunkSpecification; + vox?: { serverUrl?: string; token?: string }; + }) { + const base = (BaseVolumeChunkSource as any).encodeOptions(options); + if (options?.vox) { + (base as any).vox = { + serverUrl: options.vox.serverUrl, + token: options.vox.token, + }; + } + return base; } async getLabelIds(): Promise { diff --git a/src/voxel_annotation/index.ts b/src/voxel_annotation/index.ts index 5112834f0c..863ca02baa 100644 --- a/src/voxel_annotation/index.ts +++ b/src/voxel_annotation/index.ts @@ -3,6 +3,8 @@ * The LocalVoxSource persists per-chunk arrays into IndexedDB with a debounced saver. */ +import { DataType } from "#src/util/data_type.js"; + export interface VoxMapInitOptions { mapId?: string; dataType?: number; @@ -14,7 +16,7 @@ export interface VoxMapInitOptions { } export interface SavedChunk { - data: Uint32Array; // MVP stores UINT32 labels + data: Uint32Array | BigUint64Array; // Supports UINT32 and UINT64 size: Uint32Array; // canonical size used for linearization (usually spec.chunkDataSize) } @@ -117,6 +119,22 @@ export abstract class VoxSource { // Overridden by subclass to actually persist dirty chunks. protected async flushSaves(): Promise {} + // Abstract persistence API the backend expects + abstract getSavedChunk(key: string): Promise; + abstract ensureChunk( + key: string, + size?: Uint32Array | number[], + ): Promise; + abstract applyEdits( + edits: { + key: string; + indices: ArrayLike; + value?: number; + values?: ArrayLike; + size?: number[]; + }[], + ): Promise; + // Apply edits into an in-memory chunk array; returns the SavedChunk. protected applyEditsIntoChunk( sc: SavedChunk, @@ -124,16 +142,21 @@ export abstract class VoxSource { value?: number, values?: ArrayLike, ) { - const dst = sc.data; + const dst = sc.data as any; + const is64 = dst instanceof BigUint64Array; if (values != null) { const vv = values as ArrayLike; const n = Math.min((indices as any).length ?? 0, (vv as any).length ?? 0); for (let i = 0; i < n; ++i) { const idx = (indices as any)[i] | 0; - if (idx >= 0 && idx < dst.length) dst[idx] = (vv as any)[i] >>> 0; + if (idx >= 0 && idx < dst.length) { + const v = (vv as any)[i] >>> 0; + dst[idx] = is64 ? BigInt(v) : v; + } } } else if (value != null) { - const v = value >>> 0; + const vNum = value >>> 0; + const v = (is64 ? BigInt(vNum) : vNum) as any; const n = (indices as any).length ?? 0; for (let i = 0; i < n; ++i) { const idx = (indices as any)[i] | 0; @@ -324,18 +347,191 @@ export class LocalVoxSource extends VoxSource { export class RemoteVoxSource extends VoxSource { private labelsCache: number[] = []; - constructor(public url: string) { + private baseUrl: string; + private token?: string; + + constructor(url: string, token?: string) { super(); + this.baseUrl = url.replace(/\/$/, ""); + this.token = token; + } + + // ---- Public API overrides ---- + override async init(opts: VoxMapInitOptions) { + const meta = await super.init(opts); + // Bind dtype string + const dtypeStr = this.dtypeToString(this.dataType); + // Call /init (best-effort; server may already have it) + const qs = this.qs({ + mapId: this.mapId, + scaleKey: this.scaleKey, + dtype: dtypeStr, + }); + try { + await this.httpGet(`${this.baseUrl}/init${qs}`); + } catch { + // ignore + } + return meta; + } + + async getSavedChunk(key: string): Promise { + const existing = this.saved.get(key); + if (existing) return existing; + const qs = this.qs({ mapId: this.mapId, chunkKey: key }); + try { + const buf = await this.httpGetArrayBuffer(`${this.baseUrl}/chunk${qs}`); + if (!buf) return undefined; + const arr = this.makeTypedArrayFromBuffer(buf); + const sc: SavedChunk = { data: arr, size: new Uint32Array(this.chunkDataSize) }; + this.saved.set(key, sc); + this.enforceCap(); + return sc; + } catch (e: any) { + // 404 → not found + return undefined; + } + } + + async ensureChunk(key: string, size?: Uint32Array | number[]): Promise { + let sc = this.saved.get(key); + if (sc) return sc; + sc = await this.getSavedChunk(key); + if (sc) return sc; + // allocate zero-filled + const sz = new Uint32Array(size ?? this.chunkDataSize); + const total = (sz[0] | 0) * (sz[1] | 0) * (sz[2] | 0); + const data = this.allocateTypedArray(total); + sc = { data, size: new Uint32Array(sz) }; + this.saved.set(key, sc); + this.enforceCap(); + this.markDirty(key); + return sc; } + + async applyEdits( + edits: { + key: string; + indices: ArrayLike; + value?: number; + values?: ArrayLike; + size?: number[]; + }[], + ) { + for (const e of edits) { + const sc = await this.ensureChunk( + e.key, + e.size ? new Uint32Array(e.size) : this.chunkDataSize, + ); + this.applyEditsIntoChunk(sc, e.indices, e.value, e.values); + this.markDirty(e.key); + } + } + + protected override async flushSaves() { + const keys = Array.from(this.dirty); + if (keys.length === 0) { + this.saveTimer = undefined; + return; + } + this.dirty.clear(); + for (const key of keys) { + const sc = this.saved.get(key); + if (!sc) continue; + const qs = this.qs({ mapId: this.mapId, chunkKey: key }); + try { + await this.httpPutArrayBuffer(`${this.baseUrl}/chunk${qs}`, sc.data as any); + } catch (e) { + // If failed, keep dirty to retry later + this.dirty.add(key); + } + } + this.saveTimer = undefined; + } + + // ---- Helpers ---- + private qs(params: Record) { + const usp = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v === undefined || v === null) continue; + usp.set(k, String(v)); + } + if (this.token) usp.set("token", this.token); + const s = usp.toString(); + return s ? `?${s}` : ""; + } + + private dtypeToString(dt: number): "uint32" | "uint64" { + return dt === DataType.UINT64 ? "uint64" : "uint32"; + } + + private allocateTypedArray(total: number): Uint32Array | BigUint64Array { + if (this.dataType === DataType.UINT64) return new BigUint64Array(total); + return new Uint32Array(total); + } + + private makeTypedArrayFromBuffer(buf: ArrayBuffer): Uint32Array | BigUint64Array { + if (this.dataType === DataType.UINT64) return new BigUint64Array(buf); + return new Uint32Array(buf); + } + + private async httpGet(url: string): Promise { + const res = await fetch(url, { method: "GET", credentials: "omit" }); + if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`); + return res; + } + + private async httpGetArrayBuffer(url: string): Promise { + const res = await fetch(url, { method: "GET", credentials: "omit" }); + if (res.status === 404) return undefined; + if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`); + return await res.arrayBuffer(); + } + + private async httpPutArrayBuffer( + url: string, + body: ArrayBufferLike | ArrayBufferView, + ): Promise { + // Ensure we pass an ArrayBufferView to satisfy fetch BodyInit typing across platforms. + let payload: ArrayBufferView; + if (body instanceof ArrayBuffer) { + payload = new Uint8Array(body); + } else if ((body as any).buffer && (body as any).byteLength !== undefined) { + payload = body as ArrayBufferView; + } else { + payload = new Uint8Array(body as ArrayBufferLike); + } + const res = await fetch(url, { + method: "PUT", + body: payload as any, + headers: { "Content-Type": "application/octet-stream" }, + credentials: "omit", + }); + if (!res.ok) throw new Error(`PUT ${url} -> ${res.status}`); + } + + // Keep labels local (in-memory) unless remote endpoints are added later override async getLabelIds(): Promise { - // Placeholder: if a remote endpoint exists, fetch from `${url}/labels` with map/scale. - // For now, return in-memory cache. return Array.from(this.labelsCache); } override async setLabelIds(ids: number[]): Promise { - // Placeholder: post to remote endpoint; cache locally as best-effort. this.labelsCache = ids.map((v) => v >>> 0); } + + // LRU-style cap similar to LocalVoxSource + private enforceCap() { + while (this.saved.size > this.maxSavedChunks) { + let oldestKey: string | undefined; + for (const k of this.saved.keys()) { + if (!this.dirty.has(k)) { + oldestKey = k; + break; + } + } + if (oldestKey === undefined) break; + this.saved.delete(oldestKey); + } + } } export function openVoxDb(): Promise { diff --git a/src/voxel_annotation/volume_chunk_source.ts b/src/voxel_annotation/volume_chunk_source.ts index 90e7063113..dadb92d993 100644 --- a/src/voxel_annotation/volume_chunk_source.ts +++ b/src/voxel_annotation/volume_chunk_source.ts @@ -41,6 +41,8 @@ export interface VoxMultiscaleOptions { chunkDataSize?: Uint32Array | number[]; upperVoxelBound?: Float32Array | number[]; baseVoxelOffset?: Float32Array | number[]; + voxServerUrl?: string; + voxToken?: string; } export class VoxMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSource { @@ -53,6 +55,8 @@ export class VoxMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSource private cfgChunkDataSize: Uint32Array; private cfgUpperVoxelBound: Float32Array; private cfgBaseVoxelOffset: Float32Array; + private voxServerUrl?: string; + private voxToken?: string; constructor(chunkManager: ChunkManager, options?: VoxMultiscaleOptions) { super(chunkManager); @@ -69,6 +73,8 @@ export class VoxMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSource ? Array.from(options.baseVoxelOffset) : [0, 0, 0], ); + this.voxServerUrl = options?.voxServerUrl; + this.voxToken = options?.voxToken; } getSources(_options: VolumeSourceOptions) { @@ -85,7 +91,7 @@ export class VoxMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSource }); const baseSource: VoxChunkSource = this.chunkManager.getChunkSource( VoxChunkSource as any, - { spec: baseSpec }, + { spec: baseSpec, vox: { serverUrl: this.voxServerUrl, token: this.voxToken } }, ); // Identity transform for base scale. @@ -116,7 +122,7 @@ export class VoxMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSource const guardSource: VoxChunkSource = this.chunkManager.getChunkSource( VoxChunkSource as any, - { spec: guardSpec }, + { spec: guardSpec, vox: { serverUrl: this.voxServerUrl, token: this.voxToken } }, ); // Large diagonal scale to make effective voxel size huge, ensuring guard scale is used when From 32d7b7225bde7645c6b1db89ea092e5b3e2c503b Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 027/251] feat: replace `setLabelIds` with `addLabel` for label management - Revamp label persistence to use `addLabel` method for atomic label addition. - Update RPC handling with the new `vox.labels.add` RPC ID. - Refactor frontend and backend to replace `setLabelIds` with `addLabel`. - Enhance error handling in label creation and loading workflows. - Modify UI to display error messages for label-related issues. - Update `LocalVoxSource` and `RemoteVoxSource` for incremental label updates. --- NOTES/TODOs.md | 5 ++ src/layer/vox/index.ts | 111 +++++++++++++++--------- src/voxel_annotation/backend.ts | 10 ++- src/voxel_annotation/base.ts | 2 +- src/voxel_annotation/edit_controller.ts | 12 ++- src/voxel_annotation/frontend.ts | 15 ++-- src/voxel_annotation/index.ts | 64 ++++++++++---- 7 files changed, 140 insertions(+), 79 deletions(-) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index 217e625661..eca46af1f7 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,10 +1,15 @@ # TODO List +- Add redundancy to avoid corrupt/unsaved chunks on the remote +- Test token authentication +- Try to import pre-computed segmentation (in zarr format) into the remote server - continue to study the segmentation compression, using it should greatly reduce the ram and indexDB usage, but it no easy integration of the hot chunk reloading in the frontend for drawing tool responsiveness has been found. - add flood fill tool (with a max expansion safeguard), this tool should be 2d (e.g. act on a plane, the plane normal to the z axis is sufficient for a v1) - Fix the orientation of the disk in the brush tool - the uncaching of chunks the VoxSource is working great, but since it has no way of knowing which chunks are in view, it will delete them, causing flickering of the drawings. +- look into the massive ram usage when a lot of voxel annotations are drawn - Add Uint64 support for annotation id +- LOD # Saving/importing/exporting diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index eac257962f..c5adcffc80 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -180,6 +180,7 @@ class VoxToolTab extends Tab { this.renderLabels(); } private labelsContainer!: HTMLDivElement; + private labelsError!: HTMLDivElement; private renderLabels() { const cont = this.labelsContainer; cont.innerHTML = ""; @@ -223,6 +224,15 @@ class VoxToolTab extends Tab { }); cont.appendChild(row); } + // Update error message area + const err = this.layer.voxLabelsError; + if (err && err.length > 0) { + this.labelsError.textContent = err; + this.labelsError.style.display = "block"; + } else { + this.labelsError.textContent = ""; + this.labelsError.style.display = "none"; + } } constructor(public layer: VoxUserLayer) { super(); @@ -398,9 +408,17 @@ class VoxToolTab extends Tab { this.labelsContainer.style.maxHeight = "180px"; this.labelsContainer.style.overflowY = "auto"; + this.labelsError = document.createElement("div"); + this.labelsError.className = "neuroglancer-vox-labels-error"; + this.labelsError.style.color = "#b00020"; // Material red 700-ish + this.labelsError.style.fontSize = "12px"; + this.labelsError.style.whiteSpace = "pre-wrap"; + this.labelsError.style.display = "none"; + labelsSection.appendChild(labelsTitle); labelsSection.appendChild(buttonsRow); labelsSection.appendChild(this.labelsContainer); + labelsSection.appendChild(this.labelsError); toolbox.appendChild(labelsSection); @@ -417,6 +435,9 @@ export class VoxUserLayer extends UserLayer { // Label state for painting: only store ids; colors are hashed from id on the fly voxLabels: { id: number }[] = []; voxSelectedLabelId: number | undefined = undefined; + voxLabelsError: string | undefined = undefined; + // Indicates whether an initial labels load attempt has completed. + private voxLabelsInitialized: boolean = false; segmentColorHash = SegmentColorHash.getDefault(); // Match Image/Segmentation layers: provide a per-layer cross-section render scale target/histogram. sliceViewRenderScaleHistogram = new RenderScaleHistogram(); @@ -472,40 +493,31 @@ export class VoxUserLayer extends UserLayer { } // --- Labels persistence (via VoxSource) --- - private async saveLabels() { - try { - const ids = this.voxLabels.map((l) => l.id >>> 0); - await this.voxEditController?.setLabelIds(ids); - } catch { - // ignore persistence failures - } - } private async loadLabels() { try { const arr = await this.voxEditController?.getLabelIds(); - const existing = new Set(this.voxLabels.map((l) => l.id >>> 0)); - if (arr && Array.isArray(arr) && arr.length > 0) { - // Merge previously created labels (before init) with stored ones. - const mergedIds = new Set(arr.map((id) => id >>> 0)); - for (const id of existing) mergedIds.add(id); - this.voxLabels = Array.from(mergedIds).map((id) => ({ id })); - // Ensure selected label is valid - const sel = this.voxSelectedLabelId; - if (!sel || !this.voxLabels.some((l) => l.id === sel)) { - this.voxSelectedLabelId = this.voxLabels[0].id; + if (arr && Array.isArray(arr)) { + if (arr.length > 0) { + this.voxLabels = arr.map((id) => ({ id: id >>> 0 })); + const sel = this.voxSelectedLabelId; + if (!sel || !this.voxLabels.some((l) => l.id === sel)) { + this.voxSelectedLabelId = this.voxLabels[0].id; + } + } else { + this.voxLabels = []; + this.voxSelectedLabelId = undefined; } - // Write back merged set to keep in sync. - await this.saveLabels(); } else { - // Nothing stored: if any labels were created pre-init, persist them; otherwise, create one. - if (this.voxLabels.length === 0) this.ensureDefaultLabel(); - await this.saveLabels(); + throw new Error("Invalid labels response"); } - } catch { - // Fallback to default if load fails - if (this.voxLabels.length === 0) this.ensureDefaultLabel(); + } catch (e: any) { + const msg = `Failed to load labels: ${e?.message || e}`; + console.error(msg); + this.voxLabelsError = msg; + // Do NOT create a default label on error. } finally { - // Ensure UI reflects the loaded/merged labels. + // Mark labels as initialized; UI/painting should not trigger default creation before this point. + this.voxLabelsInitialized = true; try { this.onLabelsChanged?.(); } catch { @@ -514,27 +526,35 @@ export class VoxUserLayer extends UserLayer { } } - ensureDefaultLabel() { - if (this.voxLabels.length > 0) return; - this.createVoxLabel(); - } async createVoxLabel() { const id = this.genId(); // unique uint32 - this.voxLabels.push({ id }); - this.voxSelectedLabelId = id; - // Persist immediately once the source/controller is available. - if (this.voxEditController) { + if (!this.voxEditController) { + const msg = "Labels backend not ready; please try again after source initializes."; + console.error(msg); + this.voxLabelsError = msg; + return; + } + try { + const updated = await this.voxEditController.addLabel(id); + this.voxLabels = updated.map((x) => ({ id: x >>> 0 })); + // Prefer to select the last label from the updated list (likely the one just added). + const last = this.voxLabels[this.voxLabels.length - 1]?.id; + this.voxSelectedLabelId = last ?? id; + this.voxLabelsError = undefined; try { - await this.saveLabels(); + this.onLabelsChanged?.(); + } catch { + /* ignore */ + } + } catch (e: any) { + const msg = `Failed to create label: ${e?.message || e}`; + console.error(msg); + this.voxLabelsError = msg; + try { + this.onLabelsChanged?.(); } catch { /* ignore */ } - } - // Notify UI to re-render labels list whenever a label is created. - try { - this.onLabelsChanged?.(); - } catch { - /* ignore */ } } selectVoxLabel(id: number) { @@ -543,7 +563,12 @@ export class VoxUserLayer extends UserLayer { } getCurrentLabelValue(): number { if (this.voxEraseMode) return 0; - if (!this.voxSelectedLabelId) this.ensureDefaultLabel(); + // Avoid triggering default creation during initialization. + if (!this.voxLabelsInitialized) return 0; + // Ensure we have a valid selection if labels exist. + if (!this.voxSelectedLabelId && this.voxLabels.length > 0) { + this.voxSelectedLabelId = this.voxLabels[0].id; + } const cur = this.voxLabels.find((l) => l.id === this.voxSelectedLabelId) || this.voxLabels[0]; diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index fd6b3c15ee..f1ca09d530 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -11,7 +11,7 @@ import { VOX_COMMIT_VOXELS_RPC_ID, VOX_MAP_INIT_RPC_ID, VOX_LABELS_GET_RPC_ID, - VOX_LABELS_SET_RPC_ID, + VOX_LABELS_ADD_RPC_ID, } from "#src/voxel_annotation/base.js"; import type { VoxMapInitOptions } from "#src/voxel_annotation/index.js"; import { LocalVoxSource, RemoteVoxSource, toScaleKey } from "#src/voxel_annotation/index.js"; @@ -187,7 +187,9 @@ registerPromiseRPC( }, ); -registerRPC(VOX_LABELS_SET_RPC_ID, function (x: any) { - const obj = this.get(x.id) as VoxChunkSource; - obj.source.setLabelIds(Array.isArray(x?.ids) ? x.ids : []); + +registerPromiseRPC(VOX_LABELS_ADD_RPC_ID, async function (x: any) { + const obj = this.get(x.rpcId) as VoxChunkSource; + const ids = await obj.source.addLabel(x?.value >>> 0); + return { value: ids }; }); diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index d79312b707..af8145e7b1 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -2,4 +2,4 @@ export const VOX_CHUNK_SOURCE_RPC_ID = "vox.VoxChunkSource"; export const VOX_COMMIT_VOXELS_RPC_ID = "vox.commitVoxels"; export const VOX_MAP_INIT_RPC_ID = "vox.map.init"; export const VOX_LABELS_GET_RPC_ID = "vox.labels.get"; -export const VOX_LABELS_SET_RPC_ID = "vox.labels.set"; +export const VOX_LABELS_ADD_RPC_ID = "vox.labels.add"; diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 692ac013e2..c9580c6d11 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -126,12 +126,10 @@ export class VoxelEditController { } } - setLabelIds(ids: number[]) { - try { - const source = this.getSource(); - source?.setLabelIds(ids); - } catch { - // ignore - } + + async addLabel(value: number): Promise { + const source = this.getSource(); + if (!source) throw new Error("Voxel source not ready"); + return await source.addLabel(value >>> 0); } } diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index eb4436fa38..e464dfcdd5 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -15,7 +15,7 @@ import { VOX_COMMIT_VOXELS_RPC_ID, VOX_MAP_INIT_RPC_ID, VOX_LABELS_GET_RPC_ID, - VOX_LABELS_SET_RPC_ID, + VOX_LABELS_ADD_RPC_ID, } from "#src/voxel_annotation/base.js"; import { registerSharedObjectOwner } from "#src/worker_rpc.js"; @@ -99,12 +99,13 @@ export class VoxChunkSource extends BaseVolumeChunkSource { } } - setLabelIds(ids: number[]) { - try { - this.rpc!.invoke(VOX_LABELS_SET_RPC_ID, { id: this.rpcId, ids }); - } catch { - // ignore - } + + async addLabel(value: number): Promise { + // Do not swallow errors; caller should display them to the user and avoid UI updates on failure. + return await this.rpc!.promiseInvoke(VOX_LABELS_ADD_RPC_ID, { + rpcId: this.rpcId, + value, + }); } private scheduleUpdate(key: string) { diff --git a/src/voxel_annotation/index.ts b/src/voxel_annotation/index.ts index 863ca02baa..a7af3ca04a 100644 --- a/src/voxel_annotation/index.ts +++ b/src/voxel_annotation/index.ts @@ -67,8 +67,9 @@ export abstract class VoxSource { async getLabelIds(): Promise { return []; } - async setLabelIds(_ids: number[]): Promise { - /* no-op */ + async addLabel(_value: number): Promise { + // Default: pretend success with no labels + return []; } init(_opts: VoxMapInitOptions): Promise<{ mapId: string; scaleKey: string }> { @@ -183,18 +184,18 @@ export class LocalVoxSource extends VoxSource { } } - override async setLabelIds(ids: number[]): Promise { - try { - const db = await this.getDb(); - const tx = db.transaction("labels", "readwrite"); - const store = tx.objectStore("labels"); - const key = compositeLabelsDbKey(this.mapId, this.scaleKey); - const payload = ids.map((v) => v >>> 0); - await idbPut(store, payload, key); - await txDone(tx); - } catch { - // ignore - } + + override async addLabel(value: number): Promise { + const v = value >>> 0; + const db = await this.getDb(); + const key = compositeLabelsDbKey(this.mapId, this.scaleKey); + const arr = (await idbGet(db, "labels", key)) || []; + // Ensure uniqueness + if (!arr.some((x) => (x >>> 0) === v)) arr.push(v); + const tx = db.transaction("labels", "readwrite"); + await idbPut(tx.objectStore("labels"), arr.map((x) => x >>> 0), key); + await txDone(tx); + return arr.map((x) => x >>> 0); } private touch(key: string) { @@ -510,12 +511,41 @@ export class RemoteVoxSource extends VoxSource { if (!res.ok) throw new Error(`PUT ${url} -> ${res.status}`); } - // Keep labels local (in-memory) unless remote endpoints are added later + private async httpGetJson(url: string): Promise { + const res = await fetch(url, { method: "GET", credentials: "omit" }); + if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`); + return await res.json(); + } + + private async httpPutJson(url: string, body: any): Promise { + const res = await fetch(url, { + method: "PUT", + body: typeof body === "string" ? body : JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + credentials: "omit", + }); + if (!res.ok) throw new Error(`PUT ${url} -> ${res.status}`); + return await res.json(); + } + + // --- Labels via remote server endpoints --- override async getLabelIds(): Promise { + const qs = this.qs({ mapId: this.mapId }); + const json = await this.httpGetJson(`${this.baseUrl}/labels${qs}`); + const arr = Array.isArray(json?.labels) ? json.labels : []; + this.labelsCache = arr.map((v: any) => (v as number) >>> 0); return Array.from(this.labelsCache); } - override async setLabelIds(ids: number[]): Promise { - this.labelsCache = ids.map((v) => v >>> 0); + + + override async addLabel(value: number): Promise { + const v = value >>> 0; + // If dtype is UINT64 we still send a 32-bit value; server must accept as valid subset. -> TODO: no + const qs = this.qs({ mapId: this.mapId }); + const json = await this.httpPutJson(`${this.baseUrl}/labels${qs}`, { value: v }); + const arr = Array.isArray(json?.labels) ? json.labels : []; + this.labelsCache = arr.map((x: any) => (x as number) >>> 0); + return Array.from(this.labelsCache); } // LRU-style cap similar to LocalVoxSource From 904591ee72814332a6694368ce63f46fda0b0bcc Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 028/251] doc: add guidelines for junie and write project overview file --- .junie/guidelines.md | 1 + NOTES/backend.md | 2 + NOTES/vox-annotation-project-overview.md | 268 +++++++++++++++++++++++ 3 files changed, 271 insertions(+) create mode 100644 .junie/guidelines.md create mode 100644 NOTES/vox-annotation-project-overview.md diff --git a/.junie/guidelines.md b/.junie/guidelines.md new file mode 100644 index 0000000000..32c2b3bd73 --- /dev/null +++ b/.junie/guidelines.md @@ -0,0 +1 @@ +You are here to help me implement a new voxel annotation feature into neuroglancer. See [vox-annotation-project-overview.md](../NOTES/vox-annotation-project-overview.md) for complete project details. diff --git a/NOTES/backend.md b/NOTES/backend.md index aa911ac58d..6af0bed5bf 100644 --- a/NOTES/backend.md +++ b/NOTES/backend.md @@ -82,6 +82,8 @@ - Response: `{ status: "ok" }` on success. - GET `/health` - Response: `200 OK` if the server is ready and can reach the storage. +- `GET /labels?mapId=` → JSON `{ "labels": [ ... ] }` for that dataset. Labels are stored as a list and must fit the dataset dtype (uint32/uint64). +- `PUT /labels?mapId=` → Add a label. Body can be a JSON number, `{ "value": }`, or a plain text integer. Returns updated `{ "labels": [...] }`. scale key calculation: ```ts diff --git a/NOTES/vox-annotation-project-overview.md b/NOTES/vox-annotation-project-overview.md new file mode 100644 index 0000000000..944feeed3d --- /dev/null +++ b/NOTES/vox-annotation-project-overview.md @@ -0,0 +1,268 @@ +# Vox Annotation Project — Motivation, Vision, State, and Roadmap + +Updated: 2025-09-12 10:55 (local) + +TL;DR +- Goal: A performant voxel annotation workflow in Neuroglancer where users can paint integer labels directly on the voxel grid with smooth UX, predictable storage, and optional collaboration via a simple HTTP server. +- Today: A working Vox layer with pixel/brush tools, immediate visual feedback, debounced persistence to IndexedDB, and optional remote save/load via a Zarr-based HTTP server design. Label lists can be created/managed. Rendering integrates with existing sliceview/volume infrastructure. +- Next: Add flood-fill, LOD/downsampling pipeline, compression, better caching and memory bounds, improved remote features (auth, multi-user?), and export/import tools. + + +1) Motivation +- Traditional Neuroglancer annotations are vector-based (points, segments, etc.). They are great for geometry-centric workflows but not for dense voxel labeling required in ML training, segmentation curation, and painting workflows. +- Need: A voxel-aligned labeling system that: + - Writes label IDs into a 3D grid (uint32/uint64), lives nicely with multiscale viewing. + - Feels responsive: edits appear immediately; saving is asynchronous and robust. + - Can work offline (local browser storage) and switch to online collaboration (HTTP API over Zarr) without major UI changes. + + +2) Vision and Principles +- Ergonomic painting: + - Pixel and brush tools as baseline; flood-fill and eraser next; plane-aware disk brush by default, spherical brush optionally. + - Label palette that maps integers to colors deterministically. +- Performance and scalability: + - Chunked editing and streaming via standard sliceview/volume system. + - Immediate local overlay/display + debounced background persistence to avoid UI stalls. + - Multiscale integration for zoomed-out views with downsampling over time. +- Portability and openness: + - Zarr v2 layout for persistent storage and an HTTP server spec compatible with CDNs/object stores. + - Simple security: magic-link auth in MVP. +- Extensibility: + - Clean separation of frontend UI, worker-side authoritative state, and persistence backends (IndexedDB or HTTP server). + - Future multi-user support following doc-edit style concurrency (last-writer-wins MVP, richer models later). + + +3) Current State (What works now) +3.1 Layer, tools, and interaction +- Vox layer type with settings and tool tabs: src/layer/vox/index.ts + - UI for choosing scale/region (voxel bounds), brush size/shape, eraser mode, remote URL/token parsing, and label selection. + - Hooks to rebuild sources when settings change. +- Tools: src/ui/voxel_annotations.ts + - Pixel tool (VoxelPixelLegacyTool): line interpolation to fill continuous strokes. + - Brush tool (VoxelBrushLegacyTool): disk (aligned to slice plane) or sphere; configurable radius; oriented-disk uses the current slice basis when available. + - Tools call VoxelEditController which routes edits to the vox chunk source. +- Edit controller: src/voxel_annotation/edit_controller.ts + - paintVoxelsBatch for arbitrary point lists. + - paintBrushWithShape for disk/sphere brush generation with plane orientation support. + +3.2 Data flow and chunk sources +- Multiscale source: src/voxel_annotation/volume_chunk_source.ts + - Returns a base scale with real bounds and a coarse “guard” scale (empty bounds but huge voxel transform) to prevent extreme-zoom memory blow-ups. + - DataType = UINT32, VolumeType = SEGMENTATION, rank = 3. + - Passes optional vox serverUrl/token to worker. +- Frontend chunk owner: src/voxel_annotation/frontend.ts (class VoxChunkSource) + - Pairs with a worker counterpart via RPC type id VOX_CHUNK_SOURCE_RPC_ID. + - Local optimistic edit path: paintVoxelsBatch computes the chunk/local indices, writes into the CPU array if present, and invalidates GPU uploads per chunk to achieve immediate visual updates. + - Sends batched edit RPCs (VOX_COMMIT_VOXELS_RPC_ID) to backend with {key, indices, value, size}. + - Map initialization RPC (VOX_MAP_INIT_RPC_ID): best-effort init of worker storage and metadata. + - Label APIs: VOX_LABELS_GET_RPC_ID, VOX_LABELS_ADD_RPC_ID. +- Backend counterpart: src/voxel_annotation/backend.ts (class VoxChunkSource) + - Chooses a persistence backend: + - LocalVoxSource for IndexedDB (default). + - RemoteVoxSource when serverUrl/token supplied (HTTP). + - download(...) computes chunk bounds, returns a typed array matching spec dtype, and overlays any saved chunk data for in-bounds region (merges saved content into the allocated array). + - commitVoxels applies batched edits into the authoritative source; saving is debounced. + +3.3 Persistence backends (authoritative state in worker) +- Shared helper/types: src/voxel_annotation/index.ts + - toScaleKey(chunkDataSize, baseVoxelOffset, upperVoxelBound). + - compositeChunkDbKey(mapId, scaleKey, chunkKey) and compositeLabelsDbKey. + - VoxSource abstract base managing: mapId, scaleKey, chunkDataSize, base/upper bounds, dtype, unit, in-memory LRU cache, dirty set, and debounced flush (≈750ms). + - applyEditsIntoChunk supports both single value and per-index values arrays; typed arrays switch (Uint32 vs BigUint64 for future UINT64 support). +- LocalVoxSource (IndexedDB): + - IDB stores: maps (metadata), chunks (ArrayBuffer per chunk), labels (label list). + - Debounced flush writes dirty chunks; in-memory LRU avoids unbounded growth; avoids evicting dirty entries. + - Label persistence: getLabelIds, addLabel ensure id uniqueness. +- RemoteVoxSource (HTTP): + - Base URL + optional token; GET /chunk?mapId&chunkKey returns bytes or 404 for missing; PUT /chunk persists bytes. + - Map init: best-effort GET /init?mapId&scaleKey&dtype. + - Label endpoints: GET/PUT /labels. + - Maintains a small LRU and reuses the same debounced flush policy; failed PUT keeps keys dirty for retry. + +3.4 Rendering +- Custom render layer: src/voxel_annotation/renderlayer.ts + - Extends SliceViewVolumeRenderLayer and colors non-zero labels via SegmentColorHash; zero is transparent with alpha 0; non-zero alpha ≈ 0.5. + - Uses standard data sampling hooks (getDataValue/getUint64DataValue path) so real chunk data shows; includes helpful shader build error logging. + +3.5 Remote URL provider +- src/datasource/vox_remote.ts + - Provides a DataSource for vox+http(s):// URLs used by the Vox layer; mainly a stub that allows the layer to detect a remote source and pass URL/token to worker. + +3.6 Labels UI state +- Layer wires a simple label list; frontend/backend support getting and adding labels. Rendering maps label ids to colors via hashing; there is no named palette UI yet (hash-based is deterministic). + + +4) Storage and API (Backend) +4.1 Zarr-based HTTP server (MVP) +- See NOTES/backend.md for full spec. Summary: + - Zarr v2, arrays per scale 0/, 1/, ... under a root with NGFF multiscales. + - Missing chunk => fill_value (0) semantics. + - Chunk addressing at 0/ix/iy/iz. +- Key endpoints: + - GET /info → dataset metadata union (.zattrs + .zarray summaries) + publicBase. + - GET /chunk?mapId&chunkKey → raw bytes of a full chunk (padded at edges). + - PUT /chunk?mapId&chunkKey → raw bytes; writes in-bounds region for edge chunks; last-writer-wins. + - GET /init?mapId&scaleKey&dtype → initialize new map metadata. + - GET /labels?mapId → { labels: number[] }. + - PUT /labels?mapId → { labels: number[] } (adds new label id). +- Non-functional (MVP): + - CORS for configured origins; simple metrics; health checks; magic-link auth; local single-node Docker Compose with MinIO (S3-compatible) for development. + +4.2 Scale key and chunk key +- Scale key format used throughout (frontend and backend helpers): + - toScaleKey(chunkDataSize, baseVoxelOffset, upperVoxelBound) → "cx_cy_cz:lx_ly_lz-ux_uy_uz" (e.g., 64_64_64:0_0_0-1024_1024_1024). +- Chunk key: + - toChunkKey([cx, cy, cz]) → "cx,cy,cz" (e.g., 0,0,0). + + +5) Codebase Map (vox annotation related) +- Layer/UI + - src/layer/vox/index.ts — VoxUserLayer: settings tab (scale, bounds, remote URL/token), tool tab (tools, labels UI), wiring to render layer and multiscale. + - src/ui/voxel_annotations.ts — Legacy tools (pixel, brush) and registration. Generates voxel positions, handles stroke interpolation, uses oriented disk brush. +- Chunk sources / rendering + - src/voxel_annotation/volume_chunk_source.ts — VoxMultiscaleVolumeChunkSource: returns base and guard scales, passes vox server options. + - src/voxel_annotation/renderlayer.ts — VoxelAnnotationRenderLayer: simple segment-hash coloring of non-zero labels. +- Edit logic and RPC owner/counterpart + - src/voxel_annotation/frontend.ts — Frontend VoxChunkSource with optimistic CPU updates, batched commit RPCs, map init, label RPCs. + - src/voxel_annotation/backend.ts — Backend VoxChunkSource resolves LocalVoxSource vs RemoteVoxSource, downloads/saves chunk data, responds to RPCs. + - src/voxel_annotation/index.ts — VoxSource base; LocalVoxSource (IndexedDB); RemoteVoxSource (HTTP); scale/chunk key helpers; IDB utils. + - src/voxel_annotation/edit_controller.ts — Bridges layer tools to VoxChunkSource. +- Datasource integration + - src/datasource/vox_remote.ts — Provider for vox+http(s):// schemes to pass remote info into the layer. +- Reference and architecture notes + - NOTES/voxel-annotation-specification.md — Overall voxel annotation spec and tiered architecture. + - NOTES/annotation-chunk-source-and-sync.md — How frontend/backend chunk sources pair and how optimistic buffering works. + - NOTES/classExplanations/*.md — Deeper dives into MultiscaleVolumeChunkSource and chunk-source concepts. + - NOTES/backend.md — Zarr HTTP server requirements and API. + - NOTES/TODOs.md — Current to-do list. + + +6) Editing Model (UX + Data) +- Immediate visual feedback: edits write into CPU arrays of visible chunks when present; GPU uploads are invalidated and refreshed on the next frame. +- Authoritative state: worker holds canonical per-chunk arrays via VoxSource; writes are batched and saved after debounce to IDB or PUT to remote server. +- Batched per-chunk commits: indices are linearized local indices in the canonical chunk size; backend handles edge clips and merges into the in-memory state. +- Label management: labels are simple integer lists scoped to map/scale; API supports GET and ADD; used to drive color mapping and selected label value in UI. + + +7) Brainstorming / Reflections / Debates +- Flood fill: + - Start with 2D fill in current slice plane; impose max expansion safeguards to prevent runaway fills. + - For 3D fill, consider connected-components with thresholding against underlying image/segmentation data. +- LOD / downsampling: + - MVP: hide when zoomed too far, or use guard scale to avoid huge memory usage. + - Phase 2: On-the-fly downsampling in worker: request 8 children at LOD0 to synthesize LOD1 with majority voting; cache generated lower-LOD chunks and invalidate on parent edits. + - Persistence across scales: consider propagating writes upward (write-through) and merging on load. Conflicts arise when values differ across scales; needs a deleted-marker and per-chunk timestamps to disambiguate absence vs deletion vs disagreement. + - Undo/future: Do not resolve conflicts “live” if it precludes implementing undo/redo; prefer to defer resolution or track lineage with timestamps. +- Compression and memory: + - Compressed segmentation block formats reduce RAM/IndexDB usage. Integration is non-trivial for hot-edit rendering because in-place CPU texture updates are needed for smooth UX. Explore per-chunk compressed backing store + uncompressed hot copy for visible chunks. + - Investigate RAM usage spikes during heavy painting; ensure chunk eviction policies consider viewport visibility to avoid flicker (see TODO on uncaching without visibility awareness). +- Multi-user / concurrency: + - Remote server MVP uses last-writer-wins. For collaborative editing, introduce per-chunk versions/ETags, server-side mergers, or operational transforms tuned for voxel arrays (conflict resolution policy per-voxel or per-chunk). + - Live updates: server can emit change streams or polling-based invalidation to notify clients of updated chunks. +- Authentication / Security: + - Magic-link token is a pragmatic MVP. Add CORS configs, short metadata caching, and health endpoints. Long-lived caching of 404s should be avoided. +- Import/Export: + - “ExternalVoxSource” concept: For zarr:// or precomputed:// reads, load remote for display; keep edits local (IDB) and implement export that merges local modifications back into a chosen persistent format. + + +8) Roadmap and TODOs +8.1 From NOTES/TODOs.md (selected and grouped) +- Storage/robustness + - Add redundancy to avoid corrupt/unsaved chunks on remote (e.g., write temp objects then rename, MD5/ETag checks). + - Test token authentication thoroughly. + - Add Uint64 label id support end-to-end (frontend arrays, server dtype, render sampling already supports uint64 colors). +- Performance/UX + - Fix brush disk orientation edge cases; ensure correct plane basis on arbitrary slices. + - Visibility-aware eviction to avoid flicker when LocalVoxSource evicts unseen chunks; integrate with chunk manager visible set. + - Investigate and reduce RAM usage on heavy painting sessions. + - Segmentation compression strategy compatible with hot updates. +- Tools + - Flood fill tool (start 2D, plane normal z is ok for v1). Add eraser tooling (value 0 path exists; improve UX toggles/shortcuts). +- LOD + - Implement LOD rendering by propagating writes upward and fetching across scales; ensure deleted-marker and per-chunk timestamps to tackle conflicts; do not auto-resolve live to keep undo viable. +- Data workflows + - Import precomputed/Zarr segmentation into server; support full dataset retrieval and merge with local modifications; export to desired format. +- Remote labels sync + - Current remote server code supports labels endpoints; ensure layer UI syncs and handles errors. + +8.2 Additional tasks inferred from code and commits +- Finish wiring of map initialization from layer UI (ensure scaleKey matches UI region and chunk sizes, call initializeMap on source creation). +- Improve error handling for remote PUT/GET (status messages, retries, and user feedback). +- Add basic metrics/observability overlays (chunk read/write counters) for development. +- Provide example docker-compose and client connection snippet in docs. + + +9) Git History Highlights (vox-related) +- 7c1a3b4e feat: replace setLabelIds with addLabel for label management. +- 02e28fd4 feat: remote voxel sources via HTTP(S) (note: labels not sync initially). +- 07a59c38 feat: RPC-based voxel label persistence. +- e04b3167 feat: voxel label creation, persistence via IndexedDB, enhanced UI. +- e70c9ff3 feat: expand TODOs (compression, multi-user, tools). +- 92c3c215 feat: region-based voxel initialization with corners; update map options and UI. +- 19f4e103 feat: new local voxel storage with IndexedDB; map initialization; improved backend edits. +- faf0947d feat: persist voxel edits to backend and improve drawing responsiveness. +- 3017fe4c feat: continuous drawing and brush shape selection. +- 238958a8 feat: brush size, eraser mode, minor optimization. +- a3f05989 refactor: rename DummyMultiscaleVolumeChunkSource→VoxMultiscaleVolumeChunkSource. +- 44a6754f feat: fix pixel tool scaling issues; add primitive brush tool. +- 14336ab4 feat: pixel tool working as intended. +- 65565130 feat: WIP pixel tool; added front-end buffer; layer settings for scale/bounds; added guard scale to prevent zoom-out crashes. +- 6140a28b doc: rework voxel annotation specs. +- cbe55c86 feat: introduce VoxDummyChunkSource procedural demo. +- c0ceef34 feat: add support for voxel annotation rendering and spec. +- 9b71be4f feat: add new dummy layer type: voxel annotation (vox). + +These commits capture the evolution from a procedural/demo stage to a functional editing and persistence pipeline with labels and remote integration. + + +10) How everything connects (end-to-end) +- User paints with a tool → UI generates voxel positions (points or brush patterns). +- VoxelEditController forwards edits to the frontend VoxChunkSource. +- Frontend VoxChunkSource: + - Computes chunk indices and local offsets. + - Writes into CPU arrays when available and invalidates GPU uploads (instant feedback). + - Batches linearized indices per chunk and sends VOX_COMMIT_VOXELS_RPC_ID to the worker, including canonical size. +- Backend VoxChunkSource receives the RPC and applies edits via VoxSource (Local or Remote) — authoritative state updated immediately. +- Debounced saver writes chunks to IDB or HTTP server /chunk endpoint. +- When chunks stream (or re-stream) to the frontend (e.g., on navigation), download merges saved data and provides typed arrays; the render layer displays labels (zero → transparent, nonzero → colored). + + +11) Open Questions +- Undo/redo: Requires a journal of edits or chunk snapshots. Interaction with LOD propagation needs careful design. +- Multi-user semantics: Per-voxel conflict resolution vs per-chunk; latency trade-offs; server push vs polling. +- Remote cache invalidation: How do clients learn about external updates? ETag + If-None-Match and/or change streams. +- Label metadata: Should labels be plain integers only or have names/colors? Today rendering uses a deterministic hash; UI for named palettes could be added. +- Security: Token format and rotation; scope per-map vs per-store; server-side audit. + + +12) Quickstart (MVP) +- Local-only (IndexedDB): + 1) Add a Vox layer, set bounds and chunk size in the Settings tab. + 2) Pick a label value, select Pixel/Brush tool, paint. Data persists into your browser (IndexedDB). +- Remote (HTTP server): + 1) Run the Zarr server (see NOTES/backend.md for spec; Docker Compose recommended with MinIO for S3-like storage). + 2) In Vox layer, set source to vox+http://host:port/?token=... (or vox+https://...). + 3) Paint. Edits are PUT to the server; missing chunks read as zeros. + + +13) Glossary +- Chunk: A small 3D block of voxels (e.g., 64×64×64) used for efficient storage and rendering. +- Multiscale: Multiple resolutions of the same volume for performance at varying zoom levels. +- LOD: Level of detail; lower resolution representation used when zoomed out. +- NGFF/Zarr: Open formats for n-dimensional arrays with chunked storage; used here for persistence. + + +14) References (in repo) +- NOTES/backend.md — server API/requirements. +- NOTES/voxel-annotation-specification.md — tiered architecture, tools, and phases. +- NOTES/annotation-chunk-source-and-sync.md — RPC pairing and buffering model. +- NOTES/classExplanations/MultiscaleVolumeChunkSource.md — multiscale details. +- NOTES/classExplanations/chunk-source.md — owner/counterpart model; visibility-driven chunking. +- src/* — see Codebase Map above. + + +Appendix A) Helper formulas +- Scale key: + toScaleKey(chunkDataSize, baseVoxelOffset, upperVoxelBound) → "cx_cy_cz:lx_ly_lz-ux_uy_uz". +- Chunk key: + toChunkKey([cx, cy, cz]) → "cx,cy,cz". From eb4c0e32fe3e5a844f3b74718df934e16096be2b Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 029/251] feat: map creation and selection, the min scale is currently not saved and part of the codebase for this feature is subject to rewritting because of ugly code. --- src/layer/vox/index.ts | 483 ++------------------ src/layer/vox/tabs/settings.ts | 222 +++++++++ src/layer/vox/tabs/tools.ts | 261 +++++++++++ src/sliceview/frontend.ts | 1 - src/voxel_annotation/backend.ts | 54 +-- src/voxel_annotation/frontend.ts | 13 +- src/voxel_annotation/index.ts | 226 ++++++--- src/voxel_annotation/map.ts | 84 ++++ src/voxel_annotation/volume_chunk_source.ts | 114 ++--- 9 files changed, 829 insertions(+), 629 deletions(-) create mode 100644 src/layer/vox/tabs/settings.ts create mode 100644 src/layer/vox/tabs/tools.ts create mode 100644 src/voxel_annotation/map.ts diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index c5adcffc80..c8ee028f05 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -36,6 +36,8 @@ import { UserLayer, } from "#src/layer/index.js"; import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; +import { VoxSettingsTab } from "#src/layer/vox/tabs/settings.js"; +import { VoxToolTab } from "#src/layer/vox/tabs/tools.js"; import { getWatchableRenderLayerTransform } from "#src/render_coordinate_transform.js"; import { RenderScaleHistogram, @@ -44,394 +46,18 @@ import { import { SegmentColorHash } from "#src/segment_color.js"; import { registerVoxelAnnotationTools, - VoxelBrushLegacyTool, - VoxelPixelLegacyTool, } from "#src/ui/voxel_annotations.js"; import type { Borrowed } from "#src/util/disposable.js"; import { mat4 } from "#src/util/geom.js"; import { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; -import { toScaleKey } from "#src/voxel_annotation/index.js"; +import { RemoteVoxSource } from "#src/voxel_annotation/index.js"; +import { VoxMapRegistry } from "#src/voxel_annotation/map.js"; import { VoxelAnnotationRenderLayer } from "#src/voxel_annotation/renderlayer.js"; import { VoxMultiscaleVolumeChunkSource } from "#src/voxel_annotation/volume_chunk_source.js"; -import { Tab } from "#src/widget/tab_view.js"; - -class VoxSettingsTab extends Tab { - constructor(public layer: VoxUserLayer) { - super(); - const { element } = this; - element.classList.add("neuroglancer-vox-settings-tab"); - - const row = (label: string, inputs: HTMLElement[]) => { - const div = document.createElement("div"); - div.className = "neuroglancer-vox-row"; - const lab = document.createElement("label"); - lab.textContent = label; - lab.style.display = "inline-block"; - lab.style.width = "140px"; - div.appendChild(lab); - for (const inp of inputs) { - inp.classList.add("neuroglancer-vox-input"); - inp.setAttribute("size", "8"); - div.appendChild(inp); - } - return div; - }; - - const makeNumberInput = (value: number, step: string) => { - const inp = document.createElement("input"); - inp.type = "number"; - inp.step = step; - inp.value = String(value); - return inp; - }; - - const sMeters = this.layer.voxScale; // stored in meters - const a = this.layer.voxCornerA; - const c = this.layer.voxCornerB; - - // Unit helpers - const unitFactor: Record = { - m: 1, - mm: 1e-3, - µm: 1e-6, - nm: 1e-9, - }; - const currentUnit = - this.layer.voxScaleUnit in unitFactor ? this.layer.voxScaleUnit : "m"; - const factor = (u: string) => unitFactor[u] ?? 1; - - // Prepare UI elements - const unitSel = document.createElement("select"); - for (const u of ["m", "mm", "µm", "nm"]) { - const opt = document.createElement("option"); - opt.value = u; - opt.textContent = u; - if (u === currentUnit) opt.selected = true; - unitSel.appendChild(opt); - } - let prevUnit = currentUnit; - - // Show scale values in the chosen unit for convenience - const sx = makeNumberInput(sMeters[0] / factor(currentUnit), "any"); - const sy = makeNumberInput(sMeters[1] / factor(currentUnit), "any"); - const sz = makeNumberInput(sMeters[2] / factor(currentUnit), "any"); - - const ax = makeNumberInput(a[0], "1"); - const ay = makeNumberInput(a[1], "1"); - const az = makeNumberInput(a[2], "1"); - - const bx = makeNumberInput(c[0], "1"); - const by = makeNumberInput(c[1], "1"); - const bz = makeNumberInput(c[2], "1"); - - element.appendChild(row("Scale (x,y,z)", [sx, sy, sz])); - element.appendChild(row("Scale unit", [unitSel])); - element.appendChild(row("Corner A (x,y,z)", [ax, ay, az])); - element.appendChild(row("Corner B (x,y,z)", [bx, by, bz])); - - // When unit changes, rescale the displayed numbers to preserve physical value in meters - unitSel.addEventListener("change", () => { - const newU = unitSel.value; - const conv = factor(prevUnit) / factor(newU); - // Update the input values in-place - const x = Number.parseFloat(sx.value); - const y = Number.parseFloat(sy.value); - const z = Number.parseFloat(sz.value); - if (Number.isFinite(x)) sx.value = String(x * conv); - if (Number.isFinite(y)) sy.value = String(y * conv); - if (Number.isFinite(z)) sz.value = String(z * conv); - prevUnit = newU; - }); - - const apply = document.createElement("button"); - apply.textContent = "Regen source"; - apply.title = - "Regenerate the source volume with the new settings, warning old local source will be deleted"; - apply.addEventListener("click", () => { - const u = unitSel.value || currentUnit; - const f = factor(u); - // Convert user-entered values back to meters - const sxNum = Number.parseFloat(sx.value); - const syNum = Number.parseFloat(sy.value); - const szNum = Number.parseFloat(sz.value); - const ns = new Float64Array([ - Number.isFinite(sxNum) ? sxNum * f : sMeters[0], - Number.isFinite(syNum) ? syNum * f : sMeters[1], - Number.isFinite(szNum) ? szNum * f : sMeters[2], - ]); - const ca = new Float32Array([ - Math.floor(Number(ax.value) || this.layer.voxCornerA[0]), - Math.floor(Number(ay.value) || this.layer.voxCornerA[1]), - Math.floor(Number(az.value) || this.layer.voxCornerA[2]), - ]); - const cb = new Float32Array([ - Math.floor(Number(bx.value) || this.layer.voxCornerB[0]), - Math.floor(Number(by.value) || this.layer.voxCornerB[1]), - Math.floor(Number(bz.value) || this.layer.voxCornerB[2]), - ]); - this.layer.applyVoxSettings(ns, u, ca, cb); - }); - element.appendChild(apply); - } -} - -class VoxToolTab extends Tab { - public requestRenderLabels() { - this.renderLabels(); - } - private labelsContainer!: HTMLDivElement; - private labelsError!: HTMLDivElement; - private renderLabels() { - const cont = this.labelsContainer; - cont.innerHTML = ""; - const labels = this.layer.voxLabels; - const selected = this.layer.voxSelectedLabelId; - for (const lab of labels) { - const row = document.createElement("div"); - row.className = "neuroglancer-vox-label-row"; - row.style.display = "grid"; - row.style.gridTemplateColumns = "16px 1fr"; - row.style.alignItems = "center"; - row.style.gap = "8px"; - // color swatch - const sw = document.createElement("div"); - sw.style.width = "16px"; - sw.style.height = "16px"; - sw.style.borderRadius = "3px"; - sw.style.border = "1px solid rgba(0,0,0,0.2)"; - sw.style.background = this.layer.colorForValue(lab.id); - // id text (monospace) - const txt = document.createElement("div"); - txt.textContent = String(lab.id >>> 0); - txt.style.fontFamily = "monospace"; - txt.style.whiteSpace = "nowrap"; - txt.style.overflow = "hidden"; - txt.style.textOverflow = "ellipsis"; - row.appendChild(sw); - row.appendChild(txt); - // selection styling - const isSel = lab.id === selected; - row.style.cursor = "pointer"; - row.style.padding = "2px 4px"; - row.style.borderRadius = "4px"; - if (isSel) { - row.style.background = "rgba(100,150,255,0.15)"; - row.style.outline = "1px solid rgba(100,150,255,0.6)"; - } - row.addEventListener("click", () => { - this.layer.selectVoxLabel(lab.id); - this.renderLabels(); - }); - cont.appendChild(row); - } - // Update error message area - const err = this.layer.voxLabelsError; - if (err && err.length > 0) { - this.labelsError.textContent = err; - this.labelsError.style.display = "block"; - } else { - this.labelsError.textContent = ""; - this.labelsError.style.display = "none"; - } - } - constructor(public layer: VoxUserLayer) { - super(); - const { element } = this; - element.classList.add("neuroglancer-vox-tools-tab"); - const toolbox = document.createElement("div"); - toolbox.className = "neuroglancer-vox-toolbox"; - - // Section: Tool selection - const toolsRow = document.createElement("div"); - toolsRow.className = "neuroglancer-vox-row"; - const toolsLabel = document.createElement("label"); - toolsLabel.textContent = "Tool"; - const toolsWrap = document.createElement("div"); - toolsWrap.style.display = "flex"; - toolsWrap.style.gap = "8px"; - - const pixelButton = document.createElement("button"); - pixelButton.textContent = "Pixel"; - pixelButton.title = "ctrl+click to paint a pixel"; - pixelButton.addEventListener("click", () => { - this.layer.tool.value = new VoxelPixelLegacyTool(this.layer); - }); - - const brushButton = document.createElement("button"); - brushButton.textContent = "Brush"; - brushButton.title = "ctrl+click to paint a small sphere"; - brushButton.addEventListener("click", () => { - this.layer.tool.value = new VoxelBrushLegacyTool(this.layer); - }); - - toolsWrap.appendChild(pixelButton); - toolsWrap.appendChild(brushButton); - toolsRow.appendChild(toolsLabel); - toolsRow.appendChild(toolsWrap); - toolbox.appendChild(toolsRow); - - // Section: Brush settings - const brushRow = document.createElement("div"); - brushRow.className = "neuroglancer-vox-row"; - - // Brush size as slider + number readout - const sizeLabel = document.createElement("label"); - sizeLabel.textContent = "Brush size"; - const sizeControls = document.createElement("div"); - sizeControls.style.display = "flex"; - sizeControls.style.alignItems = "center"; - sizeControls.style.gap = "8px"; - - const sizeSlider = document.createElement("input"); - sizeSlider.type = "range"; - sizeSlider.min = "1"; - sizeSlider.max = "64"; - sizeSlider.step = "1"; - sizeSlider.value = String(this.layer.voxBrushRadius ?? 3); - - const sizeNumber = document.createElement("input"); - sizeNumber.type = "number"; - sizeNumber.className = "neuroglancer-vox-input"; - sizeNumber.min = "1"; - sizeNumber.step = "1"; - sizeNumber.value = String(this.layer.voxBrushRadius ?? 3); - - const syncSize = (v: number) => { - const clamped = Math.max(1, Math.min(256, Math.floor(v))); - this.layer.voxBrushRadius = clamped; - sizeSlider.value = String(clamped); - sizeNumber.value = String(clamped); - }; - - sizeSlider.addEventListener("input", () => { - syncSize(Number(sizeSlider.value) || 1); - }); - sizeNumber.addEventListener("change", () => { - syncSize(Number(sizeNumber.value) || 1); - }); - - sizeControls.appendChild(sizeSlider); - sizeControls.appendChild(sizeNumber); - - // Eraser toggle - const erLabel = document.createElement("label"); - erLabel.textContent = "Eraser"; - const erChk = document.createElement("input"); - erChk.type = "checkbox"; - erChk.checked = !!this.layer.voxEraseMode; - erChk.addEventListener("change", () => { - this.layer.voxEraseMode = !!erChk.checked; - }); - - // Brush shape selector - const shapeLabel = document.createElement("label"); - shapeLabel.textContent = "Brush shape"; - const shapeSel = document.createElement("select"); - const optDisk = document.createElement("option"); - optDisk.value = "disk"; - optDisk.textContent = "disk"; - const optSphere = document.createElement("option"); - optSphere.value = "sphere"; - optSphere.textContent = "sphere"; - shapeSel.appendChild(optDisk); - shapeSel.appendChild(optSphere); - shapeSel.value = this.layer.voxBrushShape === "sphere" ? "sphere" : "disk"; - shapeSel.addEventListener("change", () => { - const v = shapeSel.value === "sphere" ? "sphere" : "disk"; - this.layer.voxBrushShape = v; - shapeSel.value = v; - }); - - // Layout within the brushRow: size controls, shape, eraser - const group = document.createElement("div"); - group.style.display = "grid"; - group.style.gridTemplateColumns = "minmax(120px,auto) 1fr"; - group.style.columnGap = "8px"; - group.style.rowGap = "8px"; - - // Row 1: Brush size - const sizeLabelCell = document.createElement("div"); - sizeLabelCell.appendChild(sizeLabel); - const sizeControlsCell = document.createElement("div"); - sizeControlsCell.appendChild(sizeControls); - - // Row 2: Brush shape - const shapeLabelCell = document.createElement("div"); - shapeLabelCell.appendChild(shapeLabel); - const shapeControlCell = document.createElement("div"); - shapeControlCell.appendChild(shapeSel); - - // Row 3: Eraser - const erLabelCell = document.createElement("div"); - erLabelCell.appendChild(erLabel); - const erControlCell = document.createElement("div"); - erControlCell.appendChild(erChk); - - group.appendChild(sizeLabelCell); - group.appendChild(sizeControlsCell); - group.appendChild(shapeLabelCell); - group.appendChild(shapeControlCell); - group.appendChild(erLabelCell); - group.appendChild(erControlCell); - - brushRow.appendChild(group); - toolbox.appendChild(brushRow); - - // Section: Labels (moved to end, title on top for full width) - const labelsSection = document.createElement("div"); - labelsSection.style.display = "flex"; - labelsSection.style.flexDirection = "column"; - labelsSection.style.gap = "6px"; - labelsSection.style.marginTop = "8px"; - - const labelsTitle = document.createElement("div"); - labelsTitle.textContent = "Labels"; - labelsTitle.style.fontWeight = "600"; - - const buttonsRow = document.createElement("div"); - buttonsRow.style.display = "flex"; - buttonsRow.style.gap = "8px"; - - const createBtn = document.createElement("button"); - createBtn.textContent = "New label"; - createBtn.addEventListener("click", () => { - this.layer.createVoxLabel(); - // Rendering will be triggered by layer via onLabelsChanged callback. - }); - buttonsRow.appendChild(createBtn); - - this.labelsContainer = document.createElement("div"); - this.labelsContainer.className = "neuroglancer-vox-labels"; - this.labelsContainer.style.display = "flex"; - this.labelsContainer.style.flexDirection = "column"; - this.labelsContainer.style.gap = "4px"; - this.labelsContainer.style.maxHeight = "180px"; - this.labelsContainer.style.overflowY = "auto"; - - this.labelsError = document.createElement("div"); - this.labelsError.className = "neuroglancer-vox-labels-error"; - this.labelsError.style.color = "#b00020"; // Material red 700-ish - this.labelsError.style.fontSize = "12px"; - this.labelsError.style.whiteSpace = "pre-wrap"; - this.labelsError.style.display = "none"; - - labelsSection.appendChild(labelsTitle); - labelsSection.appendChild(buttonsRow); - labelsSection.appendChild(this.labelsContainer); - labelsSection.appendChild(this.labelsError); - - toolbox.appendChild(labelsSection); - - this.layer.onLabelsChanged = () => this.requestRenderLabels(); - this.renderLabels(); - - element.appendChild(toolbox); - } -} export class VoxUserLayer extends UserLayer { onLabelsChanged?: () => void; - private voxMapId: string | undefined; + voxMapId: string | undefined; // Label state for painting: only store ids; colors are hashed from id on the fly voxLabels: { id: number }[] = []; voxSelectedLabelId: number | undefined = undefined; @@ -463,8 +89,8 @@ export class VoxUserLayer extends UserLayer { private voxLoadedSubsource?: LoadedDataSubsource; // Remote server configuration when using vox+http(s):// data sources - private voxServerUrl?: string; - private voxServerToken?: string; + voxServerUrl?: string; + voxServerToken?: string; // --- Label helpers --- private genId(): number { @@ -514,7 +140,6 @@ export class VoxUserLayer extends UserLayer { const msg = `Failed to load labels: ${e?.message || e}`; console.error(msg); this.voxLabelsError = msg; - // Do NOT create a default label on error. } finally { // Mark labels as initialized; UI/painting should not trigger default creation before this point. this.voxLabelsInitialized = true; @@ -577,9 +202,8 @@ export class VoxUserLayer extends UserLayer { constructor(managedLayer: Borrowed) { super(managedLayer); - // Do not create/save default label yet; wait for map init and load. this.tabs.add("vox_settings", { - label: "Settings", + label: "Map", order: 0, getter: () => new VoxSettingsTab(this), }); @@ -744,12 +368,6 @@ export class VoxUserLayer extends UserLayer { } } - private qsToken(token?: string) { - const usp = new URLSearchParams(); - if (token) usp.set("token", token); - const s = usp.toString(); - return s ? `?${s}` : ""; - } private parseVoxRemoteUrl(url: string): { scheme: string; baseUrl: string; token?: string } | undefined { const m = url.match(/^(vox\+https?):\/\/(.+)$/); @@ -771,44 +389,33 @@ export class VoxUserLayer extends UserLayer { } private async verifyVoxRemote(baseUrl: string, token?: string): Promise { - const q = this.qsToken(token); - const health = await fetch(`${baseUrl}/health${q}`, { - method: "GET", - credentials: "omit", - }); - if (!health.ok) throw new Error(`/health -> ${health.status}`); - const info = await fetch(`${baseUrl}/info${q}`, { - method: "GET", - credentials: "omit", - }); - if (!info.ok) throw new Error(`/info -> ${info.status}`); + // Delegate verification to VoxSource: attempt to list maps via RemoteVoxSource. + const src = new RemoteVoxSource(baseUrl, token); + await src.listMaps(); } - private buildOrRebuildVoxLayer() { + buildOrRebuildVoxLayer() { const ls = this.voxLoadedSubsource; if (!ls) return; + + console.log("buildOrRebuildVoxLayer"); + // Require an explicit map selection/creation + const map = VoxMapRegistry.getCurrent(); + if (!map) return; + const guardScale = Array.from(this.voxScale); - // Derive region from corners for guard and source - const lower = new Float32Array(3); - const upper = new Float32Array(3); - for (let i = 0; i < 3; ++i) { - const lo = Math.floor(Math.min(this.voxCornerA[i], this.voxCornerB[i])); - const up = Math.ceil(Math.max(this.voxCornerA[i], this.voxCornerB[i])); - lower[i] = lo; - upper[i] = Math.max(up, lo + 1); - } + // Use map bounds for guard and source + const upper = new Float32Array(map.upperVoxelBound as any); const guardBounds = Array.from(upper); - const guardUnit = this.voxScaleUnit; + const guardUnit = map.unit || this.voxScaleUnit; + ls.activate( () => { + console.log("buildOrRebuildVoxLayer: activate source", map.id); const voxSource = new VoxMultiscaleVolumeChunkSource( this.manager.chunkManager, { - chunkDataSize: new Uint32Array([64, 64, 64]), - baseVoxelOffset: lower, - upperVoxelBound: upper, - voxServerUrl: this.voxServerUrl, - voxToken: this.voxServerToken, + map: map as any, }, ); // Expose a controller so tools can paint voxels via the source. @@ -816,30 +423,24 @@ export class VoxUserLayer extends UserLayer { // Initialize worker-side map persistence for this source (best-effort, fire-and-forget). const sources2D = voxSource.getSources({} as any); - const base = sources2D[0][0]; - const source = base.chunkSource as any; - // Compute deterministic identifiers on the frontend to avoid relying on an RPC return value. - const cfgCds = new Uint32Array( - Array.from((voxSource as any)["cfgChunkDataSize"] ?? [64, 64, 64]), - ); - const lowerArr: Float32Array = lower; - const upperArr: Float32Array = upper; - const scaleKey = toScaleKey(cfgCds, lowerArr, upperArr); - // mapId can be any stable string; default to 'local' unless already set. - if (!this.voxMapId) this.voxMapId = "local"; - // Initialize backend map first, then load labels from the chosen datasource. - source.initializeMap({ - mapId: this.voxMapId, - dataType: voxSource.dataType, - chunkDataSize: cfgCds, - baseVoxelOffset: lowerArr, - upperVoxelBound: upperArr, - unit: this.voxScaleUnit, - scaleKey, - serverUrl: this.voxServerUrl, - token: this.voxServerToken, - }); - this.loadLabels(); + const base = sources2D[0]?.[0]; + if (base) { + const source = base.chunkSource as any; + // Ensure we have a stable id on the map for persistence purposes. + const mapId = map.id || this.voxMapId || "local"; + this.voxMapId = mapId; + const mapForInit = { + ...map, + id: mapId, + unit: guardUnit, + serverUrl: map.serverUrl ?? this.voxServerUrl, + token: map.token ?? this.voxServerToken, + dataType: map.dataType ?? voxSource.dataType, + } as any; + // Initialize backend map first, then load labels from the chosen datasource. + source.initializeMap(mapForInit); + this.loadLabels(); + } // Build transform with current scale and units. const identity3D = this.createIdentity3D(); diff --git a/src/layer/vox/tabs/settings.ts b/src/layer/vox/tabs/settings.ts new file mode 100644 index 0000000000..02c057fca1 --- /dev/null +++ b/src/layer/vox/tabs/settings.ts @@ -0,0 +1,222 @@ +/** + * Vox Settings tab UI split from index.ts + */ +import type { VoxUserLayer } from "#src/layer/vox/index.js"; +import { VoxMapRegistry, computeSteps } from "#src/voxel_annotation/map.js"; +import { DataType } from "#src/util/data_type.js"; +import { Tab } from "#src/widget/tab_view.js"; + +export class VoxSettingsTab extends Tab { + constructor(public layer: VoxUserLayer) { + super(); + const { element } = this; + element.classList.add("neuroglancer-vox-settings-tab"); + + const row = (label: string, inputs: HTMLElement[]) => { + const div = document.createElement("div"); + div.className = "neuroglancer-vox-row"; + const lab = document.createElement("label"); + lab.textContent = label; + lab.style.display = "inline-block"; + lab.style.width = "140px"; + div.appendChild(lab); + for (const inp of inputs) { + inp.classList.add("neuroglancer-vox-input"); + inp.setAttribute("size", "8"); + div.appendChild(inp); + } + return div; + }; + + const makeNumberInput = (value: number, step: string) => { + const inp = document.createElement("input"); + inp.type = "number"; + inp.step = step; + inp.value = String(value); + return inp; + }; + + const sMeters = this.layer.voxScale; // stored in meters + const a = this.layer.voxCornerA; + const c = this.layer.voxCornerB; + + // Unit helpers + const unitFactor: Record = { + m: 1, + mm: 1e-3, + µm: 1e-6, + nm: 1e-9, + }; + const currentUnit = + this.layer.voxScaleUnit in unitFactor ? this.layer.voxScaleUnit : "m"; + const factor = (u: string) => unitFactor[u] ?? 1; + + // Prepare UI elements + const unitSel = document.createElement("select"); + for (const u of ["m", "mm", "µm", "nm"]) { + const opt = document.createElement("option"); + opt.value = u; + opt.textContent = u; + if (u === currentUnit) opt.selected = true; + unitSel.appendChild(opt); + } + let prevUnit = currentUnit; + + // Show scale values in the chosen unit for convenience + const sx = makeNumberInput(sMeters[0] / factor(currentUnit), "any"); + const sy = makeNumberInput(sMeters[1] / factor(currentUnit), "any"); + const sz = makeNumberInput(sMeters[2] / factor(currentUnit), "any"); + + const ax = makeNumberInput(a[0], "1"); + const ay = makeNumberInput(a[1], "1"); + const az = makeNumberInput(a[2], "1"); + + const bx = makeNumberInput(c[0], "1"); + const by = makeNumberInput(c[1], "1"); + const bz = makeNumberInput(c[2], "1"); + + element.appendChild(row("Scale (x,y,z)", [sx, sy, sz])); + element.appendChild(row("Scale unit", [unitSel])); + element.appendChild(row("Corner A (x,y,z)", [ax, ay, az])); + element.appendChild(row("Corner B (x,y,z)", [bx, by, bz])); + + // When unit changes, rescale the displayed numbers to preserve physical value in meters + unitSel.addEventListener("change", () => { + const newU = unitSel.value; + const conv = factor(prevUnit) / factor(newU); + // Update the input values in-place + const x = Number.parseFloat(sx.value); + const y = Number.parseFloat(sy.value); + const z = Number.parseFloat(sz.value); + if (Number.isFinite(x)) sx.value = String(x * conv); + if (Number.isFinite(y)) sy.value = String(y * conv); + if (Number.isFinite(z)) sz.value = String(z * conv); + prevUnit = newU; + }); + + // Map metadata inputs + const mapIdInp = document.createElement("input"); + mapIdInp.type = "text"; + mapIdInp.placeholder = "map id"; + mapIdInp.value = this.layer.voxMapId || ""; + const mapNameInp = document.createElement("input"); + mapNameInp.type = "text"; + mapNameInp.placeholder = "map name"; + mapNameInp.value = ""; + element.appendChild(row("Map id/name", [mapIdInp, mapNameInp])); + + // Existing maps list + const mapsSel = document.createElement("select"); + const refreshMaps = () => { + mapsSel.innerHTML = ""; + const maps = VoxMapRegistry.list(); + for (const m of maps) { + const opt = document.createElement("option"); + opt.value = m.id; + opt.textContent = `${m.name || m.id}`; + mapsSel.appendChild(opt); + } + }; + refreshMaps(); + element.appendChild(row("Existing maps", [mapsSel])); + + // Attempt to fetch existing maps via VoxSource implementation (local or remote) + (async () => { + try { + // Dynamically use the appropriate VoxSource + let maps: any[] = []; + if (this.layer.voxServerUrl) { + const { RemoteVoxSource } = await import("#src/voxel_annotation/index.js"); + const src = new RemoteVoxSource(this.layer.voxServerUrl, this.layer.voxServerToken); + maps = await src.listMaps(); + } else { + const { LocalVoxSource } = await import("#src/voxel_annotation/index.js"); + const src = new LocalVoxSource(); + maps = await src.listMaps(); + } + for (const m of maps) VoxMapRegistry.upsert(m as any); + refreshMaps(); + } catch { + // ignore + } + })(); + + const createBtn = document.createElement("button"); + createBtn.textContent = "Create / Init Map"; + createBtn.title = "Create a map with the provided id, bounds, scale, and precomputed steps"; + createBtn.addEventListener("click", () => { + const u = unitSel.value || currentUnit; + const f = factor(u); + // Convert user-entered values back to meters + const sxNum = Number.parseFloat(sx.value); + const syNum = Number.parseFloat(sy.value); + const szNum = Number.parseFloat(sz.value); + const ns = new Float64Array([ + Number.isFinite(sxNum) ? sxNum * f : sMeters[0], + Number.isFinite(syNum) ? syNum * f : sMeters[1], + Number.isFinite(szNum) ? szNum * f : sMeters[2], + ]); + const ca = new Float32Array([ + Math.floor(Number(ax.value) || this.layer.voxCornerA[0]), + Math.floor(Number(ay.value) || this.layer.voxCornerA[1]), + Math.floor(Number(az.value) || this.layer.voxCornerA[2]), + ]); + const cb = new Float32Array([ + Math.floor(Number(bx.value) || this.layer.voxCornerB[0]), + Math.floor(Number(by.value) || this.layer.voxCornerB[1]), + Math.floor(Number(bz.value) || this.layer.voxCornerB[2]), + ]); + + // Normalize bounds + const lower = new Float32Array(3); + const upper = new Float32Array(3); + for (let i = 0; i < 3; ++i) { + const lo = Math.floor(Math.min(ca[i], cb[i])); + const up = Math.ceil(Math.max(ca[i], cb[i])); + lower[i] = lo; + upper[i] = Math.max(up, lo + 1); + } + const bounds = [ + upper[0] - lower[0], + upper[1] - lower[1], + upper[2] - lower[2], + ]; + const chunkDataSize = [64, 64, 64]; + const steps = computeSteps(bounds, chunkDataSize); + + const id = mapIdInp.value || this.layer.voxMapId || `map-${Date.now()}`; + const name = mapNameInp.value || id; + + const map = { + id, + name, + baseVoxelOffset: lower, + upperVoxelBound: upper, + chunkDataSize, + dataType: DataType.UINT32, + scaleMeters: ns, + unit: u, + steps, + serverUrl: this.layer.voxServerUrl, + token: this.layer.voxServerToken, + }; + VoxMapRegistry.upsert(map as any); + VoxMapRegistry.setCurrent(map as any); + this.layer.applyVoxSettings(ns, u, ca, cb); + refreshMaps(); + }); + element.appendChild(createBtn); + + const selectBtn = document.createElement("button"); + selectBtn.textContent = "Select Map"; + selectBtn.addEventListener("click", () => { + const id = mapsSel.value; + const found = VoxMapRegistry.list().find((m) => m.id === id); + if (found) { + VoxMapRegistry.setCurrent(found); + this.layer.buildOrRebuildVoxLayer(); + } + }); + element.appendChild(selectBtn); + } +} diff --git a/src/layer/vox/tabs/tools.ts b/src/layer/vox/tabs/tools.ts new file mode 100644 index 0000000000..39ae1817bf --- /dev/null +++ b/src/layer/vox/tabs/tools.ts @@ -0,0 +1,261 @@ +/** + * Vox Tool tab UI split from index.ts + */ +import type { VoxUserLayer } from "#src/layer/vox/index.js"; +import { VoxelBrushLegacyTool, VoxelPixelLegacyTool } from "#src/ui/voxel_annotations.js"; +import { Tab } from "#src/widget/tab_view.js"; + + +export class VoxToolTab extends Tab { + public requestRenderLabels() { + this.renderLabels(); + } + private labelsContainer!: HTMLDivElement; + private labelsError!: HTMLDivElement; + private renderLabels() { + const cont = this.labelsContainer; + cont.innerHTML = ""; + const labels = this.layer.voxLabels; + const selected = this.layer.voxSelectedLabelId; + for (const lab of labels) { + const row = document.createElement("div"); + row.className = "neuroglancer-vox-label-row"; + row.style.display = "grid"; + row.style.gridTemplateColumns = "16px 1fr"; + row.style.alignItems = "center"; + row.style.gap = "8px"; + // color swatch + const sw = document.createElement("div"); + sw.style.width = "16px"; + sw.style.height = "16px"; + sw.style.borderRadius = "3px"; + sw.style.border = "1px solid rgba(0,0,0,0.2)"; + sw.style.background = this.layer.colorForValue(lab.id); + // id text (monospace) + const txt = document.createElement("div"); + txt.textContent = String(lab.id >>> 0); + txt.style.fontFamily = "monospace"; + txt.style.whiteSpace = "nowrap"; + txt.style.overflow = "hidden"; + txt.style.textOverflow = "ellipsis"; + row.appendChild(sw); + row.appendChild(txt); + // selection styling + const isSel = lab.id === selected; + row.style.cursor = "pointer"; + row.style.padding = "2px 4px"; + row.style.borderRadius = "4px"; + if (isSel) { + row.style.background = "rgba(100,150,255,0.15)"; + row.style.outline = "1px solid rgba(100,150,255,0.6)"; + } + row.addEventListener("click", () => { + this.layer.selectVoxLabel(lab.id); + this.renderLabels(); + }); + cont.appendChild(row); + } + // Update error message area + const err = this.layer.voxLabelsError; + if (err && err.length > 0) { + this.labelsError.textContent = err; + this.labelsError.style.display = "block"; + } else { + this.labelsError.textContent = ""; + this.labelsError.style.display = "none"; + } + } + constructor(public layer: VoxUserLayer) { + super(); + const { element } = this; + element.classList.add("neuroglancer-vox-tools-tab"); + const toolbox = document.createElement("div"); + toolbox.className = "neuroglancer-vox-toolbox"; + + // Section: Tool selection + const toolsRow = document.createElement("div"); + toolsRow.className = "neuroglancer-vox-row"; + const toolsLabel = document.createElement("label"); + toolsLabel.textContent = "Tool"; + const toolsWrap = document.createElement("div"); + toolsWrap.style.display = "flex"; + toolsWrap.style.gap = "8px"; + + const pixelButton = document.createElement("button"); + pixelButton.textContent = "Pixel"; + pixelButton.title = "ctrl+click to paint a pixel"; + pixelButton.addEventListener("click", () => { + this.layer.tool.value = new VoxelPixelLegacyTool(this.layer); + }); + + const brushButton = document.createElement("button"); + brushButton.textContent = "Brush"; + brushButton.title = "ctrl+click to paint a small sphere"; + brushButton.addEventListener("click", () => { + this.layer.tool.value = new VoxelBrushLegacyTool(this.layer); + }); + + toolsWrap.appendChild(pixelButton); + toolsWrap.appendChild(brushButton); + toolsRow.appendChild(toolsLabel); + toolsRow.appendChild(toolsWrap); + toolbox.appendChild(toolsRow); + + // Section: Brush settings + const brushRow = document.createElement("div"); + brushRow.className = "neuroglancer-vox-row"; + + // Brush size as slider + number readout + const sizeLabel = document.createElement("label"); + sizeLabel.textContent = "Brush size"; + const sizeControls = document.createElement("div"); + sizeControls.style.display = "flex"; + sizeControls.style.alignItems = "center"; + sizeControls.style.gap = "8px"; + + const sizeSlider = document.createElement("input"); + sizeSlider.type = "range"; + sizeSlider.min = "1"; + sizeSlider.max = "64"; + sizeSlider.step = "1"; + sizeSlider.value = String(this.layer.voxBrushRadius ?? 3); + + const sizeNumber = document.createElement("input"); + sizeNumber.type = "number"; + sizeNumber.className = "neuroglancer-vox-input"; + sizeNumber.min = "1"; + sizeNumber.step = "1"; + sizeNumber.value = String(this.layer.voxBrushRadius ?? 3); + + const syncSize = (v: number) => { + const clamped = Math.max(1, Math.min(256, Math.floor(v))); + this.layer.voxBrushRadius = clamped; + sizeSlider.value = String(clamped); + sizeNumber.value = String(clamped); + }; + + sizeSlider.addEventListener("input", () => { + syncSize(Number(sizeSlider.value) || 1); + }); + sizeNumber.addEventListener("change", () => { + syncSize(Number(sizeNumber.value) || 1); + }); + + sizeControls.appendChild(sizeSlider); + sizeControls.appendChild(sizeNumber); + + // Eraser toggle + const erLabel = document.createElement("label"); + erLabel.textContent = "Eraser"; + const erChk = document.createElement("input"); + erChk.type = "checkbox"; + erChk.checked = !!this.layer.voxEraseMode; + erChk.addEventListener("change", () => { + this.layer.voxEraseMode = !!erChk.checked; + }); + + // Brush shape selector + const shapeLabel = document.createElement("label"); + shapeLabel.textContent = "Brush shape"; + const shapeSel = document.createElement("select"); + const optDisk = document.createElement("option"); + optDisk.value = "disk"; + optDisk.textContent = "disk"; + const optSphere = document.createElement("option"); + optSphere.value = "sphere"; + optSphere.textContent = "sphere"; + shapeSel.appendChild(optDisk); + shapeSel.appendChild(optSphere); + shapeSel.value = this.layer.voxBrushShape === "sphere" ? "sphere" : "disk"; + shapeSel.addEventListener("change", () => { + const v = shapeSel.value === "sphere" ? "sphere" : "disk"; + this.layer.voxBrushShape = v; + shapeSel.value = v; + }); + + // Layout within the brushRow: size controls, shape, eraser + const group = document.createElement("div"); + group.style.display = "grid"; + group.style.gridTemplateColumns = "minmax(120px,auto) 1fr"; + group.style.columnGap = "8px"; + group.style.rowGap = "8px"; + + // Row 1: Brush size + const sizeLabelCell = document.createElement("div"); + sizeLabelCell.appendChild(sizeLabel); + const sizeControlsCell = document.createElement("div"); + sizeControlsCell.appendChild(sizeControls); + + // Row 2: Brush shape + const shapeLabelCell = document.createElement("div"); + shapeLabelCell.appendChild(shapeLabel); + const shapeControlCell = document.createElement("div"); + shapeControlCell.appendChild(shapeSel); + + // Row 3: Eraser + const erLabelCell = document.createElement("div"); + erLabelCell.appendChild(erLabel); + const erControlCell = document.createElement("div"); + erControlCell.appendChild(erChk); + + group.appendChild(sizeLabelCell); + group.appendChild(sizeControlsCell); + group.appendChild(shapeLabelCell); + group.appendChild(shapeControlCell); + group.appendChild(erLabelCell); + group.appendChild(erControlCell); + + brushRow.appendChild(group); + toolbox.appendChild(brushRow); + + // Section: Labels (moved to end, title on top for full width) + const labelsSection = document.createElement("div"); + labelsSection.style.display = "flex"; + labelsSection.style.flexDirection = "column"; + labelsSection.style.gap = "6px"; + labelsSection.style.marginTop = "8px"; + + const labelsTitle = document.createElement("div"); + labelsTitle.textContent = "Labels"; + labelsTitle.style.fontWeight = "600"; + + const buttonsRow = document.createElement("div"); + buttonsRow.style.display = "flex"; + buttonsRow.style.gap = "8px"; + + const createBtn = document.createElement("button"); + createBtn.textContent = "New label"; + createBtn.addEventListener("click", () => { + this.layer.createVoxLabel(); + // Rendering will be triggered by layer via onLabelsChanged callback. + }); + buttonsRow.appendChild(createBtn); + + this.labelsContainer = document.createElement("div"); + this.labelsContainer.className = "neuroglancer-vox-labels"; + this.labelsContainer.style.display = "flex"; + this.labelsContainer.style.flexDirection = "column"; + this.labelsContainer.style.gap = "4px"; + this.labelsContainer.style.maxHeight = "180px"; + this.labelsContainer.style.overflowY = "auto"; + + this.labelsError = document.createElement("div"); + this.labelsError.className = "neuroglancer-vox-labels-error"; + this.labelsError.style.color = "#b00020"; // Material red 700-ish + this.labelsError.style.fontSize = "12px"; + this.labelsError.style.whiteSpace = "pre-wrap"; + this.labelsError.style.display = "none"; + + labelsSection.appendChild(labelsTitle); + labelsSection.appendChild(buttonsRow); + labelsSection.appendChild(this.labelsContainer); + labelsSection.appendChild(this.labelsError); + + toolbox.appendChild(labelsSection); + + this.layer.onLabelsChanged = () => this.requestRenderLabels(); + this.renderLabels(); + + element.appendChild(toolbox); + } +} diff --git a/src/sliceview/frontend.ts b/src/sliceview/frontend.ts index dad55f6d5a..66d956d835 100644 --- a/src/sliceview/frontend.ts +++ b/src/sliceview/frontend.ts @@ -1089,7 +1089,6 @@ export function getVolumetricTransformedSources( effectiveVoxelSize[i] * globalScales[i], ); } - effectiveVoxelSize.fill(1, displayRank); return { layerRank, lowerClipBound, diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index f1ca09d530..e86a8541a8 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -13,8 +13,8 @@ import { VOX_LABELS_GET_RPC_ID, VOX_LABELS_ADD_RPC_ID, } from "#src/voxel_annotation/base.js"; -import type { VoxMapInitOptions } from "#src/voxel_annotation/index.js"; -import { LocalVoxSource, RemoteVoxSource, toScaleKey } from "#src/voxel_annotation/index.js"; +import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; +import { LocalVoxSource, RemoteVoxSource } from "#src/voxel_annotation/index.js"; import type { VoxSource } from "#src/voxel_annotation/index.js"; import type { RPC } from "#src/worker_rpc.js"; import { @@ -44,50 +44,20 @@ export class VoxChunkSource extends BaseVolumeChunkSource { : new LocalVoxSource(); } - /** Initialize map metadata and persistence backend. */ - async initMap(opts: { - mapId?: string; - dataType?: number; - chunkDataSize?: number[]; - upperVoxelBound?: number[]; - baseVoxelOffset?: number[]; - unit?: string; - scaleKey?: string; - serverUrl?: string; - token?: string; - }) { - // Allow runtime override of server settings via init options - if (opts.serverUrl) this.voxServerUrl = opts.serverUrl; - if (opts.token) this.voxToken = opts.token; + /** Initialize map metadata and persistence backend using a VoxMapConfig. */ + async initMap(arg: { map?: VoxMapConfig } | VoxMapConfig) { + const map: VoxMapConfig = (arg as any)?.map ?? (arg as any); + if (!map) return; + // Allow runtime override of server settings via map + if (map.serverUrl) this.voxServerUrl = map.serverUrl; + if (map.token) this.voxToken = map.token; // Swap source if configuration changed this.source = this.voxServerUrl ? new RemoteVoxSource(this.voxServerUrl, this.voxToken) : new LocalVoxSource(); - const cds: number[] = Array.from( - opts.chunkDataSize ?? Array.from(this.spec.chunkDataSize), - ); - const uvb: number[] = Array.from( - opts.upperVoxelBound ?? - Array.from(this.spec.upperVoxelBound ?? ([0, 0, 0] as any)), - ); - const dt = opts.dataType ?? this.spec.dataType; - // Default base offset to spec.baseVoxelOffset if not provided - const bvo: number[] = Array.from( - opts.baseVoxelOffset ?? - Array.from((this.spec as any).baseVoxelOffset ?? [0, 0, 0]), - ); - const scaleKey = opts.scaleKey ?? toScaleKey(cds, bvo, uvb); - const initOpts = { - mapId: opts.mapId, - dataType: dt, - chunkDataSize: cds as number[], - upperVoxelBound: uvb as number[], - baseVoxelOffset: bvo as number[], - unit: opts.unit, - scaleKey, - } satisfies VoxMapInitOptions; - return await this.source.init(initOpts); + // Initialize underlying source with the provided map config + await this.source.init(map); } /** Commit voxel edits from the frontend. */ @@ -174,7 +144,7 @@ registerRPC(VOX_COMMIT_VOXELS_RPC_ID, function (x: any) { // RPC to initialize map registerRPC(VOX_MAP_INIT_RPC_ID, function (x: any) { const obj = this.get(x.id) as VoxChunkSource; - obj.initMap(x || {}); + obj.initMap(x?.map || x || {}); }); // RPCs for label persistence (promise-based) diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index e464dfcdd5..46da6bc6f6 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -18,6 +18,7 @@ import { VOX_LABELS_ADD_RPC_ID, } from "#src/voxel_annotation/base.js"; import { registerSharedObjectOwner } from "#src/worker_rpc.js"; +import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; /** * Frontend owner for VoxChunkSource, extended with a local optimistic edit overlay. @@ -37,17 +38,9 @@ export class VoxChunkSource extends BaseVolumeChunkSource { ); /** Initialize map in the worker/backend for this source. */ - initializeMap(opts: { - mapId?: string; - dataType?: number; - chunkDataSize?: number[]; - upperVoxelBound?: number[]; - baseVoxelOffset?: number[]; - unit?: string; - scaleKey?: string; - }) { + initializeMap(map: VoxMapConfig) { try { - this.rpc!.invoke(VOX_MAP_INIT_RPC_ID, { id: this.rpcId, ...opts }); + this.rpc!.invoke(VOX_MAP_INIT_RPC_ID, { id: this.rpcId, map }); } catch { // initialization is best-effort; continue even if it fails console.warn( diff --git a/src/voxel_annotation/index.ts b/src/voxel_annotation/index.ts index a7af3ca04a..23b2a88df9 100644 --- a/src/voxel_annotation/index.ts +++ b/src/voxel_annotation/index.ts @@ -4,16 +4,9 @@ */ import { DataType } from "#src/util/data_type.js"; +import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; +import { computeSteps } from "#src/voxel_annotation/map.js"; -export interface VoxMapInitOptions { - mapId?: string; - dataType?: number; - chunkDataSize: number[] | Uint32Array; - upperVoxelBound?: number[] | Uint32Array | Float32Array; - baseVoxelOffset?: number[] | Uint32Array | Float32Array; - unit?: string; - scaleKey?: string; -} export interface SavedChunk { data: Uint32Array | BigUint64Array; // Supports UINT32 and UINT64 @@ -44,13 +37,16 @@ export function compositeLabelsDbKey(mapId: string, scaleKey: string): string { } export abstract class VoxSource { + /** + * Optional listing of available maps for the current source. + * Remote sources should query their endpoint; local may enumerate local IndexedDB entries. + */ + async listMaps(_args?: { baseUrl?: string; token?: string }): Promise { + return []; + } protected mapId: string = "default"; protected scaleKey: string = ""; - protected chunkDataSize: Uint32Array = new Uint32Array([64, 64, 64]); - protected upperVoxelBound: Uint32Array = new Uint32Array([0, 0, 0]); - protected baseVoxelOffset: Uint32Array = new Uint32Array([0, 0, 0]); - protected dataType: number = 6; // DataType.UINT32 default - protected unit: string = ""; + protected mapCfg?: VoxMapConfig; // Keep the entire configuration in one place // In-memory cache of loaded chunks protected maxSavedChunks = 128; // cap to prevent unbounded growth @@ -72,33 +68,36 @@ export abstract class VoxSource { return []; } - init(_opts: VoxMapInitOptions): Promise<{ mapId: string; scaleKey: string }> { - // Base provides default bookkeeping; persistence layer does real work. - const opts = _opts || ({} as VoxMapInitOptions); - this.mapId = - opts.mapId || - this.mapId || - (typeof crypto !== "undefined" && (crypto as any).randomUUID?.()) || - String(Date.now()); - this.chunkDataSize = new Uint32Array(Array.from(opts.chunkDataSize)); - this.upperVoxelBound = new Uint32Array( - Array.from(opts.upperVoxelBound ?? [0, 0, 0]), - ); - this.baseVoxelOffset = new Uint32Array( - Array.from(opts.baseVoxelOffset ?? [0, 0, 0]), - ); - this.dataType = opts.dataType ?? this.dataType; - this.unit = opts.unit ?? ""; - // Default scaleKey includes region to avoid collisions if not provided explicitly - if (opts.scaleKey) { - this.scaleKey = opts.scaleKey; - } else { - this.scaleKey = toScaleKey( - this.chunkDataSize, - this.baseVoxelOffset, - this.upperVoxelBound, - ); - } + init(map: VoxMapConfig): Promise<{ mapId: string; scaleKey: string }> { + // Store the whole map config instead of decomposing into many fields. + const cfgIn = (map || ({} as VoxMapConfig)); + // Normalize arrays and defaults while keeping a single cfg object. + const id = cfgIn.id || this.mapId || (typeof crypto !== "undefined" && (crypto as any).randomUUID?.()) || String(Date.now()); + const chunkDataSize = new Uint32Array(Array.from(cfgIn.chunkDataSize ?? [64, 64, 64])); + const upperVoxelBound = new Float32Array(Array.from(cfgIn.upperVoxelBound ?? [0, 0, 0])); + const baseVoxelOffset = new Float32Array(Array.from(cfgIn.baseVoxelOffset ?? [0, 0, 0])); + const dataType = (cfgIn.dataType ?? DataType.UINT32) as number; + const unit = cfgIn.unit ?? ""; + const steps = Array.isArray(cfgIn.steps) && cfgIn.steps.length > 0 ? [...cfgIn.steps] : [1]; + const scaleMeters = cfgIn.scaleMeters + ? new Float64Array(Array.from(cfgIn.scaleMeters as any)) + : undefined; + + this.mapCfg = { + ...cfgIn, + id, + chunkDataSize, + upperVoxelBound, + baseVoxelOffset, + dataType, + unit, + steps, + scaleMeters, + } as VoxMapConfig; + + this.mapId = id; + // Compute scaleKey from config to avoid collisions across regions + this.scaleKey = toScaleKey(chunkDataSize, baseVoxelOffset, upperVoxelBound); return Promise.resolve({ mapId: this.mapId, scaleKey: this.scaleKey }); } @@ -170,6 +169,65 @@ export abstract class VoxSource { /** IndexedDB-backed local source. */ export class LocalVoxSource extends VoxSource { + override async listMaps(): Promise { + try { + const db = await this.getDb(); + const tx = db.transaction("maps", "readonly"); + const store = tx.objectStore("maps"); + const getAll = (store as any).getAll?.bind(store); + const rows: any[] = await new Promise((resolve, reject) => { + if (getAll) { + const req = getAll(); + req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(req.result || []); + return; + } + const out: any[] = []; + const req = store.openCursor(); + req.onerror = () => reject(req.error); + req.onsuccess = (ev: any) => { + const cursor = ev.target.result as IDBCursorWithValue | null; + if (cursor) { + out.push(cursor.value); + cursor.continue(); + } else { + resolve(out); + } + }; + }); + const maps: VoxMapConfig[] = []; + for (const r of rows) { + try { + const id = String(r?.mapId ?? r?.id ?? `local-${Date.now()}`); + const lower = Array.from(r?.baseVoxelOffset ?? [0, 0, 0]).map((v: any) => Number(v) | 0) as number[]; + const upper = Array.from(r?.upperVoxelBound ?? [0, 0, 0]).map((v: any) => Number(v) | 0) as number[]; + const cds = Array.from(r?.chunkDataSize ?? [64, 64, 64]).map((v: any) => Math.max(1, Number(v) | 0)) as number[]; + const bounds = [ + (upper[0] | 0) - (lower[0] | 0), + (upper[1] | 0) - (lower[1] | 0), + (upper[2] | 0) - (lower[2] | 0), + ]; + const steps = computeSteps(bounds, cds); + maps.push({ + id, + name: r?.name ?? id, + baseVoxelOffset: new Float32Array(lower as any), + upperVoxelBound: new Float32Array(upper as any), + chunkDataSize: new Uint32Array(cds as any), + dataType: r?.dataType ?? DataType.UINT32, + scaleMeters: r?.scaleMeters ?? undefined, + unit: r?.unit ?? undefined, + steps, + }); + } catch { + // skip malformed + } + } + return maps; + } catch { + return [] as VoxMapConfig[]; + } + } private dbPromise: Promise | null = null; override async getLabelIds(): Promise { @@ -223,19 +281,20 @@ export class LocalVoxSource extends VoxSource { } } - override async init(opts: VoxMapInitOptions) { - const meta = await super.init(opts); + override async init(map: VoxMapConfig) { + const meta = await super.init(map); const db = await this.getDb(); // Persist/update map metadata const tx = db.transaction("maps", "readwrite"); + const cfg = this.mapCfg!; tx.objectStore("maps").put( { mapId: this.mapId, - dataType: this.dataType, - chunkDataSize: Array.from(this.chunkDataSize), - upperVoxelBound: Array.from(this.upperVoxelBound), - baseVoxelOffset: Array.from(this.baseVoxelOffset), - unit: this.unit, + dataType: cfg.dataType, + chunkDataSize: Array.from(cfg.chunkDataSize as any), + upperVoxelBound: Array.from(cfg.upperVoxelBound as any), + baseVoxelOffset: Array.from(cfg.baseVoxelOffset as any), + unit: cfg.unit, scaleKey: this.scaleKey, updatedAt: Date.now(), }, @@ -258,7 +317,7 @@ export class LocalVoxSource extends VoxSource { const arr = new Uint32Array(buf); const sc: SavedChunk = { data: arr, - size: new Uint32Array(this.chunkDataSize), + size: new Uint32Array(this.mapCfg!.chunkDataSize as any), }; this.saved.set(key, sc); this.enforceCap(); @@ -281,12 +340,13 @@ export class LocalVoxSource extends VoxSource { const buf = await idbGet(db, "chunks", composite); if (buf) { const arr = new Uint32Array(buf); - sc = { data: arr, size: new Uint32Array(this.chunkDataSize) }; + sc = { data: arr, size: new Uint32Array(this.mapCfg!.chunkDataSize as any) }; this.saved.set(key, sc); this.enforceCap(); return sc; } - const sz = new Uint32Array(size ?? this.chunkDataSize); + const fallbackSize = new Uint32Array(this.mapCfg!.chunkDataSize as any); + const sz = new Uint32Array(size ?? fallbackSize); let total = 1; for (let i = 0; i < 3; ++i) total *= sz[i]; const arr = new Uint32Array(total); @@ -309,7 +369,7 @@ export class LocalVoxSource extends VoxSource { for (const e of edits) { const sc = await this.ensureChunk( e.key, - e.size ? new Uint32Array(e.size) : this.chunkDataSize, + e.size ? new Uint32Array(e.size) : (this.mapCfg!.chunkDataSize as any), ); this.applyEditsIntoChunk(sc, e.indices, e.value, e.values); this.markDirty(e.key); @@ -347,6 +407,51 @@ export class LocalVoxSource extends VoxSource { } export class RemoteVoxSource extends VoxSource { + async listMaps(): Promise { + try { + const qs = this.qs({}); + const json = await this.httpGetJson(`${this.baseUrl}/info${qs}`); + const datasets = Array.isArray(json?.datasets) ? json.datasets : []; + const out: VoxMapConfig[] = []; + const toXYZ = (ary: number[]) => { + const a = ary.map((v) => Math.max(0, Math.floor(v ?? 0))); + if (a.length >= 3) return [a[2] || 0, a[1] || 0, a[0] || 0]; + return [a[0] || 0, a[1] || 0, a[2] || 0]; + }; + for (const ds of datasets) { + try { + const id: string = String(ds?.mapId ?? ds?.id ?? ds?.name ?? ds?.url ?? `map-${Date.now()}`); + const arrays = Array.isArray(ds?.arrays) ? ds.arrays : []; + let arr = arrays.find((a: any) => a?.path === "0") ?? arrays[0]; + if (!arr) continue; + const shapeRaw = Array.isArray(arr?.shape) ? arr.shape : [0, 0, 0]; + const chunksRaw = Array.isArray(arr?.chunks) ? arr.chunks : [64, 64, 64]; + const dtypeRaw = String(arr?.dtype || "uint32"); + const dtype = dtypeRaw === "uint64" ? DataType.UINT64 : DataType.UINT32; + const upper = toXYZ(shapeRaw); + const cds = toXYZ(chunksRaw).map((v) => Math.max(1, v)); + const lower = [0, 0, 0]; + const steps = computeSteps(upper, cds); + out.push({ + id, + name: ds?.name ?? id, + baseVoxelOffset: new Float32Array(lower), + upperVoxelBound: new Float32Array(upper), + chunkDataSize: new Uint32Array(cds), + dataType: dtype, + steps, + serverUrl: this.baseUrl, + token: this.token, + }); + } catch { + // ignore + } + } + return out; + } catch { + return [] as VoxMapConfig[]; + } + } private labelsCache: number[] = []; private baseUrl: string; private token?: string; @@ -358,10 +463,10 @@ export class RemoteVoxSource extends VoxSource { } // ---- Public API overrides ---- - override async init(opts: VoxMapInitOptions) { - const meta = await super.init(opts); + override async init(map: VoxMapConfig) { + const meta = await super.init(map); // Bind dtype string - const dtypeStr = this.dtypeToString(this.dataType); + const dtypeStr = this.dtypeToString((this.mapCfg?.dataType ?? DataType.UINT32) as number); // Call /init (best-effort; server may already have it) const qs = this.qs({ mapId: this.mapId, @@ -384,7 +489,7 @@ export class RemoteVoxSource extends VoxSource { const buf = await this.httpGetArrayBuffer(`${this.baseUrl}/chunk${qs}`); if (!buf) return undefined; const arr = this.makeTypedArrayFromBuffer(buf); - const sc: SavedChunk = { data: arr, size: new Uint32Array(this.chunkDataSize) }; + const sc: SavedChunk = { data: arr, size: new Uint32Array(this.mapCfg!.chunkDataSize as any) }; this.saved.set(key, sc); this.enforceCap(); return sc; @@ -400,7 +505,8 @@ export class RemoteVoxSource extends VoxSource { sc = await this.getSavedChunk(key); if (sc) return sc; // allocate zero-filled - const sz = new Uint32Array(size ?? this.chunkDataSize); + const fallbackSize = new Uint32Array(this.mapCfg!.chunkDataSize as any); + const sz = new Uint32Array(size ?? fallbackSize); const total = (sz[0] | 0) * (sz[1] | 0) * (sz[2] | 0); const data = this.allocateTypedArray(total); sc = { data, size: new Uint32Array(sz) }; @@ -422,7 +528,7 @@ export class RemoteVoxSource extends VoxSource { for (const e of edits) { const sc = await this.ensureChunk( e.key, - e.size ? new Uint32Array(e.size) : this.chunkDataSize, + e.size ? new Uint32Array(e.size) : (this.mapCfg!.chunkDataSize as any), ); this.applyEditsIntoChunk(sc, e.indices, e.value, e.values); this.markDirty(e.key); @@ -467,12 +573,12 @@ export class RemoteVoxSource extends VoxSource { } private allocateTypedArray(total: number): Uint32Array | BigUint64Array { - if (this.dataType === DataType.UINT64) return new BigUint64Array(total); + if ((this.mapCfg?.dataType ?? DataType.UINT32) === DataType.UINT64) return new BigUint64Array(total); return new Uint32Array(total); } private makeTypedArrayFromBuffer(buf: ArrayBuffer): Uint32Array | BigUint64Array { - if (this.dataType === DataType.UINT64) return new BigUint64Array(buf); + if ((this.mapCfg?.dataType ?? DataType.UINT32) === DataType.UINT64) return new BigUint64Array(buf); return new Uint32Array(buf); } diff --git a/src/voxel_annotation/map.ts b/src/voxel_annotation/map.ts new file mode 100644 index 0000000000..f435724d7d --- /dev/null +++ b/src/voxel_annotation/map.ts @@ -0,0 +1,84 @@ +/** + * Vox map configuration and registry. Central place to compute and store LOD steps. + * No clamping is applied to the step computation: we simply multiply by `step` + * until the per-slice chunk budget is satisfied, then generate [1, step, ..., S]. + */ + +export interface VoxMapConfig { + id: string; + name?: string; + // Inclusive-exclusive bounds in voxel coordinates: [baseOffset, upperBound) + baseVoxelOffset: Float32Array | number[]; + upperVoxelBound: Float32Array | number[]; + // Chunking and scale + chunkDataSize: Uint32Array | number[]; + // Data type of the voxel labels (default: uint32) + dataType?: number; + scaleMeters?: Float64Array | number[]; // physical voxel size in meters + unit?: string; // convenience for UI + // Fixed LOD steps (factors), finest → coarsest, starting at 1. + steps: number[]; + // Optional remote info for convenience + serverUrl?: string; + token?: string; +} + +/** + * Compute LOD factors based on bounds and a per-slice chunk budget. + * - step: multiplicative step between levels (e.g., 2) + * - maxChunksPerSlice: approximate chunk budget for XY slice. + * + * Returns [1, step, ..., S] where S is the smallest factor that satisfies the budget. + */ +export function computeSteps( + bounds: readonly number[] | Float32Array, + chunkDataSize: readonly number[] | Uint32Array, + step = 2, + maxChunksPerSlice = 256, +): number[] { + const bx = Math.max(0, Math.floor(bounds[0] ?? 0)); + const by = Math.max(0, Math.floor(bounds[1] ?? 0)); + const cx = Math.max(1, Math.floor(chunkDataSize[0] ?? 1)); + const cy = Math.max(1, Math.floor(chunkDataSize[1] ?? 1)); + + const withinBudget = (factor: number) => { + const chunksX = Math.ceil((bx / Math.max(1, factor)) / cx); + const chunksY = Math.ceil((by / Math.max(1, factor)) / cy); + const chunkCount2D = (chunksX || 0) * (chunksY || 0); + return chunkCount2D <= maxChunksPerSlice; + }; + + let S = 1; + while (!withinBudget(S)) S *= Math.max(1, step); + + const factors: number[] = []; + for (let f = 1; f <= S; f *= Math.max(1, step)) factors.push(f); + if (factors.length === 0) factors.push(1); + return factors; +} + +/** Simple in-memory registry to hold current map selection and list. */ +class RegistryImpl { + private current?: VoxMapConfig; + private maps: VoxMapConfig[] = []; + + setCurrent(map: VoxMapConfig | undefined) { + this.current = map; + if (map && !this.maps.find((m) => m.id === map.id)) this.maps.push(map); + } + + getCurrent(): VoxMapConfig | undefined { + return this.current; + } + + upsert(map: VoxMapConfig) { + const idx = this.maps.findIndex((m) => m.id === map.id); + if (idx >= 0) this.maps[idx] = map; else this.maps.push(map); + } + + list(): VoxMapConfig[] { + return [...this.maps]; + } +} + +export const VoxMapRegistry = new RegistryImpl(); diff --git a/src/voxel_annotation/volume_chunk_source.ts b/src/voxel_annotation/volume_chunk_source.ts index dadb92d993..6d16806b13 100644 --- a/src/voxel_annotation/volume_chunk_source.ts +++ b/src/voxel_annotation/volume_chunk_source.ts @@ -24,6 +24,7 @@ import { import { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; import { DataType } from "#src/util/data_type.js"; import { VoxChunkSource } from "#src/voxel_annotation/frontend.js"; +import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; /** * This is an abstract representation of 3D (volumetric) data that can exist at multiple resolutions or "scales." @@ -38,11 +39,7 @@ import { VoxChunkSource } from "#src/voxel_annotation/frontend.js"; * - Asynchronous: Data loading is typically asynchronous, as it might involve fetching from a remote server or reading from large local files. */ export interface VoxMultiscaleOptions { - chunkDataSize?: Uint32Array | number[]; - upperVoxelBound?: Float32Array | number[]; - baseVoxelOffset?: Float32Array | number[]; - voxServerUrl?: string; - voxToken?: string; + map?: VoxMapConfig; } export class VoxMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSource { @@ -52,96 +49,63 @@ export class VoxMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSource return 3; } - private cfgChunkDataSize: Uint32Array; - private cfgUpperVoxelBound: Float32Array; - private cfgBaseVoxelOffset: Float32Array; - private voxServerUrl?: string; - private voxToken?: string; + private mapCfg?: VoxMapConfig; constructor(chunkManager: ChunkManager, options?: VoxMultiscaleOptions) { super(chunkManager); - this.cfgChunkDataSize = new Uint32Array( - options?.chunkDataSize ? Array.from(options.chunkDataSize) : [64, 64, 64], - ); - this.cfgUpperVoxelBound = new Float32Array( - options?.upperVoxelBound - ? Array.from(options.upperVoxelBound) - : [1_000, 1_000, 1_000], - ); - this.cfgBaseVoxelOffset = new Float32Array( - options?.baseVoxelOffset - ? Array.from(options.baseVoxelOffset) - : [0, 0, 0], - ); - this.voxServerUrl = options?.voxServerUrl; - this.voxToken = options?.voxToken; + this.mapCfg = options?.map; + if (this.mapCfg?.dataType != null) { + this.dataType = this.mapCfg.dataType as any; + } } getSources(_options: VolumeSourceOptions) { - // Provide a base scale and a coarse "guard" scale to avoid memory blowups at extreme zoom out. + // Steps are computed during map creation and saved. Here we just consume the bound map. + const map = this.mapCfg; + if (!map) return []; const rank = this.rank; - // Base (fine) scale specification. + const chunkDataSize = new Uint32Array(Array.from(map.chunkDataSize)); + const upperVoxelBound = new Float32Array(Array.from(map.upperVoxelBound)); + const baseVoxelOffset = new Float32Array(Array.from(map.baseVoxelOffset)); + const baseSpec = makeVolumeChunkSpecification({ rank, dataType: this.dataType, - chunkDataSize: this.cfgChunkDataSize, - upperVoxelBound: this.cfgUpperVoxelBound, - baseVoxelOffset: this.cfgBaseVoxelOffset, + chunkDataSize, + upperVoxelBound, + baseVoxelOffset, }); const baseSource: VoxChunkSource = this.chunkManager.getChunkSource( VoxChunkSource as any, - { spec: baseSpec, vox: { serverUrl: this.voxServerUrl, token: this.voxToken } }, + { + spec: baseSpec, + vox: { + serverUrl: map.serverUrl, + token: map.token, + }, + }, ); - // Identity transform for base scale. - const identity = new Float32Array((rank + 1) * (rank + 1)); - for (let i = 0; i < rank; ++i) { - identity[i * (rank + 1) + i] = 1; - } - identity[rank * (rank + 1) + rank] = 1; - - const base: SliceViewSingleResolutionSource = { - chunkSource: baseSource, - chunkToMultiscaleTransform: identity, - lowerClipBound: baseSpec.lowerVoxelBound, - upperClipBound: baseSpec.upperVoxelBound, + // Helper to make a homogeneous scaling transform matrix with scale factor f. + const makeScale = (f: number) => { + const m = new Float32Array((rank + 1) * (rank + 1)); + for (let i = 0; i < rank; ++i) m[i * (rank + 1) + i] = f; + m[rank * (rank + 1) + rank] = 1; + return m; }; - // Coarse guard scale: no chunks will be created (zero-sized bounds) but it will be selected - // at extremely low zoom levels due to a very large voxel scale transform. - const guardSpec = makeVolumeChunkSpecification({ - rank, - dataType: this.dataType, - // Use same chunk size; since bounds are empty below, no chunks are actually requested. - chunkDataSize: this.cfgChunkDataSize, - // Zero-sized bounds => lowerChunkBound === upperChunkBound, therefore 0 chunks. - upperVoxelBound: new Float32Array(rank), - lowerVoxelBound: new Float32Array(rank), - }); + const factors = map.steps && map.steps.length > 0 ? [...map.steps] : [1]; - const guardSource: VoxChunkSource = this.chunkManager.getChunkSource( - VoxChunkSource as any, - { spec: guardSpec, vox: { serverUrl: this.voxServerUrl, token: this.voxToken } }, + const levels: SliceViewSingleResolutionSource[] = factors.map( + (f) => ({ + chunkSource: baseSource, + chunkToMultiscaleTransform: makeScale(f), + lowerClipBound: baseSpec.lowerVoxelBound, + upperClipBound: baseSpec.upperVoxelBound, + }), ); - // Large diagonal scale to make effective voxel size huge, ensuring guard scale is used when - // zoomed out. Homogeneous (rank+1)x(rank+1) matrix. - const scale = 10; - const guardXform = new Float32Array((rank + 1) * (rank + 1)); - for (let i = 0; i < rank; ++i) { - guardXform[i * (rank + 1) + i] = scale; - } - guardXform[rank * (rank + 1) + rank] = 1; - - const guard: SliceViewSingleResolutionSource = { - chunkSource: guardSource, - chunkToMultiscaleTransform: guardXform, - lowerClipBound: guardSpec.lowerVoxelBound, - upperClipBound: guardSpec.upperVoxelBound, - }; - - // Outer array: orientations. Inner array: scales ordered from finest -> coarsest. - return [[base, guard]]; + return [levels]; } } From 8cfd64466b80f0d99147f7d5b6ddb7737db94063 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 030/251] feat: cleanup map init/selection implementation, the remote still needs an update to align with the new architecture --- src/layer/vox/index.ts | 99 ++++++-------------------------- src/layer/vox/tabs/settings.ts | 65 ++++++++++----------- src/voxel_annotation/index.ts | 102 ++++++++++++++------------------- src/voxel_annotation/map.ts | 10 ++-- 4 files changed, 95 insertions(+), 181 deletions(-) diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index c8ee028f05..ff75c90051 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -57,7 +57,7 @@ import { VoxMultiscaleVolumeChunkSource } from "#src/voxel_annotation/volume_chu export class VoxUserLayer extends UserLayer { onLabelsChanged?: () => void; - voxMapId: string | undefined; + voxMapRegistry = new VoxMapRegistry(); // Label state for painting: only store ids; colors are hashed from id on the fly voxLabels: { id: number }[] = []; voxSelectedLabelId: number | undefined = undefined; @@ -72,16 +72,6 @@ export class VoxUserLayer extends UserLayer { static typeAbbreviation = "vox"; voxEditController?: VoxelEditController; - // Settings state - voxScale: Float64Array = new Float64Array([ - 0.000000008, 0.000000008, 0.000000008, - ]); - voxScaleUnit: string = "nm"; - // Region selection via corners - voxCornerA: Float32Array = new Float32Array([0, 0, 0]); - voxCornerB: Float32Array = new Float32Array([ - 1_000_000, 1_000_000, 1_000_000, - ]); // Draw tool state voxBrushRadius: number = 3; voxEraseMode: boolean = false; @@ -215,53 +205,17 @@ export class VoxUserLayer extends UserLayer { this.tabs.default = "vox"; } - applyVoxSettings( - scale: Float64Array, - unit: string, - cornerA: Float32Array, - cornerB: Float32Array, - ) { - // Update and rebuild if values changed. - let changed = false; - // Update scale - for (let i = 0; i < 3; ++i) { - if (this.voxScale[i] !== scale[i]) { - this.voxScale[i] = scale[i]; - changed = true; - } - } - // Normalize corners to an axis-aligned [lower, upper) box - const lower = new Float32Array(3); - const upper = new Float32Array(3); - for (let i = 0; i < 3; ++i) { - const lo = Math.floor(Math.min(cornerA[i], cornerB[i])); - const up = Math.ceil(Math.max(cornerA[i], cornerB[i])); - lower[i] = lo; - upper[i] = Math.max(up, lo + 1); // enforce non-empty - } - // Update stored corners and derived upper bound - for (let i = 0; i < 3; ++i) { - if (this.voxCornerA[i] !== cornerA[i]) { - this.voxCornerA[i] = cornerA[i]; - changed = true; - } - if (this.voxCornerB[i] !== cornerB[i]) { - this.voxCornerB[i] = cornerB[i]; - changed = true; - } - } - if (this.voxScaleUnit !== unit) { - this.voxScaleUnit = unit; - changed = true; - } - if (changed) this.buildOrRebuildVoxLayer(); - } - private createIdentity3D() { + const map = this.voxMapRegistry.getCurrent(); + if (!map || !map.scaleMeters || !map.unit) { + console.log("debug: ", map) + throw new Error("createIdentity3D: no map selected or missing properties"); + } + const units = [ - this.voxScaleUnit, - this.voxScaleUnit, - this.voxScaleUnit, + map.unit, + map.unit, + map.unit, ] as string[]; return new WatchableCoordinateSpaceTransform( @@ -270,7 +224,7 @@ export class VoxUserLayer extends UserLayer { rank: 3, names: ["x", "y", "z"], units, - scales: new Float64Array(this.voxScale), + scales: new Float64Array(map.scaleMeters as number[]), }), ), ); @@ -398,47 +352,32 @@ export class VoxUserLayer extends UserLayer { const ls = this.voxLoadedSubsource; if (!ls) return; - console.log("buildOrRebuildVoxLayer"); // Require an explicit map selection/creation - const map = VoxMapRegistry.getCurrent(); + const map = this.voxMapRegistry.getCurrent(); if (!map) return; - const guardScale = Array.from(this.voxScale); + const guardScale = Array.from(map?.scaleMeters || [1, 1, 1]); // Use map bounds for guard and source - const upper = new Float32Array(map.upperVoxelBound as any); + const upper = new Float32Array(map.upperVoxelBound as number[]); const guardBounds = Array.from(upper); - const guardUnit = map.unit || this.voxScaleUnit; + const guardUnit = map.unit; ls.activate( () => { - console.log("buildOrRebuildVoxLayer: activate source", map.id); const voxSource = new VoxMultiscaleVolumeChunkSource( this.manager.chunkManager, { - map: map as any, + map: map, }, ); // Expose a controller so tools can paint voxels via the source. this.voxEditController = new VoxelEditController(voxSource); - // Initialize worker-side map persistence for this source (best-effort, fire-and-forget). const sources2D = voxSource.getSources({} as any); const base = sources2D[0]?.[0]; if (base) { const source = base.chunkSource as any; - // Ensure we have a stable id on the map for persistence purposes. - const mapId = map.id || this.voxMapId || "local"; - this.voxMapId = mapId; - const mapForInit = { - ...map, - id: mapId, - unit: guardUnit, - serverUrl: map.serverUrl ?? this.voxServerUrl, - token: map.token ?? this.voxServerToken, - dataType: map.dataType ?? voxSource.dataType, - } as any; - // Initialize backend map first, then load labels from the chosen datasource. - source.initializeMap(mapForInit); + source.initializeMap(map); this.loadLabels(); } @@ -503,9 +442,7 @@ export class VoxUserLayer extends UserLayer { // Local in-memory vox datasource. this.voxServerUrl = undefined; this.voxServerToken = undefined; - this.voxMapId = "local"; this.voxLoadedSubsource = loadedSubsource; - this.buildOrRebuildVoxLayer(); continue; } @@ -518,9 +455,7 @@ export class VoxUserLayer extends UserLayer { await this.verifyVoxRemote(parsed.baseUrl, parsed.token); this.voxServerUrl = parsed.baseUrl; this.voxServerToken = parsed.token; - this.voxMapId = "remote"; this.voxLoadedSubsource = loadedSubsource; - this.buildOrRebuildVoxLayer(); } catch (e: any) { const msg = `Vox remote source check failed: ${e?.message || e}`; loadedSubsource.deactivate(msg); diff --git a/src/layer/vox/tabs/settings.ts b/src/layer/vox/tabs/settings.ts index 02c057fca1..8381acf2b2 100644 --- a/src/layer/vox/tabs/settings.ts +++ b/src/layer/vox/tabs/settings.ts @@ -2,8 +2,8 @@ * Vox Settings tab UI split from index.ts */ import type { VoxUserLayer } from "#src/layer/vox/index.js"; -import { VoxMapRegistry, computeSteps } from "#src/voxel_annotation/map.js"; import { DataType } from "#src/util/data_type.js"; +import { computeSteps, VoxMapConfig } from "#src/voxel_annotation/map.js"; import { Tab } from "#src/widget/tab_view.js"; export class VoxSettingsTab extends Tab { @@ -36,10 +36,6 @@ export class VoxSettingsTab extends Tab { return inp; }; - const sMeters = this.layer.voxScale; // stored in meters - const a = this.layer.voxCornerA; - const c = this.layer.voxCornerB; - // Unit helpers const unitFactor: Record = { m: 1, @@ -47,8 +43,7 @@ export class VoxSettingsTab extends Tab { µm: 1e-6, nm: 1e-9, }; - const currentUnit = - this.layer.voxScaleUnit in unitFactor ? this.layer.voxScaleUnit : "m"; + const currentUnit = "nm"; const factor = (u: string) => unitFactor[u] ?? 1; // Prepare UI elements @@ -63,17 +58,17 @@ export class VoxSettingsTab extends Tab { let prevUnit = currentUnit; // Show scale values in the chosen unit for convenience - const sx = makeNumberInput(sMeters[0] / factor(currentUnit), "any"); - const sy = makeNumberInput(sMeters[1] / factor(currentUnit), "any"); - const sz = makeNumberInput(sMeters[2] / factor(currentUnit), "any"); + const sx = makeNumberInput(8, "any"); + const sy = makeNumberInput(8, "any"); + const sz = makeNumberInput(8, "any"); - const ax = makeNumberInput(a[0], "1"); - const ay = makeNumberInput(a[1], "1"); - const az = makeNumberInput(a[2], "1"); + const ax = makeNumberInput(0, "1"); + const ay = makeNumberInput(0, "1"); + const az = makeNumberInput(0, "1"); - const bx = makeNumberInput(c[0], "1"); - const by = makeNumberInput(c[1], "1"); - const bz = makeNumberInput(c[2], "1"); + const bx = makeNumberInput(100_000, "1"); + const by = makeNumberInput(100_000, "1"); + const bz = makeNumberInput(100_000, "1"); element.appendChild(row("Scale (x,y,z)", [sx, sy, sz])); element.appendChild(row("Scale unit", [unitSel])); @@ -98,7 +93,7 @@ export class VoxSettingsTab extends Tab { const mapIdInp = document.createElement("input"); mapIdInp.type = "text"; mapIdInp.placeholder = "map id"; - mapIdInp.value = this.layer.voxMapId || ""; + mapIdInp.value = ""; const mapNameInp = document.createElement("input"); mapNameInp.type = "text"; mapNameInp.placeholder = "map name"; @@ -109,7 +104,7 @@ export class VoxSettingsTab extends Tab { const mapsSel = document.createElement("select"); const refreshMaps = () => { mapsSel.innerHTML = ""; - const maps = VoxMapRegistry.list(); + const maps = this.layer.voxMapRegistry.list(); for (const m of maps) { const opt = document.createElement("option"); opt.value = m.id; @@ -134,7 +129,7 @@ export class VoxSettingsTab extends Tab { const src = new LocalVoxSource(); maps = await src.listMaps(); } - for (const m of maps) VoxMapRegistry.upsert(m as any); + for (const m of maps) this.layer.voxMapRegistry.upsert(m as any); refreshMaps(); } catch { // ignore @@ -152,19 +147,19 @@ export class VoxSettingsTab extends Tab { const syNum = Number.parseFloat(sy.value); const szNum = Number.parseFloat(sz.value); const ns = new Float64Array([ - Number.isFinite(sxNum) ? sxNum * f : sMeters[0], - Number.isFinite(syNum) ? syNum * f : sMeters[1], - Number.isFinite(szNum) ? szNum * f : sMeters[2], + sxNum * f, + syNum * f, + szNum * f ]); const ca = new Float32Array([ - Math.floor(Number(ax.value) || this.layer.voxCornerA[0]), - Math.floor(Number(ay.value) || this.layer.voxCornerA[1]), - Math.floor(Number(az.value) || this.layer.voxCornerA[2]), + Math.floor(Number(ax.value)), + Math.floor(Number(ay.value)), + Math.floor(Number(az.value)), ]); const cb = new Float32Array([ - Math.floor(Number(bx.value) || this.layer.voxCornerB[0]), - Math.floor(Number(by.value) || this.layer.voxCornerB[1]), - Math.floor(Number(bz.value) || this.layer.voxCornerB[2]), + Math.floor(Number(bx.value)), + Math.floor(Number(by.value)), + Math.floor(Number(bz.value)), ]); // Normalize bounds @@ -184,10 +179,10 @@ export class VoxSettingsTab extends Tab { const chunkDataSize = [64, 64, 64]; const steps = computeSteps(bounds, chunkDataSize); - const id = mapIdInp.value || this.layer.voxMapId || `map-${Date.now()}`; + const id = mapIdInp.value || `map-${Date.now()}`; const name = mapNameInp.value || id; - const map = { + const map:VoxMapConfig = { id, name, baseVoxelOffset: lower, @@ -200,9 +195,9 @@ export class VoxSettingsTab extends Tab { serverUrl: this.layer.voxServerUrl, token: this.layer.voxServerToken, }; - VoxMapRegistry.upsert(map as any); - VoxMapRegistry.setCurrent(map as any); - this.layer.applyVoxSettings(ns, u, ca, cb); + this.layer.voxMapRegistry.upsert(map); + this.layer.voxMapRegistry.setCurrent(map); + this.layer.buildOrRebuildVoxLayer(); refreshMaps(); }); element.appendChild(createBtn); @@ -211,9 +206,9 @@ export class VoxSettingsTab extends Tab { selectBtn.textContent = "Select Map"; selectBtn.addEventListener("click", () => { const id = mapsSel.value; - const found = VoxMapRegistry.list().find((m) => m.id === id); + const found = this.layer.voxMapRegistry.list().find((m) => m.id === id); if (found) { - VoxMapRegistry.setCurrent(found); + this.layer.voxMapRegistry.setCurrent(found); this.layer.buildOrRebuildVoxLayer(); } }); diff --git a/src/voxel_annotation/index.ts b/src/voxel_annotation/index.ts index 23b2a88df9..bed9374584 100644 --- a/src/voxel_annotation/index.ts +++ b/src/voxel_annotation/index.ts @@ -46,7 +46,7 @@ export abstract class VoxSource { } protected mapId: string = "default"; protected scaleKey: string = ""; - protected mapCfg?: VoxMapConfig; // Keep the entire configuration in one place + protected mapCfg: VoxMapConfig; // Keep the entire configuration in one place // In-memory cache of loaded chunks protected maxSavedChunks = 128; // cap to prevent unbounded growth @@ -69,35 +69,13 @@ export abstract class VoxSource { } init(map: VoxMapConfig): Promise<{ mapId: string; scaleKey: string }> { - // Store the whole map config instead of decomposing into many fields. - const cfgIn = (map || ({} as VoxMapConfig)); - // Normalize arrays and defaults while keeping a single cfg object. - const id = cfgIn.id || this.mapId || (typeof crypto !== "undefined" && (crypto as any).randomUUID?.()) || String(Date.now()); - const chunkDataSize = new Uint32Array(Array.from(cfgIn.chunkDataSize ?? [64, 64, 64])); - const upperVoxelBound = new Float32Array(Array.from(cfgIn.upperVoxelBound ?? [0, 0, 0])); - const baseVoxelOffset = new Float32Array(Array.from(cfgIn.baseVoxelOffset ?? [0, 0, 0])); - const dataType = (cfgIn.dataType ?? DataType.UINT32) as number; - const unit = cfgIn.unit ?? ""; - const steps = Array.isArray(cfgIn.steps) && cfgIn.steps.length > 0 ? [...cfgIn.steps] : [1]; - const scaleMeters = cfgIn.scaleMeters - ? new Float64Array(Array.from(cfgIn.scaleMeters as any)) - : undefined; - - this.mapCfg = { - ...cfgIn, - id, - chunkDataSize, - upperVoxelBound, - baseVoxelOffset, - dataType, - unit, - steps, - scaleMeters, - } as VoxMapConfig; - - this.mapId = id; - // Compute scaleKey from config to avoid collisions across regions - this.scaleKey = toScaleKey(chunkDataSize, baseVoxelOffset, upperVoxelBound); + if(!map) + { + throw new Error("VoxSource: init: Map config is required"); + } + this.mapCfg = map; + this.mapId = map.id; + this.scaleKey = toScaleKey(map.chunkDataSize, map.baseVoxelOffset, map.upperVoxelBound); return Promise.resolve({ mapId: this.mapId, scaleKey: this.scaleKey }); } @@ -198,10 +176,27 @@ export class LocalVoxSource extends VoxSource { const maps: VoxMapConfig[] = []; for (const r of rows) { try { - const id = String(r?.mapId ?? r?.id ?? `local-${Date.now()}`); - const lower = Array.from(r?.baseVoxelOffset ?? [0, 0, 0]).map((v: any) => Number(v) | 0) as number[]; - const upper = Array.from(r?.upperVoxelBound ?? [0, 0, 0]).map((v: any) => Number(v) | 0) as number[]; - const cds = Array.from(r?.chunkDataSize ?? [64, 64, 64]).map((v: any) => Math.max(1, Number(v) | 0)) as number[]; + if ( + r?.id === undefined || + r?.baseVoxelOffset === undefined || + r?.upperVoxelBound === undefined || + r?.chunkDataSize === undefined || + r?.dataType === undefined || + r?.scaleMeters === undefined || + r?.unit === undefined + ) { + throw new Error("Invalid map configuration"); + } + const id = String(r.id); + const lower = Array.from(r.baseVoxelOffset).map((v: any) => + Number(v), + ) as number[]; + const upper = Array.from(r.upperVoxelBound).map((v: any) => + Number(v), + ) as number[]; + const cds = Array.from(r.chunkDataSize).map((v: any) => + Math.max(1, Number(v)), + ) as number[]; const bounds = [ (upper[0] | 0) - (lower[0] | 0), (upper[1] | 0) - (lower[1] | 0), @@ -211,12 +206,12 @@ export class LocalVoxSource extends VoxSource { maps.push({ id, name: r?.name ?? id, - baseVoxelOffset: new Float32Array(lower as any), - upperVoxelBound: new Float32Array(upper as any), - chunkDataSize: new Uint32Array(cds as any), - dataType: r?.dataType ?? DataType.UINT32, - scaleMeters: r?.scaleMeters ?? undefined, - unit: r?.unit ?? undefined, + baseVoxelOffset: new Float32Array(lower), + upperVoxelBound: new Float32Array(upper), + chunkDataSize: new Uint32Array(cds), + dataType: r.dataType, + scaleMeters: r.scaleMeters, + unit: r.unit, steps, }); } catch { @@ -288,16 +283,7 @@ export class LocalVoxSource extends VoxSource { const tx = db.transaction("maps", "readwrite"); const cfg = this.mapCfg!; tx.objectStore("maps").put( - { - mapId: this.mapId, - dataType: cfg.dataType, - chunkDataSize: Array.from(cfg.chunkDataSize as any), - upperVoxelBound: Array.from(cfg.upperVoxelBound as any), - baseVoxelOffset: Array.from(cfg.baseVoxelOffset as any), - unit: cfg.unit, - scaleKey: this.scaleKey, - updatedAt: Date.now(), - }, + cfg, this.mapId, ); await txDone(tx); @@ -422,17 +408,17 @@ export class RemoteVoxSource extends VoxSource { try { const id: string = String(ds?.mapId ?? ds?.id ?? ds?.name ?? ds?.url ?? `map-${Date.now()}`); const arrays = Array.isArray(ds?.arrays) ? ds.arrays : []; - let arr = arrays.find((a: any) => a?.path === "0") ?? arrays[0]; + const arr = arrays.find((a: any) => a?.path === "0") ?? arrays[0]; if (!arr) continue; - const shapeRaw = Array.isArray(arr?.shape) ? arr.shape : [0, 0, 0]; - const chunksRaw = Array.isArray(arr?.chunks) ? arr.chunks : [64, 64, 64]; - const dtypeRaw = String(arr?.dtype || "uint32"); - const dtype = dtypeRaw === "uint64" ? DataType.UINT64 : DataType.UINT32; - const upper = toXYZ(shapeRaw); - const cds = toXYZ(chunksRaw).map((v) => Math.max(1, v)); + // TODO: the server way of storing maps is wrong, espcially the bounds, data organization and missing scale and unit + const dtype = String(arr?.dtype) === "uint64" ? DataType.UINT64 : DataType.UINT32; + const upper = toXYZ(arr.shape); + const cds = toXYZ(arr.chunks).map((v) => Math.max(1, v)); const lower = [0, 0, 0]; const steps = computeSteps(upper, cds); out.push({ + scaleMeters: [0.000000008, 0.000000008, 0.000000008], + unit: "nm", id, name: ds?.name ?? id, baseVoxelOffset: new Float32Array(lower), @@ -441,7 +427,7 @@ export class RemoteVoxSource extends VoxSource { dataType: dtype, steps, serverUrl: this.baseUrl, - token: this.token, + token: this.token }); } catch { // ignore diff --git a/src/voxel_annotation/map.ts b/src/voxel_annotation/map.ts index f435724d7d..618e7885df 100644 --- a/src/voxel_annotation/map.ts +++ b/src/voxel_annotation/map.ts @@ -13,9 +13,9 @@ export interface VoxMapConfig { // Chunking and scale chunkDataSize: Uint32Array | number[]; // Data type of the voxel labels (default: uint32) - dataType?: number; - scaleMeters?: Float64Array | number[]; // physical voxel size in meters - unit?: string; // convenience for UI + dataType: number; + scaleMeters: Float64Array | number[]; // physical voxel size in meters + unit: string; // convenience for UI // Fixed LOD steps (factors), finest → coarsest, starting at 1. steps: number[]; // Optional remote info for convenience @@ -58,7 +58,7 @@ export function computeSteps( } /** Simple in-memory registry to hold current map selection and list. */ -class RegistryImpl { +export class VoxMapRegistry { private current?: VoxMapConfig; private maps: VoxMapConfig[] = []; @@ -80,5 +80,3 @@ class RegistryImpl { return [...this.maps]; } } - -export const VoxMapRegistry = new RegistryImpl(); From 792c8fa4cd2e55dba4f56a617aead2faacd28b06 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 031/251] refactor: remove `VoxelPixelLegacyTool`, update references, and enable LOD-based brush painting - Deprecate `VoxelPixelLegacyTool` in favor of `VoxelBrushLegacyTool` with LOD support. - Remove related UI elements, tools, and documentation entries for pixel-based tools. - Add support for Level of Detail (LOD) in brush painting methods, adjusting grid resolution based on brush size. - Refactor voxel painting logic with stricter type safety and enhanced error handling. - Revise controller and frontend workflows for LOD-based rendering and updates. --- .junie/guidelines.md | 7 + NOTES/TODOs.md | 6 +- src/layer/vox/tabs/tools.ts | 10 +- src/ui/voxel_annotations.ts | 21 --- src/voxel_annotation/edit_controller.ts | 169 +++++++++----------- src/voxel_annotation/frontend.ts | 20 ++- src/voxel_annotation/volume_chunk_source.ts | 36 +++-- 7 files changed, 125 insertions(+), 144 deletions(-) diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 32c2b3bd73..43cc43d7f5 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -1 +1,8 @@ +You are a coding expert in typescript, webgl, and neuroglancer. + +You must follow the following code guidelines: +- Use detailed variable and function names, a good code should explain itself without comments. +- Avoid fallbacks and default values, always prefer throwing errors on unexpected behavior. +- Avoid casting with `as` unless absolutely necessary, prefer proper type definitions and checks. + You are here to help me implement a new voxel annotation feature into neuroglancer. See [vox-annotation-project-overview.md](../NOTES/vox-annotation-project-overview.md) for complete project details. diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index eca46af1f7..ec21b9c17a 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,5 +1,9 @@ # TODO List +- LOD -> where am I: + - choosing the lod level depanding on the brush size, live rendering is working but data saving need to be updated. + +- cleanup label handling code (more specifically in the ui code: layer/vox/index.ts, would be nice to have a handler similar to the one for maps) - Add redundancy to avoid corrupt/unsaved chunks on the remote - Test token authentication - Try to import pre-computed segmentation (in zarr format) into the remote server @@ -9,7 +13,7 @@ - the uncaching of chunks the VoxSource is working great, but since it has no way of knowing which chunks are in view, it will delete them, causing flickering of the drawings. - look into the massive ram usage when a lot of voxel annotations are drawn - Add Uint64 support for annotation id -- LOD +- Replace the current map settings to use the built-ins of neuroglancer (viewable under the datasource url), handle multimap with link choices, look into how to keep the init/creation logic. # Saving/importing/exporting diff --git a/src/layer/vox/tabs/tools.ts b/src/layer/vox/tabs/tools.ts index 39ae1817bf..9b944620fa 100644 --- a/src/layer/vox/tabs/tools.ts +++ b/src/layer/vox/tabs/tools.ts @@ -2,7 +2,7 @@ * Vox Tool tab UI split from index.ts */ import type { VoxUserLayer } from "#src/layer/vox/index.js"; -import { VoxelBrushLegacyTool, VoxelPixelLegacyTool } from "#src/ui/voxel_annotations.js"; +import { VoxelBrushLegacyTool } from "#src/ui/voxel_annotations.js"; import { Tab } from "#src/widget/tab_view.js"; @@ -81,13 +81,6 @@ export class VoxToolTab extends Tab { toolsWrap.style.display = "flex"; toolsWrap.style.gap = "8px"; - const pixelButton = document.createElement("button"); - pixelButton.textContent = "Pixel"; - pixelButton.title = "ctrl+click to paint a pixel"; - pixelButton.addEventListener("click", () => { - this.layer.tool.value = new VoxelPixelLegacyTool(this.layer); - }); - const brushButton = document.createElement("button"); brushButton.textContent = "Brush"; brushButton.title = "ctrl+click to paint a small sphere"; @@ -95,7 +88,6 @@ export class VoxToolTab extends Tab { this.layer.tool.value = new VoxelBrushLegacyTool(this.layer); }); - toolsWrap.appendChild(pixelButton); toolsWrap.appendChild(brushButton); toolsRow.appendChild(toolsLabel); toolsRow.appendChild(toolsWrap); diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 62a5b3995a..ef1e41994f 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -18,7 +18,6 @@ import type { MouseSelectionState } from "#src/layer/index.js"; import type { VoxUserLayer } from "#src/layer/vox/index.js"; import { LegacyTool, registerLegacyTool } from "#src/ui/tool.js"; -export const PIXEL_TOOL_ID = "voxPixel"; export const BRUSH_TOOL_ID = "voxBrush"; abstract class BaseVoxelLegacyTool extends LegacyTool { @@ -126,22 +125,6 @@ abstract class BaseVoxelLegacyTool extends LegacyTool { } } -export class VoxelPixelLegacyTool extends BaseVoxelLegacyTool { - description = "pixel"; - - toJSON() { - return PIXEL_TOOL_ID; - } - - protected paintPoint(point: Float32Array, value: number) { - (this.layer as any).voxEditController?.paintVoxelsBatch([point], value); - } - - protected paintPoints(points: Float32Array[], value: number) { - (this.layer as any).voxEditController?.paintVoxelsBatch(points, value); - } -} - export class VoxelBrushLegacyTool extends BaseVoxelLegacyTool { description = "brush"; @@ -188,10 +171,6 @@ export class VoxelBrushLegacyTool extends BaseVoxelLegacyTool { } export function registerVoxelAnnotationTools() { - registerLegacyTool( - PIXEL_TOOL_ID, - (layer) => new VoxelPixelLegacyTool(layer as unknown as VoxUserLayer), - ); registerLegacyTool( BRUSH_TOOL_ID, (layer) => new VoxelBrushLegacyTool(layer as unknown as VoxUserLayer), diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index c9580c6d11..f74c6d1583 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -6,130 +6,117 @@ import type { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; import type { VoxChunkSource } from "#src/voxel_annotation/frontend.js"; -/** Tiny controller to forward voxel edits from tools to the VoxChunkSource. */ export class VoxelEditController { constructor(private multiscale: MultiscaleVolumeChunkSource) {} + private static readonly qualityFactor = 5.0; - private getSource(): VoxChunkSource | undefined { - try { - const sources2D = this.multiscale.getSources({} as any); - const single = sources2D?.[0]?.[0]; - return single?.chunkSource as VoxChunkSource | undefined; - } catch { - return undefined; + // Required: compute desired voxel size (power-of-two) from brush radius. + getOptimalVoxelSize(brushRadius: number, minLOD = 1, maxLOD = 128) { + if (!Number.isFinite(brushRadius) || brushRadius <= 0) { + return minLOD; } + const targetSize = brushRadius / VoxelEditController.qualityFactor; + const exponent = Math.round(Math.log2(targetSize)); + let voxelSize = Math.pow(2, exponent); + voxelSize = Math.max(minLOD, Math.min(voxelSize, maxLOD)); + return voxelSize; } - paintVoxelsBatch(voxels: Float32Array[], value: number) { - if (!voxels || voxels.length === 0) return; - try { - const source = this.getSource(); - source?.paintVoxelsBatch(voxels, value); - } catch { - // no-op - } - } - - /** Paint a brush with selectable shape: 'disk' (2D oriented to slice plane) or 'sphere' (3D). Default: 'disk'. */ + // Paint a disk (slice-aligned via basis) or sphere in WORLD/ canonical units; we transform to LOD grid before sending. paintBrushWithShape( - center: Float32Array, - radius: number, + centerCanonical: Float32Array, + radiusCanonical: number, value: number, shape: "disk" | "sphere" = "disk", basis?: { u: Float32Array; v: Float32Array }, ) { - if (!Number.isFinite(radius) || radius <= 0) return; - const r = Math.floor(radius); - const cx = Math.floor(center[0] ?? 0); - const cy = Math.floor(center[1] ?? 0); - const cz = Math.floor(center[2] ?? 0); + if (!Number.isFinite(radiusCanonical) || radiusCanonical <= 0) { + throw new Error("paintBrushWithShape: 'radius' must be > 0."); + } + if (!centerCanonical || centerCanonical.length < 3) { + throw new Error("paintBrushWithShape: 'center' must be a Float32Array[3]."); + } + + const voxelSize = this.getOptimalVoxelSize(radiusCanonical); + const sourceIndex = Math.round(Math.log2(voxelSize)); + const src2D = this.multiscale.getSources({} as any); + if (!src2D || !src2D[0] || src2D[0].length <= sourceIndex) { + throw new Error("VoxelEditController: No multiscale levels available."); + } + const source = src2D[0][sourceIndex]?.chunkSource as VoxChunkSource; + if (!source) throw new Error("paintVoxelsBatch: Selected level has no chunk source."); + + // Convert center and radius to the level’s voxel grid. + const cx = Math.floor((centerCanonical[0] ?? 0) / voxelSize); + const cy = Math.floor((centerCanonical[1] ?? 0) / voxelSize); + const cz = Math.floor((centerCanonical[2] ?? 0) / voxelSize); + const r = Math.floor(radiusCanonical / voxelSize); + if (r <= 0) { + throw new Error("paintBrushWithShape: radius too small for selected LOD."); + } const rr = r * r; - const source = this.getSource(); - if (!source) return; - const voxels: Float32Array[] = []; + + const voxelsLOD: Float32Array[] = []; + if (shape === "sphere") { for (let dz = -r; dz <= r; ++dz) { for (let dy = -r; dy <= r; ++dy) { for (let dx = -r; dx <= r; ++dx) { if (dx * dx + dy * dy + dz * dz <= rr) { - voxels.push(new Float32Array([cx + dx, cy + dy, cz + dz])); + voxelsLOD.push(new Float32Array([cx + dx, cy + dy, cz + dz])); } } } } } else { - // Oriented disk in the provided slice plane; if basis not provided, fall back to XY at fixed Z = cz. - const u = basis?.u; - const v = basis?.v; - if ( - u && - v && - Number.isFinite(u[0]) && - Number.isFinite(u[1]) && - Number.isFinite(u[2]) && - Number.isFinite(v[0]) && - Number.isFinite(v[1]) && - Number.isFinite(v[2]) - ) { - // Normalize u and v for safety. - const ul = Math.hypot(u[0], u[1], u[2]) || 1; - const vl = Math.hypot(v[0], v[1], v[2]) || 1; - const un = [u[0] / ul, u[1] / ul, u[2] / ul]; - const vn = [v[0] / vl, v[1] / vl, v[2] / vl]; - const seen = new Set(); - for (let dy = -r; dy <= r; ++dy) { - for (let dx = -r; dx <= r; ++dx) { - if (dx * dx + dy * dy <= rr) { - const px = cx + dx * un[0] + dy * vn[0]; - const py = cy + dx * un[1] + dy * vn[1]; - const pz = cz + dx * un[2] + dy * vn[2]; - const ix = Math.round(px); - const iy = Math.round(py); - const iz = Math.round(pz); - const key = ix + "," + iy + "," + iz; - if (!seen.has(key)) { - seen.add(key); - voxels.push(new Float32Array([ix, iy, iz])); - } - } - } - } - } else { - console.warn( - "No basis provided for disk brush, falling back to XY plane at fixed Z = cz.", - ); - // Fallback: Disk in XY plane at fixed Z = cz - for (let dy = -r; dy <= r; ++dy) { - for (let dx = -r; dx <= r; ++dx) { - if (dx * dx + dy * dy <= rr) { - voxels.push(new Float32Array([cx + dx, cy + dy, cz])); + // Disk: require a valid basis, no fallback. + if (!basis || !basis.u || !basis.v) { + throw new Error("paintBrushWithShape[disk]: 'basis' (u,v) is required."); + } + const u = basis.u, v = basis.v; + if (![u[0], u[1], u[2], v[0], v[1], v[2]].every(Number.isFinite)) { + throw new Error("paintBrushWithShape[disk]: Invalid basis vectors."); + } + // Normalize basis in canonical space, then convert step directions to LOD units by dividing by voxelSize + const ul = Math.hypot(u[0], u[1], u[2]) || 1; + const vl = Math.hypot(v[0], v[1], v[2]) || 1; + const un = [u[0] / ul / voxelSize, u[1] / ul / voxelSize, u[2] / ul / voxelSize]; + const vn = [v[0] / vl / voxelSize, v[1] / vl / voxelSize, v[2] / vl / voxelSize]; + const seen = new Set(); + for (let dy = -r; dy <= r; ++dy) { + for (let dx = -r; dx <= r; ++dx) { + if (dx * dx + dy * dy <= rr) { + const px = cx + dx * un[0] + dy * vn[0]; + const py = cy + dx * un[1] + dy * vn[1]; + const pz = cz + dx * un[2] + dy * vn[2]; + const ix = Math.round(px); + const iy = Math.round(py); + const iz = Math.round(pz); + const key = `${ix},${iy},${iz}`; + if (!seen.has(key)) { + seen.add(key); + voxelsLOD.push(new Float32Array([ix, iy, iz])); } } } } } - source.paintVoxelsBatch(voxels, value); - } - /** Backward-compat spherical brush API. */ - paintBrush(center: Float32Array, radius: number, value: number) { - this.paintBrushWithShape(center, radius, value, "sphere"); + source.paintVoxelsBatch(voxelsLOD, value); } async getLabelIds(): Promise { - try { - const source = this.getSource(); - if (!source) return []; - return await source.getLabelIds(); - } catch { - return []; - } + const src2D = this.multiscale.getSources({} as any); + if (!src2D || !src2D[0] || src2D[0].length === 0) return []; + const src = src2D[0][0].chunkSource as VoxChunkSource | undefined; + if (!src) return []; + return await src.getLabelIds(); } - async addLabel(value: number): Promise { - const source = this.getSource(); - if (!source) throw new Error("Voxel source not ready"); - return await source.addLabel(value >>> 0); + const src2D = this.multiscale.getSources({} as any); + const src = src2D?.[0]?.[0]?.chunkSource as VoxChunkSource | undefined; + if (!src) throw new Error("Voxel source not ready"); + return await src.addLabel(value >>> 0); } } diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 46da6bc6f6..37b53c15f1 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -17,8 +17,8 @@ import { VOX_LABELS_GET_RPC_ID, VOX_LABELS_ADD_RPC_ID, } from "#src/voxel_annotation/base.js"; -import { registerSharedObjectOwner } from "#src/worker_rpc.js"; import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; +import { registerSharedObjectOwner } from "#src/worker_rpc.js"; /** * Frontend owner for VoxChunkSource, extended with a local optimistic edit overlay. @@ -28,6 +28,7 @@ export class VoxChunkSource extends BaseVolumeChunkSource { declare OPTIONS: { spec: VolumeChunkSpecification; vox?: { serverUrl?: string; token?: string }; + lodFactor?: number; }; private voxOptions?: { serverUrl?: string; token?: string }; private tempVoxChunkGridPosition = new Float32Array(3); @@ -36,6 +37,7 @@ export class VoxChunkSource extends BaseVolumeChunkSource { private scheduleProcessPendingUploads = animationFrameDebounce(() => this.processPendingUploads(), ); + private lodFactor: number; /** Initialize map in the worker/backend for this source. */ initializeMap(map: VoxMapConfig) { @@ -51,10 +53,11 @@ export class VoxChunkSource extends BaseVolumeChunkSource { constructor( chunkManager: ChunkManager, - options: { spec: VolumeChunkSpecification; vox?: { serverUrl?: string; token?: string } }, + options: { spec: VolumeChunkSpecification; vox?: { serverUrl?: string; token?: string }; lodFactor?: number }, ) { super(chunkManager, options); this.voxOptions = options.vox; + this.lodFactor = options.lodFactor ?? 1; } override initializeCounterpart(rpc: any, options: any) { @@ -68,6 +71,7 @@ export class VoxChunkSource extends BaseVolumeChunkSource { static override encodeOptions(options: { spec: VolumeChunkSpecification; vox?: { serverUrl?: string; token?: string }; + lodFactor?: number; }) { const base = (BaseVolumeChunkSource as any).encodeOptions(options); if (options?.vox) { @@ -76,6 +80,9 @@ export class VoxChunkSource extends BaseVolumeChunkSource { token: options.vox.token, }; } + if (options?.lodFactor) { + (base as any).lodFactor = options.lodFactor; + } return base; } @@ -122,6 +129,7 @@ export class VoxChunkSource extends BaseVolumeChunkSource { if (!voxels || voxels.length === 0) return; const editsByKey = new Map(); const chunksToUpdate = new Set(); + console.log("painting a lod level: ", this.lodFactor) for (const v of voxels) { if (!v) continue; @@ -168,24 +176,27 @@ export class VoxChunkSource extends BaseVolumeChunkSource { return (local[2] * size[1] + local[1]) * size[0] + local[0]; } - /** Compute indices for both canonical (spec-sized) and actual loaded chunk. */ private computeIndices(voxel: Float32Array) { const rank = this.spec.rank; const { baseVoxelOffset, chunkDataSize } = this.spec as any; const keyParts = this.tempVoxChunkGridPosition; const local = this.tempLocalPosition; + for (let i = 0; i < rank; ++i) { - const v = voxel[i] - baseVoxelOffset[i]; + const v = (voxel[i] as number) - baseVoxelOffset[i]; const size = chunkDataSize[i]; const c = Math.floor(v / size); keyParts[i] = c; local[i] = Math.floor(v - c * size); } + const key = `${keyParts[0]},${keyParts[1]},${keyParts[2]}`; + const canonicalIndex = this.localIndexFromLocalPosition( local, this.spec.chunkDataSize as Uint32Array, ); + const chunk = this.chunks.get(key) as VolumeChunk | undefined; let chunkLocalIndex = -1; const cds = (chunk?.chunkDataSize as Uint32Array) ?? null; @@ -194,7 +205,6 @@ export class VoxChunkSource extends BaseVolumeChunkSource { chunkLocalIndex = this.localIndexFromLocalPosition(local, cds); } } else { - // If the chunk is not loaded yet, the spec size is a reasonable fallback for immediate updates (no-op if no CPU array). chunkLocalIndex = this.localIndexFromLocalPosition( local, this.spec.chunkDataSize as Uint32Array, diff --git a/src/voxel_annotation/volume_chunk_source.ts b/src/voxel_annotation/volume_chunk_source.ts index 6d16806b13..eb5ace4b2d 100644 --- a/src/voxel_annotation/volume_chunk_source.ts +++ b/src/voxel_annotation/volume_chunk_source.ts @@ -76,17 +76,6 @@ export class VoxMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSource upperVoxelBound, baseVoxelOffset, }); - const baseSource: VoxChunkSource = this.chunkManager.getChunkSource( - VoxChunkSource as any, - { - spec: baseSpec, - vox: { - serverUrl: map.serverUrl, - token: map.token, - }, - }, - ); - // Helper to make a homogeneous scaling transform matrix with scale factor f. const makeScale = (f: number) => { const m = new Float32Array((rank + 1) * (rank + 1)); @@ -98,12 +87,25 @@ export class VoxMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSource const factors = map.steps && map.steps.length > 0 ? [...map.steps] : [1]; const levels: SliceViewSingleResolutionSource[] = factors.map( - (f) => ({ - chunkSource: baseSource, - chunkToMultiscaleTransform: makeScale(f), - lowerClipBound: baseSpec.lowerVoxelBound, - upperClipBound: baseSpec.upperVoxelBound, - }), + (f) => { + const src: VoxChunkSource = this.chunkManager.getChunkSource( + VoxChunkSource, + { + spec: baseSpec, + vox: { + serverUrl: map.serverUrl, + token: map.token, + }, + lodFactor: f, + }, + ); + return { + chunkSource: src, + chunkToMultiscaleTransform: makeScale(f), + lowerClipBound: baseSpec.lowerVoxelBound, + upperClipBound: baseSpec.upperVoxelBound, + }; + }, ); return [levels]; From a7183202febfa5e5dc7f95b8c2ba149b8f41bcc5 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 032/251] feat: add LOD locking for voxel rendering and extend brush size range - Introduce LOD locking mechanisms for voxel rendering during brush strokes. - Extend brush size range limits from 1-64 to 1-512 for sliders and up to 1024 in logic. - Enhance error handling with stricter validation in drawing workflows. - Refactor rendering logic to support forced LOD locking during critical operations. - Add API to manage LOD locks and improve sliceview rendering precision. - Update voxel annotation and rendering layers for safer and more accurate user interactions. --- src/layer/vox/index.ts | 54 +++++++++++++++++++++---- src/layer/vox/tabs/tools.ts | 4 +- src/sliceview/base.ts | 20 +++++++++ src/ui/voxel_annotations.ts | 39 ++++++++++++++---- src/voxel_annotation/edit_controller.ts | 15 ++++++- src/voxel_annotation/renderlayer.ts | 24 +++++++++++ 6 files changed, 137 insertions(+), 19 deletions(-) diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index ff75c90051..88e56de719 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -56,6 +56,8 @@ import { VoxelAnnotationRenderLayer } from "#src/voxel_annotation/renderlayer.js import { VoxMultiscaleVolumeChunkSource } from "#src/voxel_annotation/volume_chunk_source.js"; export class VoxUserLayer extends UserLayer { + // While drawing, we keep a reference to the vox render layer to control temporary LOD locks. + private voxRenderLayerInstance?: VoxelAnnotationRenderLayer; onLabelsChanged?: () => void; voxMapRegistry = new VoxMapRegistry(); // Label state for painting: only store ids; colors are hashed from id on the fly @@ -82,6 +84,42 @@ export class VoxUserLayer extends UserLayer { voxServerUrl?: string; voxServerToken?: string; + beginRenderLodLock(lockedIndex: number): void { + if (!Number.isInteger(lockedIndex) || lockedIndex < 0) { + throw new Error("beginRenderLodLock: lockedIndex must be a non-negative integer"); + } + const rl = this.voxRenderLayerInstance; + if (!rl) { + throw new Error("beginRenderLodLock: render layer is not ready"); + } + // Validate against available levels in current pyramid. + const sources2D = rl.multiscaleSource.getSources({} as any); + const levels = sources2D?.[0]?.length ?? 0; + if (levels <= 0) { + throw new Error("beginRenderLodLock: multiscale source has no levels"); + } + if (lockedIndex >= levels) { + throw new Error( + `beginRenderLodLock: requested LOD ${lockedIndex} exceeds available levels (${levels})`, + ); + } + rl.setForcedSourceIndexLock(lockedIndex); + console.log("beginRenderLodLock: lockedIndex", lockedIndex); + } + + endRenderLodLock(): void { + const rl = this.voxRenderLayerInstance; + if (!rl) return; + rl.setForcedSourceIndexLock(undefined); + console.log("endRenderLodLock"); + } + + getActiveRenderedLodIndex(): number | undefined { + const rl = this.voxRenderLayerInstance; + if (!rl) return undefined; + return rl.getForcedSourceIndexOverride?.(); + } + // --- Label helpers --- private genId(): number { // Generate a unique uint32 per layer session. Try crypto.getRandomValues; fallback to Math.random. @@ -390,14 +428,14 @@ export class VoxUserLayer extends UserLayer { undefined, ); - ls.addRenderLayer( - new VoxelAnnotationRenderLayer(voxSource, { - transform: transform as any, - renderScaleTarget: this.sliceViewRenderScaleTarget, - renderScaleHistogram: undefined, - localPosition: this.localPosition, - } as any), - ); + const renderLayer = new VoxelAnnotationRenderLayer(voxSource, { + transform: transform as any, + renderScaleTarget: this.sliceViewRenderScaleTarget, + renderScaleHistogram: undefined, + localPosition: this.localPosition, + } as any); + this.voxRenderLayerInstance = renderLayer; + ls.addRenderLayer(renderLayer); }, guardScale, guardBounds, diff --git a/src/layer/vox/tabs/tools.ts b/src/layer/vox/tabs/tools.ts index 9b944620fa..df4187b18f 100644 --- a/src/layer/vox/tabs/tools.ts +++ b/src/layer/vox/tabs/tools.ts @@ -108,7 +108,7 @@ export class VoxToolTab extends Tab { const sizeSlider = document.createElement("input"); sizeSlider.type = "range"; sizeSlider.min = "1"; - sizeSlider.max = "64"; + sizeSlider.max = "512"; sizeSlider.step = "1"; sizeSlider.value = String(this.layer.voxBrushRadius ?? 3); @@ -120,7 +120,7 @@ export class VoxToolTab extends Tab { sizeNumber.value = String(this.layer.voxBrushRadius ?? 3); const syncSize = (v: number) => { - const clamped = Math.max(1, Math.min(256, Math.floor(v))); + const clamped = Math.max(1, Math.min(1024, Math.floor(v))); this.layer.voxBrushRadius = clamped; sizeSlider.value = String(clamped); sizeNumber.value = String(clamped); diff --git a/src/sliceview/base.ts b/src/sliceview/base.ts index b6d061cc07..ae31e5ed78 100644 --- a/src/sliceview/base.ts +++ b/src/sliceview/base.ts @@ -169,6 +169,14 @@ export interface SliceViewRenderLayer { localPosition: WatchableValueInterface; renderScaleTarget: WatchableValueInterface; + /** + * If implemented by a render layer, return a non-negative integer scale index to override + * automatic multiscale selection. When defined, the sliceview must use only the specified + * scale from the current orientation. Implementations must ensure the index is valid for + * their multiscale source; this function should return undefined when no override is desired. + */ + getForcedSourceIndexOverride?(): number | undefined; + filterVisibleSources( sliceView: SliceViewBase, sources: readonly TransformedSource[], @@ -686,6 +694,18 @@ export function* filterVisibleSources( renderLayer: SliceViewRenderLayer, sources: readonly TransformedSource[], ): Iterable { + // First: allow a render layer to force a specific multiscale index for safety-critical flows. + const forcedIndex = renderLayer.getForcedSourceIndexOverride?.(); + if (forcedIndex !== undefined) { + if (!Number.isInteger(forcedIndex) || forcedIndex < 0 || forcedIndex >= sources.length) { + throw new Error( + `filterVisibleSources: forced source index ${forcedIndex} is out of range [0, ${sources.length - 1}]`, + ); + } + yield sources[forcedIndex]; + return; + } + // Increase pixel size by a small margin. const pixelSize = sliceView.projectionParameters.value.pixelSize * 1.1; // At the smallest scale, all alternative sources must have the same voxel size, which is diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index ef1e41994f..5e4db78898 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -70,15 +70,33 @@ abstract class BaseVoxelLegacyTool extends LegacyTool { if (this.isDrawing) return; this.isDrawing = true; this.currentMouseState = mouseState; - const value = - (this.layer as any).getCurrentLabelValue?.() ?? - ((this.layer as any).voxEraseMode ? 0 : 42); + + const layer = this.layer as unknown as VoxUserLayer; + const brushRadius = Math.max(1, Math.floor((layer as any).voxBrushRadius ?? 3)); + if (!Number.isFinite(brushRadius) || brushRadius <= 0) { + throw new Error("startDrawing: invalid brushRadius"); + } + + // Compute starting point and lock render LOD before first paint. const start = this.getPoint(mouseState); - if (start) { - this.paintPoint(new Float32Array([start[0], start[1], start[2]]), value); - this.lastPoint = start; + if (!start) { + throw new Error("startDrawing: could not compute a starting voxel position from mouse"); } + const centerCanonical = new Float32Array([start[0], start[1], start[2]]); + const editLodIndex = layer.voxEditController?.getEditLodIndexForBrush(brushRadius); + if (!Number.isInteger(editLodIndex) || editLodIndex == undefined || editLodIndex < 0) { + throw new Error("startDrawing: computed edit LOD index is invalid"); + } + layer.beginRenderLodLock(editLodIndex); + + const value = + layer.getCurrentLabelValue() ?? + (layer.voxEraseMode ? 0 : 42); + + this.paintPoint(centerCanonical, value); + this.lastPoint = start; + this.mouseDisposer = mouseState.changed.add(() => { if (!this.isDrawing) return; this.currentMouseState = mouseState; @@ -90,8 +108,7 @@ abstract class BaseVoxelLegacyTool extends LegacyTool { this.lastPoint = cur; return; } - if (cur[0] === last[0] && cur[1] === last[1] && cur[2] === last[2]) - return; + if (cur[0] === last[0] && cur[1] === last[1] && cur[2] === last[2]) return; const points = this.linePoints(last, cur); if (points.length > 0) { this.paintPoints(points, value); @@ -109,6 +126,12 @@ abstract class BaseVoxelLegacyTool extends LegacyTool { this.mouseDisposer(); this.mouseDisposer = undefined; } + // Always release any active render LOD lock. + try { + this.layer.endRenderLodLock(); + } catch (e) { + console.warn("stopDrawing: failed to end render LOD lock:", e); + } } trigger(mouseState: MouseSelectionState) { diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index f74c6d1583..6441ace3e0 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -8,7 +8,7 @@ import type { VoxChunkSource } from "#src/voxel_annotation/frontend.js"; export class VoxelEditController { constructor(private multiscale: MultiscaleVolumeChunkSource) {} - private static readonly qualityFactor = 5.0; + private static readonly qualityFactor = 16.0; // Required: compute desired voxel size (power-of-two) from brush radius. getOptimalVoxelSize(brushRadius: number, minLOD = 1, maxLOD = 128) { @@ -22,6 +22,19 @@ export class VoxelEditController { return voxelSize; } + /** Compute the edit LOD index (scale index) from a brush radius in canonical units. */ + getEditLodIndexForBrush(brushRadiusCanonical: number): number { + if (!Number.isFinite(brushRadiusCanonical) || brushRadiusCanonical <= 0) { + throw new Error("getEditLodIndexForBrush: brushRadiusCanonical must be > 0"); + } + const voxelSize = this.getOptimalVoxelSize(brushRadiusCanonical); + const sourceIndex = Math.round(Math.log2(voxelSize)); + if (!Number.isInteger(sourceIndex) || sourceIndex < 0) { + throw new Error("getEditLodIndexForBrush: computed LOD is invalid"); + } + return sourceIndex; + } + // Paint a disk (slice-aligned via basis) or sphere in WORLD/ canonical units; we transform to LOD grid before sending. paintBrushWithShape( centerCanonical: Float32Array, diff --git a/src/voxel_annotation/renderlayer.ts b/src/voxel_annotation/renderlayer.ts index 96e9177e94..9e95bc7216 100644 --- a/src/voxel_annotation/renderlayer.ts +++ b/src/voxel_annotation/renderlayer.ts @@ -37,6 +37,30 @@ export class VoxelAnnotationRenderLayer extends SliceViewVolumeRenderLayer Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 033/251] feat: move local and remote VoxSource to separate files, updated the LocalVoxSource and VoxChunkSource backend for handling of different lod level chunks --- src/voxel_annotation/local_source.ts | 0 src/voxel_annotation/remote_source.ts | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/voxel_annotation/local_source.ts create mode 100644 src/voxel_annotation/remote_source.ts diff --git a/src/voxel_annotation/local_source.ts b/src/voxel_annotation/local_source.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/voxel_annotation/remote_source.ts b/src/voxel_annotation/remote_source.ts new file mode 100644 index 0000000000..e69de29bb2 From 5e88dcf78ff7105ea6fe02b6282b3f54d8766606 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 034/251] feat: move local and remote VoxSource to separate files, updated the LocalVoxSource and VoxChunkSource backend for handling of different lod level chunks --- NOTES/TODOs.md | 15 +- src/layer/vox/index.ts | 10 +- src/layer/vox/tabs/settings.ts | 7 +- src/layer/vox/tabs/tools.ts | 4 +- src/voxel_annotation/backend.ts | 127 ++++-- src/voxel_annotation/base.ts | 4 + src/voxel_annotation/edit_controller.ts | 31 +- src/voxel_annotation/frontend.ts | 31 +- src/voxel_annotation/index.ts | 560 ------------------------ src/voxel_annotation/local_source.ts | 303 +++++++++++++ src/voxel_annotation/remote_source.ts | 269 ++++++++++++ 11 files changed, 723 insertions(+), 638 deletions(-) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index ec21b9c17a..23bed83488 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,7 +1,9 @@ # TODO List - LOD -> where am I: - - choosing the lod level depanding on the brush size, live rendering is working but data saving need to be updated. + - choosing the lod level depanding on the brush size, live rendering is working local data saving is working. + - there are some performance issues at very high brush sizes (>1000) + - implement down/upsampleing (see diagram below) - cleanup label handling code (more specifically in the ui code: layer/vox/index.ts, would be nice to have a handler similar to the one for maps) - Add redundancy to avoid corrupt/unsaved chunks on the remote @@ -14,6 +16,8 @@ - look into the massive ram usage when a lot of voxel annotations are drawn - Add Uint64 support for annotation id - Replace the current map settings to use the built-ins of neuroglancer (viewable under the datasource url), handle multimap with link choices, look into how to keep the init/creation logic. +- adapt the brush size to the zoom level linearly + # Saving/importing/exporting @@ -28,3 +32,12 @@ The RemoteVoxSource will be activated when a https:// link to a specially made s 1. on saving of data, we should propagate the complete voxel cube to the upper levels (lower zoom levels) recursively 2. on loading of data, we should retrieve not only the current scale chunks but also the ones from the lower zoom levels. This last step will introduce conflicts what if the same voxel does not have the same value in the different scales? And how to know if there has been deletion or if there are just no data? To solve this, we must introduce a special value for the deleted voxels and also timestamp for the last chunk updates. ~~To avoid too many conficts, we should resolve them when loading the data.~~ Actually, we should not resolve those conflicts live as doing so will prevent us from implementing an undo feature. + +Drawing Flow chart: +-> Brush stroke start + -> lock LOD level to the brush size one + -> Live render the drawing + -> Commit modifications to backend -> Save, downsample and mark upsamples as dirty +-> Brush stoke ends + -> Unlock LOD level (maybe add a small delay to avoid flickering) + -> Progressivly download upscalings as they roll out diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 88e56de719..93a42e0cb1 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -50,8 +50,8 @@ import { import type { Borrowed } from "#src/util/disposable.js"; import { mat4 } from "#src/util/geom.js"; import { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; -import { RemoteVoxSource } from "#src/voxel_annotation/index.js"; import { VoxMapRegistry } from "#src/voxel_annotation/map.js"; +import { RemoteVoxSource } from "#src/voxel_annotation/remote_source.js"; import { VoxelAnnotationRenderLayer } from "#src/voxel_annotation/renderlayer.js"; import { VoxMultiscaleVolumeChunkSource } from "#src/voxel_annotation/volume_chunk_source.js"; @@ -412,12 +412,10 @@ export class VoxUserLayer extends UserLayer { this.voxEditController = new VoxelEditController(voxSource); const sources2D = voxSource.getSources({} as any); - const base = sources2D[0]?.[0]; - if (base) { - const source = base.chunkSource as any; - source.initializeMap(map); - this.loadLabels(); + for (const level of (sources2D[0] ?? [])) { + (level.chunkSource as any).initializeMap(map); } + this.loadLabels(); // Build transform with current scale and units. const identity3D = this.createIdentity3D(); diff --git a/src/layer/vox/tabs/settings.ts b/src/layer/vox/tabs/settings.ts index 8381acf2b2..f86ac4d928 100644 --- a/src/layer/vox/tabs/settings.ts +++ b/src/layer/vox/tabs/settings.ts @@ -3,7 +3,10 @@ */ import type { VoxUserLayer } from "#src/layer/vox/index.js"; import { DataType } from "#src/util/data_type.js"; -import { computeSteps, VoxMapConfig } from "#src/voxel_annotation/map.js"; +import { LocalVoxSource } from "#src/voxel_annotation/local_source.js"; +import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; +import { computeSteps } from "#src/voxel_annotation/map.js"; +import { RemoteVoxSource } from "#src/voxel_annotation/remote_source.js"; import { Tab } from "#src/widget/tab_view.js"; export class VoxSettingsTab extends Tab { @@ -121,11 +124,9 @@ export class VoxSettingsTab extends Tab { // Dynamically use the appropriate VoxSource let maps: any[] = []; if (this.layer.voxServerUrl) { - const { RemoteVoxSource } = await import("#src/voxel_annotation/index.js"); const src = new RemoteVoxSource(this.layer.voxServerUrl, this.layer.voxServerToken); maps = await src.listMaps(); } else { - const { LocalVoxSource } = await import("#src/voxel_annotation/index.js"); const src = new LocalVoxSource(); maps = await src.listMaps(); } diff --git a/src/layer/vox/tabs/tools.ts b/src/layer/vox/tabs/tools.ts index df4187b18f..8bb44ec378 100644 --- a/src/layer/vox/tabs/tools.ts +++ b/src/layer/vox/tabs/tools.ts @@ -108,7 +108,7 @@ export class VoxToolTab extends Tab { const sizeSlider = document.createElement("input"); sizeSlider.type = "range"; sizeSlider.min = "1"; - sizeSlider.max = "512"; + sizeSlider.max = "4242"; sizeSlider.step = "1"; sizeSlider.value = String(this.layer.voxBrushRadius ?? 3); @@ -120,7 +120,7 @@ export class VoxToolTab extends Tab { sizeNumber.value = String(this.layer.voxBrushRadius ?? 3); const syncSize = (v: number) => { - const clamped = Math.max(1, Math.min(1024, Math.floor(v))); + const clamped = Math.max(1, Math.min(8192, Math.floor(v))); this.layer.voxBrushRadius = clamped; sizeSlider.value = String(clamped); sizeNumber.value = String(clamped); diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index e86a8541a8..0abcd02572 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -7,31 +7,63 @@ import type { VolumeChunk } from "#src/sliceview/volume/backend.js"; import { VolumeChunkSource as BaseVolumeChunkSource } from "#src/sliceview/volume/backend.js"; import { DataType } from "#src/util/data_type.js"; import { + makePersistantChunkKey, VOX_CHUNK_SOURCE_RPC_ID, VOX_COMMIT_VOXELS_RPC_ID, - VOX_MAP_INIT_RPC_ID, - VOX_LABELS_GET_RPC_ID, VOX_LABELS_ADD_RPC_ID, + VOX_LABELS_GET_RPC_ID, + VOX_MAP_INIT_RPC_ID } from "#src/voxel_annotation/base.js"; -import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; -import { LocalVoxSource, RemoteVoxSource } from "#src/voxel_annotation/index.js"; import type { VoxSource } from "#src/voxel_annotation/index.js"; +import { LocalVoxSource } from "#src/voxel_annotation/local_source.js"; +import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; +import { RemoteVoxSource } from "#src/voxel_annotation/remote_source.js"; import type { RPC } from "#src/worker_rpc.js"; -import { - registerRPC, - registerPromiseRPC, - registerSharedObject, -} from "#src/worker_rpc.js"; +import { registerPromiseRPC, registerRPC, registerSharedObject } from "#src/worker_rpc.js"; /** * Backend volume source that persists voxel edits per chunk. It returns saved data if available, * otherwise returns an empty chunk (filled with zeros). */ +// --- VoxSource registry: share per (serverUrl, token, mapId, scaleKey) --- +const voxSourceRegistry = new Map(); + +function makeServerKey(serverUrl?: string, token?: string): string { + if (!serverUrl) return "local"; + return `remote:${serverUrl}|${token ?? ""}`; +} + +function toScaleKeySafe(map: VoxMapConfig): string { + const cds = Array.from(map.chunkDataSize); + const lower = Array.from(map.baseVoxelOffset); + const upper = Array.from(map.upperVoxelBound); + return `${cds.join(',')}:${lower.join(',')}-${upper.join(',')}`; +} + +function makeRegistryKey(serverUrl: string | undefined, token: string | undefined, map: VoxMapConfig): string { + const sk = makeServerKey(serverUrl, token); + const scaleKey = toScaleKeySafe(map); + return `${sk}|${map.id}|${scaleKey}`; +} + +async function getOrCreateRegisteredVoxSource(serverUrl: string | undefined, token: string | undefined, map: VoxMapConfig): Promise { + const key = makeRegistryKey(serverUrl, token, map); + const existing = voxSourceRegistry.get(key); + if (existing) return existing; + const src = serverUrl ? new RemoteVoxSource(serverUrl, token) : new LocalVoxSource(); + await src.init(map); + voxSourceRegistry.set(key, src); + return src; +} + @registerSharedObject(VOX_CHUNK_SOURCE_RPC_ID) export class VoxChunkSource extends BaseVolumeChunkSource { - source: VoxSource; + private source?: VoxSource; private voxServerUrl?: string; private voxToken?: string; + private lodFactor: number; + private mapReadyPromise: Promise; + private resolveMapReady!: () => void; constructor(rpc: RPC, options: any) { super(rpc, options); @@ -39,25 +71,40 @@ export class VoxChunkSource extends BaseVolumeChunkSource { const o = options || {}; this.voxServerUrl = o.voxServerUrl || o.serverUrl || o.vox?.serverUrl; this.voxToken = o.voxToken || o.token || o.vox?.token; - this.source = this.voxServerUrl - ? new RemoteVoxSource(this.voxServerUrl, this.voxToken) - : new LocalVoxSource(); + this.lodFactor = o.lodFactor; + if (this.lodFactor == undefined) { + throw new Error("lodFactor is required"); + } + this.mapReadyPromise = new Promise((resolve) => { + this.resolveMapReady = resolve; + }); } /** Initialize map metadata and persistence backend using a VoxMapConfig. */ async initMap(arg: { map?: VoxMapConfig } | VoxMapConfig) { const map: VoxMapConfig = (arg as any)?.map ?? (arg as any); - if (!map) return; - // Allow runtime override of server settings via map - if (map.serverUrl) this.voxServerUrl = map.serverUrl; - if (map.token) this.voxToken = map.token; - // Swap source if configuration changed - this.source = this.voxServerUrl - ? new RemoteVoxSource(this.voxServerUrl, this.voxToken) - : new LocalVoxSource(); - - // Initialize underlying source with the provided map config - await this.source.init(map); + if (!map) throw new Error("initMap: map configuration is required"); + // Allow runtime override/validation of server settings via map + if (map.serverUrl) { + if (this.voxServerUrl && this.voxServerUrl !== map.serverUrl) { + throw new Error("initMap: conflicting serverUrl provided"); + } + this.voxServerUrl = map.serverUrl; + } + if (map.token) { + if (this.voxToken && this.voxToken !== map.token) { + throw new Error("initMap: conflicting token provided"); + } + this.voxToken = map.token; + } + // Bind to a per-(server,map,scaleKey) registered source + this.source = await getOrCreateRegisteredVoxSource( + this.voxServerUrl, + this.voxToken, + map, + ); + // Signal readiness for any pending downloads/commits + try { this.resolveMapReady(); } catch { /* ignore multiple resolutions */ } } /** Commit voxel edits from the frontend. */ @@ -70,11 +117,31 @@ export class VoxChunkSource extends BaseVolumeChunkSource { size?: number[]; }[], ) { - await this.source.applyEdits(edits); + await this.mapReadyPromise; + const src = this.source; + if (!src) throw new Error("commitVoxels: source is not initialized for this map"); + await src.applyEdits(edits); + } + + async getLabelIds(): Promise { + await this.mapReadyPromise; + const src = this.source; + if (!src) throw new Error("getLabelIds: source is not initialized for this map"); + return await src.getLabelIds(); + } + + async addLabel(value: number): Promise { + await this.mapReadyPromise; + const src = this.source; + if (!src) throw new Error("addLabel: source is not initialized for this map"); + return await src.addLabel(value >>> 0); } async download(chunk: VolumeChunk, signal: AbortSignal): Promise { if (signal.aborted) throw signal.reason ?? new Error("aborted"); + await this.mapReadyPromise; + const src = this.source; + if (!src) throw new Error("download: source is not initialized for this map"); // Determine chunk key and size (may be clipped at upper bound). this.computeChunkBounds(chunk); const cds = chunk.chunkDataSize!; @@ -83,7 +150,7 @@ export class VoxChunkSource extends BaseVolumeChunkSource { // Always produce a typed array matching the spec type; MVP uses UINT32 const array = this.allocateTypedArray(this.spec.dataType, total, 0); // Load saved chunk if present and copy overlapping region - const saved = await this.source.getSavedChunk(key); + const saved = await src.getSavedChunk(makePersistantChunkKey(key, this.lodFactor)); if (saved) { const sxS = saved.size[0], syS = saved.size[1], @@ -94,14 +161,14 @@ export class VoxChunkSource extends BaseVolumeChunkSource { const ox = Math.min(sxS, sxD); const oy = Math.min(syS, syD); const oz = Math.min(szS, szD); - const src = saved.data as any; + const srcArr = saved.data as any; const dst = array as any; for (let z = 0; z < oz; ++z) { for (let y = 0; y < oy; ++y) { const baseSrc = (z * syS + y) * sxS; const baseDst = (z * syD + y) * sxD; for (let x = 0; x < ox; ++x) { - dst[baseDst + x] = src[baseSrc + x]; + dst[baseDst + x] = srcArr[baseSrc + x]; } } } @@ -152,7 +219,7 @@ registerPromiseRPC( VOX_LABELS_GET_RPC_ID, async function (x: any): Promise { const obj = this.get(x.rpcId) as VoxChunkSource; - const ids = await obj.source.getLabelIds(); + const ids = await obj.getLabelIds(); return { value: ids }; }, ); @@ -160,6 +227,6 @@ registerPromiseRPC( registerPromiseRPC(VOX_LABELS_ADD_RPC_ID, async function (x: any) { const obj = this.get(x.rpcId) as VoxChunkSource; - const ids = await obj.source.addLabel(x?.value >>> 0); + const ids = await obj.addLabel(x?.value >>> 0); return { value: ids }; }); diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index af8145e7b1..b06b5e3181 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -3,3 +3,7 @@ export const VOX_COMMIT_VOXELS_RPC_ID = "vox.commitVoxels"; export const VOX_MAP_INIT_RPC_ID = "vox.map.init"; export const VOX_LABELS_GET_RPC_ID = "vox.labels.get"; export const VOX_LABELS_ADD_RPC_ID = "vox.labels.add"; + +export function makePersistantChunkKey(chunkKey: string, lodFactor : number) { + return `lod${lodFactor}#${chunkKey}`; +} diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 6441ace3e0..db224406dd 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -44,6 +44,7 @@ export class VoxelEditController { basis?: { u: Float32Array; v: Float32Array }, ) { if (!Number.isFinite(radiusCanonical) || radiusCanonical <= 0) { + console.log(basis) // TODO remove this line, was only added to suppress ts errors throw new Error("paintBrushWithShape: 'radius' must be > 0."); } if (!centerCanonical || centerCanonical.length < 3) { @@ -51,7 +52,7 @@ export class VoxelEditController { } const voxelSize = this.getOptimalVoxelSize(radiusCanonical); - const sourceIndex = Math.round(Math.log2(voxelSize)); + const sourceIndex = Math.floor(Math.log2(voxelSize)); const src2D = this.multiscale.getSources({} as any); if (!src2D || !src2D[0] || src2D[0].length <= sourceIndex) { throw new Error("VoxelEditController: No multiscale levels available."); @@ -63,7 +64,7 @@ export class VoxelEditController { const cx = Math.floor((centerCanonical[0] ?? 0) / voxelSize); const cy = Math.floor((centerCanonical[1] ?? 0) / voxelSize); const cz = Math.floor((centerCanonical[2] ?? 0) / voxelSize); - const r = Math.floor(radiusCanonical / voxelSize); + const r = Math.round(radiusCanonical / voxelSize); if (r <= 0) { throw new Error("paintBrushWithShape: radius too small for selected LOD."); } @@ -82,34 +83,10 @@ export class VoxelEditController { } } } else { - // Disk: require a valid basis, no fallback. - if (!basis || !basis.u || !basis.v) { - throw new Error("paintBrushWithShape[disk]: 'basis' (u,v) is required."); - } - const u = basis.u, v = basis.v; - if (![u[0], u[1], u[2], v[0], v[1], v[2]].every(Number.isFinite)) { - throw new Error("paintBrushWithShape[disk]: Invalid basis vectors."); - } - // Normalize basis in canonical space, then convert step directions to LOD units by dividing by voxelSize - const ul = Math.hypot(u[0], u[1], u[2]) || 1; - const vl = Math.hypot(v[0], v[1], v[2]) || 1; - const un = [u[0] / ul / voxelSize, u[1] / ul / voxelSize, u[2] / ul / voxelSize]; - const vn = [v[0] / vl / voxelSize, v[1] / vl / voxelSize, v[2] / vl / voxelSize]; - const seen = new Set(); for (let dy = -r; dy <= r; ++dy) { for (let dx = -r; dx <= r; ++dx) { if (dx * dx + dy * dy <= rr) { - const px = cx + dx * un[0] + dy * vn[0]; - const py = cy + dx * un[1] + dy * vn[1]; - const pz = cz + dx * un[2] + dy * vn[2]; - const ix = Math.round(px); - const iy = Math.round(py); - const iz = Math.round(pz); - const key = `${ix},${iy},${iz}`; - if (!seen.has(key)) { - seen.add(key); - voxelsLOD.push(new Float32Array([ix, iy, iz])); - } + voxelsLOD.push(new Float32Array([cx + dx, cy + dy, cz])); } } } diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 37b53c15f1..b85e4d1580 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -16,6 +16,7 @@ import { VOX_MAP_INIT_RPC_ID, VOX_LABELS_GET_RPC_ID, VOX_LABELS_ADD_RPC_ID, + makePersistantChunkKey, } from "#src/voxel_annotation/base.js"; import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; import { registerSharedObjectOwner } from "#src/worker_rpc.js"; @@ -65,6 +66,7 @@ export class VoxChunkSource extends BaseVolumeChunkSource { if (this.voxOptions) { (opts as any).vox = { ...this.voxOptions }; } + opts.lodFactor = this.lodFactor; super.initializeCounterpart(rpc, opts); } @@ -114,13 +116,17 @@ export class VoxChunkSource extends BaseVolumeChunkSource { } private processPendingUploads() { + const remaining = new Set(); for (const key of this.dirtyChunks) { const chunk = this.chunks.get(key) as VolumeChunk | undefined; - if (chunk && this.getCpuArrayForChunk(chunk)) { + const cpuArray = chunk ? this.getCpuArrayForChunk(chunk) : null; + if (chunk && cpuArray) { this.invalidateChunkUpload(chunk); + } else { + remaining.add(key); } } - this.dirtyChunks.clear(); + this.dirtyChunks = remaining; this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); } @@ -129,7 +135,6 @@ export class VoxChunkSource extends BaseVolumeChunkSource { if (!voxels || voxels.length === 0) return; const editsByKey = new Map(); const chunksToUpdate = new Set(); - console.log("painting a lod level: ", this.lodFactor) for (const v of voxels) { if (!v) continue; @@ -137,23 +142,31 @@ export class VoxChunkSource extends BaseVolumeChunkSource { // Immediate draw on CPU array if present if (chunkLocalIndex >= 0) { const chunk = this.chunks.get(key) as VolumeChunk | undefined; - const baseArray = chunk && this.getCpuArrayForChunk(chunk); - if (baseArray) { - (baseArray as any)[chunkLocalIndex] = value as any; - chunksToUpdate.add(key); + const cpuArray = chunk ? this.getCpuArrayForChunk(chunk) : null; + + // Best effort immediate local write for responsive painting + if (cpuArray) { + (cpuArray as any)[chunkLocalIndex] = value as any; } + + // Always schedule an update for this chunk. If the CPU array isn’t ready yet, + // the pending key will be retried by processPendingUploads once it becomes available. + // This ensures the draw becomes visible once the chunk is present. + // + // Important: we schedule even if cpuArray was null. + chunksToUpdate.add(key); } + let arr = editsByKey.get(key); if (!arr) editsByKey.set(key, (arr = [])); arr.push(canonicalIndex); } - for (const key of chunksToUpdate) this.scheduleUpdate(key); if (editsByKey.size > 0) { const size = Array.from(this.spec.chunkDataSize); const edits = Array.from(editsByKey, ([key, indices]) => ({ - key, + key: makePersistantChunkKey(key, this.lodFactor), indices, value, size, diff --git a/src/voxel_annotation/index.ts b/src/voxel_annotation/index.ts index bed9374584..a05ec3c711 100644 --- a/src/voxel_annotation/index.ts +++ b/src/voxel_annotation/index.ts @@ -3,9 +3,7 @@ * The LocalVoxSource persists per-chunk arrays into IndexedDB with a debounced saver. */ -import { DataType } from "#src/util/data_type.js"; import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; -import { computeSteps } from "#src/voxel_annotation/map.js"; export interface SavedChunk { @@ -144,561 +142,3 @@ export abstract class VoxSource { return sc; } } - -/** IndexedDB-backed local source. */ -export class LocalVoxSource extends VoxSource { - override async listMaps(): Promise { - try { - const db = await this.getDb(); - const tx = db.transaction("maps", "readonly"); - const store = tx.objectStore("maps"); - const getAll = (store as any).getAll?.bind(store); - const rows: any[] = await new Promise((resolve, reject) => { - if (getAll) { - const req = getAll(); - req.onerror = () => reject(req.error); - req.onsuccess = () => resolve(req.result || []); - return; - } - const out: any[] = []; - const req = store.openCursor(); - req.onerror = () => reject(req.error); - req.onsuccess = (ev: any) => { - const cursor = ev.target.result as IDBCursorWithValue | null; - if (cursor) { - out.push(cursor.value); - cursor.continue(); - } else { - resolve(out); - } - }; - }); - const maps: VoxMapConfig[] = []; - for (const r of rows) { - try { - if ( - r?.id === undefined || - r?.baseVoxelOffset === undefined || - r?.upperVoxelBound === undefined || - r?.chunkDataSize === undefined || - r?.dataType === undefined || - r?.scaleMeters === undefined || - r?.unit === undefined - ) { - throw new Error("Invalid map configuration"); - } - const id = String(r.id); - const lower = Array.from(r.baseVoxelOffset).map((v: any) => - Number(v), - ) as number[]; - const upper = Array.from(r.upperVoxelBound).map((v: any) => - Number(v), - ) as number[]; - const cds = Array.from(r.chunkDataSize).map((v: any) => - Math.max(1, Number(v)), - ) as number[]; - const bounds = [ - (upper[0] | 0) - (lower[0] | 0), - (upper[1] | 0) - (lower[1] | 0), - (upper[2] | 0) - (lower[2] | 0), - ]; - const steps = computeSteps(bounds, cds); - maps.push({ - id, - name: r?.name ?? id, - baseVoxelOffset: new Float32Array(lower), - upperVoxelBound: new Float32Array(upper), - chunkDataSize: new Uint32Array(cds), - dataType: r.dataType, - scaleMeters: r.scaleMeters, - unit: r.unit, - steps, - }); - } catch { - // skip malformed - } - } - return maps; - } catch { - return [] as VoxMapConfig[]; - } - } - private dbPromise: Promise | null = null; - - override async getLabelIds(): Promise { - try { - const db = await this.getDb(); - const key = compositeLabelsDbKey(this.mapId, this.scaleKey); - const arr = await idbGet(db, "labels", key); - if (arr && Array.isArray(arr)) return arr.map((v) => v >>> 0); - return []; - } catch { - return []; - } - } - - - override async addLabel(value: number): Promise { - const v = value >>> 0; - const db = await this.getDb(); - const key = compositeLabelsDbKey(this.mapId, this.scaleKey); - const arr = (await idbGet(db, "labels", key)) || []; - // Ensure uniqueness - if (!arr.some((x) => (x >>> 0) === v)) arr.push(v); - const tx = db.transaction("labels", "readwrite"); - await idbPut(tx.objectStore("labels"), arr.map((x) => x >>> 0), key); - await txDone(tx); - return arr.map((x) => x >>> 0); - } - - private touch(key: string) { - const v = this.saved.get(key); - if (!v) return; - this.saved.delete(key); - this.saved.set(key, v); - } - - private enforceCap() { - // Evict only non-dirty entries to avoid losing unsaved edits. - while (this.saved.size > this.maxSavedChunks) { - let oldestKey: string | undefined = undefined; - for (const k of this.saved.keys()) { - if (!this.dirty.has(k)) { - oldestKey = k; - break; - } - } - if (oldestKey === undefined) { - // All entries are dirty; wait until they are flushed before evicting. - break; - } - this.saved.delete(oldestKey); - } - } - - override async init(map: VoxMapConfig) { - const meta = await super.init(map); - const db = await this.getDb(); - // Persist/update map metadata - const tx = db.transaction("maps", "readwrite"); - const cfg = this.mapCfg!; - tx.objectStore("maps").put( - cfg, - this.mapId, - ); - await txDone(tx); - return meta; - } - - async getSavedChunk(key: string): Promise { - const existing = this.saved.get(key); - if (existing) { - this.touch(key); - return existing; - } - const db = await this.getDb(); - const composite = this.compositeKey(key); - const buf = await idbGet(db, "chunks", composite); - if (buf) { - const arr = new Uint32Array(buf); - const sc: SavedChunk = { - data: arr, - size: new Uint32Array(this.mapCfg!.chunkDataSize as any), - }; - this.saved.set(key, sc); - this.enforceCap(); - return sc; - } - return undefined; - } - - async ensureChunk( - key: string, - size?: Uint32Array | number[], - ): Promise { - let sc = this.saved.get(key); - if (sc) { - this.touch(key); - return sc; - } - const db = await this.getDb(); - const composite = this.compositeKey(key); - const buf = await idbGet(db, "chunks", composite); - if (buf) { - const arr = new Uint32Array(buf); - sc = { data: arr, size: new Uint32Array(this.mapCfg!.chunkDataSize as any) }; - this.saved.set(key, sc); - this.enforceCap(); - return sc; - } - const fallbackSize = new Uint32Array(this.mapCfg!.chunkDataSize as any); - const sz = new Uint32Array(size ?? fallbackSize); - let total = 1; - for (let i = 0; i < 3; ++i) total *= sz[i]; - const arr = new Uint32Array(total); - sc = { data: arr, size: new Uint32Array(sz) }; - this.saved.set(key, sc); - this.enforceCap(); - this.markDirty(key); - return sc; - } - - async applyEdits( - edits: { - key: string; - indices: ArrayLike; - value?: number; - values?: ArrayLike; - size?: number[]; - }[], - ) { - for (const e of edits) { - const sc = await this.ensureChunk( - e.key, - e.size ? new Uint32Array(e.size) : (this.mapCfg!.chunkDataSize as any), - ); - this.applyEditsIntoChunk(sc, e.indices, e.value, e.values); - this.markDirty(e.key); - } - } - - protected override async flushSaves() { - const keys = Array.from(this.dirty); - if (keys.length === 0) { - this.saveTimer = undefined; - return; - } - this.dirty.clear(); - const db = await this.getDb(); - const tx = db.transaction("chunks", "readwrite"); - const store = tx.objectStore("chunks"); - for (const key of keys) { - const sc = this.saved.get(key); - if (!sc) continue; - await idbPut(store, sc.data.buffer, this.compositeKey(key)); - } - await txDone(tx); - this.saveTimer = undefined; - } - - private compositeKey(key: string) { - return compositeChunkDbKey(this.mapId, this.scaleKey, key); - } - - private async getDb(): Promise { - if (this.dbPromise) return this.dbPromise; - this.dbPromise = openVoxDb(); - return this.dbPromise; - } -} - -export class RemoteVoxSource extends VoxSource { - async listMaps(): Promise { - try { - const qs = this.qs({}); - const json = await this.httpGetJson(`${this.baseUrl}/info${qs}`); - const datasets = Array.isArray(json?.datasets) ? json.datasets : []; - const out: VoxMapConfig[] = []; - const toXYZ = (ary: number[]) => { - const a = ary.map((v) => Math.max(0, Math.floor(v ?? 0))); - if (a.length >= 3) return [a[2] || 0, a[1] || 0, a[0] || 0]; - return [a[0] || 0, a[1] || 0, a[2] || 0]; - }; - for (const ds of datasets) { - try { - const id: string = String(ds?.mapId ?? ds?.id ?? ds?.name ?? ds?.url ?? `map-${Date.now()}`); - const arrays = Array.isArray(ds?.arrays) ? ds.arrays : []; - const arr = arrays.find((a: any) => a?.path === "0") ?? arrays[0]; - if (!arr) continue; - // TODO: the server way of storing maps is wrong, espcially the bounds, data organization and missing scale and unit - const dtype = String(arr?.dtype) === "uint64" ? DataType.UINT64 : DataType.UINT32; - const upper = toXYZ(arr.shape); - const cds = toXYZ(arr.chunks).map((v) => Math.max(1, v)); - const lower = [0, 0, 0]; - const steps = computeSteps(upper, cds); - out.push({ - scaleMeters: [0.000000008, 0.000000008, 0.000000008], - unit: "nm", - id, - name: ds?.name ?? id, - baseVoxelOffset: new Float32Array(lower), - upperVoxelBound: new Float32Array(upper), - chunkDataSize: new Uint32Array(cds), - dataType: dtype, - steps, - serverUrl: this.baseUrl, - token: this.token - }); - } catch { - // ignore - } - } - return out; - } catch { - return [] as VoxMapConfig[]; - } - } - private labelsCache: number[] = []; - private baseUrl: string; - private token?: string; - - constructor(url: string, token?: string) { - super(); - this.baseUrl = url.replace(/\/$/, ""); - this.token = token; - } - - // ---- Public API overrides ---- - override async init(map: VoxMapConfig) { - const meta = await super.init(map); - // Bind dtype string - const dtypeStr = this.dtypeToString((this.mapCfg?.dataType ?? DataType.UINT32) as number); - // Call /init (best-effort; server may already have it) - const qs = this.qs({ - mapId: this.mapId, - scaleKey: this.scaleKey, - dtype: dtypeStr, - }); - try { - await this.httpGet(`${this.baseUrl}/init${qs}`); - } catch { - // ignore - } - return meta; - } - - async getSavedChunk(key: string): Promise { - const existing = this.saved.get(key); - if (existing) return existing; - const qs = this.qs({ mapId: this.mapId, chunkKey: key }); - try { - const buf = await this.httpGetArrayBuffer(`${this.baseUrl}/chunk${qs}`); - if (!buf) return undefined; - const arr = this.makeTypedArrayFromBuffer(buf); - const sc: SavedChunk = { data: arr, size: new Uint32Array(this.mapCfg!.chunkDataSize as any) }; - this.saved.set(key, sc); - this.enforceCap(); - return sc; - } catch (e: any) { - // 404 → not found - return undefined; - } - } - - async ensureChunk(key: string, size?: Uint32Array | number[]): Promise { - let sc = this.saved.get(key); - if (sc) return sc; - sc = await this.getSavedChunk(key); - if (sc) return sc; - // allocate zero-filled - const fallbackSize = new Uint32Array(this.mapCfg!.chunkDataSize as any); - const sz = new Uint32Array(size ?? fallbackSize); - const total = (sz[0] | 0) * (sz[1] | 0) * (sz[2] | 0); - const data = this.allocateTypedArray(total); - sc = { data, size: new Uint32Array(sz) }; - this.saved.set(key, sc); - this.enforceCap(); - this.markDirty(key); - return sc; - } - - async applyEdits( - edits: { - key: string; - indices: ArrayLike; - value?: number; - values?: ArrayLike; - size?: number[]; - }[], - ) { - for (const e of edits) { - const sc = await this.ensureChunk( - e.key, - e.size ? new Uint32Array(e.size) : (this.mapCfg!.chunkDataSize as any), - ); - this.applyEditsIntoChunk(sc, e.indices, e.value, e.values); - this.markDirty(e.key); - } - } - - protected override async flushSaves() { - const keys = Array.from(this.dirty); - if (keys.length === 0) { - this.saveTimer = undefined; - return; - } - this.dirty.clear(); - for (const key of keys) { - const sc = this.saved.get(key); - if (!sc) continue; - const qs = this.qs({ mapId: this.mapId, chunkKey: key }); - try { - await this.httpPutArrayBuffer(`${this.baseUrl}/chunk${qs}`, sc.data as any); - } catch (e) { - // If failed, keep dirty to retry later - this.dirty.add(key); - } - } - this.saveTimer = undefined; - } - - // ---- Helpers ---- - private qs(params: Record) { - const usp = new URLSearchParams(); - for (const [k, v] of Object.entries(params)) { - if (v === undefined || v === null) continue; - usp.set(k, String(v)); - } - if (this.token) usp.set("token", this.token); - const s = usp.toString(); - return s ? `?${s}` : ""; - } - - private dtypeToString(dt: number): "uint32" | "uint64" { - return dt === DataType.UINT64 ? "uint64" : "uint32"; - } - - private allocateTypedArray(total: number): Uint32Array | BigUint64Array { - if ((this.mapCfg?.dataType ?? DataType.UINT32) === DataType.UINT64) return new BigUint64Array(total); - return new Uint32Array(total); - } - - private makeTypedArrayFromBuffer(buf: ArrayBuffer): Uint32Array | BigUint64Array { - if ((this.mapCfg?.dataType ?? DataType.UINT32) === DataType.UINT64) return new BigUint64Array(buf); - return new Uint32Array(buf); - } - - private async httpGet(url: string): Promise { - const res = await fetch(url, { method: "GET", credentials: "omit" }); - if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`); - return res; - } - - private async httpGetArrayBuffer(url: string): Promise { - const res = await fetch(url, { method: "GET", credentials: "omit" }); - if (res.status === 404) return undefined; - if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`); - return await res.arrayBuffer(); - } - - private async httpPutArrayBuffer( - url: string, - body: ArrayBufferLike | ArrayBufferView, - ): Promise { - // Ensure we pass an ArrayBufferView to satisfy fetch BodyInit typing across platforms. - let payload: ArrayBufferView; - if (body instanceof ArrayBuffer) { - payload = new Uint8Array(body); - } else if ((body as any).buffer && (body as any).byteLength !== undefined) { - payload = body as ArrayBufferView; - } else { - payload = new Uint8Array(body as ArrayBufferLike); - } - const res = await fetch(url, { - method: "PUT", - body: payload as any, - headers: { "Content-Type": "application/octet-stream" }, - credentials: "omit", - }); - if (!res.ok) throw new Error(`PUT ${url} -> ${res.status}`); - } - - private async httpGetJson(url: string): Promise { - const res = await fetch(url, { method: "GET", credentials: "omit" }); - if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`); - return await res.json(); - } - - private async httpPutJson(url: string, body: any): Promise { - const res = await fetch(url, { - method: "PUT", - body: typeof body === "string" ? body : JSON.stringify(body), - headers: { "Content-Type": "application/json" }, - credentials: "omit", - }); - if (!res.ok) throw new Error(`PUT ${url} -> ${res.status}`); - return await res.json(); - } - - // --- Labels via remote server endpoints --- - override async getLabelIds(): Promise { - const qs = this.qs({ mapId: this.mapId }); - const json = await this.httpGetJson(`${this.baseUrl}/labels${qs}`); - const arr = Array.isArray(json?.labels) ? json.labels : []; - this.labelsCache = arr.map((v: any) => (v as number) >>> 0); - return Array.from(this.labelsCache); - } - - - override async addLabel(value: number): Promise { - const v = value >>> 0; - // If dtype is UINT64 we still send a 32-bit value; server must accept as valid subset. -> TODO: no - const qs = this.qs({ mapId: this.mapId }); - const json = await this.httpPutJson(`${this.baseUrl}/labels${qs}`, { value: v }); - const arr = Array.isArray(json?.labels) ? json.labels : []; - this.labelsCache = arr.map((x: any) => (x as number) >>> 0); - return Array.from(this.labelsCache); - } - - // LRU-style cap similar to LocalVoxSource - private enforceCap() { - while (this.saved.size > this.maxSavedChunks) { - let oldestKey: string | undefined; - for (const k of this.saved.keys()) { - if (!this.dirty.has(k)) { - oldestKey = k; - break; - } - } - if (oldestKey === undefined) break; - this.saved.delete(oldestKey); - } - } -} - -export function openVoxDb(): Promise { - return new Promise((resolve, reject) => { - const req = indexedDB.open("neuroglancer_vox", 2); - req.onerror = () => reject(req.error); - req.onupgradeneeded = () => { - const db = req.result; - if (!db.objectStoreNames.contains("maps")) db.createObjectStore("maps"); - if (!db.objectStoreNames.contains("chunks")) - db.createObjectStore("chunks"); - if (!db.objectStoreNames.contains("labels")) - db.createObjectStore("labels"); - }; - req.onsuccess = () => resolve(req.result); - }); -} - -// --- Small IDB helpers --- -export function idbGet( - db: IDBDatabase, - storeName: string, - key: IDBValidKey, -): Promise { - return new Promise((resolve, reject) => { - const tx = db.transaction(storeName, "readonly"); - const store = tx.objectStore(storeName); - const req = store.get(key); - req.onerror = () => reject(req.error); - req.onsuccess = () => resolve(req.result as any); - }); -} - -export function idbPut(store: IDBObjectStore, value: any, key?: IDBValidKey) { - return new Promise((resolve, reject) => { - const req = key === undefined ? store.put(value) : store.put(value, key); - req.onerror = () => reject(req.error); - req.onsuccess = () => resolve(); - }); -} - -export function txDone(tx: IDBTransaction) { - return new Promise((resolve, reject) => { - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - tx.onabort = () => reject(tx.error); - }); -} diff --git a/src/voxel_annotation/local_source.ts b/src/voxel_annotation/local_source.ts index e69de29bb2..8f8ba64d27 100644 --- a/src/voxel_annotation/local_source.ts +++ b/src/voxel_annotation/local_source.ts @@ -0,0 +1,303 @@ +import type { + SavedChunk} from "#src/voxel_annotation/index.js"; +import { + compositeChunkDbKey, + compositeLabelsDbKey, + VoxSource, +} from "#src/voxel_annotation/index.js"; +import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; +import { computeSteps } from "#src/voxel_annotation/map.js"; + +/** IndexedDB-backed local source. */ +export class LocalVoxSource extends VoxSource { + override async listMaps(): Promise { + try { + const db = await this.getDb(); + const tx = db.transaction("maps", "readonly"); + const store = tx.objectStore("maps"); + const getAll = (store as any).getAll?.bind(store); + const rows: any[] = await new Promise((resolve, reject) => { + if (getAll) { + const req = getAll(); + req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(req.result || []); + return; + } + const out: any[] = []; + const req = store.openCursor(); + req.onerror = () => reject(req.error); + req.onsuccess = (ev: any) => { + const cursor = ev.target.result as IDBCursorWithValue | null; + if (cursor) { + out.push(cursor.value); + cursor.continue(); + } else { + resolve(out); + } + }; + }); + const maps: VoxMapConfig[] = []; + for (const r of rows) { + try { + if ( + r?.id === undefined || + r?.baseVoxelOffset === undefined || + r?.upperVoxelBound === undefined || + r?.chunkDataSize === undefined || + r?.dataType === undefined || + r?.scaleMeters === undefined || + r?.unit === undefined + ) { + throw new Error("Invalid map configuration"); + } + const id = String(r.id); + const lower = Array.from(r.baseVoxelOffset).map((v: any) => + Number(v), + ) as number[]; + const upper = Array.from(r.upperVoxelBound).map((v: any) => + Number(v), + ) as number[]; + const cds = Array.from(r.chunkDataSize).map((v: any) => + Math.max(1, Number(v)), + ) as number[]; + const bounds = [ + (upper[0] | 0) - (lower[0] | 0), + (upper[1] | 0) - (lower[1] | 0), + (upper[2] | 0) - (lower[2] | 0), + ]; + const steps = computeSteps(bounds, cds); + maps.push({ + id, + name: r?.name ?? id, + baseVoxelOffset: new Float32Array(lower), + upperVoxelBound: new Float32Array(upper), + chunkDataSize: new Uint32Array(cds), + dataType: r.dataType, + scaleMeters: r.scaleMeters, + unit: r.unit, + steps, + }); + } catch { + // skip malformed + } + } + return maps; + } catch { + return [] as VoxMapConfig[]; + } + } + private dbPromise: Promise | null = null; + + override async getLabelIds(): Promise { + try { + const db = await this.getDb(); + const key = compositeLabelsDbKey(this.mapId, this.scaleKey); + const arr = await idbGet(db, "labels", key); + if (arr && Array.isArray(arr)) return arr.map((v) => v >>> 0); + return []; + } catch { + return []; + } + } + + + override async addLabel(value: number): Promise { + const v = value >>> 0; + const db = await this.getDb(); + const key = compositeLabelsDbKey(this.mapId, this.scaleKey); + const arr = (await idbGet(db, "labels", key)) || []; + // Ensure uniqueness + if (!arr.some((x) => (x >>> 0) === v)) arr.push(v); + const tx = db.transaction("labels", "readwrite"); + await idbPut(tx.objectStore("labels"), arr.map((x) => x >>> 0), key); + await txDone(tx); + return arr.map((x) => x >>> 0); + } + + private touch(key: string) { + const v = this.saved.get(key); + if (!v) return; + this.saved.delete(key); + this.saved.set(key, v); + } + + private enforceCap() { + // Evict only non-dirty entries to avoid losing unsaved edits. + while (this.saved.size > this.maxSavedChunks) { + let oldestKey: string | undefined = undefined; + for (const k of this.saved.keys()) { + if (!this.dirty.has(k)) { + oldestKey = k; + break; + } + } + if (oldestKey === undefined) { + // All entries are dirty; wait until they are flushed before evicting. + break; + } + this.saved.delete(oldestKey); + } + } + + override async init(map: VoxMapConfig) { + const meta = await super.init(map); + const db = await this.getDb(); + // Persist/update map metadata + const tx = db.transaction("maps", "readwrite"); + const cfg = this.mapCfg!; + tx.objectStore("maps").put( + cfg, + this.mapId, + ); + await txDone(tx); + return meta; + } + + async getSavedChunk(key: string): Promise { + const existing = this.saved.get(key); + if (existing) { + this.touch(key); + return existing; + } + const db = await this.getDb(); + const composite = this.compositeKey(key); + const buf = await idbGet(db, "chunks", composite); + if (buf) { + const arr = new Uint32Array(buf); + const sc: SavedChunk = { + data: arr, + size: new Uint32Array(this.mapCfg!.chunkDataSize as any), + }; + this.saved.set(key, sc); + this.enforceCap(); + return sc; + } + return undefined; + } + + async ensureChunk( + key: string, + size?: Uint32Array | number[], + ): Promise { + let sc = this.saved.get(key); + if (sc) { + this.touch(key); + return sc; + } + const db = await this.getDb(); + const composite = this.compositeKey(key); + const buf = await idbGet(db, "chunks", composite); + if (buf) { + const arr = new Uint32Array(buf); + sc = { data: arr, size: new Uint32Array(this.mapCfg!.chunkDataSize as any) }; + this.saved.set(key, sc); + this.enforceCap(); + return sc; + } + const fallbackSize = new Uint32Array(this.mapCfg!.chunkDataSize as any); + const sz = new Uint32Array(size ?? fallbackSize); + let total = 1; + for (let i = 0; i < 3; ++i) total *= sz[i]; + const arr = new Uint32Array(total); + sc = { data: arr, size: new Uint32Array(sz) }; + this.saved.set(key, sc); + this.enforceCap(); + this.markDirty(key); + return sc; + } + + async applyEdits( + edits: { + key: string; + indices: ArrayLike; + value?: number; + values?: ArrayLike; + size?: number[]; + }[], + ) { + for (const e of edits) { + const sc = await this.ensureChunk( + e.key, + e.size ? new Uint32Array(e.size) : (this.mapCfg!.chunkDataSize as any), + ); + this.applyEditsIntoChunk(sc, e.indices, e.value, e.values); + this.markDirty(e.key); + } + } + + protected override async flushSaves() { + const keys = Array.from(this.dirty); + if (keys.length === 0) { + this.saveTimer = undefined; + return; + } + this.dirty.clear(); + const db = await this.getDb(); + const tx = db.transaction("chunks", "readwrite"); + const store = tx.objectStore("chunks"); + for (const key of keys) { + const sc = this.saved.get(key); + if (!sc) continue; + await idbPut(store, sc.data.buffer, this.compositeKey(key)); + } + await txDone(tx); + this.saveTimer = undefined; + } + + private compositeKey(key: string) { + return compositeChunkDbKey(this.mapId, this.scaleKey, key); + } + + private async getDb(): Promise { + if (this.dbPromise) return this.dbPromise; + this.dbPromise = openVoxDb(); + return this.dbPromise; + } +} + +export function openVoxDb(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open("neuroglancer_vox", 2); + req.onerror = () => reject(req.error); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains("maps")) db.createObjectStore("maps"); + if (!db.objectStoreNames.contains("chunks")) + db.createObjectStore("chunks"); + if (!db.objectStoreNames.contains("labels")) + db.createObjectStore("labels"); + }; + req.onsuccess = () => resolve(req.result); + }); +} + +// --- Small IDB helpers --- +export function idbGet( + db: IDBDatabase, + storeName: string, + key: IDBValidKey, +): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, "readonly"); + const store = tx.objectStore(storeName); + const req = store.get(key); + req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(req.result as any); + }); +} + +export function idbPut(store: IDBObjectStore, value: any, key?: IDBValidKey) { + return new Promise((resolve, reject) => { + const req = key === undefined ? store.put(value) : store.put(value, key); + req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(); + }); +} + +export function txDone(tx: IDBTransaction) { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); +} diff --git a/src/voxel_annotation/remote_source.ts b/src/voxel_annotation/remote_source.ts index e69de29bb2..e764bdccec 100644 --- a/src/voxel_annotation/remote_source.ts +++ b/src/voxel_annotation/remote_source.ts @@ -0,0 +1,269 @@ +import { DataType } from "#src/sliceview/base.js"; +import type { SavedChunk} from "#src/voxel_annotation/index.js"; +import { VoxSource } from "#src/voxel_annotation/index.js"; +import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; +import { computeSteps } from "#src/voxel_annotation/map.js"; + +export class RemoteVoxSource extends VoxSource { + async listMaps(): Promise { + try { + const qs = this.qs({}); + const json = await this.httpGetJson(`${this.baseUrl}/info${qs}`); + const datasets = Array.isArray(json?.datasets) ? json.datasets : []; + const out: VoxMapConfig[] = []; + const toXYZ = (ary: number[]) => { + const a = ary.map((v) => Math.max(0, Math.floor(v ?? 0))); + if (a.length >= 3) return [a[2] || 0, a[1] || 0, a[0] || 0]; + return [a[0] || 0, a[1] || 0, a[2] || 0]; + }; + for (const ds of datasets) { + try { + const id: string = String(ds?.mapId ?? ds?.id ?? ds?.name ?? ds?.url ?? `map-${Date.now()}`); + const arrays = Array.isArray(ds?.arrays) ? ds.arrays : []; + const arr = arrays.find((a: any) => a?.path === "0") ?? arrays[0]; + if (!arr) continue; + // TODO: the server way of storing maps is wrong, espcially the bounds, data organization and missing scale and unit + const dtype = String(arr?.dtype) === "uint64" ? DataType.UINT64 : DataType.UINT32; + const upper = toXYZ(arr.shape); + const cds = toXYZ(arr.chunks).map((v) => Math.max(1, v)); + const lower = [0, 0, 0]; + const steps = computeSteps(upper, cds); + out.push({ + scaleMeters: [0.000000008, 0.000000008, 0.000000008], + unit: "nm", + id, + name: ds?.name ?? id, + baseVoxelOffset: new Float32Array(lower), + upperVoxelBound: new Float32Array(upper), + chunkDataSize: new Uint32Array(cds), + dataType: dtype, + steps, + serverUrl: this.baseUrl, + token: this.token + }); + } catch { + // ignore + } + } + return out; + } catch { + return [] as VoxMapConfig[]; + } + } + private labelsCache: number[] = []; + private baseUrl: string; + private token?: string; + + constructor(url: string, token?: string) { + super(); + this.baseUrl = url.replace(/\/$/, ""); + this.token = token; + } + + // ---- Public API overrides ---- + override async init(map: VoxMapConfig) { + const meta = await super.init(map); + // Bind dtype string + const dtypeStr = this.dtypeToString((this.mapCfg?.dataType ?? DataType.UINT32) as number); + // Call /init (best-effort; server may already have it) + const qs = this.qs({ + mapId: this.mapId, + scaleKey: this.scaleKey, + dtype: dtypeStr, + }); + try { + await this.httpGet(`${this.baseUrl}/init${qs}`); + } catch { + // ignore + } + return meta; + } + + async getSavedChunk(key: string): Promise { + const existing = this.saved.get(key); + if (existing) return existing; + const qs = this.qs({ mapId: this.mapId, chunkKey: key }); + try { + const buf = await this.httpGetArrayBuffer(`${this.baseUrl}/chunk${qs}`); + if (!buf) return undefined; + const arr = this.makeTypedArrayFromBuffer(buf); + const sc: SavedChunk = { data: arr, size: new Uint32Array(this.mapCfg!.chunkDataSize as any) }; + this.saved.set(key, sc); + this.enforceCap(); + return sc; + } catch (e: any) { + // 404 → not found + return undefined; + } + } + + async ensureChunk(key: string, size?: Uint32Array | number[]): Promise { + let sc = this.saved.get(key); + if (sc) return sc; + sc = await this.getSavedChunk(key); + if (sc) return sc; + // allocate zero-filled + const fallbackSize = new Uint32Array(this.mapCfg!.chunkDataSize as any); + const sz = new Uint32Array(size ?? fallbackSize); + const total = (sz[0] | 0) * (sz[1] | 0) * (sz[2] | 0); + const data = this.allocateTypedArray(total); + sc = { data, size: new Uint32Array(sz) }; + this.saved.set(key, sc); + this.enforceCap(); + this.markDirty(key); + return sc; + } + + async applyEdits( + edits: { + key: string; + indices: ArrayLike; + value?: number; + values?: ArrayLike; + size?: number[]; + }[], + ) { + for (const e of edits) { + const sc = await this.ensureChunk( + e.key, + e.size ? new Uint32Array(e.size) : (this.mapCfg!.chunkDataSize as any), + ); + this.applyEditsIntoChunk(sc, e.indices, e.value, e.values); + this.markDirty(e.key); + } + } + + protected override async flushSaves() { + const keys = Array.from(this.dirty); + if (keys.length === 0) { + this.saveTimer = undefined; + return; + } + this.dirty.clear(); + for (const key of keys) { + const sc = this.saved.get(key); + if (!sc) continue; + const qs = this.qs({ mapId: this.mapId, chunkKey: key }); + try { + await this.httpPutArrayBuffer(`${this.baseUrl}/chunk${qs}`, sc.data as any); + } catch (e) { + // If failed, keep dirty to retry later + this.dirty.add(key); + } + } + this.saveTimer = undefined; + } + + // ---- Helpers ---- + private qs(params: Record) { + const usp = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v === undefined || v === null) continue; + usp.set(k, String(v)); + } + if (this.token) usp.set("token", this.token); + const s = usp.toString(); + return s ? `?${s}` : ""; + } + + private dtypeToString(dt: number): "uint32" | "uint64" { + return dt === DataType.UINT64 ? "uint64" : "uint32"; + } + + private allocateTypedArray(total: number): Uint32Array | BigUint64Array { + if ((this.mapCfg?.dataType ?? DataType.UINT32) === DataType.UINT64) return new BigUint64Array(total); + return new Uint32Array(total); + } + + private makeTypedArrayFromBuffer(buf: ArrayBuffer): Uint32Array | BigUint64Array { + if ((this.mapCfg?.dataType ?? DataType.UINT32) === DataType.UINT64) return new BigUint64Array(buf); + return new Uint32Array(buf); + } + + private async httpGet(url: string): Promise { + const res = await fetch(url, { method: "GET", credentials: "omit" }); + if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`); + return res; + } + + private async httpGetArrayBuffer(url: string): Promise { + const res = await fetch(url, { method: "GET", credentials: "omit" }); + if (res.status === 404) return undefined; + if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`); + return await res.arrayBuffer(); + } + + private async httpPutArrayBuffer( + url: string, + body: ArrayBufferLike | ArrayBufferView, + ): Promise { + // Ensure we pass an ArrayBufferView to satisfy fetch BodyInit typing across platforms. + let payload: ArrayBufferView; + if (body instanceof ArrayBuffer) { + payload = new Uint8Array(body); + } else if ((body as any).buffer && (body as any).byteLength !== undefined) { + payload = body as ArrayBufferView; + } else { + payload = new Uint8Array(body as ArrayBufferLike); + } + const res = await fetch(url, { + method: "PUT", + body: payload as any, + headers: { "Content-Type": "application/octet-stream" }, + credentials: "omit", + }); + if (!res.ok) throw new Error(`PUT ${url} -> ${res.status}`); + } + + private async httpGetJson(url: string): Promise { + const res = await fetch(url, { method: "GET", credentials: "omit" }); + if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`); + return await res.json(); + } + + private async httpPutJson(url: string, body: any): Promise { + const res = await fetch(url, { + method: "PUT", + body: typeof body === "string" ? body : JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + credentials: "omit", + }); + if (!res.ok) throw new Error(`PUT ${url} -> ${res.status}`); + return await res.json(); + } + + // --- Labels via remote server endpoints --- + override async getLabelIds(): Promise { + const qs = this.qs({ mapId: this.mapId }); + const json = await this.httpGetJson(`${this.baseUrl}/labels${qs}`); + const arr = Array.isArray(json?.labels) ? json.labels : []; + this.labelsCache = arr.map((v: any) => (v as number) >>> 0); + return Array.from(this.labelsCache); + } + + + override async addLabel(value: number): Promise { + const v = value >>> 0; + // If dtype is UINT64 we still send a 32-bit value; server must accept as valid subset. -> TODO: no + const qs = this.qs({ mapId: this.mapId }); + const json = await this.httpPutJson(`${this.baseUrl}/labels${qs}`, { value: v }); + const arr = Array.isArray(json?.labels) ? json.labels : []; + this.labelsCache = arr.map((x: any) => (x as number) >>> 0); + return Array.from(this.labelsCache); + } + + // LRU-style cap similar to LocalVoxSource + private enforceCap() { + while (this.saved.size > this.maxSavedChunks) { + let oldestKey: string | undefined; + for (const k of this.saved.keys()) { + if (!this.dirty.has(k)) { + oldestKey = k; + break; + } + } + if (oldestKey === undefined) break; + this.saved.delete(oldestKey); + } + } +} From 579611657cf44ae943ddbc1f0543dc44d19f63a8 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 035/251] feat: introduce chunk reload and downsample propagation APIs - Add `reloadChunksByKey` to manage backend-triggered chunk reloads. - Implement downsample cascade logic to update lower LOD levels dynamically. - Replace `makePersistantChunkKey` with `makeVoxChunkKey` for better naming consistency. - Refactor scaleKey usage, removing unnecessary components. - Enhance `VoxSource` to manage `VoxChunkSource` instances and propagate chunk reloads. - Update frontend and backend interactions for better chunk processing and LOD handling. - Include utility functions for chunk key parsing and downsampling calculations. --- NOTES/TODOs.md | 4 +- src/voxel_annotation/backend.ts | 26 +++-- src/voxel_annotation/base.ts | 17 +++- src/voxel_annotation/frontend.ts | 23 ++++- src/voxel_annotation/index.ts | 45 +++++---- src/voxel_annotation/local_source.ts | 138 +++++++++++++++++++++++++- src/voxel_annotation/remote_source.ts | 1 - 7 files changed, 216 insertions(+), 38 deletions(-) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index 23bed83488..7f812bfa85 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -3,7 +3,7 @@ - LOD -> where am I: - choosing the lod level depanding on the brush size, live rendering is working local data saving is working. - there are some performance issues at very high brush sizes (>1000) - - implement down/upsampleing (see diagram below) + - TODO FOR TOMORROW: implement down/upsampling locally (see diagram below), note we may need a way to trigger redownload on the front from the backend. - cleanup label handling code (more specifically in the ui code: layer/vox/index.ts, would be nice to have a handler similar to the one for maps) - Add redundancy to avoid corrupt/unsaved chunks on the remote @@ -37,7 +37,7 @@ Drawing Flow chart: -> Brush stroke start -> lock LOD level to the brush size one -> Live render the drawing - -> Commit modifications to backend -> Save, downsample and mark upsamples as dirty + -> Commit modifications to backend -> Save, downsample and mark upsamples as dirty (they will be recalculated on the fly when needed) -> Brush stoke ends -> Unlock LOD level (maybe add a small delay to avoid flickering) -> Progressivly download upscalings as they roll out diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 0abcd02572..6e8f740923 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -7,12 +7,13 @@ import type { VolumeChunk } from "#src/sliceview/volume/backend.js"; import { VolumeChunkSource as BaseVolumeChunkSource } from "#src/sliceview/volume/backend.js"; import { DataType } from "#src/util/data_type.js"; import { - makePersistantChunkKey, + makeVoxChunkKey, VOX_CHUNK_SOURCE_RPC_ID, VOX_COMMIT_VOXELS_RPC_ID, VOX_LABELS_ADD_RPC_ID, VOX_LABELS_GET_RPC_ID, - VOX_MAP_INIT_RPC_ID + VOX_MAP_INIT_RPC_ID, + VOX_RELOAD_CHUNKS_RPC_ID, } from "#src/voxel_annotation/base.js"; import type { VoxSource } from "#src/voxel_annotation/index.js"; import { LocalVoxSource } from "#src/voxel_annotation/local_source.js"; @@ -46,13 +47,18 @@ function makeRegistryKey(serverUrl: string | undefined, token: string | undefine return `${sk}|${map.id}|${scaleKey}`; } -async function getOrCreateRegisteredVoxSource(serverUrl: string | undefined, token: string | undefined, map: VoxMapConfig): Promise { +async function getOrCreateRegisteredVoxSource(serverUrl: string | undefined, token: string | undefined, map: VoxMapConfig, vcsInstance: VoxChunkSource): Promise { const key = makeRegistryKey(serverUrl, token, map); const existing = voxSourceRegistry.get(key); - if (existing) return existing; + if (existing) + { + existing.addVoxChunkSource(vcsInstance); + return existing; + } const src = serverUrl ? new RemoteVoxSource(serverUrl, token) : new LocalVoxSource(); await src.init(map); voxSourceRegistry.set(key, src); + src.addVoxChunkSource(vcsInstance); return src; } @@ -61,7 +67,7 @@ export class VoxChunkSource extends BaseVolumeChunkSource { private source?: VoxSource; private voxServerUrl?: string; private voxToken?: string; - private lodFactor: number; + public lodFactor: number; private mapReadyPromise: Promise; private resolveMapReady!: () => void; @@ -102,11 +108,19 @@ export class VoxChunkSource extends BaseVolumeChunkSource { this.voxServerUrl, this.voxToken, map, + this ); // Signal readiness for any pending downloads/commits try { this.resolveMapReady(); } catch { /* ignore multiple resolutions */ } } + reloadChunksByKey(keys: string[]) { + this.rpc?.invoke(VOX_RELOAD_CHUNKS_RPC_ID, { + id: this.rpcId, + keys, + }); + } + /** Commit voxel edits from the frontend. */ async commitVoxels( edits: { @@ -150,7 +164,7 @@ export class VoxChunkSource extends BaseVolumeChunkSource { // Always produce a typed array matching the spec type; MVP uses UINT32 const array = this.allocateTypedArray(this.spec.dataType, total, 0); // Load saved chunk if present and copy overlapping region - const saved = await src.getSavedChunk(makePersistantChunkKey(key, this.lodFactor)); + const saved = await src.getSavedChunk(makeVoxChunkKey(key, this.lodFactor)); if (saved) { const sxS = saved.size[0], syS = saved.size[1], diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index b06b5e3181..de9670dfd1 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -3,7 +3,22 @@ export const VOX_COMMIT_VOXELS_RPC_ID = "vox.commitVoxels"; export const VOX_MAP_INIT_RPC_ID = "vox.map.init"; export const VOX_LABELS_GET_RPC_ID = "vox.labels.get"; export const VOX_LABELS_ADD_RPC_ID = "vox.labels.add"; +export const VOX_RELOAD_CHUNKS_RPC_ID = "vox.chunk.reload"; -export function makePersistantChunkKey(chunkKey: string, lodFactor : number) { +export function makeVoxChunkKey(chunkKey: string, lodFactor : number) { return `lod${lodFactor}#${chunkKey}`; } + +export function makeChunkKey(x: number, y : number, z: number) { + return `${x},${y},${z}`; +} + +export function parseVoxChunkKey(key: string) { + const parts = [Number(key.split("#")[0].substring(3)), + ...key.split("#")[1].split(",").map(Number)]; + if (parts.length !== 4 || parts.some(isNaN)) { + console.warn(`Invalid chunk key format: ${key}`); + return null; + } + return { lod: parts[0], x: parts[1], y: parts[2], z: parts[3], chunkKey: key.split("#")[1] }; +} diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index b85e4d1580..80acff7d94 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -16,10 +16,11 @@ import { VOX_MAP_INIT_RPC_ID, VOX_LABELS_GET_RPC_ID, VOX_LABELS_ADD_RPC_ID, - makePersistantChunkKey, + makeVoxChunkKey, + VOX_RELOAD_CHUNKS_RPC_ID, } from "#src/voxel_annotation/base.js"; import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; -import { registerSharedObjectOwner } from "#src/worker_rpc.js"; +import { registerRPC, registerSharedObjectOwner } from "#src/worker_rpc.js"; /** * Frontend owner for VoxChunkSource, extended with a local optimistic edit overlay. @@ -130,6 +131,17 @@ export class VoxChunkSource extends BaseVolumeChunkSource { this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); } + invalidateChunksByKey(keys: string[]) { + for (const key of keys) { + const chunk = this.chunks.get(key) as VolumeChunk | undefined; + const cpuArray = chunk ? this.getCpuArrayForChunk(chunk) : null; + if (chunk && cpuArray) { + this.invalidateChunkUpload(chunk); + } + } + this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); + } + /** Batch paint API to minimize GPU uploads by chunk. */ paintVoxelsBatch(voxels: Float32Array[], value: number) { if (!voxels || voxels.length === 0) return; @@ -166,7 +178,7 @@ export class VoxChunkSource extends BaseVolumeChunkSource { if (editsByKey.size > 0) { const size = Array.from(this.spec.chunkDataSize); const edits = Array.from(editsByKey, ([key, indices]) => ({ - key: makePersistantChunkKey(key, this.lodFactor), + key: makeVoxChunkKey(key, this.lodFactor), indices, value, size, @@ -246,3 +258,8 @@ export class VoxChunkSource extends BaseVolumeChunkSource { chunk.copyToGPU(gl); } } + +registerRPC(VOX_RELOAD_CHUNKS_RPC_ID, function (x: any) { + const obj = this.get(x.id) as VoxChunkSource; + obj.invalidateChunksByKey(x.keys); +}); diff --git a/src/voxel_annotation/index.ts b/src/voxel_annotation/index.ts index a05ec3c711..82b6be82ff 100644 --- a/src/voxel_annotation/index.ts +++ b/src/voxel_annotation/index.ts @@ -3,6 +3,8 @@ * The LocalVoxSource persists per-chunk arrays into IndexedDB with a debounced saver. */ +import type { VoxChunkSource } from "#src/voxel_annotation/backend.js"; +import { parseVoxChunkKey } from "#src/voxel_annotation/base.js"; import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; @@ -11,27 +13,15 @@ export interface SavedChunk { size: Uint32Array; // canonical size used for linearization (usually spec.chunkDataSize) } -export function toScaleKey( - chunkDataSize: number[] | Uint32Array, - baseVoxelOffset?: number[] | Uint32Array | Float32Array, - upperVoxelBound?: number[] | Uint32Array | Float32Array, -): string { - const cds = Array.from(chunkDataSize); - const lower = Array.from(baseVoxelOffset ?? [0, 0, 0]); - const upper = Array.from(upperVoxelBound ?? [0, 0, 0]); - return `${cds[0]}_${cds[1]}_${cds[2]}:${lower[0]}_${lower[1]}_${lower[2]}-${upper[0]}_${upper[1]}_${upper[2]}`; -} - export function compositeChunkDbKey( mapId: string, - scaleKey: string, chunkKey: string, ): string { - return `${mapId}:${scaleKey}:${chunkKey}`; + return `${mapId}:${chunkKey}`; } -export function compositeLabelsDbKey(mapId: string, scaleKey: string): string { - return `${mapId}:${scaleKey}:labels`; +export function compositeLabelsDbKey(mapId: string): string { + return `${mapId}:labels`; } export abstract class VoxSource { @@ -43,16 +33,16 @@ export abstract class VoxSource { return []; } protected mapId: string = "default"; - protected scaleKey: string = ""; protected mapCfg: VoxMapConfig; // Keep the entire configuration in one place // In-memory cache of loaded chunks - protected maxSavedChunks = 128; // cap to prevent unbounded growth + protected maxSavedChunks = 0; // cap to prevent unbounded growth protected saved = new Map(); // Dirty tracking and debounced save protected dirty = new Set(); protected saveTimer: number | undefined; + protected voxChunkSources = new Map(); /** * Generic label persistence hooks. Subclasses override to connect to the chosen datasource. @@ -66,15 +56,30 @@ export abstract class VoxSource { return []; } - init(map: VoxMapConfig): Promise<{ mapId: string; scaleKey: string }> { + init(map: VoxMapConfig): Promise<{ mapId: string}> { if(!map) { throw new Error("VoxSource: init: Map config is required"); } this.mapCfg = map; this.mapId = map.id; - this.scaleKey = toScaleKey(map.chunkDataSize, map.baseVoxelOffset, map.upperVoxelBound); - return Promise.resolve({ mapId: this.mapId, scaleKey: this.scaleKey }); + return Promise.resolve({ mapId: this.mapId }); + } + + addVoxChunkSource(vcs: VoxChunkSource) { + this.voxChunkSources.set(vcs.lodFactor, vcs); + } + + callChunkReload(voxChunkKey: string) { + const parsed_vck = parseVoxChunkKey(voxChunkKey); + if (!parsed_vck) { + console.error("VoxSource: callChunkReload: invalid chunk key", voxChunkKey); + return; + } + const vcs = this.voxChunkSources.get(parsed_vck.lod); + if (vcs) { + vcs.reloadChunksByKey([parsed_vck.chunkKey]); + } } // Common helpers diff --git a/src/voxel_annotation/local_source.ts b/src/voxel_annotation/local_source.ts index 8f8ba64d27..53684e7a37 100644 --- a/src/voxel_annotation/local_source.ts +++ b/src/voxel_annotation/local_source.ts @@ -1,5 +1,5 @@ -import type { - SavedChunk} from "#src/voxel_annotation/index.js"; +import { makeVoxChunkKey, parseVoxChunkKey } from "#src/voxel_annotation/base.js"; +import type { SavedChunk } from "#src/voxel_annotation/index.js"; import { compositeChunkDbKey, compositeLabelsDbKey, @@ -8,6 +8,17 @@ import { import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; import { computeSteps } from "#src/voxel_annotation/map.js"; +/** + * Calculates the number of meaningful downsample passes for the worst case + */ +function calculateDownsamplePasses(chunkSize: number) { + if (chunkSize <= 1) { + return 0; + } + + return Math.ceil(Math.log2(chunkSize)); +} + /** IndexedDB-backed local source. */ export class LocalVoxSource extends VoxSource { override async listMaps(): Promise { @@ -91,7 +102,7 @@ export class LocalVoxSource extends VoxSource { override async getLabelIds(): Promise { try { const db = await this.getDb(); - const key = compositeLabelsDbKey(this.mapId, this.scaleKey); + const key = compositeLabelsDbKey(this.mapId); const arr = await idbGet(db, "labels", key); if (arr && Array.isArray(arr)) return arr.map((v) => v >>> 0); return []; @@ -104,7 +115,7 @@ export class LocalVoxSource extends VoxSource { override async addLabel(value: number): Promise { const v = value >>> 0; const db = await this.getDb(); - const key = compositeLabelsDbKey(this.mapId, this.scaleKey); + const key = compositeLabelsDbKey(this.mapId); const arr = (await idbGet(db, "labels", key)) || []; // Ensure uniqueness if (!arr.some((x) => (x >>> 0) === v)) arr.push(v); @@ -222,9 +233,126 @@ export class LocalVoxSource extends VoxSource { ); this.applyEditsIntoChunk(sc, e.indices, e.value, e.values); this.markDirty(e.key); + this.propagateDownsample(e.key); + } + } + + /** + * Public entry point to start the downsampling cascade for a modified chunk. + * @param sourceKey The key of the chunk that was edited. + */ + public async propagateDownsample(sourceKey: string): Promise { + const keyInfo = parseVoxChunkKey(sourceKey); + if (!keyInfo || !this.mapCfg) return; + + // Assuming cubic chunks + const chunkSize = this.mapCfg.chunkDataSize[0]; + const maxPasses = calculateDownsamplePasses(chunkSize); + const maxLOD = this.mapCfg.steps[this.mapCfg.steps.length - 1]; + + let currentKey = sourceKey; + for (let i = 0; i < maxPasses; i++) { + const currentKeyInfo = parseVoxChunkKey(currentKey)!; + if (currentKeyInfo.lod >= maxLOD) { + console.log(`Reached max LOD ${maxLOD}, stopping downsample.`); + break; + } + + const targetKey = await this._downsampleStep(currentKey); + if (!targetKey) { + console.log("Downsample step failed or was unnecessary, stopping cascade."); + break; + } + console.log(`Downsampled ${currentKey} to ${targetKey}`); + this.callChunkReload(targetKey); + currentKey = targetKey; + } + } + + /** + * Performs a single downsample step, creating one lower-resolution chunk + * from a higher-resolution one. + */ + private async _downsampleStep(sourceKey: string): Promise { + const sourceKeyInfo = parseVoxChunkKey(sourceKey); + if (!sourceKeyInfo) return null; + + const sourceChunk = await this.getSavedChunk(sourceKey); + if (!sourceChunk) return null; // Cannot downsample if source doesn't exist. + + const targetLOD = sourceKeyInfo.lod * 2; + const targetX = Math.floor(sourceKeyInfo.x / 2); + const targetY = Math.floor(sourceKeyInfo.y / 2); + const targetZ = Math.floor(sourceKeyInfo.z / 2); + const targetKey = makeVoxChunkKey(`${targetX},${targetY},${targetZ}`, targetLOD); + + const targetChunk = await this.ensureChunk(targetKey); + + // Determine the 32x32x32 sub-volume to write into the target chunk + const [chunkW, chunkH, chunkD] = targetChunk.size; + const [subW, subH, subD] = [chunkW / 2, chunkH / 2, chunkD / 2]; + const offsetX = (sourceKeyInfo.x % 2) * subW; + const offsetY = (sourceKeyInfo.y % 2) * subH; + const offsetZ = (sourceKeyInfo.z % 2) * subD; + + for (let z = 0; z < subD; z++) { + for (let y = 0; y < subH; y++) { + for (let x = 0; x < subW; x++) { + const sourceValues: number[] = []; + // Collect the 8 corresponding source voxels + for (let dz = 0; dz < 2; dz++) { + for (let dy = 0; dy < 2; dy++) { + for (let dx = 0; dx < 2; dx++) { + const val = this._getVoxel(sourceChunk, x * 2 + dx, y * 2 + dy, z * 2 + dz); + sourceValues.push(val as number); // WARNING: Bigint not supported + } + } + } + const mode = this._calculateMode(sourceValues); + this._setVoxel(targetChunk, x + offsetX, y + offsetY, z + offsetZ, mode); + } + } + } + + this.saved.set(targetKey, targetChunk); + this.markDirty(targetKey); + return targetKey; + } + + private _getVoxel(chunk: SavedChunk, x: number, y: number, z: number): number | bigint { + const [sx, sy] = chunk.size; + // Bounds check is implicitly handled by the loop structure but good practice + const index = z * sx * sy + y * sx + x; + return chunk.data[index]; + } + + private _setVoxel(chunk: SavedChunk, x: number, y: number, z: number, value: number | bigint): void { + const [sx, sy] = chunk.size; + const index = z * sx * sy + y * sx + x; + chunk.data[index] = value; + } + + /** Calculates the most frequent non-zero value (mode) for label data. */ + // TODO: support bigint + private _calculateMode(values: number[] | bigint[]): number | bigint { + if (values.length === 0) return 0; + const counts = new Map(); + let maxCount = 0; + let mode = 0; // Default to 0 (background) + + for (const val of values) { + if (val === 0) continue; // Ignore the background label + const count = (counts.get(val) || 0) + 1; + counts.set(val, count); + if (count > maxCount) { + maxCount = count; + mode = val as number; // WARNING: this will break if bigint is used for labels + } } + return mode; } + protected override async flushSaves() { const keys = Array.from(this.dirty); if (keys.length === 0) { @@ -245,7 +373,7 @@ export class LocalVoxSource extends VoxSource { } private compositeKey(key: string) { - return compositeChunkDbKey(this.mapId, this.scaleKey, key); + return compositeChunkDbKey(this.mapId, key); } private async getDb(): Promise { diff --git a/src/voxel_annotation/remote_source.ts b/src/voxel_annotation/remote_source.ts index e764bdccec..9f3c6c6cac 100644 --- a/src/voxel_annotation/remote_source.ts +++ b/src/voxel_annotation/remote_source.ts @@ -68,7 +68,6 @@ export class RemoteVoxSource extends VoxSource { // Call /init (best-effort; server may already have it) const qs = this.qs({ mapId: this.mapId, - scaleKey: this.scaleKey, dtype: dtypeStr, }); try { From 33d051a2d8be3dc68da9d0784b637ac8333d466b Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 036/251] feat: chunk reloading from the backend -> currently do not work due to a design issue: the VoxSource is not unique, one is created for each VoxChunkSource --- src/voxel_annotation/backend.ts | 57 +++++++++++++++++++++------- src/voxel_annotation/frontend.ts | 3 ++ src/voxel_annotation/index.ts | 4 ++ src/voxel_annotation/local_source.ts | 6 ++- 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 6e8f740923..801e087228 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -34,24 +34,20 @@ function makeServerKey(serverUrl?: string, token?: string): string { return `remote:${serverUrl}|${token ?? ""}`; } -function toScaleKeySafe(map: VoxMapConfig): string { - const cds = Array.from(map.chunkDataSize); - const lower = Array.from(map.baseVoxelOffset); - const upper = Array.from(map.upperVoxelBound); - return `${cds.join(',')}:${lower.join(',')}-${upper.join(',')}`; -} - function makeRegistryKey(serverUrl: string | undefined, token: string | undefined, map: VoxMapConfig): string { const sk = makeServerKey(serverUrl, token); - const scaleKey = toScaleKeySafe(map); - return `${sk}|${map.id}|${scaleKey}`; + return `${sk}|${map.id}`; } async function getOrCreateRegisteredVoxSource(serverUrl: string | undefined, token: string | undefined, map: VoxMapConfig, vcsInstance: VoxChunkSource): Promise { + console.log("getOrCreateRegisteredVoxSource", vcsInstance.lodFactor) const key = makeRegistryKey(serverUrl, token, map); + console.log(voxSourceRegistry) + console.log("getOrCreateRegisteredVoxSource: key", key) const existing = voxSourceRegistry.get(key); if (existing) { + console.log("getOrCreateRegisteredVoxSource: using existing source") existing.addVoxChunkSource(vcsInstance); return existing; } @@ -71,6 +67,17 @@ export class VoxChunkSource extends BaseVolumeChunkSource { private mapReadyPromise: Promise; private resolveMapReady!: () => void; + // Debounced commit batching to minimize backend churn for rapid strokes. + private pendingCommitEdits: { + key: string; + indices: number[] | Uint32Array; + value?: number; + values?: ArrayLike; + size?: number[]; + }[] = []; + private commitDebounceTimer: number | undefined; + private readonly commitDebounceDelayMs: number = 75; + constructor(rpc: RPC, options: any) { super(rpc, options); // Detect remote server configuration from options (flexible keys) @@ -115,13 +122,27 @@ export class VoxChunkSource extends BaseVolumeChunkSource { } reloadChunksByKey(keys: string[]) { + console.log("sending RPC call") + + this.rpc?.invoke(VOX_RELOAD_CHUNKS_RPC_ID, { id: this.rpcId, keys, }); } - /** Commit voxel edits from the frontend. */ + private async flushPendingCommitEdits(): Promise { + await this.mapReadyPromise; + const src = this.source; + if (!src) throw new Error("flushPendingCommitEdits: source is not initialized for this map"); + const pending = this.pendingCommitEdits; + this.pendingCommitEdits = []; + this.commitDebounceTimer = undefined; + if (pending.length === 0) return; + await src.applyEdits(pending); + } + + /** Commit voxel edits from the frontend (debounced to batch rapid updates). */ async commitVoxels( edits: { key: string; @@ -132,9 +153,19 @@ export class VoxChunkSource extends BaseVolumeChunkSource { }[], ) { await this.mapReadyPromise; - const src = this.source; - if (!src) throw new Error("commitVoxels: source is not initialized for this map"); - await src.applyEdits(edits); + if (!this.source) throw new Error("commitVoxels: source is not initialized for this map"); + // Enqueue edits and schedule a short debounce to coalesce frames + for (const e of edits) { + if (!e || !e.key || !e.indices) { + throw new Error("commitVoxels: invalid edit payload"); + } + this.pendingCommitEdits.push(e); + } + if (this.commitDebounceTimer === undefined) { + this.commitDebounceTimer = setTimeout(() => { + void this.flushPendingCommitEdits(); + }, this.commitDebounceDelayMs) as unknown as number; + } } async getLabelIds(): Promise { diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 80acff7d94..69a7d325f2 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -132,10 +132,12 @@ export class VoxChunkSource extends BaseVolumeChunkSource { } invalidateChunksByKey(keys: string[]) { + console.log("invalidateChunksByKey", keys); for (const key of keys) { const chunk = this.chunks.get(key) as VolumeChunk | undefined; const cpuArray = chunk ? this.getCpuArrayForChunk(chunk) : null; if (chunk && cpuArray) { + console.log("chunk:", key, " has been reloaded"); this.invalidateChunkUpload(chunk); } } @@ -261,5 +263,6 @@ export class VoxChunkSource extends BaseVolumeChunkSource { registerRPC(VOX_RELOAD_CHUNKS_RPC_ID, function (x: any) { const obj = this.get(x.id) as VoxChunkSource; + console.log("received RPC call") obj.invalidateChunksByKey(x.keys); }); diff --git a/src/voxel_annotation/index.ts b/src/voxel_annotation/index.ts index 82b6be82ff..5cd421e54d 100644 --- a/src/voxel_annotation/index.ts +++ b/src/voxel_annotation/index.ts @@ -71,13 +71,17 @@ export abstract class VoxSource { } callChunkReload(voxChunkKey: string) { + console.log("VoxSource: callChunkReload: ", voxChunkKey); const parsed_vck = parseVoxChunkKey(voxChunkKey); + console.log("parsed_vck: ", parsed_vck); if (!parsed_vck) { console.error("VoxSource: callChunkReload: invalid chunk key", voxChunkKey); return; } const vcs = this.voxChunkSources.get(parsed_vck.lod); + console.log("vcs: ", this.voxChunkSources); if (vcs) { + console.log("invoking reloadChunksByKey for ", voxChunkKey); vcs.reloadChunksByKey([parsed_vck.chunkKey]); } } diff --git a/src/voxel_annotation/local_source.ts b/src/voxel_annotation/local_source.ts index 53684e7a37..f070ace894 100644 --- a/src/voxel_annotation/local_source.ts +++ b/src/voxel_annotation/local_source.ts @@ -226,6 +226,7 @@ export class LocalVoxSource extends VoxSource { size?: number[]; }[], ) { + const touchedKeys = new Set(); for (const e of edits) { const sc = await this.ensureChunk( e.key, @@ -233,7 +234,10 @@ export class LocalVoxSource extends VoxSource { ); this.applyEditsIntoChunk(sc, e.indices, e.value, e.values); this.markDirty(e.key); - this.propagateDownsample(e.key); + touchedKeys.add(e.key); + } + for (const key of touchedKeys) { + this.propagateDownsample(key); } } From cad4a808ae4163d72ea044ac3344b85e3bb51a64 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 037/251] feat: add Zarr export functionality and dirty-tree upscaling (not working for now) - Introduce a "Export to Zarr" feature for exporting LOD 1 voxel data to Zarr v2 datasets. - Add UI components for configuring export URLs and monitoring progress. - Implement dirty-tree tracking and upscaling in `LocalVoxSource` for consistent chunk hierarchy. - Extend `exportVoxToZarr` for generating minimal Zarr metadata and handling IndexedDB chunk uploads. - Update styles for improved UI readability and export progress display. --- NOTES/TODOs.md | 8 +- src/layer/vox/style.css | 14 + src/layer/vox/tabs/settings.ts | 76 +++++- src/voxel_annotation/export_to_zarr.ts | 363 +++++++++++++++++++++++++ src/voxel_annotation/local_source.ts | 263 ++++++++++++++++-- 5 files changed, 697 insertions(+), 27 deletions(-) create mode 100644 src/voxel_annotation/export_to_zarr.ts diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index 7f812bfa85..508ac1a2d9 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -3,7 +3,8 @@ - LOD -> where am I: - choosing the lod level depanding on the brush size, live rendering is working local data saving is working. - there are some performance issues at very high brush sizes (>1000) - - TODO FOR TOMORROW: implement down/upsampling locally (see diagram below), note we may need a way to trigger redownload on the front from the backend. + - fix the redownload trigger from the backend to the front -> making VoxLayer a true singleton + - implement upscaling and dirty chunks marks - cleanup label handling code (more specifically in the ui code: layer/vox/index.ts, would be nice to have a handler similar to the one for maps) - Add redundancy to avoid corrupt/unsaved chunks on the remote @@ -19,6 +20,11 @@ - adapt the brush size to the zoom level linearly + + + + + # Saving/importing/exporting The ExternalVoxSource will be activated when a zarr:// or precomputed:// link is provided. This will load the data from the remote to display, on edits, the data will still be saved in the local indexedDB. On retrieval of chunks, we must first check the IndexedDB and if not locally present, fetch the remote. An export feature should be added, this will hold the drawing capabilities, retrieve the entire data from the remote and merge it with the local modifications. Then reformat everything to the desired format. diff --git a/src/layer/vox/style.css b/src/layer/vox/style.css index 8c6c497b19..a66aec139f 100644 --- a/src/layer/vox/style.css +++ b/src/layer/vox/style.css @@ -61,6 +61,20 @@ min-width: 0; } +/* Buttons inside rows should not shrink to unreadable widths */ +.neuroglancer-vox-row button { + flex: 0 0 auto; + white-space: nowrap; +} + +/* Status text can occupy a full line to improve readability */ +.neuroglancer-vox-row .neuroglancer-vox-status { + flex: 1 1 100%; + min-width: 100%; + padding-top: 4px; + color: var(--ng-muted); +} + .neuroglancer-vox-input:focus, .neuroglancer-vox-settings-tab select:focus { border-color: color-mix(in oklab, var(--ng-accent) 60%, var(--ng-border)); diff --git a/src/layer/vox/tabs/settings.ts b/src/layer/vox/tabs/settings.ts index f86ac4d928..60b3089f51 100644 --- a/src/layer/vox/tabs/settings.ts +++ b/src/layer/vox/tabs/settings.ts @@ -3,6 +3,7 @@ */ import type { VoxUserLayer } from "#src/layer/vox/index.js"; import { DataType } from "#src/util/data_type.js"; +import { exportVoxToZarr, type ExportStatus } from "#src/voxel_annotation/export_to_zarr.js"; import { LocalVoxSource } from "#src/voxel_annotation/local_source.js"; import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; import { computeSteps } from "#src/voxel_annotation/map.js"; @@ -25,7 +26,7 @@ export class VoxSettingsTab extends Tab { div.appendChild(lab); for (const inp of inputs) { inp.classList.add("neuroglancer-vox-input"); - inp.setAttribute("size", "8"); + // Do not force a tiny size; allow CSS/layout to determine width for readability. div.appendChild(inp); } return div; @@ -207,12 +208,83 @@ export class VoxSettingsTab extends Tab { selectBtn.textContent = "Select Map"; selectBtn.addEventListener("click", () => { const id = mapsSel.value; - const found = this.layer.voxMapRegistry.list().find((m) => m.id === id); + const found = this.layer.voxMapRegistry.list().find((m: VoxMapConfig) => m.id === id); if (found) { this.layer.voxMapRegistry.setCurrent(found); this.layer.buildOrRebuildVoxLayer(); } }); element.appendChild(selectBtn); + + // --- Export to Zarr (LOD 1 only) --- + const exportUrlInput = document.createElement("input"); + exportUrlInput.type = "text"; + exportUrlInput.placeholder = "Export base URL (http(s)://..., s3+http(s)://endpoint/bucket/prefix, or s3://bucket/prefix [AWS]) e.g. http://localhost:9000/zarr/mydataset/"; + exportUrlInput.size = 40; + + const exportButton = document.createElement("button"); + exportButton.textContent = "Export to Zarr"; + exportButton.title = "Exports current map LOD=1 chunks to a Zarr v2 dataset at path '0' under the provided base URL"; + + const exportStatusSpan = document.createElement("span"); + exportStatusSpan.classList.add("neuroglancer-vox-status"); + exportStatusSpan.style.marginLeft = "0"; + + let exportPollTimer: number | undefined = undefined; + + const setExportStatus = (text: string) => { + exportStatusSpan.textContent = text; + }; + + const stopPolling = () => { + if (exportPollTimer !== undefined) { + clearInterval(exportPollTimer); + exportPollTimer = undefined; + } + }; + + exportButton.addEventListener("click", () => { + try { + const map = this.layer.voxMapRegistry.getCurrent(); + if (!map) throw new Error("No active map selected"); + const url = exportUrlInput.value.trim(); + if (url.length === 0) throw new Error("Export URL is required"); + + // Start export and polling + const getProgress = exportVoxToZarr(url, map as VoxMapConfig); + exportButton.disabled = true; + setExportStatus("Starting export..."); + stopPolling(); + exportPollTimer = setInterval(() => { + try { + const status = getProgress() as ExportStatus; + if (status.status === "loading") { + const pct = Math.round((status.progress ?? 0) * 100); + setExportStatus(`Export in progress: ${pct}%`); + } else if (status.status === "done") { + setExportStatus("Export completed"); + exportButton.disabled = false; + stopPolling(); + } else if (status.status === "error") { + setExportStatus(`Export failed: ${status.error}`); + exportButton.disabled = false; + stopPolling(); + } else { + throw new Error("Unknown export status"); + } + } catch (e: any) { + setExportStatus(`Export status error: ${e?.message || String(e)}`); + exportButton.disabled = false; + stopPolling(); + } + }, 500) as unknown as number; + } catch (e: any) { + setExportStatus(`Cannot start export: ${e?.message || String(e)}`); + exportButton.disabled = false; + stopPolling(); + } + }); + + element.appendChild(row("Export to Zarr", [exportUrlInput, exportButton, exportStatusSpan])); } } diff --git a/src/voxel_annotation/export_to_zarr.ts b/src/voxel_annotation/export_to_zarr.ts new file mode 100644 index 0000000000..507f9e5e95 --- /dev/null +++ b/src/voxel_annotation/export_to_zarr.ts @@ -0,0 +1,363 @@ +// TODO: read the whole IndexedDB and write it to a S3 bucket in zarr v2 format without compression and multiscale. the function will take a url and the VoxMapConfig in args and will return a progress function that returns the current progress when called ({status: "loading", progress: 0.5} or {status: "done", progress: 1} or {status: "error", progress: 0, error: "error message"}). We can use the helper of the local_source.ts. The function will be called from the VoxUserLayer on the click of a new export button. A new export url field will also be added to the ui. After the export is started, the ui will display the current progress thanks to the progress function. We should also, before starting the export, descend the entire dirty tree and upscale every dirty node recursively. Once this is done we can simply export every chunks at lod level 1. + +import { DataType } from "#src/util/data_type.js"; +import { parseVoxChunkKey } from "#src/voxel_annotation/base.js"; +import { openVoxDb } from "#src/voxel_annotation/local_source.js"; +import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; + +export type ExportStatus = + | { status: "loading"; progress: number } + | { status: "done"; progress: 1 } + | { status: "error"; progress: 0; error: string }; + +interface NormalizedBaseUrl { + baseUrl: string; // Must end with '/' +} + +/** + * Minimal Zarr v2 single-array exporter for voxel annotations. + * - Writes only LOD=1 chunks present in IndexedDB. + * - No dirty-tree traversal or upscaling is performed (feature under development). + * - No compression and no multiscale hierarchy. + * - Array is created at subpath "0" under the specified base URL. + */ +export function exportVoxToZarr(targetUrl: string, mapConfig: VoxMapConfig): () => ExportStatus { + if (!targetUrl || typeof targetUrl !== "string") { + throw new Error("exportVoxToZarr: targetUrl must be a non-empty string"); + } + if (!mapConfig || typeof mapConfig !== "object") { + throw new Error("exportVoxToZarr: mapConfig is required"); + } + + const progressState: { current: ExportStatus } = { + current: { status: "loading", progress: 0 }, + }; + + const { baseUrl } = normalizeBaseUrl(targetUrl); + + // Start the export asynchronously; return a progress getter immediately. + void (async () => { + try { + const db = await openVoxDb(); + const mapId = String(mapConfig.id); + + // Pre-count LOD=1 chunks for this map in IndexedDB. + const lod1Count = await countLod1Chunks(db, mapId); + + // We will write: root .zgroup, root .zattrs, 0/.zarray, 0/.zattrs, and N chunks. + const metadataFiles = 4; + const totalWrites = metadataFiles + lod1Count; + let writesCompleted = 0; + const updateProgress = () => { + if (totalWrites <= 0) { + progressState.current = { status: "loading", progress: 0 }; + return; + } + progressState.current = { + status: "loading", + progress: Math.max(0, Math.min(1, writesCompleted / totalWrites)), + }; + }; + + // Write minimal Zarr v2 metadata + const { shapeZYX, chunksZYX, dtype } = deriveZarrMetadata(mapConfig); + // Root group + await putJson(joinUrl(baseUrl, ".zgroup"), { zarr_format: 2 }); + writesCompleted++; updateProgress(); + await putJson(joinUrl(baseUrl, ".zattrs"), buildRootZattrs(mapConfig)); + writesCompleted++; updateProgress(); + + // Array at path "0" + const arrayBase = joinUrl(baseUrl, "0/"); + const zarray = { + zarr_format: 2, + shape: shapeZYX, + chunks: chunksZYX, + dtype, + order: "C", + fill_value: 0, + filters: [] as unknown as [], // Explicitly no filters + compressor: null as unknown as null, // Explicitly no compressor + dimension_separator: ".", + }; + await putJson(joinUrl(arrayBase, ".zarray"), zarray); + writesCompleted++; updateProgress(); + await putJson(joinUrl(arrayBase, ".zattrs"), { _ARRAY_DIMENSIONS: ["z", "y", "x"] }); + writesCompleted++; updateProgress(); + + // Stream chunks: single pass to read+upload each LOD=1 chunk. + await iterateLod1Chunks(db, mapId, async ({ x, y, z, value }) => { + // Zarr v2 chunk file name uses axis order; we store array as [Z, Y, X], so name is z.y.x + const chunkRelPath = `0/${z}.${y}.${x}`; + const chunkUrl = joinUrl(baseUrl, chunkRelPath); + // Ensure we upload the exact buffer contents. + const buf = ensureArrayBuffer(value); + await putBinary(chunkUrl, buf); + writesCompleted++; updateProgress(); + }); + + progressState.current = { status: "done", progress: 1 }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + progressState.current = { status: "error", progress: 0, error: message }; + } + })(); + + return () => progressState.current; +} + +/** Normalize supported base URLs to an HTTP(S) base that ends with '/'. */ +function normalizeBaseUrl(url: string): NormalizedBaseUrl { + const trimmed = url.trim(); + // Direct HTTP(S) endpoints, e.g. MinIO: http://localhost:9000/zarr/mydataset/ + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + return { baseUrl: ensureTrailingSlash(trimmed) }; + } + // S3-compatible explicit endpoint, e.g. s3+http://localhost:9000/zarr/mydataset/ + if (trimmed.startsWith("s3+http://")) { + return { baseUrl: ensureTrailingSlash(trimmed.substring("s3+".length)) }; + } + if (trimmed.startsWith("s3+https://")) { + return { baseUrl: ensureTrailingSlash(trimmed.substring("s3+".length)) }; + } + // AWS-style shorthand: s3://bucket/path → https://bucket.s3.amazonaws.com/path + if (trimmed.startsWith("s3://")) { + const rest = trimmed.substring("s3://".length); + const firstSlash = rest.indexOf("/"); + if (firstSlash < 0) { + throw new Error("exportVoxToZarr: s3:// URL must include a path prefix"); + } + const bucket = rest.substring(0, firstSlash); + const keyPrefix = rest.substring(firstSlash + 1); + if (bucket.length === 0) throw new Error("exportVoxToZarr: missing bucket in s3 URL"); + const httpsUrl = `https://${bucket}.s3.amazonaws.com/${keyPrefix}`; + return { baseUrl: ensureTrailingSlash(httpsUrl) }; + } + throw new Error( + `exportVoxToZarr: Unsupported URL scheme; use http(s)://, s3+http(s)://, or s3:// (got: ${url})`, + ); +} + +function ensureTrailingSlash(u: string): string { + return u.endsWith("/") ? u : `${u}/`; +} + +function joinUrl(base: string, path: string): string { + if (!base.endsWith("/")) throw new Error("joinUrl: base must end with '/'"); + if (!path) throw new Error("joinUrl: path must be non-empty"); + if (path.startsWith("/")) path = path.substring(1); + return base + path; +} + +function ensureArrayBuffer(value: any): ArrayBuffer { + if (value instanceof ArrayBuffer) return value; + if (ArrayBuffer.isView(value)) return value.buffer as ArrayBuffer; + throw new Error("Expected ArrayBuffer value from IndexedDB"); +} + +async function putJson(url: string, obj: unknown): Promise { + const json = JSON.stringify(obj); + const bytes = new TextEncoder().encode(json); + await putBinary(url, bytes); +} + +async function putBinary(url: string, data: ArrayBuffer | ArrayBufferView): Promise { + const response = await fetch(url, { + method: "PUT", + headers: { + "Content-Type": inferContentTypeFromPath(url), + }, + body: data, + }); + if (!response.ok) { + throw new Error(`Failed to PUT ${url}: ${response.status} ${response.statusText}`); + } +} + +function inferContentTypeFromPath(url: string): string { + if (url.endsWith(".json") || url.endsWith(".zarray") || url.endsWith(".zattrs") || url.endsWith(".zgroup")) { + return "application/json"; + } + return "application/octet-stream"; +} + +function deriveZarrMetadata(mapCfg: VoxMapConfig): { shapeZYX: number[]; chunksZYX: number[]; dtype: string } { + const lower = mapCfg.baseVoxelOffset; + const upper = mapCfg.upperVoxelBound; + if (!Array.isArray(lower) && !(lower instanceof Float32Array)) { + throw new Error("mapCfg.baseVoxelOffset must be an array-like of length 3"); + } + if (!Array.isArray(upper) && !(upper instanceof Float32Array)) { + throw new Error("mapCfg.upperVoxelBound must be an array-like of length 3"); + } + const bounds = [ + Math.max(0, Math.floor(Number(upper[0]) - Number(lower[0]))), + Math.max(0, Math.floor(Number(upper[1]) - Number(lower[1]))), + Math.max(0, Math.floor(Number(upper[2]) - Number(lower[2]))), + ]; + const cds = mapCfg.chunkDataSize as unknown as ArrayLike; + const chunkXYZ = [ + Math.max(1, Math.floor(Number(cds[0]))), + Math.max(1, Math.floor(Number(cds[1]))), + Math.max(1, Math.floor(Number(cds[2]))), + ]; + // We expose Zarr dims as [Z, Y, X] + const shapeZYX = [bounds[2], bounds[1], bounds[0]]; + const chunksZYX = [chunkXYZ[2], chunkXYZ[1], chunkXYZ[0]]; + const dtype = toZarrDtype(mapCfg.dataType as number); + return { shapeZYX, chunksZYX, dtype }; +} + +function toZarrDtype(dt: number): string { + switch (dt) { + case DataType.UINT32: + return "; + if (scale == null || (scale as any).length < 3) { + throw new Error("Invalid mapCfg.scaleMeters; expected length-3 array"); + } + const omeUnit = toOmeLongUnit(rawUnit); + const mPer = metersPerUnit(rawUnit); + const sx = Number(scale[0]); + const sy = Number(scale[1]); + const sz = Number(scale[2]); + if (!Number.isFinite(sx) || !Number.isFinite(sy) || !Number.isFinite(sz)) { + throw new Error("scaleMeters contains non-finite values"); + } + // coordinateTransformations.scale expects values in the units specified by axes[].unit. + // We therefore convert meter-based voxel sizes to that unit by dividing by meters-per-unit. + const scaleZYX = [sz / mPer, sy / mPer, sx / mPer]; + return { + multiscales: [ + { + version: "0.4", + axes: [ + { name: "z", type: "space", unit: omeUnit }, + { name: "y", type: "space", unit: omeUnit }, + { name: "x", type: "space", unit: omeUnit }, + ], + datasets: [ + { + path: "0", + coordinateTransformations: [ + { type: "scale", scale: scaleZYX }, + ], + }, + ], + }, + ], + } as const; +} + +async function countLod1Chunks(db: IDBDatabase, mapId: string): Promise { + return new Promise((resolve, reject) => { + let count = 0; + const tx = db.transaction("chunks", "readonly"); + const store = tx.objectStore("chunks"); + const req = (store as any).openKeyCursor ? (store as any).openKeyCursor() : (store as any).openCursor(); + req.onerror = () => reject(req.error); + req.onsuccess = (ev: any) => { + const cursor: IDBCursor | IDBCursorWithValue | null = ev.target.result; + if (!cursor) { + resolve(count); + return; + } + const key = String(cursor.key); + const prefix = `${mapId}:`; + if (key.startsWith(prefix)) { + const voxKey = key.substring(prefix.length); + const info = parseVoxChunkKey(voxKey); + if (info && info.lod === 1) { + count++; + } + } + cursor.continue(); + }; + }); +} + +async function iterateLod1Chunks( + db: IDBDatabase, + mapId: string, + onChunk: (args: { x: number; y: number; z: number; value: ArrayBuffer }) => Promise, +): Promise { + const pendingUploads: Promise[] = []; + await new Promise((resolve, reject) => { + const tx = db.transaction("chunks", "readonly"); + const store = tx.objectStore("chunks"); + const req = store.openCursor(); + req.onerror = () => reject(req.error); + req.onsuccess = (ev: any) => { + const cursor: IDBCursorWithValue | null = ev.target.result; + if (!cursor) { + // All matching entries queued; wait for uploads after this promise resolves. + resolve(); + return; + } + try { + const key = String(cursor.key); + const prefix = `${mapId}:`; + if (key.startsWith(prefix)) { + const voxKey = key.substring(prefix.length); + const info = parseVoxChunkKey(voxKey); + if (info && info.lod === 1) { + const value = cursor.value as ArrayBuffer; + // Clone the buffer so we can close the IDB transaction before uploading. + const cloned = value.slice(0); + const uploadPromise = onChunk({ x: info.x, y: info.y, z: info.z, value: cloned }); + pendingUploads.push(uploadPromise); + } + } + // Important: continue the cursor synchronously; do not await before calling continue. + cursor.continue(); + } catch (e) { + reject(e); + } + }; + }); + // Ensure all queued uploads complete and propagate the first error if any. + for (const p of pendingUploads) { + await p; + } +} diff --git a/src/voxel_annotation/local_source.ts b/src/voxel_annotation/local_source.ts index f070ace894..77e9a19dc1 100644 --- a/src/voxel_annotation/local_source.ts +++ b/src/voxel_annotation/local_source.ts @@ -21,6 +21,181 @@ function calculateDownsamplePasses(chunkSize: number) { /** IndexedDB-backed local source. */ export class LocalVoxSource extends VoxSource { + private static readonly DIRTY_STORE = "dirty"; + + private async readChunkFromDbWithoutSideEffects(key: string): Promise { + const existing = this.saved.get(key); + if (existing) return existing; + const db = await this.getDb(); + const composite = this.compositeKey(key); + const buf = await idbGet(db, "chunks", composite); + if (!buf) return undefined; + const arr = new Uint32Array(buf); + const sc: SavedChunk = { + data: arr, + size: new Uint32Array(this.mapCfg!.chunkDataSize as any), + }; + this.saved.set(key, sc); + this.enforceCap(); + return sc; + } + + private async setDirtyTreeFlag(key: string, isDirty: boolean): Promise { + const db = await this.getDb(); + const tx = db.transaction(LocalVoxSource.DIRTY_STORE, "readwrite"); + const store = tx.objectStore(LocalVoxSource.DIRTY_STORE); + const composite = this.compositeKey(key); + await idbPut(store, isDirty ? 1 : 0, composite); + await txDone(tx); + } + + private async getDirtyTreeValue(key: string): Promise<0 | 1 | undefined> { + const db = await this.getDb(); + const composite = this.compositeKey(key); + const v = await idbGet(db, LocalVoxSource.DIRTY_STORE, composite); + if (v === undefined) return undefined; + if (v !== 0 && v !== 1) throw new Error(`Invalid dirty-tree value for ${key}: ${String(v)}`); + return v as 0 | 1; + } + + private parentKeyOf(childKey: string): string | null { + const info = parseVoxChunkKey(childKey); + if (!info) return null; + const parentLod = info.lod * 2; + const maxLOD = this.mapCfg!.steps[this.mapCfg!.steps.length - 1]; + if (parentLod > maxLOD) return null; + const px = Math.floor(info.x / 2); + const py = Math.floor(info.y / 2); + const pz = Math.floor(info.z / 2); + return makeVoxChunkKey(`${px},${py},${pz}`, parentLod); + } + + private childKeysOf(parentKey: string): string[] { + const info = parseVoxChunkKey(parentKey); + if (!info) throw new Error(`Invalid voxel chunk key: ${parentKey}`); + const childLod = info.lod / 2; + if (childLod < 1) return []; + const baseX = info.x * 2; + const baseY = info.y * 2; + const baseZ = info.z * 2; + const out: string[] = []; + for (let dz = 0; dz < 2; dz++) { + for (let dy = 0; dy < 2; dy++) { + for (let dx = 0; dx < 2; dx++) { + out.push(makeVoxChunkKey(`${baseX + dx},${baseY + dy},${baseZ + dz}`, childLod)); + } + } + } + return out; + } + + private async markChildrenDirtyInTree(parentKey: string): Promise { + const children = this.childKeysOf(parentKey); + if (children.length === 0) return; + const db = await this.getDb(); + const tx = db.transaction(LocalVoxSource.DIRTY_STORE, "readwrite"); + const store = tx.objectStore(LocalVoxSource.DIRTY_STORE); + for (const ck of children) { + await idbPut(store, 1, this.compositeKey(ck)); + } + await txDone(tx); + } + + private async ensureUpscaledPathTo(targetKey: string): Promise { + // Find nearest CLEAN ancestor using the dirty-tree only, then descend regenerating dirty/missing nodes. + const parsedTarget = parseVoxChunkKey(targetKey); + if (!parsedTarget) throw new Error(`ensureUpscaledPathTo: invalid target key: ${targetKey}`); + const maxLOD = this.mapCfg!.steps[this.mapCfg!.steps.length - 1]; + + // Build ancestor chain from target up to the root (inclusive) + const ancestors: string[] = [targetKey]; + while (true) { + const last = ancestors[ancestors.length - 1]; + const p = this.parentKeyOf(last); + if (!p) break; + ancestors.push(p); + const pInfo = parseVoxChunkKey(p)!; + if (pInfo.lod === maxLOD) break; + } + + // Find the nearest ancestor that has a dirty entry and is CLEAN (0) + let cleanAncestorIndex = -1; + for (let i = ancestors.length - 1; i >= 0; i--) { + const k = ancestors[i]; + const v = await this.getDirtyTreeValue(k); + if (v === 0) { + cleanAncestorIndex = i; + break; + } + } + if (cleanAncestorIndex === -1) { + // No clean ancestor registered in tree -> nothing to upscale from. + return; + } + if (cleanAncestorIndex === 0) { + // Target itself is already clean according to the tree -> nothing to do. + return; + } + + // Descend from that ancestor down to the target + for (let i = cleanAncestorIndex - 1; i >= 0; i--) { + const childKey = ancestors[i]; + const parentKey = ancestors[i + 1]; + + // A clean ancestor must exist physically. Enforce invariant strictly. + const parentChunk = await this.readChunkFromDbWithoutSideEffects(parentKey); + if (!parentChunk) throw new Error(`Missing parent chunk for clean node during upscaling: ${parentKey}`); + + const childDirtyVal = await this.getDirtyTreeValue(childKey); + const needsRegeneration = childDirtyVal === 1 || childDirtyVal === undefined; + if (needsRegeneration) { + await this.upscaleFromParentIntoChild(parentChunk, parentKey, childKey); + await this.setDirtyTreeFlag(childKey, false); + await this.markChildrenDirtyInTree(childKey); + } + } + } + + private async upscaleFromParentIntoChild(parentChunk: SavedChunk, parentKey: string, childKey: string): Promise { + const pInfo = parseVoxChunkKey(parentKey); + const cInfo = parseVoxChunkKey(childKey); + if (!pInfo || !cInfo) throw new Error("Invalid parent/child keys for upscaling"); + if (cInfo.lod !== pInfo.lod / 2) throw new Error("Upscale expects child lod to be half of parent lod"); + + const childSize = new Uint32Array(this.mapCfg!.chunkDataSize as any); + const total = (childSize[0] | 0) * (childSize[1] | 0) * (childSize[2] | 0); + let child = this.saved.get(childKey); + if (!child) { + child = { data: new Uint32Array(total), size: childSize }; + this.saved.set(childKey, child); + this.enforceCap(); + } + + const [cw, ch, cd] = child.size; + const [pw, ph] = parentChunk.size; + const subW = pw / 2; + const subH = ph / 2; + const subD = parentChunk.size[2] / 2; + const offX = (cInfo.x % 2) * subW; + const offY = (cInfo.y % 2) * subH; + const offZ = (cInfo.z % 2) * subD; + + for (let z = 0; z < cd; z++) { + const pz = Math.floor(z / 2) + offZ; + for (let y = 0; y < ch; y++) { + const py = Math.floor(y / 2) + offY; + for (let x = 0; x < cw; x++) { + const px = Math.floor(x / 2) + offX; + const pIndex = (pz | 0) * pw * ph + (py | 0) * pw + (px | 0); + const cIndex = z * cw * ch + y * cw + x; + (child.data as Uint32Array)[cIndex] = (parentChunk.data as Uint32Array)[pIndex]; + } + } + } + + this.saved.set(childKey, child); + this.markDirty(childKey); + } override async listMaps(): Promise { try { const db = await this.getDb(); @@ -165,26 +340,34 @@ export class LocalVoxSource extends VoxSource { } async getSavedChunk(key: string): Promise { - const existing = this.saved.get(key); - if (existing) { - this.touch(key); - return existing; - } - const db = await this.getDb(); - const composite = this.compositeKey(key); - const buf = await idbGet(db, "chunks", composite); - if (buf) { - const arr = new Uint32Array(buf); - const sc: SavedChunk = { - data: arr, - size: new Uint32Array(this.mapCfg!.chunkDataSize as any), - }; - this.saved.set(key, sc); - this.enforceCap(); - return sc; + // Before returning, ensure any pending upscales are realized for this key. + if (this.mapCfg) { + try { + await this.ensureUpscaledPathTo(key); + } catch (e) { + console.error("ensureUpscaledPathTo failed", e); + } + } + const existing = this.saved.get(key); + if (existing) { + this.touch(key); + return existing; + } + const db = await this.getDb(); + const composite = this.compositeKey(key); + const buf = await idbGet(db, "chunks", composite); + if (buf) { + const arr = new Uint32Array(buf); + const sc: SavedChunk = { + data: arr, + size: new Uint32Array(this.mapCfg!.chunkDataSize as any), + }; + this.saved.set(key, sc); + this.enforceCap(); + return sc; + } + return undefined; } - return undefined; - } async ensureChunk( key: string, @@ -195,6 +378,14 @@ export class LocalVoxSource extends VoxSource { this.touch(key); return sc; } + // Attempt to satisfy this chunk by performing any pending upscales along its path. + if (this.mapCfg) { + try { + await this.ensureUpscaledPathTo(key); + } catch (e) { + console.error("ensureUpscaledPathTo failed in ensureChunk", e); + } + } const db = await this.getDb(); const composite = this.compositeKey(key); const buf = await idbGet(db, "chunks", composite); @@ -234,6 +425,8 @@ export class LocalVoxSource extends VoxSource { ); this.applyEditsIntoChunk(sc, e.indices, e.value, e.values); this.markDirty(e.key); + await this.setDirtyTreeFlag(e.key, false); + await this.markChildrenDirtyInTree(e.key); touchedKeys.add(e.key); } for (const key of touchedKeys) { @@ -320,6 +513,8 @@ export class LocalVoxSource extends VoxSource { this.saved.set(targetKey, targetChunk); this.markDirty(targetKey); + await this.setDirtyTreeFlag(targetKey, false); + await this.markChildrenDirtyInTree(targetKey); return targetKey; } @@ -389,15 +584,35 @@ export class LocalVoxSource extends VoxSource { export function openVoxDb(): Promise { return new Promise((resolve, reject) => { - const req = indexedDB.open("neuroglancer_vox", 2); + const req = indexedDB.open("neuroglancer_vox", 3); req.onerror = () => reject(req.error); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains("maps")) db.createObjectStore("maps"); - if (!db.objectStoreNames.contains("chunks")) - db.createObjectStore("chunks"); - if (!db.objectStoreNames.contains("labels")) - db.createObjectStore("labels"); + if (!db.objectStoreNames.contains("chunks")) db.createObjectStore("chunks"); + if (!db.objectStoreNames.contains("labels")) db.createObjectStore("labels"); + + // Ensure dirty store exists + let dirtyStore: IDBObjectStore; + if (!db.objectStoreNames.contains("dirty")) { + dirtyStore = db.createObjectStore("dirty"); + } else { + dirtyStore = (req.transaction as IDBTransaction).objectStore("dirty"); + } + + // Backfill: for every key in chunks, write a clean (0) entry in dirty store. + const tx = req.transaction as IDBTransaction; + if (Array.from(db.objectStoreNames).includes("chunks")) { + const chunksStore = tx.objectStore("chunks"); + const cursorReq = (chunksStore as any).openKeyCursor(); + cursorReq.onsuccess = () => { + const cursor: IDBCursor | null = cursorReq.result as IDBCursor | null; + if (cursor) { + dirtyStore.put(0, cursor.key); + cursor.continue(); + } + }; + } }; req.onsuccess = () => resolve(req.result); }); From 8cc92dca9863b81910645efd2c51872560acc561 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 038/251] feat: dirty tree upscaling is kinda working, at least enough to conclude that this upscaling method wont work due to unsolvable conficts and lost unavoidable lost of quality due to upscaling of downscaled strokes. A new approach will be to enqueue every upscale and downscale and throttle the user when the queue is too full, with some kind of indicator in the ui. We also may need to restrict the max brush size to avoid too long waiting time. --- NOTES/TODOs.md | 2 ++ src/voxel_annotation/index.ts | 2 +- src/voxel_annotation/local_source.ts | 25 +++++++++++-------------- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index 508ac1a2d9..195fdf37c3 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,5 +1,7 @@ # TODO List +- FOR TOMORROW: fix dirty tree upscaling and integrate it into the zarr export feature. + - LOD -> where am I: - choosing the lod level depanding on the brush size, live rendering is working local data saving is working. - there are some performance issues at very high brush sizes (>1000) diff --git a/src/voxel_annotation/index.ts b/src/voxel_annotation/index.ts index 5cd421e54d..ffcaab9f25 100644 --- a/src/voxel_annotation/index.ts +++ b/src/voxel_annotation/index.ts @@ -36,7 +36,7 @@ export abstract class VoxSource { protected mapCfg: VoxMapConfig; // Keep the entire configuration in one place // In-memory cache of loaded chunks - protected maxSavedChunks = 0; // cap to prevent unbounded growth + protected maxSavedChunks = 256; // cap to prevent unbounded growth protected saved = new Map(); // Dirty tracking and debounced save diff --git a/src/voxel_annotation/local_source.ts b/src/voxel_annotation/local_source.ts index 77e9a19dc1..e3936b9c4e 100644 --- a/src/voxel_annotation/local_source.ts +++ b/src/voxel_annotation/local_source.ts @@ -118,24 +118,24 @@ export class LocalVoxSource extends VoxSource { if (pInfo.lod === maxLOD) break; } - // Find the nearest ancestor that has a dirty entry and is CLEAN (0) + // Find the nearest clean ancestor after a dirty one let cleanAncestorIndex = -1; - for (let i = ancestors.length - 1; i >= 0; i--) { + let hasDirt = false; + for (let i = 0; i < ancestors.length; i++) { const k = ancestors[i]; const v = await this.getDirtyTreeValue(k); - if (v === 0) { + if (v === 0 && hasDirt) { cleanAncestorIndex = i; break; } + if (v === 1){ + hasDirt = true; + } } if (cleanAncestorIndex === -1) { // No clean ancestor registered in tree -> nothing to upscale from. return; } - if (cleanAncestorIndex === 0) { - // Target itself is already clean according to the tree -> nothing to do. - return; - } // Descend from that ancestor down to the target for (let i = cleanAncestorIndex - 1; i >= 0; i--) { @@ -144,7 +144,7 @@ export class LocalVoxSource extends VoxSource { // A clean ancestor must exist physically. Enforce invariant strictly. const parentChunk = await this.readChunkFromDbWithoutSideEffects(parentKey); - if (!parentChunk) throw new Error(`Missing parent chunk for clean node during upscaling: ${parentKey}`); + if (!parentChunk) throw new Error(`Missing parent chunk for clean node during upscaling: ${parentKey} ${ancestors}`); const childDirtyVal = await this.getDirtyTreeValue(childKey); const needsRegeneration = childDirtyVal === 1 || childDirtyVal === undefined; @@ -152,6 +152,7 @@ export class LocalVoxSource extends VoxSource { await this.upscaleFromParentIntoChild(parentChunk, parentKey, childKey); await this.setDirtyTreeFlag(childKey, false); await this.markChildrenDirtyInTree(childKey); + console.log(`Upscaled ${childKey} from ${parentKey}`); } } } @@ -477,11 +478,8 @@ export class LocalVoxSource extends VoxSource { const sourceChunk = await this.getSavedChunk(sourceKey); if (!sourceChunk) return null; // Cannot downsample if source doesn't exist. - const targetLOD = sourceKeyInfo.lod * 2; - const targetX = Math.floor(sourceKeyInfo.x / 2); - const targetY = Math.floor(sourceKeyInfo.y / 2); - const targetZ = Math.floor(sourceKeyInfo.z / 2); - const targetKey = makeVoxChunkKey(`${targetX},${targetY},${targetZ}`, targetLOD); + const targetKey = this.parentKeyOf(sourceKey); + if (!targetKey) return null; const targetChunk = await this.ensureChunk(targetKey); @@ -514,7 +512,6 @@ export class LocalVoxSource extends VoxSource { this.saved.set(targetKey, targetChunk); this.markDirty(targetKey); await this.setDirtyTreeFlag(targetKey, false); - await this.markChildrenDirtyInTree(targetKey); return targetKey; } From ba6ae209daf85a0699b1be9c48acae4c2a14f334 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 039/251] feat: restrict brush size and disable dirty tree upscaling - Reduce brush size range to a maximum of 64 and adjust logic to match. - Halt dirty tree upscaling due to unresolved conflicts and quality issues. - Improve debounce logic for edit commits to avoid overlapping timers. - Clean up unnecessary logging in backend and frontend layers. - Add optional LOD restriction in edit calculations for enhanced stability. --- NOTES/TODOs.md | 10 +- src/layer/vox/tabs/tools.ts | 4 +- src/voxel_annotation/backend.ts | 15 +- src/voxel_annotation/edit_controller.ts | 3 + src/voxel_annotation/frontend.ts | 1 - src/voxel_annotation/index.ts | 4 - src/voxel_annotation/local_source.ts | 323 ++++++++++++------------ 7 files changed, 188 insertions(+), 172 deletions(-) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index 195fdf37c3..13984f12d1 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,12 +1,18 @@ # TODO List -- FOR TOMORROW: fix dirty tree upscaling and integrate it into the zarr export feature. - LOD -> where am I: - choosing the lod level depanding on the brush size, live rendering is working local data saving is working. - there are some performance issues at very high brush sizes (>1000) - fix the redownload trigger from the backend to the front -> making VoxLayer a true singleton - - implement upscaling and dirty chunks marks + - "feat: dirty tree upscaling is kinda working, at lea + st enough to conclude that this upscaling method wont work + due to unsolvable conficts and lost unavoidable lost of qua + lity due to upscaling of downscaled strokes. A new approach + will be to enqueue every upscale and downscale and throttl + e the user when the queue is too full, with some kind of in + dicator in the ui. We also may need to restrict the max bru + sh size to avoid too long waiting time." - cleanup label handling code (more specifically in the ui code: layer/vox/index.ts, would be nice to have a handler similar to the one for maps) - Add redundancy to avoid corrupt/unsaved chunks on the remote diff --git a/src/layer/vox/tabs/tools.ts b/src/layer/vox/tabs/tools.ts index 8bb44ec378..16df9c0abe 100644 --- a/src/layer/vox/tabs/tools.ts +++ b/src/layer/vox/tabs/tools.ts @@ -108,7 +108,7 @@ export class VoxToolTab extends Tab { const sizeSlider = document.createElement("input"); sizeSlider.type = "range"; sizeSlider.min = "1"; - sizeSlider.max = "4242"; + sizeSlider.max = "64"; sizeSlider.step = "1"; sizeSlider.value = String(this.layer.voxBrushRadius ?? 3); @@ -120,7 +120,7 @@ export class VoxToolTab extends Tab { sizeNumber.value = String(this.layer.voxBrushRadius ?? 3); const syncSize = (v: number) => { - const clamped = Math.max(1, Math.min(8192, Math.floor(v))); + const clamped = Math.max(1, Math.min(128, Math.floor(v))); this.layer.voxBrushRadius = clamped; sizeSlider.value = String(clamped); sizeNumber.value = String(clamped); diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 801e087228..0e5d301963 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -76,7 +76,7 @@ export class VoxChunkSource extends BaseVolumeChunkSource { size?: number[]; }[] = []; private commitDebounceTimer: number | undefined; - private readonly commitDebounceDelayMs: number = 75; + private readonly commitDebounceDelayMs: number = 200; constructor(rpc: RPC, options: any) { super(rpc, options); @@ -122,9 +122,6 @@ export class VoxChunkSource extends BaseVolumeChunkSource { } reloadChunksByKey(keys: string[]) { - console.log("sending RPC call") - - this.rpc?.invoke(VOX_RELOAD_CHUNKS_RPC_ID, { id: this.rpcId, keys, @@ -139,6 +136,7 @@ export class VoxChunkSource extends BaseVolumeChunkSource { this.pendingCommitEdits = []; this.commitDebounceTimer = undefined; if (pending.length === 0) return; + console.log("####################################################################################\n flushPendingCommitEdits: applying edits", pending) await src.applyEdits(pending); } @@ -161,11 +159,12 @@ export class VoxChunkSource extends BaseVolumeChunkSource { } this.pendingCommitEdits.push(e); } - if (this.commitDebounceTimer === undefined) { - this.commitDebounceTimer = setTimeout(() => { - void this.flushPendingCommitEdits(); - }, this.commitDebounceDelayMs) as unknown as number; + if (this.commitDebounceTimer !== undefined) { + clearTimeout(this.commitDebounceTimer); } + this.commitDebounceTimer = setTimeout(() => { + void this.flushPendingCommitEdits(); + }, this.commitDebounceDelayMs) as unknown as number; } async getLabelIds(): Promise { diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index db224406dd..81b3eaf740 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -9,9 +9,11 @@ import type { VoxChunkSource } from "#src/voxel_annotation/frontend.js"; export class VoxelEditController { constructor(private multiscale: MultiscaleVolumeChunkSource) {} private static readonly qualityFactor = 16.0; + private static readonly restrictToMinLOD = true; // Required: compute desired voxel size (power-of-two) from brush radius. getOptimalVoxelSize(brushRadius: number, minLOD = 1, maxLOD = 128) { + if (VoxelEditController.restrictToMinLOD) {return minLOD;} if (!Number.isFinite(brushRadius) || brushRadius <= 0) { return minLOD; } @@ -24,6 +26,7 @@ export class VoxelEditController { /** Compute the edit LOD index (scale index) from a brush radius in canonical units. */ getEditLodIndexForBrush(brushRadiusCanonical: number): number { + if (VoxelEditController.restrictToMinLOD) {return 0;} if (!Number.isFinite(brushRadiusCanonical) || brushRadiusCanonical <= 0) { throw new Error("getEditLodIndexForBrush: brushRadiusCanonical must be > 0"); } diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 69a7d325f2..ee7bee255d 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -263,6 +263,5 @@ export class VoxChunkSource extends BaseVolumeChunkSource { registerRPC(VOX_RELOAD_CHUNKS_RPC_ID, function (x: any) { const obj = this.get(x.id) as VoxChunkSource; - console.log("received RPC call") obj.invalidateChunksByKey(x.keys); }); diff --git a/src/voxel_annotation/index.ts b/src/voxel_annotation/index.ts index ffcaab9f25..e5fd9e5319 100644 --- a/src/voxel_annotation/index.ts +++ b/src/voxel_annotation/index.ts @@ -71,17 +71,13 @@ export abstract class VoxSource { } callChunkReload(voxChunkKey: string) { - console.log("VoxSource: callChunkReload: ", voxChunkKey); const parsed_vck = parseVoxChunkKey(voxChunkKey); - console.log("parsed_vck: ", parsed_vck); if (!parsed_vck) { console.error("VoxSource: callChunkReload: invalid chunk key", voxChunkKey); return; } const vcs = this.voxChunkSources.get(parsed_vck.lod); - console.log("vcs: ", this.voxChunkSources); if (vcs) { - console.log("invoking reloadChunksByKey for ", voxChunkKey); vcs.reloadChunksByKey([parsed_vck.chunkKey]); } } diff --git a/src/voxel_annotation/local_source.ts b/src/voxel_annotation/local_source.ts index e3936b9c4e..dc61575b72 100644 --- a/src/voxel_annotation/local_source.ts +++ b/src/voxel_annotation/local_source.ts @@ -21,42 +21,45 @@ function calculateDownsamplePasses(chunkSize: number) { /** IndexedDB-backed local source. */ export class LocalVoxSource extends VoxSource { - private static readonly DIRTY_STORE = "dirty"; - private async readChunkFromDbWithoutSideEffects(key: string): Promise { - const existing = this.saved.get(key); - if (existing) return existing; - const db = await this.getDb(); - const composite = this.compositeKey(key); - const buf = await idbGet(db, "chunks", composite); - if (!buf) return undefined; - const arr = new Uint32Array(buf); - const sc: SavedChunk = { - data: arr, - size: new Uint32Array(this.mapCfg!.chunkDataSize as any), - }; - this.saved.set(key, sc); - this.enforceCap(); - return sc; - } + // Upscaling halted + /* + private static readonly DIRTY_STORE = "dirty"; - private async setDirtyTreeFlag(key: string, isDirty: boolean): Promise { - const db = await this.getDb(); - const tx = db.transaction(LocalVoxSource.DIRTY_STORE, "readwrite"); - const store = tx.objectStore(LocalVoxSource.DIRTY_STORE); - const composite = this.compositeKey(key); - await idbPut(store, isDirty ? 1 : 0, composite); - await txDone(tx); - } - - private async getDirtyTreeValue(key: string): Promise<0 | 1 | undefined> { - const db = await this.getDb(); - const composite = this.compositeKey(key); - const v = await idbGet(db, LocalVoxSource.DIRTY_STORE, composite); - if (v === undefined) return undefined; - if (v !== 0 && v !== 1) throw new Error(`Invalid dirty-tree value for ${key}: ${String(v)}`); - return v as 0 | 1; - } + private async readChunkFromDbWithoutSideEffects(key: string): Promise { + const existing = this.saved.get(key); + if (existing) return existing; + const db = await this.getDb(); + const composite = this.compositeKey(key); + const buf = await idbGet(db, "chunks", composite); + if (!buf) return undefined; + const arr = new Uint32Array(buf); + const sc: SavedChunk = { + data: arr, + size: new Uint32Array(this.mapCfg!.chunkDataSize as any), + }; + this.saved.set(key, sc); + this.enforceCap(); + return sc; + } + + private async setDirtyTreeFlag(key: string, isDirty: boolean): Promise { + const db = await this.getDb(); + const tx = db.transaction(LocalVoxSource.DIRTY_STORE, "readwrite"); + const store = tx.objectStore(LocalVoxSource.DIRTY_STORE); + const composite = this.compositeKey(key); + await idbPut(store, isDirty ? 1 : 0, composite); + await txDone(tx); + } + + private async getDirtyTreeValue(key: string): Promise<0 | 1 | undefined> { + const db = await this.getDb(); + const composite = this.compositeKey(key); + const v = await idbGet(db, LocalVoxSource.DIRTY_STORE, composite); + if (v === undefined) return undefined; + if (v !== 0 && v !== 1) throw new Error(`Invalid dirty-tree value for ${key}: ${String(v)}`); + return v as 0 | 1; + }*/ private parentKeyOf(childKey: string): string | null { const info = parseVoxChunkKey(childKey); @@ -70,133 +73,137 @@ export class LocalVoxSource extends VoxSource { return makeVoxChunkKey(`${px},${py},${pz}`, parentLod); } - private childKeysOf(parentKey: string): string[] { - const info = parseVoxChunkKey(parentKey); - if (!info) throw new Error(`Invalid voxel chunk key: ${parentKey}`); - const childLod = info.lod / 2; - if (childLod < 1) return []; - const baseX = info.x * 2; - const baseY = info.y * 2; - const baseZ = info.z * 2; - const out: string[] = []; - for (let dz = 0; dz < 2; dz++) { - for (let dy = 0; dy < 2; dy++) { - for (let dx = 0; dx < 2; dx++) { - out.push(makeVoxChunkKey(`${baseX + dx},${baseY + dy},${baseZ + dz}`, childLod)); + // Upscaling halted + /* + private childKeysOf(parentKey: string): string[] { + const info = parseVoxChunkKey(parentKey); + if (!info) throw new Error(`Invalid voxel chunk key: ${parentKey}`); + const childLod = info.lod / 2; + if (childLod < 1) return []; + const baseX = info.x * 2; + const baseY = info.y * 2; + const baseZ = info.z * 2; + const out: string[] = []; + for (let dz = 0; dz < 2; dz++) { + for (let dy = 0; dy < 2; dy++) { + for (let dx = 0; dx < 2; dx++) { + out.push(makeVoxChunkKey(`${baseX + dx},${baseY + dy},${baseZ + dz}`, childLod)); + } } } + return out; } - return out; - } - private async markChildrenDirtyInTree(parentKey: string): Promise { - const children = this.childKeysOf(parentKey); - if (children.length === 0) return; - const db = await this.getDb(); - const tx = db.transaction(LocalVoxSource.DIRTY_STORE, "readwrite"); - const store = tx.objectStore(LocalVoxSource.DIRTY_STORE); - for (const ck of children) { - await idbPut(store, 1, this.compositeKey(ck)); + private async markChildrenDirtyInTree(parentKey: string): Promise { + const children = this.childKeysOf(parentKey); + if (children.length === 0) return; + const db = await this.getDb(); + const tx = db.transaction(LocalVoxSource.DIRTY_STORE, "readwrite"); + const store = tx.objectStore(LocalVoxSource.DIRTY_STORE); + for (const ck of children) { + await idbPut(store, 1, this.compositeKey(ck)); + } + await txDone(tx); } - await txDone(tx); - } - private async ensureUpscaledPathTo(targetKey: string): Promise { - // Find nearest CLEAN ancestor using the dirty-tree only, then descend regenerating dirty/missing nodes. - const parsedTarget = parseVoxChunkKey(targetKey); - if (!parsedTarget) throw new Error(`ensureUpscaledPathTo: invalid target key: ${targetKey}`); - const maxLOD = this.mapCfg!.steps[this.mapCfg!.steps.length - 1]; - - // Build ancestor chain from target up to the root (inclusive) - const ancestors: string[] = [targetKey]; - while (true) { - const last = ancestors[ancestors.length - 1]; - const p = this.parentKeyOf(last); - if (!p) break; - ancestors.push(p); - const pInfo = parseVoxChunkKey(p)!; - if (pInfo.lod === maxLOD) break; - } + private async ensureUpscaledPathTo(targetKey: string): Promise { + // Find nearest CLEAN ancestor using the dirty-tree only, then descend regenerating dirty/missing nodes. + const parsedTarget = parseVoxChunkKey(targetKey); + if (!parsedTarget) throw new Error(`ensureUpscaledPathTo: invalid target key: ${targetKey}`); + const maxLOD = this.mapCfg!.steps[this.mapCfg!.steps.length - 1]; + + // Build ancestor chain from target up to the root (inclusive) + const ancestors: string[] = [targetKey]; + while (true) { + const last = ancestors[ancestors.length - 1]; + const p = this.parentKeyOf(last); + if (!p) break; + ancestors.push(p); + const pInfo = parseVoxChunkKey(p)!; + if (pInfo.lod === maxLOD) break; + } - // Find the nearest clean ancestor after a dirty one - let cleanAncestorIndex = -1; - let hasDirt = false; - for (let i = 0; i < ancestors.length; i++) { - const k = ancestors[i]; - const v = await this.getDirtyTreeValue(k); - if (v === 0 && hasDirt) { - cleanAncestorIndex = i; - break; + // Find the nearest clean ancestor after a dirty one + let cleanAncestorIndex = -1; + let hasDirt = false; + for (let i = 0; i < ancestors.length; i++) { + const k = ancestors[i]; + const v = await this.getDirtyTreeValue(k); + if (v === 0 && hasDirt) { + cleanAncestorIndex = i; + break; + } + if (v === 1){ + hasDirt = true; + } } - if (v === 1){ - hasDirt = true; + if (cleanAncestorIndex === -1) { + // No clean ancestor registered in tree -> nothing to upscale from. + return; } - } - if (cleanAncestorIndex === -1) { - // No clean ancestor registered in tree -> nothing to upscale from. - return; - } - // Descend from that ancestor down to the target - for (let i = cleanAncestorIndex - 1; i >= 0; i--) { - const childKey = ancestors[i]; - const parentKey = ancestors[i + 1]; - - // A clean ancestor must exist physically. Enforce invariant strictly. - const parentChunk = await this.readChunkFromDbWithoutSideEffects(parentKey); - if (!parentChunk) throw new Error(`Missing parent chunk for clean node during upscaling: ${parentKey} ${ancestors}`); - - const childDirtyVal = await this.getDirtyTreeValue(childKey); - const needsRegeneration = childDirtyVal === 1 || childDirtyVal === undefined; - if (needsRegeneration) { - await this.upscaleFromParentIntoChild(parentChunk, parentKey, childKey); - await this.setDirtyTreeFlag(childKey, false); - await this.markChildrenDirtyInTree(childKey); - console.log(`Upscaled ${childKey} from ${parentKey}`); + // Descend from that ancestor down to the target + for (let i = cleanAncestorIndex - 1; i >= 0; i--) { + const childKey = ancestors[i]; + const parentKey = ancestors[i + 1]; + + // A clean ancestor must exist physically. Enforce invariant strictly. + const parentChunk = await this.readChunkFromDbWithoutSideEffects(parentKey); + if (!parentChunk) throw new Error(`Missing parent chunk for clean node during upscaling: ${parentKey} ${ancestors}`); + + const childDirtyVal = await this.getDirtyTreeValue(childKey); + const needsRegeneration = childDirtyVal === 1 || childDirtyVal === undefined; + if (needsRegeneration) { + await this.upscaleFromParentIntoChild(parentChunk, parentKey, childKey); + await this.setDirtyTreeFlag(childKey, false); + await this.markChildrenDirtyInTree(childKey); + console.log(`Upscaled ${childKey} from ${parentKey}`); + } } } - } - private async upscaleFromParentIntoChild(parentChunk: SavedChunk, parentKey: string, childKey: string): Promise { - const pInfo = parseVoxChunkKey(parentKey); - const cInfo = parseVoxChunkKey(childKey); - if (!pInfo || !cInfo) throw new Error("Invalid parent/child keys for upscaling"); - if (cInfo.lod !== pInfo.lod / 2) throw new Error("Upscale expects child lod to be half of parent lod"); - - const childSize = new Uint32Array(this.mapCfg!.chunkDataSize as any); - const total = (childSize[0] | 0) * (childSize[1] | 0) * (childSize[2] | 0); - let child = this.saved.get(childKey); - if (!child) { - child = { data: new Uint32Array(total), size: childSize }; - this.saved.set(childKey, child); - this.enforceCap(); - } + private async upscaleFromParentIntoChild(parentChunk: SavedChunk, parentKey: string, childKey: string): Promise { + const pInfo = parseVoxChunkKey(parentKey); + const cInfo = parseVoxChunkKey(childKey); + if (!pInfo || !cInfo) throw new Error("Invalid parent/child keys for upscaling"); + if (cInfo.lod !== pInfo.lod / 2) throw new Error("Upscale expects child lod to be half of parent lod"); + + const childSize = new Uint32Array(this.mapCfg!.chunkDataSize as any); + const total = (childSize[0] | 0) * (childSize[1] | 0) * (childSize[2] | 0); + let child = this.saved.get(childKey); + if (!child) { + child = { data: new Uint32Array(total), size: childSize }; + this.saved.set(childKey, child); + this.enforceCap(); + } - const [cw, ch, cd] = child.size; - const [pw, ph] = parentChunk.size; - const subW = pw / 2; - const subH = ph / 2; - const subD = parentChunk.size[2] / 2; - const offX = (cInfo.x % 2) * subW; - const offY = (cInfo.y % 2) * subH; - const offZ = (cInfo.z % 2) * subD; - - for (let z = 0; z < cd; z++) { - const pz = Math.floor(z / 2) + offZ; - for (let y = 0; y < ch; y++) { - const py = Math.floor(y / 2) + offY; - for (let x = 0; x < cw; x++) { - const px = Math.floor(x / 2) + offX; - const pIndex = (pz | 0) * pw * ph + (py | 0) * pw + (px | 0); - const cIndex = z * cw * ch + y * cw + x; - (child.data as Uint32Array)[cIndex] = (parentChunk.data as Uint32Array)[pIndex]; + const [cw, ch, cd] = child.size; + const [pw, ph] = parentChunk.size; + const subW = pw / 2; + const subH = ph / 2; + const subD = parentChunk.size[2] / 2; + const offX = (cInfo.x % 2) * subW; + const offY = (cInfo.y % 2) * subH; + const offZ = (cInfo.z % 2) * subD; + + for (let z = 0; z < cd; z++) { + const pz = Math.floor(z / 2) + offZ; + for (let y = 0; y < ch; y++) { + const py = Math.floor(y / 2) + offY; + for (let x = 0; x < cw; x++) { + const px = Math.floor(x / 2) + offX; + const pIndex = (pz | 0) * pw * ph + (py | 0) * pw + (px | 0); + const cIndex = z * cw * ch + y * cw + x; + (child.data as Uint32Array)[cIndex] = (parentChunk.data as Uint32Array)[pIndex]; + } } } + + this.saved.set(childKey, child); + this.markDirty(childKey); } + */ - this.saved.set(childKey, child); - this.markDirty(childKey); - } override async listMaps(): Promise { try { const db = await this.getDb(); @@ -342,13 +349,14 @@ export class LocalVoxSource extends VoxSource { async getSavedChunk(key: string): Promise { // Before returning, ensure any pending upscales are realized for this key. - if (this.mapCfg) { - try { - await this.ensureUpscaledPathTo(key); - } catch (e) { - console.error("ensureUpscaledPathTo failed", e); - } + // Upscaling halted + /*if (this.mapCfg) { + try { + await this.ensureUpscaledPathTo(key); + } catch (e) { + console.error("ensureUpscaledPathTo failed", e); } + }*/ const existing = this.saved.get(key); if (existing) { this.touch(key); @@ -380,13 +388,15 @@ export class LocalVoxSource extends VoxSource { return sc; } // Attempt to satisfy this chunk by performing any pending upscales along its path. + // Upscaling halted + /* if (this.mapCfg) { try { await this.ensureUpscaledPathTo(key); } catch (e) { console.error("ensureUpscaledPathTo failed in ensureChunk", e); } - } + }*/ const db = await this.getDb(); const composite = this.compositeKey(key); const buf = await idbGet(db, "chunks", composite); @@ -426,8 +436,9 @@ export class LocalVoxSource extends VoxSource { ); this.applyEditsIntoChunk(sc, e.indices, e.value, e.values); this.markDirty(e.key); - await this.setDirtyTreeFlag(e.key, false); - await this.markChildrenDirtyInTree(e.key); + // Upscaling halted + //await this.setDirtyTreeFlag(e.key, false); + //await this.markChildrenDirtyInTree(e.key); touchedKeys.add(e.key); } for (const key of touchedKeys) { @@ -461,7 +472,6 @@ export class LocalVoxSource extends VoxSource { console.log("Downsample step failed or was unnecessary, stopping cascade."); break; } - console.log(`Downsampled ${currentKey} to ${targetKey}`); this.callChunkReload(targetKey); currentKey = targetKey; } @@ -511,7 +521,8 @@ export class LocalVoxSource extends VoxSource { this.saved.set(targetKey, targetChunk); this.markDirty(targetKey); - await this.setDirtyTreeFlag(targetKey, false); + // Upscaling halted + //await this.setDirtyTreeFlag(targetKey, false); return targetKey; } @@ -590,6 +601,8 @@ export function openVoxDb(): Promise { if (!db.objectStoreNames.contains("labels")) db.createObjectStore("labels"); // Ensure dirty store exists + // Upscaling halted + /* let dirtyStore: IDBObjectStore; if (!db.objectStoreNames.contains("dirty")) { dirtyStore = db.createObjectStore("dirty"); @@ -609,7 +622,7 @@ export function openVoxDb(): Promise { cursor.continue(); } }; - } + }*/ }; req.onsuccess = () => resolve(req.result); }); From a01be95421e57add65668ee7607392e018e76afd Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 040/251] feat: introduce VoxelEditController for centralized edit handling and map management - Add `VoxelEditController` supporting authoritative edit handling and unified label persistence. - Replace direct voxel editing logic in `VoxChunkSource` with shared backend edits via `VoxelEditController`. - Remove redundant registries and debounce logic from backend and frontend sources. - Refactor `LocalVoxSource` and `RemoteVoxSource` to integrate with new edit commit workflows. - Update RPC handlers and associated APIs for chunk reloads, label management, and edits batching. - Simplify frontend paint logic and enhance backend-driven updates. --- src/layer/vox/index.ts | 1 + src/layer/vox/tabs/settings.ts | 4 +- src/voxel_annotation/backend.ts | 154 ++---------------------- src/voxel_annotation/base.ts | 8 +- src/voxel_annotation/edit_backend.ts | 136 +++++++++++++++++++++ src/voxel_annotation/edit_controller.ts | 138 ++++++++++++++++++--- src/voxel_annotation/frontend.ts | 110 ++++++++--------- src/voxel_annotation/index.ts | 59 ++++----- src/voxel_annotation/local_source.ts | 47 ++++++-- src/voxel_annotation/remote_source.ts | 4 +- 10 files changed, 393 insertions(+), 268 deletions(-) create mode 100644 src/voxel_annotation/edit_backend.ts diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 93a42e0cb1..6ecbf3545b 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -410,6 +410,7 @@ export class VoxUserLayer extends UserLayer { ); // Expose a controller so tools can paint voxels via the source. this.voxEditController = new VoxelEditController(voxSource); + this.voxEditController.initializeMap(map); const sources2D = voxSource.getSources({} as any); for (const level of (sources2D[0] ?? [])) { diff --git a/src/layer/vox/tabs/settings.ts b/src/layer/vox/tabs/settings.ts index 60b3089f51..80119e16fb 100644 --- a/src/layer/vox/tabs/settings.ts +++ b/src/layer/vox/tabs/settings.ts @@ -4,7 +4,7 @@ import type { VoxUserLayer } from "#src/layer/vox/index.js"; import { DataType } from "#src/util/data_type.js"; import { exportVoxToZarr, type ExportStatus } from "#src/voxel_annotation/export_to_zarr.js"; -import { LocalVoxSource } from "#src/voxel_annotation/local_source.js"; +import { LocalVoxSourceWriter } from "#src/voxel_annotation/local_source.js"; import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; import { computeSteps } from "#src/voxel_annotation/map.js"; import { RemoteVoxSource } from "#src/voxel_annotation/remote_source.js"; @@ -128,7 +128,7 @@ export class VoxSettingsTab extends Tab { const src = new RemoteVoxSource(this.layer.voxServerUrl, this.layer.voxServerToken); maps = await src.listMaps(); } else { - const src = new LocalVoxSource(); + const src = new LocalVoxSourceWriter(); maps = await src.listMaps(); } for (const m of maps) this.layer.voxMapRegistry.upsert(m as any); diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 0e5d301963..4a4892894d 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -9,54 +9,23 @@ import { DataType } from "#src/util/data_type.js"; import { makeVoxChunkKey, VOX_CHUNK_SOURCE_RPC_ID, - VOX_COMMIT_VOXELS_RPC_ID, - VOX_LABELS_ADD_RPC_ID, - VOX_LABELS_GET_RPC_ID, VOX_MAP_INIT_RPC_ID, - VOX_RELOAD_CHUNKS_RPC_ID, } from "#src/voxel_annotation/base.js"; import type { VoxSource } from "#src/voxel_annotation/index.js"; -import { LocalVoxSource } from "#src/voxel_annotation/local_source.js"; +import { + LocalVoxSource, +} from "#src/voxel_annotation/local_source.js"; import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; import { RemoteVoxSource } from "#src/voxel_annotation/remote_source.js"; import type { RPC } from "#src/worker_rpc.js"; -import { registerPromiseRPC, registerRPC, registerSharedObject } from "#src/worker_rpc.js"; +import { registerRPC, registerSharedObject } from "#src/worker_rpc.js"; +// Ensure voxel edit backend and its RPC handlers are registered in the worker bundle. +import "#src/voxel_annotation/edit_backend.js"; /** * Backend volume source that persists voxel edits per chunk. It returns saved data if available, * otherwise returns an empty chunk (filled with zeros). */ -// --- VoxSource registry: share per (serverUrl, token, mapId, scaleKey) --- -const voxSourceRegistry = new Map(); - -function makeServerKey(serverUrl?: string, token?: string): string { - if (!serverUrl) return "local"; - return `remote:${serverUrl}|${token ?? ""}`; -} - -function makeRegistryKey(serverUrl: string | undefined, token: string | undefined, map: VoxMapConfig): string { - const sk = makeServerKey(serverUrl, token); - return `${sk}|${map.id}`; -} - -async function getOrCreateRegisteredVoxSource(serverUrl: string | undefined, token: string | undefined, map: VoxMapConfig, vcsInstance: VoxChunkSource): Promise { - console.log("getOrCreateRegisteredVoxSource", vcsInstance.lodFactor) - const key = makeRegistryKey(serverUrl, token, map); - console.log(voxSourceRegistry) - console.log("getOrCreateRegisteredVoxSource: key", key) - const existing = voxSourceRegistry.get(key); - if (existing) - { - console.log("getOrCreateRegisteredVoxSource: using existing source") - existing.addVoxChunkSource(vcsInstance); - return existing; - } - const src = serverUrl ? new RemoteVoxSource(serverUrl, token) : new LocalVoxSource(); - await src.init(map); - voxSourceRegistry.set(key, src); - src.addVoxChunkSource(vcsInstance); - return src; -} @registerSharedObject(VOX_CHUNK_SOURCE_RPC_ID) export class VoxChunkSource extends BaseVolumeChunkSource { @@ -67,17 +36,6 @@ export class VoxChunkSource extends BaseVolumeChunkSource { private mapReadyPromise: Promise; private resolveMapReady!: () => void; - // Debounced commit batching to minimize backend churn for rapid strokes. - private pendingCommitEdits: { - key: string; - indices: number[] | Uint32Array; - value?: number; - values?: ArrayLike; - size?: number[]; - }[] = []; - private commitDebounceTimer: number | undefined; - private readonly commitDebounceDelayMs: number = 200; - constructor(rpc: RPC, options: any) { super(rpc, options); // Detect remote server configuration from options (flexible keys) @@ -93,11 +51,9 @@ export class VoxChunkSource extends BaseVolumeChunkSource { }); } - /** Initialize map metadata and persistence backend using a VoxMapConfig. */ async initMap(arg: { map?: VoxMapConfig } | VoxMapConfig) { const map: VoxMapConfig = (arg as any)?.map ?? (arg as any); if (!map) throw new Error("initMap: map configuration is required"); - // Allow runtime override/validation of server settings via map if (map.serverUrl) { if (this.voxServerUrl && this.voxServerUrl !== map.serverUrl) { throw new Error("initMap: conflicting serverUrl provided"); @@ -110,75 +66,12 @@ export class VoxChunkSource extends BaseVolumeChunkSource { } this.voxToken = map.token; } - // Bind to a per-(server,map,scaleKey) registered source - this.source = await getOrCreateRegisteredVoxSource( - this.voxServerUrl, - this.voxToken, - map, - this - ); - // Signal readiness for any pending downloads/commits - try { this.resolveMapReady(); } catch { /* ignore multiple resolutions */ } - } - - reloadChunksByKey(keys: string[]) { - this.rpc?.invoke(VOX_RELOAD_CHUNKS_RPC_ID, { - id: this.rpcId, - keys, - }); - } - - private async flushPendingCommitEdits(): Promise { - await this.mapReadyPromise; - const src = this.source; - if (!src) throw new Error("flushPendingCommitEdits: source is not initialized for this map"); - const pending = this.pendingCommitEdits; - this.pendingCommitEdits = []; - this.commitDebounceTimer = undefined; - if (pending.length === 0) return; - console.log("####################################################################################\n flushPendingCommitEdits: applying edits", pending) - await src.applyEdits(pending); - } - - /** Commit voxel edits from the frontend (debounced to batch rapid updates). */ - async commitVoxels( - edits: { - key: string; - indices: number[] | Uint32Array; - value?: number; - values?: ArrayLike; - size?: number[]; - }[], - ) { - await this.mapReadyPromise; - if (!this.source) throw new Error("commitVoxels: source is not initialized for this map"); - // Enqueue edits and schedule a short debounce to coalesce frames - for (const e of edits) { - if (!e || !e.key || !e.indices) { - throw new Error("commitVoxels: invalid edit payload"); - } - this.pendingCommitEdits.push(e); - } - if (this.commitDebounceTimer !== undefined) { - clearTimeout(this.commitDebounceTimer); - } - this.commitDebounceTimer = setTimeout(() => { - void this.flushPendingCommitEdits(); - }, this.commitDebounceDelayMs) as unknown as number; - } - - async getLabelIds(): Promise { - await this.mapReadyPromise; - const src = this.source; - if (!src) throw new Error("getLabelIds: source is not initialized for this map"); - return await src.getLabelIds(); - } - - async addLabel(value: number): Promise { - await this.mapReadyPromise; - const src = this.source; - if (!src) throw new Error("addLabel: source is not initialized for this map"); - return await src.addLabel(value >>> 0); + const src = this.voxServerUrl + ? new RemoteVoxSource(this.voxServerUrl, this.voxToken) + : new LocalVoxSource(); + await src.init(map); + this.source = src; + try { this.resolveMapReady(); } catch { /* ignore */ } } async download(chunk: VolumeChunk, signal: AbortSignal): Promise { @@ -246,31 +139,8 @@ export class VoxChunkSource extends BaseVolumeChunkSource { } } -// RPC to commit voxel edits. -registerRPC(VOX_COMMIT_VOXELS_RPC_ID, function (x: any) { - const obj = this.get(x.id) as VoxChunkSource; - obj.commitVoxels(x.edits || []); -}); - // RPC to initialize map registerRPC(VOX_MAP_INIT_RPC_ID, function (x: any) { const obj = this.get(x.id) as VoxChunkSource; obj.initMap(x?.map || x || {}); }); - -// RPCs for label persistence (promise-based) -registerPromiseRPC( - VOX_LABELS_GET_RPC_ID, - async function (x: any): Promise { - const obj = this.get(x.rpcId) as VoxChunkSource; - const ids = await obj.getLabelIds(); - return { value: ids }; - }, -); - - -registerPromiseRPC(VOX_LABELS_ADD_RPC_ID, async function (x: any) { - const obj = this.get(x.rpcId) as VoxChunkSource; - const ids = await obj.addLabel(x?.value >>> 0); - return { value: ids }; -}); diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index de9670dfd1..58935ff15d 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -1,9 +1,11 @@ export const VOX_CHUNK_SOURCE_RPC_ID = "vox.VoxChunkSource"; -export const VOX_COMMIT_VOXELS_RPC_ID = "vox.commitVoxels"; export const VOX_MAP_INIT_RPC_ID = "vox.map.init"; -export const VOX_LABELS_GET_RPC_ID = "vox.labels.get"; -export const VOX_LABELS_ADD_RPC_ID = "vox.labels.add"; export const VOX_RELOAD_CHUNKS_RPC_ID = "vox.chunk.reload"; +export const VOX_EDIT_BACKEND_RPC_ID = "vox.EditBackend"; +export const VOX_EDIT_MAP_INIT_RPC_ID = "vox.edit.map.init"; +export const VOX_EDIT_COMMIT_VOXELS_RPC_ID = "vox.edit.commitVoxels"; +export const VOX_EDIT_LABELS_GET_RPC_ID = "vox.edit.labels.get"; +export const VOX_EDIT_LABELS_ADD_RPC_ID = "vox.edit.labels.add"; export function makeVoxChunkKey(chunkKey: string, lodFactor : number) { return `lod${lodFactor}#${chunkKey}`; diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/edit_backend.ts new file mode 100644 index 0000000000..8ce01468c2 --- /dev/null +++ b/src/voxel_annotation/edit_backend.ts @@ -0,0 +1,136 @@ +/** + * Edit controller backend: owns the authoritative VoxSourceWriter for a given map + * and handles applying edits and label persistence independent of the volume chunk + * streaming backend. This enables multiple VoxChunkSource instances (read-only) + * while keeping a single writer per map owned by the edit controller. + */ + +import { + VOX_EDIT_BACKEND_RPC_ID, + VOX_EDIT_COMMIT_VOXELS_RPC_ID, + VOX_EDIT_LABELS_ADD_RPC_ID, + VOX_EDIT_LABELS_GET_RPC_ID, + VOX_EDIT_MAP_INIT_RPC_ID, + VOX_RELOAD_CHUNKS_RPC_ID, +} from "#src/voxel_annotation/base.js"; +import type { VoxSourceWriter } from "#src/voxel_annotation/index.js"; +import { LocalVoxSourceWriter } from "#src/voxel_annotation/local_source.js"; +import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; +import { RemoteVoxSource } from "#src/voxel_annotation/remote_source.js"; +import type { RPC} from "#src/worker_rpc.js"; +import { SharedObject , registerPromiseRPC, registerRPC, registerSharedObject, initializeSharedObjectCounterpart } from "#src/worker_rpc.js"; + +@registerSharedObject(VOX_EDIT_BACKEND_RPC_ID) +export class VoxelEditController extends SharedObject { + private source?: VoxSourceWriter; + private mapReadyPromise: Promise; + private resolveMapReady!: () => void; + + // Short debounce to coalesce rapid edits coming from tools. + private pendingEdits: { + key: string; + indices: number[] | Uint32Array; + value?: number; + values?: ArrayLike; + size?: number[]; + }[] = []; + private commitDebounceTimer: number | undefined; + private readonly commitDebounceDelayMs: number = 200; + + constructor(rpc: RPC, options: any) { + super(); + // Initialize as a counterpart in the worker so RPC references are valid. + // This registers the object under the provided rpc/id and sets up ref counting. + initializeSharedObjectCounterpart(this, rpc, options); + this.mapReadyPromise = new Promise((resolve) => { + this.resolveMapReady = resolve; + }); + } + + async initMap(arg: { map?: VoxMapConfig } | VoxMapConfig) { + const map: VoxMapConfig = (arg as any)?.map ?? (arg as any); + if (!map) throw new Error("VoxEditBackend.initMap: map configuration is required"); + const src = map.serverUrl + ? new RemoteVoxSource(map.serverUrl, map.token) + : new LocalVoxSourceWriter(this); + await src.init(map); + this.source = src; + try { this.resolveMapReady(); } catch {/* ignore */} + } + + private async flushPending(): Promise { + await this.mapReadyPromise; + const src = this.source; + if (!src) throw new Error("VoxEditBackend.flushPending: source not initialized"); + const edits = this.pendingEdits; + this.pendingEdits = []; + this.commitDebounceTimer = undefined; + if (edits.length === 0) return; + await src.applyEdits(edits); + } + + async commitVoxels( + edits: { + key: string; + indices: number[] | Uint32Array; + value?: number; + values?: ArrayLike; + size?: number[]; + }[], + ) { + await this.mapReadyPromise; + if (!this.source) throw new Error("VoxEditBackend.commitVoxels: source not initialized"); + for (const e of edits) { + if (!e || !e.key || !e.indices) { + throw new Error("VoxEditBackend.commitVoxels: invalid edit payload"); + } + this.pendingEdits.push(e); + } + if (this.commitDebounceTimer !== undefined) clearTimeout(this.commitDebounceTimer); + this.commitDebounceTimer = setTimeout(() => { void this.flushPending(); }, this.commitDebounceDelayMs) as unknown as number; + } + + async getLabelIds(): Promise { + await this.mapReadyPromise; + const src = this.source; + if (!src) throw new Error("VoxEditBackend.getLabelIds: source not initialized"); + return await src.getLabelIds(); + } + + async addLabel(value: number): Promise { + await this.mapReadyPromise; + const src = this.source; + if (!src) throw new Error("VoxEditBackend.addLabel: source not initialized"); + return await src.addLabel(value >>> 0); + } + + callChunkReload(voxChunkKeys: string[]){ + this.rpc?.invoke(VOX_RELOAD_CHUNKS_RPC_ID, { + rpcId: this.rpcId, + voxChunkKeys: voxChunkKeys, + }) + } +} + +// RPC wire-up +registerRPC(VOX_EDIT_MAP_INIT_RPC_ID, function (x: any) { + const obj = this.get(x.rpcId) as VoxelEditController; + obj.initMap(x?.map || x || {}); +}); + +registerRPC(VOX_EDIT_COMMIT_VOXELS_RPC_ID, function (x: any) { + const obj = this.get(x.rpcId) as VoxelEditController; + obj.commitVoxels(x.edits || []); +}); + +registerPromiseRPC(VOX_EDIT_LABELS_GET_RPC_ID, async function (x: any) { + const obj = this.get(x.rpcId) as VoxelEditController; + const ids = await obj.getLabelIds(); + return { value: ids }; +}); + +registerPromiseRPC(VOX_EDIT_LABELS_ADD_RPC_ID, async function (x: any) { + const obj = this.get(x.rpcId) as VoxelEditController; + const ids = await obj.addLabel(x?.value >>> 0); + return { value: ids }; +}); diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 81b3eaf740..18873ba630 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -4,16 +4,70 @@ */ import type { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; +import { + parseVoxChunkKey, + VOX_EDIT_BACKEND_RPC_ID, + VOX_EDIT_COMMIT_VOXELS_RPC_ID, + VOX_EDIT_LABELS_ADD_RPC_ID, + VOX_EDIT_LABELS_GET_RPC_ID, + VOX_EDIT_MAP_INIT_RPC_ID, + VOX_RELOAD_CHUNKS_RPC_ID, +} from "#src/voxel_annotation/base.js"; import type { VoxChunkSource } from "#src/voxel_annotation/frontend.js"; +import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; +import { + registerRPC, + registerSharedObjectOwner, + SharedObject, +} from "#src/worker_rpc.js"; -export class VoxelEditController { - constructor(private multiscale: MultiscaleVolumeChunkSource) {} +@registerSharedObjectOwner(VOX_EDIT_BACKEND_RPC_ID) +export class VoxelEditController extends SharedObject { + constructor(private multiscale: MultiscaleVolumeChunkSource) { + super(); + const rpc = (this.multiscale as any)?.chunkManager?.rpc; + if (!rpc) { + throw new Error("VoxelEditController: Missing RPC from multiscale chunk manager."); + } + this.initializeCounterpart(rpc, {}); + } private static readonly qualityFactor = 16.0; private static readonly restrictToMinLOD = true; + private mapConfig?: VoxMapConfig; + + private getIdentitySliceViewSourceOptions() { + const rank = (this.multiscale as any).rank as number | undefined; + if (!Number.isInteger(rank) || (rank as number) <= 0) { + throw new Error("VoxelEditController: Invalid multiscale rank."); + } + const r = rank as number; + // Identity mapping from multiscale to view for our purposes. + const displayRank = r; + const multiscaleToViewTransform = new Float32Array(displayRank * r); + for (let chunkDim = 0; chunkDim < r; ++chunkDim) { + for (let displayDim = 0; displayDim < displayRank; ++displayDim) { + multiscaleToViewTransform[displayRank * chunkDim + displayDim] = + chunkDim === displayDim ? 1 : 0; + } + } + return { + displayRank, + multiscaleToViewTransform, + modelChannelDimensionIndices: [], + } as const; + } + + initializeMap(map: VoxMapConfig) { + if (!this.rpc) throw new Error("VoxelEditController.initializeMap: RPC not initialized."); + this.mapConfig = map; + this.rpc.invoke(VOX_EDIT_MAP_INIT_RPC_ID, { rpcId: this.rpcId, map }); + } // Required: compute desired voxel size (power-of-two) from brush radius. getOptimalVoxelSize(brushRadius: number, minLOD = 1, maxLOD = 128) { - if (VoxelEditController.restrictToMinLOD) {return minLOD;} + if (VoxelEditController.restrictToMinLOD) { + return minLOD; + } if (!Number.isFinite(brushRadius) || brushRadius <= 0) { return minLOD; } @@ -26,9 +80,13 @@ export class VoxelEditController { /** Compute the edit LOD index (scale index) from a brush radius in canonical units. */ getEditLodIndexForBrush(brushRadiusCanonical: number): number { - if (VoxelEditController.restrictToMinLOD) {return 0;} + if (VoxelEditController.restrictToMinLOD) { + return 0; + } if (!Number.isFinite(brushRadiusCanonical) || brushRadiusCanonical <= 0) { - throw new Error("getEditLodIndexForBrush: brushRadiusCanonical must be > 0"); + throw new Error( + "getEditLodIndexForBrush: brushRadiusCanonical must be > 0", + ); } const voxelSize = this.getOptimalVoxelSize(brushRadiusCanonical); const sourceIndex = Math.round(Math.log2(voxelSize)); @@ -47,21 +105,24 @@ export class VoxelEditController { basis?: { u: Float32Array; v: Float32Array }, ) { if (!Number.isFinite(radiusCanonical) || radiusCanonical <= 0) { - console.log(basis) // TODO remove this line, was only added to suppress ts errors + void basis; // basis is currently unused for disk alignment in this refactor throw new Error("paintBrushWithShape: 'radius' must be > 0."); } if (!centerCanonical || centerCanonical.length < 3) { - throw new Error("paintBrushWithShape: 'center' must be a Float32Array[3]."); + throw new Error( + "paintBrushWithShape: 'center' must be a Float32Array[3].", + ); } const voxelSize = this.getOptimalVoxelSize(radiusCanonical); const sourceIndex = Math.floor(Math.log2(voxelSize)); - const src2D = this.multiscale.getSources({} as any); + const src2D = this.multiscale.getSources(this.getIdentitySliceViewSourceOptions()); if (!src2D || !src2D[0] || src2D[0].length <= sourceIndex) { throw new Error("VoxelEditController: No multiscale levels available."); } const source = src2D[0][sourceIndex]?.chunkSource as VoxChunkSource; - if (!source) throw new Error("paintVoxelsBatch: Selected level has no chunk source."); + if (!source) + throw new Error("paintVoxelsBatch: Selected level has no chunk source."); // Convert center and radius to the level’s voxel grid. const cx = Math.floor((centerCanonical[0] ?? 0) / voxelSize); @@ -69,7 +130,9 @@ export class VoxelEditController { const cz = Math.floor((centerCanonical[2] ?? 0) / voxelSize); const r = Math.round(radiusCanonical / voxelSize); if (r <= 0) { - throw new Error("paintBrushWithShape: radius too small for selected LOD."); + throw new Error( + "paintBrushWithShape: radius too small for selected LOD.", + ); } const rr = r * r; @@ -95,21 +158,56 @@ export class VoxelEditController { } } - source.paintVoxelsBatch(voxelsLOD, value); + const editsPayload = source.paintVoxelsBatch(voxelsLOD, value); + + if (!this.rpc) throw new Error("VoxelEditController.paintBrushWithShape: RPC not initialized."); + this.rpc.invoke(VOX_EDIT_COMMIT_VOXELS_RPC_ID, { + rpcId: this.rpcId, + edits: editsPayload, + }); } async getLabelIds(): Promise { - const src2D = this.multiscale.getSources({} as any); - if (!src2D || !src2D[0] || src2D[0].length === 0) return []; - const src = src2D[0][0].chunkSource as VoxChunkSource | undefined; - if (!src) return []; - return await src.getLabelIds(); + if (!this.rpc) throw new Error("VoxelEditController.getLabelIds: RPC not initialized."); + return this.rpc.promiseInvoke(VOX_EDIT_LABELS_GET_RPC_ID, { + rpcId: this.rpcId, + }); } async addLabel(value: number): Promise { - const src2D = this.multiscale.getSources({} as any); - const src = src2D?.[0]?.[0]?.chunkSource as VoxChunkSource | undefined; - if (!src) throw new Error("Voxel source not ready"); - return await src.addLabel(value >>> 0); + if (!this.rpc) throw new Error("VoxelEditController.addLabel: RPC not initialized."); + return this.rpc.promiseInvoke(VOX_EDIT_LABELS_ADD_RPC_ID, { + rpcId: this.rpcId, + value, + }); + } + + callChunkReload(voxChunkKeys: string[]) { + const src2D = this.multiscale.getSources(this.getIdentitySliceViewSourceOptions()); + if (!src2D || !src2D[0] || src2D[0].length === 0) { + throw new Error("VoxelEditController: No multiscale levels available."); + } + for (const key of voxChunkKeys) { + const parsed = parseVoxChunkKey(key); + if (!parsed) { + throw new Error(`VoxelEditController.callChunkReload: invalid chunk key '${key}'.`); + } + if (!this.mapConfig?.steps) { + throw new Error(`VoxelEditController.callChunkReload: missing map config steps.`); + } + const levelIndex = this.mapConfig?.steps.indexOf(parsed.lod) | 0; + const level = src2D[0][levelIndex]; + if (!level || !level.chunkSource) { + throw new Error(`VoxelEditController.callChunkReload: missing chunk source for LOD ${levelIndex}.`); + } + const source = level.chunkSource as unknown as VoxChunkSource; + source.invalidateChunksByKey([parsed.chunkKey]); + } } } + +registerRPC(VOX_RELOAD_CHUNKS_RPC_ID, function (x: any) { + const obj = this.get(x.rpcId) as VoxelEditController; + const keys: string[] = Array.isArray(x.voxChunkKeys) ? x.voxChunkKeys : []; + obj.callChunkReload(keys); +}); diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index ee7bee255d..10f0afbd9b 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -12,15 +12,11 @@ import { animationFrameDebounce } from "#src/util/animation_frame_debounce.js"; import type { TypedArray } from "#src/util/array.js"; import { VOX_CHUNK_SOURCE_RPC_ID, - VOX_COMMIT_VOXELS_RPC_ID, VOX_MAP_INIT_RPC_ID, - VOX_LABELS_GET_RPC_ID, - VOX_LABELS_ADD_RPC_ID, makeVoxChunkKey, - VOX_RELOAD_CHUNKS_RPC_ID, } from "#src/voxel_annotation/base.js"; import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; -import { registerRPC, registerSharedObjectOwner } from "#src/worker_rpc.js"; +import { registerSharedObjectOwner } from "#src/worker_rpc.js"; /** * Frontend owner for VoxChunkSource, extended with a local optimistic edit overlay. @@ -89,28 +85,6 @@ export class VoxChunkSource extends BaseVolumeChunkSource { return base; } - async getLabelIds(): Promise { - try { - /* - NOTE: do not pass the rpcId as { id: this.rpcId } since the id field it will be overwritten by promiseInvoke, use another name like { rpcId: this.rpcId } - */ - return await this.rpc!.promiseInvoke(VOX_LABELS_GET_RPC_ID, { - rpcId: this.rpcId, - }); - } catch { - return []; - } - } - - - async addLabel(value: number): Promise { - // Do not swallow errors; caller should display them to the user and avoid UI updates on failure. - return await this.rpc!.promiseInvoke(VOX_LABELS_ADD_RPC_ID, { - rpcId: this.rpcId, - value, - }); - } - private scheduleUpdate(key: string) { this.dirtyChunks.add(key); this.scheduleProcessPendingUploads(); @@ -144,10 +118,11 @@ export class VoxChunkSource extends BaseVolumeChunkSource { this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); } - /** Batch paint API to minimize GPU uploads by chunk. */ - paintVoxelsBatch(voxels: Float32Array[], value: number) { - if (!voxels || voxels.length === 0) return; - const editsByKey = new Map(); + /** Batch paint API to minimize GPU uploads by chunk. Returns backend edits payload. */ + paintVoxelsBatch(voxels: Float32Array[], value: number): { key: string; indices: number[]; value: number }[] { + if (!voxels || voxels.length === 0) return []; + const indicesByInnerKey = new Map(); + const editsByFullKey = new Map(); const chunksToUpdate = new Set(); for (const v of voxels) { @@ -158,39 +133,64 @@ export class VoxChunkSource extends BaseVolumeChunkSource { const chunk = this.chunks.get(key) as VolumeChunk | undefined; const cpuArray = chunk ? this.getCpuArrayForChunk(chunk) : null; - // Best effort immediate local write for responsive painting if (cpuArray) { (cpuArray as any)[chunkLocalIndex] = value as any; } - - // Always schedule an update for this chunk. If the CPU array isn’t ready yet, - // the pending key will be retried by processPendingUploads once it becomes available. - // This ensures the draw becomes visible once the chunk is present. - // - // Important: we schedule even if cpuArray was null. chunksToUpdate.add(key); } - let arr = editsByKey.get(key); - if (!arr) editsByKey.set(key, (arr = [])); - arr.push(canonicalIndex); + let arrInner = indicesByInnerKey.get(key); + if (!arrInner) indicesByInnerKey.set(key, (arrInner = [])); + arrInner.push(canonicalIndex); } + + // Schedule GPU uploads for updated chunks (using inner keys) for (const key of chunksToUpdate) this.scheduleUpdate(key); - if (editsByKey.size > 0) { - const size = Array.from(this.spec.chunkDataSize); - const edits = Array.from(editsByKey, ([key, indices]) => ({ - key: makeVoxChunkKey(key, this.lodFactor), - indices, - value, - size, - })); - try { - this.rpc!.invoke(VOX_COMMIT_VOXELS_RPC_ID, { id: this.rpcId, edits }); - } catch { - // ignore + // Build backend edits payload using full keys (including LOD) + for (const [innerKey, indices] of indicesByInnerKey.entries()) { + const fullKey = makeVoxChunkKey(innerKey, this.lodFactor); + editsByFullKey.set(fullKey, indices); + } + + const edits: { key: string; indices: number[]; value: number }[] = []; + for (const [key, indices] of editsByFullKey.entries()) { + edits.push({ key, indices, value }); + } + return edits; + } + + /** + * Build backend edits payload by grouping provided voxels into chunk keys at this source's LOD. + * Leverages the same index computation used for immediate CPU painting to avoid duplication. + */ + buildEditsFromVoxels( + voxels: Float32Array[], + value?: number, + ): { key: string; indices: number[]; value?: number }[] { + if (!Array.isArray(voxels)) { + throw new Error("VoxChunkSource.buildEditsFromVoxels: 'voxels' must be an array"); + } + if (!Number.isInteger(this.lodFactor) || this.lodFactor <= 0) { + throw new Error("VoxChunkSource.buildEditsFromVoxels: invalid lodFactor on source"); + } + const grouped = new Map(); + for (const v of voxels) { + if (!v) continue; + const { key, canonicalIndex } = this.computeIndices(v); + const fullKey = makeVoxChunkKey(key, this.lodFactor); + let arr = grouped.get(fullKey); + if (!arr) { + arr = []; + grouped.set(fullKey, arr); } + arr.push(canonicalIndex); + } + const out: { key: string; indices: number[]; value?: number }[] = []; + for (const [key, indices] of grouped.entries()) { + out.push(value === undefined ? { key, indices } : { key, indices, value }); } + return out; } /** getValueAt simply defers to base; edits are persisted in backend and applied to CPU array when present. */ @@ -261,7 +261,3 @@ export class VoxChunkSource extends BaseVolumeChunkSource { } } -registerRPC(VOX_RELOAD_CHUNKS_RPC_ID, function (x: any) { - const obj = this.get(x.id) as VoxChunkSource; - obj.invalidateChunksByKey(x.keys); -}); diff --git a/src/voxel_annotation/index.ts b/src/voxel_annotation/index.ts index e5fd9e5319..8806942e5b 100644 --- a/src/voxel_annotation/index.ts +++ b/src/voxel_annotation/index.ts @@ -3,8 +3,7 @@ * The LocalVoxSource persists per-chunk arrays into IndexedDB with a debounced saver. */ -import type { VoxChunkSource } from "#src/voxel_annotation/backend.js"; -import { parseVoxChunkKey } from "#src/voxel_annotation/base.js"; +import type { VoxelEditController } from "#src/voxel_annotation/edit_backend.js"; import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; @@ -25,6 +24,25 @@ export function compositeLabelsDbKey(mapId: string): string { } export abstract class VoxSource { + protected mapId: string = "default"; + protected mapCfg: VoxMapConfig; // Keep the entire configuration in one place + + init(map: VoxMapConfig): Promise<{ mapId: string}> { + if(!map) + { + throw new Error("VoxSource: init: Map config is required"); + } + this.mapCfg = map; + this.mapId = map.id; + return Promise.resolve({ mapId: this.mapId }); + } + + // Abstract persistence API the backend expects + abstract getSavedChunk(key: string): Promise; +} + + +export abstract class VoxSourceWriter extends VoxSource { /** * Optional listing of available maps for the current source. * Remote sources should query their endpoint; local may enumerate local IndexedDB entries. @@ -32,8 +50,6 @@ export abstract class VoxSource { async listMaps(_args?: { baseUrl?: string; token?: string }): Promise { return []; } - protected mapId: string = "default"; - protected mapCfg: VoxMapConfig; // Keep the entire configuration in one place // In-memory cache of loaded chunks protected maxSavedChunks = 256; // cap to prevent unbounded growth @@ -42,7 +58,12 @@ export abstract class VoxSource { // Dirty tracking and debounced save protected dirty = new Set(); protected saveTimer: number | undefined; - protected voxChunkSources = new Map(); + editController?: VoxelEditController; + + constructor(editController?: VoxelEditController) { + super(); + this.editController = editController; + } /** * Generic label persistence hooks. Subclasses override to connect to the chosen datasource. @@ -56,30 +77,11 @@ export abstract class VoxSource { return []; } - init(map: VoxMapConfig): Promise<{ mapId: string}> { - if(!map) - { - throw new Error("VoxSource: init: Map config is required"); - } - this.mapCfg = map; - this.mapId = map.id; - return Promise.resolve({ mapId: this.mapId }); - } - - addVoxChunkSource(vcs: VoxChunkSource) { - this.voxChunkSources.set(vcs.lodFactor, vcs); - } - callChunkReload(voxChunkKey: string) { - const parsed_vck = parseVoxChunkKey(voxChunkKey); - if (!parsed_vck) { - console.error("VoxSource: callChunkReload: invalid chunk key", voxChunkKey); - return; - } - const vcs = this.voxChunkSources.get(parsed_vck.lod); - if (vcs) { - vcs.reloadChunksByKey([parsed_vck.chunkKey]); - } + if (!this.editController) { + throw new Error("VoxSourceWriter.callChunkReload: editController not set"); + } + this.editController.callChunkReload([voxChunkKey]); } // Common helpers @@ -101,7 +103,6 @@ export abstract class VoxSource { protected async flushSaves(): Promise {} // Abstract persistence API the backend expects - abstract getSavedChunk(key: string): Promise; abstract ensureChunk( key: string, size?: Uint32Array | number[], diff --git a/src/voxel_annotation/local_source.ts b/src/voxel_annotation/local_source.ts index dc61575b72..4eb9142164 100644 --- a/src/voxel_annotation/local_source.ts +++ b/src/voxel_annotation/local_source.ts @@ -1,5 +1,5 @@ import { makeVoxChunkKey, parseVoxChunkKey } from "#src/voxel_annotation/base.js"; -import type { SavedChunk } from "#src/voxel_annotation/index.js"; +import { SavedChunk, VoxSourceWriter } from "#src/voxel_annotation/index.js"; import { compositeChunkDbKey, compositeLabelsDbKey, @@ -19,8 +19,33 @@ function calculateDownsamplePasses(chunkSize: number) { return Math.ceil(Math.log2(chunkSize)); } -/** IndexedDB-backed local source. */ export class LocalVoxSource extends VoxSource { + private dbPromise: Promise | null = null; + + private async getDb(): Promise { + if (this.dbPromise) return this.dbPromise; + this.dbPromise = openVoxDb(); + return this.dbPromise; + } + + async getSavedChunk(key: string): Promise { + const db = await this.getDb(); + const composite = compositeChunkDbKey(this.mapId, key); + const buf = await idbGet(db, "chunks", composite); + if (buf) { + const arr = new Uint32Array(buf); + const sc: SavedChunk = { + data: arr, + size: new Uint32Array(this.mapCfg!.chunkDataSize as any), + }; + return sc; + } + return undefined; + } +} + +/** IndexedDB-backed local source. */ +export class LocalVoxSourceWriter extends VoxSourceWriter { // Upscaling halted /* @@ -30,7 +55,7 @@ export class LocalVoxSource extends VoxSource { const existing = this.saved.get(key); if (existing) return existing; const db = await this.getDb(); - const composite = this.compositeKey(key); + const composite = compositeChunkDbKey(this.mapId, key); const buf = await idbGet(db, "chunks", composite); if (!buf) return undefined; const arr = new Uint32Array(buf); @@ -47,14 +72,14 @@ export class LocalVoxSource extends VoxSource { const db = await this.getDb(); const tx = db.transaction(LocalVoxSource.DIRTY_STORE, "readwrite"); const store = tx.objectStore(LocalVoxSource.DIRTY_STORE); - const composite = this.compositeKey(key); + const composite = compositeChunkDbKey(this.mapId, key); await idbPut(store, isDirty ? 1 : 0, composite); await txDone(tx); } private async getDirtyTreeValue(key: string): Promise<0 | 1 | undefined> { const db = await this.getDb(); - const composite = this.compositeKey(key); + const composite = compositeChunkDbKey(this.mapId, key); const v = await idbGet(db, LocalVoxSource.DIRTY_STORE, composite); if (v === undefined) return undefined; if (v !== 0 && v !== 1) throw new Error(`Invalid dirty-tree value for ${key}: ${String(v)}`); @@ -101,7 +126,7 @@ export class LocalVoxSource extends VoxSource { const tx = db.transaction(LocalVoxSource.DIRTY_STORE, "readwrite"); const store = tx.objectStore(LocalVoxSource.DIRTY_STORE); for (const ck of children) { - await idbPut(store, 1, this.compositeKey(ck)); + await idbPut(store, 1, compositeChunkDbKey(this.mapId, key)); } await txDone(tx); } @@ -363,7 +388,7 @@ export class LocalVoxSource extends VoxSource { return existing; } const db = await this.getDb(); - const composite = this.compositeKey(key); + const composite = compositeChunkDbKey(this.mapId, key); const buf = await idbGet(db, "chunks", composite); if (buf) { const arr = new Uint32Array(buf); @@ -398,7 +423,7 @@ export class LocalVoxSource extends VoxSource { } }*/ const db = await this.getDb(); - const composite = this.compositeKey(key); + const composite = compositeChunkDbKey(this.mapId, key); const buf = await idbGet(db, "chunks", composite); if (buf) { const arr = new Uint32Array(buf); @@ -573,16 +598,12 @@ export class LocalVoxSource extends VoxSource { for (const key of keys) { const sc = this.saved.get(key); if (!sc) continue; - await idbPut(store, sc.data.buffer, this.compositeKey(key)); + await idbPut(store, sc.data.buffer, compositeChunkDbKey(this.mapId, key)); } await txDone(tx); this.saveTimer = undefined; } - private compositeKey(key: string) { - return compositeChunkDbKey(this.mapId, key); - } - private async getDb(): Promise { if (this.dbPromise) return this.dbPromise; this.dbPromise = openVoxDb(); diff --git a/src/voxel_annotation/remote_source.ts b/src/voxel_annotation/remote_source.ts index 9f3c6c6cac..e8d9618fc1 100644 --- a/src/voxel_annotation/remote_source.ts +++ b/src/voxel_annotation/remote_source.ts @@ -1,10 +1,10 @@ import { DataType } from "#src/sliceview/base.js"; import type { SavedChunk} from "#src/voxel_annotation/index.js"; -import { VoxSource } from "#src/voxel_annotation/index.js"; +import { VoxSourceWriter } from "#src/voxel_annotation/index.js"; import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; import { computeSteps } from "#src/voxel_annotation/map.js"; -export class RemoteVoxSource extends VoxSource { +export class RemoteVoxSource extends VoxSourceWriter { async listMaps(): Promise { try { const qs = this.qs({}); From 944998a4da174a5b45c8ca589755d862865cd608 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 041/251] refactor: improve chunk invalidation and reload workflows - Replace `invalidateChunksByKey` logic with streamlined, cache-wide invalidation. - Introduce grouped chunk reloads by LOD in `VoxelEditController`. - Update `callChunkReload` to handle batch chunk keys efficiently. - Reduce redundant methods and logging across annotation sources. --- src/voxel_annotation/edit_controller.ts | 16 +++++++-- src/voxel_annotation/frontend.ts | 48 ++----------------------- src/voxel_annotation/index.ts | 4 +-- src/voxel_annotation/local_source.ts | 3 +- 4 files changed, 21 insertions(+), 50 deletions(-) diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 18873ba630..3dc01a414c 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -187,6 +187,7 @@ export class VoxelEditController extends SharedObject { if (!src2D || !src2D[0] || src2D[0].length === 0) { throw new Error("VoxelEditController: No multiscale levels available."); } + const chkByLod = new Map>(); for (const key of voxChunkKeys) { const parsed = parseVoxChunkKey(key); if (!parsed) { @@ -195,13 +196,24 @@ export class VoxelEditController extends SharedObject { if (!this.mapConfig?.steps) { throw new Error(`VoxelEditController.callChunkReload: missing map config steps.`); } - const levelIndex = this.mapConfig?.steps.indexOf(parsed.lod) | 0; + const levelIndex = this.mapConfig?.steps.indexOf(parsed.lod); + if (levelIndex < 0) { + throw new Error( + `VoxelEditController.callChunkReload: LOD ${parsed.lod} not present in steps [${this.mapConfig?.steps.join(",")}].`, + ); + } + if (!chkByLod.has(levelIndex)){ + chkByLod.set(levelIndex, new Set()); + } + chkByLod.get(levelIndex)?.add(parsed.chunkKey); + } + for (const [levelIndex, keys] of chkByLod) { const level = src2D[0][levelIndex]; if (!level || !level.chunkSource) { throw new Error(`VoxelEditController.callChunkReload: missing chunk source for LOD ${levelIndex}.`); } const source = level.chunkSource as unknown as VoxChunkSource; - source.invalidateChunksByKey([parsed.chunkKey]); + source.invalidateChunksByKey(Array.from(keys)); } } } diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 10f0afbd9b..dcdb53061d 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -106,16 +106,9 @@ export class VoxChunkSource extends BaseVolumeChunkSource { } invalidateChunksByKey(keys: string[]) { - console.log("invalidateChunksByKey", keys); - for (const key of keys) { - const chunk = this.chunks.get(key) as VolumeChunk | undefined; - const cpuArray = chunk ? this.getCpuArrayForChunk(chunk) : null; - if (chunk && cpuArray) { - console.log("chunk:", key, " has been reloaded"); - this.invalidateChunkUpload(chunk); - } - } - this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); + console.log("invalidateChunksByKey", this.lodFactor, keys); + // TODO: Avoid invalidating the whole cache, instead invalidate only the chunks that are affected by the edits. + this.invalidateCache(); } /** Batch paint API to minimize GPU uploads by chunk. Returns backend edits payload. */ @@ -160,39 +153,6 @@ export class VoxChunkSource extends BaseVolumeChunkSource { return edits; } - /** - * Build backend edits payload by grouping provided voxels into chunk keys at this source's LOD. - * Leverages the same index computation used for immediate CPU painting to avoid duplication. - */ - buildEditsFromVoxels( - voxels: Float32Array[], - value?: number, - ): { key: string; indices: number[]; value?: number }[] { - if (!Array.isArray(voxels)) { - throw new Error("VoxChunkSource.buildEditsFromVoxels: 'voxels' must be an array"); - } - if (!Number.isInteger(this.lodFactor) || this.lodFactor <= 0) { - throw new Error("VoxChunkSource.buildEditsFromVoxels: invalid lodFactor on source"); - } - const grouped = new Map(); - for (const v of voxels) { - if (!v) continue; - const { key, canonicalIndex } = this.computeIndices(v); - const fullKey = makeVoxChunkKey(key, this.lodFactor); - let arr = grouped.get(fullKey); - if (!arr) { - arr = []; - grouped.set(fullKey, arr); - } - arr.push(canonicalIndex); - } - const out: { key: string; indices: number[]; value?: number }[] = []; - for (const [key, indices] of grouped.entries()) { - out.push(value === undefined ? { key, indices } : { key, indices, value }); - } - return out; - } - /** getValueAt simply defers to base; edits are persisted in backend and applied to CPU array when present. */ override getValueAt(chunkPosition: Float32Array, channelAccess: any) { return super.getValueAt(chunkPosition, channelAccess); @@ -247,7 +207,6 @@ export class VoxChunkSource extends BaseVolumeChunkSource { private invalidateChunkUpload(chunk: VolumeChunk) { const gl = chunk.gl; - // If already on GPU and the concrete implementation supports in-place update, use it. const anyChunk = chunk as any; if ( chunk.state === ChunkState.GPU_MEMORY && @@ -256,7 +215,6 @@ export class VoxChunkSource extends BaseVolumeChunkSource { anyChunk.updateFromCpuData(gl); return; } - // Otherwise, just upload (don’t free first). chunk.copyToGPU(gl); } } diff --git a/src/voxel_annotation/index.ts b/src/voxel_annotation/index.ts index 8806942e5b..bd6ae8b80d 100644 --- a/src/voxel_annotation/index.ts +++ b/src/voxel_annotation/index.ts @@ -77,11 +77,11 @@ export abstract class VoxSourceWriter extends VoxSource { return []; } - callChunkReload(voxChunkKey: string) { + callChunkReload(voxChunkKeys: string[]) { if (!this.editController) { throw new Error("VoxSourceWriter.callChunkReload: editController not set"); } - this.editController.callChunkReload([voxChunkKey]); + this.editController.callChunkReload(voxChunkKeys); } // Common helpers diff --git a/src/voxel_annotation/local_source.ts b/src/voxel_annotation/local_source.ts index 4eb9142164..da76482002 100644 --- a/src/voxel_annotation/local_source.ts +++ b/src/voxel_annotation/local_source.ts @@ -466,6 +466,7 @@ export class LocalVoxSourceWriter extends VoxSourceWriter { //await this.markChildrenDirtyInTree(e.key); touchedKeys.add(e.key); } + console.log(`Applied ${edits.length} edits to ${touchedKeys.size} chunks`); for (const key of touchedKeys) { this.propagateDownsample(key); } @@ -497,7 +498,6 @@ export class LocalVoxSourceWriter extends VoxSourceWriter { console.log("Downsample step failed or was unnecessary, stopping cascade."); break; } - this.callChunkReload(targetKey); currentKey = targetKey; } } @@ -602,6 +602,7 @@ export class LocalVoxSourceWriter extends VoxSourceWriter { } await txDone(tx); this.saveTimer = undefined; + this.callChunkReload(keys); } private async getDb(): Promise { From 769f6cad16700a3f810389d82d811950a793df99 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 042/251] feat: add flood fill tool and export UI improvements - Introduce 2D flood fill tool with max voxels configuration and backend edits support. - Add flood fill settings to the tools panel for user customization. - Extend voxel annotation logic to handle flood fill operations. - Update Zarr export UI with refined placeholder, styles, and larger input field. --- src/layer/vox/tabs/settings.ts | 8 +- src/layer/vox/tabs/tools.ts | 61 ++++++++++- src/ui/voxel_annotations.ts | 44 +++++++- src/voxel_annotation/edit_backend.ts | 2 +- src/voxel_annotation/edit_controller.ts | 49 +++++++++ src/voxel_annotation/frontend.ts | 128 ++++++++++++++++++++++++ 6 files changed, 285 insertions(+), 7 deletions(-) diff --git a/src/layer/vox/tabs/settings.ts b/src/layer/vox/tabs/settings.ts index 80119e16fb..a51719580d 100644 --- a/src/layer/vox/tabs/settings.ts +++ b/src/layer/vox/tabs/settings.ts @@ -219,9 +219,11 @@ export class VoxSettingsTab extends Tab { // --- Export to Zarr (LOD 1 only) --- const exportUrlInput = document.createElement("input"); exportUrlInput.type = "text"; - exportUrlInput.placeholder = "Export base URL (http(s)://..., s3+http(s)://endpoint/bucket/prefix, or s3://bucket/prefix [AWS]) e.g. http://localhost:9000/zarr/mydataset/"; - exportUrlInput.size = 40; - + exportUrlInput.placeholder = "Export base URL"; + exportUrlInput.size = 80; + exportUrlInput.classList.add("neuroglancer-vox-status"); + exportUrlInput.style.marginLeft = "0"; +1 const exportButton = document.createElement("button"); exportButton.textContent = "Export to Zarr"; exportButton.title = "Exports current map LOD=1 chunks to a Zarr v2 dataset at path '0' under the provided base URL"; diff --git a/src/layer/vox/tabs/tools.ts b/src/layer/vox/tabs/tools.ts index 16df9c0abe..dd4fbba94b 100644 --- a/src/layer/vox/tabs/tools.ts +++ b/src/layer/vox/tabs/tools.ts @@ -2,7 +2,7 @@ * Vox Tool tab UI split from index.ts */ import type { VoxUserLayer } from "#src/layer/vox/index.js"; -import { VoxelBrushLegacyTool } from "#src/ui/voxel_annotations.js"; +import { VoxelBrushLegacyTool, VoxelFloodFillLegacyTool } from "#src/ui/voxel_annotations.js"; import { Tab } from "#src/widget/tab_view.js"; @@ -88,7 +88,15 @@ export class VoxToolTab extends Tab { this.layer.tool.value = new VoxelBrushLegacyTool(this.layer); }); + const floodButton = document.createElement("button"); + floodButton.textContent = "Flood fill"; + floodButton.title = "Click a voxel to flood fill the connected region on the current Z plane"; + floodButton.addEventListener("click", () => { + this.layer.tool.value = new VoxelFloodFillLegacyTool(this.layer); + }); + toolsWrap.appendChild(brushButton); + toolsWrap.appendChild(floodButton); toolsRow.appendChild(toolsLabel); toolsRow.appendChild(toolsWrap); toolbox.appendChild(toolsRow); @@ -200,6 +208,57 @@ export class VoxToolTab extends Tab { brushRow.appendChild(group); toolbox.appendChild(brushRow); + // Section: Flood fill settings + const floodRow = document.createElement("div"); + floodRow.className = "neuroglancer-vox-row"; + + const floodLabel = document.createElement("label"); + floodLabel.textContent = "Max fill voxels"; + const floodControls = document.createElement("div"); + floodControls.style.display = "flex"; + floodControls.style.alignItems = "center"; + floodControls.style.gap = "8px"; + + const floodMaxInput = document.createElement("input"); + floodMaxInput.type = "number"; + floodMaxInput.className = "neuroglancer-vox-input"; + floodMaxInput.min = "1"; + floodMaxInput.step = "1"; + + // Initialize with an explicit safe default if not set. + if (!Number.isFinite((this.layer as any).voxFloodMaxVoxels)) { + (this.layer as any).voxFloodMaxVoxels = 100000; + } + floodMaxInput.value = String((this.layer as any).voxFloodMaxVoxels); + + floodMaxInput.addEventListener("change", () => { + const v = Math.floor(Number(floodMaxInput.value)); + if (!Number.isFinite(v) || v <= 0) { + throw new Error("VoxToolTab: Invalid max fill voxels value"); + } + (this.layer as any).voxFloodMaxVoxels = v; + floodMaxInput.value = String(v); + }); + + floodControls.appendChild(floodMaxInput); + + const floodGroup = document.createElement("div"); + floodGroup.style.display = "grid"; + floodGroup.style.gridTemplateColumns = "minmax(120px,auto) 1fr"; + floodGroup.style.columnGap = "8px"; + floodGroup.style.rowGap = "8px"; + + const floodLabelCell = document.createElement("div"); + floodLabelCell.appendChild(floodLabel); + const floodControlsCell = document.createElement("div"); + floodControlsCell.appendChild(floodControls); + + floodGroup.appendChild(floodLabelCell); + floodGroup.appendChild(floodControlsCell); + + floodRow.appendChild(floodGroup); + toolbox.appendChild(floodRow); + // Section: Labels (moved to end, title on top for full width) const labelsSection = document.createElement("div"); labelsSection.style.display = "flex"; diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 5e4db78898..b7dabcb509 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -19,8 +19,9 @@ import type { VoxUserLayer } from "#src/layer/vox/index.js"; import { LegacyTool, registerLegacyTool } from "#src/ui/tool.js"; export const BRUSH_TOOL_ID = "voxBrush"; - -abstract class BaseVoxelLegacyTool extends LegacyTool { +export const FLOODFILL_TOOL_ID = "voxFloodFill"; + + abstract class BaseVoxelLegacyTool extends LegacyTool { protected isDrawing = false; protected lastPoint: Int32Array | undefined; protected mouseDisposer: (() => void) | undefined; @@ -193,9 +194,48 @@ export class VoxelBrushLegacyTool extends BaseVoxelLegacyTool { } } +export class VoxelFloodFillLegacyTool extends LegacyTool { + description = "flood fill (2D plane, 4-connected)"; + + toJSON() { + return FLOODFILL_TOOL_ID; + } + + trigger(mouseState: MouseSelectionState) { + const layer = this.layer as unknown as VoxUserLayer; + if (!mouseState?.active) return; + const pos = (layer as any).getVoxelPositionFromMouse?.(mouseState) as Float32Array | undefined; + if (!pos || pos.length < 3) { + throw new Error("VoxelFloodFillLegacyTool.trigger: failed to get voxel position from mouse"); + } + const value = layer.getCurrentLabelValue() ?? (layer.voxEraseMode ? 0 : 42); + const max = Number((layer as any).voxFloodMaxVoxels); + if (!Number.isFinite(max) || max <= 0) { + throw new Error("VoxelFloodFillLegacyTool.trigger: invalid max voxels; set it in the tool panel"); + } + const ctrl = (layer as any).voxEditController; + if (!ctrl) throw new Error("VoxelFloodFillLegacyTool.trigger: missing VoxelEditController on layer"); + const seed = new Float32Array([Math.floor(pos[0]), Math.floor(pos[1]), Math.floor(pos[2])]); + const { edits } = ctrl.floodFillPlane2D(seed, value >>> 0, Math.floor(max)); + if (edits.length === 0) return; + if (typeof ctrl.commitEdits === "function") { + ctrl.commitEdits(edits); + } else if ((ctrl as any).rpc && (ctrl as any).rpc.invoke) { + // Fallback commit path if helper is not present + (ctrl as any).rpc.invoke("VOX_EDIT_COMMIT_VOXELS", { rpcId: (ctrl as any).rpcId, edits }); + } else { + throw new Error("VoxelFloodFillLegacyTool.trigger: no way to commit edits"); + } + } +} + export function registerVoxelAnnotationTools() { registerLegacyTool( BRUSH_TOOL_ID, (layer) => new VoxelBrushLegacyTool(layer as unknown as VoxUserLayer), ); + registerLegacyTool( + FLOODFILL_TOOL_ID, + (layer) => new VoxelFloodFillLegacyTool(layer as unknown as VoxUserLayer), + ); } diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/edit_backend.ts index 8ce01468c2..6da0896b43 100644 --- a/src/voxel_annotation/edit_backend.ts +++ b/src/voxel_annotation/edit_backend.ts @@ -35,7 +35,7 @@ export class VoxelEditController extends SharedObject { size?: number[]; }[] = []; private commitDebounceTimer: number | undefined; - private readonly commitDebounceDelayMs: number = 200; + private readonly commitDebounceDelayMs: number = 300; constructor(rpc: RPC, options: any) { super(); diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 3dc01a414c..88d4dbfe35 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -182,6 +182,55 @@ export class VoxelEditController extends SharedObject { }); } + /** Commit helper for UI tools. */ + commitEdits(edits: { key: string; indices: number[] | Uint32Array; value?: number; values?: ArrayLike; size?: number[] }[]): void { + if (!this.rpc) throw new Error("VoxelEditController.commitEdits: RPC not initialized."); + if (!Array.isArray(edits)) { + throw new Error("VoxelEditController.commitEdits: edits must be an array."); + } + this.rpc.invoke(VOX_EDIT_COMMIT_VOXELS_RPC_ID, { + rpcId: this.rpcId, + edits, + }); + } + + /** + * Frontend 2D flood fill helper: computes on currently selected LOD and returns an edits payload + * suitable for VOX_EDIT_COMMIT_VOXELS without committing. Hard-cap deny semantics. + * The seed is simply the first clicked voxel in canonical/world units. + */ + floodFillPlane2D( + startPositionCanonical: Float32Array, + fillValue: number, + maxVoxels: number, + ): { edits: { key: string; indices: number[]; value: number }[]; filledCount: number; originalValue: number } { + if (!startPositionCanonical || startPositionCanonical.length < 3) { + throw new Error("VoxelEditController.floodFillPlane2D: startPositionCanonical must be Float32Array[3]."); + } + if (!Number.isFinite(maxVoxels) || maxVoxels <= 0) { + throw new Error("VoxelEditController.floodFillPlane2D: maxVoxels must be > 0."); + } + + // For V1 we use the minimum LOD (index 0) to keep behavior predictable. + const voxelSize = this.getOptimalVoxelSize(1); // will return min when restrictToMinLOD=true + const sourceIndex = Math.floor(Math.log2(voxelSize)); + const src2D = this.multiscale.getSources(this.getIdentitySliceViewSourceOptions()); + if (!src2D || !src2D[0] || src2D[0].length <= sourceIndex) { + throw new Error("VoxelEditController.floodFillPlane2D: No multiscale levels available."); + } + const source = src2D[0][sourceIndex]?.chunkSource as VoxChunkSource; + if (!source) throw new Error("VoxelEditController.floodFillPlane2D: Selected level has no chunk source."); + + // Convert canonical/world to level grid coordinates. + const startVoxelLod = new Float32Array([ + Math.floor((startPositionCanonical[0] ?? NaN) / voxelSize), + Math.floor((startPositionCanonical[1] ?? NaN) / voxelSize), + Math.floor((startPositionCanonical[2] ?? NaN) / voxelSize), + ]); + + return source.floodFillPlane2D(startVoxelLod, fillValue >>> 0, maxVoxels | 0); + } + callChunkReload(voxChunkKeys: string[]) { const src2D = this.multiscale.getSources(this.getIdentitySliceViewSourceOptions()); if (!src2D || !src2D[0] || src2D[0].length === 0) { diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index dcdb53061d..c85105164a 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -205,6 +205,134 @@ export class VoxChunkSource extends BaseVolumeChunkSource { return data ?? null; } + /** + * 2D flood fill on a z-constant plane using 4-connectivity. Operates only on loaded chunks. + * Throws if the seed chunk is not loaded, if the fill would exceed maxVoxels, or if it crosses + * into any unloaded chunk. Returns a backend edits payload and some stats. It enqueues all + * voxel updates during BFS and only applies them to the CPU arrays after the BFS completes + * successfully, then schedules GPU uploads for live feedback. + */ + floodFillPlane2D( + startVoxelLod: Float32Array, + fillValue: number, + maxVoxels: number, + ): { edits: { key: string; indices: number[]; value: number }[]; filledCount: number; originalValue: number } { + if (!startVoxelLod || startVoxelLod.length < 3) { + throw new Error("VoxChunkSource.floodFillPlane2D: startVoxelLod must be Float32Array[3]."); + } + if (!Number.isFinite(maxVoxels) || maxVoxels <= 0) { + throw new Error("VoxChunkSource.floodFillPlane2D: maxVoxels must be > 0."); + } + + const seed = new Float32Array([ + Math.floor(startVoxelLod[0] ?? NaN), + Math.floor(startVoxelLod[1] ?? NaN), + Math.floor(startVoxelLod[2] ?? NaN), + ]); + if (!Number.isFinite(seed[0]) || !Number.isFinite(seed[1]) || !Number.isFinite(seed[2])) { + throw new Error("VoxChunkSource.floodFillPlane2D: startVoxelLod contains invalid coordinates."); + } + + // Determine the target value at the seed from loaded CPU data. + const seedIdx = this.computeIndices(seed); + const seedChunk = this.chunks.get(seedIdx.key) as VolumeChunk | undefined; + const seedCpu = seedChunk ? this.getCpuArrayForChunk(seedChunk) : null; + if (!seedCpu || seedIdx.chunkLocalIndex < 0) { + throw new Error("VoxChunkSource.floodFillPlane2D: seed lies in an unloaded chunk or out of bounds."); + } + const originalValue = Number((seedCpu as any)[seedIdx.chunkLocalIndex] ?? NaN); + if (!Number.isFinite(originalValue)) { + throw new Error("VoxChunkSource.floodFillPlane2D: unable to read seed value."); + } + if ((originalValue >>> 0) === (fillValue >>> 0)) { + return { edits: [], filledCount: 0, originalValue }; + } + + const zPlane = seed[2] | 0; + const visited = new Set(); + const queue: [number, number][] = []; + const pushIfNew = (x: number, y: number) => { + const k = `${x},${y}`; + if (visited.has(k)) return false; + visited.add(k); + queue.push([x, y]); + return true; + }; + + pushIfNew(seed[0] | 0, seed[1] | 0); + + // Collect edits without touching CPU arrays until BFS succeeds. + const indicesByInnerKey = new Map(); // canonical indices for backend edits + const localIndicesByInnerKey = new Map(); // chunk-local indices for CPU painting + let filledCount = 0; + + // BFS 4-connected on (x,y) at fixed zPlane + while (queue.length > 0) { + const [x, y] = queue.shift()!; + // Check cap early + if (filledCount >= maxVoxels) { + throw new Error(`VoxChunkSource.floodFillPlane2D: region exceeds maxVoxels (${maxVoxels}).`); + } + + const voxel = new Float32Array([x, y, zPlane]); + const { key, canonicalIndex, chunkLocalIndex } = this.computeIndices(voxel); + const chunk = this.chunks.get(key) as VolumeChunk | undefined; + const cpu = chunk ? this.getCpuArrayForChunk(chunk) : null; + if (!cpu || chunkLocalIndex < 0) { + throw new Error( + `VoxChunkSource.floodFillPlane2D: encountered unloaded chunk at (${x},${y},${zPlane}) key=${key}.`, + ); + } + + const currentValue = Number((cpu as any)[chunkLocalIndex]); + if ((currentValue >>> 0) !== (originalValue >>> 0)) { + continue; // boundary + } + + // Enqueue to apply after BFS completes. + let arr = indicesByInnerKey.get(key); + if (!arr) indicesByInnerKey.set(key, (arr = [])); + arr.push(canonicalIndex); + + let locals = localIndicesByInnerKey.get(key); + if (!locals) localIndicesByInnerKey.set(key, (locals = [])); + locals.push(chunkLocalIndex); + + filledCount++; + + // 4-neighbors + pushIfNew(x + 1, y); + pushIfNew(x - 1, y); + pushIfNew(x, y + 1); + pushIfNew(x, y - 1); + } + + // If we get here, BFS succeeded. Now apply to CPU arrays and schedule uploads. + const chunksToUpdate = new Set(); + for (const [innerKey, localIndices] of localIndicesByInnerKey.entries()) { + const chunk = this.chunks.get(innerKey) as VolumeChunk | undefined; + const cpu = chunk ? this.getCpuArrayForChunk(chunk) : null; + if (!cpu) { + throw new Error(`VoxChunkSource.floodFillPlane2D: missing CPU array when applying updates for key=${innerKey}`); + } + for (const li of localIndices) { + (cpu as any)[li] = fillValue as any; + } + chunksToUpdate.add(innerKey); + } + + for (const key of chunksToUpdate) this.scheduleUpdate(key); + + // Build backend edits payload using full keys (including LOD) + const edits: { key: string; indices: number[]; value: number }[] = []; + for (const [innerKey, indices] of indicesByInnerKey.entries()) { + const fullKey = makeVoxChunkKey(innerKey, this.lodFactor); + edits.push({ key: fullKey, indices, value: fillValue >>> 0 }); + } + + return { edits, filledCount, originalValue: originalValue >>> 0 }; + } + private invalidateChunkUpload(chunk: VolumeChunk) { const gl = chunk.gl; const anyChunk = chunk as any; From 1de7c59eb6b464a8a2823ef75a1b0f8cfa9a1083 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 043/251] feat: implement downscale job queue and improve chunk reload handling - Add serialized downscale job queue for processing cascades without blocking callers. - Integrate delayed chunk reload mechanism to batch reload operations efficiently. - Resolve errors in downsampling cascade by ensuring proper key handling and logging. - Enhance cache invalidate pipeline to improve responsiveness. - Include placeholder for enhanced Zarr import/export with dual-source support. --- NOTES/TODOs.md | 28 ++++++--------- src/voxel_annotation/local_source.ts | 54 ++++++++++++++++++++++++---- 2 files changed, 58 insertions(+), 24 deletions(-) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index 13984f12d1..a9348f0467 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,10 +1,7 @@ # TODO List -- LOD -> where am I: - - choosing the lod level depanding on the brush size, live rendering is working local data saving is working. - - there are some performance issues at very high brush sizes (>1000) - - fix the redownload trigger from the backend to the front -> making VoxLayer a true singleton +- LOD -> - "feat: dirty tree upscaling is kinda working, at lea st enough to conclude that this upscaling method wont work due to unsolvable conficts and lost unavoidable lost of qua @@ -12,26 +9,21 @@ will be to enqueue every upscale and downscale and throttl e the user when the queue is too full, with some kind of in dicator in the ui. We also may need to restrict the max bru - sh size to avoid too long waiting time." + sh size to avoid too long waiting time." -> no upscaling for now (e.g. drawn voxel size/lod level is always 1), only downscaling. - cleanup label handling code (more specifically in the ui code: layer/vox/index.ts, would be nice to have a handler similar to the one for maps) -- Add redundancy to avoid corrupt/unsaved chunks on the remote -- Test token authentication -- Try to import pre-computed segmentation (in zarr format) into the remote server - continue to study the segmentation compression, using it should greatly reduce the ram and indexDB usage, but it no easy integration of the hot chunk reloading in the frontend for drawing tool responsiveness has been found. -- add flood fill tool (with a max expansion safeguard), this tool should be 2d (e.g. act on a plane, the plane normal to the z axis is sufficient for a v1) - Fix the orientation of the disk in the brush tool -- the uncaching of chunks the VoxSource is working great, but since it has no way of knowing which chunks are in view, it will delete them, causing flickering of the drawings. +- Add support for flood fill on different planes - look into the massive ram usage when a lot of voxel annotations are drawn - Add Uint64 support for annotation id -- Replace the current map settings to use the built-ins of neuroglancer (viewable under the datasource url), handle multimap with link choices, look into how to keep the init/creation logic. -- adapt the brush size to the zoom level linearly - - - - - - +-? Replace the current map settings to use the built-ins of neuroglancer (viewable under the datasource url), handle multimap with link choices, look into how to keep the init/creation logic. +-? adapt the brush size to the zoom level linearly +- Flood fill do not work at the bounds of the layer +- need to fix this cache invalidation pipeline, it is not responsive enough +- THERE MAY BE A CASE WHERE CHUNK GETS CORRUPTED/DELETED -> observed when drawing zoomed out with a big brush, previous drawing got erased but only their high resolution mipmap, the low resolution mipmap is still there. +- save all the lod level in the zarr export +- add a zarr import feature or even better design a dual source system, where you have the zarr source with most of the data and the indexedDB where the updates are stored, this would be an augmented version of the current localsource which would first look into the indexedDB and if not present, fetch the zarr source. # Saving/importing/exporting diff --git a/src/voxel_annotation/local_source.ts b/src/voxel_annotation/local_source.ts index da76482002..2b9e19fa92 100644 --- a/src/voxel_annotation/local_source.ts +++ b/src/voxel_annotation/local_source.ts @@ -472,6 +472,14 @@ export class LocalVoxSourceWriter extends VoxSourceWriter { } } + private chunksToReload = new Set(); + private DELAY_BEFORE_CHUNK_RELOAD = 100; + + // Downscale job queue to serialize downsampling cascades + private downscaleQueue: string[] = []; + private downscaleQueuedKeys = new Set(); + private isProcessingDownscaleQueue = false; + /** * Public entry point to start the downsampling cascade for a modified chunk. * @param sourceKey The key of the chunk that was edited. @@ -479,23 +487,52 @@ export class LocalVoxSourceWriter extends VoxSourceWriter { public async propagateDownsample(sourceKey: string): Promise { const keyInfo = parseVoxChunkKey(sourceKey); if (!keyInfo || !this.mapCfg) return; + if (!this.downscaleQueuedKeys.has(sourceKey)) { + this.downscaleQueuedKeys.add(sourceKey); + this.downscaleQueue.push(sourceKey); + } + // Trigger processor but do not await full drain here to avoid blocking callers + void this._processDownscaleQueue(); + } + private async _processDownscaleQueue(): Promise { + if (this.isProcessingDownscaleQueue) return; + this.isProcessingDownscaleQueue = true; + try { + while (this.downscaleQueue.length > 0) { + const nextKey = this.downscaleQueue.shift(); + if (nextKey === undefined) { + throw new Error("Downscale queue returned undefined key"); + } + this.downscaleQueuedKeys.delete(nextKey); + try { + await this._performDownsampleCascade(nextKey); + } catch (err) { + console.error("Downscale job failed for key", nextKey, err); + } + } + } finally { + this.isProcessingDownscaleQueue = false; + } + } + + private async _performDownsampleCascade(sourceKey: string): Promise { + if (!this.mapCfg) return; // Assuming cubic chunks const chunkSize = this.mapCfg.chunkDataSize[0]; const maxPasses = calculateDownsamplePasses(chunkSize); const maxLOD = this.mapCfg.steps[this.mapCfg.steps.length - 1]; - let currentKey = sourceKey; + let currentKey: string | null = sourceKey; for (let i = 0; i < maxPasses; i++) { - const currentKeyInfo = parseVoxChunkKey(currentKey)!; + if (!currentKey) break; + const currentKeyInfo = parseVoxChunkKey(currentKey); + if (!currentKeyInfo) break; if (currentKeyInfo.lod >= maxLOD) { - console.log(`Reached max LOD ${maxLOD}, stopping downsample.`); break; } - const targetKey = await this._downsampleStep(currentKey); if (!targetKey) { - console.log("Downsample step failed or was unnecessary, stopping cascade."); break; } currentKey = targetKey; @@ -546,6 +583,7 @@ export class LocalVoxSourceWriter extends VoxSourceWriter { this.saved.set(targetKey, targetChunk); this.markDirty(targetKey); + this.chunksToReload.add(targetKey); // Upscaling halted //await this.setDirtyTreeFlag(targetKey, false); return targetKey; @@ -602,7 +640,11 @@ export class LocalVoxSourceWriter extends VoxSourceWriter { } await txDone(tx); this.saveTimer = undefined; - this.callChunkReload(keys); + const toReload = Array.from(this.chunksToReload); + this.chunksToReload.clear(); + setTimeout(() => { + this.callChunkReload(toReload); + }, this.DELAY_BEFORE_CHUNK_RELOAD); } private async getDb(): Promise { From a030b50dc04110699b0da2e7c52bb8fa358f2aae Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 044/251] feat: enhance flood fill stability and optimize Zarr export - Refactor flood fill logic to apply thickness constraints, improving behavior around narrow passages. - Introduce dynamic neighborhood growth thresholds for adaptive region filling. - Replace single LOD logic in Zarr export with multi-LOD handling, including metadata and chunk grouping. - Add detailed progress logging for flood fill and Zarr export workflows. - Optimize chunk saving by skipping all-zero segments and redundant dirty markers. --- NOTES/TODOs.md | 1 - src/ui/voxel_annotations.ts | 11 +- src/voxel_annotation/edit_controller.ts | 5 + src/voxel_annotation/export_to_zarr.ts | 123 +++++++------- src/voxel_annotation/frontend.ts | 215 ++++++++++++++++++------ src/voxel_annotation/local_source.ts | 16 +- 6 files changed, 257 insertions(+), 114 deletions(-) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index a9348f0467..50a2c8706a 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,6 +1,5 @@ # TODO List - - LOD -> - "feat: dirty tree upscaling is kinda working, at lea st enough to conclude that this upscaling method wont work diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index b7dabcb509..2c25d2a93b 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -203,7 +203,10 @@ export class VoxelFloodFillLegacyTool extends LegacyTool { trigger(mouseState: MouseSelectionState) { const layer = this.layer as unknown as VoxUserLayer; - if (!mouseState?.active) return; + if (!mouseState?.active) { + console.info("[VoxFloodFill] trigger ignored: mouse inactive"); + return; + } const pos = (layer as any).getVoxelPositionFromMouse?.(mouseState) as Float32Array | undefined; if (!pos || pos.length < 3) { throw new Error("VoxelFloodFillLegacyTool.trigger: failed to get voxel position from mouse"); @@ -216,13 +219,17 @@ export class VoxelFloodFillLegacyTool extends LegacyTool { const ctrl = (layer as any).voxEditController; if (!ctrl) throw new Error("VoxelFloodFillLegacyTool.trigger: missing VoxelEditController on layer"); const seed = new Float32Array([Math.floor(pos[0]), Math.floor(pos[1]), Math.floor(pos[2])]); - const { edits } = ctrl.floodFillPlane2D(seed, value >>> 0, Math.floor(max)); + console.info("[VoxFloodFill] starting flood fill", { seed: Array.from(seed), value: value >>> 0, max: Math.floor(max) }); + const { edits, filledCount } = ctrl.floodFillPlane2D(seed, value >>> 0, Math.floor(max)); + console.info("[VoxFloodFill] BFS completed", { filledCount, editsByChunk: edits.length }); if (edits.length === 0) return; if (typeof ctrl.commitEdits === "function") { ctrl.commitEdits(edits); + console.info("[VoxFloodFill] committed edits"); } else if ((ctrl as any).rpc && (ctrl as any).rpc.invoke) { // Fallback commit path if helper is not present (ctrl as any).rpc.invoke("VOX_EDIT_COMMIT_VOXELS", { rpcId: (ctrl as any).rpcId, edits }); + console.info("[VoxFloodFill] committed edits via fallback path"); } else { throw new Error("VoxelFloodFillLegacyTool.trigger: no way to commit edits"); } diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 88d4dbfe35..e2fbcc59e3 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -204,6 +204,11 @@ export class VoxelEditController extends SharedObject { fillValue: number, maxVoxels: number, ): { edits: { key: string; indices: number[]; value: number }[]; filledCount: number; originalValue: number } { + console.info("[VoxFloodFill] controller call", { + start: Array.from(startPositionCanonical || []), + fillValue: fillValue >>> 0, + maxVoxels: maxVoxels | 0, + }); if (!startPositionCanonical || startPositionCanonical.length < 3) { throw new Error("VoxelEditController.floodFillPlane2D: startPositionCanonical must be Float32Array[3]."); } diff --git a/src/voxel_annotation/export_to_zarr.ts b/src/voxel_annotation/export_to_zarr.ts index 507f9e5e95..ac4e494e49 100644 --- a/src/voxel_annotation/export_to_zarr.ts +++ b/src/voxel_annotation/export_to_zarr.ts @@ -35,18 +35,19 @@ export function exportVoxToZarr(targetUrl: string, mapConfig: VoxMapConfig): () const { baseUrl } = normalizeBaseUrl(targetUrl); - // Start the export asynchronously; return a progress getter immediately. void (async () => { try { const db = await openVoxDb(); const mapId = String(mapConfig.id); - // Pre-count LOD=1 chunks for this map in IndexedDB. - const lod1Count = await countLod1Chunks(db, mapId); + // First pass: count chunks per LOD and collect present LODs + const { countsByLod, totalCount } = await countAllLodChunks(db, mapId); + const presentLods = Array.from(countsByLod.keys()).sort((a, b) => a - b); + const lodsToWrite = presentLods.length > 0 ? presentLods : [1]; - // We will write: root .zgroup, root .zattrs, 0/.zarray, 0/.zattrs, and N chunks. - const metadataFiles = 4; - const totalWrites = metadataFiles + lod1Count; + // We will write: root .zgroup + root .zattrs + per-lod (.zarray + .zattrs) + all chunks + const metadataFiles = 2 + lodsToWrite.length * 2; + const totalWrites = metadataFiles + totalCount; let writesCompleted = 0; const updateProgress = () => { if (totalWrites <= 0) { @@ -59,38 +60,37 @@ export function exportVoxToZarr(targetUrl: string, mapConfig: VoxMapConfig): () }; }; - // Write minimal Zarr v2 metadata - const { shapeZYX, chunksZYX, dtype } = deriveZarrMetadata(mapConfig); - // Root group + // Root metadata await putJson(joinUrl(baseUrl, ".zgroup"), { zarr_format: 2 }); writesCompleted++; updateProgress(); - await putJson(joinUrl(baseUrl, ".zattrs"), buildRootZattrs(mapConfig)); + await putJson(joinUrl(baseUrl, ".zattrs"), buildRootZattrsForLods(mapConfig, lodsToWrite)); writesCompleted++; updateProgress(); - // Array at path "0" - const arrayBase = joinUrl(baseUrl, "0/"); - const zarray = { - zarr_format: 2, - shape: shapeZYX, - chunks: chunksZYX, - dtype, - order: "C", - fill_value: 0, - filters: [] as unknown as [], // Explicitly no filters - compressor: null as unknown as null, // Explicitly no compressor - dimension_separator: ".", - }; - await putJson(joinUrl(arrayBase, ".zarray"), zarray); - writesCompleted++; updateProgress(); - await putJson(joinUrl(arrayBase, ".zattrs"), { _ARRAY_DIMENSIONS: ["z", "y", "x"] }); - writesCompleted++; updateProgress(); + // Per-LOD arrays + for (const lod of lodsToWrite) { + const { shapeZYX, chunksZYX, dtype } = deriveZarrMetadataForLod(mapConfig, lod); + const arrayBase = joinUrl(baseUrl, `${lod}/`); + const zarray = { + zarr_format: 2, + shape: shapeZYX, + chunks: chunksZYX, + dtype, + order: "C", + fill_value: 0, + filters: [] as unknown as [], + compressor: null as unknown as null, + dimension_separator: ".", + }; + await putJson(joinUrl(arrayBase, ".zarray"), zarray); + writesCompleted++; updateProgress(); + await putJson(joinUrl(arrayBase, ".zattrs"), { _ARRAY_DIMENSIONS: ["z", "y", "x"] }); + writesCompleted++; updateProgress(); + } - // Stream chunks: single pass to read+upload each LOD=1 chunk. - await iterateLod1Chunks(db, mapId, async ({ x, y, z, value }) => { - // Zarr v2 chunk file name uses axis order; we store array as [Z, Y, X], so name is z.y.x - const chunkRelPath = `0/${z}.${y}.${x}`; + // Second pass: upload chunks grouped by their LOD + await iterateAllLodChunks(db, mapId, async ({ lod, x, y, z, value }) => { + const chunkRelPath = `${lod}/${z}.${y}.${x}`; const chunkUrl = joinUrl(baseUrl, chunkRelPath); - // Ensure we upload the exact buffer contents. const buf = ensureArrayBuffer(value); await putBinary(chunkUrl, buf); writesCompleted++; updateProgress(); @@ -208,6 +208,17 @@ function deriveZarrMetadata(mapCfg: VoxMapConfig): { shapeZYX: number[]; chunksZ return { shapeZYX, chunksZYX, dtype }; } +function deriveZarrMetadataForLod(mapCfg: VoxMapConfig, lod: number): { shapeZYX: number[]; chunksZYX: number[]; dtype: string } { + if (!Number.isFinite(lod) || lod <= 0) throw new Error("deriveZarrMetadataForLod: lod must be positive"); + const base = deriveZarrMetadata(mapCfg); + const shapeZYX = [ + Math.max(1, Math.ceil(base.shapeZYX[0] / lod)), + Math.max(1, Math.ceil(base.shapeZYX[1] / lod)), + Math.max(1, Math.ceil(base.shapeZYX[2] / lod)), + ]; + return { shapeZYX, chunksZYX: base.chunksZYX, dtype: base.dtype }; +} + function toZarrDtype(dt: number): string { switch (dt) { case DataType.UINT32: @@ -251,7 +262,7 @@ function toOmeLongUnit(unit: string): string { } } -function buildRootZattrs(mapCfg: VoxMapConfig): unknown { +function buildRootZattrsForLods(mapCfg: VoxMapConfig, lods: number[]): unknown { const rawUnit = String(mapCfg.unit); const scale = mapCfg.scaleMeters as unknown as ArrayLike; if (scale == null || (scale as any).length < 3) { @@ -265,9 +276,13 @@ function buildRootZattrs(mapCfg: VoxMapConfig): unknown { if (!Number.isFinite(sx) || !Number.isFinite(sy) || !Number.isFinite(sz)) { throw new Error("scaleMeters contains non-finite values"); } - // coordinateTransformations.scale expects values in the units specified by axes[].unit. - // We therefore convert meter-based voxel sizes to that unit by dividing by meters-per-unit. - const scaleZYX = [sz / mPer, sy / mPer, sx / mPer]; + const baseScaleZYX = [sz / mPer, sy / mPer, sx / mPer]; + const datasets = lods.map((lod) => ({ + path: String(lod), + coordinateTransformations: [ + { type: "scale", scale: [baseScaleZYX[0] * lod, baseScaleZYX[1] * lod, baseScaleZYX[2] * lod] }, + ], + })); return { multiscales: [ { @@ -277,22 +292,16 @@ function buildRootZattrs(mapCfg: VoxMapConfig): unknown { { name: "y", type: "space", unit: omeUnit }, { name: "x", type: "space", unit: omeUnit }, ], - datasets: [ - { - path: "0", - coordinateTransformations: [ - { type: "scale", scale: scaleZYX }, - ], - }, - ], + datasets, }, ], } as const; } -async function countLod1Chunks(db: IDBDatabase, mapId: string): Promise { - return new Promise((resolve, reject) => { - let count = 0; +async function countAllLodChunks(db: IDBDatabase, mapId: string): Promise<{ countsByLod: Map; totalCount: number }> { + return new Promise((resolve, reject) => { + const countsByLod = new Map(); + let totalCount = 0; const tx = db.transaction("chunks", "readonly"); const store = tx.objectStore("chunks"); const req = (store as any).openKeyCursor ? (store as any).openKeyCursor() : (store as any).openCursor(); @@ -300,7 +309,7 @@ async function countLod1Chunks(db: IDBDatabase, mapId: string): Promise req.onsuccess = (ev: any) => { const cursor: IDBCursor | IDBCursorWithValue | null = ev.target.result; if (!cursor) { - resolve(count); + resolve({ countsByLod, totalCount }); return; } const key = String(cursor.key); @@ -308,8 +317,10 @@ async function countLod1Chunks(db: IDBDatabase, mapId: string): Promise if (key.startsWith(prefix)) { const voxKey = key.substring(prefix.length); const info = parseVoxChunkKey(voxKey); - if (info && info.lod === 1) { - count++; + if (info) { + const c = countsByLod.get(info.lod) ?? 0; + countsByLod.set(info.lod, c + 1); + totalCount++; } } cursor.continue(); @@ -317,10 +328,10 @@ async function countLod1Chunks(db: IDBDatabase, mapId: string): Promise }); } -async function iterateLod1Chunks( +async function iterateAllLodChunks( db: IDBDatabase, mapId: string, - onChunk: (args: { x: number; y: number; z: number; value: ArrayBuffer }) => Promise, + onChunk: (args: { lod: number; x: number; y: number; z: number; value: ArrayBuffer }) => Promise, ): Promise { const pendingUploads: Promise[] = []; await new Promise((resolve, reject) => { @@ -331,7 +342,6 @@ async function iterateLod1Chunks( req.onsuccess = (ev: any) => { const cursor: IDBCursorWithValue | null = ev.target.result; if (!cursor) { - // All matching entries queued; wait for uploads after this promise resolves. resolve(); return; } @@ -341,22 +351,19 @@ async function iterateLod1Chunks( if (key.startsWith(prefix)) { const voxKey = key.substring(prefix.length); const info = parseVoxChunkKey(voxKey); - if (info && info.lod === 1) { + if (info) { const value = cursor.value as ArrayBuffer; - // Clone the buffer so we can close the IDB transaction before uploading. const cloned = value.slice(0); - const uploadPromise = onChunk({ x: info.x, y: info.y, z: info.z, value: cloned }); + const uploadPromise = onChunk({ lod: info.lod, x: info.x, y: info.y, z: info.z, value: cloned }); pendingUploads.push(uploadPromise); } } - // Important: continue the cursor synchronously; do not await before calling continue. cursor.continue(); } catch (e) { reject(e); } }; }); - // Ensure all queued uploads complete and propagate the first error if any. for (const p of pendingUploads) { await p; } diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index c85105164a..d5ce54c33a 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -186,16 +186,11 @@ export class VoxChunkSource extends BaseVolumeChunkSource { const chunk = this.chunks.get(key) as VolumeChunk | undefined; let chunkLocalIndex = -1; - const cds = (chunk?.chunkDataSize as Uint32Array) ?? null; - if (cds) { + if (chunk) { + const cds = chunk.chunkDataSize as Uint32Array; if (local[0] < cds[0] && local[1] < cds[1] && local[2] < cds[2]) { chunkLocalIndex = this.localIndexFromLocalPosition(local, cds); } - } else { - chunkLocalIndex = this.localIndexFromLocalPosition( - local, - this.spec.chunkDataSize as Uint32Array, - ); } return { key, canonicalIndex, chunkLocalIndex }; } @@ -205,12 +200,19 @@ export class VoxChunkSource extends BaseVolumeChunkSource { return data ?? null; } + + private morphologicalConfig = { + // At what `filledCount` thresholds the neighborhood size increases. + growthThresholds: [ + { count: 1000, size: 3 }, // Requires 3px thick channels + { count: 10000, size: 5 }, // Requires 5px thick channels + { count: 100000, size: 7 }, // Requires 7px thick channels + ], + maxSize: 9, + }; + /** - * 2D flood fill on a z-constant plane using 4-connectivity. Operates only on loaded chunks. - * Throws if the seed chunk is not loaded, if the fill would exceed maxVoxels, or if it crosses - * into any unloaded chunk. Returns a backend edits payload and some stats. It enqueues all - * voxel updates during BFS and only applies them to the CPU arrays after the BFS completes - * successfully, then schedules GPU uploads for live feedback. + * 2D flood fill with thickness constraint to prevent leaking through narrow passages */ floodFillPlane2D( startVoxelLod: Float32Array, @@ -229,17 +231,18 @@ export class VoxChunkSource extends BaseVolumeChunkSource { Math.floor(startVoxelLod[1] ?? NaN), Math.floor(startVoxelLod[2] ?? NaN), ]); + if (!Number.isFinite(seed[0]) || !Number.isFinite(seed[1]) || !Number.isFinite(seed[2])) { throw new Error("VoxChunkSource.floodFillPlane2D: startVoxelLod contains invalid coordinates."); } - // Determine the target value at the seed from loaded CPU data. const seedIdx = this.computeIndices(seed); const seedChunk = this.chunks.get(seedIdx.key) as VolumeChunk | undefined; const seedCpu = seedChunk ? this.getCpuArrayForChunk(seedChunk) : null; if (!seedCpu || seedIdx.chunkLocalIndex < 0) { throw new Error("VoxChunkSource.floodFillPlane2D: seed lies in an unloaded chunk or out of bounds."); } + const originalValue = Number((seedCpu as any)[seedIdx.chunkLocalIndex] ?? NaN); if (!Number.isFinite(originalValue)) { throw new Error("VoxChunkSource.floodFillPlane2D: unable to read seed value."); @@ -251,45 +254,31 @@ export class VoxChunkSource extends BaseVolumeChunkSource { const zPlane = seed[2] | 0; const visited = new Set(); const queue: [number, number][] = []; - const pushIfNew = (x: number, y: number) => { - const k = `${x},${y}`; - if (visited.has(k)) return false; - visited.add(k); - queue.push([x, y]); - return true; - }; - - pushIfNew(seed[0] | 0, seed[1] | 0); - - // Collect edits without touching CPU arrays until BFS succeeds. - const indicesByInnerKey = new Map(); // canonical indices for backend edits - const localIndicesByInnerKey = new Map(); // chunk-local indices for CPU painting + const indicesByInnerKey = new Map(); + const localIndicesByInnerKey = new Map(); let filledCount = 0; - // BFS 4-connected on (x,y) at fixed zPlane - while (queue.length > 0) { - const [x, y] = queue.shift()!; - // Check cap early - if (filledCount >= maxVoxels) { - throw new Error(`VoxChunkSource.floodFillPlane2D: region exceeds maxVoxels (${maxVoxels}).`); - } - - const voxel = new Float32Array([x, y, zPlane]); - const { key, canonicalIndex, chunkLocalIndex } = this.computeIndices(voxel); + const isOriginalAt = (px: number, py: number): boolean => { + const voxel = new Float32Array([px, py, zPlane]); + const { key, chunkLocalIndex } = this.computeIndices(voxel); const chunk = this.chunks.get(key) as VolumeChunk | undefined; const cpu = chunk ? this.getCpuArrayForChunk(chunk) : null; if (!cpu || chunkLocalIndex < 0) { - throw new Error( - `VoxChunkSource.floodFillPlane2D: encountered unloaded chunk at (${x},${y},${zPlane}) key=${key}.`, - ); + // For thickness checking, treat unloaded as non-original (conservative approach) + return false; } + const v = Number((cpu as any)[chunkLocalIndex]); + return (v >>> 0) === (originalValue >>> 0); + }; - const currentValue = Number((cpu as any)[chunkLocalIndex]); - if ((currentValue >>> 0) !== (originalValue >>> 0)) { - continue; // boundary + const scheduleFill = (x: number, y: number) => { + if (filledCount >= maxVoxels) { + throw new Error(`VoxChunkSource.floodFillPlane2D: region exceeds maxVoxels (${maxVoxels}).`); } - // Enqueue to apply after BFS completes. + const voxel = new Float32Array([x, y, zPlane]); + const { key, canonicalIndex, chunkLocalIndex } = this.computeIndices(voxel); + let arr = indicesByInnerKey.get(key); if (!arr) indicesByInnerKey.set(key, (arr = [])); arr.push(canonicalIndex); @@ -297,23 +286,145 @@ export class VoxChunkSource extends BaseVolumeChunkSource { let locals = localIndicesByInnerKey.get(key); if (!locals) localIndicesByInnerKey.set(key, (locals = [])); locals.push(chunkLocalIndex); - filledCount++; + }; - // 4-neighbors - pushIfNew(x + 1, y); - pushIfNew(x - 1, y); - pushIfNew(x, y + 1); - pushIfNew(x, y - 1); + const getCurrentThickness = (): number => { + let thickness = 1; + for (const threshold of this.morphologicalConfig.growthThresholds) { + if (filledCount >= threshold.count) { + thickness = Math.max(thickness, threshold.size); + } + } + return Math.min(thickness, this.morphologicalConfig.maxSize); + }; + + const hasThickEnoughChannel = ( + x: number, + y: number, + nx: number, + ny: number, + requiredThickness: number + ): boolean => { + if (requiredThickness <= 1) return true; // No thickness constraint + + const dx = nx - x; + const dy = ny - y; + + // Only allow exactly one-axis moves (4-connectivity) + if ((dx === 0) === (dy === 0)) return false; + + const halfThickness = Math.floor(requiredThickness / 2); + + if (dx !== 0) { + // Horizontal move: check vertical thickness at BOTH current and destination + // We need the channel to be thick enough along the entire path + for (const checkX of [x, nx]) { + for (let offset = -halfThickness; offset <= halfThickness; offset++) { + if (!isOriginalAt(checkX, ny + offset)) { + return false; // Channel not thick enough + } + } + } + } else { + // Vertical move: check horizontal thickness at BOTH current and destination + for (const checkY of [y, ny]) { + for (let offset = -halfThickness; offset <= halfThickness; offset++) { + if (!isOriginalAt(nx + offset, checkY)) { + return false; // Channel not thick enough + } + } + } + } + + return true; + }; + + const fillBorderRegion = ( + startX: number, + startY: number, + requiredThickness: number + ) => { + const subQueue: [number, number][] = []; + const halfThickness = Math.floor(requiredThickness / 2); + + const k = `${startX},${startY}`; + if (visited.has(k)) return; + + subQueue.push([startX, startY]); + visited.add(k); // Mark as visited immediately to avoid re-processing + + while (subQueue.length > 0) { + const [cx, cy] = subQueue.shift()!; + scheduleFill(cx, cy); // Schedule the current pixel of the sub-fill + + const neighbors: [number, number][] = [[cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]]; + for (const [nnx, nny] of neighbors) { + if (nnx < startX - halfThickness || nnx > startX + halfThickness || + nny < startY - halfThickness || nny > startY + halfThickness) { + continue; // Outside the bounding box + } + const nk = `${nnx},${nny}`; + if (visited.has(nk)) continue; + + if (isOriginalAt(nnx, nny)) { + visited.add(nk); + subQueue.push([nnx, nny]); + } + } + } + }; + + // Seed the queue + queue.push([seed[0] | 0, seed[1] | 0]); + visited.add(`${seed[0] | 0},${seed[1] | 0}`); + + // BFS with thickness constraints + while (queue.length > 0) { + const [x, y] = queue.shift()!; + + // Schedule this pixel for filling + scheduleFill(x, y); + + // Get current thickness requirement + const requiredThickness = getCurrentThickness(); + + // Check 4-neighbors + const neighbors: [number, number][] = [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]; + for (const [nx, ny] of neighbors) { + const k = `${nx},${ny}`; + if (visited.has(k)) continue; + + // Check if neighbor chunk is loaded + const nVoxel = new Float32Array([nx, ny, zPlane]); + const { key, chunkLocalIndex } = this.computeIndices(nVoxel); + const chunk = this.chunks.get(key) as VolumeChunk | undefined; + const cpu = chunk ? this.getCpuArrayForChunk(chunk) : null; + + if (!cpu || chunkLocalIndex < 0) { + throw new Error("VoxChunkSource.floodFillPlane2D: encountered adjacency to unloaded chunk; aborting to avoid partial fill."); + } + + if (isOriginalAt(nx, ny)) { + // The neighbor is a valid fill target. Now check if we can propagate from it. + if (hasThickEnoughChannel(x, y, nx, ny, requiredThickness)) { + // Channel is thick enough: Add to queue to propagate. + visited.add(k); + queue.push([nx, ny]); + } else { + fillBorderRegion(nx, ny, requiredThickness); + } + } + } } - // If we get here, BFS succeeded. Now apply to CPU arrays and schedule uploads. + // Apply changes to CPU arrays and schedule updates const chunksToUpdate = new Set(); for (const [innerKey, localIndices] of localIndicesByInnerKey.entries()) { const chunk = this.chunks.get(innerKey) as VolumeChunk | undefined; const cpu = chunk ? this.getCpuArrayForChunk(chunk) : null; if (!cpu) { - throw new Error(`VoxChunkSource.floodFillPlane2D: missing CPU array when applying updates for key=${innerKey}`); + throw new Error(`VoxChunkSource.floodFillPlane2D: missing CPU array for key=${innerKey}`); } for (const li of localIndices) { (cpu as any)[li] = fillValue as any; @@ -323,7 +434,7 @@ export class VoxChunkSource extends BaseVolumeChunkSource { for (const key of chunksToUpdate) this.scheduleUpdate(key); - // Build backend edits payload using full keys (including LOD) + // Build backend edits payload const edits: { key: string; indices: number[]; value: number }[] = []; for (const [innerKey, indices] of indicesByInnerKey.entries()) { const fullKey = makeVoxChunkKey(innerKey, this.lodFactor); diff --git a/src/voxel_annotation/local_source.ts b/src/voxel_annotation/local_source.ts index 2b9e19fa92..6feb558900 100644 --- a/src/voxel_annotation/local_source.ts +++ b/src/voxel_annotation/local_source.ts @@ -440,7 +440,6 @@ export class LocalVoxSourceWriter extends VoxSourceWriter { sc = { data: arr, size: new Uint32Array(sz) }; this.saved.set(key, sc); this.enforceCap(); - this.markDirty(key); return sc; } @@ -455,6 +454,11 @@ export class LocalVoxSourceWriter extends VoxSourceWriter { ) { const touchedKeys = new Set(); for (const e of edits) { + const count = (e.indices as any)?.length | 0; + if (count <= 0) { + // No actual edits for this key. Do not allocate, do not mark dirty. + continue; + } const sc = await this.ensureChunk( e.key, e.size ? new Uint32Array(e.size) : (this.mapCfg!.chunkDataSize as any), @@ -636,6 +640,9 @@ export class LocalVoxSourceWriter extends VoxSourceWriter { for (const key of keys) { const sc = this.saved.get(key); if (!sc) continue; + if (this._isAllZero(sc.data)) + continue; + await idbPut(store, sc.data.buffer, compositeChunkDbKey(this.mapId, key)); } await txDone(tx); @@ -652,6 +659,13 @@ export class LocalVoxSourceWriter extends VoxSourceWriter { this.dbPromise = openVoxDb(); return this.dbPromise; } + + private _isAllZero(arr: Uint32Array | BigUint64Array): boolean { + for (let i = 0; i < arr.length; i++) { + if ((arr as any)[i] !== 0 && (arr as any)[i] !== 0n) return false; + } + return true; + } } export function openVoxDb(): Promise { From f47b41bc6298b8e5ac55f6c1f5d9de35902ebef3 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 045/251] feat: rework map settings UI and add import/export improvements - Redesign map settings UI into sections (Create, Import, Select, Export) for better user experience. - Introduce map validation, error handling, and user feedback for import/export workflows. - Refactor `constructVoxMapConfig` for consistent map configuration and validation. - Deprecate `RemoteVoxSource`, transitioning to local-only map handling. - Update styles for status clarity and input field alignment. --- NOTES/TODOs.md | 7 +- src/layer/vox/style.css | 3 +- src/layer/vox/tabs/settings.ts | 213 ++++++++++++++++++++++---- src/voxel_annotation/local_source.ts | 25 +-- src/voxel_annotation/map.ts | 112 +++++++++++++- src/voxel_annotation/remote_source.ts | 1 + 6 files changed, 320 insertions(+), 41 deletions(-) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index 50a2c8706a..c17b409d95 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -14,15 +14,16 @@ - continue to study the segmentation compression, using it should greatly reduce the ram and indexDB usage, but it no easy integration of the hot chunk reloading in the frontend for drawing tool responsiveness has been found. - Fix the orientation of the disk in the brush tool - Add support for flood fill on different planes -- look into the massive ram usage when a lot of voxel annotations are drawn - Add Uint64 support for annotation id -? Replace the current map settings to use the built-ins of neuroglancer (viewable under the datasource url), handle multimap with link choices, look into how to keep the init/creation logic. -? adapt the brush size to the zoom level linearly - Flood fill do not work at the bounds of the layer - need to fix this cache invalidation pipeline, it is not responsive enough -- THERE MAY BE A CASE WHERE CHUNK GETS CORRUPTED/DELETED -> observed when drawing zoomed out with a big brush, previous drawing got erased but only their high resolution mipmap, the low resolution mipmap is still there. -- save all the lod level in the zarr export - add a zarr import feature or even better design a dual source system, where you have the zarr source with most of the data and the indexedDB where the updates are stored, this would be an augmented version of the current localsource which would first look into the indexedDB and if not present, fetch the zarr source. +- rework the ui (tabs) +- add persistance to vox layer +- add shortcuts for tools (switching tools, toogle erase mode and adjusting brush size) and label creation +- add feedback for the user when the flood fill fails # Saving/importing/exporting diff --git a/src/layer/vox/style.css b/src/layer/vox/style.css index a66aec139f..979b70b340 100644 --- a/src/layer/vox/style.css +++ b/src/layer/vox/style.css @@ -68,7 +68,8 @@ } /* Status text can occupy a full line to improve readability */ -.neuroglancer-vox-row .neuroglancer-vox-status { +.neuroglancer-vox-status { + display: block; flex: 1 1 100%; min-width: 100%; padding-top: 4px; diff --git a/src/layer/vox/tabs/settings.ts b/src/layer/vox/tabs/settings.ts index a51719580d..4e70da0543 100644 --- a/src/layer/vox/tabs/settings.ts +++ b/src/layer/vox/tabs/settings.ts @@ -6,8 +6,8 @@ import { DataType } from "#src/util/data_type.js"; import { exportVoxToZarr, type ExportStatus } from "#src/voxel_annotation/export_to_zarr.js"; import { LocalVoxSourceWriter } from "#src/voxel_annotation/local_source.js"; import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; -import { computeSteps } from "#src/voxel_annotation/map.js"; -import { RemoteVoxSource } from "#src/voxel_annotation/remote_source.js"; +import { computeSteps, constructVoxMapConfig } from "#src/voxel_annotation/map.js"; +import { scaleByExp10, unitFromJson } from "#src/util/si_units.js"; import { Tab } from "#src/widget/tab_view.js"; export class VoxSettingsTab extends Tab { @@ -32,6 +32,169 @@ export class VoxSettingsTab extends Tab { return div; }; + // Section containers + const createSection = document.createElement("div"); + createSection.className = "neuroglancer-vox-section"; + const createHeader = document.createElement("h3"); + createHeader.textContent = "Create Map"; + createSection.appendChild(createHeader); + element.appendChild(createSection); + + const importSection = document.createElement("div"); + importSection.className = "neuroglancer-vox-section"; + const importHeader = document.createElement("h3"); + importHeader.textContent = "Import Map"; + importSection.appendChild(importHeader); + element.appendChild(importSection); + + const selectSection = document.createElement("div"); + selectSection.className = "neuroglancer-vox-section"; + const selectHeader = document.createElement("h3"); + selectHeader.textContent = "Select Map"; + selectSection.appendChild(selectHeader); + element.appendChild(selectSection); + + // Import UI controls + const importUrlInput = document.createElement("input"); + importUrlInput.type = "text"; + importUrlInput.placeholder = "Data URL (e.g., precomputed://..., zarr://..., n5://...)"; + importUrlInput.size = 80; + + const importIdInput = document.createElement("input"); + importIdInput.type = "text"; + importIdInput.placeholder = "map id (leave empty to derive from URL)"; + + const importNameInput = document.createElement("input"); + importNameInput.type = "text"; + importNameInput.placeholder = "map name (optional)"; + + const importButton = document.createElement("button"); + importButton.textContent = "Validate & Import"; + + const importStatus = document.createElement("span"); + importStatus.classList.add("neuroglancer-vox-status"); + + importSection.appendChild(row("Source URL", [importUrlInput])); + importSection.appendChild(row("Map id/name", [importIdInput, importNameInput])); + importSection.appendChild(importStatus); + importSection.appendChild(importButton) + + importButton.addEventListener("click", async () => { + const url = importUrlInput.value.trim(); + if (!url || url.length === 0) { + importStatus.textContent = "Source URL is required"; + return; + } + importButton.disabled = true; + importStatus.textContent = "Validating..."; + try { + const registry = this.layer.manager.dataSourceProviderRegistry; + const ds = await registry.get({ + url, + transform: undefined, + globalCoordinateSpace: this.layer.manager.root.coordinateSpace, + } as any); + const volumeEntry = ds.subsources.find(s => (s as any)?.subsource?.volume); + if (!volumeEntry) throw new Error("No volume subsource found at URL"); + const volume = (volumeEntry as any).subsource.volume; + const rank: number = volume.modelSpace.rank; + if (!Number.isInteger(rank) || rank <= 0) { + throw new Error(`Invalid volume rank: ${String(rank)}`); + } + + const displayRank: number = Math.min(3, rank); + const multiscaleToViewTransform = new Float32Array(displayRank * rank); + for (let i = 0; i < Math.min(displayRank, rank); i++) { + // Column-major layout, linear (not affine) matrix with displayRank rows and rank columns + multiscaleToViewTransform[i + i * displayRank] = 1; + } + + const volumeSourceOptions = { + displayRank, + multiscaleToViewTransform, + modelChannelDimensionIndices: [], + }; + + const levels = volume.getSources(volumeSourceOptions); + const level0 = levels?.[0]?.[0]; + if (!level0) throw new Error("Volume has no available resolution levels"); + const spec = (level0 as any).chunkSource?.spec; + if (!spec) throw new Error("Cannot read volume specification"); + + const baseVoxelOffset = new Float32Array(Array.from(spec.baseVoxelOffset)); + const upperVoxelBound = new Float32Array(Array.from(spec.upperVoxelBound)); + const chunkDataSize = new Uint32Array(Array.from(spec.chunkDataSize)); + const bounds = [ + (upperVoxelBound[0] | 0) - (baseVoxelOffset[0] | 0), + (upperVoxelBound[1] | 0) - (baseVoxelOffset[1] | 0), + (upperVoxelBound[2] | 0) - (baseVoxelOffset[2] | 0), + ]; + const steps = computeSteps(bounds, chunkDataSize); + const dtype = Number(volume.dataType ?? DataType.UINT32); + + // Derive id and name + const derived = registry.suggestLayerName((ds as any).originalCanonicalUrl || (ds as any).canonicalUrl || url) || `map-${Date.now()}`; + const id = (importIdInput.value.trim().length > 0 ? importIdInput.value.trim() : derived); + const name = (importNameInput.value.trim().length > 0 ? importNameInput.value.trim() : id); + console.log("Importing map:", { id, name, baseVoxelOffset, upperVoxelBound, chunkDataSize, dtype, steps }); + // Derive physical voxel size (in meters) and base unit from modelSpace + const modelUnits: string[] = Array.from(volume.modelSpace?.units || []); + const modelScales: number[] = Array.from(volume.modelSpace?.scales || []); + if (modelUnits.length < 3 || modelScales.length < 3) { + throw new Error(`Model space lacks required units/scales for 3 spatial axes`); + } + // Convert each axis scale to meters using SI prefix info + const toMeters = (scale: number, unitStr: string): number => { + const u = unitFromJson(unitStr); + return scaleByExp10(scale, u.exponent); + }; + const sx_m = toMeters(modelScales[0], modelUnits[0]); + const sy_m = toMeters(modelScales[1], modelUnits[1]); + const sz_m = toMeters(modelScales[2], modelUnits[2]); + if (!(sx_m > 0 && sy_m > 0 && sz_m > 0)) { + throw new Error(`Invalid spatial scales; expected positive finite values`); + } + // Ensure base unit is consistent across spatial axes; use base unit returned by unitFromJson + const baseUnit0 = unitFromJson(modelUnits[0]).unit; + const baseUnit1 = unitFromJson(modelUnits[1]).unit; + const baseUnit2 = unitFromJson(modelUnits[2]).unit; + if (!(baseUnit0 === baseUnit1 && baseUnit0 === baseUnit2)) { + throw new Error(`Inconsistent units across spatial axes: [${modelUnits.slice(0,3).join(", ")}]`); + } + + const map: VoxMapConfig = constructVoxMapConfig({ + id, + name, + baseVoxelOffset, + upperVoxelBound, + chunkDataSize, + dataType: dtype, + scaleMeters: new Float64Array([sx_m, sy_m, sz_m]), + unit: baseUnit0 || "m", + steps, + importUrl: (ds as any).originalCanonicalUrl || (ds as any).canonicalUrl || url, + }); + this.layer.voxMapRegistry.upsert(map); + this.layer.voxMapRegistry.setCurrent(map); + this.layer.buildOrRebuildVoxLayer(); + refreshMaps(); + importStatus.textContent = `Imported: ${id}`; + } catch (e: any) { + console.error("Import error:", e); + importStatus.textContent = `Import failed: ${e?.message || String(e)}`; + } finally { + importButton.disabled = false; + } + }); + + const exportSection = document.createElement("div"); + exportSection.className = "neuroglancer-vox-section"; + const exportHeader = document.createElement("h3"); + exportHeader.textContent = "Export Map"; + exportSection.appendChild(exportHeader); + element.appendChild(exportSection); + + const makeNumberInput = (value: number, step: string) => { const inp = document.createElement("input"); inp.type = "number"; @@ -74,10 +237,10 @@ export class VoxSettingsTab extends Tab { const by = makeNumberInput(100_000, "1"); const bz = makeNumberInput(100_000, "1"); - element.appendChild(row("Scale (x,y,z)", [sx, sy, sz])); - element.appendChild(row("Scale unit", [unitSel])); - element.appendChild(row("Corner A (x,y,z)", [ax, ay, az])); - element.appendChild(row("Corner B (x,y,z)", [bx, by, bz])); + createSection.appendChild(row("Scale (x,y,z)", [sx, sy, sz])); + createSection.appendChild(row("Scale unit", [unitSel])); + createSection.appendChild(row("Corner A (x,y,z)", [ax, ay, az])); + createSection.appendChild(row("Corner B (x,y,z)", [bx, by, bz])); // When unit changes, rescale the displayed numbers to preserve physical value in meters unitSel.addEventListener("change", () => { @@ -102,7 +265,7 @@ export class VoxSettingsTab extends Tab { mapNameInp.type = "text"; mapNameInp.placeholder = "map name"; mapNameInp.value = ""; - element.appendChild(row("Map id/name", [mapIdInp, mapNameInp])); + createSection.appendChild(row("Map id/name", [mapIdInp, mapNameInp])); // Existing maps list const mapsSel = document.createElement("select"); @@ -111,26 +274,20 @@ export class VoxSettingsTab extends Tab { const maps = this.layer.voxMapRegistry.list(); for (const m of maps) { const opt = document.createElement("option"); + opt.style.color = m.id === this.layer.voxMapRegistry.getCurrent()?.id ? "blue" : "black"; opt.value = m.id; opt.textContent = `${m.name || m.id}`; mapsSel.appendChild(opt); } }; refreshMaps(); - element.appendChild(row("Existing maps", [mapsSel])); + selectSection.appendChild(row("Existing maps", [mapsSel])); - // Attempt to fetch existing maps via VoxSource implementation (local or remote) + // Load locally-stored maps from IndexedDB (async () => { try { - // Dynamically use the appropriate VoxSource - let maps: any[] = []; - if (this.layer.voxServerUrl) { - const src = new RemoteVoxSource(this.layer.voxServerUrl, this.layer.voxServerToken); - maps = await src.listMaps(); - } else { - const src = new LocalVoxSourceWriter(); - maps = await src.listMaps(); - } + const src = new LocalVoxSourceWriter(); + const maps = await src.listMaps(); for (const m of maps) this.layer.voxMapRegistry.upsert(m as any); refreshMaps(); } catch { @@ -184,7 +341,7 @@ export class VoxSettingsTab extends Tab { const id = mapIdInp.value || `map-${Date.now()}`; const name = mapNameInp.value || id; - const map:VoxMapConfig = { + const map: VoxMapConfig = constructVoxMapConfig({ id, name, baseVoxelOffset: lower, @@ -194,15 +351,13 @@ export class VoxSettingsTab extends Tab { scaleMeters: ns, unit: u, steps, - serverUrl: this.layer.voxServerUrl, - token: this.layer.voxServerToken, - }; + }); this.layer.voxMapRegistry.upsert(map); this.layer.voxMapRegistry.setCurrent(map); this.layer.buildOrRebuildVoxLayer(); refreshMaps(); }); - element.appendChild(createBtn); + createSection.appendChild(createBtn); const selectBtn = document.createElement("button"); selectBtn.textContent = "Select Map"; @@ -210,26 +365,27 @@ export class VoxSettingsTab extends Tab { const id = mapsSel.value; const found = this.layer.voxMapRegistry.list().find((m: VoxMapConfig) => m.id === id); if (found) { + console.log("Selected map:", found); this.layer.voxMapRegistry.setCurrent(found); this.layer.buildOrRebuildVoxLayer(); } }); - element.appendChild(selectBtn); + selectSection.appendChild(selectBtn); // --- Export to Zarr (LOD 1 only) --- const exportUrlInput = document.createElement("input"); exportUrlInput.type = "text"; exportUrlInput.placeholder = "Export base URL"; exportUrlInput.size = 80; - exportUrlInput.classList.add("neuroglancer-vox-status"); + exportUrlInput.classList.add("neuroglancer-vox-input"); exportUrlInput.style.marginLeft = "0"; -1 const exportButton = document.createElement("button"); exportButton.textContent = "Export to Zarr"; exportButton.title = "Exports current map LOD=1 chunks to a Zarr v2 dataset at path '0' under the provided base URL"; const exportStatusSpan = document.createElement("span"); exportStatusSpan.classList.add("neuroglancer-vox-status"); + exportStatusSpan.classList.add("neuroglancer-vox-input"); exportStatusSpan.style.marginLeft = "0"; let exportPollTimer: number | undefined = undefined; @@ -287,6 +443,9 @@ export class VoxSettingsTab extends Tab { } }); - element.appendChild(row("Export to Zarr", [exportUrlInput, exportButton, exportStatusSpan])); + exportSection.appendChild(exportUrlInput); + exportSection.appendChild(exportStatusSpan); + exportSection.appendChild(exportButton); + } } diff --git a/src/voxel_annotation/local_source.ts b/src/voxel_annotation/local_source.ts index 6feb558900..dcd772d403 100644 --- a/src/voxel_annotation/local_source.ts +++ b/src/voxel_annotation/local_source.ts @@ -1,12 +1,15 @@ import { makeVoxChunkKey, parseVoxChunkKey } from "#src/voxel_annotation/base.js"; -import { SavedChunk, VoxSourceWriter } from "#src/voxel_annotation/index.js"; -import { +import type { SavedChunk} from "#src/voxel_annotation/index.js"; +import { VoxSourceWriter , compositeChunkDbKey, compositeLabelsDbKey, VoxSource, } from "#src/voxel_annotation/index.js"; -import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; -import { computeSteps } from "#src/voxel_annotation/map.js"; +import type { + VoxMapConfig} from "#src/voxel_annotation/map.js"; +import { + constructVoxMapConfig +, computeSteps } from "#src/voxel_annotation/map.js"; /** * Calculates the number of meaningful downsample passes for the worst case @@ -285,17 +288,21 @@ export class LocalVoxSourceWriter extends VoxSourceWriter { (upper[2] | 0) - (lower[2] | 0), ]; const steps = computeSteps(bounds, cds); - maps.push({ + const map = constructVoxMapConfig({ id, name: r?.name ?? id, baseVoxelOffset: new Float32Array(lower), upperVoxelBound: new Float32Array(upper), chunkDataSize: new Uint32Array(cds), - dataType: r.dataType, - scaleMeters: r.scaleMeters, - unit: r.unit, - steps, + dataType: Number(r.dataType), + scaleMeters: Array.from(r.scaleMeters ?? [1,1,1]), + unit: String(r.unit), + steps: Array.isArray(r?.steps) ? r.steps : steps, + serverUrl: r?.serverUrl, + token: r?.token, + importUrl: r?.importUrl, }); + maps.push(map); } catch { // skip malformed } diff --git a/src/voxel_annotation/map.ts b/src/voxel_annotation/map.ts index 618e7885df..70dc8f57b2 100644 --- a/src/voxel_annotation/map.ts +++ b/src/voxel_annotation/map.ts @@ -4,6 +4,8 @@ * until the per-slice chunk budget is satisfied, then generate [1, step, ..., S]. */ +import { DataType } from "#src/util/data_type.js"; + export interface VoxMapConfig { id: string; name?: string; @@ -18,7 +20,9 @@ export interface VoxMapConfig { unit: string; // convenience for UI // Fixed LOD steps (factors), finest → coarsest, starting at 1. steps: number[]; - // Optional remote info for convenience + // Optional original data source URL for on-demand import of base labels (e.g., precomputed://, zarr://, n5://) + importUrl?: string; + // Legacy/obsolete remote server fields retained for backward compatibility. Do not use. serverUrl?: string; token?: string; } @@ -57,6 +61,112 @@ export function computeSteps( return factors; } +function toTripleArray(name: string, v: ArrayLike): [number, number, number] { + const a = Array.from(v).map((x) => Number(x)); + if (a.length !== 3) { + throw new Error(`${name} must have length 3, got ${a.length}`); + } + for (let i = 0; i < 3; i++) { + if (!Number.isFinite(a[i])) { + throw new Error(`${name}[${i}] must be a finite number`); + } + } + return [a[0], a[1], a[2]]; +} + +function validateSteps(steps?: number[]): number[] | undefined { + if (!steps) return undefined; + if (!Array.isArray(steps) || steps.length === 0) return undefined; + for (let i = 0; i < steps.length; i++) { + const f = steps[i]; + if (!Number.isInteger(f) || f <= 0) { + throw new Error(`steps[${i}] must be a positive integer`); + } + if (i === 0 && f !== 1) { + throw new Error(`steps must start at 1`); + } + if (i > 0 && f <= steps[i - 1]) { + throw new Error(`steps must be strictly increasing`); + } + } + return steps; +} + +export type VoxMapInput = { + id: string; + name?: string; + baseVoxelOffset: ArrayLike; + upperVoxelBound: ArrayLike; + chunkDataSize: ArrayLike; + dataType: number; + scaleMeters: ArrayLike; + unit: string; + steps?: number[]; + importUrl?: string; + serverUrl?: string; + token?: string; +}; + +export function constructVoxMapConfig(input: VoxMapInput): VoxMapConfig { + if (!input || typeof input !== "object") { + throw new Error("constructVoxMapConfig: input is required"); + } + const id = String(input.id || "").trim(); + if (id.length === 0) throw new Error("constructVoxMapConfig: id is required"); + const name = input.name ? String(input.name) : undefined; + + const [bx, by, bz] = toTripleArray("baseVoxelOffset", input.baseVoxelOffset); + const [ux, uy, uz] = toTripleArray("upperVoxelBound", input.upperVoxelBound); + if (!(ux > bx && uy > by && uz > bz)) { + throw new Error("upperVoxelBound must be strictly greater than baseVoxelOffset in all dimensions"); + } + + const [cx, cy, cz] = toTripleArray("chunkDataSize", input.chunkDataSize); + const cds = new Uint32Array([ + Math.max(1, Math.floor(cx)), + Math.max(1, Math.floor(cy)), + Math.max(1, Math.floor(cz)), + ]); + + const scale = toTripleArray("scaleMeters", input.scaleMeters); + if (!(scale[0] > 0 && scale[1] > 0 && scale[2] > 0)) { + throw new Error("scaleMeters must be positive in all dimensions"); + } + + const dt = Number(input.dataType); + if (!Number.isInteger(dt) || dt < DataType.UINT8 || dt > DataType.FLOAT32) { + throw new Error("Invalid dataType"); + } + + const lower = new Float32Array([Math.floor(bx), Math.floor(by), Math.floor(bz)]); + const upper = new Float32Array([Math.floor(ux), Math.floor(uy), Math.floor(uz)]); + const bounds = [upper[0] - lower[0], upper[1] - lower[1], upper[2] - lower[2]]; + + const steps = validateSteps(input.steps) ?? computeSteps(bounds, cds); + + const unit = String(input.unit); + if (unit.length === 0) throw new Error("unit is required"); + + return { + id, + name, + baseVoxelOffset: lower, + upperVoxelBound: upper, + chunkDataSize: cds, + dataType: dt, + scaleMeters: new Float64Array(scale), + unit, + steps, + importUrl: input.importUrl, + serverUrl: input.serverUrl, + token: input.token, + }; +} + +export function validateVoxMapConfig(map: VoxMapConfig): VoxMapConfig { + return constructVoxMapConfig(map as unknown as VoxMapInput); +} + /** Simple in-memory registry to hold current map selection and list. */ export class VoxMapRegistry { private current?: VoxMapConfig; diff --git a/src/voxel_annotation/remote_source.ts b/src/voxel_annotation/remote_source.ts index e8d9618fc1..f15cdfaf0d 100644 --- a/src/voxel_annotation/remote_source.ts +++ b/src/voxel_annotation/remote_source.ts @@ -4,6 +4,7 @@ import { VoxSourceWriter } from "#src/voxel_annotation/index.js"; import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; import { computeSteps } from "#src/voxel_annotation/map.js"; +// OBSOLETE DO NOT USE THIS CLASS export class RemoteVoxSource extends VoxSourceWriter { async listMaps(): Promise { try { From ea0832baaced3655f65ae3d05aac0640bcf08842 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 046/251] doc: update TODOs --- NOTES/TODOs.md | 4 +- NOTES/multi-source-plan.md | 207 +++++++++++++++++++++++++++ src/voxel_annotation/local_source.ts | 3 + 3 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 NOTES/multi-source-plan.md diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index c17b409d95..d97cc58923 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,5 +1,7 @@ # TODO List +- FOR MONDAY: see [multi-source-plan.md](multi-source-plan.md) + - LOD -> - "feat: dirty tree upscaling is kinda working, at lea st enough to conclude that this upscaling method wont work @@ -22,7 +24,7 @@ - add a zarr import feature or even better design a dual source system, where you have the zarr source with most of the data and the indexedDB where the updates are stored, this would be an augmented version of the current localsource which would first look into the indexedDB and if not present, fetch the zarr source. - rework the ui (tabs) - add persistance to vox layer -- add shortcuts for tools (switching tools, toogle erase mode and adjusting brush size) and label creation +- add shortcuts for tools (switching tools, toogle erase mode, select label from the pointed one in the slice view and adjusting brush size) and label creation - add feedback for the user when the flood fill fails # Saving/importing/exporting diff --git a/NOTES/multi-source-plan.md b/NOTES/multi-source-plan.md new file mode 100644 index 0000000000..92e1bedbd9 --- /dev/null +++ b/NOTES/multi-source-plan.md @@ -0,0 +1,207 @@ +### Objective +Convert the vox layer from bespoke sources (`VoxMultiscaleVolumeChunkSource`, monolithic `VoxChunkSource`) to a datasource-agnostic overlay that wraps any existing `VolumeChunkSource`, while keeping `LocalVoxSource` as the persistence layer. + +### Non-negotiable constraints +- Strong typing: explicit interfaces, no silent fallbacks, throw on unsupported types or missing wiring. +- No changes to base classes (`SliceViewChunkSource`, `VolumeChunkSource`, `MultiscaleVolumeChunkSource`). +- No casting with `as` unless absolutely necessary; prefer explicit type guards. +- MVP data type is `UINT32`. Reject others clearly. + +### Current inventory (relevant files) +- `src/voxel_annotation/volume_chunk_source.ts`: `VoxMultiscaleVolumeChunkSource` builds `VoxChunkSource` levels. +- `src/voxel_annotation/frontend.ts`: frontend `VoxChunkSource` subclass of `VolumeChunkSource` with editing concerns. +- `src/voxel_annotation/backend.ts`: backend `VoxChunkSource` subclass of `VolumeChunkSource`, returns saved or zero-filled chunks. +- `src/voxel_annotation/local_source.ts`: `LocalVoxSource` persistence. +- `src/voxel_annotation/remote_source.ts`: deprecated, do not use +- `src/layer/vox/index.ts`: vox layer entry. +- Base stack: `src/sliceview/volume/{frontend,backend}.ts`, `src/datasource/*` (e.g., `zarr/backend.ts`). + +### Target architecture (overlay-based) +- Frontend overlay: `VoxVolumeChunkSource` that wraps an existing `VolumeChunkSource` instance, delegates fetching, integrates edit tools and invalidation hooks, and initializes a backend overlay via RPC. +- Backend overlay: `VoxVolumeChunkSource` that wraps the backend counterpart of the real datasource source and merges edits from `LocalVoxSource` before handing data back to the pipeline. +- Layer: `vox` layer consumes any `MultiscaleVolumeChunkSource` (precomputed, zarr, nifti, …), and wraps each returned `chunkSource` with `VoxVolumeChunkSource` on-the-fly. + +### New/updated types and RPCs +- `VoxVolumeChunkSourceFrontendOptions` with `spec`, `innerSourceRpcId`, `map: VoxMapConfig` (and optional `sharedKvStoreContextRpcId` if needed by remote import only). +- `VoxVolumeChunkSourceBackendOptions` with `innerSourceRpcId`, `map: VoxMapConfig`. +- Keep existing `VOX_MAP_INIT_RPC_ID` to initialize the overlay with the map. +- Keep existing `makeVoxChunkKey` scheme; include LOD in key calculation. + +### Step-by-step conversion plan + +#### Phase 0 — Preflight checks and guards +- Add hard validations in the overlay creation path: + - `spec.dataType === DataType.UINT32`, else throw `Error("Vox overlay supports only UINT32")`. + - `volumeType === VolumeType.SEGMENTATION` (or explicitly permitted types), else throw. + - `innerSourceRpcId` is provided and resolves to a `VolumeChunkSource` on backend, else throw. +- Decide per-level LOD factor source of truth. Store it explicitly when wrapping each level (do not infer implicitly). + +#### Phase 1 — Introduce overlay classes alongside existing code +- Frontend: add `VoxVolumeChunkSource` (new file `src/voxel_annotation/overlay_frontend.ts`). Responsibilities: + - Holds `inner: VolumeChunkSource` instance and does not fetch itself. + - Overrides `initializeCounterpart` to create the backend overlay and pass `innerSourceRpcId` and `map` via RPC. + - Proxies `fetchChunk`, `getChunk`, `chunkFormat`, `getValueAt` to `inner`. Editing tools interact with this wrapper to trigger edits and invalidations. + - Provides explicit methods `commitEdit(edits: VoxEditBatch): void` that save through RPC and then invalidate by keys. +- Backend: add `VoxVolumeChunkSource` (new file `src/voxel_annotation/overlay_backend.ts`). Responsibilities: + - On `initialize(options)`, resolve `inner` from `innerSourceRpcId`, instantiate `LocalVoxSource`, and initialize with `map`. + - On `download(chunk, signal)`: `await inner.download(...)`, then merge saved edits from `LocalVoxSource` for the chunk key. + - Expose `invalidateChunksByKey(keys: string[])` RPC to trigger sliceview invalidation via the owning `ChunkManager`/source. +- Ensure both classes are registered with `registerSharedObjectOwner`/`registerSharedObject` and use the existing `VOX_MAP_INIT_RPC_ID` to confirm map initialization. + +Deliverable code artifacts to add: +- `src/voxel_annotation/overlay_frontend.ts` +- `src/voxel_annotation/overlay_backend.ts` +- Reusable small utilities in `src/voxel_annotation/chunk_merge.ts` to copy overlapping subregions without casting. + +#### Phase 2 — Wire the overlay in the vox layer +- Modify `src/layer/vox/index.ts` to accept a generic `MultiscaleVolumeChunkSource` from URL parsing (like image/seg). +- In `getSources(...)` of the layer, iterate the single-resolution sources returned by the underlying multiscale and wrap each `chunkSource` with `VoxVolumeChunkSource` while preserving the `chunkToMultiscaleTransform`. +- Explicitly pass per-level LOD factor to the overlay (e.g., compute from scale transform or read from `VoxMapConfig.steps[index]`). If neither is available, throw. + +Sketch: +```ts +function wrapLevelWithVox( + manager: ChunkManager, + level: SliceViewSingleResolutionSource, + map: VoxMapConfig, + lodFactor: number, +) { + const { chunkSource: inner } = level; + const voxWrapped = manager.getChunkSource(VoxEditableVolumeSource, { + spec: inner.spec, + inner, + map, + lodFactor, + }); + return { + ...level, + chunkSource: voxWrapped, + }; +} +``` + +#### Phase 3 — Migrate editing controllers to use the overlay +- Update `src/voxel_annotation/edit_controller.ts` to call methods on `VoxVolumeChunkSource` (or a service it exposes) for: + - Paint/fill operations scheduling + - Persistent save via RPC + - Invalidate by affected chunk keys +- Remove direct coupling to old `VoxChunkSource` frontend methods. + +#### Phase 4 — Pair with LocalVoxSource (backend overlay) +- Instantiate and initialize `LocalVoxSource` inside `VoxVolumeChunkSource` backend using the provided `map`. +- Implement merge routine without assumptions about array type other than validated `UINT32`. + +#### Phase 5 — Invalidation and cache coherence +- When edits are committed, compute affected chunk keys with LOD and call `invalidateChunksByKey` on the overlay frontend, which calls through to the backend overlay and the underlying `inner` for proper cache invalidation. +- Ensure invalidation bridges both CPU cache and GPU textures via sliceview’s existing invalidation pathways. + +#### Phase 6 — Deprecate old classes in stages +- Mark `VoxMultiscaleVolumeChunkSource` and old frontend/backend `VoxChunkSource` as deprecated. +- Switch the vox layer to the overlay implementation behind a feature flag `voxOverlay.enabled` (default on in dev). +- After verification, delete old classes and their references. + +### Detailed implementation checklist + +1) Overlay backend implementation details +- Class `VoxVolumeChunkSource` extends backend `VolumeChunkSource`. +- Fields: `inner: VolumeChunkSource`, `local: LocalVoxSource`, `lodFactor: number`. +- `initialize(options)`: resolve `inner` from RPC id; validate types; set `lodFactor` from options; init `local` with `map`. +- `download(chunk, signal)`: + - `await this.inner.download(chunk, signal)`; + - get `cds = chunk.chunkDataSize` from `inner`; + - compute `key = chunk.chunkGridPosition.join()`; + - `const saved = await local.getSavedChunk(makeVoxChunkKey(key, lodFactor));` + - if `saved`, overlay using safe copier that clamps to min extents. +- Expose RPC for invalidation; internally use the chunk manager to invalidate the wrapped source’s key. + +2) Overlay frontend implementation details +- Class `VoxVolumeChunkSource` extends `SliceViewChunkSource` but delegates to `inner: VolumeChunkSource` for `fetchChunk`, `getChunk`, `getValueAt`. +- `initializeCounterpart(rpc, options)`: create backend counterpart for overlay and send `VOX_MAP_INIT_RPC_ID` with `map`. +- Provide editing API surface: + - `beginEdit()` / `commitEdit(edits)` → RPC to backend edit service (already exists via `edit_backend.js`), then `invalidate(keys)`. + - Implement `invalidate(keys: string[])` that forwards to backend overlay and triggers visible-chunk re-fetch. + +3) Layer glue +- In `src/layer/vox/index.ts`, when constructing visible sources, wrap the datasource-provided multiscale levels with `VoxVolumeChunkSource` as per Phase 2 sketch. +- Compute `lodFactor` per level deterministically: + - Prefer explicit `map.steps[index]`. + - Alternatively, derive from transform if steps are not provided; if derivation is ambiguous (non-uniform scale), throw. + +4) Strict typing additions +- Add `VoxMapConfig` fields used by overlay: `steps: number[]`, `chunkDataSize: [number,number,number]`, `upperVoxelBound: [number,number,number]`, `baseVoxelOffset: [number,number,number]`, optional `serverUrl`, `token`. +- Define `VoxEditBatch` shape used by `commitEdit` path to ensure edits map cleanly to chunk keys. + +5) Error handling policy +- Throw on: + - Missing `innerSourceRpcId` or it resolves to a non-`VolumeChunkSource`. + - Unsupported `dataType` or `volumeType`. + - Missing `lodFactor` for a level. + - Any attempt to edit without initialized map. + +### Example minimal code snippets + +Backend overlay merge loop (typed and bounds-checked): +```ts +function overlaySavedIntoChunk( + dst: Uint32Array, + dstSize: readonly [number, number, number], + src: Uint32Array, + srcSize: readonly [number, number, number], +) { + const ox = Math.min(srcSize[0], dstSize[0]); + const oy = Math.min(srcSize[1], dstSize[1]); + const oz = Math.min(srcSize[2], dstSize[2]); + for (let z = 0; z < oz; z++) { + for (let y = 0; y < oy; y++) { + const s0 = (z * srcSize[1] + y) * srcSize[0]; + const d0 = (z * dstSize[1] + y) * dstSize[0]; + dst.set(src.subarray(s0, s0 + ox), d0); + } + } +} +``` + +Frontend wrapper fetch delegation with typed guard: +```ts +fetchChunk(position: Float32Array, transform: (c: VolumeChunk) => void) { + if (!this.inner) throw new Error("inner source is not set"); + return this.inner.fetchChunk(position, transform); +} +``` + +### Testing plan + +- Unit tests + - `overlay_backend`: merging logic overlays correctly for different sizes and partially clipped chunks. + - Type guards throw on unsupported `dataType` and invalid `inner` references. +- Worker integration tests + - Initialize overlay with a mock `inner` that returns deterministic data; verify merge with `LocalVoxSource` saved chunk. + - Verify invalidation: commit an edit, ensure subsequent `download` sees the overlayed data. +- Frontend integration + - Wrap a `ZarrVolumeChunkSource` level, render, paint single voxel, commit, and expect visual update without page reload. +- Performance checks + - Measure `download` timings with and without overlay for typical chunk sizes; ensure O(n) merge overhead is acceptable. + +### Rollout plan with PR slicing + +1) PR1: Introduce backend overlay class and copier utility. No references; covered by unit tests. +2) PR2: Introduce frontend overlay class; basic delegation tests. +3) PR3: Wire vox layer to wrap existing multiscale sources; behind a feature flag. +4) PR4: Migrate edit controller to call overlay wrapper; enable invalidation path. +5) PR5: Remove `VoxMultiscaleVolumeChunkSource` from layer; keep class deprecated but unused. +6) PR6: Delete old `VoxChunkSource` frontend/backend, consolidate RPC initializers. +7) PR7: Clean-up and documentation: update `NOTES/vox-annotation-project-overview.md`. + +### Risks and mitigations +- Risk: Cache invalidation gaps cause stale visuals. + - Mitigation: Comprehensive integration tests around `invalidateChunksByKey` and visible chunk refetch. +- Risk: Datasource variations (e.g., channel dims) complicate `getValueAt`. + - Mitigation: Delegate all value access to `inner`; overlay only touches raw array during merge. +- Risk: Ambiguous LOD factor. + - Mitigation: Require explicit `map.steps[index]` for MVP; throw otherwise. + +### Definition of done +- Vox edits visualize and persist correctly when wrapping at least one external datasource (zarr) with no changes to core base classes. +- Old `VoxMultiscaleVolumeChunkSource` and old `VoxChunkSource` are removed. +- All new code paths have unit/integration tests and pass CI. +- Type validations prevent unsupported modes and clearly explain errors. diff --git a/src/voxel_annotation/local_source.ts b/src/voxel_annotation/local_source.ts index dcd772d403..b9859ca141 100644 --- a/src/voxel_annotation/local_source.ts +++ b/src/voxel_annotation/local_source.ts @@ -22,6 +22,8 @@ function calculateDownsamplePasses(chunkSize: number) { return Math.ceil(Math.log2(chunkSize)); } + +// Simple read only local source, this class can be instantiated multiple times without side effects. export class LocalVoxSource extends VoxSource { private dbPromise: Promise | null = null; @@ -48,6 +50,7 @@ export class LocalVoxSource extends VoxSource { } /** IndexedDB-backed local source. */ +// More complete local source that supports writing and more, THIS CLASS SHOULD NOT BE INSTANTIATED MULTIPLE TIMES per maps export class LocalVoxSourceWriter extends VoxSourceWriter { // Upscaling halted From 6c8ac37f4aa0081ba1dd77c08e9e67e2a37541ac Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 047/251] feat: add Zarr import support and integrate remote chunk fallback - Implement `fetchZarrChunkIfAvailable` for importing remote Zarr chunks. - Add typed array construction and base URL normalization for import logic. - Update `LocalVoxSource` to fallback to remote Zarr import on cache miss. - Refactor TODOs to reflect inclusion of simplified Zarr import workflow. --- NOTES/TODOs.md | 4 +- src/layer/vox/tabs/settings.ts | 2 +- src/voxel_annotation/import_from_zarr.ts | 132 +++++++++++++++++++++++ src/voxel_annotation/local_source.ts | 27 +++++ 4 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 src/voxel_annotation/import_from_zarr.ts diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index d97cc58923..3b7cfdd809 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,7 +1,5 @@ # TODO List -- FOR MONDAY: see [multi-source-plan.md](multi-source-plan.md) - - LOD -> - "feat: dirty tree upscaling is kinda working, at lea st enough to conclude that this upscaling method wont work @@ -21,7 +19,7 @@ -? adapt the brush size to the zoom level linearly - Flood fill do not work at the bounds of the layer - need to fix this cache invalidation pipeline, it is not responsive enough -- add a zarr import feature or even better design a dual source system, where you have the zarr source with most of the data and the indexedDB where the updates are stored, this would be an augmented version of the current localsource which would first look into the indexedDB and if not present, fetch the zarr source. +- ~~add a zarr import feature or even better design a dual source system, where you have the zarr source with most of the data and the indexedDB where the updates are stored, this would be an augmented version of the current localsource which would first look into the indexedDB and if not present, fetch the zarr source.~~ -> this is really hard, I should ask JMS for help, I will implement a simplified import system myself for now. - rework the ui (tabs) - add persistance to vox layer - add shortcuts for tools (switching tools, toogle erase mode, select label from the pointed one in the slice view and adjusting brush size) and label creation diff --git a/src/layer/vox/tabs/settings.ts b/src/layer/vox/tabs/settings.ts index 4e70da0543..395d28a780 100644 --- a/src/layer/vox/tabs/settings.ts +++ b/src/layer/vox/tabs/settings.ts @@ -3,11 +3,11 @@ */ import type { VoxUserLayer } from "#src/layer/vox/index.js"; import { DataType } from "#src/util/data_type.js"; +import { scaleByExp10, unitFromJson } from "#src/util/si_units.js"; import { exportVoxToZarr, type ExportStatus } from "#src/voxel_annotation/export_to_zarr.js"; import { LocalVoxSourceWriter } from "#src/voxel_annotation/local_source.js"; import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; import { computeSteps, constructVoxMapConfig } from "#src/voxel_annotation/map.js"; -import { scaleByExp10, unitFromJson } from "#src/util/si_units.js"; import { Tab } from "#src/widget/tab_view.js"; export class VoxSettingsTab extends Tab { diff --git a/src/voxel_annotation/import_from_zarr.ts b/src/voxel_annotation/import_from_zarr.ts new file mode 100644 index 0000000000..fc3cab7bf2 --- /dev/null +++ b/src/voxel_annotation/import_from_zarr.ts @@ -0,0 +1,132 @@ +import { DataType, DATA_TYPE_BYTES } from "#src/util/data_type.js"; +import { parseVoxChunkKey } from "#src/voxel_annotation/base.js"; +import type { SavedChunk } from "#src/voxel_annotation/index.js"; +import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; + +function toDataTypeEnum(dt: number): DataType { + switch (dt) { + case DataType.UINT8: + case DataType.INT8: + case DataType.UINT16: + case DataType.INT16: + case DataType.UINT32: + case DataType.INT32: + case DataType.UINT64: + case DataType.FLOAT32: + return dt as DataType; + default: + throw new Error(`Invalid DataType value: ${dt}`); + } +} + +/** Normalize supported base URLs to an HTTP(S) base that ends with '/'. */ +function normalizeZarrBaseUrl(url: string): string { + if (!url || typeof url !== "string") { + throw new Error("normalizeZarrBaseUrl: url must be a non-empty string"); + } + const trimmed = url.trim(); + // Neuroglancer canonical URLs may append a driver suffix after a '|' (e.g., "|zarr2:/" or "|n5"). + // Strip any such suffix before normalizing the base path to an HTTP(S) URL. + const pipeIndex = trimmed.indexOf("|"); + const basePart = pipeIndex >= 0 ? trimmed.substring(0, pipeIndex) : trimmed; + + const ensureTrailingSlash = (u: string) => (u.endsWith("/") ? u : `${u}/`); + + // Allow explicit zarr+http(s):// or zarr:// prefixes as hints. + if (basePart.startsWith("zarr+http://")) { + return ensureTrailingSlash(basePart.substring("zarr+".length)); + } + if (basePart.startsWith("zarr+https://")) { + return ensureTrailingSlash(basePart.substring("zarr+".length)); + } + if (basePart.startsWith("zarr://")) { + const rest = basePart.substring("zarr://".length); + if (rest.startsWith("http://") || rest.startsWith("https://")) { + return ensureTrailingSlash(rest); + } + // Treat as https by default if scheme omitted after zarr:// + return ensureTrailingSlash(`https://${rest}`); + } + + // Direct HTTP(S) endpoints, e.g. MinIO: http://localhost:9000/zarr/mydataset/ + if (basePart.startsWith("http://") || basePart.startsWith("https://")) { + return ensureTrailingSlash(basePart); + } + // S3-compatible explicit endpoint, e.g. s3+http://localhost:9000/zarr/mydataset/ + if (basePart.startsWith("s3+http://")) { + return ensureTrailingSlash(basePart.substring("s3+".length)); + } + if (basePart.startsWith("s3+https://")) { + return ensureTrailingSlash(basePart.substring("s3+".length)); + } + // AWS-style shorthand: s3://bucket/path → https://bucket.s3.amazonaws.com/path + if (basePart.startsWith("s3://")) { + const rest = basePart.substring("s3://".length); + const firstSlash = rest.indexOf("/"); + if (firstSlash < 0) { + throw new Error("normalizeZarrBaseUrl: s3:// URL must include a path prefix"); + } + const bucket = rest.substring(0, firstSlash); + const keyPrefix = rest.substring(firstSlash + 1); + if (bucket.length === 0) throw new Error("normalizeZarrBaseUrl: missing bucket in s3 URL"); + return ensureTrailingSlash(`https://${bucket}.s3.amazonaws.com/${keyPrefix}`); + } + throw new Error( + `normalizeZarrBaseUrl: Unsupported URL scheme; use http(s)://, s3+http(s)://, s3://, or zarr(+http(s)):// (got: ${url})`, + ); +} + +function joinUrl(base: string, path: string): string { + if (!base.endsWith("/")) throw new Error("joinUrl: base must end with '/'"); + if (!path) throw new Error("joinUrl: path must be non-empty"); + if (path.startsWith("/")) path = path.substring(1); + return base + path; +} + +async function fetchBinary(url: string, signal?: AbortSignal): Promise { + const resp = await fetch(url, { method: "GET", signal }); + if (resp.status === 404) return undefined; + if (!resp.ok) { + throw new Error(`Failed to GET ${url}: ${resp.status} ${resp.statusText}`); + } + return await resp.arrayBuffer(); +} + +function constructTypedArray(dataType: DataType, buffer: ArrayBuffer): Uint8Array | Int8Array | Uint16Array | Int16Array | Uint32Array | Int32Array | BigUint64Array | Float32Array { + switch (dataType) { + case DataType.UINT8: return new Uint8Array(buffer); + case DataType.INT8: return new Int8Array(buffer); + case DataType.UINT16: return new Uint16Array(buffer); + case DataType.INT16: return new Int16Array(buffer); + case DataType.UINT32: return new Uint32Array(buffer); + case DataType.INT32: return new Int32Array(buffer); + case DataType.UINT64: return new BigUint64Array(buffer); + case DataType.FLOAT32: return new Float32Array(buffer); + default: + throw new Error(`Unsupported dataType for zarr import: ${dataType}`); + } +} + +export async function fetchZarrChunkIfAvailable(mapCfg: VoxMapConfig | undefined, voxKey: string, signal?: AbortSignal): Promise { + if (!mapCfg) throw new Error("fetchZarrChunkIfAvailable: mapCfg is required"); + const importUrl = mapCfg.importUrl; + if (!importUrl) return undefined; + const info = parseVoxChunkKey(voxKey); + if (!info) throw new Error(`fetchZarrChunkIfAvailable: invalid voxKey: ${voxKey}`); + const baseUrl = normalizeZarrBaseUrl(importUrl); + const chunkPath = `${info.lod}/${info.z}.${info.y}.${info.x}`; + const url = joinUrl(baseUrl, chunkPath); + const buf = await fetchBinary(url, signal); + if (buf === undefined) return undefined; // Not present remotely + + const expectedCount = (mapCfg.chunkDataSize[0] | 0) * (mapCfg.chunkDataSize[1] | 0) * (mapCfg.chunkDataSize[2] | 0); + const dataTypeEnum = toDataTypeEnum(Number(mapCfg.dataType)); + const bytesPer = DATA_TYPE_BYTES[dataTypeEnum]; + const expectedBytes = expectedCount * bytesPer; + if (buf.byteLength !== expectedBytes) { + throw new Error(`Zarr chunk size mismatch for ${voxKey}: expected ${expectedBytes}B, got ${buf.byteLength}B`); + } + const arr = constructTypedArray(dataTypeEnum, buf) as unknown as Uint32Array | BigUint64Array | any; + const saved: SavedChunk = { data: arr, size: new Uint32Array(mapCfg.chunkDataSize as any) }; + return saved; +} diff --git a/src/voxel_annotation/local_source.ts b/src/voxel_annotation/local_source.ts index b9859ca141..bdb60fcaa3 100644 --- a/src/voxel_annotation/local_source.ts +++ b/src/voxel_annotation/local_source.ts @@ -10,6 +10,7 @@ import type { import { constructVoxMapConfig , computeSteps } from "#src/voxel_annotation/map.js"; +import { fetchZarrChunkIfAvailable } from "#src/voxel_annotation/import_from_zarr.js"; /** * Calculates the number of meaningful downsample passes for the worst case @@ -45,6 +46,11 @@ export class LocalVoxSource extends VoxSource { }; return sc; } + // Fallback to remote Zarr import if available + const remote = await fetchZarrChunkIfAvailable(this.mapCfg, key); + if (remote) { + return remote; + } return undefined; } } @@ -410,6 +416,16 @@ export class LocalVoxSourceWriter extends VoxSourceWriter { this.enforceCap(); return sc; } + // Try remote Zarr import on miss + const remote = await fetchZarrChunkIfAvailable(this.mapCfg, key); + if (remote) { + this.saved.set(key, remote); + this.enforceCap(); + const tx = db.transaction("chunks", "readwrite"); + await idbPut(tx.objectStore("chunks"), remote.data.buffer, composite); + await txDone(tx); + return remote; + } return undefined; } @@ -442,6 +458,17 @@ export class LocalVoxSourceWriter extends VoxSourceWriter { this.enforceCap(); return sc; } + // Try fetching from remote Zarr before allocating an empty chunk + const remote = await fetchZarrChunkIfAvailable(this.mapCfg, key); + if (remote) { + sc = remote; + this.saved.set(key, sc); + this.enforceCap(); + const tx = db.transaction("chunks", "readwrite"); + await idbPut(tx.objectStore("chunks"), sc.data.buffer, composite); + await txDone(tx); + return sc; + } const fallbackSize = new Uint32Array(this.mapCfg!.chunkDataSize as any); const sz = new Uint32Array(size ?? fallbackSize); let total = 1; From 9c2cfed6ff664c00f2509376d9a06e3890fb5e3d Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 048/251] feat: add error handling for flood fill and display draw error messages - Introduce `drawErrorContainer` in the Draw tab for user-visible error messages. - Improve flood fill logic to handle map bounds and unloaded chunks gracefully. - Add `setDrawErrorMessage` method with `onDrawMessageChanged` callback for dynamic error updates. - Refactor flood fill tool for better error handling and messaging during user interactions. --- src/layer/vox/index.ts | 12 +++++ src/layer/vox/tabs/tools.ts | 25 ++++++++++ src/ui/voxel_annotations.ts | 78 ++++++++++++++++++++------------ src/voxel_annotation/frontend.ts | 5 +- 4 files changed, 90 insertions(+), 30 deletions(-) diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 6ecbf3545b..76f188cd93 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -80,6 +80,18 @@ export class VoxUserLayer extends UserLayer { voxBrushShape: "disk" | "sphere" = "disk"; private voxLoadedSubsource?: LoadedDataSubsource; + // Draw tab error messaging + voxDrawErrorMessage: string | undefined = undefined; + onDrawMessageChanged?: () => void; + setDrawErrorMessage(message: string | undefined): void { + this.voxDrawErrorMessage = message; + try { + this.onDrawMessageChanged?.(); + } catch { + /* ignore */ + } + } + // Remote server configuration when using vox+http(s):// data sources voxServerUrl?: string; voxServerToken?: string; diff --git a/src/layer/vox/tabs/tools.ts b/src/layer/vox/tabs/tools.ts index dd4fbba94b..0f1272253e 100644 --- a/src/layer/vox/tabs/tools.ts +++ b/src/layer/vox/tabs/tools.ts @@ -12,6 +12,7 @@ export class VoxToolTab extends Tab { } private labelsContainer!: HTMLDivElement; private labelsError!: HTMLDivElement; + private drawErrorContainer!: HTMLDivElement; private renderLabels() { const cont = this.labelsContainer; cont.innerHTML = ""; @@ -304,8 +305,32 @@ export class VoxToolTab extends Tab { toolbox.appendChild(labelsSection); + // Draw error message area at the very end of the Draw tab + this.drawErrorContainer = document.createElement("div"); + this.drawErrorContainer.className = "neuroglancer-vox-draw-error"; + this.drawErrorContainer.style.color = "#b00020"; + this.drawErrorContainer.style.fontSize = "12px"; + this.drawErrorContainer.style.whiteSpace = "pre-wrap"; + this.drawErrorContainer.style.marginTop = "8px"; + this.drawErrorContainer.style.display = "none"; + toolbox.appendChild(this.drawErrorContainer); + + const updateDrawError = () => { + const msg = this.layer.voxDrawErrorMessage; + if (msg && msg.length > 0) { + this.drawErrorContainer.textContent = msg; + this.drawErrorContainer.style.display = "block"; + } else { + this.drawErrorContainer.textContent = ""; + this.drawErrorContainer.style.display = "none"; + } + }; + this.layer.onLabelsChanged = () => this.requestRenderLabels(); + this.layer.onDrawMessageChanged = () => updateDrawError(); + this.renderLabels(); + updateDrawError(); element.appendChild(toolbox); } diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 2c25d2a93b..db265caddb 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -203,35 +203,55 @@ export class VoxelFloodFillLegacyTool extends LegacyTool { trigger(mouseState: MouseSelectionState) { const layer = this.layer as unknown as VoxUserLayer; - if (!mouseState?.active) { - console.info("[VoxFloodFill] trigger ignored: mouse inactive"); - return; - } - const pos = (layer as any).getVoxelPositionFromMouse?.(mouseState) as Float32Array | undefined; - if (!pos || pos.length < 3) { - throw new Error("VoxelFloodFillLegacyTool.trigger: failed to get voxel position from mouse"); - } - const value = layer.getCurrentLabelValue() ?? (layer.voxEraseMode ? 0 : 42); - const max = Number((layer as any).voxFloodMaxVoxels); - if (!Number.isFinite(max) || max <= 0) { - throw new Error("VoxelFloodFillLegacyTool.trigger: invalid max voxels; set it in the tool panel"); - } - const ctrl = (layer as any).voxEditController; - if (!ctrl) throw new Error("VoxelFloodFillLegacyTool.trigger: missing VoxelEditController on layer"); - const seed = new Float32Array([Math.floor(pos[0]), Math.floor(pos[1]), Math.floor(pos[2])]); - console.info("[VoxFloodFill] starting flood fill", { seed: Array.from(seed), value: value >>> 0, max: Math.floor(max) }); - const { edits, filledCount } = ctrl.floodFillPlane2D(seed, value >>> 0, Math.floor(max)); - console.info("[VoxFloodFill] BFS completed", { filledCount, editsByChunk: edits.length }); - if (edits.length === 0) return; - if (typeof ctrl.commitEdits === "function") { - ctrl.commitEdits(edits); - console.info("[VoxFloodFill] committed edits"); - } else if ((ctrl as any).rpc && (ctrl as any).rpc.invoke) { - // Fallback commit path if helper is not present - (ctrl as any).rpc.invoke("VOX_EDIT_COMMIT_VOXELS", { rpcId: (ctrl as any).rpcId, edits }); - console.info("[VoxFloodFill] committed edits via fallback path"); - } else { - throw new Error("VoxelFloodFillLegacyTool.trigger: no way to commit edits"); + try { + // Clear any previous draw error message + layer.setDrawErrorMessage(undefined); + + if (!mouseState?.active) { + console.info("[VoxFloodFill] trigger ignored: mouse inactive"); + return; + } + + const pos = layer.getVoxelPositionFromMouse?.(mouseState) as Float32Array | undefined; + if (!pos || pos.length < 3) { + throw new Error("Flood fill: failed to get voxel position from mouse"); + } + + const value = layer.getCurrentLabelValue(); + const max = Number((layer as any).voxFloodMaxVoxels); + if (!Number.isFinite(max) || max <= 0) { + throw new Error("Flood fill: invalid max voxels; set it in the tool panel"); + } + const ctrl = layer.voxEditController; + if (!ctrl) throw new Error("Flood fill: drawing backend not ready yet"); + + const seed = new Float32Array([ + Math.floor(pos[0]!), + Math.floor(pos[1]!), + Math.floor(pos[2]!), + ]); + + console.info("[VoxFloodFill] starting flood fill", { seed: Array.from(seed), value: value >>> 0, max: Math.floor(max) }); + const { edits, filledCount } = ctrl.floodFillPlane2D(seed, value >>> 0, Math.floor(max)); + console.info("[VoxFloodFill] BFS completed", { filledCount, editsByChunk: edits.length }); + + if (edits.length === 0) return; + if (typeof ctrl.commitEdits === "function") { + ctrl.commitEdits(edits); + console.info("[VoxFloodFill] committed edits"); + } else if ((ctrl as any).rpc && (ctrl as any).rpc.invoke) { + (ctrl as any).rpc.invoke("VOX_EDIT_COMMIT_VOXELS", { rpcId: (ctrl as any).rpcId, edits }); + console.info("[VoxFloodFill] committed edits via fallback path"); + } else { + throw new Error("Flood fill: no way to commit edits"); + } + } catch (e: any) { + const msg = typeof e?.message === "string" ? e.message : String(e); + try { + layer.setDrawErrorMessage(msg); + } catch { + /* ignore */ + } } } } diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index d5ce54c33a..a6a9150a8c 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -402,7 +402,10 @@ export class VoxChunkSource extends BaseVolumeChunkSource { const cpu = chunk ? this.getCpuArrayForChunk(chunk) : null; if (!cpu || chunkLocalIndex < 0) { - throw new Error("VoxChunkSource.floodFillPlane2D: encountered adjacency to unloaded chunk; aborting to avoid partial fill."); + // Stop propagation at map bounds/unloaded chunks without invalidating the fill. + // Simply do not enqueue this neighbor. + visited.add(k); + continue; } if (isOriginalAt(nx, ny)) { From f9806269fe3fc3251e6b96eabf8ac917839ecf59 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 049/251] Add `weekly-progress.md` to document the voxel annotation project's progress --- NOTES/weekly-progress.md | 100 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 NOTES/weekly-progress.md diff --git a/NOTES/weekly-progress.md b/NOTES/weekly-progress.md new file mode 100644 index 0000000000..42d5b17373 --- /dev/null +++ b/NOTES/weekly-progress.md @@ -0,0 +1,100 @@ +# Weekly progress on the vox annotation project + +## Week 1 (2025-09-02 → 2025-09-07) + +### Weekly narrative +The first week established the foundations for voxel annotations in Neuroglancer. The goal was to first familiarize myself with the project and the codebase while standing up a minimal yet end-to-end path for visualizing vox data and validating the rendering contract. We introduced the Vox layer type, a procedural dummy source to feed predictable data (a simple checkerboard). The main challenges were stabilizing the initial rendering path (fighting artifacts) and iterating on a concise but extensible annotation specification. + +### Delivered capabilities +- Bootstrapped the Vox layer type and initial tooling for voxel annotations. +- Implemented the first rendering path and specification; added a procedural demo (VoxDummyChunkSource) to visualize data. +- Brought up the checkerboard demo and iterated on rendering stability. +- Reworked/expanded the voxel annotation specifications documentation. + +### Notable commits (by brieuc.crosson) +- 2025-09-02 9b71be4f feat: add new dummy layer type: voxel annotation (vox) +- 2025-09-02 c1d2e802 feat: add a new dummy pixel tool +- 2025-09-02 07c116ad feat: retreive mouse position and current LOD scale +- 2025-09-04 c0ceef34 feat: add support for voxel annotation rendering and specification +- 2025-09-04 cbe55c86 feat: introduce VoxDummyChunkSource for procedural voxel annotation demo +- 2025-09-04 ff13ca26 feat: no errors but no checkboard tho +- 2025-09-05 8ea20800 feat: finaly the checkboard is showing, but it is a bit bugged out, it seems there is some fighting. +- 2025-09-05 6140a28b doc: rework voxel annotation specs + +## Week 2 (2025-09-08 → 2025-09-14) + +### Weekly narrative +This week focused on making editing practical and robust. We hardened the pixel tool, added a brush with configurable size, and tackled UX responsiveness during drawing. To persist user work, we introduced a local IndexedDB-backed store with RPC plumbing and laid groundwork for labels. We also began exploring remote sources and improved settings to handle extreme zoom-out safely. Key hurdles included a data corruption bug during edits and a coordinate conversion bug when scales differed; both were resolved while redesigning the toolbox UI. + +### Delivered capabilities +- Made the pixel tool robust and added a brush tool with radius, eraser mode, continuous strokes, and disk/sphere shapes. +- Added user settings for scale and bounds; introduced a guard source for safe extreme zoom-out. +- Persisted edits to the backend and improved drawing responsiveness; redesigned the toolbox UI with structured layout. +- Implemented IndexedDB-backed local storage for maps/chunks/labels with RPC plumbing; added label creation and UI rendering. +- Supported region-based voxel initialization and expanded map options in the UI. +- Introduced remote HTTP(S) voxel source; migrated label API to addLabel; added project overview and process documentation. + +### Notable commits (by brieuc.crosson) +- 2025-09-08 65565130 feat: working on the pixel tool, there are interaction but a bug seems to corruped the chunk after the usage of the tool. Added a front end buffer which is the only drawing storage for now. Added user settings to set the voxel_annotation layer scale and bounds. Added a second empty source to DummyMultiscaleVolumeChunkSource to prevent crashs when zoomed out too much +- 2025-09-08 14336ab4 feat: pixel tool is now working as intended +- 2025-09-08 44a6754f feat: fix pixel tool not working when the scale is not equal to the global one (there where a missing convertion) ; add a primitive brush tool +- 2025-09-08 a3f05989 refactor: rename DummyMultiscaleVolumeChunkSource to VoxMultiscaleVolumeChunkSource and update related imports +- 2025-09-08 238958a8 feat: brush size, eraser mode and little trivial optimization +- 2025-09-08 3017fe4c feat: continuous drawing and shape selection for the brush +- 2025-09-08 c517586e doc: add TODO list +- 2025-09-09 94af3d47 feat: small improvement on the drawing render delay +- 2025-09-09 faf0947d feat: persist voxel edits to backend and improve drawing responsiveness +- 2025-09-09 29d53634 feat: redesign toolbox with structured layout, tool selection, and expanded brush settings +- 2025-09-09 19f4e103 feat: implement new local voxel storage with IndexedDB, map initialization, and improved backend edit handling +- 2025-09-09 5172bc76 doc: brainstorming LOD +- 2025-09-10 92c3c215 feat: support region-based voxel initialization with corners, update map options and UI settings +- 2025-09-10 e70c9ff3 feat: expand TODOs with plans for segmentation compression, multi-user remote workflows, label creation, and new drawing tools +- 2025-09-10 e04b3167 feat: implement voxel label creation, persistence via IndexedDB, and enhanced UI rendering +- 2025-09-11 07a59c38 feat: implement RPC-based voxel label persistence +- 2025-09-11 73460e1d refactor: ran 'npm run format:fix' +- 2025-09-11 02e28fd4 feat: add support for remote voxel sources via HTTP(S) - (note: the labels are not sync currently) +- 2025-09-12 7c1a3b4e feat: replace `setLabelIds` with `addLabel` for label management +- 2025-09-12 673cea63 doc: add guidelines for junie and write project overview file +- 2025-09-12 d90b5569 feat: map creation and selection, the min scale is currently not saved and part of the codebase for this feature is subject to rewritting because of ugly code. +- 2025-09-12 39b3ff6f feat: cleanup map init/selection implementation, the remote still needs an update to align with the new architecture + +## Week 3 (2025-09-15 → 2025-09-22) + +### Weekly narrative +We turned to multiscale workflows and consistency across levels of detail. LOD-based painting and LOD locking shipped, and we began modularizing VoxSource implementations. We added chunk reload and downsample propagation to keep edited data coherent. An attempted “dirty-tree upscaling” approach was explored and intentionally dropped after discovering fundamental conflicts and quality loss when reconciling upscaled strokes. The system gained a centralized VoxelEditController, better invalidation/reload handling, a flood fill tool, a downscale job queue, and an export flow. + +### Delivered capabilities +- Advanced multiscale/LOD workflow: enabled LOD-based brush painting and LOD locking; began moving VoxSource implementations into separate files. +- Introduced chunk reload and downsample propagation APIs; experimented with dirty-tree upscaling, then disabled it due to conflicts/quality loss; restricted brush size for stability. +- Added VoxelEditController to centralize edit flows; improved chunk invalidation and reload mechanics. +- Added flood fill tool; implemented a downscale job queue; refined reload handling and improved flood fill stability. +- Added Zarr export and reworked map settings UI, and started working on the import flow. + +### Notable commits (by brieuc.crosson) +- 2025-09-15 0992672c refactor: remove `VoxelPixelLegacyTool`, update references, and enable LOD-based brush painting +- 2025-09-15 b08e9dd2 feat: add LOD locking for voxel rendering and extend brush size range +- 2025-09-15 92edee99 feat: move local and remote VoxSource to separate files, updated the LocalVoxSource and VoxChunkSource backend for handling of different lod level chunks +- 2025-09-15 e5ae7112 feat: move local and remote VoxSource to separate files, updated the LocalVoxSource and VoxChunkSource backend for handling of different lod level chunks +- 2025-09-16 6ec2674c feat: introduce chunk reload and downsample propagation APIs +- 2025-09-16 6ed8adda feat: chunk reloading from the backend -> currently do not work due to a design issue: the VoxSource is not unique, one is created for each VoxChunkSource +- 2025-09-16 1360c3ec feat: add Zarr export functionality and dirty-tree upscaling (not working for now) +- 2025-09-17 c8464f87 feat: dirty tree upscaling is kinda working, at least enough to conclude that this upscaling method wont work due to unsolvable conficts and lost unavoidable lost of quality due to upscaling of downscaled strokes. A new approach will be to enqueue every upscale and downscale and throttle the user when the queue is too full, with some kind of indicator in the ui. We also may need to restrict the max brush size to avoid too long waiting time. +- 2025-09-17 958df676 feat: restrict brush size and disable dirty tree upscaling +- 2025-09-18 c53888fc feat: introduce VoxelEditController for centralized edit handling and map management +- 2025-09-18 2508c671 refactor: improve chunk invalidation and reload workflows +- 2025-09-18 5335bc94 feat: add flood fill tool and export UI improvements +- 2025-09-18 e42d4fa4 feat: implement downscale job queue and improve chunk reload handling +- 2025-09-19 64f5f38e feat: enhance flood fill stability and optimize Zarr export +- 2025-09-19 ddc5c73e feat: rework map settings UI and add import/export improvements +- 2025-09-19 2addad4e doc: update TODOs + +## Week 4 (2025-09-22 → 2025-09-29) — ongoing + +### Weekly narrative +Week 4 kicked off the final leg of the basic I/O story by adding Zarr import and a remote-chunk fallback path. The motivation is to ensure people can round-trip data and recover missing local chunks from a remote source when needed. Early challenges include aligning fallback semantics with caching and ensuring consistency across LODs during import. Work is in progress. + +### Delivered capabilities +- Started Week 4 with Zarr import support and integration of remote chunk fallback to complete the basic I/O path. + +### Notable commits (by brieuc.crosson) +- 2025-09-22 73eb4ec3 feat: add Zarr import support and integrate remote chunk fallback From 3810f619c48915bbec6b4b1008be9134d535d551 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 050/251] feat: introduce `LabelsManager` for label management and cleanup `VoxUserLayer` - Add `LabelsManager` for centralized label management, replacing inline logic in `VoxUserLayer`. - Refactor `VoxUserLayer` to delegate label operations to `LabelsManager` (creation, selection, persistence). - Remove redundant label-related code from `VoxUserLayer`, improving maintainability. - Update associated imports and integrate `LabelsManager` with `VoxelEditController`. - Document pending tasks in TODOs with minor cleanup. --- NOTES/TODOs.md | 3 +- src/layer/vox/index.ts | 164 ++++++++------------------------- src/voxel_annotation/labels.ts | 131 ++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 125 deletions(-) create mode 100644 src/voxel_annotation/labels.ts diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index 3b7cfdd809..fdc3649ac4 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,5 +1,7 @@ # TODO List +- FOR TOMORROW: fix plane orientation in the brush and flood tools + ask about the R2 storage on cloudflare, it is behind a "put your credit card" wall. + - LOD -> - "feat: dirty tree upscaling is kinda working, at lea st enough to conclude that this upscaling method wont work @@ -23,7 +25,6 @@ - rework the ui (tabs) - add persistance to vox layer - add shortcuts for tools (switching tools, toogle erase mode, select label from the pointed one in the slice view and adjusting brush size) and label creation -- add feedback for the user when the flood fill fails # Saving/importing/exporting diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 76f188cd93..1e68531dce 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -43,13 +43,13 @@ import { RenderScaleHistogram, trackableRenderScaleTarget, } from "#src/render_scale_statistics.js"; -import { SegmentColorHash } from "#src/segment_color.js"; import { registerVoxelAnnotationTools, } from "#src/ui/voxel_annotations.js"; import type { Borrowed } from "#src/util/disposable.js"; import { mat4 } from "#src/util/geom.js"; import { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; +import { LabelsManager } from "#src/voxel_annotation/labels.js"; import { VoxMapRegistry } from "#src/voxel_annotation/map.js"; import { RemoteVoxSource } from "#src/voxel_annotation/remote_source.js"; import { VoxelAnnotationRenderLayer } from "#src/voxel_annotation/renderlayer.js"; @@ -58,21 +58,14 @@ import { VoxMultiscaleVolumeChunkSource } from "#src/voxel_annotation/volume_chu export class VoxUserLayer extends UserLayer { // While drawing, we keep a reference to the vox render layer to control temporary LOD locks. private voxRenderLayerInstance?: VoxelAnnotationRenderLayer; - onLabelsChanged?: () => void; - voxMapRegistry = new VoxMapRegistry(); - // Label state for painting: only store ids; colors are hashed from id on the fly - voxLabels: { id: number }[] = []; - voxSelectedLabelId: number | undefined = undefined; - voxLabelsError: string | undefined = undefined; - // Indicates whether an initial labels load attempt has completed. - private voxLabelsInitialized: boolean = false; - segmentColorHash = SegmentColorHash.getDefault(); // Match Image/Segmentation layers: provide a per-layer cross-section render scale target/histogram. sliceViewRenderScaleHistogram = new RenderScaleHistogram(); sliceViewRenderScaleTarget = trackableRenderScaleTarget(1); static type = "vox"; static typeAbbreviation = "vox"; voxEditController?: VoxelEditController; + voxLabelsManager = new LabelsManager(); + voxMapRegistry = new VoxMapRegistry(); // Draw tool state voxBrushRadius: number = 3; @@ -92,6 +85,43 @@ export class VoxUserLayer extends UserLayer { } } + // Labels manager integration proxies + get onLabelsChanged(): (() => void) | undefined { + return this.voxLabelsManager.onLabelsChanged; + } + set onLabelsChanged(cb: (() => void) | undefined) { + this.voxLabelsManager.onLabelsChanged = cb; + } + + get voxLabels(): { id: number }[] { + return this.voxLabelsManager.labels; + } + get voxSelectedLabelId(): number | undefined { + return this.voxLabelsManager.selectedLabelId; + } + get voxLabelsError(): string | undefined { + return this.voxLabelsManager.labelsError; + } + + colorForValue(v: number): string { + return this.voxLabelsManager.colorForValue(v); + } + createVoxLabel(): void { + this.voxLabelsManager.createVoxLabel(this.voxEditController); + } + selectVoxLabel(id: number): void { + this.voxLabelsManager.selectVoxLabel(id); + } + getCurrentLabelValue(): number { + return this.voxLabelsManager.getCurrentLabelValue(!!this.voxEraseMode); + } + + private async loadLabels(): Promise { + const ctrl = this.voxEditController; + if (!ctrl) return; + await this.voxLabelsManager.initialize(ctrl); + } + // Remote server configuration when using vox+http(s):// data sources voxServerUrl?: string; voxServerToken?: string; @@ -126,120 +156,6 @@ export class VoxUserLayer extends UserLayer { console.log("endRenderLodLock"); } - getActiveRenderedLodIndex(): number | undefined { - const rl = this.voxRenderLayerInstance; - if (!rl) return undefined; - return rl.getForcedSourceIndexOverride?.(); - } - - // --- Label helpers --- - private genId(): number { - // Generate a unique uint32 per layer session. Try crypto.getRandomValues; fallback to Math.random. - let id = 0; - const used = new Set(this.voxLabels.map((l) => l.id)); - for (let attempts = 0; attempts < 10_000; attempts++) { - if (typeof crypto !== "undefined" && (crypto as any).getRandomValues) { - const a = new Uint32Array(1); - (crypto as any).getRandomValues(a); - id = a[0] >>> 0; - } else { - id = Math.floor(Math.random() * 0xffffffff) >>> 0; - } - if (id !== 0 && !used.has(id)) return id; - } - // As an ultimate fallback, probe sequentially from a time-based seed. - const base = (Date.now() ^ ((Math.random() * 0xffffffff) >>> 0)) >>> 0; - id = base || 1; - while (used.has(id)) id = (id + 1) >>> 0; - return id >>> 0; - } - colorForValue(v: number): string { - // Use segmentation-like color from SegmentColorHash seeded on numeric value - return this.segmentColorHash.computeCssColor(BigInt(v >>> 0)); - } - - // --- Labels persistence (via VoxSource) --- - private async loadLabels() { - try { - const arr = await this.voxEditController?.getLabelIds(); - if (arr && Array.isArray(arr)) { - if (arr.length > 0) { - this.voxLabels = arr.map((id) => ({ id: id >>> 0 })); - const sel = this.voxSelectedLabelId; - if (!sel || !this.voxLabels.some((l) => l.id === sel)) { - this.voxSelectedLabelId = this.voxLabels[0].id; - } - } else { - this.voxLabels = []; - this.voxSelectedLabelId = undefined; - } - } else { - throw new Error("Invalid labels response"); - } - } catch (e: any) { - const msg = `Failed to load labels: ${e?.message || e}`; - console.error(msg); - this.voxLabelsError = msg; - } finally { - // Mark labels as initialized; UI/painting should not trigger default creation before this point. - this.voxLabelsInitialized = true; - try { - this.onLabelsChanged?.(); - } catch { - /* ignore */ - } - } - } - - async createVoxLabel() { - const id = this.genId(); // unique uint32 - if (!this.voxEditController) { - const msg = "Labels backend not ready; please try again after source initializes."; - console.error(msg); - this.voxLabelsError = msg; - return; - } - try { - const updated = await this.voxEditController.addLabel(id); - this.voxLabels = updated.map((x) => ({ id: x >>> 0 })); - // Prefer to select the last label from the updated list (likely the one just added). - const last = this.voxLabels[this.voxLabels.length - 1]?.id; - this.voxSelectedLabelId = last ?? id; - this.voxLabelsError = undefined; - try { - this.onLabelsChanged?.(); - } catch { - /* ignore */ - } - } catch (e: any) { - const msg = `Failed to create label: ${e?.message || e}`; - console.error(msg); - this.voxLabelsError = msg; - try { - this.onLabelsChanged?.(); - } catch { - /* ignore */ - } - } - } - selectVoxLabel(id: number) { - const found = this.voxLabels.find((l) => l.id === id); - if (found) this.voxSelectedLabelId = id; - } - getCurrentLabelValue(): number { - if (this.voxEraseMode) return 0; - // Avoid triggering default creation during initialization. - if (!this.voxLabelsInitialized) return 0; - // Ensure we have a valid selection if labels exist. - if (!this.voxSelectedLabelId && this.voxLabels.length > 0) { - this.voxSelectedLabelId = this.voxLabels[0].id; - } - const cur = - this.voxLabels.find((l) => l.id === this.voxSelectedLabelId) || - this.voxLabels[0]; - return cur ? cur.id >>> 0 : 0; - } - constructor(managedLayer: Borrowed) { super(managedLayer); this.tabs.add("vox_settings", { diff --git a/src/voxel_annotation/labels.ts b/src/voxel_annotation/labels.ts new file mode 100644 index 0000000000..fd6bf8edfb --- /dev/null +++ b/src/voxel_annotation/labels.ts @@ -0,0 +1,131 @@ +import { SegmentColorHash } from "#src/segment_color.js"; +import type { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; + +export class LabelsManager { + onLabelsChanged?: () => void; + // Label state for painting: only store ids; colors are hashed from id on the fly + labels: { id: number }[] = []; + selectedLabelId: number | undefined = undefined; + labelsError: string | undefined = undefined; + // Indicates whether an initial labels load attempt has completed. + private labelsInitialized: boolean = false; + segmentColorHash = SegmentColorHash.getDefault(); + + async initialize(editController: VoxelEditController): Promise { + if (!editController) { + throw new Error("LabelsManager.initialize: editController is required"); + } + await this.loadLabels(editController); + } + + // --- Label helpers --- + private genId(): number { + // Generate a unique uint32 per layer session. Try crypto.getRandomValues; fallback to Math.random. + let id = 0; + const used = new Set(this.labels.map((l) => l.id)); + for (let attempts = 0; attempts < 10_000; attempts++) { + if (typeof crypto !== "undefined" && (crypto as any).getRandomValues) { + const a = new Uint32Array(1); + (crypto as any).getRandomValues(a); + id = a[0] >>> 0; + } else { + id = Math.floor(Math.random() * 0xffffffff) >>> 0; + } + if (id !== 0 && !used.has(id)) return id; + } + // As an ultimate fallback, probe sequentially from a time-based seed. + const base = (Date.now() ^ ((Math.random() * 0xffffffff) >>> 0)) >>> 0; + id = base || 1; + while (used.has(id)) id = (id + 1) >>> 0; + return id >>> 0; + } + + colorForValue(v: number): string { + // Use segmentation-like color from SegmentColorHash seeded on numeric value + return this.segmentColorHash.computeCssColor(BigInt(v >>> 0)); + } + + // --- Labels persistence (via VoxSource) --- + private async loadLabels(editController: VoxelEditController) { + try { + const arr = await editController?.getLabelIds(); + if (arr && Array.isArray(arr)) { + if (arr.length > 0) { + this.labels = arr.map((id) => ({ id: id >>> 0 })); + const sel = this.selectedLabelId; + if (!sel || !this.labels.some((l) => l.id === sel)) { + this.selectedLabelId = this.labels[0].id; + } + } else { + this.labels = []; + this.selectedLabelId = undefined; + } + } else { + throw new Error("Invalid labels response"); + } + } catch (e: any) { + const msg = `Failed to load labels: ${e?.message || e}`; + console.error(msg); + this.labelsError = msg; + } finally { + // Mark labels as initialized; UI/painting should not trigger default creation before this point. + this.labelsInitialized = true; + try { + this.onLabelsChanged?.(); + } catch { + /* ignore */ + } + } + } + + async createVoxLabel(editController: VoxelEditController | undefined) { + const id = this.genId(); // unique uint32 + if (!editController) { + const msg = "Labels backend not ready; please try again after source initializes."; + console.error(msg); + this.labelsError = msg; + return; + } + try { + const updated = await editController.addLabel(id); + this.labels = updated.map((x) => ({ id: x >>> 0 })); + // Prefer to select the last label from the updated list (likely the one just added). + const last = this.labels[this.labels.length - 1]?.id; + this.selectedLabelId = last ?? id; + this.labelsError = undefined; + try { + this.onLabelsChanged?.(); + } catch { + /* ignore */ + } + } catch (e: any) { + const msg = `Failed to create label: ${e?.message || e}`; + console.error(msg); + this.labelsError = msg; + try { + this.onLabelsChanged?.(); + } catch { + /* ignore */ + } + } + } + + selectVoxLabel(id: number) { + const found = this.labels.find((l) => l.id === id); + if (found) this.selectedLabelId = id; + } + + getCurrentLabelValue(eraseMode: boolean): number { + if (eraseMode) return 0; + // Avoid triggering default creation during initialization. + if (!this.labelsInitialized) return 0; + // Ensure we have a valid selection if labels exist. + if (!this.selectedLabelId && this.labels.length > 0) { + this.selectedLabelId = this.labels[0].id; + } + const cur = + this.labels.find((l) => l.id === this.selectedLabelId) || + this.labels[0]; + return cur ? cur.id >>> 0 : 0; + } +} From d16fb23688d418b20edcfd6d0bcd7b7f6ae7d918 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 051/251] refactor: remove `RemoteVoxSource` and refactor related components for local-only map handling - Remove `RemoteVoxSource` and clean up references in backend, frontend, and voxel annotation logic. - Refactor map configuration to remove obsolete remote server fields (`serverUrl`, `token`). - Update UI components and settings for local-only map interactions. --- NOTES/TODOs.md | 1 - src/layer/vox/index.ts | 153 +---------- src/layer/vox/tabs/tools.ts | 16 +- src/ui/voxel_annotations.ts | 6 +- src/voxel_annotation/backend.ts | 21 +- src/voxel_annotation/edit_backend.ts | 5 +- src/voxel_annotation/frontend.ts | 8 +- src/voxel_annotation/local_source.ts | 2 - src/voxel_annotation/map.ts | 7 - src/voxel_annotation/remote_source.ts | 269 -------------------- src/voxel_annotation/volume_chunk_source.ts | 4 - 11 files changed, 18 insertions(+), 474 deletions(-) delete mode 100644 src/voxel_annotation/remote_source.ts diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index fdc3649ac4..dbde56e749 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -12,7 +12,6 @@ dicator in the ui. We also may need to restrict the max bru sh size to avoid too long waiting time." -> no upscaling for now (e.g. drawn voxel size/lod level is always 1), only downscaling. -- cleanup label handling code (more specifically in the ui code: layer/vox/index.ts, would be nice to have a handler similar to the one for maps) - continue to study the segmentation compression, using it should greatly reduce the ram and indexDB usage, but it no easy integration of the hot chunk reloading in the frontend for drawing tool responsiveness has been found. - Fix the orientation of the disk in the brush tool - Add support for flood fill on different planes diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 1e68531dce..c74c3771e1 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -40,7 +40,6 @@ import { VoxSettingsTab } from "#src/layer/vox/tabs/settings.js"; import { VoxToolTab } from "#src/layer/vox/tabs/tools.js"; import { getWatchableRenderLayerTransform } from "#src/render_coordinate_transform.js"; import { - RenderScaleHistogram, trackableRenderScaleTarget, } from "#src/render_scale_statistics.js"; import { @@ -51,7 +50,6 @@ import { mat4 } from "#src/util/geom.js"; import { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; import { LabelsManager } from "#src/voxel_annotation/labels.js"; import { VoxMapRegistry } from "#src/voxel_annotation/map.js"; -import { RemoteVoxSource } from "#src/voxel_annotation/remote_source.js"; import { VoxelAnnotationRenderLayer } from "#src/voxel_annotation/renderlayer.js"; import { VoxMultiscaleVolumeChunkSource } from "#src/voxel_annotation/volume_chunk_source.js"; @@ -59,7 +57,6 @@ export class VoxUserLayer extends UserLayer { // While drawing, we keep a reference to the vox render layer to control temporary LOD locks. private voxRenderLayerInstance?: VoxelAnnotationRenderLayer; // Match Image/Segmentation layers: provide a per-layer cross-section render scale target/histogram. - sliceViewRenderScaleHistogram = new RenderScaleHistogram(); sliceViewRenderScaleTarget = trackableRenderScaleTarget(1); static type = "vox"; static typeAbbreviation = "vox"; @@ -85,47 +82,6 @@ export class VoxUserLayer extends UserLayer { } } - // Labels manager integration proxies - get onLabelsChanged(): (() => void) | undefined { - return this.voxLabelsManager.onLabelsChanged; - } - set onLabelsChanged(cb: (() => void) | undefined) { - this.voxLabelsManager.onLabelsChanged = cb; - } - - get voxLabels(): { id: number }[] { - return this.voxLabelsManager.labels; - } - get voxSelectedLabelId(): number | undefined { - return this.voxLabelsManager.selectedLabelId; - } - get voxLabelsError(): string | undefined { - return this.voxLabelsManager.labelsError; - } - - colorForValue(v: number): string { - return this.voxLabelsManager.colorForValue(v); - } - createVoxLabel(): void { - this.voxLabelsManager.createVoxLabel(this.voxEditController); - } - selectVoxLabel(id: number): void { - this.voxLabelsManager.selectVoxLabel(id); - } - getCurrentLabelValue(): number { - return this.voxLabelsManager.getCurrentLabelValue(!!this.voxEraseMode); - } - - private async loadLabels(): Promise { - const ctrl = this.voxEditController; - if (!ctrl) return; - await this.voxLabelsManager.initialize(ctrl); - } - - // Remote server configuration when using vox+http(s):// data sources - voxServerUrl?: string; - voxServerToken?: string; - beginRenderLodLock(lockedIndex: number): void { if (!Number.isInteger(lockedIndex) || lockedIndex < 0) { throw new Error("beginRenderLodLock: lockedIndex must be a non-negative integer"); @@ -230,88 +186,10 @@ export class VoxUserLayer extends UserLayer { } } - /** Returns in-plane basis vectors (u, v) for the current slice plane in voxel coordinates. - * Uses MouseSelectionState.displayDimensions to select the two displayed axes, then maps unit - * vectors along those axes through the renderLayer->voxel transform. - * TODO: this is not working, ai are dogshit at 3d stuffs. - */ - getBrushPlaneBasis( - mouseState?: MouseSelectionState, - ): { u: Float32Array; v: Float32Array } | undefined { - try { - const inv = this.getModelToVoxTransform(); - if (!inv) return undefined; - const di = mouseState?.displayDimensions?.displayDimensionIndices; - const rank = mouseState?.displayDimensions?.displayRank ?? 0; - const i0 = di && rank >= 2 ? di[0] : 0; - const i1 = di && rank >= 2 ? di[1] : 1; - - // Build origin and unit vectors in model/render-layer coordinate space aligned to displayed axes. - const p0 = vec3.transformMat4( - vec3.create(), - vec3.fromValues(0, 0, 0), - inv, - ); - const uModel = [0, 0, 0] as number[]; - const vModel = [0, 0, 0] as number[]; - if (i0 >= 0 && i0 < 3) uModel[i0] = 1; - if (i1 >= 0 && i1 < 3) vModel[i1] = 1; - const pU = vec3.transformMat4( - vec3.create(), - vec3.fromValues(uModel[0], uModel[1], uModel[2]), - inv, - ); - const pV = vec3.transformMat4( - vec3.create(), - vec3.fromValues(vModel[0], vModel[1], vModel[2]), - inv, - ); - - // Compute direction vectors and normalize. - const ux = pU[0] - p0[0]; - const uy = pU[1] - p0[1]; - const uz = pU[2] - p0[2]; - const vx = pV[0] - p0[0]; - const vy = pV[1] - p0[1]; - const vz = pV[2] - p0[2]; - - const ul = Math.hypot(ux, uy, uz); - const vl = Math.hypot(vx, vy, vz); - if (!Number.isFinite(ul) || ul === 0 || !Number.isFinite(vl) || vl === 0) - return undefined; - - const u = new Float32Array([ux / ul, uy / ul, uz / ul]); - const v = new Float32Array([vx / vl, vy / vl, vz / vl]); - return { u, v }; - } catch { - return undefined; - } - } - - - private parseVoxRemoteUrl(url: string): { scheme: string; baseUrl: string; token?: string } | undefined { - const m = url.match(/^(vox\+https?):\/\/(.+)$/); - if (!m) return undefined; - const scheme = m[1]; // vox+http or vox+https - const rest = m[2]; - // Build a temporary URL for parsing. Always ensure there is a protocol. - const proto = scheme.substring(4); // http or https - // If rest already contains a path/query, URL will parse it. - let tmp: URL; - try { - tmp = new URL(`${proto}://${rest}`); - } catch { - return undefined; - } - const baseUrl = `${proto}://${tmp.host}`; - const token = tmp.searchParams.get("token") || undefined; - return { scheme, baseUrl, token }; - } - - private async verifyVoxRemote(baseUrl: string, token?: string): Promise { - // Delegate verification to VoxSource: attempt to list maps via RemoteVoxSource. - const src = new RemoteVoxSource(baseUrl, token); - await src.listMaps(); + private async loadLabels(): Promise { + const controller = this.voxEditController; + if (!controller) return; + await this.voxLabelsManager.initialize(controller); } buildOrRebuildVoxLayer() { @@ -401,37 +279,16 @@ export class VoxUserLayer extends UserLayer { const { subsourceEntry } = loadedSubsource; const { subsource } = subsourceEntry; const isLocalVox = subsource.local === LocalDataSource.voxelAnnotations; - const urlStr = loadedSubsource.loadedDataSource.layerDataSource.spec.url; if (isLocalVox) { // Local in-memory vox datasource. - this.voxServerUrl = undefined; - this.voxServerToken = undefined; this.voxLoadedSubsource = loadedSubsource; continue; } - // Non-local: only accept vox+http(s) schemes. - const parsed = this.parseVoxRemoteUrl(urlStr); - if (parsed) { - // Verify the remote server before activation. - (async () => { - try { - await this.verifyVoxRemote(parsed.baseUrl, parsed.token); - this.voxServerUrl = parsed.baseUrl; - this.voxServerToken = parsed.token; - this.voxLoadedSubsource = loadedSubsource; - } catch (e: any) { - const msg = `Vox remote source check failed: ${e?.message || e}`; - loadedSubsource.deactivate(msg); - } - })(); - continue; - } - // Reject anything else. loadedSubsource.deactivate( - "Not compatible with vox layer; supported sources: local://voxel-annotations, vox+http://host[:port]/(?token=TOKEN), vox+https://host[:port]/(?token=TOKEN)", + "Not compatible with vox layer; supported sources: local://voxel-annotations", ); } } diff --git a/src/layer/vox/tabs/tools.ts b/src/layer/vox/tabs/tools.ts index 0f1272253e..0c97990579 100644 --- a/src/layer/vox/tabs/tools.ts +++ b/src/layer/vox/tabs/tools.ts @@ -16,8 +16,8 @@ export class VoxToolTab extends Tab { private renderLabels() { const cont = this.labelsContainer; cont.innerHTML = ""; - const labels = this.layer.voxLabels; - const selected = this.layer.voxSelectedLabelId; + const labels = this.layer.voxLabelsManager.labels; + const selected = this.layer.voxLabelsManager.selectedLabelId; for (const lab of labels) { const row = document.createElement("div"); row.className = "neuroglancer-vox-label-row"; @@ -31,7 +31,7 @@ export class VoxToolTab extends Tab { sw.style.height = "16px"; sw.style.borderRadius = "3px"; sw.style.border = "1px solid rgba(0,0,0,0.2)"; - sw.style.background = this.layer.colorForValue(lab.id); + sw.style.background = this.layer.voxLabelsManager.colorForValue(lab.id); // id text (monospace) const txt = document.createElement("div"); txt.textContent = String(lab.id >>> 0); @@ -51,13 +51,13 @@ export class VoxToolTab extends Tab { row.style.outline = "1px solid rgba(100,150,255,0.6)"; } row.addEventListener("click", () => { - this.layer.selectVoxLabel(lab.id); + this.layer.voxLabelsManager.selectVoxLabel(lab.id); this.renderLabels(); }); cont.appendChild(row); } // Update error message area - const err = this.layer.voxLabelsError; + const err = this.layer.voxLabelsManager.labelsError; if (err && err.length > 0) { this.labelsError.textContent = err; this.labelsError.style.display = "block"; @@ -278,8 +278,8 @@ export class VoxToolTab extends Tab { const createBtn = document.createElement("button"); createBtn.textContent = "New label"; createBtn.addEventListener("click", () => { - this.layer.createVoxLabel(); - // Rendering will be triggered by layer via onLabelsChanged callback. + this.layer.voxLabelsManager.createVoxLabel(this.layer.voxEditController); + // Rendering will be triggered by LabelsManager via onLabelsChanged callback. }); buttonsRow.appendChild(createBtn); @@ -326,7 +326,7 @@ export class VoxToolTab extends Tab { } }; - this.layer.onLabelsChanged = () => this.requestRenderLabels(); + this.layer.voxLabelsManager.onLabelsChanged = () => this.requestRenderLabels(); this.layer.onDrawMessageChanged = () => updateDrawError(); this.renderLabels(); diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index db265caddb..0d46712375 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -91,9 +91,7 @@ export const FLOODFILL_TOOL_ID = "voxFloodFill"; } layer.beginRenderLodLock(editLodIndex); - const value = - layer.getCurrentLabelValue() ?? - (layer.voxEraseMode ? 0 : 42); + const value = layer.voxLabelsManager.getCurrentLabelValue(layer.voxEraseMode); this.paintPoint(centerCanonical, value); this.lastPoint = start; @@ -217,7 +215,7 @@ export class VoxelFloodFillLegacyTool extends LegacyTool { throw new Error("Flood fill: failed to get voxel position from mouse"); } - const value = layer.getCurrentLabelValue(); + const value = layer.voxLabelsManager.getCurrentLabelValue(layer.voxEraseMode); const max = Number((layer as any).voxFloodMaxVoxels); if (!Number.isFinite(max) || max <= 0) { throw new Error("Flood fill: invalid max voxels; set it in the tool panel"); diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts index 4a4892894d..0d2a8ad6c2 100644 --- a/src/voxel_annotation/backend.ts +++ b/src/voxel_annotation/backend.ts @@ -16,7 +16,6 @@ import { LocalVoxSource, } from "#src/voxel_annotation/local_source.js"; import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; -import { RemoteVoxSource } from "#src/voxel_annotation/remote_source.js"; import type { RPC } from "#src/worker_rpc.js"; import { registerRPC, registerSharedObject } from "#src/worker_rpc.js"; // Ensure voxel edit backend and its RPC handlers are registered in the worker bundle. @@ -30,8 +29,6 @@ import "#src/voxel_annotation/edit_backend.js"; @registerSharedObject(VOX_CHUNK_SOURCE_RPC_ID) export class VoxChunkSource extends BaseVolumeChunkSource { private source?: VoxSource; - private voxServerUrl?: string; - private voxToken?: string; public lodFactor: number; private mapReadyPromise: Promise; private resolveMapReady!: () => void; @@ -40,8 +37,6 @@ export class VoxChunkSource extends BaseVolumeChunkSource { super(rpc, options); // Detect remote server configuration from options (flexible keys) const o = options || {}; - this.voxServerUrl = o.voxServerUrl || o.serverUrl || o.vox?.serverUrl; - this.voxToken = o.voxToken || o.token || o.vox?.token; this.lodFactor = o.lodFactor; if (this.lodFactor == undefined) { throw new Error("lodFactor is required"); @@ -54,21 +49,7 @@ export class VoxChunkSource extends BaseVolumeChunkSource { async initMap(arg: { map?: VoxMapConfig } | VoxMapConfig) { const map: VoxMapConfig = (arg as any)?.map ?? (arg as any); if (!map) throw new Error("initMap: map configuration is required"); - if (map.serverUrl) { - if (this.voxServerUrl && this.voxServerUrl !== map.serverUrl) { - throw new Error("initMap: conflicting serverUrl provided"); - } - this.voxServerUrl = map.serverUrl; - } - if (map.token) { - if (this.voxToken && this.voxToken !== map.token) { - throw new Error("initMap: conflicting token provided"); - } - this.voxToken = map.token; - } - const src = this.voxServerUrl - ? new RemoteVoxSource(this.voxServerUrl, this.voxToken) - : new LocalVoxSource(); + const src = new LocalVoxSource(); await src.init(map); this.source = src; try { this.resolveMapReady(); } catch { /* ignore */ } diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/edit_backend.ts index 6da0896b43..eb95a75ce8 100644 --- a/src/voxel_annotation/edit_backend.ts +++ b/src/voxel_annotation/edit_backend.ts @@ -16,7 +16,6 @@ import { import type { VoxSourceWriter } from "#src/voxel_annotation/index.js"; import { LocalVoxSourceWriter } from "#src/voxel_annotation/local_source.js"; import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; -import { RemoteVoxSource } from "#src/voxel_annotation/remote_source.js"; import type { RPC} from "#src/worker_rpc.js"; import { SharedObject , registerPromiseRPC, registerRPC, registerSharedObject, initializeSharedObjectCounterpart } from "#src/worker_rpc.js"; @@ -50,9 +49,7 @@ export class VoxelEditController extends SharedObject { async initMap(arg: { map?: VoxMapConfig } | VoxMapConfig) { const map: VoxMapConfig = (arg as any)?.map ?? (arg as any); if (!map) throw new Error("VoxEditBackend.initMap: map configuration is required"); - const src = map.serverUrl - ? new RemoteVoxSource(map.serverUrl, map.token) - : new LocalVoxSourceWriter(this); + const src = new LocalVoxSourceWriter(this); await src.init(map); this.source = src; try { this.resolveMapReady(); } catch {/* ignore */} diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index a6a9150a8c..4adb49783b 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -25,10 +25,8 @@ import { registerSharedObjectOwner } from "#src/worker_rpc.js"; export class VoxChunkSource extends BaseVolumeChunkSource { declare OPTIONS: { spec: VolumeChunkSpecification; - vox?: { serverUrl?: string; token?: string }; lodFactor?: number; }; - private voxOptions?: { serverUrl?: string; token?: string }; private tempVoxChunkGridPosition = new Float32Array(3); private tempLocalPosition = new Uint32Array(3); private dirtyChunks = new Set(); @@ -51,18 +49,14 @@ export class VoxChunkSource extends BaseVolumeChunkSource { constructor( chunkManager: ChunkManager, - options: { spec: VolumeChunkSpecification; vox?: { serverUrl?: string; token?: string }; lodFactor?: number }, + options: { spec: VolumeChunkSpecification; lodFactor?: number }, ) { super(chunkManager, options); - this.voxOptions = options.vox; this.lodFactor = options.lodFactor ?? 1; } override initializeCounterpart(rpc: any, options: any) { const opts = { ...(options || {}), spec: this.spec }; - if (this.voxOptions) { - (opts as any).vox = { ...this.voxOptions }; - } opts.lodFactor = this.lodFactor; super.initializeCounterpart(rpc, opts); } diff --git a/src/voxel_annotation/local_source.ts b/src/voxel_annotation/local_source.ts index bdb60fcaa3..951949f279 100644 --- a/src/voxel_annotation/local_source.ts +++ b/src/voxel_annotation/local_source.ts @@ -307,8 +307,6 @@ export class LocalVoxSourceWriter extends VoxSourceWriter { scaleMeters: Array.from(r.scaleMeters ?? [1,1,1]), unit: String(r.unit), steps: Array.isArray(r?.steps) ? r.steps : steps, - serverUrl: r?.serverUrl, - token: r?.token, importUrl: r?.importUrl, }); maps.push(map); diff --git a/src/voxel_annotation/map.ts b/src/voxel_annotation/map.ts index 70dc8f57b2..6af0671915 100644 --- a/src/voxel_annotation/map.ts +++ b/src/voxel_annotation/map.ts @@ -22,9 +22,6 @@ export interface VoxMapConfig { steps: number[]; // Optional original data source URL for on-demand import of base labels (e.g., precomputed://, zarr://, n5://) importUrl?: string; - // Legacy/obsolete remote server fields retained for backward compatibility. Do not use. - serverUrl?: string; - token?: string; } /** @@ -103,8 +100,6 @@ export type VoxMapInput = { unit: string; steps?: number[]; importUrl?: string; - serverUrl?: string; - token?: string; }; export function constructVoxMapConfig(input: VoxMapInput): VoxMapConfig { @@ -158,8 +153,6 @@ export function constructVoxMapConfig(input: VoxMapInput): VoxMapConfig { unit, steps, importUrl: input.importUrl, - serverUrl: input.serverUrl, - token: input.token, }; } diff --git a/src/voxel_annotation/remote_source.ts b/src/voxel_annotation/remote_source.ts deleted file mode 100644 index f15cdfaf0d..0000000000 --- a/src/voxel_annotation/remote_source.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { DataType } from "#src/sliceview/base.js"; -import type { SavedChunk} from "#src/voxel_annotation/index.js"; -import { VoxSourceWriter } from "#src/voxel_annotation/index.js"; -import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; -import { computeSteps } from "#src/voxel_annotation/map.js"; - -// OBSOLETE DO NOT USE THIS CLASS -export class RemoteVoxSource extends VoxSourceWriter { - async listMaps(): Promise { - try { - const qs = this.qs({}); - const json = await this.httpGetJson(`${this.baseUrl}/info${qs}`); - const datasets = Array.isArray(json?.datasets) ? json.datasets : []; - const out: VoxMapConfig[] = []; - const toXYZ = (ary: number[]) => { - const a = ary.map((v) => Math.max(0, Math.floor(v ?? 0))); - if (a.length >= 3) return [a[2] || 0, a[1] || 0, a[0] || 0]; - return [a[0] || 0, a[1] || 0, a[2] || 0]; - }; - for (const ds of datasets) { - try { - const id: string = String(ds?.mapId ?? ds?.id ?? ds?.name ?? ds?.url ?? `map-${Date.now()}`); - const arrays = Array.isArray(ds?.arrays) ? ds.arrays : []; - const arr = arrays.find((a: any) => a?.path === "0") ?? arrays[0]; - if (!arr) continue; - // TODO: the server way of storing maps is wrong, espcially the bounds, data organization and missing scale and unit - const dtype = String(arr?.dtype) === "uint64" ? DataType.UINT64 : DataType.UINT32; - const upper = toXYZ(arr.shape); - const cds = toXYZ(arr.chunks).map((v) => Math.max(1, v)); - const lower = [0, 0, 0]; - const steps = computeSteps(upper, cds); - out.push({ - scaleMeters: [0.000000008, 0.000000008, 0.000000008], - unit: "nm", - id, - name: ds?.name ?? id, - baseVoxelOffset: new Float32Array(lower), - upperVoxelBound: new Float32Array(upper), - chunkDataSize: new Uint32Array(cds), - dataType: dtype, - steps, - serverUrl: this.baseUrl, - token: this.token - }); - } catch { - // ignore - } - } - return out; - } catch { - return [] as VoxMapConfig[]; - } - } - private labelsCache: number[] = []; - private baseUrl: string; - private token?: string; - - constructor(url: string, token?: string) { - super(); - this.baseUrl = url.replace(/\/$/, ""); - this.token = token; - } - - // ---- Public API overrides ---- - override async init(map: VoxMapConfig) { - const meta = await super.init(map); - // Bind dtype string - const dtypeStr = this.dtypeToString((this.mapCfg?.dataType ?? DataType.UINT32) as number); - // Call /init (best-effort; server may already have it) - const qs = this.qs({ - mapId: this.mapId, - dtype: dtypeStr, - }); - try { - await this.httpGet(`${this.baseUrl}/init${qs}`); - } catch { - // ignore - } - return meta; - } - - async getSavedChunk(key: string): Promise { - const existing = this.saved.get(key); - if (existing) return existing; - const qs = this.qs({ mapId: this.mapId, chunkKey: key }); - try { - const buf = await this.httpGetArrayBuffer(`${this.baseUrl}/chunk${qs}`); - if (!buf) return undefined; - const arr = this.makeTypedArrayFromBuffer(buf); - const sc: SavedChunk = { data: arr, size: new Uint32Array(this.mapCfg!.chunkDataSize as any) }; - this.saved.set(key, sc); - this.enforceCap(); - return sc; - } catch (e: any) { - // 404 → not found - return undefined; - } - } - - async ensureChunk(key: string, size?: Uint32Array | number[]): Promise { - let sc = this.saved.get(key); - if (sc) return sc; - sc = await this.getSavedChunk(key); - if (sc) return sc; - // allocate zero-filled - const fallbackSize = new Uint32Array(this.mapCfg!.chunkDataSize as any); - const sz = new Uint32Array(size ?? fallbackSize); - const total = (sz[0] | 0) * (sz[1] | 0) * (sz[2] | 0); - const data = this.allocateTypedArray(total); - sc = { data, size: new Uint32Array(sz) }; - this.saved.set(key, sc); - this.enforceCap(); - this.markDirty(key); - return sc; - } - - async applyEdits( - edits: { - key: string; - indices: ArrayLike; - value?: number; - values?: ArrayLike; - size?: number[]; - }[], - ) { - for (const e of edits) { - const sc = await this.ensureChunk( - e.key, - e.size ? new Uint32Array(e.size) : (this.mapCfg!.chunkDataSize as any), - ); - this.applyEditsIntoChunk(sc, e.indices, e.value, e.values); - this.markDirty(e.key); - } - } - - protected override async flushSaves() { - const keys = Array.from(this.dirty); - if (keys.length === 0) { - this.saveTimer = undefined; - return; - } - this.dirty.clear(); - for (const key of keys) { - const sc = this.saved.get(key); - if (!sc) continue; - const qs = this.qs({ mapId: this.mapId, chunkKey: key }); - try { - await this.httpPutArrayBuffer(`${this.baseUrl}/chunk${qs}`, sc.data as any); - } catch (e) { - // If failed, keep dirty to retry later - this.dirty.add(key); - } - } - this.saveTimer = undefined; - } - - // ---- Helpers ---- - private qs(params: Record) { - const usp = new URLSearchParams(); - for (const [k, v] of Object.entries(params)) { - if (v === undefined || v === null) continue; - usp.set(k, String(v)); - } - if (this.token) usp.set("token", this.token); - const s = usp.toString(); - return s ? `?${s}` : ""; - } - - private dtypeToString(dt: number): "uint32" | "uint64" { - return dt === DataType.UINT64 ? "uint64" : "uint32"; - } - - private allocateTypedArray(total: number): Uint32Array | BigUint64Array { - if ((this.mapCfg?.dataType ?? DataType.UINT32) === DataType.UINT64) return new BigUint64Array(total); - return new Uint32Array(total); - } - - private makeTypedArrayFromBuffer(buf: ArrayBuffer): Uint32Array | BigUint64Array { - if ((this.mapCfg?.dataType ?? DataType.UINT32) === DataType.UINT64) return new BigUint64Array(buf); - return new Uint32Array(buf); - } - - private async httpGet(url: string): Promise { - const res = await fetch(url, { method: "GET", credentials: "omit" }); - if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`); - return res; - } - - private async httpGetArrayBuffer(url: string): Promise { - const res = await fetch(url, { method: "GET", credentials: "omit" }); - if (res.status === 404) return undefined; - if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`); - return await res.arrayBuffer(); - } - - private async httpPutArrayBuffer( - url: string, - body: ArrayBufferLike | ArrayBufferView, - ): Promise { - // Ensure we pass an ArrayBufferView to satisfy fetch BodyInit typing across platforms. - let payload: ArrayBufferView; - if (body instanceof ArrayBuffer) { - payload = new Uint8Array(body); - } else if ((body as any).buffer && (body as any).byteLength !== undefined) { - payload = body as ArrayBufferView; - } else { - payload = new Uint8Array(body as ArrayBufferLike); - } - const res = await fetch(url, { - method: "PUT", - body: payload as any, - headers: { "Content-Type": "application/octet-stream" }, - credentials: "omit", - }); - if (!res.ok) throw new Error(`PUT ${url} -> ${res.status}`); - } - - private async httpGetJson(url: string): Promise { - const res = await fetch(url, { method: "GET", credentials: "omit" }); - if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`); - return await res.json(); - } - - private async httpPutJson(url: string, body: any): Promise { - const res = await fetch(url, { - method: "PUT", - body: typeof body === "string" ? body : JSON.stringify(body), - headers: { "Content-Type": "application/json" }, - credentials: "omit", - }); - if (!res.ok) throw new Error(`PUT ${url} -> ${res.status}`); - return await res.json(); - } - - // --- Labels via remote server endpoints --- - override async getLabelIds(): Promise { - const qs = this.qs({ mapId: this.mapId }); - const json = await this.httpGetJson(`${this.baseUrl}/labels${qs}`); - const arr = Array.isArray(json?.labels) ? json.labels : []; - this.labelsCache = arr.map((v: any) => (v as number) >>> 0); - return Array.from(this.labelsCache); - } - - - override async addLabel(value: number): Promise { - const v = value >>> 0; - // If dtype is UINT64 we still send a 32-bit value; server must accept as valid subset. -> TODO: no - const qs = this.qs({ mapId: this.mapId }); - const json = await this.httpPutJson(`${this.baseUrl}/labels${qs}`, { value: v }); - const arr = Array.isArray(json?.labels) ? json.labels : []; - this.labelsCache = arr.map((x: any) => (x as number) >>> 0); - return Array.from(this.labelsCache); - } - - // LRU-style cap similar to LocalVoxSource - private enforceCap() { - while (this.saved.size > this.maxSavedChunks) { - let oldestKey: string | undefined; - for (const k of this.saved.keys()) { - if (!this.dirty.has(k)) { - oldestKey = k; - break; - } - } - if (oldestKey === undefined) break; - this.saved.delete(oldestKey); - } - } -} diff --git a/src/voxel_annotation/volume_chunk_source.ts b/src/voxel_annotation/volume_chunk_source.ts index eb5ace4b2d..86e88dc877 100644 --- a/src/voxel_annotation/volume_chunk_source.ts +++ b/src/voxel_annotation/volume_chunk_source.ts @@ -92,10 +92,6 @@ export class VoxMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSource VoxChunkSource, { spec: baseSpec, - vox: { - serverUrl: map.serverUrl, - token: map.token, - }, lodFactor: f, }, ); From 3dd2e997feb481d461ab3bf29b19c99f2158ea41 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 052/251] feat: add Stateless S3 Authenticator (SSA) integration with KVStore - Implement `SsaCredentialsProvider` for managing OAuth2 credentials via SSA authentication flow. - Add `SsaS3KvStore` to support SSA-backed storage integration with KVStore. - Introduce `register_credentials_provider` for default SSA credential registration. - Integrate `ssaFrontendProvider` to handle SSA URLs within the KVStore frontend. - Extend URL completion and file listing logic to align with SSA conventions. - Update project guidelines to discourage inline imports for better modularity. --- .junie/guidelines.md | 1 + NOTES/TODOs.md | 4 +- package.json | 13 + src/kvstore/enabled_frontend_modules.ts | 2 + src/kvstore/ssa_s3/credentials_provider.ts | 170 ++++++++ .../ssa_s3/register_credentials_provider.ts | 22 + src/kvstore/ssa_s3/register_frontend.ts | 158 +++++++ src/kvstore/ssa_s3/ssa_s3_kvstore.ts | 391 ++++++++++++++++++ src/voxel_annotation/frontend.ts | 15 +- 9 files changed, 761 insertions(+), 15 deletions(-) create mode 100644 src/kvstore/ssa_s3/credentials_provider.ts create mode 100644 src/kvstore/ssa_s3/register_credentials_provider.ts create mode 100644 src/kvstore/ssa_s3/register_frontend.ts create mode 100644 src/kvstore/ssa_s3/ssa_s3_kvstore.ts diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 43cc43d7f5..ca41ca628c 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -4,5 +4,6 @@ You must follow the following code guidelines: - Use detailed variable and function names, a good code should explain itself without comments. - Avoid fallbacks and default values, always prefer throwing errors on unexpected behavior. - Avoid casting with `as` unless absolutely necessary, prefer proper type definitions and checks. +- Never use inline imports (e.g., `const ... = await import('...')`) You are here to help me implement a new voxel annotation feature into neuroglancer. See [vox-annotation-project-overview.md](../NOTES/vox-annotation-project-overview.md) for complete project details. diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index dbde56e749..5d5416ae0c 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,6 +1,6 @@ # TODO List -- FOR TOMORROW: fix plane orientation in the brush and flood tools + ask about the R2 storage on cloudflare, it is behind a "put your credit card" wall. +- FOR TOMORROW: review, test and finish the ssa+https - LOD -> - "feat: dirty tree upscaling is kinda working, at lea @@ -24,6 +24,8 @@ - rework the ui (tabs) - add persistance to vox layer - add shortcuts for tools (switching tools, toogle erase mode, select label from the pointed one in the slice view and adjusting brush size) and label creation +- the flood fill may fill an entire unwanted area if the user is zoomed in enough so the max number of loaded chunks are under the vox voxel count and the flood has an escape hole. +- the flood fill sometimes leaves artifacts in sharp areas # Saving/importing/exporting diff --git a/package.json b/package.json index 05bbe0a154..d40092f29a 100644 --- a/package.json +++ b/package.json @@ -485,6 +485,19 @@ "neuroglancer/kvstore/s3:disabled": "./src/util/false.ts", "default": "./src/kvstore/s3/register_backend.ts" }, + "#kvstore/ssa_s3/register_credentials_provider": { + "neuroglancer/python": "./src/util/false.ts", + "neuroglancer/kvstore/ssa_s3:enabled": "./src/kvstore/ssa_s3/register_credentials_provider.ts", + "neuroglancer/kvstore:none_by_default": "./src/util/false.ts", + "neuroglancer/kvstore/ssa_s3:disabled": "./src/util/false.ts", + "default": "./src/kvstore/ssa_s3/register_credentials_provider.ts" + }, + "#kvstore/ssa_s3/register_frontend": { + "neuroglancer/kvstore/ssa_s3:enabled": "./src/kvstore/ssa_s3/register_frontend.ts", + "neuroglancer/kvstore:none_by_default": "./src/util/false.ts", + "neuroglancer/kvstore/ssa_s3:disabled": "./src/util/false.ts", + "default": "./src/kvstore/ssa_s3/register_frontend.ts" + }, "#kvstore/zip/register_frontend": { "neuroglancer/kvstore/zip:enabled": "./src/kvstore/zip/register_frontend.ts", "neuroglancer/kvstore:none_by_default": "./src/util/false.ts", diff --git a/src/kvstore/enabled_frontend_modules.ts b/src/kvstore/enabled_frontend_modules.ts index 476e2f6d1d..3515ee62f4 100644 --- a/src/kvstore/enabled_frontend_modules.ts +++ b/src/kvstore/enabled_frontend_modules.ts @@ -10,4 +10,6 @@ import "#kvstore/ngauth/register"; import "#kvstore/ngauth/register_credentials_provider"; import "#kvstore/ocdbt/register_frontend"; import "#kvstore/s3/register_frontend"; +import "#kvstore/ssa_s3/register_credentials_provider"; +import "#kvstore/ssa_s3/register_frontend"; import "#kvstore/zip/register_frontend"; diff --git a/src/kvstore/ssa_s3/credentials_provider.ts b/src/kvstore/ssa_s3/credentials_provider.ts new file mode 100644 index 0000000000..e358a8b010 --- /dev/null +++ b/src/kvstore/ssa_s3/credentials_provider.ts @@ -0,0 +1,170 @@ +/** + * @license + * Copyright 2025 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 { + CredentialsProvider, + makeCredentialsGetter, +} from "#src/credentials_provider/index.js"; +import { + getCredentialsWithStatus, + monitorAuthPopupWindow, +} from "#src/credentials_provider/interactive_credentials_provider.js"; +import type { OAuth2Credentials } from "#src/credentials_provider/oauth2.js"; +import { raceWithAbort } from "#src/util/abort.js"; +import { fetchOk } from "#src/util/http_request.js"; +import { + verifyObject, + verifyObjectProperty, + verifyOptionalObjectProperty, + verifyString, +} from "#src/util/json.js"; +import { ProgressSpan } from "#src/util/progress_listener.js"; + +interface SsaConfiguration { + issuer: string; + authorizationUrl: string; + // Optional field to allow the SSA deployment to specify a dedicated popup endpoint. + popupAuthorizationUrl?: string; +} + +interface SsaPopupAuthResult { + access_token: string; + token_type: string; + email?: string; +} + +function parseSsaConfiguration(json: unknown): SsaConfiguration { + const obj = verifyObject(json); + const issuer = verifyObjectProperty(obj, "issuer", verifyString); + const authorizationUrl = verifyObjectProperty( + obj, + "authorization_url", + verifyString, + ); + const popupAuthorizationUrl = verifyOptionalObjectProperty( + obj, + "popup_authorization_url", + verifyString, + ); + return { issuer, authorizationUrl, popupAuthorizationUrl }; +} + +async function discoverSsaConfiguration(workerOrigin: string): Promise { + const response = await fetchOk(`${workerOrigin}/.well-known/ssa-configuration`); + const config = parseSsaConfiguration(await response.json()); + return config; +} + +async function waitForPopupAuthMessage( + expectedOrigin: string, + source: Window, + signal: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + window.addEventListener( + "message", + (event: MessageEvent) => { + if (event.source !== source) return; + if (event.origin !== expectedOrigin) return; + try { + const data = verifyObject(event.data); + const access_token = verifyObjectProperty(data, "access_token", verifyString); + const token_type = verifyObjectProperty(data, "token_type", verifyString); + const email = verifyOptionalObjectProperty(data, "email", verifyString); + resolve({ access_token, token_type, email }); + } catch (e) { + reject( + new Error( + `Received unexpected SSA authentication response: ${(e as Error).message}`, + ), + ); + } + }, + { signal }, + ); + }); +} + +function openPopupCentered(url: string, width: number, height: number) { + const top = + window.outerHeight - window.innerHeight + window.innerHeight / 2 - height / 2; + const left = window.innerWidth / 2 - width / 2; + const popup = window.open( + url, + undefined, + `toolbar=no, menubar=no, width=${width}, height=${height}, top=${top}, left=${left}`, + ); + if (popup === null) { + throw new Error("Failed to create authentication popup window"); + } + return popup; +} + +export class SsaCredentialsProvider extends CredentialsProvider { + constructor(public readonly workerOrigin: string) { + super(); + try { + // Throws if invalid URL. + const parsed = new URL(workerOrigin); + if (parsed.origin !== workerOrigin) { + throw new Error("workerOrigin must be an origin like https://host"); + } + } catch (e) { + throw new Error(`Invalid worker origin ${JSON.stringify(workerOrigin)}`, { + cause: e, + }); + } + } + + get = makeCredentialsGetter(async (options) => { + using _span = new ProgressSpan(options.progressListener, { + message: `Requesting SSA login via ${this.workerOrigin}`, + }); + + const config = await discoverSsaConfiguration(this.workerOrigin); + const popupUrl = config.popupAuthorizationUrl ?? config.authorizationUrl; + + return await getCredentialsWithStatus( + { + description: `SSA at ${this.workerOrigin}`, + requestDescription: "login", + get: async (signal, _immediate) => { + // For SSA, we do not support a silent/iframe flow; immediate just attempts a direct + // load of the authorization page and the worker may choose to complete without user + // interaction if a session is present. + const abortController = new AbortController(); + signal = AbortSignal.any([abortController.signal, signal]); + try { + const popup = openPopupCentered(popupUrl, 450, 700); + monitorAuthPopupWindow(popup, abortController); + const result = await raceWithAbort( + waitForPopupAuthMessage(this.workerOrigin, popup, abortController.signal), + signal, + ); + return { + tokenType: result.token_type, + accessToken: result.access_token, + email: result.email, + }; + } finally { + abortController.abort(); + } + }, + }, + options.signal, + ); + }); +} diff --git a/src/kvstore/ssa_s3/register_credentials_provider.ts b/src/kvstore/ssa_s3/register_credentials_provider.ts new file mode 100644 index 0000000000..2924b0ced4 --- /dev/null +++ b/src/kvstore/ssa_s3/register_credentials_provider.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2025 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 { registerDefaultCredentialsProvider } from "#src/credentials_provider/default_manager.js"; +import { SsaCredentialsProvider } from "#src/kvstore/ssa_s3/credentials_provider.js"; + +registerDefaultCredentialsProvider("ssa", (workerOrigin: string) => { + return new SsaCredentialsProvider(workerOrigin); +}); diff --git a/src/kvstore/ssa_s3/register_frontend.ts b/src/kvstore/ssa_s3/register_frontend.ts new file mode 100644 index 0000000000..a4261df133 --- /dev/null +++ b/src/kvstore/ssa_s3/register_frontend.ts @@ -0,0 +1,158 @@ +/** + * @license + * Copyright 2025 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 type { OAuth2Credentials } from "#src/credentials_provider/oauth2.js"; +import { fetchOkWithOAuth2CredentialsAdapter } from "#src/credentials_provider/oauth2.js"; +import type { BaseKvStoreProvider, BaseKvStoreCompleteUrlOptions, CompletionResult } from "#src/kvstore/context.js"; +import type { SharedKvStoreContext } from "#src/kvstore/frontend.js"; +import { frontendOnlyKvStoreProviderRegistry } from "#src/kvstore/frontend.js"; +import { SsaS3KvStore } from "#src/kvstore/ssa_s3/ssa_s3_kvstore.js"; +import { verifyObject, verifyObjectProperty, verifyString, verifyStringArray } from "#src/util/json.js"; + +const SSA_SCHEME_PREFIX = "ssa+"; + +function ensureSsaHttpsUrl(url: string): URL { + if (!url.startsWith("ssa+https://")) { + throw new Error(`Invalid URL ${JSON.stringify(url)}: expected ssa+https scheme`); + } + const httpUrl = url.substring(SSA_SCHEME_PREFIX.length); + const parsed = new URL(httpUrl); + if (parsed.hash) throw new Error("Fragment not supported in ssa+https URLs"); + if (parsed.username || parsed.password) throw new Error("Basic auth credentials are not supported in ssa+https URLs"); + return parsed; +} + +function getWorkerOriginAndDatasetPrefix(parsed: URL): { workerOrigin: string; datasetBasePrefix: string } { + const workerOrigin = parsed.origin; + const datasetBasePrefix = decodeURIComponent(parsed.pathname.replace(/^\//, "")); + return { workerOrigin, datasetBasePrefix }; +} + +function getDisplayBase(url: string): string { + // Keep exactly the ssa+https://host/ base with any search parameters preserved on base. + const parsed = ensureSsaHttpsUrl(url); + // Construct base without the path, but keep scheme and origin. + return `${SSA_SCHEME_PREFIX}${parsed.origin}/`; +} + +interface SsaAuthenticateResponseLite { + readable_prefixes: string[]; + endpoints: { sign_requests: string; list_files: string }; +} + +function parseAuthenticateResponseLite(json: unknown): SsaAuthenticateResponseLite { + const obj = verifyObject(json); + const endpoints = verifyObjectProperty(obj, "endpoints", verifyObject); + return { + readable_prefixes: verifyObjectProperty(obj, "readable_prefixes", verifyStringArray), + endpoints: { + sign_requests: verifyObjectProperty(endpoints, "sign_requests", verifyString), + list_files: verifyObjectProperty(endpoints, "list_files", verifyString), + }, + }; +} + +function dirnameAndBasename(path: string): { dir: string; base: string } { + const idx = path.lastIndexOf("/"); + if (idx === -1) return { dir: "", base: path }; + return { dir: path.substring(0, idx + 1), base: path.substring(idx + 1) }; +} + +function joinPath(base: string, suffix: string) { + if (base === "") return suffix; + if (base.endsWith("/")) return base + suffix; + return base + "/" + suffix; +} + +async function completeSsaUrl( + sharedContext: SharedKvStoreContext, + options: BaseKvStoreCompleteUrlOptions, +): Promise { + const { url } = options; + const parsed = ensureSsaHttpsUrl(url.url); + const { workerOrigin, datasetBasePrefix } = getWorkerOriginAndDatasetPrefix(parsed); + + const credentialsProvider = sharedContext.credentialsManager.getCredentialsProvider( + "ssa", + workerOrigin, + ); + const fetchOkToWorker = fetchOkWithOAuth2CredentialsAdapter(credentialsProvider); + + const authenticateResponse = parseAuthenticateResponseLite( + await (await fetchOkToWorker(`${workerOrigin}/authenticate`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + signal: options.signal, + })).json(), + ); + + // Determine context for completion. + const { dir, base } = dirnameAndBasename(datasetBasePrefix); + + // Root-level completion: suggest readable prefixes. + if (dir === "") { + const candidates = authenticateResponse.readable_prefixes.map((p) => (p.endsWith("/") ? p : p + "/")); + const matches = candidates + .filter((p) => p.startsWith(base)) + .map((p) => ({ value: p })); + const offset = url.url.length - base.length; + return { offset, completions: matches }; + } + + // Within a directory: use list-files for current dir prefix. + const listResponse = verifyObject( + await (await fetchOkToWorker(`${workerOrigin}${authenticateResponse.endpoints.list_files}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prefix: dir }), + signal: options.signal, + })).json(), + ); + const directories = verifyObjectProperty(listResponse, "directories", verifyStringArray); + const entries = verifyObjectProperty(listResponse, "entries", verifyStringArray); + const candidates = [ + ...directories.map((d) => (d.endsWith("/") ? d : d + "/")), + ...entries, + ]; + const matches = candidates + .filter((p) => p.startsWith(base)) + .map((p) => ({ value: joinPath(dir, p) })); + const offset = url.url.length - base.length; + return { offset, completions: matches }; +} + +function ssaFrontendProvider(sharedContext: SharedKvStoreContext): BaseKvStoreProvider { + return { + scheme: "ssa+https", + description: "Stateless S3 Authenticator (SSA) over HTTPS", + getKvStore(parsedUrl) { + // parsedUrl.url is full string like ssa+https://host/path + const parsed = ensureSsaHttpsUrl(parsedUrl.url); + const { workerOrigin, datasetBasePrefix } = getWorkerOriginAndDatasetPrefix(parsed); + const displayBase = getDisplayBase(parsedUrl.url); + return { + store: new SsaS3KvStore(sharedContext, workerOrigin, "", displayBase), + path: datasetBasePrefix, + }; + }, + async completeUrl(options) { + return await completeSsaUrl(sharedContext, options); + }, + }; +} + +frontendOnlyKvStoreProviderRegistry.registerBaseKvStoreProvider(ssaFrontendProvider); diff --git a/src/kvstore/ssa_s3/ssa_s3_kvstore.ts b/src/kvstore/ssa_s3/ssa_s3_kvstore.ts new file mode 100644 index 0000000000..de434e03c0 --- /dev/null +++ b/src/kvstore/ssa_s3/ssa_s3_kvstore.ts @@ -0,0 +1,391 @@ +/** + * @license + * Copyright 2025 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 type { OAuth2Credentials } from "#src/credentials_provider/oauth2.js"; +import { fetchOkWithOAuth2CredentialsAdapter } from "#src/credentials_provider/oauth2.js"; +import type { SharedKvStoreContext } from "#src/kvstore/frontend.js"; +import type { + DriverReadOptions, + KvStore, + ListResponse, + StatOptions, + StatResponse, + ReadResponse, +} from "#src/kvstore/index.js"; +import type { SsaCredentialsProvider } from "#src/kvstore/ssa_s3/credentials_provider.js"; +import { pipelineUrlJoin } from "#src/kvstore/url.js"; +import type { FetchOk } from "#src/util/http_request.js"; +import { fetchOk, HttpError } from "#src/util/http_request.js"; +import { + verifyObject, + verifyObjectProperty, + verifyString, + verifyStringArray, +} from "#src/util/json.js"; +import { MultiConsumerProgressListener, ProgressSpan } from "#src/util/progress_listener.js"; + +function joinPath(base: string, suffix: string) { + if (base === "") return suffix; + if (base.endsWith("/")) return base + suffix; + return base + "/" + suffix; +} + +interface SsaAuthenticateResponse { + readablePrefixes: string[]; + endpoints: { + signRequests: string; // path relative to worker origin, e.g. "/sign-requests" + listFiles: string; // path relative to worker origin, e.g. "/list-files" + }; +} + +function parseAuthenticateResponse(json: unknown): SsaAuthenticateResponse { + const obj = verifyObject(json); + const endpointsObj = verifyObjectProperty(obj, "endpoints", verifyObject); + return { + readablePrefixes: verifyObjectProperty(obj, "readable_prefixes", verifyStringArray), + endpoints: { + signRequests: verifyObjectProperty(endpointsObj, "sign_requests", verifyString), + listFiles: verifyObjectProperty(endpointsObj, "list_files", verifyString), + }, + }; +} + +interface SsaSignRequestBody { + requests: Array<{ + method: "GET" | "HEAD"; + path: string; // key within the SSA-managed bucket + }>; +} + +interface SsaSignRequestsResponse { + urls: string[]; // Presigned URLs matching the requests order +} + +function parseSignRequestsResponse(json: unknown): SsaSignRequestsResponse { + const obj = verifyObject(json); + const urls = verifyObjectProperty(obj, "urls", verifyStringArray); + return { urls }; +} + +interface SsaListFilesResponse { + directories: string[]; + entries: string[]; // file paths relative to the requested prefix +} + +function parseListFilesResponse(json: unknown): SsaListFilesResponse { + const obj = verifyObject(json); + return { + directories: verifyObjectProperty(obj, "directories", verifyStringArray), + entries: verifyObjectProperty(obj, "entries", verifyStringArray), + }; +} + +export class SsaS3KvStore implements KvStore { + private readonly fetchOkToWorker: FetchOk; + private readonly credentialsProvider: SsaCredentialsProvider; + private readonly workerOrigin: string; + private readonly datasetBasePrefix: string; + private readonly displayBaseUrl: string; + + private authenticatePromise: Promise | undefined; + + constructor( + public readonly sharedKvStoreContext: SharedKvStoreContext, + workerOrigin: string, + datasetBasePrefix: string, + displayBaseUrl: string, + ) { + this.workerOrigin = workerOrigin; + this.datasetBasePrefix = datasetBasePrefix; + this.displayBaseUrl = displayBaseUrl; + this.credentialsProvider = sharedKvStoreContext.credentialsManager.getCredentialsProvider( + "ssa", + workerOrigin, + ) as unknown as SsaCredentialsProvider; + this.fetchOkToWorker = fetchOkWithOAuth2CredentialsAdapter( + this.credentialsProvider, + ); + } + + getUrl(path: string): string { + return pipelineUrlJoin(this.displayBaseUrl, path); + } + + get supportsOffsetReads() { + return true; + } + + get supportsSuffixReads() { + return true; + } + + private async ensureAuthenticated(signal?: AbortSignal): Promise { + if (this.authenticatePromise === undefined) { + this.authenticatePromise = this.performAuthenticate(signal).catch((e) => { + // Clear cached promise on failure to allow retry. + this.authenticatePromise = undefined; + throw e; + }); + } + return this.authenticatePromise; + } + + private async performAuthenticate(signal?: AbortSignal): Promise { + using _span = new ProgressSpan(new MultiConsumerProgressListener(), { + message: `Connecting to SSA worker at ${this.workerOrigin}`, + }); + try { + const response = await this.fetchOkToWorker(`${this.workerOrigin}/authenticate`, { + method: "POST", + signal, + headers: { "content-type": "application/json" }, + body: "{}", + }); + const result = parseAuthenticateResponse(await response.json()); + return result; + } catch (e) { + if (e instanceof HttpError) { + if (e.status === 401 || e.status === 403) { + throw new Error( + `Failed to authenticate with SSA service at ${this.workerOrigin}: access denied (${e.status}).`, + ); + } + } + throw new Error( + `Failed to connect to SSA service at ${this.workerOrigin}: ${(e as Error).message}`, + { cause: e }, + ); + } + } + + private async signSingleUrl( + method: "GET" | "HEAD", + fullKey: string, + signal?: AbortSignal, + ): Promise { + const { endpoints } = await this.ensureAuthenticated(signal); + try { + const response = await this.fetchOkToWorker( + `${this.workerOrigin}${endpoints.signRequests}`, + { + method: "POST", + signal, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + requests: [{ method, path: fullKey }], + } satisfies SsaSignRequestBody), + }, + ); + const { urls } = parseSignRequestsResponse(await response.json()); + if (urls.length !== 1) { + throw new Error( + `SSA /sign-requests returned ${urls.length} urls, expected 1 for key ${JSON.stringify(fullKey)}`, + ); + } + return urls[0]; + } catch (e) { + if (e instanceof HttpError && (e.status === 401 || e.status === 403)) { + throw new Error( + `Permission denied by SSA while signing ${JSON.stringify(fullKey)} (HTTP ${e.status}).`, + { cause: e }, + ); + } + throw new Error( + `Failed to sign request for ${JSON.stringify(fullKey)} via SSA: ${(e as Error).message}`, + { cause: e }, + ); + } + } + + async stat(key: string, options: StatOptions): Promise { + const fullKey = joinPath(this.datasetBasePrefix, key); + const url = await this.signSingleUrl("HEAD", fullKey, options.signal); + try { + const response = await fetchOk(url, { method: "HEAD", signal: options.signal, progressListener: options.progressListener }); + const contentLength = response.headers.get("content-length"); + let totalSize: number | undefined; + if (contentLength !== null) { + const n = Number(contentLength); + if (!Number.isFinite(n) || n < 0) { + throw new Error(`Invalid content-length returned by S3 for ${JSON.stringify(fullKey)}: ${JSON.stringify(contentLength)}`); + } + totalSize = n; + } + return { totalSize }; + } catch (e) { + if (e instanceof HttpError && e.status === 404) { + if (options.throwIfMissing === true) { + throw new Error(`${this.getUrl(key)} not found`, { cause: e }); + } + return undefined; + } + throw new Error( + `Failed to stat ${this.getUrl(key)} via SSA-signed URL: ${(e as Error).message}`, + { cause: e }, + ); + } + } + + async read(key: string, options: DriverReadOptions): Promise { + const fullKey = joinPath(this.datasetBasePrefix, key); + const url = await this.signSingleUrl("GET", fullKey, options.signal); + + // Construct Range header based on options.byteRange for efficient reads. + let rangeHeader: string | undefined; + const { byteRange } = options; + if (byteRange !== undefined) { + if ("suffixLength" in byteRange) { + // For suffix reads we must know total size; issue HEAD first then compute exact range. + const statResponse = await this.stat(key, { signal: options.signal }); + if (statResponse === undefined || statResponse.totalSize === undefined) { + throw new Error( + `Failed to determine total size of ${this.getUrl(key)} in order to fetch suffix bytes`, + ); + } + const total = statResponse.totalSize; + const len = Math.min(byteRange.suffixLength, total); + const start = total - len; + rangeHeader = `bytes=${start}-${total - 1}`; + } else { + if (byteRange.length === 0) { + // Request 1 byte and discard per HTTP semantics for 0-length workaround. + const start = Math.max(byteRange.offset - 1, 0); + rangeHeader = `bytes=${start}-${start}`; + } else { + rangeHeader = `bytes=${byteRange.offset}-${byteRange.offset + byteRange.length - 1}`; + } + } + } + + try { + const response = await fetchOk(url, { + method: "GET", + signal: options.signal, + progressListener: options.progressListener, + headers: rangeHeader ? { range: rangeHeader } : undefined, + cache: rangeHeader ? (navigator.userAgent.indexOf("Chrome") !== -1 ? "no-store" : "default") : undefined, + }); + + // Interpret response similar to http/read.ts logic. + let offset: number | undefined; + let length: number | undefined; + let totalSize: number | undefined; + if (response.status === 206) { + const contentRange = response.headers.get("content-range"); + if (contentRange !== null) { + const m = contentRange.match(/bytes ([0-9]+)-([0-9]+)\/(\*|[0-9]+)/); + if (m === null) { + throw new Error( + `Invalid content-range header from S3 for ${this.getUrl(key)}: ${JSON.stringify(contentRange)}`, + ); + } + offset = Number(m[1]); + const endPos = Number(m[2]); + length = endPos - offset + 1; + if (m[3] !== "*") totalSize = Number(m[3]); + } else if (byteRange !== undefined) { + // Some servers omit content-range; use requested range info where possible. + if ("suffixLength" in byteRange) { + // Already computed via HEAD. + const statResponse = await this.stat(key, { signal: options.signal }); + totalSize = statResponse?.totalSize; + if (totalSize === undefined) { + throw new Error("Missing total size for suffix read"); + } + const len = Math.min(byteRange.suffixLength, totalSize); + offset = totalSize - len; + length = len; + } else { + if (byteRange.length === 0) { + offset = byteRange.offset; + length = 0; + // Return empty body for zero-length reads. + return { response: new Response(new Uint8Array(0)), offset, length, totalSize }; + } else { + offset = byteRange.offset; + length = byteRange.length; + } + } + } + } else { + const cl = response.headers.get("content-length"); + if (cl !== null) { + const n = Number(cl); + if (!Number.isFinite(n) || n < 0) { + throw new Error(`Invalid content-length header for ${this.getUrl(key)}: ${JSON.stringify(cl)}`); + } + length = n; + totalSize = n; + offset = 0; + } + } + if (offset === undefined) offset = 0; + return { response, offset, length, totalSize }; + } catch (e) { + if (e instanceof HttpError) { + if (e.status === 404) { + if (options.throwIfMissing === true) { + throw new Error(`${this.getUrl(key)} not found`, { cause: e }); + } + return undefined; + } + if (e.status === 401 || e.status === 403) { + throw new Error( + `Permission denied while reading ${this.getUrl(key)} (HTTP ${e.status}).`, + { cause: e }, + ); + } + } + throw new Error( + `Failed to read ${this.getUrl(key)} via SSA-signed URL: ${(e as Error).message}`, + { cause: e }, + ); + } + } + + async list(prefix: string, options: { signal?: AbortSignal } = {}): Promise { + const fullPrefix = joinPath(this.datasetBasePrefix, prefix); + const { endpoints } = await this.ensureAuthenticated(options.signal); + try { + const response = await this.fetchOkToWorker( + `${this.workerOrigin}${endpoints.listFiles}`, + { + method: "POST", + signal: options.signal, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prefix: fullPrefix }), + }, + ); + const parsed = parseListFilesResponse(await response.json()); + // Convert SSA list response into KvStore ListResponse shape. + return { + directories: parsed.directories, + entries: parsed.entries.map((key) => ({ key })), + }; + } catch (e) { + if (e instanceof HttpError && (e.status === 401 || e.status === 403)) { + throw new Error( + `Permission denied by SSA while listing ${this.getUrl(prefix)} (HTTP ${e.status}).`, + { cause: e }, + ); + } + throw new Error( + `Failed to list files for ${this.getUrl(prefix)} via SSA: ${(e as Error).message}`, + { cause: e }, + ); + } + } +} diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts index 4adb49783b..ac294b8b16 100644 --- a/src/voxel_annotation/frontend.ts +++ b/src/voxel_annotation/frontend.ts @@ -340,7 +340,7 @@ export class VoxChunkSource extends BaseVolumeChunkSource { requiredThickness: number ) => { const subQueue: [number, number][] = []; - const halfThickness = Math.floor(requiredThickness / 2); + const halfThickness = Math.floor(requiredThickness / 2) + 1; const k = `${startX},${startY}`; if (visited.has(k)) return; @@ -389,19 +389,6 @@ export class VoxChunkSource extends BaseVolumeChunkSource { const k = `${nx},${ny}`; if (visited.has(k)) continue; - // Check if neighbor chunk is loaded - const nVoxel = new Float32Array([nx, ny, zPlane]); - const { key, chunkLocalIndex } = this.computeIndices(nVoxel); - const chunk = this.chunks.get(key) as VolumeChunk | undefined; - const cpu = chunk ? this.getCpuArrayForChunk(chunk) : null; - - if (!cpu || chunkLocalIndex < 0) { - // Stop propagation at map bounds/unloaded chunks without invalidating the fill. - // Simply do not enqueue this neighbor. - visited.add(k); - continue; - } - if (isOriginalAt(nx, ny)) { // The neighbor is a valid fill target. Now check if we can propagate from it. if (hasThickEnoughChannel(x, y, nx, ny, requiredThickness)) { From 1838e52a67f0214d2d23ea7dc5fb1d81fe2f0db1 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:43 +0100 Subject: [PATCH 053/251] feat: upgrade SSA flow to OIDC-based authentication with PKCE support - Replace deprecated SSA-specific authentication logic with standards-compliant OIDC flow. - Add PKCE support for improved security in public client scenarios. - Revamp credential discovery and token exchange, handling `authorization_code` grant type. - Introduce helper methods for OIDC code exchange and PKCE challenge generation. - Update popup message handling to align with OIDC standards and improve validation. --- src/kvstore/ssa_s3/credentials_provider.ts | 141 +++++++++++++++------ src/main.ts | 17 +++ 2 files changed, 120 insertions(+), 38 deletions(-) diff --git a/src/kvstore/ssa_s3/credentials_provider.ts b/src/kvstore/ssa_s3/credentials_provider.ts index e358a8b010..23013747a8 100644 --- a/src/kvstore/ssa_s3/credentials_provider.ts +++ b/src/kvstore/ssa_s3/credentials_provider.ts @@ -34,32 +34,19 @@ import { import { ProgressSpan } from "#src/util/progress_listener.js"; interface SsaConfiguration { + // OIDC issuer for the SSA deployment. issuer: string; - authorizationUrl: string; - // Optional field to allow the SSA deployment to specify a dedicated popup endpoint. - popupAuthorizationUrl?: string; } -interface SsaPopupAuthResult { - access_token: string; - token_type: string; - email?: string; +interface OidcConfiguration { + authorization_endpoint: string; + token_endpoint: string; } function parseSsaConfiguration(json: unknown): SsaConfiguration { const obj = verifyObject(json); const issuer = verifyObjectProperty(obj, "issuer", verifyString); - const authorizationUrl = verifyObjectProperty( - obj, - "authorization_url", - verifyString, - ); - const popupAuthorizationUrl = verifyOptionalObjectProperty( - obj, - "popup_authorization_url", - verifyString, - ); - return { issuer, authorizationUrl, popupAuthorizationUrl }; + return { issuer }; } async function discoverSsaConfiguration(workerOrigin: string): Promise { @@ -68,11 +55,29 @@ async function discoverSsaConfiguration(workerOrigin: string): Promise { + const response = await fetchOk(`${issuer}/.well-known/openid-configuration`); + const json = verifyObject(await response.json()); + const authorization_endpoint = verifyObjectProperty( + json, + "authorization_endpoint", + verifyString, + ); + const token_endpoint = verifyObjectProperty(json, "token_endpoint", verifyString); + return { authorization_endpoint, token_endpoint }; +} + +interface OidcCodeMessage { + type: "oidc_code"; + code: string; + state: string; +} + +async function waitForOidcCodeMessage( expectedOrigin: string, source: Window, signal: AbortSignal, -): Promise { +): Promise { return new Promise((resolve, reject) => { window.addEventListener( "message", @@ -81,14 +86,15 @@ async function waitForPopupAuthMessage( if (event.origin !== expectedOrigin) return; try { const data = verifyObject(event.data); - const access_token = verifyObjectProperty(data, "access_token", verifyString); - const token_type = verifyObjectProperty(data, "token_type", verifyString); - const email = verifyOptionalObjectProperty(data, "email", verifyString); - resolve({ access_token, token_type, email }); + const type = verifyObjectProperty(data, "type", verifyString); + if (type !== "oidc_code") return; + const code = verifyObjectProperty(data, "code", verifyString); + const state = verifyObjectProperty(data, "state", verifyString); + resolve({ type: "oidc_code", code, state }); } catch (e) { reject( new Error( - `Received unexpected SSA authentication response: ${(e as Error).message}`, + `Received unexpected OIDC authorization response: ${(e as Error).message}`, ), ); } @@ -113,6 +119,33 @@ function openPopupCentered(url: string, width: number, height: number) { return popup; } +function base64UrlEncode(bytes: Uint8Array): string { + const s = btoa(String.fromCharCode(...bytes)); + return s.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +async function sha256Bytes(input: Uint8Array): Promise { + const digest = await crypto.subtle.digest("SHA-256", input); + return new Uint8Array(digest); +} + +function generateRandomAscii(length: number): string { + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + const random = new Uint8Array(length); + crypto.getRandomValues(random); + let s = ""; + for (let i = 0; i < length; ++i) { + s += charset[random[i] % charset.length]; + } + return s; +} + +async function createPkcePair(): Promise<{ verifier: string; challenge: string }> { + const verifier = generateRandomAscii(64); + const challenge = base64UrlEncode(await sha256Bytes(new TextEncoder().encode(verifier))); + return { verifier, challenge }; +} + export class SsaCredentialsProvider extends CredentialsProvider { constructor(public readonly workerOrigin: string) { super(); @@ -123,7 +156,7 @@ export class SsaCredentialsProvider extends CredentialsProvider( { description: `SSA at ${this.workerOrigin}`, requestDescription: "login", get: async (signal, _immediate) => { - // For SSA, we do not support a silent/iframe flow; immediate just attempts a direct - // load of the authorization page and the worker may choose to complete without user - // interaction if a session is present. const abortController = new AbortController(); signal = AbortSignal.any([abortController.signal, signal]); try { const popup = openPopupCentered(popupUrl, 450, 700); monitorAuthPopupWindow(popup, abortController); - const result = await raceWithAbort( - waitForPopupAuthMessage(this.workerOrigin, popup, abortController.signal), + const appOrigin = new URL(redirectUri).origin; + const { code, state: returnedState } = await raceWithAbort( + waitForOidcCodeMessage(appOrigin, popup, abortController.signal), signal, ); - return { - tokenType: result.token_type, - accessToken: result.access_token, - email: result.email, - }; + if (returnedState !== state) { + throw new Error("OIDC state mismatch detected"); + } + // Exchange authorization code for tokens. + const tokenResp = await fetchOk(token_endpoint, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + client_id: clientId, + code_verifier: codeVerifier, + }), + signal, + }); + const tokenJson = verifyObject(await tokenResp.json()); + const access_token = verifyObjectProperty(tokenJson, "access_token", verifyString); + const token_type = verifyObjectProperty(tokenJson, "token_type", verifyString); + const email = verifyOptionalObjectProperty(tokenJson, "email", verifyString); + return { tokenType: token_type, accessToken: access_token, email }; } finally { abortController.abort(); } diff --git a/src/main.ts b/src/main.ts index 8a06431aed..d440a0bb21 100644 --- a/src/main.ts +++ b/src/main.ts @@ -20,4 +20,21 @@ import { setupDefaultViewer } from "#src/ui/default_viewer_setup.js"; import "#src/util/google_tag_manager.js"; +(function maybeHandleOidcCallback() { + try { + // Only handle when running in a popup opened by our app and when code/state are present. + if (window.opener === null) return; + const params = new URLSearchParams(window.location.search); + const code = params.get("code"); + const state = params.get("state"); + if (code === null || state === null) return; + // Post message back to opener; opener will validate origin and state. + window.opener.postMessage({ type: "oidc_code", code, state }, "*"); + // Close this popup window. + window.close(); + } catch { + // Swallow errors; fall through to normal app startup. + } +})(); + setupDefaultViewer(); From 6890dbe104a535b3b0b13ca20a0f8564091f705c Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 054/251] feat: enhance SSA KVStore integration and improve authentication logic - Extend SSA KVStore to support improved file listing, permissions, and bucket handling. - Replace `readablePrefixes` with granular read/write permissions. - Update authentication and request flow for better alignment with SSA API changes. - Add token storage, refresh, and silent authentication fallback in `SsaCredentialsProvider`. - Revamp localStorage token management and introduce error handling for token refresh. - Refactor directory/file listing logic to dynamically compute child keys and directories. --- src/kvstore/ssa_s3/credentials_provider.ts | 137 +++++++++++++++++++-- src/kvstore/ssa_s3/register_frontend.ts | 44 +++++-- src/kvstore/ssa_s3/ssa_s3_kvstore.ts | 98 +++++++++++---- 3 files changed, 232 insertions(+), 47 deletions(-) diff --git a/src/kvstore/ssa_s3/credentials_provider.ts b/src/kvstore/ssa_s3/credentials_provider.ts index 23013747a8..c7fd5d19c0 100644 --- a/src/kvstore/ssa_s3/credentials_provider.ts +++ b/src/kvstore/ssa_s3/credentials_provider.ts @@ -31,6 +31,7 @@ import { verifyOptionalObjectProperty, verifyString, } from "#src/util/json.js"; +import type { ProgressOptions } from "#src/util/progress_listener.js"; import { ProgressSpan } from "#src/util/progress_listener.js"; interface SsaConfiguration { @@ -146,6 +147,45 @@ async function createPkcePair(): Promise<{ verifier: string; challenge: string } return { verifier, challenge }; } +interface StoredSsaToken { + accessToken: string; + refreshToken: string; + tokenType: string; + expiresAt: number; + email?: string; +} + +function getLocalStorageKeyForWorker(workerOrigin: string): string { + return `ssa_oidc_token_${workerOrigin}`; +} + +function loadStoredSsaToken(workerOrigin: string): StoredSsaToken | null { + const key = getLocalStorageKeyForWorker(workerOrigin); + const raw = localStorage.getItem(key); + if (raw === null) return null; + const parsed = JSON.parse(raw); + const obj = verifyObject(parsed); + const accessToken = verifyObjectProperty(obj, "accessToken", verifyString); + const refreshToken = verifyObjectProperty(obj, "refreshToken", verifyString); + const tokenType = verifyObjectProperty(obj, "tokenType", verifyString); + const expiresAt = Number(verifyObjectProperty(obj, "expiresAt", (v) => { + if (typeof v !== "number") throw new Error("expiresAt must be a number"); + return v; + })); + const email = verifyOptionalObjectProperty(obj, "email", verifyString); + return { accessToken, refreshToken, tokenType, expiresAt, email }; +} + +function saveStoredSsaToken(workerOrigin: string, value: StoredSsaToken): void { + const key = getLocalStorageKeyForWorker(workerOrigin); + localStorage.setItem(key, JSON.stringify(value)); +} + +function clearStoredSsaToken(workerOrigin: string): void { + const key = getLocalStorageKeyForWorker(workerOrigin); + localStorage.removeItem(key); +} + export class SsaCredentialsProvider extends CredentialsProvider { constructor(public readonly workerOrigin: string) { super(); @@ -162,7 +202,9 @@ export class SsaCredentialsProvider extends CredentialsProvider { + private async performInteractiveLogin( + options: ProgressOptions, + ): Promise { using _span = new ProgressSpan(options.progressListener, { message: `Requesting SSA login via ${this.workerOrigin}`, }); @@ -170,7 +212,6 @@ export class SsaCredentialsProvider extends CredentialsProvider( + await getCredentialsWithStatus( { description: `SSA at ${this.workerOrigin}`, requestDescription: "login", - get: async (signal, _immediate) => { + get: async (innerSignal) => { const abortController = new AbortController(); - signal = AbortSignal.any([abortController.signal, signal]); + const combined = AbortSignal.any([abortController.signal, innerSignal, options.signal]); try { const popup = openPopupCentered(popupUrl, 450, 700); monitorAuthPopupWindow(popup, abortController); const appOrigin = new URL(redirectUri).origin; const { code, state: returnedState } = await raceWithAbort( waitForOidcCodeMessage(appOrigin, popup, abortController.signal), - signal, + combined, ); if (returnedState !== state) { throw new Error("OIDC state mismatch detected"); } - // Exchange authorization code for tokens. const tokenResp = await fetchOk(token_endpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, @@ -217,12 +257,27 @@ export class SsaCredentialsProvider extends CredentialsProvider { + if (typeof v !== "number") throw new Error("expires_in must be a number"); + return v; + }), + ); const email = verifyOptionalObjectProperty(tokenJson, "email", verifyString); + const stored: StoredSsaToken = { + accessToken: access_token, + refreshToken: refresh_token, + tokenType: token_type, + expiresAt: Date.now() + expires_in * 1000, + email, + }; + saveStoredSsaToken(this.workerOrigin, stored); return { tokenType: token_type, accessToken: access_token, email }; } finally { abortController.abort(); @@ -231,5 +286,71 @@ export class SsaCredentialsProvider extends CredentialsProvider { + const { issuer } = await discoverSsaConfiguration(this.workerOrigin); + const { token_endpoint } = await discoverOpenIdConfiguration(issuer); + const clientId = "neuroglancer"; + + const resp = await fetchOk(token_endpoint, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: clientId, + }), + signal, + }); + const json = verifyObject(await resp.json()); + const access_token = verifyObjectProperty(json, "access_token", verifyString); + const token_type = verifyObjectProperty(json, "token_type", verifyString); + const new_refresh = verifyOptionalObjectProperty(json, "refresh_token", verifyString) ?? refreshToken; + const expires_in = Number( + verifyObjectProperty(json, "expires_in", (v) => { + if (typeof v !== "number") throw new Error("expires_in must be a number"); + return v; + }), + ); + const email = verifyOptionalObjectProperty(json, "email", verifyString); + const stored: StoredSsaToken = { + accessToken: access_token, + refreshToken: new_refresh, + tokenType: token_type, + expiresAt: Date.now() + expires_in * 1000, + email, + }; + saveStoredSsaToken(this.workerOrigin, stored); + return stored; + } + + get = makeCredentialsGetter(async (options) => { + // 1) Try localStorage + const existing = loadStoredSsaToken(this.workerOrigin); + if (existing !== null) { + if (Date.now() < existing.expiresAt) { + return { tokenType: existing.tokenType, accessToken: existing.accessToken, email: existing.email }; + } + // Try silent refresh + try { + const refreshed = await this.refreshTokenSilently(existing.refreshToken, options.signal); + return { tokenType: refreshed.tokenType, accessToken: refreshed.accessToken, email: refreshed.email }; + } catch (e) { + clearStoredSsaToken(this.workerOrigin); + // Fall through to interactive login + } + } + + // 4) Interactive login + const stored = await this.performInteractiveLogin(options); + return { tokenType: stored.tokenType, accessToken: stored.accessToken, email: stored.email }; }); } diff --git a/src/kvstore/ssa_s3/register_frontend.ts b/src/kvstore/ssa_s3/register_frontend.ts index a4261df133..0373d4fc1d 100644 --- a/src/kvstore/ssa_s3/register_frontend.ts +++ b/src/kvstore/ssa_s3/register_frontend.ts @@ -49,18 +49,22 @@ function getDisplayBase(url: string): string { } interface SsaAuthenticateResponseLite { - readable_prefixes: string[]; - endpoints: { sign_requests: string; list_files: string }; + permissions: { read: string[]; write: string[] }; + endpoints: { signRequests: string; listFiles: string }; } function parseAuthenticateResponseLite(json: unknown): SsaAuthenticateResponseLite { const obj = verifyObject(json); - const endpoints = verifyObjectProperty(obj, "endpoints", verifyObject); + const endpointsObj = verifyObjectProperty(obj, "endpoints", verifyObject); + const permissionsObj = verifyObjectProperty(obj, "permissions", verifyObject); return { - readable_prefixes: verifyObjectProperty(obj, "readable_prefixes", verifyStringArray), + permissions: { + read: verifyObjectProperty(permissionsObj, "read", verifyStringArray), + write: verifyObjectProperty(permissionsObj, "write", verifyStringArray), + }, endpoints: { - sign_requests: verifyObjectProperty(endpoints, "sign_requests", verifyString), - list_files: verifyObjectProperty(endpoints, "list_files", verifyString), + signRequests: verifyObjectProperty(endpointsObj, "signRequests", verifyString), + listFiles: verifyObjectProperty(endpointsObj, "listFiles", verifyString), }, }; } @@ -103,9 +107,9 @@ async function completeSsaUrl( // Determine context for completion. const { dir, base } = dirnameAndBasename(datasetBasePrefix); - // Root-level completion: suggest readable prefixes. + // Root-level completion: suggest directories from read permissions. if (dir === "") { - const candidates = authenticateResponse.readable_prefixes.map((p) => (p.endsWith("/") ? p : p + "/")); + const candidates = authenticateResponse.permissions.read.map((p) => (p.endsWith("/") ? p : p + "/")); const matches = candidates .filter((p) => p.startsWith(base)) .map((p) => ({ value: p })); @@ -115,18 +119,32 @@ async function completeSsaUrl( // Within a directory: use list-files for current dir prefix. const listResponse = verifyObject( - await (await fetchOkToWorker(`${workerOrigin}${authenticateResponse.endpoints.list_files}`, { + await (await fetchOkToWorker(`${workerOrigin}${authenticateResponse.endpoints.listFiles}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prefix: dir }), signal: options.signal, })).json(), ); - const directories = verifyObjectProperty(listResponse, "directories", verifyStringArray); - const entries = verifyObjectProperty(listResponse, "entries", verifyStringArray); + const objects = verifyObjectProperty(listResponse, "objects", (x) => x as unknown as any[]); + const childDirs = new Set(); + const childFiles = new Set(); + for (const entry of objects) { + const obj = verifyObject(entry); + const key = verifyObjectProperty(obj, "key", verifyString); + if (!key.startsWith(dir)) continue; + const remainder = key.substring(dir.length); + const slash = remainder.indexOf("/"); + if (slash === -1) { + if (remainder !== "") childFiles.add(remainder); + } else { + const first = remainder.substring(0, slash + 1); + childDirs.add(first); + } + } const candidates = [ - ...directories.map((d) => (d.endsWith("/") ? d : d + "/")), - ...entries, + ...Array.from(childDirs).map((d) => (d.endsWith("/") ? d : d + "/")), + ...Array.from(childFiles), ]; const matches = candidates .filter((p) => p.startsWith(base)) diff --git a/src/kvstore/ssa_s3/ssa_s3_kvstore.ts b/src/kvstore/ssa_s3/ssa_s3_kvstore.ts index de434e03c0..d9fc2b3ad9 100644 --- a/src/kvstore/ssa_s3/ssa_s3_kvstore.ts +++ b/src/kvstore/ssa_s3/ssa_s3_kvstore.ts @@ -44,53 +44,85 @@ function joinPath(base: string, suffix: string) { } interface SsaAuthenticateResponse { - readablePrefixes: string[]; + bucket: string; endpoints: { signRequests: string; // path relative to worker origin, e.g. "/sign-requests" listFiles: string; // path relative to worker origin, e.g. "/list-files" }; + permissions: { + read: string[]; + write: string[]; + }; } function parseAuthenticateResponse(json: unknown): SsaAuthenticateResponse { const obj = verifyObject(json); + const bucket = verifyObjectProperty(obj, "bucket", verifyString); const endpointsObj = verifyObjectProperty(obj, "endpoints", verifyObject); + const permissionsObj = verifyObjectProperty(obj, "permissions", verifyObject); return { - readablePrefixes: verifyObjectProperty(obj, "readable_prefixes", verifyStringArray), + bucket, endpoints: { - signRequests: verifyObjectProperty(endpointsObj, "sign_requests", verifyString), - listFiles: verifyObjectProperty(endpointsObj, "list_files", verifyString), + signRequests: verifyObjectProperty(endpointsObj, "signRequests", verifyString), + listFiles: verifyObjectProperty(endpointsObj, "listFiles", verifyString), + }, + permissions: { + read: verifyObjectProperty(permissionsObj, "read", verifyStringArray), + write: verifyObjectProperty(permissionsObj, "write", verifyStringArray), }, }; } interface SsaSignRequestBody { requests: Array<{ - method: "GET" | "HEAD"; - path: string; // key within the SSA-managed bucket + action: "GET" | "PUT" | "HEAD" | "DELETE"; + key: string; // key within the SSA-managed bucket }>; } +interface SsaSignRequestsResponseItem { key: string; url: string } interface SsaSignRequestsResponse { - urls: string[]; // Presigned URLs matching the requests order + signedRequests: SsaSignRequestsResponseItem[]; } function parseSignRequestsResponse(json: unknown): SsaSignRequestsResponse { const obj = verifyObject(json); - const urls = verifyObjectProperty(obj, "urls", verifyStringArray); - return { urls }; + const signedRequestsArrayUnknown = verifyObjectProperty(obj, "signedRequests", (v) => { + if (!Array.isArray(v)) { + throw new Error("signedRequests must be an array"); + } + return v as unknown[]; + }); + const signedRequests: SsaSignRequestsResponseItem[] = signedRequestsArrayUnknown.map((entry) => { + const entryObj = verifyObject(entry); + const key = verifyObjectProperty(entryObj, "key", verifyString); + const url = verifyObjectProperty(entryObj, "url", verifyString); + return { key, url }; + }); + return { signedRequests }; } +interface SsaListFilesObject { key: string; size: number; lastModified: string } interface SsaListFilesResponse { - directories: string[]; - entries: string[]; // file paths relative to the requested prefix + prefix: string; + objects: SsaListFilesObject[]; } function parseListFilesResponse(json: unknown): SsaListFilesResponse { const obj = verifyObject(json); - return { - directories: verifyObjectProperty(obj, "directories", verifyStringArray), - entries: verifyObjectProperty(obj, "entries", verifyStringArray), - }; + const prefix = verifyObjectProperty(obj, "prefix", verifyString); + const objectsArray = verifyObjectProperty(obj, "objects", (x) => x as unknown as any[]); + const objects: SsaListFilesObject[] = objectsArray.map((entry) => { + const e = verifyObject(entry); + const key = verifyObjectProperty(e, "key", verifyString); + const sizeStr = verifyObjectProperty(e, "size", (v) => { + if (typeof v !== "number") throw new Error("Expected number"); + return v; + }); + const lastModified = verifyObjectProperty(e, "lastModified", verifyString); + return { key, size: sizeStr, lastModified }; + }); + return { prefix, objects }; } export class SsaS3KvStore implements KvStore { @@ -172,8 +204,8 @@ export class SsaS3KvStore implements KvStore { } private async signSingleUrl( - method: "GET" | "HEAD", fullKey: string, + type: 'GET' | 'PUT' | 'HEAD' | 'DELETE', signal?: AbortSignal, ): Promise { const { endpoints } = await this.ensureAuthenticated(signal); @@ -185,17 +217,17 @@ export class SsaS3KvStore implements KvStore { signal, headers: { "content-type": "application/json" }, body: JSON.stringify({ - requests: [{ method, path: fullKey }], + requests: [{ action: type, key: fullKey }], } satisfies SsaSignRequestBody), }, ); - const { urls } = parseSignRequestsResponse(await response.json()); - if (urls.length !== 1) { + const { signedRequests } = parseSignRequestsResponse(await response.json()); + if (signedRequests.length !== 1) { throw new Error( - `SSA /sign-requests returned ${urls.length} urls, expected 1 for key ${JSON.stringify(fullKey)}`, + `SSA /sign-requests returned ${signedRequests.length} entries, expected 1 for key ${JSON.stringify(fullKey)}`, ); } - return urls[0]; + return signedRequests[0].url; } catch (e) { if (e instanceof HttpError && (e.status === 401 || e.status === 403)) { throw new Error( @@ -212,7 +244,7 @@ export class SsaS3KvStore implements KvStore { async stat(key: string, options: StatOptions): Promise { const fullKey = joinPath(this.datasetBasePrefix, key); - const url = await this.signSingleUrl("HEAD", fullKey, options.signal); + const url = await this.signSingleUrl(fullKey, "HEAD", options.signal); try { const response = await fetchOk(url, { method: "HEAD", signal: options.signal, progressListener: options.progressListener }); const contentLength = response.headers.get("content-length"); @@ -241,7 +273,7 @@ export class SsaS3KvStore implements KvStore { async read(key: string, options: DriverReadOptions): Promise { const fullKey = joinPath(this.datasetBasePrefix, key); - const url = await this.signSingleUrl("GET", fullKey, options.signal); + const url = await this.signSingleUrl(fullKey, "GET", options.signal); // Construct Range header based on options.byteRange for efficient reads. let rangeHeader: string | undefined; @@ -370,10 +402,24 @@ export class SsaS3KvStore implements KvStore { }, ); const parsed = parseListFilesResponse(await response.json()); - // Convert SSA list response into KvStore ListResponse shape. + // Compute immediate children relative to the requested prefix. + const childDirSet = new Set(); + const childFileSet = new Set(); + for (const obj of parsed.objects) { + const key = obj.key; + if (!key.startsWith(parsed.prefix)) continue; + const remainder = key.substring(parsed.prefix.length); + if (remainder === "") continue; + const slash = remainder.indexOf("/"); + if (slash === -1) { + childFileSet.add(remainder); + } else { + childDirSet.add(remainder.substring(0, slash + 1)); + } + } return { - directories: parsed.directories, - entries: parsed.entries.map((key) => ({ key })), + directories: Array.from(childDirSet), + entries: Array.from(childFileSet).map((k) => ({ key: k })), }; } catch (e) { if (e instanceof HttpError && (e.status === 401 || e.status === 403)) { From e749b7a358e261a82f54752c79c2650f18bb6d7e Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 055/251] feat: add backend integration for SSA KVStore - Register SSA backend provider to the KVStore registry. - Implement backend logic for Stateless S3 Authenticator (SSA) over HTTPS. - Update `SsaS3KvStore` to use shared context base and improve URL handling. - Extend package configuration to support backend registration. --- package.json | 6 +++ src/kvstore/enabled_backend_modules.ts | 1 + src/kvstore/ssa_s3/register_backend.ts | 64 ++++++++++++++++++++++++++ src/kvstore/ssa_s3/ssa_s3_kvstore.ts | 4 +- 4 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 src/kvstore/ssa_s3/register_backend.ts diff --git a/package.json b/package.json index d40092f29a..b66c774efa 100644 --- a/package.json +++ b/package.json @@ -498,6 +498,12 @@ "neuroglancer/kvstore/ssa_s3:disabled": "./src/util/false.ts", "default": "./src/kvstore/ssa_s3/register_frontend.ts" }, + "#kvstore/ssa_s3/register_backend": { + "neuroglancer/kvstore/ssa_s3:enabled": "./src/kvstore/ssa_s3/register_backend.ts", + "neuroglancer/kvstore:none_by_default": "./src/util/false.ts", + "neuroglancer/kvstore/ssa_s3:disabled": "./src/util/false.ts", + "default": "./src/kvstore/ssa_s3/register_backend.ts" + }, "#kvstore/zip/register_frontend": { "neuroglancer/kvstore/zip:enabled": "./src/kvstore/zip/register_frontend.ts", "neuroglancer/kvstore:none_by_default": "./src/util/false.ts", diff --git a/src/kvstore/enabled_backend_modules.ts b/src/kvstore/enabled_backend_modules.ts index 335031dc7d..e079aeb6e6 100644 --- a/src/kvstore/enabled_backend_modules.ts +++ b/src/kvstore/enabled_backend_modules.ts @@ -9,3 +9,4 @@ import "#kvstore/ngauth/register"; import "#kvstore/ocdbt/register_backend"; import "#kvstore/s3/register_backend"; import "#kvstore/zip/register_backend"; +import "#kvstore/ssa_s3/register_backend"; diff --git a/src/kvstore/ssa_s3/register_backend.ts b/src/kvstore/ssa_s3/register_backend.ts new file mode 100644 index 0000000000..653ce79eee --- /dev/null +++ b/src/kvstore/ssa_s3/register_backend.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2025 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 type { BaseKvStoreProvider } from "#src/kvstore/context.js"; +import type { SharedKvStoreContextBase } from "#src/kvstore/register.js"; +import { frontendBackendIsomorphicKvStoreProviderRegistry } from "#src/kvstore/register.js"; +import { SsaS3KvStore } from "#src/kvstore/ssa_s3/ssa_s3_kvstore.js"; + +const SSA_SCHEME_PREFIX = "ssa+"; + +function ensureSsaHttpsUrl(url: string): URL { + if (!url.startsWith("ssa+https://")) { + throw new Error(`Invalid URL ${JSON.stringify(url)}: expected ssa+https scheme`); + } + const httpUrl = url.substring(SSA_SCHEME_PREFIX.length); + const parsed = new URL(httpUrl); + if (parsed.hash) throw new Error("Fragment not supported in ssa+https URLs"); + if (parsed.username || parsed.password) throw new Error("Basic auth credentials are not supported in ssa+https URLs"); + return parsed; +} + +function getWorkerOriginAndDatasetPrefix(parsed: URL): { workerOrigin: string; datasetBasePrefix: string } { + const workerOrigin = parsed.origin; + const datasetBasePrefix = decodeURIComponent(parsed.pathname.replace(/^\//, "")); + return { workerOrigin, datasetBasePrefix }; +} + +function getDisplayBase(url: string): string { + const parsed = ensureSsaHttpsUrl(url); + return `${SSA_SCHEME_PREFIX}${parsed.origin}/`; +} + +function ssaIsomorphicProvider(context: SharedKvStoreContextBase): BaseKvStoreProvider { + return { + scheme: "ssa+https", + description: "Stateless S3 Authenticator (SSA) over HTTPS", + getKvStore(parsedUrl) { + const parsed = ensureSsaHttpsUrl(parsedUrl.url); + const { workerOrigin, datasetBasePrefix } = getWorkerOriginAndDatasetPrefix(parsed); + const displayBase = getDisplayBase(parsedUrl.url); + return { + store: new SsaS3KvStore(context, workerOrigin, "", displayBase), + path: datasetBasePrefix, + }; + }, + }; +} + +frontendBackendIsomorphicKvStoreProviderRegistry.registerBaseKvStoreProvider( + ssaIsomorphicProvider, +); diff --git a/src/kvstore/ssa_s3/ssa_s3_kvstore.ts b/src/kvstore/ssa_s3/ssa_s3_kvstore.ts index d9fc2b3ad9..fd76cde297 100644 --- a/src/kvstore/ssa_s3/ssa_s3_kvstore.ts +++ b/src/kvstore/ssa_s3/ssa_s3_kvstore.ts @@ -16,7 +16,7 @@ import type { OAuth2Credentials } from "#src/credentials_provider/oauth2.js"; import { fetchOkWithOAuth2CredentialsAdapter } from "#src/credentials_provider/oauth2.js"; -import type { SharedKvStoreContext } from "#src/kvstore/frontend.js"; +import type { SharedKvStoreContextBase } from "#src/kvstore/register.js"; import type { DriverReadOptions, KvStore, @@ -135,7 +135,7 @@ export class SsaS3KvStore implements KvStore { private authenticatePromise: Promise | undefined; constructor( - public readonly sharedKvStoreContext: SharedKvStoreContext, + public readonly sharedKvStoreContext: SharedKvStoreContextBase, workerOrigin: string, datasetBasePrefix: string, displayBaseUrl: string, From f100dfae78da4f6204f219a7f7383954489734bb Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 056/251] refactor: consolidate SSA URL utilities and improve PKCE verifier generation - Extract shared SSA URL utility functions (`ensureSsaHttpsUrl`, `getWorkerOriginAndDatasetPrefix`, `getDisplayBase`) into a new `url_utils.ts` module. - Update imports in frontend and backend SSA KVStore registrations to use the new utility module. - Increase PKCE verifier length from 64 to 128 characters for enhanced security in OIDC flow. - Update TODOs to reflect changes and mark relevant sections for autocomplete rework. --- NOTES/TODOs.md | 3 +- src/kvstore/ssa_s3/credentials_provider.ts | 2 +- src/kvstore/ssa_s3/register_backend.ts | 25 +------------- src/kvstore/ssa_s3/register_frontend.ts | 27 +-------------- src/kvstore/ssa_s3/ssa_s3_kvstore.ts | 2 +- src/kvstore/ssa_s3/url_utils.ts | 39 ++++++++++++++++++++++ 6 files changed, 45 insertions(+), 53 deletions(-) create mode 100644 src/kvstore/ssa_s3/url_utils.ts diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index 5d5416ae0c..46ebb419bc 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,6 +1,6 @@ # TODO List -- FOR TOMORROW: review, test and finish the ssa+https +- FOR TOMORROW: start to prepare the problematic/email to JMS + go through the todo list - LOD -> - "feat: dirty tree upscaling is kinda working, at lea @@ -26,6 +26,7 @@ - add shortcuts for tools (switching tools, toogle erase mode, select label from the pointed one in the slice view and adjusting brush size) and label creation - the flood fill may fill an entire unwanted area if the user is zoomed in enough so the max number of loaded chunks are under the vox voxel count and the flood has an escape hole. - the flood fill sometimes leaves artifacts in sharp areas +- rework the autocomplete for the ssa+https source. # Saving/importing/exporting diff --git a/src/kvstore/ssa_s3/credentials_provider.ts b/src/kvstore/ssa_s3/credentials_provider.ts index c7fd5d19c0..157111a721 100644 --- a/src/kvstore/ssa_s3/credentials_provider.ts +++ b/src/kvstore/ssa_s3/credentials_provider.ts @@ -142,7 +142,7 @@ function generateRandomAscii(length: number): string { } async function createPkcePair(): Promise<{ verifier: string; challenge: string }> { - const verifier = generateRandomAscii(64); + const verifier = generateRandomAscii(128); const challenge = base64UrlEncode(await sha256Bytes(new TextEncoder().encode(verifier))); return { verifier, challenge }; } diff --git a/src/kvstore/ssa_s3/register_backend.ts b/src/kvstore/ssa_s3/register_backend.ts index 653ce79eee..a5a67a52c8 100644 --- a/src/kvstore/ssa_s3/register_backend.ts +++ b/src/kvstore/ssa_s3/register_backend.ts @@ -18,30 +18,7 @@ import type { BaseKvStoreProvider } from "#src/kvstore/context.js"; import type { SharedKvStoreContextBase } from "#src/kvstore/register.js"; import { frontendBackendIsomorphicKvStoreProviderRegistry } from "#src/kvstore/register.js"; import { SsaS3KvStore } from "#src/kvstore/ssa_s3/ssa_s3_kvstore.js"; - -const SSA_SCHEME_PREFIX = "ssa+"; - -function ensureSsaHttpsUrl(url: string): URL { - if (!url.startsWith("ssa+https://")) { - throw new Error(`Invalid URL ${JSON.stringify(url)}: expected ssa+https scheme`); - } - const httpUrl = url.substring(SSA_SCHEME_PREFIX.length); - const parsed = new URL(httpUrl); - if (parsed.hash) throw new Error("Fragment not supported in ssa+https URLs"); - if (parsed.username || parsed.password) throw new Error("Basic auth credentials are not supported in ssa+https URLs"); - return parsed; -} - -function getWorkerOriginAndDatasetPrefix(parsed: URL): { workerOrigin: string; datasetBasePrefix: string } { - const workerOrigin = parsed.origin; - const datasetBasePrefix = decodeURIComponent(parsed.pathname.replace(/^\//, "")); - return { workerOrigin, datasetBasePrefix }; -} - -function getDisplayBase(url: string): string { - const parsed = ensureSsaHttpsUrl(url); - return `${SSA_SCHEME_PREFIX}${parsed.origin}/`; -} +import { ensureSsaHttpsUrl, getWorkerOriginAndDatasetPrefix, getDisplayBase } from "#src/kvstore/ssa_s3/url_utils.js"; function ssaIsomorphicProvider(context: SharedKvStoreContextBase): BaseKvStoreProvider { return { diff --git a/src/kvstore/ssa_s3/register_frontend.ts b/src/kvstore/ssa_s3/register_frontend.ts index 0373d4fc1d..cb95073c4d 100644 --- a/src/kvstore/ssa_s3/register_frontend.ts +++ b/src/kvstore/ssa_s3/register_frontend.ts @@ -20,34 +20,9 @@ import type { BaseKvStoreProvider, BaseKvStoreCompleteUrlOptions, CompletionResu import type { SharedKvStoreContext } from "#src/kvstore/frontend.js"; import { frontendOnlyKvStoreProviderRegistry } from "#src/kvstore/frontend.js"; import { SsaS3KvStore } from "#src/kvstore/ssa_s3/ssa_s3_kvstore.js"; +import { ensureSsaHttpsUrl, getWorkerOriginAndDatasetPrefix, getDisplayBase } from "#src/kvstore/ssa_s3/url_utils.js"; import { verifyObject, verifyObjectProperty, verifyString, verifyStringArray } from "#src/util/json.js"; -const SSA_SCHEME_PREFIX = "ssa+"; - -function ensureSsaHttpsUrl(url: string): URL { - if (!url.startsWith("ssa+https://")) { - throw new Error(`Invalid URL ${JSON.stringify(url)}: expected ssa+https scheme`); - } - const httpUrl = url.substring(SSA_SCHEME_PREFIX.length); - const parsed = new URL(httpUrl); - if (parsed.hash) throw new Error("Fragment not supported in ssa+https URLs"); - if (parsed.username || parsed.password) throw new Error("Basic auth credentials are not supported in ssa+https URLs"); - return parsed; -} - -function getWorkerOriginAndDatasetPrefix(parsed: URL): { workerOrigin: string; datasetBasePrefix: string } { - const workerOrigin = parsed.origin; - const datasetBasePrefix = decodeURIComponent(parsed.pathname.replace(/^\//, "")); - return { workerOrigin, datasetBasePrefix }; -} - -function getDisplayBase(url: string): string { - // Keep exactly the ssa+https://host/ base with any search parameters preserved on base. - const parsed = ensureSsaHttpsUrl(url); - // Construct base without the path, but keep scheme and origin. - return `${SSA_SCHEME_PREFIX}${parsed.origin}/`; -} - interface SsaAuthenticateResponseLite { permissions: { read: string[]; write: string[] }; endpoints: { signRequests: string; listFiles: string }; diff --git a/src/kvstore/ssa_s3/ssa_s3_kvstore.ts b/src/kvstore/ssa_s3/ssa_s3_kvstore.ts index fd76cde297..291441a6c5 100644 --- a/src/kvstore/ssa_s3/ssa_s3_kvstore.ts +++ b/src/kvstore/ssa_s3/ssa_s3_kvstore.ts @@ -16,7 +16,6 @@ import type { OAuth2Credentials } from "#src/credentials_provider/oauth2.js"; import { fetchOkWithOAuth2CredentialsAdapter } from "#src/credentials_provider/oauth2.js"; -import type { SharedKvStoreContextBase } from "#src/kvstore/register.js"; import type { DriverReadOptions, KvStore, @@ -25,6 +24,7 @@ import type { StatResponse, ReadResponse, } from "#src/kvstore/index.js"; +import type { SharedKvStoreContextBase } from "#src/kvstore/register.js"; import type { SsaCredentialsProvider } from "#src/kvstore/ssa_s3/credentials_provider.js"; import { pipelineUrlJoin } from "#src/kvstore/url.js"; import type { FetchOk } from "#src/util/http_request.js"; diff --git a/src/kvstore/ssa_s3/url_utils.ts b/src/kvstore/ssa_s3/url_utils.ts new file mode 100644 index 0000000000..6c70f51380 --- /dev/null +++ b/src/kvstore/ssa_s3/url_utils.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2025 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const SSA_SCHEME_PREFIX = "ssa+"; + +export function ensureSsaHttpsUrl(url: string): URL { + if (!url.startsWith("ssa+https://")) { + throw new Error(`Invalid URL ${JSON.stringify(url)}: expected ssa+https scheme`); + } + const httpUrl = url.substring(SSA_SCHEME_PREFIX.length); + const parsed = new URL(httpUrl); + if (parsed.hash) throw new Error("Fragment not supported in ssa+https URLs"); + if (parsed.username || parsed.password) throw new Error("Basic auth credentials are not supported in ssa+https URLs"); + return parsed; +} + +export function getWorkerOriginAndDatasetPrefix(parsed: URL): { workerOrigin: string; datasetBasePrefix: string } { + const workerOrigin = parsed.origin; + const datasetBasePrefix = decodeURIComponent(parsed.pathname.replace(/^\//, "")); + return { workerOrigin, datasetBasePrefix }; +} + +export function getDisplayBase(url: string): string { + const parsed = ensureSsaHttpsUrl(url); + return `${SSA_SCHEME_PREFIX}${parsed.origin}/`; +} From cbeb5cac34e675db5416c0b0f01bb55c320e7eec Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 057/251] feat: add IndexedDB-backed KVStore implementation - Implement `IndexedDBKvStore` with support for key-value storage operations (`read`, `write`, `delete`, `list`) - Register `IndexedDBKvStore` as a local storage provider (`local://`) in the frontend KVStore registry. - Update `KvStore` interface to include writable operations. - Extend package configuration to include IndexedDB module registration. --- package.json | 1 + src/kvstore/enabled_frontend_modules.ts | 1 + src/kvstore/index.ts | 7 +- src/kvstore/indexeddb/implementation.ts | 153 ++++++++++++++++++++++++ src/kvstore/indexeddb/register.ts | 45 +++++++ 5 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 src/kvstore/indexeddb/implementation.ts create mode 100644 src/kvstore/indexeddb/register.ts diff --git a/package.json b/package.json index b66c774efa..6e1c50630c 100644 --- a/package.json +++ b/package.json @@ -516,6 +516,7 @@ "neuroglancer/kvstore/zip:disabled": "./src/util/false.ts", "default": "./src/kvstore/zip/register_backend.ts" }, + "#kvstore/indexeddb/register": "./src/kvstore/indexeddb/register.ts", "#layer/annotation": { "neuroglancer/layer/annotation:enabled": "./src/layer/annotation/index.ts", "neuroglancer/layer:none_by_default": "./src/util/false.ts", diff --git a/src/kvstore/enabled_frontend_modules.ts b/src/kvstore/enabled_frontend_modules.ts index 3515ee62f4..3fdd002dd2 100644 --- a/src/kvstore/enabled_frontend_modules.ts +++ b/src/kvstore/enabled_frontend_modules.ts @@ -13,3 +13,4 @@ import "#kvstore/s3/register_frontend"; import "#kvstore/ssa_s3/register_credentials_provider"; import "#kvstore/ssa_s3/register_frontend"; import "#kvstore/zip/register_frontend"; +import "#kvstore/indexeddb/register"; diff --git a/src/kvstore/index.ts b/src/kvstore/index.ts index e94f870efb..afb249a7ba 100644 --- a/src/kvstore/index.ts +++ b/src/kvstore/index.ts @@ -91,7 +91,12 @@ export interface ListableKvStore { list?: (prefix: string, options: DriverListOptions) => Promise; } -export interface KvStore extends ReadableKvStore, ListableKvStore { +export interface WritableKvStore { + write?: (key: string, value: ArrayBuffer) => Promise; + delete?: (key: string) => Promise; +} + +export interface KvStore extends ReadableKvStore, ListableKvStore, WritableKvStore { // Indicates that the only valid key is the empty string. singleKey?: boolean; } diff --git a/src/kvstore/indexeddb/implementation.ts b/src/kvstore/indexeddb/implementation.ts new file mode 100644 index 0000000000..84b04d0ec5 --- /dev/null +++ b/src/kvstore/indexeddb/implementation.ts @@ -0,0 +1,153 @@ +/** + * @license + * Copyright 2025 + * 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 type { DriverReadOptions, KvStore, ListResponse, ReadResponse, StatOptions, StatResponse } from "#src/kvstore/index.js"; + +function promisifyRequest(req: IDBRequest, context: string): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result as T); + req.onerror = () => reject(new Error(`${context}: ${String(req.error?.message ?? req.error)}`, { cause: req.error ?? undefined })); + }); +} + +function awaitTransactionCompletion(tx: IDBTransaction, context: string): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onabort = () => reject(new Error(`${context}: transaction aborted`, { cause: tx.error ?? undefined })); + tx.onerror = () => reject(new Error(`${context}: transaction error`, { cause: tx.error ?? undefined })); + }); +} + +export class IndexedDBKvStore implements KvStore { + constructor(private readonly databaseName: string, private readonly storeName: string) {} + + private dbPromise: Promise | undefined; + + private getDb(): Promise { + if (this.dbPromise !== undefined) return this.dbPromise; + this.dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(this.databaseName); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(this.storeName)) { + db.createObjectStore(this.storeName); + } + }; + request.onerror = () => reject(new Error(`Failed to open IndexedDB database ${this.databaseName}`, { cause: request.error ?? undefined })); + request.onsuccess = () => resolve(request.result); + }); + return this.dbPromise; + } + + async stat(key: string, _options: StatOptions): Promise { + const db = await this.getDb(); + const tx = db.transaction(this.storeName, "readonly"); + const store = tx.objectStore(this.storeName); + const getReq = store.get(key); + const value = await promisifyRequest(getReq, `stat: get(${key})`); + await awaitTransactionCompletion(tx, `stat(${key})`); + if (value === undefined) return undefined; + if (!(value instanceof ArrayBuffer)) { + throw new Error(`stat(${key}): expected ArrayBuffer, got ${Object.prototype.toString.call(value)}`); + } + return { totalSize: value.byteLength }; + } + + async read(key: string, _options: DriverReadOptions): Promise { + const db = await this.getDb(); + const tx = db.transaction(this.storeName, "readonly"); + const store = tx.objectStore(this.storeName); + const getReq = store.get(key); + const value = await promisifyRequest(getReq, `read: get(${key})`); + await awaitTransactionCompletion(tx, `read(${key})`); + if (value === undefined) return undefined; + const response = new Response(value); + return { response, offset: 0, length: value.byteLength, totalSize: value.byteLength }; + } + + async write(key: string, value: ArrayBuffer): Promise { + const db = await this.getDb(); + const tx = db.transaction(this.storeName, "readwrite"); + const store = tx.objectStore(this.storeName); + const req = store.put(value, key); + await promisifyRequest(req, `write: put(${key})`); + await awaitTransactionCompletion(tx, `write(${key})`); + } + + async delete(key: string): Promise { + const db = await this.getDb(); + const tx = db.transaction(this.storeName, "readwrite"); + const store = tx.objectStore(this.storeName); + const req = store.delete(key); + await promisifyRequest(req, `delete: delete(${key})`); + await awaitTransactionCompletion(tx, `delete(${key})`); + } + + async list(prefix: string): Promise { + const db = await this.getDb(); + const tx = db.transaction(this.storeName, "readonly"); + const store = tx.objectStore(this.storeName); + + const upperBound = `${prefix}\uffff`; + const range = IDBKeyRange.bound(prefix, upperBound); + + const directories = new Set(); + const entries: Array<{ key: string }> = []; + + await new Promise((resolve, reject) => { + // IDB spec: openKeyCursor may not exist in older impls; fallback to openCursor reading keys only. + const cursorRequest = (store as any).openKeyCursor + ? (store as any).openKeyCursor(range) + : store.openCursor(range); + cursorRequest.onerror = () => reject(new Error(`list: cursor error for prefix ${prefix}`, { cause: cursorRequest.error ?? undefined })); + cursorRequest.onsuccess = () => { + const cursor: IDBCursor | null = cursorRequest.result as IDBCursor | null; + if (cursor === null) { + resolve(); + return; + } + const key = String(cursor.key); + if (!key.startsWith(prefix)) { + cursor.continue(); + return; + } + const remainder = key.substring(prefix.length); + const slashIndex = remainder.indexOf("/"); + if (slashIndex === -1) { + entries.push({ key }); + } else { + const dirName = prefix + remainder.substring(0, slashIndex); + directories.add(dirName); + } + cursor.continue(); + }; + }); + + await awaitTransactionCompletion(tx, `list(${prefix})`); + + const sortedEntries = entries.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); + const sortedDirectories = Array.from(directories).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + + return { entries: sortedEntries, directories: sortedDirectories }; + } + + getUrl(key: string): string { + return `local://${encodeURIComponent(this.databaseName)}/${encodeURIComponent(this.storeName)}/${encodeURIComponent(key)}`; + } + + get supportsOffsetReads(): boolean { return false; } + get supportsSuffixReads(): boolean { return false; } +} diff --git a/src/kvstore/indexeddb/register.ts b/src/kvstore/indexeddb/register.ts new file mode 100644 index 0000000000..0c656986d3 --- /dev/null +++ b/src/kvstore/indexeddb/register.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2025 + * 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 type { BaseKvStoreProvider } from "#src/kvstore/context.js"; +import { frontendOnlyKvStoreProviderRegistry } from "#src/kvstore/frontend.js"; +import type { KvStoreWithPath } from "#src/kvstore/index.js"; +import { IndexedDBKvStore } from "#src/kvstore/indexeddb/implementation.js"; +import type { UrlWithParsedScheme } from "#src/kvstore/url.js"; + +function getProvider(): BaseKvStoreProvider { + return { + scheme: "local", + description: "Stockage local dans le navigateur (IndexedDB)", + getKvStore(parsedUrl: UrlWithParsedScheme): KvStoreWithPath { + const suffix = parsedUrl.suffix; + if (suffix === undefined) { + throw new Error("local:// URL must include database and store names, e.g., local://db/store"); + } + // Expect suffix to start with //db/store[/path] + const m = suffix.match(/^\/\/([^\/]*)\/([^\/]*)(?:\/(.*))?$/); + if (m === null) { + throw new Error(`Invalid local URL suffix ${JSON.stringify(suffix)}; expected local:///[/path]`); + } + const databaseName = decodeURIComponent(m[1]); + const storeName = decodeURIComponent(m[2]); + const path = m[3] !== undefined ? decodeURIComponent(m[3]) : ""; + return { store: new IndexedDBKvStore(databaseName, storeName), path }; + }, + }; +} + +frontendOnlyKvStoreProviderRegistry.registerBaseKvStoreProvider(() => getProvider()); From b51bf6be1a5446622ea2f6ff18eeaafae778474b Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 058/251] feat: move the downscaling queue to the EditController backend and replace manual indexedDB operation with the use of the its related kvstore in the LocalVoxSource --- src/voxel_annotation/edit_backend.ts | 166 ++++++++ src/voxel_annotation/local_source.ts | 549 +++------------------------ 2 files changed, 228 insertions(+), 487 deletions(-) diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/edit_backend.ts index eb95a75ce8..e50dd2c881 100644 --- a/src/voxel_annotation/edit_backend.ts +++ b/src/voxel_annotation/edit_backend.ts @@ -12,6 +12,8 @@ import { VOX_EDIT_LABELS_GET_RPC_ID, VOX_EDIT_MAP_INIT_RPC_ID, VOX_RELOAD_CHUNKS_RPC_ID, + makeVoxChunkKey, + parseVoxChunkKey, } from "#src/voxel_annotation/base.js"; import type { VoxSourceWriter } from "#src/voxel_annotation/index.js"; import { LocalVoxSourceWriter } from "#src/voxel_annotation/local_source.js"; @@ -24,6 +26,7 @@ export class VoxelEditController extends SharedObject { private source?: VoxSourceWriter; private mapReadyPromise: Promise; private resolveMapReady!: () => void; + private mapCfg?: VoxMapConfig; // Short debounce to coalesce rapid edits coming from tools. private pendingEdits: { @@ -36,6 +39,11 @@ export class VoxelEditController extends SharedObject { private commitDebounceTimer: number | undefined; private readonly commitDebounceDelayMs: number = 300; + // Downsampling queue to serialize and coalesce work across edits. + private downsampleQueue: string[] = []; + private downsampleQueueSet: Set = new Set(); + private isProcessingDownsampleQueue: boolean = false; + constructor(rpc: RPC, options: any) { super(); // Initialize as a counterpart in the worker so RPC references are valid. @@ -52,6 +60,7 @@ export class VoxelEditController extends SharedObject { const src = new LocalVoxSourceWriter(this); await src.init(map); this.source = src; + this.mapCfg = map; try { this.resolveMapReady(); } catch {/* ignore */} } @@ -64,6 +73,12 @@ export class VoxelEditController extends SharedObject { this.commitDebounceTimer = undefined; if (edits.length === 0) return; await src.applyEdits(edits); + // After base edits, enqueue downsampling for affected chunks (do not await here). + const touched = new Set(); + for (const e of edits) touched.add(e.key); + for (const key of touched) { + this.enqueueDownsample(key); + } } async commitVoxels( @@ -107,6 +122,157 @@ export class VoxelEditController extends SharedObject { voxChunkKeys: voxChunkKeys, }) } + // Downsampling helpers + private parentKeyOf(childKey: string): string | null { + if (!this.mapCfg) return null; + const info = parseVoxChunkKey(childKey); + if (info === null) return null; + const parentLod = info.lod * 2; + const maxLOD = this.mapCfg.steps[this.mapCfg.steps.length - 1]; + if (parentLod > maxLOD) return null; + const px = Math.floor(info.x / 2); + const py = Math.floor(info.y / 2); + const pz = Math.floor(info.z / 2); + return makeVoxChunkKey(`${px},${py},${pz}`, parentLod); + } + + private calculateDownsamplePasses(chunkSize: number): number { + if (chunkSize <= 1) return 0; + return Math.ceil(Math.log2(chunkSize)); + } + + private async performDownsampleCascadeForKey(sourceKey: string): Promise { + const cfg = this.mapCfg; + const src = this.source; + if (!cfg || !src) return; + const chunkSize = cfg.chunkDataSize[0]; + const maxPasses = this.calculateDownsamplePasses(chunkSize); + const maxLOD = cfg.steps[cfg.steps.length - 1]; + + let currentKey: string | null = sourceKey; + for (let i = 0; i < maxPasses; i++) { + if (currentKey === null) break; + const info = parseVoxChunkKey(currentKey); + if (info === null) break; + if (info.lod >= maxLOD) break; + const nextKey = await this.downsampleStep(currentKey); + if (nextKey === null) break; + currentKey = nextKey; + } + } + + private calculateMode(values: number[]): number { + if (values.length === 0) return 0; + const counts = new Map(); + let maxCount = 0; + let mode = 0; + for (const v of values) { + if (v === 0) continue; + const c = (counts.get(v) ?? 0) + 1; + counts.set(v, c); + if (c > maxCount) { + maxCount = c; + mode = v; + } + } + return mode; + } + + private enqueueDownsample(key: string): void { + if (key.length === 0) return; + if (!this.downsampleQueueSet.has(key)) { + this.downsampleQueueSet.add(key); + this.downsampleQueue.push(key); + } + if (!this.isProcessingDownsampleQueue) { + // Kick processing asynchronously to avoid blocking the caller. + this.isProcessingDownsampleQueue = true; + Promise.resolve().then(() => this.processDownsampleQueue()); + } + } + + private async processDownsampleQueue(): Promise { + try { + while (this.downsampleQueue.length > 0) { + const key = this.downsampleQueue.shift() as string; + this.downsampleQueueSet.delete(key); + await this.performDownsampleCascadeForKey(key); + } + } finally { + this.isProcessingDownsampleQueue = false; + // If new work was enqueued during processing and flag got reset, loop again. + if (this.downsampleQueue.length > 0 && !this.isProcessingDownsampleQueue) { + this.isProcessingDownsampleQueue = true; + Promise.resolve().then(() => this.processDownsampleQueue()); + } + } + } + + private async downsampleStep(sourceKey: string): Promise { + const cfg = this.mapCfg; + const src = this.source; + if (!cfg || !src) return null; + const info = parseVoxChunkKey(sourceKey); + if (info === null) return null; + + const sourceChunk = await src.getSavedChunk(sourceKey); + if (!sourceChunk) return null; + + const targetKey = this.parentKeyOf(sourceKey); + if (targetKey === null) return null; + + // Prepare indices and values to write into the target chunk + const chunkW = sourceChunk.size[0] / 2; // target subregion width + const chunkH = sourceChunk.size[1] / 2; + const chunkD = sourceChunk.size[2] / 2; + const offsetX = (info.x % 2) * chunkW; + const offsetY = (info.y % 2) * chunkH; + const offsetZ = (info.z % 2) * chunkD; + + const targetSizeX = cfg.chunkDataSize[0]; + const targetSizeY = cfg.chunkDataSize[1]; + + const indices: number[] = []; + const values: number[] = []; + + for (let z = 0; z < chunkD; z++) { + for (let y = 0; y < chunkH; y++) { + for (let x = 0; x < chunkW; x++) { + const sx = x * 2; + const sy = y * 2; + const sz = z * 2; + const base = sz * (sourceChunk.size[0] * sourceChunk.size[1]) + sy * sourceChunk.size[0] + sx; + const row = sourceChunk.size[0]; + const plane = sourceChunk.size[0] * sourceChunk.size[1]; + // Gather 8 voxels from source + const v000 = Number((sourceChunk.data as any)[base]); + const v100 = Number((sourceChunk.data as any)[base + 1]); + const v010 = Number((sourceChunk.data as any)[base + row]); + const v110 = Number((sourceChunk.data as any)[base + row + 1]); + const v001 = Number((sourceChunk.data as any)[base + plane]); + const v101 = Number((sourceChunk.data as any)[base + plane + 1]); + const v011 = Number((sourceChunk.data as any)[base + plane + row]); + const v111 = Number((sourceChunk.data as any)[base + plane + row + 1]); + const mode = this.calculateMode([v000, v100, v010, v110, v001, v101, v011, v111]); + + const tx = x + offsetX; + const ty = y + offsetY; + const tz = z + offsetZ; + const tIndex = tz * (targetSizeX * targetSizeY) + ty * targetSizeX + tx; + indices.push(tIndex); + values.push(mode >>> 0); + } + } + } + + if (indices.length > 0) { + await src.applyEdits([ + { key: targetKey, indices, values }, + ]); + } + + return targetKey; + } } // RPC wire-up diff --git a/src/voxel_annotation/local_source.ts b/src/voxel_annotation/local_source.ts index 951949f279..934599541d 100644 --- a/src/voxel_annotation/local_source.ts +++ b/src/voxel_annotation/local_source.ts @@ -1,4 +1,5 @@ -import { makeVoxChunkKey, parseVoxChunkKey } from "#src/voxel_annotation/base.js"; +import { IndexedDBKvStore } from "#src/kvstore/indexeddb/implementation.js"; +import { fetchZarrChunkIfAvailable } from "#src/voxel_annotation/import_from_zarr.js"; import type { SavedChunk} from "#src/voxel_annotation/index.js"; import { VoxSourceWriter , compositeChunkDbKey, @@ -10,41 +11,29 @@ import type { import { constructVoxMapConfig , computeSteps } from "#src/voxel_annotation/map.js"; -import { fetchZarrChunkIfAvailable } from "#src/voxel_annotation/import_from_zarr.js"; - -/** - * Calculates the number of meaningful downsample passes for the worst case - */ -function calculateDownsamplePasses(chunkSize: number) { - if (chunkSize <= 1) { - return 0; - } - - return Math.ceil(Math.log2(chunkSize)); -} - // Simple read only local source, this class can be instantiated multiple times without side effects. export class LocalVoxSource extends VoxSource { - private dbPromise: Promise | null = null; - - private async getDb(): Promise { - if (this.dbPromise) return this.dbPromise; - this.dbPromise = openVoxDb(); - return this.dbPromise; + protected kvStore: IndexedDBKvStore; + protected labelsKvStore: IndexedDBKvStore; + + override async init(map: VoxMapConfig): Promise<{ mapId: string }> { + const meta = await super.init(map); + this.kvStore = new IndexedDBKvStore("neuroglancer_vox", "chunks"); + this.labelsKvStore = new IndexedDBKvStore("neuroglancer_vox", "labels"); + return meta; } async getSavedChunk(key: string): Promise { - const db = await this.getDb(); - const composite = compositeChunkDbKey(this.mapId, key); - const buf = await idbGet(db, "chunks", composite); - if (buf) { - const arr = new Uint32Array(buf); - const sc: SavedChunk = { - data: arr, + const compositeKey = compositeChunkDbKey(this.mapId, key); + const readResponse = await this.kvStore.read(compositeKey, {}); + if (readResponse) { + const buffer = await readResponse.response.arrayBuffer(); + const dataArray = new Uint32Array(buffer); + return { + data: dataArray, size: new Uint32Array(this.mapCfg!.chunkDataSize as any), }; - return sc; } // Fallback to remote Zarr import if available const remote = await fetchZarrChunkIfAvailable(this.mapCfg, key); @@ -59,187 +48,16 @@ export class LocalVoxSource extends VoxSource { // More complete local source that supports writing and more, THIS CLASS SHOULD NOT BE INSTANTIATED MULTIPLE TIMES per maps export class LocalVoxSourceWriter extends VoxSourceWriter { - // Upscaling halted - /* - private static readonly DIRTY_STORE = "dirty"; - - private async readChunkFromDbWithoutSideEffects(key: string): Promise { - const existing = this.saved.get(key); - if (existing) return existing; - const db = await this.getDb(); - const composite = compositeChunkDbKey(this.mapId, key); - const buf = await idbGet(db, "chunks", composite); - if (!buf) return undefined; - const arr = new Uint32Array(buf); - const sc: SavedChunk = { - data: arr, - size: new Uint32Array(this.mapCfg!.chunkDataSize as any), - }; - this.saved.set(key, sc); - this.enforceCap(); - return sc; - } - - private async setDirtyTreeFlag(key: string, isDirty: boolean): Promise { - const db = await this.getDb(); - const tx = db.transaction(LocalVoxSource.DIRTY_STORE, "readwrite"); - const store = tx.objectStore(LocalVoxSource.DIRTY_STORE); - const composite = compositeChunkDbKey(this.mapId, key); - await idbPut(store, isDirty ? 1 : 0, composite); - await txDone(tx); - } - - private async getDirtyTreeValue(key: string): Promise<0 | 1 | undefined> { - const db = await this.getDb(); - const composite = compositeChunkDbKey(this.mapId, key); - const v = await idbGet(db, LocalVoxSource.DIRTY_STORE, composite); - if (v === undefined) return undefined; - if (v !== 0 && v !== 1) throw new Error(`Invalid dirty-tree value for ${key}: ${String(v)}`); - return v as 0 | 1; - }*/ + protected kvStore: IndexedDBKvStore; + protected labelsKvStore: IndexedDBKvStore; - private parentKeyOf(childKey: string): string | null { - const info = parseVoxChunkKey(childKey); - if (!info) return null; - const parentLod = info.lod * 2; - const maxLOD = this.mapCfg!.steps[this.mapCfg!.steps.length - 1]; - if (parentLod > maxLOD) return null; - const px = Math.floor(info.x / 2); - const py = Math.floor(info.y / 2); - const pz = Math.floor(info.z / 2); - return makeVoxChunkKey(`${px},${py},${pz}`, parentLod); + private ensureArrayBuffer(value: ArrayBufferLike): ArrayBuffer { + if (value instanceof ArrayBuffer) return value; + const out = new ArrayBuffer(value.byteLength); + new Uint8Array(out).set(new Uint8Array(value)); + return out; } - // Upscaling halted - /* - private childKeysOf(parentKey: string): string[] { - const info = parseVoxChunkKey(parentKey); - if (!info) throw new Error(`Invalid voxel chunk key: ${parentKey}`); - const childLod = info.lod / 2; - if (childLod < 1) return []; - const baseX = info.x * 2; - const baseY = info.y * 2; - const baseZ = info.z * 2; - const out: string[] = []; - for (let dz = 0; dz < 2; dz++) { - for (let dy = 0; dy < 2; dy++) { - for (let dx = 0; dx < 2; dx++) { - out.push(makeVoxChunkKey(`${baseX + dx},${baseY + dy},${baseZ + dz}`, childLod)); - } - } - } - return out; - } - - private async markChildrenDirtyInTree(parentKey: string): Promise { - const children = this.childKeysOf(parentKey); - if (children.length === 0) return; - const db = await this.getDb(); - const tx = db.transaction(LocalVoxSource.DIRTY_STORE, "readwrite"); - const store = tx.objectStore(LocalVoxSource.DIRTY_STORE); - for (const ck of children) { - await idbPut(store, 1, compositeChunkDbKey(this.mapId, key)); - } - await txDone(tx); - } - - private async ensureUpscaledPathTo(targetKey: string): Promise { - // Find nearest CLEAN ancestor using the dirty-tree only, then descend regenerating dirty/missing nodes. - const parsedTarget = parseVoxChunkKey(targetKey); - if (!parsedTarget) throw new Error(`ensureUpscaledPathTo: invalid target key: ${targetKey}`); - const maxLOD = this.mapCfg!.steps[this.mapCfg!.steps.length - 1]; - - // Build ancestor chain from target up to the root (inclusive) - const ancestors: string[] = [targetKey]; - while (true) { - const last = ancestors[ancestors.length - 1]; - const p = this.parentKeyOf(last); - if (!p) break; - ancestors.push(p); - const pInfo = parseVoxChunkKey(p)!; - if (pInfo.lod === maxLOD) break; - } - - // Find the nearest clean ancestor after a dirty one - let cleanAncestorIndex = -1; - let hasDirt = false; - for (let i = 0; i < ancestors.length; i++) { - const k = ancestors[i]; - const v = await this.getDirtyTreeValue(k); - if (v === 0 && hasDirt) { - cleanAncestorIndex = i; - break; - } - if (v === 1){ - hasDirt = true; - } - } - if (cleanAncestorIndex === -1) { - // No clean ancestor registered in tree -> nothing to upscale from. - return; - } - - // Descend from that ancestor down to the target - for (let i = cleanAncestorIndex - 1; i >= 0; i--) { - const childKey = ancestors[i]; - const parentKey = ancestors[i + 1]; - - // A clean ancestor must exist physically. Enforce invariant strictly. - const parentChunk = await this.readChunkFromDbWithoutSideEffects(parentKey); - if (!parentChunk) throw new Error(`Missing parent chunk for clean node during upscaling: ${parentKey} ${ancestors}`); - - const childDirtyVal = await this.getDirtyTreeValue(childKey); - const needsRegeneration = childDirtyVal === 1 || childDirtyVal === undefined; - if (needsRegeneration) { - await this.upscaleFromParentIntoChild(parentChunk, parentKey, childKey); - await this.setDirtyTreeFlag(childKey, false); - await this.markChildrenDirtyInTree(childKey); - console.log(`Upscaled ${childKey} from ${parentKey}`); - } - } - } - - private async upscaleFromParentIntoChild(parentChunk: SavedChunk, parentKey: string, childKey: string): Promise { - const pInfo = parseVoxChunkKey(parentKey); - const cInfo = parseVoxChunkKey(childKey); - if (!pInfo || !cInfo) throw new Error("Invalid parent/child keys for upscaling"); - if (cInfo.lod !== pInfo.lod / 2) throw new Error("Upscale expects child lod to be half of parent lod"); - - const childSize = new Uint32Array(this.mapCfg!.chunkDataSize as any); - const total = (childSize[0] | 0) * (childSize[1] | 0) * (childSize[2] | 0); - let child = this.saved.get(childKey); - if (!child) { - child = { data: new Uint32Array(total), size: childSize }; - this.saved.set(childKey, child); - this.enforceCap(); - } - - const [cw, ch, cd] = child.size; - const [pw, ph] = parentChunk.size; - const subW = pw / 2; - const subH = ph / 2; - const subD = parentChunk.size[2] / 2; - const offX = (cInfo.x % 2) * subW; - const offY = (cInfo.y % 2) * subH; - const offZ = (cInfo.z % 2) * subD; - - for (let z = 0; z < cd; z++) { - const pz = Math.floor(z / 2) + offZ; - for (let y = 0; y < ch; y++) { - const py = Math.floor(y / 2) + offY; - for (let x = 0; x < cw; x++) { - const px = Math.floor(x / 2) + offX; - const pIndex = (pz | 0) * pw * ph + (py | 0) * pw + (px | 0); - const cIndex = z * cw * ch + y * cw + x; - (child.data as Uint32Array)[cIndex] = (parentChunk.data as Uint32Array)[pIndex]; - } - } - } - - this.saved.set(childKey, child); - this.markDirty(childKey); - } - */ override async listMaps(): Promise { try { @@ -323,11 +141,14 @@ export class LocalVoxSourceWriter extends VoxSourceWriter { override async getLabelIds(): Promise { try { - const db = await this.getDb(); const key = compositeLabelsDbKey(this.mapId); - const arr = await idbGet(db, "labels", key); - if (arr && Array.isArray(arr)) return arr.map((v) => v >>> 0); - return []; + const readResponse = await this.labelsKvStore.read(key, {}); + if (!readResponse) return []; + const buffer = await readResponse.response.arrayBuffer(); + const text = new TextDecoder().decode(new Uint8Array(buffer)); + const arr = JSON.parse(text); + if (!Array.isArray(arr)) return []; + return arr.map((v: unknown) => Number(v) >>> 0); } catch { return []; } @@ -336,15 +157,19 @@ export class LocalVoxSourceWriter extends VoxSourceWriter { override async addLabel(value: number): Promise { const v = value >>> 0; - const db = await this.getDb(); const key = compositeLabelsDbKey(this.mapId); - const arr = (await idbGet(db, "labels", key)) || []; - // Ensure uniqueness - if (!arr.some((x) => (x >>> 0) === v)) arr.push(v); - const tx = db.transaction("labels", "readwrite"); - await idbPut(tx.objectStore("labels"), arr.map((x) => x >>> 0), key); - await txDone(tx); - return arr.map((x) => x >>> 0); + const readResponse = await this.labelsKvStore.read(key, {} as any); + let arr: number[] = []; + if (readResponse) { + const buffer = await readResponse.response.arrayBuffer(); + const text = new TextDecoder().decode(new Uint8Array(buffer)); + const parsed = JSON.parse(text); + if (Array.isArray(parsed)) arr = parsed.map((x: unknown) => Number(x) >>> 0); + } + if (!arr.some((x) => x === v)) arr.push(v); + const encoded = new TextEncoder().encode(JSON.stringify(arr)); + await this.labelsKvStore.write(key, this.ensureArrayBuffer(encoded.buffer)); + return arr.slice(); } private touch(key: string) { @@ -374,38 +199,22 @@ export class LocalVoxSourceWriter extends VoxSourceWriter { override async init(map: VoxMapConfig) { const meta = await super.init(map); - const db = await this.getDb(); - // Persist/update map metadata - const tx = db.transaction("maps", "readwrite"); - const cfg = this.mapCfg!; - tx.objectStore("maps").put( - cfg, - this.mapId, - ); - await txDone(tx); + this.kvStore = new IndexedDBKvStore("neuroglancer_vox", "chunks"); + this.labelsKvStore = new IndexedDBKvStore("neuroglancer_vox", "labels"); return meta; } async getSavedChunk(key: string): Promise { - // Before returning, ensure any pending upscales are realized for this key. - // Upscaling halted - /*if (this.mapCfg) { - try { - await this.ensureUpscaledPathTo(key); - } catch (e) { - console.error("ensureUpscaledPathTo failed", e); - } - }*/ const existing = this.saved.get(key); if (existing) { this.touch(key); return existing; } - const db = await this.getDb(); const composite = compositeChunkDbKey(this.mapId, key); - const buf = await idbGet(db, "chunks", composite); - if (buf) { - const arr = new Uint32Array(buf); + const readResponse = await this.kvStore.read(composite, {}); + if (readResponse) { + const buffer = await readResponse.response.arrayBuffer(); + const arr = new Uint32Array(buffer); const sc: SavedChunk = { data: arr, size: new Uint32Array(this.mapCfg!.chunkDataSize as any), @@ -419,9 +228,7 @@ export class LocalVoxSourceWriter extends VoxSourceWriter { if (remote) { this.saved.set(key, remote); this.enforceCap(); - const tx = db.transaction("chunks", "readwrite"); - await idbPut(tx.objectStore("chunks"), remote.data.buffer, composite); - await txDone(tx); + await this.kvStore.write(composite, this.ensureArrayBuffer(remote.data.buffer)); return remote; } return undefined; @@ -436,21 +243,11 @@ export class LocalVoxSourceWriter extends VoxSourceWriter { this.touch(key); return sc; } - // Attempt to satisfy this chunk by performing any pending upscales along its path. - // Upscaling halted - /* - if (this.mapCfg) { - try { - await this.ensureUpscaledPathTo(key); - } catch (e) { - console.error("ensureUpscaledPathTo failed in ensureChunk", e); - } - }*/ - const db = await this.getDb(); const composite = compositeChunkDbKey(this.mapId, key); - const buf = await idbGet(db, "chunks", composite); - if (buf) { - const arr = new Uint32Array(buf); + const readResponse = await this.kvStore.read(composite, {}); + if (readResponse) { + const buffer = await readResponse.response.arrayBuffer(); + const arr = new Uint32Array(buffer); sc = { data: arr, size: new Uint32Array(this.mapCfg!.chunkDataSize as any) }; this.saved.set(key, sc); this.enforceCap(); @@ -462,9 +259,7 @@ export class LocalVoxSourceWriter extends VoxSourceWriter { sc = remote; this.saved.set(key, sc); this.enforceCap(); - const tx = db.transaction("chunks", "readwrite"); - await idbPut(tx.objectStore("chunks"), sc.data.buffer, composite); - await txDone(tx); + await this.kvStore.write(composite, this.ensureArrayBuffer(sc.data.buffer)); return sc; } const fallbackSize = new Uint32Array(this.mapCfg!.chunkDataSize as any); @@ -487,11 +282,9 @@ export class LocalVoxSourceWriter extends VoxSourceWriter { size?: number[]; }[], ) { - const touchedKeys = new Set(); for (const e of edits) { const count = (e.indices as any)?.length | 0; if (count <= 0) { - // No actual edits for this key. Do not allocate, do not mark dirty. continue; } const sc = await this.ensureChunk( @@ -500,165 +293,7 @@ export class LocalVoxSourceWriter extends VoxSourceWriter { ); this.applyEditsIntoChunk(sc, e.indices, e.value, e.values); this.markDirty(e.key); - // Upscaling halted - //await this.setDirtyTreeFlag(e.key, false); - //await this.markChildrenDirtyInTree(e.key); - touchedKeys.add(e.key); - } - console.log(`Applied ${edits.length} edits to ${touchedKeys.size} chunks`); - for (const key of touchedKeys) { - this.propagateDownsample(key); - } - } - - private chunksToReload = new Set(); - private DELAY_BEFORE_CHUNK_RELOAD = 100; - - // Downscale job queue to serialize downsampling cascades - private downscaleQueue: string[] = []; - private downscaleQueuedKeys = new Set(); - private isProcessingDownscaleQueue = false; - - /** - * Public entry point to start the downsampling cascade for a modified chunk. - * @param sourceKey The key of the chunk that was edited. - */ - public async propagateDownsample(sourceKey: string): Promise { - const keyInfo = parseVoxChunkKey(sourceKey); - if (!keyInfo || !this.mapCfg) return; - if (!this.downscaleQueuedKeys.has(sourceKey)) { - this.downscaleQueuedKeys.add(sourceKey); - this.downscaleQueue.push(sourceKey); } - // Trigger processor but do not await full drain here to avoid blocking callers - void this._processDownscaleQueue(); - } - - private async _processDownscaleQueue(): Promise { - if (this.isProcessingDownscaleQueue) return; - this.isProcessingDownscaleQueue = true; - try { - while (this.downscaleQueue.length > 0) { - const nextKey = this.downscaleQueue.shift(); - if (nextKey === undefined) { - throw new Error("Downscale queue returned undefined key"); - } - this.downscaleQueuedKeys.delete(nextKey); - try { - await this._performDownsampleCascade(nextKey); - } catch (err) { - console.error("Downscale job failed for key", nextKey, err); - } - } - } finally { - this.isProcessingDownscaleQueue = false; - } - } - - private async _performDownsampleCascade(sourceKey: string): Promise { - if (!this.mapCfg) return; - // Assuming cubic chunks - const chunkSize = this.mapCfg.chunkDataSize[0]; - const maxPasses = calculateDownsamplePasses(chunkSize); - const maxLOD = this.mapCfg.steps[this.mapCfg.steps.length - 1]; - - let currentKey: string | null = sourceKey; - for (let i = 0; i < maxPasses; i++) { - if (!currentKey) break; - const currentKeyInfo = parseVoxChunkKey(currentKey); - if (!currentKeyInfo) break; - if (currentKeyInfo.lod >= maxLOD) { - break; - } - const targetKey = await this._downsampleStep(currentKey); - if (!targetKey) { - break; - } - currentKey = targetKey; - } - } - - /** - * Performs a single downsample step, creating one lower-resolution chunk - * from a higher-resolution one. - */ - private async _downsampleStep(sourceKey: string): Promise { - const sourceKeyInfo = parseVoxChunkKey(sourceKey); - if (!sourceKeyInfo) return null; - - const sourceChunk = await this.getSavedChunk(sourceKey); - if (!sourceChunk) return null; // Cannot downsample if source doesn't exist. - - const targetKey = this.parentKeyOf(sourceKey); - if (!targetKey) return null; - - const targetChunk = await this.ensureChunk(targetKey); - - // Determine the 32x32x32 sub-volume to write into the target chunk - const [chunkW, chunkH, chunkD] = targetChunk.size; - const [subW, subH, subD] = [chunkW / 2, chunkH / 2, chunkD / 2]; - const offsetX = (sourceKeyInfo.x % 2) * subW; - const offsetY = (sourceKeyInfo.y % 2) * subH; - const offsetZ = (sourceKeyInfo.z % 2) * subD; - - for (let z = 0; z < subD; z++) { - for (let y = 0; y < subH; y++) { - for (let x = 0; x < subW; x++) { - const sourceValues: number[] = []; - // Collect the 8 corresponding source voxels - for (let dz = 0; dz < 2; dz++) { - for (let dy = 0; dy < 2; dy++) { - for (let dx = 0; dx < 2; dx++) { - const val = this._getVoxel(sourceChunk, x * 2 + dx, y * 2 + dy, z * 2 + dz); - sourceValues.push(val as number); // WARNING: Bigint not supported - } - } - } - const mode = this._calculateMode(sourceValues); - this._setVoxel(targetChunk, x + offsetX, y + offsetY, z + offsetZ, mode); - } - } - } - - this.saved.set(targetKey, targetChunk); - this.markDirty(targetKey); - this.chunksToReload.add(targetKey); - // Upscaling halted - //await this.setDirtyTreeFlag(targetKey, false); - return targetKey; - } - - private _getVoxel(chunk: SavedChunk, x: number, y: number, z: number): number | bigint { - const [sx, sy] = chunk.size; - // Bounds check is implicitly handled by the loop structure but good practice - const index = z * sx * sy + y * sx + x; - return chunk.data[index]; - } - - private _setVoxel(chunk: SavedChunk, x: number, y: number, z: number, value: number | bigint): void { - const [sx, sy] = chunk.size; - const index = z * sx * sy + y * sx + x; - chunk.data[index] = value; - } - - /** Calculates the most frequent non-zero value (mode) for label data. */ - // TODO: support bigint - private _calculateMode(values: number[] | bigint[]): number | bigint { - if (values.length === 0) return 0; - const counts = new Map(); - let maxCount = 0; - let mode = 0; // Default to 0 (background) - - for (const val of values) { - if (val === 0) continue; // Ignore the background label - const count = (counts.get(val) || 0) + 1; - counts.set(val, count); - if (count > maxCount) { - maxCount = count; - mode = val as number; // WARNING: this will break if bigint is used for labels - } - } - return mode; } @@ -669,24 +304,19 @@ export class LocalVoxSourceWriter extends VoxSourceWriter { return; } this.dirty.clear(); - const db = await this.getDb(); - const tx = db.transaction("chunks", "readwrite"); - const store = tx.objectStore("chunks"); + const flushedKeys: string[] = []; for (const key of keys) { const sc = this.saved.get(key); if (!sc) continue; - if (this._isAllZero(sc.data)) - continue; - - await idbPut(store, sc.data.buffer, compositeChunkDbKey(this.mapId, key)); + if (this._isAllZero(sc.data)) continue; + const composite = compositeChunkDbKey(this.mapId, key); + await this.kvStore.write(composite, this.ensureArrayBuffer(sc.data.buffer)); + flushedKeys.push(key); } - await txDone(tx); this.saveTimer = undefined; - const toReload = Array.from(this.chunksToReload); - this.chunksToReload.clear(); setTimeout(() => { - this.callChunkReload(toReload); - }, this.DELAY_BEFORE_CHUNK_RELOAD); + this.callChunkReload(flushedKeys); + }, 100); } private async getDb(): Promise { @@ -712,62 +342,7 @@ export function openVoxDb(): Promise { if (!db.objectStoreNames.contains("maps")) db.createObjectStore("maps"); if (!db.objectStoreNames.contains("chunks")) db.createObjectStore("chunks"); if (!db.objectStoreNames.contains("labels")) db.createObjectStore("labels"); - - // Ensure dirty store exists - // Upscaling halted - /* - let dirtyStore: IDBObjectStore; - if (!db.objectStoreNames.contains("dirty")) { - dirtyStore = db.createObjectStore("dirty"); - } else { - dirtyStore = (req.transaction as IDBTransaction).objectStore("dirty"); - } - - // Backfill: for every key in chunks, write a clean (0) entry in dirty store. - const tx = req.transaction as IDBTransaction; - if (Array.from(db.objectStoreNames).includes("chunks")) { - const chunksStore = tx.objectStore("chunks"); - const cursorReq = (chunksStore as any).openKeyCursor(); - cursorReq.onsuccess = () => { - const cursor: IDBCursor | null = cursorReq.result as IDBCursor | null; - if (cursor) { - dirtyStore.put(0, cursor.key); - cursor.continue(); - } - }; - }*/ }; req.onsuccess = () => resolve(req.result); }); } - -// --- Small IDB helpers --- -export function idbGet( - db: IDBDatabase, - storeName: string, - key: IDBValidKey, -): Promise { - return new Promise((resolve, reject) => { - const tx = db.transaction(storeName, "readonly"); - const store = tx.objectStore(storeName); - const req = store.get(key); - req.onerror = () => reject(req.error); - req.onsuccess = () => resolve(req.result as any); - }); -} - -export function idbPut(store: IDBObjectStore, value: any, key?: IDBValidKey) { - return new Promise((resolve, reject) => { - const req = key === undefined ? store.put(value) : store.put(value, key); - req.onerror = () => reject(req.error); - req.onsuccess = () => resolve(); - }); -} - -export function txDone(tx: IDBTransaction) { - return new Promise((resolve, reject) => { - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - tx.onabort = () => reject(tx.error); - }); -} From 5c66105859e3d6f8b303a7c0830d7f9fd0b15eaa Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 059/251] feat: replace IndexedDB implementation with OPFS-backed KVStore - Remove `IndexedDBKvStore` and associated frontend registration logic. - Add `OpfsKvStore` for OPFS-based key-value storage operations. - Implement separate backend and frontend registers for OPFS (`opfs://`) storage scheme. - Update KVStore module imports to use OPFS as the new default local storage option. --- src/kvstore/enabled_backend_modules.ts | 1 + src/kvstore/enabled_frontend_modules.ts | 2 +- src/kvstore/indexeddb/implementation.ts | 153 ------------------- src/kvstore/indexeddb/register.ts | 45 ------ src/kvstore/opfs/backend.ts | 189 ++++++++++++++++++++++++ src/kvstore/opfs/common.ts | 52 +++++++ src/kvstore/opfs/frontend.ts | 34 +++++ src/kvstore/opfs/register_backend.ts | 21 +++ src/kvstore/opfs/register_frontend.ts | 21 +++ 9 files changed, 319 insertions(+), 199 deletions(-) delete mode 100644 src/kvstore/indexeddb/implementation.ts delete mode 100644 src/kvstore/indexeddb/register.ts create mode 100644 src/kvstore/opfs/backend.ts create mode 100644 src/kvstore/opfs/common.ts create mode 100644 src/kvstore/opfs/frontend.ts create mode 100644 src/kvstore/opfs/register_backend.ts create mode 100644 src/kvstore/opfs/register_frontend.ts diff --git a/src/kvstore/enabled_backend_modules.ts b/src/kvstore/enabled_backend_modules.ts index e079aeb6e6..42333d904d 100644 --- a/src/kvstore/enabled_backend_modules.ts +++ b/src/kvstore/enabled_backend_modules.ts @@ -10,3 +10,4 @@ import "#kvstore/ocdbt/register_backend"; import "#kvstore/s3/register_backend"; import "#kvstore/zip/register_backend"; import "#kvstore/ssa_s3/register_backend"; +import "#kvstore/opfs/register_backend"; diff --git a/src/kvstore/enabled_frontend_modules.ts b/src/kvstore/enabled_frontend_modules.ts index 3fdd002dd2..8af8e40cfb 100644 --- a/src/kvstore/enabled_frontend_modules.ts +++ b/src/kvstore/enabled_frontend_modules.ts @@ -13,4 +13,4 @@ import "#kvstore/s3/register_frontend"; import "#kvstore/ssa_s3/register_credentials_provider"; import "#kvstore/ssa_s3/register_frontend"; import "#kvstore/zip/register_frontend"; -import "#kvstore/indexeddb/register"; +import "#kvstore/opfs/register_frontend"; diff --git a/src/kvstore/indexeddb/implementation.ts b/src/kvstore/indexeddb/implementation.ts deleted file mode 100644 index 84b04d0ec5..0000000000 --- a/src/kvstore/indexeddb/implementation.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * @license - * Copyright 2025 - * 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 type { DriverReadOptions, KvStore, ListResponse, ReadResponse, StatOptions, StatResponse } from "#src/kvstore/index.js"; - -function promisifyRequest(req: IDBRequest, context: string): Promise { - return new Promise((resolve, reject) => { - req.onsuccess = () => resolve(req.result as T); - req.onerror = () => reject(new Error(`${context}: ${String(req.error?.message ?? req.error)}`, { cause: req.error ?? undefined })); - }); -} - -function awaitTransactionCompletion(tx: IDBTransaction, context: string): Promise { - return new Promise((resolve, reject) => { - tx.oncomplete = () => resolve(); - tx.onabort = () => reject(new Error(`${context}: transaction aborted`, { cause: tx.error ?? undefined })); - tx.onerror = () => reject(new Error(`${context}: transaction error`, { cause: tx.error ?? undefined })); - }); -} - -export class IndexedDBKvStore implements KvStore { - constructor(private readonly databaseName: string, private readonly storeName: string) {} - - private dbPromise: Promise | undefined; - - private getDb(): Promise { - if (this.dbPromise !== undefined) return this.dbPromise; - this.dbPromise = new Promise((resolve, reject) => { - const request = indexedDB.open(this.databaseName); - request.onupgradeneeded = () => { - const db = request.result; - if (!db.objectStoreNames.contains(this.storeName)) { - db.createObjectStore(this.storeName); - } - }; - request.onerror = () => reject(new Error(`Failed to open IndexedDB database ${this.databaseName}`, { cause: request.error ?? undefined })); - request.onsuccess = () => resolve(request.result); - }); - return this.dbPromise; - } - - async stat(key: string, _options: StatOptions): Promise { - const db = await this.getDb(); - const tx = db.transaction(this.storeName, "readonly"); - const store = tx.objectStore(this.storeName); - const getReq = store.get(key); - const value = await promisifyRequest(getReq, `stat: get(${key})`); - await awaitTransactionCompletion(tx, `stat(${key})`); - if (value === undefined) return undefined; - if (!(value instanceof ArrayBuffer)) { - throw new Error(`stat(${key}): expected ArrayBuffer, got ${Object.prototype.toString.call(value)}`); - } - return { totalSize: value.byteLength }; - } - - async read(key: string, _options: DriverReadOptions): Promise { - const db = await this.getDb(); - const tx = db.transaction(this.storeName, "readonly"); - const store = tx.objectStore(this.storeName); - const getReq = store.get(key); - const value = await promisifyRequest(getReq, `read: get(${key})`); - await awaitTransactionCompletion(tx, `read(${key})`); - if (value === undefined) return undefined; - const response = new Response(value); - return { response, offset: 0, length: value.byteLength, totalSize: value.byteLength }; - } - - async write(key: string, value: ArrayBuffer): Promise { - const db = await this.getDb(); - const tx = db.transaction(this.storeName, "readwrite"); - const store = tx.objectStore(this.storeName); - const req = store.put(value, key); - await promisifyRequest(req, `write: put(${key})`); - await awaitTransactionCompletion(tx, `write(${key})`); - } - - async delete(key: string): Promise { - const db = await this.getDb(); - const tx = db.transaction(this.storeName, "readwrite"); - const store = tx.objectStore(this.storeName); - const req = store.delete(key); - await promisifyRequest(req, `delete: delete(${key})`); - await awaitTransactionCompletion(tx, `delete(${key})`); - } - - async list(prefix: string): Promise { - const db = await this.getDb(); - const tx = db.transaction(this.storeName, "readonly"); - const store = tx.objectStore(this.storeName); - - const upperBound = `${prefix}\uffff`; - const range = IDBKeyRange.bound(prefix, upperBound); - - const directories = new Set(); - const entries: Array<{ key: string }> = []; - - await new Promise((resolve, reject) => { - // IDB spec: openKeyCursor may not exist in older impls; fallback to openCursor reading keys only. - const cursorRequest = (store as any).openKeyCursor - ? (store as any).openKeyCursor(range) - : store.openCursor(range); - cursorRequest.onerror = () => reject(new Error(`list: cursor error for prefix ${prefix}`, { cause: cursorRequest.error ?? undefined })); - cursorRequest.onsuccess = () => { - const cursor: IDBCursor | null = cursorRequest.result as IDBCursor | null; - if (cursor === null) { - resolve(); - return; - } - const key = String(cursor.key); - if (!key.startsWith(prefix)) { - cursor.continue(); - return; - } - const remainder = key.substring(prefix.length); - const slashIndex = remainder.indexOf("/"); - if (slashIndex === -1) { - entries.push({ key }); - } else { - const dirName = prefix + remainder.substring(0, slashIndex); - directories.add(dirName); - } - cursor.continue(); - }; - }); - - await awaitTransactionCompletion(tx, `list(${prefix})`); - - const sortedEntries = entries.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); - const sortedDirectories = Array.from(directories).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); - - return { entries: sortedEntries, directories: sortedDirectories }; - } - - getUrl(key: string): string { - return `local://${encodeURIComponent(this.databaseName)}/${encodeURIComponent(this.storeName)}/${encodeURIComponent(key)}`; - } - - get supportsOffsetReads(): boolean { return false; } - get supportsSuffixReads(): boolean { return false; } -} diff --git a/src/kvstore/indexeddb/register.ts b/src/kvstore/indexeddb/register.ts deleted file mode 100644 index 0c656986d3..0000000000 --- a/src/kvstore/indexeddb/register.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * @license - * Copyright 2025 - * 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 type { BaseKvStoreProvider } from "#src/kvstore/context.js"; -import { frontendOnlyKvStoreProviderRegistry } from "#src/kvstore/frontend.js"; -import type { KvStoreWithPath } from "#src/kvstore/index.js"; -import { IndexedDBKvStore } from "#src/kvstore/indexeddb/implementation.js"; -import type { UrlWithParsedScheme } from "#src/kvstore/url.js"; - -function getProvider(): BaseKvStoreProvider { - return { - scheme: "local", - description: "Stockage local dans le navigateur (IndexedDB)", - getKvStore(parsedUrl: UrlWithParsedScheme): KvStoreWithPath { - const suffix = parsedUrl.suffix; - if (suffix === undefined) { - throw new Error("local:// URL must include database and store names, e.g., local://db/store"); - } - // Expect suffix to start with //db/store[/path] - const m = suffix.match(/^\/\/([^\/]*)\/([^\/]*)(?:\/(.*))?$/); - if (m === null) { - throw new Error(`Invalid local URL suffix ${JSON.stringify(suffix)}; expected local:///[/path]`); - } - const databaseName = decodeURIComponent(m[1]); - const storeName = decodeURIComponent(m[2]); - const path = m[3] !== undefined ? decodeURIComponent(m[3]) : ""; - return { store: new IndexedDBKvStore(databaseName, storeName), path }; - }, - }; -} - -frontendOnlyKvStoreProviderRegistry.registerBaseKvStoreProvider(() => getProvider()); diff --git a/src/kvstore/opfs/backend.ts b/src/kvstore/opfs/backend.ts new file mode 100644 index 0000000000..14ba875950 --- /dev/null +++ b/src/kvstore/opfs/backend.ts @@ -0,0 +1,189 @@ +/** + * @license + * Copyright 2025 + * 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 type { + DriverListOptions, + DriverReadOptions, + KvStore, + ListResponse, + ReadResponse, + StatOptions, + StatResponse, +} from "#src/kvstore/index.js"; +import type { SharedKvStoreContextCounterpart } from "#src/kvstore/backend.js"; +import { encodePathForUrl, kvstoreEnsureDirectoryPipelineUrl } from "#src/kvstore/url.js"; + +function ensureOpfsAvailable(context: string): void { + if (typeof navigator === "undefined" || (navigator as any).storage === undefined) { + throw new Error(`${context}: OPFS (navigator.storage) is not available in this environment`); + } +} + +async function getRootDirectoryHandle(): Promise { + ensureOpfsAvailable("opfs"); + return await (navigator as any).storage.getDirectory(); +} + +function splitPath(path: string): string[] { + const normalized = path.replace(/\\+/g, "/").replace(/^\/+|\/+$/g, ""); + return normalized === "" ? [] : normalized.split("/"); +} + +async function getDirectoryHandleForPath( + baseDir: FileSystemDirectoryHandle, + pathSegments: string[], + create: boolean, +): Promise { + let current: FileSystemDirectoryHandle = baseDir; + for (const segment of pathSegments) { + if (segment === "") continue; + current = await current.getDirectoryHandle(segment, { create }); + } + return current; +} + +async function getFileHandleForPath( + baseDir: FileSystemDirectoryHandle, + pathSegments: string[], + create: boolean, +): Promise { + if (pathSegments.length === 0) { + throw new Error("getFileHandleForPath: empty path provided"); + } + const dirSegments = pathSegments.slice(0, -1); + const fileName = pathSegments[pathSegments.length - 1]; + const parent = await getDirectoryHandleForPath(baseDir, dirSegments, create); + return await parent.getFileHandle(fileName, { create }); +} + +export class OpfsKvStore implements KvStore { + private readonly basePathSegments: string[]; + private rootDirectoryPromise: Promise | undefined; + + constructor(public sharedKvStoreContext: SharedKvStoreContextCounterpart, basePath: string) { + this.basePathSegments = splitPath(basePath); + } + + private getRoot(): Promise { + if (this.rootDirectoryPromise !== undefined) return this.rootDirectoryPromise; + this.rootDirectoryPromise = getRootDirectoryHandle(); + return this.rootDirectoryPromise; + } + + private async getBaseDirectory(): Promise { + const root = await this.getRoot(); + return await getDirectoryHandleForPath(root, this.basePathSegments, /*create=*/ true); + } + + async stat(key: string, _options: StatOptions): Promise { + const base = await this.getBaseDirectory(); + const pathSegments = splitPath(key); + try { + const fileHandle = await getFileHandleForPath(base, pathSegments, /*create=*/ false); + const file = await fileHandle.getFile(); + return { totalSize: file.size }; + } catch (e) { + if (e instanceof DOMException && (e.name === "NotFoundError" || e.name === "NotAllowedError")) { + return undefined; + } + throw new Error(`stat(${key}) failed for ${this.getUrl(key)}: ${String((e as Error).message ?? e)}`); + } + } + + async read(key: string, _options: DriverReadOptions): Promise { + const base = await this.getBaseDirectory(); + const pathSegments = splitPath(key); + try { + const fileHandle = await getFileHandleForPath(base, pathSegments, /*create=*/ false); + const file = await fileHandle.getFile(); + const buffer = await file.arrayBuffer(); + const response = new Response(buffer); + return { response, offset: 0, length: buffer.byteLength, totalSize: buffer.byteLength }; + } catch (e) { + if (e instanceof DOMException && (e.name === "NotFoundError" || e.name === "NotAllowedError")) { + return undefined; + } + throw new Error(`read(${key}) failed for ${this.getUrl(key)}: ${String((e as Error).message ?? e)}`); + } + } + + async write(key: string, value: ArrayBuffer): Promise { + const base = await this.getBaseDirectory(); + const pathSegments = splitPath(key); + const fh = await getFileHandleForPath(base, pathSegments, /*create=*/ true); + const writable = await (fh as any).createWritable({ keepExistingData: false }); + try { + await writable.write(new Uint8Array(value)); + } finally { + await writable.close(); + } + } + + async delete(key: string): Promise { + const base = await this.getBaseDirectory(); + const parts = splitPath(key); + if (parts.length === 0) throw new Error("delete: empty key"); + const parent = await getDirectoryHandleForPath(base, parts.slice(0, -1), /*create=*/ false); + await (parent as any).removeEntry(parts[parts.length - 1], { recursive: false }); + } + + async list(prefix: string, _options: DriverListOptions): Promise { + const base = await this.getBaseDirectory(); + const prefixSegments = splitPath(prefix); + + const dirForPrefix = await (async () => { + try { + return await getDirectoryHandleForPath(base, prefixSegments, /*create=*/ false); + } catch (e) { + if (e instanceof DOMException && e.name === "NotFoundError") { + return undefined; + } + throw e; + } + })(); + + if (dirForPrefix === undefined) { + return { entries: [], directories: [] }; + } + + const entries: Array<{ key: string }> = []; + const directories = new Set(); + + for await (const [name, handle] of (dirForPrefix as any).entries() as AsyncIterable<[string, FileSystemHandle]>) { + const fullKey = (prefix === "" ? name : `${prefix}${prefix.endsWith("/") ? "" : "/"}${name}`); + if ((handle as FileSystemDirectoryHandle).kind === "directory") { + directories.add(fullKey); + } else { + entries.push({ key: fullKey }); + } + } + + const sortedEntries = entries.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); + const sortedDirectories = Array.from(directories).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + + return { entries: sortedEntries, directories: sortedDirectories }; + } + + getUrl(key: string): string { + const base = this.basePathSegments.join("/"); + const baseUrl = base === "" ? "opfs://" : `opfs://${encodePathForUrl(base)}/`; + const ensured = kvstoreEnsureDirectoryPipelineUrl(baseUrl); + return ensured + (key === "" ? "" : encodePathForUrl(key)); + } + + get supportsOffsetReads(): boolean { return false; } + get supportsSuffixReads(): boolean { return false; } +} diff --git a/src/kvstore/opfs/common.ts b/src/kvstore/opfs/common.ts new file mode 100644 index 0000000000..ba385dca77 --- /dev/null +++ b/src/kvstore/opfs/common.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2025 + * 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 type { BaseKvStoreProvider } from "#src/kvstore/context.js"; +import type { KvStore } from "#src/kvstore/index.js"; +import type { + KvStoreProviderRegistry, + SharedKvStoreContextBase, +} from "#src/kvstore/register.js"; +import type { UrlWithParsedScheme } from "#src/kvstore/url.js"; + +function parseOpfsUrlSuffix(suffix: string | undefined): { basePath: string; path: string } { + // Accept opfs://, opfs:/, or opfs: + const s = suffix ?? ""; + const m = s.match(/^\/?\/?(.*)$/); + if (m === null) { + throw new Error(`Invalid opfs URL suffix ${JSON.stringify(s)}; expected opfs://`); + } + const decoded = decodeURIComponent(m[1] ?? ""); + // Choose to have basePath be empty and return full path as initial kv path. + return { basePath: "", path: decoded }; +} + +export function registerProviders( + registry: KvStoreProviderRegistry, + OpfsKvStoreClass: { new (sharedKvStoreContext: SharedKvStoreContext, basePath: string): KvStore }, +) { + const provider: (context: SharedKvStoreContext) => BaseKvStoreProvider = ( + sharedKvStoreContext: SharedKvStoreContext, + ) => ({ + scheme: "opfs", + description: "Origin Private File System (browser)", + getKvStore(url: UrlWithParsedScheme) { + const { basePath, path } = parseOpfsUrlSuffix(url.suffix); + return { store: new OpfsKvStoreClass(sharedKvStoreContext, basePath), path }; + }, + }); + registry.registerBaseKvStoreProvider(provider); +} diff --git a/src/kvstore/opfs/frontend.ts b/src/kvstore/opfs/frontend.ts new file mode 100644 index 0000000000..dc41c151b5 --- /dev/null +++ b/src/kvstore/opfs/frontend.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2025 + * 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 type { SharedKvStoreContext } from "#src/kvstore/frontend.js"; +import { ProxyKvStore } from "#src/kvstore/proxy.js"; +import { encodePathForUrl, kvstoreEnsureDirectoryPipelineUrl } from "#src/kvstore/url.js"; + +export class OpfsKvStore extends ProxyKvStore { + constructor(public override sharedKvStoreContext: SharedKvStoreContext, private readonly basePath: string) { + super(sharedKvStoreContext); + } + + getUrl(key: string): string { + const base = this.basePath === "" ? "opfs://" : `opfs://${encodePathForUrl(this.basePath)}/`; + const ensured = kvstoreEnsureDirectoryPipelineUrl(base); + return ensured + (key === "" ? "" : encodePathForUrl(key)); + } + + get supportsOffsetReads(): boolean { return false; } + get supportsSuffixReads(): boolean { return false; } +} diff --git a/src/kvstore/opfs/register_backend.ts b/src/kvstore/opfs/register_backend.ts new file mode 100644 index 0000000000..b3ea60904e --- /dev/null +++ b/src/kvstore/opfs/register_backend.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2025 + * 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 { backendOnlyKvStoreProviderRegistry } from "#src/kvstore/backend.js"; +import { OpfsKvStore } from "#src/kvstore/opfs/backend.js"; +import { registerProviders } from "#src/kvstore/opfs/common.js"; + +registerProviders(backendOnlyKvStoreProviderRegistry, OpfsKvStore); diff --git a/src/kvstore/opfs/register_frontend.ts b/src/kvstore/opfs/register_frontend.ts new file mode 100644 index 0000000000..c4ef3ee3bb --- /dev/null +++ b/src/kvstore/opfs/register_frontend.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2025 + * 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 { frontendOnlyKvStoreProviderRegistry } from "#src/kvstore/frontend.js"; +import { registerProviders } from "#src/kvstore/opfs/common.js"; +import { OpfsKvStore } from "#src/kvstore/opfs/frontend.js"; + +registerProviders(frontendOnlyKvStoreProviderRegistry, OpfsKvStore); From 5c35cddf1919a48eeb62f3bd15a8da121b8dbb69 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 060/251] feat: add OPFS-backed KVStore module registration - Extend package configuration to include OPFS-based backend and frontend KVStore modules. - Register `opfs://` storage scheme into KVStore module imports. --- package.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/package.json b/package.json index 6e1c50630c..eec4b197a8 100644 --- a/package.json +++ b/package.json @@ -485,6 +485,18 @@ "neuroglancer/kvstore/s3:disabled": "./src/util/false.ts", "default": "./src/kvstore/s3/register_backend.ts" }, + "#kvstore/opfs/register_frontend": { + "neuroglancer/kvstore/opfs:enabled": "./src/kvstore/opfs/register_frontend.ts", + "neuroglancer/kvstore:none_by_default": "./src/util/false.ts", + "neuroglancer/kvstore/opfs:disabled": "./src/util/false.ts", + "default": "./src/kvstore/opfs/register_frontend.ts" + }, + "#kvstore/opfs/register_backend": { + "neuroglancer/kvstore/opfs:enabled": "./src/kvstore/opfs/register_backend.ts", + "neuroglancer/kvstore:none_by_default": "./src/util/false.ts", + "neuroglancer/kvstore/opfs:disabled": "./src/util/false.ts", + "default": "./src/kvstore/opfs/register_backend.ts" + }, "#kvstore/ssa_s3/register_credentials_provider": { "neuroglancer/python": "./src/util/false.ts", "neuroglancer/kvstore/ssa_s3:enabled": "./src/kvstore/ssa_s3/register_credentials_provider.ts", From 78056f737e939d2c24a68c3ec998b56a5a12d140 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 061/251] chore: remove unused voxel annotation modules and associated logic - Delete backend, frontend, and export modules related to voxel annotation. - Remove IndexedDB references and associated functions in voxel logic. - Clean up imports and registration handlers for deleted voxel annotation features. --- NOTES/vox-layer-v2.md | 47 ++ src/chunk_manager/frontend.ts | 14 + src/chunk_worker.bundle.js | 1 - src/datasource/default_provider.ts | 4 - src/datasource/local.ts | 53 +-- src/datasource/vox_remote.ts | 85 ---- src/layer/vox/index.ts | 182 +++----- src/layer/vox/tabs/settings.ts | 451 -------------------- src/sliceview/volume/frontend.ts | 91 +++- src/ui/voxel_annotations.ts | 27 +- src/voxel_annotation/backend.ts | 127 ------ src/voxel_annotation/edit_backend.ts | 68 +-- src/voxel_annotation/edit_controller.ts | 301 ++++++++++--- src/voxel_annotation/export_to_zarr.ts | 370 ---------------- src/voxel_annotation/frontend.ts | 444 ------------------- src/voxel_annotation/import_from_zarr.ts | 132 ------ src/voxel_annotation/index.ts | 150 ------- src/voxel_annotation/local_source.ts | 348 --------------- src/voxel_annotation/map.ts | 185 -------- src/voxel_annotation/volume_chunk_source.ts | 109 ----- 20 files changed, 467 insertions(+), 2722 deletions(-) create mode 100644 NOTES/vox-layer-v2.md delete mode 100644 src/datasource/vox_remote.ts delete mode 100644 src/layer/vox/tabs/settings.ts delete mode 100644 src/voxel_annotation/backend.ts delete mode 100644 src/voxel_annotation/export_to_zarr.ts delete mode 100644 src/voxel_annotation/frontend.ts delete mode 100644 src/voxel_annotation/import_from_zarr.ts delete mode 100644 src/voxel_annotation/index.ts delete mode 100644 src/voxel_annotation/local_source.ts delete mode 100644 src/voxel_annotation/map.ts delete mode 100644 src/voxel_annotation/volume_chunk_source.ts diff --git a/NOTES/vox-layer-v2.md b/NOTES/vox-layer-v2.md new file mode 100644 index 0000000000..1e5ac87d06 --- /dev/null +++ b/NOTES/vox-layer-v2.md @@ -0,0 +1,47 @@ +L'implementation actuelle du vox layer ne suis pas le principe fondamental de neuroglancer visant a separer les layer des data source. Cela ce manifest dans le fait que le vox layer ne support seulement le format zarr v2 sans compression grace a deux fichiers temporaires que sont [import_from_zarr.ts](../src/voxel_annotation/import_from_zarr.ts) et [export_to_zarr.ts](../src/voxel_annotation/export_to_zarr.ts). Ces fichiers ont ete cree pour un besoin de proofs of concept (POC) mais il va de soi qu'une implementation propre du vox layer ne sera possible que lorsque les differents datasource seront supporte. Mais supporter ces datasource n'est pas trivial, cela require de faire des changements dans le code de neuroglancer qui ne support que les operation de lecture. Alors Kvstore, datasource et autre auront besoin d'etre augmenter de capacite d'ecriture. +En sommes, nous arrivons a un point de bascule qui require un potentiel fort refactor de l'implementation actuelle du vox layer, mais cette tache est complexe a cadrer et c'est pourquoi notre premier travail sera de realiser un etat des lieu de l'implementation et d'en reecrire par la suite la nouvelle architecture. + + +Jalon no1: +Abstraction des datasources, writtable kvstore&datasource: +- L'utilisation de l'indexedDB (local datasource) ne doit plus etre systematique, celle-ci doit etre encapsulee dans un datasource a l'instar de zarr ou precomputed. +- Deux nouvelles classes + +## What do we have right now + +### Custom map system / data source + +To facilitate the creation of a proof of concept, I bypassed the data handling pipeline of neuroglancer, I used a dummy `local://voxel_annotation` datasource and created a secondary simple data management system that includes a ui for map (the term I used to refer to a specific dataset) creation, selection and importation/exportation from/to zarr v2 uncompressed dataset on s3 buckets. The data is also saved in a local IndexedDB (note: the use of OPFS would have been more appropriate) with a custom format for persistence. +The related code include: +- [map.ts](../src/voxel_annotation/map.ts) map utils including a VoxMapConfig object to transmit the selected map config +- [index.ts](../src/voxel_annotation/index.ts) and [local_source.ts](../src/voxel_annotation/local_source.ts) the data source and its indexedDB management, it exposed a `VoxSource` which is read-only and destined to be used by the chunk source and a `VoxSourceWriter` which is writable and destined to be used by the unique edit controller +- [settings.ts](../src/layer/vox/tabs/settings.ts) the ui for map creation and selection +- [import_from_zarr.ts](../src/voxel_annotation/import_from_zarr.ts) and [export_to_zarr.ts](../src/voxel_annotation/export_to_zarr.ts) the zarr v2 import/export tools + +### Rendering + +A simple `VoxelAnnotationRenderLayer` ([renderlayer.ts](../src/voxel_annotation/renderlayer.ts)) provides a shader who proceduraly assigns a color to an uint64 label value (similarely to the segmentation layer) and renders those colors at 50% opacity. If the label is 0, nothing is displayed. + +### Chunking + +A `VoxMultiscaleVolumeChunkSource` ([volume_chunk_source.ts](../src/voxel_annotation/volume_chunk_source.ts)) handles the multi-resolution chunking for the voxel data. It generates a hierarchy of resolutions (levels of detail) based on the `steps` defined in the `VoxMapConfig`. For each resolution level, it creates a `VoxChunkSource` ([frontend.ts](../src/voxel_annotation/frontend.ts)) which is responsible for fetching individual data chunks. The backend counterpart, [backend.ts](../src/voxel_annotation/backend.ts), retrieves chunk data from the local IndexedDB source or the remote Zarr import source if available, otherwise returning an empty, zero-filled chunk. + +### Editing + +Only the max resolution voxels can be painted, when painting, the chunk are scheduled to be downsampled. The edited chunk are directly updated in the frontend and then persisted to the data source. We can paint using a brush (disk shape or sphere) or a flood fill tool (flooding only on 2d slices). + +The choice was made to first make the annotation work at this max resolution detail and later look at how to make upscaling work (two approach are possible, one direct like the downscaling or one delegated to when we actually need the chunk, the first one is simple but will not work for approx 3/4 levels max, the second could be less limited but comport confict handling issue which would require us to design a more complex system) + +Editing functionality is managed through a `VoxelEditController` which acts as a bridge between the user interface and the backend data storage. The frontend controller ([edit_controller.ts](../src/voxel_annotation/edit_controller.ts)) receives edit commands, such as painting with a brush, from UI tools defined in [voxel_annotations.ts](../src/ui/voxel_annotations.ts). + +These edits are then batched and sent via RPC to the `VoxelEditController` backend ([edit_backend.ts](../src/voxel_annotation/edit_backend.ts)). The backend owns the authoritative `VoxSourceWriter` for a given map and applies these edits. To persist the changes, the edited chunks are written to the local IndexedDB. The backend also queue Downsampling jobs for each edited chunk, when downsampled, a chunk is then triggered to be realoaded (note: this reloading feature still needs to be correctly implemented, for now I invalidate a whole VoxChunkSource to force a reload of the chunk). + +The live preview of paintings and chunk reloading are handled in the `VoxChunkSource`, as well as some calculations for the painting tool (e.g. the flood fill algorithm for example, which requires reading voxels around the target). + +## What we want to achieve + +Obviously, we want to replace the current custom data handling with the one of neuroglancer. But neuroglancer has been design as a read-only system. Without delving too much into the technical details, we first need to choose how we want to consider our writting path: +- we could require to use datasource that are read-write, then if we want to modify a segmentation the user would need to copy the wanted area to a writtable source. +- we could add a way to specify a secondary writtable datasource aside of the read-only one. Then the writtable source would contain an overlay of edits to apply over the original data. + +This new approach would replace the whole `custom map system / data source` detailed above. diff --git a/src/chunk_manager/frontend.ts b/src/chunk_manager/frontend.ts index 3df76ce075..bf025c47c7 100644 --- a/src/chunk_manager/frontend.ts +++ b/src/chunk_manager/frontend.ts @@ -463,6 +463,20 @@ export class ChunkSource extends SharedObject { this.chunks.delete(key); } + invalidateChunks(keys: string[]): void { + let changed = false; + for (const key of keys) { + const chunk = this.chunks.get(key); + if (chunk) { + this.deleteChunk(key); + changed = true; + } + } + if (changed) { + this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); + } + } + addChunk(key: string, chunk: Chunk) { this.chunks.set(key, chunk); } diff --git a/src/chunk_worker.bundle.js b/src/chunk_worker.bundle.js index 1ce9c822d1..a463171535 100644 --- a/src/chunk_worker.bundle.js +++ b/src/chunk_worker.bundle.js @@ -12,4 +12,3 @@ import "#src/annotation/backend.js"; import "#src/datasource/enabled_backend_modules.js"; import "#src/kvstore/enabled_backend_modules.js"; import "#src/worker_rpc_context.js"; -import "#src/voxel_annotation/backend.js"; diff --git a/src/datasource/default_provider.ts b/src/datasource/default_provider.ts index 816fe35754..85bf8b0aa7 100644 --- a/src/datasource/default_provider.ts +++ b/src/datasource/default_provider.ts @@ -25,7 +25,6 @@ import type { } from "#src/datasource/index.js"; import { DataSourceRegistry } from "#src/datasource/index.js"; import { LocalDataSourceProvider } from "#src/datasource/local.js"; -import { VoxRemoteDataSourceProvider } from "#src/datasource/vox_remote.js"; import { AutoDetectRegistry } from "#src/kvstore/auto_detect.js"; import type { SharedKvStoreContext } from "#src/kvstore/frontend.js"; @@ -51,9 +50,6 @@ export function registerKvStoreBasedDataProvider( export function getDefaultDataSourceProvider(options: ProviderOptions) { const registry = new DataSourceRegistry(options.kvStoreContext); registry.register(new LocalDataSourceProvider()); - // Register vox remote providers (HTTP/HTTPS) - registry.register(new VoxRemoteDataSourceProvider("vox+http")); - registry.register(new VoxRemoteDataSourceProvider("vox+https")); for (const provider of providers) { registry.register(provider); } diff --git a/src/datasource/local.ts b/src/datasource/local.ts index 47474e866f..94bb8e4291 100644 --- a/src/datasource/local.ts +++ b/src/datasource/local.ts @@ -106,53 +106,6 @@ export class LocalDataSourceProvider implements DataSourceProvider { ], }; } - case localVoxelAnnotationsUrl: { - // Voxels data source: by default, provide a fixed 3D identity model transform. - // Rationale: Many voxel-based layers (like our demo vox layer) expect a concrete 3D - // model space. Mirroring the global space rank/names can lead to ambiguous or rank-0 - // cases depending on viewer state. Keeping a stable 3D identity model transform here - // reduces surprises while still allowing an explicit transform override via options. - const { transform } = options; - let modelTransform: CoordinateSpaceTransform; - if (transform === undefined) { - const inputSpace = makeCoordinateSpace({ - rank: 3, - scales: new Float64Array([1, 1, 1]), - units: ["", "", ""], - names: ["x", "y", "z"], - }); - const outputSpace = makeCoordinateSpace({ - rank: 3, - scales: new Float64Array([1, 1, 1]), - units: ["", "", ""], - names: ["x", "y", "z"], - }); - modelTransform = { - rank: 3, - sourceRank: 3, - inputSpace, - outputSpace, - transform: createIdentity(Float64Array, 4), - }; - } else { - // If an explicit transform is provided, just pass through an identity over empty space, - // consistent with other local sources. - modelTransform = makeIdentityTransform(emptyValidCoordinateSpace); - } - return { - modelTransform, - canChangeModelSpaceRank: true, - subsources: [ - { - id: "default", - default: true, - subsource: { - local: LocalDataSource.voxelAnnotations, - }, - }, - ], - }; - } } throw new Error("Invalid local data source URL"); } @@ -171,11 +124,7 @@ export class LocalDataSourceProvider implements DataSourceProvider { value: "equivalences", description: "Segmentation equivalence graph stored in the JSON state", - }, - { - value: "voxel-annotations", - description: "Voxel annotations stored in the JSON state", - }, + } ], (x) => x.value, (x) => x.description, diff --git a/src/datasource/vox_remote.ts b/src/datasource/vox_remote.ts deleted file mode 100644 index 9fbe4f5384..0000000000 --- a/src/datasource/vox_remote.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * @license - * Copyright 2025. - */ - -import { - emptyValidCoordinateSpace, - makeIdentityTransform, -} from "#src/coordinate_transform.js"; -import type { - CompleteUrlOptions, - DataSource, - DataSourceProvider, - GetDataSourceOptions, -} from "#src/datasource/index.js"; -import { getPrefixMatchesWithDescriptions } from "#src/util/completion.js"; - -/** - * Provider for vox+http(s):// URLs used by the Vox layer to connect to a remote voxel server. - * - * Accepted forms: - * vox+http://host(:port)/(?token=TOKEN) - * vox+https://host(:port)/(?token=TOKEN) - * - * The DataSource returned is a minimal stub whose presence allows the Vox layer to detect - * selection of a remote source. The actual data flow is handled by the Vox layer and - * voxel_annotation chunk sources, which read the URL directly from the layer spec and pass - * serverUrl/token to the worker. - */ -export class VoxRemoteDataSourceProvider implements DataSourceProvider { - constructor(private readonly schemeName: "vox+http" | "vox+https") {} - - get scheme() { - return this.schemeName; - } - - get description() { - return this.schemeName === "vox+http" - ? "Vox remote server over HTTP" - : "Vox remote server over HTTPS"; - } - - async get(options: GetDataSourceOptions): Promise { - // Minimal identity transform; Vox layer supplies its own render transform. - const modelTransform = makeIdentityTransform(emptyValidCoordinateSpace); - return { - modelTransform, - canChangeModelSpaceRank: false, - subsources: [ - { - id: "default", - default: true, - // Leave `subsource` as an empty object to indicate a non-local provider. - // The Vox layer will further validate the URL scheme. - subsource: {}, - }, - ], - // Preserve the canonical URL for later inspection by the layer. - canonicalUrl: `${this.schemeName}://${options.providerUrl}`, - }; - } - - async completeUrl(options: CompleteUrlOptions) { - // Offer simple skeletons for host and optional token. - // Completion UI will prefix with the full scheme automatically. - const items = [ - { - value: "", - description: "Enter host[:port]/ optionally followed by ?token=...", - }, - { value: "localhost:8080/", description: "Local development server" }, - { value: "example.com/", description: "Production server" }, - { value: "example.com/?token=", description: "With token parameter" }, - ]; - return { - offset: 0, - completions: getPrefixMatchesWithDescriptions( - options.providerUrl, - items, - (x) => x.value, - (x) => x.description, - ), - }; - } -} diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index c74c3771e1..3f00ec66d5 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -18,11 +18,6 @@ import "#src/layer/vox/style.css"; import { vec3 } from "gl-matrix"; import type { CoordinateTransformSpecification } from "#src/coordinate_transform.js"; -import { - makeCoordinateSpace, - makeIdentityTransform, - WatchableCoordinateSpaceTransform, -} from "#src/coordinate_transform.js"; import type { DataSourceSpecification } from "#src/datasource/index.js"; import { LocalDataSource, @@ -36,12 +31,15 @@ import { UserLayer, } from "#src/layer/index.js"; import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; -import { VoxSettingsTab } from "#src/layer/vox/tabs/settings.js"; import { VoxToolTab } from "#src/layer/vox/tabs/tools.js"; -import { getWatchableRenderLayerTransform } from "#src/render_coordinate_transform.js"; import { trackableRenderScaleTarget, } from "#src/render_scale_statistics.js"; +import { DataType } from "#src/sliceview/base.js"; +import { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; +import { + constantWatchableValue, +} from "#src/trackable_value.js"; import { registerVoxelAnnotationTools, } from "#src/ui/voxel_annotations.js"; @@ -49,9 +47,7 @@ import type { Borrowed } from "#src/util/disposable.js"; import { mat4 } from "#src/util/geom.js"; import { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; import { LabelsManager } from "#src/voxel_annotation/labels.js"; -import { VoxMapRegistry } from "#src/voxel_annotation/map.js"; import { VoxelAnnotationRenderLayer } from "#src/voxel_annotation/renderlayer.js"; -import { VoxMultiscaleVolumeChunkSource } from "#src/voxel_annotation/volume_chunk_source.js"; export class VoxUserLayer extends UserLayer { // While drawing, we keep a reference to the vox render layer to control temporary LOD locks. @@ -62,13 +58,12 @@ export class VoxUserLayer extends UserLayer { static typeAbbreviation = "vox"; voxEditController?: VoxelEditController; voxLabelsManager = new LabelsManager(); - voxMapRegistry = new VoxMapRegistry(); + private modelToVoxTransform: mat4 | null; // Draw tool state voxBrushRadius: number = 3; voxEraseMode: boolean = false; voxBrushShape: "disk" | "sphere" = "disk"; - private voxLoadedSubsource?: LoadedDataSubsource; // Draw tab error messaging voxDrawErrorMessage: string | undefined = undefined; @@ -114,11 +109,6 @@ export class VoxUserLayer extends UserLayer { constructor(managedLayer: Borrowed) { super(managedLayer); - this.tabs.add("vox_settings", { - label: "Map", - order: 0, - getter: () => new VoxSettingsTab(this), - }); this.tabs.add("vox_tools", { label: "Draw", order: 1, @@ -127,45 +117,8 @@ export class VoxUserLayer extends UserLayer { this.tabs.default = "vox"; } - private createIdentity3D() { - const map = this.voxMapRegistry.getCurrent(); - if (!map || !map.scaleMeters || !map.unit) { - console.log("debug: ", map) - throw new Error("createIdentity3D: no map selected or missing properties"); - } - - const units = [ - map.unit, - map.unit, - map.unit, - ] as string[]; - - return new WatchableCoordinateSpaceTransform( - makeIdentityTransform( - makeCoordinateSpace({ - rank: 3, - names: ["x", "y", "z"], - units, - scales: new Float64Array(map.scaleMeters as number[]), - }), - ), - ); - } - - private getModelToVoxTransform(): mat4 | undefined { - const identity3D = this.createIdentity3D(); - const watchable = getWatchableRenderLayerTransform( - this.manager.root.coordinateSpace, - this.localPosition.coordinateSpace, - identity3D, - undefined, - ); - const tOrError = watchable.value as any; - if (tOrError?.error) return undefined; - return ( - mat4.invert(mat4.create(), tOrError.modelToRenderLayerTransform) || - mat4.identity(mat4.create()) - ); + private getModelToVoxTransform(): mat4 | null { + return this.modelToVoxTransform; } getVoxelPositionFromMouse( @@ -186,68 +139,6 @@ export class VoxUserLayer extends UserLayer { } } - private async loadLabels(): Promise { - const controller = this.voxEditController; - if (!controller) return; - await this.voxLabelsManager.initialize(controller); - } - - buildOrRebuildVoxLayer() { - const ls = this.voxLoadedSubsource; - if (!ls) return; - - // Require an explicit map selection/creation - const map = this.voxMapRegistry.getCurrent(); - if (!map) return; - - const guardScale = Array.from(map?.scaleMeters || [1, 1, 1]); - // Use map bounds for guard and source - const upper = new Float32Array(map.upperVoxelBound as number[]); - const guardBounds = Array.from(upper); - const guardUnit = map.unit; - - ls.activate( - () => { - const voxSource = new VoxMultiscaleVolumeChunkSource( - this.manager.chunkManager, - { - map: map, - }, - ); - // Expose a controller so tools can paint voxels via the source. - this.voxEditController = new VoxelEditController(voxSource); - this.voxEditController.initializeMap(map); - - const sources2D = voxSource.getSources({} as any); - for (const level of (sources2D[0] ?? [])) { - (level.chunkSource as any).initializeMap(map); - } - this.loadLabels(); - - // Build transform with current scale and units. - const identity3D = this.createIdentity3D(); - const transform = getWatchableRenderLayerTransform( - this.manager.root.coordinateSpace, - this.localPosition.coordinateSpace, - identity3D, - undefined, - ); - - const renderLayer = new VoxelAnnotationRenderLayer(voxSource, { - transform: transform as any, - renderScaleTarget: this.sliceViewRenderScaleTarget, - renderScaleHistogram: undefined, - localPosition: this.localPosition, - } as any); - this.voxRenderLayerInstance = renderLayer; - ls.addRenderLayer(renderLayer); - }, - guardScale, - guardBounds, - guardUnit, - ); - } - getLegacyDataSourceSpecifications( sourceSpec: string | undefined, layerSpec: any, @@ -276,19 +167,60 @@ export class VoxUserLayer extends UserLayer { activateDataSubsources(subsources: Iterable): void { for (const loadedSubsource of subsources) { - const { subsourceEntry } = loadedSubsource; - const { subsource } = subsourceEntry; - const isLocalVox = subsource.local === LocalDataSource.voxelAnnotations; - - if (isLocalVox) { - // Local in-memory vox datasource. - this.voxLoadedSubsource = loadedSubsource; + const { volume } = + loadedSubsource.subsourceEntry.subsource; + if (volume instanceof MultiscaleVolumeChunkSource) { + if (volume === undefined) { + loadedSubsource.deactivate("No volume source"); + continue; + } + switch (volume.dataType) { + case DataType.FLOAT32: + loadedSubsource.deactivate( + "Data type not compatible with segmentation layer", + ); + continue; + } + this.voxEditController = new VoxelEditController(volume); + loadedSubsource.activate( + () => { + const renderLayerTransform = loadedSubsource.getRenderLayerTransform(); + + const renderLayer = new VoxelAnnotationRenderLayer(volume, { + transform: loadedSubsource.getRenderLayerTransform(), + renderScaleTarget: this.sliceViewRenderScaleTarget, + localPosition: this.localPosition, + shaderParameters: constantWatchableValue({}) + }); + + this.voxRenderLayerInstance = renderLayer; + loadedSubsource.addRenderLayer(renderLayer); + + // 3. Calculate and store the inverse transform needed for mouse picking. + const updateTransform = () => { + const transformOrError = renderLayerTransform.value; + if (transformOrError.error !== undefined) { + this.modelToVoxTransform = null; + } else { + this.modelToVoxTransform = mat4.invert( + mat4.create(), + transformOrError.modelToRenderLayerTransform as mat4, + ); + } + }; + + updateTransform(); + loadedSubsource.activated!.registerDisposer( + renderLayerTransform.changed.add(updateTransform) + ); + } + ); continue; } // Reject anything else. loadedSubsource.deactivate( - "Not compatible with vox layer; supported sources: local://voxel-annotations", + "Not compatible with vox layer", ); } } diff --git a/src/layer/vox/tabs/settings.ts b/src/layer/vox/tabs/settings.ts deleted file mode 100644 index 395d28a780..0000000000 --- a/src/layer/vox/tabs/settings.ts +++ /dev/null @@ -1,451 +0,0 @@ -/** - * Vox Settings tab UI split from index.ts - */ -import type { VoxUserLayer } from "#src/layer/vox/index.js"; -import { DataType } from "#src/util/data_type.js"; -import { scaleByExp10, unitFromJson } from "#src/util/si_units.js"; -import { exportVoxToZarr, type ExportStatus } from "#src/voxel_annotation/export_to_zarr.js"; -import { LocalVoxSourceWriter } from "#src/voxel_annotation/local_source.js"; -import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; -import { computeSteps, constructVoxMapConfig } from "#src/voxel_annotation/map.js"; -import { Tab } from "#src/widget/tab_view.js"; - -export class VoxSettingsTab extends Tab { - constructor(public layer: VoxUserLayer) { - super(); - const { element } = this; - element.classList.add("neuroglancer-vox-settings-tab"); - - const row = (label: string, inputs: HTMLElement[]) => { - const div = document.createElement("div"); - div.className = "neuroglancer-vox-row"; - const lab = document.createElement("label"); - lab.textContent = label; - lab.style.display = "inline-block"; - lab.style.width = "140px"; - div.appendChild(lab); - for (const inp of inputs) { - inp.classList.add("neuroglancer-vox-input"); - // Do not force a tiny size; allow CSS/layout to determine width for readability. - div.appendChild(inp); - } - return div; - }; - - // Section containers - const createSection = document.createElement("div"); - createSection.className = "neuroglancer-vox-section"; - const createHeader = document.createElement("h3"); - createHeader.textContent = "Create Map"; - createSection.appendChild(createHeader); - element.appendChild(createSection); - - const importSection = document.createElement("div"); - importSection.className = "neuroglancer-vox-section"; - const importHeader = document.createElement("h3"); - importHeader.textContent = "Import Map"; - importSection.appendChild(importHeader); - element.appendChild(importSection); - - const selectSection = document.createElement("div"); - selectSection.className = "neuroglancer-vox-section"; - const selectHeader = document.createElement("h3"); - selectHeader.textContent = "Select Map"; - selectSection.appendChild(selectHeader); - element.appendChild(selectSection); - - // Import UI controls - const importUrlInput = document.createElement("input"); - importUrlInput.type = "text"; - importUrlInput.placeholder = "Data URL (e.g., precomputed://..., zarr://..., n5://...)"; - importUrlInput.size = 80; - - const importIdInput = document.createElement("input"); - importIdInput.type = "text"; - importIdInput.placeholder = "map id (leave empty to derive from URL)"; - - const importNameInput = document.createElement("input"); - importNameInput.type = "text"; - importNameInput.placeholder = "map name (optional)"; - - const importButton = document.createElement("button"); - importButton.textContent = "Validate & Import"; - - const importStatus = document.createElement("span"); - importStatus.classList.add("neuroglancer-vox-status"); - - importSection.appendChild(row("Source URL", [importUrlInput])); - importSection.appendChild(row("Map id/name", [importIdInput, importNameInput])); - importSection.appendChild(importStatus); - importSection.appendChild(importButton) - - importButton.addEventListener("click", async () => { - const url = importUrlInput.value.trim(); - if (!url || url.length === 0) { - importStatus.textContent = "Source URL is required"; - return; - } - importButton.disabled = true; - importStatus.textContent = "Validating..."; - try { - const registry = this.layer.manager.dataSourceProviderRegistry; - const ds = await registry.get({ - url, - transform: undefined, - globalCoordinateSpace: this.layer.manager.root.coordinateSpace, - } as any); - const volumeEntry = ds.subsources.find(s => (s as any)?.subsource?.volume); - if (!volumeEntry) throw new Error("No volume subsource found at URL"); - const volume = (volumeEntry as any).subsource.volume; - const rank: number = volume.modelSpace.rank; - if (!Number.isInteger(rank) || rank <= 0) { - throw new Error(`Invalid volume rank: ${String(rank)}`); - } - - const displayRank: number = Math.min(3, rank); - const multiscaleToViewTransform = new Float32Array(displayRank * rank); - for (let i = 0; i < Math.min(displayRank, rank); i++) { - // Column-major layout, linear (not affine) matrix with displayRank rows and rank columns - multiscaleToViewTransform[i + i * displayRank] = 1; - } - - const volumeSourceOptions = { - displayRank, - multiscaleToViewTransform, - modelChannelDimensionIndices: [], - }; - - const levels = volume.getSources(volumeSourceOptions); - const level0 = levels?.[0]?.[0]; - if (!level0) throw new Error("Volume has no available resolution levels"); - const spec = (level0 as any).chunkSource?.spec; - if (!spec) throw new Error("Cannot read volume specification"); - - const baseVoxelOffset = new Float32Array(Array.from(spec.baseVoxelOffset)); - const upperVoxelBound = new Float32Array(Array.from(spec.upperVoxelBound)); - const chunkDataSize = new Uint32Array(Array.from(spec.chunkDataSize)); - const bounds = [ - (upperVoxelBound[0] | 0) - (baseVoxelOffset[0] | 0), - (upperVoxelBound[1] | 0) - (baseVoxelOffset[1] | 0), - (upperVoxelBound[2] | 0) - (baseVoxelOffset[2] | 0), - ]; - const steps = computeSteps(bounds, chunkDataSize); - const dtype = Number(volume.dataType ?? DataType.UINT32); - - // Derive id and name - const derived = registry.suggestLayerName((ds as any).originalCanonicalUrl || (ds as any).canonicalUrl || url) || `map-${Date.now()}`; - const id = (importIdInput.value.trim().length > 0 ? importIdInput.value.trim() : derived); - const name = (importNameInput.value.trim().length > 0 ? importNameInput.value.trim() : id); - console.log("Importing map:", { id, name, baseVoxelOffset, upperVoxelBound, chunkDataSize, dtype, steps }); - // Derive physical voxel size (in meters) and base unit from modelSpace - const modelUnits: string[] = Array.from(volume.modelSpace?.units || []); - const modelScales: number[] = Array.from(volume.modelSpace?.scales || []); - if (modelUnits.length < 3 || modelScales.length < 3) { - throw new Error(`Model space lacks required units/scales for 3 spatial axes`); - } - // Convert each axis scale to meters using SI prefix info - const toMeters = (scale: number, unitStr: string): number => { - const u = unitFromJson(unitStr); - return scaleByExp10(scale, u.exponent); - }; - const sx_m = toMeters(modelScales[0], modelUnits[0]); - const sy_m = toMeters(modelScales[1], modelUnits[1]); - const sz_m = toMeters(modelScales[2], modelUnits[2]); - if (!(sx_m > 0 && sy_m > 0 && sz_m > 0)) { - throw new Error(`Invalid spatial scales; expected positive finite values`); - } - // Ensure base unit is consistent across spatial axes; use base unit returned by unitFromJson - const baseUnit0 = unitFromJson(modelUnits[0]).unit; - const baseUnit1 = unitFromJson(modelUnits[1]).unit; - const baseUnit2 = unitFromJson(modelUnits[2]).unit; - if (!(baseUnit0 === baseUnit1 && baseUnit0 === baseUnit2)) { - throw new Error(`Inconsistent units across spatial axes: [${modelUnits.slice(0,3).join(", ")}]`); - } - - const map: VoxMapConfig = constructVoxMapConfig({ - id, - name, - baseVoxelOffset, - upperVoxelBound, - chunkDataSize, - dataType: dtype, - scaleMeters: new Float64Array([sx_m, sy_m, sz_m]), - unit: baseUnit0 || "m", - steps, - importUrl: (ds as any).originalCanonicalUrl || (ds as any).canonicalUrl || url, - }); - this.layer.voxMapRegistry.upsert(map); - this.layer.voxMapRegistry.setCurrent(map); - this.layer.buildOrRebuildVoxLayer(); - refreshMaps(); - importStatus.textContent = `Imported: ${id}`; - } catch (e: any) { - console.error("Import error:", e); - importStatus.textContent = `Import failed: ${e?.message || String(e)}`; - } finally { - importButton.disabled = false; - } - }); - - const exportSection = document.createElement("div"); - exportSection.className = "neuroglancer-vox-section"; - const exportHeader = document.createElement("h3"); - exportHeader.textContent = "Export Map"; - exportSection.appendChild(exportHeader); - element.appendChild(exportSection); - - - const makeNumberInput = (value: number, step: string) => { - const inp = document.createElement("input"); - inp.type = "number"; - inp.step = step; - inp.value = String(value); - return inp; - }; - - // Unit helpers - const unitFactor: Record = { - m: 1, - mm: 1e-3, - µm: 1e-6, - nm: 1e-9, - }; - const currentUnit = "nm"; - const factor = (u: string) => unitFactor[u] ?? 1; - - // Prepare UI elements - const unitSel = document.createElement("select"); - for (const u of ["m", "mm", "µm", "nm"]) { - const opt = document.createElement("option"); - opt.value = u; - opt.textContent = u; - if (u === currentUnit) opt.selected = true; - unitSel.appendChild(opt); - } - let prevUnit = currentUnit; - - // Show scale values in the chosen unit for convenience - const sx = makeNumberInput(8, "any"); - const sy = makeNumberInput(8, "any"); - const sz = makeNumberInput(8, "any"); - - const ax = makeNumberInput(0, "1"); - const ay = makeNumberInput(0, "1"); - const az = makeNumberInput(0, "1"); - - const bx = makeNumberInput(100_000, "1"); - const by = makeNumberInput(100_000, "1"); - const bz = makeNumberInput(100_000, "1"); - - createSection.appendChild(row("Scale (x,y,z)", [sx, sy, sz])); - createSection.appendChild(row("Scale unit", [unitSel])); - createSection.appendChild(row("Corner A (x,y,z)", [ax, ay, az])); - createSection.appendChild(row("Corner B (x,y,z)", [bx, by, bz])); - - // When unit changes, rescale the displayed numbers to preserve physical value in meters - unitSel.addEventListener("change", () => { - const newU = unitSel.value; - const conv = factor(prevUnit) / factor(newU); - // Update the input values in-place - const x = Number.parseFloat(sx.value); - const y = Number.parseFloat(sy.value); - const z = Number.parseFloat(sz.value); - if (Number.isFinite(x)) sx.value = String(x * conv); - if (Number.isFinite(y)) sy.value = String(y * conv); - if (Number.isFinite(z)) sz.value = String(z * conv); - prevUnit = newU; - }); - - // Map metadata inputs - const mapIdInp = document.createElement("input"); - mapIdInp.type = "text"; - mapIdInp.placeholder = "map id"; - mapIdInp.value = ""; - const mapNameInp = document.createElement("input"); - mapNameInp.type = "text"; - mapNameInp.placeholder = "map name"; - mapNameInp.value = ""; - createSection.appendChild(row("Map id/name", [mapIdInp, mapNameInp])); - - // Existing maps list - const mapsSel = document.createElement("select"); - const refreshMaps = () => { - mapsSel.innerHTML = ""; - const maps = this.layer.voxMapRegistry.list(); - for (const m of maps) { - const opt = document.createElement("option"); - opt.style.color = m.id === this.layer.voxMapRegistry.getCurrent()?.id ? "blue" : "black"; - opt.value = m.id; - opt.textContent = `${m.name || m.id}`; - mapsSel.appendChild(opt); - } - }; - refreshMaps(); - selectSection.appendChild(row("Existing maps", [mapsSel])); - - // Load locally-stored maps from IndexedDB - (async () => { - try { - const src = new LocalVoxSourceWriter(); - const maps = await src.listMaps(); - for (const m of maps) this.layer.voxMapRegistry.upsert(m as any); - refreshMaps(); - } catch { - // ignore - } - })(); - - const createBtn = document.createElement("button"); - createBtn.textContent = "Create / Init Map"; - createBtn.title = "Create a map with the provided id, bounds, scale, and precomputed steps"; - createBtn.addEventListener("click", () => { - const u = unitSel.value || currentUnit; - const f = factor(u); - // Convert user-entered values back to meters - const sxNum = Number.parseFloat(sx.value); - const syNum = Number.parseFloat(sy.value); - const szNum = Number.parseFloat(sz.value); - const ns = new Float64Array([ - sxNum * f, - syNum * f, - szNum * f - ]); - const ca = new Float32Array([ - Math.floor(Number(ax.value)), - Math.floor(Number(ay.value)), - Math.floor(Number(az.value)), - ]); - const cb = new Float32Array([ - Math.floor(Number(bx.value)), - Math.floor(Number(by.value)), - Math.floor(Number(bz.value)), - ]); - - // Normalize bounds - const lower = new Float32Array(3); - const upper = new Float32Array(3); - for (let i = 0; i < 3; ++i) { - const lo = Math.floor(Math.min(ca[i], cb[i])); - const up = Math.ceil(Math.max(ca[i], cb[i])); - lower[i] = lo; - upper[i] = Math.max(up, lo + 1); - } - const bounds = [ - upper[0] - lower[0], - upper[1] - lower[1], - upper[2] - lower[2], - ]; - const chunkDataSize = [64, 64, 64]; - const steps = computeSteps(bounds, chunkDataSize); - - const id = mapIdInp.value || `map-${Date.now()}`; - const name = mapNameInp.value || id; - - const map: VoxMapConfig = constructVoxMapConfig({ - id, - name, - baseVoxelOffset: lower, - upperVoxelBound: upper, - chunkDataSize, - dataType: DataType.UINT32, - scaleMeters: ns, - unit: u, - steps, - }); - this.layer.voxMapRegistry.upsert(map); - this.layer.voxMapRegistry.setCurrent(map); - this.layer.buildOrRebuildVoxLayer(); - refreshMaps(); - }); - createSection.appendChild(createBtn); - - const selectBtn = document.createElement("button"); - selectBtn.textContent = "Select Map"; - selectBtn.addEventListener("click", () => { - const id = mapsSel.value; - const found = this.layer.voxMapRegistry.list().find((m: VoxMapConfig) => m.id === id); - if (found) { - console.log("Selected map:", found); - this.layer.voxMapRegistry.setCurrent(found); - this.layer.buildOrRebuildVoxLayer(); - } - }); - selectSection.appendChild(selectBtn); - - // --- Export to Zarr (LOD 1 only) --- - const exportUrlInput = document.createElement("input"); - exportUrlInput.type = "text"; - exportUrlInput.placeholder = "Export base URL"; - exportUrlInput.size = 80; - exportUrlInput.classList.add("neuroglancer-vox-input"); - exportUrlInput.style.marginLeft = "0"; - const exportButton = document.createElement("button"); - exportButton.textContent = "Export to Zarr"; - exportButton.title = "Exports current map LOD=1 chunks to a Zarr v2 dataset at path '0' under the provided base URL"; - - const exportStatusSpan = document.createElement("span"); - exportStatusSpan.classList.add("neuroglancer-vox-status"); - exportStatusSpan.classList.add("neuroglancer-vox-input"); - exportStatusSpan.style.marginLeft = "0"; - - let exportPollTimer: number | undefined = undefined; - - const setExportStatus = (text: string) => { - exportStatusSpan.textContent = text; - }; - - const stopPolling = () => { - if (exportPollTimer !== undefined) { - clearInterval(exportPollTimer); - exportPollTimer = undefined; - } - }; - - exportButton.addEventListener("click", () => { - try { - const map = this.layer.voxMapRegistry.getCurrent(); - if (!map) throw new Error("No active map selected"); - const url = exportUrlInput.value.trim(); - if (url.length === 0) throw new Error("Export URL is required"); - - // Start export and polling - const getProgress = exportVoxToZarr(url, map as VoxMapConfig); - exportButton.disabled = true; - setExportStatus("Starting export..."); - stopPolling(); - exportPollTimer = setInterval(() => { - try { - const status = getProgress() as ExportStatus; - if (status.status === "loading") { - const pct = Math.round((status.progress ?? 0) * 100); - setExportStatus(`Export in progress: ${pct}%`); - } else if (status.status === "done") { - setExportStatus("Export completed"); - exportButton.disabled = false; - stopPolling(); - } else if (status.status === "error") { - setExportStatus(`Export failed: ${status.error}`); - exportButton.disabled = false; - stopPolling(); - } else { - throw new Error("Unknown export status"); - } - } catch (e: any) { - setExportStatus(`Export status error: ${e?.message || String(e)}`); - exportButton.disabled = false; - stopPolling(); - } - }, 500) as unknown as number; - } catch (e: any) { - setExportStatus(`Cannot start export: ${e?.message || String(e)}`); - exportButton.disabled = false; - stopPolling(); - } - }); - - exportSection.appendChild(exportUrlInput); - exportSection.appendChild(exportStatusSpan); - exportSection.appendChild(exportButton); - - } -} diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index a373b5644a..07934fd7a1 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -16,21 +16,16 @@ import type { ChunkManager } from "#src/chunk_manager/frontend.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; -import type { - DataType, - SliceViewChunkSpecification, -} from "#src/sliceview/base.js"; -import { - MultiscaleSliceViewChunkSource, - SliceViewChunk, - SliceViewChunkSource, -} from "#src/sliceview/frontend.js"; +import type { DataType, SliceViewChunkSpecification } from "#src/sliceview/base.js"; +import { SLICEVIEW_REQUEST_CHUNK_RPC_ID } from "#src/sliceview/base.js"; +import { MultiscaleSliceViewChunkSource, SliceViewChunk, SliceViewChunkSource } from "#src/sliceview/frontend.js"; import type { VolumeChunkSource as VolumeChunkSourceInterface, VolumeChunkSpecification, VolumeSourceOptions, - VolumeType, + VolumeType } from "#src/sliceview/volume/base.js"; +import type { TypedArray } from "#src/util/array.js"; import type { Disposable } from "#src/util/disposable.js"; import type { GL } from "#src/webgl/context.js"; import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; @@ -212,6 +207,81 @@ export class VolumeChunkSource return this.chunkFormatHandler.chunkFormat; } + async getEnsuredValueAt( + chunkPosition: Float32Array, + channelAccess: ChunkChannelAccessParameters, + ): Promise { + const initialValue = this.getValueAt(chunkPosition, channelAccess); + if (initialValue != null) { + return initialValue; + } + + const { spec } = this; + const { rank, chunkDataSize } = spec; + const chunkGridPosition = this.tempChunkGridPosition; + + for (let chunkDim = 0; chunkDim < rank; ++chunkDim) { + const voxel = chunkPosition[chunkDim]; + const chunkSize = chunkDataSize[chunkDim]; + chunkGridPosition[chunkDim] = Math.floor(voxel / chunkSize); + } + + try { + await this.rpc!.promiseInvoke(SLICEVIEW_REQUEST_CHUNK_RPC_ID, { + source: this.rpcId, + chunkGridPosition: chunkGridPosition, + }); + } catch (e) { + console.error(`Failed to fetch chunk for position ${chunkPosition.join()}:`, e); + return null; + } + + return this.getValueAt(chunkPosition, channelAccess); + } + + applyLocalEdits(edits: Map): void { + const chunksToUpdate = new Set(); + for (const [key, edit] of edits.entries()) { + const chunk = this.chunks.get(key) as VolumeChunk | undefined; + if (!chunk || !(chunk as any).data) { + continue; + } + const cpuArray = (chunk as any).data as TypedArray; + for (const index of edit.indices) { + cpuArray[index] = edit.value; + } + chunksToUpdate.add(chunk); + } + this.invalidateGpuData(chunksToUpdate); + } + + private invalidateGpuData(chunks: Set): void { + if (chunks.size === 0) return; + for (const chunk of chunks) { + chunk.updateFromCpuData(this.chunkManager.chunkQueueManager.gl); + } + this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); + } + + computeChunkIndices(voxelCoord: Float32Array): { + chunkGridPosition: Float32Array; + positionWithinChunk: Uint32Array; + } { + const { spec } = this; + const { rank, chunkDataSize } = spec; + const chunkGridPosition = this.tempChunkGridPosition; + const positionWithinChunk = this.tempPositionWithinChunk; + + for (let chunkDim = 0; chunkDim < rank; ++chunkDim) { + const voxel = voxelCoord[chunkDim]; + const chunkSize = chunkDataSize[chunkDim]; + const chunkIndex = Math.floor(voxel / chunkSize); + chunkGridPosition[chunkDim] = chunkIndex; + positionWithinChunk[chunkDim] = Math.floor(voxel - chunkSize * chunkIndex); + } + return { chunkGridPosition, positionWithinChunk }; + } + getValueAt( chunkPosition: Float32Array, channelAccess: ChunkChannelAccessParameters, @@ -281,6 +351,7 @@ export abstract class VolumeChunk extends SliceViewChunk { this.chunkDataSize = x.chunkDataSize || source.spec.chunkDataSize; } abstract getValueAt(dataPosition: Uint32Array): any; + abstract updateFromCpuData(gl: GL): void; } export abstract class MultiscaleVolumeChunkSource extends MultiscaleSliceViewChunkSource< diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 0d46712375..8d0065f3b6 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -85,7 +85,7 @@ export const FLOODFILL_TOOL_ID = "voxFloodFill"; } const centerCanonical = new Float32Array([start[0], start[1], start[2]]); - const editLodIndex = layer.voxEditController?.getEditLodIndexForBrush(brushRadius); + const editLodIndex = layer.voxEditController?.getEditLodIndexToDraw(brushRadius); if (!Number.isInteger(editLodIndex) || editLodIndex == undefined || editLodIndex < 0) { throw new Error("startDrawing: computed edit LOD index is invalid"); } @@ -230,19 +230,20 @@ export class VoxelFloodFillLegacyTool extends LegacyTool { ]); console.info("[VoxFloodFill] starting flood fill", { seed: Array.from(seed), value: value >>> 0, max: Math.floor(max) }); - const { edits, filledCount } = ctrl.floodFillPlane2D(seed, value >>> 0, Math.floor(max)); - console.info("[VoxFloodFill] BFS completed", { filledCount, editsByChunk: edits.length }); + ctrl.floodFillPlane2D(seed, value >>> 0, Math.floor(max)).then(({ edits, filledCount }) => { + console.info("[VoxFloodFill] BFS completed", { filledCount, editsByChunk: edits.length }); - if (edits.length === 0) return; - if (typeof ctrl.commitEdits === "function") { - ctrl.commitEdits(edits); - console.info("[VoxFloodFill] committed edits"); - } else if ((ctrl as any).rpc && (ctrl as any).rpc.invoke) { - (ctrl as any).rpc.invoke("VOX_EDIT_COMMIT_VOXELS", { rpcId: (ctrl as any).rpcId, edits }); - console.info("[VoxFloodFill] committed edits via fallback path"); - } else { - throw new Error("Flood fill: no way to commit edits"); - } + if (edits.length === 0) return; + if (typeof ctrl.commitEdits === "function") { + ctrl.commitEdits(edits); + console.info("[VoxFloodFill] committed edits"); + } else if ((ctrl as any).rpc && (ctrl as any).rpc.invoke) { + (ctrl as any).rpc.invoke("VOX_EDIT_COMMIT_VOXELS", { rpcId: (ctrl as any).rpcId, edits }); + console.info("[VoxFloodFill] committed edits via fallback path"); + } else { + throw new Error("Flood fill: no way to commit edits"); + } + }); } catch (e: any) { const msg = typeof e?.message === "string" ? e.message : String(e); try { diff --git a/src/voxel_annotation/backend.ts b/src/voxel_annotation/backend.ts deleted file mode 100644 index 0d2a8ad6c2..0000000000 --- a/src/voxel_annotation/backend.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * @license - * Copyright 2025. - */ - -import type { VolumeChunk } from "#src/sliceview/volume/backend.js"; -import { VolumeChunkSource as BaseVolumeChunkSource } from "#src/sliceview/volume/backend.js"; -import { DataType } from "#src/util/data_type.js"; -import { - makeVoxChunkKey, - VOX_CHUNK_SOURCE_RPC_ID, - VOX_MAP_INIT_RPC_ID, -} from "#src/voxel_annotation/base.js"; -import type { VoxSource } from "#src/voxel_annotation/index.js"; -import { - LocalVoxSource, -} from "#src/voxel_annotation/local_source.js"; -import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; -import type { RPC } from "#src/worker_rpc.js"; -import { registerRPC, registerSharedObject } from "#src/worker_rpc.js"; -// Ensure voxel edit backend and its RPC handlers are registered in the worker bundle. -import "#src/voxel_annotation/edit_backend.js"; - -/** - * Backend volume source that persists voxel edits per chunk. It returns saved data if available, - * otherwise returns an empty chunk (filled with zeros). - */ - -@registerSharedObject(VOX_CHUNK_SOURCE_RPC_ID) -export class VoxChunkSource extends BaseVolumeChunkSource { - private source?: VoxSource; - public lodFactor: number; - private mapReadyPromise: Promise; - private resolveMapReady!: () => void; - - constructor(rpc: RPC, options: any) { - super(rpc, options); - // Detect remote server configuration from options (flexible keys) - const o = options || {}; - this.lodFactor = o.lodFactor; - if (this.lodFactor == undefined) { - throw new Error("lodFactor is required"); - } - this.mapReadyPromise = new Promise((resolve) => { - this.resolveMapReady = resolve; - }); - } - - async initMap(arg: { map?: VoxMapConfig } | VoxMapConfig) { - const map: VoxMapConfig = (arg as any)?.map ?? (arg as any); - if (!map) throw new Error("initMap: map configuration is required"); - const src = new LocalVoxSource(); - await src.init(map); - this.source = src; - try { this.resolveMapReady(); } catch { /* ignore */ } - } - - async download(chunk: VolumeChunk, signal: AbortSignal): Promise { - if (signal.aborted) throw signal.reason ?? new Error("aborted"); - await this.mapReadyPromise; - const src = this.source; - if (!src) throw new Error("download: source is not initialized for this map"); - // Determine chunk key and size (may be clipped at upper bound). - this.computeChunkBounds(chunk); - const cds = chunk.chunkDataSize!; - const key = chunk.chunkGridPosition.join(); - const total = cds[0] * cds[1] * cds[2]; - // Always produce a typed array matching the spec type; MVP uses UINT32 - const array = this.allocateTypedArray(this.spec.dataType, total, 0); - // Load saved chunk if present and copy overlapping region - const saved = await src.getSavedChunk(makeVoxChunkKey(key, this.lodFactor)); - if (saved) { - const sxS = saved.size[0], - syS = saved.size[1], - szS = saved.size[2]; - const sxD = cds[0], - syD = cds[1], - szD = cds[2]; - const ox = Math.min(sxS, sxD); - const oy = Math.min(syS, syD); - const oz = Math.min(szS, szD); - const srcArr = saved.data as any; - const dst = array as any; - for (let z = 0; z < oz; ++z) { - for (let y = 0; y < oy; ++y) { - const baseSrc = (z * syS + y) * sxS; - const baseDst = (z * syD + y) * sxD; - for (let x = 0; x < ox; ++x) { - dst[baseDst + x] = srcArr[baseSrc + x]; - } - } - } - } - (chunk as any).data = array; - } - - private allocateTypedArray(dataType: number, size: number, fill: number) { - switch (dataType) { - case DataType.UINT8: - return new Uint8Array(size).fill(fill & 0xff); - case DataType.INT8: - return new Int8Array(size).fill((fill << 24) >> 24); - case DataType.UINT16: - return new Uint16Array(size).fill(fill & 0xffff); - case DataType.INT16: - return new Int16Array(size).fill((fill << 16) >> 16); - case DataType.UINT32: - return new Uint32Array(size).fill(fill >>> 0); - case DataType.INT32: - return new Int32Array(size).fill(fill | 0); - case DataType.UINT64: { - const big = BigInt(fill >>> 0); - return new BigUint64Array(size).fill(big); - } - case DataType.FLOAT32: - return new Float32Array(size).fill(fill); - default: - return new Uint32Array(size).fill(fill >>> 0); - } - } -} - -// RPC to initialize map -registerRPC(VOX_MAP_INIT_RPC_ID, function (x: any) { - const obj = this.get(x.id) as VoxChunkSource; - obj.initMap(x?.map || x || {}); -}); diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/edit_backend.ts index e50dd2c881..f6995ea00e 100644 --- a/src/voxel_annotation/edit_backend.ts +++ b/src/voxel_annotation/edit_backend.ts @@ -5,28 +5,22 @@ * while keeping a single writer per map owned by the edit controller. */ +import type { VolumeChunkSource } from "#src/sliceview/volume/backend.js"; import { VOX_EDIT_BACKEND_RPC_ID, VOX_EDIT_COMMIT_VOXELS_RPC_ID, VOX_EDIT_LABELS_ADD_RPC_ID, VOX_EDIT_LABELS_GET_RPC_ID, - VOX_EDIT_MAP_INIT_RPC_ID, VOX_RELOAD_CHUNKS_RPC_ID, makeVoxChunkKey, parseVoxChunkKey, } from "#src/voxel_annotation/base.js"; -import type { VoxSourceWriter } from "#src/voxel_annotation/index.js"; -import { LocalVoxSourceWriter } from "#src/voxel_annotation/local_source.js"; -import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; import type { RPC} from "#src/worker_rpc.js"; import { SharedObject , registerPromiseRPC, registerRPC, registerSharedObject, initializeSharedObjectCounterpart } from "#src/worker_rpc.js"; @registerSharedObject(VOX_EDIT_BACKEND_RPC_ID) export class VoxelEditController extends SharedObject { - private source?: VoxSourceWriter; - private mapReadyPromise: Promise; - private resolveMapReady!: () => void; - private mapCfg?: VoxMapConfig; + private source?: VolumeChunkSource; // Short debounce to coalesce rapid edits coming from tools. private pendingEdits: { @@ -49,30 +43,16 @@ export class VoxelEditController extends SharedObject { // Initialize as a counterpart in the worker so RPC references are valid. // This registers the object under the provided rpc/id and sets up ref counting. initializeSharedObjectCounterpart(this, rpc, options); - this.mapReadyPromise = new Promise((resolve) => { - this.resolveMapReady = resolve; - }); - } - - async initMap(arg: { map?: VoxMapConfig } | VoxMapConfig) { - const map: VoxMapConfig = (arg as any)?.map ?? (arg as any); - if (!map) throw new Error("VoxEditBackend.initMap: map configuration is required"); - const src = new LocalVoxSourceWriter(this); - await src.init(map); - this.source = src; - this.mapCfg = map; - try { this.resolveMapReady(); } catch {/* ignore */} } private async flushPending(): Promise { - await this.mapReadyPromise; const src = this.source; if (!src) throw new Error("VoxEditBackend.flushPending: source not initialized"); const edits = this.pendingEdits; this.pendingEdits = []; this.commitDebounceTimer = undefined; if (edits.length === 0) return; - await src.applyEdits(edits); + // await src.applyEdits(edits); TODO: add writting capability to VolumeChunkSource // After base edits, enqueue downsampling for affected chunks (do not await here). const touched = new Set(); for (const e of edits) touched.add(e.key); @@ -90,7 +70,6 @@ export class VoxelEditController extends SharedObject { size?: number[]; }[], ) { - await this.mapReadyPromise; if (!this.source) throw new Error("VoxEditBackend.commitVoxels: source not initialized"); for (const e of edits) { if (!e || !e.key || !e.indices) { @@ -103,17 +82,15 @@ export class VoxelEditController extends SharedObject { } async getLabelIds(): Promise { - await this.mapReadyPromise; const src = this.source; if (!src) throw new Error("VoxEditBackend.getLabelIds: source not initialized"); - return await src.getLabelIds(); + return [] // await src.getLabelIds(); } - async addLabel(value: number): Promise { - await this.mapReadyPromise; + async addLabel(_value: number): Promise { const src = this.source; if (!src) throw new Error("VoxEditBackend.addLabel: source not initialized"); - return await src.addLabel(value >>> 0); + return [] // await src.addLabel(value >>> 0); } callChunkReload(voxChunkKeys: string[]){ @@ -124,12 +101,9 @@ export class VoxelEditController extends SharedObject { } // Downsampling helpers private parentKeyOf(childKey: string): string | null { - if (!this.mapCfg) return null; const info = parseVoxChunkKey(childKey); if (info === null) return null; const parentLod = info.lod * 2; - const maxLOD = this.mapCfg.steps[this.mapCfg.steps.length - 1]; - if (parentLod > maxLOD) return null; const px = Math.floor(info.x / 2); const py = Math.floor(info.y / 2); const pz = Math.floor(info.z / 2); @@ -142,12 +116,11 @@ export class VoxelEditController extends SharedObject { } private async performDownsampleCascadeForKey(sourceKey: string): Promise { - const cfg = this.mapCfg; const src = this.source; - if (!cfg || !src) return; - const chunkSize = cfg.chunkDataSize[0]; + if (!src) return; + const chunkSize = 64; const maxPasses = this.calculateDownsamplePasses(chunkSize); - const maxLOD = cfg.steps[cfg.steps.length - 1]; + const maxLOD = 256; let currentKey: string | null = sourceKey; for (let i = 0; i < maxPasses; i++) { @@ -209,13 +182,14 @@ export class VoxelEditController extends SharedObject { } private async downsampleStep(sourceKey: string): Promise { - const cfg = this.mapCfg; const src = this.source; - if (!cfg || !src) return null; + if (!src) return null; const info = parseVoxChunkKey(sourceKey); if (info === null) return null; - const sourceChunk = await src.getSavedChunk(sourceKey); + const sourceChunk = src.getChunk( + new Float32Array([info.x, info.y, info.z]), + ) as any; if (!sourceChunk) return null; const targetKey = this.parentKeyOf(sourceKey); @@ -229,8 +203,8 @@ export class VoxelEditController extends SharedObject { const offsetY = (info.y % 2) * chunkH; const offsetZ = (info.z % 2) * chunkD; - const targetSizeX = cfg.chunkDataSize[0]; - const targetSizeY = cfg.chunkDataSize[1]; + const targetSizeX = 1// cfg.chunkDataSize[0]; + const targetSizeY =1 // cfg.chunkDataSize[1]; const indices: number[] = []; const values: number[] = []; @@ -266,21 +240,15 @@ export class VoxelEditController extends SharedObject { } if (indices.length > 0) { - await src.applyEdits([ - { key: targetKey, indices, values }, - ]); + //await src.applyEdits([ + // { key: targetKey, indices, values }, + //]); } return targetKey; } } -// RPC wire-up -registerRPC(VOX_EDIT_MAP_INIT_RPC_ID, function (x: any) { - const obj = this.get(x.rpcId) as VoxelEditController; - obj.initMap(x?.map || x || {}); -}); - registerRPC(VOX_EDIT_COMMIT_VOXELS_RPC_ID, function (x: any) { const obj = this.get(x.rpcId) as VoxelEditController; obj.commitVoxels(x.edits || []); diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index e2fbcc59e3..20213cffa5 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -3,18 +3,15 @@ * Copyright 2025. */ -import type { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; +import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; +import type { VolumeChunkSource , MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; import { - parseVoxChunkKey, VOX_EDIT_BACKEND_RPC_ID, VOX_EDIT_COMMIT_VOXELS_RPC_ID, VOX_EDIT_LABELS_ADD_RPC_ID, VOX_EDIT_LABELS_GET_RPC_ID, - VOX_EDIT_MAP_INIT_RPC_ID, VOX_RELOAD_CHUNKS_RPC_ID, } from "#src/voxel_annotation/base.js"; -import type { VoxChunkSource } from "#src/voxel_annotation/frontend.js"; -import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; import { registerRPC, registerSharedObjectOwner, @@ -33,7 +30,23 @@ export class VoxelEditController extends SharedObject { } private static readonly qualityFactor = 16.0; private static readonly restrictToMinLOD = true; - private mapConfig?: VoxMapConfig; + private morphologicalConfig = { + // At what `filledCount` thresholds the neighborhood size increases. + growthThresholds: [ + { count: 1000, size: 3 }, // Requires 3px thick channels + { count: 10000, size: 5 }, // Requires 5px thick channels + { count: 100000, size: 7 }, // Requires 7px thick channels + ], + maxSize: 9, + }; + + private readonly singleChannelAccess: ChunkChannelAccessParameters = { + numChannels: 1, + channelSpaceShape: new Uint32Array([]), + chunkChannelDimensionIndices: [], + chunkChannelCoordinates: new Uint32Array([0]) + }; + private getIdentitySliceViewSourceOptions() { const rank = (this.multiscale as any).rank as number | undefined; @@ -57,12 +70,6 @@ export class VoxelEditController extends SharedObject { } as const; } - initializeMap(map: VoxMapConfig) { - if (!this.rpc) throw new Error("VoxelEditController.initializeMap: RPC not initialized."); - this.mapConfig = map; - this.rpc.invoke(VOX_EDIT_MAP_INIT_RPC_ID, { rpcId: this.rpcId, map }); - } - // Required: compute desired voxel size (power-of-two) from brush radius. getOptimalVoxelSize(brushRadius: number, minLOD = 1, maxLOD = 128) { if (VoxelEditController.restrictToMinLOD) { @@ -78,8 +85,22 @@ export class VoxelEditController extends SharedObject { return voxelSize; } + private getSourceForLOD(lodIndex: number): VolumeChunkSource { + const sourcesByScale = this.multiscale.getSources(this.getIdentitySliceViewSourceOptions()); + // Assuming a single orientation, which is correct for this use case. + const sources = sourcesByScale[0]; + if (!sources || sources.length <= lodIndex) { + throw new Error(`VoxelEditController: LOD index ${lodIndex} is out of bounds.`); + } + const source = sources[lodIndex]?.chunkSource; + if (!source) { + throw new Error(`VoxelEditController: No chunk source found for LOD index ${lodIndex}.`); + } + return source; + } + /** Compute the edit LOD index (scale index) from a brush radius in canonical units. */ - getEditLodIndexForBrush(brushRadiusCanonical: number): number { + getEditLodIndexToDraw(brushRadiusCanonical: number): number { if (VoxelEditController.restrictToMinLOD) { return 0; } @@ -116,13 +137,7 @@ export class VoxelEditController extends SharedObject { const voxelSize = this.getOptimalVoxelSize(radiusCanonical); const sourceIndex = Math.floor(Math.log2(voxelSize)); - const src2D = this.multiscale.getSources(this.getIdentitySliceViewSourceOptions()); - if (!src2D || !src2D[0] || src2D[0].length <= sourceIndex) { - throw new Error("VoxelEditController: No multiscale levels available."); - } - const source = src2D[0][sourceIndex]?.chunkSource as VoxChunkSource; - if (!source) - throw new Error("paintVoxelsBatch: Selected level has no chunk source."); + const source = this.getSourceForLOD(sourceIndex); // Convert center and radius to the level’s voxel grid. const cx = Math.floor((centerCanonical[0] ?? 0) / voxelSize); @@ -136,14 +151,14 @@ export class VoxelEditController extends SharedObject { } const rr = r * r; - const voxelsLOD: Float32Array[] = []; + const voxelsToPaint: Float32Array[] = []; if (shape === "sphere") { for (let dz = -r; dz <= r; ++dz) { for (let dy = -r; dy <= r; ++dy) { for (let dx = -r; dx <= r; ++dx) { if (dx * dx + dy * dy + dz * dz <= rr) { - voxelsLOD.push(new Float32Array([cx + dx, cy + dy, cz + dz])); + voxelsToPaint.push(new Float32Array([cx + dx, cy + dy, cz + dz])); } } } @@ -152,18 +167,41 @@ export class VoxelEditController extends SharedObject { for (let dy = -r; dy <= r; ++dy) { for (let dx = -r; dx <= r; ++dx) { if (dx * dx + dy * dy <= rr) { - voxelsLOD.push(new Float32Array([cx + dx, cy + dy, cz])); + voxelsToPaint.push(new Float32Array([cx + dx, cy + dy, cz])); } } } } - const editsPayload = source.paintVoxelsBatch(voxelsLOD, value); + if (!voxelsToPaint || voxelsToPaint.length === 0) return; + const editsByChunk = new Map(); + + for (const voxelCoord of voxelsToPaint) { + const { chunkGridPosition, positionWithinChunk } = source.computeChunkIndices(voxelCoord); + const key = chunkGridPosition.join(); + + let entry = editsByChunk.get(key); + if (!entry) { + entry = { indices: [], value }; + editsByChunk.set(key, entry); + } + + const { chunkDataSize } = source.spec; + const index = (positionWithinChunk[2] * chunkDataSize[1] + positionWithinChunk[1]) * chunkDataSize[0] + positionWithinChunk[0]; + entry.indices.push(index); + } + + source.applyLocalEdits(editsByChunk); + + const backendEdits = []; + for (const [key, edit] of editsByChunk.entries()) { + backendEdits.push({ key, indices: edit.indices, value: edit.value }); + } if (!this.rpc) throw new Error("VoxelEditController.paintBrushWithShape: RPC not initialized."); this.rpc.invoke(VOX_EDIT_COMMIT_VOXELS_RPC_ID, { rpcId: this.rpcId, - edits: editsPayload, + edits: backendEdits, }); } @@ -199,16 +237,11 @@ export class VoxelEditController extends SharedObject { * suitable for VOX_EDIT_COMMIT_VOXELS without committing. Hard-cap deny semantics. * The seed is simply the first clicked voxel in canonical/world units. */ - floodFillPlane2D( + async floodFillPlane2D( startPositionCanonical: Float32Array, fillValue: number, maxVoxels: number, - ): { edits: { key: string; indices: number[]; value: number }[]; filledCount: number; originalValue: number } { - console.info("[VoxFloodFill] controller call", { - start: Array.from(startPositionCanonical || []), - fillValue: fillValue >>> 0, - maxVoxels: maxVoxels | 0, - }); + ): Promise<{ edits: { key: string; indices: number[]; value: number }[]; filledCount: number; originalValue: number }> { if (!startPositionCanonical || startPositionCanonical.length < 3) { throw new Error("VoxelEditController.floodFillPlane2D: startPositionCanonical must be Float32Array[3]."); } @@ -219,12 +252,7 @@ export class VoxelEditController extends SharedObject { // For V1 we use the minimum LOD (index 0) to keep behavior predictable. const voxelSize = this.getOptimalVoxelSize(1); // will return min when restrictToMinLOD=true const sourceIndex = Math.floor(Math.log2(voxelSize)); - const src2D = this.multiscale.getSources(this.getIdentitySliceViewSourceOptions()); - if (!src2D || !src2D[0] || src2D[0].length <= sourceIndex) { - throw new Error("VoxelEditController.floodFillPlane2D: No multiscale levels available."); - } - const source = src2D[0][sourceIndex]?.chunkSource as VoxChunkSource; - if (!source) throw new Error("VoxelEditController.floodFillPlane2D: Selected level has no chunk source."); + const source = this.getSourceForLOD(sourceIndex); // Convert canonical/world to level grid coordinates. const startVoxelLod = new Float32Array([ @@ -233,42 +261,183 @@ export class VoxelEditController extends SharedObject { Math.floor((startPositionCanonical[2] ?? NaN) / voxelSize), ]); - return source.floodFillPlane2D(startVoxelLod, fillValue >>> 0, maxVoxels | 0); - } + if (!startVoxelLod || startVoxelLod.length < 3) { + throw new Error("VoxChunkSource.floodFillPlane2D: startVoxelLod must be Float32Array[3]."); + } + if (!Number.isFinite(maxVoxels) || maxVoxels <= 0) { + throw new Error("VoxChunkSource.floodFillPlane2D: maxVoxels must be > 0."); + } - callChunkReload(voxChunkKeys: string[]) { - const src2D = this.multiscale.getSources(this.getIdentitySliceViewSourceOptions()); - if (!src2D || !src2D[0] || src2D[0].length === 0) { - throw new Error("VoxelEditController: No multiscale levels available."); + const originalValueResult = await source.getEnsuredValueAt(startVoxelLod, this.singleChannelAccess); + if (originalValueResult === null) { + throw new Error("Flood fill seed is in an unloaded or out-of-bounds chunk."); } - const chkByLod = new Map>(); - for (const key of voxChunkKeys) { - const parsed = parseVoxChunkKey(key); - if (!parsed) { - throw new Error(`VoxelEditController.callChunkReload: invalid chunk key '${key}'.`); - } - if (!this.mapConfig?.steps) { - throw new Error(`VoxelEditController.callChunkReload: missing map config steps.`); + const originalValue = Number(originalValueResult); + if (originalValue === fillValue) { + return { edits: [], filledCount: 0, originalValue }; + } + + const zPlane = startVoxelLod[2] | 0; + const visited = new Set(); + const queue: [number, number][] = []; + let filledCount = 0; + + const isOriginalAt = async (px: number, py: number): Promise => { + const value = await source.getEnsuredValueAt(new Float32Array([px, py, zPlane]), this.singleChannelAccess); + return value === originalValue; + }; + + const getCurrentThickness = (): number => { + let thickness = 1; + for (const threshold of this.morphologicalConfig.growthThresholds) { + if (filledCount >= threshold.count) { + thickness = Math.max(thickness, threshold.size); + } } - const levelIndex = this.mapConfig?.steps.indexOf(parsed.lod); - if (levelIndex < 0) { - throw new Error( - `VoxelEditController.callChunkReload: LOD ${parsed.lod} not present in steps [${this.mapConfig?.steps.join(",")}].`, - ); + return Math.min(thickness, this.morphologicalConfig.maxSize); + }; + + const hasThickEnoughChannel = ( + x: number, + y: number, + nx: number, + ny: number, + requiredThickness: number + ): boolean => { + if (requiredThickness <= 1) return true; // No thickness constraint + + const dx = nx - x; + const dy = ny - y; + + // Only allow exactly one-axis moves (4-connectivity) + if ((dx === 0) === (dy === 0)) return false; + + const halfThickness = Math.floor(requiredThickness / 2); + + if (dx !== 0) { + // Horizontal move: check vertical thickness at BOTH current and destination + // We need the channel to be thick enough along the entire path + for (const checkX of [x, nx]) { + for (let offset = -halfThickness; offset <= halfThickness; offset++) { + if (!isOriginalAt(checkX, ny + offset)) { + return false; // Channel not thick enough + } + } + } + } else { + // Vertical move: check horizontal thickness at BOTH current and destination + for (const checkY of [y, ny]) { + for (let offset = -halfThickness; offset <= halfThickness; offset++) { + if (!isOriginalAt(nx + offset, checkY)) { + return false; // Channel not thick enough + } + } + } } - if (!chkByLod.has(levelIndex)){ - chkByLod.set(levelIndex, new Set()); + + return true; + }; + + const fillBorderRegion = async ( + startX: number, + startY: number, + requiredThickness: number + ) => { + const subQueue: [number, number][] = []; + const halfThickness = Math.floor(requiredThickness / 2) + 1; + + const k = `${startX},${startY}`; + if (visited.has(k)) return; + + subQueue.push([startX, startY]); + visited.add(k); // Mark as visited immediately to avoid re-processing + + while (subQueue.length > 0) { + const [cx, cy] = subQueue.shift()!; + filledCount++; + voxelsToFill.push(new Float32Array([cx, cy, zPlane])); + + const neighbors: [number, number][] = [[cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]]; + for (const [nnx, nny] of neighbors) { + if (nnx < startX - halfThickness || nnx > startX + halfThickness || + nny < startY - halfThickness || nny > startY + halfThickness) { + continue; // Outside the bounding box + } + const nk = `${nnx},${nny}`; + if (visited.has(nk)) continue; + + if (await isOriginalAt(nnx, nny)) { + visited.add(nk); + subQueue.push([nnx, nny]); + } + } } - chkByLod.get(levelIndex)?.add(parsed.chunkKey); + }; + + // Seed the queue + queue.push([startVoxelLod[0] | 0, startVoxelLod[1] | 0]); + visited.add(`${startVoxelLod[0] | 0},${startVoxelLod[1] | 0}`); + const voxelsToFill: Float32Array[] = []; + + // BFS with thickness constraints + while (queue.length > 0) { + const [x, y] = queue.shift()!; + + // Schedule this pixel for filling + filledCount++; + voxelsToFill.push(new Float32Array([x, y, zPlane])); + + // Get current thickness requirement + const requiredThickness = getCurrentThickness(); + + // Check 4-neighbors + const neighbors: [number, number][] = [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]; + for (const [nx, ny] of neighbors) { + const k = `${nx},${ny}`; + if (visited.has(k)) continue; + + if (await isOriginalAt(nx, ny)) { + // The neighbor is a valid fill target. Now check if we can propagate from it. + if (hasThickEnoughChannel(x, y, nx, ny, requiredThickness)) { + // Channel is thick enough: Add to queue to propagate. + visited.add(k); + queue.push([nx, ny]); + } else { + await fillBorderRegion(nx, ny, requiredThickness); + } + } } - for (const [levelIndex, keys] of chkByLod) { - const level = src2D[0][levelIndex]; - if (!level || !level.chunkSource) { - throw new Error(`VoxelEditController.callChunkReload: missing chunk source for LOD ${levelIndex}.`); + } + + const editsByChunk = new Map(); + for (const voxelCoord of voxelsToFill) { + const { chunkGridPosition, positionWithinChunk } = source.computeChunkIndices(voxelCoord); + const key = chunkGridPosition.join(); + + let entry = editsByChunk.get(key); + if (!entry) { + entry = { indices: [], value: fillValue }; + editsByChunk.set(key, entry); } - const source = level.chunkSource as unknown as VoxChunkSource; - source.invalidateChunksByKey(Array.from(keys)); + const { chunkDataSize } = source.spec; + const index = (positionWithinChunk[2] * chunkDataSize[1] + positionWithinChunk[1]) * chunkDataSize[0] + positionWithinChunk[0]; + entry.indices.push(index); + } + + // Apply edits locally for preview. + source.applyLocalEdits(editsByChunk); + + // Prepare edits for the backend. + const backendEdits: { key: string; indices: number[]; value: number }[] = []; + for (const [key, edit] of editsByChunk.entries()) { + backendEdits.push({ key, indices: edit.indices, value: edit.value }); } + + return { edits: backendEdits, filledCount, originalValue: originalValue >>> 0 }; + } + + callChunkReload(_voxChunkKeys: string[]) { + /// TODO } } diff --git a/src/voxel_annotation/export_to_zarr.ts b/src/voxel_annotation/export_to_zarr.ts deleted file mode 100644 index ac4e494e49..0000000000 --- a/src/voxel_annotation/export_to_zarr.ts +++ /dev/null @@ -1,370 +0,0 @@ -// TODO: read the whole IndexedDB and write it to a S3 bucket in zarr v2 format without compression and multiscale. the function will take a url and the VoxMapConfig in args and will return a progress function that returns the current progress when called ({status: "loading", progress: 0.5} or {status: "done", progress: 1} or {status: "error", progress: 0, error: "error message"}). We can use the helper of the local_source.ts. The function will be called from the VoxUserLayer on the click of a new export button. A new export url field will also be added to the ui. After the export is started, the ui will display the current progress thanks to the progress function. We should also, before starting the export, descend the entire dirty tree and upscale every dirty node recursively. Once this is done we can simply export every chunks at lod level 1. - -import { DataType } from "#src/util/data_type.js"; -import { parseVoxChunkKey } from "#src/voxel_annotation/base.js"; -import { openVoxDb } from "#src/voxel_annotation/local_source.js"; -import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; - -export type ExportStatus = - | { status: "loading"; progress: number } - | { status: "done"; progress: 1 } - | { status: "error"; progress: 0; error: string }; - -interface NormalizedBaseUrl { - baseUrl: string; // Must end with '/' -} - -/** - * Minimal Zarr v2 single-array exporter for voxel annotations. - * - Writes only LOD=1 chunks present in IndexedDB. - * - No dirty-tree traversal or upscaling is performed (feature under development). - * - No compression and no multiscale hierarchy. - * - Array is created at subpath "0" under the specified base URL. - */ -export function exportVoxToZarr(targetUrl: string, mapConfig: VoxMapConfig): () => ExportStatus { - if (!targetUrl || typeof targetUrl !== "string") { - throw new Error("exportVoxToZarr: targetUrl must be a non-empty string"); - } - if (!mapConfig || typeof mapConfig !== "object") { - throw new Error("exportVoxToZarr: mapConfig is required"); - } - - const progressState: { current: ExportStatus } = { - current: { status: "loading", progress: 0 }, - }; - - const { baseUrl } = normalizeBaseUrl(targetUrl); - - void (async () => { - try { - const db = await openVoxDb(); - const mapId = String(mapConfig.id); - - // First pass: count chunks per LOD and collect present LODs - const { countsByLod, totalCount } = await countAllLodChunks(db, mapId); - const presentLods = Array.from(countsByLod.keys()).sort((a, b) => a - b); - const lodsToWrite = presentLods.length > 0 ? presentLods : [1]; - - // We will write: root .zgroup + root .zattrs + per-lod (.zarray + .zattrs) + all chunks - const metadataFiles = 2 + lodsToWrite.length * 2; - const totalWrites = metadataFiles + totalCount; - let writesCompleted = 0; - const updateProgress = () => { - if (totalWrites <= 0) { - progressState.current = { status: "loading", progress: 0 }; - return; - } - progressState.current = { - status: "loading", - progress: Math.max(0, Math.min(1, writesCompleted / totalWrites)), - }; - }; - - // Root metadata - await putJson(joinUrl(baseUrl, ".zgroup"), { zarr_format: 2 }); - writesCompleted++; updateProgress(); - await putJson(joinUrl(baseUrl, ".zattrs"), buildRootZattrsForLods(mapConfig, lodsToWrite)); - writesCompleted++; updateProgress(); - - // Per-LOD arrays - for (const lod of lodsToWrite) { - const { shapeZYX, chunksZYX, dtype } = deriveZarrMetadataForLod(mapConfig, lod); - const arrayBase = joinUrl(baseUrl, `${lod}/`); - const zarray = { - zarr_format: 2, - shape: shapeZYX, - chunks: chunksZYX, - dtype, - order: "C", - fill_value: 0, - filters: [] as unknown as [], - compressor: null as unknown as null, - dimension_separator: ".", - }; - await putJson(joinUrl(arrayBase, ".zarray"), zarray); - writesCompleted++; updateProgress(); - await putJson(joinUrl(arrayBase, ".zattrs"), { _ARRAY_DIMENSIONS: ["z", "y", "x"] }); - writesCompleted++; updateProgress(); - } - - // Second pass: upload chunks grouped by their LOD - await iterateAllLodChunks(db, mapId, async ({ lod, x, y, z, value }) => { - const chunkRelPath = `${lod}/${z}.${y}.${x}`; - const chunkUrl = joinUrl(baseUrl, chunkRelPath); - const buf = ensureArrayBuffer(value); - await putBinary(chunkUrl, buf); - writesCompleted++; updateProgress(); - }); - - progressState.current = { status: "done", progress: 1 }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - progressState.current = { status: "error", progress: 0, error: message }; - } - })(); - - return () => progressState.current; -} - -/** Normalize supported base URLs to an HTTP(S) base that ends with '/'. */ -function normalizeBaseUrl(url: string): NormalizedBaseUrl { - const trimmed = url.trim(); - // Direct HTTP(S) endpoints, e.g. MinIO: http://localhost:9000/zarr/mydataset/ - if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { - return { baseUrl: ensureTrailingSlash(trimmed) }; - } - // S3-compatible explicit endpoint, e.g. s3+http://localhost:9000/zarr/mydataset/ - if (trimmed.startsWith("s3+http://")) { - return { baseUrl: ensureTrailingSlash(trimmed.substring("s3+".length)) }; - } - if (trimmed.startsWith("s3+https://")) { - return { baseUrl: ensureTrailingSlash(trimmed.substring("s3+".length)) }; - } - // AWS-style shorthand: s3://bucket/path → https://bucket.s3.amazonaws.com/path - if (trimmed.startsWith("s3://")) { - const rest = trimmed.substring("s3://".length); - const firstSlash = rest.indexOf("/"); - if (firstSlash < 0) { - throw new Error("exportVoxToZarr: s3:// URL must include a path prefix"); - } - const bucket = rest.substring(0, firstSlash); - const keyPrefix = rest.substring(firstSlash + 1); - if (bucket.length === 0) throw new Error("exportVoxToZarr: missing bucket in s3 URL"); - const httpsUrl = `https://${bucket}.s3.amazonaws.com/${keyPrefix}`; - return { baseUrl: ensureTrailingSlash(httpsUrl) }; - } - throw new Error( - `exportVoxToZarr: Unsupported URL scheme; use http(s)://, s3+http(s)://, or s3:// (got: ${url})`, - ); -} - -function ensureTrailingSlash(u: string): string { - return u.endsWith("/") ? u : `${u}/`; -} - -function joinUrl(base: string, path: string): string { - if (!base.endsWith("/")) throw new Error("joinUrl: base must end with '/'"); - if (!path) throw new Error("joinUrl: path must be non-empty"); - if (path.startsWith("/")) path = path.substring(1); - return base + path; -} - -function ensureArrayBuffer(value: any): ArrayBuffer { - if (value instanceof ArrayBuffer) return value; - if (ArrayBuffer.isView(value)) return value.buffer as ArrayBuffer; - throw new Error("Expected ArrayBuffer value from IndexedDB"); -} - -async function putJson(url: string, obj: unknown): Promise { - const json = JSON.stringify(obj); - const bytes = new TextEncoder().encode(json); - await putBinary(url, bytes); -} - -async function putBinary(url: string, data: ArrayBuffer | ArrayBufferView): Promise { - const response = await fetch(url, { - method: "PUT", - headers: { - "Content-Type": inferContentTypeFromPath(url), - }, - body: data, - }); - if (!response.ok) { - throw new Error(`Failed to PUT ${url}: ${response.status} ${response.statusText}`); - } -} - -function inferContentTypeFromPath(url: string): string { - if (url.endsWith(".json") || url.endsWith(".zarray") || url.endsWith(".zattrs") || url.endsWith(".zgroup")) { - return "application/json"; - } - return "application/octet-stream"; -} - -function deriveZarrMetadata(mapCfg: VoxMapConfig): { shapeZYX: number[]; chunksZYX: number[]; dtype: string } { - const lower = mapCfg.baseVoxelOffset; - const upper = mapCfg.upperVoxelBound; - if (!Array.isArray(lower) && !(lower instanceof Float32Array)) { - throw new Error("mapCfg.baseVoxelOffset must be an array-like of length 3"); - } - if (!Array.isArray(upper) && !(upper instanceof Float32Array)) { - throw new Error("mapCfg.upperVoxelBound must be an array-like of length 3"); - } - const bounds = [ - Math.max(0, Math.floor(Number(upper[0]) - Number(lower[0]))), - Math.max(0, Math.floor(Number(upper[1]) - Number(lower[1]))), - Math.max(0, Math.floor(Number(upper[2]) - Number(lower[2]))), - ]; - const cds = mapCfg.chunkDataSize as unknown as ArrayLike; - const chunkXYZ = [ - Math.max(1, Math.floor(Number(cds[0]))), - Math.max(1, Math.floor(Number(cds[1]))), - Math.max(1, Math.floor(Number(cds[2]))), - ]; - // We expose Zarr dims as [Z, Y, X] - const shapeZYX = [bounds[2], bounds[1], bounds[0]]; - const chunksZYX = [chunkXYZ[2], chunkXYZ[1], chunkXYZ[0]]; - const dtype = toZarrDtype(mapCfg.dataType as number); - return { shapeZYX, chunksZYX, dtype }; -} - -function deriveZarrMetadataForLod(mapCfg: VoxMapConfig, lod: number): { shapeZYX: number[]; chunksZYX: number[]; dtype: string } { - if (!Number.isFinite(lod) || lod <= 0) throw new Error("deriveZarrMetadataForLod: lod must be positive"); - const base = deriveZarrMetadata(mapCfg); - const shapeZYX = [ - Math.max(1, Math.ceil(base.shapeZYX[0] / lod)), - Math.max(1, Math.ceil(base.shapeZYX[1] / lod)), - Math.max(1, Math.ceil(base.shapeZYX[2] / lod)), - ]; - return { shapeZYX, chunksZYX: base.chunksZYX, dtype: base.dtype }; -} - -function toZarrDtype(dt: number): string { - switch (dt) { - case DataType.UINT32: - return "; - if (scale == null || (scale as any).length < 3) { - throw new Error("Invalid mapCfg.scaleMeters; expected length-3 array"); - } - const omeUnit = toOmeLongUnit(rawUnit); - const mPer = metersPerUnit(rawUnit); - const sx = Number(scale[0]); - const sy = Number(scale[1]); - const sz = Number(scale[2]); - if (!Number.isFinite(sx) || !Number.isFinite(sy) || !Number.isFinite(sz)) { - throw new Error("scaleMeters contains non-finite values"); - } - const baseScaleZYX = [sz / mPer, sy / mPer, sx / mPer]; - const datasets = lods.map((lod) => ({ - path: String(lod), - coordinateTransformations: [ - { type: "scale", scale: [baseScaleZYX[0] * lod, baseScaleZYX[1] * lod, baseScaleZYX[2] * lod] }, - ], - })); - return { - multiscales: [ - { - version: "0.4", - axes: [ - { name: "z", type: "space", unit: omeUnit }, - { name: "y", type: "space", unit: omeUnit }, - { name: "x", type: "space", unit: omeUnit }, - ], - datasets, - }, - ], - } as const; -} - -async function countAllLodChunks(db: IDBDatabase, mapId: string): Promise<{ countsByLod: Map; totalCount: number }> { - return new Promise((resolve, reject) => { - const countsByLod = new Map(); - let totalCount = 0; - const tx = db.transaction("chunks", "readonly"); - const store = tx.objectStore("chunks"); - const req = (store as any).openKeyCursor ? (store as any).openKeyCursor() : (store as any).openCursor(); - req.onerror = () => reject(req.error); - req.onsuccess = (ev: any) => { - const cursor: IDBCursor | IDBCursorWithValue | null = ev.target.result; - if (!cursor) { - resolve({ countsByLod, totalCount }); - return; - } - const key = String(cursor.key); - const prefix = `${mapId}:`; - if (key.startsWith(prefix)) { - const voxKey = key.substring(prefix.length); - const info = parseVoxChunkKey(voxKey); - if (info) { - const c = countsByLod.get(info.lod) ?? 0; - countsByLod.set(info.lod, c + 1); - totalCount++; - } - } - cursor.continue(); - }; - }); -} - -async function iterateAllLodChunks( - db: IDBDatabase, - mapId: string, - onChunk: (args: { lod: number; x: number; y: number; z: number; value: ArrayBuffer }) => Promise, -): Promise { - const pendingUploads: Promise[] = []; - await new Promise((resolve, reject) => { - const tx = db.transaction("chunks", "readonly"); - const store = tx.objectStore("chunks"); - const req = store.openCursor(); - req.onerror = () => reject(req.error); - req.onsuccess = (ev: any) => { - const cursor: IDBCursorWithValue | null = ev.target.result; - if (!cursor) { - resolve(); - return; - } - try { - const key = String(cursor.key); - const prefix = `${mapId}:`; - if (key.startsWith(prefix)) { - const voxKey = key.substring(prefix.length); - const info = parseVoxChunkKey(voxKey); - if (info) { - const value = cursor.value as ArrayBuffer; - const cloned = value.slice(0); - const uploadPromise = onChunk({ lod: info.lod, x: info.x, y: info.y, z: info.z, value: cloned }); - pendingUploads.push(uploadPromise); - } - } - cursor.continue(); - } catch (e) { - reject(e); - } - }; - }); - for (const p of pendingUploads) { - await p; - } -} diff --git a/src/voxel_annotation/frontend.ts b/src/voxel_annotation/frontend.ts deleted file mode 100644 index ac294b8b16..0000000000 --- a/src/voxel_annotation/frontend.ts +++ /dev/null @@ -1,444 +0,0 @@ -/** - * @license - * Copyright 2025. - */ - -import { ChunkState } from "#src/chunk_manager/base.js"; -import type { ChunkManager } from "#src/chunk_manager/frontend.js"; -import type { VolumeChunkSpecification } from "#src/sliceview/volume/base.js"; -import type { VolumeChunk } from "#src/sliceview/volume/frontend.js"; -import { VolumeChunkSource as BaseVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; -import { animationFrameDebounce } from "#src/util/animation_frame_debounce.js"; -import type { TypedArray } from "#src/util/array.js"; -import { - VOX_CHUNK_SOURCE_RPC_ID, - VOX_MAP_INIT_RPC_ID, - makeVoxChunkKey, -} from "#src/voxel_annotation/base.js"; -import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; -import { registerSharedObjectOwner } from "#src/worker_rpc.js"; - -/** - * Frontend owner for VoxChunkSource, extended with a local optimistic edit overlay. - */ -@registerSharedObjectOwner(VOX_CHUNK_SOURCE_RPC_ID) -export class VoxChunkSource extends BaseVolumeChunkSource { - declare OPTIONS: { - spec: VolumeChunkSpecification; - lodFactor?: number; - }; - private tempVoxChunkGridPosition = new Float32Array(3); - private tempLocalPosition = new Uint32Array(3); - private dirtyChunks = new Set(); - private scheduleProcessPendingUploads = animationFrameDebounce(() => - this.processPendingUploads(), - ); - private lodFactor: number; - - /** Initialize map in the worker/backend for this source. */ - initializeMap(map: VoxMapConfig) { - try { - this.rpc!.invoke(VOX_MAP_INIT_RPC_ID, { id: this.rpcId, map }); - } catch { - // initialization is best-effort; continue even if it fails - console.warn( - "VoxChunkSource.initializeMap: Failed to initialize voxel map.", - ); - } - } - - constructor( - chunkManager: ChunkManager, - options: { spec: VolumeChunkSpecification; lodFactor?: number }, - ) { - super(chunkManager, options); - this.lodFactor = options.lodFactor ?? 1; - } - - override initializeCounterpart(rpc: any, options: any) { - const opts = { ...(options || {}), spec: this.spec }; - opts.lodFactor = this.lodFactor; - super.initializeCounterpart(rpc, opts); - } - - static override encodeOptions(options: { - spec: VolumeChunkSpecification; - vox?: { serverUrl?: string; token?: string }; - lodFactor?: number; - }) { - const base = (BaseVolumeChunkSource as any).encodeOptions(options); - if (options?.vox) { - (base as any).vox = { - serverUrl: options.vox.serverUrl, - token: options.vox.token, - }; - } - if (options?.lodFactor) { - (base as any).lodFactor = options.lodFactor; - } - return base; - } - - private scheduleUpdate(key: string) { - this.dirtyChunks.add(key); - this.scheduleProcessPendingUploads(); - } - - private processPendingUploads() { - const remaining = new Set(); - for (const key of this.dirtyChunks) { - const chunk = this.chunks.get(key) as VolumeChunk | undefined; - const cpuArray = chunk ? this.getCpuArrayForChunk(chunk) : null; - if (chunk && cpuArray) { - this.invalidateChunkUpload(chunk); - } else { - remaining.add(key); - } - } - this.dirtyChunks = remaining; - this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); - } - - invalidateChunksByKey(keys: string[]) { - console.log("invalidateChunksByKey", this.lodFactor, keys); - // TODO: Avoid invalidating the whole cache, instead invalidate only the chunks that are affected by the edits. - this.invalidateCache(); - } - - /** Batch paint API to minimize GPU uploads by chunk. Returns backend edits payload. */ - paintVoxelsBatch(voxels: Float32Array[], value: number): { key: string; indices: number[]; value: number }[] { - if (!voxels || voxels.length === 0) return []; - const indicesByInnerKey = new Map(); - const editsByFullKey = new Map(); - const chunksToUpdate = new Set(); - - for (const v of voxels) { - if (!v) continue; - const { key, canonicalIndex, chunkLocalIndex } = this.computeIndices(v); - // Immediate draw on CPU array if present - if (chunkLocalIndex >= 0) { - const chunk = this.chunks.get(key) as VolumeChunk | undefined; - const cpuArray = chunk ? this.getCpuArrayForChunk(chunk) : null; - - if (cpuArray) { - (cpuArray as any)[chunkLocalIndex] = value as any; - } - chunksToUpdate.add(key); - } - - let arrInner = indicesByInnerKey.get(key); - if (!arrInner) indicesByInnerKey.set(key, (arrInner = [])); - arrInner.push(canonicalIndex); - } - - // Schedule GPU uploads for updated chunks (using inner keys) - for (const key of chunksToUpdate) this.scheduleUpdate(key); - - // Build backend edits payload using full keys (including LOD) - for (const [innerKey, indices] of indicesByInnerKey.entries()) { - const fullKey = makeVoxChunkKey(innerKey, this.lodFactor); - editsByFullKey.set(fullKey, indices); - } - - const edits: { key: string; indices: number[]; value: number }[] = []; - for (const [key, indices] of editsByFullKey.entries()) { - edits.push({ key, indices, value }); - } - return edits; - } - - /** getValueAt simply defers to base; edits are persisted in backend and applied to CPU array when present. */ - override getValueAt(chunkPosition: Float32Array, channelAccess: any) { - return super.getValueAt(chunkPosition, channelAccess); - } - - private localIndexFromLocalPosition(local: Uint32Array, size: Uint32Array) { - // (z * sy + y) * sx + x - return (local[2] * size[1] + local[1]) * size[0] + local[0]; - } - - private computeIndices(voxel: Float32Array) { - const rank = this.spec.rank; - const { baseVoxelOffset, chunkDataSize } = this.spec as any; - const keyParts = this.tempVoxChunkGridPosition; - const local = this.tempLocalPosition; - - for (let i = 0; i < rank; ++i) { - const v = (voxel[i] as number) - baseVoxelOffset[i]; - const size = chunkDataSize[i]; - const c = Math.floor(v / size); - keyParts[i] = c; - local[i] = Math.floor(v - c * size); - } - - const key = `${keyParts[0]},${keyParts[1]},${keyParts[2]}`; - - const canonicalIndex = this.localIndexFromLocalPosition( - local, - this.spec.chunkDataSize as Uint32Array, - ); - - const chunk = this.chunks.get(key) as VolumeChunk | undefined; - let chunkLocalIndex = -1; - if (chunk) { - const cds = chunk.chunkDataSize as Uint32Array; - if (local[0] < cds[0] && local[1] < cds[1] && local[2] < cds[2]) { - chunkLocalIndex = this.localIndexFromLocalPosition(local, cds); - } - } - return { key, canonicalIndex, chunkLocalIndex }; - } - - private getCpuArrayForChunk(chunk: VolumeChunk): TypedArray | null { - const data = (chunk as any).data as TypedArray | null | undefined; - return data ?? null; - } - - - private morphologicalConfig = { - // At what `filledCount` thresholds the neighborhood size increases. - growthThresholds: [ - { count: 1000, size: 3 }, // Requires 3px thick channels - { count: 10000, size: 5 }, // Requires 5px thick channels - { count: 100000, size: 7 }, // Requires 7px thick channels - ], - maxSize: 9, - }; - - /** - * 2D flood fill with thickness constraint to prevent leaking through narrow passages - */ - floodFillPlane2D( - startVoxelLod: Float32Array, - fillValue: number, - maxVoxels: number, - ): { edits: { key: string; indices: number[]; value: number }[]; filledCount: number; originalValue: number } { - if (!startVoxelLod || startVoxelLod.length < 3) { - throw new Error("VoxChunkSource.floodFillPlane2D: startVoxelLod must be Float32Array[3]."); - } - if (!Number.isFinite(maxVoxels) || maxVoxels <= 0) { - throw new Error("VoxChunkSource.floodFillPlane2D: maxVoxels must be > 0."); - } - - const seed = new Float32Array([ - Math.floor(startVoxelLod[0] ?? NaN), - Math.floor(startVoxelLod[1] ?? NaN), - Math.floor(startVoxelLod[2] ?? NaN), - ]); - - if (!Number.isFinite(seed[0]) || !Number.isFinite(seed[1]) || !Number.isFinite(seed[2])) { - throw new Error("VoxChunkSource.floodFillPlane2D: startVoxelLod contains invalid coordinates."); - } - - const seedIdx = this.computeIndices(seed); - const seedChunk = this.chunks.get(seedIdx.key) as VolumeChunk | undefined; - const seedCpu = seedChunk ? this.getCpuArrayForChunk(seedChunk) : null; - if (!seedCpu || seedIdx.chunkLocalIndex < 0) { - throw new Error("VoxChunkSource.floodFillPlane2D: seed lies in an unloaded chunk or out of bounds."); - } - - const originalValue = Number((seedCpu as any)[seedIdx.chunkLocalIndex] ?? NaN); - if (!Number.isFinite(originalValue)) { - throw new Error("VoxChunkSource.floodFillPlane2D: unable to read seed value."); - } - if ((originalValue >>> 0) === (fillValue >>> 0)) { - return { edits: [], filledCount: 0, originalValue }; - } - - const zPlane = seed[2] | 0; - const visited = new Set(); - const queue: [number, number][] = []; - const indicesByInnerKey = new Map(); - const localIndicesByInnerKey = new Map(); - let filledCount = 0; - - const isOriginalAt = (px: number, py: number): boolean => { - const voxel = new Float32Array([px, py, zPlane]); - const { key, chunkLocalIndex } = this.computeIndices(voxel); - const chunk = this.chunks.get(key) as VolumeChunk | undefined; - const cpu = chunk ? this.getCpuArrayForChunk(chunk) : null; - if (!cpu || chunkLocalIndex < 0) { - // For thickness checking, treat unloaded as non-original (conservative approach) - return false; - } - const v = Number((cpu as any)[chunkLocalIndex]); - return (v >>> 0) === (originalValue >>> 0); - }; - - const scheduleFill = (x: number, y: number) => { - if (filledCount >= maxVoxels) { - throw new Error(`VoxChunkSource.floodFillPlane2D: region exceeds maxVoxels (${maxVoxels}).`); - } - - const voxel = new Float32Array([x, y, zPlane]); - const { key, canonicalIndex, chunkLocalIndex } = this.computeIndices(voxel); - - let arr = indicesByInnerKey.get(key); - if (!arr) indicesByInnerKey.set(key, (arr = [])); - arr.push(canonicalIndex); - - let locals = localIndicesByInnerKey.get(key); - if (!locals) localIndicesByInnerKey.set(key, (locals = [])); - locals.push(chunkLocalIndex); - filledCount++; - }; - - const getCurrentThickness = (): number => { - let thickness = 1; - for (const threshold of this.morphologicalConfig.growthThresholds) { - if (filledCount >= threshold.count) { - thickness = Math.max(thickness, threshold.size); - } - } - return Math.min(thickness, this.morphologicalConfig.maxSize); - }; - - const hasThickEnoughChannel = ( - x: number, - y: number, - nx: number, - ny: number, - requiredThickness: number - ): boolean => { - if (requiredThickness <= 1) return true; // No thickness constraint - - const dx = nx - x; - const dy = ny - y; - - // Only allow exactly one-axis moves (4-connectivity) - if ((dx === 0) === (dy === 0)) return false; - - const halfThickness = Math.floor(requiredThickness / 2); - - if (dx !== 0) { - // Horizontal move: check vertical thickness at BOTH current and destination - // We need the channel to be thick enough along the entire path - for (const checkX of [x, nx]) { - for (let offset = -halfThickness; offset <= halfThickness; offset++) { - if (!isOriginalAt(checkX, ny + offset)) { - return false; // Channel not thick enough - } - } - } - } else { - // Vertical move: check horizontal thickness at BOTH current and destination - for (const checkY of [y, ny]) { - for (let offset = -halfThickness; offset <= halfThickness; offset++) { - if (!isOriginalAt(nx + offset, checkY)) { - return false; // Channel not thick enough - } - } - } - } - - return true; - }; - - const fillBorderRegion = ( - startX: number, - startY: number, - requiredThickness: number - ) => { - const subQueue: [number, number][] = []; - const halfThickness = Math.floor(requiredThickness / 2) + 1; - - const k = `${startX},${startY}`; - if (visited.has(k)) return; - - subQueue.push([startX, startY]); - visited.add(k); // Mark as visited immediately to avoid re-processing - - while (subQueue.length > 0) { - const [cx, cy] = subQueue.shift()!; - scheduleFill(cx, cy); // Schedule the current pixel of the sub-fill - - const neighbors: [number, number][] = [[cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]]; - for (const [nnx, nny] of neighbors) { - if (nnx < startX - halfThickness || nnx > startX + halfThickness || - nny < startY - halfThickness || nny > startY + halfThickness) { - continue; // Outside the bounding box - } - const nk = `${nnx},${nny}`; - if (visited.has(nk)) continue; - - if (isOriginalAt(nnx, nny)) { - visited.add(nk); - subQueue.push([nnx, nny]); - } - } - } - }; - - // Seed the queue - queue.push([seed[0] | 0, seed[1] | 0]); - visited.add(`${seed[0] | 0},${seed[1] | 0}`); - - // BFS with thickness constraints - while (queue.length > 0) { - const [x, y] = queue.shift()!; - - // Schedule this pixel for filling - scheduleFill(x, y); - - // Get current thickness requirement - const requiredThickness = getCurrentThickness(); - - // Check 4-neighbors - const neighbors: [number, number][] = [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]; - for (const [nx, ny] of neighbors) { - const k = `${nx},${ny}`; - if (visited.has(k)) continue; - - if (isOriginalAt(nx, ny)) { - // The neighbor is a valid fill target. Now check if we can propagate from it. - if (hasThickEnoughChannel(x, y, nx, ny, requiredThickness)) { - // Channel is thick enough: Add to queue to propagate. - visited.add(k); - queue.push([nx, ny]); - } else { - fillBorderRegion(nx, ny, requiredThickness); - } - } - } - } - - // Apply changes to CPU arrays and schedule updates - const chunksToUpdate = new Set(); - for (const [innerKey, localIndices] of localIndicesByInnerKey.entries()) { - const chunk = this.chunks.get(innerKey) as VolumeChunk | undefined; - const cpu = chunk ? this.getCpuArrayForChunk(chunk) : null; - if (!cpu) { - throw new Error(`VoxChunkSource.floodFillPlane2D: missing CPU array for key=${innerKey}`); - } - for (const li of localIndices) { - (cpu as any)[li] = fillValue as any; - } - chunksToUpdate.add(innerKey); - } - - for (const key of chunksToUpdate) this.scheduleUpdate(key); - - // Build backend edits payload - const edits: { key: string; indices: number[]; value: number }[] = []; - for (const [innerKey, indices] of indicesByInnerKey.entries()) { - const fullKey = makeVoxChunkKey(innerKey, this.lodFactor); - edits.push({ key: fullKey, indices, value: fillValue >>> 0 }); - } - - return { edits, filledCount, originalValue: originalValue >>> 0 }; - } - - private invalidateChunkUpload(chunk: VolumeChunk) { - const gl = chunk.gl; - const anyChunk = chunk as any; - if ( - chunk.state === ChunkState.GPU_MEMORY && - typeof anyChunk.updateFromCpuData === "function" - ) { - anyChunk.updateFromCpuData(gl); - return; - } - chunk.copyToGPU(gl); - } -} - diff --git a/src/voxel_annotation/import_from_zarr.ts b/src/voxel_annotation/import_from_zarr.ts deleted file mode 100644 index fc3cab7bf2..0000000000 --- a/src/voxel_annotation/import_from_zarr.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { DataType, DATA_TYPE_BYTES } from "#src/util/data_type.js"; -import { parseVoxChunkKey } from "#src/voxel_annotation/base.js"; -import type { SavedChunk } from "#src/voxel_annotation/index.js"; -import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; - -function toDataTypeEnum(dt: number): DataType { - switch (dt) { - case DataType.UINT8: - case DataType.INT8: - case DataType.UINT16: - case DataType.INT16: - case DataType.UINT32: - case DataType.INT32: - case DataType.UINT64: - case DataType.FLOAT32: - return dt as DataType; - default: - throw new Error(`Invalid DataType value: ${dt}`); - } -} - -/** Normalize supported base URLs to an HTTP(S) base that ends with '/'. */ -function normalizeZarrBaseUrl(url: string): string { - if (!url || typeof url !== "string") { - throw new Error("normalizeZarrBaseUrl: url must be a non-empty string"); - } - const trimmed = url.trim(); - // Neuroglancer canonical URLs may append a driver suffix after a '|' (e.g., "|zarr2:/" or "|n5"). - // Strip any such suffix before normalizing the base path to an HTTP(S) URL. - const pipeIndex = trimmed.indexOf("|"); - const basePart = pipeIndex >= 0 ? trimmed.substring(0, pipeIndex) : trimmed; - - const ensureTrailingSlash = (u: string) => (u.endsWith("/") ? u : `${u}/`); - - // Allow explicit zarr+http(s):// or zarr:// prefixes as hints. - if (basePart.startsWith("zarr+http://")) { - return ensureTrailingSlash(basePart.substring("zarr+".length)); - } - if (basePart.startsWith("zarr+https://")) { - return ensureTrailingSlash(basePart.substring("zarr+".length)); - } - if (basePart.startsWith("zarr://")) { - const rest = basePart.substring("zarr://".length); - if (rest.startsWith("http://") || rest.startsWith("https://")) { - return ensureTrailingSlash(rest); - } - // Treat as https by default if scheme omitted after zarr:// - return ensureTrailingSlash(`https://${rest}`); - } - - // Direct HTTP(S) endpoints, e.g. MinIO: http://localhost:9000/zarr/mydataset/ - if (basePart.startsWith("http://") || basePart.startsWith("https://")) { - return ensureTrailingSlash(basePart); - } - // S3-compatible explicit endpoint, e.g. s3+http://localhost:9000/zarr/mydataset/ - if (basePart.startsWith("s3+http://")) { - return ensureTrailingSlash(basePart.substring("s3+".length)); - } - if (basePart.startsWith("s3+https://")) { - return ensureTrailingSlash(basePart.substring("s3+".length)); - } - // AWS-style shorthand: s3://bucket/path → https://bucket.s3.amazonaws.com/path - if (basePart.startsWith("s3://")) { - const rest = basePart.substring("s3://".length); - const firstSlash = rest.indexOf("/"); - if (firstSlash < 0) { - throw new Error("normalizeZarrBaseUrl: s3:// URL must include a path prefix"); - } - const bucket = rest.substring(0, firstSlash); - const keyPrefix = rest.substring(firstSlash + 1); - if (bucket.length === 0) throw new Error("normalizeZarrBaseUrl: missing bucket in s3 URL"); - return ensureTrailingSlash(`https://${bucket}.s3.amazonaws.com/${keyPrefix}`); - } - throw new Error( - `normalizeZarrBaseUrl: Unsupported URL scheme; use http(s)://, s3+http(s)://, s3://, or zarr(+http(s)):// (got: ${url})`, - ); -} - -function joinUrl(base: string, path: string): string { - if (!base.endsWith("/")) throw new Error("joinUrl: base must end with '/'"); - if (!path) throw new Error("joinUrl: path must be non-empty"); - if (path.startsWith("/")) path = path.substring(1); - return base + path; -} - -async function fetchBinary(url: string, signal?: AbortSignal): Promise { - const resp = await fetch(url, { method: "GET", signal }); - if (resp.status === 404) return undefined; - if (!resp.ok) { - throw new Error(`Failed to GET ${url}: ${resp.status} ${resp.statusText}`); - } - return await resp.arrayBuffer(); -} - -function constructTypedArray(dataType: DataType, buffer: ArrayBuffer): Uint8Array | Int8Array | Uint16Array | Int16Array | Uint32Array | Int32Array | BigUint64Array | Float32Array { - switch (dataType) { - case DataType.UINT8: return new Uint8Array(buffer); - case DataType.INT8: return new Int8Array(buffer); - case DataType.UINT16: return new Uint16Array(buffer); - case DataType.INT16: return new Int16Array(buffer); - case DataType.UINT32: return new Uint32Array(buffer); - case DataType.INT32: return new Int32Array(buffer); - case DataType.UINT64: return new BigUint64Array(buffer); - case DataType.FLOAT32: return new Float32Array(buffer); - default: - throw new Error(`Unsupported dataType for zarr import: ${dataType}`); - } -} - -export async function fetchZarrChunkIfAvailable(mapCfg: VoxMapConfig | undefined, voxKey: string, signal?: AbortSignal): Promise { - if (!mapCfg) throw new Error("fetchZarrChunkIfAvailable: mapCfg is required"); - const importUrl = mapCfg.importUrl; - if (!importUrl) return undefined; - const info = parseVoxChunkKey(voxKey); - if (!info) throw new Error(`fetchZarrChunkIfAvailable: invalid voxKey: ${voxKey}`); - const baseUrl = normalizeZarrBaseUrl(importUrl); - const chunkPath = `${info.lod}/${info.z}.${info.y}.${info.x}`; - const url = joinUrl(baseUrl, chunkPath); - const buf = await fetchBinary(url, signal); - if (buf === undefined) return undefined; // Not present remotely - - const expectedCount = (mapCfg.chunkDataSize[0] | 0) * (mapCfg.chunkDataSize[1] | 0) * (mapCfg.chunkDataSize[2] | 0); - const dataTypeEnum = toDataTypeEnum(Number(mapCfg.dataType)); - const bytesPer = DATA_TYPE_BYTES[dataTypeEnum]; - const expectedBytes = expectedCount * bytesPer; - if (buf.byteLength !== expectedBytes) { - throw new Error(`Zarr chunk size mismatch for ${voxKey}: expected ${expectedBytes}B, got ${buf.byteLength}B`); - } - const arr = constructTypedArray(dataTypeEnum, buf) as unknown as Uint32Array | BigUint64Array | any; - const saved: SavedChunk = { data: arr, size: new Uint32Array(mapCfg.chunkDataSize as any) }; - return saved; -} diff --git a/src/voxel_annotation/index.ts b/src/voxel_annotation/index.ts deleted file mode 100644 index bd6ae8b80d..0000000000 --- a/src/voxel_annotation/index.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Local/Remote voxel annotation data sources and a shared base. - * The LocalVoxSource persists per-chunk arrays into IndexedDB with a debounced saver. - */ - -import type { VoxelEditController } from "#src/voxel_annotation/edit_backend.js"; -import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; - - -export interface SavedChunk { - data: Uint32Array | BigUint64Array; // Supports UINT32 and UINT64 - size: Uint32Array; // canonical size used for linearization (usually spec.chunkDataSize) -} - -export function compositeChunkDbKey( - mapId: string, - chunkKey: string, -): string { - return `${mapId}:${chunkKey}`; -} - -export function compositeLabelsDbKey(mapId: string): string { - return `${mapId}:labels`; -} - -export abstract class VoxSource { - protected mapId: string = "default"; - protected mapCfg: VoxMapConfig; // Keep the entire configuration in one place - - init(map: VoxMapConfig): Promise<{ mapId: string}> { - if(!map) - { - throw new Error("VoxSource: init: Map config is required"); - } - this.mapCfg = map; - this.mapId = map.id; - return Promise.resolve({ mapId: this.mapId }); - } - - // Abstract persistence API the backend expects - abstract getSavedChunk(key: string): Promise; -} - - -export abstract class VoxSourceWriter extends VoxSource { - /** - * Optional listing of available maps for the current source. - * Remote sources should query their endpoint; local may enumerate local IndexedDB entries. - */ - async listMaps(_args?: { baseUrl?: string; token?: string }): Promise { - return []; - } - - // In-memory cache of loaded chunks - protected maxSavedChunks = 256; // cap to prevent unbounded growth - protected saved = new Map(); - - // Dirty tracking and debounced save - protected dirty = new Set(); - protected saveTimer: number | undefined; - editController?: VoxelEditController; - - constructor(editController?: VoxelEditController) { - super(); - this.editController = editController; - } - - /** - * Generic label persistence hooks. Subclasses override to connect to the chosen datasource. - * Default implementation is a no-op empty list. - */ - async getLabelIds(): Promise { - return []; - } - async addLabel(_value: number): Promise { - // Default: pretend success with no labels - return []; - } - - callChunkReload(voxChunkKeys: string[]) { - if (!this.editController) { - throw new Error("VoxSourceWriter.callChunkReload: editController not set"); - } - this.editController.callChunkReload(voxChunkKeys); - } - - // Common helpers - protected markDirty(key: string) { - this.dirty.add(key); - this.scheduleSave(); - } - - protected scheduleSave() { - if (this.saveTimer !== undefined) return; - // Debounce writes ~750ms - this.saveTimer = setTimeout( - () => this.flushSaves(), - 750, - ) as unknown as number; - } - - // Overridden by subclass to actually persist dirty chunks. - protected async flushSaves(): Promise {} - - // Abstract persistence API the backend expects - abstract ensureChunk( - key: string, - size?: Uint32Array | number[], - ): Promise; - abstract applyEdits( - edits: { - key: string; - indices: ArrayLike; - value?: number; - values?: ArrayLike; - size?: number[]; - }[], - ): Promise; - - // Apply edits into an in-memory chunk array; returns the SavedChunk. - protected applyEditsIntoChunk( - sc: SavedChunk, - indices: ArrayLike, - value?: number, - values?: ArrayLike, - ) { - const dst = sc.data as any; - const is64 = dst instanceof BigUint64Array; - if (values != null) { - const vv = values as ArrayLike; - const n = Math.min((indices as any).length ?? 0, (vv as any).length ?? 0); - for (let i = 0; i < n; ++i) { - const idx = (indices as any)[i] | 0; - if (idx >= 0 && idx < dst.length) { - const v = (vv as any)[i] >>> 0; - dst[idx] = is64 ? BigInt(v) : v; - } - } - } else if (value != null) { - const vNum = value >>> 0; - const v = (is64 ? BigInt(vNum) : vNum) as any; - const n = (indices as any).length ?? 0; - for (let i = 0; i < n; ++i) { - const idx = (indices as any)[i] | 0; - if (idx >= 0 && idx < dst.length) dst[idx] = v; - } - } - return sc; - } -} diff --git a/src/voxel_annotation/local_source.ts b/src/voxel_annotation/local_source.ts deleted file mode 100644 index 934599541d..0000000000 --- a/src/voxel_annotation/local_source.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { IndexedDBKvStore } from "#src/kvstore/indexeddb/implementation.js"; -import { fetchZarrChunkIfAvailable } from "#src/voxel_annotation/import_from_zarr.js"; -import type { SavedChunk} from "#src/voxel_annotation/index.js"; -import { VoxSourceWriter , - compositeChunkDbKey, - compositeLabelsDbKey, - VoxSource, -} from "#src/voxel_annotation/index.js"; -import type { - VoxMapConfig} from "#src/voxel_annotation/map.js"; -import { - constructVoxMapConfig -, computeSteps } from "#src/voxel_annotation/map.js"; - -// Simple read only local source, this class can be instantiated multiple times without side effects. -export class LocalVoxSource extends VoxSource { - protected kvStore: IndexedDBKvStore; - protected labelsKvStore: IndexedDBKvStore; - - override async init(map: VoxMapConfig): Promise<{ mapId: string }> { - const meta = await super.init(map); - this.kvStore = new IndexedDBKvStore("neuroglancer_vox", "chunks"); - this.labelsKvStore = new IndexedDBKvStore("neuroglancer_vox", "labels"); - return meta; - } - - async getSavedChunk(key: string): Promise { - const compositeKey = compositeChunkDbKey(this.mapId, key); - const readResponse = await this.kvStore.read(compositeKey, {}); - if (readResponse) { - const buffer = await readResponse.response.arrayBuffer(); - const dataArray = new Uint32Array(buffer); - return { - data: dataArray, - size: new Uint32Array(this.mapCfg!.chunkDataSize as any), - }; - } - // Fallback to remote Zarr import if available - const remote = await fetchZarrChunkIfAvailable(this.mapCfg, key); - if (remote) { - return remote; - } - return undefined; - } -} - -/** IndexedDB-backed local source. */ -// More complete local source that supports writing and more, THIS CLASS SHOULD NOT BE INSTANTIATED MULTIPLE TIMES per maps -export class LocalVoxSourceWriter extends VoxSourceWriter { - - protected kvStore: IndexedDBKvStore; - protected labelsKvStore: IndexedDBKvStore; - - private ensureArrayBuffer(value: ArrayBufferLike): ArrayBuffer { - if (value instanceof ArrayBuffer) return value; - const out = new ArrayBuffer(value.byteLength); - new Uint8Array(out).set(new Uint8Array(value)); - return out; - } - - - override async listMaps(): Promise { - try { - const db = await this.getDb(); - const tx = db.transaction("maps", "readonly"); - const store = tx.objectStore("maps"); - const getAll = (store as any).getAll?.bind(store); - const rows: any[] = await new Promise((resolve, reject) => { - if (getAll) { - const req = getAll(); - req.onerror = () => reject(req.error); - req.onsuccess = () => resolve(req.result || []); - return; - } - const out: any[] = []; - const req = store.openCursor(); - req.onerror = () => reject(req.error); - req.onsuccess = (ev: any) => { - const cursor = ev.target.result as IDBCursorWithValue | null; - if (cursor) { - out.push(cursor.value); - cursor.continue(); - } else { - resolve(out); - } - }; - }); - const maps: VoxMapConfig[] = []; - for (const r of rows) { - try { - if ( - r?.id === undefined || - r?.baseVoxelOffset === undefined || - r?.upperVoxelBound === undefined || - r?.chunkDataSize === undefined || - r?.dataType === undefined || - r?.scaleMeters === undefined || - r?.unit === undefined - ) { - throw new Error("Invalid map configuration"); - } - const id = String(r.id); - const lower = Array.from(r.baseVoxelOffset).map((v: any) => - Number(v), - ) as number[]; - const upper = Array.from(r.upperVoxelBound).map((v: any) => - Number(v), - ) as number[]; - const cds = Array.from(r.chunkDataSize).map((v: any) => - Math.max(1, Number(v)), - ) as number[]; - const bounds = [ - (upper[0] | 0) - (lower[0] | 0), - (upper[1] | 0) - (lower[1] | 0), - (upper[2] | 0) - (lower[2] | 0), - ]; - const steps = computeSteps(bounds, cds); - const map = constructVoxMapConfig({ - id, - name: r?.name ?? id, - baseVoxelOffset: new Float32Array(lower), - upperVoxelBound: new Float32Array(upper), - chunkDataSize: new Uint32Array(cds), - dataType: Number(r.dataType), - scaleMeters: Array.from(r.scaleMeters ?? [1,1,1]), - unit: String(r.unit), - steps: Array.isArray(r?.steps) ? r.steps : steps, - importUrl: r?.importUrl, - }); - maps.push(map); - } catch { - // skip malformed - } - } - return maps; - } catch { - return [] as VoxMapConfig[]; - } - } - private dbPromise: Promise | null = null; - - override async getLabelIds(): Promise { - try { - const key = compositeLabelsDbKey(this.mapId); - const readResponse = await this.labelsKvStore.read(key, {}); - if (!readResponse) return []; - const buffer = await readResponse.response.arrayBuffer(); - const text = new TextDecoder().decode(new Uint8Array(buffer)); - const arr = JSON.parse(text); - if (!Array.isArray(arr)) return []; - return arr.map((v: unknown) => Number(v) >>> 0); - } catch { - return []; - } - } - - - override async addLabel(value: number): Promise { - const v = value >>> 0; - const key = compositeLabelsDbKey(this.mapId); - const readResponse = await this.labelsKvStore.read(key, {} as any); - let arr: number[] = []; - if (readResponse) { - const buffer = await readResponse.response.arrayBuffer(); - const text = new TextDecoder().decode(new Uint8Array(buffer)); - const parsed = JSON.parse(text); - if (Array.isArray(parsed)) arr = parsed.map((x: unknown) => Number(x) >>> 0); - } - if (!arr.some((x) => x === v)) arr.push(v); - const encoded = new TextEncoder().encode(JSON.stringify(arr)); - await this.labelsKvStore.write(key, this.ensureArrayBuffer(encoded.buffer)); - return arr.slice(); - } - - private touch(key: string) { - const v = this.saved.get(key); - if (!v) return; - this.saved.delete(key); - this.saved.set(key, v); - } - - private enforceCap() { - // Evict only non-dirty entries to avoid losing unsaved edits. - while (this.saved.size > this.maxSavedChunks) { - let oldestKey: string | undefined = undefined; - for (const k of this.saved.keys()) { - if (!this.dirty.has(k)) { - oldestKey = k; - break; - } - } - if (oldestKey === undefined) { - // All entries are dirty; wait until they are flushed before evicting. - break; - } - this.saved.delete(oldestKey); - } - } - - override async init(map: VoxMapConfig) { - const meta = await super.init(map); - this.kvStore = new IndexedDBKvStore("neuroglancer_vox", "chunks"); - this.labelsKvStore = new IndexedDBKvStore("neuroglancer_vox", "labels"); - return meta; - } - - async getSavedChunk(key: string): Promise { - const existing = this.saved.get(key); - if (existing) { - this.touch(key); - return existing; - } - const composite = compositeChunkDbKey(this.mapId, key); - const readResponse = await this.kvStore.read(composite, {}); - if (readResponse) { - const buffer = await readResponse.response.arrayBuffer(); - const arr = new Uint32Array(buffer); - const sc: SavedChunk = { - data: arr, - size: new Uint32Array(this.mapCfg!.chunkDataSize as any), - }; - this.saved.set(key, sc); - this.enforceCap(); - return sc; - } - // Try remote Zarr import on miss - const remote = await fetchZarrChunkIfAvailable(this.mapCfg, key); - if (remote) { - this.saved.set(key, remote); - this.enforceCap(); - await this.kvStore.write(composite, this.ensureArrayBuffer(remote.data.buffer)); - return remote; - } - return undefined; - } - - async ensureChunk( - key: string, - size?: Uint32Array | number[], - ): Promise { - let sc = this.saved.get(key); - if (sc) { - this.touch(key); - return sc; - } - const composite = compositeChunkDbKey(this.mapId, key); - const readResponse = await this.kvStore.read(composite, {}); - if (readResponse) { - const buffer = await readResponse.response.arrayBuffer(); - const arr = new Uint32Array(buffer); - sc = { data: arr, size: new Uint32Array(this.mapCfg!.chunkDataSize as any) }; - this.saved.set(key, sc); - this.enforceCap(); - return sc; - } - // Try fetching from remote Zarr before allocating an empty chunk - const remote = await fetchZarrChunkIfAvailable(this.mapCfg, key); - if (remote) { - sc = remote; - this.saved.set(key, sc); - this.enforceCap(); - await this.kvStore.write(composite, this.ensureArrayBuffer(sc.data.buffer)); - return sc; - } - const fallbackSize = new Uint32Array(this.mapCfg!.chunkDataSize as any); - const sz = new Uint32Array(size ?? fallbackSize); - let total = 1; - for (let i = 0; i < 3; ++i) total *= sz[i]; - const arr = new Uint32Array(total); - sc = { data: arr, size: new Uint32Array(sz) }; - this.saved.set(key, sc); - this.enforceCap(); - return sc; - } - - async applyEdits( - edits: { - key: string; - indices: ArrayLike; - value?: number; - values?: ArrayLike; - size?: number[]; - }[], - ) { - for (const e of edits) { - const count = (e.indices as any)?.length | 0; - if (count <= 0) { - continue; - } - const sc = await this.ensureChunk( - e.key, - e.size ? new Uint32Array(e.size) : (this.mapCfg!.chunkDataSize as any), - ); - this.applyEditsIntoChunk(sc, e.indices, e.value, e.values); - this.markDirty(e.key); - } - } - - - protected override async flushSaves() { - const keys = Array.from(this.dirty); - if (keys.length === 0) { - this.saveTimer = undefined; - return; - } - this.dirty.clear(); - const flushedKeys: string[] = []; - for (const key of keys) { - const sc = this.saved.get(key); - if (!sc) continue; - if (this._isAllZero(sc.data)) continue; - const composite = compositeChunkDbKey(this.mapId, key); - await this.kvStore.write(composite, this.ensureArrayBuffer(sc.data.buffer)); - flushedKeys.push(key); - } - this.saveTimer = undefined; - setTimeout(() => { - this.callChunkReload(flushedKeys); - }, 100); - } - - private async getDb(): Promise { - if (this.dbPromise) return this.dbPromise; - this.dbPromise = openVoxDb(); - return this.dbPromise; - } - - private _isAllZero(arr: Uint32Array | BigUint64Array): boolean { - for (let i = 0; i < arr.length; i++) { - if ((arr as any)[i] !== 0 && (arr as any)[i] !== 0n) return false; - } - return true; - } -} - -export function openVoxDb(): Promise { - return new Promise((resolve, reject) => { - const req = indexedDB.open("neuroglancer_vox", 3); - req.onerror = () => reject(req.error); - req.onupgradeneeded = () => { - const db = req.result; - if (!db.objectStoreNames.contains("maps")) db.createObjectStore("maps"); - if (!db.objectStoreNames.contains("chunks")) db.createObjectStore("chunks"); - if (!db.objectStoreNames.contains("labels")) db.createObjectStore("labels"); - }; - req.onsuccess = () => resolve(req.result); - }); -} diff --git a/src/voxel_annotation/map.ts b/src/voxel_annotation/map.ts deleted file mode 100644 index 6af0671915..0000000000 --- a/src/voxel_annotation/map.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Vox map configuration and registry. Central place to compute and store LOD steps. - * No clamping is applied to the step computation: we simply multiply by `step` - * until the per-slice chunk budget is satisfied, then generate [1, step, ..., S]. - */ - -import { DataType } from "#src/util/data_type.js"; - -export interface VoxMapConfig { - id: string; - name?: string; - // Inclusive-exclusive bounds in voxel coordinates: [baseOffset, upperBound) - baseVoxelOffset: Float32Array | number[]; - upperVoxelBound: Float32Array | number[]; - // Chunking and scale - chunkDataSize: Uint32Array | number[]; - // Data type of the voxel labels (default: uint32) - dataType: number; - scaleMeters: Float64Array | number[]; // physical voxel size in meters - unit: string; // convenience for UI - // Fixed LOD steps (factors), finest → coarsest, starting at 1. - steps: number[]; - // Optional original data source URL for on-demand import of base labels (e.g., precomputed://, zarr://, n5://) - importUrl?: string; -} - -/** - * Compute LOD factors based on bounds and a per-slice chunk budget. - * - step: multiplicative step between levels (e.g., 2) - * - maxChunksPerSlice: approximate chunk budget for XY slice. - * - * Returns [1, step, ..., S] where S is the smallest factor that satisfies the budget. - */ -export function computeSteps( - bounds: readonly number[] | Float32Array, - chunkDataSize: readonly number[] | Uint32Array, - step = 2, - maxChunksPerSlice = 256, -): number[] { - const bx = Math.max(0, Math.floor(bounds[0] ?? 0)); - const by = Math.max(0, Math.floor(bounds[1] ?? 0)); - const cx = Math.max(1, Math.floor(chunkDataSize[0] ?? 1)); - const cy = Math.max(1, Math.floor(chunkDataSize[1] ?? 1)); - - const withinBudget = (factor: number) => { - const chunksX = Math.ceil((bx / Math.max(1, factor)) / cx); - const chunksY = Math.ceil((by / Math.max(1, factor)) / cy); - const chunkCount2D = (chunksX || 0) * (chunksY || 0); - return chunkCount2D <= maxChunksPerSlice; - }; - - let S = 1; - while (!withinBudget(S)) S *= Math.max(1, step); - - const factors: number[] = []; - for (let f = 1; f <= S; f *= Math.max(1, step)) factors.push(f); - if (factors.length === 0) factors.push(1); - return factors; -} - -function toTripleArray(name: string, v: ArrayLike): [number, number, number] { - const a = Array.from(v).map((x) => Number(x)); - if (a.length !== 3) { - throw new Error(`${name} must have length 3, got ${a.length}`); - } - for (let i = 0; i < 3; i++) { - if (!Number.isFinite(a[i])) { - throw new Error(`${name}[${i}] must be a finite number`); - } - } - return [a[0], a[1], a[2]]; -} - -function validateSteps(steps?: number[]): number[] | undefined { - if (!steps) return undefined; - if (!Array.isArray(steps) || steps.length === 0) return undefined; - for (let i = 0; i < steps.length; i++) { - const f = steps[i]; - if (!Number.isInteger(f) || f <= 0) { - throw new Error(`steps[${i}] must be a positive integer`); - } - if (i === 0 && f !== 1) { - throw new Error(`steps must start at 1`); - } - if (i > 0 && f <= steps[i - 1]) { - throw new Error(`steps must be strictly increasing`); - } - } - return steps; -} - -export type VoxMapInput = { - id: string; - name?: string; - baseVoxelOffset: ArrayLike; - upperVoxelBound: ArrayLike; - chunkDataSize: ArrayLike; - dataType: number; - scaleMeters: ArrayLike; - unit: string; - steps?: number[]; - importUrl?: string; -}; - -export function constructVoxMapConfig(input: VoxMapInput): VoxMapConfig { - if (!input || typeof input !== "object") { - throw new Error("constructVoxMapConfig: input is required"); - } - const id = String(input.id || "").trim(); - if (id.length === 0) throw new Error("constructVoxMapConfig: id is required"); - const name = input.name ? String(input.name) : undefined; - - const [bx, by, bz] = toTripleArray("baseVoxelOffset", input.baseVoxelOffset); - const [ux, uy, uz] = toTripleArray("upperVoxelBound", input.upperVoxelBound); - if (!(ux > bx && uy > by && uz > bz)) { - throw new Error("upperVoxelBound must be strictly greater than baseVoxelOffset in all dimensions"); - } - - const [cx, cy, cz] = toTripleArray("chunkDataSize", input.chunkDataSize); - const cds = new Uint32Array([ - Math.max(1, Math.floor(cx)), - Math.max(1, Math.floor(cy)), - Math.max(1, Math.floor(cz)), - ]); - - const scale = toTripleArray("scaleMeters", input.scaleMeters); - if (!(scale[0] > 0 && scale[1] > 0 && scale[2] > 0)) { - throw new Error("scaleMeters must be positive in all dimensions"); - } - - const dt = Number(input.dataType); - if (!Number.isInteger(dt) || dt < DataType.UINT8 || dt > DataType.FLOAT32) { - throw new Error("Invalid dataType"); - } - - const lower = new Float32Array([Math.floor(bx), Math.floor(by), Math.floor(bz)]); - const upper = new Float32Array([Math.floor(ux), Math.floor(uy), Math.floor(uz)]); - const bounds = [upper[0] - lower[0], upper[1] - lower[1], upper[2] - lower[2]]; - - const steps = validateSteps(input.steps) ?? computeSteps(bounds, cds); - - const unit = String(input.unit); - if (unit.length === 0) throw new Error("unit is required"); - - return { - id, - name, - baseVoxelOffset: lower, - upperVoxelBound: upper, - chunkDataSize: cds, - dataType: dt, - scaleMeters: new Float64Array(scale), - unit, - steps, - importUrl: input.importUrl, - }; -} - -export function validateVoxMapConfig(map: VoxMapConfig): VoxMapConfig { - return constructVoxMapConfig(map as unknown as VoxMapInput); -} - -/** Simple in-memory registry to hold current map selection and list. */ -export class VoxMapRegistry { - private current?: VoxMapConfig; - private maps: VoxMapConfig[] = []; - - setCurrent(map: VoxMapConfig | undefined) { - this.current = map; - if (map && !this.maps.find((m) => m.id === map.id)) this.maps.push(map); - } - - getCurrent(): VoxMapConfig | undefined { - return this.current; - } - - upsert(map: VoxMapConfig) { - const idx = this.maps.findIndex((m) => m.id === map.id); - if (idx >= 0) this.maps[idx] = map; else this.maps.push(map); - } - - list(): VoxMapConfig[] { - return [...this.maps]; - } -} diff --git a/src/voxel_annotation/volume_chunk_source.ts b/src/voxel_annotation/volume_chunk_source.ts deleted file mode 100644 index 86e88dc877..0000000000 --- a/src/voxel_annotation/volume_chunk_source.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * @license - * Copyright 2024 Google Inc. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { ChunkManager } from "#src/chunk_manager/frontend.js"; -import type { SliceViewSingleResolutionSource } from "#src/sliceview/frontend.js"; -import type { VolumeSourceOptions } from "#src/sliceview/volume/base.js"; -import { - makeVolumeChunkSpecification, - VolumeType, -} from "#src/sliceview/volume/base.js"; -import { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; -import { DataType } from "#src/util/data_type.js"; -import { VoxChunkSource } from "#src/voxel_annotation/frontend.js"; -import type { VoxMapConfig } from "#src/voxel_annotation/map.js"; - -/** - * This is an abstract representation of 3D (volumetric) data that can exist at multiple resolutions or "scales." - * Think of it as a way to access a very large 3D image or a 3D array of values (like segmentation IDs, or in your case, voxel annotation data). - * - * Its primary job is to provide chunks of this volumetric data to the renderer. When you zoom in, zoom out, or pan through the 3D space, - * the `MultiscaleVolumeChunkSource` efficiently determines which resolution and which specific 3D "chunks" of data are needed for the current view and makes them available. - * - * Key Characteristics: - * - Multiscale: It manages different levels of detail for the same underlying data, allowing for efficient rendering at various zoom levels. - * - Chunking: Data is divided into smaller, manageable 3D blocks (chunks) to optimize loading and memory usage. - * - Asynchronous: Data loading is typically asynchronous, as it might involve fetching from a remote server or reading from large local files. - */ -export interface VoxMultiscaleOptions { - map?: VoxMapConfig; -} - -export class VoxMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSource { - dataType = DataType.UINT32; - volumeType = VolumeType.SEGMENTATION; - get rank() { - return 3; - } - - private mapCfg?: VoxMapConfig; - - constructor(chunkManager: ChunkManager, options?: VoxMultiscaleOptions) { - super(chunkManager); - this.mapCfg = options?.map; - if (this.mapCfg?.dataType != null) { - this.dataType = this.mapCfg.dataType as any; - } - } - - getSources(_options: VolumeSourceOptions) { - // Steps are computed during map creation and saved. Here we just consume the bound map. - const map = this.mapCfg; - if (!map) return []; - const rank = this.rank; - - const chunkDataSize = new Uint32Array(Array.from(map.chunkDataSize)); - const upperVoxelBound = new Float32Array(Array.from(map.upperVoxelBound)); - const baseVoxelOffset = new Float32Array(Array.from(map.baseVoxelOffset)); - - const baseSpec = makeVolumeChunkSpecification({ - rank, - dataType: this.dataType, - chunkDataSize, - upperVoxelBound, - baseVoxelOffset, - }); - // Helper to make a homogeneous scaling transform matrix with scale factor f. - const makeScale = (f: number) => { - const m = new Float32Array((rank + 1) * (rank + 1)); - for (let i = 0; i < rank; ++i) m[i * (rank + 1) + i] = f; - m[rank * (rank + 1) + rank] = 1; - return m; - }; - - const factors = map.steps && map.steps.length > 0 ? [...map.steps] : [1]; - - const levels: SliceViewSingleResolutionSource[] = factors.map( - (f) => { - const src: VoxChunkSource = this.chunkManager.getChunkSource( - VoxChunkSource, - { - spec: baseSpec, - lodFactor: f, - }, - ); - return { - chunkSource: src, - chunkToMultiscaleTransform: makeScale(f), - lowerClipBound: baseSpec.lowerVoxelBound, - upperClipBound: baseSpec.upperVoxelBound, - }; - }, - ); - - return [levels]; - } -} From d838eecd9d4b8070a913bb6430c9c7de5174a769 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 062/251] feat: enhance VoxUserLayer with configurable multiscale source options and temporary hardcoded label initialization - Add `SliceViewSourceOptions` to support multiscale-to-view transform configuration. - Implement identity matrix setup for multiscale transforms. - Update default tab to `vox_tools`. - Introduce temporary hardcoded label mode for frontend validation and bypass standard label initialization. - Adjust logging in chunk handler and edit functions for better debugging. - Prevent errors in uninitialized voxel edits by bypassing exceptions in `VoxEditBackend.commitVoxels`. --- src/chunk_worker.bundle.js | 1 + src/layer/vox/index.ts | 23 +++++++++++++++++++++-- src/sliceview/volume/frontend.ts | 2 ++ src/voxel_annotation/edit_backend.ts | 2 +- src/voxel_annotation/labels.ts | 18 ++++++++++++++---- 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/chunk_worker.bundle.js b/src/chunk_worker.bundle.js index a463171535..6141f24968 100644 --- a/src/chunk_worker.bundle.js +++ b/src/chunk_worker.bundle.js @@ -12,3 +12,4 @@ import "#src/annotation/backend.js"; import "#src/datasource/enabled_backend_modules.js"; import "#src/kvstore/enabled_backend_modules.js"; import "#src/worker_rpc_context.js"; +import "#src/voxel_annotation/edit_backend.js" diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 3f00ec66d5..d6db61dbd6 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -35,6 +35,7 @@ import { VoxToolTab } from "#src/layer/vox/tabs/tools.js"; import { trackableRenderScaleTarget, } from "#src/render_scale_statistics.js"; +import type { SliceViewSourceOptions } from "#src/sliceview/base.js"; import { DataType } from "#src/sliceview/base.js"; import { MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; import { @@ -86,7 +87,18 @@ export class VoxUserLayer extends UserLayer { throw new Error("beginRenderLodLock: render layer is not ready"); } // Validate against available levels in current pyramid. - const sources2D = rl.multiscaleSource.getSources({} as any); + const { multiscaleSource } = rl; + const rank = multiscaleSource.rank; + const options: SliceViewSourceOptions = { + displayRank: rank, + multiscaleToViewTransform: new Float32Array(rank * rank), + modelChannelDimensionIndices: [], + }; + // Create an identity transform matrix. + for (let i = 0; i < rank; ++i) { + options.multiscaleToViewTransform[i * rank + i] = 1; + } + const sources2D = multiscaleSource.getSources(options); const levels = sources2D?.[0]?.length ?? 0; if (levels <= 0) { throw new Error("beginRenderLodLock: multiscale source has no levels"); @@ -114,7 +126,7 @@ export class VoxUserLayer extends UserLayer { order: 1, getter: () => new VoxToolTab(this), }); - this.tabs.default = "vox"; + this.tabs.default = "vox_tools"; } private getModelToVoxTransform(): mat4 | null { @@ -213,6 +225,13 @@ export class VoxUserLayer extends UserLayer { loadedSubsource.activated!.registerDisposer( renderLayerTransform.changed.add(updateTransform) ); + + // Initialize labels manager (temporarily hardcoded to label 42). + try { + this.voxLabelsManager.initialize(this.voxEditController!); + } catch (e) { + console.warn("VoxUserLayer: labels initialization failed", e); + } } ); continue; diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index 07934fd7a1..acfa20608a 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -249,9 +249,11 @@ export class VolumeChunkSource const cpuArray = (chunk as any).data as TypedArray; for (const index of edit.indices) { cpuArray[index] = edit.value; + console.log(cpuArray[index], key, edit, edit.value, index); } chunksToUpdate.add(chunk); } + console.log("applyLocalEdits", edits, chunksToUpdate); this.invalidateGpuData(chunksToUpdate); } diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/edit_backend.ts index f6995ea00e..c7534a9e30 100644 --- a/src/voxel_annotation/edit_backend.ts +++ b/src/voxel_annotation/edit_backend.ts @@ -70,7 +70,7 @@ export class VoxelEditController extends SharedObject { size?: number[]; }[], ) { - if (!this.source) throw new Error("VoxEditBackend.commitVoxels: source not initialized"); + if (!this.source) return;//throw new Error("VoxEditBackend.commitVoxels: source not initialized"); for (const e of edits) { if (!e || !e.key || !e.indices) { throw new Error("VoxEditBackend.commitVoxels: invalid edit payload"); diff --git a/src/voxel_annotation/labels.ts b/src/voxel_annotation/labels.ts index fd6bf8edfb..b190b7f58f 100644 --- a/src/voxel_annotation/labels.ts +++ b/src/voxel_annotation/labels.ts @@ -11,11 +11,20 @@ export class LabelsManager { private labelsInitialized: boolean = false; segmentColorHash = SegmentColorHash.getDefault(); - async initialize(editController: VoxelEditController): Promise { - if (!editController) { - throw new Error("LabelsManager.initialize: editController is required"); + private readonly tempHardcodedMode = true; + + async initialize(_editController: VoxelEditController): Promise { + // Temporary hardcoded single-label setup for frontend validation. + this.labels = [{ id: 42 }]; + this.selectedLabelId = 42; + this.labelsError = undefined; + this.labelsInitialized = true; + try { + this.onLabelsChanged?.(); + } catch { + /* ignore */ } - await this.loadLabels(editController); + if (!this.tempHardcodedMode) await this.loadLabels(_editController); } // --- Label helpers --- @@ -117,6 +126,7 @@ export class LabelsManager { getCurrentLabelValue(eraseMode: boolean): number { if (eraseMode) return 0; + if(this.tempHardcodedMode) return 42; // Avoid triggering default creation during initialization. if (!this.labelsInitialized) return 0; // Ensure we have a valid selection if labels exist. From dc7457689ed860eff6cad7273f3a246979400589 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 063/251] fix: rework getVoxelPositionFromMouse to give the right coords --- NOTES/TODOs.md | 2 +- src/layer/vox/index.ts | 120 +++++++++++++++++++------------ src/sliceview/volume/frontend.ts | 2 - 3 files changed, 76 insertions(+), 48 deletions(-) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index 46ebb419bc..12b2851a9f 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,6 +1,6 @@ # TODO List -- FOR TOMORROW: start to prepare the problematic/email to JMS + go through the todo list +- FOR TOMORROW: start to prepare the problematic/email to JMS + look at neuroglancer console and fix the drawing preview: issue with chunk coord and think on how to handle compressed chunks -> maybe a second chunk source as an overlay? - LOD -> - "feat: dirty tree upscaling is kinda working, at lea diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index d6db61dbd6..bd46751de9 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -16,7 +16,6 @@ import "#src/layer/vox/style.css"; -import { vec3 } from "gl-matrix"; import type { CoordinateTransformSpecification } from "#src/coordinate_transform.js"; import type { DataSourceSpecification } from "#src/datasource/index.js"; import { @@ -32,6 +31,11 @@ import { } from "#src/layer/index.js"; import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; import { VoxToolTab } from "#src/layer/vox/tabs/tools.js"; +import { + getChunkPositionFromCombinedGlobalLocalPositions, + getChunkTransformParameters, + getWatchableRenderLayerTransform, +} from "#src/render_coordinate_transform.js"; import { trackableRenderScaleTarget, } from "#src/render_scale_statistics.js"; @@ -45,7 +49,7 @@ import { registerVoxelAnnotationTools, } from "#src/ui/voxel_annotations.js"; import type { Borrowed } from "#src/util/disposable.js"; -import { mat4 } from "#src/util/geom.js"; +import * as matrix from "#src/util/matrix.js"; import { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; import { LabelsManager } from "#src/voxel_annotation/labels.js"; import { VoxelAnnotationRenderLayer } from "#src/voxel_annotation/renderlayer.js"; @@ -59,7 +63,6 @@ export class VoxUserLayer extends UserLayer { static typeAbbreviation = "vox"; voxEditController?: VoxelEditController; voxLabelsManager = new LabelsManager(); - private modelToVoxTransform: mat4 | null; // Draw tool state voxBrushRadius: number = 3; @@ -129,24 +132,72 @@ export class VoxUserLayer extends UserLayer { this.tabs.default = "vox_tools"; } - private getModelToVoxTransform(): mat4 | null { - return this.modelToVoxTransform; - } - getVoxelPositionFromMouse( mouseState: MouseSelectionState, ): Float32Array | undefined { + const loadedDataSource = this.dataSources[0]?.loadState; + if (loadedDataSource === undefined || loadedDataSource.error !== undefined) { + return undefined; + } + + const volumeSubsource = loadedDataSource.subsources.find( + s => s.enabled && s.subsourceEntry.subsource.volume instanceof MultiscaleVolumeChunkSource + ); + + if (volumeSubsource === undefined) return undefined; + + const volume = volumeSubsource.subsourceEntry.subsource.volume as MultiscaleVolumeChunkSource; + + // This watchable will be disposed immediately after use. + const renderLayerTransformWatchable = getWatchableRenderLayerTransform( + this.manager.root.coordinateSpace, + this.localPosition.coordinateSpace, + loadedDataSource.transform, + volumeSubsource, + ); + const renderLayerTransform = renderLayerTransformWatchable.value; + renderLayerTransformWatchable.dispose(); // Clean up immediately. + + if (renderLayerTransform.error !== undefined) { + // If the render layer transform itself has an error, we can't proceed. + return undefined; + } + + // Get the base resolution source to establish the coordinate transform. + const options: SliceViewSourceOptions = { + displayRank: volume.rank, + multiscaleToViewTransform: matrix.createIdentity(Float32Array, volume.rank * volume.rank), + modelChannelDimensionIndices: [], + }; + const sources = volume.getSources(options); + if (sources.length === 0 || sources[0].length === 0) return undefined; + const baseSource = sources[0][0]; + try { - if (!mouseState?.active || !mouseState?.position) return undefined; - const inv = this.getModelToVoxTransform(); - if (!inv) return undefined; - const p = mouseState.position; - return vec3.transformMat4( - vec3.create(), - vec3.fromValues(p[0], p[1], p[2]), - inv, + // Get the complete chunk transform parameters using the valid RenderLayerTransform. + const chunkTransform = getChunkTransformParameters( + renderLayerTransform, + baseSource.chunkToMultiscaleTransform, ); - } catch { + + const chunkPosition = new Float32Array(chunkTransform.modelTransform.unpaddedRank); + + // Use the standard utility to transform from global/local viewer coordinates to chunk coordinates. + if (!getChunkPositionFromCombinedGlobalLocalPositions( + chunkPosition, + mouseState.unsnappedPosition, // Use unsnapped for higher precision + this.localPosition.value, + chunkTransform.layerRank, + chunkTransform.combinedGlobalLocalToChunkTransform, + )) { + return undefined; + } + + // The result is the floating-point position in the base-resolution voxel space. + return chunkPosition; + } catch (e) { + // getChunkTransformParameters can throw if mappings are invalid. + console.error("Error getting chunk transform parameters:", e); return undefined; } } @@ -195,44 +246,23 @@ export class VoxUserLayer extends UserLayer { } this.voxEditController = new VoxelEditController(volume); loadedSubsource.activate( - () => { - const renderLayerTransform = loadedSubsource.getRenderLayerTransform(); - - const renderLayer = new VoxelAnnotationRenderLayer(volume, { + () => { + const renderLayer = new VoxelAnnotationRenderLayer(volume, { transform: loadedSubsource.getRenderLayerTransform(), renderScaleTarget: this.sliceViewRenderScaleTarget, localPosition: this.localPosition, shaderParameters: constantWatchableValue({}) }); - this.voxRenderLayerInstance = renderLayer; - loadedSubsource.addRenderLayer(renderLayer); + this.voxRenderLayerInstance = renderLayer; + loadedSubsource.addRenderLayer(renderLayer); - // 3. Calculate and store the inverse transform needed for mouse picking. - const updateTransform = () => { - const transformOrError = renderLayerTransform.value; - if (transformOrError.error !== undefined) { - this.modelToVoxTransform = null; - } else { - this.modelToVoxTransform = mat4.invert( - mat4.create(), - transformOrError.modelToRenderLayerTransform as mat4, - ); + try { + this.voxLabelsManager.initialize(this.voxEditController!); + } catch (e) { + console.warn("VoxUserLayer: labels initialization failed", e); } - }; - - updateTransform(); - loadedSubsource.activated!.registerDisposer( - renderLayerTransform.changed.add(updateTransform) - ); - - // Initialize labels manager (temporarily hardcoded to label 42). - try { - this.voxLabelsManager.initialize(this.voxEditController!); - } catch (e) { - console.warn("VoxUserLayer: labels initialization failed", e); } - } ); continue; } diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index acfa20608a..07934fd7a1 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -249,11 +249,9 @@ export class VolumeChunkSource const cpuArray = (chunk as any).data as TypedArray; for (const index of edit.indices) { cpuArray[index] = edit.value; - console.log(cpuArray[index], key, edit, edit.value, index); } chunksToUpdate.add(chunk); } - console.log("applyLocalEdits", edits, chunksToUpdate); this.invalidateGpuData(chunksToUpdate); } From ac035376544e7c52ed64ec9e33f15949429d4ba0 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 064/251] refactor: simplify getVoxelPositionFromMouse logic by removing redundant error handling and unused transform computation steps --- src/layer/vox/index.ts | 40 +++++++++------------------------------- 1 file changed, 9 insertions(+), 31 deletions(-) diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index bd46751de9..3e2eb6c8e2 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -34,7 +34,6 @@ import { VoxToolTab } from "#src/layer/vox/tabs/tools.js"; import { getChunkPositionFromCombinedGlobalLocalPositions, getChunkTransformParameters, - getWatchableRenderLayerTransform, } from "#src/render_coordinate_transform.js"; import { trackableRenderScaleTarget, @@ -135,46 +134,28 @@ export class VoxUserLayer extends UserLayer { getVoxelPositionFromMouse( mouseState: MouseSelectionState, ): Float32Array | undefined { - const loadedDataSource = this.dataSources[0]?.loadState; - if (loadedDataSource === undefined || loadedDataSource.error !== undefined) { + const renderLayer = this.voxRenderLayerInstance; + if (renderLayer === undefined) { return undefined; } - const volumeSubsource = loadedDataSource.subsources.find( - s => s.enabled && s.subsourceEntry.subsource.volume instanceof MultiscaleVolumeChunkSource - ); - - if (volumeSubsource === undefined) return undefined; - - const volume = volumeSubsource.subsourceEntry.subsource.volume as MultiscaleVolumeChunkSource; - - // This watchable will be disposed immediately after use. - const renderLayerTransformWatchable = getWatchableRenderLayerTransform( - this.manager.root.coordinateSpace, - this.localPosition.coordinateSpace, - loadedDataSource.transform, - volumeSubsource, - ); - const renderLayerTransform = renderLayerTransformWatchable.value; - renderLayerTransformWatchable.dispose(); // Clean up immediately. - + const renderLayerTransform = renderLayer.transform.value; if (renderLayerTransform.error !== undefined) { - // If the render layer transform itself has an error, we can't proceed. + console.error("Render layer transform error:", renderLayerTransform.error); return undefined; } - // Get the base resolution source to establish the coordinate transform. + const multiscaleSource = renderLayer.multiscaleSource; const options: SliceViewSourceOptions = { - displayRank: volume.rank, - multiscaleToViewTransform: matrix.createIdentity(Float32Array, volume.rank * volume.rank), + displayRank: multiscaleSource.rank, + multiscaleToViewTransform: matrix.createIdentity(Float32Array, multiscaleSource.rank * multiscaleSource.rank), modelChannelDimensionIndices: [], }; - const sources = volume.getSources(options); + const sources = multiscaleSource.getSources(options); if (sources.length === 0 || sources[0].length === 0) return undefined; const baseSource = sources[0][0]; try { - // Get the complete chunk transform parameters using the valid RenderLayerTransform. const chunkTransform = getChunkTransformParameters( renderLayerTransform, baseSource.chunkToMultiscaleTransform, @@ -182,7 +163,6 @@ export class VoxUserLayer extends UserLayer { const chunkPosition = new Float32Array(chunkTransform.modelTransform.unpaddedRank); - // Use the standard utility to transform from global/local viewer coordinates to chunk coordinates. if (!getChunkPositionFromCombinedGlobalLocalPositions( chunkPosition, mouseState.unsnappedPosition, // Use unsnapped for higher precision @@ -193,11 +173,9 @@ export class VoxUserLayer extends UserLayer { return undefined; } - // The result is the floating-point position in the base-resolution voxel space. return chunkPosition; } catch (e) { - // getChunkTransformParameters can throw if mappings are invalid. - console.error("Error getting chunk transform parameters:", e); + console.error("Error computing voxel position:", e); return undefined; } } From 73834c6a8232d09397fffda209e5f39ad2b7746f Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 065/251] feat: optimize voxel drawing and transform caching - Implement animation frame-based draw loop for smoother rendering and input handling in voxel annotations. - Introduce cached chunk transform parameters in `VoxUserLayer` to reduce computation overhead. - Improve flood fill by adding max voxel region checks to prevent excessive processing. - Refactor `getVoxelPositionFromMouse` to utilize transform caching for enhanced efficiency. --- src/layer/vox/index.ts | 76 +++++++++++++++---------- src/ui/voxel_annotations.ts | 76 +++++++++++++++++++------ src/voxel_annotation/edit_controller.ts | 3 + 3 files changed, 108 insertions(+), 47 deletions(-) diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 3e2eb6c8e2..ccc1f79305 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -31,6 +31,7 @@ import { } from "#src/layer/index.js"; import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; import { VoxToolTab } from "#src/layer/vox/tabs/tools.js"; +import type { ChunkTransformParameters } from "#src/render_coordinate_transform.js"; import { getChunkPositionFromCombinedGlobalLocalPositions, getChunkTransformParameters, @@ -68,6 +69,11 @@ export class VoxUserLayer extends UserLayer { voxEraseMode: boolean = false; voxBrushShape: "disk" | "sphere" = "disk"; + // Cached transform and voxel buffer to avoid recomputation/allocation on every mouse move + private cachedChunkTransform: ChunkTransformParameters | undefined; + private cachedTransformGeneration: number = -1; + private cachedVoxelPosition: Float32Array = new Float32Array(3); + // Draw tab error messaging voxDrawErrorMessage: string | undefined = undefined; onDrawMessageChanged?: () => void; @@ -141,43 +147,53 @@ export class VoxUserLayer extends UserLayer { const renderLayerTransform = renderLayer.transform.value; if (renderLayerTransform.error !== undefined) { - console.error("Render layer transform error:", renderLayerTransform.error); return undefined; } - const multiscaleSource = renderLayer.multiscaleSource; - const options: SliceViewSourceOptions = { - displayRank: multiscaleSource.rank, - multiscaleToViewTransform: matrix.createIdentity(Float32Array, multiscaleSource.rank * multiscaleSource.rank), - modelChannelDimensionIndices: [], - }; - const sources = multiscaleSource.getSources(options); - if (sources.length === 0 || sources[0].length === 0) return undefined; - const baseSource = sources[0][0]; + // Caching logic for chunk transform parameters + const transformGeneration = renderLayer.transform.changed.count; + if (this.cachedTransformGeneration !== transformGeneration) { + this.cachedChunkTransform = undefined; + const multiscaleSource = renderLayer.multiscaleSource; + const options: SliceViewSourceOptions = { + displayRank: multiscaleSource.rank, + multiscaleToViewTransform: matrix.createIdentity(Float32Array, multiscaleSource.rank * multiscaleSource.rank), + modelChannelDimensionIndices: [], + }; + const sources = multiscaleSource.getSources(options); + if (sources.length > 0 && sources[0].length > 0) { + const baseSource = sources[0][0]; + try { + this.cachedChunkTransform = getChunkTransformParameters( + renderLayerTransform, + baseSource.chunkToMultiscaleTransform, + ); + this.cachedTransformGeneration = transformGeneration; + } catch (e) { + this.cachedTransformGeneration = -1; + console.error("Error computing chunk transform parameters:", e); + return undefined; + } + } + } - try { - const chunkTransform = getChunkTransformParameters( - renderLayerTransform, - baseSource.chunkToMultiscaleTransform, - ); + const chunkTransform = this.cachedChunkTransform; + if (chunkTransform === undefined) return undefined; - const chunkPosition = new Float32Array(chunkTransform.modelTransform.unpaddedRank); + if (this.cachedVoxelPosition.length !== chunkTransform.modelTransform.unpaddedRank) { + this.cachedVoxelPosition = new Float32Array(chunkTransform.modelTransform.unpaddedRank); + } - if (!getChunkPositionFromCombinedGlobalLocalPositions( - chunkPosition, - mouseState.unsnappedPosition, // Use unsnapped for higher precision - this.localPosition.value, - chunkTransform.layerRank, - chunkTransform.combinedGlobalLocalToChunkTransform, - )) { - return undefined; - } + const ok = getChunkPositionFromCombinedGlobalLocalPositions( + this.cachedVoxelPosition, + mouseState.unsnappedPosition, + this.localPosition.value, + chunkTransform.layerRank, + chunkTransform.combinedGlobalLocalToChunkTransform, + ); + if (!ok) return undefined; - return chunkPosition; - } catch (e) { - console.error("Error computing voxel position:", e); - return undefined; - } + return this.cachedVoxelPosition; } getLegacyDataSourceSpecifications( diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 8d0065f3b6..bb91655bc3 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -27,6 +27,39 @@ export const FLOODFILL_TOOL_ID = "voxFloodFill"; protected mouseDisposer: (() => void) | undefined; protected onMouseUp = () => this.stopDrawing(); protected currentMouseState: MouseSelectionState | undefined; + // Store the latest mouse state without processing it immediately. + private latestMouseState: MouseSelectionState | null = null; + private animationFrameHandle: number | null = null; + + // The main drawing loop synchronized to display refresh + private drawLoop = (): void => { + if (!this.isDrawing) { + this.animationFrameHandle = null; + return; + } + if (this.latestMouseState === null) { + this.animationFrameHandle = requestAnimationFrame(this.drawLoop); + return; + } + + const layer = this.layer as unknown as VoxUserLayer; + const value = layer.voxLabelsManager.getCurrentLabelValue(layer.voxEraseMode); + const cur = this.getPoint(this.latestMouseState); + this.latestMouseState = null; // mark processed + + if (cur) { + const last = this.lastPoint; + if (last && (cur[0] !== last[0] || cur[1] !== last[1] || cur[2] !== last[2])) { + const points = this.linePoints(last, cur); + if (points.length > 0) { + this.paintPoints(points, value); + } + } + this.lastPoint = cur; + } + + this.animationFrameHandle = requestAnimationFrame(this.drawLoop); + }; protected getPoint(mouseState: MouseSelectionState): Int32Array | undefined { const vox = (this.layer as any).getVoxelPositionFromMouse?.(mouseState) as @@ -95,36 +128,44 @@ export const FLOODFILL_TOOL_ID = "voxFloodFill"; this.paintPoint(centerCanonical, value); this.lastPoint = start; + // Initialize latest mouse state so RAF can process immediately + this.latestMouseState = mouseState; + // On mouse move, just update the latest position. this.mouseDisposer = mouseState.changed.add(() => { - if (!this.isDrawing) return; + this.latestMouseState = mouseState; this.currentMouseState = mouseState; - const cur = this.getPoint(mouseState); - if (!cur) return; - const last = this.lastPoint; - if (!last) { - this.paintPoint(new Float32Array([cur[0], cur[1], cur[2]]), value); - this.lastPoint = cur; - return; - } - if (cur[0] === last[0] && cur[1] === last[1] && cur[2] === last[2]) return; - const points = this.linePoints(last, cur); - if (points.length > 0) { - this.paintPoints(points, value); - } - this.lastPoint = cur; }); - window.addEventListener("mouseup", this.onMouseUp, { once: true }); + + // On mouse up, stop drawing and cleanup. + const mouseUpHandler = () => { + this.stopDrawing(); + window.removeEventListener("mouseup", mouseUpHandler); + if (this.mouseDisposer) { this.mouseDisposer(); this.mouseDisposer = undefined; } + }; + window.addEventListener("mouseup", mouseUpHandler); + + // Start the animation loop if not running + if (this.animationFrameHandle === null) { + this.animationFrameHandle = requestAnimationFrame(this.drawLoop); + } } protected stopDrawing() { if (!this.isDrawing) return; this.isDrawing = false; this.lastPoint = undefined; + + if (this.animationFrameHandle !== null) { + cancelAnimationFrame(this.animationFrameHandle); + this.animationFrameHandle = null; + } + if (this.mouseDisposer) { this.mouseDisposer(); this.mouseDisposer = undefined; } + // Always release any active render LOD lock. try { this.layer.endRenderLodLock(); @@ -138,7 +179,8 @@ export const FLOODFILL_TOOL_ID = "voxFloodFill"; try { this.startDrawing(mouseState); } catch (e) { - console.log(`[${this.constructor.name}] Error:`, e); + console.error(`[${this.constructor.name}] Error:`, e); + this.stopDrawing(); } } diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 20213cffa5..7e18141089 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -381,6 +381,9 @@ export class VoxelEditController extends SharedObject { // BFS with thickness constraints while (queue.length > 0) { + if (filledCount >= maxVoxels) { + throw new Error(`VoxChunkSource.floodFillPlane2D: region exceeds maxVoxels (${maxVoxels}).`); + } const [x, y] = queue.shift()!; // Schedule this pixel for filling From 118711cb7d5f1cec1391ddf24b793d35c606c3cf Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 066/251] feat: modularize `VolumeChunk` and chunk format handling - Extract `VolumeChunk` implementation into a dedicated file `src/sliceview/volume/chunk.ts`. - Create a separate registry module `src/sliceview/volume/registry.ts` for managing chunk format handlers. - Refactor imports across modules to use the new registry and chunk implementations. - Improve chunk editing logic to handle both uncompressed and compressed chunk formats, including decode-edit-reencode workflow. - Remove redundant `ChunkFormatHandler` factory and logic from `frontend.ts`. --- src/sliceview/chunk_base.ts | 15 +++ .../compressed_segmentation/chunk_format.ts | 2 +- src/sliceview/frontend.ts | 4 + src/sliceview/single_texture_chunk_format.ts | 2 +- src/sliceview/uncompressed_chunk_format.ts | 2 +- src/sliceview/volume/chunk.ts | 20 ++++ src/sliceview/volume/frontend.ts | 105 +++++++++++------- src/sliceview/volume/registry.ts | 40 +++++++ 8 files changed, 145 insertions(+), 45 deletions(-) create mode 100644 src/sliceview/chunk_base.ts create mode 100644 src/sliceview/volume/chunk.ts create mode 100644 src/sliceview/volume/registry.ts diff --git a/src/sliceview/chunk_base.ts b/src/sliceview/chunk_base.ts new file mode 100644 index 0000000000..568098ceac --- /dev/null +++ b/src/sliceview/chunk_base.ts @@ -0,0 +1,15 @@ +import { ChunkState } from "#src/chunk_manager/base.js"; +import { Chunk } from "#src/chunk_manager/frontend.js"; +import type { SliceViewChunkSource } from "#src/sliceview/frontend.js"; +import type { vec3 } from "#src/util/geom.js"; + +export class SliceViewChunk extends Chunk { + chunkGridPosition: vec3; + declare source: SliceViewChunkSource; + + constructor(source: SliceViewChunkSource, x: any) { + super(source); + this.chunkGridPosition = x.chunkGridPosition; + this.state = ChunkState.SYSTEM_MEMORY; + } +} diff --git a/src/sliceview/compressed_segmentation/chunk_format.ts b/src/sliceview/compressed_segmentation/chunk_format.ts index b025c49720..db2fc6ac05 100644 --- a/src/sliceview/compressed_segmentation/chunk_format.ts +++ b/src/sliceview/compressed_segmentation/chunk_format.ts @@ -26,7 +26,7 @@ import type { ChunkFormatHandler, VolumeChunkSource, } from "#src/sliceview/volume/frontend.js"; -import { registerChunkFormatHandler } from "#src/sliceview/volume/frontend.js"; +import { registerChunkFormatHandler } from "#src/sliceview/volume/registry.js"; import { RefCounted } from "#src/util/disposable.js"; import { vec3, vec3Key } from "#src/util/geom.js"; import type { GL } from "#src/webgl/context.js"; diff --git a/src/sliceview/frontend.ts b/src/sliceview/frontend.ts index 66d956d835..4b595e45de 100644 --- a/src/sliceview/frontend.ts +++ b/src/sliceview/frontend.ts @@ -21,6 +21,8 @@ import type { ChunkRequesterState, } from "#src/chunk_manager/frontend.js"; import { Chunk, ChunkSource } from "#src/chunk_manager/frontend.js"; +export { SliceViewChunk } from "#src/sliceview/chunk_base.js"; +import type { SliceViewChunk as SliceViewChunk } from "#src/sliceview/chunk_base.js"; import { applyRenderViewportToProjectionMatrix } from "#src/display_context.js"; import type { LayerManager } from "#src/layer/index.js"; import type { @@ -724,6 +726,7 @@ export interface SliceViewChunkSource { getChunk(x: any): any; } +/* export class SliceViewChunk extends Chunk { chunkGridPosition: vec3; declare source: SliceViewChunkSource; @@ -734,6 +737,7 @@ export class SliceViewChunk extends Chunk { this.state = ChunkState.SYSTEM_MEMORY; } } +*/ /** * Helper for rendering a SliceView that has been pre-rendered to a texture. diff --git a/src/sliceview/single_texture_chunk_format.ts b/src/sliceview/single_texture_chunk_format.ts index c0a42eee9f..2cbd711050 100644 --- a/src/sliceview/single_texture_chunk_format.ts +++ b/src/sliceview/single_texture_chunk_format.ts @@ -18,7 +18,7 @@ import type { VolumeChunkSource, ChunkFormat, } from "#src/sliceview/volume/frontend.js"; -import { VolumeChunk } from "#src/sliceview/volume/frontend.js"; +import { VolumeChunk } from "#src/sliceview/volume/chunk.js"; import type { TypedArray } from "#src/util/array.js"; import type { DataType } from "#src/util/data_type.js"; import type { Disposable } from "#src/util/disposable.js"; diff --git a/src/sliceview/uncompressed_chunk_format.ts b/src/sliceview/uncompressed_chunk_format.ts index 2f1eadbd71..09195806e1 100644 --- a/src/sliceview/uncompressed_chunk_format.ts +++ b/src/sliceview/uncompressed_chunk_format.ts @@ -26,7 +26,7 @@ import type { ChunkFormatHandler, VolumeChunkSource, } from "#src/sliceview/volume/frontend.js"; -import { registerChunkFormatHandler } from "#src/sliceview/volume/frontend.js"; +import { registerChunkFormatHandler } from "#src/sliceview/volume/registry.js"; import type { TypedArray, TypedNumberArrayConstructor, diff --git a/src/sliceview/volume/chunk.ts b/src/sliceview/volume/chunk.ts new file mode 100644 index 0000000000..36be6cace0 --- /dev/null +++ b/src/sliceview/volume/chunk.ts @@ -0,0 +1,20 @@ +import { SliceViewChunk } from "#src/sliceview/chunk_base.js"; +import type { ChunkFormat, VolumeChunkSource } from "#src/sliceview/volume/frontend.js"; +import type { GL } from "#src/webgl/context.js"; + +export abstract class VolumeChunk extends SliceViewChunk { + declare source: VolumeChunkSource; + chunkDataSize: Uint32Array; + declare CHUNK_FORMAT_TYPE: ChunkFormat; + + get chunkFormat(): this["CHUNK_FORMAT_TYPE"] { + return this.source.chunkFormat; + } + + constructor(source: VolumeChunkSource, x: any) { + super(source, x); + this.chunkDataSize = x.chunkDataSize || source.spec.chunkDataSize; + } + abstract getValueAt(dataPosition: Uint32Array): any; + abstract updateFromCpuData(gl: GL): void; +} diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index 07934fd7a1..e143f14037 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -18,14 +18,25 @@ import type { ChunkManager } from "#src/chunk_manager/frontend.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; import type { DataType, SliceViewChunkSpecification } from "#src/sliceview/base.js"; import { SLICEVIEW_REQUEST_CHUNK_RPC_ID } from "#src/sliceview/base.js"; -import { MultiscaleSliceViewChunkSource, SliceViewChunk, SliceViewChunkSource } from "#src/sliceview/frontend.js"; +import { ChunkFormat as CompressedChunkFormat } from "#src/sliceview/compressed_segmentation/chunk_format.js"; +import { decodeChannel as decodeChannelUint32 } from "#src/sliceview/compressed_segmentation/decode_uint32.js"; +import { decodeChannel as decodeChannelUint64 } from "#src/sliceview/compressed_segmentation/decode_uint64.js"; +import { encodeChannel as encodeChannelUint32 } from "#src/sliceview/compressed_segmentation/encode_uint32.js"; +import { encodeChannel as encodeChannelUint64 } from "#src/sliceview/compressed_segmentation/encode_uint64.js"; +import type { SliceViewChunk } from "#src/sliceview/frontend.js"; +import { MultiscaleSliceViewChunkSource, SliceViewChunkSource } from "#src/sliceview/frontend.js"; +import { getChunkFormatHandler } from "#src/sliceview/volume/registry.js"; +import { ChunkFormat as UncompressedChunkFormat } from "#src/sliceview/uncompressed_chunk_format.js"; import type { VolumeChunkSource as VolumeChunkSourceInterface, VolumeChunkSpecification, VolumeSourceOptions, VolumeType } from "#src/sliceview/volume/base.js"; -import type { TypedArray } from "#src/util/array.js"; +import { VolumeChunk } from "#src/sliceview/volume/chunk.js"; +import type { TypedArray} from "#src/util/array.js"; +import { TypedArrayBuilder } from "#src/util/array.js"; +import { DataType as DataTypeUtil } from "#src/util/data_type.js"; import type { Disposable } from "#src/util/disposable.js"; import type { GL } from "#src/webgl/context.js"; import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; @@ -149,26 +160,6 @@ export interface ChunkFormatHandler extends Disposable { getChunk(source: SliceViewChunkSource, x: any): SliceViewChunk; } -export type ChunkFormatHandlerFactory = ( - gl: GL, - spec: VolumeChunkSpecification, -) => ChunkFormatHandler | null; - -const chunkFormatHandlers = new Array(); - -export function registerChunkFormatHandler(factory: ChunkFormatHandlerFactory) { - chunkFormatHandlers.push(factory); -} - -export function getChunkFormatHandler(gl: GL, spec: VolumeChunkSpecification) { - for (const handler of chunkFormatHandlers) { - const result = handler(gl, spec); - if (result != null) { - return result; - } - } - throw new Error("No chunk format handler found."); -} export class VolumeChunkSource extends SliceViewChunkSource @@ -246,11 +237,54 @@ export class VolumeChunkSource if (!chunk || !(chunk as any).data) { continue; } - const cpuArray = (chunk as any).data as TypedArray; - for (const index of edit.indices) { - cpuArray[index] = edit.value; + + const chunkFormat = chunk.chunkFormat; + + if (chunkFormat instanceof UncompressedChunkFormat) { + const cpuArray = (chunk as any).data as TypedArray; + for (const index of edit.indices) { + cpuArray[index] = edit.value; + } + chunksToUpdate.add(chunk); + } else if (chunkFormat instanceof CompressedChunkFormat) { + // using an idiotic logic to handle compressed chunks: uncompress -> edit -> recompress + // TODO: rework this + const compressedData = (chunk as any).data as Uint32Array; + const { chunkDataSize } = chunk; + const numElements = chunkDataSize[0] * chunkDataSize[1] * chunkDataSize[2]; + const { dataType, subchunkSize } = chunkFormat; + + // Note: Assuming single-channel for simplicity. Multi-channel would require handling offsets. + const baseOffset = compressedData[0]; + + const outputBuilder = new TypedArrayBuilder(Uint32Array, compressedData.length); + // Write multi-channel header (for single channel) + outputBuilder.resize(1); + outputBuilder.data[0] = 1; + + if (dataType === DataTypeUtil.UINT32) { + const uncompressedData = new Uint32Array(numElements); + decodeChannelUint32(uncompressedData, compressedData, baseOffset, chunkDataSize, subchunkSize); + + for (const index of edit.indices) { + uncompressedData[index] = edit.value; + } + + encodeChannelUint32(outputBuilder, subchunkSize, uncompressedData, chunkDataSize); + } else { // Assumes UINT64 + const uncompressedData = new BigUint64Array(numElements); + decodeChannelUint64(uncompressedData, compressedData, baseOffset, chunkDataSize, subchunkSize); + + for (const index of edit.indices) { + uncompressedData[index] = BigInt(edit.value); + } + + encodeChannelUint64(outputBuilder, subchunkSize, uncompressedData, chunkDataSize); + } + + (chunk as any).data = outputBuilder.view; + chunksToUpdate.add(chunk); } - chunksToUpdate.add(chunk); } this.invalidateGpuData(chunksToUpdate); } @@ -337,22 +371,7 @@ export class VolumeChunkSource } } -export abstract class VolumeChunk extends SliceViewChunk { - declare source: VolumeChunkSource; - chunkDataSize: Uint32Array; - declare CHUNK_FORMAT_TYPE: ChunkFormat; - - get chunkFormat(): this["CHUNK_FORMAT_TYPE"] { - return this.source.chunkFormat; - } - - constructor(source: VolumeChunkSource, x: any) { - super(source, x); - this.chunkDataSize = x.chunkDataSize || source.spec.chunkDataSize; - } - abstract getValueAt(dataPosition: Uint32Array): any; - abstract updateFromCpuData(gl: GL): void; -} +// VolumeChunk moved to src/sliceview/volume/chunk.ts export abstract class MultiscaleVolumeChunkSource extends MultiscaleSliceViewChunkSource< VolumeChunkSource, @@ -361,3 +380,5 @@ export abstract class MultiscaleVolumeChunkSource extends MultiscaleSliceViewChu abstract dataType: DataType; abstract volumeType: VolumeType; } + +export { VolumeChunk }; diff --git a/src/sliceview/volume/registry.ts b/src/sliceview/volume/registry.ts new file mode 100644 index 0000000000..97992a80f9 --- /dev/null +++ b/src/sliceview/volume/registry.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2020 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 type { VolumeChunkSpecification } from "#src/sliceview/volume/base.js"; +import type { ChunkFormatHandler } from "#src/sliceview/volume/frontend.js"; +import type { GL } from "#src/webgl/context.js"; + +export type ChunkFormatHandlerFactory = ( + gl: GL, + spec: VolumeChunkSpecification, +) => ChunkFormatHandler | null; + +const chunkFormatHandlers = new Array(); + +export function registerChunkFormatHandler(factory: ChunkFormatHandlerFactory) { + chunkFormatHandlers.push(factory); +} + +export function getChunkFormatHandler(gl: GL, spec: VolumeChunkSpecification) { + for (const handler of chunkFormatHandlers) { + const result = handler(gl, spec); + if (result != null) { + return result; + } + } + throw new Error("No chunk format handler found."); +} From 402256cdecda48679b48831a2cd3d18a233535fd Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 067/251] feat: implement multiscale voxel editing and raw chunk encoding pipeline - Add minimal `encodeArray` function for encoding chunks using raw byte codec. - Extend `VolumeChunkSource` to support editing/updating chunks with `applyEdits` and `writeChunk`. - Refactor `VoxelEditBackend` and `VoxelEditController` for multiscale source support. - Enhance flood fill and brush edits to handle chunk-level edits with multi-LOD consistency. - Introduce chunk key parsing and reconstruction for edit operations. - Update TODO list with priorities for optimizing compressed chunk handling and flood fill. --- NOTES/TODOs.md | 47 ++------- src/datasource/zarr/backend.ts | 57 +++++++++++ src/datasource/zarr/codec/encode.ts | 25 +++++ src/sliceview/volume/backend.ts | 50 ++++++++++ src/sliceview/volume/frontend.ts | 2 +- src/voxel_annotation/edit_backend.ts | 123 +++++++++++++++++++----- src/voxel_annotation/edit_controller.ts | 112 ++++++++++++++++----- 7 files changed, 323 insertions(+), 93 deletions(-) create mode 100644 src/datasource/zarr/codec/encode.ts diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index 12b2851a9f..bd9f4f6b32 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,52 +1,17 @@ # TODO List -- FOR TOMORROW: start to prepare the problematic/email to JMS + look at neuroglancer console and fix the drawing preview: issue with chunk coord and think on how to handle compressed chunks -> maybe a second chunk source as an overlay? +- FOR TOMORROW: start to prepare the problematic/email to JMS -- LOD -> - - "feat: dirty tree upscaling is kinda working, at lea - st enough to conclude that this upscaling method wont work - due to unsolvable conficts and lost unavoidable lost of qua - lity due to upscaling of downscaled strokes. A new approach - will be to enqueue every upscale and downscale and throttl - e the user when the queue is too full, with some kind of in - dicator in the ui. We also may need to restrict the max bru - sh size to avoid too long waiting time." -> no upscaling for now (e.g. drawn voxel size/lod level is always 1), only downscaling. - -- continue to study the segmentation compression, using it should greatly reduce the ram and indexDB usage, but it no easy integration of the hot chunk reloading in the frontend for drawing tool responsiveness has been found. - Fix the orientation of the disk in the brush tool - Add support for flood fill on different planes -- Add Uint64 support for annotation id --? Replace the current map settings to use the built-ins of neuroglancer (viewable under the datasource url), handle multimap with link choices, look into how to keep the init/creation logic. -? adapt the brush size to the zoom level linearly -- Flood fill do not work at the bounds of the layer -- need to fix this cache invalidation pipeline, it is not responsive enough -- ~~add a zarr import feature or even better design a dual source system, where you have the zarr source with most of the data and the indexedDB where the updates are stored, this would be an augmented version of the current localsource which would first look into the indexedDB and if not present, fetch the zarr source.~~ -> this is really hard, I should ask JMS for help, I will implement a simplified import system myself for now. - rework the ui (tabs) -- add persistance to vox layer - add shortcuts for tools (switching tools, toogle erase mode, select label from the pointed one in the slice view and adjusting brush size) and label creation -- the flood fill may fill an entire unwanted area if the user is zoomed in enough so the max number of loaded chunks are under the vox voxel count and the flood has an escape hole. - the flood fill sometimes leaves artifacts in sharp areas - rework the autocomplete for the ssa+https source. +- fix the flood fill for compressed chunks +- rework the drawing preview for compressed chunk (see applyLocalEdits()) +- optimize flood fill tool (it is too slow on area containing uncached chunks, due to the getEnsuredValueAt() calls) -# Saving/importing/exporting - -The ExternalVoxSource will be activated when a zarr:// or precomputed:// link is provided. This will load the data from the remote to display, on edits, the data will still be saved in the local indexedDB. On retrieval of chunks, we must first check the IndexedDB and if not locally present, fetch the remote. An export feature should be added, this will hold the drawing capabilities, retrieve the entire data from the remote and merge it with the local modifications. Then reformat everything to the desired format. - -The RemoteVoxSource will be activated when a https:// link to a specially made server is provided. This server will replace the local indexedDB and will be the new data owner, such a workflow may allow for multi-user collaboration. - -# LOD - -- the saving of drawing data is already indexed with their scale; to allow for multiscale rendering, we should also provide a way to display the data coming from different scales than the current one. This involves two steps: - -1. on saving of data, we should propagate the complete voxel cube to the upper levels (lower zoom levels) recursively -2. on loading of data, we should retrieve not only the current scale chunks but also the ones from the lower zoom levels. - This last step will introduce conflicts what if the same voxel does not have the same value in the different scales? And how to know if there has been deletion or if there are just no data? To solve this, we must introduce a special value for the deleted voxels and also timestamp for the last chunk updates. ~~To avoid too many conficts, we should resolve them when loading the data.~~ Actually, we should not resolve those conflicts live as doing so will prevent us from implementing an undo feature. - -Drawing Flow chart: --> Brush stroke start - -> lock LOD level to the brush size one - -> Live render the drawing - -> Commit modifications to backend -> Save, downsample and mark upsamples as dirty (they will be recalculated on the fly when needed) --> Brush stoke ends - -> Unlock LOD level (maybe add a small delay to avoid flickering) - -> Progressivly download upscalings as they roll out +- rework vox backend +- rework label handling diff --git a/src/datasource/zarr/backend.ts b/src/datasource/zarr/backend.ts index 7370f7af0e..c06d64fc97 100644 --- a/src/datasource/zarr/backend.ts +++ b/src/datasource/zarr/backend.ts @@ -34,6 +34,7 @@ import { postProcessRawData } from "#src/sliceview/backend_chunk_decoders/postpr import type { VolumeChunk } from "#src/sliceview/volume/backend.js"; import { VolumeChunkSource } from "#src/sliceview/volume/backend.js"; import { registerSharedObject } from "#src/worker_rpc.js"; +import { encodeArray } from "#src/datasource/zarr/codec/encode.js"; @registerSharedObject() export class ZarrVolumeChunkSource extends WithParameters( @@ -97,4 +98,60 @@ export class ZarrVolumeChunkSource extends WithParameters( await postProcessRawData(chunk, signal, decoded); } } + + async writeChunk(chunk: VolumeChunk): Promise { + const { kvStore, getChunkKey, decodeCodecs } = this.chunkKvStore as any; + if (!kvStore.write) { + throw new Error("ZarrVolumeChunkSource.writeChunk: underlying kvStore is not writable"); + } + if (!chunk.data) { + throw new Error("ZarrVolumeChunkSource.writeChunk: missing chunk.data"); + } + // Encode using the same codecs chain that was used to decode, but in reverse; our minimal + // encodeArray currently only supports raw 'bytes'. + const encoded = await encodeArray(decodeCodecs, chunk.data as ArrayBufferView, new AbortController().signal); + + // Compute base key same as in download. + const { parameters } = this; + const { chunkGridPosition } = chunk; + const { metadata } = parameters; + let baseKey = ""; + const rank = this.spec.rank; + const { physicalToLogicalDimension } = metadata.codecs.layoutInfo[0]; + let sep: string; + if (metadata.chunkKeyEncoding === ChunkKeyEncoding.DEFAULT) { + baseKey += "c"; + sep = metadata.dimensionSeparator; + } else { + sep = ""; + if (rank === 0) { + baseKey += "0"; + } + } + const keyCoords = new Array(rank); + const { readChunkShape } = metadata.codecs.layoutInfo[0]; + const { chunkShape } = metadata; + for ( + let fOrderPhysicalDim = 0; + fOrderPhysicalDim < rank; + ++fOrderPhysicalDim + ) { + const decodedDim = + physicalToLogicalDimension[rank - 1 - fOrderPhysicalDim]; + keyCoords[decodedDim] = Math.floor( + (chunkGridPosition[fOrderPhysicalDim] * readChunkShape[decodedDim]) / + chunkShape[decodedDim], + ); + } + for (let i = 0; i < rank; ++i) { + baseKey += `${sep}${keyCoords[i]}`; + sep = metadata.dimensionSeparator; + } + + const key = getChunkKey(chunkGridPosition, baseKey) as string | unknown; + // Ensure we provide an ArrayBuffer-backed payload. If encoded is backed by SharedArrayBuffer, + // copy it into a new ArrayBuffer. + const arrayBuffer = new Uint8Array(encoded).buffer; + await kvStore.write!(key as any, arrayBuffer); + } } diff --git a/src/datasource/zarr/codec/encode.ts b/src/datasource/zarr/codec/encode.ts new file mode 100644 index 0000000000..3f368939c9 --- /dev/null +++ b/src/datasource/zarr/codec/encode.ts @@ -0,0 +1,25 @@ +/** + * Minimal Zarr encode pipeline to persist chunks. + * Supports only the common case of raw bytes (no transpose/compression/sharding). + */ +import type { CodecChainSpec } from "#src/datasource/zarr/codec/index.js"; +import { CodecKind } from "#src/datasource/zarr/codec/index.js"; + +export async function encodeArray( + codecs: CodecChainSpec, + typed: ArrayBufferView, + _signal: AbortSignal, +): Promise> { + // Only support simple "bytes" encoding with no array-to-array and no bytes-to-bytes codecs. + const hasArrayToArray = codecs[CodecKind.arrayToArray].length > 0; + const hasBytesToBytes = codecs[CodecKind.bytesToBytes].length > 0; + const arrayToBytes = codecs[CodecKind.arrayToBytes]; + if (hasArrayToArray || hasBytesToBytes || arrayToBytes.name !== "bytes") { + throw new Error( + `encodeArray: Unsupported codec chain; only raw 'bytes' without additional codecs is supported. Got arrayToArray=${hasArrayToArray}, bytesToBytes=${hasBytesToBytes}, arrayToBytes=${arrayToBytes.name}`, + ); + } + // For raw bytes, we can write the underlying buffer. + const { buffer, byteOffset, byteLength } = typed; + return new Uint8Array(buffer, byteOffset, byteLength); +} diff --git a/src/sliceview/volume/backend.ts b/src/sliceview/volume/backend.ts index 4c7c39b8de..09d6fc5021 100644 --- a/src/sliceview/volume/backend.ts +++ b/src/sliceview/volume/backend.ts @@ -15,6 +15,7 @@ */ import type { Chunk } from "#src/chunk_manager/backend.js"; +import { ChunkState } from "#src/chunk_manager/base.js"; import { SliceViewChunk, SliceViewChunkSourceBackend, @@ -27,6 +28,8 @@ import type { VolumeChunkSource as VolumeChunkSourceInterface, VolumeChunkSpecification, } from "#src/sliceview/volume/base.js"; +import type { TypedArray } from "#src/util/array.js"; +import { DATA_TYPE_ARRAY_CONSTRUCTOR } from "#src/util/data_type.js"; import type { vec3 } from "#src/util/geom.js"; import * as vector from "#src/util/vector.js"; import type { RPC } from "#src/worker_rpc.js"; @@ -155,5 +158,52 @@ export class VolumeChunkSource computeChunkBounds(chunk: VolumeChunk) { return computeChunkBounds(this, chunk); } + + // Override in data source backends to actually persist the chunk. + // Default throws to ensure write capability is explicitly implemented. + async writeChunk(_chunk: VolumeChunk): Promise { + throw new Error("VolumeChunkSource.writeChunk not implemented for this datasource"); + } + + async applyEdits(chunkKey: string, indices: ArrayLike, values: ArrayLike): Promise { + if (indices.length !== values.length) { + throw new Error("applyEdits: indices and values length mismatch"); + } + const chunkGridPosition = new Float32Array(chunkKey.split(',').map(Number)); + if (chunkGridPosition.length !== this.spec.rank || chunkGridPosition.some((v) => !Number.isFinite(v))) { + throw new Error(`applyEdits: invalid chunk key ${chunkKey}`); + } + const chunk = this.getChunk(chunkGridPosition) as unknown as VolumeChunk; + + // Ensure chunk data is available in system memory + if (chunk.state > ChunkState.SYSTEM_MEMORY_WORKER) { + const ac = new AbortController(); + await this.download(chunk, ac.signal); + } + if (!chunk.data) { + // If chunk.data is null, the chunk does not exist at the source or was evicted. + // Create a new, zero-filled chunk to apply the edits to. + if (!chunk.chunkDataSize) { + this.computeChunkBounds(chunk); // Ensure chunkDataSize is computed + } + if (!chunk.chunkDataSize) { + throw new Error(`applyEdits: Cannot create new chunk ${chunkKey} because its size is unknown.`); + } + const numElements = chunk.chunkDataSize.reduce((a, b) => a * b, 1); + const Ctor = DATA_TYPE_ARRAY_CONSTRUCTOR[this.spec.dataType]; + chunk.data = new (Ctor as any)(numElements); + // The new TypedArray is already zero-filled. + } + const data = (chunk.data as unknown) as TypedArray; + for (let i = 0; i < indices.length; ++i) { + const idx = indices[i]!; + const val = values[i]!; + if (idx < 0 || idx >= data.length) { + throw new Error(`applyEdits: index ${idx} out of bounds for chunk ${chunkKey}`); + } + (data as any)[idx] = val; // TypedArray index assignment + } + await this.writeChunk(chunk); + } } VolumeChunkSource.prototype.chunkConstructor = VolumeChunk; diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index e143f14037..78f451a052 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -25,7 +25,6 @@ import { encodeChannel as encodeChannelUint32 } from "#src/sliceview/compressed_ import { encodeChannel as encodeChannelUint64 } from "#src/sliceview/compressed_segmentation/encode_uint64.js"; import type { SliceViewChunk } from "#src/sliceview/frontend.js"; import { MultiscaleSliceViewChunkSource, SliceViewChunkSource } from "#src/sliceview/frontend.js"; -import { getChunkFormatHandler } from "#src/sliceview/volume/registry.js"; import { ChunkFormat as UncompressedChunkFormat } from "#src/sliceview/uncompressed_chunk_format.js"; import type { VolumeChunkSource as VolumeChunkSourceInterface, @@ -34,6 +33,7 @@ import type { VolumeType } from "#src/sliceview/volume/base.js"; import { VolumeChunk } from "#src/sliceview/volume/chunk.js"; +import { getChunkFormatHandler } from "#src/sliceview/volume/registry.js"; import type { TypedArray} from "#src/util/array.js"; import { TypedArrayBuilder } from "#src/util/array.js"; import { DataType as DataTypeUtil } from "#src/util/data_type.js"; diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/edit_backend.ts index c7534a9e30..6efb0b5882 100644 --- a/src/voxel_annotation/edit_backend.ts +++ b/src/voxel_annotation/edit_backend.ts @@ -20,7 +20,7 @@ import { SharedObject , registerPromiseRPC, registerRPC, registerSharedObject, i @registerSharedObject(VOX_EDIT_BACKEND_RPC_ID) export class VoxelEditController extends SharedObject { - private source?: VolumeChunkSource; + private sources = new Map(); // Short debounce to coalesce rapid edits coming from tools. private pendingEdits: { @@ -43,16 +43,83 @@ export class VoxelEditController extends SharedObject { // Initialize as a counterpart in the worker so RPC references are valid. // This registers the object under the provided rpc/id and sets up ref counting. initializeSharedObjectCounterpart(this, rpc, options); + + const passedSources = options?.sources; + if (passedSources === undefined || typeof passedSources !== 'object') { + throw new Error("VoxelEditBackend: missing required 'sources' map during initialization"); + } + + for (const lodFactorStr in passedSources) { + const lodFactor = Number(lodFactorStr); + const sourceId = passedSources[lodFactorStr]; + const resolved = rpc.get(sourceId) as VolumeChunkSource | undefined; + if (!resolved) { + throw new Error(`VoxelEditBackend: failed to resolve VolumeChunkSource for LOD factor ${lodFactor}`); + } + this.sources.set(lodFactor, resolved); + } } private async flushPending(): Promise { - const src = this.source; - if (!src) throw new Error("VoxEditBackend.flushPending: source not initialized"); const edits = this.pendingEdits; this.pendingEdits = []; this.commitDebounceTimer = undefined; if (edits.length === 0) return; - // await src.applyEdits(edits); TODO: add writting capability to VolumeChunkSource + + // 1. Group edits by vox chunk key (includes LOD). + const editsByVoxKey = new Map(); + for (const edit of edits) { + if (!editsByVoxKey.has(edit.key)) { + editsByVoxKey.set(edit.key, { indices: [], values: [] }); + } + const entry = editsByVoxKey.get(edit.key)!; + if (edit.values) { + const vals = Array.from(edit.values); + if (vals.length !== edit.indices.length) { + throw new Error("flushPending: values length mismatch with indices"); + } + for (let i = 0; i < edit.indices.length; ++i) { + entry.indices.push(Number(edit.indices[i]!)); + entry.values.push(Number(vals[i]!)); + } + } else if (edit.value !== undefined) { + const inds = edit.indices as ArrayLike; + for (let i = 0; i < inds.length; ++i) { + const index = inds[i]!; + entry.indices.push(Number(index)); + entry.values.push(Number(edit.value)); + } + } else { + throw new Error("flushPending: edit missing value(s)"); + } + } + + // 2. For each modified vox chunk, apply edits via the correct source and record vox keys to reload. + const touchedVoxChunkKeys: string[] = []; + for (const [voxKey, chunkEdits] of editsByVoxKey.entries()) { + try { + const parsedKey = parseVoxChunkKey(voxKey); + if (!parsedKey) { + console.error(`flushPending: Failed to parse vox chunk key: ${voxKey}`); + continue; + } + const source = this.sources.get(parsedKey.lod); + if (!source) { + console.error(`flushPending: No source found for LOD factor ${parsedKey.lod}`); + continue; + } + await (source as any).applyEdits(parsedKey.chunkKey, chunkEdits.indices, chunkEdits.values); + touchedVoxChunkKeys.push(voxKey); + } catch (e) { + console.error(`Failed to write chunk ${voxKey}:`, e); + } + } + + // 3. Invalidate frontend caches for the modified chunks. + if (touchedVoxChunkKeys.length > 0) { + this.callChunkReload(touchedVoxChunkKeys); + } + // After base edits, enqueue downsampling for affected chunks (do not await here). const touched = new Set(); for (const e of edits) touched.add(e.key); @@ -70,7 +137,6 @@ export class VoxelEditController extends SharedObject { size?: number[]; }[], ) { - if (!this.source) return;//throw new Error("VoxEditBackend.commitVoxels: source not initialized"); for (const e of edits) { if (!e || !e.key || !e.indices) { throw new Error("VoxEditBackend.commitVoxels: invalid edit payload"); @@ -82,15 +148,13 @@ export class VoxelEditController extends SharedObject { } async getLabelIds(): Promise { - const src = this.source; - if (!src) throw new Error("VoxEditBackend.getLabelIds: source not initialized"); - return [] // await src.getLabelIds(); + // Label operations not yet implemented for multiscale edit backend. + return []; } async addLabel(_value: number): Promise { - const src = this.source; - if (!src) throw new Error("VoxEditBackend.addLabel: source not initialized"); - return [] // await src.addLabel(value >>> 0); + // Label operations not yet implemented for multiscale edit backend. + return []; } callChunkReload(voxChunkKeys: string[]){ @@ -116,8 +180,6 @@ export class VoxelEditController extends SharedObject { } private async performDownsampleCascadeForKey(sourceKey: string): Promise { - const src = this.source; - if (!src) return; const chunkSize = 64; const maxPasses = this.calculateDownsamplePasses(chunkSize); const maxLOD = 256; @@ -182,11 +244,12 @@ export class VoxelEditController extends SharedObject { } private async downsampleStep(sourceKey: string): Promise { - const src = this.source; - if (!src) return null; const info = parseVoxChunkKey(sourceKey); if (info === null) return null; + const src = this.sources.get(info.lod); + if (!src) return null; + const sourceChunk = src.getChunk( new Float32Array([info.x, info.y, info.z]), ) as any; @@ -196,15 +259,15 @@ export class VoxelEditController extends SharedObject { if (targetKey === null) return null; // Prepare indices and values to write into the target chunk - const chunkW = sourceChunk.size[0] / 2; // target subregion width - const chunkH = sourceChunk.size[1] / 2; - const chunkD = sourceChunk.size[2] / 2; + const chunkW = sourceChunk.chunkDataSize[0] / 2; // target subregion width + const chunkH = sourceChunk.chunkDataSize[1] / 2; + const chunkD = sourceChunk.chunkDataSize[2] / 2; const offsetX = (info.x % 2) * chunkW; const offsetY = (info.y % 2) * chunkH; const offsetZ = (info.z % 2) * chunkD; - const targetSizeX = 1// cfg.chunkDataSize[0]; - const targetSizeY =1 // cfg.chunkDataSize[1]; + const targetSizeX = 1; // TODO: compute from parent spec if needed + const targetSizeY = 1; const indices: number[] = []; const values: number[] = []; @@ -215,9 +278,9 @@ export class VoxelEditController extends SharedObject { const sx = x * 2; const sy = y * 2; const sz = z * 2; - const base = sz * (sourceChunk.size[0] * sourceChunk.size[1]) + sy * sourceChunk.size[0] + sx; - const row = sourceChunk.size[0]; - const plane = sourceChunk.size[0] * sourceChunk.size[1]; + const base = sz * (sourceChunk.chunkDataSize[0] * sourceChunk.chunkDataSize[1]) + sy * sourceChunk.chunkDataSize[0] + sx; + const row = sourceChunk.chunkDataSize[0]; + const plane = sourceChunk.chunkDataSize[0] * sourceChunk.chunkDataSize[1]; // Gather 8 voxels from source const v000 = Number((sourceChunk.data as any)[base]); const v100 = Number((sourceChunk.data as any)[base + 1]); @@ -240,9 +303,17 @@ export class VoxelEditController extends SharedObject { } if (indices.length > 0) { - //await src.applyEdits([ - // { key: targetKey, indices, values }, - //]); + const targetInfo = parseVoxChunkKey(targetKey); + if (targetInfo) { + const targetSrc = this.sources.get(targetInfo.lod); + if (targetSrc) { + await (targetSrc as any).applyEdits(targetInfo.chunkKey, indices, values); + // Notify frontend to reload the target parent chunk at its LOD. + this.callChunkReload([targetKey]); + } else { + console.error(`Downsampling failed: could not find target source for LOD ${targetInfo.lod}`); + } + } } return targetKey; diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 7e18141089..85d2f3cce0 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -11,6 +11,8 @@ import { VOX_EDIT_LABELS_ADD_RPC_ID, VOX_EDIT_LABELS_GET_RPC_ID, VOX_RELOAD_CHUNKS_RPC_ID, + makeVoxChunkKey, + parseVoxChunkKey, } from "#src/voxel_annotation/base.js"; import { registerRPC, @@ -26,7 +28,26 @@ export class VoxelEditController extends SharedObject { if (!rpc) { throw new Error("VoxelEditController: Missing RPC from multiscale chunk manager."); } - this.initializeCounterpart(rpc, {}); + + // Get all sources for all scales and orientations + const sourcesByScale = this.multiscale.getSources(this.getIdentitySliceViewSourceOptions()); + const sources = sourcesByScale[0]; + if (!sources) { + throw new Error("VoxelEditController: Could not retrieve sources from multiscale object."); + } + + const sourceMap: { [key: number]: number } = {}; + for (let i = 0; i < sources.length; ++i) { + const source = sources[i]!.chunkSource; + const lodFactor = 1 << i; // LOD factor is 2^i + const rpcId = source.rpcId; + if (rpcId == null) { + throw new Error(`VoxelEditController: Source at LOD index ${i} has null rpcId during initialization.`); + } + sourceMap[lodFactor] = rpcId; + } + + this.initializeCounterpart(rpc, { sources: sourceMap }); } private static readonly qualityFactor = 16.0; private static readonly restrictToMinLOD = true; @@ -174,16 +195,18 @@ export class VoxelEditController extends SharedObject { } if (!voxelsToPaint || voxelsToPaint.length === 0) return; - const editsByChunk = new Map(); + const editsByVoxKey = new Map(); + const lodFactor = 1 << sourceIndex; for (const voxelCoord of voxelsToPaint) { const { chunkGridPosition, positionWithinChunk } = source.computeChunkIndices(voxelCoord); - const key = chunkGridPosition.join(); + const chunkKey = chunkGridPosition.join(); + const voxKey = makeVoxChunkKey(chunkKey, lodFactor); - let entry = editsByChunk.get(key); + let entry = editsByVoxKey.get(voxKey); if (!entry) { entry = { indices: [], value }; - editsByChunk.set(key, entry); + editsByVoxKey.set(voxKey, entry); } const { chunkDataSize } = source.spec; @@ -191,18 +214,21 @@ export class VoxelEditController extends SharedObject { entry.indices.push(index); } - source.applyLocalEdits(editsByChunk); + // Apply edits locally on the specific source for immediate feedback. + const localEdits = new Map(); + for (const [voxKey, edit] of editsByVoxKey.entries()) { + const parsed = parseVoxChunkKey(voxKey); + if (!parsed) continue; + localEdits.set(parsed.chunkKey, edit); + } + source.applyLocalEdits(localEdits); - const backendEdits = []; - for (const [key, edit] of editsByChunk.entries()) { - backendEdits.push({ key, indices: edit.indices, value: edit.value }); + const backendEdits = [] as { key: string; indices: number[]; value: number }[]; + for (const [voxKey, edit] of editsByVoxKey.entries()) { + backendEdits.push({ key: voxKey, indices: edit.indices, value: edit.value }); } - if (!this.rpc) throw new Error("VoxelEditController.paintBrushWithShape: RPC not initialized."); - this.rpc.invoke(VOX_EDIT_COMMIT_VOXELS_RPC_ID, { - rpcId: this.rpcId, - edits: backendEdits, - }); + this.commitEdits(backendEdits); } async getLabelIds(): Promise { @@ -412,35 +438,71 @@ export class VoxelEditController extends SharedObject { } } - const editsByChunk = new Map(); + const editsByVoxKey = new Map(); + const lodFactor = 1 << sourceIndex; for (const voxelCoord of voxelsToFill) { const { chunkGridPosition, positionWithinChunk } = source.computeChunkIndices(voxelCoord); - const key = chunkGridPosition.join(); + const chunkKey = chunkGridPosition.join(); + const voxKey = makeVoxChunkKey(chunkKey, lodFactor); - let entry = editsByChunk.get(key); + let entry = editsByVoxKey.get(voxKey); if (!entry) { entry = { indices: [], value: fillValue }; - editsByChunk.set(key, entry); + editsByVoxKey.set(voxKey, entry); } const { chunkDataSize } = source.spec; const index = (positionWithinChunk[2] * chunkDataSize[1] + positionWithinChunk[1]) * chunkDataSize[0] + positionWithinChunk[0]; entry.indices.push(index); } - // Apply edits locally for preview. - source.applyLocalEdits(editsByChunk); + // Apply edits locally for preview on this source. + const localEdits = new Map(); + for (const [voxKey, edit] of editsByVoxKey.entries()) { + const parsed = parseVoxChunkKey(voxKey); + if (!parsed) continue; + localEdits.set(parsed.chunkKey, edit); + } + source.applyLocalEdits(localEdits); - // Prepare edits for the backend. + // Prepare edits for the backend keyed by voxKey. const backendEdits: { key: string; indices: number[]; value: number }[] = []; - for (const [key, edit] of editsByChunk.entries()) { - backendEdits.push({ key, indices: edit.indices, value: edit.value }); + for (const [voxKey, edit] of editsByVoxKey.entries()) { + backendEdits.push({ key: voxKey, indices: edit.indices, value: edit.value }); } + this.commitEdits(backendEdits); + return { edits: backendEdits, filledCount, originalValue: originalValue >>> 0 }; } - callChunkReload(_voxChunkKeys: string[]) { - /// TODO + callChunkReload(voxChunkKeys: string[]) { + if (!Array.isArray(voxChunkKeys) || voxChunkKeys.length === 0) return; + // This assumes the multiscale source has a single orientation. + const sourcesByScale = (this.multiscale as any).getSources(this.getIdentitySliceViewSourceOptions()); + const sources = sourcesByScale && sourcesByScale[0]; + if (!sources) return; + + const chunksToInvalidateBySource = new Map(); + + for (const voxKey of voxChunkKeys) { + const parsed = parseVoxChunkKey(voxKey); + if (!parsed) continue; + const lodIndex = Math.log2(parsed.lod); + const source = sources[lodIndex]?.chunkSource as VolumeChunkSource | undefined; + if (!source) continue; + let arr = chunksToInvalidateBySource.get(source); + if (!arr) { + arr = []; + chunksToInvalidateBySource.set(source, arr); + } + arr.push(parsed.chunkKey); + } + + for (const [source, keys] of chunksToInvalidateBySource.entries()) { + if (keys.length > 0) { + source.invalidateChunks(keys); + } + } } } From b24666e343edcc3fc558b538f6fdecfc50f83a33 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 068/251] feat: improve chunk handling and integrate SSA write/delete support (note: writting pipeline is kinda working but feels unreliable) - Add retry logic for chunk downloads across modules to ensure data availability. - Implement `write` and `delete` methods for SSA-signed URL operations in `KvStore`. - Refactor `VoxelEditController` for extended error handling and message propagation. - Update constructors of `VoxelEditController` with `VoxUserLayer` dependency. - Fix index computations for chunk downsampling to use accurate target dimensions. --- src/kvstore/ssa_s3/ssa_s3_kvstore.ts | 49 +++++++++++++++++++++++++ src/layer/vox/index.ts | 2 +- src/sliceview/volume/backend.ts | 10 +++++ src/voxel_annotation/edit_backend.ts | 32 ++++++++++------ src/voxel_annotation/edit_controller.ts | 9 ++++- 5 files changed, 88 insertions(+), 14 deletions(-) diff --git a/src/kvstore/ssa_s3/ssa_s3_kvstore.ts b/src/kvstore/ssa_s3/ssa_s3_kvstore.ts index 291441a6c5..ace64aa608 100644 --- a/src/kvstore/ssa_s3/ssa_s3_kvstore.ts +++ b/src/kvstore/ssa_s3/ssa_s3_kvstore.ts @@ -242,6 +242,55 @@ export class SsaS3KvStore implements KvStore { } } + async write(key: string, value: ArrayBuffer): Promise { + const fullKey = joinPath(this.datasetBasePrefix, key); + const url = await this.signSingleUrl(fullKey, "PUT"); + try { + await fetchOk(url, { + method: "PUT", + body: value, + }); + } catch (e) { + if (e instanceof HttpError && (e.status === 401 || e.status === 403)) { + throw new Error( + `Permission denied by SSA while writing ${this.getUrl(key)} (HTTP ${e.status}).`, + { cause: e }, + ); + } + throw new Error( + `Failed to write ${this.getUrl(key)} via SSA-signed URL: ${(e as Error).message}`, + { cause: e }, + ); + } + } + + async delete(key: string): Promise { + const fullKey = joinPath(this.datasetBasePrefix, key); + const url = await this.signSingleUrl(fullKey, "DELETE"); + try { + await fetchOk(url, { + method: "DELETE", + }); + } catch (e) { + if (e instanceof HttpError) { + if (e.status === 404) { + // Deleting a non-existent object is not considered an error in S3. + return; + } + if (e.status === 401 || e.status === 403) { + throw new Error( + `Permission denied by SSA while deleting ${this.getUrl(key)} (HTTP ${e.status}).`, + { cause: e }, + ); + } + } + throw new Error( + `Failed to delete ${this.getUrl(key)} via SSA-signed URL: ${(e as Error).message}`, + { cause: e }, + ); + } + } + async stat(key: string, options: StatOptions): Promise { const fullKey = joinPath(this.datasetBasePrefix, key); const url = await this.signSingleUrl(fullKey, "HEAD", options.signal); diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index ccc1f79305..7518871637 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -238,7 +238,7 @@ export class VoxUserLayer extends UserLayer { ); continue; } - this.voxEditController = new VoxelEditController(volume); + this.voxEditController = new VoxelEditController(this, volume); loadedSubsource.activate( () => { const renderLayer = new VoxelAnnotationRenderLayer(volume, { diff --git a/src/sliceview/volume/backend.ts b/src/sliceview/volume/backend.ts index 09d6fc5021..282d8a0e91 100644 --- a/src/sliceview/volume/backend.ts +++ b/src/sliceview/volume/backend.ts @@ -180,6 +180,16 @@ export class VolumeChunkSource const ac = new AbortController(); await this.download(chunk, ac.signal); } + + if (!chunk.data) { + try { + const ac = new AbortController(); + await this.download(chunk, ac.signal); + } catch { + // + } + } + if (!chunk.data) { // If chunk.data is null, the chunk does not exist at the source or was evicted. // Create a new, zero-filled chunk to apply the edits to. diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/edit_backend.ts index 6efb0b5882..84314f95e5 100644 --- a/src/voxel_annotation/edit_backend.ts +++ b/src/voxel_annotation/edit_backend.ts @@ -16,7 +16,7 @@ import { parseVoxChunkKey, } from "#src/voxel_annotation/base.js"; import type { RPC} from "#src/worker_rpc.js"; -import { SharedObject , registerPromiseRPC, registerRPC, registerSharedObject, initializeSharedObjectCounterpart } from "#src/worker_rpc.js"; +import { SharedObject , registerPromiseRPC, registerSharedObject, initializeSharedObjectCounterpart } from "#src/worker_rpc.js"; @registerSharedObject(VOX_EDIT_BACKEND_RPC_ID) export class VoxelEditController extends SharedObject { @@ -115,11 +115,6 @@ export class VoxelEditController extends SharedObject { } } - // 3. Invalidate frontend caches for the modified chunks. - if (touchedVoxChunkKeys.length > 0) { - this.callChunkReload(touchedVoxChunkKeys); - } - // After base edits, enqueue downsampling for affected chunks (do not await here). const touched = new Set(); for (const e of edits) touched.add(e.key); @@ -255,6 +250,20 @@ export class VoxelEditController extends SharedObject { ) as any; if (!sourceChunk) return null; + if (!sourceChunk.data) { + try { + const ac = new AbortController(); + await src.download(sourceChunk, ac.signal); + } catch (e) { + console.warn(`Failed to download source chunk ${sourceKey} for downsampling:`, e); + } + } + + if (!sourceChunk.data) { + console.warn(`Cannot downsample from empty or missing chunk: ${sourceKey}`); + return null; + } + const targetKey = this.parentKeyOf(sourceKey); if (targetKey === null) return null; @@ -266,8 +275,8 @@ export class VoxelEditController extends SharedObject { const offsetY = (info.y % 2) * chunkH; const offsetZ = (info.z % 2) * chunkD; - const targetSizeX = 1; // TODO: compute from parent spec if needed - const targetSizeY = 1; + const targetChunkW = sourceChunk.chunkDataSize[0]; + const targetChunkH = sourceChunk.chunkDataSize[1]; const indices: number[] = []; const values: number[] = []; @@ -295,7 +304,7 @@ export class VoxelEditController extends SharedObject { const tx = x + offsetX; const ty = y + offsetY; const tz = z + offsetZ; - const tIndex = tz * (targetSizeX * targetSizeY) + ty * targetSizeX + tx; + const tIndex = tz * (targetChunkW * targetChunkH) + ty * targetChunkW + tx; indices.push(tIndex); values.push(mode >>> 0); } @@ -320,9 +329,10 @@ export class VoxelEditController extends SharedObject { } } -registerRPC(VOX_EDIT_COMMIT_VOXELS_RPC_ID, function (x: any) { +registerPromiseRPC(VOX_EDIT_COMMIT_VOXELS_RPC_ID, async function (x: any) { const obj = this.get(x.rpcId) as VoxelEditController; - obj.commitVoxels(x.edits || []); + await obj.commitVoxels(x.edits || []); + return { value: undefined }; }); registerPromiseRPC(VOX_EDIT_LABELS_GET_RPC_ID, async function (x: any) { diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 85d2f3cce0..1a2aca2e1a 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -3,6 +3,7 @@ * Copyright 2025. */ +import type { VoxUserLayer } from "#src/layer/vox/index.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; import type { VolumeChunkSource , MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; import { @@ -22,7 +23,7 @@ import { @registerSharedObjectOwner(VOX_EDIT_BACKEND_RPC_ID) export class VoxelEditController extends SharedObject { - constructor(private multiscale: MultiscaleVolumeChunkSource) { + constructor(private layer: VoxUserLayer, private multiscale: MultiscaleVolumeChunkSource) { super(); const rpc = (this.multiscale as any)?.chunkManager?.rpc; if (!rpc) { @@ -252,9 +253,13 @@ export class VoxelEditController extends SharedObject { if (!Array.isArray(edits)) { throw new Error("VoxelEditController.commitEdits: edits must be an array."); } - this.rpc.invoke(VOX_EDIT_COMMIT_VOXELS_RPC_ID, { + this.rpc.promiseInvoke(VOX_EDIT_COMMIT_VOXELS_RPC_ID, { rpcId: this.rpcId, edits, + }).catch(error => { + const message = (error instanceof Error) ? error.message : String(error); + console.error("Failed to commit edits:", message); + this.layer.setDrawErrorMessage(message); }); } From 35c58a1f5dba324b75cd7ff7389214c848c3265c Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 069/251] feat: enhance retry logic, s3 support, and voxel annotation - Add exponential backoff retry for `VolumeChunkSource.writeChunk`. - Replace `ReadableS3KvStore` with generalized `S3KvStoreBase`. - Introduce `write` and `delete` methods in `S3KvStoreBase`. - Integrate failure handling and frontend notification for voxel edits. - Refactor `VoxelEditController` for clearer error propagation. - Optimize downsampling pipeline and chunk edit handling. --- NOTES/TODOs.md | 24 ++++- src/kvstore/s3/backend.ts | 4 +- src/kvstore/s3/common.ts | 45 +++++++-- src/kvstore/s3/frontend.ts | 4 +- src/sliceview/volume/backend.ts | 18 +++- src/voxel_annotation/base.ts | 4 +- src/voxel_annotation/edit_backend.ts | 120 ++++++++++++++++++------ src/voxel_annotation/edit_controller.ts | 23 ++++- 8 files changed, 194 insertions(+), 48 deletions(-) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index bd9f4f6b32..758406329d 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,6 +1,6 @@ # TODO List -- FOR TOMORROW: start to prepare the problematic/email to JMS +- FOR TOMORROW: start to prepare the problematic/email to JMS + strengthen the drawing pipeline + add label management back - Fix the orientation of the disk in the brush tool - Add support for flood fill on different planes @@ -15,3 +15,25 @@ - rework vox backend - rework label handling + + +## mail + +Hey, I am writting a mail for Jeremy Maitin-Shepard, the creator of neuroglancer, to present him the voxel annotation layer and get his opinion on the architecture and design choices before I start to consolate the code. Can you help me write it? Here is a draft of the mail: + +Hello, + +...intro +I am currently working on a voxel annotation layer for neuroglancer as part of my internship at Ariadne.ai. The goal is to allow users to annotate volumetric data directly within the neuroglancer interface, with the objective in the end to realize labeling for deep learning. I saw that you mentioned this feature in this talk: https://www.youtube.com/watch?v=_XgfGcu81AA + +We made good progress on the feature and have a working prototype, and feel like it is the right time to share it with you, to have your opinion on the architecture and design choices before I start to consilate the code. + +Here is how it works: + +We have a new "vox" layer accepting volume data sources [src/layer/vox/index.ts], and we have a brush and a flood fill tool (with an erase mode for both, i.e. label = 0) [src/ui/voxel_annotation.ts]. + +Drawing happens at a set resolution (currently locked at the max resolution), the drawn chunks are the downscaled. Since the resolution is fixed, the max brush size is limited to 64, after what the performances are too poor. I tried to implement an upscaling root too, for this there are two path: +- the first one is to upscale right after drawing, like the downscaling, but we quickly hit limitation on the number of upscale step we can perform (because of the exponential nature of the upscale that the downscale hasn't), I estimate them to be 3 to 4 steps max. This method would allow for higher brush size, but not for giant brush ones. +- an other way is to delay the upscaling to the moment we want to draw the chunk that needs upscaling, I tried this method but we quickly hit some upscaling conflicts issues and this method is also not compatible with the generic data sources, requiring a way to mark chunks as dirty. + +The drawing pipeline is handled by the EditController class [src/voxel_annotation/edit_controller.ts & src/voxel_annotation/edit_backend.ts], a shared object, diff --git a/src/kvstore/s3/backend.ts b/src/kvstore/s3/backend.ts index 7577a34618..971c3c9dc7 100644 --- a/src/kvstore/s3/backend.ts +++ b/src/kvstore/s3/backend.ts @@ -17,9 +17,9 @@ import type { SharedKvStoreContextCounterpart } from "#src/kvstore/backend.js"; import type { DriverListOptions, ListResponse } from "#src/kvstore/index.js"; import { proxyList } from "#src/kvstore/proxy.js"; -import { ReadableS3KvStore } from "#src/kvstore/s3/common.js"; +import { S3KvStoreBase } from "#src/kvstore/s3/common.js"; -export class S3KvStore extends ReadableS3KvStore { +export class S3KvStore extends S3KvStoreBase { list(prefix: string, options: DriverListOptions): Promise { return proxyList(this.sharedKvStoreContext, this.getUrl(prefix), options); } diff --git a/src/kvstore/s3/common.ts b/src/kvstore/s3/common.ts index e1e19ca06f..2fe0253764 100644 --- a/src/kvstore/s3/common.ts +++ b/src/kvstore/s3/common.ts @@ -34,11 +34,11 @@ import { listS3CompatibleUrl, } from "#src/kvstore/s3/list.js"; import { joinBaseUrlAndPath } from "#src/kvstore/url.js"; -import type { FetchOk } from "#src/util/http_request.js"; -import { fetchOk } from "#src/util/http_request.js"; +import type { FetchOk} from "#src/util/http_request.js"; +import { HttpError, fetchOk } from "#src/util/http_request.js"; import { ProgressSpan } from "#src/util/progress_listener.js"; -export class ReadableS3KvStore< +export class S3KvStoreBase< SharedKvStoreContext extends SharedKvStoreContextBase, > implements KvStore { @@ -88,6 +88,39 @@ export class ReadableS3KvStore< ); } + + async write(key: string, value: ArrayBuffer): Promise { + const url = joinBaseUrlAndPath(this.baseUrl, key); + try { + await this.fetchOkImpl(url, { + method: 'PUT', + body: value, + }); + } catch (e) { + throw new Error( + `Failed to write to ${url}.`, + { cause: e } + ); + } + } + + async delete(key: string): Promise { + const url = joinBaseUrlAndPath(this.baseUrl, key); + try { + await this.fetchOkImpl(url, { + method: 'DELETE', + }); + } catch (e) { + if (e instanceof HttpError && e.status === 404) { + return; + } + throw new Error( + `Failed to delete ${url}.`, + { cause: e } + ); + } + } + getUrl(path: string) { return joinBaseUrlAndPath(this.baseUrlForDisplay, path); } @@ -104,7 +137,7 @@ function amazonS3Provider< SharedKvStoreContext extends SharedKvStoreContextBase, >( sharedKvStoreContext: SharedKvStoreContext, - s3KvStoreClass: typeof ReadableS3KvStore, + s3KvStoreClass: typeof S3KvStoreBase, ): BaseKvStoreProvider { return { scheme: "s3", @@ -131,7 +164,7 @@ function amazonS3Provider< function s3Provider( sharedKvStoreContext: SharedKvStoreContext, httpScheme: "http" | "https", - s3KvStoreClass: typeof ReadableS3KvStore, + s3KvStoreClass: typeof S3KvStoreBase, ): BaseKvStoreProvider { return { scheme: `s3+${httpScheme}`, @@ -161,7 +194,7 @@ export function registerProviders< SharedKvStoreContext extends SharedKvStoreContextBase, >( registry: KvStoreProviderRegistry, - s3KvStoreClass: typeof ReadableS3KvStore, + s3KvStoreClass: typeof S3KvStoreBase, ) { registry.registerBaseKvStoreProvider((context) => amazonS3Provider(context, s3KvStoreClass), diff --git a/src/kvstore/s3/frontend.ts b/src/kvstore/s3/frontend.ts index b9c4fee91d..ef0ed81111 100644 --- a/src/kvstore/s3/frontend.ts +++ b/src/kvstore/s3/frontend.ts @@ -16,7 +16,7 @@ import type { SharedKvStoreContext } from "#src/kvstore/frontend.js"; import type { DriverListOptions, ListResponse } from "#src/kvstore/index.js"; -import { ReadableS3KvStore } from "#src/kvstore/s3/common.js"; +import { S3KvStoreBase } from "#src/kvstore/s3/common.js"; import { getS3BucketListing, listS3CompatibleUrl, @@ -24,7 +24,7 @@ import { import { joinBaseUrlAndPath } from "#src/kvstore/url.js"; import { ProgressSpan } from "#src/util/progress_listener.js"; -export class S3KvStore extends ReadableS3KvStore { +export class S3KvStore extends S3KvStoreBase { list(prefix: string, options: DriverListOptions): Promise { const { progressListener } = options; using _span = diff --git a/src/sliceview/volume/backend.ts b/src/sliceview/volume/backend.ts index 282d8a0e91..bf5da4dc11 100644 --- a/src/sliceview/volume/backend.ts +++ b/src/sliceview/volume/backend.ts @@ -31,6 +31,7 @@ import type { import type { TypedArray } from "#src/util/array.js"; import { DATA_TYPE_ARRAY_CONSTRUCTOR } from "#src/util/data_type.js"; import type { vec3 } from "#src/util/geom.js"; +import { HttpError } from "#src/util/http_request.js"; import * as vector from "#src/util/vector.js"; import type { RPC } from "#src/worker_rpc.js"; @@ -213,7 +214,22 @@ export class VolumeChunkSource } (data as any)[idx] = val; // TypedArray index assignment } - await this.writeChunk(chunk); + const maxRetries = 3; + let lastError: Error | undefined; + + for (let i = 0; i < maxRetries; i++) { + try { + await this.writeChunk(chunk); + return; + } catch (e) { + lastError = e as Error; + if (e instanceof HttpError && e.status < 500 && e.status !== 429) { + break; + } + await new Promise(resolve => setTimeout(resolve, 250 * Math.pow(2, i))); + } + } + throw new Error(`Failed to write chunk ${chunkKey} after ${maxRetries} attempts.`, { cause: lastError }); } } VolumeChunkSource.prototype.chunkConstructor = VolumeChunk; diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index 58935ff15d..c9b7391488 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -1,11 +1,9 @@ -export const VOX_CHUNK_SOURCE_RPC_ID = "vox.VoxChunkSource"; -export const VOX_MAP_INIT_RPC_ID = "vox.map.init"; export const VOX_RELOAD_CHUNKS_RPC_ID = "vox.chunk.reload"; export const VOX_EDIT_BACKEND_RPC_ID = "vox.EditBackend"; -export const VOX_EDIT_MAP_INIT_RPC_ID = "vox.edit.map.init"; export const VOX_EDIT_COMMIT_VOXELS_RPC_ID = "vox.edit.commitVoxels"; export const VOX_EDIT_LABELS_GET_RPC_ID = "vox.edit.labels.get"; export const VOX_EDIT_LABELS_ADD_RPC_ID = "vox.edit.labels.add"; +export const VOX_EDIT_FAILURE_RPC_ID = "vox.edit.failure"; export function makeVoxChunkKey(chunkKey: string, lodFactor : number) { return `lod${lodFactor}#${chunkKey}`; diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/edit_backend.ts index 84314f95e5..20cd52ba46 100644 --- a/src/voxel_annotation/edit_backend.ts +++ b/src/voxel_annotation/edit_backend.ts @@ -12,11 +12,12 @@ import { VOX_EDIT_LABELS_ADD_RPC_ID, VOX_EDIT_LABELS_GET_RPC_ID, VOX_RELOAD_CHUNKS_RPC_ID, + VOX_EDIT_FAILURE_RPC_ID, makeVoxChunkKey, parseVoxChunkKey, } from "#src/voxel_annotation/base.js"; import type { RPC} from "#src/worker_rpc.js"; -import { SharedObject , registerPromiseRPC, registerSharedObject, initializeSharedObjectCounterpart } from "#src/worker_rpc.js"; +import { SharedObject , registerRPC, registerPromiseRPC, registerSharedObject, initializeSharedObjectCounterpart } from "#src/worker_rpc.js"; @registerSharedObject(VOX_EDIT_BACKEND_RPC_ID) export class VoxelEditController extends SharedObject { @@ -45,8 +46,10 @@ export class VoxelEditController extends SharedObject { initializeSharedObjectCounterpart(this, rpc, options); const passedSources = options?.sources; - if (passedSources === undefined || typeof passedSources !== 'object') { - throw new Error("VoxelEditBackend: missing required 'sources' map during initialization"); + if (passedSources === undefined || typeof passedSources !== "object") { + throw new Error( + "VoxelEditBackend: missing required 'sources' map during initialization", + ); } for (const lodFactorStr in passedSources) { @@ -54,7 +57,9 @@ export class VoxelEditController extends SharedObject { const sourceId = passedSources[lodFactorStr]; const resolved = rpc.get(sourceId) as VolumeChunkSource | undefined; if (!resolved) { - throw new Error(`VoxelEditBackend: failed to resolve VolumeChunkSource for LOD factor ${lodFactor}`); + throw new Error( + `VoxelEditBackend: failed to resolve VolumeChunkSource for LOD factor ${lodFactor}`, + ); } this.sources.set(lodFactor, resolved); } @@ -67,7 +72,10 @@ export class VoxelEditController extends SharedObject { if (edits.length === 0) return; // 1. Group edits by vox chunk key (includes LOD). - const editsByVoxKey = new Map(); + const editsByVoxKey = new Map< + string, + { indices: number[]; values: number[] } + >(); for (const edit of edits) { if (!editsByVoxKey.has(edit.key)) { editsByVoxKey.set(edit.key, { indices: [], values: [] }); @@ -95,26 +103,48 @@ export class VoxelEditController extends SharedObject { } // 2. For each modified vox chunk, apply edits via the correct source and record vox keys to reload. - const touchedVoxChunkKeys: string[] = []; + const failedVoxChunkKeys: string[] = []; + let firstErrorMessage: string | undefined = undefined; for (const [voxKey, chunkEdits] of editsByVoxKey.entries()) { try { const parsedKey = parseVoxChunkKey(voxKey); if (!parsedKey) { - console.error(`flushPending: Failed to parse vox chunk key: ${voxKey}`); + const msg = `flushPending: Failed to parse vox chunk key: ${voxKey}`; + console.error(msg); + failedVoxChunkKeys.push(voxKey); + if (firstErrorMessage === undefined) firstErrorMessage = msg; continue; } const source = this.sources.get(parsedKey.lod); if (!source) { - console.error(`flushPending: No source found for LOD factor ${parsedKey.lod}`); + const msg = `flushPending: No source found for LOD factor ${parsedKey.lod}`; + console.error(msg); + failedVoxChunkKeys.push(voxKey); + if (firstErrorMessage === undefined) firstErrorMessage = msg; continue; } - await (source as any).applyEdits(parsedKey.chunkKey, chunkEdits.indices, chunkEdits.values); - touchedVoxChunkKeys.push(voxKey); + await (source as any).applyEdits( + parsedKey.chunkKey, + chunkEdits.indices, + chunkEdits.values, + ); } catch (e) { + const msg = e instanceof Error ? e.message : String(e); console.error(`Failed to write chunk ${voxKey}:`, e); + failedVoxChunkKeys.push(voxKey); + if (firstErrorMessage === undefined) firstErrorMessage = msg; } } + // If any failures occurred, notify the frontend asynchronously. + if (failedVoxChunkKeys.length > 0) { + this.rpc?.invoke(VOX_EDIT_FAILURE_RPC_ID, { + rpcId: this.rpcId, + voxChunkKeys: failedVoxChunkKeys, + message: firstErrorMessage ?? "Voxel edit commit failed.", + }); + } + // After base edits, enqueue downsampling for affected chunks (do not await here). const touched = new Set(); for (const e of edits) touched.add(e.key); @@ -138,8 +168,11 @@ export class VoxelEditController extends SharedObject { } this.pendingEdits.push(e); } - if (this.commitDebounceTimer !== undefined) clearTimeout(this.commitDebounceTimer); - this.commitDebounceTimer = setTimeout(() => { void this.flushPending(); }, this.commitDebounceDelayMs) as unknown as number; + if (this.commitDebounceTimer !== undefined) + clearTimeout(this.commitDebounceTimer); + this.commitDebounceTimer = setTimeout(() => { + void this.flushPending(); + }, this.commitDebounceDelayMs) as unknown as number; } async getLabelIds(): Promise { @@ -152,11 +185,11 @@ export class VoxelEditController extends SharedObject { return []; } - callChunkReload(voxChunkKeys: string[]){ + callChunkReload(voxChunkKeys: string[]) { this.rpc?.invoke(VOX_RELOAD_CHUNKS_RPC_ID, { rpcId: this.rpcId, voxChunkKeys: voxChunkKeys, - }) + }); } // Downsampling helpers private parentKeyOf(childKey: string): string | null { @@ -174,7 +207,9 @@ export class VoxelEditController extends SharedObject { return Math.ceil(Math.log2(chunkSize)); } - private async performDownsampleCascadeForKey(sourceKey: string): Promise { + private async performDownsampleCascadeForKey( + sourceKey: string, + ): Promise { const chunkSize = 64; const maxPasses = this.calculateDownsamplePasses(chunkSize); const maxLOD = 256; @@ -231,7 +266,10 @@ export class VoxelEditController extends SharedObject { } finally { this.isProcessingDownsampleQueue = false; // If new work was enqueued during processing and flag got reset, loop again. - if (this.downsampleQueue.length > 0 && !this.isProcessingDownsampleQueue) { + if ( + this.downsampleQueue.length > 0 && + !this.isProcessingDownsampleQueue + ) { this.isProcessingDownsampleQueue = true; Promise.resolve().then(() => this.processDownsampleQueue()); } @@ -255,12 +293,17 @@ export class VoxelEditController extends SharedObject { const ac = new AbortController(); await src.download(sourceChunk, ac.signal); } catch (e) { - console.warn(`Failed to download source chunk ${sourceKey} for downsampling:`, e); + console.warn( + `Failed to download source chunk ${sourceKey} for downsampling:`, + e, + ); } } if (!sourceChunk.data) { - console.warn(`Cannot downsample from empty or missing chunk: ${sourceKey}`); + console.warn( + `Cannot downsample from empty or missing chunk: ${sourceKey}`, + ); return null; } @@ -287,9 +330,13 @@ export class VoxelEditController extends SharedObject { const sx = x * 2; const sy = y * 2; const sz = z * 2; - const base = sz * (sourceChunk.chunkDataSize[0] * sourceChunk.chunkDataSize[1]) + sy * sourceChunk.chunkDataSize[0] + sx; + const base = + sz * (sourceChunk.chunkDataSize[0] * sourceChunk.chunkDataSize[1]) + + sy * sourceChunk.chunkDataSize[0] + + sx; const row = sourceChunk.chunkDataSize[0]; - const plane = sourceChunk.chunkDataSize[0] * sourceChunk.chunkDataSize[1]; + const plane = + sourceChunk.chunkDataSize[0] * sourceChunk.chunkDataSize[1]; // Gather 8 voxels from source const v000 = Number((sourceChunk.data as any)[base]); const v100 = Number((sourceChunk.data as any)[base + 1]); @@ -298,13 +345,25 @@ export class VoxelEditController extends SharedObject { const v001 = Number((sourceChunk.data as any)[base + plane]); const v101 = Number((sourceChunk.data as any)[base + plane + 1]); const v011 = Number((sourceChunk.data as any)[base + plane + row]); - const v111 = Number((sourceChunk.data as any)[base + plane + row + 1]); - const mode = this.calculateMode([v000, v100, v010, v110, v001, v101, v011, v111]); + const v111 = Number( + (sourceChunk.data as any)[base + plane + row + 1], + ); + const mode = this.calculateMode([ + v000, + v100, + v010, + v110, + v001, + v101, + v011, + v111, + ]); const tx = x + offsetX; const ty = y + offsetY; const tz = z + offsetZ; - const tIndex = tz * (targetChunkW * targetChunkH) + ty * targetChunkW + tx; + const tIndex = + tz * (targetChunkW * targetChunkH) + ty * targetChunkW + tx; indices.push(tIndex); values.push(mode >>> 0); } @@ -316,11 +375,17 @@ export class VoxelEditController extends SharedObject { if (targetInfo) { const targetSrc = this.sources.get(targetInfo.lod); if (targetSrc) { - await (targetSrc as any).applyEdits(targetInfo.chunkKey, indices, values); + await (targetSrc as any).applyEdits( + targetInfo.chunkKey, + indices, + values, + ); // Notify frontend to reload the target parent chunk at its LOD. this.callChunkReload([targetKey]); } else { - console.error(`Downsampling failed: could not find target source for LOD ${targetInfo.lod}`); + console.error( + `Downsampling failed: could not find target source for LOD ${targetInfo.lod}`, + ); } } } @@ -329,10 +394,9 @@ export class VoxelEditController extends SharedObject { } } -registerPromiseRPC(VOX_EDIT_COMMIT_VOXELS_RPC_ID, async function (x: any) { +registerRPC(VOX_EDIT_COMMIT_VOXELS_RPC_ID, function (x: any) { const obj = this.get(x.rpcId) as VoxelEditController; - await obj.commitVoxels(x.edits || []); - return { value: undefined }; + void obj.commitVoxels(Array.isArray(x.edits) ? x.edits : []); }); registerPromiseRPC(VOX_EDIT_LABELS_GET_RPC_ID, async function (x: any) { diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 1a2aca2e1a..b93f9120eb 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -12,6 +12,7 @@ import { VOX_EDIT_LABELS_ADD_RPC_ID, VOX_EDIT_LABELS_GET_RPC_ID, VOX_RELOAD_CHUNKS_RPC_ID, + VOX_EDIT_FAILURE_RPC_ID, makeVoxChunkKey, parseVoxChunkKey, } from "#src/voxel_annotation/base.js"; @@ -253,13 +254,9 @@ export class VoxelEditController extends SharedObject { if (!Array.isArray(edits)) { throw new Error("VoxelEditController.commitEdits: edits must be an array."); } - this.rpc.promiseInvoke(VOX_EDIT_COMMIT_VOXELS_RPC_ID, { + this.rpc.invoke(VOX_EDIT_COMMIT_VOXELS_RPC_ID, { rpcId: this.rpcId, edits, - }).catch(error => { - const message = (error instanceof Error) ? error.message : String(error); - console.error("Failed to commit edits:", message); - this.layer.setDrawErrorMessage(message); }); } @@ -509,6 +506,15 @@ export class VoxelEditController extends SharedObject { } } } + + /** Backend failure notification handler: revert optimistic preview and show UI message. */ + handleCommitFailure(voxChunkKeys: string[], message: string): void { + try { + this.callChunkReload(voxChunkKeys); + } finally { + this.layer.setDrawErrorMessage(message); + } + } } registerRPC(VOX_RELOAD_CHUNKS_RPC_ID, function (x: any) { @@ -516,3 +522,10 @@ registerRPC(VOX_RELOAD_CHUNKS_RPC_ID, function (x: any) { const keys: string[] = Array.isArray(x.voxChunkKeys) ? x.voxChunkKeys : []; obj.callChunkReload(keys); }); + +registerRPC(VOX_EDIT_FAILURE_RPC_ID, function (x: any) { + const obj = this.get(x.rpcId) as VoxelEditController; + const keys: string[] = Array.isArray(x.voxChunkKeys) ? x.voxChunkKeys : []; + const message: string = typeof x.message === 'string' ? x.message : 'Voxel edit failed.'; + obj.handleCommitFailure(keys, message); +}); From 235af99dbfed22dbd900e5c8a703fecdcff19ca5 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 070/251] feat: add targeted chunk invalidation and improve voxel annotation precision - Introduce `invalidateCachedChunks` method for selective chunk invalidation. - Register new RPC `ChunkSource.invalidateChunks` for backend chunk invalidation. - Replace `Math.floor` with `Math.round` in voxel calculations for improved precision. - Refactor chunk invalidation logic to support partial updates and reduce overhead. --- src/chunk_manager/backend.ts | 19 ++++++++++++++++--- src/chunk_manager/base.ts | 1 + src/chunk_manager/frontend.ts | 10 +++++++++- src/voxel_annotation/edit_controller.ts | 12 ++++++------ 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/chunk_manager/backend.ts b/src/chunk_manager/backend.ts index df56bb02bb..0fa16cd5e2 100644 --- a/src/chunk_manager/backend.ts +++ b/src/chunk_manager/backend.ts @@ -15,7 +15,8 @@ */ import { throttle } from "lodash-es"; -import type { +import { + CHUNK_SOURCE_INVALIDATE_CHUNKS_RPC_ID, ChunkSourceParametersConstructor, LayerChunkProgressInfo, } from "#src/chunk_manager/base.js"; @@ -1110,8 +1111,10 @@ export class ChunkQueueManager extends SharedObjectCounterpart { } } - invalidateSourceCache(source: ChunkSource) { - for (const chunk of source.chunks.values()) { + invalidateCachedChunks(source: ChunkSource, keys: string[]){ + for (const key of keys) { + const chunk = source.chunks.get(key); + if(!chunk) continue; switch (chunk.state) { case ChunkState.DOWNLOADING: cancelChunkDownload(chunk); @@ -1123,6 +1126,10 @@ export class ChunkQueueManager extends SharedObjectCounterpart { // Note: After calling this, chunk may no longer be valid. this.updateChunkState(chunk, ChunkState.QUEUED); } + } + + invalidateSourceCache(source: ChunkSource) { + this.invalidateCachedChunks(source, [...source.chunks.keys()]) this.rpc!.invoke("Chunk.update", { source: source.rpcId }); this.scheduleUpdate(); } @@ -1378,6 +1385,12 @@ registerRPC(CHUNK_SOURCE_INVALIDATE_RPC_ID, function (x) { source.chunkManager.queueManager.invalidateSourceCache(source); }); +registerRPC(CHUNK_SOURCE_INVALIDATE_CHUNKS_RPC_ID, function (x) { + const source = this.get(x.id); + source.chunkManager.queueManager.invalidateCachedChunks(source, x.keys); + source.chunkManager.queueManager.scheduleUpdate(); +}); + registerPromiseRPC( REQUEST_CHUNK_STATISTICS_RPC_ID, function (x: { queue: number }) { diff --git a/src/chunk_manager/base.ts b/src/chunk_manager/base.ts index 81b3c912ab..18afd3e3f0 100644 --- a/src/chunk_manager/base.ts +++ b/src/chunk_manager/base.ts @@ -97,6 +97,7 @@ export const PREFETCH_PRIORITY_MULTIPLIER = 1e13; export const CHUNK_QUEUE_MANAGER_RPC_ID = "ChunkQueueManager"; export const CHUNK_MANAGER_RPC_ID = "ChunkManager"; export const CHUNK_SOURCE_INVALIDATE_RPC_ID = "ChunkSource.invalidate"; +export const CHUNK_SOURCE_INVALIDATE_CHUNKS_RPC_ID = "ChunkSource.invalidateChunks"; export const REQUEST_CHUNK_STATISTICS_RPC_ID = "ChunkQueueManager.requestChunkStatistics"; diff --git a/src/chunk_manager/frontend.ts b/src/chunk_manager/frontend.ts index bf025c47c7..798e783d62 100644 --- a/src/chunk_manager/frontend.ts +++ b/src/chunk_manager/frontend.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import type { +import { + CHUNK_SOURCE_INVALIDATE_CHUNKS_RPC_ID, ChunkSourceParametersConstructor, LayerChunkProgressInfo, } from "#src/chunk_manager/base.js"; @@ -464,14 +465,21 @@ export class ChunkSource extends SharedObject { } invalidateChunks(keys: string[]): void { + const validKeys: string[] = []; let changed = false; for (const key of keys) { const chunk = this.chunks.get(key); if (chunk) { + validKeys.push(key); this.deleteChunk(key); changed = true; } } + + if (validKeys.length > 0) { + this.rpc!.invoke(CHUNK_SOURCE_INVALIDATE_CHUNKS_RPC_ID, { id: this.rpcId, keys: validKeys }); + } + if (changed) { this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); } diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index b93f9120eb..2e7ab266b6 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -163,9 +163,9 @@ export class VoxelEditController extends SharedObject { const source = this.getSourceForLOD(sourceIndex); // Convert center and radius to the level’s voxel grid. - const cx = Math.floor((centerCanonical[0] ?? 0) / voxelSize); - const cy = Math.floor((centerCanonical[1] ?? 0) / voxelSize); - const cz = Math.floor((centerCanonical[2] ?? 0) / voxelSize); + const cx = Math.round((centerCanonical[0] ?? 0) / voxelSize); + const cy = Math.round((centerCanonical[1] ?? 0) / voxelSize); + const cz = Math.round((centerCanonical[2] ?? 0) / voxelSize); const r = Math.round(radiusCanonical / voxelSize); if (r <= 0) { throw new Error( @@ -284,9 +284,9 @@ export class VoxelEditController extends SharedObject { // Convert canonical/world to level grid coordinates. const startVoxelLod = new Float32Array([ - Math.floor((startPositionCanonical[0] ?? NaN) / voxelSize), - Math.floor((startPositionCanonical[1] ?? NaN) / voxelSize), - Math.floor((startPositionCanonical[2] ?? NaN) / voxelSize), + Math.round((startPositionCanonical[0] ?? NaN) / voxelSize), + Math.round((startPositionCanonical[1] ?? NaN) / voxelSize), + Math.round((startPositionCanonical[2] ?? NaN) / voxelSize), ]); if (!startVoxelLod || startVoxelLod.length < 3) { From fd1529320d9eb5b9b79f5534d5008be5d2281fba Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 071/251] feat: remove legacy local://voxel-annotations code and improve chunk fetching logic --- NOTES/TODOs.md | 2 +- src/datasource/local.ts | 2 - src/layer/vox/index.ts | 9 +--- src/sliceview/volume/frontend.ts | 93 +++++++++++++++++--------------- 4 files changed, 53 insertions(+), 53 deletions(-) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index 758406329d..44d4752c54 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,6 +1,6 @@ # TODO List -- FOR TOMORROW: start to prepare the problematic/email to JMS + strengthen the drawing pipeline + add label management back +- FOR TOMORROW: start to prepare the problematic/email to JMS + fix the issue with the preview not rendering on empty chunks + add label management back - Fix the orientation of the disk in the brush tool - Add support for flood fill on different planes diff --git a/src/datasource/local.ts b/src/datasource/local.ts index 94bb8e4291..f67e8abaf4 100644 --- a/src/datasource/local.ts +++ b/src/datasource/local.ts @@ -31,12 +31,10 @@ import { createIdentity } from "#src/util/matrix.js"; export const localAnnotationsUrl = "local://annotations"; export const localEquivalencesUrl = "local://equivalences"; -export const localVoxelAnnotationsUrl = "local://voxel-annotations"; export enum LocalDataSource { annotations = 0, equivalences = 1, - voxelAnnotations = 2, } export class LocalDataSourceProvider implements DataSourceProvider { diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 7518871637..385d6aa6da 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -18,10 +18,6 @@ import "#src/layer/vox/style.css"; import type { CoordinateTransformSpecification } from "#src/coordinate_transform.js"; import type { DataSourceSpecification } from "#src/datasource/index.js"; -import { - LocalDataSource, - localVoxelAnnotationsUrl, -} from "#src/datasource/local.js"; import { type ManagedUserLayer, type MouseSelectionState, @@ -214,7 +210,7 @@ export class VoxUserLayer extends UserLayer { // Default to the special local voxel annotations data source. return [ { - url: localVoxelAnnotationsUrl, + url: "TODO", transform: legacyTransform, enableDefaultSubsources: true, subsources: new Map(), @@ -272,9 +268,6 @@ export class VoxUserLayer extends UserLayer { registerVoxelAnnotationTools(); registerLayerType(VoxUserLayer); registerLayerTypeDetector((subsource) => { - if (subsource.local === LocalDataSource.voxelAnnotations) { - return { layerConstructor: VoxUserLayer, priority: 100 }; - } // Accept non-local datasources at low priority to avoid interfering with other layers. if (subsource.local === undefined) { return { layerConstructor: VoxUserLayer, priority: 0 }; diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index 78f451a052..cd4ed28477 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -232,61 +232,70 @@ export class VolumeChunkSource applyLocalEdits(edits: Map): void { const chunksToUpdate = new Set(); + const fetches: Promise[] = []; + for (const [key, edit] of edits.entries()) { const chunk = this.chunks.get(key) as VolumeChunk | undefined; - if (!chunk || !(chunk as any).data) { + if (!chunk) { continue; } - const chunkFormat = chunk.chunkFormat; - - if (chunkFormat instanceof UncompressedChunkFormat) { - const cpuArray = (chunk as any).data as TypedArray; - for (const index of edit.indices) { - cpuArray[index] = edit.value; - } - chunksToUpdate.add(chunk); - } else if (chunkFormat instanceof CompressedChunkFormat) { - // using an idiotic logic to handle compressed chunks: uncompress -> edit -> recompress - // TODO: rework this - const compressedData = (chunk as any).data as Uint32Array; - const { chunkDataSize } = chunk; - const numElements = chunkDataSize[0] * chunkDataSize[1] * chunkDataSize[2]; - const { dataType, subchunkSize } = chunkFormat; - - // Note: Assuming single-channel for simplicity. Multi-channel would require handling offsets. - const baseOffset = compressedData[0]; - - const outputBuilder = new TypedArrayBuilder(Uint32Array, compressedData.length); - // Write multi-channel header (for single channel) - outputBuilder.resize(1); - outputBuilder.data[0] = 1; - - if (dataType === DataTypeUtil.UINT32) { - const uncompressedData = new Uint32Array(numElements); - decodeChannelUint32(uncompressedData, compressedData, baseOffset, chunkDataSize, subchunkSize); - + const processEdit = (targetChunk: VolumeChunk) => { + const chunkFormat = targetChunk.chunkFormat; + if (chunkFormat instanceof UncompressedChunkFormat) { + const cpuArray = (targetChunk as any).data as TypedArray; for (const index of edit.indices) { - uncompressedData[index] = edit.value; + cpuArray[index] = edit.value; } - - encodeChannelUint32(outputBuilder, subchunkSize, uncompressedData, chunkDataSize); - } else { // Assumes UINT64 - const uncompressedData = new BigUint64Array(numElements); - decodeChannelUint64(uncompressedData, compressedData, baseOffset, chunkDataSize, subchunkSize); - - for (const index of edit.indices) { - uncompressedData[index] = BigInt(edit.value); + chunksToUpdate.add(targetChunk); + } else if (chunkFormat instanceof CompressedChunkFormat) { + // using an idiotic logic to handle compressed chunks: uncompress -> edit -> recompress + // TODO: rework this + const compressedData = (targetChunk as any).data as Uint32Array; + const { chunkDataSize } = targetChunk; + const numElements = chunkDataSize[0] * chunkDataSize[1] * chunkDataSize[2]; + const { dataType, subchunkSize } = chunkFormat; + const baseOffset = compressedData[0]; + const outputBuilder = new TypedArrayBuilder(Uint32Array, compressedData.length); + outputBuilder.resize(1); + outputBuilder.data[0] = 1; + + if (dataType === DataTypeUtil.UINT32) { + const uncompressedData = new Uint32Array(numElements); + decodeChannelUint32(uncompressedData, compressedData, baseOffset, chunkDataSize, subchunkSize); + for (const index of edit.indices) { uncompressedData[index] = edit.value; } + encodeChannelUint32(outputBuilder, subchunkSize, uncompressedData, chunkDataSize); + } else { // Assumes UINT64 + const uncompressedData = new BigUint64Array(numElements); + decodeChannelUint64(uncompressedData, compressedData, baseOffset, chunkDataSize, subchunkSize); + for (const index of edit.indices) { uncompressedData[index] = BigInt(edit.value); } + encodeChannelUint64(outputBuilder, subchunkSize, uncompressedData, chunkDataSize); } - encodeChannelUint64(outputBuilder, subchunkSize, uncompressedData, chunkDataSize); + (targetChunk as any).data = outputBuilder.view; + chunksToUpdate.add(targetChunk); } - - (chunk as any).data = outputBuilder.view; - chunksToUpdate.add(chunk); + }; + + if ((chunk as any).data) { + processEdit(chunk); + } else { + const fetchPromise = this.fetchChunk(chunk.chunkGridPosition, (fetchedChunk) => { + processEdit(fetchedChunk as VolumeChunk); + }, {}).catch(err => { + console.error(`Failed to fetch chunk ${key} for local edit preview:`, err); + }); + fetches.push(fetchPromise); } } + this.invalidateGpuData(chunksToUpdate); + + if (fetches.length > 0) { + Promise.all(fetches).then(() => { + this.invalidateGpuData(chunksToUpdate); + }); + } } private invalidateGpuData(chunks: Set): void { From d59de804e531b48a0cae0f510265df399d4f89a1 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 072/251] feat: update voxel editing to support bigint values and improve label management --- NOTES/TODOs.md | 33 +++-- src/layer/vox/index.ts | 18 +-- src/layer/vox/tabs/tools.ts | 38 ++++-- src/sliceview/volume/backend.ts | 23 ++-- src/sliceview/volume/frontend.ts | 14 +- src/ui/voxel_annotations.ts | 35 ++++- src/voxel_annotation/edit_backend.ts | 81 ++++-------- src/voxel_annotation/edit_controller.ts | 41 ++---- src/voxel_annotation/labels.ts | 165 +++++++----------------- 9 files changed, 190 insertions(+), 258 deletions(-) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index 44d4752c54..2d928f3095 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,20 +1,35 @@ # TODO List -- FOR TOMORROW: start to prepare the problematic/email to JMS + fix the issue with the preview not rendering on empty chunks + add label management back +- FOR TOMORROW: start to prepare the problematic/email to JMS +### priority +- fix the issue with the preview not rendering on empty chunks - Fix the orientation of the disk in the brush tool - Add support for flood fill on different planes --? adapt the brush size to the zoom level linearly -- rework the ui (tabs) -- add shortcuts for tools (switching tools, toogle erase mode, select label from the pointed one in the slice view and adjusting brush size) and label creation -- the flood fill sometimes leaves artifacts in sharp areas -- rework the autocomplete for the ssa+https source. - fix the flood fill for compressed chunks -- rework the drawing preview for compressed chunk (see applyLocalEdits()) +- add shortcuts for tools (switching tools, toogle erase mode, select label from the pointed one in the slice view and adjusting brush size) and label creation + +### extra +- rework the ui (tabs) - optimize flood fill tool (it is too slow on area containing uncached chunks, due to the getEnsuredValueAt() calls) +- rework the drawing preview for compressed chunk (see applyLocalEdits()) +- rework the autocomplete for the ssa+https source. +- the flood fill sometimes leaves artifacts in sharp areas + +### questionable +-? adapt the brush size to the zoom level linearly + + + + + + + + + + + -- rework vox backend -- rework label handling ## mail diff --git a/src/layer/vox/index.ts b/src/layer/vox/index.ts index 385d6aa6da..4569961134 100644 --- a/src/layer/vox/index.ts +++ b/src/layer/vox/index.ts @@ -46,6 +46,7 @@ import { } from "#src/ui/voxel_annotations.js"; import type { Borrowed } from "#src/util/disposable.js"; import * as matrix from "#src/util/matrix.js"; +import { NullarySignal } from "#src/util/signal.js"; import { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; import { LabelsManager } from "#src/voxel_annotation/labels.js"; import { VoxelAnnotationRenderLayer } from "#src/voxel_annotation/renderlayer.js"; @@ -58,7 +59,8 @@ export class VoxUserLayer extends UserLayer { static type = "vox"; static typeAbbreviation = "vox"; voxEditController?: VoxelEditController; - voxLabelsManager = new LabelsManager(); + voxLabelsManager : LabelsManager; + labelsChanged = new NullarySignal(); // Draw tool state voxBrushRadius: number = 3; @@ -228,7 +230,13 @@ export class VoxUserLayer extends UserLayer { continue; } switch (volume.dataType) { - case DataType.FLOAT32: + case DataType.UINT32: + this.voxLabelsManager = new LabelsManager(DataType.UINT32, this.labelsChanged.dispatch); + break; + case DataType.UINT64: + this.voxLabelsManager = new LabelsManager(DataType.UINT64, this.labelsChanged.dispatch); + break; + default: loadedSubsource.deactivate( "Data type not compatible with segmentation layer", ); @@ -246,12 +254,6 @@ export class VoxUserLayer extends UserLayer { this.voxRenderLayerInstance = renderLayer; loadedSubsource.addRenderLayer(renderLayer); - - try { - this.voxLabelsManager.initialize(this.voxEditController!); - } catch (e) { - console.warn("VoxUserLayer: labels initialization failed", e); - } } ); continue; diff --git a/src/layer/vox/tabs/tools.ts b/src/layer/vox/tabs/tools.ts index 0c97990579..47f88c2d7c 100644 --- a/src/layer/vox/tabs/tools.ts +++ b/src/layer/vox/tabs/tools.ts @@ -2,14 +2,15 @@ * Vox Tool tab UI split from index.ts */ import type { VoxUserLayer } from "#src/layer/vox/index.js"; -import { VoxelBrushLegacyTool, VoxelFloodFillLegacyTool } from "#src/ui/voxel_annotations.js"; +import { + VoxelBrushLegacyTool, + VoxelFloodFillLegacyTool, + AdoptVoxelLabelTool, +} from "#src/ui/voxel_annotations.js"; import { Tab } from "#src/widget/tab_view.js"; export class VoxToolTab extends Tab { - public requestRenderLabels() { - this.renderLabels(); - } private labelsContainer!: HTMLDivElement; private labelsError!: HTMLDivElement; private drawErrorContainer!: HTMLDivElement; @@ -31,10 +32,10 @@ export class VoxToolTab extends Tab { sw.style.height = "16px"; sw.style.borderRadius = "3px"; sw.style.border = "1px solid rgba(0,0,0,0.2)"; - sw.style.background = this.layer.voxLabelsManager.colorForValue(lab.id); + sw.style.background = this.layer.voxLabelsManager.colorForValue(lab); // id text (monospace) const txt = document.createElement("div"); - txt.textContent = String(lab.id >>> 0); + txt.textContent = String(lab); txt.style.fontFamily = "monospace"; txt.style.whiteSpace = "nowrap"; txt.style.overflow = "hidden"; @@ -42,7 +43,7 @@ export class VoxToolTab extends Tab { row.appendChild(sw); row.appendChild(txt); // selection styling - const isSel = lab.id === selected; + const isSel = lab === selected; row.style.cursor = "pointer"; row.style.padding = "2px 4px"; row.style.borderRadius = "4px"; @@ -51,8 +52,7 @@ export class VoxToolTab extends Tab { row.style.outline = "1px solid rgba(100,150,255,0.6)"; } row.addEventListener("click", () => { - this.layer.voxLabelsManager.selectVoxLabel(lab.id); - this.renderLabels(); + this.layer.voxLabelsManager.selectVoxLabel(lab); }); cont.appendChild(row); } @@ -68,6 +68,12 @@ export class VoxToolTab extends Tab { } constructor(public layer: VoxUserLayer) { super(); + this.registerDisposer( + this.layer.labelsChanged.add(() => { + this.renderLabels(); + }), + ); + const { element } = this; element.classList.add("neuroglancer-vox-tools-tab"); const toolbox = document.createElement("div"); @@ -96,6 +102,14 @@ export class VoxToolTab extends Tab { this.layer.tool.value = new VoxelFloodFillLegacyTool(this.layer); }); + + const adoptBtn = document.createElement("button"); + adoptBtn.textContent = "Pick"; + adoptBtn.title = "Activate tool: click a non-zero voxel to add its ID as a label"; + adoptBtn.addEventListener("click", () => { + this.layer.tool.value = new AdoptVoxelLabelTool(this.layer); + }); + toolsWrap.appendChild(adoptBtn); toolsWrap.appendChild(brushButton); toolsWrap.appendChild(floodButton); toolsRow.appendChild(toolsLabel); @@ -278,11 +292,11 @@ export class VoxToolTab extends Tab { const createBtn = document.createElement("button"); createBtn.textContent = "New label"; createBtn.addEventListener("click", () => { - this.layer.voxLabelsManager.createVoxLabel(this.layer.voxEditController); - // Rendering will be triggered by LabelsManager via onLabelsChanged callback. + this.layer.voxLabelsManager.createNewLabel(); }); buttonsRow.appendChild(createBtn); + this.labelsContainer = document.createElement("div"); this.labelsContainer.className = "neuroglancer-vox-labels"; this.labelsContainer.style.display = "flex"; @@ -326,10 +340,8 @@ export class VoxToolTab extends Tab { } }; - this.layer.voxLabelsManager.onLabelsChanged = () => this.requestRenderLabels(); this.layer.onDrawMessageChanged = () => updateDrawError(); - this.renderLabels(); updateDrawError(); element.appendChild(toolbox); diff --git a/src/sliceview/volume/backend.ts b/src/sliceview/volume/backend.ts index bf5da4dc11..111cb94c80 100644 --- a/src/sliceview/volume/backend.ts +++ b/src/sliceview/volume/backend.ts @@ -16,17 +16,11 @@ import type { Chunk } from "#src/chunk_manager/backend.js"; import { ChunkState } from "#src/chunk_manager/base.js"; -import { - SliceViewChunk, - SliceViewChunkSourceBackend, -} from "#src/sliceview/backend.js"; -import type { - DataType, - SliceViewChunkSpecification, -} from "#src/sliceview/base.js"; +import { SliceViewChunk, SliceViewChunkSourceBackend } from "#src/sliceview/backend.js"; +import { DataType, SliceViewChunkSpecification } from "#src/sliceview/base.js"; import type { VolumeChunkSource as VolumeChunkSourceInterface, - VolumeChunkSpecification, + VolumeChunkSpecification } from "#src/sliceview/volume/base.js"; import type { TypedArray } from "#src/util/array.js"; import { DATA_TYPE_ARRAY_CONSTRUCTOR } from "#src/util/data_type.js"; @@ -166,7 +160,7 @@ export class VolumeChunkSource throw new Error("VolumeChunkSource.writeChunk not implemented for this datasource"); } - async applyEdits(chunkKey: string, indices: ArrayLike, values: ArrayLike): Promise { + async applyEdits(chunkKey: string, indices: ArrayLike, values: ArrayLike): Promise { if (indices.length !== values.length) { throw new Error("applyEdits: indices and values length mismatch"); } @@ -174,9 +168,8 @@ export class VolumeChunkSource if (chunkGridPosition.length !== this.spec.rank || chunkGridPosition.some((v) => !Number.isFinite(v))) { throw new Error(`applyEdits: invalid chunk key ${chunkKey}`); } - const chunk = this.getChunk(chunkGridPosition) as unknown as VolumeChunk; + const chunk = this.getChunk(chunkGridPosition) as VolumeChunk; - // Ensure chunk data is available in system memory if (chunk.state > ChunkState.SYSTEM_MEMORY_WORKER) { const ac = new AbortController(); await this.download(chunk, ac.signal); @@ -195,7 +188,7 @@ export class VolumeChunkSource // If chunk.data is null, the chunk does not exist at the source or was evicted. // Create a new, zero-filled chunk to apply the edits to. if (!chunk.chunkDataSize) { - this.computeChunkBounds(chunk); // Ensure chunkDataSize is computed + this.computeChunkBounds(chunk); } if (!chunk.chunkDataSize) { throw new Error(`applyEdits: Cannot create new chunk ${chunkKey} because its size is unknown.`); @@ -205,14 +198,14 @@ export class VolumeChunkSource chunk.data = new (Ctor as any)(numElements); // The new TypedArray is already zero-filled. } - const data = (chunk.data as unknown) as TypedArray; + const data = chunk.data as TypedArray; for (let i = 0; i < indices.length; ++i) { const idx = indices[i]!; const val = values[i]!; if (idx < 0 || idx >= data.length) { throw new Error(`applyEdits: index ${idx} out of bounds for chunk ${chunkKey}`); } - (data as any)[idx] = val; // TypedArray index assignment + data[idx] = this.spec.dataType === DataType.UINT32 ? Number(val) : val; } const maxRetries = 3; let lastError: Error | undefined; diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index cd4ed28477..be6a20ffcd 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -230,7 +230,7 @@ export class VolumeChunkSource return this.getValueAt(chunkPosition, channelAccess); } - applyLocalEdits(edits: Map): void { + applyLocalEdits(edits: Map): void { const chunksToUpdate = new Set(); const fetches: Promise[] = []; @@ -244,8 +244,14 @@ export class VolumeChunkSource const chunkFormat = targetChunk.chunkFormat; if (chunkFormat instanceof UncompressedChunkFormat) { const cpuArray = (targetChunk as any).data as TypedArray; + const { dataType } = chunkFormat; for (const index of edit.indices) { - cpuArray[index] = edit.value; + if (dataType === DataTypeUtil.UINT32) { + cpuArray[index] = Number(edit.value); + } else { + // Assumes UINT64 + cpuArray[index] = edit.value; + } } chunksToUpdate.add(targetChunk); } else if (chunkFormat instanceof CompressedChunkFormat) { @@ -263,12 +269,12 @@ export class VolumeChunkSource if (dataType === DataTypeUtil.UINT32) { const uncompressedData = new Uint32Array(numElements); decodeChannelUint32(uncompressedData, compressedData, baseOffset, chunkDataSize, subchunkSize); - for (const index of edit.indices) { uncompressedData[index] = edit.value; } + for (const index of edit.indices) { uncompressedData[index] = Number(edit.value); } encodeChannelUint32(outputBuilder, subchunkSize, uncompressedData, chunkDataSize); } else { // Assumes UINT64 const uncompressedData = new BigUint64Array(numElements); decodeChannelUint64(uncompressedData, compressedData, baseOffset, chunkDataSize, subchunkSize); - for (const index of edit.indices) { uncompressedData[index] = BigInt(edit.value); } + for (const index of edit.indices) { uncompressedData[index] = edit.value; } encodeChannelUint64(outputBuilder, subchunkSize, uncompressedData, chunkDataSize); } diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index bb91655bc3..2624188ae8 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -16,10 +16,12 @@ import type { MouseSelectionState } from "#src/layer/index.js"; import type { VoxUserLayer } from "#src/layer/vox/index.js"; +import { StatusMessage } from "#src/status.js"; import { LegacyTool, registerLegacyTool } from "#src/ui/tool.js"; export const BRUSH_TOOL_ID = "voxBrush"; export const FLOODFILL_TOOL_ID = "voxFloodFill"; +export const ADOPT_VOXEL_LABEL_TOOL_ID = "adoptVoxelLabel"; abstract class BaseVoxelLegacyTool extends LegacyTool { protected isDrawing = false; @@ -97,8 +99,8 @@ export const FLOODFILL_TOOL_ID = "voxFloodFill"; return out; } - protected abstract paintPoint(point: Float32Array, value: number): void; - protected abstract paintPoints(points: Float32Array[], value: number): void; + protected abstract paintPoint(point: Float32Array, value: bigint): void; + protected abstract paintPoints(points: Float32Array[], value: bigint): void; protected startDrawing(mouseState: MouseSelectionState) { if (this.isDrawing) return; @@ -196,7 +198,7 @@ export class VoxelBrushLegacyTool extends BaseVoxelLegacyTool { return BRUSH_TOOL_ID; } - protected paintPoint(point: Float32Array, value: number) { + protected paintPoint(point: Float32Array, value: bigint) { const radius = Math.max( 1, Math.floor((this.layer as any).voxBrushRadius ?? 3), @@ -216,7 +218,7 @@ export class VoxelBrushLegacyTool extends BaseVoxelLegacyTool { ); } - protected paintPoints(points: Float32Array[], value: number) { + protected paintPoints(points: Float32Array[], value: bigint) { const radius = Math.max( 1, Math.floor((this.layer as any).voxBrushRadius ?? 3), @@ -271,8 +273,8 @@ export class VoxelFloodFillLegacyTool extends LegacyTool { Math.floor(pos[2]!), ]); - console.info("[VoxFloodFill] starting flood fill", { seed: Array.from(seed), value: value >>> 0, max: Math.floor(max) }); - ctrl.floodFillPlane2D(seed, value >>> 0, Math.floor(max)).then(({ edits, filledCount }) => { + console.info("[VoxFloodFill] starting flood fill", { seed: Array.from(seed), value: value, max: Math.floor(max) }); + ctrl.floodFillPlane2D(seed, value, Math.floor(max)).then(({ edits, filledCount }) => { console.info("[VoxFloodFill] BFS completed", { filledCount, editsByChunk: edits.length }); if (edits.length === 0) return; @@ -297,6 +299,23 @@ export class VoxelFloodFillLegacyTool extends LegacyTool { } } +export class AdoptVoxelLabelTool extends LegacyTool { + description = "label picker"; + toJSON() { return ADOPT_VOXEL_LABEL_TOOL_ID; } + trigger(mouseState: MouseSelectionState) { + if (!mouseState?.active) return; + const raw = (mouseState as any).pickedValue as bigint | number | undefined; + if (raw === undefined || raw === null) return; + const rawBig = typeof raw === 'bigint' ? raw : BigInt(raw); + if (rawBig === 0n) { + StatusMessage.showTemporaryMessage("Cannot adopt background label 0."); + return; + } + const layer = this.layer as unknown as VoxUserLayer; + layer.voxLabelsManager.addLabel(rawBig); + } +} + export function registerVoxelAnnotationTools() { registerLegacyTool( BRUSH_TOOL_ID, @@ -306,4 +325,8 @@ export function registerVoxelAnnotationTools() { FLOODFILL_TOOL_ID, (layer) => new VoxelFloodFillLegacyTool(layer as unknown as VoxUserLayer), ); + registerLegacyTool( + ADOPT_VOXEL_LABEL_TOOL_ID, + (layer) => new AdoptVoxelLabelTool(layer as unknown as VoxUserLayer), + ); } diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/edit_backend.ts index 20cd52ba46..830ac5c81e 100644 --- a/src/voxel_annotation/edit_backend.ts +++ b/src/voxel_annotation/edit_backend.ts @@ -1,23 +1,14 @@ -/** - * Edit controller backend: owns the authoritative VoxSourceWriter for a given map - * and handles applying edits and label persistence independent of the volume chunk - * streaming backend. This enables multiple VoxChunkSource instances (read-only) - * while keeping a single writer per map owned by the edit controller. - */ - import type { VolumeChunkSource } from "#src/sliceview/volume/backend.js"; import { VOX_EDIT_BACKEND_RPC_ID, VOX_EDIT_COMMIT_VOXELS_RPC_ID, - VOX_EDIT_LABELS_ADD_RPC_ID, - VOX_EDIT_LABELS_GET_RPC_ID, VOX_RELOAD_CHUNKS_RPC_ID, VOX_EDIT_FAILURE_RPC_ID, makeVoxChunkKey, parseVoxChunkKey, } from "#src/voxel_annotation/base.js"; -import type { RPC} from "#src/worker_rpc.js"; -import { SharedObject , registerRPC, registerPromiseRPC, registerSharedObject, initializeSharedObjectCounterpart } from "#src/worker_rpc.js"; +import type { RPC } from "#src/worker_rpc.js"; +import { SharedObject , registerRPC, registerSharedObject, initializeSharedObjectCounterpart } from "#src/worker_rpc.js"; @registerSharedObject(VOX_EDIT_BACKEND_RPC_ID) export class VoxelEditController extends SharedObject { @@ -27,8 +18,8 @@ export class VoxelEditController extends SharedObject { private pendingEdits: { key: string; indices: number[] | Uint32Array; - value?: number; - values?: ArrayLike; + value?: bigint; + values?: ArrayLike; size?: number[]; }[] = []; private commitDebounceTimer: number | undefined; @@ -74,7 +65,7 @@ export class VoxelEditController extends SharedObject { // 1. Group edits by vox chunk key (includes LOD). const editsByVoxKey = new Map< string, - { indices: number[]; values: number[] } + { indices: number[]; values: bigint[] } >(); for (const edit of edits) { if (!editsByVoxKey.has(edit.key)) { @@ -88,14 +79,14 @@ export class VoxelEditController extends SharedObject { } for (let i = 0; i < edit.indices.length; ++i) { entry.indices.push(Number(edit.indices[i]!)); - entry.values.push(Number(vals[i]!)); + entry.values.push(vals[i]!); } } else if (edit.value !== undefined) { const inds = edit.indices as ArrayLike; for (let i = 0; i < inds.length; ++i) { const index = inds[i]!; entry.indices.push(Number(index)); - entry.values.push(Number(edit.value)); + entry.values.push(edit.value); } } else { throw new Error("flushPending: edit missing value(s)"); @@ -157,8 +148,8 @@ export class VoxelEditController extends SharedObject { edits: { key: string; indices: number[] | Uint32Array; - value?: number; - values?: ArrayLike; + value?: bigint; + values?: ArrayLike; size?: number[]; }[], ) { @@ -175,16 +166,6 @@ export class VoxelEditController extends SharedObject { }, this.commitDebounceDelayMs) as unknown as number; } - async getLabelIds(): Promise { - // Label operations not yet implemented for multiscale edit backend. - return []; - } - - async addLabel(_value: number): Promise { - // Label operations not yet implemented for multiscale edit backend. - return []; - } - callChunkReload(voxChunkKeys: string[]) { this.rpc?.invoke(VOX_RELOAD_CHUNKS_RPC_ID, { rpcId: this.rpcId, @@ -226,13 +207,13 @@ export class VoxelEditController extends SharedObject { } } - private calculateMode(values: number[]): number { - if (values.length === 0) return 0; - const counts = new Map(); + private calculateMode(values: bigint[]): bigint { + if (values.length === 0) return 0n; + const counts = new Map(); let maxCount = 0; - let mode = 0; + let mode = 0n; for (const v of values) { - if (v === 0) continue; + if (v === 0n) continue; const c = (counts.get(v) ?? 0) + 1; counts.set(v, c); if (c > maxCount) { @@ -322,7 +303,7 @@ export class VoxelEditController extends SharedObject { const targetChunkH = sourceChunk.chunkDataSize[1]; const indices: number[] = []; - const values: number[] = []; + const values: bigint[] = []; for (let z = 0; z < chunkD; z++) { for (let y = 0; y < chunkH; y++) { @@ -338,15 +319,15 @@ export class VoxelEditController extends SharedObject { const plane = sourceChunk.chunkDataSize[0] * sourceChunk.chunkDataSize[1]; // Gather 8 voxels from source - const v000 = Number((sourceChunk.data as any)[base]); - const v100 = Number((sourceChunk.data as any)[base + 1]); - const v010 = Number((sourceChunk.data as any)[base + row]); - const v110 = Number((sourceChunk.data as any)[base + row + 1]); - const v001 = Number((sourceChunk.data as any)[base + plane]); - const v101 = Number((sourceChunk.data as any)[base + plane + 1]); - const v011 = Number((sourceChunk.data as any)[base + plane + row]); - const v111 = Number( - (sourceChunk.data as any)[base + plane + row + 1], + const v000 = (sourceChunk.data as any)[base]; + const v100 = ((sourceChunk.data as any)[base + 1]); + const v010 = ((sourceChunk.data as any)[base + row]); + const v110 = ((sourceChunk.data as any)[base + row + 1]); + const v001 = ((sourceChunk.data as any)[base + plane]); + const v101 = ((sourceChunk.data as any)[base + plane + 1]); + const v011 = ((sourceChunk.data as any)[base + plane + row]); + const v111 = ( + (sourceChunk.data as any)[base + plane + row + 1] ); const mode = this.calculateMode([ v000, @@ -365,7 +346,7 @@ export class VoxelEditController extends SharedObject { const tIndex = tz * (targetChunkW * targetChunkH) + ty * targetChunkW + tx; indices.push(tIndex); - values.push(mode >>> 0); + values.push(mode); } } } @@ -398,15 +379,3 @@ registerRPC(VOX_EDIT_COMMIT_VOXELS_RPC_ID, function (x: any) { const obj = this.get(x.rpcId) as VoxelEditController; void obj.commitVoxels(Array.isArray(x.edits) ? x.edits : []); }); - -registerPromiseRPC(VOX_EDIT_LABELS_GET_RPC_ID, async function (x: any) { - const obj = this.get(x.rpcId) as VoxelEditController; - const ids = await obj.getLabelIds(); - return { value: ids }; -}); - -registerPromiseRPC(VOX_EDIT_LABELS_ADD_RPC_ID, async function (x: any) { - const obj = this.get(x.rpcId) as VoxelEditController; - const ids = await obj.addLabel(x?.value >>> 0); - return { value: ids }; -}); diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 2e7ab266b6..4cd0ff7107 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -9,8 +9,6 @@ import type { VolumeChunkSource , MultiscaleVolumeChunkSource } from "#src/slice import { VOX_EDIT_BACKEND_RPC_ID, VOX_EDIT_COMMIT_VOXELS_RPC_ID, - VOX_EDIT_LABELS_ADD_RPC_ID, - VOX_EDIT_LABELS_GET_RPC_ID, VOX_RELOAD_CHUNKS_RPC_ID, VOX_EDIT_FAILURE_RPC_ID, makeVoxChunkKey, @@ -144,7 +142,7 @@ export class VoxelEditController extends SharedObject { paintBrushWithShape( centerCanonical: Float32Array, radiusCanonical: number, - value: number, + value: bigint, shape: "disk" | "sphere" = "disk", basis?: { u: Float32Array; v: Float32Array }, ) { @@ -197,7 +195,7 @@ export class VoxelEditController extends SharedObject { } if (!voxelsToPaint || voxelsToPaint.length === 0) return; - const editsByVoxKey = new Map(); + const editsByVoxKey = new Map(); const lodFactor = 1 << sourceIndex; for (const voxelCoord of voxelsToPaint) { @@ -217,7 +215,7 @@ export class VoxelEditController extends SharedObject { } // Apply edits locally on the specific source for immediate feedback. - const localEdits = new Map(); + const localEdits = new Map(); for (const [voxKey, edit] of editsByVoxKey.entries()) { const parsed = parseVoxChunkKey(voxKey); if (!parsed) continue; @@ -225,7 +223,7 @@ export class VoxelEditController extends SharedObject { } source.applyLocalEdits(localEdits); - const backendEdits = [] as { key: string; indices: number[]; value: number }[]; + const backendEdits = [] as { key: string; indices: number[]; value: bigint }[]; for (const [voxKey, edit] of editsByVoxKey.entries()) { backendEdits.push({ key: voxKey, indices: edit.indices, value: edit.value }); } @@ -233,23 +231,8 @@ export class VoxelEditController extends SharedObject { this.commitEdits(backendEdits); } - async getLabelIds(): Promise { - if (!this.rpc) throw new Error("VoxelEditController.getLabelIds: RPC not initialized."); - return this.rpc.promiseInvoke(VOX_EDIT_LABELS_GET_RPC_ID, { - rpcId: this.rpcId, - }); - } - - async addLabel(value: number): Promise { - if (!this.rpc) throw new Error("VoxelEditController.addLabel: RPC not initialized."); - return this.rpc.promiseInvoke(VOX_EDIT_LABELS_ADD_RPC_ID, { - rpcId: this.rpcId, - value, - }); - } - /** Commit helper for UI tools. */ - commitEdits(edits: { key: string; indices: number[] | Uint32Array; value?: number; values?: ArrayLike; size?: number[] }[]): void { + commitEdits(edits: { key: string; indices: number[] | Uint32Array; value?: bigint; values?: ArrayLike; size?: number[] }[]): void { if (!this.rpc) throw new Error("VoxelEditController.commitEdits: RPC not initialized."); if (!Array.isArray(edits)) { throw new Error("VoxelEditController.commitEdits: edits must be an array."); @@ -267,9 +250,9 @@ export class VoxelEditController extends SharedObject { */ async floodFillPlane2D( startPositionCanonical: Float32Array, - fillValue: number, + fillValue: bigint, maxVoxels: number, - ): Promise<{ edits: { key: string; indices: number[]; value: number }[]; filledCount: number; originalValue: number }> { + ): Promise<{ edits: { key: string; indices: number[]; value: bigint }[]; filledCount: number; originalValue: bigint }> { if (!startPositionCanonical || startPositionCanonical.length < 3) { throw new Error("VoxelEditController.floodFillPlane2D: startPositionCanonical must be Float32Array[3]."); } @@ -300,7 +283,7 @@ export class VoxelEditController extends SharedObject { if (originalValueResult === null) { throw new Error("Flood fill seed is in an unloaded or out-of-bounds chunk."); } - const originalValue = Number(originalValueResult); + const originalValue = typeof originalValueResult !== "bigint" ? BigInt(originalValueResult as number) : originalValueResult; if (originalValue === fillValue) { return { edits: [], filledCount: 0, originalValue }; } @@ -440,7 +423,7 @@ export class VoxelEditController extends SharedObject { } } - const editsByVoxKey = new Map(); + const editsByVoxKey = new Map(); const lodFactor = 1 << sourceIndex; for (const voxelCoord of voxelsToFill) { const { chunkGridPosition, positionWithinChunk } = source.computeChunkIndices(voxelCoord); @@ -458,7 +441,7 @@ export class VoxelEditController extends SharedObject { } // Apply edits locally for preview on this source. - const localEdits = new Map(); + const localEdits = new Map(); for (const [voxKey, edit] of editsByVoxKey.entries()) { const parsed = parseVoxChunkKey(voxKey); if (!parsed) continue; @@ -467,14 +450,14 @@ export class VoxelEditController extends SharedObject { source.applyLocalEdits(localEdits); // Prepare edits for the backend keyed by voxKey. - const backendEdits: { key: string; indices: number[]; value: number }[] = []; + const backendEdits: { key: string; indices: number[]; value: bigint }[] = []; for (const [voxKey, edit] of editsByVoxKey.entries()) { backendEdits.push({ key: voxKey, indices: edit.indices, value: edit.value }); } this.commitEdits(backendEdits); - return { edits: backendEdits, filledCount, originalValue: originalValue >>> 0 }; + return { edits: backendEdits, filledCount, originalValue: originalValue }; } callChunkReload(voxChunkKeys: string[]) { diff --git a/src/voxel_annotation/labels.ts b/src/voxel_annotation/labels.ts index b190b7f58f..b286c23b22 100644 --- a/src/voxel_annotation/labels.ts +++ b/src/voxel_annotation/labels.ts @@ -1,141 +1,70 @@ import { SegmentColorHash } from "#src/segment_color.js"; -import type { VoxelEditController } from "#src/voxel_annotation/edit_controller.js"; +import { DataType } from "#src/util/data_type.js"; export class LabelsManager { - onLabelsChanged?: () => void; - // Label state for painting: only store ids; colors are hashed from id on the fly - labels: { id: number }[] = []; - selectedLabelId: number | undefined = undefined; + labels: Set; + selectedLabelId: bigint; labelsError: string | undefined = undefined; - // Indicates whether an initial labels load attempt has completed. - private labelsInitialized: boolean = false; segmentColorHash = SegmentColorHash.getDefault(); - private readonly tempHardcodedMode = true; + private sessionPrefix: bigint; + private nextLocalId: bigint = 1n; - async initialize(_editController: VoxelEditController): Promise { - // Temporary hardcoded single-label setup for frontend validation. - this.labels = [{ id: 42 }]; - this.selectedLabelId = 42; - this.labelsError = undefined; - this.labelsInitialized = true; - try { - this.onLabelsChanged?.(); - } catch { - /* ignore */ + constructor(dataType: DataType, private onLabelsChanged?: () => void) { + switch (dataType){ + case DataType.UINT32: + this.sessionPrefix = BigInt(Date.now() << 20); + break; + case DataType.UINT64: + this.sessionPrefix = BigInt(getRandomUint32()) << 32n; + break; + default: + throw new Error(`LabelsManager: Unsupported data type: ${dataType}`); } - if (!this.tempHardcodedMode) await this.loadLabels(_editController); + this.labels = new Set(); } - // --- Label helpers --- - private genId(): number { - // Generate a unique uint32 per layer session. Try crypto.getRandomValues; fallback to Math.random. - let id = 0; - const used = new Set(this.labels.map((l) => l.id)); - for (let attempts = 0; attempts < 10_000; attempts++) { - if (typeof crypto !== "undefined" && (crypto as any).getRandomValues) { - const a = new Uint32Array(1); - (crypto as any).getRandomValues(a); - id = a[0] >>> 0; - } else { - id = Math.floor(Math.random() * 0xffffffff) >>> 0; - } - if (id !== 0 && !used.has(id)) return id; - } - // As an ultimate fallback, probe sequentially from a time-based seed. - const base = (Date.now() ^ ((Math.random() * 0xffffffff) >>> 0)) >>> 0; - id = base || 1; - while (used.has(id)) id = (id + 1) >>> 0; - return id >>> 0; + private generateNewGuid(): bigint { + const newId = this.sessionPrefix | this.nextLocalId; + this.nextLocalId++; + return newId; } - colorForValue(v: number): string { - // Use segmentation-like color from SegmentColorHash seeded on numeric value - return this.segmentColorHash.computeCssColor(BigInt(v >>> 0)); + colorForValue(v: bigint): string { + return this.segmentColorHash.computeCssColor(v); } - // --- Labels persistence (via VoxSource) --- - private async loadLabels(editController: VoxelEditController) { - try { - const arr = await editController?.getLabelIds(); - if (arr && Array.isArray(arr)) { - if (arr.length > 0) { - this.labels = arr.map((id) => ({ id: id >>> 0 })); - const sel = this.selectedLabelId; - if (!sel || !this.labels.some((l) => l.id === sel)) { - this.selectedLabelId = this.labels[0].id; - } - } else { - this.labels = []; - this.selectedLabelId = undefined; - } - } else { - throw new Error("Invalid labels response"); - } - } catch (e: any) { - const msg = `Failed to load labels: ${e?.message || e}`; - console.error(msg); - this.labelsError = msg; - } finally { - // Mark labels as initialized; UI/painting should not trigger default creation before this point. - this.labelsInitialized = true; - try { - this.onLabelsChanged?.(); - } catch { - /* ignore */ - } - } + createNewLabel() { + const newId = this.generateNewGuid(); + this.addLabel(newId); } - async createVoxLabel(editController: VoxelEditController | undefined) { - const id = this.genId(); // unique uint32 - if (!editController) { - const msg = "Labels backend not ready; please try again after source initializes."; - console.error(msg); - this.labelsError = msg; - return; - } - try { - const updated = await editController.addLabel(id); - this.labels = updated.map((x) => ({ id: x >>> 0 })); - // Prefer to select the last label from the updated list (likely the one just added). - const last = this.labels[this.labels.length - 1]?.id; - this.selectedLabelId = last ?? id; - this.labelsError = undefined; - try { - this.onLabelsChanged?.(); - } catch { - /* ignore */ - } - } catch (e: any) { - const msg = `Failed to create label: ${e?.message || e}`; - console.error(msg); - this.labelsError = msg; - try { - this.onLabelsChanged?.(); - } catch { - /* ignore */ - } + addLabel(id: number | bigint) { + const newId = BigInt(id); + if (newId === 0n) return; + this.selectedLabelId = newId; + this.labels.add(newId); + this.onLabelsChanged?.(); + } + + selectVoxLabel(id: bigint) { + if (this.labels.has(id)) { + this.selectedLabelId = id; + this.onLabelsChanged?.(); } } - selectVoxLabel(id: number) { - const found = this.labels.find((l) => l.id === id); - if (found) this.selectedLabelId = id; + getCurrentLabelValue(eraseMode: boolean): bigint { + if (eraseMode) return 0n; + return this.selectedLabelId ? this.selectedLabelId : 0n; } +} - getCurrentLabelValue(eraseMode: boolean): number { - if (eraseMode) return 0; - if(this.tempHardcodedMode) return 42; - // Avoid triggering default creation during initialization. - if (!this.labelsInitialized) return 0; - // Ensure we have a valid selection if labels exist. - if (!this.selectedLabelId && this.labels.length > 0) { - this.selectedLabelId = this.labels[0].id; - } - const cur = - this.labels.find((l) => l.id === this.selectedLabelId) || - this.labels[0]; - return cur ? cur.id >>> 0 : 0; +function getRandomUint32(): number { + if (typeof crypto !== "undefined" && (crypto as any).getRandomValues) { + const a = new Uint32Array(1); + (crypto as any).getRandomValues(a); + return a[0]! >>> 0; } + return Math.floor(Math.random() * 0xffffffff) >>> 0; } From 3ef5f989ec7ea3a68ad8103c23430a9cc0345d85 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 073/251] feat: refactor voxel annotation handling to support bigint and improve label adoption logic --- src/layer/vox/tabs/tools.ts | 18 ++++++++- src/ui/voxel_annotations.ts | 53 +++++++++++++++++++++---- src/voxel_annotation/edit_controller.ts | 6 +-- src/voxel_annotation/labels.ts | 2 +- 4 files changed, 67 insertions(+), 12 deletions(-) diff --git a/src/layer/vox/tabs/tools.ts b/src/layer/vox/tabs/tools.ts index 47f88c2d7c..08e8006a41 100644 --- a/src/layer/vox/tabs/tools.ts +++ b/src/layer/vox/tabs/tools.ts @@ -8,6 +8,22 @@ import { AdoptVoxelLabelTool, } from "#src/ui/voxel_annotations.js"; import { Tab } from "#src/widget/tab_view.js"; +import { DataType } from "#src/util/data_type.js"; + +function formatUnsignedId(id: bigint, dataType: DataType): string { + if (id >= 0n) { + return id.toString(); + } + // Handle two's complement representation for negative BigInts. + if (dataType === DataType.UINT32) { + return ((1n << 32n) + id).toString(); + } + if (dataType === DataType.UINT64) { + return ((1n << 64n) + id).toString(); + } + // Fallback for other types, though this case is unlikely for labels. + return id.toString(); +} export class VoxToolTab extends Tab { @@ -35,7 +51,7 @@ export class VoxToolTab extends Tab { sw.style.background = this.layer.voxLabelsManager.colorForValue(lab); // id text (monospace) const txt = document.createElement("div"); - txt.textContent = String(lab); + txt.textContent = formatUnsignedId(lab, this.layer.voxLabelsManager.dataType); txt.style.fontFamily = "monospace"; txt.style.whiteSpace = "nowrap"; txt.style.overflow = "hidden"; diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 2624188ae8..1e6ad29919 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -304,15 +304,54 @@ export class AdoptVoxelLabelTool extends LegacyTool { toJSON() { return ADOPT_VOXEL_LABEL_TOOL_ID; } trigger(mouseState: MouseSelectionState) { if (!mouseState?.active) return; - const raw = (mouseState as any).pickedValue as bigint | number | undefined; - if (raw === undefined || raw === null) return; - const rawBig = typeof raw === 'bigint' ? raw : BigInt(raw); - if (rawBig === 0n) { - StatusMessage.showTemporaryMessage("Cannot adopt background label 0."); + const layer = this.layer as VoxUserLayer; + const pos = layer.getVoxelPositionFromMouse?.(mouseState); + + if (!pos || pos.length < 3) { + StatusMessage.showTemporaryMessage( + "Cannot pick label: position is not valid.", + 3000, + ); return; } - const layer = this.layer as unknown as VoxUserLayer; - layer.voxLabelsManager.addLabel(rawBig); + + const editController = layer.voxEditController; + if (!editController) { + StatusMessage.showTemporaryMessage( + "Cannot pick label: layer is not ready.", + 3000, + ); + return; + } + + const source = editController.getSourceForLOD( + 0, + ); + const channelAccess = editController.singleChannelAccess; + + + StatusMessage.forPromise( + source.getEnsuredValueAt(pos, channelAccess).then((value: bigint | number | null) => { + if (value === null) { + throw new Error("Voxel data not available at the selected position."); + } + const label = BigInt(value); + if (label === 0n) { + StatusMessage.showTemporaryMessage( + "Cannot adopt background label (0).", + 3000, + ); + return; + } + layer.voxLabelsManager.addLabel(label); + StatusMessage.showTemporaryMessage(`Adopted label: ${label}`, 3000); + }), + { + initialMessage: "Picking voxel label...", + delay: true, + errorPrefix: "Error picking label: ", + }, + ); } } diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 4cd0ff7107..02aa4ab4ee 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -61,7 +61,7 @@ export class VoxelEditController extends SharedObject { maxSize: 9, }; - private readonly singleChannelAccess: ChunkChannelAccessParameters = { + readonly singleChannelAccess: ChunkChannelAccessParameters = { numChannels: 1, channelSpaceShape: new Uint32Array([]), chunkChannelDimensionIndices: [], @@ -106,7 +106,7 @@ export class VoxelEditController extends SharedObject { return voxelSize; } - private getSourceForLOD(lodIndex: number): VolumeChunkSource { + getSourceForLOD(lodIndex: number): VolumeChunkSource { const sourcesByScale = this.multiscale.getSources(this.getIdentitySliceViewSourceOptions()); // Assuming a single orientation, which is correct for this use case. const sources = sourcesByScale[0]; @@ -295,7 +295,7 @@ export class VoxelEditController extends SharedObject { const isOriginalAt = async (px: number, py: number): Promise => { const value = await source.getEnsuredValueAt(new Float32Array([px, py, zPlane]), this.singleChannelAccess); - return value === originalValue; + return (typeof value !== "bigint" ? BigInt(value as number) : value) === originalValue; }; const getCurrentThickness = (): number => { diff --git a/src/voxel_annotation/labels.ts b/src/voxel_annotation/labels.ts index b286c23b22..d8fafb7ba4 100644 --- a/src/voxel_annotation/labels.ts +++ b/src/voxel_annotation/labels.ts @@ -10,7 +10,7 @@ export class LabelsManager { private sessionPrefix: bigint; private nextLocalId: bigint = 1n; - constructor(dataType: DataType, private onLabelsChanged?: () => void) { + constructor(public dataType: DataType, private onLabelsChanged?: () => void) { switch (dataType){ case DataType.UINT32: this.sessionPrefix = BigInt(Date.now() << 20); From ff0c70b9a3797efa0e2dcdd6c7f14fca44465afb Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 074/251] feat: convert voxel values to BigInt for improved precision in annotation handling --- NOTES/TODOs.md | 3 +++ src/voxel_annotation/edit_backend.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index 2d928f3095..02ad805385 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,6 +1,9 @@ # TODO List - FOR TOMORROW: start to prepare the problematic/email to JMS +- look onto the max downscale steps calculation (is it correct?) +- test uint64 support +- fix label multplication and wrong color in the ui list ### priority - fix the issue with the preview not rendering on empty chunks diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/edit_backend.ts index 830ac5c81e..12b4036b60 100644 --- a/src/voxel_annotation/edit_backend.ts +++ b/src/voxel_annotation/edit_backend.ts @@ -338,7 +338,7 @@ export class VoxelEditController extends SharedObject { v101, v011, v111, - ]); + ].map(v => BigInt(v))); const tx = x + offsetX; const ty = y + offsetY; From 9ceb2ade0edde22c8484d4e6a991ecfcc0383230 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 075/251] feat: refactor voxel annotation to support multiscale resolutions and improve downsampling logic - Replace LOD factors with resolution-based initialization for enhanced clarity and flexibility. - Refactor `VoxelEditController` and `VoxelEditBackend` to support multiscale voxel layer resolutions. - Simplify downsampling logic and improve edit propagation for cascaded updates. - Add helper functions for chunk key management and transformation calculations. - Update TODOs with test suite and isolating downsampling optimizations. --- NOTES/TODOs.md | 2 + src/ui/voxel_annotations.ts | 2 +- src/voxel_annotation/base.ts | 16 +- src/voxel_annotation/edit_backend.ts | 438 +++++++++++++++--------- src/voxel_annotation/edit_controller.ts | 26 +- 5 files changed, 302 insertions(+), 182 deletions(-) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index 02ad805385..ce951dee1b 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -11,6 +11,7 @@ - Add support for flood fill on different planes - fix the flood fill for compressed chunks - add shortcuts for tools (switching tools, toogle erase mode, select label from the pointed one in the slice view and adjusting brush size) and label creation +- write a testsuite for the downsampler ### extra - rework the ui (tabs) @@ -18,6 +19,7 @@ - rework the drawing preview for compressed chunk (see applyLocalEdits()) - rework the autocomplete for the ssa+https source. - the flood fill sometimes leaves artifacts in sharp areas +- isolate the downsampling ### questionable -? adapt the brush size to the zoom level linearly diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 1e6ad29919..6973629100 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -237,7 +237,7 @@ export class VoxelBrushLegacyTool extends BaseVoxelLegacyTool { } export class VoxelFloodFillLegacyTool extends LegacyTool { - description = "flood fill (2D plane, 4-connected)"; + description = "flood fill"; toJSON() { return FLOODFILL_TOOL_ID; diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index c9b7391488..1593e3e922 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -1,12 +1,18 @@ export const VOX_RELOAD_CHUNKS_RPC_ID = "vox.chunk.reload"; export const VOX_EDIT_BACKEND_RPC_ID = "vox.EditBackend"; export const VOX_EDIT_COMMIT_VOXELS_RPC_ID = "vox.edit.commitVoxels"; -export const VOX_EDIT_LABELS_GET_RPC_ID = "vox.edit.labels.get"; -export const VOX_EDIT_LABELS_ADD_RPC_ID = "vox.edit.labels.add"; export const VOX_EDIT_FAILURE_RPC_ID = "vox.edit.failure"; -export function makeVoxChunkKey(chunkKey: string, lodFactor : number) { - return `lod${lodFactor}#${chunkKey}`; + +export interface VoxelLayerResolution { + lodIndex: number; + transform: number[]; + chunkSize: number[]; + sourceRpc: number; +} + +export function makeVoxChunkKey(chunkKey: string, lodIndex: number) { + return `lod${lodIndex}#${chunkKey}`; } export function makeChunkKey(x: number, y : number, z: number) { @@ -20,5 +26,5 @@ export function parseVoxChunkKey(key: string) { console.warn(`Invalid chunk key format: ${key}`); return null; } - return { lod: parts[0], x: parts[1], y: parts[2], z: parts[3], chunkKey: key.split("#")[1] }; + return { lodIndex: parts[0], x: parts[1], y: parts[2], z: parts[3], chunkKey: key.split("#")[1] }; } diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/edit_backend.ts index 12b4036b60..b981b60bd0 100644 --- a/src/voxel_annotation/edit_backend.ts +++ b/src/voxel_annotation/edit_backend.ts @@ -1,4 +1,8 @@ import type { VolumeChunkSource } from "#src/sliceview/volume/backend.js"; +import { mat4, vec3 } from "#src/util/geom.js"; +import * as matrix from "#src/util/matrix.js"; +import type { + VoxelLayerResolution} from "#src/voxel_annotation/base.js"; import { VOX_EDIT_BACKEND_RPC_ID, VOX_EDIT_COMMIT_VOXELS_RPC_ID, @@ -6,6 +10,7 @@ import { VOX_EDIT_FAILURE_RPC_ID, makeVoxChunkKey, parseVoxChunkKey, + makeChunkKey, } from "#src/voxel_annotation/base.js"; import type { RPC } from "#src/worker_rpc.js"; import { SharedObject , registerRPC, registerSharedObject, initializeSharedObjectCounterpart } from "#src/worker_rpc.js"; @@ -13,8 +18,8 @@ import { SharedObject , registerRPC, registerSharedObject, initializeSharedObjec @registerSharedObject(VOX_EDIT_BACKEND_RPC_ID) export class VoxelEditController extends SharedObject { private sources = new Map(); + private resolutions = new Map(); - // Short debounce to coalesce rapid edits coming from tools. private pendingEdits: { key: string; indices: number[] | Uint32Array; @@ -25,34 +30,38 @@ export class VoxelEditController extends SharedObject { private commitDebounceTimer: number | undefined; private readonly commitDebounceDelayMs: number = 300; - // Downsampling queue to serialize and coalesce work across edits. private downsampleQueue: string[] = []; private downsampleQueueSet: Set = new Set(); private isProcessingDownsampleQueue: boolean = false; constructor(rpc: RPC, options: any) { super(); - // Initialize as a counterpart in the worker so RPC references are valid. - // This registers the object under the provided rpc/id and sets up ref counting. initializeSharedObjectCounterpart(this, rpc, options); - const passedSources = options?.sources; - if (passedSources === undefined || typeof passedSources !== "object") { + const passedResolutions = options?.resolutions as + | VoxelLayerResolution[] + | undefined; + if ( + passedResolutions === undefined || + !Array.isArray(passedResolutions) + ) { throw new Error( - "VoxelEditBackend: missing required 'sources' map during initialization", + "VoxelEditBackend: missing required 'resolutions' array during initialization", ); } - for (const lodFactorStr in passedSources) { - const lodFactor = Number(lodFactorStr); - const sourceId = passedSources[lodFactorStr]; - const resolved = rpc.get(sourceId) as VolumeChunkSource | undefined; + for (const res of passedResolutions) { + const rank = res.chunkSize.length; + const invTransform = new Float32Array((rank + 1) ** 2); + matrix.inverse(invTransform, rank + 1, new Float32Array(res.transform), rank + 1, rank + 1); + this.resolutions.set(res.lodIndex, { ...res, invTransform: invTransform as mat4 }); + const resolved = rpc.get(res.sourceRpc) as VolumeChunkSource | undefined; if (!resolved) { throw new Error( - `VoxelEditBackend: failed to resolve VolumeChunkSource for LOD factor ${lodFactor}`, + `VoxelEditBackend: failed to resolve VolumeChunkSource for LOD ${res.lodIndex}`, ); } - this.sources.set(lodFactor, resolved); + this.sources.set(res.lodIndex, resolved); } } @@ -62,7 +71,6 @@ export class VoxelEditController extends SharedObject { this.commitDebounceTimer = undefined; if (edits.length === 0) return; - // 1. Group edits by vox chunk key (includes LOD). const editsByVoxKey = new Map< string, { indices: number[]; values: bigint[] } @@ -93,7 +101,6 @@ export class VoxelEditController extends SharedObject { } } - // 2. For each modified vox chunk, apply edits via the correct source and record vox keys to reload. const failedVoxChunkKeys: string[] = []; let firstErrorMessage: string | undefined = undefined; for (const [voxKey, chunkEdits] of editsByVoxKey.entries()) { @@ -106,9 +113,9 @@ export class VoxelEditController extends SharedObject { if (firstErrorMessage === undefined) firstErrorMessage = msg; continue; } - const source = this.sources.get(parsedKey.lod); + const source = this.sources.get(parsedKey.lodIndex); if (!source) { - const msg = `flushPending: No source found for LOD factor ${parsedKey.lod}`; + const msg = `flushPending: No source found for LOD index ${parsedKey.lodIndex}`; console.error(msg); failedVoxChunkKeys.push(voxKey); if (firstErrorMessage === undefined) firstErrorMessage = msg; @@ -127,7 +134,6 @@ export class VoxelEditController extends SharedObject { } } - // If any failures occurred, notify the frontend asynchronously. if (failedVoxChunkKeys.length > 0) { this.rpc?.invoke(VOX_EDIT_FAILURE_RPC_ID, { rpcId: this.rpcId, @@ -136,7 +142,6 @@ export class VoxelEditController extends SharedObject { }); } - // After base edits, enqueue downsampling for affected chunks (do not await here). const touched = new Set(); for (const e of edits) touched.add(e.key); for (const key of touched) { @@ -155,7 +160,7 @@ export class VoxelEditController extends SharedObject { ) { for (const e of edits) { if (!e || !e.key || !e.indices) { - throw new Error("VoxEditBackend.commitVoxels: invalid edit payload"); + throw new Error("VoxelEditController.commitVoxels: invalid edit payload"); } this.pendingEdits.push(e); } @@ -172,57 +177,8 @@ export class VoxelEditController extends SharedObject { voxChunkKeys: voxChunkKeys, }); } - // Downsampling helpers - private parentKeyOf(childKey: string): string | null { - const info = parseVoxChunkKey(childKey); - if (info === null) return null; - const parentLod = info.lod * 2; - const px = Math.floor(info.x / 2); - const py = Math.floor(info.y / 2); - const pz = Math.floor(info.z / 2); - return makeVoxChunkKey(`${px},${py},${pz}`, parentLod); - } - - private calculateDownsamplePasses(chunkSize: number): number { - if (chunkSize <= 1) return 0; - return Math.ceil(Math.log2(chunkSize)); - } - - private async performDownsampleCascadeForKey( - sourceKey: string, - ): Promise { - const chunkSize = 64; - const maxPasses = this.calculateDownsamplePasses(chunkSize); - const maxLOD = 256; - let currentKey: string | null = sourceKey; - for (let i = 0; i < maxPasses; i++) { - if (currentKey === null) break; - const info = parseVoxChunkKey(currentKey); - if (info === null) break; - if (info.lod >= maxLOD) break; - const nextKey = await this.downsampleStep(currentKey); - if (nextKey === null) break; - currentKey = nextKey; - } - } - - private calculateMode(values: bigint[]): bigint { - if (values.length === 0) return 0n; - const counts = new Map(); - let maxCount = 0; - let mode = 0n; - for (const v of values) { - if (v === 0n) continue; - const c = (counts.get(v) ?? 0) + 1; - counts.set(v, c); - if (c > maxCount) { - maxCount = c; - mode = v; - } - } - return mode; - } + // --- Start of Downsampling Logic --- private enqueueDownsample(key: string): void { if (key.length === 0) return; @@ -231,7 +187,6 @@ export class VoxelEditController extends SharedObject { this.downsampleQueue.push(key); } if (!this.isProcessingDownsampleQueue) { - // Kick processing asynchronously to avoid blocking the caller. this.isProcessingDownsampleQueue = true; Promise.resolve().then(() => this.processDownsampleQueue()); } @@ -246,7 +201,6 @@ export class VoxelEditController extends SharedObject { } } finally { this.isProcessingDownsampleQueue = false; - // If new work was enqueued during processing and flag got reset, loop again. if ( this.downsampleQueue.length > 0 && !this.isProcessingDownsampleQueue @@ -257,121 +211,275 @@ export class VoxelEditController extends SharedObject { } } - private async downsampleStep(sourceKey: string): Promise { - const info = parseVoxChunkKey(sourceKey); - if (info === null) return null; - - const src = this.sources.get(info.lod); - if (!src) return null; + private async performDownsampleCascadeForKey(sourceKey: string): Promise { + let currentKey: string | null = sourceKey; + while (currentKey !== null) { + currentKey = await this.downsampleStep(currentKey); + } + } - const sourceChunk = src.getChunk( - new Float32Array([info.x, info.y, info.z]), - ) as any; - if (!sourceChunk) return null; + /** + * Performs a single downsampling step from a child chunk to its parent. + * @returns The key of the parent chunk that was updated, or null if the cascade should stop. + */ + private async downsampleStep(childKey: string): Promise { + // 1. Get child chunk and ensure its data is loaded. + const childInfo = parseVoxChunkKey(childKey); + if (childInfo === null) { + console.error(`[Downsample] Invalid child key format: ${childKey}`); + return null; + } + const childSource = this.sources.get(childInfo.lodIndex); + if (!childSource) { + console.error(`[Downsample] No source found for child LOD: ${childInfo.lodIndex}`); + return null; + } - if (!sourceChunk.data) { + const childChunk = childSource.getChunk(new Float32Array([childInfo.x, childInfo.y, childInfo.z])) as any; + if (!childChunk.data) { try { - const ac = new AbortController(); - await src.download(sourceChunk, ac.signal); + await childSource.download(childChunk, new AbortController().signal); } catch (e) { - console.warn( - `Failed to download source chunk ${sourceKey} for downsampling:`, - e, - ); + console.warn(`[Downsample] Failed to download source chunk ${childKey}:`, e); + return null; } } + const childChunkData = childChunk.data as (Uint32Array | BigUint64Array); + const childRes = this.resolutions.get(childInfo.lodIndex)!; - if (!sourceChunk.data) { - console.warn( - `Cannot downsample from empty or missing chunk: ${sourceKey}`, - ); + // 2. Determine the parent chunk that corresponds to this child chunk. + const parentInfo = this._getParentChunkInfo(childKey, childRes); + if (parentInfo === null) { + // Reached the coarsest LOD, stop the cascade. return null; } + const { parentKey, parentSource, parentRes } = parentInfo; - const targetKey = this.parentKeyOf(sourceKey); - if (targetKey === null) return null; + // 3. Calculate the update for the parent chunk based on the child chunk's data. + const update = this._calculateParentUpdate(childChunkData, childRes, parentRes, childInfo); + if (update.indices.length === 0) { + console.log(`[Downsample] No update required for parent chunk ${parentKey}.`); + return parentKey; + } - // Prepare indices and values to write into the target chunk - const chunkW = sourceChunk.chunkDataSize[0] / 2; // target subregion width - const chunkH = sourceChunk.chunkDataSize[1] / 2; - const chunkD = sourceChunk.chunkDataSize[2] / 2; - const offsetX = (info.x % 2) * chunkW; - const offsetY = (info.y % 2) * chunkH; - const offsetZ = (info.z % 2) * chunkD; + // 4. Commit the update to the parent chunk and notify the frontend. + try { + console.log(`[Downsample] Committing ${update.indices.length} voxels to ${parentKey}.`); + await parentSource.applyEdits(parentInfo.chunkKey, update.indices, update.values); + this.callChunkReload([parentKey]); + } catch (e) { + console.error(`[Downsample] Failed to apply edits to parent chunk ${parentKey}:`, e); + this.rpc?.invoke(VOX_EDIT_FAILURE_RPC_ID, { + rpcId: this.rpcId, + voxChunkKeys: [parentKey], + message: `Downsampling to ${parentKey} failed.`, + }); + return null; // Stop cascade on failure. + } + + return parentKey; + } + + /** + * Helper to find and describe the parent chunk. + */ + private _getParentChunkInfo(childKey: string, childRes: VoxelLayerResolution) { + const childInfo = parseVoxChunkKey(childKey)!; + const parentLodIndex = childInfo.lodIndex + 1; + const parentRes = this.resolutions.get(parentLodIndex); + if (parentRes === undefined) return null; // No parent LOD exists + + const parentSource = this.sources.get(parentLodIndex)!; + const rank = childRes.chunkSize.length; + + // Find the world coordinate of the child chunk's origin + const childVoxelOrigin = new Float32Array(rank); + childVoxelOrigin.set([ + childInfo.x * childRes.chunkSize[0], + childInfo.y * childRes.chunkSize[1], + childInfo.z * childRes.chunkSize[2], + ]); + const childPhysOrigin = new Float32Array(rank); + matrix.transformPoint(childPhysOrigin, new Float32Array(childRes.transform), rank + 1, childVoxelOrigin, rank); + + // Transform that world coordinate into the parent's voxel space + const parentVoxelCoordOfChildOrigin = new Float32Array(rank); + matrix.transformPoint(parentVoxelCoordOfChildOrigin, parentRes.invTransform, rank + 1, childPhysOrigin, rank); + + // Determine the parent chunk's grid position + const parentX = Math.floor(parentVoxelCoordOfChildOrigin[0] / parentRes.chunkSize[0]); + const parentY = Math.floor(parentVoxelCoordOfChildOrigin[1] / parentRes.chunkSize[1]); + const parentZ = Math.floor(parentVoxelCoordOfChildOrigin[2] / parentRes.chunkSize[2]); + + const parentChunkKey = makeChunkKey(parentX, parentY, parentZ); + const parentKey = makeVoxChunkKey(parentChunkKey, parentLodIndex); + return { parentKey, chunkKey: parentChunkKey, parentRes, parentSource }; + } - const targetChunkW = sourceChunk.chunkDataSize[0]; - const targetChunkH = sourceChunk.chunkDataSize[1]; + /** + * Calculates the downsampled voxel values for a region of a parent chunk. + * This is the core aggregation logic. + */ + private _calculateParentUpdate( + childChunkData: Uint32Array | BigUint64Array, + childRes: VoxelLayerResolution & { invTransform: mat4 }, + parentRes: VoxelLayerResolution & { invTransform: mat4 }, + childInfo: { x: number, y: number, z: number } + ) { const indices: number[] = []; const values: bigint[] = []; + const rank = childRes.chunkSize.length; + const childChunkSize = childRes.chunkSize; + const parentChunkSize = parentRes.chunkSize; + + // Transform to map a point in parent-voxel-space to a point in child-voxel-space. + const parentVoxelToChildVoxelTransform = mat4.multiply( + mat4.create(), + childRes.invTransform, + new Float32Array(parentRes.transform) as mat4 + ); + + // Calculate the child chunk's origin and extent in absolute child-voxel-space + const childChunkOrigin = new Float32Array([ + childInfo.x * childChunkSize[0], + childInfo.y * childChunkSize[1], + childInfo.z * childChunkSize[2], + ]); + const childChunkMax = new Float32Array([ + (childInfo.x + 1) * childChunkSize[0], + (childInfo.y + 1) * childChunkSize[1], + (childInfo.z + 1) * childChunkSize[2], + ]); + + // Transform child chunk bounds to physical space + const childPhysOrigin = new Float32Array(rank); + matrix.transformPoint(childPhysOrigin, new Float32Array(childRes.transform), rank + 1, childChunkOrigin, rank); + const childPhysMax = new Float32Array(rank); + matrix.transformPoint(childPhysMax, new Float32Array(childRes.transform), rank + 1, childChunkMax, rank); + + // Transform to parent-voxel-space to find the affected region + const parentVoxelMin = new Float32Array(rank); + matrix.transformPoint(parentVoxelMin, parentRes.invTransform, rank + 1, childPhysOrigin, rank); + const parentVoxelMax = new Float32Array(rank); + matrix.transformPoint(parentVoxelMax, parentRes.invTransform, rank + 1, childPhysMax, rank); + + // Determine which parent chunk this corresponds to (should match _getParentChunkInfo) + const parentChunkGridX = Math.floor(parentVoxelMin[0] / parentChunkSize[0]); + const parentChunkGridY = Math.floor(parentVoxelMin[1] / parentChunkSize[1]); + const parentChunkGridZ = Math.floor(parentVoxelMin[2] / parentChunkSize[2]); + + // Calculate the parent chunk's origin in absolute parent-voxel-space + const parentChunkOriginInParentVoxels = new Float32Array([ + parentChunkGridX * parentChunkSize[0], + parentChunkGridY * parentChunkSize[1], + parentChunkGridZ * parentChunkSize[2], + ]); + + // Calculate the region to iterate over in the parent chunk's LOCAL coordinate space (0 to chunkSize) + const parentLocalMin = new Float32Array(rank); + const parentLocalMax = new Float32Array(rank); + for (let i = 0; i < rank; ++i) { + parentLocalMin[i] = Math.max(0, Math.floor(parentVoxelMin[i] - parentChunkOriginInParentVoxels[i])); + parentLocalMax[i] = Math.min(parentChunkSize[i], Math.ceil(parentVoxelMax[i] - parentChunkOriginInParentVoxels[i])); + } - for (let z = 0; z < chunkD; z++) { - for (let y = 0; y < chunkH; y++) { - for (let x = 0; x < chunkW; x++) { - const sx = x * 2; - const sy = y * 2; - const sz = z * 2; - const base = - sz * (sourceChunk.chunkDataSize[0] * sourceChunk.chunkDataSize[1]) + - sy * sourceChunk.chunkDataSize[0] + - sx; - const row = sourceChunk.chunkDataSize[0]; - const plane = - sourceChunk.chunkDataSize[0] * sourceChunk.chunkDataSize[1]; - // Gather 8 voxels from source - const v000 = (sourceChunk.data as any)[base]; - const v100 = ((sourceChunk.data as any)[base + 1]); - const v010 = ((sourceChunk.data as any)[base + row]); - const v110 = ((sourceChunk.data as any)[base + row + 1]); - const v001 = ((sourceChunk.data as any)[base + plane]); - const v101 = ((sourceChunk.data as any)[base + plane + 1]); - const v011 = ((sourceChunk.data as any)[base + plane + row]); - const v111 = ( - (sourceChunk.data as any)[base + plane + row + 1] - ); - const mode = this.calculateMode([ - v000, - v100, - v010, - v110, - v001, - v101, - v011, - v111, - ].map(v => BigInt(v))); - - const tx = x + offsetX; - const ty = y + offsetY; - const tz = z + offsetZ; - const tIndex = - tz * (targetChunkW * targetChunkH) + ty * targetChunkW + tx; - indices.push(tIndex); - values.push(mode); + const [startX, startY, startZ] = parentLocalMin; + const [endX, endY, endZ] = parentLocalMax; + + const corners = new Array(8).fill(0).map(() => vec3.create()); + const transformedCorners = new Array(8).fill(0).map(() => vec3.create()); + const sourceVoxels: bigint[] = []; + const [childW, childH, childD] = childChunkSize; + const [parentW, parentH] = parentChunkSize; + + // Iterate over each voxel in the affected region of the parent chunk (in local coordinates) + for (let pz = startZ; pz < endZ; ++pz) { + for (let py = startY; py < endY; ++py) { + for (let px = startX; px < endX; ++px) { + // Convert from parent-chunk-local to absolute parent-voxel-space + const absParentX = parentChunkOriginInParentVoxels[0] + px; + const absParentY = parentChunkOriginInParentVoxels[1] + py; + const absParentZ = parentChunkOriginInParentVoxels[2] + pz; + + // Define the 8 corners of the current parent voxel in absolute parent-voxel-space + vec3.set(corners[0], absParentX, absParentY, absParentZ); + vec3.set(corners[1], absParentX + 1, absParentY, absParentZ); + vec3.set(corners[2], absParentX, absParentY + 1, absParentZ); + vec3.set(corners[3], absParentX + 1, absParentY + 1, absParentZ); + vec3.set(corners[4], absParentX, absParentY, absParentZ + 1); + vec3.set(corners[5], absParentX + 1, absParentY, absParentZ + 1); + vec3.set(corners[6], absParentX, absParentY + 1, absParentZ + 1); + vec3.set(corners[7], absParentX + 1, absParentY + 1, absParentZ + 1); + + // Transform corners to absolute child-voxel-space + for (let i = 0; i < 8; ++i) { + vec3.transformMat4(transformedCorners[i], corners[i], parentVoxelToChildVoxelTransform); + } + + // Find bounding box in absolute child-voxel-space + const childMin = vec3.clone(transformedCorners[0]); + const childMax = vec3.clone(transformedCorners[0]); + for (let i = 1; i < 8; ++i) { + vec3.min(childMin, childMin, transformedCorners[i]); + vec3.max(childMax, childMax, transformedCorners[i]); + } + + // Convert to child-chunk-local coordinates for array indexing + const localChildMin = vec3.create(); + const localChildMax = vec3.create(); + vec3.subtract(localChildMin, childMin, childChunkOrigin as any); + vec3.subtract(localChildMax, childMax, childChunkOrigin as any); + + // Collect all child voxels within this bounding box (in local coordinates) + sourceVoxels.length = 0; + const cStartX = Math.max(0, Math.floor(localChildMin[0])); + const cEndX = Math.min(childW, Math.ceil(localChildMax[0])); + const cStartY = Math.max(0, Math.floor(localChildMin[1])); + const cEndY = Math.min(childH, Math.ceil(localChildMax[1])); + const cStartZ = Math.max(0, Math.floor(localChildMin[2])); + const cEndZ = Math.min(childD, Math.ceil(localChildMax[2])); + + for (let cz = cStartZ; cz < cEndZ; ++cz) { + for (let cy = cStartY; cy < cEndY; ++cy) { + for (let cx = cStartX; cx < cEndX; ++cx) { + const srcIndex = cz * (childW * childH) + cy * childW + cx; + sourceVoxels.push(BigInt(childChunkData[srcIndex])); + } + } + } + + if (sourceVoxels.length > 0) { + const mode = this._calculateMode(sourceVoxels); + // Use local coordinates for the parent chunk index + const parentIndex = pz * (parentW * parentH) + py * parentW + px; + indices.push(parentIndex); + values.push(mode); + } } } } - if (indices.length > 0) { - const targetInfo = parseVoxChunkKey(targetKey); - if (targetInfo) { - const targetSrc = this.sources.get(targetInfo.lod); - if (targetSrc) { - await (targetSrc as any).applyEdits( - targetInfo.chunkKey, - indices, - values, - ); - // Notify frontend to reload the target parent chunk at its LOD. - this.callChunkReload([targetKey]); - } else { - console.error( - `Downsampling failed: could not find target source for LOD ${targetInfo.lod}`, - ); - } + console.log(`[Downsample] Downsampled ${indices.length} voxels from child chunk (${childInfo.x},${childInfo.y},${childInfo.z}).`); + return { indices, values }; + } + + private _calculateMode(values: (bigint | number)[]): bigint { + if (values.length === 0) return 0n; + const counts = new Map(); + let maxCount = 0; + let mode = 0n; + for (const v of values) { + const bigV = BigInt(v); + if (bigV === 0n) continue; + const c = (counts.get(bigV) ?? 0) + 1; + counts.set(bigV, c); + if (c > maxCount) { + maxCount = c; + mode = bigV; } } - - return targetKey; + return mode; } } diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 02aa4ab4ee..04afd00bb5 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -6,13 +6,15 @@ import type { VoxUserLayer } from "#src/layer/vox/index.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; import type { VolumeChunkSource , MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; +import type { + VoxelLayerResolution} from "#src/voxel_annotation/base.js"; import { VOX_EDIT_BACKEND_RPC_ID, VOX_EDIT_COMMIT_VOXELS_RPC_ID, VOX_RELOAD_CHUNKS_RPC_ID, VOX_EDIT_FAILURE_RPC_ID, makeVoxChunkKey, - parseVoxChunkKey, + parseVoxChunkKey } from "#src/voxel_annotation/base.js"; import { registerRPC, @@ -36,18 +38,23 @@ export class VoxelEditController extends SharedObject { throw new Error("VoxelEditController: Could not retrieve sources from multiscale object."); } - const sourceMap: { [key: number]: number } = {}; + const resolutions: VoxelLayerResolution[] = []; + for (let i = 0; i < sources.length; ++i) { const source = sources[i]!.chunkSource; - const lodFactor = 1 << i; // LOD factor is 2^i const rpcId = source.rpcId; if (rpcId == null) { throw new Error(`VoxelEditController: Source at LOD index ${i} has null rpcId during initialization.`); } - sourceMap[lodFactor] = rpcId; + resolutions.push({ + lodIndex: i, + transform: Array.from(sources[i]!.chunkToMultiscaleTransform), + chunkSize: Array.from(source.spec.chunkDataSize), + sourceRpc: rpcId + }); } - this.initializeCounterpart(rpc, { sources: sourceMap }); + this.initializeCounterpart(rpc, { resolutions }); } private static readonly qualityFactor = 16.0; private static readonly restrictToMinLOD = true; @@ -197,11 +204,10 @@ export class VoxelEditController extends SharedObject { if (!voxelsToPaint || voxelsToPaint.length === 0) return; const editsByVoxKey = new Map(); - const lodFactor = 1 << sourceIndex; for (const voxelCoord of voxelsToPaint) { const { chunkGridPosition, positionWithinChunk } = source.computeChunkIndices(voxelCoord); const chunkKey = chunkGridPosition.join(); - const voxKey = makeVoxChunkKey(chunkKey, lodFactor); + const voxKey = makeVoxChunkKey(chunkKey, sourceIndex); let entry = editsByVoxKey.get(voxKey); if (!entry) { @@ -424,11 +430,10 @@ export class VoxelEditController extends SharedObject { } const editsByVoxKey = new Map(); - const lodFactor = 1 << sourceIndex; for (const voxelCoord of voxelsToFill) { const { chunkGridPosition, positionWithinChunk } = source.computeChunkIndices(voxelCoord); const chunkKey = chunkGridPosition.join(); - const voxKey = makeVoxChunkKey(chunkKey, lodFactor); + const voxKey = makeVoxChunkKey(chunkKey, sourceIndex); let entry = editsByVoxKey.get(voxKey); if (!entry) { @@ -472,8 +477,7 @@ export class VoxelEditController extends SharedObject { for (const voxKey of voxChunkKeys) { const parsed = parseVoxChunkKey(voxKey); if (!parsed) continue; - const lodIndex = Math.log2(parsed.lod); - const source = sources[lodIndex]?.chunkSource as VolumeChunkSource | undefined; + const source = sources[parsed.lodIndex]?.chunkSource as VolumeChunkSource | undefined; if (!source) continue; let arr = chunksToInvalidateBySource.get(source); if (!arr) { From c8b079bceb942dd6471be9570c1f78bb447f77ae Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 076/251] feat: improve uncompressed chunk editing and fix TODOs - Add support for editing unloaded/uninitialized chunks by creating zero-filled buffers. - Fix flood fill and picker tools when zoomed out, and update UI label color issues. - Enhance imports and extend handling for uncompressed chunk formats. - Update TODOs with new dataset creation priority. --- NOTES/TODOs.md | 5 ++-- src/sliceview/volume/frontend.ts | 34 +++++++++++++++++++++++++--- src/voxel_annotation/edit_backend.ts | 3 --- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index ce951dee1b..f37e969933 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -3,10 +3,11 @@ - FOR TOMORROW: start to prepare the problematic/email to JMS - look onto the max downscale steps calculation (is it correct?) - test uint64 support -- fix label multplication and wrong color in the ui list +- fix label multiplication and wrong color in the ui list +- fix drawing tools (flood fill and picker not working when zoomed out) +- design a dataset creation feature ### priority -- fix the issue with the preview not rendering on empty chunks - Fix the orientation of the disk in the brush tool - Add support for flood fill on different planes - fix the flood fill for compressed chunks diff --git a/src/sliceview/volume/frontend.ts b/src/sliceview/volume/frontend.ts index be6a20ffcd..9911ad4fa0 100644 --- a/src/sliceview/volume/frontend.ts +++ b/src/sliceview/volume/frontend.ts @@ -25,7 +25,11 @@ import { encodeChannel as encodeChannelUint32 } from "#src/sliceview/compressed_ import { encodeChannel as encodeChannelUint64 } from "#src/sliceview/compressed_segmentation/encode_uint64.js"; import type { SliceViewChunk } from "#src/sliceview/frontend.js"; import { MultiscaleSliceViewChunkSource, SliceViewChunkSource } from "#src/sliceview/frontend.js"; -import { ChunkFormat as UncompressedChunkFormat } from "#src/sliceview/uncompressed_chunk_format.js"; +import { + ChunkFormat as UncompressedChunkFormat, + UncompressedChunkFormatHandler, + UncompressedVolumeChunk, +} from "#src/sliceview/uncompressed_chunk_format.js"; import type { VolumeChunkSource as VolumeChunkSourceInterface, VolumeChunkSpecification, @@ -36,7 +40,10 @@ import { VolumeChunk } from "#src/sliceview/volume/chunk.js"; import { getChunkFormatHandler } from "#src/sliceview/volume/registry.js"; import type { TypedArray} from "#src/util/array.js"; import { TypedArrayBuilder } from "#src/util/array.js"; -import { DataType as DataTypeUtil } from "#src/util/data_type.js"; +import { + DATA_TYPE_ARRAY_CONSTRUCTOR, + DataType as DataTypeUtil, +} from "#src/util/data_type.js"; import type { Disposable } from "#src/util/disposable.js"; import type { GL } from "#src/webgl/context.js"; import type { ShaderBuilder, ShaderProgram } from "#src/webgl/shader.js"; @@ -243,7 +250,28 @@ export class VolumeChunkSource const processEdit = (targetChunk: VolumeChunk) => { const chunkFormat = targetChunk.chunkFormat; if (chunkFormat instanceof UncompressedChunkFormat) { - const cpuArray = (targetChunk as any).data as TypedArray; + const uncompressedChunk = targetChunk as UncompressedVolumeChunk; + let cpuArray = uncompressedChunk.data as TypedArray | null; + if (cpuArray === null) { + // If the chunk currently has the shared fill value texture, we must + // detach it so that a new texture is created for the edited data. + const handler = + uncompressedChunk.source.chunkFormatHandler as UncompressedChunkFormatHandler; + if (uncompressedChunk.texture === handler.fillValueChunk.texture) { + uncompressedChunk.texture = null; + uncompressedChunk.textureLayout = null; + } + + // Chunk data is null, meaning it's an empty/unloaded chunk. + // We must create a zero-filled buffer to apply the preview edit. + const { chunkDataSize, source } = uncompressedChunk; + const numElements = chunkDataSize.reduce((a, b) => a * b, 1); + const Ctor = DATA_TYPE_ARRAY_CONSTRUCTOR[source.spec.dataType]; + cpuArray = new (Ctor as any)(numElements); + uncompressedChunk.data = cpuArray; + } + if (cpuArray === null) + throw new Error("Unexpected null chunk data"); const { dataType } = chunkFormat; for (const index of edit.indices) { if (dataType === DataTypeUtil.UINT32) { diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/edit_backend.ts index b981b60bd0..ffcbd5fe3f 100644 --- a/src/voxel_annotation/edit_backend.ts +++ b/src/voxel_annotation/edit_backend.ts @@ -258,13 +258,11 @@ export class VoxelEditController extends SharedObject { // 3. Calculate the update for the parent chunk based on the child chunk's data. const update = this._calculateParentUpdate(childChunkData, childRes, parentRes, childInfo); if (update.indices.length === 0) { - console.log(`[Downsample] No update required for parent chunk ${parentKey}.`); return parentKey; } // 4. Commit the update to the parent chunk and notify the frontend. try { - console.log(`[Downsample] Committing ${update.indices.length} voxels to ${parentKey}.`); await parentSource.applyEdits(parentInfo.chunkKey, update.indices, update.values); this.callChunkReload([parentKey]); } catch (e) { @@ -460,7 +458,6 @@ export class VoxelEditController extends SharedObject { } } - console.log(`[Downsample] Downsampled ${indices.length} voxels from child chunk (${childInfo.x},${childInfo.y},${childInfo.z}).`); return { indices, values }; } From 06abea9da1541595d69b7174977d63563db58251 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 077/251] feat: add undo/redo support for voxel editing and enhance change tracking - Implement undo/redo stacks in `VoxelEditController` for tracking and reverting edits. - Add new `VoxelChange` type to encapsulate indices and old/new values. - Introduce RPC methods for undo/redo actions and history updates. - Update frontend to include undo/redo buttons and dynamically disable/enable based on stack states. - Refactor `VoxelEditController` to notify frontend on history changes. - Modify `applyEdits` to return detailed change information for undo/redo functionality. --- NOTES/TODOs.md | 18 +-- src/layer/vox/tabs/tools.ts | 54 +++++++- src/sliceview/volume/backend.ts | 25 +++- src/ui/voxel_annotations.ts | 5 +- src/voxel_annotation/base.ts | 18 ++- src/voxel_annotation/edit_backend.ts | 166 +++++++++++++++++++++--- src/voxel_annotation/edit_controller.ts | 83 ++++++------ 7 files changed, 289 insertions(+), 80 deletions(-) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index f37e969933..1cff9f406f 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -1,18 +1,18 @@ # TODO List -- FOR TOMORROW: start to prepare the problematic/email to JMS -- look onto the max downscale steps calculation (is it correct?) -- test uint64 support -- fix label multiplication and wrong color in the ui list -- fix drawing tools (flood fill and picker not working when zoomed out) -- design a dataset creation feature +### FOR TOMORROW: +- start to prepare the problematic/email to JMS + ### priority - Fix the orientation of the disk in the brush tool - Add support for flood fill on different planes - fix the flood fill for compressed chunks -- add shortcuts for tools (switching tools, toogle erase mode, select label from the pointed one in the slice view and adjusting brush size) and label creation -- write a testsuite for the downsampler +- test uint64 support +- fix label multiplication and wrong color in the ui list +- fix drawing tools (flood fill and picker not working when zoomed out) +- design a dataset creation feature +- undo tool (keep a stack of pre-edit values lists upon commit) ### extra - rework the ui (tabs) @@ -21,6 +21,8 @@ - rework the autocomplete for the ssa+https source. - the flood fill sometimes leaves artifacts in sharp areas - isolate the downsampling +- add shortcuts for tools (switching tools, toogle erase mode, select label from the pointed one in the slice view and adjusting brush size) and label creation +- write a testsuite for the downsampler and ensure its proper working on exotic lod levels ### questionable -? adapt the brush size to the zoom level linearly diff --git a/src/layer/vox/tabs/tools.ts b/src/layer/vox/tabs/tools.ts index 08e8006a41..0bb7a13da1 100644 --- a/src/layer/vox/tabs/tools.ts +++ b/src/layer/vox/tabs/tools.ts @@ -7,8 +7,8 @@ import { VoxelFloodFillLegacyTool, AdoptVoxelLabelTool, } from "#src/ui/voxel_annotations.js"; -import { Tab } from "#src/widget/tab_view.js"; import { DataType } from "#src/util/data_type.js"; +import { Tab } from "#src/widget/tab_view.js"; function formatUnsignedId(id: bigint, dataType: DataType): string { if (id >= 0n) { @@ -132,6 +132,58 @@ export class VoxToolTab extends Tab { toolsRow.appendChild(toolsWrap); toolbox.appendChild(toolsRow); + // Section: History (Undo/Redo) + const historyRow = document.createElement("div"); + historyRow.className = "neuroglancer-vox-row"; + const historyLabel = document.createElement("label"); + historyLabel.textContent = "History"; + const historyButtons = document.createElement("div"); + historyButtons.style.display = "flex"; + historyButtons.style.gap = "8px"; + + const undoButton = document.createElement("button"); + undoButton.textContent = "Undo"; + undoButton.title = "Undo last voxel edit"; + undoButton.addEventListener("click", () => { + const controller = this.layer.voxEditController; + if (!controller) { + throw new Error("Undo failed: voxEditController is not available."); + } + controller.undo(); + }); + + const redoButton = document.createElement("button"); + redoButton.textContent = "Redo"; + redoButton.title = "Redo last undone voxel edit"; + redoButton.addEventListener("click", () => { + const controller = this.layer.voxEditController; + if (!controller) { + throw new Error("Redo failed: voxEditController is not available."); + } + controller.redo(); + }); + + // TODO: move this logic to a signal activated function, so it runs after the voxEditController is instantiated + const ctrl = this.layer.voxEditController; + if (ctrl) { + undoButton.disabled = ctrl.undoCount.value === 0; + redoButton.disabled = ctrl.redoCount.value === 0; + this.registerDisposer(ctrl.undoCount.changed.add(() => { + undoButton.disabled = ctrl.undoCount.value === 0; + })); + this.registerDisposer(ctrl.redoCount.changed.add(() => { + redoButton.disabled = ctrl.redoCount.value === 0; + })); + } else { + console.error("TODO") + } + + historyButtons.appendChild(undoButton); + historyButtons.appendChild(redoButton); + historyRow.appendChild(historyLabel); + historyRow.appendChild(historyButtons); + toolbox.appendChild(historyRow); + // Section: Brush settings const brushRow = document.createElement("div"); brushRow.className = "neuroglancer-vox-row"; diff --git a/src/sliceview/volume/backend.ts b/src/sliceview/volume/backend.ts index 111cb94c80..f640cd7a60 100644 --- a/src/sliceview/volume/backend.ts +++ b/src/sliceview/volume/backend.ts @@ -17,7 +17,8 @@ import type { Chunk } from "#src/chunk_manager/backend.js"; import { ChunkState } from "#src/chunk_manager/base.js"; import { SliceViewChunk, SliceViewChunkSourceBackend } from "#src/sliceview/backend.js"; -import { DataType, SliceViewChunkSpecification } from "#src/sliceview/base.js"; +import type { SliceViewChunkSpecification } from "#src/sliceview/base.js"; +import { DataType } from "#src/sliceview/base.js"; import type { VolumeChunkSource as VolumeChunkSourceInterface, VolumeChunkSpecification @@ -27,6 +28,7 @@ import { DATA_TYPE_ARRAY_CONSTRUCTOR } from "#src/util/data_type.js"; import type { vec3 } from "#src/util/geom.js"; import { HttpError } from "#src/util/http_request.js"; import * as vector from "#src/util/vector.js"; +import type { VoxelChange } from "#src/voxel_annotation/base.js"; import type { RPC } from "#src/worker_rpc.js"; export class VolumeChunk extends SliceViewChunk { @@ -160,7 +162,7 @@ export class VolumeChunkSource throw new Error("VolumeChunkSource.writeChunk not implemented for this datasource"); } - async applyEdits(chunkKey: string, indices: ArrayLike, values: ArrayLike): Promise { + async applyEdits(chunkKey: string, indices: ArrayLike, values: ArrayLike): Promise { if (indices.length !== values.length) { throw new Error("applyEdits: indices and values length mismatch"); } @@ -199,13 +201,22 @@ export class VolumeChunkSource // The new TypedArray is already zero-filled. } const data = chunk.data as TypedArray; + + const ArrayCtor = DATA_TYPE_ARRAY_CONSTRUCTOR[this.spec.dataType] as any; + const indicesCopy = new Uint32Array(indices); + const newValuesArray = new ArrayCtor(values.length); + for (let i = 0; i < values.length; ++i) { + newValuesArray[i] = this.spec.dataType === DataType.UINT32 ? Number(values[i]!) : values[i]!; + } + const oldValuesArray = new ArrayCtor(indices.length); + for (let i = 0; i < indices.length; ++i) { const idx = indices[i]!; - const val = values[i]!; if (idx < 0 || idx >= data.length) { throw new Error(`applyEdits: index ${idx} out of bounds for chunk ${chunkKey}`); } - data[idx] = this.spec.dataType === DataType.UINT32 ? Number(val) : val; + oldValuesArray[i] = data[idx]; + data[idx] = newValuesArray[i]; } const maxRetries = 3; let lastError: Error | undefined; @@ -213,7 +224,11 @@ export class VolumeChunkSource for (let i = 0; i < maxRetries; i++) { try { await this.writeChunk(chunk); - return; + return { + indices: indicesCopy, + oldValues: oldValuesArray, + newValues: newValuesArray, + }; } catch (e) { lastError = e as Error; if (e instanceof HttpError && e.status < 500 && e.status !== 429) { diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 6973629100..5cb2b30645 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -120,10 +120,7 @@ export const ADOPT_VOXEL_LABEL_TOOL_ID = "adoptVoxelLabel"; } const centerCanonical = new Float32Array([start[0], start[1], start[2]]); - const editLodIndex = layer.voxEditController?.getEditLodIndexToDraw(brushRadius); - if (!Number.isInteger(editLodIndex) || editLodIndex == undefined || editLodIndex < 0) { - throw new Error("startDrawing: computed edit LOD index is invalid"); - } + const editLodIndex = 0; // locked to 0 rn layer.beginRenderLodLock(editLodIndex); const value = layer.voxLabelsManager.getCurrentLabelValue(layer.voxEraseMode); diff --git a/src/voxel_annotation/base.ts b/src/voxel_annotation/base.ts index 1593e3e922..1007f94b9e 100644 --- a/src/voxel_annotation/base.ts +++ b/src/voxel_annotation/base.ts @@ -2,7 +2,9 @@ export const VOX_RELOAD_CHUNKS_RPC_ID = "vox.chunk.reload"; export const VOX_EDIT_BACKEND_RPC_ID = "vox.EditBackend"; export const VOX_EDIT_COMMIT_VOXELS_RPC_ID = "vox.edit.commitVoxels"; export const VOX_EDIT_FAILURE_RPC_ID = "vox.edit.failure"; - +export const VOX_EDIT_UNDO_RPC_ID = "vox.edit.undo"; +export const VOX_EDIT_REDO_RPC_ID = "vox.edit.redo"; +export const VOX_EDIT_HISTORY_UPDATE_RPC_ID = "vox.edit.historyUpdate"; export interface VoxelLayerResolution { lodIndex: number; @@ -11,6 +13,20 @@ export interface VoxelLayerResolution { sourceRpc: number; } +export type VoxelChangeValues = Uint32Array | BigUint64Array; + +export interface VoxelChange { + indices: Uint32Array; + oldValues: VoxelChangeValues; + newValues: VoxelChangeValues; +} + +export interface EditAction { + changes: Map; + timestamp: number; + description: string; +} + export function makeVoxChunkKey(chunkKey: string, lodIndex: number) { return `lod${lodIndex}#${chunkKey}`; } diff --git a/src/voxel_annotation/edit_backend.ts b/src/voxel_annotation/edit_backend.ts index ffcbd5fe3f..69f24e3960 100644 --- a/src/voxel_annotation/edit_backend.ts +++ b/src/voxel_annotation/edit_backend.ts @@ -2,18 +2,24 @@ import type { VolumeChunkSource } from "#src/sliceview/volume/backend.js"; import { mat4, vec3 } from "#src/util/geom.js"; import * as matrix from "#src/util/matrix.js"; import type { - VoxelLayerResolution} from "#src/voxel_annotation/base.js"; + VoxelLayerResolution, + EditAction, + VoxelChange, +} from "#src/voxel_annotation/base.js"; import { VOX_EDIT_BACKEND_RPC_ID, VOX_EDIT_COMMIT_VOXELS_RPC_ID, VOX_RELOAD_CHUNKS_RPC_ID, VOX_EDIT_FAILURE_RPC_ID, + VOX_EDIT_UNDO_RPC_ID, + VOX_EDIT_REDO_RPC_ID, + VOX_EDIT_HISTORY_UPDATE_RPC_ID, makeVoxChunkKey, parseVoxChunkKey, makeChunkKey, } from "#src/voxel_annotation/base.js"; import type { RPC } from "#src/worker_rpc.js"; -import { SharedObject , registerRPC, registerSharedObject, initializeSharedObjectCounterpart } from "#src/worker_rpc.js"; +import { registerPromiseRPC , SharedObject , registerRPC, registerSharedObject, initializeSharedObjectCounterpart } from "#src/worker_rpc.js"; @registerSharedObject(VOX_EDIT_BACKEND_RPC_ID) export class VoxelEditController extends SharedObject { @@ -30,6 +36,11 @@ export class VoxelEditController extends SharedObject { private commitDebounceTimer: number | undefined; private readonly commitDebounceDelayMs: number = 300; + // Undo/redo history + private undoStack: EditAction[] = []; + private redoStack: EditAction[] = []; + private readonly MAX_HISTORY_SIZE: number = 100; + private downsampleQueue: string[] = []; private downsampleQueueSet: Set = new Set(); private isProcessingDownsampleQueue: boolean = false; @@ -63,38 +74,43 @@ export class VoxelEditController extends SharedObject { } this.sources.set(res.lodIndex, resolved); } + + this.notifyHistoryChanged(); } private async flushPending(): Promise { const edits = this.pendingEdits; this.pendingEdits = []; this.commitDebounceTimer = undefined; - if (edits.length === 0) return; + if (edits.length === 0) { + // Even if nothing to flush, history sizes may not have changed. + this.notifyHistoryChanged(); + return; + } + + const editsByVoxKey = new Map>(); - const editsByVoxKey = new Map< - string, - { indices: number[]; values: bigint[] } - >(); for (const edit of edits) { - if (!editsByVoxKey.has(edit.key)) { - editsByVoxKey.set(edit.key, { indices: [], values: [] }); + let chunkMap = editsByVoxKey.get(edit.key); + if (!chunkMap) { + chunkMap = new Map(); + editsByVoxKey.set(edit.key, chunkMap); } - const entry = editsByVoxKey.get(edit.key)!; + + const inds = edit.indices as ArrayLike; if (edit.values) { + // Handle array of values const vals = Array.from(edit.values); - if (vals.length !== edit.indices.length) { + if (vals.length !== inds.length) { throw new Error("flushPending: values length mismatch with indices"); } - for (let i = 0; i < edit.indices.length; ++i) { - entry.indices.push(Number(edit.indices[i]!)); - entry.values.push(vals[i]!); + for (let i = 0; i < inds.length; ++i) { + chunkMap.set(inds[i]!, vals[i]!); } } else if (edit.value !== undefined) { - const inds = edit.indices as ArrayLike; + // Handle single value for all indices for (let i = 0; i < inds.length; ++i) { - const index = inds[i]!; - entry.indices.push(Number(index)); - entry.values.push(edit.value); + chunkMap.set(inds[i]!, edit.value); } } else { throw new Error("flushPending: edit missing value(s)"); @@ -103,6 +119,13 @@ export class VoxelEditController extends SharedObject { const failedVoxChunkKeys: string[] = []; let firstErrorMessage: string | undefined = undefined; + + const newAction: EditAction = { + changes: new Map(), + timestamp: Date.now(), + description: "Voxel Edit", + }; + for (const [voxKey, chunkEdits] of editsByVoxKey.entries()) { try { const parsedKey = parseVoxChunkKey(voxKey); @@ -121,11 +144,16 @@ export class VoxelEditController extends SharedObject { if (firstErrorMessage === undefined) firstErrorMessage = msg; continue; } - await (source as any).applyEdits( + + const indices = Array.from(chunkEdits.keys()); + const values = Array.from(chunkEdits.values()); + + const change = await source.applyEdits( parsedKey.chunkKey, - chunkEdits.indices, - chunkEdits.values, + indices, + values, ); + newAction.changes.set(voxKey, change); } catch (e) { const msg = e instanceof Error ? e.message : String(e); console.error(`Failed to write chunk ${voxKey}:`, e); @@ -134,6 +162,17 @@ export class VoxelEditController extends SharedObject { } } + if (newAction.changes.size > 0) { + this.undoStack.push(newAction); + if (this.undoStack.length > this.MAX_HISTORY_SIZE) { + this.undoStack.shift(); + } + this.redoStack.length = 0; + } + + // Notify frontend of history changes after any commit attempt + this.notifyHistoryChanged(); + if (failedVoxChunkKeys.length > 0) { this.rpc?.invoke(VOX_EDIT_FAILURE_RPC_ID, { rpcId: this.rpcId, @@ -478,9 +517,94 @@ export class VoxelEditController extends SharedObject { } return mode; } + private notifyHistoryChanged(): void { + this.rpc?.invoke(VOX_EDIT_HISTORY_UPDATE_RPC_ID, { + rpcId: this.rpcId, + undoCount: this.undoStack.length, + redoCount: this.redoStack.length, + }); + } + + private async performUndoRedo( + sourceStack: EditAction[], + targetStack: EditAction[], + useOldValues: boolean, + actionDescription: 'undo' | 'redo' + ): Promise { + await this.flushPending(); + + if (sourceStack.length === 0) { + throw new Error(`Nothing to ${actionDescription}.`); + } + + const action = sourceStack.pop()!; + + const chunksToReload = new Set(); + let success = true; + + for (const [voxKey, change] of action.changes.entries()) { + const parsedKey = parseVoxChunkKey(voxKey); + if (!parsedKey) continue; + const source = this.sources.get(parsedKey.lodIndex); + if (!source) continue; + + const valuesToApply = useOldValues ? change.oldValues : change.newValues; + try { + await source.applyEdits(parsedKey.chunkKey, change.indices, valuesToApply); + chunksToReload.add(voxKey); + } catch (e) { + success = false; + console.error(`performUndoRedo: failed to apply edits for ${voxKey}`, e); + this.rpc?.invoke(VOX_EDIT_FAILURE_RPC_ID, { + rpcId: this.rpcId, + voxChunkKeys: [voxKey], + message: useOldValues ? "Undo failed." : "Redo failed.", + }); + // Stop processing this action on the first failure + break; + } + } + + if (success) { + // Only move the action to the target stack if all operations succeeded. + targetStack.push(action); + } else { + // On failure, return the action to its original stack to maintain consistency. + sourceStack.push(action); + } + + if (chunksToReload.size > 0 && success) { + for (const key of chunksToReload) { + this.enqueueDownsample(key); + } + this.callChunkReload(Array.from(chunksToReload)); + } + + this.notifyHistoryChanged(); + } + + public async undo(): Promise { + await this.performUndoRedo(this.undoStack, this.redoStack, true, 'undo'); + } + + public async redo(): Promise { + await this.performUndoRedo(this.redoStack, this.undoStack, false, 'redo'); + } } registerRPC(VOX_EDIT_COMMIT_VOXELS_RPC_ID, function (x: any) { const obj = this.get(x.rpcId) as VoxelEditController; void obj.commitVoxels(Array.isArray(x.edits) ? x.edits : []); }); + +registerPromiseRPC(VOX_EDIT_UNDO_RPC_ID, async function (this: RPC, x: any) { + const obj = this.get(x.rpcId) as VoxelEditController; + await obj.undo(); + return { value: undefined }; +}); + +registerPromiseRPC(VOX_EDIT_REDO_RPC_ID, async function (this: RPC, x: any) { + const obj = this.get(x.rpcId) as VoxelEditController; + await obj.redo(); + return { value: undefined }; +}); diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 04afd00bb5..64890f6822 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -6,6 +6,8 @@ import type { VoxUserLayer } from "#src/layer/vox/index.js"; import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transform.js"; import type { VolumeChunkSource , MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; +import { StatusMessage } from "#src/status.js"; +import { WatchableValue } from "#src/trackable_value.js"; import type { VoxelLayerResolution} from "#src/voxel_annotation/base.js"; import { @@ -13,6 +15,9 @@ import { VOX_EDIT_COMMIT_VOXELS_RPC_ID, VOX_RELOAD_CHUNKS_RPC_ID, VOX_EDIT_FAILURE_RPC_ID, + VOX_EDIT_UNDO_RPC_ID, + VOX_EDIT_REDO_RPC_ID, + VOX_EDIT_HISTORY_UPDATE_RPC_ID, makeVoxChunkKey, parseVoxChunkKey } from "#src/voxel_annotation/base.js"; @@ -24,6 +29,9 @@ import { @registerSharedObjectOwner(VOX_EDIT_BACKEND_RPC_ID) export class VoxelEditController extends SharedObject { + public undoCount = new WatchableValue(0); + public redoCount = new WatchableValue(0); + constructor(private layer: VoxUserLayer, private multiscale: MultiscaleVolumeChunkSource) { super(); const rpc = (this.multiscale as any)?.chunkManager?.rpc; @@ -56,8 +64,7 @@ export class VoxelEditController extends SharedObject { this.initializeCounterpart(rpc, { resolutions }); } - private static readonly qualityFactor = 16.0; - private static readonly restrictToMinLOD = true; + private morphologicalConfig = { // At what `filledCount` thresholds the neighborhood size increases. growthThresholds: [ @@ -98,21 +105,6 @@ export class VoxelEditController extends SharedObject { } as const; } - // Required: compute desired voxel size (power-of-two) from brush radius. - getOptimalVoxelSize(brushRadius: number, minLOD = 1, maxLOD = 128) { - if (VoxelEditController.restrictToMinLOD) { - return minLOD; - } - if (!Number.isFinite(brushRadius) || brushRadius <= 0) { - return minLOD; - } - const targetSize = brushRadius / VoxelEditController.qualityFactor; - const exponent = Math.round(Math.log2(targetSize)); - let voxelSize = Math.pow(2, exponent); - voxelSize = Math.max(minLOD, Math.min(voxelSize, maxLOD)); - return voxelSize; - } - getSourceForLOD(lodIndex: number): VolumeChunkSource { const sourcesByScale = this.multiscale.getSources(this.getIdentitySliceViewSourceOptions()); // Assuming a single orientation, which is correct for this use case. @@ -127,24 +119,6 @@ export class VoxelEditController extends SharedObject { return source; } - /** Compute the edit LOD index (scale index) from a brush radius in canonical units. */ - getEditLodIndexToDraw(brushRadiusCanonical: number): number { - if (VoxelEditController.restrictToMinLOD) { - return 0; - } - if (!Number.isFinite(brushRadiusCanonical) || brushRadiusCanonical <= 0) { - throw new Error( - "getEditLodIndexForBrush: brushRadiusCanonical must be > 0", - ); - } - const voxelSize = this.getOptimalVoxelSize(brushRadiusCanonical); - const sourceIndex = Math.round(Math.log2(voxelSize)); - if (!Number.isInteger(sourceIndex) || sourceIndex < 0) { - throw new Error("getEditLodIndexForBrush: computed LOD is invalid"); - } - return sourceIndex; - } - // Paint a disk (slice-aligned via basis) or sphere in WORLD/ canonical units; we transform to LOD grid before sending. paintBrushWithShape( centerCanonical: Float32Array, @@ -163,8 +137,9 @@ export class VoxelEditController extends SharedObject { ); } - const voxelSize = this.getOptimalVoxelSize(radiusCanonical); - const sourceIndex = Math.floor(Math.log2(voxelSize)); + // For V1 we use the minimum LOD (index 0) + const voxelSize = 1; + const sourceIndex = 0; const source = this.getSourceForLOD(sourceIndex); // Convert center and radius to the level’s voxel grid. @@ -266,9 +241,9 @@ export class VoxelEditController extends SharedObject { throw new Error("VoxelEditController.floodFillPlane2D: maxVoxels must be > 0."); } - // For V1 we use the minimum LOD (index 0) to keep behavior predictable. - const voxelSize = this.getOptimalVoxelSize(1); // will return min when restrictToMinLOD=true - const sourceIndex = Math.floor(Math.log2(voxelSize)); + // For V1 we use the minimum LOD (index 0) + const voxelSize = 1; + const sourceIndex = 0; const source = this.getSourceForLOD(sourceIndex); // Convert canonical/world to level grid coordinates. @@ -299,6 +274,8 @@ export class VoxelEditController extends SharedObject { const queue: [number, number][] = []; let filledCount = 0; + console.log("startVoxelLod", startVoxelLod, "fillValue", fillValue, "maxVoxels", maxVoxels, "originalValue", originalValue, "zPlane", zPlane); + const isOriginalAt = async (px: number, py: number): Promise => { const value = await source.getEnsuredValueAt(new Float32Array([px, py, zPlane]), this.singleChannelAccess); return (typeof value !== "bigint" ? BigInt(value as number) : value) === originalValue; @@ -502,6 +479,24 @@ export class VoxelEditController extends SharedObject { this.layer.setDrawErrorMessage(message); } } + + public undo(): void { + if (!this.rpc) throw new Error("VoxelEditController.undo: RPC not initialized."); + console.log("VoxelEditController.undo"); + this.rpc.promiseInvoke(VOX_EDIT_UNDO_RPC_ID, { rpcId: this.rpcId }).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + StatusMessage.showTemporaryMessage(`Undo failed: ${message}`, 3000); + }); + } + + public redo(): void { + if (!this.rpc) throw new Error("VoxelEditController.redo: RPC not initialized."); + console.log("VoxelEditController.redo"); + this.rpc.promiseInvoke(VOX_EDIT_REDO_RPC_ID, { rpcId: this.rpcId }).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + StatusMessage.showTemporaryMessage(`Redo failed: ${message}`, 3000); + }); + } } registerRPC(VOX_RELOAD_CHUNKS_RPC_ID, function (x: any) { @@ -516,3 +511,11 @@ registerRPC(VOX_EDIT_FAILURE_RPC_ID, function (x: any) { const message: string = typeof x.message === 'string' ? x.message : 'Voxel edit failed.'; obj.handleCommitFailure(keys, message); }); + +registerRPC(VOX_EDIT_HISTORY_UPDATE_RPC_ID, function (x: any) { + const obj = this.get(x.rpcId) as VoxelEditController; + const undoCount = typeof x.undoCount === 'number' ? x.undoCount : 0; + const redoCount = typeof x.redoCount === 'number' ? x.redoCount : 0; + obj.undoCount.value = undoCount; + obj.redoCount.value = redoCount; +}); From c51788d79a302d0e58de3924a484488b7d603539 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 078/251] fix: add mask for label generation to ensure it respect the DataType --- src/voxel_annotation/labels.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/voxel_annotation/labels.ts b/src/voxel_annotation/labels.ts index d8fafb7ba4..04af4e3fdb 100644 --- a/src/voxel_annotation/labels.ts +++ b/src/voxel_annotation/labels.ts @@ -9,14 +9,17 @@ export class LabelsManager { private sessionPrefix: bigint; private nextLocalId: bigint = 1n; + private idMask: bigint; constructor(public dataType: DataType, private onLabelsChanged?: () => void) { switch (dataType){ case DataType.UINT32: this.sessionPrefix = BigInt(Date.now() << 20); + this.idMask = 0xFFFFFFFFn; break; case DataType.UINT64: this.sessionPrefix = BigInt(getRandomUint32()) << 32n; + this.idMask = 0xFFFFFFFFFFFFFFFFn; break; default: throw new Error(`LabelsManager: Unsupported data type: ${dataType}`); @@ -25,7 +28,7 @@ export class LabelsManager { } private generateNewGuid(): bigint { - const newId = this.sessionPrefix | this.nextLocalId; + const newId = this.sessionPrefix | this.nextLocalId & this.idMask; this.nextLocalId++; return newId; } From 6c5cbf1050376174a0d27047bf4f2b6ed83d0f53 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 079/251] feat: enhance disk brush tool with arbitrary plane support - Replace `paintPoint` with `paintPoints` to unify logic. - Implement support for custom plane normals in disk-shaped brushes. - Modify flood fill to handle arbitrary planes using provided normals. - Add basis calculation for accurate u/v plane alignment. - Update mouse state to include plane normal and refactor plane-based filling logic. - Remove outdated TODO entries and improve overall tool precision and flexibility. --- NOTES/TODOs.md | 15 ++++++-- src/layer/index.ts | 2 + src/sliceview/panel.ts | 6 +++ src/ui/voxel_annotations.ts | 49 ++++++++++--------------- src/voxel_annotation/edit_controller.ts | 38 ++++++++++++------- 5 files changed, 65 insertions(+), 45 deletions(-) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index 1cff9f406f..a8f67098e7 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -5,14 +5,14 @@ ### priority -- Fix the orientation of the disk in the brush tool - Add support for flood fill on different planes - fix the flood fill for compressed chunks - test uint64 support -- fix label multiplication and wrong color in the ui list - fix drawing tools (flood fill and picker not working when zoomed out) - design a dataset creation feature -- undo tool (keep a stack of pre-edit values lists upon commit) +- fix undo/redo buttons activation states +- fix flood fill morphological hole filling, it mysteriously stopped working + ### extra - rework the ui (tabs) @@ -24,6 +24,8 @@ - add shortcuts for tools (switching tools, toogle erase mode, select label from the pointed one in the slice view and adjusting brush size) and label creation - write a testsuite for the downsampler and ensure its proper working on exotic lod levels + + ### questionable -? adapt the brush size to the zoom level linearly @@ -39,6 +41,13 @@ + + + + + + + ## mail diff --git a/src/layer/index.ts b/src/layer/index.ts index f4ea769103..5d51f23296 100644 --- a/src/layer/index.ts +++ b/src/layer/index.ts @@ -85,6 +85,7 @@ import { LayerToolBinder, SelectedLegacyTool } from "#src/ui/tool.js"; import { gatherUpdate } from "#src/util/array.js"; import type { Borrowed, Owned } from "#src/util/disposable.js"; import { invokeDisposers, RefCounted } from "#src/util/disposable.js"; +import type { vec3 } from "#src/util/geom.js"; import { emptyToUndefined, parseArray, @@ -1137,6 +1138,7 @@ export class MouseSelectionState implements PickState { unsnappedPosition: Float32Array = kEmptyFloat32Vec; active = false; displayDimensions: DisplayDimensions | undefined = undefined; + planeNormal: vec3 | undefined = undefined; pickedRenderLayer: RenderLayer | null = null; pickedValue = 0n; pickedOffset = 0; diff --git a/src/sliceview/panel.ts b/src/sliceview/panel.ts index adb9c8cc41..751e83b7d5 100644 --- a/src/sliceview/panel.ts +++ b/src/sliceview/panel.ts @@ -279,6 +279,12 @@ export class SliceViewPanel extends RenderedDataPanel { ); } + handleMouseMove(clientX: number, clientY: number) { + super.handleMouseMove(clientX, clientY); + this.viewer.mouseState.planeNormal = + this.sliceView.projectionParameters.value.viewportNormalInCanonicalCoordinates; + } + translateByViewportPixels(deltaX: number, deltaY: number): void { const { pose } = this.viewer.navigationState; pose.updateDisplayPosition((pos) => { diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 5cb2b30645..038341f2e3 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -18,6 +18,7 @@ import type { MouseSelectionState } from "#src/layer/index.js"; import type { VoxUserLayer } from "#src/layer/vox/index.js"; import { StatusMessage } from "#src/status.js"; import { LegacyTool, registerLegacyTool } from "#src/ui/tool.js"; +import { vec3 } from "#src/util/geom.js"; export const BRUSH_TOOL_ID = "voxBrush"; export const FLOODFILL_TOOL_ID = "voxFloodFill"; @@ -99,7 +100,6 @@ export const ADOPT_VOXEL_LABEL_TOOL_ID = "adoptVoxelLabel"; return out; } - protected abstract paintPoint(point: Float32Array, value: bigint): void; protected abstract paintPoints(points: Float32Array[], value: bigint): void; protected startDrawing(mouseState: MouseSelectionState) { @@ -125,7 +125,7 @@ export const ADOPT_VOXEL_LABEL_TOOL_ID = "adoptVoxelLabel"; const value = layer.voxLabelsManager.getCurrentLabelValue(layer.voxEraseMode); - this.paintPoint(centerCanonical, value); + this.paintPoints([centerCanonical], value); this.lastPoint = start; // Initialize latest mouse state so RAF can process immediately this.latestMouseState = mouseState; @@ -195,26 +195,6 @@ export class VoxelBrushLegacyTool extends BaseVoxelLegacyTool { return BRUSH_TOOL_ID; } - protected paintPoint(point: Float32Array, value: bigint) { - const radius = Math.max( - 1, - Math.floor((this.layer as any).voxBrushRadius ?? 3), - ); - const shape = - (this.layer as any).voxBrushShape === "sphere" ? "sphere" : "disk"; - const basis = - shape === "disk" - ? (this.layer as any).getBrushPlaneBasis?.(this.currentMouseState) - : undefined; - (this.layer as any).voxEditController?.paintBrushWithShape( - point, - radius, - value, - shape, - basis, - ); - } - protected paintPoints(points: Float32Array[], value: bigint) { const radius = Math.max( 1, @@ -223,10 +203,18 @@ export class VoxelBrushLegacyTool extends BaseVoxelLegacyTool { const shape = (this.layer as any).voxBrushShape === "sphere" ? "sphere" : "disk"; const ctrl = (this.layer as any).voxEditController; - const basis = - shape === "disk" - ? (this.layer as any).getBrushPlaneBasis?.(this.currentMouseState) - : undefined; + let basis = undefined; + if (shape === 'disk' && this.currentMouseState?.planeNormal) { + const n = this.currentMouseState.planeNormal; + const u = vec3.create(); + const tempVec = Math.abs(vec3.dot(n, vec3.fromValues(1, 0, 0))) < 0.9 ? + vec3.fromValues(1, 0, 0) : vec3.fromValues(0, 1, 0); + vec3.cross(u, tempVec, n); + vec3.normalize(u, u); + const v = vec3.cross(vec3.create(), n, u); + vec3.normalize(v, v); + basis = { u, v }; + } for (const point of points) { ctrl?.paintBrushWithShape(point, radius, value, shape, basis); } @@ -252,10 +240,13 @@ export class VoxelFloodFillLegacyTool extends LegacyTool { } const pos = layer.getVoxelPositionFromMouse?.(mouseState) as Float32Array | undefined; - if (!pos || pos.length < 3) { - throw new Error("Flood fill: failed to get voxel position from mouse"); + const planeNormal = mouseState.planeNormal; + + if (!pos || pos.length < 3 || !planeNormal) { + throw new Error("Flood fill: failed to get voxel position or plane normal."); } + const value = layer.voxLabelsManager.getCurrentLabelValue(layer.voxEraseMode); const max = Number((layer as any).voxFloodMaxVoxels); if (!Number.isFinite(max) || max <= 0) { @@ -271,7 +262,7 @@ export class VoxelFloodFillLegacyTool extends LegacyTool { ]); console.info("[VoxFloodFill] starting flood fill", { seed: Array.from(seed), value: value, max: Math.floor(max) }); - ctrl.floodFillPlane2D(seed, value, Math.floor(max)).then(({ edits, filledCount }) => { + ctrl.floodFillPlane2D(seed, value, Math.floor(max), planeNormal).then(({ edits, filledCount }) => { console.info("[VoxFloodFill] BFS completed", { filledCount, editsByChunk: edits.length }); if (edits.length === 0) return; diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 64890f6822..450929b63c 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -8,6 +8,7 @@ import type { ChunkChannelAccessParameters } from "#src/render_coordinate_transf import type { VolumeChunkSource , MultiscaleVolumeChunkSource } from "#src/sliceview/volume/frontend.js"; import { StatusMessage } from "#src/status.js"; import { WatchableValue } from "#src/trackable_value.js"; +import { vec3 } from "#src/util/geom.js"; import type { VoxelLayerResolution} from "#src/voxel_annotation/base.js"; import { @@ -167,10 +168,26 @@ export class VoxelEditController extends SharedObject { } } } else { - for (let dy = -r; dy <= r; ++dy) { - for (let dx = -r; dx <= r; ++dx) { - if (dx * dx + dy * dy <= rr) { - voxelsToPaint.push(new Float32Array([cx + dx, cy + dy, cz])); + if (basis === undefined) { + // Fallback to old XY-plane behavior if no basis is provided + for (let dy = -r; dy <= r; ++dy) { + for (let dx = -r; dx <= r; ++dx) { + if (dx * dx + dy * dy <= rr) { + voxelsToPaint.push(new Float32Array([cx + dx, cy + dy, cz])); + } + } + } + } else { + // New logic for arbitrary plane + const { u, v } = basis; + for (let j = -r; j <= r; ++j) { + for (let i = -r; i <= r; ++i) { + if (i * i + j * j <= rr) { + const point = vec3.fromValues(cx, cy, cz); + vec3.scaleAndAdd(point, point, u as vec3, i); + vec3.scaleAndAdd(point, point, v as vec3, j); + voxelsToPaint.push(point as Float32Array); + } } } } @@ -233,6 +250,7 @@ export class VoxelEditController extends SharedObject { startPositionCanonical: Float32Array, fillValue: bigint, maxVoxels: number, + _planeNormal: vec3, ): Promise<{ edits: { key: string; indices: number[]; value: bigint }[]; filledCount: number; originalValue: bigint }> { if (!startPositionCanonical || startPositionCanonical.length < 3) { throw new Error("VoxelEditController.floodFillPlane2D: startPositionCanonical must be Float32Array[3]."); @@ -274,8 +292,6 @@ export class VoxelEditController extends SharedObject { const queue: [number, number][] = []; let filledCount = 0; - console.log("startVoxelLod", startVoxelLod, "fillValue", fillValue, "maxVoxels", maxVoxels, "originalValue", originalValue, "zPlane", zPlane); - const isOriginalAt = async (px: number, py: number): Promise => { const value = await source.getEnsuredValueAt(new Float32Array([px, py, zPlane]), this.singleChannelAccess); return (typeof value !== "bigint" ? BigInt(value as number) : value) === originalValue; @@ -298,32 +314,28 @@ export class VoxelEditController extends SharedObject { ny: number, requiredThickness: number ): boolean => { - if (requiredThickness <= 1) return true; // No thickness constraint + if (requiredThickness <= 1) return true; const dx = nx - x; const dy = ny - y; - // Only allow exactly one-axis moves (4-connectivity) if ((dx === 0) === (dy === 0)) return false; const halfThickness = Math.floor(requiredThickness / 2); if (dx !== 0) { - // Horizontal move: check vertical thickness at BOTH current and destination - // We need the channel to be thick enough along the entire path for (const checkX of [x, nx]) { for (let offset = -halfThickness; offset <= halfThickness; offset++) { if (!isOriginalAt(checkX, ny + offset)) { - return false; // Channel not thick enough + return false; } } } } else { - // Vertical move: check horizontal thickness at BOTH current and destination for (const checkY of [y, ny]) { for (let offset = -halfThickness; offset <= halfThickness; offset++) { if (!isOriginalAt(nx + offset, checkY)) { - return false; // Channel not thick enough + return false; } } } From 45fb2c937fa680e6ee7037925fb6b13ff839ebf5 Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 080/251] fix: ensure voxel coordinates align between CPU and GPU --- src/ui/voxel_annotations.ts | 19 ++++++++++++++++--- src/voxel_annotation/edit_controller.ts | 14 +++----------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/ui/voxel_annotations.ts b/src/ui/voxel_annotations.ts index 038341f2e3..6c8a4b72bf 100644 --- a/src/ui/voxel_annotations.ts +++ b/src/ui/voxel_annotations.ts @@ -69,10 +69,23 @@ export const ADOPT_VOXEL_LABEL_TOOL_ID = "adoptVoxelLabel"; | Float32Array | undefined; if (!mouseState?.active || !vox) return undefined; + const planeNormal = mouseState?.planeNormal; + + if (!mouseState?.active || !vox || !planeNormal) return undefined; + + // Replicate the exact logic from the vertex shader to ensure the CPU and GPU + // agree on the voxel coordinate. + + const CHUNK_POSITION_EPSILON = 1e-3; + const shiftedVox = new Float32Array(3); + for (let i = 0; i < 3; ++i) { + shiftedVox[i] = vox[i] + CHUNK_POSITION_EPSILON * Math.abs(planeNormal[i]); + } + return new Int32Array([ - Math.floor(vox[0]), - Math.floor(vox[1]), - Math.floor(vox[2]), + Math.floor(shiftedVox[0]), + Math.floor(shiftedVox[1]), + Math.floor(shiftedVox[2]), ]); } diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 450929b63c..42fae60cf5 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -169,16 +169,8 @@ export class VoxelEditController extends SharedObject { } } else { if (basis === undefined) { - // Fallback to old XY-plane behavior if no basis is provided - for (let dy = -r; dy <= r; ++dy) { - for (let dx = -r; dx <= r; ++dx) { - if (dx * dx + dy * dy <= rr) { - voxelsToPaint.push(new Float32Array([cx + dx, cy + dy, cz])); - } - } - } - } else { - // New logic for arbitrary plane + throw new Error("paintBrushWithShape: 'basis' must be defined for disk alignment."); + } const { u, v } = basis; for (let j = -r; j <= r; ++j) { for (let i = -r; i <= r; ++i) { @@ -189,7 +181,7 @@ export class VoxelEditController extends SharedObject { voxelsToPaint.push(point as Float32Array); } } - } + } } From bb4698a3dd36c82d722c944edf67bd13f3d600ce Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 081/251] fix: adjust default `voxFloodMaxVoxels` and refine flood fill plane logic - Reduce the default `voxFloodMaxVoxels` value to 10,000. - Refactor flood fill implementation to enhance 2D plane support. - Improve logic for neighborhood thickness and voxel mapping accuracy. - Clean up unused variables and streamline code for better readability. - Update TODOs to reflect resolved and deferred tasks. --- NOTES/TODOs.md | 16 +- src/layer/vox/tabs/tools.ts | 2 +- src/voxel_annotation/edit_controller.ts | 203 +++++++++++------------- 3 files changed, 101 insertions(+), 120 deletions(-) diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md index a8f67098e7..e81b0a361c 100644 --- a/NOTES/TODOs.md +++ b/NOTES/TODOs.md @@ -5,24 +5,23 @@ ### priority -- Add support for flood fill on different planes - fix the flood fill for compressed chunks - test uint64 support -- fix drawing tools (flood fill and picker not working when zoomed out) - design a dataset creation feature -- fix undo/redo buttons activation states -- fix flood fill morphological hole filling, it mysteriously stopped working -### extra + +### later - rework the ui (tabs) - optimize flood fill tool (it is too slow on area containing uncached chunks, due to the getEnsuredValueAt() calls) - rework the drawing preview for compressed chunk (see applyLocalEdits()) - rework the autocomplete for the ssa+https source. - the flood fill sometimes leaves artifacts in sharp areas - isolate the downsampling -- add shortcuts for tools (switching tools, toogle erase mode, select label from the pointed one in the slice view and adjusting brush size) and label creation +- add shortcuts for tools (switching tools, toggle erase mode, select label from the pointed one in the slice view and adjusting brush size) and label creation - write a testsuite for the downsampler and ensure its proper working on exotic lod levels +- fix undo/redo buttons activation states + @@ -43,6 +42,11 @@ + + + + + diff --git a/src/layer/vox/tabs/tools.ts b/src/layer/vox/tabs/tools.ts index 0bb7a13da1..7e248fabdd 100644 --- a/src/layer/vox/tabs/tools.ts +++ b/src/layer/vox/tabs/tools.ts @@ -310,7 +310,7 @@ export class VoxToolTab extends Tab { // Initialize with an explicit safe default if not set. if (!Number.isFinite((this.layer as any).voxFloodMaxVoxels)) { - (this.layer as any).voxFloodMaxVoxels = 100000; + (this.layer as any).voxFloodMaxVoxels = 10000; } floodMaxInput.value = String((this.layer as any).voxFloodMaxVoxels); diff --git a/src/voxel_annotation/edit_controller.ts b/src/voxel_annotation/edit_controller.ts index 42fae60cf5..6ef18939ef 100644 --- a/src/voxel_annotation/edit_controller.ts +++ b/src/voxel_annotation/edit_controller.ts @@ -67,11 +67,11 @@ export class VoxelEditController extends SharedObject { } private morphologicalConfig = { - // At what `filledCount` thresholds the neighborhood size increases. growthThresholds: [ - { count: 1000, size: 3 }, // Requires 3px thick channels - { count: 10000, size: 5 }, // Requires 5px thick channels - { count: 100000, size: 7 }, // Requires 7px thick channels + { count: 100, size: 1 }, + { count: 1000, size: 3 }, + { count: 10000, size: 5 }, + { count: 100000, size: 7 }, ], maxSize: 9, }; @@ -242,51 +242,50 @@ export class VoxelEditController extends SharedObject { startPositionCanonical: Float32Array, fillValue: bigint, maxVoxels: number, - _planeNormal: vec3, + planeNormal: vec3, // MUST be a normalized vector ): Promise<{ edits: { key: string; indices: number[]; value: bigint }[]; filledCount: number; originalValue: bigint }> { - if (!startPositionCanonical || startPositionCanonical.length < 3) { - throw new Error("VoxelEditController.floodFillPlane2D: startPositionCanonical must be Float32Array[3]."); - } - if (!Number.isFinite(maxVoxels) || maxVoxels <= 0) { - throw new Error("VoxelEditController.floodFillPlane2D: maxVoxels must be > 0."); - } + const sourceIndex = 0; + const source = this.getSourceForLOD(sourceIndex); + const startVoxelLod = vec3.round(vec3.create(), startPositionCanonical as vec3); - // For V1 we use the minimum LOD (index 0) - const voxelSize = 1; - const sourceIndex = 0; - const source = this.getSourceForLOD(sourceIndex); - - // Convert canonical/world to level grid coordinates. - const startVoxelLod = new Float32Array([ - Math.round((startPositionCanonical[0] ?? NaN) / voxelSize), - Math.round((startPositionCanonical[1] ?? NaN) / voxelSize), - Math.round((startPositionCanonical[2] ?? NaN) / voxelSize), - ]); - - if (!startVoxelLod || startVoxelLod.length < 3) { - throw new Error("VoxChunkSource.floodFillPlane2D: startVoxelLod must be Float32Array[3]."); - } - if (!Number.isFinite(maxVoxels) || maxVoxels <= 0) { - throw new Error("VoxChunkSource.floodFillPlane2D: maxVoxels must be > 0."); - } - - const originalValueResult = await source.getEnsuredValueAt(startVoxelLod, this.singleChannelAccess); - if (originalValueResult === null) { + const originalValueResult = await source.getEnsuredValueAt(startVoxelLod as Float32Array, this.singleChannelAccess); + if (originalValueResult === null) { throw new Error("Flood fill seed is in an unloaded or out-of-bounds chunk."); } const originalValue = typeof originalValueResult !== "bigint" ? BigInt(originalValueResult as number) : originalValueResult; + if (originalValue === fillValue) { return { edits: [], filledCount: 0, originalValue }; } - const zPlane = startVoxelLod[2] | 0; + const U = vec3.create(); + const V = vec3.create(); + const tempVec = Math.abs(vec3.dot(planeNormal, vec3.fromValues(1, 0, 0))) < 0.9 ? + vec3.fromValues(1, 0, 0) : vec3.fromValues(0, 1, 0); + vec3.cross(U, tempVec, planeNormal); + vec3.normalize(U, U); + vec3.cross(V, planeNormal, U); + vec3.normalize(V, V); + const visited = new Set(); const queue: [number, number][] = []; let filledCount = 0; + const voxelsToFill: Float32Array[] = []; + + + const map2dTo3d = (u: number, v: number): vec3 => { + const point = vec3.clone(startVoxelLod); + vec3.scaleAndAdd(point, point, U, u); + vec3.scaleAndAdd(point, point, V, v); + return vec3.round(vec3.create(), point); + }; - const isOriginalAt = async (px: number, py: number): Promise => { - const value = await source.getEnsuredValueAt(new Float32Array([px, py, zPlane]), this.singleChannelAccess); - return (typeof value !== "bigint" ? BigInt(value as number) : value) === originalValue; + const isFillable = async (p: vec3): Promise => { + const value = await source.getEnsuredValueAt(p as Float32Array, this.singleChannelAccess); + if (value === null) return false; + const bigValue = (typeof value !== "bigint") ? BigInt(value as number) : value; + if (originalValue === 0n) return bigValue === 0n; + return bigValue === originalValue; }; const getCurrentThickness = (): number => { @@ -299,112 +298,96 @@ export class VoxelEditController extends SharedObject { return Math.min(thickness, this.morphologicalConfig.maxSize); }; - const hasThickEnoughChannel = ( - x: number, - y: number, - nx: number, - ny: number, - requiredThickness: number - ): boolean => { + const hasThickEnoughChannel = async (u: number, v: number, nu: number, nv: number, requiredThickness: number): Promise => { if (requiredThickness <= 1) return true; - const dx = nx - x; - const dy = ny - y; + const halfThickness = Math.floor(requiredThickness / 2); + const du = nu - u; + const dv = nv - v; - if ((dx === 0) === (dy === 0)) return false; + // Perpendicular direction + const perpU = -dv; + const perpV = du; - const halfThickness = Math.floor(requiredThickness / 2); + // Check if the NEIGHBOR position has sufficient thickness on both sides + for (let offset = -halfThickness; offset <= halfThickness; ++offset) { + const testU = nu + perpU * offset; + const testV = nv + perpV * offset; + const pointToTest = map2dTo3d(testU, testV); - if (dx !== 0) { - for (const checkX of [x, nx]) { - for (let offset = -halfThickness; offset <= halfThickness; offset++) { - if (!isOriginalAt(checkX, ny + offset)) { - return false; - } - } - } - } else { - for (const checkY of [y, ny]) { - for (let offset = -halfThickness; offset <= halfThickness; offset++) { - if (!isOriginalAt(nx + offset, checkY)) { - return false; - } - } + if (!await isFillable(pointToTest)) { + return false; } } return true; }; - const fillBorderRegion = async ( - startX: number, - startY: number, - requiredThickness: number - ) => { + const fillBorderRegion = async (startU: number, startV: number, requiredThickness: number) => { const subQueue: [number, number][] = []; - const halfThickness = Math.floor(requiredThickness / 2) + 1; - - const k = `${startX},${startY}`; - if (visited.has(k)) return; + // The bounding box for the local fill is defined in the (u, v) coordinate system + const halfSize = Math.floor(requiredThickness / 2) + 1; + const startKey = `${startU},${startV}`; + if (visited.has(startKey)) return; - subQueue.push([startX, startY]); - visited.add(k); // Mark as visited immediately to avoid re-processing + subQueue.push([startU, startV]); + visited.add(startKey); while (subQueue.length > 0) { - const [cx, cy] = subQueue.shift()!; - filledCount++; - voxelsToFill.push(new Float32Array([cx, cy, zPlane])); + if (filledCount >= maxVoxels) return; + const [u, v] = subQueue.shift()!; - const neighbors: [number, number][] = [[cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]]; - for (const [nnx, nny] of neighbors) { - if (nnx < startX - halfThickness || nnx > startX + halfThickness || - nny < startY - halfThickness || nny > startY + halfThickness) { - continue; // Outside the bounding box + const currentPoint = map2dTo3d(u, v); + filledCount++; + voxelsToFill.push(currentPoint as Float32Array); + + const neighbors2d: [number, number][] = [[u + 1, v], [u - 1, v], [u, v + 1], [u, v - 1]]; + for (const [nu, nv] of neighbors2d) { + // Constrain this local search to a small bounding box + if (nu < startU - halfSize || nu > startU + halfSize || + nv < startV - halfSize || nv > startV + halfSize) { + continue; } - const nk = `${nnx},${nny}`; - if (visited.has(nk)) continue; - if (await isOriginalAt(nnx, nny)) { - visited.add(nk); - subQueue.push([nnx, nny]); + const neighborKey = `${nu},${nv}`; + if (visited.has(neighborKey)) continue; + + const neighborPoint = map2dTo3d(nu, nv); + if (await isFillable(neighborPoint)) { + visited.add(neighborKey); + subQueue.push([nu, nv]); } } } }; - // Seed the queue - queue.push([startVoxelLod[0] | 0, startVoxelLod[1] | 0]); - visited.add(`${startVoxelLod[0] | 0},${startVoxelLod[1] | 0}`); - const voxelsToFill: Float32Array[] = []; + queue.push([0, 0]); + visited.add("0,0"); - // BFS with thickness constraints while (queue.length > 0) { if (filledCount >= maxVoxels) { - throw new Error(`VoxChunkSource.floodFillPlane2D: region exceeds maxVoxels (${maxVoxels}).`); + throw new Error(`Flood fill region exceeds the limit of ${maxVoxels} voxels.`); } - const [x, y] = queue.shift()!; + const [u, v] = queue.shift()!; - // Schedule this pixel for filling + const currentPoint = map2dTo3d(u, v); filledCount++; - voxelsToFill.push(new Float32Array([x, y, zPlane])); + voxelsToFill.push(currentPoint as Float32Array); - // Get current thickness requirement const requiredThickness = getCurrentThickness(); + const neighbors2d: [number, number][] = [[u + 1, v], [u - 1, v], [u, v + 1], [u, v - 1]]; - // Check 4-neighbors - const neighbors: [number, number][] = [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]; - for (const [nx, ny] of neighbors) { - const k = `${nx},${ny}`; + for (const [nu, nv] of neighbors2d) { + const k = `${nu},${nv}`; if (visited.has(k)) continue; - if (await isOriginalAt(nx, ny)) { - // The neighbor is a valid fill target. Now check if we can propagate from it. - if (hasThickEnoughChannel(x, y, nx, ny, requiredThickness)) { - // Channel is thick enough: Add to queue to propagate. + const neighborPoint = map2dTo3d(nu, nv); + if (await isFillable(neighborPoint)) { + if (await hasThickEnoughChannel(u, v, nu, nv, requiredThickness)) { visited.add(k); - queue.push([nx, ny]); + queue.push([nu, nv]); } else { - await fillBorderRegion(nx, ny, requiredThickness); + await fillBorderRegion(nu, nv, requiredThickness); } } } @@ -415,7 +398,6 @@ export class VoxelEditController extends SharedObject { const { chunkGridPosition, positionWithinChunk } = source.computeChunkIndices(voxelCoord); const chunkKey = chunkGridPosition.join(); const voxKey = makeVoxChunkKey(chunkKey, sourceIndex); - let entry = editsByVoxKey.get(voxKey); if (!entry) { entry = { indices: [], value: fillValue }; @@ -425,8 +407,6 @@ export class VoxelEditController extends SharedObject { const index = (positionWithinChunk[2] * chunkDataSize[1] + positionWithinChunk[1]) * chunkDataSize[0] + positionWithinChunk[0]; entry.indices.push(index); } - - // Apply edits locally for preview on this source. const localEdits = new Map(); for (const [voxKey, edit] of editsByVoxKey.entries()) { const parsed = parseVoxChunkKey(voxKey); @@ -434,16 +414,13 @@ export class VoxelEditController extends SharedObject { localEdits.set(parsed.chunkKey, edit); } source.applyLocalEdits(localEdits); - - // Prepare edits for the backend keyed by voxKey. const backendEdits: { key: string; indices: number[]; value: bigint }[] = []; for (const [voxKey, edit] of editsByVoxKey.entries()) { backendEdits.push({ key: voxKey, indices: edit.indices, value: edit.value }); } - this.commitEdits(backendEdits); - return { edits: backendEdits, filledCount, originalValue: originalValue }; + return { edits: backendEdits, filledCount, originalValue }; } callChunkReload(voxChunkKeys: string[]) { From d2afe3fe8c002de4dda86670a4e3e235945b29be Mon Sep 17 00:00:00 2001 From: "brieuc.crosson" Date: Mon, 10 Nov 2025 11:06:44 +0100 Subject: [PATCH 082/251] chore: cleanup dev files and format + lint code --- .junie/guidelines.md | 9 - NOTES/RPC.md | 208 -------- NOTES/TODOs.md | 75 --- NOTES/annotation-chunk-source-and-sync.md | 153 ------ NOTES/backend.md | 148 ------ .../MultiscaleVolumeChunkSource.md | 174 ------- NOTES/classExplanations/chunk-source.md | 121 ----- NOTES/custom-gc.md | 45 -- NOTES/data-saving-plan.md | 475 ------------------ NOTES/first_drawing.png | Bin 225375 -> 0 bytes NOTES/image-vs-segmentation-volumeType.md | 93 ---- NOTES/multi-source-plan.md | 207 -------- NOTES/vox-annotation-project-overview.md | 268 ---------- NOTES/vox-layer-v2.md | 47 -- NOTES/voxel-annotation-specification.md | 85 ---- NOTES/weekly-progress.md | 100 ---- src/chunk_manager/backend.ts | 10 +- src/chunk_manager/base.ts | 3 +- src/chunk_manager/frontend.ts | 9 +- src/chunk_worker.bundle.js | 2 +- src/datasource/local.ts | 2 +- src/datasource/zarr/backend.ts | 12 +- src/kvstore/index.ts | 5 +- src/kvstore/opfs/backend.ts | 130 +++-- src/kvstore/opfs/common.ts | 22 +- src/kvstore/opfs/frontend.ts | 23 +- src/kvstore/s3/common.ts | 17 +- src/kvstore/ssa_s3/README.md | 2 + src/kvstore/ssa_s3/credentials_provider.ts | 133 +++-- src/kvstore/ssa_s3/register_backend.ts | 13 +- src/kvstore/ssa_s3/register_frontend.ts | 96 ++-- src/kvstore/ssa_s3/ssa_s3_kvstore.ts | 146 ++++-- src/kvstore/ssa_s3/url_utils.ts | 18 +- src/layer/vox/index.ts | 71 +-- src/layer/vox/style.css | 16 + src/layer/vox/tabs/tools.ts | 47 +- src/sliceview/base.ts | 6 +- src/sliceview/chunk_base.ts | 16 + src/sliceview/frontend.ts | 8 +- src/sliceview/single_texture_chunk_format.ts | 2 +- src/sliceview/volume/backend.ts | 46 +- src/sliceview/volume/chunk.ts | 21 +- src/sliceview/volume/frontend.ts | 101 +++- src/ui/voxel_annotations.ts | 142 ++++-- src/voxel_annotation/TODOs.md | 21 + src/voxel_annotation/base.ts | 32 +- src/voxel_annotation/edit_backend.ts | 191 +++++-- src/voxel_annotation/edit_controller.ts | 283 ++++++++--- src/voxel_annotation/labels.ts | 29 +- src/voxel_annotation/renderlayer.ts | 16 +- 50 files changed, 1240 insertions(+), 2659 deletions(-) delete mode 100644 .junie/guidelines.md delete mode 100644 NOTES/RPC.md delete mode 100644 NOTES/TODOs.md delete mode 100644 NOTES/annotation-chunk-source-and-sync.md delete mode 100644 NOTES/backend.md delete mode 100644 NOTES/classExplanations/MultiscaleVolumeChunkSource.md delete mode 100644 NOTES/classExplanations/chunk-source.md delete mode 100644 NOTES/custom-gc.md delete mode 100644 NOTES/data-saving-plan.md delete mode 100644 NOTES/first_drawing.png delete mode 100644 NOTES/image-vs-segmentation-volumeType.md delete mode 100644 NOTES/multi-source-plan.md delete mode 100644 NOTES/vox-annotation-project-overview.md delete mode 100644 NOTES/vox-layer-v2.md delete mode 100644 NOTES/voxel-annotation-specification.md delete mode 100644 NOTES/weekly-progress.md create mode 100644 src/kvstore/ssa_s3/README.md create mode 100644 src/voxel_annotation/TODOs.md diff --git a/.junie/guidelines.md b/.junie/guidelines.md deleted file mode 100644 index ca41ca628c..0000000000 --- a/.junie/guidelines.md +++ /dev/null @@ -1,9 +0,0 @@ -You are a coding expert in typescript, webgl, and neuroglancer. - -You must follow the following code guidelines: -- Use detailed variable and function names, a good code should explain itself without comments. -- Avoid fallbacks and default values, always prefer throwing errors on unexpected behavior. -- Avoid casting with `as` unless absolutely necessary, prefer proper type definitions and checks. -- Never use inline imports (e.g., `const ... = await import('...')`) - -You are here to help me implement a new voxel annotation feature into neuroglancer. See [vox-annotation-project-overview.md](../NOTES/vox-annotation-project-overview.md) for complete project details. diff --git a/NOTES/RPC.md b/NOTES/RPC.md deleted file mode 100644 index 6ec5da8c3b..0000000000 --- a/NOTES/RPC.md +++ /dev/null @@ -1,208 +0,0 @@ -### What “SharedObject”, the decorators, and the RPC are (in plain words) - -Here’s the mental model Neuroglancer uses to let the main thread (frontend) and the worker (backend) coordinate: - -- RPC is the postal system. You “register” named message handlers on both sides and you “invoke” those names with payloads. Some RPC calls return values via a promise protocol with cancellation and progress. -- SharedObject is a cross-thread object handle with reference counting. A class instance owned on one side has a corresponding lightweight counterpart on the other side. They stay in sync by sending RPC messages. When both sides drop references, the pair auto-disposes correctly. -- Decorators are just a convenient way to register classes with the RPC factory so the other side can construct the right counterpart class by name. - -Once you keep those three ideas in mind, the rest of the patterns (like sharing a watchable value or a visibility priority) are applications of the same basic mechanism. - ---- - -### RPC in this codebase - -Core file: src/worker_rpc.ts - -- Message router - - - registerRPC(name, handler) records a function capable of handling messages named “name”. - - rpc.invoke(name, payload, transfers?) serializes your payload and posts it to the other side. The other side looks up handler by name and calls it. - -- Promise RPC (request/response) - - registerPromiseRPC(name, handlerWithProgress) wraps a handler so responses go back via a standard reply channel and the caller gets a Promise. - - rpc.promiseInvoke(name, payload, { signal, progressListener, transfers }) sends the request and returns a Promise. If you pass an AbortSignal, the other side will receive a cancellation via the standard PROMISE_CANCEL_ID. If you pass a progressListener, the other side can emit progress spans back. - -Key snippets (file: src/worker_rpc.ts): - -- Registry and invoke: handlers map, registerRPC, RPC.invoke (lines ~42–46, 225–236). -- Promise protocol: registerPromiseRPC and rpc.promiseInvoke with cancel and progress (lines ~74–108, 238–269, 110–145, 130–140). -- Ready/queue: when the peer worker isn’t ready yet, outgoing messages get queued until onPeerReady flushes them (lines ~158–189). - -Why this matters: It turns postMessage into a tiny RPC framework with named calls, requests that can be cancelled, and streamable progress events. - ---- - -### SharedObject: a cross-thread, ref-counted object pair - -Core file: src/worker_rpc.ts - -- A SharedObject is a RefCounted instance that exists on both sides (owner and counterpart). The owner creates the counterpart using a factory call; the counterpart is a lightweight representation used to send signals back to the owner. Both halves refer to each other via an RPC id. - -- Ownership and lifecycle - - - Owner side calls initializeCounterpart(rpc, options). That: - 1. sets up bookkeeping (rpc, rpcId), - 2. marks itself as owner, - 3. invokes SharedObject.new with type and options so the other side constructs the counterpart (lines ~290–298). - - Counterpart creation (other side): SharedObject.new handler looks up the registered constructor by the type string and new()’s it (lines ~439–446). Counterpart starts with refCount zero. - - Reference model: addCounterpartRef() returns { id, gen }, where gen is a monotonically increasing generation number that tracks references flowing to the other side (line ~307–309). When a counterpart’s refcount drops to zero, it notifies the owner via SharedObject.refCountReachedZero (lines ~398–402), passing back the generation that reached zero. - - Cleanup: - - If the owner’s own refCount hits zero and the most recent generation has been released by the counterpart (generations match), ownerDispose() runs and tells the counterpart to dispose (lines ~311–337). - - The SharedObject.dispose RPC validates refCount is zero, deletes the mapping, and nulls fields (lines ~382–396). - -- Key fields - - rpc, rpcId: the communication endpoint and the numeric id that identifies this object across the channel. - - isOwner: true on the side that initiated the counterpart creation; false on the counterpart; undefined before init. - - referencedGeneration, unreferencedGeneration: let the owner track which counterpart reference generation has hit zero. This avoids races if multiple references are sent over time. - -Plain-English analogy: imagine the frontend owns a remote handle in the worker. You can pass out references to that handle. When the worker is done with a reference, it says “generation 5 released.” Only when both the frontend has no refs and the last released generation equals the last handed-out generation is it safe to actually tear down the pair. - ---- - -### The decorators: registering types for cross-thread construction - -Also in src/worker_rpc.ts - -- @registerSharedObjectOwner(identifier) - - - Sets RPC_TYPE_ID on the class prototype to the given string (lines ~411–415). This is used when the owner class will initiate a counterpart. On initializeCounterpart, that RPC_TYPE_ID is sent so the other side knows which constructor to call. - -- @registerSharedObject(identifier?) - - - Registers a class constructor in a global map keyed by identifier (lines ~425–437). This is meant for counterpart classes (the classes to construct when a “SharedObject.new” message arrives). If you omit the identifier, the class’s prototype must already have RPC_TYPE_ID. - -- How they combine: - - Owner side: a class decorated with @registerSharedObjectOwner("My.Type") will send type: "My.Type" when it calls initializeCounterpart(), which triggers a SharedObject.new RPC. - - Counterpart side: a class decorated with @registerSharedObject("My.Type") is discoverable by the SharedObject.new handler, which constructs it with (rpc, options). - -In the code: - -- Owner example (frontend): - - src/annotation/renderlayer.ts: AnnotationLayerSharedObject is decorated with @registerSharedObjectOwner(ANNOTATION_RENDER_LAYER_RPC_ID) and calls this.initializeCounterpart(...) to spin up the backend counterpart with the same identifier (lines ~182–201). -- Counterpart example (backend): - - src/annotation/backend.ts has @registerSharedObject(ANNOTATION_RENDER_LAYER_RPC_ID) on the class that implements the backend side of that layer (see search results). When SharedObject.new arrives with that id, this class is constructed. - -This pattern appears broadly across the codebase for chunk sources, mesh layers, slice views, credentials, etc. See search results for @registerSharedObject and @registerSharedObjectOwner. - ---- - -### Example: sharing visibility across threads with a mixin - -Files: - -- src/visibility_priority/frontend.ts -- src/visibility_priority/backend.ts - -withSharedVisibility is a mixin that augments a SharedObject-based class with a “visibility” property that’s actually a shared, cross-thread WatchableValue. It demonstrates how to embed another shared object inside your own options during initializeCounterpart. - -- Frontend side mixin (owner): - - - Adds visibility = new VisibilityPriorityAggregator() (an aggregator of watchable priorities). - - In initializeCounterpart, it constructs a SharedWatchableValue from the existing WatchableValue and injects the rpcId into options.visibility before calling super.initializeCounterpart (frontend.ts lines ~96–105). This means the backend will receive an rpc id to a SharedWatchableValue. - -- Backend side mixin (counterpart): - - In constructor(rpc, options), it grabs the shared watchable from rpc.get(options.visibility), subscribes to changes, and reacts (e.g., reprioritize chunk requests) (backend.ts lines ~35–46). - -So a field in your class can itself be a shared object, referenced by id in the “options” payload used to construct the counterpart. - ---- - -### Example: SharedWatchableValue in detail (a simple shared data container) - -File: src/shared_watchable_value.ts - -- It’s a counterpart class that implements WatchableValueInterface and is decorated with @registerSharedObject("SharedWatchableValue"). -- You typically create one on the owner side via SharedWatchableValue.makeFromExisting(rpc, someWatchableValue). That sets up change listeners that forward updates across the RPC. -- On the counterpart side, the constructor builds a WatchableValue and wires up a handler for a CHANGED_RPC_METHOD_ID so that remote changes update the local WatchableValue (lines ~45–51, 58–74, 104–109). - -This is the building block used by withSharedVisibility and also elsewhere when a simple shared scalar or object needs to stay in sync. - ---- - -### Putting it all together: a typical flow - -Let’s walk through a concrete case from annotations (simplified): - -1. Frontend creates an owner object - -- class AnnotationLayerSharedObject extends withSharedVisibility(...) is decorated with @registerSharedObjectOwner(ANNOTATION_RENDER_LAYER_RPC_ID). -- Its constructor calls initializeCounterpart(this.chunkManager.rpc, { source: source.rpcId, segmentationStates: ..., visibility: SharedWatchableValue.makeFromExisting(...).rpcId }) - -2. RPC constructs the backend counterpart - -- The owner call triggers rpc.invoke("SharedObject.new", { id, type: ANNOTATION_RENDER_LAYER_RPC_ID, ...options }). -- On the backend, the SharedObject.new handler looks up the registered constructor for that id (registered by @registerSharedObject on the backend class) and constructs it with (rpc, options). -- The backend counterpart receives options.visibility as a reference id and does rpc.get(options.visibility) to obtain the SharedWatchableValue handle for ongoing updates. - -3. Runtime updates - -- If frontend changes visibility, SharedWatchableValue sends a CHANGED message; backend’s handler updates its copy and may reprioritize chunk requests. -- If backend needs to respond with progress or results, it uses RPC handlers or registerPromiseRPC to return data. - -4. Cleanup - -- Any references sent to the other side are stamped with a generation (addCounterpartRef()). When the counterpart’s refcount drops to zero, it notifies the owner (SharedObject.refCountReachedZero). When both sides are done for the current generation and the owner’s own refcount is zero, the owner sends SharedObject.dispose, and both sides free their mapping. - ---- - -### How to define your own shared class (step-by-step) - -- Decide which side “owns” it (the side that will call initializeCounterpart()). -- On the owner class: - - - Decorate: @registerSharedObjectOwner("my.unique.type") - - Derive from SharedObject or a mixin that includes it (e.g., withSharedVisibility(SharedObject)). - - In your constructor, call this.initializeCounterpart(rpc, { ...options }) and include any nested shared object ids (e.g., visibility: SharedWatchableValue.makeFromExisting(rpc, myWatchable).rpcId). - -- On the counterpart class (other thread): - - - Decorate: @registerSharedObject("my.unique.type") - - Derive from SharedObjectCounterpart or another mixin chain suitable for the backend (e.g., withSharedVisibility(ChunkRequesterBase)). - - In the constructor(rpc, options), read back nested shared objects using rpc.get(options.someSharedId) and wire up listeners. - -- For request/response operations, expose named RPC endpoints: - - registerPromiseRPC("MyType.doThing", function (x, { signal, progressListener }) { … return Promise<{ value, transfers? }>; }) - - From the caller side, await rpc.promiseInvoke("MyType.doThing", { … }, { signal, progressListener }) - ---- - -### Debugging tips - -- Confirm the type id matches on both sides - - - The string passed to @registerSharedObject on the counterpart must match the RPC_TYPE_ID of the owner class (or the string you gave to @registerSharedObjectOwner). Mismatches lead to SharedObject.new failing to find a constructor. - -- Check map sizes and ids - - - RPC keeps a map of id -> object on each side. If you leak references, numObjects will grow. The debug logs (guarded by DEBUG) can help trace lifecycle. - -- Progress/cancel plumbing - - - If you pass a progressListener to promiseInvoke, ensure the backend handler is registered with registerPromiseRPC and that it uses the provided progressListener to add/remove spans. Cancellation will call abortController.abort() on the backend. - -- Be careful with structured clone - - Payloads sent via rpc.invoke must be structured-cloneable. If you need to share a non-cloneable resource, wrap it as a SharedObject and pass ids instead. - ---- - -### Pointers to concrete code you can read next - -- RPC core, SharedObject lifecycle, and decorators: - - - src/worker_rpc.ts - -- A minimal, reusable shared value: - - - src/shared_watchable_value.ts - -- A realistic composite use (visibility sharing): - - - src/visibility_priority/frontend.ts (owner side mixin) - - src/visibility_priority/backend.ts (counterpart side mixin) - -- End-to-end example around a real feature: - - Owner side: src/annotation/renderlayer.ts (AnnotationLayerSharedObject, @registerSharedObjectOwner) - - Counterpart side: src/annotation/backend.ts (classes with @registerSharedObject matching the same ids) - -If you want, tell me which class or feature you plan to modify (e.g., voxel annotation buffering), and I’ll map out the exact owner/counterpart classes and the RPC surface you’ll need to extend. diff --git a/NOTES/TODOs.md b/NOTES/TODOs.md deleted file mode 100644 index e81b0a361c..0000000000 --- a/NOTES/TODOs.md +++ /dev/null @@ -1,75 +0,0 @@ -# TODO List - -### FOR TOMORROW: -- start to prepare the problematic/email to JMS - - -### priority -- fix the flood fill for compressed chunks -- test uint64 support -- design a dataset creation feature - - - -### later -- rework the ui (tabs) -- optimize flood fill tool (it is too slow on area containing uncached chunks, due to the getEnsuredValueAt() calls) -- rework the drawing preview for compressed chunk (see applyLocalEdits()) -- rework the autocomplete for the ssa+https source. -- the flood fill sometimes leaves artifacts in sharp areas -- isolate the downsampling -- add shortcuts for tools (switching tools, toggle erase mode, select label from the pointed one in the slice view and adjusting brush size) and label creation -- write a testsuite for the downsampler and ensure its proper working on exotic lod levels -- fix undo/redo buttons activation states - - - - -### questionable --? adapt the brush size to the zoom level linearly - - - - - - - - - - - - - - - - - - - - - - - - - - -## mail - -Hey, I am writting a mail for Jeremy Maitin-Shepard, the creator of neuroglancer, to present him the voxel annotation layer and get his opinion on the architecture and design choices before I start to consolate the code. Can you help me write it? Here is a draft of the mail: - -Hello, - -...intro -I am currently working on a voxel annotation layer for neuroglancer as part of my internship at Ariadne.ai. The goal is to allow users to annotate volumetric data directly within the neuroglancer interface, with the objective in the end to realize labeling for deep learning. I saw that you mentioned this feature in this talk: https://www.youtube.com/watch?v=_XgfGcu81AA - -We made good progress on the feature and have a working prototype, and feel like it is the right time to share it with you, to have your opinion on the architecture and design choices before I start to consilate the code. - -Here is how it works: - -We have a new "vox" layer accepting volume data sources [src/layer/vox/index.ts], and we have a brush and a flood fill tool (with an erase mode for both, i.e. label = 0) [src/ui/voxel_annotation.ts]. - -Drawing happens at a set resolution (currently locked at the max resolution), the drawn chunks are the downscaled. Since the resolution is fixed, the max brush size is limited to 64, after what the performances are too poor. I tried to implement an upscaling root too, for this there are two path: -- the first one is to upscale right after drawing, like the downscaling, but we quickly hit limitation on the number of upscale step we can perform (because of the exponential nature of the upscale that the downscale hasn't), I estimate them to be 3 to 4 steps max. This method would allow for higher brush size, but not for giant brush ones. -- an other way is to delay the upscaling to the moment we want to draw the chunk that needs upscaling, I tried this method but we quickly hit some upscaling conflicts issues and this method is also not compatible with the generic data sources, requiring a way to mark chunks as dirty. - -The drawing pipeline is handled by the EditController class [src/voxel_annotation/edit_controller.ts & src/voxel_annotation/edit_backend.ts], a shared object, diff --git a/NOTES/annotation-chunk-source-and-sync.md b/NOTES/annotation-chunk-source-and-sync.md deleted file mode 100644 index 7ef3304aba..0000000000 --- a/NOTES/annotation-chunk-source-and-sync.md +++ /dev/null @@ -1,153 +0,0 @@ -### What “chunk sources” are in the annotation system - -In Neuroglancer, rendering and data flow are built around chunked sources: - -- Frontend chunk sources live on the main thread and integrate with rendering, visibility, and GPU upload. -- Backend chunk sources live in a Web Worker and actually fetch/produce the bytes for each chunk. -- The two halves are paired via a small RPC layer. The frontend owner has a type id; the backend counterpart class registers itself under the same id. When the frontend initializes, it requests the backend to construct the counterpart, and they talk by sending messages with ids. - -For annotations, the system uses three closely-related chunk sources on the frontend side (with backend counterparts): - -- AnnotationGeometryChunkSource: provides spatially indexed geometry of annotations to draw (slice-view geometry per chunk). -- AnnotationSubsetGeometryChunkSource: a filtered geometry source tied to segmentation relationships; supplies geometry subsets keyed by segment id. -- AnnotationMetadataChunkSource: per-annotation metadata keyed by annotation id (used to keep the value of AnnotationReference in sync). - -These objects are owned on the frontend and mirrored on the backend. They’re coordinated by MultiscaleAnnotationSource, which: - -- Holds and wires the three sources together. -- Keeps local references and local-update state for edits (add/update/delete). -- Initializes its counterparts in the worker (passing nested shared-object references, like its metadata/filtered sources and the chunk manager id). - -The voxel_annotation dummy volume you added (VoxDummyChunkSource) uses the same pairing mechanism as the standard volume/annotation sources: the frontend owner sets a shared type id; the backend counterpart registers with the same id and implements download(), which fills chunk.data with a procedurally generated pattern. - -### Frontend↔Backend synchronization: the RPC pairing - -- On the owner side, classes are decorated with @registerSharedObjectOwner("…ID…"). When they call initializeCounterpart(rpc, options), the RPC sends a SharedObject.new(type=ID, options) message. -- On the worker side, counterpart classes are decorated with @registerSharedObject("…ID…"). The worker’s SharedObject.new handler looks up the constructor for that id and constructs the backend instance. -- Both sides keep ref-counted object handles with a shared numeric id. You can nest references to other shared objects inside options (e.g., pass a MetadataChunkSource id to the backend inside the parent’s initialize payload). - -For annotation commit flow specifically, there are two named RPCs (strings exported from annotation/base): - -- ANNOTATION_COMMIT_UPDATE_RPC_ID: frontend→backend to request an add/update/delete commit. -- ANNOTATION_COMMIT_UPDATE_RESULT_RPC_ID: backend→frontend to return success/failure and the updated annotation (or null for deletion). - -### The annotation edit pipeline (buffering + commit system) - -The key design goal is to show edits immediately on the frontend (optimistic UI), while guaranteeing consistency as the authoritative backend accepts/rejects them. - -1. Local overlay buffering on the frontend - -- MultiscaleAnnotationSource maintains: - - - references: Map from annotation id to AnnotationReference; each holds the current value and a changed signal for listeners. - - localUpdates: Map from id → LocalUpdateUndoState. Tracks: - - existingAnnotation: the server-committed annotation prior to local edits (if any). - - commitInProgress: the annotation payload (or null for deletion) that has been sent and is awaiting backend result. - - pendingCommit: a queued annotation payload to send after commitInProgress finishes (if the user edited again before the prior commit returned). - - temporary: an in-memory “temporary geometry chunk” overlay that stores serialized bytes for the edited version of an annotation until the commit completes. - -- When you call add/update/delete (or add followed by commit): - - applyLocalUpdate() moves geometry bytes out of any existing visible geometry chunks (deleteAnnotation from those chunks) and writes the edited geometry into the temporary overlay chunk (updateAnnotation). This ensures rendering immediately reflects the local edit. - - It updates the AnnotationReference.value on the frontend and notifies listeners (notifyChanged), causing render invalidation and UI updates without waiting for the backend. - -2. Sending the commit request - -- If commit=true, applyLocalUpdate() either: - - queues the new edit into pendingCommit if a commit is already in-flight for that annotation, or - - calls sendCommitRequest(): - - increments a global commit-in-progress counter (used to show a StatusMessage like “Committing annotations”). - - sets commitInProgress to the payload. - - invokes ANNOTATION_COMMIT_UPDATE_RPC_ID with { id: this.rpcId, annotationId?, newAnnotation? }: - - annotationId undefined + newAnnotation → add - - annotationId set + newAnnotation → update - - annotationId set + newAnnotation null → delete - -3. Backend receives commit - -- The worker-side registerRPC(ANNOTATION_COMMIT_UPDATE_RPC_ID, …) handler looks up the AnnotationSource counterpart object from x.id and dispatches to obj.add/delete/update as appropriate. Those methods are expected to return a Promise with the outcome. -- Once resolved, it invokes ANNOTATION_COMMIT_UPDATE_RESULT_RPC_ID to the frontend with { id, annotationId, newAnnotation | error }. Note there’s a FIXME in the backend handler: “Handle new chunks requested prior to update but not yet sent to frontend.” This is a hint that the backend does not yet buffer/resynchronize in-flight visible-chunk streams vs. the commit result; the frontend overlay is the primary buffering mechanism for edits. - -4. Frontend applies commit result - -- The frontend registerRPC(ANNOTATION_COMMIT_UPDATE_RESULT_RPC_ID, …) handler calls either handleSuccessfulUpdate or handleFailedUpdate. - -- On success (handleSuccessfulUpdate): - - - Decrement the global commit counter and potentially clear the “Committing annotations” StatusMessage. - - If the server returned a new id (common on add), re-key all local state: - - Update AnnotationReference.id and references map entries. - - If there is an overlay entry in the temporary chunk, delete the old-id overlay and write a new overlay with the updated id. - - Set existingAnnotation to the newAnnotation (or undefined if null), clear commitInProgress. - - If there was a pendingCommit queued during the in-flight commit, update its id to the returned id (if needed) and immediately send a new commit request. Otherwise, revert the local overlay to finish the cycle (see below). - -- On failure (handleFailedUpdate): - - Show an error StatusMessage. - - Revert local overlay and references (revertLocalUpdate): - - Remove any edited overlay geometry for this id from the temporary chunk. - - If there was an existingAnnotation, add its geometry back into visible geometry chunks (updateAnnotation for those chunks) so the display matches server state. - - Restore AnnotationReference.value to existingAnnotation (or null) and dispatch its changed signal. - - Decrement the global commit counter. - -5. Reverting overlay after a successful cycle - -- If there is no pending commit, revertLocalUpdate() is called to remove the overlay and restore the world to a “no local edits pending” state. Since existingAnnotation has already been updated to the committed version, the visible chunks + metadata now represent the committed data, and the temporary overlay can be dropped. - -6. Metadata sync for live references - -- MetadataChunkSource is used so that references.get(id) consumers stay synced: when a metadata chunk arrives for an id, AnnotationMetadataChunkSource.addChunk sets the associated AnnotationReference.value and dispatches changed. -- notifyChanged() is also called whenever local overlay changes the value, so UI stays responsive. - -### How chunk streaming and visibility interact with edits - -- Geometry chunks are streamed independently of commits. The backend recomputes priorities for visible annotation chunks based on the view, and for each needed chunk requests it from the appropriate backend geometry source (spatially indexed or subset by segmentation). When bytes arrive, the frontend replaces or updates the corresponding chunk’s AnnotationGeometryData. -- The frontend overlay logic in temporary ensures local edits appear immediately, regardless of when backend geometry chunks stream in. The overlay is kept separate from streamed chunks and is applied/removed deterministically during the commit flow. -- A note in backend commit handling acknowledges a potential race: a chunk could be requested based on an outdated state. The overlay strategy on the frontend is what guarantees the user sees their edits; any mismatches are corrected as commits resolve and overlay is removed. - -### The buffering model in a nutshell - -- Frontend buffering: a dedicated temporary chunk stores serialized geometry for locally edited annotations. It is immediately read by the renderer to display edits. This buffer is the single source of truth for in-flight user edits. -- Queuing and coalescing: if an edit for the same annotation happens while a commit is in-flight, the new payload is queued in pendingCommit. As soon as the in-flight commit returns, the queued payload is updated with the authoritative id (if needed) and is sent immediately. This effectively debounces rapid user edits into a linear sequence of commits without losing intermediate UI responsiveness. -- Backend buffering: the backend does not do significant edit buffering; it executes add/update/delete and returns results. The FIXME suggests future work could better correlate pre-commit chunk requests with post-commit state, but the current design relies on the frontend overlay to mask such transitions. - -### Where to look in code (ready-made pointers) - -Frontend (src/annotation/frontend_source.ts): - -- MultiscaleAnnotationSource - - applyLocalUpdate() — creates/updates the local overlay, manages pending/active commit flags. - - sendCommitRequest() — sends ANNOTATION_COMMIT_UPDATE_RPC_ID and marks commitInProgress. - - handleSuccessfulUpdate() — applies server result, re-keys ids, chains pending commits, and reverts overlay when done. - - handleFailedUpdate() — shows error, reverts overlay to the last committed state. - - revertLocalUpdate() — the overlay/undo routine. - - notifyChanged() — synchronizes AnnotationReference and invalidates rendering. -- AnnotationGeometryChunkSource, AnnotationSubsetGeometryChunkSource, AnnotationMetadataChunkSource — the three chunk sources used by the layer to render and to keep references synced. - -Backend (src/annotation/backend.ts): - -- registerRPC(ANNOTATION_COMMIT_UPDATE_RPC_ID, …) — receives commit requests, routes them to add/update/delete, sends result via ANNOTATION_COMMIT_UPDATE_RESULT_RPC_ID. -- AnnotationSpatiallyIndexedRenderLayerBackend.recomputeChunkPriorities() — visibility-driven chunk scheduling that requests geometry chunks. - -### Relation to your VoxDummyChunkSource - -Your voxel_annotation VoxDummyChunkSource mirrors the standard infrastructure used above: - -- Frontend owner: VoxDummyChunkSource (src/voxel_annotation/frontend.ts) extends volume/frontend VolumeChunkSource and is annotated with @registerSharedObjectOwner(VOX_DUMMY_CHUNK_SOURCE_RPC_ID). -- Backend counterpart: VoxDummyChunkSource (src/voxel_annotation/backend.ts) extends volume/backend VolumeChunkSource and is decorated with @registerSharedObject(VOX_DUMMY_CHUNK_SOURCE_RPC_ID). It implements download() to fill chunk.data with a checkerboard pattern. -- The RPC pairing and chunk lifecycle are the same: frontend requests visible chunks, backend download() produces bytes, they’re transferred back and uploaded to GPU by the frontend’s format handler; rendering samples those textures in your custom render layer. - -### Practical implications for modifying or extending the commit/buffering logic - -- To change how many edits can be coalesced: adjust logic around pendingCommit and commitInProgress in applyLocalUpdate, handleSuccessfulUpdate, and sendCommitRequest. The current model serializes edits: one in-flight + at most one queued per annotation id. You could extend it to keep a small queue and squash updates. -- To draw overlay differently (e.g., highlight uncommitted edits): modify how the temporary chunk is fed into the shader/render mix. Today, temporary bytes are written in a separate chunk object; your render layer or geometry-data upload path could add a visual flag. -- To ensure consistency with streaming chunks: if you need stronger guarantees that streamed chunks reflect post-commit state, you could implement a small backend-side buffer or generation tracking in the annotation geometry sources, then drop or re-request chunks when a commit completes. -- To wire new properties into commit: extend AnnotationPropertySerializer and the serialize/deserialize paths used by updateAnnotation/deleteAnnotation/computeNumPickIds. - -### TL;DR flow - -- User edits → frontend immediately updates a “temporary” overlay chunk and updates the AnnotationReference value; UI responds instantly. -- If commit requested → frontend sends ANNOTATION_COMMIT_UPDATE_RPC_ID to worker, marks commitInProgress; subsequent edit on same id sets pendingCommit. -- Backend performs add/update/delete; returns via ANNOTATION_COMMIT_UPDATE_RESULT_RPC_ID. -- Frontend success: re-key ids if needed, chain any pendingCommit, or revert overlay to the committed state; failure: revert overlay to prior committed state and show error. -- Meanwhile, visible annotation geometry chunks stream independently; the overlay ensures visual correctness during the transition. - -If you want, I can also trace the exact WebGL upload path for AnnotationGeometryData and where the temporary overlay’s bytes are combined with streamed chunks at draw time, or sketch how to add a visual “pending commit” tint to uncommitted annotations. diff --git a/NOTES/backend.md b/NOTES/backend.md deleted file mode 100644 index 6af0bed5bf..0000000000 --- a/NOTES/backend.md +++ /dev/null @@ -1,148 +0,0 @@ -### Requirements Document: Zarr-based Voxel Annotation Server (MVP) - -#### 1) Overview and Scope -- Goal: Deliver a production-ready HTTP server to host and edit voxel annotation data (integer label volumes) stored in Zarr. The server must be easy to deploy via Docker Compose and suitable for browser clients (e.g., Neuroglancer-based UIs or custom viewers). -- Out of scope: Any form of backup, history, or undo functionality (explicitly excluded from this requirements list). - -#### 2) Context and Assumptions -- Data model: 3D label volumes (`uint32` or `uint64`) chunked in Zarr v2 layout; optional multiscale hierarchy following NGFF `multiscales` attribute. -- Hosting model: Reads served over plain HTTP/HTTPS from an object store or filesystem via the app or via a CDN/reverse proxy. Writes are authenticated and validated by the app and applied to the Zarr store. -- Authentication: Unique link (magic link). -- Clients: Browser-based viewers/editors. Clients read/write whole chunks; no sub-chunk partial writes. -- Deployment target: Single-node Docker Compose for development and small teams. - -#### 3) Data Format and Layout (Zarr) -- Zarr version: v2. -- Root group: `annotations.zarr/` containing: - - `.zgroup` (group marker) - - `.zattrs` with NGFF `multiscales` describing axes and coordinate transforms. - - One or more arrays for scale levels: `0/`, `1/`, ... (strings). -- Array (`0/`) `.zarray` baseline (example values): -```json -{ - "zarr_format": 2, - "shape": [Z, Y, X], - "chunks": [64, 64, 64], - "dtype": "uint32", - "compressor": {"id": "zlib", "level": 5}, - "order": "C", - "fill_value": 0 -} -``` -- Missing chunk semantics: Unwritten chunks are implicitly `fill_value` (0). -- Chunk addressing (v2): Files at `0/ix/iy/iz` for chunk indices `(ix, iy, iz)`. - -#### 4) Functional Requirements -- Dataset discovery and metadata - - The server exposes an endpoint to return dataset info (union of NGFF `.zattrs` and per-array `.zarray` summaries). - - The server must report shapes, chunk sizes, dtype, fill value, and the public base URL for direct HTTP reads (if configured). -- Read operations - - Clients can fetch chunks as raw binary blocks via the server or directly from the store/reverse proxy. - - Missing chunks must be interpreted as background (fill value 0). -- Write operations - - Clients upload full chunks for updates. Payload must match the logical chunk voxel count and dtype. - - Edges: For boundary chunks smaller than full chunk size, the server accepts a full-sized block and writes only the in-bounds subregion. - - Concurrency: MVP supports last-writer-wins. -- Dataset resize - - Resize for expanding the array shape (Zarr `resize`). -- Authentication and authorization - - Magic link token required for all endpoints. -- Multi-scale (optional) - - The server lists available scales; reading/writing operates per selected scale path (string). - -#### 5) Non-Functional Requirements -- Performance - - Target chunk size: 64 cubed for labels. Throughput goal: at least hundreds of chunk reads/s and tens of chunk writes/s on a single node with local/S3-like storage. - - Compression: zlib (level 5) or zstd; deterministic compressor to keep payloads predictable. -- Availability - - Single instance acceptable for MVP; health checks and graceful shutdown required. -- Consistency - - Per-chunk write is atomic from the client perspective. Readers may observe eventual consistency on object stores. -- Security - - CORS: Allow configured origins; methods `GET, HEAD, PUT, OPTIONS`. -- Caching - - Metadata: short `Cache-Control` (e.g., 60s) with ETags. Chunks: cacheable but consider short TTLs during active editing. Avoid long-lived caching of 404s. -- Observability - - Structured logs for all requests with dataset id, path, role, status, duration, payload size. - - Basic metrics: request counts, latencies, error codes, chunk read/write counters. -- Portability - - Storage backends via fsspec-compatible URLs (`file://`, S3, etc.). Docker-compose provides local S3-compatible MinIO for development. - -#### 6) API Specification (HTTP, JSON/binary) -- GET `/info` - - Response: datasets metadata including `publicBase` URL and a list of arrays: `{ path, shape, chunks, dtype, fill_value, compressor }`. -- GET `/chunk?mapId=&chunkKey=` - - Response: `application/octet-stream` raw bytes of a full chunk in row-major order with array dtype. Edge chunks are padded to full chunk size. -- PUT `/chunk?mapId=&chunkKey=` - - Request body: raw bytes matching `chunks[0]*chunks[1]*chunks[2]*dtype.itemsize`. - - Behavior: Writes the corresponding chunk region. For edge chunks, only in-bounds subset is written. - - Response: JSON `{ status: "ok" }` on success. -- GET `/init?mapId=&scaleKey=&dtype=` - - Behavior: Init a new map with id mapId, and sets up its metadata. If a map already exists, return an error. - - Response: `{ status: "ok" }` on success. -- GET `/health` - - Response: `200 OK` if the server is ready and can reach the storage. -- `GET /labels?mapId=` → JSON `{ "labels": [ ... ] }` for that dataset. Labels are stored as a list and must fit the dataset dtype (uint32/uint64). -- `PUT /labels?mapId=` → Add a label. Body can be a JSON number, `{ "value": }`, or a plain text integer. Returns updated `{ "labels": [...] }`. - -scale key calculation: -```ts -export function toScaleKey( - chunkDataSize: number[] | Uint32Array, - baseVoxelOffset?: number[] | Uint32Array | Float32Array, - upperVoxelBound?: number[] | Uint32Array | Float32Array, -): string { - const cds = Array.from(chunkDataSize); - const lower = Array.from(baseVoxelOffset ?? [0, 0, 0]); - const upper = Array.from(upperVoxelBound ?? [0, 0, 0]); - return `${cds[0]}_${cds[1]}_${cds[2]}:${lower[0]}_${lower[1]}_${lower[2]}-${upper[0]}_${upper[1]}_${upper[2]}`; // "cx_cy_cz:lx_ly_lz-ux_uy_uz" -> "64_64_64:0_0_0-1024_1024_1024" -} -``` - -chunk key calculation: -```ts -export function toChunkKey( - chunkIndices: number[] | Uint32Array, -): string { - const cis = Array.from(chunkIndices); - return `${cis[0]},${cis[1]},${cis[2]}`; // "cx,cy,cz" -> "0,0,0" -} -``` - -#### 7) Storage and Infrastructure -- Backends: Local filesystem or S3-compatible object store. Docker Compose includes MinIO for local S3-like storage. -- Directory and object naming - - One Zarr root per dataset (MVP). Scale arrays named `"0"`, `"1"`, ... - -#### 8) Deployment Architecture -- Components - - App server: Hosts the HTTP API, performs auth, validates input, reads/writes Zarr store. - - Object store: MinIO (compose); durable storage for Zarr. -- Read flow: Client → App → Store → App → Client. -- Write flow: Client → App → Store (write) → App response. - -#### 9) Configuration -- Environment variables (app) - - `ZARR_URL`: Zarr root URL (`file://` or `s3://zarr/annotations.zarr`). - - `PUBLIC_BASE`: Public base URL for direct reads (optional). - - `CORS_ORIGINS`: Comma-separated origins allowed. -- Environment variables (MinIO) - - `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`. -- Volumes - - Persistent volume for MinIO data. - - Optional bind mount for filesystem-backed Zarr. - -#### 10) Health, Logging, and Metrics -- Health endpoint: `GET /health` returns 200 when app is ready and can reach storage. -- Logging: JSON logs with timestamp, method, path, dataset id, http status, latency ms, bytes. - -#### 13) Risks and Mitigations -- Object-store eventual consistency: Edge cases where a just-written chunk isn’t visible immediately; mitigate with read-after-write via the app or retries. -- Misconfigured CORS: Prevents browser access; provide a CORS self-test on `/info`. -- Payload mismatch (size/dtype): Strict validation and clear error messages. - -#### 14) Operational Runbook (MVP) -- First start - - `docker compose up -d` - - Visit `http://localhost:8042/info?token=...` with a valid magic link token to verify connection, server should provide a token throw its console - - Connect neuroglancer to `zarr://http://localhost:8042/?token=...`, create a new map and try drawing diff --git a/NOTES/classExplanations/MultiscaleVolumeChunkSource.md b/NOTES/classExplanations/MultiscaleVolumeChunkSource.md deleted file mode 100644 index 2d855381f0..0000000000 --- a/NOTES/classExplanations/MultiscaleVolumeChunkSource.md +++ /dev/null @@ -1,174 +0,0 @@ -### What MultiscaleVolumeChunkSource is and why it exists - -MultiscaleVolumeChunkSource is the frontend abstraction for volumetric data in Neuroglancer that can be viewed at multiple resolutions and/or orientations. It doesn’t load or store voxels itself; instead it: - -- Defines the set of per-scale, per-orientation chunk sources that the renderer can query. -- Encodes the coordinate transforms needed to map each chunk space into the layer’s “multiscale” space. -- Supplies metadata such as rank, data type, and volume type (image vs segmentation) to drive shader code paths and default compression decisions. - -Concretely, the type is defined in src/sliceview/volume/frontend.ts: - -- MultiscaleVolumeChunkSource extends the generic MultiscaleSliceViewChunkSource with Source = VolumeChunkSource and Options = VolumeSourceOptions. You must implement: - - rank: number — typically 3 for a 3D volume (or 4 if you include channels as a dimension in chunking). - - dataType: DataType — e.g., UINT8, UINT16, FLOAT32, UINT32/UINT64 (segmentation). - - volumeType: VolumeType — IMAGE or SEGMENTATION (affects shader behavior and default compression rules downstream). - - getSources(options: VolumeSourceOptions): SliceViewSingleResolutionSource[][] — returns a 2D array: [orientation][scale]. Each element supplies: - - chunkSource: VolumeChunkSource — the per-scale chunk producer/holder. - - chunkToMultiscaleTransform: mat (rank+1 x rank+1) mapping chunk voxel coordinates into the multiscale space for this source. - - lowerClipBound/upperClipBound (optional) — clip region in chunk voxel space. - -How the renderer uses it (high level): - -- SliceView requests transformed sources via getVolumetricTransformedSources (src/sliceview/frontend.ts). That function: - 1. Calls your getSources with the view’s transforms and channel mapping. - 2. Computes, for each source, the transforms between chunk space, multiscale space, and the 2D view, plus an effective voxel size at that scale. - 3. Chooses which scale(s) to render given current zoom, pixel size, and RenderLayer settings. - 4. Enumerates visible chunks for those sources and asks the ChunkManager to fetch them. - -Where the actual voxel bytes come from: - -- VolumeChunkSource (also in src/sliceview/volume/frontend.ts) is the frontend pair to your selected spec (VolumeChunkSpecification). It defines chunk layout/format and provides getValueAt for picking. -- The frontend VolumeChunkSource depends on a backend chunk source implementation (in workers) to fill chunk data on demand. Without a backend, no data arrives; rendering either shows nothing or can still draw “proced“procedural” effects that don’t sample the chunk textures. - ural” effects that don’t sample the chunk textures. - -Helpful related APIs: - -- makeVolumeChunkSpecification in src/sliceview/volume/base.ts builds the spec (rank, bounds, chunk size, data type, etc.). -- makeVolumeChunkSpecificationWithDefaultCompression can choose compressed segmentation blocks for segmentation data. -- SliceViewVolumeRenderLayer in src/sliceview/volume/renderlayer.ts is the default renderer that consumes your MultiscaleVolumeChunkSource and handles WebGL setup, transforms, chunk iteration, and shader integration. - -### How to use MultiscaleVolumeChunkSource - -Typical usage pattern when building a layer: - -1. Construct a subclass instance and pass it to a SliceViewVolumeRenderLayer (or your own subclass of it), e.g.: - -- const multiscale = new MyMultiscaleSource(chunkManager); -- const renderLayer = new SliceViewVolumeRenderLayer(multiscale, { ... }); - -2. The layer uses your getSources to choose appropriate scales and request chunks through the ChunkManager. -3. A backend implementation for VolumeChunkSource provides the voxel bytes when requested. - -### How to extend it (implement your own) - -To implement a custom multiscale volume: - -- Extend MultiscaleVolumeChunkSource. -- Define rank, dataType, and volumeType. -- Implement getSources(options). For each scale/orientation you want to expose: - 1. Create a VolumeChunkSpecification via makeVolumeChunkSpecification (or the default-compression variant for segmentation). You must provide at least: - - rank - - chunkDataSize (Uint32Array length = rank) - - lowerVoxelBound (defaults to zeros if not given) - - upperVoxelBound (required) - - dataType - 2. Obtain a frontend VolumeChunkSource from the ChunkManager: - - const source = chunkManager.getChunkSource(VolumeChunkSource, { spec }) - 3. Provide chunkToMultiscaleTransform (Float32Array of size (rank+1)^2). This defines the voxel size/axis orientation and any downsampling between the chunk’s voxel grid and your multiscale space. - 4. Optionally specify lowerClipBound/upperClipBound to restrict rendering. - 5. Push a SliceViewSingleResolutionSource { chunkSource, chunkToMultiscaleTransform, ... } into the returned arrays. The outer array indexes orientations; the inner array indexes scales from fine to coarse (or vice-versa; the utility code reorders as needed, but keep a consistent order, typically coarse-to-fine or fine-to-coarse). The filterVisibleSources logic in src/sliceview/base.ts picks suitable scales given zoom. - -Multiple scales example sketch: - -- For a three-scale pyramid, you might set chunkToMultiscaleTransform with voxel sizes [1,1,1], [2,2,2], [4,4,4] (or encode that into the matrix). Each scale also can have different chunkDataSize to better match the level’s voxel size. - -Backends: - -- For real data, implement a corresponding backend chunk source (worker) that understands your source’s spec key and returns bytes. Most datasources under src/datasource/\* demonstrate this by subclassing GenericMultiscaleVolumeChunkSource or MultiscaleVolumeChunkSource and providing a backend counterpart. - -### Review of your DummyMultiscaleVolumeChunkSource - -File: src/voxel_annotation/volume_chunk_source.ts - -What it sets up: - -- Extends MultiscaleVolumeChunkSource with: - - dataType = DataType.UINT32 - - volumeType = VolumeType.SEGMENTATION - - rank = 3 -- getSources returns a single orientation with a single scale: - - chunkDataSize = [64, 64, 64] - - upperVoxelBound = [1000, 1000, 1000] - - lowerVoxelBound defaults to [0, 0, 0] via makeSliceViewChunkSpecification - - spec = makeVolumeChunkSpecification({ rank, dataType, chunkDataSize, upperVoxelBound }) - - chunkSource = chunkManager.getChunkSource(VolumeChunkSource, { spec }) - - chunkToMultiscaleTransform = identity (no scaling, no rotation, 1 voxel unit per multiscale unit) - - lowerClipBound = spec.lowerVoxelBound; upperClipBound = spec.upperVoxelBound - - returns [[single]] - -What this means in practice: - -- Geometry and bounds: - - Your multiscale space is a simple axis-aligned 1000x1000x1000 volume with voxel size implicitly equal to 1 in all axes (identity transform). Chunks are 64^3. -- Data type and volume type: - - You chose segmentation semantics (VolumeType.SEGMENTATION) with UINT32 values. This is coherent; many segmentations are UINT32. It will influence shader behavior in the stock SliceViewVolumeRenderLayer (e.g., how interpolation and histogram calculations are treated). -- Multiscale levels: - - Only a single scale is provided. The viewer won’t be able to switch to a coarser level as you zoom out. For testing this is fine; for large volumes, consider adding multiple scales. -- Backend data: - - The frontend VolumeChunkSource expects the backend to provide chunk bytes. As written, there is no backend companion to actually fill data. Your VoxelAnnotationRenderLayer’s shader currently emits a procedural checkerboard using vChunkPosition and uChunkDataSize, which can render without sampling voxel textures — that’s why this can still “show something” even without real data. However, if you later want to read voxel values in the shader (e.g., segmentation ID), you’ll need a backend chunk provider. - -Correctness/consistency observations: - -- Using makeVolumeChunkSpecification with minimal fields is valid; lowerVoxelBound defaults correctly. -- The identity chunkToMultiscaleTransform is valid; it means multiscale coordinates and chunk voxel coordinates coincide. If your layer’s model/render transforms assume a different physical voxel size (e.g., anisotropic data), you should encode that scale into this matrix. -- VolumeType.SEGMENTATION + UINT32 can optionally benefit from compressed segmentation block sizes, but that is set on the spec via makeVolumeChunkSpecificationWithDefaultCompression (and requires chunkToMultiscaleTransform and options.multiscaleToViewTransform). For a dummy source, skipping compression is fine. -- The return shape [[single]] is correct: outer index is orientation (only one), inner is scale (only one). - -Suggestions to evolve DummyMultiscaleVolumeChunkSource: - -- Multiple scales: Create a list of specs for different resolutions. For each coarser level: - - Either encode a larger voxel size into chunkToMultiscaleTransform (e.g., 2x, 4x) and keep a similar chunkDataSize, or keep voxel size = 1 and adjust transforms so that coarser scales map appropriately into multiscale space. - - Return [[level0, level1, level2]] ordered from fine to coarse (or vice-versa consistently). -- Anisotropic voxels: If your data units are not isotropic, build chunkToMultiscaleTransform with per-axis scales (e.g., diag([sx, sy, sz, 1])). -- Clip bounds: You can tighten lowerClipBound/upperClipBound (floats allowed) to define a visible subregion without changing retrieval bounds. -- Backend stub: For development, add a backend VolumeChunkSource that fills chunks procedurally (e.g., write a pattern or ID = x+y+z) so you can test sampling in shaders and getValueAt. - -### Quick look at your VoxelAnnotationRenderLayer (to see integration) - -File: src/voxel_annotation/renderlayer.ts - -- Extends SliceViewVolumeRenderLayer and overrides defineShader to render a 2D checkerboard using vChunkPosition.xy and uChunkDataSize.xy, without sampling volume data. This is consistent with your dummy source and is why you can render even without actual chunk bytes. -- initializeShader is a no-op (fine for now). The base class takes care of binding uniforms like uChunkDataSize, uLowerClipBound, uUpperClipBound, etc. - -If/when you want to use real voxel values in the shader, you’ll need to: - -- Let defineChunkDataShaderAccess (already wired by the base class) provide sampling functions and texture bindings. -- Ensure your backend supplies chunk data with the right format for the selected DataType. - -### Minimal template for a multiscale source you can extend - -- class MyMultiscaleSource extends MultiscaleVolumeChunkSource { - - dataType = DataType.UINT32; - - volumeType = VolumeType.SEGMENTATION; - - get rank() { return 3; } - - constructor(cm) { super(cm); } - - getSources(options) { - - const rank = this.rank; - - const upperVoxelBound = new Float32Array([X, Y, Z]); - - const scales = [1, 2, 4]; // voxel size multipliers - - const sources = scales.map(s => { - - const spec = makeVolumeChunkSpecification({ - rank, - dataType: this.dataType, - chunkDataSize: new Uint32Array([64,64,64]), - upperVoxelBound, - }); - - const chunkSource = this.chunkManager.getChunkSource(VolumeChunkSource, { spec }); - - const xform = new Float32Array((rank+1)\*(rank+1)); - // set identity and scale diagonal by s - - for (let i=0;i and options (e.g., spec). -- The worker must have previously registered a constructor under that same identifier via registerSharedObject("id"). -- When SharedObject.new arrives in the worker: - - worker_rpc looks up sharedObjectConstructors.get(typeName) - - It calls new constructorFunction(rpc, options) - - The new backend object is recorded in the RPC map and linked to the same id -- From then on, any time the frontend sends a reference {id, gen}, the worker can resolve it to the concrete backend object with rpc.getRef. - -In your code: - -- Frontend: VoxDummyChunkSource sets its prototype.RPC_TYPE_ID = VOX_DUMMY_CHUNK_SOURCE_RPC_ID. -- Backend: VoxDummyChunkSource is decorated with @registerSharedObject(VOX_DUMMY_CHUNK_SOURCE_RPC_ID) so it registers its constructor under the same ID. - -### Why MultiscaleVolumeChunkSource exists at all - -- It lets Neuroglancer render the same dataset at multiple levels of detail and orientations without changing the rest of the rendering pipeline. -- It abstracts: “here is the set of sources to use for the current view and resolution.” The core sliceview logic can then pick the best scale based on zoom and request those chunks only. - -### How “a single value at a position” is read - -- Frontend VolumeChunkSource.getValueAt does a small lookup: - - Convert a voxel coordinate to a chunk grid coordinate and an index within the chunk (modulo chunk size). - - Fetch the chunk by key from the in-memory map. If missing, return null/undefined. - - Ask the chunk object to read the typed array at the computed offset. - -This is useful for picking/hover reads and is why chunks are kept in a hash map keyed by grid position. - -### Reference counting and disposal (brief) - -- Both owner and counterpart are ref-counted SharedObjects. When no visible layers reference a source anymore, ref counts drop; the system eventually sends dispose messages across RPC to free memory on both sides. -- The generation fields (referencedGeneration/unreferencedGeneration) guard against stale references as messages can cross. - -### Common pitfalls and quick checks (ties to earlier errors) - -- The backend class not being loaded in the Worker bundle: - - Even if you call registerSharedObject in backend.ts, it will not run unless that file is actually imported by chunk_worker.bundle.js (or one of its transitively imported modules). - - Symptom 1: worker_rpc.ts:443 constructorFunction is not a constructor (actually undefined). That’s exactly what happens when sharedObjectConstructors.get(type) finds nothing because your backend module never ran its registration. - - Fix: ensure src/voxel_annotation/backend.ts is imported from the worker entry (e.g., via a central “enabled backend modules” file) so the decorator executes. -- RPC type ID mismatch: - - Frontend prototype.RPC_TYPE_ID must equal the identifier used by @registerSharedObject in the backend. If they differ, the worker won’t find a constructor. -- Wrong base classes: - - Frontend source should extend sliceview/volume/frontend VolumeChunkSource; backend should extend sliceview/volume/backend VolumeChunkSource. Mixing these up breaks chunk and spec handling. -- Spec inconsistencies: - - rank, dataType, and chunkDataSize must be consistent. If computeChunkBounds clips a chunk, backend must set chunk.chunkDataSize appropriately so the frontend knows the actual size. - -### TL;DR flow - -- You create a MultiscaleVolumeChunkSource that returns one or more per-resolution frontend VolumeChunkSources (owners) plus transforms/bounds. -- The frontend sends these to the worker; the worker resolves the backend counterparts via a shared ID system. -- The backend decides which chunks to download and calls your backend source’s download to fill typed arrays. -- Chunks are transferred back; the frontend uploads to GPU and renders. - -If you want, I can sketch the minimal import line(s) needed so your vox backend class is included in chunk_worker.bundle.js, which should resolve the constructorFunction is not a constructor error you saw earlier. diff --git a/NOTES/custom-gc.md b/NOTES/custom-gc.md deleted file mode 100644 index 6cb80e97ae..0000000000 --- a/NOTES/custom-gc.md +++ /dev/null @@ -1,45 +0,0 @@ -# Why does Neuroglancer need a custom gc? - -## AI-resume - -Neuroglancer employs a custom reference-counting system, implemented in `disposable.ts`, to manage the lifetimes of critical resources like WebGL textures, event listeners, and remote worker objects. This system complements JavaScript's built-in garbage collector by providing deterministic cleanup of non-memory resources that the GC cannot handle on its own. - -## Practicale example - WebGL texture management - -See this stackoverflow post: -https://stackoverflow.com/questions/58499937/are-webgl-objects-garbage-collected - -#### Are WebGL objects garbage collected? - -In JavaScript memory that I allocated (e.g. an ArrayBuffer) gets freed up when I don't have any reference to it anymore by the GC as I understood that right? - -WebGL objects like Buffers or Textures are associated with a memory block on the GPU as allocated by gl.bufferData() or gl.textureImage2D(). - -I'm wondering: if I give up my last reference to a WebGLTexture or WebGLBuffer object, does it get garbage collected with its GPU memory block freed by the JavaScript VM automatically? - -#### Response - -Yes and no. - -Yes they are garbage collected. But garbage collection happens whenever the browser decides to collect them. From the POV of most browser JavaScript engines the WebGLObject object is a tiny object that just contains an int so it has no easy way to know of any special pressure to collect it. In other words when the GPU runs out of memory the JavaScript garbage collector, which has no connection to the GPU, has no way of knowing that it needs to free these tiny WebGLObject objects in order to free up texture memory. It's only looking at CPU memory. - -This is actually a well known problem of garbage collection. It's great for memory. It's not so great for other resources. - -So, yes, WebGLObject objects are garbage collected and yes the texture/buffer/renderbuffer/program/shader will be freed but practically speaking you need to delete them yourself if you don't want to run out of memory. - -Of course the browser will free them all if you refresh the page or visit a new page in the same tab but you can't count on the browser to garbage collect WebGLObject objects (textures/buffers/renderbuffers/programs/shaders) in any useful way. - -## What’s in util/disposable.ts - -It’s not a general-purpose GC. It provides: - -- A Disposable interface: anything with dispose(): void. -- RefCounted: a base class with addRef() and dispose() that decrements a refCount, and when it reaches zero runs registered cleanup actions. -- Disposer helpers: - - registerDisposer(() => void | Disposable) to collect cleanup actions. - - invokeDisposers in reverse order for safe teardown. - - registerEventListener(target, type, listener, options) that returns an unregister function (and RefCounted.registerEventListener wraps it so it’s auto-removed on dispose). - - registerCancellable(cancellable) to call cancel() during dispose. - - disposableOnce(...) to guard one-time cleanup. -- Owned / Borrowed type aliases to express ownership semantics in function signatures (convention: Owned donates a reference; Borrowed does not increase refCount). -- Debug aids (DEBUG_REF_COUNTS, disposedStacks) for leak/early-dispose diagnosis. diff --git a/NOTES/data-saving-plan.md b/NOTES/data-saving-plan.md deleted file mode 100644 index 1c9ff44f5d..0000000000 --- a/NOTES/data-saving-plan.md +++ /dev/null @@ -1,475 +0,0 @@ -### Goal - -Implement backend-side saving for the voxel annotation “map,” mirroring the annotation system’s commit/buffer architecture, add a map-initialization endpoint, and evaluate persistent storage in the browser (WebWorker-accessible) for offline resilience. - -Below is a concrete design that fits the current codebase and follows the attached notes. It references actual files and lines to make implementation straightforward. - ---- - -### What we already have in the repo - -- Frontend Vox chunk owner with optimistic edit overlay: - - src/voxel_annotation/frontend.ts - - VoxChunkSource extends a volume chunk source owner and adds an in-memory sparse overlay for immediate visual feedback. - - paintVoxel(voxel, value): updates overlay and triggers re-upload to GPU (L164 lines total; key logic at lines 67–94 and helpers at 124–146). -- Backend Vox chunk counterpart producing procedural data: - - src/voxel_annotation/backend.ts - - VoxChunkSource backend counterpart returns a checkerboard in download() (lines 24–54). -- A dummy multiscale provider and layer hookup: - - src/voxel_annotation/volume_chunk_source.ts: builds multiscale and returns our frontend VoxChunkSource (lines 64–129). - - src/layer/vox/index.ts: the Vox layer, its settings/draw tabs, and the render layer. It already has UI hooks to rebuild based on scale/bounds (lines 194–253), and a simple toolset that calls VoxelEditController which calls VoxChunkSource.paintVoxel() (src/voxel_annotation/edit_controller.ts lines 10–22 and 24–52; ui tools in src/ui/voxel_annotations.ts lines 26–187). - -This is already close to the “tiered” architecture in the spec: - -- Tier 1 (Frontend hot cache): the sparse overlay in src/voxel_annotation/frontend.ts lines 15–52. -- Tier 2 (Worker authoritative map): to be added in backend VoxChunkSource (this proposal). -- Tier 3 (Persistent storage): to be added (this proposal; IndexedDB/OPFS in worker). - ---- - -### High-level design - -#### 1) Worker-side authoritative state (Tier 2) - -Add an in-worker map of chunk data keyed by scale and chunk-id, a dirty set, and a debounced saver. - -- Data structures in src/voxel_annotation/backend.ts (backend VoxChunkSource instance): - - - mapId: string (unique id for this map instance) - - spec metadata: chunkDataSize, upperVoxelBound, dataType (already in spec) - - voxels: Map where key = `${scaleKey}/${cx},${cy},${cz}` - - dirty: Set of keys needing persistence - - saver: debounced function to flush dirty chunks to persistent storage - -- Chunk keying and scale: - - - MVP assumes a single user-selected scale (as per spec §3.1). We can encode that as scaleKey = `${spec.chunkDataSize[0]}_${spec.chunkDataSize[1]}_${spec.chunkDataSize[2]}` or a numeric “scaleId” supplied at init. - - Chunk id format: the grid coords string `${cx},${cy},${cz}` (consistent with the frontend overlay key at src/voxel_annotation/frontend.ts line 141). If you prefer the spec example (ranges like `0-64_0-64_0-64`) we can derive that on persistence, but the grid format is simpler and consistent with running code. - -- download(chunk): - - - Compute cx,cy,cz and use key = `${scaleKey}/${cx},${cy},${cz}`. - - Look up voxels.get(key), or lazily allocate a zero-filled Uint32Array sized for the clipped chunkDataSize; store it in the map and return it as chunk.data. - - This makes the worker the authoritative “warm” state backing the streamed chunks. - -- Edit API in worker: - - Implement RPC to handle edits: set-voxel (and later brush/fill batches). For performance, always batch by chunk: payload is { key, edits: Uint32Array or array of [localIndex, value] pairs }. - - Apply into voxels map and mark dirty; schedule saver. - -This mirrors the annotation commit pipeline where the frontend immediately shows edits while the backend is authoritative and persists results (see NOTES/annotation-chunk-source-and-sync.md, esp. the optimistic overlay + commit queue flow at lines 33–105). - -#### 2) Frontend→Worker edit flow (Tier 1→Tier 2) - -Keep the current “optimistic overlay” in the frontend, but also send edits to worker as actions: - -- In src/voxel_annotation/frontend.ts VoxChunkSource: - - - In paintVoxel(...), after overlay.applyEdit, send an RPC to the backend counterpart with the chunk key and localIndex+value. This is analogous to ANNOTATION_COMMIT_UPDATE_RPC_ID from the notes (lines 28–61) but tailored for voxel edits. - - Batch calls: for brush tool, aggregate per-chunk edits client-side and send one RPC per dirty chunk. - -- RPC identifiers (in a new file src/voxel_annotation/base.ts): - - - export const VOX_CHUNK_SOURCE_RPC_ID = 'voxChunkSource'; (already exists and used) - - export const VOX_EDIT_APPLY_RPC_ID = 'vox/edit/apply'; // frontend→worker - - export const VOX_SAVE_STATUS_RPC_ID = 'vox/save/status'; // worker→frontend (optional) - - export const VOX_MAP_INIT_RPC_ID = 'vox/map/init'; // frontend→worker - - export const VOX_MAP_META_RPC_ID = 'vox/map/meta'; // worker→frontend (optional) - -- Semantics: - - VOX_EDIT_APPLY_RPC_ID payload: { id: backendObjectId, key: string, edits: Array<[number /*localIndex*/, number /*value*/]> } - - Worker applies immediately and returns success or throws error. Frontend does not block rendering on this. - -This aligns with the annotation approach: immediate local overlay + a commit-like call to the worker (see notes lines 50–63). - -#### 3) Persistent storage (Tier 3) - -localStorage is not available in Web Workers and is synchronous (bad for large data). Recommended options that work in workers: - -- IndexedDB (IDB) - - - Available in dedicated workers. Good for large binary blobs. Transactional. - - Store per-chunk ArrayBuffers and a small metadata store for maps. - -- OPFS (Origin Private File System) - - Available in workers (File System Access API). Sync access handle (FileSystemSyncAccessHandle) is worker-only and ideal for chunk files; supports atomic writes. - - Simplifies storing each chunk as its own file under /maps/{mapId}/{scaleKey}/{cx},{cy},{cz}.bin. - -Recommendation: IndexedDB is widely used and integrates well with existing code. OPFS is excellent for very large datasets and low-latency writes if you need it later. I’ll outline IDB now and note where OPFS would plug in similarly. - -IndexedDB schema (db name: 'neuroglancer_vox'): - -- objectStore 'maps' (key: mapId: string) → { mapId, createdAt, dataType, chunkDataSize [3], upperVoxelBound [3], unit, scaleKey } -- objectStore 'chunks' (key: `${mapId}:${scaleKey}:${cx},${cy},${cz}`) → ArrayBuffer (Uint32Array.buffer) + optional small header for clipping size. - -Saving strategy: - -- Maintain dirty: Set of keys in worker. A debounced saver runs every e.g. 750 ms or when dirty size exceeds e.g. 32 chunks. -- On flush: open a 'chunks' readwrite transaction and put each dirty chunk, then clear them from dirty. -- Crash safety: each put is a separate record; IDB is durable. Optionally store a compact “dirtyIndex” record before and after flush for recovery. - -Loading strategy: - -- On download() for a chunk: - - If not present in voxels map, try IDB.get(key). If found, deserialize into a typed array and put into voxels map; otherwise allocate zero array. - - Return typed array as chunk.data. - -Offline behavior: - -- Because saving is local (IDB), edits persist without network. If you also have an HTTP backend, you can add a second “cloud sync” layer: write to IDB first, try to POST to server when navigator.onLine, retry later. - ---- - -### Map Initialization Endpoint - -We need a way to create a map with user-specified dimensions and scale. The repo currently sets these in the UI (src/layer/vox/index.ts lines 174–177 for scale/unit/bounds) and constructs a DummyMultiscaleVolumeChunkSource (lines 212–219) with chunkDataSize and upperVoxelBound. - -Add a programmatic initialize step between the frontend owner and the worker counterpart: - -- RPC VOX_MAP_INIT_RPC_ID (frontend→worker): - - - Request: { id, mapId?: string, dataType: number, chunkDataSize: [x,y,z], upperVoxelBound: [x,y,z], unit: string, scaleKey?: string } - - Behavior: if mapId missing, generate one (e.g., UUID). Store metadata in worker instance and persist to IDB 'maps'. Return { mapId, scaleKey }. - - On subsequent restores, the UI can pass a known mapId to re-open the same dataset. - -- Wire from UI: - - - In src/layer/vox/index.ts, VoxUserLayer.applyVoxSettings(...) (lines 194–203) currently rebuilds the layer; extend buildOrRebuildVoxLayer() (lines 205–253) to call a new method on VoxChunkSource owner to initialize the map in the worker. - - Implementation path: - - After creating DummyMultiscaleVolumeChunkSource, call getSources(), take base source, grab its chunkSource (our VoxChunkSource owner instance) and call source.initializeMap(...) which internally calls the RPC. - -- Frontend owner changes (src/voxel_annotation/frontend.ts): - - Add initializeMap(opts) on VoxChunkSource owner which calls rpc.invoke(VOX_MAP_INIT_RPC_ID, { id: this.rpcId, ...opts }). You already have @registerSharedObjectOwner for this type (line 57), so you can use the existing counterpart wiring. - -This mirrors the “counterpart initialization” mechanism described in NOTES/annotation-chunk-source-and-sync.md lines 22–31. - ---- - -### Concrete API and pseudo-code - -#### Constants (new) src/voxel_annotation/base.ts - -```ts -export const VOX_CHUNK_SOURCE_RPC_ID = "voxChunkSource"; // already exists -export const VOX_MAP_INIT_RPC_ID = "vox/map/init"; -export const VOX_EDIT_APPLY_RPC_ID = "vox/edit/apply"; -export const VOX_SAVE_STATUS_RPC_ID = "vox/save/status"; // optional progress events -``` - -#### Frontend owner additions src/voxel_annotation/frontend.ts - -- Add initializeMap() and sendEdits() methods. -- Call sendEdits() from paintVoxel() (batch for brush). - -Pseudo-snippets around existing code: - -```ts -@registerSharedObjectOwner(VOX_CHUNK_SOURCE_RPC_ID) -export class VoxChunkSource extends BaseVolumeChunkSource { - // ...existing code... - - async initializeMap(opts: { - mapId?: string; - dataType?: number; // default DataType.UINT32 - chunkDataSize: [number, number, number]; - upperVoxelBound: [number, number, number]; - unit?: string; - scaleKey?: string; // optional explicit scale identifier - }) { - const resp = await (this as any).rpc!.invoke(VOX_MAP_INIT_RPC_ID, { - id: (this as any).rpcId, - ...opts, - }); - return resp; // { mapId, scaleKey } - } - - private pendingChunkEdits = new Map>(); - private editFlushHandle: number | undefined; - - private queueEdit(key: string, localIndex: number, value: number) { - let a = this.pendingChunkEdits.get(key); - if (!a) { - a = []; - this.pendingChunkEdits.set(key, a); - } - a.push([localIndex, value]); - if (this.editFlushHandle === undefined) { - this.editFlushHandle = self.setTimeout(() => this.flushEdits(), 16); - } - } - - private async flushEdits() { - const entries = Array.from(this.pendingChunkEdits.entries()); - this.pendingChunkEdits.clear(); - this.editFlushHandle = undefined; - const rpc = (this as any).rpc!; - for (const [key, edits] of entries) { - try { - await rpc.invoke(VOX_EDIT_APPLY_RPC_ID, { - id: (this as any).rpcId, - key, - edits, - }); - } catch (e) { - console.warn("Failed to apply voxel edits to worker", e); - } - } - } - - paintVoxel(voxel: Float32Array, value: number) { - const { key, localIndex } = this.computeChunkKeyAndIndex(voxel); - if (localIndex < 0) return; - this.overlay.applyEdit(key, localIndex, value); - // Existing CPU array merge + reupload - const chunk = this.chunks.get(key) as VolumeChunk | undefined; - if (chunk) { - const baseArray = this.getCpuArrayForChunk(chunk); - if (baseArray) { - this.overlay.mergeIntoChunkData(key, baseArray); - this.invalidateChunkUpload(chunk); - } - } - // NEW: forward to worker authoritative state. - this.queueEdit(key, localIndex, value); - this.chunkManager.chunkQueueManager.visibleChunksChanged.dispatch(); - } -} -``` - -#### Backend counterpart additions src/voxel_annotation/backend.ts - -- Maintain map state + IDB. -- Register RPCs: init and apply edits. -- Modify download() to source from voxels map/IDB instead of procedural. - -Pseudo-structure inside class VoxChunkSource: - -```ts -@registerSharedObject(VOX_CHUNK_SOURCE_RPC_ID) -export class VoxChunkSource extends BaseVolumeChunkSource { - private mapId: string = "default"; - private scaleKey = ""; - private voxels = new Map(); - private dirty = new Set(); - private dbPromise: Promise | null = null; - private saveTimer: number | undefined; - - constructor(rpc: RPC, options: any) { - super(rpc, options); - this.scaleKey = `${this.spec.chunkDataSize[0]}_${this.spec.chunkDataSize[1]}_${this.spec.chunkDataSize[2]}`; - // register RPCs - (this as any).rpc!.register(VOX_MAP_INIT_RPC_ID, ({ id, ...opts }: any) => - this.handleInit(opts), - ); - (this as any).rpc!.register( - VOX_EDIT_APPLY_RPC_ID, - ({ id, key, edits }: any) => this.handleApplyEdits(key, edits), - ); - } - - private async handleInit(opts: { - mapId?: string; - unit?: string; - dataType?: number; - chunkDataSize?: number[]; - upperVoxelBound?: number[]; - scaleKey?: string; - }) { - // adopt metadata - if (opts.scaleKey) this.scaleKey = opts.scaleKey; - if (opts.mapId) this.mapId = opts.mapId; - else this.mapId = crypto.randomUUID?.() ?? String(Date.now()); - // Open IDB and persist metadata row - const db = await this.getDb(); - await put(db, "maps", { - mapId: this.mapId, - dataType: this.spec.dataType, - chunkDataSize: Array.from(this.spec.chunkDataSize), - upperVoxelBound: Array.from(this.spec.upperVoxelBound ?? []), - unit: opts.unit ?? "", - scaleKey: this.scaleKey, - createdAt: Date.now(), - }); - return { mapId: this.mapId, scaleKey: this.scaleKey }; - } - - private async handleApplyEdits(key: string, edits: Array<[number, number]>) { - const arr = await this.getOrLoadChunk(key); - for (const [idx, val] of edits) { - if (idx >= 0 && idx < arr.length) arr[idx] = val >>> 0; - } - this.dirty.add(key); - this.scheduleSave(); - } - - async download(chunk: VolumeChunk, signal: AbortSignal): Promise { - if (signal.aborted) throw signal.reason ?? new Error("aborted"); - const origin = this.computeChunkBounds(chunk); // existing helper - const cds = chunk.chunkDataSize!; // clipped size - const [cx, cy, cz] = [ - Math.floor(origin[0] / this.spec.chunkDataSize[0]), - Math.floor(origin[1] / this.spec.chunkDataSize[1]), - Math.floor(origin[2] / this.spec.chunkDataSize[2]), - ]; - const key = `${this.scaleKey}/${cx},${cy},${cz}`; - const arr = await this.getOrLoadChunk(key, cds); - (chunk as any).data = arr; - } - - private async getOrLoadChunk( - key: string, - cdsMaybe?: Uint32Array, - ): Promise { - let arr = this.voxels.get(key); - if (arr) return arr; - // Try IDB - const db = await this.getDb(); - const buf = await get(db, "chunks", `${this.mapId}:${key}`); - if (buf instanceof ArrayBuffer) { - arr = new Uint32Array(buf); - this.voxels.set(key, arr); - return arr; - } - // allocate zero - const cds = cdsMaybe ?? (this.spec.chunkDataSize as Uint32Array); - let n = 1; - for (let i = 0; i < 3; ++i) n *= cds[i]; - arr = new Uint32Array(n); - this.voxels.set(key, arr); - return arr; - } - - private scheduleSave() { - if (this.saveTimer !== undefined) return; - this.saveTimer = setTimeout( - () => this.flushSaves(), - 750, - ) as unknown as number; - } - - private async flushSaves() { - const keys = Array.from(this.dirty); - if (keys.length === 0) { - this.saveTimer = undefined; - return; - } - this.dirty.clear(); - const db = await this.getDb(); - const tx = db.transaction("chunks", "readwrite"); - const store = tx.objectStore("chunks"); - for (const key of keys) { - const arr = this.voxels.get(key); - if (!arr) continue; - await reqAsPromise(store.put(arr.buffer, `${this.mapId}:${key}`)); - } - await txDone(tx); - this.saveTimer = undefined; - } - - // Helpers: IDB open and promisified ops (implementation omitted here for brevity) -} -``` - -You can swap the IDB bits for OPFS by writing to files under `/maps/${mapId}/${key}.bin` using FileSystemDirectoryHandle + FileSystemSyncAccessHandle (great for large data and atomic writes). The rest of the flow is identical. - ---- - -### UI/Layer wiring for initialization - -- src/layer/vox/index.ts already exposes a VoxSettingsTab with controls for scale/unit/bounds. Add an “Initialize Map” button that triggers map init after settings are applied. -- In buildOrRebuildVoxLayer() (lines 205–253), after creating DummyMultiscaleVolumeChunkSource and before adding the render layer, call something like: - -```ts -const sources2D = dummySource.getSources({} as any); -const base = sources2D[0][0]; -const source = base.chunkSource as any; // VoxChunkSource (frontend owner) -await source.initializeMap({ - dataType: dummySource.dataType, - chunkDataSize: Array.from(dummySource["cfgChunkDataSize"] ?? [64, 64, 64]), - upperVoxelBound: Array.from(this.voxUpperBound), - unit: this.voxScaleUnit, -}); -``` - -This ensures the worker knows the map identity and has persisted metadata before any edits. - ---- - -### How this mirrors the annotation system - -- Optimistic UI and buffering: frontend overlay mirrors “temporary chunk” approach from NOTES/annotation-chunk-source-and-sync.md lines 37–49, 84–96, 99–105. -- Commit requests: VOX_EDIT_APPLY_RPC_ID plays the role of ANNOTATION_COMMIT_UPDATE_RPC_ID (lines 28–61). We intentionally keep this simple (no per-id coalescing) because voxel edits are applied per chunk; batching per chunk provides similar debouncing semantics (spec §3 Tier 3 debounced writes). -- Backend counterpart object: Registered with the same shared id (VOX_CHUNK_SOURCE_RPC_ID) and receives RPCs for edits and map init (notes lines 22–31). - ---- - -### Offline persistence study - -- localStorage: Not available in Web Workers (and synchronous, low capacity). Not recommended. -- IndexedDB: Available in workers, supports large binary data and transactions. Good default. Write amplification is acceptable if batching edits. -- Cache Storage API: Good for HTTP response caching, less suited to mutable structured data per chunk. -- OPFS (Origin Private File System): Available in workers. Ideal for large persistent data, supports atomic, lock-free sync access in workers. Higher performance for heavy write loads than IDB in some browsers. Requires more code for directory/handle management, but is a strong option for “plus” offline feature. - -Recommendation: Start with IndexedDB for MVP; keep the persistence layer abstract so OPFS can be plugged in. - ---- - -### Edge cases and details - -- Chunk clipping: Neuroglancer chunks near the upper bound may be smaller. Persist the full logical chunk size and optionally store clipped size per record if needed; or keep the array sized to actual chunkDataSize and let the geometry handle clipping. -- DataType: MVP DataType.UINT32 (as already configured in dummy multiscale). Keep type in map metadata; if supporting multiple types later, convert appropriately on load/save. -- Multi-user future: Store a per-map generation and per-chunk version; define conflict policy (e.g., last-writer-wins or CRDT). For now, single-user writes. -- Save frequency: Tune debounce (e.g., 250–1000 ms) and a max batch size (e.g., 64 chunks per flush). On tab close, hook self.onclose to flush synchronously if possible. -- Loading existing maps: Allow passing mapId to initializeMap to reopen; otherwise create a new one. -- Networked backend (optional future): - - POST /maps to init; GET/PUT /maps/{id}/chunks/{key} to read/write chunks - - Worker download(): fetch if not in IDB (then cache in IDB). Edits: write-through to IDB then attempt PUT to server. Retry queue when offline. - ---- - -### Step-by-step implementation checklist - -1. Add new RPC ids in src/voxel_annotation/base.ts. -2. Frontend owner (src/voxel_annotation/frontend.ts): - -- Add initializeMap() invoking VOX_MAP_INIT_RPC_ID. -- Add batching queueEdit/flushEdits; call from paintVoxel(). - -3. Backend counterpart (src/voxel_annotation/backend.ts): - -- Add fields voxels Map, dirty Set, mapId, scaleKey. -- Register VOX_MAP_INIT_RPC_ID and VOX_EDIT_APPLY_RPC_ID handlers. -- Replace procedural download() body to use getOrLoadChunk() and return stored Uint32Array. -- Implement debounced flush to IDB (and IDB helpers). - -4. UI wiring (src/layer/vox/index.ts): - -- After creating DummyMultiscaleVolumeChunkSource and before adding render layer, call initializeMap() with the UI settings. -- Optionally add an explicit “Initialize Map” button in VoxSettingsTab to force re-init/reset. - -5. Optional: Add a small status notifier (VOX_SAVE_STATUS_RPC_ID) for “Saving…” progress. - ---- - -### Minimal changes by file (where to edit) - -- src/voxel_annotation/base.ts: define VOX_MAP_INIT_RPC_ID, VOX_EDIT_APPLY_RPC_ID, VOX_SAVE_STATUS_RPC_ID. -- src/voxel_annotation/frontend.ts: - - Add initializeMap() method to VoxChunkSource owner. - - Add batching RPC for edits (queueEdit/flushEdits) and call it from paintVoxel(). -- src/voxel_annotation/backend.ts: - - Add worker state (voxels, dirty, mapId, scaleKey) and IDB persistence, register RPC handlers, change download() to load from state/IDB. -- src/layer/vox/index.ts: - - In buildOrRebuildVoxLayer(), after DummyMultiscaleVolumeChunkSource creation, grab the base source’s VoxChunkSource and call initializeMap() with current settings. - - (Optional) add UI button to “Initialize/Reset Map”. - ---- - -### Summary - -- Keep the existing optimistic frontend overlay (fast UI). -- Make the worker the authoritative source of voxel data and persist it with a debounced saver. -- Add an initialization RPC to create/open a map with user-provided dimensions and scale. -- Use IndexedDB in the worker for persistence; OPFS is a strong future option. Avoid localStorage. -- The design mirrors the annotation system’s paired shared objects + RPC commit result loop, adapted for chunked voxel data. - -If you want, I can follow up with concrete IDB helper utilities (openDb, get, put, txDone, reqAsPromise) and exact code patches to each file to accelerate implementation. diff --git a/NOTES/first_drawing.png b/NOTES/first_drawing.png deleted file mode 100644 index edc622f85f9909711b893af36c9e03445842fa37..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 225375 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�|5xg~jYhiM^lLeMVpR%rufNi1Z5N z1h8-_kYdYTYYp?p;vc8NXSbRk_2k^*+~}b>ncFB+JmG(*P`yC_i9iN(P@*j^99_6B zXm>~Krhnj`>kx*jOTyNnn89Cby*yr7gD!x10fYE~T$sap2+4UGiDw>9J?Dw73hBkj zXi5o9eFLNpBA*Clw1G(^`Pk1HS!nO%>f?I3ZVwATzWZLtjIA}ypb#=cjw0J_!RE)- zBo#8lZaCLvM&d!PtpQe;qt--E-YWSjdVP1zbs`LP>k#11yVEc2h9#;FY6svO+@?tV zSZ6$V^5n@6BY=ovK0(uf3Tply`^x?TKBqdoVY?TlUhI0`n;~xwKY>@seTj9F*61?{ zp~SFI21#6Rl`Sb=D4vPbEl(;g-!Nc_44lDlQqVU`jv!@hA8k_(5JEF>9+^`7$fxTl z2McM2h$i9rImgmx?tS0y2$@KnCILa|gSRMq9Q;%dAJr@|6PcqgGSbr1V=&_4$Gv+J z6u7;S=`=B0Ri7Lqi|hKZMjn zi5Cm_iDN>b6o?L#>e8{vU}!=ZbqlD+1z{fCyD+)XwoZ;;R@vwbUsx^Wd~*&zxRIU3 zzVRq9Bl0*RTTO>r^UcAgnQEZ(f)_~j@sJ?v#e$Lnr&Cw39|?q-6Qm3O^5sL#&)fK4 z_lI6bF4x9`0tTj2ID@VIXxX#C+${gfd;ie0*GeLh!A)};#2`*nRIMYk&o)L{5`;OH z?YAynQf&&_-6%n$$9#>ue*7UX(yzZXj$wGE*>I@I$<;KzOfm*@DyX^NjacbH(N$7; z*?TZWCHtp^%E|G<)tM~dbEzURSiEIWv}au__257_ok0I$9#j3IkXh~5#`9~{-fvCZ zCnKf$N>k58oRHU0Jb8jsb!FXb9uq0fvix8cQZ=#;hUopcB6E#s4#I5DU()Jm+1)EX z+ud2pC_7ra&qfm|fc+uyt18=*R7O?d=lF})g81KrLN%1mb!H^~%OQ#JDu?c;f49ssS|ik2qVoC6dBwQZJT7;h`a8vj zvA!iL?I&`Let(>#*+!amyN>_T6C}wXs@!nc^dosMac)E!$=(t~uw(`a0kB zMj}qh@bkr`!kQg}oJ=h~^{X^?sW-TWi^5lZZl2k?4eD3!u`DU~7{w6A8b`~R7~{`h z?p&ccm))@1%<08#rDbo2NWNo0qn!=Dpqx$IY0HyZ+CT1QFE&6Iqwlf8sqxl*g{VAF_59G{V6CxP|C4x@WBK}P#GY}UYIB>%(z^r1ERNOV<7g@i zxOTYCmdNMfR^?j7J&{hITXUH^0yTvx4vktplu>hBS6{!iV0DU9136W9#>K;OM11ol zr-m8-&V;78=AdvP^=!<@FZk1W7k= zOg#t~Z%kv_<^53B%fFnuy| zcNy7X{g!Zh(%J(y`1aAGz44Is)wcDuG(u;J>-HRGgXiZ26BuAXlgGJ!gSp~2HkGow z0(PTnRU_Bn1oqpr>iE&b#G&3+@NhfXWi}_NH<7sNd>Oc+B>N01jjXM;xe*u4+O&kp zpFD*OMcpd(44We!PIZp()jZ3fs?}uHPFcJ^FAIK6~Pbo_+&DzX9>< z%1tNj(_J#E1}0in{|e%+$Vja=vxp$IiORi?gP9g&fmJB-R4>+7h!T8=2D;(oIi-{bFx?_fpG~}R6 z{8^mmW`;i4K#p5HfftLyM#&nbv0vPkU|v(VU?5-rC%mR^T9un$$*k&mC5V}gj& z*|zC<;iRsudj6VTPOfE7V#RUdTHx%rC&#?P?}&H$i;lHBJvzo1oO*7JW2OYR)!9*m zYa|{WTrY>XX@r!O96tN+demkWF;$~Jv9GP;k^Bt z#+6Ie#mQ~&!iBzubb3pCJ0kh9v#)tT!zs}>9@Wroy1Do0FmK!T#G7x^9;0{FFH3hk zK$A-sd7DNGx?^n!gsu4N&+1Ljc+#elX7t*j--Uv3)ANVDTCyfX2Gv`S6xYaKet%EO z5bZ2}S=HtiBgFgk&%E>OhA+>^2NqITicCp%tkVdeb@i$nv!EwdXy@Iyb<4Bgsqgm6 z&Di2%%p~4VdgtCi4%JLo*JbvOlu%D2Z{>>%bD?X<0q?=*c4lZ~)VeFaZ^-=VK_u(g zd&3V81yz2_)ialsbU=W#E>!}cwHKioGP7d*e151XloPeB)quWFVd+W{$yfkVOVH*l zg2N=j6uPBDb%aiiT1(|V82JItu4!so3-#}yF{0{$`UR3EW@hgZ^TF_vUDSZ4d6V0L zVc3XI+M1f(9#bj?o=l9sip!pi`&7oRrUq)r8E3KHYM$ZBEHr}XG-hIVmm4=Bx98}R~s?M(D?cGTdQ3H26viXOu#j^BXz_RyU=}*Vm&;H zdu5Xgg(1~YI6&(D5jj+H=^4ZQPkYPPmTI8e9CSElLrBBUKq_{Fh9ad)9>uPP8HvQY32AfH--z}M=TRAa!CWETIpnjJgBLuDy^;d=UgX^l;(>g^juRF~V- zEQM&oWcu??KS$H1(oiLTQKL%rUajI8@o<(+bYeGu=!96R?qEl1?5@#0C$q?`_74+I zPUo5`r=lewt>ROi7o~^^oIlz_c+Vcv_YKBS>fS(=^}Cax=&5{RaKw zq3d3^fw@oYcYH?AEU8-vfUEEyXAnD!jyN_ziI^F*fI}mlpwrKaOg!|Ihl#=%rXYO3 z-zgayvF*-j_(@EJ3Ok?r0)vC~@{eU2)6>@n=OVVvKg<201atYFPb#ossNNT|J^4;= zxH9&>*n(W=a`!c4?}mK!jSBiE%APa68fZXa4~c?|@RQG41vNTzE?Omx{a%xnnyVCJ ze4AxV$P{CM-5hCRQ81ac1)`*nGP(7oGfKOQXQoak0lMSRo{MAIleum7!Ds`etS^yf zLE(h+-*7DmqgxOpWruI~GJ1sb`M?+hgm%tBB>UYOxMSf?u}r{&;ZIV}c)CD94w}B2 zHMsdv;P3W#>i0vMCWxjFQdT7^pvO=*sspsANrC!>Hw3}gXpq0HJykj>5XMsw5M1Cd zLY81TSf#iPjP3oX0y5(k$X8(#_W>!|mcu?mkzNp>CAbTIuvi|h$kNUO%xmv^gdfkP zOJ4iGHlcf7H#CPMx|zFwJ;HYj1V;2wtp~&C;_5cnn@wU4=cQBI;c7=i*eY)&Y97u)t%<03w5fpv*qP<@Z1^5vm8jc@qk zC<|UirQyn@Em{em(J$Oj^KISKqeDttN+yb{CECXQ&=!ODxApUegM}@HflyrmL_MQ= z_k+udmZFF6oa<$2-qt5K1ZTPl_#8_}wpt}!@KqzH8CxWdXdHk$V$ZEzvpy#b>`feT5U+y1jRF^_E?l#IY0@cn@RjK6h1tC%QyVhRMtX3fkakl?tkoGz3g)kAFjCAnB zhFNvH0vKvQ%kLRU3 zJMgy}j>>$73JK01lVL!1-Q^$=qHzAN>;%9zY~tD@FKpFGD%nRyJ|89t`I$z+1q=H3 zIgn;I5d1;bfRonvfc+kv74`G^1tjVSPSEMueCVwv)lf0IwJW7rt%mgc`jpgg?MNL4 zc5G5NKbf%K!2sFnRYJKED^deveL-OvX?a4P?3vsnj}Z1F4-;})YzT2eIYG^pcAqzi zJbBSA7LQ~V(`NADc62#hCaeV-ylvh=G_l!IJ{c2uJinQ6WqRCAz!Jn(_oVcJP- z=1rQQ;sg&8{V~%y z(2^<1{fQVD6@Y>_4H~Dx<>-bNw?a0$$~6F2-1WTG6by<7LGl6B6vpM&PH;}|LGL`j z@gR;>%pbV@xoo5ont;+edP!00WQ;fk7j=AOq~j)?9@^*QUnE2g0^QQib_ zM+E=ddMLby*oV{^4%EZtuelS?SQ#?-_}%we?b*`lNmVjPqX?WL(Z zVAgT%G>GouOq**ZC-t=zu|3DHktp=k(p68dA0jpPJ0J;xCXDqlv9Vwu76!4e{#27mq}}@DcXw}C z8PH$5@HN*RgR*LeBWz)OEd0=zp5wO{3)s~j{$6R)eD!`z4kpK{e}VzU`~2IxIDyH$ zvTUp4{sU^?G|=zn?+qSzVQ?+XZC1%fBgz*%S`DRNnQ9j!|M54_$%{$&)#YZ|i!vqL zYeOPal$2lIe-b&6k&|$qr)|8A+SeSh5I!-B?~K}pD6CcbV%WPpTq-xk<8Fet?H+9A z+SBsXo>&9heuBza>D=7Qq`0dq2Z`oGg)}ZfI+&QNWnD5*D+0A;4@j+1!O{-B4#BrG zoKv`D=MxCi`-7GwGlItp?AM|EY7b6)h0GoxnHRs+{n<8YFKFGoqnMT!C+_WD-eI&-Xw@)mRPOf|Z6bv^6L zz123iDPivO1tZXBd#wlx?eWk78Z=(m?}zA~w0jN-D7B6}-zbk&Y4OAobb(-q4W-Uk z*RV=HuVt^9)BiCH(r88-l6#nSD{5OUXz(>DH^46xgD>fs9uh6zEk|)so-GAA2FR=x zkdbiCfPuSn#JXf@4k+s8NIofcLnxa*&MkE_=?f6)fR+m4?NUf&Jn4Pm@F#6%UIfy? zM|vToq17Fd;l{m5-d69RrFdQ)q*%?(uflKMkG$%;-z-p?73U)S6UsO;rqM4lO^E6r zCIst!4PVXYV4N!|VpZfbwAgyFfl)z!o8SGAX5SP$U(%?@^R4?TwM2F_-)_b|z8wAA z*w`-D9L!K5t_#c zdTz0L9mnrv{>GchZr0Px18ENaC!96Ez}}M7e{kV*A^qShhfiRM@g&&^ba@~j8%;XL z4r%4B_x`ot{!l>=0c>gUPFFkf5;=`D6>odfvh-7aK%|9&d8zqv>C2Y_j1gsL!_dfZ zJ013?ykDg4hR>y<5D{hKBHnTgKa%vhEsf)7g3f3?)~lFl^cJ*Zi>{mqod2TBovm+i ztg(CnUwWD%A_Ao9*c^HcsQ6IoJN+fTXG0nTNiFAF@4woZhKALhIk50w+8M7K4iHL{ z*paD@LRG27c`rJ3CDc#n<(?l%K5Z% zO?s@4dL=s8^#@Bu=oe{Mr8yqzNKuWAo%C=esBbJ1Du_`tkuzXBCvDvPE?;l%qeFG} zSGqss1wC0H9-~mFtbBtV@s7T)`_Y%X>MXL4uwgjG$)Uq2HS6U9nvAMbk#i|@7oSZskYp+RkW7hBp ztocIjtSo|aZ##OKGKl);#?YjdPoSQ?BV>LcK+UP0=+n`#l;&McS$Khy|4?~6HEyHa zy4u$hfBhZd^X6ino`zR96fKcqnNOR@`AmelPA9I<7nI*R?6aZgIq~)p%8I;po7>b` znE~s$D2SDQbIVH+jkdN<)}?yg4E4O;<)Dgj zac9eV!};?}iDxMXL(yVW4Y9Wc-!n{8^_2s9HZdFZt1!HY>q5s1le`HrWWRsPYun)* zbN0of!a0pvBGqiEL@?NRWrJn^Mnzt87nivMYI6*xbP_U5!wvsO$!k zxN4_Cbsa|)Ikxv~nW=HZ?U(tH{IkqX$&&6yo!_RP8bwRLx5qyAlok)pSHutD-A)2m-n0F6aS-r;ea|0E!Kzw6Tv+s+KhWC(OXYRtG7Sr-Q z2t#)GyALt2awuBf z>2RK6t({-EK3pm#TbTG@V%u6!wI;*dLoYm0pZ%V$fO;7oZ)E@MmzS1S@6wUlFvr9Ay&!IPB|L0S3se4bFa9q#cUgY&?#tu1Dr(x+L);!-iUc+&(~ z0&X=+I_|vJ$7yhwKArI>wVAP?988i77lvlMkP}XeaW!~iANLb78g5D3WouW=&$)h3 ze2H*U^k-tm7GH|wiFuZKE(rM;Xh>3q=~N>1n*^gz31sw5@ZD1NT)9dsjg8}ZvHaQA zey@?gxzu{?F0GKiLYRs}&7LRiWi`9m4I*ac=JxnWWCjG!M=5d!%P>SfTB(G+T`Oe9 zfy4m*edtxD3`6agL)%{ts)L$oGnQ(9T+_DhELLvPW-b`R=sBc48fUiN|RCQcjpjv}~&kS7< zT1*z(Rz8T&(dG(IJZF#XVvHGLWMHFpp7PJ;=ecOs0E-lpM zX0wp6(hm8>+g8f602xNjWRxm^eNo>-uW6L&o%b|KE{2?dlKKzLxj4%y)YL>G@}zk?9=#6oQ9~o zkYS0yQB--JC*7ewn{Zv#VSmAVZg-8)79pdeA)|~g_mu|Hl8p@vAgDun4pVf~IZTQ& z{+@9UpU}P7dS88d(n>#9q!IaS$N~A%bnu3YCjMhw*X&kDu;cv8sYzO^hEn=5b5gIR zUnQ}>IES6?4adm~SovItGSqLa-4d$R)c%prXByZn5bA1DQ4%%vYWyN*IVks-l``+g zu!g+TEx6%IhA;*BC-)NN)9i+SNNM;#%hKlRT7##Vff*_&spGz$BBv4J(lXJ&!v6-- znt^M)F_npHs938RHan^@Ge96^Nho7AB&~1&@W-k z?V=mf9^a(LF^Ns#*)l%XlS;@8UgOebbM0#s(rAt}HEA!(dU=?GK7?D(dj8?aikEbY z;%m<$n&Ut+Vql*A4;5|U%%5j-ILYxe&#<028%qlHi-kujGtYa@`NmoCxSGV%#p38$ zUp#qQ;cALJ!Rn=C*)~T$i>E703`@va60?7~Y8234w1B_1BT?CLsPD{lpA)CGyc?m< zL^OFdQU)V%JZWb_ES$&D&Gf;ORf9egm$Fsz9&23!swbU(;OYymOX8Sqm~T8hJggaj z7q)p~yV!t6xGWB9GDG}V;}yGi1ey)kaEl1~2l|y36kl_=aH*X?r${2@ZVSO6>tz#( zZR`2rc)mBGAHy=gPtd&{7ek#rR%EQWFn~aqdU6fkSGRM!3~d1liZi-oGGTe<7hvZ5 zut|LN$$a%v4a{BAidL!0Y2Mcwo_$jv`x65mLy&qP`;WOk5%1}-j_IFdl=XTPh%zO* zn&w65GgRfga*gX?8{k;jmIPB8T@J8|N=s#wBD z-t;ZEzfki;>?7)wK|Viu!--GdZL6lfjt5eRfAoKBmUJz%Bg#m>D+v9VRrnJ5`KTc$ z`a#P!Pds2yLuuxC_qWne{~uG=0Znxu_m!j&DSKvQlvOU-l9h~W3z4mB zlT9gQW|Zu4W!~&9D~0UcHA6zS>>2TXZ_o3*?|aWZ(s2&|`R#9E`G#Ea}-08!L-oupP(!OjJZ!rcp-dmP0%}imUo@IwA z$<4I%S2-_TNcA(;A`~!J2w!?~8!-VLbQCyrnPxhTHOJrqXt+v}O>goE*Jy zpL6TrICwNyF!+qwyN=l>I2u%hKHYpaV9MO6~uKm-1Qq&v?+=NR@5Qlvlx;GH<%8Dt|3{7bM;^OEh~bFFSm2 zlTyYJ(hAFs!KLgR_D5xxD5)EX;XvM!=!v)5v7=ECd27Hy7v5o>q{5MrHu{>UGq=5e zZop2J4Gy@<;In0msD2m8?Q-9b{?M_~?zk05u z-94n^XibH#WWn<9xuJrP_nkc{au)L#jD{EvQS>a6o6hD&q?!5p@nQJjzs3s@{0uw)YW-aPq(8Hi=2*fY~T%s}Z7QhA>#6x8G*LC;InH8QGH%w~0BGc) z$sHT5g|t@L{R`0SV|bqSACQ$&e$M?ti9dB!Zq3Bkthyy7(E(brOck9$P*RqBlFm;R zupWL_3h2pK36k!VQMhm^LJ3|$k~j9l?ou;G#I8HhU%g7OR%3JmxKy-(C5qQ_fRbq3 zU&3}^3JUdd=)%{&O1TuOoekKIwqi4?L?Ncg&LCp(-r?(3XbPG@@({V72ke497Wbtk zc>~pW%I$|E01TjZR{{DaNroR*OCMv}tl9-fWr@9PygoNC9zUPNsQ^FH{rrzKFw6=0 z8%zDddD^Ef2=eATENzUj{G6<#F{Ne${(*7b*|Ulk>akAe$DALuzR}7x;@)Qa7-@f* zUP<-xowr*#_LIF1fP7)_PGrZgUtMp~${DyZR;eMNpA;Nwb_0;&Z0Nhidv-dSjklWr zMA;PDs&TxHrBLSWJx?2vFytml7oL5T*7rx#L3rSSEU$NGcZWW6oqS<22TFkL%{l(- z-id?-x{}=%#OOH!HX6?qfEc7vHB;68^_3B6i_Ii4XA=nML&;*v`f)?=(;((m>x|o|me-4(qxmk`PTrU01U1GF4 zIyjifyyOn|Bmdw~$Vgwkng!;XTx>IXzPd24N@Mhy;-9pOxdu!Ll2T@Vs#puo<^5n{ zgsR|!BGso;qL#zhd!2A8Bo;#SXvXy=g=PpVWV9*%7UFbq<{|9VUjv*+R8Zn(5_Q43 zlhPOCG&m-KV^Ze}rE9RGoPnY@lc1GK3mk0^a+GkzB4`Nnf8751^8967GxO{Uss18y$15nbVyO@~J93w8yBq z4PsCCZb|W9`~8p{Yntftf%sJ@QM!icC7{GLo81r%NqUuHdD6+GPeHKd)P7B0V|OL7 z*#@+2gFnwT&GMgZ^x_UQT`#zU+;ZpZCS6`s1MQEXEz-b~kN)S-x_~peE#6rd?7IfI z9mrI8%u5NCz9m-iq2I#^zT;Z{?#c(Wn#Y(lHDH)aaUTLfP1meG={{x;VwN~1VQ5zb zKl<7BX3}@78#+#LJo+xs{oa%U42t(m_^orJK>8N5mB1E%S;>~|P zxxZf;9?p5GuAeyY`MLXw3hY;}85(up`n1Wi@+!t1N}yww;yu}h!h$IU} zw${TUfI-wF%x@+Z`%QqPXD)sA!|95#sg$o|V=-^}w1%+hCx3$~QWD${ z`K*0}TDZ^eMNCocY7GeIfV8gHzRyhN%eyj&5BZU;k|>hbJ`S!46WoW^oxZxe2RL4z!ipuCA=D?UTvE8 z!!`5rx1P3$$8EI_PDXJbE1|%ybuFfYTtZcI$Mv^a8PM7DOF^YU75Qput-HW*SP6Ok zunldH+_Oo9IS~ z+*M7i#IWjq%&>z~gcNJ294oO|$a-;^)Mh;W4tjxT=i2e87Di z!5$g`&^cC+``cEZot@1_fvkxs67qRaVZ`;vVC9CC$TqRS7r z{nt>6y?H4Ftku#r_;Gre@N&Yt;#$UF&2m>?_nLN8{s8gYhppFM4cIF(f8>qVghbU+ zKh%!0f%LTMa14>z@RnVxDeAkYw}J6fkHxPZaeS)G3nDJo03nJ#z2p?`RipNRE*{WrQU5>`Odf61ErD(kPy|B<;X^_!YX_o8asz-8f$!JvKs7SFOZC>om&w>y{DTQ_ zDINdhl;*y6?b4bC6Eqm%(D8+qZwCubCmwnzIPQ4Xzl9>abLNTJ=}G!A9rvPz-a`#+ zyWBo$?~PW>D7j&5bMq$%_*l~c{v`W!U&i5~>dD1MJ)21ZlHt<3Sh;&U_5E0XGTrd&WPamY`+db_kAM8PGGX3+Qb3dU+6rTN;D# z1L(PFWG|tz@}_I5p0rlFq5Qwtd5b+sNb@4cj>*nkq1x=nF7mQmRn^}H(89stvvpin zdHZo%35lBo)BLAdXnh~q7JYOFDI`EuWCCd)I(@Vti4A>UXx?~WRc{#qMyUUhD}aWe z|5NC@599d71$us8Hlfc7L~dS?Yw1oF-GU1pq$!epagz8za=Z94NUs?E~@( zhY5rFWQ&{a=6ss!T<@}nbfP5M3t(I+?{rod-F0H8lWisd(~TuJfxj&s?u)&$Zvj@%1cVk5ALa9wM- zwT8Y~fjR-)YnyaPCG1Z(msHpae$SNido6vCRwu8d2n*K+SoUyzG{;U)n|a}EqF}lp{E8ciQsIz? zKC1*$lD7tkW8=;^h&lUD++DsM$td12)+`ZZHZ4a^e+NXHL`T{3>jYPEaW;Y2vwLm%l&Q8@6k3b91w#19SV09z0m* ztq_H(N`nU#_G7V@w1H_qt+*!n+PuHLxmV47SQmT;`KshD^DH6qx-7*Iwk|QlRgCIY z2|7ZKwR)%ACG>W_d$vWS-IfyW^i+*i=>0FVw?WJbo^gz8#-`7Rt zZborr?eD#-TXeAqQZ3`*3`G#j=9Jz@2$mR*nOC#3@iqV{4FkuCdvc&O?i>9;FLPOp z(|OA&1d_DA5pm*;jszw^#lQKM9>lPkCTFC@&6K@H~tj<0ZGk$Nf(J%qKrqlXW})ti>D%=oH?2 zMRjAkL^{zAY0CNeV)dHW2oo}+M-}BdzHDz=v+#}(&RS#Ie`MaO&CC5ccp6oDg{b>= zM)YV~s%CH-se4YqwqUO;sVIBv^UVkAaumehb4haOj~23gFKzitzqRuj&=5-Ly}7K` zG8mScV)Of=gz~lhrm5g)4_YZ<#j{yogVu{8A8)JEewADAPe)`)@XV$(k1NvFW+uy{ zbZMvjllL-C$33*Py_cPZcrFuY^@7kw6lp_P)T46OtC$Fq}=v_(5yVXNmQbfo;) zOXp|u&5jJCdWJV|I@&%VFnzL!JzM{qk2*>^Dz1~69Nf>+w?xi9)z*!Xrma31=kjDtga|Fml%I}uT6s?J{>onC3b`(&GVor0RT z;vTvHDT-~;%eV@-?Q)YOP3qqTstW1D6#%b5%M+j*=Kg1=T*CRPqSrnKN-i=NFqo9S zTMV?E8gh$&tB^0OOx^=n2;Ia#5ON-L3F8IQWD@@?oS9gVXtwR!n?VMiY{f%mPDpn= z2k@3Q8=d!>^1+>~E=G||zHL*s!%txc2vBku9HY5`nxQu^;%y*2C%-(roTva9N1jVb z8N)^cF?fmqW?-_Hd-APizD=A)xeHvjcbfE(%QZcdDi(H}?=T^EiO|_U{58ew6SMbY z@1XRnPzN%sd;)OPj(dxF7~!6*4}m%xNcU{+)m?M4$AJG?>->kv@yt$wF|ny-tIjF- zjAd@phD2{E&*-2PqOPV8KsM^?n?%(NZ=V*M>Bll$D$VxsFvoIKb2kZ$e7N?h=3RCg zM(OCv+L$92%mHxt$Hc~hknkMsP(@ipC#fF#aTFOjTayo6V1vd$-5ctWsn97(Kyw73 zV~cJXt|JZ#_0I%f$EO^^b~w%UVM|~2Dk2H%Maa=&$_!8x!Znq5k}Pp-4prH|!3CU* z+&p7*ewzxzZuXt`45c5Q<9Ms2aogx|m1UMDxBSJwjMymR=zbQCOXsgXy1O^Ee(U)P z0_N3ztkFQ2SCUuvLX&Z2mvLp7o;;;s#z8P|tYy1y;+?C5AgPeeovQ7c4J9g+0U)X* z+w(C(;r>~D7xFN(7H|as9?08(DLGMNQ(OTGCpU^|F$=i5Vd71oTOjyMY5%hNaOL`t z))_cl&JTGQ#Ysd;tkr(iwDfCEg+`U`$fm+>*u9qkt1C?i2ic)_Jc*$9as30cdxgv4 z)OmW;rzI5hLO(Upy(zJYDY4OMSQ`APr_jl$E+ORub*UbVCDpJEzOqu)j6ho;Z$Il@xj#e3q|G@mP(% zB}^{K+oN6OV3entWMKbIuD&V`<2!)pb~CC@en4w@by;@YS>l=9N83610R+@;WQW~) z-2%`K03Pc8LqmktH7lYQ9%WN!VIm>R5gKh;r!KwfqphcR8Miv~@1Bs`k49;7CyK?W zNhPdSnV-0ye};egZ(9&b)FEaXf>Z<* z>@NRa69fH2-0M9w6p>%CzGlaM@VH_}`{v+e(jK`LTbsN8q*rmb5tlqzreTPQiQ##- z0hQf=Ww(zO&p3k|mPHb~0I7ECY?G`FZwi1GMB@5DwrTL+#=)X=cn*b;8)bzf5=hpH zj1_*h|5m*54udg8bzhwsDx4C7fie2nKFNP5CVW;;D7WUga7iEw8pSeZSW&m=MvVv_76*&>gyt}(C3V< z=VJZ@+NzY_oUv5hyLr_|y_mrei*U!X6F`bd>{5mD(kH1;;vSWyWr(_mnSGn?3IWXL z=Q6*G%x@~?P;B@zef7a9mf&6@cgYphH`4NLg;Zxuh(^B?-RjWQR!N|Nq96tF=0v*F zxNjS1ZqVr>JNfP>=K&aca|rw9$9&V-A#9pTj050t8w01!sIX>t1B~N7k@Z28jIB$0 zU01#BTI`yb`2$rgldHT~enoE)0s0K~AM98jBInT*ApOCcs==2+MIWOt1~rdoR?ibL z|CK}uDOb3g5g|g&1r3dZ%%q|({U5X6jJ0%bDXHtf@T)yH&Ni#L1=5Gt58d)q!rvFi zc^eFUfz({CT#!aPWkU|4LZ;|W7*EUlIzYSSEk!S1QL5e!KcX7I8h`ipRzz`E zJIF3BIxV{HpPn_gQi@_h)|Iv{($zwq&$%~Z9`SK)K z%e#UA`8Y}Zp0bw?dAH~)Y3~*!Ia2{g%9vRVN^b*qtXDYBE1@s?*?%z`L!Bp!tZGyr zML;2t!!P7SEjGkw(~|aa@$h8!@dU8$W#s1CtnIkIdsZVX$tcpNH>jhdV`dJm4pxOd zW8_La>hsBC7#U2#7gt0Qr|nnG1=lBIQ5$}`g;ECM0!6L zzXeZ(E@bNX5C{7?f)j7X2tEeL*Jnw$H4$2HrFd`pJvw*;x=k@&Kq^aFV{ z)$_}X>*FtkybWxGAD0G|KjGhTtum|0CYjtq^jq1tGo3z|;@n0^b>QmG_3z-`4OC3H z{m*his4}#cFt$dA70-LEoT1EnPp^QWQ&kcZrwhN9n|QSMydyJM4rJEZsXt!QfD8xf z_n^pCuu}JFyZo6}ZhO(!34U(_ZehI^0DGGw!h`FHIegUBB!AU7{MjxS@sWbcAOa3* zJ|#phn9=cHZ+z}-I+e<$bJ zi|E9}M~k0`?6N*EK0#c)WM=M3B&UkKui9Z|5WU@Tj8iSTx&rp%Aq%f%NzZ?%p4=%{ zLC`@XhdgrFwdnl9BAt053IoTQ+{4xHyldRM*8D3q4hoV+wu|LaUaXggu$A`K`3)GJ z%XT(y!`SC|wx}RGkH1az?Hi`8{|p8mA*aydSC*N4+eCWT{F3`n#~fBw(ivn|&2G)R z^Qa>?amNhui<=2Q&!2}pv-g%g2sYA-0(KIMak%H5c$EI*z9~ylXs08qw}IVJBxv2& zJ#3yamHf4Ly|3i#Ft$S#_OWh}MI@}?m2Cd1AN~MswqC~hKJ*RT?)Vq}_QS-Yqc5#d z-KR19Ug&F~N1n&Bg}J(}x##f6i^sLivO)bEZV8~#7>h^5R7Dmn6 zK;c0SW&Z8n@zjs;)EYLjx%{#8+H`xpK4zvY{7-a@RFBu)C#uVydW${aPi<1wTD6Gm zqg)KsU`w=B-hGc6b%W~S_s4zjWaJRw$Fx3*JL9qgl{lYNe>%kb+|ZqVa?bHESDBjV zBfYK^6PM#S@t<6(Y>;o>ycTzK?&r2F>%F_u`Z@->J?Xxev^b7Nt8t8SkZ~)zHmGoq zCG4D-#q`(SyCLs!G>d9rM2eEy0pB4)ic2=v*QD*E_kwlmpevfI6|Lr^bKYM2@b&E5 zOXg=ibV~`EQsYY9+B>)V@4h2gv&_=HTRjsU>E6L6_g&x1v#NnG(uK+r`09#7r8yFP zrE7I_RWm-6!Z#5bx;}jRKm(^#MFvks&AwO|SV%Ko}K zbFb(_m9XSq5y7h5`fooyKc8{b{H9@ijo9BKd6V_sxN`@wMg*b#s^a4mre7VicA~Qs zUOHYzQ5xv#>QoLqk%*a^!p;1yeSgP9E0Km5%#Nnd^SVA!hsfR6e8o9@i~rMj`&RHH z!zAG;!BPxII$MMYt6}fsg++-})Xot0=9@>+jmY*t>6kK-#t?aVx$W82iFXL{-QYBg5w&7iO^?N>m8mf!Kh5S0K}_|610 zk&3H)|1P(UfR8lNUHOY63qx=MN}J>wdAdor>@K!e0^EPdA`-U3^Un4hOj(Rt5Luj} zerGhW+W*~YHX~+LTeBB+xBU7Dmtr!$G1xxs0Skn>9JKZy>5G|>j;gB(^a*uP=8Ij z<&Y`ij{$mltL%uX78NemU;T>mvfE0f(14~=5KvwHK7Nt@1^OS@azGhJ_U;31GtdWg zUh!7=P2S@k4_<_9F7b{gwe!wguUQi0_!6zUIoI+Rl>6^^`T&b-VTCaJ4;KMnyJg+b z*Q{AN5c>QvgKypC{zirliR>&)XjLugHQg;iQ@O4}<>h_)9p9^;F)(dGoD$=Q7GyUn z-4ZD@78VUA5SSrPO>u}70!@LyW$54zJG7<{@L(rI9=JZ$$lm`ODH|16k-G1Qn0Gc* zzS*d22?KRT=NGIqetCW8L`ue3)76^Nq`0sAu+-Oi6%IA%@vAp9T8{^@qjF`YG~>P6 zPK9mbiW(F1@|kstx1C#$Vs7h?RSvxK7Hf5-qunl_KcDo#wlru&=J-2nK88S|6~A^7 z7b9xa<)RGOFI5A@*Jv0j`c$|fI{aTE_-C0`^QJ&(@2R}Ib|&5KE#vTPJ09g>m-upx zgMO@tda#~;b>NPxuwenazDNH1AC{v=M8x_U8ZvT$sB41q))vgmsOr@IyD$CGH6<@F zw!bP>T0@DXQ1Y!2rjoqV%|Hz(WPXI+Z~?)tHtHzx(F`>Q1MJSwSN3FRYn9p?s-U0? zN*u*O$j8N11q_pY@?+o_h{*Q_G%EKye zgJHxiE=erujj9_l_ewIUYSc4j7itQt3`T>7WRacGA8a{em7nx-?S0D44XxFJk>jVbj$?7d~RQrUf{juIAe7fIaycJHM;yr{~vQ^o`m1?kVzc#%~*AdJ*;mO!$3>MPs^@L%CjY#kdaoqMCOT ziKEiuTbZ$kd3x~qp&ZEUs`?6~$@WgS^f^mRYKPJ-f!!TxrZ5Rxe!OJwq31V*rE&`? zS1VBr_c98L=p6gNzn3BXGv1CgC%APcU0Ao6Y`eU08OnH{x9Z+UMy)5gahoDCXzR8Lwksd=#5Ko8+)t8Mz||L;uXfT`_u?Vsha8k9gxt0mGiuK% zJsBkUg|5#yd|x{hNSya~Z+||P-};3%Xvtd+EgSP4(5RCqUYrO_TVz?;lT+x?qZY^i*=45=hzRV)vbaEj%AibWKn?9%BSjpc^j0LIfCLU>eF z`1nicUH$eqD{l;uofoe0E(E3p-I3>tUujUaJlcFw8mIP(6bIxbMiMJ&gQqX}T)Z(d ze()f9=IIBX&eD8fIN|6IGdK&V+fABVggJ`C{_XrRe%0xT!Bl)>g44S~zYOS)T1)mc z3Gz+=^=#yyOJdP$NjT+(|vP3z%d|JFiF8e>Gg z#lZs0ucF%bO`$>?Oh&|{i!@IhO$Qe~6|$wLofLI{nRHdkt{Y5>;_e*NHT^UHhrUND zYp|P54E?)on%U#q8sGaG`_1f$LH$p&XWzBE%n$I&xwbCwf%U9%=+3ra*@v~Yr+>%y zS-wTSU3aC?R(S4oV!&++x6TheNn(S<*S>{u=H{U}l$0FT`kr5}g$={~WGGW29Hc@S zKyuDK;3M1~Lq9z)PCh6z@6N~2^NzCB$j~sh_A*^Ib(qHzwL3H z1r?i)EbLSUKn>cdmm%;_Wxte+P?dXt1OeE>2LuOu`SxS?wuNzPYGj~}(aXJO_tSXa zRo!>?<5o{9UH2-9Yz+hC<*xFEeK>ahnZX}7`DY;3-202v)6MrWwwL#$KDzbsD}OgE zUhc_B3VF0}V*7aJ$@2V@3ZIp}ZT^r4aS?0!p1oK@tL}->JRiH}6NbZHzdu#g>t=svb%-3jY#J#rkChPD1BE#PI7%!Hs z%-~BPokaf_8A`Jd##1V|h>|fiO*5tOU4x1fz>HXP3k&CTrt-NdxO;5G4RJv%s6)&- z&Q}o%`+nfNVRpM#R!qLeHTYeVx3s_HD-4S9cjk z-_cK3boKd#aF;Z^mFr*Xf+v(Ss`vh>Y8;X`>3wui@3nj}SWLP74Lx6yAMN&C3c5HB z;^H1dh=QG1UfSNa(kRv0`eCfYZae#6lO(@{z9rg0OtCZC2xs zeQ)-PRqGTV$Z@T)>g91TAhXPQoO(IrOi_zj$w}|`q*f)3lvjt2Iy*NH>pR7G@L3y4 zI9h(uRfkNIK8il)@JxB&700bxvnL+8nfCkAw6D^z+_dPzDCefJk~a<%4@4YK1f-=C7Jn(>eQqZE?JVU{FL*6)+^LlrNV@hML{p2u}Dz-r8 zcvQqw;`Q4pl+B_yvLJWS7n8ct`9eB13K2k>`D%tRugqX@-(YWV08`u+hGb3={ zZ!=?CTFNls)nTOjgPn&DgQU0<5W@Aj94_V85J#o_zrX7nWh5q#?8GL-&5oX2047V` z?;lz*WKue!R`Gs=YeZXRb!8{>>XWr>!Kc9bCU*P&hpO~7)bmlc`r+(daX(C>-jWupo%Yz`;J9LUHO zVKQ8?#zk>tyL*R!vNL}&9O*avgv>TmJ>|QNd#FE(^JDS3i+dAMCw;#NxI>8W*jqY3 zCB#v`C-bYs{0jeiwn(vNhBM`$GWNUpO2-x!@15lP!@88U;0993p85Seu9j@Z?f}d_ z|B(z4Ue`y~<$*UFco*m2MiwS{8arRYTao^@;pU|%`&e7Llr8vjDEWbu3{rYsd-erV zXWY-Wfegb=wmJecfO{L(F_9f#9-g+pIq>f5 zecNB}CF(&(?>8BKGN>&v{gtY~>y`7{Nxm(s?BhddO6P7mo(M{$FOAjPHM#q<*f*F@ z9xVmd9Xh@#!%BABFB!dx*gbiES#E{a>wuc)ywK)ntr%uI+XEBBGAtiV8Ez?kZYpO? z;)!V29pn)k8yhS6tcXs})TIc>^2U2sa9!`)5{fB$019JN^CLyxi7cHi=1-;YG7~TB z2x^UjA<-$%FeaZgOhz*Gn^wyj>1AiP?Bmz$^+K(&=82EfLA+|0tmPW0>6U2Jt zi|v$2+T*P+5nd>I+V^MR)fs12wkp)wMby7%MW_z&dOLI}m!t3cgDPJH4c4Qrw9ccO zj#{#t-;B1Iwd@0aHb%d?zwP*qfj&z06~p+S|1%i^|nW&(PoX)8$45UF#rpKNvVv+?pHXpnV zqGpYkQT6&PTY>hrPx`TH36lBeOeYevb8qlWz9A{`#WiI-jgJN+R7umB#Kk%XCCX9J7nvvBD0+Z(J*tFEqVmbZ=vfDg-OtF-<(H{k1++;dlzUC3BAPIrt~Rf!-xctnNp zR*cF5ixx98Cw34>H#Z?X3YvPFvvSC-fw`Y1EET0udp#pYhCbqW>|a_HGce{+p5NR* zzQY{EQWiGew%RSGjlXKZ>?SbhT}i9N0}}Mi%)mSGiI~2*lXayC%FGl5AB8QC{$3v> ziK%gak1$v%Z_gp3LiPL8pJ-DAd~j)PjnrEi0$;X_M)CC)dbI;d?_A8m7vjWhC=io$ zf*6L~AG`(BQ>l-EbrJ$|+6Xtyvc0|i{jgV&kum61)dfVbcVf(_;MuV+eI#*ROltTS zoxWl#N$HY^f|@F-tMOh_o-?UH*P!Gz;?cT1^KVmk`^0F9hF^W6{M;^m&U16iyM>P+dr2S0aBrM`*PgU$-nR+1>H<)2F&R zhE6~uS}zmcMwZFicJ;Qds_PKzbBl#_aY}vid$&{W#m?tE`gyjomLSU5$ffGl${Gc!|Fy%4LPZsTE=j(NU?4nH`k zlIo_@>CUk!8upo9Kxw%X^SM90BIq+B+?@JrCL!T|TT#&m`_~QQ+Z(q(52+f8SEv=f z=OU^>QH&;LyScjl2ybwZucW@3$pOy;)-Y+fe{t_=_D1ur+il38a}Tl!Z0&dw#f9Cb z;!cj{@s?4#yWStcA>_WAvhrh*FX%-Si@~@qa%-7>bF1XfKqyi1xzPyqb9i-kF2$gG zU(r07MqxgV@YRfJ&Gg(aqE3+f%_P?CIPDmf0rX^=uLBnLnPYt@QyQ^b=In*0ui!w1 zme$GPpX0mi{#a3>pxdScxEcF=J23Ho7a76aW%01PHhDiDcj$*DT&xEj4mdHM8^coa zFx2^l*nhNL{*KLl2QevcF7rGaMWZ?4MWIkpOrEg1<$Wl^Zx$VbNeL4M<-I2CsU;Ve zD-?|u-G3;=F7$XCwAD3Ck_1i9oHEIL!;@yRoAuK|CK%6kQT1!#e}2R^QayHgM071Z_w?%)?Fru6Uc@7^bpOj?ha_yh_5d2$+f%LjnK)Cjk_2*|^ zTRf^);G=B1vV^Md1~?xcIML59Z)+oom#{|2ty$QuJQ!x_h~)#V{_*j!favIGsij4< z)h?d3WcO}!ytiw`kVdmWS&;HVxz^{+Q!uCHtd(qW^)Fh*qSU!4NP|Yn(=ci`T|$1` z1;3dakLWN+i$O4LxuNm*`AG0A-v1LLpYs>tR@c$F=o7_$sza)5LQ9A852M!LEWPl# z18z+%t&+Ao@uuoKi0jO7pb!H(d+`;za&T{`uweH0_=6Qzwu_&4j!-B&yEC=y-#*@z zn80>1{dk9YUfbu%K`C$AD*ibQt6Pk{Dc@j&5oJePy|Z_8bOdAM&qe}Q=q3;jvxKzZ z+DJ{U=h%gffBpL1viDmfK0DkdS>S9_|L^K^F1<(|MJE_W!F-3wT1Q7VOy7ph4D!o#b?+?JRCx(#?IC+pyHIsGtQ@+4a%!C9A;r->-FSojLeh}qQtuw)?8Ux z35K$sY=(E0m5FIM8Nv)-2_>P?5&hWxNthbA5;p(YLw%_C;P;wr>RV`5UQxja(0&lH z16b*X_eU^Eg;O2RX-MCiQNmNeM1qX-pH+O$e?QigG{wr`t?Fg#T-%BMQe#4JCT-bt z=40D3Swz+QuK$q1eW+Z1?{8N}19XsOHw1*bO8wT228M?_qt4)=78lK^B~|{8y@@-v zk(Ej0m>R=SPWl~DcYHpjQn!g#oQ#v*gg+xpRJt?#-P5`taLE=3toitn(W7-N3BFFREnG$hmUTAG^4fr_|e{6;2L9RQ~I#8C1IkBpoI5M|!;>aMR?ESTTsRUr)^ zCSl$$Q=B4>x){K6rtF;I#{657*nPm#XmzbKcTY&m62H1rD3(TjoEPVo=Tv;hl4+{% zjME*O!1X^e`)PUW1xWWE)VVpbg=ysV+N8S^mUpN1xVMHDtTFvv;-POli6=P&igX&& z51gibqWoWfBK#Uwq?A&Sh%B;Om9L6>Y0J`OJ=iUZAZ!1v^ET}QV+TKx7fZlnF5efF zeBqsOn#Bx8ZSiYuV(f7PsY3;g^^7gl+H(6z)8dqgm)t3izX*H}Wl6p|b(i2JKJ_*8 zV5*GD%GekmSA&<$nW02aH9S?WIjA}!YJuq4_lFTiFelv@7I>qw)=+=@}t#ibM* z@kQzM_s@+N0@q%U>rfh>i7&SOR+?p7P==nP@9OSup2mz@7oWmn8&i3EhadWd2LNJr zM;$4*x5UR3=9;qtWrS7rt~2Kwh@#j>#b!^POHmkE zW?7znH%T^bEpWV9QBgtgLI$$Z)C<0(jsYPdA*DQJdr#ywe5EFVM!t%;i`#O+EE5(7 za1=2M+uhgK9|fTjN_oIej)KJ4a1T>2ls-4gp32HL0qtO-g8J`GkGnd4G-_wR%Vo(9tSOWilqYq@4C1K16yhIHQdGZup9=lm7!Piedo}RfHW^zfy=f*wxVK zEKP2nl{ei5Q*NbLOJTNI2#@eO&E@l2TWJXn=4)>xQ;^pXr&R9xTOvB6*srp)pF`$A zVsapS!=-tCYvVx2LcgWo%-yYM`1+YD+`5(fbV-M43G9&oU>zMzAyr9-hm$%o8V1gT zmQoZ{*+3Vx{i&3~zv0Rm=jrto)m!?00WWlW%nugv3_cCnkEM65HSoIf4bp(Bva1oC ze>}0^m)1H&7LRs6P{miv69KOy8ZOE150TbNv9lz6LF^7TD0>3D^H`sii(89MJ>$QCGyeVc*Zd4T#!wNvxth0 za)0}K8JnrCS>N0Cl+f4Lhf@UfVTN}gWCDx!i2rxbaCFt{mu2Nl3E*)h(?z7A=f1rL z*`!FzSSQeHM~Yu<2Tf4N;y*~<+`=Y{L8F~)`Ux}Q%>FrXXWBXWkr5Iy6GdLdU{He5bu7WweiZX z)I0Zqq}1ZYv4@3-}ZNP^XURm8X|r? z{NGH=BtLrDXxeNy9)Cewhw@e$$%KZ^;G}mVGk!My+0LKkVX*IfD=&8)pTZ*`Am}5M z{Pi=tL0<>|c7bUEHj>%;`e=0Vj+@(*lgOCJAb#vmt!WA_xZB=|cr6`M6h0Ur=k=BM@2e+3{PbE8do;MVDab_vZ63~8Ce@w z(3B^nhO&_-pc+wnUs%|3LP;vZh5%sjNZf?$shn<6C z7r>)GQCt+cJsqc?m3Q2$j0#W7EiGk*OI88zcB-JyHlOP^Dx+cjcOH;Xed5+l!}6If z^3i^HBE{TuMF?!#Vt?Zki_qxk1Q_8)D*Yk3bg(a|r5>4qe43@@RXoJ4TaMq#&+9z} z+QNPCv9ZYd?@2`(Lf+?6h2TB2(=DM9D^LK%|1DE}+G~r5>>Y2U~jsT4Q7U;eHs zw6?S~O^D20#q^=#qB)L)Vk{E^v{QXnt4n#d40Uv}Xl&qd%|0(YVZwd$_Z{p=ZNNUg z;VpBq8&3s^e_Pg^&HMAffHsW;?1kLi?5r%)gq$TfH0`n`*IMr=Dk{QMk41zpqNGY` zdX2N3@9eL}hl02L`{Cv0b|#jgx2*2OpI*7`B_}iBojY^7G7ak=8R4~1{)^AJ$OAzC zcupXiXBt^kC_W+acYiRy{=alYoK&rpli0MM=61OIpxO$!ho`qGE2#xGV6pdaizg=~ z5#xdXq;SK==Vg?DgcKgQx9v!7Agdj=bDmJL)zq%oBO-{ z4O@OhLB5@hLyOD2Mc|)ojQs>6{+B?FGD-7=voFBxZb;33#}(l~=lvc7Y2f{YtEI5` zBTS)M>E4gUdp+EThgUI9Y5Jn*{rk@w^Ru&4i6d&Ek0zQeJ)XYJMu7|<9$sW?nd78X zrBq@c3PVe7G@adWNi3XT$_f%gVAJ6}1I)AtBLVKF`$E+V1edcuB)GBr`^F~@l{Q*~ z%xo4i0G`&?<_mS`x#p`{AK=QYGKWZUTNWZs4CVkEu*-l4mY-(vqc2Pcv3AMTE!kq` zBuD!oeY#$C^X5$xfjb!eQEySO{UgK!3Xj#Y51p8O%2?mkf}p*g%^)q{E-n7(l>M# z@BI1mABV6LR=dwuVY!=YYDTduE#wfGp+}qARf#o)1O+b>@9gZ{=*RLoBsywoP`C>% zKYm=$3Iv?_y~DRrQ6{hNU<#UAhIfAgxp-*Hz-;;1i8&&D=EBZfVvMSW1z%`LyafFWP_%XQqoH9tjg!%+IO zHyw$1*pSZf4vgsoX)bSrbC>3IgbXM5x|oKX6Eh2V@Net72@U1Ursr-rf$!(K$;`}* zDelSK*3WWXyVkfvNXlT6WJW1wuexk%_|WtFGc?^@9b6*u2andq>~+FT%MKGV)7NP# z;{QzJAEKUv7Hep&86&s2LK|IZ#}st7!0Dx(#}hnby^3M5n6#__!+uuyzIP(OpbmiC zx`*~|lo4Jx1OZo0!Ham$V$);;*a)8O?z+3S!Z=@KEV+Vy=5WGQ5kVK{xV6z-AtBE0tSB zM5~drH+yn(8vNg)=S;PzIXF25yO-GQ58-OH49gutghy&wkz}Ih4!e7L&OTdOT8g*W zy(m|pgY<~~_`N*$TK=Sk9Cl0hLqWj>;?>-m6Bx1Ojk7eub~Jo|nKf^q1F-X=j{(^j z+dVP&u!k!P3-Ll;H}UF<&4cWp{N1QbH*rbM-*>W{$Rdi#_@uowELW_c6r_eAxHGh% z_(LwnJFcuN8UEwK>x6_Hu6w-H6J%ESJ=oTACW$(9i=tG%g!z#t&&gIN ze~SCxS6gF+Q9Li-~w^>=TeZe3F$+p*?4D_B2h9ZSxSX8Tx|7j~#^H%6Sh84m*8B> zGw2zehOOk3?&w2Ra$gE5roTc%Qc4ds2N}OD%$rYN)Y7na{fnAJ?2O_q+pda=L68fR z0g3SKk=p-(zx&<~fNlE` zZgA5xi#VCUe{dSb1E#HL9Q6;pg(Y(jW|&XnRjFjYyc{1N$LZ?gpxsR>3Sl%Gz~Z+D z(%M9g8*q#oV>UK6UQzLWt8$)!0AjUvO=`E_W@#V?#IZ-~ytaTP3Rr>Zucz_Gf#r=; zvzGe3kddVvXJujWC088N@z2uwawQ#Qz`TPXrTkAvkFFfwQdRP`>`!$30_6vH;oKE4lX||1oH- zgRz1wkn+SmR+EM8>F|I@duMnDCnpZ_&Tuq?0_qBoDG!0wO>p`-aBVN`b#!-w5cDs& z9&pnAIA=~!ohN)2b6t-Q5(fBPz`O*7=);UisSbrw071eo7;%5ibMyD-=lI|gfK%E8 zvtTLE6b1|97_cafiv!|3li!O#A&hF$E+~1XIJeM(IC{jf_C-DlDynoe)eyfy`6FckGL+YkfL7J_H2=uTjgmkFY0U*c$b# z?G38{g;i8F9U8>s{Ro8DpWl8qLGxs-r}E}MN6ZZ_0sd;glVk9Oh6)QLaSKW4xkc^4 z=Xi!QnHYUh2GU`gZ8aouL+23DMR2rOWk2&mCc0R_6YAMFPFji~M7zK&^(B7fq8X!@ zz5K1Su`L1GFAhvzG`zd0XlEbWEJUnX^-vZQuyM|a*g*_7n(iFgXuB%@-thCyC*Oj} zu?k_u_Q|+?bOy&^1ZJlz$gm%MYiVf#TVL^CGtrN`&#Pi_Qo8gGRiNW}{`u`6C&z0i zb1;F<(_etGEWTQWkB8F$1*rnqERw*H{M`g@qQHLqIX^!mUYO1`kM>yws{@C+jh3#i zE|5I620dMHtKdBATx3v@o0EeAn^`!aYO+zF>`Xxl(Jcz6#Eqlp&Zqi41zqj^vk=Pf z!y^I{HGiyM4ZJF%l=%`6C{3^>wf`^yZ2$7<>1l{$KD8FYXS6VYZa|u zII&U?O@N-VVb_)nq*G8L<2SAoxp=M)P70vF56#9sT;1`;i7ZQ>9Q1-K#w3UhfIVtB zQV1tQ0i#i*UzS~1=+GRB(`yIC?=*X2JSW_I0|g&wmmA;+0@lC}03s9@H@vr+eC^-5 z-itdvKH^pqKX{FOeI+^{n5HUlkpFl9dsMh{_EeMe*b>Uv zOd-|t+mnw4GY)okBxg>G#5?S`GUE#J5s<^#1NIqS#>PyBlUkD9x;0g(Y>XRTezdMm zjImCPgN5sq(5-lRdxSp5UKT@O#H0%4Qvg*%&)v_lFyp8Ncl^U!e6r zu{&hN1?zH5lHRWDmrMmupUm?6nd)QK!0IAx7xaIB&r6@^M`eO+e4gTj-9$}AU7Z9C z2~KAkwg>nXkp2&2Zyguq+I9^Oh=8PlG$@J~NJ+PX2&j}WgtU}HcS}gBAd=DuDnqwO zcPNb`-O>)-NPWlX{XF;kegAy#{AQP%y`8zP>painSZf_?1xUeSZ|$bT#VX*;V8=aH z@#>)WS5{YVDPqsjt)xakRnCOa{A$gFupF-{&&{=J3O={LzYoIWBTULM0o2`)ig67x zN$OojaW)NK2J}w(KsqwqCnL_c*t|;I3=lNei_RqwaR1!KhKq|!62hlJm^olYJJO;U1Yq)0CC+&_00|KXsgYhKQ*}0a zPU9Y*AE~RIfK-jN>M)s4LkDcbvjJMpR`S+P;5dr)xA|77*>By#ozu`H3}0L48^O`0 z1&L2o>bLk-4YdHcXJ_Z-{nNqxQgubZk)BL&%&p)7OK#Df>AiKel}O2t)wmZ^z8V*< zd=_vN`BTDdz%yV)Saw5KW6aW;O1#xVzb zd(P^uC#Ef63j-rgg^H@G7Z8)u28X?L0u3!4iKgI_(mE|X8>Nc)ke5fH0W1V==Y!q( zDnbW_=5e<2eAfehbG38xoZz5PPQc-DhKjGrjCSUk>@vl5&hQtXF9(+RY7eQUW_a_7G%NsWI z?6~-Bz%v3E=PF6QtA=LI|b?j%Nbb2yX2Ud z#q`6VtO8`)RvCG4T6yXm0dv z>V+LETyCQGOPF&cyzd{sG(tfZz z_5An|5f#R~hpm#w>);eD?zr&opbJuf;M~}p?}+nz1Mx?*eH!}J z=6zYo6whGmhFzr5zl)BJF28z*{!OZLYEjWfBc)`M8mmOgYF_1v`@!mjX7N^&ko0)<5`Ae|vtd0pISJ0c%|GJE_#u8mVcY0Fj6cLl# z`&AM%EN{Rq=wC)aiX`V-%7>F9%_qRF^LPE4?H`zylyDq|e*9qtSbldJKhH+*(~f|q zIp?%|1ouHJlms8;#jJD}BZeBiWJSfvN42);QR0^?*SZILUw10e!XhpUDPU(78Q z`gL&;wNS#J8-zif%PX_})UY=s3=b8`+`K{mmbBwd^cY&Ygqb3Cn=eq&NSH-}8Pbkf z0p((4)6;nJ^27%oBcXCA!6ApNY&J$4QUP$LiT(ZkuyBlgsA0GLW_q9uLyF)tBh3kq0KBSywm#dNTB-qmW?T7h0nw%QuU@ti4PymHx%Beo16T@XC)- zRYjNRkBY)$LtnBJOe-hFou}i>#$D&)a1yn|oI&rE{kOOnrT|hJ@ThPuAd(uaYY4?{ zP`Gf8i4usYCT9@%^y$-W%>o1HXQ2MkhtL9up$gQ7%2yI>QQ)3}?As0^5X$V~s!1LU zbpyrl=+Psvt$K-SOM1H38|LNMNG>=tGgAd%{G<1r&2sZKF7s+bui%lGa;w9)hdX`> zkup!;ap|~jqgQx*uOrB{d_wpsyXevA`6)?FQvQI$S5mG*Cn^_jPvrP_W2M>~YP@Pw z^dHYiG;d&Feow;G;M3>NYsQ1m}Cit{k^qh|lFFS|S!q zwWSDP&@AI?7~?$rQgJv%EPa0X+*`H5TTAWUDFp4L!D|C;g4dP?2M51#ZG2}>t(0`Y z+il|v!hFPBxvh9-{@}dV@{G&TdZg{tUTqX}wL7uPn*()2e?13TDQjPD7p4HonPA(5 zI2v6qD{_Ry<`l7ZzTT+h7{j85r>(5$J6ww?R=oH?&k7bcudyAm_8 z?yuILbnAt$A+2F87}OB?Bg}2ftDTq-@d*i_joQ~P#;yi;^Ki5yTUu#H5-^#h$3{=K z+$&^LF$QRKu1Q#H;}iY|`DmlrbSn>@=7iQ^g;PF3C$aU>31x22{pX36Iz&Gw625nM zrbBf&K4m<$K3#~Bbr%RSn=de-3&ApHmoWDyGV8uE6&&LyTOoN^bl5T1SsL`fQ+sm4 zEFixE*E4$VO1_8Mx^`#S0vaH9@Jk}}xb08gp`23kF12**J=vFKV%^KqV_%+w%Z<4O z`flJb-kqPN+TWEgOgvClI_1-+Ts&axnRFjHrV1O^Sl`c4#tY(O$e2&JHs|SV)w>V1 z$xc#(Q)qgJr5G#{)+wZOKx-67f$$G|lhld~+90%SyyAUJzY+q&NmTTD# zJ7YtF)APz@j$flpc2r*_#52;I$O&Cv_Qj=^L_>Wl> zf{`LN_u+m8(a@fmc&8-tI~`0$-u{YyTGD8GB8%)SpsTsHADQWQS<{!XUlC(-M9W&! zx5mW2L$u6Jt11?Y907xK7*uz>VlBQZhH!l?2H&8`C26%RH5oeX(Q*eO52}CY1HnBq z0>CoPd(pq-cKE&S>aQR7;m(1)vw*Q#P{S05j{2VtNGCBKIG5iQax z8=VW7ybl2J>$`GFyH@}jERO6r`4u`_Bqt|F8{5IGEhiJM%);Y<7v-}Z|Y&n(;YToZE`csghrDXLtvh;Ny>**L$Q%xg|HQoU8@ z+eJRMxcJU(M~2FI=Ukh!`*4&)%P&E^%lWBzNd_iQtx3E17&WU~w^) zHF>39VN)yQM|Kthaf!*beEZZ#bZ#EqY*E^cEL}PKOVa4vkEf^QRBzp?di_JpF{5DI zx%YSa$4@k=n0}sVx~?{$dg9~blhYHO|9*?ceg(gMHC4I4_u%54pG}vg=`#O4H^DW2 zU@3X=)*FqBrl%3o=!X%;49>Y(zqPK)-g=E+@iwbMTRb!_;GBF!Y%GQ^S9NXsq0*^& z0osA(pO+GIVfkA3aOt5!jAMyb>k`pZKD~_Xhp_`KG@yY zlUbBmfJKtKZ|=GmiLnsmC^;2~CiHp8Dc_E$L>+_Jx=UD7bEj7pcSv|%|38FnE|)<4 zCc#NXa^}LBKS#%1iy|D(=L06S0s>y&&W_(X_a;jK6m&TuFQBIE8W$B=;`Q*UPM(`a zx_O8;#eOfnaxGy0S#|wHsxyjU{eC)r3>rXa#n}57M$zs6Tw}ujqPCuug+K@=iw$KKbGMzv@()2nD7njliUt;2g zS8TJ6WMh1iciWyoU$)NVL&neZ4U}Fc1wq{3l>gJ(1lJgVS7pYy#NBHxLg4PyW8qCtNb(f_IMTg(1bix#5MQ$ebw>0D^NqD=VxGEqNTaj zTTkU2JTdJZhZ}K4(0)Z`n7q0p7I>*6FSnU{Eq=-JJwh)q?MAGHX2hqbS7q;e8}BTL zU3rtdlJhakAj2Gq(M+?&x;|qTS(X~O@OCgjaZ@Njakoq1)CpgM;qJ;9-(ZlU-+tH6 zGLMm&aH3I#&Bn^3!_C4f$LR+N(>T0CQSQIOtM%L_c_kNxDmMAD8{Z^MD|3ANZcG}> z6k1C;3=j9m7G`uudiVYlksip#R#3Nq@DW7X6u;2i2C0MM=nHGo$&ZSFPXpiurQ>5yG9E^7!onfM(5i|hQTP~uhE4fWK$;%!C z^t-q^<}&q7bhtHee_Yl87a>^LSbc^R{2CpD3!N|xYt~0!y-)d|B@UIv?n3MKt4W&B zCn6#O+MCZgIXTtUFTk}12b_Xws{7BjpP8ySAnTZJ>P!=$G)}tRot=OmC+1;=6#PX! zJKZ+tLP2SD67(19h5I=1VsyKN+wDWz4dyUe6{>o5h-#2yeHPtoASbW=X5h#0FuePR z&BuO@kB|Qt7U2}H7Lu$L;tJz$7JPI1o{~$nldd|ImdaMfc1bJtOCP4{N=Hs!7cs#d zd_1obu+I(S=tF4y@sx<_1(u)}<}WaN9nA1oFR^f2p`X3yelNT;DaWpre=t=TI6x{T z3~#xvUBZS*KByhQW}rJ&1}!g)`&){8#~|*`+drRFrx6e32O(RjjXwj+xPMYMf9Ssc zv6`svXn$#G`kd*G|EZG|TZ0^pnimSlA@RDeXQU>@zLI(qD>gQYd`n!OH9!A8k=i?+(d7Xvu%%DRuY1A#_RG=fP@b- zKmM&zD+1uhA8*HrXLH>tC*9u1o6t$>Kmdn=rmCiGFGjO&Mfuw;oyVN)uHww;z@SXrQ`&5e(9#V(vD_}1 zBnoe8Y69fB~!{0;G4L1ab1;CVMzV{I}j@N7)40XK^MkWv+ z%F4=0N*rNzz|R2E%eCSG*?5p0jKk{zh{EBvIab%|aK$H2&ga$s`JK(cz_4!Sj(_`(JAk1uMZWX377q5@ z{Csbqs(?5d$E5_HB^KMCiS+D*=)X|t+yEp!S-*60t~h&l3}>bY04n^T2RITQ!zaci ztu}4r2&!VUZr}rPUvGbNe!Al|4%Ei{_yG^9_wJYr&BcqIDUz2E8=IRr&uJi1^dOcE z0LnjLP`$P>RBDSG@3m{bh)e4O|KU@J-9EHL|Adw)l5qVkv)}UaF@h(hcycgs5V^?Z#dzOVmFT6r`U%bqg z!I*b0;Uy-=7{iD|aS=${p8(xB=B+3R;jS&Fw5iCkWh4$#xYxdsUFU3nji+xNg$V zB*Far?~C@fU}p*uqHMcr^TsF8pZ&TIdnt~gfXLtqa2UXOHYO$}PPc}wj#bhilD&`j zL6k7*(FhrgAbYI>P)L^9%@{mXLF_6cBU6#r9m_LK3HiPd-~66S->aSDE8v+m&;nxz z@^SvdW*xEXDUjy?289F&1q}Gp*?zzF{&75CW=6(B>4-IqRzJ0~TZg?nuY8W)@bQ?s zUg0wtIO^e7YIdtvlbnZ9VPYG^EBvS*p8!|EnIQwB;SHQH#Es&FYht^j4mjEp57)bc zLF)`a-atfnLa;9`QV!CV=@1!Ne{4>6Y;aMfI73b{!uigGpvJxLN?sel=RLc^aeeKD zno1NfI4*#D0E}y7YB~o-5WLX)1I7~By=XV)G?1C)KA+?w0U?xy6|NIJAU#Y5G8%IK zit_SSfD&T@Q6-2#V3&7y{{!?PJUKub?m|qrE__tI{N1!SoPRb`PO5t0- z<j}_2@Ti&D+1l{+Wn`LRZ9NA7O$Q!LT(BTSyLtjo1dA75AavQT{$E1%{^MA2 z4}nX}?cDP6qLSGR#8_VES(F0;)k;Kk&*eT8r0op&sS-v=_ z@FeTWMa+OgS}#ArDvWd1@heIk08cYNgvec}i{Pc85c;TX(aQ6_tH8;Dvp_0p7^0o6%ccks!C_%w(;aW?sQCZp zh@6M}`vsmg1vBf0L?-_D@dM6KqkUZHuKkQ0oC>fIK~pq{j>DTP(4=07H{W+Z%HS&%a)**Xi5JR@<=Hq>b)S3XhQB+ za|v$jB4=m@HlqQwx?|fVnKvc6-e>!z@$#OXbU+ptw<#^OK9$SOfcL`aS4x;c^8=ZT zse)DtpMT?&0r#MJQz_-$g%S;wLy44JnP!z?0qjcp$BExH-zplb`UF}pevHDXRGSsc4E?e{YBQ zfTAar?%@kfp^cw<;s(e!V(t6&6ayMuaNWIocx-oAHS(vf8lv`9>3_HFi4N&+) zEoSv_k#FOorP*)Z6yEIM<$(fxwT7Wn-qPG0-^koXz#fUTq6}S#frWnvho(0K-{M_{ z#}6bR3LtUG%*=%Cb;>rj+nP=O3DBKD2wmD7@9&REPPT?k0e0ApDQ?bx1kqp;^Iw7} zIFd>anq?)hqa{~X<5~WUdfHPr{K4wBfOG__MO2CEWr4Ev3-^?A0~-+^>|Ze-PHpausl8D}zh)*pclx1q2% zFwx$9NR0rgRIne0{CQ6O8c(n@hJ&98@NS4$#`}<&xmXWlv2Y1nL+FZu>wuQ|IPw|J z8vmm#aGm4cS0!B6E+axNNhSwmK9C1?EP z7!~!ir^f(pPf1BhLExjF0rM_B`be-P~x^nRl=0ZzRSmD9mDU~Qpaj_G~ zhHEnN2o;sI$M|fA&d2+RwG*4sg#tmK279@_Wo3;+YBrQr$`2pHq&OSfF~lUFA;5>qk`9rh zUt3dE27fB{yt~qIKMxiQakyQluaR?00EBG^&ny|H|?ai_yUDr z7ZfjW%RNC{W+b{{!2PY;Q$e#FN=mRd^ZJ8Hv7V@58ejyP99I8rTO-JI1%92Ki;FTq zN+^g05yBvt2WKl4N%Z^o5N1=<|Nl#T1r^KuCBE#=v&`()z06Hc<~GEq-Mk?3f{Zjk zszJ<&Oe-QW?bC?gy;QBSV7XBL#?lzo7!wJ`=+>Kz+5=8dKpC4VcD0L&3(^1pR_Z$f z-&q0vASZ`+e-kFXKEY4kO}x|mVNv2!t+=OJnOG4Bq}k*tGUcfy*jb4h@>}lP?LkRQ zCQ+`E2T1{64{xMro8?DjlgS;zNH+04bTNIwlEi?cEqAcH%9&SM5f z8gLag7=<50m^PdiwV(|OzbdPkBm!;))D}243Z(Mv8hHdh2Ha%^^?z3$j-mmnjS}$e zM9i)T1PH0V!oQ-F*!xgZbNu5GR}vHk@B_fZ;6x23X(7yZz`^Vt9ph9Kev}fiqAr~2 zzAG_s(vLa~=;BPlLf>KSP-E<2yKU0p&FeC@Xp@TY*(m#F_IWIreS=@GqO4k=}HCrPeB3(K_ zJAnsJ9jBbUyc?7ZR@!xKKp^ovcn=MQ+2&j)bY%ZWXWMI$Hh@tdq5V}*%J}NOt8e>9 ztUJiTVRq!ewCPS2Dr-2irDIG!;%D6V;U6DN4~t*^=rrmr5}LDHT$IVojNIHv+$vG< zOLULC__?E%0^5vs*gQkrI z6zJngljg=8g{bg3CFPMO*OO#vbo)`a%ls9wy`Q(wZrqnm>6ez1ot{Z|vlO>SK7ERO z8oy23t2KSqU#|?c8-?k|+q~|p)ZIA%i>8e`p~6= zX^Y~DeKBCT&Q)9{Fgt_RSda;sIZvH?Vex*L^!VcfwoE>oi`E=dPrt={V8Nu>7R;Sn z7fkN4l#xGEdt=nUL72+gVCo04OXb?Y?~S0VWb%i7S&d-sKW zeyfVuVL)Y1nmYX%>6ti(fP4eEx6oHh>{Qp@%2c4?lZZcJem9fsPB4*Xoa z*t-hS7-||bnSS~IayD|`oMVwr>%~B2xsa5bP`CF{mFhIR@#+v}D0n@hZ@53OnvVgM zG{kPm%DCfN?P(%#?^(u2zY_-oE_&Sy^;)Cs2gS`shQ&tvL)fnw99A^*~+V1!5hr80rq`FmEL zfUZ_vhj$_^m0)KNA4jB{d8)#Q`m^QqA#Mc1`Bb<#V*8m5N?(TF-P*Ea?aUp!#jHE9&_s%Id1gTDdOB)2`U1;=uQ-qNAh zSY13WsT)gLJ4J07RjIq&vKzPS2h;9wF9nmHYCMZdg1YGv%4y7rcJIlzZc!aycF0>TYGtXm}EXkGYh-Pv5QAa ztQ10kH2%UWb~t;MGxS|i-PJ>u-5D+Mp8}SoXiiUl?OaqI_8ju`W=a8& zm4)A9YEPqGtPV#KqY~caGgMlb1NK~6S*)R%j_hI1q6u97LR*;shD4OXWq<}FdwpM#e7r}=BRQO zE=OUXeYK`P$P&zZO1)cms{3N#s{$J$zuELcWo?Zhn>TSnscBXhVhXpvDFElcjy?iG z%uQ-;Usl}7;4(_K4J0j27i}O-=Lj0Gn^FIfX+d|FxW$(;N{xtUZ(ry2+`NhYq^35b zZ|iGy%lN))7l#jJbYtqffemiyV!mc8OkIJ-N|@A8toZAfdL!l?{JO1;=PsQoUPFe- zx>mgEKIvkVVC4Tb4vMc=-AlcFQ6}#qSb0u=KN0DKQ&Upz`0iTC09`p7K=)X>NsFTN z!3&}zLGs{h&^htcRcM=>Tu)}|BjcZi^!h1bUn>O{L8}+W3tOiJ9tv`VkmS>^W)?~u z2pf55Gmr%)5N=)_F`rKVr5f+3K7BCTUhRb8tUdV2w+x!M<_*x2Kd%(Ul}FdC|93Kk z_}OH+^N&yT(vg{2@ivd!t}mdNwPM`X2@=PO)lDTMdX}&7^g3l_{oZ51x9m}VdQUpn z`TOlDL4pS^Fo*WLXL|jEQ1S7*_G>E)f^IKrC^oqSdTLW5V+awQxw-nZ)cM-tWBklA zC@U16Ps>Yu(@kKXaFlRA8h^XITvJZc3w$WFIarxB?jz%9muo>YZQ2;j`)Dd-tEBj| z(-~Wd)KM6g;fiCGU({)^VC~v7L3(%`d?-Ux%LAgj((svu3UcyS5Syor3&R0RS@Uf`8! z>AF;>6_-SfBW(||JG)x&QxHw+_lw9-P-G0^x4hcrajei5Tt-rTMx(?N(GP69o4I;G z7>K!a{9rXb;E$pbjiavFVsy@K@)yjpEk<`%ZYG20bTjv~6XG`Sho(`55k(JMr~^;k z)z%(Ku&F_f;5dybg8veTic~r7^!FkitFm6dLN6^@-c!2Tv7(i9lUKDO{PC%J%Z({p z+UxUc2cA-1Zj*Ngvb((+sJV4>4GyvR-|M<=5FZ%-HEud7Fl zN~0?kumDU0onLg@gW7LeC*S@ zb_;8UI*=&cB(=&WH8WB|$KaxT=&sj~L+fDP1z_Hyqw)CGLt^ykXH-Mx%J z^u@(p4dt(LQ5TwDFs3ae&ECcm2aK}aaVAV?NM!DhwWys4i@a8jblXNyjUR*QQ1H`I;+=XsPtAWR zy0Q})%C3UU&WhJnJw*Yc5+Z~MVnKw$Cn+N~ND-!ZBJ6O0`t%*|oV&FZsG8lH zGE^$5TA$|*WsV<99{@9<#y(>ubwG~25EFm#tLat0;=zc5prBy!M$}E!g1fa0!00Sc zAF*35KVh-z*g;CXzz?FiT(~7}it){uJM1O^f)!ws~lYmi78_TahY3!|9 z8Omu8m#^JCgK*?5s3?BpPk<1znECeBpa;8Y1NayO? z)6V$W#YF{D&Q=@~FFRzX?R9Uwj(8VKjHuwLea2;apFmV_tWQ^Il#fEI<;p+Ge)&Y+GxB9WWK^&z? zoq~hbigZh1Z#Lbl&BExB4#Oxw6+Cv9U~rF+XfcEx21=FjOTT<^O2{4-cpUBWb}4@D zXJ4EYn!MuIF9=5Ul4R336ChQcd`{&Zy7$xeYinS;%URPqvPABZ}mRRaNT`6rTJ2K>&0{~6X(zZL;3Ixr}d&yU6!xhQM; z`hlR;7poqK%kn>9`omn*3upaA_g^Uo*&U4(XL|Ns0D0~mnRPz&U86f=Y1X_T+;v*Z*OV*U4SupSZ}C9KUcp`HNFD){(I8X z2r3!F@+|NhtTQjbVB(XOL+@Xyrbu#*Xy*ivyxBa_Qg}p~KFhoXCH^+*wJnz94GhO> zKDV-328q*LZKloI@9S)H{R5l(7=8t5qXgrb&*vCc%>+Buf9YR49)GsCHU)k^*Phes zjPW%n9_-ehyikC0WX#;-HjV|jp2YQ@M%S@-)oZsBaKU}g8kmN90chXJrsT==&a34n z9PI;GQ0~|9-=%Xf39${O1q!;o>J=ue~7YwV5z+c)VMCX-VbpVgwfn zo8VW;JFa3kCM}s_z@FzZkzp`8XRNYNSwlZ8pnuMkDn5t8g7I^LtV--{Reo}WvDSm` z=lqjk){tX?Rp;v}V%2i+X#(SwU8X=;)6~xO2fh>S^P5uz$VV0C`BQfT@S5nBzrDWF z(Ymm?FUH(WXO@_Xif5IFkrATGp2e;)6D`fF^5-wCZ@0W!7I2JjjTf&H;k|$~GIjbQ z|6D}VD6G{lA(nATQm~Wj0W{%zX=7Ifb8oz!GjIw}k&kGq;y$_Gq^q?1Q(;p{^kw~I z_?~NU7?Dp^AYqMYjCtT}A)SF>=%fQA8_+YJ8KH{wXFeN{Fo#WqfiKtGua-m4Jf7n& z^2Iv{+~g3B1BJ;x+&mNLD_~4w48m%0R5GE5<(LED=qXP9T7KfM@0sGffoV7ymsFYp z|2s5j!a|GS2Ic~=Q)_@YV5&6%9JupB{=eM+(6MdEzvEwMeK9k6ZfcuP`;`N$yBM?A zHSpvF=dM@xbIfTGe8@hvzMD_3|C;=UsGHy1uhy#_NHF)$C((xe^4V^9-|FW63kkLF zFGaS%rs+Cs6T?)W7<7|~6W!jXgotJ=(s-zpCLgc%e08Z+=>_g9O+aDlz<|J$CokD6 z@7!-M%1vHje3b@UUi9Brc(`wo9unyyzRXvC?X5Dh@9u*KPU`AT>M`k9MfdpSJgmDA z&S>v3`avdo$jUEND2dU~g{Gc^R_S=VQXAEicCZI{4Mmayo>ZcU{OaGnrAS{*dRvj38;V+(n=?&E54m?D5MqQ& zGt=^uSf73K<4GdPy?RP7196bd1V@q#&2NhD3>pEczJQw&T^%Z(2cqlG(W68Ooj9YsUgH%wUJg^O1D2zGI0;LvVvJbT0O?LJX zpz7h51x-6>ft2pomONN0#yvj3#=+_6{yP(mm>d9d7f>@C-%|&-Lo1b zvf)WdM;q;2!1=(7L%$}mnnnVy4-Vet;LwFW6~M{7>Yb;+9A`ne9)-FqsQlsLT}2d# zQ^K@_lJ_6Xf968r8@F>--sb1uhArr!oZKJ;Fyhd`RwkFIm>8Hn0o{aI?Orq2AA2)0 zF)-}QfJv$UTbY74ci`iHo7L8R+J1~JRf7^Kt=ML7sQ_0$p{vJHz7iAx{1c=ick-Ki zKhi{U6c4hL-U5d(-2u3b=*Mv{v*N>kn4H#w;h0@JAfkl-z9w_fg>~9erVIgUWKgW_ zb?@95T+vlPJx%Oxt(deq<27@r8oD5j)}3THr}l+a9?p*{oaj+g3ccTA#YIv{U4f_R zpYtViyc{*zWh?WLmv?B8g6otWhe%lF5ZY9*N~GBSU!JT42LKbZQ+ zKh}rr>q|188|fB{PD~sH`*@h}(DzvWa*B`)tQuep2d3&)Fz9aF@TkD^9^AN$bv1pU z-H6rKYG5T{#;HM45v1LAP?whYx@I}+}{Mp0VRh@qZZ<7VV(?rE>7Zwi1q3~{scsFo@Zc4 z0G%)!@D9`ayaW@6soll_efW;;bIxisC zwvKiuaMBHki~zh{x59C8yjryOY}2d~H!UOI^LJ|((J*7n!o#x;i|E#k8=7Ct``}9S zR=U{KPIyDaY7MAUKtL`DW6YrK!41qj;{R(R2O^fGg}#jNunPf!c?=cSwt275E-Yurx;1xD%D)?Ecp#)L$l>#TQ7)AJ?6KqZTOuOx z2CvgvY}b|N^w&9t2`za-t>eX~eQmiq9@(}Rt%bnxf0Lo0nXdxnbud(pDyI+)&$pVn z05%F`^gZ!>s5I;!$e(9&neaY2!ri9hgH^mq90Lt^55mJsoUrPMSbmEbR6NXZ0wa>G z{go53wLIbi^%X6C$i$QSz7nQ=NW49UQ=|AjpzcDRd)$aE?0^mY1g@F!!9BZ@lC==q zj8N9pgwc5^#5br+ySr@$3#PtKcwq|h#2~0%;5U(gK{%+?V2Wv__al+F+~u=3Zr-E=-WbRkI7UW+`CWCW8WpmgL?C{W zQ3gC8uiA%ge{f>^=wKICM5Wi!AJFnx%FKampd1*gw`Y$3swFN5w*!&xr`vFX4XhW_ zWp#fVc#YF%Z9%R7{W+5TPtQ%Fex@7xd^(4BmomU}Mf}fl0G-KVNW^fD)4K?YQ#8Ds z7Am#fM2PtG?=^!qM5lvTow_P|%EL2Wzx1P&UyZY)pVf;kk8HJ!XKPWlh%fazawn!t zGVK$)60Y)uA<$ReceaGNE=%*hg%*?LADB;AR>Ca2) zT3TI;ERL*}b_v>gVbAK9RnJ$B9)`*L@26|U>0bSM^H(=J8(aDfHbOx?aNIcbg$dy@ zrY}BqTC54C;zf5P_p9*c&3;UJ?ZCM(wDV2l9vm_nop#YGML(cwM0@~A-eU}IHe`9C zR)1VKTRj&B1B>3n5%*;Y84adV1qQT)$L2M2@fL&Tw9)p5dv?brUS1MdJJ?ca9YYK* zb8A1vLp;W8xiYYX#HlDN*Ad=6sit|?t*8<;VqM80A>p;NG-TcGKB@IHTZ0lo;LA)y z14w%lq59LOZ71~(Vx(@&u-6Vi8G%zsffxq%J2%)a0GV&PMMXpN)ndRJidBdE&L*>+QKfLgr;BV89|-m)fh(Y_8! zmf>}gPFN{Jma_Te7N7I73Z}G#HSE+9_a}e6Oh<_@n`lw9A?!kODTHPnC3GKjT4^Eb zu0+M1HtOfPd|=nlk3g*P{ESaGB(=B9oLH6J&Iynms0cmm_~ZLTb;&R6Y;H)Bh*#hf zxBi8PBF>+O1hguhrqFYC%;5We)>?yrD<*(arK?#XG8SNq3@Rs3SiqG5>E_&5=fxk_*x4UpO5o124TXKg z`v76X*1i3RHP|@{E@ngR2OMcg_vTRk90A%yn&2(7_@9|brCW^90c8KUxo)KR@L>gv zu#X-a|E}nAmXVch72%L-D27rC_RpQw2}xdF%t!hM@~|QkCEoyp2mhv!F8IwGqc=JX zf%SnZ)$UHt)NeNn;5mZ@*_W%0o9Kb=qv`Nx2s9s%MFOXzB?PnJNZ#Yl= zGMqPBfEDi^47~{G9^o1PYxMxTVTbS|adJ>+#3m+o#MBvX&(2PdrtvxIw)Sm4vkHG| z?roTY5@O5hqGfL)ybfRRYUeU z9f7UL?;oEZ!P-WAfOqsMoX1^zfkxK7X#rVTI6E3p=Z}oU!)^kLoJ&}k z4guaWH4iU!!T#M1zYR`hhpYCtwj95j1GxrHXC8p1+RZ=;jXQKNh)Ga$;V$iTODNtX ztX;EJGC(xm@G=q;fDh9G4<+mi+C|1^5V7fTNl8p(gs?S%@?duNV10QKs`#F+eBH_& z@Bj%9D$y@p0=Em;?3Z-qz|Dh#QOFh^Ed%c0M{M}e4z@sXccYo;FKE>?8wP%!wipbe zZhRlAq5h(9Robi1f8F}~Vw8P(tBqg2gtV832i|Yl$zcRQz}4+b;~j?myCoShC=rTl zmlur!f2~L;wBO-qUqAKso#`LfY<0w0uu+IT)WDQv{C&})!XG#u6>-RrgOs}xR?YZx zh6jNLrwZ#zuKq?fsxpQy?LTziP#^4%Walv>#TzEKE(qp9MM`%J$Plf)Bch}I~-q8MzHO`I% z9$7G%(YYqL9Bvu!X699?%|y783%e%V`u!FK250lP*_R^{{%#)RIIthLcTilyO(aht zl-1O>aQ71gABX}NTZS5>J5g8_*$47X08o!WED3frHAdQiKDfiS3?q%upbJAv_zD@QFb4SIKk?xDY6VDWNhL2igg|izt{x)KEK=xcWyOE;3C0 zdSes>g=?A#d~n(_oFxwwKA;!})&c(c9s)PDeaUSm92}=$*vSTVBxoHQ+i(IG=vVtP zRnZ_cIX;*;X^}aB0vT8RDk;GTgbI8;d;5)dEYgtf2j>k~9svFewgN)n;Q~G#Ac}%X z0aG)x?l@lb4@`N-K8#j4fNUQ`_HfyXt;Z^G<}mtjjp6r#eise3$_ZTGL$4E1AcG!e z>5=%8e~uAp=XJJAbl-wuJ%L|6tZY1=JnZ1*)o9H`u1DMpd!OY65l`(N zGLt*Ep={f_l?_Lvktz+0pR@1>;)@3y>wh{SdN|(DmE_yRnyl5NGp;oxaa(8#U) z_4|dc(t=mb`z5y@C3_Y$>%bPLGG#SbP|qTfRJpL^aOt2!Gh3a*`GKeVvzn`HMEiJ~ z*6}IEUnkK8nJQG2g3D#P%ooclQEqC{b6df-ZVv<*9o*MWP5Ld&`r_O=5Fc)ks;UptJ3*u=wS}sy*g?KBjdd3QYt*#U(L+KCB_Bzg#R3}ZN0+^ zsn-oPP6*ds%S!iWhLUAoF?xI-(nWCo1)$4F1<{K#{i(nlLsn4TtUp zL}<`%z?0t?t%)#z8vvgMhl7BXkC@Z}m$&V;A7HyFu@#nQv zDOWT*-!)?yGj`u^L?rt77v0zOCS_cQo6v=kyCf->_TKt;$4H^21Xx)9JfN*zJB{2w z$P(*xUNJVA+I+>#Y-{YF@}@vTsQ=*O+uhaYUUdyF{sE@q)F?b*t7m(K4Y?-nE3=X; z#FhS^KMy8&uyP(2kwjP9aM;B(bSpajN%Wx_b-Zx$L-fTbA4aXlTB?<6Q#*4(EQ||f z_mTcw)nVIZL{&swmpf8>GS8M=J8U|`70}+Ml-cuUjr3lbZ-7V9cnk-#2g_l@8JBqO zh61lfN-EEOa39)#-yjwhbaF>>cE3`$j4{R|HhHRC;Qg-8X#$(i>T?fW_u5%>=s6DW zvV8hU;}u5RlG4QZl+XunpVo z_npX#gAoEt3X02CBWrA8CbcZWktB_Lct-D*8?^-HeI~*rU6*!DPaoEYM=}-j_)D>f zaqVD&CEfQXfIo`ZYdlvZnyB;;vD2MyqO~-y>$H#T_A11to<$HyvnXH&hi3I%m_(aa zciRauG+yS1w1d$N_?1^IGd#_WJXg^-NIWFDpc=X=n9vb%ur7abPqc}zbNGp~kv@SP z27z9EAZ)-WHc@=0)?Cc%D^*{n-|58chwr7MoYtTK9ix|}=X*XW#Etnr>`{sGS9;=m zTCHzG#cVl!nvzk-0e`AEC^`OQ`kOT`hL5W#A*Nm!P;xwb%+#8hpBVazuUF$?d36XC7Ngff5WB8_Okw-k@0fP_50h=v{;ytv2Lcq<3TC)DaX$+TC58sv1Y<}~f2p`a`2{X$9isvdF_$LBlNOK>F6a3qv zUzWkxH)bkU$DL$GCk-9yb43V`igExncSAADXzd9srlaZ zHn)&^l7LaN6myfGl^oJYtp(3BC2L6`k&F=Q2tyKYNzcDTOPMUD(xsb}f3|) z9EG#9HGS7TC5?Fdiig(m^HPA+0$^q5m8SM8*dwb!#NwzX#| zn8kuGa(91kcEk{uqX2t$@cl2xNAr4wK`=&;BqAd%O$v`#T@9#{L?e63@j7=8X&Dtw z3CDcP%PW(#t{N9}G7tw#@VRL>EVY>Br-r_rdO~|Ar9iL0=0+FQ$nzkV=XgG-_@V{A zQS#diuX3^qlkQ%=Jo5!$%pLa}rq*p4Amm31hm>!8LOliYnMy_liB#^>BpTzxvP%he zGV_+f*zCwhqi44iu=Q(tUmE<1Z7~~1w>_RTa>I$7J)+V=Q?F2L#$aj4pgXW{Le|}- z(Ef~m>cR`~Y(mt9)ARd_0PfHt1-0c>7Yu7Ubc>u|bo=TNS?*us5g2#2nB8d!HR)yZ zxMG@RkC?2(L-aU_i;L@K%%!7kYLA?Sk4_g?z+q}hCXv$3qtX(`%7Q#2FTqP&I&8T2 zT!~2CBg?tAgw|4Spgtx`<%zO@_S9L{@E0OMUr-y_eA7B{>5Fqu>{#LX&h(c~JzKo| zOh2B`nT-FI91^)&HZ44Q@cnvzP$-?M7yD}yt;3@aSdpE)tgM~@6fR_=`R52Ajs{5^ z*72YBY%%$HshAa%_+6FJ$Fuuj6+z>cL+ROX9sjqF4+nRYH;d`@6dzkoz(3H;*m%;} zMsS&lFLVuNqxG<@G|I|5kg3r@Q8t9UXhJzL*t&gJK)qwFW0;SoU?z-b>1 zx#?-HwjqUA_o*fd!Yt_;93%!B}Q_OsLn6&jE41S8zCOV zv`6}{ztCp|#n@&Pu^8O*f2G_T=DHAxVZyp~M7>OoDGonzi9;ghiSo0Gxtnii&z?2i zRr`S&^2&0Rzb70P`Xy9RQ5*vZ%O~~8WLa73n?`zH3@q4k%`+bAdi0r`lBZo*bxnob zWuC=-8wr~Jf=uvlY&#eaPfSl+wF?7exu+pilG%A8^9>wXraB>g;Cqle*NsK3=b7@4 zd}O4V5T?{LWj{X-YDv4}hVQwg4jG;Okz=^GCU^9I_Sl64-~_fgNrx^~^T$7T4DE)< zT3YQd->VujX0}3P8$4+bH1Gv|Apk_}=i4&DgWq9Y7$;y?eE--FeKyrQDXXg&^=hsR z8zm=>!t(PCMcW!KuJ`*&%)82^+I;r%LtTv^Rh1w9ir(`Sba)+^!m9k)koD~Mj6QD; zYU*aUHhK$M_t*-ZZ|**cdDytd=>*{ysu5_TiMfQa$$c*@g(^sP|M-3AA2+GCL%rTz z851>sJBm`?Beim>tgO^^$0k}aos&qYV17RTu^_1BxGwug#2*Bv@;e#c(;cK7mmo;O z?K#NEOlg7EDLDt<>tmpZ7tmHyU(`cK9@O^2zO z79JA1VREe%)yVS4)WigsCj~r<&ie}yro3_}EN#TEIJ)%^xo$SOsI6$pc_Tg{p)N~u z{T>&cK=Qc!@1LdYbW>voQ8VA}AF9<}6Ku{*4R}26>Fmsp#X1XRN;DH@c?5nc;Dv?( znh=jF2g#S{jM{~CWuod=8&ktV3wK0M>xNaAAHP7n2LlS-@|!AxI1kmw@|M1?;ZHD^%UTe;;x@iwg_EIW^ok4@r(- zo4_4m!QhKKW=~NC*GY1nE9ZYWx`SFdJbbGd|L{S(Ob`;YOT0`F%x|)7cNA66;O7jx zI{auk!SYKKYa^eVM961t;pJ{=>8SM)W}tzqP|w(kA7%OU7;4L}@4odfD=FC^d#*?8 zy4zxrVgImA=7~zLC`#tti}1RiI!}0KM0MxhmGq+u1{O}|%Lw$g%`&U+RFa)N`1woo z`lERP1FcZH2ovZW{~if73Py1=L3>l12I*qUwo4!QWqU3i(Q^OVntP7^H9q_}UOC&2iljz2+UI~Lje`zHNf%RXmSfwGJm6wvx z6P2Y3#^{WdqJ3$gr}nBt!pT4hLLz)~*bjqA=a(G*3} z4r``Dfq;O;rrp&XqTHF{j82=jcDv7O~=Pt-dmD36B)_MFE*>16_C<2D!-p7 zA}+31mj?7!9N|Q0Rc6PGwRO65(gzf|kla{};S-Fv=Eo^($#rywZVcp$P+o{@6ZL%Z zru?OKm4mB07ELP00?Rh^*5X@@V(4If2$H7=^zWb78kc{--gcG$CW`y-bL^mn_Y_n; zxM%?ooX@`g}-o!B81#N>&Op>k~Y&noYTYQVXxff6&FMxpFy?X}~Hbmu}g!sO5vvYGmwNhLx zGp@y%w)?ufEBG%$90J7Q{@EFY@N0n!7mOgznbl`(%3B1O16-mdo&~@{fK2}ZyEt5x z20)?(O&csC;VXe(c>yE|Va)ZbuSZ>Ha*uu%gR}xuSLX}QAUK~C?rN)<*p+B)q8p_p zqP`@JemOyk{_+o#L?Qvfz}GC}tC?@>A|=`;CIW)xMzG7+7cN7hR*QdOVI4P(92{51 ztc+Ok1N@+Uo4;3}^USvAcZG#^6jlUPYr>hD(1i>>c5r|AK(m`9QfbN|gVI#O#g+W7 z1UP`-#*s}u`1I*EECY?1^q_{avit8eRr_Dmp1hphKh9Mhhf5@s_NE1|Fdf}HVioJz zx*~_BC_ARdR3E(_HTg;o{~G&QnkDW@-6AonC5H~=fljMuh^6cS%jLIxp#Rf z0{21SEw)eJh6a&&6DN8G$fT)J2&!EeQc{t7F37OPwK;nsEE!q^&I|##0O-{xg&%i@e@fBiq%^}1b3`?*`?!oQFjkd7H8sN-? zhfl;0E~cZyeYnGoKyT&10)~Hn>MW3)0WCp)01SA#{2Q`76d<@6O#1=3a5Mw#TpN&O zK>ZXT7#*_carh}N91_$mfZNprL zyKtJGsv|E0kzX3dM%R77zv+k-y@zR# z{f>wDf^<>8%%IZo%LN2-8+IUaVhFSqOW0!IH7Nk01~oB`u?B&bU%Jk89A~Kz(wZSB z?EbEh?H>_w2VG1-&OG+kwlRSm<)MfSo;e+IYCQ>R2SvO@~*Hb+qx}__{Ip^wF?j9<7eHFSnlESJq*EyJ&NI$ zyI`TRR;1-GQfTx%Q?T54$VE1BTxxo?X#ZOOII6lQc7Gpvz{pHqS&6~0Nx2i4jMey5 zIgqprrZhNs)6!d6S)Y~tIrkaLguR|RaG4I}03h?o0R0R8e$2pZn@P!RB;p@9QBk9{x zgMAuUxZGXw=e-uvd1<>BrNJo5`Y-^?rQm?4q|J|Mqm zufRZws=~vcq)+<%PXwK((e+j`k!+w}Ag^nwN03zR`Ymwro8;VqtS6jOL;*b}8JL}! zISMg75cvSN2(X9u#4D36>_6Aft{t)sBbss#)9;l`oeAbUf zp&tQ6eA86x-(D?44Fp6WMY)XtF=*3>YeB1*L_>r`evw05)wYZdLgb5RDp*pH&zE&QR zJf7#lg@oXF6#D-&@2U_4LV#=8{kNYNuHM=PoU)+EA$3YR2;qy23}iJO($bgu(Tm$Z z65GWn-tnD@oZnR)#dp)pjT*Zl=y`QhE!;QI zvnzT@8KwRrfBHL6-HphFijjiIR%`jS%Iw9Lf&6#x6P#Broi%`B18Xbl^B28L8HfhV zO`1RG$0sK4xMC00-l7_HJ?>}P@nVd0B~~jhSVz>wIz*VBe9gU8=g@4&nYRCkVK)$3 z=gv?Gy$)`;XiAdIfK9aLfgKqCyJ~k;x$0F_Z6)Y>i@C_!Cww17)DYjcgHvLG;P*f z?>0@a{uJ*iXxHE^t1R~isIwtZbF@73#^pT0tS4Ypvj{m1dU|@Wc@LijL~R*L$>3F~ zj{VTzf08I1NMCz53~afFpe=)40hf}+!_D0X-fR$e0F!wHf9L2cCm+cEfM0>mtV;N> zPjK_15?)Au0^G^PF%qV8ZS5|2NW&ri*A;rlf1XNeT|0PzmyP8q6Z2;k_ zynL=}O3C+WiAQ)8-s=YUviCN*4jG9wC|%lCMUCP&Dd(zpJ}2LsG;QyxW?zpX%w>Js zCz=Z~(OGX)d+Y4CqYdF%z}x%fI3nXztEdZ^)zXfx>b_gRODWpu{M}X)pkhfJe&s$U zu>%#Z-AU6SGxzh%0iT>cN$GiFR`T6}NFT^!BY~U^>~&B+8T5Z3Unq)fx30I$GW}<` zzIBS@(30Y)7Q*i|jF`|ofA`FHc@Xh8&~h-o9z}7c3h08G8d2Io*repVSJDgeh(ciM z(ST_Jgk8WqK>Gj;H4Xdz0hzG9TxW2hz}texT+VJ-lL+MAXf||hoDew6FN-^BpQ4=zXncDk_6&o z(D~}?=@s$z08_sU$4~o#9QZQf9&&+s2j*`XnE=RMvi~UQRw!(Zk0|O*V*P&)Tx|OV z2nX&Nbd%qM3<$$|^WIbgNmU2~^%X&BV@26XCOOKXjB&#os>%_9iNuNesD?3Omof!3&2rli% zHnB!8+^HN1JybjE`R#>_ymnk>X4HirOCFxsMB}F9KUFJ5XFw9>ivn%Q(#~+}C=L^b z_gVkAb78T@`}R`DD&R8hd&ZD3lNSEi!?()O7kYZ}o=&|Q`pb{kRShB>GPn=piCU;7 zJ4)(+M<71(@bC~;eD5zPd60sfAAU$A8r-~VdF9GmSm^_I{cu8P4$$GO#k|@8!K-hU z^6}B1%2pBIig%277N6i%cOKQY_0L5WVCfghqShx>zm5G7^UvnR5GrL{Yn6BWYpC7t z6PB8-4U`d@LG@C$xdUmhJ$Onmb9RrouCtW=4*U+~XhhiCv|TY4SGD?U%&&@#`1CmH znH{RXyuccdF_NH2@c8+G@!Hj^x;i=}mfZ!a_R}9ApQ@}|#;SL>UXB7(^Om|dOodhZ%_G&3(qQ(X^h>or;-jH+3c}WS0KB~@-sOoc4 za0D5qonW?~Dq;;U3iZIMtK%z1{$a72%Tq>>J##>Mrh7v52+x(%@6}53KHqF%`}Z6b zv)#SYmua})%#4P$@nQ;ppkN7Z{Z!T3=2@S7MMec>5RMTGk4rGhv+i(q% ztKC?;RqCkPXUgZ(^ooG|;_&3?_><5lF$i2U7_=Kjn4qk+?90IK`Pl7h<)>VyG{UHGyvv zfMT~--}T!jilu)zARFP>9}EB7%nrY~8C0ghe)EQqp&p+<6JaTf;=MQz`=;|Vmi=1V zx^iRZtr;-+>8Z9E=W{1A65^h_>SEQ$r{vDb^l0hz2hIi?I#Joacy!Ts@ZL`)J+ogo z*}J594Q$`9Euz2E4b=EvteM$&-eKh_;)I&|O%9N#$j6R43?F*U;17HQh1CLsqR zns%k)d-7j$LbuOItiC67$0oDzHab{ZgM>q4y{|7NX-h+#Qr8o^buBOlG*|<2dBm~s zoyC}+0g|LNGOO@3_IIt!tlDnLBfFIkhSHn)l(GMCn@C7f{qT>Yorg=wiw=17ihcG8 zt-3Sd$GUJ?RhJTBQrRdn`X4O-=2DBf55^+nJadxuUIoUX>=b>nz`{jM+IOyTEk_ou zD9J5_!(Q(DZQ127gTo@{gvJSP2T|y134b$M+*O?EydWX&?d=@Y)7xuHE*pHr%iqxz z-~OP$=M1#!WTh5WCdFM5(WFSF%}r9KiMe!cNUiu)(-8w{d@R>pFArD$=s zjKF)IzrM|NZLwVJ_|mYWIdy8Vmt_~1%n3gjLPv~WTjZ@DA5zsVJ7B9cBc#rckr0KW z%?8$YnfZ&Ov6W2XrM%S5-OS-WesTE~Xi@rP7on?*lHxz#WegB&jHFHmSG=&zK)z#Z zqU{40e96jr$%b!#dDviA;4bSVe?2SIiK6F5erP5^?VhK^?;X~C;ETfl%!UxZE6nE& zD;#%RxRlg^(pPY8?L#Tog@BrWXRX$L37C&A=cR?kqEouMPApPwMly!Ag57B8CYe;_ zLy%EznXTJkzfTwKCo}<`-AD&nWG8${z83%0VCY}M-G9~sR*66c2+rT^DQ(CeQsc3^xt+$lzKiOsZu<*m405F zKi*Kv(%JX6`uj~gzs~km;kf$a0H--?!qlX?%;4uGn3;yeILz-$gEF*X8?O3p8>xl7 zWX6tEVkOq%RHxGvRDlzv#So@ylOe`BN}er8Gl=5HvYJHTV!B*_+k#<#_`&mEiT zt)4&Kz+XImeeCFuf%gZzdv@dq(xG1(ReK5ZqkFu@dQfy2{q2GK76;w|Z|=atYS9gK z+#F2#Ulp8Hc5h$1lpQvDUUFdecpPQv8?R4WCJzCC91GuCHLz0>eGeXCh|;)c_Qs3s z32VGC0}B;xXG0*#Ior`NRah8F|Kr6ea`>V;yZOS`656~Z{w*sdn zA^R)4k%gX>bdLnDc*qTa)_3R0Pc0t$%F@evQe@ltE<3D$9=3lAIQ*;N{i~Y?k=Hc7 zyd2ooU2P4mz;ji$eCO!Bl@OkFsvO>9u5MVoB{S)J*HQ_`E}#93oyAd{GX%IBHRg^z3B^h)q#?)+w(o?kn#6y7feu6PU7?7 z{aL^1AUJRF_z@MA%GG@n_mB;Kb~hg?^c$70uWo4}tre`?FLT6D+U=KZw;L-g3{%JM z^3pz}dtJVqcx5>y%t#ws6BkF8XL#OhE|pO0VuVJuNbpmO;5XwnKD(fEhNXU+p`(e4G2P9xRC@My-A z;{V5hY4TjScZm_)c4X)-MWZft)(-=GJD7Xw7cXLdP!S;GPaMLs3z-o9b_D5L@Nj+< zDs4%{4_rsEEC(6DbK!WW=B7%wp?R9ZNVhGQRST=8Swms7mj`WeVjO zuANcLw`4_*kP}+s&-kB7(~`Xp{er>h@$q;)UY&m1@7LGrbfGvl#Fg}7hg)?TLmw(P z5@RQ&E=%M}kIv}&h$8pW_*xpxJhQXDz`!%R5ch}U^9{B3wka-E(DU|Q!A?(kTYgMr zO)Lhz=aoYvpiTbHQh=S%-2%c}qyh7$5D;pbTqG-Gpg+)%w0TP+s z6Yt|3Gv-;{0*`B6Rtj$?AO9+u@9xq6edpWK#K+Cevz zQoa+4E6w+c8)rIOJHy>}ahhiYBtC)pu5hB{kZsNV%X8_T1jBqCi>2C=ZDvs7?We)y zc&PT8LV`0;*3~2{`fOm!!(hIqv-DBt0vo6&Y{<<|&FN1QIq0%jQnc}K9(j&6Q^o<( zxhVvC>a~yYGxkW7Yt=`SZ}OID6`gX&Ue*uFuX^rHq;HV(n0?(yJK9LxGpvfZNEhS~ zaft+bv0VP#2iHBbvn0I?K~9_P)Xi7;e{$Zxa=pjFmx$MIGP-T|d2fA9Y;oJ&wx*_v zLhZ=Wf{KaohE%K6>C{yuKQv%F1C^VGy+>fc?#e&&1i z)MzcaL&Yg#P06o)E@v-IUFJ8D46;)hpqWVAD8Trek>~MQeu{9|B>8hXNGH-RG8eU8 zp`DlZX3B$i?CjwrgJn%yuEM(cK}QPV(F*x`Fyo2_OmVBG4^*i)m&h{ayG+=pd^r3i z)UY832^~g|_CqLf^f~*T>2B-Ky9RzoPX$3)oS-_+7s61`gzEiNO4Cn)49I7`>jBT+ zg9?UXQAP@~;Ce(+SNBl&>9;G3DOMRp#wh0=;rkW4Qfn6s>zbXTAB4<(YH)b6y;gK4 z8B``|ptYZ7oTg$yKqw>_t&qC0A9!f&e^&zaS5^=~{GaOCI!-Cj3TXSHlLkFaZ@qsn zmF+xqgJ0x@T~ybo$YF-}{H0sIggcBk-29GaPqna+?rIpWR_bf(?vyYtoaKM~Mehs8 z$bcLr)AzdZ)Ei(;r>YQk_*6iB^JiCS+d8PtRwcdT8BX(MJI?q4)WOh#vh*BR`FdS6z ztDP+Kp26Pgx)68A!m!!Fb29N`2b&w#5>-$90%U4Zg*OV(-wql%k~O&{SV?_vHdqBK>YRjrMUutETFeNeO`SJy1sr4z03td2F@sud29Z|EyoV<0Yu9zt8g|4=3|-8bI9zq0Oq zj!SvM$&g-6tcW-6fdH}Pjamw9#|QDES+aW=$2^SprT&bfDdC6@c8j-emEPuwm71CI zE<3NLV9MR_O^xelVc-vktRek3Kb;3v7Zu`D{xI%NDiD!xFC@`;Pf_S(NJbJD_is{r zzlYfncwU_<<9^;QV*C$z0ks^9OWW&3T-{45XbUWtcB&ALWzur)9pSp+R(W5qED@qKFXa)*R^V?S)cE$j9jJW`v?| z;BmpX$ z9xi=TabisOaoXiKUFx~Fuz@>M#rh_$=E+JGU!L+hzq|UO`>oX*Et_D;3j8RA-GI9; z=Zg6UmtILB8@UR*+fvWXP*@5X*)zA^S0-DwANkRnhCddNrZJd4X?D8$+U$MarZ$jX z*y-?j{^qQ6c!2$>x>&@uXe4RaZ2Q_7Ke$ovM^U8X!D^($?XWBJMf=~Kyd44TmGgg0 zNj~)HFRFW`F7JdQsQn?RzZ&>A*o8r5D0ulY`1;mufH!iXfB5 zK@MP>0MJ6ftf>xkbkJSaP_AF9M~Q%g(8};DRGe;Z0GT6y?{~!!Fg9QWz|~&D--0XT z1owK~A21q^ymHdNapQ)!w>S8kk*Cnn3J`^~`)oHrSWpl6z?rAr3TJc!HXXoyNTBS6 zQqA+ZnGP9%XvzEkyaVTOP_W0}MYCkyTdC}VPm}OjGqE(~n;wc_qFaAm>S1m0r_NO1 z!FRoX^_g|f&tJJPAE}KrkH1Kq?0li5_lK38Awyf+izo-8a4;7v zYU`?I$}VgsZWDVwQ7Gx<32$tm9KdfErNrvcMoLsT%NdNJ4O6XeuK-;{a-6`HZ8#T1 zS&*L}7ED2XR+4dq;UG|cL}h#7ow2^@klfE2`Wylihz*E;BTC7|pEyxPz~yWNoR&HF zlF2iA8y5ZhWv}n%mK1Z{>3Ly0PK4mP{%9|}vMe>vj}@%7WL6dj+v*tv0=b9?} z{iMm$Bo&|8Nx%>7|J$zB()cT;J(^*4_=-P>Zo)Pc2}2;2(E^UO;3vQZiQ}64a&vJh z$a)13{Dq7219}8D1!4+D1Oe0ukVQ1Gbb!jEuvyYxj)0;Akq)jqyzl>^@ zP%V|;^2Io=vHABeC!kva=EYIQK>Z+!wdip`fser~@T|BPeC@b;Rb&H%vjitXK?ySn zUBs|z08^aly0_gY0U~-2zzl&i{fCF4EUw8)Dp~ywF+k`x4-Z*SvQzxS zo@jFjUwVgbA`t>%mLrwcs-R(jD+@|rTwkNZr*lOqKwrIaz@A4S)pkV__+App)$kExB_8c@CQ}SgF|8<@i%xwiJMNbbv2KJ{&p$`WIEb|o{}yTdQP;z+pfiHHti1$wCb%V2 zO!wbRn#~W3AS#3>5Y4x?lbuTH&ishVlk-UTBz;0$Tw6J`f4>w`y}#G}f(zN(o6$!& zAmqU^;CkShqlfjjB$rUyy1GY3lI3NKG4vC5+){FFLa9X#Rhc`B60~HoPU6?>9@$E) zYQBHY*Y$N@auc|&uFg(99UbSfx>F`O+#A-N2jCOHBPS#zY)ZHL;(Mu2Jg2^J)o`4h zAvzp3wlwT@IF^yLrC@`zig3+3J+#Oju#toG;T2@10=P@>!}>bc567ArfuI8U-kr=< zw+S~WqX=v&_Rz8kb}vsrnsHJg_!F?hR0Es{ZY+~ZPW1y@dwT$Dw>P`kCqW#YBNs3M zRS;nC1qyUoJ@G;&WVS)OLNG3|!Ni0G0TVvY;RGPOFzT)zvddZ zess39v`jglSm*ALPNFcXAMk6Y*xcK1{bAz2*iX#;7I!{^GuH^n5w^P{4^x~PZBE1{ zCfdb~Tw+PDtxWvAUh|@w-y)tnYPQ zS9P!SM^Zwr6h`;rD{YHji}^>3i+5_t*q+|(Y+b#;x@fl@N=`sO|CYqYox zTy#RA$+`S9DxNj#3&SNYPl&roi&`JLWJ!*|7KO>r=j7tTzXiiepZ&MWaZ&d2{vHgb z=-D#_j5P|!hnHQgnCjSxyX+~Ph5sIwW2%*%#_Be4DHY&Oo>~UCBD|=;j02C@X1zQk z#2OQaK>dj55J2)^y2iyE1Beeq*#t<7A5uV`7dK*TFyQ%O%BHtkfHwKF36oJ42|J^7|XGtE`E>?b^ zX920xL?2Cy-Tw#m)nR+%>eWaLFG4-{Oj}pbj{dTcjQH)rCaK{lQLnC(s=7V5wPeW5 zzX#?TUw=8jy>HNj(dOE{b&!#=K=p&hn-fEZAwx2>w24cvD}B>lE#fz`k@Jb*Ke z3He%XnhDAzpywgeWGfny<|($L|I1K_E4v4E2z)h4y10J_=+AOc34shIy`um|7hHq% z#&{*Jpaot@&^3;Pzzu_?F{#8vjcWtw$~p@j(#l|=f}IgtVX3@s7i%s?hIpbWvYFpH zjh`(QYWfPX?Y#;Xeds*;e~$?D+FEAi(Mo%_CgQKWO_WL`JSpL{qZUuUmvqM1?8J0i ztX8Pv2g684F8<0Yv^}ZW-yW~jXP6!Hgb#&@gzZFo;5VH;#>4}KT zubUflEx@JM)YZY42p>0ztKz4J3v3OQ^6itdW-^NW7U4ACw1rah|0Xk8lhrdKe81;c zji-Yni}Y-kvvd<*-+J1wni-=scYkzVq9tRh?YL_@C60UoF$U8~$rnZ4i7ua>y}#?s zAsZ=i(k>0fy^Skkqt3Yl57IU+>MosK0#}`0&dqK?`r;EI_f4&7aAIDP$LGDTu;hHkP0-wvxC!Q zfJqTvI#9W@3@;JLKU#))lND4u(7)jeWihxE50HIX;szSr=m+iIZ>p+9u4=(@XtW<- zYv$6`#W@q9;pBLv`CdR^;30fQt?#B4;_AHf{cZSyk5Melt;`5g_4_XYdDO&df@8>W z-)))%=26_MbibebVgyqo-udp6o%%WMM2?dH#Bi0gIqAGSi4eEH5eR=i@mxC^R^zjqUGIUe4~stD zOgeq>#fSE5oLzYZJaW)xe6}TyFEWlCZIoVqbEb$ZPA*OGN`i89hqOl6=q2>G%~SDR zvoO+lla%w(kr05sMFs}oS5W~k8`q=}@T3km`Uq(b;&?eXvycBLp>^xm|2v^&3g(oL zP~>s;?!e#4a=KO)v|NY1;Cr-~x*L4JOb>-epowuVesls!mxTxL{iC^6bRY1ReJ}W? z^@$#Ru94>ad*wRlh_~CyPxYO$F;(=7W;Ymf_hfnBjUc^Mw|!Pz(H(LeOnjv!im0V@ zM=qMRoSLi@h2XW5LHdD8VO@S!v;w&j;kQZ?!syyRu)dl7{9GB2gOTDS+hKXdGK52v znZOo%eV~KrRh6@^7%JXoT&?+(s^BdN)7vl267Y%J#7z+i7ejic;3NLAW;u%tJ6PD2 zc7}#$a?3NKbMd^%lW}HPj(!=lGmRwELj=e36x?D*Lj_F#KD36H8UlRuPJUbau&`ZE zNSj-CKlkgvZxChKPyGWBfdLdBIYL+<+p00u}H8bO;AUAiLr`l0`? z4sZ3#4+F8fWEu2C;e3D?ZsmNq{Kko?`ir`5^%)B*|950@Yq)}HL0TNNPuN1UBZ$?4 zMpJgqgzk z=YD^;B!>5JcR-|d>5n2m!LrPg3uEC$?}*x!KNb`ejOV0vQ%)y*1RHa^FdXOMfhBgw z$q75IhT~Ya2i2ZdM#tXKflF*&pYCT%sgnS+*WF1kdjB)Ot0!pjn=D{ZWqh?S z2P<{`G=Tp#n9_v1)OGmPt0%z1ZCS9v@FNw`SVZLY+d9290pd zIvms%m0##INMFg__i%HooYX6=L#$_)2LcGv8&S!%n$50gqoDhzVlz|UiHdXIpKWSf zx-8(tbaZD!|G_OMt^!`NNe;mr+~E}b0;$%Ia9^Jw?_Vx$FfSqcyg9q+8}I!}*)B4- zSLJd#z672_yVL({GaYl`wwW?lv11Xer=Mdm4Yt=Uij1{rA3qHHsY1G3;&z5OL)0>; zI1m9W-Q-5ylM0!l*~N6=%4m~l9@vk@~I#|X#U^g z*%-_;tKBkIv|Ub{;J2j2t%N$;EQ`be0eqr?wFa+BS4jBy8BQ$cScN!Bq{{lJ3d#Jb zk!br+!Rvn3b4Zjzoe^jJ<8=UjCc8d8^3g%IQV0$S_U7nkK6%@0fm$d|H8lysGmamk z2y?#M&K1o$0vdVt(?|m3Rm<9nhroZD*drbRJ+Vg8=-SkbyTEQ!0N&xYbKHueYaKSe zYa6BE^)V5c9hu0s*yWeIcPPiRjN_;#^8FW7ckJx5r4da%z0{z^!S>6|9xsY{5!XqD z=EuAIerqSibJ(ouNdqLBei0+55uMxtw`=Jbab3V7*&HUaR^wu0@sM_5mNz`wUjX-U zC-96hFZv`mPR6M&a(CbMbkD#J%zy*~PK-q6Z~IU(%|91o#QIyxbci7$Pv%^vcia_m zl=dK2mj*l0?y78d_O!js*1?lPTZjqbh#{x^Pati%Fs<>%zF1!$m&hv~;#HA7;oA~e zj6+66IY)JES?h(zDJgkpI%*g#RJYdD)tE0$qsK%tjDJ+zPO!5mw_+(f=ew~PACW|!qL>eSxq%*NYHb1obQR$ox z<_;IBu6)kJ6H|t$v9X$pcMIF^!xhZYOu5|fK;{-qu6GqdlJyE{g7pxDl)DL_;1Cp6 zr}$rN8(C|yLs5C=LphZMhSSXCDcqYI6TKAHytLA3{KUiYs`q!ug$_@WH;bD8&QoSX zJ&)te6-ok<>Bv+|mx70C@6TJ!?#EgD9(_vXR7Ds+pZ6-VqtkUe1+~Ls6yaB0dPUOq zHKlA0+&!^VAb-5V6dZwWz}UZVU`o7sXHpb3*55$A<#T!1DC6%MIrZObB-~LoJnYeH zZC$bRVV?;;=rlaJL;Gs+Vr1*o1-Aw|Zz% z;3J1SntpW58wZp=Ut-!lC_MCZ>8#6HA88w>{`$R~Yq9@oD2NJyI!4*9PTN2Tev1o) zb>e$3Qkt!DBe8wP9AJa-s_YGw=_1yIYHPvA*T+0lSSs^*-K)H^M0=|us;#-~O~1Fzl^GVRUu#o(h?4s_iA7#joF<-GHQ_ z^<63^(MXr1#MoF12v^FpcwoD)nH6>RenKJV6^i!OO48BOG&I9giN9&ae*$lmb5C4t zVp%RAIYK~nx@cy1-N#v&M#9u#rYiHRm#O4nABx;qP-U>DV2C~noZ8+3hmsX45-VR* zh7!7-&Hj%T!06onV+|wf$YbxsYuopeydPEv!osDo6*Cl@UrNvVbuzwOw3iq^pc22N zJzd0>8c`8Tz+`4Fe+;9({~@=|He-t{`Y_ZY`hm>(ylZ?PURFn|T)pbYpCD=3h9K9~ z)gvy4PT99!cg;F;C5#;3T3_20JAwI~U+-Y476BvFPW&LMal9r)UdCKi)azN=x8V2Q1?Nqa$ulGiN@R#AmZ=ad8RHero?s znTdmHTWdv#8Y=(i<1Ze5e)TIhmD)!lSKZ$m7{2&0s+W z130rbf1YU~@Lc`&Wqmgok|6{x0RHcbV+yA7H1b;U$%_r@8+hGZIlWLGZtu zu5F|P<3uS5XCO=E%DV5LAB;CCvWh)=zrU?@W+YnYeX_wSu{P<Lt)2o zdER+Tfwi~CXJS8p#(*`tYqhpj&a?d`#({xc^OdjRM6IeKA&}jenP~?*0%g)jtyfnX`vgGZSc3_0M@fQyEcN*~<>_}2+U z!thoBVE6_KMHm8}4;&A_AFC3Kn3RsN|35`}_}0uwe=Z{V52Y zQ2j ziNP?jvc7=irWvSc|3uH9cp*2bkNGaY@BW;jP>bwx{eoTHm(9^S-R`>+EXQ;vKH1iU zvVDj1ed7^!>bMVzcrJP<|46$hI8gd>;^0T=?)23uZjqhkuVom_S!O?>;K#d>nzY>N z;}_zd7DqB)lh0Nbb0qOM8uEIpvMhdh&Cdcu+48Z9|7_FvW~WC}nrJT7G3n?y+BB-H z+N}nBxOJR$2Kk4Vp{eM{NBO@4rK%1Bmz4_El-QlYrx0*l4Nx|yVBx}8|BrD9fDTdg zm@gQI5Mj7l&?f}X9E2o)677RLpLtkgb8}wn6RZfjel8VQCa;y@Q;3L&2>5nh*}SLu zY6Oolxc@{UpA5FgTY!qgI7-PRDojxTm>g(t0nJXeK0t(_w-JJ7zC!md{60V15kQWwoJZWDpkqLGo1#U3=vVA% zp0&vq1xPD`_W|G}lWmdYk@P_IDnMhZeeS*2@UH>evkbwkx@)1OgtbDU-e`(a_VE4$ z3mc!8j{m!H12*I^t&rrXE~-l7Vx*^c{;mzvH!gCI!XL6U5Cq)mG0?{-?fGLcPg~Ca zRHU;c;gG4m>$q3s3!{2n2z$Va%F8}s=qM3#>m0jH&Lp1LsLnhVmi#?b5&2a<+KnUE z7$3j}WZ zaA&KS?f;*-k(PmFAjElT{ehLjLny`2wSNf={MD1OkWkf1_8%&zHh_`jE~p?Fk{5 ze!SCo-+|9;cOJ(HQow`(=s&p9Mr#}{L!>OMA79|mCekbcP1}3d06?adNxdR0?%@47 z1_als*3Wh;iA^q%U7^|H?Fpp2zki~^tJKGb;Fg{_5+W90F&n2QM~fTCftZMu2-x8l z5*j)TLWHxXT~MP+Mn%u-GVlEKarTnzk}lD^WMhmKJ=>J#^AgP%<+i5wVe_H?+E_9* zn>Gc~lt>#Du~T26d$H^(=7*TV5wB0E+jQ$32fYOm03W@U{ei`aJM0%LoVyRZj}-#! z+1Re8y#7;+DU}H&u%bz&rXQhrs!rg>IZaGLq^YQNrWH@i)%DB?l9H$GF){cjwoX*> za&{MWa8#X-qfTXD83}WyX=4gyAnB-xc;MOKw0|VI)V`#xTP%Zj;<9WD^wSw>WWT~R z{w^JtAo(AB*ppQN()_ zCs@N%q!CR}(LAuz21LTQ$V4&DcWD#|YDGE(Qc zL}OUddkkfk@Oi@!WF~xfq6wFy4f~RPf7Xrb*WuB+bMt1LB_q64a52FFRF?8MH4;*B z@4)-@Vnsy->{&3X5UrF!ULp{FpT6K&zvs6j`A-MaY>an+?Gt90-|(1&d?8)Z%?g^2 zVHL&<-X;C>d%;dG5B5x5u)Bf109yCHf;|U(J6RBt{9GFhKL5OdM$Bw4#-=}KCT&T} zLRTy$JpAHaeb*^ly(3qeusOhqOu|@h_hO`n&y>qS#1-X?V^h8WnOT?9q!+R_1^={zsAS;>n;3u+gv2 zo6@_ei#cb&T|F^M*O4ePL|+~?jSWBWvN_@t?0yn3laBg8c}bs4pBSAY4^nAnd{dnbF+^jVpXZf$S>;YjM`qZ;tmisgLuf`ahA;UD&gIM?pN~y7`Rw@X7pT zOx3wI1&RmEd^}i|a?PYZ6-$D&KQi<4*5OIBCypbiuny-Ui zw@USFayjxxG}5;(CU4%HFDJC<4-UN|J`PjCK*bKWFyDUQlBeHX&*nVAN_2SP;=U@j z2+?$zlt(H&Pn`O2?K+c&T)e>9H|Jt*FZgG63MVJbh9@5=*-tK0K1E%=a~>td=mK_; zkb@?HYH^n}?p`uu@1jCs%Ax>gw|?4BxmElgE|(N8b37m-sZ?3(cd!{kW7i5{4o@i@+(kK!GI<&CaAfO!erp1{QgKnNZcn=A<6D()U04lh7d0$GUF{;%Gxa_R5Gb8&lr;^9B7 zRT7+>R)Pci8SfZKN1{KY*vED#Lz~QN{ z$H198c^M=TUR;(zLVJ@l!gg}^myo0(Vq~%9^W&3$K_!Cv@QdsHEE-<>lo8ujz>M@j^4}PZ(aHcOE@bH3!L@ zF-(oXw1tJ8V2YtcQlr6zW(&I~jvqB|g?Flo;xSkXft((Kk=rm8rfV4xD?sdhTwGmD zJs748Q_cP6=#UZ^Uf}%(F*z&#;@sSkbvowoHvtLdeIAXcrRxPU7np706B4}YO$LCk zfW4}s=^BJh^KC_DB(hxj(($(pgtFCyCkZN#hy z^6eg8W|GHq+yRl`%7bG@mN=y&&Q*M|?$vu*TFK?hR^*8=hyvvMaJVTgCa0ks`rk+U zk_~ipI#%<0b(+BWW;QDnvorGP)iTM}P+_l3Dyb(?FToco*8Jd9(hE8zu1Qo?k#vHw zHY`j`J%5UyA0a^$ClGJ{^4!=RlLU^JMcqTp%z#|1d~X zK*i$k?+^9DeH~lWh%Zp)Fi%6eCa#_xg7|Gg4uF7;AqtdVND`b>Zy0^tKt@UpOFS#-9405drp zK5@DFpf7@V>1FVt9`~9bwH1P&s~dg|i!=wSCLTYAjNJ>H&Qf>a2V zd(`=J=`KN8Qi!1G#Hpc2*SEuPcOK)lV(`Mciz!Q9fVX=s`r1|Yj?oL8oMCDlIol5? zSR^939^c5^5iyPtc7fY}bxg%2Lv6-Ya!o2Ab@Y0~lEo{BcHaH%XcBD0AUf0Y`Mwdy zqigi^^zQu{W-o`9fAv&-kQ2wzwGWKRex!GgE19%#t748)_;&*s=XWFec=%U}XO0Zq zZGv*Q%2^aaZ0Ef`fzr44{6Vm&+~=ygr#V#O_Qrovd38qNTXe&>zKy&f+jm<~9d?o)GOxmT3$|yy4N2A3q;*-dX7DoxbyXvN8qqj|{oW~*$^Pin(lbMmEq$d6} zzbHQVaJ~^-jQQ4*E0EFP`rz8fsKM`UFNXpmo)p^7+ipj~z7TX_fB*Lv= zm*cO_AEa8nd4KV)pE3Gito6A1&4b@QqXaSv!;KPqzk8-!2hX18k&?QstLr75Z9~4# zc8({+lCg^a5gV_d;Q3hCTRa*uXJe3^fYgKM@f;*pv8xe`BwOZ02gdiVbpARHv%!s# zVI18q_O}=MF&KBMfMMdhI!x#KyLEErIgEtsN2&v^DeXXGpnJV6TM~A@GHaM;*8g1~ zrcmmQg`HAc?PTJ4a>2Ca{e|N*>TY%h%8s13jL#3QW}f!h;ZJ!+rJ|a7TIE1o52F(s zK-Nt^n9Y- zQX(3yeE+=quB=JM7^r`~{gws&@SGc7ed?fr7M@^Qd|dT(Ejvk+t{4+BjfC|uF#XG44=C}v-nX*j<&?zv$4qG#N{~)tJ3#VDvGlzw~DdvpZ_?Kd=Jtr zSY=L%-7O~SQE{!Iz_-sdToKx;BE7CF0Hrw`68dh^cpUk4dlJ`P6t(|fq`d`DmTUVj z`qCXDB@NOgl9Ey)-Q6H9Al(WgQWhcINQ-nMp$O8AG$;Z}N{0%Z>*ZVD-s_zC@0oMv zFw87ngYfX&dHw2=x$io?Z}Y~MZFs9xf@kN5@ipAKy42I^rW}%>=;sS}@1Z_su>aSC`wg83_yklBM>=i4k59z;Sn@?YkONEinbPck(CL`jr}?tV z(EIc0>7P1J&9P-mW;-f3&LSfF5m%fEL!~<16M-9UwYhfF)S1?@kM#=O_mIF+srdFko# zyqW27^J_5|Polp3z1ENeqE>XrXHO7e+BZ-)i)=p#pK}VKf;0^ApW?n6xh`mttBpBW zmP62uGu=$b{ez1t}&)i=eNdU>`A3OB$e{jSb50Ysco=vacX+-a%HPw zk^O^~pv!D)vU;!Op2~~pi`e`?)PTP_WSgvk9KHC%l}2I9oYaC8GAHOy^>K5adn zJWW;sMN8Co}&+(dXRorj*km%v-+pwPhGw-oI3YqYwCcHD^Nrx7L+w}J04p9FPzWOXT zF3H(F6ZO8S4C@!P45SZ=**pI;kHHbu>&*7%YOL?p38GDD!^4_cJW;kW4+h@c?cw8yjvH&J6ctNjZxnoy{n!qUHA^3h zn}wC6FZ<-uSUJ?zRNks#1?7Z@y>)xRB&i|0xh5TdRU?ks^(PPgqs!OLgZi)6ZCW%{ zxYpEh`}fq!RfP|1MOuw~OiUkn<pj2wIU10csU z<|mOE!o)FBjFUdjO(@1YUAAFKk03m6vAU+X;nCQ>f{M74_^dR&0Rv?a@ zm!-BWK?lR(4`cVC>FUj&i`*`$2WbsIeobXN+C|h3h|p?QuhfjKF+YJsAI18Z4cX1V zm-nxFRNbO~2qg5=p==w@+Z~83M-ORJUIV_$u5`2;SXz9Yy!0RPP(q-$`u(QAJ#1&4 zwc4be>Jlt3st)QBnK>#vMKWZx5+O87+?!pU?S#s@Sl<8}2PNkT?(l9^PVa-Z5vKDY zkzIywAZW7rmRW7z_@Ot5j6VCX|JKEpHzYyMW$QH=RC7VwBiO zGT+L6&u3M-Bm#D=Vn@uOoo}9Nd&i5x==uWe+VjKen!@-`il*cl39L*t5z-TaDXW&( zeRPzdt8-KT5#=;KjDRb&k zSPX5)4e!)WZcWX)L0o__b`MJVQC_pjMyH6duo!i>KUgDaSXVCJFbDD|rHEsmo~KBi zpZ=$^5XUOikR10mbEcQ^S`UfLkYxASKhb)#4#m>WD+vN3A~w8xpLy{LSZU^)+cc7b zPpqmK^7H$!R+_YLC|TxH#Vhm(&xjF3*5PHeW{!3-rJy$^QN|h0hrKOP_SV~=L3JFn zOEA)#fO5CeqdH8^Y1274(22=M)NJxNO(aMp2d+^2pX69Y~V<(z_WoZGPC>#AM}H=t^)5Gv6f!?@XnOaKC(Dw2JH8@d2M^cqs9 zdH5-RMYN!r1ko91v^B*-t|O#Y!EYeBa{JD$%ZnAZ#}8>i9cx*h(+CoahbLB^0i54( zxTT7W?|y%?6$Y8hjtym#8wBi6!GgeaU&0Ji%=b|m?`-jea}1CfU#@(y;g_uX*>+c? zQ7oAraiVK8E5#g7JtLT#N7}?7PGhb!F2FM$kca;yzL2NZv2{jigk$FgwqrG{jojRvb(F6(z@OaQ&gzB-xTqdSA1hN=usPug#W?vqdrv~2AKKSSB9M<` zd%e8{;|2fA=g&<+X$z(Su$I-^_K~M%`|nR)F+Lfs41zd@NP*}FF*kRuZz7-}R4QMBS0f`G_3&2wvDg00GXP0dNb#t*{ap6SS1%rc0K$W)<0Mk<8Ipr=g zlLc};AklLSqV8}I(YYY<&xh<3=xz)${}&<)mW#B3(40^ha+aXfgzPKT^jpBaTL4^) z?2iHEF+_y!0!0I|RtXjsTi~~ls2RM3-%zoF`^q7ewQmm+7NMyYHUfeYA)5LKI&%;p zAU|*IKY&^t2wKf1kwA5$$4X>mB!F>1Na7&^j@aV}g+VP+0KWiUbSaUyp9<%K`X5vV{&wQ*CA+9gsB6ggZZ%KZA_kk9# z2`~MwIj_BI!k^fVG7k>XCyMSJjg?B+Qw+wh{P{8X?Q2 z`&A*OnY^LCaQf3+ZO;Yq!D64#i}$*&Sd;ldL;^;lr8PLXlcyN^R{EsdpDWgH3z}c> zt_%wLF244~;@r#M2nysiK&X;%B?k5_#4?w## zD}x*6@4L$&V`XGTIf1jXxnb}}xWeC?osNR0gLY%uJ_)q6W#5(_Fu58|m(%fOELfc{ z>X;T*y8I|H_|UeW!An7N3(*}+-0ygGiCWDAwIYjePOqSZ^KcP_pMjfua3Yz%f2txPQ~=aohSu1ee|sS+zj4y|P5+1i_Bnf2%uDCL1eC6d?y9|ce>%+%MJn208%*vc9%LeBoD`deYNoW z_VicVMJqr(z*>SLU>C4RNP(oIrf!BNG|-<0*-#K(_A7}!Y=q`<6r@7?f&&0Tsz87N z`@mnw8lo0-klubEdoRC{-4!;lJSNQ;M!rDhRv;ha3^6I-UHQ-P4+MIF+ zfE#$gEI0)Khl1}0$m0goOEeOGF%HLeUS_|hD!*U+3BLGsvilv`Qv!0-U}w^UT?UY` z!?iJ_5eU2r@L+HNZz2!HR0)6nwRkv2pj`v;m2?(M)(czoYpWr@s*qY=sG}IM<*ho` zNCTCL%gkGP%RG#Q@%+`5GBxnx{96_*O9>q_GSmw#XSzf^!?m^kBpAp)8nZ5XzU_8v zb4i`1O4a-B1S_j4SDY(I)h?^^>o@^pvvrrNx3g*FPr+-h<89jKu3 z@~W4)c+^o>?HYe%TOt-V>w~;%+p1ZNUj$P^>zMZE@<=#JTWind02)A1Abv#dDmB;1t7z!YB$9`Q;T#3Vm&Dk;P7K*V>@0Oi;!FgtP=Ql7<6cS36$&u0ESV% zLgJh;gb%`8LP7SIk^}`4-i`N~VC-Pa^s@%46~1b=%2q{JcN{EJ_l&*+_6=|)2pfnH z=RbnaKYw$R*xXNpzEe?U8?N=@5KsyF7C>6yr@`j`0QOSg0CY4YJ7GVJ$;!HbMnC%? zc!xO&6qz$L$NKy|>e;h@V03^yHL|2=DJZZIAdp9%eQg(KzhJKgeL`IKSh&@#j6pc% zhrnI?>_#gh1dNRyFa_Y?yTQpB0HT%YUw;7i2DKFh!aQiV6R1Li@yEz3*>e;fDG|+l5^?8WkpHg^wc(5XSdG6T=?`06%AeHXO zPwjKnpW{*7P=S|E-uLOtcPLRlFH1?6zBT9SDITlz(cj!wF?XMSCP-{VZwfzcm}PScyEH?BuWKTYf5T7faYsJ zG^KTkZn5kGDM)8u|MISnE+I4X9Qsane*O$C5Riee@WQzfNPj5Y8V`y+jrneKC0mmAmW%G!rsV?nXdmz|2vpjHs5}(nSs3q zkSwn?Z2-72)Cz7i#fiU*iHU(Hs1WrmN9eSI^amy$^rCTt`!Gym)DWOW;D;f{7dR_C z{(mef=#6s21RXi-%R~GhV8Xx!+XtRGK*RO!qeqW&h-CPg>~$(%E!@`Gdd2=k{wv<@ z2V2?Nk?w9qrUV6fd2C#>N^4W6b>=<$Bn7Jq6}d-ldg5M+G;N!Q0Xzvt(H(1Ew=$x~ z9LaCmDv>-J3AZ9vn;sUdhT~~gUja%aq!>Wy8!rfSM{w# zzkEsFf8Q7ACy;U>MW3qb>bwxlD;X%97INtMJUSZs%NfFki_}6wE*o6bun-X8X3Z;o4q1d?=zkwhxTK)(kJ#VRoTaLlR3-I13cwoROyGp(D0))H_*CqP8<2uXe^ z(_+RFwigzWg{%8aoy#8xQ80(+g*z)KilA}sAK1J}u3qhNS)mX93kMgG`+wJJXUb^bYY!kfaGtXfSF6LHqG`50yG~OA124C!xnn3 zmJ>D0bu-f(oM$Cw;ZHQl_sAF<5zI@4GW4f z$tx$SdWOYtH-IfqtAD0uu|(;lPT=5c-Otk6$cOwNd4wG5`i--%nh@4-_vyY{{VhM~ z-OTPN7R7`du{qw*hdLbBuYU)t6*d6@aA`0PuTz7~I79sh82fwH;dM59Z*HMVJnASN))?EJ72lH<%kL)2;&CF zEt01K3L3KXBPHfxal@G%&k&ifl7#GNcjfW}xF-<5B`FcY?g>9;8t&4q5G6i5JPbQ2 z8!Ibp^#X!|UO)~e9uq*`vl=L|=#X9H96)__{gs4lsW1rfdK8lSCg7s+!Oa=SO=020 z10UFbbjG&);HZcB3l`8b9ww$-xGHH#?0y3N3FRxC7r>3(a$k~%)!F_}r*PtBADLBm zZ_a2Pt$uQJyLUUOMB@Ij>?5xAvxADM?<)-9=A4#hvV7fAGAb&9;c?$e)w6d6dilZ( z9~zV*39d0KF}-9*ZF4q`&R$i&;~pLsbKCu4JKtaDH$D8*-CNdfH%)Jk-+m#gn2xTd z;}2bJ1dgVix zrUsae;dpAhI1hwF5XvTaBb-W*l$->)4#>=PWM@FwuOK==O+&-})o-XMpfZJ~@#Lm3Q z%MmId!69V7Yi{mMV4mH@GN5>a*;Czoj0^{#YxJbxHUzxW2=+>)a+1UY28KF4m?(=|J9dHYIiDGaB zw>pgE!YK`%0f9jM4H2s~)Gy)2(hqe%+}uDdAqfu#qJ#$Og7NWj;6FD|HtKiqd|7(x;bh|~PoBZZ?`U?6(`Q+=Pg|^t$vs(qLbfJNk>r^rllKmtCooimW zN7@eQP~;}keL$ze;cEZpM$D5i#&uS371b-no3)^*5<<9C{&_|LDg}RFsQs#yTQoe7MIxe-7EHk-SY_^p1l;m z)7C3opVM8uo_mENHQ3n}u6{w)^f#E)os7J^3Pq~vV%|SF_&L4-JAK`~fcV#DE3Tg! z3oQzlzOV87Vhwc`y8Zg$alMna?*+zjTEqsH%;0L0iBSVKhbMllWw~AI^GLImN8FWf zO_kLdh(*$=@wN@O*(8$^8p62=s;j2-cr{*J)@q=_?RYn=vm)pqM3{QL*MN!mBiz_=V_k`h2bO|$qmX_Dn%S*7i_Yf_Ks6J`GYbCYg;htMHz%8b5wpshS zXAF^Xo+mGb5;T^6%2M%ZN+HR4Id?$#SoODt>oGu_HF2!@Tk!WBE6JAAXkTnlkQg1dRYgCX=Q<5t zQlTnhFPT)is`Hcz;d6d2o`!Zx)$%nBzo%5~BCCf%Ytma>j)*YsIhAqM7JtX8G@D%2 zlYzXwi!*v7_G`X6)+67s7f;AjP^%lt{IcyecIq{9ycvWFAAVfmjvWh6=UL9}?(0Ka z6om*?3%rtW2A_(>>NyQFz=>5{9i*&3slrMIU@<->27$Qoen-~aobD0%A|tx!pCbbG z(SJf==19Bc{o}Z?zB>{xe{zL3*#<#VqJE?T*j2e*S9}1!A`RY*6AeOl-u!)-6|F|; z70g|-EbxweNEC1%Zmuf)clX*U`KVtDw#I{DleO1w89NX7CHV>D)GRABM?!;Fd zYhMef*sIA;i^vw>xndYy zVho%)hX8p&p83nMH6*47XXREwkbLhoJv|Vf&Vja~nSu`*fx};(mskH&0zR&|*|r)J zs&lw8W*Z=0xDikkOe2{T9y!h&Fuq3~3;(SEW1qNITI4t?N~)}d178|v1!XwnBXL~H zfmlk7Sr+K&@^u4M*JjcyL&1E1jPp0MolzY3p~G)GqMQ76Wdb|pSXzoZxBQ>P1RfWC zVh-yWt3pkbwBt}U(dCZJb}SG>O1eXb!Z0=e*jlHI8-#3SN>c%zu{;MbN1%^vp`75` z(bR%i5gfSfJh}N6iIB&n5ps@;WWgp0Rr#vVBx(X+v{2aix7 z+=fN#8BvooKam}|c;Q$|;t`J7*60dtpN+gX*|Qnkd%GyCBBDD+K@Fy5Bu{nYywZjV zrhkaI7_b);jojyX;|5$rKJmNhBOZl#ed;`6+V^8#a6W-jiZ9~H!=gt!i88iV3sjCw zCX_X6psK>#vDbLOtJ&1haO>;XB@@(mNqzDF3k4TnP+yL0yVL?vw$2y%pWkRs!SIFZ zVF1}%j`K1$I~vL&TxXn#Y(2dd>pMIz)_P`R5_?%klA=c0{BUk?IFg%zCXi?=ZiML* z%7~+5R=0J*;5Sq?HDa?rOXjB9l8HTFeE@?!E7E_!u30ID? zDrz>G)Af&HL7_q!t~V)NjO$6XdB{w}U1r{ozaZ%#+h|g^ zyYbqKc5~uLGmD|jiHfVF>P~!-Q`Jycc%wYygwTunz3k-vq=DQLqqJ7Ck2SU0Aw_R7nt3`%h;K~V^XKK{mrbal3Keq<+(-fRDxlxuvFJoAp4?C^cTj=7#* zaZ+WDV_b^?W^8iQ2o?frRV_{S+CRgBYQ&i|baaHz6C#X;2o@N>HU3Z-S{6nhORcIRkw7e|dSGVs|2JzH-E*3Bj`Fv>1BaQ2KOo+-CbG{2dOOl89G zk~Nq47wI+l@uSg{f~|lcL6CB#U)e{faYt+`e=DF%Eb*o-h$ET96i<9BODxoE&z->^EI_uJT;jHC#b%c3Qq7 zUaVJt#UmxKpKe`wYE|ywJI}cov!uMyzld7YLrXX z`8wOGXAbBa8}lqZSyxkEb2`jy9H#1-iCfL=%~fnfSM@a85*OeQ8ew+X%}6MCP4MCS z92Ea)YJ`c4AKh{9+2bQ?IH&#CjLUe3!*dFN$taY%~5{YzN`^DQutroRSRn)JyKF<02CaIOvpQ+7d&IISmcTU79*i zXGU|AHd&ks+M}8M9{qOYg0IyQiy_P#O3Mj&*98|=T=`x;QC5dES|3{Fs36?qisdl(V8u${_;9OKZr7Rqpx zxKE8@?5r)S?qX#0iHtQgO=T)^HX=fEht3agWvExWL;_%Vf^8=ynVMW`Or=*?2!KFV ztN*@znn z_#J$kgF&MLuxI`C2+8)ooSR%RD3)NVA&htf2rX#Sq4$>)RP0czUK?&Kr2(2t0T3ea z@LXoctPDeh4ODdpt6!9*kvTNJzvyg2fsONp(54kF9&`kPiApk-Mbc}cuT4?0aoyWi ziRg}CK(567fAmX+*Oj6>WX^vIS8F!h<|hdXc$-h&lT%Xi?YbqI;WaT!WdKz@8vCUf zY2;-H*^_3Y#%Y-8%XpjzNkXzIT#HKq;^Te1ei(QAgJ2wtduoYO$vWY3 zs%+KXY_6k>)0jE^oX=xEmtr*&J9A?zvE;jWwvQ$Htiosc@T)%!efdxpE?-Evn)@Fe z=wp1u;l?CLw$-2u9+=yJupfb{70~4o`!xtm8XO#K1>OaUZa~nXuNTy?P^=QtioFI^ zaB3Z}>JLzDjOZYt z=Nn+AbP@oCbpfw(LrhFeNQevpagKa$U!@TzfX%{9H3+Z*@GpU|7!v{Pk1M4DR7E{D zQ0r#}?cwnJgshst^V1*5;4LV?Jq8|u7YkXC8yYqM_6dDZ5}YOASNRM$8#JeZ9yP&$ zhyu5O76O7_XdlK0&O1QOf#6kFR%VKVL9PRm2;c~ax&vP2j)H>J>2f@XhLMo%rG4rr zSn$Ahy0^byT~#FoeHsy8Nrow%JcDNvKpF%IZdJh-=YS4EG*v4+f{5^MUkVRJmS(e8 zA|Yz6Q@+!Bjp>|W|GCE{R|uy0aN?d`jSSZ{-4AFHMAao`Fa>2e-TmT$5eu%(yhxOZ ztq>-mJ8^m1j!EgL=9rxVl->&IRr!P+a+!e> z+W^?7r?(e6+1kLmKrr0A=?)Z&ybQ9ZC7{~K(3Vsa*kk}?KL*wsI{B$4^W{Jkl+7Pl zW9QI>qz(v<$5?c?V8o);=R5za1psIOB4$A6Eqa+t2=2rqKww~)k%6NyI|c^^XhmF2 z+v^?zS`Xa!EWixKm}(A?PX%PzKRg|>?H7~_%TgMXhj;1x^We7u9uK4z{?Hbv2mH6< zNQfOEeP|;Fj$sH1yn7d#Z6Z-$aO}aPcnEwrlI#Oe9f&8O4?qasyW)ZDVFTL(lBxsy z3$oq&a=bo znK1Vs8L*;1@uz|0j&H+}wUv6->W^4F(xemfOIRjW8;rgi77=qOX&2}9OFis5T)X}o z=lUsjazft<$;M2Lwag6pvtAD|GbuT;WN!3Q?!Sr>`}!)sUY#=_mfg$m}xd%iVh36a@1BK?tlGGjcX zQIK8QXuvQHJ0#L;2s9h4+ZxBQqEZPMUSPBk(xBXjT{CjOOO8kq9Z^y;m?{Ka8TArZ z;PutJ%=&ajlfhVLKx?-s+if5-fi#`o34`|zB!)XcV2GH^%+}zGM^!BV8p2c(1=}Iw zJ*@8^Ls_7daD4PgN^%FdF+go^bnn3uT!6vguRjGl4&1tIL_rw=q#jaYn)??lkczl~ zD_X$FsElrcRs<#j(i3+C;a595JCIA8s@wz1{0tmykxUx9eiG0X#zs_9@(dg-@JCp= zLIz!Bw|V*iu5|M9IsnWWo}&#W96WJi<5l9`znKU`nnru_VSO{_<@v|pD-z%AGvO^s z8JjA*qTbiD*dJAl?krpZm8p$^tq~zBRccB4xN(y~Infr0C(|LMb)v3nC~4Fuw?+JQ zW4PdHp^a2j3*|$9T&a5~7#OY@8f)4UTf@+drg%mEc^x&s2Jz7ndY={l+tGG>aur8= z3_<*kGA?wpPnm)FOzm0)A4>LS9~>0*4Wt@)0B4NMo#6NVIIn89TKKs@B-@qw z?<1UNSmB|~d47z?XADYzU zTVa3Qo9TjsEK{miR0YbeE3teS{FwPVsIp1qctY0Ky>i8eM9gf{hk}ga$qffFO8?Y= z;A(0l3+j+MFZ;ak0&)g0jKGZ-?cgMD^|v(yar1B)BUE}?f&P~C#b$IxP=$;IE4xcA zve?ga?gooFGOBLvH&8(!E&`nsl3eIRQ_qqjK)`#j5n^TS+VTNMu^11}7P!HoyW0QL zF;@rE^k@fsFTDxnfBqt+CtIMANTft&za1g%ThKx6Q&*RAoV=O%`*V%?>2wTawrGFGN6{GdN{4p&dI=jf=^Ez*2k z;(_wysTr>U27Uhh5J?S*o5_DM#tVu&BN{SY>rE^%KdMhD+G=|CGQ7C!Afp~=;B z<2R~lqg~mP9zCa!tL91F(}A{~^m=-r!$VvCCSWUl1v?9od|R5N5xKW)rbp*jG9Uig zp02;)adED=7}!s$rx*30b_G8Mxoouw|K)pIAo&00drMYDL#A3oD5z;`&?)Q@o8O@S z?28vKw!XZhMliFog1Fo33+7E876SiTo)>$mXVV z{VbBhLPS1$2BD)j4Q8RT^n5M{%qQHEfbZez+XL%Vd+=(!rJ{;TyzOa%fjLSDT-G6v z_d293BB0Zkk~=cR8L&(DyK6vX!wK0VNvuX(ahvCwursB*#fZ10q~!efT@cpg>k73s z$0EM&V($1oJjq7u|Aj;Eg+MqQV>n9<=Kr%~T+ynSIe?}YxF5MBJs#qeT~hJ^&rRN6 z5Njb7XP2$}ZznpvwAwny%grOd78WOtGl385=O`?W?wWj2_sIXo=u*OA*kkF! zTL&`r=~0mtTzvo=)#l28I=Z@EVD1DO2?RY-H*O4VCxZeIraUs0WqEo0t-*uk?pT{Y zaL<5i`Uso|2&5Gh8G{2E=ddxw$HhUMWS`^_B;`oM1~lUmgL%( z?Z`CXN&SQ&nsD!P!({TJp%2(&p`S6r+k{;vnX2Y_-x}Y2^+hb8rss5j)r?&m z30_BMMUkb{ zfKKE(_f4Op74@Cn+9BHb>KY_!()IEF$K1k`R`%>OQ$L&if%)D* z+`_{KE%0VfpD(SHshyil2bcG=kT*qonDy#Kokf0K!1(f)Xjv?)FH!uoQ9$jGo~k@J z$-|yXTpNBKgU9v3!3QXa!21){2A-at7*9QZkcj-;{OQI4nTXSRW%La}4J~tjG)z4k zH0XR5@GLh+5ZS>zF1pCMBGBMj_>fbC2hy2MM_BsZ{L0Gf4crm}zY{ zO}>~q2-X-}^B^#6j7v1HGwO>m`Mg|awuVLf=jNRurQgNcnugd2l6L6PZ28mZLuSOL zqeAP!z>`7O9E{i(x;X_T!hznyXluuWEmaP*KQk~zD^!wvH%;g%#dMg?*gbF2#4(TP z1&qXU-r1ftsLJ>)dm<*k`=EGU=uVnKbaoc=+@VTyD7U765-XEPjQOH{VC*ab;%RHS znXlFrzN|+n!GC2uS$USC@BLaFJnf3~K|PzQ$4gq3A_8fjz*frWA^jHpjbI6r)^U-TW9!Pj3H!Y% zg|Fz{$}e>JY=0QnGxKa+)sE)wTP?n#g2&NEvvvP$*f`Fi*0KGu5gUuqh`yWA2s_Gj zp}dK{Y8-PMmc6?xGtMobLxp_sBhoFDyvH^Xf)-`4Ff;I zo&xqntI?Hoba|bgy(l)!XVln_Q8_}7r4nr#nbW`UeVQJXkdu||cmGne7jA`Zcl0Ku zsHtq~^_{=vTgFx#s_`V#z40{M^Ag;6ET0p<40M#m)vG6yXZ>BJbL-|eOi-l5p-lW` zsidv+=`HuHq}D$cRL*g*pn}uxo`vGAjmWEcQ4=r0hF3dR@=uwpX%oo!YI7;CYw_=y zkKuds8+zMtA(=`{l~>hirgpc^)z=w_twt;{)Tzr`k~NGW+5sMj!!Vl^_olK_uWXo*ix;*yUWAnDb}jxf-VT&i6>0u;@gL8i|pC(No$^);3C^i}Oh-u9Bc;^+gq} z{B=SF4oueY)Sqoj2fEiGF^}fOhDdREnF;6fJjUB+f(c?48%+yMhZ{crqo<5L!GqSB zPub&GwrP$MUWXmglB!=>osqStCH_n}W&<8lt1Vd^uV>` z^!7=`FIoS56h)C1!C$)VHb5HIH{(SL&f@rB4|tcZ0VXO%Gt|R;Vb(_(#M1IkUtBs~ zzB6edY&HOj1e+bAtv5B(j?7`tP_IGdfV7tm6t98$?`aynf_QiSKY70^Nq^ppbn zZuw5zGk`kvlaAZv`uMl;&Q%~f7hRB);k=QYw@C`*uDDVkQX``P=pz52^z^ppxbWkI z>f%SrHk$66b$(yuRg~X{V0%nXTJAGtNLX*xG`;DZ(0}&k zek!Y=)^3MxinbB+qP&&ydzF}+&)=^kccEWbP0G={;D;MkM}=2GL1AShLqx~ptKFBU z9S-L>a>gJ(=N=VVl`W^$5AYL?tM;I~>qommp_||U=_a2Vm68j;#@nV$xhHoh4ipSk zagWJ%hL5A?dQ@>%vtSA|Tk>7QOiAv#v2m2jA?}|&wz3x>u_`#8@r0W8nZixnO@JyI zP)6UISCCpgAur%LoagU1^y3P1Y&LU4`{*UD*Hlr2jR4o$QYHEen|hCA_gggbN+O{< zjF%*<3Ch17(+HiKsW;n^D&c68kd2?lJGJ2ENMaWQVQ~F@+=<$0?NE2Z9Jk6*)L(>6 zeY~I2TyrX$mSZ5a;_+Ln6{8ROU5;e8UBj1&huKq@8P?x5HY@MhD_81xY^_^~{Pdaa zxsIhmV9cO;Kh-WN2ks0JMNeAzc>ECe?z+3*^q**#P-BX5^HV}aC?)dDwLH0jdFhj) z`77!!D?ibF=A_EN+`M11j=i0LamjO$ zeN7_%XRI0OuSK-waj8516eF|8Q@hG$oG0=e%org(;PtyEoAX3f3FEFiANRD1$z6Sd zuH46JrR>VZ&!r5xF*Jn-lXTh##JDR7w7~hNqUTMEkTAa?n!aVSbXO}*wM7cYW@koF z>(RjPa_K8|V+DPzs!uf22pyJ!u0C}n)NsFf!pq*dxGfiFD>sIK#XCK&m0FTmG?A*0 zN#T9@pjZAy9Qy3}mz#STS<5TT+sL_trgQ(Bpk(DDfdh_OzBaUs370BD7KwQBJ1O$xr#-PcAQXx#*%ND`S* zDQ=z`{9Tyz*q3+)Xhw;e%I+V$ns7mfkVIOfW9 zWjpKft$VrC9!5m>Rf<}j%hnsa6)Py7c77E6Mi*S3jM%C&a7I&5oxP%&On9;%wnaW* zpKhHJr#n(X<*ui?4t041#SmgH9io- zLgXE*TB;%ITY%ibRsr$rW_3;sD{}BtAhe^HJ$l|Oslc(M1c^gJ#?y+a$R!0A(|oOS zjak`Ln#K4q3-4y~4yrYenwOIB?}Sv%YT|Fn(aE~6mzM{>s$f}I3PA)5N9d>M++?$N zJXR`6wnjA1-rb_HA^8}>@#OKGdi;g;u@hC`_F(`9<}h;czdzZ|NN5uKU===sj7yh~o(Pd>K zPZ9&ZG*lHf_mZ&p0lZZlpA zo@Y)4q1jp`j@p3^DlJvIzrIPHF1t0ocO2uEQRX$-B_ha8j1!bmug8`>ttTRndNXq$iYhZIhJ}f1{p-T5Fvo}v#-BwOx2$HqA1VuzU7vjFd^OF2 zKWN$TLVvM=1rNC~phx|msh9F)P>BLW2Jtm3;79?O9222iZvGGoP=MHb0hU)&gE*51 za}AZiAAS3V7%npt@8HA4r<5kV4LCfsW140%sdcIaBiF}|AD=&e4)Vyj7j&=ep^ybx zWp_^xNbGMQzZUgHfC?rKhjsefM z7J*cj9f&6!92#1Lg9gY)6w97C764C?+DHjMKE!+IeS*}5a&ynY7rZTR?PZ|^j2xyj zA{zimBzpj?lG``g|8$lDMyCfdfTaHJbU=!!3Rp?OPu*hUT5vj*CJ?UZrdwtT*tr5S z4gf}2&}qB~D&G|&uFDxs{D}~BRUMEtIx6@=@0d{jj!}UD=^ZW7!dJMf!&<@bfic`j z2#RsIkFx9{5&EH@-7r=7bfnBIZu0KeQ$_;0d&GHQe#^s}igZ>ntV~c_<5z0ozw;B7 z5s-9^&p@!?#|=v5nMJYy~7bqV%)Hj9=660$z8oIddDr%t~0Z_#D1Nx$|= zc48SK?kt^TR|czaVx8T*rWurN{~SH(&bn1u;AmcMpr=>;%kAJXnrU5X86dx840t}~ zUFg%_vYVU`Y_?1y)Zu1+H1jyL0e4GBMHR*GBR4Up8=c_>EO zne*CGOU~?b4+%iFF&=XN@A(jzr6bT*ge0f5WF4$-+B!P6M#x}L0Bj6E*aeDUaKFX+ z{Qy=0xYpXDqR7PKgE5W3mIVl+LPf;7_8h^&h2{6Q1UB?(@S{z>GdYIE=993{=*d>O zItAYy*?q_FR$~er2p_->0*k%n-e*GLmcErHK*`{X!3~BCBrVJgi0<2g1$a;=^uOp5 z8`}V75T8TH4{RGT>FE;Sv}&d@78dBfcPU1LWH;9*k>g?$H1A$FgWJ4Ku9ulAy4HX< z*f?zJN@#`aykHfBl6C~)gTx$H8r{5whLNEM!`yj^C0d)Pz6|1(R#8G>!E7djL5g;s zjD4Xm9#13wr)c{y1QIBZj97{103XThw z!G1&Rfp!yI1Rxh5Y0`rA2kQdbFu~l1#RqYogDd6iy>P5nS66$R4Gj)r{t=G@3_loR zvB33r%X?iHb~dn(@WdPa`*rf(9Xs66YLY?sfJ~+msi`$$QNzVo*7Tso*i)i1MC%(xOX+i#~yq%7j(p5f_kdhY^aGY!8;wC$BgX7*6Z4IH4ESGQ8 zjzPp>_$#=J^Zy&!*S8V?ehD6;TMvH=A|U%LW&+$b^|K(!Ujv64Sp6@+&m!fw!+-#P z^*cC!A+K)=c9iK3FqY*~&NjfA1wGJ=$>q04f6_|xZMQ=n065;Xz_jk*z+S%ry~`lX z4P>9d<_yDr^jXyT-&{mW!%J)kDRqTO3wgA^e40Vp64)o`Gl0K(DRD#I{SAwh?(o5w ze+4{}j%Xr#0H^;$oo;QOE74N+HBMVh0Ufb~lHlKt>rk=^vBfY)jf-YCVwsra%0_c( z=AJGj7oyYO@4sdJ_Gm`zgxO3_&rH2=diHm~Iig8zt|p&Pt*a>g=^FJ&K~Cwn9@IB3 zxUq3tnVzK0^CgpS16q7MJbN1RN~ka|BU0*#?u^yIu)}(Ghhr6{5&$en4>mxt$n?Cm zNeD1(gdG;2R_p*&3O@fsfSw}`a8f}!FOZ0Nvi149SN0pOAO z01O18eaq)6Pcz6%;RXsu^8{OexLhHm>&DPu!v|6pgtR}v0Sox`()aHu+2G@D{#@%^ zuu_R_{bE95bM9FT#$;I~1b7yATo6Kcl8%oZ(-UqN6TMfic%}T2`8eO`alA|R_XkDE z&)br*eYcsS0zYg1y&kiL+j;Q=MZ}CqgpJ6)>GMPuL||5$ZcK@}o%KA_ME@((SLR{l z-X`6^&bPgSU-xltnwHYE=D%8ikF|H+_nzr~$6YD;G?6viXV+=VWxlfa?NO)uyYCtj z7XmK=b9(@xeRGEPtie-hSa7H1g?dB1I&J4g(CT6oIXe64uO-}-NBExJUz?u(+3hv6 zp8KUOeA56c;=-xuB4OjJ`hyE+y$eqFN!iHYK~rme*`<>ud^40X| zK{}&7N1%rSU%e!EQ`uN*xt&|Nn3=TDh7RaKS+C55Q_P+yf65GW6PS z$MV(ZYVgB=xjW;Q`^T!+K(~XA;<~7)KM?k?H^AQsy72n9e2zgpf$Zc3?yR*g;3J_3 zLh7vG<}d)4U(k*zDWQbGeFykyCs=#`C(Z>_1UJ;AtD$44Gc@>uy$YmAqJBGHK^cbv zVBrSboB~$+uTc@XPOJ9)_t-i z9hPLls0D1ICWHQpK&6v}g;iG&{E#C@*AC3?J|G#wx{#?%QySXXWhaba!WXkEG$ExR zXw7zf7P={^jh=#MolA?qdGz&ebw4^iFKu4vrcd*;xoxKU=Bf3Zr#TzUT+LtI3&*bQ zOygf7Mckne{a!L$tQ~-i2@Z2Gdxr|Ht*yZ|I^}loqsEEoQEPwVU3&&vcI{F3-oIPs~{V2;M7MqYS{y6kAwPLuW?r4d@ex=R5a!M?*h{@8B7j10(d-N*k?B%s~uC0yI?Kiy8{G?P4B-(Q2KdyCwi8mzSKsRK2MJ%h?@)w