From 30dbf6dae00f70f5ebea9a9e15f8e1d0fc4ba45f Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Tue, 19 May 2026 22:45:59 +0100 Subject: [PATCH 1/5] feat: Add limit-based source selection using spacing targets (similar to spatially indexed annotations) --- docs/user-guide/skeleton_editing.rst | 6 +- src/datasource/catmaid/api.spec.ts | 21 ++ src/datasource/catmaid/backend.spec.ts | 64 ++++ src/datasource/catmaid/backend.ts | 4 +- src/datasource/catmaid/frontend.ts | 10 +- src/layer/segmentation/index.ts | 226 ++------------ src/layer/segmentation/json_keys.ts | 8 +- src/layer/segmentation/layer_controls.ts | 43 ++- src/skeleton/backend.spec.ts | 33 +-- src/skeleton/backend.ts | 345 +++------------------- src/skeleton/base.ts | 136 +++++++++ src/skeleton/chunk_serialization.ts | 4 - src/skeleton/frontend.spec.ts | 12 +- src/skeleton/frontend.ts | 345 +++++++--------------- src/skeleton/source_selection.spec.ts | 190 +++++++++--- src/skeleton/source_selection.ts | 149 ++++------ src/skeleton/spatial_chunk_sizing.spec.ts | 19 +- src/skeleton/spatial_chunk_sizing.ts | 15 +- src/widget/layer_control.ts | 4 +- src/widget/render_scale_widget.ts | 51 ---- 20 files changed, 675 insertions(+), 1010 deletions(-) create mode 100644 src/datasource/catmaid/backend.spec.ts diff --git a/docs/user-guide/skeleton_editing.rst b/docs/user-guide/skeleton_editing.rst index 9ca833d134..c97b337b06 100644 --- a/docs/user-guide/skeleton_editing.rst +++ b/docs/user-guide/skeleton_editing.rst @@ -72,9 +72,9 @@ In the **Render** tab you can adjust: When you make a skeleton visible, a full fetch is triggered and you are guaranteed to see all nodes and details of that skeleton. Otherwise you see whatever is -provided by the spatial index level selected for the current view. The selected -grid size is controlled via the **Resolution (skeleton grid 2D)** and -**Resolution (skeleton grid 3D)** settings. +provided by the spatial index levels selected for the current view. The selected +levels are controlled via the **Spacing (skeleton grid 2D)** and +**Spacing (skeleton grid 3D)** settings. The **Seg** tab works as normal for a segmentation layer, allowing you to set the visibility of segments/skeletons by their ID or by label if one has been assigned. diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index 4c2133155f..e378b14ab3 100644 --- a/src/datasource/catmaid/api.spec.ts +++ b/src/datasource/catmaid/api.spec.ts @@ -539,9 +539,30 @@ describe("CatmaidClient skeleton editing methods", () => { ]); expect(getFetchPath(fetchMock)).toMatch(/^node\/list\?/); + expect( + new URLSearchParams(getFetchPath(fetchMock).split("?")[1]).get("lod"), + ).toBe("0"); expect(getFetchInit(fetchMock).priority).toBe("low"); }); + it("passes the CATMAID source-associated lod to node/list", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchMock = vi.fn().mockResolvedValue([[], [], {}, false, [], []]); + (client as any).fetch = fetchMock; + + await client.fetchNodes( + { + lowerBounds: [0, 0, 0], + upperBounds: [10, 10, 10], + }, + 0.5, + ); + + expect( + new URLSearchParams(getFetchPath(fetchMock).split("?")[1]).get("lod"), + ).toBe("0.5"); + }); + it("converts spatial skeleton grid cell indices to CATMAID bounds", () => { expect( getCatmaidSpatialSkeletonGridCellBounds([2, 3, 4], [10, 20, 30]), diff --git a/src/datasource/catmaid/backend.spec.ts b/src/datasource/catmaid/backend.spec.ts new file mode 100644 index 0000000000..e0ad46bb13 --- /dev/null +++ b/src/datasource/catmaid/backend.spec.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it, vi } from "vitest"; + +import { CatmaidSpatiallyIndexedSkeletonSourceBackend } from "#src/datasource/catmaid/backend.js"; + +describe("CatmaidSpatiallyIndexedSkeletonSourceBackend", () => { + it("passes the source-associated CATMAID lod to node/list downloads", async () => { + const signal = new AbortController().signal; + const fetchNodes = vi.fn().mockResolvedValue([]); + const source = Object.create( + CatmaidSpatiallyIndexedSkeletonSourceBackend.prototype, + ); + Object.defineProperties(source, { + client: { value: { fetchNodes } }, + parameters: { + value: { + catmaidLod: 0.5, + catmaidParameters: { cacheProvider: "cached_msgpack_grid" }, + }, + }, + spec: { + value: { + chunkDataSize: Float32Array.of(10, 20, 30), + }, + }, + }); + const chunk = { + chunkGridPosition: Float32Array.of(2, 3, 4), + }; + + await CatmaidSpatiallyIndexedSkeletonSourceBackend.prototype.download.call( + source, + chunk, + signal, + ); + + expect(fetchNodes).toHaveBeenCalledWith( + { + lowerBounds: [20, 60, 120], + upperBounds: [30, 80, 150], + }, + 0.5, + { + cacheProvider: "cached_msgpack_grid", + signal, + }, + ); + }); +}); diff --git a/src/datasource/catmaid/backend.ts b/src/datasource/catmaid/backend.ts index 2836bed353..765523396a 100644 --- a/src/datasource/catmaid/backend.ts +++ b/src/datasource/catmaid/backend.ts @@ -64,9 +64,9 @@ export class CatmaidSpatiallyIndexedSkeletonSourceBackend extends WithParameters chunkGridPosition, chunkDataSize, ); - const lodValue = this.parameters.catmaidLod ?? 0; + const catmaidLod = this.parameters.catmaidLod ?? 0; const cacheProvider = this.parameters.catmaidParameters.cacheProvider; - const nodes = await this.client.fetchNodes(bounds, lodValue, { + const nodes = await this.client.fetchNodes(bounds, catmaidLod, { cacheProvider, signal, }); diff --git a/src/datasource/catmaid/frontend.ts b/src/datasource/catmaid/frontend.ts index 1acc90e67d..8c5e259923 100644 --- a/src/datasource/catmaid/frontend.ts +++ b/src/datasource/catmaid/frontend.ts @@ -241,10 +241,12 @@ export class CatmaidMultiscaleSpatiallyIndexedSkeletonSource extends MultiscaleS const sources: SliceViewSingleResolutionSource[] = []; + const lastGridIndex = this.gridLevels.length - 1; for (const [ gridIndex, - { size: gridCellSize, lod }, + { size: gridCellSize, limit }, ] of this.gridLevels.entries()) { + const catmaidLod = lastGridIndex === 0 ? 0 : gridIndex / lastGridIndex; const chunkDataSize = Uint32Array.from([ gridCellSize.x, gridCellSize.y, @@ -275,6 +277,7 @@ export class CatmaidMultiscaleSpatiallyIndexedSkeletonSource extends MultiscaleS upperVoxelBound: this.upperBoundsInNanometers, }), chunkLayout, + limit, }; const parameters = new CatmaidSkeletonSourceParameters(); @@ -284,7 +287,7 @@ export class CatmaidMultiscaleSpatiallyIndexedSkeletonSource extends MultiscaleS parameters.catmaidParameters.cacheProvider = this.cacheProvider; parameters.catmaidParameters.readonly = this.sourceReadonly; parameters.gridIndex = gridIndex; - parameters.catmaidLod = lod; + parameters.catmaidLod = catmaidLod; parameters.metadata = makeCatmaidSkeletonMetadata(); const chunkSource = this.chunkManager.getChunkSource( @@ -383,10 +386,11 @@ export class CatmaidDataSourceProvider implements DataSourceProvider { spatial, readonly: sourceReadonly, } = spatialIndexMetadata; - const gridCellSizes = spatial.map(({ chunkSize }) => ({ + const gridCellSizes = spatial.map(({ chunkSize, limit }) => ({ x: Number(chunkSize[0]), y: Number(chunkSize[1]), z: Number(chunkSize[2]), + limit, })); // The model-space coordinates we emit are in nanometers, converted to meters for Neuroglancer. diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 3ece8d5da0..269082fc54 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -68,9 +68,6 @@ import { } from "#src/mesh/frontend.js"; import { RenderScaleHistogram, - numRenderScaleHistogramBins, - renderScaleHistogramBinSize, - renderScaleHistogramOrigin, trackableRenderScaleTarget, } from "#src/render_scale_statistics.js"; import { getCssColor, SegmentColorHash } from "#src/segment_color.js"; @@ -142,7 +139,6 @@ import { } from "#src/skeleton/node_types.js"; import { buildSpatialSkeletonGridLevels, - getSpatialSkeletonGridSpacing, type SpatialSkeletonGridLevel, type SpatialSkeletonGridSize, } from "#src/skeleton/spatial_chunk_sizing.js"; @@ -159,7 +155,6 @@ 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 { trackableFiniteFloat } from "#src/trackable_finite_float.js"; import type { TrackableValueInterface, WatchableValueInterface, @@ -199,7 +194,6 @@ import { parseArray, parseUint64, verifyFiniteNonNegativeFloat, - verifyNonnegativeInt, verifyObjectAsMap, verifyOptionalObjectProperty, verifyString, @@ -585,84 +579,6 @@ class LinkedSegmentationGroupState< } } -function findClosestSpatialSkeletonGridLevelBySpacing( - levels: SpatialSkeletonGridLevel[], - spacing: number, -): number { - let bestIndex = 0; - let bestDistance = Number.POSITIVE_INFINITY; - for (let i = 0; i < levels.length; ++i) { - const gridSpacing = getSpatialSkeletonGridSpacing(levels[i].size); - const distance = Math.abs(gridSpacing - spacing); - if (distance < bestDistance) { - bestDistance = distance; - bestIndex = i; - } - } - return bestIndex; -} - -function getSpatialSkeletonGridHistogramConfig( - levels: SpatialSkeletonGridLevel[], -) { - if (levels.length === 0) { - return { - origin: renderScaleHistogramOrigin, - binSize: renderScaleHistogramBinSize, - }; - } - const logSpacings: number[] = []; - let minLogSpacing = Number.POSITIVE_INFINITY; - let maxLogSpacing = Number.NEGATIVE_INFINITY; - for (const level of levels) { - const spacing = Math.max(getSpatialSkeletonGridSpacing(level.size), 1e-6); - const logSpacing = Math.log2(spacing); - logSpacings.push(logSpacing); - minLogSpacing = Math.min(minLogSpacing, logSpacing); - maxLogSpacing = Math.max(maxLogSpacing, logSpacing); - } - if (!Number.isFinite(minLogSpacing) || !Number.isFinite(maxLogSpacing)) { - return { - origin: renderScaleHistogramOrigin, - binSize: renderScaleHistogramBinSize, - }; - } - logSpacings.sort((a, b) => a - b); - let minDelta = Number.POSITIVE_INFINITY; - for (let i = 1; i < logSpacings.length; ++i) { - const delta = logSpacings[i] - logSpacings[i - 1]; - if (delta > 0) minDelta = Math.min(minDelta, delta); - } - const span = maxLogSpacing - minLogSpacing; - const minBinSizeForCoverage = - span / Math.max(numRenderScaleHistogramBins - 4, 1); - const lowerBound = Math.max(minBinSizeForCoverage, 0.05); - let binSize = lowerBound; - if (Number.isFinite(minDelta)) { - const maxBinSizeForDistinctBars = minDelta * 0.9; - if (maxBinSizeForDistinctBars >= lowerBound) { - binSize = maxBinSizeForDistinctBars; - } - } - if (!Number.isFinite(binSize) || binSize <= 0) { - binSize = renderScaleHistogramBinSize; - } - - const range = numRenderScaleHistogramBins * binSize; - const desiredPadding = binSize * 2; - const minOrigin = maxLogSpacing + desiredPadding - range; - const maxOrigin = minLogSpacing - desiredPadding; - const centeredOrigin = (minLogSpacing + maxLogSpacing - range) / 2; - const clampedOrigin = Math.min( - Math.max(centeredOrigin, minOrigin), - maxOrigin, - ); - const roundedBinSize = Math.max(binSize, 1e-3); - const roundedOrigin = - Math.round(clampedOrigin / roundedBinSize) * roundedBinSize; - return { origin: roundedOrigin, binSize: roundedBinSize }; -} - class SegmentationUserLayerDisplayState implements SegmentationDisplayState { constructor(public layer: SegmentationUserLayer) { // Even though `SegmentationUserLayer` assigns this to its `displayState` property, redundantly @@ -785,31 +701,6 @@ class SegmentationUserLayerDisplayState implements SegmentationDisplayState { (group) => group.segmentPropertyMap, ), ); - - this.spatialSkeletonGridResolutionTarget2d.changed.add(() => { - const levels = this.spatialSkeletonGridLevels.value; - if (levels.length > 0) { - this.setSpatialSkeletonGridLevel( - "2d", - findClosestSpatialSkeletonGridLevelBySpacing( - levels, - this.spatialSkeletonGridResolutionTarget2d.value, - ), - ); - } - }); - this.spatialSkeletonGridResolutionTarget3d.changed.add(() => { - const levels = this.spatialSkeletonGridLevels.value; - if (levels.length > 0) { - this.setSpatialSkeletonGridLevel( - "3d", - findClosestSpatialSkeletonGridLevelBySpacing( - levels, - this.spatialSkeletonGridResolutionTarget3d.value, - ), - ); - } - }); } segmentSelectionState = new SegmentSelectionState(); @@ -824,33 +715,13 @@ class SegmentationUserLayerDisplayState implements SegmentationDisplayState { ); objectAlpha = trackableAlphaValue(1.0); hiddenObjectAlpha = trackableAlphaValue(0.5); - skeletonLod = trackableFiniteFloat(0.0); - spatialSkeletonGridLevel2d = new TrackableValue( - 0, - verifyNonnegativeInt, - 0, - ); - spatialSkeletonGridLevel3d = new TrackableValue( - 0, - verifyNonnegativeInt, - 0, - ); spatialSkeletonGridLevels = new WatchableValue( [], ); - spatialSkeletonGridResolutionTarget2d = new TrackableValue( - 1, - verifyFiniteNonNegativeFloat, - 1, - ); - spatialSkeletonGridResolutionTarget3d = new TrackableValue( - 1, - verifyFiniteNonNegativeFloat, - 1, - ); - spatialSkeletonGridRenderScaleHistogram2d = new RenderScaleHistogram(); - spatialSkeletonGridRenderScaleHistogram3d = new RenderScaleHistogram(); - spatialSkeletonLod2d = new WatchableValue(0); + spatialSkeletonSpacingTarget2d = trackableRenderScaleTarget(8); + spatialSkeletonSpacingTarget3d = trackableRenderScaleTarget(8); + spatialSkeletonSpacingHistogram2d = new RenderScaleHistogram(); + spatialSkeletonSpacingHistogram3d = new RenderScaleHistogram(); spatialSkeletonNodeQuery = new TrackableValue("", verifyString); spatialSkeletonNodeFilter = new TrackableEnum( SpatialSkeletonNodeFilterType, @@ -876,64 +747,7 @@ class SegmentationUserLayerDisplayState implements SegmentationDisplayState { setSpatialSkeletonGridSizes(gridSizes: SpatialSkeletonGridSize[]) { const levels = buildSpatialSkeletonGridLevels(gridSizes); - const { origin: histogramOrigin, binSize: histogramBinSize } = - getSpatialSkeletonGridHistogramConfig(levels); - if ( - this.spatialSkeletonGridRenderScaleHistogram2d.logScaleOrigin !== - histogramOrigin || - this.spatialSkeletonGridRenderScaleHistogram2d.logScaleBinSize !== - histogramBinSize - ) { - this.spatialSkeletonGridRenderScaleHistogram2d.logScaleOrigin = - histogramOrigin; - this.spatialSkeletonGridRenderScaleHistogram2d.logScaleBinSize = - histogramBinSize; - this.spatialSkeletonGridRenderScaleHistogram2d.changed.dispatch(); - } - if ( - this.spatialSkeletonGridRenderScaleHistogram3d.logScaleOrigin !== - histogramOrigin || - this.spatialSkeletonGridRenderScaleHistogram3d.logScaleBinSize !== - histogramBinSize - ) { - this.spatialSkeletonGridRenderScaleHistogram3d.logScaleOrigin = - histogramOrigin; - this.spatialSkeletonGridRenderScaleHistogram3d.logScaleBinSize = - histogramBinSize; - this.spatialSkeletonGridRenderScaleHistogram3d.changed.dispatch(); - } this.spatialSkeletonGridLevels.value = levels; - if (levels.length === 0) return; - const target3dIndex = findClosestSpatialSkeletonGridLevelBySpacing( - levels, - this.spatialSkeletonGridResolutionTarget3d.value, - ); - this.setSpatialSkeletonGridLevel("3d", target3dIndex); - const target2dIndex = findClosestSpatialSkeletonGridLevelBySpacing( - levels, - this.spatialSkeletonGridResolutionTarget2d.value, - ); - this.setSpatialSkeletonGridLevel("2d", target2dIndex); - } - - private setSpatialSkeletonGridLevel(kind: "2d" | "3d", index: number) { - const levels = this.spatialSkeletonGridLevels.value; - if (levels.length === 0) return 0; - const clampedIndex = Math.min(Math.max(index, 0), levels.length - 1); - if (kind === "2d") { - this.spatialSkeletonGridLevel2d.value = clampedIndex; - const nextLod = levels[clampedIndex].lod; - if (this.spatialSkeletonLod2d.value !== nextLod) { - this.spatialSkeletonLod2d.value = nextLod; - } - return clampedIndex; - } - this.spatialSkeletonGridLevel3d.value = clampedIndex; - const nextLod = levels[clampedIndex].lod; - if (this.skeletonLod.value !== nextLod) { - this.skeletonLod.value = nextLod; - } - return clampedIndex; } linkedSegmentationGroup: LinkedLayerGroup; @@ -1337,10 +1151,10 @@ export class SegmentationUserLayer extends Base { this.displayState.spatialSkeletonNodeFilter.changed.add( this.specificationChanged.dispatch, ); - this.displayState.spatialSkeletonGridResolutionTarget2d.changed.add( + this.displayState.spatialSkeletonSpacingTarget2d.changed.add( this.specificationChanged.dispatch, ); - this.displayState.spatialSkeletonGridResolutionTarget3d.changed.add( + this.displayState.spatialSkeletonSpacingTarget3d.changed.add( this.specificationChanged.dispatch, ); this.displayState.hoverHighlight.changed.add( @@ -1790,10 +1604,8 @@ export class SegmentationUserLayer extends Base { sharedSpatialSkeletonSources, displayState, { - gridLevel: displayState.spatialSkeletonGridLevel3d, - lod: displayState.skeletonLod, - gridLevel2d: displayState.spatialSkeletonGridLevel2d, - lod2d: displayState.spatialSkeletonLod2d, + spacingTarget: displayState.spatialSkeletonSpacingTarget3d, + spacingTarget2d: displayState.spatialSkeletonSpacingTarget2d, sources2d: slicePanelSources, selectedNodeId: this.selectedSpatialSkeletonNodeId, pendingNodePositionVersion: @@ -1828,10 +1640,8 @@ export class SegmentationUserLayer extends Base { mesh, displayState, { - gridLevel: displayState.spatialSkeletonGridLevel3d, - lod: displayState.skeletonLod, - gridLevel2d: displayState.spatialSkeletonGridLevel2d, - lod2d: displayState.spatialSkeletonLod2d, + spacingTarget: displayState.spatialSkeletonSpacingTarget3d, + spacingTarget2d: displayState.spatialSkeletonSpacingTarget2d, selectedNodeId: this.selectedSpatialSkeletonNodeId, pendingNodePositionVersion: this.spatialSkeletonState.pendingNodePositionVersion, @@ -2048,11 +1858,11 @@ export class SegmentationUserLayer extends Base { (value) => this.displayState.spatialSkeletonNodeFilter.restoreState(value), ); - this.displayState.spatialSkeletonGridResolutionTarget2d.restoreState( - specification[json_keys.SKELETON_CROSS_SECTION_RENDER_SCALE_JSON_KEY], + this.displayState.spatialSkeletonSpacingTarget2d.restoreState( + specification[json_keys.SKELETON_CROSS_SECTION_SPACING_JSON_KEY], ); - this.displayState.spatialSkeletonGridResolutionTarget3d.restoreState( - specification[json_keys.SKELETON_PERSPECTIVE_RENDER_SCALE_JSON_KEY], + this.displayState.spatialSkeletonSpacingTarget3d.restoreState( + specification[json_keys.SKELETON_PERSPECTIVE_SPACING_JSON_KEY], ); this.displayState.baseSegmentColoring.restoreState( specification[json_keys.BASE_SEGMENT_COLORING_JSON_KEY], @@ -2123,10 +1933,10 @@ export class SegmentationUserLayer extends Base { this.displayState.spatialSkeletonNodeFilter.toJSON(); x[json_keys.HIDDEN_OPACITY_3D_JSON_KEY] = this.displayState.hiddenObjectAlpha.toJSON(); - x[json_keys.SKELETON_CROSS_SECTION_RENDER_SCALE_JSON_KEY] = - this.displayState.spatialSkeletonGridResolutionTarget2d.toJSON(); - x[json_keys.SKELETON_PERSPECTIVE_RENDER_SCALE_JSON_KEY] = - this.displayState.spatialSkeletonGridResolutionTarget3d.toJSON(); + x[json_keys.SKELETON_CROSS_SECTION_SPACING_JSON_KEY] = + this.displayState.spatialSkeletonSpacingTarget2d.toJSON(); + x[json_keys.SKELETON_PERSPECTIVE_SPACING_JSON_KEY] = + this.displayState.spatialSkeletonSpacingTarget3d.toJSON(); x[json_keys.HOVER_HIGHLIGHT_JSON_KEY] = this.displayState.hoverHighlight.toJSON(); x[json_keys.BASE_SEGMENT_COLORING_JSON_KEY] = diff --git a/src/layer/segmentation/json_keys.ts b/src/layer/segmentation/json_keys.ts index b1464ab1ed..017046b141 100644 --- a/src/layer/segmentation/json_keys.ts +++ b/src/layer/segmentation/json_keys.ts @@ -2,10 +2,10 @@ export const SELECTED_ALPHA_JSON_KEY = "selectedAlpha"; export const NOT_SELECTED_ALPHA_JSON_KEY = "notSelectedAlpha"; export const OBJECT_ALPHA_JSON_KEY = "objectAlpha"; export const HIDDEN_OPACITY_3D_JSON_KEY = "hiddenObjectAlpha"; -export const SKELETON_CROSS_SECTION_RENDER_SCALE_JSON_KEY = - "skeletonCrossSectionRenderScale"; -export const SKELETON_PERSPECTIVE_RENDER_SCALE_JSON_KEY = - "skeletonPerspectiveRenderScale"; +export const SKELETON_CROSS_SECTION_SPACING_JSON_KEY = + "skeletonCrossSectionSpacing"; +export const SKELETON_PERSPECTIVE_SPACING_JSON_KEY = + "skeletonPerspectiveSpacing"; export const SATURATION_JSON_KEY = "saturation"; export const HOVER_HIGHLIGHT_JSON_KEY = "hoverHighlight"; export const HIDE_SEGMENT_ZERO_JSON_KEY = "hideSegmentZero"; diff --git a/src/layer/segmentation/layer_controls.ts b/src/layer/segmentation/layer_controls.ts index 29d500c72d..20ae807763 100644 --- a/src/layer/segmentation/layer_controls.ts +++ b/src/layer/segmentation/layer_controls.ts @@ -6,10 +6,7 @@ import { registerLayerControl } from "#src/widget/layer_control.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 { - renderScaleLayerControl, - SpatialSkeletonGridRenderScaleWidget, -} from "#src/widget/render_scale_widget.js"; +import { renderScaleLayerControl } from "#src/widget/render_scale_widget.js"; import { colorSeedLayerControl, fixedColorLayerControl, @@ -72,8 +69,8 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ })), }, { - label: "Resolution (skeleton grid 2D)", - toolJson: json_keys.SKELETON_CROSS_SECTION_RENDER_SCALE_JSON_KEY, + label: "Spacing (skeleton grid 2D)", + toolJson: json_keys.SKELETON_CROSS_SECTION_SPACING_JSON_KEY, isValid: (layer) => makeCachedDerivedWatchableValue( (levels, hasSpatialSkeletons) => @@ -84,18 +81,15 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ ], ), title: - "Select the grid size level for spatially indexed skeletons in 2D views", - ...renderScaleLayerControl( - (layer) => ({ - histogram: layer.displayState.spatialSkeletonGridRenderScaleHistogram2d, - target: layer.displayState.spatialSkeletonGridResolutionTarget2d, - }), - SpatialSkeletonGridRenderScaleWidget, - ), - }, - { - label: "Resolution (skeleton grid 3D)", - toolJson: json_keys.SKELETON_PERSPECTIVE_RENDER_SCALE_JSON_KEY, + "Select the node spacing for spatially indexed skeletons in 2D views", + ...renderScaleLayerControl((layer) => ({ + histogram: layer.displayState.spatialSkeletonSpacingHistogram2d, + target: layer.displayState.spatialSkeletonSpacingTarget2d, + })), + }, + { + label: "Spacing (skeleton grid 3D)", + toolJson: json_keys.SKELETON_PERSPECTIVE_SPACING_JSON_KEY, isValid: (layer) => makeCachedDerivedWatchableValue( (levels, hasSpatialSkeletons) => @@ -106,14 +100,11 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ ], ), title: - "Select the grid size level for spatially indexed skeletons in 3D views", - ...renderScaleLayerControl( - (layer) => ({ - histogram: layer.displayState.spatialSkeletonGridRenderScaleHistogram3d, - target: layer.displayState.spatialSkeletonGridResolutionTarget3d, - }), - SpatialSkeletonGridRenderScaleWidget, - ), + "Select the node spacing for spatially indexed skeletons in 3D views", + ...renderScaleLayerControl((layer) => ({ + histogram: layer.displayState.spatialSkeletonSpacingHistogram3d, + target: layer.displayState.spatialSkeletonSpacingTarget3d, + })), }, { label: "Opacity (3d)", diff --git a/src/skeleton/backend.spec.ts b/src/skeleton/backend.spec.ts index bd978e48be..6340012108 100644 --- a/src/skeleton/backend.spec.ts +++ b/src/skeleton/backend.spec.ts @@ -20,7 +20,6 @@ import { getSpatiallyIndexedSkeletonChunkPriority, getSpatiallyIndexedSkeletonRenderPriority, SPATIALLY_INDEXED_SKELETON_PRIORITY_BOOST, - SpatiallyIndexedSkeletonChunkRequestOwner, } from "#src/skeleton/backend.js"; import { BASE_PRIORITY, @@ -63,7 +62,7 @@ describe("skeleton/backend chunk priority", () => { expect(SPATIALLY_INDEXED_SKELETON_PRIORITY_BOOST).toBe(-BASE_PRIORITY); }); - it("boosts spatial skeleton chunks in all views above equivalent volume-rendering chunks", () => { + it("boosts spatial skeleton chunks above equivalent volume-rendering chunks", () => { const basePriority = BASE_PRIORITY; const scaleIndex = 2; const localCenter = Float32Array.of(10, 20, 30); @@ -77,26 +76,18 @@ describe("skeleton/backend chunk priority", () => { const equivalentVolumeRenderingPriority = basePriority + SCALE_PRIORITY_MULTIPLIER * scaleIndex + distancePriority; - for (const owner of [ - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_2D, - SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_3D, - ]) { - expect(owner).not.toBe(SpatiallyIndexedSkeletonChunkRequestOwner.NONE); - const skeletonPriority = getSpatiallyIndexedSkeletonRenderPriority( - basePriority, - scaleIndex, - localCenter, - chunkSize, - positionInChunks, - ); + const skeletonPriority = getSpatiallyIndexedSkeletonRenderPriority( + basePriority, + scaleIndex, + localCenter, + chunkSize, + positionInChunks, + ); - expect(skeletonPriority).toBeGreaterThan( - equivalentVolumeRenderingPriority, - ); - expect(skeletonPriority - equivalentVolumeRenderingPriority).toBeCloseTo( - SPATIALLY_INDEXED_SKELETON_PRIORITY_BOOST, - ); - } + expect(skeletonPriority).toBeGreaterThan(equivalentVolumeRenderingPriority); + expect(skeletonPriority - equivalentVolumeRenderingPriority).toBeCloseTo( + SPATIALLY_INDEXED_SKELETON_PRIORITY_BOOST, + ); }); it("keeps spatial skeleton chunks ordered by distance after applying the boost", () => { diff --git a/src/skeleton/backend.ts b/src/skeleton/backend.ts index e7b1e67b13..688815a453 100644 --- a/src/skeleton/backend.ts +++ b/src/skeleton/backend.ts @@ -14,8 +14,6 @@ * limitations under the License. */ -import { debounce } from "lodash-es"; -import type { ChunkManager } from "#src/chunk_manager/backend.js"; import { Chunk, ChunkRenderLayerBackend, @@ -41,7 +39,9 @@ import { import type { SharedWatchableValue } from "#src/shared_watchable_value.js"; import type { SpatialSkeletonSourceState } from "#src/skeleton/api.js"; import { + forEachVisibleSpatialSkeletonChunk, SKELETON_LAYER_RPC_ID, + type SpatiallyIndexedSkeletonChunkSpecification, SPATIALLY_INDEXED_SKELETON_RENDER_LAYER_RPC_ID, SPATIALLY_INDEXED_SKELETON_RENDER_LAYER_UPDATE_SOURCES_RPC_ID, } from "#src/skeleton/base.js"; @@ -51,10 +51,6 @@ import { serializeSkeletonChunkData, type SkeletonChunkData, } from "#src/skeleton/chunk_serialization.js"; -import { - getSpatiallyIndexedSkeletonGridIndex, - selectSpatiallyIndexedSkeletonEntriesByGrid, -} from "#src/skeleton/source_selection.js"; import { BASE_PRIORITY, deserializeTransformedSources, @@ -63,8 +59,6 @@ import { SliceViewChunkSourceBackend, } from "#src/sliceview/backend.js"; import { - forEachVisibleVolumetricChunk, - type SliceViewChunkSpecification, type SliceViewProjectionParameters, type TransformedSource, } from "#src/sliceview/base.js"; @@ -79,14 +73,9 @@ import { import type { RPC } from "#src/worker_rpc.js"; import { registerRPC, registerSharedObject } from "#src/worker_rpc.js"; -export interface SpatiallyIndexedSkeletonChunkSpecification - extends SliceViewChunkSpecification { - chunkLayout: any; -} const SKELETON_CHUNK_PRIORITY = 60; export const SPATIALLY_INDEXED_SKELETON_PRIORITY_BOOST = -BASE_PRIORITY; -const SPATIALLY_INDEXED_SKELETON_LOD_DEBOUNCE_MS = 300; const tempCenter = vec3.create(); const tempChunkSize = vec3.create(); const tempCenterDataPosition = vec3.create(); @@ -123,59 +112,6 @@ export function getSpatiallyIndexedSkeletonRenderPriority( ); } -export enum SpatiallyIndexedSkeletonChunkRequestOwner { - NONE = 0, - VIEW_2D = 1 << 0, - VIEW_3D = 1 << 1, -} - -export function markSpatiallyIndexedSkeletonChunkRequested( - chunk: SpatiallyIndexedSkeletonChunk, - currentGeneration: number, - owner: SpatiallyIndexedSkeletonChunkRequestOwner, -) { - if ( - owner === SpatiallyIndexedSkeletonChunkRequestOwner.NONE || - currentGeneration < 0 - ) { - return; - } - if (chunk.requestGeneration !== currentGeneration) { - chunk.requestGeneration = currentGeneration; - chunk.requestOwners = owner; - return; - } - chunk.requestOwners |= owner; -} - -export function cancelStaleSpatiallyIndexedSkeletonDownloads( - chunkManager: ChunkManager, - sources: Iterable, - currentGeneration: number, -) { - const queueManager = chunkManager.queueManager; - for (const source of sources) { - for (const chunk of source.chunks.values()) { - const typedChunk = chunk as SpatiallyIndexedSkeletonChunk; - if (typedChunk.state !== ChunkState.DOWNLOADING) continue; - if ( - typedChunk.requestGeneration === currentGeneration && - typedChunk.requestOwners !== - SpatiallyIndexedSkeletonChunkRequestOwner.NONE - ) { - continue; - } - const controller = typedChunk.downloadAbortController; - if (controller === undefined) continue; - typedChunk.downloadAbortController = undefined; - controller.abort( - new DOMException("stale spatial skeleton LOD download", "AbortError"), - ); - queueManager.updateChunkState(typedChunk, ChunkState.QUEUED); - } - } -} - registerRPC( SPATIALLY_INDEXED_SKELETON_RENDER_LAYER_UPDATE_SOURCES_RPC_ID, function (x) { @@ -191,7 +127,7 @@ registerRPC( >; attachment.state!.transformedSources = deserializeTransformedSources< SpatiallyIndexedSkeletonSourceBackend, - SpatiallyIndexedSkeletonRenderLayerBackend + any >(this, x.sources, layer); attachment.state!.displayDimensionRenderInfo = x.displayDimensionRenderInfo; layer.chunkManager.scheduleUpdateChunkPriorities(); @@ -317,9 +253,6 @@ export class SpatiallyIndexedSkeletonChunk vertexPositions: Float32Array | null = null; vertexAttributes: TypedNumberArray[] | null = null; indices: Uint32Array | null = null; - lod: number = 0; - requestGeneration = -1; - requestOwners = SpatiallyIndexedSkeletonChunkRequestOwner.NONE; nodeIds: Int32Array | undefined; nodeSourceStates: Array | undefined; @@ -345,35 +278,12 @@ export class SpatiallyIndexedSkeletonSourceBackend extends SliceViewChunkSourceB SpatiallyIndexedSkeletonChunk > { chunkConstructor = SpatiallyIndexedSkeletonChunk; - currentLod: number = 0; - currentRequestGeneration = -1; - currentRequestOwner = SpatiallyIndexedSkeletonChunkRequestOwner.NONE; - - getChunk(chunkGridPosition: Float32Array) { - const lodValue = this.currentLod; - const key = `${chunkGridPosition.join()}:${lodValue}`; - let chunk = this.chunks.get(key); - if (chunk === undefined) { - chunk = this.getNewChunk_( - this.chunkConstructor, - ) as SpatiallyIndexedSkeletonChunk; - chunk.initializeVolumeChunk(key, chunkGridPosition); - chunk.lod = lodValue; - this.addChunk(chunk); - } - markSpatiallyIndexedSkeletonChunkRequested( - chunk, - this.currentRequestGeneration, - this.currentRequestOwner, - ); - return chunk; - } } interface SpatiallyIndexedSkeletonRenderLayerAttachmentState { displayDimensionRenderInfo: DisplayDimensionRenderInfo; transformedSources: TransformedSource< - SpatiallyIndexedSkeletonRenderLayerBackend, + any, SpatiallyIndexedSkeletonSourceBackend >[][]; } @@ -383,78 +293,30 @@ export class SpatiallyIndexedSkeletonRenderLayerBackend extends withChunkManager RenderLayerBackend, ) { localPosition: SharedWatchableValue; - renderScaleTarget: SharedWatchableValue; - skeletonLod: SharedWatchableValue; - skeletonGridLevel: SharedWatchableValue; - skeletonLod2d: SharedWatchableValue; - skeletonGridLevel2d: SharedWatchableValue; - private pendingLodCleanup = false; + skeletonSpacingTarget: SharedWatchableValue; + skeletonSpacingTarget2d: SharedWatchableValue; constructor(rpc: RPC, options: any) { super(rpc, options); - this.renderScaleTarget = rpc.get(options.renderScaleTarget); + this.skeletonSpacingTarget = rpc.get(options.skeletonSpacingTarget); + this.skeletonSpacingTarget2d = rpc.get(options.skeletonSpacingTarget2d); this.localPosition = rpc.get(options.localPosition); - this.skeletonLod = rpc.get(options.skeletonLod); - this.skeletonGridLevel = rpc.get(options.skeletonGridLevel); - this.skeletonLod2d = rpc.get(options.skeletonLod2d); - this.skeletonGridLevel2d = rpc.get(options.skeletonGridLevel2d); const scheduleUpdateChunkPriorities = () => this.chunkManager.scheduleUpdateChunkPriorities(); this.registerDisposer( this.localPosition.changed.add(scheduleUpdateChunkPriorities), ); this.registerDisposer( - this.renderScaleTarget.changed.add(scheduleUpdateChunkPriorities), + this.skeletonSpacingTarget.changed.add(scheduleUpdateChunkPriorities), ); this.registerDisposer( - this.skeletonGridLevel.changed.add(scheduleUpdateChunkPriorities), + this.skeletonSpacingTarget2d.changed.add(scheduleUpdateChunkPriorities), ); - this.registerDisposer( - this.skeletonGridLevel2d.changed.add(scheduleUpdateChunkPriorities), - ); - - // Debounce LOD changes to avoid making requests for every slider value - const debouncedLodUpdate = debounce(() => { - scheduleUpdateChunkPriorities(); - }, SPATIALLY_INDEXED_SKELETON_LOD_DEBOUNCE_MS); - this.registerDisposer(() => debouncedLodUpdate.cancel()); - - const onLodChanged = () => { - this.pendingLodCleanup = true; - debouncedLodUpdate(); - }; - this.registerDisposer(this.skeletonLod.changed.add(onLodChanged)); - this.registerDisposer(this.skeletonLod2d.changed.add(onLodChanged)); this.registerDisposer( this.chunkManager.recomputeChunkPriorities.add(() => this.recomputeChunkPriorities(), ), ); - this.registerDisposer( - this.chunkManager.recomputeChunkPrioritiesLate.add(() => { - if (!this.pendingLodCleanup) return; - const sources = new Set(); - for (const attachment of this.attachments.values()) { - const attachmentState = attachment.state as - | SpatiallyIndexedSkeletonRenderLayerAttachmentState - | undefined; - if (attachmentState === undefined) continue; - for (const scales of attachmentState.transformedSources) { - for (const tsource of scales) { - sources.add( - tsource.source as SpatiallyIndexedSkeletonSourceBackend, - ); - } - } - } - cancelStaleSpatiallyIndexedSkeletonDownloads( - this.chunkManager, - sources, - this.chunkManager.recomputeChunkPriorities.count, - ); - this.pendingLodCleanup = false; - }), - ); } attach( @@ -482,7 +344,6 @@ export class SpatiallyIndexedSkeletonRenderLayerBackend extends withChunkManager private recomputeChunkPriorities() { this.chunkManager.registerLayer(this); - const currentGeneration = this.chunkManager.recomputeChunkPriorities.count; for (const attachment of this.attachments.values()) { const { view } = attachment; const visibility = view.visibility.value; @@ -523,160 +384,46 @@ export class SpatiallyIndexedSkeletonRenderLayerBackend extends withChunkManager "pixelSize" in sliceProjectionParameters ? sliceProjectionParameters.pixelSize : undefined; - let resolvedPixelSize = pixelSize; - if (resolvedPixelSize === undefined) { - const voxelPhysicalScales = - projectionParameters.displayDimensionRenderInfo?.voxelPhysicalScales; - if (voxelPhysicalScales) { - let computedPixelSize = 0; - const { invViewMatrix } = projectionParameters; - for (let i = 0; i < 3; ++i) { - const s = voxelPhysicalScales[i]; - const x = invViewMatrix[i]; - computedPixelSize += (s * x) ** 2; - } - resolvedPixelSize = Math.sqrt(computedPixelSize); - } - } - const renderScaleTarget = this.renderScaleTarget.value; const is2dView = pixelSize !== undefined; - const skeletonGridLevel = ( - is2dView ? this.skeletonGridLevel2d : this.skeletonGridLevel + const spacingTarget = ( + is2dView ? this.skeletonSpacingTarget2d : this.skeletonSpacingTarget ).value; - - const selectScales = ( - scales: TransformedSource< - SpatiallyIndexedSkeletonRenderLayerBackend, - SpatiallyIndexedSkeletonSourceBackend - >[], - ): Array<{ - tsource: TransformedSource< - SpatiallyIndexedSkeletonRenderLayerBackend, - SpatiallyIndexedSkeletonSourceBackend - >; - scaleIndex: number; - }> => { - if (scales.length === 0) { - return []; - } - if ( - scales.every( - (scale) => - getSpatiallyIndexedSkeletonGridIndex(scale) !== undefined, - ) - ) { - return selectSpatiallyIndexedSkeletonEntriesByGrid( - scales.map((tsource, scaleIndex) => ({ tsource, scaleIndex })), - skeletonGridLevel, - ({ tsource }) => getSpatiallyIndexedSkeletonGridIndex(tsource), - ); - } - if (resolvedPixelSize === undefined) { - return scales.map((tsource, scaleIndex) => ({ - tsource, - scaleIndex, - })); - } - const pixelSizeWithMargin = resolvedPixelSize * 1.1; - const smallestVoxelSize = scales[0].effectiveVoxelSize; - const canImproveOnVoxelSize = (voxelSize: Float32Array) => { - const targetSize = pixelSizeWithMargin * renderScaleTarget; - for (let i = 0; i < 3; ++i) { - const size = voxelSize[i]; - if (size > targetSize && size > 1.01 * smallestVoxelSize[i]) { - return true; + for (const scales of transformedSources) { + forEachVisibleSpatialSkeletonChunk( + projectionParameters, + this.localPosition.value, + spacingTarget, + scales, + () => {}, + (tsource, scaleIndex) => { + const source = + tsource.source as SpatiallyIndexedSkeletonSourceBackend; + const { chunkLayout } = tsource; + chunkLayout.globalToLocalSpatial(localCenter, centerDataPosition); + const { size, finiteRank } = chunkLayout; + vec3.copy(chunkSize, size); + for (let i = finiteRank; i < 3; ++i) { + chunkSize[i] = 0; + localCenter[i] = 0; } - } - return false; - }; - const improvesOnPrevVoxelSize = ( - voxelSize: Float32Array, - prevVoxelSize: Float32Array, - ) => { - const targetSize = pixelSizeWithMargin * renderScaleTarget; - for (let i = 0; i < 3; ++i) { - const size = voxelSize[i]; - const prevSize = prevVoxelSize[i]; - if ( - Math.abs(targetSize - size) < Math.abs(targetSize - prevSize) && - size < 1.01 * prevSize - ) { - return true; + const chunk = source.getChunk(tsource.curPositionInChunks); + ++this.numVisibleChunksNeeded; + if (chunk.state === ChunkState.GPU_MEMORY) { + ++this.numVisibleChunksAvailable; } - } - return false; - }; - - const selected: Array<{ - tsource: TransformedSource< - SpatiallyIndexedSkeletonRenderLayerBackend, - SpatiallyIndexedSkeletonSourceBackend - >; - scaleIndex: number; - }> = []; - let scaleIndex = scales.length - 1; - let prevVoxelSize: Float32Array | undefined; - while (true) { - const tsource = scales[scaleIndex]; - const selectionVoxelSize = tsource.effectiveVoxelSize; - if ( - prevVoxelSize !== undefined && - !improvesOnPrevVoxelSize(selectionVoxelSize, prevVoxelSize) - ) { - break; - } - selected.push({ tsource, scaleIndex }); - if (scaleIndex === 0) break; - if (!canImproveOnVoxelSize(selectionVoxelSize)) break; - prevVoxelSize = selectionVoxelSize; - --scaleIndex; - } - return selected; - }; - - const lodValue = (is2dView ? this.skeletonLod2d : this.skeletonLod).value; - for (const scales of transformedSources) { - const selectedScales = selectScales(scales); - for (const { tsource, scaleIndex } of selectedScales) { - const source = - tsource.source as SpatiallyIndexedSkeletonSourceBackend; - const { chunkLayout } = tsource; - chunkLayout.globalToLocalSpatial(localCenter, centerDataPosition); - const { size, finiteRank } = chunkLayout; - vec3.copy(chunkSize, size); - for (let i = finiteRank; i < 3; ++i) { - chunkSize[i] = 0; - localCenter[i] = 0; - } - source.currentLod = lodValue; - source.currentRequestGeneration = currentGeneration; - source.currentRequestOwner = is2dView - ? SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_2D - : SpatiallyIndexedSkeletonChunkRequestOwner.VIEW_3D; - forEachVisibleVolumetricChunk( - projectionParameters, - this.localPosition.value, - tsource, - () => { - const chunk = source.getChunk(tsource.curPositionInChunks); - ++this.numVisibleChunksNeeded; - if (chunk.state === ChunkState.GPU_MEMORY) { - ++this.numVisibleChunksAvailable; - } - chunkManager.requestChunk( - chunk, - priorityTier, - getSpatiallyIndexedSkeletonRenderPriority( - basePriority, - scaleIndex, - localCenter, - chunkSize, - tsource.curPositionInChunks, - ), - ); - }, - ); - } + chunkManager.requestChunk( + chunk, + priorityTier, + getSpatiallyIndexedSkeletonRenderPriority( + basePriority, + scaleIndex, + localCenter, + chunkSize, + tsource.curPositionInChunks, + ), + ); + }, + ); } } } diff --git a/src/skeleton/base.ts b/src/skeleton/base.ts index 051e19f1ad..5cb389328b 100644 --- a/src/skeleton/base.ts +++ b/src/skeleton/base.ts @@ -14,7 +14,21 @@ * limitations under the License. */ +import type { ProjectionParameters } from "#src/projection_parameters.js"; +import type { + SliceViewChunkSource, + SliceViewChunkSpecification, + TransformedSource, +} from "#src/sliceview/base.js"; +import { forEachVisibleVolumetricChunk } from "#src/sliceview/base.js"; +import { selectSpatialSkeletonSourcesByLimit } from "#src/skeleton/source_selection.js"; import type { DataType } from "#src/util/data_type.js"; +import { + getViewFrustrumVolume, + mat3, + mat3FromMat4, + prod3, +} from "#src/util/geom.js"; export const SKELETON_LAYER_RPC_ID = "skeleton/SkeletonLayer"; @@ -27,3 +41,125 @@ export interface VertexAttributeInfo { dataType: DataType; numComponents: number; } + +export interface SpatiallyIndexedSkeletonChunkSpecification + extends SliceViewChunkSpecification { + chunkLayout: any; + limit: number; +} + +const tempMat3 = mat3.create(); + +function getSpatialSkeletonSliceFraction(transformedSource: TransformedSource) { + const spec = transformedSource.source + .spec as SpatiallyIndexedSkeletonChunkSpecification; + const { rank } = spec; + const { nonDisplayLowerClipBound, nonDisplayUpperClipBound } = + transformedSource; + let sliceFraction = 1; + for (let i = 0; i < rank; ++i) { + const b = nonDisplayUpperClipBound[i] - nonDisplayLowerClipBound[i]; + if (Number.isFinite(b)) sliceFraction /= b; + } + return sliceFraction; +} + +function getSpatialSkeletonChunkPhysicalVolume( + transformedSource: TransformedSource, + canonicalToPhysicalScale: number, +) { + const { chunkLayout } = transformedSource; + return ( + prod3(chunkLayout.size) * + Math.abs(chunkLayout.detTransform) * + canonicalToPhysicalScale + ); +} + +export function forEachVisibleSpatialSkeletonChunk< + Transformed extends TransformedSource, +>( + projectionParameters: ProjectionParameters, + localPosition: Float32Array, + spacingTarget: number, + transformedSources: readonly Transformed[], + beginScale: (source: Transformed, index: number) => void, + callback: ( + source: Transformed, + index: number, + physicalSpacing: number, + pixelSpacing: number, + ) => void, +) { + if (transformedSources.length === 0) return; + + const { + displayDimensionRenderInfo, + viewMatrix, + projectionMat, + width, + height, + } = projectionParameters; + const { voxelPhysicalScales } = displayDimensionRenderInfo; + const viewDet = Math.abs( + mat3.determinant(mat3FromMat4(tempMat3, viewMatrix)), + ); + const canonicalToPhysicalScale = prod3(voxelPhysicalScales); + const viewFrustrumVolume = + (getViewFrustrumVolume(projectionMat) / viewDet) * canonicalToPhysicalScale; + + const sourceDensityInputs = transformedSources.map((tsource, index) => { + const spec = tsource.source + .spec as SpatiallyIndexedSkeletonChunkSpecification; + return { + source: tsource, + index, + physicalVolume: getSpatialSkeletonChunkPhysicalVolume( + tsource, + canonicalToPhysicalScale, + ), + limit: spec.limit, + sliceFraction: getSpatialSkeletonSliceFraction(tsource), + }; + }); + const baseSource = sourceDensityInputs.reduce((best, source) => + source.physicalVolume > best.physicalVolume ? source : best, + ).source; + let sourceVolume = + Math.abs(baseSource.chunkLayout.detTransform) * canonicalToPhysicalScale; + const { lowerClipDisplayBound, upperClipDisplayBound } = baseSource; + for (let i = 0; i < 3; ++i) { + sourceVolume *= upperClipDisplayBound[i] - lowerClipDisplayBound[i]; + } + + const effectiveVolume = Math.min(sourceVolume, viewFrustrumVolume); + const viewportArea = width * height; + const targetNumNodes = viewportArea / spacingTarget ** 2; + const physicalDensityTarget = targetNumNodes / effectiveVolume; + + for (const { + source: tsource, + index, + physicalSpacing, + pixelSpacing, + } of selectSpatialSkeletonSourcesByLimit( + sourceDensityInputs, + physicalDensityTarget, + effectiveVolume, + viewportArea, + )) { + let firstChunk = true; + forEachVisibleVolumetricChunk( + projectionParameters, + localPosition, + tsource, + () => { + if (firstChunk) { + beginScale(tsource, index); + firstChunk = false; + } + callback(tsource, index, physicalSpacing, pixelSpacing); + }, + ); + } +} diff --git a/src/skeleton/chunk_serialization.ts b/src/skeleton/chunk_serialization.ts index 2e9b762eb3..1fee9d2a68 100644 --- a/src/skeleton/chunk_serialization.ts +++ b/src/skeleton/chunk_serialization.ts @@ -21,7 +21,6 @@ export interface SkeletonChunkData { vertexPositions: Float32Array | null; vertexAttributes: TypedNumberArray[] | null; indices: Uint32Array | null; - lod?: number; nodeIds?: Int32Array; nodeSourceStates?: Array; } @@ -49,9 +48,6 @@ export function serializeSkeletonChunkData( msg: any, transfers: any[], ): void { - if (data.lod !== undefined) { - msg.lod = data.lod; - } const vertexPositions = data.vertexPositions!; const indices = data.indices!; msg.numVertices = vertexPositions.length / 3; diff --git a/src/skeleton/frontend.spec.ts b/src/skeleton/frontend.spec.ts index 00ad3d68ca..34ed008da6 100644 --- a/src/skeleton/frontend.spec.ts +++ b/src/skeleton/frontend.spec.ts @@ -120,13 +120,13 @@ describe("SpatiallyIndexedSkeletonLayer targeted source invalidation", () => { new Float32Array([100, 200, 300]), new Float32Array([100, 100, 100]), ), - ).toBe("1,2,3:"); + ).toBe("1,2,3"); expect( getSpatialSkeletonCellKeyPrefix( new Float32Array([99.999, 199.999, 299.999]), new Float32Array([100, 100, 100]), ), - ).toBe("0,1,2:"); + ).toBe("0,1,2"); }); it("dedupes cell prefixes per unique source entry", () => { @@ -163,13 +163,11 @@ describe("SpatiallyIndexedSkeletonLayer targeted source invalidation", () => { expect(invalidated).toBe(true); expect(invalidateCacheKeyPrefixes).toHaveBeenCalledTimes(1); - expect([...invalidateCacheKeyPrefixes.mock.calls[0][0]]).toEqual([ - "1,2,3:", - ]); + expect([...invalidateCacheKeyPrefixes.mock.calls[0][0]]).toEqual(["1,2,3"]); expect(source2d.invalidateCacheKeyPrefixes).toHaveBeenCalledTimes(1); expect([...source2d.invalidateCacheKeyPrefixes.mock.calls[0][0]]).toEqual([ - "2,4,6:", - "3,4,6:", + "2,4,6", + "3,4,6", ]); expect(redrawNeeded.dispatch).toHaveBeenCalledTimes(1); }); diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index 4867bf1a56..c7b8a4de2f 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -68,11 +68,13 @@ import type { SpatiallyIndexedSkeletonNode, SpatialSkeletonSourceState, } from "#src/skeleton/api.js"; -import type { VertexAttributeInfo } from "#src/skeleton/base.js"; import { + forEachVisibleSpatialSkeletonChunk, SKELETON_LAYER_RPC_ID, + type SpatiallyIndexedSkeletonChunkSpecification, SPATIALLY_INDEXED_SKELETON_RENDER_LAYER_RPC_ID, SPATIALLY_INDEXED_SKELETON_RENDER_LAYER_UPDATE_SOURCES_RPC_ID, + type VertexAttributeInfo, } from "#src/skeleton/base.js"; import { buildSpatiallyIndexedSkeletonOverlayGeometry, @@ -83,15 +85,8 @@ import { mergeSpatiallyIndexedSkeletonOverlaySegmentIds, retainSpatiallyIndexedSkeletonOverlaySegment, } from "#src/skeleton/segment_overlay.js"; +import type { SpatiallyIndexedSkeletonView } from "#src/skeleton/source_selection.js"; import { - getSpatiallyIndexedSkeletonGridIndex, - getSpatiallyIndexedSkeletonSourceView, - selectSpatiallyIndexedSkeletonEntriesForView, - type SpatiallyIndexedSkeletonView, -} from "#src/skeleton/source_selection.js"; -import { - forEachVisibleVolumetricChunk, - type SliceViewChunkSpecification, type SliceViewSourceOptions, type TransformedSource, } from "#src/sliceview/base.js"; @@ -1693,11 +1688,6 @@ export class SpatiallyIndexedSkeletonChunk } } -export interface SpatiallyIndexedSkeletonChunkSpecification - extends SliceViewChunkSpecification { - chunkLayout: ChunkLayout; -} - type SpatiallyIndexedSkeletonChunkListener = ( key: string, chunk: SpatiallyIndexedSkeletonChunk, @@ -1781,7 +1771,7 @@ export function getSpatialSkeletonCellKeyPrefix( } cell[i] = Math.floor(coordinate / chunkSize); } - return `${cell[0]},${cell[1]},${cell[2]}:`; + return `${cell[0]},${cell[1]},${cell[2]}`; } export abstract class MultiscaleSpatiallyIndexedSkeletonSource extends MultiscaleSliceViewChunkSource { @@ -1802,7 +1792,7 @@ export abstract class MultiscaleSpatiallyIndexedSkeletonSource extends Multiscal } getSpatialSkeletonGridSizes(): - | { x: number; y: number; z: number }[] + | { x: number; y: number; z: number; limit: number }[] | undefined { return undefined; } @@ -1811,12 +1801,9 @@ export abstract class MultiscaleSpatiallyIndexedSkeletonSource extends Multiscal type SpatiallyIndexedSkeletonSourceEntry = SliceViewSingleResolutionSource; -// TODO (SKM): is all of this really optional? interface SpatiallyIndexedSkeletonLayerOptions { - gridLevel?: WatchableValueInterface; - lod?: WatchableValueInterface; - gridLevel2d?: WatchableValueInterface; - lod2d?: WatchableValueInterface; + spacingTarget?: WatchableValueInterface; + spacingTarget2d?: WatchableValueInterface; sources2d?: SpatiallyIndexedSkeletonSourceEntry[]; selectedNodeId?: WatchableValueInterface; pendingNodePositionVersion?: WatchableValueInterface; @@ -1951,21 +1938,6 @@ class SkeletonOverlayChunk implements SkeletonGPUGeometry { } } -function getSpatialSkeletonGridSpacing( - transformedSource: TransformedSource, - levels: - | Array<{ size: { x: number; y: number; z: number }; lod: number }> - | undefined, - gridIndex: number, -) { - const levelSize = levels?.[gridIndex]?.size; - if (levelSize !== undefined) { - return Math.max(Math.min(levelSize.x, levelSize.y, levelSize.z), 1e-6); - } - const chunkSize = transformedSource.chunkLayout.size; - return Math.max(Math.min(chunkSize[0], chunkSize[1], chunkSize[2]), 1e-6); -} - // Tracks chunk keys already counted for a given histogram within a single frame, // preventing the same chunk from being counted multiple times when it falls within // the visible frustum of more than one slice panel in the same frame. @@ -1974,24 +1946,16 @@ const seenChunkKeysPerFrame = new WeakMap< { frameNumber: number; keys: Set } >(); -function updateSpatialSkeletonGridRenderScaleHistogram( +function updateSpatialSkeletonSpacingHistogram( histogram: RenderScaleHistogram, frameNumber: number, transformedSources: readonly TransformedSource[][], - projectionParameters: any, + projectionParameters: ProjectionParameters, localPosition: Float32Array, - lod: number | undefined, - levels: - | Array<{ size: { x: number; y: number; z: number }; lod: number }> - | undefined, + spacingTarget: number, ) { histogram.begin(frameNumber); - if (lod === undefined || transformedSources.length === 0) { - return; - } - const lodSuffix = `:${lod}`; - const scales = transformedSources[0] ?? []; - if (scales.length === 0) { + if (transformedSources.length === 0) { return; } let seen = seenChunkKeysPerFrame.get(histogram); @@ -2000,54 +1964,42 @@ function updateSpatialSkeletonGridRenderScaleHistogram( seenChunkKeysPerFrame.set(histogram, seen); } const seenKeys = seen.keys; - for (const tsource of scales) { - const gridIndex = getSpatiallyIndexedSkeletonGridIndex(tsource.source); - if (gridIndex === undefined) { - continue; - } - const source = tsource.source as SpatiallyIndexedSkeletonSource; - let presentCount = 0; - let missingCount = 0; - forEachVisibleVolumetricChunk( + for (const scales of transformedSources) { + forEachVisibleSpatialSkeletonChunk( projectionParameters, localPosition, - tsource, - (positionInChunks) => { - const chunkKey = `${positionInChunks.join()}${lodSuffix}`; - const seenKey = `${gridIndex}:${chunkKey}`; + spacingTarget, + scales, + () => {}, + (tsource, _, physicalSpacing, pixelSpacing) => { + const source = tsource.source as SpatiallyIndexedSkeletonSource; + const chunkKey = tsource.curPositionInChunks.join(); + const seenKey = `${getObjectId(source)}:${chunkKey}`; if (seenKeys.has(seenKey)) return; seenKeys.add(seenKey); const chunk = source.chunks.get(chunkKey); if (chunk?.state === ChunkState.GPU_MEMORY) { - presentCount++; + histogram.add(physicalSpacing, pixelSpacing, 1, 0); } else { - missingCount++; + histogram.add(physicalSpacing, pixelSpacing, 0, 1); } }, ); - const spacing = getSpatialSkeletonGridSpacing(tsource, levels, gridIndex); - const total = presentCount + missingCount; - if (total > 0) { - histogram.add(spacing, spacing, presentCount, missingCount); - } else if (!histogram.spatialScales.has(spacing)) { - // Keep the row visible in the histogram when no chunks are in view, - // but only if no earlier panel already populated it this frame. - histogram.add(spacing, spacing, 0, 1, true); - } } } export interface SpatiallyIndexedSkeletonLayerDisplayState extends SkeletonLayerDisplayState { - spatialSkeletonGridLevel2d?: WatchableValueInterface; - spatialSkeletonGridLevel3d?: WatchableValueInterface; - skeletonLod?: WatchableValueInterface; - spatialSkeletonLod2d?: WatchableValueInterface; + spatialSkeletonSpacingTarget2d?: WatchableValueInterface; + spatialSkeletonSpacingTarget3d?: WatchableValueInterface; spatialSkeletonGridLevels?: WatchableValueInterface< - Array<{ size: { x: number; y: number; z: number }; lod: number }> + Array<{ + size: { x: number; y: number; z: number; limit: number }; + limit: number; + }> >; - spatialSkeletonGridRenderScaleHistogram2d?: RenderScaleHistogram; - spatialSkeletonGridRenderScaleHistogram3d?: RenderScaleHistogram; + spatialSkeletonSpacingHistogram2d?: RenderScaleHistogram; + spatialSkeletonSpacingHistogram3d?: RenderScaleHistogram; } export function resolveSpatiallyIndexedSkeletonSegmentPick( @@ -2113,10 +2065,8 @@ export class SpatiallyIndexedSkeletonLayer computeTextureFormat(new TextureFormat(), dataType, numComponents), )); } - gridLevel: WatchableValueInterface; - lod: WatchableValueInterface; - gridLevel2d: WatchableValueInterface; - lod2d: WatchableValueInterface; + spacingTarget: WatchableValueInterface; + spacingTarget2d: WatchableValueInterface; private selectedNodeId: | WatchableValueInterface | undefined; @@ -2442,16 +2392,14 @@ export class SpatiallyIndexedSkeletonLayer this.displayState.transform, ), ); - this.gridLevel = - options.gridLevel ?? - displayState.spatialSkeletonGridLevel3d ?? - new WatchableValue(0); - this.lod = options.lod ?? displayState.skeletonLod ?? new WatchableValue(0); - this.gridLevel2d = - options.gridLevel2d ?? - displayState.spatialSkeletonGridLevel2d ?? - this.gridLevel; - this.lod2d = options.lod2d ?? displayState.spatialSkeletonLod2d ?? this.lod; + this.spacingTarget = + options.spacingTarget ?? + displayState.spatialSkeletonSpacingTarget3d ?? + new WatchableValue(8); + this.spacingTarget2d = + options.spacingTarget2d ?? + displayState.spatialSkeletonSpacingTarget2d ?? + this.spacingTarget; this.selectedNodeId = options.selectedNodeId; this.pendingNodePositionVersion = options.pendingNodePositionVersion; this.getPendingNodePositionOverride = options.getPendingNodePosition; @@ -2568,27 +2516,12 @@ export class SpatiallyIndexedSkeletonLayer this.rpc = rpc; sharedObject.RPC_TYPE_ID = SPATIALLY_INDEXED_SKELETON_RENDER_LAYER_RPC_ID; - const renderScaleTargetWatchable = this.registerDisposer( - SharedWatchableValue.makeFromExisting( - rpc, - displayState.renderScaleTarget, - ), - ); - - const skeletonLodWatchable = this.registerDisposer( - SharedWatchableValue.makeFromExisting(rpc, this.lod), - ); - - const skeletonGridLevelWatchable = this.registerDisposer( - SharedWatchableValue.makeFromExisting(rpc, this.gridLevel), + const skeletonSpacingTargetWatchable = this.registerDisposer( + SharedWatchableValue.makeFromExisting(rpc, this.spacingTarget), ); - const skeletonLod2dWatchable = this.registerDisposer( - SharedWatchableValue.makeFromExisting(rpc, this.lod2d), - ); - - const skeletonGridLevel2dWatchable = this.registerDisposer( - SharedWatchableValue.makeFromExisting(rpc, this.gridLevel2d), + const skeletonSpacingTarget2dWatchable = this.registerDisposer( + SharedWatchableValue.makeFromExisting(rpc, this.spacingTarget2d), ); sharedObject.initializeCounterpart(rpc, { @@ -2596,11 +2529,8 @@ export class SpatiallyIndexedSkeletonLayer localPosition: this.registerDisposer( SharedWatchableValue.makeFromExisting(rpc, this.localPosition), ).rpcId, - renderScaleTarget: renderScaleTargetWatchable.rpcId, - skeletonLod: skeletonLodWatchable.rpcId, - skeletonGridLevel: skeletonGridLevelWatchable.rpcId, - skeletonLod2d: skeletonLod2dWatchable.rpcId, - skeletonGridLevel2d: skeletonGridLevel2dWatchable.rpcId, + skeletonSpacingTarget: skeletonSpacingTargetWatchable.rpcId, + skeletonSpacingTarget2d: skeletonSpacingTarget2dWatchable.rpcId, }); this.backend = sharedObject; this.gpuBrowseExcludedSegmentsHashTable = this.registerDisposer( @@ -2616,19 +2546,6 @@ export class SpatiallyIndexedSkeletonLayer return view === "2d" ? this.sources2d : this.sources; } - private selectSourcesForViewAndGrid( - view: SpatiallyIndexedSkeletonView, - gridLevel: number | undefined, - ) { - return selectSpatiallyIndexedSkeletonEntriesForView( - this.getSources(view), - view, - gridLevel, - getSpatiallyIndexedSkeletonSourceView, - getSpatiallyIndexedSkeletonGridIndex, - ); - } - private getCachedNodeSnapshot(nodeId: number) { const cachedNode = this.getCachedNodeInfo?.(nodeId); if (cachedNode === undefined) { @@ -2759,70 +2676,46 @@ export class SpatiallyIndexedSkeletonLayer }; } - // Iterates every chunk slot in view for the given view/gridLevel/lod. - // Callback receives (chunkKey, chunkSource, chunkLayout); return false to stop early. + // Iterates every chunk slot selected by the current spacing target. + // Callback receives (chunkKey, chunkSource, chunkLayout). private forEachVisibleChunkSlot( - view: SpatiallyIndexedSkeletonView, - gridLevel: number | undefined, transformedSources: readonly TransformedSource[][], projectionParameters: ProjectionParameters, - lod: number, + spacingTarget: number, callback: ( chunkKey: string, chunkSource: SpatiallyIndexedSkeletonSource, chunkLayout: ChunkLayout, - ) => boolean | void, + ) => void, ) { - const selectedSourceIds = new Set( - this.selectSourcesForViewAndGrid(view, gridLevel).map((s) => - getObjectId(s.chunkSource), - ), - ); - const lodSuffix = `:${lod}`; - let shouldContinue = true; for (const scales of transformedSources) { - for (const tsource of scales) { - if (!shouldContinue) return; - if (!selectedSourceIds.has(getObjectId(tsource.source))) continue; - forEachVisibleVolumetricChunk( - projectionParameters, - this.localPosition.value, - tsource, - (positionInChunks) => { - if (!shouldContinue) return; - const chunkKey = `${positionInChunks.join()}${lodSuffix}`; - if ( - callback( - chunkKey, - tsource.source as SpatiallyIndexedSkeletonSource, - tsource.chunkLayout, - ) === false - ) { - shouldContinue = false; - } - }, - ); - } + forEachVisibleSpatialSkeletonChunk( + projectionParameters, + this.localPosition.value, + spacingTarget, + scales, + () => {}, + (tsource) => { + callback( + tsource.curPositionInChunks.join(), + tsource.source as SpatiallyIndexedSkeletonSource, + tsource.chunkLayout, + ); + }, + ); } } - getVisibleChunksInCurrentViewAndLod( - view: SpatiallyIndexedSkeletonView, - gridLevel: number | undefined, + getVisibleChunksInCurrentView( transformedSources: readonly TransformedSource[][], - projectionParameters: any, - lod: number | undefined, + projectionParameters: ProjectionParameters, + spacingTarget: number, ): VisibleChunk[] { - if (lod === undefined) { - return []; - } const result: VisibleChunk[] = []; this.forEachVisibleChunkSlot( - view, - gridLevel, transformedSources, projectionParameters, - lod, + spacingTarget, (chunkKey, chunkSource, chunkLayout) => { const chunk = chunkSource.chunks.get(chunkKey); if (chunk?.state === ChunkState.GPU_MEMORY) { @@ -2834,11 +2727,9 @@ export class SpatiallyIndexedSkeletonLayer } private areVisibleChunksReady( - view: SpatiallyIndexedSkeletonView, - gridLevel: number | undefined, transformedSources: readonly TransformedSource[][], projectionParameters: ProjectionParameters, - lod: number | undefined, + spacingTarget: number, ) { if ( this.displayState.objectAlpha.value <= 0.0 && @@ -2846,39 +2737,25 @@ export class SpatiallyIndexedSkeletonLayer ) { return true; } - if (lod === undefined) { - // No LOD configured — draw() renders nothing in this case, so nothing to wait for. - return true; - } if (transformedSources.length === 0) { return false; } let ready = true; this.forEachVisibleChunkSlot( - view, - gridLevel, transformedSources, projectionParameters, - lod, + spacingTarget, (chunkKey, chunkSource, _) => { const chunk = chunkSource.chunks.get(chunkKey); if (chunk?.state !== ChunkState.GPU_MEMORY) { ready = false; - return false; } - return true; }, ); return ready; } - getNode( - nodeId: number, - options: { - lod?: number; - } = {}, - ): SpatiallyIndexedSkeletonNode | undefined { - void options.lod; + getNode(nodeId: number): SpatiallyIndexedSkeletonNode | undefined { if (!Number.isSafeInteger(nodeId) || nodeId <= 0) return undefined; return this.getCachedNodeSnapshot(nodeId); } @@ -2886,10 +2763,8 @@ export class SpatiallyIndexedSkeletonLayer getNodes( options: { segmentId?: bigint; - lod?: number; } = {}, ): SpatiallyIndexedSkeletonNode[] { - void options.lod; const normalizedSegmentFilter = options.segmentId === undefined ? undefined @@ -3258,18 +3133,14 @@ export class SpatiallyIndexedSkeletonLayer } isReady( - view: SpatiallyIndexedSkeletonView, - gridLevel: number | undefined, transformedSources: readonly TransformedSource[][], projectionParameters: ProjectionParameters, - lod?: number, + spacingTarget: number, ) { return this.areVisibleChunksReady( - view, - gridLevel, transformedSources, projectionParameters, - lod, + spacingTarget, ); } } @@ -3452,8 +3323,13 @@ export class PerspectiveViewSpatiallyIndexedSkeletonLayer extends PerspectiveVie this.registerDisposer( renderOptions.lineWidth.changed.add(this.redrawNeeded.dispatch), ); - const histogram3d = - base.displayState.spatialSkeletonGridRenderScaleHistogram3d; + const spacingTarget3d = base.displayState.spatialSkeletonSpacingTarget3d; + if (spacingTarget3d?.changed) { + this.registerDisposer( + spacingTarget3d.changed.add(this.redrawNeeded.dispatch), + ); + } + const histogram3d = base.displayState.spatialSkeletonSpacingHistogram3d; if (histogram3d !== undefined) { this.registerDisposer(histogram3d.visibility.add(this.visibility)); } @@ -3515,27 +3391,25 @@ export class PerspectiveViewSpatiallyIndexedSkeletonLayer extends PerspectiveVie return; } const { displayState } = this.base; - const lodValue = displayState.skeletonLod?.value; - const visibleChunks = this.base.getVisibleChunksInCurrentViewAndLod( - "3d", - displayState.spatialSkeletonGridLevel3d?.value, + const spacingTarget = + displayState.spatialSkeletonSpacingTarget3d?.value ?? + this.base.spacingTarget.value; + const visibleChunks = this.base.getVisibleChunksInCurrentView( this.transformedSources, renderContext.projectionParameters, - lodValue, + spacingTarget, ); - const levels = displayState.spatialSkeletonGridLevels?.value; - const histogram = displayState.spatialSkeletonGridRenderScaleHistogram3d; + const histogram = displayState.spatialSkeletonSpacingHistogram3d; if (histogram !== undefined) { const frameNumber = this.base.chunkManager.chunkQueueManager.frameNumberCounter.frameNumber; - updateSpatialSkeletonGridRenderScaleHistogram( + updateSpatialSkeletonSpacingHistogram( histogram, frameNumber, this.transformedSources, renderContext.projectionParameters, this.base.localPosition.value, - lodValue, - levels, + spacingTarget, ); } const modelMatrix = update3dRenderLayerAttachment( @@ -3599,11 +3473,10 @@ export class PerspectiveViewSpatiallyIndexedSkeletonLayer extends PerspectiveVie ) { const { displayState } = this.base; return this.base.isReady( - "3d", - displayState.spatialSkeletonGridLevel3d?.value, this.transformedSources, renderContext.projectionParameters, - displayState.skeletonLod?.value, + displayState.spatialSkeletonSpacingTarget3d?.value ?? + this.base.spacingTarget.value, ); } } @@ -3633,18 +3506,13 @@ export class SliceViewPanelSpatiallyIndexedSkeletonLayer extends SliceViewPanelR renderOptions.lineWidth.changed.add(this.redrawNeeded.dispatch), ); const { displayState: displayState2d } = base; - const gridLevel2d = displayState2d.spatialSkeletonGridLevel2d; - if (gridLevel2d?.changed) { + const spacingTarget2d = displayState2d.spatialSkeletonSpacingTarget2d; + if (spacingTarget2d?.changed) { this.registerDisposer( - gridLevel2d.changed.add(this.redrawNeeded.dispatch), + spacingTarget2d.changed.add(this.redrawNeeded.dispatch), ); } - const lod2d = displayState2d.spatialSkeletonLod2d; - if (lod2d?.changed) { - this.registerDisposer(lod2d.changed.add(this.redrawNeeded.dispatch)); - } - const histogram2d = - displayState2d.spatialSkeletonGridRenderScaleHistogram2d; + const histogram2d = displayState2d.spatialSkeletonSpacingHistogram2d; if (histogram2d !== undefined) { this.registerDisposer(histogram2d.visibility.add(this.visibility)); } @@ -3694,27 +3562,25 @@ export class SliceViewPanelSpatiallyIndexedSkeletonLayer extends SliceViewPanelR >, ) { const { displayState } = this.base; - const lodValue = displayState.spatialSkeletonLod2d?.value; - const visibleChunks = this.base.getVisibleChunksInCurrentViewAndLod( - "2d", - displayState.spatialSkeletonGridLevel2d?.value, + const spacingTarget = + displayState.spatialSkeletonSpacingTarget2d?.value ?? + this.base.spacingTarget2d.value; + const visibleChunks = this.base.getVisibleChunksInCurrentView( this.transformedSources, renderContext.sliceView.projectionParameters.value, - lodValue, + spacingTarget, ); - const levels = displayState.spatialSkeletonGridLevels?.value; - const histogram = displayState.spatialSkeletonGridRenderScaleHistogram2d; + const histogram = displayState.spatialSkeletonSpacingHistogram2d; if (histogram !== undefined) { const frameNumber = this.base.chunkManager.chunkQueueManager.frameNumberCounter.frameNumber; - updateSpatialSkeletonGridRenderScaleHistogram( + updateSpatialSkeletonSpacingHistogram( histogram, frameNumber, this.transformedSources, renderContext.sliceView.projectionParameters.value, this.base.localPosition.value, - lodValue, - levels, + spacingTarget, ); } const modelMatrix = update3dRenderLayerAttachment( @@ -3743,11 +3609,10 @@ export class SliceViewPanelSpatiallyIndexedSkeletonLayer extends SliceViewPanelR ) { const { displayState } = this.base; return this.base.isReady( - "2d", - displayState.spatialSkeletonGridLevel2d?.value, this.transformedSources, renderContext.projectionParameters, - displayState.spatialSkeletonLod2d?.value, + displayState.spatialSkeletonSpacingTarget2d?.value ?? + this.base.spacingTarget2d.value, ); } } diff --git a/src/skeleton/source_selection.spec.ts b/src/skeleton/source_selection.spec.ts index 8ce9895776..ff99683936 100644 --- a/src/skeleton/source_selection.spec.ts +++ b/src/skeleton/source_selection.spec.ts @@ -16,50 +16,170 @@ import { describe, expect, it } from "vitest"; -import { selectSpatiallyIndexedSkeletonEntriesByGrid } from "#src/skeleton/source_selection.js"; +import { selectSpatialSkeletonSourcesByLimit } from "#src/skeleton/source_selection.js"; describe("skeleton/source_selection", () => { - it("returns the exact grid match when available", () => { - const entries = [ - { id: "coarse", gridIndex: 0 }, - { id: "medium", gridIndex: 2 }, - { id: "fine", gridIndex: 4 }, - ]; + it("selects only enough 3D sources to meet the spacing-derived density target", () => { expect( - selectSpatiallyIndexedSkeletonEntriesByGrid( - entries, - 2, - (entry) => entry.gridIndex, - ), - ).toEqual([entries[1]]); + selectSpatialSkeletonSourcesByLimit( + [ + { + source: "coarse", + index: 0, + physicalVolume: 1000, + limit: 200, + sliceFraction: 1, + }, + { + source: "fine", + index: 1, + physicalVolume: 125, + limit: 500, + sliceFraction: 1, + }, + ], + 0.1, + 1000, + 100, + ).map((selection) => selection.source), + ).toEqual(["coarse"]); }); - it("returns the nearest grid match and keeps the first entry on ties", () => { - const entries = [ - { id: "left", gridIndex: 0 }, - { id: "right", gridIndex: 4 }, - ]; + it("adds finer 2D sources when slice density is below the target", () => { expect( - selectSpatiallyIndexedSkeletonEntriesByGrid( - entries, - 2, - (entry) => entry.gridIndex, - ), - ).toEqual([entries[0]]); + selectSpatialSkeletonSourcesByLimit( + [ + { + source: "coarse", + index: 0, + physicalVolume: 1000, + limit: 200, + sliceFraction: 0.1, + }, + { + source: "fine", + index: 1, + physicalVolume: 125, + limit: 500, + sliceFraction: 0.1, + }, + ], + 0.1, + 1000, + 100, + ).map((selection) => selection.source), + ).toEqual(["coarse", "fine"]); }); - it("returns all entries if any entry is missing a grid index", () => { - const entries = [ - { id: "indexed", gridIndex: 0 }, - { id: "unindexed" }, - { id: "indexed-2", gridIndex: 2 }, - ]; + it("keeps zero-limit sources selectable without density contribution", () => { + const selections = selectSpatialSkeletonSourcesByLimit( + [ + { + source: "unknown-density-coarse", + index: 0, + physicalVolume: 1000, + limit: 0, + sliceFraction: 1, + }, + { + source: "unknown-density-fine", + index: 1, + physicalVolume: 125, + limit: 0, + sliceFraction: 1, + }, + ], + 0.1, + 1000, + 100, + ); + + expect(selections.map((selection) => selection.source)).toEqual([ + "unknown-density-coarse", + "unknown-density-fine", + ]); + for (const selection of selections) { + expect(selection.physicalDensity).toBe(0); + expect(selection.physicalSpacing).toBe(Number.POSITIVE_INFINITY); + expect(selection.pixelSpacing).toBe(Number.POSITIVE_INFINITY); + } + }); + + it("includes zero-limit sources reached before the density target is met", () => { + expect( + selectSpatialSkeletonSourcesByLimit( + [ + { + source: "unknown-density", + index: 0, + physicalVolume: 1000, + limit: 0, + sliceFraction: 1, + }, + { + source: "estimated-density", + index: 1, + physicalVolume: 125, + limit: 500, + sliceFraction: 1, + }, + ], + 0.1, + 1000, + 100, + ).map((selection) => selection.source), + ).toEqual(["unknown-density", "estimated-density"]); + }); + + it("stops before later zero-limit sources once positive density reaches the target", () => { + expect( + selectSpatialSkeletonSourcesByLimit( + [ + { + source: "coarse", + index: 0, + physicalVolume: 1000, + limit: 200, + sliceFraction: 1, + }, + { + source: "unknown-density-fine", + index: 1, + physicalVolume: 125, + limit: 0, + sliceFraction: 1, + }, + ], + 0.1, + 1000, + 100, + ).map((selection) => selection.source), + ).toEqual(["coarse"]); + }); + + it("selects sources from coarsest to finest physical volume", () => { expect( - selectSpatiallyIndexedSkeletonEntriesByGrid( - entries, + selectSpatialSkeletonSourcesByLimit( + [ + { + source: "fine", + index: 1, + physicalVolume: 125, + limit: 500, + sliceFraction: 1, + }, + { + source: "coarse", + index: 0, + physicalVolume: 1000, + limit: 10, + sliceFraction: 1, + }, + ], 1, - (entry) => entry.gridIndex, - ), - ).toEqual(entries); + 1000, + 100, + ).map((selection) => selection.source), + ).toEqual(["coarse", "fine"]); }); }); diff --git a/src/skeleton/source_selection.ts b/src/skeleton/source_selection.ts index 765acee937..5057cddfec 100644 --- a/src/skeleton/source_selection.ts +++ b/src/skeleton/source_selection.ts @@ -16,108 +16,61 @@ export type SpatiallyIndexedSkeletonView = "2d" | "3d"; -interface SpatiallyIndexedSkeletonParameterHolder { - parameters?: { - gridIndex?: unknown; - view?: unknown; - }; +export interface SpatialSkeletonSourceDensityInput { + source: T; + index: number; + physicalVolume: number; + limit: number; + sliceFraction: number; } -function isSpatiallyIndexedSkeletonParameterHolder( - value: unknown, -): value is SpatiallyIndexedSkeletonParameterHolder { - return typeof value === "object" && value !== null; +export interface SpatialSkeletonSourceDensitySelection + extends SpatialSkeletonSourceDensityInput { + physicalDensity: number; + totalPhysicalDensity: number; + physicalSpacing: number; + pixelSpacing: number; } -function getSpatiallyIndexedSkeletonParameterHolder( - value: unknown, -): SpatiallyIndexedSkeletonParameterHolder | undefined { - if (!isSpatiallyIndexedSkeletonParameterHolder(value)) { - return undefined; - } - if ("chunkSource" in value && value.chunkSource !== undefined) { - return isSpatiallyIndexedSkeletonParameterHolder(value.chunkSource) - ? value.chunkSource - : undefined; - } - if ("source" in value && value.source !== undefined) { - return isSpatiallyIndexedSkeletonParameterHolder(value.source) - ? value.source - : undefined; - } - return value; -} - -export function getSpatiallyIndexedSkeletonGridIndex( - value: T, -): number | undefined { - const gridIndex = - getSpatiallyIndexedSkeletonParameterHolder(value)?.parameters?.gridIndex; - return typeof gridIndex === "number" ? gridIndex : undefined; -} - -export function getSpatiallyIndexedSkeletonSourceView( - value: T, -): string | undefined { - const sourceView = - getSpatiallyIndexedSkeletonParameterHolder(value)?.parameters?.view; - return typeof sourceView === "string" ? sourceView : undefined; -} - -export function selectSpatiallyIndexedSkeletonEntriesByGrid( - entries: readonly T[], - gridLevel: number | undefined, - getGridIndex: (entry: T) => number | undefined, -) { - if (entries.length === 0 || gridLevel === undefined) { - return [...entries]; - } - let exactMatch: T | undefined; - let closestMatch: T | undefined; - let bestDistance = Number.POSITIVE_INFINITY; - for (const entry of entries) { - const gridIndex = getGridIndex(entry); - if (gridIndex === undefined) { - return [...entries]; - } - if (exactMatch === undefined && gridIndex === gridLevel) { - exactMatch = entry; - } - const distance = Math.abs(gridIndex - gridLevel); - if (distance < bestDistance) { - bestDistance = distance; - closestMatch = entry; +export function selectSpatialSkeletonSourcesByLimit( + sources: readonly SpatialSkeletonSourceDensityInput[], + physicalDensityTarget: number, + effectiveVolume: number, + viewportArea: number, +): SpatialSkeletonSourceDensitySelection[] { + const orderedSources = [...sources].sort( + (a, b) => b.physicalVolume - a.physicalVolume || a.index - b.index, + ); + const selected: SpatialSkeletonSourceDensitySelection[] = []; + let totalPhysicalDensity = 0; + for (const source of orderedSources) { + if ( + totalPhysicalDensity > 0 && + totalPhysicalDensity >= physicalDensityTarget + ) { + break; } + const physicalDensity = + source.limit > 0 + ? (source.limit * source.sliceFraction) / source.physicalVolume + : 0; + const newTotalPhysicalDensity = totalPhysicalDensity + physicalDensity; + selected.push({ + ...source, + physicalDensity, + totalPhysicalDensity: newTotalPhysicalDensity, + physicalSpacing: + newTotalPhysicalDensity > 0 + ? (1 / newTotalPhysicalDensity) ** (1 / 3) + : Number.POSITIVE_INFINITY, + pixelSpacing: + newTotalPhysicalDensity > 0 + ? Math.sqrt( + viewportArea / (newTotalPhysicalDensity * effectiveVolume), + ) + : Number.POSITIVE_INFINITY, + }); + totalPhysicalDensity = newTotalPhysicalDensity; } - return [exactMatch ?? closestMatch!]; -} - -export function filterSpatiallyIndexedSkeletonEntriesByView( - entries: readonly T[], - view: SpatiallyIndexedSkeletonView, - getView: (entry: T) => string | undefined, -) { - return entries.filter((entry) => { - const sourceView = getView(entry); - return sourceView === undefined || sourceView === view; - }); -} - -export function selectSpatiallyIndexedSkeletonEntriesForView( - entries: readonly T[], - view: SpatiallyIndexedSkeletonView, - gridLevel: number | undefined, - getView: (entry: T) => string | undefined, - getGridIndex: (entry: T) => number | undefined, -) { - const viewFiltered = filterSpatiallyIndexedSkeletonEntriesByView( - entries, - view, - getView, - ); - return selectSpatiallyIndexedSkeletonEntriesByGrid( - viewFiltered, - gridLevel, - getGridIndex, - ); + return selected; } diff --git a/src/skeleton/spatial_chunk_sizing.spec.ts b/src/skeleton/spatial_chunk_sizing.spec.ts index efa3efbc77..261d18b402 100644 --- a/src/skeleton/spatial_chunk_sizing.spec.ts +++ b/src/skeleton/spatial_chunk_sizing.spec.ts @@ -16,7 +16,10 @@ import { describe, expect, it } from "vitest"; -import { getDefaultSpatiallyIndexedSkeletonChunkSize } from "#src/skeleton/spatial_chunk_sizing.js"; +import { + buildSpatialSkeletonGridLevels, + getDefaultSpatiallyIndexedSkeletonChunkSize, +} from "#src/skeleton/spatial_chunk_sizing.js"; describe("skeleton/spatial_chunk_sizing", () => { it("derives an isotropic chunk size that stays within the default chunk budget", () => { @@ -117,4 +120,18 @@ describe("skeleton/spatial_chunk_sizing", () => { ), ).toThrow(/maxChunks must be finite/i); }); + + it("sorts spatial skeleton grid levels by spacing and preserves limits", () => { + expect( + buildSpatialSkeletonGridLevels([ + { x: 10, y: 10, z: 10, limit: 1000 }, + { x: 40, y: 40, z: 40, limit: 10 }, + { x: 20, y: 20, z: 20, limit: 100 }, + ]), + ).toEqual([ + { size: { x: 40, y: 40, z: 40, limit: 10 }, limit: 10 }, + { size: { x: 20, y: 20, z: 20, limit: 100 }, limit: 100 }, + { size: { x: 10, y: 10, z: 10, limit: 1000 }, limit: 1000 }, + ]); + }); }); diff --git a/src/skeleton/spatial_chunk_sizing.ts b/src/skeleton/spatial_chunk_sizing.ts index 36a7cd1c8a..3da55a4abc 100644 --- a/src/skeleton/spatial_chunk_sizing.ts +++ b/src/skeleton/spatial_chunk_sizing.ts @@ -23,10 +23,15 @@ const DEFAULT_SPATIALLY_INDEXED_SKELETON_MAX_CHUNKS = 64; const DEFAULT_SPATIALLY_INDEXED_SKELETON_MIN_CHUNK_SIZE = 1; export type SpatiallyIndexedSkeletonChunkSize = number[]; -export type SpatialSkeletonGridSize = { x: number; y: number; z: number }; +export type SpatialSkeletonGridSize = { + x: number; + y: number; + z: number; + limit: number; +}; export type SpatialSkeletonGridLevel = { size: SpatialSkeletonGridSize; - lod: number; + limit: number; }; export interface DefaultSpatiallyIndexedSkeletonChunkSizeOptions { @@ -51,11 +56,9 @@ export function buildSpatialSkeletonGridLevels( gridSizes: readonly SpatialSkeletonGridSize[], ): SpatialSkeletonGridLevel[] { const sortedSizes = sortSpatialSkeletonGridSizes(gridSizes); - if (sortedSizes.length === 0) return []; - const lastIndex = sortedSizes.length - 1; - return sortedSizes.map((size, index) => ({ + return sortedSizes.map((size) => ({ size, - lod: lastIndex === 0 ? 0 : index / lastIndex, + limit: size.limit, })); } diff --git a/src/widget/layer_control.ts b/src/widget/layer_control.ts index 7497ee3006..3164ca4d45 100644 --- a/src/widget/layer_control.ts +++ b/src/widget/layer_control.ts @@ -42,7 +42,7 @@ export interface LayerControlLabelOptions< export interface LayerControlFactory< LayerType extends UserLayer, - ControlType = unknown, + ControlType = any, > { makeControl: ( layer: LayerType, @@ -65,7 +65,7 @@ export interface LayerControlFactory< export interface LayerControlDefinition< LayerType extends UserLayer, - ControlType = unknown, + ControlType = any, > extends LayerControlLabelOptions, LayerControlFactory {} diff --git a/src/widget/render_scale_widget.ts b/src/widget/render_scale_widget.ts index a98d15f413..2310f1e845 100644 --- a/src/widget/render_scale_widget.ts +++ b/src/widget/render_scale_widget.ts @@ -410,57 +410,6 @@ export class VolumeRenderingRenderScaleWidget extends RenderScaleWidget { } } -export class SpatialSkeletonGridRenderScaleWidget extends RenderScaleWidget { - protected unitOfTarget = "nm"; - - updateView() { - this.logScaleOrigin = this.histogram.logScaleOrigin; - this.logScaleBinSize = this.histogram.logScaleBinSize; - super.updateView(); - } - - protected getLegendChunkCounts( - totalPresent: number, - totalNotPresent: number, - ) { - // When hovering, the base class already filtered to the hovered row. - if (this.hoverTarget.value !== undefined) { - return { - presentCount: totalPresent, - totalCount: totalPresent + totalNotPresent, - }; - } - // When not hovering, show counts only for the row closest to the target spacing. - const { histogram, target } = this; - const { value: histogramData, spatialScales } = histogram; - let closestScale: number | undefined; - let closestLogDist = Infinity; - const logTarget = Math.log2(target.value); - for (const scale of spatialScales.keys()) { - const dist = Math.abs(Math.log2(scale) - logTarget); - if (dist < closestLogDist) { - closestLogDist = dist; - closestScale = scale; - } - } - if (closestScale === undefined) { - return { - presentCount: totalPresent, - totalCount: totalPresent + totalNotPresent, - }; - } - const row = spatialScales.get(closestScale)!; - const base = 2 * row * numRenderScaleHistogramBins; - let present = 0; - let notPresent = 0; - for (let bin = 0; bin < numRenderScaleHistogramBins; ++bin) { - present += histogramData[base + bin]; - notPresent += histogramData[base + bin + numRenderScaleHistogramBins]; - } - return { presentCount: present, totalCount: present + notPresent }; - } -} - const TOOL_INPUT_EVENT_MAP = EventActionMap.fromObject({ "at:shift+wheel": { action: "adjust-via-wheel" }, "at:shift+dblclick0": { action: "reset" }, From 212929fd85bd0b2293e05f431f9d262c5e304e7d Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Fri, 22 May 2026 11:56:12 +0100 Subject: [PATCH 2/5] feat: Replace cumulative skeleton source selection with single-source selection --- src/datasource/catmaid/api.spec.ts | 40 ++++ src/datasource/catmaid/api.ts | 28 ++- src/skeleton/base.spec.ts | 119 ++++++++++++ src/skeleton/base.ts | 41 ++--- src/skeleton/source_selection.spec.ts | 254 ++++++++++++++++---------- src/skeleton/source_selection.ts | 125 +++++++++---- 6 files changed, 458 insertions(+), 149 deletions(-) create mode 100644 src/skeleton/base.spec.ts diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index e378b14ab3..0fe0d511c9 100644 --- a/src/datasource/catmaid/api.spec.ts +++ b/src/datasource/catmaid/api.spec.ts @@ -260,6 +260,46 @@ describe("CatmaidClient skeleton editing methods", () => { }); }); + it("accepts zero CATMAID spatial skeleton metadata limits only on the finest level", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + (client as any).listStacks = vi.fn().mockResolvedValue([{ id: 7 }]); + (client as any).getStackInfo = vi.fn().mockResolvedValue({ + dimension: { x: 10, y: 20, z: 30 }, + resolution: { x: 2, y: 3, z: 4 }, + translation: { x: 5, y: 6, z: 7 }, + metadata: { + spatial: [ + { chunk_size: [30, 30, 30], limit: 10 }, + { chunk_size: [15, 15, 15], limit: 0 }, + ], + }, + }); + + await expect(client.getSpatialIndexMetadata()).resolves.toMatchObject({ + spatial: [{ limit: 10 }, { limit: 0 }], + }); + }); + + it("rejects zero CATMAID spatial skeleton metadata limits on non-finest levels", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + (client as any).listStacks = vi.fn().mockResolvedValue([{ id: 7 }]); + (client as any).getStackInfo = vi.fn().mockResolvedValue({ + dimension: { x: 10, y: 20, z: 30 }, + resolution: { x: 2, y: 3, z: 4 }, + translation: { x: 5, y: 6, z: 7 }, + metadata: { + spatial: [ + { chunk_size: [30, 30, 30], limit: 0 }, + { chunk_size: [15, 15, 15], limit: 10 }, + ], + }, + }); + + await expect(client.getSpatialIndexMetadata()).rejects.toThrow( + "Spatial skeleton limit: 0 is only supported on the finest source level.", + ); + }); + it("parses live compact-detail history rows and current label maps", async () => { const client = new CatmaidClient("https://example.invalid", 1); const fetchMock = vi diff --git a/src/datasource/catmaid/api.ts b/src/datasource/catmaid/api.ts index 0e66c38562..f6092bbf0d 100644 --- a/src/datasource/catmaid/api.ts +++ b/src/datasource/catmaid/api.ts @@ -28,6 +28,7 @@ import type { } from "#src/skeleton/api.js"; import { SpatialSkeletonEditConflictError } from "#src/skeleton/edit_errors.js"; import type { SpatiallyIndexedSkeletonNavigationTarget } from "#src/skeleton/navigation_graph.js"; +import { validateSpatialSkeletonLimitZeroOnlyFinest } from "#src/skeleton/source_selection.js"; import { getDefaultSpatiallyIndexedSkeletonChunkSize } from "#src/skeleton/spatial_chunk_sizing.js"; import { HttpError } from "#src/util/http_request.js"; @@ -569,6 +570,29 @@ function getDefaultCatmaidSpatialIndexLevel( }; } +function getCatmaidSpatialIndexLevelVolume( + level: SpatialSkeletonSpatialIndexLevel, +) { + let volume = 1; + for (let i = 0; i < level.chunkSize.length; ++i) { + volume *= level.chunkSize[i]; + } + return volume; +} + +function validateCatmaidSpatialSkeletonLimitZeroOnlyFinest( + levels: readonly SpatialSkeletonSpatialIndexLevel[], +) { + validateSpatialSkeletonLimitZeroOnlyFinest( + levels.map((level, index) => ({ + source: level, + index, + physicalVolume: getCatmaidSpatialIndexLevelVolume(level), + limit: level.limit, + })), + ); +} + export function requireCatmaidRank3Vector( vector: SpatialSkeletonVector, label: string, @@ -1303,7 +1327,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { if (spatial.length === 0) { return [getDefaultCatmaidSpatialIndexLevel(bounds, extents)]; } - return spatial.map((level, index) => { + const levels = spatial.map((level, index) => { const chunkSize = requireCatmaidPositiveRank3Vector( level?.chunk_size, `spatial skeleton metadata spatial[${index}].chunk_size`, @@ -1318,6 +1342,8 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { limit, }; }); + validateCatmaidSpatialSkeletonLimitZeroOnlyFinest(levels); + return levels; } private getSpatialIndexLevelsFromMetadataInfo( diff --git a/src/skeleton/base.spec.ts b/src/skeleton/base.spec.ts new file mode 100644 index 0000000000..d0cfadad39 --- /dev/null +++ b/src/skeleton/base.spec.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from "vitest"; + +import { ProjectionParameters } from "#src/projection_parameters.js"; +import { forEachVisibleSpatialSkeletonChunk } from "#src/skeleton/base.js"; +import { makeSliceViewChunkSpecification } from "#src/sliceview/base.js"; +import { ChunkLayout } from "#src/sliceview/chunk_layout.js"; +import { mat4, vec3 } from "#src/util/geom.js"; + +describe("forEachVisibleSpatialSkeletonChunk", () => { + function makeProjectionParameters() { + const projectionParameters = new ProjectionParameters(); + projectionParameters.width = 1000; + projectionParameters.height = 1000; + projectionParameters.logicalWidth = 1000; + projectionParameters.logicalHeight = 1000; + projectionParameters.visibleWidthFraction = 1; + projectionParameters.visibleHeightFraction = 1; + projectionParameters.globalPosition = new Float32Array(0); + projectionParameters.displayDimensionRenderInfo = { + voxelPhysicalScales: Float32Array.of(1, 1, 1), + } as any; + return projectionParameters; + } + + function makeTransformedSource( + label: string, + chunkSize: number, + limit: number, + ) { + const chunkDataSize = Uint32Array.of(chunkSize, chunkSize, chunkSize); + const chunkLayout = new ChunkLayout( + vec3.fromValues(chunkSize, chunkSize, chunkSize), + mat4.create(), + 3, + ); + const spec = { + ...makeSliceViewChunkSpecification({ + rank: 3, + chunkDataSize, + lowerVoxelBound: Float32Array.of(0, 0, 0), + upperVoxelBound: Float32Array.of(chunkSize, chunkSize, chunkSize), + }), + chunkLayout, + limit, + }; + return { + label, + renderLayer: { + localPosition: { value: new Float32Array(0) }, + renderScaleTarget: { value: 1 }, + }, + source: { label, spec, dispose: () => {} }, + effectiveVoxelSize: vec3.fromValues(1, 1, 1), + chunkLayout, + nonDisplayLowerClipBound: Float32Array.of( + Number.NEGATIVE_INFINITY, + Number.NEGATIVE_INFINITY, + Number.NEGATIVE_INFINITY, + ), + nonDisplayUpperClipBound: Float32Array.of( + Number.POSITIVE_INFINITY, + Number.POSITIVE_INFINITY, + Number.POSITIVE_INFINITY, + ), + lowerClipBound: Float32Array.of(0, 0, 0), + upperClipBound: Float32Array.of(chunkSize, chunkSize, chunkSize), + lowerClipDisplayBound: vec3.fromValues(0, 0, 0), + upperClipDisplayBound: vec3.fromValues(1, 1, 1), + lowerChunkDisplayBound: vec3.fromValues(0, 0, 0), + upperChunkDisplayBound: vec3.fromValues(1, 1, 1), + chunkDisplayDimensionIndices: [0, 1, 2], + layerRank: 3, + combinedGlobalLocalToChunkTransform: Float32Array.of(0, 0, 0), + fixedLayerToChunkTransform: Float32Array.of(0, 0, 0), + curPositionInChunks: Float32Array.of(0, 0, 0), + fixedPositionWithinChunk: new Uint32Array(3), + }; + } + + it("visits only the selected source level", () => { + const projectionParameters = makeProjectionParameters(); + const coarse = makeTransformedSource("coarse", 4, 1); + const fine = makeTransformedSource("fine", 1, 1000); + const begun: string[] = []; + const visited: string[] = []; + + forEachVisibleSpatialSkeletonChunk( + projectionParameters, + new Float32Array(0), + 1, + [coarse, fine], + (source) => { + begun.push((source as any).label); + }, + (source) => { + visited.push((source as any).label); + }, + ); + + expect(begun).toEqual(["fine"]); + expect(visited).toEqual(["fine"]); + }); +}); diff --git a/src/skeleton/base.ts b/src/skeleton/base.ts index 5cb389328b..2e83b91f68 100644 --- a/src/skeleton/base.ts +++ b/src/skeleton/base.ts @@ -21,7 +21,7 @@ import type { TransformedSource, } from "#src/sliceview/base.js"; import { forEachVisibleVolumetricChunk } from "#src/sliceview/base.js"; -import { selectSpatialSkeletonSourcesByLimit } from "#src/skeleton/source_selection.js"; +import { selectSpatialSkeletonSourceByLimit } from "#src/skeleton/source_selection.js"; import type { DataType } from "#src/util/data_type.js"; import { getViewFrustrumVolume, @@ -137,29 +137,26 @@ export function forEachVisibleSpatialSkeletonChunk< const targetNumNodes = viewportArea / spacingTarget ** 2; const physicalDensityTarget = targetNumNodes / effectiveVolume; - for (const { - source: tsource, - index, - physicalSpacing, - pixelSpacing, - } of selectSpatialSkeletonSourcesByLimit( + const selection = selectSpatialSkeletonSourceByLimit( sourceDensityInputs, physicalDensityTarget, effectiveVolume, viewportArea, - )) { - let firstChunk = true; - forEachVisibleVolumetricChunk( - projectionParameters, - localPosition, - tsource, - () => { - if (firstChunk) { - beginScale(tsource, index); - firstChunk = false; - } - callback(tsource, index, physicalSpacing, pixelSpacing); - }, - ); - } + ); + if (selection === undefined) return; + + const { source: tsource, index, physicalSpacing, pixelSpacing } = selection; + let firstChunk = true; + forEachVisibleVolumetricChunk( + projectionParameters, + localPosition, + tsource, + () => { + if (firstChunk) { + beginScale(tsource, index); + firstChunk = false; + } + callback(tsource, index, physicalSpacing, pixelSpacing); + }, + ); } diff --git a/src/skeleton/source_selection.spec.ts b/src/skeleton/source_selection.spec.ts index ff99683936..fde7544185 100644 --- a/src/skeleton/source_selection.spec.ts +++ b/src/skeleton/source_selection.spec.ts @@ -16,73 +16,134 @@ import { describe, expect, it } from "vitest"; -import { selectSpatialSkeletonSourcesByLimit } from "#src/skeleton/source_selection.js"; +import { + selectSpatialSkeletonSourceByLimit, + SPATIAL_SKELETON_ZERO_LIMIT_FINEST_ERROR, +} from "#src/skeleton/source_selection.js"; describe("skeleton/source_selection", () => { - it("selects only enough 3D sources to meet the spacing-derived density target", () => { - expect( - selectSpatialSkeletonSourcesByLimit( - [ - { - source: "coarse", - index: 0, - physicalVolume: 1000, - limit: 200, - sliceFraction: 1, - }, - { - source: "fine", - index: 1, - physicalVolume: 125, - limit: 500, - sliceFraction: 1, - }, - ], - 0.1, - 1000, - 100, - ).map((selection) => selection.source), - ).toEqual(["coarse"]); + it("selects exactly one coarse source when it satisfies the density target", () => { + const selection = selectSpatialSkeletonSourceByLimit( + [ + { + source: "coarse", + index: 0, + physicalVolume: 1000, + limit: 200, + sliceFraction: 1, + }, + { + source: "fine", + index: 1, + physicalVolume: 125, + limit: 500, + sliceFraction: 1, + }, + ], + 0.1, + 1000, + 100, + ); + + expect(selection?.source).toBe("coarse"); + expect(selection?.physicalDensity).toBeCloseTo(0.2); }); - it("adds finer 2D sources when slice density is below the target", () => { - expect( - selectSpatialSkeletonSourcesByLimit( - [ - { - source: "coarse", - index: 0, - physicalVolume: 1000, - limit: 200, - sliceFraction: 0.1, - }, - { - source: "fine", - index: 1, - physicalVolume: 125, - limit: 500, - sliceFraction: 0.1, - }, - ], - 0.1, - 1000, - 100, - ).map((selection) => selection.source), - ).toEqual(["coarse", "fine"]); + it("selects a finer positive-limit source when coarser sources are too sparse", () => { + const selection = selectSpatialSkeletonSourceByLimit( + [ + { + source: "coarse", + index: 0, + physicalVolume: 1000, + limit: 10, + sliceFraction: 1, + }, + { + source: "fine", + index: 1, + physicalVolume: 125, + limit: 500, + sliceFraction: 1, + }, + ], + 0.1, + 1000, + 100, + ); + + expect(selection?.source).toBe("fine"); + expect(selection?.physicalDensity).toBeCloseTo(4); }); - it("keeps zero-limit sources selectable without density contribution", () => { - const selections = selectSpatialSkeletonSourcesByLimit( + it("falls back to the finest positive-limit source when no source satisfies the target", () => { + const selection = selectSpatialSkeletonSourceByLimit( [ { - source: "unknown-density-coarse", + source: "coarse", index: 0, physicalVolume: 1000, + limit: 10, + sliceFraction: 1, + }, + { + source: "fine", + index: 1, + physicalVolume: 125, + limit: 10, + sliceFraction: 1, + }, + ], + 100, + 1000, + 100, + ); + + expect(selection?.source).toBe("fine"); + expect(selection?.physicalDensity).toBeCloseTo(0.08); + }); + + it("selects a finest zero-limit source when positive-limit sources are too sparse", () => { + const selection = selectSpatialSkeletonSourceByLimit( + [ + { + source: "coarse", + index: 0, + physicalVolume: 1000, + limit: 10, + sliceFraction: 1, + }, + { + source: "complete-finest", + index: 1, + physicalVolume: 125, limit: 0, sliceFraction: 1, }, + ], + 100, + 1000, + 100, + ); + + expect(selection?.source).toBe("complete-finest"); + expect(selection?.physicalDensity).toBe(Number.POSITIVE_INFINITY); + expect(selection?.physicalSpacing).toBe(Number.POSITIVE_INFINITY); + expect(selection?.pixelSpacing).toBe(Number.POSITIVE_INFINITY); + }); + + it("does not select a finest zero-limit source if a coarser source satisfies the target", () => { + const selection = selectSpatialSkeletonSourceByLimit( + [ + { + source: "coarse", + index: 0, + physicalVolume: 1000, + limit: 200, + sliceFraction: 1, + }, { - source: "unknown-density-fine", + source: "complete-finest", index: 1, physicalVolume: 125, limit: 0, @@ -94,30 +155,22 @@ describe("skeleton/source_selection", () => { 100, ); - expect(selections.map((selection) => selection.source)).toEqual([ - "unknown-density-coarse", - "unknown-density-fine", - ]); - for (const selection of selections) { - expect(selection.physicalDensity).toBe(0); - expect(selection.physicalSpacing).toBe(Number.POSITIVE_INFINITY); - expect(selection.pixelSpacing).toBe(Number.POSITIVE_INFINITY); - } + expect(selection?.source).toBe("coarse"); }); - it("includes zero-limit sources reached before the density target is met", () => { - expect( - selectSpatialSkeletonSourcesByLimit( + it("rejects a non-finest zero-limit source", () => { + expect(() => + selectSpatialSkeletonSourceByLimit( [ { - source: "unknown-density", + source: "complete-coarse", index: 0, physicalVolume: 1000, limit: 0, sliceFraction: 1, }, { - source: "estimated-density", + source: "fine", index: 1, physicalVolume: 125, limit: 500, @@ -127,23 +180,23 @@ describe("skeleton/source_selection", () => { 0.1, 1000, 100, - ).map((selection) => selection.source), - ).toEqual(["unknown-density", "estimated-density"]); + ), + ).toThrow(SPATIAL_SKELETON_ZERO_LIMIT_FINEST_ERROR); }); - it("stops before later zero-limit sources once positive density reaches the target", () => { - expect( - selectSpatialSkeletonSourcesByLimit( + it("rejects multiple zero-limit sources unless there is only one source", () => { + expect(() => + selectSpatialSkeletonSourceByLimit( [ { - source: "coarse", + source: "complete-coarse", index: 0, physicalVolume: 1000, - limit: 200, + limit: 0, sliceFraction: 1, }, { - source: "unknown-density-fine", + source: "complete-fine", index: 1, physicalVolume: 125, limit: 0, @@ -153,33 +206,50 @@ describe("skeleton/source_selection", () => { 0.1, 1000, 100, - ).map((selection) => selection.source), - ).toEqual(["coarse"]); - }); + ), + ).toThrow(SPATIAL_SKELETON_ZERO_LIMIT_FINEST_ERROR); - it("selects sources from coarsest to finest physical volume", () => { expect( - selectSpatialSkeletonSourcesByLimit( + selectSpatialSkeletonSourceByLimit( [ { - source: "fine", - index: 1, - physicalVolume: 125, - limit: 500, - sliceFraction: 1, - }, - { - source: "coarse", + source: "single-complete", index: 0, physicalVolume: 1000, - limit: 10, + limit: 0, sliceFraction: 1, }, ], - 1, + 0.1, 1000, 100, - ).map((selection) => selection.source), - ).toEqual(["coarse", "fine"]); + )?.source, + ).toBe("single-complete"); + }); + + it("selects sources from coarsest to finest physical volume", () => { + const selection = selectSpatialSkeletonSourceByLimit( + [ + { + source: "fine", + index: 1, + physicalVolume: 125, + limit: 500, + sliceFraction: 1, + }, + { + source: "coarse", + index: 0, + physicalVolume: 1000, + limit: 200, + sliceFraction: 1, + }, + ], + 0.1, + 1000, + 100, + ); + + expect(selection?.source).toBe("coarse"); }); }); diff --git a/src/skeleton/source_selection.ts b/src/skeleton/source_selection.ts index 5057cddfec..7569a2e480 100644 --- a/src/skeleton/source_selection.ts +++ b/src/skeleton/source_selection.ts @@ -16,11 +16,15 @@ export type SpatiallyIndexedSkeletonView = "2d" | "3d"; -export interface SpatialSkeletonSourceDensityInput { +export interface SpatialSkeletonSourceLimitInput { source: T; index: number; physicalVolume: number; limit: number; +} + +export interface SpatialSkeletonSourceDensityInput + extends SpatialSkeletonSourceLimitInput { sliceFraction: number; } @@ -32,45 +36,98 @@ export interface SpatialSkeletonSourceDensitySelection pixelSpacing: number; } -export function selectSpatialSkeletonSourcesByLimit( +export const SPATIAL_SKELETON_ZERO_LIMIT_FINEST_ERROR = + "Spatial skeleton limit: 0 is only supported on the finest source level."; + +function getOrderedSpatialSkeletonSources< + T extends SpatialSkeletonSourceLimitInput, +>(sources: readonly T[]): T[] { + return [...sources].sort( + (a, b) => b.physicalVolume - a.physicalVolume || a.index - b.index, + ); +} + +function validateOrderedSpatialSkeletonLimitZeroOnlyFinest< + T extends SpatialSkeletonSourceLimitInput, +>(orderedSources: readonly T[]) { + for (let i = 0; i < orderedSources.length - 1; ++i) { + if (orderedSources[i].limit === 0) { + throw new Error(SPATIAL_SKELETON_ZERO_LIMIT_FINEST_ERROR); + } + } +} + +function makeSpatialSkeletonSourceSelection( + source: SpatialSkeletonSourceDensityInput, + physicalDensity: number, + effectiveVolume: number, + viewportArea: number, +): SpatialSkeletonSourceDensitySelection { + const hasKnownDensity = + Number.isFinite(physicalDensity) && physicalDensity > 0; + return { + ...source, + physicalDensity, + totalPhysicalDensity: physicalDensity, + physicalSpacing: hasKnownDensity + ? (1 / physicalDensity) ** (1 / 3) + : Number.POSITIVE_INFINITY, + pixelSpacing: hasKnownDensity + ? Math.sqrt(viewportArea / (physicalDensity * effectiveVolume)) + : Number.POSITIVE_INFINITY, + }; +} + +export function selectSpatialSkeletonSourceByLimit( sources: readonly SpatialSkeletonSourceDensityInput[], physicalDensityTarget: number, effectiveVolume: number, viewportArea: number, -): SpatialSkeletonSourceDensitySelection[] { - const orderedSources = [...sources].sort( - (a, b) => b.physicalVolume - a.physicalVolume || a.index - b.index, - ); - const selected: SpatialSkeletonSourceDensitySelection[] = []; - let totalPhysicalDensity = 0; +): SpatialSkeletonSourceDensitySelection | undefined { + const orderedSources = getOrderedSpatialSkeletonSources(sources); + if (orderedSources.length === 0) return undefined; + validateOrderedSpatialSkeletonLimitZeroOnlyFinest(orderedSources); + for (const source of orderedSources) { - if ( - totalPhysicalDensity > 0 && - totalPhysicalDensity >= physicalDensityTarget - ) { - break; + if (source.limit === 0) { + continue; } const physicalDensity = - source.limit > 0 - ? (source.limit * source.sliceFraction) / source.physicalVolume - : 0; - const newTotalPhysicalDensity = totalPhysicalDensity + physicalDensity; - selected.push({ - ...source, - physicalDensity, - totalPhysicalDensity: newTotalPhysicalDensity, - physicalSpacing: - newTotalPhysicalDensity > 0 - ? (1 / newTotalPhysicalDensity) ** (1 / 3) - : Number.POSITIVE_INFINITY, - pixelSpacing: - newTotalPhysicalDensity > 0 - ? Math.sqrt( - viewportArea / (newTotalPhysicalDensity * effectiveVolume), - ) - : Number.POSITIVE_INFINITY, - }); - totalPhysicalDensity = newTotalPhysicalDensity; + (source.limit * source.sliceFraction) / source.physicalVolume; + if (physicalDensity >= physicalDensityTarget) { + return makeSpatialSkeletonSourceSelection( + source, + physicalDensity, + effectiveVolume, + viewportArea, + ); + } } - return selected; + + const finestSource = orderedSources[orderedSources.length - 1]; + if (finestSource.limit === 0) { + return makeSpatialSkeletonSourceSelection( + finestSource, + Number.POSITIVE_INFINITY, + effectiveVolume, + viewportArea, + ); + } + const physicalDensity = + (finestSource.limit * finestSource.sliceFraction) / + finestSource.physicalVolume; + return makeSpatialSkeletonSourceSelection( + finestSource, + physicalDensity, + effectiveVolume, + viewportArea, + ); +} + +export function validateSpatialSkeletonLimitZeroOnlyFinest< + T extends SpatialSkeletonSourceLimitInput, +>(sources: readonly T[]) { + validateOrderedSpatialSkeletonLimitZeroOnlyFinest( + getOrderedSpatialSkeletonSources(sources), + ); } From 0fee85a5a914ed2d0e8c4eec3e4a1f25c770b5d6 Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Fri, 22 May 2026 14:00:13 +0100 Subject: [PATCH 3/5] feat: Add indicator bars for non-selected skeleton source levels --- src/skeleton/base.spec.ts | 29 ++++++- src/skeleton/base.ts | 112 +++++++++++++++++++++++--- src/skeleton/frontend.ts | 22 +++++ src/skeleton/source_selection.spec.ts | 33 ++++++++ src/skeleton/source_selection.ts | 21 +++++ 5 files changed, 204 insertions(+), 13 deletions(-) diff --git a/src/skeleton/base.spec.ts b/src/skeleton/base.spec.ts index d0cfadad39..699ed4ad5d 100644 --- a/src/skeleton/base.spec.ts +++ b/src/skeleton/base.spec.ts @@ -17,7 +17,10 @@ import { describe, expect, it } from "vitest"; import { ProjectionParameters } from "#src/projection_parameters.js"; -import { forEachVisibleSpatialSkeletonChunk } from "#src/skeleton/base.js"; +import { + forEachSpatialSkeletonSourceScale, + forEachVisibleSpatialSkeletonChunk, +} from "#src/skeleton/base.js"; import { makeSliceViewChunkSpecification } from "#src/sliceview/base.js"; import { ChunkLayout } from "#src/sliceview/chunk_layout.js"; import { mat4, vec3 } from "#src/util/geom.js"; @@ -116,4 +119,28 @@ describe("forEachVisibleSpatialSkeletonChunk", () => { expect(begun).toEqual(["fine"]); expect(visited).toEqual(["fine"]); }); + + it("reports every source level scale while marking only the selected source", () => { + const projectionParameters = makeProjectionParameters(); + const coarse = makeTransformedSource("coarse", 4, 1); + const fine = makeTransformedSource("fine", 1, 1000); + const reported: Array<{ label: string; selected: boolean }> = []; + + forEachSpatialSkeletonSourceScale( + projectionParameters, + 1, + [coarse, fine], + (source, _index, _physicalSpacing, _pixelSpacing, selected) => { + reported.push({ + label: (source as any).label, + selected, + }); + }, + ); + + expect(reported).toEqual([ + { label: "coarse", selected: false }, + { label: "fine", selected: true }, + ]); + }); }); diff --git a/src/skeleton/base.ts b/src/skeleton/base.ts index 2e83b91f68..fd64dff912 100644 --- a/src/skeleton/base.ts +++ b/src/skeleton/base.ts @@ -21,7 +21,11 @@ import type { TransformedSource, } from "#src/sliceview/base.js"; import { forEachVisibleVolumetricChunk } from "#src/sliceview/base.js"; -import { selectSpatialSkeletonSourceByLimit } from "#src/skeleton/source_selection.js"; +import { + getSpatialSkeletonSourceScalesByLimit, + selectSpatialSkeletonSourceByLimit, + type SpatialSkeletonSourceDensityInput, +} from "#src/skeleton/source_selection.js"; import type { DataType } from "#src/util/data_type.js"; import { getViewFrustrumVolume, @@ -50,6 +54,15 @@ export interface SpatiallyIndexedSkeletonChunkSpecification const tempMat3 = mat3.create(); +export interface SpatialSkeletonSourceDensityContext< + Transformed extends TransformedSource, +> { + sourceDensityInputs: SpatialSkeletonSourceDensityInput[]; + physicalDensityTarget: number; + effectiveVolume: number; + viewportArea: number; +} + function getSpatialSkeletonSliceFraction(transformedSource: TransformedSource) { const spec = transformedSource.source .spec as SpatiallyIndexedSkeletonChunkSpecification; @@ -76,22 +89,14 @@ function getSpatialSkeletonChunkPhysicalVolume( ); } -export function forEachVisibleSpatialSkeletonChunk< +export function getSpatialSkeletonSourceDensityContext< Transformed extends TransformedSource, >( projectionParameters: ProjectionParameters, - localPosition: Float32Array, spacingTarget: number, transformedSources: readonly Transformed[], - beginScale: (source: Transformed, index: number) => void, - callback: ( - source: Transformed, - index: number, - physicalSpacing: number, - pixelSpacing: number, - ) => void, -) { - if (transformedSources.length === 0) return; +): SpatialSkeletonSourceDensityContext | undefined { + if (transformedSources.length === 0) return undefined; const { displayDimensionRenderInfo, @@ -136,6 +141,89 @@ export function forEachVisibleSpatialSkeletonChunk< const viewportArea = width * height; const targetNumNodes = viewportArea / spacingTarget ** 2; const physicalDensityTarget = targetNumNodes / effectiveVolume; + return { + sourceDensityInputs, + physicalDensityTarget, + effectiveVolume, + viewportArea, + }; +} + +export function forEachSpatialSkeletonSourceScale< + Transformed extends TransformedSource, +>( + projectionParameters: ProjectionParameters, + spacingTarget: number, + transformedSources: readonly Transformed[], + callback: ( + source: Transformed, + index: number, + physicalSpacing: number, + pixelSpacing: number, + selected: boolean, + ) => void, +) { + const densityContext = getSpatialSkeletonSourceDensityContext( + projectionParameters, + spacingTarget, + transformedSources, + ); + if (densityContext === undefined) return; + const { + sourceDensityInputs, + physicalDensityTarget, + effectiveVolume, + viewportArea, + } = densityContext; + const selection = selectSpatialSkeletonSourceByLimit( + sourceDensityInputs, + physicalDensityTarget, + effectiveVolume, + viewportArea, + ); + if (selection === undefined) return; + for (const scale of getSpatialSkeletonSourceScalesByLimit( + sourceDensityInputs, + effectiveVolume, + viewportArea, + )) { + callback( + scale.source, + scale.index, + scale.physicalSpacing, + scale.pixelSpacing, + scale.source === selection.source, + ); + } +} + +export function forEachVisibleSpatialSkeletonChunk< + Transformed extends TransformedSource, +>( + projectionParameters: ProjectionParameters, + localPosition: Float32Array, + spacingTarget: number, + transformedSources: readonly Transformed[], + beginScale: (source: Transformed, index: number) => void, + callback: ( + source: Transformed, + index: number, + physicalSpacing: number, + pixelSpacing: number, + ) => void, +) { + const densityContext = getSpatialSkeletonSourceDensityContext( + projectionParameters, + spacingTarget, + transformedSources, + ); + if (densityContext === undefined) return; + const { + sourceDensityInputs, + physicalDensityTarget, + effectiveVolume, + viewportArea, + } = densityContext; const selection = selectSpatialSkeletonSourceByLimit( sourceDensityInputs, diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index c7b8a4de2f..9fb7222aec 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -69,6 +69,7 @@ import type { SpatialSkeletonSourceState, } from "#src/skeleton/api.js"; import { + forEachSpatialSkeletonSourceScale, forEachVisibleSpatialSkeletonChunk, SKELETON_LAYER_RPC_ID, type SpatiallyIndexedSkeletonChunkSpecification, @@ -1946,6 +1947,8 @@ const seenChunkKeysPerFrame = new WeakMap< { frameNumber: number; keys: Set } >(); +const SPATIAL_SKELETON_RESOLUTION_INDICATOR_BAR_HEIGHT = 10; + function updateSpatialSkeletonSpacingHistogram( histogram: RenderScaleHistogram, frameNumber: number, @@ -1965,6 +1968,25 @@ function updateSpatialSkeletonSpacingHistogram( } const seenKeys = seen.keys; for (const scales of transformedSources) { + forEachSpatialSkeletonSourceScale( + projectionParameters, + spacingTarget, + scales, + (tsource, _, physicalSpacing, pixelSpacing, selected) => { + if (selected) return; + const source = tsource.source as SpatiallyIndexedSkeletonSource; + const indicatorKey = `indicator:${getObjectId(source)}`; + if (seenKeys.has(indicatorKey)) return; + seenKeys.add(indicatorKey); + histogram.add( + physicalSpacing, + pixelSpacing, + 0, + SPATIAL_SKELETON_RESOLUTION_INDICATOR_BAR_HEIGHT, + true, + ); + }, + ); forEachVisibleSpatialSkeletonChunk( projectionParameters, localPosition, diff --git a/src/skeleton/source_selection.spec.ts b/src/skeleton/source_selection.spec.ts index fde7544185..c68443c955 100644 --- a/src/skeleton/source_selection.spec.ts +++ b/src/skeleton/source_selection.spec.ts @@ -17,6 +17,7 @@ import { describe, expect, it } from "vitest"; import { + getSpatialSkeletonSourceScalesByLimit, selectSpatialSkeletonSourceByLimit, SPATIAL_SKELETON_ZERO_LIMIT_FINEST_ERROR, } from "#src/skeleton/source_selection.js"; @@ -252,4 +253,36 @@ describe("skeleton/source_selection", () => { expect(selection?.source).toBe("coarse"); }); + + it("reports all source scales in coarse-to-fine order for histogram indicators", () => { + const scales = getSpatialSkeletonSourceScalesByLimit( + [ + { + source: "complete-finest", + index: 1, + physicalVolume: 125, + limit: 0, + sliceFraction: 1, + }, + { + source: "coarse", + index: 0, + physicalVolume: 1000, + limit: 200, + sliceFraction: 1, + }, + ], + 1000, + 100, + ); + + expect(scales.map((scale) => scale.source)).toEqual([ + "coarse", + "complete-finest", + ]); + expect(scales[0].physicalDensity).toBeCloseTo(0.2); + expect(scales[1].physicalDensity).toBe(Number.POSITIVE_INFINITY); + expect(scales[1].physicalSpacing).toBe(Number.POSITIVE_INFINITY); + expect(scales[1].pixelSpacing).toBe(Number.POSITIVE_INFINITY); + }); }); diff --git a/src/skeleton/source_selection.ts b/src/skeleton/source_selection.ts index 7569a2e480..e41f4f406a 100644 --- a/src/skeleton/source_selection.ts +++ b/src/skeleton/source_selection.ts @@ -124,6 +124,27 @@ export function selectSpatialSkeletonSourceByLimit( ); } +export function getSpatialSkeletonSourceScalesByLimit( + sources: readonly SpatialSkeletonSourceDensityInput[], + effectiveVolume: number, + viewportArea: number, +): SpatialSkeletonSourceDensitySelection[] { + const orderedSources = getOrderedSpatialSkeletonSources(sources); + validateOrderedSpatialSkeletonLimitZeroOnlyFinest(orderedSources); + return orderedSources.map((source) => { + const physicalDensity = + source.limit === 0 + ? Number.POSITIVE_INFINITY + : (source.limit * source.sliceFraction) / source.physicalVolume; + return makeSpatialSkeletonSourceSelection( + source, + physicalDensity, + effectiveVolume, + viewportArea, + ); + }); +} + export function validateSpatialSkeletonLimitZeroOnlyFinest< T extends SpatialSkeletonSourceLimitInput, >(sources: readonly T[]) { From 78b73a30bf24e2081011cafc91f9ae35ae4e522e Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Fri, 22 May 2026 14:00:24 +0100 Subject: [PATCH 4/5] docs: Update user guide --- docs/user-guide/skeleton_editing.rst | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/user-guide/skeleton_editing.rst b/docs/user-guide/skeleton_editing.rst index c97b337b06..bc37b621b1 100644 --- a/docs/user-guide/skeleton_editing.rst +++ b/docs/user-guide/skeleton_editing.rst @@ -44,7 +44,9 @@ entry for each spatial index level: } ``chunk_size`` is specified in CATMAID project-space nanometers. ``limit`` is -the maximum node count expected for that spatial level and is required. +the maximum node count expected for that spatial level and is required. A +``limit`` of ``0`` is allowed only on the finest spatial level and means that +level is complete/unlimited. ``cache_provider`` is optional and, when present, is passed to CATMAID node-list requests. If ``read_only`` is not set to ``false``, Neuroglancer treats the source as read-only: skeletons can be inspected, but edit actions are disabled. @@ -72,8 +74,8 @@ In the **Render** tab you can adjust: When you make a skeleton visible, a full fetch is triggered and you are guaranteed to see all nodes and details of that skeleton. Otherwise you see whatever is -provided by the spatial index levels selected for the current view. The selected -levels are controlled via the **Spacing (skeleton grid 2D)** and +provided by the spatial index level selected for the current view. The selected +level is controlled via the **Spacing (skeleton grid 2D)** and **Spacing (skeleton grid 3D)** settings. The **Seg** tab works as normal for a segmentation layer, allowing you to set the From 2c6eb573a30da086234ef179a5bc865f4eb33806 Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Fri, 22 May 2026 17:23:02 +0100 Subject: [PATCH 5/5] refactor: Rename layer controls --- src/layer/segmentation/layer_controls.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/layer/segmentation/layer_controls.ts b/src/layer/segmentation/layer_controls.ts index 20ae807763..c0689da1a7 100644 --- a/src/layer/segmentation/layer_controls.ts +++ b/src/layer/segmentation/layer_controls.ts @@ -69,7 +69,7 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ })), }, { - label: "Spacing (skeleton grid 2D)", + label: "Spacing (cross section)", toolJson: json_keys.SKELETON_CROSS_SECTION_SPACING_JSON_KEY, isValid: (layer) => makeCachedDerivedWatchableValue( @@ -81,14 +81,14 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ ], ), title: - "Select the node spacing for spatially indexed skeletons in 2D views", + "Select the node spacing for spatially indexed skeletons in cross-section views", ...renderScaleLayerControl((layer) => ({ histogram: layer.displayState.spatialSkeletonSpacingHistogram2d, target: layer.displayState.spatialSkeletonSpacingTarget2d, })), }, { - label: "Spacing (skeleton grid 3D)", + label: "Spacing (projection)", toolJson: json_keys.SKELETON_PERSPECTIVE_SPACING_JSON_KEY, isValid: (layer) => makeCachedDerivedWatchableValue( @@ -100,7 +100,7 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ ], ), title: - "Select the node spacing for spatially indexed skeletons in 3D views", + "Select the node spacing for spatially indexed skeletons in projection views", ...renderScaleLayerControl((layer) => ({ histogram: layer.displayState.spatialSkeletonSpacingHistogram3d, target: layer.displayState.spatialSkeletonSpacingTarget3d,