Skip to content
2 changes: 1 addition & 1 deletion src/Atlas2DSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] },
});
}
}
Expand Down
45 changes: 21 additions & 24 deletions src/Volume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = {};
Expand Down Expand Up @@ -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)
);
}

Expand All @@ -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
);
}

Expand Down Expand Up @@ -229,10 +229,7 @@ export default class Volume {
*/
private async loadNewData(onChannelLoaded?: PerChannelCallback): Promise<void> {
this.setUnloaded();
this.loadSpec = {
...this.loadSpecRequired,
subregion: this.loadSpecRequired.subregion.clone(),
};
this.loadSpec = cloneLoadSpec(this.loadSpecRequired);

try {
await this.loader?.loadVolumeData(this, undefined, onChannelLoaded);
Expand Down
4 changes: 2 additions & 2 deletions src/VolumeDrawable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Object3D,
Euler,
Vector2,
Box3,

Check warning on line 6 in src/VolumeDrawable.ts

View workflow job for this annotation

GitHub Actions / ✅ Lint

'Box3' is defined but never used
Comment thread
frasercl marked this conversation as resolved.
Outdated
DepthTexture,
OrthographicCamera,
PerspectiveCamera,
Expand Down Expand Up @@ -845,7 +845,7 @@
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:
Expand All @@ -855,7 +855,7 @@
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) {
Expand Down
46 changes: 40 additions & 6 deletions src/loaders/IVolumeLoader.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
import { Box3, Vector3 } from "three";
import { Vector3, Box3 } from "three";
Comment thread
frasercl marked this conversation as resolved.
Outdated

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;
Expand All @@ -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,
Expand All @@ -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<LoadSpec> {
return {
...new LoadSpec(),
maxAtlasEdge: MAX_ATLAS_EDGE,
scaleLevelBias: 0,
multiscaleLevel: 0,
channels: [0],
};
}

export function cloneLoadSpec<S extends LoadSpec>(spec: S): S {
return {
...spec,
channels: spec.channels?.slice() ?? [],
subregion: { min: [...spec.subregion.min], max: [...spec.subregion.max] },
};
}

export type LoadedVolumeInfo = {
Expand Down
6 changes: 2 additions & 4 deletions src/loaders/JsonImageInfoLoader.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { Box3, Vector3 } from "three";

import {
ThreadableVolumeLoader,
type LoadSpec,
Expand Down Expand Up @@ -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),
Expand Down
22 changes: 9 additions & 13 deletions src/loaders/OmeZarrLoader.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Box3, Vector3 } from "three";
import { Vector3 } from "three";

import * as zarr from "zarrita";
const { slice } = zarr;
Expand All @@ -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,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -293,7 +289,7 @@ class OMEZarrLoader extends ThreadableVolumeLoader {
loadDims(loadSpec: LoadSpec): Promise<VolumeDims[]> {
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];
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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());
Expand Down
6 changes: 2 additions & 4 deletions src/loaders/RawArrayLoader.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { Box3, Vector3 } from "three";

import {
ThreadableVolumeLoader,
type LoadSpec,
Expand Down Expand Up @@ -140,10 +138,10 @@ class RawArrayLoader extends ThreadableVolumeLoader {
): Promise<void> {
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);
Expand Down
21 changes: 12 additions & 9 deletions src/loaders/VolumeLoaderUtils.ts
Original file line number Diff line number Diff line change
@@ -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";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: is there a place that is more "math utils"-like to put the regionToBox3 and box3toRegion functions?

Copy link
Copy Markdown
Collaborator Author

@frasercl frasercl May 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's utils/num_utils.ts, but it seems to be geared more towards formatting numbers for display


export const MAX_ATLAS_EDGE = 4096;

Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}

Expand Down
8 changes: 4 additions & 4 deletions src/test/RequestQueue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -404,7 +404,7 @@ describe("test RequestQueue", () => {
});

async function mockLoader(loadSpec: Required<LoadSpec>, maxDelayMs = 10.0): Promise<TypedArray<"uint8">> {
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;

Expand All @@ -426,8 +426,8 @@ describe("test RequestQueue", () => {
const requests: Request<T>[] = [];
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),
Expand Down
7 changes: 3 additions & 4 deletions src/workers/VolumeLoadWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -74,20 +73,20 @@ const messageHandlers: { [T in WorkerMsgType]: MessageHandler<T> } = {
[WorkerMsgType.CREATE_VOLUME]: async (loadSpec, loaderId) => {
const { loader } = getLoader(loaderId);

return await loader.createImageInfo(rebuildLoadSpec(loadSpec));
return await loader.createImageInfo(loadSpec);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this safe to do now because it now uses primitive types instead of the threejs classes?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep! All rebuildLoadSpec was doing was re-creating the Box3 to get the prototype chain back.

},

[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) => {
const { loader, copyOnLoad } = getLoader(loaderId);

return loader.loadRawChannelData(
imageInfo,
rebuildLoadSpec(loadSpec),
loadSpec,
(imageInfo, loadSpec) => {
const message: WorkerResponse<WorkerMsgType> = {
responseResult: WorkerResponseResult.EVENT,
Expand Down
Loading
Loading