diff --git a/src/Atlas2DSlice.ts b/src/Atlas2DSlice.ts index a69d18f1..21c9c301 100644 --- a/src/Atlas2DSlice.ts +++ b/src/Atlas2DSlice.ts @@ -187,7 +187,7 @@ export default class Atlas2DSlice implements VolumeRenderImpl { const sliceLowerBound = Math.floor(this.settings.zSlice) / this.volume.imageInfo.volumeSize.z; const sliceUpperBound = (Math.floor(this.settings.zSlice) + 1) / this.volume.imageInfo.volumeSize.z; this.volume.updateRequiredData({ - subregion: new Box3(new Vector3(0, 0, sliceLowerBound), new Vector3(1, 1, sliceUpperBound)), + subregion: { min: [0, 0, sliceLowerBound], max: [1, 1, sliceUpperBound] }, }); } } diff --git a/src/Volume.ts b/src/Volume.ts index 204eef1b..16a861ce 100644 --- a/src/Volume.ts +++ b/src/Volume.ts @@ -4,8 +4,9 @@ import Channel from "./Channel.js"; import Histogram from "./Histogram.js"; import { Lut } from "./Lut.js"; import { getColorByChannelIndex } from "./constants/colors.js"; -import { type IVolumeLoader, LoadSpec, type PerChannelCallback } from "./loaders/IVolumeLoader.js"; -import { MAX_ATLAS_EDGE, pickLevelToLoadUnscaled } from "./loaders/VolumeLoaderUtils.js"; +import type { IVolumeLoader, PerChannelCallback } from "./loaders/IVolumeLoader.js"; +import { cloneLoadSpec, defaultLoadSpec, LoadSpec, regionToBox3 } from "./loaders/IVolumeLoader.js"; +import { pickLevelToLoadUnscaled } from "./loaders/VolumeLoaderUtils.js"; import type { NumberType, TypedArray } from "./types.js"; import { type ImageInfo, CImageInfo, defaultImageInfo } from "./ImageInfo.js"; import type { VolumeDims } from "./VolumeDims.js"; @@ -78,18 +79,11 @@ export default class Volume { // TODO: use getter? this.name = imageInfo.name; this.loadSpec = { - // Fill in defaults for optional properties - multiscaleLevel: 0, - scaleLevelBias: 0, - maxAtlasEdge: MAX_ATLAS_EDGE, + ...defaultLoadSpec(), channels: Array.from({ length: this.imageInfo.numChannels }, (_val, idx) => idx), - ...loadSpec, - }; - this.loadSpecRequired = { - ...this.loadSpec, - channels: this.loadSpec.channels.slice(), - subregion: this.loadSpec.subregion.clone(), + ...cloneLoadSpec(loadSpec), }; + this.loadSpecRequired = cloneLoadSpec(this.loadSpec); this.loader = loader; // imageMetadata to be filled in by Volume Loaders this.imageMetadata = {}; @@ -166,11 +160,14 @@ export default class Volume { /** Returns `true` iff differences between `loadSpec` and `loadSpecRequired` indicate new data *must* be loaded. */ private mustLoadNewData(): boolean { + const { loadSpec, loadSpecRequired } = this; + const currentRegion = regionToBox3(loadSpec.subregion); + const requiredRegion = regionToBox3(loadSpecRequired.subregion); return ( - this.loadSpec.useExplicitLevel !== this.loadSpecRequired.useExplicitLevel || // explicit vs automatic level changed - this.loadSpec.time !== this.loadSpecRequired.time || // time point changed - !this.loadSpec.subregion.containsBox(this.loadSpecRequired.subregion) || // new subregion not contained in old - this.loadSpecRequired.channels.some((channel) => !this.loadSpec.channels.includes(channel)) // new channel(s) + loadSpec.useExplicitLevel !== loadSpecRequired.useExplicitLevel || // explicit vs automatic level changed + loadSpec.time !== loadSpecRequired.time || // time point changed + !currentRegion.containsBox(requiredRegion) || // new subregion not contained in old + (loadSpecRequired.channels ?? []).some((channel) => !(loadSpec.channels ?? []).includes(channel)) // new channel(s) ); } @@ -184,11 +181,14 @@ export default class Volume { * imposed by `multiscaleLevel`. */ private mayLoadNewScaleLevel(): boolean { + const { loadSpec, loadSpecRequired } = this; + const currentRegion = regionToBox3(loadSpec.subregion); + const requiredRegion = regionToBox3(loadSpecRequired.subregion); return ( - !this.loadSpec.subregion.equals(this.loadSpecRequired.subregion) || - this.loadSpecRequired.maxAtlasEdge !== this.loadSpec.maxAtlasEdge || - this.loadSpecRequired.multiscaleLevel !== this.loadSpec.multiscaleLevel || - this.loadSpecRequired.scaleLevelBias !== this.loadSpec.scaleLevelBias + !currentRegion.equals(requiredRegion) || + loadSpecRequired.maxAtlasEdge !== loadSpec.maxAtlasEdge || + loadSpecRequired.multiscaleLevel !== loadSpec.multiscaleLevel || + loadSpecRequired.scaleLevelBias !== loadSpec.scaleLevelBias ); } @@ -229,10 +229,7 @@ export default class Volume { */ private async loadNewData(onChannelLoaded?: PerChannelCallback): Promise { this.setUnloaded(); - this.loadSpec = { - ...this.loadSpecRequired, - subregion: this.loadSpecRequired.subregion.clone(), - }; + this.loadSpec = cloneLoadSpec(this.loadSpecRequired); try { await this.loader?.loadVolumeData(this, undefined, onChannelLoaded); diff --git a/src/VolumeDrawable.ts b/src/VolumeDrawable.ts index 32af925a..ff45ef76 100644 --- a/src/VolumeDrawable.ts +++ b/src/VolumeDrawable.ts @@ -3,7 +3,6 @@ import { Object3D, Euler, Vector2, - Box3, DepthTexture, OrthographicCamera, PerspectiveCamera, @@ -845,7 +844,7 @@ export default class VolumeDrawable { switch (newRenderMode) { case RenderMode.PATHTRACE: this.volumeRendering = new PathTracedVolume(this.volume, this.settings); - this.volume.updateRequiredData({ subregion: new Box3(new Vector3(0, 0, 0), new Vector3(1, 1, 1)) }); + this.volume.updateRequiredData({ subregion: { min: [0, 0, 0], max: [1, 1, 1] } }); this.volumeRendering.setRenderUpdateListener(this.renderUpdateListener); break; case RenderMode.SLICE: @@ -855,7 +854,7 @@ export default class VolumeDrawable { case RenderMode.RAYMARCH: default: this.volumeRendering = new RayMarchedAtlasVolume(this.volume, this.settings); - this.volume.updateRequiredData({ subregion: new Box3(new Vector3(0, 0, 0), new Vector3(1, 1, 1)) }); + this.volume.updateRequiredData({ subregion: { min: [0, 0, 0], max: [1, 1, 1] } }); break; } if (this.pickRendering) { diff --git a/src/loaders/IVolumeLoader.ts b/src/loaders/IVolumeLoader.ts index 2ef8f60a..f838c201 100644 --- a/src/loaders/IVolumeLoader.ts +++ b/src/loaders/IVolumeLoader.ts @@ -3,10 +3,24 @@ import { Box3, Vector3 } from "three"; import Volume from "../Volume.js"; import type { VolumeDims } from "../VolumeDims.js"; import { CImageInfo, type ImageInfo } from "../ImageInfo.js"; -import { TypedArray, NumberType } from "../types.js"; -import { createDefaultMetadata } from "./VolumeLoaderUtils.js"; +import type { TypedArray, NumberType } from "../types.js"; +import { createDefaultMetadata, MAX_ATLAS_EDGE } from "./VolumeLoaderUtils.js"; import { PrefetchDirection } from "./zarr_utils/types.js"; -import { ZarrLoaderFetchOptions } from "./OmeZarrLoader.js"; +import type { ZarrLoaderFetchOptions } from "./OmeZarrLoader.js"; + +/** A vanilla JS variant of `three`'s `Box3` type, to avoid using `three` types in our public interface */ +export type Region = { + min: [number, number, number]; + max: [number, number, number]; +}; + +export function regionToBox3(region: Region): Box3 { + return new Box3(new Vector3(...region.min), new Vector3(...region.max)); +} + +export function box3ToRegion(box: Box3): Region { + return { min: box.min.toArray(), max: box.max.toArray() }; +} export class LoadSpec { time = 0; @@ -20,7 +34,7 @@ export class LoadSpec { */ multiscaleLevel?: number; /** Subregion of volume to load. If not specified, the entire volume is loaded. Specify as floats between 0-1. */ - subregion = new Box3(new Vector3(0, 0, 0), new Vector3(1, 1, 1)); + subregion: Region = { min: [0, 0, 0], max: [1, 1, 1] }; channels?: number[]; /** Treat multiscaleLevel literally and don't use other constraints to change it. * By default we will try to load the best level based on the maxAtlasEdge and scaleLevelBias, @@ -31,7 +45,27 @@ export class LoadSpec { export function loadSpecToString(spec: LoadSpec): string { const { min, max } = spec.subregion; - return `${spec.multiscaleLevel}:${spec.time}:x(${min.x},${max.x}):y(${min.y},${max.y}):z(${min.z},${max.z})`; + const [xmin, ymin, zmin] = min; + const [xmax, ymax, zmax] = max; + return `${spec.multiscaleLevel}:${spec.time}:x(${xmin},${xmax}):y(${ymin},${ymax}):z(${zmin},${zmax})`; +} + +export function defaultLoadSpec(): Required { + return { + ...new LoadSpec(), + maxAtlasEdge: MAX_ATLAS_EDGE, + scaleLevelBias: 0, + multiscaleLevel: 0, + channels: [0], + }; +} + +export function cloneLoadSpec(spec: S): S { + return { + ...spec, + channels: spec.channels?.slice() ?? [], + subregion: { min: [...spec.subregion.min], max: [...spec.subregion.max] }, + }; } export type LoadedVolumeInfo = { diff --git a/src/loaders/JsonImageInfoLoader.ts b/src/loaders/JsonImageInfoLoader.ts index 8fe1115c..244831c6 100644 --- a/src/loaders/JsonImageInfoLoader.ts +++ b/src/loaders/JsonImageInfoLoader.ts @@ -1,5 +1,3 @@ -import { Box3, Vector3 } from "three"; - import { ThreadableVolumeLoader, type LoadSpec, @@ -206,10 +204,10 @@ class JsonImageInfoLoader extends ThreadableVolumeLoader { images = images.map((element) => ({ ...element, name: urlPrefix + element.name })); // Update `image`'s `loadSpec` before loading - const adjustedLoadSpec = { + const adjustedLoadSpec: LoadSpec = { ...loadSpec, // `subregion` and `multiscaleLevel` are unused by this loader - subregion: new Box3(new Vector3(0, 0, 0), new Vector3(1, 1, 1)), + subregion: { min: [0, 0, 0], max: [1, 1, 1] }, multiscaleLevel: 0, // include all channels in any loaded images channels: images.flatMap(({ channels }) => channels), diff --git a/src/loaders/OmeZarrLoader.ts b/src/loaders/OmeZarrLoader.ts index b11bd96e..46a351cb 100644 --- a/src/loaders/OmeZarrLoader.ts +++ b/src/loaders/OmeZarrLoader.ts @@ -1,4 +1,4 @@ -import { Box3, Vector3 } from "three"; +import { Vector3 } from "three"; import * as zarr from "zarrita"; const { slice } = zarr; @@ -8,12 +8,8 @@ import type { VolumeDims } from "../VolumeDims.js"; import VolumeCache from "../VolumeCache.js"; import { getDataRange } from "../utils/num_utils.js"; import SubscribableRequestQueue from "../utils/SubscribableRequestQueue.js"; -import { - ThreadableVolumeLoader, - LoadSpec, - type RawChannelDataCallback, - type LoadedVolumeInfo, -} from "./IVolumeLoader.js"; +import type { RawChannelDataCallback, LoadedVolumeInfo, Region } from "./IVolumeLoader.js"; +import { ThreadableVolumeLoader, LoadSpec, box3ToRegion } from "./IVolumeLoader.js"; import { composeSubregion, computePackedAtlasDims, @@ -100,7 +96,7 @@ class OMEZarrLoader extends ThreadableVolumeLoader { // TODO: this property should definitely be owned by `Volume` if this loader is ever used by multiple volumes. // This may cause errors or incorrect results otherwise! - private maxExtent?: Box3; + private maxExtent?: Region; private syncChannels = false; @@ -293,7 +289,7 @@ class OMEZarrLoader extends ThreadableVolumeLoader { loadDims(loadSpec: LoadSpec): Promise { const [spaceUnit, timeUnit] = this.getUnitSymbols(); // Compute subregion size so we can factor that in - const maxExtent = this.maxExtent ?? new Box3(new Vector3(0, 0, 0), new Vector3(1, 1, 1)); + const maxExtent = this.maxExtent ?? { min: [0, 0, 0], max: [1, 1, 1] }; const subregion = composeSubregion(loadSpec.subregion, maxExtent); const regionSize = subregion.getSize(new Vector3()); const regionArr = [1, 1, regionSize.z, regionSize.y, regionSize.x]; @@ -350,7 +346,7 @@ class OMEZarrLoader extends ThreadableVolumeLoader { } if (!this.maxExtent) { - this.maxExtent = loadSpec.subregion.clone(); + this.maxExtent = { min: [...loadSpec.subregion.min], max: [...loadSpec.subregion.max] }; } // from source 0: @@ -423,7 +419,7 @@ class OMEZarrLoader extends ThreadableVolumeLoader { // which the volume contains. The volume contains the full extent of the subset recognized by this loader. const fullExtentLoadSpec: LoadSpec = { ...loadSpec, - subregion: new Box3(new Vector3(0, 0, 0), new Vector3(1, 1, 1)), + subregion: { min: [0, 0, 0], max: [1, 1, 1] }, }; return Promise.resolve({ imageInfo: imgdata, loadSpec: fullExtentLoadSpec }); @@ -496,8 +492,8 @@ class OMEZarrLoader extends ThreadableVolumeLoader { private updateImageInfoForLoad(imageInfo: ImageInfo, loadSpec: LoadSpec): ImageInfo { // Apply `this.maxExtent` to subregion, if it exists - const maxExtent = this.maxExtent ?? new Box3(new Vector3(0, 0, 0), new Vector3(1, 1, 1)); - const subregion = composeSubregion(loadSpec.subregion, maxExtent); + const maxExtent = this.maxExtent ?? { min: [0, 0, 0], max: [1, 1, 1] }; + const subregion = box3ToRegion(composeSubregion(loadSpec.subregion, maxExtent)); // Pick the level to load based on the subregion size const multiscaleLevel = pickLevelToLoad({ ...loadSpec, subregion }, this.getLevelShapesZYX()); diff --git a/src/loaders/RawArrayLoader.ts b/src/loaders/RawArrayLoader.ts index 64e065a7..4b6635df 100644 --- a/src/loaders/RawArrayLoader.ts +++ b/src/loaders/RawArrayLoader.ts @@ -1,5 +1,3 @@ -import { Box3, Vector3 } from "three"; - import { ThreadableVolumeLoader, type LoadSpec, @@ -140,10 +138,10 @@ class RawArrayLoader extends ThreadableVolumeLoader { ): Promise { const requestedChannels = loadSpec.channels; - const adjustedLoadSpec = { + const adjustedLoadSpec: LoadSpec = { ...loadSpec, // `subregion` and `multiscaleLevel` are unused by this loader - subregion: new Box3(new Vector3(0, 0, 0), new Vector3(1, 1, 1)), + subregion: { min: [0, 0, 0], max: [1, 1, 1] }, multiscaleLevel: 0, }; onUpdateMetadata(undefined, adjustedLoadSpec); diff --git a/src/loaders/VolumeLoaderUtils.ts b/src/loaders/VolumeLoaderUtils.ts index b375041b..c68344cd 100644 --- a/src/loaders/VolumeLoaderUtils.ts +++ b/src/loaders/VolumeLoaderUtils.ts @@ -1,7 +1,7 @@ import { Box3, Vector2, Vector3 } from "three"; import { CImageInfo, type ImageInfo } from "../ImageInfo.js"; -import { LoadSpec } from "./IVolumeLoader.js"; +import { LoadSpec, type Region, regionToBox3 } from "./IVolumeLoader.js"; export const MAX_ATLAS_EDGE = 4096; @@ -175,14 +175,15 @@ export function pickLevelToLoadUnscaled(loadSpec: LoadSpec, spatialDimsZYX: ZYX[ * `LoadSpec`'s `subregion` property. */ export function pickLevelToLoad(loadSpec: LoadSpec, spatialDimsZYX: ZYX[]): number { - const scaledDims = scaleMultipleDimsToSubregion(loadSpec.subregion, spatialDimsZYX); + const region = regionToBox3(loadSpec.subregion); + const scaledDims = scaleMultipleDimsToSubregion(region, spatialDimsZYX); return pickLevelToLoadUnscaled(loadSpec, scaledDims); } /** Given the size of a volume in pixels, convert a `Box3` in the 0-1 range to pixels */ -export function convertSubregionToPixels(region: Box3, size: Vector3): Box3 { - const min = region.min.clone().multiply(size).floor(); - const max = region.max.clone().multiply(size).ceil(); +export function convertSubregionToPixels(region: Region, size: Vector3): Box3 { + const min = new Vector3(...region.min).multiply(size).floor(); + const max = new Vector3(...region.max).multiply(size).ceil(); // ensure it's always valid to specify the same number at both ends and get a single slice if (min.x === max.x && min.x < size.x) { @@ -202,10 +203,12 @@ export function convertSubregionToPixels(region: Box3, size: Vector3): Box3 { * Return the subset of `container` specified by `region`, assuming that `region` contains fractional values (between 0 * and 1). i.e. if `container`'s range on the X axis is 0-4 and `region`'s is 0.25-0.5, the result will have range 1-2. */ -export function composeSubregion(region: Box3, container: Box3): Box3 { - const size = container.getSize(new Vector3()); - const min = region.min.clone().multiply(size).add(container.min); - const max = region.max.clone().multiply(size).add(container.min); +export function composeSubregion(region: Region, container: Region): Box3 { + const regionBox = regionToBox3(region); + const containerBox = regionToBox3(container); + const size = containerBox.getSize(new Vector3()); + const min = regionBox.min.clone().multiply(size).add(containerBox.min); + const max = regionBox.max.clone().multiply(size).add(containerBox.min); return new Box3(min, max); } diff --git a/src/test/RequestQueue.test.ts b/src/test/RequestQueue.test.ts index fb3445c6..2734df24 100644 --- a/src/test/RequestQueue.test.ts +++ b/src/test/RequestQueue.test.ts @@ -4,7 +4,7 @@ import { Vector3 } from "three"; import type { TypedArray } from "zarrita"; import RequestQueue, { type Request } from "../utils/RequestQueue.js"; -import { LoadSpec, loadSpecToString } from "../loaders/IVolumeLoader.js"; +import { LoadSpec, loadSpecToString, regionToBox3 } from "../loaders/IVolumeLoader.js"; /** * Returns a promise that resolves once the timeout (give in ms) is completed. @@ -404,7 +404,7 @@ describe("test RequestQueue", () => { }); async function mockLoader(loadSpec: Required, maxDelayMs = 10.0): Promise> { - const { x, y, z } = loadSpec.subregion.getSize(new Vector3()); + const { x, y, z } = regionToBox3(loadSpec.subregion).getSize(new Vector3()); const data = new Uint8Array(x * y * z); const delayMs = Math.random() * maxDelayMs; @@ -426,8 +426,8 @@ describe("test RequestQueue", () => { const requests: Request[] = []; for (let i = startingFrame; i < startingFrame + frames; i++) { const loadSpec = new LoadSpec(); - loadSpec.subregion.min.set(0, 0, i); - loadSpec.subregion.max.set(xDim, yDim, i + 1); + loadSpec.subregion.min = [0, 0, i]; + loadSpec.subregion.max = [xDim, yDim, i + 1]; requests.push({ key: loadSpecToString(loadSpec), diff --git a/src/workers/VolumeLoadWorker.ts b/src/workers/VolumeLoadWorker.ts index cdcd0d76..687d12ee 100644 --- a/src/workers/VolumeLoadWorker.ts +++ b/src/workers/VolumeLoadWorker.ts @@ -14,7 +14,6 @@ import type { WorkerResponsePayload, } from "./types.js"; import { WorkerEventType, WorkerMsgType, WorkerResponseResult } from "./types.js"; -import { rebuildLoadSpec } from "./util.js"; type LoaderEntry = { loader: ThreadableVolumeLoader; copyOnLoad: boolean }; @@ -74,12 +73,12 @@ const messageHandlers: { [T in WorkerMsgType]: MessageHandler } = { [WorkerMsgType.CREATE_VOLUME]: async (loadSpec, loaderId) => { const { loader } = getLoader(loaderId); - return await loader.createImageInfo(rebuildLoadSpec(loadSpec)); + return await loader.createImageInfo(loadSpec); }, [WorkerMsgType.LOAD_DIMS]: async (loadSpec, loaderId) => { const { loader } = getLoader(loaderId); - return await loader.loadDims(rebuildLoadSpec(loadSpec)); + return await loader.loadDims(loadSpec); }, [WorkerMsgType.LOAD_VOLUME_DATA]: ({ imageInfo, loadSpec, loadId }, loaderId) => { @@ -87,7 +86,7 @@ const messageHandlers: { [T in WorkerMsgType]: MessageHandler } = { return loader.loadRawChannelData( imageInfo, - rebuildLoadSpec(loadSpec), + loadSpec, (imageInfo, loadSpec) => { const message: WorkerResponse = { responseResult: WorkerResponseResult.EVENT, diff --git a/src/workers/VolumeLoaderContext.ts b/src/workers/VolumeLoaderContext.ts index 124dd971..090fb21c 100644 --- a/src/workers/VolumeLoaderContext.ts +++ b/src/workers/VolumeLoaderContext.ts @@ -23,7 +23,6 @@ import type { } from "./types.js"; import type { ZarrLoaderFetchOptions } from "../loaders/OmeZarrLoader.js"; import { WorkerMsgType, WorkerResponseResult, WorkerEventType } from "./types.js"; -import { rebuildLoadSpec } from "./util.js"; type StoredPromise = { type: T; @@ -299,7 +298,7 @@ class WorkerLoader extends ThreadableVolumeLoader { loadSpec, this.getLoaderId() ); - return { imageInfo, loadSpec: rebuildLoadSpec(adjustedLoadSpec) }; + return { imageInfo, loadSpec: adjustedLoadSpec }; } loadRawChannelData( @@ -333,9 +332,7 @@ class WorkerLoader extends ThreadableVolumeLoader { return; } - const imageInfo = e.imageInfo; - const loadSpec = e.loadSpec && rebuildLoadSpec(e.loadSpec); - this.currentMetadataUpdateCallback?.(imageInfo, loadSpec); + this.currentMetadataUpdateCallback?.(e.imageInfo, e.loadSpec); } } diff --git a/src/workers/util.ts b/src/workers/util.ts deleted file mode 100644 index ddc1453e..00000000 --- a/src/workers/util.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Box3, Vector3 } from "three"; - -import { LoadSpec } from "../loaders/IVolumeLoader.js"; - -/** Recreates a `LoadSpec` that has just been sent to/from a worker to restore three.js object prototypes */ -export function rebuildLoadSpec(spec: LoadSpec): LoadSpec { - return { - ...spec, - subregion: new Box3(new Vector3().copy(spec.subregion.min), new Vector3().copy(spec.subregion.max)), - }; -}