-
+
-
-
+
+
+
+
+
+
+
+
+
+
+
@@ -393,6 +561,10 @@ export default defineComponent({
}
.mapping-range-editor {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ column-gap: 8px;
padding: 8px 16px 8px 4px;
}
diff --git a/src/components/VolumeViewer.vue b/src/components/VolumeViewer.vue
index 54a8d33c8..fe920f18d 100644
--- a/src/components/VolumeViewer.vue
+++ b/src/components/VolumeViewer.vue
@@ -79,7 +79,7 @@ import CropTool from '@/src/components/tools/crop/CropTool.vue';
import { useWebGLWatchdog } from '@/src/composables/useWebGLWatchdog';
import VtkOrientationMarker from '@/src/components/vtk/VtkOrientationMarker.vue';
import ViewOverlayGrid from '@/src/components/ViewOverlayGrid.vue';
-import useVolumeColoringStore from '@/src/store/view-configs/volume-coloring';
+import { useVolumeColoringConfig } from '@/src/composables/useVolumeColoringConfig';
import { useResetViewsEvents } from '@/src/components/tools/ResetViews.vue';
import { onVTKEvent } from '@/src/composables/onVTKEvent';
import { useViewStore } from '@/src/store/views';
@@ -133,10 +133,7 @@ onVTKEvent(currentImageData, 'onModified', () => {
});
// color preset
-const coloringStore = useVolumeColoringStore();
-const coloringConfig = computed(() =>
- coloringStore.getConfig(viewId.value, currentImageID.value)
-);
+const coloringConfig = useVolumeColoringConfig(viewId, currentImageID);
const presetName = computed(
() => coloringConfig.value?.transferFunction.preset.replace(/-/g, ' ') ?? ''
);
diff --git a/src/components/vtk/VtkBaseVolumeRepresentation.vue b/src/components/vtk/VtkBaseVolumeRepresentation.vue
index 03953f45d..34b608b86 100644
--- a/src/components/vtk/VtkBaseVolumeRepresentation.vue
+++ b/src/components/vtk/VtkBaseVolumeRepresentation.vue
@@ -14,7 +14,7 @@ import { useVolumeRepresentation } from '@/src/core/vtk/useVolumeRepresentation'
import { Maybe } from '@/src/types';
import { VtkViewContext } from '@/src/components/vtk/context';
import { useColoringEffect } from '@/src/composables/useColoringEffect';
-import useVolumeColoringStore, {
+import {
DEFAULT_AMBIENT,
DEFAULT_DIFFUSE,
DEFAULT_SPECULAR,
@@ -22,6 +22,7 @@ import useVolumeColoringStore, {
import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction';
import vtkPiecewiseFunction from '@kitware/vtk.js/Common/DataModel/PiecewiseFunction';
import { useVolumeColoringInitializer } from '@/src/composables/useVolumeColoringInitializer';
+import { useVolumeColoringConfig } from '@/src/composables/useVolumeColoringConfig';
import { isViewAnimating } from '@/src/composables/isViewAnimating';
import { InterpolationType } from '@kitware/vtk.js/Rendering/Core/VolumeProperty/Constants';
import {
@@ -42,10 +43,10 @@ import {
type ChunkLoadedInfo,
} from '@/src/core/streaming/chunkImage';
-interface Props {
+type Props = {
viewId: string;
imageId: Maybe
;
-}
+};
const props = defineProps();
const { viewId: viewID, imageId: imageID } = toRefs(props);
@@ -59,9 +60,6 @@ const {
isLoading: isImageStreaming,
image,
} = useImage(imageID);
-const coloringConfig = computed(() =>
- useVolumeColoringStore().getConfig(viewID.value, imageID.value)
-);
// setup base image
const rep = useVolumeRepresentation(view, imageData);
@@ -104,6 +102,8 @@ watch(updatedExtents, (current, old) => {
view.requestRender();
});
+const coloringConfig = useVolumeColoringConfig(viewID, imageID);
+
// cinematic volume rendering
const cvrParams = computed(() => coloringConfig.value?.cvr);
const center = computed(() =>
diff --git a/src/composables/__tests__/usePiecewiseHistogram.spec.ts b/src/composables/__tests__/usePiecewiseHistogram.spec.ts
new file mode 100644
index 000000000..cd5bff96b
--- /dev/null
+++ b/src/composables/__tests__/usePiecewiseHistogram.spec.ts
@@ -0,0 +1,241 @@
+import { MessageChannel } from 'node:worker_threads';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { effectScope } from 'vue';
+import * as Comlink from 'comlink';
+import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
+import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
+import { usePiecewiseHistogram } from '@/src/composables/usePiecewiseHistogram';
+import { piecewiseHistogram } from '@/src/utils/histogram';
+import { defer } from '@/src/utils';
+
+function histogramEndpoint() {
+ const { port1, port2 } = new MessageChannel();
+ const started = defer();
+ const completion = defer();
+ const terminated = defer();
+ Comlink.expose(
+ {
+ async piecewiseHistogram(...args: Parameters) {
+ started.resolve();
+ await completion.promise;
+ return piecewiseHistogram(...args);
+ },
+ },
+ port2
+ );
+ port1.start();
+ return Object.assign(port1, {
+ started: started.promise,
+ terminated: terminated.promise,
+ async finish(error?: Error) {
+ if (error) completion.reject(error);
+ else completion.resolve();
+ await terminated.promise;
+ },
+ terminate() {
+ port1.close();
+ port2.close();
+ terminated.resolve();
+ },
+ });
+}
+
+function createImage() {
+ const image = vtkImageData.newInstance();
+ image.setDimensions([3, 2, 2]);
+ image.getPointData().setScalars(
+ vtkDataArray.newInstance({
+ values: Float32Array.from([0, 1, 1, 2, 2, 2, 3, 3, 3, 3, 4, 4]),
+ })
+ );
+ return image;
+}
+
+describe('opacity editor histogram reuse', () => {
+ let workers: ReturnType[];
+ let scope: ReturnType;
+
+ beforeEach(() => {
+ workers = [];
+ scope = effectScope();
+ vi.stubGlobal(
+ 'Worker',
+ class {
+ constructor() {
+ const worker = histogramEndpoint();
+ workers.push(worker);
+ return worker;
+ }
+ }
+ );
+ });
+
+ afterEach(() => {
+ scope.stop();
+ workers.forEach((worker) => worker.terminate());
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ function createEditor() {
+ const changes: Array<{ histogram: Float32Array | null; range: number[] }> =
+ [];
+ const update = scope.run(() =>
+ usePiecewiseHistogram({
+ getNumberOfBins: () => 5,
+ setHistogram: (histogram, range) => changes.push({ histogram, range }),
+ })
+ )!;
+ return { update, changes };
+ }
+
+ it('restores completed histograms without clearing the chart or copying volume data again', async () => {
+ const image = createImage();
+ const { update, changes } = createEditor();
+ update(image);
+ await workers[0].started;
+ await workers[0].finish();
+ const ordinary = changes.at(-1);
+
+ update(image, [2, 4]);
+ await workers[1].started;
+ await workers[1].finish();
+ const adaptive = changes.at(-1);
+ expect(adaptive?.histogram).not.toEqual(ordinary?.histogram);
+
+ update(image);
+ expect(changes.at(-1)).toEqual(ordinary);
+ update(image, [2, 4]);
+ expect(changes.at(-1)).toEqual(adaptive);
+ expect(workers).toHaveLength(2);
+ expect(changes.filter(({ histogram }) => !histogram)).toHaveLength(2);
+ });
+
+ it('keeps an adaptive histogram calculation across preset changes with identical ranges', async () => {
+ const image = createImage();
+ const { update, changes } = createEditor();
+ update(image, [2, 4]);
+ await workers[0].started;
+ update(image, [2, 4]);
+ expect(workers).toHaveLength(1);
+ expect(changes).toHaveLength(1);
+ await workers[0].finish();
+ expect(changes.at(-1)?.histogram).toEqual(
+ piecewiseHistogram([2, 4, 2, 2, 2, 3, 3, 3, 3, 4, 4], [2, 4], 5)
+ );
+ });
+
+ it('uses RGB component intensities across the full histogram width', async () => {
+ const image = createImage();
+ const values = Uint8Array.from([
+ 0, 0, 0, 255, 0, 0, 0, 255, 0, 255, 255, 255,
+ ]);
+ image
+ .getPointData()
+ .setScalars(vtkDataArray.newInstance({ values, numberOfComponents: 3 }));
+ const { update, changes } = createEditor();
+ update(image);
+ await workers[0].started;
+ await workers[0].finish();
+ expect(changes.at(-1)).toEqual({
+ range: [0, 255],
+ histogram: piecewiseHistogram(values, [0, 255], 5),
+ });
+ expect(changes.at(-1)?.histogram?.at(-1)).toBeGreaterThan(0);
+ });
+
+ it('excludes fitted-out intensities instead of piling them into endpoint bins', async () => {
+ const image = createImage();
+ const values = Float32Array.from({ length: 1000 }, (_, index) => index);
+ image.getPointData().getScalars().setData(values);
+ const { update, changes } = createEditor();
+ update(image, [400, 600]);
+ await workers[0].started;
+ await workers[0].finish();
+ expect(changes.at(-1)?.histogram).toEqual(
+ piecewiseHistogram(
+ [400, 600, ...values.filter((value) => value >= 400 && value <= 600)],
+ [400, 600],
+ 5
+ )
+ );
+ });
+
+ it('stops a pending image calculation when returning to a cached image', async () => {
+ const first = createImage();
+ const second = createImage();
+ const { update, changes } = createEditor();
+ update(first);
+ await workers[0].started;
+ await workers[0].finish();
+ const original = changes.at(-1);
+
+ update(second);
+ await workers[1].started;
+ update(first);
+ await workers[1].terminated;
+ await workers[1].finish();
+ expect(changes.at(-1)).toEqual(original);
+
+ update(second);
+ expect(workers).toHaveLength(3);
+ await workers[2].started;
+ await workers[2].finish();
+ expect(changes.at(-1)).toEqual(original);
+ });
+
+ it('retries a failed histogram for the same image and range', async () => {
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const image = createImage();
+ const { update, changes } = createEditor();
+ update(image);
+ await workers[0].started;
+ await workers[0].finish(new Error('Histogram failed'));
+ expect(errors).toHaveBeenCalledOnce();
+ expect(changes.at(-1)?.histogram).toBeNull();
+
+ update(image);
+ expect(workers).toHaveLength(2);
+ await workers[1].started;
+ await workers[1].finish();
+ expect(changes.at(-1)?.histogram).toEqual(
+ piecewiseHistogram(image.getPointData().getScalars().getData(), [0, 4], 5)
+ );
+ });
+
+ it('blocks late completion and cached revisits after the editor is disposed', async () => {
+ const image = createImage();
+ const { update, changes } = createEditor();
+ update(image);
+ await workers[0].started;
+ await workers[0].finish();
+ update(image, [2, 4]);
+ await workers[1].started;
+ const beforeDisposal = changes.length;
+
+ scope.stop();
+ await workers[1].terminated;
+ await workers[1].finish();
+ update(image);
+ expect(changes).toHaveLength(beforeDisposal);
+ expect(workers).toHaveLength(2);
+ });
+
+ it('refreshes the histogram when the underlying volume changes', async () => {
+ const image = createImage();
+ const { update, changes } = createEditor();
+ update(image);
+ await workers[0].started;
+ await workers[0].finish();
+ const original = changes.at(-1)?.histogram;
+
+ const scalars = image.getPointData().getScalars();
+ scalars.setComponent(0, 0, 8);
+ scalars.modified();
+ update(image);
+ await workers[1].started;
+ await workers[1].finish();
+ expect(changes.at(-1)?.range).toEqual([1, 8]);
+ expect(changes.at(-1)?.histogram).not.toEqual(original);
+ });
+});
diff --git a/src/composables/__tests__/useVolumeColoringConfig.spec.ts b/src/composables/__tests__/useVolumeColoringConfig.spec.ts
new file mode 100644
index 000000000..d8580b8a3
--- /dev/null
+++ b/src/composables/__tests__/useVolumeColoringConfig.spec.ts
@@ -0,0 +1,134 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { createPinia, setActivePinia } from 'pinia';
+import { mount, type VueWrapper } from '@vue/test-utils';
+import { defineComponent, h, nextTick, onScopeDispose, ref } from 'vue';
+import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
+import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
+import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction';
+import vtkPiecewiseFunction from '@kitware/vtk.js/Common/DataModel/PiecewiseFunction';
+import { useColoringEffect } from '@/src/composables/useColoringEffect';
+import { useVolumeColoringConfig } from '@/src/composables/useVolumeColoringConfig';
+import { useVolumeColoringInitializer } from '@/src/composables/useVolumeColoringInitializer';
+import { useVtkComputed } from '@/src/core/vtk/useVtkComputed';
+import { useImageCacheStore } from '@/src/store/image-cache';
+import useVolumeColoringStore from '@/src/store/view-configs/volume-coloring';
+
+const viewID = 'volume';
+let wrapper: VueWrapper | undefined;
+
+function addImage(values: number[]) {
+ const image = vtkImageData.newInstance();
+ const scalars = vtkDataArray.newInstance({
+ values: Float32Array.from(values),
+ name: 'Scalars',
+ });
+ image.setDimensions(values.length, 1, 1);
+ image.getPointData().setScalars(scalars);
+ return {
+ image,
+ imageID: useImageCacheStore().addVTKImageData(image, 'volume'),
+ };
+}
+
+async function settleColoring() {
+ await nextTick();
+ await vi.runAllTimersAsync();
+ await nextTick();
+}
+
+describe('displayed volume coloring', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ setActivePinia(createPinia());
+ });
+
+ afterEach(() => {
+ wrapper?.unmount();
+ wrapper = undefined;
+ Object.values(useImageCacheStore().imageById).forEach((image) =>
+ image.dispose()
+ );
+ vi.useRealTimers();
+ });
+
+ it('replaces the previous appearance with a zero-range provisional preset and follows decoded intensities', async () => {
+ const first = addImage([-10, -5, 0]);
+ const streaming = addImage([0, 0, 0]);
+ useImageCacheStore().imageById[streaming.imageID].loaded.value = false;
+ const store = useVolumeColoringStore();
+ store.setDefaults(streaming.imageID, {
+ transferFunction: { preset: 'Grayscale' },
+ });
+ store.setColorPreset(viewID, first.imageID, 'Grayscale');
+ const imageID = ref(first.imageID);
+ const sampleValue = ref(0);
+
+ wrapper = mount(
+ defineComponent({
+ setup() {
+ useVolumeColoringInitializer(viewID, imageID);
+ const config = useVolumeColoringConfig(viewID, imageID);
+ const colors = vtkColorTransferFunction.newInstance();
+ const opacity = vtkPiecewiseFunction.newInstance();
+ onScopeDispose(() => {
+ colors.delete();
+ opacity.delete();
+ });
+ useColoringEffect(config, colors, opacity);
+ const rgb = useVtkComputed(colors, () => {
+ const color: number[] = [];
+ colors.getColor(sampleValue.value, color);
+ return color;
+ });
+ const alpha = useVtkComputed(opacity, () =>
+ opacity.getValue(sampleValue.value)
+ );
+ return () =>
+ h('div', [
+ h(
+ 'p',
+ { 'data-testid': 'preset' },
+ config.value?.transferFunction.preset
+ ),
+ h('div', {
+ 'data-testid': 'sample',
+ style: {
+ backgroundColor: `rgb(${rgb.value.map((value) => Math.round(value * 255)).join(', ')})`,
+ opacity: alpha.value,
+ },
+ }),
+ ]);
+ },
+ })
+ );
+ await settleColoring();
+ expect(wrapper.get('[data-testid="sample"]').attributes('style')).toContain(
+ 'background-color: rgb(255, 255, 255)'
+ );
+
+ imageID.value = streaming.imageID;
+ await settleColoring();
+ expect(wrapper.get('[data-testid="preset"]').text()).toBe('Grayscale');
+ expect(wrapper.get('[data-testid="sample"]').attributes('style')).toContain(
+ 'background-color: rgb(0, 0, 0)'
+ );
+
+ sampleValue.value = 100;
+ streaming.image
+ .getPointData()
+ .getScalars()
+ .setData(Float32Array.from([0, 100, 200]));
+ streaming.image.modified();
+ await settleColoring();
+ expect(wrapper.get('[data-testid="sample"]').attributes('style')).toContain(
+ 'background-color: rgb(128, 128, 128)'
+ );
+
+ store.setDefaults(streaming.imageID, {});
+ useImageCacheStore().imageById[streaming.imageID].loaded.value = true;
+ await settleColoring();
+ expect(wrapper.get('[data-testid="preset"]').text()).toBe(
+ 'Adaptive-Balanced'
+ );
+ });
+});
diff --git a/src/composables/__tests__/useVolumeThumbnailing.spec.ts b/src/composables/__tests__/useVolumeThumbnailing.spec.ts
index 27df935ef..5f8a8fd28 100644
--- a/src/composables/__tests__/useVolumeThumbnailing.spec.ts
+++ b/src/composables/__tests__/useVolumeThumbnailing.spec.ts
@@ -17,7 +17,9 @@ const OTHER_IMAGE_ID = 'img-2';
// the real thumbnailer needs a WebGL context the test DOM cannot provide, so
// this stands in for the surface the composable touches
-function createStubThumbnailer(capture?: Promise) {
+function createStubThumbnailer(
+ capture?: Promise | (() => Promise)
+) {
let deleted = false;
let captureCount = 0;
const noopProxy = {
@@ -31,7 +33,11 @@ function createStubThumbnailer(capture?: Promise) {
render: NOOP,
captureImages: () => {
captureCount += 1;
- return [capture ?? Promise.resolve('')];
+ return [
+ typeof capture === 'function'
+ ? capture()
+ : (capture ?? Promise.resolve('')),
+ ];
},
};
const thumbnailer = {
@@ -39,6 +45,7 @@ function createStubThumbnailer(capture?: Promise) {
opacityFuncProxy: noopProxy,
colorTransferFuncProxy: noopProxy,
setInputImage: NOOP,
+ setPreset: NOOP,
resetCameraWithOrientation: NOOP,
delete() {
deleted = true;
@@ -66,8 +73,20 @@ function mountThumbnailing(
) {
const component = defineComponent({
setup() {
- useVolumeThumbnailing(64, () => thumbnailer);
- return () => h('div');
+ const { currentThumbnails } = useVolumeThumbnailing(
+ 64,
+ () => thumbnailer
+ );
+ return () =>
+ h('div', [
+ h('img', {
+ src: currentThumbnails.value['Adaptive-Balanced'] ?? '',
+ }),
+ h('img', {
+ 'data-preset': 'CT-AAA',
+ src: currentThumbnails.value['CT-AAA'] ?? '',
+ }),
+ ]);
},
});
return mount(component, {
@@ -136,4 +155,73 @@ describe('useVolumeThumbnailing', () => {
expect(stub.getCaptureCount()).toBeGreaterThan(1);
});
+ it('keeps the thumbnail map renderable when a volume finishes loading', async () => {
+ const uri = 'data:image/png;base64,dGh1bWJuYWls';
+ const stub = createStubThumbnailer(Promise.resolve(uri));
+ addImageToCache();
+ const image = useImageCacheStore().imageById[IMAGE_ID];
+ image.loaded.value = false;
+ const wrapper = mountThumbnailing(stub.thumbnailer, ref(IMAGE_ID));
+ await flushPromises();
+ expect(wrapper.get('img').attributes('src')).toBe('');
+
+ image.loaded.value = true;
+ await flushPromises();
+ expect(wrapper.get('img').attributes('src')).toBe(uri);
+ wrapper.unmount();
+ await flushPromises();
+ });
+
+ it('shows the selected image thumbnails when its cache is already complete', async () => {
+ const firstURI = 'data:image/png;base64,Zmlyc3Q=';
+ const secondURI = 'data:image/png;base64,c2Vjb25k';
+ let captureURI = firstURI;
+ const stub = createStubThumbnailer(() => Promise.resolve(captureURI));
+ addImageToCache();
+ addImageToCache(OTHER_IMAGE_ID);
+ const imageID = ref(IMAGE_ID);
+ const wrapper = mountThumbnailing(stub.thumbnailer, imageID);
+ await flushPromises();
+ expect(wrapper.get('img').attributes('src')).toBe(firstURI);
+
+ captureURI = secondURI;
+ imageID.value = OTHER_IMAGE_ID;
+ await flushPromises();
+ expect(wrapper.get('img').attributes('src')).toBe(secondURI);
+
+ imageID.value = IMAGE_ID;
+ await flushPromises();
+ expect(wrapper.get('img').attributes('src')).toBe(firstURI);
+ wrapper.unmount();
+ await flushPromises();
+ });
+
+ it('waits for complete data before rendering thumbnails, including background loads', async () => {
+ const partialURI = 'data:image/png;base64,cGFydGlhbA==';
+ const completeURI = 'data:image/png;base64,Y29tcGxldGU=';
+ let captureURI = partialURI;
+ const stub = createStubThumbnailer(() => Promise.resolve(captureURI));
+ addImageToCache();
+ addImageToCache(OTHER_IMAGE_ID);
+ const image = useImageCacheStore().imageById[IMAGE_ID];
+ image.loaded.value = false;
+ const imageID = ref(IMAGE_ID);
+ const wrapper = mountThumbnailing(stub.thumbnailer, imageID);
+ await flushPromises();
+ expect(stub.getCaptureCount()).toBe(0);
+ expect(wrapper.get('[data-preset="CT-AAA"]').attributes('src')).toBe('');
+
+ imageID.value = OTHER_IMAGE_ID;
+ await flushPromises();
+ image.loaded.value = true;
+ captureURI = completeURI;
+ imageID.value = IMAGE_ID;
+ await flushPromises();
+
+ expect(wrapper.get('[data-preset="CT-AAA"]').attributes('src')).toBe(
+ completeURI
+ );
+ wrapper.unmount();
+ await flushPromises();
+ });
});
diff --git a/src/composables/usePiecewiseHistogram.ts b/src/composables/usePiecewiseHistogram.ts
new file mode 100644
index 000000000..568403ad6
--- /dev/null
+++ b/src/composables/usePiecewiseHistogram.ts
@@ -0,0 +1,105 @@
+import { onScopeDispose } from 'vue';
+import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
+import { getAdaptiveVolumeHistogram } from '@/src/utils/adaptiveVolumePreset';
+import { createHistogramWorker } from '@/src/utils/createHistogramWorker';
+import { getAllComponentRange } from '@/src/utils/dataArrayRange';
+import { logError } from '@/src/utils/loggers';
+
+type HistogramWidget = {
+ getNumberOfBins(): number;
+ setHistogram(histogram: Float32Array | null, range: number[]): void;
+};
+
+type HistogramCache = {
+ revision: string;
+ histograms: Map;
+};
+
+function getImageHistograms(
+ cache: WeakMap,
+ image: vtkImageData,
+ revision: string
+) {
+ const stored = cache.get(image);
+ if (stored?.revision === revision) return stored.histograms;
+ const histograms = new Map();
+ cache.set(image, { revision, histograms });
+ return histograms;
+}
+
+function computeHistogram(
+ data: ArrayLike,
+ range: [number, number],
+ bins: number
+) {
+ const worker = createHistogramWorker();
+ return {
+ result: worker.remote.piecewiseHistogram(data, range, bins),
+ stop: worker.stop,
+ };
+}
+
+export function usePiecewiseHistogram(widget: HistogramWidget) {
+ const cache = new WeakMap();
+ let previous:
+ | { image: vtkImageData; revision: string; key: string }
+ | undefined;
+ let active: ReturnType | undefined;
+ let disposed = false;
+
+ onScopeDispose(() => {
+ disposed = true;
+ active?.stop();
+ active = undefined;
+ });
+
+ return (image: vtkImageData, fittedRange?: [number, number]) => {
+ if (disposed) return;
+ const scalars = image.getPointData().getScalars();
+ const revision = `${image.getMTime()}:${scalars.getMTime()}`;
+ const range: [number, number] = [
+ ...(fittedRange ?? getAllComponentRange(scalars)),
+ ];
+ const bins = widget.getNumberOfBins();
+ const key = `${!!fittedRange}:${range}:${bins}`;
+ if (
+ previous?.image === image &&
+ previous.revision === revision &&
+ previous.key === key
+ )
+ return;
+ previous = { image, revision, key };
+ active?.stop();
+ active = undefined;
+
+ const histograms = getImageHistograms(cache, image, revision);
+ const cached = histograms.get(key);
+ if (cached) {
+ widget.setHistogram(cached, range);
+ return;
+ }
+
+ const data = fittedRange
+ ? Float64Array.from(getAdaptiveVolumeHistogram(image, range))
+ : scalars.getData();
+ const request = computeHistogram(data, range, bins);
+ active = request;
+ widget.setHistogram(null, range);
+ request.result
+ .then((histogram) => {
+ if (disposed || active !== request) return;
+ histograms.set(key, histogram);
+ widget.setHistogram(histogram, range);
+ })
+ .catch((error) => {
+ if (!disposed && active === request) {
+ previous = undefined;
+ logError(error);
+ }
+ })
+ .finally(() => {
+ if (active === request) active = undefined;
+ request.stop();
+ });
+ };
+}
diff --git a/src/composables/useVolumeColoringConfig.ts b/src/composables/useVolumeColoringConfig.ts
new file mode 100644
index 000000000..481e4e585
--- /dev/null
+++ b/src/composables/useVolumeColoringConfig.ts
@@ -0,0 +1,22 @@
+import { computed, type MaybeRef, unref } from 'vue';
+import { useImage } from '@/src/composables/useCurrentImage';
+import { useVtkComputed } from '@/src/core/vtk/useVtkComputed';
+import useVolumeColoringStore from '@/src/store/view-configs/volume-coloring';
+import type { Maybe } from '@/src/types';
+
+export function useVolumeColoringConfig(
+ viewId: MaybeRef>,
+ imageId: MaybeRef>
+) {
+ const store = useVolumeColoringStore();
+ const { imageData } = useImage(imageId);
+ const provisionalColoring = useVtkComputed(imageData, () =>
+ store.getProvisionalColoring(unref(imageId))
+ );
+
+ return computed(
+ () =>
+ store.getConfig(unref(viewId), unref(imageId)) ??
+ provisionalColoring.value
+ );
+}
diff --git a/src/composables/useVolumeColoringInitializer.ts b/src/composables/useVolumeColoringInitializer.ts
index 791bfccf0..2694a0655 100644
--- a/src/composables/useVolumeColoringInitializer.ts
+++ b/src/composables/useVolumeColoringInitializer.ts
@@ -13,17 +13,30 @@ export function useVolumeColoringInitializer(
store.getConfig(unref(viewId), unref(imageId))
);
- const { imageData } = useImage(imageId);
+ const { imageData, image } = useImage(imageId);
const viewIdRef = computed(() => unref(viewId));
const imageIdRef = computed(() => unref(imageId));
- watchImmediate([coloringConfig, viewIdRef, imageIdRef], () => {
- if (coloringConfig.value) return;
+ watchImmediate(
+ [
+ coloringConfig,
+ viewIdRef,
+ imageIdRef,
+ imageData,
+ () => image.value?.isLoaded(),
+ ],
+ () => {
+ const viewIdVal = unref(viewId);
+ const imageIdVal = unref(imageId);
+ if (!viewIdVal || !imageIdVal || !imageData.value) return;
- const viewIdVal = unref(viewId);
- const imageIdVal = unref(imageId);
- if (!viewIdVal || !imageIdVal || !imageData.value) return;
-
- store.resetToDefaultColoring(viewIdVal, imageIdVal, imageData.value);
- });
+ // Default selection uses the complete scalar range, including for DICOM.
+ if (!image.value?.isLoaded()) return;
+ if (coloringConfig.value) {
+ store.finalizePresetRange(viewIdVal, imageIdVal);
+ return;
+ }
+ store.resetToDefaultColoring(viewIdVal, imageIdVal, imageData.value);
+ }
+ );
}
diff --git a/src/composables/useVolumeThumbnailing.ts b/src/composables/useVolumeThumbnailing.ts
index 5900f9aa8..4562f8d4d 100644
--- a/src/composables/useVolumeThumbnailing.ts
+++ b/src/composables/useVolumeThumbnailing.ts
@@ -1,57 +1,33 @@
-import { onBeforeUnmount, reactive, ref, watch } from 'vue';
-import { Mode as LookupTableProxyMode } from '@kitware/vtk.js/Proxy/Core/LookupTableProxy';
+import { computed, onBeforeUnmount, reactive, watch } from 'vue';
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
-import vtkPiecewiseFunctionProxy from '@kitware/vtk.js/Proxy/Core/PiecewiseFunctionProxy';
-import { getPresetByName } from '@/src/types/color-map-preset';
import { createVolumeThumbnailer } from '../core/thumbnailers/volume-thumbnailer';
import { useCameraOrientation } from './useCameraOrientation';
import { useImageStore } from '../store/datasets-images';
import { useCurrentImage } from './useCurrentImage';
-import {
- getColorFunctionRangeFromPreset,
- getOpacityRangeFromPreset,
-} from '../utils/vtk-helpers';
import { PresetNameList } from '../vtk/ColorMaps';
import { logError } from '../utils/loggers';
-
-function resetOpacityFunction(
- pwfProxy: vtkPiecewiseFunctionProxy,
- dataRange: [number, number],
- presetName: string
-) {
- // reset pwf proxy range
- pwfProxy.setDataRange(...dataRange);
-
- const preset = getPresetByName(presetName);
- if (preset?.OpacityPoints) {
- const OpacityPoints = preset.OpacityPoints;
- const points = [];
- for (let i = 0; i < OpacityPoints.length; i += 2) {
- points.push([OpacityPoints[i], OpacityPoints[i + 1]]);
- }
-
- const [xmin, xmax] = dataRange;
- const width = xmax - xmin;
- const pointsNormalized = points.map(([x, y]) => [(x - xmin) / width, y]);
-
- pwfProxy.setMode(vtkPiecewiseFunctionProxy.Mode.Points);
- pwfProxy.setPoints(pointsNormalized);
- } else {
- pwfProxy.setMode(vtkPiecewiseFunctionProxy.Mode.Gaussians);
- pwfProxy.setGaussians(vtkPiecewiseFunctionProxy.Defaults.Gaussians);
- }
-}
+import {
+ getAdaptiveVolumePresetUnavailableReason,
+ isAdaptiveVolumePreset,
+} from '@/src/utils/adaptiveVolumePreset';
export function useVolumeThumbnailing(
thumbnailSize: number,
createThumbnailer = createVolumeThumbnailer
) {
const thumbnails = reactive>>({});
+ const thumbnailSources = new Map();
const thumbnailer = createThumbnailer(thumbnailSize);
- const currentThumbnails = ref>({});
- const { currentImageMetadata, currentImageID, currentImageData } =
- useCurrentImage();
+ const {
+ currentImageMetadata,
+ currentImageID,
+ currentImageData,
+ currentImage,
+ } = useCurrentImage();
+ const currentThumbnails = computed(
+ () => thumbnails[currentImageID.value ?? ''] ?? {}
+ );
// same as 3D view
const { cameraDirVec, cameraUpVec } = useCameraOrientation(
@@ -75,18 +51,16 @@ export function useVolumeThumbnailing(
interruptSentinel = localSentinel;
thumbnailer.setInputImage(image);
- const imageDataRange = image.getPointData().getScalars().getRange();
+ const adaptiveAvailable = !getAdaptiveVolumePresetUnavailableReason(
+ image,
+ !!currentImage.value?.isLoaded()
+ );
+ const cycleIsCurrent = () =>
+ interruptSentinel === localSentinel && imageID === currentImageID.value;
async function helper(presetName: string) {
- // bail if a new thumbnail process has started
- if (interruptSentinel !== localSentinel) {
- return;
- }
-
- // sanity check; did the current image change
- if (imageID !== currentImageID.value) {
- return;
- }
+ if (!cycleIsCurrent()) return;
+ if (isAdaptiveVolumePreset(presetName) && !adaptiveAvailable) return;
if (!(imageID in thumbnails)) {
thumbnails[imageID] = {};
@@ -96,19 +70,7 @@ export function useVolumeThumbnailing(
return;
}
- const opRange = getOpacityRangeFromPreset(presetName);
- resetOpacityFunction(
- thumbnailer.opacityFuncProxy,
- opRange || imageDataRange,
- presetName
- );
-
- thumbnailer.colorTransferFuncProxy.setMode(LookupTableProxyMode.Preset);
- thumbnailer.colorTransferFuncProxy.setPresetName(presetName);
- const ctRange = getColorFunctionRangeFromPreset(presetName);
- thumbnailer.colorTransferFuncProxy.setDataRange(
- ...(ctRange || imageDataRange)
- );
+ thumbnailer.setPreset(presetName);
thumbnailer.resetCameraWithOrientation(
cameraDirVec.value,
@@ -116,7 +78,6 @@ export function useVolumeThumbnailing(
);
const renWin = thumbnailer.scene.getRenderWindow();
- renWin.render();
const capture = renWin.captureImages()[0];
inFlightCaptures.add(capture);
const imageURL = await capture.finally(() => {
@@ -124,8 +85,7 @@ export function useVolumeThumbnailing(
});
// the capture spans a render, so the cycle may have been superseded
- if (interruptSentinel !== localSentinel) return;
- if (imageID !== currentImageID.value) return;
+ if (!cycleIsCurrent()) return;
if (imageURL) {
thumbnails[imageID][presetName] = imageURL;
@@ -138,18 +98,6 @@ export function useVolumeThumbnailing(
).catch(logError);
}
- // workaround for computed not properly working on deeply reactive objects
- // in vue 2.
- watch(
- thumbnails,
- () => {
- if (currentImageID.value) {
- currentThumbnails.value = thumbnails[currentImageID.value];
- }
- },
- { deep: true }
- );
-
// force thumbnailing to stop
onBeforeUnmount(() => {
interruptSentinel = UNMOUNTED;
@@ -160,10 +108,17 @@ export function useVolumeThumbnailing(
// trigger thumbnailing
watch(
- currentImageID,
- (imageID) => {
- if (imageID) {
- doThumbnailing(imageID, currentImageData.value!);
+ [currentImageID, currentImageData, () => currentImage.value?.isLoaded()],
+ ([imageID, image, loaded]) => {
+ if (interruptSentinel === UNMOUNTED) return;
+ interruptSentinel = Symbol('interrupt');
+ if (imageID && image && loaded) {
+ const source = thumbnailSources.get(imageID);
+ if (source !== image) {
+ thumbnails[imageID] = {};
+ thumbnailSources.set(imageID, image);
+ }
+ doThumbnailing(imageID, image);
}
},
{ immediate: true }
@@ -177,6 +132,7 @@ export function useVolumeThumbnailing(
if (id in thumbnails) {
after(() => {
delete thumbnails[id];
+ thumbnailSources.delete(id);
});
}
}
diff --git a/src/config.ts b/src/config.ts
index 2ac555334..c47a83e4c 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -180,7 +180,7 @@ export const STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT = 1;
export const DEFAULT_PRESET_BY_MODALITY: Record = {
CT: 'CT-AAA',
- MR: 'CT-Coronary-Arteries-2',
+ MR: 'MR-Default',
US: 'US-Fetal',
};
diff --git a/src/core/thumbnailers/volume-thumbnailer.ts b/src/core/thumbnailers/volume-thumbnailer.ts
index 8d5d21c2f..ef965cd3f 100644
--- a/src/core/thumbnailers/volume-thumbnailer.ts
+++ b/src/core/thumbnailers/volume-thumbnailer.ts
@@ -13,6 +13,17 @@ import vtkOpenGLRenderWindow from '@kitware/vtk.js/Rendering/OpenGL/RenderWindow
import { getDiagonalLength } from '@kitware/vtk.js/Common/DataModel/BoundingBox';
import { beginContextRelease } from '@/src/core/vtk/releaseRenderWindow';
import { deleteInteractor } from '@/src/core/vtk/deleteInteractor';
+import { getPresetByName } from '@/src/types/color-map-preset';
+import {
+ getColorFunctionRangeFromPreset,
+ getOpacityRangeFromPreset,
+} from '@/src/utils/vtk-helpers';
+import {
+ ADAPTIVE_VOLUME_LIGHTING,
+ getAdaptiveVolumePreset,
+} from '@/src/utils/adaptiveVolumePreset';
+import { applyOpacityFunction } from '@/src/composables/useColoringEffect';
+import { setCinematicVolumeShading } from '@/src/utils/volumeProperties';
export function createRenderingPipeline() {
const actor = vtkVolume.newInstance();
@@ -69,6 +80,31 @@ function updateRenderingProperty(
prop.setSpecularPower(8.0);
}
+function resetOpacityFunction(
+ pwfProxy: vtkPiecewiseFunctionProxy,
+ dataRange: [number, number],
+ presetName: string
+) {
+ pwfProxy.setDataRange(...dataRange);
+ const preset = getPresetByName(presetName);
+ if (preset?.OpacityPoints) {
+ const points = [];
+ const [minimum, maximum] = dataRange;
+ const width = maximum - minimum;
+ for (let i = 0; i < preset.OpacityPoints.length; i += 2) {
+ points.push([
+ (preset.OpacityPoints[i] - minimum) / width,
+ preset.OpacityPoints[i + 1],
+ ]);
+ }
+ pwfProxy.setMode(vtkPiecewiseFunctionProxy.Mode.Points);
+ pwfProxy.setPoints(points);
+ } else {
+ pwfProxy.setMode(vtkPiecewiseFunctionProxy.Mode.Gaussians);
+ pwfProxy.setGaussians(vtkPiecewiseFunctionProxy.Defaults.Gaussians);
+ }
+}
+
export function createVolumeThumbnailer(size: number) {
const container = document.createElement('div');
container.style.width = `${size}px`;
@@ -79,6 +115,10 @@ export function createVolumeThumbnailer(size: number) {
background: [0.2, 0.3, 0.4],
});
scene.setContainer(container);
+ const apiRenderWindow =
+ scene.getApiSpecificRenderWindow() as vtkOpenGLRenderWindow;
+ // Size the capture directly because the detached container has no layout.
+ apiRenderWindow.setSize(size, size);
const pipeline = createRenderingPipeline();
const { actor, mapper } = pipeline;
@@ -108,6 +148,37 @@ export function createVolumeThumbnailer(size: number) {
renderer.removeVolume(actor);
}
},
+ setPreset(presetName: string) {
+ const image = mapper.getInputData();
+ if (!image) return;
+ const dataRange = image.getPointData().getScalars().getRange();
+ const adaptive = getAdaptiveVolumePreset(image, presetName);
+ updateRenderingProperty(actor.getProperty(), mapper, image);
+ colorTransferFuncProxy.setMode(vtkLookupTableProxy.Mode.Preset);
+ colorTransferFuncProxy.setPresetName(presetName);
+ colorTransferFuncProxy.setDataRange(
+ ...(adaptive?.transferFunction.mappingRange ??
+ getColorFunctionRangeFromPreset(presetName) ??
+ dataRange)
+ );
+ if (adaptive) {
+ applyOpacityFunction(adaptive.opacityFunction, pipeline.ofun);
+ setCinematicVolumeShading({
+ enabled: true,
+ image,
+ property: actor.getProperty(),
+ ambient: ADAPTIVE_VOLUME_LIGHTING.ambient,
+ diffuse: ADAPTIVE_VOLUME_LIGHTING.diffuse,
+ specular: ADAPTIVE_VOLUME_LIGHTING.specular,
+ });
+ } else {
+ resetOpacityFunction(
+ opacityFuncProxy,
+ getOpacityRangeFromPreset(presetName) ?? dataRange,
+ presetName
+ );
+ }
+ },
resetCameraWithOrientation(direction: Vector3, up: Vector3) {
const image = mapper.getInputData();
if (image) {
@@ -134,8 +205,6 @@ export function createVolumeThumbnailer(size: number) {
renderer.removeVolume(actor);
// scene.delete() deletes the API specific render window, so the context
// can only be handed back afterwards
- const apiRenderWindow =
- scene.getApiSpecificRenderWindow() as vtkOpenGLRenderWindow;
const loseContext = beginContextRelease(apiRenderWindow);
const interactor = scene.getInteractor();
const renderWindow = scene.getRenderWindow();
diff --git a/src/core/vtk/__tests__/useVtkComputed.spec.ts b/src/core/vtk/__tests__/useVtkComputed.spec.ts
new file mode 100644
index 000000000..d70d0f70d
--- /dev/null
+++ b/src/core/vtk/__tests__/useVtkComputed.spec.ts
@@ -0,0 +1,74 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { defineComponent, h, nextTick, ref, shallowRef } from 'vue';
+import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
+import { useVtkComputed } from '@/src/core/vtk/useVtkComputed';
+
+describe('useVtkComputed', () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('refreshes displayed values when the VTK object or reactive getter inputs change', async () => {
+ const image = shallowRef(null);
+ const multiplier = ref(1);
+ const wrapper = mount(
+ defineComponent({
+ setup() {
+ const dimensions = useVtkComputed(image, () =>
+ image.value
+ ?.getDimensions()
+ .map((value) => value * multiplier.value)
+ );
+ return () => h('output', dimensions.value?.join(',') ?? 'No image');
+ },
+ })
+ );
+ expect(wrapper.text()).toBe('No image');
+
+ const first = vtkImageData.newInstance();
+ first.setDimensions([2, 3, 4]);
+ image.value = first;
+ await nextTick();
+ expect(wrapper.text()).toBe('2,3,4');
+
+ multiplier.value = 2;
+ await nextTick();
+ expect(wrapper.text()).toBe('4,6,8');
+
+ const second = vtkImageData.newInstance();
+ second.setDimensions([5, 6, 7]);
+ image.value = second;
+ await nextTick();
+ expect(wrapper.text()).toBe('10,12,14');
+
+ image.value = null;
+ await nextTick();
+ expect(wrapper.text()).toBe('No image');
+ wrapper.unmount();
+ first.delete();
+ second.delete();
+ });
+
+ it('refreshes the displayed range after VTK modification and cancels queued work on unmount', async () => {
+ vi.useFakeTimers();
+ const image = vtkImageData.newInstance();
+ const wrapper = mount(
+ defineComponent({
+ setup() {
+ const dimensions = useVtkComputed(image, () => image.getDimensions());
+ return () => h('output', dimensions.value.join(','));
+ },
+ })
+ );
+ image.setDimensions([2, 3, 4]);
+ await vi.runAllTimersAsync();
+ expect(wrapper.text()).toBe('2,3,4');
+
+ image.setDimensions([5, 6, 7]);
+ wrapper.unmount();
+ image.delete();
+ await vi.runAllTimersAsync();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+});
diff --git a/src/core/vtk/useVtkComputed.ts b/src/core/vtk/useVtkComputed.ts
index c26a55e38..b6864dca9 100644
--- a/src/core/vtk/useVtkComputed.ts
+++ b/src/core/vtk/useVtkComputed.ts
@@ -1,4 +1,12 @@
-import { MaybeRef, computed, unref, ref, Ref } from 'vue';
+import {
+ MaybeRef,
+ computed,
+ unref,
+ ref,
+ Ref,
+ watchEffect,
+ onScopeDispose,
+} from 'vue';
import type { vtkObject } from '@kitware/vtk.js/interfaces';
import { onPausableVTKEvent } from '@/src/composables/onPausableVTKEvent';
import { batchForNextTask } from '@/src/utils/batchForNextTask';
@@ -21,31 +29,25 @@ export function useVtkComputed, R>(
const trackedValue = ref(
Array.isArray(initialValue) ? [...initialValue] : initialValue
) as Ref;
- let lastValueIsArray = Array.isArray(initialValue);
- const onModified = batchForNextTask(() => {
+ const refresh = () => {
if (unref(obj)?.isDeleted()) return;
const currentValue = propertyGetter(); // Avoid unnecessary updates when array contents haven't changed
- if (lastValueIsArray && Array.isArray(currentValue)) {
+ if (Array.isArray(currentValue)) {
const previousValue = trackedValue.value;
if (
Array.isArray(previousValue) &&
- !arrayEquals(previousValue as any[], currentValue as any[])
- ) {
- trackedValue.value = [...currentValue] as R;
+ arrayEquals(previousValue, currentValue)
+ )
return;
- }
- return;
- }
-
- if (Array.isArray(currentValue)) {
trackedValue.value = [...currentValue] as R;
- lastValueIsArray = true;
} else {
trackedValue.value = currentValue;
- lastValueIsArray = false;
}
- });
+ };
+ watchEffect(refresh);
+ const onModified = batchForNextTask(refresh);
+ onScopeDispose(onModified.cancel);
onPausableVTKEvent(obj as vtkObject, 'onModified', onModified);
return computed(() => {
diff --git a/src/store/image-stats.ts b/src/store/image-stats.ts
index 84e79aeab..1e08a72c1 100644
--- a/src/store/image-stats.ts
+++ b/src/store/image-stats.ts
@@ -8,12 +8,11 @@ import {
effectScope,
type EffectScope,
} from 'vue';
-import * as Comlink from 'comlink';
-import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
import { useVtkComputed } from '@/src/core/vtk/useVtkComputed';
import { WLAutoRanges, WL_HIST_BINS } from '@/src/constants';
-import { HistogramWorker } from '@/src/utils/histogram.worker';
+import { createHistogramWorker } from '@/src/utils/createHistogramWorker';
+import { getAllComponentRange } from '@/src/utils/dataArrayRange';
import { Maybe } from '@/src/types';
import { useImage } from '@/src/composables/useCurrentImage';
import { ensureError } from '@/src/utils';
@@ -27,46 +26,18 @@ export type ImageStats = {
autoRangeValues?: Record;
};
-function getRangesWithCache(scalars: vtkDataArray) {
- const numberOfComponents = scalars.getNumberOfComponents();
- return Array.from({ length: numberOfComponents }, (_, i) => {
- const [min, max] = scalars.getRange(i);
- return { min, max };
- });
-}
-
-function getAllComponentRange(scalars: vtkDataArray) {
- const ranges = getRangesWithCache(scalars);
-
- const min = ranges
- .map((range) => range.min)
- .reduce((acc, val) => Math.min(acc, val), Infinity);
- const max = ranges
- .map((range) => range.max)
- .reduce((acc, val) => Math.max(acc, val), -Infinity);
-
- return { min, max };
-}
-
async function computeAutoRangeValues(imageData: vtkImageData) {
const scalars = imageData.getPointData()?.getScalars();
if (!scalars) {
return {};
}
- const { min, max } = getAllComponentRange(scalars);
+ const [min, max] = getAllComponentRange(scalars);
const scalarData = scalars.getData();
- const worker = new Worker(
- new URL('@/src/utils/histogram.worker.ts', import.meta.url),
- { type: 'module' }
- );
- const remote = Comlink.wrap(worker);
- const hist = await remote
+ const worker = createHistogramWorker();
+ const hist = await worker.remote
.histogram(scalarData, [min, max], WL_HIST_BINS)
- .finally(() => {
- remote[Comlink.releaseProxy]();
- worker.terminate();
- });
+ .finally(worker.stop);
const cumulativeHist: number[] = [];
hist.reduce((acc, val) => {
diff --git a/src/store/view-configs/volume-coloring.test.ts b/src/store/view-configs/volume-coloring.test.ts
new file mode 100644
index 000000000..3855d3876
--- /dev/null
+++ b/src/store/view-configs/volume-coloring.test.ts
@@ -0,0 +1,175 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { createPinia, setActivePinia } from 'pinia';
+import { effectScope, nextTick } from 'vue';
+import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
+import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
+import vtkPiecewiseFunction from '@kitware/vtk.js/Common/DataModel/PiecewiseFunction';
+import { useImageCacheStore } from '@/src/store/image-cache';
+import { applyOpacityFunction } from '@/src/composables/useColoringEffect';
+import { useVolumeColoringInitializer } from '@/src/composables/useVolumeColoringInitializer';
+import { ADAPTIVE_VOLUME_LIGHTING } from '@/src/utils/adaptiveVolumePreset';
+import useVolumeColoringStore from './volume-coloring';
+
+const viewID = 'volume';
+const images: vtkImageData[] = [];
+const scopes: ReturnType[] = [];
+
+function addImage(values: number[], numberOfComponents = 1) {
+ const image = vtkImageData.newInstance();
+ image.setDimensions(values.length / numberOfComponents, 1, 1);
+ image.getPointData().setScalars(
+ vtkDataArray.newInstance({
+ name: 'Scalars',
+ values: Float32Array.from(values),
+ numberOfComponents,
+ })
+ );
+ images.push(image);
+ const imageID = useImageCacheStore().addVTKImageData(image, 'volume');
+ return { image, imageID };
+}
+
+function watchColoring(imageID: string) {
+ const scope = effectScope();
+ scopes.push(scope);
+ scope.run(() => useVolumeColoringInitializer(viewID, imageID));
+}
+
+function selectStreamingPreset() {
+ const { image, imageID } = addImage([0, 100, 200]);
+ const progressiveImage = useImageCacheStore().imageById[imageID];
+ progressiveImage.loaded.value = false;
+ watchColoring(imageID);
+ const store = useVolumeColoringStore();
+ store.setColorPreset(viewID, imageID, 'CT-AAA');
+ const finish = async () => {
+ image
+ .getPointData()
+ .getScalars()
+ .setData(Float32Array.from([-100, 250, 600]));
+ progressiveImage.loaded.value = true;
+ await nextTick();
+ };
+ return { imageID, store, finish };
+}
+
+describe('volume coloring initialization and preset selection', () => {
+ beforeEach(() => setActivePinia(createPinia()));
+ afterEach(() => {
+ scopes.splice(0).forEach((scope) => scope.stop());
+ const cache = useImageCacheStore();
+ Object.values(cache.imageById).forEach((image) => image.dispose());
+ images.splice(0).forEach((image) => {
+ if (!image.isDeleted()) image.delete();
+ });
+ });
+
+ for (const { name, values, components } of [
+ { name: 'multicomponent', values: [0, 1, 2, 3, 4, 5], components: 3 },
+ { name: 'nonfinite', values: [0, Infinity], components: 1 },
+ ]) {
+ it(`uses an applicable fallback for a configured adaptive preset on ${name} data`, () => {
+ const { image, imageID } = addImage(values, components);
+ const store = useVolumeColoringStore();
+ store.setDefaults(imageID, {
+ transferFunction: { preset: 'Adaptive-Balanced' },
+ });
+
+ store.resetToDefaultColoring(viewID, imageID, image);
+
+ const config = store.getConfig(viewID, imageID)!;
+ expect(config.transferFunction.preset).toBe('CT-AAA');
+ const opacity = vtkPiecewiseFunction.newInstance();
+ try {
+ expect(() =>
+ applyOpacityFunction(config.opacityFunction, opacity)
+ ).not.toThrow();
+ } finally {
+ opacity.delete();
+ }
+ });
+ }
+
+ it('preserves explicit lighting while selecting adaptive and conventional presets', () => {
+ const { imageID } = addImage([0, 50, 100, 200, 500]);
+ const store = useVolumeColoringStore();
+ const lighting = {
+ enabled: false,
+ ambient: 0.1,
+ diffuse: 0.9,
+ specular: 0.8,
+ useLocalAmbientOcclusion: true,
+ };
+ store.updateCVRParameters(viewID, imageID, lighting);
+
+ for (const preset of ['Adaptive-Balanced', 'CT-AAA', 'Adaptive-Surface']) {
+ store.setColorPreset(viewID, imageID, preset);
+ expect(store.getConfig(viewID, imageID)?.cvr).toMatchObject(lighting);
+ }
+ });
+
+ it('applies adaptive lighting to the initial automatic appearance', () => {
+ const { image, imageID } = addImage([0, 50, 100, 200, 500]);
+ const store = useVolumeColoringStore();
+
+ store.resetToDefaultColoring(viewID, imageID, image);
+
+ expect(store.getConfig(viewID, imageID)?.transferFunction.preset).toBe(
+ 'Adaptive-Balanced'
+ );
+ expect(store.getConfig(viewID, imageID)?.cvr).toMatchObject(
+ ADAPTIVE_VOLUME_LIGHTING
+ );
+ });
+
+ it('fits a streaming preset to the completed range without resetting opacity or lighting edits', async () => {
+ const { imageID, store, finish } = selectStreamingPreset();
+ store.updateOpacityFunction(viewID, imageID, {
+ shift: 0.2,
+ shiftAlpha: 0.1,
+ });
+ store.updateCVRParameters(viewID, imageID, {
+ ambient: 0.4,
+ enabled: false,
+ });
+ const colorRange = [
+ ...store.getConfig(viewID, imageID)!.transferFunction.mappingRange,
+ ];
+
+ await finish();
+
+ const config = store.getConfig(viewID, imageID)!;
+ expect(config.opacityFunction).toMatchObject({
+ mappingRange: [-100, 600],
+ shift: 0.2,
+ shiftAlpha: 0.1,
+ });
+ expect(config.transferFunction).toMatchObject({
+ preset: 'CT-AAA',
+ mappingRange: colorRange,
+ });
+ expect(config.cvr).toMatchObject({ ambient: 0.4, enabled: false });
+ });
+
+ it('preserves a restored opacity range when an image finishes streaming', async () => {
+ const { imageID, store, finish } = selectStreamingPreset();
+ const selected = store.getConfig(viewID, imageID)!;
+ store.deserialize(viewID, {
+ [imageID]: {
+ volumeColorConfig: {
+ ...selected,
+ opacityFunction: {
+ ...selected.opacityFunction,
+ mappingRange: [10, 90],
+ },
+ },
+ },
+ });
+
+ await finish();
+
+ expect(
+ store.getConfig(viewID, imageID)?.opacityFunction.mappingRange
+ ).toEqual([10, 90]);
+ });
+});
diff --git a/src/store/view-configs/volume-coloring.ts b/src/store/view-configs/volume-coloring.ts
index 4c2e0e4df..cf8c47954 100644
--- a/src/store/view-configs/volume-coloring.ts
+++ b/src/store/view-configs/volume-coloring.ts
@@ -5,7 +5,7 @@ import {
getOpacityFunctionFromPreset,
} from '@/src/utils/vtk-helpers';
import { DEFAULT_PRESET_BY_MODALITY, DEFAULT_PRESET } from '@/src/config';
-import { ColorTransferFunction } from '@/src/types/views';
+import type { OpacityFunction } from '@/src/types/views';
import { defineStore } from 'pinia';
import { reactive } from 'vue';
import {
@@ -16,8 +16,19 @@ import {
} from '@/src/utils/doubleKeyRecord';
import { DeepPartial, Maybe } from '@/src/types';
import { identity } from '@/src/utils';
-import { isDicomImage } from '@/src/utils/dataSelection';
import { useImageCacheStore } from '@/src/store/image-cache';
+import DicomChunkImage from '@/src/core/streaming/dicomChunkImage';
+import type { ProgressiveImage } from '@/src/core/progressiveImage';
+import {
+ getConventionalVolumePreset,
+ getDefaultVolumePreset,
+} from '@/src/utils/defaultVolumePreset';
+import {
+ ADAPTIVE_VOLUME_LIGHTING,
+ getAdaptiveVolumePreset,
+ getAdaptiveVolumePresetUnavailableReason,
+ isAdaptiveVolumePreset,
+} from '@/src/utils/adaptiveVolumePreset';
import { createViewConfigSerializer } from './common';
import { ViewConfig } from '../../io/state-file/schema';
import { VolumeColorConfig } from './types';
@@ -29,36 +40,159 @@ export const DEFAULT_SPECULAR = 0.3;
export const DEFAULT_EDGE_GRADIENT = 0.2;
export const DEFAULT_SAMPLING_DISTANCE = 0.2;
+function getVolumeMetadata(image: ProgressiveImage | undefined) {
+ return image instanceof DicomChunkImage && image.getChunks().length
+ ? (image.getDicomMetadata() ?? undefined)
+ : image?.headerMetadata;
+}
+
+function getImageModality(imageID: string) {
+ const image = useImageCacheStore().imageById[imageID];
+ const nativeModality = useDICOMStore().volumeInfo[imageID]?.Modality;
+ return {
+ nativeModality,
+ metadata: getVolumeMetadata(image),
+ fallbackPreset:
+ DEFAULT_PRESET_BY_MODALITY[nativeModality ?? ''] ?? DEFAULT_PRESET,
+ };
+}
+
function getPresetFromImageModality(imageID: string) {
- const dicomStore = useDICOMStore();
- if (isDicomImage(imageID)) {
- const volKey = imageID;
- const { Modality } = dicomStore.volumeInfo[volKey];
- if (Modality in DEFAULT_PRESET_BY_MODALITY) {
- return DEFAULT_PRESET_BY_MODALITY[Modality];
- }
- }
- return DEFAULT_PRESET;
+ const scalars = useImageCacheStore()
+ .getVtkImageData(imageID)
+ ?.getPointData()
+ .getScalars();
+ return getDefaultVolumePreset({
+ ...getImageModality(imageID),
+ numberOfComponents: scalars?.getNumberOfComponents(),
+ scalarRange: scalars?.getRange(),
+ });
}
-/**
- * Gets partial color and opacity function configs from a preset.
- * @param preset
- * @returns
- */
-function getColorAndOpacityFuncsFromPreset(preset: string) {
- const ctFunc: Partial = {
- preset,
+type ColorAndOpacity = Pick<
+ VolumeColorConfig,
+ 'transferFunction' | 'opacityFunction'
+>;
+
+function getConventionalColorAndOpacity(
+ preset: string,
+ imageRange: readonly [number, number]
+): ColorAndOpacity {
+ return {
+ transferFunction: {
+ preset,
+ mappingRange: getColorFunctionRangeFromPreset(preset) ?? [...imageRange],
+ },
+ opacityFunction: {
+ ...getOpacityFunctionFromPreset(preset),
+ mappingRange: [...imageRange],
+ },
};
+}
- const ctRange = getColorFunctionRangeFromPreset(preset);
- if (ctRange) {
- ctFunc.mappingRange = ctRange;
- }
+export function getColorAndOpacityFromPreset(
+ image: vtkImageData,
+ preset: string
+): ColorAndOpacity | null {
+ return isAdaptiveVolumePreset(preset)
+ ? getAdaptiveVolumePreset(image, preset)
+ : getConventionalColorAndOpacity(
+ preset,
+ image.getPointData().getScalars().getRange()
+ );
+}
- const opFunc = getOpacityFunctionFromPreset(preset);
+function getProvisionalScalarRange(image: vtkImageData) {
+ const range = image.getPointData().getScalars().getRange();
+ const [min, max] = range;
+ if (!range.every(Number.isFinite) || min > max) return [0, 1] as const;
+ if (min !== max) return range;
+ if (min === 0) return [0, 1] as const;
+ // Constant decoded pixels still share the volume with unloaded zero voxels.
+ return [Math.min(0, min), Math.max(0, max)] as const;
+}
- return { colorFunc: ctFunc, opacityFunc: opFunc };
+function equalMappingRanges(
+ left: readonly [number, number],
+ right: readonly [number, number]
+) {
+ const span = Math.max(
+ Math.abs(left[1] - left[0]),
+ Math.abs(right[1] - right[0])
+ );
+ // Intensity units and offsets vary; only the span sets meaningful precision.
+ const tolerance = Number.isFinite(span) ? span * 1e-10 : 0;
+ return left.every(
+ (value, index) =>
+ value === right[index] ||
+ (Number.isFinite(value) &&
+ Number.isFinite(right[index]) &&
+ Math.abs(value - right[index]) <= tolerance)
+ );
+}
+
+function equalControlValues(
+ left: Array,
+ right: Array
+) {
+ return (
+ left.length === right.length &&
+ left.every((value, index) => {
+ const other = right[index];
+ if (value === other) return true;
+ if (typeof value !== 'number' || typeof other !== 'number') return false;
+ // Slider shift/width arithmetic can introduce insignificant roundoff.
+ const tolerance = 1e-10 * Math.max(1, Math.abs(value), Math.abs(other));
+ return (
+ Number.isFinite(value) &&
+ Number.isFinite(other) &&
+ Math.abs(value - other) <= tolerance
+ );
+ })
+ );
+}
+
+function getOpacityControlValues(opacity: OpacityFunction) {
+ switch (opacity.mode) {
+ case vtkPiecewiseFunctionProxy.Mode.Points:
+ return [opacity.preset, opacity.shift, opacity.shiftAlpha];
+ case vtkPiecewiseFunctionProxy.Mode.Gaussians:
+ return opacity.gaussians.flatMap(
+ ({ position, width, height, xBias, yBias }) => [
+ position,
+ width,
+ height,
+ xBias,
+ yBias,
+ ]
+ );
+ case vtkPiecewiseFunctionProxy.Mode.Nodes:
+ return opacity.nodes.flatMap(({ x, y, midpoint, sharpness }) => [
+ x,
+ y,
+ midpoint,
+ sharpness,
+ ]);
+ }
+}
+
+function equalColorAndOpacity(left: ColorAndOpacity, right: ColorAndOpacity) {
+ return (
+ left.transferFunction.preset === right.transferFunction.preset &&
+ equalMappingRanges(
+ left.transferFunction.mappingRange,
+ right.transferFunction.mappingRange
+ ) &&
+ left.opacityFunction.mode === right.opacityFunction.mode &&
+ equalMappingRanges(
+ left.opacityFunction.mappingRange,
+ right.opacityFunction.mappingRange
+ ) &&
+ equalControlValues(
+ getOpacityControlValues(left.opacityFunction),
+ getOpacityControlValues(right.opacityFunction)
+ )
+ );
}
export const defaultVolumeColorConfig = (): VolumeColorConfig => ({
@@ -97,6 +231,65 @@ export const useVolumeColoringStore = defineStore('volumeColoring', () => {
const defaultConfigs = reactive<
Record>
>(Object.create(null));
+ // Reconcile presets selected on partial data without changing restored ranges.
+ const pendingOpacityRanges: DoubleKeyRecord = Object.create(null);
+ const provisionalColorings = new WeakMap<
+ vtkImageData,
+ { key: string; config: VolumeColorConfig }
+ >();
+ const provisionalDefaults = defaultVolumeColorConfig();
+
+ const getDefaultColorPreset = (imageID: string) =>
+ defaultConfigs[imageID]?.transferFunction?.preset ??
+ getPresetFromImageModality(imageID);
+
+ const getInitialColorAndOpacity = (imageID: string, image: vtkImageData) => {
+ const preset = getDefaultColorPreset(imageID);
+ const adaptiveUnavailable = getAdaptiveVolumePresetUnavailableReason(
+ image,
+ useImageCacheStore().imageById[imageID]?.isLoaded() ?? false
+ );
+ return (
+ (isAdaptiveVolumePreset(preset) && adaptiveUnavailable
+ ? null
+ : getColorAndOpacityFromPreset(image, preset)) ??
+ getConventionalColorAndOpacity(
+ getConventionalVolumePreset(getImageModality(imageID)),
+ image.getPointData().getScalars().getRange()
+ )
+ );
+ };
+
+ // Streaming appearances stay outside configs so completed data still selects
+ // the automatic default and only deliberate settings are saved or restored.
+ const getProvisionalColoring = (imageID: Maybe) => {
+ if (!imageID) return undefined;
+ const cache = useImageCacheStore();
+ const image = cache.imageById[imageID];
+ const imageData = cache.getVtkImageData(imageID);
+ if (!imageData || image.isLoaded()) return undefined;
+
+ const range = getProvisionalScalarRange(imageData);
+ const configuredPreset = defaultConfigs[imageID]?.transferFunction?.preset;
+ const preset =
+ configuredPreset && !isAdaptiveVolumePreset(configuredPreset)
+ ? configuredPreset
+ : getConventionalVolumePreset(getImageModality(imageID));
+ const key = `${preset}:${range}`;
+ const previous = provisionalColorings.get(imageData);
+ if (previous?.key === key) return previous.config;
+
+ const coloring = {
+ ...provisionalDefaults,
+ ...getConventionalColorAndOpacity(preset, range),
+ colorBy: {
+ arrayName: imageData.getPointData().getScalars().getName(),
+ location: 'pointData' as const,
+ },
+ };
+ provisionalColorings.set(imageData, { key, config: coloring });
+ return coloring;
+ };
const getConfig = (viewID: Maybe, dataID: Maybe) =>
getDoubleKeyRecord(configs, viewID, dataID);
@@ -108,7 +301,7 @@ export const useVolumeColoringStore = defineStore('volumeColoring', () => {
) => {
const config = {
...defaultVolumeColorConfig(),
- ...getConfig(viewID, dataID),
+ ...(getConfig(viewID, dataID) ?? getProvisionalColoring(dataID)),
...patch,
};
@@ -124,7 +317,10 @@ export const useVolumeColoringStore = defineStore('volumeColoring', () => {
dataID: string,
update: Partial
) => {
- const config = getConfig(viewID, dataID) ?? defaultVolumeColorConfig();
+ const config =
+ getConfig(viewID, dataID) ??
+ getProvisionalColoring(dataID) ??
+ defaultVolumeColorConfig();
const updatedConfig = transform({
...config[key],
...update,
@@ -143,18 +339,48 @@ export const useVolumeColoringStore = defineStore('volumeColoring', () => {
const image = imageCacheStore.getVtkImageData(imageID);
if (!image) throw new Error('Invalid imageID');
- const imageDataRange = image.getPointData().getScalars().getRange();
- const { colorFunc, opacityFunc } =
- getColorAndOpacityFuncsFromPreset(preset);
- colorFunc.mappingRange ||= imageDataRange;
+ const loaded = imageCacheStore.imageById[imageID]?.isLoaded() ?? false;
+ if (
+ isAdaptiveVolumePreset(preset) &&
+ getAdaptiveVolumePresetUnavailableReason(image, loaded)
+ )
+ return;
+ const coloring = getColorAndOpacityFromPreset(image, preset);
+ if (!coloring) return;
- const completeOpacityFunc = {
- ...opacityFunc,
- mappingRange: imageDataRange,
- };
+ pendingOpacityRanges[viewID] ??= {};
+ pendingOpacityRanges[viewID][imageID] = !loaded;
+ updateConfig(viewID, imageID, coloring);
+ };
+
+ const finalizePresetRange = (viewID: string, imageID: string) => {
+ if (!pendingOpacityRanges[viewID]?.[imageID]) return;
+ const cache = useImageCacheStore();
+ if (!cache.imageById[imageID]?.isLoaded()) return;
+ delete pendingOpacityRanges[viewID][imageID];
+ const image = cache.getVtkImageData(imageID);
+ const current = getConfig(viewID, imageID);
+ if (!image || !current) return;
+ updateOpacityFunction(viewID, imageID, {
+ mappingRange: [...image.getPointData().getScalars().getRange()],
+ });
+ };
- updateColorTransferFunction(viewID, imageID, colorFunc);
- updateOpacityFunction(viewID, imageID, completeOpacityFunc);
+ const isColorAndOpacityDefault = (
+ viewID: Maybe,
+ imageID: Maybe
+ ) => {
+ const current = getConfig(viewID, imageID);
+ if (!current || !imageID) return false;
+ const imageCacheStore = useImageCacheStore();
+ if (!imageCacheStore.imageById[imageID]?.isLoaded()) return false;
+ const image = imageCacheStore.getVtkImageData(imageID);
+ if (!image) return false;
+ const baseline = getColorAndOpacityFromPreset(
+ image,
+ current.transferFunction.preset
+ );
+ return baseline !== null && equalColorAndOpacity(current, baseline);
};
const resetToDefaultColoring = (
@@ -165,15 +391,28 @@ export const useVolumeColoringStore = defineStore('volumeColoring', () => {
const defaults = defaultConfigs[dataID];
const scalars = image.getPointData().getScalars();
- updateColorBy(viewID, dataID, {
- arrayName: defaults?.colorBy?.arrayName ?? scalars.getName(),
- location: defaults?.colorBy?.location ?? 'pointData',
+ const coloring = getInitialColorAndOpacity(dataID, image);
+ const cvr = {
+ ...defaultVolumeColorConfig().cvr,
+ ...(isAdaptiveVolumePreset(coloring.transferFunction.preset)
+ ? ADAPTIVE_VOLUME_LIGHTING
+ : {}),
+ };
+ updateConfig(viewID, dataID, {
+ ...coloring,
+ colorBy: {
+ arrayName: defaults?.colorBy?.arrayName ?? scalars.getName(),
+ location: defaults?.colorBy?.location ?? 'pointData',
+ },
+ cvr,
});
- setColorPreset(
- viewID,
- dataID,
- defaults?.transferFunction?.preset ?? getPresetFromImageModality(dataID)
- );
+ };
+
+ const resetColorAndOpacity = (viewID: string, imageID: string) => {
+ const preset = getConfig(viewID, imageID)?.transferFunction.preset;
+ if (!preset) return;
+ if (!useImageCacheStore().imageById[imageID]?.isLoaded()) return;
+ setColorPreset(viewID, imageID, preset);
};
/**
@@ -190,13 +429,16 @@ export const useVolumeColoringStore = defineStore('volumeColoring', () => {
const removeView = (viewID: string) => {
delete configs[viewID];
+ delete pendingOpacityRanges[viewID];
};
const removeData = (dataID: string, viewID?: string) => {
if (viewID) {
delete configs[viewID]?.[dataID];
+ delete pendingOpacityRanges[viewID]?.[dataID];
} else {
deleteSecondKey(configs, dataID);
+ deleteSecondKey(pendingOpacityRanges, dataID);
}
};
@@ -205,6 +447,7 @@ export const useVolumeColoringStore = defineStore('volumeColoring', () => {
const deserialize = (viewID: string, config: Record) => {
Object.entries(config).forEach(([dataID, viewConfig]) => {
if (viewConfig.volumeColorConfig) {
+ delete pendingOpacityRanges[viewID]?.[dataID];
updateConfig(viewID, dataID, viewConfig.volumeColorConfig);
}
});
@@ -219,8 +462,12 @@ export const useVolumeColoringStore = defineStore('volumeColoring', () => {
updateOpacityFunction,
updateCVRParameters,
resetToDefaultColoring,
+ getProvisionalColoring,
+ resetColorAndOpacity,
+ isColorAndOpacityDefault,
setDefaults,
setColorPreset,
+ finalizePresetRange,
removeView,
removeData,
serialize,
diff --git a/src/utils/__tests__/adaptiveVolumePreset.spec.ts b/src/utils/__tests__/adaptiveVolumePreset.spec.ts
new file mode 100644
index 000000000..402ce4bcf
--- /dev/null
+++ b/src/utils/__tests__/adaptiveVolumePreset.spec.ts
@@ -0,0 +1,155 @@
+import { describe, expect, it, onTestFinished } from 'vitest';
+import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
+import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
+import vtkPiecewiseFunction from '@kitware/vtk.js/Common/DataModel/PiecewiseFunction';
+import vtkPiecewiseGaussianWidget from '@kitware/vtk.js/Interaction/Widgets/PiecewiseGaussianWidget';
+import vtkPiecewiseFunctionProxy from '@kitware/vtk.js/Proxy/Core/PiecewiseFunctionProxy';
+import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction';
+import vtkColorMaps from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction/ColorMaps';
+import {
+ ADAPTIVE_VOLUME_PRESETS,
+ getAdaptiveVolumePreset,
+} from '../adaptiveVolumePreset';
+
+function makeImage(values: Float32Array, size: number) {
+ const image = vtkImageData.newInstance();
+ const scalars = vtkDataArray.newInstance({ values, numberOfComponents: 1 });
+ image.setDimensions(size, size, size);
+ image.getPointData().setScalars(scalars);
+ onTestFinished(() => {
+ image.delete();
+ scalars.delete();
+ });
+ return image;
+}
+
+function makeSparseImage(size: number, background: number, start: number) {
+ const values = new Float32Array(size ** 3).fill(background);
+ const width = size === 256 ? 5 : 3;
+ for (let z = start; z < start + width; z += 1) {
+ for (let y = start; y < start + width; y += 1) {
+ for (let x = start; x < start + width; x += 1) {
+ values[x + size * (y + size * z)] = background + 1000;
+ }
+ }
+ }
+ return makeImage(values, size);
+}
+
+function transferFunctions(image: vtkImageData, preset: string) {
+ const config = getAdaptiveVolumePreset(image, preset);
+ if (
+ !config ||
+ config.opacityFunction.mode !== vtkPiecewiseFunctionProxy.Mode.Gaussians
+ ) {
+ throw new Error('Expected an adaptive Gaussian opacity function');
+ }
+ const opacity = vtkPiecewiseFunction.newInstance();
+ vtkPiecewiseGaussianWidget.applyGaussianToPiecewiseFunction(
+ config.opacityFunction.gaussians,
+ 256,
+ config.opacityFunction.mappingRange,
+ opacity
+ );
+ const color = vtkColorTransferFunction.newInstance();
+ color.applyColorMap(vtkColorMaps.getPresetByName('Grayscale'));
+ color.setMappingRange(...config.transferFunction.mappingRange);
+ onTestFinished(() => {
+ opacity.delete();
+ color.delete();
+ });
+ return {
+ opacity: (value: number) => opacity.getValue(value),
+ brightness: (value: number) => {
+ const rgb: number[] = [];
+ color.getColor(value, rgb);
+ return rgb[0];
+ },
+ };
+}
+
+describe('adaptive volume transfer functions', () => {
+ it.each([120, 121])(
+ 'keeps sparse foreground visible at voxel offset %i',
+ (start) => {
+ const image = makeSparseImage(256, 0, start);
+ ADAPTIVE_VOLUME_PRESETS.forEach((preset) => {
+ const transfer = transferFunctions(image, preset);
+ expect(transfer.opacity(0)).toBe(0);
+ expect(transfer.opacity(1000)).toBeGreaterThan(0);
+ expect(transfer.brightness(0)).toBe(0);
+ expect(transfer.brightness(1000)).toBe(1);
+ });
+ }
+ );
+
+ it.each([0, 100, -1000])(
+ 'separates a rare signal from background %i',
+ (background) => {
+ const image = makeSparseImage(32, background, 15);
+ ADAPTIVE_VOLUME_PRESETS.forEach((preset) => {
+ const transfer = transferFunctions(image, preset);
+ expect(transfer.opacity(background)).toBe(0);
+ expect(transfer.opacity(background + 1000)).toBeGreaterThan(0);
+ expect(transfer.brightness(background)).toBe(0);
+ expect(transfer.brightness(background + 1000)).toBe(1);
+ });
+ }
+ );
+
+ it.each([0, 100, -1000])(
+ 'keeps the constant value %i transparent only when it is zero',
+ (value) => {
+ const image = makeImage(new Float32Array(32 ** 3).fill(value), 32);
+ ADAPTIVE_VOLUME_PRESETS.forEach((preset) => {
+ const transfer = transferFunctions(image, preset);
+ if (value === 0) expect(transfer.opacity(value)).toBe(0);
+ else expect(transfer.opacity(value)).toBeGreaterThan(0);
+ });
+ }
+ );
+
+ it('trims a high outlier within a rare upper tail', () => {
+ const image = makeSparseImage(32, 100, 15);
+ const scalars = image.getPointData().getScalars();
+ scalars.getData()[0] = 1e6;
+ scalars.modified();
+ ADAPTIVE_VOLUME_PRESETS.forEach((preset) => {
+ const transfer = transferFunctions(image, preset);
+ expect(transfer.opacity(100)).toBe(0);
+ expect(transfer.opacity(1100)).toBeGreaterThan(0);
+ expect(transfer.brightness(1100)).toBe(1);
+ });
+ });
+
+ it('keeps ordinary multilevel contrast despite a high outlier', () => {
+ const values = Float32Array.from(
+ { length: 32 ** 3 },
+ (_, index) => index % 1000
+ );
+ values[0] = 1e6;
+ const image = makeImage(values, 32);
+ ADAPTIVE_VOLUME_PRESETS.forEach((preset) => {
+ const transfer = transferFunctions(image, preset);
+ expect(transfer.opacity(0)).toBe(0);
+ expect(transfer.opacity(999)).toBeGreaterThan(0);
+ expect(transfer.brightness(750)).toBeGreaterThan(0.1);
+ expect(transfer.brightness(999)).toBe(1);
+ });
+ });
+
+ it('refits changed scalars after an initially transparent image', () => {
+ const image = makeImage(new Float32Array(32 ** 3), 32);
+ ADAPTIVE_VOLUME_PRESETS.forEach((preset) => {
+ expect(transferFunctions(image, preset).opacity(1000)).toBe(0);
+ });
+ const scalars = image.getPointData().getScalars();
+ scalars.getData()[0] = 1000;
+ scalars.modified();
+ ADAPTIVE_VOLUME_PRESETS.forEach((preset) => {
+ const transfer = transferFunctions(image, preset);
+ expect(transfer.opacity(0)).toBe(0);
+ expect(transfer.opacity(1000)).toBeGreaterThan(0);
+ });
+ });
+});
diff --git a/src/utils/__tests__/histogram.spec.ts b/src/utils/__tests__/histogram.spec.ts
new file mode 100644
index 000000000..f0c75da82
--- /dev/null
+++ b/src/utils/__tests__/histogram.spec.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest';
+import { piecewiseHistogram } from '@/src/utils/histogram';
+
+describe('opacity editor histograms', () => {
+ it('keeps endpoint-centered editor bins and scales below the largest peak', () => {
+ const ordinary = [0, 1, 1, 2, 2, 2, 3, 3, 3, 3, 4];
+ expect(Array.from(piecewiseHistogram(ordinary, [0, 4], 5))).toAlmostEqual([
+ 1 / 3,
+ 2 / 3,
+ 1,
+ 4 / 3,
+ 1 / 3,
+ ]);
+ const fitted = ordinary.map((value) => Math.max(2, value));
+ expect(Array.from(piecewiseHistogram(fitted, [2, 4], 5))).toAlmostEqual([
+ 1.5, 0, 1, 0, 0.25,
+ ]);
+ });
+});
diff --git a/src/utils/adaptiveVolumePreset.ts b/src/utils/adaptiveVolumePreset.ts
new file mode 100644
index 000000000..6b1c2f21b
--- /dev/null
+++ b/src/utils/adaptiveVolumePreset.ts
@@ -0,0 +1,206 @@
+import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
+import vtkPiecewiseFunctionProxy from '@kitware/vtk.js/Proxy/Core/PiecewiseFunctionProxy';
+import type { VolumeColorConfig } from '@/src/store/view-configs/types';
+
+import {
+ ADAPTIVE_VOLUME_PROFILES,
+ isAdaptiveVolumePreset,
+} from './adaptiveVolumePresets';
+
+export {
+ ADAPTIVE_VOLUME_PRESETS,
+ DEFAULT_ADAPTIVE_VOLUME_PRESET,
+ getAdaptiveVolumePresetDescription,
+ isAdaptiveVolumePreset,
+} from './adaptiveVolumePresets';
+
+export const ADAPTIVE_VOLUME_LIGHTING = {
+ enabled: true,
+ ambient: 0.6,
+ diffuse: 0.4,
+ specular: 0.1,
+ useLocalAmbientOcclusion: false,
+};
+
+export function getAdaptiveVolumePresetUnavailableReason(
+ image: vtkImageData | null | undefined,
+ isLoaded: boolean
+) {
+ if (!image) return 'Load a grayscale volume to use adaptive presets';
+ if (!isLoaded) return 'Wait for the complete volume to load';
+ const scalars = image.getPointData().getScalars();
+ if (!scalars || scalars.getNumberOfComponents() !== 1)
+ return 'Adaptive presets require a grayscale volume';
+ if (!scalars.getRange().every(Number.isFinite))
+ return 'Adaptive presets require finite intensities';
+ return '';
+}
+
+type AdaptiveColoring = Pick<
+ VolumeColorConfig,
+ 'transferFunction' | 'opacityFunction'
+>;
+
+const sampleBudget = 131072;
+const summaries = new WeakMap<
+ vtkImageData,
+ {
+ revision: string;
+ samples: number[];
+ colorRange: [number, number];
+ low: number;
+ high: number;
+ meanWeight: number;
+ maxDimension: number;
+ }
+>();
+
+function sampleScalars(image: vtkImageData) {
+ const values = image.getPointData().getScalars()?.getData();
+ if (!values?.length) return [];
+ const count = Math.min(values.length, sampleBudget);
+ return Array.from({ length: count }, (_, index) => {
+ // Stratification covers the whole volume; deterministic jitter avoids aliasing
+ // repeated voxel patterns when the sampling stride is an integer.
+ let hash = Math.imul(index + 1, 0x9e3779b1);
+ hash = Math.imul(hash ^ (hash >>> 16), 0x85ebca6b);
+ const offset = ((hash ^ (hash >>> 13)) >>> 0) / 0x100000000;
+ return values[Math.floor(((index + offset) * values.length) / count)];
+ }).filter(Number.isFinite);
+}
+
+export function getAdaptiveVolumeHistogram(
+ image: vtkImageData,
+ range: [number, number]
+) {
+ return [
+ ...range,
+ ...(summarize(image)?.samples ?? []).filter(
+ (value) => value >= range[0] && value <= range[1]
+ ),
+ ];
+}
+
+function meanIntensityWeight(samples: number[], low: number, high: number) {
+ const span = high - low;
+ return (
+ samples.reduce((total, value) => {
+ if (value <= low) return total;
+ const t = Math.min(1, (value - low) / span);
+ return total + 1 - (1 - t) ** 2;
+ }, 0) / Math.max(1, samples.length)
+ );
+}
+
+function fitSparseIntensities(samples: number[], range: [number, number]) {
+ const [low, maximum] = range;
+ // Fit the sparse upper tail robustly; use the known range if sampling missed it.
+ const upperTail = samples
+ .filter((value) => value > low)
+ .sort((a, b) => a - b);
+ const high =
+ upperTail[Math.floor(0.995 * (upperTail.length - 1))] ??
+ (maximum > low ? maximum : low + 1);
+ const colorRange: [number, number] = [low, high];
+ return {
+ colorRange,
+ low,
+ high,
+ meanWeight:
+ maximum > low
+ ? Math.max(
+ meanIntensityWeight(samples, low, high),
+ 1 / Math.max(1, samples.length)
+ )
+ : 0,
+ };
+}
+
+function fitIntensities(samples: number[], range: [number, number]) {
+ // Exclude the zero peak from fitting nonnegative intensity volumes.
+ const foreground = samples
+ .filter((value) => range[0] < 0 || value !== 0)
+ .sort((a, b) => a - b);
+ if (!foreground.length) return fitSparseIntensities(samples, range);
+ const percentile = (fraction: number) =>
+ foreground[Math.floor(fraction * (foreground.length - 1))];
+ const high = percentile(0.995);
+ if (high === range[0] && range[1] > range[0]) {
+ return fitSparseIntensities(samples, range);
+ }
+ const lower = percentile(0.85);
+ // Binary foreground needs a ramp across its background-to-signal interval
+ // so linearly interpolated samples remain visible.
+ const low =
+ lower < high
+ ? lower
+ : range[0] < high
+ ? range[0]
+ : high - (high === 0 ? 1 : Math.abs(high) * 0.01);
+ const meanWeight = meanIntensityWeight(samples, low, high);
+ // Use a wider color ramp than the opacity ramp so visible tissue is bright
+ // while low-intensity background remains transparent.
+ const colorLow = percentile(0.5);
+ const colorHigh = percentile(0.98);
+ const colorRange: [number, number] =
+ colorHigh > colorLow ? [colorLow, colorHigh] : [low, high];
+ return { colorRange, low, high, meanWeight };
+}
+
+function summarize(image: vtkImageData) {
+ const scalars = image.getPointData().getScalars();
+ if (!scalars || scalars.getNumberOfComponents() !== 1) return null;
+ const revision = `${image.getMTime()}:${scalars.getMTime()}`;
+ const previous = summaries.get(image);
+ if (previous?.revision === revision) return previous;
+
+ const values = scalars.getData();
+ if (!values.length) return null;
+ const [minimum, maximum] = scalars.getRange();
+ if (!Number.isFinite(minimum) || !Number.isFinite(maximum)) return null;
+ const range: [number, number] = [minimum, maximum];
+ const samples = sampleScalars(image);
+ const summary = {
+ revision,
+ samples,
+ maxDimension: Math.max(...image.getDimensions()),
+ ...fitIntensities(samples, range),
+ };
+ summaries.set(image, summary);
+ return summary;
+}
+
+export function getAdaptiveVolumePreset(
+ image: vtkImageData,
+ preset: string
+): AdaptiveColoring | null {
+ if (!isAdaptiveVolumePreset(preset)) return null;
+ const summary = summarize(image);
+ if (!summary) return null;
+ const { colorRange, low, high, meanWeight, maxDimension } = summary;
+ const profile = ADAPTIVE_VOLUME_PROFILES[preset];
+ // Opacity is defined over half the volume diagonal / max dimension. Fit a
+ // representative third-diagonal ray to 40%, 75%, or 95% integrated opacity.
+ const unitsPerRay = (2 * maxDimension) / 3;
+ const height =
+ meanWeight > 0
+ ? Math.min(
+ profile.maximumOpacity,
+ -Math.expm1(-profile.opticalDepth / (unitsPerRay * meanWeight))
+ )
+ : 0;
+ return {
+ transferFunction: {
+ preset,
+ mappingRange: [...colorRange],
+ },
+ opacityFunction: {
+ mode: vtkPiecewiseFunctionProxy.Mode.Gaussians,
+ mappingRange: [low, high],
+ gaussians:
+ height > 0
+ ? [{ position: 1, width: 1, height, xBias: 0, yBias: 1 }]
+ : [],
+ },
+ };
+}
diff --git a/src/utils/adaptiveVolumePresets.ts b/src/utils/adaptiveVolumePresets.ts
new file mode 100644
index 000000000..d6b78e867
--- /dev/null
+++ b/src/utils/adaptiveVolumePresets.ts
@@ -0,0 +1,39 @@
+export const ADAPTIVE_VOLUME_PRESETS = [
+ 'Adaptive-Transparent',
+ 'Adaptive-Balanced',
+ 'Adaptive-Surface',
+] as const;
+
+export const DEFAULT_ADAPTIVE_VOLUME_PRESET = 'Adaptive-Balanced';
+
+type AdaptiveVolumePreset = (typeof ADAPTIVE_VOLUME_PRESETS)[number];
+
+export const ADAPTIVE_VOLUME_PROFILES = {
+ 'Adaptive-Transparent': {
+ opticalDepth: -Math.log(0.6),
+ maximumOpacity: 0.12,
+ description: 'Lower opacity to reveal overlapping structures',
+ },
+ 'Adaptive-Balanced': {
+ opticalDepth: -Math.log(0.25),
+ maximumOpacity: 0.25,
+ description: 'A general starting view fitted to the data',
+ },
+ 'Adaptive-Surface': {
+ opticalDepth: -Math.log(0.05),
+ maximumOpacity: 0.6,
+ description: 'Higher opacity to emphasize front-facing bright structures',
+ },
+};
+
+export function isAdaptiveVolumePreset(
+ preset: string
+): preset is AdaptiveVolumePreset {
+ return ADAPTIVE_VOLUME_PRESETS.some((name) => name === preset);
+}
+
+export function getAdaptiveVolumePresetDescription(preset: string) {
+ return isAdaptiveVolumePreset(preset)
+ ? ADAPTIVE_VOLUME_PROFILES[preset].description
+ : '';
+}
diff --git a/src/utils/createHistogramWorker.ts b/src/utils/createHistogramWorker.ts
new file mode 100644
index 000000000..93df17693
--- /dev/null
+++ b/src/utils/createHistogramWorker.ts
@@ -0,0 +1,20 @@
+import * as Comlink from 'comlink';
+import type { HistogramWorker } from '@/src/utils/histogram.worker';
+
+export function createHistogramWorker() {
+ const worker = new Worker(
+ new URL('@/src/utils/histogram.worker.ts', import.meta.url),
+ { type: 'module' }
+ );
+ const remote = Comlink.wrap(worker);
+ let stopped = false;
+ return {
+ remote,
+ stop() {
+ if (stopped) return;
+ stopped = true;
+ remote[Comlink.releaseProxy]();
+ worker.terminate();
+ },
+ };
+}
diff --git a/src/utils/dataArrayRange.ts b/src/utils/dataArrayRange.ts
new file mode 100644
index 000000000..774f47c34
--- /dev/null
+++ b/src/utils/dataArrayRange.ts
@@ -0,0 +1,12 @@
+import type vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
+
+export function getAllComponentRange(scalars: vtkDataArray): [number, number] {
+ const ranges = Array.from(
+ { length: scalars.getNumberOfComponents() },
+ (_, component) => scalars.getRange(component)
+ );
+ return [
+ Math.min(...ranges.map(([minimum]) => minimum)),
+ Math.max(...ranges.map(([, maximum]) => maximum)),
+ ];
+}
diff --git a/src/utils/defaultVolumePreset.ts b/src/utils/defaultVolumePreset.ts
new file mode 100644
index 000000000..b183060f5
--- /dev/null
+++ b/src/utils/defaultVolumePreset.ts
@@ -0,0 +1,96 @@
+import medicalPresets from '@/src/vtk/MedicalColorPresets.json';
+import { DEFAULT_ADAPTIVE_VOLUME_PRESET } from '@/src/utils/adaptiveVolumePreset';
+import { DEFAULT_PRESET_BY_MODALITY } from '@/src/config';
+
+type DefaultVolumePresetInput = {
+ nativeModality?: string;
+ metadata?: Iterable;
+ numberOfComponents?: number;
+ scalarRange?: readonly [number, number];
+ fallbackPreset: string;
+};
+
+function getDeclaredModality({
+ nativeModality,
+ metadata = [],
+}: Pick) {
+ const fields = new Map(
+ Array.from(metadata, ([key, value]) => [
+ key.trim().toLowerCase(),
+ value.trim().toUpperCase(),
+ ])
+ );
+ const modality =
+ nativeModality?.trim().toUpperCase() ||
+ fields.get('0008|0060') ||
+ fields.get('modality');
+ if (modality) return modality;
+
+ // Scalar units identify HU; spatial units and intensity ranges do not.
+ const units =
+ fields.get('0028|1054') ||
+ fields.get('rescaletype') ||
+ fields.get('intensityunits');
+ return units && ['HU', 'HOUNSFIELD UNIT', 'HOUNSFIELD UNITS'].includes(units)
+ ? 'CT'
+ : '';
+}
+
+function opacityAt(points: number[], value: number) {
+ if (value <= points[0]) return points[1];
+ for (let index = 2; index < points.length; index += 2) {
+ if (value <= points[index]) {
+ const weight =
+ (value - points[index - 2]) / (points[index] - points[index - 2]);
+ return points[index - 1] * (1 - weight) + points[index + 1] * weight;
+ }
+ }
+ return points[points.length - 1];
+}
+
+function hasVisibleOpacity(points: number[], range: readonly [number, number]) {
+ if (range.some((value) => opacityAt(points, value) > 0)) return true;
+ return points.some(
+ (value, index) =>
+ index % 2 === 0 &&
+ value >= range[0] &&
+ value <= range[1] &&
+ points[index + 1] > 0
+ );
+}
+
+export function getConventionalVolumePreset(
+ input: Pick<
+ DefaultVolumePresetInput,
+ 'nativeModality' | 'metadata' | 'fallbackPreset'
+ >
+) {
+ return (
+ DEFAULT_PRESET_BY_MODALITY[getDeclaredModality(input)] ??
+ input.fallbackPreset
+ );
+}
+
+export function getDefaultVolumePreset(input: DefaultVolumePresetInput) {
+ const { numberOfComponents, scalarRange, fallbackPreset } = input;
+ if (
+ numberOfComponents !== 1 ||
+ !scalarRange ||
+ !scalarRange.every(Number.isFinite) ||
+ scalarRange[0] > scalarRange[1]
+ )
+ return fallbackPreset;
+
+ const presetName = DEFAULT_PRESET_BY_MODALITY[getDeclaredModality(input)];
+ if (!presetName) return DEFAULT_ADAPTIVE_VOLUME_PRESET;
+
+ const preset = medicalPresets.find(({ Name }) => Name === presetName);
+ // Absolute presets can be fully transparent for normalized or rescaled data.
+ if (
+ preset?.AbsoluteRange &&
+ preset.OpacityPoints &&
+ !hasVisibleOpacity(preset.OpacityPoints, scalarRange)
+ )
+ return DEFAULT_ADAPTIVE_VOLUME_PRESET;
+ return presetName;
+}
diff --git a/src/utils/histogram.ts b/src/utils/histogram.ts
index 5fe7b752a..481a3bcfc 100644
--- a/src/utils/histogram.ts
+++ b/src/utils/histogram.ts
@@ -12,3 +12,23 @@ export function histogram(
data.forEach((value) => hist[Math.floor((value - min) / width)]++);
return hist;
}
+
+/** The opacity editor places both scalar endpoints at bin centers. */
+export function piecewiseHistogram(
+ data: ArrayLike,
+ [minimum, maximum]: readonly number[],
+ numberOfBins: number
+) {
+ const counts = new Array(numberOfBins).fill(0);
+ const span = maximum - minimum;
+ for (let index = 0; index < data.length; index += 1) {
+ const bin = Math.floor(
+ ((numberOfBins - 1) * (Number(data[index]) - minimum)) / span
+ );
+ if (bin >= 0 && bin < numberOfBins) counts[bin] += 1;
+ }
+ // Match the widget's scaling: the largest bin does not set chart height.
+ const sorted = Array.from(counts).sort((left, right) => left - right);
+ const scale = sorted[sorted.length - 2];
+ return Float32Array.from(counts, (count) => count / scale);
+}
diff --git a/src/utils/histogram.worker.ts b/src/utils/histogram.worker.ts
index 292a9c4f6..6a5d7c145 100644
--- a/src/utils/histogram.worker.ts
+++ b/src/utils/histogram.worker.ts
@@ -1,8 +1,9 @@
import * as Comlink from 'comlink';
-import { histogram } from '@/src/utils/histogram';
+import { histogram, piecewiseHistogram } from '@/src/utils/histogram';
export interface HistogramWorker {
histogram: typeof histogram;
+ piecewiseHistogram: typeof piecewiseHistogram;
}
-Comlink.expose({ histogram });
+Comlink.expose({ histogram, piecewiseHistogram });
diff --git a/src/utils/volumeProperties.ts b/src/utils/volumeProperties.ts
index 324d56afd..c8abe3d16 100644
--- a/src/utils/volumeProperties.ts
+++ b/src/utils/volumeProperties.ts
@@ -135,9 +135,12 @@ export function setCinematicVolumeShading({
);
property.setShade(true);
- property.setUseGradientOpacity(component, !enabled);
- property.setGradientOpacityMinimumValue(component, 0.0);
const dataRange = image.getPointData().getScalars().getRange();
+ property.setUseGradientOpacity(
+ component,
+ !enabled && dataRange[1] > dataRange[0]
+ );
+ property.setGradientOpacityMinimumValue(component, 0.0);
property.setGradientOpacityMaximumValue(
component,
(dataRange[1] - dataRange[0]) * 0.01
diff --git a/src/vtk/ColorMaps.ts b/src/vtk/ColorMaps.ts
index c46777464..e42b89b99 100644
--- a/src/vtk/ColorMaps.ts
+++ b/src/vtk/ColorMaps.ts
@@ -1,5 +1,6 @@
import vtkColorMaps from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction/ColorMaps';
import { ColorMapPreset } from '@/src/types/color-map-preset';
+import { ADAPTIVE_VOLUME_PRESETS } from '../utils/adaptiveVolumePresets';
import MedicalPresets from './MedicalColorPresets.json';
import DicomPresets from './DicomColorPresets.json';
@@ -16,8 +17,14 @@ DicomPresets.forEach((preset) =>
vtkColorMaps.addPreset(preset as ColorMapPreset)
);
+ADAPTIVE_VOLUME_PRESETS.forEach((name) => {
+ const preset = vtkColorMaps.getPresetByName('Grayscale');
+ if (preset) vtkColorMaps.addPreset({ ...preset, Name: name });
+});
+
/* prettier-ignore */
const GroupedPresets = [
+ { group: 'Adaptive', presets: [...ADAPTIVE_VOLUME_PRESETS] },
{
group: 'CT',
presets: [
diff --git a/src/vtk/PiecewiseWidget/index.js b/src/vtk/PiecewiseWidget/index.js
index 88d513eff..1e73a515c 100644
--- a/src/vtk/PiecewiseWidget/index.js
+++ b/src/vtk/PiecewiseWidget/index.js
@@ -77,6 +77,12 @@ function vtkPiecewiseWidget(publicAPI, model) {
model.opacityPointShift = 0;
model.opacityValueShift = 0;
+ publicAPI.setHistogram = (histogram, dataRange) => {
+ model.histogram = histogram;
+ model.dataRange = dataRange;
+ publicAPI.modified();
+ };
+
publicAPI.setGaussiansMode = () => {
model.pwMode = Mode.Gaussians;
};
diff --git a/tests/fixtures/label-outline/server.mjs b/tests/fixtures/label-outline/server.mjs
index 8435bf9f9..f0337f239 100644
--- a/tests/fixtures/label-outline/server.mjs
+++ b/tests/fixtures/label-outline/server.mjs
@@ -1,26 +1,5 @@
-import { mkdtemp, rm } from 'node:fs/promises';
-import { tmpdir } from 'node:os';
-import { resolve } from 'node:path';
-import { fileURLToPath } from 'node:url';
-import { createServer } from 'vite';
+import { startVtkFixture } from '../vtkFixtureServer.mjs';
-// Serve the isolated WebGL scene without the application's HTML entry plugin.
-export async function startOutlineFixture() {
- const cacheDir = await mkdtemp(resolve(tmpdir(), 'label-outline-'));
- const root = fileURLToPath(new URL('../../../', import.meta.url));
- const server = await createServer({
- configFile: false,
- root: fileURLToPath(new URL('.', import.meta.url)),
- cacheDir,
- resolve: { alias: { '@': root } },
- server: { host: '127.0.0.1', fs: { allow: [root] } },
- });
- await server.listen();
- return {
- url: server.resolvedUrls.local[0],
- async close() {
- await server.close();
- await rm(cacheDir, { recursive: true, force: true });
- },
- };
+export function startOutlineFixture() {
+ return startVtkFixture(new URL('.', import.meta.url));
}
diff --git a/tests/fixtures/volume-texture/index.html b/tests/fixtures/volume-texture/index.html
new file mode 100644
index 000000000..3535002dd
--- /dev/null
+++ b/tests/fixtures/volume-texture/index.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/fixtures/volume-texture/scene.ts b/tests/fixtures/volume-texture/scene.ts
new file mode 100644
index 000000000..b1fca17b6
--- /dev/null
+++ b/tests/fixtures/volume-texture/scene.ts
@@ -0,0 +1,96 @@
+import '@kitware/vtk.js/Rendering/Profiles/Volume';
+import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
+import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
+import vtkVolume from '@kitware/vtk.js/Rendering/Core/Volume';
+import vtkVolumeMapper from '@kitware/vtk.js/Rendering/Core/VolumeMapper';
+import vtkRenderWindow from '@kitware/vtk.js/Rendering/Core/RenderWindow';
+import vtkRenderWindowInteractor from '@kitware/vtk.js/Rendering/Core/RenderWindowInteractor';
+import vtkRenderer from '@kitware/vtk.js/Rendering/Core/Renderer';
+import vtkOpenGLRenderWindow from '@kitware/vtk.js/Rendering/OpenGL/RenderWindow';
+import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction';
+import vtkPiecewiseFunction from '@kitware/vtk.js/Common/DataModel/PiecewiseFunction';
+
+const size = 200;
+const renderer = vtkRenderer.newInstance({ background: [0, 0, 0] });
+const renderWindow = vtkRenderWindow.newInstance();
+const view = vtkOpenGLRenderWindow.newInstance();
+renderWindow.addRenderer(renderer);
+renderWindow.addView(view);
+view.setContainer(document.querySelector('#view')!);
+view.setSize(size, size);
+const interactor = vtkRenderWindowInteractor.newInstance();
+interactor.setView(view);
+interactor.enable();
+
+const image = vtkImageData.newInstance();
+image.setDimensions(8, 8, 8);
+const values = new Uint8Array(8 * 8 * 8).fill(255);
+const scalars = vtkDataArray.newInstance({
+ name: 'Volume',
+ numberOfComponents: 1,
+ values,
+});
+image.getPointData().setScalars(scalars);
+const mapper = vtkVolumeMapper.newInstance();
+mapper.setInputData(image);
+mapper.setAutoAdjustSampleDistances(false);
+mapper.setSampleDistance(0.5);
+const actor = vtkVolume.newInstance();
+actor.setMapper(mapper);
+const property = actor.getProperty();
+property.setInterpolationTypeToNearest();
+property.setShade(false);
+property.setScalarOpacityUnitDistance(0, 1);
+const colors = vtkColorTransferFunction.newInstance();
+colors.addRGBPoint(0, 0, 0, 0);
+colors.addRGBPoint(128, 0, 0, 1);
+colors.addRGBPoint(255, 1, 0, 0);
+const opacity = vtkPiecewiseFunction.newInstance();
+opacity.addPoint(0, 0);
+opacity.addPoint(128, 1);
+opacity.addPoint(255, 1);
+property.setRGBTransferFunction(0, colors);
+property.setScalarOpacity(0, opacity);
+renderer.addVolume(actor);
+const camera = renderer.getActiveCamera();
+camera.setParallelProjection(true);
+camera.setPosition(3.5, 3.5, 20);
+camera.setFocalPoint(3.5, 3.5, 3.5);
+camera.setViewUp(0, 1, 0);
+camera.setParallelScale(5);
+renderer.resetCameraClippingRange();
+
+function render() {
+ const status = document.querySelector('#render-status')!;
+ try {
+ renderWindow.render();
+ const gl = view.get3DContext({});
+ if (!gl) throw new Error('WebGL is required for volume texture rendering');
+ const pixels = new Uint8Array(size * size * 4);
+ gl.readPixels(0, 0, size, size, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
+ const pixel = (x: number, y: number) =>
+ Array.from(pixels.subarray((y * size + x) * 4, (y * size + x) * 4 + 4));
+ document.querySelector('#render-pixels')!.textContent = JSON.stringify({
+ center: pixel(100, 100),
+ edge: pixel(50, 100),
+ outside: pixel(10, 10),
+ });
+ status.textContent = 'Rendered';
+ } catch (error) {
+ status.textContent = String(error);
+ throw error;
+ }
+}
+
+document.querySelector('#update-volume')!.addEventListener('click', () => {
+ for (let z = 0; z < 8; z++)
+ for (let y = 2; y <= 5; y++)
+ for (let x = 2; x <= 5; x++) values[(z * 8 + y) * 8 + x] = 128;
+ scalars.modified();
+ image.modified();
+ property.setUpdatedExtents([[2, 5, 2, 5, 0, 7]]);
+ render();
+});
+
+property.setUpdatedExtents([[0, 1, 0, 1, 0, 1]]);
+render();
diff --git a/tests/fixtures/volume-texture/server.mjs b/tests/fixtures/volume-texture/server.mjs
new file mode 100644
index 000000000..6d8d082a4
--- /dev/null
+++ b/tests/fixtures/volume-texture/server.mjs
@@ -0,0 +1,5 @@
+import { startVtkFixture } from '../vtkFixtureServer.mjs';
+
+export function startVolumeTextureFixture() {
+ return startVtkFixture(new URL('.', import.meta.url));
+}
diff --git a/tests/fixtures/vtkFixtureServer.mjs b/tests/fixtures/vtkFixtureServer.mjs
new file mode 100644
index 000000000..330dd67e9
--- /dev/null
+++ b/tests/fixtures/vtkFixtureServer.mjs
@@ -0,0 +1,25 @@
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createServer } from 'vite';
+
+export async function startVtkFixture(directory) {
+ const cacheDir = await mkdtemp(resolve(tmpdir(), 'vtk-fixture-'));
+ const root = fileURLToPath(new URL('../../', import.meta.url));
+ const server = await createServer({
+ configFile: false,
+ root: fileURLToPath(directory),
+ cacheDir,
+ resolve: { alias: { '@': root } },
+ server: { host: '127.0.0.1', port: 0, fs: { allow: [root] } },
+ });
+ await server.listen();
+ return {
+ url: server.resolvedUrls.local[0],
+ async close() {
+ await server.close();
+ await rm(cacheDir, { recursive: true, force: true });
+ },
+ };
+}
diff --git a/tests/rootHooks.ts b/tests/rootHooks.ts
index 3e537873d..33f3636a5 100644
--- a/tests/rootHooks.ts
+++ b/tests/rootHooks.ts
@@ -11,6 +11,7 @@ const WEBGL_FAILURES = [
'Error compiling shader',
'CONTEXT_LOST_WEBGL',
"reading 'setContext'",
+ "reading 'oglObject'",
];
let subscribedSession = '';
diff --git a/tests/specs/adaptive-sparse-volumes.e2e.ts b/tests/specs/adaptive-sparse-volumes.e2e.ts
new file mode 100644
index 000000000..e800c24b0
--- /dev/null
+++ b/tests/specs/adaptive-sparse-volumes.e2e.ts
@@ -0,0 +1,132 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { applyTestViewport, TEMP_DIR } from '../../wdio.shared.conf';
+import { volViewPage } from '../pageobjects/volview.page';
+import {
+ createVolumeScalars,
+ openVolumeRenderingControls,
+} from './volumeRenderingTestUtils';
+import { captureViewPixels, writeManifestToFile } from './utils';
+
+function writeSparseVolume(
+ name: string,
+ size: number,
+ first: number,
+ background = 0
+) {
+ const width = size === 64 ? 1 : 3;
+ const encoding = size > 32 ? 'uint16' : 'float';
+ const voxels = createVolumeScalars(size, encoding, (x, y, z) =>
+ [x, y, z].every((index) => index >= first && index < first + width)
+ ? background + 1000
+ : background
+ );
+ const header = [
+ 'NRRD0005',
+ `type: ${encoding === 'uint16' ? 'ushort' : 'float'}`,
+ 'dimension: 3',
+ `sizes: ${size} ${size} ${size}`,
+ 'space: left-posterior-superior',
+ 'space directions: (1,0,0) (0,1,0) (0,0,1)',
+ 'space origin: (0,0,0)',
+ 'encoding: raw',
+ 'endian: little',
+ '',
+ '',
+ ].join('\n');
+ fs.writeFileSync(
+ path.join(TEMP_DIR, name),
+ Buffer.concat([Buffer.from(header), voxels])
+ );
+ return name;
+}
+
+async function expectSparseAppearance() {
+ await browser.waitUntil(
+ async () => {
+ const [{ data }] = await captureViewPixels(
+ '[data-testid~="vtk-volume-view"]',
+ 75,
+ 'sparse-volume.png'
+ );
+ let visible = 0;
+ let background = 0;
+ for (let index = 0; index < data.length; index += 4) {
+ const value = Math.max(data[index], data[index + 1], data[index + 2]);
+ if (value > 25) visible++;
+ if (value < 5) background++;
+ }
+ return visible > 0 && background > (data.length / 4) * 0.95;
+ },
+ {
+ timeoutMsg:
+ 'Expected visible sparse foreground surrounded by transparent background',
+ }
+ );
+}
+
+async function openSparseVolume(name: string) {
+ await volViewPage.open(`?urls=[tmp/sparse-layout.json,tmp/${name}]`);
+ await volViewPage.waitForViewCounts(0, true);
+ await $('[data-testid~="vtk-volume-view"]').waitForDisplayed();
+ await expectSparseAppearance();
+}
+
+describe('sparse adaptive volume rendering', () => {
+ before(async () => {
+ await browser.setViewport({
+ width: 1280,
+ height: 400,
+ devicePixelRatio: 1,
+ });
+ await writeManifestToFile(
+ {
+ layouts: {
+ default: {
+ direction: 'row',
+ items: [
+ { type: '3D', viewDirection: 'Superior', viewUp: 'Anterior' },
+ ],
+ },
+ },
+ },
+ 'sparse-layout.json'
+ );
+ });
+
+ after(async () => {
+ await applyTestViewport(browser);
+ });
+
+ it('shows a small foreground block on both sides of a one-voxel translation', async () => {
+ // The image exceeds the fitting sample budget; moving this isolated signal
+ // by one voxel changes whether it falls on a sampled location.
+ for (const first of [32, 31]) {
+ await openSparseVolume(
+ writeSparseVolume(`sparse-${first}.nrrd`, 64, first)
+ );
+ }
+ });
+
+ for (const background of [100, -1000]) {
+ it(`separates sparse signal from a background of ${background}`, async () => {
+ await openSparseVolume(
+ writeSparseVolume(
+ `sparse-offset-${background}.nrrd`,
+ 32,
+ 14,
+ background
+ )
+ );
+ await openVolumeRenderingControls();
+ for (const profile of ['Transparent', 'Surface']) {
+ const card = $(`[data-testid="volume-preset-Adaptive-${profile}"]`);
+ await card.scrollIntoView({ block: 'center', behavior: 'instant' });
+ await card.click();
+ await expect(card).toHaveElementClass('bg-blue');
+ await $('header').moveTo();
+ await expectSparseAppearance();
+ }
+ });
+ }
+});
diff --git a/tests/specs/adaptive-volume-presets.e2e.ts b/tests/specs/adaptive-volume-presets.e2e.ts
new file mode 100644
index 000000000..cd1798fcd
--- /dev/null
+++ b/tests/specs/adaptive-volume-presets.e2e.ts
@@ -0,0 +1,421 @@
+import fs from 'node:fs';
+import { checkAdaptiveCtReset } from './adaptiveCtCheck';
+import path from 'node:path';
+import { applyTestViewport, TEMP_DIR } from '../../wdio.shared.conf';
+import { volViewPage } from '../pageobjects/volview.page';
+import {
+ appearance,
+ colorShift,
+ createVolumeScalars,
+ openVolumeRenderingControls,
+ pixelDifference,
+ visibleAppearance,
+ type Appearance,
+} from './volumeRenderingTestUtils';
+import {
+ openVolViewPage,
+ SESSION_SAVE_TIMEOUT,
+ waitForDownload,
+ writeManifestToFile,
+} from './utils';
+import {
+ captureOpacityEditor,
+ hasHistogramRequest,
+ holdVolumeHistograms,
+ releaseHistogram,
+ restoreHistogramWorkers,
+ selectAdaptiveHistogramPhase,
+ waitForHistogram,
+} from './volumeHistogramTestUtils';
+
+const resetSelector = '[data-testid="volume-reset-color-opacity"]';
+let fixtureIndex = 0;
+const encodings = {
+ uint8: { elementType: 'MET_UCHAR', scale: 1 },
+ uint16: { elementType: 'MET_USHORT', scale: 200 },
+ float: { elementType: 'MET_FLOAT', scale: 1 / 255 },
+} as const;
+
+type Encoding = keyof typeof encodings;
+
+// Curved bright layers surrounded by empty voxels, with identical geometry and
+// scalar ratios in all three encodings.
+function writeVolume(
+ encoding: Encoding,
+ variant:
+ | 'layers'
+ | 'outlier'
+ | 'empty'
+ | 'constant'
+ | 'binary'
+ | 'ct' = 'layers',
+ scalarScale = 1
+) {
+ const { elementType, scale } = encodings[encoding];
+ // A power-of-two binary fixture puts signal only on even columns. A fixed
+ // two-voxel midpoint sampler would miss every foreground voxel.
+ const size = variant === 'binary' ? 64 : 48;
+ const voxels = createVolumeScalars(size, encoding, (x, y, z) => {
+ const u = (x - (size - 1) / 2) / (size * 0.35);
+ const v = (y - (size - 1) / 2) / (size * 0.35);
+ const radius = u * u + v * v;
+ const depth = z - size * 0.4 - 3 * radius;
+ const layeredValue =
+ radius < 1 && depth >= 0 && depth < 12
+ ? Math.round(30 + 170 * Math.sin((depth / 12) * Math.PI) ** 2)
+ : 0;
+ const value =
+ variant === 'empty'
+ ? 0
+ : variant === 'constant'
+ ? 100
+ : variant === 'binary'
+ ? layeredValue > 0 && x % 2 === 0
+ ? 100
+ : 0
+ : layeredValue;
+ return (
+ value *
+ (encoding === 'uint16' && variant === 'ct' ? 10 : scale) *
+ scalarScale
+ );
+ });
+ if (variant === 'outlier') voxels.writeFloatLE(1e6, 0);
+ const header = [
+ 'ObjectType = Image',
+ 'NDims = 3',
+ `DimSize = ${size} ${size} ${size}`,
+ 'ElementSpacing = 1 1 1',
+ 'BinaryDataByteOrderMSB = False',
+ `ElementType = ${elementType}`,
+ 'ElementDataFile = LOCAL',
+ '',
+ ].join('\n');
+ const name = `adaptive-volume-${encoding}-${variant}${scalarScale === 1 ? '' : `-${scalarScale}`}.mha`;
+ fs.writeFileSync(
+ path.join(TEMP_DIR, name),
+ Buffer.concat([Buffer.from(header), voxels])
+ );
+ return name;
+}
+
+async function selectPreset(name: string) {
+ const card = $(`[data-testid="volume-preset-${name}"]`);
+ await card.scrollIntoView({ block: 'center', behavior: 'instant' });
+ await card.waitForClickable();
+ await card.click();
+ await expect(card).toHaveElementClass('bg-blue');
+ await expect($(resetSelector)).toBeDisabled();
+ await $('header').moveTo();
+}
+
+async function renderedProfile(name: string) {
+ const card = $(`[data-testid="volume-preset-${name}"]`);
+ const selected = (await card.getAttribute('class'))?.includes('bg-blue');
+ const previous = await visibleAppearance();
+ await selectPreset(name);
+ if (!selected) {
+ await browser.waitUntil(
+ async () => pixelDifference(await appearance(), previous, 4) > 2,
+ { timeoutMsg: `Expected ${name} to change the visible volume opacity` }
+ );
+ }
+ return visibleAppearance();
+}
+
+async function openVolume(
+ encoding: Encoding,
+ variant:
+ | 'layers'
+ | 'outlier'
+ | 'empty'
+ | 'constant'
+ | 'binary'
+ | 'ct' = 'layers',
+ scalarScale = 1
+) {
+ const name = writeVolume(encoding, variant, scalarScale);
+ const configName = `adaptive-volume-layout-${encoding}-${variant}-${fixtureIndex++}.json`;
+ await writeManifestToFile(
+ {
+ layouts: {
+ default: {
+ direction: 'row',
+ items: [
+ { type: '3D', viewDirection: 'Superior', viewUp: 'Anterior' },
+ ],
+ },
+ },
+ },
+ configName
+ );
+ await volViewPage.open(`?urls=[tmp/${configName},tmp/${name}]`);
+ await volViewPage.waitForViewCounts(0, true);
+ await openVolumeRenderingControls();
+ await selectPreset('Adaptive-Balanced');
+}
+
+describe('adaptive volume presets', () => {
+ // Bound software rendering cost while keeping every comparison at the same size.
+ before(async () => {
+ await browser.setViewport({
+ width: 1280,
+ height: 400,
+ devicePixelRatio: 1,
+ });
+ });
+
+ after(async () => {
+ await applyTestViewport(browser);
+ });
+
+ it('keeps the fitted histogram when an older ordinary histogram finishes late', async () => {
+ await openVolume('uint16');
+ await holdVolumeHistograms();
+ try {
+ await selectPreset('CT-AAA');
+ await waitForHistogram('ordinary');
+ await selectAdaptiveHistogramPhase();
+ await selectPreset('Adaptive-Balanced');
+ if (await hasHistogramRequest('adaptive'))
+ await waitForHistogram('adaptive');
+ await releaseHistogram('adaptive');
+ const fitted = await captureOpacityEditor();
+ await releaseHistogram('ordinary');
+ expect((await captureOpacityEditor()) === fitted).toBe(true);
+ await expect(
+ $('[data-testid="volume-preset-Adaptive-Balanced"]')
+ ).toHaveElementClass('bg-blue');
+ } finally {
+ await restoreHistogramWorkers();
+ }
+ });
+
+ describe('intensity scaling', () => {
+ const names = ['Transparent', 'Balanced', 'Surface'];
+ const reference: Record = {};
+ before(async () => {
+ await openVolume('uint8', 'layers', 1);
+ const cards = $$('[data-testid^="volume-preset-Adaptive-"]');
+ await expect(cards).toBeElementsArrayOfSize(3);
+ const rows = (await cards.map((card) =>
+ card.execute((element) => element.getBoundingClientRect().top)
+ )) as number[];
+ expect(new Set(rows).size).toBe(1);
+ for (const name of names) {
+ reference[name] = await renderedProfile(`Adaptive-${name}`);
+ }
+ });
+
+ for (const encoding of Object.keys(encodings) as Encoding[]) {
+ it(`renders ${encoding} layers with three distinct grayscale opacity choices`, async () => {
+ await openVolume(encoding);
+ const results: Appearance[] = [];
+ for (const name of names) {
+ const rendered = await renderedProfile(`Adaptive-${name}`);
+ // Block means compare structure and brightness despite ray jitter.
+ expect(pixelDifference(rendered, reference[name], 4)).toBeLessThan(3);
+ expect(
+ Math.abs(rendered.visible - reference[name].visible)
+ ).toBeLessThan(reference[name].visible * 0.02);
+ for (const prior of results)
+ expect(pixelDifference(rendered, prior, 4)).toBeGreaterThan(2);
+ results.push(rendered);
+ }
+ });
+ }
+ });
+
+ it('preserves manual appearance in a saved session and resets the selected preset on request', async () => {
+ await openVolume('uint16');
+ const fitted = await renderedProfile('Adaptive-Transparent');
+ const shift = await colorShift();
+ const fittedShift = (await shift.getAttribute('aria-valuenow'))!;
+ await shift.execute((element) => element.focus());
+ await browser.keys('End');
+ const editedShift = (await shift.getAttribute('aria-valuenow'))!;
+ expect(editedShift).not.toBe(fittedShift);
+ await expect($(resetSelector)).toBeEnabled();
+ await browser.waitUntil(
+ async () => pixelDifference(await appearance(), fitted) > 2,
+ {
+ timeoutMsg:
+ 'Expected a manual color shift to change the rendered volume',
+ }
+ );
+ const colorEdited = await appearance();
+ const opacity = $('[data-testid="volume-opacity-editor"] canvas');
+ await opacity.scrollIntoView({ block: 'center', behavior: 'instant' });
+ await opacity.waitForStable();
+ const location = await opacity.getLocation();
+ const dimensions = await opacity.getSize();
+ await browser
+ .action('pointer')
+ .move({
+ x: Math.round(location.x + dimensions.width * 0.94),
+ y: Math.round(location.y + dimensions.height * 0.7),
+ })
+ .perform();
+ // Use the rendered green peak handle to adjust opacity height, keeping the
+ // low endpoint at zero so the padded voxels remain transparent.
+ let handle: { x: number; y: number } | null = null;
+ await browser.waitUntil(
+ async () => {
+ handle = (await opacity.execute((element) => {
+ const canvas = element as HTMLCanvasElement;
+ const data = canvas
+ .getContext('2d')!
+ .getImageData(0, 0, canvas.width, canvas.height).data;
+ for (let y = 0; y < canvas.height; y++) {
+ for (
+ let x = Math.floor(canvas.width * 0.7);
+ x < canvas.width;
+ x++
+ ) {
+ const i = (y * canvas.width + x) * 4;
+ if (data[i] === 50 && data[i + 1] === 150 && data[i + 2] === 50)
+ return { x: x / canvas.width, y: y / canvas.height };
+ }
+ }
+ return null;
+ })) as { x: number; y: number } | null;
+ return handle !== null;
+ },
+ { timeoutMsg: 'Expected the visible opacity peak handle' }
+ );
+ const x = Math.round(location.x + dimensions.width * handle!.x);
+ const y = Math.round(location.y + dimensions.height * handle!.y);
+ await browser
+ .action('pointer')
+ .move({ x, y })
+ .down()
+ .move({ x, y: y - 25, duration: 100 })
+ .up()
+ .perform();
+ await browser.waitUntil(
+ async () => pixelDifference(await appearance(), colorEdited) > 2,
+ {
+ timeoutMsg: 'Expected dragging the opacity height to change the volume',
+ }
+ );
+ const edited = await appearance();
+ const saved = await volViewPage.saveSession();
+ await waitForDownload(path.join(TEMP_DIR, saved), SESSION_SAVE_TIMEOUT);
+ await openVolViewPage(saved);
+ await volViewPage.waitForViewCounts(0, true);
+ await openVolumeRenderingControls();
+ await expect(
+ $('[data-testid="volume-preset-Adaptive-Transparent"]')
+ ).toHaveElementClass('bg-blue');
+ await expect(await colorShift()).toHaveAttribute(
+ 'aria-valuenow',
+ editedShift
+ );
+ await browser.waitUntil(
+ async () => pixelDifference(await appearance(), edited, 4) < 3,
+ {
+ timeoutMsg:
+ 'Expected the saved manual appearance to survive restoration',
+ }
+ );
+ const refit = $(resetSelector);
+ await refit.scrollIntoView({ block: 'center', behavior: 'instant' });
+ await refit.click();
+ await expect(refit).toBeDisabled();
+ await expect(await colorShift()).toHaveAttribute(
+ 'aria-valuenow',
+ fittedShift
+ );
+ await browser.waitUntil(
+ async () => pixelDifference(await appearance(), fitted, 4) < 3,
+ {
+ timeoutMsg:
+ 'Expected resetting to restore the original adaptive appearance',
+ }
+ );
+ });
+
+ it('preserves CT edits across tabs and resets color and opacity without changing its palette or lighting', async () => {
+ await checkAdaptiveCtReset(
+ () => openVolume('uint16', 'ct', 1),
+ (name) => selectPreset(name)
+ );
+ });
+
+ it('enables Reset for visible contrast edits on tiny floating-point intensities', async () => {
+ await openVolume('float', 'layers', 1e-12);
+ const original = await visibleAppearance();
+ const shift = await colorShift();
+ const initialShift = (await shift.getAttribute('aria-valuenow'))!;
+ await shift.execute((element) => element.focus());
+ await browser.keys('End');
+ expect(await shift.getAttribute('aria-valuenow')).not.toBe(initialShift);
+ await browser.waitUntil(
+ async () => pixelDifference(await appearance(), original) > 2,
+ {
+ timeoutMsg:
+ 'Expected the tiny-float color shift to visibly change the volume',
+ }
+ );
+ const reset = $(resetSelector);
+ await expect(reset).toBeEnabled();
+ await reset.scrollIntoView({ block: 'center', behavior: 'instant' });
+ await reset.click();
+ await expect(reset).toBeDisabled();
+ await expect(await colorShift()).toHaveAttribute(
+ 'aria-valuenow',
+ initialShift
+ );
+ await browser.waitUntil(
+ async () => pixelDifference(await appearance(), original, 4) < 3,
+ {
+ timeoutMsg:
+ 'Expected Reset to restore the tiny-float volume appearance',
+ }
+ );
+ });
+
+ it('keeps an all-zero volume transparent', async () => {
+ await openVolume('uint8', 'empty');
+ await browser.waitUntil(async () => (await appearance()).visible === 0, {
+ timeoutMsg: 'Expected an all-zero volume to remain transparent',
+ });
+ });
+
+ it('keeps nonzero constant voxels visible', async () => {
+ await openVolume('uint8', 'constant');
+ await browser.waitUntil(async () => (await appearance()).visible > 500, {
+ timeoutMsg: 'Expected nonzero constant voxels to remain visible',
+ });
+ });
+
+ it('shows sparse binary foreground and still offers ordinary CT presets', async () => {
+ await openVolume('uint8', 'binary');
+ await visibleAppearance();
+ const ordinary = $('[data-testid="volume-preset-CT-Bone"]');
+ await ordinary.scrollIntoView({ block: 'center', behavior: 'instant' });
+ await ordinary.click();
+ await expect(ordinary).toHaveElementClass('bg-blue');
+ await expect($(resetSelector)).toBeDisabled();
+
+ await selectPreset('US-Fetal');
+ await expect(await colorShift()).toHaveAttribute('aria-valuenow', '0');
+ const width = $('[data-testid="volume-color-width"] [role="slider"]');
+ await width.scrollIntoView({ block: 'center', behavior: 'instant' });
+ await expect(width).toHaveAttribute('aria-valuenow', '280');
+ await width.execute((element) => element.focus());
+ await browser.keys('ArrowRight');
+ await expect($(resetSelector)).toBeEnabled();
+ await browser.keys('ArrowLeft');
+ await expect(width).toHaveAttribute('aria-valuenow', '280');
+ await expect($(resetSelector)).toBeDisabled();
+ });
+
+ it('keeps the layers visible when one padded voxel is an extreme outlier', async () => {
+ await openVolume('float', 'outlier');
+ await visibleAppearance();
+ for (const preset of ['Adaptive-Transparent', 'Adaptive-Surface']) {
+ await renderedProfile(preset);
+ }
+ });
+});
diff --git a/tests/specs/adaptiveCtCheck.ts b/tests/specs/adaptiveCtCheck.ts
new file mode 100644
index 000000000..33092a26a
--- /dev/null
+++ b/tests/specs/adaptiveCtCheck.ts
@@ -0,0 +1,162 @@
+import path from 'node:path';
+import { TEMP_DIR } from '../../wdio.shared.conf';
+import {
+ appearance,
+ colorShift,
+ openVolumeRenderingControls,
+ pixelDifference,
+} from './volumeRenderingTestUtils';
+import { waitForModuleTransitions } from './utils';
+const resetSelector = '[data-testid="volume-reset-color-opacity"]';
+export async function checkAdaptiveCtReset(
+ load: () => Promise,
+ selectPreset: (name: string) => Promise
+) {
+ await load();
+ await selectPreset('CT-AAA');
+ const thumbnail = $('[data-testid="volume-preset-CT-AAA"] img');
+ await thumbnail.waitForExist();
+ const captured = (await thumbnail.execute(async (element) => {
+ const image = element as HTMLImageElement;
+ await image.decode();
+ const canvas = document.createElement('canvas');
+ canvas.width = image.naturalWidth;
+ canvas.height = image.naturalHeight;
+ const context = canvas.getContext('2d')!;
+ context.drawImage(image, 0, 0);
+ const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
+ let foreground = 0;
+ for (let offset = 0; offset < pixels.length; offset += 4) {
+ if (
+ [0, 1, 2].some(
+ (channel) => Math.abs(pixels[offset + channel] - pixels[channel]) > 20
+ )
+ )
+ foreground++;
+ }
+ return { width: canvas.width, height: canvas.height, foreground };
+ })) as { width: number; height: number; foreground: number };
+ expect([captured.width, captured.height]).toEqual([80, 80]);
+ expect(captured.foreground).toBeGreaterThan(0);
+
+ const cinematic = $('.v-expansion-panel-title*=Cinematic Rendering');
+ await cinematic.scrollIntoView({ block: 'center', behavior: 'instant' });
+ if ((await cinematic.getAttribute('aria-expanded')) !== 'true')
+ await cinematic.click();
+ await waitForModuleTransitions();
+ const ambient = $(
+ "//label[normalize-space(.)='Ambient']/ancestor::div[contains(@class, 'v-slider')][1]//*[@role='slider']"
+ );
+ await ambient.scrollIntoView({ block: 'center', behavior: 'instant' });
+ await ambient.execute((element) => element.focus());
+ await browser.keys('End');
+ await expect(ambient).toHaveAttribute('aria-valuenow', '1');
+ await expect($(resetSelector)).toBeDisabled();
+ await selectPreset('Adaptive-Balanced');
+ await expect(ambient).toHaveAttribute('aria-valuenow', '1');
+ await selectPreset('CT-AAA');
+ await expect(ambient).toHaveAttribute('aria-valuenow', '1');
+ await ambient.execute((element) => element.blur());
+ await $('header').moveTo();
+ await browser.waitUntil(async () => (await appearance()).visible > 250);
+ const original = await appearance();
+ const shift = await colorShift();
+ const initialShift = (await shift.getAttribute('aria-valuenow'))!;
+ expect(initialShift).toBe('0');
+ await shift.execute((element) => element.focus());
+ await browser.keys('ArrowRight');
+ await expect($(resetSelector)).toBeEnabled();
+ await browser.keys('ArrowLeft');
+ await expect(shift).toHaveAttribute('aria-valuenow', initialShift);
+ await expect($(resetSelector)).toBeDisabled();
+ await shift.execute((element) => element.blur());
+ const opacity = $('[data-testid="volume-opacity-editor"] canvas');
+ await opacity.scrollIntoView({ block: 'center', behavior: 'instant' });
+ await opacity.waitForStable();
+ const bounds = (await opacity.execute((element) => {
+ const { x, y, width, height } = element.getBoundingClientRect();
+ return { x, y, width, height };
+ })) as Pick;
+ const x = Math.round(bounds.x + bounds.width * 0.5);
+ const y = Math.round(bounds.y + bounds.height * 0.5);
+ await browser
+ .action('pointer')
+ .move({ x, y })
+ .down()
+ .move({ x: x + Math.round(bounds.width * 0.2), y, duration: 100 })
+ .up()
+ .perform();
+ await browser.waitUntil(
+ async () => pixelDifference(await appearance(), original) > 2,
+ {
+ timeoutMsg: 'Expected a CT opacity shift to change the rendered volume',
+ }
+ );
+ await expect($(resetSelector)).toBeEnabled();
+ const opacityEdited = await appearance();
+ await shift.scrollIntoView({ block: 'center', behavior: 'instant' });
+ await shift.execute((element) => element.focus());
+ await browser.keys('End');
+ const changedShift = (await shift.getAttribute('aria-valuenow'))!;
+ expect(changedShift).not.toBe(initialShift);
+ await expect($(resetSelector)).toBeEnabled();
+ await browser.waitUntil(
+ async () => pixelDifference(await appearance(), opacityEdited) > 2
+ );
+ const edited = await appearance();
+ await $('[data-testid="module-tab-Data"]').click();
+ await waitForModuleTransitions();
+ await openVolumeRenderingControls();
+ await expect(await colorShift()).toHaveAttribute(
+ 'aria-valuenow',
+ changedShift
+ );
+ await browser.waitUntil(
+ async () => pixelDifference(await appearance(), edited, 4) < 3,
+ {
+ timeoutMsg:
+ 'Expected both manual CT edits to survive reopening Rendering',
+ }
+ );
+ const presets = $('.v-expansion-panel-title*=Color Presets');
+ await presets.scrollIntoView({ block: 'center', behavior: 'instant' });
+ await presets.click();
+ await expect(presets).toHaveAttribute('aria-expanded', 'false');
+ await waitForModuleTransitions();
+ const reset = $(resetSelector);
+ await reset.scrollIntoView({ block: 'center', behavior: 'instant' });
+ await expect(reset).toBeDisplayed();
+ await expect(reset).toHaveAttribute('aria-label', 'Reset color and opacity');
+ await expect($('.mapping-range-editor').$(resetSelector)).toBeDisplayed();
+ await expect(reset).toBeEnabled();
+ await browser.saveScreenshot(
+ path.join(TEMP_DIR, 'volume-reset-placement.png')
+ );
+ await reset.click();
+ await expect(reset).toBeDisabled();
+ await expect(await colorShift()).toHaveAttribute(
+ 'aria-valuenow',
+ initialShift
+ );
+ await cinematic.scrollIntoView({ block: 'center', behavior: 'instant' });
+ if ((await cinematic.getAttribute('aria-expanded')) !== 'true')
+ await cinematic.click();
+ await waitForModuleTransitions();
+ await expect(
+ $(
+ "//label[normalize-space(.)='Ambient']/ancestor::div[contains(@class, 'v-slider')][1]//*[@role='slider']"
+ )
+ ).toHaveAttribute('aria-valuenow', '1');
+ await browser.waitUntil(
+ async () => pixelDifference(await appearance(), original, 4) < 3,
+ {
+ timeoutMsg:
+ 'Expected reset to restore CT color and opacity with the edited lighting',
+ }
+ );
+ await presets.scrollIntoView({ block: 'center', behavior: 'instant' });
+ await presets.click();
+ await expect($('[data-testid="volume-preset-CT-AAA"]')).toHaveElementClass(
+ 'bg-blue'
+ );
+}
diff --git a/tests/specs/pet-ct-sample.e2e.ts b/tests/specs/pet-ct-sample.e2e.ts
index ed9e9fa09..3a6e32bde 100644
--- a/tests/specs/pet-ct-sample.e2e.ts
+++ b/tests/specs/pet-ct-sample.e2e.ts
@@ -83,12 +83,15 @@ async function openAxialPetLayer() {
await $('[data-testid="layer-range-max"] input').waitForEnabled();
await waitForModuleTransitions();
await $('header').moveTo();
- await browser.waitUntil(
- async () => (await captureAxialPixels()).colored > 1000,
+ const initialPixels = await browser.waitUntil(
+ async () => {
+ const pixels = await captureAxialPixels();
+ return pixels.colored > 1000 ? pixels : false;
+ },
{ timeoutMsg: 'Expected the sample to display PET over CT' }
);
return {
- pixels: await captureAxialPixels(),
+ pixels: initialPixels,
range: await displayedPetRange(),
};
}
@@ -108,17 +111,18 @@ async function editPetColors(
await coolToWarm.waitForClickable();
await coolToWarm.click();
await $('header').moveTo();
- await browser.waitUntil(
+ const beforeRange = await browser.waitUntil(
async () => {
const pixels = await captureAxialPixels();
- return pixels.colored > 1000 && pixels.checksum !== initial.checksum;
+ return pixels.colored > 1000 && pixels.checksum !== initial.checksum
+ ? pixels
+ : false;
},
{
timeoutMsg:
'Expected the selected palette to change the visible PET colors',
}
);
- const beforeRange = await captureAxialPixels();
const narrowedMaximum = String((Number(minimum) + Number(maximum)) / 2);
await setValueVueInput(
$('[data-testid="layer-range-max"] input'),
@@ -133,10 +137,12 @@ async function editPetColors(
'title',
narrowedMaximum
);
- await browser.waitUntil(
+ const editedPixels = await browser.waitUntil(
async () => {
const pixels = await captureAxialPixels();
- return pixels.colored > 1000 && pixels.checksum !== beforeRange.checksum;
+ return pixels.colored > 1000 && pixels.checksum !== beforeRange.checksum
+ ? pixels
+ : false;
},
{
timeoutMsg:
@@ -144,7 +150,7 @@ async function editPetColors(
}
);
return {
- pixels: await captureAxialPixels(),
+ pixels: editedPixels,
range: [minimum, narrowedMaximum],
};
}
@@ -168,7 +174,6 @@ async function reopenSample(
await sample.waitForExist();
await waitForModuleTransitions();
await sample.scrollIntoView({ block: 'center', behavior: 'instant' });
- await browser.saveScreenshot(path.join(TEMP_DIR, 'pet-ct-sample-reload.png'));
await sample.waitForClickable();
await sample.click();
await sample.$('.mdi-check').waitForExist();
@@ -460,17 +465,28 @@ describe('PET/CT sample', () => {
});
it('keeps one PET overlay with edited colors when reopening the sample repeatedly', async () => {
+ const started = Date.now();
+ const phase = (name: string) =>
+ console.info('PET sample %s: %d ms', name, Date.now() - started);
+ phase('open start');
const initial = await openAxialPetLayer();
+ phase('open end');
+ phase('edit start');
const { pixels: edited, range } = await editPetColors(
initial.pixels,
initial.range
);
+ phase('edit end');
for (let reopening = 0; reopening < 2; reopening++) {
+ phase('reopen ' + (reopening + 1) + ' start');
await reopenSample(edited, range, 'Cool to Warm');
+ phase('reopen ' + (reopening + 1) + ' end');
}
+ phase('final capture start');
await browser.saveScreenshot(
path.join(TEMP_DIR, 'pet-ct-sample-reopened.png')
);
+ phase('final capture end');
expect(await volViewPage.getNotificationsCount()).toBe(0);
});
diff --git a/tests/specs/progressive-volume-presets.e2e.ts b/tests/specs/progressive-volume-presets.e2e.ts
new file mode 100644
index 000000000..987adf0d8
--- /dev/null
+++ b/tests/specs/progressive-volume-presets.e2e.ts
@@ -0,0 +1,261 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { TEMP_DIR } from '../../wdio.shared.conf';
+import { volViewPage } from '../pageobjects/volview.page';
+import { buildSyntheticDicom, newUid } from './syntheticDicom';
+import {
+ appearance,
+ restoredVolumeManifest,
+ openVolumeRenderingControls,
+} from './volumeRenderingTestUtils';
+
+const layout = {
+ direction: 'row',
+ items: [{ type: '3D', viewDirection: 'Superior', viewUp: 'Anterior' }],
+};
+const loadingIndicator = () => $('.volume-viewer-container .loading-indicator');
+const presetCard = (name: string) => $(`[data-testid="volume-preset-${name}"]`);
+const presetLabel = () => $('.volume-viewer-container .view-annotations');
+let heldSlice: Awaited> | undefined;
+
+async function openStreamingVolume(
+ modality: string,
+ {
+ normalized = false,
+ sliceCount = 8,
+ finalPixelValue = 0,
+ restoreColoring = false,
+ } = {}
+) {
+ const directory = `progressive-${modality}-${normalized}-${sliceCount}-${finalPixelValue}`;
+ const folder = path.join(TEMP_DIR, directory);
+ fs.mkdirSync(folder, { recursive: true });
+ const studyUid = newUid();
+ const seriesUid = newUid();
+ const rows = 24;
+ const cols = 24;
+ let headerLength = 0;
+ const resources = Array.from({ length: sliceCount }, (_, index) => {
+ const name = `slice-${index}.dcm`;
+ const bytes = buildSyntheticDicom({
+ studyUid,
+ seriesUid,
+ sopUid: newUid(),
+ modality,
+ instanceNumber: index + 1,
+ rows,
+ cols,
+ imageOrientationPatient: [1, 0, 0, 0, 1, 0],
+ imagePositionPatient: [0, 0, index * 3],
+ spacingBetweenSlices: 3,
+ pixelValue:
+ index === sliceCount - 1
+ ? finalPixelValue
+ : sliceCount > 2 && index === 0
+ ? 0
+ : 200 + index * 50,
+ ...(normalized ? { rescaleSlope: 0.001 } : {}),
+ });
+ fs.writeFileSync(path.join(folder, name), bytes);
+ headerLength = bytes.length - rows * cols * 2;
+ return { url: `tmp/${directory}/${name}`, name };
+ });
+ const manifest = `${directory}.json`;
+ const config = `${directory}-layout.json`;
+ // Keep fixtures until the runner removes TEMP_DIR, including after failures
+ // that release the outstanding request from this spec's afterEach hook.
+ fs.writeFileSync(
+ path.join(TEMP_DIR, manifest),
+ JSON.stringify(
+ restoreColoring
+ ? restoredVolumeManifest(resources, {
+ preset: 'Adaptive-Balanced',
+ mappingRange: [250, 1000],
+ })
+ : { resources }
+ )
+ );
+ fs.writeFileSync(
+ path.join(TEMP_DIR, config),
+ JSON.stringify({ layouts: { default: layout } })
+ );
+
+ // Let the ordinary URI loader read every slice's metadata. Keep one slice's
+ // pixel request outstanding until the test explicitly releases it.
+ heldSlice = await browser.addInitScript(
+ ({ pathname, prefixLength }) => {
+ const original = window.fetch.bind(window);
+ const released = new Promise((resolve) => {
+ window.addEventListener('release-volume-slice', () => resolve(), {
+ once: true,
+ });
+ });
+ window.fetch = async (input, init) => {
+ const request = new Request(input, init);
+ if (new URL(request.url).pathname !== pathname)
+ return original(input, init);
+ if (request.headers.has('range')) {
+ await released;
+ return original(input, init);
+ }
+ const response = await original(input, init);
+ const bytes = new Uint8Array(await response.arrayBuffer());
+ return new Response(bytes.slice(0, prefixLength), {
+ status: response.status,
+ headers: response.headers,
+ });
+ };
+ },
+ {
+ pathname: `/tmp/${directory}/slice-${sliceCount - 1}.dcm`,
+ prefixLength: headerLength,
+ }
+ );
+ await volViewPage.open(`?urls=[tmp/${config},tmp/${manifest}]`);
+ await volViewPage.waitForViewCounts(0, true);
+ await expect(loadingIndicator()).toBeDisplayed();
+}
+
+async function switchViewType(name: string, count2D: number, has3D: boolean) {
+ await $('.view-type-select').click();
+ const option = $(`[role="option"]*=${name}`);
+ await option.waitForClickable();
+ await option.click();
+ await volViewPage.waitForViewCounts(count2D, has3D);
+}
+
+async function releaseSlice() {
+ await browser.execute(() => {
+ window.dispatchEvent(new Event('release-volume-slice'));
+ });
+}
+
+async function expectVisibleVolume() {
+ await browser.waitUntil(async () => (await appearance()).visible > 180, {
+ timeoutMsg: 'Expected decoded DICOM slices to be visible in the 3D view',
+ });
+}
+
+async function expectCompletedPreset(preset: string) {
+ await expect(loadingIndicator()).not.toExist();
+ await expect(presetCard(preset)).toHaveElementClass('bg-blue');
+ await expect(presetLabel()).toHaveText(
+ expect.stringContaining(preset.replace(/-/g, ' '))
+ );
+ await expectVisibleVolume();
+ expect(await volViewPage.getNotificationsCount()).toBe(0);
+}
+
+describe('progressive volume presets', () => {
+ afterEach(async () => {
+ await releaseSlice();
+ await heldSlice?.remove();
+ heldSlice = undefined;
+ });
+
+ for (const modality of ['CT', 'MR']) {
+ it(`displays partial ${modality} data before the final slice loads`, async () => {
+ await openStreamingVolume(modality);
+ await expectVisibleVolume();
+ await expect(loadingIndicator()).toBeDisplayed();
+ await openVolumeRenderingControls();
+ const preset = modality === 'CT' ? 'CT-AAA' : 'MR-Default';
+ await expect(presetCard(preset)).toHaveElementClass('bg-blue');
+ await expect(presetLabel()).toHaveText(
+ expect.stringContaining(preset.replace(/-/g, ' '))
+ );
+ const cinematic = $('[data-testid="volume-properties"]');
+ await cinematic.scrollIntoView({ block: 'center', behavior: 'instant' });
+ await expect(cinematic).toBeDisplayed();
+ await expect(cinematic.$('.v-slider input')).toBeDisabled();
+ await expect(cinematic.$('.v-switch input')).toBeDisabled();
+ await cinematic.moveTo();
+ const tooltipID = await cinematic.getAttribute('aria-describedby');
+ if (!tooltipID) throw new Error('Expected a Cinematic loading tooltip');
+ const loadingTooltip = $(`#${tooltipID}`);
+ await expect(loadingTooltip).toBeDisplayed();
+ await expect(loadingTooltip).toHaveText(
+ expect.stringContaining('after the volume finishes loading')
+ );
+ await expect(presetCard('Adaptive-Balanced')).toHaveAttribute(
+ 'aria-disabled',
+ 'true'
+ );
+ await releaseSlice();
+ await expectCompletedPreset(preset);
+ await expect(cinematic.$('.v-slider input')).toBeEnabled();
+ await expect(cinematic.$('.v-switch input')).toBeEnabled();
+ });
+ }
+
+ it('displays a constant MR slice before completion and after a 3D view mounts', async () => {
+ await openStreamingVolume('MR', { sliceCount: 2 });
+ await expectVisibleVolume();
+ await switchViewType('Axial', 1, false);
+ await switchViewType('Volume', 0, true);
+ await expect(loadingIndicator()).toBeDisplayed();
+ await expectVisibleVolume();
+ await openVolumeRenderingControls();
+ await releaseSlice();
+ await expectCompletedPreset('MR-Default');
+ });
+
+ it('fits normalized MR data only after all slices load', async () => {
+ await openStreamingVolume('MR', { normalized: true });
+ await openVolumeRenderingControls();
+ await expect(presetCard('Adaptive-Balanced')).toHaveAttribute(
+ 'aria-disabled',
+ 'true'
+ );
+ await expect(presetCard('MR-Default')).toHaveElementClass('bg-blue');
+ await expect(presetLabel()).toHaveText(
+ expect.stringContaining('MR Default')
+ );
+ await releaseSlice();
+ await expectCompletedPreset('Adaptive-Balanced');
+ });
+
+ it('preserves a selected preset and finishes its opacity range when the final slice expands the intensities', async () => {
+ await openStreamingVolume('MR', { finalPixelValue: 2000 });
+ await expectVisibleVolume();
+ await openVolumeRenderingControls();
+ const selected = presetCard('CT-AAA');
+ await selected.scrollIntoView({ block: 'center', behavior: 'instant' });
+ await selected.click();
+ await expect(selected).toHaveElementClass('bg-blue');
+ await expect(loadingIndicator()).toBeDisplayed();
+ await releaseSlice();
+ await expectCompletedPreset('CT-AAA');
+ await expect(
+ $('[data-testid="volume-reset-color-opacity"]')
+ ).toBeDisabled();
+ });
+
+ it('updates restored adaptive Shift bounds when the final slice expands the intensities', async () => {
+ await openStreamingVolume('MR', {
+ finalPixelValue: 2000,
+ restoreColoring: true,
+ });
+ await openVolumeRenderingControls();
+ await expect(presetCard('Adaptive-Balanced')).toHaveElementClass('bg-blue');
+ await expect(presetLabel()).toHaveText(
+ expect.stringContaining('Adaptive Balanced')
+ );
+ await releaseSlice();
+ await expect(loadingIndicator()).not.toExist();
+ const shift = $('[data-testid="volume-color-shift"] [role="slider"]');
+ const width = $('[data-testid="volume-color-width"] [role="slider"]');
+ await expect(width).toHaveAttribute('aria-valuenow', '750');
+ // Seven equally sized nonzero slices have median 400 and upper fit 2000.
+ // The restored color center is 625, so its shift from the new fit is -575.
+ await expect(shift).toHaveAttribute('aria-valuenow', '-575');
+ await shift.scrollIntoView({ block: 'center', behavior: 'instant' });
+ await shift.execute((element) => element.focus());
+ await browser.keys('ArrowRight');
+ await expect(shift).toHaveAttribute('aria-valuenow', '-574');
+ await expect(width).toHaveAttribute('aria-valuenow', '750');
+ await browser.keys('ArrowLeft');
+ await expect(shift).toHaveAttribute('aria-valuenow', '-575');
+ expect(await volViewPage.getNotificationsCount()).toBe(0);
+ });
+});
diff --git a/tests/specs/utils.ts b/tests/specs/utils.ts
index b6f2cc82c..7e1562301 100644
--- a/tests/specs/utils.ts
+++ b/tests/specs/utils.ts
@@ -101,15 +101,28 @@ export async function waitForModuleTransitions() {
() =>
browser.execute(() => {
const container = document.getElementById('module-container')!;
- const transitioning = container.querySelector(
- '[class*="-enter-active"], [class*="-leave-active"]'
- );
+ // Hidden modules can animate without moving the visible controls.
+ const movesModule = (element?: Element | null) => {
+ const panel = element?.closest('.module-window-item');
+ return (
+ !panel ||
+ panel === element ||
+ panel.classList.contains('v-window-item--active') ||
+ getComputedStyle(panel).display !== 'none'
+ );
+ };
+ const transitioning = Array.from(
+ container.querySelectorAll(
+ '[class*="-enter-active"], [class*="-leave-active"]'
+ )
+ ).some(movesModule);
const animating = container
.getAnimations({ subtree: true })
.some(
(animation) =>
animation.playState === 'running' &&
- Number.isFinite(animation.effect?.getComputedTiming().endTime)
+ Number.isFinite(animation.effect?.getComputedTiming().endTime) &&
+ movesModule((animation.effect as KeyframeEffect)?.target)
);
return !transitioning && !animating;
}),
diff --git a/tests/specs/volume-default-presets.e2e.ts b/tests/specs/volume-default-presets.e2e.ts
new file mode 100644
index 000000000..367be57cd
--- /dev/null
+++ b/tests/specs/volume-default-presets.e2e.ts
@@ -0,0 +1,259 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { TEMP_DIR } from '../../wdio.shared.conf';
+import { volViewPage } from '../pageobjects/volview.page';
+import {
+ createVolumeScalars,
+ restoredVolumeManifest,
+ openVolumeRenderingControls,
+} from './volumeRenderingTestUtils';
+import { buildSyntheticDicom, newUid } from './syntheticDicom';
+import { makeTempDir, writeManifestToFile } from './utils';
+
+const layout = {
+ direction: 'row',
+ items: [{ type: '3D', viewDirection: 'Superior', viewUp: 'Anterior' }],
+};
+
+// Header metadata and intensity encoding vary independently. The geometry is
+// fixed so a selected default must produce visible voxels, not just a card.
+function writeVolume(
+ name: string,
+ type: 'float' | 'ushort' | 'short',
+ low: number,
+ high: number,
+ metadata: Record
+) {
+ const size = 32;
+ const encoding =
+ type === 'ushort' ? 'uint16' : type === 'short' ? 'int16' : 'float';
+ const voxels = createVolumeScalars(size, encoding, (x, y, z) => {
+ const radius =
+ ((x - 15.5) ** 2 + (y - 15.5) ** 2 + (z - 15.5) ** 2) / 12 ** 2;
+ const value =
+ radius < 1 ? low + (high - low) * (0.35 + 0.65 * (1 - radius)) : low;
+ return type === 'float' ? value : Math.round(value);
+ });
+ const header = [
+ 'NRRD0005',
+ `type: ${type}`,
+ 'dimension: 3',
+ `sizes: ${size} ${size} ${size}`,
+ 'space: left-posterior-superior',
+ 'space directions: (1,0,0) (0,1,0) (0,0,1)',
+ 'space origin: (0,0,0)',
+ 'encoding: raw',
+ 'endian: little',
+ ...Object.entries(metadata).map(([key, value]) => `${key}:=${value}`),
+ '',
+ '',
+ ].join('\n');
+ fs.writeFileSync(
+ path.join(TEMP_DIR, name),
+ Buffer.concat([Buffer.from(header), voxels])
+ );
+ return name;
+}
+
+async function expectInitialPreset(preset: string) {
+ await volViewPage.waitForViewCounts(0, true);
+ await openVolumeRenderingControls();
+ await expect($(`[data-testid="volume-preset-${preset}"]`)).toHaveElementClass(
+ 'bg-blue'
+ );
+ await browser.waitUntil(
+ async () => {
+ const count = await $('[data-testid~="vtk-volume-view"] canvas').execute(
+ (element) => {
+ const source = element as HTMLCanvasElement;
+ const copy = document.createElement('canvas');
+ copy.width = 64;
+ copy.height = 64;
+ const context = copy.getContext('2d')!;
+ context.drawImage(
+ source,
+ source.width * 0.15,
+ source.height * 0.15,
+ source.width * 0.7,
+ source.height * 0.7,
+ 0,
+ 0,
+ 64,
+ 64
+ );
+ const data = context.getImageData(0, 0, 64, 64).data;
+ let visible = 0;
+ for (let index = 0; index < data.length; index += 4)
+ if (Math.max(data[index], data[index + 1], data[index + 2]) > 25)
+ visible++;
+ return visible;
+ }
+ );
+ return Number(count) > 80;
+ },
+ {
+ timeoutMsg: `Expected ${preset} to display the generated volume without manual contrast changes`,
+ }
+ );
+ expect(await volViewPage.getNotificationsCount()).toBe(0);
+}
+
+async function openGeneratedVolume(name: string) {
+ const config = `default-layout-${name}.json`;
+ await writeManifestToFile({ layouts: { default: layout } }, config);
+ await volViewPage.open(`?urls=[tmp/${config},tmp/${name}]`);
+}
+
+const cases = [
+ {
+ name: 'unclassified-float',
+ type: 'float',
+ low: 0,
+ high: 1,
+ metadata: {},
+ preset: 'Adaptive-Balanced',
+ },
+ {
+ name: 'unclassified-uint16',
+ type: 'ushort',
+ low: 0,
+ high: 52000,
+ metadata: {},
+ preset: 'Adaptive-Balanced',
+ },
+ {
+ name: 'CT-MR-US-OCT-without-metadata',
+ type: 'short',
+ low: -1000,
+ high: 1500,
+ metadata: {},
+ preset: 'Adaptive-Balanced',
+ },
+ {
+ name: 'declared-CT',
+ type: 'short',
+ low: -1000,
+ high: 1500,
+ metadata: { modality: 'CT' },
+ preset: 'CT-AAA',
+ },
+ {
+ name: 'declared-MR',
+ type: 'ushort',
+ low: 0,
+ high: 1000,
+ metadata: { '0008|0060': 'MR' },
+ preset: 'MR-Default',
+ },
+ {
+ name: 'declared-US',
+ type: 'ushort',
+ low: 0,
+ high: 255,
+ metadata: { Modality: 'US' },
+ preset: 'US-Fetal',
+ },
+ {
+ name: 'normalized-MR',
+ type: 'float',
+ low: 0,
+ high: 1,
+ metadata: { modality: 'MR' },
+ preset: 'Adaptive-Balanced',
+ },
+ {
+ name: 'normalized-CT',
+ type: 'float',
+ low: 0,
+ high: 1,
+ metadata: { modality: 'CT' },
+ preset: 'Adaptive-Balanced',
+ },
+ {
+ name: 'explicit-HU',
+ type: 'short',
+ low: -1000,
+ high: 1500,
+ metadata: { IntensityUnits: 'HU' },
+ preset: 'CT-AAA',
+ },
+ {
+ name: 'unknown-modality-with-HU',
+ type: 'short',
+ low: -1000,
+ high: 1500,
+ metadata: { modality: 'OT', IntensityUnits: 'HU' },
+ preset: 'Adaptive-Balanced',
+ },
+] as const;
+
+describe('initial volume presets', () => {
+ for (const entry of cases) {
+ it(`uses ${entry.preset} for ${entry.name}`, async () => {
+ const name = writeVolume(
+ `${entry.name}.nrrd`,
+ entry.type,
+ entry.low,
+ entry.high,
+ entry.metadata
+ );
+ await openGeneratedVolume(name);
+ await expectInitialPreset(entry.preset);
+ });
+ }
+
+ for (const modality of ['CT', 'MR']) {
+ it(`uses the native DICOM ${modality} modality`, async () => {
+ const directory = `default-native-${modality}`;
+ const folder = makeTempDir(directory);
+ const studyUid = newUid();
+ const seriesUid = newUid();
+ const resources = Array.from({ length: 8 }, (_, index) => {
+ const name = `slice-${index}.dcm`;
+ fs.writeFileSync(
+ path.join(folder, name),
+ buildSyntheticDicom({
+ studyUid,
+ seriesUid,
+ sopUid: newUid(),
+ modality,
+ instanceNumber: index + 1,
+ rows: 24,
+ cols: 24,
+ imageOrientationPatient: [1, 0, 0, 0, 1, 0],
+ imagePositionPatient: [0, 0, index * 3],
+ spacingBetweenSlices: 3,
+ pixelValue: index === 0 || index === 7 ? 0 : 200 + index * 50,
+ })
+ );
+ return { url: `tmp/${directory}/${name}`, name };
+ });
+ const manifest = `${directory}.json`;
+ const config = `${directory}-layout.json`;
+ await writeManifestToFile({ resources }, manifest);
+ await writeManifestToFile({ layouts: { default: layout } }, config);
+ await volViewPage.open(`?urls=[tmp/${config},tmp/${manifest}]`);
+ await expectInitialPreset(modality === 'CT' ? 'CT-AAA' : 'MR-Default');
+ });
+ }
+
+ it('honors an explicit manifest palette and mapping instead of the metadata default', async () => {
+ const name = writeVolume('manifest-MR.nrrd', 'ushort', 0, 1000, {
+ modality: 'MR',
+ });
+ const manifest = 'explicit-volume-preset.json';
+ await writeManifestToFile(
+ restoredVolumeManifest([{ url: `tmp/${name}` }], {
+ preset: 'Adaptive-Transparent',
+ mappingRange: [0, 1000],
+ cinematic: true,
+ }),
+ manifest
+ );
+ await volViewPage.open(`?urls=[tmp/${manifest}]`);
+ await expectInitialPreset('Adaptive-Transparent');
+ await expect(
+ $('[data-testid="volume-color-width"] [role="slider"]')
+ ).toHaveAttribute('aria-valuenow', '1000');
+ });
+});
diff --git a/tests/specs/volume-texture-updates.e2e.ts b/tests/specs/volume-texture-updates.e2e.ts
new file mode 100644
index 000000000..190fc9ecd
--- /dev/null
+++ b/tests/specs/volume-texture-updates.e2e.ts
@@ -0,0 +1,40 @@
+import { startVolumeTextureFixture } from '../fixtures/volume-texture/server.mjs';
+
+describe('Volume texture updates', () => {
+ let fixture: Awaited>;
+
+ before(async () => {
+ fixture = await startVolumeTextureFixture();
+ });
+
+ after(async () => {
+ await fixture?.close();
+ });
+
+ it('renders the complete first volume with queued updates and preserves later partial updates', async () => {
+ await browser.url(fixture.url);
+ const status = $('#render-status');
+ await browser.waitUntil(
+ async () => (await status.getText()) !== 'Preparing'
+ );
+ expect(await status.getText()).toBe('Rendered');
+ const initial = JSON.parse(await $('#render-pixels').getText());
+ for (const pixel of [initial.center, initial.edge]) {
+ expect(pixel[0]).toBeGreaterThan(240);
+ expect(pixel[1]).toBeLessThan(5);
+ expect(pixel[2]).toBeLessThan(5);
+ expect(pixel[3]).toBe(255);
+ }
+ expect(initial.outside).toEqual([0, 0, 0, 255]);
+
+ await $('#update-volume').click();
+ expect(await status.getText()).toBe('Rendered');
+ const updated = JSON.parse(await $('#render-pixels').getText());
+ expect(updated.center[0]).toBeLessThan(5);
+ expect(updated.center[1]).toBeLessThan(5);
+ expect(updated.center[2]).toBeGreaterThan(240);
+ expect(updated.center[3]).toBe(255);
+ expect(updated.edge).toEqual(initial.edge);
+ expect(updated.outside).toEqual(initial.outside);
+ });
+});
diff --git a/tests/specs/volume-thumbnail-layout.e2e.ts b/tests/specs/volume-thumbnail-layout.e2e.ts
index 017f12a09..0ba84d3b3 100644
--- a/tests/specs/volume-thumbnail-layout.e2e.ts
+++ b/tests/specs/volume-thumbnail-layout.e2e.ts
@@ -1,43 +1,32 @@
import { volViewPage } from '../pageobjects/volview.page';
import { openConfigAndDataset } from './configTestUtils';
+import { openVolumeRenderingControls } from './volumeRenderingTestUtils';
async function waitForPresetThumbnails() {
- await browser.waitUntil(
- () =>
- browser.execute(() =>
- Array.from(document.querySelectorAll('.v-expansion-panel-title')).some(
- (element) => element.textContent?.includes('Color Presets')
- )
- ),
- { timeoutMsg: 'Expected the color presets panel' }
- );
-
- const imageCount = await browser.execute(
- () => document.querySelectorAll('.thumbnail-container img').length
- );
- if (imageCount === 0) {
- await browser.execute(() => {
- const title = Array.from(
- document.querySelectorAll('.v-expansion-panel-title')
- ).find((element) => element.textContent?.includes('Color Presets'));
- title?.click();
- });
- }
+ await openVolumeRenderingControls();
+ const card = $('[data-testid="volume-preset-Adaptive-Balanced"]');
+ await card.scrollIntoView({ block: 'center', behavior: 'instant' });
await browser.waitUntil(
() =>
browser.execute(() => {
const images = Array.from(
document.querySelectorAll(
- '.thumbnail-container img'
+ '[data-testid^="volume-preset-"] img'
)
);
return (
images.length > 0 &&
- images.every((image) => image.src.startsWith('data:image/png'))
+ images.every(
+ (image) =>
+ image.src.startsWith('data:image/png') &&
+ image.complete &&
+ image.naturalWidth === 80 &&
+ image.naturalHeight === 80
+ )
);
}),
- { timeoutMsg: 'Expected every color preset thumbnail to finish rendering' }
+ { timeoutMsg: 'Expected decoded 80px preset thumbnails' }
);
}
diff --git a/tests/specs/volumeHistogramTestUtils.ts b/tests/specs/volumeHistogramTestUtils.ts
new file mode 100644
index 000000000..da3099b55
--- /dev/null
+++ b/tests/specs/volumeHistogramTestUtils.ts
@@ -0,0 +1,123 @@
+type HistogramPhase = 'ordinary' | 'adaptive';
+type HistogramGate = {
+ phase: HistogramPhase;
+ posts: HistogramPhase[];
+ held: Array<{
+ phase: HistogramPhase;
+ release: () => void;
+ }>;
+ restore: () => void;
+};
+type HistogramGateWindow = Window & { volumeHistogramGate: HistogramGate };
+
+/** Hold real worker responses so histogram completion order is deterministic. */
+export async function holdVolumeHistograms() {
+ await browser.execute(() => {
+ const original = window.Worker;
+ let active = true;
+ const gate: HistogramGate = {
+ phase: 'ordinary',
+ posts: [],
+ held: [],
+ restore() {
+ active = false;
+ window.Worker = original;
+ },
+ };
+ (window as unknown as HistogramGateWindow).volumeHistogramGate = gate;
+ window.Worker = new Proxy(original, {
+ construct(target, args) {
+ const worker = Reflect.construct(target, args) as Worker;
+ let phase: HistogramPhase | undefined;
+ let replaying = false;
+ worker.addEventListener('message', (event) => {
+ if (!active || !phase || replaying) return;
+ event.stopImmediatePropagation();
+ gate.held.push({
+ phase,
+ release() {
+ replaying = true;
+ worker.dispatchEvent(
+ new MessageEvent('message', { data: event.data })
+ );
+ replaying = false;
+ },
+ });
+ });
+ const post = worker.postMessage.bind(worker);
+ worker.postMessage = (
+ message,
+ transfers: Transferable[] | StructuredSerializeOptions = []
+ ) => {
+ const vtkHistogram =
+ Array.isArray(message) && message[1]?.numberOfBins;
+ const histogram = message?.path?.[0] === 'piecewiseHistogram';
+ if (vtkHistogram || histogram) {
+ phase = gate.phase;
+ gate.posts.push(phase);
+ }
+ if (Array.isArray(transfers)) post(message, transfers);
+ else post(message, transfers);
+ };
+ return worker;
+ },
+ });
+ });
+}
+
+export async function waitForHistogram(phase: HistogramPhase) {
+ await browser.waitUntil(
+ () =>
+ browser.execute((selectedPhase) => {
+ const gate = (window as unknown as HistogramGateWindow)
+ .volumeHistogramGate;
+ const posts = gate.posts.filter((entry) => entry === selectedPhase);
+ return (
+ posts.length > 0 &&
+ gate.held.filter((entry) => entry.phase === selectedPhase).length ===
+ posts.length
+ );
+ }, phase),
+ { timeoutMsg: `Expected the ${phase} histogram workers to finish` }
+ );
+}
+
+export async function hasHistogramRequest(phase: HistogramPhase) {
+ return browser.execute(
+ (selectedPhase) =>
+ (
+ window as unknown as HistogramGateWindow
+ ).volumeHistogramGate.posts.includes(selectedPhase),
+ phase
+ );
+}
+
+export async function releaseHistogram(phase: HistogramPhase) {
+ await browser.execute((selectedPhase) => {
+ (window as unknown as HistogramGateWindow).volumeHistogramGate.held
+ .filter((entry) => entry.phase === selectedPhase)
+ .forEach((entry) => entry.release());
+ }, phase);
+ await browser.executeAsync((done) => {
+ requestAnimationFrame(() => requestAnimationFrame(() => done()));
+ });
+}
+
+export async function captureOpacityEditor() {
+ return $('[data-testid="volume-opacity-editor"] canvas').execute((element) =>
+ (element as HTMLCanvasElement).toDataURL()
+ );
+}
+
+export async function selectAdaptiveHistogramPhase() {
+ await browser.execute(() => {
+ (window as unknown as HistogramGateWindow).volumeHistogramGate.phase =
+ 'adaptive';
+ });
+}
+
+export async function restoreHistogramWorkers() {
+ await browser.execute(() => {
+ (window as unknown as HistogramGateWindow).volumeHistogramGate.restore();
+ });
+}
diff --git a/tests/specs/volumeRenderingTestUtils.ts b/tests/specs/volumeRenderingTestUtils.ts
new file mode 100644
index 000000000..6be708bba
--- /dev/null
+++ b/tests/specs/volumeRenderingTestUtils.ts
@@ -0,0 +1,210 @@
+import { volViewPage } from '../pageobjects/volview.page';
+import { waitForModuleTransitions } from './utils';
+
+export function createVolumeScalars(
+ size: number,
+ encoding: 'uint8' | 'uint16' | 'int16' | 'float',
+ valueAt: (x: number, y: number, z: number) => number
+) {
+ const bytes = encoding === 'uint8' ? 1 : encoding === 'float' ? 4 : 2;
+ const buffer = Buffer.alloc(size ** 3 * bytes);
+ const write = {
+ uint8: buffer.writeUInt8.bind(buffer),
+ uint16: buffer.writeUInt16LE.bind(buffer),
+ int16: buffer.writeInt16LE.bind(buffer),
+ float: buffer.writeFloatLE.bind(buffer),
+ }[encoding];
+ let offset = 0;
+ for (let z = 0; z < size; z++) {
+ for (let y = 0; y < size; y++) {
+ for (let x = 0; x < size; x++) {
+ write(valueAt(x, y, z), offset);
+ offset += bytes;
+ }
+ }
+ }
+ return buffer;
+}
+
+export async function openVolumeRenderingControls() {
+ await volViewPage.renderingModuleTab.waitForClickable();
+ await volViewPage.renderingModuleTab.click();
+ await waitForModuleTransitions();
+ const volume = $('[data-testid="volume-rendering-toggle"]');
+ if ((await volume.getAttribute('aria-expanded')) !== 'true')
+ await volume.click();
+ const presets = $('.v-expansion-panel-title*=Color Presets');
+ await presets.scrollIntoView({ block: 'center', behavior: 'instant' });
+ if ((await presets.getAttribute('aria-expanded')) !== 'true')
+ await presets.click();
+ await waitForModuleTransitions();
+}
+
+export type Appearance = {
+ data: number[];
+ visible: number;
+ colored: number;
+ background: number;
+};
+
+export async function appearance(): Promise {
+ const captured = (await $('[data-testid~="vtk-volume-view"] canvas').execute(
+ (source) => {
+ const canvas = source as HTMLCanvasElement;
+ const copy = document.createElement('canvas');
+ copy.width = 96;
+ copy.height = 96;
+ const ctx = copy.getContext('2d')!;
+ // Exclude the orientation marker and view annotations at the edges.
+ ctx.drawImage(
+ canvas,
+ canvas.width * 0.15,
+ canvas.height * 0.15,
+ canvas.width * 0.7,
+ canvas.height * 0.7,
+ 0,
+ 0,
+ copy.width,
+ copy.height
+ );
+ const data = ctx.getImageData(0, 0, 96, 96).data;
+ let visible = 0;
+ let colored = 0;
+ let background = 0;
+ for (let index = 0; index < data.length; index += 4) {
+ const max = Math.max(data[index], data[index + 1], data[index + 2]);
+ const min = Math.min(data[index], data[index + 1], data[index + 2]);
+ if (max > 25) visible++;
+ if (max - min > 15) colored++;
+ if (max < 5) background++;
+ }
+ // Transfer bytes as one protocol value instead of one value per channel.
+ return {
+ encoded: btoa(String.fromCharCode(...data)),
+ visible,
+ colored,
+ background,
+ };
+ }
+ )) as Omit & { encoded: string };
+ const { encoded, ...metrics } = captured;
+ return { data: Array.from(Buffer.from(encoded, 'base64')), ...metrics };
+}
+
+export function pixelDifference(a: Appearance, b: Appearance, blockSize = 1) {
+ let difference = 0;
+ let samples = 0;
+ for (let y = 0; y < 96; y += blockSize) {
+ for (let x = 0; x < 96; x += blockSize) {
+ const colors = [a, b].map(({ data }) => {
+ const rgb = [0, 0, 0];
+ for (let dy = 0; dy < blockSize; dy++) {
+ for (let dx = 0; dx < blockSize; dx++) {
+ const index = ((y + dy) * 96 + x + dx) * 4;
+ for (let channel = 0; channel < 3; channel++)
+ rgb[channel] += data[index + channel] / blockSize ** 2;
+ }
+ }
+ return rgb;
+ });
+ if (Math.max(...colors.flat()) <= 25) continue;
+ for (let channel = 0; channel < 3; channel++)
+ difference += Math.abs(colors[0][channel] - colors[1][channel]);
+ samples += 3;
+ }
+ }
+ return difference / Math.max(1, samples);
+}
+
+export async function visibleAppearance() {
+ let result: Appearance;
+ await browser.waitUntil(
+ async () => {
+ result = await appearance();
+ return result.visible > 250 && result.colored < 30;
+ },
+ {
+ timeoutMsg: 'Expected the adaptive preset to render the generated layers',
+ }
+ );
+ expect(result!.background).toBeGreaterThan(96 * 96 * 0.4);
+ return result!;
+}
+
+export async function colorShift() {
+ const slider = $('[data-testid="volume-color-shift"] [role="slider"]');
+ await slider.scrollIntoView({ block: 'center', behavior: 'instant' });
+ await slider.waitForDisplayed();
+ return slider;
+}
+
+export function restoredVolumeManifest(
+ resources: { url: string; name?: string }[],
+ {
+ preset,
+ mappingRange,
+ cinematic = false,
+ }: {
+ preset: string;
+ mappingRange: [number, number];
+ cinematic?: boolean;
+ }
+) {
+ return {
+ version: '7.0.0',
+ datasets: [{ id: 'volume-data', dataSourceId: 0 }],
+ dataSources: [
+ { id: 0, type: 'collection', sources: resources.map((_, i) => i + 1) },
+ ...resources.map(({ url, name }, index) => ({
+ id: index + 1,
+ type: 'uri',
+ uri: `/${url}`,
+ name,
+ })),
+ ],
+ primarySelection: 'volume-data',
+ activeView: 'volume',
+ layoutSlots: ['volume'],
+ layout: { direction: 'row', items: [{ type: 'slot', slotIndex: 0 }] },
+ viewByID: {
+ volume: {
+ id: 'volume',
+ name: 'Volume',
+ type: '3D',
+ dataID: 'volume-data',
+ options: { viewDirection: 'Superior', viewUp: 'Anterior' },
+ config: {
+ 'volume-data': {
+ volumeColorConfig: {
+ colorBy: { arrayName: 'Scalars', location: 'pointData' },
+ transferFunction: {
+ preset,
+ mappingRange,
+ },
+ opacityFunction: {
+ mode: 0,
+ mappingRange,
+ gaussians: [
+ { position: 1, height: 0.4, width: 1, xBias: 0, yBias: 1 },
+ ],
+ },
+ cvr: {
+ enabled: cinematic,
+ lightFollowsCamera: true,
+ volumeQuality: 1,
+ useVolumetricScatteringBlending: false,
+ volumetricScatteringBlending: 0.5,
+ useLocalAmbientOcclusion: false,
+ laoKernelSize: 15,
+ laoKernelRadius: 5,
+ ambient: 0.6,
+ diffuse: 0.4,
+ specular: 0.1,
+ },
+ },
+ },
+ },
+ },
+ },
+ };
+}
diff --git a/wdio.chrome.conf.ts b/wdio.chrome.conf.ts
index 0b98d3bfe..3340adeea 100644
--- a/wdio.chrome.conf.ts
+++ b/wdio.chrome.conf.ts
@@ -29,6 +29,7 @@ export const config = {
capabilities: [
{
browserName: 'chrome',
+ browserVersion: '157.0.8103.26',
// this overrides the default chrome download directory with our temporary one
'goog:chromeOptions': {
args: chromeArgs,
diff --git a/wdio.shared.conf.ts b/wdio.shared.conf.ts
index 921cf2bff..4178837b9 100644
--- a/wdio.shared.conf.ts
+++ b/wdio.shared.conf.ts
@@ -246,9 +246,12 @@ export const config: Options.Testrunner = {
if (!(console.error as any).__patched) {
const originalError = console.error;
console.error = (...args: any[]) => {
- const stringArgs = args.map((arg) =>
- arg instanceof Error ? `${arg.name}: ${arg.message}` : arg
- );
+ const stringArgs = args.map((arg) => {
+ const error = arg instanceof ErrorEvent ? arg.error : arg;
+ return error instanceof Error
+ ? (error.stack ?? `${error.name}: ${error.message}`)
+ : arg;
+ });
originalError.apply(console, stringArgs);
};
(console.error as any).__patched = true;